chore: remove obsolete docs and planning files
This commit is contained in:
@@ -1,79 +0,0 @@
|
||||
# Design - BoosAPI Console
|
||||
|
||||
A locked design system for the BoosAPI operations console. Page work preserves
|
||||
the existing React routes, API behaviour, session model, and i18n contracts.
|
||||
|
||||
## Product Scope
|
||||
|
||||
BoosAPI is an API aggregation and operations platform. The interface serves two
|
||||
audiences: administrators who operate providers, routes, credentials, billing,
|
||||
and incidents; and users who manage keys, balance, usage, and generation work.
|
||||
|
||||
### Route Families
|
||||
|
||||
- Operations: dashboard, ops monitor, sites, accounts, routes, logs, events,
|
||||
settings, IP management, downstream keys, notifications, import/export.
|
||||
- Platform inventory: models, OAuth, site announcements, users, payments,
|
||||
user balances, about.
|
||||
- Creation: picture agent, digital human, workflows, ComfyUI agent, agent
|
||||
workbench, workspace, money printer.
|
||||
- User self-service: dashboard, models, recharge, keys, profile, usage,
|
||||
announcements, creation and workspace routes.
|
||||
- Entry: admin login, user login, registration, and OAuth callback.
|
||||
|
||||
## Genre
|
||||
|
||||
Modern-minimal operational software. The interface should feel quiet, exact,
|
||||
and resilient under repeated use. Information density comes from alignment and
|
||||
typographic hierarchy, not decorative surfaces.
|
||||
|
||||
## Page Families
|
||||
|
||||
- App pages: Workbench. A concise operational header, a priority summary, then
|
||||
data surfaces arranged by task flow. Dashboard is the reference implementation.
|
||||
- Creation pages: Workbench with a focused canvas or conversation surface.
|
||||
- Entry pages: a compact, branded authentication shell with no marketing hero.
|
||||
- Reference and policy pages: Long Document, typography-led and calm.
|
||||
|
||||
## Tokens And Typography
|
||||
|
||||
- Preserve the existing `src/web/index.css` tokens and theme presets.
|
||||
- Accent: existing low-saturation teal (`--color-primary`), used for focus,
|
||||
selection, and a small number of primary actions only.
|
||||
- All UI text, including code-like values, metrics, logs, and identifiers, uses
|
||||
the existing Google Sans stack. Metric values retain tabular numbers rather
|
||||
than switching to a separate mono face. Headings remain upright and use
|
||||
normal letter spacing.
|
||||
- Keep the existing 4 px-derived spacing rhythm. Use the existing radius and
|
||||
shadow tokens; operational surfaces favour a visible border over elevation.
|
||||
|
||||
## Interaction
|
||||
|
||||
- Motion is informative only: 140-220 ms transform/opacity transitions, with
|
||||
`prefers-reduced-motion` support.
|
||||
- Every interactive control has visible `:focus-visible`, active, disabled,
|
||||
loading, error, and success treatment where that state is applicable.
|
||||
- Hover enhancements are conditional on fine pointers. No action is hover-only.
|
||||
- Buttons and tabs remain one line and target at least 44 px on touch devices.
|
||||
|
||||
## Responsive Rules
|
||||
|
||||
- Validate at 320, 375, 414, and 768 CSS pixels.
|
||||
- App grids collapse by content pressure: one column below 40rem, two columns
|
||||
at tablet widths, and dense workstation layouts only from 60rem upward.
|
||||
- Preserve the existing mobile drawer and card alternatives for data tables.
|
||||
|
||||
## Page Constraints
|
||||
|
||||
- Do not invent metrics, testimonials, provider logos, or product claims.
|
||||
- Avoid visual card nesting, ornamental gradients, floating blobs, and thick
|
||||
coloured side stripes. A border, a compact status marker, and type hierarchy
|
||||
are sufficient for operational priority.
|
||||
- Do not modify API contracts, route ownership, auth/session behaviour, or
|
||||
i18n keys for visual work.
|
||||
|
||||
## First Rollout
|
||||
|
||||
`/dashboard` is the first implementation. It adopts a Workbench header, an
|
||||
asymmetric but stable metric grid, flat data surfaces, and explicit keyboard
|
||||
focus treatment while retaining all current data and actions.
|
||||
@@ -1,694 +0,0 @@
|
||||
# 计费闭环 实现计划
|
||||
|
||||
> **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 建立 ledger-first 的双轨计费闭环(USD 池 + 工作流积分池),所有余额变动写统一流水账 `credit_ledger`。
|
||||
|
||||
**Architecture:** 新建 `credit_ledger`(唯一真相源)+ `user_credits`(积分缓存)+ `recharge_tiers`(充值档位)三张表。USD 轨沿用 `downstream_api_keys.max_cost/used_cost` 作热路径缓存,每笔变动同事务写一条 ledger。积分轨为工作流执行新增扣费/拦截。分 6 期,本计划详写第 1 期(地基),后 5 期为概要待细化。
|
||||
|
||||
**Tech Stack:** Rust (axum + sqlx + SQLite)、React + Vite 前端、in-memory SQLite 测试。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构(第 1 期)
|
||||
|
||||
- 新建 `src/services/billing/ledger.rs` — ledger 写入 + 余额查询的唯一入口(`record_ledger_entry`、`ensure_ledger_schema`)。所有动余额的地方调它,保证"缓存 + ledger 同事务"不变量集中在一处。
|
||||
- 修改 `src/services/billing/mod.rs` — `mod ledger;` 导出;`record_usage` 在写 usage_logs + increment 后,同事务补写 ledger(usd, consume)。
|
||||
- 修改 `src/services/payment.rs` — `credit_user_balance` 加写 ledger(usd, recharge)。
|
||||
- 修改 `src/routes/admin/users.rs` — `adjust_user_balance` 加写 ledger(usd, admin_adjust)。
|
||||
- 测试:`src/services/billing/ledger.rs` 内 `#[cfg(test)] mod tests`(复用 in-memory SQLite 模式)。
|
||||
|
||||
---
|
||||
|
||||
## 第 1 期:地基(credit_ledger + user_credits 表 + USD 轨记账接入)
|
||||
|
||||
### Task 1:ledger 表结构 + 写入入口
|
||||
|
||||
**Files:**
|
||||
- Create: `src/services/billing/ledger.rs`
|
||||
- Modify: `src/services/billing/mod.rs`(顶部加 `pub mod ledger;`)
|
||||
|
||||
- [ ] **Step 1: 写失败测试** — 在 `src/services/billing/ledger.rs` 末尾:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
async fn mem_pool() -> SqlitePool {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
ensure_ledger_schema(&pool).await.unwrap();
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_ledger_entry_writes_row_with_balance_snapshot() {
|
||||
let pool = mem_pool().await;
|
||||
let mut tx = pool.begin().await.unwrap();
|
||||
record_ledger_entry(
|
||||
&mut tx,
|
||||
LedgerEntry {
|
||||
user_id: 7,
|
||||
track: "usd",
|
||||
entry_type: "recharge",
|
||||
amount: 10.0,
|
||||
balance_after: 10.0,
|
||||
ref_type: Some("stripe"),
|
||||
ref_id: Some("cs_1"),
|
||||
model: None,
|
||||
operator: Some("system"),
|
||||
note: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let row: (i64, String, String, f64, f64) = sqlx::query_as(
|
||||
"SELECT user_id, track, entry_type, amount, balance_after
|
||||
FROM credit_ledger WHERE user_id = 7",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(row, (7, "usd".into(), "recharge".into(), 10.0, 10.0));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run: `cargo test -p boosapi --lib services::billing::ledger 2>&1 | tail -5`
|
||||
Expected: 编译失败(`ensure_ledger_schema`/`record_ledger_entry`/`LedgerEntry` 未定义)。
|
||||
|
||||
- [ ] **Step 3: 写最小实现** — 在 `src/services/billing/ledger.rs` 顶部(tests 之前):
|
||||
|
||||
```rust
|
||||
use crate::error::AppResult;
|
||||
use sqlx::{SqlitePool, Transaction};
|
||||
|
||||
/// One balance-changing event. Written to `credit_ledger` in the same
|
||||
/// transaction that updates the cached balance, so ledger and cache never diverge.
|
||||
pub struct LedgerEntry<'a> {
|
||||
pub user_id: i64,
|
||||
pub track: &'a str, // "usd" | "points"
|
||||
pub entry_type: &'a str, // "recharge" | "admin_adjust" | "consume" | "refund" | "grant"
|
||||
pub amount: f64, // +credit / -debit
|
||||
pub balance_after: f64,
|
||||
pub ref_type: Option<&'a str>,
|
||||
pub ref_id: Option<&'a str>,
|
||||
pub model: Option<&'a str>,
|
||||
pub operator: Option<&'a str>,
|
||||
pub note: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub async fn ensure_ledger_schema(db: &SqlitePool) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS credit_ledger (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
track TEXT NOT NULL,
|
||||
entry_type TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
balance_after REAL NOT NULL,
|
||||
ref_type TEXT,
|
||||
ref_id TEXT,
|
||||
model TEXT,
|
||||
operator TEXT,
|
||||
note TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS credit_ledger_user_created_idx
|
||||
ON credit_ledger (user_id, created_at DESC)",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS credit_ledger_track_type_idx
|
||||
ON credit_ledger (track, entry_type)",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS user_credits (
|
||||
user_id INTEGER PRIMARY KEY NOT NULL,
|
||||
points_balance REAL NOT NULL DEFAULT 0,
|
||||
points_total_recharged REAL NOT NULL DEFAULT 0,
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Append one ledger row inside an existing transaction. Caller is responsible
|
||||
/// for also updating the cached balance in the same `tx`.
|
||||
pub async fn record_ledger_entry(
|
||||
tx: &mut Transaction<'_, sqlx::Sqlite>,
|
||||
entry: LedgerEntry<'_>,
|
||||
) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO credit_ledger
|
||||
(user_id, track, entry_type, amount, balance_after,
|
||||
ref_type, ref_id, model, operator, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(entry.user_id)
|
||||
.bind(entry.track)
|
||||
.bind(entry.entry_type)
|
||||
.bind(entry.amount)
|
||||
.bind(entry.balance_after)
|
||||
.bind(entry.ref_type)
|
||||
.bind(entry.ref_id)
|
||||
.bind(entry.model)
|
||||
.bind(entry.operator)
|
||||
.bind(entry.note)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
并在 `src/services/billing/mod.rs` 顶部加:`pub mod ledger;`
|
||||
|
||||
- [ ] **Step 4: 运行测试确认通过**
|
||||
|
||||
Run: `cargo test -p boosapi --lib services::billing::ledger 2>&1 | tail -5`
|
||||
Expected: PASS(1 passed)。
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add src/services/billing/ledger.rs src/services/billing/mod.rs
|
||||
git commit -m "feat(billing): credit_ledger schema + record_ledger_entry"
|
||||
```
|
||||
|
||||
### Task 2:USD 消费接入 ledger(record_usage)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/services/billing/mod.rs`(`record_usage`,行 ~48-77)
|
||||
|
||||
- [ ] **Step 1: 写失败测试** — 在 billing/mod.rs 的 tests mod 加:
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn record_usage_writes_consume_ledger_entry() {
|
||||
let pool = billing_test_pool().await; // 复用现有测试 pool helper;若无则建 users+downstream_api_keys+usage_logs
|
||||
let tracker = UsageTracker::new(std::sync::Arc::new(pool.clone()));
|
||||
crate::services::billing::ledger::ensure_ledger_schema(&pool).await.unwrap();
|
||||
// key_id=7 属于 user 9,max_cost=10 used_cost=2(由 helper 建好)
|
||||
tracker.record_usage(7, "gpt-4o", 100, 50, 0.42).await.unwrap();
|
||||
|
||||
let row: (String, String, f64, Option<String>) = sqlx::query_as(
|
||||
"SELECT track, entry_type, amount, model FROM credit_ledger
|
||||
WHERE user_id = 9 ORDER BY id DESC LIMIT 1",
|
||||
).fetch_one(&pool).await.unwrap();
|
||||
assert_eq!(row.0, "usd");
|
||||
assert_eq!(row.1, "consume");
|
||||
assert_eq!(row.2, -0.42); // 消费为负
|
||||
assert_eq!(row.3.as_deref(), Some("gpt-4o"));
|
||||
}
|
||||
```
|
||||
|
||||
注:若 billing/mod.rs 无现成 `billing_test_pool`,在 tests mod 加一个建 `users`/`downstream_api_keys`(含 user_id=9,key id=7 max_cost=10 used_cost=2)/`usage_logs` 的 helper(参照 payment.rs 的 payment_test_pool)。
|
||||
|
||||
- [ ] **Step 2: 运行确认失败**
|
||||
|
||||
Run: `cargo test -p boosapi --lib record_usage_writes_consume_ledger_entry 2>&1 | tail -5`
|
||||
Expected: FAIL(credit_ledger 无 user 9 行 / 表不存在)。
|
||||
|
||||
- [ ] **Step 3: 实现** — `record_usage` 改为事务化,末尾补写 ledger。替换 record_usage 函数体为:
|
||||
|
||||
```rust
|
||||
pub async fn record_usage(
|
||||
&self,
|
||||
key_id: i64,
|
||||
model: impl Into<String>,
|
||||
prompt_tokens: i64,
|
||||
completion_tokens: i64,
|
||||
cost: f64,
|
||||
) -> AppResult<()> {
|
||||
Self::ensure_schema(&self.db).await?;
|
||||
crate::services::billing::ledger::ensure_ledger_schema(&self.db).await?;
|
||||
let prompt_tokens = prompt_tokens.max(0);
|
||||
let completion_tokens = completion_tokens.max(0);
|
||||
let total_tokens = prompt_tokens + completion_tokens;
|
||||
let cost = cost.max(0.0);
|
||||
let model = model.into();
|
||||
|
||||
let mut tx = self.db.begin().await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO usage_logs
|
||||
(downstream_key_id, model, prompt_tokens, completion_tokens, total_tokens, cost)
|
||||
VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(key_id).bind(&model)
|
||||
.bind(prompt_tokens).bind(completion_tokens).bind(total_tokens).bind(cost)
|
||||
.execute(&mut *tx).await?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE downstream_api_keys
|
||||
SET used_requests = COALESCE(used_requests,0)+1,
|
||||
used_cost = COALESCE(used_cost,0)+?,
|
||||
last_used_at = datetime('now'), updated_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
).bind(cost).bind(key_id).execute(&mut *tx).await?;
|
||||
|
||||
// Resolve owning user + new used_cost for the ledger snapshot.
|
||||
let owner: Option<(Option<i64>, Option<f64>, Option<f64>)> = sqlx::query_as(
|
||||
"SELECT user_id, max_cost, used_cost FROM downstream_api_keys WHERE id = ?",
|
||||
).bind(key_id).fetch_optional(&mut *tx).await?;
|
||||
if let Some((Some(user_id), max_cost, used_cost)) = owner {
|
||||
let used = used_cost.unwrap_or(0.0);
|
||||
let remaining = max_cost.map(|m| (m - used).max(0.0)).unwrap_or(used);
|
||||
crate::services::billing::ledger::record_ledger_entry(
|
||||
&mut tx,
|
||||
crate::services::billing::ledger::LedgerEntry {
|
||||
user_id, track: "usd", entry_type: "consume",
|
||||
amount: -cost, balance_after: remaining,
|
||||
ref_type: Some("usage_log"), ref_id: None,
|
||||
model: Some(&model), operator: Some("system"), note: None,
|
||||
},
|
||||
).await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
(注:原 `increment_key_usage(&self.db, ...)` 调用被事务内 UPDATE 取代,删除该行。)
|
||||
|
||||
- [ ] **Step 4: 运行确认通过**
|
||||
|
||||
Run: `cargo test -p boosapi --lib record_usage 2>&1 | tail -6`
|
||||
Expected: PASS。
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add src/services/billing/mod.rs
|
||||
git commit -m "feat(billing): USD consume writes credit_ledger in one tx"
|
||||
```
|
||||
|
||||
### Task 3:USD 充值接入 ledger(credit_user_balance)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/services/payment.rs`(`credit_user_balance`,行 ~2167-2202)
|
||||
|
||||
- [ ] **Step 1: 写失败测试** — payment.rs tests mod(复用 payment_test_pool,user 9/key 7 max_cost=10):
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn credit_user_balance_writes_recharge_ledger() {
|
||||
let pool = payment_test_pool().await;
|
||||
crate::services::billing::ledger::ensure_ledger_schema(&pool).await.unwrap();
|
||||
let mut tx = pool.begin().await.unwrap();
|
||||
credit_user_balance(&mut tx, 9, 20.0).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
let row: (String, String, f64) = sqlx::query_as(
|
||||
"SELECT track, entry_type, amount FROM credit_ledger WHERE user_id = 9 ORDER BY id DESC LIMIT 1",
|
||||
).fetch_one(&pool).await.unwrap();
|
||||
assert_eq!(row, ("usd".into(), "recharge".into(), 20.0));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行确认失败**
|
||||
|
||||
Run: `cargo test -p boosapi --lib credit_user_balance_writes_recharge_ledger 2>&1 | tail -5`
|
||||
Expected: FAIL。
|
||||
|
||||
- [ ] **Step 3: 实现** — `credit_user_balance` 在 UPDATE max_cost 之后、`Ok(Some(key_id))` 之前插入 ledger 写入(已在事务 tx 内):
|
||||
|
||||
```rust
|
||||
// After the UPDATE that sets next_max_cost on the key:
|
||||
crate::services::billing::ledger::record_ledger_entry(
|
||||
tx,
|
||||
crate::services::billing::ledger::LedgerEntry {
|
||||
user_id, track: "usd", entry_type: "recharge",
|
||||
amount: amount_usd.max(0.0),
|
||||
balance_after: (next_max_cost - used_cost).max(0.0),
|
||||
ref_type: Some("recharge_record"), ref_id: None,
|
||||
model: None, operator: Some("system"), note: None,
|
||||
},
|
||||
).await?;
|
||||
```
|
||||
|
||||
(`credit_user_balance` 已是 `tx: &mut Transaction`,直接传 `tx`。确保调用方在调用前已 `ensure_ledger_schema`,或在函数开头加一次幂等建表。)
|
||||
|
||||
- [ ] **Step 4: 运行确认通过**
|
||||
|
||||
Run: `cargo test -p boosapi --lib credit_user_balance 2>&1 | tail -6`
|
||||
Expected: PASS(含已有的 recharge completion 测试)。
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add src/services/payment.rs
|
||||
git commit -m "feat(billing): USD recharge writes credit_ledger"
|
||||
```
|
||||
|
||||
### Task 4:USD 管理员调整接入 ledger(adjust_user_balance)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/routes/admin/users.rs`(`adjust_user_balance`,行 ~820)
|
||||
|
||||
- [ ] **Step 1: 写失败测试** — users.rs tests mod:调 `adjust_user_balance(State, Path(9), Json{delta:5.0})` 后断言 credit_ledger 有一行 `(usd, admin_adjust, 5.0)`。(建表与 user/key 同 Task 3 helper。)
|
||||
|
||||
- [ ] **Step 2: 运行确认失败** — `cargo test -p boosapi --lib adjust_user_balance 2>&1 | tail -5` → FAIL。
|
||||
|
||||
- [ ] **Step 3: 实现** — `adjust_user_balance` 现在直接 `execute(&*state.db)`。改为事务:begin → UPDATE max_cost → `record_ledger_entry(track="usd", entry_type="admin_adjust", amount=delta, balance_after=next_max-used, operator=<管理员,暂用 "admin">)` → commit。函数开头 `ledger::ensure_ledger_schema(&state.db).await?;`。
|
||||
|
||||
- [ ] **Step 4: 运行确认通过** — `cargo test -p boosapi --lib adjust_user_balance 2>&1 | tail -6` → PASS。
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add src/routes/admin/users.rs
|
||||
git commit -m "feat(billing): USD admin adjust writes credit_ledger"
|
||||
```
|
||||
|
||||
### Task 5:全量回归
|
||||
|
||||
- [ ] **Step 1:** `cargo test 2>&1 | grep "test result"` → 全 ok。
|
||||
- [ ] **Step 2:** `cargo build 2>&1 | tail -3` → Finished。
|
||||
- [ ] **Step 3:** 提交(若有遗留)。
|
||||
|
||||
---
|
||||
|
||||
## 后续期次(概要,第 1 期落地后逐期细化为同样的 TDD 任务)
|
||||
|
||||
### 第 2 期:积分轨
|
||||
- `user_credits` 已建表(Task 1)。新增 `adjust_user_credits`(admin 发放/增减,写 ledger points/admin_adjust)。
|
||||
- comfy_cloud 确认到账 `admin_confirm_user_recharge` 改为加 `points_balance` + ledger(points, recharge),受 config 开关 `payment.comfy_recharge_to_points` 控制(默认 true);历史不迁移。
|
||||
- 工作流积分单价 config(默认值 + 按工作流名分级映射)。
|
||||
- `workflow/executor.rs` `run_workflow`:执行**前**查 `points_balance >= 单价`,不足返回 Forbidden「工作流积分不足」;执行**成功后**扣积分 + ledger(points, consume, model=工作流名)。
|
||||
- 测试:拦截、扣费、充值进积分、管理员调整。
|
||||
|
||||
### 第 3 期:A 消费明细
|
||||
- `GET /api/user/billing/ledger`(查本人 credit_ledger,track/type/时间过滤、分页)。
|
||||
- `GET /api/user/billing/summary`(按天/模型聚合,双轨分开)。
|
||||
- 扩展 `UsageRecords.tsx`:双轨切换 + 汇总卡 + 明细列表。
|
||||
|
||||
### 第 4 期:B 充值档位
|
||||
- `recharge_tiers` 表(已在 spec,Task 1 未建,本期建)+ admin CRUD `/api/admin/recharge-tiers`。
|
||||
- 用户充值档位从 DB 读(替代 env `USER_RECHARGE_OPTIONS`)。
|
||||
- admin `RechargeTiers` 配置页 + `UserRecharge.tsx` 接 DB。
|
||||
|
||||
### 第 5 期:C 预警
|
||||
- 余额接口(user_billing_summary 等)加 `lowBalance`(余额 < 阈值,config 默认 0.2)。
|
||||
- Dashboard / UserRecharge / ComfyUIAgent 顶部预警条。
|
||||
|
||||
### 第 6 期:D 审计 + 对账
|
||||
- `GET /api/admin/billing/ledger`(跨用户,过滤分页)+ admin `BillingAudit` 页。
|
||||
- cron:拉 Comfy `/customers/balance` 总余额,与 points consume ledger 比对,偏差超阈值写 log + admin 告警标志(不自动改数据)。
|
||||
|
||||
---
|
||||
|
||||
## 自审
|
||||
|
||||
- **spec 覆盖**:第 1 期覆盖 spec「数据模型」+「USD 轨记账接入」三处(充值/消费/调整);后 5 期概要逐条对应 spec 的积分轨/A/B/C/D + 对账。recharge_tiers 表 spec 列在数据模型,实现挪到第 4 期建(第 1 期用不到),已注明。
|
||||
- **占位扫描**:第 1 期 Task 1-3 含完整代码;Task 4-5 用文字描述但步骤明确(adjust 与 Task 3 同模式,刻意不重复大段代码,执行时参照 Task 3)。后 5 期为概要,执行前细化。
|
||||
- **类型一致**:`LedgerEntry` 字段、`record_ledger_entry`/`ensure_ledger_schema` 签名在 Task 1 定义,Task 2-4 一致引用。`track`/`entry_type` 取值(usd/points、recharge/consume/admin_adjust)全程一致。
|
||||
|
||||
---
|
||||
|
||||
## 第 2 期:积分轨(细化 TDD)
|
||||
|
||||
文件结构:
|
||||
- `src/services/billing/ledger.rs` 加积分轨核心:`points_balance(db,user_id)`(读余额)、`apply_points_delta(db, user_id, delta, entry_type, model, operator, ref_*)`(事务内:UPSERT user_credits 余额 + 写 ledger(points),返回新余额)。所有积分增减走这一个入口。
|
||||
- `src/config.rs`:ComfyUIConfig 加 `workflow_points_cost`(默认单价)。
|
||||
- `src/workflow/executor.rs`:run_workflow_with_progress 前置拦截 + 收尾扣积分。
|
||||
- `src/services/payment.rs`:admin_confirm_user_recharge 改为加积分(受 config 开关)。
|
||||
- `src/routes/admin/users.rs`:新增 `adjust_user_credits` handler + 路由。
|
||||
|
||||
### Task 6:积分轨核心 apply_points_delta + points_balance
|
||||
|
||||
**Files:** Modify `src/services/billing/ledger.rs`
|
||||
|
||||
- [ ] **Step1 失败测试**(ledger.rs tests mod):
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn apply_points_delta_upserts_balance_and_writes_ledger() {
|
||||
let pool = mem_pool().await;
|
||||
// 首次 +100(充值)
|
||||
let bal = apply_points_delta(&pool, 9, 100.0, "recharge", None, Some("system"), Some("recharge_record"), None).await.unwrap();
|
||||
assert_eq!(bal, 100.0);
|
||||
// 再 -30(消费)
|
||||
let bal2 = apply_points_delta(&pool, 9, -30.0, "consume", Some("wf/video"), Some("system"), Some("workflow"), None).await.unwrap();
|
||||
assert_eq!(bal2, 70.0);
|
||||
// user_credits 缓存 = 70
|
||||
let cached: f64 = sqlx::query_scalar("SELECT points_balance FROM user_credits WHERE user_id = 9").fetch_one(&pool).await.unwrap();
|
||||
assert_eq!(cached, 70.0);
|
||||
// ledger 两行
|
||||
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM credit_ledger WHERE user_id=9 AND track='points'").fetch_one(&pool).await.unwrap();
|
||||
assert_eq!(n, 2);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step2** `cargo test -p boosapi --lib apply_points_delta_upserts 2>&1 | tail -6` → FAIL。
|
||||
|
||||
- [ ] **Step3 实现**(ledger.rs,tests 之前):
|
||||
|
||||
```rust
|
||||
/// Current points balance for a user (0 if no row yet).
|
||||
pub async fn points_balance(db: &SqlitePool, user_id: i64) -> AppResult<f64> {
|
||||
let bal: Option<f64> = sqlx::query_scalar(
|
||||
"SELECT points_balance FROM user_credits WHERE user_id = ?",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
Ok(bal.unwrap_or(0.0))
|
||||
}
|
||||
|
||||
/// Apply a points delta (+credit / -debit) atomically: upsert the cached
|
||||
/// balance in `user_credits` and append one `credit_ledger(track=points)` row,
|
||||
/// in a single transaction. Returns the new balance.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn apply_points_delta(
|
||||
db: &SqlitePool,
|
||||
user_id: i64,
|
||||
delta: f64,
|
||||
entry_type: &str,
|
||||
model: Option<&str>,
|
||||
operator: Option<&str>,
|
||||
ref_type: Option<&str>,
|
||||
ref_id: Option<&str>,
|
||||
) -> AppResult<f64> {
|
||||
ensure_ledger_schema(db).await?;
|
||||
let mut tx = db.begin().await?;
|
||||
let current: f64 = sqlx::query_scalar(
|
||||
"SELECT points_balance FROM user_credits WHERE user_id = ?",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.unwrap_or(0.0);
|
||||
let next = current + delta;
|
||||
let total_recharged_inc = if delta > 0.0 && entry_type == "recharge" { delta } else { 0.0 };
|
||||
sqlx::query(
|
||||
"INSERT INTO user_credits (user_id, points_balance, points_total_recharged, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
points_balance = ?,
|
||||
points_total_recharged = points_total_recharged + ?,
|
||||
updated_at = datetime('now')",
|
||||
)
|
||||
.bind(user_id).bind(next).bind(total_recharged_inc)
|
||||
.bind(next).bind(total_recharged_inc)
|
||||
.execute(&mut *tx).await?;
|
||||
record_ledger_entry(&mut tx, LedgerEntry {
|
||||
user_id, track: "points", entry_type,
|
||||
amount: delta, balance_after: next,
|
||||
ref_type, ref_id, model, operator, note: None,
|
||||
}).await?;
|
||||
tx.commit().await?;
|
||||
Ok(next)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step4** `cargo test -p boosapi --lib apply_points_delta 2>&1 | tail -6` → PASS;`cargo build -j 2 | tail -2`。
|
||||
- [ ] **Step5** `git add src/services/billing/ledger.rs && git commit -m "feat(billing): points track apply_points_delta + balance"`
|
||||
|
||||
### Task 7:工作流积分单价 config
|
||||
|
||||
**Files:** Modify `src/config.rs`
|
||||
|
||||
- [ ] **Step1** 测试:config 默认值测试(若 config.rs 有 default 测试则加断言;否则跳测试,靠编译)。
|
||||
- [ ] **Step2/3 实现**:ComfyUIConfig 加字段 `#[serde(default = "default_workflow_points_cost")] pub workflow_points_cost: f64,`,default fn 返回 `10.0`;Default impl 里加 `workflow_points_cost: 10.0,`。
|
||||
- [ ] **Step4** `cargo build -j 2 2>&1 | tail -3` → Finished(config 字段加完整个 crate 能编译)。
|
||||
- [ ] **Step5** `git add src/config.rs && git commit -m "feat(billing): workflow_points_cost config"`
|
||||
|
||||
### Task 8:工作流执行前置拦截 + 成功扣积分
|
||||
|
||||
**Files:** Modify `src/workflow/executor.rs`
|
||||
|
||||
- [ ] **Step1 失败测试**:executor.rs tests mod(若无则建)。建 in-memory pool + user_credits/credit_ledger,构造一个最简 graph,`ctx.user_id=Some(9)`:
|
||||
- 余额不足(0 积分,单价 10)→ run_workflow_with_progress 返回 Err(Forbidden 含"积分不足")。
|
||||
- 余额充足(100)→ 执行成功后 points_balance 变 90,credit_ledger 出现一行 (points, consume, -10)。
|
||||
(若 executor 难以单测真实节点,则把"拦截"和"扣费"逻辑抽成小函数 `check_workflow_points(state, user_id, cost)` 和 `charge_workflow_points(state, user_id, cost, workflow_name)` 各自单测,run_workflow 调它们。优先抽函数单测。)
|
||||
|
||||
- [ ] **Step2** 确认 FAIL。
|
||||
|
||||
- [ ] **Step3 实现**:
|
||||
- 在 run_workflow_with_progress 的 `validate_and_sort` 之后、`insert_run` 之前:若 `ctx.user_id` 是 Some 且 `ctx.charge_points`(见下):`let cost = state.config.comfyui.workflow_points_cost; if cost > 0.0 { let bal = ledger::points_balance(&state.db, uid).await?; if bal < cost { return Err(AppError::Forbidden("工作流积分不足,请充值".into())); } }`
|
||||
- 在 `complete_run` 之后、`Ok(run_public_id)` 之前:`if let Some(uid) = ctx.user_id { let cost = state.config.comfyui.workflow_points_cost; if cost > 0.0 { let _ = ledger::apply_points_delta(&state.db, uid, -cost, "consume", Some(&format!("workflow:{}", workflow_id)), Some("system"), Some("workflow_run"), Some(&run_public_id)).await; } }`(扣费 best-effort,失败记 log 不阻断已产出结果)。
|
||||
- 注意:Admin 调用(builtin key,user_id 可能是 Some(admin) 或特殊)是否扣?按设计仅对普通用户扣。可用 RunContext 加 `pub charge_points: bool` 字段,run_context 构造时 User=>true / Admin=>false。models.rs RunContext 加该字段,两处构造(routes.rs run_context、workflow_ui.rs workflow_context)补 `charge_points`。
|
||||
|
||||
- [ ] **Step4** 测试 PASS + `cargo build -j 2`。
|
||||
- [ ] **Step5** `git add src/workflow/executor.rs src/workflow/models.rs src/workflow/routes.rs src/routes/workflow_ui.rs && git commit -m "feat(billing): workflow points gating + charge"`
|
||||
|
||||
### Task 9:comfy 充值改加积分 + admin 积分调整
|
||||
|
||||
**Files:** Modify `src/services/payment.rs`、`src/routes/admin/users.rs`、`src/config.rs`(开关)、`src/routes/admin/mod.rs`(路由)
|
||||
|
||||
- [ ] **Step1 失败测试**:
|
||||
- payment.rs:`admin_confirm_user_recharge` 确认后,credit_ledger 出现 (points, recharge, +amount)(而非 usd);user_credits 增加。
|
||||
- users.rs:`adjust_user_credits` handler 调用后 credit_ledger 出现 (points, admin_adjust)。
|
||||
- [ ] **Step2** FAIL。
|
||||
- [ ] **Step3 实现**:
|
||||
- config 开关 `payment.comfy_recharge_to_points: bool`(默认 true)。
|
||||
- `admin_confirm_user_recharge`:开关为 true 时改调 `ledger::apply_points_delta(db, user_id, amount_usd_as_points, "recharge", None, Some("admin"), Some("recharge_record"), Some(public_id))`(USD→积分换算:本期 1 USD = N 积分,N 用 config `payment.usd_to_points_rate` 默认 100;即 grant_points = amount_usd * rate)。开关 false 时维持原 credit_user_balance(USD)。**历史不迁移**。
|
||||
- 新增 `adjust_user_credits(State, Path(id), Json{delta})` handler(仿 adjust_user_balance)→ `apply_points_delta(db,id,delta,"admin_adjust",None,Some("admin"),Some("manual"),None)`,返回 {success, data{userId, pointsBalance}}。
|
||||
- admin/mod.rs 注册 `POST /api/admin/users/{id}/credits/adjust`。
|
||||
- [ ] **Step4** 相关测试 + payment/users 模块全过 + build。
|
||||
- [ ] **Step5** 分两个 commit 或一个:`git commit -m "feat(billing): comfy recharge to points + admin credit adjust"`
|
||||
|
||||
### Task 10:第 2 期全量回归
|
||||
- [ ] `cargo test 2>&1 | grep "test result"` 全 ok;`cargo build -j 2 | tail -2` Finished。
|
||||
|
||||
---
|
||||
|
||||
## 第 3 期:A 消费明细(细化 TDD)
|
||||
|
||||
文件:`src/services/billing/ledger.rs`(加查询函数)、`src/routes/user/mod.rs`(加 2 路由)、`src/web/api.ts`(加 client)、`src/web/pages/UsageRecords.tsx`(扩展)。
|
||||
|
||||
### Task 11:ledger 查询函数(明细 + 聚合)
|
||||
|
||||
**Files:** Modify `src/services/billing/ledger.rs`
|
||||
|
||||
- [ ] **Step1 失败测试**(ledger.rs tests mod):
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn list_and_summarize_user_ledger() {
|
||||
let pool = mem_pool().await;
|
||||
apply_points_delta(&pool, 9, 100.0, "recharge", None, Some("system"), Some("rr"), None).await.unwrap();
|
||||
apply_points_delta(&pool, 9, -10.0, "consume", Some("wf:1"), Some("system"), Some("run"), None).await.unwrap();
|
||||
// 明细:user 9 两行,track=points
|
||||
let rows = list_user_ledger(&pool, 9, None, None, 50, 0).await.unwrap();
|
||||
assert_eq!(rows.len(), 2);
|
||||
// 聚合:points 轨 consume 合计 -10
|
||||
let sum = summarize_user_ledger(&pool, 9).await.unwrap();
|
||||
assert!(sum.iter().any(|v| v["track"]=="points" && v["entryType"]=="consume"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step2** `cargo test -p boosapi --lib list_and_summarize_user_ledger 2>&1|tail -6` FAIL。
|
||||
|
||||
- [ ] **Step3 实现**(ledger.rs,tests 之前):
|
||||
```rust
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// List a user's ledger rows, newest first, optional track/entry_type filter + paging.
|
||||
pub async fn list_user_ledger(
|
||||
db: &SqlitePool, user_id: i64,
|
||||
track: Option<&str>, entry_type: Option<&str>,
|
||||
limit: i64, offset: i64,
|
||||
) -> AppResult<Vec<Value>> {
|
||||
ensure_ledger_schema(db).await?;
|
||||
let limit = limit.clamp(1, 500);
|
||||
let rows = sqlx::query(
|
||||
"SELECT track, entry_type, amount, balance_after, model, operator, ref_type, ref_id, note, created_at
|
||||
FROM credit_ledger
|
||||
WHERE user_id = ?
|
||||
AND (?1_track IS NULL OR track = ?1_track)
|
||||
AND (?1_type IS NULL OR entry_type = ?1_type)
|
||||
ORDER BY id DESC LIMIT ? OFFSET ?"
|
||||
.replace("?1_track", "?2").replace("?1_type", "?3")
|
||||
)
|
||||
.bind(user_id).bind(track).bind(entry_type).bind(limit).bind(offset)
|
||||
.fetch_all(db).await?;
|
||||
use sqlx::Row;
|
||||
Ok(rows.iter().map(|r| json!({
|
||||
"track": r.try_get::<String,_>("track").unwrap_or_default(),
|
||||
"entryType": r.try_get::<String,_>("entry_type").unwrap_or_default(),
|
||||
"amount": r.try_get::<f64,_>("amount").unwrap_or(0.0),
|
||||
"balanceAfter": r.try_get::<f64,_>("balance_after").unwrap_or(0.0),
|
||||
"model": r.try_get::<Option<String>,_>("model").ok().flatten(),
|
||||
"operator": r.try_get::<Option<String>,_>("operator").ok().flatten(),
|
||||
"refType": r.try_get::<Option<String>,_>("ref_type").ok().flatten(),
|
||||
"note": r.try_get::<Option<String>,_>("note").ok().flatten(),
|
||||
"createdAt": r.try_get::<Option<String>,_>("created_at").ok().flatten(),
|
||||
})).collect())
|
||||
}
|
||||
|
||||
/// Aggregate a user's ledger by (track, entry_type): sum amount + count.
|
||||
pub async fn summarize_user_ledger(db: &SqlitePool, user_id: i64) -> AppResult<Vec<Value>> {
|
||||
ensure_ledger_schema(db).await?;
|
||||
let rows = sqlx::query(
|
||||
"SELECT track, entry_type, COALESCE(SUM(amount),0) AS total, COUNT(*) AS cnt
|
||||
FROM credit_ledger WHERE user_id = ?
|
||||
GROUP BY track, entry_type ORDER BY track, entry_type"
|
||||
).bind(user_id).fetch_all(db).await?;
|
||||
use sqlx::Row;
|
||||
Ok(rows.iter().map(|r| json!({
|
||||
"track": r.try_get::<String,_>("track").unwrap_or_default(),
|
||||
"entryType": r.try_get::<String,_>("entry_type").unwrap_or_default(),
|
||||
"total": r.try_get::<f64,_>("total").unwrap_or(0.0),
|
||||
"count": r.try_get::<i64,_>("cnt").unwrap_or(0),
|
||||
})).collect())
|
||||
}
|
||||
```
|
||||
注:上面 SQL 的 named-param replace 写法若 sqlx 不接受,改用直白的可选过滤:用 `track.is_none() OR track = ?` 形式,bind 顺序 user_id, track, track, entry_type, entry_type, limit, offset(每个 Option bind 两次)。子代理按 sqlx 实际行为调整到能编译+测试过。
|
||||
|
||||
- [ ] **Step4** 测试 PASS + `cargo build -j 2`。
|
||||
- [ ] **Step5** `git add src/services/billing/ledger.rs && git commit -m "feat(billing): user ledger list + summary queries"`
|
||||
|
||||
### Task 12:user 侧 billing 接口
|
||||
|
||||
**Files:** Modify `src/routes/user/mod.rs`(加 2 路由 + 2 handler)
|
||||
|
||||
- [ ] **Step1 失败测试**:user mod tests(若有 test_state/建表 helper 则复用)——构造 user JWT + ledger 数据,GET /api/user/billing/ledger 返回行,/summary 返回聚合。若 user mod 无现成集成测试框架,改为直接单测 handler 逻辑或跳测试靠 ledger.rs 的 Task11 测试覆盖,仅验证编译 + 路由注册。
|
||||
- [ ] **Step2** FAIL/编译失败。
|
||||
- [ ] **Step3 实现**:
|
||||
```rust
|
||||
async fn get_billing_ledger(State(state): State<AppState>, headers: HeaderMap, Query(q): Query<BillingLedgerQuery>) -> AppResult<Json<Value>> {
|
||||
let user_id = current_user_id(&headers, &state)?;
|
||||
let rows = crate::services::billing::ledger::list_user_ledger(
|
||||
&state.db, user_id, q.track.as_deref(), q.entry_type.as_deref(),
|
||||
q.limit.unwrap_or(100), q.offset.unwrap_or(0)).await?;
|
||||
Ok(Json(json!({ "success": true, "records": rows })))
|
||||
}
|
||||
async fn get_billing_summary(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
|
||||
let user_id = current_user_id(&headers, &state)?;
|
||||
let agg = crate::services::billing::ledger::summarize_user_ledger(&state.db, user_id).await?;
|
||||
Ok(Json(json!({ "success": true, "summary": agg })))
|
||||
}
|
||||
```
|
||||
+ `BillingLedgerQuery{ track:Option<String>, entry_type:Option<String>, limit:Option<i64>, offset:Option<i64> }`(serde Deserialize, rename_all camelCase 视前端传参)。
|
||||
+ routes() 注册 `.route("/api/user/billing/ledger", get(get_billing_ledger)).route("/api/user/billing/summary", get(get_billing_summary))`。
|
||||
- [ ] **Step4** `cargo test -p boosapi --lib 2>&1|command grep "test result"` + `cargo build -j 2`。
|
||||
- [ ] **Step5** `git add src/routes/user/mod.rs && git commit -m "feat(billing): user billing ledger + summary endpoints"`
|
||||
|
||||
### Task 13:前端 api + UsageRecords 双轨展示
|
||||
|
||||
**Files:** Modify `src/web/api.ts`、`src/web/pages/UsageRecords.tsx`
|
||||
|
||||
- [ ] **Step1**:api.ts 的 userApi 加 `getBillingLedger(params)` → GET /api/user/billing/ledger、`getBillingSummary()` → /summary。
|
||||
- [ ] **Step2**:UsageRecords.tsx 加一个"积分/USD"双轨切换 tab + 聚合汇总卡(用 getBillingSummary)+ ledger 明细列表(getBillingLedger)。保留现有 logs 视图作为 USD 明细来源之一,新增积分明细来自 ledger。具体 UI 复用现有 usage-records-* 样式。
|
||||
- [ ] **Step3**:`npm run typecheck:web` 干净 + 现有 UsageRecords 测试不破坏(`npx vitest run src/web/pages/UsageRecords`)。
|
||||
- [ ] **Step4** `git add src/web/api.ts src/web/pages/UsageRecords.tsx && git commit -m "feat(billing): UsageRecords dual-track ledger view"`
|
||||
|
||||
### Task 14:第3期回归
|
||||
- [ ] `cargo test 2>&1|command grep "test result"` 全ok;`cargo build -j 2`;`npm run typecheck`。
|
||||
@@ -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,149 +0,0 @@
|
||||
# 计费闭环系统设计
|
||||
|
||||
日期:2026-06-16
|
||||
状态:设计已获批,待写实现计划
|
||||
|
||||
## 背景
|
||||
|
||||
BoosAPI 现有计费是零散拼接的:每个用户的 downstream API key 有 `max_cost`(美元上限)/ `used_cost`(已消耗),proxy 请求路径在 `used_cost >= max_cost` 时硬拦截返回 429;充值通过 Stripe 自动到账或 comfy_cloud 管理员确认到账,调用 `credit_user_balance` 增加 `max_cost`;新用户默认 $1 额度;usage_logs 按 key 记每笔消费。
|
||||
|
||||
缺口:ComfyUI 工作流执行不扣额度、无积分体系、无消费明细可视化、无充值档位配置、无低额度预警、无全量变动审计。
|
||||
|
||||
本设计把上述补齐为一个 **ledger-first(统一流水账)** 的计费闭环。
|
||||
|
||||
## 核心模型:双轨额度
|
||||
|
||||
- **USD 轨**:LLM 对话、生图、视频等所有走 `/v1/*` 的调用。沿用 `downstream_api_keys.max_cost/used_cost`。
|
||||
- **工作流积分轨**:仅 ComfyUI 工作流执行。新建每用户独立的积分池,平台可管理。
|
||||
|
||||
两轨并行、互不换算。comfy_cloud 充值进积分轨;其他充值(Stripe)进 USD 轨。
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 新表 `credit_ledger`(唯一真相源,所有变动记一行)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | INTEGER PK AUTOINCREMENT | |
|
||||
| `user_id` | INTEGER NOT NULL | 所属用户 |
|
||||
| `track` | TEXT NOT NULL | `usd` / `points` |
|
||||
| `entry_type` | TEXT NOT NULL | `recharge` / `admin_adjust` / `consume` / `refund` / `grant` |
|
||||
| `amount` | REAL NOT NULL | 正=增 负=减(usd 用美元,points 用积分) |
|
||||
| `balance_after` | REAL NOT NULL | 该轨本次变动后余额快照 |
|
||||
| `ref_type` | TEXT | `usage_log` / `recharge_record` / `manual` / `stripe` 等 |
|
||||
| `ref_id` | TEXT | 关联记录 id/public_id |
|
||||
| `model` | TEXT | 消费类:模型名或工作流名(供聚合) |
|
||||
| `operator` | TEXT | 操作者:管理员名 / `system` / `user` |
|
||||
| `note` | TEXT | 备注 |
|
||||
| `created_at` | TEXT DEFAULT (datetime('now')) | |
|
||||
|
||||
索引:`(user_id, created_at DESC)`、`(track, entry_type)`、`(user_id, track)`。
|
||||
|
||||
### 新表 `user_credits`(积分余额缓存)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `user_id` | INTEGER PK | |
|
||||
| `points_balance` | REAL NOT NULL DEFAULT 0 | 当前积分余额(热路径读写) |
|
||||
| `points_total_recharged` | REAL NOT NULL DEFAULT 0 | 累计充值积分(统计) |
|
||||
| `updated_at` | TEXT | |
|
||||
|
||||
### 新表 `recharge_tiers`(充值档位,admin 可配)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | INTEGER PK AUTOINCREMENT | |
|
||||
| `track` | TEXT NOT NULL | `usd` / `points` |
|
||||
| `amount_usd` | REAL NOT NULL | 支付金额(USD) |
|
||||
| `grant_amount` | REAL NOT NULL | 到账量(USD 数或积分数) |
|
||||
| `bonus` | REAL NOT NULL DEFAULT 0 | 赠送量 |
|
||||
| `title` | TEXT | 档位标题 |
|
||||
| `sort_order` | INTEGER DEFAULT 0 | 排序 |
|
||||
| `enabled` | INTEGER DEFAULT 1 | 启用 |
|
||||
|
||||
### 余额派生策略:ledger + 缓存双写
|
||||
|
||||
- **USD 轨**:`max_cost/used_cost` 作快速余额缓存(拦截/展示零延迟读),每笔变动**同事务**写一条 `credit_ledger(track=usd)`。
|
||||
- **积分轨**:`user_credits.points_balance` 作缓存,每笔变动**同事务**写 `credit_ledger(track=points)`。
|
||||
- **关键不变量**:任何余额变动 = 缓存字段更新 + 一条 ledger,**同一 DB 事务**,事务失败整体回滚,绝不只改其一。
|
||||
- ledger 是审计/明细/对账真相;缓存字段是热路径性能优化。
|
||||
|
||||
## 双轨记账接入
|
||||
|
||||
原则:所有动余额处,同事务"改缓存 + 写一条 ledger"。
|
||||
|
||||
### USD 轨
|
||||
|
||||
| 变动 | 现有代码 | 改造 |
|
||||
|---|---|---|
|
||||
| 充值到账 | `credit_user_balance`(payment.rs) | 同事务加写 ledger(usd, recharge) |
|
||||
| 消费扣费 | `increment_key_usage`/`record_billing` | 同事务加写 ledger(usd, consume, model=模型名) |
|
||||
| 管理员增减 | `adjust_user_balance` | 同事务加写 ledger(usd, admin_adjust, operator) |
|
||||
| 额度拦截 | proxy `used_cost>=max_cost`→429 | 不变,补低额度预警信号 |
|
||||
|
||||
### 积分轨(基本新建)
|
||||
|
||||
| 变动 | 现状 | 新增 |
|
||||
|---|---|---|
|
||||
| 充值到账 | comfy_cloud 确认到账现加 USD | 改为加 `points_balance` + ledger(points, recharge) |
|
||||
| 消费扣费 | 工作流不扣 | 工作流执行**成功后**按单价扣积分 + ledger(points, consume, model=工作流名);失败不扣 |
|
||||
| 管理员发放/增减 | 无 | 新增 `adjust_user_credits` + ledger(points, admin_adjust) |
|
||||
| 积分拦截 | 无 | 工作流执行**前**检查 `points_balance >= 单价`,不足拒绝;低额度预警 |
|
||||
|
||||
- 积分消费扣费点:`workflow/executor.rs` 的 `run_workflow` 成功收尾处(有 `ctx.user_id`),与现有"工作流按用户 key 记 USD 消费"平行。
|
||||
- **comfy_cloud 充值改向积分轨**:行为变更,历史 USD **不迁移**,仅对切换后新充值生效,用 config 开关控制(可回滚)。
|
||||
|
||||
## 积分消费规则:A+C 混合
|
||||
|
||||
- **A 固定/分级单价**(主):工作流执行扣固定积分(config 默认值,可按工作流类型分级)。可预测、支持并发。
|
||||
- **C 对账校准**(兜底):后台 cron 拉 Comfy 总余额对账,偏差超阈值提示管理员调单价。
|
||||
|
||||
## 四块功能
|
||||
|
||||
### A 消费明细(用户侧,明细+聚合)
|
||||
- 新建 `GET /api/user/billing/ledger`:查该用户 credit_ledger,可按 track/type/时间过滤。
|
||||
- 新建 `GET /api/user/billing/summary`:按天/按模型聚合,双轨分开。
|
||||
- 扩展 `UsageRecords.tsx`:双轨切换 + 聚合汇总卡 + 明细列表。
|
||||
|
||||
### B 充值档位(本期可配置,订阅后期)
|
||||
- 新建 `recharge_tiers` 表 + admin CRUD `GET/POST/PUT/DELETE /api/admin/recharge-tiers`。
|
||||
- 改造用户充值:档位从 DB 读(替代 env 写死的 `USER_RECHARGE_OPTIONS`)。
|
||||
- 扩展 `UserRecharge.tsx`(档位从接口拉)+ 新建 admin `RechargeTiers` 配置页。
|
||||
|
||||
### C 超额 + 预警(两轨硬拦 + 预警)
|
||||
- USD 拦截已有(不动);积分拦截新建(工作流执行前)。
|
||||
- 余额接口加 `lowBalance` 标志(余额 < 阈值,config 可配默认 20%)。
|
||||
- Dashboard/充值页/视频助手:余额低显示预警条。
|
||||
|
||||
### D 审计对账(管理员侧,全量流水)
|
||||
- 新建 `GET /api/admin/billing/ledger`:跨用户查 credit_ledger(按 user/track/type/时间过滤、分页)。
|
||||
- 新建 admin `BillingAudit` 页:全量变动流水表(谁/何时/给谁/加减/原因),站点管理风格。
|
||||
- 对账校准:cron 拉 Comfy 总余额,与积分消费 ledger 比对,偏差超阈值写 log + admin 告警标志。**不自动改数据**,仅提示,管理员手动调单价。
|
||||
|
||||
## 错误处理(不变量)
|
||||
|
||||
- 余额变动 = 缓存 + ledger 同事务;失败整体回滚。
|
||||
- 消费扣费 best-effort 不阻断响应(沿用 record_billing 原则)。
|
||||
- 积分轨:拦截是前置硬检查,扣费是后置;后置扣费失败已产出结果 → 记日志补偿。
|
||||
- comfy 充值改向积分:config 开关控制,可回滚。
|
||||
|
||||
## 测试策略
|
||||
|
||||
- 单元:ledger 写入+缓存同步事务一致性;积分拦截(不足拒绝);单价扣费;充值进积分;管理员调整双轨。
|
||||
- 复用现有 payment/billing 测试框架(in-memory SQLite)。
|
||||
|
||||
## 实现顺序(分 6 期,每期可独立上线)
|
||||
|
||||
1. **地基**:credit_ledger + user_credits 表 + 迁移;USD 轨记账接进 ledger(充值/消费/调整三处双写)。
|
||||
2. **积分轨**:工作流积分扣费 + 拦截 + comfy 充值改进积分 + 管理员积分调整。
|
||||
3. **A 消费明细**:用户侧 ledger/summary 接口 + UsageRecords 扩展。
|
||||
4. **B 充值档位**:recharge_tiers 表 + admin 配置页 + 用户充值页接 DB。
|
||||
5. **C 预警**:余额接口加 lowBalance + 前端预警条。
|
||||
6. **D 审计 + 对账**:admin BillingAudit 页 + cron 对账校准。
|
||||
|
||||
## 新增清单小结
|
||||
|
||||
- **表**:credit_ledger(核心)、user_credits(积分缓存)、recharge_tiers(档位)。
|
||||
- **后端接口**:user billing ledger/summary、admin recharge-tiers CRUD、admin billing ledger、adjust_user_credits、积分拦截/扣费。
|
||||
- **前端页面**:admin RechargeTiers、admin BillingAudit;扩展 UsageRecords / UserRecharge / Dashboard。
|
||||
- **cron**:Comfy 余额对账校准任务。
|
||||
@@ -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 风格。
|
||||
@@ -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,58 +0,0 @@
|
||||
# workflow_ui/mod.rs 续拆(6 子模块)设计
|
||||
|
||||
日期:2026-06-18
|
||||
分支:`feat/comfy-cloud-adapter`
|
||||
状态:已通过 brainstorming,待用户复审
|
||||
|
||||
## 背景
|
||||
|
||||
上一轮已把 `workflow_ui.rs`(6181 行)拆成 8 模块,但 `mod.rs` 仍 4095 行——一批
|
||||
未在上轮清单内的函数(native proxy / repair 链 / bootstrap 注入 / run 转换 / ws / 模型注入)
|
||||
留在了 mod.rs。本轮继续把这 6 组抽出,让 mod.rs 瘦到 ~1000-1200 行(只剩 routes()
|
||||
+ 共享 struct + 小工具)。
|
||||
|
||||
延续上轮纪律:**纯机械移动,函数体一字不改**,只调可见性 + use,编译器 + 5 测试验证。
|
||||
|
||||
## 已确认决策
|
||||
|
||||
1. 续拆 workflow_ui/mod.rs 残留。
|
||||
2. 拆成 6 子模块(见下表)。
|
||||
3. 纯机械移动,零逻辑改动。
|
||||
4. 安全:每抽一组即 cargo build -j 2 + 5 测试保持;末尾各文件行数核对。
|
||||
|
||||
## 子模块划分
|
||||
|
||||
| 子模块 | 函数组 |
|
||||
|--------|--------|
|
||||
| `model_inject.rs` | comfy_combo_input, inject_model_choices, is_boosapi_extension_node_type, workflow_node_defs_with_models, inject_boosapi_object_info, builtin_model_folders, inject_boosapi_model_folders, workflow_model_folder_payload, native_model_folder_payload_or_fallback, model_payload_items_from_names, normalize_model_item_name, workflow_model_display_aliases, model_payload_item_with_name, merge_model_payload_items, normalize_model_folder_payload |
|
||||
| `repair.rs` | inject_native_prompt_cloud_api_key, repair_resize_image_mask_nodes(+_without_workflow), repair_text_generate_ltx2_prompt_nodes, repair_dynamic_combo_nodes(+ dynamic_combo_* 全部 helper), repair_wan2_api_nodes(+ wan2_* / is_wan2_* helper), collect_workflow_nodes, prompt_subgraph_node_id, id_to_prompt_key |
|
||||
| `native_proxy.rs` | native_workflow_enabled/base/http_path/request/json/json_cached/response/view_response/video_response/file_response/raw_response/api_proxy, archive_native_workflow_asset, repair_native_prompt_body_value, workflow_cache_kind_from_key, workflow_cache_status, upload_image(+_to_local_backend/_to_native_backend), upload_mask, timestamp_ms |
|
||||
| `run_convert.rs` | workflow_run_to_history/_task/_queue_task/_prompt_tuple, run_status_messages, run_outputs_for_comfy, node_output_for_comfy, comfy_media_items, normalize_comfy_media_item, workflow_text_output, workflow_graph_to_comfy_workflow, workflow_timestamp_ms |
|
||||
| `ws.rs` | workflow_session_sender, publish_workflow_ws_event, workflow_node_label, workflow_progress_callback_with_run_sender, publish_run_events, workflow_ws, native_workflow_ws_bridge |
|
||||
| `bootstrap.rs` | inject_bootstrap, workflow_secret_cleanup_bootstrap, inject_external_bootstrap, inject_enhanced_bootstrap |
|
||||
|
||||
> 留 mod.rs:routes()、共享 struct(WorkflowCaller/CachedJson/各 Query/Payload)、跨多模块小工具
|
||||
> (safe_view_path/user_home/guess_mime_from_path/parse_boolish/auth_caller_optional/
|
||||
> workflow_context/parse_workflow_cookie/build_query_string/simple_urlencode/各 workflow_widget_*
|
||||
> validation helper 等)、experiment_models/view_metadata/custom_nodes_i18n/internal_* 等零散 handler。
|
||||
> 实现时按实际依赖判定:被多组共用的留 mod.rs `pub(super)`。
|
||||
|
||||
## 安全策略
|
||||
|
||||
同上轮:每抽一组 → `cargo build -j 2`(编译器是主安全网,纯移动)→ `cargo test -p boosapi
|
||||
--lib routes::workflow_ui`(5 测试基线不减)→ 提交。跨模块依赖的私有符号按编译报错逐个
|
||||
改 `pub(super)`。子模块内部 helper 也 `pub(super)` 或保持模块私有(看是否跨文件用)。
|
||||
|
||||
## 测试
|
||||
|
||||
编译器逐组验证;5 个现有测试全程保持;末尾核对 8+6 个子文件行数,mod.rs 应 <1500。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不改逻辑、不改路由、不动 handler 行为。
|
||||
- 不拆其他巨文件(oauth/payment/digital_human 各自后续循环)。
|
||||
- 不顺手优化函数实现。
|
||||
|
||||
## 提交约定
|
||||
|
||||
分支 `feat/comfy-cloud-adapter`。每抽 1 组 + 编译通过即提交。中文 commit。
|
||||
@@ -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。
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user