Compare commits
85 Commits
main
..
aaba6f152e
| Author | SHA1 | Date | |
|---|---|---|---|
| aaba6f152e | |||
| f3d2d50f9a | |||
| 16162095f7 | |||
| 6629d9d0a3 | |||
| 3c6f061709 | |||
| d7d14e077e | |||
| 3b3292b934 | |||
| f7916a42e5 | |||
| f725de28bd | |||
| 97113964c8 | |||
| b429695cba | |||
| d6bf7352a0 | |||
| d800ed1f9f | |||
| 4855db3fc8 | |||
| 6cd0a8caca | |||
| 44dc89a0dd | |||
| 823ac3c25d | |||
| 5ecea182bc | |||
| cd843465b2 | |||
| ef0f91e683 | |||
| 9c1e5ae293 | |||
| 00b3365f5a | |||
| d5babf5f3f | |||
| 4b757b8250 | |||
| def8d7a204 | |||
| 25c6fdbd6c | |||
| 47202e6848 | |||
| 1b2880739a | |||
| 188e457e7f | |||
| 13756adc9d | |||
| f026582008 | |||
| b3eb86d5ea | |||
| 301d086cb6 | |||
| 023384b14b | |||
| d315f0ffe2 | |||
| f0385c9c8c | |||
| 339f91d014 | |||
| 0d231ec467 | |||
| be1fc1df48 | |||
| dd874bae30 | |||
| 45aa741d9c | |||
| 49c398db5e | |||
| 4d748dd938 | |||
| b40c522749 | |||
| d467e27fd8 | |||
| 5ef017c54e | |||
| 03046152fd | |||
| d75daf72d6 | |||
| d39100b56d | |||
| 2e4c5801b6 | |||
| 870b2a2da3 | |||
| 4448567368 | |||
| fae0f607aa | |||
| e4630c4cb6 | |||
| e72e323313 | |||
| 2b836b3e1e | |||
| 598b2f48d6 | |||
| 3f22d7b1ab | |||
| 5ce5ea1687 | |||
| f8741b544e | |||
| c316a78511 | |||
| 68ae9acb27 | |||
| 13c7d8aa4e | |||
| fb5fc9f1ae | |||
| 5765ea48b0 | |||
| 55bd3f41f8 | |||
| 938597b766 | |||
| fe0e3faa22 | |||
| d0f26118ff | |||
| c98d9a2c6d | |||
| 1293ebae68 | |||
| b29c7ffa7a | |||
| 056e9fb651 | |||
| fa5a2419d8 | |||
| 7b34153235 | |||
| 04792bd3f7 | |||
| bb31118792 | |||
| 5027767ca2 | |||
| fd930757f9 | |||
| eb6ed11cff | |||
| 931bcf3fb9 | |||
| e0bd9b4194 | |||
| 0f9fe4a81d | |||
| 6869504119 | |||
| 3f9f9f7665 |
+6
-5
@@ -1,18 +1,19 @@
|
|||||||
# Cargo build acceleration.
|
# Cargo build acceleration.
|
||||||
#
|
#
|
||||||
# The biggest single speedup is the `mold` linker (~3-5x faster link
|
# The biggest single speedup is the `mold` linker (~3-5x faster link
|
||||||
# time on debug builds). It isn't installed by default on this host
|
# time on debug builds). `build-and-run.sh` enables mold/lld automatically
|
||||||
# (no apt-get, see build-and-run.sh notes), so the mold block below is
|
# when available, so the block below is kept as an optional manual setup for
|
||||||
# commented out — uncomment it after running:
|
# direct `cargo build` / `cargo run` usage:
|
||||||
#
|
#
|
||||||
# sudo apt-get install -y mold
|
# sudo apt-get install -y mold
|
||||||
#
|
#
|
||||||
# (or build it from source: https://github.com/rui314/mold)
|
# (or build it from source: https://github.com/rui314/mold)
|
||||||
#
|
#
|
||||||
# Without mold, the [profile.dev] tweaks in Cargo.toml still give a
|
# Without mold/lld, the [profile.dev] tweaks in Cargo.toml still give a
|
||||||
# meaningful speedup (line-tables-only debuginfo + bumped codegen-units).
|
# meaningful speedup (line-tables-only debuginfo + bumped codegen-units).
|
||||||
|
|
||||||
# Uncomment once mold is on PATH:
|
# Optional: uncomment once mold is on PATH if you want direct cargo commands
|
||||||
|
# to use it without going through build-and-run.sh.
|
||||||
# [target.x86_64-unknown-linux-gnu]
|
# [target.x86_64-unknown-linux-gnu]
|
||||||
# linker = "clang"
|
# linker = "clang"
|
||||||
# rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
# rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
||||||
|
|||||||
@@ -2,3 +2,19 @@ comfy_source/venv
|
|||||||
comfy_source/.git
|
comfy_source/.git
|
||||||
comfy_source/__pycache__
|
comfy_source/__pycache__
|
||||||
**/__pycache__
|
**/__pycache__
|
||||||
|
target/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
release/
|
||||||
|
data/
|
||||||
|
logs/
|
||||||
|
tmp/
|
||||||
|
.agents/
|
||||||
|
.cache/
|
||||||
|
.codex/
|
||||||
|
.claude/
|
||||||
|
.comfyui/
|
||||||
|
boosapi
|
||||||
|
bossai
|
||||||
|
boosapi.db
|
||||||
|
docker/*.tar
|
||||||
|
|||||||
+7
-1
@@ -1,13 +1,15 @@
|
|||||||
/target
|
/target
|
||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
Cargo.lock
|
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
data/
|
data/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
website/.next/
|
||||||
|
website/out/
|
||||||
dist/
|
dist/
|
||||||
release/
|
release/
|
||||||
*.log
|
*.log
|
||||||
|
*.tsbuildinfo
|
||||||
logs/
|
logs/
|
||||||
tmp/
|
tmp/
|
||||||
!tmp/.gitkeep
|
!tmp/.gitkeep
|
||||||
@@ -19,5 +21,9 @@ docker/.env
|
|||||||
.comfyui/
|
.comfyui/
|
||||||
.claude/
|
.claude/
|
||||||
.codex/
|
.codex/
|
||||||
|
.agents/
|
||||||
|
.cache/
|
||||||
openspec/
|
openspec/
|
||||||
boosapi
|
boosapi
|
||||||
|
bossai
|
||||||
|
docker/*.tar
|
||||||
|
|||||||
Generated
+4174
File diff suppressed because it is too large
Load Diff
@@ -81,6 +81,8 @@ boosapi
|
|||||||
config.toml
|
config.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
部署目录只需要运行产物和配置。仓库里的 `.agents/`、`.cache/`、本地二进制、Docker tar 包、运行日志等均为本地缓存或构建产物,不应进入发布包。`Cargo.lock` 应保留在仓库中,用于保证生产构建依赖可复现。
|
||||||
|
|
||||||
启动前检查配置:
|
启动前检查配置:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
# BossAI
|
# BoosAPI
|
||||||
|
|
||||||
**AI 聚合网关 + Workflow Studio — 将分散的中转站整合为一个统一入口**
|
**AI 聚合网关 + Workflow Studio — 将分散的中转站整合为一个统一入口**
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
|
|
||||||
现在 AI 生态里出现了越来越多基于 New API / One API 系列的聚合中转站,要管理多个站点的余额、模型列表和 API 密钥,往往既分散又费时。
|
现在 AI 生态里出现了越来越多基于 New API / One API 系列的聚合中转站,要管理多个站点的余额、模型列表和 API 密钥,往往既分散又费时。
|
||||||
|
|
||||||
**BossAI** 作为中转站之上的**元聚合层**,把多个站点统一到 **一个入口**——下游所有工具即可无感接入全部模型。
|
**BoosAPI** 作为中转站之上的**元聚合层**,把多个站点统一到 **一个入口**——下游所有工具即可无感接入全部模型。
|
||||||
|
|
||||||
支持的上游范围:
|
支持的上游范围:
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
- **官方预设**:阿里云、智谱、豆包 Coding Plan、DeepSeek、Moonshot、MiniMax、ModelScope
|
- **官方预设**:阿里云、智谱、豆包 Coding Plan、DeepSeek、Moonshot、MiniMax、ModelScope
|
||||||
- **OAuth 连接**:Codex、Claude、Gemini CLI、Antigravity
|
- **OAuth 连接**:Codex、Claude、Gemini CLI、Antigravity
|
||||||
|
|
||||||
| 痛点 | BossAI 怎么解决 |
|
| 痛点 | BoosAPI 怎么解决 |
|
||||||
|------|-----------------|
|
|------|-----------------|
|
||||||
| 🔑 每个站点一个 Key,下游配置一堆 | **统一代理入口 + 多下游 Key 策略**,模型自动聚合 |
|
| 🔑 每个站点一个 Key,下游配置一堆 | **统一代理入口 + 多下游 Key 策略**,模型自动聚合 |
|
||||||
| 💸 不知道哪个站点最便宜 | **智能路由** 按成本、余额、使用率选最优通道 |
|
| 💸 不知道哪个站点最便宜 | **智能路由** 按成本、余额、使用率选最优通道 |
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
| 📊 余额分散各处 | **集中看板** 一目了然,余额不足自动告警 |
|
| 📊 余额分散各处 | **集中看板** 一目了然,余额不足自动告警 |
|
||||||
| ✅ 每天签到领额度 | **自动签到** 定时执行,奖励自动追踪 |
|
| ✅ 每天签到领额度 | **自动签到** 定时执行,奖励自动追踪 |
|
||||||
| 🤷 不知道各站有什么模型 | **自动模型发现**,上游新增模型零配置 |
|
| 🤷 不知道各站有什么模型 | **自动模型发现**,上游新增模型零配置 |
|
||||||
| 🎨 想编排图像/视频流程但不想跑本地模型 | **Workflow Studio + 对话助手**,直接复用 BossAI 模型路由 |
|
| 🎨 想编排图像/视频流程但不想跑本地模型 | **Workflow Studio + 对话助手**,直接复用 BoosAPI 模型路由 |
|
||||||
| 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 |
|
| 👥 多人协作管理中转站 | **组织管理**,成员邀请,角色权限控制 |
|
||||||
| 📋 不同团队需要不同配额 | **套餐系统**,Free/Pro/Enterprise 三级限制 |
|
| 📋 不同团队需要不同配额 | **套餐系统**,Free/Pro/Enterprise 三级限制 |
|
||||||
|
|
||||||
@@ -156,13 +156,13 @@
|
|||||||
|
|
||||||
## 🖥️ 硬件配置建议
|
## 🖥️ 硬件配置建议
|
||||||
|
|
||||||
BossAI 本身是 Rust 网关与管理后台,不在本地跑大模型推理时不需要 GPU。实际硬件主要取决于你是否把图像/视频生成放在本机 ComfyUI 上执行。
|
BoosAPI 本身是 Rust 网关与管理后台,不在本地跑大模型推理时不需要 GPU。实际硬件主要取决于你是否把图像/视频生成放在本机 ComfyUI 上执行。
|
||||||
|
|
||||||
| 使用模式 | CPU | 内存 | 磁盘 | GPU |
|
| 使用模式 | CPU | 内存 | 磁盘 | GPU |
|
||||||
|----------|-----|------|------|-----|
|
|----------|-----|------|------|-----|
|
||||||
| 仅网关 / 管理后台 / `/v1/*` 代理 | 2 核起步,4 核推荐 | 2GB 起步,4GB 推荐 | 10GB+ | 不需要 |
|
| 仅网关 / 管理后台 / `/v1/*` 代理 | 2 核起步,4 核推荐 | 2GB 起步,4GB 推荐 | 10GB+ | 不需要 |
|
||||||
| BossAI + Workflow UI + 远程模型 API / Comfy Cloud / RunningHub | 4 核推荐 | 4GB 起步,8GB 推荐 | 20-50GB | 不需要 |
|
| BoosAPI + Workflow UI + 远程模型 API / Comfy Cloud / RunningHub | 4 核推荐 | 4GB 起步,8GB 推荐 | 20-50GB | 不需要 |
|
||||||
| 外部独立 ComfyUI 代理 | 4 核推荐 | 8GB 推荐 | 50GB+ | BossAI 机器不需要;ComfyUI 机器按模型需求配置 |
|
| 外部独立 ComfyUI 代理 | 4 核推荐 | 8GB 推荐 | 50GB+ | BoosAPI 机器不需要;ComfyUI 机器按模型需求配置 |
|
||||||
| 本机 Docker / 本地 ComfyUI 轻量文生图 | 4-8 核 | 16GB+ | 100GB+ | NVIDIA 8GB VRAM 勉强,12GB+ 推荐 |
|
| 本机 Docker / 本地 ComfyUI 轻量文生图 | 4-8 核 | 16GB+ | 100GB+ | NVIDIA 8GB VRAM 勉强,12GB+ 推荐 |
|
||||||
| 本机视频生成 / 数字人 / 大工作流 | 8 核+ | 32GB 起步,64GB 推荐 | 200GB-1TB | NVIDIA 24GB VRAM 起步,48GB+ 更稳 |
|
| 本机视频生成 / 数字人 / 大工作流 | 8 核+ | 32GB 起步,64GB 推荐 | 200GB-1TB | NVIDIA 24GB VRAM 起步,48GB+ 更稳 |
|
||||||
|
|
||||||
@@ -175,7 +175,7 @@ BossAI 本身是 Rust 网关与管理后台,不在本地跑大模型推理时
|
|||||||
无需 GPU
|
无需 GPU
|
||||||
```
|
```
|
||||||
|
|
||||||
如果图像、视频、语音能力走 BossAI 上游路由、Comfy Cloud、RunningHub 或其他远程 API,本机只负责网关、数据库、Workflow UI 代理、日志和 Workspace 归档,上面这档配置通常足够。只有把 ComfyUI 模型和视频生成放在本机运行时,才需要高显存 NVIDIA GPU。
|
如果图像、视频、语音能力走 BoosAPI 上游路由、Comfy Cloud、RunningHub 或其他远程 API,本机只负责网关、数据库、Workflow UI 代理、日志和 Workspace 归档,上面这档配置通常足够。只有把 ComfyUI 模型和视频生成放在本机运行时,才需要高显存 NVIDIA GPU。
|
||||||
|
|
||||||
## 🎛️ Workflow API 后端模式
|
## 🎛️ Workflow API 后端模式
|
||||||
|
|
||||||
@@ -218,9 +218,9 @@ base_url = "http://127.0.0.1:8188"
|
|||||||
backend = "boosapi"
|
backend = "boosapi"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 将 Comfy 视频节点包装为 BossAI `/v1/videos`
|
### 将 Comfy 视频节点包装为 BoosAPI `/v1/videos`
|
||||||
|
|
||||||
ComfyUI/Comfy Cloud 没有 OpenAI 兼容 `/v1/videos`,但 BossAI 可以把一个已保存的视频工作流模板包装成统一视频模型:
|
ComfyUI/Comfy Cloud 没有 OpenAI 兼容 `/v1/videos`,但 BoosAPI 可以把一个已保存的视频工作流模板包装成统一视频模型:
|
||||||
|
|
||||||
1. 在工作流页面保存一个真实 Comfy 视频模板,模板需要能被 `POST /prompt` 执行。
|
1. 在工作流页面保存一个真实 Comfy 视频模板,模板需要能被 `POST /prompt` 执行。
|
||||||
2. 模板图里最好保存节点映射:
|
2. 模板图里最好保存节点映射:
|
||||||
@@ -236,7 +236,7 @@ ComfyUI/Comfy Cloud 没有 OpenAI 兼容 `/v1/videos`,但 BossAI 可以把一
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://127.0.0.1:4000/v1/videos \
|
curl http://127.0.0.1:4000/v1/videos \
|
||||||
-H "Authorization: Bearer <BossAI downstream key>" \
|
-H "Authorization: Bearer <BoosAPI downstream key>" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"model": "comfy-wan-video",
|
"model": "comfy-wan-video",
|
||||||
@@ -325,7 +325,7 @@ docker compose up -d
|
|||||||
│ ├── billing/ # 计费逻辑
|
│ ├── billing/ # 计费逻辑
|
||||||
│ └── notification/ # 通知推送
|
│ └── notification/ # 通知推送
|
||||||
├── migrations/ # SQLite 迁移
|
├── migrations/ # SQLite 迁移
|
||||||
├── src/web/ # 前端源码(React 19 + Vite)
|
├── src/web/ # 管理台前端源码(React + Vite)
|
||||||
│ ├── authSession.ts # 统一会话工具:commitSession/clearSession/getSessionToken(按 'admin' | 'user' 分桶)
|
│ ├── authSession.ts # 统一会话工具:commitSession/clearSession/getSessionToken(按 'admin' | 'user' 分桶)
|
||||||
│ ├── loginError.ts # 登录错误归一化(resolveLoginErrorMessage)
|
│ ├── loginError.ts # 登录错误归一化(resolveLoginErrorMessage)
|
||||||
│ ├── pages/
|
│ ├── pages/
|
||||||
@@ -334,6 +334,7 @@ docker compose up -d
|
|||||||
│ │ ├── Workflows.tsx # Workflow Studio 入口
|
│ │ ├── Workflows.tsx # Workflow Studio 入口
|
||||||
│ │ └── ...
|
│ │ └── ...
|
||||||
│ └── components/
|
│ └── components/
|
||||||
|
├── website/ # 独立官网(Next.js 16 App Router + React 19)
|
||||||
└── dist/web/ # 前端构建产物(由 Rust serve)
|
└── dist/web/ # 前端构建产物(由 Rust serve)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -391,7 +392,8 @@ BOOSAPI_COMFYUI__API_KEY=your-workflow-backend-key
|
|||||||
| 协议转换 | OpenAI / Claude / Gemini 全兼容 |
|
| 协议转换 | OpenAI / Claude / Gemini 全兼容 |
|
||||||
| Workflow Studio | 内嵌 ComfyUI 前端 + Cloud / RunningHub / External / BoosAPI 多后端 |
|
| Workflow Studio | 内嵌 ComfyUI 前端 + Cloud / RunningHub / External / BoosAPI 多后端 |
|
||||||
| 视频助手 | SSE 流式 + gpt-image-2 + TTS 自动生成 |
|
| 视频助手 | SSE 流式 + gpt-image-2 + TTS 自动生成 |
|
||||||
| 前端框架 | React 19 + Vite(仅构建工具) |
|
| 管理台前端 | React + Vite(仅构建工具) |
|
||||||
|
| 独立官网 | Next.js 16 App Router + React 19 + TypeScript |
|
||||||
| 样式 | Tailwind CSS v4 |
|
| 样式 | Tailwind CSS v4 |
|
||||||
| 数据可视化 | VChart |
|
| 数据可视化 | VChart |
|
||||||
| 容器化 | Docker + Docker Compose + K3s |
|
| 容器化 | Docker + Docker Compose + K3s |
|
||||||
@@ -399,6 +401,8 @@ BOOSAPI_COMFYUI__API_KEY=your-workflow-backend-key
|
|||||||
|
|
||||||
> **运行时零 Node.js 依赖**,Node.js 仅用作前端构建工具。生产环境只需 Rust 编译后的单二进制文件。
|
> **运行时零 Node.js 依赖**,Node.js 仅用作前端构建工具。生产环境只需 Rust 编译后的单二进制文件。
|
||||||
|
|
||||||
|
> 独立官网位于 `website/`。默认构建会把官网静态导出到 `dist/website/`,Rust 后端会让根路径 `/` 显示官网;管理台仍由 `dist/web/` 提供,管理员入口是 `/boos`,登录后进入 `/dashboard`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛠️ 本地开发
|
## 🛠️ 本地开发
|
||||||
@@ -406,11 +410,20 @@ BOOSAPI_COMFYUI__API_KEY=your-workflow-backend-key
|
|||||||
### 后端(Rust)
|
### 后端(Rust)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 编译并运行
|
# 最快验证:只检查 Rust 后端,不构建前端、不启动服务
|
||||||
cargo run --release
|
./build-and-run.sh --check
|
||||||
|
|
||||||
# 仅检查编译
|
# 日常启动:复用前端缓存,后端 dev profile 编译后启动
|
||||||
cargo check
|
./build-and-run.sh
|
||||||
|
|
||||||
|
# 只重启/编译后端,不碰 Vite/Next
|
||||||
|
./build-and-run.sh --backend-only
|
||||||
|
|
||||||
|
# 接近生产性能但比 release 快
|
||||||
|
./build-and-run.sh --fast-release
|
||||||
|
|
||||||
|
# 完整生产包:构建并嵌入前端/Workflow UI,只输出 boosapi + config.toml
|
||||||
|
./build-and-run.sh --production
|
||||||
|
|
||||||
# 运行测试
|
# 运行测试
|
||||||
cargo test
|
cargo test
|
||||||
@@ -421,9 +434,15 @@ export BOOSAPI_SERVER__PORT=4000
|
|||||||
export BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token
|
export BOOSAPI_AUTH__AUTH_TOKEN=your-admin-token
|
||||||
export BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token
|
export BOOSAPI_AUTH__PROXY_TOKEN=sk-your-proxy-token
|
||||||
export BOOSAPI_UPSTREAM_BASE=https://api.boos.lat/v1
|
export BOOSAPI_UPSTREAM_BASE=https://api.boos.lat/v1
|
||||||
export BOOSAPI_UPSTREAM_KEY=sk-boos4721
|
export BOOSAPI_UPSTREAM_KEY=sk-your-upstream-key
|
||||||
```
|
```
|
||||||
|
|
||||||
|
构建加速说明:
|
||||||
|
|
||||||
|
- 脚本会在 Linux 下自动使用 `mold` / `lld` 快速链接器;可用 `BOOSAPI_FAST_LINKER=off` 关闭。
|
||||||
|
- `BOOSAPI_CARGO_JOBS=8 ./build-and-run.sh --check` 可限制 Cargo 并发,避免把开发机打满。
|
||||||
|
- 生产性能验证优先用 `--fast-release`,真正发布再用 `--production` 或 `--release`。
|
||||||
|
|
||||||
### 前端(React SPA)
|
### 前端(React SPA)
|
||||||
|
|
||||||
前端使用 Vite 构建,产物由 Rust 后端静态 serve:
|
前端使用 Vite 构建,产物由 Rust 后端静态 serve:
|
||||||
@@ -456,6 +475,24 @@ 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`。
|
||||||
|
|
||||||
|
### 独立官网(Next.js)
|
||||||
|
|
||||||
|
官网已经从管理台中隔离,使用 Next.js 16 App Router、React 19、TypeScript、Tailwind CSS 4 和自定义 SVG/CSS motion system:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 首次安装
|
||||||
|
npm --prefix website install
|
||||||
|
|
||||||
|
# 本地开发官网
|
||||||
|
npm run website:dev
|
||||||
|
|
||||||
|
# 类型检查与生产构建
|
||||||
|
npm run website:typecheck
|
||||||
|
npm run website:build
|
||||||
|
```
|
||||||
|
|
||||||
|
`./build-and-run.sh` 会同时构建管理台 `dist/web/` 和官网 `dist/website/`。打开主域名 `/` 显示官网;后台路径 `/boos`、`/dashboard`、`/user/*`、`/api/*`、`/v1/*` 等仍由 BoosAPI Rust 服务处理。
|
||||||
|
|
||||||
### 生产单二进制构建
|
### 生产单二进制构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -469,6 +506,13 @@ boosapi
|
|||||||
config.toml
|
config.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
仓库卫生说明:
|
||||||
|
|
||||||
|
- `Cargo.lock` 应随代码提交,保证 Rust 应用在 CI、生产构建和本地排障时依赖版本一致。
|
||||||
|
- `boosapi.db`、`config.toml` 是本地运行态文件;实际部署时不要提交带真实密钥、Comfy Cloud Key 或生产数据的改动。
|
||||||
|
- `.agents/`、`.cache/`、`boosapi` / `bossai` 本地二进制、`docker/*.tar` 等构建缓存与大文件已加入忽略规则。
|
||||||
|
- 旧的 `boosapi-comfyui-multi-tenant-*.tar` 不再作为当前 Workflow 路线的必需产物,生产包应优先使用单二进制 + 配置文件。
|
||||||
|
|
||||||
### Docker 部署
|
### Docker 部署
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+226
-14
@@ -17,13 +17,17 @@ fail() { echo "[$(date '+%H:%M:%S')] ✗ $*" >&2; exit 1; }
|
|||||||
BUILD_PROFILE="${BOOSAPI_BUILD_PROFILE:-dev}"
|
BUILD_PROFILE="${BOOSAPI_BUILD_PROFILE:-dev}"
|
||||||
FRONTEND_ONLY=0
|
FRONTEND_ONLY=0
|
||||||
BACKEND_ONLY=0
|
BACKEND_ONLY=0
|
||||||
|
CHECK_ONLY=0
|
||||||
NO_RUN=0
|
NO_RUN=0
|
||||||
FORCE_FRONTEND=0
|
FORCE_FRONTEND=0
|
||||||
SKIP_FRONTEND=0
|
SKIP_FRONTEND=0
|
||||||
PRODUCTION_BUNDLE=0
|
PRODUCTION_BUNDLE=0
|
||||||
PRODUCTION_DIR="${BOOSAPI_PRODUCTION_DIR:-$ROOT_DIR/dist/production}"
|
PRODUCTION_DIR="${BOOSAPI_PRODUCTION_DIR:-$ROOT_DIR/dist/production}"
|
||||||
CARGO_FEATURES="${BOOSAPI_CARGO_FEATURES:-}"
|
CARGO_FEATURES="${BOOSAPI_CARGO_FEATURES:-}"
|
||||||
|
CARGO_JOBS="${BOOSAPI_CARGO_JOBS:-}"
|
||||||
|
FAST_LINKER="${BOOSAPI_FAST_LINKER:-auto}"
|
||||||
FRONTEND_PM="${BOOSAPI_FRONTEND_PM:-auto}"
|
FRONTEND_PM="${BOOSAPI_FRONTEND_PM:-auto}"
|
||||||
|
BUILD_TIMINGS=()
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
@@ -38,6 +42,8 @@ usage() {
|
|||||||
--prod 同 --production
|
--prod 同 --production
|
||||||
--production-dir <dir>
|
--production-dir <dir>
|
||||||
指定生产产物目录(默认:dist/production)
|
指定生产产物目录(默认:dist/production)
|
||||||
|
--check 只执行 cargo check,不构建前端、不启动服务(最快验证)
|
||||||
|
--backend-check 同 --check
|
||||||
--frontend-only 只构建前端
|
--frontend-only 只构建前端
|
||||||
--backend-only 只构建后端
|
--backend-only 只构建后端
|
||||||
--force-frontend 强制重建前端,忽略缓存
|
--force-frontend 强制重建前端,忽略缓存
|
||||||
@@ -46,6 +52,8 @@ usage() {
|
|||||||
--no-run 构建后不启动服务
|
--no-run 构建后不启动服务
|
||||||
BOOSAPI_BUILD_PROFILE 可设置默认 profile(dev / release / fast-release)
|
BOOSAPI_BUILD_PROFILE 可设置默认 profile(dev / release / fast-release)
|
||||||
BOOSAPI_CARGO_FEATURES 可覆盖后端 Cargo features(默认:空)
|
BOOSAPI_CARGO_FEATURES 可覆盖后端 Cargo features(默认:空)
|
||||||
|
BOOSAPI_CARGO_JOBS 可设置 cargo 并发任务数,例如 8
|
||||||
|
BOOSAPI_FAST_LINKER 可设置 auto / off / mold / lld(默认:auto,Linux 下优先 mold)
|
||||||
BOOSAPI_FRONTEND_PM 可设置主前端构建器:auto / npm / bun(默认:auto)
|
BOOSAPI_FRONTEND_PM 可设置主前端构建器:auto / npm / bun(默认:auto)
|
||||||
BOOSAPI_PRODUCTION_DIR 可设置 --production 默认产物目录
|
BOOSAPI_PRODUCTION_DIR 可设置 --production 默认产物目录
|
||||||
-h, --help 显示帮助
|
-h, --help 显示帮助
|
||||||
@@ -53,6 +61,7 @@ usage() {
|
|||||||
示例:
|
示例:
|
||||||
./build-and-run.sh --release
|
./build-and-run.sh --release
|
||||||
./build-and-run.sh --fast-release
|
./build-and-run.sh --fast-release
|
||||||
|
./build-and-run.sh --check
|
||||||
./build-and-run.sh --bun
|
./build-and-run.sh --bun
|
||||||
./build-and-run.sh --production
|
./build-and-run.sh --production
|
||||||
./build-and-run.sh --production --production-dir /opt/boosapi-release
|
./build-and-run.sh --production --production-dir /opt/boosapi-release
|
||||||
@@ -75,6 +84,11 @@ while [ $# -gt 0 ]; do
|
|||||||
[ $# -gt 0 ] || fail "--production-dir 需要目录参数"
|
[ $# -gt 0 ] || fail "--production-dir 需要目录参数"
|
||||||
PRODUCTION_DIR="$1"
|
PRODUCTION_DIR="$1"
|
||||||
;;
|
;;
|
||||||
|
--check|--backend-check)
|
||||||
|
CHECK_ONLY=1
|
||||||
|
BACKEND_ONLY=1
|
||||||
|
NO_RUN=1
|
||||||
|
;;
|
||||||
--profile)
|
--profile)
|
||||||
shift
|
shift
|
||||||
[ $# -gt 0 ] || fail "--profile 需要参数:dev / release / fast-release"
|
[ $# -gt 0 ] || fail "--profile 需要参数:dev / release / fast-release"
|
||||||
@@ -105,12 +119,114 @@ if [ "$PRODUCTION_BUNDLE" -eq 1 ] && [ "$BACKEND_ONLY" -eq 1 ]; then
|
|||||||
fail "--production 需要先构建前端以嵌入 web,不能和 --backend-only 同时使用"
|
fail "--production 需要先构建前端以嵌入 web,不能和 --backend-only 同时使用"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$PRODUCTION_BUNDLE" -eq 1 ] && [ "$CHECK_ONLY" -eq 1 ]; then
|
||||||
|
fail "--production 不能和 --check 同时使用"
|
||||||
|
fi
|
||||||
|
|
||||||
case "$BUILD_PROFILE" in
|
case "$BUILD_PROFILE" in
|
||||||
dev|debug) BUILD_PROFILE="dev" ;;
|
dev|debug) BUILD_PROFILE="dev" ;;
|
||||||
release|fast-release) ;;
|
release|fast-release) ;;
|
||||||
*) fail "未知构建 profile: $BUILD_PROFILE(可选:dev / release / fast-release)" ;;
|
*) fail "未知构建 profile: $BUILD_PROFILE(可选:dev / release / fast-release)" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
case "$FAST_LINKER" in
|
||||||
|
auto|off|none|mold|lld) ;;
|
||||||
|
*) fail "未知 BOOSAPI_FAST_LINKER: $FAST_LINKER(可选:auto / off / mold / lld)" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
timer_start() {
|
||||||
|
date +%s
|
||||||
|
}
|
||||||
|
|
||||||
|
record_timing() {
|
||||||
|
local label="$1"
|
||||||
|
local started="$2"
|
||||||
|
local ended
|
||||||
|
ended="$(date +%s)"
|
||||||
|
BUILD_TIMINGS+=("${label}: $((ended - started))s")
|
||||||
|
}
|
||||||
|
|
||||||
|
print_timing_summary() {
|
||||||
|
[ "${#BUILD_TIMINGS[@]}" -gt 0 ] || return 0
|
||||||
|
log "构建耗时:"
|
||||||
|
local item
|
||||||
|
for item in "${BUILD_TIMINGS[@]}"; do
|
||||||
|
log " - $item"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
append_rustflags() {
|
||||||
|
local flags="$1"
|
||||||
|
if [[ " ${RUSTFLAGS:-} " != *" $flags "* ]]; then
|
||||||
|
export RUSTFLAGS="${RUSTFLAGS:+$RUSTFLAGS }$flags"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
build_cargo_args() {
|
||||||
|
CARGO_BASE_ARGS=()
|
||||||
|
CARGO_FEATURE_ARGS=()
|
||||||
|
|
||||||
|
if [ -n "$CARGO_JOBS" ]; then
|
||||||
|
CARGO_BASE_ARGS+=(--jobs "$CARGO_JOBS")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$CARGO_FEATURES" ]; then
|
||||||
|
CARGO_FEATURE_ARGS=(--features "$CARGO_FEATURES")
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_fast_linker() {
|
||||||
|
local mode="$FAST_LINKER"
|
||||||
|
|
||||||
|
case "$(uname -s 2>/dev/null || true)" in
|
||||||
|
Linux) ;;
|
||||||
|
*)
|
||||||
|
[ "$mode" = "auto" ] && return 0
|
||||||
|
fail "BOOSAPI_FAST_LINKER=$mode 目前只在 Linux 构建环境自动配置"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ "$mode" = "off" ] || [ "$mode" = "none" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$mode" = "auto" ]; then
|
||||||
|
if command -v mold >/dev/null 2>&1; then
|
||||||
|
mode="mold"
|
||||||
|
elif command -v ld.lld >/dev/null 2>&1 || command -v lld >/dev/null 2>&1; then
|
||||||
|
mode="lld"
|
||||||
|
else
|
||||||
|
log "提示: 安装 mold 或 lld 可显著缩短 Rust 链接时间;当前使用系统默认链接器"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
mold)
|
||||||
|
command -v mold >/dev/null 2>&1 || fail "BOOSAPI_FAST_LINKER=mold 但未找到 mold"
|
||||||
|
if command -v clang >/dev/null 2>&1; then
|
||||||
|
append_rustflags "-C linker=clang"
|
||||||
|
elif ! command -v gcc >/dev/null 2>&1 && [ -z "${RUSTC_LINKER:-}" ]; then
|
||||||
|
fail "启用 mold 需要 clang 或 gcc 作为 linker driver"
|
||||||
|
fi
|
||||||
|
append_rustflags "-C link-arg=-fuse-ld=mold"
|
||||||
|
log "Rust 快速链接器: mold(BOOSAPI_FAST_LINKER=$FAST_LINKER)"
|
||||||
|
;;
|
||||||
|
lld)
|
||||||
|
if ! command -v ld.lld >/dev/null 2>&1 && ! command -v lld >/dev/null 2>&1; then
|
||||||
|
fail "BOOSAPI_FAST_LINKER=lld 但未找到 ld.lld/lld"
|
||||||
|
fi
|
||||||
|
if command -v clang >/dev/null 2>&1; then
|
||||||
|
append_rustflags "-C linker=clang"
|
||||||
|
elif ! command -v gcc >/dev/null 2>&1 && [ -z "${RUSTC_LINKER:-}" ]; then
|
||||||
|
fail "启用 lld 需要 clang 或 gcc 作为 linker driver"
|
||||||
|
fi
|
||||||
|
append_rustflags "-C link-arg=-fuse-ld=lld"
|
||||||
|
log "Rust 快速链接器: lld(BOOSAPI_FAST_LINKER=$FAST_LINKER)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
server_port() {
|
server_port() {
|
||||||
if [ -n "${BOOSAPI_SERVER__PORT:-}" ]; then
|
if [ -n "${BOOSAPI_SERVER__PORT:-}" ]; then
|
||||||
printf '%s\n' "$BOOSAPI_SERVER__PORT"
|
printf '%s\n' "$BOOSAPI_SERVER__PORT"
|
||||||
@@ -224,8 +340,12 @@ install_frontend_dependencies() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
build_frontend() {
|
build_frontend() {
|
||||||
|
local started
|
||||||
|
started="$(timer_start)"
|
||||||
|
|
||||||
if [ "$SKIP_FRONTEND" -eq 1 ]; then
|
if [ "$SKIP_FRONTEND" -eq 1 ]; then
|
||||||
log "跳过前端构建(--skip-frontend)"
|
log "跳过前端构建(--skip-frontend)"
|
||||||
|
record_timing "前端检查" "$started"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -267,6 +387,7 @@ build_frontend() {
|
|||||||
&& [ -f "$ROOT_DIR/dist/web/index.html" ] \
|
&& [ -f "$ROOT_DIR/dist/web/index.html" ] \
|
||||||
&& [ "$(cat "$fingerprint_file" 2>/dev/null || true)" = "$fingerprint" ]; then
|
&& [ "$(cat "$fingerprint_file" 2>/dev/null || true)" = "$fingerprint" ]; then
|
||||||
ok "前端无变化,跳过 Vite 构建 → dist/web/"
|
ok "前端无变化,跳过 Vite 构建 → dist/web/"
|
||||||
|
record_timing "前端检查" "$started"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -280,11 +401,71 @@ build_frontend() {
|
|||||||
mkdir -p "$ROOT_DIR/dist/web"
|
mkdir -p "$ROOT_DIR/dist/web"
|
||||||
printf '%s\n' "$fingerprint" > "$fingerprint_file"
|
printf '%s\n' "$fingerprint" > "$fingerprint_file"
|
||||||
ok "前端构建完成 → dist/web/"
|
ok "前端构建完成 → dist/web/"
|
||||||
|
record_timing "前端构建" "$started"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_website_dependencies() {
|
||||||
|
command -v node >/dev/null 2>&1 || fail "未找到 node,请先安装 Node.js"
|
||||||
|
if [ ! -d "$ROOT_DIR/website/node_modules" ] || [ "$FORCE_FRONTEND" -eq 1 ]; then
|
||||||
|
log "安装官网依赖 (npm --prefix website install)..."
|
||||||
|
npm --prefix "$ROOT_DIR/website" install
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
build_public_website() {
|
||||||
|
local started
|
||||||
|
started="$(timer_start)"
|
||||||
|
|
||||||
|
if [ "$SKIP_FRONTEND" -eq 1 ]; then
|
||||||
|
log "跳过官网构建(--skip-frontend)"
|
||||||
|
record_timing "官网检查" "$started"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -f "$ROOT_DIR/website/package.json" ] || fail "未找到 website/package.json,无法构建官网"
|
||||||
|
install_website_dependencies
|
||||||
|
|
||||||
|
local fingerprint_file="$ROOT_DIR/dist/website/.bossai-website-build-fingerprint"
|
||||||
|
local fingerprint
|
||||||
|
fingerprint="$(
|
||||||
|
{
|
||||||
|
printf 'website-build-v1\n'
|
||||||
|
printf 'node='
|
||||||
|
node --version 2>/dev/null || true
|
||||||
|
printf 'npm='
|
||||||
|
npm --version 2>/dev/null || true
|
||||||
|
for path in website/package.json website/package-lock.json website/next.config.ts website/postcss.config.mjs website/tsconfig.json website/app website/public; do
|
||||||
|
[ -e "$ROOT_DIR/$path" ] && find "$ROOT_DIR/$path" -type f -print
|
||||||
|
done | sort | xargs -r sha256sum
|
||||||
|
} | sha256sum | awk '{print $1}'
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [ "$FORCE_FRONTEND" -eq 0 ] \
|
||||||
|
&& [ -f "$fingerprint_file" ] \
|
||||||
|
&& [ -f "$ROOT_DIR/dist/website/index.html" ] \
|
||||||
|
&& [ "$(cat "$fingerprint_file" 2>/dev/null || true)" = "$fingerprint" ]; then
|
||||||
|
ok "官网无变化,跳过 Next 构建 → dist/website/"
|
||||||
|
record_timing "官网检查" "$started"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "构建官网 (Next.js static export)..."
|
||||||
|
npm --prefix "$ROOT_DIR/website" run build
|
||||||
|
rm -rf "$ROOT_DIR/dist/website"
|
||||||
|
mkdir -p "$ROOT_DIR/dist/website"
|
||||||
|
cp -R "$ROOT_DIR/website/out/." "$ROOT_DIR/dist/website/"
|
||||||
|
printf '%s\n' "$fingerprint" > "$fingerprint_file"
|
||||||
|
ok "官网构建完成 → dist/website/"
|
||||||
|
record_timing "官网构建" "$started"
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_workflow_ui_for_embedding() {
|
ensure_workflow_ui_for_embedding() {
|
||||||
|
local started
|
||||||
|
started="$(timer_start)"
|
||||||
|
|
||||||
if [ "$SKIP_FRONTEND" -eq 1 ]; then
|
if [ "$SKIP_FRONTEND" -eq 1 ]; then
|
||||||
log "跳过 Workflow UI 构建检查(--skip-frontend)"
|
log "跳过 Workflow UI 构建检查(--skip-frontend)"
|
||||||
|
record_timing "Workflow UI 检查" "$started"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -293,6 +474,7 @@ ensure_workflow_ui_for_embedding() {
|
|||||||
&& [ -f "$ROOT_DIR/dist/workflow-ui/index.html" ] \
|
&& [ -f "$ROOT_DIR/dist/workflow-ui/index.html" ] \
|
||||||
&& [ "$FORCE_FRONTEND" -eq 0 ]; then
|
&& [ "$FORCE_FRONTEND" -eq 0 ]; then
|
||||||
ok "Workflow UI 已存在,跳过构建 → dist/workflow-ui-{external,cloud}/"
|
ok "Workflow UI 已存在,跳过构建 → dist/workflow-ui-{external,cloud}/"
|
||||||
|
record_timing "Workflow UI 检查" "$started"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -303,43 +485,60 @@ ensure_workflow_ui_for_embedding() {
|
|||||||
log "构建 Workflow UI 双前端(external + cloud,用于嵌入二进制)..."
|
log "构建 Workflow UI 双前端(external + cloud,用于嵌入二进制)..."
|
||||||
pnpm run workflow-ui:install
|
pnpm run workflow-ui:install
|
||||||
ok "Workflow UI 构建完成 → dist/workflow-ui-external/ dist/workflow-ui-cloud/"
|
ok "Workflow UI 构建完成 → dist/workflow-ui-external/ dist/workflow-ui-cloud/"
|
||||||
|
record_timing "Workflow UI 构建" "$started"
|
||||||
}
|
}
|
||||||
|
|
||||||
build_backend() {
|
build_backend() {
|
||||||
|
local started
|
||||||
|
started="$(timer_start)"
|
||||||
|
|
||||||
if ! command -v cargo >/dev/null 2>&1; then
|
if ! command -v cargo >/dev/null 2>&1; then
|
||||||
fail "未找到 cargo,请先安装 Rust 工具链"
|
fail "未找到 cargo,请先安装 Rust 工具链"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 提示一次:如果链接器走默认 ld 而 mold 不在 PATH,给出一条性能建议。
|
configure_fast_linker
|
||||||
# mold 安装后还需在 .cargo/config.toml 取消 `linker = "clang"` 那段注释。
|
build_cargo_args
|
||||||
if ! command -v mold >/dev/null 2>&1; then
|
|
||||||
log "提示: 安装 mold 后取消 .cargo/config.toml 中的相应注释,链接将快 3-5x"
|
|
||||||
fi
|
|
||||||
|
|
||||||
CARGO_FEATURE_ARGS=()
|
if [ "$CHECK_ONLY" -eq 1 ]; then
|
||||||
if [ -n "$CARGO_FEATURES" ]; then
|
if [ "$BUILD_PROFILE" = "release" ]; then
|
||||||
CARGO_FEATURE_ARGS=(--features "$CARGO_FEATURES")
|
log "检查后端 (cargo check --release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
|
||||||
|
cargo check "${CARGO_BASE_ARGS[@]}" --release "${CARGO_FEATURE_ARGS[@]}"
|
||||||
|
elif [ "$BUILD_PROFILE" = "fast-release" ]; then
|
||||||
|
log "检查后端 (cargo check --profile fast-release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
|
||||||
|
cargo check "${CARGO_BASE_ARGS[@]}" --profile fast-release "${CARGO_FEATURE_ARGS[@]}"
|
||||||
|
else
|
||||||
|
log "检查后端 (cargo check ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
|
||||||
|
cargo check "${CARGO_BASE_ARGS[@]}" "${CARGO_FEATURE_ARGS[@]}"
|
||||||
|
fi
|
||||||
|
ok "后端检查完成"
|
||||||
|
record_timing "后端检查" "$started"
|
||||||
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$BUILD_PROFILE" = "release" ]; then
|
if [ "$BUILD_PROFILE" = "release" ]; then
|
||||||
log "构建后端 (cargo build --release ${CARGO_FEATURE_ARGS[*]})..."
|
log "构建后端 (cargo build --release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
|
||||||
cargo build --release "${CARGO_FEATURE_ARGS[@]}"
|
cargo build "${CARGO_BASE_ARGS[@]}" --release "${CARGO_FEATURE_ARGS[@]}"
|
||||||
BINARY="$ROOT_DIR/target/release/boosapi"
|
BINARY="$ROOT_DIR/target/release/boosapi"
|
||||||
ok "后端构建完成 → target/release/boosapi"
|
ok "后端构建完成 → target/release/boosapi"
|
||||||
elif [ "$BUILD_PROFILE" = "fast-release" ]; then
|
elif [ "$BUILD_PROFILE" = "fast-release" ]; then
|
||||||
log "构建后端 (cargo build --profile fast-release ${CARGO_FEATURE_ARGS[*]})..."
|
log "构建后端 (cargo build --profile fast-release ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
|
||||||
cargo build --profile fast-release "${CARGO_FEATURE_ARGS[@]}"
|
cargo build "${CARGO_BASE_ARGS[@]}" --profile fast-release "${CARGO_FEATURE_ARGS[@]}"
|
||||||
BINARY="$ROOT_DIR/target/fast-release/boosapi"
|
BINARY="$ROOT_DIR/target/fast-release/boosapi"
|
||||||
ok "后端构建完成 → target/fast-release/boosapi"
|
ok "后端构建完成 → target/fast-release/boosapi"
|
||||||
else
|
else
|
||||||
log "构建后端 (cargo build ${CARGO_FEATURE_ARGS[*]})..."
|
log "构建后端 (cargo build ${CARGO_BASE_ARGS[*]} ${CARGO_FEATURE_ARGS[*]})..."
|
||||||
cargo build "${CARGO_FEATURE_ARGS[@]}"
|
cargo build "${CARGO_BASE_ARGS[@]}" "${CARGO_FEATURE_ARGS[@]}"
|
||||||
BINARY="$ROOT_DIR/target/debug/boosapi"
|
BINARY="$ROOT_DIR/target/debug/boosapi"
|
||||||
ok "后端构建完成 → target/debug/boosapi"
|
ok "后端构建完成 → target/debug/boosapi"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
record_timing "后端构建" "$started"
|
||||||
}
|
}
|
||||||
|
|
||||||
build_production_bundle() {
|
build_production_bundle() {
|
||||||
|
local started
|
||||||
|
started="$(timer_start)"
|
||||||
|
|
||||||
[ -n "${BINARY:-}" ] || fail "生产打包失败:后端二进制路径为空"
|
[ -n "${BINARY:-}" ] || fail "生产打包失败:后端二进制路径为空"
|
||||||
[ -f "$BINARY" ] || fail "生产打包失败:未找到二进制 $BINARY"
|
[ -f "$BINARY" ] || fail "生产打包失败:未找到二进制 $BINARY"
|
||||||
[ -f "$ROOT_DIR/config.toml" ] || fail "生产打包失败:未找到 config.toml"
|
[ -f "$ROOT_DIR/config.toml" ] || fail "生产打包失败:未找到 config.toml"
|
||||||
@@ -360,6 +559,7 @@ build_production_bundle() {
|
|||||||
log "产物内容:"
|
log "产物内容:"
|
||||||
(cd "$PRODUCTION_DIR" && ls -lh boosapi config.toml)
|
(cd "$PRODUCTION_DIR" && ls -lh boosapi config.toml)
|
||||||
log "部署时只需要复制该目录内的 boosapi 和 config.toml;主 Web 前端已嵌入二进制。"
|
log "部署时只需要复制该目录内的 boosapi 和 config.toml;主 Web 前端已嵌入二进制。"
|
||||||
|
record_timing "生产打包" "$started"
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup_started_server() {
|
cleanup_started_server() {
|
||||||
@@ -374,12 +574,15 @@ cleanup_started_server() {
|
|||||||
|
|
||||||
if [ "$FRONTEND_ONLY" -eq 1 ]; then
|
if [ "$FRONTEND_ONLY" -eq 1 ]; then
|
||||||
build_frontend
|
build_frontend
|
||||||
|
build_public_website
|
||||||
|
print_timing_summary
|
||||||
log "跳过启动(--frontend-only)"
|
log "跳过启动(--frontend-only)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$BACKEND_ONLY" -ne 1 ]; then
|
if [ "$BACKEND_ONLY" -ne 1 ]; then
|
||||||
build_frontend
|
build_frontend
|
||||||
|
build_public_website
|
||||||
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
|
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
|
||||||
ensure_workflow_ui_for_embedding
|
ensure_workflow_ui_for_embedding
|
||||||
fi
|
fi
|
||||||
@@ -387,16 +590,25 @@ fi
|
|||||||
|
|
||||||
build_backend
|
build_backend
|
||||||
|
|
||||||
|
if [ "$CHECK_ONLY" -eq 1 ]; then
|
||||||
|
print_timing_summary
|
||||||
|
log "跳过启动(--check)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
|
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
|
||||||
build_production_bundle
|
build_production_bundle
|
||||||
|
print_timing_summary
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$NO_RUN" -eq 1 ]; then
|
if [ "$NO_RUN" -eq 1 ]; then
|
||||||
|
print_timing_summary
|
||||||
log "跳过启动(--no-run)"
|
log "跳过启动(--no-run)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
print_timing_summary
|
||||||
stop_previous_server
|
stop_previous_server
|
||||||
|
|
||||||
log "启动服务..."
|
log "启动服务..."
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE proxy_logs ADD COLUMN content_summary TEXT;
|
||||||
|
ALTER TABLE proxy_logs ADD COLUMN request_content TEXT;
|
||||||
|
ALTER TABLE proxy_logs ADD COLUMN response_content TEXT;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
-- Performance-oriented projections and indexes for logs/workspace views.
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS proxy_logs_key_created_status_idx
|
||||||
|
ON proxy_logs (downstream_api_key_id, created_at DESC, status);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS proxy_logs_key_model_created_idx
|
||||||
|
ON proxy_logs (downstream_api_key_id, model_actual, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS proxy_logs_content_summary_idx
|
||||||
|
ON proxy_logs (content_summary);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS workspace_assets_user_output_created_idx
|
||||||
|
ON workspace_assets (
|
||||||
|
user_id,
|
||||||
|
status,
|
||||||
|
deleted_at,
|
||||||
|
source,
|
||||||
|
kind,
|
||||||
|
created_at DESC
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS workspace_assets_output_role_created_idx
|
||||||
|
ON workspace_assets (
|
||||||
|
json_extract(metadata, '$.workspaceAsset.role'),
|
||||||
|
created_at DESC
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS workspace_assets_workflow_output_created_idx
|
||||||
|
ON workspace_assets (
|
||||||
|
json_extract(metadata, '$.workflowUi.type'),
|
||||||
|
created_at DESC
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workspace_asset_daily (
|
||||||
|
local_day TEXT NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
asset_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
local_file_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(local_day, user_id, source, kind)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS workspace_asset_daily_user_day_idx
|
||||||
|
ON workspace_asset_daily (user_id, local_day DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS workspace_asset_daily_source_day_idx
|
||||||
|
ON workspace_asset_daily (source, local_day DESC);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE proxy_logs ADD COLUMN IF NOT EXISTS content_summary TEXT;
|
||||||
|
ALTER TABLE proxy_logs ADD COLUMN IF NOT EXISTS request_content TEXT;
|
||||||
|
ALTER TABLE proxy_logs ADD COLUMN IF NOT EXISTS response_content TEXT;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- Performance-oriented projections and indexes for logs/workspace views.
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS proxy_logs_key_created_status_idx
|
||||||
|
ON proxy_logs (downstream_api_key_id, created_at DESC, status);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS proxy_logs_key_model_created_idx
|
||||||
|
ON proxy_logs (downstream_api_key_id, model_actual, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS proxy_logs_content_summary_idx
|
||||||
|
ON proxy_logs (content_summary);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS workspace_assets_user_output_created_idx
|
||||||
|
ON workspace_assets (
|
||||||
|
user_id,
|
||||||
|
status,
|
||||||
|
deleted_at,
|
||||||
|
source,
|
||||||
|
kind,
|
||||||
|
created_at DESC
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS workspace_assets_output_role_created_idx
|
||||||
|
ON workspace_assets (((metadata::jsonb #>> '{workspaceAsset,role}')), created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS workspace_assets_workflow_output_created_idx
|
||||||
|
ON workspace_assets (((metadata::jsonb #>> '{workflowUi,type}')), created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workspace_asset_daily (
|
||||||
|
local_day TEXT NOT NULL,
|
||||||
|
user_id BIGINT NOT NULL DEFAULT 0,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
asset_count BIGINT NOT NULL DEFAULT 0,
|
||||||
|
local_file_count BIGINT NOT NULL DEFAULT 0,
|
||||||
|
total_size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
|
||||||
|
updated_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT),
|
||||||
|
UNIQUE(local_day, user_id, source, kind)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS workspace_asset_daily_user_day_idx
|
||||||
|
ON workspace_asset_daily (user_id, local_day DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS workspace_asset_daily_source_day_idx
|
||||||
|
ON workspace_asset_daily (source, local_day DESC);
|
||||||
+7
-2
@@ -32,20 +32,25 @@
|
|||||||
"typecheck:web": "tsc -p tsconfig.web.json",
|
"typecheck:web": "tsc -p tsconfig.web.json",
|
||||||
"typecheck:web:test": "tsc -p tsconfig.web.test.json",
|
"typecheck:web:test": "tsc -p tsconfig.web.test.json",
|
||||||
"typecheck": "npm run typecheck:web && npm run typecheck:web:test",
|
"typecheck": "npm run typecheck:web && npm run typecheck:web:test",
|
||||||
|
"website:dev": "npm --prefix website run dev",
|
||||||
|
"website:build": "npm --prefix website run build",
|
||||||
|
"website:start": "npm --prefix website run start",
|
||||||
|
"website:typecheck": "npm --prefix website run typecheck",
|
||||||
"docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173",
|
"docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173",
|
||||||
"docs:build": "vitepress build docs",
|
"docs:build": "vitepress build docs",
|
||||||
"docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173",
|
"docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173",
|
||||||
"workflow-ui:install": "node scripts/workflow-ui/install.mjs all",
|
"workflow-ui:install": "node scripts/workflow-ui/install.mjs all",
|
||||||
"workflow-ui:install:external": "node scripts/workflow-ui/install.mjs external",
|
"workflow-ui:install:external": "node scripts/workflow-ui/install.mjs external",
|
||||||
"workflow-ui:install:cloud": "node scripts/workflow-ui/install.mjs cloud",
|
"workflow-ui:install:cloud": "node scripts/workflow-ui/install.mjs cloud",
|
||||||
"test": "vitest run --root .",
|
"test": "vitest run --root src/web",
|
||||||
"test:watch": "vitest --root ."
|
"test:watch": "vitest --root src/web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@visactor/react-vchart": "^2.0.21",
|
"@visactor/react-vchart": "^2.0.21",
|
||||||
|
"gsap": "^3.15.0",
|
||||||
"marked": "^17.0.5",
|
"marked": "^17.0.5",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+8
@@ -20,6 +20,9 @@ importers:
|
|||||||
'@visactor/react-vchart':
|
'@visactor/react-vchart':
|
||||||
specifier: ^2.0.21
|
specifier: ^2.0.21
|
||||||
version: 2.0.21(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 2.0.21(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
gsap:
|
||||||
|
specifier: ^3.15.0
|
||||||
|
version: 3.15.0
|
||||||
marked:
|
marked:
|
||||||
specifier: ^17.0.5
|
specifier: ^17.0.5
|
||||||
version: 17.0.5
|
version: 17.0.5
|
||||||
@@ -2210,6 +2213,9 @@ packages:
|
|||||||
graceful-fs@4.2.11:
|
graceful-fs@4.2.11:
|
||||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||||
|
|
||||||
|
gsap@3.15.0:
|
||||||
|
resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==}
|
||||||
|
|
||||||
hachure-fill@0.5.2:
|
hachure-fill@0.5.2:
|
||||||
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
|
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
|
||||||
|
|
||||||
@@ -5138,6 +5144,8 @@ snapshots:
|
|||||||
|
|
||||||
graceful-fs@4.2.11: {}
|
graceful-fs@4.2.11: {}
|
||||||
|
|
||||||
|
gsap@3.15.0: {}
|
||||||
|
|
||||||
hachure-fill@0.5.2: {}
|
hachure-fill@0.5.2: {}
|
||||||
|
|
||||||
has-symbols@1.1.0:
|
has-symbols@1.1.0:
|
||||||
|
|||||||
+980
-2
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ use sqlx::{
|
|||||||
|
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod queries;
|
pub mod queries;
|
||||||
|
pub mod schema_compat;
|
||||||
pub mod sqlite_to_postgres;
|
pub mod sqlite_to_postgres;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
@@ -45,6 +46,23 @@ pub async fn create_pool(database_url: &str, max_connections: u32) -> anyhow::Re
|
|||||||
}
|
}
|
||||||
let pool = SqlitePoolOptions::new()
|
let pool = SqlitePoolOptions::new()
|
||||||
.max_connections(max_connections)
|
.max_connections(max_connections)
|
||||||
|
.after_connect(|conn, _meta| {
|
||||||
|
Box::pin(async move {
|
||||||
|
sqlx::query("PRAGMA journal_mode = WAL")
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("PRAGMA synchronous = NORMAL")
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("PRAGMA busy_timeout = 5000")
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("PRAGMA foreign_keys = ON")
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
})
|
||||||
.connect(database_url)
|
.connect(database_url)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(pool)
|
Ok(pool)
|
||||||
|
|||||||
@@ -0,0 +1,382 @@
|
|||||||
|
use anyhow::Context;
|
||||||
|
use sqlx::{Row, SqlitePool};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
pub async fn ensure_sqlite_upstream_compat(pool: &SqlitePool) -> anyhow::Result<()> {
|
||||||
|
ensure_columns(
|
||||||
|
pool,
|
||||||
|
"sites",
|
||||||
|
&[
|
||||||
|
("post_refresh_probe_enabled", "INTEGER DEFAULT false"),
|
||||||
|
("post_refresh_probe_model", "TEXT DEFAULT ''"),
|
||||||
|
("post_refresh_probe_scope", "TEXT DEFAULT 'single'"),
|
||||||
|
(
|
||||||
|
"post_refresh_probe_latency_threshold_ms",
|
||||||
|
"INTEGER DEFAULT 0",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ensure_columns(
|
||||||
|
pool,
|
||||||
|
"downstream_api_keys",
|
||||||
|
&[
|
||||||
|
("excluded_site_ids", "TEXT"),
|
||||||
|
("excluded_credential_refs", "TEXT"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ensure_columns(
|
||||||
|
pool,
|
||||||
|
"route_channels",
|
||||||
|
&[("oauth_route_unit_id", "INTEGER")],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ensure_columns(
|
||||||
|
pool,
|
||||||
|
"proxy_logs",
|
||||||
|
&[
|
||||||
|
("client_family", "TEXT"),
|
||||||
|
("client_app_id", "TEXT"),
|
||||||
|
("client_app_name", "TEXT"),
|
||||||
|
("client_confidence", "TEXT"),
|
||||||
|
("is_stream", "INTEGER"),
|
||||||
|
("first_byte_latency_ms", "INTEGER"),
|
||||||
|
("content_summary", "TEXT"),
|
||||||
|
("request_content", "TEXT"),
|
||||||
|
("response_content", "TEXT"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
run_compat_ddl(pool).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_columns(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
table: &str,
|
||||||
|
columns: &[(&str, &str)],
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let existing = table_columns(pool, table).await?;
|
||||||
|
if existing.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (name, definition) in columns {
|
||||||
|
if existing.contains(*name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let sql = format!("ALTER TABLE {table} ADD {name} {definition}");
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("add column {table}.{name}"))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn table_columns(pool: &SqlitePool, table: &str) -> anyhow::Result<HashSet<String>> {
|
||||||
|
let sql = format!("PRAGMA table_info({table})");
|
||||||
|
let rows = sqlx::query(&sql).fetch_all(pool).await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|row| row.try_get::<String, _>("name").ok())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_compat_ddl(pool: &SqlitePool) -> anyhow::Result<()> {
|
||||||
|
for statement in SQLITE_COMPAT_DDL {
|
||||||
|
sqlx::query(statement)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("run SQLite compat DDL: {statement}"))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
const SQLITE_COMPAT_DDL: &[&str] = &[
|
||||||
|
"CREATE INDEX IF NOT EXISTS proxy_logs_client_app_id_created_at_idx
|
||||||
|
ON proxy_logs (client_app_id, created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS proxy_logs_client_family_created_at_idx
|
||||||
|
ON proxy_logs (client_family, created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS proxy_logs_key_created_status_idx
|
||||||
|
ON proxy_logs (downstream_api_key_id, created_at DESC, status)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS proxy_logs_key_model_created_idx
|
||||||
|
ON proxy_logs (downstream_api_key_id, model_actual, created_at DESC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS proxy_logs_content_summary_idx
|
||||||
|
ON proxy_logs (content_summary)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS workspace_assets_user_output_created_idx
|
||||||
|
ON workspace_assets (user_id, status, deleted_at, source, kind, created_at DESC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS workspace_assets_output_role_created_idx
|
||||||
|
ON workspace_assets (json_extract(metadata, '$.workspaceAsset.role'), created_at DESC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS workspace_assets_workflow_output_created_idx
|
||||||
|
ON workspace_assets (json_extract(metadata, '$.workflowUi.type'), created_at DESC)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS workspace_asset_daily (
|
||||||
|
local_day TEXT NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
asset_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
local_file_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(local_day, user_id, source, kind)
|
||||||
|
)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS workspace_asset_daily_user_day_idx
|
||||||
|
ON workspace_asset_daily (user_id, local_day DESC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS workspace_asset_daily_source_day_idx
|
||||||
|
ON workspace_asset_daily (source, local_day DESC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS route_channels_oauth_route_unit_id_idx
|
||||||
|
ON route_channels (oauth_route_unit_id)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS site_api_endpoints (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
site_id INTEGER NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
enabled INTEGER DEFAULT true,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
cooldown_until TEXT,
|
||||||
|
last_selected_at TEXT,
|
||||||
|
last_failed_at TEXT,
|
||||||
|
last_failure_reason TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
|
||||||
|
)",
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS site_api_endpoints_site_url_unique
|
||||||
|
ON site_api_endpoints (site_id, url)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS site_api_endpoints_site_enabled_sort_idx
|
||||||
|
ON site_api_endpoints (site_id, enabled, sort_order)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS site_api_endpoints_site_cooldown_idx
|
||||||
|
ON site_api_endpoints (site_id, cooldown_until)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS proxy_debug_traces (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
downstream_path TEXT NOT NULL,
|
||||||
|
client_kind TEXT,
|
||||||
|
session_id TEXT,
|
||||||
|
trace_hint TEXT,
|
||||||
|
requested_model TEXT,
|
||||||
|
downstream_api_key_id INTEGER,
|
||||||
|
request_headers_json TEXT,
|
||||||
|
request_body_json TEXT,
|
||||||
|
sticky_session_key TEXT,
|
||||||
|
sticky_hit_channel_id INTEGER,
|
||||||
|
selected_channel_id INTEGER,
|
||||||
|
selected_route_id INTEGER,
|
||||||
|
selected_account_id INTEGER,
|
||||||
|
selected_site_id INTEGER,
|
||||||
|
selected_site_platform TEXT,
|
||||||
|
endpoint_candidates_json TEXT,
|
||||||
|
endpoint_runtime_state_json TEXT,
|
||||||
|
decision_summary_json TEXT,
|
||||||
|
final_status TEXT,
|
||||||
|
final_http_status INTEGER,
|
||||||
|
final_upstream_path TEXT,
|
||||||
|
final_response_headers_json TEXT,
|
||||||
|
final_response_body_json TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_created_at_idx
|
||||||
|
ON proxy_debug_traces (created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_session_created_at_idx
|
||||||
|
ON proxy_debug_traces (session_id, created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_model_created_at_idx
|
||||||
|
ON proxy_debug_traces (requested_model, created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS proxy_debug_traces_final_status_created_at_idx
|
||||||
|
ON proxy_debug_traces (final_status, created_at)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS proxy_debug_attempts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
trace_id INTEGER NOT NULL,
|
||||||
|
attempt_index INTEGER NOT NULL,
|
||||||
|
endpoint TEXT NOT NULL,
|
||||||
|
request_path TEXT NOT NULL,
|
||||||
|
target_url TEXT NOT NULL,
|
||||||
|
runtime_executor TEXT,
|
||||||
|
request_headers_json TEXT,
|
||||||
|
request_body_json TEXT,
|
||||||
|
response_status INTEGER,
|
||||||
|
response_headers_json TEXT,
|
||||||
|
response_body_json TEXT,
|
||||||
|
raw_error_text TEXT,
|
||||||
|
recover_applied INTEGER DEFAULT false,
|
||||||
|
downgrade_decision INTEGER DEFAULT false,
|
||||||
|
downgrade_reason TEXT,
|
||||||
|
memory_write_json TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (trace_id) REFERENCES proxy_debug_traces(id) ON DELETE CASCADE
|
||||||
|
)",
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS proxy_debug_attempts_trace_attempt_unique
|
||||||
|
ON proxy_debug_attempts (trace_id, attempt_index)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS proxy_debug_attempts_trace_created_at_idx
|
||||||
|
ON proxy_debug_attempts (trace_id, created_at)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS oauth_route_units (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
site_id INTEGER NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
strategy TEXT NOT NULL DEFAULT 'round_robin',
|
||||||
|
enabled INTEGER DEFAULT true,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
|
||||||
|
)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS oauth_route_units_site_provider_idx
|
||||||
|
ON oauth_route_units (site_id, provider)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS oauth_route_units_enabled_idx
|
||||||
|
ON oauth_route_units (enabled)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS oauth_route_unit_members (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
unit_id INTEGER NOT NULL,
|
||||||
|
account_id INTEGER NOT NULL,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
success_count INTEGER DEFAULT 0,
|
||||||
|
fail_count INTEGER DEFAULT 0,
|
||||||
|
total_latency_ms INTEGER DEFAULT 0,
|
||||||
|
total_cost REAL DEFAULT 0,
|
||||||
|
last_used_at TEXT,
|
||||||
|
last_selected_at TEXT,
|
||||||
|
last_fail_at TEXT,
|
||||||
|
consecutive_fail_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cooldown_level INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cooldown_until TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (unit_id) REFERENCES oauth_route_units(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
|
||||||
|
)",
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS oauth_route_unit_members_unit_account_unique
|
||||||
|
ON oauth_route_unit_members (unit_id, account_id)",
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS oauth_route_unit_members_account_unique
|
||||||
|
ON oauth_route_unit_members (account_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS oauth_route_unit_members_unit_sort_idx
|
||||||
|
ON oauth_route_unit_members (unit_id, sort_order)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS oauth_route_unit_members_unit_cooldown_idx
|
||||||
|
ON oauth_route_unit_members (unit_id, cooldown_until)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS admin_snapshots (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
namespace TEXT NOT NULL,
|
||||||
|
snapshot_key TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
generated_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
stale_until TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)",
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS admin_snapshots_namespace_key_unique
|
||||||
|
ON admin_snapshots (namespace, snapshot_key)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS admin_snapshots_expires_at_idx
|
||||||
|
ON admin_snapshots (expires_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS admin_snapshots_stale_until_idx
|
||||||
|
ON admin_snapshots (stale_until)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS analytics_projection_checkpoints (
|
||||||
|
projector_key TEXT PRIMARY KEY NOT NULL,
|
||||||
|
time_zone TEXT NOT NULL DEFAULT 'Local',
|
||||||
|
last_proxy_log_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
watermark_created_at TEXT,
|
||||||
|
recompute_from_id INTEGER,
|
||||||
|
recompute_requested_at TEXT,
|
||||||
|
recompute_reason TEXT,
|
||||||
|
recompute_started_at TEXT,
|
||||||
|
recompute_completed_at TEXT,
|
||||||
|
last_projected_at TEXT,
|
||||||
|
last_successful_at TEXT,
|
||||||
|
last_error TEXT,
|
||||||
|
lease_owner TEXT,
|
||||||
|
lease_token TEXT,
|
||||||
|
lease_expires_at TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS analytics_projection_checkpoints_recompute_from_id_idx
|
||||||
|
ON analytics_projection_checkpoints (recompute_from_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS analytics_projection_checkpoints_lease_expires_at_idx
|
||||||
|
ON analytics_projection_checkpoints (lease_expires_at)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS model_day_usage (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
local_day TEXT NOT NULL,
|
||||||
|
site_id INTEGER NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
total_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
success_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failed_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
latency_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
|
||||||
|
CHECK (
|
||||||
|
total_calls >= 0 AND success_calls >= 0 AND failed_calls >= 0
|
||||||
|
AND total_tokens >= 0 AND total_spend >= 0
|
||||||
|
AND total_latency_ms >= 0 AND latency_count >= 0
|
||||||
|
)
|
||||||
|
)",
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS model_day_usage_day_site_model_unique
|
||||||
|
ON model_day_usage (local_day, site_id, model)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS model_day_usage_day_idx
|
||||||
|
ON model_day_usage (local_day)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS model_day_usage_site_id_idx
|
||||||
|
ON model_day_usage (site_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS model_day_usage_model_idx
|
||||||
|
ON model_day_usage (model)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS site_day_usage (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
local_day TEXT NOT NULL,
|
||||||
|
site_id INTEGER NOT NULL,
|
||||||
|
total_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
success_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failed_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_summary_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_site_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
latency_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
|
||||||
|
CHECK (
|
||||||
|
total_calls >= 0 AND success_calls >= 0 AND failed_calls >= 0
|
||||||
|
AND total_tokens >= 0 AND total_summary_spend >= 0
|
||||||
|
AND total_site_spend >= 0 AND total_latency_ms >= 0
|
||||||
|
AND latency_count >= 0
|
||||||
|
)
|
||||||
|
)",
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS site_day_usage_day_site_unique
|
||||||
|
ON site_day_usage (local_day, site_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS site_day_usage_day_idx
|
||||||
|
ON site_day_usage (local_day)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS site_day_usage_site_id_idx
|
||||||
|
ON site_day_usage (site_id)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS site_hour_usage (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
bucket_start_utc TEXT NOT NULL,
|
||||||
|
site_id INTEGER NOT NULL,
|
||||||
|
total_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
success_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failed_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_summary_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_site_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
latency_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
|
||||||
|
CHECK (
|
||||||
|
total_calls >= 0 AND success_calls >= 0 AND failed_calls >= 0
|
||||||
|
AND total_tokens >= 0 AND total_summary_spend >= 0
|
||||||
|
AND total_site_spend >= 0 AND total_latency_ms >= 0
|
||||||
|
AND latency_count >= 0
|
||||||
|
)
|
||||||
|
)",
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS site_hour_usage_hour_site_unique
|
||||||
|
ON site_hour_usage (bucket_start_utc, site_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS site_hour_usage_hour_idx
|
||||||
|
ON site_hour_usage (bucket_start_utc)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS site_hour_usage_site_id_idx
|
||||||
|
ON site_hour_usage (site_id)",
|
||||||
|
];
|
||||||
@@ -299,6 +299,200 @@ const TABLES: &[TableSpec] = &[
|
|||||||
col!("read_at", text),
|
col!("read_at", text),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "site_api_endpoints",
|
||||||
|
columns: &[
|
||||||
|
col!("id", i64),
|
||||||
|
col!("site_id", i64),
|
||||||
|
col!("url", text),
|
||||||
|
col!("enabled", bool),
|
||||||
|
col!("sort_order", i64),
|
||||||
|
col!("cooldown_until", text),
|
||||||
|
col!("last_selected_at", text),
|
||||||
|
col!("last_failed_at", text),
|
||||||
|
col!("last_failure_reason", text),
|
||||||
|
col!("created_at", text),
|
||||||
|
col!("updated_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "oauth_route_units",
|
||||||
|
columns: &[
|
||||||
|
col!("id", i64),
|
||||||
|
col!("site_id", i64),
|
||||||
|
col!("provider", text),
|
||||||
|
col!("name", text),
|
||||||
|
col!("strategy", text),
|
||||||
|
col!("enabled", bool),
|
||||||
|
col!("created_at", text),
|
||||||
|
col!("updated_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "oauth_route_unit_members",
|
||||||
|
columns: &[
|
||||||
|
col!("id", i64),
|
||||||
|
col!("unit_id", i64),
|
||||||
|
col!("account_id", i64),
|
||||||
|
col!("sort_order", i64),
|
||||||
|
col!("success_count", i64),
|
||||||
|
col!("fail_count", i64),
|
||||||
|
col!("total_latency_ms", i64),
|
||||||
|
col!("total_cost", f64),
|
||||||
|
col!("last_used_at", text),
|
||||||
|
col!("last_selected_at", text),
|
||||||
|
col!("last_fail_at", text),
|
||||||
|
col!("consecutive_fail_count", i64),
|
||||||
|
col!("cooldown_level", i64),
|
||||||
|
col!("cooldown_until", text),
|
||||||
|
col!("created_at", text),
|
||||||
|
col!("updated_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "proxy_debug_traces",
|
||||||
|
columns: &[
|
||||||
|
col!("id", i64),
|
||||||
|
col!("downstream_path", text),
|
||||||
|
col!("client_kind", text),
|
||||||
|
col!("session_id", text),
|
||||||
|
col!("trace_hint", text),
|
||||||
|
col!("requested_model", text),
|
||||||
|
col!("downstream_api_key_id", i64),
|
||||||
|
col!("request_headers_json", text),
|
||||||
|
col!("request_body_json", text),
|
||||||
|
col!("sticky_session_key", text),
|
||||||
|
col!("sticky_hit_channel_id", i64),
|
||||||
|
col!("selected_channel_id", i64),
|
||||||
|
col!("selected_route_id", i64),
|
||||||
|
col!("selected_account_id", i64),
|
||||||
|
col!("selected_site_id", i64),
|
||||||
|
col!("selected_site_platform", text),
|
||||||
|
col!("endpoint_candidates_json", text),
|
||||||
|
col!("endpoint_runtime_state_json", text),
|
||||||
|
col!("decision_summary_json", text),
|
||||||
|
col!("final_status", text),
|
||||||
|
col!("final_http_status", i64),
|
||||||
|
col!("final_upstream_path", text),
|
||||||
|
col!("final_response_headers_json", text),
|
||||||
|
col!("final_response_body_json", text),
|
||||||
|
col!("created_at", text),
|
||||||
|
col!("updated_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "proxy_debug_attempts",
|
||||||
|
columns: &[
|
||||||
|
col!("id", i64),
|
||||||
|
col!("trace_id", i64),
|
||||||
|
col!("attempt_index", i64),
|
||||||
|
col!("endpoint", text),
|
||||||
|
col!("request_path", text),
|
||||||
|
col!("target_url", text),
|
||||||
|
col!("runtime_executor", text),
|
||||||
|
col!("request_headers_json", text),
|
||||||
|
col!("request_body_json", text),
|
||||||
|
col!("response_status", i64),
|
||||||
|
col!("response_headers_json", text),
|
||||||
|
col!("response_body_json", text),
|
||||||
|
col!("raw_error_text", text),
|
||||||
|
col!("recover_applied", bool),
|
||||||
|
col!("downgrade_decision", bool),
|
||||||
|
col!("downgrade_reason", text),
|
||||||
|
col!("memory_write_json", text),
|
||||||
|
col!("created_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "admin_snapshots",
|
||||||
|
columns: &[
|
||||||
|
col!("id", i64),
|
||||||
|
col!("namespace", text),
|
||||||
|
col!("snapshot_key", text),
|
||||||
|
col!("payload", text),
|
||||||
|
col!("generated_at", text),
|
||||||
|
col!("expires_at", text),
|
||||||
|
col!("stale_until", text),
|
||||||
|
col!("created_at", text),
|
||||||
|
col!("updated_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "analytics_projection_checkpoints",
|
||||||
|
columns: &[
|
||||||
|
col!("projector_key", text),
|
||||||
|
col!("time_zone", text),
|
||||||
|
col!("last_proxy_log_id", i64),
|
||||||
|
col!("watermark_created_at", text),
|
||||||
|
col!("recompute_from_id", i64),
|
||||||
|
col!("recompute_requested_at", text),
|
||||||
|
col!("recompute_reason", text),
|
||||||
|
col!("recompute_started_at", text),
|
||||||
|
col!("recompute_completed_at", text),
|
||||||
|
col!("last_projected_at", text),
|
||||||
|
col!("last_successful_at", text),
|
||||||
|
col!("last_error", text),
|
||||||
|
col!("lease_owner", text),
|
||||||
|
col!("lease_token", text),
|
||||||
|
col!("lease_expires_at", text),
|
||||||
|
col!("created_at", text),
|
||||||
|
col!("updated_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "model_day_usage",
|
||||||
|
columns: &[
|
||||||
|
col!("id", i64),
|
||||||
|
col!("local_day", text),
|
||||||
|
col!("site_id", i64),
|
||||||
|
col!("model", text),
|
||||||
|
col!("total_calls", i64),
|
||||||
|
col!("success_calls", i64),
|
||||||
|
col!("failed_calls", i64),
|
||||||
|
col!("total_tokens", i64),
|
||||||
|
col!("total_spend", f64),
|
||||||
|
col!("total_latency_ms", i64),
|
||||||
|
col!("latency_count", i64),
|
||||||
|
col!("created_at", text),
|
||||||
|
col!("updated_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "site_day_usage",
|
||||||
|
columns: &[
|
||||||
|
col!("id", i64),
|
||||||
|
col!("local_day", text),
|
||||||
|
col!("site_id", i64),
|
||||||
|
col!("total_calls", i64),
|
||||||
|
col!("success_calls", i64),
|
||||||
|
col!("failed_calls", i64),
|
||||||
|
col!("total_tokens", i64),
|
||||||
|
col!("total_summary_spend", f64),
|
||||||
|
col!("total_site_spend", f64),
|
||||||
|
col!("total_latency_ms", i64),
|
||||||
|
col!("latency_count", i64),
|
||||||
|
col!("created_at", text),
|
||||||
|
col!("updated_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TableSpec {
|
||||||
|
name: "site_hour_usage",
|
||||||
|
columns: &[
|
||||||
|
col!("id", i64),
|
||||||
|
col!("bucket_start_utc", text),
|
||||||
|
col!("site_id", i64),
|
||||||
|
col!("total_calls", i64),
|
||||||
|
col!("success_calls", i64),
|
||||||
|
col!("failed_calls", i64),
|
||||||
|
col!("total_tokens", i64),
|
||||||
|
col!("total_summary_spend", f64),
|
||||||
|
col!("total_site_spend", f64),
|
||||||
|
col!("total_latency_ms", i64),
|
||||||
|
col!("latency_count", i64),
|
||||||
|
col!("created_at", text),
|
||||||
|
col!("updated_at", text),
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
pub async fn migrate(
|
pub async fn migrate(
|
||||||
|
|||||||
+22
-2
@@ -11,14 +11,16 @@ pub mod transformers;
|
|||||||
pub mod workflow;
|
pub mod workflow;
|
||||||
|
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use std::sync::Arc;
|
use std::{sync::Arc, time::Duration};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub config: config::AppConfig,
|
pub config: config::AppConfig,
|
||||||
pub db: Arc<SqlitePool>,
|
pub db: Arc<SqlitePool>,
|
||||||
|
pub http: reqwest::Client,
|
||||||
pub comfyui: Arc<services::comfyui_process::ComfyUIProcess>,
|
pub comfyui: Arc<services::comfyui_process::ComfyUIProcess>,
|
||||||
pub builtin_keys: Arc<services::builtin_keys::BuiltinKeys>,
|
pub builtin_keys: Arc<services::builtin_keys::BuiltinKeys>,
|
||||||
|
pub runtime_auth: Arc<services::runtime_auth::RuntimeAuthSettings>,
|
||||||
pub register_captcha: Arc<services::register_captcha::RegisterCaptchaStore>,
|
pub register_captcha: Arc<services::register_captcha::RegisterCaptchaStore>,
|
||||||
pub registration_limiter: Arc<services::registration_limiter::RegistrationLimiter>,
|
pub registration_limiter: Arc<services::registration_limiter::RegistrationLimiter>,
|
||||||
}
|
}
|
||||||
@@ -28,6 +30,7 @@ impl AppState {
|
|||||||
config: config::AppConfig,
|
config: config::AppConfig,
|
||||||
db: SqlitePool,
|
db: SqlitePool,
|
||||||
builtin_keys: services::builtin_keys::BuiltinKeys,
|
builtin_keys: services::builtin_keys::BuiltinKeys,
|
||||||
|
runtime_auth: services::runtime_auth::RuntimeAuthSettings,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let boosapi_host = if config.server.host == "0.0.0.0" || config.server.host == "::" {
|
let boosapi_host = if config.server.host == "0.0.0.0" || config.server.host == "::" {
|
||||||
"127.0.0.1".to_string()
|
"127.0.0.1".to_string()
|
||||||
@@ -39,19 +42,36 @@ impl AppState {
|
|||||||
let comfyui = services::comfyui_process::ComfyUIProcess::new(
|
let comfyui = services::comfyui_process::ComfyUIProcess::new(
|
||||||
config.comfyui.clone(),
|
config.comfyui.clone(),
|
||||||
boosapi_url.clone(),
|
boosapi_url.clone(),
|
||||||
config.auth.proxy_token.clone(),
|
runtime_auth.proxy_token(),
|
||||||
comfyui_agent_secret.clone(),
|
comfyui_agent_secret.clone(),
|
||||||
);
|
);
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(config.proxy.upstream_timeout_secs.max(30)))
|
||||||
|
.connect_timeout(Duration::from_secs(15))
|
||||||
|
.pool_idle_timeout(Duration::from_secs(90))
|
||||||
|
.pool_max_idle_per_host(64)
|
||||||
|
.tcp_nodelay(true)
|
||||||
|
.build()
|
||||||
|
.unwrap_or_else(|err| {
|
||||||
|
tracing::warn!(%err, "failed to build shared HTTP client; falling back to reqwest default");
|
||||||
|
reqwest::Client::new()
|
||||||
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
db: Arc::new(db),
|
db: Arc::new(db),
|
||||||
|
http,
|
||||||
comfyui,
|
comfyui,
|
||||||
builtin_keys: Arc::new(builtin_keys),
|
builtin_keys: Arc::new(builtin_keys),
|
||||||
|
runtime_auth: Arc::new(runtime_auth),
|
||||||
register_captcha: Arc::new(services::register_captcha::RegisterCaptchaStore::new()),
|
register_captcha: Arc::new(services::register_captcha::RegisterCaptchaStore::new()),
|
||||||
registration_limiter: Arc::new(
|
registration_limiter: Arc::new(
|
||||||
services::registration_limiter::RegistrationLimiter::new(),
|
services::registration_limiter::RegistrationLimiter::new(),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn runtime_proxy_token(&self) -> String {
|
||||||
|
self.runtime_auth.proxy_token()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-4
@@ -1,11 +1,16 @@
|
|||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use axum::serve;
|
use axum::serve;
|
||||||
use boosapi::{config::AppConfig, db, services::builtin_keys, AppState};
|
use boosapi::{
|
||||||
|
config::AppConfig,
|
||||||
|
db,
|
||||||
|
services::{builtin_keys, runtime_auth},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use tokio::signal;
|
use tokio::signal;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::{prelude::*, EnvFilter};
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(
|
#[command(
|
||||||
@@ -104,7 +109,11 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let filter = EnvFilter::try_from_default_env()
|
let filter = EnvFilter::try_from_default_env()
|
||||||
.unwrap_or_else(|_| EnvFilter::new(&config.server.log_level));
|
.unwrap_or_else(|_| EnvFilter::new(&config.server.log_level));
|
||||||
|
|
||||||
tracing_subscriber::fmt().with_env_filter(filter).init();
|
tracing_subscriber::registry()
|
||||||
|
.with(filter)
|
||||||
|
.with(tracing_subscriber::fmt::layer())
|
||||||
|
.with(boosapi::services::runtime_logs::RuntimeLogLayer)
|
||||||
|
.init();
|
||||||
|
|
||||||
tracing::info!("Loaded configuration from {}", args.config);
|
tracing::info!("Loaded configuration from {}", args.config);
|
||||||
|
|
||||||
@@ -116,6 +125,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
// Run migrations
|
// Run migrations
|
||||||
db::run_migrations(db_kind, Some(&pool), None).await?;
|
db::run_migrations(db_kind, Some(&pool), None).await?;
|
||||||
|
db::schema_compat::ensure_sqlite_upstream_compat(&pool).await?;
|
||||||
tracing::info!("Database migrations applied");
|
tracing::info!("Database migrations applied");
|
||||||
|
|
||||||
if args.migrate_only {
|
if args.migrate_only {
|
||||||
@@ -134,9 +144,15 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let builtin = builtin_keys::BuiltinKeys {
|
let builtin = builtin_keys::BuiltinKeys {
|
||||||
comfyui_agent: comfyui_agent_key,
|
comfyui_agent: comfyui_agent_key,
|
||||||
};
|
};
|
||||||
|
let runtime_auth = runtime_auth::RuntimeAuthSettings::load(
|
||||||
|
&pool,
|
||||||
|
config.auth.proxy_token.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.context("failed to load runtime auth settings")?;
|
||||||
|
|
||||||
// Build app state
|
// Build app state
|
||||||
let state = AppState::new(config.clone(), pool, builtin);
|
let state = AppState::new(config.clone(), pool, builtin, runtime_auth);
|
||||||
if let Err(err) = state.comfyui.start().await {
|
if let Err(err) = state.comfyui.start().await {
|
||||||
tracing::warn!(%err, "ComfyUI embedded process did not start");
|
tracing::warn!(%err, "ComfyUI embedded process did not start");
|
||||||
}
|
}
|
||||||
|
|||||||
+113
-51
@@ -14,9 +14,7 @@ use crate::transformers::{
|
|||||||
canonical::CanonicalRequest,
|
canonical::CanonicalRequest,
|
||||||
};
|
};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use reqwest::Client;
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
/// Resolved upstream info for a given model request.
|
/// Resolved upstream info for a given model request.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -26,15 +24,6 @@ pub struct UpstreamRoute {
|
|||||||
pub model: String,
|
pub model: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a shared reqwest client with sensible defaults.
|
|
||||||
fn http_client() -> Client {
|
|
||||||
Client::builder()
|
|
||||||
.timeout(Duration::from_secs(300))
|
|
||||||
.pool_max_idle_per_host(32)
|
|
||||||
.build()
|
|
||||||
.expect("Failed to build HTTP client")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_upstream_json(
|
fn parse_upstream_json(
|
||||||
text: &str,
|
text: &str,
|
||||||
upstream_url: &str,
|
upstream_url: &str,
|
||||||
@@ -174,11 +163,11 @@ fn aggregate_chat_sse(text: &str, fallback_model: &str) -> Option<Value> {
|
|||||||
|
|
||||||
/// Execute a non-streaming chat completions request.
|
/// Execute a non-streaming chat completions request.
|
||||||
pub async fn execute_chat_completions(
|
pub async fn execute_chat_completions(
|
||||||
_state: AppState,
|
state: AppState,
|
||||||
route: UpstreamRoute,
|
route: UpstreamRoute,
|
||||||
body: Value,
|
body: Value,
|
||||||
) -> AppResult<Value> {
|
) -> AppResult<Value> {
|
||||||
let client = http_client();
|
let client = state.http.clone();
|
||||||
let upstream_url = upstream_url(&route.base_url, "/chat/completions");
|
let upstream_url = upstream_url(&route.base_url, "/chat/completions");
|
||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
@@ -210,19 +199,29 @@ pub async fn execute_chat_completions(
|
|||||||
/// Execute a streaming chat completions request.
|
/// Execute a streaming chat completions request.
|
||||||
/// Returns a vector of SSE data chunks as strings.
|
/// Returns a vector of SSE data chunks as strings.
|
||||||
pub async fn execute_chat_completions_stream(
|
pub async fn execute_chat_completions_stream(
|
||||||
_state: AppState,
|
state: AppState,
|
||||||
route: UpstreamRoute,
|
route: UpstreamRoute,
|
||||||
body: Value,
|
body: Value,
|
||||||
) -> AppResult<Vec<String>> {
|
) -> AppResult<Vec<String>> {
|
||||||
execute_streaming_request(route, "/chat/completions", body).await
|
execute_streaming_request(state, route, "/chat/completions", body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a streaming chat completions request and return the live upstream response.
|
||||||
|
pub async fn execute_chat_completions_stream_response(
|
||||||
|
state: AppState,
|
||||||
|
route: UpstreamRoute,
|
||||||
|
body: Value,
|
||||||
|
) -> AppResult<reqwest::Response> {
|
||||||
|
execute_streaming_response(state, route, "/chat/completions", body).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute an Anthropic Messages API request against an Anthropic-shaped upstream.
|
/// Execute an Anthropic Messages API request against an Anthropic-shaped upstream.
|
||||||
pub async fn execute_anthropic_messages(
|
pub async fn execute_anthropic_messages(
|
||||||
|
state: AppState,
|
||||||
route: UpstreamRoute,
|
route: UpstreamRoute,
|
||||||
request: CanonicalRequest,
|
request: CanonicalRequest,
|
||||||
) -> AppResult<Value> {
|
) -> AppResult<Value> {
|
||||||
let client = http_client();
|
let client = state.http.clone();
|
||||||
let upstream_url = format!("{}/messages", route.base_url.trim_end_matches('/'));
|
let upstream_url = format!("{}/messages", route.base_url.trim_end_matches('/'));
|
||||||
let compatibility = AnthropicMessagesCompatibility::default();
|
let compatibility = AnthropicMessagesCompatibility::default();
|
||||||
let body = format_messages_request_with_compat(&request, &compatibility);
|
let body = format_messages_request_with_compat(&request, &compatibility);
|
||||||
@@ -260,10 +259,21 @@ pub async fn execute_anthropic_messages(
|
|||||||
|
|
||||||
/// Execute a streaming Anthropic Messages request and return Anthropic SSE frames.
|
/// Execute a streaming Anthropic Messages request and return Anthropic SSE frames.
|
||||||
pub async fn execute_anthropic_messages_stream(
|
pub async fn execute_anthropic_messages_stream(
|
||||||
|
state: AppState,
|
||||||
route: UpstreamRoute,
|
route: UpstreamRoute,
|
||||||
request: CanonicalRequest,
|
request: CanonicalRequest,
|
||||||
) -> AppResult<Vec<String>> {
|
) -> AppResult<Vec<String>> {
|
||||||
let client = http_client();
|
let response = execute_anthropic_messages_stream_response(state, route, request).await?;
|
||||||
|
collect_anthropic_stream_frames(response).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a streaming Anthropic Messages request and return the live upstream response.
|
||||||
|
pub async fn execute_anthropic_messages_stream_response(
|
||||||
|
state: AppState,
|
||||||
|
route: UpstreamRoute,
|
||||||
|
request: CanonicalRequest,
|
||||||
|
) -> AppResult<reqwest::Response> {
|
||||||
|
let client = state.http.clone();
|
||||||
let upstream_url = format!("{}/messages", route.base_url.trim_end_matches('/'));
|
let upstream_url = format!("{}/messages", route.base_url.trim_end_matches('/'));
|
||||||
let compatibility = AnthropicMessagesCompatibility::default();
|
let compatibility = AnthropicMessagesCompatibility::default();
|
||||||
let body = format_messages_request_with_compat(&request, &compatibility);
|
let body = format_messages_request_with_compat(&request, &compatibility);
|
||||||
@@ -292,6 +302,10 @@ pub async fn execute_anthropic_messages_stream(
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn collect_anthropic_stream_frames(resp: reqwest::Response) -> AppResult<Vec<String>> {
|
||||||
let mut frames = Vec::new();
|
let mut frames = Vec::new();
|
||||||
let mut stream = resp.bytes_stream();
|
let mut stream = resp.bytes_stream();
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
@@ -406,32 +420,12 @@ fn denormalize_anthropic_stop_reason(reason: &str) -> &str {
|
|||||||
/// Execute a streaming request against any upstream path (e.g. /responses).
|
/// Execute a streaming request against any upstream path (e.g. /responses).
|
||||||
/// Returns SSE `data: ...` lines verbatim.
|
/// Returns SSE `data: ...` lines verbatim.
|
||||||
pub async fn execute_streaming_request(
|
pub async fn execute_streaming_request(
|
||||||
|
state: AppState,
|
||||||
route: UpstreamRoute,
|
route: UpstreamRoute,
|
||||||
path_suffix: &str,
|
path_suffix: &str,
|
||||||
body: Value,
|
body: Value,
|
||||||
) -> AppResult<Vec<String>> {
|
) -> AppResult<Vec<String>> {
|
||||||
let client = http_client();
|
let resp = execute_streaming_response(state, route, path_suffix, body).await?;
|
||||||
let upstream_url = upstream_url(&route.base_url, path_suffix);
|
|
||||||
|
|
||||||
let resp = client
|
|
||||||
.post(&upstream_url)
|
|
||||||
.header("Authorization", format!("Bearer {}", route.api_key))
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.json(&body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::UpstreamError(format!("Upstream request failed: {e}")))?;
|
|
||||||
|
|
||||||
let status = resp.status();
|
|
||||||
if !status.is_success() {
|
|
||||||
let text = resp
|
|
||||||
.text()
|
|
||||||
.await
|
|
||||||
.unwrap_or_else(|_| "unknown error".to_string());
|
|
||||||
return Err(AppError::UpstreamError(format!(
|
|
||||||
"Upstream returned {status}: {text}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut chunks = Vec::new();
|
let mut chunks = Vec::new();
|
||||||
let mut stream = resp.bytes_stream();
|
let mut stream = resp.bytes_stream();
|
||||||
@@ -455,14 +449,50 @@ pub async fn execute_streaming_request(
|
|||||||
Ok(chunks)
|
Ok(chunks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Execute a streaming request against any upstream path and return the live response.
|
||||||
|
///
|
||||||
|
/// The caller owns draining `bytes_stream()` so bytes can be flushed to the
|
||||||
|
/// downstream client as soon as the upstream sends them.
|
||||||
|
pub async fn execute_streaming_response(
|
||||||
|
state: AppState,
|
||||||
|
route: UpstreamRoute,
|
||||||
|
path_suffix: &str,
|
||||||
|
body: Value,
|
||||||
|
) -> AppResult<reqwest::Response> {
|
||||||
|
let client = state.http.clone();
|
||||||
|
let upstream_url = upstream_url(&route.base_url, path_suffix);
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.post(&upstream_url)
|
||||||
|
.header("Authorization", format!("Bearer {}", route.api_key))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::UpstreamError(format!("Upstream request failed: {e}")))?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let text = resp
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| "unknown error".to_string());
|
||||||
|
return Err(AppError::UpstreamError(format!(
|
||||||
|
"Upstream returned {status}: {text}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
/// Generic non-streaming proxy request to any upstream endpoint path.
|
/// Generic non-streaming proxy request to any upstream endpoint path.
|
||||||
pub async fn execute_proxy_request(
|
pub async fn execute_proxy_request(
|
||||||
_state: AppState,
|
state: AppState,
|
||||||
route: UpstreamRoute,
|
route: UpstreamRoute,
|
||||||
path_suffix: &str,
|
path_suffix: &str,
|
||||||
body: Value,
|
body: Value,
|
||||||
) -> AppResult<Value> {
|
) -> AppResult<Value> {
|
||||||
let client = http_client();
|
let client = state.http.clone();
|
||||||
let upstream_url = upstream_url(&route.base_url, path_suffix);
|
let upstream_url = upstream_url(&route.base_url, path_suffix);
|
||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
@@ -492,8 +522,12 @@ pub async fn execute_proxy_request(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Generic GET proxy request to an upstream endpoint path.
|
/// Generic GET proxy request to an upstream endpoint path.
|
||||||
pub async fn execute_proxy_get(route: UpstreamRoute, path_suffix: &str) -> AppResult<Value> {
|
pub async fn execute_proxy_get(
|
||||||
let client = http_client();
|
state: AppState,
|
||||||
|
route: UpstreamRoute,
|
||||||
|
path_suffix: &str,
|
||||||
|
) -> AppResult<Value> {
|
||||||
|
let client = state.http.clone();
|
||||||
let upstream_url = upstream_url(&route.base_url, path_suffix);
|
let upstream_url = upstream_url(&route.base_url, path_suffix);
|
||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
@@ -532,7 +566,7 @@ pub async fn execute_video_task_status(
|
|||||||
} else {
|
} else {
|
||||||
format!("/videos/{id}")
|
format!("/videos/{id}")
|
||||||
};
|
};
|
||||||
execute_proxy_get(route, &suffix).await
|
execute_proxy_get(state, route, &suffix).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute an OpenAI-shaped video task request, with Volcengine Ark native
|
/// Execute an OpenAI-shaped video task request, with Volcengine Ark native
|
||||||
@@ -929,7 +963,7 @@ async fn comfy_json(
|
|||||||
path: &str,
|
path: &str,
|
||||||
body: Option<Value>,
|
body: Option<Value>,
|
||||||
) -> AppResult<Value> {
|
) -> AppResult<Value> {
|
||||||
let client = http_client();
|
let client = state.http.clone();
|
||||||
let mut request = client.request(method, comfy_native_url(state, path));
|
let mut request = client.request(method, comfy_native_url(state, path));
|
||||||
if let Some(api_key) = state
|
if let Some(api_key) = state
|
||||||
.config
|
.config
|
||||||
@@ -963,16 +997,44 @@ async fn comfy_json(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn comfy_history(state: &AppState, prompt_id: &str) -> AppResult<Value> {
|
async fn comfy_history(state: &AppState, prompt_id: &str) -> AppResult<Value> {
|
||||||
let history = comfy_json(state, reqwest::Method::GET, &format!("/history/{prompt_id}"), None).await?;
|
let path = if state.config.comfyui.uses_cloud_backend() {
|
||||||
|
format!("/jobs/{prompt_id}")
|
||||||
|
} else {
|
||||||
|
format!("/history/{prompt_id}")
|
||||||
|
};
|
||||||
|
let history = comfy_json(state, reqwest::Method::GET, &path, None).await?;
|
||||||
|
let failed = comfy_failure_message(&history);
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"id": prompt_id,
|
"id": prompt_id,
|
||||||
"object": "video.generation.task",
|
"object": "video.generation.task",
|
||||||
"backend": "comfy",
|
"backend": "comfy",
|
||||||
"status": if comfy_output_objects(&history) { "succeeded" } else { "running" },
|
"status": if failed.is_some() {
|
||||||
|
"failed"
|
||||||
|
} else if comfy_output_objects(&history) {
|
||||||
|
"succeeded"
|
||||||
|
} else {
|
||||||
|
"running"
|
||||||
|
},
|
||||||
|
"error": failed,
|
||||||
"history": history,
|
"history": history,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn comfy_failure_message(value: &Value) -> Option<String> {
|
||||||
|
let status = scalar_string(value.get("status").or_else(|| value.get("state")))
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
if matches!(status.as_str(), "failed" | "error" | "cancelled" | "canceled") {
|
||||||
|
return Some(
|
||||||
|
scalar_string(value.pointer("/execution_error/exception_message"))
|
||||||
|
.or_else(|| scalar_string(value.get("error")))
|
||||||
|
.or_else(|| scalar_string(value.get("message")))
|
||||||
|
.unwrap_or_else(|| format!("Comfy job {status}")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
scalar_string(value.pointer("/execution_error/exception_message"))
|
||||||
|
}
|
||||||
|
|
||||||
fn comfy_output_objects(value: &Value) -> bool {
|
fn comfy_output_objects(value: &Value) -> bool {
|
||||||
match value {
|
match value {
|
||||||
Value::Object(object) => {
|
Value::Object(object) => {
|
||||||
@@ -988,11 +1050,11 @@ fn comfy_output_objects(value: &Value) -> bool {
|
|||||||
|
|
||||||
/// Execute an image generation request.
|
/// Execute an image generation request.
|
||||||
pub async fn execute_image_generation(
|
pub async fn execute_image_generation(
|
||||||
_state: AppState,
|
state: AppState,
|
||||||
route: UpstreamRoute,
|
route: UpstreamRoute,
|
||||||
body: Value,
|
body: Value,
|
||||||
) -> AppResult<Value> {
|
) -> AppResult<Value> {
|
||||||
let client = http_client();
|
let client = state.http.clone();
|
||||||
let upstream_url = upstream_url(&route.base_url, "/images/generations");
|
let upstream_url = upstream_url(&route.base_url, "/images/generations");
|
||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
@@ -1045,13 +1107,13 @@ pub struct MultipartField {
|
|||||||
/// the route-resolved upstream model and skip any client-side fields
|
/// the route-resolved upstream model and skip any client-side fields
|
||||||
/// that don't belong on the wire.
|
/// that don't belong on the wire.
|
||||||
pub async fn execute_image_edit(
|
pub async fn execute_image_edit(
|
||||||
_state: AppState,
|
state: AppState,
|
||||||
route: UpstreamRoute,
|
route: UpstreamRoute,
|
||||||
fields: Vec<MultipartField>,
|
fields: Vec<MultipartField>,
|
||||||
) -> AppResult<Value> {
|
) -> AppResult<Value> {
|
||||||
use reqwest::multipart::{Form, Part};
|
use reqwest::multipart::{Form, Part};
|
||||||
|
|
||||||
let client = http_client();
|
let client = state.http.clone();
|
||||||
let upstream_url = upstream_url(&route.base_url, "/images/edits");
|
let upstream_url = upstream_url(&route.base_url, "/images/edits");
|
||||||
|
|
||||||
let mut form = Form::new();
|
let mut form = Form::new();
|
||||||
|
|||||||
@@ -2,16 +2,52 @@ use super::types::{RouteDecision, RouteMatch};
|
|||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::proxy::conductor::{FailureInfo, SelectedChannel, SuccessMetrics};
|
use crate::proxy::conductor::{FailureInfo, SelectedChannel, SuccessMetrics};
|
||||||
use crate::proxy::executors::UpstreamRoute;
|
use crate::proxy::executors::UpstreamRoute;
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use parking_lot::RwLock;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use sqlx::{FromRow, SqlitePool};
|
use sqlx::{FromRow, SqlitePool};
|
||||||
use std::sync::Arc;
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
sync::Arc,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROUTE_CACHE_TTL: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
static ROUTE_ROWS_CACHE: Lazy<RwLock<Option<TimedCache<Vec<RouteRow>>>>> =
|
||||||
|
Lazy::new(|| RwLock::new(None));
|
||||||
|
static CHANNEL_ROWS_CACHE: Lazy<RwLock<HashMap<i64, TimedCache<Vec<ChannelRow>>>>> =
|
||||||
|
Lazy::new(|| RwLock::new(HashMap::new()));
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct TimedCache<T> {
|
||||||
|
loaded_at: Instant,
|
||||||
|
value: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> TimedCache<T> {
|
||||||
|
fn fresh(&self) -> bool {
|
||||||
|
self.loaded_at.elapsed() <= ROUTE_CACHE_TTL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn invalidate_route_cache() {
|
||||||
|
*ROUTE_ROWS_CACHE.write() = None;
|
||||||
|
CHANNEL_ROWS_CACHE.write().clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalidate_channel_cache_for_channel(channel_id: i64) {
|
||||||
|
CHANNEL_ROWS_CACHE
|
||||||
|
.write()
|
||||||
|
.retain(|_, cache| !cache.value.iter().any(|row| row.channel_id == channel_id));
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct TokenRouter {
|
pub struct TokenRouter {
|
||||||
db: Arc<SqlitePool>,
|
db: Arc<SqlitePool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, FromRow)]
|
#[derive(Debug, Clone, FromRow)]
|
||||||
struct RouteRow {
|
struct RouteRow {
|
||||||
id: i64,
|
id: i64,
|
||||||
model_pattern: String,
|
model_pattern: String,
|
||||||
@@ -19,7 +55,7 @@ struct RouteRow {
|
|||||||
route_mode: Option<String>,
|
route_mode: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, FromRow)]
|
#[derive(Debug, Clone, FromRow)]
|
||||||
struct ChannelRow {
|
struct ChannelRow {
|
||||||
channel_id: i64,
|
channel_id: i64,
|
||||||
route_id: i64,
|
route_id: i64,
|
||||||
@@ -91,14 +127,7 @@ impl TokenRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn match_route(&self, model: &str) -> AppResult<Option<(RouteRow, RouteMatch)>> {
|
async fn match_route(&self, model: &str) -> AppResult<Option<(RouteRow, RouteMatch)>> {
|
||||||
let rows: Vec<RouteRow> = sqlx::query_as(
|
let rows = self.route_rows().await?;
|
||||||
"SELECT id, model_pattern, model_mapping, route_mode
|
|
||||||
FROM token_routes
|
|
||||||
WHERE enabled = 1
|
|
||||||
ORDER BY id",
|
|
||||||
)
|
|
||||||
.fetch_all(&*self.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let mut best: Option<(RouteRow, RouteMatch, usize, usize)> = None;
|
let mut best: Option<(RouteRow, RouteMatch, usize, usize)> = None;
|
||||||
for row in rows {
|
for row in rows {
|
||||||
@@ -131,48 +160,7 @@ impl TokenRouter {
|
|||||||
requested_model: &str,
|
requested_model: &str,
|
||||||
exclude_channel_ids: &[i64],
|
exclude_channel_ids: &[i64],
|
||||||
) -> AppResult<Option<SelectedChannel>> {
|
) -> AppResult<Option<SelectedChannel>> {
|
||||||
let rows: Vec<ChannelRow> = sqlx::query_as(
|
let rows = self.channel_rows(route.id).await?;
|
||||||
"SELECT
|
|
||||||
rc.id AS channel_id,
|
|
||||||
rc.route_id AS route_id,
|
|
||||||
rc.account_id AS account_id,
|
|
||||||
a.site_id AS site_id,
|
|
||||||
COALESCE((
|
|
||||||
SELECT sae.url
|
|
||||||
FROM site_api_endpoints sae
|
|
||||||
WHERE sae.site_id = s.id
|
|
||||||
AND COALESCE(sae.enabled, 1) = 1
|
|
||||||
AND (sae.cooldown_until IS NULL OR sae.cooldown_until <= datetime('now'))
|
|
||||||
ORDER BY COALESCE(sae.sort_order, 0), sae.id
|
|
||||||
LIMIT 1
|
|
||||||
), s.url) AS base_url,
|
|
||||||
s.api_key AS site_api_key,
|
|
||||||
a.access_token AS account_access_token,
|
|
||||||
a.api_token AS account_api_token,
|
|
||||||
a.unit_cost AS account_unit_cost,
|
|
||||||
at.name AS token_name,
|
|
||||||
at.token AS token_value,
|
|
||||||
rc.source_model AS source_model
|
|
||||||
FROM route_channels rc
|
|
||||||
JOIN accounts a ON a.id = rc.account_id
|
|
||||||
JOIN sites s ON s.id = a.site_id
|
|
||||||
LEFT JOIN account_tokens at ON at.id = rc.token_id AND at.enabled = 1
|
|
||||||
WHERE rc.route_id = ?
|
|
||||||
AND rc.enabled = 1
|
|
||||||
AND a.status = 'active'
|
|
||||||
AND s.status = 'active'
|
|
||||||
AND (rc.cooldown_until IS NULL OR rc.cooldown_until <= datetime('now'))
|
|
||||||
AND (rc.token_id IS NULL OR at.id IS NOT NULL)
|
|
||||||
ORDER BY
|
|
||||||
COALESCE(rc.priority, 0) DESC,
|
|
||||||
rc.consecutive_fail_count ASC,
|
|
||||||
COALESCE(rc.weight, 10) DESC,
|
|
||||||
rc.last_selected_at IS NOT NULL,
|
|
||||||
rc.id ASC",
|
|
||||||
)
|
|
||||||
.bind(route.id)
|
|
||||||
.fetch_all(&*self.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let row = rows
|
let row = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -214,6 +202,89 @@ impl TokenRouter {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn route_rows(&self) -> AppResult<Vec<RouteRow>> {
|
||||||
|
if let Some(cache) = ROUTE_ROWS_CACHE.read().as_ref() {
|
||||||
|
if cache.fresh() {
|
||||||
|
return Ok(cache.value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows: Vec<RouteRow> = sqlx::query_as(
|
||||||
|
"SELECT id, model_pattern, model_mapping, route_mode
|
||||||
|
FROM token_routes
|
||||||
|
WHERE enabled = 1
|
||||||
|
ORDER BY id",
|
||||||
|
)
|
||||||
|
.fetch_all(&*self.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
*ROUTE_ROWS_CACHE.write() = Some(TimedCache {
|
||||||
|
loaded_at: Instant::now(),
|
||||||
|
value: rows.clone(),
|
||||||
|
});
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn channel_rows(&self, route_id: i64) -> AppResult<Vec<ChannelRow>> {
|
||||||
|
if let Some(cache) = CHANNEL_ROWS_CACHE.read().get(&route_id) {
|
||||||
|
if cache.fresh() {
|
||||||
|
return Ok(cache.value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows: Vec<ChannelRow> = sqlx::query_as(
|
||||||
|
"SELECT
|
||||||
|
rc.id AS channel_id,
|
||||||
|
rc.route_id AS route_id,
|
||||||
|
rc.account_id AS account_id,
|
||||||
|
a.site_id AS site_id,
|
||||||
|
COALESCE((
|
||||||
|
SELECT sae.url
|
||||||
|
FROM site_api_endpoints sae
|
||||||
|
WHERE sae.site_id = s.id
|
||||||
|
AND COALESCE(sae.enabled, 1) = 1
|
||||||
|
AND (sae.cooldown_until IS NULL OR sae.cooldown_until <= datetime('now'))
|
||||||
|
ORDER BY COALESCE(sae.sort_order, 0), sae.id
|
||||||
|
LIMIT 1
|
||||||
|
), s.url) AS base_url,
|
||||||
|
s.api_key AS site_api_key,
|
||||||
|
a.access_token AS account_access_token,
|
||||||
|
a.api_token AS account_api_token,
|
||||||
|
a.unit_cost AS account_unit_cost,
|
||||||
|
at.name AS token_name,
|
||||||
|
at.token AS token_value,
|
||||||
|
rc.source_model AS source_model
|
||||||
|
FROM route_channels rc
|
||||||
|
JOIN accounts a ON a.id = rc.account_id
|
||||||
|
JOIN sites s ON s.id = a.site_id
|
||||||
|
LEFT JOIN account_tokens at ON at.id = rc.token_id AND at.enabled = 1
|
||||||
|
WHERE rc.route_id = ?
|
||||||
|
AND rc.enabled = 1
|
||||||
|
AND a.status = 'active'
|
||||||
|
AND s.status = 'active'
|
||||||
|
AND (rc.cooldown_until IS NULL OR rc.cooldown_until <= datetime('now'))
|
||||||
|
AND (rc.token_id IS NULL OR at.id IS NOT NULL)
|
||||||
|
ORDER BY
|
||||||
|
COALESCE(rc.priority, 0) DESC,
|
||||||
|
rc.consecutive_fail_count ASC,
|
||||||
|
COALESCE(rc.weight, 10) DESC,
|
||||||
|
rc.last_selected_at IS NOT NULL,
|
||||||
|
rc.id ASC",
|
||||||
|
)
|
||||||
|
.bind(route_id)
|
||||||
|
.fetch_all(&*self.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
CHANNEL_ROWS_CACHE.write().insert(
|
||||||
|
route_id,
|
||||||
|
TimedCache {
|
||||||
|
loaded_at: Instant::now(),
|
||||||
|
value: rows.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn record_success(&self, channel_id: i64, metrics: SuccessMetrics) -> AppResult<()> {
|
pub async fn record_success(&self, channel_id: i64, metrics: SuccessMetrics) -> AppResult<()> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE route_channels
|
"UPDATE route_channels
|
||||||
@@ -232,6 +303,7 @@ impl TokenRouter {
|
|||||||
.bind(channel_id)
|
.bind(channel_id)
|
||||||
.execute(&*self.db)
|
.execute(&*self.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
invalidate_channel_cache_for_channel(channel_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,6 +326,7 @@ impl TokenRouter {
|
|||||||
.bind(channel_id)
|
.bind(channel_id)
|
||||||
.execute(&*self.db)
|
.execute(&*self.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
invalidate_channel_cache_for_channel(channel_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -359,6 +432,7 @@ mod tests {
|
|||||||
site_id INTEGER NOT NULL,
|
site_id INTEGER NOT NULL,
|
||||||
access_token TEXT NOT NULL,
|
access_token TEXT NOT NULL,
|
||||||
api_token TEXT,
|
api_token TEXT,
|
||||||
|
unit_cost REAL,
|
||||||
status TEXT NOT NULL DEFAULT 'active'
|
status TEXT NOT NULL DEFAULT 'active'
|
||||||
)",
|
)",
|
||||||
)
|
)
|
||||||
|
|||||||
+357
-7
@@ -1,6 +1,7 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
db::models::{route_channel::RouteChannel, token_route::TokenRoute},
|
db::models::{route_channel::RouteChannel, token_route::TokenRoute},
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
|
services::notification::NotificationService,
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -12,6 +13,7 @@ use chrono::{DateTime, Duration, Utc};
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
@@ -286,6 +288,7 @@ async fn create_route(
|
|||||||
.bind(result.last_insert_rowid())
|
.bind(result.last_insert_rowid())
|
||||||
.fetch_one(&*state.db)
|
.fetch_one(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
crate::proxy::token_router::router::invalidate_route_cache();
|
||||||
Ok(Json(json!({ "route": route, "data": route })))
|
Ok(Json(json!({ "route": route, "data": route })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,6 +321,7 @@ async fn update_route(
|
|||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_one(&*state.db)
|
.fetch_one(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
crate::proxy::token_router::router::invalidate_route_cache();
|
||||||
Ok(Json(json!({ "route": route, "data": route })))
|
Ok(Json(json!({ "route": route, "data": route })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,6 +333,7 @@ async fn delete_route(
|
|||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&*state.db)
|
.execute(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
crate::proxy::token_router::router::invalidate_route_cache();
|
||||||
Ok(Json(
|
Ok(Json(
|
||||||
json!({ "success": true, "data": { "deleted": true, "id": id } }),
|
json!({ "success": true, "data": { "deleted": true, "id": id } }),
|
||||||
))
|
))
|
||||||
@@ -373,6 +378,7 @@ async fn add_channel(
|
|||||||
.bind(result.last_insert_rowid())
|
.bind(result.last_insert_rowid())
|
||||||
.fetch_one(&*state.db)
|
.fetch_one(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
crate::proxy::token_router::router::invalidate_route_cache();
|
||||||
Ok(Json(json!({ "channel": channel, "data": channel })))
|
Ok(Json(json!({ "channel": channel, "data": channel })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,6 +405,9 @@ async fn batch_add_channels(
|
|||||||
created += 1;
|
created += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if created > 0 {
|
||||||
|
crate::proxy::token_router::router::invalidate_route_cache();
|
||||||
|
}
|
||||||
Ok(Json(json!({ "success": true, "created": created })))
|
Ok(Json(json!({ "success": true, "created": created })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,6 +426,7 @@ async fn update_channel(
|
|||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&*state.db)
|
.execute(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
crate::proxy::token_router::router::invalidate_route_cache();
|
||||||
Ok(Json(json!({ "success": true })))
|
Ok(Json(json!({ "success": true })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,6 +438,7 @@ async fn delete_channel(
|
|||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&*state.db)
|
.execute(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
crate::proxy::token_router::router::invalidate_route_cache();
|
||||||
Ok(Json(json!({ "success": true })))
|
Ok(Json(json!({ "success": true })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,6 +459,7 @@ async fn batch_update_channels(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
crate::proxy::token_router::router::invalidate_route_cache();
|
||||||
Ok(Json(json!({ "success": true })))
|
Ok(Json(json!({ "success": true })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,6 +471,7 @@ async fn clear_route_cooldown(
|
|||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&*state.db)
|
.execute(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
crate::proxy::token_router::router::invalidate_route_cache();
|
||||||
Ok(Json(json!({ "success": true })))
|
Ok(Json(json!({ "success": true })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,6 +513,7 @@ async fn batch_routes(
|
|||||||
.execute(&*state.db)
|
.execute(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
crate::proxy::token_router::router::invalidate_route_cache();
|
||||||
}
|
}
|
||||||
Ok(Json(json!({ "success": true })))
|
Ok(Json(json!({ "success": true })))
|
||||||
}
|
}
|
||||||
@@ -914,6 +928,88 @@ fn mask_downstream_key(key: &str) -> String {
|
|||||||
format!("••••••••{}", last4)
|
format!("••••••••{}", last4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mask_optional_secret(value: Option<&str>) -> Option<String> {
|
||||||
|
let value = value?.trim();
|
||||||
|
if value.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let chars: Vec<char> = value.chars().collect();
|
||||||
|
let keep = chars.len().min(4);
|
||||||
|
let suffix: String = chars[chars.len().saturating_sub(keep)..].iter().collect();
|
||||||
|
Some(format!("••••{}", suffix))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_settings_map(
|
||||||
|
state: &AppState,
|
||||||
|
prefix: Option<&str>,
|
||||||
|
) -> AppResult<HashMap<String, String>> {
|
||||||
|
let rows: Vec<(String, Option<String>)> = match prefix {
|
||||||
|
Some(prefix) => sqlx::query_as("SELECT key, value FROM settings WHERE key LIKE ?")
|
||||||
|
.bind(format!("{prefix}%"))
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?,
|
||||||
|
None => sqlx::query_as("SELECT key, value FROM settings")
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?,
|
||||||
|
};
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(key, value)| value.map(|value| (key, value)))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setting_string(map: &HashMap<String, String>, key: &str) -> Option<String> {
|
||||||
|
map.get(key)
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setting_bool(map: &HashMap<String, String>, key: &str, default: bool) -> bool {
|
||||||
|
map.get(key)
|
||||||
|
.map(|value| parse_runtime_bool(value))
|
||||||
|
.unwrap_or(default)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setting_i64(map: &HashMap<String, String>, key: &str, default: i64) -> i64 {
|
||||||
|
map.get(key)
|
||||||
|
.and_then(|value| value.trim().parse::<i64>().ok())
|
||||||
|
.unwrap_or(default)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_runtime_bool(value: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
value.trim().to_ascii_lowercase().as_str(),
|
||||||
|
"1" | "true" | "yes" | "on" | "enabled"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upsert_setting(state: &AppState, key: &str, value: impl Into<String>) -> AppResult<()> {
|
||||||
|
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
||||||
|
.bind(key)
|
||||||
|
.bind(value.into())
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upsert_optional_secret(
|
||||||
|
state: &AppState,
|
||||||
|
key: &str,
|
||||||
|
value: Option<String>,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let Some(value) = value.map(|value| value.trim().to_string()) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if value.is_empty() || value.chars().all(|ch| ch == '•' || ch == '*') {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
upsert_setting(state, key, value).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bool_setting_value(value: bool) -> &'static str {
|
||||||
|
if value { "true" } else { "false" }
|
||||||
|
}
|
||||||
|
|
||||||
fn json_text_array(raw: Option<&str>) -> Vec<Value> {
|
fn json_text_array(raw: Option<&str>) -> Vec<Value> {
|
||||||
let Some(raw) = raw.map(str::trim).filter(|value| !value.is_empty()) else {
|
let Some(raw) = raw.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -964,8 +1060,45 @@ async fn change_auth_token() -> Json<Value> {
|
|||||||
Json(json!({ "success": false, "message": "auth token is managed by config.toml" }))
|
Json(json!({ "success": false, "message": "auth token is managed by config.toml" }))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn runtime_settings(State(state): State<AppState>) -> Json<Value> {
|
async fn runtime_settings(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
Json(json!({
|
let settings = load_settings_map(&state, Some("notify_")).await?;
|
||||||
|
let smtp_port = setting_i64(&settings, "notify_smtp_port", 587).clamp(1, 65_535);
|
||||||
|
let has_feishu = setting_string(&settings, "notify_feishu_webhook").is_some();
|
||||||
|
let has_serverchan = setting_string(&settings, "notify_serverchan_key").is_some();
|
||||||
|
let has_telegram = setting_string(&settings, "notify_telegram_bot_token").is_some()
|
||||||
|
&& setting_string(&settings, "notify_telegram_chat_id").is_some();
|
||||||
|
let has_smtp = setting_string(&settings, "notify_smtp_host").is_some()
|
||||||
|
&& setting_string(&settings, "notify_smtp_user").is_some()
|
||||||
|
&& setting_string(&settings, "notify_smtp_pass").is_some()
|
||||||
|
&& setting_string(&settings, "notify_smtp_from").is_some();
|
||||||
|
Ok(Json(json!({
|
||||||
|
"proxyTokenMasked": state.runtime_auth.proxy_token_masked(),
|
||||||
|
"proxyTokenSource": if state.runtime_auth.using_default_proxy_token() { "config" } else { "runtime" },
|
||||||
|
"webhookEnabled": setting_bool(&settings, "notify_webhook_enabled", true),
|
||||||
|
"webhookUrl": setting_string(&settings, "notify_webhook_url").unwrap_or_default(),
|
||||||
|
"barkEnabled": setting_bool(&settings, "notify_bark_enabled", true),
|
||||||
|
"barkUrl": setting_string(&settings, "notify_bark_url").unwrap_or_default(),
|
||||||
|
"feishuEnabled": setting_bool(&settings, "notify_feishu_enabled", has_feishu),
|
||||||
|
"feishuWebhook": "",
|
||||||
|
"feishuWebhookMasked": mask_optional_secret(setting_string(&settings, "notify_feishu_webhook").as_deref()),
|
||||||
|
"serverChanEnabled": setting_bool(&settings, "notify_serverchan_enabled", has_serverchan),
|
||||||
|
"serverChanKeyMasked": mask_optional_secret(setting_string(&settings, "notify_serverchan_key").as_deref()),
|
||||||
|
"telegramEnabled": setting_bool(&settings, "notify_telegram_enabled", has_telegram),
|
||||||
|
"telegramApiBaseUrl": setting_string(&settings, "notify_telegram_api_base_url")
|
||||||
|
.unwrap_or_else(|| "https://api.telegram.org".to_string()),
|
||||||
|
"telegramBotTokenMasked": mask_optional_secret(setting_string(&settings, "notify_telegram_bot_token").as_deref()),
|
||||||
|
"telegramChatId": setting_string(&settings, "notify_telegram_chat_id").unwrap_or_default(),
|
||||||
|
"telegramUseSystemProxy": setting_bool(&settings, "notify_telegram_use_system_proxy", false),
|
||||||
|
"telegramMessageThreadId": setting_string(&settings, "notify_telegram_thread_id").unwrap_or_default(),
|
||||||
|
"smtpEnabled": setting_bool(&settings, "notify_smtp_enabled", has_smtp),
|
||||||
|
"smtpHost": setting_string(&settings, "notify_smtp_host").unwrap_or_default(),
|
||||||
|
"smtpPort": smtp_port,
|
||||||
|
"smtpSecure": setting_bool(&settings, "notify_smtp_secure", true),
|
||||||
|
"smtpUser": setting_string(&settings, "notify_smtp_user").unwrap_or_default(),
|
||||||
|
"smtpPassMasked": mask_optional_secret(setting_string(&settings, "notify_smtp_pass").as_deref()),
|
||||||
|
"smtpFrom": setting_string(&settings, "notify_smtp_from").unwrap_or_default(),
|
||||||
|
"smtpTo": setting_string(&settings, "notify_smtp_to").unwrap_or_default(),
|
||||||
|
"notifyCooldownSec": setting_i64(&settings, "notify_cooldown_sec", 300).max(0),
|
||||||
"server": {
|
"server": {
|
||||||
"host": state.config.server.host,
|
"host": state.config.server.host,
|
||||||
"port": state.config.server.port,
|
"port": state.config.server.port,
|
||||||
@@ -986,11 +1119,137 @@ async fn runtime_settings(State(state): State<AppState>) -> Json<Value> {
|
|||||||
"externalUrl": state.config.comfyui.external_url,
|
"externalUrl": state.config.comfyui.external_url,
|
||||||
"agentModel": state.config.comfyui.agent_model,
|
"agentModel": state.config.comfyui.agent_model,
|
||||||
},
|
},
|
||||||
}))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_runtime_settings() -> Json<Value> {
|
#[derive(Debug, Default, Deserialize)]
|
||||||
Json(json!({ "success": true }))
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct RuntimeSettingsUpdateRequest {
|
||||||
|
proxy_token: Option<String>,
|
||||||
|
webhook_url: Option<String>,
|
||||||
|
bark_url: Option<String>,
|
||||||
|
webhook_enabled: Option<bool>,
|
||||||
|
bark_enabled: Option<bool>,
|
||||||
|
feishu_enabled: Option<bool>,
|
||||||
|
feishu_webhook: Option<String>,
|
||||||
|
server_chan_enabled: Option<bool>,
|
||||||
|
server_chan_key: Option<String>,
|
||||||
|
telegram_enabled: Option<bool>,
|
||||||
|
telegram_api_base_url: Option<String>,
|
||||||
|
telegram_bot_token: Option<String>,
|
||||||
|
telegram_chat_id: Option<String>,
|
||||||
|
telegram_use_system_proxy: Option<bool>,
|
||||||
|
telegram_message_thread_id: Option<String>,
|
||||||
|
smtp_enabled: Option<bool>,
|
||||||
|
smtp_host: Option<String>,
|
||||||
|
smtp_port: Option<i64>,
|
||||||
|
smtp_secure: Option<bool>,
|
||||||
|
smtp_user: Option<String>,
|
||||||
|
smtp_pass: Option<String>,
|
||||||
|
smtp_from: Option<String>,
|
||||||
|
smtp_to: Option<String>,
|
||||||
|
notify_cooldown_sec: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_runtime_settings(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<RuntimeSettingsUpdateRequest>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
if let Some(proxy_token) = req.proxy_token {
|
||||||
|
let updated = state
|
||||||
|
.runtime_auth
|
||||||
|
.set_proxy_token(&state.db, proxy_token)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::BadRequest(err.to_string()))?;
|
||||||
|
return Ok(Json(json!({
|
||||||
|
"success": true,
|
||||||
|
"proxyTokenMasked": crate::services::runtime_auth::mask_token(&updated),
|
||||||
|
"proxyTokenSource": if state.runtime_auth.using_default_proxy_token() { "config" } else { "runtime" },
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(enabled) = req.webhook_enabled {
|
||||||
|
upsert_setting(&state, "notify_webhook_enabled", bool_setting_value(enabled)).await?;
|
||||||
|
}
|
||||||
|
if let Some(value) = req.webhook_url {
|
||||||
|
upsert_setting(&state, "notify_webhook_url", value.trim().to_string()).await?;
|
||||||
|
}
|
||||||
|
if let Some(enabled) = req.bark_enabled {
|
||||||
|
upsert_setting(&state, "notify_bark_enabled", bool_setting_value(enabled)).await?;
|
||||||
|
}
|
||||||
|
if let Some(value) = req.bark_url {
|
||||||
|
upsert_setting(&state, "notify_bark_url", value.trim().to_string()).await?;
|
||||||
|
}
|
||||||
|
if let Some(enabled) = req.feishu_enabled {
|
||||||
|
upsert_setting(&state, "notify_feishu_enabled", bool_setting_value(enabled)).await?;
|
||||||
|
}
|
||||||
|
upsert_optional_secret(&state, "notify_feishu_webhook", req.feishu_webhook).await?;
|
||||||
|
if let Some(enabled) = req.server_chan_enabled {
|
||||||
|
upsert_setting(
|
||||||
|
&state,
|
||||||
|
"notify_serverchan_enabled",
|
||||||
|
bool_setting_value(enabled),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
upsert_optional_secret(&state, "notify_serverchan_key", req.server_chan_key).await?;
|
||||||
|
if let Some(enabled) = req.telegram_enabled {
|
||||||
|
upsert_setting(
|
||||||
|
&state,
|
||||||
|
"notify_telegram_enabled",
|
||||||
|
bool_setting_value(enabled),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
if let Some(value) = req.telegram_api_base_url {
|
||||||
|
upsert_setting(&state, "notify_telegram_api_base_url", value.trim().to_string()).await?;
|
||||||
|
}
|
||||||
|
upsert_optional_secret(&state, "notify_telegram_bot_token", req.telegram_bot_token).await?;
|
||||||
|
if let Some(value) = req.telegram_chat_id {
|
||||||
|
upsert_setting(&state, "notify_telegram_chat_id", value.trim().to_string()).await?;
|
||||||
|
}
|
||||||
|
if let Some(enabled) = req.telegram_use_system_proxy {
|
||||||
|
upsert_setting(
|
||||||
|
&state,
|
||||||
|
"notify_telegram_use_system_proxy",
|
||||||
|
bool_setting_value(enabled),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
if let Some(value) = req.telegram_message_thread_id {
|
||||||
|
upsert_setting(&state, "notify_telegram_thread_id", value.trim().to_string()).await?;
|
||||||
|
}
|
||||||
|
if let Some(enabled) = req.smtp_enabled {
|
||||||
|
upsert_setting(&state, "notify_smtp_enabled", bool_setting_value(enabled)).await?;
|
||||||
|
}
|
||||||
|
if let Some(value) = req.smtp_host {
|
||||||
|
upsert_setting(&state, "notify_smtp_host", value.trim().to_string()).await?;
|
||||||
|
}
|
||||||
|
if let Some(value) = req.smtp_port {
|
||||||
|
upsert_setting(&state, "notify_smtp_port", value.clamp(1, 65_535).to_string()).await?;
|
||||||
|
}
|
||||||
|
if let Some(enabled) = req.smtp_secure {
|
||||||
|
upsert_setting(&state, "notify_smtp_secure", bool_setting_value(enabled)).await?;
|
||||||
|
}
|
||||||
|
if let Some(value) = req.smtp_user {
|
||||||
|
upsert_setting(&state, "notify_smtp_user", value.trim().to_string()).await?;
|
||||||
|
}
|
||||||
|
upsert_optional_secret(&state, "notify_smtp_pass", req.smtp_pass).await?;
|
||||||
|
if let Some(value) = req.smtp_from {
|
||||||
|
upsert_setting(&state, "notify_smtp_from", value.trim().to_string()).await?;
|
||||||
|
}
|
||||||
|
if let Some(value) = req.smtp_to {
|
||||||
|
upsert_setting(&state, "notify_smtp_to", value.trim().to_string()).await?;
|
||||||
|
}
|
||||||
|
if let Some(value) = req.notify_cooldown_sec {
|
||||||
|
upsert_setting(&state, "notify_cooldown_sec", value.max(0).to_string()).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Json(mut body) = runtime_settings(State(state.clone())).await?;
|
||||||
|
if let Value::Object(ref mut map) = body {
|
||||||
|
map.insert("success".to_string(), Value::Bool(true));
|
||||||
|
}
|
||||||
|
Ok(Json(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn brand_list() -> Json<Value> {
|
async fn brand_list() -> Json<Value> {
|
||||||
@@ -1163,6 +1422,97 @@ async fn clear_usage_data(State(state): State<AppState>) -> AppResult<Json<Value
|
|||||||
Ok(Json(json!({ "success": true })))
|
Ok(Json(json!({ "success": true })))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn test_notification() -> Json<Value> {
|
async fn test_notification(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
Json(json!({ "success": true, "message": "notification endpoint available" }))
|
let channels = NotificationService::new(state.db.clone()).send_test().await?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"success": true,
|
||||||
|
"message": format!("测试通知已发送:{}", channels.join(", ")),
|
||||||
|
"channels": channels,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{
|
||||||
|
config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig},
|
||||||
|
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
|
||||||
|
};
|
||||||
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
|
|
||||||
|
async fn test_state() -> AppState {
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query("CREATE TABLE settings (key TEXT PRIMARY KEY NOT NULL, value TEXT)")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let runtime_auth = RuntimeAuthSettings::load(&pool, "sk-default-token".to_string())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
AppState::new(
|
||||||
|
AppConfig {
|
||||||
|
server: ServerConfig {
|
||||||
|
host: "127.0.0.1".into(),
|
||||||
|
port: 0,
|
||||||
|
log_level: "info".into(),
|
||||||
|
cors_origins: vec![],
|
||||||
|
},
|
||||||
|
db: DbConfig {
|
||||||
|
url: "sqlite::memory:".into(),
|
||||||
|
max_connections: 1,
|
||||||
|
ssl: false,
|
||||||
|
runtime_db_url: None,
|
||||||
|
runtime_db_dialect: None,
|
||||||
|
runtime_db_ssl: None,
|
||||||
|
},
|
||||||
|
auth: AuthConfig {
|
||||||
|
jwt_secret: "secret".into(),
|
||||||
|
jwt_expiry_hours: 1,
|
||||||
|
auth_token: "auth".into(),
|
||||||
|
proxy_token: "sk-default-token".into(),
|
||||||
|
},
|
||||||
|
proxy: ProxyConfig {
|
||||||
|
upstream_timeout_secs: 30,
|
||||||
|
first_byte_timeout_secs: 30,
|
||||||
|
max_retries: 3,
|
||||||
|
proxy_log_retention_days: 7,
|
||||||
|
template_proxy_url: None,
|
||||||
|
},
|
||||||
|
comfyui: Default::default(),
|
||||||
|
},
|
||||||
|
pool,
|
||||||
|
BuiltinKeys {
|
||||||
|
comfyui_agent: (1, "sk-comfyui-test".into()),
|
||||||
|
},
|
||||||
|
runtime_auth,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn runtime_settings_reports_and_updates_proxy_token() {
|
||||||
|
let state = test_state().await;
|
||||||
|
let Json(initial) = runtime_settings(State(state.clone())).await.unwrap();
|
||||||
|
assert_eq!(initial["proxyTokenMasked"], "••••oken");
|
||||||
|
assert_eq!(initial["proxyTokenSource"], "config");
|
||||||
|
|
||||||
|
let Json(updated) = update_runtime_settings(
|
||||||
|
State(state.clone()),
|
||||||
|
Json(RuntimeSettingsUpdateRequest {
|
||||||
|
proxy_token: Some("sk-new-runtime-token".into()),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(updated["success"], true);
|
||||||
|
assert_eq!(updated["proxyTokenMasked"], "••••oken");
|
||||||
|
assert_eq!(updated["proxyTokenSource"], "runtime");
|
||||||
|
assert_eq!(state.runtime_proxy_token(), "sk-new-runtime-token");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -417,6 +417,7 @@ mod tests {
|
|||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
comfyui_agent: (1, "sk-comfyui-abcdef0123456789".into()),
|
comfyui_agent: (1, "sk-comfyui-abcdef0123456789".into()),
|
||||||
},
|
},
|
||||||
|
crate::services::runtime_auth::RuntimeAuthSettings::from_token_for_test("proxy"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-2
@@ -217,6 +217,7 @@ const HIDDEN_SETTINGS_PREFIX: &str = "builtin_key.";
|
|||||||
|
|
||||||
fn is_hidden_setting_key(key: &str) -> bool {
|
fn is_hidden_setting_key(key: &str) -> bool {
|
||||||
key.starts_with(HIDDEN_SETTINGS_PREFIX)
|
key.starts_with(HIDDEN_SETTINGS_PREFIX)
|
||||||
|
|| key == crate::services::runtime_auth::PROXY_TOKEN_SETTING_KEY
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/settings — return all settings as a key-value map.
|
/// GET /api/settings — return all settings as a key-value map.
|
||||||
@@ -313,6 +314,12 @@ mod tests {
|
|||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO settings (key, value) VALUES ('runtime.auth.proxy_token', 'sk-runtime-secret')",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
AppState::new(
|
AppState::new(
|
||||||
AppConfig {
|
AppConfig {
|
||||||
@@ -349,11 +356,12 @@ mod tests {
|
|||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
comfyui_agent: (1, "sk-comfyui-secret".into()),
|
comfyui_agent: (1, "sk-comfyui-secret".into()),
|
||||||
},
|
},
|
||||||
|
crate::services::runtime_auth::RuntimeAuthSettings::from_token_for_test("proxy"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 5.2 — `GET /api/settings` returns regular settings but never any
|
/// 5.2 — `GET /api/settings` returns regular settings but never protected
|
||||||
/// `builtin_key.*` rows.
|
/// secret rows.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn list_settings_filters_builtin_key_prefix() {
|
async fn list_settings_filters_builtin_key_prefix() {
|
||||||
let state = settings_state().await;
|
let state = settings_state().await;
|
||||||
@@ -364,6 +372,10 @@ mod tests {
|
|||||||
!data.contains_key("builtin_key.comfyui_agent.secret"),
|
!data.contains_key("builtin_key.comfyui_agent.secret"),
|
||||||
"builtin_key.* rows must not appear in /api/settings response"
|
"builtin_key.* rows must not appear in /api/settings response"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
!data.contains_key("runtime.auth.proxy_token"),
|
||||||
|
"runtime proxy token must not appear in /api/settings response"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 5.3 — `PUT /api/settings` rejects writes targeting `builtin_key.*`
|
/// 5.3 — `PUT /api/settings` rejects writes targeting `builtin_key.*`
|
||||||
|
|||||||
@@ -49,8 +49,9 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
|
|||||||
|
|
||||||
// Downstream API key
|
// Downstream API key
|
||||||
if auth.starts_with("sk-") {
|
if auth.starts_with("sk-") {
|
||||||
|
let proxy_token = state.runtime_proxy_token();
|
||||||
let result =
|
let result =
|
||||||
authorize_downstream_token(auth, &state.db, Some(&state.config.auth.proxy_token))
|
authorize_downstream_token(auth, &state.db, Some(&proxy_token))
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Unauthorized)?;
|
.map_err(AppError::Unauthorized)?;
|
||||||
|
|
||||||
|
|||||||
+130
-19
@@ -243,11 +243,12 @@ async fn resolve_agent_auth(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
|
let proxy_token = state.runtime_proxy_token();
|
||||||
|
if token == state.config.auth.auth_token || token == proxy_token {
|
||||||
return Ok(AgentAuthContext {
|
return Ok(AgentAuthContext {
|
||||||
user_id: None,
|
user_id: None,
|
||||||
role: "admin".to_string(),
|
role: "admin".to_string(),
|
||||||
outbound_key: state.config.auth.proxy_token.clone(),
|
outbound_key: proxy_token,
|
||||||
key_name: "global-proxy".to_string(),
|
key_name: "global-proxy".to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -282,7 +283,7 @@ async fn resolve_agent_auth(
|
|||||||
Err(_) if claims.role == "admin" => Ok(AgentAuthContext {
|
Err(_) if claims.role == "admin" => Ok(AgentAuthContext {
|
||||||
user_id: Some(user_id),
|
user_id: Some(user_id),
|
||||||
role: claims.role,
|
role: claims.role,
|
||||||
outbound_key: state.config.auth.proxy_token.clone(),
|
outbound_key: state.runtime_proxy_token(),
|
||||||
key_name: "global-proxy".to_string(),
|
key_name: "global-proxy".to_string(),
|
||||||
}),
|
}),
|
||||||
Err(message) => Err((
|
Err(message) => Err((
|
||||||
@@ -368,6 +369,8 @@ async fn status_handler(
|
|||||||
"keyName": key_name,
|
"keyName": key_name,
|
||||||
"userId": auth.user_id,
|
"userId": auth.user_id,
|
||||||
"role": auth.role,
|
"role": auth.role,
|
||||||
|
"apiBase": upstream_base,
|
||||||
|
"upstreamBase": upstream_base,
|
||||||
"upstreamLabel": upstream_label,
|
"upstreamLabel": upstream_label,
|
||||||
"comfyui": state.comfyui.status_response().await,
|
"comfyui": state.comfyui.status_response().await,
|
||||||
})))
|
})))
|
||||||
@@ -680,6 +683,77 @@ fn send_sse(tx: &UnboundedSender<Result<Event, Infallible>>, payload: Value) ->
|
|||||||
.is_ok()
|
.is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn string_field(value: &Value, keys: &[&str]) -> Option<String> {
|
||||||
|
keys.iter()
|
||||||
|
.find_map(|key| value.get(*key).and_then(Value::as_str))
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_reasoning_delta(parsed: &Value) -> Option<String> {
|
||||||
|
if let Some(delta) = parsed
|
||||||
|
.get("choices")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.and_then(|choices| choices.first())
|
||||||
|
.and_then(|choice| choice.get("delta"))
|
||||||
|
{
|
||||||
|
if let Some(text) = string_field(
|
||||||
|
delta,
|
||||||
|
&[
|
||||||
|
"reasoning_content",
|
||||||
|
"reasoning",
|
||||||
|
"thinking",
|
||||||
|
"thinking_delta",
|
||||||
|
"reasoning_delta",
|
||||||
|
],
|
||||||
|
) {
|
||||||
|
return Some(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(text) = delta
|
||||||
|
.get("reasoning")
|
||||||
|
.and_then(|value| value.get("content").or_else(|| value.get("text")))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
{
|
||||||
|
return Some(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let event_type = parsed
|
||||||
|
.get("type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default();
|
||||||
|
match event_type {
|
||||||
|
"response.reasoning.delta"
|
||||||
|
| "response.reasoning_text.delta"
|
||||||
|
| "response.reasoning_summary_text.delta" => {
|
||||||
|
string_field(parsed, &["delta", "text", "content"])
|
||||||
|
}
|
||||||
|
"content_block_delta" => {
|
||||||
|
let delta = parsed.get("delta")?;
|
||||||
|
match delta
|
||||||
|
.get("type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
{
|
||||||
|
"thinking_delta" => string_field(delta, &["thinking", "text", "delta"]),
|
||||||
|
"signature_delta" => None,
|
||||||
|
other if other.contains("thinking") || other.contains("reasoning") => {
|
||||||
|
string_field(delta, &["thinking", "text", "content", "delta"])
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other if other.contains("reasoning") || other.contains("thinking") => string_field(
|
||||||
|
parsed,
|
||||||
|
&["delta", "text", "content", "thinking", "reasoning"],
|
||||||
|
),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn attachment_summary(attachment: &AgentAttachment) -> String {
|
fn attachment_summary(attachment: &AgentAttachment) -> String {
|
||||||
let mime = attachment
|
let mime = attachment
|
||||||
.mime_type
|
.mime_type
|
||||||
@@ -936,22 +1010,7 @@ async fn chat_handler(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
let delta = parsed
|
if let Some(reasoning) = extract_reasoning_delta(&parsed) {
|
||||||
.get("choices")
|
|
||||||
.and_then(Value::as_array)
|
|
||||||
.and_then(|choices| choices.first())
|
|
||||||
.and_then(|choice| choice.get("delta"))
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or(Value::Null);
|
|
||||||
|
|
||||||
let reasoning = delta
|
|
||||||
.get("reasoning_content")
|
|
||||||
.or_else(|| delta.get("reasoning"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
if !reasoning.is_empty() {
|
|
||||||
if !send_sse(
|
if !send_sse(
|
||||||
&tx,
|
&tx,
|
||||||
json!({
|
json!({
|
||||||
@@ -963,6 +1022,14 @@ async fn chat_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let delta = parsed
|
||||||
|
.get("choices")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.and_then(|choices| choices.first())
|
||||||
|
.and_then(|choice| choice.get("delta"))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
|
|
||||||
let content = delta
|
let content = delta
|
||||||
.get("content")
|
.get("content")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
@@ -1243,6 +1310,7 @@ mod tests {
|
|||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
comfyui_agent: (1, "sk-comfyui-test".into()),
|
comfyui_agent: (1, "sk-comfyui-test".into()),
|
||||||
},
|
},
|
||||||
|
crate::services::runtime_auth::RuntimeAuthSettings::from_token_for_test("proxy"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1262,6 +1330,49 @@ mod tests {
|
|||||||
result.expect("status_handler returned error").0
|
result.expect("status_handler returned error").0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_chat_reasoning_delta() {
|
||||||
|
let parsed = json!({
|
||||||
|
"choices": [{
|
||||||
|
"delta": {
|
||||||
|
"reasoning_content": "正在拆解镜头..."
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
extract_reasoning_delta(&parsed).as_deref(),
|
||||||
|
Some("正在拆解镜头...")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_responses_reasoning_delta() {
|
||||||
|
let parsed = json!({
|
||||||
|
"type": "response.reasoning_summary_text.delta",
|
||||||
|
"delta": "先分析角色和场景。"
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
extract_reasoning_delta(&parsed).as_deref(),
|
||||||
|
Some("先分析角色和场景。")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_anthropic_thinking_delta() {
|
||||||
|
let parsed = json!({
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"index": 0,
|
||||||
|
"delta": {
|
||||||
|
"type": "thinking_delta",
|
||||||
|
"thinking": "用户需要一个视频分镜。"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
extract_reasoning_delta(&parsed).as_deref(),
|
||||||
|
Some("用户需要一个视频分镜。")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 3.5 — `/api/comfyui-agent/status` reflects the env state in one shot:
|
/// 3.5 — `/api/comfyui-agent/status` reflects the env state in one shot:
|
||||||
/// no env → local; whitespace/empty env → still local (treated as unset);
|
/// no env → local; whitespace/empty env → still local (treated as unset);
|
||||||
/// real env → fallback_remote with trimmed `upstreamBase`.
|
/// real env → fallback_remote with trimmed `upstreamBase`.
|
||||||
|
|||||||
@@ -104,11 +104,12 @@ async fn resolve_auth(headers: &HeaderMap, state: &AppState) -> Result<DigitalHu
|
|||||||
let token = extract_bearer_token(headers)
|
let token = extract_bearer_token(headers)
|
||||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||||
|
|
||||||
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
|
let proxy_token = state.runtime_proxy_token();
|
||||||
|
if token == state.config.auth.auth_token || token == proxy_token {
|
||||||
return Ok(DigitalHumanAuth {
|
return Ok(DigitalHumanAuth {
|
||||||
user_id: None,
|
user_id: None,
|
||||||
role: "admin".to_string(),
|
role: "admin".to_string(),
|
||||||
outbound_key: state.config.auth.proxy_token.clone(),
|
outbound_key: proxy_token,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +132,7 @@ async fn resolve_auth(headers: &HeaderMap, state: &AppState) -> Result<DigitalHu
|
|||||||
Err(_) if claims.role == "admin" => Ok(DigitalHumanAuth {
|
Err(_) if claims.role == "admin" => Ok(DigitalHumanAuth {
|
||||||
user_id: Some(user_id),
|
user_id: Some(user_id),
|
||||||
role: claims.role,
|
role: claims.role,
|
||||||
outbound_key: state.config.auth.proxy_token.clone(),
|
outbound_key: state.runtime_proxy_token(),
|
||||||
}),
|
}),
|
||||||
Err(message) => Err(AppError::Forbidden(message)),
|
Err(message) => Err(AppError::Forbidden(message)),
|
||||||
}
|
}
|
||||||
@@ -720,7 +721,27 @@ async fn submit_prompt(state: &AppState, prompt: Value) -> AppResult<(String, Va
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn prompt_history(state: &AppState, prompt_id: &str) -> AppResult<Value> {
|
async fn prompt_history(state: &AppState, prompt_id: &str) -> AppResult<Value> {
|
||||||
native_json(state, Method::GET, &format!("/history/{prompt_id}"), None).await
|
let path = if state.config.comfyui.uses_cloud_backend() {
|
||||||
|
format!("/jobs/{prompt_id}")
|
||||||
|
} else {
|
||||||
|
format!("/history/{prompt_id}")
|
||||||
|
};
|
||||||
|
native_json(state, Method::GET, &path, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_failure_message(value: &Value) -> Option<String> {
|
||||||
|
let status = scalar_string(value.get("status").or_else(|| value.get("state")))
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
if matches!(status.as_str(), "failed" | "error" | "cancelled" | "canceled") {
|
||||||
|
return Some(
|
||||||
|
scalar_string(value.pointer("/execution_error/exception_message"))
|
||||||
|
.or_else(|| scalar_string(value.get("error")))
|
||||||
|
.or_else(|| scalar_string(value.get("message")))
|
||||||
|
.unwrap_or_else(|| format!("Comfy job {status}")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
scalar_string(value.pointer("/execution_error/exception_message"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn output_objects(value: &Value) -> Vec<Value> {
|
fn output_objects(value: &Value) -> Vec<Value> {
|
||||||
@@ -835,6 +856,7 @@ async fn archive_output(
|
|||||||
model: Some("comfy-native".to_string()),
|
model: Some("comfy-native".to_string()),
|
||||||
remote_url: None,
|
remote_url: None,
|
||||||
metadata: json!({
|
metadata: json!({
|
||||||
|
"workspaceAsset": { "role": "output" },
|
||||||
"digitalHuman": {
|
"digitalHuman": {
|
||||||
"promptId": prompt_id,
|
"promptId": prompt_id,
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
@@ -1015,6 +1037,9 @@ async fn create_job(
|
|||||||
update_progress(&state_for_task, &task_id_for_spawn, 3, "Comfy 正在生成视频")
|
update_progress(&state_for_task, &task_id_for_spawn, 3, "Comfy 正在生成视频")
|
||||||
.await?;
|
.await?;
|
||||||
let history = prompt_history(&state_for_task, &prompt_id).await?;
|
let history = prompt_history(&state_for_task, &prompt_id).await?;
|
||||||
|
if let Some(message) = prompt_failure_message(&history) {
|
||||||
|
return Err(AppError::UpstreamError(message));
|
||||||
|
}
|
||||||
let outputs = output_objects(&history);
|
let outputs = output_objects(&history);
|
||||||
if !outputs.is_empty() {
|
if !outputs.is_empty() {
|
||||||
update_progress(
|
update_progress(
|
||||||
@@ -1230,7 +1255,10 @@ async fn upload_asset(
|
|||||||
prompt: None,
|
prompt: None,
|
||||||
model: None,
|
model: None,
|
||||||
remote_url: None,
|
remote_url: None,
|
||||||
metadata: json!({ "digitalHumanUpload": { "purpose": purpose, "fileName": file_name } }),
|
metadata: json!({
|
||||||
|
"workspaceAsset": { "role": "input" },
|
||||||
|
"digitalHumanUpload": { "purpose": purpose, "fileName": file_name },
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
let asset = workspace::record_bytes_asset(&state, input, &bytes, &mime_type).await?;
|
let asset = workspace::record_bytes_asset(&state, input, &bytes, &mime_type).await?;
|
||||||
let file_url = format!(
|
let file_url = format!(
|
||||||
|
|||||||
+83
-10
@@ -1,14 +1,15 @@
|
|||||||
// Events routes — system event notifications
|
// Events routes — system event notifications
|
||||||
|
|
||||||
use crate::{db::models::event::Event, error::AppResult, AppState};
|
use crate::{db::models::event::Event, error::AppResult, services::runtime_logs, AppState};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use serde::Serialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tracing::info;
|
use tracing::{debug, info};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct ListEventsQuery {
|
pub struct ListEventsQuery {
|
||||||
@@ -32,17 +33,15 @@ async fn list_events(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(q): Query<ListEventsQuery>,
|
Query(q): Query<ListEventsQuery>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
info!("Listing events");
|
debug!("Listing events");
|
||||||
let limit = q.limit.unwrap_or(30).clamp(1, 500);
|
let limit = q.limit.unwrap_or(30).clamp(1, 500);
|
||||||
let offset = q.offset.unwrap_or(0).max(0);
|
let offset = q.offset.unwrap_or(0).max(0);
|
||||||
|
|
||||||
let mut sql = "SELECT * FROM events WHERE 1=1".to_string();
|
let mut sql = "SELECT id, type, title, message, level, read, related_id, related_type, created_at FROM events WHERE 1=1".to_string();
|
||||||
let mut bind_idx = 0;
|
|
||||||
let mut params: Vec<String> = vec![];
|
let mut params: Vec<String> = vec![];
|
||||||
|
|
||||||
if let Some(ref t) = q.filter_type {
|
if let Some(ref t) = q.filter_type {
|
||||||
bind_idx += 1;
|
sql.push_str(" AND type = ?");
|
||||||
sql.push_str(&format!(" AND type = ?{}", bind_idx));
|
|
||||||
params.push(t.clone());
|
params.push(t.clone());
|
||||||
}
|
}
|
||||||
if let Some(ref r) = q.read {
|
if let Some(ref r) = q.read {
|
||||||
@@ -53,16 +52,41 @@ async fn list_events(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sql.push_str(" ORDER BY created_at DESC");
|
sql.push_str(" ORDER BY created_at DESC LIMIT 1000");
|
||||||
sql.push_str(&format!(" LIMIT {} OFFSET {}", limit, offset));
|
|
||||||
|
|
||||||
let mut query = sqlx::query_as::<_, Event>(&sql);
|
let mut query = sqlx::query_as::<_, Event>(&sql);
|
||||||
for p in ¶ms {
|
for p in ¶ms {
|
||||||
query = query.bind(p);
|
query = query.bind(p);
|
||||||
}
|
}
|
||||||
let events: Vec<Event> = query.fetch_all(&*state.db).await?;
|
let events: Vec<Event> = query.fetch_all(&*state.db).await?;
|
||||||
|
let mut rows: Vec<EventListItem> = events.into_iter().map(EventListItem::from).collect();
|
||||||
|
|
||||||
Ok(Json(json!({ "data": events })))
|
if q.filter_type
|
||||||
|
.as_deref()
|
||||||
|
.is_none_or(|filter_type| filter_type == "runtime")
|
||||||
|
{
|
||||||
|
rows.extend(
|
||||||
|
runtime_logs::list()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|entry| q.read.as_deref() != Some("false") || !entry.is_read)
|
||||||
|
.map(EventListItem::from),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.sort_by(|a, b| {
|
||||||
|
b.created_at
|
||||||
|
.cmp(&a.created_at)
|
||||||
|
.then_with(|| a.id.cmp(&b.id))
|
||||||
|
});
|
||||||
|
let start = offset as usize;
|
||||||
|
let end = start.saturating_add(limit as usize).min(rows.len());
|
||||||
|
let page = if start < rows.len() {
|
||||||
|
rows[start..end].to_vec()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(json!({ "data": page })))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/events/count — unread count
|
/// GET /api/events/count — unread count
|
||||||
@@ -98,5 +122,54 @@ async fn clear_events(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
|||||||
sqlx::query("DELETE FROM events")
|
sqlx::query("DELETE FROM events")
|
||||||
.execute(&*state.db)
|
.execute(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
runtime_logs::clear();
|
||||||
Ok(Json(json!({ "success": true })))
|
Ok(Json(json!({ "success": true })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct EventListItem {
|
||||||
|
id: i64,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
event_type: String,
|
||||||
|
title: String,
|
||||||
|
message: Option<String>,
|
||||||
|
level: String,
|
||||||
|
#[serde(rename = "read")]
|
||||||
|
is_read: bool,
|
||||||
|
related_id: Option<i64>,
|
||||||
|
related_type: Option<String>,
|
||||||
|
created_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Event> for EventListItem {
|
||||||
|
fn from(event: Event) -> Self {
|
||||||
|
Self {
|
||||||
|
id: event.id,
|
||||||
|
event_type: event.event_type,
|
||||||
|
title: event.title,
|
||||||
|
message: event.message,
|
||||||
|
level: event.level.unwrap_or_else(|| "info".to_string()),
|
||||||
|
is_read: event.is_read.unwrap_or(false),
|
||||||
|
related_id: event.related_id,
|
||||||
|
related_type: event.related_type,
|
||||||
|
created_at: event.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<runtime_logs::RuntimeLogEntry> for EventListItem {
|
||||||
|
fn from(entry: runtime_logs::RuntimeLogEntry) -> Self {
|
||||||
|
Self {
|
||||||
|
id: entry.id,
|
||||||
|
event_type: entry.event_type,
|
||||||
|
title: entry.title,
|
||||||
|
message: entry.message,
|
||||||
|
level: entry.level,
|
||||||
|
is_read: entry.is_read,
|
||||||
|
related_id: entry.related_id,
|
||||||
|
related_type: entry.related_type,
|
||||||
|
created_at: Some(entry.created_at),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+100
-28
@@ -10,9 +10,14 @@ use include_dir::{include_dir, Dir};
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use tower::service_fn;
|
use tower::service_fn;
|
||||||
|
use tower_http::compression::CompressionLayer;
|
||||||
use tower_http::services::ServeDir;
|
use tower_http::services::ServeDir;
|
||||||
|
|
||||||
static EMBEDDED_WEB: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/web");
|
static EMBEDDED_WEB: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/web");
|
||||||
|
static EMBEDDED_WEBSITE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/website");
|
||||||
|
const _: &[u8] = include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/dist/web/index.html"));
|
||||||
|
const _: &[u8] =
|
||||||
|
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/dist/website/index.html"));
|
||||||
|
|
||||||
mod admin;
|
mod admin;
|
||||||
mod auth;
|
mod auth;
|
||||||
@@ -32,7 +37,6 @@ mod search;
|
|||||||
mod site_announcements;
|
mod site_announcements;
|
||||||
pub(crate) mod stats;
|
pub(crate) mod stats;
|
||||||
mod tasks;
|
mod tasks;
|
||||||
mod update_center;
|
|
||||||
mod user;
|
mod user;
|
||||||
mod user_api_keys;
|
mod user_api_keys;
|
||||||
#[path = "video_agent_legacy.rs"]
|
#[path = "video_agent_legacy.rs"]
|
||||||
@@ -68,7 +72,6 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.merge(workflow_ui::routes())
|
.merge(workflow_ui::routes())
|
||||||
.merge(stats::routes())
|
.merge(stats::routes())
|
||||||
.merge(tasks::routes())
|
.merge(tasks::routes())
|
||||||
.merge(update_center::routes())
|
|
||||||
.merge(user::routes())
|
.merge(user::routes())
|
||||||
.merge(user_api_keys::routes())
|
.merge(user_api_keys::routes())
|
||||||
.merge(workspace::routes())
|
.merge(workspace::routes())
|
||||||
@@ -76,40 +79,42 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
let router = Router::new().merge(api_routes);
|
let router = Router::new().merge(api_routes);
|
||||||
match std::env::var("BOOSAPI_WEB_DIR")
|
let router = match std::env::var("BOOSAPI_WEB_DIR")
|
||||||
.ok()
|
.ok()
|
||||||
.map(|value| value.trim().to_string())
|
.map(|value| value.trim().to_string())
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
{
|
{
|
||||||
Some(web_dir) => {
|
Some(web_dir) => {
|
||||||
let web_dir_owned = web_dir.clone();
|
let website_dir = std::env::var("BOOSAPI_WEBSITE_DIR")
|
||||||
|
.ok()
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or_else(|| "dist/website".to_string());
|
||||||
let static_service = ServeDir::new(&web_dir)
|
let static_service = ServeDir::new(&web_dir)
|
||||||
.append_index_html_on_directories(true)
|
.append_index_html_on_directories(true)
|
||||||
.fallback(service_fn(move |req: Request<Body>| {
|
.fallback(service_fn(move |req: Request<Body>| {
|
||||||
let web_dir = web_dir_owned.clone();
|
let web_dir = web_dir.clone();
|
||||||
|
let website_dir = website_dir.clone();
|
||||||
async move {
|
async move {
|
||||||
let path = req.uri().path();
|
let path = req.uri().path();
|
||||||
if is_api_path(path) {
|
if is_api_path(path) {
|
||||||
return Ok::<_, Infallible>(api_not_found_response(path));
|
return Ok::<_, Infallible>(api_not_found_response(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
let index_path = format!("{}/index.html", web_dir);
|
Ok::<_, Infallible>(filesystem_static_response(
|
||||||
match tokio::fs::read_to_string(&index_path).await {
|
path,
|
||||||
Ok(html) => Ok::<_, Infallible>(html_response(html)),
|
&web_dir,
|
||||||
Err(_) => Ok::<_, Infallible>(
|
&website_dir,
|
||||||
Response::builder()
|
)
|
||||||
.status(StatusCode::NOT_FOUND)
|
.await)
|
||||||
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
|
|
||||||
.body(Body::from(format!("404: {} not found", path)))
|
|
||||||
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response()),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
router.fallback_service(static_service)
|
router.fallback_service(static_service)
|
||||||
}
|
}
|
||||||
None => router.fallback(embedded_static_handler),
|
None => router.fallback(embedded_static_handler),
|
||||||
}
|
};
|
||||||
|
|
||||||
|
router.layer(CompressionLayer::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn api_not_found_response(path: &str) -> Response {
|
fn api_not_found_response(path: &str) -> Response {
|
||||||
@@ -122,6 +127,49 @@ fn api_not_found_response(path: &str) -> Response {
|
|||||||
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response())
|
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn filesystem_static_response(path: &str, web_dir: &str, website_dir: &str) -> Response {
|
||||||
|
let root_dir = if should_serve_website(path) {
|
||||||
|
website_dir
|
||||||
|
} else {
|
||||||
|
web_dir
|
||||||
|
};
|
||||||
|
let asset_path = embedded_asset_path(path);
|
||||||
|
let candidate = format!("{}/{}", root_dir.trim_end_matches('/'), asset_path);
|
||||||
|
|
||||||
|
match tokio::fs::read(&candidate).await {
|
||||||
|
Ok(bytes) => return embedded_file_response(&asset_path, &bytes),
|
||||||
|
Err(_) => {
|
||||||
|
if candidate.ends_with("/index.html") {
|
||||||
|
let without_index = candidate.trim_end_matches("index.html");
|
||||||
|
if let Ok(bytes) = tokio::fs::read(without_index).await {
|
||||||
|
return embedded_file_response(&asset_path, &bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let index_path = format!("{}/index.html", root_dir.trim_end_matches('/'));
|
||||||
|
match tokio::fs::read(&index_path).await {
|
||||||
|
Ok(bytes) => embedded_file_response("index.html", &bytes),
|
||||||
|
Err(_) if should_serve_website(path) => {
|
||||||
|
let fallback_index = format!("{}/index.html", web_dir.trim_end_matches('/'));
|
||||||
|
match tokio::fs::read(&fallback_index).await {
|
||||||
|
Ok(bytes) => embedded_file_response("index.html", &bytes),
|
||||||
|
Err(_) => not_found_response(path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => not_found_response(path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn not_found_response(path: &str) -> Response {
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::NOT_FOUND)
|
||||||
|
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||||
|
.body(Body::from(format!("404: {} not found", path)))
|
||||||
|
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
async fn embedded_static_handler(req: Request<Body>) -> Response {
|
async fn embedded_static_handler(req: Request<Body>) -> Response {
|
||||||
let path = req.uri().path();
|
let path = req.uri().path();
|
||||||
if is_api_path(path) {
|
if is_api_path(path) {
|
||||||
@@ -129,23 +177,55 @@ async fn embedded_static_handler(req: Request<Body>) -> Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let asset_path = embedded_asset_path(path);
|
let asset_path = embedded_asset_path(path);
|
||||||
if let Some(file) = EMBEDDED_WEB.get_file(&asset_path) {
|
let static_dir = if should_serve_website(path) {
|
||||||
|
&EMBEDDED_WEBSITE
|
||||||
|
} else {
|
||||||
|
&EMBEDDED_WEB
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(file) = static_dir.get_file(&asset_path) {
|
||||||
return embedded_file_response(&asset_path, file.contents());
|
return embedded_file_response(&asset_path, file.contents());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(file) = static_dir.get_file("index.html") {
|
||||||
|
return embedded_file_response("index.html", file.contents());
|
||||||
|
}
|
||||||
|
|
||||||
|
let disk_fallback = filesystem_static_response(path, "dist/web", "dist/website").await;
|
||||||
|
if disk_fallback.status() != StatusCode::NOT_FOUND {
|
||||||
|
return disk_fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if should_serve_website(path) && EMBEDDED_WEBSITE.get_file("index.html").is_none() {
|
||||||
if let Some(file) = EMBEDDED_WEB.get_file("index.html") {
|
if let Some(file) = EMBEDDED_WEB.get_file("index.html") {
|
||||||
return embedded_file_response("index.html", file.contents());
|
return embedded_file_response("index.html", file.contents());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Response::builder()
|
Response::builder()
|
||||||
.status(StatusCode::NOT_FOUND)
|
.status(StatusCode::NOT_FOUND)
|
||||||
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
|
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||||
.body(Body::from("embedded dist/web/index.html not found"))
|
.body(Body::from(
|
||||||
|
"embedded frontend index.html not found; rebuild with ./build-and-run.sh --production or set BOOSAPI_WEB_DIR/BOOSAPI_WEBSITE_DIR",
|
||||||
|
))
|
||||||
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response())
|
.unwrap_or_else(|_| StatusCode::NOT_FOUND.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_api_path(path: &str) -> bool {
|
fn is_api_path(path: &str) -> bool {
|
||||||
path.starts_with("/api/") || path.starts_with("/v1/")
|
path.starts_with("/api/")
|
||||||
|
|| path.starts_with("/v1/")
|
||||||
|
|| path == "/health"
|
||||||
|
|| path == "/ready"
|
||||||
|
|| path == "/metrics"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_serve_website(path: &str) -> bool {
|
||||||
|
path == "/"
|
||||||
|
|| path.starts_with("/_next/")
|
||||||
|
|| path == "/favicon.ico"
|
||||||
|
|| path == "/favicon.png"
|
||||||
|
|| path == "/favicon-64.png"
|
||||||
|
|| path == "/logo.png"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn embedded_asset_path(path: &str) -> String {
|
fn embedded_asset_path(path: &str) -> String {
|
||||||
@@ -157,14 +237,6 @@ fn embedded_asset_path(path: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn html_response(html: String) -> Response {
|
|
||||||
Response::builder()
|
|
||||||
.status(StatusCode::OK)
|
|
||||||
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
|
|
||||||
.body(Body::from(html))
|
|
||||||
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn embedded_file_response(path: &str, bytes: &[u8]) -> Response {
|
fn embedded_file_response(path: &str, bytes: &[u8]) -> Response {
|
||||||
let content_type = mime_guess::from_path(path)
|
let content_type = mime_guess::from_path(path)
|
||||||
.first_or_octet_stream()
|
.first_or_octet_stream()
|
||||||
|
|||||||
+313
-21
@@ -14,7 +14,7 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Map, Value};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
const TASK_TYPE_MONEY_PRINTER: &str = "money-printer-video";
|
const TASK_TYPE_MONEY_PRINTER: &str = "money-printer-video";
|
||||||
@@ -88,11 +88,12 @@ async fn resolve_auth(headers: &HeaderMap, state: &AppState) -> Result<MoneyPrin
|
|||||||
let token = extract_bearer_token(headers)
|
let token = extract_bearer_token(headers)
|
||||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||||
|
|
||||||
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
|
let proxy_token = state.runtime_proxy_token();
|
||||||
|
if token == state.config.auth.auth_token || token == proxy_token {
|
||||||
return Ok(MoneyPrinterAuth {
|
return Ok(MoneyPrinterAuth {
|
||||||
user_id: None,
|
user_id: None,
|
||||||
role: "admin".to_string(),
|
role: "admin".to_string(),
|
||||||
outbound_key: state.config.auth.proxy_token.clone(),
|
outbound_key: proxy_token,
|
||||||
key_name: "global-proxy".to_string(),
|
key_name: "global-proxy".to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -117,7 +118,7 @@ async fn resolve_auth(headers: &HeaderMap, state: &AppState) -> Result<MoneyPrin
|
|||||||
Err(_) if claims.role == "admin" => Ok(MoneyPrinterAuth {
|
Err(_) if claims.role == "admin" => Ok(MoneyPrinterAuth {
|
||||||
user_id: Some(user_id),
|
user_id: Some(user_id),
|
||||||
role: claims.role,
|
role: claims.role,
|
||||||
outbound_key: state.config.auth.proxy_token.clone(),
|
outbound_key: state.runtime_proxy_token(),
|
||||||
key_name: "global-proxy".to_string(),
|
key_name: "global-proxy".to_string(),
|
||||||
}),
|
}),
|
||||||
Err(message) => Err(AppError::Forbidden(message)),
|
Err(message) => Err(AppError::Forbidden(message)),
|
||||||
@@ -126,6 +127,7 @@ async fn resolve_auth(headers: &HeaderMap, state: &AppState) -> Result<MoneyPrin
|
|||||||
|
|
||||||
async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
|
async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
|
||||||
let auth = resolve_auth(&headers, &state).await?;
|
let auth = resolve_auth(&headers, &state).await?;
|
||||||
|
let cloud_backend = state.config.comfyui.uses_cloud_backend();
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"success": true,
|
"success": true,
|
||||||
"mode": "boosapi-native",
|
"mode": "boosapi-native",
|
||||||
@@ -142,6 +144,15 @@ async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<
|
|||||||
"enabled": state.config.comfyui.uses_native_backend(),
|
"enabled": state.config.comfyui.uses_native_backend(),
|
||||||
"backend": format!("{:?}", state.config.comfyui.backend).to_ascii_lowercase(),
|
"backend": format!("{:?}", state.config.comfyui.backend).to_ascii_lowercase(),
|
||||||
"baseUrl": state.config.comfyui.target_base_url()
|
"baseUrl": state.config.comfyui.target_base_url()
|
||||||
|
},
|
||||||
|
"builtinCloudTemplate": {
|
||||||
|
"enabled": cloud_backend,
|
||||||
|
"label": "内置 Cloud Seedance 文生视频",
|
||||||
|
"nodeType": "ByteDanceTextToVideoNode",
|
||||||
|
"model": "seedance-1-0-lite-t2v-250428",
|
||||||
|
"duration": { "min": 3, "max": 12, "default": 5 },
|
||||||
|
"resolutions": ["480p", "720p", "1080p"],
|
||||||
|
"ratios": ["9:16", "16:9", "1:1", "4:3", "3:4", "21:9"]
|
||||||
}
|
}
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
@@ -585,6 +596,24 @@ fn first_node_by_class(prompt: &serde_json::Map<String, Value>, class_type: &str
|
|||||||
.map(|(id, _)| id.clone())
|
.map(|(id, _)| id.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn first_existing_input_field(
|
||||||
|
prompt: &serde_json::Map<String, Value>,
|
||||||
|
node_id: &str,
|
||||||
|
fields: &[&str],
|
||||||
|
fallback: &str,
|
||||||
|
) -> String {
|
||||||
|
let inputs = prompt
|
||||||
|
.get(node_id)
|
||||||
|
.and_then(|node| node.get("inputs"))
|
||||||
|
.and_then(Value::as_object);
|
||||||
|
fields
|
||||||
|
.iter()
|
||||||
|
.find(|field| inputs.is_some_and(|inputs| inputs.contains_key(**field)))
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(fallback)
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn prompt_contains_class_type(prompt: &Value, class_type: &str) -> bool {
|
fn prompt_contains_class_type(prompt: &Value, class_type: &str) -> bool {
|
||||||
match prompt {
|
match prompt {
|
||||||
Value::Object(object) => {
|
Value::Object(object) => {
|
||||||
@@ -616,10 +645,14 @@ fn infer_comfy_mapping(prompt: &Value) -> Value {
|
|||||||
.or_else(|| first_node_by_class(object, "KSampler"))
|
.or_else(|| first_node_by_class(object, "KSampler"))
|
||||||
.or_else(|| first_node_by_class(object, "EmptyLatentImage"))
|
.or_else(|| first_node_by_class(object, "EmptyLatentImage"))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
let prompt_field = first_existing_input_field(object, &prompt_node, &["prompt", "positive", "text"], "text");
|
||||||
|
let ratio_field = first_existing_input_field(object, &video_node, &["ratio", "model.ratio", "aspect_ratio"], "ratio");
|
||||||
|
let resolution_field = first_existing_input_field(object, &video_node, &["model.resolution", "resolution", "size"], "resolution");
|
||||||
|
let duration_field = first_existing_input_field(object, &video_node, &["model.duration", "duration", "length"], "duration");
|
||||||
json!({
|
json!({
|
||||||
"prompt": {
|
"prompt": {
|
||||||
"nodeId": prompt_node,
|
"nodeId": prompt_node,
|
||||||
"field": "text"
|
"field": prompt_field
|
||||||
},
|
},
|
||||||
"negativePrompt": {
|
"negativePrompt": {
|
||||||
"nodeId": negative_node,
|
"nodeId": negative_node,
|
||||||
@@ -627,15 +660,15 @@ fn infer_comfy_mapping(prompt: &Value) -> Value {
|
|||||||
},
|
},
|
||||||
"ratio": {
|
"ratio": {
|
||||||
"nodeId": video_node,
|
"nodeId": video_node,
|
||||||
"field": "ratio"
|
"field": ratio_field
|
||||||
},
|
},
|
||||||
"resolution": {
|
"resolution": {
|
||||||
"nodeId": video_node,
|
"nodeId": video_node,
|
||||||
"field": "resolution"
|
"field": resolution_field
|
||||||
},
|
},
|
||||||
"duration": {
|
"duration": {
|
||||||
"nodeId": video_node,
|
"nodeId": video_node,
|
||||||
"field": "duration"
|
"field": duration_field
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -701,6 +734,25 @@ fn workflow_prompt_from_graph(graph: &Value) -> Value {
|
|||||||
.unwrap_or_else(|| graph.clone())
|
.unwrap_or_else(|| graph.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_cloud_seedance_prompt() -> Value {
|
||||||
|
json!({
|
||||||
|
"1": {
|
||||||
|
"class_type": "ByteDanceTextToVideoNode",
|
||||||
|
"inputs": {
|
||||||
|
"model": "seedance-1-0-lite-t2v-250428",
|
||||||
|
"prompt": "A clean vertical product demo video, minimal futuristic interface, smooth camera movement, bright studio lighting",
|
||||||
|
"resolution": "480p",
|
||||||
|
"aspect_ratio": "9:16",
|
||||||
|
"duration": 5,
|
||||||
|
"seed": 0,
|
||||||
|
"camera_fixed": false,
|
||||||
|
"watermark": false,
|
||||||
|
"generate_audio": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn workflow_prompt_from_body(
|
async fn workflow_prompt_from_body(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
auth: &MoneyPrinterAuth,
|
auth: &MoneyPrinterAuth,
|
||||||
@@ -725,8 +777,11 @@ async fn workflow_prompt_from_body(
|
|||||||
.await?;
|
.await?;
|
||||||
return Ok(workflow_prompt_from_graph(&workflow.graph));
|
return Ok(workflow_prompt_from_graph(&workflow.graph));
|
||||||
}
|
}
|
||||||
|
if state.config.comfyui.uses_cloud_backend() {
|
||||||
|
return Ok(default_cloud_seedance_prompt());
|
||||||
|
}
|
||||||
Err(AppError::BadRequest(
|
Err(AppError::BadRequest(
|
||||||
"Comfy API 模式需要选择真实 Comfy/Cloud 视频工作流模板,不能使用 BoosAPI 自定义节点".into(),
|
"Comfy API 模式需要选择真实 Comfy/Cloud 视频工作流模板;Cloud 模式可使用内置 Seedance 兜底模板".into(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -744,6 +799,47 @@ fn apply_node_input(prompt: &mut Value, node_id: &str, field: &str, value: Value
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn node_has_input(prompt: &Value, node_id: &str, field: &str) -> bool {
|
||||||
|
prompt
|
||||||
|
.get(node_id)
|
||||||
|
.and_then(|node| node.get("inputs"))
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.is_some_and(|inputs| inputs.contains_key(field))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_optional_node_input(
|
||||||
|
prompt: &mut Value,
|
||||||
|
mapping: &Value,
|
||||||
|
mapping_keys: &[&str],
|
||||||
|
default_field: &str,
|
||||||
|
value: Value,
|
||||||
|
) {
|
||||||
|
let Some(node_id) = mapping_node_id(mapping, mapping_keys) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let field = mapping_field(mapping, mapping_keys, default_field);
|
||||||
|
if !node_has_input(prompt, &node_id, &field) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let _ = apply_node_input(prompt, &node_id, &field, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_to_duration_seconds(value: &Value) -> Option<i64> {
|
||||||
|
value
|
||||||
|
.as_i64()
|
||||||
|
.or_else(|| value.as_str().and_then(|text| text.trim().parse::<i64>().ok()))
|
||||||
|
.map(|duration| duration.clamp(2, 120))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_resolution_for_comfy(value: &Value) -> Value {
|
||||||
|
let text = value.as_str().unwrap_or_default().trim().to_ascii_lowercase();
|
||||||
|
match text.as_str() {
|
||||||
|
"720p" | "720" => json!("720P"),
|
||||||
|
"1080p" | "1080" => json!("1080P"),
|
||||||
|
_ => value.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value, plan: &Value) -> AppResult<String> {
|
fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value, plan: &Value) -> AppResult<String> {
|
||||||
let video_prompt = string_from(body, &["videoPrompt", "video_prompt"]).unwrap_or_else(|| {
|
let video_prompt = string_from(body, &["videoPrompt", "video_prompt"]).unwrap_or_else(|| {
|
||||||
plan.get("video_prompt")
|
plan.get("video_prompt")
|
||||||
@@ -773,20 +869,162 @@ fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value,
|
|||||||
json!(negative),
|
json!(negative),
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
for (keys, request_keys, default_field) in [
|
if let Some(value) = ["ratio", "aspectRatio", "aspect_ratio"].iter().find_map(|key| body.get(*key).cloned()) {
|
||||||
(&["ratio", "ratioNode"][..], &["ratio", "aspectRatio", "aspect_ratio"][..], "ratio"),
|
apply_optional_node_input(prompt, mapping, &["ratio", "ratioNode"], "ratio", value);
|
||||||
(&["resolution", "resolutionNode"][..], &["resolution", "size"][..], "resolution"),
|
|
||||||
(&["duration", "durationNode"][..], &["duration"][..], "duration"),
|
|
||||||
] {
|
|
||||||
if let Some(node_id) = mapping_node_id(mapping, keys) {
|
|
||||||
if let Some(value) = request_keys.iter().find_map(|key| body.get(*key).cloned()) {
|
|
||||||
apply_node_input(prompt, &node_id, &mapping_field(mapping, keys, default_field), value)?;
|
|
||||||
}
|
}
|
||||||
|
if let Some(value) = ["resolution", "size"].iter().find_map(|key| body.get(*key).cloned()) {
|
||||||
|
apply_optional_node_input(
|
||||||
|
prompt,
|
||||||
|
mapping,
|
||||||
|
&["resolution", "resolutionNode"],
|
||||||
|
"resolution",
|
||||||
|
normalize_resolution_for_comfy(&value),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
if let Some(value) = ["duration"].iter().find_map(|key| body.get(*key)).and_then(value_to_duration_seconds) {
|
||||||
|
apply_optional_node_input(prompt, mapping, &["duration", "durationNode"], "duration", json!(value));
|
||||||
}
|
}
|
||||||
|
repair_money_printer_comfy_prompt(prompt);
|
||||||
Ok(video_prompt)
|
Ok(video_prompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn repair_money_printer_comfy_prompt(prompt: &mut Value) {
|
||||||
|
repair_bytedance_video_nodes(prompt);
|
||||||
|
repair_resize_image_mask_nodes(prompt);
|
||||||
|
repair_text_generate_ltx2_prompt_nodes(prompt);
|
||||||
|
repair_wan2_api_nodes(prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn repair_bytedance_video_nodes(prompt: &mut Value) {
|
||||||
|
let Some(nodes) = prompt.as_object_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for (_node_id, node) in nodes.iter_mut() {
|
||||||
|
let class_type = node.get("class_type").and_then(Value::as_str).unwrap_or_default();
|
||||||
|
if !matches!(class_type, "ByteDanceTextToVideoNode" | "ByteDance2TextToVideoNode") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Some(duration) = inputs.get("duration").and_then(value_to_duration_seconds) {
|
||||||
|
inputs.insert("duration".to_string(), json!(duration.clamp(3, 12)));
|
||||||
|
}
|
||||||
|
if let Some(duration) = inputs.get("model.duration").and_then(value_to_duration_seconds) {
|
||||||
|
inputs.insert("model.duration".to_string(), json!(duration.clamp(4, 15)));
|
||||||
|
}
|
||||||
|
if inputs
|
||||||
|
.get("resolution")
|
||||||
|
.is_some_and(|value| {
|
||||||
|
let normalized = value.as_str().unwrap_or_default().trim().to_ascii_lowercase();
|
||||||
|
!matches!(normalized.as_str(), "480p" | "720p" | "1080p")
|
||||||
|
})
|
||||||
|
{
|
||||||
|
inputs.insert("resolution".to_string(), json!("480p"));
|
||||||
|
} else if let Some(normalized) = inputs
|
||||||
|
.get("resolution")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(|value| value.trim().to_ascii_lowercase())
|
||||||
|
{
|
||||||
|
inputs.insert("resolution".to_string(), json!(normalized));
|
||||||
|
}
|
||||||
|
if inputs
|
||||||
|
.get("aspect_ratio")
|
||||||
|
.is_some_and(|value| !matches!(value.as_str(), Some("16:9" | "4:3" | "1:1" | "3:4" | "9:16" | "21:9")))
|
||||||
|
{
|
||||||
|
inputs.insert("aspect_ratio".to_string(), json!("9:16"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn repair_resize_image_mask_nodes(prompt: &mut Value) {
|
||||||
|
let Some(nodes) = prompt.as_object_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for (_node_id, node) in nodes.iter_mut() {
|
||||||
|
if node.get("class_type").and_then(Value::as_str) != Some("ResizeImageMaskNode") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if inputs
|
||||||
|
.get("resize_type.crop")
|
||||||
|
.is_some_and(|value| !matches!(value.as_str(), Some("disabled" | "center")))
|
||||||
|
{
|
||||||
|
inputs.insert("resize_type.crop".to_string(), Value::String("center".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn repair_text_generate_ltx2_prompt_nodes(prompt: &mut Value) {
|
||||||
|
let Some(nodes) = prompt.as_object_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for (_node_id, node) in nodes.iter_mut() {
|
||||||
|
if node.get("class_type").and_then(Value::as_str) != Some("TextGenerateLTX2Prompt") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if inputs
|
||||||
|
.get("sampling_mode.repetition_penalty")
|
||||||
|
.and_then(Value::as_f64)
|
||||||
|
.is_some_and(|value| !(0.0..=5.0).contains(&value))
|
||||||
|
{
|
||||||
|
inputs.insert("sampling_mode".to_string(), Value::String("on".to_string()));
|
||||||
|
inputs.insert("sampling_mode.temperature".to_string(), json!(0.7));
|
||||||
|
inputs.insert("sampling_mode.top_k".to_string(), json!(64));
|
||||||
|
inputs.insert("sampling_mode.top_p".to_string(), json!(0.95));
|
||||||
|
inputs.insert("sampling_mode.min_p".to_string(), json!(0.05));
|
||||||
|
inputs.insert("sampling_mode.repetition_penalty".to_string(), json!(1.05));
|
||||||
|
inputs.entry("sampling_mode.seed".to_string()).or_insert_with(|| json!(0));
|
||||||
|
inputs.insert("sampling_mode.presence_penalty".to_string(), json!(0.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn repair_wan2_api_nodes(prompt: &mut Value) {
|
||||||
|
let Some(nodes) = prompt.as_object_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for (_node_id, node) in nodes.iter_mut() {
|
||||||
|
let class_type = node.get("class_type").and_then(Value::as_str).unwrap_or_default();
|
||||||
|
if !class_type.starts_with("Wan2") || !class_type.ends_with("Api") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
repair_wan2_model_group(inputs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn repair_wan2_model_group(inputs: &mut Map<String, Value>) {
|
||||||
|
let model_value = inputs.get("model").cloned().unwrap_or(Value::Null);
|
||||||
|
if let Some(items) = model_value.as_array() {
|
||||||
|
if let Some(model) = items.first().cloned() {
|
||||||
|
inputs.insert("model".to_string(), model);
|
||||||
|
}
|
||||||
|
if let Some(resolution) = items.get(1).cloned() {
|
||||||
|
inputs.insert("model.resolution".to_string(), normalize_resolution_for_comfy(&resolution));
|
||||||
|
}
|
||||||
|
if let Some(duration) = items.get(2).and_then(value_to_duration_seconds) {
|
||||||
|
inputs.insert("model.duration".to_string(), json!(duration));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inputs
|
||||||
|
.get("model.resolution")
|
||||||
|
.is_some_and(|value| !matches!(value.as_str(), Some("720P" | "1080P")))
|
||||||
|
{
|
||||||
|
inputs.insert("model.resolution".to_string(), json!("1080P"));
|
||||||
|
}
|
||||||
|
if let Some(duration) = inputs.get("model.duration").and_then(value_to_duration_seconds) {
|
||||||
|
inputs.insert("model.duration".to_string(), json!(duration));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn submit_comfy_prompt(state: &AppState, prompt: Value) -> AppResult<(String, Value)> {
|
async fn submit_comfy_prompt(state: &AppState, prompt: Value) -> AppResult<(String, Value)> {
|
||||||
let body = json!({
|
let body = json!({
|
||||||
"client_id": format!("boosapi-money-printer-{}", uuid::Uuid::new_v4().simple()),
|
"client_id": format!("boosapi-money-printer-{}", uuid::Uuid::new_v4().simple()),
|
||||||
@@ -805,7 +1043,30 @@ async fn submit_comfy_prompt(state: &AppState, prompt: Value) -> AppResult<(Stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn comfy_history(state: &AppState, prompt_id: &str) -> AppResult<Value> {
|
async fn comfy_history(state: &AppState, prompt_id: &str) -> AppResult<Value> {
|
||||||
native_json(state, Method::GET, &format!("/history/{prompt_id}"), None).await
|
let path = if state.config.comfyui.uses_cloud_backend() {
|
||||||
|
format!("/jobs/{prompt_id}")
|
||||||
|
} else {
|
||||||
|
format!("/history/{prompt_id}")
|
||||||
|
};
|
||||||
|
native_json(state, Method::GET, &path, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn comfy_failure_message(value: &Value) -> Option<String> {
|
||||||
|
let status = scalar_string(value.get("status").or_else(|| value.get("state")))
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
if matches!(status.as_str(), "failed" | "error" | "cancelled" | "canceled") {
|
||||||
|
return Some(
|
||||||
|
scalar_string(value.pointer("/execution_error/exception_message"))
|
||||||
|
.or_else(|| scalar_string(value.get("error")))
|
||||||
|
.or_else(|| scalar_string(value.get("message")))
|
||||||
|
.unwrap_or_else(|| format!("Comfy job {status}")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(message) = scalar_string(value.pointer("/execution_error/exception_message")) {
|
||||||
|
return Some(message);
|
||||||
|
}
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn comfy_output_objects(value: &Value) -> Vec<Value> {
|
fn comfy_output_objects(value: &Value) -> Vec<Value> {
|
||||||
@@ -892,7 +1153,10 @@ async fn archive_comfy_output(
|
|||||||
prompt: Some(prompt.chars().take(1000).collect()),
|
prompt: Some(prompt.chars().take(1000).collect()),
|
||||||
model: Some("comfy-api".to_string()),
|
model: Some("comfy-api".to_string()),
|
||||||
remote_url: None,
|
remote_url: None,
|
||||||
metadata: json!({ "moneyPrinter": { "backend": "comfy", "promptId": prompt_id, "raw": item } }),
|
metadata: json!({
|
||||||
|
"workspaceAsset": { "role": "output" },
|
||||||
|
"moneyPrinter": { "backend": "comfy", "promptId": prompt_id, "raw": item },
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
match workspace::record_bytes_asset(state, input, &bytes, &content_type).await {
|
match workspace::record_bytes_asset(state, input, &bytes, &content_type).await {
|
||||||
Ok(asset) => Some(json!({
|
Ok(asset) => Some(json!({
|
||||||
@@ -1045,8 +1309,16 @@ async fn archive_video(
|
|||||||
prompt: &str,
|
prompt: &str,
|
||||||
model: &str,
|
model: &str,
|
||||||
video_url: &str,
|
video_url: &str,
|
||||||
metadata: Value,
|
mut metadata: Value,
|
||||||
) -> Option<Value> {
|
) -> Option<Value> {
|
||||||
|
if let Some(object) = metadata.as_object_mut() {
|
||||||
|
object.insert("workspaceAsset".to_string(), json!({ "role": "output" }));
|
||||||
|
} else {
|
||||||
|
metadata = json!({
|
||||||
|
"workspaceAsset": { "role": "output" },
|
||||||
|
"metadata": metadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
let input = WorkspaceAssetInput {
|
let input = WorkspaceAssetInput {
|
||||||
user_id: auth.user_id,
|
user_id: auth.user_id,
|
||||||
source: "money-printer".to_string(),
|
source: "money-printer".to_string(),
|
||||||
@@ -1154,6 +1426,23 @@ async fn create_job(
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string();
|
.to_string();
|
||||||
|
let submitted_result = json!({
|
||||||
|
"userId": auth_for_task.user_id,
|
||||||
|
"role": auth_for_task.role,
|
||||||
|
"keyName": auth_for_task.key_name,
|
||||||
|
"topic": topic,
|
||||||
|
"request": body,
|
||||||
|
"script": script,
|
||||||
|
"videoBackend": video_backend,
|
||||||
|
"model": "comfy-api",
|
||||||
|
"prompt": prompt,
|
||||||
|
"video": submitted,
|
||||||
|
});
|
||||||
|
sqlx::query("UPDATE background_tasks SET result_json = ?, updated_at = datetime('now') WHERE id = ? AND status IN ('pending', 'running')")
|
||||||
|
.bind(submitted_result.to_string())
|
||||||
|
.bind(&task_id_for_spawn)
|
||||||
|
.execute(&*state_for_task.db)
|
||||||
|
.await?;
|
||||||
let mut final_history = Value::Null;
|
let mut final_history = Value::Null;
|
||||||
for _ in 0..COMFY_MAX_POLLS {
|
for _ in 0..COMFY_MAX_POLLS {
|
||||||
if is_cancelled(&state_for_task, &task_id_for_spawn).await? {
|
if is_cancelled(&state_for_task, &task_id_for_spawn).await? {
|
||||||
@@ -1161,6 +1450,9 @@ async fn create_job(
|
|||||||
}
|
}
|
||||||
update_progress(&state_for_task, &task_id_for_spawn, 4, "Comfy 正在生成视频").await?;
|
update_progress(&state_for_task, &task_id_for_spawn, 4, "Comfy 正在生成视频").await?;
|
||||||
let history = comfy_history(&state_for_task, &prompt_id).await?;
|
let history = comfy_history(&state_for_task, &prompt_id).await?;
|
||||||
|
if let Some(message) = comfy_failure_message(&history) {
|
||||||
|
return Err(AppError::UpstreamError(message));
|
||||||
|
}
|
||||||
let outputs = comfy_output_objects(&history);
|
let outputs = comfy_output_objects(&history);
|
||||||
if !outputs.is_empty() {
|
if !outputs.is_empty() {
|
||||||
update_progress(&state_for_task, &task_id_for_spawn, 5, "Comfy 视频完成,正在归档").await?;
|
update_progress(&state_for_task, &task_id_for_spawn, 5, "Comfy 视频完成,正在归档").await?;
|
||||||
@@ -1188,7 +1480,7 @@ async fn create_job(
|
|||||||
}
|
}
|
||||||
(submitted, final_history, None, prompt, "comfy-api".to_string())
|
(submitted, final_history, None, prompt, "comfy-api".to_string())
|
||||||
} else {
|
} else {
|
||||||
update_progress(&state_for_task, &task_id_for_spawn, 3, "正在提交 BossAI 视频模型").await?;
|
update_progress(&state_for_task, &task_id_for_spawn, 3, "正在提交 BoosAPI 视频模型").await?;
|
||||||
let submitted = submit_video(&state_for_task, &auth_for_task, &body, &plan).await?;
|
let submitted = submit_video(&state_for_task, &auth_for_task, &body, &plan).await?;
|
||||||
if is_cancelled(&state_for_task, &task_id_for_spawn).await? {
|
if is_cancelled(&state_for_task, &task_id_for_spawn).await? {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|||||||
@@ -123,11 +123,12 @@ async fn resolve_picture_agent_auth(
|
|||||||
let token = extract_bearer_token(headers)
|
let token = extract_bearer_token(headers)
|
||||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||||
|
|
||||||
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
|
let proxy_token = state.runtime_proxy_token();
|
||||||
|
if token == state.config.auth.auth_token || token == proxy_token {
|
||||||
return Ok(PictureAgentAuth {
|
return Ok(PictureAgentAuth {
|
||||||
user_id: None,
|
user_id: None,
|
||||||
role: "admin".to_string(),
|
role: "admin".to_string(),
|
||||||
outbound_key: state.config.auth.proxy_token.clone(),
|
outbound_key: proxy_token,
|
||||||
key_name: "global-proxy".to_string(),
|
key_name: "global-proxy".to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -153,7 +154,7 @@ async fn resolve_picture_agent_auth(
|
|||||||
Err(_) if claims.role == "admin" => Ok(PictureAgentAuth {
|
Err(_) if claims.role == "admin" => Ok(PictureAgentAuth {
|
||||||
user_id: Some(user_id),
|
user_id: Some(user_id),
|
||||||
role: claims.role,
|
role: claims.role,
|
||||||
outbound_key: state.config.auth.proxy_token.clone(),
|
outbound_key: state.runtime_proxy_token(),
|
||||||
key_name: "global-proxy".to_string(),
|
key_name: "global-proxy".to_string(),
|
||||||
}),
|
}),
|
||||||
Err(message) => Err(AppError::Forbidden(message)),
|
Err(message) => Err(AppError::Forbidden(message)),
|
||||||
@@ -225,7 +226,7 @@ async fn status_handler(
|
|||||||
"role": auth.role,
|
"role": auth.role,
|
||||||
"userId": auth.user_id,
|
"userId": auth.user_id,
|
||||||
"keyName": auth.key_name,
|
"keyName": auth.key_name,
|
||||||
"upstreamLabel": "BossAI 本地图片路由",
|
"upstreamLabel": "BoosAPI 本地图片路由",
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+709
-329
File diff suppressed because it is too large
Load Diff
+260
-34
@@ -25,6 +25,15 @@ pub struct SiteDistributionQuery {
|
|||||||
_refresh: Option<String>,
|
_refresh: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ProxyDebugTraceQuery {
|
||||||
|
limit: Option<i64>,
|
||||||
|
offset: Option<i64>,
|
||||||
|
session_id: Option<String>,
|
||||||
|
model: Option<String>,
|
||||||
|
status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/stats/dashboard", get(dashboard))
|
.route("/api/stats/dashboard", get(dashboard))
|
||||||
@@ -45,6 +54,45 @@ async fn dashboard(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(q): Query<std::collections::HashMap<String, String>>,
|
Query(q): Query<std::collections::HashMap<String, String>>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
|
match q.get("view").map(String::as_str) {
|
||||||
|
Some("summary") => return Ok(Json(build_dashboard_summary(&state).await?)),
|
||||||
|
Some("insights") => {
|
||||||
|
let site_availability = build_site_availability(&state).await?;
|
||||||
|
let model_analysis = build_model_analysis(&state, 7).await?;
|
||||||
|
return Ok(Json(json!({
|
||||||
|
"generatedAt": chrono::Utc::now().to_rfc3339(),
|
||||||
|
"insights": [],
|
||||||
|
"siteAvailability": site_availability,
|
||||||
|
"modelAnalysis": model_analysis,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let summary = build_dashboard_summary(&state).await?;
|
||||||
|
let site_availability = build_site_availability(&state).await?;
|
||||||
|
let model_analysis = build_model_analysis(&state, 7).await?;
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"generatedAt": summary["generatedAt"].clone(),
|
||||||
|
"totalBalance": summary["totalBalance"].clone(),
|
||||||
|
"totalUsed": summary["totalUsed"].clone(),
|
||||||
|
"todaySpend": summary["todaySpend"].clone(),
|
||||||
|
"todayReward": summary["todayReward"].clone(),
|
||||||
|
"activeAccounts": summary["activeAccounts"].clone(),
|
||||||
|
"totalAccounts": summary["totalAccounts"].clone(),
|
||||||
|
"todayCheckin": summary["todayCheckin"].clone(),
|
||||||
|
"proxy24h": summary["proxy24h"].clone(),
|
||||||
|
"performance": summary["performance"].clone(),
|
||||||
|
"siteAvailability": site_availability,
|
||||||
|
"modelAnalysis": model_analysis,
|
||||||
|
"summary": summary["summary"].clone(),
|
||||||
|
"insights": [],
|
||||||
|
"stats": summary["stats"].clone(),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_dashboard_summary(state: &AppState) -> AppResult<Value> {
|
||||||
let sites: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sites")
|
let sites: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sites")
|
||||||
.fetch_one(&*state.db)
|
.fetch_one(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -125,10 +173,8 @@ async fn dashboard(
|
|||||||
)
|
)
|
||||||
.fetch_one(&*state.db)
|
.fetch_one(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
let site_availability = build_site_availability(&state).await?;
|
|
||||||
let model_analysis = build_model_analysis(&state, 7).await?;
|
|
||||||
|
|
||||||
let payload = json!({
|
Ok(json!({
|
||||||
"generatedAt": chrono::Utc::now().to_rfc3339(),
|
"generatedAt": chrono::Utc::now().to_rfc3339(),
|
||||||
"totalBalance": total_balance,
|
"totalBalance": total_balance,
|
||||||
"totalUsed": total_used,
|
"totalUsed": total_used,
|
||||||
@@ -150,8 +196,6 @@ async fn dashboard(
|
|||||||
"requestsPerMinute": perf_requests as f64 * 60.0 / perf_window_secs as f64,
|
"requestsPerMinute": perf_requests as f64 * 60.0 / perf_window_secs as f64,
|
||||||
"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,
|
|
||||||
"modelAnalysis": model_analysis,
|
|
||||||
"summary": {
|
"summary": {
|
||||||
"sites": sites,
|
"sites": sites,
|
||||||
"accounts": accounts,
|
"accounts": accounts,
|
||||||
@@ -169,31 +213,7 @@ async fn dashboard(
|
|||||||
"successRequests": success_24h,
|
"successRequests": success_24h,
|
||||||
"totalCost": total_cost_24h,
|
"totalCost": total_cost_24h,
|
||||||
},
|
},
|
||||||
});
|
}))
|
||||||
|
|
||||||
match q.get("view").map(String::as_str) {
|
|
||||||
Some("summary") => Ok(Json(json!({
|
|
||||||
"generatedAt": payload["generatedAt"],
|
|
||||||
"summary": payload["summary"],
|
|
||||||
"stats": payload["stats"],
|
|
||||||
"totalBalance": payload["totalBalance"],
|
|
||||||
"totalUsed": payload["totalUsed"],
|
|
||||||
"todaySpend": payload["todaySpend"],
|
|
||||||
"todayReward": payload["todayReward"],
|
|
||||||
"activeAccounts": payload["activeAccounts"],
|
|
||||||
"totalAccounts": payload["totalAccounts"],
|
|
||||||
"todayCheckin": payload["todayCheckin"],
|
|
||||||
"proxy24h": payload["proxy24h"],
|
|
||||||
"performance": payload["performance"],
|
|
||||||
}))),
|
|
||||||
Some("insights") => Ok(Json(json!({
|
|
||||||
"generatedAt": payload["generatedAt"],
|
|
||||||
"insights": payload["insights"],
|
|
||||||
"siteAvailability": payload["siteAvailability"],
|
|
||||||
"modelAnalysis": payload["modelAnalysis"],
|
|
||||||
}))),
|
|
||||||
_ => Ok(Json(payload)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn build_model_analysis(state: &AppState, days: i64) -> AppResult<Value> {
|
async fn build_model_analysis(state: &AppState, days: i64) -> AppResult<Value> {
|
||||||
@@ -407,6 +427,7 @@ async fn proxy_logs(
|
|||||||
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost,
|
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost,
|
||||||
l.error_message, l.retry_count, l.is_stream,
|
l.error_message, l.retry_count, l.is_stream,
|
||||||
l.client_family, l.client_app_id, l.client_app_name, l.client_confidence,
|
l.client_family, l.client_app_id, l.client_app_name, l.client_confidence,
|
||||||
|
l.content_summary, l.request_content, l.response_content,
|
||||||
l.created_at,
|
l.created_at,
|
||||||
a.site_id AS site_id,
|
a.site_id AS site_id,
|
||||||
COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username,
|
COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username,
|
||||||
@@ -486,6 +507,9 @@ pub struct ProxyLogRow {
|
|||||||
client_app_id: Option<String>,
|
client_app_id: Option<String>,
|
||||||
client_app_name: Option<String>,
|
client_app_name: Option<String>,
|
||||||
client_confidence: Option<String>,
|
client_confidence: Option<String>,
|
||||||
|
content_summary: Option<String>,
|
||||||
|
request_content: Option<String>,
|
||||||
|
response_content: Option<String>,
|
||||||
created_at: Option<String>,
|
created_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,6 +526,7 @@ async fn proxy_log_detail(
|
|||||||
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost,
|
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost,
|
||||||
l.error_message, l.retry_count, l.is_stream,
|
l.error_message, l.retry_count, l.is_stream,
|
||||||
l.client_family, l.client_app_id, l.client_app_name, l.client_confidence,
|
l.client_family, l.client_app_id, l.client_app_name, l.client_confidence,
|
||||||
|
l.content_summary, l.request_content, l.response_content,
|
||||||
l.created_at,
|
l.created_at,
|
||||||
a.site_id AS site_id,
|
a.site_id AS site_id,
|
||||||
COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username,
|
COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username,
|
||||||
@@ -523,14 +548,215 @@ async fn proxy_log_detail(
|
|||||||
Ok(Json(json!({ "item": item, "data": item })))
|
Ok(Json(json!({ "item": item, "data": item })))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn proxy_debug_traces() -> Json<Value> {
|
async fn proxy_debug_traces(
|
||||||
Json(json!({ "items": [], "data": [], "total": 0 }))
|
State(state): State<AppState>,
|
||||||
|
Query(q): Query<ProxyDebugTraceQuery>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let limit = q.limit.unwrap_or(50).clamp(1, 200);
|
||||||
|
let offset = q.offset.unwrap_or(0).max(0);
|
||||||
|
let mut where_sql = Vec::new();
|
||||||
|
let mut bind_values = Vec::new();
|
||||||
|
|
||||||
|
if let Some(session_id) = q
|
||||||
|
.session_id
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
{
|
||||||
|
where_sql.push("session_id = ?");
|
||||||
|
bind_values.push(session_id.to_string());
|
||||||
|
}
|
||||||
|
if let Some(model) = q.model.as_deref().map(str::trim).filter(|v| !v.is_empty()) {
|
||||||
|
where_sql.push("requested_model LIKE ?");
|
||||||
|
bind_values.push(format!("%{model}%"));
|
||||||
|
}
|
||||||
|
if let Some(status) = q.status.as_deref().map(str::trim).filter(|v| !v.is_empty()) {
|
||||||
|
where_sql.push("final_status = ?");
|
||||||
|
bind_values.push(status.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let filter = if where_sql.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" WHERE {}", where_sql.join(" AND "))
|
||||||
|
};
|
||||||
|
|
||||||
|
let list_sql = format!(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id, downstream_path, client_kind, session_id, trace_hint,
|
||||||
|
requested_model, downstream_api_key_id, sticky_session_key,
|
||||||
|
sticky_hit_channel_id, selected_channel_id, selected_route_id,
|
||||||
|
selected_account_id, selected_site_id, selected_site_platform,
|
||||||
|
final_status, final_http_status, final_upstream_path,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM proxy_debug_traces
|
||||||
|
{filter}
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
let mut list_query = sqlx::query_as::<_, ProxyDebugTraceRow>(&list_sql);
|
||||||
|
for value in &bind_values {
|
||||||
|
list_query = list_query.bind(value);
|
||||||
|
}
|
||||||
|
let items: Vec<Value> = list_query
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| serde_json::to_value(row).unwrap_or_else(|_| json!({})))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let count_sql = format!("SELECT COUNT(*) FROM proxy_debug_traces{filter}");
|
||||||
|
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
|
||||||
|
for value in &bind_values {
|
||||||
|
count_query = count_query.bind(value);
|
||||||
|
}
|
||||||
|
let total = count_query.fetch_one(&*state.db).await?;
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"items": items,
|
||||||
|
"data": items,
|
||||||
|
"total": total,
|
||||||
|
"page": (offset / limit) + 1,
|
||||||
|
"pageSize": limit,
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn proxy_debug_trace_detail(
|
async fn proxy_debug_trace_detail(
|
||||||
|
State(state): State<AppState>,
|
||||||
axum::extract::Path(id): axum::extract::Path<i64>,
|
axum::extract::Path(id): axum::extract::Path<i64>,
|
||||||
) -> Json<Value> {
|
) -> AppResult<Json<Value>> {
|
||||||
Json(json!({ "id": id, "events": [], "data": null }))
|
let trace: Option<ProxyDebugTraceDetailRow> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id, downstream_path, client_kind, session_id, trace_hint,
|
||||||
|
requested_model, downstream_api_key_id, request_headers_json,
|
||||||
|
request_body_json, sticky_session_key, sticky_hit_channel_id,
|
||||||
|
selected_channel_id, selected_route_id, selected_account_id,
|
||||||
|
selected_site_id, selected_site_platform, endpoint_candidates_json,
|
||||||
|
endpoint_runtime_state_json, decision_summary_json, final_status,
|
||||||
|
final_http_status, final_upstream_path, final_response_headers_json,
|
||||||
|
final_response_body_json, created_at, updated_at
|
||||||
|
FROM proxy_debug_traces
|
||||||
|
WHERE id = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some(trace) = trace else {
|
||||||
|
return Err(crate::error::AppError::NotFound(
|
||||||
|
"proxy debug trace not found".into(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let attempts: Vec<Value> = sqlx::query_as::<_, ProxyDebugAttemptRow>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id, trace_id, attempt_index, endpoint, request_path, target_url,
|
||||||
|
runtime_executor, request_headers_json, request_body_json,
|
||||||
|
response_status, response_headers_json, response_body_json,
|
||||||
|
raw_error_text, recover_applied, downgrade_decision,
|
||||||
|
downgrade_reason, memory_write_json, created_at
|
||||||
|
FROM proxy_debug_attempts
|
||||||
|
WHERE trace_id = ?
|
||||||
|
ORDER BY attempt_index ASC, id ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| serde_json::to_value(row).unwrap_or_else(|_| json!({})))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let data = serde_json::to_value(trace).unwrap_or_else(|_| json!({}));
|
||||||
|
Ok(Json(json!({
|
||||||
|
"id": id,
|
||||||
|
"item": data,
|
||||||
|
"data": data,
|
||||||
|
"attempts": attempts,
|
||||||
|
"events": attempts,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
|
||||||
|
pub struct ProxyDebugTraceRow {
|
||||||
|
id: i64,
|
||||||
|
downstream_path: String,
|
||||||
|
client_kind: Option<String>,
|
||||||
|
session_id: Option<String>,
|
||||||
|
trace_hint: Option<String>,
|
||||||
|
requested_model: Option<String>,
|
||||||
|
downstream_api_key_id: Option<i64>,
|
||||||
|
sticky_session_key: Option<String>,
|
||||||
|
sticky_hit_channel_id: Option<i64>,
|
||||||
|
selected_channel_id: Option<i64>,
|
||||||
|
selected_route_id: Option<i64>,
|
||||||
|
selected_account_id: Option<i64>,
|
||||||
|
selected_site_id: Option<i64>,
|
||||||
|
selected_site_platform: Option<String>,
|
||||||
|
final_status: Option<String>,
|
||||||
|
final_http_status: Option<i64>,
|
||||||
|
final_upstream_path: Option<String>,
|
||||||
|
created_at: Option<String>,
|
||||||
|
updated_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
|
||||||
|
pub struct ProxyDebugTraceDetailRow {
|
||||||
|
id: i64,
|
||||||
|
downstream_path: String,
|
||||||
|
client_kind: Option<String>,
|
||||||
|
session_id: Option<String>,
|
||||||
|
trace_hint: Option<String>,
|
||||||
|
requested_model: Option<String>,
|
||||||
|
downstream_api_key_id: Option<i64>,
|
||||||
|
request_headers_json: Option<String>,
|
||||||
|
request_body_json: Option<String>,
|
||||||
|
sticky_session_key: Option<String>,
|
||||||
|
sticky_hit_channel_id: Option<i64>,
|
||||||
|
selected_channel_id: Option<i64>,
|
||||||
|
selected_route_id: Option<i64>,
|
||||||
|
selected_account_id: Option<i64>,
|
||||||
|
selected_site_id: Option<i64>,
|
||||||
|
selected_site_platform: Option<String>,
|
||||||
|
endpoint_candidates_json: Option<String>,
|
||||||
|
endpoint_runtime_state_json: Option<String>,
|
||||||
|
decision_summary_json: Option<String>,
|
||||||
|
final_status: Option<String>,
|
||||||
|
final_http_status: Option<i64>,
|
||||||
|
final_upstream_path: Option<String>,
|
||||||
|
final_response_headers_json: Option<String>,
|
||||||
|
final_response_body_json: Option<String>,
|
||||||
|
created_at: Option<String>,
|
||||||
|
updated_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
|
||||||
|
pub struct ProxyDebugAttemptRow {
|
||||||
|
id: i64,
|
||||||
|
trace_id: i64,
|
||||||
|
attempt_index: i64,
|
||||||
|
endpoint: String,
|
||||||
|
request_path: String,
|
||||||
|
target_url: String,
|
||||||
|
runtime_executor: Option<String>,
|
||||||
|
request_headers_json: Option<String>,
|
||||||
|
request_body_json: Option<String>,
|
||||||
|
response_status: Option<i64>,
|
||||||
|
response_headers_json: Option<String>,
|
||||||
|
response_body_json: Option<String>,
|
||||||
|
raw_error_text: Option<String>,
|
||||||
|
recover_applied: Option<bool>,
|
||||||
|
downgrade_decision: Option<bool>,
|
||||||
|
downgrade_reason: Option<String>,
|
||||||
|
memory_write_json: Option<String>,
|
||||||
|
created_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/stats/model-by-site?siteId=...&days=...
|
/// GET /api/stats/model-by-site?siteId=...&days=...
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
// Background tasks routes — list and inspect async operations
|
// Background tasks routes — list and inspect async operations
|
||||||
|
|
||||||
use std::convert::Infallible;
|
|
||||||
|
|
||||||
use crate::{db::models::background_task::BackgroundTask, error::AppResult, AppState};
|
use crate::{db::models::background_task::BackgroundTask, error::AppResult, AppState};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
response::{
|
|
||||||
sse::{Event, Sse},
|
|
||||||
IntoResponse,
|
|
||||||
},
|
|
||||||
routing::get,
|
routing::get,
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use futures::stream::Stream;
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
@@ -26,10 +19,6 @@ pub fn routes() -> Router<AppState> {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/tasks", get(list_tasks))
|
.route("/api/tasks", get(list_tasks))
|
||||||
.route("/api/tasks/{id}", get(get_task))
|
.route("/api/tasks/{id}", get(get_task))
|
||||||
.route(
|
|
||||||
"/api/update-center/tasks/{id}/stream",
|
|
||||||
get(stream_task_logs),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/tasks — list background tasks
|
/// GET /api/tasks — list background tasks
|
||||||
@@ -67,113 +56,3 @@ async fn get_task(State(state): State<AppState>, Path(id): Path<String>) -> AppR
|
|||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/update-center/tasks/{id}/stream — SSE stream of task progress
|
|
||||||
async fn stream_task_logs(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, axum::response::Response> {
|
|
||||||
info!("Streaming task logs for: {}", id);
|
|
||||||
|
|
||||||
// Check task exists
|
|
||||||
let task: Option<BackgroundTask> =
|
|
||||||
sqlx::query_as::<_, BackgroundTask>("SELECT * FROM background_tasks WHERE id = ?")
|
|
||||||
.bind(&id)
|
|
||||||
.fetch_optional(&*state.db)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
(
|
|
||||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
format!("DB error: {e}"),
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let task = match task {
|
|
||||||
Some(t) => t,
|
|
||||||
None => {
|
|
||||||
return Err((
|
|
||||||
axum::http::StatusCode::NOT_FOUND,
|
|
||||||
json!({ "success": false, "message": "task not found" }).to_string(),
|
|
||||||
)
|
|
||||||
.into_response());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Stream task state with polling
|
|
||||||
let initial_status = task.status.clone();
|
|
||||||
let is_done = initial_status != "pending" && initial_status != "running";
|
|
||||||
|
|
||||||
let state_clone = state.clone();
|
|
||||||
let stream = async_stream::stream! {
|
|
||||||
// Yield current task status
|
|
||||||
yield Ok::<_, Infallible>(Event::default()
|
|
||||||
.event("status")
|
|
||||||
.data(json!({ "status": initial_status }).to_string()));
|
|
||||||
|
|
||||||
yield Ok::<_, Infallible>(Event::default()
|
|
||||||
.event("log")
|
|
||||||
.data(json!({
|
|
||||||
"seq": 1,
|
|
||||||
"message": format!("Task {}: {}", initial_status, task.message.as_deref().unwrap_or("")),
|
|
||||||
"createdAt": task.created_at.as_deref().unwrap_or(""),
|
|
||||||
}).to_string()));
|
|
||||||
|
|
||||||
if is_done {
|
|
||||||
yield Ok::<_, Infallible>(Event::default()
|
|
||||||
.event("done")
|
|
||||||
.data(json!({ "status": initial_status }).to_string()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Poll for updates
|
|
||||||
let poll_id = id.clone();
|
|
||||||
for i in 0..120 { // Poll up to 120 times (2 minutes)
|
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
|
|
||||||
|
|
||||||
let current: Option<BackgroundTask> = sqlx::query_as(
|
|
||||||
"SELECT * FROM background_tasks WHERE id = ?"
|
|
||||||
)
|
|
||||||
.bind(&poll_id)
|
|
||||||
.fetch_optional(&*state_clone.db)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten();
|
|
||||||
|
|
||||||
match current {
|
|
||||||
Some(t) => {
|
|
||||||
let status = t.status;
|
|
||||||
let updated_at = t.updated_at.unwrap_or_default();
|
|
||||||
|
|
||||||
yield Ok::<_, Infallible>(Event::default()
|
|
||||||
.event("log")
|
|
||||||
.data(json!({
|
|
||||||
"seq": i + 2,
|
|
||||||
"message": format!("Status: {} — {}", status, t.message.as_deref().unwrap_or("")),
|
|
||||||
"createdAt": updated_at,
|
|
||||||
}).to_string()));
|
|
||||||
|
|
||||||
if status != "pending" && status != "running" {
|
|
||||||
yield Ok::<_, Infallible>(Event::default()
|
|
||||||
.event("done")
|
|
||||||
.data(json!({ "status": status }).to_string()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
yield Ok::<_, Infallible>(Event::default()
|
|
||||||
.event("done")
|
|
||||||
.data(json!({ "status": "unknown", "message": "task removed" }).to_string()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timeout
|
|
||||||
yield Ok::<_, Infallible>(Event::default()
|
|
||||||
.event("done")
|
|
||||||
.data(json!({ "status": "timeout" }).to_string()));
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Sse::new(stream))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,435 +0,0 @@
|
|||||||
// Update Center routes — deploy/rollback orchestration via helper API
|
|
||||||
|
|
||||||
#![allow(clippy::type_complexity)]
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
error::{AppError, AppResult},
|
|
||||||
AppState,
|
|
||||||
};
|
|
||||||
use axum::{
|
|
||||||
extract::State,
|
|
||||||
routing::{get, post, put},
|
|
||||||
Json, Router,
|
|
||||||
};
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
const UC_CONFIG_KEY: &str = "update_center_config";
|
|
||||||
|
|
||||||
const TASK_TYPE_DEPLOY: &str = "update-center-deploy";
|
|
||||||
const TASK_TYPE_ROLLBACK: &str = "update-center-rollback";
|
|
||||||
|
|
||||||
const DEDUPE_KEY_DEPLOY: &str = "update-center:deploy";
|
|
||||||
const DEDUPE_KEY_ROLLBACK: &str = "update-center:rollback";
|
|
||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
|
||||||
Router::new()
|
|
||||||
.route("/api/update-center/status", get(get_status))
|
|
||||||
.route("/api/update-center/check", post(check_status))
|
|
||||||
.route("/api/update-center/config", put(update_config))
|
|
||||||
.route("/api/update-center/deploy", post(deploy_handler))
|
|
||||||
.route("/api/update-center/rollback", post(rollback_handler))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /api/update-center/status
|
|
||||||
async fn get_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
|
||||||
let config = load_uc_config(&state).await?;
|
|
||||||
let helper_token = get_helper_token();
|
|
||||||
|
|
||||||
let last_deploy = load_last_task(&state, TASK_TYPE_DEPLOY).await?;
|
|
||||||
let last_rollback = load_last_task(&state, TASK_TYPE_ROLLBACK).await?;
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"enabled": config.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false),
|
|
||||||
"helperBaseUrl": config.get("helperBaseUrl"),
|
|
||||||
"namespace": config.get("namespace"),
|
|
||||||
"releaseName": config.get("releaseName"),
|
|
||||||
"helperTokenConfigured": !helper_token.is_empty(),
|
|
||||||
"lastDeploy": last_deploy,
|
|
||||||
"lastRollback": last_rollback,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /api/update-center/check — refresh status cache
|
|
||||||
async fn check_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
|
||||||
// Same as get_status for now
|
|
||||||
get_status(State(state)).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PUT /api/update-center/config
|
|
||||||
async fn update_config(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(body): Json<Value>,
|
|
||||||
) -> AppResult<Json<Value>> {
|
|
||||||
let serialized = serde_json::to_string(&body)
|
|
||||||
.map_err(|e| AppError::BadRequest(format!("Invalid config JSON: {e}")))?;
|
|
||||||
|
|
||||||
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
|
||||||
.bind(UC_CONFIG_KEY)
|
|
||||||
.bind(&serialized)
|
|
||||||
.execute(&*state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"success": true,
|
|
||||||
"config": body,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /api/update-center/deploy
|
|
||||||
async fn deploy_handler(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(body): Json<Value>,
|
|
||||||
) -> AppResult<Json<Value>> {
|
|
||||||
let config = load_uc_config(&state).await?;
|
|
||||||
let helper_token = get_helper_token();
|
|
||||||
|
|
||||||
let helper_base = config
|
|
||||||
.get("helperBaseUrl")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim();
|
|
||||||
let namespace = config
|
|
||||||
.get("namespace")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim();
|
|
||||||
let chart_ref = config
|
|
||||||
.get("chartRef")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim();
|
|
||||||
let image_repo = config
|
|
||||||
.get("imageRepository")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
if helper_base.is_empty()
|
|
||||||
|| namespace.is_empty()
|
|
||||||
|| chart_ref.is_empty()
|
|
||||||
|| helper_token.is_empty()
|
|
||||||
{
|
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
"Update center not fully configured".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let target_tag = body
|
|
||||||
.get("targetTag")
|
|
||||||
.or_else(|| body.get("targetVersion"))
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(str::trim)
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
if target_tag.is_empty() {
|
|
||||||
return Err(AppError::BadRequest("targetTag is required".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract owned values for the spawn
|
|
||||||
let helper_base_owned = helper_base.trim_end_matches('/').to_string();
|
|
||||||
let namespace_owned = namespace.to_string();
|
|
||||||
let chart_ref_owned = chart_ref.to_string();
|
|
||||||
let image_repo_owned = image_repo.to_string();
|
|
||||||
let release_name = config
|
|
||||||
.get("releaseName")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.map(ToOwned::to_owned);
|
|
||||||
let target_tag_owned = target_tag.to_string();
|
|
||||||
let helper_token_owned = helper_token;
|
|
||||||
let title = format!("部署 {}", target_tag);
|
|
||||||
|
|
||||||
let task_id = uuid::Uuid::new_v4().to_string();
|
|
||||||
|
|
||||||
// Create background task
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO background_tasks (id, task_type, title, status, message, dedupe_key)
|
|
||||||
VALUES (?, ?, ?, 'running', ?, ?)",
|
|
||||||
)
|
|
||||||
.bind(&task_id)
|
|
||||||
.bind(TASK_TYPE_DEPLOY)
|
|
||||||
.bind(&title)
|
|
||||||
.bind(format!(
|
|
||||||
"Starting deploy of {} to {}/{}",
|
|
||||||
target_tag, namespace, chart_ref
|
|
||||||
))
|
|
||||||
.bind(DEDUPE_KEY_DEPLOY)
|
|
||||||
.execute(&*state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Call deploy helper in background (fire-and-forget via tokio)
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
let deploy_url = format!("{}/api/deploy", helper_base_owned);
|
|
||||||
let task_id_clone = task_id.clone();
|
|
||||||
let db_pool = state.db.clone();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let payload = json!({
|
|
||||||
"namespace": namespace_owned,
|
|
||||||
"releaseName": release_name,
|
|
||||||
"chartRef": chart_ref_owned,
|
|
||||||
"imageRepository": image_repo_owned,
|
|
||||||
"targetTag": target_tag_owned,
|
|
||||||
});
|
|
||||||
|
|
||||||
match client
|
|
||||||
.post(&deploy_url)
|
|
||||||
.header("Authorization", format!("Bearer {}", helper_token_owned))
|
|
||||||
.json(&payload)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(resp) => {
|
|
||||||
let status = resp.status();
|
|
||||||
let status_code = status.as_u16();
|
|
||||||
let body_text = resp.text().await.unwrap_or_default();
|
|
||||||
|
|
||||||
if status.is_success() {
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"UPDATE background_tasks
|
|
||||||
SET status = 'succeeded', message = ?, finished_at = datetime('now')
|
|
||||||
WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(format!("Deploy completed (HTTP {})", status_code))
|
|
||||||
.bind(&task_id_clone)
|
|
||||||
.execute(&*db_pool)
|
|
||||||
.await;
|
|
||||||
} else {
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"UPDATE background_tasks
|
|
||||||
SET status = 'failed', error = ?, finished_at = datetime('now')
|
|
||||||
WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(format!(
|
|
||||||
"Helper returned HTTP {}: {}",
|
|
||||||
status_code, body_text
|
|
||||||
))
|
|
||||||
.bind(&task_id_clone)
|
|
||||||
.execute(&*db_pool)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"UPDATE background_tasks
|
|
||||||
SET status = 'failed', error = ?, finished_at = datetime('now')
|
|
||||||
WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(format!("Helper unreachable: {}", e))
|
|
||||||
.bind(&task_id_clone)
|
|
||||||
.execute(&*db_pool)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"success": true,
|
|
||||||
"reused": false,
|
|
||||||
"task": {
|
|
||||||
"id": task_id,
|
|
||||||
"type": TASK_TYPE_DEPLOY,
|
|
||||||
"title": title,
|
|
||||||
"status": "running",
|
|
||||||
},
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /api/update-center/rollback
|
|
||||||
async fn rollback_handler(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(body): Json<Value>,
|
|
||||||
) -> AppResult<Json<Value>> {
|
|
||||||
let config = load_uc_config(&state).await?;
|
|
||||||
let helper_token = get_helper_token();
|
|
||||||
|
|
||||||
let helper_base = config
|
|
||||||
.get("helperBaseUrl")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim();
|
|
||||||
let namespace = config
|
|
||||||
.get("namespace")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
if helper_base.is_empty() || namespace.is_empty() || helper_token.is_empty() {
|
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
"Update center not fully configured".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let target_revision = body
|
|
||||||
.get("targetRevision")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(str::trim)
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
if target_revision.is_empty() {
|
|
||||||
return Err(AppError::BadRequest("targetRevision is required".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let helper_base_owned = helper_base.trim_end_matches('/').to_string();
|
|
||||||
let namespace_owned = namespace.to_string();
|
|
||||||
let release_name = config
|
|
||||||
.get("releaseName")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.map(ToOwned::to_owned);
|
|
||||||
let target_revision_owned = target_revision.to_string();
|
|
||||||
let helper_token_owned = helper_token;
|
|
||||||
let title = format!("回退 {}", target_revision);
|
|
||||||
|
|
||||||
let task_id = uuid::Uuid::new_v4().to_string();
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO background_tasks (id, task_type, title, status, message, dedupe_key)
|
|
||||||
VALUES (?, ?, ?, 'running', ?, ?)",
|
|
||||||
)
|
|
||||||
.bind(&task_id)
|
|
||||||
.bind(TASK_TYPE_ROLLBACK)
|
|
||||||
.bind(&title)
|
|
||||||
.bind(format!(
|
|
||||||
"Starting rollback to {} in {}",
|
|
||||||
target_revision, namespace
|
|
||||||
))
|
|
||||||
.bind(DEDUPE_KEY_ROLLBACK)
|
|
||||||
.execute(&*state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
let rollback_url = format!("{}/api/rollback", helper_base_owned);
|
|
||||||
let task_id_clone = task_id.clone();
|
|
||||||
let db_pool = state.db.clone();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let payload = json!({
|
|
||||||
"namespace": namespace_owned,
|
|
||||||
"releaseName": release_name,
|
|
||||||
"targetRevision": target_revision_owned,
|
|
||||||
});
|
|
||||||
|
|
||||||
match client
|
|
||||||
.post(&rollback_url)
|
|
||||||
.header("Authorization", format!("Bearer {}", helper_token_owned))
|
|
||||||
.json(&payload)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(resp) => {
|
|
||||||
let status = resp.status();
|
|
||||||
let status_code = status.as_u16();
|
|
||||||
let body_text = resp.text().await.unwrap_or_default();
|
|
||||||
|
|
||||||
if status.is_success() {
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"UPDATE background_tasks
|
|
||||||
SET status = 'succeeded', message = ?, finished_at = datetime('now')
|
|
||||||
WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(format!("Rollback completed (HTTP {})", status_code))
|
|
||||||
.bind(&task_id_clone)
|
|
||||||
.execute(&*db_pool)
|
|
||||||
.await;
|
|
||||||
} else {
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"UPDATE background_tasks
|
|
||||||
SET status = 'failed', error = ?, finished_at = datetime('now')
|
|
||||||
WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(format!(
|
|
||||||
"Helper returned HTTP {}: {}",
|
|
||||||
status_code, body_text
|
|
||||||
))
|
|
||||||
.bind(&task_id_clone)
|
|
||||||
.execute(&*db_pool)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let _ = sqlx::query(
|
|
||||||
"UPDATE background_tasks
|
|
||||||
SET status = 'failed', error = ?, finished_at = datetime('now')
|
|
||||||
WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(format!("Helper unreachable: {}", e))
|
|
||||||
.bind(&task_id_clone)
|
|
||||||
.execute(&*db_pool)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"success": true,
|
|
||||||
"reused": false,
|
|
||||||
"task": {
|
|
||||||
"id": task_id,
|
|
||||||
"type": TASK_TYPE_ROLLBACK,
|
|
||||||
"title": title,
|
|
||||||
"status": "running",
|
|
||||||
},
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fn get_helper_token() -> String {
|
|
||||||
std::env::var("UPDATE_CENTER_HELPER_TOKEN")
|
|
||||||
.or_else(|_| std::env::var("DEPLOY_HELPER_TOKEN"))
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn load_uc_config(state: &AppState) -> AppResult<Value> {
|
|
||||||
let row: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?")
|
|
||||||
.bind(UC_CONFIG_KEY)
|
|
||||||
.fetch_optional(&*state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
match row {
|
|
||||||
Some((v,)) => serde_json::from_str(&v)
|
|
||||||
.map_err(|e| AppError::BadRequest(format!("Invalid update center config JSON: {e}"))),
|
|
||||||
None => Ok(json!({})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn load_last_task(state: &AppState, task_type: &str) -> AppResult<Option<Value>> {
|
|
||||||
let row: Option<(
|
|
||||||
String,
|
|
||||||
String,
|
|
||||||
Option<String>,
|
|
||||||
Option<String>,
|
|
||||||
String,
|
|
||||||
Option<String>,
|
|
||||||
Option<String>,
|
|
||||||
)> = sqlx::query_as(
|
|
||||||
"SELECT id, status, message, error, created_at, started_at, finished_at
|
|
||||||
FROM background_tasks
|
|
||||||
WHERE task_type = ?
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT 1",
|
|
||||||
)
|
|
||||||
.bind(task_type)
|
|
||||||
.fetch_optional(&*state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(row.map(
|
|
||||||
|(id, status, message, error, created_at, started_at, finished_at)| {
|
|
||||||
json!({
|
|
||||||
"id": id,
|
|
||||||
"type": task_type,
|
|
||||||
"status": status,
|
|
||||||
"message": message,
|
|
||||||
"error": error,
|
|
||||||
"createdAt": created_at,
|
|
||||||
"startedAt": started_at,
|
|
||||||
"finishedAt": finished_at,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
))
|
|
||||||
}
|
|
||||||
+120
-24
@@ -29,16 +29,22 @@ pub fn routes() -> Router<AppState> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn user_picture_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<Value>> {
|
async fn user_picture_log_items_limited(
|
||||||
|
state: &AppState,
|
||||||
|
user_id: i64,
|
||||||
|
limit: i64,
|
||||||
|
) -> AppResult<Vec<Value>> {
|
||||||
let tasks: Vec<BackgroundTask> = sqlx::query_as(
|
let tasks: Vec<BackgroundTask> = sqlx::query_as(
|
||||||
"SELECT *
|
"SELECT *
|
||||||
FROM background_tasks
|
FROM background_tasks
|
||||||
WHERE task_type = ?
|
WHERE task_type = ?
|
||||||
AND json_extract(result_json, '$.userId') = ?
|
AND json_extract(result_json, '$.userId') = ?
|
||||||
ORDER BY created_at DESC, id DESC",
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT ?",
|
||||||
)
|
)
|
||||||
.bind("picture-agent-generation")
|
.bind("picture-agent-generation")
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
|
.bind(limit.clamp(1, 5000))
|
||||||
.fetch_all(&*state.db)
|
.fetch_all(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -57,7 +63,19 @@ async fn user_picture_log_items(state: &AppState, user_id: i64) -> AppResult<Vec
|
|||||||
"pending" => "pending",
|
"pending" => "pending",
|
||||||
_ => "running",
|
_ => "running",
|
||||||
};
|
};
|
||||||
json!({
|
let content_summary = result
|
||||||
|
.get("prompt")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.or_else(|| result.get("input").and_then(Value::as_str))
|
||||||
|
.or_else(|| result.get("message").and_then(Value::as_str))
|
||||||
|
.map(|text| text.chars().take(240).collect::<String>());
|
||||||
|
let request_content = result
|
||||||
|
.get("prompt")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.or_else(|| result.get("input").and_then(Value::as_str))
|
||||||
|
.or_else(|| result.get("message").and_then(Value::as_str))
|
||||||
|
.map(str::to_string);
|
||||||
|
let mut item = json!({
|
||||||
"id": task.id,
|
"id": task.id,
|
||||||
"route_id": Value::Null,
|
"route_id": Value::Null,
|
||||||
"channel_id": Value::Null,
|
"channel_id": Value::Null,
|
||||||
@@ -97,12 +115,28 @@ async fn user_picture_log_items(state: &AppState, user_id: i64) -> AppResult<Vec
|
|||||||
"progress_total": task.progress_total,
|
"progress_total": task.progress_total,
|
||||||
"status_label": status,
|
"status_label": status,
|
||||||
"result": result,
|
"result": result,
|
||||||
})
|
});
|
||||||
|
if let Some(object) = item.as_object_mut() {
|
||||||
|
object.insert(
|
||||||
|
"content_summary".to_string(),
|
||||||
|
content_summary.map(Value::String).unwrap_or(Value::Null),
|
||||||
|
);
|
||||||
|
object.insert(
|
||||||
|
"request_content".to_string(),
|
||||||
|
request_content.map(Value::String).unwrap_or(Value::Null),
|
||||||
|
);
|
||||||
|
object.insert("response_content".to_string(), Value::Null);
|
||||||
|
}
|
||||||
|
item
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn user_usage_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<Value>> {
|
async fn user_usage_log_items_limited(
|
||||||
|
state: &AppState,
|
||||||
|
user_id: i64,
|
||||||
|
limit: i64,
|
||||||
|
) -> AppResult<Vec<Value>> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
@@ -118,10 +152,11 @@ async fn user_usage_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<V
|
|||||||
JOIN downstream_api_keys k ON k.id = l.downstream_key_id
|
JOIN downstream_api_keys k ON k.id = l.downstream_key_id
|
||||||
WHERE k.user_id = ?
|
WHERE k.user_id = ?
|
||||||
ORDER BY l.created_at DESC, l.id DESC
|
ORDER BY l.created_at DESC, l.id DESC
|
||||||
LIMIT 5000
|
LIMIT ?
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
|
.bind(limit.clamp(1, 5000))
|
||||||
.fetch_all(&*state.db)
|
.fetch_all(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -170,6 +205,9 @@ async fn user_usage_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<V
|
|||||||
"client_app_id": "picture-agent",
|
"client_app_id": "picture-agent",
|
||||||
"client_app_name": "Picture Agent",
|
"client_app_name": "Picture Agent",
|
||||||
"client_confidence": "high",
|
"client_confidence": "high",
|
||||||
|
"content_summary": Value::Null,
|
||||||
|
"request_content": Value::Null,
|
||||||
|
"response_content": Value::Null,
|
||||||
"created_at": row.try_get::<Option<String>, _>("created_at").ok().flatten(),
|
"created_at": row.try_get::<Option<String>, _>("created_at").ok().flatten(),
|
||||||
"source": "usage-logs",
|
"source": "usage-logs",
|
||||||
"task_type": "usage-log",
|
"task_type": "usage-log",
|
||||||
@@ -191,6 +229,9 @@ fn user_log_text(item: &Value) -> String {
|
|||||||
item.get("site_name").and_then(Value::as_str),
|
item.get("site_name").and_then(Value::as_str),
|
||||||
item.get("site_url").and_then(Value::as_str),
|
item.get("site_url").and_then(Value::as_str),
|
||||||
item.get("error_message").and_then(Value::as_str),
|
item.get("error_message").and_then(Value::as_str),
|
||||||
|
item.get("content_summary").and_then(Value::as_str),
|
||||||
|
item.get("request_content").and_then(Value::as_str),
|
||||||
|
item.get("response_content").and_then(Value::as_str),
|
||||||
item.get("message").and_then(Value::as_str),
|
item.get("message").and_then(Value::as_str),
|
||||||
item.get("title").and_then(Value::as_str),
|
item.get("title").and_then(Value::as_str),
|
||||||
item.get("source").and_then(Value::as_str),
|
item.get("source").and_then(Value::as_str),
|
||||||
@@ -285,6 +326,16 @@ fn sort_user_logs_desc(items: &mut [Value]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn load_user_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<Value>> {
|
async fn load_user_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<Value>> {
|
||||||
|
load_user_log_items_limited(state, user_id, 5000, false).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_user_log_items_limited(
|
||||||
|
state: &AppState,
|
||||||
|
user_id: i64,
|
||||||
|
limit: i64,
|
||||||
|
truncate_combined: bool,
|
||||||
|
) -> AppResult<Vec<Value>> {
|
||||||
|
let limit = limit.clamp(1, 5000);
|
||||||
let mut items: Vec<Value> = sqlx::query_as::<_, crate::routes::stats::ProxyLogRow>(
|
let mut items: Vec<Value> = sqlx::query_as::<_, crate::routes::stats::ProxyLogRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
@@ -294,6 +345,7 @@ async fn load_user_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<Va
|
|||||||
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost,
|
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost,
|
||||||
l.error_message, l.retry_count, l.is_stream,
|
l.error_message, l.retry_count, l.is_stream,
|
||||||
l.client_family, l.client_app_id, l.client_app_name, l.client_confidence,
|
l.client_family, l.client_app_id, l.client_app_name, l.client_confidence,
|
||||||
|
l.content_summary, l.request_content, l.response_content,
|
||||||
l.created_at,
|
l.created_at,
|
||||||
a.site_id AS site_id,
|
a.site_id AS site_id,
|
||||||
COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username,
|
COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username,
|
||||||
@@ -308,22 +360,78 @@ async fn load_user_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<Va
|
|||||||
LEFT JOIN sites s ON s.id = a.site_id
|
LEFT JOIN sites s ON s.id = a.site_id
|
||||||
WHERE k.user_id = ?
|
WHERE k.user_id = ?
|
||||||
ORDER BY l.created_at DESC
|
ORDER BY l.created_at DESC
|
||||||
LIMIT 5000
|
LIMIT ?
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
|
.bind(limit)
|
||||||
.fetch_all(&*state.db)
|
.fetch_all(&*state.db)
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|row| serde_json::to_value(row).unwrap_or(json!({})))
|
.map(|row| serde_json::to_value(row).unwrap_or(json!({})))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
items.extend(user_usage_log_items(state, user_id).await?);
|
items.extend(user_usage_log_items_limited(state, user_id, limit).await?);
|
||||||
items.extend(user_picture_log_items(state, user_id).await?);
|
items.extend(user_picture_log_items_limited(state, user_id, limit).await?);
|
||||||
sort_user_logs_desc(&mut items);
|
sort_user_logs_desc(&mut items);
|
||||||
|
if truncate_combined {
|
||||||
|
items.truncate(limit as usize);
|
||||||
|
}
|
||||||
Ok(items)
|
Ok(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn load_user_log_summary(state: &AppState, user_id: i64) -> AppResult<(i64, i64, f64)> {
|
||||||
|
let proxy_summary: (i64, i64, f64) = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
COUNT(l.id) AS total,
|
||||||
|
COALESCE(SUM(CASE WHEN l.status = 'success' THEN 1 ELSE 0 END), 0) AS success_count,
|
||||||
|
COALESCE(SUM(l.estimated_cost), 0.0) AS total_cost
|
||||||
|
FROM proxy_logs l
|
||||||
|
JOIN downstream_api_keys k ON l.downstream_api_key_id = k.id
|
||||||
|
WHERE k.user_id = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let usage_summary: (i64, f64) = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
COUNT(l.id) AS total,
|
||||||
|
COALESCE(SUM(l.cost), 0.0) AS total_cost
|
||||||
|
FROM usage_logs l
|
||||||
|
JOIN downstream_api_keys k ON k.id = l.downstream_key_id
|
||||||
|
WHERE k.user_id = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let picture_summary: (i64, i64) = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total,
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'succeeded' THEN 1 ELSE 0 END), 0) AS success_count
|
||||||
|
FROM background_tasks
|
||||||
|
WHERE task_type = ?
|
||||||
|
AND json_extract(result_json, '$.userId') = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind("picture-agent-generation")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
proxy_summary.0 + usage_summary.0 + picture_summary.0,
|
||||||
|
proxy_summary.1 + usage_summary.0 + picture_summary.1,
|
||||||
|
proxy_summary.2 + usage_summary.1,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct UpdateProfilePayload {
|
struct UpdateProfilePayload {
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
@@ -537,13 +645,7 @@ async fn get_user_summary(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let user_id = current_user_id(&headers, &state)?;
|
let user_id = current_user_id(&headers, &state)?;
|
||||||
let items = load_user_log_items(&state, user_id).await?;
|
let (total, success_count, total_cost) = load_user_log_summary(&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>();
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"totalRequests": total,
|
"totalRequests": total,
|
||||||
@@ -571,14 +673,8 @@ async fn get_user_dashboard(
|
|||||||
.fetch_all(&*state.db)
|
.fetch_all(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let items = load_user_log_items(&state, user_id).await?;
|
let (total, success_count, total_cost) = load_user_log_summary(&state, user_id).await?;
|
||||||
let total = items.len() as i64;
|
let recent_logs = load_user_log_items_limited(&state, user_id, 5, true).await?;
|
||||||
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!({
|
Ok(Json(json!({
|
||||||
"user": {
|
"user": {
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ async fn call_llm(state: &AppState, prompt: &str, system: &str) -> AppResult<Str
|
|||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
.post(url)
|
.post(url)
|
||||||
.bearer_auth(&state.config.auth.proxy_token)
|
.bearer_auth(state.runtime_proxy_token())
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"model": &state.config.comfyui.agent_model,
|
"model": &state.config.comfyui.agent_model,
|
||||||
"messages": [
|
"messages": [
|
||||||
@@ -319,7 +319,7 @@ async fn call_image_gen(state: &AppState, prompt: &str) -> AppResult<String> {
|
|||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
.post(url)
|
.post(url)
|
||||||
.bearer_auth(&state.config.auth.proxy_token)
|
.bearer_auth(state.runtime_proxy_token())
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"model": "gpt-image-2",
|
"model": "gpt-image-2",
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
@@ -362,7 +362,7 @@ async fn call_tts(state: &AppState, text: &str, voice: &str) -> AppResult<Vec<u8
|
|||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
.post(url)
|
.post(url)
|
||||||
.bearer_auth(&state.config.auth.proxy_token)
|
.bearer_auth(state.runtime_proxy_token())
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"model": "tts-1",
|
"model": "tts-1",
|
||||||
"input": text,
|
"input": text,
|
||||||
|
|||||||
+45
-68
@@ -307,7 +307,8 @@ fn caller_from_headers(state: &AppState, headers: &HeaderMap) -> AppResult<Workf
|
|||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.ok_or_else(|| AppError::Unauthorized("Missing authorization token".into()))?;
|
.ok_or_else(|| AppError::Unauthorized("Missing authorization token".into()))?;
|
||||||
|
|
||||||
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
|
let proxy_token = state.runtime_proxy_token();
|
||||||
|
if token == state.config.auth.auth_token || token == proxy_token {
|
||||||
return Ok(WorkflowCaller::Admin);
|
return Ok(WorkflowCaller::Admin);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,28 +544,28 @@ fn core_workflow_template_index() -> Value {
|
|||||||
json!([
|
json!([
|
||||||
{
|
{
|
||||||
"moduleName": "default",
|
"moduleName": "default",
|
||||||
"title": "BossAI Examples",
|
"title": "BoosAPI Examples",
|
||||||
"category": "basics",
|
"category": "basics",
|
||||||
"type": "core",
|
"type": "core",
|
||||||
"isEssential": true,
|
"isEssential": true,
|
||||||
"templates": [
|
"templates": [
|
||||||
{
|
{
|
||||||
"name": "boosapi-text-to-image",
|
"name": "boosapi-text-to-image",
|
||||||
"title": "BossAI 文生图",
|
"title": "BoosAPI 文生图",
|
||||||
"description": "使用 BossAI 模型路由生成图片并预览输出。",
|
"description": "使用 BoosAPI 模型路由生成图片并预览输出。",
|
||||||
"mediaType": "image",
|
"mediaType": "image",
|
||||||
"mediaSubtype": "svg",
|
"mediaSubtype": "svg",
|
||||||
"tags": ["boosapi", "image"],
|
"tags": ["boosapi", "image"],
|
||||||
"models": ["BossAI"]
|
"models": ["BoosAPI"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "boosapi-text-to-video",
|
"name": "boosapi-text-to-video",
|
||||||
"title": "BossAI 文生视频",
|
"title": "BoosAPI 文生视频",
|
||||||
"description": "使用 BossAI 视频节点生成视频并保存输出。",
|
"description": "使用 BoosAPI 视频节点生成视频并保存输出。",
|
||||||
"mediaType": "image",
|
"mediaType": "image",
|
||||||
"mediaSubtype": "svg",
|
"mediaSubtype": "svg",
|
||||||
"tags": ["boosapi", "video"],
|
"tags": ["boosapi", "video"],
|
||||||
"models": ["BossAI"]
|
"models": ["BoosAPI"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1497,7 +1498,7 @@ async fn workflow_ui_index(State(state): State<AppState>) -> Html<String> {
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>BossAI Workflow Studio</title>
|
<title>BoosAPI Workflow Studio</title>
|
||||||
<style>
|
<style>
|
||||||
html, body { margin: 0; width: 100%; height: 100%; background: #111827; color: #e5e7eb; font-family: system-ui, sans-serif; }
|
html, body { margin: 0; width: 100%; height: 100%; background: #111827; color: #e5e7eb; font-family: system-ui, sans-serif; }
|
||||||
.wrap { display: grid; place-items: center; height: 100%; padding: 24px; text-align: center; }
|
.wrap { display: grid; place-items: center; height: 100%; padding: 24px; text-align: center; }
|
||||||
@@ -1664,9 +1665,9 @@ fn local_workflow_template_asset(path: &str) -> Option<Response> {
|
|||||||
"boosapi-text-to-video.json" => Some(Json(text_to_video_workflow_graph()).into_response()),
|
"boosapi-text-to-video.json" => Some(Json(text_to_video_workflow_graph()).into_response()),
|
||||||
"boosapi-text-to-image.svg" | "boosapi-text-to-video.svg" => {
|
"boosapi-text-to-image.svg" | "boosapi-text-to-video.svg" => {
|
||||||
let label = if path.contains("video") {
|
let label = if path.contains("video") {
|
||||||
"BossAI Video"
|
"BoosAPI Video"
|
||||||
} else {
|
} else {
|
||||||
"BossAI Image"
|
"BoosAPI Image"
|
||||||
};
|
};
|
||||||
Some(
|
Some(
|
||||||
(
|
(
|
||||||
@@ -3424,7 +3425,7 @@ async fn queue_boosapi_prompt(
|
|||||||
if graph.nodes.is_empty() {
|
if graph.nodes.is_empty() {
|
||||||
return Ok(prompt_error_response(
|
return Ok(prompt_error_response(
|
||||||
"画布里还没有可执行节点",
|
"画布里还没有可执行节点",
|
||||||
"请先从左侧节点库添加 BossAI 节点并连接后再执行。",
|
"请先从左侧节点库添加 BoosAPI 节点并连接后再执行。",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let name = payload
|
let name = payload
|
||||||
@@ -4077,6 +4078,7 @@ async fn archive_native_workflow_asset(
|
|||||||
model: None,
|
model: None,
|
||||||
remote_url: None,
|
remote_url: None,
|
||||||
metadata: json!({
|
metadata: json!({
|
||||||
|
"workspaceAsset": { "role": "output" },
|
||||||
"workflowUi": {
|
"workflowUi": {
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
"type": file_type,
|
"type": file_type,
|
||||||
@@ -4308,7 +4310,7 @@ fn run_status_messages(run: &crate::workflow::models::WorkflowRunSummary) -> Vec
|
|||||||
"node_type": failed_node.map(|node| node.node_type.as_str()).unwrap_or("Workflow"),
|
"node_type": failed_node.map(|node| node.node_type.as_str()).unwrap_or("Workflow"),
|
||||||
"executed": run.node_runs.iter().filter(|node| node.status == "completed").map(|node| node.node_id.clone()).collect::<Vec<_>>(),
|
"executed": run.node_runs.iter().filter(|node| node.status == "completed").map(|node| node.node_id.clone()).collect::<Vec<_>>(),
|
||||||
"exception_message": run.error_message.clone().unwrap_or_else(|| "Workflow execution failed".to_string()),
|
"exception_message": run.error_message.clone().unwrap_or_else(|| "Workflow execution failed".to_string()),
|
||||||
"exception_type": "BossAIWorkflowError",
|
"exception_type": "BoosAPIWorkflowError",
|
||||||
"traceback": [],
|
"traceback": [],
|
||||||
"current_inputs": failed_node.map(|node| node.inputs.clone()).unwrap_or_else(|| json!({})),
|
"current_inputs": failed_node.map(|node| node.inputs.clone()).unwrap_or_else(|| json!({})),
|
||||||
"current_outputs": failed_node.and_then(|node| node.outputs.clone()).unwrap_or_else(|| json!({})),
|
"current_outputs": failed_node.and_then(|node| node.outputs.clone()).unwrap_or_else(|| json!({})),
|
||||||
@@ -4430,7 +4432,7 @@ fn workflow_run_to_prompt_tuple(run: &crate::workflow::models::WorkflowRunSummar
|
|||||||
.workflow_name
|
.workflow_name
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.or(run.workflow_public_id.as_deref())
|
.or(run.workflow_public_id.as_deref())
|
||||||
.unwrap_or("BossAI workflow");
|
.unwrap_or("BoosAPI workflow");
|
||||||
let workflow = json!({ "name": workflow_name, "nodes": [], "links": [] });
|
let workflow = json!({ "name": workflow_name, "nodes": [], "links": [] });
|
||||||
let prompt = run
|
let prompt = run
|
||||||
.node_runs
|
.node_runs
|
||||||
@@ -4634,7 +4636,7 @@ fn workflow_progress_callback_with_run_sender(
|
|||||||
"node_id": "workflow",
|
"node_id": "workflow",
|
||||||
"node_type": "Workflow",
|
"node_type": "Workflow",
|
||||||
"exception_message": error.unwrap_or_else(|| "Workflow execution failed".to_string()),
|
"exception_message": error.unwrap_or_else(|| "Workflow execution failed".to_string()),
|
||||||
"exception_type": "BossAIWorkflowError",
|
"exception_type": "BoosAPIWorkflowError",
|
||||||
"traceback": [],
|
"traceback": [],
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -4712,7 +4714,7 @@ fn publish_run_events(
|
|||||||
"node_id": run.node_runs.last().map(|node| node.node_id.as_str()).unwrap_or("workflow"),
|
"node_id": run.node_runs.last().map(|node| node.node_id.as_str()).unwrap_or("workflow"),
|
||||||
"node_type": run.node_runs.last().map(|node| node.node_type.as_str()).unwrap_or("Workflow"),
|
"node_type": run.node_runs.last().map(|node| node.node_type.as_str()).unwrap_or("Workflow"),
|
||||||
"exception_message": run.error_message.clone().unwrap_or_else(|| "Workflow execution failed".to_string()),
|
"exception_message": run.error_message.clone().unwrap_or_else(|| "Workflow execution failed".to_string()),
|
||||||
"exception_type": "BossAIWorkflowError",
|
"exception_type": "BoosAPIWorkflowError",
|
||||||
"traceback": [],
|
"traceback": [],
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -5183,55 +5185,30 @@ async fn workflow_context(state: &AppState, caller: &WorkflowCaller) -> AppResul
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn inject_bootstrap(html: String, config: &crate::config::ComfyUIConfig) -> String {
|
fn inject_bootstrap(html: String, config: &crate::config::ComfyUIConfig) -> String {
|
||||||
let cloud_api_key = workflow_cloud_api_key(config);
|
|
||||||
if config.uses_native_backend() && !config.uses_cloud_backend() {
|
if config.uses_native_backend() && !config.uses_cloud_backend() {
|
||||||
return inject_external_bootstrap(html, cloud_api_key);
|
return inject_external_bootstrap(html);
|
||||||
}
|
}
|
||||||
inject_enhanced_bootstrap(html, cloud_api_key)
|
inject_enhanced_bootstrap(html)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn workflow_api_key_bootstrap(api_key: Option<&str>) -> String {
|
fn workflow_secret_cleanup_bootstrap() -> &'static str {
|
||||||
let Some(api_key) = api_key else {
|
|
||||||
return String::new();
|
|
||||||
};
|
|
||||||
let api_key = serde_json::to_string(api_key).unwrap_or_else(|_| "null".to_string());
|
|
||||||
format!(
|
|
||||||
r#"<script>
|
r#"<script>
|
||||||
(function () {{
|
(function () {
|
||||||
const apiKey = {api_key};
|
const clearLeakedComfyApiKey = () => {
|
||||||
if (!apiKey) return;
|
try {
|
||||||
const installComfyApiKey = () => {{
|
localStorage.removeItem('comfy_api_key');
|
||||||
try {{
|
sessionStorage.removeItem('comfy_api_key');
|
||||||
localStorage.setItem('comfy_api_key', apiKey);
|
document.cookie = 'comfy_api_key=; Path=/workflow-ui; Max-Age=0; SameSite=Lax';
|
||||||
sessionStorage.setItem('comfy_api_key', apiKey);
|
} catch (err) {}
|
||||||
for (const api of [window.app?.api, window.comfyAPI?.api, window.api]) {{
|
};
|
||||||
if (api) {{
|
clearLeakedComfyApiKey();
|
||||||
api.apiKey = api.apiKey || apiKey;
|
window.addEventListener('DOMContentLoaded', clearLeakedComfyApiKey);
|
||||||
if (!api.__boosapiCloudApiKeyQueuePatch && typeof api.queuePrompt === 'function') {{
|
})();
|
||||||
const originalQueuePrompt = api.queuePrompt.bind(api);
|
|
||||||
api.queuePrompt = function() {{
|
|
||||||
if (!this.apiKey) this.apiKey = apiKey;
|
|
||||||
return originalQueuePrompt(...arguments);
|
|
||||||
}};
|
|
||||||
api.__boosapiCloudApiKeyQueuePatch = true;
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
document.cookie = 'comfy_api_key=' + encodeURIComponent(apiKey) + '; Path=/workflow-ui; SameSite=Lax';
|
|
||||||
}} catch (err) {{}}
|
|
||||||
}};
|
|
||||||
installComfyApiKey();
|
|
||||||
window.addEventListener('DOMContentLoaded', installComfyApiKey);
|
|
||||||
window.setTimeout(installComfyApiKey, 300);
|
|
||||||
window.setTimeout(installComfyApiKey, 1200);
|
|
||||||
window.setInterval(installComfyApiKey, 5000);
|
|
||||||
}})();
|
|
||||||
</script>"#
|
</script>"#
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inject_external_bootstrap(html: String, cloud_api_key: Option<&str>) -> String {
|
fn inject_external_bootstrap(html: String) -> String {
|
||||||
let api_key_bootstrap = workflow_api_key_bootstrap(cloud_api_key);
|
let secret_cleanup_bootstrap = workflow_secret_cleanup_bootstrap();
|
||||||
let shell = r#"<script>
|
let shell = r#"<script>
|
||||||
(function () {
|
(function () {
|
||||||
const renameComfyUiBrand = () => {
|
const renameComfyUiBrand = () => {
|
||||||
@@ -5310,12 +5287,12 @@ fn inject_external_bootstrap(html: String, cloud_api_key: Option<&str>) -> Strin
|
|||||||
window.setTimeout(installWorkflowValidationButton, 1800);
|
window.setTimeout(installWorkflowValidationButton, 1800);
|
||||||
})();
|
})();
|
||||||
</script>"#;
|
</script>"#;
|
||||||
let bootstrap = format!("{api_key_bootstrap}{shell}");
|
let bootstrap = format!("{secret_cleanup_bootstrap}{shell}");
|
||||||
html.replace("</head>", &format!("{bootstrap}\n</head>"))
|
html.replace("</head>", &format!("{bootstrap}\n</head>"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inject_enhanced_bootstrap(html: String, cloud_api_key: Option<&str>) -> String {
|
fn inject_enhanced_bootstrap(html: String) -> String {
|
||||||
let api_key_bootstrap = workflow_api_key_bootstrap(cloud_api_key);
|
let secret_cleanup_bootstrap = workflow_secret_cleanup_bootstrap();
|
||||||
let shell = r#"<style id="boosapi-workflow-shell-style">
|
let shell = r#"<style id="boosapi-workflow-shell-style">
|
||||||
</style>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
@@ -5380,16 +5357,16 @@ fn inject_enhanced_bootstrap(html: String, cloud_api_key: Option<&str>) -> Strin
|
|||||||
'CLIP': 'CLIP',
|
'CLIP': 'CLIP',
|
||||||
'VAE': 'VAE',
|
'VAE': 'VAE',
|
||||||
'CONDITIONING': '条件',
|
'CONDITIONING': '条件',
|
||||||
'BossAI': 'BossAI',
|
'BoosAPI': 'BoosAPI',
|
||||||
'boosapi.image_to_video': 'BossAI 图生视频',
|
'boosapi.image_to_video': 'BoosAPI 图生视频',
|
||||||
'boosapi.text_to_video': 'BossAI 文生视频',
|
'boosapi.text_to_video': 'BoosAPI 文生视频',
|
||||||
'boosapi.video_status': '查询视频任务',
|
'boosapi.video_status': '查询视频任务',
|
||||||
'output.preview_video': '输出:预览视频',
|
'output.preview_video': '输出:预览视频',
|
||||||
'output.preview_image': '输出:预览图像',
|
'output.preview_image': '输出:预览图像',
|
||||||
'input.text': '输入:文本',
|
'input.text': '输入:文本',
|
||||||
'boosapi.chat_completion': 'BossAI 对话',
|
'boosapi.chat_completion': 'BoosAPI 对话',
|
||||||
'boosapi.prompt_rewrite': 'BossAI 提示词改写',
|
'boosapi.prompt_rewrite': 'BoosAPI 提示词改写',
|
||||||
'boosapi.text_to_image': 'BossAI 文生图',
|
'boosapi.text_to_image': 'BoosAPI 文生图',
|
||||||
'output.save_file': '输出:保存文件'
|
'output.save_file': '输出:保存文件'
|
||||||
}));
|
}));
|
||||||
const translateWorkflowText = (text) => {
|
const translateWorkflowText = (text) => {
|
||||||
@@ -5577,7 +5554,7 @@ fn inject_enhanced_bootstrap(html: String, cloud_api_key: Option<&str>) -> Strin
|
|||||||
const graph = app?.graph || window.LGraphCanvas?.active_canvas?.graph;
|
const graph = app?.graph || window.LGraphCanvas?.active_canvas?.graph;
|
||||||
const canvas = app?.canvas || window.LGraphCanvas?.active_canvas;
|
const canvas = app?.canvas || window.LGraphCanvas?.active_canvas;
|
||||||
if (!graph) return false;
|
if (!graph) return false;
|
||||||
const node = window.LiteGraph.createNode('boosapi.text_to_video', 'BossAI 文生视频');
|
const node = window.LiteGraph.createNode('boosapi.text_to_video', 'BoosAPI 文生视频');
|
||||||
if (!node) return false;
|
if (!node) return false;
|
||||||
const area = canvas?.ds?.visible_area;
|
const area = canvas?.ds?.visible_area;
|
||||||
node.pos = Array.isArray(area) ? [area[0] + area[2] / 2 - 190, area[1] + area[3] / 2 - 120] : [160, 160];
|
node.pos = Array.isArray(area) ? [area[0] + area[2] / 2 - 190, area[1] + area[3] / 2 - 120] : [160, 160];
|
||||||
@@ -5713,7 +5690,7 @@ fn inject_enhanced_bootstrap(html: String, cloud_api_key: Option<&str>) -> Strin
|
|||||||
window.setTimeout(patchWorkflowShell, 1200);
|
window.setTimeout(patchWorkflowShell, 1200);
|
||||||
})();
|
})();
|
||||||
</script>"#;
|
</script>"#;
|
||||||
let bootstrap = format!("{api_key_bootstrap}{shell}");
|
let bootstrap = format!("{secret_cleanup_bootstrap}{shell}");
|
||||||
html.replace("</head>", &format!("{bootstrap}\n</head>"))
|
html.replace("</head>", &format!("{bootstrap}\n</head>"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+34
-17
@@ -91,7 +91,8 @@ fn resolve_caller(
|
|||||||
let token = bearer(headers)
|
let token = bearer(headers)
|
||||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||||
|
|
||||||
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
|
let proxy_token = state.runtime_proxy_token();
|
||||||
|
if token == state.config.auth.auth_token || token == proxy_token {
|
||||||
return Ok(WorkspaceCaller::Admin);
|
return Ok(WorkspaceCaller::Admin);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +173,20 @@ fn row_to_json(row: sqlx::sqlite::SqliteRow) -> Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WORKSPACE_OUTPUT_FILTER_SQL: &str = r#"
|
||||||
|
AND status = 'completed'
|
||||||
|
AND (
|
||||||
|
json_extract(metadata, '$.workspaceAsset.role') = 'output'
|
||||||
|
OR json_extract(metadata, '$.workflowUi.type') = 'output'
|
||||||
|
OR (
|
||||||
|
source IN ('picture-agent', 'workflow-studio')
|
||||||
|
AND json_type(metadata, '$.responseItem') IS NOT NULL
|
||||||
|
)
|
||||||
|
OR json_type(metadata, '$.moneyPrinter') IS NOT NULL
|
||||||
|
OR json_type(metadata, '$.digitalHuman') IS NOT NULL
|
||||||
|
)
|
||||||
|
"#;
|
||||||
|
|
||||||
async fn list_assets(
|
async fn list_assets(
|
||||||
state: AppState,
|
state: AppState,
|
||||||
caller: WorkspaceCaller,
|
caller: WorkspaceCaller,
|
||||||
@@ -184,15 +199,12 @@ async fn list_assets(
|
|||||||
};
|
};
|
||||||
let is_admin = matches!(caller, WorkspaceCaller::Admin);
|
let is_admin = matches!(caller, WorkspaceCaller::Admin);
|
||||||
|
|
||||||
let items = sqlx::query(
|
let list_sql = format!(
|
||||||
"SELECT *
|
"SELECT *
|
||||||
FROM workspace_assets
|
FROM workspace_assets
|
||||||
WHERE deleted_at IS NULL
|
WHERE deleted_at IS NULL
|
||||||
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))
|
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))
|
||||||
AND (
|
{WORKSPACE_OUTPUT_FILTER_SQL}
|
||||||
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 (
|
||||||
@@ -203,8 +215,9 @@ async fn list_assets(
|
|||||||
OR source LIKE ?
|
OR source LIKE ?
|
||||||
)
|
)
|
||||||
ORDER BY datetime(created_at) DESC, id DESC
|
ORDER BY datetime(created_at) DESC, id DESC
|
||||||
LIMIT ? OFFSET ?",
|
LIMIT ? OFFSET ?"
|
||||||
)
|
);
|
||||||
|
let items = sqlx::query(&list_sql)
|
||||||
.bind(is_admin)
|
.bind(is_admin)
|
||||||
.bind(scoped_user_id)
|
.bind(scoped_user_id)
|
||||||
.bind(scoped_user_id)
|
.bind(scoped_user_id)
|
||||||
@@ -225,15 +238,12 @@ async fn list_assets(
|
|||||||
.fetch_all(&*state.db)
|
.fetch_all(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let total: i64 = sqlx::query_scalar(
|
let count_sql = format!(
|
||||||
"SELECT COUNT(*)
|
"SELECT COUNT(*)
|
||||||
FROM workspace_assets
|
FROM workspace_assets
|
||||||
WHERE deleted_at IS NULL
|
WHERE deleted_at IS NULL
|
||||||
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))
|
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))
|
||||||
AND (
|
{WORKSPACE_OUTPUT_FILTER_SQL}
|
||||||
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 (
|
||||||
@@ -242,8 +252,9 @@ async fn list_assets(
|
|||||||
OR prompt LIKE ?
|
OR prompt LIKE ?
|
||||||
OR model LIKE ?
|
OR model LIKE ?
|
||||||
OR source LIKE ?
|
OR source LIKE ?
|
||||||
)",
|
)"
|
||||||
)
|
);
|
||||||
|
let total: i64 = sqlx::query_scalar(&count_sql)
|
||||||
.bind(is_admin)
|
.bind(is_admin)
|
||||||
.bind(scoped_user_id)
|
.bind(scoped_user_id)
|
||||||
.bind(scoped_user_id)
|
.bind(scoped_user_id)
|
||||||
@@ -288,7 +299,7 @@ async fn get_asset_row(
|
|||||||
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 = ?))
|
||||||
LIMIT 1",
|
LIMIT 1",
|
||||||
)
|
)
|
||||||
.bind(public_id)
|
.bind(&public_id)
|
||||||
.bind(is_admin)
|
.bind(is_admin)
|
||||||
.bind(scoped_user_id)
|
.bind(scoped_user_id)
|
||||||
.bind(scoped_user_id)
|
.bind(scoped_user_id)
|
||||||
@@ -328,7 +339,7 @@ async fn delete_asset(
|
|||||||
AND deleted_at IS NULL
|
AND 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 = ?))",
|
||||||
)
|
)
|
||||||
.bind(public_id)
|
.bind(&public_id)
|
||||||
.bind(is_admin)
|
.bind(is_admin)
|
||||||
.bind(scoped_user_id)
|
.bind(scoped_user_id)
|
||||||
.bind(scoped_user_id)
|
.bind(scoped_user_id)
|
||||||
@@ -339,6 +350,9 @@ async fn delete_asset(
|
|||||||
if result.rows_affected() == 0 {
|
if result.rows_affected() == 0 {
|
||||||
return Err(AppError::NotFound("Workspace asset not found".into()));
|
return Err(AppError::NotFound("Workspace asset not found".into()));
|
||||||
}
|
}
|
||||||
|
if let Err(err) = crate::services::workspace::project_deleted_asset(&state, &public_id).await {
|
||||||
|
tracing::warn!(%err, %public_id, "failed to update workspace asset delete projection");
|
||||||
|
}
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,6 +398,9 @@ async fn delete_assets_batch(
|
|||||||
.execute(&*state.db)
|
.execute(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
if result.rows_affected() > 0 {
|
if result.rows_affected() > 0 {
|
||||||
|
if let Err(err) = crate::services::workspace::project_deleted_asset(&state, &public_id).await {
|
||||||
|
tracing::warn!(%err, %public_id, "failed to update workspace asset batch delete projection");
|
||||||
|
}
|
||||||
deleted_ids.push(public_id);
|
deleted_ids.push(public_id);
|
||||||
} else {
|
} else {
|
||||||
failed_ids.push(public_id);
|
failed_ids.push(public_id);
|
||||||
|
|||||||
+468
-1
@@ -2,8 +2,9 @@ use crate::{
|
|||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
services::shared::{enforce_key_request_limit, increment_key_usage},
|
services::shared::{enforce_key_request_limit, increment_key_usage},
|
||||||
};
|
};
|
||||||
|
use chrono::Timelike;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::{FromRow, SqlitePool};
|
use sqlx::{FromRow, Row, SqlitePool};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -152,6 +153,215 @@ impl UsageTracker {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn project_proxy_usage_for_db(db: &SqlitePool) -> AppResult<i64> {
|
||||||
|
Self::ensure_schema(db).await?;
|
||||||
|
let last_id: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COALESCE(last_proxy_log_id, 0)
|
||||||
|
FROM analytics_projection_checkpoints
|
||||||
|
WHERE projector_key = 'usage-aggregates-v1'",
|
||||||
|
)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await?
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
l.id,
|
||||||
|
l.created_at,
|
||||||
|
l.status,
|
||||||
|
l.latency_ms,
|
||||||
|
l.total_tokens,
|
||||||
|
l.estimated_cost,
|
||||||
|
COALESCE(NULLIF(l.model_actual, ''), NULLIF(l.model_requested, ''), 'unknown') AS model,
|
||||||
|
a.site_id,
|
||||||
|
s.platform
|
||||||
|
FROM proxy_logs l
|
||||||
|
LEFT JOIN accounts a ON a.id = l.account_id
|
||||||
|
LEFT JOIN sites s ON s.id = a.site_id
|
||||||
|
WHERE l.id > ?
|
||||||
|
ORDER BY l.id ASC
|
||||||
|
LIMIT 5000
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(last_id)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if rows.is_empty() {
|
||||||
|
touch_projection_checkpoint(db, last_id, None).await?;
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut max_id = last_id;
|
||||||
|
let mut tx = db.begin().await?;
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
let id: i64 = row.try_get("id").unwrap_or(0);
|
||||||
|
max_id = max_id.max(id);
|
||||||
|
let site_id: Option<i64> = row.try_get("site_id").ok().flatten();
|
||||||
|
let Some(site_id) = site_id.filter(|value| *value > 0) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let created_at: Option<String> = row.try_get("created_at").ok().flatten();
|
||||||
|
let Some(created_at) = created_at else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let local_day = sqlite_day_key(&created_at);
|
||||||
|
let bucket_start_utc = sqlite_hour_key(&created_at);
|
||||||
|
let status: Option<String> = row.try_get("status").ok().flatten();
|
||||||
|
let is_success = status
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.eq_ignore_ascii_case("success");
|
||||||
|
let latency_ms = non_negative_i64(row.try_get("latency_ms").ok().flatten());
|
||||||
|
let latency_count = i64::from(latency_ms > 0);
|
||||||
|
let total_tokens = non_negative_i64(row.try_get("total_tokens").ok().flatten());
|
||||||
|
let estimated_cost = non_negative_f64(row.try_get("estimated_cost").ok().flatten());
|
||||||
|
let platform: Option<String> = row.try_get("platform").ok().flatten();
|
||||||
|
let model: String = row
|
||||||
|
.try_get::<Option<String>, _>("model")
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
let summary_spend = if estimated_cost > 0.0 {
|
||||||
|
estimated_cost
|
||||||
|
} else if platform
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case("veloera")
|
||||||
|
{
|
||||||
|
total_tokens as f64 / 1_000_000.0
|
||||||
|
} else {
|
||||||
|
total_tokens as f64 / 500_000.0
|
||||||
|
};
|
||||||
|
let site_spend = if estimated_cost > 0.0 {
|
||||||
|
estimated_cost
|
||||||
|
} else {
|
||||||
|
fallback_token_cost(total_tokens, platform.as_deref())
|
||||||
|
};
|
||||||
|
let model_spend = if estimated_cost > 0.0 {
|
||||||
|
estimated_cost
|
||||||
|
} else {
|
||||||
|
total_tokens as f64 / 500_000.0
|
||||||
|
};
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO site_day_usage
|
||||||
|
(local_day, site_id, total_calls, success_calls, failed_calls,
|
||||||
|
total_tokens, total_summary_spend, total_site_spend,
|
||||||
|
total_latency_ms, latency_count, updated_at)
|
||||||
|
VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(local_day, site_id) DO UPDATE SET
|
||||||
|
total_calls = total_calls + 1,
|
||||||
|
success_calls = success_calls + excluded.success_calls,
|
||||||
|
failed_calls = failed_calls + excluded.failed_calls,
|
||||||
|
total_tokens = total_tokens + excluded.total_tokens,
|
||||||
|
total_summary_spend = total_summary_spend + excluded.total_summary_spend,
|
||||||
|
total_site_spend = total_site_spend + excluded.total_site_spend,
|
||||||
|
total_latency_ms = total_latency_ms + excluded.total_latency_ms,
|
||||||
|
latency_count = latency_count + excluded.latency_count,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&local_day)
|
||||||
|
.bind(site_id)
|
||||||
|
.bind(i64::from(is_success))
|
||||||
|
.bind(i64::from(!is_success))
|
||||||
|
.bind(total_tokens)
|
||||||
|
.bind(summary_spend)
|
||||||
|
.bind(site_spend)
|
||||||
|
.bind(latency_ms)
|
||||||
|
.bind(latency_count)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO site_hour_usage
|
||||||
|
(bucket_start_utc, site_id, total_calls, success_calls, failed_calls,
|
||||||
|
total_tokens, total_summary_spend, total_site_spend,
|
||||||
|
total_latency_ms, latency_count, updated_at)
|
||||||
|
VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(bucket_start_utc, site_id) DO UPDATE SET
|
||||||
|
total_calls = total_calls + 1,
|
||||||
|
success_calls = success_calls + excluded.success_calls,
|
||||||
|
failed_calls = failed_calls + excluded.failed_calls,
|
||||||
|
total_tokens = total_tokens + excluded.total_tokens,
|
||||||
|
total_summary_spend = total_summary_spend + excluded.total_summary_spend,
|
||||||
|
total_site_spend = total_site_spend + excluded.total_site_spend,
|
||||||
|
total_latency_ms = total_latency_ms + excluded.total_latency_ms,
|
||||||
|
latency_count = latency_count + excluded.latency_count,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&bucket_start_utc)
|
||||||
|
.bind(site_id)
|
||||||
|
.bind(i64::from(is_success))
|
||||||
|
.bind(i64::from(!is_success))
|
||||||
|
.bind(total_tokens)
|
||||||
|
.bind(summary_spend)
|
||||||
|
.bind(site_spend)
|
||||||
|
.bind(latency_ms)
|
||||||
|
.bind(latency_count)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO model_day_usage
|
||||||
|
(local_day, site_id, model, total_calls, success_calls, failed_calls,
|
||||||
|
total_tokens, total_spend, total_latency_ms, latency_count, updated_at)
|
||||||
|
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(local_day, site_id, model) DO UPDATE SET
|
||||||
|
total_calls = total_calls + 1,
|
||||||
|
success_calls = success_calls + excluded.success_calls,
|
||||||
|
failed_calls = failed_calls + excluded.failed_calls,
|
||||||
|
total_tokens = total_tokens + excluded.total_tokens,
|
||||||
|
total_spend = total_spend + excluded.total_spend,
|
||||||
|
total_latency_ms = total_latency_ms + excluded.total_latency_ms,
|
||||||
|
latency_count = latency_count + excluded.latency_count,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&local_day)
|
||||||
|
.bind(site_id)
|
||||||
|
.bind(&model)
|
||||||
|
.bind(i64::from(is_success))
|
||||||
|
.bind(i64::from(!is_success))
|
||||||
|
.bind(total_tokens)
|
||||||
|
.bind(model_spend)
|
||||||
|
.bind(latency_ms)
|
||||||
|
.bind(latency_count)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO analytics_projection_checkpoints
|
||||||
|
(projector_key, time_zone, last_proxy_log_id, watermark_created_at,
|
||||||
|
last_projected_at, last_successful_at, last_error, updated_at)
|
||||||
|
VALUES ('usage-aggregates-v1', 'Local', ?, datetime('now'), datetime('now'), datetime('now'), NULL, datetime('now'))
|
||||||
|
ON CONFLICT(projector_key) DO UPDATE SET
|
||||||
|
last_proxy_log_id = excluded.last_proxy_log_id,
|
||||||
|
watermark_created_at = excluded.watermark_created_at,
|
||||||
|
last_projected_at = excluded.last_projected_at,
|
||||||
|
last_successful_at = excluded.last_successful_at,
|
||||||
|
last_error = NULL,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(max_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(max_id - last_id)
|
||||||
|
}
|
||||||
|
|
||||||
async fn ensure_schema(db: &SqlitePool) -> AppResult<()> {
|
async fn ensure_schema(db: &SqlitePool) -> AppResult<()> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"CREATE TABLE IF NOT EXISTS usage_logs (
|
"CREATE TABLE IF NOT EXISTS usage_logs (
|
||||||
@@ -190,10 +400,179 @@ impl UsageTracker {
|
|||||||
)
|
)
|
||||||
.execute(db)
|
.execute(db)
|
||||||
.await?;
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS analytics_projection_checkpoints (
|
||||||
|
projector_key TEXT PRIMARY KEY NOT NULL,
|
||||||
|
time_zone TEXT NOT NULL DEFAULT 'Local',
|
||||||
|
last_proxy_log_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
watermark_created_at TEXT,
|
||||||
|
recompute_from_id INTEGER,
|
||||||
|
recompute_requested_at TEXT,
|
||||||
|
recompute_reason TEXT,
|
||||||
|
recompute_started_at TEXT,
|
||||||
|
recompute_completed_at TEXT,
|
||||||
|
last_projected_at TEXT,
|
||||||
|
last_successful_at TEXT,
|
||||||
|
last_error TEXT,
|
||||||
|
lease_owner TEXT,
|
||||||
|
lease_token TEXT,
|
||||||
|
lease_expires_at TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS model_day_usage (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
local_day TEXT NOT NULL,
|
||||||
|
site_id INTEGER NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
total_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
success_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failed_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
latency_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(local_day, site_id, model)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS site_day_usage (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
local_day TEXT NOT NULL,
|
||||||
|
site_id INTEGER NOT NULL,
|
||||||
|
total_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
success_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failed_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_summary_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_site_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
latency_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(local_day, site_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS site_hour_usage (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
bucket_start_utc TEXT NOT NULL,
|
||||||
|
site_id INTEGER NOT NULL,
|
||||||
|
total_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
success_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failed_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_summary_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_site_spend REAL NOT NULL DEFAULT 0,
|
||||||
|
total_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
latency_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(bucket_start_utc, site_id)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn touch_projection_checkpoint(
|
||||||
|
db: &SqlitePool,
|
||||||
|
last_proxy_log_id: i64,
|
||||||
|
error: Option<&str>,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO analytics_projection_checkpoints
|
||||||
|
(projector_key, time_zone, last_proxy_log_id, last_projected_at,
|
||||||
|
last_successful_at, last_error, updated_at)
|
||||||
|
VALUES ('usage-aggregates-v1', 'Local', ?, datetime('now'), datetime('now'), ?, datetime('now'))
|
||||||
|
ON CONFLICT(projector_key) DO UPDATE SET
|
||||||
|
last_projected_at = datetime('now'),
|
||||||
|
last_successful_at = CASE WHEN ? IS NULL THEN datetime('now') ELSE last_successful_at END,
|
||||||
|
last_error = ?,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(last_proxy_log_id)
|
||||||
|
.bind(error)
|
||||||
|
.bind(error)
|
||||||
|
.bind(error)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_negative_i64(value: Option<i64>) -> i64 {
|
||||||
|
value.unwrap_or(0).max(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_negative_f64(value: Option<f64>) -> f64 {
|
||||||
|
value.unwrap_or(0.0).max(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sqlite_day_key(created_at: &str) -> String {
|
||||||
|
parse_sqlite_datetime(created_at)
|
||||||
|
.map(|dt| dt.format("%Y-%m-%d").to_string())
|
||||||
|
.unwrap_or_else(|| created_at.chars().take(10).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sqlite_hour_key(created_at: &str) -> String {
|
||||||
|
parse_sqlite_datetime(created_at)
|
||||||
|
.map(|dt| {
|
||||||
|
dt.with_minute(0)
|
||||||
|
.and_then(|dt| dt.with_second(0))
|
||||||
|
.and_then(|dt| dt.with_nanosecond(0))
|
||||||
|
.unwrap_or(dt)
|
||||||
|
.format("%Y-%m-%d %H:%M:%S")
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let prefix: String = created_at.chars().take(13).collect();
|
||||||
|
if prefix.len() == 13 {
|
||||||
|
format!("{prefix}:00:00")
|
||||||
|
} else {
|
||||||
|
created_at.to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_sqlite_datetime(value: &str) -> Option<chrono::NaiveDateTime> {
|
||||||
|
chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S")
|
||||||
|
.ok()
|
||||||
|
.or_else(|| {
|
||||||
|
chrono::DateTime::parse_from_rfc3339(value)
|
||||||
|
.ok()
|
||||||
|
.map(|dt| dt.naive_utc())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fallback_token_cost(tokens: i64, platform: Option<&str>) -> f64 {
|
||||||
|
if tokens <= 0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
match platform
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
"veloera" => tokens as f64 / 1_000_000.0,
|
||||||
|
_ => tokens as f64 / 500_000.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -282,4 +661,92 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(count, 1);
|
assert_eq!(count, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn projects_proxy_logs_to_site_and_model_usage() {
|
||||||
|
let pool = pool().await;
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE sites (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
platform TEXT
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE accounts (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
site_id INTEGER NOT NULL
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE proxy_logs (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
account_id INTEGER,
|
||||||
|
model_requested TEXT,
|
||||||
|
model_actual TEXT,
|
||||||
|
status TEXT,
|
||||||
|
latency_ms INTEGER,
|
||||||
|
total_tokens INTEGER,
|
||||||
|
estimated_cost REAL,
|
||||||
|
created_at TEXT
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query("INSERT INTO sites (id, name, platform) VALUES (10, 'Site', 'new-api')")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query("INSERT INTO accounts (id, site_id) VALUES (20, 10)")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO proxy_logs
|
||||||
|
(id, account_id, model_requested, model_actual, status, latency_ms, total_tokens, estimated_cost, created_at)
|
||||||
|
VALUES
|
||||||
|
(1, 20, 'gpt-test', NULL, 'success', 120, 1000, 0.12, '2026-06-06 08:12:30')",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let projected = UsageTracker::project_proxy_usage_for_db(&pool).await.unwrap();
|
||||||
|
assert_eq!(projected, 1);
|
||||||
|
|
||||||
|
let model_usage: (i64, i64, f64, i64) = sqlx::query_as(
|
||||||
|
"SELECT total_calls, total_tokens, total_spend, total_latency_ms
|
||||||
|
FROM model_day_usage
|
||||||
|
WHERE site_id = 10 AND model = 'gpt-test'",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(model_usage, (1, 1000, 0.12, 120));
|
||||||
|
|
||||||
|
let site_usage: (i64, i64, f64) = sqlx::query_as(
|
||||||
|
"SELECT total_calls, success_calls, total_site_spend
|
||||||
|
FROM site_day_usage
|
||||||
|
WHERE site_id = 10 AND local_day = '2026-06-06'",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(site_usage, (1, 1, 0.12));
|
||||||
|
|
||||||
|
let hour_bucket: String = sqlx::query_scalar(
|
||||||
|
"SELECT bucket_start_utc FROM site_hour_usage WHERE site_id = 10",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(hour_bucket, "2026-06-06 08:00:00");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ impl CronScheduler {
|
|||||||
|
|
||||||
async fn aggregate_usage(db: Arc<SqlitePool>) -> AppResult<()> {
|
async fn aggregate_usage(db: Arc<SqlitePool>) -> AppResult<()> {
|
||||||
UsageTracker::create_daily_usage_snapshot_for_db(&db).await?;
|
UsageTracker::create_daily_usage_snapshot_for_db(&db).await?;
|
||||||
|
let projected = UsageTracker::project_proxy_usage_for_db(&db).await?;
|
||||||
|
if projected > 0 {
|
||||||
|
tracing::debug!(projected, "usage aggregates projected");
|
||||||
|
}
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE downstream_api_keys
|
"UPDATE downstream_api_keys
|
||||||
SET updated_at = datetime('now')
|
SET updated_at = datetime('now')
|
||||||
@@ -123,6 +127,7 @@ impl CronScheduler {
|
|||||||
)
|
)
|
||||||
.execute(&*db)
|
.execute(&*db)
|
||||||
.await?;
|
.await?;
|
||||||
|
crate::proxy::token_router::router::invalidate_route_cache();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -8,6 +8,7 @@ pub mod oauth;
|
|||||||
pub mod register_captcha;
|
pub mod register_captcha;
|
||||||
pub mod registration_limiter;
|
pub mod registration_limiter;
|
||||||
pub mod route_rebuild;
|
pub mod route_rebuild;
|
||||||
|
pub mod runtime_auth;
|
||||||
|
pub mod runtime_logs;
|
||||||
pub mod shared;
|
pub mod shared;
|
||||||
pub mod update_center;
|
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::error::AppResult;
|
use crate::error::{AppError, AppResult};
|
||||||
use chrono::{FixedOffset, SecondsFormat, TimeZone, Utc};
|
use chrono::{FixedOffset, SecondsFormat, TimeZone, Utc};
|
||||||
use lettre::{
|
use lettre::{
|
||||||
message::Mailbox, transport::smtp::authentication::Credentials, Message, SmtpTransport,
|
message::Mailbox, transport::smtp::authentication::Credentials, Message, SmtpTransport,
|
||||||
@@ -36,13 +36,19 @@ pub struct Notification {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
struct NotificationConfig {
|
struct NotificationConfig {
|
||||||
|
telegram_enabled: bool,
|
||||||
telegram_bot_token: Option<String>,
|
telegram_bot_token: Option<String>,
|
||||||
telegram_chat_id: Option<String>,
|
telegram_chat_id: Option<String>,
|
||||||
telegram_thread_id: Option<i64>,
|
telegram_thread_id: Option<i64>,
|
||||||
|
wecom_enabled: bool,
|
||||||
wecom_webhook: Option<String>,
|
wecom_webhook: Option<String>,
|
||||||
|
feishu_enabled: bool,
|
||||||
feishu_webhook: Option<String>,
|
feishu_webhook: Option<String>,
|
||||||
|
webhook_enabled: bool,
|
||||||
webhook_url: Option<String>,
|
webhook_url: Option<String>,
|
||||||
|
bark_enabled: bool,
|
||||||
bark_url: Option<String>,
|
bark_url: Option<String>,
|
||||||
|
serverchan_enabled: bool,
|
||||||
serverchan_key: Option<String>,
|
serverchan_key: Option<String>,
|
||||||
smtp: SmtpConfig,
|
smtp: SmtpConfig,
|
||||||
cooldown_sec: i64,
|
cooldown_sec: i64,
|
||||||
@@ -50,8 +56,10 @@ struct NotificationConfig {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
struct SmtpConfig {
|
struct SmtpConfig {
|
||||||
|
enabled: bool,
|
||||||
host: Option<String>,
|
host: Option<String>,
|
||||||
port: Option<u16>,
|
port: Option<u16>,
|
||||||
|
secure: Option<bool>,
|
||||||
user: Option<String>,
|
user: Option<String>,
|
||||||
pass: Option<String>,
|
pass: Option<String>,
|
||||||
from: Option<String>,
|
from: Option<String>,
|
||||||
@@ -100,6 +108,33 @@ impl NotificationService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn send_test(&self) -> AppResult<Vec<String>> {
|
||||||
|
Self::ensure_schema(&self.db).await?;
|
||||||
|
let config = self.load_config().await?;
|
||||||
|
let channels = self.enabled_channels(&config);
|
||||||
|
if channels.is_empty() {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"请至少配置并启用一个通知通道".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let notif = Notification {
|
||||||
|
title: "BossAI 通知测试".to_string(),
|
||||||
|
message: "通知通道配置正常。这条消息来自管理后台的测试发送。".to_string(),
|
||||||
|
level: NotificationLevel::Info,
|
||||||
|
channel: None,
|
||||||
|
signature: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
for channel in &channels {
|
||||||
|
self.send_channel(channel, ¬if, &config)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::BadRequest(format!("{channel} 通知测试失败: {err}")))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(channels)
|
||||||
|
}
|
||||||
|
|
||||||
async fn load_config(&self) -> AppResult<NotificationConfig> {
|
async fn load_config(&self) -> AppResult<NotificationConfig> {
|
||||||
let rows: Vec<(String, Option<String>)> =
|
let rows: Vec<(String, Option<String>)> =
|
||||||
sqlx::query_as("SELECT key, value FROM settings WHERE key LIKE 'notify_%'")
|
sqlx::query_as("SELECT key, value FROM settings WHERE key LIKE 'notify_%'")
|
||||||
@@ -112,18 +147,26 @@ impl NotificationService {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(NotificationConfig {
|
Ok(NotificationConfig {
|
||||||
|
telegram_enabled: setting_bool(&map, "notify_telegram_enabled", true),
|
||||||
telegram_bot_token: setting(&map, "notify_telegram_bot_token"),
|
telegram_bot_token: setting(&map, "notify_telegram_bot_token"),
|
||||||
telegram_chat_id: setting(&map, "notify_telegram_chat_id"),
|
telegram_chat_id: setting(&map, "notify_telegram_chat_id"),
|
||||||
telegram_thread_id: setting(&map, "notify_telegram_thread_id")
|
telegram_thread_id: setting(&map, "notify_telegram_thread_id")
|
||||||
.and_then(|value| value.parse().ok()),
|
.and_then(|value| value.parse().ok()),
|
||||||
|
wecom_enabled: setting_bool(&map, "notify_wecom_enabled", true),
|
||||||
wecom_webhook: setting(&map, "notify_wecom_webhook"),
|
wecom_webhook: setting(&map, "notify_wecom_webhook"),
|
||||||
|
feishu_enabled: setting_bool(&map, "notify_feishu_enabled", true),
|
||||||
feishu_webhook: setting(&map, "notify_feishu_webhook"),
|
feishu_webhook: setting(&map, "notify_feishu_webhook"),
|
||||||
|
webhook_enabled: setting_bool(&map, "notify_webhook_enabled", true),
|
||||||
webhook_url: setting(&map, "notify_webhook_url"),
|
webhook_url: setting(&map, "notify_webhook_url"),
|
||||||
|
bark_enabled: setting_bool(&map, "notify_bark_enabled", true),
|
||||||
bark_url: setting(&map, "notify_bark_url"),
|
bark_url: setting(&map, "notify_bark_url"),
|
||||||
|
serverchan_enabled: setting_bool(&map, "notify_serverchan_enabled", true),
|
||||||
serverchan_key: setting(&map, "notify_serverchan_key"),
|
serverchan_key: setting(&map, "notify_serverchan_key"),
|
||||||
smtp: SmtpConfig {
|
smtp: SmtpConfig {
|
||||||
|
enabled: setting_bool(&map, "notify_smtp_enabled", true),
|
||||||
host: setting(&map, "notify_smtp_host"),
|
host: setting(&map, "notify_smtp_host"),
|
||||||
port: setting(&map, "notify_smtp_port").and_then(|value| value.parse().ok()),
|
port: setting(&map, "notify_smtp_port").and_then(|value| value.parse().ok()),
|
||||||
|
secure: setting(&map, "notify_smtp_secure").map(|value| parse_bool(&value)),
|
||||||
user: setting(&map, "notify_smtp_user"),
|
user: setting(&map, "notify_smtp_user"),
|
||||||
pass: setting(&map, "notify_smtp_pass"),
|
pass: setting(&map, "notify_smtp_pass"),
|
||||||
from: setting(&map, "notify_smtp_from"),
|
from: setting(&map, "notify_smtp_from"),
|
||||||
@@ -188,25 +231,28 @@ impl NotificationService {
|
|||||||
|
|
||||||
fn enabled_channels(&self, config: &NotificationConfig) -> Vec<String> {
|
fn enabled_channels(&self, config: &NotificationConfig) -> Vec<String> {
|
||||||
let mut channels = Vec::new();
|
let mut channels = Vec::new();
|
||||||
if config.telegram_bot_token.is_some() && config.telegram_chat_id.is_some() {
|
if config.telegram_enabled
|
||||||
|
&& config.telegram_bot_token.is_some()
|
||||||
|
&& config.telegram_chat_id.is_some()
|
||||||
|
{
|
||||||
channels.push("telegram".to_string());
|
channels.push("telegram".to_string());
|
||||||
}
|
}
|
||||||
if config.wecom_webhook.is_some() {
|
if config.wecom_enabled && config.wecom_webhook.is_some() {
|
||||||
channels.push("wecom".to_string());
|
channels.push("wecom".to_string());
|
||||||
}
|
}
|
||||||
if config.feishu_webhook.is_some() {
|
if config.feishu_enabled && config.feishu_webhook.is_some() {
|
||||||
channels.push("feishu".to_string());
|
channels.push("feishu".to_string());
|
||||||
}
|
}
|
||||||
if config.webhook_url.is_some() {
|
if config.webhook_enabled && config.webhook_url.is_some() {
|
||||||
channels.push("webhook".to_string());
|
channels.push("webhook".to_string());
|
||||||
}
|
}
|
||||||
if config.bark_url.is_some() {
|
if config.bark_enabled && config.bark_url.is_some() {
|
||||||
channels.push("bark".to_string());
|
channels.push("bark".to_string());
|
||||||
}
|
}
|
||||||
if config.serverchan_key.is_some() {
|
if config.serverchan_enabled && config.serverchan_key.is_some() {
|
||||||
channels.push("serverchan".to_string());
|
channels.push("serverchan".to_string());
|
||||||
}
|
}
|
||||||
if config.smtp.is_configured() {
|
if config.smtp.enabled && config.smtp.is_configured() {
|
||||||
channels.push("smtp".to_string());
|
channels.push("smtp".to_string());
|
||||||
}
|
}
|
||||||
channels
|
channels
|
||||||
@@ -385,6 +431,7 @@ impl SmtpConfig {
|
|||||||
fn send(self, notif: &Notification) -> anyhow::Result<()> {
|
fn send(self, notif: &Notification) -> anyhow::Result<()> {
|
||||||
let host = required(self.host.as_deref(), "smtp host")?;
|
let host = required(self.host.as_deref(), "smtp host")?;
|
||||||
let port = self.port.unwrap_or(587);
|
let port = self.port.unwrap_or(587);
|
||||||
|
let secure = self.secure.unwrap_or(true);
|
||||||
let user = required(self.user.as_deref(), "smtp user")?;
|
let user = required(self.user.as_deref(), "smtp user")?;
|
||||||
let pass = required(self.pass.as_deref(), "smtp pass")?;
|
let pass = required(self.pass.as_deref(), "smtp pass")?;
|
||||||
let from = required(self.from.as_deref(), "smtp from")?;
|
let from = required(self.from.as_deref(), "smtp from")?;
|
||||||
@@ -400,7 +447,12 @@ impl SmtpConfig {
|
|||||||
))
|
))
|
||||||
.body(format_notification_text(notif))?;
|
.body(format_notification_text(notif))?;
|
||||||
|
|
||||||
let mailer = SmtpTransport::relay(host)?
|
let builder = if secure {
|
||||||
|
SmtpTransport::relay(host)?
|
||||||
|
} else {
|
||||||
|
SmtpTransport::builder_dangerous(host)
|
||||||
|
};
|
||||||
|
let mailer = builder
|
||||||
.port(port)
|
.port(port)
|
||||||
.credentials(Credentials::new(user.to_string(), pass.to_string()))
|
.credentials(Credentials::new(user.to_string(), pass.to_string()))
|
||||||
.build();
|
.build();
|
||||||
@@ -413,6 +465,17 @@ fn setting(map: &HashMap<String, String>, key: &str) -> Option<String> {
|
|||||||
map.get(key).map(|value| value.trim().to_string())
|
map.get(key).map(|value| value.trim().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn setting_bool(map: &HashMap<String, String>, key: &str, default: bool) -> bool {
|
||||||
|
map.get(key).map(|value| parse_bool(value)).unwrap_or(default)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_bool(value: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
value.trim().to_ascii_lowercase().as_str(),
|
||||||
|
"1" | "true" | "yes" | "on" | "enabled"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn required<'a>(value: Option<&'a str>, name: &str) -> anyhow::Result<&'a str> {
|
fn required<'a>(value: Option<&'a str>, name: &str) -> anyhow::Result<&'a str> {
|
||||||
value.ok_or_else(|| anyhow::anyhow!("{name} is not configured"))
|
value.ok_or_else(|| anyhow::anyhow!("{name} is not configured"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ struct AvailRow {
|
|||||||
/// single transaction so a partial failure doesn't leave `token_routes` and
|
/// single transaction so a partial failure doesn't leave `token_routes` and
|
||||||
/// `route_channels` out of sync.
|
/// `route_channels` out of sync.
|
||||||
pub async fn rebuild_routes(state: &AppState) -> AppResult<RebuildSummary> {
|
pub async fn rebuild_routes(state: &AppState) -> AppResult<RebuildSummary> {
|
||||||
rebuild_routes_inner(&state.db).await
|
let summary = rebuild_routes_inner(&state.db).await?;
|
||||||
|
crate::proxy::token_router::router::invalidate_route_cache();
|
||||||
|
Ok(summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pool-only entry point. Identical to `rebuild_routes`, just decoupled from
|
/// Pool-only entry point. Identical to `rebuild_routes`, just decoupled from
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
|
||||||
|
pub const PROXY_TOKEN_SETTING_KEY: &str = "runtime.auth.proxy_token";
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct RuntimeAuthSettings {
|
||||||
|
default_proxy_token: String,
|
||||||
|
proxy_token: RwLock<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimeAuthSettings {
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn from_token_for_test(proxy_token: impl Into<String>) -> Self {
|
||||||
|
let proxy_token = proxy_token.into();
|
||||||
|
Self {
|
||||||
|
default_proxy_token: proxy_token.clone(),
|
||||||
|
proxy_token: RwLock::new(proxy_token),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load(pool: &SqlitePool, default_proxy_token: String) -> anyhow::Result<Self> {
|
||||||
|
let stored: Option<String> =
|
||||||
|
sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
|
||||||
|
.bind(PROXY_TOKEN_SETTING_KEY)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
let proxy_token = stored
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(default_proxy_token.trim())
|
||||||
|
.to_string();
|
||||||
|
Ok(Self {
|
||||||
|
default_proxy_token,
|
||||||
|
proxy_token: RwLock::new(proxy_token),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn proxy_token(&self) -> String {
|
||||||
|
self.proxy_token.read().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_proxy_token(
|
||||||
|
&self,
|
||||||
|
pool: &SqlitePool,
|
||||||
|
proxy_token: String,
|
||||||
|
) -> anyhow::Result<String> {
|
||||||
|
let normalized = normalize_proxy_token(&proxy_token)?;
|
||||||
|
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
||||||
|
.bind(PROXY_TOKEN_SETTING_KEY)
|
||||||
|
.bind(&normalized)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
*self.proxy_token.write() = normalized.clone();
|
||||||
|
Ok(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn proxy_token_masked(&self) -> String {
|
||||||
|
mask_token(&self.proxy_token())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn using_default_proxy_token(&self) -> bool {
|
||||||
|
self.proxy_token() == self.default_proxy_token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_proxy_token(value: &str) -> anyhow::Result<String> {
|
||||||
|
let token = value.trim();
|
||||||
|
if !token.starts_with("sk-") {
|
||||||
|
anyhow::bail!("下游访问令牌必须以 sk- 开头");
|
||||||
|
}
|
||||||
|
if token.chars().count() < 6 {
|
||||||
|
anyhow::bail!("下游访问令牌至少 6 位(含 sk-)");
|
||||||
|
}
|
||||||
|
Ok(token.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mask_token(value: &str) -> String {
|
||||||
|
let token = value.trim();
|
||||||
|
if token.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let suffix: String = token
|
||||||
|
.chars()
|
||||||
|
.rev()
|
||||||
|
.take(4)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
|
.collect();
|
||||||
|
format!("••••{}", suffix)
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
use chrono::{SecondsFormat, Utc};
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::fmt;
|
||||||
|
use std::sync::atomic::{AtomicI64, Ordering};
|
||||||
|
use tracing::{
|
||||||
|
field::{Field, Visit},
|
||||||
|
Level, Subscriber,
|
||||||
|
};
|
||||||
|
use tracing_subscriber::{layer::Context, Layer};
|
||||||
|
|
||||||
|
const MAX_RUNTIME_LOGS: usize = 500;
|
||||||
|
|
||||||
|
static NEXT_ID: AtomicI64 = AtomicI64::new(1);
|
||||||
|
static RUNTIME_LOGS: Lazy<RwLock<VecDeque<RuntimeLogEntry>>> =
|
||||||
|
Lazy::new(|| RwLock::new(VecDeque::with_capacity(MAX_RUNTIME_LOGS)));
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RuntimeLogEntry {
|
||||||
|
pub id: i64,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub event_type: String,
|
||||||
|
pub title: String,
|
||||||
|
pub message: Option<String>,
|
||||||
|
pub level: String,
|
||||||
|
#[serde(rename = "read")]
|
||||||
|
pub is_read: bool,
|
||||||
|
pub related_id: Option<i64>,
|
||||||
|
pub related_type: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct RuntimeLogLayer;
|
||||||
|
|
||||||
|
impl<S> Layer<S> for RuntimeLogLayer
|
||||||
|
where
|
||||||
|
S: Subscriber,
|
||||||
|
{
|
||||||
|
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
|
||||||
|
let metadata = event.metadata();
|
||||||
|
let level = metadata.level();
|
||||||
|
if !matches!(*level, Level::ERROR | Level::WARN | Level::INFO) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut visitor = LogFieldVisitor::default();
|
||||||
|
event.record(&mut visitor);
|
||||||
|
|
||||||
|
let title = visitor
|
||||||
|
.message
|
||||||
|
.unwrap_or_else(|| metadata.name().to_string());
|
||||||
|
let mut details = Vec::with_capacity(visitor.fields.len() + 1);
|
||||||
|
details.push(metadata.target().to_string());
|
||||||
|
details.extend(visitor.fields);
|
||||||
|
|
||||||
|
push(RuntimeLogEntry {
|
||||||
|
id: -NEXT_ID.fetch_add(1, Ordering::Relaxed),
|
||||||
|
event_type: "runtime".to_string(),
|
||||||
|
title,
|
||||||
|
message: Some(details.join(" · ")).filter(|value| !value.trim().is_empty()),
|
||||||
|
level: level_name(level).to_string(),
|
||||||
|
is_read: true,
|
||||||
|
related_id: None,
|
||||||
|
related_type: None,
|
||||||
|
created_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list() -> Vec<RuntimeLogEntry> {
|
||||||
|
RUNTIME_LOGS.read().iter().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear() {
|
||||||
|
RUNTIME_LOGS.write().clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(entry: RuntimeLogEntry) {
|
||||||
|
let mut logs = RUNTIME_LOGS.write();
|
||||||
|
if logs.len() >= MAX_RUNTIME_LOGS {
|
||||||
|
logs.pop_front();
|
||||||
|
}
|
||||||
|
logs.push_back(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn level_name(level: &Level) -> &'static str {
|
||||||
|
match *level {
|
||||||
|
Level::ERROR => "error",
|
||||||
|
Level::WARN => "warning",
|
||||||
|
_ => "info",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct LogFieldVisitor {
|
||||||
|
message: Option<String>,
|
||||||
|
fields: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogFieldVisitor {
|
||||||
|
fn record_value(&mut self, field: &Field, value: String) {
|
||||||
|
if field.name() == "message" {
|
||||||
|
self.message = Some(value);
|
||||||
|
} else {
|
||||||
|
self.fields.push(format!("{}={}", field.name(), value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Visit for LogFieldVisitor {
|
||||||
|
fn record_str(&mut self, field: &Field, value: &str) {
|
||||||
|
self.record_value(field, value.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_bool(&mut self, field: &Field, value: bool) {
|
||||||
|
self.record_value(field, value.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_i64(&mut self, field: &Field, value: i64) {
|
||||||
|
self.record_value(field, value.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_u64(&mut self, field: &Field, value: u64) {
|
||||||
|
self.record_value(field, value.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_error(&mut self, field: &Field, value: &(dyn std::error::Error + 'static)) {
|
||||||
|
self.record_value(field, value.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
|
||||||
|
self.record_value(field, normalize_debug_value(format!("{value:?}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_debug_value(value: String) -> String {
|
||||||
|
if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') {
|
||||||
|
value[1..value.len() - 1].replace("\\\"", "\"")
|
||||||
|
} else {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tracing_subscriber::prelude::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_log_layer_captures_info_events() {
|
||||||
|
clear();
|
||||||
|
let subscriber = tracing_subscriber::registry().with(RuntimeLogLayer);
|
||||||
|
|
||||||
|
tracing::subscriber::with_default(subscriber, || {
|
||||||
|
tracing::info!(key_id = 42, "Built-in key ready");
|
||||||
|
});
|
||||||
|
|
||||||
|
let logs = list();
|
||||||
|
let entry = logs.last().expect("runtime log entry");
|
||||||
|
assert_eq!(entry.event_type, "runtime");
|
||||||
|
assert_eq!(entry.title, "Built-in key ready");
|
||||||
|
assert_eq!(entry.level, "info");
|
||||||
|
assert_eq!(entry.is_read, true);
|
||||||
|
assert!(entry
|
||||||
|
.message
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("key_id=42"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
use crate::error::{AppError, AppResult};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::Value;
|
|
||||||
use sqlx::SqlitePool;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
const UPDATE_CONFIG_KEY: &str = "update_center_config";
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct UpdateCenter {
|
|
||||||
db: Arc<SqlitePool>,
|
|
||||||
client: reqwest::Client,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct UpdateInfo {
|
|
||||||
pub current_version: String,
|
|
||||||
pub latest_version: Option<String>,
|
|
||||||
pub update_available: bool,
|
|
||||||
pub release_notes: Option<String>,
|
|
||||||
pub download_url: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UpdateCenter {
|
|
||||||
pub fn new(db: Arc<SqlitePool>) -> Self {
|
|
||||||
Self {
|
|
||||||
db,
|
|
||||||
client: reqwest::Client::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn check_for_updates(&self) -> AppResult<UpdateInfo> {
|
|
||||||
let config = self.load_config().await?;
|
|
||||||
let current_version = env!("CARGO_PKG_VERSION").to_string();
|
|
||||||
let Some(endpoint) = config
|
|
||||||
.get("check_url")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|url| !url.trim().is_empty())
|
|
||||||
else {
|
|
||||||
return Ok(UpdateInfo {
|
|
||||||
current_version,
|
|
||||||
latest_version: None,
|
|
||||||
update_available: false,
|
|
||||||
release_notes: None,
|
|
||||||
download_url: None,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
let payload: Value = self
|
|
||||||
.client
|
|
||||||
.get(endpoint)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|err| AppError::UpstreamError(format!("Update check failed: {err}")))?
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|err| AppError::UpstreamError(format!("Invalid update response: {err}")))?;
|
|
||||||
|
|
||||||
let latest_version = payload
|
|
||||||
.get("version")
|
|
||||||
.or_else(|| payload.get("latestVersion"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::to_string);
|
|
||||||
let update_available = latest_version
|
|
||||||
.as_deref()
|
|
||||||
.map(|latest| latest != current_version)
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
Ok(UpdateInfo {
|
|
||||||
current_version,
|
|
||||||
latest_version,
|
|
||||||
update_available,
|
|
||||||
release_notes: payload
|
|
||||||
.get("release_notes")
|
|
||||||
.or_else(|| payload.get("releaseNotes"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::to_string),
|
|
||||||
download_url: payload
|
|
||||||
.get("download_url")
|
|
||||||
.or_else(|| payload.get("downloadUrl"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::to_string),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn apply_update(&self, version: &str) -> AppResult<String> {
|
|
||||||
let task_id = format!("update-{}", uuid::Uuid::new_v4());
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO background_tasks
|
|
||||||
(id, task_type, title, status, message, dedupe_key, created_at, updated_at)
|
|
||||||
VALUES (?, 'update-center-deploy', ?, 'pending', ?, ?, datetime('now'), datetime('now'))",
|
|
||||||
)
|
|
||||||
.bind(&task_id)
|
|
||||||
.bind(format!("Apply BoosAPI {version}"))
|
|
||||||
.bind("Update request accepted; an external supervisor should perform download and restart")
|
|
||||||
.bind(format!("update-center:deploy:{version}"))
|
|
||||||
.execute(&*self.db)
|
|
||||||
.await?;
|
|
||||||
Ok(task_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn load_config(&self) -> AppResult<Value> {
|
|
||||||
let raw: Option<String> = sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
|
|
||||||
.bind(UPDATE_CONFIG_KEY)
|
|
||||||
.fetch_optional(&*self.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
raw.as_deref()
|
|
||||||
.map(serde_json::from_str)
|
|
||||||
.transpose()
|
|
||||||
.map_err(|err| AppError::BadRequest(format!("Invalid update center config: {err}")))?
|
|
||||||
.map(Ok)
|
|
||||||
.unwrap_or_else(|| Ok(Value::Object(Default::default())))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+110
-7
@@ -160,6 +160,23 @@ async fn insert_asset(
|
|||||||
.execute(&*state.db)
|
.execute(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
if should_project_workspace_asset(status, &metadata) {
|
||||||
|
if let Err(err) = upsert_workspace_asset_daily(
|
||||||
|
state,
|
||||||
|
None,
|
||||||
|
input.user_id,
|
||||||
|
&input.source,
|
||||||
|
&input.kind,
|
||||||
|
size_bytes,
|
||||||
|
file_path.is_some(),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(%err, "failed to update workspace asset projection");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(StoredWorkspaceAsset {
|
Ok(StoredWorkspaceAsset {
|
||||||
public_id: public_id.to_string(),
|
public_id: public_id.to_string(),
|
||||||
file_name,
|
file_name,
|
||||||
@@ -169,6 +186,51 @@ async fn insert_asset(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn should_project_workspace_asset(status: &str, metadata: &Value) -> bool {
|
||||||
|
status == "completed"
|
||||||
|
&& (metadata.pointer("/workspaceAsset/role").and_then(Value::as_str) == Some("output")
|
||||||
|
|| metadata.pointer("/workflowUi/type").and_then(Value::as_str) == Some("output")
|
||||||
|
|| metadata.get("responseItem").is_some()
|
||||||
|
|| metadata.get("moneyPrinter").is_some()
|
||||||
|
|| metadata.get("digitalHuman").is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upsert_workspace_asset_daily(
|
||||||
|
state: &AppState,
|
||||||
|
local_day: Option<&str>,
|
||||||
|
user_id: Option<i64>,
|
||||||
|
source: &str,
|
||||||
|
kind: &str,
|
||||||
|
size_bytes: i64,
|
||||||
|
has_local_file: bool,
|
||||||
|
delta: i64,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let asset_count_delta = delta;
|
||||||
|
let local_file_delta = if has_local_file { delta } else { 0 };
|
||||||
|
let size_delta = size_bytes.max(0) * delta;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO workspace_asset_daily
|
||||||
|
(local_day, user_id, source, kind, asset_count, local_file_count, total_size_bytes, updated_at)
|
||||||
|
VALUES (COALESCE(?, date('now', 'localtime')), ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(local_day, user_id, source, kind) DO UPDATE SET
|
||||||
|
asset_count = MAX(0, asset_count + excluded.asset_count),
|
||||||
|
local_file_count = MAX(0, local_file_count + excluded.local_file_count),
|
||||||
|
total_size_bytes = MAX(0, total_size_bytes + excluded.total_size_bytes),
|
||||||
|
updated_at = datetime('now')",
|
||||||
|
)
|
||||||
|
.bind(local_day)
|
||||||
|
.bind(user_id.unwrap_or(0))
|
||||||
|
.bind(source)
|
||||||
|
.bind(kind)
|
||||||
|
.bind(asset_count_delta)
|
||||||
|
.bind(local_file_delta)
|
||||||
|
.bind(size_delta)
|
||||||
|
.execute(&*state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn record_remote_asset(
|
pub async fn record_remote_asset(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
input: WorkspaceAssetInput,
|
input: WorkspaceAssetInput,
|
||||||
@@ -188,11 +250,7 @@ pub async fn record_remote_asset(
|
|||||||
.await;
|
.await;
|
||||||
};
|
};
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
match state.http.get(url).send().await {
|
||||||
.timeout(std::time::Duration::from_secs(60))
|
|
||||||
.build()
|
|
||||||
.unwrap_or_else(|_| reqwest::Client::new());
|
|
||||||
match client.get(url).send().await {
|
|
||||||
Ok(response) if response.status().is_success() => {
|
Ok(response) if response.status().is_success() => {
|
||||||
let mime_type = response
|
let mime_type = response
|
||||||
.headers()
|
.headers()
|
||||||
@@ -307,6 +365,45 @@ pub async fn record_bytes_asset(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn project_deleted_asset(state: &AppState, public_id: &str) -> Result<(), AppError> {
|
||||||
|
use sqlx::Row;
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT user_id, source, kind, status, metadata, size_bytes, file_path, date(created_at, 'localtime') AS local_day
|
||||||
|
FROM workspace_assets
|
||||||
|
WHERE public_id = ?
|
||||||
|
LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(public_id)
|
||||||
|
.fetch_optional(&*state.db)
|
||||||
|
.await?;
|
||||||
|
let Some(row) = row else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let status: String = row.try_get("status").unwrap_or_default();
|
||||||
|
let metadata_raw: String = row.try_get("metadata").unwrap_or_else(|_| "{}".to_string());
|
||||||
|
let metadata = serde_json::from_str::<Value>(&metadata_raw).unwrap_or_else(|_| json!({}));
|
||||||
|
if !should_project_workspace_asset(&status, &metadata) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let source: String = row.try_get("source").unwrap_or_default();
|
||||||
|
let kind: String = row.try_get("kind").unwrap_or_default();
|
||||||
|
let user_id: Option<i64> = row.try_get("user_id").ok().flatten();
|
||||||
|
let size_bytes: i64 = row.try_get("size_bytes").unwrap_or_default();
|
||||||
|
let file_path: Option<String> = row.try_get("file_path").ok().flatten();
|
||||||
|
let local_day: Option<String> = row.try_get("local_day").ok().flatten();
|
||||||
|
upsert_workspace_asset_daily(
|
||||||
|
state,
|
||||||
|
local_day.as_deref(),
|
||||||
|
user_id,
|
||||||
|
&source,
|
||||||
|
&kind,
|
||||||
|
size_bytes,
|
||||||
|
file_path.as_deref().is_some_and(|value| !value.trim().is_empty()),
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn record_image_assets_from_response(
|
pub async fn record_image_assets_from_response(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
user_id: Option<i64>,
|
user_id: Option<i64>,
|
||||||
@@ -338,7 +435,10 @@ pub async fn record_image_assets_from_response(
|
|||||||
prompt: prompt.clone(),
|
prompt: prompt.clone(),
|
||||||
model: model.clone(),
|
model: model.clone(),
|
||||||
remote_url: Some(url.trim().to_string()),
|
remote_url: Some(url.trim().to_string()),
|
||||||
metadata: json!({ "responseItem": item }),
|
metadata: json!({
|
||||||
|
"workspaceAsset": { "role": "output" },
|
||||||
|
"responseItem": item,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
match record_remote_asset(state, input).await {
|
match record_remote_asset(state, input).await {
|
||||||
Ok(asset) => stored_assets.push(asset),
|
Ok(asset) => stored_assets.push(asset),
|
||||||
@@ -357,7 +457,10 @@ pub async fn record_image_assets_from_response(
|
|||||||
prompt: prompt.clone(),
|
prompt: prompt.clone(),
|
||||||
model: model.clone(),
|
model: model.clone(),
|
||||||
remote_url: None,
|
remote_url: None,
|
||||||
metadata: json!({ "responseItem": item }),
|
metadata: json!({
|
||||||
|
"workspaceAsset": { "role": "output" },
|
||||||
|
"responseItem": item,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
match record_base64_asset(state, input, b64).await {
|
match record_base64_asset(state, input, b64).await {
|
||||||
Ok(asset) => stored_assets.push(asset),
|
Ok(asset) => stored_assets.push(asset),
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
export type UpdateVersionCandidateLike = {
|
|
||||||
normalizedVersion?: string | null;
|
|
||||||
displayVersion?: string | null;
|
|
||||||
tagName?: string | null;
|
|
||||||
digest?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateHelperRuntimeLike = {
|
|
||||||
imageTag?: string | null;
|
|
||||||
imageDigest?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateReminderCandidate = {
|
|
||||||
source: 'github-release' | 'docker-hub-tag';
|
|
||||||
kind: 'new-version' | 'new-digest';
|
|
||||||
candidateKey: string;
|
|
||||||
displayVersion: string;
|
|
||||||
tagName: string;
|
|
||||||
digest: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i;
|
|
||||||
|
|
||||||
function normalizeString(value?: string | null): string {
|
|
||||||
return String(value || '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDigest(value?: string | null): string {
|
|
||||||
const digest = normalizeString(value);
|
|
||||||
return /^sha256:[a-f0-9]{64}$/i.test(digest) ? digest.toLowerCase() : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeStableTag(value?: string | null): string {
|
|
||||||
return normalizeStableVersion(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasSameImageTag(left?: string | null, right?: string | null): boolean {
|
|
||||||
const rawLeft = normalizeString(left);
|
|
||||||
const rawRight = normalizeString(right);
|
|
||||||
if (!rawLeft || !rawRight) return false;
|
|
||||||
|
|
||||||
const stableLeft = normalizeStableTag(left);
|
|
||||||
const stableRight = normalizeStableTag(right);
|
|
||||||
if (stableLeft && stableRight) {
|
|
||||||
return stableLeft === stableRight;
|
|
||||||
}
|
|
||||||
|
|
||||||
return rawLeft === rawRight;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeStableVersion(value?: string | null): string {
|
|
||||||
const raw = normalizeString(value);
|
|
||||||
if (!raw) return '';
|
|
||||||
const match = raw.match(STABLE_SEMVER_PATTERN);
|
|
||||||
if (!match) return '';
|
|
||||||
return [
|
|
||||||
Number.parseInt(match[1], 10),
|
|
||||||
Number.parseInt(match[2], 10),
|
|
||||||
Number.parseInt(match[3], 10),
|
|
||||||
].join('.');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function compareStableVersions(left?: string | null, right?: string | null): number | null {
|
|
||||||
const normalizedLeft = normalizeStableVersion(left);
|
|
||||||
const normalizedRight = normalizeStableVersion(right);
|
|
||||||
if (!normalizedLeft || !normalizedRight) return null;
|
|
||||||
|
|
||||||
const leftParts = normalizedLeft.split('.').map((item) => Number.parseInt(item, 10));
|
|
||||||
const rightParts = normalizedRight.split('.').map((item) => Number.parseInt(item, 10));
|
|
||||||
for (let index = 0; index < 3; index += 1) {
|
|
||||||
if (leftParts[index] === rightParts[index]) continue;
|
|
||||||
return leftParts[index] < rightParts[index] ? -1 : 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSameImageTarget(
|
|
||||||
current: UpdateHelperRuntimeLike | null | undefined,
|
|
||||||
target: { tag?: string | null; digest?: string | null },
|
|
||||||
): boolean {
|
|
||||||
const currentDigest = normalizeDigest(current?.imageDigest);
|
|
||||||
const targetDigest = normalizeDigest(target.digest);
|
|
||||||
if (currentDigest && targetDigest) {
|
|
||||||
return currentDigest === targetDigest;
|
|
||||||
}
|
|
||||||
|
|
||||||
return hasSameImageTag(current?.imageTag, target.tag);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildUpdateReminderCandidateKey(
|
|
||||||
source: 'github-release' | 'docker-hub-tag',
|
|
||||||
candidate: { tagName?: string | null; digest?: string | null },
|
|
||||||
): string {
|
|
||||||
const tagName = normalizeString(candidate.tagName);
|
|
||||||
const digest = normalizeDigest(candidate.digest);
|
|
||||||
if (!tagName) return '';
|
|
||||||
if (source === 'docker-hub-tag' && digest) {
|
|
||||||
return `${source}:${tagName}@${digest}`;
|
|
||||||
}
|
|
||||||
return `${source}:${tagName}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveUpdateReminderCandidate(input: {
|
|
||||||
currentVersion?: string | null;
|
|
||||||
helper: UpdateHelperRuntimeLike | null | undefined;
|
|
||||||
githubRelease: UpdateVersionCandidateLike | null | undefined;
|
|
||||||
dockerHubTag: UpdateVersionCandidateLike | null | undefined;
|
|
||||||
}): UpdateReminderCandidate | null {
|
|
||||||
const githubCandidateVersion = normalizeString(input.githubRelease?.normalizedVersion);
|
|
||||||
const githubTag = normalizeString(input.githubRelease?.tagName || githubCandidateVersion);
|
|
||||||
if (githubTag) {
|
|
||||||
const githubTargetVersion = githubCandidateVersion || githubTag;
|
|
||||||
const versionCompare = compareStableVersions(input.currentVersion, githubTargetVersion);
|
|
||||||
const helperVersionCompare = compareStableVersions(input.helper?.imageTag, githubTargetVersion);
|
|
||||||
if (versionCompare === -1 && (helperVersionCompare == null || helperVersionCompare === -1)) {
|
|
||||||
return {
|
|
||||||
source: 'github-release',
|
|
||||||
kind: 'new-version',
|
|
||||||
candidateKey: buildUpdateReminderCandidateKey('github-release', { tagName: githubTag, digest: null }),
|
|
||||||
displayVersion: normalizeString(input.githubRelease?.displayVersion || input.githubRelease?.normalizedVersion || githubTag),
|
|
||||||
tagName: githubTag,
|
|
||||||
digest: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const dockerCandidateVersion = normalizeString(input.dockerHubTag?.normalizedVersion);
|
|
||||||
const dockerTag = normalizeString(input.dockerHubTag?.tagName || dockerCandidateVersion);
|
|
||||||
const dockerDigest = normalizeDigest(input.dockerHubTag?.digest);
|
|
||||||
if (!dockerTag) return null;
|
|
||||||
|
|
||||||
if (isSameImageTarget(input.helper, { tag: dockerTag, digest: dockerDigest })) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dockerVersionCompare = compareStableVersions(input.currentVersion, dockerCandidateVersion || dockerTag);
|
|
||||||
if (dockerVersionCompare === -1) {
|
|
||||||
return {
|
|
||||||
source: 'docker-hub-tag',
|
|
||||||
kind: 'new-version',
|
|
||||||
candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest || null }),
|
|
||||||
displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag),
|
|
||||||
tagName: dockerTag,
|
|
||||||
digest: dockerDigest || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const helperDigest = normalizeDigest(input.helper?.imageDigest);
|
|
||||||
if (dockerDigest && helperDigest && hasSameImageTag(input.helper?.imageTag, dockerTag) && helperDigest !== dockerDigest) {
|
|
||||||
return {
|
|
||||||
source: 'docker-hub-tag',
|
|
||||||
kind: 'new-digest',
|
|
||||||
candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest }),
|
|
||||||
displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag),
|
|
||||||
tagName: dockerTag,
|
|
||||||
digest: dockerDigest,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
import { create, type ReactTestInstance } from 'react-test-renderer';
|
import { create, type ReactTestInstance } from 'react-test-renderer';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import AdminLoginPage from './pages/auth/AdminLoginPage.js';
|
import AdminLoginPage from './pages/auth/AdminLoginPage.js';
|
||||||
|
import UserLoginPage from './pages/auth/UserLoginPage.js';
|
||||||
|
|
||||||
function collectText(node: ReactTestInstance): string {
|
function collectText(node: ReactTestInstance): string {
|
||||||
return (node.children || []).map((child) => {
|
return (node.children || []).map((child) => {
|
||||||
@@ -37,12 +38,12 @@ describe('Login surface', () => {
|
|||||||
));
|
));
|
||||||
|
|
||||||
expect(pageText).toContain('BossAI');
|
expect(pageText).toContain('BossAI');
|
||||||
expect(pageText).toContain('聚合多模型多模态的 AI 应用平台');
|
expect(pageText).toContain('AI 应用平台');
|
||||||
expect(pageText).not.toContain('一个 API Key,一个入口');
|
expect(pageText).not.toContain('一个 API Key,一个入口');
|
||||||
expect(pageText).toContain('兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API');
|
expect(pageText).toContain('兼容 OpenAI / Claude Messages / Comfy Cloud / RunningHub / 火山方舟等多类上游');
|
||||||
expect(pageText).toContain('统一代理网关');
|
expect(pageText).toContain('统一 API 网关');
|
||||||
expect(pageText).toContain('智能路由引擎');
|
expect(pageText).toContain('智能路由引擎');
|
||||||
expect(pageText).toContain('自动模型发现');
|
expect(pageText).toContain('工作流生产台');
|
||||||
expect(lightBrandPanel).toBeTruthy();
|
expect(lightBrandPanel).toBeTruthy();
|
||||||
expect(authStage).toBeTruthy();
|
expect(authStage).toBeTruthy();
|
||||||
expect(brandMarkCanvas).toBeTruthy();
|
expect(brandMarkCanvas).toBeTruthy();
|
||||||
@@ -61,4 +62,26 @@ describe('Login surface', () => {
|
|||||||
root?.unmount();
|
root?.unmount();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders OAuth provider entries as configured placeholders on user login', () => {
|
||||||
|
const root = create(
|
||||||
|
<MemoryRouter>
|
||||||
|
<UserLoginPage t={(text) => text} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const providers = root.root.findAll((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('login-oauth-provider')
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(providers.map((node) => node.props['data-provider'])).toEqual(['google', 'github', 'wechat']);
|
||||||
|
expect(providers.every((node) => node.props.disabled === true)).toBe(true);
|
||||||
|
expect(collectText(root.root)).toContain('待配置');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+309
-88
@@ -1,9 +1,11 @@
|
|||||||
import React, { Suspense, lazy, useState, useEffect, useRef } from 'react';
|
import React, { Suspense, lazy, useState, useEffect, useRef, useId } from 'react';
|
||||||
import { Routes, Route, NavLink, Navigate, useLocation } from 'react-router-dom';
|
import { Routes, Route, NavLink, Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { gsap } from 'gsap';
|
||||||
import { ToastProvider, useToast } from './components/Toast.js';
|
import { ToastProvider, useToast } from './components/Toast.js';
|
||||||
import SearchModal from './components/SearchModal.js';
|
import SearchModal from './components/SearchModal.js';
|
||||||
import NotificationPanel from './components/NotificationPanel.js';
|
import NotificationPanel from './components/NotificationPanel.js';
|
||||||
import TooltipLayer from './components/TooltipLayer.js';
|
import TooltipLayer from './components/TooltipLayer.js';
|
||||||
|
import MotionDiv from './components/AppMotion.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 {
|
||||||
@@ -57,6 +59,7 @@ type UserProfile = {
|
|||||||
name: string;
|
name: string;
|
||||||
avatarSeed: string;
|
avatarSeed: string;
|
||||||
avatarStyle: string;
|
avatarStyle: string;
|
||||||
|
avatarUrl?: string;
|
||||||
};
|
};
|
||||||
type UserAnnouncementPopupRow = {
|
type UserAnnouncementPopupRow = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -66,21 +69,17 @@ type UserAnnouncementPopupRow = {
|
|||||||
publishedAt?: string | null;
|
publishedAt?: string | null;
|
||||||
firstSeenAt?: string | null;
|
firstSeenAt?: string | null;
|
||||||
};
|
};
|
||||||
const DICEBEAR_STYLES = [
|
const AVATAR_STYLES = [
|
||||||
'pixel-art',
|
'aurora',
|
||||||
'pixel-art-neutral',
|
'skyline',
|
||||||
'bottts',
|
'lotus',
|
||||||
'bottts-neutral',
|
'ember',
|
||||||
'identicon',
|
'mint',
|
||||||
'initials',
|
'violet',
|
||||||
'avataaars',
|
'graphite',
|
||||||
'avataaars-neutral',
|
'cobalt',
|
||||||
'personas',
|
|
||||||
'lorelei',
|
|
||||||
'lorelei-neutral',
|
|
||||||
'fun-emoji',
|
|
||||||
] as const;
|
] as const;
|
||||||
type DicebearStyle = typeof DICEBEAR_STYLES[number];
|
type AvatarStyle = typeof AVATAR_STYLES[number];
|
||||||
function resolveStoredThemeMode(): ThemeMode {
|
function resolveStoredThemeMode(): ThemeMode {
|
||||||
const saved = localStorage.getItem(THEME_MODE_STORAGE_KEY);
|
const saved = localStorage.getItem(THEME_MODE_STORAGE_KEY);
|
||||||
if (saved === 'system' || saved === 'light' || saved === 'dark') return saved;
|
if (saved === 'system' || saved === 'light' || saved === 'dark') return saved;
|
||||||
@@ -102,21 +101,71 @@ function hashString(input: string): number {
|
|||||||
}
|
}
|
||||||
return Math.abs(hash);
|
return Math.abs(hash);
|
||||||
}
|
}
|
||||||
function pickDicebearStyle(seed: string): DicebearStyle {
|
function pickAvatarStyle(seed: string): AvatarStyle {
|
||||||
const index = hashString(seed || 'default') % DICEBEAR_STYLES.length;
|
const index = hashString(seed || 'default') % AVATAR_STYLES.length;
|
||||||
return DICEBEAR_STYLES[index];
|
return AVATAR_STYLES[index];
|
||||||
}
|
}
|
||||||
function buildDicebearAvatarUrl(style: string, seed: string): string {
|
function resolveAvatarStyle(style: string, seed: string): AvatarStyle {
|
||||||
const safeStyle = DICEBEAR_STYLES.includes(style as DicebearStyle)
|
return AVATAR_STYLES.includes(style as AvatarStyle)
|
||||||
? style
|
? (style as AvatarStyle)
|
||||||
: pickDicebearStyle(seed);
|
: pickAvatarStyle(seed);
|
||||||
const safeSeed = (seed || 'default').trim() || 'default';
|
}
|
||||||
return `https://api.dicebear.com/9.x/${safeStyle}/svg?seed=${encodeURIComponent(safeSeed)}`;
|
function buildLocalAvatarStyle(style: string, seed: string): React.CSSProperties {
|
||||||
|
const safeStyle = resolveAvatarStyle(style, seed);
|
||||||
|
const hue = hashString(`${safeStyle}:${seed || 'default'}`) % 360;
|
||||||
|
const secondHue = (hue + 46 + (hashString(seed || safeStyle) % 92)) % 360;
|
||||||
|
return {
|
||||||
|
background:
|
||||||
|
`radial-gradient(circle at 28% 24%, hsl(${secondHue} 86% 88%) 0 18%, transparent 19%), ` +
|
||||||
|
`linear-gradient(135deg, hsl(${hue} 74% 50%), hsl(${secondHue} 70% 48%))`,
|
||||||
|
color: '#fff',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAvatarUrl(value: unknown): string {
|
||||||
|
if (typeof value !== 'string') return '';
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
if (
|
||||||
|
trimmed.startsWith('https://')
|
||||||
|
|| trimmed.startsWith('http://')
|
||||||
|
|| trimmed.startsWith('/')
|
||||||
|
|| trimmed.startsWith('data:image/')
|
||||||
|
) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileToLocalAvatarDataUrl(file: File): Promise<string> {
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
throw new Error('头像文件必须是图片格式');
|
||||||
|
}
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
throw new Error('头像图片不能超过 5MB');
|
||||||
|
}
|
||||||
|
const bitmap = await createImageBitmap(file);
|
||||||
|
try {
|
||||||
|
const size = 256;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('当前浏览器无法处理头像图片');
|
||||||
|
const scale = Math.max(size / bitmap.width, size / bitmap.height);
|
||||||
|
const width = bitmap.width * scale;
|
||||||
|
const height = bitmap.height * scale;
|
||||||
|
ctx.clearRect(0, 0, size, size);
|
||||||
|
ctx.drawImage(bitmap, (size - width) / 2, (size - height) / 2, width, height);
|
||||||
|
return canvas.toDataURL('image/webp', 0.82);
|
||||||
|
} finally {
|
||||||
|
bitmap.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultProfile(name: string): UserProfile {
|
function defaultProfile(name: string): UserProfile {
|
||||||
const avatarSeed = createRandomAvatarSeed();
|
const avatarSeed = createRandomAvatarSeed();
|
||||||
return { name, avatarSeed, avatarStyle: pickDicebearStyle(avatarSeed) };
|
return { name, avatarSeed, avatarStyle: pickAvatarStyle(avatarSeed) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseStoredProfile(raw: string | null, fallbackName: string): UserProfile | null {
|
function parseStoredProfile(raw: string | null, fallbackName: string): UserProfile | null {
|
||||||
@@ -134,9 +183,10 @@ function parseStoredProfile(raw: string | null, fallbackName: string): UserProfi
|
|||||||
return {
|
return {
|
||||||
name: name || fallbackName,
|
name: name || fallbackName,
|
||||||
avatarSeed: resolvedSeed,
|
avatarSeed: resolvedSeed,
|
||||||
avatarStyle: DICEBEAR_STYLES.includes(avatarStyle as DicebearStyle)
|
avatarStyle: AVATAR_STYLES.includes(avatarStyle as AvatarStyle)
|
||||||
? avatarStyle
|
? avatarStyle
|
||||||
: pickDicebearStyle(resolvedSeed),
|
: pickAvatarStyle(resolvedSeed),
|
||||||
|
avatarUrl: normalizeAvatarUrl((parsed as any)?.avatarUrl),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -169,16 +219,47 @@ function profileStorageKey(audience: 'admin' | 'user'): string {
|
|||||||
function profileFromUsername(username: string, current?: UserProfile, fallbackName = '用户'): UserProfile {
|
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() || pickAvatarStyle(avatarSeed);
|
||||||
return {
|
return {
|
||||||
name: name || current?.name || fallbackName,
|
name: name || current?.name || fallbackName,
|
||||||
avatarSeed,
|
avatarSeed,
|
||||||
avatarStyle: DICEBEAR_STYLES.includes(avatarStyle as DicebearStyle)
|
avatarStyle: AVATAR_STYLES.includes(avatarStyle as AvatarStyle)
|
||||||
? avatarStyle
|
? avatarStyle
|
||||||
: pickDicebearStyle(avatarSeed),
|
: pickAvatarStyle(avatarSeed),
|
||||||
|
avatarUrl: current?.avatarUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LocalAvatar({
|
||||||
|
profile,
|
||||||
|
label,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
profile: UserProfile;
|
||||||
|
label: string;
|
||||||
|
className: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}) {
|
||||||
|
const [imageBroken, setImageBroken] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
setImageBroken(false);
|
||||||
|
}, [profile.avatarUrl]);
|
||||||
|
const initial = (label || profile.name || 'B').trim().slice(0, 1).toUpperCase() || 'B';
|
||||||
|
const avatarUrl = normalizeAvatarUrl(profile.avatarUrl);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={className}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ ...buildLocalAvatarStyle(profile.avatarStyle, profile.avatarSeed), ...style }}
|
||||||
|
>
|
||||||
|
{avatarUrl && !imageBroken ? (
|
||||||
|
<img src={avatarUrl} alt="" onError={() => setImageBroken(true)} />
|
||||||
|
) : initial}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function UserProfileModal({
|
function UserProfileModal({
|
||||||
open,
|
open,
|
||||||
profile,
|
profile,
|
||||||
@@ -192,18 +273,23 @@ function UserProfileModal({
|
|||||||
onSave: (nextProfile: UserProfile) => void;
|
onSave: (nextProfile: UserProfile) => void;
|
||||||
t: (text: string) => string;
|
t: (text: string) => string;
|
||||||
}) {
|
}) {
|
||||||
|
const avatarInputId = useId();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [name, setName] = useState(profile.name);
|
const [name, setName] = useState(profile.name);
|
||||||
const [avatarSeed, setAvatarSeed] = useState(profile.avatarSeed);
|
const [avatarSeed, setAvatarSeed] = useState(profile.avatarSeed);
|
||||||
const [avatarStyle, setAvatarStyle] = useState(profile.avatarStyle);
|
const [avatarStyle, setAvatarStyle] = useState(profile.avatarStyle);
|
||||||
|
const [avatarUrl, setAvatarUrl] = useState(profile.avatarUrl || '');
|
||||||
|
const [avatarUploadName, setAvatarUploadName] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
setName(profile.name);
|
setName(profile.name);
|
||||||
setAvatarSeed(profile.avatarSeed);
|
setAvatarSeed(profile.avatarSeed);
|
||||||
setAvatarStyle(profile.avatarStyle);
|
setAvatarStyle(profile.avatarStyle);
|
||||||
|
setAvatarUrl(profile.avatarUrl || '');
|
||||||
|
setAvatarUploadName('');
|
||||||
setError('');
|
setError('');
|
||||||
}, [open, profile]);
|
}, [open, profile]);
|
||||||
const avatarUrl = buildDicebearAvatarUrl(avatarStyle, avatarSeed);
|
|
||||||
const inputStyle: React.CSSProperties = {
|
const inputStyle: React.CSSProperties = {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
padding: '10px 14px',
|
padding: '10px 14px',
|
||||||
@@ -217,7 +303,29 @@ function UserProfileModal({
|
|||||||
const handleRandomAvatar = () => {
|
const handleRandomAvatar = () => {
|
||||||
const nextSeed = createRandomAvatarSeed();
|
const nextSeed = createRandomAvatarSeed();
|
||||||
setAvatarSeed(nextSeed);
|
setAvatarSeed(nextSeed);
|
||||||
setAvatarStyle(pickDicebearStyle(nextSeed));
|
setAvatarStyle(pickAvatarStyle(nextSeed));
|
||||||
|
setAvatarUrl('');
|
||||||
|
setAvatarUploadName('');
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
};
|
||||||
|
const handleAvatarFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
setError('');
|
||||||
|
const dataUrl = await fileToLocalAvatarDataUrl(file);
|
||||||
|
setAvatarUrl(dataUrl);
|
||||||
|
setAvatarUploadName(file.name);
|
||||||
|
} catch (uploadError: any) {
|
||||||
|
setError(t(uploadError?.message || '头像上传失败'));
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleClearAvatarImage = () => {
|
||||||
|
setAvatarUrl('');
|
||||||
|
setAvatarUploadName('');
|
||||||
|
setError('');
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
};
|
};
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
const normalizedName = name.trim();
|
const normalizedName = name.trim();
|
||||||
@@ -229,14 +337,26 @@ function UserProfileModal({
|
|||||||
setError(t('用户名最多 24 个字符'));
|
setError(t('用户名最多 24 个字符'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const normalizedAvatarUrl = normalizeAvatarUrl(avatarUrl);
|
||||||
|
if (avatarUrl.trim() && !normalizedAvatarUrl) {
|
||||||
|
setError(t('头像图片读取失败,请重新上传'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
onSave({
|
onSave({
|
||||||
name: normalizedName,
|
name: normalizedName,
|
||||||
avatarSeed: avatarSeed.trim() || createRandomAvatarSeed(),
|
avatarSeed: avatarSeed.trim() || createRandomAvatarSeed(),
|
||||||
avatarStyle: DICEBEAR_STYLES.includes(avatarStyle as DicebearStyle)
|
avatarStyle: AVATAR_STYLES.includes(avatarStyle as AvatarStyle)
|
||||||
? avatarStyle
|
? avatarStyle
|
||||||
: pickDicebearStyle(avatarSeed),
|
: pickAvatarStyle(avatarSeed),
|
||||||
|
avatarUrl: normalizedAvatarUrl || undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const previewProfile: UserProfile = {
|
||||||
|
name: name.trim() || profile.name,
|
||||||
|
avatarSeed,
|
||||||
|
avatarStyle,
|
||||||
|
avatarUrl: normalizeAvatarUrl(avatarUrl) || undefined,
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<CenteredModal
|
<CenteredModal
|
||||||
open={open}
|
open={open}
|
||||||
@@ -253,15 +373,16 @@ function UserProfileModal({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 2 }}>
|
<div className="profile-avatar-preview-row">
|
||||||
<div className="topbar-avatar" style={{ width: 40, height: 40, fontSize: 14 }}>
|
<LocalAvatar
|
||||||
<img
|
className="profile-avatar-preview"
|
||||||
src={avatarUrl}
|
profile={previewProfile}
|
||||||
alt={name.trim() || 'avatar'}
|
label={previewProfile.name}
|
||||||
style={{ width: '100%', height: '100%', borderRadius: '50%', objectFit: 'cover' }}
|
|
||||||
/>
|
/>
|
||||||
|
<div>
|
||||||
|
<strong>{t('头像实时预览')}</strong>
|
||||||
|
<span>{t('会同步显示在右上角和侧边栏。')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{t('右上角头像实时预览')}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>{t('用户名')}</div>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>{t('用户名')}</div>
|
||||||
@@ -276,12 +397,41 @@ function UserProfileModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>{t('本地上传头像')}</div>
|
||||||
{t('头像(Dicebear 随机) · 风格:')}{avatarStyle}
|
<div className="profile-avatar-upload">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
id={avatarInputId}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||||
|
onChange={handleAvatarFileChange}
|
||||||
|
/>
|
||||||
|
<label htmlFor={avatarInputId}>
|
||||||
|
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1M8 8l4-4m0 0l4 4m-4-4v12" />
|
||||||
|
</svg>
|
||||||
|
<span>{t('选择图片')}</span>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<strong>{avatarUploadName || (avatarUrl ? t('已选择本地头像') : t('未选择文件'))}</strong>
|
||||||
|
<span>{t('图片会压缩后保存在本机浏览器,不上传服务器。')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
|
||||||
|
{t('头像 · 风格:')}{resolveAvatarStyle(avatarStyle, avatarSeed)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={handleRandomAvatar}>
|
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={handleRandomAvatar}>
|
||||||
{t('换一个随机头像')}
|
{t('换一个随机头像')}
|
||||||
</button>
|
</button>
|
||||||
|
{avatarUrl.trim() && (
|
||||||
|
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={handleClearAvatarImage}>
|
||||||
|
{t('清除图片头像')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="alert alert-error">
|
<div className="alert alert-error">
|
||||||
@@ -378,7 +528,7 @@ export const sidebarGroups = [
|
|||||||
{
|
{
|
||||||
label: '控制台',
|
label: '控制台',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/', label: '仪表盘', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v5a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 12a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1v-7z" /></svg> },
|
{ to: '/dashboard', label: '仪表盘', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v5a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 12a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1v-7z" /></svg> },
|
||||||
{ to: '/sites', label: '站点管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg> },
|
{ to: '/sites', label: '站点管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg> },
|
||||||
{ to: '/site-announcements', label: '用户公告', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 8h10M7 12h10M7 16h6M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
|
{ to: '/site-announcements', label: '用户公告', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 8h10M7 12h10M7 16h6M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
|
||||||
{ to: '/accounts', label: '连接管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg> },
|
{ to: '/accounts', label: '连接管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg> },
|
||||||
@@ -430,18 +580,18 @@ export const userSidebarGroups = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
const topNavItems = [
|
const topNavItems = [
|
||||||
{ label: '控制台', to: '/' },
|
{ label: '控制台', to: '/dashboard' },
|
||||||
{ label: '模型广场', to: '/models' },
|
{ label: '模型广场', to: '/models' },
|
||||||
{ label: '生图助手', to: '/picture-agent' },
|
|
||||||
{ label: '短视频工厂', to: '/money-printer' },
|
|
||||||
{ label: '工作流', to: '/workflows' },
|
{ label: '工作流', to: '/workflows' },
|
||||||
{ label: '视频助手', to: '/comfyui-agent' },
|
|
||||||
{ label: '工作空间', to: '/workspace' },
|
|
||||||
{ label: '关于', to: '/about' },
|
{ label: '关于', to: '/about' },
|
||||||
];
|
];
|
||||||
function PageTransition({ children }: { children: React.ReactNode }) {
|
function PageTransition({ children }: { children: React.ReactNode }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
return <div key={location.pathname} className="page-enter">{children}</div>;
|
return (
|
||||||
|
<MotionDiv key={location.pathname} motionKey={location.pathname} className="page-enter">
|
||||||
|
{children}
|
||||||
|
</MotionDiv>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
function RouteLoadingFallback() {
|
function RouteLoadingFallback() {
|
||||||
return (
|
return (
|
||||||
@@ -454,6 +604,7 @@ function RouteLoadingFallback() {
|
|||||||
function AppShell() {
|
function AppShell() {
|
||||||
const { language, toggleLanguage, t } = useI18n();
|
const { language, toggleLanguage, t } = useI18n();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const topbarRef = useRef<HTMLElement>(null);
|
||||||
const [authed, setAuthed] = useState(() => getSessionToken('admin') !== null);
|
const [authed, setAuthed] = useState(() => getSessionToken('admin') !== null);
|
||||||
const [userAuthed, setUserAuthed] = useState(() => getSessionToken('user') !== null);
|
const [userAuthed, setUserAuthed] = useState(() => getSessionToken('user') !== null);
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
@@ -484,9 +635,51 @@ function AppShell() {
|
|||||||
? (systemPrefersDark ? 'dark' : 'light')
|
? (systemPrefersDark ? 'dark' : 'light')
|
||||||
: themeMode;
|
: themeMode;
|
||||||
const rawDisplayName = (userProfile.name || '').trim();
|
const rawDisplayName = (userProfile.name || '').trim();
|
||||||
const displayName = rawDisplayName ? (rawDisplayName === '管理员' ? t('管理员') : rawDisplayName) : t('管理员');
|
const accountRoleLabel = profileAudience === 'admin' ? t('管理员') : t('普通用户');
|
||||||
|
const displayName = rawDisplayName ? (rawDisplayName === '管理员' ? t('管理员') : rawDisplayName) : accountRoleLabel;
|
||||||
const resolvedThemeLabel = resolvedTheme === 'dark' ? t('深色') : t('浅色');
|
const resolvedThemeLabel = resolvedTheme === 'dark' ? t('深色') : t('浅色');
|
||||||
const avatarUrl = buildDicebearAvatarUrl(userProfile.avatarStyle, userProfile.avatarSeed);
|
useEffect(() => {
|
||||||
|
const root = topbarRef.current;
|
||||||
|
if (!root) return undefined;
|
||||||
|
|
||||||
|
const mm = gsap.matchMedia();
|
||||||
|
mm.add(
|
||||||
|
{
|
||||||
|
reduceMotion: '(prefers-reduced-motion: reduce)',
|
||||||
|
isDesktop: '(min-width: 900px)',
|
||||||
|
},
|
||||||
|
(context) => {
|
||||||
|
const conditions = context.conditions || {};
|
||||||
|
const items = Array.from(root.querySelectorAll<HTMLElement>('.topbar-motion-item'));
|
||||||
|
|
||||||
|
if (conditions.reduceMotion) {
|
||||||
|
gsap.set(items, {
|
||||||
|
autoAlpha: 1,
|
||||||
|
clearProps: 'transform,opacity,visibility',
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tween = gsap.fromTo(
|
||||||
|
items,
|
||||||
|
{ autoAlpha: 0, y: conditions.isDesktop ? -8 : -4, scale: 0.985 },
|
||||||
|
{
|
||||||
|
autoAlpha: 1,
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
duration: 0.34,
|
||||||
|
ease: 'power2.out',
|
||||||
|
stagger: 0.032,
|
||||||
|
clearProps: 'transform,opacity,visibility',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => tween.kill();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => mm.revert();
|
||||||
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUserProfile(resolveStoredProfile(profileAudience));
|
setUserProfile(resolveStoredProfile(profileAudience));
|
||||||
}, [profileAudience]);
|
}, [profileAudience]);
|
||||||
@@ -659,9 +852,10 @@ function AppShell() {
|
|||||||
const normalized = {
|
const normalized = {
|
||||||
name: nextProfile.name.trim() || t(profileAudience === 'admin' ? '管理员' : '用户'),
|
name: nextProfile.name.trim() || t(profileAudience === 'admin' ? '管理员' : '用户'),
|
||||||
avatarSeed: normalizedSeed,
|
avatarSeed: normalizedSeed,
|
||||||
avatarStyle: DICEBEAR_STYLES.includes(nextProfile.avatarStyle as DicebearStyle)
|
avatarStyle: AVATAR_STYLES.includes(nextProfile.avatarStyle as AvatarStyle)
|
||||||
? nextProfile.avatarStyle
|
? nextProfile.avatarStyle
|
||||||
: pickDicebearStyle(normalizedSeed),
|
: pickAvatarStyle(normalizedSeed),
|
||||||
|
avatarUrl: normalizeAvatarUrl(nextProfile.avatarUrl) || undefined,
|
||||||
};
|
};
|
||||||
setUserProfile(normalized);
|
setUserProfile(normalized);
|
||||||
localStorage.setItem(profileStorageKey(profileAudience), JSON.stringify(normalized));
|
localStorage.setItem(profileStorageKey(profileAudience), JSON.stringify(normalized));
|
||||||
@@ -697,16 +891,14 @@ function AppShell() {
|
|||||||
};
|
};
|
||||||
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`.
|
|
||||||
const isDefaultLoginRoot = !authed && !userAuthed && location.pathname === '/';
|
|
||||||
|
|
||||||
if (isUserPage && isPublicUserPage) {
|
if (isUserPage && isPublicUserPage) {
|
||||||
if (userAuthed) return <Navigate to="/user/dashboard" />;
|
if (userAuthed) return <Navigate to="/user/dashboard" />;
|
||||||
if (authed) return <Navigate to="/" />;
|
if (authed) return <Navigate to="/dashboard" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAdminLoginPage) {
|
if (isAdminLoginPage) {
|
||||||
if (authed) return <Navigate to="/" />;
|
if (authed) return <Navigate to="/dashboard" />;
|
||||||
return (
|
return (
|
||||||
<AdminLoginPage
|
<AdminLoginPage
|
||||||
t={t}
|
t={t}
|
||||||
@@ -715,10 +907,6 @@ function AppShell() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDefaultLoginRoot) {
|
|
||||||
return <Navigate to="/user/login" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!authed && !isUserPage) {
|
if (!authed && !isUserPage) {
|
||||||
if (userAuthed) {
|
if (userAuthed) {
|
||||||
return <Navigate to="/user/dashboard" />;
|
return <Navigate to="/user/dashboard" />;
|
||||||
@@ -751,18 +939,15 @@ function AppShell() {
|
|||||||
const currentTopNavItems = isUserPage ? [
|
const currentTopNavItems = isUserPage ? [
|
||||||
{ label: '个人中心', to: '/user/dashboard' },
|
{ label: '个人中心', to: '/user/dashboard' },
|
||||||
{ label: '模型广场', to: '/user/models' },
|
{ label: '模型广场', to: '/user/models' },
|
||||||
{ label: '用户公告', to: '/user/announcements' },
|
|
||||||
{ label: '工作流', to: '/user/workflows' },
|
{ label: '工作流', to: '/user/workflows' },
|
||||||
{ label: '视频助手', to: '/user/comfyui-agent' },
|
|
||||||
{ label: '工作空间', to: '/user/workspace' },
|
|
||||||
{ label: '关于', to: '/user/about' },
|
{ label: '关于', to: '/user/about' },
|
||||||
] : topNavItems;
|
] : topNavItems;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="topbar">
|
<header ref={topbarRef} className="topbar">
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<button
|
<button
|
||||||
className="topbar-icon-btn"
|
className="topbar-icon-btn topbar-motion-item"
|
||||||
aria-label={t('打开导航')}
|
aria-label={t('打开导航')}
|
||||||
onClick={() => setDrawerOpen(true)}
|
onClick={() => setDrawerOpen(true)}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -772,11 +957,16 @@ function AppShell() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="topbar-logo">
|
<div className="topbar-logo topbar-motion-item">
|
||||||
<img src="/logo.png" alt="BossAI" style={{ width: 28, height: 28, borderRadius: 6 }} />
|
<span className="topbar-logo-mark">
|
||||||
|
<img src="/logo.png" alt="BossAI" />
|
||||||
|
</span>
|
||||||
|
<span className="topbar-logo-copy">
|
||||||
<span className="topbar-logo-text">BossAI</span>
|
<span className="topbar-logo-text">BossAI</span>
|
||||||
|
<span className="topbar-logo-subtitle">{isUserPage ? t('用户工作台') : t('管理控制台')}</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<nav className="topbar-nav">
|
<nav className="topbar-nav topbar-motion-item">
|
||||||
{currentTopNavItems.map((item) => (
|
{currentTopNavItems.map((item) => (
|
||||||
<NavLink key={item.to} to={item.to} end className={({ isActive }) => `topbar-nav-item ${isActive ? 'active' : ''}`}>
|
<NavLink key={item.to} to={item.to} end className={({ isActive }) => `topbar-nav-item ${isActive ? 'active' : ''}`}>
|
||||||
{t(item.label)}
|
{t(item.label)}
|
||||||
@@ -785,20 +975,19 @@ function AppShell() {
|
|||||||
</nav>
|
</nav>
|
||||||
<div className="topbar-right">
|
<div className="topbar-right">
|
||||||
<button
|
<button
|
||||||
className="topbar-icon-btn"
|
className="topbar-icon-btn topbar-language-btn topbar-motion-item"
|
||||||
aria-label={language === 'zh' ? 'Switch to English' : '切换到中文'}
|
aria-label={language === 'zh' ? 'Switch to English' : '切换到中文'}
|
||||||
onClick={toggleLanguage}
|
onClick={toggleLanguage}
|
||||||
style={{ minWidth: 36, fontSize: 12, fontWeight: 700 }}
|
|
||||||
>
|
>
|
||||||
{language === 'zh' ? 'EN' : '中'}
|
{language === 'zh' ? 'EN' : '中'}
|
||||||
</button>
|
</button>
|
||||||
<button className="topbar-search-trigger" aria-label={t('搜索 (Ctrl+K)')} onClick={() => setShowSearch(true)}>
|
<button className="topbar-search-trigger topbar-motion-item" aria-label={t('搜索 (Ctrl+K)')} onClick={() => setShowSearch(true)}>
|
||||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||||
<span className="topbar-search-label">{t('搜索')}</span>
|
<span className="topbar-search-label">{t('搜索')}</span>
|
||||||
<kbd className="topbar-search-kbd">Ctrl K</kbd>
|
<kbd className="topbar-search-kbd">Ctrl K</kbd>
|
||||||
</button>
|
</button>
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<button ref={notifBtnRef} className="topbar-icon-btn" aria-label={t('通知')} onClick={() => setShowNotifications(!showNotifications)}>
|
<button ref={notifBtnRef} className="topbar-icon-btn topbar-motion-item" aria-label={t('通知')} onClick={() => setShowNotifications(!showNotifications)}>
|
||||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg>
|
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg>
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<span className="topbar-badge">
|
<span className="topbar-badge">
|
||||||
@@ -810,7 +999,7 @@ function AppShell() {
|
|||||||
</div>
|
</div>
|
||||||
<div ref={themeMenuRef} style={{ position: 'relative' }}>
|
<div ref={themeMenuRef} style={{ position: 'relative' }}>
|
||||||
<button
|
<button
|
||||||
className="topbar-icon-btn"
|
className="topbar-icon-btn topbar-motion-item"
|
||||||
aria-label={themeMode === 'system'
|
aria-label={themeMode === 'system'
|
||||||
? `${t('跟随系统')} (${resolvedThemeLabel})`
|
? `${t('跟随系统')} (${resolvedThemeLabel})`
|
||||||
: (themeMode === 'light' ? t('浅色模式') : t('深色模式'))}
|
: (themeMode === 'light' ? t('浅色模式') : t('深色模式'))}
|
||||||
@@ -856,8 +1045,8 @@ function AppShell() {
|
|||||||
<div ref={userMenuRef} style={{ position: 'relative' }}>
|
<div ref={userMenuRef} style={{ position: 'relative' }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="topbar-avatar"
|
className="topbar-user-trigger topbar-motion-item"
|
||||||
aria-label={displayName}
|
aria-label={`${t('账户菜单')}: ${displayName}`}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={showUserMenu}
|
aria-expanded={showUserMenu}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -865,14 +1054,37 @@ function AppShell() {
|
|||||||
setShowThemeMenu(false);
|
setShowThemeMenu(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<span className="topbar-user-avatar">
|
||||||
src={avatarUrl}
|
<LocalAvatar
|
||||||
alt={displayName}
|
className="topbar-user-avatar-inner"
|
||||||
style={{ width: '100%', height: '100%', borderRadius: '50%', objectFit: 'cover' }}
|
profile={userProfile}
|
||||||
|
label={displayName}
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="topbar-user-copy">
|
||||||
|
<span className="topbar-user-name">{displayName}</span>
|
||||||
|
<span className="topbar-user-role">{accountRoleLabel}</span>
|
||||||
|
</span>
|
||||||
|
<svg className={`topbar-user-chevron ${showUserMenu ? 'open' : ''}`} width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{userMenuPresence.shouldRender && (
|
{userMenuPresence.shouldRender && (
|
||||||
<div className={`user-dropdown ${userMenuPresence.isVisible ? '' : 'is-closing'}`.trim()}>
|
<div className={`user-dropdown user-dropdown-profile ${userMenuPresence.isVisible ? '' : 'is-closing'}`.trim()}>
|
||||||
|
<div className="user-dropdown-header">
|
||||||
|
<span className="user-dropdown-avatar">
|
||||||
|
<LocalAvatar
|
||||||
|
className="user-dropdown-avatar-inner"
|
||||||
|
profile={userProfile}
|
||||||
|
label={displayName}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="user-dropdown-identity">
|
||||||
|
<strong>{displayName}</strong>
|
||||||
|
<span>{accountRoleLabel} / {resolvedThemeLabel}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="user-dropdown-divider" />
|
||||||
<button
|
<button
|
||||||
className="user-dropdown-item"
|
className="user-dropdown-item"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -917,7 +1129,7 @@ function AppShell() {
|
|||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
end={item.to === '/' || item.to === '/user/dashboard' || item.to === '/settings'}
|
end={item.to === '/dashboard' || item.to === '/user/dashboard' || item.to === '/settings'}
|
||||||
className={({ isActive }) => `mobile-nav-item ${isActive ? 'active' : ''}`}
|
className={({ isActive }) => `mobile-nav-item ${isActive ? 'active' : ''}`}
|
||||||
onClick={() => setDrawerOpen(false)}
|
onClick={() => setDrawerOpen(false)}
|
||||||
>
|
>
|
||||||
@@ -929,7 +1141,7 @@ function AppShell() {
|
|||||||
))}
|
))}
|
||||||
<div className="mobile-nav-group">
|
<div className="mobile-nav-group">
|
||||||
<div className="mobile-nav-label">{t('更多')}</div>
|
<div className="mobile-nav-label">{t('更多')}</div>
|
||||||
{currentTopNavItems.filter((n) => n.to !== '/' && n.to !== '/user/dashboard').map((item) => (
|
{currentTopNavItems.filter((n) => n.to !== '/dashboard' && n.to !== '/user/dashboard').map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
@@ -944,6 +1156,7 @@ function AppShell() {
|
|||||||
</MobileDrawer>
|
</MobileDrawer>
|
||||||
) : (
|
) : (
|
||||||
<aside className={`sidebar ${sidebarCollapsed ? 'collapsed' : ''}`}>
|
<aside className={`sidebar ${sidebarCollapsed ? 'collapsed' : ''}`}>
|
||||||
|
<nav className="sidebar-scroll" aria-label={t(isUserPage ? '用户工作台' : '管理控制台')}>
|
||||||
{currentSidebarGroups.map((group) => (
|
{currentSidebarGroups.map((group) => (
|
||||||
<div key={group.label} className="sidebar-group">
|
<div key={group.label} className="sidebar-group">
|
||||||
{!sidebarCollapsed && <div className="sidebar-group-label">{t(group.label)}</div>}
|
{!sidebarCollapsed && <div className="sidebar-group-label">{t(group.label)}</div>}
|
||||||
@@ -951,7 +1164,7 @@ function AppShell() {
|
|||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
end={item.to === '/' || item.to === '/user/dashboard' || item.to === '/settings'}
|
end={item.to === '/dashboard' || item.to === '/user/dashboard' || item.to === '/settings'}
|
||||||
className={({ isActive }) => `sidebar-item ${isActive ? 'active' : ''}`}
|
className={({ isActive }) => `sidebar-item ${isActive ? 'active' : ''}`}
|
||||||
data-tooltip={sidebarCollapsed ? t(item.label) : undefined}
|
data-tooltip={sidebarCollapsed ? t(item.label) : undefined}
|
||||||
aria-label={sidebarCollapsed ? t(item.label) : undefined}
|
aria-label={sidebarCollapsed ? t(item.label) : undefined}
|
||||||
@@ -962,19 +1175,27 @@ function AppShell() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button className="sidebar-collapse-btn" onClick={() => setSidebarCollapsed(!sidebarCollapsed)}>
|
</nav>
|
||||||
|
<div className="sidebar-footer">
|
||||||
|
<button
|
||||||
|
className="sidebar-collapse-btn"
|
||||||
|
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||||
|
aria-label={sidebarCollapsed ? t('展开侧边栏') : t('收起侧边栏')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ transform: sidebarCollapsed ? 'rotate(180deg)' : 'none', transition: 'transform 0.3s ease', flexShrink: 0 }}>
|
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ transform: sidebarCollapsed ? 'rotate(180deg)' : 'none', transition: 'transform 0.3s ease', flexShrink: 0 }}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
{!sidebarCollapsed && <span>{t('收起侧边栏')}</span>}
|
{!sidebarCollapsed && <span>{t('收起侧边栏')}</span>}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
<main className="main-content">
|
<main className="main-content">
|
||||||
<PageTransition>
|
<PageTransition>
|
||||||
<Suspense fallback={<RouteLoadingFallback />}>
|
<Suspense fallback={<RouteLoadingFallback />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard adminName={displayName} />} />
|
<Route path="/dashboard" element={<Dashboard adminName={displayName} />} />
|
||||||
<Route path="/sites" element={<Sites />} />
|
<Route path="/sites" element={<Sites />} />
|
||||||
<Route path="/site-announcements" element={<SiteAnnouncements />} />
|
<Route path="/site-announcements" element={<SiteAnnouncements />} />
|
||||||
<Route path="/accounts" element={<Accounts />} />
|
<Route path="/accounts" element={<Accounts />} />
|
||||||
@@ -1019,7 +1240,7 @@ function AppShell() {
|
|||||||
<Route path="/user/workspace" element={<Workspace />} />
|
<Route path="/user/workspace" element={<Workspace />} />
|
||||||
<Route path="/user/about" element={<About />} />
|
<Route path="/user/about" element={<About />} />
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to={isUserPage ? "/user/dashboard" : "/"} />} />
|
<Route path="*" element={<Navigate to={isUserPage ? "/user/dashboard" : "/dashboard"} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</PageTransition>
|
</PageTransition>
|
||||||
|
|||||||
+37
-37
@@ -392,6 +392,9 @@ function normalizeProxyLogRow(row: any): any {
|
|||||||
clientAppId: row.clientAppId ?? row.client_app_id ?? null,
|
clientAppId: row.clientAppId ?? row.client_app_id ?? null,
|
||||||
clientAppName: row.clientAppName ?? row.client_app_name ?? null,
|
clientAppName: row.clientAppName ?? row.client_app_name ?? null,
|
||||||
clientConfidence: row.clientConfidence ?? row.client_confidence ?? null,
|
clientConfidence: row.clientConfidence ?? row.client_confidence ?? null,
|
||||||
|
contentSummary: row.contentSummary ?? row.content_summary ?? null,
|
||||||
|
requestContent: row.requestContent ?? row.request_content ?? null,
|
||||||
|
responseContent: row.responseContent ?? row.response_content ?? null,
|
||||||
createdAt: row.createdAt ?? row.created_at ?? null,
|
createdAt: row.createdAt ?? row.created_at ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -692,6 +695,8 @@ export type RuntimeSettingsPayload = {
|
|||||||
barkUrl?: string;
|
barkUrl?: string;
|
||||||
webhookEnabled?: boolean;
|
webhookEnabled?: boolean;
|
||||||
barkEnabled?: boolean;
|
barkEnabled?: boolean;
|
||||||
|
feishuEnabled?: boolean;
|
||||||
|
feishuWebhook?: string;
|
||||||
serverChanEnabled?: boolean;
|
serverChanEnabled?: boolean;
|
||||||
serverChanKey?: string;
|
serverChanKey?: string;
|
||||||
telegramEnabled?: boolean;
|
telegramEnabled?: boolean;
|
||||||
@@ -784,6 +789,9 @@ export type ProxyLogListItem = {
|
|||||||
promptTokens?: number | null;
|
promptTokens?: number | null;
|
||||||
completionTokens?: number | null;
|
completionTokens?: number | null;
|
||||||
estimatedCost?: number | null;
|
estimatedCost?: number | null;
|
||||||
|
contentSummary?: string | null;
|
||||||
|
requestContent?: string | null;
|
||||||
|
responseContent?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProxyLogDetail = ProxyLogListItem & {
|
export type ProxyLogDetail = ProxyLogListItem & {
|
||||||
@@ -1196,6 +1204,31 @@ export type MoneyPrinterJobsResponse = {
|
|||||||
jobs: MoneyPrinterJob[];
|
jobs: MoneyPrinterJob[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MoneyPrinterStatusResponse = {
|
||||||
|
success: boolean;
|
||||||
|
mode: string;
|
||||||
|
keyName?: string;
|
||||||
|
capabilities?: string[];
|
||||||
|
comfy?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
backend?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
};
|
||||||
|
builtinCloudTemplate?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
label?: string;
|
||||||
|
nodeType?: string;
|
||||||
|
model?: string;
|
||||||
|
duration?: {
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
default?: number;
|
||||||
|
};
|
||||||
|
resolutions?: string[];
|
||||||
|
ratios?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
// Sites
|
// Sites
|
||||||
getSites: async () => {
|
getSites: async () => {
|
||||||
@@ -1758,43 +1791,6 @@ export const api = {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}),
|
}),
|
||||||
getUpdateCenterStatus: () => request("/api/update-center/status"),
|
|
||||||
saveUpdateCenterConfig: (data: any) =>
|
|
||||||
request("/api/update-center/config", {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
checkUpdateCenter: () =>
|
|
||||||
request("/api/update-center/check", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
}),
|
|
||||||
deployUpdateCenter: (data: {
|
|
||||||
source: "github-release" | "docker-hub-tag";
|
|
||||||
targetTag: string;
|
|
||||||
targetDigest?: string | null;
|
|
||||||
}) =>
|
|
||||||
request("/api/update-center/deploy", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
rollbackUpdateCenter: (data: { targetRevision: string }) =>
|
|
||||||
request("/api/update-center/rollback", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
streamUpdateCenterTaskLogs: (
|
|
||||||
taskId: string,
|
|
||||||
handlers: {
|
|
||||||
onLog?: (entry: any) => void;
|
|
||||||
onDone?: (payload: any) => void;
|
|
||||||
signal?: AbortSignal;
|
|
||||||
},
|
|
||||||
) =>
|
|
||||||
streamSse(
|
|
||||||
`/api/update-center/tasks/${encodeURIComponent(taskId)}/stream`,
|
|
||||||
handlers,
|
|
||||||
),
|
|
||||||
testSystemProxy: (data: SystemProxyTestRequest) =>
|
testSystemProxy: (data: SystemProxyTestRequest) =>
|
||||||
request("/api/settings/system-proxy/test", {
|
request("/api/settings/system-proxy/test", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -2093,6 +2089,8 @@ export const api = {
|
|||||||
request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
|
request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
|
||||||
clearDigitalHumanJobs: () =>
|
clearDigitalHumanJobs: () =>
|
||||||
request<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }),
|
request<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }),
|
||||||
|
getMoneyPrinterStatus: () =>
|
||||||
|
request<MoneyPrinterStatusResponse>("/api/money-printer/status"),
|
||||||
createMoneyPrinterJob: (data: any) =>
|
createMoneyPrinterJob: (data: any) =>
|
||||||
request<MoneyPrinterJobResponse>("/api/money-printer/jobs", {
|
request<MoneyPrinterJobResponse>("/api/money-printer/jobs", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -2196,6 +2194,8 @@ export const userApi = {
|
|||||||
userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
|
userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
|
||||||
clearDigitalHumanJobs: () =>
|
clearDigitalHumanJobs: () =>
|
||||||
userRequest<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }),
|
userRequest<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }),
|
||||||
|
getMoneyPrinterStatus: () =>
|
||||||
|
userRequest<MoneyPrinterStatusResponse>("/api/money-printer/status"),
|
||||||
createMoneyPrinterJob: (data: any) =>
|
createMoneyPrinterJob: (data: any) =>
|
||||||
userRequest<MoneyPrinterJobResponse>("/api/money-printer/jobs", {
|
userRequest<MoneyPrinterJobResponse>("/api/money-printer/jobs", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const USER_PROFILE_STORAGE_KEY = 'user_profile';
|
|||||||
export const ADMIN_PROFILE_STORAGE_KEY = 'admin_profile';
|
export const ADMIN_PROFILE_STORAGE_KEY = 'admin_profile';
|
||||||
export const LEGACY_USER_PROFILE_STORAGE_KEY = USER_PROFILE_STORAGE_KEY;
|
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';
|
||||||
|
export const LEGACY_FIRST_USE_DOC_REMINDER_KEY = 'metapi_first_use_docs_reminder_seen_v1';
|
||||||
|
|
||||||
type StorageLike = {
|
type StorageLike = {
|
||||||
getItem?: (key: string) => string | null;
|
getItem?: (key: string) => string | null;
|
||||||
@@ -28,4 +29,5 @@ export function clearAppInstallationState(storage?: StorageLike | null): void {
|
|||||||
target.removeItem(USER_PROFILE_STORAGE_KEY);
|
target.removeItem(USER_PROFILE_STORAGE_KEY);
|
||||||
target.removeItem(ADMIN_PROFILE_STORAGE_KEY);
|
target.removeItem(ADMIN_PROFILE_STORAGE_KEY);
|
||||||
target.removeItem(FIRST_USE_DOC_REMINDER_KEY);
|
target.removeItem(FIRST_USE_DOC_REMINDER_KEY);
|
||||||
|
target.removeItem(LEGACY_FIRST_USE_DOC_REMINDER_KEY);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { gsap } from 'gsap';
|
||||||
|
|
||||||
|
type MotionVariant = 'page' | 'login';
|
||||||
|
|
||||||
|
interface MotionDivProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
motionKey?: string;
|
||||||
|
motionVariant?: MotionVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_TARGET_SELECTOR = [
|
||||||
|
':scope > .console-hero',
|
||||||
|
':scope > .page-header',
|
||||||
|
':scope > .card',
|
||||||
|
':scope > .mobile-card',
|
||||||
|
':scope > .workflow-shell',
|
||||||
|
':scope > .picture-agent-shell',
|
||||||
|
':scope > .workspace-layout',
|
||||||
|
':scope > .dashboard-grid',
|
||||||
|
':scope > .digital-human-layout',
|
||||||
|
':scope > .money-printer-layout',
|
||||||
|
':scope > .about-page',
|
||||||
|
].join(', ');
|
||||||
|
|
||||||
|
const LOGIN_TARGET_SELECTOR = [
|
||||||
|
'.login-brand-header',
|
||||||
|
'.login-brand-copy-block',
|
||||||
|
'.login-compat-line',
|
||||||
|
'.login-capability-row',
|
||||||
|
'.login-auth-eyebrow',
|
||||||
|
'.login-auth-title',
|
||||||
|
'.login-auth-copy',
|
||||||
|
'.login-mode-tabs',
|
||||||
|
'.login-auth-panel form',
|
||||||
|
'.login-auth-footer',
|
||||||
|
].join(', ');
|
||||||
|
|
||||||
|
function selectMotionTargets(root: HTMLDivElement, variant: MotionVariant): Element[] {
|
||||||
|
const selector = variant === 'login' ? LOGIN_TARGET_SELECTOR : PAGE_TARGET_SELECTOR;
|
||||||
|
return Array.from(root.querySelectorAll(selector)).slice(0, variant === 'login' ? 14 : 18);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MotionDiv({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
motionKey,
|
||||||
|
motionVariant = 'page',
|
||||||
|
...props
|
||||||
|
}: MotionDivProps) {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const resolvedMotionKey = motionKey || motionVariant;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = rootRef.current;
|
||||||
|
if (!root) return undefined;
|
||||||
|
|
||||||
|
const mm = gsap.matchMedia();
|
||||||
|
mm.add(
|
||||||
|
{
|
||||||
|
reduceMotion: '(prefers-reduced-motion: reduce)',
|
||||||
|
isDesktop: '(min-width: 900px)',
|
||||||
|
},
|
||||||
|
(context) => {
|
||||||
|
const conditions = context.conditions || {};
|
||||||
|
if (conditions.reduceMotion) {
|
||||||
|
gsap.set(root, { autoAlpha: 1, clearProps: 'transform,opacity,visibility' });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = selectMotionTargets(root, motionVariant);
|
||||||
|
const distance = motionVariant === 'login' ? 16 : 10;
|
||||||
|
|
||||||
|
const rootTween = gsap.fromTo(
|
||||||
|
root,
|
||||||
|
{ autoAlpha: 0, y: Math.max(6, distance - 4) },
|
||||||
|
{
|
||||||
|
autoAlpha: 1,
|
||||||
|
y: 0,
|
||||||
|
duration: motionVariant === 'login' ? 0.46 : 0.34,
|
||||||
|
ease: 'power2.out',
|
||||||
|
clearProps: 'transform,opacity,visibility',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const targetTween = targets.length
|
||||||
|
? gsap.fromTo(
|
||||||
|
targets,
|
||||||
|
{ autoAlpha: 0, y: distance },
|
||||||
|
{
|
||||||
|
autoAlpha: 1,
|
||||||
|
y: 0,
|
||||||
|
duration: motionVariant === 'login' ? 0.58 : 0.42,
|
||||||
|
ease: 'power2.out',
|
||||||
|
stagger: motionVariant === 'login' ? 0.045 : 0.025,
|
||||||
|
delay: 0.04,
|
||||||
|
clearProps: 'transform,opacity,visibility',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
rootTween.kill();
|
||||||
|
targetTween?.kill();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => mm.revert();
|
||||||
|
}, [resolvedMotionKey, motionVariant]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className={['motion-root', className].filter(Boolean).join(' ')}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { CSSProperties, ReactNode } from 'react';
|
||||||
|
|
||||||
|
type PageHeaderProps = {
|
||||||
|
title: ReactNode;
|
||||||
|
subtitle?: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
titleAs?: 'h1' | 'h2';
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PageHeader({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
actions,
|
||||||
|
className = '',
|
||||||
|
style,
|
||||||
|
titleAs = 'h2',
|
||||||
|
}: PageHeaderProps) {
|
||||||
|
const Heading = titleAs;
|
||||||
|
const classes = ['page-header', className].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes} style={style}>
|
||||||
|
<div className="page-header-copy">
|
||||||
|
<Heading className="page-title">{title}</Heading>
|
||||||
|
{subtitle ? <div className="page-subtitle">{subtitle}</div> : null}
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="page-actions">{actions}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,11 +8,12 @@ export function useIsMobile(breakpoint = MOBILE_BREAKPOINT) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
const targetWindow = window;
|
||||||
const query = breakpoint === MOBILE_BREAKPOINT
|
const query = breakpoint === MOBILE_BREAKPOINT
|
||||||
? MOBILE_MEDIA_QUERY
|
? MOBILE_MEDIA_QUERY
|
||||||
: `(max-width: ${breakpoint}px)`;
|
: `(max-width: ${breakpoint}px)`;
|
||||||
const media = typeof window.matchMedia === 'function' ? window.matchMedia(query) : null;
|
const media = typeof targetWindow.matchMedia === 'function' ? targetWindow.matchMedia(query) : null;
|
||||||
const update = () => setIsMobile(media ? media.matches : window.innerWidth <= breakpoint);
|
const update = () => setIsMobile(media ? media.matches : targetWindow.innerWidth <= breakpoint);
|
||||||
update();
|
update();
|
||||||
|
|
||||||
if (media && typeof media.addEventListener === 'function') {
|
if (media && typeof media.addEventListener === 'function') {
|
||||||
@@ -24,14 +25,14 @@ export function useIsMobile(breakpoint = MOBILE_BREAKPOINT) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const addResizeListener = typeof window.addEventListener === 'function';
|
const addResizeListener = typeof targetWindow.addEventListener === 'function';
|
||||||
const removeResizeListener = typeof window.removeEventListener === 'function';
|
const removeResizeListener = typeof targetWindow.removeEventListener === 'function';
|
||||||
if (!addResizeListener) return;
|
if (!addResizeListener) return;
|
||||||
|
|
||||||
window.addEventListener('resize', update);
|
targetWindow.addEventListener('resize', update);
|
||||||
return () => {
|
return () => {
|
||||||
if (removeResizeListener) {
|
if (removeResizeListener) {
|
||||||
window.removeEventListener('resize', update);
|
targetWindow.removeEventListener('resize', update);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [breakpoint]);
|
}, [breakpoint]);
|
||||||
|
|||||||
@@ -180,6 +180,18 @@ export const zhToEnSupplemental: Record<string, string> = {
|
|||||||
'个模型': 'models',
|
'个模型': 'models',
|
||||||
'个人信息': 'personal information',
|
'个人信息': 'personal information',
|
||||||
'个人信息已保存': 'Personal information has been saved',
|
'个人信息已保存': 'Personal information has been saved',
|
||||||
|
'头像实时预览': 'Avatar live preview',
|
||||||
|
'会同步显示在右上角和侧边栏。': 'It will appear in the top-right menu and sidebar.',
|
||||||
|
'本地上传头像': 'Upload local avatar',
|
||||||
|
'选择图片': 'Choose image',
|
||||||
|
'已选择本地头像': 'Local avatar selected',
|
||||||
|
'未选择文件': 'No file selected',
|
||||||
|
'图片会压缩后保存在本机浏览器,不上传服务器。': 'The image is compressed and saved in this browser, not uploaded to the server.',
|
||||||
|
'头像文件必须是图片格式': 'Avatar file must be an image',
|
||||||
|
'头像图片不能超过 5MB': 'Avatar image must be under 5MB',
|
||||||
|
'当前浏览器无法处理头像图片': 'This browser cannot process the avatar image',
|
||||||
|
'头像上传失败': 'Avatar upload failed',
|
||||||
|
'头像图片读取失败,请重新上传': 'Avatar image could not be read. Please upload it again.',
|
||||||
'个站点': 'sites',
|
'个站点': 'sites',
|
||||||
'个账号': 'accounts',
|
'个账号': 'accounts',
|
||||||
'跟随系统': 'Follow the system',
|
'跟随系统': 'Follow the system',
|
||||||
@@ -191,7 +203,7 @@ export const zhToEnSupplemental: Record<string, string> = {
|
|||||||
'共 ${models.length} 个模型': 'Total ${models.length} models',
|
'共 ${models.length} 个模型': 'Total ${models.length} models',
|
||||||
'关闭': 'closure',
|
'关闭': 'closure',
|
||||||
'关于': 'about',
|
'关于': 'about',
|
||||||
'关于 BossAI': 'About BossAI',
|
'关于 BoosAPI': 'About BoosAPI',
|
||||||
'管理端 IP 白名单': 'Management IP whitelist',
|
'管理端 IP 白名单': 'Management IP whitelist',
|
||||||
'管理员': 'administrator',
|
'管理员': 'administrator',
|
||||||
'管理员登录 Token 已被修改,请使用新 Token 登录。': 'The administrator login token has been modified, please use the new token to log in.',
|
'管理员登录 Token 已被修改,请使用新 Token 登录。': 'The administrator login token has been modified, please use the new token to log in.',
|
||||||
@@ -205,6 +217,7 @@ export const zhToEnSupplemental: Record<string, string> = {
|
|||||||
'缓存清理后重建完成:新增路由 ${rebuild.createdRoutes},新增通道 ${rebuild.createdChannels},移除通道 ${rebuild.removedChannels}': 'Reconstruction is completed after cache cleaning: new route ${rebuild.createdRoutes}, new channel ${rebuild.createdChannels}, removed channel ${rebuild.removedChannels}',
|
'缓存清理后重建完成:新增路由 ${rebuild.createdRoutes},新增通道 ${rebuild.createdChannels},移除通道 ${rebuild.removedChannels}': 'Reconstruction is completed after cache cleaning: new route ${rebuild.createdRoutes}, new channel ${rebuild.createdChannels}, removed channel ${rebuild.removedChannels}',
|
||||||
'缓存已清理,重建路由已开始执行': 'The cache has been cleared and routing reconstruction has started.',
|
'缓存已清理,重建路由已开始执行': 'The cache has been cleared and routing reconstruction has started.',
|
||||||
'缓存已清理(模型缓存 ${res.deletedModelAvailability || 0} 条)': 'Cache cleared (model cache ${res.deletedModelAvailability || 0} items)',
|
'缓存已清理(模型缓存 ${res.deletedModelAvailability || 0} 条)': 'Cache cleared (model cache ${res.deletedModelAvailability || 0} items)',
|
||||||
|
'清除图片头像': 'Clear image avatar',
|
||||||
'换一个随机头像': 'Change a random avatar',
|
'换一个随机头像': 'Change a random avatar',
|
||||||
'恢复待处理的请求快照。': 'Restore a pending request snapshot.',
|
'恢复待处理的请求快照。': 'Restore a pending request snapshot.',
|
||||||
'恢复任务 ${restored.pendingJobId}。': 'Recovery task ${restored.pendingJobId}.',
|
'恢复任务 ${restored.pendingJobId}。': 'Recovery task ${restored.pendingJobId}.',
|
||||||
@@ -259,7 +272,7 @@ export const zhToEnSupplemental: Record<string, string> = {
|
|||||||
'拉取分组失败': 'Failed to pull group',
|
'拉取分组失败': 'Failed to pull group',
|
||||||
'拉取分组失败,已回退 default': 'Failed to pull the group and has fallen back to default',
|
'拉取分组失败,已回退 default': 'Failed to pull the group and has fallen back to default',
|
||||||
'冷却中': 'Cooling down',
|
'冷却中': 'Cooling down',
|
||||||
'例如 BossAI': 'For example BossAI',
|
'例如 BoosAPI': 'For example BoosAPI',
|
||||||
'例如: admin@example.com': 'For example: admin@example.com',
|
'例如: admin@example.com': 'For example: admin@example.com',
|
||||||
'例如: smtp.qq.com': 'For example: smtp.qq.com',
|
'例如: smtp.qq.com': 'For example: smtp.qq.com',
|
||||||
'例如: target@example.com': 'For example: target@example.com',
|
'例如: target@example.com': 'For example: target@example.com',
|
||||||
@@ -468,6 +481,7 @@ export const zhToEnSupplemental: Record<string, string> = {
|
|||||||
'收件人地址': 'Recipient address',
|
'收件人地址': 'Recipient address',
|
||||||
'收起': 'close',
|
'收起': 'close',
|
||||||
'收起侧边栏': 'Collapse sidebar',
|
'收起侧边栏': 'Collapse sidebar',
|
||||||
|
'展开侧边栏': 'Expand sidebar',
|
||||||
'收起高级参数': 'Close advanced parameters',
|
'收起高级参数': 'Close advanced parameters',
|
||||||
'手动模式适合高级场景;自动路由仍会保持开启。': 'Manual mode is suitable for advanced scenarios; automatic routing will still remain on.',
|
'手动模式适合高级场景;自动路由仍会保持开启。': 'Manual mode is suitable for advanced scenarios; automatic routing will still remain on.',
|
||||||
'手动增改路由': 'Manually add and modify routes',
|
'手动增改路由': 'Manually add and modify routes',
|
||||||
@@ -727,7 +741,7 @@ export const zhToEnSupplemental: Record<string, string> = {
|
|||||||
'智能路由引擎': 'Intelligent routing engine',
|
'智能路由引擎': 'Intelligent routing engine',
|
||||||
'智谱 AI': 'Wisdom spectrum AI',
|
'智谱 AI': 'Wisdom spectrum AI',
|
||||||
'中': 'ZH',
|
'中': 'ZH',
|
||||||
'聚合多模型多模态的 AI 应用平台 — 统一接入文本、图像、视频、语音等能力,构建可复用的 AI 工作流和应用。': 'A multimodal AI application platform for many models — unify text, image, video, and voice capabilities to build reusable AI workflows and applications.',
|
'中转站的中转站 — 将你在各处注册的 New API / One API / OneHub 等 AI 中转站聚合为一个统一网关。一个 API Key、一个入口,自动发现模型、智能路由、成本最优。': 'A transit station of transit stations — Aggregate your New API / One API / OneHub and other AI transit stations registered everywhere into a unified gateway. One API Key, one entrance, automatic model discovery, intelligent routing, and cost optimization.',
|
||||||
'重建路由失败': 'Failed to rebuild route',
|
'重建路由失败': 'Failed to rebuild route',
|
||||||
'重新登录或同步新令牌': 'Log back in or sync new token',
|
'重新登录或同步新令牌': 'Log back in or sync new token',
|
||||||
'重新加载当前站点': 'Reload current site',
|
'重新加载当前站点': 'Reload current site',
|
||||||
@@ -770,7 +784,7 @@ export const zhToEnSupplemental: Record<string, string> = {
|
|||||||
'LDOH Cookie 已更新': 'LDOH Cookie updated',
|
'LDOH Cookie 已更新': 'LDOH Cookie updated',
|
||||||
'LDOH Cookie 已清空': 'LDOH Cookie Cleared',
|
'LDOH Cookie 已清空': 'LDOH Cookie Cleared',
|
||||||
'LinuxDo 可用性监控': 'LinuxDo availability monitoring',
|
'LinuxDo 可用性监控': 'LinuxDo availability monitoring',
|
||||||
'BossAI 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。': 'BossAI is completely self-hosted, all data (accounts, tokens, routes, logs) is stored in a local SQLite database and no data is sent to any third party. Proxy requests only travel directly between your server and the upstream site.',
|
'BoosAPI 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。': 'boosapi is completely self-hosted, all data (accounts, tokens, routes, logs) is stored in a local SQLite database and no data is sent to any third party. Proxy requests only travel directly between your server and the upstream site.',
|
||||||
'model 不能为空': 'model cannot be empty',
|
'model 不能为空': 'model cannot be empty',
|
||||||
'modelLimitsEnabled 参数无效': 'modelLimitsEnabled parameter is invalid',
|
'modelLimitsEnabled 参数无效': 'modelLimitsEnabled parameter is invalid',
|
||||||
'models 必须是非空数组': 'models must be a non-empty array',
|
'models 必须是非空数组': 'models must be a non-empty array',
|
||||||
|
|||||||
+88
-7
@@ -7,6 +7,9 @@ const LANGUAGE_STORAGE_KEY = 'app_language';
|
|||||||
|
|
||||||
const zhToEn: Record<string, string> = {
|
const zhToEn: Record<string, string> = {
|
||||||
'管理员': 'Admin',
|
'管理员': 'Admin',
|
||||||
|
'管理控制台': 'Admin Console',
|
||||||
|
'用户工作台': 'User Workspace',
|
||||||
|
'账户菜单': 'Account Menu',
|
||||||
'登录令牌无效': 'Invalid admin token',
|
'登录令牌无效': 'Invalid admin token',
|
||||||
'当前 IP 不在管理白名单中': 'Current IP is not in admin allowlist',
|
'当前 IP 不在管理白名单中': 'Current IP is not in admin allowlist',
|
||||||
'当前识别到的管理端 IP(由服务端判定):': 'Current recognized admin IP (server-side detected):',
|
'当前识别到的管理端 IP(由服务端判定):': 'Current recognized admin IP (server-side detected):',
|
||||||
@@ -19,17 +22,39 @@ const zhToEn: Record<string, string> = {
|
|||||||
'仅校验本地服务访问权限,不会把令牌发送到第三方。': 'Only checks local service access and never sends the token to a third party.',
|
'仅校验本地服务访问权限,不会把令牌发送到第三方。': 'Only checks local service access and never sends the token to a third party.',
|
||||||
'验证中...': 'Verifying...',
|
'验证中...': 'Verifying...',
|
||||||
'登录': 'Sign In',
|
'登录': 'Sign In',
|
||||||
'聚合多模型多模态的 AI 应用平台': 'A multimodal AI application platform for many models',
|
'第三方登录': 'Third-party sign-in',
|
||||||
'聚合文本、图像、视频、语音等多模态能力,统一接入多模型工作流和 AI 应用。': 'Unify text, image, video, and voice capabilities across multi-model workflows and AI applications.',
|
'第三方账号快速注册': 'Quick sign-up with third-party accounts',
|
||||||
|
'接入后可自动创建账号,并绑定同一用户身份。': 'Once connected, accounts can be created automatically and bound to the same user identity.',
|
||||||
|
'管理员配置 OAuth 后即可启用这些入口。': 'These entries become available after an admin configures OAuth.',
|
||||||
|
'等待管理员配置 OAuth 后启用': 'Waiting for an admin to configure OAuth',
|
||||||
|
'待配置': 'Not configured',
|
||||||
|
'AI 应用平台': 'AI Application Platform',
|
||||||
|
'聚合多模型、多模态、工作流与智能路由,把对话、生图、视频和数字人生产放进一个统一平台。': 'Unify multi-model routing, multimodal creation, workflows, chat, image generation, video, and digital human production in one platform.',
|
||||||
|
'中转站的中转站': 'The Hub of Hubs',
|
||||||
|
'把分散的 New API / One API / OneHub 等站点聚合成统一网关,自动发现模型、智能路由、成本更优。': 'Turn fragmented New API / One API / OneHub sites into one unified gateway with auto model discovery, smart routing, and better cost control.',
|
||||||
'兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API': 'Compatible with New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API',
|
'兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API': 'Compatible with New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API',
|
||||||
'用户名不能为空': 'Username cannot be empty',
|
'用户名不能为空': 'Username cannot be empty',
|
||||||
'用户名最多 24 个字符': 'Username can be at most 24 characters',
|
'用户名最多 24 个字符': 'Username can be at most 24 characters',
|
||||||
'个人信息': 'Profile',
|
'个人信息': 'Profile',
|
||||||
'右上角头像实时预览': 'Top-right avatar live preview',
|
'右上角头像实时预览': 'Top-right avatar live preview',
|
||||||
|
'头像实时预览': 'Avatar live preview',
|
||||||
|
'会同步显示在右上角和侧边栏。': 'It will appear in the top-right menu and sidebar.',
|
||||||
'用户名': 'Username',
|
'用户名': 'Username',
|
||||||
'例如:小王': 'e.g. Alex',
|
'例如:小王': 'e.g. Alex',
|
||||||
|
'本地上传头像': 'Upload Local Avatar',
|
||||||
|
'选择图片': 'Choose Image',
|
||||||
|
'已选择本地头像': 'Local avatar selected',
|
||||||
|
'未选择文件': 'No file selected',
|
||||||
|
'图片会压缩后保存在本机浏览器,不上传服务器。': 'The image is compressed and saved in this browser, not uploaded to the server.',
|
||||||
|
'头像文件必须是图片格式': 'Avatar file must be an image',
|
||||||
|
'头像图片不能超过 5MB': 'Avatar image must be under 5MB',
|
||||||
|
'当前浏览器无法处理头像图片': 'This browser cannot process the avatar image',
|
||||||
|
'头像上传失败': 'Avatar upload failed',
|
||||||
|
'头像图片读取失败,请重新上传': 'Avatar image could not be read. Please upload it again.',
|
||||||
'头像(Dicebear 随机) · 风格:': 'Avatar (Dicebear Random) · Style:',
|
'头像(Dicebear 随机) · 风格:': 'Avatar (Dicebear Random) · Style:',
|
||||||
|
'头像 · 风格:': 'Avatar · Style: ',
|
||||||
'换一个随机头像': 'Randomize Avatar',
|
'换一个随机头像': 'Randomize Avatar',
|
||||||
|
'清除图片头像': 'Clear Image Avatar',
|
||||||
'取消': 'Cancel',
|
'取消': 'Cancel',
|
||||||
'保存': 'Save',
|
'保存': 'Save',
|
||||||
'关闭': 'Close',
|
'关闭': 'Close',
|
||||||
@@ -84,7 +109,63 @@ const zhToEn: Record<string, string> = {
|
|||||||
'模型操练场': 'Model Playground',
|
'模型操练场': 'Model Playground',
|
||||||
'模型测试': 'Model Testing',
|
'模型测试': 'Model Testing',
|
||||||
'关于': 'About',
|
'关于': 'About',
|
||||||
'关于 BossAI': 'About BossAI',
|
'关于 BoosAPI': 'About BoosAPI',
|
||||||
|
'BoosAPI 网关内核驱动': 'Powered by the BoosAPI gateway core',
|
||||||
|
'一个平台,接住模型、工作流和多模态生产。': 'One platform for models, workflows, and multimodal production.',
|
||||||
|
'BossAI 把多站点账号、模型路由、兼容 API、ComfyUI 工作流、创作助手和普通用户工作空间收进同一个自托管控制台。': 'BossAI brings multi-site accounts, model routing, compatible APIs, ComfyUI workflows, creative assistants, and user workspaces into one self-hosted console.',
|
||||||
|
'核心兼容能力': 'Core compatibility',
|
||||||
|
'兼容协议': 'Compatible protocol',
|
||||||
|
'消息入口': 'Messages endpoint',
|
||||||
|
'工作流前端': 'Workflow frontend',
|
||||||
|
'平台运行结构': 'Platform runtime structure',
|
||||||
|
'当前版本': 'Current version',
|
||||||
|
'平台能力概览': 'Platform capability overview',
|
||||||
|
'工作流后端': 'Workflow backends',
|
||||||
|
'自托管服务': 'Self-hosted service',
|
||||||
|
'统一模型入口': 'Unified model entry',
|
||||||
|
'OpenAI、Claude、Responses、图像和视频接口集中到一套鉴权、计费和日志体系。': 'OpenAI, Claude, Responses, image, and video interfaces share one authentication, billing, and logging system.',
|
||||||
|
'模型路由中枢': 'Model routing hub',
|
||||||
|
'按站点、账号、成本、健康度和冷却状态选择通道,失败后自动避让。': 'Select channels by site, account, cost, health, and cooldown state, with automatic failover.',
|
||||||
|
'工作流执行面': 'Workflow execution surface',
|
||||||
|
'嵌入官方 ComfyUI 前端,同时接入 Cloud、RunningHub、外部 ComfyUI 和 BoosAPI 执行器。': 'Embed the official ComfyUI frontend while connecting Cloud, RunningHub, external ComfyUI, and the BoosAPI executor.',
|
||||||
|
'多模态应用层': 'Multimodal app layer',
|
||||||
|
'生图助手、视频助手、数字人和短视频工厂复用同一套模型路由与用户 Key。': 'Picture Agent, Video Assistant, Digital Human, and Short Video Factory reuse the same model routing and user keys.',
|
||||||
|
'运行能力': 'Runtime capabilities',
|
||||||
|
'围绕接入、路由、创作和运维,把 AI 应用需要的底座放在一个控制台里。': 'Access, routing, creation, and operations are organized into one console for AI apps.',
|
||||||
|
'站点与账号': 'Sites and accounts',
|
||||||
|
'维护上游站点、账号、OAuth、API Key、余额和连接状态。': 'Manage upstream sites, accounts, OAuth, API keys, balances, and connection state.',
|
||||||
|
'聚合模型可用性、价格、账号覆盖、绑定令牌和路由健康度。': 'Aggregate model availability, prices, account coverage, bound tokens, and route health.',
|
||||||
|
'用户体系': 'User system',
|
||||||
|
'普通用户拥有独立 API Key、使用日志、工作空间和可见模型范围。': 'Regular users have independent API keys, usage logs, workspaces, and visible model scopes.',
|
||||||
|
'审计告警': 'Audit and alerts',
|
||||||
|
'使用日志、程序日志、通知渠道和运行事件集中留痕。': 'Usage logs, program logs, notification channels, and runtime events are recorded centrally.',
|
||||||
|
'只归档最终产出,按用户隔离图片、视频、音频和工作流资产。': 'Archive only final outputs, with images, videos, audio, and workflow assets isolated by user.',
|
||||||
|
'任务编排': 'Task orchestration',
|
||||||
|
'后台生成、取消、查询、归档和失败原因追踪保持一致体验。': 'Background generation, cancellation, queries, archiving, and failure tracking stay consistent.',
|
||||||
|
'请求处理路径': 'Request processing path',
|
||||||
|
'接入请求': 'Receive request',
|
||||||
|
'下游 Key、用户身份和协议类型先进入统一网关。': 'Downstream keys, user identity, and protocol type enter the unified gateway first.',
|
||||||
|
'选择通道': 'Select channel',
|
||||||
|
'结合路由优先级、账号状态、成本、延迟和冷却策略选择上游。': 'Choose an upstream using route priority, account state, cost, latency, and cooldown policy.',
|
||||||
|
'执行生产': 'Execute production',
|
||||||
|
'对话、生图、视频、工作流和数字人任务共享同一套执行链路。': 'Chat, image, video, workflow, and digital human tasks share one execution path.',
|
||||||
|
'记录归档': 'Record and archive',
|
||||||
|
'调用日志、费用、延迟和最终产出进入用户隔离的工作空间。': 'Call logs, cost, latency, and final outputs enter user-isolated workspaces.',
|
||||||
|
'OpenAI 对话兼容入口,支持流式与非流式。': 'OpenAI-compatible chat endpoint with streaming and non-streaming support.',
|
||||||
|
'Claude Messages API 兼容入口,支持 Anthropic 请求和事件。': 'Claude Messages API-compatible endpoint for Anthropic requests and events.',
|
||||||
|
'Responses API 入口,适配支持该协议的上游。': 'Responses API endpoint for upstreams that support the protocol.',
|
||||||
|
'图像生成入口,供生图助手与工作流节点调用。': 'Image generation endpoint for Picture Agent and workflow nodes.',
|
||||||
|
'视频任务创建入口,可接火山方舟或 Comfy 视频模板。': 'Video task creation endpoint for Volcengine Ark or Comfy video templates.',
|
||||||
|
'只暴露当前可路由、未冷却且账号有效的模型。': 'Expose only currently routable models with valid accounts and no cooldown.',
|
||||||
|
'Comfy Cloud API 后端,适合官方订阅和云端执行。': 'Comfy Cloud API backend for official subscriptions and cloud execution.',
|
||||||
|
'RunningHub API 后端,适合复用插件、模型和模板生态。': 'RunningHub API backend for plugin, model, and template ecosystems.',
|
||||||
|
'连接自部署完整版 ComfyUI,由 BossAI 统一代理和嵌入。': 'Connect a self-hosted full ComfyUI instance through BossAI proxy and embedding.',
|
||||||
|
'Rust API-native 执行器,直接调用后台模型路由。': 'Rust API-native executor that directly calls backend model routing.',
|
||||||
|
'本地完整版 ComfyUI 模式,适合有 GPU 和完整模型目录的环境。': 'Local full ComfyUI mode for environments with a GPU and full model directory.',
|
||||||
|
'技术底座': 'Technical foundation',
|
||||||
|
'后端、前端和工作流资产都由同一个 Rust 服务统一托管。': 'Backend, frontend, and workflow assets are served by the same Rust service.',
|
||||||
|
'数据与隐私': 'Data and privacy',
|
||||||
|
'BossAI 完全自托管。账号、令牌、路由、日志、用户资产和工作台状态都保存在你的运行环境中,系统 Key 默认脱敏并受保护。': 'BossAI is fully self-hosted. Accounts, tokens, routes, logs, user assets, and workspace state stay in your runtime, with system keys masked and protected by default.',
|
||||||
'站点文档': 'Site Docs',
|
'站点文档': 'Site Docs',
|
||||||
'任务状态已更新': 'Task status updated',
|
'任务状态已更新': 'Task status updated',
|
||||||
'会话已过期,请重新登录': 'Session expired, please sign in again',
|
'会话已过期,请重新登录': 'Session expired, please sign in again',
|
||||||
@@ -102,6 +183,7 @@ const zhToEn: Record<string, string> = {
|
|||||||
'深色模式': 'Dark Mode',
|
'深色模式': 'Dark Mode',
|
||||||
'退出登录': 'Sign Out',
|
'退出登录': 'Sign Out',
|
||||||
'收起侧边栏': 'Collapse Sidebar',
|
'收起侧边栏': 'Collapse Sidebar',
|
||||||
|
'展开侧边栏': 'Expand Sidebar',
|
||||||
'系统设置': 'System Settings',
|
'系统设置': 'System Settings',
|
||||||
'站点管理': 'Site Management',
|
'站点管理': 'Site Management',
|
||||||
'账号管理': 'Connection Management',
|
'账号管理': 'Connection Management',
|
||||||
@@ -221,7 +303,7 @@ const zhToEn: Record<string, string> = {
|
|||||||
'代理端点': 'Proxy Endpoints',
|
'代理端点': 'Proxy Endpoints',
|
||||||
'路由行为': 'Routing Behavior',
|
'路由行为': 'Routing Behavior',
|
||||||
'指标口径': 'Metric Notes',
|
'指标口径': 'Metric Notes',
|
||||||
'BossAI 将多个上游兼容供应商聚合为统一的 OpenAI / Claude 下游兼容入口。': 'BossAI aggregates multiple upstream compatible providers into a unified OpenAI / Claude compatible downstream endpoint.',
|
'BoosAPI 将多个上游兼容供应商聚合为统一的 OpenAI / Claude 下游兼容入口。': 'BoosAPI aggregates multiple upstream compatible providers into a unified OpenAI / Claude compatible downstream endpoint.',
|
||||||
'核心目标:自动签到、自动模型发现、自动路由重建、统一代理可观测性。': 'Core goals: auto check-in, auto model discovery, auto route rebuild, and unified proxy observability.',
|
'核心目标:自动签到、自动模型发现、自动路由重建、统一代理可观测性。': 'Core goals: auto check-in, auto model discovery, auto route rebuild, and unified proxy observability.',
|
||||||
'1. 路由根据模型可用性自动生成。': '1. Routes are auto-generated based on model availability.',
|
'1. 路由根据模型可用性自动生成。': '1. Routes are auto-generated based on model availability.',
|
||||||
'2. 当模型或账号发生变更时,路由通道会自动重建。': '2. Route channels are auto-rebuilt when models or accounts change.',
|
'2. 当模型或账号发生变更时,路由通道会自动重建。': '2. Route channels are auto-rebuilt when models or accounts change.',
|
||||||
@@ -296,7 +378,7 @@ const zhToEn: Record<string, string> = {
|
|||||||
'清空中...': 'Clearing...',
|
'清空中...': 'Clearing...',
|
||||||
|
|
||||||
// About page
|
// About page
|
||||||
'聚合多模型多模态的 AI 应用平台 — 统一接入文本、图像、视频、语音等能力,构建可复用的 AI 工作流和应用。': 'A multimodal AI application platform for many models — unify text, image, video, and voice capabilities to build reusable AI workflows and applications.',
|
'中转站的中转站 — 将你在各处注册的 New API / One API / OneHub 等 AI 中转站聚合为一个统一网关。一个 API Key、一个入口,自动发现模型、智能路由、成本最优。': 'The hub of hubs — aggregate all your New API / One API / OneHub relay sites into one unified gateway. One API Key, one endpoint, with auto model discovery, smart routing, and cost optimization.',
|
||||||
'核心特色': 'Key Features',
|
'核心特色': 'Key Features',
|
||||||
'统一代理网关': 'Unified Proxy Gateway',
|
'统一代理网关': 'Unified Proxy Gateway',
|
||||||
'一个 Key、一个入口,兼容 OpenAI / Claude 下游格式': 'One Key, one endpoint, compatible with OpenAI / Claude downstream formats',
|
'一个 Key、一个入口,兼容 OpenAI / Claude 下游格式': 'One Key, one endpoint, compatible with OpenAI / Claude downstream formats',
|
||||||
@@ -321,8 +403,7 @@ const zhToEn: Record<string, string> = {
|
|||||||
'轻量 TypeScript ORM': 'Lightweight TypeScript ORM',
|
'轻量 TypeScript ORM': 'Lightweight TypeScript ORM',
|
||||||
'零配置嵌入式数据库': 'Zero-config embedded database',
|
'零配置嵌入式数据库': 'Zero-config embedded database',
|
||||||
'项目链接': 'Project Links',
|
'项目链接': 'Project Links',
|
||||||
'数据与隐私': 'Data & Privacy',
|
'BoosAPI 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。': 'BoosAPI is fully self-hosted. All data (accounts, tokens, routes, logs) is stored in a local SQLite database and never sent to any third party. Proxy requests travel directly between your server and upstream sites.',
|
||||||
'BossAI 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。': 'BossAI is fully self-hosted. All data (accounts, tokens, routes, logs) is stored in a local SQLite database and never sent to any third party. Proxy requests travel directly between your server and upstream sites.',
|
|
||||||
|
|
||||||
// User SaaS translations
|
// User SaaS translations
|
||||||
'用户登录': 'User Login',
|
'用户登录': 'User Login',
|
||||||
|
|||||||
+4051
-140
File diff suppressed because it is too large
Load Diff
+2
-5
@@ -3,12 +3,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="BossAI - 聚合多模型多模态的 AI 应用平台" />
|
<meta name="description" content="BossAI 聚合多模型多模态的 AI 应用平台" />
|
||||||
<title>BossAI</title>
|
<title>BoosAPI</title>
|
||||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen">
|
<body class="min-h-screen">
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
+263
-106
@@ -1,170 +1,327 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { gsap } from 'gsap';
|
||||||
import { tr } from '../i18n.js';
|
import { tr } from '../i18n.js';
|
||||||
|
|
||||||
const VERSION = '1.3.0';
|
const VERSION = '1.3.0';
|
||||||
|
|
||||||
const FEATURES = [
|
const HERO_TAGS = [
|
||||||
{ icon: 'GW', title: '统一模型网关', desc: '一个入口聚合 OpenAI / Claude / Gemini / 方舟等兼容上游,统一鉴权、计费与日志。' },
|
'Unified API Gateway',
|
||||||
{ icon: 'RT', title: '模型路由与容灾', desc: '按模型、账号、站点和通道优先级调度,失败后自动冷却并切换到可用通道。' },
|
'Intelligent Routing',
|
||||||
{ icon: 'AC', title: '站点与账号管理', desc: '集中维护站点、账号、API Key、OAuth、下游 Key 和普通用户可用模型。' },
|
'Cloud Workflow',
|
||||||
{ icon: 'MD', title: '模型广场', desc: '聚合各站点模型可用性、账号覆盖、绑定令牌与路由状态,便于快速修路由。' },
|
'Workspace Archive',
|
||||||
{ icon: 'WF', title: '工作流画布', desc: '内嵌 ComfyUI 前端,支持 Comfy Cloud、RunningHub、外部 ComfyUI 与 BossAI API-native 执行器。' },
|
'Multimodal Agents',
|
||||||
{ icon: 'AG', title: '视频助手', desc: '面向工作流创作的对话助手,支持附件、多会话、用户 API Key 和模型路由选择。' },
|
|
||||||
{ icon: 'PI', title: '生图助手', desc: '提供提示词模板、比例尺寸控制和图像模型调用,管理员与普通用户均可使用。' },
|
|
||||||
{ icon: 'VD', title: '视频任务模板', desc: '支持火山方舟 Step1 创建视频任务、Step2 查询任务结果,并可复用 BossAI 模型路由。' },
|
|
||||||
{ icon: 'CK', title: '签到与余额刷新', desc: '按站点/账号执行签到、余额刷新、健康检查和连接状态追踪。' },
|
|
||||||
{ icon: 'NT', title: '通知与审计', desc: 'Webhook / Bark / Server酱 / Telegram / SMTP 告警,配合代理日志和程序事件排障。' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const TECH_STACK = [
|
const HERO_SIGNALS = [
|
||||||
{ name: 'Rust', desc: '后端核心服务,提供代理、路由、认证、任务和静态资源服务。' },
|
{ value: 'OpenAI', label: '兼容协议' },
|
||||||
{ name: 'Axum / Tokio', desc: '异步 HTTP 服务、SSE、后台任务和代理执行。' },
|
{ value: 'Claude', label: '消息入口' },
|
||||||
{ name: 'SQLx', desc: '类型化数据库访问与迁移管理。' },
|
{ value: 'Comfy', label: '工作流前端' },
|
||||||
{ name: 'SQLite', desc: '默认嵌入式数据库,适合单机部署和低运维成本场景。' },
|
];
|
||||||
{ name: 'React / Vite', desc: '管理后台、普通用户面板、模型测试器和 Agent 界面。' },
|
|
||||||
{ name: 'Workflow UI', desc: '内嵌官方 ComfyUI 前端资产,按后端模式代理 Cloud、RunningHub 或外部 ComfyUI。' },
|
const CAPABILITIES = [
|
||||||
{ name: 'Multi Backend', desc: '支持 cloud / runninghub / external / BossAI / docker 五种工作流后端配置。' },
|
{
|
||||||
|
label: 'Gateway',
|
||||||
|
title: '统一模型入口',
|
||||||
|
desc: 'OpenAI、Claude、Responses、图像和视频接口集中到一套鉴权、计费和日志体系。',
|
||||||
|
stat: '/v1/*',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Routing',
|
||||||
|
title: '模型路由中枢',
|
||||||
|
desc: '按站点、账号、成本、健康度和冷却状态选择通道,失败后自动避让。',
|
||||||
|
stat: 'P0 / P1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Workflow',
|
||||||
|
title: '工作流执行面',
|
||||||
|
desc: '嵌入官方 ComfyUI 前端,同时接入 Cloud、RunningHub、外部 ComfyUI 和 BoosAPI 执行器。',
|
||||||
|
stat: '5 modes',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Agents',
|
||||||
|
title: '多模态应用层',
|
||||||
|
desc: '生图助手、视频助手、数字人和短视频工厂复用同一套模型路由与用户 Key。',
|
||||||
|
stat: '4 apps',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const OPERATIONS = [
|
||||||
|
{ title: '站点与账号', desc: '维护上游站点、账号、OAuth、API Key、余额和连接状态。' },
|
||||||
|
{ title: '模型广场', desc: '聚合模型可用性、价格、账号覆盖、绑定令牌和路由健康度。' },
|
||||||
|
{ title: '用户体系', desc: '普通用户拥有独立 API Key、使用日志、工作空间和可见模型范围。' },
|
||||||
|
{ title: '审计告警', desc: '使用日志、程序日志、通知渠道和运行事件集中留痕。' },
|
||||||
|
{ title: '工作空间', desc: '只归档最终产出,按用户隔离图片、视频、音频和工作流资产。' },
|
||||||
|
{ title: '任务编排', desc: '后台生成、取消、查询、归档和失败原因追踪保持一致体验。' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ENDPOINTS = [
|
const ENDPOINTS = [
|
||||||
{ path: '/v1/chat/completions', desc: 'OpenAI 兼容对话代理,支持非流式与流式。' },
|
{ path: '/v1/chat/completions', desc: 'OpenAI 对话兼容入口,支持流式与非流式。' },
|
||||||
{ path: '/v1/messages', desc: 'Claude Messages API 兼容入口,支持 Anthropic 请求、响应和流式事件。' },
|
{ path: '/v1/messages', desc: 'Claude Messages API 兼容入口,支持 Anthropic 请求和事件。' },
|
||||||
{ path: '/v1/responses', desc: 'Responses API 兼容代理,可接入支持该协议的上游。' },
|
{ path: '/v1/responses', desc: 'Responses API 入口,适配支持该协议的上游。' },
|
||||||
{ path: '/v1/images/generations', desc: '图像生成代理,供生图助手和工作流图像节点使用。' },
|
{ path: '/v1/images/generations', desc: '图像生成入口,供生图助手与工作流节点调用。' },
|
||||||
{ path: '/v1/videos', desc: '视频生成任务入口,已适配火山方舟任务创建。' },
|
{ path: '/v1/videos', desc: '视频任务创建入口,可接火山方舟或 Comfy 视频模板。' },
|
||||||
{ path: '/v1/videos/{id}', desc: '视频任务查询入口,已适配火山方舟任务轮询。' },
|
{ path: '/v1/models', desc: '只暴露当前可路由、未冷却且账号有效的模型。' },
|
||||||
{ path: '/v1/models', desc: '仅暴露当前可路由、未冷却、账号有效的模型列表。' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const WORKFLOW_BACKENDS = [
|
const WORKFLOW_BACKENDS = [
|
||||||
{ name: 'cloud', desc: 'Comfy Cloud API 后端,适合使用官方订阅能力和云端执行。' },
|
{ name: 'cloud', desc: 'Comfy Cloud API 后端,适合官方订阅和云端执行。' },
|
||||||
{ name: 'runninghub', desc: 'RunningHub API 后端,适合复用 RunningHub 插件、模型与工作流生态。' },
|
{ name: 'runninghub', desc: 'RunningHub API 后端,适合复用插件、模型和模板生态。' },
|
||||||
{ name: 'external', desc: '连接你自己部署的完整版 ComfyUI,BossAI 负责统一代理与嵌入。' },
|
{ name: 'external', desc: '连接自部署完整版 ComfyUI,由 BossAI 统一代理和嵌入。' },
|
||||||
{ name: 'BossAI', desc: '使用 Rust API-native 工作流执行器,直接调用后台模型路由。' },
|
{ name: 'boosapi', desc: 'Rust API-native 执行器,直接调用后台模型路由。' },
|
||||||
{ name: 'docker', desc: '本地完整版 ComfyUI 模式,适合有本地 GPU 和完整模型目录的环境。' },
|
{ name: 'docker', desc: '本地完整版 ComfyUI 模式,适合有 GPU 和完整模型目录的环境。' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TECH_STACK = [
|
||||||
|
'Rust',
|
||||||
|
'Axum',
|
||||||
|
'Tokio',
|
||||||
|
'SQLx',
|
||||||
|
'React',
|
||||||
|
'Vite',
|
||||||
|
'GSAP',
|
||||||
|
'ComfyUI Frontend',
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function About() {
|
export default function About() {
|
||||||
return (
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
<div className="about-page animate-fade-in">
|
|
||||||
<div className="page-header about-header">
|
|
||||||
<div>
|
|
||||||
<h2 className="page-title">{tr('关于 BossAI')}</h2>
|
|
||||||
<p className="page-subtitle">{tr('统一网关、模型路由、工作流与多模态助手的运行概览。')}</p>
|
|
||||||
</div>
|
|
||||||
<span className="about-version">v{VERSION}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="about-hero animate-slide-up stagger-1">
|
useEffect(() => {
|
||||||
|
const root = rootRef.current;
|
||||||
|
if (!root) return undefined;
|
||||||
|
|
||||||
|
const mm = gsap.matchMedia();
|
||||||
|
mm.add(
|
||||||
|
{
|
||||||
|
reduceMotion: '(prefers-reduced-motion: reduce)',
|
||||||
|
isDesktop: '(min-width: 900px)',
|
||||||
|
},
|
||||||
|
(context) => {
|
||||||
|
const conditions = context.conditions || {};
|
||||||
|
const motionItems = Array.from(root.querySelectorAll<HTMLElement>('.about-motion-item'));
|
||||||
|
const orbitItems = Array.from(root.querySelectorAll<HTMLElement>('.about-orbit-card'));
|
||||||
|
const orbitCore = root.querySelector<HTMLElement>('.about-orbit-core');
|
||||||
|
|
||||||
|
if (conditions.reduceMotion) {
|
||||||
|
gsap.set([...motionItems, ...orbitItems, orbitCore].filter(Boolean), {
|
||||||
|
autoAlpha: 1,
|
||||||
|
clearProps: 'transform,opacity,visibility',
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enterTween = gsap.fromTo(
|
||||||
|
motionItems,
|
||||||
|
{ autoAlpha: 0, y: conditions.isDesktop ? 18 : 10, scale: 0.99 },
|
||||||
|
{
|
||||||
|
autoAlpha: 1,
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
duration: 0.54,
|
||||||
|
ease: 'power2.out',
|
||||||
|
stagger: 0.045,
|
||||||
|
clearProps: 'transform,opacity,visibility',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const orbitTween = orbitItems.length
|
||||||
|
? gsap.fromTo(
|
||||||
|
orbitItems,
|
||||||
|
{ autoAlpha: 0, y: 12 },
|
||||||
|
{
|
||||||
|
autoAlpha: 1,
|
||||||
|
y: 0,
|
||||||
|
duration: 0.48,
|
||||||
|
ease: 'power2.out',
|
||||||
|
stagger: { each: 0.06, from: 'center' },
|
||||||
|
delay: 0.12,
|
||||||
|
clearProps: 'transform,opacity,visibility',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const floatTween = orbitCore
|
||||||
|
? gsap.to(orbitCore, {
|
||||||
|
y: -4,
|
||||||
|
duration: 2.4,
|
||||||
|
ease: 'sine.inOut',
|
||||||
|
repeat: -1,
|
||||||
|
yoyo: true,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
enterTween.kill();
|
||||||
|
orbitTween?.kill();
|
||||||
|
floatTween?.kill();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => mm.revert();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} className="about-page">
|
||||||
|
<section className="about-hero about-motion-item">
|
||||||
|
<div className="about-hero-copy-block">
|
||||||
<div className="about-brand">
|
<div className="about-brand">
|
||||||
<img
|
<img src="/logo.png" alt="BossAI" className="about-logo" />
|
||||||
src="/logo.png"
|
|
||||||
alt="BossAI"
|
|
||||||
className="about-logo"
|
|
||||||
/>
|
|
||||||
<div>
|
<div>
|
||||||
<div className="about-brand-name">BossAI</div>
|
<div className="about-brand-name">BossAI</div>
|
||||||
<div className="about-brand-meta">{tr('自托管 AI 模型网关与工作台')}</div>
|
<div className="about-brand-meta">{tr('BoosAPI 网关内核驱动')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="about-hero-copy">
|
|
||||||
{tr('BossAI 是一个自托管 AI 模型网关与工作台。它把多站点账号、下游 Key、模型路由、图像生成、视频任务、ComfyUI 风格工作流和普通用户面板收束到同一个 Rust 服务中,让前端、Agent、Workflow Studio 和外部 API 都能使用后台已经配置好的模型。')}
|
<h1>{tr('一个平台,接住模型、工作流和多模态生产。')}</h1>
|
||||||
|
<p>
|
||||||
|
{tr('BossAI 把多站点账号、模型路由、兼容 API、ComfyUI 工作流、创作助手和普通用户工作空间收进同一个自托管控制台。')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="about-proof-row" aria-label={tr('核心兼容能力')}>
|
||||||
|
{HERO_SIGNALS.map((item) => (
|
||||||
|
<div key={item.value}>
|
||||||
|
<strong>{item.value}</strong>
|
||||||
|
<span>{tr(item.label)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="about-chip-row">
|
<div className="about-chip-row">
|
||||||
{['Rust Backend', 'OpenAI Compatible', 'Claude Messages', 'ComfyUI Frontend', 'Comfy Cloud', 'RunningHub'].map((tag) => (
|
{HERO_TAGS.map((tag) => (
|
||||||
<span key={tag} className="about-chip">
|
<span key={tag} className="about-chip">{tag}</span>
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="about-section animate-slide-up stagger-2">
|
|
||||||
<div className="about-section-heading">
|
|
||||||
<h3>{tr('核心特色')}</h3>
|
|
||||||
<p>{tr('围绕网关、模型、工作流和运维审计组织能力。')}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="about-feature-grid">
|
|
||||||
{FEATURES.map((f) => (
|
<div className="about-platform-card" aria-label={tr('平台运行结构')}>
|
||||||
<div key={f.title} className="about-feature-row">
|
<div className="about-version">{tr('当前版本')} v{VERSION}</div>
|
||||||
<span className="about-feature-mark">
|
<div className="about-orbit">
|
||||||
{f.icon}
|
<div className="about-orbit-core">
|
||||||
</span>
|
<img src="/logo.png" alt="" />
|
||||||
|
<span>BossAI</span>
|
||||||
|
</div>
|
||||||
|
<div className="about-orbit-card orbit-card-1">API Gateway</div>
|
||||||
|
<div className="about-orbit-card orbit-card-2">Route Engine</div>
|
||||||
|
<div className="about-orbit-card orbit-card-3">Workflow UI</div>
|
||||||
|
<div className="about-orbit-card orbit-card-4">Agent Apps</div>
|
||||||
|
</div>
|
||||||
|
<div className="about-signal-grid" aria-label={tr('平台能力概览')}>
|
||||||
<div>
|
<div>
|
||||||
<div className="about-feature-title">{tr(f.title)}</div>
|
<strong>7</strong>
|
||||||
<div className="about-feature-desc">
|
<span>{tr('兼容入口')}</span>
|
||||||
{tr(f.desc)}
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>5</strong>
|
||||||
|
<span>{tr('工作流后端')}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>1</strong>
|
||||||
|
<span>{tr('自托管服务')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="about-capability-grid about-motion-item">
|
||||||
|
{CAPABILITIES.map((item) => (
|
||||||
|
<article key={item.title} className="about-capability-card">
|
||||||
|
<div className="about-capability-topline">
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<code>{item.stat}</code>
|
||||||
|
</div>
|
||||||
|
<h3>{tr(item.title)}</h3>
|
||||||
|
<p>{tr(item.desc)}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="about-section about-motion-item">
|
||||||
|
<div className="about-section-heading">
|
||||||
|
<h2>{tr('运行能力')}</h2>
|
||||||
|
<p>{tr('围绕接入、路由、创作和运维,把 AI 应用需要的底座放在一个控制台里。')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="about-ops-grid">
|
||||||
|
{OPERATIONS.map((item) => (
|
||||||
|
<div key={item.title} className="about-ops-item">
|
||||||
|
<span>{tr(item.title)}</span>
|
||||||
|
<p>{tr(item.desc)}</p>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="about-two-column animate-slide-up stagger-3">
|
<section className="about-flow about-motion-item" aria-label={tr('请求处理路径')}>
|
||||||
|
<div className="about-flow-step">
|
||||||
|
<span>01</span>
|
||||||
|
<strong>{tr('接入请求')}</strong>
|
||||||
|
<p>{tr('下游 Key、用户身份和协议类型先进入统一网关。')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="about-flow-step">
|
||||||
|
<span>02</span>
|
||||||
|
<strong>{tr('选择通道')}</strong>
|
||||||
|
<p>{tr('结合路由优先级、账号状态、成本、延迟和冷却策略选择上游。')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="about-flow-step">
|
||||||
|
<span>03</span>
|
||||||
|
<strong>{tr('执行生产')}</strong>
|
||||||
|
<p>{tr('对话、生图、视频、工作流和数字人任务共享同一套执行链路。')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="about-flow-step">
|
||||||
|
<span>04</span>
|
||||||
|
<strong>{tr('记录归档')}</strong>
|
||||||
|
<p>{tr('调用日志、费用、延迟和最终产出进入用户隔离的工作空间。')}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="about-two-column about-motion-item">
|
||||||
<section className="about-section">
|
<section className="about-section">
|
||||||
<div className="about-section-heading">
|
<div className="about-section-heading">
|
||||||
<h3>{tr('工作流后端模式')}</h3>
|
<h2>{tr('工作流后端')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="about-definition-list">
|
<div className="about-definition-list">
|
||||||
{WORKFLOW_BACKENDS.map((backend) => (
|
{WORKFLOW_BACKENDS.map((backend) => (
|
||||||
<div
|
<div key={backend.name} className="about-definition-row">
|
||||||
key={backend.name}
|
<code>{backend.name}</code>
|
||||||
className="about-definition-row"
|
<div>{tr(backend.desc)}</div>
|
||||||
>
|
|
||||||
<code>
|
|
||||||
{backend.name}
|
|
||||||
</code>
|
|
||||||
<div>
|
|
||||||
{tr(backend.desc)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="about-section">
|
<section className="about-section about-api-section">
|
||||||
<div className="about-section-heading">
|
<div className="about-section-heading">
|
||||||
<h3>{tr('兼容入口')}</h3>
|
<h2>{tr('兼容入口')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="about-definition-list">
|
<div className="about-definition-list">
|
||||||
{ENDPOINTS.map((endpoint) => (
|
{ENDPOINTS.map((endpoint) => (
|
||||||
<div
|
<div key={endpoint.path} className="about-definition-row about-endpoint-row">
|
||||||
key={endpoint.path}
|
<code>{endpoint.path}</code>
|
||||||
className="about-definition-row about-endpoint-row"
|
<div>{tr(endpoint.desc)}</div>
|
||||||
>
|
|
||||||
<code>
|
|
||||||
{endpoint.path}
|
|
||||||
</code>
|
|
||||||
<div>
|
|
||||||
{tr(endpoint.desc)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="about-section animate-slide-up stagger-4">
|
<section className="about-bottom-grid about-motion-item">
|
||||||
|
<div className="about-section about-tech-panel">
|
||||||
<div className="about-section-heading">
|
<div className="about-section-heading">
|
||||||
<h3>{tr('技术栈')}</h3>
|
<h2>{tr('技术底座')}</h2>
|
||||||
</div>
|
<p>{tr('后端、前端和工作流资产都由同一个 Rust 服务统一托管。')}</p>
|
||||||
<div className="about-tech-grid">
|
|
||||||
{TECH_STACK.map((t) => (
|
|
||||||
<div key={t.name} className="about-tech-item">
|
|
||||||
<div className="about-tech-name">{t.name}</div>
|
|
||||||
<div className="about-tech-desc">
|
|
||||||
{tr(t.desc)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="about-tech-strip">
|
||||||
|
{TECH_STACK.map((item) => (
|
||||||
|
<span key={item}>{item}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
<section className="about-privacy animate-slide-up stagger-5">
|
<div className="about-section about-privacy">
|
||||||
<h3>{tr('数据与隐私')}</h3>
|
<h2>{tr('数据与隐私')}</h2>
|
||||||
<p>
|
<p>
|
||||||
{tr('BossAI 完全自托管,账号、令牌、路由、日志、普通用户和工作台状态均保存在你的运行环境中。代理请求只在你的服务器、可选外部 ComfyUI 与已配置上游之间流转;内置系统 Key 会被保护,后台列表中只展示脱敏值。')}
|
{tr('BossAI 完全自托管。账号、令牌、路由、日志、用户资产和工作台状态都保存在你的运行环境中,系统 Key 默认脱敏并受保护。')}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
|
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
|
||||||
|
|
||||||
import About from './About.js';
|
|
||||||
|
|
||||||
const { apiMock } = vi.hoisted(() => ({
|
|
||||||
apiMock: {
|
|
||||||
getUpdateCenterStatus: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../api.js', () => ({
|
|
||||||
api: apiMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
function collectText(node: ReactTestInstance): string {
|
|
||||||
return (node.children || []).map((child) => {
|
|
||||||
if (typeof child === 'string') return child;
|
|
||||||
return collectText(child);
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function flushMicrotasks() {
|
|
||||||
await act(async () => {
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('About page', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows product information without update prompts or project links', async () => {
|
|
||||||
let root!: ReactTestRenderer;
|
|
||||||
try {
|
|
||||||
await act(async () => {
|
|
||||||
root = create(
|
|
||||||
<MemoryRouter>
|
|
||||||
<About />
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
const text = collectText(root.root);
|
|
||||||
expect(text).toContain('关于 BossAI');
|
|
||||||
expect(text).toContain('核心特色');
|
|
||||||
expect(text).toContain('兼容入口');
|
|
||||||
expect(text).not.toContain('更新提醒');
|
|
||||||
expect(text).not.toContain('前往更新中心');
|
|
||||||
expect(text).not.toContain('项目链接');
|
|
||||||
expect(apiMock.getUpdateCenterStatus).not.toHaveBeenCalled();
|
|
||||||
} finally {
|
|
||||||
root?.unmount();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1339,7 +1339,7 @@ export default function Accounts() {
|
|||||||
: canAddVerifiedConnection;
|
: canAddVerifiedConnection;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in admin-ops-page accounts-ops-page">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h2 className="page-title">{tr("连接管理")}</h2>
|
<h2 className="page-title">{tr("连接管理")}</h2>
|
||||||
{activeSegment !== "tokens" && (
|
{activeSegment !== "tokens" && (
|
||||||
@@ -1910,7 +1910,7 @@ export default function Accounts() {
|
|||||||
color: "var(--color-text-muted)",
|
color: "var(--color-text-muted)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
配置 refresh_token 后,BossAI 会在 JWT 临近过期或
|
配置 refresh_token 后,BoosAPI 会在 JWT 临近过期或
|
||||||
401 时自动续期并回写新 token。
|
401 时自动续期并回写新 token。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -112,6 +112,9 @@ const QUICK_PROMPTS = [
|
|||||||
'根据已有角色和场景,继续生成 ComfyUI 工作流 JSON',
|
'根据已有角色和场景,继续生成 ComfyUI 工作流 JSON',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const STREAMING_REASONING_PLACEHOLDER = '正在等待模型返回真实思考过程,收到后会实时替换这里。';
|
||||||
|
const STREAMING_BODY_PLACEHOLDER = '模型正在组织正文和素材步骤...';
|
||||||
|
|
||||||
const SEEDANCE_PROMPT_TEMPLATES = [
|
const SEEDANCE_PROMPT_TEMPLATES = [
|
||||||
{
|
{
|
||||||
title: '文生视频',
|
title: '文生视频',
|
||||||
@@ -651,7 +654,7 @@ function renderMarkdown(content: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function processThinkTags(content: string, reasoningContent = '') {
|
function processThinkTags(content: string, reasoningContent = '') {
|
||||||
if (!content.includes('<think>')) {
|
if (!content || !/<think>/i.test(content)) {
|
||||||
return { content, reasoningContent };
|
return { content, reasoningContent };
|
||||||
}
|
}
|
||||||
const thoughts: string[] = [];
|
const thoughts: string[] = [];
|
||||||
@@ -665,7 +668,18 @@ function processThinkTags(content: string, reasoningContent = '') {
|
|||||||
lastIndex = match.index + match[0].length;
|
lastIndex = match.index + match[0].length;
|
||||||
}
|
}
|
||||||
replyParts.push(content.substring(lastIndex));
|
replyParts.push(content.substring(lastIndex));
|
||||||
const cleanContent = replyParts.join('').replace(/<\/?think>/gi, '').trim();
|
|
||||||
|
let cleanContent = replyParts.join('');
|
||||||
|
const lastOpenThinkIndex = cleanContent.toLowerCase().lastIndexOf('<think>');
|
||||||
|
if (lastOpenThinkIndex !== -1) {
|
||||||
|
const fragmentAfterLastOpen = cleanContent.substring(lastOpenThinkIndex + '<think>'.length);
|
||||||
|
if (!/<\/think>/i.test(fragmentAfterLastOpen)) {
|
||||||
|
thoughts.push(fragmentAfterLastOpen.trim());
|
||||||
|
cleanContent = cleanContent.substring(0, lastOpenThinkIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanContent = cleanContent.replace(/<\/?think>/gi, '').trim();
|
||||||
const thoughtsText = thoughts.filter(Boolean).join('\n\n---\n\n');
|
const thoughtsText = thoughts.filter(Boolean).join('\n\n---\n\n');
|
||||||
return {
|
return {
|
||||||
content: cleanContent,
|
content: cleanContent,
|
||||||
@@ -821,7 +835,7 @@ export default function ComfyUIAgent() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
chatEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
chatEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||||
}, [messages, streaming?.content]);
|
}, [messages, streaming?.content, streaming?.reasoningContent, streaming?.images.length, streaming?.scenes.length, streaming?.tts.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeConversationIdRef.current = activeConversationId;
|
activeConversationIdRef.current = activeConversationId;
|
||||||
@@ -952,7 +966,7 @@ export default function ComfyUIAgent() {
|
|||||||
|
|
||||||
const exportWorkflow = () => {
|
const exportWorkflow = () => {
|
||||||
const workflow = latestWorkflow || {
|
const workflow = latestWorkflow || {
|
||||||
name: 'BossAI Video Agent Project',
|
name: 'BoosAPI Video Agent Project',
|
||||||
version: 1,
|
version: 1,
|
||||||
messages: messages
|
messages: messages
|
||||||
.filter((message) => message.role !== 'system')
|
.filter((message) => message.role !== 'system')
|
||||||
@@ -1351,9 +1365,16 @@ export default function ComfyUIAgent() {
|
|||||||
const hasReasoning = processed.reasoningContent.trim().length > 0;
|
const hasReasoning = processed.reasoningContent.trim().length > 0;
|
||||||
if (!hasReasoning && !isStreaming) return null;
|
if (!hasReasoning && !isStreaming) return null;
|
||||||
return (
|
return (
|
||||||
<details open={isStreaming} style={reasoningBoxStyle}>
|
<details
|
||||||
|
open={isStreaming}
|
||||||
|
style={{
|
||||||
|
...reasoningBoxStyle,
|
||||||
|
...(!hasReasoning && isStreaming ? reasoningPendingBoxStyle : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
<summary style={reasoningSummaryStyle}>
|
<summary style={reasoningSummaryStyle}>
|
||||||
{isStreaming ? '思考中...' : '思考过程'}
|
<span>思考过程</span>
|
||||||
|
{isStreaming ? <em style={reasoningLiveBadgeStyle}>实时</em> : null}
|
||||||
</summary>
|
</summary>
|
||||||
{hasReasoning ? (
|
{hasReasoning ? (
|
||||||
<div
|
<div
|
||||||
@@ -1361,8 +1382,9 @@ export default function ComfyUIAgent() {
|
|||||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(processed.reasoningContent) }}
|
dangerouslySetInnerHTML={{ __html: renderMarkdown(processed.reasoningContent) }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div style={reasoningContentStyle}>
|
<div style={reasoningPendingStyle}>
|
||||||
正在分析你的需求、拆解角色、场景、镜头与后续工作流步骤...
|
<span style={reasoningPulseDotStyle} />
|
||||||
|
<span>{STREAMING_REASONING_PLACEHOLDER}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</details>
|
</details>
|
||||||
@@ -1717,6 +1739,9 @@ export default function ComfyUIAgent() {
|
|||||||
{renderReasoning(streaming, true)}
|
{renderReasoning(streaming, true)}
|
||||||
{streaming.content ? renderMessageContent(streaming) : null}
|
{streaming.content ? renderMessageContent(streaming) : null}
|
||||||
{streaming.content ? <span style={cursorStyle}>|</span> : null}
|
{streaming.content ? <span style={cursorStyle}>|</span> : null}
|
||||||
|
{!streaming.content && !streaming.images.length && !streaming.scenes.length && !streaming.tts.length ? (
|
||||||
|
<div style={streamingBodyStatusStyle}>{STREAMING_BODY_PLACEHOLDER}</div>
|
||||||
|
) : null}
|
||||||
{renderAssets(streaming)}
|
{renderAssets(streaming)}
|
||||||
<div style={messageActionsStyle}>
|
<div style={messageActionsStyle}>
|
||||||
<button
|
<button
|
||||||
@@ -2127,6 +2152,9 @@ const reasoningSummaryStyle: CSSProperties = {
|
|||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: 'var(--color-primary)',
|
color: 'var(--color-primary)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
};
|
};
|
||||||
|
|
||||||
const reasoningContentStyle: CSSProperties = {
|
const reasoningContentStyle: CSSProperties = {
|
||||||
@@ -2136,6 +2164,57 @@ const reasoningContentStyle: CSSProperties = {
|
|||||||
color: 'var(--color-text-secondary)',
|
color: 'var(--color-text-secondary)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const reasoningPendingBoxStyle: CSSProperties = {
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
background:
|
||||||
|
'linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 7%, var(--color-bg)), color-mix(in srgb, var(--color-info) 5%, var(--color-bg)))',
|
||||||
|
};
|
||||||
|
|
||||||
|
const reasoningLiveBadgeStyle: CSSProperties = {
|
||||||
|
marginLeft: 8,
|
||||||
|
padding: '1px 6px',
|
||||||
|
border: '1px solid color-mix(in srgb, var(--color-primary) 22%, var(--color-border-light))',
|
||||||
|
borderRadius: 999,
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
background: 'color-mix(in srgb, var(--color-primary) 8%, transparent)',
|
||||||
|
fontSize: 11,
|
||||||
|
fontStyle: 'normal',
|
||||||
|
fontWeight: 700,
|
||||||
|
};
|
||||||
|
|
||||||
|
const reasoningPendingStyle: CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
padding: '0 10px 10px',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const reasoningPulseDotStyle: CSSProperties = {
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: 999,
|
||||||
|
background: 'var(--color-primary)',
|
||||||
|
boxShadow: '0 0 0 5px color-mix(in srgb, var(--color-primary) 12%, transparent)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const streamingBodyStatusStyle: CSSProperties = {
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: 'fit-content',
|
||||||
|
maxWidth: '100%',
|
||||||
|
marginTop: 4,
|
||||||
|
padding: '7px 10px',
|
||||||
|
border: '1px solid var(--color-border-light)',
|
||||||
|
borderRadius: 999,
|
||||||
|
background: 'color-mix(in srgb, var(--color-bg) 74%, var(--color-surface))',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
fontSize: 12,
|
||||||
|
};
|
||||||
|
|
||||||
const inputBarStyle: CSSProperties = {
|
const inputBarStyle: CSSProperties = {
|
||||||
padding: 16,
|
padding: 16,
|
||||||
borderTop: '1px solid var(--color-border)',
|
borderTop: '1px solid var(--color-border)',
|
||||||
|
|||||||
+203
-12
@@ -15,6 +15,15 @@ const SiteTrendChart = lazy(
|
|||||||
() => import("../components/charts/SiteTrendChart.js"),
|
() => import("../components/charts/SiteTrendChart.js"),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function scheduleDashboardIdleWork(callback: () => void, timeout = 1600): () => void {
|
||||||
|
if (typeof requestIdleCallback === "function") {
|
||||||
|
const idleId = requestIdleCallback(callback, { timeout });
|
||||||
|
return () => cancelIdleCallback(idleId);
|
||||||
|
}
|
||||||
|
const timer = globalThis.setTimeout(callback, Math.min(timeout, 450));
|
||||||
|
return () => globalThis.clearTimeout(timer);
|
||||||
|
}
|
||||||
|
|
||||||
function getGreeting(): string {
|
function getGreeting(): string {
|
||||||
const hour = new Date().getHours();
|
const hour = new Date().getHours();
|
||||||
if (hour < 6) return "夜深了";
|
if (hour < 6) return "夜深了";
|
||||||
@@ -233,6 +242,7 @@ export default function Dashboard({
|
|||||||
const [siteTrend, setSiteTrend] = useState<any[]>([]);
|
const [siteTrend, setSiteTrend] = useState<any[]>([]);
|
||||||
const [siteLoading, setSiteLoading] = useState(true);
|
const [siteLoading, setSiteLoading] = useState(true);
|
||||||
const [sites, setSites] = useState<any[]>([]);
|
const [sites, setSites] = useState<any[]>([]);
|
||||||
|
const [chartsReady, setChartsReady] = useState(false);
|
||||||
const [siteSpeedStates, setSiteSpeedStates] = useState<
|
const [siteSpeedStates, setSiteSpeedStates] = useState<
|
||||||
Record<string, SiteSpeedState>
|
Record<string, SiteSpeedState>
|
||||||
>({});
|
>({});
|
||||||
@@ -318,6 +328,8 @@ export default function Dashboard({
|
|||||||
loadSiteStats();
|
loadSiteStats();
|
||||||
}, [loadSiteStats]);
|
}, [loadSiteStats]);
|
||||||
|
|
||||||
|
useEffect(() => scheduleDashboardIdleWork(() => setChartsReady(true)), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let timer: ReturnType<typeof setInterval> | null = null;
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
@@ -526,6 +538,111 @@ export default function Dashboard({
|
|||||||
const siteAvailability = showInactiveSites
|
const siteAvailability = showInactiveSites
|
||||||
? [...activeSites, ...inactiveSites]
|
? [...activeSites, ...inactiveSites]
|
||||||
: activeSites;
|
: activeSites;
|
||||||
|
const proxySuccessRate =
|
||||||
|
proxy24hTotal > 0 ? (proxy24hSuccess / proxy24hTotal) * 100 : 0;
|
||||||
|
const activeAccountRate =
|
||||||
|
totalAccounts > 0 ? (activeAccounts / totalAccounts) * 100 : 0;
|
||||||
|
const siteRiskCount = activeSites.filter((site) => {
|
||||||
|
const availability = site.availabilityPercent ?? 100;
|
||||||
|
const latency = site.averageLatencyMs ?? 0;
|
||||||
|
return availability < 99 || latency > 1800 || site.failedCount > 0;
|
||||||
|
}).length;
|
||||||
|
const riskySites = activeSites
|
||||||
|
.filter((site) => {
|
||||||
|
const availability = site.availabilityPercent ?? 100;
|
||||||
|
const latency = site.averageLatencyMs ?? 0;
|
||||||
|
return availability < 99 || latency > 1800 || site.failedCount > 0;
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aScore =
|
||||||
|
(100 - (a.availabilityPercent ?? 100)) * 100 +
|
||||||
|
(a.failedCount || 0) * 8 +
|
||||||
|
Math.max(0, (a.averageLatencyMs || 0) - 1200) / 10;
|
||||||
|
const bScore =
|
||||||
|
(100 - (b.availabilityPercent ?? 100)) * 100 +
|
||||||
|
(b.failedCount || 0) * 8 +
|
||||||
|
Math.max(0, (b.averageLatencyMs || 0) - 1200) / 10;
|
||||||
|
return bScore - aScore;
|
||||||
|
})
|
||||||
|
.slice(0, 3);
|
||||||
|
const overallHealthTone =
|
||||||
|
proxySuccessRate >= 99 && siteRiskCount === 0
|
||||||
|
? "success"
|
||||||
|
: proxySuccessRate >= 95 && siteRiskCount <= 2
|
||||||
|
? "warning"
|
||||||
|
: "danger";
|
||||||
|
const overallHealthLabel =
|
||||||
|
overallHealthTone === "success"
|
||||||
|
? "稳定"
|
||||||
|
: overallHealthTone === "warning"
|
||||||
|
? "需关注"
|
||||||
|
: "异常";
|
||||||
|
const spendTone =
|
||||||
|
todaySpend <= todayReward
|
||||||
|
? "success"
|
||||||
|
: todaySpend <= Math.max(1, todayReward * 2)
|
||||||
|
? "warning"
|
||||||
|
: "danger";
|
||||||
|
const throughputLabel =
|
||||||
|
requestsPerMinute > 0
|
||||||
|
? `${Math.round(requestsPerMinute).toLocaleString()} RPM`
|
||||||
|
: "暂无流量";
|
||||||
|
const operationsPulse = [
|
||||||
|
{
|
||||||
|
label: "整体状态",
|
||||||
|
value: overallHealthLabel,
|
||||||
|
note: `${proxySuccessRate.toFixed(1)}% 成功率`,
|
||||||
|
tone: overallHealthTone,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "站点风险",
|
||||||
|
value: `${siteRiskCount}`,
|
||||||
|
note: `${activeSites.length}/${rawSiteAvailability.length} 活跃站点`,
|
||||||
|
tone: siteRiskCount > 0 ? "warning" : "success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "今日成本",
|
||||||
|
value: `$${todaySpend.toFixed(2)}`,
|
||||||
|
note: `奖励 $${todayReward.toFixed(2)}`,
|
||||||
|
tone: spendTone,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "当前吞吐",
|
||||||
|
value: throughputLabel,
|
||||||
|
note: `${formatCompactTokenMetric(tokensPerMinute)} TPM`,
|
||||||
|
tone: requestsPerMinute > 0 ? "primary" : "muted",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const dashboardCommandLinks = [
|
||||||
|
{
|
||||||
|
to: "/routes",
|
||||||
|
label: "路由健康",
|
||||||
|
value: `${proxySuccessRate.toFixed(1)}%`,
|
||||||
|
note: "24h 成功率",
|
||||||
|
tone: proxySuccessRate >= 99 ? "success" : proxySuccessRate >= 95 ? "warning" : "danger",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/logs",
|
||||||
|
label: "使用日志",
|
||||||
|
value: Math.round(proxy24hTotal).toLocaleString(),
|
||||||
|
note: "最近 24h 请求",
|
||||||
|
tone: "primary",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/sites",
|
||||||
|
label: "站点管理",
|
||||||
|
value: String(siteRiskCount),
|
||||||
|
note: siteRiskCount > 0 ? "需关注站点" : "风险站点",
|
||||||
|
tone: siteRiskCount > 0 ? "warning" : "success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/settings/notify",
|
||||||
|
label: "通知设置",
|
||||||
|
value: `${activeAccountRate.toFixed(0)}%`,
|
||||||
|
note: "账户启用率",
|
||||||
|
tone: activeAccountRate >= 80 ? "success" : "warning",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const getLatencyColor = (ms: number) =>
|
const getLatencyColor = (ms: number) =>
|
||||||
ms <= 500
|
ms <= 500
|
||||||
@@ -577,18 +694,27 @@ export default function Dashboard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard-page animate-fade-in">
|
<div className="dashboard-page animate-fade-in">
|
||||||
<div
|
<section className="console-hero dashboard-hero">
|
||||||
style={{
|
<div className="console-hero-copy">
|
||||||
display: "flex",
|
<span className="console-kicker">BossAI Console</span>
|
||||||
justifyContent: "space-between",
|
<h1>{getGreeting()},{normalizedAdminName}</h1>
|
||||||
alignItems: "center",
|
<p>统一观察模型路由、站点健康、账户余额、请求吞吐和计费成本。这里是整个平台的运营控制台。</p>
|
||||||
marginBottom: 24,
|
</div>
|
||||||
}}
|
<div className="console-hero-rail" aria-label="实时运营摘要">
|
||||||
>
|
<div>
|
||||||
<h2 className="greeting">
|
<span>24h 请求</span>
|
||||||
{getGreeting() + "\uFF0C" + normalizedAdminName}
|
<strong>{Math.round(proxy24hTotal).toLocaleString()}</strong>
|
||||||
</h2>
|
</div>
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
<div>
|
||||||
|
<span>RPM</span>
|
||||||
|
<strong>{Math.round(requestsPerMinute).toLocaleString()}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>站点</span>
|
||||||
|
<strong>{activeSites.length}/{rawSiteAvailability.length}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="console-hero-actions">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void load(true);
|
void load(true);
|
||||||
@@ -619,7 +745,62 @@ export default function Dashboard({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="dashboard-war-room" aria-label="运营战情室">
|
||||||
|
<div className="dashboard-pulse-grid">
|
||||||
|
{operationsPulse.map((item) => (
|
||||||
|
<div className="dashboard-pulse-card" data-tone={item.tone} key={item.label}>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<strong>{item.value}</strong>
|
||||||
|
<em>{item.note}</em>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-risk-panel">
|
||||||
|
<div className="dashboard-risk-head">
|
||||||
|
<div>
|
||||||
|
<span>风险雷达</span>
|
||||||
|
<strong>{riskySites.length > 0 ? "优先检查这些站点" : "暂无明显站点风险"}</strong>
|
||||||
|
</div>
|
||||||
|
<Link to="/sites" className="btn btn-ghost">站点管理</Link>
|
||||||
|
</div>
|
||||||
|
{riskySites.length > 0 ? (
|
||||||
|
<div className="dashboard-risk-list">
|
||||||
|
{riskySites.map((site) => (
|
||||||
|
<Link
|
||||||
|
key={site.siteId}
|
||||||
|
to={buildSiteLast24hLogsRoute(site.siteId)}
|
||||||
|
className="dashboard-risk-row"
|
||||||
|
>
|
||||||
|
<strong>{site.siteName}</strong>
|
||||||
|
<span>{formatAvailabilityPercent(site.availabilityPercent)}</span>
|
||||||
|
<em>{site.averageLatencyMs != null ? `${site.averageLatencyMs}ms` : "暂无延迟"}</em>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="dashboard-risk-empty">
|
||||||
|
站点可用性、平均延迟和失败数都处于可接受范围。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav className="dashboard-command-strip" aria-label="运营快捷入口">
|
||||||
|
{dashboardCommandLinks.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
className="dashboard-command-item"
|
||||||
|
data-tone={item.tone}
|
||||||
|
>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<strong>{item.value}</strong>
|
||||||
|
<em>{item.note}</em>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div className="dashboard-stat-grid">
|
<div className="dashboard-stat-grid">
|
||||||
<div className="stat-card animate-slide-up stagger-1">
|
<div className="stat-card animate-slide-up stagger-1">
|
||||||
@@ -1002,17 +1183,25 @@ export default function Dashboard({
|
|||||||
|
|
||||||
<div className="dashboard-chart-grid">
|
<div className="dashboard-chart-grid">
|
||||||
<div className="chart-panel-enter animate-slide-up stagger-6">
|
<div className="chart-panel-enter animate-slide-up stagger-6">
|
||||||
|
{chartsReady ? (
|
||||||
<Suspense fallback={<ChartFallback height={320} />}>
|
<Suspense fallback={<ChartFallback height={320} />}>
|
||||||
<SiteDistributionChart
|
<SiteDistributionChart
|
||||||
data={siteDistribution}
|
data={siteDistribution}
|
||||||
loading={siteLoading}
|
loading={siteLoading}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
) : (
|
||||||
|
<ChartFallback height={320} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="chart-panel-enter animate-slide-up stagger-7">
|
<div className="chart-panel-enter animate-slide-up stagger-7">
|
||||||
|
{chartsReady ? (
|
||||||
<Suspense fallback={<ChartFallback height={320} />}>
|
<Suspense fallback={<ChartFallback height={320} />}>
|
||||||
<SiteTrendChart data={siteTrend} loading={siteLoading} />
|
<SiteTrendChart data={siteTrend} loading={siteLoading} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
) : (
|
||||||
|
<ChartFallback height={320} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1257,6 +1446,8 @@ export default function Dashboard({
|
|||||||
</div>
|
</div>
|
||||||
{insightsLoading && !insightsData ? (
|
{insightsLoading && !insightsData ? (
|
||||||
<ChartFallback height={260} />
|
<ChartFallback height={260} />
|
||||||
|
) : !chartsReady ? (
|
||||||
|
<ChartFallback height={260} />
|
||||||
) : (
|
) : (
|
||||||
<Suspense fallback={<ChartFallback height={260} />}>
|
<Suspense fallback={<ChartFallback height={260} />}>
|
||||||
<ModelAnalysisPanel data={insightsData?.modelAnalysis} />
|
<ModelAnalysisPanel data={insightsData?.modelAnalysis} />
|
||||||
|
|||||||
@@ -384,18 +384,33 @@ export default function DigitalHuman() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="digital-human-page animate-fade-in">
|
<div className="digital-human-page animate-fade-in">
|
||||||
<div className="page-header">
|
<section className="console-hero digital-human-hero">
|
||||||
<div>
|
<div className="console-hero-copy">
|
||||||
<h1 className="page-title">数字人</h1>
|
<span className="console-kicker">Digital Human</span>
|
||||||
<p className="page-subtitle">选择 Comfy 工作流模板,上传人物素材和参考音频,生成真人商品讲解视频。</p>
|
<h1>数字人商品讲解</h1>
|
||||||
|
<p>选择 Comfy 工作流模板,上传人物图片或视频、商品文案和参考音频,后台生成真人口播讲解视频并归档到工作空间。</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="page-actions">
|
<div className="console-hero-rail" aria-label="数字人任务摘要">
|
||||||
|
<div>
|
||||||
|
<span>模板</span>
|
||||||
|
<strong>{sortedWorkflows.length}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>任务</span>
|
||||||
|
<strong>{jobs.length}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>音频用途</span>
|
||||||
|
<strong>{AUDIO_ROLES.find((item) => item.value === audioRole)?.label || 'both'}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="console-hero-actions">
|
||||||
<button type="button" className="btn btn-ghost" onClick={loadData}>刷新</button>
|
<button type="button" className="btn btn-ghost" onClick={loadData}>刷新</button>
|
||||||
<button type="button" className="btn btn-primary" onClick={submit} disabled={!canSubmit}>
|
<button type="button" className="btn btn-primary" onClick={submit} disabled={!canSubmit}>
|
||||||
{submitting ? '提交中...' : '生成讲解视频'}
|
{submitting ? '提交中...' : '生成讲解视频'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
|
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const { apiMock } = vi.hoisted(() => ({
|
|||||||
getAccounts: vi.fn(),
|
getAccounts: vi.fn(),
|
||||||
getAccountsSnapshot: vi.fn(),
|
getAccountsSnapshot: vi.fn(),
|
||||||
getAccountTokens: vi.fn(),
|
getAccountTokens: vi.fn(),
|
||||||
|
getSites: vi.fn(),
|
||||||
getDownstreamApiKeyOverview: vi.fn(),
|
getDownstreamApiKeyOverview: vi.fn(),
|
||||||
getDownstreamApiKeyTrend: vi.fn(),
|
getDownstreamApiKeyTrend: vi.fn(),
|
||||||
createDownstreamApiKey: vi.fn(),
|
createDownstreamApiKey: vi.fn(),
|
||||||
@@ -166,6 +167,10 @@ beforeEach(() => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
apiMock.getSites.mockResolvedValue([
|
||||||
|
{ id: 201, name: '站点A', status: 'active' },
|
||||||
|
{ id: 202, name: '站点B', status: 'active' },
|
||||||
|
]);
|
||||||
apiMock.getAccountTokens.mockResolvedValue([
|
apiMock.getAccountTokens.mockResolvedValue([
|
||||||
{
|
{
|
||||||
id: 301,
|
id: 301,
|
||||||
|
|||||||
@@ -1065,7 +1065,7 @@ export default function DownstreamKeys() {
|
|||||||
const empty = !loading && visibleItems.length === 0;
|
const empty = !loading && visibleItems.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in" style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div className="animate-fade-in admin-ops-page downstream-keys-page" style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
<div className="page-header" style={{ marginBottom: 0 }}>
|
<div className="page-header" style={{ marginBottom: 0 }}>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="page-title">下游密钥</h2>
|
<h2 className="page-title">下游密钥</h2>
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ describe('ImportExport', () => {
|
|||||||
success: true,
|
success: true,
|
||||||
config: {
|
config: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
fileUrl: 'https://dav.example.com/backups/metapi.json',
|
fileUrl: 'https://dav.example.com/backups/boosapi.json',
|
||||||
username: 'alice',
|
username: 'alice',
|
||||||
exportType: 'all',
|
exportType: 'all',
|
||||||
autoSyncEnabled: true,
|
autoSyncEnabled: true,
|
||||||
@@ -231,7 +231,7 @@ describe('ImportExport', () => {
|
|||||||
success: true,
|
success: true,
|
||||||
config: {
|
config: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
fileUrl: 'https://dav.example.com/backups/metapi.json',
|
fileUrl: 'https://dav.example.com/backups/boosapi.json',
|
||||||
username: 'alice',
|
username: 'alice',
|
||||||
exportType: 'all',
|
exportType: 'all',
|
||||||
autoSyncEnabled: true,
|
autoSyncEnabled: true,
|
||||||
@@ -246,7 +246,7 @@ describe('ImportExport', () => {
|
|||||||
});
|
});
|
||||||
apiMock.exportBackupToWebdav.mockResolvedValue({
|
apiMock.exportBackupToWebdav.mockResolvedValue({
|
||||||
success: true,
|
success: true,
|
||||||
fileUrl: 'https://dav.example.com/backups/metapi.json',
|
fileUrl: 'https://dav.example.com/backups/boosapi.json',
|
||||||
exportType: 'all',
|
exportType: 'all',
|
||||||
});
|
});
|
||||||
apiMock.importBackupFromWebdav.mockResolvedValue({
|
apiMock.importBackupFromWebdav.mockResolvedValue({
|
||||||
@@ -459,7 +459,7 @@ describe('ImportExport', () => {
|
|||||||
|
|
||||||
const fileUrlInput = root!.root.findAll((node) => (
|
const fileUrlInput = root!.root.findAll((node) => (
|
||||||
node.type === 'input'
|
node.type === 'input'
|
||||||
&& node.props.placeholder === 'https://dav.example.com/backups/metapi.json'
|
&& node.props.placeholder === 'https://dav.example.com/backups/boosapi.json'
|
||||||
)).at(-1);
|
)).at(-1);
|
||||||
const cronInput = root!.root.findAll((node) => (
|
const cronInput = root!.root.findAll((node) => (
|
||||||
node.type === 'input'
|
node.type === 'input'
|
||||||
@@ -507,7 +507,7 @@ describe('ImportExport', () => {
|
|||||||
|
|
||||||
const fileUrlInput = root!.root.findAll((node) => (
|
const fileUrlInput = root!.root.findAll((node) => (
|
||||||
node.type === 'input'
|
node.type === 'input'
|
||||||
&& node.props.placeholder === 'https://dav.example.com/backups/metapi.json'
|
&& node.props.placeholder === 'https://dav.example.com/backups/boosapi.json'
|
||||||
)).at(-1);
|
)).at(-1);
|
||||||
|
|
||||||
expect(fileUrlInput).toBeTruthy();
|
expect(fileUrlInput).toBeTruthy();
|
||||||
@@ -538,7 +538,7 @@ describe('ImportExport', () => {
|
|||||||
success: true,
|
success: true,
|
||||||
config: {
|
config: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
fileUrl: 'https://dav.example.com/backups/metapi.json',
|
fileUrl: 'https://dav.example.com/backups/boosapi.json',
|
||||||
username: 'alice',
|
username: 'alice',
|
||||||
exportType: 'all',
|
exportType: 'all',
|
||||||
autoSyncEnabled: true,
|
autoSyncEnabled: true,
|
||||||
|
|||||||
@@ -789,7 +789,7 @@ describe('Models marketplace text', () => {
|
|||||||
|
|
||||||
expect(String(latencyBadge.props.className || '')).toContain('badge-muted');
|
expect(String(latencyBadge.props.className || '')).toContain('badge-muted');
|
||||||
expect(collectText(latencyBadge)).toContain('延迟');
|
expect(collectText(latencyBadge)).toContain('延迟');
|
||||||
expect(collectText(latencyBadge)).toContain('—');
|
expect(collectText(latencyBadge)).toContain('暂无');
|
||||||
expect(collectText(root!.root)).not.toContain('680ms');
|
expect(collectText(root!.root)).not.toContain('680ms');
|
||||||
} finally {
|
} finally {
|
||||||
root?.unmount();
|
root?.unmount();
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
|
||||||
|
import MoneyPrinter from './MoneyPrinter.js';
|
||||||
|
|
||||||
|
const { apiMock, userApiMock } = vi.hoisted(() => {
|
||||||
|
const base = {
|
||||||
|
getMoneyPrinterJobs: vi.fn(),
|
||||||
|
getMoneyPrinterStatus: vi.fn(),
|
||||||
|
getWorkflows: vi.fn(),
|
||||||
|
createMoneyPrinterJob: vi.fn(),
|
||||||
|
cancelMoneyPrinterJob: vi.fn(),
|
||||||
|
clearMoneyPrinterJobs: vi.fn(),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
apiMock: base,
|
||||||
|
userApiMock: { ...base },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../api.js', () => ({
|
||||||
|
api: apiMock,
|
||||||
|
userApi: userApiMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function collectText(node: ReactTestInstance): string {
|
||||||
|
return (node.children || []).map((child) => {
|
||||||
|
if (typeof child === 'string') return child;
|
||||||
|
return collectText(child);
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushMicrotasks() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderMoneyPrinter(initialPath = '/money-printer') {
|
||||||
|
let root!: ReactTestRenderer;
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={[initialPath]}>
|
||||||
|
<MoneyPrinter />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findButton(root: ReactTestRenderer, label: string) {
|
||||||
|
return root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).includes(label)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultStatus(overrides: Record<string, any> = {}) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
mode: 'boosapi-native',
|
||||||
|
keyName: 'global-proxy',
|
||||||
|
comfy: {
|
||||||
|
enabled: true,
|
||||||
|
backend: 'cloud',
|
||||||
|
baseUrl: 'https://cloud.comfy.org',
|
||||||
|
},
|
||||||
|
builtinCloudTemplate: {
|
||||||
|
enabled: true,
|
||||||
|
label: '内置 Cloud Seedance 文生视频',
|
||||||
|
nodeType: 'ByteDanceTextToVideoNode',
|
||||||
|
model: 'seedance-1-0-lite-t2v-250428',
|
||||||
|
duration: { min: 3, max: 12, default: 5 },
|
||||||
|
resolutions: ['480p', '720p', '1080p'],
|
||||||
|
ratios: ['9:16', '16:9'],
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MoneyPrinter page', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
apiMock.getMoneyPrinterJobs.mockResolvedValue({ success: true, jobs: [] });
|
||||||
|
apiMock.getMoneyPrinterStatus.mockResolvedValue(defaultStatus());
|
||||||
|
apiMock.getWorkflows.mockResolvedValue({ success: true, items: [] });
|
||||||
|
apiMock.clearMoneyPrinterJobs.mockResolvedValue({ success: true, cleared: 0 });
|
||||||
|
apiMock.cancelMoneyPrinterJob.mockResolvedValue({ success: true, job: null });
|
||||||
|
userApiMock.getMoneyPrinterJobs.mockResolvedValue({ success: true, jobs: [] });
|
||||||
|
userApiMock.getMoneyPrinterStatus.mockResolvedValue(defaultStatus());
|
||||||
|
userApiMock.getWorkflows.mockResolvedValue({ success: true, items: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the built-in Cloud video template when Cloud backend is active', async () => {
|
||||||
|
let root!: ReactTestRenderer;
|
||||||
|
try {
|
||||||
|
root = await renderMoneyPrinter();
|
||||||
|
const text = collectText(root.root);
|
||||||
|
expect(text).toContain('Comfy Cloud');
|
||||||
|
expect(text).toContain('内置 Cloud Seedance');
|
||||||
|
expect(text).toContain('完成后进入工作空间');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies a quick topic preset into the production form', async () => {
|
||||||
|
let root!: ReactTestRenderer;
|
||||||
|
try {
|
||||||
|
root = await renderMoneyPrinter();
|
||||||
|
await act(async () => {
|
||||||
|
findButton(root, '知识科普').props.onClick();
|
||||||
|
});
|
||||||
|
|
||||||
|
const textareas = root.root.findAll((node) => node.type === 'textarea');
|
||||||
|
expect(textareas[0].props.value).toContain('统一的 AI API 聚合网关');
|
||||||
|
expect(textareas[1].props.value).toContain('用清晰分镜解释问题');
|
||||||
|
const ratioSelect = root.root.findAll((node) => node.type === 'select')
|
||||||
|
.find((node) => node.props.value === '16:9');
|
||||||
|
expect(ratioSelect).toBeTruthy();
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters the task queue by failed jobs', async () => {
|
||||||
|
apiMock.getMoneyPrinterJobs.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
jobs: [
|
||||||
|
{
|
||||||
|
id: 'mp_running',
|
||||||
|
type: 'money-printer-video',
|
||||||
|
title: '正在生成的视频',
|
||||||
|
status: 'running',
|
||||||
|
message: 'Comfy 正在生成视频',
|
||||||
|
progress: 4,
|
||||||
|
progressTotal: 5,
|
||||||
|
result: { request: { videoBackend: 'comfy', duration: 5, ratio: '9:16', resolution: '720p' } },
|
||||||
|
createdAt: '2026-05-26 10:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mp_failed',
|
||||||
|
type: 'money-printer-video',
|
||||||
|
title: '失败的视频',
|
||||||
|
status: 'failed',
|
||||||
|
message: '任务失败',
|
||||||
|
error: '余额不足',
|
||||||
|
progress: 3,
|
||||||
|
progressTotal: 5,
|
||||||
|
result: { request: { videoBackend: 'comfy', duration: 5, ratio: '9:16', resolution: '720p' } },
|
||||||
|
createdAt: '2026-05-26 10:01:00',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let root!: ReactTestRenderer;
|
||||||
|
try {
|
||||||
|
root = await renderMoneyPrinter();
|
||||||
|
expect(collectText(root.root)).toContain('正在生成的视频');
|
||||||
|
expect(collectText(root.root)).toContain('失败的视频');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
findButton(root, '失败').props.onClick();
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredText = collectText(root.root);
|
||||||
|
expect(filteredText).not.toContain('正在生成的视频');
|
||||||
|
expect(filteredText).toContain('失败的视频');
|
||||||
|
expect(filteredText).toContain('余额不足');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+454
-51
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { api, userApi, type MoneyPrinterJob, type WorkflowSummary } from '../api.js';
|
import { api, userApi, type MoneyPrinterJob, type MoneyPrinterStatusResponse, type WorkflowSummary } from '../api.js';
|
||||||
import type { SessionAudience } from '../authSession.js';
|
import type { SessionAudience } from '../authSession.js';
|
||||||
|
|
||||||
const STYLE_PRESETS = [
|
const STYLE_PRESETS = [
|
||||||
@@ -10,6 +10,59 @@ const STYLE_PRESETS = [
|
|||||||
'电影感广告,镜头运动丰富,高级质感',
|
'电影感广告,镜头运动丰富,高级质感',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const TOPIC_PRESETS = [
|
||||||
|
{
|
||||||
|
title: '商品种草',
|
||||||
|
desc: '适合 SaaS、工具、服务类产品',
|
||||||
|
topic: '为 BossAI 生成一个 30 秒商品种草短视频,突出多模型、多模态、工作流和统一 API 网关能力。',
|
||||||
|
audience: 'AI 工具用户、内容创作者、企业运营',
|
||||||
|
style: STYLE_PRESETS[1],
|
||||||
|
requirements: '开头 3 秒给出强钩子,中段展示核心卖点,结尾引导试用。画面干净,有真实产品演示感。',
|
||||||
|
duration: 30,
|
||||||
|
ratio: '9:16',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '知识科普',
|
||||||
|
desc: '解释概念、流程、技术卖点',
|
||||||
|
topic: '做一个 45 秒知识科普短视频,解释为什么企业需要统一的 AI API 聚合网关。',
|
||||||
|
audience: '开发者、技术负责人、AI 产品经理',
|
||||||
|
style: STYLE_PRESETS[2],
|
||||||
|
requirements: '用清晰分镜解释问题、方案和收益,避免空泛口号,字幕信息密度高但不要堆字。',
|
||||||
|
duration: 45,
|
||||||
|
ratio: '16:9',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '功能更新',
|
||||||
|
desc: '发布新能力和版本亮点',
|
||||||
|
topic: '生成一个 30 秒 BossAI 工作流新功能发布短视频,重点展示短视频工厂、数字人和工作空间归档。',
|
||||||
|
audience: '现有用户、团队管理员、内容运营',
|
||||||
|
style: STYLE_PRESETS[0],
|
||||||
|
requirements: '节奏要快,按「新功能 -> 使用场景 -> 结果产出」展开,结尾提醒用户打开工作流试用。',
|
||||||
|
duration: 30,
|
||||||
|
ratio: '9:16',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '活动预热',
|
||||||
|
desc: '适合促销、课程、发布会',
|
||||||
|
topic: '生成一个 20 秒活动预热视频,主题是 BossAI 多模态创作工作流体验日。',
|
||||||
|
audience: '创作者、营销团队、AI 学习者',
|
||||||
|
style: STYLE_PRESETS[3],
|
||||||
|
requirements: '画面需要有倒计时、场景切换和高能结尾,重点营造期待感,不要像普通广告口播。',
|
||||||
|
duration: 20,
|
||||||
|
ratio: '9:16',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const PIPELINE_STEPS = [
|
||||||
|
'创建任务',
|
||||||
|
'脚本分镜',
|
||||||
|
'提交视频',
|
||||||
|
'生成成片',
|
||||||
|
'归档产出',
|
||||||
|
];
|
||||||
|
|
||||||
|
type JobFilter = 'all' | 'active' | 'succeeded' | 'failed' | 'cancelled';
|
||||||
|
|
||||||
function terminal(status: string) {
|
function terminal(status: string) {
|
||||||
return ['succeeded', 'failed', 'cancelled'].includes(status);
|
return ['succeeded', 'failed', 'cancelled'].includes(status);
|
||||||
}
|
}
|
||||||
@@ -22,6 +75,14 @@ function statusLabel(status: string) {
|
|||||||
return '排队中';
|
return '排队中';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusTone(status: string) {
|
||||||
|
if (status === 'succeeded') return 'success';
|
||||||
|
if (status === 'failed') return 'danger';
|
||||||
|
if (status === 'cancelled') return 'muted';
|
||||||
|
if (status === 'running') return 'primary';
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
function progress(job: MoneyPrinterJob) {
|
function progress(job: MoneyPrinterJob) {
|
||||||
return Math.max(0, Math.min(100, Math.round((job.progress || 0) / Math.max(1, job.progressTotal || 5) * 100)));
|
return Math.max(0, Math.min(100, Math.round((job.progress || 0) / Math.max(1, job.progressTotal || 5) * 100)));
|
||||||
}
|
}
|
||||||
@@ -37,6 +98,47 @@ function jobAssets(job: MoneyPrinterJob) {
|
|||||||
return Array.isArray(job.result?.assets) ? job.result.assets : [];
|
return Array.isArray(job.result?.assets) ? job.result.assets : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function backendLabel(value?: string | null) {
|
||||||
|
const normalized = String(value || '').toLowerCase();
|
||||||
|
if (normalized === 'cloud') return 'Comfy Cloud';
|
||||||
|
if (normalized === 'external') return '外部 Comfy';
|
||||||
|
if (normalized === 'runninghub') return 'RunningHub';
|
||||||
|
if (normalized === 'docker') return 'Docker Comfy';
|
||||||
|
if (normalized === 'comfy') return 'Comfy API';
|
||||||
|
if (normalized === 'boosapi') return 'BoosAPI /v1/videos';
|
||||||
|
if (!normalized) return '未配置';
|
||||||
|
return value || '未配置';
|
||||||
|
}
|
||||||
|
|
||||||
|
function jobBackendLabel(job: MoneyPrinterJob) {
|
||||||
|
return backendLabel(job.result?.videoBackend || job.result?.request?.videoBackend || job.result?.request?.video_backend);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jobModelLabel(job: MoneyPrinterJob) {
|
||||||
|
return job.result?.model
|
||||||
|
|| job.result?.request?.videoModel
|
||||||
|
|| job.result?.request?.model
|
||||||
|
|| (String(job.result?.videoBackend || job.result?.request?.videoBackend || '').toLowerCase() === 'comfy' ? 'Comfy 模板模型' : '默认视频模型');
|
||||||
|
}
|
||||||
|
|
||||||
|
function jobTemplateLabel(job: MoneyPrinterJob, workflows: WorkflowSummary[]) {
|
||||||
|
const templateId = job.result?.request?.templateId || job.result?.request?.template_id;
|
||||||
|
if (!templateId) return '';
|
||||||
|
if (templateId === BUILTIN_CLOUD_TEMPLATE_VALUE) return '内置 Cloud Seedance';
|
||||||
|
const workflow = workflows.find((item) => item.publicId === templateId);
|
||||||
|
return workflow?.name || String(templateId).slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jobSpecLabel(job: MoneyPrinterJob) {
|
||||||
|
const request = job.result?.request || {};
|
||||||
|
const parts = [
|
||||||
|
request.duration ? `${request.duration} 秒` : '',
|
||||||
|
request.ratio || request.aspectRatio || request.aspect_ratio || '',
|
||||||
|
request.resolution || request.size || '',
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
function planValue(job: MoneyPrinterJob, key: string) {
|
function planValue(job: MoneyPrinterJob, key: string) {
|
||||||
return job.result?.script?.plan?.[key] || '';
|
return job.result?.script?.plan?.[key] || '';
|
||||||
}
|
}
|
||||||
@@ -55,6 +157,11 @@ function mappingFromGraph(graph: any) {
|
|||||||
|
|
||||||
function inferMappingFromPrompt(prompt: any) {
|
function inferMappingFromPrompt(prompt: any) {
|
||||||
const nodes = prompt && typeof prompt === 'object' && !Array.isArray(prompt) ? Object.entries(prompt as Record<string, any>) : [];
|
const nodes = prompt && typeof prompt === 'object' && !Array.isArray(prompt) ? Object.entries(prompt as Record<string, any>) : [];
|
||||||
|
const inputKeys = (nodeId: string) => Object.keys(prompt?.[String(nodeId)]?.inputs || {});
|
||||||
|
const firstInputField = (nodeId: string, fields: string[], fallback: string) => {
|
||||||
|
const keys = inputKeys(nodeId);
|
||||||
|
return fields.find((field) => keys.includes(field)) || fallback;
|
||||||
|
};
|
||||||
const find = (classWords: string[], inputWords: string[] = []) => {
|
const find = (classWords: string[], inputWords: string[] = []) => {
|
||||||
return nodes.find(([, node]) => {
|
return nodes.find(([, node]) => {
|
||||||
const classType = String(node?.class_type || node?.type || '').toLowerCase();
|
const classType = String(node?.class_type || node?.type || '').toLowerCase();
|
||||||
@@ -66,11 +173,11 @@ function inferMappingFromPrompt(prompt: any) {
|
|||||||
const negativeNode = find(['negative'], ['negative']);
|
const negativeNode = find(['negative'], ['negative']);
|
||||||
const videoNode = find(['wan', 'ltx', 'video'], ['duration', 'ratio', 'resolution']);
|
const videoNode = find(['wan', 'ltx', 'video'], ['duration', 'ratio', 'resolution']);
|
||||||
return {
|
return {
|
||||||
prompt: { nodeId: String(promptNode), field: prompt?.[String(promptNode)]?.inputs?.prompt !== undefined ? 'prompt' : 'text' },
|
prompt: { nodeId: String(promptNode), field: firstInputField(String(promptNode), ['prompt', 'positive', 'text', 'positive_prompt'], 'text') },
|
||||||
negativePrompt: { nodeId: String(negativeNode), field: 'text' },
|
negativePrompt: { nodeId: String(negativeNode), field: 'text' },
|
||||||
ratio: { nodeId: String(videoNode), field: 'ratio' },
|
ratio: { nodeId: String(videoNode), field: firstInputField(String(videoNode), ['ratio', 'model.ratio', 'aspect_ratio'], 'ratio') },
|
||||||
resolution: { nodeId: String(videoNode), field: 'resolution' },
|
resolution: { nodeId: String(videoNode), field: firstInputField(String(videoNode), ['model.resolution', 'resolution', 'size'], 'resolution') },
|
||||||
duration: { nodeId: String(videoNode), field: 'duration' },
|
duration: { nodeId: String(videoNode), field: firstInputField(String(videoNode), ['model.duration', 'duration', 'length'], 'duration') },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +299,6 @@ function workflowLooksVideoCapable(workflow: WorkflowSummary) {
|
|||||||
function moneyPrinterWorkflows(items: WorkflowSummary[]) {
|
function moneyPrinterWorkflows(items: WorkflowSummary[]) {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
return items
|
return items
|
||||||
.filter((workflow) => workflowLooksVideoCapable(workflow))
|
|
||||||
.filter((workflow) => {
|
.filter((workflow) => {
|
||||||
const key = workflow.publicId || String(workflow.id);
|
const key = workflow.publicId || String(workflow.id);
|
||||||
if (seen.has(key)) return false;
|
if (seen.has(key)) return false;
|
||||||
@@ -200,6 +306,9 @@ function moneyPrinterWorkflows(items: WorkflowSummary[]) {
|
|||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
|
const aVideo = workflowLooksVideoCapable(a);
|
||||||
|
const bVideo = workflowLooksVideoCapable(b);
|
||||||
|
if (aVideo !== bVideo) return aVideo ? -1 : 1;
|
||||||
const aTemplate = a.isTemplate || a.visibility === 'template' || a.visibility === 'public';
|
const aTemplate = a.isTemplate || a.visibility === 'template' || a.visibility === 'public';
|
||||||
const bTemplate = b.isTemplate || b.visibility === 'template' || b.visibility === 'public';
|
const bTemplate = b.isTemplate || b.visibility === 'template' || b.visibility === 'public';
|
||||||
if (aTemplate !== bTemplate) return aTemplate ? -1 : 1;
|
if (aTemplate !== bTemplate) return aTemplate ? -1 : 1;
|
||||||
@@ -207,6 +316,22 @@ function moneyPrinterWorkflows(items: WorkflowSummary[]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseJsonInput(value: string, label: string) {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${label} 不是有效 JSON`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUILTIN_CLOUD_TEMPLATE_VALUE = '__builtin_cloud_seedance__';
|
||||||
|
const DEFAULT_BUILTIN_CLOUD_DURATION = 5;
|
||||||
|
const DEFAULT_BUILTIN_CLOUD_RESOLUTION = '480p';
|
||||||
|
const DEFAULT_CLOUD_RATIOS = ['9:16', '16:9', '1:1'];
|
||||||
|
const DEFAULT_CLOUD_RESOLUTIONS = ['480p', '720p', '1080p'];
|
||||||
|
|
||||||
export default function MoneyPrinter() {
|
export default function MoneyPrinter() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
||||||
@@ -215,7 +340,7 @@ export default function MoneyPrinter() {
|
|||||||
const [topic, setTopic] = useState('帮我生成一个 30 秒的 AI 应用平台 BossAI 商品介绍短视频');
|
const [topic, setTopic] = useState('帮我生成一个 30 秒的 AI 应用平台 BossAI 商品介绍短视频');
|
||||||
const [scriptModel, setScriptModel] = useState('gpt-5.4');
|
const [scriptModel, setScriptModel] = useState('gpt-5.4');
|
||||||
const [videoModel, setVideoModel] = useState('seedance-1-0-lite-t2v');
|
const [videoModel, setVideoModel] = useState('seedance-1-0-lite-t2v');
|
||||||
const [videoBackend, setVideoBackend] = useState<'boosapi' | 'comfy'>('boosapi');
|
const [videoBackend, setVideoBackend] = useState<'boosapi' | 'comfy'>('comfy');
|
||||||
const [style, setStyle] = useState(STYLE_PRESETS[0]);
|
const [style, setStyle] = useState(STYLE_PRESETS[0]);
|
||||||
const [audienceText, setAudienceText] = useState('AI 工具用户、内容创作者、企业运营');
|
const [audienceText, setAudienceText] = useState('AI 工具用户、内容创作者、企业运营');
|
||||||
const [duration, setDuration] = useState(30);
|
const [duration, setDuration] = useState(30);
|
||||||
@@ -226,16 +351,46 @@ export default function MoneyPrinter() {
|
|||||||
const [comfyNodeMapping, setComfyNodeMapping] = useState('{\n "prompt": { "nodeId": "", "field": "prompt" },\n "negativePrompt": { "nodeId": "", "field": "text" },\n "ratio": { "nodeId": "", "field": "ratio" },\n "resolution": { "nodeId": "", "field": "resolution" },\n "duration": { "nodeId": "", "field": "duration" }\n}');
|
const [comfyNodeMapping, setComfyNodeMapping] = useState('{\n "prompt": { "nodeId": "", "field": "prompt" },\n "negativePrompt": { "nodeId": "", "field": "text" },\n "ratio": { "nodeId": "", "field": "ratio" },\n "resolution": { "nodeId": "", "field": "resolution" },\n "duration": { "nodeId": "", "field": "duration" }\n}');
|
||||||
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
|
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
|
||||||
const [templateId, setTemplateId] = useState('');
|
const [templateId, setTemplateId] = useState('');
|
||||||
const [useAdvancedComfy, setUseAdvancedComfy] = useState(false);
|
const [showAdvancedComfy, setShowAdvancedComfy] = useState(false);
|
||||||
const [jobs, setJobs] = useState<MoneyPrinterJob[]>([]);
|
const [jobs, setJobs] = useState<MoneyPrinterJob[]>([]);
|
||||||
|
const [status, setStatus] = useState<MoneyPrinterStatusResponse | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [clearing, setClearing] = useState(false);
|
||||||
|
const [cancellingIds, setCancellingIds] = useState<string[]>([]);
|
||||||
|
const [jobFilter, setJobFilter] = useState<JobFilter>('all');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const isCloudComfy = status?.comfy?.backend === 'cloud' && status?.builtinCloudTemplate?.enabled !== false;
|
||||||
|
const cloudDurationMin = status?.builtinCloudTemplate?.duration?.min ?? 3;
|
||||||
|
const cloudDurationMax = status?.builtinCloudTemplate?.duration?.max ?? 12;
|
||||||
|
const cloudDefaultDuration = status?.builtinCloudTemplate?.duration?.default ?? DEFAULT_BUILTIN_CLOUD_DURATION;
|
||||||
|
const cloudResolutions = useMemo(
|
||||||
|
() => status?.builtinCloudTemplate?.resolutions?.length
|
||||||
|
? status.builtinCloudTemplate.resolutions
|
||||||
|
: DEFAULT_CLOUD_RESOLUTIONS,
|
||||||
|
[status?.builtinCloudTemplate?.resolutions],
|
||||||
|
);
|
||||||
|
const cloudRatios = useMemo(
|
||||||
|
() => status?.builtinCloudTemplate?.ratios?.length
|
||||||
|
? status.builtinCloudTemplate.ratios
|
||||||
|
: DEFAULT_CLOUD_RATIOS,
|
||||||
|
[status?.builtinCloudTemplate?.ratios],
|
||||||
|
);
|
||||||
|
|
||||||
const loadJobs = useCallback(async () => {
|
const loadJobs = useCallback(async () => {
|
||||||
const payload = await client.getMoneyPrinterJobs({ limit: 30 });
|
const payload = await client.getMoneyPrinterJobs({ limit: 30 });
|
||||||
setJobs(Array.isArray(payload.jobs) ? payload.jobs : []);
|
setJobs(Array.isArray(payload.jobs) ? payload.jobs : []);
|
||||||
}, [client]);
|
}, [client]);
|
||||||
|
|
||||||
|
const loadStatus = useCallback(async () => {
|
||||||
|
const payload = await client.getMoneyPrinterStatus();
|
||||||
|
setStatus(payload);
|
||||||
|
}, [client]);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
await Promise.all([loadJobs(), loadStatus()]);
|
||||||
|
}, [loadJobs, loadStatus]);
|
||||||
|
|
||||||
const loadWorkflows = useCallback(async () => {
|
const loadWorkflows = useCallback(async () => {
|
||||||
const payload = await client.getWorkflows({ include_public: true });
|
const payload = await client.getWorkflows({ include_public: true });
|
||||||
const items = Array.isArray(payload.items) ? payload.items : [];
|
const items = Array.isArray(payload.items) ? payload.items : [];
|
||||||
@@ -243,41 +398,137 @@ export default function MoneyPrinter() {
|
|||||||
setWorkflows(videoItems);
|
setWorkflows(videoItems);
|
||||||
setTemplateId((current) => {
|
setTemplateId((current) => {
|
||||||
if (current && videoItems.some((item) => item.publicId === current)) return current;
|
if (current && videoItems.some((item) => item.publicId === current)) return current;
|
||||||
|
if (isCloudComfy) return BUILTIN_CLOUD_TEMPLATE_VALUE;
|
||||||
return videoItems.find((item) => item.isTemplate || item.visibility === 'template' || item.visibility === 'public')?.publicId
|
return videoItems.find((item) => item.isTemplate || item.visibility === 'template' || item.visibility === 'public')?.publicId
|
||||||
|| videoItems[0]?.publicId
|
|| videoItems[0]?.publicId
|
||||||
|| '';
|
|| '';
|
||||||
});
|
});
|
||||||
}, [client]);
|
}, [client, isCloudComfy]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([loadJobs(), loadWorkflows()]).catch((err) => setError(err?.message || '短视频任务加载失败'));
|
Promise.all([loadJobs(), loadWorkflows(), loadStatus()]).catch((err) => setError(err?.message || '短视频任务加载失败'));
|
||||||
}, [loadJobs, loadWorkflows]);
|
}, [loadJobs, loadWorkflows, loadStatus]);
|
||||||
|
|
||||||
const selectedWorkflow = workflows.find((item) => item.publicId === templateId) || null;
|
const selectedWorkflow = workflows.find((item) => item.publicId === templateId) || null;
|
||||||
|
const useBuiltinCloudTemplate = videoBackend === 'comfy' && isCloudComfy && templateId === BUILTIN_CLOUD_TEMPLATE_VALUE;
|
||||||
|
const comfyHasTemplate = useBuiltinCloudTemplate || Boolean(templateId && selectedWorkflow);
|
||||||
|
const canSubmit = Boolean(topic.trim()) && !submitting && (videoBackend !== 'comfy' || comfyHasTemplate);
|
||||||
|
const ratioOptions = useBuiltinCloudTemplate ? cloudRatios : DEFAULT_CLOUD_RATIOS;
|
||||||
|
const resolutionOptions = useBuiltinCloudTemplate ? cloudResolutions : DEFAULT_CLOUD_RESOLUTIONS;
|
||||||
|
const durationMin = useBuiltinCloudTemplate ? cloudDurationMin : 5;
|
||||||
|
const durationMax = useBuiltinCloudTemplate ? cloudDurationMax : 120;
|
||||||
|
const runningJobs = useMemo(() => jobs.filter((job) => !terminal(job.status)), [jobs]);
|
||||||
|
const succeededJobs = useMemo(() => jobs.filter((job) => job.status === 'succeeded'), [jobs]);
|
||||||
|
const failedJobs = useMemo(() => jobs.filter((job) => job.status === 'failed'), [jobs]);
|
||||||
|
const cancelledJobs = useMemo(() => jobs.filter((job) => job.status === 'cancelled'), [jobs]);
|
||||||
|
const filteredJobs = useMemo(() => {
|
||||||
|
if (jobFilter === 'active') return jobs.filter((job) => !terminal(job.status));
|
||||||
|
if (jobFilter === 'succeeded') return succeededJobs;
|
||||||
|
if (jobFilter === 'failed') return failedJobs;
|
||||||
|
if (jobFilter === 'cancelled') return cancelledJobs;
|
||||||
|
return jobs;
|
||||||
|
}, [cancelledJobs, failedJobs, jobFilter, jobs, succeededJobs]);
|
||||||
|
const videoTemplateCount = useMemo(() => workflows.filter(workflowLooksVideoCapable).length, [workflows]);
|
||||||
|
const comfyBackendLabel = backendLabel(status?.comfy?.backend);
|
||||||
|
const currentTemplateLabel = useBuiltinCloudTemplate
|
||||||
|
? '内置 Cloud Seedance'
|
||||||
|
: selectedWorkflow?.name || (videoBackend === 'comfy' ? '未选择模板' : '不使用模板');
|
||||||
|
const archivedAssetCount = useMemo(
|
||||||
|
() => jobs.reduce((total, job) => total + jobAssets(job).filter((asset: any) => asset?.status !== 'failed').length, 0),
|
||||||
|
[jobs],
|
||||||
|
);
|
||||||
|
const activePipelineStep = useMemo(() => {
|
||||||
|
if (runningJobs.length === 0) return -1;
|
||||||
|
const step = Math.max(...runningJobs.map((job) => Number(job.progress || 1)));
|
||||||
|
return Math.max(0, Math.min(PIPELINE_STEPS.length - 1, step - 1));
|
||||||
|
}, [runningJobs]);
|
||||||
|
const filterOptions = useMemo(() => [
|
||||||
|
{ key: 'all' as const, label: '全部', count: jobs.length },
|
||||||
|
{ key: 'active' as const, label: '进行中', count: runningJobs.length },
|
||||||
|
{ key: 'succeeded' as const, label: '已完成', count: succeededJobs.length },
|
||||||
|
{ key: 'failed' as const, label: '失败', count: failedJobs.length },
|
||||||
|
{ key: 'cancelled' as const, label: '已取消', count: cancelledJobs.length },
|
||||||
|
], [cancelledJobs.length, failedJobs.length, jobs.length, runningJobs.length, succeededJobs.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedWorkflow || videoBackend !== 'comfy' || !useAdvancedComfy) return;
|
if (videoBackend !== 'comfy') return;
|
||||||
|
if (isCloudComfy) {
|
||||||
|
setTemplateId((current) => current || BUILTIN_CLOUD_TEMPLATE_VALUE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTemplateId((current) => current === BUILTIN_CLOUD_TEMPLATE_VALUE ? '' : current);
|
||||||
|
}, [isCloudComfy, videoBackend]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!useBuiltinCloudTemplate) return;
|
||||||
|
setDuration((current) => {
|
||||||
|
const numeric = Number.isFinite(current) ? current : cloudDefaultDuration;
|
||||||
|
return Math.max(cloudDurationMin, Math.min(cloudDurationMax, numeric));
|
||||||
|
});
|
||||||
|
setResolution((current) => cloudResolutions.includes(current) ? current : DEFAULT_BUILTIN_CLOUD_RESOLUTION);
|
||||||
|
setRatio((current) => cloudRatios.includes(current) ? current : '9:16');
|
||||||
|
}, [cloudDefaultDuration, cloudDurationMax, cloudDurationMin, cloudRatios, cloudResolutions, useBuiltinCloudTemplate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (useBuiltinCloudTemplate) {
|
||||||
|
setComfyPrompt('');
|
||||||
|
setComfyNodeMapping('{\n "prompt": { "nodeId": "1", "field": "prompt" },\n "ratio": { "nodeId": "1", "field": "aspect_ratio" },\n "resolution": { "nodeId": "1", "field": "resolution" },\n "duration": { "nodeId": "1", "field": "duration" }\n}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedWorkflow || videoBackend !== 'comfy') return;
|
||||||
const prompt = workflowPromptFromGraph(selectedWorkflow.graph);
|
const prompt = workflowPromptFromGraph(selectedWorkflow.graph);
|
||||||
if (prompt) setComfyPrompt(JSON.stringify(prompt, null, 2));
|
if (prompt) setComfyPrompt(JSON.stringify(prompt, null, 2));
|
||||||
const mapping = mappingFromGraph(selectedWorkflow.graph);
|
const mapping = mappingFromGraph(selectedWorkflow.graph);
|
||||||
setComfyNodeMapping(JSON.stringify(mapping || inferMappingFromPrompt(prompt), null, 2));
|
setComfyNodeMapping(JSON.stringify(mapping || inferMappingFromPrompt(prompt), null, 2));
|
||||||
}, [selectedWorkflow?.publicId, videoBackend]);
|
}, [selectedWorkflow?.publicId, videoBackend, useBuiltinCloudTemplate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!jobs.some((job) => !terminal(job.status))) return;
|
if (!jobs.some((job) => !terminal(job.status))) return;
|
||||||
const timer = window.setInterval(() => {
|
const timer = globalThis.setInterval(() => {
|
||||||
client.getMoneyPrinterJobs({ limit: 30 })
|
client.getMoneyPrinterJobs({ limit: 30 })
|
||||||
.then((payload) => setJobs(payload.jobs || []))
|
.then((payload) => setJobs(payload.jobs || []))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, 5000);
|
}, 5000);
|
||||||
return () => window.clearInterval(timer);
|
return () => globalThis.clearInterval(timer);
|
||||||
}, [client, jobs]);
|
}, [client, jobs]);
|
||||||
|
|
||||||
|
const refreshNow = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await refresh();
|
||||||
|
await loadWorkflows();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || '短视频任务刷新失败');
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyPreset = (preset: typeof TOPIC_PRESETS[number]) => {
|
||||||
|
setTopic(preset.topic);
|
||||||
|
setAudienceText(preset.audience);
|
||||||
|
setStyle(preset.style);
|
||||||
|
setRequirements(preset.requirements);
|
||||||
|
setDuration(preset.duration);
|
||||||
|
setRatio(preset.ratio);
|
||||||
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!topic.trim() || submitting) return;
|
if (!topic.trim() || submitting) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
|
if (videoBackend === 'comfy' && !comfyHasTemplate) {
|
||||||
|
throw new Error(isCloudComfy ? '请选择 Cloud 内置模板或一个真实视频工作流模板' : '当前 Comfy 后端需要选择一个真实视频工作流模板');
|
||||||
|
}
|
||||||
|
const nextDuration = useBuiltinCloudTemplate
|
||||||
|
? Math.max(cloudDurationMin, Math.min(cloudDurationMax, Number(duration) || cloudDefaultDuration))
|
||||||
|
: duration;
|
||||||
|
const nextResolution = useBuiltinCloudTemplate && !cloudResolutions.includes(resolution)
|
||||||
|
? DEFAULT_BUILTIN_CLOUD_RESOLUTION
|
||||||
|
: resolution;
|
||||||
|
const nextRatio = useBuiltinCloudTemplate && !cloudRatios.includes(ratio) ? '9:16' : ratio;
|
||||||
const payload = await client.createMoneyPrinterJob({
|
const payload = await client.createMoneyPrinterJob({
|
||||||
topic: topic.trim(),
|
topic: topic.trim(),
|
||||||
scriptModel: scriptModel.trim(),
|
scriptModel: scriptModel.trim(),
|
||||||
@@ -285,15 +536,15 @@ export default function MoneyPrinter() {
|
|||||||
videoModel: videoModel.trim(),
|
videoModel: videoModel.trim(),
|
||||||
style,
|
style,
|
||||||
audience: audienceText,
|
audience: audienceText,
|
||||||
duration,
|
duration: nextDuration,
|
||||||
ratio,
|
ratio: nextRatio,
|
||||||
resolution,
|
resolution: nextResolution,
|
||||||
requirements,
|
requirements,
|
||||||
language: '中文',
|
language: '中文',
|
||||||
...(videoBackend === 'comfy' ? {
|
...(videoBackend === 'comfy' ? {
|
||||||
templateId: templateId || undefined,
|
templateId: useBuiltinCloudTemplate ? undefined : templateId,
|
||||||
comfyPrompt: comfyPrompt.trim() ? JSON.parse(comfyPrompt) : undefined,
|
comfyPrompt: useBuiltinCloudTemplate ? undefined : parseJsonInput(comfyPrompt, 'Comfy prompt JSON'),
|
||||||
comfyNodeMapping: comfyNodeMapping.trim() ? JSON.parse(comfyNodeMapping) : undefined,
|
comfyNodeMapping: parseJsonInput(comfyNodeMapping, '节点映射 JSON'),
|
||||||
} : {}),
|
} : {}),
|
||||||
});
|
});
|
||||||
setJobs((prev) => [payload.job, ...prev.filter((item) => item.id !== payload.job.id)]);
|
setJobs((prev) => [payload.job, ...prev.filter((item) => item.id !== payload.job.id)]);
|
||||||
@@ -305,13 +556,30 @@ export default function MoneyPrinter() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const cancel = async (id: string) => {
|
const cancel = async (id: string) => {
|
||||||
|
setCancellingIds((prev) => prev.includes(id) ? prev : [...prev, id]);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
const payload = await client.cancelMoneyPrinterJob(id);
|
const payload = await client.cancelMoneyPrinterJob(id);
|
||||||
setJobs((prev) => prev.map((item) => item.id === id ? payload.job : item));
|
setJobs((prev) => prev.map((item) => item.id === id ? payload.job : item));
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || '取消短视频任务失败');
|
||||||
|
} finally {
|
||||||
|
setCancellingIds((prev) => prev.filter((item) => item !== id));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const clear = async () => {
|
const clear = async () => {
|
||||||
|
setClearing(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
await client.clearMoneyPrinterJobs();
|
await client.clearMoneyPrinterJobs();
|
||||||
await loadJobs();
|
await loadJobs();
|
||||||
|
setJobFilter('all');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || '清空短视频任务失败');
|
||||||
|
} finally {
|
||||||
|
setClearing(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const retry = (job: MoneyPrinterJob) => {
|
const retry = (job: MoneyPrinterJob) => {
|
||||||
@@ -329,31 +597,102 @@ export default function MoneyPrinter() {
|
|||||||
setComfyPrompt(request.comfyPrompt ? JSON.stringify(request.comfyPrompt, null, 2) : comfyPrompt);
|
setComfyPrompt(request.comfyPrompt ? JSON.stringify(request.comfyPrompt, null, 2) : comfyPrompt);
|
||||||
setComfyNodeMapping(request.comfyNodeMapping ? JSON.stringify(request.comfyNodeMapping, null, 2) : comfyNodeMapping);
|
setComfyNodeMapping(request.comfyNodeMapping ? JSON.stringify(request.comfyNodeMapping, null, 2) : comfyNodeMapping);
|
||||||
setTemplateId(request.templateId || templateId);
|
setTemplateId(request.templateId || templateId);
|
||||||
setUseAdvancedComfy(Boolean(request.templateId));
|
setShowAdvancedComfy(Boolean(request.comfyPrompt || request.comfyNodeMapping));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="money-printer-page animate-fade-in">
|
<div className="money-printer-page animate-fade-in">
|
||||||
<div className="page-header">
|
<section className="console-hero money-printer-hero-shell">
|
||||||
<div>
|
<div className="console-hero-copy">
|
||||||
<h1 className="page-title">短视频工厂</h1>
|
<span className="console-kicker">Short Video Factory</span>
|
||||||
<p className="page-subtitle">内置 MoneyPrinterTurbo 思路:主题生成脚本和分镜,再用 BossAI 视频模型生产成片。</p>
|
<h1>短视频工厂</h1>
|
||||||
|
<p>把 MoneyPrinterTurbo 的生产思路接入 BossAI:主题生成脚本和分镜,再交给 BoosAPI 或 Comfy API 模板生产成片。</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="page-actions">
|
<div className="console-hero-rail" aria-label="短视频工厂摘要">
|
||||||
<button type="button" className="btn btn-ghost" onClick={() => void loadJobs()}>刷新</button>
|
<div>
|
||||||
<button type="button" className="btn btn-primary" onClick={submit} disabled={submitting || !topic.trim()}>
|
<span>后端</span>
|
||||||
|
<strong>{videoBackend === 'comfy' ? comfyBackendLabel : 'BoosAPI'}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>队列</span>
|
||||||
|
<strong>{runningJobs.length}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>归档</span>
|
||||||
|
<strong>{archivedAssetCount}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="console-hero-actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => void refreshNow()} disabled={refreshing}>
|
||||||
|
{refreshing ? '刷新中...' : '刷新'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={submit} disabled={!canSubmit}>
|
||||||
{submitting ? '提交中...' : '生成短视频'}
|
{submitting ? '提交中...' : '生成短视频'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
|
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
|
||||||
|
|
||||||
|
<div className="money-printer-summary">
|
||||||
|
<div className="money-printer-summary-card">
|
||||||
|
<span>当前后端</span>
|
||||||
|
<strong>{videoBackend === 'comfy' ? comfyBackendLabel : 'BoosAPI 视频接口'}</strong>
|
||||||
|
<em>{status?.keyName ? `使用 ${status.keyName}` : '等待状态同步'}</em>
|
||||||
|
</div>
|
||||||
|
<div className="money-printer-summary-card">
|
||||||
|
<span>视频模板</span>
|
||||||
|
<strong>{videoBackend === 'comfy' ? currentTemplateLabel : videoModel || '默认模型'}</strong>
|
||||||
|
<em>{videoBackend === 'comfy' ? `${videoTemplateCount} 个视频模板可选` : '直接调用 /v1/videos'}</em>
|
||||||
|
</div>
|
||||||
|
<div className="money-printer-summary-card">
|
||||||
|
<span>队列</span>
|
||||||
|
<strong>{runningJobs.length} 个进行中</strong>
|
||||||
|
<em>{failedJobs.length > 0 ? `${failedJobs.length} 个失败待处理` : '队列状态正常'}</em>
|
||||||
|
</div>
|
||||||
|
<div className="money-printer-summary-card">
|
||||||
|
<span>产出归档</span>
|
||||||
|
<strong>{archivedAssetCount} 个文件</strong>
|
||||||
|
<em>完成后进入工作空间</em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="money-printer-layout">
|
<div className="money-printer-layout">
|
||||||
<section className="money-printer-panel">
|
<section className="money-printer-panel">
|
||||||
<div className="money-printer-hero">
|
<div className="money-printer-hero">
|
||||||
<span>MoneyPrinterTurbo inside</span>
|
<span>MoneyPrinterTurbo inside</span>
|
||||||
<strong>一句主题,自动拆成脚本、分镜、视频提示词和成片任务。</strong>
|
<strong>一句主题,自动拆成脚本、分镜、视频提示词,再提交到 Comfy API 生成成片。</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="money-printer-pipeline" aria-label="短视频生成流程">
|
||||||
|
{PIPELINE_STEPS.map((step, index) => (
|
||||||
|
<div
|
||||||
|
key={step}
|
||||||
|
className={[
|
||||||
|
'money-printer-pipeline-step',
|
||||||
|
activePipelineStep >= index ? 'is-active' : '',
|
||||||
|
activePipelineStep === index ? 'is-current' : '',
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
|
>
|
||||||
|
<span>{index + 1}</span>
|
||||||
|
<strong>{step}</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="money-printer-section-head money-printer-section-head-compact">
|
||||||
|
<div>
|
||||||
|
<h2>快捷选题</h2>
|
||||||
|
<p>先套一个视频方向,再微调主题和要求。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="money-printer-presets">
|
||||||
|
{TOPIC_PRESETS.map((preset) => (
|
||||||
|
<button key={preset.title} type="button" className="money-printer-preset-button" onClick={() => applyPreset(preset)}>
|
||||||
|
<strong>{preset.title}</strong>
|
||||||
|
<span>{preset.desc}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="money-printer-field">
|
<label className="money-printer-field">
|
||||||
@@ -375,26 +714,27 @@ export default function MoneyPrinter() {
|
|||||||
</label>
|
</label>
|
||||||
<label className="money-printer-field">
|
<label className="money-printer-field">
|
||||||
<span>视频模型</span>
|
<span>视频模型</span>
|
||||||
<input value={videoModel} onChange={(event) => setVideoModel(event.target.value)} />
|
<input
|
||||||
|
value={videoModel}
|
||||||
|
onChange={(event) => setVideoModel(event.target.value)}
|
||||||
|
disabled={videoBackend === 'comfy'}
|
||||||
|
title={videoBackend === 'comfy' ? 'Comfy API 模式使用所选工作流模板里的模型' : undefined}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="money-printer-field">
|
<label className="money-printer-field">
|
||||||
<span>时长</span>
|
<span>时长</span>
|
||||||
<input type="number" min={5} max={120} value={duration} onChange={(event) => setDuration(Number(event.target.value || 30))} />
|
<input type="number" min={durationMin} max={durationMax} value={duration} onChange={(event) => setDuration(Number(event.target.value || durationMin))} />
|
||||||
</label>
|
</label>
|
||||||
<label className="money-printer-field">
|
<label className="money-printer-field">
|
||||||
<span>画幅</span>
|
<span>画幅</span>
|
||||||
<select value={ratio} onChange={(event) => setRatio(event.target.value)}>
|
<select value={ratio} onChange={(event) => setRatio(event.target.value)}>
|
||||||
<option value="9:16">9:16 竖屏</option>
|
{ratioOptions.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||||
<option value="16:9">16:9 横屏</option>
|
|
||||||
<option value="1:1">1:1 方屏</option>
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="money-printer-field">
|
<label className="money-printer-field">
|
||||||
<span>分辨率</span>
|
<span>分辨率</span>
|
||||||
<select value={resolution} onChange={(event) => setResolution(event.target.value)}>
|
<select value={resolution} onChange={(event) => setResolution(event.target.value)}>
|
||||||
<option value="1080p">1080p</option>
|
{resolutionOptions.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||||
<option value="720p">720p</option>
|
|
||||||
<option value="480p">480p</option>
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -427,20 +767,47 @@ export default function MoneyPrinter() {
|
|||||||
<label className="money-printer-field">
|
<label className="money-printer-field">
|
||||||
<span>工作流模板</span>
|
<span>工作流模板</span>
|
||||||
<select value={templateId} onChange={(event) => setTemplateId(event.target.value)}>
|
<select value={templateId} onChange={(event) => setTemplateId(event.target.value)}>
|
||||||
<option value="">请选择模板</option>
|
{isCloudComfy && <option value={BUILTIN_CLOUD_TEMPLATE_VALUE}>内置 Cloud Seedance 文生视频</option>}
|
||||||
|
{!isCloudComfy && <option value="">选择真实 Comfy 视频模板</option>}
|
||||||
{workflows.map((workflow) => (
|
{workflows.map((workflow) => (
|
||||||
<option key={workflow.publicId} value={workflow.publicId}>
|
<option key={workflow.publicId} value={workflow.publicId}>
|
||||||
{workflowOptionLabel(workflow)}
|
{workflowLooksVideoCapable(workflow) ? '视频 · ' : '模板 · '}{workflowOptionLabel(workflow)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{selectedWorkflow ? (
|
{useBuiltinCloudTemplate ? (
|
||||||
|
<div className="money-printer-template-meta">
|
||||||
|
<strong>内置 Cloud Seedance 文生视频</strong>
|
||||||
|
<span>
|
||||||
|
当前后端为 Cloud,使用官方 {status?.builtinCloudTemplate?.nodeType || 'ByteDanceTextToVideoNode'}
|
||||||
|
{status?.builtinCloudTemplate?.model ? ` / ${status.builtinCloudTemplate.model}` : ''}。时长 {cloudDurationMin}-{cloudDurationMax} 秒,若账号无余额会直接显示余额不足。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : selectedWorkflow ? (
|
||||||
<div className="money-printer-template-meta">
|
<div className="money-printer-template-meta">
|
||||||
<strong>{selectedWorkflow.name}</strong>
|
<strong>{selectedWorkflow.name}</strong>
|
||||||
<span>{mappingFromGraph(selectedWorkflow.graph) ? '已带节点映射' : '这个模板没有内置节点映射,系统会尝试自动推断。'}</span>
|
<span>
|
||||||
|
{workflowLooksVideoCapable(selectedWorkflow) ? '已识别为视频工作流' : '未识别视频关键词,请确认这是 Comfy 视频模板'}
|
||||||
|
{' · '}
|
||||||
|
{mappingFromGraph(selectedWorkflow.graph) ? '已带节点映射' : '系统会按节点结构自动推断提示词和参数映射'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : (
|
||||||
|
<div className="money-printer-template-meta is-warning">
|
||||||
|
<strong>需要视频工作流模板</strong>
|
||||||
|
<span>{isCloudComfy ? '可以使用内置 Cloud 模板,也可以选择模板库里的真实视频工作流。' : '当前不是 Cloud 后端,内置 Seedance 模板不可用,请选择 external/runninghub 可执行的视频模板。'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost money-printer-advanced-toggle"
|
||||||
|
onClick={() => setShowAdvancedComfy((prev) => !prev)}
|
||||||
|
>
|
||||||
|
{showAdvancedComfy ? '收起模板调试' : '展开模板调试'}
|
||||||
|
</button>
|
||||||
|
<div className={`anim-collapse ${showAdvancedComfy ? 'is-open' : ''}`.trim()}>
|
||||||
|
<div className="anim-collapse-inner">
|
||||||
<label className="money-printer-field">
|
<label className="money-printer-field">
|
||||||
<span>Comfy prompt JSON</span>
|
<span>Comfy prompt JSON</span>
|
||||||
<textarea value={comfyPrompt} onChange={(event) => setComfyPrompt(event.target.value)} rows={7} placeholder='{"12":{"class_type":"...","inputs":{...}}}' />
|
<textarea value={comfyPrompt} onChange={(event) => setComfyPrompt(event.target.value)} rows={7} placeholder='{"12":{"class_type":"...","inputs":{...}}}' />
|
||||||
@@ -450,6 +817,8 @@ export default function MoneyPrinter() {
|
|||||||
<textarea value={comfyNodeMapping} onChange={(event) => setComfyNodeMapping(event.target.value)} rows={7} />
|
<textarea value={comfyNodeMapping} onChange={(event) => setComfyNodeMapping(event.target.value)} rows={7} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -459,30 +828,60 @@ export default function MoneyPrinter() {
|
|||||||
<h2>任务队列</h2>
|
<h2>任务队列</h2>
|
||||||
<p>脚本、视频生成和工作空间归档都会在后台执行。</p>
|
<p>脚本、视频生成和工作空间归档都会在后台执行。</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-ghost" onClick={() => void clear()}>清空已完成</button>
|
<button type="button" className="btn btn-ghost" onClick={() => void clear()} disabled={clearing}>
|
||||||
|
{clearing ? '清空中...' : '清空已完成'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="money-printer-filter-bar" role="tablist" aria-label="任务状态筛选">
|
||||||
|
{filterOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.key}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={jobFilter === option.key}
|
||||||
|
className={jobFilter === option.key ? 'is-active' : ''}
|
||||||
|
onClick={() => setJobFilter(option.key)}
|
||||||
|
>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
<strong>{option.count}</strong>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="money-printer-job-list">
|
<div className="money-printer-job-list">
|
||||||
{jobs.length === 0 ? (
|
{jobs.length === 0 ? (
|
||||||
<div className="empty-state" style={{ minHeight: 220 }}>
|
<div className="empty-state" style={{ minHeight: 220 }}>
|
||||||
<div className="empty-state-title">暂无短视频任务</div>
|
<div className="empty-state-title">暂无短视频任务</div>
|
||||||
<div className="empty-state-desc">提交主题后,系统会自动调用 BossAI 内置模型生成脚本和视频。</div>
|
<div className="empty-state-desc">提交主题后,系统会自动调用 BoosAPI 内置模型生成脚本和视频。</div>
|
||||||
</div>
|
</div>
|
||||||
) : jobs.map((job) => {
|
) : filteredJobs.length === 0 ? (
|
||||||
|
<div className="empty-state" style={{ minHeight: 180 }}>
|
||||||
|
<div className="empty-state-title">当前筛选没有任务</div>
|
||||||
|
<div className="empty-state-desc">切换到“全部”可以查看最近 30 条短视频任务。</div>
|
||||||
|
</div>
|
||||||
|
) : filteredJobs.map((job) => {
|
||||||
const percent = progress(job);
|
const percent = progress(job);
|
||||||
const assets = jobAssets(job);
|
const assets = jobAssets(job);
|
||||||
|
const spec = jobSpecLabel(job);
|
||||||
|
const template = jobTemplateLabel(job, workflows);
|
||||||
|
const cancelling = cancellingIds.includes(job.id);
|
||||||
return (
|
return (
|
||||||
<article key={job.id} className="money-printer-job-card">
|
<article key={job.id} className={`money-printer-job-card is-${statusTone(job.status)}`}>
|
||||||
<div className="money-printer-job-head">
|
<div className="money-printer-job-head">
|
||||||
<div>
|
<div>
|
||||||
<strong>{job.title || '短视频工厂任务'}</strong>
|
<strong>{job.title || '短视频工厂任务'}</strong>
|
||||||
<span>{job.message || statusLabel(job.status)}{formatTime(job.createdAt) ? ` · ${formatTime(job.createdAt)}` : ''}</span>
|
<span>{job.message || statusLabel(job.status)}{formatTime(job.createdAt) ? ` · ${formatTime(job.createdAt)}` : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
<em>{statusLabel(job.status)}</em>
|
<em className={`money-printer-status-pill is-${statusTone(job.status)}`}>{statusLabel(job.status)}</em>
|
||||||
</div>
|
</div>
|
||||||
<div className="money-printer-progress"><span style={{ width: `${percent}%` }} /></div>
|
<div className="money-printer-progress"><span style={{ width: `${percent}%` }} /></div>
|
||||||
<div className="money-printer-job-meta">
|
<div className="money-printer-job-meta">
|
||||||
<span>进度 {percent}%</span>
|
<span>进度 {percent}%</span>
|
||||||
|
<span>{jobBackendLabel(job)}</span>
|
||||||
|
<span>{jobModelLabel(job)}</span>
|
||||||
|
{template && <span>{template}</span>}
|
||||||
|
{spec && <span>{spec}</span>}
|
||||||
{job.result?.videoUrl && <a href={job.result.videoUrl} target="_blank" rel="noreferrer">远端视频</a>}
|
{job.result?.videoUrl && <a href={job.result.videoUrl} target="_blank" rel="noreferrer">远端视频</a>}
|
||||||
</div>
|
</div>
|
||||||
{planValue(job, 'hook') && <p className="money-printer-hook">{planValue(job, 'hook')}</p>}
|
{planValue(job, 'hook') && <p className="money-printer-hook">{planValue(job, 'hook')}</p>}
|
||||||
@@ -491,13 +890,17 @@ export default function MoneyPrinter() {
|
|||||||
<div className="money-printer-assets">
|
<div className="money-printer-assets">
|
||||||
{assets.map((asset: any) => (
|
{assets.map((asset: any) => (
|
||||||
<a key={asset.publicId || asset.remoteUrl} href={`${workspacePath}?q=${encodeURIComponent(asset.publicId || '')}`}>
|
<a key={asset.publicId || asset.remoteUrl} href={`${workspacePath}?q=${encodeURIComponent(asset.publicId || '')}`}>
|
||||||
成片 · {asset.publicId || asset.status}
|
工作空间 · {asset.publicId || asset.status}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="money-printer-job-buttons">
|
<div className="money-printer-job-buttons">
|
||||||
{!terminal(job.status) && <button type="button" className="btn btn-ghost" onClick={() => void cancel(job.id)}>取消</button>}
|
{!terminal(job.status) && (
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => void cancel(job.id)} disabled={cancelling}>
|
||||||
|
{cancelling ? '取消中...' : '取消任务'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{terminal(job.status) && <button type="button" className="btn btn-ghost" onClick={() => retry(job)}>复用参数</button>}
|
{terminal(job.status) && <button type="button" className="btn btn-ghost" onClick={() => retry(job)}>复用参数</button>}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export default function Monitors() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="page-title">{tr('监控内嵌')}</h2>
|
<h2 className="page-title">{tr('监控内嵌')}</h2>
|
||||||
<div style={{ marginTop: 6, fontSize: 13, color: 'var(--color-text-muted)' }}>
|
<div style={{ marginTop: 6, fontSize: 13, color: 'var(--color-text-muted)' }}>
|
||||||
在 BossAI 内查看外部站点监控页面。
|
在 BoosAPI 内查看外部站点监控页面。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ describe('NotificationSettings', () => {
|
|||||||
barkUrl: '',
|
barkUrl: '',
|
||||||
webhookEnabled: true,
|
webhookEnabled: true,
|
||||||
barkEnabled: true,
|
barkEnabled: true,
|
||||||
|
feishuEnabled: false,
|
||||||
|
feishuWebhookMasked: '',
|
||||||
serverChanEnabled: false,
|
serverChanEnabled: false,
|
||||||
telegramEnabled: true,
|
telegramEnabled: true,
|
||||||
telegramApiBaseUrl: 'https://tg-proxy.example.com',
|
telegramApiBaseUrl: 'https://tg-proxy.example.com',
|
||||||
@@ -123,6 +125,8 @@ describe('NotificationSettings', () => {
|
|||||||
barkUrl: '',
|
barkUrl: '',
|
||||||
webhookEnabled: true,
|
webhookEnabled: true,
|
||||||
barkEnabled: true,
|
barkEnabled: true,
|
||||||
|
feishuEnabled: false,
|
||||||
|
feishuWebhookMasked: '',
|
||||||
serverChanEnabled: false,
|
serverChanEnabled: false,
|
||||||
telegramEnabled: true,
|
telegramEnabled: true,
|
||||||
telegramApiBaseUrl: 'https://api.telegram.org',
|
telegramApiBaseUrl: 'https://api.telegram.org',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { api, type RuntimeSettingsPayload } from '../api.js';
|
import { api, type RuntimeSettingsPayload } from '../api.js';
|
||||||
|
import PageHeader from '../components/PageHeader.js';
|
||||||
import { useToast } from '../components/Toast.js';
|
import { useToast } from '../components/Toast.js';
|
||||||
import { tr } from '../i18n.js';
|
import { tr } from '../i18n.js';
|
||||||
|
|
||||||
@@ -8,6 +9,8 @@ type RuntimeSettings = {
|
|||||||
barkUrl: string;
|
barkUrl: string;
|
||||||
webhookEnabled: boolean;
|
webhookEnabled: boolean;
|
||||||
barkEnabled: boolean;
|
barkEnabled: boolean;
|
||||||
|
feishuEnabled: boolean;
|
||||||
|
feishuWebhookMasked?: string;
|
||||||
serverChanEnabled: boolean;
|
serverChanEnabled: boolean;
|
||||||
telegramEnabled: boolean;
|
telegramEnabled: boolean;
|
||||||
telegramApiBaseUrl: string;
|
telegramApiBaseUrl: string;
|
||||||
@@ -33,6 +36,7 @@ export default function NotificationSettings() {
|
|||||||
barkUrl: '',
|
barkUrl: '',
|
||||||
webhookEnabled: true,
|
webhookEnabled: true,
|
||||||
barkEnabled: true,
|
barkEnabled: true,
|
||||||
|
feishuEnabled: false,
|
||||||
serverChanEnabled: false,
|
serverChanEnabled: false,
|
||||||
telegramEnabled: false,
|
telegramEnabled: false,
|
||||||
telegramApiBaseUrl: 'https://api.telegram.org',
|
telegramApiBaseUrl: 'https://api.telegram.org',
|
||||||
@@ -50,6 +54,7 @@ export default function NotificationSettings() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [serverChanKey, setServerChanKey] = useState('');
|
const [serverChanKey, setServerChanKey] = useState('');
|
||||||
|
const [feishuWebhook, setFeishuWebhook] = useState('');
|
||||||
const [telegramBotToken, setTelegramBotToken] = useState('');
|
const [telegramBotToken, setTelegramBotToken] = useState('');
|
||||||
const [smtpPass, setSmtpPass] = useState('');
|
const [smtpPass, setSmtpPass] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -78,6 +83,8 @@ export default function NotificationSettings() {
|
|||||||
barkUrl: runtimeInfo.barkUrl || '',
|
barkUrl: runtimeInfo.barkUrl || '',
|
||||||
webhookEnabled: runtimeInfo.webhookEnabled ?? true,
|
webhookEnabled: runtimeInfo.webhookEnabled ?? true,
|
||||||
barkEnabled: runtimeInfo.barkEnabled ?? true,
|
barkEnabled: runtimeInfo.barkEnabled ?? true,
|
||||||
|
feishuEnabled: !!runtimeInfo.feishuEnabled,
|
||||||
|
feishuWebhookMasked: runtimeInfo.feishuWebhookMasked || '',
|
||||||
serverChanEnabled: !!runtimeInfo.serverChanEnabled,
|
serverChanEnabled: !!runtimeInfo.serverChanEnabled,
|
||||||
telegramEnabled: !!runtimeInfo.telegramEnabled,
|
telegramEnabled: !!runtimeInfo.telegramEnabled,
|
||||||
telegramApiBaseUrl: runtimeInfo.telegramApiBaseUrl || 'https://api.telegram.org',
|
telegramApiBaseUrl: runtimeInfo.telegramApiBaseUrl || 'https://api.telegram.org',
|
||||||
@@ -117,6 +124,7 @@ export default function NotificationSettings() {
|
|||||||
barkUrl: runtime.barkUrl,
|
barkUrl: runtime.barkUrl,
|
||||||
webhookEnabled: runtime.webhookEnabled,
|
webhookEnabled: runtime.webhookEnabled,
|
||||||
barkEnabled: runtime.barkEnabled,
|
barkEnabled: runtime.barkEnabled,
|
||||||
|
feishuEnabled: runtime.feishuEnabled,
|
||||||
serverChanEnabled: runtime.serverChanEnabled,
|
serverChanEnabled: runtime.serverChanEnabled,
|
||||||
telegramEnabled: runtime.telegramEnabled,
|
telegramEnabled: runtime.telegramEnabled,
|
||||||
telegramApiBaseUrl: runtime.telegramApiBaseUrl,
|
telegramApiBaseUrl: runtime.telegramApiBaseUrl,
|
||||||
@@ -132,6 +140,7 @@ export default function NotificationSettings() {
|
|||||||
smtpTo: runtime.smtpTo,
|
smtpTo: runtime.smtpTo,
|
||||||
notifyCooldownSec: Math.max(0, Math.trunc(Number(runtime.notifyCooldownSec) || 0)),
|
notifyCooldownSec: Math.max(0, Math.trunc(Number(runtime.notifyCooldownSec) || 0)),
|
||||||
};
|
};
|
||||||
|
if (feishuWebhook.trim()) payload.feishuWebhook = feishuWebhook.trim();
|
||||||
if (serverChanKey.trim()) payload.serverChanKey = serverChanKey.trim();
|
if (serverChanKey.trim()) payload.serverChanKey = serverChanKey.trim();
|
||||||
if (telegramBotToken.trim()) payload.telegramBotToken = telegramBotToken.trim();
|
if (telegramBotToken.trim()) payload.telegramBotToken = telegramBotToken.trim();
|
||||||
if (smtpPass.trim()) payload.smtpPass = smtpPass.trim();
|
if (smtpPass.trim()) payload.smtpPass = smtpPass.trim();
|
||||||
@@ -140,9 +149,11 @@ export default function NotificationSettings() {
|
|||||||
setRuntime((prev) => ({
|
setRuntime((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
serverChanKeyMasked: res.serverChanKeyMasked || prev.serverChanKeyMasked,
|
serverChanKeyMasked: res.serverChanKeyMasked || prev.serverChanKeyMasked,
|
||||||
|
feishuWebhookMasked: res.feishuWebhookMasked || prev.feishuWebhookMasked,
|
||||||
telegramBotTokenMasked: res.telegramBotTokenMasked || prev.telegramBotTokenMasked,
|
telegramBotTokenMasked: res.telegramBotTokenMasked || prev.telegramBotTokenMasked,
|
||||||
smtpPassMasked: res.smtpPassMasked || prev.smtpPassMasked,
|
smtpPassMasked: res.smtpPassMasked || prev.smtpPassMasked,
|
||||||
}));
|
}));
|
||||||
|
setFeishuWebhook('');
|
||||||
setServerChanKey('');
|
setServerChanKey('');
|
||||||
setTelegramBotToken('');
|
setTelegramBotToken('');
|
||||||
setSmtpPass('');
|
setSmtpPass('');
|
||||||
@@ -168,7 +179,7 @@ export default function NotificationSettings() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in admin-ops-page notification-settings-page">
|
||||||
<div className="skeleton" style={{ width: 220, height: 28, marginBottom: 20 }} />
|
<div className="skeleton" style={{ width: 220, height: 28, marginBottom: 20 }} />
|
||||||
<div className="skeleton" style={{ width: '100%', height: 320, borderRadius: 'var(--radius-sm)' }} />
|
<div className="skeleton" style={{ width: '100%', height: 320, borderRadius: 'var(--radius-sm)' }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -176,23 +187,24 @@ export default function NotificationSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in" style={{ paddingBottom: 40 }}>
|
<div className="animate-fade-in admin-ops-page notification-settings-page" style={{ paddingBottom: 40 }}>
|
||||||
{/* 头部标题与操作 */}
|
<PageHeader
|
||||||
<div className="page-header">
|
title={tr('通知设置')}
|
||||||
<h2 className="page-title">{tr('通知设置')}</h2>
|
actions={(
|
||||||
<div className="page-actions">
|
<>
|
||||||
<button onClick={testNotify} disabled={testingNotify} className="btn btn-success">
|
<button onClick={testNotify} disabled={testingNotify} className="btn btn-success">
|
||||||
{testingNotify ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 发送中...</> : '发送测试通知'}
|
{testingNotify ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 发送中...</> : '发送测试通知'}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={saveNotify} disabled={savingNotify} className="btn btn-primary">
|
<button onClick={saveNotify} disabled={savingNotify} className="btn btn-primary">
|
||||||
{savingNotify ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存通知设置'}
|
{savingNotify ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存通知设置'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</>
|
||||||
</div>
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div style={{ maxWidth: 860, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
<div className="notification-settings-layout" style={{ maxWidth: 980, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
|
|
||||||
<div className="card animate-slide-up stagger-1" style={{ padding: 20 }}>
|
<div className="card notification-settings-card animate-slide-up stagger-1" style={{ padding: 20 }}>
|
||||||
<div style={{ fontWeight: 600, fontSize: 15, marginBottom: 8 }}>告警去噪与冷静期</div>
|
<div style={{ fontWeight: 600, fontSize: 15, marginBottom: 8 }}>告警去噪与冷静期</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
||||||
相同告警在冷静期内不会重复推送;冷静期结束后会自动合并重复条数。
|
相同告警在冷静期内不会重复推送;冷静期结束后会自动合并重复条数。
|
||||||
@@ -215,7 +227,7 @@ export default function NotificationSettings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 卡片:Webhook & Bark */}
|
{/* 卡片:Webhook & Bark */}
|
||||||
<div className="card animate-slide-up stagger-2" style={{ padding: 24, border: (runtime.webhookEnabled || runtime.barkEnabled) ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}>
|
<div className="card notification-channel-card animate-slide-up stagger-2" style={{ padding: 24, border: (runtime.webhookEnabled || runtime.barkEnabled) ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--color-primary-light)', color: 'var(--color-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--color-primary-light)', color: 'var(--color-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
@@ -223,7 +235,7 @@ export default function NotificationSettings() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 600, fontSize: 15 }}>Webhook & Bark</div>
|
<div style={{ fontWeight: 600, fontSize: 15 }}>Webhook & Bark</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>通过 HTTP URL 推送消息通知(自动识别企业微信、飞书格式)</div>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>通过 HTTP URL 或 Bark 推送消息通知</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -273,8 +285,47 @@ export default function NotificationSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 卡片:飞书 */}
|
||||||
|
<div className="card notification-channel-card animate-slide-up stagger-3" style={{ padding: 24, border: runtime.feishuEnabled ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'color-mix(in srgb, var(--color-info) 14%, var(--color-bg-card))', color: 'var(--color-info)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 5h14v10H8l-3 3V5zM9 9h6M9 12h4" /></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 15 }}>飞书机器人</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>管理员告警、任务状态和系统事件可发送到飞书群</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 500, color: runtime.feishuEnabled ? 'var(--color-primary)' : 'var(--color-text-muted)' }}>启用飞书</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
style={{ width: 16, height: 16, cursor: 'pointer' }}
|
||||||
|
checked={runtime.feishuEnabled}
|
||||||
|
onChange={(e) => setRuntime((prev) => ({ ...prev, feishuEnabled: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ opacity: runtime.feishuEnabled ? 1 : 0.6, transition: 'opacity 0.2s' }}>
|
||||||
|
<code style={{ display: 'block', padding: '10px 14px', background: 'var(--color-bg)', borderRadius: 'var(--radius-sm)', fontSize: 13, fontFamily: 'var(--font-mono)', color: 'var(--color-text-secondary)', border: '1px solid var(--color-border-light)', marginBottom: 10 }}>
|
||||||
|
当前配置: {runtime.feishuWebhookMasked || '未设置'}
|
||||||
|
</code>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={feishuWebhook}
|
||||||
|
onChange={(e) => setFeishuWebhook(e.target.value)}
|
||||||
|
placeholder="输入新的飞书机器人 Webhook(留空则不改)"
|
||||||
|
style={inputStyle}
|
||||||
|
disabled={!runtime.feishuEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 卡片:Server酱 */}
|
{/* 卡片:Server酱 */}
|
||||||
<div className="card animate-slide-up stagger-3" style={{ padding: 24, border: runtime.serverChanEnabled ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}>
|
<div className="card notification-channel-card animate-slide-up stagger-4" style={{ padding: 24, border: runtime.serverChanEnabled ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--color-warning-soft)', color: 'var(--color-warning)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--color-warning-soft)', color: 'var(--color-warning)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
@@ -313,7 +364,7 @@ export default function NotificationSettings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 卡片:Telegram */}
|
{/* 卡片:Telegram */}
|
||||||
<div className="card animate-slide-up stagger-4" style={{ padding: 24, border: runtime.telegramEnabled ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}>
|
<div className="card notification-channel-card animate-slide-up stagger-4" style={{ padding: 24, border: runtime.telegramEnabled ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--color-primary-light)', color: 'var(--color-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--color-primary-light)', color: 'var(--color-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
@@ -399,15 +450,15 @@ export default function NotificationSettings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 卡片:SMTP 邮件设置 */}
|
{/* 卡片:SMTP 邮件设置 */}
|
||||||
<div className="card animate-slide-up stagger-4" style={{ padding: 24, border: runtime.smtpEnabled ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}>
|
<div className="card notification-channel-card animate-slide-up stagger-4" style={{ padding: 24, border: runtime.smtpEnabled ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--color-primary-light)', color: 'var(--color-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--color-primary-light)', color: 'var(--color-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
|
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 600, fontSize: 15 }}>邮件服务 (SMTP)</div>
|
<div style={{ fontWeight: 600, fontSize: 15 }}>内置邮件服务 (SMTP)</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>通过电子邮件推送提醒</div>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>用于邮件通知,后续注册邮箱验证码会复用这里的发信配置</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2016,7 +2016,7 @@ export default function OAuthManagement() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container animate-fade-in">
|
<div className="page-container animate-fade-in admin-ops-page oauth-ops-page">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="page-title">OAuth 管理</h2>
|
<h2 className="page-title">OAuth 管理</h2>
|
||||||
@@ -2313,16 +2313,16 @@ export default function OAuthManagement() {
|
|||||||
|
|
||||||
{renderGuideCard(
|
{renderGuideCard(
|
||||||
'本地部署',
|
'本地部署',
|
||||||
'BossAI 和浏览器在同一台机器时,不需要 SSH 隧道。直接点击“连接”,在弹窗里完成授权即可。',
|
'BoosAPI 和浏览器在同一台机器时,不需要 SSH 隧道。直接点击“连接”,在弹窗里完成授权即可。',
|
||||||
<div className="oauth-guide-copy">
|
<div className="oauth-guide-copy">
|
||||||
如果浏览器能直接访问上面的 localhost 回调地址,授权完成后会自动回到 BossAI。
|
如果浏览器能直接访问上面的 localhost 回调地址,授权完成后会自动回到 BoosAPI。
|
||||||
</div>,
|
</div>,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeSession.instructions.sshTunnelCommand
|
{activeSession.instructions.sshTunnelCommand
|
||||||
? renderGuideCard(
|
? renderGuideCard(
|
||||||
'云端部署',
|
'云端部署',
|
||||||
'BossAI 部署在 VPS、容器或远程主机时,浏览器访问到的是你自己电脑的 localhost。先在本地开 SSH 隧道,再继续登录。',
|
'BoosAPI 部署在 VPS、容器或远程主机时,浏览器访问到的是你自己电脑的 localhost。先在本地开 SSH 隧道,再继续登录。',
|
||||||
<div className="oauth-guide-block-list">
|
<div className="oauth-guide-block-list">
|
||||||
<div className="oauth-guide-block-label">常规 SSH 隧道</div>
|
<div className="oauth-guide-block-label">常规 SSH 隧道</div>
|
||||||
{renderCodeBlock(activeSession.instructions.sshTunnelCommand)}
|
{renderCodeBlock(activeSession.instructions.sshTunnelCommand)}
|
||||||
|
|||||||
@@ -199,6 +199,12 @@ function buildSize(aspect: string, width: number, height: number) {
|
|||||||
return `${option.width}x${option.height}`;
|
return `${option.width}x${option.height}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function jobProgressPercent(job: PictureJob) {
|
||||||
|
const total = Math.max(1, Number(job.progressTotal || 4));
|
||||||
|
const progress = Math.min(total, Math.max(0, Number(job.progress || 0)));
|
||||||
|
return Math.round((progress / total) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
function templateTitle(prompt: string, postNumber?: number) {
|
function templateTitle(prompt: string, postNumber?: number) {
|
||||||
const clean = prompt.split(/\s+/).join(' ').trim();
|
const clean = prompt.split(/\s+/).join(' ').trim();
|
||||||
let title = Array.from(clean).slice(0, 18).join('');
|
let title = Array.from(clean).slice(0, 18).join('');
|
||||||
@@ -311,6 +317,7 @@ export default function PictureAgent() {
|
|||||||
|
|
||||||
const selectedImage = images.find((image) => image.id === selectedId) ?? images[0] ?? null;
|
const selectedImage = images.find((image) => image.id === selectedId) ?? images[0] ?? null;
|
||||||
const activeJobs = jobs.filter((job) => job.status === 'pending' || job.status === 'running');
|
const activeJobs = jobs.filter((job) => job.status === 'pending' || job.status === 'running');
|
||||||
|
const primaryActiveJob = activeJobs[0] ?? null;
|
||||||
const modeLabel = mode === 'edit' ? '图片编辑' : '文生图';
|
const modeLabel = mode === 'edit' ? '图片编辑' : '文生图';
|
||||||
const resultLabel = selectedImage?.mode === 'edit' ? '编辑结果' : '生成结果';
|
const resultLabel = selectedImage?.mode === 'edit' ? '编辑结果' : '生成结果';
|
||||||
const resolvedSize = buildSize(aspect, customWidth, customHeight);
|
const resolvedSize = buildSize(aspect, customWidth, customHeight);
|
||||||
@@ -381,7 +388,7 @@ export default function PictureAgent() {
|
|||||||
setStatus((current) => current || {
|
setStatus((current) => current || {
|
||||||
success: true,
|
success: true,
|
||||||
mode: 'local',
|
mode: 'local',
|
||||||
upstreamLabel: 'BossAI 本地图片路由',
|
upstreamLabel: 'BoosAPI 本地图片路由',
|
||||||
keyName: '加载密钥中',
|
keyName: '加载密钥中',
|
||||||
});
|
});
|
||||||
setModel((current) => current || 'gpt-image-2');
|
setModel((current) => current || 'gpt-image-2');
|
||||||
@@ -654,9 +661,7 @@ export default function PictureAgent() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderJobCard = (job: PictureJob) => {
|
const renderJobCard = (job: PictureJob) => {
|
||||||
const total = Math.max(1, Number(job.progressTotal || 4));
|
const percent = jobProgressPercent(job);
|
||||||
const progress = Math.min(total, Math.max(0, Number(job.progress || 0)));
|
|
||||||
const percent = Math.round((progress / total) * 100);
|
|
||||||
const hint = staleRunningHint(job);
|
const hint = staleRunningHint(job);
|
||||||
const isActive = job.status === 'pending' || job.status === 'running';
|
const isActive = job.status === 'pending' || job.status === 'running';
|
||||||
const isFinished = !isActive;
|
const isFinished = !isActive;
|
||||||
@@ -721,7 +726,7 @@ export default function PictureAgent() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">生图助手</h1>
|
<h1 className="page-title">生图助手</h1>
|
||||||
<p className="page-subtitle">
|
<p className="page-subtitle">
|
||||||
{modeLabel} · {status?.upstreamLabel || 'BossAI 本地图片路由'} · {status?.keyName || '加载密钥中'}
|
{modeLabel} · {status?.upstreamLabel || 'BoosAPI 本地图片路由'} · {status?.keyName || '加载密钥中'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="page-actions">
|
<div className="page-actions">
|
||||||
@@ -864,12 +869,24 @@ export default function PictureAgent() {
|
|||||||
<div className="picture-agent-job-summary">
|
<div className="picture-agent-job-summary">
|
||||||
<div>
|
<div>
|
||||||
<strong>生图进度</strong>
|
<strong>生图进度</strong>
|
||||||
<span>{latestJobLabel} · {jobs.length} 条记录</span>
|
<span>{latestJobLabel} · {activeJobs.length} 个进行中 · {jobs.length} 条记录</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-ghost" onClick={() => setJobDialogOpen(true)}>
|
<button type="button" className="btn btn-ghost" onClick={() => setJobDialogOpen(true)}>
|
||||||
查看进度
|
查看进度
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeJobs.length > 0 ? (
|
||||||
|
<div className="picture-agent-active-stack">
|
||||||
|
<div className="picture-agent-active-head">
|
||||||
|
<strong>后台任务</strong>
|
||||||
|
<span>{activeJobs.length} 个生成中</span>
|
||||||
|
</div>
|
||||||
|
<div className="picture-agent-job-list">
|
||||||
|
{activeJobs.slice(0, 3).map(renderJobCard)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section className="picture-agent-canvas">
|
<section className="picture-agent-canvas">
|
||||||
@@ -917,6 +934,18 @@ export default function PictureAgent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="picture-agent-board">
|
<div className="picture-agent-board">
|
||||||
|
{primaryActiveJob ? (
|
||||||
|
<div className="picture-agent-board-progress">
|
||||||
|
<div>
|
||||||
|
<strong>{primaryActiveJob.status === 'pending' ? '任务已进入队列' : '正在生成图片'}</strong>
|
||||||
|
<span>{primaryActiveJob.message || primaryActiveJob.result?.prompt || prompt}</span>
|
||||||
|
</div>
|
||||||
|
<em>{jobProgressPercent(primaryActiveJob)}%</em>
|
||||||
|
<div className="picture-agent-board-progress-bar">
|
||||||
|
<span style={{ width: `${jobProgressPercent(primaryActiveJob)}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{selectedImage ? (
|
{selectedImage ? (
|
||||||
<>
|
<>
|
||||||
<div className="picture-agent-image-frame">
|
<div className="picture-agent-image-frame">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||||
|
import PageHeader from '../components/PageHeader.js';
|
||||||
import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.js';
|
import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.js';
|
||||||
import { useToast } from '../components/Toast.js';
|
import { useToast } from '../components/Toast.js';
|
||||||
import { useIsMobile } from '../components/useIsMobile.js';
|
import { useIsMobile } from '../components/useIsMobile.js';
|
||||||
@@ -30,6 +31,7 @@ const TYPE_OPTIONS = [
|
|||||||
{ value: 'proxy', label: '代理' },
|
{ value: 'proxy', label: '代理' },
|
||||||
{ value: 'status', label: '状态' },
|
{ value: 'status', label: '状态' },
|
||||||
{ value: 'site_notice', label: '站点公告' },
|
{ value: 'site_notice', label: '站点公告' },
|
||||||
|
{ value: 'runtime', label: '运行日志' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function levelLabel(level: string) {
|
function levelLabel(level: string) {
|
||||||
@@ -184,9 +186,10 @@ export default function ProgramLogs() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
<div className="page-header">
|
<PageHeader
|
||||||
<h2 className="page-title">{tr('程序日志')}</h2>
|
title={tr('程序日志')}
|
||||||
<div className="page-actions">
|
actions={(
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => load(true)}
|
onClick={() => load(true)}
|
||||||
disabled={refreshing}
|
disabled={refreshing}
|
||||||
@@ -210,8 +213,9 @@ export default function ProgramLogs() {
|
|||||||
>
|
>
|
||||||
{clearing ? <><span className="spinner spinner-sm" /> 清空中...</> : '清空日志'}
|
{clearing ? <><span className="spinner spinner-sm" /> 清空中...</> : '清空日志'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</>
|
||||||
</div>
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<ResponsiveFilterPanel
|
<ResponsiveFilterPanel
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
|||||||
@@ -520,7 +520,7 @@ describe('ProxyLogs server-driven page', () => {
|
|||||||
});
|
});
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
|
||||||
expect(globalThis.localStorage.setItem).toHaveBeenCalledWith('metapi.proxyLogs.debugTracePanelExpanded', 'false');
|
expect(globalThis.localStorage.setItem).toHaveBeenCalledWith('boosapi.proxyLogs.debugTracePanelExpanded', 'false');
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
@@ -547,7 +547,7 @@ describe('ProxyLogs server-driven page', () => {
|
|||||||
&& node.props['data-debug-trace-panel-body'] === true
|
&& node.props['data-debug-trace-panel-body'] === true
|
||||||
));
|
));
|
||||||
|
|
||||||
expect(globalThis.localStorage.getItem).toHaveBeenCalledWith('metapi.proxyLogs.debugTracePanelExpanded');
|
expect(globalThis.localStorage.getItem).toHaveBeenCalledWith('boosapi.proxyLogs.debugTracePanelExpanded');
|
||||||
expect(restoredToggleButton.props['aria-expanded']).toBe(false);
|
expect(restoredToggleButton.props['aria-expanded']).toBe(false);
|
||||||
expect(String(restoredPanelBody.props.className || '')).not.toContain('is-open');
|
expect(String(restoredPanelBody.props.className || '')).not.toContain('is-open');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -276,6 +276,88 @@ function formatLatency(ms?: number | null) {
|
|||||||
return `${ms}ms`;
|
return `${ms}ms`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderContentSummary(log: ProxyLogRenderItem) {
|
||||||
|
const summary = log.contentSummary?.trim();
|
||||||
|
if (!summary) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1.45,
|
||||||
|
maxWidth: 360,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
display: "-webkit-box",
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: "vertical",
|
||||||
|
}}
|
||||||
|
title={summary}
|
||||||
|
>
|
||||||
|
内容:{summary}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContentDetailBlock(log: ProxyLogRenderItem) {
|
||||||
|
const sections = [
|
||||||
|
{ label: "请求内容", value: log.requestContent?.trim() },
|
||||||
|
{ label: "响应内容", value: log.responseContent?.trim() },
|
||||||
|
].filter(
|
||||||
|
(section): section is { label: string; value: string } =>
|
||||||
|
Boolean(section.value),
|
||||||
|
);
|
||||||
|
if (sections.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "grid", gap: 10, marginTop: 10 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
对话内容
|
||||||
|
</div>
|
||||||
|
{sections.map((section) => (
|
||||||
|
<div key={section.label} style={{ display: "grid", gap: 6 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{section.label}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-link"
|
||||||
|
style={{ fontSize: 11, padding: 0 }}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
void copyTextToClipboard(section.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre style={debugCodeBlockStyle}>{section.value}</pre>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function latencyColor(ms?: number | null) {
|
function latencyColor(ms?: number | null) {
|
||||||
if (typeof ms !== "number" || !Number.isFinite(ms)) {
|
if (typeof ms !== "number" || !Number.isFinite(ms)) {
|
||||||
return "var(--color-text-muted)";
|
return "var(--color-text-muted)";
|
||||||
@@ -2023,7 +2105,7 @@ export default function ProxyLogs() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in admin-ops-page proxy-logs-page">
|
||||||
<div className="page-header" style={{ marginBottom: 16 }}>
|
<div className="page-header" style={{ marginBottom: 16 }}>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="page-title">{tr("使用日志")}</h2>
|
<h2 className="page-title">{tr("使用日志")}</h2>
|
||||||
@@ -2628,6 +2710,7 @@ export default function ProxyLogs() {
|
|||||||
const firstByteLabel = formatFirstByteLabel(
|
const firstByteLabel = formatFirstByteLabel(
|
||||||
detailLog.firstByteLatencyMs,
|
detailLog.firstByteLatencyMs,
|
||||||
);
|
);
|
||||||
|
const contentDetailBlock = renderContentDetailBlock(detailLog);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileCard
|
<MobileCard
|
||||||
@@ -2731,6 +2814,7 @@ export default function ProxyLogs() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{renderContentSummary(detailLog)}
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<div className="mobile-card-extra">
|
<div className="mobile-card-extra">
|
||||||
<MobileField
|
<MobileField
|
||||||
@@ -2811,6 +2895,7 @@ export default function ProxyLogs() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{contentDetailBlock}
|
||||||
{detail && pathMeta.errorMessage.trim().length > 0 && (
|
{detail && pathMeta.errorMessage.trim().length > 0 && (
|
||||||
<div style={{ color: "var(--color-danger)" }}>
|
<div style={{ color: "var(--color-danger)" }}>
|
||||||
{pathMeta.errorMessage}
|
{pathMeta.errorMessage}
|
||||||
@@ -2863,6 +2948,7 @@ export default function ProxyLogs() {
|
|||||||
const firstByteLabel = formatFirstByteLabel(
|
const firstByteLabel = formatFirstByteLabel(
|
||||||
detailLog.firstByteLatencyMs,
|
detailLog.firstByteLatencyMs,
|
||||||
);
|
);
|
||||||
|
const contentDetailBlock = renderContentDetailBlock(detailLog);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={log.id}>
|
<React.Fragment key={log.id}>
|
||||||
@@ -2968,6 +3054,7 @@ export default function ProxyLogs() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{renderContentSummary(detailLog)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
@@ -3426,6 +3513,8 @@ export default function ProxyLogs() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{contentDetailBlock}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|||||||
+402
-60
@@ -20,7 +20,6 @@ import {
|
|||||||
visualRulesToPayloadRules,
|
visualRulesToPayloadRules,
|
||||||
} from './settings/payloadRulesVisual.js';
|
} from './settings/payloadRulesVisual.js';
|
||||||
import { PAYLOAD_RULE_PROTOCOL_OPTIONS } from './settings/payloadRuleProtocolOptions.js';
|
import { PAYLOAD_RULE_PROTOCOL_OPTIONS } from './settings/payloadRuleProtocolOptions.js';
|
||||||
import UpdateCenterSection from './settings/UpdateCenterSection.js';
|
|
||||||
import {
|
import {
|
||||||
applyRoutingProfilePreset,
|
applyRoutingProfilePreset,
|
||||||
resolveRoutingProfilePreset,
|
resolveRoutingProfilePreset,
|
||||||
@@ -56,6 +55,19 @@ const CHECKIN_INTERVAL_OPTIONS = Array.from({ length: 24 }, (_, index) => {
|
|||||||
type DbDialect = 'sqlite' | 'mysql' | 'postgres';
|
type DbDialect = 'sqlite' | 'mysql' | 'postgres';
|
||||||
type RouteCooldownUnit = typeof ROUTE_COOLDOWN_UNIT_OPTIONS[number]['value'];
|
type RouteCooldownUnit = typeof ROUTE_COOLDOWN_UNIT_OPTIONS[number]['value'];
|
||||||
type SettingsPillTone = 'neutral' | 'primary' | 'danger' | 'warning';
|
type SettingsPillTone = 'neutral' | 'primary' | 'danger' | 'warning';
|
||||||
|
type SettingsNavItem = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
badge: string;
|
||||||
|
tone: SettingsPillTone;
|
||||||
|
};
|
||||||
|
type SettingsAttentionItem = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
href: string;
|
||||||
|
tone: SettingsPillTone;
|
||||||
|
};
|
||||||
type PayloadRulesEditorSectionKey = PayloadRuleAction;
|
type PayloadRulesEditorSectionKey = PayloadRuleAction;
|
||||||
type PayloadRulesEditorDrafts = Record<PayloadRulesEditorSectionKey, string>;
|
type PayloadRulesEditorDrafts = Record<PayloadRulesEditorSectionKey, string>;
|
||||||
|
|
||||||
@@ -574,6 +586,102 @@ export default function Settings() {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 10,
|
gap: 10,
|
||||||
};
|
};
|
||||||
|
const settingsModernSectionStyle: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 16,
|
||||||
|
scrollMarginTop: 96,
|
||||||
|
};
|
||||||
|
const settingsSummaryGridStyle: React.CSSProperties = {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: isMobile ? '1fr' : 'repeat(4, minmax(0, 1fr))',
|
||||||
|
gap: 12,
|
||||||
|
maxWidth: 1120,
|
||||||
|
marginBottom: 16,
|
||||||
|
};
|
||||||
|
const settingsSummaryCardStyle: React.CSSProperties = {
|
||||||
|
display: 'grid',
|
||||||
|
gap: 8,
|
||||||
|
padding: '14px 16px',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: '1px solid var(--color-border-light)',
|
||||||
|
background: 'color-mix(in srgb, var(--color-bg-card) 92%, var(--color-bg))',
|
||||||
|
boxShadow: 'var(--shadow-card)',
|
||||||
|
minWidth: 0,
|
||||||
|
};
|
||||||
|
const settingsShellStyle: React.CSSProperties = {
|
||||||
|
maxWidth: 1120,
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: isMobile ? '1fr' : '220px minmax(0, 1fr)',
|
||||||
|
alignItems: 'start',
|
||||||
|
gap: isMobile ? 14 : 18,
|
||||||
|
};
|
||||||
|
const settingsNavStyle: React.CSSProperties = {
|
||||||
|
display: 'grid',
|
||||||
|
gap: 6,
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: '1px solid var(--color-border-light)',
|
||||||
|
background: 'color-mix(in srgb, var(--color-bg-card) 94%, var(--color-bg))',
|
||||||
|
boxShadow: 'var(--shadow-card)',
|
||||||
|
position: isMobile ? 'static' : 'sticky',
|
||||||
|
top: 88,
|
||||||
|
maxHeight: isMobile ? undefined : 'calc(100dvh - 112px)',
|
||||||
|
overflowY: 'auto',
|
||||||
|
};
|
||||||
|
const settingsNavLinkStyle: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
padding: '8px 10px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.35,
|
||||||
|
};
|
||||||
|
const settingsContentStyle: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 16,
|
||||||
|
minWidth: 0,
|
||||||
|
};
|
||||||
|
const settingsAnchorCardStyle: React.CSSProperties = {
|
||||||
|
scrollMarginTop: 96,
|
||||||
|
};
|
||||||
|
const settingsAttentionPanelStyle: React.CSSProperties = {
|
||||||
|
maxWidth: 1120,
|
||||||
|
display: 'grid',
|
||||||
|
gap: 12,
|
||||||
|
padding: '14px 16px',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: '1px solid var(--color-border-light)',
|
||||||
|
background: 'color-mix(in srgb, var(--color-bg-card) 94%, var(--color-bg))',
|
||||||
|
boxShadow: 'var(--shadow-card)',
|
||||||
|
marginBottom: 16,
|
||||||
|
};
|
||||||
|
const settingsAttentionHeaderStyle: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
};
|
||||||
|
const settingsAttentionGridStyle: React.CSSProperties = {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))',
|
||||||
|
gap: 10,
|
||||||
|
};
|
||||||
|
const settingsAttentionItemStyle: React.CSSProperties = {
|
||||||
|
display: 'grid',
|
||||||
|
gap: 4,
|
||||||
|
padding: '12px 14px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--color-border-light)',
|
||||||
|
background: 'color-mix(in srgb, var(--color-bg) 78%, var(--color-bg-card))',
|
||||||
|
textDecoration: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
const getSettingsPillStyle = (tone: SettingsPillTone): React.CSSProperties => {
|
const getSettingsPillStyle = (tone: SettingsPillTone): React.CSSProperties => {
|
||||||
const toneStyles: Record<SettingsPillTone, React.CSSProperties> = {
|
const toneStyles: Record<SettingsPillTone, React.CSSProperties> = {
|
||||||
@@ -627,6 +735,83 @@ export default function Settings() {
|
|||||||
: savedModelAvailabilityProbeEnabled
|
: savedModelAvailabilityProbeEnabled
|
||||||
? '已启用'
|
? '已启用'
|
||||||
: '已关闭';
|
: '已关闭';
|
||||||
|
const activeRoutingProfileLabel = activeRoutingProfile === 'stable'
|
||||||
|
? '稳定优先'
|
||||||
|
: activeRoutingProfile === 'cost'
|
||||||
|
? '成本优先'
|
||||||
|
: '均衡';
|
||||||
|
const runtimeDatabaseSummary = runtimeDatabaseState
|
||||||
|
? `${runtimeDatabaseState.active.dialect}${runtimeDatabaseState.restartRequired ? ' · 待重启' : ''}`
|
||||||
|
: '加载中';
|
||||||
|
const systemProxySummary = runtime.systemProxyUrl.trim() ? '已配置' : '未配置';
|
||||||
|
const securitySummary = runtime.adminIpAllowlist?.length
|
||||||
|
? `${runtime.adminIpAllowlist.length} 条白名单`
|
||||||
|
: '未限制 IP';
|
||||||
|
const modelGovernanceSummary = [
|
||||||
|
blockedBrands.length > 0 ? `${blockedBrands.length} 个品牌屏蔽` : '',
|
||||||
|
allowedModels.length > 0 ? `${allowedModels.length} 个模型白名单` : '',
|
||||||
|
].filter(Boolean).join(' / ') || '全部模型开放';
|
||||||
|
const settingsSections: SettingsNavItem[] = [
|
||||||
|
{ id: 'settings-access', label: '管理员令牌', badge: maskedToken ? '已配置' : '检查', tone: maskedToken ? 'neutral' : 'warning' },
|
||||||
|
{ id: 'settings-schedule', label: '定时任务', badge: runtime.checkinScheduleMode === 'interval' ? `${runtime.checkinIntervalHours}h` : 'Cron', tone: 'neutral' },
|
||||||
|
{ id: 'settings-proxy', label: '代理与失败判定', badge: systemProxySummary, tone: systemProxySummary === '已配置' ? 'primary' : 'neutral' },
|
||||||
|
{ id: 'settings-payload', label: 'Payload 规则', badge: configuredPayloadRuleCount > 0 ? `${configuredPayloadRuleCount}` : '0', tone: payloadAdvancedDirty ? 'warning' : configuredPayloadRuleCount > 0 ? 'primary' : 'neutral' },
|
||||||
|
{ id: 'settings-transport', label: '上游传输', badge: runtime.codexUpstreamWebsocketEnabled ? 'WS' : 'HTTP', tone: runtime.codexUpstreamWebsocketEnabled ? 'primary' : 'neutral' },
|
||||||
|
{ id: 'settings-probe', label: '批量测活', badge: modelAvailabilityProbeStatusLabel, tone: modelAvailabilityProbeStatusTone },
|
||||||
|
{ id: 'settings-downstream-token', label: '下游令牌', badge: runtime.proxyTokenMasked ? '已配置' : '未设置', tone: runtime.proxyTokenMasked ? 'primary' : 'warning' },
|
||||||
|
{ id: 'settings-routing', label: '路由策略', badge: activeRoutingProfileLabel, tone: activeRoutingProfile === 'balanced' ? 'neutral' : 'primary' },
|
||||||
|
{ id: 'settings-governance', label: '模型治理', badge: blockedBrands.length + allowedModels.length > 0 ? '已配置' : '开放', tone: blockedBrands.length + allowedModels.length > 0 ? 'primary' : 'neutral' },
|
||||||
|
{ id: 'settings-database', label: '数据库', badge: runtimeDatabaseSummary, tone: runtimeDatabaseState?.restartRequired ? 'warning' : 'neutral' },
|
||||||
|
{ id: 'settings-maintenance', label: '维护与危险操作', badge: '谨慎', tone: 'danger' },
|
||||||
|
{ id: 'settings-security', label: '会话与安全', badge: securitySummary, tone: runtime.adminIpAllowlist?.length ? 'primary' : 'neutral' },
|
||||||
|
];
|
||||||
|
const settingsAttentionItems: SettingsAttentionItem[] = [
|
||||||
|
runtimeDatabaseState?.restartRequired
|
||||||
|
? {
|
||||||
|
key: 'database-restart',
|
||||||
|
title: '数据库配置待重启',
|
||||||
|
detail: '运行数据库和已保存配置不一致,重启后才会切换到新连接。',
|
||||||
|
href: '#settings-database',
|
||||||
|
tone: 'warning',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
payloadAdvancedDirty
|
||||||
|
? {
|
||||||
|
key: 'payload-dirty',
|
||||||
|
title: 'Payload 高级 JSON 待同步',
|
||||||
|
detail: '高级编辑器有未同步或未保存内容,离开页面前建议保存。',
|
||||||
|
href: '#settings-payload',
|
||||||
|
tone: 'warning',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
modelAvailabilityProbeDirty
|
||||||
|
? {
|
||||||
|
key: 'probe-dirty',
|
||||||
|
title: '批量测活开关待保存',
|
||||||
|
detail: '测活状态已修改但尚未保存,当前运行状态不会立刻改变。',
|
||||||
|
href: '#settings-probe',
|
||||||
|
tone: 'warning',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
savedModelAvailabilityProbeEnabled
|
||||||
|
? {
|
||||||
|
key: 'probe-enabled',
|
||||||
|
title: '批量测活处于开启状态',
|
||||||
|
detail: '请确认你的上游中转站允许主动探测,避免触发风控。',
|
||||||
|
href: '#settings-probe',
|
||||||
|
tone: 'danger',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
!runtime.proxyTokenMasked
|
||||||
|
? {
|
||||||
|
key: 'downstream-token',
|
||||||
|
title: '下游访问令牌未设置',
|
||||||
|
detail: '下游客户端调用代理接口前,需要先配置 PROXY_TOKEN。',
|
||||||
|
href: '#settings-downstream-token',
|
||||||
|
tone: 'warning',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
].filter((item): item is SettingsAttentionItem => item !== null);
|
||||||
|
|
||||||
const syncPayloadRuleDraftsFromObject = (value: unknown) => {
|
const syncPayloadRuleDraftsFromObject = (value: unknown) => {
|
||||||
setPayloadRuleDrafts(normalizePayloadRulesForEditor(value));
|
setPayloadRuleDrafts(normalizePayloadRulesForEditor(value));
|
||||||
@@ -824,7 +1009,7 @@ export default function Settings() {
|
|||||||
const res = await api.updateRuntimeSettings({ proxyToken: `${PROXY_TOKEN_PREFIX}${suffix}` });
|
const res = await api.updateRuntimeSettings({ proxyToken: `${PROXY_TOKEN_PREFIX}${suffix}` });
|
||||||
setRuntime((prev) => ({ ...prev, proxyTokenMasked: res.proxyTokenMasked || prev.proxyTokenMasked }));
|
setRuntime((prev) => ({ ...prev, proxyTokenMasked: res.proxyTokenMasked || prev.proxyTokenMasked }));
|
||||||
setProxyTokenSuffix('');
|
setProxyTokenSuffix('');
|
||||||
toast.success('Proxy token updated');
|
toast.success('下游访问令牌已更新');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.error(err?.message || '保存失败');
|
toast.error(err?.message || '保存失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1070,7 +1255,7 @@ export default function Settings() {
|
|||||||
),
|
),
|
||||||
disableCrossProtocolFallback: runtime.disableCrossProtocolFallback,
|
disableCrossProtocolFallback: runtime.disableCrossProtocolFallback,
|
||||||
});
|
});
|
||||||
toast.success('Routing weights saved');
|
toast.success('路由策略已保存');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.error(err?.message || '保存失败');
|
toast.error(err?.message || '保存失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1144,7 +1329,7 @@ export default function Settings() {
|
|||||||
? res.currentAdminIp
|
? res.currentAdminIp
|
||||||
: prev.currentAdminIp,
|
: prev.currentAdminIp,
|
||||||
}));
|
}));
|
||||||
toast.success('Security settings saved');
|
toast.success('安全设置已保存');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.error(err?.message || '保存失败');
|
toast.error(err?.message || '保存失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1209,7 +1394,7 @@ export default function Settings() {
|
|||||||
|
|
||||||
const handleTestExternalDatabaseConnection = async () => {
|
const handleTestExternalDatabaseConnection = async () => {
|
||||||
if (!effectiveMigrationConnectionString) {
|
if (!effectiveMigrationConnectionString) {
|
||||||
toast.info('Please fill target database connection first');
|
toast.info('请先填写目标数据库连接');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1226,9 +1411,9 @@ export default function Settings() {
|
|||||||
connectionString: effectiveMigrationConnectionString,
|
connectionString: effectiveMigrationConnectionString,
|
||||||
ssl: migrationSsl,
|
ssl: migrationSsl,
|
||||||
});
|
});
|
||||||
toast.success(`Connection success: ${res.connection || migrationDialect}`);
|
toast.success(`连接测试成功:${res.connection || migrationDialect}`);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.error(err?.message || 'Target database connection failed');
|
toast.error(err?.message || '目标数据库连接失败');
|
||||||
} finally {
|
} finally {
|
||||||
setTestingMigrationConnection(false);
|
setTestingMigrationConnection(false);
|
||||||
}
|
}
|
||||||
@@ -1236,7 +1421,7 @@ export default function Settings() {
|
|||||||
|
|
||||||
const handleMigrateToExternalDatabase = async () => {
|
const handleMigrateToExternalDatabase = async () => {
|
||||||
if (!effectiveMigrationConnectionString) {
|
if (!effectiveMigrationConnectionString) {
|
||||||
toast.info('Please fill target database connection first');
|
toast.info('请先填写目标数据库连接');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1247,8 +1432,8 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const warning = migrationOverwrite
|
const warning = migrationOverwrite
|
||||||
? 'Confirm migration and overwrite existing data in target database?'
|
? '确认迁移并覆盖目标数据库现有数据?'
|
||||||
: 'Confirm migration to target database? If target has data, migration may fail.';
|
: '确认迁移到目标数据库?如果目标库已有数据,迁移可能失败。';
|
||||||
if (!window.confirm(warning)) return;
|
if (!window.confirm(warning)) return;
|
||||||
|
|
||||||
setMigratingDatabase(true);
|
setMigratingDatabase(true);
|
||||||
@@ -1260,9 +1445,9 @@ export default function Settings() {
|
|||||||
ssl: migrationSsl,
|
ssl: migrationSsl,
|
||||||
});
|
});
|
||||||
setMigrationSummary(res);
|
setMigrationSummary(res);
|
||||||
toast.success(res?.message || 'Database migration completed');
|
toast.success(res?.message || '数据库迁移已完成');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.error(err?.message || 'Database migration failed');
|
toast.error(err?.message || '数据库迁移失败');
|
||||||
} finally {
|
} finally {
|
||||||
setMigratingDatabase(false);
|
setMigratingDatabase(false);
|
||||||
}
|
}
|
||||||
@@ -1270,7 +1455,7 @@ export default function Settings() {
|
|||||||
|
|
||||||
const handleSaveRuntimeDatabaseConfig = async () => {
|
const handleSaveRuntimeDatabaseConfig = async () => {
|
||||||
if (!effectiveMigrationConnectionString) {
|
if (!effectiveMigrationConnectionString) {
|
||||||
toast.info('Please fill target database connection first');
|
toast.info('请先填写目标数据库连接');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1302,9 +1487,9 @@ export default function Settings() {
|
|||||||
: null,
|
: null,
|
||||||
restartRequired: !!res?.restartRequired,
|
restartRequired: !!res?.restartRequired,
|
||||||
});
|
});
|
||||||
toast.success(res?.message || 'Runtime database config saved');
|
toast.success(res?.message || '运行数据库配置已保存');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.error(err?.message || 'Runtime database config save failed');
|
toast.error(err?.message || '运行数据库配置保存失败');
|
||||||
} finally {
|
} finally {
|
||||||
setSavingRuntimeDatabase(false);
|
setSavingRuntimeDatabase(false);
|
||||||
}
|
}
|
||||||
@@ -1322,12 +1507,102 @@ export default function Settings() {
|
|||||||
return (
|
return (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
<h2 className="page-title">系统设置</h2>
|
<h2 className="page-title">系统设置</h2>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--color-text-muted)', lineHeight: 1.7, marginTop: 6 }}>
|
||||||
|
管理员运行台:集中配置访问令牌、代理、路由、模型治理、数据库和安全策略。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ maxWidth: 720, display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<div style={settingsSummaryGridStyle}>
|
||||||
<div className="card animate-slide-up stagger-1" style={{ padding: 20 }}>
|
<div style={settingsSummaryCardStyle}>
|
||||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>管理员登录令牌</div>
|
<div style={settingsModernFieldLabelStyle}>运行数据库</div>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text-primary)' }}>{runtimeDatabaseSummary}</div>
|
||||||
|
<div style={settingsModernFieldHintStyle}>
|
||||||
|
{runtimeDatabaseState?.active.connection || '使用默认本地数据库'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={settingsSummaryCardStyle}>
|
||||||
|
<div style={settingsModernFieldLabelStyle}>上游传输</div>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text-primary)' }}>{proxyTransportModeLabel}</div>
|
||||||
|
<div style={settingsModernFieldHintStyle}>{proxyTransportQueueLabel}</div>
|
||||||
|
</div>
|
||||||
|
<div style={settingsSummaryCardStyle}>
|
||||||
|
<div style={settingsModernFieldLabelStyle}>路由策略</div>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text-primary)' }}>{activeRoutingProfileLabel}</div>
|
||||||
|
<div style={settingsModernFieldHintStyle}>失败冷却上限 {runtime.routeFailureCooldownMaxValue} {ROUTE_COOLDOWN_UNIT_OPTIONS.find((item) => item.value === runtime.routeFailureCooldownMaxUnit)?.label || ''}</div>
|
||||||
|
</div>
|
||||||
|
<div style={settingsSummaryCardStyle}>
|
||||||
|
<div style={settingsModernFieldLabelStyle}>模型治理</div>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text-primary)' }}>{modelGovernanceSummary}</div>
|
||||||
|
<div style={settingsModernFieldHintStyle}>批量测活:{modelAvailabilityProbeStatusLabel}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={settingsAttentionPanelStyle} aria-label="运行关注事项">
|
||||||
|
<div style={settingsAttentionHeaderStyle}>
|
||||||
|
<div>
|
||||||
|
<div style={settingsModernTitleStyle}>运行关注事项</div>
|
||||||
|
<div style={settingsModernDescriptionStyle}>
|
||||||
|
自动汇总需要管理员确认的设置状态;没有异常时保持安静。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={getSettingsPillStyle(settingsAttentionItems.length > 0 ? 'warning' : 'primary')}>
|
||||||
|
{settingsAttentionItems.length > 0 ? `${settingsAttentionItems.length} 项待确认` : '当前配置平稳'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{settingsAttentionItems.length > 0 ? (
|
||||||
|
<div style={settingsAttentionGridStyle}>
|
||||||
|
{settingsAttentionItems.map((item) => (
|
||||||
|
<a key={item.key} href={item.href} style={settingsAttentionItemStyle}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--color-text-primary)' }}>
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
<span style={getSettingsPillStyle(item.tone)}>
|
||||||
|
{item.tone === 'danger' ? '高风险' : item.tone === 'warning' ? '待处理' : '提示'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, lineHeight: 1.7, color: 'var(--color-text-muted)' }}>
|
||||||
|
{item.detail}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ ...settingsModernFieldHintStyle, marginTop: 0 }}>
|
||||||
|
暂无待处理事项。可以继续检查下方分区,或直接保持当前运行配置。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={settingsShellStyle}>
|
||||||
|
<aside style={settingsNavStyle} aria-label="设置分区导航">
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--color-text-muted)', padding: '2px 10px 6px' }}>
|
||||||
|
设置分区
|
||||||
|
</div>
|
||||||
|
{settingsSections.map((section) => (
|
||||||
|
<a key={section.id} href={`#${section.id}`} style={settingsNavLinkStyle}>
|
||||||
|
<span>{section.label}</span>
|
||||||
|
<span style={getSettingsPillStyle(section.tone)}>{section.badge}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div style={settingsContentStyle}>
|
||||||
|
<div id="settings-access" className="card animate-slide-up stagger-1" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }}>
|
||||||
|
<div style={settingsModernHeaderStyle}>
|
||||||
|
<div style={settingsModernTitleBlockStyle}>
|
||||||
|
<div style={settingsModernTitleStyle}>管理员登录令牌</div>
|
||||||
|
<div style={settingsModernDescriptionStyle}>
|
||||||
|
用于管理后台登录。修改后建议同步更新部署侧保存的管理员入口说明。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={getSettingsPillStyle(maskedToken ? 'primary' : 'warning')}>
|
||||||
|
{maskedToken ? '已配置' : '待检查'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<code style={{ display: 'block', padding: '10px 14px', background: 'var(--color-bg)', borderRadius: 'var(--radius-sm)', fontSize: 13, fontFamily: 'var(--font-mono)', color: 'var(--color-text-secondary)', border: '1px solid var(--color-border-light)', marginBottom: 12 }}>
|
<code style={{ display: 'block', padding: '10px 14px', background: 'var(--color-bg)', borderRadius: 'var(--radius-sm)', fontSize: 13, fontFamily: 'var(--font-mono)', color: 'var(--color-text-secondary)', border: '1px solid var(--color-border-light)', marginBottom: 12 }}>
|
||||||
{maskedToken || '****'}
|
{maskedToken || '****'}
|
||||||
</code>
|
</code>
|
||||||
@@ -1341,8 +1616,18 @@ export default function Settings() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card animate-slide-up stagger-2" style={{ padding: 20 }}>
|
<div id="settings-schedule" className="card animate-slide-up stagger-2" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }}>
|
||||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>定时任务</div>
|
<div style={settingsModernHeaderStyle}>
|
||||||
|
<div style={settingsModernTitleBlockStyle}>
|
||||||
|
<div style={settingsModernTitleStyle}>定时任务</div>
|
||||||
|
<div style={settingsModernDescriptionStyle}>
|
||||||
|
控制账号签到、余额刷新和日志清理节奏,适合按站点规模调小或调大频率。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={getSettingsPillStyle('neutral')}>
|
||||||
|
{runtime.checkinScheduleMode === 'interval' ? `${runtime.checkinIntervalHours} 小时签到` : 'Cron 签到'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '180px 180px auto', gap: 12, alignItems: 'end', marginBottom: 12 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '180px 180px auto', gap: 12, alignItems: 'end', marginBottom: 12 }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>签到方式</div>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>签到方式</div>
|
||||||
@@ -1462,11 +1747,18 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card animate-slide-up stagger-3" style={{ padding: 20 }}>
|
<div id="settings-proxy" className="card animate-slide-up stagger-3" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }}>
|
||||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>系统代理</div>
|
<div style={settingsModernHeaderStyle}>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
<div style={settingsModernTitleBlockStyle}>
|
||||||
|
<div style={settingsModernTitleStyle}>系统代理</div>
|
||||||
|
<div style={settingsModernDescriptionStyle}>
|
||||||
配置一个全局出站代理地址,站点页可按站点决定是否启用系统代理。
|
配置一个全局出站代理地址,站点页可按站点决定是否启用系统代理。
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={getSettingsPillStyle(runtime.systemProxyUrl.trim() ? 'primary' : 'neutral')}>
|
||||||
|
{systemProxySummary}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
value={runtime.systemProxyUrl}
|
value={runtime.systemProxyUrl}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -1504,11 +1796,18 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}>
|
<div className="card animate-slide-up stagger-4" style={settingsModernCardStyle}>
|
||||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>代理失败判定</div>
|
<div style={settingsModernHeaderStyle}>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
<div style={settingsModernTitleBlockStyle}>
|
||||||
|
<div style={settingsModernTitleStyle}>代理失败判定</div>
|
||||||
|
<div style={settingsModernDescriptionStyle}>
|
||||||
命中任一关键词或空内容时判定失败,可触发重试。
|
命中任一关键词或空内容时判定失败,可触发重试。
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={getSettingsPillStyle(runtime.proxyEmptyContentFailEnabled || proxyErrorKeywordsText.trim() ? 'primary' : 'neutral')}>
|
||||||
|
{runtime.proxyEmptyContentFailEnabled || proxyErrorKeywordsText.trim() ? '已配置' : '默认'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
value={proxyErrorKeywordsText}
|
value={proxyErrorKeywordsText}
|
||||||
onChange={(e) => setProxyErrorKeywordsText(e.target.value)}
|
onChange={(e) => setProxyErrorKeywordsText(e.target.value)}
|
||||||
@@ -1536,7 +1835,7 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card animate-slide-up stagger-4" style={settingsModernCardStyle} data-settings-card="payload-rules">
|
<div id="settings-payload" className="card animate-slide-up stagger-4" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }} data-settings-card="payload-rules">
|
||||||
<div style={settingsModernHeaderStyle}>
|
<div style={settingsModernHeaderStyle}>
|
||||||
<div style={settingsModernTitleBlockStyle}>
|
<div style={settingsModernTitleBlockStyle}>
|
||||||
<div style={settingsModernTitleStyle}>Payload 规则</div>
|
<div style={settingsModernTitleStyle}>Payload 规则</div>
|
||||||
@@ -1794,12 +2093,12 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card animate-slide-up stagger-4" style={settingsModernCardStyle} data-settings-card="proxy-transport">
|
<div id="settings-transport" className="card animate-slide-up stagger-4" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }} data-settings-card="proxy-transport">
|
||||||
<div style={settingsModernHeaderStyle}>
|
<div style={settingsModernHeaderStyle}>
|
||||||
<div style={settingsModernTitleBlockStyle}>
|
<div style={settingsModernTitleBlockStyle}>
|
||||||
<div style={settingsModernTitleStyle}>Codex 上游传输与会话并发</div>
|
<div style={settingsModernTitleStyle}>Codex 上游传输与会话并发</div>
|
||||||
<div style={settingsModernDescriptionStyle}>
|
<div style={settingsModernDescriptionStyle}>
|
||||||
默认采用 HTTP 优先。只有这里开启后,BossAI 才会在 Codex 请求上尝试把上游升级为 WebSocket。下游 Codex 客户端也必须同时启用 `/v1/responses` websocket,单开这里不会生效。
|
默认采用 HTTP 优先。只有这里开启后,BoosAPI 才会在 Codex 请求上尝试把上游升级为 WebSocket。下游 Codex 客户端也必须同时启用 `/v1/responses` websocket,单开这里不会生效。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={settingsModernPillRowStyle}>
|
<div style={settingsModernPillRowStyle}>
|
||||||
@@ -1813,7 +2112,7 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
<label style={settingsModernToggleStyle}>
|
<label style={settingsModernToggleStyle}>
|
||||||
<div style={settingsModernToggleCopyStyle}>
|
<div style={settingsModernToggleCopyStyle}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-secondary)' }}>允许 BossAI 到 Codex 上游使用 WebSocket</span>
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-secondary)' }}>允许 BoosAPI 到 Codex 上游使用 WebSocket</span>
|
||||||
<span style={{ fontSize: 12, lineHeight: 1.7, color: 'var(--color-text-muted)' }}>
|
<span style={{ fontSize: 12, lineHeight: 1.7, color: 'var(--color-text-muted)' }}>
|
||||||
仅在下游 Codex 客户端已同步开启 `/v1/responses` websocket 时启用;否则仍按 HTTP 优先执行。
|
仅在下游 Codex 客户端已同步开启 `/v1/responses` websocket 时启用;否则仍按 HTTP 优先执行。
|
||||||
</span>
|
</span>
|
||||||
@@ -1891,12 +2190,12 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card animate-slide-up stagger-4" style={settingsModernDangerCardStyle} data-settings-card="model-availability-probe">
|
<div id="settings-probe" className="card animate-slide-up stagger-4" style={{ ...settingsModernDangerCardStyle, ...settingsAnchorCardStyle }} data-settings-card="model-availability-probe">
|
||||||
<div style={settingsModernHeaderStyle}>
|
<div style={settingsModernHeaderStyle}>
|
||||||
<div style={settingsModernTitleBlockStyle}>
|
<div style={settingsModernTitleBlockStyle}>
|
||||||
<div style={{ ...settingsModernTitleStyle, color: 'var(--color-danger)' }}>批量测活</div>
|
<div style={{ ...settingsModernTitleStyle, color: 'var(--color-danger)' }}>批量测活</div>
|
||||||
<div style={settingsModernDescriptionStyle}>
|
<div style={settingsModernDescriptionStyle}>
|
||||||
默认关闭。开启后,BossAI 会在后台定时对活跃账号模型发送最小化探测请求,用来校正“/models 能看到但实际不可用”的假阳性。
|
默认关闭。开启后,BoosAPI 会在后台定时对活跃账号模型发送最小化探测请求,用来校正“/models 能看到但实际不可用”的假阳性。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={settingsModernPillRowStyle}>
|
<div style={settingsModernPillRowStyle}>
|
||||||
@@ -1922,7 +2221,7 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
<label style={settingsModernToggleStyle}>
|
<label style={settingsModernToggleStyle}>
|
||||||
<div style={settingsModernToggleCopyStyle}>
|
<div style={settingsModernToggleCopyStyle}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-secondary)' }}>允许 BossAI 后台主动批量测活</span>
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-secondary)' }}>允许 BoosAPI 后台主动批量测活</span>
|
||||||
<span style={{ fontSize: 12, lineHeight: 1.7, color: 'var(--color-text-muted)' }}>
|
<span style={{ fontSize: 12, lineHeight: 1.7, color: 'var(--color-text-muted)' }}>
|
||||||
首次从关闭切换到开启时,需要手动输入确认语句;关闭时可直接保存。
|
首次从关闭切换到开启时,需要手动输入确认语句;关闭时可直接保存。
|
||||||
</span>
|
</span>
|
||||||
@@ -1962,7 +2261,7 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}>
|
<div id="settings-downstream-token" className="card animate-slide-up stagger-4" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }}>
|
||||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>下游访问令牌(PROXY_TOKEN)</div>
|
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>下游访问令牌(PROXY_TOKEN)</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
||||||
用于下游站点或客户端访问本服务代理接口。前缀 sk- 固定不可修改,只需填写后缀。
|
用于下游站点或客户端访问本服务代理接口。前缀 sk- 固定不可修改,只需填写后缀。
|
||||||
@@ -2055,11 +2354,23 @@ export default function Settings() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card animate-slide-up stagger-5" style={{ padding: 20 }}>
|
<div id="settings-routing" className="card animate-slide-up stagger-5" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }}>
|
||||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10 }}>路由策略</div>
|
<div style={settingsModernHeaderStyle}>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
<div style={settingsModernTitleBlockStyle}>
|
||||||
|
<div style={settingsModernTitleStyle}>路由策略</div>
|
||||||
|
<div style={settingsModernDescriptionStyle}>
|
||||||
先选择预设策略,只有需要精调时再展开高级参数。
|
先选择预设策略,只有需要精调时再展开高级参数。
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={settingsModernPillRowStyle}>
|
||||||
|
<span style={getSettingsPillStyle(activeRoutingProfile === 'balanced' ? 'neutral' : 'primary')}>
|
||||||
|
{activeRoutingProfileLabel}
|
||||||
|
</span>
|
||||||
|
<span style={getSettingsPillStyle(runtime.disableCrossProtocolFallback ? 'warning' : 'neutral')}>
|
||||||
|
{runtime.disableCrossProtocolFallback ? '禁止跨协议' : '允许兼容切换'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div style={{ marginBottom: 12, maxWidth: 280 }}>
|
<div style={{ marginBottom: 12, maxWidth: 280 }}>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
|
||||||
无实测/配置/目录价时默认单价
|
无实测/配置/目录价时默认单价
|
||||||
@@ -2252,11 +2563,18 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Global Brand Filter */}
|
{/* Global Brand Filter */}
|
||||||
<div className="card animate-slide-up stagger-6" style={{ padding: 20 }}>
|
<div id="settings-governance" className="card animate-slide-up stagger-6" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }}>
|
||||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>全局品牌屏蔽</div>
|
<div style={settingsModernHeaderStyle}>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12, lineHeight: 1.6 }}>
|
<div style={settingsModernTitleBlockStyle}>
|
||||||
|
<div style={settingsModernTitleStyle}>全局品牌屏蔽</div>
|
||||||
|
<div style={settingsModernDescriptionStyle}>
|
||||||
屏蔽选定品牌后,路由重建时将自动跳过匹配该品牌的所有模型。点击品牌切换屏蔽状态,保存后自动触发路由重建。
|
屏蔽选定品牌后,路由重建时将自动跳过匹配该品牌的所有模型。点击品牌切换屏蔽状态,保存后自动触发路由重建。
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={getSettingsPillStyle(blockedBrands.length > 0 ? 'warning' : 'neutral')}>
|
||||||
|
{blockedBrands.length > 0 ? `已屏蔽 ${blockedBrands.length}` : '未屏蔽'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
|
||||||
{(allBrandNames || []).map((brand) => {
|
{(allBrandNames || []).map((brand) => {
|
||||||
const isBlocked = blockedBrands.includes(brand);
|
const isBlocked = blockedBrands.includes(brand);
|
||||||
@@ -2301,11 +2619,18 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Global Allowed Models Whitelist */}
|
{/* Global Allowed Models Whitelist */}
|
||||||
<div className="card animate-slide-up stagger-7" style={{ padding: 20 }}>
|
<div className="card animate-slide-up stagger-7" style={settingsModernCardStyle}>
|
||||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>全局模型白名单</div>
|
<div style={settingsModernHeaderStyle}>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12, lineHeight: 1.6 }}>
|
<div style={settingsModernTitleBlockStyle}>
|
||||||
|
<div style={settingsModernTitleStyle}>全局模型白名单</div>
|
||||||
|
<div style={settingsModernDescriptionStyle}>
|
||||||
配置白名单后,路由重建和候选生成将只针对白名单中的模型。留空表示允许所有模型(向后兼容)。保存后自动触发路由重建。
|
配置白名单后,路由重建和候选生成将只针对白名单中的模型。留空表示允许所有模型(向后兼容)。保存后自动触发路由重建。
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={getSettingsPillStyle(allowedModels.length > 0 ? 'primary' : 'neutral')}>
|
||||||
|
{allowedModels.length > 0 ? `${allowedModels.length} 个模型` : '全部开放'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||||
<input
|
<input
|
||||||
@@ -2411,11 +2736,23 @@ export default function Settings() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card animate-slide-up stagger-8" style={{ padding: 20 }}>
|
<div id="settings-database" className="card animate-slide-up stagger-8" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }}>
|
||||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10 }}>数据库迁移(SQLite / MySQL / PostgreSQL)</div>
|
<div style={settingsModernHeaderStyle}>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
<div style={settingsModernTitleBlockStyle}>
|
||||||
|
<div style={settingsModernTitleStyle}>数据库迁移(SQLite / MySQL / PostgreSQL)</div>
|
||||||
|
<div style={settingsModernDescriptionStyle}>
|
||||||
可先测试连接,再迁移数据;迁移完成后可保存为运行数据库配置(重启容器后生效)。
|
可先测试连接,再迁移数据;迁移完成后可保存为运行数据库配置(重启容器后生效)。
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={settingsModernPillRowStyle}>
|
||||||
|
<span style={getSettingsPillStyle(runtimeDatabaseState?.restartRequired ? 'warning' : 'neutral')}>
|
||||||
|
{runtimeDatabaseSummary}
|
||||||
|
</span>
|
||||||
|
<span style={getSettingsPillStyle(migrationOverwrite ? 'warning' : 'neutral')}>
|
||||||
|
{migrationOverwrite ? '允许覆盖' : '不覆盖'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '180px 1fr', gap: 10, marginBottom: 10, alignItems: 'center' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '180px 1fr', gap: 10, marginBottom: 10, alignItems: 'center' }}>
|
||||||
<ModernSelect
|
<ModernSelect
|
||||||
@@ -2462,19 +2799,19 @@ export default function Settings() {
|
|||||||
<input
|
<input
|
||||||
value={shorthandConnection.host}
|
value={shorthandConnection.host}
|
||||||
onChange={(e) => setShorthandConnection((prev) => ({ ...prev, host: e.target.value }))}
|
onChange={(e) => setShorthandConnection((prev) => ({ ...prev, host: e.target.value }))}
|
||||||
placeholder="Host (required)"
|
placeholder="主机地址(必填)"
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
value={shorthandConnection.user}
|
value={shorthandConnection.user}
|
||||||
onChange={(e) => setShorthandConnection((prev) => ({ ...prev, user: e.target.value }))}
|
onChange={(e) => setShorthandConnection((prev) => ({ ...prev, user: e.target.value }))}
|
||||||
placeholder="User (required)"
|
placeholder="用户名(必填)"
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
value={shorthandConnection.password}
|
value={shorthandConnection.password}
|
||||||
onChange={(e) => setShorthandConnection((prev) => ({ ...prev, password: e.target.value }))}
|
onChange={(e) => setShorthandConnection((prev) => ({ ...prev, password: e.target.value }))}
|
||||||
placeholder="Password (required)"
|
placeholder="密码(必填)"
|
||||||
type="password"
|
type="password"
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
@@ -2505,7 +2842,7 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<code style={{ display: 'block', padding: '10px 14px', background: 'var(--color-bg)', borderRadius: 'var(--radius-sm)', fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--color-text-secondary)', border: '1px solid var(--color-border-light)' }}>
|
<code style={{ display: 'block', padding: '10px 14px', background: 'var(--color-bg)', borderRadius: 'var(--radius-sm)', fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--color-text-secondary)', border: '1px solid var(--color-border-light)' }}>
|
||||||
{generatedConnectionString || 'Fill host/user/password to generate connection string'}
|
{generatedConnectionString || '填写主机、用户名、密码后自动生成连接串'}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2559,12 +2896,15 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{runtimeDatabaseState && (
|
{runtimeDatabaseState && (
|
||||||
<div style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-sm)', padding: 10, fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.8, marginBottom: migrationSummary ? 12 : 0 }}>
|
<div style={{ ...settingsModernFieldCardStyle, marginBottom: migrationSummary ? 12 : 0 }}>
|
||||||
<div>当前运行:{runtimeDatabaseState.active.dialect}({runtimeDatabaseState.active.connection || '(empty)' }){runtimeDatabaseState.active.ssl && ' [SSL]'}</div>
|
<div style={settingsModernFieldLabelStyle}>当前数据库状态</div>
|
||||||
<div>
|
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
|
||||||
|
当前运行:{runtimeDatabaseState.active.dialect}({runtimeDatabaseState.active.connection || '未配置'}){runtimeDatabaseState.active.ssl && '(已启用 SSL)'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
|
||||||
已保存待生效:
|
已保存待生效:
|
||||||
{runtimeDatabaseState.saved
|
{runtimeDatabaseState.saved
|
||||||
? ` ${runtimeDatabaseState.saved.dialect}(${runtimeDatabaseState.saved.connection})${runtimeDatabaseState.saved.ssl ? ' [SSL]' : ''}`
|
? ` ${runtimeDatabaseState.saved.dialect}(${runtimeDatabaseState.saved.connection})${runtimeDatabaseState.saved.ssl ? '(已启用 SSL)' : ''}`
|
||||||
: ' 未保存'}
|
: ' 未保存'}
|
||||||
</div>
|
</div>
|
||||||
{runtimeDatabaseState.restartRequired && (
|
{runtimeDatabaseState.restartRequired && (
|
||||||
@@ -2582,9 +2922,8 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UpdateCenterSection />
|
<div id="settings-maintenance" style={settingsModernSectionStyle}>
|
||||||
|
<div className="card animate-slide-up stagger-6" style={settingsModernCardStyle}>
|
||||||
<div className="card animate-slide-up stagger-6" style={{ padding: 20 }}>
|
|
||||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>维护工具</div>
|
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>维护工具</div>
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
<button onClick={handleClearCache} disabled={clearingCache} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
<button onClick={handleClearCache} disabled={clearingCache} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||||
@@ -2596,10 +2935,10 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card animate-slide-up stagger-7" style={{ padding: 20, border: '1px solid color-mix(in srgb, var(--color-danger) 30%, var(--color-border))' }}>
|
<div className="card animate-slide-up stagger-7" style={settingsModernDangerCardStyle}>
|
||||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10, color: 'var(--color-danger)' }}>危险操作</div>
|
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10, color: 'var(--color-danger)' }}>危险操作</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.8, marginBottom: 12 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.8, marginBottom: 12 }}>
|
||||||
重新初始化系统会清空当前 BossAI 使用中的全部数据库内容;若当前运行在外部 MySQL/Postgres,也会先清空该外部库中的 BossAI 数据,然后切回默认 SQLite。
|
重新初始化系统会清空当前 BoosAPI 使用中的全部数据库内容;若当前运行在外部 MySQL/Postgres,也会先清空该外部库中的 BoosAPI 数据,然后切回默认 SQLite。
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.8, marginBottom: 14 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.8, marginBottom: 14 }}>
|
||||||
完成后管理员 Token 会重置为 <code style={{ fontFamily: 'var(--font-mono)' }}>{FACTORY_RESET_ADMIN_TOKEN}</code>,当前会话会立即退出并刷新页面。
|
完成后管理员 Token 会重置为 <code style={{ fontFamily: 'var(--font-mono)' }}>{FACTORY_RESET_ADMIN_TOKEN}</code>,当前会话会立即退出并刷新页面。
|
||||||
@@ -2609,7 +2948,9 @@ export default function Settings() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card animate-slide-up stagger-7" style={{ padding: 20 }}>
|
</div>
|
||||||
|
|
||||||
|
<div id="settings-security" className="card animate-slide-up stagger-7" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }}>
|
||||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10 }}>会话与安全</div>
|
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10 }}>会话与安全</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
||||||
登录会话默认 12 小时自动过期。可选配置管理端 IP 白名单,支持每行一个 IP 或 IPv4 CIDR 网段。
|
登录会话默认 12 小时自动过期。可选配置管理端 IP 白名单,支持每行一个 IP 或 IPv4 CIDR 网段。
|
||||||
@@ -2643,6 +2984,7 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<FactoryResetModal
|
<FactoryResetModal
|
||||||
presence={factoryResetPresence}
|
presence={factoryResetPresence}
|
||||||
factoryResetting={factoryResetting}
|
factoryResetting={factoryResetting}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
|
import PageHeader from '../components/PageHeader.js';
|
||||||
import { useToast } from '../components/Toast.js';
|
import { useToast } from '../components/Toast.js';
|
||||||
import { SiteAnnouncementContent } from './helpers/siteAnnouncementPresentation.js';
|
import { SiteAnnouncementContent } from './helpers/siteAnnouncementPresentation.js';
|
||||||
import { tr } from '../i18n.js';
|
import { tr } from '../i18n.js';
|
||||||
@@ -175,15 +176,12 @@ export default function SiteAnnouncements() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in admin-ops-page site-announcements-page">
|
||||||
<div className="page-header">
|
<PageHeader
|
||||||
<div>
|
title={tr('用户公告')}
|
||||||
<h2 className="page-title">{tr('用户公告')}</h2>
|
subtitle="管理员发布给普通用户的站内公告,会在普通用户面板中展示。"
|
||||||
<div style={{ marginTop: 4, fontSize: 12, color: 'var(--color-text-tertiary)' }}>
|
actions={(
|
||||||
管理员发布给普通用户的站内公告,会在普通用户面板中展示。
|
<>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="page-actions">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => load(true)}
|
onClick={() => load(true)}
|
||||||
disabled={refreshing}
|
disabled={refreshing}
|
||||||
@@ -199,12 +197,13 @@ export default function SiteAnnouncements() {
|
|||||||
>
|
>
|
||||||
{clearing ? <><span className="spinner spinner-sm" /> 清空中...</> : '清空公告'}
|
{clearing ? <><span className="spinner spinner-sm" /> 清空中...</> : '清空公告'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</>
|
||||||
</div>
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(280px, 360px) 1fr', gap: 14, alignItems: 'start' }}>
|
<div className="site-announcements-layout" style={{ display: 'grid', gridTemplateColumns: 'minmax(280px, 360px) 1fr', gap: 14, alignItems: 'start' }}>
|
||||||
<div className="card" style={{ padding: 18 }}>
|
<div className="card site-announcements-form-card" style={{ padding: 18 }}>
|
||||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>
|
<h3 className="site-announcements-section-title" style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>
|
||||||
{form.id ? '编辑公告' : '发布公告'}
|
{form.id ? '编辑公告' : '发布公告'}
|
||||||
</h3>
|
</h3>
|
||||||
<div style={{ display: 'grid', gap: 12 }}>
|
<div style={{ display: 'grid', gap: 12 }}>
|
||||||
@@ -266,8 +265,9 @@ export default function SiteAnnouncements() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card" style={{ padding: 0 }}>
|
<div className="card site-announcements-list-card" style={{ padding: 0 }}>
|
||||||
<div
|
<div
|
||||||
|
className="site-announcements-list-head"
|
||||||
style={{
|
style={{
|
||||||
padding: '14px 18px',
|
padding: '14px 18px',
|
||||||
borderBottom: '1px solid var(--color-border-light)',
|
borderBottom: '1px solid var(--color-border-light)',
|
||||||
@@ -298,13 +298,13 @@ export default function SiteAnnouncements() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={row.id}
|
key={row.id}
|
||||||
className={`animate-slide-up stagger-${Math.min(index + 1, 5)}`}
|
className={`site-announcements-row animate-slide-up stagger-${Math.min(index + 1, 5)}`}
|
||||||
style={{
|
style={{
|
||||||
padding: '16px 18px',
|
padding: '16px 18px',
|
||||||
borderBottom: index === rows.length - 1 ? 'none' : '1px solid var(--color-border-light)',
|
borderBottom: index === rows.length - 1 ? 'none' : '1px solid var(--color-border-light)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start', marginBottom: 8 }}>
|
<div className="site-announcements-row-head" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start', marginBottom: 8 }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 15, fontWeight: 600 }}>{row.title}</div>
|
<div style={{ fontSize: 15, fontWeight: 600 }}>{row.title}</div>
|
||||||
<div style={{ marginTop: 5, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
<div style={{ marginTop: 5, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
@@ -317,7 +317,7 @@ export default function SiteAnnouncements() {
|
|||||||
<span className="badge badge-muted">已读 {row.readCount || 0}</span>
|
<span className="badge badge-muted">已读 {row.readCount || 0}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
<div className="site-announcements-row-actions" style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||||
<button type="button" className="btn btn-link btn-link-primary" onClick={() => editRow(row)}>
|
<button type="button" className="btn btn-link btn-link-primary" onClick={() => editRow(row)}>
|
||||||
编辑
|
编辑
|
||||||
</button>
|
</button>
|
||||||
@@ -327,7 +327,7 @@ export default function SiteAnnouncements() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SiteAnnouncementContent content={row.content} />
|
<SiteAnnouncementContent content={row.content} />
|
||||||
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--color-text-muted)' }}>
|
<div className="site-announcements-meta" style={{ marginTop: 8, fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||||
发布时间:{row.publishedAt || row.firstSeenAt || '-'}
|
发布时间:{row.publishedAt || row.firstSeenAt || '-'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @Author: 橘子
|
* @Author: 橘子
|
||||||
* @Project_description: BossAI 站点管理页
|
* @Project_description: BoosAPI 站点管理页
|
||||||
* @Description: 代码是我抄的,不会也是真的
|
* @Description: 代码是我抄的,不会也是真的
|
||||||
*/
|
*/
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
@@ -1148,7 +1148,7 @@ export default function Sites() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in admin-ops-page sites-ops-page">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h2 className="page-title">{tr('站点管理')}</h2>
|
<h2 className="page-title">{tr('站点管理')}</h2>
|
||||||
<div className="page-actions sites-page-actions">
|
<div className="page-actions sites-page-actions">
|
||||||
|
|||||||
@@ -95,6 +95,16 @@ const EMPTY_ROUTE_FORM: RouteEditorForm = {
|
|||||||
};
|
};
|
||||||
const DESKTOP_DETAIL_ENTER_MS = 260;
|
const DESKTOP_DETAIL_ENTER_MS = 260;
|
||||||
const DESKTOP_DETAIL_COLLAPSE_MS = 200;
|
const DESKTOP_DETAIL_COLLAPSE_MS = 200;
|
||||||
|
const ROUTE_CANDIDATE_PRELOAD_DELAY_MS = 1600;
|
||||||
|
|
||||||
|
function scheduleRouteIdleWork(callback: () => void, timeout = 1200): () => void {
|
||||||
|
if (typeof requestIdleCallback === 'function') {
|
||||||
|
const idleId = requestIdleCallback(callback, { timeout });
|
||||||
|
return () => cancelIdleCallback(idleId);
|
||||||
|
}
|
||||||
|
const timer = globalThis.setTimeout(callback, Math.min(timeout, 300));
|
||||||
|
return () => globalThis.clearTimeout(timer);
|
||||||
|
}
|
||||||
|
|
||||||
function prefersReducedMotion(): boolean {
|
function prefersReducedMotion(): boolean {
|
||||||
return typeof globalThis.matchMedia === 'function'
|
return typeof globalThis.matchMedia === 'function'
|
||||||
@@ -178,6 +188,28 @@ export function DesktopDetailPanelPresence({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RouteListSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="route-loading-skeleton" aria-label={tr('正在加载路由')}>
|
||||||
|
{Array.from({ length: 8 }).map((_, index) => (
|
||||||
|
<div key={`route-skeleton-${index}`} className="route-loading-card">
|
||||||
|
<div className="route-loading-card-head">
|
||||||
|
<div className="skeleton route-loading-icon" />
|
||||||
|
<div>
|
||||||
|
<div className="skeleton route-loading-title" />
|
||||||
|
<div className="skeleton route-loading-subtitle" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="route-loading-lines">
|
||||||
|
<div className="skeleton" />
|
||||||
|
<div className="skeleton" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function TokenRoutes() {
|
export default function TokenRoutes() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [routeSummaries, setRouteSummaries] = useState<RouteSummaryRow[]>([]);
|
const [routeSummaries, setRouteSummaries] = useState<RouteSummaryRow[]>([]);
|
||||||
@@ -202,6 +234,7 @@ export default function TokenRoutes() {
|
|||||||
const [form, setForm] = useState<RouteEditorForm>(EMPTY_ROUTE_FORM);
|
const [form, setForm] = useState<RouteEditorForm>(EMPTY_ROUTE_FORM);
|
||||||
const [editingRouteId, setEditingRouteId] = useState<number | null>(null);
|
const [editingRouteId, setEditingRouteId] = useState<number | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [initialLoading, setInitialLoading] = useState(true);
|
||||||
const [rebuilding, setRebuilding] = useState(false);
|
const [rebuilding, setRebuilding] = useState(false);
|
||||||
const [batchUpdatingRoutes, setBatchUpdatingRoutes] = useState(false);
|
const [batchUpdatingRoutes, setBatchUpdatingRoutes] = useState(false);
|
||||||
const [batchSelectMode, setBatchSelectMode] = useState(false);
|
const [batchSelectMode, setBatchSelectMode] = useState(false);
|
||||||
@@ -381,20 +414,26 @@ export default function TokenRoutes() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true;
|
mountedRef.current = true;
|
||||||
(async () => {
|
let cancelCandidatePreload: (() => void) | null = null;
|
||||||
|
const preloadTimer = globalThis.setTimeout(() => {
|
||||||
|
cancelCandidatePreload = scheduleRouteIdleWork(() => loadCandidates(), 1800);
|
||||||
|
}, ROUTE_CANDIDATE_PRELOAD_DELAY_MS);
|
||||||
|
|
||||||
|
void resumeRouteDecisionRefreshTask();
|
||||||
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await resumeRouteDecisionRefreshTask();
|
|
||||||
await load();
|
await load();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('加载路由配置失败');
|
toast.error('加载路由配置失败');
|
||||||
|
} finally {
|
||||||
|
if (mountedRef.current) setInitialLoading(false);
|
||||||
}
|
}
|
||||||
// Preload candidates in background after first paint
|
|
||||||
const scheduleIdle = typeof requestIdleCallback === 'function' ? requestIdleCallback : (cb: () => void) => setTimeout(cb, 0);
|
|
||||||
scheduleIdle(() => loadCandidates());
|
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
mountedRef.current = false;
|
mountedRef.current = false;
|
||||||
decisionRefreshWatchSeqRef.current += 1;
|
decisionRefreshWatchSeqRef.current += 1;
|
||||||
|
globalThis.clearTimeout(preloadTimer);
|
||||||
|
cancelCandidatePreload?.();
|
||||||
};
|
};
|
||||||
}, [resumeRouteDecisionRefreshTask, toast]);
|
}, [resumeRouteDecisionRefreshTask, toast]);
|
||||||
|
|
||||||
@@ -1520,7 +1559,7 @@ export default function TokenRoutes() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in" style={{ minHeight: 400 }}>
|
<div className="animate-fade-in admin-ops-page token-routes-page" style={{ minHeight: 400 }}>
|
||||||
{/* Toolbar: search + sort + actions */}
|
{/* Toolbar: search + sort + actions */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
|
||||||
<div className="toolbar-search" style={{ minWidth: 220, flex: 1, maxWidth: 360 }}>
|
<div className="toolbar-search" style={{ minWidth: 220, flex: 1, maxWidth: 360 }}>
|
||||||
@@ -1628,8 +1667,8 @@ export default function TokenRoutes() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="badge badge-info" style={{ fontSize: 12, fontWeight: 500, marginLeft: 'auto' }}>
|
<span className="badge badge-info route-count-badge" style={{ fontSize: 12, fontWeight: 500, marginLeft: 'auto' }}>
|
||||||
{tr('共')} {filteredRoutes.length} {tr('条路由')}
|
{initialLoading ? tr('正在加载路由') : `${tr('共')} ${filteredRoutes.length} ${tr('条路由')}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1747,6 +1786,9 @@ export default function TokenRoutes() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{initialLoading ? (
|
||||||
|
<RouteListSkeleton />
|
||||||
|
) : (
|
||||||
<div className={isMobile ? 'mobile-card-list' : 'route-card-grid'}>
|
<div className={isMobile ? 'mobile-card-list' : 'route-card-grid'}>
|
||||||
{visibleRoutes.map((route) => {
|
{visibleRoutes.map((route) => {
|
||||||
const isExpanded = expandedRouteIds.includes(route.id);
|
const isExpanded = expandedRouteIds.includes(route.id);
|
||||||
@@ -1998,8 +2040,9 @@ export default function TokenRoutes() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{shouldShowLoadMore && (
|
{!initialLoading && shouldShowLoadMore && (
|
||||||
<div
|
<div
|
||||||
ref={loadMoreSentinelRef}
|
ref={loadMoreSentinelRef}
|
||||||
style={{ textAlign: 'center', padding: '12px 0', fontSize: 12, color: 'var(--color-text-muted)' }}
|
style={{ textAlign: 'center', padding: '12px 0', fontSize: 12, color: 'var(--color-text-muted)' }}
|
||||||
@@ -2008,7 +2051,7 @@ export default function TokenRoutes() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredRoutes.length === 0 && (
|
{!initialLoading && filteredRoutes.length === 0 && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
|||||||
@@ -1099,7 +1099,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
|||||||
<input
|
<input
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||||
placeholder="例如 BossAI"
|
placeholder="例如 BoosAPI"
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { userApi } from '../api.js';
|
import { userApi } from '../api.js';
|
||||||
|
import PageHeader from '../components/PageHeader.js';
|
||||||
import { useToast } from '../components/Toast.js';
|
import { useToast } from '../components/Toast.js';
|
||||||
|
|
||||||
interface LogItem {
|
interface LogItem {
|
||||||
id: number;
|
id: number | string;
|
||||||
model_requested: string | null;
|
model_requested: string | null;
|
||||||
model_actual: string | null;
|
model_actual: string | null;
|
||||||
status: string | null;
|
status: string | null;
|
||||||
@@ -16,6 +17,9 @@ interface LogItem {
|
|||||||
estimated_cost: number | null;
|
estimated_cost: number | null;
|
||||||
error_message: string | null;
|
error_message: string | null;
|
||||||
is_stream: boolean | null;
|
is_stream: boolean | null;
|
||||||
|
content_summary?: string | null;
|
||||||
|
request_content?: string | null;
|
||||||
|
response_content?: string | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +64,63 @@ function latencyTextColor(log: LogItem): string {
|
|||||||
return latencyColor(log.latency_ms);
|
return latencyColor(log.latency_ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderContentPreview(log: LogItem) {
|
||||||
|
const summary = log.content_summary?.trim();
|
||||||
|
if (!summary) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 4,
|
||||||
|
maxWidth: 420,
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1.45,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
}}
|
||||||
|
title={summary}
|
||||||
|
>
|
||||||
|
内容:{summary}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContentDetail(log: LogItem) {
|
||||||
|
const sections = [
|
||||||
|
{ label: '请求内容', value: log.request_content?.trim() },
|
||||||
|
{ label: '响应内容', value: log.response_content?.trim() },
|
||||||
|
].filter((section): section is { label: string; value: string } => Boolean(section.value));
|
||||||
|
if (sections.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div style={{ gridColumn: '1 / -1', display: 'grid', gap: 10 }}>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', fontWeight: 600 }}>对话内容</div>
|
||||||
|
{sections.map((section) => (
|
||||||
|
<div key={section.label} style={{ display: 'grid', gap: 4 }}>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>{section.label}</div>
|
||||||
|
<pre style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: 10,
|
||||||
|
maxHeight: 220,
|
||||||
|
overflow: 'auto',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'var(--color-bg)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}>{section.value}</pre>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function UsageRecords() {
|
export default function UsageRecords() {
|
||||||
const [logs, setLogs] = useState<LogItem[]>([]);
|
const [logs, setLogs] = useState<LogItem[]>([]);
|
||||||
const [summary, setSummary] = useState<Summary | null>(null);
|
const [summary, setSummary] = useState<Summary | null>(null);
|
||||||
@@ -70,7 +131,7 @@ export default function UsageRecords() {
|
|||||||
const [statusFilter, setStatusFilter] = useState('');
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
const [dateFrom, setDateFrom] = useState('');
|
const [dateFrom, setDateFrom] = useState('');
|
||||||
const [dateTo, setDateTo] = useState('');
|
const [dateTo, setDateTo] = useState('');
|
||||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
@@ -122,11 +183,10 @@ export default function UsageRecords() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container animate-fade-in">
|
<div className="page-container animate-fade-in">
|
||||||
<div className="page-header">
|
<PageHeader
|
||||||
<div>
|
title="使用记录"
|
||||||
<h2 className="page-title">使用记录</h2>
|
subtitle="当前账号下所有 API 调用日志"
|
||||||
<div className="page-subtitle">当前账号下所有 API 调用日志</div>
|
actions={(
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
@@ -135,7 +195,8 @@ export default function UsageRecords() {
|
|||||||
>
|
>
|
||||||
刷新
|
刷新
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Summary cards */}
|
{/* Summary cards */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, marginBottom: 20 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, marginBottom: 20 }}>
|
||||||
@@ -162,7 +223,7 @@ export default function UsageRecords() {
|
|||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
style={{ width: '100%', paddingLeft: 30, paddingRight: 10, height: 34, border: '1px solid var(--color-border)', borderRadius: 6, background: 'var(--color-bg)', color: 'var(--color-text-primary)', fontSize: 13, boxSizing: 'border-box' }}
|
style={{ width: '100%', paddingLeft: 30, paddingRight: 10, height: 34, border: '1px solid var(--color-border)', borderRadius: 6, background: 'var(--color-bg)', color: 'var(--color-text-primary)', fontSize: 13, boxSizing: 'border-box' }}
|
||||||
placeholder="搜索模型名称..."
|
placeholder="搜索模型、提示词或错误..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => handleSearchChange(e.target.value)}
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -233,11 +294,13 @@ export default function UsageRecords() {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
logs.map((log) => (
|
logs.map((log) => {
|
||||||
<React.Fragment key={log.id}>
|
const logKey = String(log.id);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={logKey}>
|
||||||
<tr
|
<tr
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
onClick={() => setExpandedId(expandedId === log.id ? null : log.id)}
|
onClick={() => setExpandedId(expandedId === logKey ? null : logKey)}
|
||||||
>
|
>
|
||||||
<td style={{ fontSize: 12, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
|
<td style={{ fontSize: 12, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
|
||||||
{formatDate(log.created_at)}
|
{formatDate(log.created_at)}
|
||||||
@@ -247,6 +310,7 @@ export default function UsageRecords() {
|
|||||||
{log.model_actual && log.model_actual !== log.model_requested && (
|
{log.model_actual && log.model_actual !== log.model_requested && (
|
||||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>→ {log.model_actual}</div>
|
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>→ {log.model_actual}</div>
|
||||||
)}
|
)}
|
||||||
|
{renderContentPreview(log)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`badge badge-${log.status === 'success' ? 'success' : 'error'}`} style={{ fontSize: 11 }}>
|
<span className={`badge badge-${log.status === 'success' ? 'success' : 'error'}`} style={{ fontSize: 11 }}>
|
||||||
@@ -268,14 +332,14 @@ export default function UsageRecords() {
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<svg
|
<svg
|
||||||
style={{ display: 'block', color: 'var(--color-text-muted)', transition: 'transform 0.15s', transform: expandedId === log.id ? 'rotate(180deg)' : 'none' }}
|
style={{ display: 'block', color: 'var(--color-text-muted)', transition: 'transform 0.15s', transform: expandedId === logKey ? 'rotate(180deg)' : 'none' }}
|
||||||
width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||||
>
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{expandedId === log.id && (
|
{expandedId === logKey && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} style={{ padding: 0, background: 'var(--color-bg-alt)' }}>
|
<td colSpan={7} style={{ padding: 0, background: 'var(--color-bg-alt)' }}>
|
||||||
<div style={{ padding: '14px 20px', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, borderBottom: '1px solid var(--color-border)' }}>
|
<div style={{ padding: '14px 20px', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, borderBottom: '1px solid var(--color-border)' }}>
|
||||||
@@ -296,12 +360,14 @@ export default function UsageRecords() {
|
|||||||
<div style={{ fontSize: 12, color: 'var(--color-danger)', wordBreak: 'break-all' }}>{log.error_message}</div>
|
<div style={{ fontSize: 12, color: 'var(--color-danger)', wordBreak: 'break-all' }}>{log.error_message}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{renderContentDetail(log)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { userApi } from '../api.js';
|
import { userApi } from '../api.js';
|
||||||
|
import PageHeader from '../components/PageHeader.js';
|
||||||
import { useToast } from '../components/Toast.js';
|
import { useToast } from '../components/Toast.js';
|
||||||
import { SiteAnnouncementContent } from './helpers/siteAnnouncementPresentation.js';
|
import { SiteAnnouncementContent } from './helpers/siteAnnouncementPresentation.js';
|
||||||
|
|
||||||
@@ -76,9 +77,10 @@ export default function UserAnnouncements() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
<div className="page-header">
|
<PageHeader
|
||||||
<h2 className="page-title">用户公告</h2>
|
title="用户公告"
|
||||||
<div className="page-actions">
|
actions={(
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => load(true)}
|
onClick={() => load(true)}
|
||||||
@@ -97,8 +99,9 @@ export default function UserAnnouncements() {
|
|||||||
>
|
>
|
||||||
{markingAll ? <><span className="spinner spinner-sm" /> 标记中...</> : '全部已读'}
|
{markingAll ? <><span className="spinner spinner-sm" /> 标记中...</> : '全部已读'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</>
|
||||||
</div>
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="card" style={{ padding: 0 }}>
|
<div className="card" style={{ padding: 0 }}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|||||||
+114
-18
@@ -138,6 +138,64 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
|||||||
const failedRequests = Math.max(0, (summary?.totalRequests || 0) - (summary?.successRequests || 0));
|
const failedRequests = Math.max(0, (summary?.totalRequests || 0) - (summary?.successRequests || 0));
|
||||||
const activeKeyCount = keys.filter((key) => key.enabled).length;
|
const activeKeyCount = keys.filter((key) => key.enabled).length;
|
||||||
const userInitial = (profile.username || profile.email || 'U').trim().slice(0, 1).toUpperCase();
|
const userInitial = (profile.username || profile.email || 'U').trim().slice(0, 1).toUpperCase();
|
||||||
|
const lastActivityLabel = recentLogs[0] ? formatDateLabel(recentLogs[0].created_at) : '暂无调用';
|
||||||
|
const primarySuggestion = activeKeyCount === 0
|
||||||
|
? {
|
||||||
|
title: '先创建一个 API 密钥',
|
||||||
|
body: '密钥创建后,工作流、生图助手和外部客户端都会进入同一套日志与计费。',
|
||||||
|
action: '管理密钥',
|
||||||
|
to: '/user/dashboard/keys',
|
||||||
|
}
|
||||||
|
: recentLogs.length === 0
|
||||||
|
? {
|
||||||
|
title: '从一次创作任务开始',
|
||||||
|
body: '试试生图助手或工作流,产出会自动进入工作空间。',
|
||||||
|
action: '打开生图助手',
|
||||||
|
to: '/user/picture-agent',
|
||||||
|
}
|
||||||
|
: failedRequests > 0
|
||||||
|
? {
|
||||||
|
title: '检查失败调用',
|
||||||
|
body: '最近存在失败请求,进入使用记录可以查看模型、延迟和错误原因。',
|
||||||
|
action: '查看使用记录',
|
||||||
|
to: '/user/usage',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
title: '继续扩展你的创作链路',
|
||||||
|
body: '把现有模型调用升级为工作流,复用提示词、图片、视频和归档产出。',
|
||||||
|
action: '打开工作流',
|
||||||
|
to: '/user/workflows',
|
||||||
|
};
|
||||||
|
const creationActions = [
|
||||||
|
{
|
||||||
|
label: '工作流',
|
||||||
|
note: '画布编排图片、视频与 API 节点',
|
||||||
|
to: '/user/workflows',
|
||||||
|
kind: 'workflow',
|
||||||
|
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>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '视频助手',
|
||||||
|
note: '脚本拆解、镜头规划、素材生成',
|
||||||
|
to: '/user/comfyui-agent',
|
||||||
|
kind: 'video',
|
||||||
|
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>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '生图助手',
|
||||||
|
note: '使用生图模型并自动归档产出',
|
||||||
|
to: '/user/picture-agent',
|
||||||
|
kind: 'image',
|
||||||
|
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 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>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '工作空间',
|
||||||
|
note: '查看已生成图片、视频与音频',
|
||||||
|
to: '/user/workspace',
|
||||||
|
kind: 'workspace',
|
||||||
|
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 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>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="user-dashboard-page animate-fade-in">
|
<div className="user-dashboard-page animate-fade-in">
|
||||||
@@ -149,24 +207,63 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
|||||||
<p className="page-subtitle">这里汇总你的模型调用、密钥状态和创作入口。</p>
|
<p className="page-subtitle">这里汇总你的模型调用、密钥状态和创作入口。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="user-dashboard-hero-side">
|
||||||
|
<div className="user-dashboard-hero-rail" aria-label="账号运行状态">
|
||||||
|
<div>
|
||||||
|
<span>启用密钥</span>
|
||||||
|
<strong>{activeKeyCount}/{keys.length}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>成功率</span>
|
||||||
|
<strong>{successRate}%</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>最近调用</span>
|
||||||
|
<strong>{lastActivityLabel}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="user-dashboard-header-actions">
|
<div className="user-dashboard-header-actions">
|
||||||
<Link to="/user/models" className="btn btn-ghost">模型广场</Link>
|
<Link to="/user/models" className="btn btn-ghost">模型广场</Link>
|
||||||
<Link to="/user/usage" className="btn btn-primary">使用记录</Link>
|
<Link to="/user/usage" className="btn btn-primary">使用记录</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<section className="user-dashboard-workbench">
|
||||||
|
<div className="user-dashboard-suggestion">
|
||||||
|
<span>下一步</span>
|
||||||
|
<strong>{primarySuggestion.title}</strong>
|
||||||
|
<p>{primarySuggestion.body}</p>
|
||||||
|
<Link to={primarySuggestion.to} className="btn btn-primary">{primarySuggestion.action}</Link>
|
||||||
|
</div>
|
||||||
|
<div className="user-dashboard-workbench-metrics">
|
||||||
|
<div>
|
||||||
|
<span>今日入口</span>
|
||||||
|
<strong>创作工作台</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>产出归档</span>
|
||||||
|
<strong>Workspace</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>计费口径</span>
|
||||||
|
<strong>Token + Media</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="user-dashboard-stat-grid">
|
<div className="user-dashboard-stat-grid">
|
||||||
<div className="card user-dashboard-stat">
|
<div className="card user-dashboard-stat" data-tone="volume">
|
||||||
<div className="user-dashboard-stat-label">总请求次数</div>
|
<div className="user-dashboard-stat-label">总请求次数</div>
|
||||||
<div className="user-dashboard-stat-value">{summary?.totalRequests || 0}</div>
|
<div className="user-dashboard-stat-value">{summary?.totalRequests || 0}</div>
|
||||||
<div className="user-dashboard-stat-note">{failedRequests} 次失败</div>
|
<div className="user-dashboard-stat-note">{failedRequests} 次失败</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card user-dashboard-stat">
|
<div className="card user-dashboard-stat" data-tone="success">
|
||||||
<div className="user-dashboard-stat-label">请求成功率</div>
|
<div className="user-dashboard-stat-label">请求成功率</div>
|
||||||
<div className="user-dashboard-stat-value is-success">{successRate}%</div>
|
<div className="user-dashboard-stat-value is-success">{successRate}%</div>
|
||||||
<div className="user-dashboard-stat-note">{summary?.successRequests || 0} 次成功</div>
|
<div className="user-dashboard-stat-note">{summary?.successRequests || 0} 次成功</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card user-dashboard-stat">
|
<div className="card user-dashboard-stat" data-tone="cost">
|
||||||
<div className="user-dashboard-stat-label">总消耗金额</div>
|
<div className="user-dashboard-stat-label">总消耗金额</div>
|
||||||
<div className="user-dashboard-stat-value">{formatMoney(summary?.totalCost)}</div>
|
<div className="user-dashboard-stat-value">{formatMoney(summary?.totalCost)}</div>
|
||||||
<div className="user-dashboard-stat-note">{activeKeyCount}/{keys.length} 个密钥启用</div>
|
<div className="user-dashboard-stat-note">{activeKeyCount}/{keys.length} 个密钥启用</div>
|
||||||
@@ -177,25 +274,24 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
|||||||
<div className="user-dashboard-column">
|
<div className="user-dashboard-column">
|
||||||
<section className="card user-dashboard-panel">
|
<section className="card user-dashboard-panel">
|
||||||
<div className="user-dashboard-panel-head">
|
<div className="user-dashboard-panel-head">
|
||||||
|
<div>
|
||||||
<h2>快速入口</h2>
|
<h2>快速入口</h2>
|
||||||
|
<p>选择一个入口继续创作</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="user-dashboard-entry-grid">
|
<div className="user-dashboard-entry-grid">
|
||||||
<Link to="/user/workflows" className="user-dashboard-entry">
|
{creationActions.map((action) => (
|
||||||
<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>
|
<Link
|
||||||
<span>工作流</span>
|
key={action.to}
|
||||||
</Link>
|
to={action.to}
|
||||||
<Link to="/user/comfyui-agent" className="user-dashboard-entry">
|
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>
|
data-kind={action.kind}
|
||||||
<span>视频助手</span>
|
>
|
||||||
</Link>
|
{action.svg}
|
||||||
<Link to="/user/picture-agent" className="user-dashboard-entry">
|
<span>{action.label}</span>
|
||||||
<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>
|
<small>{action.note}</small>
|
||||||
<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>
|
</Link>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import CenteredModal from '../components/CenteredModal.js';
|
import CenteredModal from '../components/CenteredModal.js';
|
||||||
import DeleteConfirmModal from '../components/DeleteConfirmModal.js';
|
import DeleteConfirmModal from '../components/DeleteConfirmModal.js';
|
||||||
|
import PageHeader from '../components/PageHeader.js';
|
||||||
|
|
||||||
type UserRole = 'admin' | 'user';
|
type UserRole = 'admin' | 'user';
|
||||||
type UserStatus = 'active' | 'disabled';
|
type UserStatus = 'active' | 'disabled';
|
||||||
@@ -179,6 +180,27 @@ function statusLabel(status: UserStatus): string {
|
|||||||
return status === 'active' ? '正常' : '已禁用';
|
return status === 'active' ? '正常' : '已禁用';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hashString(input: string): number {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < input.length; i += 1) {
|
||||||
|
hash = (hash << 5) - hash + input.charCodeAt(i);
|
||||||
|
hash |= 0;
|
||||||
|
}
|
||||||
|
return Math.abs(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
function userAvatarStyle(user: Pick<User, 'id' | 'username' | 'email'>): React.CSSProperties {
|
||||||
|
const seed = `${user.id}:${user.username}:${user.email}`;
|
||||||
|
const hue = hashString(seed) % 360;
|
||||||
|
const secondHue = (hue + 42 + (hashString(user.email || seed) % 80)) % 360;
|
||||||
|
return {
|
||||||
|
background:
|
||||||
|
`radial-gradient(circle at 30% 24%, hsl(${secondHue} 86% 90%) 0 18%, transparent 19%), ` +
|
||||||
|
`linear-gradient(135deg, hsl(${hue} 72% 48%), hsl(${secondHue} 68% 46%))`,
|
||||||
|
color: '#fff',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function UserManagement() {
|
export default function UserManagement() {
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [stats, setStats] = useState<UserStats>({ total: 0, active: 0, disabled: 0, admins: 0 });
|
const [stats, setStats] = useState<UserStats>({ total: 0, active: 0, disabled: 0, admins: 0 });
|
||||||
@@ -464,28 +486,28 @@ export default function UserManagement() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const statCards = [
|
const statCards = [
|
||||||
{ label: '总用户', value: stats.total, tone: 'primary' },
|
{ label: '总用户', value: stats.total, tone: 'primary', desc: '平台账号总量' },
|
||||||
{ label: '正常用户', value: stats.active, tone: 'success' },
|
{ label: '正常用户', value: stats.active, tone: 'success', desc: '可登录并调用 API' },
|
||||||
{ label: '管理员', value: stats.admins, tone: 'info' },
|
{ label: '管理员', value: stats.admins, tone: 'info', desc: '具备后台权限' },
|
||||||
{ label: '已禁用', value: stats.disabled, tone: 'danger' },
|
{ label: '已禁用', value: stats.disabled, tone: 'danger', desc: '保留数据但禁止访问' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container animate-fade-in user-management-page">
|
<div className="page-container animate-fade-in user-management-page">
|
||||||
<div className="page-header">
|
<PageHeader
|
||||||
<div>
|
title="用户管理"
|
||||||
<h2 className="page-title">用户管理</h2>
|
subtitle="维护平台用户、角色与登录状态。禁用不会删除用户数据,可随时恢复。"
|
||||||
<div className="page-subtitle">维护平台用户、角色与登录状态。禁用不会删除用户数据,可随时恢复。</div>
|
actions={(
|
||||||
</div>
|
<>
|
||||||
<div className="page-actions">
|
|
||||||
<button type="button" className="btn btn-ghost" onClick={loadUsers} disabled={loading || saving}>
|
<button type="button" className="btn btn-ghost" onClick={loadUsers} disabled={loading || saving}>
|
||||||
刷新
|
刷新
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-primary" onClick={openCreateModal}>
|
<button type="button" className="btn btn-primary" onClick={openCreateModal}>
|
||||||
新建用户
|
新建用户
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</>
|
||||||
</div>
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
{error ? <div className="alert alert-error user-management-alert">{error}</div> : null}
|
{error ? <div className="alert alert-error user-management-alert">{error}</div> : null}
|
||||||
|
|
||||||
@@ -509,7 +531,10 @@ export default function UserManagement() {
|
|||||||
<div className="user-management-stats">
|
<div className="user-management-stats">
|
||||||
{statCards.map((stat) => (
|
{statCards.map((stat) => (
|
||||||
<div className={`user-stat-card user-stat-${stat.tone}`} key={stat.label}>
|
<div className={`user-stat-card user-stat-${stat.tone}`} key={stat.label}>
|
||||||
|
<div>
|
||||||
<div className="user-stat-label">{stat.label}</div>
|
<div className="user-stat-label">{stat.label}</div>
|
||||||
|
<div className="user-stat-desc">{stat.desc}</div>
|
||||||
|
</div>
|
||||||
<div className="user-stat-value">{loading ? '-' : stat.value}</div>
|
<div className="user-stat-value">{loading ? '-' : stat.value}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -608,7 +633,7 @@ export default function UserManagement() {
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="user-identity-cell">
|
<div className="user-identity-cell">
|
||||||
<div className="user-avatar">{user.username.slice(0, 1).toUpperCase() || 'U'}</div>
|
<div className="user-avatar" style={userAvatarStyle(user)}>{user.username.slice(0, 1).toUpperCase() || 'U'}</div>
|
||||||
<div className="user-identity-text">
|
<div className="user-identity-text">
|
||||||
<div className="user-name-line">
|
<div className="user-name-line">
|
||||||
<span className="user-name">{user.username}</span>
|
<span className="user-name">{user.username}</span>
|
||||||
@@ -653,27 +678,25 @@ export default function UserManagement() {
|
|||||||
{expandedUserId === user.id && (
|
{expandedUserId === user.id && (
|
||||||
<tr className="usage-detail-row">
|
<tr className="usage-detail-row">
|
||||||
<td colSpan={9}>
|
<td colSpan={9}>
|
||||||
<div className="usage-detail-container animate-fade-in" style={{ padding: '16px 20px', background: 'var(--color-bg-alt)', borderBottom: '1px solid var(--color-border)' }}>
|
<div className="usage-detail-container animate-fade-in">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
<div className="usage-detail-header">
|
||||||
<div style={{ display: 'flex', gap: 20 }}>
|
<div className="usage-detail-tabs">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`btn-link ${expandedTab === 'usage' ? 'btn-link-primary' : ''}`}
|
className={`usage-detail-tab ${expandedTab === 'usage' ? 'active' : ''}`}
|
||||||
style={{ fontWeight: expandedTab === 'usage' ? 600 : 400, padding: 0, fontSize: 14 }}
|
|
||||||
onClick={(e) => { e.stopPropagation(); handleExpandedTabChange('usage', user.id); }}
|
onClick={(e) => { e.stopPropagation(); handleExpandedTabChange('usage', user.id); }}
|
||||||
>
|
>
|
||||||
用量详情
|
用量详情
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`btn-link ${expandedTab === 'keys' ? 'btn-link-primary' : ''}`}
|
className={`usage-detail-tab ${expandedTab === 'keys' ? 'active' : ''}`}
|
||||||
style={{ fontWeight: expandedTab === 'keys' ? 600 : 400, padding: 0, fontSize: 14 }}
|
|
||||||
onClick={(e) => { e.stopPropagation(); handleExpandedTabChange('keys', user.id); }}
|
onClick={(e) => { e.stopPropagation(); handleExpandedTabChange('keys', user.id); }}
|
||||||
>
|
>
|
||||||
API 密钥管理
|
API 密钥管理
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>用户: {user.username} (ID: {user.id})</div>
|
<div className="usage-detail-user">用户: {user.username} (ID: {user.id})</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expandedTab === 'usage' ? (
|
{expandedTab === 'usage' ? (
|
||||||
@@ -681,18 +704,18 @@ export default function UserManagement() {
|
|||||||
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--color-text-muted)' }}>加载中...</div>
|
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--color-text-muted)' }}>加载中...</div>
|
||||||
) : usageData ? (
|
) : usageData ? (
|
||||||
<div className="usage-detail-content">
|
<div className="usage-detail-content">
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 20 }}>
|
<div className="usage-metric-grid">
|
||||||
<div style={{ padding: '12px 16px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 8 }}>
|
<div className="usage-metric-card">
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>总请求数</div>
|
<div>总请求数</div>
|
||||||
<div style={{ fontSize: 20, fontWeight: 600 }}>{usageData.totalRequests.toLocaleString()}</div>
|
<strong>{usageData.totalRequests.toLocaleString()}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '12px 16px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 8 }}>
|
<div className="usage-metric-card">
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>总消耗</div>
|
<div>总消耗</div>
|
||||||
<div style={{ fontSize: 20, fontWeight: 600, color: 'var(--color-primary)' }}>${usageData.totalCost.toFixed(4)}</div>
|
<strong className="accent">${usageData.totalCost.toFixed(4)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '12px 16px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 8 }}>
|
<div className="usage-metric-card">
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>近7天消耗</div>
|
<div>近7天消耗</div>
|
||||||
<div style={{ fontSize: 20, fontWeight: 600 }}>${usageData.last7DaysCost.toFixed(4)}</div>
|
<strong>${usageData.last7DaysCost.toFixed(4)}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -701,7 +724,7 @@ export default function UserManagement() {
|
|||||||
{usageData.trend.length === 0 ? (
|
{usageData.trend.length === 0 ? (
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无趋势数据</div>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无趋势数据</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 2, height: 100, borderBottom: '1px solid var(--color-border)', paddingBottom: 4 }}>
|
<div className="usage-trend-bars">
|
||||||
{usageData.trend.map((t: any) => (
|
{usageData.trend.map((t: any) => (
|
||||||
<div
|
<div
|
||||||
key={t.day}
|
key={t.day}
|
||||||
@@ -710,7 +733,7 @@ export default function UserManagement() {
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
background: 'var(--color-primary)',
|
background: 'var(--color-primary)',
|
||||||
height: `${Math.max(4, (t.cost / (Math.max(...usageData.trend.map((x: any) => x.cost)) || 1)) * 100)}%`,
|
height: `${Math.max(4, (t.cost / (Math.max(...usageData.trend.map((x: any) => x.cost)) || 1)) * 100)}%`,
|
||||||
borderRadius: '2px 2px 0 0',
|
borderRadius: '4px 4px 0 0',
|
||||||
minWidth: 4
|
minWidth: 4
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
|
|||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { commitSession } from '../authSession.js';
|
import { commitSession } from '../authSession.js';
|
||||||
import LoginShell from './auth/LoginShell.js';
|
import LoginShell from './auth/LoginShell.js';
|
||||||
|
import OAuthSlot from './auth/OAuthSlot.js';
|
||||||
|
|
||||||
export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -15,6 +16,16 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
|||||||
const [captchaLoading, setCaptchaLoading] = useState(false);
|
const [captchaLoading, setCaptchaLoading] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const passwordScore = [
|
||||||
|
password.length >= 6,
|
||||||
|
/[A-Za-z]/.test(password) && /\d/.test(password),
|
||||||
|
password.length >= 10,
|
||||||
|
].filter(Boolean).length;
|
||||||
|
const passwordTone = passwordScore >= 3 ? 'strong' : passwordScore >= 2 ? 'ok' : 'weak';
|
||||||
|
const passwordLabel = password
|
||||||
|
? (passwordScore >= 3 ? '强' : passwordScore >= 2 ? '可用' : '偏弱')
|
||||||
|
: '等待输入';
|
||||||
|
const passwordsMatch = Boolean(confirmPassword) && password === confirmPassword;
|
||||||
|
|
||||||
const loadCaptcha = useCallback(async () => {
|
const loadCaptcha = useCallback(async () => {
|
||||||
setCaptchaLoading(true);
|
setCaptchaLoading(true);
|
||||||
@@ -95,24 +106,26 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
|||||||
t={(text) => text}
|
t={(text) => text}
|
||||||
variant="user"
|
variant="user"
|
||||||
eyebrow="用户注册"
|
eyebrow="用户注册"
|
||||||
title="创建账号"
|
title="创建 BossAI 账号"
|
||||||
description="注册后即可使用 API 代理服务。"
|
description="注册后自动进入个人控制台,并创建默认 API Key。"
|
||||||
error={error}
|
error={error}
|
||||||
footer={(
|
footer={(
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center' }}>
|
<div className="login-auth-footer-links">
|
||||||
<span>
|
<span>
|
||||||
已有账号?
|
已有账号?
|
||||||
<Link to="/user/login" style={{ color: 'var(--color-primary)', textDecoration: 'none', marginLeft: 4 }}>
|
<Link to="/user/login">
|
||||||
登录
|
登录
|
||||||
</Link>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
<span>
|
||||||
<Link to="/boos" style={{ color: 'var(--color-text-muted)' }}>← 管理员登录</Link>
|
<Link to="/boos">管理员登录</Link>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit} className="login-register-form">
|
<form onSubmit={handleSubmit} className="login-register-form">
|
||||||
|
<div className="login-register-grid">
|
||||||
|
<div className="login-field-group">
|
||||||
<label className="login-auth-label" htmlFor="reg-username-input">用户名</label>
|
<label className="login-auth-label" htmlFor="reg-username-input">用户名</label>
|
||||||
<input
|
<input
|
||||||
id="reg-username-input"
|
id="reg-username-input"
|
||||||
@@ -124,6 +137,8 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
|||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="login-field-group">
|
||||||
<label className="login-auth-label" htmlFor="reg-email-input">邮箱</label>
|
<label className="login-auth-label" htmlFor="reg-email-input">邮箱</label>
|
||||||
<input
|
<input
|
||||||
id="reg-email-input"
|
id="reg-email-input"
|
||||||
@@ -135,6 +150,8 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
|||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="login-field-group">
|
||||||
<label className="login-auth-label" htmlFor="reg-password-input">密码</label>
|
<label className="login-auth-label" htmlFor="reg-password-input">密码</label>
|
||||||
<input
|
<input
|
||||||
id="reg-password-input"
|
id="reg-password-input"
|
||||||
@@ -146,6 +163,14 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<div className="login-password-meter" data-tone={passwordTone}>
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<strong>{passwordLabel}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="login-field-group">
|
||||||
<label className="login-auth-label" htmlFor="reg-confirm-password-input">确认密码</label>
|
<label className="login-auth-label" htmlFor="reg-confirm-password-input">确认密码</label>
|
||||||
<input
|
<input
|
||||||
id="reg-confirm-password-input"
|
id="reg-confirm-password-input"
|
||||||
@@ -157,8 +182,16 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
{confirmPassword ? (
|
||||||
|
<div className={`login-field-hint ${passwordsMatch ? 'is-ok' : 'is-error'}`}>
|
||||||
|
{passwordsMatch ? '两次密码一致' : '两次密码不一致'}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="login-captcha-panel">
|
||||||
|
<div className="login-field-group">
|
||||||
<label className="login-auth-label" htmlFor="reg-captcha-input">图像验证码</label>
|
<label className="login-auth-label" htmlFor="reg-captcha-input">图像验证码</label>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 132px', gap: 10, alignItems: 'center' }}>
|
|
||||||
<input
|
<input
|
||||||
id="reg-captcha-input"
|
id="reg-captcha-input"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -171,34 +204,30 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
|||||||
maxLength={8}
|
maxLength={8}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
className="login-captcha-card"
|
||||||
onClick={() => void loadCaptcha()}
|
onClick={() => void loadCaptcha()}
|
||||||
disabled={captchaLoading || loading}
|
disabled={captchaLoading || loading}
|
||||||
aria-label="刷新图像验证码"
|
aria-label="刷新图像验证码"
|
||||||
title="点击刷新验证码"
|
title="点击刷新验证码"
|
||||||
style={{
|
|
||||||
height: 44,
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 0,
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
cursor: captchaLoading || loading ? 'not-allowed' : 'pointer',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{captchaImage && !captchaLoading ? (
|
{captchaImage && !captchaLoading ? (
|
||||||
<img
|
<img
|
||||||
src={captchaImage}
|
src={captchaImage}
|
||||||
alt="图像验证码"
|
alt="图像验证码"
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>加载中...</span>
|
<span>加载中...</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="login-register-hint">
|
||||||
|
<strong>注册保护已启用</strong>
|
||||||
|
<span>图像验证码和频率限制会拦截批量注册,默认 API Key 会在账号创建后自动生成。</span>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={
|
disabled={
|
||||||
@@ -226,6 +255,7 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
<OAuthSlot t={(text) => text} variant="user" mode="register" />
|
||||||
</LoginShell>
|
</LoginShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -331,19 +331,34 @@ export default function Workspace() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="workspace-page animate-fade-in">
|
<div className="workspace-page animate-fade-in">
|
||||||
<div className="page-header">
|
<section className="console-hero workspace-hero">
|
||||||
<div>
|
<div className="console-hero-copy">
|
||||||
<h1 className="page-title">工作空间</h1>
|
<span className="console-kicker">Workspace</span>
|
||||||
<p className="page-subtitle">
|
<h1>工作空间</h1>
|
||||||
{audience === 'user' ? '你的创作成品库' : '全部用户的创作成品库'},只归档最终产出。工作流上传素材和预置模板资源不会进入这里。
|
<p>
|
||||||
|
{audience === 'user' ? '你的创作成品库' : '全部用户的创作成品库'}。这里只保留最终交付文件,工作流上传素材和预置模板资源不会污染资产库。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="page-actions">
|
<div className="console-hero-rail" aria-label="工作空间摘要">
|
||||||
|
<div>
|
||||||
|
<span>当前结果</span>
|
||||||
|
<strong>{total}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>本地文件</span>
|
||||||
|
<strong>{localCount}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>已选择</span>
|
||||||
|
<strong>{selectedIds.length}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="console-hero-actions">
|
||||||
<button type="button" className="btn btn-ghost" onClick={() => void load()} disabled={loading}>
|
<button type="button" className="btn btn-ghost" onClick={() => void load()} disabled={loading}>
|
||||||
{loading ? '刷新中...' : '刷新'}
|
{loading ? '刷新中...' : '刷新'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div className="workspace-summary">
|
<div className="workspace-summary">
|
||||||
<div className="workspace-summary-item">
|
<div className="workspace-summary-item">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import MotionDiv from '../../components/AppMotion.js';
|
||||||
|
|
||||||
export type LoginShellVariant = 'admin' | 'user';
|
export type LoginShellVariant = 'admin' | 'user';
|
||||||
|
|
||||||
@@ -16,9 +17,27 @@ interface LoginShellProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ADMIN_CAPABILITY_KEYS: Array<{ titleKey: string; descKey: string }> = [
|
const ADMIN_CAPABILITY_KEYS: Array<{ titleKey: string; descKey: string }> = [
|
||||||
{ titleKey: '统一代理网关', descKey: '一个 Key、一个入口,兼容 OpenAI / Claude 下游格式' },
|
{ titleKey: '统一 API 网关', descKey: 'OpenAI / Claude / 图像 / 视频模型统一接入、统一计费' },
|
||||||
{ titleKey: '自动模型发现', descKey: '上游新增模型自动出现在模型列表,零配置路由生成' },
|
{ titleKey: '工作流生产台', descKey: '连接 Comfy Cloud、RunningHub 与自有 API,把复杂流程产品化' },
|
||||||
{ titleKey: '智能路由引擎', descKey: '按成本、延迟、成功率自动选择最优通道,故障自动转移' },
|
{ titleKey: '智能路由引擎', descKey: '按成本、延迟、成功率选择通道,失败自动切换' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LOGIN_METRICS: Array<{ valueKey: string; labelKey: string }> = [
|
||||||
|
{ valueKey: '99.9%', labelKey: '网关可用性目标' },
|
||||||
|
{ valueKey: '4+', labelKey: '多模态生产入口' },
|
||||||
|
{ valueKey: '/v1', labelKey: '统一兼容入口' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const USER_METRICS: Array<{ valueKey: string; labelKey: string }> = [
|
||||||
|
{ valueKey: 'Key', labelKey: '注册后默认创建' },
|
||||||
|
{ valueKey: '日志', labelKey: '调用内容与费用可查' },
|
||||||
|
{ valueKey: '空间', labelKey: '产出自动归档' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const USER_CAPABILITY_KEYS: Array<{ titleKey: string; descKey: string }> = [
|
||||||
|
{ titleKey: '模型创作入口', descKey: '对话、生图、视频助手与数字人任务统一进入个人控制台' },
|
||||||
|
{ titleKey: 'API Key 与计费', descKey: '默认 Key 可直接调用兼容接口,每次请求都有日志和费用记录' },
|
||||||
|
{ titleKey: '工作空间归档', descKey: '只保存最终产出的图片、视频和音频,素材上传不会混进资产库' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function LoginShell({
|
export default function LoginShell({
|
||||||
@@ -31,45 +50,49 @@ export default function LoginShell({
|
|||||||
footer,
|
footer,
|
||||||
children,
|
children,
|
||||||
}: LoginShellProps) {
|
}: LoginShellProps) {
|
||||||
|
const metrics = variant === 'user' ? USER_METRICS : LOGIN_METRICS;
|
||||||
|
const capabilities = variant === 'user' ? USER_CAPABILITY_KEYS : ADMIN_CAPABILITY_KEYS;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="login-shell" data-variant={variant}>
|
<div className="login-shell" data-variant={variant}>
|
||||||
<div className="login-surface animate-scale-in">
|
<MotionDiv className="login-surface animate-scale-in" motionVariant="login" motionKey={variant}>
|
||||||
<section
|
<section className="login-brand-panel login-brand-panel-light">
|
||||||
className="login-brand-panel login-brand-panel-light"
|
<div className="login-brand-header">
|
||||||
style={variant === 'user' ? { padding: '28px 24px' } : undefined}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="login-brand-header"
|
|
||||||
style={variant === 'user' ? { justifyContent: 'center' } : undefined}
|
|
||||||
>
|
|
||||||
<div className="brand-mark-frame brand-mark-frame-hero">
|
<div className="brand-mark-frame brand-mark-frame-hero">
|
||||||
<div className="brand-mark-canvas">
|
<div className="brand-mark-canvas">
|
||||||
<img src="/logo.png" alt="BossAI" className="login-brand-logo" />
|
<img src="/logo.png" alt="BossAI" className="login-brand-logo" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="login-brand-summary">
|
||||||
className="login-brand-summary"
|
|
||||||
style={variant === 'user' ? { textAlign: 'center' } : undefined}
|
|
||||||
>
|
|
||||||
<div className="login-brand-name">BossAI</div>
|
<div className="login-brand-name">BossAI</div>
|
||||||
<div className="login-brand-kicker">
|
<div className="login-brand-kicker">
|
||||||
{variant === 'user' ? t('用户登录') : t('聚合多模型多模态的 AI 应用平台')}
|
{variant === 'user' ? t('用户创作控制台') : t('多模型多模态 AI 应用平台')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{variant === 'admin' && (
|
|
||||||
<>
|
|
||||||
<div className="login-brand-copy-block">
|
<div className="login-brand-copy-block">
|
||||||
<p className="login-brand-copy">
|
<p className="login-brand-copy">
|
||||||
{t('聚合文本、图像、视频、语音等多模态能力,统一接入多模型工作流和 AI 应用。')}
|
{variant === 'user'
|
||||||
|
? t('登录后即可使用模型广场、视频助手、生图助手、工作流和工作空间,把创意任务沉淀成可追踪的产出。')
|
||||||
|
: t('聚合对话、生图、视频、数字人与工作流编排,把 API 中转、模型路由和内容生产放进一套可运营的平台。')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="login-compat-line">
|
<div className="login-compat-line">
|
||||||
{t('兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API')}
|
{variant === 'user'
|
||||||
|
? t('支持 OpenAI / Claude Messages / 图像 / 视频 / Comfy Cloud 工作流')
|
||||||
|
: t('兼容 OpenAI / Claude Messages / Comfy Cloud / RunningHub / 火山方舟等多类上游')}
|
||||||
|
</div>
|
||||||
|
<div className="login-metric-grid" aria-label={t(variant === 'user' ? '用户能力概览' : '平台能力指标')}>
|
||||||
|
{metrics.map((metric) => (
|
||||||
|
<div key={metric.labelKey} className="login-metric-card">
|
||||||
|
<strong>{t(metric.valueKey)}</strong>
|
||||||
|
<span>{t(metric.labelKey)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="login-capability-list">
|
<div className="login-capability-list">
|
||||||
{ADMIN_CAPABILITY_KEYS.map((feature, index) => (
|
{capabilities.map((feature, index) => (
|
||||||
<div key={feature.titleKey} className="login-capability-row">
|
<div key={feature.titleKey} className="login-capability-row">
|
||||||
<div className="login-capability-index">
|
<div className="login-capability-index">
|
||||||
{String(index + 1).padStart(2, '0')}
|
{String(index + 1).padStart(2, '0')}
|
||||||
@@ -81,24 +104,22 @@ export default function LoginShell({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
<section className="login-auth-stage">
|
<section className="login-auth-stage">
|
||||||
<div className="login-auth-panel">
|
<div className="login-auth-panel">
|
||||||
<div className="login-auth-eyebrow">{eyebrow}</div>
|
<div className="login-auth-eyebrow">{eyebrow}</div>
|
||||||
<h2 className="login-auth-title">{title}</h2>
|
<h2 className="login-auth-title">{title}</h2>
|
||||||
<p className="login-auth-copy">{description}</p>
|
<p className="login-auth-copy">{description}</p>
|
||||||
{children}
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="alert alert-error animate-shake" style={{ marginBottom: 12 }}>
|
<div className="alert alert-error animate-shake" style={{ marginBottom: 12 }}>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{children}
|
||||||
{footer && <div className="login-auth-footer">{footer}</div>}
|
{footer && <div className="login-auth-footer">{footer}</div>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</MotionDiv>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { BrandGlyph } from '../../components/BrandIcon.js';
|
||||||
|
|
||||||
export interface OAuthSlotProps {
|
export interface OAuthSlotProps {
|
||||||
t: (text: string) => string;
|
t: (text: string) => string;
|
||||||
variant?: 'user';
|
variant?: 'user';
|
||||||
|
mode?: 'login' | 'register';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,27 +17,55 @@ export interface OAuthSlotProps {
|
|||||||
* This component intentionally performs no network I/O, no navigation, and
|
* This component intentionally performs no network I/O, no navigation, and
|
||||||
* does not touch `authSession`. Tests assert that property.
|
* does not touch `authSession`. Tests assert that property.
|
||||||
*/
|
*/
|
||||||
export default function OAuthSlot({ t }: OAuthSlotProps) {
|
const OAUTH_PROVIDER_KEYS = [
|
||||||
|
{ key: 'google', label: 'Google', icon: 'google-color' },
|
||||||
|
{ key: 'github', label: 'GitHub', icon: 'github' },
|
||||||
|
{ key: 'wechat', label: '微信', icon: 'wechat-color' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function OAuthSlot({ t, mode = 'login' }: OAuthSlotProps) {
|
||||||
|
const title = mode === 'register' ? t('第三方账号快速注册') : t('第三方登录');
|
||||||
|
const desc = mode === 'register'
|
||||||
|
? t('接入后可自动创建账号,并绑定同一用户身份。')
|
||||||
|
: t('管理员配置 OAuth 后即可启用这些入口。');
|
||||||
|
const pendingLabel = t('待配置');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="oauth-slot"
|
data-testid="oauth-slot"
|
||||||
className="login-oauth-slot"
|
className="login-oauth-slot"
|
||||||
style={{
|
data-mode={mode}
|
||||||
marginTop: 16,
|
|
||||||
padding: '12px 0',
|
|
||||||
borderTop: '1px dashed var(--color-border)',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
fontSize: 12,
|
|
||||||
color: 'var(--color-text-muted)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span>{t('第三方登录')}</span>
|
<div className="login-oauth-divider">
|
||||||
<span style={{ fontSize: 11 }}>
|
<span>{title}</span>
|
||||||
{t('即将上线,敬请期待')}
|
</div>
|
||||||
|
<div className="login-oauth-provider-row" aria-label={title}>
|
||||||
|
{OAUTH_PROVIDER_KEYS.map((provider) => (
|
||||||
|
<button
|
||||||
|
key={provider.key}
|
||||||
|
type="button"
|
||||||
|
className="login-oauth-provider"
|
||||||
|
disabled
|
||||||
|
aria-disabled="true"
|
||||||
|
data-provider={provider.key}
|
||||||
|
title={t('等待管理员配置 OAuth 后启用')}
|
||||||
|
>
|
||||||
|
<span className="login-oauth-provider-mark" aria-hidden="true">
|
||||||
|
<BrandGlyph
|
||||||
|
icon={provider.icon}
|
||||||
|
alt=""
|
||||||
|
size={18}
|
||||||
|
fallbackText={provider.label}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
<span className="login-oauth-provider-copy">
|
||||||
|
<span className="login-oauth-provider-name">{provider.label}</span>
|
||||||
|
<span className="login-oauth-provider-state">{pendingLabel}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="login-oauth-desc">{desc}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,22 +95,28 @@ export default function UserLoginPage({ t, onLogin }: UserLoginPageProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const description = mode === 'account'
|
const description = mode === 'account'
|
||||||
? t('使用邮箱和密码登录你的账号。')
|
? t('进入个人控制台,继续使用模型、工作流和工作空间。')
|
||||||
: t('使用账户中已签发的 API Key 直接登录。');
|
: t('适合开发者和自动化脚本,使用账户中已签发的 Key 进入。');
|
||||||
|
const modeContext = mode === 'account'
|
||||||
|
? {
|
||||||
|
title: t('推荐方式'),
|
||||||
|
desc: t('邮箱密码会保留完整的个人中心、工作空间和使用日志权限。'),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
title: t('开发者入口'),
|
||||||
|
desc: t('仅识别已绑定到当前账号的 API Key,登录后仍会进入同一个用户控制台。'),
|
||||||
|
};
|
||||||
const footer = (
|
const footer = (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center' }}>
|
<div className="login-auth-footer-links">
|
||||||
<span>
|
<span>
|
||||||
{t('还没有账号?')}
|
{t('还没有账号?')}
|
||||||
<Link
|
<Link to="/user/register">
|
||||||
to="/user/register"
|
|
||||||
style={{ color: 'var(--color-primary)', textDecoration: 'none', marginLeft: 4 }}
|
|
||||||
>
|
|
||||||
{t('注册')}
|
{t('注册')}
|
||||||
</Link>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
<span>
|
||||||
<Link to="/boos" style={{ color: 'var(--color-text-muted)' }}>
|
<Link to="/boos">
|
||||||
← {t('管理员登录')}
|
{t('管理员登录')}
|
||||||
</Link>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,6 +158,10 @@ export default function UserLoginPage({ t, onLogin }: UserLoginPageProps) {
|
|||||||
{t('API Key')}
|
{t('API Key')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="login-mode-context">
|
||||||
|
<span>{modeContext.title}</span>
|
||||||
|
<p>{modeContext.desc}</p>
|
||||||
|
</div>
|
||||||
{mode === 'account' ? (
|
{mode === 'account' ? (
|
||||||
<form onSubmit={handleAccountSubmit}>
|
<form onSubmit={handleAccountSubmit}>
|
||||||
<label className="login-auth-label" htmlFor="user-email-input">
|
<label className="login-auth-label" htmlFor="user-email-input">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ describe('Dashboard mobile layout', () => {
|
|||||||
|
|
||||||
expect(source).toContain('import { useIsMobile } from "../components/useIsMobile.js";');
|
expect(source).toContain('import { useIsMobile } from "../components/useIsMobile.js";');
|
||||||
expect(source).toContain('const isMobile = useIsMobile()');
|
expect(source).toContain('const isMobile = useIsMobile()');
|
||||||
expect(source).toContain('gridTemplateColumns: isMobile ? "1fr" : "1fr 1fr"');
|
expect(source).toContain('className="dashboard-chart-grid"');
|
||||||
expect(source).toContain('gridTemplateColumns: isMobile ? "1fr" : "1fr 300px"');
|
expect(source).toContain('gridTemplateColumns: isMobile ? "1fr" : "1fr 300px"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function normalizeVerifyFailureMessage(message: unknown): string {
|
|||||||
if (!text) return '验证失败';
|
if (!text) return '验证失败';
|
||||||
const lowered = text.toLowerCase();
|
const lowered = text.toLowerCase();
|
||||||
if (isNetworkFailureMessage(text)) {
|
if (isNetworkFailureMessage(text)) {
|
||||||
return '无法连接到 BossAI 服务端,请检查服务状态或网络连接';
|
return '无法连接到 BoosAPI 服务端,请检查服务状态或网络连接';
|
||||||
}
|
}
|
||||||
if (lowered.includes('user id mismatch') || lowered.includes('does not match this token')) {
|
if (lowered.includes('user id mismatch') || lowered.includes('does not match this token')) {
|
||||||
return '填写的用户 ID 与当前 Token / Cookie 不匹配';
|
return '填写的用户 ID 与当前 Token / Cookie 不匹配';
|
||||||
@@ -43,7 +43,7 @@ export function buildVerifyFailureHint(result: VerifyResultLike): string | null
|
|||||||
return '这不是 Token 错误判断。请检查填写的用户 ID 是否与当前 Token / Cookie 属于同一账号。';
|
return '这不是 Token 错误判断。请检查填写的用户 ID 是否与当前 Token / Cookie 属于同一账号。';
|
||||||
}
|
}
|
||||||
if (isNetworkFailureMessage(result.message)) {
|
if (isNetworkFailureMessage(result.message)) {
|
||||||
return '这不是 Token 错误判断。请检查 BossAI 服务是否在线,以及目标站点或代理是否可达。';
|
return '这不是 Token 错误判断。请检查 BoosAPI 服务是否在线,以及目标站点或代理是否可达。';
|
||||||
}
|
}
|
||||||
if (isTimeoutFailureMessage(result.message)) {
|
if (isTimeoutFailureMessage(result.message)) {
|
||||||
return '这不是 Token 错误判断。目标站点响应超时,请稍后重试或检查代理/网络。';
|
return '这不是 Token 错误判断。目标站点响应超时,请稍后重试或检查代理/网络。';
|
||||||
@@ -63,7 +63,7 @@ export function buildAddAccountPrereqHint(result: VerifyResultLike): string {
|
|||||||
return '请先修正用户 ID 并重新验证,验证成功后才能添加账号。';
|
return '请先修正用户 ID 并重新验证,验证成功后才能添加账号。';
|
||||||
}
|
}
|
||||||
if (isNetworkFailureMessage(result.message)) {
|
if (isNetworkFailureMessage(result.message)) {
|
||||||
return '验证请求未成功完成,请先检查 BossAI 服务、站点网络或代理配置。';
|
return '验证请求未成功完成,请先检查 BoosAPI 服务、站点网络或代理配置。';
|
||||||
}
|
}
|
||||||
if (isTimeoutFailureMessage(result.message)) {
|
if (isTimeoutFailureMessage(result.message)) {
|
||||||
return '验证请求超时,请先检查站点或代理连通性后再添加账号。';
|
return '验证请求超时,请先检查站点或代理连通性后再添加账号。';
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ describe('modelTesterSession', () => {
|
|||||||
name: 'draft.pdf',
|
name: 'draft.pdf',
|
||||||
mimeType: 'application/pdf',
|
mimeType: 'application/pdf',
|
||||||
dataUrl: 'data:application/pdf;base64,JVBERi0x',
|
dataUrl: 'data:application/pdf;base64,JVBERi0x',
|
||||||
fileId: 'file-metapi-draft',
|
fileId: 'file-boosapi-draft',
|
||||||
status: 'uploaded',
|
status: 'uploaded',
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
},
|
},
|
||||||
@@ -243,7 +243,7 @@ describe('modelTesterSession', () => {
|
|||||||
it('creates user conversation messages that preserve uploaded file references', () => {
|
it('creates user conversation messages that preserve uploaded file references', () => {
|
||||||
const message = createConversationUserMessage('请总结附件', [
|
const message = createConversationUserMessage('请总结附件', [
|
||||||
{
|
{
|
||||||
fileId: 'file-metapi-123',
|
fileId: 'file-boosapi-123',
|
||||||
filename: 'paper.pdf',
|
filename: 'paper.pdf',
|
||||||
mimeType: 'application/pdf',
|
mimeType: 'application/pdf',
|
||||||
},
|
},
|
||||||
@@ -254,7 +254,7 @@ describe('modelTesterSession', () => {
|
|||||||
expect(message.parts).toEqual([
|
expect(message.parts).toEqual([
|
||||||
{
|
{
|
||||||
type: 'input_file',
|
type: 'input_file',
|
||||||
fileId: 'file-metapi-123',
|
fileId: 'file-boosapi-123',
|
||||||
filename: 'paper.pdf',
|
filename: 'paper.pdf',
|
||||||
mimeType: 'application/pdf',
|
mimeType: 'application/pdf',
|
||||||
},
|
},
|
||||||
@@ -295,7 +295,7 @@ describe('modelTesterSession', () => {
|
|||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
type: 'input_file',
|
type: 'input_file',
|
||||||
fileId: 'file-metapi-456',
|
fileId: 'file-boosapi-456',
|
||||||
filename: 'appendix.txt',
|
filename: 'appendix.txt',
|
||||||
mimeType: 'text/plain',
|
mimeType: 'text/plain',
|
||||||
},
|
},
|
||||||
@@ -304,7 +304,7 @@ describe('modelTesterSession', () => {
|
|||||||
|
|
||||||
expect(files).toEqual([
|
expect(files).toEqual([
|
||||||
{
|
{
|
||||||
fileId: 'file-metapi-456',
|
fileId: 'file-boosapi-456',
|
||||||
filename: 'appendix.txt',
|
filename: 'appendix.txt',
|
||||||
mimeType: 'text/plain',
|
mimeType: 'text/plain',
|
||||||
},
|
},
|
||||||
@@ -322,7 +322,7 @@ describe('modelTesterSession', () => {
|
|||||||
const files = await resolveConversationReplayFiles?.(
|
const files = await resolveConversationReplayFiles?.(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
fileId: 'file-metapi-123',
|
fileId: 'file-boosapi-123',
|
||||||
filename: 'brief.pdf',
|
filename: 'brief.pdf',
|
||||||
mimeType: 'application/pdf',
|
mimeType: 'application/pdf',
|
||||||
},
|
},
|
||||||
@@ -331,10 +331,10 @@ describe('modelTesterSession', () => {
|
|||||||
loader,
|
loader,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(loader).toHaveBeenCalledWith('file-metapi-123');
|
expect(loader).toHaveBeenCalledWith('file-boosapi-123');
|
||||||
expect(files).toEqual([
|
expect(files).toEqual([
|
||||||
{
|
{
|
||||||
fileId: 'file-metapi-123',
|
fileId: 'file-boosapi-123',
|
||||||
filename: 'brief.pdf',
|
filename: 'brief.pdf',
|
||||||
mimeType: 'application/pdf',
|
mimeType: 'application/pdf',
|
||||||
data: 'data:application/pdf;base64,JVBERi0x',
|
data: 'data:application/pdf;base64,JVBERi0x',
|
||||||
@@ -564,7 +564,7 @@ describe('modelTesterSession', () => {
|
|||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
type: 'input_file' as const,
|
type: 'input_file' as const,
|
||||||
fileId: 'file-metapi-123',
|
fileId: 'file-boosapi-123',
|
||||||
filename: 'brief.pdf',
|
filename: 'brief.pdf',
|
||||||
mimeType: 'application/pdf',
|
mimeType: 'application/pdf',
|
||||||
data: 'data:application/pdf;base64,JVBERi0x',
|
data: 'data:application/pdf;base64,JVBERi0x',
|
||||||
@@ -592,7 +592,7 @@ describe('modelTesterSession', () => {
|
|||||||
{
|
{
|
||||||
type: 'file',
|
type: 'file',
|
||||||
file: {
|
file: {
|
||||||
file_id: 'file-metapi-123',
|
file_id: 'file-boosapi-123',
|
||||||
filename: 'brief.pdf',
|
filename: 'brief.pdf',
|
||||||
mime_type: 'application/pdf',
|
mime_type: 'application/pdf',
|
||||||
},
|
},
|
||||||
@@ -625,7 +625,7 @@ describe('modelTesterSession', () => {
|
|||||||
{ type: 'input_text', text: 'reuse native file id' },
|
{ type: 'input_text', text: 'reuse native file id' },
|
||||||
{
|
{
|
||||||
type: 'input_file',
|
type: 'input_file',
|
||||||
file_id: 'file-metapi-123',
|
file_id: 'file-boosapi-123',
|
||||||
filename: 'brief.pdf',
|
filename: 'brief.pdf',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -76,12 +76,6 @@ describe('navigationFocus helpers', () => {
|
|||||||
type: 'proxy',
|
type: 'proxy',
|
||||||
})).toBe('/routes');
|
})).toBe('/routes');
|
||||||
|
|
||||||
expect(buildEventNavigationPath({
|
|
||||||
relatedType: 'update_center',
|
|
||||||
relatedId: null,
|
|
||||||
type: 'status',
|
|
||||||
})).toBe('/settings');
|
|
||||||
|
|
||||||
expect(buildEventNavigationPath({ type: 'proxy' })).toBe('/logs');
|
expect(buildEventNavigationPath({ type: 'proxy' })).toBe('/logs');
|
||||||
expect(buildEventNavigationPath({ type: 'checkin' })).toBe('/checkin');
|
expect(buildEventNavigationPath({ type: 'checkin' })).toBe('/checkin');
|
||||||
expect(buildEventNavigationPath({ type: 'site_notice' })).toBe('/site-announcements');
|
expect(buildEventNavigationPath({ type: 'site_notice' })).toBe('/site-announcements');
|
||||||
|
|||||||
@@ -98,9 +98,6 @@ export function buildEventNavigationPath(event: {
|
|||||||
if (relatedType === 'site_announcement' && relatedId) {
|
if (relatedType === 'site_announcement' && relatedId) {
|
||||||
return buildAnnouncementFocusPath(relatedId);
|
return buildAnnouncementFocusPath(relatedId);
|
||||||
}
|
}
|
||||||
if (relatedType === 'update_center') {
|
|
||||||
return '/settings';
|
|
||||||
}
|
|
||||||
if (relatedType === 'route') {
|
if (relatedType === 'route') {
|
||||||
return '/routes';
|
return '/routes';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { marked } from 'marked';
|
|
||||||
import { formatDateTimeLocal } from './checkinLogTime.js';
|
import { formatDateTimeLocal } from './checkinLogTime.js';
|
||||||
|
|
||||||
const VOID_TAGS = new Set(['br', 'hr', 'img']);
|
const VOID_TAGS = new Set(['br', 'hr', 'img']);
|
||||||
@@ -367,8 +366,7 @@ export function renderSiteAnnouncementHtml(content: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isLikelyMarkdown(raw)) {
|
if (isLikelyMarkdown(raw)) {
|
||||||
const rendered = String(marked.parse(raw, { gfm: true, breaks: true }));
|
return sanitizeAnnouncementHtml(renderMarkdown(raw));
|
||||||
return sanitizeAnnouncementHtml(rendered || renderMarkdown(raw));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return renderPlainText(raw);
|
return renderPlainText(raw);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user