Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3293e007f | |||
| a1b7b60ad4 |
Generated
-20
@@ -413,7 +413,6 @@ dependencies = [
|
||||
"rstest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
@@ -2529,19 +2528,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
@@ -3471,12 +3457,6 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
||||
@@ -32,7 +32,6 @@ lettre = { version = "0.11", default-features = false, features = ["builder", "h
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
regex = "1"
|
||||
|
||||
# Auth
|
||||
|
||||
+4
-41
@@ -227,24 +227,6 @@ configure_fast_linker() {
|
||||
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() {
|
||||
if [ -n "${BOOSAPI_SERVER__PORT:-}" ]; then
|
||||
printf '%s\n' "$BOOSAPI_SERVER__PORT"
|
||||
@@ -515,7 +497,6 @@ build_backend() {
|
||||
fi
|
||||
|
||||
configure_fast_linker
|
||||
configure_sccache
|
||||
build_cargo_args
|
||||
|
||||
if [ "$CHECK_ONLY" -eq 1 ]; then
|
||||
@@ -600,33 +581,15 @@ if [ "$FRONTEND_ONLY" -eq 1 ]; then
|
||||
fi
|
||||
|
||||
if [ "$BACKEND_ONLY" -ne 1 ]; then
|
||||
build_frontend
|
||||
build_public_website
|
||||
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_public_website
|
||||
ensure_workflow_ui_for_embedding
|
||||
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
|
||||
|
||||
build_backend
|
||||
|
||||
if [ "$CHECK_ONLY" -eq 1 ]; then
|
||||
print_timing_summary
|
||||
log "跳过启动(--check)"
|
||||
|
||||
@@ -1,632 +0,0 @@
|
||||
# Comfy Cloud 工作流执行适配器 实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在 BoosAPI 内实现 ComfyUI 原生协议 ↔ Comfy Cloud API 翻译适配器,让 Pixelle/comfykit 零改动通过 BoosAPI 调用 Comfy Cloud 执行工作流。
|
||||
|
||||
**Architecture:** 新增 `/comfy-cloud/{prompt,history,view}` 端点,内部翻译成 `cloud.comfy.org/api/*` + `X-API-Key`。Comfy Cloud 的 base_url + key 从站点管理(`platform='comfy_cloud'` 的 active 站点)读取。纯翻译函数单测,wiremock 集成测。
|
||||
|
||||
**Tech Stack:** Rust (axum, reqwest, sqlx, serde_json, wiremock, tokio)。
|
||||
|
||||
> **构建/测试:** `cargo build -j 2`、`cargo test -p boosapi --lib comfy_cloud`。分支 `feat/comfy-cloud-adapter`。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
- Create `src/services/comfy_cloud/mod.rs` — 模块导出。
|
||||
- Create `src/services/comfy_cloud/site.rs` — `resolve_comfy_cloud_site` 读站点配置。
|
||||
- Create `src/services/comfy_cloud/translate.rs` — `comfy_cloud_job_to_history` 纯翻译函数。
|
||||
- Create `src/routes/comfy_cloud_adapter.rs` — 三个适配端点 + `routes()`。
|
||||
- Modify `src/services/mod.rs` — 加 `pub mod comfy_cloud;`。
|
||||
- Modify `src/routes/mod.rs` — 加 `mod comfy_cloud_adapter;` + `.merge(...)`。
|
||||
- Modify `src/web/pages/Sites.tsx` — `SITE_PLATFORM_OPTIONS` 加 `comfy_cloud` 选项。
|
||||
|
||||
---
|
||||
|
||||
<!-- TASKS -->
|
||||
|
||||
## Task 1: 站点配置读取 `resolve_comfy_cloud_site`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/services/comfy_cloud/mod.rs`
|
||||
- Create: `src/services/comfy_cloud/site.rs`
|
||||
- Modify: `src/services/mod.rs`
|
||||
|
||||
- [ ] **Step 1: `src/services/mod.rs` 末尾加模块声明**
|
||||
|
||||
```rust
|
||||
pub mod comfy_cloud;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 写 `src/services/comfy_cloud/mod.rs`**
|
||||
|
||||
```rust
|
||||
//! Comfy Cloud 工作流执行适配器 service 层。
|
||||
pub mod site;
|
||||
pub mod translate;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 写失败测试 `src/services/comfy_cloud/site.rs`**
|
||||
|
||||
```rust
|
||||
use crate::error::{AppError, AppResult};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// 解析 Comfy Cloud 站点配置,返回 (base_url, api_key)。
|
||||
/// 取 platform='comfy_cloud' 且 status='active' 的站点。
|
||||
pub async fn resolve_comfy_cloud_site(db: &SqlitePool) -> AppResult<(String, String)> {
|
||||
let row: Option<(String, Option<String>)> = sqlx::query_as(
|
||||
"SELECT url, api_key FROM sites
|
||||
WHERE platform = 'comfy_cloud' AND status = 'active'
|
||||
ORDER BY COALESCE(sort_order, 0), id LIMIT 1",
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
let (url, api_key) = row.ok_or_else(|| {
|
||||
AppError::ServiceUnavailable("未配置 Comfy Cloud 站点(站点管理添加 comfy_cloud 平台)".into())
|
||||
})?;
|
||||
let api_key = api_key
|
||||
.map(|k| k.trim().to_string())
|
||||
.filter(|k| !k.is_empty())
|
||||
.ok_or_else(|| AppError::ServiceUnavailable("Comfy Cloud 站点缺少 API Key".into()))?;
|
||||
let base_url = url.trim().trim_end_matches('/').to_string();
|
||||
Ok((base_url, api_key))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
async fn pool() -> SqlitePool {
|
||||
let pool = SqlitePoolOptions::new().max_connections(1).connect("sqlite::memory:").await.unwrap();
|
||||
sqlx::query("CREATE TABLE sites (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, url TEXT NOT NULL, platform TEXT NOT NULL, status TEXT NOT NULL, api_key TEXT, sort_order INTEGER)")
|
||||
.execute(&pool).await.unwrap();
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolves_active_comfy_cloud_site() {
|
||||
let pool = pool().await;
|
||||
sqlx::query("INSERT INTO sites (name,url,platform,status,api_key) VALUES ('cc','https://cloud.comfy.org/','comfy_cloud','active','key-123')")
|
||||
.execute(&pool).await.unwrap();
|
||||
let (url, key) = resolve_comfy_cloud_site(&pool).await.unwrap();
|
||||
assert_eq!(url, "https://cloud.comfy.org");
|
||||
assert_eq!(key, "key-123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn errors_when_not_configured() {
|
||||
let pool = pool().await;
|
||||
let r = resolve_comfy_cloud_site(&pool).await;
|
||||
assert!(matches!(r, Err(AppError::ServiceUnavailable(_))));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行测试,确认失败**
|
||||
|
||||
Run: `cargo test -p boosapi --lib services::comfy_cloud::site 2>&1 | tail -15`
|
||||
Expected: FAIL —— 模块未挂载/函数未定义(编译错误)。
|
||||
|
||||
- [ ] **Step 5: 实现**
|
||||
|
||||
Step 3 的函数体即实现(测试与实现同文件)。确认 `src/services/comfy_cloud/mod.rs` 已 `pub mod site;`。
|
||||
|
||||
- [ ] **Step 6: 运行测试,确认通过**
|
||||
|
||||
Run: `cargo test -p boosapi --lib services::comfy_cloud::site 2>&1 | tail -10`
|
||||
Expected: PASS(2 个测试)。
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/services/mod.rs src/services/comfy_cloud/mod.rs src/services/comfy_cloud/site.rs
|
||||
git commit -m "feat(comfy-cloud): 站点配置读取 resolve_comfy_cloud_site"
|
||||
```
|
||||
|
||||
<!-- T2 -->
|
||||
|
||||
## Task 2: 翻译函数 `comfy_cloud_job_to_history`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/services/comfy_cloud/translate.rs`
|
||||
|
||||
- [ ] **Step 1: 写失败测试 `src/services/comfy_cloud/translate.rs`**
|
||||
|
||||
```rust
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// 把 Comfy Cloud 的 job status(+ 完成时的 jobs detail outputs)翻译成
|
||||
/// ComfyUI 原生 /history/{id} 的形状。
|
||||
///
|
||||
/// - status pending/in_progress → 返回空对象 {}(comfykit 据此继续轮询)
|
||||
/// - status completed → { "{id}": { "status": {completed:true, status_str:"success"}, "outputs": {...} } }
|
||||
/// - status failed/cancelled → { "{id}": { "status": {completed:false, status_str:"error"} } }
|
||||
pub fn comfy_cloud_job_to_history(
|
||||
prompt_id: &str,
|
||||
status: &str,
|
||||
outputs: Option<&Value>,
|
||||
) -> Value {
|
||||
match status {
|
||||
"completed" => json!({
|
||||
prompt_id: {
|
||||
"status": { "completed": true, "status_str": "success" },
|
||||
"outputs": outputs.cloned().unwrap_or_else(|| json!({})),
|
||||
}
|
||||
}),
|
||||
"failed" | "cancelled" => json!({
|
||||
prompt_id: {
|
||||
"status": { "completed": false, "status_str": "error" },
|
||||
"outputs": {},
|
||||
}
|
||||
}),
|
||||
_ => json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn in_progress_returns_empty() {
|
||||
assert_eq!(comfy_cloud_job_to_history("p1", "in_progress", None), json!({}));
|
||||
assert_eq!(comfy_cloud_job_to_history("p1", "pending", None), json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_wraps_outputs() {
|
||||
let outputs = json!({ "9": { "images": [{ "filename": "a.png", "subfolder": "", "type": "output" }] } });
|
||||
let h = comfy_cloud_job_to_history("p1", "completed", Some(&outputs));
|
||||
assert_eq!(h["p1"]["status"]["completed"], true);
|
||||
assert_eq!(h["p1"]["outputs"]["9"]["images"][0]["filename"], "a.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_marks_error() {
|
||||
let h = comfy_cloud_job_to_history("p1", "failed", None);
|
||||
assert_eq!(h["p1"]["status"]["status_str"], "error");
|
||||
assert_eq!(h["p1"]["status"]["completed"], false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试,确认失败**
|
||||
|
||||
Run: `cargo test -p boosapi --lib services::comfy_cloud::translate 2>&1 | tail -15`
|
||||
Expected: FAIL —— 模块未挂载/函数未定义。
|
||||
|
||||
- [ ] **Step 3: 实现**
|
||||
|
||||
Step 1 的函数体即实现。确认 `mod.rs` 已 `pub mod translate;`(Task 1 Step 2 已加)。
|
||||
|
||||
- [ ] **Step 4: 运行测试,确认通过**
|
||||
|
||||
Run: `cargo test -p boosapi --lib services::comfy_cloud::translate 2>&1 | tail -10`
|
||||
Expected: PASS(3 个测试)。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/services/comfy_cloud/translate.rs
|
||||
git commit -m "feat(comfy-cloud): job→ComfyUI history 翻译函数"
|
||||
```
|
||||
|
||||
<!-- T3 -->
|
||||
|
||||
## Task 3: 适配端点骨架 + `POST /comfy-cloud/prompt`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/routes/comfy_cloud_adapter.rs`
|
||||
|
||||
- [ ] **Step 1: 写骨架 + 测试 `src/routes/comfy_cloud_adapter.rs`**
|
||||
|
||||
```rust
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::services::comfy_cloud::site::resolve_comfy_cloud_site;
|
||||
use crate::services::comfy_cloud::translate::comfy_cloud_job_to_history;
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path, RawQuery, State},
|
||||
http::{header, StatusCode},
|
||||
response::Response,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/comfy-cloud/prompt", post(submit_prompt))
|
||||
.route("/comfy-cloud/history/{prompt_id}", get(get_history))
|
||||
.route("/comfy-cloud/view", get(get_view))
|
||||
}
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new())
|
||||
}
|
||||
|
||||
async fn submit_prompt(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (base_url, api_key) = resolve_comfy_cloud_site(&state.db).await?;
|
||||
let resp = client()
|
||||
.post(format!("{base_url}/api/prompt"))
|
||||
.header("X-API-Key", api_key)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
if !status.is_success() {
|
||||
return Err(AppError::UpstreamError(format!("Comfy Cloud prompt [{status}]: {text}")));
|
||||
}
|
||||
let value: Value = serde_json::from_str(&text).unwrap_or_else(|_| serde_json::json!({ "body": text }));
|
||||
Ok(Json(value))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wiremock::matchers::{header as match_header, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
async fn db_with_site(base_url: &str) -> sqlx::SqlitePool {
|
||||
let pool = SqlitePoolOptions::new().max_connections(1).connect("sqlite::memory:").await.unwrap();
|
||||
sqlx::query("CREATE TABLE sites (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, url TEXT NOT NULL, platform TEXT NOT NULL, status TEXT NOT NULL, api_key TEXT, sort_order INTEGER)")
|
||||
.execute(&pool).await.unwrap();
|
||||
sqlx::query("INSERT INTO sites (name,url,platform,status,api_key) VALUES ('cc',?,'comfy_cloud','active','key-123')")
|
||||
.bind(base_url).execute(&pool).await.unwrap();
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submit_forwards_with_api_key() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/prompt"))
|
||||
.and(match_header("X-API-Key", "key-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "prompt_id": "p1" })))
|
||||
.mount(&server).await;
|
||||
|
||||
let pool = db_with_site(&server.uri()).await;
|
||||
let (base_url, api_key) = resolve_comfy_cloud_site(&pool).await.unwrap();
|
||||
// 直接验证转发契约(handler 依赖 AppState,这里测底层契约)
|
||||
let resp = client().post(format!("{base_url}/api/prompt"))
|
||||
.header("X-API-Key", api_key)
|
||||
.json(&serde_json::json!({ "prompt": {} }))
|
||||
.send().await.unwrap();
|
||||
let v: Value = resp.json().await.unwrap();
|
||||
assert_eq!(v["prompt_id"], "p1");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 注:`submit_prompt` handler 需 `AppState`,完整 handler 走 Task 6 联调验证;本 task 的
|
||||
> wiremock 测试验证「X-API-Key 注入 + `/api/prompt` 路径 + prompt_id 透传」这一核心契约。
|
||||
|
||||
- [ ] **Step 2: 运行测试,确认失败**
|
||||
|
||||
Run: `cargo test -p boosapi --lib routes::comfy_cloud_adapter 2>&1 | tail -15`
|
||||
Expected: FAIL —— 模块未挂载(`get_history`/`get_view` 未定义,编译错误)。
|
||||
|
||||
> 因 `routes()` 引用了 Task 4/5 的 `get_history`/`get_view`,本 task 先写它们的空桩
|
||||
> 让编译过(Step 3),Task 4/5 再填实现。
|
||||
|
||||
- [ ] **Step 3: 加 `get_history`/`get_view` 空桩(接在 `submit_prompt` 之后,让编译通过)**
|
||||
|
||||
```rust
|
||||
async fn get_history(
|
||||
State(state): State<AppState>,
|
||||
Path(prompt_id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let _ = (&state, &prompt_id);
|
||||
Ok(Json(serde_json::json!({})))
|
||||
}
|
||||
|
||||
async fn get_view(
|
||||
State(state): State<AppState>,
|
||||
RawQuery(query): RawQuery,
|
||||
) -> AppResult<Response> {
|
||||
let _ = (&state, &query);
|
||||
Ok(Response::builder().status(StatusCode::NOT_IMPLEMENTED).body(Body::empty())
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行测试,确认通过**
|
||||
|
||||
Run: `cargo test -p boosapi --lib routes::comfy_cloud_adapter 2>&1 | tail -10`
|
||||
Expected: PASS(1 个测试)。
|
||||
|
||||
> 此时 `routes::comfy_cloud_adapter` 模块还没挂进 `routes/mod.rs`,测试是模块内单测,
|
||||
> 不需要挂载即可跑。挂载在 Task 6。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/routes/comfy_cloud_adapter.rs
|
||||
git commit -m "feat(comfy-cloud): 适配端点骨架 + POST /prompt 转发"
|
||||
```
|
||||
|
||||
<!-- T4 -->
|
||||
|
||||
## Task 4: `GET /comfy-cloud/history/{id}` 轮询翻译
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/routes/comfy_cloud_adapter.rs`
|
||||
|
||||
- [ ] **Step 1: 写失败测试(加到测试模块内)**
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn history_in_progress_then_completed() {
|
||||
let server = MockServer::start().await;
|
||||
// 状态查询:completed
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/job/p1/status"))
|
||||
.and(match_header("X-API-Key", "key-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "status": "completed" })))
|
||||
.mount(&server).await;
|
||||
// 详情:outputs
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/jobs/p1"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"outputs": { "9": { "images": [{ "filename": "a.png", "subfolder": "", "type": "output" }] } }
|
||||
})))
|
||||
.mount(&server).await;
|
||||
|
||||
let pool = db_with_site(&server.uri()).await;
|
||||
let history = fetch_history(&pool, "p1").await.unwrap();
|
||||
assert_eq!(history["p1"]["status"]["completed"], true);
|
||||
assert_eq!(history["p1"]["outputs"]["9"]["images"][0]["filename"], "a.png");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试,确认失败**
|
||||
|
||||
Run: `cargo test -p boosapi --lib routes::comfy_cloud_adapter::tests::history 2>&1 | tail -15`
|
||||
Expected: FAIL —— `fetch_history` 未定义。
|
||||
|
||||
- [ ] **Step 3: 实现 `fetch_history` + 替换 `get_history` 空桩**
|
||||
|
||||
把 Task 3 的 `get_history` 空桩替换为:
|
||||
|
||||
```rust
|
||||
async fn fetch_history(db: &sqlx::SqlitePool, prompt_id: &str) -> AppResult<Value> {
|
||||
let (base_url, api_key) = resolve_comfy_cloud_site(db).await?;
|
||||
let http = client();
|
||||
let status_resp = http
|
||||
.get(format!("{base_url}/api/job/{prompt_id}/status"))
|
||||
.header("X-API-Key", &api_key)
|
||||
.send().await
|
||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
if !status_resp.status().is_success() {
|
||||
let st = status_resp.status();
|
||||
let body = status_resp.text().await.unwrap_or_default();
|
||||
return Err(AppError::UpstreamError(format!("Comfy Cloud status [{st}]: {body}")));
|
||||
}
|
||||
let status_json: Value = status_resp.json().await.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
let status = status_json.get("status").and_then(Value::as_str).unwrap_or("in_progress");
|
||||
|
||||
if status != "completed" {
|
||||
return Ok(comfy_cloud_job_to_history(prompt_id, status, None));
|
||||
}
|
||||
let detail_resp = http
|
||||
.get(format!("{base_url}/api/jobs/{prompt_id}"))
|
||||
.header("X-API-Key", &api_key)
|
||||
.send().await
|
||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
let detail_json: Value = detail_resp.json().await.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
let outputs = detail_json.get("outputs");
|
||||
Ok(comfy_cloud_job_to_history(prompt_id, status, outputs))
|
||||
}
|
||||
|
||||
async fn get_history(
|
||||
State(state): State<AppState>,
|
||||
Path(prompt_id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
Ok(Json(fetch_history(&state.db, &prompt_id).await?))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行测试,确认通过**
|
||||
|
||||
Run: `cargo test -p boosapi --lib routes::comfy_cloud_adapter 2>&1 | tail -10`
|
||||
Expected: PASS(submit + history 共 2 个测试)。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/routes/comfy_cloud_adapter.rs
|
||||
git commit -m "feat(comfy-cloud): /history 轮询翻译(status+jobs→history)"
|
||||
```
|
||||
|
||||
<!-- T5 -->
|
||||
|
||||
## Task 5: `GET /comfy-cloud/view` 产出文件透传
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/routes/comfy_cloud_adapter.rs`
|
||||
|
||||
- [ ] **Step 1: 写失败测试(加到测试模块内)**
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn view_proxies_file_bytes() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/view"))
|
||||
.and(match_header("X-API-Key", "key-123"))
|
||||
.respond_with(ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "image/png")
|
||||
.set_body_bytes(b"PNGDATA".to_vec()))
|
||||
.mount(&server).await;
|
||||
|
||||
let pool = db_with_site(&server.uri()).await;
|
||||
let (mime, bytes) = fetch_view(&pool, Some("filename=a.png&type=output")).await.unwrap();
|
||||
assert_eq!(mime, "image/png");
|
||||
assert_eq!(bytes, b"PNGDATA");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试,确认失败**
|
||||
|
||||
Run: `cargo test -p boosapi --lib routes::comfy_cloud_adapter::tests::view 2>&1 | tail -15`
|
||||
Expected: FAIL —— `fetch_view` 未定义。
|
||||
|
||||
- [ ] **Step 3: 实现 `fetch_view` + 替换 `get_view` 空桩**
|
||||
|
||||
把 Task 3 的 `get_view` 空桩替换为:
|
||||
|
||||
```rust
|
||||
async fn fetch_view(db: &sqlx::SqlitePool, query: Option<&str>) -> AppResult<(String, Vec<u8>)> {
|
||||
let (base_url, api_key) = resolve_comfy_cloud_site(db).await?;
|
||||
let q = query.unwrap_or("");
|
||||
let url = if q.is_empty() { format!("{base_url}/api/view") } else { format!("{base_url}/api/view?{q}") };
|
||||
let resp = client()
|
||||
.get(url)
|
||||
.header("X-API-Key", api_key)
|
||||
.send().await
|
||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
if !resp.status().is_success() {
|
||||
let st = resp.status();
|
||||
return Err(AppError::UpstreamError(format!("Comfy Cloud view [{st}]")));
|
||||
}
|
||||
let mime = resp
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
let bytes = resp.bytes().await.map_err(|e| AppError::UpstreamError(e.to_string()))?.to_vec();
|
||||
Ok((mime, bytes))
|
||||
}
|
||||
|
||||
async fn get_view(
|
||||
State(state): State<AppState>,
|
||||
RawQuery(query): RawQuery,
|
||||
) -> AppResult<Response> {
|
||||
let (mime, bytes) = fetch_view(&state.db, query.as_deref()).await?;
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, mime)
|
||||
.body(Body::from(bytes))
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))
|
||||
}
|
||||
```
|
||||
|
||||
> reqwest 默认跟随 302,所以 Comfy Cloud `/api/view` 的签名 URL 跳转会被自动跟随,
|
||||
> 最终拿到文件字节。无需手动处理 Location。
|
||||
|
||||
- [ ] **Step 4: 运行测试,确认通过**
|
||||
|
||||
Run: `cargo test -p boosapi --lib routes::comfy_cloud_adapter 2>&1 | tail -10`
|
||||
Expected: PASS(submit + history + view 共 3 个测试)。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/routes/comfy_cloud_adapter.rs
|
||||
git commit -m "feat(comfy-cloud): /view 产出文件透传"
|
||||
```
|
||||
|
||||
<!-- T6 -->
|
||||
|
||||
## Task 6: 挂载路由 + 前端平台选项 + 全量验证
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/routes/mod.rs`
|
||||
- Modify: `src/web/pages/Sites.tsx`
|
||||
|
||||
- [ ] **Step 1: `src/routes/mod.rs` 顶部模块声明区加**
|
||||
|
||||
```rust
|
||||
mod comfy_cloud_adapter;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `build_router` 的 merge 链里加(`.merge(picture_agent::routes())` 附近)**
|
||||
|
||||
```rust
|
||||
.merge(comfy_cloud_adapter::routes())
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 前端 `SITE_PLATFORM_OPTIONS` 加 comfy_cloud 选项**
|
||||
|
||||
在 `src/web/pages/Sites.tsx` 的 `SITE_PLATFORM_OPTIONS` 数组(约 line 700-710,
|
||||
`{ value: 'cliproxyapi', ... }` 附近)加一项:
|
||||
|
||||
```tsx
|
||||
{ value: 'comfy_cloud', label: 'Comfy Cloud', description: '工作流执行 API(数字人/图片视频)' },
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 后端全量构建 + comfy_cloud 测试**
|
||||
|
||||
Run: `cargo build -j 2 2>&1 | tail -3 && cargo test -p boosapi --lib comfy_cloud 2>&1 | command grep "test result"`
|
||||
Expected: `Finished`;comfy_cloud service + routes 测试全 PASS。
|
||||
|
||||
- [ ] **Step 5: 前端 typecheck**
|
||||
|
||||
Run: `npm run typecheck:web 2>&1 | tail -5`
|
||||
Expected: 无错误。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/routes/mod.rs src/web/pages/Sites.tsx
|
||||
git commit -m "feat(comfy-cloud): 挂载适配路由 + 站点管理 comfy_cloud 平台选项"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 真实联调(手动验证,非自动测试)
|
||||
|
||||
> 这一步需要运行中的 BoosAPI + Pixelle,属手动验证。前 6 个 task 完成且 push 后执行。
|
||||
|
||||
- [ ] **Step 1: 站点管理添加 Comfy Cloud 站点**
|
||||
|
||||
在管理控制台「站点管理」新增站点:平台选 `Comfy Cloud`,URL 填 `https://cloud.comfy.org`,
|
||||
API Key 填有效的 Comfy Cloud key(`X-API-Key`),状态 active。
|
||||
|
||||
- [ ] **Step 2: 改 Pixelle config.yaml 的 comfyui_url**
|
||||
|
||||
把 `/opt/Pixelle-Video/config.yaml` 的 `comfyui.comfyui_url` 改为
|
||||
`http://<boosapi-host>:<port>/comfy-cloud`(指向 BoosAPI 适配端点),
|
||||
然后 `docker compose restart api`(在 `/opt/Pixelle-Video`)。
|
||||
|
||||
- [ ] **Step 3: 跑一次 selfhost 视频生成**
|
||||
|
||||
从前端触发数字人/图片 selfhost 工作流生成视频,确认:
|
||||
- 不再出现 `401 AuthenticationRequired`。
|
||||
- 任务能提交(拿到 prompt_id)、轮询到 completed、取回产出文件。
|
||||
|
||||
- [ ] **Step 4: 失败排查指引(若仍报错)**
|
||||
|
||||
- 仍 401:检查站点 api_key 是否为有效 Comfy Cloud key(`curl -H "X-API-Key: KEY" https://cloud.comfy.org/api/user` 应回 200)。
|
||||
- 404/路径错:确认 Pixelle comfyui_url 末尾无多余 `/`,BoosAPI 适配端点前缀为 `/comfy-cloud`。
|
||||
- 取不到产出:看 BoosAPI 日志里 `/api/jobs/{id}` 的响应 outputs 结构,必要时调 translate 映射。
|
||||
|
||||
---
|
||||
|
||||
## 完成标准
|
||||
|
||||
- 后端:`resolve_comfy_cloud_site` + `comfy_cloud_job_to_history` + 三个适配端点
|
||||
(`/comfy-cloud/{prompt,history,view}`)实现并测试通过,`cargo build -j 2` 干净。
|
||||
- 前端:站点管理可选 `comfy_cloud` 平台,`npm run typecheck:web` 干净。
|
||||
- 联调:Pixelle 指向 BoosAPI 适配端点后,selfhost 视频生成不再 401 且能出片。
|
||||
- 全部提交在 `feat/comfy-cloud-adapter` 分支。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
# 数字人接入 Comfy Cloud 视频工作流(完整视频入口)实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 给 BoosAPI 数字人加「视频工作流」入口,让用户选 `comfy/video_digital_ltx2.json` 触发「肖像图+旁白音频→LTX2.3 说话视频」,贯穿前端→BoosAPI 后端→Pixelle 三层。
|
||||
|
||||
**Architecture:** 前端 `DigitalHuman.tsx` 加可选「视频工作流」选择器(列 `video_*`),提交 `api_video_workflow`;BoosAPI `digital_human.rs` 的 `pixelle_request_body` 透传该字段;Pixelle `asset_based.py` 对 `comfy/` 视频工作流也注入 `audio_path`。视频入口默认空=不启用,保持现有「图片+HTML 合成」向后兼容。
|
||||
|
||||
**Tech Stack:** React/TS(BoosAPI 前端)、Rust/axum(BoosAPI 后端)、Python(Pixelle,容器内)。
|
||||
|
||||
> **环境:** BoosAPI 仓库 `/root/metapi`,前端 `src/web`,后端 `src/routes`。Pixelle 在 Docker 容器 `pixelle-video-api`,源码 `/app/pixelle_video`(镜像内置,改后 `docker compose restart api`,备份到 `/opt/Pixelle-Video/patches/`)。构建:前端 `npm run typecheck:web`,后端 `cargo build -j 2`(-j 2 防 OOM)。分支 `feat/comfy-cloud-adapter`。
|
||||
|
||||
> **前置(已完成):** comfy-cloud 适配器、`comfy/video_digital_ltx2.json`(probe 通过)、media.py 转发 image_path。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
- Modify `src/web/pages/DigitalHuman.tsx` — 加 `apiVideoWorkflow` state + 「视频工作流」选择器 + payload 字段 + draft 持久化。
|
||||
- Modify `src/routes/digital_human.rs` — `pixelle_request_body` 转发 `api_video_workflow`。
|
||||
- Modify(容器内)`/app/pixelle_video/pipelines/asset_based.py` — comfy 视频工作流注入 audio_path。
|
||||
|
||||
---
|
||||
|
||||
<!-- TASKS -->
|
||||
|
||||
## Task 1: 前端加「视频工作流」选择器 + 提交字段
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/web/pages/DigitalHuman.tsx`
|
||||
|
||||
- [ ] **Step 1: `DigitalHumanDraft` type 加字段(~line 34 附近,imageWorkflow 旁)**
|
||||
|
||||
找到 `imageWorkflow?: string;`(约 line 34),其后加一行:
|
||||
```ts
|
||||
apiVideoWorkflow?: string;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 加 state(~line 370,imageWorkflow useState 旁)**
|
||||
|
||||
找到 `const [imageWorkflow, setImageWorkflow] = useState(draftRef.current.imageWorkflow || '');`(约 line 370),其后加:
|
||||
```ts
|
||||
const [apiVideoWorkflow, setApiVideoWorkflow] = useState(draftRef.current.apiVideoWorkflow || '');
|
||||
```
|
||||
|
||||
- [ ] **Step 3: draft 持久化加字段(~line 462 写入 + ~line 480 依赖数组)**
|
||||
|
||||
在 `writeDigitalHumanDraft({...})` 对象里(约 line 462),`imageWorkflow,` 后加 `apiVideoWorkflow,`;
|
||||
在该 useEffect 的依赖数组里(约 line 480),`imageWorkflow,` 后同样加 `apiVideoWorkflow,`。
|
||||
|
||||
- [ ] **Step 4: 提交 payload 加字段(~line 570 payload 对象)**
|
||||
|
||||
找到 payload 对象里的 `media_workflow: imageWorkflow || undefined,`(约 line 575),其后加:
|
||||
```ts
|
||||
api_video_workflow: apiVideoWorkflow || undefined,
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 加「视频工作流」选择器 UI(图片工作流 select 之后,~line 1012)**
|
||||
|
||||
找到「图片工作流」那个 `<label className="digital-human-field">...select...</label>` 块结束处
|
||||
(约 line 1012,`</label>` 后),插入新选择器:
|
||||
```tsx
|
||||
<label className="digital-human-field">
|
||||
<span>视频工作流(可选,生成说话视频)</span>
|
||||
<select value={apiVideoWorkflow} onChange={(event) => setApiVideoWorkflow(event.target.value)}>
|
||||
<option value="">不生成视频(仅图片合成)</option>
|
||||
{mediaWorkflows
|
||||
.filter((wf) => wf.name.toLowerCase().includes('video'))
|
||||
.map((wf) => (
|
||||
<option key={workflowValue(wf)} value={workflowValue(wf)}>{pickItemLabel(wf)}{wf.source ? ` · ${wf.source}` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: typecheck**
|
||||
|
||||
Run: `cd /root/metapi && npm run typecheck:web 2>&1 | tail -5`
|
||||
Expected: 无 TS 错误。
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
cd /root/metapi && git add src/web/pages/DigitalHuman.tsx
|
||||
git commit -m "feat(digital-human): 前端加视频工作流选择器(api_video_workflow)
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
<!-- T2 -->
|
||||
|
||||
## Task 2: BoosAPI 后端转发 api_video_workflow 给 Pixelle
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/routes/digital_human.rs`
|
||||
|
||||
- [ ] **Step 1: 在 pixelle_request_body 加转发(~line 2077,tts_workflow 块之后)**
|
||||
|
||||
找到 `pixelle_request_body` 里转发 tts_workflow 的块(约 line 2073-2078):
|
||||
```rust
|
||||
if let Some(value) = optional_string(body, &["ttsWorkflow", "tts_workflow"])
|
||||
.or_else(|| default_pixelle_tts_workflow(state, backend_provider))
|
||||
{
|
||||
request_body["tts_workflow"] = json!(value);
|
||||
}
|
||||
```
|
||||
在它**之后**插入:
|
||||
```rust
|
||||
if let Some(value) = optional_string(body, &["apiVideoWorkflow", "api_video_workflow"]) {
|
||||
request_body["api_video_workflow"] = json!(value);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 构建确认编译**
|
||||
|
||||
Run: `cd /root/metapi && cargo build -j 2 2>&1 | tail -3`
|
||||
Expected: `Finished`。
|
||||
|
||||
- [ ] **Step 3: 确认 optional_string 接受多 key(它已在文件内大量使用,签名兼容)**
|
||||
|
||||
Run: `cd /root/metapi && rtk proxy grep -n "fn optional_string" src/routes/digital_human.rs | head`
|
||||
Expected: 找到定义,签名为 `(body, keys: &[&str])`(与 Step 1 用法一致)。若签名不同,按实际调整调用形式。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd /root/metapi && git add src/routes/digital_human.rs
|
||||
git commit -m "feat(digital-human): 后端转发 api_video_workflow 给 Pixelle
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
<!-- T3 -->
|
||||
|
||||
## Task 3: Pixelle 给 comfy 视频工作流注入 audio_path
|
||||
|
||||
**Files:**
|
||||
- Modify(容器内):`/app/pixelle_video/pipelines/asset_based.py`
|
||||
|
||||
- [ ] **Step 1: 定位音频注入块(~line 703)**
|
||||
|
||||
Run: `rtk proxy docker exec pixelle-video-api sh -lc "grep -n 'audio_driven_i2v' /app/pixelle_video/pipelines/asset_based.py"`
|
||||
Expected: 命中 `if "audio_driven_i2v" in adapter_abilities:` 那行(约 703)。
|
||||
|
||||
- [ ] **Step 2: 在该判断旁加 comfy 前缀分支(用 python 精确改写)**
|
||||
|
||||
```bash
|
||||
rtk proxy docker exec pixelle-video-api /app/.venv/bin/python -c "
|
||||
p='/app/pixelle_video/pipelines/asset_based.py'
|
||||
src=open(p).read()
|
||||
anchor=' if \"audio_driven_i2v\" in adapter_abilities:\n api_video_params[\"audio_path\"] = frame.audio_path'
|
||||
assert anchor in src, 'anchor not found'
|
||||
add=anchor + '''
|
||||
elif isinstance(api_video_workflow, str) and api_video_workflow.startswith(\"comfy/\"):
|
||||
api_video_params[\"audio_path\"] = frame.audio_path'''
|
||||
if 'api_video_workflow.startswith(\"comfy/\")' in src:
|
||||
print('already patched')
|
||||
else:
|
||||
src=src.replace(anchor, add, 1)
|
||||
open(p,'w').write(src)
|
||||
print('patched')
|
||||
"
|
||||
```
|
||||
Expected: `patched`(或 `already patched`)。
|
||||
|
||||
- [ ] **Step 3: 语法校验 + 重启 + 备份**
|
||||
|
||||
```bash
|
||||
rtk proxy docker exec pixelle-video-api /app/.venv/bin/python -c "import ast; ast.parse(open('/app/pixelle_video/pipelines/asset_based.py').read()); print('syntax OK')"
|
||||
cd /opt/Pixelle-Video && rtk proxy docker compose restart api && sleep 7 && rtk proxy docker ps --filter name=pixelle --format '{{.Names}} {{.Status}}'
|
||||
mkdir -p /opt/Pixelle-Video/patches && rtk proxy docker cp pixelle-video-api:/app/pixelle_video/pipelines/asset_based.py /opt/Pixelle-Video/patches/asset_based.py.patched
|
||||
```
|
||||
Expected: `syntax OK`;容器 healthy;备份文件存在。
|
||||
|
||||
- [ ] **Step 4: 确认改动重启后仍在**
|
||||
|
||||
Run: `rtk proxy docker exec pixelle-video-api sh -lc "grep -c 'api_video_workflow.startswith(\"comfy/\")' /app/pixelle_video/pipelines/asset_based.py"`
|
||||
Expected: `1`。无 git commit(Pixelle 非本仓库)。
|
||||
|
||||
<!-- T4 -->
|
||||
|
||||
## Task 4: 部署 BoosAPI + 端到端联调
|
||||
|
||||
**Files:** 无(部署 + 运行时验证)
|
||||
|
||||
- [ ] **Step 1: 重新构建并重启 BoosAPI(含前端改动)**
|
||||
|
||||
前端改了需重新 build 前端资源,后端改了需重新编译。用项目脚本:
|
||||
```bash
|
||||
cd /root/metapi && ./build-and-run.sh --fast-release 2>&1 | tail -15
|
||||
```
|
||||
> 若只想快速验证后端逻辑、前端单独 build,可 `npm run build:web` + `./build-and-run.sh --backend-only`。
|
||||
> Expected: 构建成功,BoosAPI 起在 :4000。注意这是动线上服务,确认当前无关键流量再重启。
|
||||
|
||||
- [ ] **Step 2: 确认数字人 UI 出现「视频工作流」选择器**
|
||||
|
||||
打开管理控制台数字人页面,确认新增「视频工作流(可选)」下拉,选项含
|
||||
`video_digital_ltx2`(comfy)+ `video_wan2.1_fusionx`(comfy)等 video_* 工作流。
|
||||
|
||||
- [ ] **Step 3: 发起数字人任务,视频工作流选 `comfy/video_digital_ltx2.json`**
|
||||
|
||||
给肖像图 + 文本 + 选 TTS,视频工作流选 `comfy/video_digital_ltx2.json`,提交。
|
||||
|
||||
- [ ] **Step 4: 监控流转(BoosAPI 日志 + Comfy Cloud job)**
|
||||
|
||||
```bash
|
||||
cd /root/metapi && rtk proxy sqlite3 boosapi.db "SELECT id, created_at, client_family, http_status, substr(error_message,1,80) FROM proxy_logs ORDER BY id DESC LIMIT 5;"
|
||||
```
|
||||
Expected: 新日志(id 增长),数字人任务推进;Comfy Cloud 出现 LTX2.3 job;最终出说话视频。
|
||||
|
||||
- [ ] **Step 5: 出错归因**
|
||||
|
||||
- LoadImage 仍用默认图 → 查 media.py image_path 转发(Task 已完成,确认 `grep image_path /app/pixelle_video/services/media.py`)。
|
||||
- LoadAudio 无音频 → 查 Task 3 注入(`grep comfy/ /app/pixelle_video/pipelines/asset_based.py`)+ workflow 标记 `$~audio_path!`。
|
||||
- `api_video_workflow` 没传到 → 查 Task 2 后端转发 + Task 1 前端 payload 字段名(`api_video_workflow`)。
|
||||
- 节点/模型错 → 不应发生(probe 已过);若发生看 Comfy Cloud 真实回包。
|
||||
|
||||
- [ ] **Step 6: 成功标准**
|
||||
|
||||
数字人任务选 comfy/video_digital_ltx2.json 后,Comfy Cloud 出真实 job,注入了
|
||||
image_path+audio_path+prompt,返回带音频说话视频,数字人成片。
|
||||
|
||||
---
|
||||
|
||||
## 完成标准
|
||||
|
||||
- 前端:数字人页有「视频工作流」可选下拉,列 video_* 工作流,typecheck 干净。
|
||||
- 后端:`api_video_workflow` 透传给 Pixelle,`cargo build -j 2` 通过。
|
||||
- Pixelle:comfy 视频工作流注入 audio_path,重启生效,备份 patches/。
|
||||
- 端到端:选 comfy/video_digital_ltx2.json 出说话视频。
|
||||
- 向后兼容:不选视频工作流时,现有「图片+HTML 合成」行为不变。
|
||||
|
||||
## 已知风险 / 备注
|
||||
|
||||
- Pixelle asset_based.py 改动在镜像内置层,重建镜像会丢,已备份 patches/,长期应固化 Dockerfile。
|
||||
- Task 4 动线上服务(重启 BoosAPI),确认时机。
|
||||
- 单人肖像;多人不在范围。
|
||||
- LTX2.3 执行时长/分辨率等真实生成参数,若出片质量/超时问题属调参,非本计划的接线问题。
|
||||
|
||||
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
# LTX2.3 数字人工作流(Comfy Cloud)实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 让 Pixelle 数字人(说话头像)走 Comfy Cloud:把官方 LTX2.3 id_lora 模板做成 `comfy/digital_ltx2.json` 工作流,补上 Pixelle media.py 的 image_path 转发缺口,经已建好的 comfy-cloud 适配器执行。
|
||||
|
||||
**Architecture:** 工作流文件落 Pixelle 宿主 `data/workflows/comfy/`(经 `./data` 挂载),用 comfykit 的 `_meta.title` 标记(`$~image_path!`/`$~audio_path!`/`$prompt!`)注入肖像/音频/脚本;media.py 非 api 分支补 image_path 进 workflow_params;comfy/ 通道经 `comfyui_url`(=BoosAPI `/comfy-cloud` 适配器)打到 Comfy Cloud。
|
||||
|
||||
**Tech Stack:** Python (Pixelle/comfykit), ComfyUI API-format JSON, Comfy Cloud API, Docker。
|
||||
|
||||
> **环境约定:** Pixelle 跑在 Docker 容器 `pixelle-video-api`,代码 `/app/pixelle_video/`,工作流 `/app/data/workflows/`(宿主 `/opt/Pixelle-Video/data/workflows/`,bind mount,改宿主即生效)。容器内 python:`/app/.venv/bin/python`。BoosAPI 适配端点:容器内经 `http://172.17.0.1:4000/comfy-cloud`,宿主经 `http://127.0.0.1:4000/comfy-cloud`。改 media.py 后需 `docker compose restart api`。
|
||||
|
||||
> **硬依赖(Task 1 阻塞全局):** 需用户从 Comfy Cloud 网页导出 `video_ltx2_3_id_lora` 的 **API 格式** JSON 到 `/tmp/ltx2_api.json`。服务端无法代取。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
- 用户提供:`/tmp/ltx2_api.json` —— Comfy Cloud 导出的 API 格式工作流(子图已展开)。
|
||||
- Create `/opt/Pixelle-Video/data/workflows/comfy/digital_ltx2.json` —— 改造后的数字人工作流(加标记、删 RecordAudio)。
|
||||
- Modify(容器内)`/app/pixelle_video/services/media.py` —— 非 api 分支把 image_path 加入 workflow_params;同步宿主持久化方案。
|
||||
- 临时:`/tmp/digital_ltx2_probe.json` —— 校验探测用 payload。
|
||||
|
||||
---
|
||||
|
||||
<!-- TASKS -->
|
||||
|
||||
## Task 1: 获取并校验 API 格式导出(硬依赖)
|
||||
|
||||
**Files:**
|
||||
- 用户提供:`/tmp/ltx2_api.json`
|
||||
|
||||
- [ ] **Step 1: 确认导出文件存在且是 API 格式**
|
||||
|
||||
用户在 Comfy Cloud 网页打开 `video_ltx2_3_id_lora`(或 share `71f221d28df8`)→ Workflow → Export (API Format) → 放到 `/tmp/ltx2_api.json`。
|
||||
|
||||
Run: `rtk proxy docker exec pixelle-video-api sh -lc "ls -la /tmp/ltx2_api.json 2>/dev/null || echo MISSING"`
|
||||
注意:文件在宿主还是容器?用户可能放宿主 `/tmp`。两处都查:
|
||||
Run(宿主): `ls -la /tmp/ltx2_api.json 2>/dev/null || echo HOST_MISSING`
|
||||
Expected: 文件存在(任一处)。若两处都 MISSING → BLOCKED,等用户提供。
|
||||
|
||||
- [ ] **Step 2: 校验是 API 格式(不是 UI 格式)**
|
||||
|
||||
API 格式是 `{节点id: {class_type, inputs, ...}}`,无顶层 `nodes` 数组。
|
||||
|
||||
Run:
|
||||
```bash
|
||||
python3 -c "
|
||||
import json
|
||||
wf=json.load(open('/tmp/ltx2_api.json'))
|
||||
is_ui = isinstance(wf,dict) and 'nodes' in wf
|
||||
print('UI格式(需重导)' if is_ui else 'API格式 OK')
|
||||
if not is_ui:
|
||||
print('节点数:', len(wf))
|
||||
has_sub = any('subgraph' in str(v.get('class_type','')).lower() for v in wf.values() if isinstance(v,dict))
|
||||
print('含未展开子图?', has_sub)
|
||||
"
|
||||
```
|
||||
Expected: `API格式 OK`,节点数 >10,无未展开子图。若是 UI 格式或含子图 → 让用户用 **API Format** 重新导出(普通 Export 是 UI 格式)。
|
||||
|
||||
- [ ] **Step 3: 列出节点类型 + 模型引用,核对 Comfy Cloud 兼容**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
python3 -c "
|
||||
import json
|
||||
wf=json.load(open('/tmp/ltx2_api.json'))
|
||||
from collections import Counter
|
||||
c=Counter(v.get('class_type') for v in wf.values() if isinstance(v,dict))
|
||||
print('节点类型:')
|
||||
for t,n in c.most_common(): print(f' {n}x {t}')
|
||||
print('--- 模型引用 ---')
|
||||
for nid,nd in wf.items():
|
||||
if not isinstance(nd,dict): continue
|
||||
for k,v in (nd.get('inputs') or {}).items():
|
||||
if isinstance(v,str) and v.endswith('.safetensors'):
|
||||
print(f' {nid}.{k} = {v}')
|
||||
"
|
||||
```
|
||||
Expected: 看到 LTX2.3 节点 + 模型文件名。记录下来,Task 2 核对它们在 Comfy Cloud 池里(之前已验证 ltx-2.3-* 系列在;若导出引用了不同文件名,需在此对照 `/tmp/cc_nodes.json` 的池子)。
|
||||
|
||||
- [ ] **Step 4: 无需提交(纯校验,无文件改动)**
|
||||
|
||||
本任务只确认输入就绪。无 commit。
|
||||
|
||||
<!-- T2 -->
|
||||
|
||||
## Task 2: 改造工作流(加标记 + 删 RecordAudio + 落 comfy/)
|
||||
|
||||
**Files:**
|
||||
- Create: `/opt/Pixelle-Video/data/workflows/comfy/digital_ltx2.json`
|
||||
|
||||
- [ ] **Step 1: 找到要标记的节点 id(LoadImage/LoadAudio/文本/RecordAudio)**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
python3 -c "
|
||||
import json
|
||||
wf=json.load(open('/tmp/ltx2_api.json'))
|
||||
for nid,nd in wf.items():
|
||||
if not isinstance(nd,dict): continue
|
||||
ct=nd.get('class_type','')
|
||||
if ct in ('LoadImage','LoadAudio','RecordAudio','SaveVideo','PrimitiveStringMultiline','CLIPTextEncode'):
|
||||
print(nid, ct, '| inputs:', json.dumps(nd.get('inputs',{}),ensure_ascii=False)[:100])
|
||||
"
|
||||
```
|
||||
Expected: 看到 LoadImage、LoadAudio、RecordAudio、SaveVideo、文本节点各自的 id。记下:
|
||||
- LoadImage 的 id(标 `$~image_path!`)
|
||||
- LoadAudio 的 id(标 `$~audio_path!`)
|
||||
- 文本提示节点 id(标 `$prompt!`)—— 优先 `PrimitiveStringMultiline`;若文本经 CLIPTextEncode 的 `text` 字段直接给,则标 CLIPTextEncode
|
||||
- RecordAudio 的 id(删除)
|
||||
|
||||
- [ ] **Step 2: 写改造脚本生成 comfy/digital_ltx2.json**
|
||||
|
||||
> 把下面 `IMAGE_ID`/`AUDIO_ID`/`TEXT_ID`/`RECORD_ID` 替换为 Step 1 查到的实际 id。
|
||||
> 文本节点字段名:若 PrimitiveStringMultiline 用 `value` 字段,标记写 `$prompt.value!`;
|
||||
> 若 CLIPTextEncode 用 `text` 字段,写 `$prompt.text!`。按 Step 1 实际字段定。
|
||||
|
||||
```bash
|
||||
python3 -c "
|
||||
import json
|
||||
wf=json.load(open('/tmp/ltx2_api.json'))
|
||||
|
||||
IMAGE_ID='REPLACE_IMAGE_ID'
|
||||
AUDIO_ID='REPLACE_AUDIO_ID'
|
||||
TEXT_ID='REPLACE_TEXT_ID'
|
||||
TEXT_MARKER='\$prompt.value!' # 或 \$prompt.text! 视字段而定
|
||||
RECORD_ID='REPLACE_RECORD_ID' # 若无 RecordAudio 留空字符串
|
||||
|
||||
def set_title(nid, marker):
|
||||
nd=wf[nid]
|
||||
nd.setdefault('_meta',{})['title']=marker
|
||||
|
||||
set_title(IMAGE_ID, '\$~image_path!')
|
||||
set_title(AUDIO_ID, '\$~audio_path!')
|
||||
set_title(TEXT_ID, TEXT_MARKER)
|
||||
|
||||
# 删 RecordAudio 并清理引用它的连线(把指向它的输入置空/删除)
|
||||
if RECORD_ID and RECORD_ID in wf:
|
||||
wf.pop(RECORD_ID)
|
||||
for nid,nd in wf.items():
|
||||
if not isinstance(nd,dict): continue
|
||||
for k,v in list((nd.get('inputs') or {}).items()):
|
||||
if isinstance(v,list) and len(v)==2 and str(v[0])==RECORD_ID:
|
||||
# 该输入原本接 RecordAudio;数字人用 LoadAudio,删除这个悬空连接
|
||||
del nd['inputs'][k]
|
||||
|
||||
import os
|
||||
os.makedirs('/opt/Pixelle-Video/data/workflows/comfy', exist_ok=True)
|
||||
json.dump(wf, open('/opt/Pixelle-Video/data/workflows/comfy/digital_ltx2.json','w'), ensure_ascii=False, indent=2)
|
||||
print('written, 节点数:', len(wf))
|
||||
# 校验标记落位
|
||||
for nid in [IMAGE_ID,AUDIO_ID,TEXT_ID]:
|
||||
print(nid, '->', wf.get(nid,{}).get('_meta',{}).get('title'))
|
||||
"
|
||||
```
|
||||
Expected: 写入成功,三个标记落位正确,RecordAudio 已删。
|
||||
|
||||
> ⚠️ RecordAudio 与 LoadAudio 的关系:数字人用 LoadAudio 输入旁白。若导出的图里音频源是 RecordAudio 而非 LoadAudio(录音节点直接接到音频处理),则不能简单删 RecordAudio——要把音频处理节点的输入改接到 LoadAudio。Step 1 查清音频实际从哪个节点流出;若是 RecordAudio 流出,本步改为:保留一个 LoadAudio(标 `$~audio_path!`)并把下游音频输入接到 LoadAudio 的输出。具体连接以 Step 1 的图为准。
|
||||
|
||||
- [ ] **Step 3: 校验生成的 JSON 合法 + comfykit 能解析出参数**
|
||||
|
||||
Run(容器内,用 comfykit 解析):
|
||||
```bash
|
||||
rtk proxy docker exec pixelle-video-api /app/.venv/bin/python -c "
|
||||
import json
|
||||
from comfykit.comfyui.workflow_parser import WorkflowParser
|
||||
wf=json.load(open('/app/data/workflows/comfy/digital_ltx2.json'))
|
||||
p=WorkflowParser()
|
||||
params=[]
|
||||
for nid,nd in wf.items():
|
||||
if not isinstance(nd,dict): continue
|
||||
pl,ml,om=p.parse_node(nid,nd)
|
||||
for x in pl: params.append((x.name,x.required,x.need_upload))
|
||||
print('解析出的参数:', params)
|
||||
"
|
||||
```
|
||||
Expected: 看到 `image_path`(upload=True)、`audio_path`(upload=True)、`prompt` 三个参数。
|
||||
|
||||
- [ ] **Step 4: Commit(工作流文件在 Pixelle 宿主目录,非 BoosAPI 仓库——仅记录到计划备注)**
|
||||
|
||||
工作流文件不在 git 仓库内。无 git commit;在此记录文件已落 `/opt/Pixelle-Video/data/workflows/comfy/digital_ltx2.json`。
|
||||
|
||||
<!-- T3 -->
|
||||
|
||||
## Task 3: 补 media.py 转发 image_path 到 comfy 通道
|
||||
|
||||
**Files:**
|
||||
- Modify(容器内):`/app/pixelle_video/services/media.py`
|
||||
|
||||
> 注:`pixelle_video` 源码**在镜像内置,不在挂载卷**。容器内改 `docker compose restart` 能留,
|
||||
> 但 `docker compose down && up`/重建镜像会丢。持久化方案见 Step 4。
|
||||
|
||||
- [ ] **Step 1: 定位 workflow_params 构建处**
|
||||
|
||||
`media.py` 的 `__call__` 里,api/ 分支之后、`workflow_params = {"prompt": prompt}` 起的那段
|
||||
(约 line 232)。`image_path` 是 `__call__` 的具名参数但没进 `workflow_params`。
|
||||
|
||||
Run: `rtk proxy docker exec pixelle-video-api sh -lc "grep -n 'workflow_params = {\"prompt\"' /app/pixelle_video/services/media.py"`
|
||||
Expected: 定位到该行号(约 232)。
|
||||
|
||||
- [ ] **Step 2: 在 workflow_params 构建处加入 image_path**
|
||||
|
||||
在 `workflow_params = {"prompt": prompt}` 之后插入(用 sed 或 python 改文件)。注意这是容器内文件,
|
||||
用 `docker exec ... python` 做精确插入最稳妥:
|
||||
|
||||
```bash
|
||||
rtk proxy docker exec pixelle-video-api /app/.venv/bin/python -c "
|
||||
p='/app/pixelle_video/services/media.py'
|
||||
src=open(p).read()
|
||||
anchor='workflow_params = {\"prompt\": prompt}'
|
||||
assert anchor in src, 'anchor not found'
|
||||
add='''workflow_params = {\"prompt\": prompt}
|
||||
if image_path is not None:
|
||||
workflow_params[\"image_path\"] = image_path'''
|
||||
if 'workflow_params[\"image_path\"]' in src:
|
||||
print('already patched')
|
||||
else:
|
||||
src=src.replace(anchor, add, 1)
|
||||
open(p,'w').write(src)
|
||||
print('patched')
|
||||
"
|
||||
```
|
||||
Expected: `patched`(或 `already patched`)。
|
||||
|
||||
- [ ] **Step 3: 校验语法 + 重启容器**
|
||||
|
||||
```bash
|
||||
rtk proxy docker exec pixelle-video-api /app/.venv/bin/python -c "import ast; ast.parse(open('/app/pixelle_video/services/media.py').read()); print('syntax OK')"
|
||||
cd /opt/Pixelle-Video && rtk proxy docker compose restart api && sleep 7 && rtk proxy docker ps --filter name=pixelle --format '{{.Names}} {{.Status}}'
|
||||
```
|
||||
Expected: `syntax OK`;容器 `Up ... (healthy)`。
|
||||
|
||||
- [ ] **Step 4: 持久化方案(防镜像重建丢失)**
|
||||
|
||||
把改动备份到宿主,并记录到 Pixelle 部署说明:
|
||||
```bash
|
||||
rtk proxy docker cp pixelle-video-api:/app/pixelle_video/services/media.py /opt/Pixelle-Video/patches/media.py.patched 2>/dev/null || (mkdir -p /opt/Pixelle-Video/patches && rtk proxy docker cp pixelle-video-api:/app/pixelle_video/services/media.py /opt/Pixelle-Video/patches/media.py.patched)
|
||||
echo "已备份改后 media.py 到 /opt/Pixelle-Video/patches/;重建镜像后需重新 docker cp 回 /app/pixelle_video/services/media.py 或固化进 Dockerfile"
|
||||
```
|
||||
Expected: 备份文件存在。无 git commit(Pixelle 非本仓库)。
|
||||
|
||||
<!-- T4 -->
|
||||
|
||||
## Task 4: 校验探测(确认 Comfy Cloud 接受工作流)
|
||||
|
||||
**Files:**
|
||||
- 临时:`/tmp/digital_ltx2_probe.json`
|
||||
|
||||
- [ ] **Step 1: 用占位输入构建 probe payload**
|
||||
|
||||
注入标记的字段需要具体值才能提交校验。用占位文件名探测(校验在执行前,不真正读文件):
|
||||
```bash
|
||||
python3 -c "
|
||||
import json
|
||||
wf=json.load(open('/opt/Pixelle-Video/data/workflows/comfy/digital_ltx2.json'))
|
||||
# probe 直接提交节点图本身(标记字段已有默认值或占位);comfykit 注入是运行时的事,
|
||||
# 这里验证 Comfy Cloud 是否接受这套节点+模型
|
||||
json.dump({'prompt':wf}, open('/tmp/digital_ltx2_probe.json','w'), ensure_ascii=False)
|
||||
print('probe payload bytes:', len(open('/tmp/digital_ltx2_probe.json').read()))
|
||||
"
|
||||
```
|
||||
Expected: payload 生成。
|
||||
|
||||
> 注:工作流里 LoadImage/LoadAudio 的默认 widget 值(模板自带的示例文件名)可能在 Comfy Cloud
|
||||
> 不存在,导致校验报"文件不存在"。这是**预期且可接受**的——它证明节点+模型校验已过,只差真实输入。
|
||||
> 关注点是有没有 `unsupported node type` / 模型缺失类错误。
|
||||
|
||||
- [ ] **Step 2: 提交探测,读校验结果**
|
||||
|
||||
```bash
|
||||
curl -s -w "\nHTTP %{http_code}\n" -X POST http://127.0.0.1:4000/comfy-cloud/prompt -H "Content-Type: application/json" --data @/tmp/digital_ltx2_probe.json 2>&1 | head -15
|
||||
```
|
||||
Expected 判读:
|
||||
- `{"node_errors":{},"prompt_id":"..."}` HTTP 200 → 完美,全兼容。
|
||||
- `400 ... unsupported node type 'X'` → 节点 X 不在 Comfy Cloud,需在工作流里替换/移除该节点(回 Task 2 处理)。
|
||||
- `400 ... 文件/模型 不存在`(针对 LoadImage 示例图或某 .safetensors)→ 若是输入文件(LoadImage 默认图)属预期;若是 .safetensors 模型缺失,需换 Comfy Cloud 有的等价模型(对照 `/tmp/cc_nodes.json` 池)。
|
||||
|
||||
- [ ] **Step 3: 据结果迭代或放行**
|
||||
|
||||
- 全兼容 → 进 Task 5。
|
||||
- 节点/模型问题 → 回 Task 2 调整工作流(换节点或模型名),重跑 Task 4。不要跳过。
|
||||
|
||||
<!-- T5 -->
|
||||
|
||||
## Task 5: 真实数字人任务联调
|
||||
|
||||
**Files:** 无(运行时验证)
|
||||
|
||||
- [ ] **Step 1: 在 Pixelle 前端发起数字人任务,视频/动画工作流选 `comfy/digital_ltx2.json`**
|
||||
|
||||
数字人流程的 image→video 动画步(`api_video_workflow` 或对应 UI 项)填/选 `comfy/digital_ltx2.json`。
|
||||
触发一个任务(给个肖像图 + 文本)。
|
||||
|
||||
- [ ] **Step 2: 观察任务流转**
|
||||
|
||||
预期:LLM 脚本步 → 生成肖像图(comfy 图片工作流)+ TTS 音频 → digital_ltx2 动画步:
|
||||
注入 image_path/audio_path/prompt → Comfy Cloud 建 job → 轮询 completed → 取回说话视频 → HTML 合成。
|
||||
|
||||
监控 BoosAPI 侧 proxy_logs:
|
||||
```bash
|
||||
rtk proxy sqlite3 boosapi.db "SELECT id, created_at, client_family, http_status, substr(error_message,1,80) FROM proxy_logs ORDER BY id DESC LIMIT 5;"
|
||||
```
|
||||
Expected: 新日志(id 增长),数字人任务最终 success / 出片。
|
||||
|
||||
- [ ] **Step 3: 出错则按错误类型归因**
|
||||
|
||||
- `unsupported node` / 模型缺失 → 回 Task 2/4。
|
||||
- image_path 未注入(LoadImage 仍用默认图)→ 回 Task 3 确认 media.py 改动生效(`grep image_path /app/.../media.py`)+ Task 2 确认 `$~image_path!` 标记落位。
|
||||
- 执行期失败(Comfy Cloud job failed)→ 真实生成问题(参数/分辨率/时长),据 job 错误调工作流默认值。
|
||||
|
||||
- [ ] **Step 4: 成功标准**
|
||||
|
||||
数字人任务在 Comfy Cloud 出现真实 job、跑完、返回带音频的说话视频,Pixelle 合成出片。
|
||||
|
||||
---
|
||||
|
||||
## 完成标准
|
||||
|
||||
- `comfy/digital_ltx2.json` 落地、comfykit 能解析出 image_path/audio_path/prompt 三参数。
|
||||
- media.py 已转发 image_path 到 comfy 通道(并备份到宿主 patches/)。
|
||||
- 校验探测确认 Comfy Cloud 接受工作流(节点+模型兼容)。
|
||||
- 真实数字人任务经 comfy/Comfy Cloud 出片。
|
||||
|
||||
## 已知风险 / 备注
|
||||
|
||||
- 硬依赖用户导出 API JSON(Task 1);拿不到则全卡。
|
||||
- media.py 改动在镜像内置层,重建镜像会丢,已备份到 `/opt/Pixelle-Video/patches/`,长期应固化进 Dockerfile/启动脚本。
|
||||
- 工作流默认 widget 值(示例图/音频文件名)在 Comfy Cloud 不存在属正常,真实运行靠注入覆盖。
|
||||
- 单人肖像;多人对话不在范围。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,503 +0,0 @@
|
||||
# Pixelle 连接凭证纳入 BoosAPI 管理 实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在 BoosAPI 连接管理页加「Pixelle 连接」卡片,管 runninghub_api_key / comfyui_url / comfyui_api_key 三个凭证,保存即写入 settings 表 + 同步 Pixelle config.yaml + 重启 pixelle 容器生效。
|
||||
|
||||
**Architecture:** 新增 service `pixelle_connection.rs`(读写 settings 表 + 改 config.yaml YAML 三键 + docker restart)+ admin 路由(GET/PUT,密钥掩码)+ 前端 Accounts 页卡片。settings 表为真相源,config.yaml 是同步目标。
|
||||
|
||||
**Tech Stack:** Rust(axum, sqlx, serde_yaml<新增依赖>, tokio::process)、React/TS。
|
||||
|
||||
> **构建/测试:** `cargo build -j 2`(-j2 防 OOM)、`cargo test -p boosapi --lib pixelle_connection`、`npm run typecheck:web`。分支 `feat/comfy-cloud-adapter`。Pixelle compose 目录 `/opt/Pixelle-Video`,config `/opt/Pixelle-Video/config.yaml`。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
- Modify `Cargo.toml` — 加 `serde_yaml` 依赖。
|
||||
- Modify `src/config.rs` — `PixelleVideoConfig` 加 `config_path` / `compose_dir` 两字段(默认 `/opt/Pixelle-Video/config.yaml`、`/opt/Pixelle-Video`)。
|
||||
- Create `src/services/pixelle_connection.rs` — read/save settings + sync_to_pixelle(改 yaml)+ restart。
|
||||
- Modify `src/services/mod.rs` — `pub mod pixelle_connection;`。
|
||||
- Create `src/routes/admin/pixelle_connection.rs` — GET/PUT handler(掩码)。
|
||||
- Modify `src/routes/admin/mod.rs` — 声明子模块 + 挂 2 条路由。
|
||||
- Modify `src/web/api.ts` — adminApi 加 getPixelleConnection / savePixelleConnection。
|
||||
- Modify `src/web/pages/Accounts.tsx` — 加「Pixelle 连接」卡片。
|
||||
|
||||
---
|
||||
|
||||
<!-- TASKS -->
|
||||
|
||||
## Task 1: 加 serde_yaml 依赖 + config 路径字段
|
||||
|
||||
**Files:**
|
||||
- Modify: `Cargo.toml`
|
||||
- Modify: `src/config.rs`
|
||||
|
||||
- [ ] **Step 1: Cargo.toml 加 serde_yaml**
|
||||
|
||||
在 `[dependencies]` 段加(版本对齐项目 serde 生态,用 0.9):
|
||||
```toml
|
||||
serde_yaml = "0.9"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: PixelleVideoConfig 加两字段(src/config.rs,struct 内 timeout_secs 后)**
|
||||
|
||||
在 `pub timeout_secs: u64,` 之后加:
|
||||
```rust
|
||||
#[serde(default = "default_pixelle_config_path")]
|
||||
pub config_path: String,
|
||||
#[serde(default = "default_pixelle_compose_dir")]
|
||||
pub compose_dir: String,
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 加默认值函数(src/config.rs,default_pixelle_backend_provider 附近)**
|
||||
|
||||
```rust
|
||||
fn default_pixelle_config_path() -> String {
|
||||
"/opt/Pixelle-Video/config.yaml".to_string()
|
||||
}
|
||||
fn default_pixelle_compose_dir() -> String {
|
||||
"/opt/Pixelle-Video".to_string()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: PixelleVideoConfig::default() 补两字段**
|
||||
|
||||
找到 `impl Default for ...` 或 `PixelleVideoConfig::default()`(约 line 207 区域,有 `api_base_url: Some(...)` 那个),在其字段列表末尾加:
|
||||
```rust
|
||||
config_path: default_pixelle_config_path(),
|
||||
compose_dir: default_pixelle_compose_dir(),
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 构建确认**
|
||||
|
||||
Run: `cd /root/metapi && cargo build -j 2 2>&1 | tail -3`
|
||||
Expected: `Finished`。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
cd /root/metapi && git add Cargo.toml Cargo.lock src/config.rs
|
||||
git commit -m "feat(pixelle): 加 serde_yaml + Pixelle config 路径配置项
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
<!-- T2 -->
|
||||
|
||||
## Task 2: pixelle_connection service(settings + yaml 同步)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/services/pixelle_connection.rs`
|
||||
- Modify: `src/services/mod.rs`
|
||||
|
||||
- [ ] **Step 1: src/services/mod.rs 加模块声明**
|
||||
|
||||
```rust
|
||||
pub mod pixelle_connection;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 写失败测试 `src/services/pixelle_connection.rs`(含结构 + sync 测试)**
|
||||
|
||||
```rust
|
||||
use crate::error::{AppError, AppResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PixelleConnection {
|
||||
pub runninghub_api_key: Option<String>,
|
||||
pub comfyui_url: Option<String>,
|
||||
pub comfyui_api_key: Option<String>,
|
||||
}
|
||||
|
||||
const KEY_RH: &str = "pixelle.runninghub_api_key";
|
||||
const KEY_URL: &str = "pixelle.comfyui_url";
|
||||
const KEY_CK: &str = "pixelle.comfyui_api_key";
|
||||
|
||||
pub async fn read_connection(db: &SqlitePool) -> AppResult<PixelleConnection> {
|
||||
async fn get(db: &SqlitePool, k: &str) -> AppResult<Option<String>> {
|
||||
Ok(sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
|
||||
.bind(k).fetch_optional(db).await?)
|
||||
}
|
||||
Ok(PixelleConnection {
|
||||
runninghub_api_key: get(db, KEY_RH).await?,
|
||||
comfyui_url: get(db, KEY_URL).await?,
|
||||
comfyui_api_key: get(db, KEY_CK).await?,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn save_connection(db: &SqlitePool, conn: &PixelleConnection) -> AppResult<()> {
|
||||
async fn put(db: &SqlitePool, k: &str, v: &Option<String>) -> AppResult<()> {
|
||||
if let Some(val) = v {
|
||||
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
||||
.bind(k).bind(val).execute(db).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
put(db, KEY_RH, &conn.runninghub_api_key).await?;
|
||||
put(db, KEY_URL, &conn.comfyui_url).await?;
|
||||
put(db, KEY_CK, &conn.comfyui_api_key).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 改 config.yaml 的 comfyui.{runninghub_api_key,comfyui_url,comfyui_api_key} 三键,其余保留。
|
||||
/// 改前备份到 <path>.bak-<ts>。返回备份路径。
|
||||
pub fn sync_to_pixelle(config_path: &str, conn: &PixelleConnection) -> AppResult<String> {
|
||||
let content = std::fs::read_to_string(config_path)
|
||||
.map_err(|e| AppError::ServiceUnavailable(format!("读取 Pixelle config 失败: {e}")))?;
|
||||
let mut doc: serde_yaml::Value = serde_yaml::from_str(&content)
|
||||
.map_err(|e| AppError::ServiceUnavailable(format!("解析 config.yaml 失败: {e}")))?;
|
||||
let comfyui = doc.get_mut("comfyui")
|
||||
.ok_or_else(|| AppError::ServiceUnavailable("config.yaml 缺少 comfyui 段".into()))?;
|
||||
let map = comfyui.as_mapping_mut()
|
||||
.ok_or_else(|| AppError::ServiceUnavailable("comfyui 段不是映射".into()))?;
|
||||
if let Some(v) = &conn.runninghub_api_key {
|
||||
map.insert("runninghub_api_key".into(), serde_yaml::Value::String(v.clone()));
|
||||
}
|
||||
if let Some(v) = &conn.comfyui_url {
|
||||
map.insert("comfyui_url".into(), serde_yaml::Value::String(v.clone()));
|
||||
}
|
||||
if let Some(v) = &conn.comfyui_api_key {
|
||||
map.insert("comfyui_api_key".into(), serde_yaml::Value::String(v.clone()));
|
||||
}
|
||||
let ts = chrono::Utc::now().format("%Y%m%d%H%M%S").to_string();
|
||||
let backup = format!("{config_path}.bak-{ts}");
|
||||
std::fs::copy(config_path, &backup)
|
||||
.map_err(|e| AppError::ServiceUnavailable(format!("备份失败: {e}")))?;
|
||||
let out = serde_yaml::to_string(&doc)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
||||
std::fs::write(config_path, out)
|
||||
.map_err(|e| AppError::ServiceUnavailable(format!("写入 config 失败: {e}")))?;
|
||||
Ok(backup)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sync_updates_three_keys_preserves_rest() {
|
||||
let dir = std::env::temp_dir().join(format!("pxtest_{}", uuid::Uuid::new_v4().simple()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("config.yaml");
|
||||
std::fs::write(&path,
|
||||
"project_name: Pixelle-Video\ncomfyui:\n comfyui_url: https://old\n runninghub_api_key: oldkey\n comfyui_api_key: oldck\n runninghub_concurrent_limit: 1\n").unwrap();
|
||||
let conn = PixelleConnection {
|
||||
runninghub_api_key: Some("newkey".into()),
|
||||
comfyui_url: Some("http://new".into()),
|
||||
comfyui_api_key: Some("newck".into()),
|
||||
};
|
||||
let backup = sync_to_pixelle(path.to_str().unwrap(), &conn).unwrap();
|
||||
assert!(std::path::Path::new(&backup).exists());
|
||||
let after = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(after.contains("newkey"));
|
||||
assert!(after.contains("http://new"));
|
||||
assert!(after.contains("newck"));
|
||||
// 保留其余键
|
||||
assert!(after.contains("runninghub_concurrent_limit"));
|
||||
assert!(after.contains("project_name"));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 运行测试,确认失败**
|
||||
|
||||
Run: `cd /root/metapi && cargo test -p boosapi --lib services::pixelle_connection 2>&1 | tail -15`
|
||||
Expected: FAIL —— 模块/函数未定义(首次编译错误)。
|
||||
|
||||
- [ ] **Step 4: 实现**
|
||||
|
||||
Step 2 的代码即实现(测试与实现同文件)。确保 mod.rs 已声明。
|
||||
|
||||
- [ ] **Step 5: 运行测试,确认通过**
|
||||
|
||||
Run: `cd /root/metapi && cargo test -p boosapi --lib services::pixelle_connection 2>&1 | tail -10`
|
||||
Expected: PASS(1 个测试)。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
cd /root/metapi && git add src/services/mod.rs src/services/pixelle_connection.rs
|
||||
git commit -m "feat(pixelle): 连接凭证 service(settings 读写 + config.yaml 同步)
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
<!-- T3 -->
|
||||
|
||||
## Task 3: restart helper + admin 路由(掩码)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/services/pixelle_connection.rs`(加 restart)
|
||||
- Create: `src/routes/admin/pixelle_connection.rs`
|
||||
- Modify: `src/routes/admin/mod.rs`
|
||||
|
||||
- [ ] **Step 1: 加 restart_pixelle(pixelle_connection.rs,sync_to_pixelle 之后)**
|
||||
|
||||
```rust
|
||||
pub async fn restart_pixelle(compose_dir: &str) -> AppResult<()> {
|
||||
let output = tokio::process::Command::new("docker")
|
||||
.args(["compose", "restart", "api"])
|
||||
.current_dir(compose_dir)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| AppError::ServiceUnavailable(format!("docker compose restart 启动失败: {e}")))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(AppError::ServiceUnavailable(format!(
|
||||
"Pixelle 重启失败: {}", stderr.trim()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 写 admin 路由 `src/routes/admin/pixelle_connection.rs`**
|
||||
|
||||
```rust
|
||||
use crate::error::AppResult;
|
||||
use crate::services::pixelle_connection::{self, PixelleConnection};
|
||||
use crate::AppState;
|
||||
use axum::{extract::State, Json};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const MASK_SENTINEL: &str = "••••";
|
||||
|
||||
fn mask(v: &Option<String>) -> Value {
|
||||
match v {
|
||||
Some(s) if s.len() > 4 => json!(format!("{}{}", MASK_SENTINEL, &s[s.len()-4..])),
|
||||
Some(s) if !s.is_empty() => json!(MASK_SENTINEL),
|
||||
_ => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_connection(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let c = pixelle_connection::read_connection(&state.db).await?;
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"connection": {
|
||||
// url 不算敏感,明文回;两个 key 掩码
|
||||
"comfyuiUrl": c.comfyui_url,
|
||||
"runninghubApiKey": mask(&c.runninghub_api_key),
|
||||
"comfyuiApiKey": mask(&c.comfyui_api_key),
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdatePayload {
|
||||
runninghub_api_key: Option<String>,
|
||||
comfyui_url: Option<String>,
|
||||
comfyui_api_key: Option<String>,
|
||||
}
|
||||
|
||||
fn keep_if_not_mask(incoming: Option<String>) -> Option<String> {
|
||||
// 掩码值(以 •••• 开头)表示"未修改",跳过
|
||||
match incoming {
|
||||
Some(s) if s.starts_with(MASK_SENTINEL) => None,
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn put_connection(
|
||||
State(state): State<AppState>,
|
||||
Json(p): Json<UpdatePayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let conn = PixelleConnection {
|
||||
runninghub_api_key: keep_if_not_mask(p.runninghub_api_key),
|
||||
comfyui_url: p.comfyui_url, // url 明文,不掩码
|
||||
comfyui_api_key: keep_if_not_mask(p.comfyui_api_key),
|
||||
};
|
||||
pixelle_connection::save_connection(&state.db, &conn).await?;
|
||||
let backup = pixelle_connection::sync_to_pixelle(&state.config.pixelle_video.config_path, &conn)?;
|
||||
let restart = pixelle_connection::restart_pixelle(&state.config.pixelle_video.compose_dir).await;
|
||||
match restart {
|
||||
Ok(()) => Ok(Json(json!({ "success": true, "backup": backup, "restarted": true }))),
|
||||
Err(e) => Ok(Json(json!({
|
||||
"success": true, "backup": backup, "restarted": false,
|
||||
"warning": format!("配置已写入,但 Pixelle 重启失败,请手动重启: {e}")
|
||||
}))),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: src/routes/admin/mod.rs 声明子模块 + 挂路由**
|
||||
|
||||
模块声明区(`mod users;` 附近)加:
|
||||
```rust
|
||||
mod pixelle_connection;
|
||||
```
|
||||
在 `routes()` 的路由链里(`/api/admin/user-balances` 那一带)加:
|
||||
```rust
|
||||
.route(
|
||||
"/api/admin/pixelle/connection",
|
||||
get(pixelle_connection::get_connection).put(pixelle_connection::put_connection),
|
||||
)
|
||||
```
|
||||
确认 `get`/`put` 已从 axum::routing 引入(文件顶部应已 `use axum::routing::{get, post, ...}`;若缺 put 则补)。
|
||||
|
||||
- [ ] **Step 4: 构建确认**
|
||||
|
||||
Run: `cd /root/metapi && cargo build -j 2 2>&1 | tail -3`
|
||||
Expected: `Finished`。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /root/metapi && git add src/services/pixelle_connection.rs src/routes/admin/pixelle_connection.rs src/routes/admin/mod.rs
|
||||
git commit -m "feat(pixelle): 连接凭证 admin 端点(GET/PUT 掩码 + 重启)
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
<!-- T4 -->
|
||||
|
||||
## Task 4: 前端连接管理卡片
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/web/api.ts`
|
||||
- Modify: `src/web/pages/Accounts.tsx`
|
||||
|
||||
- [ ] **Step 1: api.ts adminApi 加方法**
|
||||
|
||||
在 adminApi 对象里(找 `request(` 范式的 admin 方法群)加:
|
||||
```typescript
|
||||
getPixelleConnection: () =>
|
||||
request<{ success: boolean; connection: { comfyuiUrl?: string | null; runninghubApiKey?: string | null; comfyuiApiKey?: string | null } }>("/api/admin/pixelle/connection"),
|
||||
savePixelleConnection: (data: { runninghubApiKey?: string; comfyuiUrl?: string; comfyuiApiKey?: string }) =>
|
||||
request<{ success: boolean; backup?: string; restarted?: boolean; warning?: string }>("/api/admin/pixelle/connection", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Accounts.tsx 加「Pixelle 连接」卡片**
|
||||
|
||||
先读 `src/web/pages/Accounts.tsx` 顶部,确认 `api`/`adminApi` 的导入名和 toast 用法。在页面主体某处(如页尾)加一个卡片组件块。最小实现(state + 加载 + 保存):
|
||||
```tsx
|
||||
// —— Pixelle 连接(加在组件内 state 区)——
|
||||
const [pxConn, setPxConn] = useState({ comfyuiUrl: '', runninghubApiKey: '', comfyuiApiKey: '' });
|
||||
const [pxSaving, setPxSaving] = useState(false);
|
||||
useEffect(() => {
|
||||
adminApi.getPixelleConnection()
|
||||
.then((r) => setPxConn({
|
||||
comfyuiUrl: r.connection?.comfyuiUrl || '',
|
||||
runninghubApiKey: r.connection?.runninghubApiKey || '',
|
||||
comfyuiApiKey: r.connection?.comfyuiApiKey || '',
|
||||
}))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
const savePxConn = async () => {
|
||||
setPxSaving(true);
|
||||
try {
|
||||
const res = await adminApi.savePixelleConnection({
|
||||
runninghubApiKey: pxConn.runninghubApiKey || undefined,
|
||||
comfyuiUrl: pxConn.comfyuiUrl || undefined,
|
||||
comfyuiApiKey: pxConn.comfyuiApiKey || undefined,
|
||||
});
|
||||
if (res.warning) toast.error(res.warning);
|
||||
else toast.success('已保存并重启 Pixelle,约数秒生效');
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || '保存失败');
|
||||
} finally {
|
||||
setPxSaving(false);
|
||||
}
|
||||
};
|
||||
```
|
||||
卡片 JSX(加到 return 的合适位置,样式对齐页面现有 card):
|
||||
```tsx
|
||||
<div className="card" style={{ marginTop: 16, padding: 16 }}>
|
||||
<h3>Pixelle 连接</h3>
|
||||
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||
<span>ComfyUI URL</span>
|
||||
<input value={pxConn.comfyuiUrl} onChange={(e) => setPxConn((s) => ({ ...s, comfyuiUrl: e.target.value }))} placeholder="http://172.17.0.1:4000/comfy-cloud" style={{ width: '100%' }} />
|
||||
</label>
|
||||
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||
<span>RunningHub API Key</span>
|
||||
<input value={pxConn.runninghubApiKey} onChange={(e) => setPxConn((s) => ({ ...s, runninghubApiKey: e.target.value }))} placeholder="留空••••表示不改" style={{ width: '100%' }} />
|
||||
</label>
|
||||
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||
<span>Comfy Cloud API Key</span>
|
||||
<input value={pxConn.comfyuiApiKey} onChange={(e) => setPxConn((s) => ({ ...s, comfyuiApiKey: e.target.value }))} placeholder="留空••••表示不改" style={{ width: '100%' }} />
|
||||
</label>
|
||||
<button type="button" className="btn btn-primary" disabled={pxSaving} onClick={savePxConn}>
|
||||
{pxSaving ? '保存中...' : '保存并重启 Pixelle'}
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
> 注:`useState`/`useEffect` 若未导入需补;`adminApi`/`api` 用 Accounts.tsx 实际的导入名(读文件确认是 `api` 还是 `adminApi`)。若提交掩码值(`••••...`)后端会跳过不改,符合预期。
|
||||
|
||||
- [ ] **Step 3: typecheck**
|
||||
|
||||
Run: `cd /root/metapi && npm run typecheck:web 2>&1 | tail -5`
|
||||
Expected: 无 TS 错误。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd /root/metapi && git add src/web/api.ts src/web/pages/Accounts.tsx
|
||||
git commit -m "feat(pixelle): 连接管理页加 Pixelle 连接卡片
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
<!-- T5 -->
|
||||
|
||||
## Task 5: 部署 + 端到端验证
|
||||
|
||||
**Files:** 无(部署 + 运行时)
|
||||
|
||||
- [ ] **Step 1: 重建并重启 BoosAPI(含前后端改动)**
|
||||
|
||||
```bash
|
||||
cd /root/metapi && ./build-and-run.sh --fast-release 2>&1 | tail -15
|
||||
```
|
||||
Expected: 构建成功,BoosAPI 起在 :4000。动线上服务,确认时机。
|
||||
|
||||
- [ ] **Step 2: 连接管理页确认「Pixelle 连接」卡片**
|
||||
|
||||
硬刷新管理控制台连接管理页(`/accounts`),确认出现「Pixelle 连接」卡片,
|
||||
ComfyUI URL 显示当前值,两个 key 显示掩码(`••••xxxx`)。
|
||||
|
||||
- [ ] **Step 3: 改一个值测试同步**
|
||||
|
||||
把 RunningHub API Key 改成一个新值,保存。确认:
|
||||
```bash
|
||||
grep runninghub_api_key /opt/Pixelle-Video/config.yaml | sed 's/[a-f0-9]\{20\}/.../'
|
||||
ls -t /opt/Pixelle-Video/config.yaml.bak-* | head -1
|
||||
rtk proxy docker ps --filter name=pixelle --format '{{.Names}} {{.Status}}'
|
||||
```
|
||||
Expected: config.yaml 的 key 变成新值;有新备份文件;容器重启后 healthy。
|
||||
|
||||
- [ ] **Step 4: 掩码不改测试**
|
||||
|
||||
只改 ComfyUI URL、两个 key 保持掩码原值,保存。确认 config.yaml 里两个 key **没变**
|
||||
(掩码值被后端跳过),URL 变了。
|
||||
|
||||
- [ ] **Step 5: 成功标准**
|
||||
|
||||
UI 改 Pixelle 凭证 → settings 表存值 + config.yaml 同步 + 容器重启,不再需要手动编辑文件。
|
||||
|
||||
---
|
||||
|
||||
## 完成标准
|
||||
|
||||
- 后端:pixelle_connection service(读写 settings + sync_to_pixelle + restart)+ admin GET/PUT 端点,`cargo build -j 2` 通过,service 单测通过。
|
||||
- 前端:连接管理页有「Pixelle 连接」卡片,typecheck 干净。
|
||||
- 端到端:UI 改凭证 → config.yaml 同步 + 容器重启生效;掩码值不误改。
|
||||
- 向后兼容:不改 Pixelle 源码;config.yaml 其余键保留;每次改前备份。
|
||||
|
||||
## 已知风险 / 备注
|
||||
|
||||
- serde_yaml 重序列化会丢 config.yaml 注释(已接受;每次有时间戳备份兜底)。
|
||||
- BoosAPI 进程需有 docker compose 执行权限 + config.yaml 读写权限(同宿主 root 运行,满足)。
|
||||
- Task 5 重启 BoosAPI 动线上服务,确认时机。
|
||||
- 备份文件会累积(config.yaml.bak-*),长期可加清理,本期不做。
|
||||
|
||||
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
# workflow_ui.rs 拆分(8 子模块)实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 把 `src/routes/workflow_ui.rs`(6181 行)按职责拆成 `src/routes/workflow_ui/` 目录 8 个子模块,纯机械移动、零逻辑改动,对外接口不变。
|
||||
|
||||
**Architecture:** 先建目录把原文件变 `mod.rs`,再逐个子模块把相关函数剪切出去(`pub(super)` 可见性 + 补 use),每步 cargo 编译验证。Rust 编译器是主安全网。
|
||||
|
||||
**Tech Stack:** Rust (axum, sqlx)。验证:`cargo build -j 2`、`cargo test -p boosapi --lib routes::workflow_ui`。
|
||||
|
||||
> **核心纪律:** 纯搬运,函数体一字不改;只改可见性 + use 路径。不优化、不改逻辑、不动路由路径。每抽完一组即编译+测试+提交。分支 `feat/comfy-cloud-adapter`。
|
||||
|
||||
> **验证基线(开工前先记录):** 跑 `cargo test -p boosapi --lib routes::workflow_ui 2>&1 | grep "test result"` 记下当前测试数(应 5 个),全程不得减少。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构(目标)
|
||||
|
||||
```
|
||||
src/routes/workflow_ui/
|
||||
mod.rs — routes() + 共享类型(WorkflowCaller/各 Query/Payload struct)+ 跨模块小工具 + 子模块声明 + 测试入口
|
||||
assets.rs — UI 静态资源服务/嵌入目录/index/static/serve_file/官方模板资源
|
||||
auth.rs — caller 解析
|
||||
node_defs.rs — 节点定义 + 工作流模板图
|
||||
models.rs — 模型分类 + folder 模型
|
||||
comfy_proxy.rs — ComfyUI API 代理 handler(settings/userdata/object_info/...)
|
||||
validate.rs — 工作流校验
|
||||
queue.rs — 队列/历史/prompt/view
|
||||
```
|
||||
|
||||
> 当前 `src/routes/workflow_ui.rs`(单文件)→ 变为 `src/routes/workflow_ui/mod.rs` + 7 个兄弟文件。
|
||||
> `src/routes/mod.rs` 的 `mod workflow_ui;` 声明无需改(目录模块同名,Rust 自动找 mod.rs)。
|
||||
|
||||
---
|
||||
|
||||
<!-- TASKS -->
|
||||
|
||||
## Task 0: 文件转目录模块(零拆分,先搭壳)
|
||||
|
||||
**Files:**
|
||||
- Move: `src/routes/workflow_ui.rs` → `src/routes/workflow_ui/mod.rs`
|
||||
|
||||
- [ ] **Step 1: 记录验证基线**
|
||||
|
||||
Run: `cd /root/metapi && cargo test -p boosapi --lib routes::workflow_ui 2>&1 | grep "test result"`
|
||||
Expected: 记下测试数(预期 `5 passed`)。后续每步此数不得减少。
|
||||
|
||||
- [ ] **Step 2: 把文件移成目录模块**
|
||||
|
||||
```bash
|
||||
cd /root/metapi
|
||||
mkdir -p src/routes/workflow_ui
|
||||
git mv src/routes/workflow_ui.rs src/routes/workflow_ui/mod.rs
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编译 + 测试验证(内容没动,应原样通过)**
|
||||
|
||||
Run: `cd /root/metapi && cargo build -j 2 2>&1 | tail -3 && cargo test -p boosapi --lib routes::workflow_ui 2>&1 | grep "test result"`
|
||||
Expected: `Finished`;测试数与基线一致。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd /root/metapi && git add src/routes/workflow_ui.rs src/routes/workflow_ui/mod.rs
|
||||
git commit -m "refactor(workflow_ui): 文件转目录模块(mod.rs,内容不变)
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
<!-- T1 -->
|
||||
|
||||
## Task 1: 抽 auth.rs(最小、最低风险,先验证流程)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/routes/workflow_ui/auth.rs`
|
||||
- Modify: `src/routes/workflow_ui/mod.rs`
|
||||
|
||||
要搬的函数(用 grep 定位,函数体一字不改):`caller_from_headers`、`auth_caller`、`caller_user_id`、`caller_is_admin`。它们依赖类型 `WorkflowCaller`(留在 mod.rs)。
|
||||
|
||||
- [ ] **Step 1: 在 mod.rs 顶部加子模块声明**
|
||||
|
||||
在 mod.rs 的 `use` 区之后(`pub fn routes()` 之前)加:
|
||||
```rust
|
||||
mod auth;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 确认这 4 个函数的精确范围 + WorkflowCaller 可见性**
|
||||
|
||||
Run: `cd /root/metapi && rtk proxy grep -nE "fn caller_from_headers|fn auth_caller|fn caller_user_id|fn caller_is_admin|enum WorkflowCaller" src/routes/workflow_ui/mod.rs`
|
||||
记录每个函数起止行。`WorkflowCaller` 留 mod.rs,改成 `pub(super) enum WorkflowCaller`(让子模块可用)。
|
||||
|
||||
- [ ] **Step 3: 新建 auth.rs,粘入 4 个函数(函数体不改,补 use + 改可见性)**
|
||||
|
||||
新建 `src/routes/workflow_ui/auth.rs`,结构:
|
||||
```rust
|
||||
use super::WorkflowCaller;
|
||||
use crate::error::AppResult;
|
||||
use crate::AppState;
|
||||
use axum::http::HeaderMap;
|
||||
// (按 4 个函数实际用到的依赖补齐 use:validate_jwt、find_downstream_key_by_token 等——
|
||||
// 从 mod.rs 顶部现有 use 里照抄它们需要的那几条)
|
||||
|
||||
pub(super) fn caller_from_headers(state: &AppState, headers: &HeaderMap) -> AppResult<WorkflowCaller> {
|
||||
// ←原函数体一字不改粘进来
|
||||
}
|
||||
pub(super) fn caller_user_id(caller: &WorkflowCaller) -> Option<i64> {
|
||||
// ←原体
|
||||
}
|
||||
pub(super) fn caller_is_admin(caller: &WorkflowCaller) -> bool {
|
||||
// ←原体
|
||||
}
|
||||
pub(super) async fn auth_caller(state: &AppState, headers: &HeaderMap) -> AppResult<WorkflowCaller> {
|
||||
// ←原体
|
||||
}
|
||||
```
|
||||
> 关键:这 4 个函数从 mod.rs **删除**;其在 mod.rs/其他子模块的调用点改成 `auth::caller_from_headers(...)` 等(mod.rs 内加 `use auth::{caller_from_headers, auth_caller, caller_user_id, caller_is_admin};` 即可不改调用点写法)。
|
||||
|
||||
- [ ] **Step 4: mod.rs 加 use 引回,删除原 4 函数**
|
||||
|
||||
mod.rs 顶部子模块声明后加:
|
||||
```rust
|
||||
use auth::{auth_caller, caller_from_headers, caller_is_admin, caller_user_id};
|
||||
```
|
||||
并从 mod.rs 删掉这 4 个函数的原定义。
|
||||
|
||||
- [ ] **Step 5: 编译 + 测试**
|
||||
|
||||
Run: `cd /root/metapi && cargo build -j 2 2>&1 | tail -3 && cargo test -p boosapi --lib routes::workflow_ui 2>&1 | grep "test result"`
|
||||
Expected: `Finished`;测试数 == 基线。若编译报「未使用的 use」「私有」「未解析」,按提示补 use / 调可见性(纯机械修),不改逻辑。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
cd /root/metapi && git add src/routes/workflow_ui/mod.rs src/routes/workflow_ui/auth.rs
|
||||
git commit -m "refactor(workflow_ui): 抽出 auth 子模块
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
<!-- T2 -->
|
||||
|
||||
## 通用抽取流程(每个子模块任务都按这 6 步)
|
||||
|
||||
每个抽取任务对一组函数执行:
|
||||
1. mod.rs 顶部加 `mod <name>;` 声明。
|
||||
2. grep 定位本组函数精确范围;凡被本组函数引用、但定义留在别处的类型/函数,把那些定义改 `pub(super)`。
|
||||
3. 新建 `<name>.rs`,粘入本组函数(**函数体一字不改**),顶部按实际依赖补 `use`(从 mod.rs 现有 use 照抄需要的;跨模块的用 `use super::...` 或 `use super::<other_mod>::...`),函数可见性改 `pub(super)`。
|
||||
4. 从 mod.rs 删掉这些函数原定义;mod.rs 顶部加 `use <name>::{...};` 把它们引回(调用点写法不变)。
|
||||
5. `cargo build -j 2` + `cargo test -p boosapi --lib routes::workflow_ui`。编译报错按提示机械修(补 use / 调可见性),不改逻辑。测试数不得低于基线。
|
||||
6. 提交:`git add src/routes/workflow_ui/mod.rs src/routes/workflow_ui/<name>.rs && git commit -m "refactor(workflow_ui): 抽出 <name> 子模块"`(带 Co-Authored-By)。
|
||||
|
||||
> 注:若某函数同时被多个子模块用,保留在 mod.rs 顶层并 `pub(super)`,各子模块 `use super::该函数`。
|
||||
> 共用小工具(guess_mime_from_path / parse_boolish / safe_view_path / user_home / default_history_limit
|
||||
> / workspace_kind_from_mime)统一留 mod.rs 顶层 `pub(super)`,不下沉到子模块。
|
||||
|
||||
## Task 2: 抽 node_defs.rs(节点定义 + 工作流模板图)
|
||||
|
||||
**Files:** Create `src/routes/workflow_ui/node_defs.rs`;Modify `src/routes/workflow_ui/mod.rs`
|
||||
|
||||
要搬的函数(grep 定位):`known_output_ports`、`core_node_definitions`、`workflow_template_payload`、`core_workflow_template_index`、`inject_boosapi_workflow_template_payload`、`default_boosapi_workflow_graph`、`text_to_video_workflow_graph`、`port_name`、`source_node_type`、`normalize_prompt_payload`、`prompt_link`、`node_type_def_to_comfy`、`input_spec_to_comfy`、`output_specs`、`output_is_list_specs`。
|
||||
|
||||
- [ ] **Step 1-6: 按「通用抽取流程」执行**,`<name>` = `node_defs`。
|
||||
注意:这些函数用到 `WorkflowGraph` / `NodeTypeDefinition` 类型——确认它们的定义位置(可能在 crate::workflow 或 mod.rs),mod.rs 内的留存定义改 `pub(super)`,node_defs.rs 用 `use super::...` 或 `use crate::...` 引入。
|
||||
验证命令:`cd /root/metapi && cargo build -j 2 2>&1 | tail -3 && cargo test -p boosapi --lib routes::workflow_ui 2>&1 | grep "test result"`,期望 Finished + 测试数≥基线。
|
||||
提交 message:`refactor(workflow_ui): 抽出 node_defs 子模块`。
|
||||
|
||||
<!-- T3 -->
|
||||
|
||||
## Task 3: 抽 models.rs(模型分类 + folder 模型)
|
||||
|
||||
**Files:** Create `src/routes/workflow_ui/models.rs`;Modify `src/routes/workflow_ui/mod.rs`
|
||||
|
||||
要搬的函数:`is_exact_workflow_model`、`is_image_workflow_model`、`is_video_workflow_model`、`is_audio_workflow_model`、`is_embedding_workflow_model`、`is_3d_workflow_model`、`is_vision_workflow_model`、`is_chat_workflow_model`、`workflow_route_models`、`image_model_choices`、`workflow_folder_models`、`workflow_folder_is_api_only`、`workflow_folder_has_route_models`、`workflow_folder_route_model_items`、`combo_choices_from_node_input`、`combo_fields_from_node`、`native_folder_models_from_object_info`。
|
||||
|
||||
- [ ] **Step 1-6: 按「通用抽取流程」执行**,`<name>` = `models`。
|
||||
注意:`workflow_route_models` 是 `async fn` 且用 `state.db`,补 `use crate::AppState;` 等。
|
||||
验证命令同上,提交 message:`refactor(workflow_ui): 抽出 models 子模块`。
|
||||
|
||||
<!-- T4 -->
|
||||
|
||||
## Task 4: 抽 assets.rs(UI 静态资源服务)
|
||||
|
||||
**Files:** Create `src/routes/workflow_ui/assets.rs`;Modify `src/routes/workflow_ui/mod.rs`
|
||||
|
||||
要搬的函数:`workflow_ui_root_for_config`、`resolve_workflow_ui_dir`、`embedded_workflow_ui_for_config`、`workflow_ui_dir_is_usable`、`embedded_workflow_ui_is_usable`、`workflow_ui_index_is_placeholder`、`workflow_ui_runtime`、`workflow_ui_build_metadata`、`workflow_cloud_api_key`、`workflow_ui_index`、`workflow_ui_static`、`official_workflow_template_asset`、`serve_file`。
|
||||
|
||||
- [ ] **Step 1-6: 按「通用抽取流程」执行**,`<name>` = `assets`。
|
||||
注意:用到 `include_dir::Dir`、`PathBuf`、`Html`、`Response` 等,按实际补 use。`serve_file` 若被 queue/view 模块也用到,则保留 mod.rs 顶层 `pub(super)`(实现时看引用)。
|
||||
验证命令同上,提交 message:`refactor(workflow_ui): 抽出 assets 子模块`。
|
||||
|
||||
<!-- T5 -->
|
||||
|
||||
## Task 5: 抽 comfy_proxy.rs(ComfyUI API 代理 handler)
|
||||
|
||||
**Files:** Create `src/routes/workflow_ui/comfy_proxy.rs`;Modify `src/routes/workflow_ui/mod.rs`
|
||||
|
||||
要搬的函数:`get_user_config`、`create_user`、`get_settings`、`get_setting`、`store_settings`、`store_setting`、`userdata_list`、`userdata_get`、`userdata_store`、`userdata_delete`、`move_userdata`、`extensions`、`workflow_templates`、`embeddings`、`object_info`、`system_stats`。
|
||||
|
||||
- [ ] **Step 1-6: 按「通用抽取流程」执行**,`<name>` = `comfy_proxy`。
|
||||
注意:这些是路由 handler(多 `async fn ...(State, HeaderMap) -> AppResult<...>`),依赖 `auth_caller`(已在 auth.rs)→ 用 `use super::auth_caller;` 或 `super::auth::auth_caller`。补 axum extract / Json / Response use。
|
||||
验证命令同上,提交 message:`refactor(workflow_ui): 抽出 comfy_proxy 子模块`。
|
||||
|
||||
<!-- T6 -->
|
||||
|
||||
## Task 6: 抽 validate.rs(工作流校验)
|
||||
|
||||
**Files:** Create `src/routes/workflow_ui/validate.rs`;Modify `src/routes/workflow_ui/mod.rs`
|
||||
|
||||
要搬的函数:`validate_workflow`、`validate_workflow_against_object_info`、`validate_workflow_input_file`。
|
||||
|
||||
- [ ] **Step 1-6: 按「通用抽取流程」执行**,`<name>` = `validate`。
|
||||
注意:依赖 node_defs/models 的函数(如 normalize_prompt_payload、combo_fields_from_node)→ 用 `use super::node_defs::...` / `use super::models::...`(确保那些函数已 `pub(super)`)。WorkflowValidateQuery struct 留 mod.rs `pub(super)`。
|
||||
验证命令同上,提交 message:`refactor(workflow_ui): 抽出 validate 子模块`。
|
||||
|
||||
<!-- T7 -->
|
||||
|
||||
## Task 7: 抽 queue.rs(队列/历史/prompt/view)+ 收尾
|
||||
|
||||
**Files:** Create `src/routes/workflow_ui/queue.rs`;Modify `src/routes/workflow_ui/mod.rs`
|
||||
|
||||
要搬的函数:`queue_status`、`queue`、`queue_mutate`、`history`、`history_mutate`、`history_by_id`、`queue_prompt`、`repair_native_prompt_payload`、`queue_boosapi_prompt`、`interrupt`、`view`、`internal_logs`、`internal_logs_raw`。
|
||||
|
||||
- [ ] **Step 1-6: 按「通用抽取流程」执行**,`<name>` = `queue`。
|
||||
注意:`repair_native_prompt_payload` 很长(2814-3474),依赖 node_defs/models 多个函数;`view` 用 serve_file/safe_view_path。补 `use super::...`。PromptPayload/HistoryQuery/ViewQuery/QueueMutation/UserDataQuery/UploadQuery struct 留 mod.rs `pub(super)`(被多模块用)。
|
||||
验证命令同上,提交 message:`refactor(workflow_ui): 抽出 queue 子模块`。
|
||||
|
||||
- [ ] **Step 7: 最终验收**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd /root/metapi
|
||||
cargo build -j 2 2>&1 | tail -3
|
||||
cargo test -p boosapi --lib routes::workflow_ui 2>&1 | grep "test result"
|
||||
for f in mod auth node_defs models assets comfy_proxy validate queue; do echo -n "$f.rs: "; wc -l < src/routes/workflow_ui/$f.rs; done
|
||||
```
|
||||
Expected: Finished;测试数≥基线;每个子文件行数显著小于原 6181(mod.rs 应只剩 routes()+共享类型+小工具,目标 <1500 行;无单文件超 ~2000)。
|
||||
|
||||
- [ ] **Step 8: 冒烟(手动)**
|
||||
|
||||
部署后打开工作流 UI、跑一个 prompt,确认行为与拆分前一致(路由路径、handler 行为不变)。
|
||||
|
||||
<!-- END -->
|
||||
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
# Comfy Cloud 工作流执行适配器 — 设计
|
||||
|
||||
日期:2026-06-17
|
||||
分支:`feat/comfy-cloud-adapter`
|
||||
状态:已通过 brainstorming,待落实现计划
|
||||
|
||||
## 背景
|
||||
|
||||
数字人/图片工作流走 `selfhost` 生成视频时报 `Submit workflow failed: [401] AuthenticationRequired`。
|
||||
根因经多层验证已锁定:
|
||||
|
||||
- Pixelle 的 comfykit 0.1.12 `HttpExecutor` 说的是 **ComfyUI 原生协议**:
|
||||
`POST {base_url}/prompt`、`GET {base_url}/history/{id}`、鉴权用 `Authorization: Bearer`。
|
||||
- 配置里 `comfyui_url = https://cloud.comfy.org`,但 **Comfy Cloud 的 API 契约完全不同**
|
||||
(官方文档 https://docs.comfy.org/development/cloud/overview):
|
||||
- 鉴权:`X-API-Key` 头(非 Bearer)。
|
||||
- 提交:`POST /api/prompt`(带 `/api/` 前缀)。
|
||||
- 轮询:`GET /api/job/{prompt_id}/status` → 状态 pending/in_progress/completed/failed/cancelled。
|
||||
- 详情/产出:`GET /api/jobs/{prompt_id}`(产出按 node_id 聚合 images/video/audio)。
|
||||
- 下载:`GET /api/view?filename=&subfolder=&type=output`(302 跳带签名 URL)。
|
||||
- 接受标准 ComfyUI API-format 工作流。
|
||||
- 已用官方方式验证 Comfy Cloud key 有效:`GET /api/user` 返回 200 `{"status":"active"}`。
|
||||
|
||||
所以这不是 key 失效,而是 comfykit 与 Comfy Cloud 协议不兼容(路径前缀 + 鉴权头 + 轮询端点三处全不同)。
|
||||
|
||||
## 目标
|
||||
|
||||
在 **BoosAPI** 里实现一个 **ComfyUI 原生协议 ↔ Comfy Cloud API 翻译适配器**:对外暴露
|
||||
ComfyUI 原生端点(`/prompt`、`/history/{id}`、`/view`),内部翻译成 Comfy Cloud 的
|
||||
`/api/*` 契约 + `X-API-Key` 鉴权。Pixelle/comfykit **一行代码都不改**,只把 `comfyui_url`
|
||||
指向 BoosAPI 的适配端点即可。
|
||||
|
||||
Comfy Cloud 的 base URL + API Key 通过**站点管理**录入(新增 `comfy_cloud` 平台类型),
|
||||
适配器从站点配置读取,而非散落的环境变量/配置文件。
|
||||
|
||||
## 已确认的关键决策
|
||||
|
||||
1. 采用 **Approach A**:站点管理只作「配置载体 + 前端入口」(存 base_url + X-API-Key),
|
||||
执行逻辑由独立的 Comfy Cloud 适配器承担;**不**把 Comfy Cloud 塞进 `/v1/*` token 路由
|
||||
(它是工作流执行 API,不是聊天协议,塞进 token-router 跑不通)。
|
||||
2. 适配器对外说 ComfyUI 原生协议,Pixelle/comfykit 零改动,只改 `comfyui_url` 配置。
|
||||
3. 鉴权翻译:入口端点不强制校验(本机互信)/出口注入 `X-API-Key`。
|
||||
4. 分支 `feat/comfy-cloud-adapter`,与 NIT 工作无关,不混入。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不改 Pixelle / comfykit 源码。
|
||||
- 不做 WebSocket 实时进度(comfykit HTTP 模式只轮询 `/history/{id}`,够用)。
|
||||
- 不把 Comfy Cloud 接入 LLM token 路由/failover。
|
||||
- 不做 Comfy Cloud 的计费集成(已有 `fetch_comfy_cloud_balance` 另说)。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
Pixelle(comfykit 0.1.12, 不改)
|
||||
comfyui_url = http://<boosapi-host>/comfy-cloud
|
||||
说 ComfyUI 原生协议: POST /prompt · GET /history/{id} · GET /view · Bearer(忽略)
|
||||
│
|
||||
▼
|
||||
BoosAPI 适配端点(新增 src/routes/comfy_cloud_adapter.rs)
|
||||
· POST /comfy-cloud/prompt → 翻译 → POST cloud.comfy.org/api/prompt
|
||||
· GET /comfy-cloud/history/{id} → 翻译 → GET /api/job/{id}/status (+ /api/jobs/{id})
|
||||
拼回 ComfyUI history 格式
|
||||
· GET /comfy-cloud/view → 翻译 → GET /api/view (跟随 302)
|
||||
鉴权: 出口注入 X-API-Key(从站点配置读)
|
||||
│
|
||||
▼
|
||||
Comfy Cloud (https://cloud.comfy.org/api/*)
|
||||
```
|
||||
|
||||
## 站点管理集成(配置载体)
|
||||
|
||||
- 前端 `SITE_PLATFORM_OPTIONS`(`src/web/pages/Sites.tsx`)新增一项
|
||||
`{ value: 'comfy_cloud', label: 'Comfy Cloud', description: '工作流执行 API' }`。
|
||||
- 站点记录复用现有字段:`url` 存 `https://cloud.comfy.org`,API Key 存入现有的
|
||||
站点凭证字段(沿用站点已有的 key/header 存储方式;实现时确认 sites 表里承载 key 的列)。
|
||||
- 适配器通过一个 helper `resolve_comfy_cloud_site(state) -> (base_url, api_key)`:
|
||||
查 `platform = 'comfy_cloud'` 且 enabled 的站点,取其 url + key。无配置 → 明确报错。
|
||||
|
||||
## 适配端点详细行为
|
||||
|
||||
### POST /comfy-cloud/prompt
|
||||
- 入参:ComfyUI 原生 body `{ "prompt": {...}, "client_id": "..." }`。
|
||||
- 出口:`POST {base_url}/api/prompt`,头 `X-API-Key: {key}` + `Content-Type: application/json`,
|
||||
body 原样转发(Comfy Cloud 接受相同的 `{prompt: ...}` 包裹)。
|
||||
- 回包:Comfy Cloud 返回 `{ "prompt_id": "..." }`,原样回给 comfykit(字段名一致,无需改)。
|
||||
|
||||
### GET /comfy-cloud/history/{prompt_id}
|
||||
comfykit 轮询此端点,期待 ComfyUI history 格式:
|
||||
`{ "{prompt_id}": { "status": {...}, "outputs": { "{node_id}": { "images": [...] } } } }`。
|
||||
适配器:
|
||||
1. `GET {base_url}/api/job/{prompt_id}/status` 拿 `{ "status": "completed|in_progress|..." }`。
|
||||
2. 若未完成(pending/in_progress)→ 回 `{}`(comfykit 据此继续轮询)。
|
||||
3. 若 completed → `GET {base_url}/api/jobs/{prompt_id}` 拿产出,翻译成 ComfyUI history 形状:
|
||||
```json
|
||||
{ "{prompt_id}": {
|
||||
"status": { "completed": true, "status_str": "success" },
|
||||
"outputs": { /* node_id → {images|video|audio: [{filename,subfolder,type}]} */ }
|
||||
} }
|
||||
```
|
||||
4. 若 failed/cancelled → 回带 `status_str: "error"` 的 history,让 comfykit 终止轮询并报错。
|
||||
|
||||
### GET /comfy-cloud/view
|
||||
- 透传 query(`filename`/`subfolder`/`type`)→ `GET {base_url}/api/view?...`,头注入 X-API-Key。
|
||||
- Comfy Cloud 回 302 签名 URL → 适配器跟随重定向取字节流回传(reqwest 默认跟随,
|
||||
或手动取 Location 再 GET),Content-Type 透传。
|
||||
|
||||
## 数据流(comfykit 视角,全程无感)
|
||||
|
||||
1. comfykit `POST {comfyui_url}/prompt` → 适配器 → Comfy Cloud → 返回 prompt_id。
|
||||
2. comfykit 轮询 `GET {comfyui_url}/history/{id}` → 适配器翻译 job 状态 → 未完成回 `{}`,
|
||||
完成回 ComfyUI history。
|
||||
3. comfykit 从 outputs 取 filename → `GET {comfyui_url}/view?filename=...` → 适配器取回字节。
|
||||
|
||||
## 错误处理
|
||||
|
||||
- 站点未配置 `comfy_cloud` → 适配端点返回 503 + 中文提示「未配置 Comfy Cloud 站点」。
|
||||
- Comfy Cloud 返回非 2xx → 适配器透传状态码 + 上游错误体(便于排查,不吞错)。
|
||||
- job failed/cancelled → 在 history 里标 error,comfykit 会抛 `Submit workflow failed` 类错误
|
||||
(但这次是真实的执行失败,不是鉴权问题)。
|
||||
|
||||
## 路由挂载
|
||||
|
||||
`src/routes/mod.rs` 的 `build_router` 加 `.merge(comfy_cloud_adapter::routes())`。
|
||||
端点前缀 `/comfy-cloud/*`,不需要 admin/user 鉴权(本机内 Pixelle → BoosAPI 互信;
|
||||
如需收紧可后续加内部 token,本期不做)。
|
||||
|
||||
## 测试策略(TDD)
|
||||
|
||||
- 单测翻译函数:Comfy Cloud `job status + jobs detail` JSON → ComfyUI history JSON 的纯函数
|
||||
(`comfy_cloud_job_to_history`),覆盖 completed / in_progress / failed 三种。
|
||||
- 单测 `resolve_comfy_cloud_site`:有配置返回 (url,key);无配置报错。
|
||||
- 用 wiremock 起一个假 Comfy Cloud,集成测 `POST /comfy-cloud/prompt` 透传 + X-API-Key 注入、
|
||||
`GET /comfy-cloud/history/{id}` 轮询翻译(沿用项目现有 wiremock 测试范式)。
|
||||
|
||||
## 验证
|
||||
|
||||
- `cargo build -j 2`、`cargo test -p boosapi --lib comfy_cloud`。
|
||||
- 真实联调:把 Pixelle `comfyui_url` 指向 `http://<boosapi>/comfy-cloud`,跑一次 selfhost 视频,
|
||||
确认不再 401 且能取回产出。
|
||||
|
||||
## 提交约定
|
||||
|
||||
分支 `feat/comfy-cloud-adapter`,提交按逻辑拆分(站点平台类型 / 适配器 service + 端点 /
|
||||
路由挂载 / 前端平台选项),沿用项目中文 commit 风格。
|
||||
@@ -0,0 +1,100 @@
|
||||
# NIT AI Studio — 第一阶段(聚合图像/数字人)设计
|
||||
|
||||
日期:2026-06-17
|
||||
分支:`nit_edu`
|
||||
状态:已通过 brainstorming,待落实现计划
|
||||
|
||||
## 背景
|
||||
|
||||
NIT 精英班专属的「AI 综合应用软件」是一套面向精英班学员的 AI 创作工具,完整愿景涵盖
|
||||
文本结构化创作(论文/小说/剧本/分镜/教材)、PPT 生成、图像(P图/制图)、视频
|
||||
(文生/图生/短视频)、数字人。体量过大,分阶段建设。
|
||||
|
||||
**本 spec 仅覆盖 AI Studio 第一阶段:聚合现有图像 + 数字人能力**,为精英班学员提供
|
||||
统一创作入口,复用 BoosAPI 已有的 `picture_agent`(图像)与 `digital_human`(数字人)。
|
||||
|
||||
## 已确认的关键决策
|
||||
|
||||
1. 定位:NIT 精英班专属模块,非通用套件。
|
||||
2. 第一阶段只聚合 `picture_agent`(图像)+ `digital_human`(数字人)。
|
||||
3. 视频(含 money_printer、工作流视频)留后续阶段,本阶段完全不碰 money_printer。
|
||||
4. 认证桥接:给精英班学员配后台下游 key + 扩展 surface 的 resolve_auth 接受 student JWT。
|
||||
5. 计费:学员创作消耗走积分轨(已建的 points track),管理员可给学员充积分。
|
||||
6. 文本创作 / PPT 留后续阶段。
|
||||
|
||||
## 前置依赖
|
||||
|
||||
本阶段 gate 在第一期(报考闭环)产物上,实现排在第一期之后:
|
||||
- student JWT(`audience='student'`,第一期发放)。
|
||||
- `enrollment.status=approved` 且 `tier=elite`。
|
||||
- 非精英班 / 未审核 → 403。
|
||||
|
||||
## 非目标(本阶段明确不做)
|
||||
|
||||
- 视频能力(文生/图生/短视频、money_printer、工作流视频)。
|
||||
- 文本结构化创作(论文/小说/剧本/分镜/教材)。
|
||||
- PPT 生成。
|
||||
- 通用套件(面向非 NIT 用户)。
|
||||
|
||||
## 学员下游 key 配置
|
||||
|
||||
- `downstream_api_keys` 加可空 `student_id` 列(关联 `students.id`,与现有 `user_id` 并列;
|
||||
二者互斥:一行要么属于 user,要么属于 student)。
|
||||
- 审核通过且 `tier=elite` 时,自动 provision 一个学员下游 key,沿用现有 `comfyui-agent`
|
||||
内置 key 的幂等 `ensure` 模式:缺失则建、被禁用则重新启用、保持唯一。
|
||||
- 学员从精英班降级或报考被驳回时,禁用该 key。
|
||||
- key 命名约定:`sk-nit-stu-{uuid_hex}`,`name='nit-student'`,便于识别。
|
||||
|
||||
## 认证扩展
|
||||
|
||||
仅改两个 surface:`src/routes/picture_agent.rs`、`src/routes/digital_human.rs`。
|
||||
**money_printer 不动。**
|
||||
|
||||
各自的 `resolve_auth` 加一个分支(现有 admin/proxy token、user JWT 逻辑不变):
|
||||
- 新增:`audience='student'` 的 JWT → 校验该学员存在 `approved` 且 `tier=elite` 的报考
|
||||
→ 查其 `student_id` 对应的下游 key → 作 `outbound_key`。
|
||||
- 抽共享 helper `resolve_student_outbound_key(student_id, state) -> AppResult<(key_id, key)>`,
|
||||
放在 `src/services/nit/`,两处 surface 复用,避免重复逻辑。
|
||||
- 校验失败(无 elite 报考 / key 缺失)→ 403。
|
||||
|
||||
## 计费(复用积分轨)
|
||||
|
||||
- 学员用图像/数字人,消耗扣在其下游 key 上,走已建的积分轨(`apply_points_delta`,track=points)。
|
||||
- 管理员给学员充积分:扩展现有 admin 积分调整端点支持 `student_id`(或新增 NIT 学员积分端点,
|
||||
实现时择一,优先扩展现有端点)。
|
||||
- 精英班开通时可送一笔初始积分,数额为配置项,默认 `0`。
|
||||
|
||||
## 统一入口(前端)
|
||||
|
||||
- `src/web/pages/nit/Studio.tsx`:精英班学员创作工作台首页,卡片式列出「图像 / 数字人」
|
||||
两个入口 + 显示积分余额。
|
||||
- 卡片跳进现有 `picture_agent` / `digital_human` 页面(带 student token)。
|
||||
- 非精英班学员:显示引导升级提示,不展示工具卡片。
|
||||
- `api.ts` 的 `nitApi` 扩展:查学员积分余额、查 Studio 可用工具。
|
||||
|
||||
## 错误处理
|
||||
|
||||
- 非精英班 / 未审核学员访问 → 403 + 友好提示。
|
||||
- 学员下游 key 缺失(provision 失败)→ 明确错误,提示联系管理员。
|
||||
- 积分余额不足 → 拦截创作,提示充值(复用现有 billing 引导文案范式)。
|
||||
|
||||
## 测试策略(TDD)
|
||||
|
||||
后端(rstest + 内存 SQLite,沿用现有 `recharge_test_pool` 范式):
|
||||
- 学员下游 key ensure 幂等性(缺失则建 / 禁用则启用 / 降级则禁用)。
|
||||
- `resolve_auth` 接受 elite student JWT、拒绝非精英班、拒绝未审核。
|
||||
- 学员创作扣积分、余额不足拦截。
|
||||
|
||||
前端(vitest):
|
||||
- `nitApi` 积分余额 / Studio 工具查询方法(沿用 `api.test.ts` 范式)。
|
||||
- Studio 页卡片渲染 + 余额展示 + 非精英班引导(沿用 `UsageRecords.test.tsx` 范式)。
|
||||
|
||||
## 验证
|
||||
|
||||
- `cargo build -j 2`、`cargo test -p boosapi --lib`(后端 nit / picture_agent / digital_human 相关)。
|
||||
- `npx vitest run`(相关前端测试)、`npm run typecheck`。
|
||||
|
||||
## 提交约定
|
||||
|
||||
全部在 `nit_edu` 分支,不混入 `refactor/rust-backend`。提交按逻辑拆分(迁移 / service /
|
||||
surface 认证 / 前端 分开),沿用项目中文 commit 风格。
|
||||
@@ -0,0 +1,195 @@
|
||||
# NIT 报考学习平台 — 第一期(报考闭环 A+B+C)设计
|
||||
|
||||
日期:2026-06-17
|
||||
分支:`nit_edu`
|
||||
状态:已通过 brainstorming,待落实现计划
|
||||
|
||||
## 背景
|
||||
|
||||
NIT 报考学习平台面向报考「NIT 人工智能与大模型应用」考试的学员,提供注册、报考、
|
||||
学习等功能。完整需求(见 `plan/NIT报考学习平台(初版需求备注解析).docx`)涵盖
|
||||
8 个子系统,体量过大,无法单 spec 完成,故拆分分期建设。
|
||||
|
||||
**本 spec 仅覆盖第一期:报考闭环 A+B+C** —— 学员注册 → 报考 → 缴费审核开课。
|
||||
学习中心(理论课/实操课/练习题)、OCR、校区负责人多级权限、内容保护等留后续期。
|
||||
|
||||
## 已确认的关键决策
|
||||
|
||||
1. 第一个子项目:A+B+C 报考闭环(注册 → 报考 → 缴费审核开课)。
|
||||
2. 独立 `students` 表,与现有 `users`(API 网关用户)完全隔离。
|
||||
3. 缴费:线下转账/扫码给校区 + 学员上传凭证截图 + 后台人工审核开课(非在线支付)。
|
||||
4. 第一期不做 OCR:学员手动填身份证号/姓名,二寸照手动命名上传。
|
||||
5. 开课权限只打状态标记(enrollment.status=approved + tier),学习中心留后续期再读此标记。
|
||||
6. 后台第一期只做平台超管(复用现有 admin 认证),校区负责人登录留后续期。
|
||||
7. 文件存储复用现有 workspace 机制(multipart + `record_bytes_asset` + `workspace_assets` 表)。
|
||||
8. 代码组织:NIT 作为 BoosAPI 内的新模块,复用现有 axum/sqlx/JWT/workspace 基建。
|
||||
9. 学员认证:发 `audience='student'` 的 JWT,与 admin/user token 互不通用。
|
||||
10. 报名信息表:固定核心字段 + 一个 JSON 扩展字段(应对考试中心模版变化),不做动态表单引擎。
|
||||
|
||||
## 非目标(第一期明确不做)
|
||||
|
||||
- 学习中心:理论课/实操课视频、二维码、学习进度、练习题、模拟题、错题集。
|
||||
- 实操课专属 AI 综合应用软件入口。
|
||||
- 身份证 OCR 自动识别与二寸照自动命名。
|
||||
- 校区负责人多级权限与数据隔离。
|
||||
- 内容保护(防截图/录屏/水印)。
|
||||
- 在线支付。
|
||||
|
||||
## 数据模型
|
||||
|
||||
三张新表,SQLite,迁移文件放 `migrations/`。
|
||||
|
||||
### `students` — 学员实名账户
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | INTEGER PK | |
|
||||
| `public_id` | TEXT UNIQUE | 对外 ID,格式 `stu_{uuid_hex}` |
|
||||
| `name` | TEXT NOT NULL | 姓名(实名,注册后不可改) |
|
||||
| `phone` | TEXT NOT NULL UNIQUE | 实名手机号,登录用 |
|
||||
| `id_card_no` | TEXT NOT NULL UNIQUE | 身份证号 |
|
||||
| `campus_id` | INTEGER NOT NULL | FK → `nit_campuses.id` |
|
||||
| `major` | TEXT | 所学专业(社会学员可空) |
|
||||
| `class_name` | TEXT | 班级(可空) |
|
||||
| `head_teacher` | TEXT | 班主任姓名(可空) |
|
||||
| `password_hash` | TEXT NOT NULL | 登录密码哈希(沿用现有 users 哈希方案) |
|
||||
| `privacy_agreed_at` | TEXT NOT NULL | 勾选隐私协议时间 |
|
||||
| `status` | TEXT NOT NULL DEFAULT 'active' | active / disabled |
|
||||
| `created_at` / `updated_at` | TEXT | |
|
||||
|
||||
### `nit_campuses` — 校区/机构库(超管录入,学员注册下拉选)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | INTEGER PK | |
|
||||
| `public_id` | TEXT UNIQUE | `cmp_{uuid_hex}` |
|
||||
| `province` | TEXT NOT NULL | 省份 |
|
||||
| `name` | TEXT NOT NULL | 院校/机构名称 |
|
||||
| `manager_name` | TEXT | 负责人姓名 |
|
||||
| `manager_phone` | TEXT | 负责人联系方式 |
|
||||
| `enabled` | INTEGER NOT NULL DEFAULT 1 | 仅 enabled 的校区可被学员选择 |
|
||||
| `created_at` / `updated_at` | TEXT | |
|
||||
|
||||
### `nit_enrollments` — 报考记录
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | INTEGER PK | |
|
||||
| `public_id` | TEXT UNIQUE | `enr_{uuid_hex}` |
|
||||
| `student_id` | INTEGER NOT NULL | FK → `students.id` |
|
||||
| `subject` | TEXT NOT NULL | 第一期固定「NIT人工智能与大模型应用」 |
|
||||
| `tier` | TEXT NOT NULL | `basic`(980)/ `elite`(1980) |
|
||||
| `payment_time` | TEXT | 学员填写的缴费时间 |
|
||||
| `payment_proof_asset_id` | TEXT | 缴费凭证截图(workspace asset public_id) |
|
||||
| `id_card_front_asset_id` | TEXT | 身份证正面 |
|
||||
| `id_card_back_asset_id` | TEXT | 身份证反面 |
|
||||
| `photo_asset_id` | TEXT | 二寸蓝底照 |
|
||||
| `applicant_info` | TEXT (JSON) | 报名材料表可变字段 |
|
||||
| `status` | TEXT NOT NULL DEFAULT 'pending_review' | pending_review / approved / rejected |
|
||||
| `reviewed_by` | TEXT | 审核管理员标识 |
|
||||
| `reviewed_at` | TEXT | |
|
||||
| `review_note` | TEXT | 驳回原因/备注 |
|
||||
| `created_at` / `updated_at` | TEXT | |
|
||||
|
||||
**关系**:student → campus(多对一);enrollment → student(多对一)。一个学员允许在被驳回后
|
||||
重新提交报考(可有多条 rejected + 至多一条 active 的 pending_review/approved)。
|
||||
|
||||
**班型权限**:第一期权限即 `approved` 后 enrollment 上的 `tier` 标记;`basic` 对应理论课权限、
|
||||
`elite` 对应理论+实操权限,实际课程内容由后续期读取此标记实现。
|
||||
|
||||
**文件**:四类上传文件全部走现有 `workspace_assets`(`source='nit-enrollment'`,
|
||||
经 `record_bytes_asset` 落本地磁盘),enrollment 仅存其 `public_id` 引用。
|
||||
|
||||
## 认证与路由
|
||||
|
||||
### 学员认证
|
||||
|
||||
复用现有 `auth::jwt` 工具,给 students 发 `audience='student'` 的 JWT。新增
|
||||
`resolve_student(headers, state) -> AppResult<i64>`(返回 student_id),与 admin/user token
|
||||
互不通用。登录凭证为「手机号 + 密码」。
|
||||
|
||||
### 路由分布(挂进现有 axum router via `routes/mod.rs` merge)
|
||||
|
||||
- `src/routes/nit/mod.rs` — 装配 NIT 子路由,导出 `routes()`。
|
||||
- `src/routes/nit/student_auth.rs`
|
||||
- `POST /api/nit/register` — 学员注册
|
||||
- `POST /api/nit/login` — 学员登录
|
||||
- `src/routes/nit/enrollment.rs`(学员侧,需 student JWT)
|
||||
- `POST /api/nit/enrollment/uploads` — multipart 上传单个文件,返回 asset public_id
|
||||
- `POST /api/nit/enrollment` — 创建报考记录
|
||||
- `GET /api/nit/enrollment/mine` — 查本人报考状态
|
||||
- `src/routes/nit/admin.rs`(超管侧,复用现有 admin token 校验)
|
||||
- `GET/POST/PUT /api/admin/nit/campuses` — 校区 CRUD
|
||||
- `GET /api/admin/nit/enrollments` — 报考列表(按 省份/校区/班型/状态 筛选)
|
||||
- `POST /api/admin/nit/enrollments/{public_id}/approve`
|
||||
- `POST /api/admin/nit/enrollments/{public_id}/reject`
|
||||
- `GET /api/admin/nit/enrollments/export` — CSV 导出
|
||||
|
||||
业务逻辑(查询/状态机)抽到 `src/services/nit/`(如 `mod.rs` + `enrollment.rs`),
|
||||
路由层只做参数解析 + 鉴权 + 调用 service,保持文件聚焦。
|
||||
|
||||
## 数据流
|
||||
|
||||
### 注册
|
||||
1. 校验手机号、身份证号未被占用(唯一约束 + 友好报错)。
|
||||
2. 校验 `campus_id` 存在且 enabled。
|
||||
3. 必须 `privacy_agreed=true`。
|
||||
4. 写入 `students`(password 哈希),发 student JWT 返回。
|
||||
|
||||
### 报考(两步,避免半截事务)
|
||||
1. 学员逐个调 `POST /api/nit/enrollment/uploads`(multipart,带 `purpose` 区分四类文件)
|
||||
→ `record_bytes_asset` → 返回各 asset `public_id`。
|
||||
2. 学员调 `POST /api/nit/enrollment`,带各 asset id + tier + payment_time + applicant_info
|
||||
→ 校验本人无在审/已批的活动报考 → 创建 `pending_review` 记录。
|
||||
|
||||
### 审核(超管)
|
||||
- 列表筛选 `pending_review` → 查看凭证/证件图(经 workspace asset file 接口)。
|
||||
- `approve`:状态机 `pending_review → approved`,落 `tier` 权限标记,记 reviewed_by/at。
|
||||
- `reject`:`pending_review → rejected`,记 review_note;学员可据此重新提交。
|
||||
- 状态机只允许从 `pending_review` 流转,杜绝并发重复审核。
|
||||
|
||||
### 导出
|
||||
按 省份/校区/班型/状态 筛选,导出 CSV(实名信息 + 班型),用于核算报考人数/教材数量。
|
||||
|
||||
## 前端页面(`src/web/pages/nit/`)
|
||||
|
||||
- 学员注册页(实名信息 + 校区下拉 + 隐私协议勾选)
|
||||
- 学员登录页(手机号 + 密码)
|
||||
- 报考页(选班型 + 上传四类文件 + 填缴费时间 + 报名信息表 + 提交)
|
||||
- 「我的报考」状态页(pending/approved/rejected + 驳回原因 + 可重新提交)
|
||||
- 超管校区管理页(CRUD)
|
||||
- 超管报考审核页(列表 + 筛选 + 看图 + 通过/驳回 + 导出)
|
||||
|
||||
`api.ts` 新增 `nitApi`(学员侧,带 student token 注入)与 `adminApi` NIT 审核扩展。
|
||||
复用现有 React/Vite/api.ts 范式与 UI 组件。
|
||||
|
||||
## 错误处理
|
||||
|
||||
- 唯一约束冲突 → 友好提示「该手机号/身份证号已注册」。
|
||||
- 上传非图片或超大文件 → 拒绝并提示。
|
||||
- 报考时存在活动报考 → 拒绝重复提交。
|
||||
- 审核状态机:非 `pending_review` 状态的 approve/reject → 409/冲突提示。
|
||||
|
||||
## 测试策略(TDD)
|
||||
|
||||
后端(rstest + 内存 SQLite,沿用现有 `recharge_test_pool` 范式):
|
||||
- 注册手机号/身份证唯一性、校区校验、隐私协议必填。
|
||||
- 登录成功/失败。
|
||||
- 文件上传返回 asset id。
|
||||
- 报考创建 + 重复报考拦截。
|
||||
- 审核状态机(approve/reject/非法流转)。
|
||||
- 导出筛选正确性。
|
||||
|
||||
前端(vitest):
|
||||
- `nitApi` 各方法 URL/method/body 正确(沿用 `api.test.ts` 范式)。
|
||||
- 注册/报考/审核关键页面交互(沿用 `UsageRecords.test.tsx` 的 react-test-renderer 范式)。
|
||||
|
||||
## 验证
|
||||
|
||||
- `cargo build -j 2`(避免 OOM)、`cargo test -p boosapi --lib nit`(后端)。
|
||||
- `npx vitest run`(相关前端测试)、`npm run typecheck`。
|
||||
|
||||
## 提交约定
|
||||
|
||||
NIT 工作全部在 `nit_edu` 分支,不混入 `refactor/rust-backend`。提交按逻辑拆分
|
||||
(迁移 / service / 路由 / 前端 分开),沿用项目中文 commit 风格。
|
||||
@@ -1,93 +0,0 @@
|
||||
# 数字人接入 Comfy Cloud 视频工作流(完整视频入口)设计
|
||||
|
||||
日期:2026-06-18
|
||||
分支:`feat/comfy-cloud-adapter`
|
||||
状态:已通过调查,待用户复审
|
||||
|
||||
## 背景
|
||||
|
||||
LTX2.3 说话头像工作流(`comfy/video_digital_ltx2.json`)已就绪并通过 Comfy Cloud 校验
|
||||
(节点+模型全兼容,probe `node_errors:{}`)。但 BoosAPI 数字人 UI 没有「视频/动画工作流」
|
||||
入口,无法触发「肖像图 + 旁白音频 → 说话视频」这一步。本设计补齐贯穿三层的入口。
|
||||
|
||||
## 现状(调查结论)
|
||||
|
||||
- **BoosAPI 数字人前端 `src/web/pages/DigitalHuman.tsx`**:仅 3 个选择器——ttsWorkflow、
|
||||
imageWorkflow(从 mediaWorkflows 选,默认含 "image" 的)、frameTemplate(HTML 模板)。
|
||||
**无视频/动画工作流选择器**。流程是「生成图片 + TTS + HTML 模板合成」,不做说话头像动画。
|
||||
- **BoosAPI 后端 `src/routes/digital_human.rs`** 的 `pixelle_request_body`(~line 2053):转发
|
||||
text/mode/title/backend_provider/workflow_source/n_scenes/video_fps/frame_template/
|
||||
template_params/prompt_prefix/bgm_*/media_workflow/tts_workflow/ref_audio。
|
||||
**不转发 `api_video_workflow`**;`workflow_source` 写死 `runninghub`/`selfhost`(无 comfy)。
|
||||
- **Pixelle `pixelle_video/pipelines/asset_based.py`**(~line 693):动画步读
|
||||
`context.request.get("api_video_workflow")`;`audio_path` 仅当 `_get_api_workflow_info`
|
||||
(查 `api_media`,只 `api/` 通道)匹配到 `audio_driven_i2v` 能力时才注入 → **comfy 工作流
|
||||
拿不到 audio_path**。
|
||||
- **Pixelle 工作流执行按前缀路由**:`runninghub` 走 RunningHub,其余(含 `comfy/`)走
|
||||
`comfyui_url`(= BoosAPI `/comfy-cloud` 适配器)。`comfy/video_digital_ltx2.json` 已在
|
||||
`/workflows/media` 列出(已加 `video_` 前缀)。
|
||||
- 已完成的前置:comfy-cloud 适配器(已验证)、`comfy/video_digital_ltx2.json`(标记
|
||||
image_path/audio_path/prompt,probe 通过)、media.py 转发 image_path(已打补丁)。
|
||||
|
||||
## 已确认决策
|
||||
|
||||
1. 加完整视频入口:前端选择器 + 后端转发 + Pixelle 音频注入,三层都改。
|
||||
2. 视频工作流选择器列 `video_*` media 工作流(含 `comfy/video_digital_ltx2.json`)。
|
||||
3. 单人肖像说话头像;不做多人。
|
||||
|
||||
## 三层改动设计
|
||||
|
||||
### 层 1:前端 `src/web/pages/DigitalHuman.tsx`
|
||||
- 新增 state `apiVideoWorkflow`(默认空=不启用动画,保持现有「图片+HTML」行为向后兼容)。
|
||||
- 新增「视频工作流(可选)」`<select>`:选项 = `mediaWorkflows.filter(name 含 "video")`,
|
||||
含空选项「不生成视频(仅图片合成)」。
|
||||
- 提交 payload 增加 `apiVideoWorkflow` 字段;draft 持久化同步。
|
||||
|
||||
### 层 2:BoosAPI 后端 `src/routes/digital_human.rs`
|
||||
- `pixelle_request_body` 增加:
|
||||
```rust
|
||||
if let Some(value) = optional_string(body, &["apiVideoWorkflow", "api_video_workflow"]) {
|
||||
request_body["api_video_workflow"] = json!(value);
|
||||
}
|
||||
```
|
||||
- 若所选视频工作流以 `comfy/` 开头,确保 `workflow_source` 不会阻断(comfy 工作流靠前缀
|
||||
路由,workflow_source 主要影响 media/tts 默认通道;api_video_workflow 独立按前缀执行,
|
||||
无需改 workflow_source。实现时验证不冲突)。
|
||||
- 可选转发 `api_video_params`(若前端给了音频驱动等参数)。本期最小化:只转发 api_video_workflow。
|
||||
|
||||
### 层 3:Pixelle `pixelle_video/pipelines/asset_based.py`
|
||||
- 动画步注入 `audio_path` 的条件放宽:除 `api_media` 能力匹配外,**当 `api_video_workflow`
|
||||
以 `comfy/` 开头时,也注入 `audio_path = frame.audio_path`**(comfy 工作流的 `$~audio_path!`
|
||||
标记需要它)。最小改动:在 `audio_driven_i2v` 判断旁边加 comfy 前缀分支。
|
||||
- 镜像内置文件,改后重启容器,备份到 `/opt/Pixelle-Video/patches/`。
|
||||
|
||||
## 数据流(目标)
|
||||
|
||||
```
|
||||
DigitalHuman.tsx: 选 comfy/video_digital_ltx2.json (apiVideoWorkflow)
|
||||
→ BoosAPI digital_human.rs: request_body.api_video_workflow
|
||||
→ Pixelle /api/video/generate/async: context.request.api_video_workflow
|
||||
→ asset_based.py 动画步: 注入 image_path(media.py已补)+ audio_path(本期补)+ prompt
|
||||
→ media() comfy 前缀 → comfyui_url=/comfy-cloud 适配器 → Comfy Cloud LTX2.3
|
||||
→ 说话视频 → 帧合成/HTML 模板 → 成片
|
||||
```
|
||||
|
||||
## 验证
|
||||
|
||||
- 前端 typecheck:`npm run typecheck:web`。
|
||||
- 后端构建:`cargo build -j 2`。
|
||||
- Pixelle 改动:语法校验 + 重启 healthy。
|
||||
- 端到端:数字人任务选 `comfy/video_digital_ltx2.json`,确认 Comfy Cloud 出现 job、
|
||||
注入了 image_path+audio_path、出说话视频。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不改 comfy-cloud 适配器(已完成)。
|
||||
- 不做多人对话数字人。
|
||||
- 不重构数字人现有「图片+HTML 合成」路径(视频入口为可选叠加,默认不启用,向后兼容)。
|
||||
- 不动 RunningHub 数字人路径。
|
||||
|
||||
## 提交约定
|
||||
|
||||
分支 `feat/comfy-cloud-adapter`。前端、BoosAPI 后端分别提交(项目内,受版本控制);
|
||||
Pixelle 改动在容器内 + 备份 patches/(非本仓库)。中文 commit。
|
||||
@@ -1,71 +0,0 @@
|
||||
# 数字人脚本 LLM 模型可选 设计
|
||||
|
||||
日期:2026-06-18
|
||||
分支:`feat/comfy-cloud-adapter`
|
||||
状态:已通过 brainstorming,待用户复审
|
||||
|
||||
## 背景
|
||||
|
||||
数字人脚本生成的 LLM 模型现在写死在 Pixelle config(`llm.model = gpt-5.5`),用户无法在
|
||||
数字人页面选用别的模型。本功能加一个「脚本模型(可选)」下拉,让用户自选生成脚本的 LLM。
|
||||
生图模型已可通过现有「图片工作流」下拉选择,不在本范围。
|
||||
|
||||
## 已确认决策
|
||||
|
||||
1. 只做脚本 LLM 模型可选;生图模型已由「图片工作流」下拉覆盖,不动。
|
||||
2. 下拉只列文本/对话模型(过滤掉生图/视频模型,避免选错导致生成失败)。
|
||||
3. 默认空 = 用 Pixelle config 的默认模型(`deepseek-v4-flash`),向后兼容。实现时把 Pixelle
|
||||
`config.yaml` 的 `llm.model` 设为 `deepseek-v4-flash`。
|
||||
4. 三层改动(前端选择器 + 后端转发 + Pixelle 传 model)。
|
||||
|
||||
## 集成点(调查结论)
|
||||
|
||||
- Pixelle `llm_service.py`:`llm(prompt=, model=...)` **已支持按调用传 model 覆盖**
|
||||
(优先级:参数 > config)。
|
||||
- Pixelle `asset_based.py` 脚本生成调用 `self.core.llm(prompt=, response_type=VideoScript, ...)`
|
||||
**当前未传 model** → 需补 `model=context.request.get("llm_model")`。
|
||||
- BoosAPI `digital_human.rs` `pixelle_request_body` 转发字段(媒体/tts 工作流等),
|
||||
**未转发 llm_model** → 需补。
|
||||
- 前端模型列表:`api.getModelsMarketplace()`(`/api/models/marketplace`)已有,可作下拉数据源。
|
||||
- `/v1/models` 需鉴权(401 直取不行),故用 marketplace 接口。
|
||||
|
||||
## 三层改动设计
|
||||
|
||||
### 层1 前端 `src/web/pages/DigitalHuman.tsx`
|
||||
- 加 state `llmModel`(默认空)+ draft 持久化。
|
||||
- 加载时调 `api.getModelsMarketplace()` 取模型,过滤掉生图/视频模型(名称含
|
||||
image/video/dall/flux/sd/wan/seedance/kling/banana 等,或按 marketplace 项的类型字段),
|
||||
得文本模型列表 state `llmModels`。
|
||||
- 加「脚本模型(可选)」`<select>`:空选项「默认(deepseek-v4-flash)」+ 文本模型选项。
|
||||
- 提交 payload 加 `llm_model: llmModel || undefined`。
|
||||
|
||||
### 层2 后端 `src/routes/digital_human.rs`
|
||||
- `pixelle_request_body` 加(照搬 tts_workflow 转发模式):
|
||||
```rust
|
||||
if let Some(value) = optional_string(body, &["llmModel", "llm_model"]) {
|
||||
request_body["llm_model"] = json!(value);
|
||||
}
|
||||
```
|
||||
|
||||
### 层3 Pixelle `pixelle_video/pipelines/asset_based.py`
|
||||
- 脚本生成调用前取 `llm_model = context.request.get("llm_model")`,非空则传入:
|
||||
`self.core.llm(prompt=prompt, response_type=VideoScript, temperature=0.8, max_tokens=4000, model=llm_model)`。
|
||||
(llm() 已接受 model 参数;None 时走 config 默认。)
|
||||
- 容器内改 + 备份到 `/opt/Pixelle-Video/patches/`。
|
||||
|
||||
## 验证
|
||||
|
||||
- 前端 `npm run typecheck:web` 干净。
|
||||
- 后端 `cargo build -j 2` 通过。
|
||||
- Pixelle 语法校验 + 重启 healthy。
|
||||
- 端到端:数字人任务选非默认脚本模型,Pixelle 日志出现 `LLM call: model=<所选模型>`。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不改生图模型选择(已由图片工作流下拉覆盖)。
|
||||
- 不做参考图上传(功能 B,留后续)。
|
||||
- 不动 comfy-cloud 适配器、LTX2.3 工作流。
|
||||
|
||||
## 提交约定
|
||||
|
||||
分支 `feat/comfy-cloud-adapter`。前端、后端分别提交;Pixelle 容器内改 + 备份(非本仓库)。中文 commit。
|
||||
@@ -1,93 +0,0 @@
|
||||
# LTX2.3 数字人工作流(Comfy Cloud)设计
|
||||
|
||||
日期:2026-06-18
|
||||
分支:`feat/comfy-cloud-adapter`
|
||||
状态:已通过 brainstorming,待落实现计划
|
||||
|
||||
## 背景
|
||||
|
||||
Pixelle 数字人(说话头像)能力原先只有 RunningHub 形态(`runninghub/digital_*.json` 是
|
||||
`{"source":"runninghub","workflow_id":"..."}` 指针,非 ComfyUI 节点图,无法在 Comfy Cloud 跑)。
|
||||
为让数字人走 Comfy Cloud,采用官方 ComfyUI 模板 `video_ltx2_3_id_lora`(LTX2.3 身份保持
|
||||
LoRA + 音频驱动),把它做成 Pixelle 的 `comfy/` 工作流,经已建好的 comfy-cloud 适配器执行。
|
||||
|
||||
可行性已验证(查 Comfy Cloud `/object_info` 3548 节点 + 模型池):
|
||||
- 模型齐全:`ltx-2.3-22b-dev-fp8`、`ltx-2.3-id-lora-talkvid-3k`、`gemma_3_12B_it_fp4_mixed`、
|
||||
distilled LoRA 均托管;另有 `ltx-2.3-22b-ic-lora-lipdub-0.9` 口型 LoRA。
|
||||
- 节点齐全:LTX2.3 音视频节点(LTXVReferenceAudio/LTXVConcatAVLatent/LTXVAudioVAEDecode 等)
|
||||
全在 Comfy Cloud 节点集中。
|
||||
- 模板 I/O = LoadImage(肖像)+ LoadAudio(旁白)+ 文本 → SaveVideo(带音频说话视频),
|
||||
正好匹配数字人。
|
||||
|
||||
## 已确认决策
|
||||
|
||||
1. 用官方模板 `video_ltx2_3_id_lora`(LTX2.3),不自拼 InfiniteTalk(官方模板天然 Comfy-Cloud
|
||||
兼容、模型已托管、风险低)。
|
||||
2. 接入方式:comfy 通道 + 节点图注入(放 `comfy/digital_ltx2.json`,经适配器),不走 RunningHub、
|
||||
不另做独立入口。
|
||||
3. comfykit 注入机制:节点 `_meta.title` 标记(`$~image!` / `$~audio!` / `$prompt!`)。
|
||||
4. 分支 `feat/comfy-cloud-adapter`(与 comfy-cloud 适配器同分支,属同一能力线)。
|
||||
|
||||
## 前置依赖(硬依赖)
|
||||
|
||||
**需要用户从 Comfy Cloud 导出 API 格式 JSON**:模板当前是 UI 格式(含 subgraph 子图),
|
||||
`/api/prompt` 要求 API 格式(`{node_id:{class_type,inputs,_meta}}`)。子图必须展开。
|
||||
用户操作:Comfy Cloud 网页打开模板/share → Workflow → Export (API Format) → 放服务器
|
||||
(如 `/tmp/ltx2_api.json`)。服务端无法代取(需登录态)。
|
||||
|
||||
## Pixelle media.py 改动(writing-plans 阶段发现的必需项)
|
||||
|
||||
现状:`pixelle_video/services/media.py` 的 `__call__` 只把 `image_path` 转发给 `api/` 通道
|
||||
(`api_media`),**comfy/ 通道的 workflow_params 不含 image_path**。`audio_path` 经 `**params`
|
||||
能进 workflow_params,但肖像图进不去。所以 comfy/ 工作流收不到肖像图。
|
||||
|
||||
改动:在 `media.py` 的非 api/ 分支(构建 `workflow_params` 处),把 `image_path` 也加入
|
||||
`workflow_params`(命名 `image_path`),使其能被 `$~image_path!` 标记注入。一行级小改,
|
||||
但属 Pixelle 源码(非 BoosAPI 仓库),改在容器内 `/app/pixelle_video/services/media.py`,
|
||||
并同步到宿主持久化(确认 media.py 是否在挂载卷;若在镜像内则需固化方案)。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
Pixelle 数字人流程
|
||||
生成肖像图(image_path)+ TTS 旁白(audio_path)+ 脚本文本
|
||||
│ comfykit 按 _meta.title 标记注入到 LoadImage/LoadAudio/文本节点
|
||||
▼
|
||||
comfy/digital_ltx2.json (API 格式 + 标记)
|
||||
│ comfyui_url = http://<boosapi>/comfy-cloud
|
||||
▼
|
||||
Comfy Cloud /api/prompt → LTX2.3(id-lora-talkvid + 可选 lipdub)→ 带音频说话视频
|
||||
▼
|
||||
SaveVideo 产出 → 适配器 /view 取回 → Pixelle HTML 模板合成
|
||||
```
|
||||
|
||||
## 工作流改造(实现步骤)
|
||||
|
||||
基于用户导出的 API-format JSON:
|
||||
1. `LoadImage` 节点 → `_meta.title = "$~image_path!"`(必填、媒体上传;键名对齐 media.py 转发的 image_path)
|
||||
2. `LoadAudio` 节点 → `_meta.title = "$~audio_path!"`(键名对齐 api_video_params 注入的 audio_path)
|
||||
3. 文本提示节点(PrimitiveStringMultiline / CLIPTextEncode)→ `_meta.title = "$prompt!"`(对齐 media.py 的 prompt)
|
||||
4. `SaveVideo` 已知输出节点,自动识别,不标记
|
||||
5. 删除 `RecordAudio` 节点(UI 录音交互,服务端不需要)
|
||||
6. 成品写 `data/workflows/comfy/digital_ltx2.json`(宿主持久化,经 `./data` 挂载)
|
||||
7. 键名以 media.py 实际放入 workflow_params 的为准(image_path/audio_path/prompt),已在 writing-plans 阶段确认。
|
||||
|
||||
## 验证
|
||||
|
||||
- 校验探测:`POST /comfy-cloud/prompt`(body 为改造后 `{prompt:节点图}`)→ 期望
|
||||
`node_errors:{}` + 返回 prompt_id(不跑完整数字人流程,先验证节点+模型全兼容)。
|
||||
- 真实验证:Pixelle 数字人任务选 `comfy/digital_ltx2.json`,出片确认。
|
||||
- 错误处理沿用适配器:节点不支持→400 透传;模型缺失→校验错误透传;执行失败→job failed。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不改 comfy-cloud 适配器代码(已完成且验证)。
|
||||
- 不做多人对话(multi-speaker)数字人,先单人肖像。
|
||||
- 不在 Comfy Cloud 上自训/上传新模型,只用已托管模型。
|
||||
- 不做 RunningHub 数字人的迁移/替换(保留并存)。
|
||||
|
||||
## 提交约定
|
||||
|
||||
分支 `feat/comfy-cloud-adapter`,工作流文件 + 任何 Pixelle 侧配置分开提交,中文 commit。
|
||||
注:工作流文件落在 Pixelle 宿主目录(`/opt/Pixelle-Video/data/workflows/comfy/`),不在
|
||||
BoosAPI 仓库内;若需纳入版本管理另议。
|
||||
@@ -1,90 +0,0 @@
|
||||
# Pixelle 连接凭证纳入 BoosAPI 管理 设计
|
||||
|
||||
日期:2026-06-18
|
||||
分支:`feat/comfy-cloud-adapter`
|
||||
状态:已通过 brainstorming,待用户复审
|
||||
|
||||
## 背景
|
||||
|
||||
Pixelle 的连接凭证(RunningHub API key、Comfy Cloud url/key)现在只能手动编辑
|
||||
`/opt/Pixelle-Video/config.yaml` 再重启容器,很麻烦。本功能把这 3 个凭证纳入 BoosAPI
|
||||
管理控制台,在 UI 里改、一键写入 Pixelle config 并重启生效。
|
||||
|
||||
## 已确认决策
|
||||
|
||||
1. 范围:只管 Pixelle 的 3 个连接凭证 —— `runninghub_api_key`、`comfyui_url`、`comfyui_api_key`。
|
||||
不做整份 config.yaml 编辑,不做通用外部连接管理。
|
||||
2. 同步机制:写文件 + 重启容器(BoosAPI 直接改 bind-mount 的 config.yaml,再 restart pixelle 容器)。
|
||||
不给 Pixelle 加 HTTP 配置端点(避免改 Pixelle 源码)。
|
||||
3. 真相源:BoosAPI settings 表;config.yaml 是被同步的目标。
|
||||
4. 放置:连接管理页(`/accounts`,`src/web/pages/Accounts.tsx`)作为一个卡片/分区。
|
||||
|
||||
## 集成点(调查结论)
|
||||
|
||||
- Pixelle 无配置更新 HTTP 端点;`config.yaml` 是 bind-mount(`/opt/Pixelle-Video/config.yaml`),
|
||||
BoosAPI 跑在宿主,有读写权限。
|
||||
- 凭证在 config.yaml 的 `comfyui:` 段:`runninghub_api_key` / `comfyui_url` / `comfyui_api_key`。
|
||||
- 改后 Pixelle 需重启容器生效(`cd /opt/Pixelle-Video && docker compose restart api`)。
|
||||
- BoosAPI 有 settings 表(key-value)+ builtin_key 掩码范式可复用。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
连接管理页「Pixelle 连接」卡片(3 输入框 + 保存)
|
||||
→ PUT /api/admin/pixelle/connection
|
||||
1. 写 settings 表(pixelle.runninghub_api_key 等)
|
||||
2. 备份 config.yaml → config.yaml.bak-<ts>
|
||||
3. YAML 改 comfyui.{runninghub_api_key,comfyui_url,comfyui_api_key} 三键(其余保留)
|
||||
4. docker compose restart api(cwd=/opt/Pixelle-Video)
|
||||
→ GET /api/admin/pixelle/connection 返回当前值(密钥掩码)
|
||||
```
|
||||
|
||||
## 三层改动设计
|
||||
|
||||
### 层1 后端 service `src/services/pixelle_connection.rs`(新建)
|
||||
- `struct PixelleConnection { runninghub_api_key, comfyui_url, comfyui_api_key }`(均 Option<String>)。
|
||||
- `read_connection(db) -> PixelleConnection`:从 settings 表读 `pixelle.*` 三键。
|
||||
- `save_connection(db, conn)`:三键写 settings 表(UPSERT)。
|
||||
- `sync_to_pixelle(config_path) -> AppResult<()>`:读 yaml → 改 comfyui 三键 → 备份 → 写回。
|
||||
用 `serde_yaml`(确认 Cargo.toml 有;无则加)。只改这三键,其余键原样保留(读成
|
||||
`serde_yaml::Value` 再定点改,不整体重序列化丢注释——注意:serde_yaml 会丢注释,
|
||||
接受此代价,或保留备份)。
|
||||
- `restart_pixelle(compose_dir) -> AppResult<()>`:`docker compose restart api`,cwd=compose_dir。
|
||||
- 配置项:Pixelle config 路径 + compose 目录,默认 `/opt/Pixelle-Video/config.yaml`、
|
||||
`/opt/Pixelle-Video`,从 BoosAPI config(新增 `[pixelle_video] config_path` / 复用现有)读。
|
||||
|
||||
### 层2 后端路由 `src/routes/admin/pixelle_connection.rs`(新建)+ 挂载
|
||||
- `GET /api/admin/pixelle/connection`:返回三值,api_key 掩码(`••••<last4>`)。
|
||||
- `PUT /api/admin/pixelle/connection`:收三字段 → save_connection → sync_to_pixelle → restart。
|
||||
掩码值(`••••`)表示「不改」,跳过该字段。
|
||||
- 挂到 admin router(走现有 require_admin 中间件)。
|
||||
|
||||
### 层3 前端 `src/web/pages/Accounts.tsx` + `api.ts`
|
||||
- `api.ts` adminApi 加 `getPixelleConnection()` / `savePixelleConnection(data)`。
|
||||
- Accounts 页加「Pixelle 连接」卡片:3 输入框(runninghub key、comfy url、comfy key)+ 保存。
|
||||
加载时拉当前值(掩码),保存提示「已保存并重启 Pixelle,约数秒生效」。
|
||||
|
||||
## 错误处理
|
||||
|
||||
- config.yaml 不存在/无写权限 → 503 + 明确提示,**不重启**。
|
||||
- docker restart 失败 → 返回错误,提示「配置已写入,但 Pixelle 重启失败,请手动重启」。
|
||||
- 写文件前必备份;写失败保留原文件。
|
||||
|
||||
## 验证
|
||||
|
||||
- 后端:rstest 用临时 yaml 测 sync_to_pixelle(读→改3键→保留其余→备份生成);
|
||||
`cargo build -j 2`。
|
||||
- 前端:`npm run typecheck:web`。
|
||||
- docker restart 部分手动验证(端到端:UI 改 runninghub key → 确认 config.yaml 变 + 容器重启)。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不做整份 config.yaml 编辑。
|
||||
- 不做通用「外部服务连接」管理(只 Pixelle)。
|
||||
- 不给 Pixelle 加源码/端点。
|
||||
- 不动 comfy-cloud 适配器、数字人工作流。
|
||||
|
||||
## 提交约定
|
||||
|
||||
分支 `feat/comfy-cloud-adapter`。service / 路由 / 前端 分开提交,中文 commit。
|
||||
serde_yaml 若需新增依赖,单独说明。
|
||||
@@ -1,69 +0,0 @@
|
||||
# workflow_ui.rs 拆分(8 子模块)设计
|
||||
|
||||
日期:2026-06-18
|
||||
分支:`feat/comfy-cloud-adapter`
|
||||
状态:已通过 brainstorming,待用户复审
|
||||
|
||||
## 背景
|
||||
|
||||
`src/routes/workflow_ui.rs` 单文件 6181 行,是项目最大文件,难维护/难测/edit 易错。
|
||||
按职责拆成 `src/routes/workflow_ui/` 模块目录(8 子模块)。纯机械移动、零逻辑改动,
|
||||
靠 Rust 编译器 + 现有 5 个测试验证。
|
||||
|
||||
## 已确认决策
|
||||
|
||||
1. 第一个优化目标:拆 workflow_ui.rs(项目最大文件)。
|
||||
2. 拆成 8 子模块(见下表)。
|
||||
3. 纯机械移动:函数体一字不改,只调可见性 + use 路径。零逻辑改动。
|
||||
4. 安全策略:每抽一个子模块即 cargo build -j 2 验证;5 个测试全程保持通过;末尾冒烟。
|
||||
|
||||
## 子模块划分
|
||||
|
||||
| 子模块 | 职责 | 现有行号(约) |
|
||||
|--------|------|--------------|
|
||||
| `mod.rs` | routes() 装配 + 共享类型(WorkflowCaller、Query/Payload structs)+ 子模块声明 | 56-124 |
|
||||
| `assets.rs` | UI 静态资源服务、嵌入目录解析、index/static/serve_file/官方模板资源 | 211-305, 1533-1814 |
|
||||
| `auth.rs` | caller 解析(caller_from_headers/auth_caller/caller_user_id/caller_is_admin) | 313-363, 1875 |
|
||||
| `node_defs.rs` | 节点定义 + 工作流模板(core_node_definitions、*_workflow_graph、node_type_def_to_comfy) | 429-932 |
|
||||
| `models.rs` | 模型分类 + folder 模型(is_*_workflow_model、workflow_folder_*、native_folder_models) | 939-1530 |
|
||||
| `comfy_proxy.rs` | ComfyUI API 代理 handler(settings/userdata/object_info/system_stats/extensions 等) | 1883-2200 |
|
||||
| `validate.rs` | 工作流校验(validate_workflow* 三个) | 2206-2586 |
|
||||
| `queue.rs` | 队列/历史/prompt(queue/history/queue_prompt/repair_native_prompt_payload/queue_boosapi_prompt/view) | 2586-3679 |
|
||||
|
||||
> 注:行号是当前快照,实现时以函数名定位为准(文件会随抽取缩短)。各子模块的辅助小函数
|
||||
> 随其主要消费者走;跨多模块共用的(如 guess_mime_from_path、parse_boolish、safe_view_path)
|
||||
> 放 mod.rs 或新建 util.rs(实现时按实际依赖定,优先放 mod.rs 顶部 pub(super))。
|
||||
|
||||
## 安全策略(核心)
|
||||
|
||||
1. **纯移动**:剪切函数到子模块,函数体一字不改。仅:
|
||||
- 可见性:跨模块调用的改 `pub(super)` / `pub(crate)`;
|
||||
- `use`:子模块顶部补齐它用到的依赖路径(crate::、super::、外部 crate)。
|
||||
2. **逐模块验证**:每抽一个子模块就 `cargo build -j 2`。Rust 编译器对未解析引用/类型不符
|
||||
直接报错——这是纯移动重构的强安全网。
|
||||
3. **测试保持**:现有 5 个测试(在文件尾 `#[cfg(test)] mod tests`)迁到相关子模块(多与
|
||||
models/node_defs 相关),全程保持通过:`cargo test -p boosapi --lib routes::workflow_ui`。
|
||||
4. **末尾冒烟**:全部完成后,工作流 UI 打开 + 跑一个 prompt,确认行为不变。
|
||||
|
||||
## 数据流
|
||||
|
||||
不变。`routes/mod.rs` 仍 `.merge(workflow_ui::routes())`,对外接口零变化。仅文件内部
|
||||
组织从 1 文件变 8 文件,所有路由路径、handler 行为、类型签名完全一致。
|
||||
|
||||
## 测试
|
||||
|
||||
- 编译器:每步 cargo build -j 2(主验证手段)。
|
||||
- 单测:迁移后 5 个测试保持通过。
|
||||
- 冒烟:手动开工作流 UI + 跑 prompt。
|
||||
- 不新增功能测试(本任务是重构,非加功能;但若迁移中发现某函数明显缺测且易加,可顺手补)。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不改任何逻辑、不改路由路径、不改 handler 行为。
|
||||
- 不拆其他巨文件(oauth.rs/digital_human.rs/user/mod.rs 各自独立后续循环)。
|
||||
- 不顺手重构/优化函数实现(纯搬运)。
|
||||
|
||||
## 提交约定
|
||||
|
||||
分支 `feat/comfy-cloud-adapter`。**按子模块分多次提交**(每抽 1-2 个子模块 + 编译通过即提交),
|
||||
便于回溯;不一锅烩。中文 commit。
|
||||
@@ -29,7 +29,6 @@
|
||||
"dev": "vite",
|
||||
"build:web": "vite build",
|
||||
"build": "npm run build:web",
|
||||
"build:all": "node scripts/build-all.mjs",
|
||||
"typecheck:web": "tsc -p tsconfig.web.json",
|
||||
"typecheck:web:test": "tsc -p tsconfig.web.test.json",
|
||||
"typecheck": "npm run typecheck:web && npm run typecheck:web:test",
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# 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 镜像拉取超时,需排查网络 |
|
||||
@@ -0,0 +1,340 @@
|
||||
# 内置 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
@@ -1,53 +0,0 @@
|
||||
#!/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');
|
||||
@@ -98,10 +98,6 @@ pub struct PixelleVideoConfig {
|
||||
pub video_fps: i64,
|
||||
pub poll_interval_secs: u64,
|
||||
pub timeout_secs: u64,
|
||||
#[serde(default = "default_pixelle_config_path")]
|
||||
pub config_path: String,
|
||||
#[serde(default = "default_pixelle_compose_dir")]
|
||||
pub compose_dir: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
@@ -169,14 +165,6 @@ fn default_pixelle_backend_provider() -> String {
|
||||
"comfy".to_string()
|
||||
}
|
||||
|
||||
fn default_pixelle_config_path() -> String {
|
||||
"/opt/Pixelle-Video/config.yaml".to_string()
|
||||
}
|
||||
|
||||
fn default_pixelle_compose_dir() -> String {
|
||||
"/opt/Pixelle-Video".to_string()
|
||||
}
|
||||
|
||||
fn default_workflow_points_cost() -> f64 {
|
||||
10.0
|
||||
}
|
||||
@@ -229,8 +217,6 @@ impl Default for PixelleVideoConfig {
|
||||
video_fps: 30,
|
||||
poll_interval_secs: 5,
|
||||
timeout_secs: 900,
|
||||
config_path: default_pixelle_config_path(),
|
||||
compose_dir: default_pixelle_compose_dir(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-7
@@ -57,15 +57,9 @@ impl AppState {
|
||||
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 {
|
||||
config,
|
||||
db,
|
||||
db: Arc::new(db),
|
||||
http,
|
||||
comfyui,
|
||||
builtin_keys: Arc::new(builtin_keys),
|
||||
|
||||
+6
-4
@@ -144,10 +144,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
let builtin = builtin_keys::BuiltinKeys {
|
||||
comfyui_agent: comfyui_agent_key,
|
||||
};
|
||||
let runtime_auth =
|
||||
runtime_auth::RuntimeAuthSettings::load(&pool, config.auth.proxy_token.clone())
|
||||
.await
|
||||
.context("failed to load runtime auth settings")?;
|
||||
let runtime_auth = runtime_auth::RuntimeAuthSettings::load(
|
||||
&pool,
|
||||
config.auth.proxy_token.clone(),
|
||||
)
|
||||
.await
|
||||
.context("failed to load runtime auth settings")?;
|
||||
|
||||
// Build app state
|
||||
let state = AppState::new(config.clone(), pool, builtin, runtime_auth);
|
||||
|
||||
+35
-122
@@ -589,8 +589,7 @@ pub async fn execute_video_generation(
|
||||
}
|
||||
|
||||
fn comfy_video_template_id(route: &UpstreamRoute) -> Option<String> {
|
||||
route
|
||||
.base_url
|
||||
route.base_url
|
||||
.strip_prefix("comfy://")
|
||||
.or_else(|| route.base_url.strip_prefix("comfy-template://"))
|
||||
.or_else(|| route.model.strip_prefix("comfy://"))
|
||||
@@ -615,16 +614,14 @@ async fn execute_comfy_video_generation(
|
||||
let workflow = crate::workflow::storage::get_workflow(&state.db, template_id, None, true)
|
||||
.await
|
||||
.map_err(|err| AppError::BadRequest(format!("Comfy 视频模型模板不可用: {err}")))?;
|
||||
let mut prompt = workflow_prompt_from_graph(&workflow.graph).ok_or_else(|| {
|
||||
AppError::BadRequest("Comfy 视频模型模板缺少 prompt/workflow JSON".into())
|
||||
})?;
|
||||
let mut prompt = workflow_prompt_from_graph(&workflow.graph)
|
||||
.ok_or_else(|| AppError::BadRequest("Comfy 视频模型模板缺少 prompt/workflow JSON".into()))?;
|
||||
if prompt_contains_class_type(&prompt, "BoosAPITextToVideo") {
|
||||
return Err(AppError::BadRequest(
|
||||
"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"]) {
|
||||
mapping = infer_comfy_mapping(&prompt);
|
||||
}
|
||||
@@ -647,9 +644,7 @@ async fn execute_comfy_video_generation(
|
||||
.or_else(|| response.pointer("/data/prompt_id"))
|
||||
.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!({
|
||||
"id": prompt_id,
|
||||
@@ -737,8 +732,7 @@ fn scalar_string(value: Option<&Value>) -> Option<String> {
|
||||
}
|
||||
|
||||
fn workflow_prompt_from_graph(graph: &Value) -> Option<Value> {
|
||||
graph
|
||||
.get("prompt")
|
||||
graph.get("prompt")
|
||||
.cloned()
|
||||
.or_else(|| graph.get("workflow").cloned())
|
||||
.or_else(|| graph.get("comfyPrompt").cloned())
|
||||
@@ -747,8 +741,7 @@ fn workflow_prompt_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("/metadata/video/nodeMapping"))
|
||||
.or_else(|| graph.pointer("/video/nodeMapping"))
|
||||
@@ -779,24 +772,11 @@ fn graph_to_comfy_prompt(graph: &Value) -> Option<Value> {
|
||||
.unwrap_or_default();
|
||||
prompt.insert(id, json!({ "class_type": class_type, "inputs": inputs }));
|
||||
}
|
||||
for edge in graph
|
||||
.get("edges")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.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;
|
||||
};
|
||||
for edge in graph.get("edges").and_then(Value::as_array).into_iter().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
|
||||
.get_mut(to_id)
|
||||
.and_then(Value::as_object_mut)
|
||||
@@ -826,12 +806,7 @@ fn mapping_value<'a>(mapping: &'a Value, keys: &[&str]) -> Option<&'a Value> {
|
||||
fn mapping_node_id(mapping: &Value, keys: &[&str]) -> Option<String> {
|
||||
mapping_value(mapping, keys).and_then(|value| {
|
||||
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 {
|
||||
scalar_string(Some(value))
|
||||
}
|
||||
@@ -854,10 +829,7 @@ fn mapping_has_node(mapping: &Value, keys: &[&str]) -> bool {
|
||||
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
|
||||
.iter()
|
||||
.find(|(_, node)| node.get("class_type").and_then(Value::as_str) == Some(class_type))
|
||||
@@ -871,22 +843,11 @@ fn infer_comfy_mapping(prompt: &Value) -> Value {
|
||||
let prompt_node = object
|
||||
.iter()
|
||||
.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
|
||||
.get("inputs")
|
||||
.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();
|
||||
class_type.contains("text")
|
||||
|| class_type.contains("prompt")
|
||||
@@ -914,29 +875,19 @@ fn infer_comfy_mapping(prompt: &Value) -> Value {
|
||||
|
||||
fn apply_node_input(prompt: &mut Value, node_id: &str, field: &str, value: Value) -> AppResult<()> {
|
||||
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 {
|
||||
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 {
|
||||
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);
|
||||
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"))
|
||||
.ok_or_else(|| AppError::BadRequest("prompt is required for Comfy video models".into()))?;
|
||||
let prompt_node = mapping_node_id(mapping, &["prompt", "positive", "text", "promptNode"])
|
||||
@@ -944,56 +895,27 @@ fn inject_comfy_video_inputs(
|
||||
apply_node_input(
|
||||
prompt,
|
||||
&prompt_node,
|
||||
&mapping_field(
|
||||
mapping,
|
||||
&["prompt", "positive", "text", "promptNode"],
|
||||
"prompt",
|
||||
),
|
||||
&mapping_field(mapping, &["prompt", "positive", "text", "promptNode"], "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")))
|
||||
.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(
|
||||
prompt,
|
||||
&negative_node,
|
||||
&mapping_field(
|
||||
mapping,
|
||||
&["negativePrompt", "negative", "negativeNode"],
|
||||
"text",
|
||||
),
|
||||
&mapping_field(mapping, &["negativePrompt", "negative", "negativeNode"], "text"),
|
||||
json!(negative),
|
||||
)?;
|
||||
}
|
||||
for (keys, request_keys, default_field) in [
|
||||
(
|
||||
&["ratio", "ratioNode"][..],
|
||||
&["ratio", "aspectRatio", "aspect_ratio"][..],
|
||||
"ratio",
|
||||
),
|
||||
(
|
||||
&["resolution", "resolutionNode"][..],
|
||||
&["resolution", "size"][..],
|
||||
"resolution",
|
||||
),
|
||||
(
|
||||
&["duration", "durationNode"][..],
|
||||
&["duration"][..],
|
||||
"duration",
|
||||
),
|
||||
(&["ratio", "ratioNode"][..], &["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(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)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1007,13 +929,9 @@ fn prompt_contains_class_type(prompt: &Value, class_type: &str) -> bool {
|
||||
.get("class_type")
|
||||
.and_then(Value::as_str)
|
||||
.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,
|
||||
}
|
||||
}
|
||||
@@ -1106,10 +1024,7 @@ fn comfy_failure_message(value: &Value) -> Option<String> {
|
||||
let status = scalar_string(value.get("status").or_else(|| value.get("state")))
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
if matches!(
|
||||
status.as_str(),
|
||||
"failed" | "error" | "cancelled" | "canceled"
|
||||
) {
|
||||
if matches!(status.as_str(), "failed" | "error" | "cancelled" | "canceled") {
|
||||
return Some(
|
||||
scalar_string(value.pointer("/execution_error/exception_message"))
|
||||
.or_else(|| scalar_string(value.get("error")))
|
||||
@@ -1123,12 +1038,10 @@ fn comfy_failure_message(value: &Value) -> Option<String> {
|
||||
fn comfy_output_objects(value: &Value) -> bool {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
["videos", "gifs", "images"].iter().any(|key| {
|
||||
object
|
||||
.get(*key)
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|items| !items.is_empty())
|
||||
}) || object.values().any(comfy_output_objects)
|
||||
["videos", "gifs", "images"]
|
||||
.iter()
|
||||
.any(|key| object.get(*key).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),
|
||||
_ => false,
|
||||
|
||||
@@ -305,7 +305,8 @@ fn account_response_value(
|
||||
route_id,
|
||||
channel_id,
|
||||
log_id,
|
||||
)) = runtime_health
|
||||
)) =
|
||||
runtime_health
|
||||
{
|
||||
let ok = status == "success";
|
||||
let is_degraded_latency =
|
||||
@@ -313,11 +314,7 @@ fn account_response_value(
|
||||
let model = model_actual
|
||||
.as_ref()
|
||||
.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!({
|
||||
"state": if !ok {
|
||||
"unhealthy"
|
||||
|
||||
+34
-167
@@ -20,7 +20,7 @@ use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
use sqlx::Row;
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::collections::HashMap;
|
||||
|
||||
static PROXY_TEST_JOBS: Lazy<RwLock<HashMap<String, ProxyTestJob>>> =
|
||||
Lazy::new(|| RwLock::new(HashMap::new()));
|
||||
@@ -1242,14 +1242,6 @@ async fn runtime_settings(State(state): State<AppState>) -> AppResult<Json<Value
|
||||
false,
|
||||
)),
|
||||
);
|
||||
body.insert(
|
||||
"registrationDisabled".to_string(),
|
||||
json!(setting_bool(
|
||||
&settings,
|
||||
"runtime.registration_disabled",
|
||||
false,
|
||||
)),
|
||||
);
|
||||
body.insert(
|
||||
"codexUpstreamWebsocketEnabled".to_string(),
|
||||
json!(setting_bool(
|
||||
@@ -1549,7 +1541,6 @@ struct RuntimeSettingsUpdateRequest {
|
||||
system_proxy_url: Option<String>,
|
||||
payload_rules: Option<Value>,
|
||||
model_availability_probe_enabled: Option<bool>,
|
||||
registration_disabled: Option<bool>,
|
||||
codex_upstream_websocket_enabled: Option<bool>,
|
||||
responses_compact_fallback_to_responses_enabled: Option<bool>,
|
||||
disable_cross_protocol_fallback: Option<bool>,
|
||||
@@ -1633,14 +1624,6 @@ async fn update_runtime_settings(
|
||||
)
|
||||
.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 {
|
||||
upsert_setting(
|
||||
&state,
|
||||
@@ -1887,102 +1870,8 @@ async fn update_runtime_settings(
|
||||
Ok(Json(body))
|
||||
}
|
||||
|
||||
async fn brand_list(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
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 brand_list() -> Json<Value> {
|
||||
Json(json!({ "brands": ["BoosAPI"], "current": "BoosAPI" }))
|
||||
}
|
||||
|
||||
async fn test_system_proxy() -> Json<Value> {
|
||||
@@ -2063,9 +1952,7 @@ fn normalize_proxy_test_path(path: &str) -> AppResult<String> {
|
||||
));
|
||||
}
|
||||
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 allowed = [
|
||||
@@ -2081,8 +1968,7 @@ fn normalize_proxy_test_path(path: &str) -> AppResult<String> {
|
||||
"/v1/videos",
|
||||
];
|
||||
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);
|
||||
if allowed_exact || allowed_video_task || allowed_gemini {
|
||||
return Ok(strip_legacy_gemini_proxy_prefix(normalized).to_string());
|
||||
@@ -2102,9 +1988,9 @@ fn path_suffix_for_proxy_test(path: &str) -> AppResult<String> {
|
||||
if is_gemini_proxy_test_path(without_query) {
|
||||
return Ok(without_query.trim_start_matches('/').to_string());
|
||||
}
|
||||
let suffix = without_query.strip_prefix("/v1").ok_or_else(|| {
|
||||
AppError::BadRequest(format!("unsupported proxy test path: {without_query}"))
|
||||
})?;
|
||||
let suffix = without_query
|
||||
.strip_prefix("/v1")
|
||||
.ok_or_else(|| AppError::BadRequest(format!("unsupported proxy test path: {without_query}")))?;
|
||||
Ok(if suffix.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
@@ -2203,9 +2089,7 @@ fn native_gemini_proxy_test_url(base_url: &str, path: &str) -> AppResult<String>
|
||||
let normalized = normalize_proxy_test_path(path)?;
|
||||
let trimmed_base = base_url.trim().trim_end_matches('/');
|
||||
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/") {
|
||||
trimmed_base.to_string()
|
||||
@@ -2397,10 +2281,7 @@ async fn execute_proxy_test(
|
||||
) -> Result<Value, Value> {
|
||||
if envelope.request_kind == "multipart"
|
||||
|| 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(
|
||||
&envelope,
|
||||
@@ -2522,9 +2403,11 @@ async fn stream_proxy_test(
|
||||
Json(json!({ "error": error_json_for_proxy_test(&envelope, err) })),
|
||||
)
|
||||
})?;
|
||||
let stream = response
|
||||
.bytes_stream()
|
||||
.map(|chunk| chunk.map_err(std::io::Error::other).map(bytes::Bytes::from));
|
||||
let stream = response.bytes_stream().map(|chunk| {
|
||||
chunk
|
||||
.map_err(std::io::Error::other)
|
||||
.map(bytes::Bytes::from)
|
||||
});
|
||||
return Response::builder()
|
||||
.header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
|
||||
.header(header::CACHE_CONTROL, "no-cache, no-transform")
|
||||
@@ -2558,9 +2441,11 @@ async fn stream_proxy_test(
|
||||
)
|
||||
})?;
|
||||
|
||||
let stream = response
|
||||
.bytes_stream()
|
||||
.map(|chunk| chunk.map_err(std::io::Error::other).map(bytes::Bytes::from));
|
||||
let stream = response.bytes_stream().map(|chunk| {
|
||||
chunk
|
||||
.map_err(std::io::Error::other)
|
||||
.map(bytes::Bytes::from)
|
||||
});
|
||||
Response::builder()
|
||||
.header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
|
||||
.header(header::CACHE_CONTROL, "no-cache, no-transform")
|
||||
@@ -2955,8 +2840,6 @@ mod tests {
|
||||
template_proxy_url: None,
|
||||
},
|
||||
comfyui: Default::default(),
|
||||
pixelle_video: Default::default(),
|
||||
payment: Default::default(),
|
||||
},
|
||||
pool,
|
||||
BuiltinKeys {
|
||||
@@ -2969,7 +2852,8 @@ mod tests {
|
||||
#[test]
|
||||
fn proxy_test_accepts_standard_and_legacy_gemini_paths() {
|
||||
assert_eq!(
|
||||
normalize_proxy_test_path("/v1beta/models/gemini-2.5-pro:generateContent").unwrap(),
|
||||
normalize_proxy_test_path("/v1beta/models/gemini-2.5-pro:generateContent")
|
||||
.unwrap(),
|
||||
"/v1beta/models/gemini-2.5-pro:generateContent"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -3097,10 +2981,12 @@ mod tests {
|
||||
let error = result.expect_err("unknown paths should fail");
|
||||
|
||||
assert_eq!(error["path"], "/api/test/proxy/jobs");
|
||||
assert!(error["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("unsupported proxy test path"));
|
||||
assert!(
|
||||
error["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("unsupported proxy test path")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3172,10 +3058,12 @@ mod tests {
|
||||
|
||||
assert_eq!(error["path"], "/v1/chat/completions");
|
||||
assert_eq!(error["forcedChannelId"], 77);
|
||||
assert!(error["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("Forced channel #77"));
|
||||
assert!(
|
||||
error["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("Forced channel #77")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3242,25 +3130,4 @@ mod tests {
|
||||
|
||||
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,8 +412,6 @@ mod tests {
|
||||
template_proxy_url: None,
|
||||
},
|
||||
comfyui: Default::default(),
|
||||
pixelle_video: Default::default(),
|
||||
payment: Default::default(),
|
||||
},
|
||||
pool,
|
||||
BuiltinKeys {
|
||||
|
||||
@@ -21,7 +21,6 @@ mod compat;
|
||||
mod downstream_keys;
|
||||
mod model_candidates;
|
||||
pub(crate) mod models_marketplace;
|
||||
mod pixelle_connection;
|
||||
mod sites;
|
||||
mod tokens;
|
||||
mod users;
|
||||
@@ -192,10 +191,6 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
||||
"/api/admin/user-balances",
|
||||
get(users::list_user_balances),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/pixelle/connection",
|
||||
get(pixelle_connection::get_connection).put(pixelle_connection::put_connection),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/users/{id}/balance/adjust",
|
||||
post(users::adjust_user_balance),
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
use crate::error::AppResult;
|
||||
use crate::services::pixelle_connection::{self, PixelleConnection};
|
||||
use crate::AppState;
|
||||
use axum::{extract::State, Json};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const MASK_SENTINEL: &str = "••••";
|
||||
|
||||
fn mask(v: &Option<String>) -> Value {
|
||||
match v {
|
||||
Some(s) if s.chars().count() > 4 => {
|
||||
let last4: String = s.chars().rev().take(4).collect::<Vec<_>>().into_iter().rev().collect();
|
||||
json!(format!("{}{}", MASK_SENTINEL, last4))
|
||||
}
|
||||
Some(s) if !s.is_empty() => json!(MASK_SENTINEL),
|
||||
_ => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_connection(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let c = pixelle_connection::read_connection(&state.db).await?;
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"connection": {
|
||||
"comfyuiUrl": c.comfyui_url,
|
||||
"runninghubApiKey": mask(&c.runninghub_api_key),
|
||||
"comfyuiApiKey": mask(&c.comfyui_api_key),
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdatePayload {
|
||||
runninghub_api_key: Option<String>,
|
||||
comfyui_url: Option<String>,
|
||||
comfyui_api_key: Option<String>,
|
||||
}
|
||||
|
||||
fn keep_if_not_mask(incoming: Option<String>) -> Option<String> {
|
||||
match incoming {
|
||||
Some(s) if s.starts_with(MASK_SENTINEL) => None,
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn put_connection(
|
||||
State(state): State<AppState>,
|
||||
Json(p): Json<UpdatePayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let conn = PixelleConnection {
|
||||
runninghub_api_key: keep_if_not_mask(p.runninghub_api_key),
|
||||
comfyui_url: p.comfyui_url,
|
||||
comfyui_api_key: keep_if_not_mask(p.comfyui_api_key),
|
||||
};
|
||||
pixelle_connection::save_connection(&state.db, &conn).await?;
|
||||
let backup = pixelle_connection::sync_to_pixelle(&state.config.pixelle_video.config_path, &conn)?;
|
||||
let restart = pixelle_connection::restart_pixelle(&state.config.pixelle_video.compose_dir).await;
|
||||
match restart {
|
||||
Ok(()) => Ok(Json(json!({ "success": true, "backup": backup, "restarted": true }))),
|
||||
Err(e) => Ok(Json(json!({
|
||||
"success": true, "backup": backup, "restarted": false,
|
||||
"warning": format!("配置已写入,但 Pixelle 重启失败,请手动重启: {e}")
|
||||
}))),
|
||||
}
|
||||
}
|
||||
+18
-56
@@ -913,14 +913,9 @@ async fn probe_site_model(
|
||||
|
||||
match response {
|
||||
Ok(_) if latency_exceeded => {
|
||||
if let Err(err) = update_model_probe_latency(
|
||||
state,
|
||||
selected.account_id,
|
||||
&model.name,
|
||||
latency_ms,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
if let Err(err) =
|
||||
update_model_probe_latency(state, selected.account_id, &model.name, latency_ms, true)
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, model = %model.name, "failed to save probe latency");
|
||||
}
|
||||
@@ -929,10 +924,7 @@ async fn probe_site_model(
|
||||
status: "unsupported".to_string(),
|
||||
latency_ms: Some(latency_ms),
|
||||
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,
|
||||
stage: Some("model"),
|
||||
source: Some("probe"),
|
||||
@@ -955,14 +947,9 @@ async fn probe_site_model(
|
||||
warn!(channel_id, error = %err, "failed to record probe success");
|
||||
}
|
||||
}
|
||||
if let Err(err) = update_model_probe_latency(
|
||||
state,
|
||||
selected.account_id,
|
||||
&model.name,
|
||||
latency_ms,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
if let Err(err) =
|
||||
update_model_probe_latency(state, selected.account_id, &model.name, latency_ms, true)
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, model = %model.name, "failed to save probe latency");
|
||||
}
|
||||
@@ -995,14 +982,9 @@ async fn probe_site_model(
|
||||
warn!(channel_id, error = %record_err, "failed to record probe failure");
|
||||
}
|
||||
}
|
||||
if let Err(update_err) = update_model_probe_latency(
|
||||
state,
|
||||
selected.account_id,
|
||||
&model.name,
|
||||
latency_ms,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
if let Err(update_err) =
|
||||
update_model_probe_latency(state, selected.account_id, &model.name, latency_ms, false)
|
||||
.await
|
||||
{
|
||||
warn!(error = %update_err, model = %model.name, "failed to save failed probe state");
|
||||
}
|
||||
@@ -1059,8 +1041,7 @@ async fn resolve_probe_channel(
|
||||
.bind(plan.site.id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
fallback
|
||||
.ok_or_else(|| AppError::BadRequest("当前站点没有启用中的账号,无法探测".into()))?
|
||||
fallback.ok_or_else(|| AppError::BadRequest("当前站点没有启用中的账号,无法探测".into()))?
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1086,9 +1067,7 @@ async fn resolve_probe_channel(
|
||||
};
|
||||
|
||||
if api_key.trim().is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"当前站点账号缺少可用 Token,无法探测".into(),
|
||||
));
|
||||
return Err(AppError::BadRequest("当前站点账号缺少可用 Token,无法探测".into()));
|
||||
}
|
||||
|
||||
Ok(crate::proxy::conductor::SelectedChannel {
|
||||
@@ -1186,10 +1165,8 @@ fn send_probe_sse(
|
||||
tx.send(Ok(Event::default()
|
||||
.event(event_type)
|
||||
.json_data(payload)
|
||||
.unwrap_or_else(|_| {
|
||||
Event::default().event(event_type).data("{}")
|
||||
})))
|
||||
.is_ok()
|
||||
.unwrap_or_else(|_| Event::default().event(event_type).data("{}"))))
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn probe_error_message(err: &AppError) -> String {
|
||||
@@ -1409,9 +1386,6 @@ fn detect_site_platform(url: &str) -> (&'static str, Option<&'static str>) {
|
||||
{
|
||||
return ("openai", Some("volcengine-ark-openai"));
|
||||
}
|
||||
if lower.starts_with("https://cloud.comfy.org") {
|
||||
return ("comfy_cloud", Some("comfy-cloud"));
|
||||
}
|
||||
|
||||
if lower.contains("anthropic") || lower.contains("claude") {
|
||||
return ("claude", None);
|
||||
@@ -1542,14 +1516,8 @@ mod tests {
|
||||
|
||||
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("source").and_then(Value::as_str),
|
||||
Some("upstream")
|
||||
);
|
||||
assert_eq!(
|
||||
payload.get("modelName").and_then(Value::as_str),
|
||||
Some("gpt-4o")
|
||||
);
|
||||
assert_eq!(payload.get("source").and_then(Value::as_str), Some("upstream"));
|
||||
assert_eq!(payload.get("modelName").and_then(Value::as_str), Some("gpt-4o"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1560,15 +1528,9 @@ mod tests {
|
||||
|
||||
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("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("modelName").and_then(Value::as_str),
|
||||
Some("gpt-4o")
|
||||
);
|
||||
assert_eq!(payload.get("modelName").and_then(Value::as_str), Some("gpt-4o"));
|
||||
assert_eq!(
|
||||
payload.get("message").and_then(Value::as_str),
|
||||
Some("上游返回 HTTP 404 Not Found:model missing")
|
||||
|
||||
+166
-206
@@ -19,9 +19,6 @@ use serde_json::{json, Value};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
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 ─────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -111,21 +108,6 @@ async fn register(
|
||||
) -> AppResult<Json<Value>> {
|
||||
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() {
|
||||
return Err(AppError::BadRequest(
|
||||
"email and username are required".into(),
|
||||
@@ -195,12 +177,11 @@ async fn register(
|
||||
let default_key = format!("sk-user-{}", uuid::Uuid::new_v4().simple());
|
||||
let default_key_name = format!("{} 的默认密钥", payload.username.trim());
|
||||
sqlx::query(
|
||||
"INSERT INTO downstream_api_keys (name, key, user_id, enabled, max_cost) VALUES (?, ?, ?, 1, ?)",
|
||||
"INSERT INTO downstream_api_keys (name, key, user_id, enabled) VALUES (?, ?, ?, 1)",
|
||||
)
|
||||
.bind(&default_key_name)
|
||||
.bind(&default_key)
|
||||
.bind(user_id)
|
||||
.bind(DEFAULT_NEW_USER_MAX_COST_USD)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
@@ -256,66 +237,72 @@ async fn login(
|
||||
let user_agent = user_agent_from_headers(&headers);
|
||||
let email = payload.email.trim().to_string();
|
||||
|
||||
let user: User = if let Some(user) =
|
||||
sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?")
|
||||
.bind(&email)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
let user: User = if let Some(user) = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?")
|
||||
.bind(&email)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
{
|
||||
user
|
||||
} else {
|
||||
audit_user_login(
|
||||
&state,
|
||||
LoginAuditEvent {
|
||||
method: LoginMethod::Password,
|
||||
success: false,
|
||||
user: None,
|
||||
identifier: Some(&email),
|
||||
api_key: None,
|
||||
client_ip: &client_ip,
|
||||
user_agent: user_agent.as_deref(),
|
||||
reason: Some("Invalid email or password"),
|
||||
},
|
||||
)
|
||||
audit_user_login(&state, LoginAuditEvent {
|
||||
method: LoginMethod::Password,
|
||||
success: false,
|
||||
user: None,
|
||||
identifier: Some(&email),
|
||||
api_key: None,
|
||||
client_ip: &client_ip,
|
||||
user_agent: user_agent.as_deref(),
|
||||
reason: Some("Invalid email or password"),
|
||||
})
|
||||
.await;
|
||||
return Err(AppError::Unauthorized("Invalid email or password".into()));
|
||||
};
|
||||
|
||||
if user.status != "active" {
|
||||
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("Account is disabled"),
|
||||
},
|
||||
)
|
||||
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("Account is disabled"),
|
||||
})
|
||||
.await;
|
||||
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)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Password verification failed: {e}")))?;
|
||||
|
||||
if !valid {
|
||||
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("Invalid email or password"),
|
||||
},
|
||||
)
|
||||
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("Invalid email or password"),
|
||||
})
|
||||
.await;
|
||||
return Err(AppError::Unauthorized("Invalid email or password".into()));
|
||||
}
|
||||
@@ -328,19 +315,16 @@ async fn login(
|
||||
)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
|
||||
|
||||
audit_user_login(
|
||||
&state,
|
||||
LoginAuditEvent {
|
||||
method: LoginMethod::Password,
|
||||
success: true,
|
||||
user: Some(&user),
|
||||
identifier: Some(&email),
|
||||
api_key: None,
|
||||
client_ip: &client_ip,
|
||||
user_agent: user_agent.as_deref(),
|
||||
reason: None,
|
||||
},
|
||||
)
|
||||
audit_user_login(&state, LoginAuditEvent {
|
||||
method: LoginMethod::Password,
|
||||
success: true,
|
||||
user: Some(&user),
|
||||
identifier: Some(&email),
|
||||
api_key: None,
|
||||
client_ip: &client_ip,
|
||||
user_agent: user_agent.as_deref(),
|
||||
reason: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
Ok(Json(json!({
|
||||
@@ -381,19 +365,16 @@ async fn login_api_key(
|
||||
let user_agent = user_agent_from_headers(&headers);
|
||||
let key = payload.api_key.trim();
|
||||
if key.is_empty() {
|
||||
audit_user_login(
|
||||
&state,
|
||||
LoginAuditEvent {
|
||||
method: LoginMethod::ApiKey,
|
||||
success: false,
|
||||
user: None,
|
||||
identifier: None,
|
||||
api_key: None,
|
||||
client_ip: &client_ip,
|
||||
user_agent: user_agent.as_deref(),
|
||||
reason: Some("api_key is required"),
|
||||
},
|
||||
)
|
||||
audit_user_login(&state, LoginAuditEvent {
|
||||
method: LoginMethod::ApiKey,
|
||||
success: false,
|
||||
user: None,
|
||||
identifier: None,
|
||||
api_key: None,
|
||||
client_ip: &client_ip,
|
||||
user_agent: user_agent.as_deref(),
|
||||
reason: Some("api_key is required"),
|
||||
})
|
||||
.await;
|
||||
return Err(AppError::BadRequest("api_key is required".into()));
|
||||
}
|
||||
@@ -409,86 +390,87 @@ async fn login_api_key(
|
||||
api_key
|
||||
} else {
|
||||
let masked_key = mask_api_key(key);
|
||||
audit_user_login(
|
||||
&state,
|
||||
LoginAuditEvent {
|
||||
method: LoginMethod::ApiKey,
|
||||
success: false,
|
||||
user: None,
|
||||
identifier: Some(masked_key.as_str()),
|
||||
api_key: None,
|
||||
client_ip: &client_ip,
|
||||
user_agent: user_agent.as_deref(),
|
||||
reason: Some("Invalid API key"),
|
||||
},
|
||||
)
|
||||
audit_user_login(&state, LoginAuditEvent {
|
||||
method: LoginMethod::ApiKey,
|
||||
success: false,
|
||||
user: None,
|
||||
identifier: Some(masked_key.as_str()),
|
||||
api_key: None,
|
||||
client_ip: &client_ip,
|
||||
user_agent: user_agent.as_deref(),
|
||||
reason: Some("Invalid API key"),
|
||||
})
|
||||
.await;
|
||||
return Err(AppError::Unauthorized("Invalid API key".into()));
|
||||
};
|
||||
let user_id = if let Some(user_id) = api_key.user_id {
|
||||
user_id
|
||||
} else {
|
||||
audit_user_login(
|
||||
&state,
|
||||
LoginAuditEvent {
|
||||
method: LoginMethod::ApiKey,
|
||||
success: false,
|
||||
user: None,
|
||||
identifier: Some(&api_key.name),
|
||||
api_key: Some(&api_key),
|
||||
client_ip: &client_ip,
|
||||
user_agent: user_agent.as_deref(),
|
||||
reason: Some("API key is not bound to a user"),
|
||||
},
|
||||
)
|
||||
audit_user_login(&state, LoginAuditEvent {
|
||||
method: LoginMethod::ApiKey,
|
||||
success: false,
|
||||
user: None,
|
||||
identifier: Some(&api_key.name),
|
||||
api_key: Some(&api_key),
|
||||
client_ip: &client_ip,
|
||||
user_agent: user_agent.as_deref(),
|
||||
reason: Some("API key is not bound to a user"),
|
||||
})
|
||||
.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 = ?")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
let user: User = if let Some(user) = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
{
|
||||
user
|
||||
} else {
|
||||
audit_user_login(
|
||||
&state,
|
||||
LoginAuditEvent {
|
||||
method: LoginMethod::ApiKey,
|
||||
success: false,
|
||||
user: None,
|
||||
identifier: Some(&api_key.name),
|
||||
api_key: Some(&api_key),
|
||||
client_ip: &client_ip,
|
||||
user_agent: user_agent.as_deref(),
|
||||
reason: Some("User not found"),
|
||||
},
|
||||
)
|
||||
audit_user_login(&state, LoginAuditEvent {
|
||||
method: LoginMethod::ApiKey,
|
||||
success: false,
|
||||
user: None,
|
||||
identifier: Some(&api_key.name),
|
||||
api_key: Some(&api_key),
|
||||
client_ip: &client_ip,
|
||||
user_agent: user_agent.as_deref(),
|
||||
reason: Some("User not found"),
|
||||
})
|
||||
.await;
|
||||
return Err(AppError::Unauthorized("User not found".into()));
|
||||
};
|
||||
|
||||
if user.status != "active" {
|
||||
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("Account is disabled"),
|
||||
},
|
||||
)
|
||||
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("Account is disabled"),
|
||||
})
|
||||
.await;
|
||||
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);
|
||||
|
||||
@@ -500,19 +482,16 @@ async fn login_api_key(
|
||||
)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
|
||||
|
||||
audit_user_login(
|
||||
&state,
|
||||
LoginAuditEvent {
|
||||
method: LoginMethod::ApiKey,
|
||||
success: true,
|
||||
user: Some(&user),
|
||||
identifier: Some(&user.email),
|
||||
api_key: Some(&api_key),
|
||||
client_ip: &client_ip,
|
||||
user_agent: user_agent.as_deref(),
|
||||
reason: None,
|
||||
},
|
||||
)
|
||||
audit_user_login(&state, LoginAuditEvent {
|
||||
method: LoginMethod::ApiKey,
|
||||
success: true,
|
||||
user: Some(&user),
|
||||
identifier: Some(&user.email),
|
||||
api_key: Some(&api_key),
|
||||
client_ip: &client_ip,
|
||||
user_agent: user_agent.as_deref(),
|
||||
reason: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
Ok(Json(json!({
|
||||
@@ -768,11 +747,7 @@ async fn audit_user_login(state: &AppState, audit: LoginAuditEvent<'_>) {
|
||||
.bind(message_parts.join(" · "))
|
||||
.bind(level)
|
||||
.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)
|
||||
.await
|
||||
{
|
||||
@@ -889,17 +864,17 @@ fn normalize_ip_token(raw: &str) -> Option<String> {
|
||||
token = host;
|
||||
}
|
||||
}
|
||||
token.parse::<IpAddr>().map(|ip| ip.to_string()).ok()
|
||||
token
|
||||
.parse::<IpAddr>()
|
||||
.map(|ip| ip.to_string())
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn truncate_for_audit(value: &str, max_chars: usize) -> String {
|
||||
if value.chars().count() <= max_chars {
|
||||
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
|
||||
}
|
||||
@@ -1043,7 +1018,6 @@ mod tests {
|
||||
template_proxy_url: None,
|
||||
},
|
||||
comfyui: Default::default(),
|
||||
pixelle_video: Default::default(),
|
||||
payment: Default::default(),
|
||||
},
|
||||
pool,
|
||||
@@ -1081,7 +1055,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_login_returns_admin_role_for_admin_users() {
|
||||
async fn password_login_rejects_admin_users() {
|
||||
let state = test_state().await;
|
||||
let password_hash = hash_password("admin-password").expect("password hash");
|
||||
sqlx::query(
|
||||
@@ -1095,8 +1069,8 @@ mod tests {
|
||||
.await
|
||||
.expect("insert admin user");
|
||||
|
||||
let Json(value) = login(
|
||||
State(state.clone()),
|
||||
let err = login(
|
||||
State(state),
|
||||
test_addr(),
|
||||
empty_headers(),
|
||||
Json(LoginRequest {
|
||||
@@ -1105,12 +1079,9 @@ mod tests {
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("admin password login should succeed");
|
||||
.expect_err("admin password login must be rejected");
|
||||
|
||||
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!(matches!(err, AppError::Forbidden(message) if message.contains("admin API token")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1176,8 +1147,7 @@ mod tests {
|
||||
.await
|
||||
.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!(title.contains("用户登录成功"));
|
||||
assert!(title.contains("Regular User <audit@example.test>"));
|
||||
@@ -1190,7 +1160,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_key_login_returns_admin_role_for_admin_user() {
|
||||
async fn api_key_login_rejects_admin_role() {
|
||||
let state = test_state().await;
|
||||
let password_hash = hash_password("unused-password").expect("password hash");
|
||||
let user_id = sqlx::query(
|
||||
@@ -1216,8 +1186,8 @@ mod tests {
|
||||
.await
|
||||
.expect("insert bound api key");
|
||||
|
||||
let Json(value) = login_api_key(
|
||||
State(state.clone()),
|
||||
let err = login_api_key(
|
||||
State(state),
|
||||
test_addr(),
|
||||
empty_headers(),
|
||||
Json(LoginApiKeyRequest {
|
||||
@@ -1225,17 +1195,14 @@ mod tests {
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("admin api key login should succeed");
|
||||
.expect_err("admin api key login must be rejected");
|
||||
|
||||
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!(matches!(err, AppError::Forbidden(message) if message.contains("admin API token")));
|
||||
assert!(user_id > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_key_login_returns_latest_admin_role_after_promotion() {
|
||||
async fn api_key_login_rejects_latest_admin_role_after_promotion() {
|
||||
let state = test_state().await;
|
||||
let password_hash = hash_password("unused-password").expect("password hash");
|
||||
let user_id = sqlx::query(
|
||||
@@ -1267,8 +1234,8 @@ mod tests {
|
||||
.await
|
||||
.expect("promote user to admin");
|
||||
|
||||
let Json(value) = login_api_key(
|
||||
State(state.clone()),
|
||||
let err = login_api_key(
|
||||
State(state),
|
||||
test_addr(),
|
||||
empty_headers(),
|
||||
Json(LoginApiKeyRequest {
|
||||
@@ -1276,12 +1243,9 @@ mod tests {
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("api key login uses latest role and accepts admin");
|
||||
.expect_err("api key login must use latest role and reject admin");
|
||||
|
||||
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!(matches!(err, AppError::Forbidden(message) if message.contains("admin API token")));
|
||||
assert!(user_id > 0);
|
||||
}
|
||||
|
||||
@@ -1369,8 +1333,7 @@ mod tests {
|
||||
.await
|
||||
.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!(title.contains("用户登录成功"));
|
||||
assert!(title.contains("Regular User <key-audit@example.test>"));
|
||||
@@ -1400,10 +1363,7 @@ mod tests {
|
||||
let Json(_) = login(
|
||||
State(state.clone()),
|
||||
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 {
|
||||
email: "ipv4@example.test".into(),
|
||||
password: "user-password".into(),
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::services::comfy_cloud::site::resolve_comfy_cloud_site;
|
||||
use crate::services::comfy_cloud::translate::comfy_cloud_job_to_history;
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path, RawQuery, State},
|
||||
http::{header, StatusCode},
|
||||
response::Response,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/comfy-cloud/prompt", post(submit_prompt))
|
||||
.route("/comfy-cloud/history/{prompt_id}", get(get_history))
|
||||
.route("/comfy-cloud/view", get(get_view))
|
||||
}
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new())
|
||||
}
|
||||
|
||||
async fn submit_prompt(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (base_url, api_key) = resolve_comfy_cloud_site(&state.db).await?;
|
||||
let resp = client()
|
||||
.post(format!("{base_url}/api/prompt"))
|
||||
.header("X-API-Key", api_key)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
if !status.is_success() {
|
||||
return Err(AppError::UpstreamError(format!("Comfy Cloud prompt [{status}]: {text}")));
|
||||
}
|
||||
let value: Value = serde_json::from_str(&text).unwrap_or_else(|_| serde_json::json!({ "body": text }));
|
||||
Ok(Json(value))
|
||||
}
|
||||
|
||||
async fn fetch_history(db: &sqlx::SqlitePool, prompt_id: &str) -> AppResult<Value> {
|
||||
let (base_url, api_key) = resolve_comfy_cloud_site(db).await?;
|
||||
let http = client();
|
||||
let status_resp = http
|
||||
.get(format!("{base_url}/api/job/{prompt_id}/status"))
|
||||
.header("X-API-Key", &api_key)
|
||||
.send().await
|
||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
if !status_resp.status().is_success() {
|
||||
let st = status_resp.status();
|
||||
let body = status_resp.text().await.unwrap_or_default();
|
||||
return Err(AppError::UpstreamError(format!("Comfy Cloud status [{st}]: {body}")));
|
||||
}
|
||||
let status_json: Value = status_resp.json().await.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
let status = status_json.get("status").and_then(Value::as_str).unwrap_or("in_progress");
|
||||
|
||||
if status != "completed" {
|
||||
return Ok(comfy_cloud_job_to_history(prompt_id, status, None));
|
||||
}
|
||||
let detail_resp = http
|
||||
.get(format!("{base_url}/api/jobs/{prompt_id}"))
|
||||
.header("X-API-Key", &api_key)
|
||||
.send().await
|
||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
if !detail_resp.status().is_success() {
|
||||
let st = detail_resp.status();
|
||||
let body = detail_resp.text().await.unwrap_or_default();
|
||||
return Err(AppError::UpstreamError(format!("Comfy Cloud jobs [{st}]: {body}")));
|
||||
}
|
||||
let detail_json: Value = detail_resp.json().await.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
let outputs = detail_json.get("outputs");
|
||||
Ok(comfy_cloud_job_to_history(prompt_id, status, outputs))
|
||||
}
|
||||
|
||||
async fn get_history(
|
||||
State(state): State<AppState>,
|
||||
Path(prompt_id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
Ok(Json(fetch_history(&state.db, &prompt_id).await?))
|
||||
}
|
||||
|
||||
async fn fetch_view(db: &sqlx::SqlitePool, query: Option<&str>) -> AppResult<(String, Vec<u8>)> {
|
||||
let (base_url, api_key) = resolve_comfy_cloud_site(db).await?;
|
||||
let q = query.unwrap_or("");
|
||||
let url = if q.is_empty() { format!("{base_url}/api/view") } else { format!("{base_url}/api/view?{q}") };
|
||||
let resp = client()
|
||||
.get(url)
|
||||
.header("X-API-Key", api_key)
|
||||
.send().await
|
||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
if !resp.status().is_success() {
|
||||
let st = resp.status();
|
||||
return Err(AppError::UpstreamError(format!("Comfy Cloud view [{st}]")));
|
||||
}
|
||||
let mime = resp
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
let bytes = resp.bytes().await.map_err(|e| AppError::UpstreamError(e.to_string()))?.to_vec();
|
||||
Ok((mime, bytes))
|
||||
}
|
||||
|
||||
async fn get_view(
|
||||
State(state): State<AppState>,
|
||||
RawQuery(query): RawQuery,
|
||||
) -> AppResult<Response> {
|
||||
let (mime, bytes) = fetch_view(&state.db, query.as_deref()).await?;
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, mime)
|
||||
.body(Body::from(bytes))
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wiremock::matchers::{header as match_header, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
async fn db_with_site(base_url: &str) -> sqlx::SqlitePool {
|
||||
let pool = SqlitePoolOptions::new().max_connections(1).connect("sqlite::memory:").await.unwrap();
|
||||
sqlx::query("CREATE TABLE sites (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, url TEXT NOT NULL, platform TEXT NOT NULL, status TEXT NOT NULL, api_key TEXT, sort_order INTEGER)")
|
||||
.execute(&pool).await.unwrap();
|
||||
sqlx::query("INSERT INTO sites (name,url,platform,status,api_key) VALUES ('cc',?,'comfy_cloud','active','key-123')")
|
||||
.bind(base_url).execute(&pool).await.unwrap();
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submit_forwards_with_api_key() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/prompt"))
|
||||
.and(match_header("X-API-Key", "key-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "prompt_id": "p1" })))
|
||||
.mount(&server).await;
|
||||
|
||||
let pool = db_with_site(&server.uri()).await;
|
||||
let (base_url, api_key) = resolve_comfy_cloud_site(&pool).await.unwrap();
|
||||
let resp = client().post(format!("{base_url}/api/prompt"))
|
||||
.header("X-API-Key", api_key)
|
||||
.json(&serde_json::json!({ "prompt": {} }))
|
||||
.send().await.unwrap();
|
||||
let v: Value = resp.json().await.unwrap();
|
||||
assert_eq!(v["prompt_id"], "p1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn history_in_progress_then_completed() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/job/p1/status"))
|
||||
.and(match_header("X-API-Key", "key-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "status": "completed" })))
|
||||
.mount(&server).await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/jobs/p1"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"outputs": { "9": { "images": [{ "filename": "a.png", "subfolder": "", "type": "output" }] } }
|
||||
})))
|
||||
.mount(&server).await;
|
||||
|
||||
let pool = db_with_site(&server.uri()).await;
|
||||
let history = fetch_history(&pool, "p1").await.unwrap();
|
||||
assert_eq!(history["p1"]["status"]["completed"], true);
|
||||
assert_eq!(history["p1"]["outputs"]["9"]["images"][0]["filename"], "a.png");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn history_completed_job_detail_non_success_errors() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/job/p1/status"))
|
||||
.and(match_header("X-API-Key", "key-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "status": "completed" })))
|
||||
.mount(&server).await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/jobs/p1"))
|
||||
.respond_with(ResponseTemplate::new(500).set_body_json(serde_json::json!({ "error": "boom" })))
|
||||
.mount(&server).await;
|
||||
|
||||
let pool = db_with_site(&server.uri()).await;
|
||||
assert!(fetch_history(&pool, "p1").await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn history_failed_status_maps_to_error() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/job/p1/status"))
|
||||
.and(match_header("X-API-Key", "key-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "status": "failed" })))
|
||||
.mount(&server).await;
|
||||
|
||||
let pool = db_with_site(&server.uri()).await;
|
||||
let history = fetch_history(&pool, "p1").await.unwrap();
|
||||
assert_eq!(history["p1"]["status"]["status_str"], "error");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn view_proxies_file_bytes() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/view"))
|
||||
.and(match_header("X-API-Key", "key-123"))
|
||||
.respond_with(ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "image/png")
|
||||
.set_body_bytes(b"PNGDATA".to_vec()))
|
||||
.mount(&server).await;
|
||||
|
||||
let pool = db_with_site(&server.uri()).await;
|
||||
let (mime, bytes) = fetch_view(&pool, Some("filename=a.png&type=output")).await.unwrap();
|
||||
assert_eq!(mime, "image/png");
|
||||
assert_eq!(bytes, b"PNGDATA");
|
||||
}
|
||||
}
|
||||
@@ -60,9 +60,10 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
|
||||
// Downstream API key
|
||||
if auth.starts_with("sk-") {
|
||||
let proxy_token = state.runtime_proxy_token();
|
||||
let result = authorize_downstream_token(auth, &state.db, Some(&proxy_token))
|
||||
.await
|
||||
.map_err(AppError::Unauthorized)?;
|
||||
let result =
|
||||
authorize_downstream_token(auth, &state.db, Some(&proxy_token))
|
||||
.await
|
||||
.map_err(AppError::Unauthorized)?;
|
||||
|
||||
match result {
|
||||
DownstreamAuthResult::Managed { key, .. } => {
|
||||
@@ -126,8 +127,6 @@ mod tests {
|
||||
template_proxy_url: None,
|
||||
},
|
||||
comfyui: ComfyUIConfig::default(),
|
||||
pixelle_video: Default::default(),
|
||||
payment: Default::default(),
|
||||
},
|
||||
pool,
|
||||
BuiltinKeys {
|
||||
|
||||
+39
-288
@@ -16,30 +16,47 @@ use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
|
||||
use tokio_stream::{wrappers::UnboundedReceiverStream, StreamExt};
|
||||
use tracing::info;
|
||||
|
||||
const SYSTEM_PROMPT: &str = r#"你是 BoosAPI 的 AI 视频生成助手(智能体),帮用户通过 AI 制作视频。
|
||||
const SYSTEM_PROMPT: &str = r#"你是 BoosAPI 的 AI 视频生成助手,帮用户通过 AI 制作视频。
|
||||
|
||||
## 工作方式
|
||||
1. 先用中文与用户讨论脚本构思、视觉风格、角色设定。
|
||||
2. 用户确认后(说"开始制作"或"生成"),调用工具逐步产出素材。
|
||||
3. 你拥有以下工具,应主动按需调用,并根据每次工具返回的结果决定下一步:
|
||||
- generate_character_images:为角色生成正/背/左/右四视图参考图
|
||||
- generate_scene_image:生成单张场景/分镜图
|
||||
- generate_tts:为一句台词合成语音
|
||||
- build_comfyui_workflow:在素材就绪后交付最终可执行的 ComfyUI 工作流 JSON
|
||||
## 规则
|
||||
1. 先用中文与用户讨论脚本构思、视觉风格、角色设定
|
||||
2. 用户确认后(说"开始制作"或"生成"),再分步输出结构化内容
|
||||
3. **分两步走:第一步输出角色+场景+TTS,第二步输出 WORKFLOW**(不要一次全出)
|
||||
|
||||
## 调用原则
|
||||
- 一次可调用一个或多个工具;每个角色/场景/台词分别调用对应工具。
|
||||
- 收到工具结果后继续推进,直到所有素材生成完毕,最后调用 build_comfyui_workflow 交付。
|
||||
- 图像 prompt 用英文,包含 主体 + 视角 + 细节 + 画质标签。
|
||||
- 没有用户明确要求时,不要自作主张批量生成;先沟通确认。
|
||||
## 结构化标记格式
|
||||
严格按照以下格式输出,系统会自动解析并生成图片/TTS:
|
||||
|
||||
## 兼容降级
|
||||
若你所在的模型不支持工具调用,可改用结构化标记输出(系统会解析):
|
||||
【CHARACTER】name/description/prompt_front/prompt_back/prompt_left/prompt_right【/CHARACTER】
|
||||
【SCENE】id/description/prompt【/SCENE】
|
||||
【TTS】character/text/emotion【/TTS】
|
||||
【WORKFLOW】```json {...} ```【/WORKFLOW】
|
||||
但只要支持工具调用,就优先使用工具,不要输出这些标记。
|
||||
【CHARACTER】
|
||||
name: 林澈
|
||||
description: 女宇航员,30岁,冷静坚毅,短发收在头盔内
|
||||
prompt_front: cinematic full body front view of a female astronaut, white EVA suit, photorealistic
|
||||
prompt_back: cinematic full body back view of a female astronaut, life support backpack
|
||||
prompt_left: cinematic full body left side view of a female astronaut, side utility pouches
|
||||
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;
|
||||
@@ -666,190 +683,6 @@ fn send_sse(tx: &UnboundedSender<Result<Event, Infallible>>, payload: Value) ->
|
||||
.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> {
|
||||
keys.iter()
|
||||
.find_map(|key| value.get(*key).and_then(Value::as_str))
|
||||
@@ -1077,15 +910,6 @@ async fn chat_handler(
|
||||
|
||||
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");
|
||||
|
||||
let response = match client
|
||||
@@ -1096,8 +920,6 @@ async fn chat_handler(
|
||||
"messages": upstream_messages,
|
||||
"temperature": 0.7,
|
||||
"stream": true,
|
||||
"tools": agent_tool_definitions(),
|
||||
"tool_choice": "auto",
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
@@ -1130,10 +952,7 @@ async fn chat_handler(
|
||||
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut buffer: Vec<u8> = Vec::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();
|
||||
let mut accumulated_text = String::new();
|
||||
|
||||
// Phase 1: Stream the LLM text response
|
||||
loop {
|
||||
@@ -1229,75 +1048,9 @@ async fn chat_handler(
|
||||
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
|
||||
info!("comfyui-agent: text streaming complete, parsing sections");
|
||||
let sections = parse_sections(&accumulated_text);
|
||||
@@ -1552,8 +1305,6 @@ mod tests {
|
||||
template_proxy_url: None,
|
||||
},
|
||||
comfyui: Default::default(),
|
||||
pixelle_video: Default::default(),
|
||||
payment: Default::default(),
|
||||
},
|
||||
pool,
|
||||
BuiltinKeys {
|
||||
|
||||
+99
-1738
File diff suppressed because it is too large
Load Diff
+3
-49
@@ -439,12 +439,6 @@ struct EventListItem {
|
||||
event_type: String,
|
||||
title: 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,
|
||||
#[serde(rename = "read")]
|
||||
is_read: bool,
|
||||
@@ -455,15 +449,11 @@ struct EventListItem {
|
||||
|
||||
impl From<Event> for EventListItem {
|
||||
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 {
|
||||
id: event.id,
|
||||
event_type: event.event_type,
|
||||
title: event.title,
|
||||
message: event.message,
|
||||
account,
|
||||
client_ip,
|
||||
level: event.level.unwrap_or_else(|| "info".to_string()),
|
||||
is_read: event.is_read.unwrap_or(false),
|
||||
related_id: event.related_id,
|
||||
@@ -480,8 +470,6 @@ impl From<runtime_logs::RuntimeLogEntry> for EventListItem {
|
||||
event_type: entry.event_type,
|
||||
title: entry.title,
|
||||
message: entry.message,
|
||||
account: None,
|
||||
client_ip: None,
|
||||
level: entry.level,
|
||||
is_read: entry.is_read,
|
||||
related_id: entry.related_id,
|
||||
@@ -491,38 +479,6 @@ 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -531,11 +487,11 @@ mod tests {
|
||||
config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig},
|
||||
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
|
||||
};
|
||||
use axum::extract::{Query, State};
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use axum::extract::{Query, State};
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
@@ -580,8 +536,6 @@ mod tests {
|
||||
template_proxy_url: None,
|
||||
},
|
||||
comfyui: Default::default(),
|
||||
pixelle_video: Default::default(),
|
||||
payment: Default::default(),
|
||||
},
|
||||
pool,
|
||||
BuiltinKeys {
|
||||
@@ -677,8 +631,8 @@ mod tests {
|
||||
.expect("admin token request");
|
||||
assert_eq!(ok_response.status(), StatusCode::OK);
|
||||
|
||||
let user_jwt =
|
||||
generate_jwt(&state.config.auth.jwt_secret, "1", "user", 1).expect("user jwt");
|
||||
let user_jwt = generate_jwt(&state.config.auth.jwt_secret, "1", "user", 1)
|
||||
.expect("user jwt");
|
||||
let user_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
|
||||
+7
-16
@@ -16,15 +16,12 @@ use tower_http::services::ServeDir;
|
||||
static EMBEDDED_WEB: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/web");
|
||||
static EMBEDDED_WEBSITE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/website");
|
||||
const _: &[u8] = include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/dist/web/index.html"));
|
||||
const _: &[u8] = include_bytes!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/dist/website/index.html"
|
||||
));
|
||||
const _: &[u8] =
|
||||
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/dist/website/index.html"));
|
||||
|
||||
mod admin;
|
||||
mod auth;
|
||||
mod checkin;
|
||||
mod comfy_cloud_adapter;
|
||||
mod comfyui;
|
||||
mod comfyui_agent;
|
||||
mod digital_human;
|
||||
@@ -33,7 +30,6 @@ mod health;
|
||||
mod money_printer;
|
||||
mod oauth;
|
||||
mod organizations;
|
||||
mod payment;
|
||||
mod picture_agent;
|
||||
mod proxy;
|
||||
mod search;
|
||||
@@ -54,7 +50,6 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.merge(admin::routes(state.clone()))
|
||||
.merge(proxy::routes())
|
||||
.merge(picture_agent::routes())
|
||||
.merge(comfy_cloud_adapter::routes())
|
||||
.merge(money_printer::routes())
|
||||
.merge(comfyui_agent::routes())
|
||||
.merge(digital_human::routes())
|
||||
@@ -70,7 +65,6 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.merge(checkin::routes())
|
||||
.merge(oauth::routes())
|
||||
.merge(organizations::routes())
|
||||
.merge(payment::routes())
|
||||
.merge(search::routes())
|
||||
.merge(site_announcements::routes())
|
||||
.merge(workflow_ui::routes())
|
||||
@@ -105,9 +99,12 @@ pub fn build_router(state: AppState) -> Router {
|
||||
return Ok::<_, Infallible>(api_not_found_response(path));
|
||||
}
|
||||
|
||||
Ok::<_, Infallible>(
|
||||
filesystem_static_response(path, &web_dir, &website_dir).await,
|
||||
Ok::<_, Infallible>(filesystem_static_response(
|
||||
path,
|
||||
&web_dir,
|
||||
&website_dir,
|
||||
)
|
||||
.await)
|
||||
}
|
||||
}));
|
||||
router.fallback_service(static_service)
|
||||
@@ -288,12 +285,6 @@ mod tests {
|
||||
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");
|
||||
|
||||
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]
|
||||
|
||||
+110
-349
@@ -108,11 +108,7 @@ fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
|
||||
headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.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)
|
||||
.filter(|token| !token.is_empty())
|
||||
}
|
||||
@@ -185,28 +181,25 @@ async fn resolve_auth(headers: &HeaderMap, state: &AppState) -> Result<MoneyPrin
|
||||
async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
|
||||
let auth = resolve_auth(&headers, &state).await?;
|
||||
let cloud_backend = state.config.comfyui.uses_cloud_backend();
|
||||
let local_only_enabled = state.config.comfyui.uses_docker_backend();
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"mode": if local_only_enabled { "local-comfy" } else { "disabled" },
|
||||
"disabled": !local_only_enabled,
|
||||
"disabledReason": if local_only_enabled { Value::Null } else { json!("短视频工厂暂时只支持本地模型部署;请启用本地 ComfyUI/Docker 后再创建任务。") },
|
||||
"mode": "boosapi-native",
|
||||
"keyName": auth.key_name,
|
||||
"capabilities": [
|
||||
"topic_to_script",
|
||||
"boosapi_chat_completion",
|
||||
"local_comfy_prompt",
|
||||
"local_text_to_video",
|
||||
"comfy_prompt",
|
||||
"boosapi_text_to_video",
|
||||
"workspace_archive",
|
||||
"background_jobs"
|
||||
],
|
||||
"comfy": {
|
||||
"enabled": local_only_enabled,
|
||||
"enabled": state.config.comfyui.uses_native_backend(),
|
||||
"backend": format!("{:?}", state.config.comfyui.backend).to_ascii_lowercase(),
|
||||
"baseUrl": state.config.comfyui.target_base_url()
|
||||
},
|
||||
"builtinCloudTemplate": {
|
||||
"enabled": cloud_backend && local_only_enabled,
|
||||
"enabled": cloud_backend,
|
||||
"label": "内置 Cloud Seedance 文生视频",
|
||||
"nodeType": "ByteDanceTextToVideoNode",
|
||||
"model": "seedance-1-0-lite-t2v-250428",
|
||||
@@ -283,12 +276,7 @@ async fn load_job(state: &AppState, id: &str, auth: &MoneyPrinterAuth) -> AppRes
|
||||
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(
|
||||
"UPDATE background_tasks
|
||||
SET progress = ?, message = ?, updated_at = datetime('now')
|
||||
@@ -320,8 +308,7 @@ fn http_client(timeout_secs: u64) -> Client {
|
||||
|
||||
fn script_prompt(topic: &str, body: &Value) -> 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 audience = string_from(body, &["audience"]).unwrap_or_else(|| "泛大众用户".to_string());
|
||||
let extra = string_from(body, &["requirements", "extra"]).unwrap_or_default();
|
||||
@@ -347,8 +334,9 @@ async fn generate_script(
|
||||
auth: &MoneyPrinterAuth,
|
||||
body: &Value,
|
||||
) -> AppResult<Value> {
|
||||
let topic = string_from(body, &["topic", "prompt"])
|
||||
.ok_or_else(|| AppError::BadRequest("topic is required".into()))?;
|
||||
let topic = string_from(body, &["topic", "prompt"]).ok_or_else(|| {
|
||||
AppError::BadRequest("topic is required".into())
|
||||
})?;
|
||||
let script_model = string_from(body, &["scriptModel", "script_model"])
|
||||
.unwrap_or_else(|| "gpt-5.4".to_string());
|
||||
let payload = json!({
|
||||
@@ -464,11 +452,7 @@ fn native_path(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 {
|
||||
@@ -575,12 +559,7 @@ fn mapping_value<'a>(mapping: &'a Value, keys: &[&str]) -> Option<&'a Value> {
|
||||
fn mapping_node_id(mapping: &Value, keys: &[&str]) -> Option<String> {
|
||||
mapping_value(mapping, keys).and_then(|value| {
|
||||
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 {
|
||||
scalar_string(Some(value))
|
||||
}
|
||||
@@ -642,38 +621,21 @@ fn graph_node_params(node: &Value) -> serde_json::Map<String, Value> {
|
||||
|
||||
fn normalize_comfy_value(class_type: &str, field: &str, value: Value) -> Value {
|
||||
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", "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", "cfg") => value.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") => {
|
||||
if value.is_string() {
|
||||
value
|
||||
} else {
|
||||
json!("euler")
|
||||
}
|
||||
if value.is_string() { value } else { json!("euler") }
|
||||
}
|
||||
("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))),
|
||||
("EmptyLatentImage", "width") | ("EmptyLatentImage", "height") => value
|
||||
.as_i64()
|
||||
.filter(|v| (64..=4096).contains(v))
|
||||
.map_or(json!(1024), |v| json!(v)),
|
||||
("EmptyLatentImage", "batch_size") => value
|
||||
.as_i64()
|
||||
.filter(|v| (1..=16).contains(v))
|
||||
.map_or(json!(1), |v| json!(v)),
|
||||
("EmptyLatentImage", "width") | ("EmptyLatentImage", "height") => {
|
||||
value.as_i64().filter(|v| (64..=4096).contains(v)).map_or(json!(1024), |v| json!(v))
|
||||
}
|
||||
("EmptyLatentImage", "batch_size") => {
|
||||
value.as_i64().filter(|v| (1..=16).contains(v)).map_or(json!(1), |v| json!(v))
|
||||
}
|
||||
_ => value,
|
||||
}
|
||||
}
|
||||
@@ -694,37 +656,18 @@ fn graph_to_comfy_prompt(graph: &Value) -> Option<Value> {
|
||||
}
|
||||
let mut inputs = serde_json::Map::new();
|
||||
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!({
|
||||
"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()
|
||||
{
|
||||
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;
|
||||
};
|
||||
for edge in graph.get("edges").and_then(Value::as_array).into_iter().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
|
||||
.get_mut(to_id)
|
||||
.and_then(Value::as_object_mut)
|
||||
@@ -741,10 +684,7 @@ fn graph_to_comfy_prompt(graph: &Value) -> Option<Value> {
|
||||
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
|
||||
.iter()
|
||||
.find(|(_, node)| node.get("class_type").and_then(Value::as_str) == Some(class_type))
|
||||
@@ -776,13 +716,9 @@ fn prompt_contains_class_type(prompt: &Value, class_type: &str) -> bool {
|
||||
.get("class_type")
|
||||
.and_then(Value::as_str)
|
||||
.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,
|
||||
}
|
||||
}
|
||||
@@ -804,36 +740,11 @@ fn infer_comfy_mapping(prompt: &Value) -> Value {
|
||||
.or_else(|| first_node_by_class(object, "KSampler"))
|
||||
.or_else(|| first_node_by_class(object, "EmptyLatentImage"))
|
||||
.unwrap_or_default();
|
||||
let prompt_field = first_existing_input_field(
|
||||
object,
|
||||
&prompt_node,
|
||||
&["prompt", "positive", "text"],
|
||||
"text",
|
||||
);
|
||||
let ratio_field = first_existing_input_field(
|
||||
object,
|
||||
&video_node,
|
||||
&["ratio", "model.ratio", "aspect_ratio"],
|
||||
"ratio",
|
||||
);
|
||||
let resolution_field = first_existing_input_field(
|
||||
object,
|
||||
&video_node,
|
||||
&["model.resolution", "resolution", "size"],
|
||||
"resolution",
|
||||
);
|
||||
let duration_field = first_existing_input_field(
|
||||
object,
|
||||
&video_node,
|
||||
&["model.duration", "duration", "length"],
|
||||
"duration",
|
||||
);
|
||||
let model_field = first_existing_input_field(
|
||||
object,
|
||||
&video_node,
|
||||
&["model", "model_name", "ckpt_name"],
|
||||
"model",
|
||||
);
|
||||
let prompt_field = first_existing_input_field(object, &prompt_node, &["prompt", "positive", "text"], "text");
|
||||
let ratio_field = first_existing_input_field(object, &video_node, &["ratio", "model.ratio", "aspect_ratio"], "ratio");
|
||||
let resolution_field = first_existing_input_field(object, &video_node, &["model.resolution", "resolution", "size"], "resolution");
|
||||
let duration_field = first_existing_input_field(object, &video_node, &["model.duration", "duration", "length"], "duration");
|
||||
let model_field = first_existing_input_field(object, &video_node, &["model", "model_name", "ckpt_name"], "model");
|
||||
json!({
|
||||
"prompt": {
|
||||
"nodeId": prompt_node,
|
||||
@@ -865,9 +776,7 @@ fn infer_comfy_mapping(prompt: &Value) -> Value {
|
||||
fn clip_text_nodes(prompt: &serde_json::Map<String, Value>) -> Vec<String> {
|
||||
prompt
|
||||
.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())
|
||||
.collect()
|
||||
}
|
||||
@@ -917,8 +826,7 @@ fn repair_common_comfy_prompt_links(prompt: &mut serde_json::Map<String, Value>)
|
||||
}
|
||||
|
||||
fn workflow_prompt_from_graph(graph: &Value) -> Value {
|
||||
graph
|
||||
.get("prompt")
|
||||
graph.get("prompt")
|
||||
.cloned()
|
||||
.or_else(|| graph.get("workflow").cloned())
|
||||
.or_else(|| graph.get("comfyPrompt").cloned())
|
||||
@@ -950,24 +858,23 @@ async fn workflow_prompt_from_body(
|
||||
auth: &MoneyPrinterAuth,
|
||||
body: &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("promptWorkflow"))
|
||||
.or_else(|| body.get("prompt_workflow"))
|
||||
.or_else(|| body.get("workflowPrompt"))
|
||||
.or_else(|| body.get("workflow_prompt"))
|
||||
.cloned()
|
||||
{
|
||||
.cloned() {
|
||||
return Ok(value);
|
||||
}
|
||||
if let Some(template_id) = string_from(
|
||||
body,
|
||||
&["templateId", "template_id", "workflowId", "workflow_id"],
|
||||
) {
|
||||
let workflow =
|
||||
storage::get_workflow(&state.db, &template_id, auth.user_id, auth.role == "admin")
|
||||
.await?;
|
||||
if let Some(template_id) = string_from(body, &["templateId", "template_id", "workflowId", "workflow_id"]) {
|
||||
let workflow = storage::get_workflow(
|
||||
&state.db,
|
||||
&template_id,
|
||||
auth.user_id,
|
||||
auth.role == "admin",
|
||||
)
|
||||
.await?;
|
||||
return Ok(workflow_prompt_from_graph(&workflow.graph));
|
||||
}
|
||||
if state.config.comfyui.uses_cloud_backend() {
|
||||
@@ -983,14 +890,10 @@ fn apply_node_input(prompt: &mut Value, node_id: &str, field: &str, value: Value
|
||||
return Err(AppError::BadRequest("Comfy 节点映射包含空 nodeId".into()));
|
||||
}
|
||||
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 {
|
||||
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);
|
||||
Ok(())
|
||||
@@ -1024,20 +927,12 @@ fn apply_optional_node_input(
|
||||
fn value_to_duration_seconds(value: &Value) -> Option<i64> {
|
||||
value
|
||||
.as_i64()
|
||||
.or_else(|| {
|
||||
value
|
||||
.as_str()
|
||||
.and_then(|text| text.trim().parse::<i64>().ok())
|
||||
})
|
||||
.or_else(|| value.as_str().and_then(|text| text.trim().parse::<i64>().ok()))
|
||||
.map(|duration| duration.clamp(2, 120))
|
||||
}
|
||||
|
||||
fn normalize_resolution_for_comfy(value: &Value) -> Value {
|
||||
let text = value
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
let text = value.as_str().unwrap_or_default().trim().to_ascii_lowercase();
|
||||
match text.as_str() {
|
||||
"720p" | "720" => json!("720P"),
|
||||
"1080p" | "1080" => json!("1080P"),
|
||||
@@ -1045,12 +940,7 @@ 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(|| {
|
||||
plan.get("video_prompt")
|
||||
.and_then(Value::as_str)
|
||||
@@ -1066,35 +956,20 @@ fn inject_comfy_video_inputs(
|
||||
apply_node_input(
|
||||
prompt,
|
||||
&prompt_node,
|
||||
&mapping_field(
|
||||
mapping,
|
||||
&["prompt", "positive", "text", "promptNode"],
|
||||
"prompt",
|
||||
),
|
||||
&mapping_field(mapping, &["prompt", "positive", "text", "promptNode"], "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"])
|
||||
.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(
|
||||
prompt,
|
||||
&negative_node,
|
||||
&mapping_field(
|
||||
mapping,
|
||||
&["negativePrompt", "negative", "negativeNode"],
|
||||
"text",
|
||||
),
|
||||
&mapping_field(mapping, &["negativePrompt", "negative", "negativeNode"], "text"),
|
||||
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(
|
||||
prompt,
|
||||
mapping,
|
||||
@@ -1103,16 +978,10 @@ fn inject_comfy_video_inputs(
|
||||
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);
|
||||
}
|
||||
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(
|
||||
prompt,
|
||||
mapping,
|
||||
@@ -1121,18 +990,8 @@ fn inject_comfy_video_inputs(
|
||||
normalize_resolution_for_comfy(&value),
|
||||
);
|
||||
}
|
||||
if let Some(value) = ["duration"]
|
||||
.iter()
|
||||
.find_map(|key| body.get(*key))
|
||||
.and_then(value_to_duration_seconds)
|
||||
{
|
||||
apply_optional_node_input(
|
||||
prompt,
|
||||
mapping,
|
||||
&["duration", "durationNode"],
|
||||
"duration",
|
||||
json!(value),
|
||||
);
|
||||
if let Some(value) = ["duration"].iter().find_map(|key| body.get(*key)).and_then(value_to_duration_seconds) {
|
||||
apply_optional_node_input(prompt, mapping, &["duration", "durationNode"], "duration", json!(value));
|
||||
}
|
||||
repair_money_printer_comfy_prompt(prompt);
|
||||
Ok(video_prompt)
|
||||
@@ -1150,14 +1009,8 @@ fn repair_bytedance_video_nodes(prompt: &mut Value) {
|
||||
return;
|
||||
};
|
||||
for (_node_id, node) in nodes.iter_mut() {
|
||||
let class_type = node
|
||||
.get("class_type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if !matches!(
|
||||
class_type,
|
||||
"ByteDanceTextToVideoNode" | "ByteDance2TextToVideoNode"
|
||||
) {
|
||||
let class_type = node.get("class_type").and_then(Value::as_str).unwrap_or_default();
|
||||
if !matches!(class_type, "ByteDanceTextToVideoNode" | "ByteDance2TextToVideoNode") {
|
||||
continue;
|
||||
}
|
||||
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
|
||||
@@ -1166,20 +1019,16 @@ fn repair_bytedance_video_nodes(prompt: &mut Value) {
|
||||
if let Some(duration) = inputs.get("duration").and_then(value_to_duration_seconds) {
|
||||
inputs.insert("duration".to_string(), json!(duration.clamp(3, 12)));
|
||||
}
|
||||
if let Some(duration) = inputs
|
||||
.get("model.duration")
|
||||
.and_then(value_to_duration_seconds)
|
||||
{
|
||||
if let Some(duration) = inputs.get("model.duration").and_then(value_to_duration_seconds) {
|
||||
inputs.insert("model.duration".to_string(), json!(duration.clamp(4, 15)));
|
||||
}
|
||||
if inputs.get("resolution").is_some_and(|value| {
|
||||
let normalized = value
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
!matches!(normalized.as_str(), "480p" | "720p" | "1080p")
|
||||
}) {
|
||||
if inputs
|
||||
.get("resolution")
|
||||
.is_some_and(|value| {
|
||||
let normalized = value.as_str().unwrap_or_default().trim().to_ascii_lowercase();
|
||||
!matches!(normalized.as_str(), "480p" | "720p" | "1080p")
|
||||
})
|
||||
{
|
||||
inputs.insert("resolution".to_string(), json!("480p"));
|
||||
} else if let Some(normalized) = inputs
|
||||
.get("resolution")
|
||||
@@ -1188,12 +1037,10 @@ fn repair_bytedance_video_nodes(prompt: &mut Value) {
|
||||
{
|
||||
inputs.insert("resolution".to_string(), json!(normalized));
|
||||
}
|
||||
if inputs.get("aspect_ratio").is_some_and(|value| {
|
||||
!matches!(
|
||||
value.as_str(),
|
||||
Some("16:9" | "4:3" | "1:1" | "3:4" | "9:16" | "21:9")
|
||||
)
|
||||
}) {
|
||||
if inputs
|
||||
.get("aspect_ratio")
|
||||
.is_some_and(|value| !matches!(value.as_str(), Some("16:9" | "4:3" | "1:1" | "3:4" | "9:16" | "21:9")))
|
||||
{
|
||||
inputs.insert("aspect_ratio".to_string(), json!("9:16"));
|
||||
}
|
||||
}
|
||||
@@ -1214,10 +1061,7 @@ fn repair_resize_image_mask_nodes(prompt: &mut Value) {
|
||||
.get("resize_type.crop")
|
||||
.is_some_and(|value| !matches!(value.as_str(), Some("disabled" | "center")))
|
||||
{
|
||||
inputs.insert(
|
||||
"resize_type.crop".to_string(),
|
||||
Value::String("center".to_string()),
|
||||
);
|
||||
inputs.insert("resize_type.crop".to_string(), Value::String("center".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1244,9 +1088,7 @@ 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.min_p".to_string(), json!(0.05));
|
||||
inputs.insert("sampling_mode.repetition_penalty".to_string(), json!(1.05));
|
||||
inputs
|
||||
.entry("sampling_mode.seed".to_string())
|
||||
.or_insert_with(|| json!(0));
|
||||
inputs.entry("sampling_mode.seed".to_string()).or_insert_with(|| json!(0));
|
||||
inputs.insert("sampling_mode.presence_penalty".to_string(), json!(0.0));
|
||||
}
|
||||
}
|
||||
@@ -1257,10 +1099,7 @@ fn repair_wan2_api_nodes(prompt: &mut Value) {
|
||||
return;
|
||||
};
|
||||
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") {
|
||||
continue;
|
||||
}
|
||||
@@ -1278,10 +1117,7 @@ fn repair_wan2_model_group(inputs: &mut Map<String, Value>) {
|
||||
inputs.insert("model".to_string(), model);
|
||||
}
|
||||
if let Some(resolution) = items.get(1).cloned() {
|
||||
inputs.insert(
|
||||
"model.resolution".to_string(),
|
||||
normalize_resolution_for_comfy(&resolution),
|
||||
);
|
||||
inputs.insert("model.resolution".to_string(), normalize_resolution_for_comfy(&resolution));
|
||||
}
|
||||
if let Some(duration) = items.get(2).and_then(value_to_duration_seconds) {
|
||||
inputs.insert("model.duration".to_string(), json!(duration));
|
||||
@@ -1293,10 +1129,7 @@ fn repair_wan2_model_group(inputs: &mut Map<String, Value>) {
|
||||
{
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1331,10 +1164,7 @@ fn comfy_failure_message(value: &Value) -> Option<String> {
|
||||
let status = scalar_string(value.get("status").or_else(|| value.get("state")))
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
if matches!(
|
||||
status.as_str(),
|
||||
"failed" | "error" | "cancelled" | "canceled"
|
||||
) {
|
||||
if matches!(status.as_str(), "failed" | "error" | "cancelled" | "canceled") {
|
||||
return Some(
|
||||
scalar_string(value.pointer("/execution_error/exception_message"))
|
||||
.or_else(|| scalar_string(value.get("error")))
|
||||
@@ -1352,10 +1182,7 @@ fn comfy_output_objects(value: &Value) -> Vec<Value> {
|
||||
let mut out = Vec::new();
|
||||
fn push_unique(out: &mut Vec<Value>, item: Value) {
|
||||
let identity = output_identity(&item);
|
||||
if out
|
||||
.iter()
|
||||
.any(|existing| output_identity(existing) == identity)
|
||||
{
|
||||
if out.iter().any(|existing| output_identity(existing) == identity) {
|
||||
return;
|
||||
}
|
||||
out.push(item);
|
||||
@@ -1402,10 +1229,7 @@ fn output_identity(item: &Value) -> String {
|
||||
|
||||
fn looks_like_media_reference(value: &str) -> bool {
|
||||
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;
|
||||
}
|
||||
let lower = trimmed
|
||||
@@ -1504,8 +1328,7 @@ fn output_kind(item: &Value) -> &'static str {
|
||||
|| filename.ends_with(".gif")
|
||||
{
|
||||
"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"
|
||||
} else {
|
||||
"image"
|
||||
@@ -1567,11 +1390,7 @@ async fn archive_comfy_output(
|
||||
}
|
||||
let query = output_query(item)?;
|
||||
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}"))
|
||||
.send()
|
||||
.await
|
||||
@@ -1636,12 +1455,9 @@ async fn submit_comfy_video(
|
||||
}
|
||||
let video_prompt = inject_comfy_video_inputs(&mut prompt, &mapping, body, plan)?;
|
||||
let (prompt_id, response) = submit_comfy_prompt(state, prompt).await?;
|
||||
let model = string_from(
|
||||
body,
|
||||
&["comfyApiModel", "comfy_api_model", "apiModel", "api_model"],
|
||||
)
|
||||
.map(|value| normalize_video_model(&value))
|
||||
.unwrap_or_else(|| "comfy-api".to_string());
|
||||
let model = string_from(body, &["comfyApiModel", "comfy_api_model", "apiModel", "api_model"])
|
||||
.map(|value| normalize_video_model(&value))
|
||||
.unwrap_or_else(|| "comfy-api".to_string());
|
||||
Ok(json!({
|
||||
"backend": "comfy",
|
||||
"promptId": prompt_id,
|
||||
@@ -1672,8 +1488,7 @@ async fn submit_video(
|
||||
return Err(AppError::BadRequest("video prompt is empty".into()));
|
||||
}
|
||||
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
|
||||
.get("duration")
|
||||
.and_then(Value::as_i64)
|
||||
@@ -1744,10 +1559,7 @@ async fn poll_video(
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.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()));
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(VIDEO_POLL_INTERVAL_SECS)).await;
|
||||
@@ -1805,15 +1617,8 @@ async fn create_job(
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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 {
|
||||
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"])
|
||||
.ok_or_else(|| AppError::BadRequest("topic is required".into()))?;
|
||||
@@ -1851,15 +1656,7 @@ async fn create_job(
|
||||
.to_ascii_lowercase();
|
||||
let failure_model = string_from(
|
||||
&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))
|
||||
.unwrap_or_else(|| {
|
||||
@@ -2224,10 +2021,7 @@ async fn clear_jobs(State(state): State<AppState>, headers: HeaderMap) -> AppRes
|
||||
mod tests {
|
||||
use super::*;
|
||||
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},
|
||||
};
|
||||
use axum::http::HeaderValue;
|
||||
@@ -2277,8 +2071,6 @@ mod tests {
|
||||
template_proxy_url: None,
|
||||
},
|
||||
comfyui,
|
||||
pixelle_video: Default::default(),
|
||||
payment: Default::default(),
|
||||
},
|
||||
pool,
|
||||
BuiltinKeys {
|
||||
@@ -2289,7 +2081,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_disables_cloud_api_video_factory_but_keeps_diagnostics() {
|
||||
async fn status_exposes_comfy_cloud_api_model_candidates() {
|
||||
let state = test_state_with_comfy_backend(ComfyUIBackend::Cloud).await;
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
@@ -2301,9 +2093,7 @@ mod tests {
|
||||
.await
|
||||
.expect("status response");
|
||||
|
||||
assert_eq!(value["mode"], "disabled");
|
||||
assert_eq!(value["disabled"], true);
|
||||
assert_eq!(value["builtinCloudTemplate"]["enabled"], false);
|
||||
assert_eq!(value["builtinCloudTemplate"]["enabled"], true);
|
||||
assert_eq!(
|
||||
value["builtinCloudTemplate"]["model"],
|
||||
"seedance-1-0-lite-t2v-250428"
|
||||
@@ -2342,13 +2132,8 @@ mod tests {
|
||||
|
||||
let outputs = comfy_output_objects(&history);
|
||||
assert_eq!(outputs.len(), 3);
|
||||
assert!(outputs
|
||||
.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_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"));
|
||||
}
|
||||
|
||||
@@ -2367,10 +2152,7 @@ mod tests {
|
||||
|
||||
let outputs = comfy_output_objects(&history);
|
||||
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]
|
||||
@@ -2379,9 +2161,7 @@ mod tests {
|
||||
assert!(is_pending_history_status(StatusCode::ACCEPTED));
|
||||
assert!(is_pending_history_status(StatusCode::NO_CONTENT));
|
||||
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]
|
||||
@@ -2419,29 +2199,10 @@ mod tests {
|
||||
.expect("injecting Comfy video inputs should succeed");
|
||||
|
||||
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!(
|
||||
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)
|
||||
);
|
||||
assert_eq!(prompt.pointer("/1/inputs/model").and_then(Value::as_str), Some("seedance-1-0-lite-t2v-250428"));
|
||||
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,8 +2550,6 @@ mod tests {
|
||||
template_proxy_url: None,
|
||||
},
|
||||
comfyui: ComfyUIConfig::default(),
|
||||
pixelle_video: Default::default(),
|
||||
payment: Default::default(),
|
||||
},
|
||||
pool,
|
||||
BuiltinKeys {
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
use crate::{
|
||||
AppState,
|
||||
auth::jwt::validate_jwt,
|
||||
db::models::background_task::BackgroundTask,
|
||||
db::models::downstream_api_key::DownstreamApiKey,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
body::Body,
|
||||
extract::{Multipart, Path, Query, State},
|
||||
http::{header, HeaderMap, Response, StatusCode},
|
||||
http::{HeaderMap, Response, StatusCode, header},
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::time::Duration;
|
||||
|
||||
const TEMPLATE_GALLERY_PAGE_URL: &str =
|
||||
@@ -291,6 +291,7 @@ mod tests {
|
||||
assert!(!is_picture_model_pattern("deepseek-chat"));
|
||||
assert!(!is_picture_model_pattern("qwen-plus"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fn prompt_title(prompt: &str, post_number: Option<i64>) -> String {
|
||||
|
||||
@@ -2121,8 +2121,6 @@ mod tests {
|
||||
template_proxy_url: None,
|
||||
},
|
||||
comfyui: Default::default(),
|
||||
pixelle_video: Default::default(),
|
||||
payment: Default::default(),
|
||||
},
|
||||
pool,
|
||||
crate::services::builtin_keys::BuiltinKeys {
|
||||
|
||||
@@ -696,8 +696,6 @@ mod tests {
|
||||
template_proxy_url: None,
|
||||
},
|
||||
comfyui: ComfyUIConfig::default(),
|
||||
pixelle_video: Default::default(),
|
||||
payment: Default::default(),
|
||||
},
|
||||
pool,
|
||||
BuiltinKeys {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,311 +0,0 @@
|
||||
use super::{
|
||||
cache_control_for_path, content_type_for_path, default_boosapi_workflow_graph,
|
||||
embedded_workflow_ui_response, inject_bootstrap, local_workflow_template_asset,
|
||||
native_workflow_base, simple_urlencode, text_to_video_workflow_graph,
|
||||
workflow_template_not_found_response, EMBEDDED_WORKFLOW_UI, EMBEDDED_WORKFLOW_UI_CLOUD,
|
||||
EMBEDDED_WORKFLOW_UI_EXTERNAL, OFFICIAL_WORKFLOW_TEMPLATES_BASE,
|
||||
};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{header, StatusCode},
|
||||
response::{Html, IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use include_dir::Dir;
|
||||
use serde_json::{json, Value};
|
||||
use std::path::{Path as StdPath, PathBuf};
|
||||
|
||||
pub(super) fn workflow_ui_root_for_config(config: &crate::config::ComfyUIConfig) -> PathBuf {
|
||||
if let Ok(path) = std::env::var("BOOSAPI_WORKFLOW_UI_DIR") {
|
||||
return resolve_workflow_ui_dir(PathBuf::from(path));
|
||||
}
|
||||
let preferred = resolve_workflow_ui_dir(if config.uses_cloud_backend() {
|
||||
std::env::var("BOOSAPI_WORKFLOW_UI_CLOUD_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("dist/workflow-ui-cloud"))
|
||||
} else if config.uses_native_backend() {
|
||||
std::env::var("BOOSAPI_WORKFLOW_UI_EXTERNAL_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("dist/workflow-ui-external"))
|
||||
} else {
|
||||
PathBuf::from("dist/workflow-ui")
|
||||
});
|
||||
if workflow_ui_dir_is_usable(&preferred) {
|
||||
preferred
|
||||
} else {
|
||||
resolve_workflow_ui_dir(PathBuf::from("dist/workflow-ui"))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resolve_workflow_ui_dir(path: PathBuf) -> PathBuf {
|
||||
if path.is_absolute() || workflow_ui_dir_is_usable(&path) {
|
||||
return path;
|
||||
}
|
||||
let manifest_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(&path);
|
||||
if workflow_ui_dir_is_usable(&manifest_path) {
|
||||
manifest_path
|
||||
} else {
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn embedded_workflow_ui_for_config(
|
||||
config: &crate::config::ComfyUIConfig,
|
||||
) -> &'static Dir<'static> {
|
||||
if config.uses_cloud_backend() && embedded_workflow_ui_is_usable(&EMBEDDED_WORKFLOW_UI_CLOUD) {
|
||||
&EMBEDDED_WORKFLOW_UI_CLOUD
|
||||
} else if config.uses_native_backend()
|
||||
&& embedded_workflow_ui_is_usable(&EMBEDDED_WORKFLOW_UI_EXTERNAL)
|
||||
{
|
||||
&EMBEDDED_WORKFLOW_UI_EXTERNAL
|
||||
} else {
|
||||
&EMBEDDED_WORKFLOW_UI
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn workflow_ui_dir_is_usable(dir: &PathBuf) -> bool {
|
||||
let index = dir.join("index.html");
|
||||
std::fs::read_to_string(index)
|
||||
.map(|html| !workflow_ui_index_is_placeholder(&html))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(super) fn embedded_workflow_ui_is_usable(dir: &Dir<'static>) -> bool {
|
||||
dir.get_file("index.html")
|
||||
.and_then(|file| std::str::from_utf8(file.contents()).ok())
|
||||
.map(|html| !workflow_ui_index_is_placeholder(html))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(super) fn workflow_ui_index_is_placeholder(html: &str) -> bool {
|
||||
html.contains("Workflow UI not built")
|
||||
}
|
||||
|
||||
pub(super) async fn workflow_ui_runtime(State(state): State<AppState>) -> Json<Value> {
|
||||
let mode = if state.config.comfyui.uses_docker_backend() {
|
||||
"docker"
|
||||
} else if state.config.comfyui.uses_cloud_backend() {
|
||||
"cloud"
|
||||
} else if state.config.comfyui.uses_runninghub_backend() {
|
||||
"runninghub"
|
||||
} else if state.config.comfyui.uses_native_backend() {
|
||||
"external"
|
||||
} else {
|
||||
"boosapi"
|
||||
};
|
||||
let frontend_dir = workflow_ui_root_for_config(&state.config.comfyui);
|
||||
Json(json!({
|
||||
"mode": mode,
|
||||
"baseUrl": state.config.comfyui.target_base_url(),
|
||||
"frontendDir": frontend_dir,
|
||||
"frontendBuild": workflow_ui_build_metadata(&frontend_dir),
|
||||
"cloudApiKeyConfigured": workflow_cloud_api_key(&state.config.comfyui).is_some(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn workflow_ui_build_metadata(dir: &StdPath) -> Value {
|
||||
let path = dir.join("boosapi-workflow-ui.json");
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
pub(super) fn workflow_cloud_api_key(config: &crate::config::ComfyUIConfig) -> Option<&str> {
|
||||
config
|
||||
.api_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
pub(super) async fn workflow_ui_index(State(state): State<AppState>) -> Html<String> {
|
||||
let dir = workflow_ui_root_for_config(&state.config.comfyui);
|
||||
let index = dir.join("index.html");
|
||||
match tokio::fs::read_to_string(&index).await {
|
||||
Ok(html) => Html(inject_bootstrap(html, &state.config.comfyui)),
|
||||
Err(_) => {
|
||||
if let Some(file) =
|
||||
embedded_workflow_ui_for_config(&state.config.comfyui).get_file("index.html")
|
||||
{
|
||||
let html = String::from_utf8_lossy(file.contents()).to_string();
|
||||
return Html(inject_bootstrap(html, &state.config.comfyui));
|
||||
}
|
||||
Html(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BoosAPI Workflow Studio</title>
|
||||
<style>
|
||||
html, body { margin: 0; width: 100%; height: 100%; background: #111827; color: #e5e7eb; font-family: system-ui, sans-serif; }
|
||||
.wrap { display: grid; place-items: center; height: 100%; padding: 24px; text-align: center; }
|
||||
.box { max-width: 720px; line-height: 1.7; }
|
||||
code { background: rgba(255,255,255,0.08); padding: 2px 6px; border-radius: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="box">
|
||||
<h1>Workflow Studio 前端未构建</h1>
|
||||
<p>官方 ComfyUI 前端会按后端类型从 <code>dist/workflow-ui-external</code> 或 <code>dist/workflow-ui-cloud</code> 加载。请先运行 <code>npm run workflow-ui:install</code>,然后刷新此页面。</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn workflow_ui_static(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> AppResult<Response> {
|
||||
let dir = workflow_ui_root_for_config(&state.config.comfyui);
|
||||
let path = path.trim_start_matches('/').to_string();
|
||||
if path == "api/workflow_templates/boosapi/文生图示例.json" {
|
||||
return Ok(Json(default_boosapi_workflow_graph()).into_response());
|
||||
}
|
||||
if path == "api/workflow_templates/boosapi/文生视频示例.json" {
|
||||
return Ok(Json(text_to_video_workflow_graph()).into_response());
|
||||
}
|
||||
let file_path = if path.is_empty() {
|
||||
dir.join("index.html")
|
||||
} else {
|
||||
dir.join(&path)
|
||||
};
|
||||
|
||||
if file_path.is_dir() {
|
||||
return serve_file(file_path.join("index.html")).await;
|
||||
}
|
||||
if tokio::fs::metadata(&file_path).await.is_ok() {
|
||||
return serve_file(file_path).await;
|
||||
}
|
||||
if let Some(response) =
|
||||
embedded_workflow_ui_response(&state.config.comfyui, &path, path.is_empty())
|
||||
{
|
||||
return Ok(response);
|
||||
}
|
||||
if PathBuf::from(&path).extension().is_none() {
|
||||
return Ok(workflow_ui_index(State(state)).await.into_response());
|
||||
}
|
||||
serve_file(file_path).await
|
||||
}
|
||||
|
||||
pub(super) async fn official_workflow_template_asset(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> AppResult<Response> {
|
||||
let path = path.trim_start_matches('/').replace('\\', "/");
|
||||
if path.is_empty() || path.contains("..") {
|
||||
return Err(AppError::BadRequest("invalid template path".into()));
|
||||
}
|
||||
if !state.config.comfyui.uses_cloud_backend() {
|
||||
if let Some(response) = local_workflow_template_asset(&path) {
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
let encoded_path = path
|
||||
.split('/')
|
||||
.map(simple_urlencode)
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
let url = if state.config.comfyui.uses_cloud_backend() {
|
||||
format!(
|
||||
"{}/templates/{}",
|
||||
native_workflow_base(&state),
|
||||
encoded_path
|
||||
)
|
||||
} else {
|
||||
format!("{}/{}", OFFICIAL_WORKFLOW_TEMPLATES_BASE, encoded_path)
|
||||
};
|
||||
let mut request =
|
||||
crate::template_http::client(&state.config, state.config.proxy.upstream_timeout_secs)
|
||||
.get(url);
|
||||
if state.config.comfyui.uses_cloud_backend() {
|
||||
if let Some(api_key) = state
|
||||
.config
|
||||
.comfyui
|
||||
.api_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
request = request
|
||||
.header("X-API-Key", api_key)
|
||||
.header("Authorization", format!("Bearer {api_key}"));
|
||||
}
|
||||
}
|
||||
let response = match request.send().await {
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, path, "workflow template remote source unavailable");
|
||||
if let Some(response) = local_workflow_template_asset(&path) {
|
||||
return Ok(response);
|
||||
}
|
||||
return Ok(workflow_template_not_found_response(&path));
|
||||
}
|
||||
};
|
||||
let status = response.status();
|
||||
let bytes = match response.bytes().await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, path, "workflow template remote body unavailable");
|
||||
if let Some(response) = local_workflow_template_asset(&path) {
|
||||
return Ok(response);
|
||||
}
|
||||
return Ok(workflow_template_not_found_response(&path));
|
||||
}
|
||||
};
|
||||
Ok((
|
||||
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY),
|
||||
[
|
||||
(
|
||||
header::CONTENT_TYPE,
|
||||
content_type_for_path(&PathBuf::from(&path)),
|
||||
),
|
||||
(
|
||||
header::CACHE_CONTROL,
|
||||
if path == "index.json" {
|
||||
"public, max-age=1800"
|
||||
} else {
|
||||
"public, max-age=86400"
|
||||
},
|
||||
),
|
||||
],
|
||||
bytes,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub(super) async fn serve_file(path: PathBuf) -> AppResult<Response> {
|
||||
match tokio::fs::read(&path).await {
|
||||
Ok(bytes) => {
|
||||
let content_type = content_type_for_path(&path);
|
||||
let cache_control = cache_control_for_path(&path);
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[
|
||||
(axum::http::header::CONTENT_TYPE, content_type),
|
||||
(axum::http::header::CACHE_CONTROL, cache_control),
|
||||
],
|
||||
bytes,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
Err(_) => Ok((
|
||||
StatusCode::NOT_FOUND,
|
||||
[(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
"text/plain; charset=utf-8",
|
||||
)],
|
||||
"workflow-ui asset not found".to_string(),
|
||||
)
|
||||
.into_response()),
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
use super::{parse_workflow_cookie, WorkflowCaller};
|
||||
use crate::auth::jwt::validate_jwt;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::AppState;
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
pub(super) fn caller_from_headers(
|
||||
state: &AppState,
|
||||
headers: &HeaderMap,
|
||||
) -> AppResult<WorkflowCaller> {
|
||||
let token = headers
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| {
|
||||
value
|
||||
.strip_prefix("Bearer ")
|
||||
.or_else(|| value.strip_prefix("bearer "))
|
||||
})
|
||||
.or_else(|| {
|
||||
headers
|
||||
.get(axum::http::header::COOKIE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(parse_workflow_cookie)
|
||||
})
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing authorization token".into()))?;
|
||||
|
||||
let proxy_token = state.runtime_proxy_token();
|
||||
if token == state.config.auth.auth_token || token == proxy_token {
|
||||
return Ok(WorkflowCaller::Admin);
|
||||
}
|
||||
|
||||
let claims = validate_jwt(&state.config.auth.jwt_secret, token)
|
||||
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
|
||||
if claims.role == "admin" {
|
||||
return Ok(WorkflowCaller::Admin);
|
||||
}
|
||||
if claims.role == "user" {
|
||||
let user_id = claims
|
||||
.sub
|
||||
.parse::<i64>()
|
||||
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))?;
|
||||
return Ok(WorkflowCaller::User { user_id });
|
||||
}
|
||||
Err(AppError::Forbidden("Workflow access denied".into()))
|
||||
}
|
||||
|
||||
pub(super) fn caller_user_id(caller: &WorkflowCaller) -> Option<i64> {
|
||||
match caller {
|
||||
WorkflowCaller::Admin => None,
|
||||
WorkflowCaller::User { user_id } => Some(*user_id),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn caller_is_admin(caller: &WorkflowCaller) -> bool {
|
||||
matches!(caller, WorkflowCaller::Admin)
|
||||
}
|
||||
|
||||
pub(super) async fn auth_caller(state: &AppState, headers: &HeaderMap) -> AppResult<WorkflowCaller> {
|
||||
caller_from_headers(state, headers)
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
use super::model_payload_items_from_names;
|
||||
use crate::error::AppResult;
|
||||
use crate::AppState;
|
||||
use serde_json::Value;
|
||||
|
||||
pub(super) fn is_exact_workflow_model(value: &str) -> bool {
|
||||
let normalized = value.trim();
|
||||
!normalized.is_empty()
|
||||
&& !normalized.to_ascii_lowercase().starts_with("re:")
|
||||
&& !normalized.contains('*')
|
||||
&& !normalized.contains('?')
|
||||
}
|
||||
|
||||
pub(super) fn is_image_workflow_model(value: &str) -> bool {
|
||||
if !is_exact_workflow_model(value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let normalized = value.trim().to_ascii_lowercase();
|
||||
let non_image_keywords = [
|
||||
"tts",
|
||||
"voice",
|
||||
"audio",
|
||||
"embedding",
|
||||
"embed",
|
||||
"seedance",
|
||||
"wan2",
|
||||
"t2v",
|
||||
"i2v",
|
||||
"flf2v",
|
||||
"seaweed",
|
||||
"3d",
|
||||
"hitem3d",
|
||||
"hyper3d",
|
||||
];
|
||||
if non_image_keywords
|
||||
.iter()
|
||||
.any(|keyword| normalized.contains(keyword))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let image_keywords = [
|
||||
"gpt-image",
|
||||
"dall-e",
|
||||
"dalle",
|
||||
"t2i",
|
||||
"i2i",
|
||||
"midjourney",
|
||||
"recraft",
|
||||
"kolors",
|
||||
"cogview",
|
||||
"seedream",
|
||||
"sdxl",
|
||||
"stable-diffusion",
|
||||
"stablediffusion",
|
||||
"pixart",
|
||||
"playground",
|
||||
"firefly",
|
||||
"imagen",
|
||||
"flux",
|
||||
"image",
|
||||
"img",
|
||||
"canvas",
|
||||
"painting",
|
||||
"draw",
|
||||
];
|
||||
image_keywords
|
||||
.iter()
|
||||
.any(|keyword| normalized.contains(keyword))
|
||||
}
|
||||
|
||||
pub(super) fn is_video_workflow_model(value: &str) -> bool {
|
||||
let normalized = value.trim().to_ascii_lowercase();
|
||||
[
|
||||
"seedance", "wan2", "t2v", "i2v", "flf2v", "video", "seaweed",
|
||||
]
|
||||
.iter()
|
||||
.any(|keyword| normalized.contains(keyword))
|
||||
}
|
||||
|
||||
pub(super) fn is_audio_workflow_model(value: &str) -> bool {
|
||||
let normalized = value.trim().to_ascii_lowercase();
|
||||
["tts", "voice", "audio", "speech"]
|
||||
.iter()
|
||||
.any(|keyword| normalized.contains(keyword))
|
||||
}
|
||||
|
||||
pub(super) fn is_embedding_workflow_model(value: &str) -> bool {
|
||||
let normalized = value.trim().to_ascii_lowercase();
|
||||
normalized.contains("embedding") || normalized.contains("embed")
|
||||
}
|
||||
|
||||
pub(super) fn is_3d_workflow_model(value: &str) -> bool {
|
||||
let normalized = value.trim().to_ascii_lowercase();
|
||||
["3d", "hitem3d", "hyper3d"]
|
||||
.iter()
|
||||
.any(|keyword| normalized.contains(keyword))
|
||||
}
|
||||
|
||||
pub(super) fn is_vision_workflow_model(value: &str) -> bool {
|
||||
let normalized = value.trim().to_ascii_lowercase();
|
||||
["vision", "vl", "omni", "ui-tars"]
|
||||
.iter()
|
||||
.any(|keyword| normalized.contains(keyword))
|
||||
&& !is_image_workflow_model(value)
|
||||
&& !is_video_workflow_model(value)
|
||||
&& !is_embedding_workflow_model(value)
|
||||
}
|
||||
|
||||
pub(super) fn is_chat_workflow_model(value: &str) -> bool {
|
||||
is_exact_workflow_model(value)
|
||||
&& !is_image_workflow_model(value)
|
||||
&& !is_video_workflow_model(value)
|
||||
&& !is_audio_workflow_model(value)
|
||||
&& !is_embedding_workflow_model(value)
|
||||
&& !is_3d_workflow_model(value)
|
||||
}
|
||||
|
||||
pub(super) async fn workflow_route_models(state: &AppState) -> AppResult<Vec<String>> {
|
||||
let rows = sqlx::query_scalar::<_, String>(
|
||||
"SELECT DISTINCT model_pattern
|
||||
FROM token_routes
|
||||
WHERE COALESCE(enabled, 1) = 1
|
||||
AND model_pattern IS NOT NULL
|
||||
AND model_pattern <> ''
|
||||
ORDER BY model_pattern COLLATE NOCASE",
|
||||
)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| is_exact_workflow_model(value))
|
||||
.filter(|value| seen.insert(value.clone()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(super) fn image_model_choices(models: &[String]) -> Vec<String> {
|
||||
let image_models = models
|
||||
.iter()
|
||||
.filter(|model| is_image_workflow_model(model))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if image_models.is_empty() {
|
||||
models.to_vec()
|
||||
} else {
|
||||
image_models
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn workflow_folder_models(folder: &str, models: &[String]) -> Vec<String> {
|
||||
let normalized = folder.trim().to_ascii_lowercase();
|
||||
match normalized.as_str() {
|
||||
"boosapi_models" | "boosapi" | "api_models" => models.to_vec(),
|
||||
"image_models" => image_model_choices(models),
|
||||
"chat_models" => models
|
||||
.iter()
|
||||
.filter(|model| is_chat_workflow_model(model))
|
||||
.cloned()
|
||||
.collect(),
|
||||
"clip" => models.to_vec(),
|
||||
// Comfy's video_models folder is a native Comfy API model folder. Keep it
|
||||
// separate from BoosAPI route models so video apps submit real Comfy API
|
||||
// model names instead of OpenAI-compatible route aliases.
|
||||
"video_models" => Vec::new(),
|
||||
"audio_tts_models" => models
|
||||
.iter()
|
||||
.filter(|model| is_audio_workflow_model(model))
|
||||
.cloned()
|
||||
.collect(),
|
||||
"embeddings" => models
|
||||
.iter()
|
||||
.filter(|model| is_embedding_workflow_model(model))
|
||||
.cloned()
|
||||
.collect(),
|
||||
"3d_models" => models
|
||||
.iter()
|
||||
.filter(|model| is_3d_workflow_model(model))
|
||||
.cloned()
|
||||
.collect(),
|
||||
"vision_models" => models
|
||||
.iter()
|
||||
.filter(|model| is_vision_workflow_model(model))
|
||||
.cloned()
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn workflow_folder_is_api_only(folder: &str) -> bool {
|
||||
matches!(
|
||||
folder.trim().to_ascii_lowercase().as_str(),
|
||||
"boosapi" | "boosapi_models" | "api_models"
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn workflow_folder_has_route_models(folder: &str) -> bool {
|
||||
matches!(
|
||||
folder.trim().to_ascii_lowercase().as_str(),
|
||||
"boosapi"
|
||||
| "boosapi_models"
|
||||
| "api_models"
|
||||
| "image_models"
|
||||
| "chat_models"
|
||||
| "audio_tts_models"
|
||||
| "embeddings"
|
||||
| "3d_models"
|
||||
| "vision_models"
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn workflow_folder_route_model_items(folder: &str, route_models: &[String]) -> Vec<Value> {
|
||||
model_payload_items_from_names(workflow_folder_models(folder, route_models), 0)
|
||||
}
|
||||
|
||||
pub(super) fn combo_choices_from_node_input(
|
||||
object_info: &Value,
|
||||
node_type: &str,
|
||||
input_name: &str,
|
||||
) -> Vec<String> {
|
||||
object_info
|
||||
.get(node_type)
|
||||
.and_then(|node| {
|
||||
node.pointer(&format!("/input/required/{input_name}"))
|
||||
.or_else(|| node.pointer(&format!("/input/optional/{input_name}")))
|
||||
})
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|input| input.first())
|
||||
.and_then(Value::as_array)
|
||||
.map(|choices| {
|
||||
choices
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty() && *value != "None")
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(super) fn combo_fields_from_node(object_info: &Value, node_type: &str) -> Vec<(String, Vec<String>)> {
|
||||
let mut fields = Vec::new();
|
||||
let Some(node) = object_info.get(node_type) else {
|
||||
return fields;
|
||||
};
|
||||
for section in ["required", "optional"] {
|
||||
if let Some(inputs) = node
|
||||
.pointer(&format!("/input/{section}"))
|
||||
.and_then(Value::as_object)
|
||||
{
|
||||
for (name, value) in inputs {
|
||||
let Some(choices) = value
|
||||
.as_array()
|
||||
.and_then(|input| input.first())
|
||||
.and_then(Value::as_array)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let choices = choices
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty() && *value != "None")
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
fields.push((name.clone(), choices));
|
||||
}
|
||||
}
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
pub(super) fn native_folder_models_from_object_info(folder: &str, object_info: &Value) -> Vec<String> {
|
||||
let normalized = folder.trim().to_ascii_lowercase();
|
||||
match normalized.as_str() {
|
||||
"checkpoints" | "image_models" => {
|
||||
combo_choices_from_node_input(object_info, "CheckpointLoaderSimple", "ckpt_name")
|
||||
}
|
||||
"loras" => combo_choices_from_node_input(object_info, "LoraLoader", "lora_name"),
|
||||
"vae" => combo_choices_from_node_input(object_info, "VAELoader", "vae_name"),
|
||||
"clip" | "clip_models" | "text_encoders" => {
|
||||
combo_choices_from_node_input(object_info, "CLIPLoader", "clip_name")
|
||||
}
|
||||
"unet" | "diffusion_models" | "diffusion_models_files" => {
|
||||
combo_choices_from_node_input(object_info, "UNETLoader", "unet_name")
|
||||
}
|
||||
"controlnet" | "controlnets" => {
|
||||
combo_choices_from_node_input(object_info, "ControlNetLoader", "control_net_name")
|
||||
}
|
||||
"upscale_models" => {
|
||||
combo_choices_from_node_input(object_info, "UpscaleModelLoader", "model_name")
|
||||
}
|
||||
"embeddings" => combo_choices_from_node_input(object_info, "EmbeddingPicker", "embedding"),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -1,517 +0,0 @@
|
||||
use super::DEFAULT_TEXT_TO_VIDEO_MODEL;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::workflow::{
|
||||
graph,
|
||||
models::{NodeTypeDefinition, WorkflowGraph, WorkflowNode},
|
||||
};
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
pub(super) fn known_output_ports(node_type: &str) -> &'static [&'static str] {
|
||||
match node_type {
|
||||
"CheckpointLoaderSimple" => &["MODEL", "CLIP", "VAE"],
|
||||
"CLIPTextEncode" => &["CONDITIONING"],
|
||||
"EmptyLatentImage" => &["LATENT"],
|
||||
"KSampler" => &["LATENT"],
|
||||
"VAEDecode" => &["IMAGE"],
|
||||
"SaveImage" => &["IMAGE"],
|
||||
"PreviewImage" => &["IMAGE"],
|
||||
"input.text" => &["text"],
|
||||
"boosapi.chat_completion" => &["text", "raw"],
|
||||
"boosapi.prompt_rewrite" => &["prompt", "raw"],
|
||||
"boosapi.text_to_image" => &["image", "images", "raw"],
|
||||
"boosapi.text_to_video" | "boosapi.image_to_video" => &["task_id", "video", "raw"],
|
||||
"boosapi.video_status" => &["video", "raw"],
|
||||
"boosapi.comfyui_workflow" => &["workflow", "text", "raw"],
|
||||
"comfyui.submit_workflow" => &["prompt_id", "response", "workflow"],
|
||||
"output.preview_image" => &["image"],
|
||||
"output.preview_video" => &["video"],
|
||||
"output.save_file" => &["file"],
|
||||
"util.text_join" => &["text"],
|
||||
"util.json_extract" => &["value", "text"],
|
||||
_ => &["output"],
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn core_node_definitions() -> Vec<NodeTypeDefinition> {
|
||||
vec![
|
||||
NodeTypeDefinition {
|
||||
node_type: "CheckpointLoaderSimple",
|
||||
title: "CheckpointLoaderSimple",
|
||||
category: "loaders",
|
||||
description: "Loads a checkpoint model and provides model, clip and vae outputs.",
|
||||
inputs: json!({}),
|
||||
params: json!({
|
||||
"ckpt_name": { "ui": "combo", "type": "COMBO", "default": "gpt-image-2" }
|
||||
}),
|
||||
outputs: json!({
|
||||
"MODEL": { "type": "MODEL" },
|
||||
"CLIP": { "type": "CLIP" },
|
||||
"VAE": { "type": "VAE" }
|
||||
}),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "CLIPTextEncode",
|
||||
title: "CLIPTextEncode",
|
||||
category: "conditioning",
|
||||
description: "Encodes text into conditioning.",
|
||||
inputs: json!({
|
||||
"clip": { "type": "CLIP", "required": false }
|
||||
}),
|
||||
params: json!({
|
||||
"text": { "ui": "textarea", "type": "STRING", "default": "" }
|
||||
}),
|
||||
outputs: json!({ "CONDITIONING": { "type": "CONDITIONING" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "EmptyLatentImage",
|
||||
title: "EmptyLatentImage",
|
||||
category: "latent",
|
||||
description: "Creates an empty latent image.",
|
||||
inputs: json!({}),
|
||||
params: json!({
|
||||
"width": { "ui": "number", "type": "INT", "default": 512, "min": 64, "max": 4096 },
|
||||
"height": { "ui": "number", "type": "INT", "default": 512, "min": 64, "max": 4096 },
|
||||
"batch_size": { "ui": "number", "type": "INT", "default": 1, "min": 1, "max": 64 }
|
||||
}),
|
||||
outputs: json!({ "LATENT": { "type": "LATENT" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "KSampler",
|
||||
title: "KSampler",
|
||||
category: "sampling",
|
||||
description: "Samples latent images with a diffusion model.",
|
||||
inputs: json!({
|
||||
"model": { "type": "MODEL", "required": true },
|
||||
"positive": { "type": "CONDITIONING", "required": true },
|
||||
"negative": { "type": "CONDITIONING", "required": true },
|
||||
"latent_image": { "type": "LATENT", "required": true }
|
||||
}),
|
||||
params: json!({
|
||||
"seed": { "ui": "number", "type": "INT", "default": 0 },
|
||||
"control_after_generate": { "ui": "boolean", "type": "BOOLEAN", "default": true },
|
||||
"steps": { "ui": "number", "type": "INT", "default": 20, "min": 1, "max": 100 },
|
||||
"cfg": { "ui": "number", "type": "FLOAT", "default": 8, "min": 0, "max": 50 },
|
||||
"sampler_name": { "ui": "text", "type": "STRING", "default": "euler" },
|
||||
"scheduler": { "ui": "text", "type": "STRING", "default": "normal" },
|
||||
"denoise": { "ui": "number", "type": "FLOAT", "default": 1, "min": 0, "max": 1 }
|
||||
}),
|
||||
outputs: json!({ "LATENT": { "type": "LATENT" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "VAEDecode",
|
||||
title: "VAEDecode",
|
||||
category: "latent",
|
||||
description: "Decodes latent samples to images.",
|
||||
inputs: json!({
|
||||
"samples": { "type": "LATENT", "required": true },
|
||||
"vae": { "type": "VAE", "required": true }
|
||||
}),
|
||||
params: json!({}),
|
||||
outputs: json!({ "IMAGE": { "type": "IMAGE" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "SaveImage",
|
||||
title: "SaveImage",
|
||||
category: "image",
|
||||
description: "Saves images to disk.",
|
||||
inputs: json!({
|
||||
"images": { "type": "IMAGE", "required": true }
|
||||
}),
|
||||
params: json!({
|
||||
"filename_prefix": { "ui": "text", "type": "STRING", "default": "ComfyUI" }
|
||||
}),
|
||||
outputs: json!({ "IMAGE": { "type": "IMAGE" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "PreviewImage",
|
||||
title: "PreviewImage",
|
||||
category: "image",
|
||||
description: "Previews generated images.",
|
||||
inputs: json!({
|
||||
"images": { "type": "IMAGE", "required": true }
|
||||
}),
|
||||
params: json!({}),
|
||||
outputs: json!({ "IMAGE": { "type": "IMAGE" } }),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) fn workflow_template_payload() -> Value {
|
||||
json!({
|
||||
"boosapi": ["文生图示例", "文生视频示例"]
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn core_workflow_template_index() -> Value {
|
||||
json!([
|
||||
{
|
||||
"moduleName": "default",
|
||||
"title": "BoosAPI Examples",
|
||||
"category": "basics",
|
||||
"type": "core",
|
||||
"isEssential": true,
|
||||
"templates": [
|
||||
{
|
||||
"name": "boosapi-text-to-image",
|
||||
"title": "BoosAPI 文生图",
|
||||
"description": "使用 BoosAPI 模型路由生成图片并预览输出。",
|
||||
"mediaType": "image",
|
||||
"mediaSubtype": "svg",
|
||||
"tags": ["boosapi", "image"],
|
||||
"models": ["BoosAPI"]
|
||||
},
|
||||
{
|
||||
"name": "boosapi-text-to-video",
|
||||
"title": "BoosAPI 文生视频",
|
||||
"description": "使用 BoosAPI 视频节点生成视频并保存输出。",
|
||||
"mediaType": "image",
|
||||
"mediaSubtype": "svg",
|
||||
"tags": ["boosapi", "video"],
|
||||
"models": ["BoosAPI"]
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
pub(super) fn inject_boosapi_workflow_template_payload(value: &mut Value) {
|
||||
let Some(object) = value.as_object_mut() else {
|
||||
*value = workflow_template_payload();
|
||||
return;
|
||||
};
|
||||
object
|
||||
.entry("boosapi".to_string())
|
||||
.or_insert_with(|| json!(["文生图示例", "文生视频示例"]));
|
||||
}
|
||||
|
||||
pub(super) fn default_boosapi_workflow_graph() -> Value {
|
||||
json!({
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "input.text",
|
||||
"pos": [80, 140],
|
||||
"size": [460, 260],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [{ "name": "text", "type": "STRING", "links": [1], "slot_index": 0 }],
|
||||
"properties": { "Node name for S&R": "input.text" },
|
||||
"widgets_values": ["cinematic golden hour portrait of an ancient Chinese woman in jade-green hanfu, delicate light, ultra detailed"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "boosapi.text_to_image",
|
||||
"pos": [520, 120],
|
||||
"size": [380, 180],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "prompt", "type": "STRING", "link": 1 }],
|
||||
"outputs": [
|
||||
{ "name": "image", "type": "IMAGE", "links": [2], "slot_index": 0 },
|
||||
{ "name": "images", "type": "IMAGE", "links": null, "slot_index": 1 },
|
||||
{ "name": "raw", "type": "JSON", "links": null, "slot_index": 2 }
|
||||
],
|
||||
"properties": { "Node name for S&R": "boosapi.text_to_image" },
|
||||
"widgets_values": ["gpt-image-2", 1024, 1024, ""]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "output.preview_image",
|
||||
"pos": [980, 140],
|
||||
"size": [320, 120],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "image", "type": "IMAGE", "link": 2 }],
|
||||
"outputs": [{ "name": "image", "type": "IMAGE", "links": null, "slot_index": 0 }],
|
||||
"properties": { "Node name for S&R": "output.preview_image" },
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 0, 2, 0, "STRING"],
|
||||
[2, 2, 0, 3, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": { "ds": { "scale": 0.9, "offset": [80, 80] } },
|
||||
"version": 0.4
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn text_to_video_workflow_graph() -> Value {
|
||||
json!({
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 3,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "input.text",
|
||||
"pos": [80, 150],
|
||||
"size": [480, 260],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [{ "name": "text", "type": "STRING", "links": [1], "slot_index": 0 }],
|
||||
"properties": { "Node name for S&R": "input.text" },
|
||||
"widgets_values": ["cinematic aerial shot of a glass bottle floating through a purple galaxy nebula, elegant camera motion, dreamy lighting"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "boosapi.text_to_video",
|
||||
"pos": [600, 120],
|
||||
"size": [420, 300],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "prompt", "type": "STRING", "link": 1 }],
|
||||
"outputs": [
|
||||
{ "name": "task_id", "type": "STRING", "links": [2], "slot_index": 0 },
|
||||
{ "name": "video", "type": "VIDEO", "links": [3], "slot_index": 1 },
|
||||
{ "name": "raw", "type": "JSON", "links": null, "slot_index": 2 }
|
||||
],
|
||||
"properties": { "Node name for S&R": "boosapi.text_to_video" },
|
||||
"widgets_values": [DEFAULT_TEXT_TO_VIDEO_MODEL, "16:9", 5, "720p", 24, true, 5, 180]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "boosapi.video_status",
|
||||
"pos": [1080, 95],
|
||||
"size": [360, 140],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "task_id", "type": "STRING", "link": 2 }],
|
||||
"outputs": [
|
||||
{ "name": "video", "type": "VIDEO", "links": null, "slot_index": 0 },
|
||||
{ "name": "raw", "type": "JSON", "links": null, "slot_index": 1 }
|
||||
],
|
||||
"properties": { "Node name for S&R": "boosapi.video_status" },
|
||||
"widgets_values": [DEFAULT_TEXT_TO_VIDEO_MODEL]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "output.preview_video",
|
||||
"pos": [1080, 285],
|
||||
"size": [340, 120],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "video", "type": "VIDEO", "link": 3 }],
|
||||
"outputs": [{ "name": "video", "type": "VIDEO", "links": null, "slot_index": 0 }],
|
||||
"properties": { "Node name for S&R": "output.preview_video" },
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 0, 2, 0, "STRING"],
|
||||
[2, 2, 0, 3, 0, "STRING"],
|
||||
[3, 2, 1, 4, 0, "VIDEO"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": { "ds": { "scale": 0.85, "offset": [80, 80] } },
|
||||
"version": 0.4
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn port_name(node_type: &str, index: usize) -> &'static str {
|
||||
known_output_ports(node_type)
|
||||
.get(index)
|
||||
.copied()
|
||||
.unwrap_or_else(|| known_output_ports(node_type)[0])
|
||||
}
|
||||
|
||||
pub(super) fn source_node_type(prompt: &Value, node_id: &str) -> Option<String> {
|
||||
prompt
|
||||
.as_object()?
|
||||
.get(node_id)?
|
||||
.get("class_type")?
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub(super) fn normalize_prompt_payload(prompt: &Value) -> AppResult<WorkflowGraph> {
|
||||
let nodes = prompt
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::BadRequest("prompt must be an object".into()))?;
|
||||
|
||||
let mut workflow_nodes = Vec::with_capacity(nodes.len());
|
||||
let mut edges = Vec::new();
|
||||
|
||||
for (node_id, raw_node) in nodes {
|
||||
let node_type = raw_node
|
||||
.get("class_type")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| AppError::BadRequest(format!("node {node_id} missing class_type")))?;
|
||||
let inputs = raw_node.get("inputs").and_then(Value::as_object);
|
||||
let mut params = Map::new();
|
||||
|
||||
if let Some(map) = inputs {
|
||||
for (input_key, input_value) in map {
|
||||
if let Some((source_id, output_index)) = prompt_link(input_value) {
|
||||
let source_type = source_node_type(prompt, &source_id)
|
||||
.unwrap_or_else(|| "input.text".to_string());
|
||||
edges.push(crate::workflow::models::WorkflowEdge {
|
||||
from: format!("{source_id}.{}", port_name(&source_type, output_index)),
|
||||
to: format!("{node_id}.{input_key}"),
|
||||
});
|
||||
} else {
|
||||
params.insert(input_key.clone(), input_value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
workflow_nodes.push(WorkflowNode {
|
||||
id: node_id.clone(),
|
||||
node_type: node_type.to_string(),
|
||||
position: raw_node
|
||||
.get("_meta")
|
||||
.and_then(|meta| meta.get("pos"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({ "x": 80, "y": 120 })),
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
let graph = WorkflowGraph {
|
||||
version: "1.0".to_string(),
|
||||
name: Some("ComfyUI workflow".to_string()),
|
||||
nodes: workflow_nodes,
|
||||
edges,
|
||||
};
|
||||
graph::validate_and_sort(&graph)?;
|
||||
Ok(graph)
|
||||
}
|
||||
|
||||
pub(super) fn prompt_link(value: &Value) -> Option<(String, usize)> {
|
||||
let array = value.as_array()?;
|
||||
let source = array.first()?.as_str()?.to_string();
|
||||
let index = array.get(1).and_then(Value::as_u64).unwrap_or_default() as usize;
|
||||
Some((source, index))
|
||||
}
|
||||
|
||||
pub(super) fn node_type_def_to_comfy(def: &NodeTypeDefinition) -> Value {
|
||||
let mut required = Map::new();
|
||||
let mut optional = Map::new();
|
||||
|
||||
if let Some(inputs) = def.inputs.as_object() {
|
||||
for (name, spec) in inputs {
|
||||
required.insert(name.clone(), input_spec_to_comfy(spec, true));
|
||||
}
|
||||
}
|
||||
if let Some(params) = def.params.as_object() {
|
||||
for (name, spec) in params {
|
||||
optional.insert(name.clone(), input_spec_to_comfy(spec, false));
|
||||
}
|
||||
}
|
||||
|
||||
json!({
|
||||
"name": def.node_type,
|
||||
"display_name": def.title,
|
||||
"description": def.description,
|
||||
"category": def.category,
|
||||
"output_node": def.category.eq_ignore_ascii_case("output"),
|
||||
"python_module": "boosapi.workflow",
|
||||
"input": {
|
||||
"required": required,
|
||||
"optional": optional,
|
||||
},
|
||||
"output": output_specs(def),
|
||||
"output_name": known_output_ports(def.node_type),
|
||||
"output_is_list": output_is_list_specs(def),
|
||||
"output_tooltips": vec![""; known_output_ports(def.node_type).len()],
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn input_spec_to_comfy(value: &Value, required: bool) -> Value {
|
||||
let ty = value
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("STRING");
|
||||
let default = value.get("default").cloned();
|
||||
let options = value
|
||||
.get("options")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let mut props = Map::new();
|
||||
if let Some(default) = default {
|
||||
props.insert("default".into(), default);
|
||||
}
|
||||
if value.get("ui").and_then(Value::as_str) == Some("textarea") {
|
||||
props.insert("multiline".into(), Value::Bool(true));
|
||||
props.insert("dynamicPrompts".into(), Value::Bool(false));
|
||||
}
|
||||
if required {
|
||||
props.insert("forceInput".into(), Value::Bool(true));
|
||||
}
|
||||
if ty == "CONDITIONING" {
|
||||
return json!(["CONDITIONING", props]);
|
||||
}
|
||||
match ty {
|
||||
"COMBO" => {
|
||||
let choices = if options.is_empty() {
|
||||
props
|
||||
.get("default")
|
||||
.cloned()
|
||||
.map(|value| vec![value])
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
options
|
||||
};
|
||||
json!([choices, props])
|
||||
}
|
||||
"BOOLEAN" => json!(["BOOLEAN", props]),
|
||||
"NUMBER" | "FLOAT" => json!(["FLOAT", props]),
|
||||
"INT" => json!(["INT", props]),
|
||||
"JSON" => json!(["STRING", props]),
|
||||
"FILE" => json!(["STRING", props]),
|
||||
"STRING" => json!(["STRING", props]),
|
||||
_ => json!([ty, props]),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn output_specs(def: &NodeTypeDefinition) -> Vec<Value> {
|
||||
let mut outputs = Vec::new();
|
||||
match def.node_type {
|
||||
"input.text" => outputs.push(json!("STRING")),
|
||||
"boosapi.chat_completion" => outputs.extend([json!("STRING"), json!("JSON")]),
|
||||
"boosapi.prompt_rewrite" => outputs.extend([json!("STRING"), json!("JSON")]),
|
||||
"boosapi.text_to_image" => outputs.extend([json!("IMAGE"), json!("IMAGE"), json!("JSON")]),
|
||||
"boosapi.text_to_video" | "boosapi.image_to_video" => {
|
||||
outputs.extend([json!("STRING"), json!("VIDEO"), json!("JSON")])
|
||||
}
|
||||
"boosapi.video_status" => outputs.extend([json!("VIDEO"), json!("JSON")]),
|
||||
"boosapi.comfyui_workflow" => {
|
||||
outputs.extend([json!("JSON"), json!("STRING"), json!("JSON")])
|
||||
}
|
||||
"comfyui.submit_workflow" => {
|
||||
outputs.extend([json!("STRING"), json!("JSON"), json!("JSON")])
|
||||
}
|
||||
"CheckpointLoaderSimple" => outputs.extend([json!("MODEL"), json!("CLIP"), json!("VAE")]),
|
||||
"CLIPTextEncode" => outputs.push(json!("CONDITIONING")),
|
||||
"EmptyLatentImage" | "KSampler" => outputs.push(json!("LATENT")),
|
||||
"VAEDecode" | "SaveImage" | "PreviewImage" => outputs.push(json!("IMAGE")),
|
||||
"output.preview_image" => outputs.push(json!("IMAGE")),
|
||||
"output.preview_video" => outputs.push(json!("VIDEO")),
|
||||
"output.save_file" => outputs.push(json!("FILE")),
|
||||
"util.text_join" => outputs.push(json!("STRING")),
|
||||
"util.json_extract" => outputs.extend([json!("JSON"), json!("STRING")]),
|
||||
_ => outputs.push(json!("STRING")),
|
||||
}
|
||||
outputs
|
||||
}
|
||||
|
||||
pub(super) fn output_is_list_specs(def: &NodeTypeDefinition) -> Vec<bool> {
|
||||
match def.node_type {
|
||||
"boosapi.text_to_image" => vec![false, true, false],
|
||||
_ => vec![false; known_output_ports(def.node_type).len()],
|
||||
}
|
||||
}
|
||||
+36
-38
@@ -218,25 +218,25 @@ async fn list_assets(
|
||||
LIMIT ? OFFSET ?"
|
||||
);
|
||||
let items = sqlx::query(&list_sql)
|
||||
.bind(is_admin)
|
||||
.bind(scoped_user_id)
|
||||
.bind(scoped_user_id)
|
||||
.bind(is_admin)
|
||||
.bind(scoped_user_id)
|
||||
.bind(&source)
|
||||
.bind(&source)
|
||||
.bind(&kind)
|
||||
.bind(&kind)
|
||||
.bind(&q)
|
||||
.bind(&q)
|
||||
.bind(&q)
|
||||
.bind(&q)
|
||||
.bind(&q)
|
||||
.bind(page_size)
|
||||
.bind(offset)
|
||||
.map(row_to_json)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
.bind(is_admin)
|
||||
.bind(scoped_user_id)
|
||||
.bind(scoped_user_id)
|
||||
.bind(is_admin)
|
||||
.bind(scoped_user_id)
|
||||
.bind(&source)
|
||||
.bind(&source)
|
||||
.bind(&kind)
|
||||
.bind(&kind)
|
||||
.bind(&q)
|
||||
.bind(&q)
|
||||
.bind(&q)
|
||||
.bind(&q)
|
||||
.bind(&q)
|
||||
.bind(page_size)
|
||||
.bind(offset)
|
||||
.map(row_to_json)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
let count_sql = format!(
|
||||
"SELECT COUNT(*)
|
||||
@@ -255,22 +255,22 @@ async fn list_assets(
|
||||
)"
|
||||
);
|
||||
let total: i64 = sqlx::query_scalar(&count_sql)
|
||||
.bind(is_admin)
|
||||
.bind(scoped_user_id)
|
||||
.bind(scoped_user_id)
|
||||
.bind(is_admin)
|
||||
.bind(scoped_user_id)
|
||||
.bind(&source)
|
||||
.bind(&source)
|
||||
.bind(&kind)
|
||||
.bind(&kind)
|
||||
.bind(&q)
|
||||
.bind(&q)
|
||||
.bind(&q)
|
||||
.bind(&q)
|
||||
.bind(&q)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
.bind(is_admin)
|
||||
.bind(scoped_user_id)
|
||||
.bind(scoped_user_id)
|
||||
.bind(is_admin)
|
||||
.bind(scoped_user_id)
|
||||
.bind(&source)
|
||||
.bind(&source)
|
||||
.bind(&kind)
|
||||
.bind(&kind)
|
||||
.bind(&q)
|
||||
.bind(&q)
|
||||
.bind(&q)
|
||||
.bind(&q)
|
||||
.bind(&q)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
@@ -398,9 +398,7 @@ async fn delete_assets_batch(
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
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");
|
||||
}
|
||||
deleted_ids.push(public_id);
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
//! Comfy Cloud 工作流执行适配器 service 层。
|
||||
pub mod site;
|
||||
pub mod translate;
|
||||
@@ -1,53 +0,0 @@
|
||||
use crate::error::{AppError, AppResult};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// 解析 Comfy Cloud 站点配置,返回 (base_url, api_key)。
|
||||
/// 取 platform='comfy_cloud' 且 status='active' 的站点。
|
||||
pub async fn resolve_comfy_cloud_site(db: &SqlitePool) -> AppResult<(String, String)> {
|
||||
let row: Option<(String, Option<String>)> = sqlx::query_as(
|
||||
"SELECT url, api_key FROM sites
|
||||
WHERE platform = 'comfy_cloud' AND status = 'active'
|
||||
ORDER BY COALESCE(sort_order, 0), id LIMIT 1",
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
let (url, api_key) = row.ok_or_else(|| {
|
||||
AppError::ServiceUnavailable("未配置 Comfy Cloud 站点(站点管理添加 comfy_cloud 平台)".into())
|
||||
})?;
|
||||
let api_key = api_key
|
||||
.map(|k| k.trim().to_string())
|
||||
.filter(|k| !k.is_empty())
|
||||
.ok_or_else(|| AppError::ServiceUnavailable("Comfy Cloud 站点缺少 API Key".into()))?;
|
||||
let base_url = url.trim().trim_end_matches('/').to_string();
|
||||
Ok((base_url, api_key))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
async fn pool() -> SqlitePool {
|
||||
let pool = SqlitePoolOptions::new().max_connections(1).connect("sqlite::memory:").await.unwrap();
|
||||
sqlx::query("CREATE TABLE sites (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, url TEXT NOT NULL, platform TEXT NOT NULL, status TEXT NOT NULL, api_key TEXT, sort_order INTEGER)")
|
||||
.execute(&pool).await.unwrap();
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolves_active_comfy_cloud_site() {
|
||||
let pool = pool().await;
|
||||
sqlx::query("INSERT INTO sites (name,url,platform,status,api_key) VALUES ('cc','https://cloud.comfy.org/','comfy_cloud','active','key-123')")
|
||||
.execute(&pool).await.unwrap();
|
||||
let (url, key) = resolve_comfy_cloud_site(&pool).await.unwrap();
|
||||
assert_eq!(url, "https://cloud.comfy.org");
|
||||
assert_eq!(key, "key-123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn errors_when_not_configured() {
|
||||
let pool = pool().await;
|
||||
let r = resolve_comfy_cloud_site(&pool).await;
|
||||
assert!(matches!(r, Err(AppError::ServiceUnavailable(_))));
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// 把 Comfy Cloud 的 job status(+ 完成时的 jobs detail outputs)翻译成
|
||||
/// ComfyUI 原生 /history/{id} 的形状。
|
||||
///
|
||||
/// - status pending/in_progress → 返回空对象 {}(comfykit 据此继续轮询)
|
||||
/// - status completed → { "{id}": { "status": {completed:true, status_str:"success"}, "outputs": {...} } }
|
||||
/// - status failed/cancelled → { "{id}": { "status": {completed:false, status_str:"error"} } }
|
||||
pub fn comfy_cloud_job_to_history(
|
||||
prompt_id: &str,
|
||||
status: &str,
|
||||
outputs: Option<&Value>,
|
||||
) -> Value {
|
||||
match status {
|
||||
"completed" => json!({
|
||||
prompt_id: {
|
||||
"status": { "completed": true, "status_str": "success" },
|
||||
"outputs": outputs.cloned().unwrap_or_else(|| json!({})),
|
||||
}
|
||||
}),
|
||||
"failed" | "cancelled" => json!({
|
||||
prompt_id: {
|
||||
"status": { "completed": false, "status_str": "error" },
|
||||
"outputs": {},
|
||||
}
|
||||
}),
|
||||
_ => json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn in_progress_returns_empty() {
|
||||
assert_eq!(comfy_cloud_job_to_history("p1", "in_progress", None), json!({}));
|
||||
assert_eq!(comfy_cloud_job_to_history("p1", "pending", None), json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_wraps_outputs() {
|
||||
let outputs = json!({ "9": { "images": [{ "filename": "a.png", "subfolder": "", "type": "output" }] } });
|
||||
let h = comfy_cloud_job_to_history("p1", "completed", Some(&outputs));
|
||||
assert_eq!(h["p1"]["status"]["completed"], true);
|
||||
assert_eq!(h["p1"]["outputs"]["9"]["images"][0]["filename"], "a.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_marks_error() {
|
||||
let h = comfy_cloud_job_to_history("p1", "failed", None);
|
||||
assert_eq!(h["p1"]["status"]["status_str"], "error");
|
||||
assert_eq!(h["p1"]["status"]["completed"], false);
|
||||
}
|
||||
}
|
||||
@@ -13,5 +13,3 @@ pub mod runtime_auth;
|
||||
pub mod runtime_logs;
|
||||
pub mod shared;
|
||||
pub mod workspace;
|
||||
pub mod comfy_cloud;
|
||||
pub mod pixelle_connection;
|
||||
|
||||
@@ -113,7 +113,9 @@ impl NotificationService {
|
||||
let config = self.load_config().await?;
|
||||
let channels = self.enabled_channels(&config);
|
||||
if channels.is_empty() {
|
||||
return Err(AppError::BadRequest("请至少配置并启用一个通知通道".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"请至少配置并启用一个通知通道".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let notif = Notification {
|
||||
@@ -464,9 +466,7 @@ fn setting(map: &HashMap<String, String>, key: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
use crate::error::{AppError, AppResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PixelleConnection {
|
||||
pub runninghub_api_key: Option<String>,
|
||||
pub comfyui_url: Option<String>,
|
||||
pub comfyui_api_key: Option<String>,
|
||||
}
|
||||
|
||||
const KEY_RH: &str = "pixelle.runninghub_api_key";
|
||||
const KEY_URL: &str = "pixelle.comfyui_url";
|
||||
const KEY_CK: &str = "pixelle.comfyui_api_key";
|
||||
|
||||
pub async fn read_connection(db: &SqlitePool) -> AppResult<PixelleConnection> {
|
||||
async fn get(db: &SqlitePool, k: &str) -> AppResult<Option<String>> {
|
||||
Ok(sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
|
||||
.bind(k).fetch_optional(db).await?)
|
||||
}
|
||||
Ok(PixelleConnection {
|
||||
runninghub_api_key: get(db, KEY_RH).await?,
|
||||
comfyui_url: get(db, KEY_URL).await?,
|
||||
comfyui_api_key: get(db, KEY_CK).await?,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn save_connection(db: &SqlitePool, conn: &PixelleConnection) -> AppResult<()> {
|
||||
async fn put(db: &SqlitePool, k: &str, v: &Option<String>) -> AppResult<()> {
|
||||
if let Some(val) = v {
|
||||
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
||||
.bind(k).bind(val).execute(db).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
put(db, KEY_RH, &conn.runninghub_api_key).await?;
|
||||
put(db, KEY_URL, &conn.comfyui_url).await?;
|
||||
put(db, KEY_CK, &conn.comfyui_api_key).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 改 config.yaml 的 comfyui.{runninghub_api_key,comfyui_url,comfyui_api_key} 三键,其余保留。
|
||||
/// 改前备份到 <path>.bak-<ts>。返回备份路径。
|
||||
pub fn sync_to_pixelle(config_path: &str, conn: &PixelleConnection) -> AppResult<String> {
|
||||
let content = std::fs::read_to_string(config_path)
|
||||
.map_err(|e| AppError::ServiceUnavailable(format!("读取 Pixelle config 失败: {e}")))?;
|
||||
let mut doc: serde_yaml::Value = serde_yaml::from_str(&content)
|
||||
.map_err(|e| AppError::ServiceUnavailable(format!("解析 config.yaml 失败: {e}")))?;
|
||||
let comfyui = doc.get_mut("comfyui")
|
||||
.ok_or_else(|| AppError::ServiceUnavailable("config.yaml 缺少 comfyui 段".into()))?;
|
||||
let map = comfyui.as_mapping_mut()
|
||||
.ok_or_else(|| AppError::ServiceUnavailable("comfyui 段不是映射".into()))?;
|
||||
if let Some(v) = &conn.runninghub_api_key {
|
||||
map.insert("runninghub_api_key".into(), serde_yaml::Value::String(v.clone()));
|
||||
}
|
||||
if let Some(v) = &conn.comfyui_url {
|
||||
map.insert("comfyui_url".into(), serde_yaml::Value::String(v.clone()));
|
||||
}
|
||||
if let Some(v) = &conn.comfyui_api_key {
|
||||
map.insert("comfyui_api_key".into(), serde_yaml::Value::String(v.clone()));
|
||||
}
|
||||
let ts = chrono::Utc::now().format("%Y%m%d%H%M%S").to_string();
|
||||
let backup = format!("{config_path}.bak-{ts}");
|
||||
std::fs::copy(config_path, &backup)
|
||||
.map_err(|e| AppError::ServiceUnavailable(format!("备份失败: {e}")))?;
|
||||
let out = serde_yaml::to_string(&doc)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
||||
std::fs::write(config_path, out)
|
||||
.map_err(|e| AppError::ServiceUnavailable(format!("写入 config 失败: {e}")))?;
|
||||
Ok(backup)
|
||||
}
|
||||
|
||||
pub async fn restart_pixelle(compose_dir: &str) -> AppResult<()> {
|
||||
let output = tokio::process::Command::new("docker")
|
||||
.args(["compose", "restart", "api"])
|
||||
.current_dir(compose_dir)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| AppError::ServiceUnavailable(format!("docker compose restart 启动失败: {e}")))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(AppError::ServiceUnavailable(format!(
|
||||
"Pixelle 重启失败: {}", stderr.trim()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sync_updates_three_keys_preserves_rest() {
|
||||
let dir = std::env::temp_dir().join(format!("pxtest_{}", uuid::Uuid::new_v4().simple()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("config.yaml");
|
||||
std::fs::write(&path,
|
||||
"project_name: Pixelle-Video\ncomfyui:\n comfyui_url: https://old\n runninghub_api_key: oldkey\n comfyui_api_key: oldck\n runninghub_concurrent_limit: 1\n").unwrap();
|
||||
let conn = PixelleConnection {
|
||||
runninghub_api_key: Some("newkey".into()),
|
||||
comfyui_url: Some("http://new".into()),
|
||||
comfyui_api_key: Some("newck".into()),
|
||||
};
|
||||
let backup = sync_to_pixelle(path.to_str().unwrap(), &conn).unwrap();
|
||||
assert!(std::path::Path::new(&backup).exists());
|
||||
let after = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(after.contains("newkey"));
|
||||
assert!(after.contains("http://new"));
|
||||
assert!(after.contains("newck"));
|
||||
assert!(after.contains("runninghub_concurrent_limit"));
|
||||
assert!(after.contains("project_name"));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use parking_lot::RwLock;
|
||||
use sqlx::SqlitePool;
|
||||
use parking_lot::RwLock;
|
||||
|
||||
pub const PROXY_TOKEN_SETTING_KEY: &str = "runtime.auth.proxy_token";
|
||||
|
||||
@@ -20,10 +20,11 @@ impl RuntimeAuthSettings {
|
||||
}
|
||||
|
||||
pub async fn load(pool: &SqlitePool, default_proxy_token: String) -> anyhow::Result<Self> {
|
||||
let stored: Option<String> = sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
|
||||
.bind(PROXY_TOKEN_SETTING_KEY)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
let stored: Option<String> =
|
||||
sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
|
||||
.bind(PROXY_TOKEN_SETTING_KEY)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
let proxy_token = stored
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
|
||||
@@ -188,10 +188,7 @@ async fn insert_asset(
|
||||
|
||||
fn should_project_workspace_asset(status: &str, metadata: &Value) -> bool {
|
||||
status == "completed"
|
||||
&& (metadata
|
||||
.pointer("/workspaceAsset/role")
|
||||
.and_then(Value::as_str)
|
||||
== Some("output")
|
||||
&& (metadata.pointer("/workspaceAsset/role").and_then(Value::as_str) == Some("output")
|
||||
|| metadata.pointer("/workflowUi/type").and_then(Value::as_str) == Some("output")
|
||||
|| metadata.get("responseItem").is_some()
|
||||
|| metadata.get("moneyPrinter").is_some()
|
||||
@@ -401,9 +398,7 @@ pub async fn project_deleted_asset(state: &AppState, public_id: &str) -> Result<
|
||||
&source,
|
||||
&kind,
|
||||
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,
|
||||
)
|
||||
.await
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
export interface SiteInitializationPreset {
|
||||
id: string;
|
||||
label: string;
|
||||
platform: 'openai' | 'claude' | 'comfy_cloud';
|
||||
platform: 'openai' | 'claude';
|
||||
defaultUrl: string;
|
||||
initialSegment: 'session' | 'apikey';
|
||||
description?: string;
|
||||
@@ -12,5 +12,5 @@ export declare function getSiteInitializationPreset(id?: string | null): SiteIni
|
||||
export declare function listSiteInitializationPresets(): SiteInitializationPreset[];
|
||||
export declare function detectSiteInitializationPreset(url: string, knownPlatform?: string): {
|
||||
id: string;
|
||||
platform: 'openai' | 'claude' | 'comfy_cloud';
|
||||
platform: 'openai' | 'claude';
|
||||
} | null;
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
const presets = [
|
||||
{
|
||||
id: 'comfy-cloud',
|
||||
label: 'Comfy Cloud (工作流执行)',
|
||||
platform: 'comfy_cloud',
|
||||
defaultUrl: 'https://cloud.comfy.org',
|
||||
initialSegment: 'apikey',
|
||||
description: 'Comfy Cloud 工作流执行 API;API Key 填 X-API-Key,经 BoosAPI /comfy-cloud 适配端点供 Pixelle 等调用。',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: [],
|
||||
},
|
||||
{
|
||||
id: 'codingplan-openai',
|
||||
label: '阿里云 Coding Plan (OpenAI)',
|
||||
@@ -155,7 +145,6 @@ const urlMatchers = [
|
||||
{ urlPrefix: 'https://api-inference.modelscope.cn', id: 'modelscope-claude', platform: 'claude' },
|
||||
{ urlPrefix: 'https://ark.cn-beijing.volces.com/api/coding/v3', id: 'doubao-coding-openai', platform: 'openai' },
|
||||
{ urlPrefix: 'https://ark.cn-beijing.volces.com/api/v3', id: 'volcengine-ark-openai', platform: 'openai', stripTrailingSlash: true },
|
||||
{ urlPrefix: 'https://cloud.comfy.org', id: 'comfy-cloud', platform: 'comfy_cloud', stripTrailingSlash: true },
|
||||
];
|
||||
// Canonical root → preset mapping for reselection when platform is known
|
||||
const rootReselect = [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export interface SiteInitializationPreset {
|
||||
id: string;
|
||||
label: string;
|
||||
platform: 'openai' | 'claude' | 'comfy_cloud';
|
||||
platform: 'openai' | 'claude';
|
||||
defaultUrl: string;
|
||||
initialSegment: 'session' | 'apikey';
|
||||
description?: string;
|
||||
@@ -10,16 +10,6 @@ export interface SiteInitializationPreset {
|
||||
}
|
||||
|
||||
const presets: SiteInitializationPreset[] = [
|
||||
{
|
||||
id: 'comfy-cloud',
|
||||
label: 'Comfy Cloud (工作流执行)',
|
||||
platform: 'comfy_cloud',
|
||||
defaultUrl: 'https://cloud.comfy.org',
|
||||
initialSegment: 'apikey',
|
||||
description: 'Comfy Cloud 工作流执行 API;API Key 填 X-API-Key,经 BoosAPI /comfy-cloud 适配端点供 Pixelle 等调用。',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: [],
|
||||
},
|
||||
{
|
||||
id: 'codingplan-openai',
|
||||
label: '阿里云 Coding Plan (OpenAI)',
|
||||
@@ -156,7 +146,7 @@ const presets: SiteInitializationPreset[] = [
|
||||
const urlMatchers: Array<{
|
||||
urlPrefix: string;
|
||||
id: string;
|
||||
platform: 'openai' | 'claude' | 'comfy_cloud';
|
||||
platform: 'openai' | 'claude';
|
||||
stripTrailingSlash?: boolean;
|
||||
}> = [
|
||||
{ urlPrefix: 'https://coding.dashscope.aliyuncs.com/v1', id: 'codingplan-openai', platform: 'openai' },
|
||||
@@ -172,7 +162,6 @@ const urlMatchers: Array<{
|
||||
{ urlPrefix: 'https://api-inference.modelscope.cn', id: 'modelscope-claude', platform: 'claude' },
|
||||
{ urlPrefix: 'https://ark.cn-beijing.volces.com/api/coding/v3', id: 'doubao-coding-openai', platform: 'openai' },
|
||||
{ urlPrefix: 'https://ark.cn-beijing.volces.com/api/v3', id: 'volcengine-ark-openai', platform: 'openai', stripTrailingSlash: true },
|
||||
{ urlPrefix: 'https://cloud.comfy.org', id: 'comfy-cloud', platform: 'comfy_cloud', stripTrailingSlash: true },
|
||||
];
|
||||
|
||||
// Canonical root → preset mapping for reselection when platform is known
|
||||
@@ -194,7 +183,7 @@ export function listSiteInitializationPresets(): SiteInitializationPreset[] {
|
||||
export function detectSiteInitializationPreset(
|
||||
url: string,
|
||||
knownPlatform?: string,
|
||||
): { id: string; platform: 'openai' | 'claude' | 'comfy_cloud' } | null {
|
||||
): { id: string; platform: 'openai' | 'claude' } | null {
|
||||
// Try exact URL prefix matching
|
||||
const normalizedUrl = url.replace(/\/+$/, '');
|
||||
for (const matcher of urlMatchers) {
|
||||
|
||||
@@ -24,11 +24,7 @@ fn template_proxy_url(config: &AppConfig) -> Option<String> {
|
||||
.as_deref()
|
||||
.and_then(non_empty)
|
||||
.map(str::to_string)
|
||||
.or_else(|| {
|
||||
std::env::var("BOOSAPI_TEMPLATE_PROXY_URL")
|
||||
.ok()
|
||||
.and_then(|value| non_empty_owned(value))
|
||||
})
|
||||
.or_else(|| std::env::var("BOOSAPI_TEMPLATE_PROXY_URL").ok().and_then(|value| non_empty_owned(value)))
|
||||
}
|
||||
|
||||
fn non_empty(value: &str) -> Option<&str> {
|
||||
|
||||
@@ -250,9 +250,10 @@ describe('Login surface', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('promotes admin-role API Key login into both audiences and navigates to /dashboard', async () => {
|
||||
it('rejects admin API Key login on the user login page', async () => {
|
||||
const storage = createMemoryStorage();
|
||||
vi.stubGlobal('localStorage', storage);
|
||||
commitSession('user', 'stale-user-jwt', undefined, storage);
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
@@ -297,16 +298,17 @@ describe('Login surface', () => {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ apiKey: 'sk-admin-user-key' }),
|
||||
}));
|
||||
expect(getSessionToken('admin', storage)).toBe('jwt-admin-from-key');
|
||||
expect(getSessionToken('user', storage)).toBe('jwt-admin-from-key');
|
||||
expect(onLogin).toHaveBeenCalledWith('admin');
|
||||
expect(paths.at(-1)).toBe('/dashboard');
|
||||
expect(getSessionToken('admin', storage)).toBeNull();
|
||||
expect(getSessionToken('user', storage)).toBe('stale-user-jwt');
|
||||
expect(onLogin).not.toHaveBeenCalled();
|
||||
expect(collectText(root.root)).toContain('管理员请使用管理员 API Token 登录。');
|
||||
expect(paths.at(-1)).toBe('/user/login');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('promotes admin role from JWT fallback when API Key response omits user.role', async () => {
|
||||
it('rejects API Key login when JWT role falls back to admin', async () => {
|
||||
const storage = createMemoryStorage();
|
||||
vi.stubGlobal('localStorage', storage);
|
||||
const adminJwt = unsignedJwt({ sub: '5', role: 'admin', exp: 9_999_999_999 });
|
||||
@@ -353,10 +355,11 @@ describe('Login surface', () => {
|
||||
});
|
||||
});
|
||||
|
||||
expect(getSessionToken('admin', storage)).toBe(adminJwt);
|
||||
expect(getSessionToken('user', storage)).toBe(adminJwt);
|
||||
expect(onLogin).toHaveBeenCalledWith('admin');
|
||||
expect(paths.at(-1)).toBe('/dashboard');
|
||||
expect(getSessionToken('admin', storage)).toBeNull();
|
||||
expect(getSessionToken('user', storage)).toBeNull();
|
||||
expect(onLogin).not.toHaveBeenCalled();
|
||||
expect(collectText(root.root)).toContain('管理员请使用管理员 API Token 登录。');
|
||||
expect(paths.at(-1)).toBe('/user/login');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
|
||||
@@ -200,19 +200,4 @@ describe('App sidebar config', () => {
|
||||
expect(appSource).toContain("{ to: '/user/recharge', label: '账户充值'");
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
+4
-9
@@ -51,8 +51,6 @@ const OpsMonitor = lazy(() => import('./pages/OpsMonitor.js'));
|
||||
const IpManagement = lazy(() => import('./pages/IpManagement.js'));
|
||||
const OAuthManagement = lazy(() => import('./pages/OAuthManagement.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 UserDashboard = lazy(() => import('./pages/UserDashboard.js'));
|
||||
const UsageRecords = lazy(() => import('./pages/UsageRecords.js'));
|
||||
@@ -567,7 +565,6 @@ 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>,
|
||||
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>,
|
||||
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>,
|
||||
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>,
|
||||
@@ -602,8 +599,6 @@ export const sidebarGroups: SidebarGroup[] = [
|
||||
items: [
|
||||
{ to: '/users', label: '用户管理', icon: navIcons.users },
|
||||
{ to: '/site-announcements', label: '用户公告', icon: navIcons.announcement },
|
||||
{ to: '/payment', label: '支付管理', icon: navIcons.payment },
|
||||
{ to: '/user-balances', label: '用户余额', icon: navIcons.payment },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -623,7 +618,8 @@ export const sidebarGroups: SidebarGroup[] = [
|
||||
label: '创作应用',
|
||||
items: [
|
||||
{ to: '/picture-agent', label: '生图助手', icon: navIcons.image },
|
||||
{ to: '/digital-human', label: '短视频工厂/数字人', icon: navIcons.digitalHuman },
|
||||
{ to: '/money-printer', label: '短视频工厂', icon: navIcons.video },
|
||||
{ to: '/digital-human', label: '数字人', icon: navIcons.digitalHuman },
|
||||
{ to: '/workflows', label: '工作流', icon: navIcons.workflow },
|
||||
{ to: '/comfyui-agent', label: '视频助手', icon: navIcons.assistant },
|
||||
{ to: '/workspace', label: '工作空间', icon: navIcons.workspace },
|
||||
@@ -655,7 +651,8 @@ export const userSidebarGroups = [
|
||||
label: '创作应用',
|
||||
items: [
|
||||
{ to: '/user/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
|
||||
{ to: '/user/digital-human', label: '短视频工厂/数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> },
|
||||
{ to: '/user/money-printer', label: '短视频工厂', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 4h10a2 2 0 012 2v12a2 2 0 01-2 2H7a2 2 0 01-2-2V6a2 2 0 012-2z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M10 9l5 3-5 3V9zM8 20l2-4m6 4l-2-4" /></svg> },
|
||||
{ to: '/user/digital-human', label: '数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> },
|
||||
{ to: '/user/workflows', label: '工作流', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg> },
|
||||
{ to: '/user/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
|
||||
{ to: '/user/workspace', label: '工作空间', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 7a2 2 0 012-2h4l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H6a2 2 0 01-2-2V7z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 13h8M8 16h5" /></svg> },
|
||||
@@ -1670,8 +1667,6 @@ function AppShell() {
|
||||
<Route path="/workspace" element={<Workspace />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="/users" element={<UserManagement />} />
|
||||
<Route path="/payment" element={<PaymentManagement />} />
|
||||
<Route path="/user-balances" element={<UserBalances />} />
|
||||
|
||||
{/* User specific pages */}
|
||||
<Route
|
||||
|
||||
+5
-87
@@ -52,7 +52,7 @@ function sanitizeErrorText(status: number, text: string): string {
|
||||
|| trimmed.includes("</html>")
|
||||
|| trimmed.includes("<!--[if");
|
||||
if (looksLikeHtml) {
|
||||
return `HTTP ${status}: 上游返回 HTML 错误页,请检查对应上游服务配置`;
|
||||
return `HTTP ${status}: 上游返回 HTML 错误页,请检查支付/上游 Token 配置`;
|
||||
}
|
||||
return `HTTP ${status}: ${trimmed.slice(0, 120)}`;
|
||||
}
|
||||
@@ -1476,7 +1476,6 @@ export type DigitalHumanJob = {
|
||||
progress: number;
|
||||
progressTotal: number;
|
||||
result?: any;
|
||||
reusePayload?: any;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
startedAt?: string | null;
|
||||
@@ -2018,45 +2017,13 @@ export const api = {
|
||||
"/api/digital-human/pixelle/bgm",
|
||||
{ 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) =>
|
||||
request<{ template: string; media_width?: number; media_height?: number; params?: any }>(
|
||||
`/api/digital-human/pixelle/template-params?template=${encodeURIComponent(template)}`,
|
||||
{ preserveAuthErrors: true },
|
||||
),
|
||||
previewImage: (data: {
|
||||
prompt: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
workflow?: string;
|
||||
workflow_key?: string;
|
||||
media_workflow?: string;
|
||||
backend_provider?: string;
|
||||
}) =>
|
||||
request<any>(
|
||||
previewImage: (data: { prompt: string; width?: number; height?: number; workflow?: string }) =>
|
||||
request<{ image_path: string }>(
|
||||
"/api/digital-human/pixelle/preview-image",
|
||||
{
|
||||
method: "POST",
|
||||
@@ -2066,7 +2033,7 @@ export const api = {
|
||||
},
|
||||
),
|
||||
previewTts: (data: { text: string; workflow?: string; ref_audio?: string }) =>
|
||||
request<any>(
|
||||
request<{ audio_path: string; duration?: number }>(
|
||||
"/api/digital-human/pixelle/preview-tts",
|
||||
{
|
||||
method: "POST",
|
||||
@@ -2102,22 +2069,7 @@ export const api = {
|
||||
{ preserveAuthErrors: true },
|
||||
),
|
||||
fileUrl: (path: string) => {
|
||||
if (/^(blob:|data:)/i.test(path) || path.startsWith("/api/")) return path;
|
||||
let trimmed = String(path || "").trim();
|
||||
if (/^https?:/i.test(trimmed)) {
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if ((url.hostname === "127.0.0.1" || url.hostname === "localhost") && url.pathname.startsWith("/api/files/")) {
|
||||
trimmed = url.pathname.replace(/^\/api\/files\/?/, "");
|
||||
} else {
|
||||
return trimmed;
|
||||
}
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
trimmed = trimmed.replace(/^\/+/, "");
|
||||
trimmed = trimmed.replace(/^api\/files\/?/, "");
|
||||
const trimmed = String(path || "").replace(/^\/+/, "");
|
||||
const segments = trimmed.split("/").map((segment) => encodeURIComponent(segment));
|
||||
return `/api/digital-human/pixelle/files/${segments.join("/")}`;
|
||||
},
|
||||
@@ -2906,8 +2858,6 @@ export const api = {
|
||||
request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}`),
|
||||
cancelDigitalHumanJob: (id: string) =>
|
||||
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: () =>
|
||||
request<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }),
|
||||
getMoneyPrinterStatus: () =>
|
||||
@@ -2926,13 +2876,6 @@ export const api = {
|
||||
request<MoneyPrinterJobResponse>(`/api/money-printer/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
|
||||
clearMoneyPrinterJobs: () =>
|
||||
request<{ success: boolean; cleared: number }>("/api/money-printer/jobs/clear", { method: "POST" }),
|
||||
getPixelleConnection: () =>
|
||||
request<{ success: boolean; connection: { comfyuiUrl?: string | null; runninghubApiKey?: string | null; comfyuiApiKey?: string | null } }>("/api/admin/pixelle/connection"),
|
||||
savePixelleConnection: (data: { runninghubApiKey?: string; comfyuiUrl?: string; comfyuiApiKey?: string }) =>
|
||||
request<{ success: boolean; backup?: string; restarted?: boolean; warning?: string }>("/api/admin/pixelle/connection", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
};
|
||||
export const userApi = {
|
||||
register: (data: {
|
||||
@@ -3034,29 +2977,6 @@ export const userApi = {
|
||||
userRequest<{ success: boolean; run: WorkflowRunSummary }>(`/api/user/workflow-runs/${encodeURIComponent(publicId)}`),
|
||||
getDigitalHumanStatus: () =>
|
||||
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) =>
|
||||
userRequest<DigitalHumanJobResponse>("/api/digital-human/jobs", {
|
||||
method: "POST",
|
||||
@@ -3069,8 +2989,6 @@ export const userApi = {
|
||||
userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}`),
|
||||
cancelDigitalHumanJob: (id: string) =>
|
||||
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: () =>
|
||||
userRequest<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }),
|
||||
getMoneyPrinterStatus: () =>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
USER_RECHARGE_CONFIG,
|
||||
formatRechargePoints,
|
||||
isUserRechargeOptionPayable,
|
||||
type UserRechargeOption,
|
||||
type UserRechargeCheckoutOption,
|
||||
@@ -48,10 +47,8 @@ export default function UserRechargeOptions({
|
||||
title={option.unavailableReason || '该额度支付链接未配置'}
|
||||
disabled
|
||||
>
|
||||
{option.recommended ? <small>推荐</small> : null}
|
||||
<strong>{option.label}</strong>
|
||||
<span>{option.title}</span>
|
||||
{option.amountUsd ? <b>{formatRechargePoints(option.amountUsd)}</b> : null}
|
||||
<em>待配置</em>
|
||||
</button>
|
||||
);
|
||||
@@ -65,10 +62,8 @@ export default function UserRechargeOptions({
|
||||
className={className}
|
||||
aria-label={`前往系统内充值页确认 ${option.label}`}
|
||||
>
|
||||
{option.recommended ? <small>推荐</small> : null}
|
||||
<strong>{option.label}</strong>
|
||||
<span>{option.title}</span>
|
||||
{option.amountUsd ? <b>{formatRechargePoints(option.amountUsd)}</b> : null}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -84,10 +79,8 @@ export default function UserRechargeOptions({
|
||||
disabled={pending}
|
||||
data-checkout-option-id={option.id}
|
||||
>
|
||||
{option.recommended ? <small>推荐</small> : null}
|
||||
<strong>{option.label}</strong>
|
||||
<span>{option.title}</span>
|
||||
{option.amountUsd ? <b>{formatRechargePoints(option.amountUsd)}</b> : null}
|
||||
{pending ? <em>创建记录中</em> : null}
|
||||
</button>
|
||||
);
|
||||
|
||||
+48
-310
@@ -2439,7 +2439,7 @@ body {
|
||||
|
||||
.program-logs-table {
|
||||
table-layout: fixed;
|
||||
min-width: 1420px;
|
||||
min-width: 1120px;
|
||||
}
|
||||
|
||||
.program-logs-filter-bar {
|
||||
@@ -2474,21 +2474,8 @@ body {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.program-logs-table .program-logs-account-cell,
|
||||
.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) {
|
||||
.program-logs-table td:nth-child(6),
|
||||
.program-logs-table td:nth-child(7) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -4431,11 +4418,6 @@ textarea {
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.picture-agent-tabs + [role="tabpanel"] {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.picture-agent-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -4500,24 +4482,22 @@ textarea {
|
||||
.picture-agent-grid-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.picture-agent-size-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
grid-template-columns: 1fr 1fr minmax(92px, 0.8fr);
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.picture-agent-size-preview {
|
||||
grid-column: 1 / -1;
|
||||
min-height: 46px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
min-height: 38px;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 2px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--color-info) 7%, var(--color-bg-card));
|
||||
@@ -4531,21 +4511,21 @@ textarea {
|
||||
|
||||
.picture-agent-size-preview strong {
|
||||
color: var(--color-info);
|
||||
font-size: 14px;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.picture-agent-presets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.picture-agent-presets button {
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
@@ -4610,9 +4590,9 @@ textarea {
|
||||
}
|
||||
|
||||
.picture-agent-submit {
|
||||
min-height: 44px;
|
||||
min-height: 40px;
|
||||
width: 100%;
|
||||
margin-top: 2px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.picture-agent-submit-hint {
|
||||
@@ -5793,11 +5773,6 @@ textarea {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.picture-agent-presets {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.picture-agent-size-row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
@@ -12984,63 +12959,6 @@ textarea {
|
||||
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 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
@@ -13151,35 +13069,6 @@ textarea {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -13529,8 +13418,7 @@ textarea {
|
||||
}
|
||||
|
||||
.money-printer-summary,
|
||||
.digital-human-summary,
|
||||
.picture-agent-summary {
|
||||
.digital-human-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
@@ -13538,8 +13426,7 @@ textarea {
|
||||
}
|
||||
|
||||
.money-printer-summary-card,
|
||||
.digital-human-summary-card,
|
||||
.picture-agent-summary-card {
|
||||
.digital-human-summary-card {
|
||||
min-height: 92px;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
@@ -13556,17 +13443,14 @@ textarea {
|
||||
.money-printer-summary-card span,
|
||||
.money-printer-summary-card em,
|
||||
.digital-human-summary-card span,
|
||||
.digital-human-summary-card em,
|
||||
.picture-agent-summary-card span,
|
||||
.picture-agent-summary-card em {
|
||||
.digital-human-summary-card em {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.money-printer-summary-card strong,
|
||||
.digital-human-summary-card strong,
|
||||
.picture-agent-summary-card strong {
|
||||
.digital-human-summary-card strong {
|
||||
overflow: hidden;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 18px;
|
||||
@@ -13576,8 +13460,7 @@ textarea {
|
||||
}
|
||||
|
||||
.money-printer-summary-card em,
|
||||
.digital-human-summary-card em,
|
||||
.picture-agent-summary-card em {
|
||||
.digital-human-summary-card em {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@@ -14054,7 +13937,6 @@ textarea {
|
||||
@media (max-width: 1180px) {
|
||||
.money-printer-summary,
|
||||
.digital-human-summary,
|
||||
.picture-agent-summary,
|
||||
.money-printer-presets {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -14067,7 +13949,6 @@ textarea {
|
||||
@media (max-width: 720px) {
|
||||
.money-printer-summary,
|
||||
.digital-human-summary,
|
||||
.picture-agent-summary,
|
||||
.money-printer-presets {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -14888,38 +14769,22 @@ textarea {
|
||||
}
|
||||
|
||||
.user-dashboard-recharge-panel {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1fr) minmax(440px, 0.78fr);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
overflow: hidden;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
padding: 18px 20px;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 24%, var(--console-line));
|
||||
border-radius: 18px;
|
||||
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)));
|
||||
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 {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -14931,9 +14796,9 @@ textarea {
|
||||
|
||||
.user-dashboard-recharge-panel strong {
|
||||
color: var(--color-text-primary);
|
||||
font-size: clamp(18px, 2.1vw, 24px);
|
||||
font-weight: 860;
|
||||
line-height: 1.18;
|
||||
font-size: 16px;
|
||||
font-weight: 820;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.user-dashboard-recharge-panel p {
|
||||
@@ -15032,12 +14897,10 @@ textarea {
|
||||
|
||||
.user-recharge-options {
|
||||
display: grid;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 0 1 560px;
|
||||
grid-template-columns: repeat(4, minmax(98px, 1fr));
|
||||
gap: 10px;
|
||||
min-width: min(560px, 100%);
|
||||
flex: 0 1 520px;
|
||||
grid-template-columns: repeat(4, minmax(92px, 1fr));
|
||||
gap: 8px;
|
||||
min-width: min(520px, 100%);
|
||||
}
|
||||
|
||||
.user-recharge-options.is-compact {
|
||||
@@ -15047,37 +14910,18 @@ textarea {
|
||||
}
|
||||
|
||||
.user-recharge-option {
|
||||
position: relative;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 4px;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
min-height: 76px;
|
||||
overflow: hidden;
|
||||
padding: 12px 14px;
|
||||
min-height: 64px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 18%, var(--console-line));
|
||||
border-radius: 16px;
|
||||
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)));
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--color-bg-card) 92%, var(--color-primary));
|
||||
color: var(--color-text-primary);
|
||||
text-decoration: none;
|
||||
box-shadow:
|
||||
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;
|
||||
box-shadow: 0 10px 24px color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
button.user-recharge-option {
|
||||
@@ -15090,18 +14934,9 @@ button.user-recharge-option {
|
||||
.user-recharge-option:focus-visible {
|
||||
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));
|
||||
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);
|
||||
}
|
||||
|
||||
.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:hover,
|
||||
.user-recharge-option.is-disabled:focus-visible {
|
||||
@@ -15113,35 +14948,14 @@ button.user-recharge-option {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.user-recharge-option.is-disabled::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-recharge-option.is-recommended {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 64%, var(--console-line));
|
||||
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;
|
||||
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)));
|
||||
}
|
||||
|
||||
.user-recharge-option strong {
|
||||
overflow: hidden;
|
||||
font-size: 20px;
|
||||
font-size: 17px;
|
||||
font-weight: 860;
|
||||
line-height: 1.15;
|
||||
text-overflow: ellipsis;
|
||||
@@ -15151,19 +14965,9 @@ button.user-recharge-option {
|
||||
.user-recharge-option span {
|
||||
overflow: hidden;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 680;
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-recharge-option b {
|
||||
overflow: hidden;
|
||||
color: var(--color-primary);
|
||||
font-size: 11px;
|
||||
font-weight: 820;
|
||||
line-height: 1.25;
|
||||
font-weight: 650;
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -15279,68 +15083,6 @@ button.user-recharge-option {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@@ -15832,7 +15574,7 @@ button.user-recharge-option {
|
||||
|
||||
.user-dashboard-recharge-panel {
|
||||
align-items: stretch;
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.user-dashboard-recharge-panel .btn,
|
||||
@@ -15861,10 +15603,6 @@ button.user-recharge-option {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.user-recharge-custom {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.user-recharge-info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -203,36 +203,6 @@ export default function Accounts() {
|
||||
const lastRebindTargetRef = useRef<any | null>(null);
|
||||
const modelModalRequestSeqRef = useRef(0);
|
||||
const toast = useToast();
|
||||
const [pxConn, setPxConn] = useState({ comfyuiUrl: "", runninghubApiKey: "", comfyuiApiKey: "" });
|
||||
const [pxSaving, setPxSaving] = useState(false);
|
||||
useEffect(() => {
|
||||
api
|
||||
.getPixelleConnection()
|
||||
.then((r) =>
|
||||
setPxConn({
|
||||
comfyuiUrl: r.connection?.comfyuiUrl || "",
|
||||
runninghubApiKey: r.connection?.runninghubApiKey || "",
|
||||
comfyuiApiKey: r.connection?.comfyuiApiKey || "",
|
||||
}),
|
||||
)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
const savePxConn = async () => {
|
||||
setPxSaving(true);
|
||||
try {
|
||||
const res = await api.savePixelleConnection({
|
||||
runninghubApiKey: pxConn.runninghubApiKey || undefined,
|
||||
comfyuiUrl: pxConn.comfyuiUrl || undefined,
|
||||
comfyuiApiKey: pxConn.comfyuiApiKey || undefined,
|
||||
});
|
||||
if (res.warning) toast.error(res.warning);
|
||||
else toast.success("已保存并重启 Pixelle,约数秒生效");
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || "保存失败");
|
||||
} finally {
|
||||
setPxSaving(false);
|
||||
}
|
||||
};
|
||||
if (rebindTarget) lastRebindTargetRef.current = rebindTarget;
|
||||
const activeRebindTarget = rebindTarget || lastRebindTargetRef.current;
|
||||
const isRebindSub2Api =
|
||||
@@ -3707,40 +3677,6 @@ export default function Accounts() {
|
||||
}
|
||||
onAddManualModels={handleAddManualModels}
|
||||
/>
|
||||
|
||||
<div className="card" style={{ marginTop: 16, padding: 16 }}>
|
||||
<h3>Pixelle 连接</h3>
|
||||
<label style={{ display: "block", marginBottom: 8 }}>
|
||||
<span>ComfyUI URL</span>
|
||||
<input
|
||||
value={pxConn.comfyuiUrl}
|
||||
onChange={(e) => setPxConn((s) => ({ ...s, comfyuiUrl: e.target.value }))}
|
||||
placeholder="http://172.17.0.1:4000/comfy-cloud"
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: "block", marginBottom: 8 }}>
|
||||
<span>RunningHub API Key</span>
|
||||
<input
|
||||
value={pxConn.runninghubApiKey}
|
||||
onChange={(e) => setPxConn((s) => ({ ...s, runninghubApiKey: e.target.value }))}
|
||||
placeholder="留空或 •••• 表示不改"
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: "block", marginBottom: 8 }}>
|
||||
<span>Comfy Cloud API Key</span>
|
||||
<input
|
||||
value={pxConn.comfyuiApiKey}
|
||||
onChange={(e) => setPxConn((s) => ({ ...s, comfyuiApiKey: e.target.value }))}
|
||||
placeholder="留空或 •••• 表示不改"
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" className="btn btn-primary" disabled={pxSaving} onClick={savePxConn}>
|
||||
{pxSaving ? "保存中..." : "保存并重启 Pixelle"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -576,16 +576,6 @@ function angleLabel(angle?: string) {
|
||||
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) {
|
||||
if (!line) return current;
|
||||
return current ? `${current}\n${line}` : line;
|
||||
@@ -1037,11 +1027,11 @@ export default function ComfyUIAgent() {
|
||||
next.images.push({
|
||||
url: String(event.url || ''),
|
||||
character: event.character,
|
||||
angle: event.angle || event.view,
|
||||
angle: event.angle,
|
||||
});
|
||||
next.content = appendLine(
|
||||
next.content,
|
||||
`已生成 ${event.character || '角色'} ${angleLabel(event.angle || event.view)}。`,
|
||||
`已生成 ${event.character || '角色'} ${angleLabel(event.angle)}。`,
|
||||
);
|
||||
} else if (event.type === 'scene') {
|
||||
next.scenes.push({
|
||||
@@ -1065,27 +1055,8 @@ export default function ComfyUIAgent() {
|
||||
`已生成 ${event.character || '角色'} 的语音。`,
|
||||
);
|
||||
} 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 已就绪。');
|
||||
} 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') {
|
||||
next.content = appendLine(next.content, `错误:${event.message || '生成失败'}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,964 @@
|
||||
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));');
|
||||
});
|
||||
});
|
||||
+966
-1114
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 [scriptModel, setScriptModel] = useState('gpt-5.4');
|
||||
const [videoModel, setVideoModel] = useState(DEFAULT_BOOSAPI_VIDEO_MODEL);
|
||||
const [videoBackend, setVideoBackend] = useState<'boosapi' | 'comfy'>('boosapi');
|
||||
const [videoBackend, setVideoBackend] = useState<'boosapi' | 'comfy'>('comfy');
|
||||
const [style, setStyle] = useState(STYLE_PRESETS[0]);
|
||||
const [audienceText, setAudienceText] = useState('AI 工具用户、内容创作者、企业运营');
|
||||
const [duration, setDuration] = useState(30);
|
||||
@@ -493,7 +493,6 @@ export default function MoneyPrinter() {
|
||||
const activeJobFilterTabId = `money-printer-filter-tab-${jobFilter}`;
|
||||
const activeJobFilterPanelId = `money-printer-filter-panel-${jobFilter}`;
|
||||
const isCloudComfy = status?.comfy?.backend === 'cloud' && status?.builtinCloudTemplate?.enabled !== false;
|
||||
const moneyPrinterDisabled = Boolean(status?.disabled);
|
||||
const cloudDurationMin = status?.builtinCloudTemplate?.duration?.min ?? 3;
|
||||
const cloudDurationMax = status?.builtinCloudTemplate?.duration?.max ?? 12;
|
||||
const cloudDefaultDuration = status?.builtinCloudTemplate?.duration?.default ?? DEFAULT_BUILTIN_CLOUD_DURATION;
|
||||
@@ -603,7 +602,6 @@ export default function MoneyPrinter() {
|
||||
const hasSelectedComfyApiModel = Boolean(comfyApiModel.trim());
|
||||
const canSubmit = Boolean(topic.trim())
|
||||
&& !submitting
|
||||
&& !moneyPrinterDisabled
|
||||
&& (videoBackend !== 'comfy' || (comfyHasTemplate && hasSelectedComfyApiModel));
|
||||
const comfyApiModelHint = templateComfyApiModel
|
||||
? (hasConfiguredComfyApiVideoModels
|
||||
@@ -846,11 +844,6 @@ export default function MoneyPrinter() {
|
||||
) : error ? (
|
||||
<div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>
|
||||
) : null}
|
||||
{moneyPrinterDisabled ? (
|
||||
<div className="alert alert-warning" style={{ marginBottom: 12 }}>
|
||||
{status?.disabledReason || '短视频工厂暂时只支持本地模型部署。'}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="money-printer-summary">
|
||||
<div className="money-printer-summary-card">
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,916 +0,0 @@
|
||||
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,56 +985,20 @@ export default function PictureAgent() {
|
||||
|
||||
return (
|
||||
<div className="picture-agent-page">
|
||||
<section className="console-hero">
|
||||
<div className="console-hero-copy">
|
||||
<span className="console-kicker">Picture Studio</span>
|
||||
<h1>生图助手</h1>
|
||||
<p>{modeLabel} · {status?.upstreamLabel || 'BoosAPI 本地图片路由'} · {status?.keyName || '加载密钥中'}</p>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">生图助手</h1>
|
||||
<p className="page-subtitle">
|
||||
{modeLabel} · {status?.upstreamLabel || 'BoosAPI 本地图片路由'} · {status?.keyName || '加载密钥中'}
|
||||
</p>
|
||||
</div>
|
||||
<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">
|
||||
<div className="page-actions">
|
||||
<span className="picture-agent-status-pill">{audience === 'user' ? '普通用户' : '管理员'}</span>
|
||||
<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.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
</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 className="picture-agent-shell">
|
||||
|
||||
+12
-52
@@ -81,7 +81,6 @@ type RuntimeSettings = {
|
||||
logCleanupProgramLogsEnabled: boolean;
|
||||
logCleanupRetentionDays: number;
|
||||
modelAvailabilityProbeEnabled: boolean;
|
||||
registrationDisabled: boolean;
|
||||
codexUpstreamWebsocketEnabled: boolean;
|
||||
responsesCompactFallbackToResponsesEnabled: boolean;
|
||||
disableCrossProtocolFallback: boolean;
|
||||
@@ -364,7 +363,6 @@ export default function Settings() {
|
||||
logCleanupProgramLogsEnabled: false,
|
||||
logCleanupRetentionDays: 30,
|
||||
modelAvailabilityProbeEnabled: false,
|
||||
registrationDisabled: false,
|
||||
codexUpstreamWebsocketEnabled: false,
|
||||
responsesCompactFallbackToResponsesEnabled: false,
|
||||
disableCrossProtocolFallback: false,
|
||||
@@ -388,7 +386,6 @@ export default function Settings() {
|
||||
const [savingToken, setSavingToken] = useState(false);
|
||||
const [savingSystemProxy, setSavingSystemProxy] = useState(false);
|
||||
const [savingModelAvailabilityProbe, setSavingModelAvailabilityProbe] = useState(false);
|
||||
const [savingRegistrationDisabled, setSavingRegistrationDisabled] = useState(false);
|
||||
const [savingProxyTransport, setSavingProxyTransport] = useState(false);
|
||||
const [testingSystemProxy, setTestingSystemProxy] = useState(false);
|
||||
const [systemProxyTestState, setSystemProxyTestState] = useState<SystemProxyTestState>(null);
|
||||
@@ -751,7 +748,7 @@ export default function Settings() {
|
||||
? `${runtime.adminIpAllowlist.length} 条白名单`
|
||||
: '未限制 IP';
|
||||
const modelGovernanceSummary = [
|
||||
blockedBrands.length > 0 ? `${blockedBrands.length} 个推荐屏蔽词` : '',
|
||||
blockedBrands.length > 0 ? `${blockedBrands.length} 个品牌屏蔽` : '',
|
||||
allowedModels.length > 0 ? `${allowedModels.length} 个模型白名单` : '',
|
||||
].filter(Boolean).join(' / ') || '全部模型开放';
|
||||
const settingsSections: SettingsNavItem[] = [
|
||||
@@ -864,7 +861,6 @@ export default function Settings() {
|
||||
? Math.trunc(Number(runtimeInfo.logCleanupRetentionDays))
|
||||
: 30,
|
||||
modelAvailabilityProbeEnabled: !!runtimeInfo.modelAvailabilityProbeEnabled,
|
||||
registrationDisabled: !!runtimeInfo.registrationDisabled,
|
||||
codexUpstreamWebsocketEnabled: !!runtimeInfo.codexUpstreamWebsocketEnabled,
|
||||
responsesCompactFallbackToResponsesEnabled: !!runtimeInfo.responsesCompactFallbackToResponsesEnabled,
|
||||
disableCrossProtocolFallback: !!runtimeInfo.disableCrossProtocolFallback,
|
||||
@@ -1065,24 +1061,6 @@ 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 () => {
|
||||
if (runtime.modelAvailabilityProbeEnabled === savedModelAvailabilityProbeEnabled) {
|
||||
toast.info('批量测活设置未变化');
|
||||
@@ -1299,15 +1277,15 @@ export default function Settings() {
|
||||
const resolved = Array.isArray(res?.globalBlockedBrands) ? res.globalBlockedBrands : blockedBrands;
|
||||
setRuntime((prev) => ({ ...prev, globalBlockedBrands: resolved }));
|
||||
setBlockedBrands(resolved);
|
||||
toast.success('模型品牌推荐屏蔽词已保存');
|
||||
toast.success('品牌屏蔽设置已保存');
|
||||
try {
|
||||
await api.rebuildRoutes(false);
|
||||
toast.success('路由已重建');
|
||||
} catch {
|
||||
toast.error('模型品牌推荐屏蔽词已保存,但路由重建失败,请手动重建');
|
||||
toast.error('品牌屏蔽已保存,但路由重建失败,请手动重建');
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || '保存模型品牌推荐屏蔽词失败');
|
||||
toast.error(err?.message || '保存品牌屏蔽设置失败');
|
||||
} finally {
|
||||
setSavingBrandFilter(false);
|
||||
}
|
||||
@@ -2594,17 +2572,17 @@ export default function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Brand Recommendation Filter */}
|
||||
{/* Global Brand Filter */}
|
||||
<div id="settings-governance" className="card animate-slide-up stagger-6" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }}>
|
||||
<div style={settingsModernHeaderStyle}>
|
||||
<div style={settingsModernTitleBlockStyle}>
|
||||
<div style={settingsModernTitleStyle}>模型品牌推荐屏蔽词</div>
|
||||
<div style={settingsModernTitleStyle}>全局品牌屏蔽</div>
|
||||
<div style={settingsModernDescriptionStyle}>
|
||||
用于 LLM 对话推荐和模型路由推荐:选中某个模型品牌后,路由重建会跳过匹配该品牌的模型。这里不是屏蔽 BoosAPI 平台品牌。
|
||||
屏蔽选定品牌后,路由重建时将自动跳过匹配该品牌的所有模型。点击品牌切换屏蔽状态,保存后自动触发路由重建。
|
||||
</div>
|
||||
</div>
|
||||
<span style={getSettingsPillStyle(blockedBrands.length > 0 ? 'warning' : 'neutral')}>
|
||||
{blockedBrands.length > 0 ? `已设置 ${blockedBrands.length}` : '未设置'}
|
||||
{blockedBrands.length > 0 ? `已屏蔽 ${blockedBrands.length}` : '未屏蔽'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
|
||||
@@ -2634,19 +2612,19 @@ export default function Settings() {
|
||||
);
|
||||
})}
|
||||
{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 && (
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无可用模型品牌</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无可用品牌</span>
|
||||
)}
|
||||
</div>
|
||||
{blockedBrands.length > 0 && (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-warning)', marginBottom: 10 }}>
|
||||
已设置 {blockedBrands.length} 个模型品牌推荐屏蔽词:{blockedBrands.join('、')}
|
||||
已屏蔽 {blockedBrands.length} 个品牌:{blockedBrands.join('、')}
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -2992,24 +2970,6 @@ export default function Settings() {
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
||||
登录会话默认 12 小时自动过期。可选配置管理端 IP 白名单,支持每行一个 IP 或 IPv4 CIDR 网段。
|
||||
</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 }}>
|
||||
当前识别到的管理端 IP(由服务端判定):
|
||||
</div>
|
||||
|
||||
+2
-10
@@ -706,7 +706,6 @@ const SITE_PLATFORM_OPTIONS = [
|
||||
{ value: 'claude', label: 'claude', description: '通用 Claude / Anthropic 兼容接口' },
|
||||
{ value: 'gemini', label: 'gemini', description: '通用 Gemini / Google AI 兼容接口' },
|
||||
{ value: 'cliproxyapi', label: 'cliproxyapi', description: 'CPA接入口' },
|
||||
{ value: 'comfy_cloud', label: 'Comfy Cloud', description: '工作流执行 API(数字人/图片视频)' },
|
||||
];
|
||||
|
||||
export default function Sites() {
|
||||
@@ -960,15 +959,8 @@ export default function Sites() {
|
||||
return;
|
||||
}
|
||||
setEditor({ mode: 'add' });
|
||||
const base = createEmptySiteForm();
|
||||
const comfyPreset = getSiteInitializationPreset('comfy-cloud');
|
||||
if (comfyPreset) {
|
||||
setForm({ ...base, platform: comfyPreset.platform, url: comfyPreset.defaultUrl });
|
||||
setSelectedInitializationPresetId(comfyPreset.id);
|
||||
} else {
|
||||
setForm(base);
|
||||
setSelectedInitializationPresetId(null);
|
||||
}
|
||||
setForm(createEmptySiteForm());
|
||||
setSelectedInitializationPresetId(null);
|
||||
scrollToEditorTop();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
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,15 +80,6 @@ describe('UserDashboard recharge entry', () => {
|
||||
successRequests: 4,
|
||||
totalCost: 0.12,
|
||||
},
|
||||
billing: {
|
||||
keyCount: 1,
|
||||
usedCost: 0.12,
|
||||
maxCost: 50,
|
||||
remainingCost: 49.88,
|
||||
unlimitedKeyCount: 0,
|
||||
rechargePath: '/user/recharge',
|
||||
usagePath: '/user/usage',
|
||||
},
|
||||
recentLogs: [],
|
||||
});
|
||||
});
|
||||
@@ -116,8 +107,6 @@ describe('UserDashboard recharge entry', () => {
|
||||
expect(text).toContain('用户自助工作台');
|
||||
expect(text).toContain('密钥、额度、使用日志一处处理');
|
||||
expect(text).toContain('1/1 个密钥启用');
|
||||
expect(text).toContain('剩余 10,525 积分');
|
||||
expect(text).toContain('当前剩余:10,525 积分');
|
||||
expect(text).toContain('已消耗 $0.120000');
|
||||
expect(text).toContain('调用状态正常');
|
||||
expect(root.root.findAll((node) => isInternalLinkTo(node, '/user/dashboard/keys')).length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { userApi, type UserBillingSummary } from '../api.js';
|
||||
import { userApi } from '../api.js';
|
||||
import { clearSession, getSessionToken } from '../authSession.js';
|
||||
import UserAvatar from '../components/UserAvatar.js';
|
||||
import UserRechargeOptions from '../components/UserRechargeOptions.js';
|
||||
import { formatRechargePointBalance } from '../userRecharge.js';
|
||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||
|
||||
interface UserProfile {
|
||||
@@ -62,7 +61,6 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [summary, setSummary] = useState<UserSummary | null>(null);
|
||||
const [billing, setBilling] = useState<UserBillingSummary | null>(null);
|
||||
const [recentLogs, setRecentLogs] = useState<LogItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
@@ -84,7 +82,6 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
||||
setProfile(dashboardRes.user || null);
|
||||
setKeys(Array.isArray(dashboardRes.keys) ? dashboardRes.keys : []);
|
||||
setSummary(dashboardRes.summary || { totalRequests: 0, successRequests: 0, totalCost: 0 });
|
||||
setBilling(dashboardRes.billing || null);
|
||||
setRecentLogs(Array.isArray(dashboardRes.recentLogs) ? dashboardRes.recentLogs : []);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '加载失败');
|
||||
@@ -94,7 +91,6 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
||||
}
|
||||
setKeys([]);
|
||||
setSummary({ totalRequests: 0, successRequests: 0, totalCost: 0 });
|
||||
setBilling(null);
|
||||
setRecentLogs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -143,9 +139,6 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
||||
const failedRequests = Math.max(0, (summary?.totalRequests || 0) - (summary?.successRequests || 0));
|
||||
const activeKeyCount = keys.filter((key) => key.enabled).length;
|
||||
const lastActivityLabel = recentLogs[0] ? formatDateLabel(recentLogs[0].created_at) : '暂无调用';
|
||||
const pointBalanceLabel = billing
|
||||
? formatRechargePointBalance(billing.remainingCost, billing.maxCost)
|
||||
: '等待余额同步';
|
||||
const primarySuggestion = activeKeyCount === 0
|
||||
? {
|
||||
title: '先创建一个 API 密钥',
|
||||
@@ -215,8 +208,8 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
||||
},
|
||||
{
|
||||
label: '额度',
|
||||
title: `剩余 ${pointBalanceLabel}`,
|
||||
detail: `已消耗 ${formatMoney(summary?.totalCost)},系统内选择充值档位后跳转 Comfy Checkout。`,
|
||||
title: `已消耗 ${formatMoney(summary?.totalCost)}`,
|
||||
detail: '系统内选择充值档位,再跳转 Comfy Checkout 完成付款。',
|
||||
to: '/user/recharge',
|
||||
action: '去充值',
|
||||
},
|
||||
@@ -278,7 +271,7 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
||||
<div>
|
||||
<span>账户充值</span>
|
||||
<strong>添加积分后继续调用模型与 Comfy API</strong>
|
||||
<p>当前剩余:{pointBalanceLabel}。进入系统内充值页确认额度后,再跳转到 Comfy Checkout 完成付款。</p>
|
||||
<p>进入系统内充值页确认额度后,再跳转到 Comfy Checkout 完成付款。</p>
|
||||
</div>
|
||||
<UserRechargeOptions internalOnly />
|
||||
</section>
|
||||
|
||||
@@ -127,7 +127,6 @@ describe('UserKeys billing center', () => {
|
||||
expect(text).toContain('20 / 100');
|
||||
expect(text).toContain('$0.120000 / $1.500');
|
||||
expect(text).toContain('账单累计 $0.456000');
|
||||
expect(text).toContain('当前剩余 291 积分');
|
||||
expect(text).toContain('1,280 tokens');
|
||||
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 { userApi } from '../api.js';
|
||||
import UserRechargeOptions from '../components/UserRechargeOptions.js';
|
||||
import { USER_RECHARGE_CONFIG, formatRechargePointBalance } from '../userRecharge.js';
|
||||
import { USER_RECHARGE_CONFIG } from '../userRecharge.js';
|
||||
import { buildUserUsageHref } from './helpers/userUsageLinks.js';
|
||||
|
||||
interface ApiKey {
|
||||
@@ -421,9 +421,6 @@ export default function UserKeys() {
|
||||
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 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 failedLogs = asNumber(usageSummary?.failed, recentLogs.filter((log) => log.status !== 'success').length);
|
||||
const totalCost = asNumber(usageSummary?.total_cost ?? usageSummary?.totalCost, usedCost);
|
||||
@@ -441,8 +438,6 @@ export default function UserKeys() {
|
||||
? `${formatCount(usedRequests)} / 不限`
|
||||
: `${formatCount(usedRequests)} / ${formatCount(maxRequests)}`,
|
||||
usedCost,
|
||||
remainingCost: limitedCostLimit > 0 ? Math.max(0, limitedCostLimit - limitedCostUsed) : null,
|
||||
maxCost: limitedCostLimit > 0 ? limitedCostLimit : null,
|
||||
costLabel: hasUnlimitedCost || !maxCost
|
||||
? `${formatMoney(usedCost)} / 不限`
|
||||
: `${formatMoney(usedCost)} / ${formatMoney(maxCost)}`,
|
||||
@@ -479,8 +474,7 @@ export default function UserKeys() {
|
||||
<section className="user-keys-recharge-panel" aria-label="账户充值">
|
||||
<div>
|
||||
<span>账户余额</span>
|
||||
<strong>当前剩余 {formatRechargePointBalance(overview.remainingCost, overview.maxCost)}</strong>
|
||||
<p>需要更多额度时,可跳转到 {recharge.providerLabel} 添加积分。</p>
|
||||
<strong>需要更多额度时,可跳转到 {recharge.providerLabel} 添加积分。</strong>
|
||||
</div>
|
||||
<UserRechargeOptions compact internalOnly />
|
||||
</section>
|
||||
|
||||
@@ -125,19 +125,15 @@ describe('UserRecharge page', () => {
|
||||
));
|
||||
const primaryButton = root!.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& String(node.props.className || '').includes('user-recharge-primary-action')
|
||||
&& collectText(node).includes('创建记录并支付')
|
||||
));
|
||||
|
||||
expect(text).toContain('账户充值');
|
||||
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('充值记录');
|
||||
expect(text).toContain('rch_test_1');
|
||||
expect(text).toContain('付款后由管理员核对到账。');
|
||||
expect(text).toContain('291 积分');
|
||||
expect(text).toContain('已用 $0.1200 / 剩余 $1.3800');
|
||||
expect(optionButtons).toHaveLength(payableOptions.length);
|
||||
expect(text).not.toContain('待配置');
|
||||
expect(disabledOptions).toHaveLength(0);
|
||||
@@ -199,50 +195,6 @@ 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 () => {
|
||||
(window.open as any).mockReturnValueOnce(null);
|
||||
let root: ReturnType<typeof create>;
|
||||
@@ -261,7 +213,7 @@ describe('UserRecharge page', () => {
|
||||
try {
|
||||
const primaryButton = root!.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& String(node.props.className || '').includes('user-recharge-primary-action')
|
||||
&& collectText(node).includes('创建记录并支付')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -2,14 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { userApi, type UserRechargeRecord } from '../api.js';
|
||||
import UserRechargeOptions from '../components/UserRechargeOptions.js';
|
||||
import {
|
||||
USER_RECHARGE_CONFIG,
|
||||
USER_RECHARGE_POINTS_PER_USD,
|
||||
calculateRechargePoints,
|
||||
formatRechargePointBalance,
|
||||
formatRechargePoints,
|
||||
type UserRechargeCheckoutOption,
|
||||
} from '../userRecharge.js';
|
||||
import { USER_RECHARGE_CONFIG, type UserRechargeCheckoutOption } from '../userRecharge.js';
|
||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||
|
||||
function formatUsd(value?: number | null) {
|
||||
@@ -18,15 +11,6 @@ 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) {
|
||||
return value ? formatDateTimeLocal(value) : '--';
|
||||
}
|
||||
@@ -68,7 +52,6 @@ export default function UserRecharge() {
|
||||
const [cancellingId, setCancellingId] = useState<string | null>(null);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [customAmount, setCustomAmount] = useState('10');
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -93,10 +76,6 @@ export default function UserRecharge() {
|
||||
const remaining = billing.remainingCost == null ? '不限' : formatUsd(billing.remainingCost);
|
||||
return `已用 ${formatUsd(billing.usedCost)} / 剩余 ${remaining}`;
|
||||
}, [billing]);
|
||||
const pointBalanceText = useMemo(() => {
|
||||
if (!billing) return '等待余额同步';
|
||||
return formatRechargePointBalance(billing.remainingCost, billing.maxCost);
|
||||
}, [billing]);
|
||||
|
||||
const openCheckout = async (option: UserRechargeCheckoutOption) => {
|
||||
if (pendingOptionId) return;
|
||||
@@ -167,16 +146,6 @@ export default function UserRecharge() {
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="page-container user-recharge-page animate-fade-in">
|
||||
@@ -186,7 +155,7 @@ export default function UserRecharge() {
|
||||
<h1>为模型和 Comfy API 添加积分</h1>
|
||||
<p>
|
||||
先创建系统内充值记录,再跳转到 {recharge.providerLabel} 完成付款。
|
||||
按 $1 = {USER_RECHARGE_POINTS_PER_USD} 积分换算,充值记录会和使用记录一起保留,便于到账核对。
|
||||
充值记录会和使用记录一起保留,便于到账核对。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -196,7 +165,7 @@ export default function UserRecharge() {
|
||||
disabled={pendingOptionId === defaultOption.id}
|
||||
onClick={() => void openCheckout(defaultOption)}
|
||||
>
|
||||
{pendingOptionId === defaultOption.id ? '创建记录中...' : `支付 ${defaultOption.label} · ${formatRechargePoints(defaultOption.amountUsd)}`}
|
||||
{pendingOptionId === defaultOption.id ? '创建记录中...' : '创建记录并支付'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@@ -206,7 +175,7 @@ export default function UserRecharge() {
|
||||
<div className="user-recharge-section-head">
|
||||
<div>
|
||||
<span>充值额度</span>
|
||||
<strong>$1 = {USER_RECHARGE_POINTS_PER_USD} 积分,付款前先确认预计到账</strong>
|
||||
<strong>先落库记录,再打开 Comfy Checkout</strong>
|
||||
</div>
|
||||
<small>{recharge.providerLabel}</small>
|
||||
</div>
|
||||
@@ -215,39 +184,13 @@ export default function UserRecharge() {
|
||||
onPay={openCheckout}
|
||||
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 className="user-recharge-info-grid" aria-label="充值说明">
|
||||
<div>
|
||||
<span>余额状态</span>
|
||||
<strong>{pointBalanceText}</strong>
|
||||
<p>{billingText}。余额不足时,创作任务会提示回到此页创建充值记录。</p>
|
||||
<strong>{billingText}</strong>
|
||||
<p>余额不足时,创作任务会提示回到此页创建充值记录。</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>到账说明</span>
|
||||
|
||||
@@ -56,16 +56,9 @@ function commitLoginSession(
|
||||
navigate: ReturnType<typeof useNavigate>,
|
||||
) {
|
||||
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');
|
||||
onLogin?.('user');
|
||||
navigate('/user/dashboard');
|
||||
}
|
||||
clearSession('admin');
|
||||
onLogin?.('user');
|
||||
navigate('/user/dashboard');
|
||||
}
|
||||
|
||||
export default function UserLoginPage({ t, onLogin }: UserLoginPageProps) {
|
||||
@@ -98,6 +91,10 @@ export default function UserLoginPage({ t, onLogin }: UserLoginPageProps) {
|
||||
setError(t(resolveLoginErrorMessage(401, '')));
|
||||
return;
|
||||
}
|
||||
if (payload.role === 'admin') {
|
||||
setError(t('管理员请使用管理员 API Token 登录。'));
|
||||
return;
|
||||
}
|
||||
commitLoginSession(payload, onLogin, navigate);
|
||||
} catch {
|
||||
setError(t(resolveLoginErrorMessage('network', '')));
|
||||
@@ -127,6 +124,10 @@ export default function UserLoginPage({ t, onLogin }: UserLoginPageProps) {
|
||||
setError(t(resolveLoginErrorMessage(401, '')));
|
||||
return;
|
||||
}
|
||||
if (payload.role === 'admin') {
|
||||
setError(t('管理员请使用管理员 API Token 登录。'));
|
||||
return;
|
||||
}
|
||||
commitLoginSession(payload, onLogin, navigate);
|
||||
} catch {
|
||||
setError(t(resolveLoginErrorMessage('network', '')));
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
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,16 +21,4 @@ describe('PictureAgent mobile layout', () => {
|
||||
expect(css).not.toContain('min-height: 340px;\n padding: 10px;');
|
||||
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);');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
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('保存品牌屏蔽');
|
||||
});
|
||||
});
|
||||
@@ -110,13 +110,6 @@ describe('Sites detect race handling', () => {
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
// 新增表单现在默认预选 Comfy Cloud 预设;本用例测试空平台下的检测竞态,
|
||||
// 先把平台重置为空(等价于操作员清空平台选择),恢复检测前置条件。
|
||||
await act(async () => {
|
||||
findPlatformSelect(root).props.onChange('');
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const urlInput = findPrimarySiteUrlInput(root);
|
||||
await act(async () => {
|
||||
urlInput.props.onChange({ target: { value: 'https://stale.example.com/v1' } });
|
||||
@@ -197,12 +190,6 @@ describe('Sites detect race handling', () => {
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
// 重置默认 Comfy Cloud 预选,恢复空平台检测前置条件。
|
||||
await act(async () => {
|
||||
findPlatformSelect(root).props.onChange('');
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const urlInput = findPrimarySiteUrlInput(root);
|
||||
await act(async () => {
|
||||
urlInput.props.onChange({ target: { value: 'https://blur.example.com/v1' } });
|
||||
|
||||
@@ -27,7 +27,6 @@ export type UserRechargeConfig = {
|
||||
};
|
||||
|
||||
const UNCONFIGURED_CHECKOUT_REASON = '该额度支付链接未配置';
|
||||
export const USER_RECHARGE_POINTS_PER_USD = 211;
|
||||
|
||||
const DEFAULT_USER_RECHARGE_AMOUNTS: UserRechargeCheckoutOption[] = [
|
||||
{ id: 'usd-5', label: '$5', title: '轻量试用', amountUsd: 5 },
|
||||
@@ -45,29 +44,6 @@ export function isUserRechargeOptionPayable(option: UserRechargeOption): 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 {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const checkoutUrl = String(value.checkoutUrl || value.checkout_url || value.url || '').trim();
|
||||
|
||||
@@ -26,7 +26,6 @@ describe('website GSAP motion contract', () => {
|
||||
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 (min-width: 641px)"');
|
||||
expect(source).toContain('ScrollTrigger.config({ ignoreMobileResize: true });');
|
||||
expect(source).toContain('matchMedia.revert();');
|
||||
});
|
||||
|
||||
@@ -87,12 +86,9 @@ describe('website GSAP motion contract', () => {
|
||||
expect(source).toContain('const activeMobileLink = links.find((link) => (');
|
||||
expect(source).toContain('link.getAttribute("href") === `#${id}` && Boolean(link.closest(".mobile-nav-rail"))');
|
||||
expect(source).not.toContain('CSS.escape');
|
||||
expect(source).toContain('centerMobileNavLink(activeMobileLink);');
|
||||
expect(source).toContain('function centerMobileNavLink(link: HTMLAnchorElement) {');
|
||||
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('activeMobileLink?.scrollIntoView({');
|
||||
expect(source).toContain('block: "nearest",');
|
||||
expect(source).toContain('inline: "center",');
|
||||
expect(source).toContain('const syncActiveFromHash = () => {');
|
||||
expect(source).toContain('window.location.hash.slice(1)');
|
||||
expect(source).toContain('const ANCHOR_NAV_EVENT = "bossai:anchor-nav";');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -23,11 +23,6 @@ describe('website /new experimental page', () => {
|
||||
expect(home).not.toContain('new-acid-page');
|
||||
expect(routes).toContain('path == "/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', () => {
|
||||
@@ -37,8 +32,7 @@ describe('website /new experimental page', () => {
|
||||
|
||||
expect(pkg).toContain('"three"');
|
||||
expect(pkg).toContain('"lenis"');
|
||||
expect(source).not.toContain('@react-three/fiber');
|
||||
expect(source).not.toContain('@react-three/drei');
|
||||
expect(pkg).not.toContain('"@react-three/fiber"');
|
||||
expect(source).toContain('"use client";');
|
||||
expect(source).toContain('import("three")');
|
||||
expect(source).toContain('import("gsap")');
|
||||
@@ -65,20 +59,4 @@ describe('website /new experimental page', () => {
|
||||
expect(css).toContain('@media (max-width: 640px)');
|
||||
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,7 +9,6 @@ import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import { SplitText } from "gsap/SplitText";
|
||||
|
||||
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 ANCHOR_NAV_EVENT = "bossai:anchor-nav";
|
||||
@@ -398,9 +397,10 @@ function setupActiveNavigation(root: HTMLElement) {
|
||||
const activeMobileLink = links.find((link) => (
|
||||
link.getAttribute("href") === `#${id}` && Boolean(link.closest(".mobile-nav-rail"))
|
||||
));
|
||||
if (activeMobileLink) {
|
||||
centerMobileNavLink(activeMobileLink);
|
||||
}
|
||||
activeMobileLink?.scrollIntoView({
|
||||
block: "nearest",
|
||||
inline: "center",
|
||||
});
|
||||
};
|
||||
|
||||
const syncActiveFromHash = () => {
|
||||
@@ -438,19 +438,6 @@ 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) {
|
||||
const syncCompactState = () => {
|
||||
root.classList.toggle("is-nav-compact", window.scrollY > 18);
|
||||
|
||||
@@ -3073,4 +3073,3 @@ a {
|
||||
will-change: auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
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