feat: Codex 工作区改动迁移(支付管理/用户余额/创作页等)
将 Codex 在工作区累积的改动整体落到 refactor/rust-backend: 支付管理与用户余额页、各创作 surface、website 等。排除 boosapi.db/config.toml 等敏感文件。 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+41
-4
@@ -227,6 +227,24 @@ configure_fast_linker() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Enable sccache as the rustc wrapper when available. sccache caches compiled
|
||||||
|
# crates keyed by source + flags, so dependency rebuilds / clean builds / branch
|
||||||
|
# switches reuse prior output. It transparently skips incremental crates, so the
|
||||||
|
# dev profile still relies mainly on incremental; release/fast-release benefit
|
||||||
|
# most. Set BOOSAPI_SCCACHE=off to disable.
|
||||||
|
configure_sccache() {
|
||||||
|
case "${BOOSAPI_SCCACHE:-auto}" in
|
||||||
|
off|none|0) return 0 ;;
|
||||||
|
esac
|
||||||
|
if [ -n "${RUSTC_WRAPPER:-}" ]; then
|
||||||
|
return 0 # respect an externally configured wrapper
|
||||||
|
fi
|
||||||
|
if command -v sccache >/dev/null 2>&1; then
|
||||||
|
export RUSTC_WRAPPER="sccache"
|
||||||
|
log "Rust 编译缓存: sccache(BOOSAPI_SCCACHE=off 可关闭)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
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"
|
||||||
@@ -497,6 +515,7 @@ build_backend() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
configure_fast_linker
|
configure_fast_linker
|
||||||
|
configure_sccache
|
||||||
build_cargo_args
|
build_cargo_args
|
||||||
|
|
||||||
if [ "$CHECK_ONLY" -eq 1 ]; then
|
if [ "$CHECK_ONLY" -eq 1 ]; then
|
||||||
@@ -581,14 +600,32 @@ if [ "$FRONTEND_ONLY" -eq 1 ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$BACKEND_ONLY" -ne 1 ]; then
|
if [ "$BACKEND_ONLY" -ne 1 ]; then
|
||||||
|
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
|
||||||
|
# Production embeds frontend assets into the binary; keep the ordering
|
||||||
|
# explicit (frontend → workflow UI → backend) to avoid a race on the
|
||||||
|
# embedded files. No parallelism here.
|
||||||
build_frontend
|
build_frontend
|
||||||
build_public_website
|
build_public_website
|
||||||
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
|
|
||||||
ensure_workflow_ui_for_embedding
|
ensure_workflow_ui_for_embedding
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
build_backend
|
build_backend
|
||||||
|
else
|
||||||
|
# Dev / normal builds: frontend (vite) + website (next) run in the
|
||||||
|
# background while the backend (cargo) builds in the foreground. The
|
||||||
|
# backend stays in the main shell so it can still set BINARY and other
|
||||||
|
# globals the launch step depends on. Total time ≈ max(frontend, backend).
|
||||||
|
log "并行构建:前端/官网(后台)+ 后端(前台)..."
|
||||||
|
( build_frontend && build_public_website ) &
|
||||||
|
fe_pid=$!
|
||||||
|
be_status=0
|
||||||
|
build_backend || be_status=$?
|
||||||
|
fe_status=0
|
||||||
|
wait "$fe_pid" || fe_status=$?
|
||||||
|
[ "$be_status" -eq 0 ] || fail "后端构建失败(exit $be_status)"
|
||||||
|
[ "$fe_status" -eq 0 ] || fail "前端/官网构建失败(exit $fe_status)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
build_backend
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$CHECK_ONLY" -eq 1 ]; then
|
if [ "$CHECK_ONLY" -eq 1 ]; then
|
||||||
print_timing_summary
|
print_timing_summary
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build:web": "vite build",
|
"build:web": "vite build",
|
||||||
"build": "npm run build:web",
|
"build": "npm run build:web",
|
||||||
|
"build:all": "node scripts/build-all.mjs",
|
||||||
"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",
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
# 2026-05-15 技术报告 / Technical Report
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
本日完成 BoosAPI 品牌迁移、ComfyUI Agent 对话助手开发、Gitea CI/CD Runner 部署、Notification Service 能力梳理及部署文档重写。核心变更包括:将项目内 `Metapi` 全局替换为 `BoosAPI`,新增 `/comfyui-agent` 对话式 AI 视频生成入口,完成 SSE streaming 前后端链路,并完善生产部署文档与通知服务说明。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Metapi → BoosAPI 全局改名 / Global Rename
|
|
||||||
|
|
||||||
### 1.1 变更范围 / Change Scope
|
|
||||||
|
|
||||||
| 区域 / Area | 变更内容 / Changes |
|
|
||||||
|---|---|
|
|
||||||
| UI 界面 / UI | About 页面、侧边栏、登录页品牌名更新为 `BoosAPI` |
|
|
||||||
| i18n 翻译 / Internationalization | 所有语言文件中的 `Metapi` 替换为 `BoosAPI` |
|
|
||||||
| 后端通知 / Backend Notification | `notifyService.ts` 通知前缀由 `[metapi]` 改为 `[BoosAPI]` |
|
|
||||||
| 桌面元数据 / Desktop Metadata | Electron / Docker 描述信息更新 |
|
|
||||||
|
|
||||||
### 1.2 已完成的技术标识替换 / Completed Technical Identifier Changes
|
|
||||||
|
|
||||||
| 项目 / Item | 变更说明 / Description |
|
|
||||||
|---|---|
|
|
||||||
| HTTP Header | `x-metapi-*` → `x-boosapi-*` |
|
|
||||||
| 推理签名前缀 / Inference Signature Prefix | `metapi:` → `boosapi:` |
|
|
||||||
| 运行时属性 / Runtime Properties | `__metapiTruncated` 等 → `__boosapi*` |
|
|
||||||
| 环境变量 / Environment Variables | `METAPI_*` → `BOOSAPI_*` |
|
|
||||||
| Package Name | `package.json` 中 `name` 更新为 `boosapi` |
|
|
||||||
|
|
||||||
### 1.3 验证方式 / Verification
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -rn '[mM]etapi' src/ --include='*.ts' --include='*.tsx' --include='*.html' \
|
|
||||||
| grep -v '.test.' | grep -v node_modules | grep -iv 'boosapi'
|
|
||||||
```
|
|
||||||
|
|
||||||
预期结果:无输出。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. ComfyUI Agent 对话助手 / Conversational Assistant
|
|
||||||
|
|
||||||
### 2.1 功能概述 / Feature Overview
|
|
||||||
|
|
||||||
侧边栏新增「对话助手」入口,路由为 `/comfyui-agent`,提供与 AI 视频生成助手的对话式交互,支持文本、角色提示词、图像、场景、语音和 ComfyUI workflow 等多类型内容的流式渲染。
|
|
||||||
|
|
||||||
### 2.2 技术架构 / Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
用户 / User → POST /api/comfyui-agent/chat → BoosAPI → LLM 本地 API (streaming)
|
|
||||||
→ SSE 事件流 → BoosAPI 转发 → 浏览器
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3 后端实现 / Backend
|
|
||||||
|
|
||||||
| 文件 / File | 作用 / Responsibility |
|
|
||||||
|---|---|
|
|
||||||
| `src/server/routes/api/comfyuiAgent.ts` | SSE 流式聊天接口,调用本地 LLM API |
|
|
||||||
| `src/server/services/boosapi_comfy_nodes.py` | ComfyUI 自定义节点,默认连接 `http://127.0.0.1:4000/v1` |
|
|
||||||
|
|
||||||
### 2.4 前端实现 / Frontend
|
|
||||||
|
|
||||||
| 文件 / File | 作用 / Responsibility |
|
|
||||||
|---|---|
|
|
||||||
| `src/web/pages/ComfyUIAgent.tsx` | 对话界面组件,SSE 流式渲染 |
|
|
||||||
| `src/web/App.tsx` | 注册 `/comfyui-agent` 路由及侧边栏入口 |
|
|
||||||
|
|
||||||
### 2.5 SSE 事件类型 / SSE Event Types
|
|
||||||
|
|
||||||
| `type` | 含义 / Meaning | 前端渲染 / Rendering |
|
|
||||||
|---|---|---|
|
|
||||||
| `text` | 流式文本 | 追加到对话内容 |
|
|
||||||
| `character_prompts` | 四角度角色提示词 | 角色卡片 |
|
|
||||||
| `image` | 生成的角色图像 | 内联图片 |
|
|
||||||
| `scene` | 场景提示词 | 场景块 |
|
|
||||||
| `tts` | 语音音频 | 音频播放器 |
|
|
||||||
| `comfyui_workflow` | 完整 ComfyUI 工作流 JSON | 下载按钮 |
|
|
||||||
| `done` | 流结束 | 停止等待 |
|
|
||||||
|
|
||||||
### 2.6 验证结果 / Verification
|
|
||||||
|
|
||||||
| 命令 | 结果 |
|
|
||||||
|---|---|
|
|
||||||
| `npm install` | ✅ |
|
|
||||||
| `npm run typecheck:web` | ✅ 0 error |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Gitea CI/CD Runner 部署 / Runner Deployment
|
|
||||||
|
|
||||||
### 3.1 部署信息 / Deployment Info
|
|
||||||
|
|
||||||
| 项目 | 详情 |
|
|
||||||
|---|---|
|
|
||||||
| 系统 | Alpine Linux, OpenRC |
|
|
||||||
| 部署方式 | 裸机直跑,无 DinD |
|
|
||||||
| Runner 版本 | v0.1.3 |
|
|
||||||
| 注册模式 | `:host` 标签 |
|
|
||||||
| OpenRC 服务 | `/etc/init.d/gitea-runner` |
|
|
||||||
| 代理 | `HTTP_PROXY=http://127.0.0.1:7890` |
|
|
||||||
| `NO_PROXY` | 包含 `git.boos.lat` |
|
|
||||||
| 持久化 | `ephemeral: false` |
|
|
||||||
|
|
||||||
### 3.2 已知限制 / Known Limitations
|
|
||||||
|
|
||||||
- 不支持 Docker in Docker;CI 使用 `runs-on: host`
|
|
||||||
- 无原生 artifact 存储;构建产物需手动备份
|
|
||||||
- `actions/checkout` 偶发 timeout;可重试或使用本地 git 镜像
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Notification Service
|
|
||||||
|
|
||||||
### 4.1 支持通道 / Supported Channels
|
|
||||||
|
|
||||||
| 通道 | 配置前缀 | 协议 |
|
|
||||||
|---|---|---|
|
|
||||||
| Telegram | `telegram*` | Bot API |
|
|
||||||
| 企业微信 | `wecom*` | Webhook |
|
|
||||||
| 飞书 | `feishu*` | Webhook |
|
|
||||||
| 通用 Webhook | `webhook*` | JSON POST |
|
|
||||||
| Bark | `bark*` | HTTP, iOS |
|
|
||||||
| Server酱 | `serverchan*` | HTTP |
|
|
||||||
| SMTP | `smtp*` | Email |
|
|
||||||
|
|
||||||
### 4.2 Telegram 消息格式
|
|
||||||
|
|
||||||
- 纯文本,不启用 HTML/Markdown 解析
|
|
||||||
- 单条最大 3900 字符,超长截断 + `...(truncated)`
|
|
||||||
- 支持 Telegram Topics(`message_thread_id`)
|
|
||||||
|
|
||||||
### 4.3 节流机制 / Throttling
|
|
||||||
|
|
||||||
- 基于通知签名的全局冷静期:`notifyCooldownSec`
|
|
||||||
- 同一签名在冷静期内重复触发时合并为一次
|
|
||||||
- 冷静期为全局共享,不按通道独立
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 部署文档完善 / Deployment Documentation
|
|
||||||
|
|
||||||
Commit: `5bd847d`
|
|
||||||
|
|
||||||
对 `docs/deployment.md` 全量重写,修复所有 `metapi` 残留:
|
|
||||||
|
|
||||||
- 完整 `docker-compose.yml` 示例(含 healthcheck)
|
|
||||||
- `.env` 变量插值模式
|
|
||||||
- 全部环境变量参考表(20+ 变量)
|
|
||||||
- 扩展配置:外部 MySQL/PostgreSQL、Redis、资源限制
|
|
||||||
- 生产部署检查清单
|
|
||||||
- Nginx 反向代理(WebSocket + SSE)
|
|
||||||
- 管理后台 IP 白名单限制示例
|
|
||||||
- Docker run 常用选项表
|
|
||||||
- 升级/回滚操作流程
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 待办事项 / TODO
|
|
||||||
|
|
||||||
| 优先级 | 事项 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| High | 服务未运行 | CI 崩溃后容器未恢复,需确定 Docker vs `npm run dev` |
|
|
||||||
| Medium | Telegram 格式优化 | 待确认 HTML/Markdown/简化 |
|
|
||||||
| Medium | CI pipeline 稳定性 | Docker 镜像拉取超时,需排查网络 |
|
|
||||||
@@ -1,340 +0,0 @@
|
|||||||
# 内置 ComfyUI 视频助手 + SaaS 多租户方案
|
|
||||||
|
|
||||||
> 生成: gpt-5.5 via api.boos.lat | 2026-05-18
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 数据表设计 (SQLite migrations)
|
|
||||||
|
|
||||||
### 1.1 `tenants` 租户
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE tenants (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
slug TEXT UNIQUE NOT NULL,
|
|
||||||
plan TEXT NOT NULL DEFAULT 'free',
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
owner_user_id TEXT NOT NULL,
|
|
||||||
settings TEXT NOT NULL DEFAULT '{}',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.2 `tenant_members` 租户成员
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE tenant_members (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
role TEXT NOT NULL DEFAULT 'member',
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
UNIQUE(tenant_id, user_id)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.3 `subscriptions` 订阅与额度
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE subscriptions (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
|
||||||
plan TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
monthly_generations_limit INTEGER NOT NULL DEFAULT 100,
|
|
||||||
monthly_video_seconds_limit INTEGER NOT NULL DEFAULT 300,
|
|
||||||
storage_bytes_limit INTEGER NOT NULL DEFAULT 5368709120,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.4 `usage_counters` 用量统计
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE usage_counters (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
|
||||||
period TEXT NOT NULL,
|
|
||||||
generations_used INTEGER NOT NULL DEFAULT 0,
|
|
||||||
video_seconds_used INTEGER NOT NULL DEFAULT 0,
|
|
||||||
storage_bytes_used INTEGER NOT NULL DEFAULT 0,
|
|
||||||
ai_tokens_used INTEGER NOT NULL DEFAULT 0,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
UNIQUE(tenant_id, period)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.5 `comfy_workflows` 工作流模板
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE comfy_workflows (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
tenant_id TEXT REFERENCES tenants(id) ON DELETE CASCADE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
type TEXT NOT NULL DEFAULT 'video',
|
|
||||||
visibility TEXT NOT NULL DEFAULT 'private',
|
|
||||||
workflow_json TEXT NOT NULL,
|
|
||||||
input_schema TEXT NOT NULL DEFAULT '{}',
|
|
||||||
version INTEGER NOT NULL DEFAULT 1,
|
|
||||||
is_default INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_by TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.6 `video_jobs` 视频任务
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE video_jobs (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
|
||||||
user_id TEXT,
|
|
||||||
workflow_id TEXT REFERENCES comfy_workflows(id),
|
|
||||||
status TEXT NOT NULL DEFAULT 'queued',
|
|
||||||
priority INTEGER NOT NULL DEFAULT 100,
|
|
||||||
prompt TEXT,
|
|
||||||
negative_prompt TEXT,
|
|
||||||
params TEXT NOT NULL DEFAULT '{}',
|
|
||||||
comfy_prompt_id TEXT,
|
|
||||||
progress REAL NOT NULL DEFAULT 0,
|
|
||||||
error_message TEXT,
|
|
||||||
result_asset_id TEXT,
|
|
||||||
estimated_video_seconds INTEGER DEFAULT 0,
|
|
||||||
actual_video_seconds INTEGER DEFAULT 0,
|
|
||||||
started_at TEXT,
|
|
||||||
completed_at TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.7 `assets` 文件资产
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE assets (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
|
||||||
user_id TEXT,
|
|
||||||
job_id TEXT REFERENCES video_jobs(id) ON DELETE SET NULL,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
mime_type TEXT,
|
|
||||||
file_name TEXT,
|
|
||||||
storage_path TEXT NOT NULL,
|
|
||||||
public_url TEXT,
|
|
||||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
|
||||||
metadata TEXT NOT NULL DEFAULT '{}',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. API 路由设计
|
|
||||||
|
|
||||||
### 租户管理
|
|
||||||
```
|
|
||||||
POST /api/tenants → 创建租户
|
|
||||||
GET /api/tenants → 我的租户列表
|
|
||||||
GET /api/tenants/:id → 租户详情
|
|
||||||
PATCH /api/tenants/:id → 更新租户
|
|
||||||
```
|
|
||||||
|
|
||||||
### 成员管理
|
|
||||||
```
|
|
||||||
GET /api/tenants/:id/members → 成员列表
|
|
||||||
POST /api/tenants/:id/members/invite → 邀请成员
|
|
||||||
DELETE /api/tenants/:id/members/:uid → 移除成员
|
|
||||||
PATCH /api/tenants/:id/members/:uid/role → 修改角色
|
|
||||||
```
|
|
||||||
|
|
||||||
### 视频助手
|
|
||||||
```
|
|
||||||
POST /api/tenants/:id/video/analyze → 解析脚本(角色/场景/对话)
|
|
||||||
POST /api/tenants/:id/video/generate → 生成视频任务
|
|
||||||
GET /api/tenants/:id/video/jobs → 任务列表
|
|
||||||
GET /api/tenants/:id/video/jobs/:jid → 任务详情
|
|
||||||
POST /api/tenants/:id/video/jobs/:jid/cancel → 取消任务
|
|
||||||
GET /api/tenants/:id/video/jobs/:jid/events → SSE 进度
|
|
||||||
```
|
|
||||||
|
|
||||||
### ComfyUI 工作流
|
|
||||||
```
|
|
||||||
GET /api/tenants/:id/workflows → 工作流模板列表
|
|
||||||
POST /api/tenants/:id/workflows → 创建模板
|
|
||||||
GET /api/tenants/:id/workflows/:wid → 模板详情
|
|
||||||
DELETE /api/tenants/:id/workflows/:wid → 删除模板
|
|
||||||
```
|
|
||||||
|
|
||||||
### 资产生成(ai Helper)
|
|
||||||
```
|
|
||||||
POST /api/tenants/:id/ai/character → gpt-image-2 生成角色四视图
|
|
||||||
POST /api/tenants/:id/ai/scene → 生成场景图
|
|
||||||
POST /api/tenants/:id/ai/tts → TTS 语音
|
|
||||||
POST /api/tenants/:id/ai/prompt-optimize → 优化视频 prompt
|
|
||||||
```
|
|
||||||
|
|
||||||
### 用量与订阅
|
|
||||||
```
|
|
||||||
GET /api/tenants/:id/usage → 当前用量
|
|
||||||
GET /api/tenants/:id/subscription → 订阅信息
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 前端页面/组件结构
|
|
||||||
|
|
||||||
```
|
|
||||||
src/web/pages/
|
|
||||||
├── VideoAssistant.tsx ← 视频助手主页面(prompt→生成→预览)
|
|
||||||
├── VideoJobs.tsx ← 历史任务列表
|
|
||||||
├── WorkflowLibrary.tsx ← 工作流模板库
|
|
||||||
├── TenantSettings.tsx ← 租户设置
|
|
||||||
└── TenantMembers.tsx ← 成员管理
|
|
||||||
|
|
||||||
src/web/components/
|
|
||||||
├── video/
|
|
||||||
│ ├── ScriptEditor.tsx ← 自然语言脚本编辑
|
|
||||||
│ ├── ScriptPreview.tsx ← 脚本解析预览(角色/场景/对话)
|
|
||||||
│ ├── CharacterCard.tsx ← 角色四视图卡片
|
|
||||||
│ ├── SceneCard.tsx ← 场景图卡片
|
|
||||||
│ ├── TTSPlayer.tsx ← TTS 语音播放
|
|
||||||
│ ├── WorkflowExport.tsx ← ComfyUI 工作流导出
|
|
||||||
│ └── VideoPlayer.tsx ← 视频播放/下载
|
|
||||||
├── tenant/
|
|
||||||
│ ├── TenantSwitcher.tsx ← 租户切换
|
|
||||||
│ ├── MemberList.tsx ← 成员列表
|
|
||||||
│ ├── InviteDialog.tsx ← 邀请对话框
|
|
||||||
│ └── UsageQuota.tsx ← 用量/配额显示
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 核心业务流程
|
|
||||||
|
|
||||||
### 注册 → 创作 → 导出
|
|
||||||
|
|
||||||
```
|
|
||||||
1. 注册/登录
|
|
||||||
2. 自动创建个人租户(或个人空间)
|
|
||||||
3. 进入视频助手页面
|
|
||||||
4. 输入自然语言脚本
|
|
||||||
5. 系统解析 → 提取角色/场景/对话
|
|
||||||
6. 自动调用 AI 生成:
|
|
||||||
- 【CHARACTER】→ gpt-image-2 四视图
|
|
||||||
- 【SCENE】 → 场景图
|
|
||||||
- 【TTS】 → 语音
|
|
||||||
- 【WORKFLOW】 → ComfyUI 工作流 JSON
|
|
||||||
7. 预览所有生成内容
|
|
||||||
8. 导出/下载/提交 ComfyUI 执行
|
|
||||||
9. 视频生成 → 预览 → 下载
|
|
||||||
|
|
||||||
如需协作: → 创建组织 → 邀请成员 → 设置角色
|
|
||||||
```
|
|
||||||
|
|
||||||
### 租户管理流程
|
|
||||||
|
|
||||||
```
|
|
||||||
创建组织
|
|
||||||
→ 成为 owner
|
|
||||||
→ 可创建 API Key
|
|
||||||
→ 可邀请成员(邮箱/链接)
|
|
||||||
→ 角色: owner / admin / member
|
|
||||||
→ 每个成员有独立用量跟踪
|
|
||||||
|
|
||||||
套餐限制:
|
|
||||||
free: 50 次/月, 5GB 存储, 基础功能
|
|
||||||
pro: 500 次/月, 50GB, 全功能+更高配额
|
|
||||||
custom: 定制
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 文件变更清单
|
|
||||||
|
|
||||||
### 新增文件
|
|
||||||
|
|
||||||
**后端 Rust:**
|
|
||||||
```
|
|
||||||
src/routes/tenants/mod.rs → 租户 CRUD
|
|
||||||
src/routes/tenants/{handlers,types}.rs
|
|
||||||
src/routes/video_jobs/ → 视频任务管理
|
|
||||||
src/routes/video_jobs/{handlers,sse}.rs
|
|
||||||
src/routes/ai_assist.rs → AI 辅助生成(角色/场景/TTS/prompt)
|
|
||||||
src/routes/subscriptions.rs → 订阅/用量
|
|
||||||
src/services/tenant_service.rs → 租户业务逻辑
|
|
||||||
src/services/video_service.rs → 视频任务调度
|
|
||||||
src/services/usage_service.rs → 用量统计
|
|
||||||
src/services/comfy_client.rs → ComfyUI API 客户端
|
|
||||||
src/workers/video_worker.rs → 后台视频任务 Worker
|
|
||||||
src/middleware/tenant_context.rs → 租户上下文中间件
|
|
||||||
migrations/0010_tenants.sql
|
|
||||||
migrations/0011_tenant_members.sql
|
|
||||||
migrations/0012_subscriptions.sql
|
|
||||||
migrations/0013_usage_counters.sql
|
|
||||||
migrations/0014_comfy_workflows.sql
|
|
||||||
migrations/0015_video_jobs.sql
|
|
||||||
migrations/0016_assets.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
**前端:**
|
|
||||||
```
|
|
||||||
src/web/pages/VideoAssistant.tsx
|
|
||||||
src/web/pages/VideoJobs.tsx
|
|
||||||
src/web/pages/WorkflowLibrary.tsx
|
|
||||||
src/web/pages/TenantSettings.tsx
|
|
||||||
src/web/pages/TenantMembers.tsx
|
|
||||||
src/web/components/video/*.tsx
|
|
||||||
src/web/components/tenant/*.tsx
|
|
||||||
src/web/hooks/useVideoJobs.ts
|
|
||||||
src/web/hooks/useTenant.ts
|
|
||||||
src/web/hooks/useAssets.ts
|
|
||||||
src/web/hooks/useUsage.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
### 修改文件
|
|
||||||
|
|
||||||
```
|
|
||||||
src/routes/mod.rs → 注册新路由
|
|
||||||
src/web/App.tsx → 添加导航项和路由
|
|
||||||
src/routes/video_agent.rs → 增强现有 Agent SSE
|
|
||||||
src/services/mod.rs → 注册新服务
|
|
||||||
src/config.rs → 添加 ComfyUI/套餐配置
|
|
||||||
src/main.rs → 注册 Worker
|
|
||||||
src/db/mod.rs → 新增表类型
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 实施步骤(按优先级)
|
|
||||||
|
|
||||||
### P0 - 基础链路
|
|
||||||
1. migrations: tenants + video_jobs + assets 三张核心表
|
|
||||||
2. 租户上下文中间件
|
|
||||||
3. 视频助手 Agent SSE 增强 → 脚本解析 + AI 生成调用
|
|
||||||
4. 前端 VideoAssistant 页面(脚本输入→解析→预览→导出)
|
|
||||||
5. ComfyUI client → 提交 prompt + 获取结果
|
|
||||||
|
|
||||||
### P1 - 多租户
|
|
||||||
6. 租户 CRUD + 成员管理 + 邀请
|
|
||||||
7. 资源 tenant_id 隔离
|
|
||||||
8. 用量统计 + 套餐限制
|
|
||||||
9. API Key 管理
|
|
||||||
10. 顶栏租户切换 + 用量显示
|
|
||||||
|
|
||||||
### P2 - 生产增强
|
|
||||||
11. SSE 实时进度
|
|
||||||
12. Worker 后台任务队列
|
|
||||||
13. 视频任务历史 + 重试
|
|
||||||
14. ComfyUI 工作流模板库
|
|
||||||
15. AI prompt 优化(调用 gpt-5.5)
|
|
||||||
|
|
||||||
### P3 - 商业化
|
|
||||||
16. 订阅管理(Stripe/内部)
|
|
||||||
17. 多 ComfyUI 节点调度
|
|
||||||
18. 对象存储集成
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Build the management console (vite) and the public website (next) in
|
||||||
|
// parallel. Each child inherits stdio with a prefix so logs stay readable.
|
||||||
|
// Exits non-zero if any build fails. Does NOT build the Rust backend.
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
|
||||||
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
/** Run a command, prefixing each output line with [label]. */
|
||||||
|
function run(label, cmd, args, cwd) {
|
||||||
|
return new Promise((resolveJob) => {
|
||||||
|
const started = Date.now();
|
||||||
|
const child = spawn(cmd, args, { cwd, shell: false });
|
||||||
|
const pipe = (stream, sink) => {
|
||||||
|
let buf = '';
|
||||||
|
stream.on('data', (chunk) => {
|
||||||
|
buf += chunk.toString();
|
||||||
|
const lines = buf.split('\n');
|
||||||
|
buf = lines.pop() ?? '';
|
||||||
|
for (const line of lines) sink.write(`[${label}] ${line}\n`);
|
||||||
|
});
|
||||||
|
stream.on('end', () => {
|
||||||
|
if (buf) sink.write(`[${label}] ${buf}\n`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
pipe(child.stdout, process.stdout);
|
||||||
|
pipe(child.stderr, process.stderr);
|
||||||
|
child.on('close', (code) => {
|
||||||
|
const secs = ((Date.now() - started) / 1000).toFixed(1);
|
||||||
|
process.stdout.write(`[${label}] ${code === 0 ? '✓ 完成' : '✗ 失败'}(${secs}s)\n`);
|
||||||
|
resolveJob({ label, code: code ?? 1 });
|
||||||
|
});
|
||||||
|
child.on('error', (err) => {
|
||||||
|
process.stderr.write(`[${label}] 启动失败: ${err.message}\n`);
|
||||||
|
resolveJob({ label, code: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobs = [
|
||||||
|
run('web', 'npx', ['vite', 'build'], root),
|
||||||
|
run('website', 'npm', ['--prefix', 'website', 'run', 'build'], root),
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = await Promise.all(jobs);
|
||||||
|
const failed = results.filter((r) => r.code !== 0);
|
||||||
|
if (failed.length > 0) {
|
||||||
|
process.stderr.write(`\n构建失败: ${failed.map((r) => r.label).join(', ')}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
process.stdout.write('\n前端 + 官网构建完成。\n');
|
||||||
+7
-1
@@ -57,9 +57,15 @@ impl AppState {
|
|||||||
reqwest::Client::new()
|
reqwest::Client::new()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let db = Arc::new(db);
|
||||||
|
// Hand the payment module a process-wide DB handle so the Comfy Firebase
|
||||||
|
// refresh path can persist a rotated refresh token without threading
|
||||||
|
// &SqlitePool through every balance/checkout call site.
|
||||||
|
services::payment::init_payment_db(db.clone());
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
db: Arc::new(db),
|
db,
|
||||||
http,
|
http,
|
||||||
comfyui,
|
comfyui,
|
||||||
builtin_keys: Arc::new(builtin_keys),
|
builtin_keys: Arc::new(builtin_keys),
|
||||||
|
|||||||
+2
-4
@@ -144,10 +144,8 @@ 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(
|
let runtime_auth =
|
||||||
&pool,
|
runtime_auth::RuntimeAuthSettings::load(&pool, config.auth.proxy_token.clone())
|
||||||
config.auth.proxy_token.clone(),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.context("failed to load runtime auth settings")?;
|
.context("failed to load runtime auth settings")?;
|
||||||
|
|
||||||
|
|||||||
+122
-35
@@ -589,7 +589,8 @@ pub async fn execute_video_generation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn comfy_video_template_id(route: &UpstreamRoute) -> Option<String> {
|
fn comfy_video_template_id(route: &UpstreamRoute) -> Option<String> {
|
||||||
route.base_url
|
route
|
||||||
|
.base_url
|
||||||
.strip_prefix("comfy://")
|
.strip_prefix("comfy://")
|
||||||
.or_else(|| route.base_url.strip_prefix("comfy-template://"))
|
.or_else(|| route.base_url.strip_prefix("comfy-template://"))
|
||||||
.or_else(|| route.model.strip_prefix("comfy://"))
|
.or_else(|| route.model.strip_prefix("comfy://"))
|
||||||
@@ -614,14 +615,16 @@ async fn execute_comfy_video_generation(
|
|||||||
let workflow = crate::workflow::storage::get_workflow(&state.db, template_id, None, true)
|
let workflow = crate::workflow::storage::get_workflow(&state.db, template_id, None, true)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::BadRequest(format!("Comfy 视频模型模板不可用: {err}")))?;
|
.map_err(|err| AppError::BadRequest(format!("Comfy 视频模型模板不可用: {err}")))?;
|
||||||
let mut prompt = workflow_prompt_from_graph(&workflow.graph)
|
let mut prompt = workflow_prompt_from_graph(&workflow.graph).ok_or_else(|| {
|
||||||
.ok_or_else(|| AppError::BadRequest("Comfy 视频模型模板缺少 prompt/workflow JSON".into()))?;
|
AppError::BadRequest("Comfy 视频模型模板缺少 prompt/workflow JSON".into())
|
||||||
|
})?;
|
||||||
if prompt_contains_class_type(&prompt, "BoosAPITextToVideo") {
|
if prompt_contains_class_type(&prompt, "BoosAPITextToVideo") {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"Comfy 视频模型模板不能包含 BoosAPITextToVideo 自定义节点,请选择真实 Comfy 视频节点模板".into(),
|
"Comfy 视频模型模板不能包含 BoosAPITextToVideo 自定义节点,请选择真实 Comfy 视频节点模板".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let mut mapping = comfy_mapping_from_graph(&workflow.graph).unwrap_or_else(default_comfy_mapping);
|
let mut mapping =
|
||||||
|
comfy_mapping_from_graph(&workflow.graph).unwrap_or_else(default_comfy_mapping);
|
||||||
if !mapping_has_node(&mapping, &["prompt", "positive", "text", "promptNode"]) {
|
if !mapping_has_node(&mapping, &["prompt", "positive", "text", "promptNode"]) {
|
||||||
mapping = infer_comfy_mapping(&prompt);
|
mapping = infer_comfy_mapping(&prompt);
|
||||||
}
|
}
|
||||||
@@ -644,7 +647,9 @@ async fn execute_comfy_video_generation(
|
|||||||
.or_else(|| response.pointer("/data/prompt_id"))
|
.or_else(|| response.pointer("/data/prompt_id"))
|
||||||
.or_else(|| response.pointer("/data/promptId")),
|
.or_else(|| response.pointer("/data/promptId")),
|
||||||
)
|
)
|
||||||
.ok_or_else(|| AppError::UpstreamError(format!("Comfy response missing prompt_id: {response}")))?;
|
.ok_or_else(|| {
|
||||||
|
AppError::UpstreamError(format!("Comfy response missing prompt_id: {response}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"id": prompt_id,
|
"id": prompt_id,
|
||||||
@@ -732,7 +737,8 @@ fn scalar_string(value: Option<&Value>) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn workflow_prompt_from_graph(graph: &Value) -> Option<Value> {
|
fn workflow_prompt_from_graph(graph: &Value) -> Option<Value> {
|
||||||
graph.get("prompt")
|
graph
|
||||||
|
.get("prompt")
|
||||||
.cloned()
|
.cloned()
|
||||||
.or_else(|| graph.get("workflow").cloned())
|
.or_else(|| graph.get("workflow").cloned())
|
||||||
.or_else(|| graph.get("comfyPrompt").cloned())
|
.or_else(|| graph.get("comfyPrompt").cloned())
|
||||||
@@ -741,7 +747,8 @@ fn workflow_prompt_from_graph(graph: &Value) -> Option<Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn comfy_mapping_from_graph(graph: &Value) -> Option<Value> {
|
fn comfy_mapping_from_graph(graph: &Value) -> Option<Value> {
|
||||||
graph.pointer("/metadata/moneyPrinter/nodeMapping")
|
graph
|
||||||
|
.pointer("/metadata/moneyPrinter/nodeMapping")
|
||||||
.or_else(|| graph.pointer("/moneyPrinter/nodeMapping"))
|
.or_else(|| graph.pointer("/moneyPrinter/nodeMapping"))
|
||||||
.or_else(|| graph.pointer("/metadata/video/nodeMapping"))
|
.or_else(|| graph.pointer("/metadata/video/nodeMapping"))
|
||||||
.or_else(|| graph.pointer("/video/nodeMapping"))
|
.or_else(|| graph.pointer("/video/nodeMapping"))
|
||||||
@@ -772,11 +779,24 @@ fn graph_to_comfy_prompt(graph: &Value) -> Option<Value> {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
prompt.insert(id, json!({ "class_type": class_type, "inputs": inputs }));
|
prompt.insert(id, json!({ "class_type": class_type, "inputs": inputs }));
|
||||||
}
|
}
|
||||||
for edge in graph.get("edges").and_then(Value::as_array).into_iter().flatten() {
|
for edge in graph
|
||||||
let Some(from) = edge.get("from").and_then(Value::as_str) else { continue; };
|
.get("edges")
|
||||||
let Some(to) = edge.get("to").and_then(Value::as_str) else { continue; };
|
.and_then(Value::as_array)
|
||||||
let Some((from_id, _from_port)) = from.split_once('.') else { continue; };
|
.into_iter()
|
||||||
let Some((to_id, to_field)) = to.split_once('.') else { continue; };
|
.flatten()
|
||||||
|
{
|
||||||
|
let Some(from) = edge.get("from").and_then(Value::as_str) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(to) = edge.get("to").and_then(Value::as_str) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some((from_id, _from_port)) = from.split_once('.') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some((to_id, to_field)) = to.split_once('.') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
if let Some(inputs) = prompt
|
if let Some(inputs) = prompt
|
||||||
.get_mut(to_id)
|
.get_mut(to_id)
|
||||||
.and_then(Value::as_object_mut)
|
.and_then(Value::as_object_mut)
|
||||||
@@ -806,7 +826,12 @@ fn mapping_value<'a>(mapping: &'a Value, keys: &[&str]) -> Option<&'a Value> {
|
|||||||
fn mapping_node_id(mapping: &Value, keys: &[&str]) -> Option<String> {
|
fn mapping_node_id(mapping: &Value, keys: &[&str]) -> Option<String> {
|
||||||
mapping_value(mapping, keys).and_then(|value| {
|
mapping_value(mapping, keys).and_then(|value| {
|
||||||
if value.is_object() {
|
if value.is_object() {
|
||||||
scalar_string(value.get("nodeId").or_else(|| value.get("node_id")).or(Some(value)))
|
scalar_string(
|
||||||
|
value
|
||||||
|
.get("nodeId")
|
||||||
|
.or_else(|| value.get("node_id"))
|
||||||
|
.or(Some(value)),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
scalar_string(Some(value))
|
scalar_string(Some(value))
|
||||||
}
|
}
|
||||||
@@ -829,7 +854,10 @@ fn mapping_has_node(mapping: &Value, keys: &[&str]) -> bool {
|
|||||||
mapping_node_id(mapping, keys).is_some_and(|value| !value.trim().is_empty())
|
mapping_node_id(mapping, keys).is_some_and(|value| !value.trim().is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn first_node_by_class(prompt: &serde_json::Map<String, Value>, class_type: &str) -> Option<String> {
|
fn first_node_by_class(
|
||||||
|
prompt: &serde_json::Map<String, Value>,
|
||||||
|
class_type: &str,
|
||||||
|
) -> Option<String> {
|
||||||
prompt
|
prompt
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(_, node)| node.get("class_type").and_then(Value::as_str) == Some(class_type))
|
.find(|(_, node)| node.get("class_type").and_then(Value::as_str) == Some(class_type))
|
||||||
@@ -843,11 +871,22 @@ fn infer_comfy_mapping(prompt: &Value) -> Value {
|
|||||||
let prompt_node = object
|
let prompt_node = object
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(_, node)| {
|
.find(|(_, node)| {
|
||||||
let class_type = node.get("class_type").and_then(Value::as_str).unwrap_or_default().to_ascii_lowercase();
|
let class_type = node
|
||||||
|
.get("class_type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_ascii_lowercase();
|
||||||
let inputs = node
|
let inputs = node
|
||||||
.get("inputs")
|
.get("inputs")
|
||||||
.and_then(Value::as_object)
|
.and_then(Value::as_object)
|
||||||
.map(|value| value.keys().cloned().collect::<Vec<_>>().join(" ").to_ascii_lowercase())
|
.map(|value| {
|
||||||
|
value
|
||||||
|
.keys()
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
class_type.contains("text")
|
class_type.contains("text")
|
||||||
|| class_type.contains("prompt")
|
|| class_type.contains("prompt")
|
||||||
@@ -875,19 +914,29 @@ fn infer_comfy_mapping(prompt: &Value) -> Value {
|
|||||||
|
|
||||||
fn apply_node_input(prompt: &mut Value, node_id: &str, field: &str, value: Value) -> AppResult<()> {
|
fn apply_node_input(prompt: &mut Value, node_id: &str, field: &str, value: Value) -> AppResult<()> {
|
||||||
if node_id.trim().is_empty() {
|
if node_id.trim().is_empty() {
|
||||||
return Err(AppError::BadRequest("Comfy 视频模型节点映射包含空 nodeId".into()));
|
return Err(AppError::BadRequest(
|
||||||
|
"Comfy 视频模型节点映射包含空 nodeId".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let Some(node) = prompt.get_mut(node_id) else {
|
let Some(node) = prompt.get_mut(node_id) else {
|
||||||
return Err(AppError::BadRequest(format!("Comfy prompt missing node {node_id}")));
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Comfy prompt missing node {node_id}"
|
||||||
|
)));
|
||||||
};
|
};
|
||||||
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
|
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
|
||||||
return Err(AppError::BadRequest(format!("Comfy node {node_id} has no inputs")));
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Comfy node {node_id} has no inputs"
|
||||||
|
)));
|
||||||
};
|
};
|
||||||
inputs.insert(field.to_string(), value);
|
inputs.insert(field.to_string(), value);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value) -> AppResult<String> {
|
fn inject_comfy_video_inputs(
|
||||||
|
prompt: &mut Value,
|
||||||
|
mapping: &Value,
|
||||||
|
body: &Value,
|
||||||
|
) -> AppResult<String> {
|
||||||
let video_prompt = scalar_string(body.get("prompt"))
|
let video_prompt = scalar_string(body.get("prompt"))
|
||||||
.ok_or_else(|| AppError::BadRequest("prompt is required for Comfy video models".into()))?;
|
.ok_or_else(|| AppError::BadRequest("prompt is required for Comfy video models".into()))?;
|
||||||
let prompt_node = mapping_node_id(mapping, &["prompt", "positive", "text", "promptNode"])
|
let prompt_node = mapping_node_id(mapping, &["prompt", "positive", "text", "promptNode"])
|
||||||
@@ -895,27 +944,56 @@ fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value)
|
|||||||
apply_node_input(
|
apply_node_input(
|
||||||
prompt,
|
prompt,
|
||||||
&prompt_node,
|
&prompt_node,
|
||||||
&mapping_field(mapping, &["prompt", "positive", "text", "promptNode"], "prompt"),
|
&mapping_field(
|
||||||
|
mapping,
|
||||||
|
&["prompt", "positive", "text", "promptNode"],
|
||||||
|
"prompt",
|
||||||
|
),
|
||||||
json!(video_prompt),
|
json!(video_prompt),
|
||||||
)?;
|
)?;
|
||||||
if let Some(negative_node) = mapping_node_id(mapping, &["negativePrompt", "negative", "negativeNode"]) {
|
if let Some(negative_node) =
|
||||||
|
mapping_node_id(mapping, &["negativePrompt", "negative", "negativeNode"])
|
||||||
|
{
|
||||||
let negative = scalar_string(body.get("negative_prompt").or_else(|| body.get("negative")))
|
let negative = scalar_string(body.get("negative_prompt").or_else(|| body.get("negative")))
|
||||||
.unwrap_or_else(|| "low quality, blurry, distorted, watermark, text artifacts".to_string());
|
.unwrap_or_else(|| {
|
||||||
|
"low quality, blurry, distorted, watermark, text artifacts".to_string()
|
||||||
|
});
|
||||||
apply_node_input(
|
apply_node_input(
|
||||||
prompt,
|
prompt,
|
||||||
&negative_node,
|
&negative_node,
|
||||||
&mapping_field(mapping, &["negativePrompt", "negative", "negativeNode"], "text"),
|
&mapping_field(
|
||||||
|
mapping,
|
||||||
|
&["negativePrompt", "negative", "negativeNode"],
|
||||||
|
"text",
|
||||||
|
),
|
||||||
json!(negative),
|
json!(negative),
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
for (keys, request_keys, default_field) in [
|
for (keys, request_keys, default_field) in [
|
||||||
(&["ratio", "ratioNode"][..], &["ratio", "aspectRatio", "aspect_ratio"][..], "ratio"),
|
(
|
||||||
(&["resolution", "resolutionNode"][..], &["resolution", "size"][..], "resolution"),
|
&["ratio", "ratioNode"][..],
|
||||||
(&["duration", "durationNode"][..], &["duration"][..], "duration"),
|
&["ratio", "aspectRatio", "aspect_ratio"][..],
|
||||||
|
"ratio",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&["resolution", "resolutionNode"][..],
|
||||||
|
&["resolution", "size"][..],
|
||||||
|
"resolution",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&["duration", "durationNode"][..],
|
||||||
|
&["duration"][..],
|
||||||
|
"duration",
|
||||||
|
),
|
||||||
] {
|
] {
|
||||||
if let Some(node_id) = mapping_node_id(mapping, keys) {
|
if let Some(node_id) = mapping_node_id(mapping, keys) {
|
||||||
if let Some(value) = request_keys.iter().find_map(|key| body.get(*key).cloned()) {
|
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)?;
|
apply_node_input(
|
||||||
|
prompt,
|
||||||
|
&node_id,
|
||||||
|
&mapping_field(mapping, keys, default_field),
|
||||||
|
value,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -929,9 +1007,13 @@ fn prompt_contains_class_type(prompt: &Value, class_type: &str) -> bool {
|
|||||||
.get("class_type")
|
.get("class_type")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.is_some_and(|value| value == class_type)
|
.is_some_and(|value| value == class_type)
|
||||||
|| object.values().any(|value| prompt_contains_class_type(value, class_type))
|
|| object
|
||||||
|
.values()
|
||||||
|
.any(|value| prompt_contains_class_type(value, class_type))
|
||||||
}
|
}
|
||||||
Value::Array(items) => items.iter().any(|value| prompt_contains_class_type(value, class_type)),
|
Value::Array(items) => items
|
||||||
|
.iter()
|
||||||
|
.any(|value| prompt_contains_class_type(value, class_type)),
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1024,7 +1106,10 @@ fn comfy_failure_message(value: &Value) -> Option<String> {
|
|||||||
let status = scalar_string(value.get("status").or_else(|| value.get("state")))
|
let status = scalar_string(value.get("status").or_else(|| value.get("state")))
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_ascii_lowercase();
|
.to_ascii_lowercase();
|
||||||
if matches!(status.as_str(), "failed" | "error" | "cancelled" | "canceled") {
|
if matches!(
|
||||||
|
status.as_str(),
|
||||||
|
"failed" | "error" | "cancelled" | "canceled"
|
||||||
|
) {
|
||||||
return Some(
|
return Some(
|
||||||
scalar_string(value.pointer("/execution_error/exception_message"))
|
scalar_string(value.pointer("/execution_error/exception_message"))
|
||||||
.or_else(|| scalar_string(value.get("error")))
|
.or_else(|| scalar_string(value.get("error")))
|
||||||
@@ -1038,10 +1123,12 @@ fn comfy_failure_message(value: &Value) -> Option<String> {
|
|||||||
fn comfy_output_objects(value: &Value) -> bool {
|
fn comfy_output_objects(value: &Value) -> bool {
|
||||||
match value {
|
match value {
|
||||||
Value::Object(object) => {
|
Value::Object(object) => {
|
||||||
["videos", "gifs", "images"]
|
["videos", "gifs", "images"].iter().any(|key| {
|
||||||
.iter()
|
object
|
||||||
.any(|key| object.get(*key).and_then(Value::as_array).is_some_and(|items| !items.is_empty()))
|
.get(*key)
|
||||||
|| object.values().any(comfy_output_objects)
|
.and_then(Value::as_array)
|
||||||
|
.is_some_and(|items| !items.is_empty())
|
||||||
|
}) || object.values().any(comfy_output_objects)
|
||||||
}
|
}
|
||||||
Value::Array(items) => items.iter().any(comfy_output_objects),
|
Value::Array(items) => items.iter().any(comfy_output_objects),
|
||||||
_ => false,
|
_ => false,
|
||||||
|
|||||||
@@ -305,8 +305,7 @@ fn account_response_value(
|
|||||||
route_id,
|
route_id,
|
||||||
channel_id,
|
channel_id,
|
||||||
log_id,
|
log_id,
|
||||||
)) =
|
)) = runtime_health
|
||||||
runtime_health
|
|
||||||
{
|
{
|
||||||
let ok = status == "success";
|
let ok = status == "success";
|
||||||
let is_degraded_latency =
|
let is_degraded_latency =
|
||||||
@@ -314,7 +313,11 @@ fn account_response_value(
|
|||||||
let model = model_actual
|
let model = model_actual
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.filter(|value| !value.trim().is_empty())
|
.filter(|value| !value.trim().is_empty())
|
||||||
.or_else(|| model_requested.as_ref().filter(|value| !value.trim().is_empty()));
|
.or_else(|| {
|
||||||
|
model_requested
|
||||||
|
.as_ref()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
});
|
||||||
json!({
|
json!({
|
||||||
"state": if !ok {
|
"state": if !ok {
|
||||||
"unhealthy"
|
"unhealthy"
|
||||||
|
|||||||
+163
-30
@@ -20,7 +20,7 @@ use parking_lot::RwLock;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use std::collections::HashMap;
|
use std::collections::{BTreeSet, HashMap};
|
||||||
|
|
||||||
static PROXY_TEST_JOBS: Lazy<RwLock<HashMap<String, ProxyTestJob>>> =
|
static PROXY_TEST_JOBS: Lazy<RwLock<HashMap<String, ProxyTestJob>>> =
|
||||||
Lazy::new(|| RwLock::new(HashMap::new()));
|
Lazy::new(|| RwLock::new(HashMap::new()));
|
||||||
@@ -1242,6 +1242,14 @@ async fn runtime_settings(State(state): State<AppState>) -> AppResult<Json<Value
|
|||||||
false,
|
false,
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
|
body.insert(
|
||||||
|
"registrationDisabled".to_string(),
|
||||||
|
json!(setting_bool(
|
||||||
|
&settings,
|
||||||
|
"runtime.registration_disabled",
|
||||||
|
false,
|
||||||
|
)),
|
||||||
|
);
|
||||||
body.insert(
|
body.insert(
|
||||||
"codexUpstreamWebsocketEnabled".to_string(),
|
"codexUpstreamWebsocketEnabled".to_string(),
|
||||||
json!(setting_bool(
|
json!(setting_bool(
|
||||||
@@ -1541,6 +1549,7 @@ struct RuntimeSettingsUpdateRequest {
|
|||||||
system_proxy_url: Option<String>,
|
system_proxy_url: Option<String>,
|
||||||
payload_rules: Option<Value>,
|
payload_rules: Option<Value>,
|
||||||
model_availability_probe_enabled: Option<bool>,
|
model_availability_probe_enabled: Option<bool>,
|
||||||
|
registration_disabled: Option<bool>,
|
||||||
codex_upstream_websocket_enabled: Option<bool>,
|
codex_upstream_websocket_enabled: Option<bool>,
|
||||||
responses_compact_fallback_to_responses_enabled: Option<bool>,
|
responses_compact_fallback_to_responses_enabled: Option<bool>,
|
||||||
disable_cross_protocol_fallback: Option<bool>,
|
disable_cross_protocol_fallback: Option<bool>,
|
||||||
@@ -1624,6 +1633,14 @@ async fn update_runtime_settings(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
if let Some(disabled) = req.registration_disabled {
|
||||||
|
upsert_setting(
|
||||||
|
&state,
|
||||||
|
"runtime.registration_disabled",
|
||||||
|
bool_setting_value(disabled),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
if let Some(enabled) = req.codex_upstream_websocket_enabled {
|
if let Some(enabled) = req.codex_upstream_websocket_enabled {
|
||||||
upsert_setting(
|
upsert_setting(
|
||||||
&state,
|
&state,
|
||||||
@@ -1870,8 +1887,102 @@ async fn update_runtime_settings(
|
|||||||
Ok(Json(body))
|
Ok(Json(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn brand_list() -> Json<Value> {
|
async fn brand_list(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
Json(json!({ "brands": ["BoosAPI"], "current": "BoosAPI" }))
|
let mut brands = BTreeSet::new();
|
||||||
|
for model in load_model_brand_source_names(&state).await? {
|
||||||
|
if let Some(brand) = infer_model_brand(&model) {
|
||||||
|
brands.insert(brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"brands": brands.into_iter().collect::<Vec<_>>(),
|
||||||
|
"current": Value::Null,
|
||||||
|
"scope": "model-brand",
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_model_brand_source_names(state: &AppState) -> AppResult<Vec<String>> {
|
||||||
|
let rows: Vec<(String,)> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT model_name AS model FROM model_availability
|
||||||
|
UNION
|
||||||
|
SELECT model_name AS model FROM token_model_availability
|
||||||
|
UNION
|
||||||
|
SELECT model_pattern AS model FROM token_routes
|
||||||
|
UNION
|
||||||
|
SELECT COALESCE(model_mapping, '') AS model FROM token_routes
|
||||||
|
UNION
|
||||||
|
SELECT COALESCE(source_model, '') AS model FROM route_channels
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(model,)| model.trim().to_string())
|
||||||
|
.filter(|model| !model.is_empty() && !model.contains('*'))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn infer_model_brand(model: &str) -> Option<String> {
|
||||||
|
let normalized = model.trim();
|
||||||
|
if normalized.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let lower = normalized.to_ascii_lowercase();
|
||||||
|
let brand = if lower.starts_with("openai/")
|
||||||
|
|| lower.starts_with("gpt-")
|
||||||
|
|| lower.starts_with("o1")
|
||||||
|
|| lower.starts_with("o3")
|
||||||
|
|| lower.starts_with("o4")
|
||||||
|
{
|
||||||
|
"OpenAI"
|
||||||
|
} else if lower.starts_with("anthropic/") || lower.starts_with("claude") {
|
||||||
|
"Anthropic"
|
||||||
|
} else if lower.starts_with("google/") || lower.starts_with("gemini") {
|
||||||
|
"Google"
|
||||||
|
} else if lower.starts_with("deepseek") || lower.starts_with("deepseek-ai/") {
|
||||||
|
"DeepSeek"
|
||||||
|
} else if lower.starts_with("qwen")
|
||||||
|
|| lower.starts_with("qwen/")
|
||||||
|
|| lower.starts_with("alibaba/")
|
||||||
|
|| lower.starts_with("alibaba-cloud/")
|
||||||
|
{
|
||||||
|
"Qwen"
|
||||||
|
} else if lower.starts_with("glm")
|
||||||
|
|| lower.starts_with("zhipu")
|
||||||
|
|| lower.starts_with("bigmodel/")
|
||||||
|
{
|
||||||
|
"GLM"
|
||||||
|
} else if lower.starts_with("moonshot") || lower.starts_with("kimi") {
|
||||||
|
"Moonshot"
|
||||||
|
} else if lower.starts_with("minimax") || lower.starts_with("abab") {
|
||||||
|
"MiniMax"
|
||||||
|
} else if lower.starts_with("doubao")
|
||||||
|
|| lower.starts_with("seedance")
|
||||||
|
|| lower.starts_with("seedream")
|
||||||
|
|| lower.starts_with("volcengine/")
|
||||||
|
|| lower.starts_with("bytedance/")
|
||||||
|
{
|
||||||
|
"Doubao"
|
||||||
|
} else if lower.starts_with("meta-llama/") || lower.starts_with("llama") {
|
||||||
|
"Llama"
|
||||||
|
} else if lower.starts_with("mistral") || lower.starts_with("mixtral") {
|
||||||
|
"Mistral"
|
||||||
|
} else if lower.starts_with("x-ai/") || lower.starts_with("grok") {
|
||||||
|
"xAI"
|
||||||
|
} else if let Some((prefix, _)) = normalized.split_once('/') {
|
||||||
|
let prefix = prefix.trim();
|
||||||
|
if prefix.eq_ignore_ascii_case("boosapi") || prefix.eq_ignore_ascii_case("comfy") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
return Some(prefix.to_string());
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
Some(brand.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn test_system_proxy() -> Json<Value> {
|
async fn test_system_proxy() -> Json<Value> {
|
||||||
@@ -1952,7 +2063,9 @@ fn normalize_proxy_test_path(path: &str) -> AppResult<String> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
if !normalized.starts_with('/') {
|
if !normalized.starts_with('/') {
|
||||||
return Err(AppError::BadRequest("proxy test path must start with /".into()));
|
return Err(AppError::BadRequest(
|
||||||
|
"proxy test path must start with /".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let base_path = proxy_test_base_path(normalized);
|
let base_path = proxy_test_base_path(normalized);
|
||||||
let allowed = [
|
let allowed = [
|
||||||
@@ -1968,7 +2081,8 @@ fn normalize_proxy_test_path(path: &str) -> AppResult<String> {
|
|||||||
"/v1/videos",
|
"/v1/videos",
|
||||||
];
|
];
|
||||||
let allowed_exact = allowed.iter().any(|item| base_path == *item);
|
let allowed_exact = allowed.iter().any(|item| base_path == *item);
|
||||||
let allowed_video_task = base_path.starts_with("/v1/videos/") && base_path.len() > "/v1/videos/".len();
|
let allowed_video_task =
|
||||||
|
base_path.starts_with("/v1/videos/") && base_path.len() > "/v1/videos/".len();
|
||||||
let allowed_gemini = is_gemini_proxy_test_path(normalized);
|
let allowed_gemini = is_gemini_proxy_test_path(normalized);
|
||||||
if allowed_exact || allowed_video_task || allowed_gemini {
|
if allowed_exact || allowed_video_task || allowed_gemini {
|
||||||
return Ok(strip_legacy_gemini_proxy_prefix(normalized).to_string());
|
return Ok(strip_legacy_gemini_proxy_prefix(normalized).to_string());
|
||||||
@@ -1988,9 +2102,9 @@ fn path_suffix_for_proxy_test(path: &str) -> AppResult<String> {
|
|||||||
if is_gemini_proxy_test_path(without_query) {
|
if is_gemini_proxy_test_path(without_query) {
|
||||||
return Ok(without_query.trim_start_matches('/').to_string());
|
return Ok(without_query.trim_start_matches('/').to_string());
|
||||||
}
|
}
|
||||||
let suffix = without_query
|
let suffix = without_query.strip_prefix("/v1").ok_or_else(|| {
|
||||||
.strip_prefix("/v1")
|
AppError::BadRequest(format!("unsupported proxy test path: {without_query}"))
|
||||||
.ok_or_else(|| AppError::BadRequest(format!("unsupported proxy test path: {without_query}")))?;
|
})?;
|
||||||
Ok(if suffix.is_empty() {
|
Ok(if suffix.is_empty() {
|
||||||
"/".to_string()
|
"/".to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -2089,7 +2203,9 @@ fn native_gemini_proxy_test_url(base_url: &str, path: &str) -> AppResult<String>
|
|||||||
let normalized = normalize_proxy_test_path(path)?;
|
let normalized = normalize_proxy_test_path(path)?;
|
||||||
let trimmed_base = base_url.trim().trim_end_matches('/');
|
let trimmed_base = base_url.trim().trim_end_matches('/');
|
||||||
if trimmed_base.is_empty() {
|
if trimmed_base.is_empty() {
|
||||||
return Err(AppError::BadRequest("Gemini upstream base URL is empty".into()));
|
return Err(AppError::BadRequest(
|
||||||
|
"Gemini upstream base URL is empty".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let base = if trimmed_base.ends_with("/v1beta") || trimmed_base.contains("/v1beta/") {
|
let base = if trimmed_base.ends_with("/v1beta") || trimmed_base.contains("/v1beta/") {
|
||||||
trimmed_base.to_string()
|
trimmed_base.to_string()
|
||||||
@@ -2281,7 +2397,10 @@ async fn execute_proxy_test(
|
|||||||
) -> Result<Value, Value> {
|
) -> Result<Value, Value> {
|
||||||
if envelope.request_kind == "multipart"
|
if envelope.request_kind == "multipart"
|
||||||
|| envelope.multipart_fields.is_some()
|
|| envelope.multipart_fields.is_some()
|
||||||
|| envelope.multipart_files.as_ref().is_some_and(|files| !files.is_empty())
|
|| envelope
|
||||||
|
.multipart_files
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|files| !files.is_empty())
|
||||||
{
|
{
|
||||||
return Err(error_json_for_proxy_test(
|
return Err(error_json_for_proxy_test(
|
||||||
&envelope,
|
&envelope,
|
||||||
@@ -2403,11 +2522,9 @@ async fn stream_proxy_test(
|
|||||||
Json(json!({ "error": error_json_for_proxy_test(&envelope, err) })),
|
Json(json!({ "error": error_json_for_proxy_test(&envelope, err) })),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let stream = response.bytes_stream().map(|chunk| {
|
let stream = response
|
||||||
chunk
|
.bytes_stream()
|
||||||
.map_err(std::io::Error::other)
|
.map(|chunk| chunk.map_err(std::io::Error::other).map(bytes::Bytes::from));
|
||||||
.map(bytes::Bytes::from)
|
|
||||||
});
|
|
||||||
return Response::builder()
|
return Response::builder()
|
||||||
.header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
|
.header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
|
||||||
.header(header::CACHE_CONTROL, "no-cache, no-transform")
|
.header(header::CACHE_CONTROL, "no-cache, no-transform")
|
||||||
@@ -2441,11 +2558,9 @@ async fn stream_proxy_test(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let stream = response.bytes_stream().map(|chunk| {
|
let stream = response
|
||||||
chunk
|
.bytes_stream()
|
||||||
.map_err(std::io::Error::other)
|
.map(|chunk| chunk.map_err(std::io::Error::other).map(bytes::Bytes::from));
|
||||||
.map(bytes::Bytes::from)
|
|
||||||
});
|
|
||||||
Response::builder()
|
Response::builder()
|
||||||
.header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
|
.header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
|
||||||
.header(header::CACHE_CONTROL, "no-cache, no-transform")
|
.header(header::CACHE_CONTROL, "no-cache, no-transform")
|
||||||
@@ -2840,6 +2955,8 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
|
payment: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
@@ -2852,8 +2969,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn proxy_test_accepts_standard_and_legacy_gemini_paths() {
|
fn proxy_test_accepts_standard_and_legacy_gemini_paths() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
normalize_proxy_test_path("/v1beta/models/gemini-2.5-pro:generateContent")
|
normalize_proxy_test_path("/v1beta/models/gemini-2.5-pro:generateContent").unwrap(),
|
||||||
.unwrap(),
|
|
||||||
"/v1beta/models/gemini-2.5-pro:generateContent"
|
"/v1beta/models/gemini-2.5-pro:generateContent"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -2981,12 +3097,10 @@ mod tests {
|
|||||||
let error = result.expect_err("unknown paths should fail");
|
let error = result.expect_err("unknown paths should fail");
|
||||||
|
|
||||||
assert_eq!(error["path"], "/api/test/proxy/jobs");
|
assert_eq!(error["path"], "/api/test/proxy/jobs");
|
||||||
assert!(
|
assert!(error["message"]
|
||||||
error["message"]
|
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.contains("unsupported proxy test path")
|
.contains("unsupported proxy test path"));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -3058,12 +3172,10 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(error["path"], "/v1/chat/completions");
|
assert_eq!(error["path"], "/v1/chat/completions");
|
||||||
assert_eq!(error["forcedChannelId"], 77);
|
assert_eq!(error["forcedChannelId"], 77);
|
||||||
assert!(
|
assert!(error["message"]
|
||||||
error["message"]
|
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.contains("Forced channel #77")
|
.contains("Forced channel #77"));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -3130,4 +3242,25 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(result["model"], "upstream-mini");
|
assert_eq!(result["model"], "upstream-mini");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn infers_model_brands_without_boosapi_platform_brand() {
|
||||||
|
assert_eq!(infer_model_brand("gpt-4o-mini").as_deref(), Some("OpenAI"));
|
||||||
|
assert_eq!(
|
||||||
|
infer_model_brand("claude-3-5-sonnet").as_deref(),
|
||||||
|
Some("Anthropic")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
infer_model_brand("deepseek-ai/DeepSeek-V3.2").as_deref(),
|
||||||
|
Some("DeepSeek")
|
||||||
|
);
|
||||||
|
assert_eq!(infer_model_brand("Qwen/Qwen3-32B").as_deref(), Some("Qwen"));
|
||||||
|
assert_eq!(
|
||||||
|
infer_model_brand("seedance-1-0-lite-t2v").as_deref(),
|
||||||
|
Some("Doubao")
|
||||||
|
);
|
||||||
|
assert_eq!(infer_model_brand("BoosAPI").as_deref(), None);
|
||||||
|
assert_eq!(infer_model_brand("boosapi/text-to-image").as_deref(), None);
|
||||||
|
assert_eq!(infer_model_brand("comfy/template").as_deref(), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -412,6 +412,8 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
|
payment: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
|
|||||||
+49
-14
@@ -913,8 +913,13 @@ async fn probe_site_model(
|
|||||||
|
|
||||||
match response {
|
match response {
|
||||||
Ok(_) if latency_exceeded => {
|
Ok(_) if latency_exceeded => {
|
||||||
if let Err(err) =
|
if let Err(err) = update_model_probe_latency(
|
||||||
update_model_probe_latency(state, selected.account_id, &model.name, latency_ms, true)
|
state,
|
||||||
|
selected.account_id,
|
||||||
|
&model.name,
|
||||||
|
latency_ms,
|
||||||
|
true,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
warn!(error = %err, model = %model.name, "failed to save probe latency");
|
warn!(error = %err, model = %model.name, "failed to save probe latency");
|
||||||
@@ -924,7 +929,10 @@ async fn probe_site_model(
|
|||||||
status: "unsupported".to_string(),
|
status: "unsupported".to_string(),
|
||||||
latency_ms: Some(latency_ms),
|
latency_ms: Some(latency_ms),
|
||||||
latency_exceeded: true,
|
latency_exceeded: true,
|
||||||
reason: Some(format!("响应延迟 {}ms 超过阈值 {}ms", latency_ms, plan.latency_threshold_ms)),
|
reason: Some(format!(
|
||||||
|
"响应延迟 {}ms 超过阈值 {}ms",
|
||||||
|
latency_ms, plan.latency_threshold_ms
|
||||||
|
)),
|
||||||
http_status: None,
|
http_status: None,
|
||||||
stage: Some("model"),
|
stage: Some("model"),
|
||||||
source: Some("probe"),
|
source: Some("probe"),
|
||||||
@@ -947,8 +955,13 @@ async fn probe_site_model(
|
|||||||
warn!(channel_id, error = %err, "failed to record probe success");
|
warn!(channel_id, error = %err, "failed to record probe success");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Err(err) =
|
if let Err(err) = update_model_probe_latency(
|
||||||
update_model_probe_latency(state, selected.account_id, &model.name, latency_ms, true)
|
state,
|
||||||
|
selected.account_id,
|
||||||
|
&model.name,
|
||||||
|
latency_ms,
|
||||||
|
true,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
warn!(error = %err, model = %model.name, "failed to save probe latency");
|
warn!(error = %err, model = %model.name, "failed to save probe latency");
|
||||||
@@ -982,8 +995,13 @@ async fn probe_site_model(
|
|||||||
warn!(channel_id, error = %record_err, "failed to record probe failure");
|
warn!(channel_id, error = %record_err, "failed to record probe failure");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Err(update_err) =
|
if let Err(update_err) = update_model_probe_latency(
|
||||||
update_model_probe_latency(state, selected.account_id, &model.name, latency_ms, false)
|
state,
|
||||||
|
selected.account_id,
|
||||||
|
&model.name,
|
||||||
|
latency_ms,
|
||||||
|
false,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
warn!(error = %update_err, model = %model.name, "failed to save failed probe state");
|
warn!(error = %update_err, model = %model.name, "failed to save failed probe state");
|
||||||
@@ -1041,7 +1059,8 @@ async fn resolve_probe_channel(
|
|||||||
.bind(plan.site.id)
|
.bind(plan.site.id)
|
||||||
.fetch_optional(&*state.db)
|
.fetch_optional(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
fallback.ok_or_else(|| AppError::BadRequest("当前站点没有启用中的账号,无法探测".into()))?
|
fallback
|
||||||
|
.ok_or_else(|| AppError::BadRequest("当前站点没有启用中的账号,无法探测".into()))?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1067,7 +1086,9 @@ async fn resolve_probe_channel(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if api_key.trim().is_empty() {
|
if api_key.trim().is_empty() {
|
||||||
return Err(AppError::BadRequest("当前站点账号缺少可用 Token,无法探测".into()));
|
return Err(AppError::BadRequest(
|
||||||
|
"当前站点账号缺少可用 Token,无法探测".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(crate::proxy::conductor::SelectedChannel {
|
Ok(crate::proxy::conductor::SelectedChannel {
|
||||||
@@ -1165,7 +1186,9 @@ fn send_probe_sse(
|
|||||||
tx.send(Ok(Event::default()
|
tx.send(Ok(Event::default()
|
||||||
.event(event_type)
|
.event(event_type)
|
||||||
.json_data(payload)
|
.json_data(payload)
|
||||||
.unwrap_or_else(|_| Event::default().event(event_type).data("{}"))))
|
.unwrap_or_else(|_| {
|
||||||
|
Event::default().event(event_type).data("{}")
|
||||||
|
})))
|
||||||
.is_ok()
|
.is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1516,8 +1539,14 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(payload.get("httpStatus").and_then(Value::as_u64), Some(404));
|
assert_eq!(payload.get("httpStatus").and_then(Value::as_u64), Some(404));
|
||||||
assert_eq!(payload.get("stage").and_then(Value::as_str), Some("model"));
|
assert_eq!(payload.get("stage").and_then(Value::as_str), Some("model"));
|
||||||
assert_eq!(payload.get("source").and_then(Value::as_str), Some("upstream"));
|
assert_eq!(
|
||||||
assert_eq!(payload.get("modelName").and_then(Value::as_str), Some("gpt-4o"));
|
payload.get("source").and_then(Value::as_str),
|
||||||
|
Some("upstream")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload.get("modelName").and_then(Value::as_str),
|
||||||
|
Some("gpt-4o")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1528,9 +1557,15 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(payload.get("httpStatus").and_then(Value::as_u64), Some(404));
|
assert_eq!(payload.get("httpStatus").and_then(Value::as_u64), Some(404));
|
||||||
assert_eq!(payload.get("stage").and_then(Value::as_str), Some("model"));
|
assert_eq!(payload.get("stage").and_then(Value::as_str), Some("model"));
|
||||||
assert_eq!(payload.get("source").and_then(Value::as_str), Some("upstream"));
|
assert_eq!(
|
||||||
|
payload.get("source").and_then(Value::as_str),
|
||||||
|
Some("upstream")
|
||||||
|
);
|
||||||
assert_eq!(payload.get("siteId").and_then(Value::as_i64), Some(7));
|
assert_eq!(payload.get("siteId").and_then(Value::as_i64), Some(7));
|
||||||
assert_eq!(payload.get("modelName").and_then(Value::as_str), Some("gpt-4o"));
|
assert_eq!(
|
||||||
|
payload.get("modelName").and_then(Value::as_str),
|
||||||
|
Some("gpt-4o")
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload.get("message").and_then(Value::as_str),
|
payload.get("message").and_then(Value::as_str),
|
||||||
Some("上游返回 HTTP 404 Not Found:model missing")
|
Some("上游返回 HTTP 404 Not Found:model missing")
|
||||||
|
|||||||
+120
-80
@@ -19,6 +19,9 @@ use serde_json::{json, Value};
|
|||||||
use std::net::{IpAddr, SocketAddr};
|
use std::net::{IpAddr, SocketAddr};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
/// Credit (in USD) granted to a newly self-registered user's default API key.
|
||||||
|
pub const DEFAULT_NEW_USER_MAX_COST_USD: f64 = 1.0;
|
||||||
|
|
||||||
// ── Request / Response types ─────────────────────────────────────────
|
// ── Request / Response types ─────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -108,6 +111,21 @@ async fn register(
|
|||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
info!("Registering user: {}", payload.email);
|
info!("Registering user: {}", payload.email);
|
||||||
|
|
||||||
|
// Admin can disable self-service registration from 系统设置.
|
||||||
|
let registration_disabled: Option<String> =
|
||||||
|
sqlx::query_scalar("SELECT value FROM settings WHERE key = 'runtime.registration_disabled'")
|
||||||
|
.fetch_optional(&*state.db)
|
||||||
|
.await?;
|
||||||
|
if registration_disabled
|
||||||
|
.as_deref()
|
||||||
|
.map(|v| matches!(v.trim(), "true" | "1"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return Err(AppError::Forbidden(
|
||||||
|
"用户注册已被管理员关闭".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
if payload.email.trim().is_empty() || payload.username.trim().is_empty() {
|
if payload.email.trim().is_empty() || payload.username.trim().is_empty() {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"email and username are required".into(),
|
"email and username are required".into(),
|
||||||
@@ -177,11 +195,12 @@ async fn register(
|
|||||||
let default_key = format!("sk-user-{}", uuid::Uuid::new_v4().simple());
|
let default_key = format!("sk-user-{}", uuid::Uuid::new_v4().simple());
|
||||||
let default_key_name = format!("{} 的默认密钥", payload.username.trim());
|
let default_key_name = format!("{} 的默认密钥", payload.username.trim());
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO downstream_api_keys (name, key, user_id, enabled) VALUES (?, ?, ?, 1)",
|
"INSERT INTO downstream_api_keys (name, key, user_id, enabled, max_cost) VALUES (?, ?, ?, 1, ?)",
|
||||||
)
|
)
|
||||||
.bind(&default_key_name)
|
.bind(&default_key_name)
|
||||||
.bind(&default_key)
|
.bind(&default_key)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
|
.bind(DEFAULT_NEW_USER_MAX_COST_USD)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
@@ -237,14 +256,17 @@ async fn login(
|
|||||||
let user_agent = user_agent_from_headers(&headers);
|
let user_agent = user_agent_from_headers(&headers);
|
||||||
let email = payload.email.trim().to_string();
|
let email = payload.email.trim().to_string();
|
||||||
|
|
||||||
let user: User = if let Some(user) = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?")
|
let user: User = if let Some(user) =
|
||||||
|
sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?")
|
||||||
.bind(&email)
|
.bind(&email)
|
||||||
.fetch_optional(&*state.db)
|
.fetch_optional(&*state.db)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
user
|
user
|
||||||
} else {
|
} else {
|
||||||
audit_user_login(&state, LoginAuditEvent {
|
audit_user_login(
|
||||||
|
&state,
|
||||||
|
LoginAuditEvent {
|
||||||
method: LoginMethod::Password,
|
method: LoginMethod::Password,
|
||||||
success: false,
|
success: false,
|
||||||
user: None,
|
user: None,
|
||||||
@@ -253,13 +275,16 @@ async fn login(
|
|||||||
client_ip: &client_ip,
|
client_ip: &client_ip,
|
||||||
user_agent: user_agent.as_deref(),
|
user_agent: user_agent.as_deref(),
|
||||||
reason: Some("Invalid email or password"),
|
reason: Some("Invalid email or password"),
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
return Err(AppError::Unauthorized("Invalid email or password".into()));
|
return Err(AppError::Unauthorized("Invalid email or password".into()));
|
||||||
};
|
};
|
||||||
|
|
||||||
if user.status != "active" {
|
if user.status != "active" {
|
||||||
audit_user_login(&state, LoginAuditEvent {
|
audit_user_login(
|
||||||
|
&state,
|
||||||
|
LoginAuditEvent {
|
||||||
method: LoginMethod::Password,
|
method: LoginMethod::Password,
|
||||||
success: false,
|
success: false,
|
||||||
user: Some(&user),
|
user: Some(&user),
|
||||||
@@ -268,32 +293,19 @@ async fn login(
|
|||||||
client_ip: &client_ip,
|
client_ip: &client_ip,
|
||||||
user_agent: user_agent.as_deref(),
|
user_agent: user_agent.as_deref(),
|
||||||
reason: Some("Account is disabled"),
|
reason: Some("Account is disabled"),
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
return Err(AppError::Forbidden("Account is disabled".into()));
|
return Err(AppError::Forbidden("Account is disabled".into()));
|
||||||
}
|
}
|
||||||
if user.role == "admin" {
|
|
||||||
audit_user_login(&state, LoginAuditEvent {
|
|
||||||
method: LoginMethod::Password,
|
|
||||||
success: false,
|
|
||||||
user: Some(&user),
|
|
||||||
identifier: Some(&email),
|
|
||||||
api_key: None,
|
|
||||||
client_ip: &client_ip,
|
|
||||||
user_agent: user_agent.as_deref(),
|
|
||||||
reason: Some("Admin users must sign in with the admin API token"),
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
return Err(AppError::Forbidden(
|
|
||||||
"Admin users must sign in with the admin API token".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let valid = verify_password(&payload.password, &user.password_hash)
|
let valid = verify_password(&payload.password, &user.password_hash)
|
||||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Password verification failed: {e}")))?;
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("Password verification failed: {e}")))?;
|
||||||
|
|
||||||
if !valid {
|
if !valid {
|
||||||
audit_user_login(&state, LoginAuditEvent {
|
audit_user_login(
|
||||||
|
&state,
|
||||||
|
LoginAuditEvent {
|
||||||
method: LoginMethod::Password,
|
method: LoginMethod::Password,
|
||||||
success: false,
|
success: false,
|
||||||
user: Some(&user),
|
user: Some(&user),
|
||||||
@@ -302,7 +314,8 @@ async fn login(
|
|||||||
client_ip: &client_ip,
|
client_ip: &client_ip,
|
||||||
user_agent: user_agent.as_deref(),
|
user_agent: user_agent.as_deref(),
|
||||||
reason: Some("Invalid email or password"),
|
reason: Some("Invalid email or password"),
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
return Err(AppError::Unauthorized("Invalid email or password".into()));
|
return Err(AppError::Unauthorized("Invalid email or password".into()));
|
||||||
}
|
}
|
||||||
@@ -315,7 +328,9 @@ async fn login(
|
|||||||
)
|
)
|
||||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
|
||||||
|
|
||||||
audit_user_login(&state, LoginAuditEvent {
|
audit_user_login(
|
||||||
|
&state,
|
||||||
|
LoginAuditEvent {
|
||||||
method: LoginMethod::Password,
|
method: LoginMethod::Password,
|
||||||
success: true,
|
success: true,
|
||||||
user: Some(&user),
|
user: Some(&user),
|
||||||
@@ -324,7 +339,8 @@ async fn login(
|
|||||||
client_ip: &client_ip,
|
client_ip: &client_ip,
|
||||||
user_agent: user_agent.as_deref(),
|
user_agent: user_agent.as_deref(),
|
||||||
reason: None,
|
reason: None,
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -365,7 +381,9 @@ async fn login_api_key(
|
|||||||
let user_agent = user_agent_from_headers(&headers);
|
let user_agent = user_agent_from_headers(&headers);
|
||||||
let key = payload.api_key.trim();
|
let key = payload.api_key.trim();
|
||||||
if key.is_empty() {
|
if key.is_empty() {
|
||||||
audit_user_login(&state, LoginAuditEvent {
|
audit_user_login(
|
||||||
|
&state,
|
||||||
|
LoginAuditEvent {
|
||||||
method: LoginMethod::ApiKey,
|
method: LoginMethod::ApiKey,
|
||||||
success: false,
|
success: false,
|
||||||
user: None,
|
user: None,
|
||||||
@@ -374,7 +392,8 @@ async fn login_api_key(
|
|||||||
client_ip: &client_ip,
|
client_ip: &client_ip,
|
||||||
user_agent: user_agent.as_deref(),
|
user_agent: user_agent.as_deref(),
|
||||||
reason: Some("api_key is required"),
|
reason: Some("api_key is required"),
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
return Err(AppError::BadRequest("api_key is required".into()));
|
return Err(AppError::BadRequest("api_key is required".into()));
|
||||||
}
|
}
|
||||||
@@ -390,7 +409,9 @@ async fn login_api_key(
|
|||||||
api_key
|
api_key
|
||||||
} else {
|
} else {
|
||||||
let masked_key = mask_api_key(key);
|
let masked_key = mask_api_key(key);
|
||||||
audit_user_login(&state, LoginAuditEvent {
|
audit_user_login(
|
||||||
|
&state,
|
||||||
|
LoginAuditEvent {
|
||||||
method: LoginMethod::ApiKey,
|
method: LoginMethod::ApiKey,
|
||||||
success: false,
|
success: false,
|
||||||
user: None,
|
user: None,
|
||||||
@@ -399,14 +420,17 @@ async fn login_api_key(
|
|||||||
client_ip: &client_ip,
|
client_ip: &client_ip,
|
||||||
user_agent: user_agent.as_deref(),
|
user_agent: user_agent.as_deref(),
|
||||||
reason: Some("Invalid API key"),
|
reason: Some("Invalid API key"),
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
return Err(AppError::Unauthorized("Invalid API key".into()));
|
return Err(AppError::Unauthorized("Invalid API key".into()));
|
||||||
};
|
};
|
||||||
let user_id = if let Some(user_id) = api_key.user_id {
|
let user_id = if let Some(user_id) = api_key.user_id {
|
||||||
user_id
|
user_id
|
||||||
} else {
|
} else {
|
||||||
audit_user_login(&state, LoginAuditEvent {
|
audit_user_login(
|
||||||
|
&state,
|
||||||
|
LoginAuditEvent {
|
||||||
method: LoginMethod::ApiKey,
|
method: LoginMethod::ApiKey,
|
||||||
success: false,
|
success: false,
|
||||||
user: None,
|
user: None,
|
||||||
@@ -415,19 +439,25 @@ async fn login_api_key(
|
|||||||
client_ip: &client_ip,
|
client_ip: &client_ip,
|
||||||
user_agent: user_agent.as_deref(),
|
user_agent: user_agent.as_deref(),
|
||||||
reason: Some("API key is not bound to a user"),
|
reason: Some("API key is not bound to a user"),
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
return Err(AppError::Unauthorized("API key is not bound to a user".into()));
|
return Err(AppError::Unauthorized(
|
||||||
|
"API key is not bound to a user".into(),
|
||||||
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
let user: User = if let Some(user) = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
let user: User = if let Some(user) =
|
||||||
|
sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_optional(&*state.db)
|
.fetch_optional(&*state.db)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
user
|
user
|
||||||
} else {
|
} else {
|
||||||
audit_user_login(&state, LoginAuditEvent {
|
audit_user_login(
|
||||||
|
&state,
|
||||||
|
LoginAuditEvent {
|
||||||
method: LoginMethod::ApiKey,
|
method: LoginMethod::ApiKey,
|
||||||
success: false,
|
success: false,
|
||||||
user: None,
|
user: None,
|
||||||
@@ -436,13 +466,16 @@ async fn login_api_key(
|
|||||||
client_ip: &client_ip,
|
client_ip: &client_ip,
|
||||||
user_agent: user_agent.as_deref(),
|
user_agent: user_agent.as_deref(),
|
||||||
reason: Some("User not found"),
|
reason: Some("User not found"),
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
return Err(AppError::Unauthorized("User not found".into()));
|
return Err(AppError::Unauthorized("User not found".into()));
|
||||||
};
|
};
|
||||||
|
|
||||||
if user.status != "active" {
|
if user.status != "active" {
|
||||||
audit_user_login(&state, LoginAuditEvent {
|
audit_user_login(
|
||||||
|
&state,
|
||||||
|
LoginAuditEvent {
|
||||||
method: LoginMethod::ApiKey,
|
method: LoginMethod::ApiKey,
|
||||||
success: false,
|
success: false,
|
||||||
user: Some(&user),
|
user: Some(&user),
|
||||||
@@ -451,26 +484,11 @@ async fn login_api_key(
|
|||||||
client_ip: &client_ip,
|
client_ip: &client_ip,
|
||||||
user_agent: user_agent.as_deref(),
|
user_agent: user_agent.as_deref(),
|
||||||
reason: Some("Account is disabled"),
|
reason: Some("Account is disabled"),
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
return Err(AppError::Forbidden("Account is disabled".into()));
|
return Err(AppError::Forbidden("Account is disabled".into()));
|
||||||
}
|
}
|
||||||
if user.role == "admin" {
|
|
||||||
audit_user_login(&state, LoginAuditEvent {
|
|
||||||
method: LoginMethod::ApiKey,
|
|
||||||
success: false,
|
|
||||||
user: Some(&user),
|
|
||||||
identifier: Some(&user.email),
|
|
||||||
api_key: Some(&api_key),
|
|
||||||
client_ip: &client_ip,
|
|
||||||
user_agent: user_agent.as_deref(),
|
|
||||||
reason: Some("Admin users must sign in with the admin API token"),
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
return Err(AppError::Forbidden(
|
|
||||||
"Admin users must sign in with the admin API token".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Login via API key: user_id={}", user.id);
|
info!("Login via API key: user_id={}", user.id);
|
||||||
|
|
||||||
@@ -482,7 +500,9 @@ async fn login_api_key(
|
|||||||
)
|
)
|
||||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
|
||||||
|
|
||||||
audit_user_login(&state, LoginAuditEvent {
|
audit_user_login(
|
||||||
|
&state,
|
||||||
|
LoginAuditEvent {
|
||||||
method: LoginMethod::ApiKey,
|
method: LoginMethod::ApiKey,
|
||||||
success: true,
|
success: true,
|
||||||
user: Some(&user),
|
user: Some(&user),
|
||||||
@@ -491,7 +511,8 @@ async fn login_api_key(
|
|||||||
client_ip: &client_ip,
|
client_ip: &client_ip,
|
||||||
user_agent: user_agent.as_deref(),
|
user_agent: user_agent.as_deref(),
|
||||||
reason: None,
|
reason: None,
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -747,7 +768,11 @@ async fn audit_user_login(state: &AppState, audit: LoginAuditEvent<'_>) {
|
|||||||
.bind(message_parts.join(" · "))
|
.bind(message_parts.join(" · "))
|
||||||
.bind(level)
|
.bind(level)
|
||||||
.bind(audit.user.map(|user| user.id))
|
.bind(audit.user.map(|user| user.id))
|
||||||
.bind(if audit.user.is_some() { Some("user") } else { None::<&str> })
|
.bind(if audit.user.is_some() {
|
||||||
|
Some("user")
|
||||||
|
} else {
|
||||||
|
None::<&str>
|
||||||
|
})
|
||||||
.execute(&*state.db)
|
.execute(&*state.db)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -864,17 +889,17 @@ fn normalize_ip_token(raw: &str) -> Option<String> {
|
|||||||
token = host;
|
token = host;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
token
|
token.parse::<IpAddr>().map(|ip| ip.to_string()).ok()
|
||||||
.parse::<IpAddr>()
|
|
||||||
.map(|ip| ip.to_string())
|
|
||||||
.ok()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn truncate_for_audit(value: &str, max_chars: usize) -> String {
|
fn truncate_for_audit(value: &str, max_chars: usize) -> String {
|
||||||
if value.chars().count() <= max_chars {
|
if value.chars().count() <= max_chars {
|
||||||
return value.to_string();
|
return value.to_string();
|
||||||
}
|
}
|
||||||
let mut truncated = value.chars().take(max_chars.saturating_sub(1)).collect::<String>();
|
let mut truncated = value
|
||||||
|
.chars()
|
||||||
|
.take(max_chars.saturating_sub(1))
|
||||||
|
.collect::<String>();
|
||||||
truncated.push('…');
|
truncated.push('…');
|
||||||
truncated
|
truncated
|
||||||
}
|
}
|
||||||
@@ -1018,6 +1043,7 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
payment: Default::default(),
|
payment: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
@@ -1055,7 +1081,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn password_login_rejects_admin_users() {
|
async fn password_login_returns_admin_role_for_admin_users() {
|
||||||
let state = test_state().await;
|
let state = test_state().await;
|
||||||
let password_hash = hash_password("admin-password").expect("password hash");
|
let password_hash = hash_password("admin-password").expect("password hash");
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -1069,8 +1095,8 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("insert admin user");
|
.expect("insert admin user");
|
||||||
|
|
||||||
let err = login(
|
let Json(value) = login(
|
||||||
State(state),
|
State(state.clone()),
|
||||||
test_addr(),
|
test_addr(),
|
||||||
empty_headers(),
|
empty_headers(),
|
||||||
Json(LoginRequest {
|
Json(LoginRequest {
|
||||||
@@ -1079,9 +1105,12 @@ mod tests {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect_err("admin password login must be rejected");
|
.expect("admin password login should succeed");
|
||||||
|
|
||||||
assert!(matches!(err, AppError::Forbidden(message) if message.contains("admin API token")));
|
assert_eq!(value["user"]["role"], "admin");
|
||||||
|
let token = value["token"].as_str().expect("token");
|
||||||
|
let claims = validate_jwt(&state.config.auth.jwt_secret, token).expect("valid jwt");
|
||||||
|
assert_eq!(claims.role, "admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1147,7 +1176,8 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("regular user password login succeeds");
|
.expect("regular user password login succeeds");
|
||||||
|
|
||||||
let (event_type, title, message, related_id, related_type) = latest_audit_event(&state).await;
|
let (event_type, title, message, related_id, related_type) =
|
||||||
|
latest_audit_event(&state).await;
|
||||||
assert_eq!(event_type, "user_login");
|
assert_eq!(event_type, "user_login");
|
||||||
assert!(title.contains("用户登录成功"));
|
assert!(title.contains("用户登录成功"));
|
||||||
assert!(title.contains("Regular User <audit@example.test>"));
|
assert!(title.contains("Regular User <audit@example.test>"));
|
||||||
@@ -1160,7 +1190,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn api_key_login_rejects_admin_role() {
|
async fn api_key_login_returns_admin_role_for_admin_user() {
|
||||||
let state = test_state().await;
|
let state = test_state().await;
|
||||||
let password_hash = hash_password("unused-password").expect("password hash");
|
let password_hash = hash_password("unused-password").expect("password hash");
|
||||||
let user_id = sqlx::query(
|
let user_id = sqlx::query(
|
||||||
@@ -1186,8 +1216,8 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("insert bound api key");
|
.expect("insert bound api key");
|
||||||
|
|
||||||
let err = login_api_key(
|
let Json(value) = login_api_key(
|
||||||
State(state),
|
State(state.clone()),
|
||||||
test_addr(),
|
test_addr(),
|
||||||
empty_headers(),
|
empty_headers(),
|
||||||
Json(LoginApiKeyRequest {
|
Json(LoginApiKeyRequest {
|
||||||
@@ -1195,14 +1225,17 @@ mod tests {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect_err("admin api key login must be rejected");
|
.expect("admin api key login should succeed");
|
||||||
|
|
||||||
assert!(matches!(err, AppError::Forbidden(message) if message.contains("admin API token")));
|
assert_eq!(value["user"]["role"], "admin");
|
||||||
|
let token = value["token"].as_str().expect("token");
|
||||||
|
let claims = validate_jwt(&state.config.auth.jwt_secret, token).expect("valid jwt");
|
||||||
|
assert_eq!(claims.role, "admin");
|
||||||
assert!(user_id > 0);
|
assert!(user_id > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn api_key_login_rejects_latest_admin_role_after_promotion() {
|
async fn api_key_login_returns_latest_admin_role_after_promotion() {
|
||||||
let state = test_state().await;
|
let state = test_state().await;
|
||||||
let password_hash = hash_password("unused-password").expect("password hash");
|
let password_hash = hash_password("unused-password").expect("password hash");
|
||||||
let user_id = sqlx::query(
|
let user_id = sqlx::query(
|
||||||
@@ -1234,8 +1267,8 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("promote user to admin");
|
.expect("promote user to admin");
|
||||||
|
|
||||||
let err = login_api_key(
|
let Json(value) = login_api_key(
|
||||||
State(state),
|
State(state.clone()),
|
||||||
test_addr(),
|
test_addr(),
|
||||||
empty_headers(),
|
empty_headers(),
|
||||||
Json(LoginApiKeyRequest {
|
Json(LoginApiKeyRequest {
|
||||||
@@ -1243,9 +1276,12 @@ mod tests {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect_err("api key login must use latest role and reject admin");
|
.expect("api key login uses latest role and accepts admin");
|
||||||
|
|
||||||
assert!(matches!(err, AppError::Forbidden(message) if message.contains("admin API token")));
|
assert_eq!(value["user"]["role"], "admin");
|
||||||
|
let token = value["token"].as_str().expect("token");
|
||||||
|
let claims = validate_jwt(&state.config.auth.jwt_secret, token).expect("valid jwt");
|
||||||
|
assert_eq!(claims.role, "admin");
|
||||||
assert!(user_id > 0);
|
assert!(user_id > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1333,7 +1369,8 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("regular api key login succeeds");
|
.expect("regular api key login succeeds");
|
||||||
|
|
||||||
let (event_type, title, message, related_id, related_type) = latest_audit_event(&state).await;
|
let (event_type, title, message, related_id, related_type) =
|
||||||
|
latest_audit_event(&state).await;
|
||||||
assert_eq!(event_type, "user_login");
|
assert_eq!(event_type, "user_login");
|
||||||
assert!(title.contains("用户登录成功"));
|
assert!(title.contains("用户登录成功"));
|
||||||
assert!(title.contains("Regular User <key-audit@example.test>"));
|
assert!(title.contains("Regular User <key-audit@example.test>"));
|
||||||
@@ -1363,7 +1400,10 @@ mod tests {
|
|||||||
let Json(_) = login(
|
let Json(_) = login(
|
||||||
State(state.clone()),
|
State(state.clone()),
|
||||||
test_addr(),
|
test_addr(),
|
||||||
headers_with("x-forwarded-for", "2a09:bac2:3918:16dc::247:4d, 203.0.113.9"),
|
headers_with(
|
||||||
|
"x-forwarded-for",
|
||||||
|
"2a09:bac2:3918:16dc::247:4d, 203.0.113.9",
|
||||||
|
),
|
||||||
Json(LoginRequest {
|
Json(LoginRequest {
|
||||||
email: "ipv4@example.test".into(),
|
email: "ipv4@example.test".into(),
|
||||||
password: "user-password".into(),
|
password: "user-password".into(),
|
||||||
|
|||||||
@@ -60,8 +60,7 @@ 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 proxy_token = state.runtime_proxy_token();
|
||||||
let result =
|
let result = authorize_downstream_token(auth, &state.db, Some(&proxy_token))
|
||||||
authorize_downstream_token(auth, &state.db, Some(&proxy_token))
|
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Unauthorized)?;
|
.map_err(AppError::Unauthorized)?;
|
||||||
|
|
||||||
@@ -127,6 +126,8 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: ComfyUIConfig::default(),
|
comfyui: ComfyUIConfig::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
|
payment: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
|
|||||||
+288
-39
@@ -16,47 +16,30 @@ use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
|
|||||||
use tokio_stream::{wrappers::UnboundedReceiverStream, StreamExt};
|
use tokio_stream::{wrappers::UnboundedReceiverStream, StreamExt};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
const SYSTEM_PROMPT: &str = r#"你是 BoosAPI 的 AI 视频生成助手,帮用户通过 AI 制作视频。
|
const SYSTEM_PROMPT: &str = r#"你是 BoosAPI 的 AI 视频生成助手(智能体),帮用户通过 AI 制作视频。
|
||||||
|
|
||||||
## 规则
|
## 工作方式
|
||||||
1. 先用中文与用户讨论脚本构思、视觉风格、角色设定
|
1. 先用中文与用户讨论脚本构思、视觉风格、角色设定。
|
||||||
2. 用户确认后(说"开始制作"或"生成"),再分步输出结构化内容
|
2. 用户确认后(说"开始制作"或"生成"),调用工具逐步产出素材。
|
||||||
3. **分两步走:第一步输出角色+场景+TTS,第二步输出 WORKFLOW**(不要一次全出)
|
3. 你拥有以下工具,应主动按需调用,并根据每次工具返回的结果决定下一步:
|
||||||
|
- generate_character_images:为角色生成正/背/左/右四视图参考图
|
||||||
|
- generate_scene_image:生成单张场景/分镜图
|
||||||
|
- generate_tts:为一句台词合成语音
|
||||||
|
- build_comfyui_workflow:在素材就绪后交付最终可执行的 ComfyUI 工作流 JSON
|
||||||
|
|
||||||
## 结构化标记格式
|
## 调用原则
|
||||||
严格按照以下格式输出,系统会自动解析并生成图片/TTS:
|
- 一次可调用一个或多个工具;每个角色/场景/台词分别调用对应工具。
|
||||||
|
- 收到工具结果后继续推进,直到所有素材生成完毕,最后调用 build_comfyui_workflow 交付。
|
||||||
|
- 图像 prompt 用英文,包含 主体 + 视角 + 细节 + 画质标签。
|
||||||
|
- 没有用户明确要求时,不要自作主张批量生成;先沟通确认。
|
||||||
|
|
||||||
【CHARACTER】
|
## 兼容降级
|
||||||
name: 林澈
|
若你所在的模型不支持工具调用,可改用结构化标记输出(系统会解析):
|
||||||
description: 女宇航员,30岁,冷静坚毅,短发收在头盔内
|
【CHARACTER】name/description/prompt_front/prompt_back/prompt_left/prompt_right【/CHARACTER】
|
||||||
prompt_front: cinematic full body front view of a female astronaut, white EVA suit, photorealistic
|
【SCENE】id/description/prompt【/SCENE】
|
||||||
prompt_back: cinematic full body back view of a female astronaut, life support backpack
|
【TTS】character/text/emotion【/TTS】
|
||||||
prompt_left: cinematic full body left side view of a female astronaut, side utility pouches
|
【WORKFLOW】```json {...} ```【/WORKFLOW】
|
||||||
prompt_right: cinematic full body right side view of a female astronaut, helmet visor reflection
|
但只要支持工具调用,就优先使用工具,不要输出这些标记。
|
||||||
【/CHARACTER】
|
|
||||||
|
|
||||||
【SCENE】
|
|
||||||
id: 1
|
|
||||||
description: 飞船驾驶舱内景
|
|
||||||
prompt: interior of a sci-fi spacecraft cockpit, holographic displays, cold blue lighting, cinematic
|
|
||||||
【/SCENE】
|
|
||||||
|
|
||||||
【TTS】
|
|
||||||
character: 林澈
|
|
||||||
text: 我们准备出舱检查
|
|
||||||
emotion: 平静
|
|
||||||
【/TTS】
|
|
||||||
|
|
||||||
【WORKFLOW】
|
|
||||||
```json
|
|
||||||
{"comfyui_workflow": {}}
|
|
||||||
```
|
|
||||||
【/WORKFLOW】
|
|
||||||
|
|
||||||
## 关键要求
|
|
||||||
- prompt_* 用英文,同时包含主体 + 视角 + 细节 + 画质标签
|
|
||||||
- 不要一次输出太多,先输角色和场景,下次再输工作流
|
|
||||||
- 如果没有用户明确要求,切勿自作主张输出全部内容
|
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
const AGENT_CHAT_BODY_LIMIT_BYTES: usize = 64 * 1024 * 1024;
|
const AGENT_CHAT_BODY_LIMIT_BYTES: usize = 64 * 1024 * 1024;
|
||||||
@@ -683,6 +666,190 @@ fn send_sse(tx: &UnboundedSender<Result<Event, Infallible>>, payload: Value) ->
|
|||||||
.is_ok()
|
.is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// OpenAI-style tool definitions advertised to the upstream model so the
|
||||||
|
/// agent can request image / scene / TTS / workflow generation step by step.
|
||||||
|
fn agent_tool_definitions() -> Value {
|
||||||
|
json!([
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "generate_character_images",
|
||||||
|
"description": "为一个角色生成四视图(正/背/左/右)参考图。当需要确定角色形象时调用。",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"character_name": { "type": "string", "description": "角色名称" },
|
||||||
|
"description": { "type": "string", "description": "角色整体描述" },
|
||||||
|
"prompt_front": { "type": "string", "description": "正面视角的图像提示词" },
|
||||||
|
"prompt_back": { "type": "string", "description": "背面视角的图像提示词" },
|
||||||
|
"prompt_left": { "type": "string", "description": "左侧视角的图像提示词" },
|
||||||
|
"prompt_right": { "type": "string", "description": "右侧视角的图像提示词" }
|
||||||
|
},
|
||||||
|
"required": ["character_name", "prompt_front"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "generate_scene_image",
|
||||||
|
"description": "生成一张场景/分镜图。在规划好分镜后逐个调用。",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"scene_id": { "type": "integer", "description": "场景编号" },
|
||||||
|
"description": { "type": "string", "description": "场景描述" },
|
||||||
|
"prompt": { "type": "string", "description": "场景图像提示词" }
|
||||||
|
},
|
||||||
|
"required": ["prompt"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "generate_tts",
|
||||||
|
"description": "为一句台词生成语音。",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"character": { "type": "string", "description": "说话角色" },
|
||||||
|
"text": { "type": "string", "description": "台词文本" },
|
||||||
|
"emotion": { "type": "string", "description": "情绪:平静/激动/愤怒/悲伤/恐惧" }
|
||||||
|
},
|
||||||
|
"required": ["text"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "build_comfyui_workflow",
|
||||||
|
"description": "输出最终可执行的 ComfyUI 工作流 JSON。在素材准备完成后调用以交付工作流。",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"workflow_json": { "type": "string", "description": "完整的 ComfyUI 工作流 JSON 字符串" }
|
||||||
|
},
|
||||||
|
"required": ["workflow_json"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map an emotion label to an OpenAI TTS voice.
|
||||||
|
fn voice_for_emotion(emotion: &str) -> &'static str {
|
||||||
|
match emotion {
|
||||||
|
"激动" | "愤怒" => "nova",
|
||||||
|
"悲伤" | "恐惧" => "shimmer",
|
||||||
|
_ => "alloy",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute one tool call requested by the model. Emits SSE side-effects
|
||||||
|
/// (image/scene/tts/comfyui_workflow events) and returns a short text result
|
||||||
|
/// that is fed back to the model as the tool message content.
|
||||||
|
async fn execute_agent_tool(
|
||||||
|
tx: &UnboundedSender<Result<Event, Infallible>>,
|
||||||
|
api_base: &str,
|
||||||
|
api_key: &str,
|
||||||
|
image_model: &str,
|
||||||
|
tool_name: &str,
|
||||||
|
args: &Value,
|
||||||
|
) -> String {
|
||||||
|
match tool_name {
|
||||||
|
"generate_character_images" => {
|
||||||
|
let name = args.get("character_name").and_then(Value::as_str).unwrap_or("角色");
|
||||||
|
let views: [(&str, &str); 4] = [
|
||||||
|
("front", "prompt_front"),
|
||||||
|
("back", "prompt_back"),
|
||||||
|
("left", "prompt_left"),
|
||||||
|
("right", "prompt_right"),
|
||||||
|
];
|
||||||
|
let mut ok = 0;
|
||||||
|
for (view, key) in views {
|
||||||
|
let Some(prompt) = args.get(key).and_then(Value::as_str).filter(|p| !p.is_empty()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
send_sse(tx, json!({ "type": "tool_progress", "tool": tool_name, "detail": format!("生成 {} 的 {} 视图", name, view) }));
|
||||||
|
match call_image_gen(api_base, api_key, image_model, prompt).await {
|
||||||
|
Ok(url) if !url.is_empty() => {
|
||||||
|
ok += 1;
|
||||||
|
send_sse(tx, json!({ "type": "image", "character": name, "view": view, "url": url }));
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
send_sse(tx, json!({ "type": "tool_progress", "tool": tool_name, "detail": format!("{} 的 {} 视图生成失败: {}", name, view, e) }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
format!("已为角色「{}」生成 {} 张视图。", name, ok)
|
||||||
|
}
|
||||||
|
"generate_scene_image" => {
|
||||||
|
let scene_id = args.get("scene_id").and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
let desc = args.get("description").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let Some(prompt) = args.get("prompt").and_then(Value::as_str).filter(|p| !p.is_empty()) else {
|
||||||
|
return "缺少 prompt,未生成场景图。".to_string();
|
||||||
|
};
|
||||||
|
send_sse(tx, json!({ "type": "tool_progress", "tool": tool_name, "detail": format!("生成场景 {}", scene_id) }));
|
||||||
|
match call_image_gen(api_base, api_key, image_model, prompt).await {
|
||||||
|
Ok(url) if !url.is_empty() => {
|
||||||
|
send_sse(tx, json!({ "type": "scene", "id": scene_id, "description": desc, "prompt": prompt, "url": url }));
|
||||||
|
format!("场景 {} 已生成。", scene_id)
|
||||||
|
}
|
||||||
|
Ok(_) => format!("场景 {} 上游未返回图像。", scene_id),
|
||||||
|
Err(e) => format!("场景 {} 生成失败: {}", scene_id, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"generate_tts" => {
|
||||||
|
let character = args.get("character").and_then(Value::as_str).unwrap_or("旁白");
|
||||||
|
let emotion = args.get("emotion").and_then(Value::as_str).unwrap_or("平静");
|
||||||
|
let Some(text) = args.get("text").and_then(Value::as_str).filter(|t| !t.is_empty()) else {
|
||||||
|
return "缺少台词文本,未生成语音。".to_string();
|
||||||
|
};
|
||||||
|
send_sse(tx, json!({ "type": "tool_progress", "tool": tool_name, "detail": format!("为 {} 合成语音", character) }));
|
||||||
|
match call_tts(api_base, api_key, text, voice_for_emotion(emotion)).await {
|
||||||
|
Ok(audio_bytes) => {
|
||||||
|
let audio_path = format!(
|
||||||
|
"/tmp/comfyui_tts_{}_{}.mp3",
|
||||||
|
character.replace(' ', "_"),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis()
|
||||||
|
);
|
||||||
|
if tokio::fs::write(&audio_path, &audio_bytes).await.is_ok() {
|
||||||
|
send_sse(tx, json!({
|
||||||
|
"type": "tts",
|
||||||
|
"character": character,
|
||||||
|
"text": text,
|
||||||
|
"url": format!("/audio/{}", audio_path.trim_start_matches("/tmp/comfyui_tts_")),
|
||||||
|
}));
|
||||||
|
format!("已为「{}」生成语音。", character)
|
||||||
|
} else {
|
||||||
|
"语音生成成功但写入失败。".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => format!("语音生成失败: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"build_comfyui_workflow" => {
|
||||||
|
let Some(raw) = args.get("workflow_json").and_then(Value::as_str).filter(|w| !w.is_empty()) else {
|
||||||
|
return "缺少 workflow_json。".to_string();
|
||||||
|
};
|
||||||
|
match serde_json::from_str::<Value>(raw) {
|
||||||
|
Ok(parsed) => {
|
||||||
|
send_sse(tx, json!({ "type": "comfyui_workflow", "workflow": parsed }));
|
||||||
|
"ComfyUI 工作流已交付。".to_string()
|
||||||
|
}
|
||||||
|
Err(e) => format!("工作流 JSON 无效: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => format!("未知工具: {}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn string_field(value: &Value, keys: &[&str]) -> Option<String> {
|
fn string_field(value: &Value, keys: &[&str]) -> Option<String> {
|
||||||
keys.iter()
|
keys.iter()
|
||||||
.find_map(|key| value.get(*key).and_then(Value::as_str))
|
.find_map(|key| value.get(*key).and_then(Value::as_str))
|
||||||
@@ -910,6 +1077,15 @@ async fn chat_handler(
|
|||||||
|
|
||||||
let client = Client::new();
|
let client = Client::new();
|
||||||
|
|
||||||
|
// Agentic loop: each round may produce tool_calls; we execute them,
|
||||||
|
// feed results back, and re-query until the model returns a final text
|
||||||
|
// answer (no tool_calls) or the round budget is exhausted.
|
||||||
|
const MAX_AGENT_ROUNDS: usize = 6;
|
||||||
|
let mut agent_round = 0usize;
|
||||||
|
let mut accumulated_text = String::new();
|
||||||
|
loop {
|
||||||
|
agent_round += 1;
|
||||||
|
|
||||||
info!("comfyui-agent: sending chat request to upstream");
|
info!("comfyui-agent: sending chat request to upstream");
|
||||||
|
|
||||||
let response = match client
|
let response = match client
|
||||||
@@ -920,6 +1096,8 @@ async fn chat_handler(
|
|||||||
"messages": upstream_messages,
|
"messages": upstream_messages,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"stream": true,
|
"stream": true,
|
||||||
|
"tools": agent_tool_definitions(),
|
||||||
|
"tool_choice": "auto",
|
||||||
}))
|
}))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -952,7 +1130,10 @@ async fn chat_handler(
|
|||||||
|
|
||||||
let mut stream = response.bytes_stream();
|
let mut stream = response.bytes_stream();
|
||||||
let mut buffer: Vec<u8> = Vec::new();
|
let mut buffer: Vec<u8> = Vec::new();
|
||||||
let mut accumulated_text = String::new();
|
accumulated_text.clear();
|
||||||
|
// Accumulate streamed tool_calls: index -> (id, name, arguments-buffer).
|
||||||
|
let mut tool_calls_acc: std::collections::BTreeMap<i64, (String, String, String)> =
|
||||||
|
std::collections::BTreeMap::new();
|
||||||
|
|
||||||
// Phase 1: Stream the LLM text response
|
// Phase 1: Stream the LLM text response
|
||||||
loop {
|
loop {
|
||||||
@@ -1048,8 +1229,74 @@ async fn chat_handler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accumulate streamed tool_calls fragments.
|
||||||
|
if let Some(tcs) = delta.get("tool_calls").and_then(Value::as_array) {
|
||||||
|
for tc in tcs {
|
||||||
|
let idx = tc.get("index").and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
let entry = tool_calls_acc.entry(idx).or_default();
|
||||||
|
if let Some(id) = tc.get("id").and_then(Value::as_str) {
|
||||||
|
if !id.is_empty() {
|
||||||
|
entry.0 = id.to_string();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(name) = tc.pointer("/function/name").and_then(Value::as_str) {
|
||||||
|
if !name.is_empty() {
|
||||||
|
entry.1 = name.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(args) = tc.pointer("/function/arguments").and_then(Value::as_str) {
|
||||||
|
entry.2.push_str(args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the model requested tools this round, execute them, append the
|
||||||
|
// assistant tool_calls + tool results to the conversation, and loop
|
||||||
|
// for another round so the model can react to the results.
|
||||||
|
if !tool_calls_acc.is_empty() && agent_round < MAX_AGENT_ROUNDS {
|
||||||
|
// Reconstruct the assistant message carrying the tool_calls.
|
||||||
|
let assistant_tool_calls: Vec<Value> = tool_calls_acc
|
||||||
|
.iter()
|
||||||
|
.map(|(_, (id, name, args))| {
|
||||||
|
json!({
|
||||||
|
"id": id,
|
||||||
|
"type": "function",
|
||||||
|
"function": { "name": name, "arguments": args },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
upstream_messages.push(json!({
|
||||||
|
"role": "assistant",
|
||||||
|
"content": if accumulated_text.is_empty() { Value::Null } else { Value::from(accumulated_text.clone()) },
|
||||||
|
"tool_calls": assistant_tool_calls,
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (_, (id, name, args)) in tool_calls_acc.iter() {
|
||||||
|
let parsed_args: Value = serde_json::from_str(args).unwrap_or(json!({}));
|
||||||
|
send_sse(&tx, json!({ "type": "tool_call", "tool": name, "arguments": parsed_args }));
|
||||||
|
let result = execute_agent_tool(
|
||||||
|
&tx, &api_base, &api_key, &image_model, name, &parsed_args,
|
||||||
|
).await;
|
||||||
|
send_sse(&tx, json!({ "type": "tool_result", "tool": name, "result": result }));
|
||||||
|
upstream_messages.push(json!({
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": id,
|
||||||
|
"content": result,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue to the next agent round.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No tool calls → final answer. Leave the agentic loop and fall through
|
||||||
|
// to the legacy structured-section parsing as a fallback for models
|
||||||
|
// that emit 【...】 markers instead of tool calls.
|
||||||
|
break;
|
||||||
|
} // end agentic loop
|
||||||
|
|
||||||
// Phase 2: Parse structured sections from accumulated text
|
// Phase 2: Parse structured sections from accumulated text
|
||||||
info!("comfyui-agent: text streaming complete, parsing sections");
|
info!("comfyui-agent: text streaming complete, parsing sections");
|
||||||
@@ -1305,6 +1552,8 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
|
payment: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
|
|||||||
+1694
-87
File diff suppressed because it is too large
Load Diff
+49
-3
@@ -439,6 +439,12 @@ struct EventListItem {
|
|||||||
event_type: String,
|
event_type: String,
|
||||||
title: String,
|
title: String,
|
||||||
message: Option<String>,
|
message: Option<String>,
|
||||||
|
#[sqlx(default)]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
account: Option<String>,
|
||||||
|
#[sqlx(default)]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
client_ip: Option<String>,
|
||||||
level: String,
|
level: String,
|
||||||
#[serde(rename = "read")]
|
#[serde(rename = "read")]
|
||||||
is_read: bool,
|
is_read: bool,
|
||||||
@@ -449,11 +455,15 @@ struct EventListItem {
|
|||||||
|
|
||||||
impl From<Event> for EventListItem {
|
impl From<Event> for EventListItem {
|
||||||
fn from(event: Event) -> Self {
|
fn from(event: Event) -> Self {
|
||||||
|
let account = event_account(&event.event_type, &event.title, event.message.as_deref());
|
||||||
|
let client_ip = event_client_ip(event.message.as_deref());
|
||||||
Self {
|
Self {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
event_type: event.event_type,
|
event_type: event.event_type,
|
||||||
title: event.title,
|
title: event.title,
|
||||||
message: event.message,
|
message: event.message,
|
||||||
|
account,
|
||||||
|
client_ip,
|
||||||
level: event.level.unwrap_or_else(|| "info".to_string()),
|
level: event.level.unwrap_or_else(|| "info".to_string()),
|
||||||
is_read: event.is_read.unwrap_or(false),
|
is_read: event.is_read.unwrap_or(false),
|
||||||
related_id: event.related_id,
|
related_id: event.related_id,
|
||||||
@@ -470,6 +480,8 @@ impl From<runtime_logs::RuntimeLogEntry> for EventListItem {
|
|||||||
event_type: entry.event_type,
|
event_type: entry.event_type,
|
||||||
title: entry.title,
|
title: entry.title,
|
||||||
message: entry.message,
|
message: entry.message,
|
||||||
|
account: None,
|
||||||
|
client_ip: None,
|
||||||
level: entry.level,
|
level: entry.level,
|
||||||
is_read: entry.is_read,
|
is_read: entry.is_read,
|
||||||
related_id: entry.related_id,
|
related_id: entry.related_id,
|
||||||
@@ -479,6 +491,38 @@ impl From<runtime_logs::RuntimeLogEntry> for EventListItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn event_account(event_type: &str, title: &str, message: Option<&str>) -> Option<String> {
|
||||||
|
if event_type != "user_login" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
message
|
||||||
|
.and_then(|value| keyed_message_value(value, "账号"))
|
||||||
|
.or_else(|| message.and_then(|value| keyed_message_value(value, "邮箱")))
|
||||||
|
.or_else(|| message.and_then(|value| keyed_message_value(value, "标识")))
|
||||||
|
.or_else(|| {
|
||||||
|
title
|
||||||
|
.split_once(':')
|
||||||
|
.map(|(_, value)| value.trim().to_string())
|
||||||
|
})
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event_client_ip(message: Option<&str>) -> Option<String> {
|
||||||
|
message
|
||||||
|
.and_then(|value| keyed_message_value(value, "IP"))
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn keyed_message_value(message: &str, key: &str) -> Option<String> {
|
||||||
|
let prefix = format!("{key}=");
|
||||||
|
message
|
||||||
|
.split('·')
|
||||||
|
.map(str::trim)
|
||||||
|
.find_map(|part| part.strip_prefix(&prefix).map(str::trim))
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -487,11 +531,11 @@ mod tests {
|
|||||||
config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig},
|
config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig},
|
||||||
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
|
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
|
||||||
};
|
};
|
||||||
|
use axum::extract::{Query, State};
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
http::{Request, StatusCode},
|
http::{Request, StatusCode},
|
||||||
};
|
};
|
||||||
use axum::extract::{Query, State};
|
|
||||||
use sqlx::sqlite::SqlitePoolOptions;
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
use tower::ServiceExt as _;
|
use tower::ServiceExt as _;
|
||||||
|
|
||||||
@@ -536,6 +580,8 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
|
payment: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
@@ -631,8 +677,8 @@ mod tests {
|
|||||||
.expect("admin token request");
|
.expect("admin token request");
|
||||||
assert_eq!(ok_response.status(), StatusCode::OK);
|
assert_eq!(ok_response.status(), StatusCode::OK);
|
||||||
|
|
||||||
let user_jwt = generate_jwt(&state.config.auth.jwt_secret, "1", "user", 1)
|
let user_jwt =
|
||||||
.expect("user jwt");
|
generate_jwt(&state.config.auth.jwt_secret, "1", "user", 1).expect("user jwt");
|
||||||
let user_response = app
|
let user_response = app
|
||||||
.clone()
|
.clone()
|
||||||
.oneshot(
|
.oneshot(
|
||||||
|
|||||||
+14
-7
@@ -16,8 +16,10 @@ 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");
|
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/web/index.html"));
|
||||||
const _: &[u8] =
|
const _: &[u8] = include_bytes!(concat!(
|
||||||
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/dist/website/index.html"));
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/dist/website/index.html"
|
||||||
|
));
|
||||||
|
|
||||||
mod admin;
|
mod admin;
|
||||||
mod auth;
|
mod auth;
|
||||||
@@ -30,6 +32,7 @@ mod health;
|
|||||||
mod money_printer;
|
mod money_printer;
|
||||||
mod oauth;
|
mod oauth;
|
||||||
mod organizations;
|
mod organizations;
|
||||||
|
mod payment;
|
||||||
mod picture_agent;
|
mod picture_agent;
|
||||||
mod proxy;
|
mod proxy;
|
||||||
mod search;
|
mod search;
|
||||||
@@ -65,6 +68,7 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.merge(checkin::routes())
|
.merge(checkin::routes())
|
||||||
.merge(oauth::routes())
|
.merge(oauth::routes())
|
||||||
.merge(organizations::routes())
|
.merge(organizations::routes())
|
||||||
|
.merge(payment::routes())
|
||||||
.merge(search::routes())
|
.merge(search::routes())
|
||||||
.merge(site_announcements::routes())
|
.merge(site_announcements::routes())
|
||||||
.merge(workflow_ui::routes())
|
.merge(workflow_ui::routes())
|
||||||
@@ -99,12 +103,9 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
return Ok::<_, Infallible>(api_not_found_response(path));
|
return Ok::<_, Infallible>(api_not_found_response(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok::<_, Infallible>(filesystem_static_response(
|
Ok::<_, Infallible>(
|
||||||
path,
|
filesystem_static_response(path, &web_dir, &website_dir).await,
|
||||||
&web_dir,
|
|
||||||
&website_dir,
|
|
||||||
)
|
)
|
||||||
.await)
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
router.fallback_service(static_service)
|
router.fallback_service(static_service)
|
||||||
@@ -285,6 +286,12 @@ mod tests {
|
|||||||
assert_eq!(embedded_asset_path("/new"), "new.html");
|
assert_eq!(embedded_asset_path("/new"), "new.html");
|
||||||
assert_eq!(embedded_asset_path("/new/"), "new.html");
|
assert_eq!(embedded_asset_path("/new/"), "new.html");
|
||||||
assert_eq!(embedded_asset_path("/new/index.html"), "new.html");
|
assert_eq!(embedded_asset_path("/new/index.html"), "new.html");
|
||||||
|
|
||||||
|
for route in ["new1", "new2", "new3", "new4", "new5"] {
|
||||||
|
assert!(!should_serve_website(&format!("/{route}")));
|
||||||
|
assert!(!should_serve_website(&format!("/{route}/")));
|
||||||
|
assert!(!should_serve_website(&format!("/{route}/index.html")));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+343
-104
@@ -108,7 +108,11 @@ fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
|
|||||||
headers
|
headers
|
||||||
.get(header::AUTHORIZATION)
|
.get(header::AUTHORIZATION)
|
||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
.and_then(|value| value.strip_prefix("Bearer ").or_else(|| value.strip_prefix("bearer ")))
|
.and_then(|value| {
|
||||||
|
value
|
||||||
|
.strip_prefix("Bearer ")
|
||||||
|
.or_else(|| value.strip_prefix("bearer "))
|
||||||
|
})
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|token| !token.is_empty())
|
.filter(|token| !token.is_empty())
|
||||||
}
|
}
|
||||||
@@ -181,25 +185,28 @@ 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();
|
let cloud_backend = state.config.comfyui.uses_cloud_backend();
|
||||||
|
let local_only_enabled = state.config.comfyui.uses_docker_backend();
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"success": true,
|
"success": true,
|
||||||
"mode": "boosapi-native",
|
"mode": if local_only_enabled { "local-comfy" } else { "disabled" },
|
||||||
|
"disabled": !local_only_enabled,
|
||||||
|
"disabledReason": if local_only_enabled { Value::Null } else { json!("短视频工厂暂时只支持本地模型部署;请启用本地 ComfyUI/Docker 后再创建任务。") },
|
||||||
"keyName": auth.key_name,
|
"keyName": auth.key_name,
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
"topic_to_script",
|
"topic_to_script",
|
||||||
"boosapi_chat_completion",
|
"boosapi_chat_completion",
|
||||||
"comfy_prompt",
|
"local_comfy_prompt",
|
||||||
"boosapi_text_to_video",
|
"local_text_to_video",
|
||||||
"workspace_archive",
|
"workspace_archive",
|
||||||
"background_jobs"
|
"background_jobs"
|
||||||
],
|
],
|
||||||
"comfy": {
|
"comfy": {
|
||||||
"enabled": state.config.comfyui.uses_native_backend(),
|
"enabled": local_only_enabled,
|
||||||
"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": {
|
"builtinCloudTemplate": {
|
||||||
"enabled": cloud_backend,
|
"enabled": cloud_backend && local_only_enabled,
|
||||||
"label": "内置 Cloud Seedance 文生视频",
|
"label": "内置 Cloud Seedance 文生视频",
|
||||||
"nodeType": "ByteDanceTextToVideoNode",
|
"nodeType": "ByteDanceTextToVideoNode",
|
||||||
"model": "seedance-1-0-lite-t2v-250428",
|
"model": "seedance-1-0-lite-t2v-250428",
|
||||||
@@ -276,7 +283,12 @@ async fn load_job(state: &AppState, id: &str, auth: &MoneyPrinterAuth) -> AppRes
|
|||||||
Ok(task_to_json(task))
|
Ok(task_to_json(task))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_progress(state: &AppState, id: &str, progress: i64, message: &str) -> AppResult<()> {
|
async fn update_progress(
|
||||||
|
state: &AppState,
|
||||||
|
id: &str,
|
||||||
|
progress: i64,
|
||||||
|
message: &str,
|
||||||
|
) -> AppResult<()> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE background_tasks
|
"UPDATE background_tasks
|
||||||
SET progress = ?, message = ?, updated_at = datetime('now')
|
SET progress = ?, message = ?, updated_at = datetime('now')
|
||||||
@@ -308,7 +320,8 @@ fn http_client(timeout_secs: u64) -> Client {
|
|||||||
|
|
||||||
fn script_prompt(topic: &str, body: &Value) -> String {
|
fn script_prompt(topic: &str, body: &Value) -> String {
|
||||||
let language = string_from(body, &["language"]).unwrap_or_else(|| "中文".to_string());
|
let language = string_from(body, &["language"]).unwrap_or_else(|| "中文".to_string());
|
||||||
let style = string_from(body, &["style"]).unwrap_or_else(|| "爆款短视频,节奏紧凑,适合口播".to_string());
|
let style = string_from(body, &["style"])
|
||||||
|
.unwrap_or_else(|| "爆款短视频,节奏紧凑,适合口播".to_string());
|
||||||
let duration = string_from(body, &["duration"]).unwrap_or_else(|| "30".to_string());
|
let duration = string_from(body, &["duration"]).unwrap_or_else(|| "30".to_string());
|
||||||
let audience = string_from(body, &["audience"]).unwrap_or_else(|| "泛大众用户".to_string());
|
let audience = string_from(body, &["audience"]).unwrap_or_else(|| "泛大众用户".to_string());
|
||||||
let extra = string_from(body, &["requirements", "extra"]).unwrap_or_default();
|
let extra = string_from(body, &["requirements", "extra"]).unwrap_or_default();
|
||||||
@@ -334,9 +347,8 @@ async fn generate_script(
|
|||||||
auth: &MoneyPrinterAuth,
|
auth: &MoneyPrinterAuth,
|
||||||
body: &Value,
|
body: &Value,
|
||||||
) -> AppResult<Value> {
|
) -> AppResult<Value> {
|
||||||
let topic = string_from(body, &["topic", "prompt"]).ok_or_else(|| {
|
let topic = string_from(body, &["topic", "prompt"])
|
||||||
AppError::BadRequest("topic is required".into())
|
.ok_or_else(|| AppError::BadRequest("topic is required".into()))?;
|
||||||
})?;
|
|
||||||
let script_model = string_from(body, &["scriptModel", "script_model"])
|
let script_model = string_from(body, &["scriptModel", "script_model"])
|
||||||
.unwrap_or_else(|| "gpt-5.4".to_string());
|
.unwrap_or_else(|| "gpt-5.4".to_string());
|
||||||
let payload = json!({
|
let payload = json!({
|
||||||
@@ -452,7 +464,11 @@ fn native_path(state: &AppState, path: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn native_url(state: &AppState, path: &str) -> String {
|
fn native_url(state: &AppState, path: &str) -> String {
|
||||||
format!("{}{}", state.config.comfyui.target_base_url(), native_path(state, path))
|
format!(
|
||||||
|
"{}{}",
|
||||||
|
state.config.comfyui.target_base_url(),
|
||||||
|
native_path(state, path)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn native_request(state: &AppState, method: Method, path: &str) -> reqwest::RequestBuilder {
|
fn native_request(state: &AppState, method: Method, path: &str) -> reqwest::RequestBuilder {
|
||||||
@@ -559,7 +575,12 @@ fn mapping_value<'a>(mapping: &'a Value, keys: &[&str]) -> Option<&'a Value> {
|
|||||||
fn mapping_node_id(mapping: &Value, keys: &[&str]) -> Option<String> {
|
fn mapping_node_id(mapping: &Value, keys: &[&str]) -> Option<String> {
|
||||||
mapping_value(mapping, keys).and_then(|value| {
|
mapping_value(mapping, keys).and_then(|value| {
|
||||||
if value.is_object() {
|
if value.is_object() {
|
||||||
scalar_string(value.get("nodeId").or_else(|| value.get("node_id")).or(Some(value)))
|
scalar_string(
|
||||||
|
value
|
||||||
|
.get("nodeId")
|
||||||
|
.or_else(|| value.get("node_id"))
|
||||||
|
.or(Some(value)),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
scalar_string(Some(value))
|
scalar_string(Some(value))
|
||||||
}
|
}
|
||||||
@@ -621,21 +642,38 @@ fn graph_node_params(node: &Value) -> serde_json::Map<String, Value> {
|
|||||||
|
|
||||||
fn normalize_comfy_value(class_type: &str, field: &str, value: Value) -> Value {
|
fn normalize_comfy_value(class_type: &str, field: &str, value: Value) -> Value {
|
||||||
match (class_type, field) {
|
match (class_type, field) {
|
||||||
("KSampler", "cfg") => value.as_f64().filter(|v| (0.0..=50.0).contains(v)).map_or(json!(8.0), |v| json!(v)),
|
("KSampler", "cfg") => value
|
||||||
("KSampler", "denoise") => value.as_f64().map(|v| if v > 1.0 { v / 100.0 } else { v }).filter(|v| (0.0..=1.0).contains(v)).map_or(json!(1.0), |v| json!(v)),
|
.as_f64()
|
||||||
|
.filter(|v| (0.0..=50.0).contains(v))
|
||||||
|
.map_or(json!(8.0), |v| json!(v)),
|
||||||
|
("KSampler", "denoise") => value
|
||||||
|
.as_f64()
|
||||||
|
.map(|v| if v > 1.0 { v / 100.0 } else { v })
|
||||||
|
.filter(|v| (0.0..=1.0).contains(v))
|
||||||
|
.map_or(json!(1.0), |v| json!(v)),
|
||||||
("KSampler", "sampler_name") => {
|
("KSampler", "sampler_name") => {
|
||||||
if value.is_string() { value } else { json!("euler") }
|
if value.is_string() {
|
||||||
|
value
|
||||||
|
} else {
|
||||||
|
json!("euler")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
("KSampler", "scheduler") => {
|
("KSampler", "scheduler") => {
|
||||||
if value.is_string() { value } else { json!("normal") }
|
if value.is_string() {
|
||||||
|
value
|
||||||
|
} else {
|
||||||
|
json!("normal")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
("KSampler", "seed") => value.as_i64().map_or(json!(0), |v| json!(v.max(0))),
|
("KSampler", "seed") => value.as_i64().map_or(json!(0), |v| json!(v.max(0))),
|
||||||
("EmptyLatentImage", "width") | ("EmptyLatentImage", "height") => {
|
("EmptyLatentImage", "width") | ("EmptyLatentImage", "height") => value
|
||||||
value.as_i64().filter(|v| (64..=4096).contains(v)).map_or(json!(1024), |v| json!(v))
|
.as_i64()
|
||||||
}
|
.filter(|v| (64..=4096).contains(v))
|
||||||
("EmptyLatentImage", "batch_size") => {
|
.map_or(json!(1024), |v| json!(v)),
|
||||||
value.as_i64().filter(|v| (1..=16).contains(v)).map_or(json!(1), |v| json!(v))
|
("EmptyLatentImage", "batch_size") => value
|
||||||
}
|
.as_i64()
|
||||||
|
.filter(|v| (1..=16).contains(v))
|
||||||
|
.map_or(json!(1), |v| json!(v)),
|
||||||
_ => value,
|
_ => value,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -656,18 +694,37 @@ fn graph_to_comfy_prompt(graph: &Value) -> Option<Value> {
|
|||||||
}
|
}
|
||||||
let mut inputs = serde_json::Map::new();
|
let mut inputs = serde_json::Map::new();
|
||||||
for (field, value) in graph_node_params(node) {
|
for (field, value) in graph_node_params(node) {
|
||||||
inputs.insert(field.clone(), normalize_comfy_value(&class_type, &field, value));
|
inputs.insert(
|
||||||
|
field.clone(),
|
||||||
|
normalize_comfy_value(&class_type, &field, value),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
prompt.insert(id, json!({
|
prompt.insert(
|
||||||
|
id,
|
||||||
|
json!({
|
||||||
"class_type": class_type,
|
"class_type": class_type,
|
||||||
"inputs": inputs,
|
"inputs": inputs,
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for edge in graph.get("edges").and_then(Value::as_array).into_iter().flatten() {
|
for edge in graph
|
||||||
let Some(from) = edge.get("from").and_then(Value::as_str) else { continue; };
|
.get("edges")
|
||||||
let Some(to) = edge.get("to").and_then(Value::as_str) else { continue; };
|
.and_then(Value::as_array)
|
||||||
let Some((from_id, from_port)) = split_endpoint(from) else { continue; };
|
.into_iter()
|
||||||
let Some((to_id, to_field)) = split_endpoint(to) else { continue; };
|
.flatten()
|
||||||
|
{
|
||||||
|
let Some(from) = edge.get("from").and_then(Value::as_str) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(to) = edge.get("to").and_then(Value::as_str) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some((from_id, from_port)) = split_endpoint(from) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some((to_id, to_field)) = split_endpoint(to) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
if let Some(inputs) = prompt
|
if let Some(inputs) = prompt
|
||||||
.get_mut(to_id)
|
.get_mut(to_id)
|
||||||
.and_then(Value::as_object_mut)
|
.and_then(Value::as_object_mut)
|
||||||
@@ -684,7 +741,10 @@ fn graph_to_comfy_prompt(graph: &Value) -> Option<Value> {
|
|||||||
Some(Value::Object(prompt))
|
Some(Value::Object(prompt))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn first_node_by_class(prompt: &serde_json::Map<String, Value>, class_type: &str) -> Option<String> {
|
fn first_node_by_class(
|
||||||
|
prompt: &serde_json::Map<String, Value>,
|
||||||
|
class_type: &str,
|
||||||
|
) -> Option<String> {
|
||||||
prompt
|
prompt
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(_, node)| node.get("class_type").and_then(Value::as_str) == Some(class_type))
|
.find(|(_, node)| node.get("class_type").and_then(Value::as_str) == Some(class_type))
|
||||||
@@ -716,9 +776,13 @@ fn prompt_contains_class_type(prompt: &Value, class_type: &str) -> bool {
|
|||||||
.get("class_type")
|
.get("class_type")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.is_some_and(|value| value == class_type)
|
.is_some_and(|value| value == class_type)
|
||||||
|| object.values().any(|value| prompt_contains_class_type(value, class_type))
|
|| object
|
||||||
|
.values()
|
||||||
|
.any(|value| prompt_contains_class_type(value, class_type))
|
||||||
}
|
}
|
||||||
Value::Array(items) => items.iter().any(|value| prompt_contains_class_type(value, class_type)),
|
Value::Array(items) => items
|
||||||
|
.iter()
|
||||||
|
.any(|value| prompt_contains_class_type(value, class_type)),
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -740,11 +804,36 @@ 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 prompt_field = first_existing_input_field(
|
||||||
let ratio_field = first_existing_input_field(object, &video_node, &["ratio", "model.ratio", "aspect_ratio"], "ratio");
|
object,
|
||||||
let resolution_field = first_existing_input_field(object, &video_node, &["model.resolution", "resolution", "size"], "resolution");
|
&prompt_node,
|
||||||
let duration_field = first_existing_input_field(object, &video_node, &["model.duration", "duration", "length"], "duration");
|
&["prompt", "positive", "text"],
|
||||||
let model_field = first_existing_input_field(object, &video_node, &["model", "model_name", "ckpt_name"], "model");
|
"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",
|
||||||
|
);
|
||||||
|
let model_field = first_existing_input_field(
|
||||||
|
object,
|
||||||
|
&video_node,
|
||||||
|
&["model", "model_name", "ckpt_name"],
|
||||||
|
"model",
|
||||||
|
);
|
||||||
json!({
|
json!({
|
||||||
"prompt": {
|
"prompt": {
|
||||||
"nodeId": prompt_node,
|
"nodeId": prompt_node,
|
||||||
@@ -776,7 +865,9 @@ fn infer_comfy_mapping(prompt: &Value) -> Value {
|
|||||||
fn clip_text_nodes(prompt: &serde_json::Map<String, Value>) -> Vec<String> {
|
fn clip_text_nodes(prompt: &serde_json::Map<String, Value>) -> Vec<String> {
|
||||||
prompt
|
prompt
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, node)| node.get("class_type").and_then(Value::as_str) == Some("CLIPTextEncode"))
|
.filter(|(_, node)| {
|
||||||
|
node.get("class_type").and_then(Value::as_str) == Some("CLIPTextEncode")
|
||||||
|
})
|
||||||
.map(|(id, _)| id.clone())
|
.map(|(id, _)| id.clone())
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -826,7 +917,8 @@ fn repair_common_comfy_prompt_links(prompt: &mut serde_json::Map<String, Value>)
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn workflow_prompt_from_graph(graph: &Value) -> Value {
|
fn workflow_prompt_from_graph(graph: &Value) -> Value {
|
||||||
graph.get("prompt")
|
graph
|
||||||
|
.get("prompt")
|
||||||
.cloned()
|
.cloned()
|
||||||
.or_else(|| graph.get("workflow").cloned())
|
.or_else(|| graph.get("workflow").cloned())
|
||||||
.or_else(|| graph.get("comfyPrompt").cloned())
|
.or_else(|| graph.get("comfyPrompt").cloned())
|
||||||
@@ -858,22 +950,23 @@ async fn workflow_prompt_from_body(
|
|||||||
auth: &MoneyPrinterAuth,
|
auth: &MoneyPrinterAuth,
|
||||||
body: &Value,
|
body: &Value,
|
||||||
) -> AppResult<Value> {
|
) -> AppResult<Value> {
|
||||||
if let Some(value) = body.get("comfyPrompt")
|
if let Some(value) = body
|
||||||
|
.get("comfyPrompt")
|
||||||
.or_else(|| body.get("comfy_prompt"))
|
.or_else(|| body.get("comfy_prompt"))
|
||||||
.or_else(|| body.get("promptWorkflow"))
|
.or_else(|| body.get("promptWorkflow"))
|
||||||
.or_else(|| body.get("prompt_workflow"))
|
.or_else(|| body.get("prompt_workflow"))
|
||||||
.or_else(|| body.get("workflowPrompt"))
|
.or_else(|| body.get("workflowPrompt"))
|
||||||
.or_else(|| body.get("workflow_prompt"))
|
.or_else(|| body.get("workflow_prompt"))
|
||||||
.cloned() {
|
.cloned()
|
||||||
|
{
|
||||||
return Ok(value);
|
return Ok(value);
|
||||||
}
|
}
|
||||||
if let Some(template_id) = string_from(body, &["templateId", "template_id", "workflowId", "workflow_id"]) {
|
if let Some(template_id) = string_from(
|
||||||
let workflow = storage::get_workflow(
|
body,
|
||||||
&state.db,
|
&["templateId", "template_id", "workflowId", "workflow_id"],
|
||||||
&template_id,
|
) {
|
||||||
auth.user_id,
|
let workflow =
|
||||||
auth.role == "admin",
|
storage::get_workflow(&state.db, &template_id, auth.user_id, auth.role == "admin")
|
||||||
)
|
|
||||||
.await?;
|
.await?;
|
||||||
return Ok(workflow_prompt_from_graph(&workflow.graph));
|
return Ok(workflow_prompt_from_graph(&workflow.graph));
|
||||||
}
|
}
|
||||||
@@ -890,10 +983,14 @@ fn apply_node_input(prompt: &mut Value, node_id: &str, field: &str, value: Value
|
|||||||
return Err(AppError::BadRequest("Comfy 节点映射包含空 nodeId".into()));
|
return Err(AppError::BadRequest("Comfy 节点映射包含空 nodeId".into()));
|
||||||
}
|
}
|
||||||
let Some(node) = prompt.get_mut(node_id) else {
|
let Some(node) = prompt.get_mut(node_id) else {
|
||||||
return Err(AppError::BadRequest(format!("Comfy prompt missing node {node_id}")));
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Comfy prompt missing node {node_id}"
|
||||||
|
)));
|
||||||
};
|
};
|
||||||
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
|
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
|
||||||
return Err(AppError::BadRequest(format!("Comfy node {node_id} has no inputs")));
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Comfy node {node_id} has no inputs"
|
||||||
|
)));
|
||||||
};
|
};
|
||||||
inputs.insert(field.to_string(), value);
|
inputs.insert(field.to_string(), value);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -927,12 +1024,20 @@ fn apply_optional_node_input(
|
|||||||
fn value_to_duration_seconds(value: &Value) -> Option<i64> {
|
fn value_to_duration_seconds(value: &Value) -> Option<i64> {
|
||||||
value
|
value
|
||||||
.as_i64()
|
.as_i64()
|
||||||
.or_else(|| value.as_str().and_then(|text| text.trim().parse::<i64>().ok()))
|
.or_else(|| {
|
||||||
|
value
|
||||||
|
.as_str()
|
||||||
|
.and_then(|text| text.trim().parse::<i64>().ok())
|
||||||
|
})
|
||||||
.map(|duration| duration.clamp(2, 120))
|
.map(|duration| duration.clamp(2, 120))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_resolution_for_comfy(value: &Value) -> Value {
|
fn normalize_resolution_for_comfy(value: &Value) -> Value {
|
||||||
let text = value.as_str().unwrap_or_default().trim().to_ascii_lowercase();
|
let text = value
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase();
|
||||||
match text.as_str() {
|
match text.as_str() {
|
||||||
"720p" | "720" => json!("720P"),
|
"720p" | "720" => json!("720P"),
|
||||||
"1080p" | "1080" => json!("1080P"),
|
"1080p" | "1080" => json!("1080P"),
|
||||||
@@ -940,7 +1045,12 @@ fn normalize_resolution_for_comfy(value: &Value) -> Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
@@ -956,20 +1066,35 @@ fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value,
|
|||||||
apply_node_input(
|
apply_node_input(
|
||||||
prompt,
|
prompt,
|
||||||
&prompt_node,
|
&prompt_node,
|
||||||
&mapping_field(mapping, &["prompt", "positive", "text", "promptNode"], "prompt"),
|
&mapping_field(
|
||||||
|
mapping,
|
||||||
|
&["prompt", "positive", "text", "promptNode"],
|
||||||
|
"prompt",
|
||||||
|
),
|
||||||
json!(video_prompt),
|
json!(video_prompt),
|
||||||
)?;
|
)?;
|
||||||
if let Some(negative_node) = mapping_node_id(mapping, &["negativePrompt", "negative", "negativeNode"]) {
|
if let Some(negative_node) =
|
||||||
|
mapping_node_id(mapping, &["negativePrompt", "negative", "negativeNode"])
|
||||||
|
{
|
||||||
let negative = string_from(body, &["negativePrompt", "negative_prompt", "negative"])
|
let negative = string_from(body, &["negativePrompt", "negative_prompt", "negative"])
|
||||||
.unwrap_or_else(|| "low quality, blurry, distorted, watermark, text artifacts".to_string());
|
.unwrap_or_else(|| {
|
||||||
|
"low quality, blurry, distorted, watermark, text artifacts".to_string()
|
||||||
|
});
|
||||||
apply_node_input(
|
apply_node_input(
|
||||||
prompt,
|
prompt,
|
||||||
&negative_node,
|
&negative_node,
|
||||||
&mapping_field(mapping, &["negativePrompt", "negative", "negativeNode"], "text"),
|
&mapping_field(
|
||||||
|
mapping,
|
||||||
|
&["negativePrompt", "negative", "negativeNode"],
|
||||||
|
"text",
|
||||||
|
),
|
||||||
json!(negative),
|
json!(negative),
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
if let Some(model) = string_from(body, &["comfyApiModel", "comfy_api_model", "apiModel", "api_model"]) {
|
if let Some(model) = string_from(
|
||||||
|
body,
|
||||||
|
&["comfyApiModel", "comfy_api_model", "apiModel", "api_model"],
|
||||||
|
) {
|
||||||
apply_optional_node_input(
|
apply_optional_node_input(
|
||||||
prompt,
|
prompt,
|
||||||
mapping,
|
mapping,
|
||||||
@@ -978,10 +1103,16 @@ fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value,
|
|||||||
json!(normalize_video_model(&model)),
|
json!(normalize_video_model(&model)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(value) = ["ratio", "aspectRatio", "aspect_ratio"].iter().find_map(|key| body.get(*key).cloned()) {
|
if let Some(value) = ["ratio", "aspectRatio", "aspect_ratio"]
|
||||||
|
.iter()
|
||||||
|
.find_map(|key| body.get(*key).cloned())
|
||||||
|
{
|
||||||
apply_optional_node_input(prompt, mapping, &["ratio", "ratioNode"], "ratio", value);
|
apply_optional_node_input(prompt, mapping, &["ratio", "ratioNode"], "ratio", value);
|
||||||
}
|
}
|
||||||
if let Some(value) = ["resolution", "size"].iter().find_map(|key| body.get(*key).cloned()) {
|
if let Some(value) = ["resolution", "size"]
|
||||||
|
.iter()
|
||||||
|
.find_map(|key| body.get(*key).cloned())
|
||||||
|
{
|
||||||
apply_optional_node_input(
|
apply_optional_node_input(
|
||||||
prompt,
|
prompt,
|
||||||
mapping,
|
mapping,
|
||||||
@@ -990,8 +1121,18 @@ fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value,
|
|||||||
normalize_resolution_for_comfy(&value),
|
normalize_resolution_for_comfy(&value),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(value) = ["duration"].iter().find_map(|key| body.get(*key)).and_then(value_to_duration_seconds) {
|
if let Some(value) = ["duration"]
|
||||||
apply_optional_node_input(prompt, mapping, &["duration", "durationNode"], "duration", json!(value));
|
.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);
|
repair_money_printer_comfy_prompt(prompt);
|
||||||
Ok(video_prompt)
|
Ok(video_prompt)
|
||||||
@@ -1009,8 +1150,14 @@ fn repair_bytedance_video_nodes(prompt: &mut Value) {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
for (_node_id, node) in nodes.iter_mut() {
|
for (_node_id, node) in nodes.iter_mut() {
|
||||||
let class_type = node.get("class_type").and_then(Value::as_str).unwrap_or_default();
|
let class_type = node
|
||||||
if !matches!(class_type, "ByteDanceTextToVideoNode" | "ByteDance2TextToVideoNode") {
|
.get("class_type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !matches!(
|
||||||
|
class_type,
|
||||||
|
"ByteDanceTextToVideoNode" | "ByteDance2TextToVideoNode"
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
|
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
|
||||||
@@ -1019,16 +1166,20 @@ fn repair_bytedance_video_nodes(prompt: &mut Value) {
|
|||||||
if let Some(duration) = inputs.get("duration").and_then(value_to_duration_seconds) {
|
if let Some(duration) = inputs.get("duration").and_then(value_to_duration_seconds) {
|
||||||
inputs.insert("duration".to_string(), json!(duration.clamp(3, 12)));
|
inputs.insert("duration".to_string(), json!(duration.clamp(3, 12)));
|
||||||
}
|
}
|
||||||
if let Some(duration) = inputs.get("model.duration").and_then(value_to_duration_seconds) {
|
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)));
|
inputs.insert("model.duration".to_string(), json!(duration.clamp(4, 15)));
|
||||||
}
|
}
|
||||||
if inputs
|
if inputs.get("resolution").is_some_and(|value| {
|
||||||
.get("resolution")
|
let normalized = value
|
||||||
.is_some_and(|value| {
|
.as_str()
|
||||||
let normalized = value.as_str().unwrap_or_default().trim().to_ascii_lowercase();
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase();
|
||||||
!matches!(normalized.as_str(), "480p" | "720p" | "1080p")
|
!matches!(normalized.as_str(), "480p" | "720p" | "1080p")
|
||||||
})
|
}) {
|
||||||
{
|
|
||||||
inputs.insert("resolution".to_string(), json!("480p"));
|
inputs.insert("resolution".to_string(), json!("480p"));
|
||||||
} else if let Some(normalized) = inputs
|
} else if let Some(normalized) = inputs
|
||||||
.get("resolution")
|
.get("resolution")
|
||||||
@@ -1037,10 +1188,12 @@ fn repair_bytedance_video_nodes(prompt: &mut Value) {
|
|||||||
{
|
{
|
||||||
inputs.insert("resolution".to_string(), json!(normalized));
|
inputs.insert("resolution".to_string(), json!(normalized));
|
||||||
}
|
}
|
||||||
if inputs
|
if inputs.get("aspect_ratio").is_some_and(|value| {
|
||||||
.get("aspect_ratio")
|
!matches!(
|
||||||
.is_some_and(|value| !matches!(value.as_str(), Some("16:9" | "4:3" | "1:1" | "3:4" | "9:16" | "21:9")))
|
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"));
|
inputs.insert("aspect_ratio".to_string(), json!("9:16"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1061,7 +1214,10 @@ fn repair_resize_image_mask_nodes(prompt: &mut Value) {
|
|||||||
.get("resize_type.crop")
|
.get("resize_type.crop")
|
||||||
.is_some_and(|value| !matches!(value.as_str(), Some("disabled" | "center")))
|
.is_some_and(|value| !matches!(value.as_str(), Some("disabled" | "center")))
|
||||||
{
|
{
|
||||||
inputs.insert("resize_type.crop".to_string(), Value::String("center".to_string()));
|
inputs.insert(
|
||||||
|
"resize_type.crop".to_string(),
|
||||||
|
Value::String("center".to_string()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1088,7 +1244,9 @@ fn repair_text_generate_ltx2_prompt_nodes(prompt: &mut Value) {
|
|||||||
inputs.insert("sampling_mode.top_p".to_string(), json!(0.95));
|
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.min_p".to_string(), json!(0.05));
|
||||||
inputs.insert("sampling_mode.repetition_penalty".to_string(), json!(1.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
|
||||||
|
.entry("sampling_mode.seed".to_string())
|
||||||
|
.or_insert_with(|| json!(0));
|
||||||
inputs.insert("sampling_mode.presence_penalty".to_string(), json!(0.0));
|
inputs.insert("sampling_mode.presence_penalty".to_string(), json!(0.0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1099,7 +1257,10 @@ fn repair_wan2_api_nodes(prompt: &mut Value) {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
for (_node_id, node) in nodes.iter_mut() {
|
for (_node_id, node) in nodes.iter_mut() {
|
||||||
let class_type = node.get("class_type").and_then(Value::as_str).unwrap_or_default();
|
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") {
|
if !class_type.starts_with("Wan2") || !class_type.ends_with("Api") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1117,7 +1278,10 @@ fn repair_wan2_model_group(inputs: &mut Map<String, Value>) {
|
|||||||
inputs.insert("model".to_string(), model);
|
inputs.insert("model".to_string(), model);
|
||||||
}
|
}
|
||||||
if let Some(resolution) = items.get(1).cloned() {
|
if let Some(resolution) = items.get(1).cloned() {
|
||||||
inputs.insert("model.resolution".to_string(), normalize_resolution_for_comfy(&resolution));
|
inputs.insert(
|
||||||
|
"model.resolution".to_string(),
|
||||||
|
normalize_resolution_for_comfy(&resolution),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if let Some(duration) = items.get(2).and_then(value_to_duration_seconds) {
|
if let Some(duration) = items.get(2).and_then(value_to_duration_seconds) {
|
||||||
inputs.insert("model.duration".to_string(), json!(duration));
|
inputs.insert("model.duration".to_string(), json!(duration));
|
||||||
@@ -1129,7 +1293,10 @@ fn repair_wan2_model_group(inputs: &mut Map<String, Value>) {
|
|||||||
{
|
{
|
||||||
inputs.insert("model.resolution".to_string(), json!("1080P"));
|
inputs.insert("model.resolution".to_string(), json!("1080P"));
|
||||||
}
|
}
|
||||||
if let Some(duration) = inputs.get("model.duration").and_then(value_to_duration_seconds) {
|
if let Some(duration) = inputs
|
||||||
|
.get("model.duration")
|
||||||
|
.and_then(value_to_duration_seconds)
|
||||||
|
{
|
||||||
inputs.insert("model.duration".to_string(), json!(duration));
|
inputs.insert("model.duration".to_string(), json!(duration));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1164,7 +1331,10 @@ fn comfy_failure_message(value: &Value) -> Option<String> {
|
|||||||
let status = scalar_string(value.get("status").or_else(|| value.get("state")))
|
let status = scalar_string(value.get("status").or_else(|| value.get("state")))
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_ascii_lowercase();
|
.to_ascii_lowercase();
|
||||||
if matches!(status.as_str(), "failed" | "error" | "cancelled" | "canceled") {
|
if matches!(
|
||||||
|
status.as_str(),
|
||||||
|
"failed" | "error" | "cancelled" | "canceled"
|
||||||
|
) {
|
||||||
return Some(
|
return Some(
|
||||||
scalar_string(value.pointer("/execution_error/exception_message"))
|
scalar_string(value.pointer("/execution_error/exception_message"))
|
||||||
.or_else(|| scalar_string(value.get("error")))
|
.or_else(|| scalar_string(value.get("error")))
|
||||||
@@ -1182,7 +1352,10 @@ fn comfy_output_objects(value: &Value) -> Vec<Value> {
|
|||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
fn push_unique(out: &mut Vec<Value>, item: Value) {
|
fn push_unique(out: &mut Vec<Value>, item: Value) {
|
||||||
let identity = output_identity(&item);
|
let identity = output_identity(&item);
|
||||||
if out.iter().any(|existing| output_identity(existing) == identity) {
|
if out
|
||||||
|
.iter()
|
||||||
|
.any(|existing| output_identity(existing) == identity)
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
out.push(item);
|
out.push(item);
|
||||||
@@ -1229,7 +1402,10 @@ fn output_identity(item: &Value) -> String {
|
|||||||
|
|
||||||
fn looks_like_media_reference(value: &str) -> bool {
|
fn looks_like_media_reference(value: &str) -> bool {
|
||||||
let trimmed = value.trim();
|
let trimmed = value.trim();
|
||||||
if !(trimmed.starts_with("http://") || trimmed.starts_with("https://") || trimmed.starts_with('/')) {
|
if !(trimmed.starts_with("http://")
|
||||||
|
|| trimmed.starts_with("https://")
|
||||||
|
|| trimmed.starts_with('/'))
|
||||||
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let lower = trimmed
|
let lower = trimmed
|
||||||
@@ -1328,7 +1504,8 @@ fn output_kind(item: &Value) -> &'static str {
|
|||||||
|| filename.ends_with(".gif")
|
|| filename.ends_with(".gif")
|
||||||
{
|
{
|
||||||
"video"
|
"video"
|
||||||
} else if filename.ends_with(".mp3") || filename.ends_with(".wav") || filename.ends_with(".m4a") {
|
} else if filename.ends_with(".mp3") || filename.ends_with(".wav") || filename.ends_with(".m4a")
|
||||||
|
{
|
||||||
"audio"
|
"audio"
|
||||||
} else {
|
} else {
|
||||||
"image"
|
"image"
|
||||||
@@ -1390,7 +1567,11 @@ async fn archive_comfy_output(
|
|||||||
}
|
}
|
||||||
let query = output_query(item)?;
|
let query = output_query(item)?;
|
||||||
let kind = output_kind(item).to_string();
|
let kind = output_kind(item).to_string();
|
||||||
let path = if kind == "video" { "/viewvideo" } else { "/view" };
|
let path = if kind == "video" {
|
||||||
|
"/viewvideo"
|
||||||
|
} else {
|
||||||
|
"/view"
|
||||||
|
};
|
||||||
let response = native_request(state, Method::GET, &format!("{path}{query}"))
|
let response = native_request(state, Method::GET, &format!("{path}{query}"))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -1455,7 +1636,10 @@ async fn submit_comfy_video(
|
|||||||
}
|
}
|
||||||
let video_prompt = inject_comfy_video_inputs(&mut prompt, &mapping, body, plan)?;
|
let video_prompt = inject_comfy_video_inputs(&mut prompt, &mapping, body, plan)?;
|
||||||
let (prompt_id, response) = submit_comfy_prompt(state, prompt).await?;
|
let (prompt_id, response) = submit_comfy_prompt(state, prompt).await?;
|
||||||
let model = string_from(body, &["comfyApiModel", "comfy_api_model", "apiModel", "api_model"])
|
let model = string_from(
|
||||||
|
body,
|
||||||
|
&["comfyApiModel", "comfy_api_model", "apiModel", "api_model"],
|
||||||
|
)
|
||||||
.map(|value| normalize_video_model(&value))
|
.map(|value| normalize_video_model(&value))
|
||||||
.unwrap_or_else(|| "comfy-api".to_string());
|
.unwrap_or_else(|| "comfy-api".to_string());
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
@@ -1488,7 +1672,8 @@ async fn submit_video(
|
|||||||
return Err(AppError::BadRequest("video prompt is empty".into()));
|
return Err(AppError::BadRequest("video prompt is empty".into()));
|
||||||
}
|
}
|
||||||
let size = string_from(body, &["size", "resolution"]).unwrap_or_else(|| "1080p".to_string());
|
let size = string_from(body, &["size", "resolution"]).unwrap_or_else(|| "1080p".to_string());
|
||||||
let ratio = string_from(body, &["ratio", "aspectRatio", "aspect_ratio"]).unwrap_or_else(|| "9:16".to_string());
|
let ratio = string_from(body, &["ratio", "aspectRatio", "aspect_ratio"])
|
||||||
|
.unwrap_or_else(|| "9:16".to_string());
|
||||||
let duration = body
|
let duration = body
|
||||||
.get("duration")
|
.get("duration")
|
||||||
.and_then(Value::as_i64)
|
.and_then(Value::as_i64)
|
||||||
@@ -1559,7 +1744,10 @@ async fn poll_video(
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_ascii_lowercase();
|
.to_ascii_lowercase();
|
||||||
if matches!(state_text.as_str(), "failed" | "error" | "cancelled" | "canceled") {
|
if matches!(
|
||||||
|
state_text.as_str(),
|
||||||
|
"failed" | "error" | "cancelled" | "canceled"
|
||||||
|
) {
|
||||||
return Err(AppError::UpstreamError(value.to_string()));
|
return Err(AppError::UpstreamError(value.to_string()));
|
||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_secs(VIDEO_POLL_INTERVAL_SECS)).await;
|
tokio::time::sleep(Duration::from_secs(VIDEO_POLL_INTERVAL_SECS)).await;
|
||||||
@@ -1617,8 +1805,15 @@ async fn create_job(
|
|||||||
Json(body): Json<Value>,
|
Json(body): Json<Value>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let auth = resolve_auth(&headers, &state).await?;
|
let auth = resolve_auth(&headers, &state).await?;
|
||||||
|
if !state.config.comfyui.uses_docker_backend() {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"短视频工厂暂时只支持本地模型部署;当前 Comfy/RunningHub/Cloud API 路径已禁用。".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
if let Some(key_id) = auth.key_id {
|
if let Some(key_id) = auth.key_id {
|
||||||
UsageTracker::new(state.db.clone()).check_rate_limit(key_id).await?;
|
UsageTracker::new(state.db.clone())
|
||||||
|
.check_rate_limit(key_id)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
let topic = string_from(&body, &["topic", "prompt"])
|
let topic = string_from(&body, &["topic", "prompt"])
|
||||||
.ok_or_else(|| AppError::BadRequest("topic is required".into()))?;
|
.ok_or_else(|| AppError::BadRequest("topic is required".into()))?;
|
||||||
@@ -1656,7 +1851,15 @@ async fn create_job(
|
|||||||
.to_ascii_lowercase();
|
.to_ascii_lowercase();
|
||||||
let failure_model = string_from(
|
let failure_model = string_from(
|
||||||
&failure_body,
|
&failure_body,
|
||||||
&["comfyApiModel", "comfy_api_model", "apiModel", "api_model", "videoModel", "video_model", "model"],
|
&[
|
||||||
|
"comfyApiModel",
|
||||||
|
"comfy_api_model",
|
||||||
|
"apiModel",
|
||||||
|
"api_model",
|
||||||
|
"videoModel",
|
||||||
|
"video_model",
|
||||||
|
"model",
|
||||||
|
],
|
||||||
)
|
)
|
||||||
.map(|value| normalize_video_model(&value))
|
.map(|value| normalize_video_model(&value))
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
@@ -2021,7 +2224,10 @@ async fn clear_jobs(State(state): State<AppState>, headers: HeaderMap) -> AppRes
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{AppConfig, AuthConfig, ComfyUIBackend, ComfyUIConfig, DbConfig, ProxyConfig, ServerConfig},
|
config::{
|
||||||
|
AppConfig, AuthConfig, ComfyUIBackend, ComfyUIConfig, DbConfig, ProxyConfig,
|
||||||
|
ServerConfig,
|
||||||
|
},
|
||||||
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
|
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
|
||||||
};
|
};
|
||||||
use axum::http::HeaderValue;
|
use axum::http::HeaderValue;
|
||||||
@@ -2071,6 +2277,8 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui,
|
comfyui,
|
||||||
|
pixelle_video: Default::default(),
|
||||||
|
payment: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
@@ -2081,7 +2289,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn status_exposes_comfy_cloud_api_model_candidates() {
|
async fn status_disables_cloud_api_video_factory_but_keeps_diagnostics() {
|
||||||
let state = test_state_with_comfy_backend(ComfyUIBackend::Cloud).await;
|
let state = test_state_with_comfy_backend(ComfyUIBackend::Cloud).await;
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert(
|
headers.insert(
|
||||||
@@ -2093,7 +2301,9 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("status response");
|
.expect("status response");
|
||||||
|
|
||||||
assert_eq!(value["builtinCloudTemplate"]["enabled"], true);
|
assert_eq!(value["mode"], "disabled");
|
||||||
|
assert_eq!(value["disabled"], true);
|
||||||
|
assert_eq!(value["builtinCloudTemplate"]["enabled"], false);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
value["builtinCloudTemplate"]["model"],
|
value["builtinCloudTemplate"]["model"],
|
||||||
"seedance-1-0-lite-t2v-250428"
|
"seedance-1-0-lite-t2v-250428"
|
||||||
@@ -2132,8 +2342,13 @@ mod tests {
|
|||||||
|
|
||||||
let outputs = comfy_output_objects(&history);
|
let outputs = comfy_output_objects(&history);
|
||||||
assert_eq!(outputs.len(), 3);
|
assert_eq!(outputs.len(), 3);
|
||||||
assert!(outputs.iter().any(|item| output_filename(item).as_deref() == Some("clip.mp4")));
|
assert!(outputs
|
||||||
assert!(outputs.iter().any(|item| output_remote_url(item).as_deref() == Some("https://cdn.example.com/poster.png")));
|
.iter()
|
||||||
|
.any(|item| output_filename(item).as_deref() == Some("clip.mp4")));
|
||||||
|
assert!(outputs
|
||||||
|
.iter()
|
||||||
|
.any(|item| output_remote_url(item).as_deref()
|
||||||
|
== Some("https://cdn.example.com/poster.png")));
|
||||||
assert!(outputs.iter().any(|item| output_kind(item) == "video"));
|
assert!(outputs.iter().any(|item| output_kind(item) == "video"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2152,7 +2367,10 @@ mod tests {
|
|||||||
|
|
||||||
let outputs = comfy_output_objects(&history);
|
let outputs = comfy_output_objects(&history);
|
||||||
assert_eq!(outputs.len(), 1);
|
assert_eq!(outputs.len(), 1);
|
||||||
assert_eq!(output_remote_url(&outputs[0]).as_deref(), Some("https://cdn.example.com/final.mp4"));
|
assert_eq!(
|
||||||
|
output_remote_url(&outputs[0]).as_deref(),
|
||||||
|
Some("https://cdn.example.com/final.mp4")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2161,7 +2379,9 @@ mod tests {
|
|||||||
assert!(is_pending_history_status(StatusCode::ACCEPTED));
|
assert!(is_pending_history_status(StatusCode::ACCEPTED));
|
||||||
assert!(is_pending_history_status(StatusCode::NO_CONTENT));
|
assert!(is_pending_history_status(StatusCode::NO_CONTENT));
|
||||||
assert!(!is_pending_history_status(StatusCode::OK));
|
assert!(!is_pending_history_status(StatusCode::OK));
|
||||||
assert!(!is_pending_history_status(StatusCode::INTERNAL_SERVER_ERROR));
|
assert!(!is_pending_history_status(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2199,10 +2419,29 @@ mod tests {
|
|||||||
.expect("injecting Comfy video inputs should succeed");
|
.expect("injecting Comfy video inputs should succeed");
|
||||||
|
|
||||||
assert_eq!(prompt_text, "cinematic product demo");
|
assert_eq!(prompt_text, "cinematic product demo");
|
||||||
assert_eq!(prompt.pointer("/1/inputs/model").and_then(Value::as_str), Some("seedance-1-0-lite-t2v-250428"));
|
assert_eq!(
|
||||||
assert_eq!(prompt.pointer("/1/inputs/prompt").and_then(Value::as_str), Some("cinematic product demo"));
|
prompt.pointer("/1/inputs/model").and_then(Value::as_str),
|
||||||
assert_eq!(prompt.pointer("/1/inputs/aspect_ratio").and_then(Value::as_str), Some("16:9"));
|
Some("seedance-1-0-lite-t2v-250428")
|
||||||
assert_eq!(prompt.pointer("/1/inputs/resolution").and_then(Value::as_str), Some("720p"));
|
);
|
||||||
assert_eq!(prompt.pointer("/1/inputs/duration").and_then(Value::as_i64), Some(8));
|
assert_eq!(
|
||||||
|
prompt.pointer("/1/inputs/prompt").and_then(Value::as_str),
|
||||||
|
Some("cinematic product demo")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
prompt
|
||||||
|
.pointer("/1/inputs/aspect_ratio")
|
||||||
|
.and_then(Value::as_str),
|
||||||
|
Some("16:9")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
prompt
|
||||||
|
.pointer("/1/inputs/resolution")
|
||||||
|
.and_then(Value::as_str),
|
||||||
|
Some("720p")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
prompt.pointer("/1/inputs/duration").and_then(Value::as_i64),
|
||||||
|
Some(8)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2550,6 +2550,8 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: ComfyUIConfig::default(),
|
comfyui: ComfyUIConfig::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
|
payment: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
AppState,
|
|
||||||
auth::jwt::validate_jwt,
|
auth::jwt::validate_jwt,
|
||||||
db::models::background_task::BackgroundTask,
|
db::models::background_task::BackgroundTask,
|
||||||
db::models::downstream_api_key::DownstreamApiKey,
|
db::models::downstream_api_key::DownstreamApiKey,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
|
AppState,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
|
||||||
body::Body,
|
body::Body,
|
||||||
extract::{Multipart, Path, Query, State},
|
extract::{Multipart, Path, Query, State},
|
||||||
http::{HeaderMap, Response, StatusCode, header},
|
http::{header, HeaderMap, Response, StatusCode},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
};
|
};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
const TEMPLATE_GALLERY_PAGE_URL: &str =
|
const TEMPLATE_GALLERY_PAGE_URL: &str =
|
||||||
@@ -291,7 +291,6 @@ mod tests {
|
|||||||
assert!(!is_picture_model_pattern("deepseek-chat"));
|
assert!(!is_picture_model_pattern("deepseek-chat"));
|
||||||
assert!(!is_picture_model_pattern("qwen-plus"));
|
assert!(!is_picture_model_pattern("qwen-plus"));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prompt_title(prompt: &str, post_number: Option<i64>) -> String {
|
fn prompt_title(prompt: &str, post_number: Option<i64>) -> String {
|
||||||
|
|||||||
@@ -2121,6 +2121,8 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: Default::default(),
|
comfyui: Default::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
|
payment: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
crate::services::builtin_keys::BuiltinKeys {
|
crate::services::builtin_keys::BuiltinKeys {
|
||||||
|
|||||||
@@ -696,6 +696,8 @@ mod tests {
|
|||||||
template_proxy_url: None,
|
template_proxy_url: None,
|
||||||
},
|
},
|
||||||
comfyui: ComfyUIConfig::default(),
|
comfyui: ComfyUIConfig::default(),
|
||||||
|
pixelle_video: Default::default(),
|
||||||
|
payment: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
BuiltinKeys {
|
BuiltinKeys {
|
||||||
|
|||||||
@@ -5616,6 +5616,292 @@ fn inject_enhanced_bootstrap(html: String) -> String {
|
|||||||
window.setTimeout(normalizeWorkflowGraphSlots, 600);
|
window.setTimeout(normalizeWorkflowGraphSlots, 600);
|
||||||
window.setTimeout(normalizeWorkflowGraphSlots, 1500);
|
window.setTimeout(normalizeWorkflowGraphSlots, 1500);
|
||||||
};
|
};
|
||||||
|
const workflowConnectionQuickNodes = {
|
||||||
|
model: [
|
||||||
|
{ title: 'K 采样器', types: ['KSampler'], hint: '把模型接入采样流程' },
|
||||||
|
{ title: 'LoRA 加载器', types: ['LoraLoader', 'LoraLoaderModelOnly'], hint: '给模型叠加 LoRA' },
|
||||||
|
{ title: '模型采样设置', types: ['ModelSamplingDiscrete', 'ModelSamplingContinuousEDM'], hint: '调整模型采样方式' },
|
||||||
|
{ title: '保存 Checkpoint', types: ['CheckpointSave'], hint: '保存模型/CLIP/VAE 组合' }
|
||||||
|
],
|
||||||
|
clip: [
|
||||||
|
{ title: 'CLIP 文本编码', types: ['CLIPTextEncode'], hint: '输入提示词生成条件' },
|
||||||
|
{ title: 'CLIP 跳层', types: ['CLIPSetLastLayer'], hint: '设置 CLIP skip' },
|
||||||
|
{ title: 'LoRA 加载器', types: ['LoraLoader'], hint: '同时接入模型与 CLIP' },
|
||||||
|
{ title: '保存 Checkpoint', types: ['CheckpointSave'], hint: '保存模型/CLIP/VAE 组合' }
|
||||||
|
],
|
||||||
|
vae: [
|
||||||
|
{ title: 'VAE 解码', types: ['VAEDecode', 'VAEDecodeTiled'], hint: '潜空间转图像' },
|
||||||
|
{ title: 'VAE 编码', types: ['VAEEncode', 'VAEEncodeTiled'], hint: '图像转潜空间' },
|
||||||
|
{ title: '保存 Checkpoint', types: ['CheckpointSave'], hint: '保存模型/CLIP/VAE 组合' }
|
||||||
|
],
|
||||||
|
image: [
|
||||||
|
{ title: '预览图像', types: ['PreviewImage'], hint: '查看这一条图像输出' },
|
||||||
|
{ title: '保存图像', types: ['SaveImage'], hint: '保存到输出目录' },
|
||||||
|
{ title: 'VAE 编码', types: ['VAEEncode', 'VAEEncodeTiled'], hint: '图像转潜空间' },
|
||||||
|
{ title: '放大图像', types: ['ImageScale', 'ImageScaleBy', 'ImageUpscaleWithModel'], hint: '调整尺寸/放大' },
|
||||||
|
{ title: '图像对比', types: ['Image Comparer (rgthree)', 'Image Comparer', 'ImageCompare'], hint: '对比输入图像' },
|
||||||
|
{ title: '图像转遮罩', types: ['ImageToMask'], hint: '从图像提取遮罩' }
|
||||||
|
],
|
||||||
|
latent: [
|
||||||
|
{ title: 'VAE 解码', types: ['VAEDecode', 'VAEDecodeTiled'], hint: '潜空间转图像' },
|
||||||
|
{ title: 'K 采样器', types: ['KSampler'], hint: '继续采样' },
|
||||||
|
{ title: '潜空间放大', types: ['LatentUpscale', 'LatentUpscaleBy'], hint: '调整潜空间尺寸' },
|
||||||
|
{ title: '潜空间合成', types: ['LatentComposite'], hint: '合成两个潜空间' }
|
||||||
|
],
|
||||||
|
conditioning: [
|
||||||
|
{ title: 'K 采样器', types: ['KSampler'], hint: '接入正/负条件' },
|
||||||
|
{ title: '合并条件', types: ['ConditioningCombine', 'ConditioningConcat'], hint: '组合提示条件' },
|
||||||
|
{ title: '条件平均', types: ['ConditioningAverage'], hint: '混合两组条件' },
|
||||||
|
{ title: '条件区域', types: ['ConditioningSetArea'], hint: '限制条件作用区域' }
|
||||||
|
],
|
||||||
|
mask: [
|
||||||
|
{ title: '遮罩转图像', types: ['MaskToImage'], hint: '查看/保存遮罩' },
|
||||||
|
{ title: '图像转遮罩', types: ['ImageToMask'], hint: '从图像提取遮罩' },
|
||||||
|
{ title: '遮罩合成', types: ['MaskComposite'], hint: '组合遮罩' },
|
||||||
|
{ title: '遮罩扩张', types: ['GrowMask'], hint: '扩大或收缩遮罩' }
|
||||||
|
],
|
||||||
|
video: [
|
||||||
|
{ title: '预览视频', types: ['VHS_VideoCombine', 'Video Combine 🎥🅥🅗🅢', 'VideoCombine'], hint: '合成为可预览视频' },
|
||||||
|
{ title: '保存动画', types: ['SaveAnimatedPNG', 'SaveAnimatedWEBP'], hint: '保存序列动画' },
|
||||||
|
{ title: '加载视频', types: ['VHS_LoadVideo', 'LoadVideo'], hint: '读取视频素材' }
|
||||||
|
],
|
||||||
|
audio: [
|
||||||
|
{ title: '预览音频', types: ['PreviewAudio', 'SaveAudio'], hint: '试听或保存音频' },
|
||||||
|
{ title: '加载音频', types: ['LoadAudio'], hint: '读取音频素材' }
|
||||||
|
],
|
||||||
|
string: [
|
||||||
|
{ title: '文本输入', types: ['PrimitiveNode', 'StringConstant', 'Text Input'], hint: '创建文本值' },
|
||||||
|
{ title: '显示文本', types: ['ShowText|pysssss', 'ShowText', 'PreviewText'], hint: '查看文本输出' }
|
||||||
|
],
|
||||||
|
int: [
|
||||||
|
{ title: '数值输入', types: ['PrimitiveNode', 'Integer Constant'], hint: '创建整数值' }
|
||||||
|
],
|
||||||
|
float: [
|
||||||
|
{ title: '数值输入', types: ['PrimitiveNode', 'Float Constant'], hint: '创建小数值' }
|
||||||
|
],
|
||||||
|
boolean: [
|
||||||
|
{ title: '开关输入', types: ['PrimitiveNode', 'Boolean Constant'], hint: '创建布尔值' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const findWorkflowNodeType = (candidates) => {
|
||||||
|
const registry = window.LiteGraph?.registered_node_types || {};
|
||||||
|
for (const name of candidates) {
|
||||||
|
if (registry[name]) return name;
|
||||||
|
}
|
||||||
|
const entries = Object.keys(registry);
|
||||||
|
for (const name of candidates) {
|
||||||
|
const normalized = String(name).toLowerCase().replace(/[\s_-]+/g, '');
|
||||||
|
const found = entries.find((item) => String(item).toLowerCase().replace(/[\s_-]+/g, '') === normalized);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
const getWorkflowConnectionType = (options) => {
|
||||||
|
try {
|
||||||
|
const link = options || window.__boosapiLastConnectionMenuOptions || {};
|
||||||
|
if (link.nodeFrom && Number.isInteger(link.slotFrom)) {
|
||||||
|
return normalizeWorkflowPortType(link.nodeFrom.outputs?.[link.slotFrom]?.type || link.nodeFrom.outputs?.[link.slotFrom]?.name);
|
||||||
|
}
|
||||||
|
if (link.nodeTo && Number.isInteger(link.slotTo)) {
|
||||||
|
return normalizeWorkflowPortType(link.nodeTo.inputs?.[link.slotTo]?.type || link.nodeTo.inputs?.[link.slotTo]?.name);
|
||||||
|
}
|
||||||
|
const title = String(document.querySelector('.litecontextmenu .litemenu-title')?.textContent || '');
|
||||||
|
if (title.includes('IMAGE') || title.includes('图像')) return 'image';
|
||||||
|
if (title.includes('LATENT') || title.includes('潜空间')) return 'latent';
|
||||||
|
if (title.includes('CONDITIONING') || title.includes('条件')) return 'conditioning';
|
||||||
|
if (title.includes('MODEL') || title.includes('模型')) return 'model';
|
||||||
|
if (title.includes('CLIP')) return 'clip';
|
||||||
|
if (title.includes('VAE')) return 'vae';
|
||||||
|
if (title.includes('MASK') || title.includes('遮罩')) return 'mask';
|
||||||
|
if (title.includes('VIDEO') || title.includes('视频')) return 'video';
|
||||||
|
if (title.includes('AUDIO') || title.includes('音频')) return 'audio';
|
||||||
|
if (title.includes('STRING') || title.includes('TEXT') || title.includes('文本')) return 'string';
|
||||||
|
if (title.includes('INT') || title.includes('整数')) return 'int';
|
||||||
|
if (title.includes('FLOAT') || title.includes('NUMBER') || title.includes('数字')) return 'float';
|
||||||
|
if (title.includes('BOOLEAN') || title.includes('BOOL') || title.includes('布尔')) return 'boolean';
|
||||||
|
} catch (err) {}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
const workflowNodeDisplayTitle = (nodeType) => {
|
||||||
|
const registry = window.LiteGraph?.registered_node_types || {};
|
||||||
|
const ctor = registry[nodeType];
|
||||||
|
return String(ctor?.title || ctor?.nodeData?.display_name || ctor?.comfyClass || nodeType || '').trim();
|
||||||
|
};
|
||||||
|
const fallbackWorkflowConnectionItems = (connectionType) => {
|
||||||
|
try {
|
||||||
|
const options = window.__boosapiLastConnectionMenuOptions || {};
|
||||||
|
const liteGraph = window.LiteGraph || {};
|
||||||
|
const canonical = {
|
||||||
|
image: 'IMAGE',
|
||||||
|
latent: 'LATENT',
|
||||||
|
conditioning: 'CONDITIONING',
|
||||||
|
model: 'MODEL',
|
||||||
|
clip: 'CLIP',
|
||||||
|
vae: 'VAE',
|
||||||
|
mask: 'MASK',
|
||||||
|
video: 'VIDEO',
|
||||||
|
audio: 'AUDIO',
|
||||||
|
string: 'STRING',
|
||||||
|
int: 'INT',
|
||||||
|
float: 'FLOAT',
|
||||||
|
boolean: 'BOOLEAN'
|
||||||
|
}[connectionType] || String(connectionType || '').toUpperCase();
|
||||||
|
const defaults = options.nodeFrom
|
||||||
|
? liteGraph.slot_types_default_out?.[canonical]
|
||||||
|
: liteGraph.slot_types_default_in?.[canonical];
|
||||||
|
const registered = options.nodeFrom
|
||||||
|
? liteGraph.registered_slot_in_types?.[String(canonical).toLowerCase()]?.nodes
|
||||||
|
: liteGraph.registered_slot_out_types?.[canonical]?.nodes;
|
||||||
|
const names = [...(Array.isArray(defaults) ? defaults : []), ...(Array.isArray(registered) ? registered : [])]
|
||||||
|
.map((value) => typeof value === 'string' ? value : value?.comfyClass || value?.type || value?.title)
|
||||||
|
.filter(Boolean);
|
||||||
|
return [...new Set(names)].slice(0, 8).map((nodeType) => ({
|
||||||
|
title: workflowNodeDisplayTitle(nodeType),
|
||||||
|
types: [nodeType],
|
||||||
|
hint: `${canonical} 兼容节点`
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const findCompatibleSlotIndex = (slots, type) => {
|
||||||
|
const wanted = normalizeWorkflowPortType(type);
|
||||||
|
if (!Array.isArray(slots)) return 0;
|
||||||
|
const exact = slots.findIndex((slot) => normalizeWorkflowPortType(slot?.type || slot?.name || slot?.localized_name) === wanted);
|
||||||
|
return exact >= 0 ? exact : 0;
|
||||||
|
};
|
||||||
|
const connectedNodePosition = (options) => {
|
||||||
|
const event = options?.e || {};
|
||||||
|
if (typeof event.canvasX === 'number' && typeof event.canvasY === 'number') {
|
||||||
|
return [event.canvasX + 24, event.canvasY - 28];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const canvas = window.app?.canvas || window.LGraphCanvas?.active_canvas;
|
||||||
|
const area = canvas?.ds?.visible_area;
|
||||||
|
if (Array.isArray(area)) return [area[0] + area[2] / 2 - 120, area[1] + area[3] / 2 - 80];
|
||||||
|
} catch (err) {}
|
||||||
|
return [180, 180];
|
||||||
|
};
|
||||||
|
const addConnectedWorkflowQuickNode = (item) => {
|
||||||
|
try {
|
||||||
|
const options = window.__boosapiLastConnectionMenuOptions || {};
|
||||||
|
const nodeType = findWorkflowNodeType(item.types || []);
|
||||||
|
const graph = window.app?.graph || window.LGraphCanvas?.active_canvas?.graph;
|
||||||
|
const canvas = window.app?.canvas || window.LGraphCanvas?.active_canvas;
|
||||||
|
if (!nodeType || !graph || !window.LiteGraph?.createNode) return false;
|
||||||
|
const node = window.LiteGraph.createNode(nodeType, item.title || nodeType);
|
||||||
|
if (!node) return false;
|
||||||
|
node.pos = connectedNodePosition(options);
|
||||||
|
graph.add(node);
|
||||||
|
normalizeWorkflowNodeSlots(node);
|
||||||
|
if (options.nodeFrom && Number.isInteger(options.slotFrom)) {
|
||||||
|
const output = options.nodeFrom.outputs?.[options.slotFrom];
|
||||||
|
const inputIndex = findCompatibleSlotIndex(node.inputs, output?.type || output?.name);
|
||||||
|
options.nodeFrom.connect(options.slotFrom, node, inputIndex, options.afterRerouteId);
|
||||||
|
} else if (options.nodeTo && Number.isInteger(options.slotTo)) {
|
||||||
|
const input = options.nodeTo.inputs?.[options.slotTo];
|
||||||
|
const outputIndex = findCompatibleSlotIndex(node.outputs, input?.type || input?.name);
|
||||||
|
node.connect(outputIndex, options.nodeTo, options.slotTo, options.afterRerouteId);
|
||||||
|
}
|
||||||
|
graph.setDirtyCanvas?.(true, true);
|
||||||
|
canvas?.selectNode?.(node);
|
||||||
|
if (window.LiteGraph?.closeAllContextMenus) window.LiteGraph.closeAllContextMenus(window);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const installWorkflowConnectionMenuMemory = () => {
|
||||||
|
try {
|
||||||
|
const proto = window.LGraphCanvas?.prototype;
|
||||||
|
if (!proto?.showConnectionMenu || proto.__boosapiConnectionMenuMemory) return;
|
||||||
|
const original = proto.showConnectionMenu;
|
||||||
|
proto.showConnectionMenu = function (options) {
|
||||||
|
window.__boosapiLastConnectionMenuOptions = options || {};
|
||||||
|
const result = original.apply(this, arguments);
|
||||||
|
window.setTimeout(enhanceWorkflowConnectionMenu, 0);
|
||||||
|
window.setTimeout(enhanceWorkflowConnectionMenu, 80);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
proto.__boosapiConnectionMenuMemory = true;
|
||||||
|
} catch (err) {}
|
||||||
|
};
|
||||||
|
const enhanceWorkflowConnectionMenu = () => {
|
||||||
|
try {
|
||||||
|
const menu = document.querySelector('.litecontextmenu');
|
||||||
|
if (!menu || menu.__boosapiConnectionSuggestions) return;
|
||||||
|
const connectionType = getWorkflowConnectionType();
|
||||||
|
const items = [
|
||||||
|
...(workflowConnectionQuickNodes[connectionType] || []),
|
||||||
|
...fallbackWorkflowConnectionItems(connectionType)
|
||||||
|
];
|
||||||
|
if (!items?.length) return;
|
||||||
|
const available = items
|
||||||
|
.map((item) => ({ ...item, nodeType: findWorkflowNodeType(item.types || []) }))
|
||||||
|
.filter((item) => item.nodeType);
|
||||||
|
if (!available.length) return;
|
||||||
|
menu.__boosapiConnectionSuggestions = true;
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.className = 'boosapi-connection-suggestions';
|
||||||
|
panel.style.cssText = 'padding:8px 8px 6px;border-bottom:1px solid rgba(255,255,255,.12);background:rgba(20,24,30,.96);min-width:260px;';
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.textContent = '最相关';
|
||||||
|
title.style.cssText = 'font-size:12px;font-weight:700;color:#e9f7f5;margin:0 0 6px 2px;';
|
||||||
|
panel.appendChild(title);
|
||||||
|
for (const item of available.slice(0, 6)) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.textContent = item.title;
|
||||||
|
button.title = item.hint || item.nodeType;
|
||||||
|
button.style.cssText = 'display:flex;width:100%;align-items:center;justify-content:space-between;gap:12px;margin:2px 0;padding:8px 10px;border:0;border-radius:6px;background:transparent;color:#fff;text-align:left;font-size:13px;cursor:pointer;';
|
||||||
|
button.addEventListener('mouseenter', () => { button.style.background = 'rgba(20,184,166,.22)'; });
|
||||||
|
button.addEventListener('mouseleave', () => { button.style.background = 'transparent'; });
|
||||||
|
button.addEventListener('click', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
addConnectedWorkflowQuickNode(item);
|
||||||
|
}, true);
|
||||||
|
panel.appendChild(button);
|
||||||
|
}
|
||||||
|
menu.insertBefore(panel, menu.firstChild);
|
||||||
|
} catch (err) {}
|
||||||
|
};
|
||||||
|
const installWorkflowConnectionMenuObserver = () => {
|
||||||
|
if (window.__boosapiWorkflowConnectionMenuObserver) return;
|
||||||
|
window.__boosapiWorkflowConnectionMenuObserver = true;
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
if (mutations.some((item) => item.addedNodes && item.addedNodes.length)) {
|
||||||
|
window.requestAnimationFrame(enhanceWorkflowConnectionMenu);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
};
|
||||||
|
const boostWorkflowSlotDefaults = () => {
|
||||||
|
try {
|
||||||
|
const liteGraph = window.LiteGraph;
|
||||||
|
if (!liteGraph) return;
|
||||||
|
const prepend = (target, type, values) => {
|
||||||
|
const current = Array.isArray(target[type]) ? target[type] : [];
|
||||||
|
target[type] = [...values.filter((value) => findWorkflowNodeType([value])), ...current.filter((value) => !values.includes(value))].slice(0, 24);
|
||||||
|
};
|
||||||
|
liteGraph.slot_types_default_out ||= {};
|
||||||
|
liteGraph.slot_types_default_in ||= {};
|
||||||
|
prepend(liteGraph.slot_types_default_out, 'MODEL', ['KSampler', 'LoraLoader', 'ModelSamplingDiscrete']);
|
||||||
|
prepend(liteGraph.slot_types_default_out, 'CLIP', ['CLIPTextEncode', 'CLIPSetLastLayer', 'LoraLoader']);
|
||||||
|
prepend(liteGraph.slot_types_default_out, 'VAE', ['VAEDecode', 'VAEEncode']);
|
||||||
|
prepend(liteGraph.slot_types_default_out, 'IMAGE', ['PreviewImage', 'SaveImage', 'VAEEncode', 'ImageScale', 'ImageScaleBy']);
|
||||||
|
prepend(liteGraph.slot_types_default_in, 'IMAGE', ['LoadImage', 'EmptyImage', 'PreviewImage']);
|
||||||
|
prepend(liteGraph.slot_types_default_out, 'LATENT', ['VAEDecode', 'KSampler']);
|
||||||
|
prepend(liteGraph.slot_types_default_out, 'CONDITIONING', ['KSampler', 'ConditioningCombine']);
|
||||||
|
prepend(liteGraph.slot_types_default_out, 'MASK', ['MaskToImage', 'ImageToMask', 'MaskComposite', 'GrowMask']);
|
||||||
|
prepend(liteGraph.slot_types_default_out, 'VIDEO', ['VHS_VideoCombine', 'VideoCombine', 'SaveAnimatedPNG']);
|
||||||
|
prepend(liteGraph.slot_types_default_out, 'AUDIO', ['PreviewAudio', 'SaveAudio', 'LoadAudio']);
|
||||||
|
prepend(liteGraph.slot_types_default_out, 'STRING', ['PrimitiveNode', 'ShowText', 'PreviewText']);
|
||||||
|
prepend(liteGraph.slot_types_default_out, 'INT', ['PrimitiveNode']);
|
||||||
|
prepend(liteGraph.slot_types_default_out, 'FLOAT', ['PrimitiveNode']);
|
||||||
|
prepend(liteGraph.slot_types_default_out, 'BOOLEAN', ['PrimitiveNode']);
|
||||||
|
} catch (err) {}
|
||||||
|
};
|
||||||
const isWorkflowVideoModelName = (modelName) => {
|
const isWorkflowVideoModelName = (modelName) => {
|
||||||
const value = String(modelName || '').toLowerCase();
|
const value = String(modelName || '').toLowerCase();
|
||||||
return ['seedance', 'wan2', 't2v', 'i2v', 'flf2v', 'video', 'seaweed'].some((keyword) => value.includes(keyword));
|
return ['seedance', 'wan2', 't2v', 'i2v', 'flf2v', 'video', 'seaweed'].some((keyword) => value.includes(keyword));
|
||||||
@@ -5773,6 +6059,10 @@ fn inject_enhanced_bootstrap(html: String) -> String {
|
|||||||
installBoosApiFetchAuth();
|
installBoosApiFetchAuth();
|
||||||
patchWorkflowConnectionTypes();
|
patchWorkflowConnectionTypes();
|
||||||
startWorkflowSlotNormalizer();
|
startWorkflowSlotNormalizer();
|
||||||
|
installWorkflowConnectionMenuMemory();
|
||||||
|
installWorkflowConnectionMenuObserver();
|
||||||
|
boostWorkflowSlotDefaults();
|
||||||
|
enhanceWorkflowConnectionMenu();
|
||||||
patchModelLibraryClicks();
|
patchModelLibraryClicks();
|
||||||
installWorkflowValidationButton();
|
installWorkflowValidationButton();
|
||||||
localizeWorkflowMenus();
|
localizeWorkflowMenus();
|
||||||
@@ -5872,4 +6162,20 @@ mod tests {
|
|||||||
"public, max-age=604800"
|
"public, max-age=604800"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enhanced_bootstrap_prioritizes_connection_slot_suggestions() {
|
||||||
|
let html = inject_enhanced_bootstrap("<html><head></head><body></body></html>".to_string());
|
||||||
|
|
||||||
|
assert!(html.contains("workflowConnectionQuickNodes"));
|
||||||
|
assert!(html.contains("installWorkflowConnectionMenuMemory"));
|
||||||
|
assert!(html.contains("最相关"));
|
||||||
|
assert!(html.contains("PreviewImage"));
|
||||||
|
assert!(html.contains("boostWorkflowSlotDefaults"));
|
||||||
|
assert!(html.contains("fallbackWorkflowConnectionItems"));
|
||||||
|
assert!(html.contains("KSampler"));
|
||||||
|
assert!(html.contains("CLIPTextEncode"));
|
||||||
|
assert!(html.contains("MaskToImage"));
|
||||||
|
assert!(html.contains("VHS_VideoCombine"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -398,7 +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 {
|
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");
|
tracing::warn!(%err, %public_id, "failed to update workspace asset batch delete projection");
|
||||||
}
|
}
|
||||||
deleted_ids.push(public_id);
|
deleted_ids.push(public_id);
|
||||||
|
|||||||
@@ -113,9 +113,7 @@ impl NotificationService {
|
|||||||
let config = self.load_config().await?;
|
let config = self.load_config().await?;
|
||||||
let channels = self.enabled_channels(&config);
|
let channels = self.enabled_channels(&config);
|
||||||
if channels.is_empty() {
|
if channels.is_empty() {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest("请至少配置并启用一个通知通道".into()));
|
||||||
"请至少配置并启用一个通知通道".into(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let notif = Notification {
|
let notif = Notification {
|
||||||
@@ -466,7 +464,9 @@ fn setting(map: &HashMap<String, String>, key: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn setting_bool(map: &HashMap<String, String>, key: &str, default: bool) -> bool {
|
fn setting_bool(map: &HashMap<String, String>, key: &str, default: bool) -> bool {
|
||||||
map.get(key).map(|value| parse_bool(value)).unwrap_or(default)
|
map.get(key)
|
||||||
|
.map(|value| parse_bool(value))
|
||||||
|
.unwrap_or(default)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_bool(value: &str) -> bool {
|
fn parse_bool(value: &str) -> bool {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use sqlx::SqlitePool;
|
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
pub const PROXY_TOKEN_SETTING_KEY: &str = "runtime.auth.proxy_token";
|
pub const PROXY_TOKEN_SETTING_KEY: &str = "runtime.auth.proxy_token";
|
||||||
|
|
||||||
@@ -20,8 +20,7 @@ impl RuntimeAuthSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn load(pool: &SqlitePool, default_proxy_token: String) -> anyhow::Result<Self> {
|
pub async fn load(pool: &SqlitePool, default_proxy_token: String) -> anyhow::Result<Self> {
|
||||||
let stored: Option<String> =
|
let stored: Option<String> = sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
|
||||||
sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
|
|
||||||
.bind(PROXY_TOKEN_SETTING_KEY)
|
.bind(PROXY_TOKEN_SETTING_KEY)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -188,7 +188,10 @@ async fn insert_asset(
|
|||||||
|
|
||||||
fn should_project_workspace_asset(status: &str, metadata: &Value) -> bool {
|
fn should_project_workspace_asset(status: &str, metadata: &Value) -> bool {
|
||||||
status == "completed"
|
status == "completed"
|
||||||
&& (metadata.pointer("/workspaceAsset/role").and_then(Value::as_str) == Some("output")
|
&& (metadata
|
||||||
|
.pointer("/workspaceAsset/role")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
== Some("output")
|
||||||
|| metadata.pointer("/workflowUi/type").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("responseItem").is_some()
|
||||||
|| metadata.get("moneyPrinter").is_some()
|
|| metadata.get("moneyPrinter").is_some()
|
||||||
@@ -398,7 +401,9 @@ pub async fn project_deleted_asset(state: &AppState, public_id: &str) -> Result<
|
|||||||
&source,
|
&source,
|
||||||
&kind,
|
&kind,
|
||||||
size_bytes,
|
size_bytes,
|
||||||
file_path.as_deref().is_some_and(|value| !value.trim().is_empty()),
|
file_path
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|value| !value.trim().is_empty()),
|
||||||
-1,
|
-1,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ fn template_proxy_url(config: &AppConfig) -> Option<String> {
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(non_empty)
|
.and_then(non_empty)
|
||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
.or_else(|| std::env::var("BOOSAPI_TEMPLATE_PROXY_URL").ok().and_then(|value| non_empty_owned(value)))
|
.or_else(|| {
|
||||||
|
std::env::var("BOOSAPI_TEMPLATE_PROXY_URL")
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| non_empty_owned(value))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn non_empty(value: &str) -> Option<&str> {
|
fn non_empty(value: &str) -> Option<&str> {
|
||||||
|
|||||||
@@ -250,10 +250,9 @@ describe('Login surface', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects admin API Key login on the user login page', async () => {
|
it('promotes admin-role API Key login into both audiences and navigates to /dashboard', async () => {
|
||||||
const storage = createMemoryStorage();
|
const storage = createMemoryStorage();
|
||||||
vi.stubGlobal('localStorage', storage);
|
vi.stubGlobal('localStorage', storage);
|
||||||
commitSession('user', 'stale-user-jwt', undefined, storage);
|
|
||||||
const fetchMock = vi.fn().mockResolvedValue({
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
@@ -298,17 +297,16 @@ describe('Login surface', () => {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ apiKey: 'sk-admin-user-key' }),
|
body: JSON.stringify({ apiKey: 'sk-admin-user-key' }),
|
||||||
}));
|
}));
|
||||||
expect(getSessionToken('admin', storage)).toBeNull();
|
expect(getSessionToken('admin', storage)).toBe('jwt-admin-from-key');
|
||||||
expect(getSessionToken('user', storage)).toBe('stale-user-jwt');
|
expect(getSessionToken('user', storage)).toBe('jwt-admin-from-key');
|
||||||
expect(onLogin).not.toHaveBeenCalled();
|
expect(onLogin).toHaveBeenCalledWith('admin');
|
||||||
expect(collectText(root.root)).toContain('管理员请使用管理员 API Token 登录。');
|
expect(paths.at(-1)).toBe('/dashboard');
|
||||||
expect(paths.at(-1)).toBe('/user/login');
|
|
||||||
} finally {
|
} finally {
|
||||||
root?.unmount();
|
root?.unmount();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects API Key login when JWT role falls back to admin', async () => {
|
it('promotes admin role from JWT fallback when API Key response omits user.role', async () => {
|
||||||
const storage = createMemoryStorage();
|
const storage = createMemoryStorage();
|
||||||
vi.stubGlobal('localStorage', storage);
|
vi.stubGlobal('localStorage', storage);
|
||||||
const adminJwt = unsignedJwt({ sub: '5', role: 'admin', exp: 9_999_999_999 });
|
const adminJwt = unsignedJwt({ sub: '5', role: 'admin', exp: 9_999_999_999 });
|
||||||
@@ -355,11 +353,10 @@ describe('Login surface', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(getSessionToken('admin', storage)).toBeNull();
|
expect(getSessionToken('admin', storage)).toBe(adminJwt);
|
||||||
expect(getSessionToken('user', storage)).toBeNull();
|
expect(getSessionToken('user', storage)).toBe(adminJwt);
|
||||||
expect(onLogin).not.toHaveBeenCalled();
|
expect(onLogin).toHaveBeenCalledWith('admin');
|
||||||
expect(collectText(root.root)).toContain('管理员请使用管理员 API Token 登录。');
|
expect(paths.at(-1)).toBe('/dashboard');
|
||||||
expect(paths.at(-1)).toBe('/user/login');
|
|
||||||
} finally {
|
} finally {
|
||||||
root?.unmount();
|
root?.unmount();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,4 +200,19 @@ describe('App sidebar config', () => {
|
|||||||
expect(appSource).toContain("{ to: '/user/recharge', label: '账户充值'");
|
expect(appSource).toContain("{ to: '/user/recharge', label: '账户充值'");
|
||||||
expect(appSource).toContain('<Route path="/user/recharge" element={<UserRecharge />} />');
|
expect(appSource).toContain('<Route path="/user/recharge" element={<UserRecharge />} />');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adds admin payment management navigation and route without fallback static checkout links', () => {
|
||||||
|
const appSource = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
||||||
|
const paymentSource = readFileSync(resolve(process.cwd(), 'src/web/pages/PaymentManagement.tsx'), 'utf8');
|
||||||
|
const opsGroupSource = appSource.slice(
|
||||||
|
appSource.indexOf("label: '运营管理'"),
|
||||||
|
appSource.indexOf("label: '接入管理'"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(appSource).toContain("const PaymentManagement = lazy(() => import('./pages/PaymentManagement.js'));");
|
||||||
|
expect(appSource).toContain('payment: <svg className="sidebar-item-icon"');
|
||||||
|
expect(opsGroupSource).toContain("{ to: '/payment', label: '支付管理', icon: navIcons.payment }");
|
||||||
|
expect(appSource).toContain('<Route path="/payment" element={<PaymentManagement />} />');
|
||||||
|
expect(paymentSource).not.toContain('fallbackStaticUrlsSupported');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ const OpsMonitor = lazy(() => import('./pages/OpsMonitor.js'));
|
|||||||
const IpManagement = lazy(() => import('./pages/IpManagement.js'));
|
const IpManagement = lazy(() => import('./pages/IpManagement.js'));
|
||||||
const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));
|
const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));
|
||||||
const SiteAnnouncements = lazy(() => import('./pages/SiteAnnouncements.js'));
|
const SiteAnnouncements = lazy(() => import('./pages/SiteAnnouncements.js'));
|
||||||
|
const PaymentManagement = lazy(() => import('./pages/PaymentManagement.js'));
|
||||||
|
const UserBalances = lazy(() => import('./pages/UserBalances.js'));
|
||||||
const UserRegister = lazy(() => import('./pages/UserRegister.js'));
|
const UserRegister = lazy(() => import('./pages/UserRegister.js'));
|
||||||
const UserDashboard = lazy(() => import('./pages/UserDashboard.js'));
|
const UserDashboard = lazy(() => import('./pages/UserDashboard.js'));
|
||||||
const UsageRecords = lazy(() => import('./pages/UsageRecords.js'));
|
const UsageRecords = lazy(() => import('./pages/UsageRecords.js'));
|
||||||
@@ -565,6 +567,7 @@ const navIcons = {
|
|||||||
ops: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 19V9m4 10V5m4 14v-7m4 7V8m4 11V4M3 19h18" /></svg>,
|
ops: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 19V9m4 10V5m4 14v-7m4 7V8m4 11V4M3 19h18" /></svg>,
|
||||||
users: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" /></svg>,
|
users: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" /></svg>,
|
||||||
announcement: <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>,
|
announcement: <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>,
|
||||||
|
payment: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M3 8h18M5 5h14a2 2 0 012 2v10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2zm3 9h4" /></svg>,
|
||||||
channels: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 7h16M4 12h10m-10 5h16M17 9l3 3-3 3" /></svg>,
|
channels: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 7h16M4 12h10m-10 5h16M17 9l3 3-3 3" /></svg>,
|
||||||
sites: <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>,
|
sites: <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>,
|
||||||
accounts: <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>,
|
accounts: <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>,
|
||||||
@@ -599,6 +602,8 @@ export const sidebarGroups: SidebarGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ to: '/users', label: '用户管理', icon: navIcons.users },
|
{ to: '/users', label: '用户管理', icon: navIcons.users },
|
||||||
{ to: '/site-announcements', label: '用户公告', icon: navIcons.announcement },
|
{ to: '/site-announcements', label: '用户公告', icon: navIcons.announcement },
|
||||||
|
{ to: '/payment', label: '支付管理', icon: navIcons.payment },
|
||||||
|
{ to: '/user-balances', label: '用户余额', icon: navIcons.payment },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1667,6 +1672,8 @@ function AppShell() {
|
|||||||
<Route path="/workspace" element={<Workspace />} />
|
<Route path="/workspace" element={<Workspace />} />
|
||||||
<Route path="/about" element={<About />} />
|
<Route path="/about" element={<About />} />
|
||||||
<Route path="/users" element={<UserManagement />} />
|
<Route path="/users" element={<UserManagement />} />
|
||||||
|
<Route path="/payment" element={<PaymentManagement />} />
|
||||||
|
<Route path="/user-balances" element={<UserBalances />} />
|
||||||
|
|
||||||
{/* User specific pages */}
|
{/* User specific pages */}
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
+65
-4
@@ -52,7 +52,7 @@ function sanitizeErrorText(status: number, text: string): string {
|
|||||||
|| trimmed.includes("</html>")
|
|| trimmed.includes("</html>")
|
||||||
|| trimmed.includes("<!--[if");
|
|| trimmed.includes("<!--[if");
|
||||||
if (looksLikeHtml) {
|
if (looksLikeHtml) {
|
||||||
return `HTTP ${status}: 上游返回 HTML 错误页,请检查支付/上游 Token 配置`;
|
return `HTTP ${status}: 上游返回 HTML 错误页,请检查对应上游服务配置`;
|
||||||
}
|
}
|
||||||
return `HTTP ${status}: ${trimmed.slice(0, 120)}`;
|
return `HTTP ${status}: ${trimmed.slice(0, 120)}`;
|
||||||
}
|
}
|
||||||
@@ -1476,6 +1476,7 @@ export type DigitalHumanJob = {
|
|||||||
progress: number;
|
progress: number;
|
||||||
progressTotal: number;
|
progressTotal: number;
|
||||||
result?: any;
|
result?: any;
|
||||||
|
reusePayload?: any;
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
startedAt?: string | null;
|
startedAt?: string | null;
|
||||||
@@ -2017,13 +2018,45 @@ export const api = {
|
|||||||
"/api/digital-human/pixelle/bgm",
|
"/api/digital-human/pixelle/bgm",
|
||||||
{ preserveAuthErrors: true },
|
{ preserveAuthErrors: true },
|
||||||
),
|
),
|
||||||
|
uploadAsset: (file: File, purpose = "reference_audio") => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set("file", file);
|
||||||
|
formData.set("purpose", purpose);
|
||||||
|
return request<{
|
||||||
|
success: boolean;
|
||||||
|
asset: {
|
||||||
|
publicId: string;
|
||||||
|
kind: string;
|
||||||
|
status: string;
|
||||||
|
fileUrl: string;
|
||||||
|
pixellePath?: string;
|
||||||
|
workspaceFileUrl?: string;
|
||||||
|
};
|
||||||
|
}>(
|
||||||
|
"/api/digital-human/upload",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
preserveAuthErrors: true,
|
||||||
|
timeoutMs: 120_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
getTemplateParams: (template: string) =>
|
getTemplateParams: (template: string) =>
|
||||||
request<{ template: string; media_width?: number; media_height?: number; params?: any }>(
|
request<{ template: string; media_width?: number; media_height?: number; params?: any }>(
|
||||||
`/api/digital-human/pixelle/template-params?template=${encodeURIComponent(template)}`,
|
`/api/digital-human/pixelle/template-params?template=${encodeURIComponent(template)}`,
|
||||||
{ preserveAuthErrors: true },
|
{ preserveAuthErrors: true },
|
||||||
),
|
),
|
||||||
previewImage: (data: { prompt: string; width?: number; height?: number; workflow?: string }) =>
|
previewImage: (data: {
|
||||||
request<{ image_path: string }>(
|
prompt: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
workflow?: string;
|
||||||
|
workflow_key?: string;
|
||||||
|
media_workflow?: string;
|
||||||
|
backend_provider?: string;
|
||||||
|
}) =>
|
||||||
|
request<any>(
|
||||||
"/api/digital-human/pixelle/preview-image",
|
"/api/digital-human/pixelle/preview-image",
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -2033,7 +2066,7 @@ export const api = {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
previewTts: (data: { text: string; workflow?: string; ref_audio?: string }) =>
|
previewTts: (data: { text: string; workflow?: string; ref_audio?: string }) =>
|
||||||
request<{ audio_path: string; duration?: number }>(
|
request<any>(
|
||||||
"/api/digital-human/pixelle/preview-tts",
|
"/api/digital-human/pixelle/preview-tts",
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -2069,6 +2102,7 @@ export const api = {
|
|||||||
{ preserveAuthErrors: true },
|
{ preserveAuthErrors: true },
|
||||||
),
|
),
|
||||||
fileUrl: (path: string) => {
|
fileUrl: (path: string) => {
|
||||||
|
if (/^(https?:|blob:|data:)/i.test(path) || path.startsWith("/api/")) return path;
|
||||||
const trimmed = String(path || "").replace(/^\/+/, "");
|
const trimmed = String(path || "").replace(/^\/+/, "");
|
||||||
const segments = trimmed.split("/").map((segment) => encodeURIComponent(segment));
|
const segments = trimmed.split("/").map((segment) => encodeURIComponent(segment));
|
||||||
return `/api/digital-human/pixelle/files/${segments.join("/")}`;
|
return `/api/digital-human/pixelle/files/${segments.join("/")}`;
|
||||||
@@ -2858,6 +2892,8 @@ export const api = {
|
|||||||
request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}`),
|
request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}`),
|
||||||
cancelDigitalHumanJob: (id: string) =>
|
cancelDigitalHumanJob: (id: string) =>
|
||||||
request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
|
request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
|
||||||
|
retryDigitalHumanJob: (id: string) =>
|
||||||
|
request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/retry`, { 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: () =>
|
getMoneyPrinterStatus: () =>
|
||||||
@@ -2977,6 +3013,29 @@ export const userApi = {
|
|||||||
userRequest<{ success: boolean; run: WorkflowRunSummary }>(`/api/user/workflow-runs/${encodeURIComponent(publicId)}`),
|
userRequest<{ success: boolean; run: WorkflowRunSummary }>(`/api/user/workflow-runs/${encodeURIComponent(publicId)}`),
|
||||||
getDigitalHumanStatus: () =>
|
getDigitalHumanStatus: () =>
|
||||||
userRequest<DigitalHumanStatusResponse>("/api/digital-human/status"),
|
userRequest<DigitalHumanStatusResponse>("/api/digital-human/status"),
|
||||||
|
uploadDigitalHumanAsset: (file: File, purpose = "reference_audio") => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set("file", file);
|
||||||
|
formData.set("purpose", purpose);
|
||||||
|
return userRequest<{
|
||||||
|
success: boolean;
|
||||||
|
asset: {
|
||||||
|
publicId: string;
|
||||||
|
kind: string;
|
||||||
|
status: string;
|
||||||
|
fileUrl: string;
|
||||||
|
pixellePath?: string;
|
||||||
|
workspaceFileUrl?: string;
|
||||||
|
};
|
||||||
|
}>(
|
||||||
|
"/api/digital-human/upload",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
timeoutMs: 120_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
createDigitalHumanJob: (data: any) =>
|
createDigitalHumanJob: (data: any) =>
|
||||||
userRequest<DigitalHumanJobResponse>("/api/digital-human/jobs", {
|
userRequest<DigitalHumanJobResponse>("/api/digital-human/jobs", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -2989,6 +3048,8 @@ export const userApi = {
|
|||||||
userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}`),
|
userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}`),
|
||||||
cancelDigitalHumanJob: (id: string) =>
|
cancelDigitalHumanJob: (id: string) =>
|
||||||
userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
|
userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
|
||||||
|
retryDigitalHumanJob: (id: string) =>
|
||||||
|
userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/retry`, { 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: () =>
|
getMoneyPrinterStatus: () =>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
USER_RECHARGE_CONFIG,
|
USER_RECHARGE_CONFIG,
|
||||||
|
formatRechargePoints,
|
||||||
isUserRechargeOptionPayable,
|
isUserRechargeOptionPayable,
|
||||||
type UserRechargeOption,
|
type UserRechargeOption,
|
||||||
type UserRechargeCheckoutOption,
|
type UserRechargeCheckoutOption,
|
||||||
@@ -47,8 +48,10 @@ export default function UserRechargeOptions({
|
|||||||
title={option.unavailableReason || '该额度支付链接未配置'}
|
title={option.unavailableReason || '该额度支付链接未配置'}
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
|
{option.recommended ? <small>推荐</small> : null}
|
||||||
<strong>{option.label}</strong>
|
<strong>{option.label}</strong>
|
||||||
<span>{option.title}</span>
|
<span>{option.title}</span>
|
||||||
|
{option.amountUsd ? <b>{formatRechargePoints(option.amountUsd)}</b> : null}
|
||||||
<em>待配置</em>
|
<em>待配置</em>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -62,8 +65,10 @@ export default function UserRechargeOptions({
|
|||||||
className={className}
|
className={className}
|
||||||
aria-label={`前往系统内充值页确认 ${option.label}`}
|
aria-label={`前往系统内充值页确认 ${option.label}`}
|
||||||
>
|
>
|
||||||
|
{option.recommended ? <small>推荐</small> : null}
|
||||||
<strong>{option.label}</strong>
|
<strong>{option.label}</strong>
|
||||||
<span>{option.title}</span>
|
<span>{option.title}</span>
|
||||||
|
{option.amountUsd ? <b>{formatRechargePoints(option.amountUsd)}</b> : null}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -79,8 +84,10 @@ export default function UserRechargeOptions({
|
|||||||
disabled={pending}
|
disabled={pending}
|
||||||
data-checkout-option-id={option.id}
|
data-checkout-option-id={option.id}
|
||||||
>
|
>
|
||||||
|
{option.recommended ? <small>推荐</small> : null}
|
||||||
<strong>{option.label}</strong>
|
<strong>{option.label}</strong>
|
||||||
<span>{option.title}</span>
|
<span>{option.title}</span>
|
||||||
|
{option.amountUsd ? <b>{formatRechargePoints(option.amountUsd)}</b> : null}
|
||||||
{pending ? <em>创建记录中</em> : null}
|
{pending ? <em>创建记录中</em> : null}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
+310
-48
@@ -2439,7 +2439,7 @@ body {
|
|||||||
|
|
||||||
.program-logs-table {
|
.program-logs-table {
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
min-width: 1120px;
|
min-width: 1420px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.program-logs-filter-bar {
|
.program-logs-filter-bar {
|
||||||
@@ -2474,8 +2474,21 @@ body {
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.program-logs-table td:nth-child(6),
|
.program-logs-table .program-logs-account-cell,
|
||||||
.program-logs-table td:nth-child(7) {
|
.program-logs-table .program-logs-ip-cell {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: normal;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.program-logs-table .program-logs-ip-cell {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.program-logs-table td:nth-child(8),
|
||||||
|
.program-logs-table td:nth-child(9) {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4418,6 +4431,11 @@ textarea {
|
|||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.picture-agent-tabs + [role="tabpanel"] {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.picture-agent-field {
|
.picture-agent-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -4482,22 +4500,24 @@ textarea {
|
|||||||
.picture-agent-grid-fields {
|
.picture-agent-grid-fields {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.picture-agent-size-row {
|
.picture-agent-size-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr minmax(92px, 0.8fr);
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.picture-agent-size-preview {
|
.picture-agent-size-preview {
|
||||||
min-height: 38px;
|
grid-column: 1 / -1;
|
||||||
display: grid;
|
min-height: 46px;
|
||||||
align-content: center;
|
display: flex;
|
||||||
gap: 2px;
|
align-items: center;
|
||||||
padding: 6px 10px;
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: color-mix(in srgb, var(--color-info) 7%, var(--color-bg-card));
|
background: color-mix(in srgb, var(--color-info) 7%, var(--color-bg-card));
|
||||||
@@ -4511,21 +4531,21 @@ textarea {
|
|||||||
|
|
||||||
.picture-agent-size-preview strong {
|
.picture-agent-size-preview strong {
|
||||||
color: var(--color-info);
|
color: var(--color-info);
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.picture-agent-presets {
|
.picture-agent-presets {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.picture-agent-presets button {
|
.picture-agent-presets button {
|
||||||
min-height: 30px;
|
min-height: 34px;
|
||||||
padding: 0 10px;
|
padding: 0 12px;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
@@ -4590,9 +4610,9 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.picture-agent-submit {
|
.picture-agent-submit {
|
||||||
min-height: 40px;
|
min-height: 44px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: auto;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.picture-agent-submit-hint {
|
.picture-agent-submit-hint {
|
||||||
@@ -5773,6 +5793,11 @@ textarea {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.picture-agent-presets {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.picture-agent-size-row {
|
.picture-agent-size-row {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
}
|
}
|
||||||
@@ -12959,6 +12984,63 @@ textarea {
|
|||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.digital-human-upload-note {
|
||||||
|
margin-top: -2px;
|
||||||
|
color: var(--color-success);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digital-human-file-picker {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: color-mix(in srgb, var(--color-bg-card) 76%, var(--color-bg));
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digital-human-file-picker input {
|
||||||
|
position: absolute;
|
||||||
|
inline-size: 1px;
|
||||||
|
block-size: 1px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digital-human-file-picker label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: calc(var(--radius-sm) - 2px);
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-primary-contrast);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digital-human-file-picker input:disabled + label {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digital-human-file-picker span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.digital-human-panel h2 {
|
.digital-human-panel h2 {
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -13069,6 +13151,35 @@ textarea {
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.digital-human-backend-switch {
|
||||||
|
display: inline-grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 2px;
|
||||||
|
width: min(360px, 100%);
|
||||||
|
padding: 3px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-primary) 16%, var(--color-border));
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: color-mix(in srgb, var(--color-bg) 86%, var(--color-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.digital-human-backend-switch button {
|
||||||
|
min-height: 34px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: calc(var(--radius-sm) - 3px);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 760;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digital-human-backend-switch button.is-active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-primary-contrast);
|
||||||
|
box-shadow: 0 8px 18px color-mix(in srgb, var(--color-primary) 22%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.digital-human-upload-grid {
|
.digital-human-upload-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -13418,7 +13529,8 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.money-printer-summary,
|
.money-printer-summary,
|
||||||
.digital-human-summary {
|
.digital-human-summary,
|
||||||
|
.picture-agent-summary {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -13426,7 +13538,8 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.money-printer-summary-card,
|
.money-printer-summary-card,
|
||||||
.digital-human-summary-card {
|
.digital-human-summary-card,
|
||||||
|
.picture-agent-summary-card {
|
||||||
min-height: 92px;
|
min-height: 92px;
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: center;
|
align-content: center;
|
||||||
@@ -13443,14 +13556,17 @@ textarea {
|
|||||||
.money-printer-summary-card span,
|
.money-printer-summary-card span,
|
||||||
.money-printer-summary-card em,
|
.money-printer-summary-card em,
|
||||||
.digital-human-summary-card span,
|
.digital-human-summary-card span,
|
||||||
.digital-human-summary-card em {
|
.digital-human-summary-card em,
|
||||||
|
.picture-agent-summary-card span,
|
||||||
|
.picture-agent-summary-card em {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.money-printer-summary-card strong,
|
.money-printer-summary-card strong,
|
||||||
.digital-human-summary-card strong {
|
.digital-human-summary-card strong,
|
||||||
|
.picture-agent-summary-card strong {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
@@ -13460,7 +13576,8 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.money-printer-summary-card em,
|
.money-printer-summary-card em,
|
||||||
.digital-human-summary-card em {
|
.digital-human-summary-card em,
|
||||||
|
.picture-agent-summary-card em {
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13937,6 +14054,7 @@ textarea {
|
|||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
.money-printer-summary,
|
.money-printer-summary,
|
||||||
.digital-human-summary,
|
.digital-human-summary,
|
||||||
|
.picture-agent-summary,
|
||||||
.money-printer-presets {
|
.money-printer-presets {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -13949,6 +14067,7 @@ textarea {
|
|||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.money-printer-summary,
|
.money-printer-summary,
|
||||||
.digital-human-summary,
|
.digital-human-summary,
|
||||||
|
.picture-agent-summary,
|
||||||
.money-printer-presets {
|
.money-printer-presets {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -14769,22 +14888,38 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.user-dashboard-recharge-panel {
|
.user-dashboard-recharge-panel {
|
||||||
display: flex;
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 1fr) minmax(440px, 0.78fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 22px;
|
||||||
gap: 18px;
|
overflow: hidden;
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
padding: 16px 18px;
|
padding: 18px 20px;
|
||||||
border: 1px solid color-mix(in srgb, var(--color-primary) 24%, var(--console-line));
|
border: 1px solid color-mix(in srgb, var(--color-primary) 24%, var(--console-line));
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
background:
|
background:
|
||||||
|
radial-gradient(circle at 92% 12%, color-mix(in srgb, var(--color-primary) 18%, transparent), transparent 34%),
|
||||||
linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card)), color-mix(in srgb, var(--color-bg-card) 86%, var(--color-bg)));
|
linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card)), color-mix(in srgb, var(--color-bg-card) 86%, var(--color-bg)));
|
||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-dashboard-recharge-panel::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, color-mix(in srgb, var(--color-bg-card) 54%, transparent), transparent 58%),
|
||||||
|
repeating-linear-gradient(90deg, color-mix(in srgb, var(--color-primary) 7%, transparent) 0 1px, transparent 1px 44px);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
.user-dashboard-recharge-panel>div {
|
.user-dashboard-recharge-panel>div {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 5px;
|
gap: 6px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14796,9 +14931,9 @@ textarea {
|
|||||||
|
|
||||||
.user-dashboard-recharge-panel strong {
|
.user-dashboard-recharge-panel strong {
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
font-size: 16px;
|
font-size: clamp(18px, 2.1vw, 24px);
|
||||||
font-weight: 820;
|
font-weight: 860;
|
||||||
line-height: 1.35;
|
line-height: 1.18;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-dashboard-recharge-panel p {
|
.user-dashboard-recharge-panel p {
|
||||||
@@ -14897,10 +15032,12 @@ textarea {
|
|||||||
|
|
||||||
.user-recharge-options {
|
.user-recharge-options {
|
||||||
display: grid;
|
display: grid;
|
||||||
flex: 0 1 520px;
|
position: relative;
|
||||||
grid-template-columns: repeat(4, minmax(92px, 1fr));
|
z-index: 1;
|
||||||
gap: 8px;
|
flex: 0 1 560px;
|
||||||
min-width: min(520px, 100%);
|
grid-template-columns: repeat(4, minmax(98px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
min-width: min(560px, 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-recharge-options.is-compact {
|
.user-recharge-options.is-compact {
|
||||||
@@ -14910,18 +15047,37 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.user-recharge-option {
|
.user-recharge-option {
|
||||||
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 2px;
|
align-content: center;
|
||||||
|
gap: 4px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 64px;
|
min-height: 76px;
|
||||||
padding: 10px 12px;
|
overflow: hidden;
|
||||||
|
padding: 12px 14px;
|
||||||
border: 1px solid color-mix(in srgb, var(--color-primary) 18%, var(--console-line));
|
border: 1px solid color-mix(in srgb, var(--color-primary) 18%, var(--console-line));
|
||||||
border-radius: 14px;
|
border-radius: 16px;
|
||||||
background: color-mix(in srgb, var(--color-bg-card) 92%, var(--color-primary));
|
background:
|
||||||
|
linear-gradient(180deg, color-mix(in srgb, var(--color-bg-card) 94%, var(--color-primary)), color-mix(in srgb, var(--color-bg-card) 86%, var(--color-bg)));
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
box-shadow: 0 10px 24px color-mix(in srgb, var(--color-primary) 8%, transparent);
|
box-shadow:
|
||||||
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
|
inset 0 1px 0 color-mix(in srgb, white 46%, transparent),
|
||||||
|
0 12px 28px color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||||
|
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease, box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-recharge-option::after {
|
||||||
|
content: "→";
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 10px;
|
||||||
|
color: color-mix(in srgb, var(--color-primary) 62%, var(--color-text-muted));
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-4px);
|
||||||
|
transition: opacity 160ms ease, transform 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.user-recharge-option {
|
button.user-recharge-option {
|
||||||
@@ -14934,9 +15090,18 @@ button.user-recharge-option {
|
|||||||
.user-recharge-option:focus-visible {
|
.user-recharge-option:focus-visible {
|
||||||
border-color: color-mix(in srgb, var(--color-primary) 54%, var(--console-line));
|
border-color: color-mix(in srgb, var(--color-primary) 54%, var(--console-line));
|
||||||
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-bg-card));
|
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-bg-card));
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 color-mix(in srgb, white 50%, transparent),
|
||||||
|
0 16px 34px color-mix(in srgb, var(--color-primary) 14%, transparent);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-recharge-option:hover::after,
|
||||||
|
.user-recharge-option:focus-visible::after {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
.user-recharge-option.is-disabled,
|
.user-recharge-option.is-disabled,
|
||||||
.user-recharge-option.is-disabled:hover,
|
.user-recharge-option.is-disabled:hover,
|
||||||
.user-recharge-option.is-disabled:focus-visible {
|
.user-recharge-option.is-disabled:focus-visible {
|
||||||
@@ -14948,14 +15113,35 @@ button.user-recharge-option {
|
|||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-recharge-option.is-disabled::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.user-recharge-option.is-recommended {
|
.user-recharge-option.is-recommended {
|
||||||
border-color: color-mix(in srgb, var(--color-primary) 64%, var(--console-line));
|
border-color: color-mix(in srgb, var(--color-primary) 64%, var(--console-line));
|
||||||
background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 22%, var(--color-bg-card)), color-mix(in srgb, var(--color-bg-card) 84%, var(--color-bg)));
|
background:
|
||||||
|
radial-gradient(circle at 86% 18%, color-mix(in srgb, var(--color-primary) 24%, transparent), transparent 38%),
|
||||||
|
linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 20%, var(--color-bg-card)), color-mix(in srgb, var(--color-bg-card) 84%, var(--color-bg)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-recharge-option small {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 9px;
|
||||||
|
max-width: calc(100% - 18px);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-bg-card));
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 820;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-recharge-option strong {
|
.user-recharge-option strong {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 17px;
|
font-size: 20px;
|
||||||
font-weight: 860;
|
font-weight: 860;
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -14965,13 +15151,23 @@ button.user-recharge-option {
|
|||||||
.user-recharge-option span {
|
.user-recharge-option span {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 650;
|
font-weight: 680;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-recharge-option b {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 820;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.user-recharge-option em {
|
.user-recharge-option em {
|
||||||
align-self: end;
|
align-self: end;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
@@ -15083,6 +15279,68 @@ button.user-recharge-option {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-recharge-custom {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(170px, 0.28fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-primary) 22%, var(--console-line));
|
||||||
|
border-radius: 16px;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 8%, transparent), transparent 58%),
|
||||||
|
var(--color-bg-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-recharge-custom>div {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-recharge-custom span {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 820;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-recharge-custom strong {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 860;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-recharge-custom p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-recharge-custom label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-recharge-custom input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 42px;
|
||||||
|
border: 1px solid var(--console-line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 760;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-recharge-custom .btn {
|
||||||
|
min-height: 42px;
|
||||||
|
justify-content: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.user-recharge-info-grid {
|
.user-recharge-info-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
@@ -15574,7 +15832,7 @@ button.user-recharge-option {
|
|||||||
|
|
||||||
.user-dashboard-recharge-panel {
|
.user-dashboard-recharge-panel {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
flex-direction: column;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-dashboard-recharge-panel .btn,
|
.user-dashboard-recharge-panel .btn,
|
||||||
@@ -15603,6 +15861,10 @@ button.user-recharge-option {
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-recharge-custom {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.user-recharge-info-grid {
|
.user-recharge-info-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -576,6 +576,16 @@ function angleLabel(angle?: string) {
|
|||||||
return angle || '图像';
|
return angle || '图像';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toolLabel(tool?: string) {
|
||||||
|
switch (tool) {
|
||||||
|
case 'generate_character_images': return '生成角色四视图';
|
||||||
|
case 'generate_scene_image': return '生成场景图';
|
||||||
|
case 'generate_tts': return '生成语音';
|
||||||
|
case 'build_comfyui_workflow': return '生成工作流';
|
||||||
|
default: return tool || '工具';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function appendLine(current: string, line: string) {
|
function appendLine(current: string, line: string) {
|
||||||
if (!line) return current;
|
if (!line) return current;
|
||||||
return current ? `${current}\n${line}` : line;
|
return current ? `${current}\n${line}` : line;
|
||||||
@@ -1027,11 +1037,11 @@ export default function ComfyUIAgent() {
|
|||||||
next.images.push({
|
next.images.push({
|
||||||
url: String(event.url || ''),
|
url: String(event.url || ''),
|
||||||
character: event.character,
|
character: event.character,
|
||||||
angle: event.angle,
|
angle: event.angle || event.view,
|
||||||
});
|
});
|
||||||
next.content = appendLine(
|
next.content = appendLine(
|
||||||
next.content,
|
next.content,
|
||||||
`已生成 ${event.character || '角色'} ${angleLabel(event.angle)}。`,
|
`已生成 ${event.character || '角色'} ${angleLabel(event.angle || event.view)}。`,
|
||||||
);
|
);
|
||||||
} else if (event.type === 'scene') {
|
} else if (event.type === 'scene') {
|
||||||
next.scenes.push({
|
next.scenes.push({
|
||||||
@@ -1055,8 +1065,27 @@ export default function ComfyUIAgent() {
|
|||||||
`已生成 ${event.character || '角色'} 的语音。`,
|
`已生成 ${event.character || '角色'} 的语音。`,
|
||||||
);
|
);
|
||||||
} else if (event.type === 'comfyui_workflow') {
|
} else if (event.type === 'comfyui_workflow') {
|
||||||
|
// New agent path sends a parsed object in `workflow`; legacy path sends a
|
||||||
|
// JSON string in `content`.
|
||||||
|
if (event.workflow !== undefined && event.workflow !== null) {
|
||||||
|
next.workflowJson = event.workflow;
|
||||||
|
} else {
|
||||||
next.workflowJson = parseWorkflow(String(event.content || ''));
|
next.workflowJson = parseWorkflow(String(event.content || ''));
|
||||||
|
}
|
||||||
next.content = appendLine(next.content, 'ComfyUI 工作流 JSON 已就绪。');
|
next.content = appendLine(next.content, 'ComfyUI 工作流 JSON 已就绪。');
|
||||||
|
} else if (event.type === 'tool_call') {
|
||||||
|
next.content = appendLine(
|
||||||
|
next.content,
|
||||||
|
`🛠 调用工具:${toolLabel(event.tool)}`,
|
||||||
|
);
|
||||||
|
} else if (event.type === 'tool_progress') {
|
||||||
|
if (event.detail) {
|
||||||
|
next.content = appendLine(next.content, `· ${event.detail}`);
|
||||||
|
}
|
||||||
|
} else if (event.type === 'tool_result') {
|
||||||
|
if (event.result) {
|
||||||
|
next.content = appendLine(next.content, `✓ ${String(event.result)}`);
|
||||||
|
}
|
||||||
} else if (event.type === 'error') {
|
} else if (event.type === 'error') {
|
||||||
next.content = appendLine(next.content, `错误:${event.message || '生成失败'}`);
|
next.content = appendLine(next.content, `错误:${event.message || '生成失败'}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,964 +0,0 @@
|
|||||||
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 { readFileSync } from 'node:fs';
|
|
||||||
import { resolve } from 'node:path';
|
|
||||||
import DigitalHuman from './DigitalHuman.js';
|
|
||||||
|
|
||||||
const { apiMock, userApiMock } = vi.hoisted(() => {
|
|
||||||
const createBase = () => ({
|
|
||||||
getWorkflows: vi.fn(),
|
|
||||||
getWorkflowModelFolder: vi.fn(),
|
|
||||||
getDigitalHumanStatus: vi.fn(),
|
|
||||||
getDigitalHumanJobs: vi.fn(),
|
|
||||||
getWorkspaceAssets: vi.fn(),
|
|
||||||
createDigitalHumanJob: vi.fn(),
|
|
||||||
cancelDigitalHumanJob: vi.fn(),
|
|
||||||
clearDigitalHumanJobs: vi.fn(),
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
apiMock: createBase(),
|
|
||||||
userApiMock: createBase(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('../api.js', () => ({
|
|
||||||
api: apiMock,
|
|
||||||
userApi: userApiMock,
|
|
||||||
fetchSessionResponse: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function findButton(root: ReactTestRenderer, label: string) {
|
|
||||||
return root.root.findAll((node) => (
|
|
||||||
node.type === 'button'
|
|
||||||
&& typeof node.props.onClick === 'function'
|
|
||||||
&& collectText(node).includes(label)
|
|
||||||
))[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function findComfyModelInput(root: ReactTestRenderer) {
|
|
||||||
return root.root.find((node) => (
|
|
||||||
node.type === 'input'
|
|
||||||
&& node.props.list === 'digital-human-comfy-api-models'
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
function findDatalistOptionValues(root: ReactTestRenderer, id: string) {
|
|
||||||
const datalist = root.root.find((node) => node.type === 'datalist' && node.props.id === id);
|
|
||||||
return datalist.findAll((node) => node.type === 'option').map((node) => node.props.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function linkTarget(node: ReactTestInstance): string {
|
|
||||||
return String(node.props.href || node.props.to || '');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderDigitalHuman(initialPath = '/digital-human') {
|
|
||||||
let root!: ReactTestRenderer;
|
|
||||||
await act(async () => {
|
|
||||||
root = create(
|
|
||||||
<MemoryRouter initialEntries={[initialPath]}>
|
|
||||||
<DigitalHuman />
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await flushMicrotasks();
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('DigitalHuman page', () => {
|
|
||||||
let documentListeners!: Map<string, EventListener[]>;
|
|
||||||
let activeElementMock!: HTMLElement & { focus: ReturnType<typeof vi.fn> };
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
documentListeners = new Map();
|
|
||||||
class MockHTMLElement {
|
|
||||||
isConnected = true;
|
|
||||||
focus = vi.fn();
|
|
||||||
}
|
|
||||||
activeElementMock = new MockHTMLElement() as HTMLElement & { focus: ReturnType<typeof vi.fn> };
|
|
||||||
vi.stubGlobal('HTMLElement', MockHTMLElement);
|
|
||||||
vi.stubGlobal('document', {
|
|
||||||
activeElement: activeElementMock,
|
|
||||||
body: {
|
|
||||||
style: {
|
|
||||||
overflow: '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
|
||||||
documentListeners.set(type, [...(documentListeners.get(type) || []), listener]);
|
|
||||||
}),
|
|
||||||
removeEventListener: vi.fn((type: string, listener: EventListener) => {
|
|
||||||
documentListeners.set(type, (documentListeners.get(type) || []).filter((item) => item !== listener));
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
vi.stubGlobal('window', {
|
|
||||||
setInterval,
|
|
||||||
clearInterval,
|
|
||||||
});
|
|
||||||
|
|
||||||
apiMock.getWorkflows.mockResolvedValue({ success: true, items: [] });
|
|
||||||
apiMock.getWorkflowModelFolder.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
items: [{ name: 'wan2.1-digital-human' }, { name: 'seedance-1-0-pro-t2v-250528' }],
|
|
||||||
names: ['wan2.1-digital-human', 'seedance-1-0-pro-t2v-250528'],
|
|
||||||
});
|
|
||||||
apiMock.getDigitalHumanStatus.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
mode: 'comfy-native',
|
|
||||||
keyName: 'comfyui-agent',
|
|
||||||
capabilities: ['product_explainer_video', 'workspace_archive'],
|
|
||||||
comfy: {
|
|
||||||
configured: true,
|
|
||||||
backend: 'cloud',
|
|
||||||
baseUrl: 'https://comfy.example.com',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
apiMock.getDigitalHumanJobs.mockResolvedValue({ success: true, jobs: [] });
|
|
||||||
apiMock.getWorkspaceAssets.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
publicId: 'asset-audio-1',
|
|
||||||
title: '参考音频',
|
|
||||||
kind: 'audio',
|
|
||||||
source: 'workspace',
|
|
||||||
hasLocalFile: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
userApiMock.getWorkflows.mockResolvedValue({ success: true, items: [] });
|
|
||||||
userApiMock.getWorkflowModelFolder.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
items: [{ name: 'wan2.1-digital-human' }],
|
|
||||||
names: ['wan2.1-digital-human'],
|
|
||||||
});
|
|
||||||
userApiMock.getDigitalHumanStatus.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
mode: 'boosapi-video-route',
|
|
||||||
keyName: 'user-default',
|
|
||||||
capabilities: [],
|
|
||||||
comfy: {
|
|
||||||
configured: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
userApiMock.getDigitalHumanJobs.mockResolvedValue({ success: true, jobs: [] });
|
|
||||||
userApiMock.getWorkspaceAssets.mockResolvedValue({ success: true, items: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('opens the workspace asset picker as a labelled dialog and dismisses it with Escape', async () => {
|
|
||||||
let root!: ReactTestRenderer;
|
|
||||||
|
|
||||||
try {
|
|
||||||
root = await renderDigitalHuman();
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
findButton(root, '工作空间').props.onClick();
|
|
||||||
});
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
const dialog = root.root.find((node) => node.props.role === 'dialog');
|
|
||||||
expect(dialog.props['aria-modal']).toBe('true');
|
|
||||||
expect(dialog.props['aria-labelledby']).toBeTruthy();
|
|
||||||
expect(dialog.props.tabIndex).toBe(-1);
|
|
||||||
expect(collectText(dialog)).toContain('选择人物素材');
|
|
||||||
expect(document.body.style.overflow).toBe('hidden');
|
|
||||||
expect(apiMock.getWorkspaceAssets).toHaveBeenCalledWith({ kind: undefined, pageSize: 60 });
|
|
||||||
|
|
||||||
const keydownListeners = documentListeners.get('keydown') || [];
|
|
||||||
expect(keydownListeners.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const tabEvent = {
|
|
||||||
key: 'Tab',
|
|
||||||
preventDefault: vi.fn(),
|
|
||||||
shiftKey: false,
|
|
||||||
};
|
|
||||||
await act(async () => {
|
|
||||||
keydownListeners.at(-1)?.(tabEvent as unknown as KeyboardEvent);
|
|
||||||
});
|
|
||||||
expect(root.root.findAll((node) => node.props.role === 'dialog')).toHaveLength(1);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
keydownListeners.at(-1)?.({ key: 'Escape' } as KeyboardEvent);
|
|
||||||
});
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
expect(root.root.findAll((node) => node.props.role === 'dialog')).toHaveLength(0);
|
|
||||||
expect(document.body.style.overflow).toBe('');
|
|
||||||
expect(documentListeners.get('keydown') || []).toHaveLength(0);
|
|
||||||
expect(activeElementMock.focus).toHaveBeenCalledWith({ preventScroll: true });
|
|
||||||
} finally {
|
|
||||||
if (root) {
|
|
||||||
await act(async () => {
|
|
||||||
root.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the active digital human backend, key, and Comfy status', async () => {
|
|
||||||
let root!: ReactTestRenderer;
|
|
||||||
try {
|
|
||||||
root = await renderDigitalHuman();
|
|
||||||
const text = collectText(root.root);
|
|
||||||
expect(apiMock.getDigitalHumanStatus).toHaveBeenCalled();
|
|
||||||
expect(text).toContain('当前后端');
|
|
||||||
expect(text).toContain('Comfy API');
|
|
||||||
expect(text).toContain('使用 comfyui-agent');
|
|
||||||
expect(text).toContain('Comfy 状态');
|
|
||||||
expect(text).toContain('cloud');
|
|
||||||
expect(text).toContain('https://comfy.example.com');
|
|
||||||
expect(text).toContain('能力');
|
|
||||||
expect(text).toContain('2 项');
|
|
||||||
} finally {
|
|
||||||
if (root) {
|
|
||||||
await act(async () => {
|
|
||||||
root.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows generation when only optional output mapping is missing', async () => {
|
|
||||||
apiMock.getWorkflows.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
publicId: 'tpl_digital_human',
|
|
||||||
name: '数字人模板',
|
|
||||||
visibility: 'template',
|
|
||||||
isTemplate: true,
|
|
||||||
graph: {
|
|
||||||
metadata: {
|
|
||||||
digitalHuman: {
|
|
||||||
nodeMapping: {
|
|
||||||
prompt: { nodeId: '1', field: 'prompt' },
|
|
||||||
script: { nodeId: '2', field: 'text' },
|
|
||||||
model: { nodeId: '5', field: 'model' },
|
|
||||||
avatar: { nodeId: '3', field: 'image' },
|
|
||||||
referenceAudio: { nodeId: '4', field: 'audio' },
|
|
||||||
output: { nodeId: '', field: 'video' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
prompt: {
|
|
||||||
'5': {
|
|
||||||
class_type: 'ByteDanceDigitalHumanNode',
|
|
||||||
inputs: { model: 'seedance-1-0-lite-t2v-250428' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
apiMock.createDigitalHumanJob.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
job: {
|
|
||||||
id: 'dh_done',
|
|
||||||
type: 'digital-human-generation',
|
|
||||||
title: '数字人模板',
|
|
||||||
status: 'running',
|
|
||||||
progress: 1,
|
|
||||||
progressTotal: 5,
|
|
||||||
result: {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let root!: ReactTestRenderer;
|
|
||||||
try {
|
|
||||||
root = await renderDigitalHuman();
|
|
||||||
const text = collectText(root.root);
|
|
||||||
expect(text).toContain('必需节点映射已准备好');
|
|
||||||
expect(text).not.toContain('缺少映射:输出节点');
|
|
||||||
|
|
||||||
const avatarInput = root.root.find((node) => (
|
|
||||||
node.type === 'input'
|
|
||||||
&& node.props.placeholder === '或粘贴图片/视频 URL'
|
|
||||||
));
|
|
||||||
const audioInput = root.root.find((node) => (
|
|
||||||
node.type === 'input'
|
|
||||||
&& node.props.placeholder === '或粘贴音频 URL'
|
|
||||||
));
|
|
||||||
await act(async () => {
|
|
||||||
avatarInput.props.onChange({ target: { value: 'https://cdn.example.com/avatar.png' } });
|
|
||||||
audioInput.props.onChange({ target: { value: 'https://cdn.example.com/voice.wav' } });
|
|
||||||
});
|
|
||||||
const modelInput = findComfyModelInput(root);
|
|
||||||
expect(modelInput.props.value).toBe('seedance-1-0-lite-t2v-250428');
|
|
||||||
expect(modelInput.props.list).toBe('digital-human-comfy-api-models');
|
|
||||||
const modelOptions = findDatalistOptionValues(root, 'digital-human-comfy-api-models');
|
|
||||||
expect(modelOptions).toContain('wan2.1-digital-human');
|
|
||||||
expect(modelOptions).toContain('seedance-1-0-lite-t2v-250428');
|
|
||||||
await act(async () => {
|
|
||||||
modelInput.props.onChange({ target: { value: 'seedance-1-0-pro-t2v-250528' } });
|
|
||||||
});
|
|
||||||
|
|
||||||
const generateButton = findButton(root, '生成讲解视频');
|
|
||||||
expect(generateButton.props.disabled).toBe(false);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
generateButton.props.onClick();
|
|
||||||
});
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
expect(apiMock.createDigitalHumanJob).toHaveBeenCalledWith(expect.objectContaining({
|
|
||||||
templateId: 'tpl_digital_human',
|
|
||||||
avatarUrl: 'https://cdn.example.com/avatar.png',
|
|
||||||
referenceAudioUrl: 'https://cdn.example.com/voice.wav',
|
|
||||||
comfyApiModel: 'seedance-1-0-pro-t2v-250528',
|
|
||||||
nodeMapping: expect.objectContaining({
|
|
||||||
model: { nodeId: '5', field: 'model' },
|
|
||||||
output: { nodeId: '', field: 'video' },
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
} finally {
|
|
||||||
if (root) {
|
|
||||||
await act(async () => {
|
|
||||||
root.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows internal recharge guidance when a user digital human task hits quota', async () => {
|
|
||||||
userApiMock.getWorkflows.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
publicId: 'tpl_digital_human_quota',
|
|
||||||
name: '数字人模板',
|
|
||||||
visibility: 'template',
|
|
||||||
isTemplate: true,
|
|
||||||
graph: {
|
|
||||||
metadata: {
|
|
||||||
digitalHuman: {
|
|
||||||
nodeMapping: {
|
|
||||||
prompt: { nodeId: '1', field: 'prompt' },
|
|
||||||
script: { nodeId: '2', field: 'text' },
|
|
||||||
avatar: { nodeId: '3', field: 'image' },
|
|
||||||
referenceAudio: { nodeId: '4', field: 'audio' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
prompt: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const quotaError = new Error('下游密钥成本额度已用完') as any;
|
|
||||||
quotaError.status = 429;
|
|
||||||
quotaError.billing = {
|
|
||||||
rechargePath: '/user/recharge',
|
|
||||||
usagePath: '/user/usage',
|
|
||||||
};
|
|
||||||
userApiMock.createDigitalHumanJob.mockRejectedValue(quotaError);
|
|
||||||
|
|
||||||
let root!: ReactTestRenderer;
|
|
||||||
try {
|
|
||||||
root = await renderDigitalHuman('/user/digital-human');
|
|
||||||
const avatarInput = root.root.find((node) => (
|
|
||||||
node.type === 'input'
|
|
||||||
&& node.props.placeholder === '或粘贴图片/视频 URL'
|
|
||||||
));
|
|
||||||
const audioInput = root.root.find((node) => (
|
|
||||||
node.type === 'input'
|
|
||||||
&& node.props.placeholder === '或粘贴音频 URL'
|
|
||||||
));
|
|
||||||
await act(async () => {
|
|
||||||
avatarInput.props.onChange({ target: { value: 'https://cdn.example.com/avatar.png' } });
|
|
||||||
audioInput.props.onChange({ target: { value: 'https://cdn.example.com/voice.wav' } });
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
findButton(root, '生成讲解视频').props.onClick();
|
|
||||||
});
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
const text = collectText(root.root);
|
|
||||||
const links = root.root.findAll((node) => node.type === 'a');
|
|
||||||
expect(text).toContain('账户额度不足');
|
|
||||||
expect(text).toContain('去充值');
|
|
||||||
expect(links.some((link) => linkTarget(link) === '/user/recharge')).toBe(true);
|
|
||||||
expect(links.some((link) => linkTarget(link) === '/user/usage')).toBe(true);
|
|
||||||
} finally {
|
|
||||||
if (root) {
|
|
||||||
await act(async () => {
|
|
||||||
root.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the template Comfy API model when video_models is empty', async () => {
|
|
||||||
apiMock.getWorkflowModelFolder.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
items: [],
|
|
||||||
names: [],
|
|
||||||
});
|
|
||||||
apiMock.getWorkflows.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
publicId: 'tpl_digital_human_template_model',
|
|
||||||
name: '模板内模型数字人',
|
|
||||||
visibility: 'template',
|
|
||||||
isTemplate: true,
|
|
||||||
graph: {
|
|
||||||
metadata: {
|
|
||||||
digitalHuman: {
|
|
||||||
nodeMapping: {
|
|
||||||
prompt: { nodeId: '1', field: 'prompt' },
|
|
||||||
script: { nodeId: '2', field: 'text' },
|
|
||||||
model: { nodeId: '5', field: 'model' },
|
|
||||||
avatar: { nodeId: '3', field: 'image' },
|
|
||||||
referenceAudio: { nodeId: '4', field: 'audio' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
prompt: {
|
|
||||||
'1': { class_type: 'PromptTextNode', inputs: { prompt: '' } },
|
|
||||||
'2': { class_type: 'TTSVoiceNode', inputs: { text: '' } },
|
|
||||||
'3': { class_type: 'LoadImage', inputs: { image: '' } },
|
|
||||||
'4': { class_type: 'LoadAudio', inputs: { audio: '' } },
|
|
||||||
'5': {
|
|
||||||
class_type: 'ByteDanceDigitalHumanNode',
|
|
||||||
inputs: { model: 'seedance-1-0-lite-t2v' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
apiMock.createDigitalHumanJob.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
job: {
|
|
||||||
id: 'dh_template_model',
|
|
||||||
type: 'digital-human-generation',
|
|
||||||
title: '数字人讲解任务',
|
|
||||||
status: 'running',
|
|
||||||
progress: 1,
|
|
||||||
progressTotal: 5,
|
|
||||||
result: {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let root!: ReactTestRenderer;
|
|
||||||
try {
|
|
||||||
root = await renderDigitalHuman();
|
|
||||||
const modelInput = findComfyModelInput(root);
|
|
||||||
expect(modelInput.props.value).toBe('seedance-1-0-lite-t2v-250428');
|
|
||||||
expect(collectText(root.root)).toContain('已使用模板内 Comfy API 模型 seedance-1-0-lite-t2v-250428');
|
|
||||||
|
|
||||||
const modelOptions = findDatalistOptionValues(root, 'digital-human-comfy-api-models');
|
|
||||||
expect(modelOptions).toEqual(['seedance-1-0-lite-t2v-250428']);
|
|
||||||
|
|
||||||
const avatarInput = root.root.find((node) => (
|
|
||||||
node.type === 'input'
|
|
||||||
&& node.props.placeholder === '或粘贴图片/视频 URL'
|
|
||||||
));
|
|
||||||
const audioInput = root.root.find((node) => (
|
|
||||||
node.type === 'input'
|
|
||||||
&& node.props.placeholder === '或粘贴音频 URL'
|
|
||||||
));
|
|
||||||
await act(async () => {
|
|
||||||
avatarInput.props.onChange({ target: { value: 'https://cdn.example.com/avatar.png' } });
|
|
||||||
audioInput.props.onChange({ target: { value: 'https://cdn.example.com/voice.wav' } });
|
|
||||||
});
|
|
||||||
|
|
||||||
const generateButton = findButton(root, '生成讲解视频');
|
|
||||||
expect(generateButton.props.disabled).toBe(false);
|
|
||||||
await act(async () => {
|
|
||||||
generateButton.props.onClick();
|
|
||||||
});
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
expect(apiMock.createDigitalHumanJob).toHaveBeenCalledWith(expect.objectContaining({
|
|
||||||
templateId: 'tpl_digital_human_template_model',
|
|
||||||
comfyApiModel: 'seedance-1-0-lite-t2v-250428',
|
|
||||||
}));
|
|
||||||
} finally {
|
|
||||||
if (root) {
|
|
||||||
await act(async () => {
|
|
||||||
root.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('requires a Comfy API video model when neither video_models nor the template provide one', async () => {
|
|
||||||
apiMock.getWorkflowModelFolder.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
items: [],
|
|
||||||
names: [],
|
|
||||||
});
|
|
||||||
apiMock.getWorkflows.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
publicId: 'tpl_digital_human_no_model',
|
|
||||||
name: '无模型字段数字人模板',
|
|
||||||
visibility: 'template',
|
|
||||||
isTemplate: true,
|
|
||||||
graph: {
|
|
||||||
metadata: {
|
|
||||||
digitalHuman: {
|
|
||||||
nodeMapping: {
|
|
||||||
prompt: { nodeId: '1', field: 'prompt' },
|
|
||||||
script: { nodeId: '2', field: 'text' },
|
|
||||||
avatar: { nodeId: '3', field: 'image' },
|
|
||||||
referenceAudio: { nodeId: '4', field: 'audio' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
prompt: {
|
|
||||||
'1': { class_type: 'PromptTextNode', inputs: { prompt: '' } },
|
|
||||||
'2': { class_type: 'TTSVoiceNode', inputs: { text: '' } },
|
|
||||||
'3': { class_type: 'LoadImage', inputs: { image: '' } },
|
|
||||||
'4': { class_type: 'LoadAudio', inputs: { audio: '' } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
apiMock.createDigitalHumanJob.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
job: {
|
|
||||||
id: 'dh_default_model',
|
|
||||||
type: 'digital-human-generation',
|
|
||||||
title: '数字人讲解任务',
|
|
||||||
status: 'running',
|
|
||||||
progress: 1,
|
|
||||||
progressTotal: 5,
|
|
||||||
result: {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let root!: ReactTestRenderer;
|
|
||||||
try {
|
|
||||||
root = await renderDigitalHuman();
|
|
||||||
const modelInput = root.root.find((node) => (
|
|
||||||
node.type === 'input'
|
|
||||||
&& node.props.list === 'digital-human-comfy-api-models'
|
|
||||||
));
|
|
||||||
expect(modelInput.props.value).toBe('');
|
|
||||||
expect(collectText(root.root)).toContain('未读取到 Comfy API 视频模型,请先在 Comfy 模板或 video_models 中配置模型');
|
|
||||||
|
|
||||||
const modelOptions = findDatalistOptionValues(root, 'digital-human-comfy-api-models');
|
|
||||||
expect(modelOptions).toEqual([]);
|
|
||||||
|
|
||||||
const avatarInput = root.root.find((node) => (
|
|
||||||
node.type === 'input'
|
|
||||||
&& node.props.placeholder === '或粘贴图片/视频 URL'
|
|
||||||
));
|
|
||||||
const audioInput = root.root.find((node) => (
|
|
||||||
node.type === 'input'
|
|
||||||
&& node.props.placeholder === '或粘贴音频 URL'
|
|
||||||
));
|
|
||||||
await act(async () => {
|
|
||||||
avatarInput.props.onChange({ target: { value: 'https://cdn.example.com/avatar.png' } });
|
|
||||||
audioInput.props.onChange({ target: { value: 'https://cdn.example.com/voice.wav' } });
|
|
||||||
});
|
|
||||||
|
|
||||||
const generateButton = findButton(root, '生成讲解视频');
|
|
||||||
expect(generateButton.props.disabled).toBe(true);
|
|
||||||
await act(async () => {
|
|
||||||
generateButton.props.onClick();
|
|
||||||
});
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
expect(apiMock.createDigitalHumanJob).not.toHaveBeenCalled();
|
|
||||||
} finally {
|
|
||||||
if (root) {
|
|
||||||
await act(async () => {
|
|
||||||
root.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('filters non-video route aliases out of the Comfy API model picker', async () => {
|
|
||||||
apiMock.getWorkflowModelFolder.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
items: [
|
|
||||||
{ name: 'gpt-4o' },
|
|
||||||
{ name: 'seedance-1-0-pro-t2v-250528' },
|
|
||||||
{ name: 'gpt-image-2' },
|
|
||||||
],
|
|
||||||
names: ['gpt-4o', 'seedance-1-0-pro-t2v-250528', 'gpt-image-2'],
|
|
||||||
});
|
|
||||||
|
|
||||||
let root!: ReactTestRenderer;
|
|
||||||
try {
|
|
||||||
root = await renderDigitalHuman();
|
|
||||||
const modelOptions = findDatalistOptionValues(root, 'digital-human-comfy-api-models');
|
|
||||||
expect(modelOptions).toContain('seedance-1-0-pro-t2v-250528');
|
|
||||||
expect(modelOptions).not.toContain('seedance-1-0-lite-t2v-250428');
|
|
||||||
expect(modelOptions).not.toContain('gpt-4o');
|
|
||||||
expect(modelOptions).not.toContain('gpt-image-2');
|
|
||||||
} finally {
|
|
||||||
if (root) {
|
|
||||||
await act(async () => {
|
|
||||||
root.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reads Comfy API video models from item payloads and normalizes old Seedance aliases', async () => {
|
|
||||||
apiMock.getWorkflowModelFolder.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
items: [
|
|
||||||
{ value: 'seedance-1-0-lite-t2v' },
|
|
||||||
{ filename: 'wan2.2-digital-human' },
|
|
||||||
{ model: 'gpt-image-2' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
let root!: ReactTestRenderer;
|
|
||||||
try {
|
|
||||||
root = await renderDigitalHuman();
|
|
||||||
const modelInput = findComfyModelInput(root);
|
|
||||||
expect(modelInput.props.value).toBe('seedance-1-0-lite-t2v-250428');
|
|
||||||
const modelOptions = findDatalistOptionValues(root, 'digital-human-comfy-api-models');
|
|
||||||
expect(modelOptions).toContain('seedance-1-0-lite-t2v-250428');
|
|
||||||
expect(modelOptions).toContain('wan2.2-digital-human');
|
|
||||||
expect(modelOptions).not.toContain('seedance-1-0-lite-t2v');
|
|
||||||
expect(modelOptions).not.toContain('gpt-image-2');
|
|
||||||
expect(collectText(root.root)).toContain('优先使用 Comfy API video_models;已读取 2 个视频模型');
|
|
||||||
} finally {
|
|
||||||
if (root) {
|
|
||||||
await act(async () => {
|
|
||||||
root.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps a manually selected Comfy API video model after data refresh', async () => {
|
|
||||||
apiMock.getWorkflows.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
publicId: 'tpl_digital_human',
|
|
||||||
name: '数字人模板',
|
|
||||||
visibility: 'template',
|
|
||||||
isTemplate: true,
|
|
||||||
graph: {
|
|
||||||
metadata: {
|
|
||||||
digitalHuman: {
|
|
||||||
nodeMapping: {
|
|
||||||
prompt: { nodeId: '1', field: 'prompt' },
|
|
||||||
script: { nodeId: '2', field: 'text' },
|
|
||||||
avatar: { nodeId: '3', field: 'image' },
|
|
||||||
referenceAudio: { nodeId: '4', field: 'audio' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
prompt: {
|
|
||||||
'5': {
|
|
||||||
class_type: 'ByteDanceDigitalHumanNode',
|
|
||||||
inputs: { model: 'seedance-1-0-lite-t2v-250428' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
let root!: ReactTestRenderer;
|
|
||||||
try {
|
|
||||||
root = await renderDigitalHuman();
|
|
||||||
const modelInput = findComfyModelInput(root);
|
|
||||||
expect(modelInput.props.value).toBe('seedance-1-0-lite-t2v-250428');
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
modelInput.props.onChange({ target: { value: 'seedance-1-0-pro-t2v-250528' } });
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
findButton(root, '刷新').props.onClick();
|
|
||||||
});
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
const refreshedModelInput = findComfyModelInput(root);
|
|
||||||
expect(refreshedModelInput.props.value).toBe('seedance-1-0-pro-t2v-250528');
|
|
||||||
} finally {
|
|
||||||
if (root) {
|
|
||||||
await act(async () => {
|
|
||||||
root.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults to digital-human capable templates instead of generic workflows', async () => {
|
|
||||||
apiMock.getWorkflows.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
publicId: 'tpl_generic_image',
|
|
||||||
name: '普通生图模板',
|
|
||||||
visibility: 'template',
|
|
||||||
isTemplate: true,
|
|
||||||
graph: {
|
|
||||||
prompt: {
|
|
||||||
'1': { class_type: 'CLIPTextEncode', inputs: { text: '' } },
|
|
||||||
'2': { class_type: 'KSampler', inputs: {} },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
publicId: 'tpl_digital_human_ready',
|
|
||||||
name: '数字人可用模板',
|
|
||||||
visibility: 'public',
|
|
||||||
isTemplate: false,
|
|
||||||
graph: {
|
|
||||||
prompt: {
|
|
||||||
'1': { class_type: 'PromptTextNode', inputs: { prompt: '' } },
|
|
||||||
'2': { class_type: 'TTSVoiceNode', inputs: { text: '' } },
|
|
||||||
'3': { class_type: 'LoadImage', inputs: { image: '' } },
|
|
||||||
'4': { class_type: 'LoadAudio', inputs: { audio: '' } },
|
|
||||||
'5': { class_type: 'VHS_VideoCombine', inputs: { filename_prefix: 'dh' } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
let root!: ReactTestRenderer;
|
|
||||||
try {
|
|
||||||
root = await renderDigitalHuman();
|
|
||||||
const text = collectText(root.root);
|
|
||||||
expect(text).toContain('数字人可用模板');
|
|
||||||
expect(text).not.toContain('普通生图模板');
|
|
||||||
|
|
||||||
const templateSelect = root.root.find((node) => (
|
|
||||||
node.type === 'select'
|
|
||||||
&& node.props.value === 'tpl_digital_human_ready'
|
|
||||||
));
|
|
||||||
expect(templateSelect).toBeTruthy();
|
|
||||||
expect(collectText(root.root)).toContain('必需节点映射已准备好');
|
|
||||||
} finally {
|
|
||||||
if (root) {
|
|
||||||
await act(async () => {
|
|
||||||
root.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('auto-infers required mappings from a plain Comfy workflow graph', async () => {
|
|
||||||
apiMock.getWorkflows.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
publicId: 'tpl_plain_comfy',
|
|
||||||
name: '普通 Comfy 数字人',
|
|
||||||
visibility: 'public',
|
|
||||||
isTemplate: false,
|
|
||||||
graph: {
|
|
||||||
prompt: {
|
|
||||||
'1': { class_type: 'PromptTextNode', inputs: { prompt: '' } },
|
|
||||||
'2': { class_type: 'TTSVoiceNode', inputs: { text: '' } },
|
|
||||||
'3': { class_type: 'LoadImage', inputs: { image: '' } },
|
|
||||||
'4': { class_type: 'LoadAudio', inputs: { audio: '' } },
|
|
||||||
'5': { class_type: 'VHS_VideoCombine', inputs: { filename_prefix: 'dh' } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
let root!: ReactTestRenderer;
|
|
||||||
try {
|
|
||||||
root = await renderDigitalHuman();
|
|
||||||
expect(collectText(root.root)).toContain('必需节点映射已准备好');
|
|
||||||
expect(collectText(root.root)).not.toContain('缺少映射');
|
|
||||||
|
|
||||||
const avatarInput = root.root.find((node) => (
|
|
||||||
node.type === 'input'
|
|
||||||
&& node.props.placeholder === '或粘贴图片/视频 URL'
|
|
||||||
));
|
|
||||||
const audioInput = root.root.find((node) => (
|
|
||||||
node.type === 'input'
|
|
||||||
&& node.props.placeholder === '或粘贴音频 URL'
|
|
||||||
));
|
|
||||||
await act(async () => {
|
|
||||||
avatarInput.props.onChange({ target: { value: 'https://cdn.example.com/avatar.png' } });
|
|
||||||
audioInput.props.onChange({ target: { value: 'https://cdn.example.com/voice.wav' } });
|
|
||||||
});
|
|
||||||
|
|
||||||
const generateButton = findButton(root, '生成讲解视频');
|
|
||||||
expect(generateButton.props.disabled).toBe(false);
|
|
||||||
} finally {
|
|
||||||
if (root) {
|
|
||||||
await act(async () => {
|
|
||||||
root.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('surfaces remote Comfy outputs and workspace assets on completed jobs', async () => {
|
|
||||||
apiMock.getDigitalHumanJobs.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
jobs: [
|
|
||||||
{
|
|
||||||
id: 'dh_remote_result',
|
|
||||||
type: 'digital-human-generation',
|
|
||||||
title: '数字人讲解任务',
|
|
||||||
status: 'succeeded',
|
|
||||||
progress: 5,
|
|
||||||
progressTotal: 5,
|
|
||||||
createdAt: '2026-06-08T05:00:00Z',
|
|
||||||
result: {
|
|
||||||
promptId: 'prompt-remote-123456',
|
|
||||||
keyId: 88,
|
|
||||||
keyName: 'comfyui-agent',
|
|
||||||
templateName: '数字人模板',
|
|
||||||
script: 'BossAI 数字人讲解脚本',
|
|
||||||
request: {
|
|
||||||
comfyApiModel: 'seedance-1-0-pro-t2v-250528',
|
|
||||||
},
|
|
||||||
assets: [
|
|
||||||
{
|
|
||||||
kind: 'video',
|
|
||||||
status: 'ready',
|
|
||||||
remoteUrl: 'https://cdn.example.com/digital-human/output.mp4',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
publicId: 'asset_audio_result',
|
|
||||||
kind: 'audio',
|
|
||||||
status: 'ready',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
let root!: ReactTestRenderer;
|
|
||||||
try {
|
|
||||||
root = await renderDigitalHuman();
|
|
||||||
const text = collectText(root.root);
|
|
||||||
expect(text).toContain('Prompt prompt-remot');
|
|
||||||
expect(text).toContain('数字人模板');
|
|
||||||
expect(text).toContain('comfyui-agent');
|
|
||||||
expect(text).toContain('远端产物 · 视频');
|
|
||||||
expect(text).toContain('工作空间 · 音频 · asset_audio_result');
|
|
||||||
|
|
||||||
const remoteLink = root.root.find((node) => (
|
|
||||||
node.type === 'a'
|
|
||||||
&& node.props.href === 'https://cdn.example.com/digital-human/output.mp4'
|
|
||||||
));
|
|
||||||
expect(remoteLink.props.target).toBe('_blank');
|
|
||||||
expect(remoteLink.props.rel).toBe('noreferrer');
|
|
||||||
|
|
||||||
const workspaceLink = root.root.find((node) => (
|
|
||||||
node.type === 'a'
|
|
||||||
&& node.props.href === '/workspace?q=asset_audio_result'
|
|
||||||
));
|
|
||||||
expect(workspaceLink.props.target).toBeUndefined();
|
|
||||||
|
|
||||||
const traceLinks = root.root.findAll((node) => node.type === 'a');
|
|
||||||
expect(traceLinks.some((link) => linkTarget(link) === '/logs?q=seedance-1-0-pro-t2v-250528&status=success&downstreamKeyId=88')).toBe(true);
|
|
||||||
expect(traceLinks.some((link) => linkTarget(link) === '/logs?q=seedance-1-0-pro-t2v-250528&status=failed&downstreamKeyId=88')).toBe(true);
|
|
||||||
expect(traceLinks.some((link) => linkTarget(link) === '/model-tester?model=seedance-1-0-pro-t2v-250528')).toBe(true);
|
|
||||||
} finally {
|
|
||||||
if (root) {
|
|
||||||
await act(async () => {
|
|
||||||
root.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('links user digital human tasks back to self-service usage records', async () => {
|
|
||||||
userApiMock.getDigitalHumanJobs.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
jobs: [
|
|
||||||
{
|
|
||||||
id: 'dh_user_done',
|
|
||||||
type: 'digital-human-generation',
|
|
||||||
title: '数字人讲解任务',
|
|
||||||
status: 'succeeded',
|
|
||||||
progress: 5,
|
|
||||||
progressTotal: 5,
|
|
||||||
createdAt: '2026-06-08T05:00:00Z',
|
|
||||||
result: {
|
|
||||||
keyId: 88,
|
|
||||||
keyName: '讲解 Key',
|
|
||||||
templateName: '数字人模板',
|
|
||||||
script: 'BossAI 数字人讲解脚本',
|
|
||||||
request: {
|
|
||||||
comfyApiModel: 'seedance-1-0-lite-t2v-250428',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
let root!: ReactTestRenderer;
|
|
||||||
try {
|
|
||||||
root = await renderDigitalHuman('/user/digital-human');
|
|
||||||
const traceLinks = root.root.findAll((node) => node.type === 'a');
|
|
||||||
expect(userApiMock.getDigitalHumanJobs).toHaveBeenCalled();
|
|
||||||
expect(traceLinks.some((link) => linkTarget(link) === '/user/usage?q=BossAI+%E6%95%B0%E5%AD%97%E4%BA%BA%E8%AE%B2%E8%A7%A3%E8%84%9A%E6%9C%AC&key_id=88&key=%E8%AE%B2%E8%A7%A3+Key&status=success')).toBe(true);
|
|
||||||
expect(traceLinks.some((link) => linkTarget(link) === '/user/usage?q=BossAI+%E6%95%B0%E5%AD%97%E4%BA%BA%E8%AE%B2%E8%A7%A3%E8%84%9A%E6%9C%AC&key_id=88&key=%E8%AE%B2%E8%A7%A3+Key&status=failed')).toBe(true);
|
|
||||||
expect(traceLinks.some((link) => linkTarget(link) === '/model-tester?model=seedance-1-0-lite-t2v-250428')).toBe(true);
|
|
||||||
expect(traceLinks.some((link) => linkTarget(link).startsWith('/logs'))).toBe(false);
|
|
||||||
} finally {
|
|
||||||
if (root) {
|
|
||||||
await act(async () => {
|
|
||||||
root.unmount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps side JSON panels bounded on mobile viewports', () => {
|
|
||||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
|
||||||
|
|
||||||
expect(css).not.toContain('max-height: 280px;');
|
|
||||||
expect(css).toContain('.digital-human-side pre {\n max-height: min(280px, calc(100dvh - var(--topbar-height) - 320px));');
|
|
||||||
expect(css).toContain('overscroll-behavior: contain;');
|
|
||||||
expect(css).toContain('.digital-human-side pre {\n max-height: calc(100dvh - var(--topbar-height) - 360px);');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps template, picker, and upload control grids shrink-safe on phones', () => {
|
|
||||||
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
|
||||||
|
|
||||||
expect(css).toContain('grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr));');
|
|
||||||
expect(css).toContain('.digital-human-template-grid {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(min(210px, 100%), 1fr));');
|
|
||||||
expect(css).toContain('.digital-human-upload-actions {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(min(132px, 100%), 1fr));');
|
|
||||||
expect(css).toContain('.digital-human-picker-grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(min(160px, 100%), 1fr));');
|
|
||||||
expect(css).not.toContain('grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+1004
-932
File diff suppressed because it is too large
Load Diff
@@ -466,7 +466,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(DEFAULT_BOOSAPI_VIDEO_MODEL);
|
const [videoModel, setVideoModel] = useState(DEFAULT_BOOSAPI_VIDEO_MODEL);
|
||||||
const [videoBackend, setVideoBackend] = useState<'boosapi' | 'comfy'>('comfy');
|
const [videoBackend, setVideoBackend] = useState<'boosapi' | 'comfy'>('boosapi');
|
||||||
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);
|
||||||
@@ -493,6 +493,7 @@ export default function MoneyPrinter() {
|
|||||||
const activeJobFilterTabId = `money-printer-filter-tab-${jobFilter}`;
|
const activeJobFilterTabId = `money-printer-filter-tab-${jobFilter}`;
|
||||||
const activeJobFilterPanelId = `money-printer-filter-panel-${jobFilter}`;
|
const activeJobFilterPanelId = `money-printer-filter-panel-${jobFilter}`;
|
||||||
const isCloudComfy = status?.comfy?.backend === 'cloud' && status?.builtinCloudTemplate?.enabled !== false;
|
const isCloudComfy = status?.comfy?.backend === 'cloud' && status?.builtinCloudTemplate?.enabled !== false;
|
||||||
|
const moneyPrinterDisabled = Boolean(status?.disabled);
|
||||||
const cloudDurationMin = status?.builtinCloudTemplate?.duration?.min ?? 3;
|
const cloudDurationMin = status?.builtinCloudTemplate?.duration?.min ?? 3;
|
||||||
const cloudDurationMax = status?.builtinCloudTemplate?.duration?.max ?? 12;
|
const cloudDurationMax = status?.builtinCloudTemplate?.duration?.max ?? 12;
|
||||||
const cloudDefaultDuration = status?.builtinCloudTemplate?.duration?.default ?? DEFAULT_BUILTIN_CLOUD_DURATION;
|
const cloudDefaultDuration = status?.builtinCloudTemplate?.duration?.default ?? DEFAULT_BUILTIN_CLOUD_DURATION;
|
||||||
@@ -602,6 +603,7 @@ export default function MoneyPrinter() {
|
|||||||
const hasSelectedComfyApiModel = Boolean(comfyApiModel.trim());
|
const hasSelectedComfyApiModel = Boolean(comfyApiModel.trim());
|
||||||
const canSubmit = Boolean(topic.trim())
|
const canSubmit = Boolean(topic.trim())
|
||||||
&& !submitting
|
&& !submitting
|
||||||
|
&& !moneyPrinterDisabled
|
||||||
&& (videoBackend !== 'comfy' || (comfyHasTemplate && hasSelectedComfyApiModel));
|
&& (videoBackend !== 'comfy' || (comfyHasTemplate && hasSelectedComfyApiModel));
|
||||||
const comfyApiModelHint = templateComfyApiModel
|
const comfyApiModelHint = templateComfyApiModel
|
||||||
? (hasConfiguredComfyApiVideoModels
|
? (hasConfiguredComfyApiVideoModels
|
||||||
@@ -844,6 +846,11 @@ export default function MoneyPrinter() {
|
|||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>
|
<div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{moneyPrinterDisabled ? (
|
||||||
|
<div className="alert alert-warning" style={{ marginBottom: 12 }}>
|
||||||
|
{status?.disabledReason || '短视频工厂暂时只支持本地模型部署。'}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="money-printer-summary">
|
<div className="money-printer-summary">
|
||||||
<div className="money-printer-summary-card">
|
<div className="money-printer-summary-card">
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
buildRechargeRecordsCsv,
|
||||||
|
formatComfyBalanceMicros,
|
||||||
|
formatDurationSeconds,
|
||||||
|
formatRechargeRecordTime,
|
||||||
|
paymentFieldLabel,
|
||||||
|
} from './PaymentManagement.js';
|
||||||
|
|
||||||
|
describe('PaymentManagement Comfy balance formatting', () => {
|
||||||
|
it('formats normalized Comfy balance micros as USD', () => {
|
||||||
|
expect(formatComfyBalanceMicros(49_800_000, 'usd')).toBe('49.8 usd');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('localizes Comfy balance and diagnostics field labels', () => {
|
||||||
|
expect(paymentFieldLabel('amountMicros')).toBe('账户余额');
|
||||||
|
expect(paymentFieldLabel('effectiveBalanceMicros')).toBe('可用余额');
|
||||||
|
expect(paymentFieldLabel('prepaidBalanceMicros')).toBe('预付余额');
|
||||||
|
expect(paymentFieldLabel('cloudCreditBalanceMicros')).toBe('云积分余额');
|
||||||
|
expect(paymentFieldLabel('pendingChargesMicros')).toBe('待扣费用');
|
||||||
|
expect(paymentFieldLabel('currency')).toBe('币种');
|
||||||
|
expect(paymentFieldLabel('diagnostics.httpStatus')).toBe('HTTP 状态');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats sqlite UTC timestamps into local display time', () => {
|
||||||
|
expect(formatRechargeRecordTime('2026-06-16 05:02:16', 'Asia/Shanghai')).toBe('2026/06/16 13:02:16');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats live Comfy login cache countdown', () => {
|
||||||
|
expect(formatDurationSeconds(2064)).toBe('34 分 24 秒');
|
||||||
|
expect(formatDurationSeconds(9)).toBe('9 秒');
|
||||||
|
expect(formatDurationSeconds(0)).toBe('需重新换取');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports recharge records as csv with readable timestamps', () => {
|
||||||
|
const csv = buildRechargeRecordsCsv([
|
||||||
|
{
|
||||||
|
publicId: 'rch_test',
|
||||||
|
userId: 7,
|
||||||
|
userEmail: 'user@example.com',
|
||||||
|
userName: '测试用户',
|
||||||
|
amountUsd: 50,
|
||||||
|
provider: 'comfy_cloud_checkout',
|
||||||
|
status: 'completed',
|
||||||
|
note: '管理员已确认到账',
|
||||||
|
createdAt: '2026-06-16 04:59:05',
|
||||||
|
completedAt: '2026-06-16 05:01:00',
|
||||||
|
},
|
||||||
|
], 'Asia/Shanghai');
|
||||||
|
|
||||||
|
expect(csv).toContain('"测试用户","user@example.com","50","completed"');
|
||||||
|
expect(csv).toContain('"2026/06/16 12:59:05"');
|
||||||
|
expect(csv).toContain('"2026/06/16 13:01:00"');
|
||||||
|
expect(csv).toContain('"rch_test"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps admin recharge actions explicit and compact', () => {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/PaymentManagement.tsx'), 'utf8');
|
||||||
|
|
||||||
|
expect(source).toContain('取消支付');
|
||||||
|
expect(source).toContain('adminCancelRecharge');
|
||||||
|
expect(source).toContain('onClick={exportRechargeRecords}');
|
||||||
|
expect(source).toContain('setLiveCacheRemainingSecs');
|
||||||
|
expect(source).toContain('lastTokenRefreshAttemptRef');
|
||||||
|
expect(source).toContain('导出');
|
||||||
|
expect(source).not.toContain('导出 CSV');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,916 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
type AdminComfyCloudBalanceResponse,
|
||||||
|
type ComfyCloudBalance,
|
||||||
|
type ComfyCloudDiagnostics,
|
||||||
|
type PaymentConfigResponse,
|
||||||
|
} from '../api.js';
|
||||||
|
import PageHeader from '../components/PageHeader.js';
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
if (error instanceof Error && error.message) return error.message;
|
||||||
|
if (typeof error === 'string' && error.trim()) return error.trim();
|
||||||
|
const message = (error as any)?.message;
|
||||||
|
return typeof message === 'string' && message.trim() ? message.trim() : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBoolean(value: boolean | undefined): string {
|
||||||
|
if (value === true) return '已启用';
|
||||||
|
if (value === false) return '未启用';
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value: number | null | undefined): string {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? String(value) : '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDurationSeconds(value: number | null | undefined): string {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||||
|
return '需重新换取';
|
||||||
|
}
|
||||||
|
const total = Math.max(0, Math.floor(value));
|
||||||
|
const minutes = Math.floor(total / 60);
|
||||||
|
const seconds = total % 60;
|
||||||
|
if (minutes <= 0) return `${seconds} 秒`;
|
||||||
|
return `${minutes} 分 ${seconds} 秒`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAYMENT_FIELD_LABELS: Record<string, string> = {
|
||||||
|
amountMicros: '账户余额',
|
||||||
|
effectiveBalanceMicros: '可用余额',
|
||||||
|
prepaidBalanceMicros: '预付余额',
|
||||||
|
cloudCreditBalanceMicros: '云积分余额',
|
||||||
|
pendingChargesMicros: '待扣费用',
|
||||||
|
currency: '币种',
|
||||||
|
'diagnostics.tokenKind': 'Token 类型',
|
||||||
|
'diagnostics.tokenSource': 'Token 来源',
|
||||||
|
'diagnostics.httpStatus': 'HTTP 状态',
|
||||||
|
'diagnostics.apiBaseUrl': '上游 API 地址',
|
||||||
|
'diagnostics.jwtIssuer': 'JWT 签发方',
|
||||||
|
'diagnostics.jwtAudience': 'JWT 受众',
|
||||||
|
'diagnostics.upstreamMessage': '上游返回信息',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function paymentFieldLabel(label: string): string {
|
||||||
|
return PAYMENT_FIELD_LABELS[label] || label;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { formatDurationSeconds };
|
||||||
|
|
||||||
|
type RechargeRecord = {
|
||||||
|
publicId: string;
|
||||||
|
userId: number;
|
||||||
|
userEmail?: string | null;
|
||||||
|
userName?: string | null;
|
||||||
|
amountUsd?: number | null;
|
||||||
|
provider: string;
|
||||||
|
status: string;
|
||||||
|
label?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
note?: string | null;
|
||||||
|
createdAt?: string | null;
|
||||||
|
completedAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatComfyBalanceMicros(value: number | null | undefined, currency: string): string {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) return '--';
|
||||||
|
const amount = value / 1_000_000;
|
||||||
|
return `${amount.toLocaleString('zh-CN', {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 6,
|
||||||
|
})} ${currency || ''}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRechargeRecordTime(value?: string | null, timeZone?: string): string {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw) return '--';
|
||||||
|
const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(raw)
|
||||||
|
? `${raw.replace(' ', 'T')}Z`
|
||||||
|
: raw;
|
||||||
|
const date = new Date(normalized);
|
||||||
|
if (Number.isNaN(date.getTime())) return raw;
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
...(timeZone ? { timeZone } : {}),
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function csvCell(value: unknown): string {
|
||||||
|
const text = value == null ? '' : String(value);
|
||||||
|
return `"${text.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRechargeRecordsCsv(records: RechargeRecord[], timeZone?: string): string {
|
||||||
|
const header = ['用户', '邮箱', '金额USD', '状态', 'Provider', '创建时间', '完成时间', '记录ID', '备注'];
|
||||||
|
const rows = records.map((record) => [
|
||||||
|
record.userName || record.userEmail || `用户 #${record.userId}`,
|
||||||
|
record.userEmail || '',
|
||||||
|
typeof record.amountUsd === 'number' && Number.isFinite(record.amountUsd) ? record.amountUsd : '',
|
||||||
|
record.status,
|
||||||
|
record.provider,
|
||||||
|
formatRechargeRecordTime(record.createdAt, timeZone),
|
||||||
|
formatRechargeRecordTime(record.completedAt, timeZone),
|
||||||
|
record.publicId,
|
||||||
|
record.note || '',
|
||||||
|
]);
|
||||||
|
return [header, ...rows].map((row) => row.map(csvCell).join(',')).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
padding: 14,
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
minWidth: 0,
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>{paymentFieldLabel(label)}</div>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--color-text-primary)', wordBreak: 'break-word' }}>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BalanceField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
currency,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number | null | undefined;
|
||||||
|
currency: string;
|
||||||
|
}) {
|
||||||
|
return <Field label={label} value={formatComfyBalanceMicros(value, currency)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PaymentManagement() {
|
||||||
|
const [config, setConfig] = useState<PaymentConfigResponse | null>(null);
|
||||||
|
const [balanceResponse, setBalanceResponse] = useState<AdminComfyCloudBalanceResponse | null>(null);
|
||||||
|
const [diagnostics, setDiagnostics] = useState<ComfyCloudDiagnostics | null>(null);
|
||||||
|
const [loadingConfig, setLoadingConfig] = useState(true);
|
||||||
|
const [loadingBalance, setLoadingBalance] = useState(false);
|
||||||
|
const [savingSettings, setSavingSettings] = useState(false);
|
||||||
|
const [configError, setConfigError] = useState('');
|
||||||
|
const [balanceError, setBalanceError] = useState('');
|
||||||
|
const [settingsMessage, setSettingsMessage] = useState('');
|
||||||
|
const [settingsError, setSettingsError] = useState('');
|
||||||
|
const [customerTokenInput, setCustomerTokenInput] = useState('');
|
||||||
|
const [customerTokenEndpointInput, setCustomerTokenEndpointInput] = useState('');
|
||||||
|
const [firebaseApiKeyInput, setFirebaseApiKeyInput] = useState('');
|
||||||
|
const [googleOAuthClientIdInput, setGoogleOAuthClientIdInput] = useState('');
|
||||||
|
const [googleOAuthClientSecretInput, setGoogleOAuthClientSecretInput] = useState('');
|
||||||
|
const [firebaseRefreshTokenInput, setFirebaseRefreshTokenInput] = useState('');
|
||||||
|
const [checkoutEndpointInput, setCheckoutEndpointInput] = useState('');
|
||||||
|
const [rechargeAmount, setRechargeAmount] = useState('10');
|
||||||
|
const [rechargeError, setRechargeError] = useState('');
|
||||||
|
const [rechargeMessage, setRechargeMessage] = useState('');
|
||||||
|
const [rechargeLoading, setRechargeLoading] = useState(false);
|
||||||
|
const [rechargeRecords, setRechargeRecords] = useState<RechargeRecord[]>([]);
|
||||||
|
const [recordsLoading, setRecordsLoading] = useState(false);
|
||||||
|
const [recordsError, setRecordsError] = useState('');
|
||||||
|
const [confirmingId, setConfirmingId] = useState('');
|
||||||
|
const [cancellingId, setCancellingId] = useState('');
|
||||||
|
const [clearingRecords, setClearingRecords] = useState(false);
|
||||||
|
const [liveCacheRemainingSecs, setLiveCacheRemainingSecs] = useState<number | null>(null);
|
||||||
|
const tokenRefreshInFlightRef = useRef(false);
|
||||||
|
const lastTokenRefreshAttemptRef = useRef(0);
|
||||||
|
|
||||||
|
const loadConfig = useCallback(async () => {
|
||||||
|
setLoadingConfig(true);
|
||||||
|
setConfigError('');
|
||||||
|
try {
|
||||||
|
const payload = await api.getPaymentConfig();
|
||||||
|
setConfig(payload);
|
||||||
|
} catch (error) {
|
||||||
|
setConfigError(getErrorMessage(error, '支付配置加载失败'));
|
||||||
|
} finally {
|
||||||
|
setLoadingConfig(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshBalance = useCallback(async () => {
|
||||||
|
setLoadingBalance(true);
|
||||||
|
setBalanceError('');
|
||||||
|
try {
|
||||||
|
const payload = await api.getComfyCloudBalance();
|
||||||
|
setBalanceResponse(payload);
|
||||||
|
setDiagnostics(null);
|
||||||
|
} catch (error) {
|
||||||
|
setBalanceError(getErrorMessage(error, 'Comfy Cloud 余额查询失败'));
|
||||||
|
try {
|
||||||
|
const result = await api.getComfyCloudDiagnostics();
|
||||||
|
setDiagnostics(result.diagnostics || null);
|
||||||
|
} catch {
|
||||||
|
setDiagnostics(null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoadingBalance(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadConfig();
|
||||||
|
}, [loadConfig]);
|
||||||
|
|
||||||
|
const payment = config?.payment;
|
||||||
|
const checkout = config?.comfyCloud?.checkout;
|
||||||
|
const customerTokenConfigured = checkout?.customerTokenConfigured;
|
||||||
|
const customerTokenEndpoint = typeof checkout?.customerTokenEndpoint === 'string' ? checkout.customerTokenEndpoint : '';
|
||||||
|
const checkoutEndpoint = typeof checkout?.checkoutEndpoint === 'string' ? checkout.checkoutEndpoint : '';
|
||||||
|
const firebaseLogin = checkout?.firebaseLogin;
|
||||||
|
const balance: ComfyCloudBalance | null = balanceResponse?.balance || null;
|
||||||
|
const balanceCurrency = String(balance?.currency || payment?.currency || '').trim();
|
||||||
|
const finishedRechargeCount = useMemo(
|
||||||
|
() => rechargeRecords.filter((record) => ['completed', 'cancelled', 'failed'].includes(record.status)).length,
|
||||||
|
[rechargeRecords],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCheckoutEndpointInput(checkoutEndpoint || '');
|
||||||
|
}, [checkoutEndpoint]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCustomerTokenEndpointInput(customerTokenEndpoint || '');
|
||||||
|
}, [customerTokenEndpoint]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (customerTokenConfigured === true) {
|
||||||
|
void refreshBalance();
|
||||||
|
}
|
||||||
|
}, [customerTokenConfigured, refreshBalance]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLiveCacheRemainingSecs(
|
||||||
|
typeof firebaseLogin?.cacheRemainingSecs === 'number'
|
||||||
|
? firebaseLogin.cacheRemainingSecs
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}, [firebaseLogin?.cacheRemainingSecs, firebaseLogin?.lastRefreshAtUnix]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!firebaseLogin?.configured || firebaseLogin.invalidGrant) return undefined;
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
setLiveCacheRemainingSecs((current) => {
|
||||||
|
if (typeof current !== 'number' || !Number.isFinite(current)) return current;
|
||||||
|
return Math.max(0, current - 1);
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [firebaseLogin?.configured, firebaseLogin?.invalidGrant]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!firebaseLogin?.configured || firebaseLogin.invalidGrant || firebaseLogin.lastRefreshOk === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof liveCacheRemainingSecs !== 'number' || liveCacheRemainingSecs > 60) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
if (tokenRefreshInFlightRef.current || now - lastTokenRefreshAttemptRef.current < 30_000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokenRefreshInFlightRef.current = true;
|
||||||
|
lastTokenRefreshAttemptRef.current = now;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await refreshBalance();
|
||||||
|
await loadConfig();
|
||||||
|
} finally {
|
||||||
|
tokenRefreshInFlightRef.current = false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [
|
||||||
|
firebaseLogin?.configured,
|
||||||
|
firebaseLogin?.invalidGrant,
|
||||||
|
firebaseLogin?.lastRefreshOk,
|
||||||
|
liveCacheRemainingSecs,
|
||||||
|
loadConfig,
|
||||||
|
refreshBalance,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!firebaseLogin?.configured || firebaseLogin.invalidGrant) return undefined;
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
void loadConfig();
|
||||||
|
}, 20_000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [firebaseLogin?.configured, firebaseLogin?.invalidGrant, loadConfig]);
|
||||||
|
|
||||||
|
const saveSettings = useCallback(async () => {
|
||||||
|
setSavingSettings(true);
|
||||||
|
setSettingsError('');
|
||||||
|
setSettingsMessage('');
|
||||||
|
try {
|
||||||
|
await api.updateComfyCloudPaymentSettings({
|
||||||
|
customerToken: customerTokenInput.trim() || undefined,
|
||||||
|
customerTokenEndpoint: customerTokenEndpointInput.trim(),
|
||||||
|
firebaseApiKey: firebaseApiKeyInput.trim() || undefined,
|
||||||
|
googleOAuthClientId: googleOAuthClientIdInput.trim() || undefined,
|
||||||
|
googleOAuthClientSecret: googleOAuthClientSecretInput.trim() || undefined,
|
||||||
|
firebaseRefreshToken: firebaseRefreshTokenInput.trim() || undefined,
|
||||||
|
checkoutEndpoint: checkoutEndpointInput.trim(),
|
||||||
|
});
|
||||||
|
setCustomerTokenInput('');
|
||||||
|
setFirebaseApiKeyInput('');
|
||||||
|
setGoogleOAuthClientSecretInput('');
|
||||||
|
setFirebaseRefreshTokenInput('');
|
||||||
|
setSettingsMessage('Comfy 支付配置已保存,余额诊断和用户充值会立即使用新配置。');
|
||||||
|
await loadConfig();
|
||||||
|
} catch (error) {
|
||||||
|
setSettingsError(getErrorMessage(error, 'Comfy 支付配置保存失败'));
|
||||||
|
} finally {
|
||||||
|
setSavingSettings(false);
|
||||||
|
}
|
||||||
|
}, [checkoutEndpointInput, customerTokenEndpointInput, customerTokenInput, firebaseApiKeyInput, firebaseRefreshTokenInput, googleOAuthClientIdInput, googleOAuthClientSecretInput, loadConfig]);
|
||||||
|
|
||||||
|
const clearCustomerToken = useCallback(async () => {
|
||||||
|
setSavingSettings(true);
|
||||||
|
setSettingsError('');
|
||||||
|
setSettingsMessage('');
|
||||||
|
try {
|
||||||
|
await api.updateComfyCloudPaymentSettings({
|
||||||
|
clearCustomerToken: true,
|
||||||
|
customerTokenEndpoint: customerTokenEndpointInput.trim(),
|
||||||
|
checkoutEndpoint: checkoutEndpointInput.trim(),
|
||||||
|
});
|
||||||
|
setCustomerTokenInput('');
|
||||||
|
setSettingsMessage('已清空数据库中的 Comfy Customer Token 覆盖;如 config/env 有默认值会继续生效。');
|
||||||
|
await loadConfig();
|
||||||
|
} catch (error) {
|
||||||
|
setSettingsError(getErrorMessage(error, 'Comfy Customer Token 清空失败'));
|
||||||
|
} finally {
|
||||||
|
setSavingSettings(false);
|
||||||
|
}
|
||||||
|
}, [checkoutEndpointInput, customerTokenEndpointInput, loadConfig]);
|
||||||
|
|
||||||
|
const startOAuth = useCallback(async () => {
|
||||||
|
setSavingSettings(true);
|
||||||
|
setSettingsError('');
|
||||||
|
setSettingsMessage('');
|
||||||
|
try {
|
||||||
|
// Save any pending Firebase API key first so the login page mints with it.
|
||||||
|
if (firebaseApiKeyInput.trim()) {
|
||||||
|
await api.updateComfyCloudPaymentSettings({
|
||||||
|
firebaseApiKey: firebaseApiKeyInput.trim() || undefined,
|
||||||
|
customerTokenEndpoint: customerTokenEndpointInput.trim(),
|
||||||
|
checkoutEndpoint: checkoutEndpointInput.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const loginPath = '/api/admin/payment/comfy-cloud/firebase-login';
|
||||||
|
if (window.location.hostname === 'localhost') {
|
||||||
|
// 已在本机 localhost:直接同源跳登录页(Firebase 信任 localhost)。
|
||||||
|
window.location.href = loginPath;
|
||||||
|
} else {
|
||||||
|
// 线上域名(tybossai.com 等):Firebase 不信任此域,跳本机 localhost
|
||||||
|
// 登录页并带回执地址,登录成功后自动跳回本页。需本机已建 SSH 隧道。
|
||||||
|
const port = window.location.port || '4000';
|
||||||
|
const returnTo = encodeURIComponent(window.location.href);
|
||||||
|
window.location.href = `http://localhost:${port}${loginPath}?return_to=${returnTo}`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setSettingsError(getErrorMessage(error, 'Comfy Cloud Google 登录失败'));
|
||||||
|
setSavingSettings(false);
|
||||||
|
}
|
||||||
|
}, [checkoutEndpointInput, customerTokenEndpointInput, firebaseApiKeyInput]);
|
||||||
|
|
||||||
|
const startRecharge = useCallback(async () => {
|
||||||
|
setRechargeError('');
|
||||||
|
setRechargeMessage('');
|
||||||
|
const amount = Number(rechargeAmount);
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0) {
|
||||||
|
setRechargeError('请输入有效的金额(USD)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRechargeLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await api.adminComfyCloudRecharge(amount);
|
||||||
|
const url = result?.checkout?.url;
|
||||||
|
if (!url) {
|
||||||
|
throw new Error('充值会话已创建,但未返回 Checkout URL');
|
||||||
|
}
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
|
setRechargeMessage(`已生成 $${amount} 的 Comfy Cloud Checkout 链接,请在新窗口完成付款后点「刷新 Comfy Cloud 余额」。`);
|
||||||
|
} catch (error) {
|
||||||
|
setRechargeError(getErrorMessage(error, 'Comfy 充值失败'));
|
||||||
|
} finally {
|
||||||
|
setRechargeLoading(false);
|
||||||
|
}
|
||||||
|
}, [rechargeAmount]);
|
||||||
|
|
||||||
|
const loadRechargeRecords = useCallback(async () => {
|
||||||
|
setRecordsLoading(true);
|
||||||
|
setRecordsError('');
|
||||||
|
try {
|
||||||
|
const result = await api.adminListRechargeRecords(100);
|
||||||
|
setRechargeRecords(Array.isArray(result?.records) ? result.records : []);
|
||||||
|
} catch (error) {
|
||||||
|
setRecordsError(getErrorMessage(error, '充值记录加载失败'));
|
||||||
|
} finally {
|
||||||
|
setRecordsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadRechargeRecords();
|
||||||
|
}, [loadRechargeRecords]);
|
||||||
|
|
||||||
|
const confirmRecord = useCallback(async (publicId: string) => {
|
||||||
|
setConfirmingId(publicId);
|
||||||
|
setRecordsError('');
|
||||||
|
try {
|
||||||
|
await api.adminConfirmRecharge(publicId);
|
||||||
|
await loadRechargeRecords();
|
||||||
|
} catch (error) {
|
||||||
|
setRecordsError(getErrorMessage(error, '确认到账失败'));
|
||||||
|
} finally {
|
||||||
|
setConfirmingId('');
|
||||||
|
}
|
||||||
|
}, [loadRechargeRecords]);
|
||||||
|
|
||||||
|
const cancelRecord = useCallback(async (publicId: string) => {
|
||||||
|
if (cancellingId) return;
|
||||||
|
const ok = window.confirm('确认取消这笔待支付记录吗?取消后不会给用户增加额度。');
|
||||||
|
if (!ok) return;
|
||||||
|
setCancellingId(publicId);
|
||||||
|
setRecordsError('');
|
||||||
|
try {
|
||||||
|
await api.adminCancelRecharge(publicId);
|
||||||
|
await loadRechargeRecords();
|
||||||
|
setRechargeMessage('已取消待支付记录。');
|
||||||
|
} catch (error) {
|
||||||
|
setRecordsError(getErrorMessage(error, '取消支付失败'));
|
||||||
|
} finally {
|
||||||
|
setCancellingId('');
|
||||||
|
}
|
||||||
|
}, [cancellingId, loadRechargeRecords]);
|
||||||
|
|
||||||
|
const exportRechargeRecords = useCallback(() => {
|
||||||
|
const csv = buildRechargeRecordsCsv(rechargeRecords);
|
||||||
|
const blob = new Blob([`\uFEFF${csv}`], { type: 'text/csv;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
const stamp = new Date().toISOString().slice(0, 10);
|
||||||
|
link.href = url;
|
||||||
|
link.download = `boosapi-recharge-records-${stamp}.csv`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, [rechargeRecords]);
|
||||||
|
|
||||||
|
const clearFinishedRechargeRecords = useCallback(async () => {
|
||||||
|
if (finishedRechargeCount <= 0 || clearingRecords) return;
|
||||||
|
const ok = window.confirm(`将清理 ${finishedRechargeCount} 条已结束充值记录,待确认记录会保留。继续吗?`);
|
||||||
|
if (!ok) return;
|
||||||
|
setClearingRecords(true);
|
||||||
|
setRecordsError('');
|
||||||
|
try {
|
||||||
|
const result = await api.adminClearRechargeRecords();
|
||||||
|
await loadRechargeRecords();
|
||||||
|
setRechargeMessage(`已清理 ${result.cleared || 0} 条已结束充值记录,待确认记录已保留。`);
|
||||||
|
} catch (error) {
|
||||||
|
setRecordsError(getErrorMessage(error, '清理充值记录失败'));
|
||||||
|
} finally {
|
||||||
|
setClearingRecords(false);
|
||||||
|
}
|
||||||
|
}, [clearingRecords, finishedRechargeCount, loadRechargeRecords]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-fade-in admin-ops-page payment-ops-page" style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||||
|
<PageHeader
|
||||||
|
title="支付管理"
|
||||||
|
subtitle="Comfy Cloud 登录、充值与到账确认。"
|
||||||
|
actions={(
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => void loadConfig()} disabled={loadingConfig}>
|
||||||
|
{loadingConfig ? '加载中...' : '刷新配置'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{configError && <div className="alert alert-error">{configError}</div>}
|
||||||
|
|
||||||
|
<section style={{
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
background: 'var(--color-panel)',
|
||||||
|
padding: 18,
|
||||||
|
}} aria-label="Comfy Cloud Token 持久化">
|
||||||
|
{customerTokenConfigured === false && (
|
||||||
|
<div className="alert alert-warning" style={{ marginTop: 16 }}>
|
||||||
|
Comfy Cloud 登录凭证未配置。点下方「Google 登录 Comfy Cloud」使用你的 Google 账号直接授权(无需注册 OAuth 应用),系统会自动保存 Firebase Refresh Token;也可以直接粘贴从 cloud.comfy.org 抓取的 Firebase Refresh Token 或 Bearer JWT。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{firebaseLogin?.configured && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
border: `1px solid ${firebaseLogin.invalidGrant ? 'var(--color-danger, #dc2626)' : 'var(--color-border)'}`,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
padding: 14,
|
||||||
|
background: firebaseLogin.invalidGrant
|
||||||
|
? 'color-mix(in srgb, var(--color-danger, #dc2626) 8%, var(--color-surface))'
|
||||||
|
: 'var(--color-surface)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8, flexWrap: 'wrap' }}>
|
||||||
|
<strong style={{ fontSize: 14, color: 'var(--color-text-primary)' }}>Comfy 登录态</strong>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
firebaseLogin.invalidGrant
|
||||||
|
? 'badge badge-warning'
|
||||||
|
: firebaseLogin.lastRefreshOk === false
|
||||||
|
? 'badge badge-warning'
|
||||||
|
: 'badge badge-success'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{firebaseLogin.invalidGrant
|
||||||
|
? '已失效,需重新登录'
|
||||||
|
: firebaseLogin.lastRefreshOk === false
|
||||||
|
? '上次刷新失败'
|
||||||
|
: firebaseLogin.lastRefreshOk === true
|
||||||
|
? '正常'
|
||||||
|
: '已配置'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 10 }}>
|
||||||
|
<Field label="Refresh Token" value={firebaseLogin.configured ? '已保存' : '未配置'} />
|
||||||
|
<Field
|
||||||
|
label="ID Token 缓存剩余"
|
||||||
|
value={formatDurationSeconds(liveCacheRemainingSecs)}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="上次刷新"
|
||||||
|
value={
|
||||||
|
firebaseLogin.lastRefreshAtUnix
|
||||||
|
? new Date(firebaseLogin.lastRefreshAtUnix * 1000).toLocaleString('zh-CN')
|
||||||
|
: '尚未刷新'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{firebaseLogin.invalidGrant && (
|
||||||
|
<div className="alert alert-error" style={{ marginTop: 12, marginBottom: 0 }}>
|
||||||
|
登录态已失效(可能在 cloud.comfy.org 主动登出或改密)。请点下方「Google 登录 Comfy Cloud」通过 localhost 重新登录。
|
||||||
|
{firebaseLogin.lastRefreshDetail ? ` 详情:${firebaseLogin.lastRefreshDetail}` : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!firebaseLogin.invalidGrant && firebaseLogin.lastRefreshOk === false && firebaseLogin.lastRefreshDetail && (
|
||||||
|
<div className="alert alert-warning" style={{ marginTop: 12, marginBottom: 0 }}>
|
||||||
|
{firebaseLogin.lastRefreshDetail}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
marginTop: 16,
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
padding: 14,
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--color-text-primary)', marginBottom: 6 }}>
|
||||||
|
Comfy Customer Token 持久化
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
||||||
|
点「Google 登录 Comfy Cloud」直接用你的 Google 账号授权,无需注册任何 OAuth 应用。登录成功后系统自动保存 Firebase Refresh Token,之后充值和查余额全程自动换取 token。
|
||||||
|
<br/>
|
||||||
|
<strong style={{ color: 'var(--color-text-primary)' }}>注意:</strong> Firebase 只允许 localhost 和 Comfy 自己的域名走弹窗登录。如果你在 tybossai.com 等线上域名访问遇到「unauthorized-domain」,请改用以下任一方式:
|
||||||
|
<br/>① 通过 <code>http://localhost:4000/payment</code> 访问 BoosAPI,登录一次后 refresh token 会持久保存到数据库,线上域名也会自动使用;
|
||||||
|
<br/>② 直接从 cloud.comfy.org 浏览器 IndexedDB(<code>firebaseLocalStorageDb</code> → 找 <code>stsTokenManager.refreshToken</code>)复制后粘到下方「Firebase Refresh Token」字段。
|
||||||
|
</div>
|
||||||
|
{settingsError && <div className="alert alert-error" style={{ marginBottom: 12 }}>{settingsError}</div>}
|
||||||
|
{settingsMessage && <div className="alert alert-success" style={{ marginBottom: 12 }}>{settingsMessage}</div>}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
|
||||||
|
gap: 12,
|
||||||
|
}}>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 }}>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>Comfy Session / Bearer JWT</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={customerTokenInput}
|
||||||
|
onChange={(event) => setCustomerTokenInput(event.target.value)}
|
||||||
|
placeholder="session=eyJ... 或 Bearer eyJ..."
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 }}>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>Firebase Web API Key(可选)</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={firebaseApiKeyInput}
|
||||||
|
onChange={(event) => setFirebaseApiKeyInput(event.target.value)}
|
||||||
|
placeholder="留空使用 Comfy Cloud 默认 Firebase Key"
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 }}>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>Google OAuth Client ID</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={googleOAuthClientIdInput}
|
||||||
|
onChange={(event) => setGoogleOAuthClientIdInput(event.target.value)}
|
||||||
|
placeholder="xxx.apps.googleusercontent.com"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 }}>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>Google OAuth Client Secret</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={googleOAuthClientSecretInput}
|
||||||
|
onChange={(event) => setGoogleOAuthClientSecretInput(event.target.value)}
|
||||||
|
placeholder="GOCSPX-..."
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 }}>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>Firebase Refresh Token</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={firebaseRefreshTokenInput}
|
||||||
|
onChange={(event) => setFirebaseRefreshTokenInput(event.target.value)}
|
||||||
|
placeholder="可选:粘贴后即可自动充值/查余额,无需 Google 授权"
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 }}>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>Token Provider Endpoint</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={customerTokenEndpointInput}
|
||||||
|
onChange={(event) => setCustomerTokenEndpointInput(event.target.value)}
|
||||||
|
placeholder="https://your-service.example/comfy-token"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 }}>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>Checkout Endpoint</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={checkoutEndpointInput}
|
||||||
|
onChange={(event) => setCheckoutEndpointInput(event.target.value)}
|
||||||
|
placeholder="/customers/credit"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10, marginTop: 12, flexWrap: 'wrap' }}>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => void saveSettings()} disabled={savingSettings}>
|
||||||
|
{savingSettings ? '保存中...' : '保存 Comfy 配置'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => void startOAuth()} disabled={savingSettings}>
|
||||||
|
Google 登录 Comfy Cloud
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => void clearCustomerToken()} disabled={savingSettings}>
|
||||||
|
清空运行时 Token
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
background: 'var(--color-panel)',
|
||||||
|
padding: 18,
|
||||||
|
}} aria-label="Comfy Cloud 余额">
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>Comfy Cloud</div>
|
||||||
|
<strong style={{ fontSize: 18, color: 'var(--color-text-primary)' }}>余额诊断</strong>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => void refreshBalance()} disabled={loadingBalance}>
|
||||||
|
{loadingBalance ? '查询中...' : '刷新 Comfy Cloud 余额'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{balanceError && <div className="alert alert-error" style={{ marginBottom: 16 }}>{balanceError}</div>}
|
||||||
|
{diagnostics && (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(190px, 1fr))',
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 16,
|
||||||
|
}}>
|
||||||
|
<Field label="diagnostics.tokenKind" value={String(diagnostics.tokenKind || '--')} />
|
||||||
|
<Field label="diagnostics.tokenSource" value={String(diagnostics.tokenSource || '--')} />
|
||||||
|
<Field label="diagnostics.httpStatus" value={formatNumber(diagnostics.httpStatus)} />
|
||||||
|
<Field label="diagnostics.apiBaseUrl" value={String(diagnostics.apiBaseUrl || '--')} />
|
||||||
|
<Field label="diagnostics.jwtIssuer" value={String(diagnostics.jwtIssuer || '--')} />
|
||||||
|
<Field label="diagnostics.jwtAudience" value={String(diagnostics.jwtAudience || '--')} />
|
||||||
|
<Field label="diagnostics.upstreamMessage" value={String(diagnostics.upstreamMessage || '--')} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(190px, 1fr))',
|
||||||
|
gap: 12,
|
||||||
|
}}>
|
||||||
|
<BalanceField label="amountMicros" value={balance?.amountMicros} currency={balanceCurrency} />
|
||||||
|
<BalanceField label="effectiveBalanceMicros" value={balance?.effectiveBalanceMicros} currency={balanceCurrency} />
|
||||||
|
<BalanceField label="prepaidBalanceMicros" value={balance?.prepaidBalanceMicros} currency={balanceCurrency} />
|
||||||
|
<BalanceField label="cloudCreditBalanceMicros" value={balance?.cloudCreditBalanceMicros} currency={balanceCurrency} />
|
||||||
|
<BalanceField label="pendingChargesMicros" value={balance?.pendingChargesMicros} currency={balanceCurrency} />
|
||||||
|
<Field label="currency" value={balanceCurrency || '--'} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
background: 'var(--color-panel)',
|
||||||
|
padding: 18,
|
||||||
|
}} aria-label="Comfy Cloud 充值">
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>Comfy Cloud</div>
|
||||||
|
<strong style={{ fontSize: 18, color: 'var(--color-text-primary)' }}>充值积分</strong>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 6 }}>
|
||||||
|
直接调用 Comfy Cloud 的 customer credit API 创建一笔充值会话,新窗口打开后用绑定的支付方式付款即可到账。需要 Comfy Cloud 登录凭证已配置(上方 Refresh Token / Customer Token / Token Endpoint 任一)。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rechargeError && <div className="alert alert-error" style={{ marginBottom: 12 }}>{rechargeError}</div>}
|
||||||
|
{rechargeMessage && <div className="alert alert-success" style={{ marginBottom: 12 }}>{rechargeMessage}</div>}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 180 }}>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>金额(USD)</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
value={rechargeAmount}
|
||||||
|
onChange={(event) => setRechargeAmount(event.target.value)}
|
||||||
|
placeholder="例如 10"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
{[5, 10, 20, 50, 100].map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset}
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => setRechargeAmount(String(preset))}
|
||||||
|
disabled={rechargeLoading}
|
||||||
|
>
|
||||||
|
${preset}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => void startRecharge()}
|
||||||
|
disabled={rechargeLoading || !customerTokenConfigured}
|
||||||
|
>
|
||||||
|
{rechargeLoading ? '生成 Checkout 链接...' : '立即充值'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{customerTokenConfigured === false && (
|
||||||
|
<div className="alert alert-warning" style={{ marginTop: 12 }}>
|
||||||
|
还没配置 Comfy Cloud 登录凭证,无法创建充值会话。请先回到上方完成 Google 登录或粘贴 Refresh Token / Customer Token。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
background: 'var(--color-panel)',
|
||||||
|
padding: 18,
|
||||||
|
}} aria-label="用户充值记录">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>用户充值</div>
|
||||||
|
<strong style={{ fontSize: 18, color: 'var(--color-text-primary)' }}>充值记录与确认到账</strong>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 6 }}>
|
||||||
|
Comfy Cloud 支付无自动回调。用户付款后,在此点「确认到账」给对应用户的 API Key 增加额度(独立计费池)。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => void loadRechargeRecords()}
|
||||||
|
disabled={recordsLoading || clearingRecords}
|
||||||
|
>
|
||||||
|
{recordsLoading ? '加载中...' : '刷新记录'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={exportRechargeRecords}
|
||||||
|
disabled={rechargeRecords.length === 0}
|
||||||
|
>
|
||||||
|
导出
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => void clearFinishedRechargeRecords()}
|
||||||
|
disabled={finishedRechargeCount === 0 || clearingRecords}
|
||||||
|
>
|
||||||
|
{clearingRecords ? '清理中...' : `清理已结束 ${finishedRechargeCount}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{recordsError && <div className="alert alert-error" style={{ marginBottom: 12 }}>{recordsError}</div>}
|
||||||
|
|
||||||
|
{rechargeRecords.length === 0 ? (
|
||||||
|
<div className="empty-state" style={{ minHeight: 120 }}>
|
||||||
|
<div className="empty-state-title">暂无充值记录</div>
|
||||||
|
<div className="empty-state-desc">用户在充值页发起充值后会出现在这里。</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{rechargeRecords.map((rec) => {
|
||||||
|
const pending = rec.status === 'pending';
|
||||||
|
const tone = rec.status === 'completed' ? 'badge badge-success'
|
||||||
|
: rec.status === 'cancelled' ? 'badge badge-warning' : 'badge';
|
||||||
|
return (
|
||||||
|
<div key={rec.publicId} style={{
|
||||||
|
border: '1px solid var(--color-border-light)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
padding: 12,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>
|
||||||
|
{rec.userName || rec.userEmail || `用户 #${rec.userId}`}
|
||||||
|
<span style={{ marginLeft: 8, fontSize: 13, color: 'var(--color-primary)' }}>
|
||||||
|
${typeof rec.amountUsd === 'number' ? rec.amountUsd : '--'}
|
||||||
|
</span>
|
||||||
|
<span className={tone} style={{ marginLeft: 8 }}>{rec.status}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||||
|
{rec.provider} · 创建 {formatRechargeRecordTime(rec.createdAt)}
|
||||||
|
{rec.completedAt ? ` · 完成 ${formatRechargeRecordTime(rec.completedAt)}` : ''}
|
||||||
|
{' · '}
|
||||||
|
<code style={{ fontSize: 11 }}>{rec.publicId}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{pending ? (
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => void confirmRecord(rec.publicId)}
|
||||||
|
disabled={confirmingId === rec.publicId || cancellingId === rec.publicId}
|
||||||
|
>
|
||||||
|
{confirmingId === rec.publicId ? '确认中...' : '确认到账'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => void cancelRecord(rec.publicId)}
|
||||||
|
disabled={confirmingId === rec.publicId || cancellingId === rec.publicId}
|
||||||
|
>
|
||||||
|
{cancellingId === rec.publicId ? '取消中...' : '取消支付'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||||
|
{rec.status === 'completed' ? '已到账' : '—'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -985,20 +985,56 @@ export default function PictureAgent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="picture-agent-page">
|
<div className="picture-agent-page">
|
||||||
<div className="page-header">
|
<section className="console-hero">
|
||||||
<div>
|
<div className="console-hero-copy">
|
||||||
<h1 className="page-title">生图助手</h1>
|
<span className="console-kicker">Picture Studio</span>
|
||||||
<p className="page-subtitle">
|
<h1>生图助手</h1>
|
||||||
{modeLabel} · {status?.upstreamLabel || 'BoosAPI 本地图片路由'} · {status?.keyName || '加载密钥中'}
|
<p>{modeLabel} · {status?.upstreamLabel || 'BoosAPI 本地图片路由'} · {status?.keyName || '加载密钥中'}</p>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="page-actions">
|
<div className="console-hero-rail" aria-label="生图助手摘要">
|
||||||
|
<div>
|
||||||
|
<span>队列</span>
|
||||||
|
<strong>{activeJobs.length}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>已完成</span>
|
||||||
|
<strong>{jobs.filter((job) => job.status === 'succeeded').length}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>图库</span>
|
||||||
|
<strong>{images.length}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="console-hero-actions">
|
||||||
<span className="picture-agent-status-pill">{audience === 'user' ? '普通用户' : '管理员'}</span>
|
<span className="picture-agent-status-pill">{audience === 'user' ? '普通用户' : '管理员'}</span>
|
||||||
<select className="picture-agent-select" value={model} onChange={(event) => setModel(event.target.value)}>
|
<select className="picture-agent-select" value={model} onChange={(event) => setModel(event.target.value)}>
|
||||||
{models.length === 0 && <option value={model || 'gpt-image-2'}>{model || 'gpt-image-2'}</option>}
|
{models.length === 0 && <option value={model || 'gpt-image-2'}>{model || 'gpt-image-2'}</option>}
|
||||||
{models.map((item) => <option key={item} value={item}>{item}</option>)}
|
{models.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="picture-agent-summary">
|
||||||
|
<div className="picture-agent-summary-card">
|
||||||
|
<span>当前模型</span>
|
||||||
|
<strong>{model || '默认'}</strong>
|
||||||
|
<em>{status?.upstreamLabel || 'BoosAPI 图片路由'}</em>
|
||||||
|
</div>
|
||||||
|
<div className="picture-agent-summary-card">
|
||||||
|
<span>进行中</span>
|
||||||
|
<strong>{activeJobs.length} 个</strong>
|
||||||
|
<em>{activeJobs.length > 0 ? '生成中…' : '队列空闲'}</em>
|
||||||
|
</div>
|
||||||
|
<div className="picture-agent-summary-card">
|
||||||
|
<span>已完成</span>
|
||||||
|
<strong>{jobs.filter((job) => job.status === 'succeeded').length} 个</strong>
|
||||||
|
<em>{jobs.filter((job) => job.status === 'failed' || job.status === 'cancelled').length} 个失败/取消</em>
|
||||||
|
</div>
|
||||||
|
<div className="picture-agent-summary-card">
|
||||||
|
<span>图库</span>
|
||||||
|
<strong>{images.length} 张</strong>
|
||||||
|
<em>最多保留 48 张</em>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="picture-agent-shell">
|
<div className="picture-agent-shell">
|
||||||
|
|||||||
+52
-12
@@ -81,6 +81,7 @@ type RuntimeSettings = {
|
|||||||
logCleanupProgramLogsEnabled: boolean;
|
logCleanupProgramLogsEnabled: boolean;
|
||||||
logCleanupRetentionDays: number;
|
logCleanupRetentionDays: number;
|
||||||
modelAvailabilityProbeEnabled: boolean;
|
modelAvailabilityProbeEnabled: boolean;
|
||||||
|
registrationDisabled: boolean;
|
||||||
codexUpstreamWebsocketEnabled: boolean;
|
codexUpstreamWebsocketEnabled: boolean;
|
||||||
responsesCompactFallbackToResponsesEnabled: boolean;
|
responsesCompactFallbackToResponsesEnabled: boolean;
|
||||||
disableCrossProtocolFallback: boolean;
|
disableCrossProtocolFallback: boolean;
|
||||||
@@ -363,6 +364,7 @@ export default function Settings() {
|
|||||||
logCleanupProgramLogsEnabled: false,
|
logCleanupProgramLogsEnabled: false,
|
||||||
logCleanupRetentionDays: 30,
|
logCleanupRetentionDays: 30,
|
||||||
modelAvailabilityProbeEnabled: false,
|
modelAvailabilityProbeEnabled: false,
|
||||||
|
registrationDisabled: false,
|
||||||
codexUpstreamWebsocketEnabled: false,
|
codexUpstreamWebsocketEnabled: false,
|
||||||
responsesCompactFallbackToResponsesEnabled: false,
|
responsesCompactFallbackToResponsesEnabled: false,
|
||||||
disableCrossProtocolFallback: false,
|
disableCrossProtocolFallback: false,
|
||||||
@@ -386,6 +388,7 @@ export default function Settings() {
|
|||||||
const [savingToken, setSavingToken] = useState(false);
|
const [savingToken, setSavingToken] = useState(false);
|
||||||
const [savingSystemProxy, setSavingSystemProxy] = useState(false);
|
const [savingSystemProxy, setSavingSystemProxy] = useState(false);
|
||||||
const [savingModelAvailabilityProbe, setSavingModelAvailabilityProbe] = useState(false);
|
const [savingModelAvailabilityProbe, setSavingModelAvailabilityProbe] = useState(false);
|
||||||
|
const [savingRegistrationDisabled, setSavingRegistrationDisabled] = useState(false);
|
||||||
const [savingProxyTransport, setSavingProxyTransport] = useState(false);
|
const [savingProxyTransport, setSavingProxyTransport] = useState(false);
|
||||||
const [testingSystemProxy, setTestingSystemProxy] = useState(false);
|
const [testingSystemProxy, setTestingSystemProxy] = useState(false);
|
||||||
const [systemProxyTestState, setSystemProxyTestState] = useState<SystemProxyTestState>(null);
|
const [systemProxyTestState, setSystemProxyTestState] = useState<SystemProxyTestState>(null);
|
||||||
@@ -748,7 +751,7 @@ export default function Settings() {
|
|||||||
? `${runtime.adminIpAllowlist.length} 条白名单`
|
? `${runtime.adminIpAllowlist.length} 条白名单`
|
||||||
: '未限制 IP';
|
: '未限制 IP';
|
||||||
const modelGovernanceSummary = [
|
const modelGovernanceSummary = [
|
||||||
blockedBrands.length > 0 ? `${blockedBrands.length} 个品牌屏蔽` : '',
|
blockedBrands.length > 0 ? `${blockedBrands.length} 个推荐屏蔽词` : '',
|
||||||
allowedModels.length > 0 ? `${allowedModels.length} 个模型白名单` : '',
|
allowedModels.length > 0 ? `${allowedModels.length} 个模型白名单` : '',
|
||||||
].filter(Boolean).join(' / ') || '全部模型开放';
|
].filter(Boolean).join(' / ') || '全部模型开放';
|
||||||
const settingsSections: SettingsNavItem[] = [
|
const settingsSections: SettingsNavItem[] = [
|
||||||
@@ -861,6 +864,7 @@ export default function Settings() {
|
|||||||
? Math.trunc(Number(runtimeInfo.logCleanupRetentionDays))
|
? Math.trunc(Number(runtimeInfo.logCleanupRetentionDays))
|
||||||
: 30,
|
: 30,
|
||||||
modelAvailabilityProbeEnabled: !!runtimeInfo.modelAvailabilityProbeEnabled,
|
modelAvailabilityProbeEnabled: !!runtimeInfo.modelAvailabilityProbeEnabled,
|
||||||
|
registrationDisabled: !!runtimeInfo.registrationDisabled,
|
||||||
codexUpstreamWebsocketEnabled: !!runtimeInfo.codexUpstreamWebsocketEnabled,
|
codexUpstreamWebsocketEnabled: !!runtimeInfo.codexUpstreamWebsocketEnabled,
|
||||||
responsesCompactFallbackToResponsesEnabled: !!runtimeInfo.responsesCompactFallbackToResponsesEnabled,
|
responsesCompactFallbackToResponsesEnabled: !!runtimeInfo.responsesCompactFallbackToResponsesEnabled,
|
||||||
disableCrossProtocolFallback: !!runtimeInfo.disableCrossProtocolFallback,
|
disableCrossProtocolFallback: !!runtimeInfo.disableCrossProtocolFallback,
|
||||||
@@ -1061,6 +1065,24 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const persistRegistrationDisabled = async (disabled: boolean) => {
|
||||||
|
setSavingRegistrationDisabled(true);
|
||||||
|
// Optimistic toggle so the checkbox responds immediately.
|
||||||
|
setRuntime((prev) => ({ ...prev, registrationDisabled: disabled }));
|
||||||
|
try {
|
||||||
|
const res = await api.updateRuntimeSettings({ registrationDisabled: disabled });
|
||||||
|
const next = typeof res?.registrationDisabled === 'boolean' ? res.registrationDisabled : disabled;
|
||||||
|
setRuntime((prev) => ({ ...prev, registrationDisabled: next }));
|
||||||
|
toast.success(next ? '已禁止用户注册' : '已允许用户注册');
|
||||||
|
} catch (err: any) {
|
||||||
|
// Revert on failure.
|
||||||
|
setRuntime((prev) => ({ ...prev, registrationDisabled: !disabled }));
|
||||||
|
toast.error(err?.message || '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSavingRegistrationDisabled(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const saveModelAvailabilityProbeSettings = async () => {
|
const saveModelAvailabilityProbeSettings = async () => {
|
||||||
if (runtime.modelAvailabilityProbeEnabled === savedModelAvailabilityProbeEnabled) {
|
if (runtime.modelAvailabilityProbeEnabled === savedModelAvailabilityProbeEnabled) {
|
||||||
toast.info('批量测活设置未变化');
|
toast.info('批量测活设置未变化');
|
||||||
@@ -1277,15 +1299,15 @@ export default function Settings() {
|
|||||||
const resolved = Array.isArray(res?.globalBlockedBrands) ? res.globalBlockedBrands : blockedBrands;
|
const resolved = Array.isArray(res?.globalBlockedBrands) ? res.globalBlockedBrands : blockedBrands;
|
||||||
setRuntime((prev) => ({ ...prev, globalBlockedBrands: resolved }));
|
setRuntime((prev) => ({ ...prev, globalBlockedBrands: resolved }));
|
||||||
setBlockedBrands(resolved);
|
setBlockedBrands(resolved);
|
||||||
toast.success('品牌屏蔽设置已保存');
|
toast.success('模型品牌推荐屏蔽词已保存');
|
||||||
try {
|
try {
|
||||||
await api.rebuildRoutes(false);
|
await api.rebuildRoutes(false);
|
||||||
toast.success('路由已重建');
|
toast.success('路由已重建');
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('品牌屏蔽已保存,但路由重建失败,请手动重建');
|
toast.error('模型品牌推荐屏蔽词已保存,但路由重建失败,请手动重建');
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.error(err?.message || '保存品牌屏蔽设置失败');
|
toast.error(err?.message || '保存模型品牌推荐屏蔽词失败');
|
||||||
} finally {
|
} finally {
|
||||||
setSavingBrandFilter(false);
|
setSavingBrandFilter(false);
|
||||||
}
|
}
|
||||||
@@ -2572,17 +2594,17 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Global Brand Filter */}
|
{/* Model Brand Recommendation Filter */}
|
||||||
<div id="settings-governance" className="card animate-slide-up stagger-6" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }}>
|
<div id="settings-governance" className="card animate-slide-up stagger-6" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }}>
|
||||||
<div style={settingsModernHeaderStyle}>
|
<div style={settingsModernHeaderStyle}>
|
||||||
<div style={settingsModernTitleBlockStyle}>
|
<div style={settingsModernTitleBlockStyle}>
|
||||||
<div style={settingsModernTitleStyle}>全局品牌屏蔽</div>
|
<div style={settingsModernTitleStyle}>模型品牌推荐屏蔽词</div>
|
||||||
<div style={settingsModernDescriptionStyle}>
|
<div style={settingsModernDescriptionStyle}>
|
||||||
屏蔽选定品牌后,路由重建时将自动跳过匹配该品牌的所有模型。点击品牌切换屏蔽状态,保存后自动触发路由重建。
|
用于 LLM 对话推荐和模型路由推荐:选中某个模型品牌后,路由重建会跳过匹配该品牌的模型。这里不是屏蔽 BoosAPI 平台品牌。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span style={getSettingsPillStyle(blockedBrands.length > 0 ? 'warning' : 'neutral')}>
|
<span style={getSettingsPillStyle(blockedBrands.length > 0 ? 'warning' : 'neutral')}>
|
||||||
{blockedBrands.length > 0 ? `已屏蔽 ${blockedBrands.length}` : '未屏蔽'}
|
{blockedBrands.length > 0 ? `已设置 ${blockedBrands.length}` : '未设置'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
|
||||||
@@ -2612,19 +2634,19 @@ export default function Settings() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{allBrandNames === null && (
|
{allBrandNames === null && (
|
||||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>加载品牌列表中...</span>
|
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>加载模型品牌中...</span>
|
||||||
)}
|
)}
|
||||||
{allBrandNames !== null && allBrandNames.length === 0 && (
|
{allBrandNames !== null && allBrandNames.length === 0 && (
|
||||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无可用品牌</span>
|
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无可用模型品牌</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{blockedBrands.length > 0 && (
|
{blockedBrands.length > 0 && (
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-warning)', marginBottom: 10 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-warning)', marginBottom: 10 }}>
|
||||||
已屏蔽 {blockedBrands.length} 个品牌:{blockedBrands.join('、')}
|
已设置 {blockedBrands.length} 个模型品牌推荐屏蔽词:{blockedBrands.join('、')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button onClick={handleSaveBrandFilter} disabled={savingBrandFilter} className="btn btn-primary" style={{ fontSize: 12, padding: '6px 16px' }}>
|
<button onClick={handleSaveBrandFilter} disabled={savingBrandFilter} className="btn btn-primary" style={{ fontSize: 12, padding: '6px 16px' }}>
|
||||||
{savingBrandFilter ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存品牌屏蔽'}
|
{savingBrandFilter ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存模型品牌推荐屏蔽词'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2970,6 +2992,24 @@ export default function Settings() {
|
|||||||
<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 网段。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 10, padding: '12px 14px', background: 'var(--color-bg)', borderRadius: 'var(--radius-sm)', border: '1px solid var(--color-border-light)', marginBottom: 14, cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={runtime.registrationDisabled}
|
||||||
|
disabled={savingRegistrationDisabled}
|
||||||
|
onChange={(e) => void persistRegistrationDisabled(e.target.checked)}
|
||||||
|
style={{ marginTop: 2 }}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--color-text-primary)' }}>禁止用户自助注册</span>
|
||||||
|
<span style={{ display: 'block', fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4, lineHeight: 1.6 }}>
|
||||||
|
开启后 <code style={{ fontFamily: 'var(--font-mono)' }}>/api/users/register</code> 会直接拒绝(403),仅管理员可在后台手动创建用户。已注册用户不受影响。
|
||||||
|
{savingRegistrationDisabled ? ' 保存中...' : ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
|
||||||
当前识别到的管理端 IP(由服务端判定):
|
当前识别到的管理端 IP(由服务端判定):
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { api } from '../api.js';
|
||||||
|
import PageHeader from '../components/PageHeader.js';
|
||||||
|
|
||||||
|
type BalanceRow = {
|
||||||
|
userId: number;
|
||||||
|
username?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
role?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
keyCount: number;
|
||||||
|
usedCost: number;
|
||||||
|
maxCost: number | null;
|
||||||
|
remainingCost: number | null;
|
||||||
|
unlimitedKeyCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
if (error instanceof Error && error.message) return error.message;
|
||||||
|
if (typeof error === 'string' && error.trim()) return error.trim();
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtCost(value: number | null | undefined): string {
|
||||||
|
if (value == null || !Number.isFinite(value)) return '不限';
|
||||||
|
return `$${value.toFixed(6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserBalances() {
|
||||||
|
const [rows, setRows] = useState<BalanceRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [adjustingId, setAdjustingId] = useState<number | null>(null);
|
||||||
|
const [adjustInputs, setAdjustInputs] = useState<Record<number, string>>({});
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
|
const load = useCallback(async (targetPage: number, query: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const res = await api.listUserBalances({ page: targetPage, pageSize: 50, q: query || undefined });
|
||||||
|
setRows(Array.isArray(res?.data) ? res.data : []);
|
||||||
|
setTotalPages(res?.meta?.totalPages || 1);
|
||||||
|
setPage(res?.meta?.page || targetPage);
|
||||||
|
} catch (err) {
|
||||||
|
setError(getErrorMessage(err, '加载用户余额失败'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load(1, '');
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const applyAdjust = useCallback(async (userId: number, sign: 1 | -1) => {
|
||||||
|
const raw = (adjustInputs[userId] || '').trim();
|
||||||
|
const amount = Number(raw);
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0) {
|
||||||
|
setError('请输入正数金额');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAdjustingId(userId);
|
||||||
|
setError('');
|
||||||
|
setMessage('');
|
||||||
|
try {
|
||||||
|
const res = await api.adjustUserBalance(userId, sign * amount);
|
||||||
|
setMessage(`用户 #${userId} 额度已${sign > 0 ? '增加' : '扣减'} $${amount},当前剩余 ${fmtCost(res.data.remainingCost)}`);
|
||||||
|
setAdjustInputs((prev) => ({ ...prev, [userId]: '' }));
|
||||||
|
await load(page, q);
|
||||||
|
} catch (err) {
|
||||||
|
setError(getErrorMessage(err, '调整额度失败'));
|
||||||
|
} finally {
|
||||||
|
setAdjustingId(null);
|
||||||
|
}
|
||||||
|
}, [adjustInputs, load, page, q]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-fade-in admin-ops-page user-balances-ops-page" style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||||
|
<PageHeader
|
||||||
|
title="用户余额管理"
|
||||||
|
subtitle="查看每个用户的额度池(API Key 的 max_cost − used_cost),可直接增减额度。"
|
||||||
|
actions={(
|
||||||
|
<div className="page-actions user-balances-page-actions" style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') void load(1, q); }}
|
||||||
|
placeholder="搜索用户名 / 邮箱 / ID"
|
||||||
|
style={{ minWidth: 220 }}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => void load(1, q)} disabled={loading}>
|
||||||
|
{loading ? '加载中...' : '搜索'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
{message && <div className="alert alert-success">{message}</div>}
|
||||||
|
|
||||||
|
<section style={{
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
background: 'var(--color-panel)',
|
||||||
|
padding: 18,
|
||||||
|
}}>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="empty-state" style={{ minHeight: 160 }}>
|
||||||
|
<div className="empty-state-title">{loading ? '加载中...' : '没有用户'}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<div key={row.userId} style={{
|
||||||
|
border: '1px solid var(--color-border-light)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
padding: 12,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}>
|
||||||
|
<div style={{ minWidth: 200 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>
|
||||||
|
{row.username || row.email || `用户 #${row.userId}`}
|
||||||
|
<span style={{ marginLeft: 8, fontSize: 12, color: 'var(--color-text-muted)' }}>#{row.userId}</span>
|
||||||
|
{row.role === 'admin' && <span className="badge" style={{ marginLeft: 8 }}>管理员</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||||
|
{row.email || ''} · {row.keyCount} 个 Key
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>剩余 / 上限</div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--color-primary)' }}>
|
||||||
|
{fmtCost(row.remainingCost)} / {fmtCost(row.maxCost)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>已用 {fmtCost(row.usedCost)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
value={adjustInputs[row.userId] || ''}
|
||||||
|
onChange={(e) => setAdjustInputs((prev) => ({ ...prev, [row.userId]: e.target.value }))}
|
||||||
|
placeholder="$"
|
||||||
|
style={{ width: 90 }}
|
||||||
|
disabled={adjustingId === row.userId}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => void applyAdjust(row.userId, 1)}
|
||||||
|
disabled={adjustingId === row.userId}
|
||||||
|
>
|
||||||
|
{adjustingId === row.userId ? '...' : '加额度'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => void applyAdjust(row.userId, -1)}
|
||||||
|
disabled={adjustingId === row.userId}
|
||||||
|
>
|
||||||
|
扣减
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 14 }}>
|
||||||
|
<button type="button" className="btn btn-ghost" disabled={page <= 1 || loading} onClick={() => void load(page - 1, q)}>上一页</button>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--color-text-muted)', alignSelf: 'center' }}>{page} / {totalPages}</span>
|
||||||
|
<button type="button" className="btn btn-ghost" disabled={page >= totalPages || loading} onClick={() => void load(page + 1, q)}>下一页</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -80,6 +80,15 @@ describe('UserDashboard recharge entry', () => {
|
|||||||
successRequests: 4,
|
successRequests: 4,
|
||||||
totalCost: 0.12,
|
totalCost: 0.12,
|
||||||
},
|
},
|
||||||
|
billing: {
|
||||||
|
keyCount: 1,
|
||||||
|
usedCost: 0.12,
|
||||||
|
maxCost: 50,
|
||||||
|
remainingCost: 49.88,
|
||||||
|
unlimitedKeyCount: 0,
|
||||||
|
rechargePath: '/user/recharge',
|
||||||
|
usagePath: '/user/usage',
|
||||||
|
},
|
||||||
recentLogs: [],
|
recentLogs: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -107,6 +116,8 @@ describe('UserDashboard recharge entry', () => {
|
|||||||
expect(text).toContain('用户自助工作台');
|
expect(text).toContain('用户自助工作台');
|
||||||
expect(text).toContain('密钥、额度、使用日志一处处理');
|
expect(text).toContain('密钥、额度、使用日志一处处理');
|
||||||
expect(text).toContain('1/1 个密钥启用');
|
expect(text).toContain('1/1 个密钥启用');
|
||||||
|
expect(text).toContain('剩余 10,525 积分');
|
||||||
|
expect(text).toContain('当前剩余:10,525 积分');
|
||||||
expect(text).toContain('已消耗 $0.120000');
|
expect(text).toContain('已消耗 $0.120000');
|
||||||
expect(text).toContain('调用状态正常');
|
expect(text).toContain('调用状态正常');
|
||||||
expect(root.root.findAll((node) => isInternalLinkTo(node, '/user/dashboard/keys')).length).toBeGreaterThanOrEqual(1);
|
expect(root.root.findAll((node) => isInternalLinkTo(node, '/user/dashboard/keys')).length).toBeGreaterThanOrEqual(1);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { userApi } from '../api.js';
|
import { userApi, type UserBillingSummary } from '../api.js';
|
||||||
import { clearSession, getSessionToken } from '../authSession.js';
|
import { clearSession, getSessionToken } from '../authSession.js';
|
||||||
import UserAvatar from '../components/UserAvatar.js';
|
import UserAvatar from '../components/UserAvatar.js';
|
||||||
import UserRechargeOptions from '../components/UserRechargeOptions.js';
|
import UserRechargeOptions from '../components/UserRechargeOptions.js';
|
||||||
|
import { formatRechargePointBalance } from '../userRecharge.js';
|
||||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||||
|
|
||||||
interface UserProfile {
|
interface UserProfile {
|
||||||
@@ -61,6 +62,7 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
|||||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||||
const [summary, setSummary] = useState<UserSummary | null>(null);
|
const [summary, setSummary] = useState<UserSummary | null>(null);
|
||||||
|
const [billing, setBilling] = useState<UserBillingSummary | null>(null);
|
||||||
const [recentLogs, setRecentLogs] = useState<LogItem[]>([]);
|
const [recentLogs, setRecentLogs] = useState<LogItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -82,6 +84,7 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
|||||||
setProfile(dashboardRes.user || null);
|
setProfile(dashboardRes.user || null);
|
||||||
setKeys(Array.isArray(dashboardRes.keys) ? dashboardRes.keys : []);
|
setKeys(Array.isArray(dashboardRes.keys) ? dashboardRes.keys : []);
|
||||||
setSummary(dashboardRes.summary || { totalRequests: 0, successRequests: 0, totalCost: 0 });
|
setSummary(dashboardRes.summary || { totalRequests: 0, successRequests: 0, totalCost: 0 });
|
||||||
|
setBilling(dashboardRes.billing || null);
|
||||||
setRecentLogs(Array.isArray(dashboardRes.recentLogs) ? dashboardRes.recentLogs : []);
|
setRecentLogs(Array.isArray(dashboardRes.recentLogs) ? dashboardRes.recentLogs : []);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.message || '加载失败');
|
setError(err?.message || '加载失败');
|
||||||
@@ -91,6 +94,7 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
|||||||
}
|
}
|
||||||
setKeys([]);
|
setKeys([]);
|
||||||
setSummary({ totalRequests: 0, successRequests: 0, totalCost: 0 });
|
setSummary({ totalRequests: 0, successRequests: 0, totalCost: 0 });
|
||||||
|
setBilling(null);
|
||||||
setRecentLogs([]);
|
setRecentLogs([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -139,6 +143,9 @@ 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 lastActivityLabel = recentLogs[0] ? formatDateLabel(recentLogs[0].created_at) : '暂无调用';
|
const lastActivityLabel = recentLogs[0] ? formatDateLabel(recentLogs[0].created_at) : '暂无调用';
|
||||||
|
const pointBalanceLabel = billing
|
||||||
|
? formatRechargePointBalance(billing.remainingCost, billing.maxCost)
|
||||||
|
: '等待余额同步';
|
||||||
const primarySuggestion = activeKeyCount === 0
|
const primarySuggestion = activeKeyCount === 0
|
||||||
? {
|
? {
|
||||||
title: '先创建一个 API 密钥',
|
title: '先创建一个 API 密钥',
|
||||||
@@ -208,8 +215,8 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '额度',
|
label: '额度',
|
||||||
title: `已消耗 ${formatMoney(summary?.totalCost)}`,
|
title: `剩余 ${pointBalanceLabel}`,
|
||||||
detail: '系统内选择充值档位,再跳转 Comfy Checkout 完成付款。',
|
detail: `已消耗 ${formatMoney(summary?.totalCost)},系统内选择充值档位后跳转 Comfy Checkout。`,
|
||||||
to: '/user/recharge',
|
to: '/user/recharge',
|
||||||
action: '去充值',
|
action: '去充值',
|
||||||
},
|
},
|
||||||
@@ -271,7 +278,7 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
|||||||
<div>
|
<div>
|
||||||
<span>账户充值</span>
|
<span>账户充值</span>
|
||||||
<strong>添加积分后继续调用模型与 Comfy API</strong>
|
<strong>添加积分后继续调用模型与 Comfy API</strong>
|
||||||
<p>进入系统内充值页确认额度后,再跳转到 Comfy Checkout 完成付款。</p>
|
<p>当前剩余:{pointBalanceLabel}。进入系统内充值页确认额度后,再跳转到 Comfy Checkout 完成付款。</p>
|
||||||
</div>
|
</div>
|
||||||
<UserRechargeOptions internalOnly />
|
<UserRechargeOptions internalOnly />
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ describe('UserKeys billing center', () => {
|
|||||||
expect(text).toContain('20 / 100');
|
expect(text).toContain('20 / 100');
|
||||||
expect(text).toContain('$0.120000 / $1.500');
|
expect(text).toContain('$0.120000 / $1.500');
|
||||||
expect(text).toContain('账单累计 $0.456000');
|
expect(text).toContain('账单累计 $0.456000');
|
||||||
|
expect(text).toContain('当前剩余 291 积分');
|
||||||
expect(text).toContain('1,280 tokens');
|
expect(text).toContain('1,280 tokens');
|
||||||
expect(text).toContain('计费运行建议');
|
expect(text).toContain('计费运行建议');
|
||||||
expect(text).toContain('额度状态正常');
|
expect(text).toContain('额度状态正常');
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { userApi } from '../api.js';
|
import { userApi } from '../api.js';
|
||||||
import UserRechargeOptions from '../components/UserRechargeOptions.js';
|
import UserRechargeOptions from '../components/UserRechargeOptions.js';
|
||||||
import { USER_RECHARGE_CONFIG } from '../userRecharge.js';
|
import { USER_RECHARGE_CONFIG, formatRechargePointBalance } from '../userRecharge.js';
|
||||||
import { buildUserUsageHref } from './helpers/userUsageLinks.js';
|
import { buildUserUsageHref } from './helpers/userUsageLinks.js';
|
||||||
|
|
||||||
interface ApiKey {
|
interface ApiKey {
|
||||||
@@ -421,6 +421,9 @@ export default function UserKeys() {
|
|||||||
const usedCost = keys.reduce((sum, item) => sum + asNumber(item.usedCost), 0);
|
const usedCost = keys.reduce((sum, item) => sum + asNumber(item.usedCost), 0);
|
||||||
const maxCost = keys.reduce((sum, item) => sum + (hasPositiveLimit(item.maxCost) ? Number(item.maxCost) : 0), 0);
|
const maxCost = keys.reduce((sum, item) => sum + (hasPositiveLimit(item.maxCost) ? Number(item.maxCost) : 0), 0);
|
||||||
const hasUnlimitedCost = keys.some((item) => !hasPositiveLimit(item.maxCost));
|
const hasUnlimitedCost = keys.some((item) => !hasPositiveLimit(item.maxCost));
|
||||||
|
const limitedCostKeys = keys.filter((item) => hasPositiveLimit(item.maxCost));
|
||||||
|
const limitedCostLimit = limitedCostKeys.reduce((sum, item) => sum + Number(item.maxCost), 0);
|
||||||
|
const limitedCostUsed = limitedCostKeys.reduce((sum, item) => sum + asNumber(item.usedCost), 0);
|
||||||
const totalLogs = asNumber(usageSummary?.total, recentLogs.length);
|
const totalLogs = asNumber(usageSummary?.total, recentLogs.length);
|
||||||
const failedLogs = asNumber(usageSummary?.failed, recentLogs.filter((log) => log.status !== 'success').length);
|
const failedLogs = asNumber(usageSummary?.failed, recentLogs.filter((log) => log.status !== 'success').length);
|
||||||
const totalCost = asNumber(usageSummary?.total_cost ?? usageSummary?.totalCost, usedCost);
|
const totalCost = asNumber(usageSummary?.total_cost ?? usageSummary?.totalCost, usedCost);
|
||||||
@@ -438,6 +441,8 @@ export default function UserKeys() {
|
|||||||
? `${formatCount(usedRequests)} / 不限`
|
? `${formatCount(usedRequests)} / 不限`
|
||||||
: `${formatCount(usedRequests)} / ${formatCount(maxRequests)}`,
|
: `${formatCount(usedRequests)} / ${formatCount(maxRequests)}`,
|
||||||
usedCost,
|
usedCost,
|
||||||
|
remainingCost: limitedCostLimit > 0 ? Math.max(0, limitedCostLimit - limitedCostUsed) : null,
|
||||||
|
maxCost: limitedCostLimit > 0 ? limitedCostLimit : null,
|
||||||
costLabel: hasUnlimitedCost || !maxCost
|
costLabel: hasUnlimitedCost || !maxCost
|
||||||
? `${formatMoney(usedCost)} / 不限`
|
? `${formatMoney(usedCost)} / 不限`
|
||||||
: `${formatMoney(usedCost)} / ${formatMoney(maxCost)}`,
|
: `${formatMoney(usedCost)} / ${formatMoney(maxCost)}`,
|
||||||
@@ -474,7 +479,8 @@ export default function UserKeys() {
|
|||||||
<section className="user-keys-recharge-panel" aria-label="账户充值">
|
<section className="user-keys-recharge-panel" aria-label="账户充值">
|
||||||
<div>
|
<div>
|
||||||
<span>账户余额</span>
|
<span>账户余额</span>
|
||||||
<strong>需要更多额度时,可跳转到 {recharge.providerLabel} 添加积分。</strong>
|
<strong>当前剩余 {formatRechargePointBalance(overview.remainingCost, overview.maxCost)}</strong>
|
||||||
|
<p>需要更多额度时,可跳转到 {recharge.providerLabel} 添加积分。</p>
|
||||||
</div>
|
</div>
|
||||||
<UserRechargeOptions compact internalOnly />
|
<UserRechargeOptions compact internalOnly />
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -125,15 +125,19 @@ describe('UserRecharge page', () => {
|
|||||||
));
|
));
|
||||||
const primaryButton = root!.root.find((node) => (
|
const primaryButton = root!.root.find((node) => (
|
||||||
node.type === 'button'
|
node.type === 'button'
|
||||||
&& collectText(node).includes('创建记录并支付')
|
&& String(node.props.className || '').includes('user-recharge-primary-action')
|
||||||
));
|
));
|
||||||
|
|
||||||
expect(text).toContain('账户充值');
|
expect(text).toContain('账户充值');
|
||||||
expect(text).toContain('为模型和 Comfy API 添加积分');
|
expect(text).toContain('为模型和 Comfy API 添加积分');
|
||||||
|
expect(text).toContain('$1 = 211 积分');
|
||||||
|
expect(text).toContain('10,550 积分');
|
||||||
expect(text).toContain(USER_RECHARGE_CONFIG.providerLabel);
|
expect(text).toContain(USER_RECHARGE_CONFIG.providerLabel);
|
||||||
expect(text).toContain('充值记录');
|
expect(text).toContain('充值记录');
|
||||||
expect(text).toContain('rch_test_1');
|
expect(text).toContain('rch_test_1');
|
||||||
expect(text).toContain('付款后由管理员核对到账。');
|
expect(text).toContain('付款后由管理员核对到账。');
|
||||||
|
expect(text).toContain('291 积分');
|
||||||
|
expect(text).toContain('已用 $0.1200 / 剩余 $1.3800');
|
||||||
expect(optionButtons).toHaveLength(payableOptions.length);
|
expect(optionButtons).toHaveLength(payableOptions.length);
|
||||||
expect(text).not.toContain('待配置');
|
expect(text).not.toContain('待配置');
|
||||||
expect(disabledOptions).toHaveLength(0);
|
expect(disabledOptions).toHaveLength(0);
|
||||||
@@ -195,6 +199,50 @@ describe('UserRecharge page', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('calculates points for a custom amount before creating checkout', async () => {
|
||||||
|
let root: ReturnType<typeof create>;
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/user/recharge']}>
|
||||||
|
<UserRecharge />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const amountInput = root!.root.find((node) => (
|
||||||
|
node.type === 'input'
|
||||||
|
&& node.props['aria-label'] === '自定义充值金额,单位美元'
|
||||||
|
));
|
||||||
|
await act(async () => {
|
||||||
|
amountInput.props.onChange({ target: { value: '12' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(collectText(root!.root)).toContain('2,532 积分');
|
||||||
|
|
||||||
|
const customButton = root!.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& collectText(node).includes('按自定义金额支付')
|
||||||
|
));
|
||||||
|
await act(async () => {
|
||||||
|
customButton.props.onClick();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(userApiMock.createRechargeCheckout).toHaveBeenCalledWith({
|
||||||
|
optionId: 'custom-usd-12',
|
||||||
|
amountUsd: 12,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
root!.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('falls back to current-tab navigation when the checkout popup is blocked', async () => {
|
it('falls back to current-tab navigation when the checkout popup is blocked', async () => {
|
||||||
(window.open as any).mockReturnValueOnce(null);
|
(window.open as any).mockReturnValueOnce(null);
|
||||||
let root: ReturnType<typeof create>;
|
let root: ReturnType<typeof create>;
|
||||||
@@ -213,7 +261,7 @@ describe('UserRecharge page', () => {
|
|||||||
try {
|
try {
|
||||||
const primaryButton = root!.root.find((node) => (
|
const primaryButton = root!.root.find((node) => (
|
||||||
node.type === 'button'
|
node.type === 'button'
|
||||||
&& collectText(node).includes('创建记录并支付')
|
&& String(node.props.className || '').includes('user-recharge-primary-action')
|
||||||
));
|
));
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { userApi, type UserRechargeRecord } from '../api.js';
|
import { userApi, type UserRechargeRecord } from '../api.js';
|
||||||
import UserRechargeOptions from '../components/UserRechargeOptions.js';
|
import UserRechargeOptions from '../components/UserRechargeOptions.js';
|
||||||
import { USER_RECHARGE_CONFIG, type UserRechargeCheckoutOption } from '../userRecharge.js';
|
import {
|
||||||
|
USER_RECHARGE_CONFIG,
|
||||||
|
USER_RECHARGE_POINTS_PER_USD,
|
||||||
|
calculateRechargePoints,
|
||||||
|
formatRechargePointBalance,
|
||||||
|
formatRechargePoints,
|
||||||
|
type UserRechargeCheckoutOption,
|
||||||
|
} from '../userRecharge.js';
|
||||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||||
|
|
||||||
function formatUsd(value?: number | null) {
|
function formatUsd(value?: number | null) {
|
||||||
@@ -11,6 +18,15 @@ function formatUsd(value?: number | null) {
|
|||||||
: '不限';
|
: '不限';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function customRechargeOption(amountUsd: number): UserRechargeCheckoutOption {
|
||||||
|
return {
|
||||||
|
id: `custom-usd-${amountUsd}`,
|
||||||
|
label: `$${amountUsd.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`,
|
||||||
|
title: '自定义金额',
|
||||||
|
amountUsd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function formatTime(value?: string | null) {
|
function formatTime(value?: string | null) {
|
||||||
return value ? formatDateTimeLocal(value) : '--';
|
return value ? formatDateTimeLocal(value) : '--';
|
||||||
}
|
}
|
||||||
@@ -52,6 +68,7 @@ export default function UserRecharge() {
|
|||||||
const [cancellingId, setCancellingId] = useState<string | null>(null);
|
const [cancellingId, setCancellingId] = useState<string | null>(null);
|
||||||
const [clearing, setClearing] = useState(false);
|
const [clearing, setClearing] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [customAmount, setCustomAmount] = useState('10');
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -76,6 +93,10 @@ export default function UserRecharge() {
|
|||||||
const remaining = billing.remainingCost == null ? '不限' : formatUsd(billing.remainingCost);
|
const remaining = billing.remainingCost == null ? '不限' : formatUsd(billing.remainingCost);
|
||||||
return `已用 ${formatUsd(billing.usedCost)} / 剩余 ${remaining}`;
|
return `已用 ${formatUsd(billing.usedCost)} / 剩余 ${remaining}`;
|
||||||
}, [billing]);
|
}, [billing]);
|
||||||
|
const pointBalanceText = useMemo(() => {
|
||||||
|
if (!billing) return '等待余额同步';
|
||||||
|
return formatRechargePointBalance(billing.remainingCost, billing.maxCost);
|
||||||
|
}, [billing]);
|
||||||
|
|
||||||
const openCheckout = async (option: UserRechargeCheckoutOption) => {
|
const openCheckout = async (option: UserRechargeCheckoutOption) => {
|
||||||
if (pendingOptionId) return;
|
if (pendingOptionId) return;
|
||||||
@@ -146,6 +167,16 @@ export default function UserRecharge() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const defaultOption = recharge.defaultOption;
|
const defaultOption = recharge.defaultOption;
|
||||||
|
const customAmountValue = Number(customAmount);
|
||||||
|
const customAmountValid = Number.isFinite(customAmountValue) && customAmountValue > 0;
|
||||||
|
const customPoints = customAmountValid ? calculateRechargePoints(customAmountValue) : 0;
|
||||||
|
const openCustomCheckout = () => {
|
||||||
|
if (!customAmountValid) {
|
||||||
|
setError('请输入有效的充值金额(USD)。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void openCheckout(customRechargeOption(Number(customAmountValue.toFixed(2))));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container user-recharge-page animate-fade-in">
|
<div className="page-container user-recharge-page animate-fade-in">
|
||||||
@@ -155,7 +186,7 @@ export default function UserRecharge() {
|
|||||||
<h1>为模型和 Comfy API 添加积分</h1>
|
<h1>为模型和 Comfy API 添加积分</h1>
|
||||||
<p>
|
<p>
|
||||||
先创建系统内充值记录,再跳转到 {recharge.providerLabel} 完成付款。
|
先创建系统内充值记录,再跳转到 {recharge.providerLabel} 完成付款。
|
||||||
充值记录会和使用记录一起保留,便于到账核对。
|
按 $1 = {USER_RECHARGE_POINTS_PER_USD} 积分换算,充值记录会和使用记录一起保留,便于到账核对。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -165,7 +196,7 @@ export default function UserRecharge() {
|
|||||||
disabled={pendingOptionId === defaultOption.id}
|
disabled={pendingOptionId === defaultOption.id}
|
||||||
onClick={() => void openCheckout(defaultOption)}
|
onClick={() => void openCheckout(defaultOption)}
|
||||||
>
|
>
|
||||||
{pendingOptionId === defaultOption.id ? '创建记录中...' : '创建记录并支付'}
|
{pendingOptionId === defaultOption.id ? '创建记录中...' : `支付 ${defaultOption.label} · ${formatRechargePoints(defaultOption.amountUsd)}`}
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -175,7 +206,7 @@ export default function UserRecharge() {
|
|||||||
<div className="user-recharge-section-head">
|
<div className="user-recharge-section-head">
|
||||||
<div>
|
<div>
|
||||||
<span>充值额度</span>
|
<span>充值额度</span>
|
||||||
<strong>先落库记录,再打开 Comfy Checkout</strong>
|
<strong>$1 = {USER_RECHARGE_POINTS_PER_USD} 积分,付款前先确认预计到账</strong>
|
||||||
</div>
|
</div>
|
||||||
<small>{recharge.providerLabel}</small>
|
<small>{recharge.providerLabel}</small>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,13 +215,39 @@ export default function UserRecharge() {
|
|||||||
onPay={openCheckout}
|
onPay={openCheckout}
|
||||||
pendingOptionId={pendingOptionId}
|
pendingOptionId={pendingOptionId}
|
||||||
/>
|
/>
|
||||||
|
<div className="user-recharge-custom" aria-label="自定义充值金额">
|
||||||
|
<div>
|
||||||
|
<span>自定义金额</span>
|
||||||
|
<strong>{customAmountValid ? `${customPoints.toLocaleString('zh-CN')} 积分` : '输入金额后计算'}</strong>
|
||||||
|
<p>按美元金额实时换算,提交后同样会先创建系统充值记录。</p>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>USD</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
value={customAmount}
|
||||||
|
onChange={(event) => setCustomAmount(event.target.value)}
|
||||||
|
aria-label="自定义充值金额,单位美元"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={!customAmountValid || Boolean(pendingOptionId)}
|
||||||
|
onClick={openCustomCheckout}
|
||||||
|
>
|
||||||
|
{pendingOptionId?.startsWith('custom-usd-') ? '创建记录中...' : '按自定义金额支付'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="user-recharge-info-grid" aria-label="充值说明">
|
<section className="user-recharge-info-grid" aria-label="充值说明">
|
||||||
<div>
|
<div>
|
||||||
<span>余额状态</span>
|
<span>余额状态</span>
|
||||||
<strong>{billingText}</strong>
|
<strong>{pointBalanceText}</strong>
|
||||||
<p>余额不足时,创作任务会提示回到此页创建充值记录。</p>
|
<p>{billingText}。余额不足时,创作任务会提示回到此页创建充值记录。</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>到账说明</span>
|
<span>到账说明</span>
|
||||||
|
|||||||
@@ -56,10 +56,17 @@ function commitLoginSession(
|
|||||||
navigate: ReturnType<typeof useNavigate>,
|
navigate: ReturnType<typeof useNavigate>,
|
||||||
) {
|
) {
|
||||||
commitSession('user', payload.token);
|
commitSession('user', payload.token);
|
||||||
|
if (payload.role === 'admin') {
|
||||||
|
// Admin-role user gets both audiences so /admin/* routes work without re-login.
|
||||||
|
commitSession('admin', payload.token);
|
||||||
|
onLogin?.('admin');
|
||||||
|
navigate('/dashboard');
|
||||||
|
} else {
|
||||||
clearSession('admin');
|
clearSession('admin');
|
||||||
onLogin?.('user');
|
onLogin?.('user');
|
||||||
navigate('/user/dashboard');
|
navigate('/user/dashboard');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function UserLoginPage({ t, onLogin }: UserLoginPageProps) {
|
export default function UserLoginPage({ t, onLogin }: UserLoginPageProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -91,10 +98,6 @@ export default function UserLoginPage({ t, onLogin }: UserLoginPageProps) {
|
|||||||
setError(t(resolveLoginErrorMessage(401, '')));
|
setError(t(resolveLoginErrorMessage(401, '')));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (payload.role === 'admin') {
|
|
||||||
setError(t('管理员请使用管理员 API Token 登录。'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
commitLoginSession(payload, onLogin, navigate);
|
commitLoginSession(payload, onLogin, navigate);
|
||||||
} catch {
|
} catch {
|
||||||
setError(t(resolveLoginErrorMessage('network', '')));
|
setError(t(resolveLoginErrorMessage('network', '')));
|
||||||
@@ -124,10 +127,6 @@ export default function UserLoginPage({ t, onLogin }: UserLoginPageProps) {
|
|||||||
setError(t(resolveLoginErrorMessage(401, '')));
|
setError(t(resolveLoginErrorMessage(401, '')));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (payload.role === 'admin') {
|
|
||||||
setError(t('管理员请使用管理员 API Token 登录。'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
commitLoginSession(payload, onLogin, navigate);
|
commitLoginSession(payload, onLogin, navigate);
|
||||||
} catch {
|
} catch {
|
||||||
setError(t(resolveLoginErrorMessage('network', '')));
|
setError(t(resolveLoginErrorMessage('network', '')));
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { resolve } from 'node:path';
|
|
||||||
|
|
||||||
describe('DigitalHuman template picker accessibility', () => {
|
|
||||||
it('exposes the active workflow template card as a pressed button', () => {
|
|
||||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/DigitalHuman.tsx'), 'utf8').replace(/\r\n/g, '\n');
|
|
||||||
|
|
||||||
expect(source).toContain("className={`digital-human-template-card${active ? ' is-active' : ''}`}");
|
|
||||||
expect(source).toContain('aria-pressed={active}');
|
|
||||||
expect(source).toContain('onClick={() => setTemplateId(workflow.publicId)}');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the workspace asset picker accessible and keyboard dismissible', () => {
|
|
||||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/DigitalHuman.tsx'), 'utf8').replace(/\r\n/g, '\n');
|
|
||||||
|
|
||||||
expect(source).toContain('const assetPickerTitleId = useId();');
|
|
||||||
expect(source).toContain('const assetPickerDialogRef = useRef<HTMLDivElement | null>(null);');
|
|
||||||
expect(source).toContain('const lastAssetPickerActiveElementRef = useRef<HTMLElement | null>(null);');
|
|
||||||
expect(source).toContain('const closeAssetPicker = useCallback(() => {');
|
|
||||||
expect(source).toContain('document.body.style.overflow = ');
|
|
||||||
expect(source).toContain('dialog.focus({ preventScroll: true });');
|
|
||||||
expect(source).toContain("event.key === 'Escape'");
|
|
||||||
expect(source).toContain("import { trapFocusInContainer } from '../components/focusTrap.js';");
|
|
||||||
expect(source).toContain('trapFocusInContainer(event, assetPickerDialogRef.current);');
|
|
||||||
expect(source).toContain('lastAssetPickerActiveElementRef.current?.focus({ preventScroll: true });');
|
|
||||||
expect(source).toContain('role="dialog"');
|
|
||||||
expect(source).toContain('aria-modal="true"');
|
|
||||||
expect(source).toContain('aria-labelledby={assetPickerTitleId}');
|
|
||||||
expect(source).toContain('ref={assetPickerDialogRef}');
|
|
||||||
expect(source).toContain('tabIndex={-1}');
|
|
||||||
expect(source).toContain('<h3 id={assetPickerTitleId}>');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats output mapping as optional and auto-infers mappings for plain workflow graphs', () => {
|
|
||||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/DigitalHuman.tsx'), 'utf8').replace(/\r\n/g, '\n');
|
|
||||||
|
|
||||||
expect(source).toContain("const REQUIRED_MAPPING_KEYS: MappingKey[] = ['prompt', 'script', 'avatar', 'referenceAudio'];");
|
|
||||||
expect(source).toContain('return REQUIRED_MAPPING_KEYS.every((key) => {');
|
|
||||||
expect(source).not.toContain("['prompt', 'script', 'avatar', 'referenceAudio', 'output'].every");
|
|
||||||
expect(source).not.toContain("['输出节点', mapped.output.nodeId]");
|
|
||||||
expect(source).toContain('|| inferMappingFromWorkflow(workflow || null);');
|
|
||||||
expect(source).toContain("node.inputs && typeof node.inputs === 'object'");
|
|
||||||
expect(source).toContain("node.params && typeof node.params === 'object'");
|
|
||||||
expect(source).toContain("['output', '输出视频节点(可选)'],");
|
|
||||||
expect(source).toContain('输出节点可选,系统会从 Comfy 历史中自动识别产物。');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('links remote Comfy outputs directly while preserving workspace asset links', () => {
|
|
||||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/DigitalHuman.tsx'), 'utf8').replace(/\r\n/g, '\n');
|
|
||||||
|
|
||||||
expect(source).toContain('function jobAssetHref(asset: any, workspacePath: string) {');
|
|
||||||
expect(source).toContain('if (asset?.remoteUrl) return String(asset.remoteUrl);');
|
|
||||||
expect(source).toContain("if (asset?.publicId) return `${workspacePath}?q=${encodeURIComponent(asset.publicId)}`;");
|
|
||||||
expect(source).toContain("if (asset?.remoteUrl) return `远端产物 · ${kind}`;");
|
|
||||||
expect(source).toContain("target={asset.remoteUrl ? '_blank' : undefined}");
|
|
||||||
expect(source).toContain("rel={asset.remoteUrl ? 'noreferrer' : undefined}");
|
|
||||||
expect(source).not.toContain('href={`${workspacePath}?q=${encodeURIComponent(asset.publicId)}`}');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('surfaces the digital human Comfy status contract in the page and API client', () => {
|
|
||||||
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/DigitalHuman.tsx'), 'utf8').replace(/\r\n/g, '\n');
|
|
||||||
const api = readFileSync(resolve(process.cwd(), 'src/web/api.ts'), 'utf8').replace(/\r\n/g, '\n');
|
|
||||||
|
|
||||||
expect(api).toContain('export type DigitalHumanStatusResponse = {');
|
|
||||||
expect(api).toContain('request<DigitalHumanStatusResponse>("/api/digital-human/status")');
|
|
||||||
expect(api).toContain('userRequest<DigitalHumanStatusResponse>("/api/digital-human/status")');
|
|
||||||
expect(source).toContain('type DigitalHumanStatusResponse');
|
|
||||||
expect(source).toContain('const [status, setStatus] = useState<DigitalHumanStatusResponse | null>(null);');
|
|
||||||
expect(source).toContain('client.getDigitalHumanStatus(),');
|
|
||||||
expect(source).toContain('<div className="digital-human-summary">');
|
|
||||||
expect(source).toContain('Comfy 状态');
|
|
||||||
expect(source).toContain('status?.keyName ? `使用 ${status.keyName}` :');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -21,4 +21,16 @@ describe('PictureAgent mobile layout', () => {
|
|||||||
expect(css).not.toContain('min-height: 340px;\n padding: 10px;');
|
expect(css).not.toContain('min-height: 340px;\n padding: 10px;');
|
||||||
expect(css).not.toContain('min-height: 300px;\n display: grid;');
|
expect(css).not.toContain('min-height: 300px;\n display: grid;');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps generation controls breathable in narrow side panels', () => {
|
||||||
|
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||||
|
|
||||||
|
expect(css).toContain('.picture-agent-tabs + [role="tabpanel"] {\n display: grid;\n gap: 14px;');
|
||||||
|
expect(css).toContain('.picture-agent-presets {\n display: grid;\n grid-template-columns: repeat(4, minmax(0, 1fr));');
|
||||||
|
expect(css).toContain('.picture-agent-size-row {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));');
|
||||||
|
expect(css).toContain('.picture-agent-size-preview {\n grid-column: 1 / -1;');
|
||||||
|
expect(css).toContain('.picture-agent-submit {\n min-height: 44px;');
|
||||||
|
expect(css).toMatch(/@media \(max-width: 640px\) \{[\s\S]*?\.picture-agent-presets \{[\s\S]*?grid-template-columns: repeat\(2, minmax\(0, 1fr\)\);/);
|
||||||
|
expect(css).not.toContain('grid-template-columns: 1fr 1fr minmax(92px, 0.8fr);');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
describe('Settings model brand recommendation filter copy', () => {
|
||||||
|
it('frames brand filtering as model-brand recommendation terms instead of platform branding', () => {
|
||||||
|
const source = fs.readFileSync(path.join(__dirname, 'Settings.tsx'), 'utf8');
|
||||||
|
|
||||||
|
expect(source).toContain('模型品牌推荐屏蔽词');
|
||||||
|
expect(source).toContain('LLM 对话推荐');
|
||||||
|
expect(source).toContain('这里不是屏蔽 BoosAPI 平台品牌');
|
||||||
|
expect(source).not.toContain('全局品牌屏蔽');
|
||||||
|
expect(source).not.toContain('保存品牌屏蔽');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,6 +27,7 @@ export type UserRechargeConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const UNCONFIGURED_CHECKOUT_REASON = '该额度支付链接未配置';
|
const UNCONFIGURED_CHECKOUT_REASON = '该额度支付链接未配置';
|
||||||
|
export const USER_RECHARGE_POINTS_PER_USD = 211;
|
||||||
|
|
||||||
const DEFAULT_USER_RECHARGE_AMOUNTS: UserRechargeCheckoutOption[] = [
|
const DEFAULT_USER_RECHARGE_AMOUNTS: UserRechargeCheckoutOption[] = [
|
||||||
{ id: 'usd-5', label: '$5', title: '轻量试用', amountUsd: 5 },
|
{ id: 'usd-5', label: '$5', title: '轻量试用', amountUsd: 5 },
|
||||||
@@ -44,6 +45,29 @@ export function isUserRechargeOptionPayable(option: UserRechargeOption): option
|
|||||||
return !option.disabled && (Boolean(checkoutUrl) || hasValidAmount(option));
|
return !option.disabled && (Boolean(checkoutUrl) || hasValidAmount(option));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function calculateRechargePoints(amountUsd?: number | null): number {
|
||||||
|
if (typeof amountUsd !== 'number' || !Number.isFinite(amountUsd) || amountUsd <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.round(amountUsd * USER_RECHARGE_POINTS_PER_USD);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRechargePoints(amountUsd?: number | null): string {
|
||||||
|
return `${calculateRechargePoints(amountUsd).toLocaleString('zh-CN')} 积分`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRechargePointBalance(
|
||||||
|
remainingCost?: number | null,
|
||||||
|
maxCost?: number | null,
|
||||||
|
): string {
|
||||||
|
if (remainingCost == null) {
|
||||||
|
return '不限积分';
|
||||||
|
}
|
||||||
|
const balance = Math.max(0, remainingCost);
|
||||||
|
const points = formatRechargePoints(balance);
|
||||||
|
return maxCost == null ? points : points;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRechargeOption(value: any, index: number): UserRechargeOption | null {
|
function normalizeRechargeOption(value: any, index: number): UserRechargeOption | null {
|
||||||
if (!value || typeof value !== 'object') return null;
|
if (!value || typeof value !== 'object') return null;
|
||||||
const checkoutUrl = String(value.checkoutUrl || value.checkout_url || value.url || '').trim();
|
const checkoutUrl = String(value.checkoutUrl || value.checkout_url || value.url || '').trim();
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ describe('website GSAP motion contract', () => {
|
|||||||
expect(source).toContain('matchMedia.add("(prefers-reduced-motion: reduce)"');
|
expect(source).toContain('matchMedia.add("(prefers-reduced-motion: reduce)"');
|
||||||
expect(source).toContain('matchMedia.add("(prefers-reduced-motion: no-preference) and (max-width: 640px)"');
|
expect(source).toContain('matchMedia.add("(prefers-reduced-motion: no-preference) and (max-width: 640px)"');
|
||||||
expect(source).toContain('matchMedia.add("(prefers-reduced-motion: no-preference) and (min-width: 641px)"');
|
expect(source).toContain('matchMedia.add("(prefers-reduced-motion: no-preference) and (min-width: 641px)"');
|
||||||
|
expect(source).toContain('ScrollTrigger.config({ ignoreMobileResize: true });');
|
||||||
expect(source).toContain('matchMedia.revert();');
|
expect(source).toContain('matchMedia.revert();');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,9 +87,12 @@ describe('website GSAP motion contract', () => {
|
|||||||
expect(source).toContain('const activeMobileLink = links.find((link) => (');
|
expect(source).toContain('const activeMobileLink = links.find((link) => (');
|
||||||
expect(source).toContain('link.getAttribute("href") === `#${id}` && Boolean(link.closest(".mobile-nav-rail"))');
|
expect(source).toContain('link.getAttribute("href") === `#${id}` && Boolean(link.closest(".mobile-nav-rail"))');
|
||||||
expect(source).not.toContain('CSS.escape');
|
expect(source).not.toContain('CSS.escape');
|
||||||
expect(source).toContain('activeMobileLink?.scrollIntoView({');
|
expect(source).toContain('centerMobileNavLink(activeMobileLink);');
|
||||||
expect(source).toContain('block: "nearest",');
|
expect(source).toContain('function centerMobileNavLink(link: HTMLAnchorElement) {');
|
||||||
expect(source).toContain('inline: "center",');
|
expect(source).toContain('const rail = link.closest<HTMLElement>(".mobile-nav-rail");');
|
||||||
|
expect(source).toContain('rail.scrollTo({');
|
||||||
|
expect(source).toContain('left: Math.max(0, nextScrollLeft),');
|
||||||
|
expect(source).toContain('behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",');
|
||||||
expect(source).toContain('const syncActiveFromHash = () => {');
|
expect(source).toContain('const syncActiveFromHash = () => {');
|
||||||
expect(source).toContain('window.location.hash.slice(1)');
|
expect(source).toContain('window.location.hash.slice(1)');
|
||||||
expect(source).toContain('const ANCHOR_NAV_EVENT = "bossai:anchor-nav";');
|
expect(source).toContain('const ANCHOR_NAV_EVENT = "bossai:anchor-nav";');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
@@ -23,6 +23,11 @@ describe('website /new experimental page', () => {
|
|||||||
expect(home).not.toContain('new-acid-page');
|
expect(home).not.toContain('new-acid-page');
|
||||||
expect(routes).toContain('path == "/new"');
|
expect(routes).toContain('path == "/new"');
|
||||||
expect(routes).toContain('path.starts_with("/new/")');
|
expect(routes).toContain('path.starts_with("/new/")');
|
||||||
|
expect(routes).not.toContain('path == "/new1"');
|
||||||
|
expect(routes).not.toContain('path == "/new2"');
|
||||||
|
expect(routes).not.toContain('path == "/new3"');
|
||||||
|
expect(routes).not.toContain('path == "/new4"');
|
||||||
|
expect(routes).not.toContain('path == "/new5"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses Three.js as a client-only lightweight background with mobile and reduced-motion fallback', () => {
|
it('uses Three.js as a client-only lightweight background with mobile and reduced-motion fallback', () => {
|
||||||
@@ -32,7 +37,8 @@ describe('website /new experimental page', () => {
|
|||||||
|
|
||||||
expect(pkg).toContain('"three"');
|
expect(pkg).toContain('"three"');
|
||||||
expect(pkg).toContain('"lenis"');
|
expect(pkg).toContain('"lenis"');
|
||||||
expect(pkg).not.toContain('"@react-three/fiber"');
|
expect(source).not.toContain('@react-three/fiber');
|
||||||
|
expect(source).not.toContain('@react-three/drei');
|
||||||
expect(source).toContain('"use client";');
|
expect(source).toContain('"use client";');
|
||||||
expect(source).toContain('import("three")');
|
expect(source).toContain('import("three")');
|
||||||
expect(source).toContain('import("gsap")');
|
expect(source).toContain('import("gsap")');
|
||||||
@@ -59,4 +65,20 @@ describe('website /new experimental page', () => {
|
|||||||
expect(css).toContain('@media (max-width: 640px)');
|
expect(css).toContain('@media (max-width: 640px)');
|
||||||
expect(css).toContain('@media (prefers-reduced-motion: reduce)');
|
expect(css).toContain('@media (prefers-reduced-motion: reduce)');
|
||||||
});
|
});
|
||||||
|
it('removes the retired /new1-/new5 comparison experiments and their extra R3F dependencies', () => {
|
||||||
|
const routes = read('src/routes/mod.rs');
|
||||||
|
const css = read('website/app/globals.css');
|
||||||
|
const pkg = read('website/package.json');
|
||||||
|
|
||||||
|
for (const route of ['new1', 'new2', 'new3', 'new4', 'new5']) {
|
||||||
|
expect(existsSync(resolve(process.cwd(), `website/app/${route}`))).toBe(false);
|
||||||
|
expect(routes).not.toContain(`path == "/${route}"`);
|
||||||
|
expect(routes).not.toContain(`path.starts_with("/${route}/")`);
|
||||||
|
expect(routes).not.toContain(`return "${route}.html".to_string();`);
|
||||||
|
expect(css).not.toContain(`.${route}-`);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(pkg).not.toContain('"@react-three/fiber"');
|
||||||
|
expect(pkg).not.toContain('"@react-three/drei"');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ScrollTrigger } from "gsap/ScrollTrigger";
|
|||||||
import { SplitText } from "gsap/SplitText";
|
import { SplitText } from "gsap/SplitText";
|
||||||
|
|
||||||
gsap.registerPlugin(useGSAP, ScrollTrigger, ScrollToPlugin, SplitText, CustomEase);
|
gsap.registerPlugin(useGSAP, ScrollTrigger, ScrollToPlugin, SplitText, CustomEase);
|
||||||
|
ScrollTrigger.config({ ignoreMobileResize: true });
|
||||||
|
|
||||||
const bossEase = CustomEase.create("boss-ease", "M0,0 C0.18,0.84 0.32,1 1,1");
|
const bossEase = CustomEase.create("boss-ease", "M0,0 C0.18,0.84 0.32,1 1,1");
|
||||||
const ANCHOR_NAV_EVENT = "bossai:anchor-nav";
|
const ANCHOR_NAV_EVENT = "bossai:anchor-nav";
|
||||||
@@ -397,10 +398,9 @@ function setupActiveNavigation(root: HTMLElement) {
|
|||||||
const activeMobileLink = links.find((link) => (
|
const activeMobileLink = links.find((link) => (
|
||||||
link.getAttribute("href") === `#${id}` && Boolean(link.closest(".mobile-nav-rail"))
|
link.getAttribute("href") === `#${id}` && Boolean(link.closest(".mobile-nav-rail"))
|
||||||
));
|
));
|
||||||
activeMobileLink?.scrollIntoView({
|
if (activeMobileLink) {
|
||||||
block: "nearest",
|
centerMobileNavLink(activeMobileLink);
|
||||||
inline: "center",
|
}
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const syncActiveFromHash = () => {
|
const syncActiveFromHash = () => {
|
||||||
@@ -438,6 +438,19 @@ function setupActiveNavigation(root: HTMLElement) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function centerMobileNavLink(link: HTMLAnchorElement) {
|
||||||
|
const rail = link.closest<HTMLElement>(".mobile-nav-rail");
|
||||||
|
if (!rail) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextScrollLeft = link.offsetLeft - (rail.clientWidth - link.clientWidth) / 2;
|
||||||
|
rail.scrollTo({
|
||||||
|
left: Math.max(0, nextScrollLeft),
|
||||||
|
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function setupCompactNavigation(root: HTMLElement) {
|
function setupCompactNavigation(root: HTMLElement) {
|
||||||
const syncCompactState = () => {
|
const syncCompactState = () => {
|
||||||
root.classList.toggle("is-nav-compact", window.scrollY > 18);
|
root.classList.toggle("is-nav-compact", window.scrollY > 18);
|
||||||
|
|||||||
@@ -3073,3 +3073,4 @@ a {
|
|||||||
will-change: auto;
|
will-change: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
apiVersion: zeabur.com/v1
|
|
||||||
kind: Template
|
|
||||||
metadata:
|
|
||||||
name: Metapi
|
|
||||||
spec:
|
|
||||||
description: Meta-layer management and unified proxy for AI API aggregation platforms.
|
|
||||||
icon: https://raw.githubusercontent.com/cita-777/boosapi/main/docs/logos/logo-icon-512.png
|
|
||||||
coverImage: https://raw.githubusercontent.com/cita-777/boosapi/main/docs/logos/logo-banner.png
|
|
||||||
tags:
|
|
||||||
- AI
|
|
||||||
- Tool
|
|
||||||
- Proxy
|
|
||||||
readme: |-
|
|
||||||
# Metapi (Zeabur Template)
|
|
||||||
|
|
||||||
Metapi is an AI API aggregation proxy with token routing, account management, monitoring, and admin dashboard.
|
|
||||||
|
|
||||||
## Required Variables
|
|
||||||
|
|
||||||
- `AUTH_TOKEN`: Admin login token for the dashboard.
|
|
||||||
- `PROXY_TOKEN`: Bearer token for downstream `/v1/*` proxy requests.
|
|
||||||
|
|
||||||
## Data Persistence
|
|
||||||
|
|
||||||
Runtime data is stored in `/app/data` and persisted with a Zeabur volume.
|
|
||||||
variables:
|
|
||||||
- key: AUTH_TOKEN
|
|
||||||
type: STRING
|
|
||||||
name: Admin Token
|
|
||||||
description: Dashboard login token (must be strong and private).
|
|
||||||
- key: PROXY_TOKEN
|
|
||||||
type: STRING
|
|
||||||
name: Proxy Token
|
|
||||||
description: Bearer token used by downstream clients.
|
|
||||||
- key: TZ
|
|
||||||
type: STRING
|
|
||||||
name: Time Zone
|
|
||||||
description: Service timezone (for cron jobs and logs).
|
|
||||||
- key: PORT
|
|
||||||
type: STRING
|
|
||||||
name: Service Port
|
|
||||||
description: Internal HTTP port (default 4000).
|
|
||||||
services:
|
|
||||||
- name: boosapi
|
|
||||||
icon: https://raw.githubusercontent.com/cita-777/boosapi/main/docs/logos/logo-icon-512.png
|
|
||||||
template: PREBUILT
|
|
||||||
spec:
|
|
||||||
source:
|
|
||||||
image: 1467078763/boosapi:latest
|
|
||||||
ports:
|
|
||||||
- id: web
|
|
||||||
port: 4000
|
|
||||||
type: HTTP
|
|
||||||
volumes:
|
|
||||||
- id: data
|
|
||||||
dir: /app/data
|
|
||||||
env:
|
|
||||||
AUTH_TOKEN:
|
|
||||||
default: ${AUTH_TOKEN}
|
|
||||||
expose: false
|
|
||||||
PROXY_TOKEN:
|
|
||||||
default: ${PROXY_TOKEN}
|
|
||||||
expose: false
|
|
||||||
ACCOUNT_CREDENTIAL_SECRET:
|
|
||||||
default: ${AUTH_TOKEN}
|
|
||||||
expose: false
|
|
||||||
CHECKIN_CRON:
|
|
||||||
default: 0 8 * * *
|
|
||||||
BALANCE_REFRESH_CRON:
|
|
||||||
default: 0 * * * *
|
|
||||||
PORT:
|
|
||||||
default: ${PORT}
|
|
||||||
DATA_DIR:
|
|
||||||
default: /app/data
|
|
||||||
TZ:
|
|
||||||
default: ${TZ}
|
|
||||||
NODE_ENV:
|
|
||||||
default: production
|
|
||||||
localization:
|
|
||||||
zh-CN:
|
|
||||||
description: Metapi 是一个 AI 中转聚合代理,提供路由、监控和管理后台。
|
|
||||||
variables:
|
|
||||||
- key: AUTH_TOKEN
|
|
||||||
name: 管理员令牌
|
|
||||||
description: 用于登录后台的管理员令牌(请设置强密码)。
|
|
||||||
- key: PROXY_TOKEN
|
|
||||||
name: 代理令牌
|
|
||||||
description: 下游客户端调用 /v1/* 时使用的 Bearer Token。
|
|
||||||
- key: TZ
|
|
||||||
name: 时区
|
|
||||||
description: 服务时区(影响定时任务和日志时间)。
|
|
||||||
- key: PORT
|
|
||||||
name: 服务端口
|
|
||||||
description: 内部监听端口(默认 4000)。
|
|
||||||
readme: |-
|
|
||||||
# Metapi (Zeabur 模板)
|
|
||||||
|
|
||||||
Metapi 是一个 AI 中转聚合代理,支持 Token 路由、账号管理、监控和管理后台。
|
|
||||||
|
|
||||||
## 必填变量
|
|
||||||
|
|
||||||
- `AUTH_TOKEN`:后台管理员登录令牌
|
|
||||||
- `PROXY_TOKEN`:下游调用 `/v1/*` 使用的 Bearer Token
|
|
||||||
|
|
||||||
## 数据持久化
|
|
||||||
|
|
||||||
运行数据写入 `/app/data`,模板已配置 Zeabur Volume 挂载持久化。
|
|
||||||
Reference in New Issue
Block a user