Merge remote-tracking branch 'origin/feat/comfy-cloud-adapter' into _merge_test

# Conflicts:
#	src/routes/admin/compat.rs
#	src/routes/admin/downstream_keys.rs
#	src/routes/auth.rs
#	src/routes/comfyui/mod.rs
#	src/routes/comfyui_agent.rs
#	src/routes/events.rs
#	src/routes/money_printer.rs
#	src/routes/oauth.rs
#	src/routes/proxy/mod.rs
#	src/routes/video_agent_legacy.rs
This commit is contained in:
boos
2026-06-22 10:14:52 +08:00
160 changed files with 26496 additions and 11151 deletions
+14
View File
@@ -98,6 +98,10 @@ pub struct PixelleVideoConfig {
pub video_fps: i64,
pub poll_interval_secs: u64,
pub timeout_secs: u64,
#[serde(default = "default_pixelle_config_path")]
pub config_path: String,
#[serde(default = "default_pixelle_compose_dir")]
pub compose_dir: String,
}
#[derive(Debug, Deserialize, Clone)]
@@ -165,6 +169,14 @@ fn default_pixelle_backend_provider() -> String {
"comfy".to_string()
}
fn default_pixelle_config_path() -> String {
"/opt/Pixelle-Video/config.yaml".to_string()
}
fn default_pixelle_compose_dir() -> String {
"/opt/Pixelle-Video".to_string()
}
fn default_workflow_points_cost() -> f64 {
10.0
}
@@ -217,6 +229,8 @@ impl Default for PixelleVideoConfig {
video_fps: 30,
poll_interval_secs: 5,
timeout_secs: 900,
config_path: default_pixelle_config_path(),
compose_dir: default_pixelle_compose_dir(),
}
}
}
+7 -1
View File
@@ -57,9 +57,15 @@ impl AppState {
reqwest::Client::new()
});
let db = Arc::new(db);
// Hand the payment module a process-wide DB handle so the Comfy Firebase
// refresh path can persist a rotated refresh token without threading
// &SqlitePool through every balance/checkout call site.
services::payment::init_payment_db(db.clone());
Self {
config,
db: Arc::new(db),
db,
http,
comfyui,
builtin_keys: Arc::new(builtin_keys),
+4 -6
View File
@@ -144,12 +144,10 @@ async fn main() -> anyhow::Result<()> {
let builtin = builtin_keys::BuiltinKeys {
comfyui_agent: comfyui_agent_key,
};
let runtime_auth = runtime_auth::RuntimeAuthSettings::load(
&pool,
config.auth.proxy_token.clone(),
)
.await
.context("failed to load runtime auth settings")?;
let runtime_auth =
runtime_auth::RuntimeAuthSettings::load(&pool, config.auth.proxy_token.clone())
.await
.context("failed to load runtime auth settings")?;
// Build app state
let state = AppState::new(config.clone(), pool, builtin, runtime_auth);
+2
View File
@@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize};
pub mod templates;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum CliProfileKind {
Codex,
+152
View File
@@ -0,0 +1,152 @@
//! Client integration templates — copy-ready config snippets for pointing
//! popular AI clients at this BoosAPI gateway.
//!
//! Ported from AQBot's `list_gateway_templates` (`commands/gateway.rs:665-730`).
//! AQBot is a desktop app whose gateway listens on `localhost:8080`, so its
//! templates hardcode that origin. BoosAPI is a server-side SaaS, so the origin
//! is injected at request time (public domain / port) — see
//! `routes::admin::cli_templates`.
//!
//! Scope guard (per the integration analysis): this module only returns config
//! **strings + copy hints**. It deliberately does NOT rewrite any local CLI
//! config files (AQBot's `cli_config.rs` desktop behavior) — that has no meaning
//! on a server and is explicitly out of scope.
use serde::Serialize;
/// A copy-ready integration snippet for one client.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct GatewayTemplate {
/// Stable identifier (e.g. `"cursor"`).
pub id: String,
/// Human-facing name (e.g. `"Cursor"`).
pub name: String,
/// Target client slug, mirrors `id` (kept for frontend parity with AQBot).
pub target: String,
/// Content format hint for the UI renderer: `"json"` | `"markdown"`.
pub format: String,
/// The snippet itself, with the live gateway origin already substituted.
pub content: String,
/// Short instruction on where to put the snippet.
pub copy_hint: Option<String>,
}
/// Build the full template list for a given gateway `origin`
/// (scheme + host + optional port, no trailing slash, e.g.
/// `https://api.boos.lat`).
///
/// URL conventions differ per client:
/// - OpenAI-compatible clients (Cursor, VS Code Continue, generic) want the
/// `/v1` base (`{origin}/v1`).
/// - Claude Code's `ANTHROPIC_BASE_URL` must be the bare origin — it appends
/// `/v1/messages` itself, so a `/v1` suffix would double it.
pub fn list_templates(origin: &str) -> Vec<GatewayTemplate> {
let origin = origin.trim_end_matches('/');
let openai_base = format!("{origin}/v1");
vec![
GatewayTemplate {
id: "cursor".to_string(),
name: "Cursor".to_string(),
target: "cursor".to_string(),
format: "json".to_string(),
content: format!(
"{{\n \"openai.apiKey\": \"YOUR_KEY\",\n \"openai.apiBaseUrl\": \"{openai_base}\"\n}}"
),
copy_hint: Some("Add to Cursor User settings.json".to_string()),
},
GatewayTemplate {
id: "vscode".to_string(),
name: "VS Code Continue".to_string(),
target: "vscode".to_string(),
format: "json".to_string(),
content: format!(
"{{\n \"models\": [{{\n \"title\": \"BoosAPI Gateway\",\n \"provider\": \"openai\",\n \"apiBase\": \"{openai_base}\",\n \"apiKey\": \"YOUR_KEY\"\n }}]\n}}"
),
copy_hint: Some("Add to ~/.continue/config.json".to_string()),
},
GatewayTemplate {
id: "claude_code".to_string(),
name: "Claude Code".to_string(),
target: "claude_code".to_string(),
format: "markdown".to_string(),
// Claude Code appends `/v1/messages` to ANTHROPIC_BASE_URL, so the
// base is the bare origin (no `/v1`).
content: format!(
"export ANTHROPIC_AUTH_TOKEN=\"YOUR_KEY\"\nexport ANTHROPIC_BASE_URL=\"{origin}\""
),
copy_hint: Some("Add to your shell profile (~/.bashrc or ~/.zshrc)".to_string()),
},
GatewayTemplate {
id: "openai_compatible".to_string(),
name: "OpenAI Compatible".to_string(),
target: "openai_compatible".to_string(),
format: "json".to_string(),
content: format!(
"{{\n \"api_key\": \"YOUR_KEY\",\n \"base_url\": \"{openai_base}\",\n \"model\": \"gpt-4\"\n}}"
),
copy_hint: Some("Use these settings in any OpenAI-compatible client".to_string()),
},
]
}
/// Look up a single template's content by id for the given `origin`.
/// Ports AQBot's `copy_gateway_template`.
pub fn find_template(id: &str, origin: &str) -> Option<GatewayTemplate> {
list_templates(origin).into_iter().find(|t| t.id == id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lists_all_four_templates() {
let templates = list_templates("https://api.boos.lat");
let ids: Vec<&str> = templates.iter().map(|t| t.id.as_str()).collect();
assert_eq!(ids, ["cursor", "vscode", "claude_code", "openai_compatible"]);
}
#[test]
fn openai_clients_get_v1_base() {
let templates = list_templates("https://api.boos.lat");
for id in ["cursor", "vscode", "openai_compatible"] {
let t = templates.iter().find(|t| t.id == id).unwrap();
assert!(
t.content.contains("https://api.boos.lat/v1"),
"{id} should embed the /v1 base, got: {}",
t.content
);
}
}
#[test]
fn claude_code_base_has_no_v1_suffix() {
// Claude Code appends /v1/messages itself; a /v1 base would double it.
let t = find_template("claude_code", "https://api.boos.lat").unwrap();
assert!(t.content.contains("ANTHROPIC_BASE_URL=\"https://api.boos.lat\""));
assert!(!t.content.contains("api.boos.lat/v1"));
}
#[test]
fn trailing_slash_in_origin_is_normalized() {
let t = find_template("cursor", "https://api.boos.lat/").unwrap();
assert!(t.content.contains("https://api.boos.lat/v1"));
assert!(!t.content.contains("//v1"));
}
#[test]
fn unknown_id_returns_none() {
assert!(find_template("nope", "https://api.boos.lat").is_none());
}
#[test]
fn templates_serialize_to_expected_shape() {
let t = find_template("cursor", "http://localhost:4000").unwrap();
let value = serde_json::to_value(&t).unwrap();
assert_eq!(value["id"], "cursor");
assert_eq!(value["format"], "json");
assert!(value["content"].as_str().unwrap().contains("localhost:4000/v1"));
assert!(value["copy_hint"].is_string());
}
}
+122 -35
View File
@@ -589,7 +589,8 @@ pub async fn execute_video_generation(
}
fn comfy_video_template_id(route: &UpstreamRoute) -> Option<String> {
route.base_url
route
.base_url
.strip_prefix("comfy://")
.or_else(|| route.base_url.strip_prefix("comfy-template://"))
.or_else(|| route.model.strip_prefix("comfy://"))
@@ -614,14 +615,16 @@ async fn execute_comfy_video_generation(
let workflow = crate::workflow::storage::get_workflow(&state.db, template_id, None, true)
.await
.map_err(|err| AppError::BadRequest(format!("Comfy 视频模型模板不可用: {err}")))?;
let mut prompt = workflow_prompt_from_graph(&workflow.graph)
.ok_or_else(|| AppError::BadRequest("Comfy 视频模型模板缺少 prompt/workflow JSON".into()))?;
let mut prompt = workflow_prompt_from_graph(&workflow.graph).ok_or_else(|| {
AppError::BadRequest("Comfy 视频模型模板缺少 prompt/workflow JSON".into())
})?;
if prompt_contains_class_type(&prompt, "BoosAPITextToVideo") {
return Err(AppError::BadRequest(
"Comfy 视频模型模板不能包含 BoosAPITextToVideo 自定义节点,请选择真实 Comfy 视频节点模板".into(),
));
}
let mut mapping = comfy_mapping_from_graph(&workflow.graph).unwrap_or_else(default_comfy_mapping);
let mut mapping =
comfy_mapping_from_graph(&workflow.graph).unwrap_or_else(default_comfy_mapping);
if !mapping_has_node(&mapping, &["prompt", "positive", "text", "promptNode"]) {
mapping = infer_comfy_mapping(&prompt);
}
@@ -644,7 +647,9 @@ async fn execute_comfy_video_generation(
.or_else(|| response.pointer("/data/prompt_id"))
.or_else(|| response.pointer("/data/promptId")),
)
.ok_or_else(|| AppError::UpstreamError(format!("Comfy response missing prompt_id: {response}")))?;
.ok_or_else(|| {
AppError::UpstreamError(format!("Comfy response missing prompt_id: {response}"))
})?;
Ok(json!({
"id": prompt_id,
@@ -732,7 +737,8 @@ fn scalar_string(value: Option<&Value>) -> Option<String> {
}
fn workflow_prompt_from_graph(graph: &Value) -> Option<Value> {
graph.get("prompt")
graph
.get("prompt")
.cloned()
.or_else(|| graph.get("workflow").cloned())
.or_else(|| graph.get("comfyPrompt").cloned())
@@ -741,7 +747,8 @@ fn workflow_prompt_from_graph(graph: &Value) -> Option<Value> {
}
fn comfy_mapping_from_graph(graph: &Value) -> Option<Value> {
graph.pointer("/metadata/moneyPrinter/nodeMapping")
graph
.pointer("/metadata/moneyPrinter/nodeMapping")
.or_else(|| graph.pointer("/moneyPrinter/nodeMapping"))
.or_else(|| graph.pointer("/metadata/video/nodeMapping"))
.or_else(|| graph.pointer("/video/nodeMapping"))
@@ -772,11 +779,24 @@ fn graph_to_comfy_prompt(graph: &Value) -> Option<Value> {
.unwrap_or_default();
prompt.insert(id, json!({ "class_type": class_type, "inputs": inputs }));
}
for edge in graph.get("edges").and_then(Value::as_array).into_iter().flatten() {
let Some(from) = edge.get("from").and_then(Value::as_str) else { continue; };
let Some(to) = edge.get("to").and_then(Value::as_str) else { continue; };
let Some((from_id, _from_port)) = from.split_once('.') else { continue; };
let Some((to_id, to_field)) = to.split_once('.') else { continue; };
for edge in graph
.get("edges")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let Some(from) = edge.get("from").and_then(Value::as_str) else {
continue;
};
let Some(to) = edge.get("to").and_then(Value::as_str) else {
continue;
};
let Some((from_id, _from_port)) = from.split_once('.') else {
continue;
};
let Some((to_id, to_field)) = to.split_once('.') else {
continue;
};
if let Some(inputs) = prompt
.get_mut(to_id)
.and_then(Value::as_object_mut)
@@ -806,7 +826,12 @@ fn mapping_value<'a>(mapping: &'a Value, keys: &[&str]) -> Option<&'a Value> {
fn mapping_node_id(mapping: &Value, keys: &[&str]) -> Option<String> {
mapping_value(mapping, keys).and_then(|value| {
if value.is_object() {
scalar_string(value.get("nodeId").or_else(|| value.get("node_id")).or(Some(value)))
scalar_string(
value
.get("nodeId")
.or_else(|| value.get("node_id"))
.or(Some(value)),
)
} else {
scalar_string(Some(value))
}
@@ -829,7 +854,10 @@ fn mapping_has_node(mapping: &Value, keys: &[&str]) -> bool {
mapping_node_id(mapping, keys).is_some_and(|value| !value.trim().is_empty())
}
fn first_node_by_class(prompt: &serde_json::Map<String, Value>, class_type: &str) -> Option<String> {
fn first_node_by_class(
prompt: &serde_json::Map<String, Value>,
class_type: &str,
) -> Option<String> {
prompt
.iter()
.find(|(_, node)| node.get("class_type").and_then(Value::as_str) == Some(class_type))
@@ -843,11 +871,22 @@ fn infer_comfy_mapping(prompt: &Value) -> Value {
let prompt_node = object
.iter()
.find(|(_, node)| {
let class_type = node.get("class_type").and_then(Value::as_str).unwrap_or_default().to_ascii_lowercase();
let class_type = node
.get("class_type")
.and_then(Value::as_str)
.unwrap_or_default()
.to_ascii_lowercase();
let inputs = node
.get("inputs")
.and_then(Value::as_object)
.map(|value| value.keys().cloned().collect::<Vec<_>>().join(" ").to_ascii_lowercase())
.map(|value| {
value
.keys()
.cloned()
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase()
})
.unwrap_or_default();
class_type.contains("text")
|| class_type.contains("prompt")
@@ -875,19 +914,29 @@ fn infer_comfy_mapping(prompt: &Value) -> Value {
fn apply_node_input(prompt: &mut Value, node_id: &str, field: &str, value: Value) -> AppResult<()> {
if node_id.trim().is_empty() {
return Err(AppError::BadRequest("Comfy 视频模型节点映射包含空 nodeId".into()));
return Err(AppError::BadRequest(
"Comfy 视频模型节点映射包含空 nodeId".into(),
));
}
let Some(node) = prompt.get_mut(node_id) else {
return Err(AppError::BadRequest(format!("Comfy prompt missing node {node_id}")));
return Err(AppError::BadRequest(format!(
"Comfy prompt missing node {node_id}"
)));
};
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
return Err(AppError::BadRequest(format!("Comfy node {node_id} has no inputs")));
return Err(AppError::BadRequest(format!(
"Comfy node {node_id} has no inputs"
)));
};
inputs.insert(field.to_string(), value);
Ok(())
}
fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value) -> AppResult<String> {
fn inject_comfy_video_inputs(
prompt: &mut Value,
mapping: &Value,
body: &Value,
) -> AppResult<String> {
let video_prompt = scalar_string(body.get("prompt"))
.ok_or_else(|| AppError::BadRequest("prompt is required for Comfy video models".into()))?;
let prompt_node = mapping_node_id(mapping, &["prompt", "positive", "text", "promptNode"])
@@ -895,27 +944,56 @@ fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value)
apply_node_input(
prompt,
&prompt_node,
&mapping_field(mapping, &["prompt", "positive", "text", "promptNode"], "prompt"),
&mapping_field(
mapping,
&["prompt", "positive", "text", "promptNode"],
"prompt",
),
json!(video_prompt),
)?;
if let Some(negative_node) = mapping_node_id(mapping, &["negativePrompt", "negative", "negativeNode"]) {
if let Some(negative_node) =
mapping_node_id(mapping, &["negativePrompt", "negative", "negativeNode"])
{
let negative = scalar_string(body.get("negative_prompt").or_else(|| body.get("negative")))
.unwrap_or_else(|| "low quality, blurry, distorted, watermark, text artifacts".to_string());
.unwrap_or_else(|| {
"low quality, blurry, distorted, watermark, text artifacts".to_string()
});
apply_node_input(
prompt,
&negative_node,
&mapping_field(mapping, &["negativePrompt", "negative", "negativeNode"], "text"),
&mapping_field(
mapping,
&["negativePrompt", "negative", "negativeNode"],
"text",
),
json!(negative),
)?;
}
for (keys, request_keys, default_field) in [
(&["ratio", "ratioNode"][..], &["ratio", "aspectRatio", "aspect_ratio"][..], "ratio"),
(&["resolution", "resolutionNode"][..], &["resolution", "size"][..], "resolution"),
(&["duration", "durationNode"][..], &["duration"][..], "duration"),
(
&["ratio", "ratioNode"][..],
&["ratio", "aspectRatio", "aspect_ratio"][..],
"ratio",
),
(
&["resolution", "resolutionNode"][..],
&["resolution", "size"][..],
"resolution",
),
(
&["duration", "durationNode"][..],
&["duration"][..],
"duration",
),
] {
if let Some(node_id) = mapping_node_id(mapping, keys) {
if let Some(value) = request_keys.iter().find_map(|key| body.get(*key).cloned()) {
apply_node_input(prompt, &node_id, &mapping_field(mapping, keys, default_field), value)?;
apply_node_input(
prompt,
&node_id,
&mapping_field(mapping, keys, default_field),
value,
)?;
}
}
}
@@ -929,9 +1007,13 @@ fn prompt_contains_class_type(prompt: &Value, class_type: &str) -> bool {
.get("class_type")
.and_then(Value::as_str)
.is_some_and(|value| value == class_type)
|| object.values().any(|value| prompt_contains_class_type(value, class_type))
|| object
.values()
.any(|value| prompt_contains_class_type(value, class_type))
}
Value::Array(items) => items.iter().any(|value| prompt_contains_class_type(value, class_type)),
Value::Array(items) => items
.iter()
.any(|value| prompt_contains_class_type(value, class_type)),
_ => false,
}
}
@@ -1024,7 +1106,10 @@ fn comfy_failure_message(value: &Value) -> Option<String> {
let status = scalar_string(value.get("status").or_else(|| value.get("state")))
.unwrap_or_default()
.to_ascii_lowercase();
if matches!(status.as_str(), "failed" | "error" | "cancelled" | "canceled") {
if matches!(
status.as_str(),
"failed" | "error" | "cancelled" | "canceled"
) {
return Some(
scalar_string(value.pointer("/execution_error/exception_message"))
.or_else(|| scalar_string(value.get("error")))
@@ -1038,10 +1123,12 @@ fn comfy_failure_message(value: &Value) -> Option<String> {
fn comfy_output_objects(value: &Value) -> bool {
match value {
Value::Object(object) => {
["videos", "gifs", "images"]
.iter()
.any(|key| object.get(*key).and_then(Value::as_array).is_some_and(|items| !items.is_empty()))
|| object.values().any(comfy_output_objects)
["videos", "gifs", "images"].iter().any(|key| {
object
.get(*key)
.and_then(Value::as_array)
.is_some_and(|items| !items.is_empty())
}) || object.values().any(comfy_output_objects)
}
Value::Array(items) => items.iter().any(comfy_output_objects),
_ => false,
+6 -3
View File
@@ -305,8 +305,7 @@ fn account_response_value(
route_id,
channel_id,
log_id,
)) =
runtime_health
)) = runtime_health
{
let ok = status == "success";
let is_degraded_latency =
@@ -314,7 +313,11 @@ fn account_response_value(
let model = model_actual
.as_ref()
.filter(|value| !value.trim().is_empty())
.or_else(|| model_requested.as_ref().filter(|value| !value.trim().is_empty()));
.or_else(|| {
model_requested
.as_ref()
.filter(|value| !value.trim().is_empty())
});
json!({
"state": if !ok {
"unhealthy"
+135
View File
@@ -0,0 +1,135 @@
//! `GET /api/cli-profiles/templates` — copy-ready client integration snippets.
//!
//! Returns config blobs (Cursor / VS Code Continue / Claude Code /
//! OpenAI-compatible) with this gateway's live origin already substituted, so
//! the admin UI can render "copy & paste" cards. The actual snippet content
//! lives in `proxy::cli_profiles::templates`.
//!
//! Origin resolution priority:
//! 1. `?base_url=` query override (operator-supplied public URL)
//! 2. `X-Forwarded-Proto` + `Host` headers (the address the client actually
//! reached us on — correct behind a TLS-terminating reverse proxy)
//! 3. `Host` header alone (scheme inferred as http)
//! 4. config `server.host:port` fallback (`0.0.0.0`/`::` rewritten to
//! `127.0.0.1`, matching the comfyui-agent local-base convention)
use crate::{proxy::cli_profiles::templates, AppState};
use axum::{
extract::{Query, State},
http::HeaderMap,
Json,
};
use serde::Deserialize;
use serde_json::{json, Value};
#[derive(Debug, Deserialize)]
pub struct TemplatesQuery {
/// Optional explicit public base URL, e.g. `https://api.boos.lat`.
/// A trailing `/v1` (or `/`) is stripped so callers can pass either form.
pub base_url: Option<String>,
}
/// Resolve the gateway origin (scheme + host[:port], no trailing slash) to
/// embed in the templates.
fn resolve_origin(state: &AppState, headers: &HeaderMap, query: &TemplatesQuery) -> String {
// 1. Explicit override wins. Normalize away a trailing `/v1` or `/`.
if let Some(raw) = query.base_url.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) {
let trimmed = raw.trim_end_matches('/');
let trimmed = trimmed.strip_suffix("/v1").unwrap_or(trimmed);
return trimmed.trim_end_matches('/').to_string();
}
// 2/3. Derive from the inbound request headers.
if let Some(host) = header_str(headers, "host").filter(|h| !h.is_empty()) {
let scheme = header_str(headers, "x-forwarded-proto")
.map(|s| s.split(',').next().unwrap_or(s).trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "http".to_string());
return format!("{scheme}://{host}");
}
// 4. Config fallback. Mirrors comfyui_agent::local_base host rewrite.
let host = if state.config.server.host == "0.0.0.0" || state.config.server.host == "::" {
"127.0.0.1"
} else {
state.config.server.host.as_str()
};
format!("http://{host}:{}", state.config.server.port)
}
fn header_str<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
headers.get(name).and_then(|v| v.to_str().ok()).map(str::trim)
}
/// `GET /api/cli-profiles/templates` — list copy-ready integration snippets.
pub async fn list_templates(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<TemplatesQuery>,
) -> Json<Value> {
let origin = resolve_origin(&state, &headers, &query);
let templates = templates::list_templates(&origin);
Json(json!({
"data": {
"origin": origin,
"templates": templates,
}
}))
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
let mut map = HeaderMap::new();
for (k, v) in pairs {
map.insert(
axum::http::HeaderName::from_bytes(k.as_bytes()).unwrap(),
HeaderValue::from_str(v).unwrap(),
);
}
map
}
fn query(base: Option<&str>) -> TemplatesQuery {
TemplatesQuery {
base_url: base.map(|s| s.to_string()),
}
}
// resolve_origin doesn't touch the DB, so build a config-only state stub
// via the existing admin test helper would be heavy; instead these tests
// cover the header/override branches that don't need `state` fallback by
// exercising resolve_origin's pure header logic through a tiny shim.
#[test]
fn override_strips_v1_and_trailing_slash() {
// The override branch returns before touching `state`, so a null-ish
// pointer is never dereferenced — we validate via the helper directly.
let q = query(Some("https://api.boos.lat/v1/"));
// Reconstruct the override-only logic the function applies.
let raw = q.base_url.as_ref().unwrap().trim();
let trimmed = raw.trim_end_matches('/');
let trimmed = trimmed.strip_suffix("/v1").unwrap_or(trimmed);
assert_eq!(trimmed.trim_end_matches('/'), "https://api.boos.lat");
}
#[test]
fn host_header_with_forwarded_proto_builds_https_origin() {
let h = headers(&[("host", "api.boos.lat"), ("x-forwarded-proto", "https")]);
let scheme = header_str(&h, "x-forwarded-proto").unwrap();
let host = header_str(&h, "host").unwrap();
assert_eq!(format!("{scheme}://{host}"), "https://api.boos.lat");
}
#[test]
fn forwarded_proto_takes_first_value_when_chained() {
let h = headers(&[("x-forwarded-proto", "https, http")]);
let scheme = header_str(&h, "x-forwarded-proto")
.map(|s| s.split(',').next().unwrap_or(s).trim().to_string())
.unwrap();
assert_eq!(scheme, "https");
}
}
+165 -34
View File
@@ -20,7 +20,7 @@ use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use sqlx::Row;
use std::collections::HashMap;
use std::collections::{BTreeSet, HashMap};
static PROXY_TEST_JOBS: Lazy<RwLock<HashMap<String, ProxyTestJob>>> =
Lazy::new(|| RwLock::new(HashMap::new()));
@@ -1242,6 +1242,14 @@ async fn runtime_settings(State(state): State<AppState>) -> AppResult<Json<Value
false,
)),
);
body.insert(
"registrationDisabled".to_string(),
json!(setting_bool(
&settings,
"runtime.registration_disabled",
false,
)),
);
body.insert(
"codexUpstreamWebsocketEnabled".to_string(),
json!(setting_bool(
@@ -1541,6 +1549,7 @@ struct RuntimeSettingsUpdateRequest {
system_proxy_url: Option<String>,
payload_rules: Option<Value>,
model_availability_probe_enabled: Option<bool>,
registration_disabled: Option<bool>,
codex_upstream_websocket_enabled: Option<bool>,
responses_compact_fallback_to_responses_enabled: Option<bool>,
disable_cross_protocol_fallback: Option<bool>,
@@ -1624,6 +1633,14 @@ async fn update_runtime_settings(
)
.await?;
}
if let Some(disabled) = req.registration_disabled {
upsert_setting(
&state,
"runtime.registration_disabled",
bool_setting_value(disabled),
)
.await?;
}
if let Some(enabled) = req.codex_upstream_websocket_enabled {
upsert_setting(
&state,
@@ -1870,8 +1887,102 @@ async fn update_runtime_settings(
Ok(Json(body))
}
async fn brand_list() -> Json<Value> {
Json(json!({ "brands": ["BoosAPI"], "current": "BoosAPI" }))
async fn brand_list(State(state): State<AppState>) -> AppResult<Json<Value>> {
let mut brands = BTreeSet::new();
for model in load_model_brand_source_names(&state).await? {
if let Some(brand) = infer_model_brand(&model) {
brands.insert(brand);
}
}
Ok(Json(json!({
"brands": brands.into_iter().collect::<Vec<_>>(),
"current": Value::Null,
"scope": "model-brand",
})))
}
async fn load_model_brand_source_names(state: &AppState) -> AppResult<Vec<String>> {
let rows: Vec<(String,)> = sqlx::query_as(
r#"
SELECT model_name AS model FROM model_availability
UNION
SELECT model_name AS model FROM token_model_availability
UNION
SELECT model_pattern AS model FROM token_routes
UNION
SELECT COALESCE(model_mapping, '') AS model FROM token_routes
UNION
SELECT COALESCE(source_model, '') AS model FROM route_channels
"#,
)
.fetch_all(&*state.db)
.await?;
Ok(rows
.into_iter()
.map(|(model,)| model.trim().to_string())
.filter(|model| !model.is_empty() && !model.contains('*'))
.collect())
}
fn infer_model_brand(model: &str) -> Option<String> {
let normalized = model.trim();
if normalized.is_empty() {
return None;
}
let lower = normalized.to_ascii_lowercase();
let brand = if lower.starts_with("openai/")
|| lower.starts_with("gpt-")
|| lower.starts_with("o1")
|| lower.starts_with("o3")
|| lower.starts_with("o4")
{
"OpenAI"
} else if lower.starts_with("anthropic/") || lower.starts_with("claude") {
"Anthropic"
} else if lower.starts_with("google/") || lower.starts_with("gemini") {
"Google"
} else if lower.starts_with("deepseek") || lower.starts_with("deepseek-ai/") {
"DeepSeek"
} else if lower.starts_with("qwen")
|| lower.starts_with("qwen/")
|| lower.starts_with("alibaba/")
|| lower.starts_with("alibaba-cloud/")
{
"Qwen"
} else if lower.starts_with("glm")
|| lower.starts_with("zhipu")
|| lower.starts_with("bigmodel/")
{
"GLM"
} else if lower.starts_with("moonshot") || lower.starts_with("kimi") {
"Moonshot"
} else if lower.starts_with("minimax") || lower.starts_with("abab") {
"MiniMax"
} else if lower.starts_with("doubao")
|| lower.starts_with("seedance")
|| lower.starts_with("seedream")
|| lower.starts_with("volcengine/")
|| lower.starts_with("bytedance/")
{
"Doubao"
} else if lower.starts_with("meta-llama/") || lower.starts_with("llama") {
"Llama"
} else if lower.starts_with("mistral") || lower.starts_with("mixtral") {
"Mistral"
} else if lower.starts_with("x-ai/") || lower.starts_with("grok") {
"xAI"
} else if let Some((prefix, _)) = normalized.split_once('/') {
let prefix = prefix.trim();
if prefix.eq_ignore_ascii_case("boosapi") || prefix.eq_ignore_ascii_case("comfy") {
return None;
}
return Some(prefix.to_string());
} else {
return None;
};
Some(brand.to_string())
}
async fn test_system_proxy() -> Json<Value> {
@@ -1952,7 +2063,9 @@ fn normalize_proxy_test_path(path: &str) -> AppResult<String> {
));
}
if !normalized.starts_with('/') {
return Err(AppError::BadRequest("proxy test path must start with /".into()));
return Err(AppError::BadRequest(
"proxy test path must start with /".into(),
));
}
let base_path = proxy_test_base_path(normalized);
let allowed = [
@@ -1968,7 +2081,8 @@ fn normalize_proxy_test_path(path: &str) -> AppResult<String> {
"/v1/videos",
];
let allowed_exact = allowed.iter().any(|item| base_path == *item);
let allowed_video_task = base_path.starts_with("/v1/videos/") && base_path.len() > "/v1/videos/".len();
let allowed_video_task =
base_path.starts_with("/v1/videos/") && base_path.len() > "/v1/videos/".len();
let allowed_gemini = is_gemini_proxy_test_path(normalized);
if allowed_exact || allowed_video_task || allowed_gemini {
return Ok(strip_legacy_gemini_proxy_prefix(normalized).to_string());
@@ -1988,9 +2102,9 @@ fn path_suffix_for_proxy_test(path: &str) -> AppResult<String> {
if is_gemini_proxy_test_path(without_query) {
return Ok(without_query.trim_start_matches('/').to_string());
}
let suffix = without_query
.strip_prefix("/v1")
.ok_or_else(|| AppError::BadRequest(format!("unsupported proxy test path: {without_query}")))?;
let suffix = without_query.strip_prefix("/v1").ok_or_else(|| {
AppError::BadRequest(format!("unsupported proxy test path: {without_query}"))
})?;
Ok(if suffix.is_empty() {
"/".to_string()
} else {
@@ -2089,7 +2203,9 @@ fn native_gemini_proxy_test_url(base_url: &str, path: &str) -> AppResult<String>
let normalized = normalize_proxy_test_path(path)?;
let trimmed_base = base_url.trim().trim_end_matches('/');
if trimmed_base.is_empty() {
return Err(AppError::BadRequest("Gemini upstream base URL is empty".into()));
return Err(AppError::BadRequest(
"Gemini upstream base URL is empty".into(),
));
}
let base = if trimmed_base.ends_with("/v1beta") || trimmed_base.contains("/v1beta/") {
trimmed_base.to_string()
@@ -2281,7 +2397,10 @@ async fn execute_proxy_test(
) -> Result<Value, Value> {
if envelope.request_kind == "multipart"
|| envelope.multipart_fields.is_some()
|| envelope.multipart_files.as_ref().is_some_and(|files| !files.is_empty())
|| envelope
.multipart_files
.as_ref()
.is_some_and(|files| !files.is_empty())
{
return Err(error_json_for_proxy_test(
&envelope,
@@ -2403,11 +2522,9 @@ async fn stream_proxy_test(
Json(json!({ "error": error_json_for_proxy_test(&envelope, err) })),
)
})?;
let stream = response.bytes_stream().map(|chunk| {
chunk
.map_err(std::io::Error::other)
.map(bytes::Bytes::from)
});
let stream = response
.bytes_stream()
.map(|chunk| chunk.map_err(std::io::Error::other).map(bytes::Bytes::from));
return Response::builder()
.header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
.header(header::CACHE_CONTROL, "no-cache, no-transform")
@@ -2441,11 +2558,9 @@ async fn stream_proxy_test(
)
})?;
let stream = response.bytes_stream().map(|chunk| {
chunk
.map_err(std::io::Error::other)
.map(bytes::Bytes::from)
});
let stream = response
.bytes_stream()
.map(|chunk| chunk.map_err(std::io::Error::other).map(bytes::Bytes::from));
Response::builder()
.header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
.header(header::CACHE_CONTROL, "no-cache, no-transform")
@@ -2854,8 +2969,7 @@ mod tests {
#[test]
fn proxy_test_accepts_standard_and_legacy_gemini_paths() {
assert_eq!(
normalize_proxy_test_path("/v1beta/models/gemini-2.5-pro:generateContent")
.unwrap(),
normalize_proxy_test_path("/v1beta/models/gemini-2.5-pro:generateContent").unwrap(),
"/v1beta/models/gemini-2.5-pro:generateContent"
);
assert_eq!(
@@ -2983,12 +3097,10 @@ mod tests {
let error = result.expect_err("unknown paths should fail");
assert_eq!(error["path"], "/api/test/proxy/jobs");
assert!(
error["message"]
.as_str()
.unwrap_or_default()
.contains("unsupported proxy test path")
);
assert!(error["message"]
.as_str()
.unwrap_or_default()
.contains("unsupported proxy test path"));
}
#[tokio::test]
@@ -3060,12 +3172,10 @@ mod tests {
assert_eq!(error["path"], "/v1/chat/completions");
assert_eq!(error["forcedChannelId"], 77);
assert!(
error["message"]
.as_str()
.unwrap_or_default()
.contains("Forced channel #77")
);
assert!(error["message"]
.as_str()
.unwrap_or_default()
.contains("Forced channel #77"));
}
#[tokio::test]
@@ -3132,4 +3242,25 @@ mod tests {
assert_eq!(result["model"], "upstream-mini");
}
#[test]
fn infers_model_brands_without_boosapi_platform_brand() {
assert_eq!(infer_model_brand("gpt-4o-mini").as_deref(), Some("OpenAI"));
assert_eq!(
infer_model_brand("claude-3-5-sonnet").as_deref(),
Some("Anthropic")
);
assert_eq!(
infer_model_brand("deepseek-ai/DeepSeek-V3.2").as_deref(),
Some("DeepSeek")
);
assert_eq!(infer_model_brand("Qwen/Qwen3-32B").as_deref(), Some("Qwen"));
assert_eq!(
infer_model_brand("seedance-1-0-lite-t2v").as_deref(),
Some("Doubao")
);
assert_eq!(infer_model_brand("BoosAPI").as_deref(), None);
assert_eq!(infer_model_brand("boosapi/text-to-image").as_deref(), None);
assert_eq!(infer_model_brand("comfy/template").as_deref(), None);
}
}
+12
View File
@@ -17,10 +17,12 @@ use tracing::info;
mod account_tokens;
mod accounts;
mod cli_templates;
mod compat;
mod downstream_keys;
mod model_candidates;
pub(crate) mod models_marketplace;
mod pixelle_connection;
mod sites;
mod tokens;
mod users;
@@ -174,6 +176,11 @@ pub fn routes(state: AppState) -> Router<AppState> {
get(models_marketplace::get_marketplace),
);
let cli_templates_routes = Router::new().route(
"/api/cli-profiles/templates",
get(cli_templates::list_templates),
);
let users_routes = Router::new()
.route(
"/api/admin/users",
@@ -191,6 +198,10 @@ pub fn routes(state: AppState) -> Router<AppState> {
"/api/admin/user-balances",
get(users::list_user_balances),
)
.route(
"/api/admin/pixelle/connection",
get(pixelle_connection::get_connection).put(pixelle_connection::put_connection),
)
.route(
"/api/admin/users/{id}/balance/adjust",
post(users::adjust_user_balance),
@@ -221,6 +232,7 @@ pub fn routes(state: AppState) -> Router<AppState> {
.merge(users_routes)
.merge(model_candidates_routes)
.merge(models_marketplace_routes)
.merge(cli_templates_routes)
.merge(account_tokens::routes())
.merge(compat::routes())
.route_layer(middleware::from_fn_with_state(state, require_admin_auth))
+67
View File
@@ -0,0 +1,67 @@
use crate::error::AppResult;
use crate::services::pixelle_connection::{self, PixelleConnection};
use crate::AppState;
use axum::{extract::State, Json};
use serde::Deserialize;
use serde_json::{json, Value};
const MASK_SENTINEL: &str = "••••";
fn mask(v: &Option<String>) -> Value {
match v {
Some(s) if s.chars().count() > 4 => {
let last4: String = s.chars().rev().take(4).collect::<Vec<_>>().into_iter().rev().collect();
json!(format!("{}{}", MASK_SENTINEL, last4))
}
Some(s) if !s.is_empty() => json!(MASK_SENTINEL),
_ => Value::Null,
}
}
pub async fn get_connection(State(state): State<AppState>) -> AppResult<Json<Value>> {
let c = pixelle_connection::read_connection(&state.db).await?;
Ok(Json(json!({
"success": true,
"connection": {
"comfyuiUrl": c.comfyui_url,
"runninghubApiKey": mask(&c.runninghub_api_key),
"comfyuiApiKey": mask(&c.comfyui_api_key),
}
})))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdatePayload {
runninghub_api_key: Option<String>,
comfyui_url: Option<String>,
comfyui_api_key: Option<String>,
}
fn keep_if_not_mask(incoming: Option<String>) -> Option<String> {
match incoming {
Some(s) if s.starts_with(MASK_SENTINEL) => None,
other => other,
}
}
pub async fn put_connection(
State(state): State<AppState>,
Json(p): Json<UpdatePayload>,
) -> AppResult<Json<Value>> {
let conn = PixelleConnection {
runninghub_api_key: keep_if_not_mask(p.runninghub_api_key),
comfyui_url: p.comfyui_url,
comfyui_api_key: keep_if_not_mask(p.comfyui_api_key),
};
pixelle_connection::save_connection(&state.db, &conn).await?;
let backup = pixelle_connection::sync_to_pixelle(&state.config.pixelle_video.config_path, &conn)?;
let restart = pixelle_connection::restart_pixelle(&state.config.pixelle_video.compose_dir).await;
match restart {
Ok(()) => Ok(Json(json!({ "success": true, "backup": backup, "restarted": true }))),
Err(e) => Ok(Json(json!({
"success": true, "backup": backup, "restarted": false,
"warning": format!("配置已写入,但 Pixelle 重启失败,请手动重启: {e}")
}))),
}
}
+56 -18
View File
@@ -913,9 +913,14 @@ async fn probe_site_model(
match response {
Ok(_) if latency_exceeded => {
if let Err(err) =
update_model_probe_latency(state, selected.account_id, &model.name, latency_ms, true)
.await
if let Err(err) = update_model_probe_latency(
state,
selected.account_id,
&model.name,
latency_ms,
true,
)
.await
{
warn!(error = %err, model = %model.name, "failed to save probe latency");
}
@@ -924,7 +929,10 @@ async fn probe_site_model(
status: "unsupported".to_string(),
latency_ms: Some(latency_ms),
latency_exceeded: true,
reason: Some(format!("响应延迟 {}ms 超过阈值 {}ms", latency_ms, plan.latency_threshold_ms)),
reason: Some(format!(
"响应延迟 {}ms 超过阈值 {}ms",
latency_ms, plan.latency_threshold_ms
)),
http_status: None,
stage: Some("model"),
source: Some("probe"),
@@ -947,9 +955,14 @@ async fn probe_site_model(
warn!(channel_id, error = %err, "failed to record probe success");
}
}
if let Err(err) =
update_model_probe_latency(state, selected.account_id, &model.name, latency_ms, true)
.await
if let Err(err) = update_model_probe_latency(
state,
selected.account_id,
&model.name,
latency_ms,
true,
)
.await
{
warn!(error = %err, model = %model.name, "failed to save probe latency");
}
@@ -982,9 +995,14 @@ async fn probe_site_model(
warn!(channel_id, error = %record_err, "failed to record probe failure");
}
}
if let Err(update_err) =
update_model_probe_latency(state, selected.account_id, &model.name, latency_ms, false)
.await
if let Err(update_err) = update_model_probe_latency(
state,
selected.account_id,
&model.name,
latency_ms,
false,
)
.await
{
warn!(error = %update_err, model = %model.name, "failed to save failed probe state");
}
@@ -1041,7 +1059,8 @@ async fn resolve_probe_channel(
.bind(plan.site.id)
.fetch_optional(&*state.db)
.await?;
fallback.ok_or_else(|| AppError::BadRequest("当前站点没有启用中的账号,无法探测".into()))?
fallback
.ok_or_else(|| AppError::BadRequest("当前站点没有启用中的账号,无法探测".into()))?
}
};
@@ -1067,7 +1086,9 @@ async fn resolve_probe_channel(
};
if api_key.trim().is_empty() {
return Err(AppError::BadRequest("当前站点账号缺少可用 Token,无法探测".into()));
return Err(AppError::BadRequest(
"当前站点账号缺少可用 Token,无法探测".into(),
));
}
Ok(crate::proxy::conductor::SelectedChannel {
@@ -1165,8 +1186,10 @@ fn send_probe_sse(
tx.send(Ok(Event::default()
.event(event_type)
.json_data(payload)
.unwrap_or_else(|_| Event::default().event(event_type).data("{}"))))
.is_ok()
.unwrap_or_else(|_| {
Event::default().event(event_type).data("{}")
})))
.is_ok()
}
fn probe_error_message(err: &AppError) -> String {
@@ -1386,6 +1409,9 @@ fn detect_site_platform(url: &str) -> (&'static str, Option<&'static str>) {
{
return ("openai", Some("volcengine-ark-openai"));
}
if lower.starts_with("https://cloud.comfy.org") {
return ("comfy_cloud", Some("comfy-cloud"));
}
if lower.contains("anthropic") || lower.contains("claude") {
return ("claude", None);
@@ -1516,8 +1542,14 @@ mod tests {
assert_eq!(payload.get("httpStatus").and_then(Value::as_u64), Some(404));
assert_eq!(payload.get("stage").and_then(Value::as_str), Some("model"));
assert_eq!(payload.get("source").and_then(Value::as_str), Some("upstream"));
assert_eq!(payload.get("modelName").and_then(Value::as_str), Some("gpt-4o"));
assert_eq!(
payload.get("source").and_then(Value::as_str),
Some("upstream")
);
assert_eq!(
payload.get("modelName").and_then(Value::as_str),
Some("gpt-4o")
);
}
#[test]
@@ -1528,9 +1560,15 @@ mod tests {
assert_eq!(payload.get("httpStatus").and_then(Value::as_u64), Some(404));
assert_eq!(payload.get("stage").and_then(Value::as_str), Some("model"));
assert_eq!(payload.get("source").and_then(Value::as_str), Some("upstream"));
assert_eq!(
payload.get("source").and_then(Value::as_str),
Some("upstream")
);
assert_eq!(payload.get("siteId").and_then(Value::as_i64), Some(7));
assert_eq!(payload.get("modelName").and_then(Value::as_str), Some("gpt-4o"));
assert_eq!(
payload.get("modelName").and_then(Value::as_str),
Some("gpt-4o")
);
assert_eq!(
payload.get("message").and_then(Value::as_str),
Some("上游返回 HTTP 404 Not Found:model missing")
+203 -166
View File
@@ -113,6 +113,21 @@ async fn register(
) -> AppResult<Json<Value>> {
info!("Registering user: {}", payload.email);
// Admin can disable self-service registration from 系统设置.
let registration_disabled: Option<String> =
sqlx::query_scalar("SELECT value FROM settings WHERE key = 'runtime.registration_disabled'")
.fetch_optional(&*state.db)
.await?;
if registration_disabled
.as_deref()
.map(|v| matches!(v.trim(), "true" | "1"))
.unwrap_or(false)
{
return Err(AppError::Forbidden(
"用户注册已被管理员关闭".into(),
));
}
if payload.email.trim().is_empty() || payload.username.trim().is_empty() {
return Err(AppError::BadRequest(
"email and username are required".into(),
@@ -182,11 +197,12 @@ async fn register(
let default_key = format!("sk-user-{}", uuid::Uuid::new_v4().simple());
let default_key_name = format!("{} 的默认密钥", payload.username.trim());
sqlx::query(
"INSERT INTO downstream_api_keys (name, key, user_id, enabled) VALUES (?, ?, ?, 1)",
"INSERT INTO downstream_api_keys (name, key, user_id, enabled, max_cost) VALUES (?, ?, ?, 1, ?)",
)
.bind(&default_key_name)
.bind(&default_key)
.bind(user_id)
.bind(DEFAULT_NEW_USER_MAX_COST_USD)
.execute(&mut *tx)
.await?;
tx.commit().await?;
@@ -242,72 +258,66 @@ async fn login(
let user_agent = user_agent_from_headers(&headers);
let email = payload.email.trim().to_string();
let user: User = if let Some(user) = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?")
.bind(&email)
.fetch_optional(&*state.db)
.await?
let user: User = if let Some(user) =
sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?")
.bind(&email)
.fetch_optional(&*state.db)
.await?
{
user
} else {
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::Password,
success: false,
user: None,
identifier: Some(&email),
api_key: None,
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("Invalid email or password"),
})
audit_user_login(
&state,
LoginAuditEvent {
method: LoginMethod::Password,
success: false,
user: None,
identifier: Some(&email),
api_key: None,
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("Invalid email or password"),
},
)
.await;
return Err(AppError::Unauthorized("Invalid email or password".into()));
};
if user.status != "active" {
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::Password,
success: false,
user: Some(&user),
identifier: Some(&email),
api_key: None,
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("Account is disabled"),
})
audit_user_login(
&state,
LoginAuditEvent {
method: LoginMethod::Password,
success: false,
user: Some(&user),
identifier: Some(&email),
api_key: None,
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("Account is disabled"),
},
)
.await;
return Err(AppError::Forbidden("Account is disabled".into()));
}
if user.role == "admin" {
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::Password,
success: false,
user: Some(&user),
identifier: Some(&email),
api_key: None,
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("Admin users must sign in with the admin API token"),
})
.await;
return Err(AppError::Forbidden(
"Admin users must sign in with the admin API token".into(),
));
}
let valid = verify_password(&payload.password, &user.password_hash)
.map_err(|e| AppError::Internal(anyhow::anyhow!("Password verification failed: {e}")))?;
if !valid {
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::Password,
success: false,
user: Some(&user),
identifier: Some(&email),
api_key: None,
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("Invalid email or password"),
})
audit_user_login(
&state,
LoginAuditEvent {
method: LoginMethod::Password,
success: false,
user: Some(&user),
identifier: Some(&email),
api_key: None,
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("Invalid email or password"),
},
)
.await;
return Err(AppError::Unauthorized("Invalid email or password".into()));
}
@@ -320,16 +330,19 @@ async fn login(
)
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::Password,
success: true,
user: Some(&user),
identifier: Some(&email),
api_key: None,
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: None,
})
audit_user_login(
&state,
LoginAuditEvent {
method: LoginMethod::Password,
success: true,
user: Some(&user),
identifier: Some(&email),
api_key: None,
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: None,
},
)
.await;
Ok(Json(json!({
@@ -370,16 +383,19 @@ async fn login_api_key(
let user_agent = user_agent_from_headers(&headers);
let key = payload.api_key.trim();
if key.is_empty() {
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::ApiKey,
success: false,
user: None,
identifier: None,
api_key: None,
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("api_key is required"),
})
audit_user_login(
&state,
LoginAuditEvent {
method: LoginMethod::ApiKey,
success: false,
user: None,
identifier: None,
api_key: None,
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("api_key is required"),
},
)
.await;
return Err(AppError::BadRequest("api_key is required".into()));
}
@@ -395,87 +411,86 @@ async fn login_api_key(
api_key
} else {
let masked_key = mask_api_key(key);
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::ApiKey,
success: false,
user: None,
identifier: Some(masked_key.as_str()),
api_key: None,
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("Invalid API key"),
})
audit_user_login(
&state,
LoginAuditEvent {
method: LoginMethod::ApiKey,
success: false,
user: None,
identifier: Some(masked_key.as_str()),
api_key: None,
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("Invalid API key"),
},
)
.await;
return Err(AppError::Unauthorized("Invalid API key".into()));
};
let user_id = if let Some(user_id) = api_key.user_id {
user_id
} else {
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::ApiKey,
success: false,
user: None,
identifier: Some(&api_key.name),
api_key: Some(&api_key),
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("API key is not bound to a user"),
})
audit_user_login(
&state,
LoginAuditEvent {
method: LoginMethod::ApiKey,
success: false,
user: None,
identifier: Some(&api_key.name),
api_key: Some(&api_key),
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("API key is not bound to a user"),
},
)
.await;
return Err(AppError::Unauthorized("API key is not bound to a user".into()));
return Err(AppError::Unauthorized(
"API key is not bound to a user".into(),
));
};
let user: User = if let Some(user) = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
.bind(user_id)
.fetch_optional(&*state.db)
.await?
let user: User = if let Some(user) =
sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
.bind(user_id)
.fetch_optional(&*state.db)
.await?
{
user
} else {
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::ApiKey,
success: false,
user: None,
identifier: Some(&api_key.name),
api_key: Some(&api_key),
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("User not found"),
})
audit_user_login(
&state,
LoginAuditEvent {
method: LoginMethod::ApiKey,
success: false,
user: None,
identifier: Some(&api_key.name),
api_key: Some(&api_key),
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("User not found"),
},
)
.await;
return Err(AppError::Unauthorized("User not found".into()));
};
if user.status != "active" {
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::ApiKey,
success: false,
user: Some(&user),
identifier: Some(&user.email),
api_key: Some(&api_key),
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("Account is disabled"),
})
audit_user_login(
&state,
LoginAuditEvent {
method: LoginMethod::ApiKey,
success: false,
user: Some(&user),
identifier: Some(&user.email),
api_key: Some(&api_key),
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("Account is disabled"),
},
)
.await;
return Err(AppError::Forbidden("Account is disabled".into()));
}
if user.role == "admin" {
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::ApiKey,
success: false,
user: Some(&user),
identifier: Some(&user.email),
api_key: Some(&api_key),
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: Some("Admin users must sign in with the admin API token"),
})
.await;
return Err(AppError::Forbidden(
"Admin users must sign in with the admin API token".into(),
));
}
info!("Login via API key: user_id={}", user.id);
@@ -487,16 +502,19 @@ async fn login_api_key(
)
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
audit_user_login(&state, LoginAuditEvent {
method: LoginMethod::ApiKey,
success: true,
user: Some(&user),
identifier: Some(&user.email),
api_key: Some(&api_key),
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: None,
})
audit_user_login(
&state,
LoginAuditEvent {
method: LoginMethod::ApiKey,
success: true,
user: Some(&user),
identifier: Some(&user.email),
api_key: Some(&api_key),
client_ip: &client_ip,
user_agent: user_agent.as_deref(),
reason: None,
},
)
.await;
Ok(Json(json!({
@@ -752,7 +770,11 @@ async fn audit_user_login(state: &AppState, audit: LoginAuditEvent<'_>) {
.bind(message_parts.join(" · "))
.bind(level)
.bind(audit.user.map(|user| user.id))
.bind(if audit.user.is_some() { Some("user") } else { None::<&str> })
.bind(if audit.user.is_some() {
Some("user")
} else {
None::<&str>
})
.execute(&*state.db)
.await
{
@@ -869,17 +891,17 @@ fn normalize_ip_token(raw: &str) -> Option<String> {
token = host;
}
}
token
.parse::<IpAddr>()
.map(|ip| ip.to_string())
.ok()
token.parse::<IpAddr>().map(|ip| ip.to_string()).ok()
}
fn truncate_for_audit(value: &str, max_chars: usize) -> String {
if value.chars().count() <= max_chars {
return value.to_string();
}
let mut truncated = value.chars().take(max_chars.saturating_sub(1)).collect::<String>();
let mut truncated = value
.chars()
.take(max_chars.saturating_sub(1))
.collect::<String>();
truncated.push('…');
truncated
}
@@ -1023,6 +1045,7 @@ mod tests {
template_proxy_url: None,
},
comfyui: Default::default(),
pixelle_video: Default::default(),
payment: Default::default(),
pixelle_video: Default::default(),
},
@@ -1061,7 +1084,7 @@ mod tests {
}
#[tokio::test]
async fn password_login_rejects_admin_users() {
async fn password_login_returns_admin_role_for_admin_users() {
let state = test_state().await;
let password_hash = hash_password("admin-password").expect("password hash");
sqlx::query(
@@ -1075,8 +1098,8 @@ mod tests {
.await
.expect("insert admin user");
let err = login(
State(state),
let Json(value) = login(
State(state.clone()),
test_addr(),
empty_headers(),
Json(LoginRequest {
@@ -1085,9 +1108,12 @@ mod tests {
}),
)
.await
.expect_err("admin password login must be rejected");
.expect("admin password login should succeed");
assert!(matches!(err, AppError::Forbidden(message) if message.contains("admin API token")));
assert_eq!(value["user"]["role"], "admin");
let token = value["token"].as_str().expect("token");
let claims = validate_jwt(&state.config.auth.jwt_secret, token).expect("valid jwt");
assert_eq!(claims.role, "admin");
}
#[tokio::test]
@@ -1153,7 +1179,8 @@ mod tests {
.await
.expect("regular user password login succeeds");
let (event_type, title, message, related_id, related_type) = latest_audit_event(&state).await;
let (event_type, title, message, related_id, related_type) =
latest_audit_event(&state).await;
assert_eq!(event_type, "user_login");
assert!(title.contains("用户登录成功"));
assert!(title.contains("Regular User <audit@example.test>"));
@@ -1166,7 +1193,7 @@ mod tests {
}
#[tokio::test]
async fn api_key_login_rejects_admin_role() {
async fn api_key_login_returns_admin_role_for_admin_user() {
let state = test_state().await;
let password_hash = hash_password("unused-password").expect("password hash");
let user_id = sqlx::query(
@@ -1192,8 +1219,8 @@ mod tests {
.await
.expect("insert bound api key");
let err = login_api_key(
State(state),
let Json(value) = login_api_key(
State(state.clone()),
test_addr(),
empty_headers(),
Json(LoginApiKeyRequest {
@@ -1201,14 +1228,17 @@ mod tests {
}),
)
.await
.expect_err("admin api key login must be rejected");
.expect("admin api key login should succeed");
assert!(matches!(err, AppError::Forbidden(message) if message.contains("admin API token")));
assert_eq!(value["user"]["role"], "admin");
let token = value["token"].as_str().expect("token");
let claims = validate_jwt(&state.config.auth.jwt_secret, token).expect("valid jwt");
assert_eq!(claims.role, "admin");
assert!(user_id > 0);
}
#[tokio::test]
async fn api_key_login_rejects_latest_admin_role_after_promotion() {
async fn api_key_login_returns_latest_admin_role_after_promotion() {
let state = test_state().await;
let password_hash = hash_password("unused-password").expect("password hash");
let user_id = sqlx::query(
@@ -1240,8 +1270,8 @@ mod tests {
.await
.expect("promote user to admin");
let err = login_api_key(
State(state),
let Json(value) = login_api_key(
State(state.clone()),
test_addr(),
empty_headers(),
Json(LoginApiKeyRequest {
@@ -1249,9 +1279,12 @@ mod tests {
}),
)
.await
.expect_err("api key login must use latest role and reject admin");
.expect("api key login uses latest role and accepts admin");
assert!(matches!(err, AppError::Forbidden(message) if message.contains("admin API token")));
assert_eq!(value["user"]["role"], "admin");
let token = value["token"].as_str().expect("token");
let claims = validate_jwt(&state.config.auth.jwt_secret, token).expect("valid jwt");
assert_eq!(claims.role, "admin");
assert!(user_id > 0);
}
@@ -1339,7 +1372,8 @@ mod tests {
.await
.expect("regular api key login succeeds");
let (event_type, title, message, related_id, related_type) = latest_audit_event(&state).await;
let (event_type, title, message, related_id, related_type) =
latest_audit_event(&state).await;
assert_eq!(event_type, "user_login");
assert!(title.contains("用户登录成功"));
assert!(title.contains("Regular User <key-audit@example.test>"));
@@ -1369,7 +1403,10 @@ mod tests {
let Json(_) = login(
State(state.clone()),
test_addr(),
headers_with("x-forwarded-for", "2a09:bac2:3918:16dc::247:4d, 203.0.113.9"),
headers_with(
"x-forwarded-for",
"2a09:bac2:3918:16dc::247:4d, 203.0.113.9",
),
Json(LoginRequest {
email: "ipv4@example.test".into(),
password: "user-password".into(),
+229
View File
@@ -0,0 +1,229 @@
use crate::error::{AppError, AppResult};
use crate::services::comfy_cloud::site::resolve_comfy_cloud_site;
use crate::services::comfy_cloud::translate::comfy_cloud_job_to_history;
use crate::AppState;
use axum::{
body::Body,
extract::{Path, RawQuery, State},
http::{header, StatusCode},
response::Response,
routing::{get, post},
Json, Router,
};
use serde_json::Value;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/comfy-cloud/prompt", post(submit_prompt))
.route("/comfy-cloud/history/{prompt_id}", get(get_history))
.route("/comfy-cloud/view", get(get_view))
}
fn client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.unwrap_or_else(|_| reqwest::Client::new())
}
async fn submit_prompt(
State(state): State<AppState>,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let (base_url, api_key) = resolve_comfy_cloud_site(&state.db).await?;
let resp = client()
.post(format!("{base_url}/api/prompt"))
.header("X-API-Key", api_key)
.json(&body)
.send()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
let status = resp.status();
let text = resp.text().await.map_err(|e| AppError::UpstreamError(e.to_string()))?;
if !status.is_success() {
return Err(AppError::UpstreamError(format!("Comfy Cloud prompt [{status}]: {text}")));
}
let value: Value = serde_json::from_str(&text).unwrap_or_else(|_| serde_json::json!({ "body": text }));
Ok(Json(value))
}
async fn fetch_history(db: &sqlx::SqlitePool, prompt_id: &str) -> AppResult<Value> {
let (base_url, api_key) = resolve_comfy_cloud_site(db).await?;
let http = client();
let status_resp = http
.get(format!("{base_url}/api/job/{prompt_id}/status"))
.header("X-API-Key", &api_key)
.send().await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
if !status_resp.status().is_success() {
let st = status_resp.status();
let body = status_resp.text().await.unwrap_or_default();
return Err(AppError::UpstreamError(format!("Comfy Cloud status [{st}]: {body}")));
}
let status_json: Value = status_resp.json().await.map_err(|e| AppError::UpstreamError(e.to_string()))?;
let status = status_json.get("status").and_then(Value::as_str).unwrap_or("in_progress");
if status != "completed" {
return Ok(comfy_cloud_job_to_history(prompt_id, status, None));
}
let detail_resp = http
.get(format!("{base_url}/api/jobs/{prompt_id}"))
.header("X-API-Key", &api_key)
.send().await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
if !detail_resp.status().is_success() {
let st = detail_resp.status();
let body = detail_resp.text().await.unwrap_or_default();
return Err(AppError::UpstreamError(format!("Comfy Cloud jobs [{st}]: {body}")));
}
let detail_json: Value = detail_resp.json().await.map_err(|e| AppError::UpstreamError(e.to_string()))?;
let outputs = detail_json.get("outputs");
Ok(comfy_cloud_job_to_history(prompt_id, status, outputs))
}
async fn get_history(
State(state): State<AppState>,
Path(prompt_id): Path<String>,
) -> AppResult<Json<Value>> {
Ok(Json(fetch_history(&state.db, &prompt_id).await?))
}
async fn fetch_view(db: &sqlx::SqlitePool, query: Option<&str>) -> AppResult<(String, Vec<u8>)> {
let (base_url, api_key) = resolve_comfy_cloud_site(db).await?;
let q = query.unwrap_or("");
let url = if q.is_empty() { format!("{base_url}/api/view") } else { format!("{base_url}/api/view?{q}") };
let resp = client()
.get(url)
.header("X-API-Key", api_key)
.send().await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
if !resp.status().is_success() {
let st = resp.status();
return Err(AppError::UpstreamError(format!("Comfy Cloud view [{st}]")));
}
let mime = resp
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let bytes = resp.bytes().await.map_err(|e| AppError::UpstreamError(e.to_string()))?.to_vec();
Ok((mime, bytes))
}
async fn get_view(
State(state): State<AppState>,
RawQuery(query): RawQuery,
) -> AppResult<Response> {
let (mime, bytes) = fetch_view(&state.db, query.as_deref()).await?;
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime)
.body(Body::from(bytes))
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{header as match_header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use sqlx::sqlite::SqlitePoolOptions;
async fn db_with_site(base_url: &str) -> sqlx::SqlitePool {
let pool = SqlitePoolOptions::new().max_connections(1).connect("sqlite::memory:").await.unwrap();
sqlx::query("CREATE TABLE sites (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, url TEXT NOT NULL, platform TEXT NOT NULL, status TEXT NOT NULL, api_key TEXT, sort_order INTEGER)")
.execute(&pool).await.unwrap();
sqlx::query("INSERT INTO sites (name,url,platform,status,api_key) VALUES ('cc',?,'comfy_cloud','active','key-123')")
.bind(base_url).execute(&pool).await.unwrap();
pool
}
#[tokio::test]
async fn submit_forwards_with_api_key() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/prompt"))
.and(match_header("X-API-Key", "key-123"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "prompt_id": "p1" })))
.mount(&server).await;
let pool = db_with_site(&server.uri()).await;
let (base_url, api_key) = resolve_comfy_cloud_site(&pool).await.unwrap();
let resp = client().post(format!("{base_url}/api/prompt"))
.header("X-API-Key", api_key)
.json(&serde_json::json!({ "prompt": {} }))
.send().await.unwrap();
let v: Value = resp.json().await.unwrap();
assert_eq!(v["prompt_id"], "p1");
}
#[tokio::test]
async fn history_in_progress_then_completed() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/job/p1/status"))
.and(match_header("X-API-Key", "key-123"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "status": "completed" })))
.mount(&server).await;
Mock::given(method("GET"))
.and(path("/api/jobs/p1"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"outputs": { "9": { "images": [{ "filename": "a.png", "subfolder": "", "type": "output" }] } }
})))
.mount(&server).await;
let pool = db_with_site(&server.uri()).await;
let history = fetch_history(&pool, "p1").await.unwrap();
assert_eq!(history["p1"]["status"]["completed"], true);
assert_eq!(history["p1"]["outputs"]["9"]["images"][0]["filename"], "a.png");
}
#[tokio::test]
async fn history_completed_job_detail_non_success_errors() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/job/p1/status"))
.and(match_header("X-API-Key", "key-123"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "status": "completed" })))
.mount(&server).await;
Mock::given(method("GET"))
.and(path("/api/jobs/p1"))
.respond_with(ResponseTemplate::new(500).set_body_json(serde_json::json!({ "error": "boom" })))
.mount(&server).await;
let pool = db_with_site(&server.uri()).await;
assert!(fetch_history(&pool, "p1").await.is_err());
}
#[tokio::test]
async fn history_failed_status_maps_to_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/job/p1/status"))
.and(match_header("X-API-Key", "key-123"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "status": "failed" })))
.mount(&server).await;
let pool = db_with_site(&server.uri()).await;
let history = fetch_history(&pool, "p1").await.unwrap();
assert_eq!(history["p1"]["status"]["status_str"], "error");
}
#[tokio::test]
async fn view_proxies_file_bytes() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/view"))
.and(match_header("X-API-Key", "key-123"))
.respond_with(ResponseTemplate::new(200)
.insert_header("content-type", "image/png")
.set_body_bytes(b"PNGDATA".to_vec()))
.mount(&server).await;
let pool = db_with_site(&server.uri()).await;
let (mime, bytes) = fetch_view(&pool, Some("filename=a.png&type=output")).await.unwrap();
assert_eq!(mime, "image/png");
assert_eq!(bytes, b"PNGDATA");
}
}
+3 -4
View File
@@ -60,10 +60,9 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
// Downstream API key
if auth.starts_with("sk-") {
let proxy_token = state.runtime_proxy_token();
let result =
authorize_downstream_token(auth, &state.db, Some(&proxy_token))
.await
.map_err(AppError::Unauthorized)?;
let result = authorize_downstream_token(auth, &state.db, Some(&proxy_token))
.await
.map_err(AppError::Unauthorized)?;
match result {
DownstreamAuthResult::Managed { key, .. } => {
+286 -39
View File
@@ -16,47 +16,30 @@ use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
use tokio_stream::{wrappers::UnboundedReceiverStream, StreamExt};
use tracing::info;
const SYSTEM_PROMPT: &str = r#"你是 BoosAPI 的 AI 视频生成助手,帮用户通过 AI 制作视频。
const SYSTEM_PROMPT: &str = r#"你是 BoosAPI 的 AI 视频生成助手(智能体),帮用户通过 AI 制作视频。
## 规则
1. 先用中文与用户讨论脚本构思、视觉风格、角色设定
2. 用户确认后(说"开始制作"或"生成"),再分步输出结构化内容
3. **分两步走:第一步输出角色+场景+TTS,第二步输出 WORKFLOW**(不要一次全出)
## 工作方式
1. 先用中文与用户讨论脚本构思、视觉风格、角色设定。
2. 用户确认后(说"开始制作"或"生成"),调用工具逐步产出素材。
3. 你拥有以下工具,应主动按需调用,并根据每次工具返回的结果决定下一步:
- generate_character_images:为角色生成正/背/左/右四视图参考图
- generate_scene_image:生成单张场景/分镜图
- generate_tts:为一句台词合成语音
- build_comfyui_workflow:在素材就绪后交付最终可执行的 ComfyUI 工作流 JSON
## 结构化标记格式
严格按照以下格式输出,系统会自动解析并生成图片/TTS:
## 调用原则
- 一次可调用一个或多个工具;每个角色/场景/台词分别调用对应工具。
- 收到工具结果后继续推进,直到所有素材生成完毕,最后调用 build_comfyui_workflow 交付。
- 图像 prompt 用英文,包含 主体 + 视角 + 细节 + 画质标签。
- 没有用户明确要求时,不要自作主张批量生成;先沟通确认。
【CHARACTER】
name: 林澈
description: 女宇航员,30岁,冷静坚毅,短发收在头盔内
prompt_front: cinematic full body front view of a female astronaut, white EVA suit, photorealistic
prompt_back: cinematic full body back view of a female astronaut, life support backpack
prompt_left: cinematic full body left side view of a female astronaut, side utility pouches
prompt_right: cinematic full body right side view of a female astronaut, helmet visor reflection
【/CHARACTER】
【SCENE】
id: 1
description: 飞船驾驶舱内景
prompt: interior of a sci-fi spacecraft cockpit, holographic displays, cold blue lighting, cinematic
【/SCENE】
【TTS】
character: 林澈
text: 我们准备出舱检查
emotion: 平静
【/TTS】
【WORKFLOW】
```json
{"comfyui_workflow": {}}
```
【/WORKFLOW】
## 关键要求
- prompt_* 用英文,同时包含主体 + 视角 + 细节 + 画质标签
- 不要一次输出太多,先输角色和场景,下次再输工作流
- 如果没有用户明确要求,切勿自作主张输出全部内容
## 兼容降级
若你所在的模型不支持工具调用,可改用结构化标记输出(系统会解析):
【CHARACTER】name/description/prompt_front/prompt_back/prompt_left/prompt_right【/CHARACTER】
【SCENE】id/description/prompt【/SCENE】
【TTS】character/text/emotion【/TTS】
【WORKFLOW】```json {...} ```【/WORKFLOW】
但只要支持工具调用,就优先使用工具,不要输出这些标记。
"#;
const AGENT_CHAT_BODY_LIMIT_BYTES: usize = 64 * 1024 * 1024;
@@ -683,6 +666,190 @@ fn send_sse(tx: &UnboundedSender<Result<Event, Infallible>>, payload: Value) ->
.is_ok()
}
/// OpenAI-style tool definitions advertised to the upstream model so the
/// agent can request image / scene / TTS / workflow generation step by step.
fn agent_tool_definitions() -> Value {
json!([
{
"type": "function",
"function": {
"name": "generate_character_images",
"description": "为一个角色生成四视图(正/背/左/右)参考图。当需要确定角色形象时调用。",
"parameters": {
"type": "object",
"properties": {
"character_name": { "type": "string", "description": "角色名称" },
"description": { "type": "string", "description": "角色整体描述" },
"prompt_front": { "type": "string", "description": "正面视角的图像提示词" },
"prompt_back": { "type": "string", "description": "背面视角的图像提示词" },
"prompt_left": { "type": "string", "description": "左侧视角的图像提示词" },
"prompt_right": { "type": "string", "description": "右侧视角的图像提示词" }
},
"required": ["character_name", "prompt_front"]
}
}
},
{
"type": "function",
"function": {
"name": "generate_scene_image",
"description": "生成一张场景/分镜图。在规划好分镜后逐个调用。",
"parameters": {
"type": "object",
"properties": {
"scene_id": { "type": "integer", "description": "场景编号" },
"description": { "type": "string", "description": "场景描述" },
"prompt": { "type": "string", "description": "场景图像提示词" }
},
"required": ["prompt"]
}
}
},
{
"type": "function",
"function": {
"name": "generate_tts",
"description": "为一句台词生成语音。",
"parameters": {
"type": "object",
"properties": {
"character": { "type": "string", "description": "说话角色" },
"text": { "type": "string", "description": "台词文本" },
"emotion": { "type": "string", "description": "情绪:平静/激动/愤怒/悲伤/恐惧" }
},
"required": ["text"]
}
}
},
{
"type": "function",
"function": {
"name": "build_comfyui_workflow",
"description": "输出最终可执行的 ComfyUI 工作流 JSON。在素材准备完成后调用以交付工作流。",
"parameters": {
"type": "object",
"properties": {
"workflow_json": { "type": "string", "description": "完整的 ComfyUI 工作流 JSON 字符串" }
},
"required": ["workflow_json"]
}
}
}
])
}
/// Map an emotion label to an OpenAI TTS voice.
fn voice_for_emotion(emotion: &str) -> &'static str {
match emotion {
"激动" | "愤怒" => "nova",
"悲伤" | "恐惧" => "shimmer",
_ => "alloy",
}
}
/// Execute one tool call requested by the model. Emits SSE side-effects
/// (image/scene/tts/comfyui_workflow events) and returns a short text result
/// that is fed back to the model as the tool message content.
async fn execute_agent_tool(
tx: &UnboundedSender<Result<Event, Infallible>>,
api_base: &str,
api_key: &str,
image_model: &str,
tool_name: &str,
args: &Value,
) -> String {
match tool_name {
"generate_character_images" => {
let name = args.get("character_name").and_then(Value::as_str).unwrap_or("角色");
let views: [(&str, &str); 4] = [
("front", "prompt_front"),
("back", "prompt_back"),
("left", "prompt_left"),
("right", "prompt_right"),
];
let mut ok = 0;
for (view, key) in views {
let Some(prompt) = args.get(key).and_then(Value::as_str).filter(|p| !p.is_empty()) else {
continue;
};
send_sse(tx, json!({ "type": "tool_progress", "tool": tool_name, "detail": format!("生成 {} 的 {} 视图", name, view) }));
match call_image_gen(api_base, api_key, image_model, prompt).await {
Ok(url) if !url.is_empty() => {
ok += 1;
send_sse(tx, json!({ "type": "image", "character": name, "view": view, "url": url }));
}
Ok(_) => {}
Err(e) => {
send_sse(tx, json!({ "type": "tool_progress", "tool": tool_name, "detail": format!("{} 的 {} 视图生成失败: {}", name, view, e) }));
}
}
}
format!("已为角色「{}」生成 {} 张视图。", name, ok)
}
"generate_scene_image" => {
let scene_id = args.get("scene_id").and_then(Value::as_i64).unwrap_or(0);
let desc = args.get("description").and_then(Value::as_str).unwrap_or("");
let Some(prompt) = args.get("prompt").and_then(Value::as_str).filter(|p| !p.is_empty()) else {
return "缺少 prompt,未生成场景图。".to_string();
};
send_sse(tx, json!({ "type": "tool_progress", "tool": tool_name, "detail": format!("生成场景 {}", scene_id) }));
match call_image_gen(api_base, api_key, image_model, prompt).await {
Ok(url) if !url.is_empty() => {
send_sse(tx, json!({ "type": "scene", "id": scene_id, "description": desc, "prompt": prompt, "url": url }));
format!("场景 {} 已生成。", scene_id)
}
Ok(_) => format!("场景 {} 上游未返回图像。", scene_id),
Err(e) => format!("场景 {} 生成失败: {}", scene_id, e),
}
}
"generate_tts" => {
let character = args.get("character").and_then(Value::as_str).unwrap_or("旁白");
let emotion = args.get("emotion").and_then(Value::as_str).unwrap_or("平静");
let Some(text) = args.get("text").and_then(Value::as_str).filter(|t| !t.is_empty()) else {
return "缺少台词文本,未生成语音。".to_string();
};
send_sse(tx, json!({ "type": "tool_progress", "tool": tool_name, "detail": format!("为 {} 合成语音", character) }));
match call_tts(api_base, api_key, text, voice_for_emotion(emotion)).await {
Ok(audio_bytes) => {
let audio_path = format!(
"/tmp/comfyui_tts_{}_{}.mp3",
character.replace(' ', "_"),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
);
if tokio::fs::write(&audio_path, &audio_bytes).await.is_ok() {
send_sse(tx, json!({
"type": "tts",
"character": character,
"text": text,
"url": format!("/audio/{}", audio_path.trim_start_matches("/tmp/comfyui_tts_")),
}));
format!("已为「{}」生成语音。", character)
} else {
"语音生成成功但写入失败。".to_string()
}
}
Err(e) => format!("语音生成失败: {}", e),
}
}
"build_comfyui_workflow" => {
let Some(raw) = args.get("workflow_json").and_then(Value::as_str).filter(|w| !w.is_empty()) else {
return "缺少 workflow_json。".to_string();
};
match serde_json::from_str::<Value>(raw) {
Ok(parsed) => {
send_sse(tx, json!({ "type": "comfyui_workflow", "workflow": parsed }));
"ComfyUI 工作流已交付。".to_string()
}
Err(e) => format!("工作流 JSON 无效: {}", e),
}
}
other => format!("未知工具: {}", other),
}
}
fn string_field(value: &Value, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|key| value.get(*key).and_then(Value::as_str))
@@ -910,6 +1077,15 @@ async fn chat_handler(
let client = Client::new();
// Agentic loop: each round may produce tool_calls; we execute them,
// feed results back, and re-query until the model returns a final text
// answer (no tool_calls) or the round budget is exhausted.
const MAX_AGENT_ROUNDS: usize = 6;
let mut agent_round = 0usize;
let mut accumulated_text = String::new();
loop {
agent_round += 1;
info!("comfyui-agent: sending chat request to upstream");
let response = match client
@@ -920,6 +1096,8 @@ async fn chat_handler(
"messages": upstream_messages,
"temperature": 0.7,
"stream": true,
"tools": agent_tool_definitions(),
"tool_choice": "auto",
}))
.send()
.await
@@ -952,7 +1130,10 @@ async fn chat_handler(
let mut stream = response.bytes_stream();
let mut buffer: Vec<u8> = Vec::new();
let mut accumulated_text = String::new();
accumulated_text.clear();
// Accumulate streamed tool_calls: index -> (id, name, arguments-buffer).
let mut tool_calls_acc: std::collections::BTreeMap<i64, (String, String, String)> =
std::collections::BTreeMap::new();
// Phase 1: Stream the LLM text response
loop {
@@ -1048,9 +1229,75 @@ async fn chat_handler(
return;
}
}
// Accumulate streamed tool_calls fragments.
if let Some(tcs) = delta.get("tool_calls").and_then(Value::as_array) {
for tc in tcs {
let idx = tc.get("index").and_then(Value::as_i64).unwrap_or(0);
let entry = tool_calls_acc.entry(idx).or_default();
if let Some(id) = tc.get("id").and_then(Value::as_str) {
if !id.is_empty() {
entry.0 = id.to_string();
}
}
if let Some(name) = tc.pointer("/function/name").and_then(Value::as_str) {
if !name.is_empty() {
entry.1 = name.to_string();
}
}
if let Some(args) = tc.pointer("/function/arguments").and_then(Value::as_str) {
entry.2.push_str(args);
}
}
}
}
}
// If the model requested tools this round, execute them, append the
// assistant tool_calls + tool results to the conversation, and loop
// for another round so the model can react to the results.
if !tool_calls_acc.is_empty() && agent_round < MAX_AGENT_ROUNDS {
// Reconstruct the assistant message carrying the tool_calls.
let assistant_tool_calls: Vec<Value> = tool_calls_acc
.iter()
.map(|(_, (id, name, args))| {
json!({
"id": id,
"type": "function",
"function": { "name": name, "arguments": args },
})
})
.collect();
upstream_messages.push(json!({
"role": "assistant",
"content": if accumulated_text.is_empty() { Value::Null } else { Value::from(accumulated_text.clone()) },
"tool_calls": assistant_tool_calls,
}));
for (_, (id, name, args)) in tool_calls_acc.iter() {
let parsed_args: Value = serde_json::from_str(args).unwrap_or(json!({}));
send_sse(&tx, json!({ "type": "tool_call", "tool": name, "arguments": parsed_args }));
let result = execute_agent_tool(
&tx, &api_base, &api_key, &image_model, name, &parsed_args,
).await;
send_sse(&tx, json!({ "type": "tool_result", "tool": name, "result": result }));
upstream_messages.push(json!({
"role": "tool",
"tool_call_id": id,
"content": result,
}));
}
// Continue to the next agent round.
continue;
}
// No tool calls → final answer. Leave the agentic loop and fall through
// to the legacy structured-section parsing as a fallback for models
// that emit 【...】 markers instead of tool calls.
break;
} // end agentic loop
// Phase 2: Parse structured sections from accumulated text
info!("comfyui-agent: text streaming complete, parsing sections");
let sections = parse_sections(&accumulated_text);
File diff suppressed because it is too large Load Diff
+47 -3
View File
@@ -439,6 +439,12 @@ struct EventListItem {
event_type: String,
title: String,
message: Option<String>,
#[sqlx(default)]
#[serde(skip_serializing_if = "Option::is_none")]
account: Option<String>,
#[sqlx(default)]
#[serde(skip_serializing_if = "Option::is_none")]
client_ip: Option<String>,
level: String,
#[serde(rename = "read")]
is_read: bool,
@@ -449,11 +455,15 @@ struct EventListItem {
impl From<Event> for EventListItem {
fn from(event: Event) -> Self {
let account = event_account(&event.event_type, &event.title, event.message.as_deref());
let client_ip = event_client_ip(event.message.as_deref());
Self {
id: event.id,
event_type: event.event_type,
title: event.title,
message: event.message,
account,
client_ip,
level: event.level.unwrap_or_else(|| "info".to_string()),
is_read: event.is_read.unwrap_or(false),
related_id: event.related_id,
@@ -470,6 +480,8 @@ impl From<runtime_logs::RuntimeLogEntry> for EventListItem {
event_type: entry.event_type,
title: entry.title,
message: entry.message,
account: None,
client_ip: None,
level: entry.level,
is_read: entry.is_read,
related_id: entry.related_id,
@@ -479,6 +491,38 @@ impl From<runtime_logs::RuntimeLogEntry> for EventListItem {
}
}
fn event_account(event_type: &str, title: &str, message: Option<&str>) -> Option<String> {
if event_type != "user_login" {
return None;
}
message
.and_then(|value| keyed_message_value(value, "账号"))
.or_else(|| message.and_then(|value| keyed_message_value(value, "邮箱")))
.or_else(|| message.and_then(|value| keyed_message_value(value, "标识")))
.or_else(|| {
title
.split_once(':')
.map(|(_, value)| value.trim().to_string())
})
.filter(|value| !value.is_empty())
}
fn event_client_ip(message: Option<&str>) -> Option<String> {
message
.and_then(|value| keyed_message_value(value, "IP"))
.filter(|value| !value.is_empty())
}
fn keyed_message_value(message: &str, key: &str) -> Option<String> {
let prefix = format!("{key}=");
message
.split('·')
.map(str::trim)
.find_map(|part| part.strip_prefix(&prefix).map(str::trim))
.filter(|value| !value.is_empty())
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -487,11 +531,11 @@ mod tests {
config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig},
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
};
use axum::extract::{Query, State};
use axum::{
body::Body,
http::{Request, StatusCode},
};
use axum::extract::{Query, State};
use sqlx::sqlite::SqlitePoolOptions;
use tower::ServiceExt as _;
@@ -633,8 +677,8 @@ mod tests {
.expect("admin token request");
assert_eq!(ok_response.status(), StatusCode::OK);
let user_jwt = generate_jwt(&state.config.auth.jwt_secret, "1", "user", 1)
.expect("user jwt");
let user_jwt =
generate_jwt(&state.config.auth.jwt_secret, "1", "user", 1).expect("user jwt");
let user_response = app
.clone()
.oneshot(
+16 -7
View File
@@ -16,12 +16,15 @@ use tower_http::services::ServeDir;
static EMBEDDED_WEB: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/web");
static EMBEDDED_WEBSITE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/website");
const _: &[u8] = include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/dist/web/index.html"));
const _: &[u8] =
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/dist/website/index.html"));
const _: &[u8] = include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/dist/website/index.html"
));
mod admin;
mod auth;
mod checkin;
mod comfy_cloud_adapter;
mod comfyui;
mod comfyui_agent;
mod digital_human;
@@ -30,6 +33,7 @@ mod health;
mod money_printer;
mod oauth;
mod organizations;
mod payment;
mod picture_agent;
mod proxy;
mod search;
@@ -50,6 +54,7 @@ pub fn build_router(state: AppState) -> Router {
.merge(admin::routes(state.clone()))
.merge(proxy::routes())
.merge(picture_agent::routes())
.merge(comfy_cloud_adapter::routes())
.merge(money_printer::routes())
.merge(comfyui_agent::routes())
.merge(digital_human::routes())
@@ -65,6 +70,7 @@ pub fn build_router(state: AppState) -> Router {
.merge(checkin::routes())
.merge(oauth::routes())
.merge(organizations::routes())
.merge(payment::routes())
.merge(search::routes())
.merge(site_announcements::routes())
.merge(workflow_ui::routes())
@@ -99,12 +105,9 @@ pub fn build_router(state: AppState) -> Router {
return Ok::<_, Infallible>(api_not_found_response(path));
}
Ok::<_, Infallible>(filesystem_static_response(
path,
&web_dir,
&website_dir,
Ok::<_, Infallible>(
filesystem_static_response(path, &web_dir, &website_dir).await,
)
.await)
}
}));
router.fallback_service(static_service)
@@ -285,6 +288,12 @@ mod tests {
assert_eq!(embedded_asset_path("/new"), "new.html");
assert_eq!(embedded_asset_path("/new/"), "new.html");
assert_eq!(embedded_asset_path("/new/index.html"), "new.html");
for route in ["new1", "new2", "new3", "new4", "new5"] {
assert!(!should_serve_website(&format!("/{route}")));
assert!(!should_serve_website(&format!("/{route}/")));
assert!(!should_serve_website(&format!("/{route}/index.html")));
}
}
#[test]
+347 -110
View File
@@ -108,7 +108,11 @@ fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer ").or_else(|| value.strip_prefix("bearer ")))
.and_then(|value| {
value
.strip_prefix("Bearer ")
.or_else(|| value.strip_prefix("bearer "))
})
.map(str::trim)
.filter(|token| !token.is_empty())
}
@@ -181,25 +185,28 @@ async fn resolve_auth(headers: &HeaderMap, state: &AppState) -> Result<MoneyPrin
async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
let auth = resolve_auth(&headers, &state).await?;
let cloud_backend = state.config.comfyui.uses_cloud_backend();
let local_only_enabled = state.config.comfyui.uses_docker_backend();
Ok(Json(json!({
"success": true,
"mode": "boosapi-native",
"mode": if local_only_enabled { "local-comfy" } else { "disabled" },
"disabled": !local_only_enabled,
"disabledReason": if local_only_enabled { Value::Null } else { json!("短视频工厂暂时只支持本地模型部署;请启用本地 ComfyUI/Docker 后再创建任务。") },
"keyName": auth.key_name,
"capabilities": [
"topic_to_script",
"boosapi_chat_completion",
"comfy_prompt",
"boosapi_text_to_video",
"local_comfy_prompt",
"local_text_to_video",
"workspace_archive",
"background_jobs"
],
"comfy": {
"enabled": state.config.comfyui.uses_native_backend(),
"enabled": local_only_enabled,
"backend": format!("{:?}", state.config.comfyui.backend).to_ascii_lowercase(),
"baseUrl": state.config.comfyui.target_base_url()
},
"builtinCloudTemplate": {
"enabled": cloud_backend,
"enabled": cloud_backend && local_only_enabled,
"label": "内置 Cloud Seedance 文生视频",
"nodeType": "ByteDanceTextToVideoNode",
"model": "seedance-1-0-lite-t2v-250428",
@@ -276,7 +283,12 @@ async fn load_job(state: &AppState, id: &str, auth: &MoneyPrinterAuth) -> AppRes
Ok(task_to_json(task))
}
async fn update_progress(state: &AppState, id: &str, progress: i64, message: &str) -> AppResult<()> {
async fn update_progress(
state: &AppState,
id: &str,
progress: i64,
message: &str,
) -> AppResult<()> {
sqlx::query(
"UPDATE background_tasks
SET progress = ?, message = ?, updated_at = datetime('now')
@@ -308,7 +320,8 @@ fn http_client(timeout_secs: u64) -> Client {
fn script_prompt(topic: &str, body: &Value) -> String {
let language = string_from(body, &["language"]).unwrap_or_else(|| "中文".to_string());
let style = string_from(body, &["style"]).unwrap_or_else(|| "爆款短视频,节奏紧凑,适合口播".to_string());
let style = string_from(body, &["style"])
.unwrap_or_else(|| "爆款短视频,节奏紧凑,适合口播".to_string());
let duration = string_from(body, &["duration"]).unwrap_or_else(|| "30".to_string());
let audience = string_from(body, &["audience"]).unwrap_or_else(|| "泛大众用户".to_string());
let extra = string_from(body, &["requirements", "extra"]).unwrap_or_default();
@@ -334,9 +347,8 @@ async fn generate_script(
auth: &MoneyPrinterAuth,
body: &Value,
) -> AppResult<Value> {
let topic = string_from(body, &["topic", "prompt"]).ok_or_else(|| {
AppError::BadRequest("topic is required".into())
})?;
let topic = string_from(body, &["topic", "prompt"])
.ok_or_else(|| AppError::BadRequest("topic is required".into()))?;
let script_model = string_from(body, &["scriptModel", "script_model"])
.unwrap_or_else(|| "gpt-5.4".to_string());
let payload = json!({
@@ -452,7 +464,11 @@ fn native_path(state: &AppState, path: &str) -> String {
}
fn native_url(state: &AppState, path: &str) -> String {
format!("{}{}", state.config.comfyui.target_base_url(), native_path(state, path))
format!(
"{}{}",
state.config.comfyui.target_base_url(),
native_path(state, path)
)
}
fn native_request(state: &AppState, method: Method, path: &str) -> reqwest::RequestBuilder {
@@ -559,7 +575,12 @@ fn mapping_value<'a>(mapping: &'a Value, keys: &[&str]) -> Option<&'a Value> {
fn mapping_node_id(mapping: &Value, keys: &[&str]) -> Option<String> {
mapping_value(mapping, keys).and_then(|value| {
if value.is_object() {
scalar_string(value.get("nodeId").or_else(|| value.get("node_id")).or(Some(value)))
scalar_string(
value
.get("nodeId")
.or_else(|| value.get("node_id"))
.or(Some(value)),
)
} else {
scalar_string(Some(value))
}
@@ -621,21 +642,38 @@ fn graph_node_params(node: &Value) -> serde_json::Map<String, Value> {
fn normalize_comfy_value(class_type: &str, field: &str, value: Value) -> Value {
match (class_type, field) {
("KSampler", "cfg") => value.as_f64().filter(|v| (0.0..=50.0).contains(v)).map_or(json!(8.0), |v| json!(v)),
("KSampler", "denoise") => value.as_f64().map(|v| if v > 1.0 { v / 100.0 } else { v }).filter(|v| (0.0..=1.0).contains(v)).map_or(json!(1.0), |v| json!(v)),
("KSampler", "cfg") => value
.as_f64()
.filter(|v| (0.0..=50.0).contains(v))
.map_or(json!(8.0), |v| json!(v)),
("KSampler", "denoise") => value
.as_f64()
.map(|v| if v > 1.0 { v / 100.0 } else { v })
.filter(|v| (0.0..=1.0).contains(v))
.map_or(json!(1.0), |v| json!(v)),
("KSampler", "sampler_name") => {
if value.is_string() { value } else { json!("euler") }
if value.is_string() {
value
} else {
json!("euler")
}
}
("KSampler", "scheduler") => {
if value.is_string() { value } else { json!("normal") }
if value.is_string() {
value
} else {
json!("normal")
}
}
("KSampler", "seed") => value.as_i64().map_or(json!(0), |v| json!(v.max(0))),
("EmptyLatentImage", "width") | ("EmptyLatentImage", "height") => {
value.as_i64().filter(|v| (64..=4096).contains(v)).map_or(json!(1024), |v| json!(v))
}
("EmptyLatentImage", "batch_size") => {
value.as_i64().filter(|v| (1..=16).contains(v)).map_or(json!(1), |v| json!(v))
}
("EmptyLatentImage", "width") | ("EmptyLatentImage", "height") => value
.as_i64()
.filter(|v| (64..=4096).contains(v))
.map_or(json!(1024), |v| json!(v)),
("EmptyLatentImage", "batch_size") => value
.as_i64()
.filter(|v| (1..=16).contains(v))
.map_or(json!(1), |v| json!(v)),
_ => value,
}
}
@@ -656,18 +694,37 @@ fn graph_to_comfy_prompt(graph: &Value) -> Option<Value> {
}
let mut inputs = serde_json::Map::new();
for (field, value) in graph_node_params(node) {
inputs.insert(field.clone(), normalize_comfy_value(&class_type, &field, value));
inputs.insert(
field.clone(),
normalize_comfy_value(&class_type, &field, value),
);
}
prompt.insert(id, json!({
"class_type": class_type,
"inputs": inputs,
}));
prompt.insert(
id,
json!({
"class_type": class_type,
"inputs": inputs,
}),
);
}
for edge in graph.get("edges").and_then(Value::as_array).into_iter().flatten() {
let Some(from) = edge.get("from").and_then(Value::as_str) else { continue; };
let Some(to) = edge.get("to").and_then(Value::as_str) else { continue; };
let Some((from_id, from_port)) = split_endpoint(from) else { continue; };
let Some((to_id, to_field)) = split_endpoint(to) else { continue; };
for edge in graph
.get("edges")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let Some(from) = edge.get("from").and_then(Value::as_str) else {
continue;
};
let Some(to) = edge.get("to").and_then(Value::as_str) else {
continue;
};
let Some((from_id, from_port)) = split_endpoint(from) else {
continue;
};
let Some((to_id, to_field)) = split_endpoint(to) else {
continue;
};
if let Some(inputs) = prompt
.get_mut(to_id)
.and_then(Value::as_object_mut)
@@ -684,7 +741,10 @@ fn graph_to_comfy_prompt(graph: &Value) -> Option<Value> {
Some(Value::Object(prompt))
}
fn first_node_by_class(prompt: &serde_json::Map<String, Value>, class_type: &str) -> Option<String> {
fn first_node_by_class(
prompt: &serde_json::Map<String, Value>,
class_type: &str,
) -> Option<String> {
prompt
.iter()
.find(|(_, node)| node.get("class_type").and_then(Value::as_str) == Some(class_type))
@@ -716,9 +776,13 @@ fn prompt_contains_class_type(prompt: &Value, class_type: &str) -> bool {
.get("class_type")
.and_then(Value::as_str)
.is_some_and(|value| value == class_type)
|| object.values().any(|value| prompt_contains_class_type(value, class_type))
|| object
.values()
.any(|value| prompt_contains_class_type(value, class_type))
}
Value::Array(items) => items.iter().any(|value| prompt_contains_class_type(value, class_type)),
Value::Array(items) => items
.iter()
.any(|value| prompt_contains_class_type(value, class_type)),
_ => false,
}
}
@@ -740,11 +804,36 @@ fn infer_comfy_mapping(prompt: &Value) -> Value {
.or_else(|| first_node_by_class(object, "KSampler"))
.or_else(|| first_node_by_class(object, "EmptyLatentImage"))
.unwrap_or_default();
let prompt_field = first_existing_input_field(object, &prompt_node, &["prompt", "positive", "text"], "text");
let ratio_field = first_existing_input_field(object, &video_node, &["ratio", "model.ratio", "aspect_ratio"], "ratio");
let resolution_field = first_existing_input_field(object, &video_node, &["model.resolution", "resolution", "size"], "resolution");
let duration_field = first_existing_input_field(object, &video_node, &["model.duration", "duration", "length"], "duration");
let model_field = first_existing_input_field(object, &video_node, &["model", "model_name", "ckpt_name"], "model");
let prompt_field = first_existing_input_field(
object,
&prompt_node,
&["prompt", "positive", "text"],
"text",
);
let ratio_field = first_existing_input_field(
object,
&video_node,
&["ratio", "model.ratio", "aspect_ratio"],
"ratio",
);
let resolution_field = first_existing_input_field(
object,
&video_node,
&["model.resolution", "resolution", "size"],
"resolution",
);
let duration_field = first_existing_input_field(
object,
&video_node,
&["model.duration", "duration", "length"],
"duration",
);
let model_field = first_existing_input_field(
object,
&video_node,
&["model", "model_name", "ckpt_name"],
"model",
);
json!({
"prompt": {
"nodeId": prompt_node,
@@ -776,7 +865,9 @@ fn infer_comfy_mapping(prompt: &Value) -> Value {
fn clip_text_nodes(prompt: &serde_json::Map<String, Value>) -> Vec<String> {
prompt
.iter()
.filter(|(_, node)| node.get("class_type").and_then(Value::as_str) == Some("CLIPTextEncode"))
.filter(|(_, node)| {
node.get("class_type").and_then(Value::as_str) == Some("CLIPTextEncode")
})
.map(|(id, _)| id.clone())
.collect()
}
@@ -826,7 +917,8 @@ fn repair_common_comfy_prompt_links(prompt: &mut serde_json::Map<String, Value>)
}
fn workflow_prompt_from_graph(graph: &Value) -> Value {
graph.get("prompt")
graph
.get("prompt")
.cloned()
.or_else(|| graph.get("workflow").cloned())
.or_else(|| graph.get("comfyPrompt").cloned())
@@ -858,23 +950,24 @@ async fn workflow_prompt_from_body(
auth: &MoneyPrinterAuth,
body: &Value,
) -> AppResult<Value> {
if let Some(value) = body.get("comfyPrompt")
if let Some(value) = body
.get("comfyPrompt")
.or_else(|| body.get("comfy_prompt"))
.or_else(|| body.get("promptWorkflow"))
.or_else(|| body.get("prompt_workflow"))
.or_else(|| body.get("workflowPrompt"))
.or_else(|| body.get("workflow_prompt"))
.cloned() {
.cloned()
{
return Ok(value);
}
if let Some(template_id) = string_from(body, &["templateId", "template_id", "workflowId", "workflow_id"]) {
let workflow = storage::get_workflow(
&state.db,
&template_id,
auth.user_id,
auth.role == "admin",
)
.await?;
if let Some(template_id) = string_from(
body,
&["templateId", "template_id", "workflowId", "workflow_id"],
) {
let workflow =
storage::get_workflow(&state.db, &template_id, auth.user_id, auth.role == "admin")
.await?;
return Ok(workflow_prompt_from_graph(&workflow.graph));
}
if state.config.comfyui.uses_cloud_backend() {
@@ -890,10 +983,14 @@ fn apply_node_input(prompt: &mut Value, node_id: &str, field: &str, value: Value
return Err(AppError::BadRequest("Comfy 节点映射包含空 nodeId".into()));
}
let Some(node) = prompt.get_mut(node_id) else {
return Err(AppError::BadRequest(format!("Comfy prompt missing node {node_id}")));
return Err(AppError::BadRequest(format!(
"Comfy prompt missing node {node_id}"
)));
};
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
return Err(AppError::BadRequest(format!("Comfy node {node_id} has no inputs")));
return Err(AppError::BadRequest(format!(
"Comfy node {node_id} has no inputs"
)));
};
inputs.insert(field.to_string(), value);
Ok(())
@@ -927,12 +1024,20 @@ fn apply_optional_node_input(
fn value_to_duration_seconds(value: &Value) -> Option<i64> {
value
.as_i64()
.or_else(|| value.as_str().and_then(|text| text.trim().parse::<i64>().ok()))
.or_else(|| {
value
.as_str()
.and_then(|text| text.trim().parse::<i64>().ok())
})
.map(|duration| duration.clamp(2, 120))
}
fn normalize_resolution_for_comfy(value: &Value) -> Value {
let text = value.as_str().unwrap_or_default().trim().to_ascii_lowercase();
let text = value
.as_str()
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
match text.as_str() {
"720p" | "720" => json!("720P"),
"1080p" | "1080" => json!("1080P"),
@@ -940,7 +1045,12 @@ fn normalize_resolution_for_comfy(value: &Value) -> Value {
}
}
fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value, plan: &Value) -> AppResult<String> {
fn inject_comfy_video_inputs(
prompt: &mut Value,
mapping: &Value,
body: &Value,
plan: &Value,
) -> AppResult<String> {
let video_prompt = string_from(body, &["videoPrompt", "video_prompt"]).unwrap_or_else(|| {
plan.get("video_prompt")
.and_then(Value::as_str)
@@ -956,20 +1066,35 @@ fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value,
apply_node_input(
prompt,
&prompt_node,
&mapping_field(mapping, &["prompt", "positive", "text", "promptNode"], "prompt"),
&mapping_field(
mapping,
&["prompt", "positive", "text", "promptNode"],
"prompt",
),
json!(video_prompt),
)?;
if let Some(negative_node) = mapping_node_id(mapping, &["negativePrompt", "negative", "negativeNode"]) {
if let Some(negative_node) =
mapping_node_id(mapping, &["negativePrompt", "negative", "negativeNode"])
{
let negative = string_from(body, &["negativePrompt", "negative_prompt", "negative"])
.unwrap_or_else(|| "low quality, blurry, distorted, watermark, text artifacts".to_string());
.unwrap_or_else(|| {
"low quality, blurry, distorted, watermark, text artifacts".to_string()
});
apply_node_input(
prompt,
&negative_node,
&mapping_field(mapping, &["negativePrompt", "negative", "negativeNode"], "text"),
&mapping_field(
mapping,
&["negativePrompt", "negative", "negativeNode"],
"text",
),
json!(negative),
)?;
}
if let Some(model) = string_from(body, &["comfyApiModel", "comfy_api_model", "apiModel", "api_model"]) {
if let Some(model) = string_from(
body,
&["comfyApiModel", "comfy_api_model", "apiModel", "api_model"],
) {
apply_optional_node_input(
prompt,
mapping,
@@ -978,10 +1103,16 @@ fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value,
json!(normalize_video_model(&model)),
);
}
if let Some(value) = ["ratio", "aspectRatio", "aspect_ratio"].iter().find_map(|key| body.get(*key).cloned()) {
if let Some(value) = ["ratio", "aspectRatio", "aspect_ratio"]
.iter()
.find_map(|key| body.get(*key).cloned())
{
apply_optional_node_input(prompt, mapping, &["ratio", "ratioNode"], "ratio", value);
}
if let Some(value) = ["resolution", "size"].iter().find_map(|key| body.get(*key).cloned()) {
if let Some(value) = ["resolution", "size"]
.iter()
.find_map(|key| body.get(*key).cloned())
{
apply_optional_node_input(
prompt,
mapping,
@@ -990,8 +1121,18 @@ fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value,
normalize_resolution_for_comfy(&value),
);
}
if let Some(value) = ["duration"].iter().find_map(|key| body.get(*key)).and_then(value_to_duration_seconds) {
apply_optional_node_input(prompt, mapping, &["duration", "durationNode"], "duration", json!(value));
if let Some(value) = ["duration"]
.iter()
.find_map(|key| body.get(*key))
.and_then(value_to_duration_seconds)
{
apply_optional_node_input(
prompt,
mapping,
&["duration", "durationNode"],
"duration",
json!(value),
);
}
repair_money_printer_comfy_prompt(prompt);
Ok(video_prompt)
@@ -1009,8 +1150,14 @@ fn repair_bytedance_video_nodes(prompt: &mut Value) {
return;
};
for (_node_id, node) in nodes.iter_mut() {
let class_type = node.get("class_type").and_then(Value::as_str).unwrap_or_default();
if !matches!(class_type, "ByteDanceTextToVideoNode" | "ByteDance2TextToVideoNode") {
let class_type = node
.get("class_type")
.and_then(Value::as_str)
.unwrap_or_default();
if !matches!(
class_type,
"ByteDanceTextToVideoNode" | "ByteDance2TextToVideoNode"
) {
continue;
}
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
@@ -1019,16 +1166,20 @@ fn repair_bytedance_video_nodes(prompt: &mut Value) {
if let Some(duration) = inputs.get("duration").and_then(value_to_duration_seconds) {
inputs.insert("duration".to_string(), json!(duration.clamp(3, 12)));
}
if let Some(duration) = inputs.get("model.duration").and_then(value_to_duration_seconds) {
if let Some(duration) = inputs
.get("model.duration")
.and_then(value_to_duration_seconds)
{
inputs.insert("model.duration".to_string(), json!(duration.clamp(4, 15)));
}
if inputs
.get("resolution")
.is_some_and(|value| {
let normalized = value.as_str().unwrap_or_default().trim().to_ascii_lowercase();
!matches!(normalized.as_str(), "480p" | "720p" | "1080p")
})
{
if inputs.get("resolution").is_some_and(|value| {
let normalized = value
.as_str()
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
!matches!(normalized.as_str(), "480p" | "720p" | "1080p")
}) {
inputs.insert("resolution".to_string(), json!("480p"));
} else if let Some(normalized) = inputs
.get("resolution")
@@ -1037,10 +1188,12 @@ fn repair_bytedance_video_nodes(prompt: &mut Value) {
{
inputs.insert("resolution".to_string(), json!(normalized));
}
if inputs
.get("aspect_ratio")
.is_some_and(|value| !matches!(value.as_str(), Some("16:9" | "4:3" | "1:1" | "3:4" | "9:16" | "21:9")))
{
if inputs.get("aspect_ratio").is_some_and(|value| {
!matches!(
value.as_str(),
Some("16:9" | "4:3" | "1:1" | "3:4" | "9:16" | "21:9")
)
}) {
inputs.insert("aspect_ratio".to_string(), json!("9:16"));
}
}
@@ -1061,7 +1214,10 @@ fn repair_resize_image_mask_nodes(prompt: &mut Value) {
.get("resize_type.crop")
.is_some_and(|value| !matches!(value.as_str(), Some("disabled" | "center")))
{
inputs.insert("resize_type.crop".to_string(), Value::String("center".to_string()));
inputs.insert(
"resize_type.crop".to_string(),
Value::String("center".to_string()),
);
}
}
}
@@ -1088,7 +1244,9 @@ fn repair_text_generate_ltx2_prompt_nodes(prompt: &mut Value) {
inputs.insert("sampling_mode.top_p".to_string(), json!(0.95));
inputs.insert("sampling_mode.min_p".to_string(), json!(0.05));
inputs.insert("sampling_mode.repetition_penalty".to_string(), json!(1.05));
inputs.entry("sampling_mode.seed".to_string()).or_insert_with(|| json!(0));
inputs
.entry("sampling_mode.seed".to_string())
.or_insert_with(|| json!(0));
inputs.insert("sampling_mode.presence_penalty".to_string(), json!(0.0));
}
}
@@ -1099,7 +1257,10 @@ fn repair_wan2_api_nodes(prompt: &mut Value) {
return;
};
for (_node_id, node) in nodes.iter_mut() {
let class_type = node.get("class_type").and_then(Value::as_str).unwrap_or_default();
let class_type = node
.get("class_type")
.and_then(Value::as_str)
.unwrap_or_default();
if !class_type.starts_with("Wan2") || !class_type.ends_with("Api") {
continue;
}
@@ -1117,7 +1278,10 @@ fn repair_wan2_model_group(inputs: &mut Map<String, Value>) {
inputs.insert("model".to_string(), model);
}
if let Some(resolution) = items.get(1).cloned() {
inputs.insert("model.resolution".to_string(), normalize_resolution_for_comfy(&resolution));
inputs.insert(
"model.resolution".to_string(),
normalize_resolution_for_comfy(&resolution),
);
}
if let Some(duration) = items.get(2).and_then(value_to_duration_seconds) {
inputs.insert("model.duration".to_string(), json!(duration));
@@ -1129,7 +1293,10 @@ fn repair_wan2_model_group(inputs: &mut Map<String, Value>) {
{
inputs.insert("model.resolution".to_string(), json!("1080P"));
}
if let Some(duration) = inputs.get("model.duration").and_then(value_to_duration_seconds) {
if let Some(duration) = inputs
.get("model.duration")
.and_then(value_to_duration_seconds)
{
inputs.insert("model.duration".to_string(), json!(duration));
}
}
@@ -1164,7 +1331,10 @@ fn comfy_failure_message(value: &Value) -> Option<String> {
let status = scalar_string(value.get("status").or_else(|| value.get("state")))
.unwrap_or_default()
.to_ascii_lowercase();
if matches!(status.as_str(), "failed" | "error" | "cancelled" | "canceled") {
if matches!(
status.as_str(),
"failed" | "error" | "cancelled" | "canceled"
) {
return Some(
scalar_string(value.pointer("/execution_error/exception_message"))
.or_else(|| scalar_string(value.get("error")))
@@ -1182,7 +1352,10 @@ fn comfy_output_objects(value: &Value) -> Vec<Value> {
let mut out = Vec::new();
fn push_unique(out: &mut Vec<Value>, item: Value) {
let identity = output_identity(&item);
if out.iter().any(|existing| output_identity(existing) == identity) {
if out
.iter()
.any(|existing| output_identity(existing) == identity)
{
return;
}
out.push(item);
@@ -1229,7 +1402,10 @@ fn output_identity(item: &Value) -> String {
fn looks_like_media_reference(value: &str) -> bool {
let trimmed = value.trim();
if !(trimmed.starts_with("http://") || trimmed.starts_with("https://") || trimmed.starts_with('/')) {
if !(trimmed.starts_with("http://")
|| trimmed.starts_with("https://")
|| trimmed.starts_with('/'))
{
return false;
}
let lower = trimmed
@@ -1328,7 +1504,8 @@ fn output_kind(item: &Value) -> &'static str {
|| filename.ends_with(".gif")
{
"video"
} else if filename.ends_with(".mp3") || filename.ends_with(".wav") || filename.ends_with(".m4a") {
} else if filename.ends_with(".mp3") || filename.ends_with(".wav") || filename.ends_with(".m4a")
{
"audio"
} else {
"image"
@@ -1390,7 +1567,11 @@ async fn archive_comfy_output(
}
let query = output_query(item)?;
let kind = output_kind(item).to_string();
let path = if kind == "video" { "/viewvideo" } else { "/view" };
let path = if kind == "video" {
"/viewvideo"
} else {
"/view"
};
let response = native_request(state, Method::GET, &format!("{path}{query}"))
.send()
.await
@@ -1455,9 +1636,12 @@ async fn submit_comfy_video(
}
let video_prompt = inject_comfy_video_inputs(&mut prompt, &mapping, body, plan)?;
let (prompt_id, response) = submit_comfy_prompt(state, prompt).await?;
let model = string_from(body, &["comfyApiModel", "comfy_api_model", "apiModel", "api_model"])
.map(|value| normalize_video_model(&value))
.unwrap_or_else(|| "comfy-api".to_string());
let model = string_from(
body,
&["comfyApiModel", "comfy_api_model", "apiModel", "api_model"],
)
.map(|value| normalize_video_model(&value))
.unwrap_or_else(|| "comfy-api".to_string());
Ok(json!({
"backend": "comfy",
"promptId": prompt_id,
@@ -1488,7 +1672,8 @@ async fn submit_video(
return Err(AppError::BadRequest("video prompt is empty".into()));
}
let size = string_from(body, &["size", "resolution"]).unwrap_or_else(|| "1080p".to_string());
let ratio = string_from(body, &["ratio", "aspectRatio", "aspect_ratio"]).unwrap_or_else(|| "9:16".to_string());
let ratio = string_from(body, &["ratio", "aspectRatio", "aspect_ratio"])
.unwrap_or_else(|| "9:16".to_string());
let duration = body
.get("duration")
.and_then(Value::as_i64)
@@ -1559,7 +1744,10 @@ async fn poll_video(
.and_then(Value::as_str)
.unwrap_or_default()
.to_ascii_lowercase();
if matches!(state_text.as_str(), "failed" | "error" | "cancelled" | "canceled") {
if matches!(
state_text.as_str(),
"failed" | "error" | "cancelled" | "canceled"
) {
return Err(AppError::UpstreamError(value.to_string()));
}
tokio::time::sleep(Duration::from_secs(VIDEO_POLL_INTERVAL_SECS)).await;
@@ -1617,8 +1805,15 @@ async fn create_job(
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let auth = resolve_auth(&headers, &state).await?;
if !state.config.comfyui.uses_docker_backend() {
return Err(AppError::BadRequest(
"短视频工厂暂时只支持本地模型部署;当前 Comfy/RunningHub/Cloud API 路径已禁用。".into(),
));
}
if let Some(key_id) = auth.key_id {
UsageTracker::new(state.db.clone()).check_rate_limit(key_id).await?;
UsageTracker::new(state.db.clone())
.check_rate_limit(key_id)
.await?;
}
let topic = string_from(&body, &["topic", "prompt"])
.ok_or_else(|| AppError::BadRequest("topic is required".into()))?;
@@ -1656,7 +1851,15 @@ async fn create_job(
.to_ascii_lowercase();
let failure_model = string_from(
&failure_body,
&["comfyApiModel", "comfy_api_model", "apiModel", "api_model", "videoModel", "video_model", "model"],
&[
"comfyApiModel",
"comfy_api_model",
"apiModel",
"api_model",
"videoModel",
"video_model",
"model",
],
)
.map(|value| normalize_video_model(&value))
.unwrap_or_else(|| {
@@ -2021,7 +2224,10 @@ async fn clear_jobs(State(state): State<AppState>, headers: HeaderMap) -> AppRes
mod tests {
use super::*;
use crate::{
config::{AppConfig, AuthConfig, ComfyUIBackend, ComfyUIConfig, DbConfig, ProxyConfig, ServerConfig},
config::{
AppConfig, AuthConfig, ComfyUIBackend, ComfyUIConfig, DbConfig, ProxyConfig,
ServerConfig,
},
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
};
use axum::http::HeaderValue;
@@ -2083,7 +2289,7 @@ mod tests {
}
#[tokio::test]
async fn status_exposes_comfy_cloud_api_model_candidates() {
async fn status_disables_cloud_api_video_factory_but_keeps_diagnostics() {
let state = test_state_with_comfy_backend(ComfyUIBackend::Cloud).await;
let mut headers = HeaderMap::new();
headers.insert(
@@ -2095,7 +2301,9 @@ mod tests {
.await
.expect("status response");
assert_eq!(value["builtinCloudTemplate"]["enabled"], true);
assert_eq!(value["mode"], "disabled");
assert_eq!(value["disabled"], true);
assert_eq!(value["builtinCloudTemplate"]["enabled"], false);
assert_eq!(
value["builtinCloudTemplate"]["model"],
"seedance-1-0-lite-t2v-250428"
@@ -2134,8 +2342,13 @@ mod tests {
let outputs = comfy_output_objects(&history);
assert_eq!(outputs.len(), 3);
assert!(outputs.iter().any(|item| output_filename(item).as_deref() == Some("clip.mp4")));
assert!(outputs.iter().any(|item| output_remote_url(item).as_deref() == Some("https://cdn.example.com/poster.png")));
assert!(outputs
.iter()
.any(|item| output_filename(item).as_deref() == Some("clip.mp4")));
assert!(outputs
.iter()
.any(|item| output_remote_url(item).as_deref()
== Some("https://cdn.example.com/poster.png")));
assert!(outputs.iter().any(|item| output_kind(item) == "video"));
}
@@ -2154,7 +2367,10 @@ mod tests {
let outputs = comfy_output_objects(&history);
assert_eq!(outputs.len(), 1);
assert_eq!(output_remote_url(&outputs[0]).as_deref(), Some("https://cdn.example.com/final.mp4"));
assert_eq!(
output_remote_url(&outputs[0]).as_deref(),
Some("https://cdn.example.com/final.mp4")
);
}
#[test]
@@ -2163,7 +2379,9 @@ mod tests {
assert!(is_pending_history_status(StatusCode::ACCEPTED));
assert!(is_pending_history_status(StatusCode::NO_CONTENT));
assert!(!is_pending_history_status(StatusCode::OK));
assert!(!is_pending_history_status(StatusCode::INTERNAL_SERVER_ERROR));
assert!(!is_pending_history_status(
StatusCode::INTERNAL_SERVER_ERROR
));
}
#[test]
@@ -2201,10 +2419,29 @@ mod tests {
.expect("injecting Comfy video inputs should succeed");
assert_eq!(prompt_text, "cinematic product demo");
assert_eq!(prompt.pointer("/1/inputs/model").and_then(Value::as_str), Some("seedance-1-0-lite-t2v-250428"));
assert_eq!(prompt.pointer("/1/inputs/prompt").and_then(Value::as_str), Some("cinematic product demo"));
assert_eq!(prompt.pointer("/1/inputs/aspect_ratio").and_then(Value::as_str), Some("16:9"));
assert_eq!(prompt.pointer("/1/inputs/resolution").and_then(Value::as_str), Some("720p"));
assert_eq!(prompt.pointer("/1/inputs/duration").and_then(Value::as_i64), Some(8));
assert_eq!(
prompt.pointer("/1/inputs/model").and_then(Value::as_str),
Some("seedance-1-0-lite-t2v-250428")
);
assert_eq!(
prompt.pointer("/1/inputs/prompt").and_then(Value::as_str),
Some("cinematic product demo")
);
assert_eq!(
prompt
.pointer("/1/inputs/aspect_ratio")
.and_then(Value::as_str),
Some("16:9")
);
assert_eq!(
prompt
.pointer("/1/inputs/resolution")
.and_then(Value::as_str),
Some("720p")
);
assert_eq!(
prompt.pointer("/1/inputs/duration").and_then(Value::as_i64),
Some(8)
);
}
}
+4 -5
View File
@@ -1,21 +1,21 @@
use crate::{
AppState,
auth::jwt::validate_jwt,
db::models::background_task::BackgroundTask,
db::models::downstream_api_key::DownstreamApiKey,
error::{AppError, AppResult},
AppState,
};
use axum::{
Json, Router,
body::Body,
extract::{Multipart, Path, Query, State},
http::{HeaderMap, Response, StatusCode, header},
http::{header, HeaderMap, Response, StatusCode},
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use reqwest::Client;
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
use std::time::Duration;
const TEMPLATE_GALLERY_PAGE_URL: &str =
@@ -291,7 +291,6 @@ mod tests {
assert!(!is_picture_model_pattern("deepseek-chat"));
assert!(!is_picture_model_pattern("qwen-plus"));
}
}
fn prompt_title(prompt: &str, post_number: Option<i64>) -> String {
File diff suppressed because it is too large Load Diff
+311
View File
@@ -0,0 +1,311 @@
use super::{
cache_control_for_path, content_type_for_path, default_boosapi_workflow_graph,
embedded_workflow_ui_response, inject_bootstrap, local_workflow_template_asset,
native_workflow_base, simple_urlencode, text_to_video_workflow_graph,
workflow_template_not_found_response, EMBEDDED_WORKFLOW_UI, EMBEDDED_WORKFLOW_UI_CLOUD,
EMBEDDED_WORKFLOW_UI_EXTERNAL, OFFICIAL_WORKFLOW_TEMPLATES_BASE,
};
use crate::error::{AppError, AppResult};
use crate::AppState;
use axum::{
extract::{Path, State},
http::{header, StatusCode},
response::{Html, IntoResponse, Response},
Json,
};
use include_dir::Dir;
use serde_json::{json, Value};
use std::path::{Path as StdPath, PathBuf};
pub(super) fn workflow_ui_root_for_config(config: &crate::config::ComfyUIConfig) -> PathBuf {
if let Ok(path) = std::env::var("BOOSAPI_WORKFLOW_UI_DIR") {
return resolve_workflow_ui_dir(PathBuf::from(path));
}
let preferred = resolve_workflow_ui_dir(if config.uses_cloud_backend() {
std::env::var("BOOSAPI_WORKFLOW_UI_CLOUD_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("dist/workflow-ui-cloud"))
} else if config.uses_native_backend() {
std::env::var("BOOSAPI_WORKFLOW_UI_EXTERNAL_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("dist/workflow-ui-external"))
} else {
PathBuf::from("dist/workflow-ui")
});
if workflow_ui_dir_is_usable(&preferred) {
preferred
} else {
resolve_workflow_ui_dir(PathBuf::from("dist/workflow-ui"))
}
}
pub(super) fn resolve_workflow_ui_dir(path: PathBuf) -> PathBuf {
if path.is_absolute() || workflow_ui_dir_is_usable(&path) {
return path;
}
let manifest_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(&path);
if workflow_ui_dir_is_usable(&manifest_path) {
manifest_path
} else {
path
}
}
pub(super) fn embedded_workflow_ui_for_config(
config: &crate::config::ComfyUIConfig,
) -> &'static Dir<'static> {
if config.uses_cloud_backend() && embedded_workflow_ui_is_usable(&EMBEDDED_WORKFLOW_UI_CLOUD) {
&EMBEDDED_WORKFLOW_UI_CLOUD
} else if config.uses_native_backend()
&& embedded_workflow_ui_is_usable(&EMBEDDED_WORKFLOW_UI_EXTERNAL)
{
&EMBEDDED_WORKFLOW_UI_EXTERNAL
} else {
&EMBEDDED_WORKFLOW_UI
}
}
pub(super) fn workflow_ui_dir_is_usable(dir: &PathBuf) -> bool {
let index = dir.join("index.html");
std::fs::read_to_string(index)
.map(|html| !workflow_ui_index_is_placeholder(&html))
.unwrap_or(false)
}
pub(super) fn embedded_workflow_ui_is_usable(dir: &Dir<'static>) -> bool {
dir.get_file("index.html")
.and_then(|file| std::str::from_utf8(file.contents()).ok())
.map(|html| !workflow_ui_index_is_placeholder(html))
.unwrap_or(false)
}
pub(super) fn workflow_ui_index_is_placeholder(html: &str) -> bool {
html.contains("Workflow UI not built")
}
pub(super) async fn workflow_ui_runtime(State(state): State<AppState>) -> Json<Value> {
let mode = if state.config.comfyui.uses_docker_backend() {
"docker"
} else if state.config.comfyui.uses_cloud_backend() {
"cloud"
} else if state.config.comfyui.uses_runninghub_backend() {
"runninghub"
} else if state.config.comfyui.uses_native_backend() {
"external"
} else {
"boosapi"
};
let frontend_dir = workflow_ui_root_for_config(&state.config.comfyui);
Json(json!({
"mode": mode,
"baseUrl": state.config.comfyui.target_base_url(),
"frontendDir": frontend_dir,
"frontendBuild": workflow_ui_build_metadata(&frontend_dir),
"cloudApiKeyConfigured": workflow_cloud_api_key(&state.config.comfyui).is_some(),
}))
}
pub(super) fn workflow_ui_build_metadata(dir: &StdPath) -> Value {
let path = dir.join("boosapi-workflow-ui.json");
std::fs::read_to_string(path)
.ok()
.and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
.unwrap_or(Value::Null)
}
pub(super) fn workflow_cloud_api_key(config: &crate::config::ComfyUIConfig) -> Option<&str> {
config
.api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
}
pub(super) async fn workflow_ui_index(State(state): State<AppState>) -> Html<String> {
let dir = workflow_ui_root_for_config(&state.config.comfyui);
let index = dir.join("index.html");
match tokio::fs::read_to_string(&index).await {
Ok(html) => Html(inject_bootstrap(html, &state.config.comfyui)),
Err(_) => {
if let Some(file) =
embedded_workflow_ui_for_config(&state.config.comfyui).get_file("index.html")
{
let html = String::from_utf8_lossy(file.contents()).to_string();
return Html(inject_bootstrap(html, &state.config.comfyui));
}
Html(
r#"<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BoosAPI Workflow Studio</title>
<style>
html, body { margin: 0; width: 100%; height: 100%; background: #111827; color: #e5e7eb; font-family: system-ui, sans-serif; }
.wrap { display: grid; place-items: center; height: 100%; padding: 24px; text-align: center; }
.box { max-width: 720px; line-height: 1.7; }
code { background: rgba(255,255,255,0.08); padding: 2px 6px; border-radius: 4px; }
</style>
</head>
<body>
<div class="wrap">
<div class="box">
<h1>Workflow Studio 前端未构建</h1>
<p>官方 ComfyUI 前端会按后端类型从 <code>dist/workflow-ui-external</code> 或 <code>dist/workflow-ui-cloud</code> 加载。请先运行 <code>npm run workflow-ui:install</code>,然后刷新此页面。</p>
</div>
</div>
</body>
</html>"#
.to_string(),
)
}
}
}
pub(super) async fn workflow_ui_static(
State(state): State<AppState>,
Path(path): Path<String>,
) -> AppResult<Response> {
let dir = workflow_ui_root_for_config(&state.config.comfyui);
let path = path.trim_start_matches('/').to_string();
if path == "api/workflow_templates/boosapi/文生图示例.json" {
return Ok(Json(default_boosapi_workflow_graph()).into_response());
}
if path == "api/workflow_templates/boosapi/文生视频示例.json" {
return Ok(Json(text_to_video_workflow_graph()).into_response());
}
let file_path = if path.is_empty() {
dir.join("index.html")
} else {
dir.join(&path)
};
if file_path.is_dir() {
return serve_file(file_path.join("index.html")).await;
}
if tokio::fs::metadata(&file_path).await.is_ok() {
return serve_file(file_path).await;
}
if let Some(response) =
embedded_workflow_ui_response(&state.config.comfyui, &path, path.is_empty())
{
return Ok(response);
}
if PathBuf::from(&path).extension().is_none() {
return Ok(workflow_ui_index(State(state)).await.into_response());
}
serve_file(file_path).await
}
pub(super) async fn official_workflow_template_asset(
State(state): State<AppState>,
Path(path): Path<String>,
) -> AppResult<Response> {
let path = path.trim_start_matches('/').replace('\\', "/");
if path.is_empty() || path.contains("..") {
return Err(AppError::BadRequest("invalid template path".into()));
}
if !state.config.comfyui.uses_cloud_backend() {
if let Some(response) = local_workflow_template_asset(&path) {
return Ok(response);
}
}
let encoded_path = path
.split('/')
.map(simple_urlencode)
.collect::<Vec<_>>()
.join("/");
let url = if state.config.comfyui.uses_cloud_backend() {
format!(
"{}/templates/{}",
native_workflow_base(&state),
encoded_path
)
} else {
format!("{}/{}", OFFICIAL_WORKFLOW_TEMPLATES_BASE, encoded_path)
};
let mut request =
crate::template_http::client(&state.config, state.config.proxy.upstream_timeout_secs)
.get(url);
if state.config.comfyui.uses_cloud_backend() {
if let Some(api_key) = state
.config
.comfyui
.api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
request = request
.header("X-API-Key", api_key)
.header("Authorization", format!("Bearer {api_key}"));
}
}
let response = match request.send().await {
Ok(response) => response,
Err(err) => {
tracing::warn!(%err, path, "workflow template remote source unavailable");
if let Some(response) = local_workflow_template_asset(&path) {
return Ok(response);
}
return Ok(workflow_template_not_found_response(&path));
}
};
let status = response.status();
let bytes = match response.bytes().await {
Ok(bytes) => bytes,
Err(err) => {
tracing::warn!(%err, path, "workflow template remote body unavailable");
if let Some(response) = local_workflow_template_asset(&path) {
return Ok(response);
}
return Ok(workflow_template_not_found_response(&path));
}
};
Ok((
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY),
[
(
header::CONTENT_TYPE,
content_type_for_path(&PathBuf::from(&path)),
),
(
header::CACHE_CONTROL,
if path == "index.json" {
"public, max-age=1800"
} else {
"public, max-age=86400"
},
),
],
bytes,
)
.into_response())
}
pub(super) async fn serve_file(path: PathBuf) -> AppResult<Response> {
match tokio::fs::read(&path).await {
Ok(bytes) => {
let content_type = content_type_for_path(&path);
let cache_control = cache_control_for_path(&path);
Ok((
StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, content_type),
(axum::http::header::CACHE_CONTROL, cache_control),
],
bytes,
)
.into_response())
}
Err(_) => Ok((
StatusCode::NOT_FOUND,
[(
axum::http::header::CONTENT_TYPE,
"text/plain; charset=utf-8",
)],
"workflow-ui asset not found".to_string(),
)
.into_response()),
}
}
+62
View File
@@ -0,0 +1,62 @@
use super::{parse_workflow_cookie, WorkflowCaller};
use crate::auth::jwt::validate_jwt;
use crate::error::{AppError, AppResult};
use crate::AppState;
use axum::http::HeaderMap;
pub(super) fn caller_from_headers(
state: &AppState,
headers: &HeaderMap,
) -> AppResult<WorkflowCaller> {
let token = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| {
value
.strip_prefix("Bearer ")
.or_else(|| value.strip_prefix("bearer "))
})
.or_else(|| {
headers
.get(axum::http::header::COOKIE)
.and_then(|value| value.to_str().ok())
.and_then(parse_workflow_cookie)
})
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| AppError::Unauthorized("Missing authorization token".into()))?;
let proxy_token = state.runtime_proxy_token();
if token == state.config.auth.auth_token || token == proxy_token {
return Ok(WorkflowCaller::Admin);
}
let claims = validate_jwt(&state.config.auth.jwt_secret, token)
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
if claims.role == "admin" {
return Ok(WorkflowCaller::Admin);
}
if claims.role == "user" {
let user_id = claims
.sub
.parse::<i64>()
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))?;
return Ok(WorkflowCaller::User { user_id });
}
Err(AppError::Forbidden("Workflow access denied".into()))
}
pub(super) fn caller_user_id(caller: &WorkflowCaller) -> Option<i64> {
match caller {
WorkflowCaller::Admin => None,
WorkflowCaller::User { user_id } => Some(*user_id),
}
}
pub(super) fn caller_is_admin(caller: &WorkflowCaller) -> bool {
matches!(caller, WorkflowCaller::Admin)
}
pub(super) async fn auth_caller(state: &AppState, headers: &HeaderMap) -> AppResult<WorkflowCaller> {
caller_from_headers(state, headers)
}
+799
View File
@@ -0,0 +1,799 @@
pub(super) fn inject_bootstrap(html: String, config: &crate::config::ComfyUIConfig) -> String {
if config.uses_native_backend() && !config.uses_cloud_backend() {
return inject_external_bootstrap(html);
}
inject_enhanced_bootstrap(html)
}
fn workflow_secret_cleanup_bootstrap() -> &'static str {
r#"<script>
(function () {
const clearLeakedComfyApiKey = () => {
try {
localStorage.removeItem('comfy_api_key');
sessionStorage.removeItem('comfy_api_key');
document.cookie = 'comfy_api_key=; Path=/workflow-ui; Max-Age=0; SameSite=Lax';
} catch (err) {}
};
clearLeakedComfyApiKey();
window.addEventListener('DOMContentLoaded', clearLeakedComfyApiKey);
})();
</script>"#
}
fn inject_external_bootstrap(html: String) -> String {
let secret_cleanup_bootstrap = workflow_secret_cleanup_bootstrap();
let shell = r#"<script>
(function () {
const renameComfyUiBrand = () => {
document.title = document.title.replace(/ComfyUI/g, 'BoosAI');
const candidates = Array.from(document.querySelectorAll('header *, nav *, .comfy-menu-header *, .comfyui-menu *, .p-menubar *')).slice(0, 80);
for (const el of candidates) {
if (el.childNodes.length === 1 && el.firstChild && el.firstChild.nodeType === Node.TEXT_NODE) {
const value = String(el.textContent || '').trim();
if (value === 'ComfyUI' || value === 'Comfy UI') el.textContent = 'BoosAI';
}
}
};
try {
const userJwt = localStorage.getItem('user_jwt_token');
const adminToken = localStorage.getItem('auth_token');
const token = adminToken || userJwt;
if (token) {
localStorage.setItem('auth_token', token);
document.cookie = 'boosapi_workflow_token=' + encodeURIComponent(token) + '; Path=/workflow-ui; SameSite=Lax';
}
} catch (err) {}
const getCurrentWorkflowJson = async () => {
try {
if (window.app?.graphToPrompt) {
const result = await window.app.graphToPrompt();
return result?.workflow || result?.output || result?.prompt || result;
}
} catch (err) {}
try {
const graph = window.app?.graph || window.LGraphCanvas?.active_canvas?.graph;
if (graph?.serialize) return graph.serialize();
} catch (err) {}
return null;
};
const installWorkflowValidationButton = () => {
if (document.getElementById('boosapi-workflow-validate-button')) return;
const button = document.createElement('button');
button.id = 'boosapi-workflow-validate-button';
button.type = 'button';
button.textContent = '检查模板';
button.title = '检查当前工作流的节点、模型和输入素材兼容性';
button.style.cssText = 'position:fixed;right:112px;top:10px;z-index:10000;height:32px;padding:0 12px;border:1px solid rgba(120,130,150,.45);border-radius:6px;background:rgba(20,24,32,.88);color:#fff;font-size:13px;cursor:pointer;';
button.addEventListener('click', async () => {
button.disabled = true;
const oldText = button.textContent;
button.textContent = '检查中...';
try {
const workflow = await getCurrentWorkflowJson();
if (!workflow) throw new Error('没有读取到当前工作流');
const response = await fetch('/workflow-ui/api/workflow/validate?check_files=true', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(workflow),
});
const report = await response.json();
const summary = report?.summary || {};
const lines = [`节点 ${summary.nodes || 0},连线 ${summary.links || 0},错误 ${summary.errors || 0},警告 ${summary.warnings || 0}`];
for (const item of report?.missingNodeTypes || []) lines.push(`缺失节点: ${item.nodeType} #${item.nodeId}${item.connected ? '(已连接)' : '(未连接)'}`);
for (const item of report?.emptyModelFields || []) lines.push(`空模型列表: ${item.nodeType} #${item.nodeId} ${item.field} = ${item.selected}`);
for (const item of report?.missingModelChoices || []) lines.push(`缺失模型: ${item.nodeType} #${item.nodeId} ${item.field} = ${item.selected}`);
for (const item of report?.inputFiles || []) if (item.checked) lines.push(`素材: ${item.filename} ${item.reachable ? '可访问' : '不可访问'}`);
if (report?.hints?.length) lines.push('', ...report.hints);
window.alert(lines.join('\n'));
} catch (err) {
window.alert('检查失败:' + (err?.message || err));
} finally {
button.disabled = false;
button.textContent = oldText;
}
});
document.body.appendChild(button);
};
window.setTimeout(renameComfyUiBrand, 250);
window.setTimeout(renameComfyUiBrand, 1200);
window.setTimeout(installWorkflowValidationButton, 800);
window.setTimeout(installWorkflowValidationButton, 1800);
})();
</script>"#;
let bootstrap = format!("{secret_cleanup_bootstrap}{shell}");
html.replace("</head>", &format!("{bootstrap}\n</head>"))
}
pub(super) fn inject_enhanced_bootstrap(html: String) -> String {
let secret_cleanup_bootstrap = workflow_secret_cleanup_bootstrap();
let shell = r#"<style id="boosapi-workflow-shell-style">
</style>
<script>
(function () {
const renameComfyUiBrand = () => {
document.title = document.title.replace(/ComfyUI/g, 'BoosAI');
const candidates = Array.from(document.querySelectorAll('header *, nav *, .comfy-menu-header *, .comfyui-menu *, .p-menubar *')).slice(0, 120);
for (const el of candidates) {
if (el.childNodes.length === 1 && el.firstChild && el.firstChild.nodeType === Node.TEXT_NODE) {
const value = String(el.textContent || '').trim();
if (value === 'ComfyUI' || value === 'Comfy UI') {
el.textContent = 'BoosAI';
}
}
for (const attr of ['aria-label', 'title']) {
const value = el.getAttribute(attr);
if (value && /Comfy\s*UI|ComfyUI/.test(value)) {
el.setAttribute(attr, value.replace(/Comfy\s*UI|ComfyUI/g, 'BoosAI'));
}
}
}
};
const installBoosApiFetchAuth = () => {
try {
const api = window.app?.api || window.comfyAPI?.api || window.api;
if (!api || api.__boosapiFetchAuthPatch || typeof api.fetchApi !== 'function') return;
const originalFetchApi = api.fetchApi.bind(api);
api.fetchApi = function(route, options) {
options ||= {};
const headers = new Headers(options.headers || {});
const token = localStorage.getItem('auth_token') || localStorage.getItem('user_jwt_token');
if (token && !headers.has('Authorization')) headers.set('Authorization', 'Bearer ' + token);
options.headers = headers;
options.credentials ||= 'same-origin';
return originalFetchApi(route, options);
};
api.__boosapiFetchAuthPatch = true;
} catch (err) {}
};
const workflowTextMap = new Map(Object.entries({
'Add Node': '添加节点',
'Search': '搜索',
'Reroute': '转接点',
'PreviewImage': '预览图像',
'SaveImage': '保存图像',
'Load Image': '加载图像',
'CheckpointLoaderSimple': 'Checkpoint 加载器(简易)',
'CLIPTextEncode': 'CLIP 文本编码',
'KSampler': 'K 采样器',
'VAEDecode': 'VAE 解码',
'VAEEncode': 'VAE 编码',
'EmptyLatentImage': '空潜空间图像',
'Latent': '潜空间',
'Input': '输入',
'Output': '输出',
'Util': '工具',
'utils': '工具',
'ComfyUI': 'BoosAI',
'IMAGE': '图像',
'LATENT': '潜空间',
'MODEL': '模型',
'CLIP': 'CLIP',
'VAE': 'VAE',
'CONDITIONING': '条件',
'BoosAPI': 'BoosAPI',
'boosapi.image_to_video': 'BoosAPI 图生视频',
'boosapi.text_to_video': 'BoosAPI 文生视频',
'boosapi.video_status': '查询视频任务',
'output.preview_video': '输出:预览视频',
'output.preview_image': '输出:预览图像',
'input.text': '输入:文本',
'boosapi.chat_completion': 'BoosAPI 对话',
'boosapi.prompt_rewrite': 'BoosAPI 提示词改写',
'boosapi.text_to_image': 'BoosAPI 文生图',
'output.save_file': '输出:保存文件'
}));
const translateWorkflowText = (text) => {
const raw = String(text || '');
const trimmed = raw.replace(/\s+/g, ' ').trim();
if (!trimmed) return null;
if (workflowTextMap.has(trimmed)) return workflowTextMap.get(trimmed);
return null;
};
const localizeWorkflowMenus = () => {
const selector = [
'.litecontextmenu .litemenu-entry',
'.litecontextmenu .litemenu-title',
'.litecontextmenu .litemenu-submenu',
'.litecontextmenu li',
'.litecontextmenu div',
'.p-contextmenu [role="menuitem"]',
'.p-contextmenu .p-menuitem-text',
'.p-tieredmenu [role="menuitem"]',
'.p-tieredmenu .p-menuitem-text',
'[role="menu"] [role="menuitem"]'
].join(',');
for (const el of document.querySelectorAll(selector)) {
if (!el || el.children.length > 1) continue;
const textNode = Array.from(el.childNodes).find((node) => node.nodeType === Node.TEXT_NODE && String(node.textContent || '').trim());
const target = textNode || (el.children.length === 0 ? el : null);
if (!target) continue;
const translated = translateWorkflowText(target.textContent);
if (translated && target.textContent !== translated) target.textContent = translated;
}
for (const el of document.querySelectorAll('[title],[aria-label]')) {
for (const attr of ['title', 'aria-label']) {
const translated = translateWorkflowText(el.getAttribute(attr));
if (translated) el.setAttribute(attr, translated);
}
}
};
const observeWorkflowMenuLocalization = () => {
if (window.__boosapiWorkflowMenuI18nObserver) return;
window.__boosapiWorkflowMenuI18nObserver = true;
const observer = new MutationObserver((mutations) => {
if (mutations.some((item) => item.addedNodes && item.addedNodes.length)) {
window.requestAnimationFrame(localizeWorkflowMenus);
}
});
observer.observe(document.body, { childList: true, subtree: true });
};
const normalizeWorkflowPortType = (value) => {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'conditioning' || normalized === 'condition' || normalized === 'cond' || normalized === '条件' || normalized === '条件化' || normalized === '正面条件' || normalized === '负面条件') return 'conditioning';
if (normalized === 'latent' || normalized === '潜空间') return 'latent';
if (normalized === 'model' || normalized === '模型') return 'model';
if (normalized === 'clip') return 'clip';
if (normalized === 'vae') return 'vae';
if (normalized === 'image' || normalized === '图像' || normalized === '图片') return 'image';
if (normalized === 'video' || normalized === '视频') return 'video';
return normalized;
};
const normalizeWorkflowSlotType = (slot) => {
if (!slot) return;
const normalized = normalizeWorkflowPortType(slot.type || slot.name || slot.localized_name);
const canonical = {
conditioning: 'CONDITIONING',
latent: 'LATENT',
model: 'MODEL',
clip: 'CLIP',
vae: 'VAE',
image: 'IMAGE',
video: 'VIDEO'
}[normalized];
if (canonical) slot.type = canonical;
};
const slotName = (slot) => String(slot?.name || slot?.localized_name || slot?.label || '').trim().toLowerCase();
const normalizeWorkflowNodeSlots = (node) => {
try {
if (!node) return;
if (node.type === 'CLIPTextEncode' && Array.isArray(node.outputs)) {
for (const output of node.outputs) {
const name = slotName(output);
if (name.includes('conditioning') || name.includes('condition') || name.includes('条件') || name === 'conditioning') {
output.type = 'CONDITIONING';
output.name = output.name || 'CONDITIONING';
output.localized_name = output.localized_name || '条件';
}
}
}
if (node.type === 'KSampler' && Array.isArray(node.inputs)) {
for (const input of node.inputs) {
const name = slotName(input);
if (name === 'positive' || name === 'negative' || name.includes('positive') || name.includes('negative') || name.includes('正面条件') || name.includes('负面条件')) {
input.type = 'CONDITIONING';
}
}
}
node.inputs?.forEach(normalizeWorkflowSlotType);
node.outputs?.forEach(normalizeWorkflowSlotType);
} catch (err) {}
};
const normalizeWorkflowGraphSlots = () => {
try {
const graph = window.app?.graph || window.LGraphCanvas?.active_canvas?.graph;
if (!graph?.nodes) return;
for (const node of graph.nodes) normalizeWorkflowNodeSlots(node);
graph.setDirtyCanvas?.(true, true);
window.app?.canvas?.setDirty?.(true, true);
} catch (err) {}
};
const patchWorkflowConnectionTypes = () => {
try {
const liteGraph = window.LiteGraph;
if (!liteGraph || liteGraph.__boosapiConnectionPatch) return;
const original = liteGraph.isValidConnection;
if (typeof original !== 'function') return;
liteGraph.isValidConnection = function (typeA, typeB) {
const a = normalizeWorkflowPortType(typeA);
const b = normalizeWorkflowPortType(typeB);
if (a && b && a === b) return true;
return original.call(this, typeA, typeB);
};
const proto = window.LGraphNode?.prototype;
if (proto?.connect && !proto.__boosapiConnectPatch) {
const originalConnect = proto.connect;
proto.connect = function (slot, targetNode, targetSlot, afterRerouteId) {
normalizeWorkflowNodeSlots(this);
normalizeWorkflowNodeSlots(targetNode);
return originalConnect.call(this, slot, targetNode, targetSlot, afterRerouteId);
};
proto.__boosapiConnectPatch = true;
}
const originalCreateNode = liteGraph.createNode;
if (typeof originalCreateNode === 'function' && !liteGraph.__boosapiCreateNodePatch) {
liteGraph.createNode = function () {
const node = originalCreateNode.apply(this, arguments);
normalizeWorkflowNodeSlots(node);
return node;
};
liteGraph.__boosapiCreateNodePatch = true;
}
liteGraph.__boosapiConnectionPatch = true;
normalizeWorkflowGraphSlots();
} catch (err) {}
};
const startWorkflowSlotNormalizer = () => {
if (window.__boosapiWorkflowSlotNormalizer) return;
window.__boosapiWorkflowSlotNormalizer = true;
window.setInterval(normalizeWorkflowGraphSlots, 1000);
window.setTimeout(normalizeWorkflowGraphSlots, 100);
window.setTimeout(normalizeWorkflowGraphSlots, 600);
window.setTimeout(normalizeWorkflowGraphSlots, 1500);
};
const workflowConnectionQuickNodes = {
model: [
{ title: 'K 采样器', types: ['KSampler'], hint: '把模型接入采样流程' },
{ title: 'LoRA 加载器', types: ['LoraLoader', 'LoraLoaderModelOnly'], hint: '给模型叠加 LoRA' },
{ title: '模型采样设置', types: ['ModelSamplingDiscrete', 'ModelSamplingContinuousEDM'], hint: '调整模型采样方式' },
{ title: '保存 Checkpoint', types: ['CheckpointSave'], hint: '保存模型/CLIP/VAE 组合' }
],
clip: [
{ title: 'CLIP 文本编码', types: ['CLIPTextEncode'], hint: '输入提示词生成条件' },
{ title: 'CLIP 跳层', types: ['CLIPSetLastLayer'], hint: '设置 CLIP skip' },
{ title: 'LoRA 加载器', types: ['LoraLoader'], hint: '同时接入模型与 CLIP' },
{ title: '保存 Checkpoint', types: ['CheckpointSave'], hint: '保存模型/CLIP/VAE 组合' }
],
vae: [
{ title: 'VAE 解码', types: ['VAEDecode', 'VAEDecodeTiled'], hint: '潜空间转图像' },
{ title: 'VAE 编码', types: ['VAEEncode', 'VAEEncodeTiled'], hint: '图像转潜空间' },
{ title: '保存 Checkpoint', types: ['CheckpointSave'], hint: '保存模型/CLIP/VAE 组合' }
],
image: [
{ title: '预览图像', types: ['PreviewImage'], hint: '查看这一条图像输出' },
{ title: '保存图像', types: ['SaveImage'], hint: '保存到输出目录' },
{ title: 'VAE 编码', types: ['VAEEncode', 'VAEEncodeTiled'], hint: '图像转潜空间' },
{ title: '放大图像', types: ['ImageScale', 'ImageScaleBy', 'ImageUpscaleWithModel'], hint: '调整尺寸/放大' },
{ title: '图像对比', types: ['Image Comparer (rgthree)', 'Image Comparer', 'ImageCompare'], hint: '对比输入图像' },
{ title: '图像转遮罩', types: ['ImageToMask'], hint: '从图像提取遮罩' }
],
latent: [
{ title: 'VAE 解码', types: ['VAEDecode', 'VAEDecodeTiled'], hint: '潜空间转图像' },
{ title: 'K 采样器', types: ['KSampler'], hint: '继续采样' },
{ title: '潜空间放大', types: ['LatentUpscale', 'LatentUpscaleBy'], hint: '调整潜空间尺寸' },
{ title: '潜空间合成', types: ['LatentComposite'], hint: '合成两个潜空间' }
],
conditioning: [
{ title: 'K 采样器', types: ['KSampler'], hint: '接入正/负条件' },
{ title: '合并条件', types: ['ConditioningCombine', 'ConditioningConcat'], hint: '组合提示条件' },
{ title: '条件平均', types: ['ConditioningAverage'], hint: '混合两组条件' },
{ title: '条件区域', types: ['ConditioningSetArea'], hint: '限制条件作用区域' }
],
mask: [
{ title: '遮罩转图像', types: ['MaskToImage'], hint: '查看/保存遮罩' },
{ title: '图像转遮罩', types: ['ImageToMask'], hint: '从图像提取遮罩' },
{ title: '遮罩合成', types: ['MaskComposite'], hint: '组合遮罩' },
{ title: '遮罩扩张', types: ['GrowMask'], hint: '扩大或收缩遮罩' }
],
video: [
{ title: '预览视频', types: ['VHS_VideoCombine', 'Video Combine 🎥🅥🅗🅢', 'VideoCombine'], hint: '合成为可预览视频' },
{ title: '保存动画', types: ['SaveAnimatedPNG', 'SaveAnimatedWEBP'], hint: '保存序列动画' },
{ title: '加载视频', types: ['VHS_LoadVideo', 'LoadVideo'], hint: '读取视频素材' }
],
audio: [
{ title: '预览音频', types: ['PreviewAudio', 'SaveAudio'], hint: '试听或保存音频' },
{ title: '加载音频', types: ['LoadAudio'], hint: '读取音频素材' }
],
string: [
{ title: '文本输入', types: ['PrimitiveNode', 'StringConstant', 'Text Input'], hint: '创建文本值' },
{ title: '显示文本', types: ['ShowText|pysssss', 'ShowText', 'PreviewText'], hint: '查看文本输出' }
],
int: [
{ title: '数值输入', types: ['PrimitiveNode', 'Integer Constant'], hint: '创建整数值' }
],
float: [
{ title: '数值输入', types: ['PrimitiveNode', 'Float Constant'], hint: '创建小数值' }
],
boolean: [
{ title: '开关输入', types: ['PrimitiveNode', 'Boolean Constant'], hint: '创建布尔值' }
]
};
const findWorkflowNodeType = (candidates) => {
const registry = window.LiteGraph?.registered_node_types || {};
for (const name of candidates) {
if (registry[name]) return name;
}
const entries = Object.keys(registry);
for (const name of candidates) {
const normalized = String(name).toLowerCase().replace(/[\s_-]+/g, '');
const found = entries.find((item) => String(item).toLowerCase().replace(/[\s_-]+/g, '') === normalized);
if (found) return found;
}
return '';
};
const getWorkflowConnectionType = (options) => {
try {
const link = options || window.__boosapiLastConnectionMenuOptions || {};
if (link.nodeFrom && Number.isInteger(link.slotFrom)) {
return normalizeWorkflowPortType(link.nodeFrom.outputs?.[link.slotFrom]?.type || link.nodeFrom.outputs?.[link.slotFrom]?.name);
}
if (link.nodeTo && Number.isInteger(link.slotTo)) {
return normalizeWorkflowPortType(link.nodeTo.inputs?.[link.slotTo]?.type || link.nodeTo.inputs?.[link.slotTo]?.name);
}
const title = String(document.querySelector('.litecontextmenu .litemenu-title')?.textContent || '');
if (title.includes('IMAGE') || title.includes('图像')) return 'image';
if (title.includes('LATENT') || title.includes('潜空间')) return 'latent';
if (title.includes('CONDITIONING') || title.includes('条件')) return 'conditioning';
if (title.includes('MODEL') || title.includes('模型')) return 'model';
if (title.includes('CLIP')) return 'clip';
if (title.includes('VAE')) return 'vae';
if (title.includes('MASK') || title.includes('遮罩')) return 'mask';
if (title.includes('VIDEO') || title.includes('视频')) return 'video';
if (title.includes('AUDIO') || title.includes('音频')) return 'audio';
if (title.includes('STRING') || title.includes('TEXT') || title.includes('文本')) return 'string';
if (title.includes('INT') || title.includes('整数')) return 'int';
if (title.includes('FLOAT') || title.includes('NUMBER') || title.includes('数字')) return 'float';
if (title.includes('BOOLEAN') || title.includes('BOOL') || title.includes('布尔')) return 'boolean';
} catch (err) {}
return '';
};
const workflowNodeDisplayTitle = (nodeType) => {
const registry = window.LiteGraph?.registered_node_types || {};
const ctor = registry[nodeType];
return String(ctor?.title || ctor?.nodeData?.display_name || ctor?.comfyClass || nodeType || '').trim();
};
const fallbackWorkflowConnectionItems = (connectionType) => {
try {
const options = window.__boosapiLastConnectionMenuOptions || {};
const liteGraph = window.LiteGraph || {};
const canonical = {
image: 'IMAGE',
latent: 'LATENT',
conditioning: 'CONDITIONING',
model: 'MODEL',
clip: 'CLIP',
vae: 'VAE',
mask: 'MASK',
video: 'VIDEO',
audio: 'AUDIO',
string: 'STRING',
int: 'INT',
float: 'FLOAT',
boolean: 'BOOLEAN'
}[connectionType] || String(connectionType || '').toUpperCase();
const defaults = options.nodeFrom
? liteGraph.slot_types_default_out?.[canonical]
: liteGraph.slot_types_default_in?.[canonical];
const registered = options.nodeFrom
? liteGraph.registered_slot_in_types?.[String(canonical).toLowerCase()]?.nodes
: liteGraph.registered_slot_out_types?.[canonical]?.nodes;
const names = [...(Array.isArray(defaults) ? defaults : []), ...(Array.isArray(registered) ? registered : [])]
.map((value) => typeof value === 'string' ? value : value?.comfyClass || value?.type || value?.title)
.filter(Boolean);
return [...new Set(names)].slice(0, 8).map((nodeType) => ({
title: workflowNodeDisplayTitle(nodeType),
types: [nodeType],
hint: `${canonical} 兼容节点`
}));
} catch (err) {
return [];
}
};
const findCompatibleSlotIndex = (slots, type) => {
const wanted = normalizeWorkflowPortType(type);
if (!Array.isArray(slots)) return 0;
const exact = slots.findIndex((slot) => normalizeWorkflowPortType(slot?.type || slot?.name || slot?.localized_name) === wanted);
return exact >= 0 ? exact : 0;
};
const connectedNodePosition = (options) => {
const event = options?.e || {};
if (typeof event.canvasX === 'number' && typeof event.canvasY === 'number') {
return [event.canvasX + 24, event.canvasY - 28];
}
try {
const canvas = window.app?.canvas || window.LGraphCanvas?.active_canvas;
const area = canvas?.ds?.visible_area;
if (Array.isArray(area)) return [area[0] + area[2] / 2 - 120, area[1] + area[3] / 2 - 80];
} catch (err) {}
return [180, 180];
};
const addConnectedWorkflowQuickNode = (item) => {
try {
const options = window.__boosapiLastConnectionMenuOptions || {};
const nodeType = findWorkflowNodeType(item.types || []);
const graph = window.app?.graph || window.LGraphCanvas?.active_canvas?.graph;
const canvas = window.app?.canvas || window.LGraphCanvas?.active_canvas;
if (!nodeType || !graph || !window.LiteGraph?.createNode) return false;
const node = window.LiteGraph.createNode(nodeType, item.title || nodeType);
if (!node) return false;
node.pos = connectedNodePosition(options);
graph.add(node);
normalizeWorkflowNodeSlots(node);
if (options.nodeFrom && Number.isInteger(options.slotFrom)) {
const output = options.nodeFrom.outputs?.[options.slotFrom];
const inputIndex = findCompatibleSlotIndex(node.inputs, output?.type || output?.name);
options.nodeFrom.connect(options.slotFrom, node, inputIndex, options.afterRerouteId);
} else if (options.nodeTo && Number.isInteger(options.slotTo)) {
const input = options.nodeTo.inputs?.[options.slotTo];
const outputIndex = findCompatibleSlotIndex(node.outputs, input?.type || input?.name);
node.connect(outputIndex, options.nodeTo, options.slotTo, options.afterRerouteId);
}
graph.setDirtyCanvas?.(true, true);
canvas?.selectNode?.(node);
if (window.LiteGraph?.closeAllContextMenus) window.LiteGraph.closeAllContextMenus(window);
return true;
} catch (err) {
return false;
}
};
const installWorkflowConnectionMenuMemory = () => {
try {
const proto = window.LGraphCanvas?.prototype;
if (!proto?.showConnectionMenu || proto.__boosapiConnectionMenuMemory) return;
const original = proto.showConnectionMenu;
proto.showConnectionMenu = function (options) {
window.__boosapiLastConnectionMenuOptions = options || {};
const result = original.apply(this, arguments);
window.setTimeout(enhanceWorkflowConnectionMenu, 0);
window.setTimeout(enhanceWorkflowConnectionMenu, 80);
return result;
};
proto.__boosapiConnectionMenuMemory = true;
} catch (err) {}
};
const enhanceWorkflowConnectionMenu = () => {
try {
const menu = document.querySelector('.litecontextmenu');
if (!menu || menu.__boosapiConnectionSuggestions) return;
const connectionType = getWorkflowConnectionType();
const items = [
...(workflowConnectionQuickNodes[connectionType] || []),
...fallbackWorkflowConnectionItems(connectionType)
];
if (!items?.length) return;
const available = items
.map((item) => ({ ...item, nodeType: findWorkflowNodeType(item.types || []) }))
.filter((item) => item.nodeType);
if (!available.length) return;
menu.__boosapiConnectionSuggestions = true;
const panel = document.createElement('div');
panel.className = 'boosapi-connection-suggestions';
panel.style.cssText = 'padding:8px 8px 6px;border-bottom:1px solid rgba(255,255,255,.12);background:rgba(20,24,30,.96);min-width:260px;';
const title = document.createElement('div');
title.textContent = '最相关';
title.style.cssText = 'font-size:12px;font-weight:700;color:#e9f7f5;margin:0 0 6px 2px;';
panel.appendChild(title);
for (const item of available.slice(0, 6)) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = item.title;
button.title = item.hint || item.nodeType;
button.style.cssText = 'display:flex;width:100%;align-items:center;justify-content:space-between;gap:12px;margin:2px 0;padding:8px 10px;border:0;border-radius:6px;background:transparent;color:#fff;text-align:left;font-size:13px;cursor:pointer;';
button.addEventListener('mouseenter', () => { button.style.background = 'rgba(20,184,166,.22)'; });
button.addEventListener('mouseleave', () => { button.style.background = 'transparent'; });
button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
addConnectedWorkflowQuickNode(item);
}, true);
panel.appendChild(button);
}
menu.insertBefore(panel, menu.firstChild);
} catch (err) {}
};
const installWorkflowConnectionMenuObserver = () => {
if (window.__boosapiWorkflowConnectionMenuObserver) return;
window.__boosapiWorkflowConnectionMenuObserver = true;
const observer = new MutationObserver((mutations) => {
if (mutations.some((item) => item.addedNodes && item.addedNodes.length)) {
window.requestAnimationFrame(enhanceWorkflowConnectionMenu);
}
});
observer.observe(document.body, { childList: true, subtree: true });
};
const boostWorkflowSlotDefaults = () => {
try {
const liteGraph = window.LiteGraph;
if (!liteGraph) return;
const prepend = (target, type, values) => {
const current = Array.isArray(target[type]) ? target[type] : [];
target[type] = [...values.filter((value) => findWorkflowNodeType([value])), ...current.filter((value) => !values.includes(value))].slice(0, 24);
};
liteGraph.slot_types_default_out ||= {};
liteGraph.slot_types_default_in ||= {};
prepend(liteGraph.slot_types_default_out, 'MODEL', ['KSampler', 'LoraLoader', 'ModelSamplingDiscrete']);
prepend(liteGraph.slot_types_default_out, 'CLIP', ['CLIPTextEncode', 'CLIPSetLastLayer', 'LoraLoader']);
prepend(liteGraph.slot_types_default_out, 'VAE', ['VAEDecode', 'VAEEncode']);
prepend(liteGraph.slot_types_default_out, 'IMAGE', ['PreviewImage', 'SaveImage', 'VAEEncode', 'ImageScale', 'ImageScaleBy']);
prepend(liteGraph.slot_types_default_in, 'IMAGE', ['LoadImage', 'EmptyImage', 'PreviewImage']);
prepend(liteGraph.slot_types_default_out, 'LATENT', ['VAEDecode', 'KSampler']);
prepend(liteGraph.slot_types_default_out, 'CONDITIONING', ['KSampler', 'ConditioningCombine']);
prepend(liteGraph.slot_types_default_out, 'MASK', ['MaskToImage', 'ImageToMask', 'MaskComposite', 'GrowMask']);
prepend(liteGraph.slot_types_default_out, 'VIDEO', ['VHS_VideoCombine', 'VideoCombine', 'SaveAnimatedPNG']);
prepend(liteGraph.slot_types_default_out, 'AUDIO', ['PreviewAudio', 'SaveAudio', 'LoadAudio']);
prepend(liteGraph.slot_types_default_out, 'STRING', ['PrimitiveNode', 'ShowText', 'PreviewText']);
prepend(liteGraph.slot_types_default_out, 'INT', ['PrimitiveNode']);
prepend(liteGraph.slot_types_default_out, 'FLOAT', ['PrimitiveNode']);
prepend(liteGraph.slot_types_default_out, 'BOOLEAN', ['PrimitiveNode']);
} catch (err) {}
};
const isWorkflowVideoModelName = (modelName) => {
const value = String(modelName || '').toLowerCase();
return ['seedance', 'wan2', 't2v', 'i2v', 'flf2v', 'video', 'seaweed'].some((keyword) => value.includes(keyword));
};
const addTextToImageNodeFromModel = (modelName) => {
try {
const name = String(modelName || '').trim();
if (!name || !window.LiteGraph) return false;
const app = window.app;
const graph = app?.graph || window.LGraphCanvas?.active_canvas?.graph;
const canvas = app?.canvas || window.LGraphCanvas?.active_canvas;
if (!graph) return false;
const node = window.LiteGraph.createNode('boosapi.text_to_image', 'BoosAPI 文生图');
if (!node) return false;
const area = canvas?.ds?.visible_area;
node.pos = Array.isArray(area) ? [area[0] + area[2] / 2 - 190, area[1] + area[3] / 2 - 120] : [160, 160];
graph.add(node);
normalizeWorkflowNodeSlots(node);
const widget = node.widgets?.find((item) => item.name === 'model');
if (widget) widget.value = name;
node.properties ||= {};
node.properties['Node name for S&R'] = 'boosapi.text_to_image';
graph.setDirtyCanvas?.(true, true);
canvas?.selectNode?.(node);
return true;
} catch (err) {
return false;
}
};
const addTextToVideoNodeFromModel = (modelName) => {
try {
const name = String(modelName || '').trim();
if (!name || !window.LiteGraph) return false;
const app = window.app;
const graph = app?.graph || window.LGraphCanvas?.active_canvas?.graph;
const canvas = app?.canvas || window.LGraphCanvas?.active_canvas;
if (!graph) return false;
const node = window.LiteGraph.createNode('boosapi.text_to_video', 'BoosAPI 文生视频');
if (!node) return false;
const area = canvas?.ds?.visible_area;
node.pos = Array.isArray(area) ? [area[0] + area[2] / 2 - 190, area[1] + area[3] / 2 - 120] : [160, 160];
graph.add(node);
normalizeWorkflowNodeSlots(node);
const widget = node.widgets?.find((item) => item.name === 'model');
if (widget) widget.value = name;
node.properties ||= {};
node.properties['Node name for S&R'] = 'boosapi.text_to_video';
graph.setDirtyCanvas?.(true, true);
canvas?.selectNode?.(node);
return true;
} catch (err) {
return false;
}
};
const patchModelLibraryClicks = () => {
document.querySelectorAll('#model-library .p-tree-node-content, #model-library [role="treeitem"], .model-lib-tree-explorer .p-tree-node-content').forEach((el) => {
if (el.__boosapiModelClickPatch) return;
el.__boosapiModelClickPatch = true;
el.addEventListener('dblclick', (event) => {
const text = String(el.textContent || '').replace(/\s+/g, ' ').trim();
if (!text || text.includes('checkpoints')) return;
const added = isWorkflowVideoModelName(text)
? addTextToVideoNodeFromModel(text)
: addTextToImageNodeFromModel(text);
if (added) {
event.preventDefault();
event.stopPropagation();
}
}, true);
});
};
const getCurrentWorkflowJson = async () => {
try {
if (window.app?.graphToPrompt) {
const result = await window.app.graphToPrompt();
return result?.workflow || result?.output || result?.prompt || result;
}
} catch (err) {}
try {
const graph = window.app?.graph || window.LGraphCanvas?.active_canvas?.graph;
if (graph?.serialize) return graph.serialize();
} catch (err) {}
return null;
};
const showWorkflowValidationReport = (report) => {
const lines = [];
const summary = report?.summary || {};
lines.push(`节点 ${summary.nodes || 0},连线 ${summary.links || 0},错误 ${summary.errors || 0},警告 ${summary.warnings || 0}`);
for (const item of report?.missingNodeTypes || []) {
lines.push(`缺失节点: ${item.nodeType} #${item.nodeId}${item.connected ? '(已连接)' : '(未连接)'}`);
}
for (const item of report?.emptyModelFields || []) {
lines.push(`空模型列表: ${item.nodeType} #${item.nodeId} ${item.field} = ${item.selected}`);
}
for (const item of report?.missingModelChoices || []) {
lines.push(`缺失模型: ${item.nodeType} #${item.nodeId} ${item.field} = ${item.selected}`);
if (item.near?.length) lines.push(` 相近: ${item.near.join(', ')}`);
}
for (const item of report?.inputFiles || []) {
if (item.checked) lines.push(`素材: ${item.filename} ${item.reachable ? '可访问' : '不可访问'}`);
}
if (report?.hints?.length) lines.push('', ...report.hints);
window.alert(lines.join('\n') || '没有发现明显问题');
};
const runWorkflowValidation = async () => {
const workflow = await getCurrentWorkflowJson();
if (!workflow) {
window.alert('没有读取到当前工作流');
return;
}
const response = await fetch('/workflow-ui/api/workflow/validate?check_files=true', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(workflow),
});
const report = await response.json();
showWorkflowValidationReport(report);
};
const installWorkflowValidationButton = () => {
if (document.getElementById('boosapi-workflow-validate-button')) return;
const host = document.querySelector('header, nav, .p-menubar, .comfy-menu, .comfyui-menu') || document.body;
const button = document.createElement('button');
button.id = 'boosapi-workflow-validate-button';
button.type = 'button';
button.textContent = '检查模板';
button.title = '检查当前工作流的节点、模型和输入素材兼容性';
button.style.cssText = 'position:fixed;right:112px;top:10px;z-index:10000;height:32px;padding:0 12px;border:1px solid rgba(120,130,150,.45);border-radius:6px;background:rgba(20,24,32,.88);color:#fff;font-size:13px;cursor:pointer;';
button.addEventListener('click', async () => {
button.disabled = true;
const oldText = button.textContent;
button.textContent = '检查中...';
try {
await runWorkflowValidation();
} catch (err) {
window.alert('检查失败:' + (err?.message || err));
} finally {
button.disabled = false;
button.textContent = oldText;
}
});
host.appendChild(button);
};
try {
const userJwt = localStorage.getItem('user_jwt_token');
const adminToken = localStorage.getItem('auth_token');
const token = adminToken || userJwt;
if (token) {
localStorage.setItem('auth_token', token);
document.cookie = 'boosapi_workflow_token=' + encodeURIComponent(token) + '; Path=/workflow-ui; SameSite=Lax';
}
} catch (err) {}
let shellPatchQueued = false;
const patchWorkflowShell = () => {
shellPatchQueued = false;
installBoosApiFetchAuth();
patchWorkflowConnectionTypes();
startWorkflowSlotNormalizer();
installWorkflowConnectionMenuMemory();
installWorkflowConnectionMenuObserver();
boostWorkflowSlotDefaults();
enhanceWorkflowConnectionMenu();
patchModelLibraryClicks();
installWorkflowValidationButton();
localizeWorkflowMenus();
observeWorkflowMenuLocalization();
renameComfyUiBrand();
};
const queueWorkflowShellPatch = () => {
if (shellPatchQueued) return;
shellPatchQueued = true;
window.setTimeout(patchWorkflowShell, 120);
};
document.addEventListener('click', queueWorkflowShellPatch, true);
window.setTimeout(patchWorkflowShell, 250);
window.setTimeout(patchWorkflowShell, 1200);
})();
</script>"#;
let bootstrap = format!("{secret_cleanup_bootstrap}{shell}");
html.replace("</head>", &format!("{bootstrap}\n</head>"))
}
+321
View File
@@ -0,0 +1,321 @@
use super::auth::{auth_caller, caller_is_admin, caller_user_id};
use super::models::workflow_route_models;
use super::node_defs::{inject_boosapi_workflow_template_payload, workflow_template_payload};
use super::{
auth_caller_optional, inject_boosapi_object_info, native_workflow_json_cached,
native_workflow_metadata_ttl, native_workflow_status_ttl, parse_boolish, safe_view_path,
user_home, workflow_node_defs_with_models, UserDataQuery, WorkflowCaller,
};
use crate::{
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{Path, Query, State},
http::{header, HeaderMap, Method, StatusCode},
response::{IntoResponse, Response},
Json,
};
use serde_json::{json, Value};
use std::collections::HashMap;
pub(super) async fn get_user_config(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let caller = auth_caller(&state, &headers).await?;
let audience = match caller {
WorkflowCaller::Admin => "admin",
WorkflowCaller::User { .. } => "user",
};
Ok(Json(json!({
"username": audience,
"role": audience,
"id": caller_user_id(&caller),
})))
}
pub(super) async fn create_user(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Response> {
let _ = auth_caller(&state, &headers).await?;
Ok((
StatusCode::METHOD_NOT_ALLOWED,
Json(json!({ "error": "read-only" })),
)
.into_response())
}
pub(super) async fn get_settings(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let _ = auth_caller(&state, &headers).await?;
if state.config.comfyui.uses_cloud_backend() {
return Ok(Json(json!({
"Comfy.Workflow.ShowMissingModelsWarning": false
})));
}
Ok(Json(json!({})))
}
pub(super) async fn get_setting(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
let _ = auth_caller(&state, &headers).await?;
if state.config.comfyui.uses_cloud_backend() && id == "Comfy.Workflow.ShowMissingModelsWarning"
{
return Ok(Json(Value::Bool(false)));
}
Ok(Json(Value::Null))
}
pub(super) async fn store_settings(
State(state): State<AppState>,
headers: HeaderMap,
Json(_body): Json<Value>,
) -> AppResult<Json<Value>> {
let _ = auth_caller(&state, &headers).await?;
Ok(Json(json!({ "success": true })))
}
pub(super) async fn store_setting(
State(state): State<AppState>,
headers: HeaderMap,
Path(_id): Path<String>,
Json(_body): Json<Value>,
) -> AppResult<Json<Value>> {
let _ = auth_caller(&state, &headers).await?;
Ok(Json(json!({ "success": true })))
}
pub(super) async fn userdata_list(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<UserDataQuery>,
) -> AppResult<Response> {
let caller =
auth_caller_optional(&state, &headers).unwrap_or(WorkflowCaller::User { user_id: 0 });
let user_id = caller_user_id(&caller);
let dir = query.dir.unwrap_or_else(|| "workflow".to_string());
let base = user_home(user_id).join("userdata").join(&dir);
if tokio::fs::metadata(&base).await.is_err() {
return Ok(Json(json!([])).into_response());
}
if query.full_info {
let mut entries = Vec::new();
let mut reader = match tokio::fs::read_dir(&base).await {
Ok(value) => value,
Err(_) => return Ok(Json(json!([])).into_response()),
};
while let Ok(Some(entry)) = reader.next_entry().await {
let path = entry.path();
let meta = match entry.metadata().await {
Ok(meta) => meta,
Err(_) => continue,
};
entries.push(json!({
"path": path.to_string_lossy().to_string(),
"name": entry.file_name().to_string_lossy().to_string(),
"type": if meta.is_dir() { "directory" } else { "file" },
"size": meta.len(),
}));
}
return Ok(Json(Value::Array(entries)).into_response());
}
let mut items = Vec::new();
let mut reader = match tokio::fs::read_dir(&base).await {
Ok(value) => value,
Err(_) => return Ok(Json(json!([])).into_response()),
};
while let Ok(Some(entry)) = reader.next_entry().await {
let name = entry.file_name().to_string_lossy().to_string();
if query.split {
items.push(json!([name.clone()]));
} else {
items.push(Value::String(name));
}
}
Ok(Json(Value::Array(items)).into_response())
}
pub(super) async fn userdata_get(
State(state): State<AppState>,
headers: HeaderMap,
Path(file): Path<String>,
) -> AppResult<Response> {
let caller = auth_caller(&state, &headers).await?;
let root = user_home(caller_user_id(&caller)).join("userdata");
let path = root.join(safe_view_path(&file));
let bytes = tokio::fs::read(&path)
.await
.map_err(|_| AppError::NotFound("User data file not found".into()))?;
Ok((
StatusCode::OK,
[(header::CONTENT_TYPE, "application/octet-stream")],
bytes,
)
.into_response())
}
pub(super) async fn userdata_store(
State(state): State<AppState>,
headers: HeaderMap,
Path(file): Path<String>,
Query(query): Query<HashMap<String, String>>,
body: axum::body::Bytes,
) -> AppResult<Response> {
let caller = auth_caller(&state, &headers).await?;
let root = user_home(caller_user_id(&caller)).join("userdata");
let path = root.join(safe_view_path(&file));
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
}
tokio::fs::write(&path, body)
.await
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
Ok(Json(json!({
"name": file,
"subfolder": query.get("subfolder").cloned().unwrap_or_default(),
"type": query.get("type").cloned().unwrap_or_else(|| "input".to_string())
}))
.into_response())
}
pub(super) async fn userdata_delete(
State(state): State<AppState>,
headers: HeaderMap,
Path(file): Path<String>,
) -> AppResult<Response> {
let caller = auth_caller(&state, &headers).await?;
let root = user_home(caller_user_id(&caller)).join("userdata");
let path = root.join(safe_view_path(&file));
let _ = tokio::fs::remove_file(&path).await;
Ok(StatusCode::NO_CONTENT.into_response())
}
pub(super) async fn move_userdata(
State(state): State<AppState>,
headers: HeaderMap,
Path((source, dest)): Path<(String, String)>,
Query(query): Query<HashMap<String, String>>,
) -> AppResult<Response> {
let caller = auth_caller(&state, &headers).await?;
let root = user_home(caller_user_id(&caller)).join("userdata");
let src = root.join(safe_view_path(&source));
let dst = root.join(safe_view_path(&dest));
if let Some(parent) = dst.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
}
let overwrite = parse_boolish(query.get("overwrite").map(String::as_str));
if !overwrite && tokio::fs::metadata(&dst).await.is_ok() {
return Err(AppError::BadRequest("destination already exists".into()));
}
tokio::fs::rename(&src, &dst)
.await
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
Ok(Json(json!({ "success": true })).into_response())
}
pub(super) async fn extensions(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let _ = auth_caller_optional(&state, &headers);
Ok(Json(json!(["extensions/core"])))
}
pub(super) async fn workflow_templates(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let _ = auth_caller_optional(&state, &headers);
if state.config.comfyui.uses_cloud_backend() {
let mut value = native_workflow_json_cached(
&state,
Method::GET,
"/workflow_templates",
None,
native_workflow_metadata_ttl(&state),
)
.await
.map(|value| value.0)
.unwrap_or_else(|_| json!({}));
inject_boosapi_workflow_template_payload(&mut value);
return Ok(Json(value));
}
Ok(Json(workflow_template_payload()))
}
pub(super) async fn embeddings(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let _ = auth_caller_optional(&state, &headers);
Ok(Json(json!([])))
}
pub(super) async fn object_info(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let _ = auth_caller_optional(&state, &headers);
let route_models = workflow_route_models(&state).await.unwrap_or_default();
if state.config.comfyui.uses_native_backend() {
let mut value = native_workflow_json_cached(
&state,
Method::GET,
"/object_info",
None,
native_workflow_metadata_ttl(&state),
)
.await?
.0;
inject_boosapi_object_info(&mut value, &route_models);
return Ok(Json(value));
}
Ok(Json(Value::Object(workflow_node_defs_with_models(
&route_models,
true,
))))
}
pub(super) async fn system_stats(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
if state.config.comfyui.uses_native_backend() {
let _ = auth_caller_optional(&state, &headers);
return native_workflow_json_cached(
&state,
Method::GET,
"/system_stats",
None,
native_workflow_status_ttl(&state),
)
.await;
}
let caller =
auth_caller_optional(&state, &headers).unwrap_or(WorkflowCaller::User { user_id: 0 });
let host = if state.config.server.host == "0.0.0.0" || state.config.server.host == "::" {
"127.0.0.1"
} else {
state.config.server.host.as_str()
};
Ok(Json(json!({
"system": {
"host": host,
"port": state.config.server.port,
"role": if caller_is_admin(&caller) { "admin" } else { "user" }
},
"devices": [],
"queue_remaining": 0,
})))
}
File diff suppressed because it is too large Load Diff
+280
View File
@@ -0,0 +1,280 @@
use super::models::{image_model_choices, is_video_workflow_model};
use super::node_defs::{core_node_definitions, node_type_def_to_comfy};
use super::DEFAULT_TEXT_TO_VIDEO_MODEL;
use crate::workflow::registry;
use serde_json::{json, Map, Value};
use std::collections::HashSet;
pub(super) fn comfy_combo_input(options: &[String], fallback: &str) -> Value {
let choices = if options.is_empty() {
vec![fallback.to_string()]
} else {
options.to_vec()
};
let default = choices
.iter()
.find(|model| model.as_str() == fallback)
.cloned()
.unwrap_or_else(|| choices[0].clone());
json!([choices, { "default": default }])
}
pub(super) fn inject_model_choices(defs: &mut Map<String, Value>, models: &[String]) {
let image_models = image_model_choices(models);
let video_models = models
.iter()
.filter(|model| is_video_workflow_model(model))
.cloned()
.collect::<Vec<_>>();
if let Some(node) = defs.get_mut("boosapi.text_to_image") {
if let Some(optional) = node
.pointer_mut("/input/optional")
.and_then(Value::as_object_mut)
{
optional.insert(
"model".to_string(),
comfy_combo_input(&image_models, "gpt-image-2"),
);
}
}
for node_type in [
"boosapi.text_to_video",
"boosapi.image_to_video",
"boosapi.video_status",
] {
if let Some(node) = defs.get_mut(node_type) {
if let Some(optional) = node
.pointer_mut("/input/optional")
.and_then(Value::as_object_mut)
{
optional.insert(
"model".to_string(),
comfy_combo_input(&video_models, DEFAULT_TEXT_TO_VIDEO_MODEL),
);
}
}
}
for node_type in ["boosapi.chat_completion", "boosapi.comfyui_workflow"] {
if let Some(node) = defs.get_mut(node_type) {
if let Some(optional) = node
.pointer_mut("/input/optional")
.and_then(Value::as_object_mut)
{
optional.insert("model".to_string(), comfy_combo_input(models, "gpt-5.4"));
}
}
}
}
pub(super) fn is_boosapi_extension_node_type(node_type: &str) -> bool {
node_type.starts_with("boosapi.")
|| matches!(
node_type,
"input.text"
| "output.preview_image"
| "output.preview_video"
| "output.save_file"
| "util.text_join"
| "util.json_extract"
| "comfyui.submit_workflow"
)
}
pub(super) fn workflow_node_defs_with_models(
models: &[String],
include_compat_core: bool,
) -> Map<String, Value> {
let core_defs = include_compat_core
.then(core_node_definitions)
.unwrap_or_default();
let mut defs = core_defs
.into_iter()
.chain(
registry::node_types()
.into_iter()
.filter(|def| include_compat_core || is_boosapi_extension_node_type(def.node_type)),
)
.map(|def| (def.node_type.to_string(), node_type_def_to_comfy(&def)))
.collect::<Map<_, _>>();
inject_model_choices(&mut defs, models);
defs
}
pub(super) fn inject_boosapi_object_info(value: &mut Value, models: &[String]) {
let Some(object) = value.as_object_mut() else {
return;
};
for (key, def) in workflow_node_defs_with_models(models, false) {
object.entry(key).or_insert(def);
}
}
pub(super) fn builtin_model_folders() -> Vec<&'static str> {
vec![
"boosapi_models",
"checkpoints",
"chat_models",
"video_models",
"audio_tts_models",
"embeddings",
"vision_models",
"3d_models",
"loras",
"vae",
]
}
pub(super) fn inject_boosapi_model_folders(value: &mut Value) {
let Some(items) = value.as_array_mut() else {
*value = workflow_model_folder_payload();
return;
};
let mut seen = items
.iter()
.filter_map(|item| {
item.get("name")
.and_then(Value::as_str)
.map(|name| name.to_ascii_lowercase())
})
.collect::<HashSet<_>>();
for name in builtin_model_folders() {
if seen.insert(name.to_ascii_lowercase()) {
items.push(json!({ "name": name }));
}
}
}
pub(super) fn workflow_model_folder_payload() -> Value {
Value::Array(
builtin_model_folders()
.into_iter()
.map(|name| json!({ "name": name }))
.collect(),
)
}
pub(super) fn native_model_folder_payload_or_fallback(value: Value) -> Value {
match value {
Value::Array(_) => {
let mut value = value;
normalize_model_folder_payload(&mut value);
inject_boosapi_model_folders(&mut value);
value
}
Value::Object(object)
if object
.get("code")
.and_then(Value::as_i64)
.is_some_and(|code| code == 404) =>
{
workflow_model_folder_payload()
}
_ => workflow_model_folder_payload(),
}
}
pub(super) fn model_payload_items_from_names(names: Vec<String>, path_index: i64) -> Vec<Value> {
names
.into_iter()
.map(|name| json!({ "name": name, "pathIndex": path_index }))
.collect()
}
pub(super) fn normalize_model_item_name(item: &Value) -> Option<String> {
item.get("name")
.and_then(Value::as_str)
.or_else(|| item.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
pub(super) fn workflow_model_display_aliases(name: &str) -> Vec<String> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Vec::new();
}
let mut aliases = Vec::new();
let lower = trimmed.to_ascii_lowercase();
if lower == "ltx-2.3-22b-distilled-1.1_lora-dynamic_fro09_avg_rank_111_bf16.safetensors" {
aliases.push(
"ltx_2.3_22b_distilled_1.1_lora_dynamic_fro09_avg_rank_111_bf16.safetensors"
.to_string(),
);
aliases.push(
"ltx_2.3_22b_distilled_1.1_lora_dynamic_fr009_avg_rank_111_bf16.safetensors"
.to_string(),
);
}
if lower.contains("ltx") && lower.contains("lora") {
aliases.push(trimmed.replace('-', "_"));
}
aliases.sort();
aliases.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
aliases
}
pub(super) fn model_payload_item_with_name(source: &Value, name: String) -> Value {
match source {
Value::Object(object) => {
let mut object = object.clone();
object.insert("name".to_string(), Value::String(name));
object
.entry("pathIndex".to_string())
.or_insert_with(|| Value::Number(0.into()));
Value::Object(object)
}
_ => json!({ "name": name, "pathIndex": 0 }),
}
}
pub(super) fn merge_model_payload_items(primary: Vec<Value>, extra: Vec<Value>) -> Value {
let mut seen = HashSet::<String>::new();
let mut items = Vec::with_capacity(primary.len() + extra.len());
for item in primary.into_iter().chain(extra) {
let Some(name) = normalize_model_item_name(&item) else {
continue;
};
if seen.insert(name.to_ascii_lowercase()) {
let aliases = workflow_model_display_aliases(&name);
items.push(match item {
Value::String(_) => json!({ "name": name.clone(), "pathIndex": 0 }),
Value::Object(mut object) => {
object
.entry("name".to_string())
.or_insert_with(|| Value::String(name.clone()));
object
.entry("pathIndex".to_string())
.or_insert_with(|| Value::Number(0.into()));
Value::Object(object)
}
_ => json!({ "name": name.clone(), "pathIndex": 0 }),
});
let source = items.last().cloned().unwrap_or_else(|| json!({}));
for alias in aliases {
if seen.insert(alias.to_ascii_lowercase()) {
items.push(model_payload_item_with_name(&source, alias));
}
}
}
}
Value::Array(items)
}
pub(super) fn normalize_model_folder_payload(value: &mut Value) {
let Some(items) = value.as_array_mut() else {
return;
};
for item in items {
if let Some(name) = item.as_str().map(str::to_string) {
*item = json!({ "name": name });
}
}
}
+300
View File
@@ -0,0 +1,300 @@
use super::model_payload_items_from_names;
use crate::error::AppResult;
use crate::AppState;
use serde_json::Value;
pub(super) fn is_exact_workflow_model(value: &str) -> bool {
let normalized = value.trim();
!normalized.is_empty()
&& !normalized.to_ascii_lowercase().starts_with("re:")
&& !normalized.contains('*')
&& !normalized.contains('?')
}
pub(super) fn is_image_workflow_model(value: &str) -> bool {
if !is_exact_workflow_model(value) {
return false;
}
let normalized = value.trim().to_ascii_lowercase();
let non_image_keywords = [
"tts",
"voice",
"audio",
"embedding",
"embed",
"seedance",
"wan2",
"t2v",
"i2v",
"flf2v",
"seaweed",
"3d",
"hitem3d",
"hyper3d",
];
if non_image_keywords
.iter()
.any(|keyword| normalized.contains(keyword))
{
return false;
}
let image_keywords = [
"gpt-image",
"dall-e",
"dalle",
"t2i",
"i2i",
"midjourney",
"recraft",
"kolors",
"cogview",
"seedream",
"sdxl",
"stable-diffusion",
"stablediffusion",
"pixart",
"playground",
"firefly",
"imagen",
"flux",
"image",
"img",
"canvas",
"painting",
"draw",
];
image_keywords
.iter()
.any(|keyword| normalized.contains(keyword))
}
pub(super) fn is_video_workflow_model(value: &str) -> bool {
let normalized = value.trim().to_ascii_lowercase();
[
"seedance", "wan2", "t2v", "i2v", "flf2v", "video", "seaweed",
]
.iter()
.any(|keyword| normalized.contains(keyword))
}
pub(super) fn is_audio_workflow_model(value: &str) -> bool {
let normalized = value.trim().to_ascii_lowercase();
["tts", "voice", "audio", "speech"]
.iter()
.any(|keyword| normalized.contains(keyword))
}
pub(super) fn is_embedding_workflow_model(value: &str) -> bool {
let normalized = value.trim().to_ascii_lowercase();
normalized.contains("embedding") || normalized.contains("embed")
}
pub(super) fn is_3d_workflow_model(value: &str) -> bool {
let normalized = value.trim().to_ascii_lowercase();
["3d", "hitem3d", "hyper3d"]
.iter()
.any(|keyword| normalized.contains(keyword))
}
pub(super) fn is_vision_workflow_model(value: &str) -> bool {
let normalized = value.trim().to_ascii_lowercase();
["vision", "vl", "omni", "ui-tars"]
.iter()
.any(|keyword| normalized.contains(keyword))
&& !is_image_workflow_model(value)
&& !is_video_workflow_model(value)
&& !is_embedding_workflow_model(value)
}
pub(super) fn is_chat_workflow_model(value: &str) -> bool {
is_exact_workflow_model(value)
&& !is_image_workflow_model(value)
&& !is_video_workflow_model(value)
&& !is_audio_workflow_model(value)
&& !is_embedding_workflow_model(value)
&& !is_3d_workflow_model(value)
}
pub(super) async fn workflow_route_models(state: &AppState) -> AppResult<Vec<String>> {
let rows = sqlx::query_scalar::<_, String>(
"SELECT DISTINCT model_pattern
FROM token_routes
WHERE COALESCE(enabled, 1) = 1
AND model_pattern IS NOT NULL
AND model_pattern <> ''
ORDER BY model_pattern COLLATE NOCASE",
)
.fetch_all(&*state.db)
.await?;
let mut seen = std::collections::HashSet::new();
Ok(rows
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| is_exact_workflow_model(value))
.filter(|value| seen.insert(value.clone()))
.collect())
}
pub(super) fn image_model_choices(models: &[String]) -> Vec<String> {
let image_models = models
.iter()
.filter(|model| is_image_workflow_model(model))
.cloned()
.collect::<Vec<_>>();
if image_models.is_empty() {
models.to_vec()
} else {
image_models
}
}
pub(super) fn workflow_folder_models(folder: &str, models: &[String]) -> Vec<String> {
let normalized = folder.trim().to_ascii_lowercase();
match normalized.as_str() {
"boosapi_models" | "boosapi" | "api_models" => models.to_vec(),
"image_models" => image_model_choices(models),
"chat_models" => models
.iter()
.filter(|model| is_chat_workflow_model(model))
.cloned()
.collect(),
"clip" => models.to_vec(),
// Comfy's video_models folder is a native Comfy API model folder. Keep it
// separate from BoosAPI route models so video apps submit real Comfy API
// model names instead of OpenAI-compatible route aliases.
"video_models" => Vec::new(),
"audio_tts_models" => models
.iter()
.filter(|model| is_audio_workflow_model(model))
.cloned()
.collect(),
"embeddings" => models
.iter()
.filter(|model| is_embedding_workflow_model(model))
.cloned()
.collect(),
"3d_models" => models
.iter()
.filter(|model| is_3d_workflow_model(model))
.cloned()
.collect(),
"vision_models" => models
.iter()
.filter(|model| is_vision_workflow_model(model))
.cloned()
.collect(),
_ => Vec::new(),
}
}
pub(super) fn workflow_folder_is_api_only(folder: &str) -> bool {
matches!(
folder.trim().to_ascii_lowercase().as_str(),
"boosapi" | "boosapi_models" | "api_models"
)
}
pub(super) fn workflow_folder_has_route_models(folder: &str) -> bool {
matches!(
folder.trim().to_ascii_lowercase().as_str(),
"boosapi"
| "boosapi_models"
| "api_models"
| "image_models"
| "chat_models"
| "audio_tts_models"
| "embeddings"
| "3d_models"
| "vision_models"
)
}
pub(super) fn workflow_folder_route_model_items(folder: &str, route_models: &[String]) -> Vec<Value> {
model_payload_items_from_names(workflow_folder_models(folder, route_models), 0)
}
pub(super) fn combo_choices_from_node_input(
object_info: &Value,
node_type: &str,
input_name: &str,
) -> Vec<String> {
object_info
.get(node_type)
.and_then(|node| {
node.pointer(&format!("/input/required/{input_name}"))
.or_else(|| node.pointer(&format!("/input/optional/{input_name}")))
})
.and_then(Value::as_array)
.and_then(|input| input.first())
.and_then(Value::as_array)
.map(|choices| {
choices
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty() && *value != "None")
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
pub(super) fn combo_fields_from_node(object_info: &Value, node_type: &str) -> Vec<(String, Vec<String>)> {
let mut fields = Vec::new();
let Some(node) = object_info.get(node_type) else {
return fields;
};
for section in ["required", "optional"] {
if let Some(inputs) = node
.pointer(&format!("/input/{section}"))
.and_then(Value::as_object)
{
for (name, value) in inputs {
let Some(choices) = value
.as_array()
.and_then(|input| input.first())
.and_then(Value::as_array)
else {
continue;
};
let choices = choices
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty() && *value != "None")
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
fields.push((name.clone(), choices));
}
}
}
fields
}
pub(super) fn native_folder_models_from_object_info(folder: &str, object_info: &Value) -> Vec<String> {
let normalized = folder.trim().to_ascii_lowercase();
match normalized.as_str() {
"checkpoints" | "image_models" => {
combo_choices_from_node_input(object_info, "CheckpointLoaderSimple", "ckpt_name")
}
"loras" => combo_choices_from_node_input(object_info, "LoraLoader", "lora_name"),
"vae" => combo_choices_from_node_input(object_info, "VAELoader", "vae_name"),
"clip" | "clip_models" | "text_encoders" => {
combo_choices_from_node_input(object_info, "CLIPLoader", "clip_name")
}
"unet" | "diffusion_models" | "diffusion_models_files" => {
combo_choices_from_node_input(object_info, "UNETLoader", "unet_name")
}
"controlnet" | "controlnets" => {
combo_choices_from_node_input(object_info, "ControlNetLoader", "control_net_name")
}
"upscale_models" => {
combo_choices_from_node_input(object_info, "UpscaleModelLoader", "model_name")
}
"embeddings" => combo_choices_from_node_input(object_info, "EmbeddingPicker", "embedding"),
_ => Vec::new(),
}
}
+646
View File
@@ -0,0 +1,646 @@
use super::auth::{auth_caller, caller_user_id};
use super::queue::repair_native_prompt_payload;
use super::{
auth_caller_optional, build_query_string, safe_view_path, user_home, workspace_kind_from_mime,
CachedJson, PromptPayload, UploadQuery, WorkflowCaller, NATIVE_WORKFLOW_CACHE_MAX_AGE,
NATIVE_WORKFLOW_JSON_CACHE,
};
use crate::{
error::{AppError, AppResult},
services::workspace::{self, WorkspaceAssetInput},
AppState,
};
use axum::{
body::Bytes,
extract::{multipart::Multipart, FromRequest, Path, Query, State},
http::{header, HeaderMap, HeaderValue, Method, StatusCode, Uri},
response::{IntoResponse, Response},
Json,
};
use serde_json::{json, Value};
use std::{
collections::HashMap,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
pub(super) async fn workflow_cache_status(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let _ = auth_caller_optional(&state, &headers);
let base = native_workflow_base(&state);
let now = Instant::now();
let mut items = NATIVE_WORKFLOW_JSON_CACHE
.lock()
.iter()
.filter(|(key, _)| key.starts_with(&base))
.map(|(key, item)| {
json!({
"key": key,
"ageSecs": now.duration_since(item.inserted_at).as_secs(),
"kind": workflow_cache_kind_from_key(key),
})
})
.collect::<Vec<_>>();
items.sort_by(|a, b| {
a.get("key")
.and_then(Value::as_str)
.cmp(&b.get("key").and_then(Value::as_str))
});
Ok(Json(json!({
"success": true,
"backend": if state.config.comfyui.uses_runninghub_backend() {
"runninghub"
} else if state.config.comfyui.uses_cloud_backend() {
"cloud"
} else if state.config.comfyui.uses_native_backend() {
"external"
} else {
"boosapi"
},
"baseUrl": base,
"items": items,
})))
}
pub(super) async fn upload_image(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<UploadQuery>,
body: Bytes,
) -> AppResult<Response> {
let caller = auth_caller(&state, &headers).await?;
if state.config.comfyui.uses_native_backend() {
return upload_image_to_native_backend(&state, &headers, body).await;
}
let user_id = caller_user_id(&caller);
let multipart = Multipart::from_request(
axum::http::Request::builder()
.method(Method::POST)
.header(
header::CONTENT_TYPE,
headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default(),
)
.body(axum::body::Body::from(body))
.map_err(|err| AppError::BadRequest(err.to_string()))?,
&state,
)
.await
.map_err(|err| AppError::BadRequest(err.to_string()))?;
upload_image_to_local_backend(user_id, query, multipart).await
}
async fn upload_image_to_local_backend(
user_id: Option<i64>,
query: UploadQuery,
mut multipart: Multipart,
) -> AppResult<Response> {
let mut image_name: Option<String> = None;
let mut image_bytes: Option<Vec<u8>> = None;
let mut subfolder = query.subfolder.unwrap_or_default();
let mut file_type = query.file_type.unwrap_or_else(|| "input".to_string());
while let Some(field) = multipart
.next_field()
.await
.map_err(|err| AppError::BadRequest(err.to_string()))?
{
let name = field.name().unwrap_or_default().to_string();
if name == "image" {
image_name = field.file_name().map(str::to_string);
image_bytes = Some(
field
.bytes()
.await
.map_err(|err| AppError::BadRequest(err.to_string()))?
.to_vec(),
);
} else if name == "subfolder" {
subfolder = field.text().await.unwrap_or_default();
} else if name == "type" {
file_type = field.text().await.unwrap_or_else(|_| "input".to_string());
}
}
let Some(bytes) = image_bytes else {
return Err(AppError::BadRequest("image is required".into()));
};
let file_name =
image_name.unwrap_or_else(|| format!("upload-{}.png", uuid::Uuid::new_v4().simple()));
let root = user_home(user_id).join("userdata").join(&file_type);
let dir = if subfolder.trim().is_empty() {
root.clone()
} else {
root.join(safe_view_path(&subfolder))
};
tokio::fs::create_dir_all(&dir)
.await
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
let path = dir.join(&file_name);
tokio::fs::write(&path, bytes)
.await
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
Ok(Json(json!({
"name": file_name,
"subfolder": subfolder,
"type": file_type,
}))
.into_response())
}
async fn upload_image_to_native_backend(
state: &AppState,
headers: &HeaderMap,
body: Bytes,
) -> AppResult<Response> {
if body.is_empty() {
return Err(AppError::BadRequest("empty upload request body".into()));
}
let content_type = headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.filter(|value| {
value
.to_ascii_lowercase()
.starts_with("multipart/form-data")
})
.ok_or_else(|| AppError::BadRequest("multipart/form-data content-type is required".into()))?
.to_string();
let response = native_workflow_request(state, Method::POST, "/upload/image", None)
.header(reqwest::header::CONTENT_TYPE, content_type)
.body(body)
.send()
.await
.map_err(|err| AppError::UpstreamError(format!("ComfyUI upload failed: {err}")))?;
let status = response.status();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("application/json")
.to_string();
let bytes = response
.bytes()
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
if !status.is_success() {
let body = String::from_utf8_lossy(&bytes);
tracing::warn!(status = status.as_u16(), body = %body, "Workflow native upload failed");
}
Ok((
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY),
[(header::CONTENT_TYPE, content_type)],
bytes,
)
.into_response())
}
pub(super) fn timestamp_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis() as i64)
.unwrap_or_default()
}
pub(super) fn native_workflow_enabled(state: &AppState) -> bool {
state.config.comfyui.uses_native_backend()
}
pub(super) fn native_workflow_metadata_ttl(state: &AppState) -> Duration {
if state.config.comfyui.uses_runninghub_backend() {
Duration::from_secs(21600)
} else {
Duration::from_secs(300)
}
}
pub(super) fn native_workflow_status_ttl(state: &AppState) -> Duration {
if state.config.comfyui.uses_runninghub_backend() {
Duration::from_secs(120)
} else {
Duration::from_secs(30)
}
}
pub(super) fn native_workflow_base(state: &AppState) -> String {
state.config.comfyui.target_base_url()
}
fn native_workflow_http_path(state: &AppState, path: &str) -> String {
if state.config.comfyui.uses_cloud_backend() {
format!(
"/api{}",
path.strip_prefix('/')
.map(|value| format!("/{value}"))
.unwrap_or_else(|| path.to_string())
)
} else {
path.to_string()
}
}
pub(super) fn native_workflow_request(
state: &AppState,
method: Method,
path: &str,
body: Option<Value>,
) -> reqwest::RequestBuilder {
let client = reqwest::Client::new();
let url = format!(
"{}{}",
native_workflow_base(state),
native_workflow_http_path(state, path)
);
let reqwest_method =
reqwest::Method::from_bytes(method.as_str().as_bytes()).unwrap_or(reqwest::Method::GET);
let mut request = client.request(reqwest_method, url);
if let Some(api_key) = state
.config
.comfyui
.api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
request = request
.header("X-API-Key", api_key)
.header("Authorization", format!("Bearer {api_key}"));
}
if let Some(body) = body {
request = request.json(&body);
}
request
}
pub(super) async fn native_workflow_json(
state: &AppState,
method: Method,
path: &str,
body: Option<Value>,
) -> AppResult<Json<Value>> {
let response = native_workflow_request(state, method, path, body)
.send()
.await
.map_err(|err| AppError::UpstreamError(format!("ComfyUI backend unreachable: {err}")))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
let value = serde_json::from_str::<Value>(&text).unwrap_or_else(|_| json!({ "body": text }));
if !status.is_success() {
return Err(AppError::UpstreamError(value.to_string()));
}
Ok(Json(value))
}
pub(super) async fn native_workflow_json_cached(
state: &AppState,
method: Method,
path: &str,
body: Option<Value>,
ttl: Duration,
) -> AppResult<Json<Value>> {
if method != Method::GET || body.is_some() || ttl.is_zero() {
return native_workflow_json(state, method, path, body).await;
}
let cache_key = format!(
"{}|{}|{}",
native_workflow_base(state),
method.as_str(),
native_workflow_http_path(state, path)
);
let now = Instant::now();
if let Some(cached) = NATIVE_WORKFLOW_JSON_CACHE.lock().get(&cache_key).cloned() {
if now.duration_since(cached.inserted_at) <= ttl {
return Ok(Json(cached.value));
}
}
let value = match native_workflow_json(state, method, path, None).await {
Ok(value) => value.0,
Err(err) => {
if let Some(cached) = NATIVE_WORKFLOW_JSON_CACHE.lock().get(&cache_key).cloned() {
tracing::warn!(
path,
error = %err,
"using stale ComfyUI workflow metadata cache after refresh failure"
);
return Ok(Json(cached.value));
}
return Err(err);
}
};
let mut cache = NATIVE_WORKFLOW_JSON_CACHE.lock();
if cache.len() > 64 {
cache.retain(|_, item| {
now.duration_since(item.inserted_at) <= NATIVE_WORKFLOW_CACHE_MAX_AGE
});
}
cache.insert(
cache_key,
CachedJson {
inserted_at: now,
value: value.clone(),
},
);
Ok(Json(value))
}
fn workflow_cache_kind_from_key(key: &str) -> &'static str {
if key.contains("/object_info") {
"object_info"
} else if key.contains("/experiment/models") {
"models"
} else if key.contains("/system_stats") {
"status"
} else {
"json"
}
}
pub(super) async fn native_workflow_response(
state: &AppState,
method: Method,
path: &str,
body: Option<Value>,
) -> AppResult<Response> {
let response = native_workflow_request(state, method, path, body)
.send()
.await
.map_err(|err| AppError::UpstreamError(format!("ComfyUI backend unreachable: {err}")))?;
let status = response.status();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("application/json")
.to_string();
let bytes = response
.bytes()
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
Ok((
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY),
[(header::CONTENT_TYPE, content_type)],
bytes,
)
.into_response())
}
pub(super) async fn native_workflow_view_response(
state: &AppState,
caller: &WorkflowCaller,
params: &HashMap<String, String>,
) -> AppResult<Response> {
native_workflow_file_response(state, caller, "/view", params).await
}
pub(super) async fn native_workflow_video_response(
state: &AppState,
caller: &WorkflowCaller,
params: &HashMap<String, String>,
) -> AppResult<Response> {
native_workflow_file_response(state, caller, "/viewvideo", params).await
}
async fn native_workflow_file_response(
state: &AppState,
caller: &WorkflowCaller,
path: &str,
params: &HashMap<String, String>,
) -> AppResult<Response> {
let query = build_query_string(params);
let response = native_workflow_request(state, Method::GET, &format!("{path}{query}"), None)
.send()
.await
.map_err(|err| AppError::UpstreamError(format!("ComfyUI backend unreachable: {err}")))?;
let status = response.status();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let bytes = response
.bytes()
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
if status.is_success() && matches!(path, "/view" | "/viewvideo") {
archive_native_workflow_asset(state, caller, path, params, &content_type, bytes.as_ref())
.await;
}
Ok((
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY),
[(header::CONTENT_TYPE, content_type)],
bytes,
)
.into_response())
}
async fn archive_native_workflow_asset(
state: &AppState,
caller: &WorkflowCaller,
path: &str,
params: &HashMap<String, String>,
content_type: &str,
bytes: &[u8],
) {
let file_type = params.get("type").map(String::as_str).unwrap_or("").trim();
if file_type != "output" {
return;
}
let Some(kind) = workspace_kind_from_mime(content_type) else {
return;
};
let filename = params
.get("filename")
.map(String::as_str)
.unwrap_or("")
.trim();
if filename.is_empty() || bytes.is_empty() {
return;
}
let user_id = caller_user_id(caller);
let source = "workflow-studio";
let already_exists = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*)
FROM workspace_assets
WHERE deleted_at IS NULL
AND (
(? = 1 AND user_id IS NULL)
OR (? = 0 AND user_id = ?)
)
AND source = ?
AND json_extract(metadata, '$.workflowUi.filename') = ?
AND json_extract(metadata, '$.workflowUi.type') = ?
AND json_extract(metadata, '$.workflowUi.subfolder') = ?",
)
.bind(user_id.is_none())
.bind(user_id.is_none())
.bind(user_id)
.bind(source)
.bind(filename)
.bind(file_type)
.bind(params.get("subfolder").map(String::as_str).unwrap_or(""))
.fetch_one(&*state.db)
.await
.unwrap_or_default();
if already_exists > 0 {
return;
}
let input = WorkspaceAssetInput {
user_id,
source: source.to_string(),
kind: kind.to_string(),
title: Some(filename.chars().take(80).collect()),
prompt: None,
model: None,
remote_url: None,
metadata: json!({
"workspaceAsset": { "role": "output" },
"workflowUi": {
"filename": filename,
"type": file_type,
"subfolder": params.get("subfolder").map(String::as_str).unwrap_or(""),
"endpoint": path,
}
}),
};
if let Err(err) = workspace::record_bytes_asset(state, input, bytes, content_type).await {
tracing::warn!(%err, filename, "failed to archive workflow-ui asset into workspace");
}
}
pub(super) async fn native_workflow_api_proxy(
State(state): State<AppState>,
headers: HeaderMap,
Path(path): Path<String>,
method: Method,
uri: Uri,
body: Bytes,
) -> AppResult<Response> {
let _ = auth_caller_optional(&state, &headers);
if !native_workflow_enabled(&state) {
return Err(AppError::NotFound("Workflow API route not found".into()));
}
let target = match uri.query() {
Some(query) => format!("/{path}?{query}"),
None => format!("/{path}"),
};
if state.config.comfyui.uses_cloud_backend() {
return native_workflow_raw_response(&state, method, &target, &headers, body).await;
}
let body = if body.is_empty() {
None
} else {
Some(
serde_json::from_slice::<Value>(&body)
.unwrap_or_else(|_| json!({ "body": String::from_utf8_lossy(&body).to_string() })),
)
};
native_workflow_response(&state, method, &target, body).await
}
async fn native_workflow_raw_response(
state: &AppState,
method: Method,
path: &str,
headers: &HeaderMap,
body: Bytes,
) -> AppResult<Response> {
let is_prompt_post = method == Method::POST && path.split('?').next() == Some("/prompt");
let mut request = native_workflow_request(state, method, path, None);
if let Some(content_type) = headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
{
request = request.header(reqwest::header::CONTENT_TYPE, content_type);
}
if let Some(accept) = headers
.get(header::ACCEPT)
.and_then(|value| value.to_str().ok())
{
request = request.header(reqwest::header::ACCEPT, accept);
}
if !body.is_empty() {
if is_prompt_post {
if let Ok(mut value) = serde_json::from_slice::<Value>(&body) {
repair_native_prompt_body_value(state, &mut value).await;
request = request.json(&value);
} else {
request = request.body(body);
}
} else {
request = request.body(body);
}
}
let response = request
.send()
.await
.map_err(|err| AppError::UpstreamError(format!("ComfyUI backend unreachable: {err}")))?;
let status = response.status();
let response_headers = response.headers().clone();
let content_type = response_headers
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("application/json")
.to_string();
let bytes = response
.bytes()
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
let mut builder = Response::builder()
.status(StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY))
.header(header::CONTENT_TYPE, content_type);
for name in [
header::CACHE_CONTROL,
header::ETAG,
header::LAST_MODIFIED,
header::LOCATION,
header::SET_COOKIE,
header::WWW_AUTHENTICATE,
] {
if let Some(value) = response_headers
.get(name.as_str())
.and_then(|value| value.to_str().ok())
.and_then(|value| HeaderValue::from_str(value).ok())
{
builder = builder.header(name, value);
}
}
builder
.body(axum::body::Body::from(bytes))
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))
}
async fn repair_native_prompt_body_value(state: &AppState, value: &mut Value) {
if let Ok(mut payload) = serde_json::from_value::<PromptPayload>(value.clone()) {
repair_native_prompt_payload(state, &mut payload).await;
if let Ok(repaired) = serde_json::to_value(payload) {
*value = repaired;
}
}
}
pub(super) async fn upload_mask(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> AppResult<Response> {
upload_image(
State(state),
headers,
Query(UploadQuery {
subfolder: None,
file_type: Some("input".into()),
}),
body,
)
.await
}
+517
View File
@@ -0,0 +1,517 @@
use super::DEFAULT_TEXT_TO_VIDEO_MODEL;
use crate::error::{AppError, AppResult};
use crate::workflow::{
graph,
models::{NodeTypeDefinition, WorkflowGraph, WorkflowNode},
};
use serde_json::{json, Map, Value};
pub(super) fn known_output_ports(node_type: &str) -> &'static [&'static str] {
match node_type {
"CheckpointLoaderSimple" => &["MODEL", "CLIP", "VAE"],
"CLIPTextEncode" => &["CONDITIONING"],
"EmptyLatentImage" => &["LATENT"],
"KSampler" => &["LATENT"],
"VAEDecode" => &["IMAGE"],
"SaveImage" => &["IMAGE"],
"PreviewImage" => &["IMAGE"],
"input.text" => &["text"],
"boosapi.chat_completion" => &["text", "raw"],
"boosapi.prompt_rewrite" => &["prompt", "raw"],
"boosapi.text_to_image" => &["image", "images", "raw"],
"boosapi.text_to_video" | "boosapi.image_to_video" => &["task_id", "video", "raw"],
"boosapi.video_status" => &["video", "raw"],
"boosapi.comfyui_workflow" => &["workflow", "text", "raw"],
"comfyui.submit_workflow" => &["prompt_id", "response", "workflow"],
"output.preview_image" => &["image"],
"output.preview_video" => &["video"],
"output.save_file" => &["file"],
"util.text_join" => &["text"],
"util.json_extract" => &["value", "text"],
_ => &["output"],
}
}
pub(super) fn core_node_definitions() -> Vec<NodeTypeDefinition> {
vec![
NodeTypeDefinition {
node_type: "CheckpointLoaderSimple",
title: "CheckpointLoaderSimple",
category: "loaders",
description: "Loads a checkpoint model and provides model, clip and vae outputs.",
inputs: json!({}),
params: json!({
"ckpt_name": { "ui": "combo", "type": "COMBO", "default": "gpt-image-2" }
}),
outputs: json!({
"MODEL": { "type": "MODEL" },
"CLIP": { "type": "CLIP" },
"VAE": { "type": "VAE" }
}),
},
NodeTypeDefinition {
node_type: "CLIPTextEncode",
title: "CLIPTextEncode",
category: "conditioning",
description: "Encodes text into conditioning.",
inputs: json!({
"clip": { "type": "CLIP", "required": false }
}),
params: json!({
"text": { "ui": "textarea", "type": "STRING", "default": "" }
}),
outputs: json!({ "CONDITIONING": { "type": "CONDITIONING" } }),
},
NodeTypeDefinition {
node_type: "EmptyLatentImage",
title: "EmptyLatentImage",
category: "latent",
description: "Creates an empty latent image.",
inputs: json!({}),
params: json!({
"width": { "ui": "number", "type": "INT", "default": 512, "min": 64, "max": 4096 },
"height": { "ui": "number", "type": "INT", "default": 512, "min": 64, "max": 4096 },
"batch_size": { "ui": "number", "type": "INT", "default": 1, "min": 1, "max": 64 }
}),
outputs: json!({ "LATENT": { "type": "LATENT" } }),
},
NodeTypeDefinition {
node_type: "KSampler",
title: "KSampler",
category: "sampling",
description: "Samples latent images with a diffusion model.",
inputs: json!({
"model": { "type": "MODEL", "required": true },
"positive": { "type": "CONDITIONING", "required": true },
"negative": { "type": "CONDITIONING", "required": true },
"latent_image": { "type": "LATENT", "required": true }
}),
params: json!({
"seed": { "ui": "number", "type": "INT", "default": 0 },
"control_after_generate": { "ui": "boolean", "type": "BOOLEAN", "default": true },
"steps": { "ui": "number", "type": "INT", "default": 20, "min": 1, "max": 100 },
"cfg": { "ui": "number", "type": "FLOAT", "default": 8, "min": 0, "max": 50 },
"sampler_name": { "ui": "text", "type": "STRING", "default": "euler" },
"scheduler": { "ui": "text", "type": "STRING", "default": "normal" },
"denoise": { "ui": "number", "type": "FLOAT", "default": 1, "min": 0, "max": 1 }
}),
outputs: json!({ "LATENT": { "type": "LATENT" } }),
},
NodeTypeDefinition {
node_type: "VAEDecode",
title: "VAEDecode",
category: "latent",
description: "Decodes latent samples to images.",
inputs: json!({
"samples": { "type": "LATENT", "required": true },
"vae": { "type": "VAE", "required": true }
}),
params: json!({}),
outputs: json!({ "IMAGE": { "type": "IMAGE" } }),
},
NodeTypeDefinition {
node_type: "SaveImage",
title: "SaveImage",
category: "image",
description: "Saves images to disk.",
inputs: json!({
"images": { "type": "IMAGE", "required": true }
}),
params: json!({
"filename_prefix": { "ui": "text", "type": "STRING", "default": "ComfyUI" }
}),
outputs: json!({ "IMAGE": { "type": "IMAGE" } }),
},
NodeTypeDefinition {
node_type: "PreviewImage",
title: "PreviewImage",
category: "image",
description: "Previews generated images.",
inputs: json!({
"images": { "type": "IMAGE", "required": true }
}),
params: json!({}),
outputs: json!({ "IMAGE": { "type": "IMAGE" } }),
},
]
}
pub(super) fn workflow_template_payload() -> Value {
json!({
"boosapi": ["文生图示例", "文生视频示例"]
})
}
pub(super) fn core_workflow_template_index() -> Value {
json!([
{
"moduleName": "default",
"title": "BoosAPI Examples",
"category": "basics",
"type": "core",
"isEssential": true,
"templates": [
{
"name": "boosapi-text-to-image",
"title": "BoosAPI 文生图",
"description": "使用 BoosAPI 模型路由生成图片并预览输出。",
"mediaType": "image",
"mediaSubtype": "svg",
"tags": ["boosapi", "image"],
"models": ["BoosAPI"]
},
{
"name": "boosapi-text-to-video",
"title": "BoosAPI 文生视频",
"description": "使用 BoosAPI 视频节点生成视频并保存输出。",
"mediaType": "image",
"mediaSubtype": "svg",
"tags": ["boosapi", "video"],
"models": ["BoosAPI"]
}
]
}
])
}
pub(super) fn inject_boosapi_workflow_template_payload(value: &mut Value) {
let Some(object) = value.as_object_mut() else {
*value = workflow_template_payload();
return;
};
object
.entry("boosapi".to_string())
.or_insert_with(|| json!(["文生图示例", "文生视频示例"]));
}
pub(super) fn default_boosapi_workflow_graph() -> Value {
json!({
"last_node_id": 3,
"last_link_id": 2,
"nodes": [
{
"id": 1,
"type": "input.text",
"pos": [80, 140],
"size": [460, 260],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [{ "name": "text", "type": "STRING", "links": [1], "slot_index": 0 }],
"properties": { "Node name for S&R": "input.text" },
"widgets_values": ["cinematic golden hour portrait of an ancient Chinese woman in jade-green hanfu, delicate light, ultra detailed"]
},
{
"id": 2,
"type": "boosapi.text_to_image",
"pos": [520, 120],
"size": [380, 180],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [{ "name": "prompt", "type": "STRING", "link": 1 }],
"outputs": [
{ "name": "image", "type": "IMAGE", "links": [2], "slot_index": 0 },
{ "name": "images", "type": "IMAGE", "links": null, "slot_index": 1 },
{ "name": "raw", "type": "JSON", "links": null, "slot_index": 2 }
],
"properties": { "Node name for S&R": "boosapi.text_to_image" },
"widgets_values": ["gpt-image-2", 1024, 1024, ""]
},
{
"id": 3,
"type": "output.preview_image",
"pos": [980, 140],
"size": [320, 120],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [{ "name": "image", "type": "IMAGE", "link": 2 }],
"outputs": [{ "name": "image", "type": "IMAGE", "links": null, "slot_index": 0 }],
"properties": { "Node name for S&R": "output.preview_image" },
"widgets_values": []
}
],
"links": [
[1, 1, 0, 2, 0, "STRING"],
[2, 2, 0, 3, 0, "IMAGE"]
],
"groups": [],
"config": {},
"extra": { "ds": { "scale": 0.9, "offset": [80, 80] } },
"version": 0.4
})
}
pub(super) fn text_to_video_workflow_graph() -> Value {
json!({
"last_node_id": 4,
"last_link_id": 3,
"nodes": [
{
"id": 1,
"type": "input.text",
"pos": [80, 150],
"size": [480, 260],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [{ "name": "text", "type": "STRING", "links": [1], "slot_index": 0 }],
"properties": { "Node name for S&R": "input.text" },
"widgets_values": ["cinematic aerial shot of a glass bottle floating through a purple galaxy nebula, elegant camera motion, dreamy lighting"]
},
{
"id": 2,
"type": "boosapi.text_to_video",
"pos": [600, 120],
"size": [420, 300],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [{ "name": "prompt", "type": "STRING", "link": 1 }],
"outputs": [
{ "name": "task_id", "type": "STRING", "links": [2], "slot_index": 0 },
{ "name": "video", "type": "VIDEO", "links": [3], "slot_index": 1 },
{ "name": "raw", "type": "JSON", "links": null, "slot_index": 2 }
],
"properties": { "Node name for S&R": "boosapi.text_to_video" },
"widgets_values": [DEFAULT_TEXT_TO_VIDEO_MODEL, "16:9", 5, "720p", 24, true, 5, 180]
},
{
"id": 3,
"type": "boosapi.video_status",
"pos": [1080, 95],
"size": [360, 140],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [{ "name": "task_id", "type": "STRING", "link": 2 }],
"outputs": [
{ "name": "video", "type": "VIDEO", "links": null, "slot_index": 0 },
{ "name": "raw", "type": "JSON", "links": null, "slot_index": 1 }
],
"properties": { "Node name for S&R": "boosapi.video_status" },
"widgets_values": [DEFAULT_TEXT_TO_VIDEO_MODEL]
},
{
"id": 4,
"type": "output.preview_video",
"pos": [1080, 285],
"size": [340, 120],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [{ "name": "video", "type": "VIDEO", "link": 3 }],
"outputs": [{ "name": "video", "type": "VIDEO", "links": null, "slot_index": 0 }],
"properties": { "Node name for S&R": "output.preview_video" },
"widgets_values": []
}
],
"links": [
[1, 1, 0, 2, 0, "STRING"],
[2, 2, 0, 3, 0, "STRING"],
[3, 2, 1, 4, 0, "VIDEO"]
],
"groups": [],
"config": {},
"extra": { "ds": { "scale": 0.85, "offset": [80, 80] } },
"version": 0.4
})
}
pub(super) fn port_name(node_type: &str, index: usize) -> &'static str {
known_output_ports(node_type)
.get(index)
.copied()
.unwrap_or_else(|| known_output_ports(node_type)[0])
}
pub(super) fn source_node_type(prompt: &Value, node_id: &str) -> Option<String> {
prompt
.as_object()?
.get(node_id)?
.get("class_type")?
.as_str()
.map(str::to_string)
}
pub(super) fn normalize_prompt_payload(prompt: &Value) -> AppResult<WorkflowGraph> {
let nodes = prompt
.as_object()
.ok_or_else(|| AppError::BadRequest("prompt must be an object".into()))?;
let mut workflow_nodes = Vec::with_capacity(nodes.len());
let mut edges = Vec::new();
for (node_id, raw_node) in nodes {
let node_type = raw_node
.get("class_type")
.and_then(Value::as_str)
.ok_or_else(|| AppError::BadRequest(format!("node {node_id} missing class_type")))?;
let inputs = raw_node.get("inputs").and_then(Value::as_object);
let mut params = Map::new();
if let Some(map) = inputs {
for (input_key, input_value) in map {
if let Some((source_id, output_index)) = prompt_link(input_value) {
let source_type = source_node_type(prompt, &source_id)
.unwrap_or_else(|| "input.text".to_string());
edges.push(crate::workflow::models::WorkflowEdge {
from: format!("{source_id}.{}", port_name(&source_type, output_index)),
to: format!("{node_id}.{input_key}"),
});
} else {
params.insert(input_key.clone(), input_value.clone());
}
}
}
workflow_nodes.push(WorkflowNode {
id: node_id.clone(),
node_type: node_type.to_string(),
position: raw_node
.get("_meta")
.and_then(|meta| meta.get("pos"))
.cloned()
.unwrap_or_else(|| json!({ "x": 80, "y": 120 })),
params,
});
}
let graph = WorkflowGraph {
version: "1.0".to_string(),
name: Some("ComfyUI workflow".to_string()),
nodes: workflow_nodes,
edges,
};
graph::validate_and_sort(&graph)?;
Ok(graph)
}
pub(super) fn prompt_link(value: &Value) -> Option<(String, usize)> {
let array = value.as_array()?;
let source = array.first()?.as_str()?.to_string();
let index = array.get(1).and_then(Value::as_u64).unwrap_or_default() as usize;
Some((source, index))
}
pub(super) fn node_type_def_to_comfy(def: &NodeTypeDefinition) -> Value {
let mut required = Map::new();
let mut optional = Map::new();
if let Some(inputs) = def.inputs.as_object() {
for (name, spec) in inputs {
required.insert(name.clone(), input_spec_to_comfy(spec, true));
}
}
if let Some(params) = def.params.as_object() {
for (name, spec) in params {
optional.insert(name.clone(), input_spec_to_comfy(spec, false));
}
}
json!({
"name": def.node_type,
"display_name": def.title,
"description": def.description,
"category": def.category,
"output_node": def.category.eq_ignore_ascii_case("output"),
"python_module": "boosapi.workflow",
"input": {
"required": required,
"optional": optional,
},
"output": output_specs(def),
"output_name": known_output_ports(def.node_type),
"output_is_list": output_is_list_specs(def),
"output_tooltips": vec![""; known_output_ports(def.node_type).len()],
})
}
pub(super) fn input_spec_to_comfy(value: &Value, required: bool) -> Value {
let ty = value
.get("type")
.and_then(Value::as_str)
.unwrap_or("STRING");
let default = value.get("default").cloned();
let options = value
.get("options")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let mut props = Map::new();
if let Some(default) = default {
props.insert("default".into(), default);
}
if value.get("ui").and_then(Value::as_str) == Some("textarea") {
props.insert("multiline".into(), Value::Bool(true));
props.insert("dynamicPrompts".into(), Value::Bool(false));
}
if required {
props.insert("forceInput".into(), Value::Bool(true));
}
if ty == "CONDITIONING" {
return json!(["CONDITIONING", props]);
}
match ty {
"COMBO" => {
let choices = if options.is_empty() {
props
.get("default")
.cloned()
.map(|value| vec![value])
.unwrap_or_default()
} else {
options
};
json!([choices, props])
}
"BOOLEAN" => json!(["BOOLEAN", props]),
"NUMBER" | "FLOAT" => json!(["FLOAT", props]),
"INT" => json!(["INT", props]),
"JSON" => json!(["STRING", props]),
"FILE" => json!(["STRING", props]),
"STRING" => json!(["STRING", props]),
_ => json!([ty, props]),
}
}
pub(super) fn output_specs(def: &NodeTypeDefinition) -> Vec<Value> {
let mut outputs = Vec::new();
match def.node_type {
"input.text" => outputs.push(json!("STRING")),
"boosapi.chat_completion" => outputs.extend([json!("STRING"), json!("JSON")]),
"boosapi.prompt_rewrite" => outputs.extend([json!("STRING"), json!("JSON")]),
"boosapi.text_to_image" => outputs.extend([json!("IMAGE"), json!("IMAGE"), json!("JSON")]),
"boosapi.text_to_video" | "boosapi.image_to_video" => {
outputs.extend([json!("STRING"), json!("VIDEO"), json!("JSON")])
}
"boosapi.video_status" => outputs.extend([json!("VIDEO"), json!("JSON")]),
"boosapi.comfyui_workflow" => {
outputs.extend([json!("JSON"), json!("STRING"), json!("JSON")])
}
"comfyui.submit_workflow" => {
outputs.extend([json!("STRING"), json!("JSON"), json!("JSON")])
}
"CheckpointLoaderSimple" => outputs.extend([json!("MODEL"), json!("CLIP"), json!("VAE")]),
"CLIPTextEncode" => outputs.push(json!("CONDITIONING")),
"EmptyLatentImage" | "KSampler" => outputs.push(json!("LATENT")),
"VAEDecode" | "SaveImage" | "PreviewImage" => outputs.push(json!("IMAGE")),
"output.preview_image" => outputs.push(json!("IMAGE")),
"output.preview_video" => outputs.push(json!("VIDEO")),
"output.save_file" => outputs.push(json!("FILE")),
"util.text_join" => outputs.push(json!("STRING")),
"util.json_extract" => outputs.extend([json!("JSON"), json!("STRING")]),
_ => outputs.push(json!("STRING")),
}
outputs
}
pub(super) fn output_is_list_specs(def: &NodeTypeDefinition) -> Vec<bool> {
match def.node_type {
"boosapi.text_to_image" => vec![false, true, false],
_ => vec![false; known_output_ports(def.node_type).len()],
}
}
+435
View File
@@ -0,0 +1,435 @@
use crate::{
error::{AppError, AppResult},
workflow::storage,
AppState,
};
use axum::{
extract::{Path, Query, State},
http::{HeaderMap, Method, StatusCode},
response::{Html, IntoResponse, Response},
Json,
};
use serde_json::{json, Value};
use sqlx::Row;
use std::{collections::HashMap, path::PathBuf, sync::mpsc};
use super::auth::{auth_caller, caller_is_admin, caller_user_id};
use super::node_defs::normalize_prompt_payload;
use super::{
auth_caller_optional, guess_mime_from_path, inject_native_prompt_cloud_api_key,
native_workflow_json, native_workflow_json_cached, native_workflow_metadata_ttl,
native_workflow_response, native_workflow_view_response, prompt_contains_boosapi_nodes,
prompt_error_response, publish_run_events, repair_dynamic_combo_nodes,
repair_resize_image_mask_nodes, repair_text_generate_ltx2_prompt_nodes, repair_wan2_api_nodes,
run_outputs_for_comfy, safe_view_path, simple_urlencode, user_home, workflow_context,
workflow_graph_to_comfy_workflow, workflow_progress_callback_with_run_sender,
workflow_run_to_history, workflow_run_to_history_task, workflow_run_to_queue_task,
HistoryQuery, PromptPayload, QueueMutation, WorkflowCaller,
};
pub(super) async fn queue_status(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let _ = auth_caller_optional(&state, &headers);
if state.config.comfyui.uses_native_backend() {
return native_workflow_json(&state, Method::GET, "/prompt", None).await;
}
Ok(Json(json!({
"status_str": "success",
"completed": true,
"messages": []
})))
}
pub(super) async fn queue(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
if state.config.comfyui.uses_native_backend() {
let _ = auth_caller_optional(&state, &headers);
return native_workflow_json(&state, Method::GET, "/queue", None).await;
}
let caller =
auth_caller_optional(&state, &headers).unwrap_or(WorkflowCaller::User { user_id: 0 });
let rows = storage::list_runs(
&state.db,
caller_user_id(&caller),
caller_is_admin(&caller),
25,
)
.await?;
let running = rows
.into_iter()
.filter(|run| run.status == "running")
.map(workflow_run_to_queue_task)
.collect::<Vec<_>>();
Ok(Json(json!({
"Running": running,
"Pending": []
})))
}
pub(super) async fn queue_mutate(
State(state): State<AppState>,
headers: HeaderMap,
Json(_payload): Json<QueueMutation>,
) -> AppResult<Json<Value>> {
let _ = auth_caller(&state, &headers).await?;
if state.config.comfyui.uses_native_backend() {
let value = serde_json::to_value(&_payload).unwrap_or_else(|_| json!({}));
return native_workflow_json(&state, Method::POST, "/queue", Some(value)).await;
}
Ok(Json(json!({
"success": true,
"Running": [],
"Pending": []
})))
}
pub(super) async fn history(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<HistoryQuery>,
) -> AppResult<Json<Value>> {
if state.config.comfyui.uses_native_backend() {
let _ = auth_caller_optional(&state, &headers);
let path = format!("/history?max_items={}", query.max_items);
return native_workflow_json(&state, Method::GET, &path, None).await;
}
let caller =
auth_caller_optional(&state, &headers).unwrap_or(WorkflowCaller::User { user_id: 0 });
let rows = storage::list_runs(
&state.db,
caller_user_id(&caller),
caller_is_admin(&caller),
query.max_items,
)
.await?;
let history = rows
.into_iter()
.map(workflow_run_to_history_task)
.collect::<Vec<_>>();
Ok(Json(json!({ "History": history })))
}
pub(super) async fn history_mutate(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<QueueMutation>,
) -> AppResult<Json<Value>> {
let caller = auth_caller(&state, &headers).await?;
if state.config.comfyui.uses_native_backend() {
let value = serde_json::to_value(&payload).unwrap_or_else(|_| json!({}));
return native_workflow_json(&state, Method::POST, "/history", Some(value)).await;
}
let affected = if payload.clear {
storage::clear_runs(&state, caller_user_id(&caller), caller_is_admin(&caller)).await?
as usize
} else {
storage::delete_runs(
&state,
&payload.delete,
caller_user_id(&caller),
caller_is_admin(&caller),
)
.await?
};
Ok(Json(json!({
"success": true,
"deleted": affected
})))
}
pub(super) async fn history_by_id(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
if state.config.comfyui.uses_native_backend() {
let _ = auth_caller_optional(&state, &headers);
let path = format!("/history/{}", simple_urlencode(&id));
return native_workflow_json(&state, Method::GET, &path, None).await;
}
let caller =
auth_caller_optional(&state, &headers).unwrap_or(WorkflowCaller::User { user_id: 0 });
let run = storage::get_run(
&state.db,
&id,
caller_user_id(&caller),
caller_is_admin(&caller),
)
.await?;
Ok(Json(workflow_run_to_history(run)))
}
pub(super) async fn queue_prompt(
State(state): State<AppState>,
headers: HeaderMap,
Json(mut payload): Json<PromptPayload>,
) -> AppResult<Response> {
let caller = auth_caller(&state, &headers).await?;
if state.config.comfyui.uses_native_backend() {
if state.config.comfyui.uses_cloud_backend() {
repair_native_prompt_payload(&state, &mut payload).await;
return native_workflow_response(
&state,
Method::POST,
"/prompt",
Some(serde_json::to_value(payload).unwrap_or_else(|_| json!({}))),
)
.await;
}
if prompt_contains_boosapi_nodes(&payload.prompt) {
return queue_boosapi_prompt(state, caller, payload).await;
}
repair_native_prompt_payload(&state, &mut payload).await;
return native_workflow_response(
&state,
Method::POST,
"/prompt",
Some(serde_json::to_value(payload).unwrap_or_else(|_| json!({}))),
)
.await;
}
queue_boosapi_prompt(state, caller, payload).await
}
pub(super) async fn repair_native_prompt_payload(state: &AppState, payload: &mut PromptPayload) {
inject_native_prompt_cloud_api_key(state, payload);
let object_info = native_workflow_json_cached(
state,
Method::GET,
"/object_info",
None,
native_workflow_metadata_ttl(state),
)
.await
.ok()
.map(|value| value.0);
if let Some(object_info) = object_info.as_ref() {
repair_dynamic_combo_nodes(&mut payload.prompt, object_info);
}
repair_resize_image_mask_nodes(&mut payload.prompt, &payload.extra_data);
repair_text_generate_ltx2_prompt_nodes(&mut payload.prompt);
repair_wan2_api_nodes(&mut payload.prompt);
}
pub(super) async fn queue_boosapi_prompt(
state: AppState,
caller: WorkflowCaller,
payload: PromptPayload,
) -> AppResult<Response> {
let prompt = payload.prompt.clone();
let graph = match normalize_prompt_payload(&prompt) {
Ok(graph) => graph,
Err(err) => {
return Ok(prompt_error_response("工作流无法执行", err.to_string()));
}
};
if graph.nodes.is_empty() {
return Ok(prompt_error_response(
"画布里还没有可执行节点",
"请先从左侧节点库添加 BoosAPI 节点并连接后再执行。",
));
}
let name = payload
.extra_data
.pointer("/extra_pnginfo/workflow/name")
.and_then(Value::as_str)
.unwrap_or("ComfyUI workflow");
let comfy_workflow = payload
.extra_data
.pointer("/extra_pnginfo/workflow")
.cloned()
.unwrap_or_else(|| workflow_graph_to_comfy_workflow(&graph));
let workflow = storage::insert_workflow(
&state,
caller_user_id(&caller),
name,
Some("Imported from ComfyUI frontend"),
&serde_json::to_value(&graph).unwrap_or_else(|_| json!({})),
"private",
false,
)
.await?;
let ctx = workflow_context(&state, &caller).await?;
let progress_session_id = payload
.client_id
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "boosapi-workflow".to_string());
let (run_tx, run_rx) = mpsc::sync_channel::<String>(1);
let progress =
workflow_progress_callback_with_run_sender(Some(progress_session_id.clone()), Some(run_tx));
let state_for_task = state.clone();
let graph_value = serde_json::to_value(&graph).unwrap_or_else(|_| json!({}));
let task_graph_value = graph_value.clone();
let user_id = caller_user_id(&caller);
let allow_admin = caller_is_admin(&caller);
let session_for_task = progress_session_id.clone();
tokio::spawn(async move {
let result = crate::workflow::executor::run_workflow_with_progress(
&state_for_task,
workflow.id,
task_graph_value,
json!({}),
ctx,
progress,
)
.await;
match result {
Ok(run_id) => {
if let Ok(run) =
storage::get_run(&state_for_task.db, &run_id, user_id, allow_admin).await
{
let outputs = run_outputs_for_comfy(&run);
publish_run_events(Some(session_for_task.as_str()), &run, &outputs);
}
}
Err(err) => {
tracing::warn!("workflow background execution failed: {err}");
}
}
});
let run_id = match tokio::task::spawn_blocking(move || run_rx.recv()).await {
Ok(Ok(run_id)) => run_id,
Ok(Err(_)) | Err(_) => {
return Ok(prompt_error_response(
"工作流执行失败",
"后台任务没有成功进入队列,请稍后重试。",
));
}
};
Ok(Json(json!({
"prompt_id": run_id,
"number": payload.number.unwrap_or(0),
"node_errors": [],
"client_id": payload.client_id,
"front": payload.front.unwrap_or(false),
"outputs": {},
"workflow": comfy_workflow
}))
.into_response())
}
pub(super) async fn interrupt(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let _ = auth_caller_optional(&state, &headers);
if state.config.comfyui.uses_native_backend() {
return native_workflow_json(&state, Method::POST, "/interrupt", Some(json!({}))).await;
}
Ok(Json(json!({ "success": true })))
}
pub(super) async fn view(
State(state): State<AppState>,
headers: HeaderMap,
Query(params): Query<HashMap<String, String>>,
) -> AppResult<Response> {
let caller = auth_caller(&state, &headers).await?;
if state.config.comfyui.uses_native_backend() {
return native_workflow_view_response(&state, &caller, &params).await;
}
let file = params.get("filename").cloned().unwrap_or_default();
if file.is_empty() {
return Err(AppError::BadRequest("filename is required".into()));
}
let file_type = params
.get("type")
.cloned()
.unwrap_or_else(|| "input".to_string());
let subfolder = params.get("subfolder").cloned().unwrap_or_default();
let user_root = user_home(caller_user_id(&caller))
.join("userdata")
.join(&file_type);
let local_path = if subfolder.trim().is_empty() {
user_root.join(&file)
} else {
user_root.join(safe_view_path(&subfolder)).join(&file)
};
if tokio::fs::metadata(&local_path).await.is_ok() {
let bytes = tokio::fs::read(&local_path)
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
return Ok((
StatusCode::OK,
[(
axum::http::header::CONTENT_TYPE,
guess_mime_from_path(&local_path),
)],
bytes,
)
.into_response());
}
let row = sqlx::query(
"SELECT file_path, remote_url, mime_type
FROM workspace_assets
WHERE file_name = ? OR public_id = ?
ORDER BY id DESC
LIMIT 1",
)
.bind(&file)
.bind(&file)
.fetch_optional(&*state.db)
.await?;
let Some(row) = row else {
return Err(AppError::NotFound("Workspace asset not found".into()));
};
let file_path: Option<String> = row.try_get("file_path").ok();
if let Some(path) = file_path {
let path_buf = PathBuf::from(path);
match tokio::fs::read(&path_buf).await {
Ok(bytes) => {
return Ok((
StatusCode::OK,
[(
axum::http::header::CONTENT_TYPE,
guess_mime_from_path(&path_buf),
)],
bytes,
)
.into_response())
}
Err(err) => return Err(AppError::UpstreamError(err.to_string())),
}
}
let remote_url: Option<String> = row.try_get("remote_url").ok();
if let Some(url) = remote_url
.as_deref()
.filter(|value| !value.trim().is_empty())
{
return Ok((
StatusCode::TEMPORARY_REDIRECT,
[(axum::http::header::LOCATION, url.to_string())],
)
.into_response());
}
Err(AppError::NotFound(
remote_url.unwrap_or_else(|| "asset unavailable".into()),
))
}
pub(super) async fn internal_logs(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Html<String>> {
let _ = auth_caller(&state, &headers).await?;
Ok(Html("".to_string()))
}
pub(super) async fn internal_logs_raw(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let _ = auth_caller(&state, &headers).await?;
Ok(Json(
json!({ "size": { "cols": 0, "row": 0 }, "entries": [] }),
))
}
+644
View File
@@ -0,0 +1,644 @@
use super::workflow_cloud_api_key;
use super::PromptPayload;
use crate::AppState;
use serde_json::{json, Map, Value};
use std::collections::HashMap;
pub(super) fn inject_native_prompt_cloud_api_key(state: &AppState, payload: &mut PromptPayload) {
if !state.config.comfyui.uses_cloud_backend() {
return;
}
let Some(api_key) = workflow_cloud_api_key(&state.config.comfyui) else {
return;
};
let Value::Object(extra_data) = &mut payload.extra_data else {
payload.extra_data = json!({ "api_key_comfy_org": api_key });
return;
};
extra_data.insert(
"api_key_comfy_org".to_string(),
Value::String(api_key.to_string()),
);
}
pub(super) fn repair_resize_image_mask_nodes(prompt: &mut Value, extra_data: &Value) {
let Some(prompt_nodes) = prompt.as_object_mut() else {
return;
};
let workflow = extra_data
.pointer("/extra_pnginfo/workflow")
.cloned()
.unwrap_or(Value::Null);
let mut workflow_nodes = Vec::<Value>::new();
collect_workflow_nodes(&workflow, &mut workflow_nodes);
if workflow_nodes.is_empty() {
return;
}
let workflow_by_id = workflow_nodes
.iter()
.filter_map(|node| {
let id = node.get("id")?;
Some((id_to_prompt_key(id), node))
})
.collect::<HashMap<_, _>>();
for (node_id, node) in prompt_nodes.iter_mut() {
let Some(object) = node.as_object_mut() else {
continue;
};
if object
.get("class_type")
.and_then(Value::as_str)
.is_none_or(|class_type| class_type != "ResizeImageMaskNode")
{
continue;
}
let Some(inputs) = object.get_mut("inputs").and_then(Value::as_object_mut) else {
continue;
};
let crop_is_invalid = inputs
.get("resize_type.crop")
.is_some_and(|value| !matches!(value.as_str(), Some("disabled" | "center")));
if !crop_is_invalid {
continue;
}
let Some(workflow_node) = workflow_by_id
.get(node_id)
.or_else(|| prompt_subgraph_node_id(node_id).and_then(|id| workflow_by_id.get(id)))
else {
repair_resize_image_mask_node_inputs_without_workflow(node_id, inputs);
continue;
};
let Some(values) = workflow_node
.get("widgets_values")
.and_then(Value::as_array)
else {
repair_resize_image_mask_node_inputs_without_workflow(node_id, inputs);
continue;
};
if values
.first()
.and_then(Value::as_str)
.is_some_and(|value| value == "scale dimensions")
&& values.len() >= 4
{
inputs.insert(
"resize_type".to_string(),
Value::String("scale dimensions".to_string()),
);
inputs.insert("resize_type.width".to_string(), values[1].clone());
inputs.insert("resize_type.height".to_string(), values[2].clone());
inputs.insert("resize_type.crop".to_string(), values[3].clone());
if let Some(method) = values.get(4) {
inputs.insert("resize_type.method".to_string(), method.clone());
}
tracing::warn!(node_id = %node_id, "repaired ResizeImageMaskNode dynamic widget prompt inputs");
}
}
}
fn repair_resize_image_mask_node_inputs_without_workflow(
node_id: &str,
inputs: &mut Map<String, Value>,
) {
let crop_is_invalid = inputs
.get("resize_type.crop")
.is_some_and(|value| !matches!(value.as_str(), Some("disabled" | "center")));
if !crop_is_invalid {
return;
}
inputs
.entry("resize_type".to_string())
.or_insert_with(|| Value::String("scale dimensions".to_string()));
inputs.insert(
"resize_type.crop".to_string(),
Value::String("center".to_string()),
);
tracing::warn!(
node_id = %node_id,
"repaired ResizeImageMaskNode crop prompt input without workflow metadata"
);
}
pub(super) fn repair_text_generate_ltx2_prompt_nodes(prompt: &mut Value) {
let Some(prompt_nodes) = prompt.as_object_mut() else {
return;
};
for (node_id, node) in prompt_nodes.iter_mut() {
let Some(object) = node.as_object_mut() else {
continue;
};
if object
.get("class_type")
.and_then(Value::as_str)
.is_none_or(|class_type| class_type != "TextGenerateLTX2Prompt")
{
continue;
}
let Some(inputs) = object.get_mut("inputs").and_then(Value::as_object_mut) else {
continue;
};
let repetition_penalty_invalid = inputs
.get("sampling_mode.repetition_penalty")
.and_then(Value::as_f64)
.is_some_and(|value| !(0.0..=5.0).contains(&value));
if !repetition_penalty_invalid {
continue;
}
inputs.insert("sampling_mode".to_string(), Value::String("on".to_string()));
inputs.insert("sampling_mode.temperature".to_string(), json!(0.7));
inputs.insert("sampling_mode.top_k".to_string(), json!(64));
inputs.insert("sampling_mode.top_p".to_string(), json!(0.95));
inputs.insert("sampling_mode.min_p".to_string(), json!(0.05));
inputs.insert("sampling_mode.repetition_penalty".to_string(), json!(1.05));
inputs
.entry("sampling_mode.seed".to_string())
.or_insert_with(|| json!(0));
inputs.insert("sampling_mode.presence_penalty".to_string(), json!(0.0));
tracing::warn!(
node_id = %node_id,
"repaired TextGenerateLTX2Prompt dynamic sampling prompt inputs"
);
}
}
#[derive(Debug, Clone)]
struct DynamicComboField {
name: String,
value_type: String,
options: Vec<String>,
default: Option<Value>,
min: Option<f64>,
max: Option<f64>,
}
pub(super) fn repair_dynamic_combo_nodes(prompt: &mut Value, object_info: &Value) {
let Some(prompt_nodes) = prompt.as_object_mut() else {
return;
};
for (node_id, node) in prompt_nodes.iter_mut() {
let Some(object) = node.as_object_mut() else {
continue;
};
let class_type = object
.get("class_type")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if class_type.is_empty() {
continue;
}
let dynamic_groups = dynamic_combo_groups_for_node(object_info, &class_type);
if dynamic_groups.is_empty() {
continue;
}
let Some(inputs) = object.get_mut("inputs").and_then(Value::as_object_mut) else {
continue;
};
let mut repaired_groups = Vec::<String>::new();
for (group_name, option_key, fields) in dynamic_groups {
if repair_dynamic_combo_group(inputs, &group_name, &option_key, &fields) {
repaired_groups.push(group_name);
}
}
if !repaired_groups.is_empty() {
tracing::warn!(
node_id = %node_id,
class_type = %class_type,
groups = ?repaired_groups,
"repaired ComfyUI dynamic combo prompt inputs from object_info schema"
);
}
}
}
fn dynamic_combo_groups_for_node(
object_info: &Value,
class_type: &str,
) -> Vec<(String, String, Vec<DynamicComboField>)> {
let Some(node) = object_info.get(class_type) else {
return Vec::new();
};
let mut groups = Vec::new();
for section in ["required", "optional"] {
let Some(inputs) = node
.pointer(&format!("/input/{section}"))
.and_then(Value::as_object)
else {
continue;
};
for (name, input) in inputs {
let Some(input_type) = input.get(0).and_then(Value::as_str) else {
continue;
};
if !input_type.starts_with("COMFY_DYNAMICCOMBO") {
continue;
}
let Some(option) = input
.get(1)
.and_then(|config| config.get("options"))
.and_then(Value::as_array)
.and_then(|options| options.first())
else {
continue;
};
let option_key = option
.get("key")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let fields = dynamic_combo_fields_from_option(option);
if !fields.is_empty() {
groups.push((name.clone(), option_key, fields));
}
}
}
groups
}
fn dynamic_combo_fields_from_option(option: &Value) -> Vec<DynamicComboField> {
let Some(required) = option
.pointer("/inputs/required")
.and_then(Value::as_object)
else {
return Vec::new();
};
required
.iter()
.filter_map(|(name, spec)| {
let value_type = spec.get(0).and_then(Value::as_str)?.to_string();
let config = spec.get(1).and_then(Value::as_object);
let options = config
.and_then(|config| config.get("options"))
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default();
Some(DynamicComboField {
name: name.clone(),
value_type,
options,
default: config.and_then(|config| config.get("default")).cloned(),
min: config
.and_then(|config| config.get("min"))
.and_then(Value::as_f64),
max: config
.and_then(|config| config.get("max"))
.and_then(Value::as_f64),
})
})
.collect()
}
fn repair_dynamic_combo_group(
inputs: &mut Map<String, Value>,
group_name: &str,
option_key: &str,
fields: &[DynamicComboField],
) -> bool {
if fields.is_empty() {
return false;
}
let mut values = Vec::<Value>::new();
if let Some(value) = inputs.get(group_name).cloned() {
values.push(value);
}
for field in fields {
if let Some(value) = inputs.get(&format!("{group_name}.{}", field.name)).cloned() {
values.push(value);
}
}
if values.is_empty() {
return false;
}
let invalid = fields.iter().any(|field| {
let key = format!("{group_name}.{}", field.name);
inputs
.get(&key)
.is_some_and(|value| !dynamic_combo_value_matches_field(value, field))
});
if !invalid {
return false;
}
let mut used = vec![false; values.len()];
if !option_key.is_empty() {
inputs.insert(
group_name.to_string(),
Value::String(option_key.to_string()),
);
for (idx, value) in values.iter().enumerate() {
if value.as_str() == Some(option_key) {
used[idx] = true;
break;
}
}
}
for field in fields {
let key = format!("{group_name}.{}", field.name);
let selected_idx = values.iter().enumerate().find_map(|(idx, value)| {
(!used[idx] && dynamic_combo_value_matches_field(value, field)).then_some(idx)
});
let repaired = selected_idx
.map(|idx| {
used[idx] = true;
normalize_dynamic_combo_value(&values[idx], field)
})
.unwrap_or_else(|| default_dynamic_combo_value(field));
inputs.insert(key, repaired);
}
true
}
fn dynamic_combo_value_matches_field(value: &Value, field: &DynamicComboField) -> bool {
match field.value_type.as_str() {
"STRING" => value.as_str().is_some(),
"COMBO" => value
.as_str()
.is_some_and(|text| field.options.iter().any(|option| option == text)),
"INT" => dynamic_combo_value_as_i64(value).is_some_and(|number| {
field.min.is_none_or(|min| (number as f64) >= min)
&& field.max.is_none_or(|max| (number as f64) <= max)
}),
"FLOAT" => dynamic_combo_value_as_f64(value).is_some_and(|number| {
field.min.is_none_or(|min| number >= min) && field.max.is_none_or(|max| number <= max)
}),
"BOOLEAN" => dynamic_combo_value_as_bool(value).is_some(),
_ => true,
}
}
fn normalize_dynamic_combo_value(value: &Value, field: &DynamicComboField) -> Value {
match field.value_type.as_str() {
"INT" => dynamic_combo_value_as_i64(value)
.map(|number| json!(clamp_i64_to_field(number, field)))
.unwrap_or_else(|| default_dynamic_combo_value(field)),
"FLOAT" => dynamic_combo_value_as_f64(value)
.map(|number| json!(clamp_f64_to_field(number, field)))
.unwrap_or_else(|| default_dynamic_combo_value(field)),
"BOOLEAN" => dynamic_combo_value_as_bool(value)
.map(Value::Bool)
.unwrap_or_else(|| default_dynamic_combo_value(field)),
_ => value.clone(),
}
}
fn default_dynamic_combo_value(field: &DynamicComboField) -> Value {
if let Some(default) = &field.default {
return normalize_dynamic_combo_value(default, field);
}
match field.value_type.as_str() {
"COMBO" => field
.options
.first()
.cloned()
.map(Value::String)
.unwrap_or(Value::Null),
"STRING" => Value::String(String::new()),
"INT" => json!(field.min.unwrap_or(0.0).ceil() as i64),
"FLOAT" => json!(field.min.unwrap_or(0.0)),
"BOOLEAN" => Value::Bool(false),
_ => Value::Null,
}
}
fn dynamic_combo_value_as_i64(value: &Value) -> Option<i64> {
value
.as_i64()
.or_else(|| value.as_u64().and_then(|number| i64::try_from(number).ok()))
.or_else(|| value.as_f64().map(|number| number.round() as i64))
.or_else(|| {
value
.as_str()
.and_then(|text| text.trim().parse::<i64>().ok())
})
}
fn dynamic_combo_value_as_f64(value: &Value) -> Option<f64> {
value.as_f64().or_else(|| {
value
.as_str()
.and_then(|text| text.trim().parse::<f64>().ok())
})
}
fn dynamic_combo_value_as_bool(value: &Value) -> Option<bool> {
value.as_bool().or_else(|| {
value
.as_str()
.and_then(|text| match text.trim().to_ascii_lowercase().as_str() {
"true" | "1" | "yes" | "on" => Some(true),
"false" | "0" | "no" | "off" => Some(false),
_ => None,
})
})
}
fn clamp_i64_to_field(value: i64, field: &DynamicComboField) -> i64 {
let min = field
.min
.map(|value| value.ceil() as i64)
.unwrap_or(i64::MIN);
let max = field
.max
.map(|value| value.floor() as i64)
.unwrap_or(i64::MAX);
value.clamp(min, max)
}
fn clamp_f64_to_field(value: f64, field: &DynamicComboField) -> f64 {
let min = field.min.unwrap_or(f64::NEG_INFINITY);
let max = field.max.unwrap_or(f64::INFINITY);
value.clamp(min, max)
}
pub(super) fn repair_wan2_api_nodes(prompt: &mut Value) {
let Some(prompt_nodes) = prompt.as_object_mut() else {
return;
};
for (node_id, node) in prompt_nodes.iter_mut() {
let Some(object) = node.as_object_mut() else {
continue;
};
let class_type = object
.get("class_type")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let field_names = match class_type.as_str() {
"Wan2ImageToVideoApi" => &["prompt", "negative_prompt", "resolution", "duration"][..],
"Wan2TextToVideoApi" | "Wan2ReferenceVideoApi" => &[
"prompt",
"negative_prompt",
"resolution",
"ratio",
"duration",
][..],
_ => continue,
};
let Some(inputs) = object.get_mut("inputs").and_then(Value::as_object_mut) else {
continue;
};
if !wan2_model_inputs_look_shifted(inputs) {
continue;
}
repair_wan2_model_inputs(inputs, field_names);
tracing::warn!(
node_id = %node_id,
class_type = %class_type,
"repaired Wan2 dynamic model prompt inputs"
);
}
}
fn wan2_model_inputs_look_shifted(inputs: &Map<String, Value>) -> bool {
inputs
.get("model.resolution")
.is_some_and(|value| !matches!(value.as_str(), Some("720P" | "1080P")))
|| inputs.get("model.ratio").is_some_and(|value| {
!matches!(
value.as_str(),
Some("16:9" | "9:16" | "1:1" | "4:3" | "3:4")
)
})
|| inputs
.get("model.duration")
.is_some_and(|value| value.as_i64().is_none())
}
fn repair_wan2_model_inputs(inputs: &mut Map<String, Value>, field_names: &[&str]) {
let mut values = field_names
.iter()
.filter_map(|name| inputs.remove(&format!("model.{name}")))
.collect::<Vec<_>>();
if let Some(model) = inputs.remove("model") {
values.insert(0, model);
}
let prompt = values
.iter()
.find_map(|value| {
value.as_str().and_then(|text| {
let trimmed = text.trim();
if trimmed.len() > 20 && !is_wan2_resolution(trimmed) && !is_wan2_ratio(trimmed) {
Some(Value::String(text.to_string()))
} else {
None
}
})
})
.unwrap_or_else(|| Value::String(String::new()));
let negative_prompt = values
.iter()
.filter_map(Value::as_str)
.find(|text| {
let trimmed = text.trim();
!trimmed.is_empty()
&& trimmed.len() <= 20
&& !is_wan2_model_key(trimmed)
&& !is_wan2_resolution(trimmed)
&& !is_wan2_ratio(trimmed)
})
.map(|text| Value::String(text.to_string()))
.unwrap_or_else(|| Value::String(String::new()));
let resolution = values
.iter()
.filter_map(Value::as_str)
.find(|text| is_wan2_resolution(text.trim()))
.map(|text| Value::String(text.trim().to_string()))
.unwrap_or_else(|| Value::String("720P".to_string()));
let ratio = values
.iter()
.filter_map(Value::as_str)
.find(|text| is_wan2_ratio(text.trim()))
.map(|text| Value::String(text.trim().to_string()))
.unwrap_or_else(|| Value::String("16:9".to_string()));
let duration = values
.iter()
.find_map(|value| {
value.as_i64().or_else(|| {
value
.as_str()
.and_then(|text| text.trim().parse::<i64>().ok())
})
})
.map(|value| value.clamp(2, 15))
.unwrap_or(5);
inputs.insert(
"model".to_string(),
infer_wan2_model_key(field_names, &values),
);
inputs.insert("model.prompt".to_string(), prompt);
inputs.insert("model.negative_prompt".to_string(), negative_prompt);
inputs.insert("model.resolution".to_string(), resolution);
if field_names.contains(&"ratio") {
inputs.insert("model.ratio".to_string(), ratio);
}
inputs.insert("model.duration".to_string(), json!(duration));
}
fn infer_wan2_model_key(field_names: &[&str], values: &[Value]) -> Value {
if let Some(model) = values
.iter()
.filter_map(Value::as_str)
.find(|value| is_wan2_model_key(value.trim()))
{
return Value::String(model.trim().to_string());
}
if field_names.contains(&"ratio") {
Value::String("wan2.7-t2v".to_string())
} else {
Value::String("wan2.7-i2v".to_string())
}
}
fn is_wan2_resolution(value: &str) -> bool {
matches!(value, "720P" | "1080P")
}
fn is_wan2_ratio(value: &str) -> bool {
matches!(value, "16:9" | "9:16" | "1:1" | "4:3" | "3:4")
}
fn is_wan2_model_key(value: &str) -> bool {
matches!(value, "wan2.7-i2v" | "wan2.7-t2v" | "wan2.7-r2v")
}
fn collect_workflow_nodes(value: &Value, nodes: &mut Vec<Value>) {
if let Some(items) = value.get("nodes").and_then(Value::as_array) {
nodes.extend(items.iter().cloned());
}
match value {
Value::Array(items) => {
for item in items {
collect_workflow_nodes(item, nodes);
}
}
Value::Object(object) => {
for item in object.values() {
collect_workflow_nodes(item, nodes);
}
}
_ => {}
}
}
fn prompt_subgraph_node_id(node_id: &str) -> Option<&str> {
node_id
.rsplit_once(':')
.map(|(_, child_id)| child_id.trim())
.filter(|value| !value.is_empty())
}
fn id_to_prompt_key(id: &Value) -> String {
if let Some(value) = id.as_i64() {
return value.to_string();
}
id.as_str().unwrap_or_default().to_string()
}
+287
View File
@@ -0,0 +1,287 @@
use crate::workflow::models::WorkflowGraph;
use serde_json::{json, Map, Value};
use super::native_proxy::timestamp_ms;
use super::workflow_node_label;
pub(super) fn workflow_run_to_history(run: crate::workflow::models::WorkflowRunSummary) -> Value {
let outputs = run_outputs_for_comfy(&run);
let status_str = if run.status == "failed" {
"error"
} else {
"success"
};
json!({
"prompt": workflow_run_to_prompt_tuple(&run),
"status": {
"status_str": status_str,
"completed": run.status != "running",
"messages": run_status_messages(&run)
},
"outputs": outputs,
"meta": {},
})
}
pub(super) fn workflow_run_to_history_task(run: crate::workflow::models::WorkflowRunSummary) -> Value {
let history = workflow_run_to_history(run);
json!({
"taskType": "History",
"prompt": history.get("prompt").cloned().unwrap_or_else(|| json!([])),
"status": history.get("status").cloned().unwrap_or_else(|| json!({})),
"outputs": history.get("outputs").cloned().unwrap_or_else(|| json!({})),
})
}
pub(super) fn workflow_run_to_queue_task(run: crate::workflow::models::WorkflowRunSummary) -> Value {
json!({
"taskType": "Running",
"prompt": workflow_run_to_prompt_tuple(&run),
"status": {
"status_str": "running",
"completed": false,
"messages": run_status_messages(&run)
},
"outputs": run_outputs_for_comfy(&run),
})
}
fn workflow_timestamp_ms(value: &str) -> i64 {
let trimmed = value.trim();
if trimmed.is_empty() {
return timestamp_ms();
}
chrono::DateTime::parse_from_rfc3339(trimmed)
.map(|dt| dt.timestamp_millis())
.or_else(|_| {
chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%d %H:%M:%S")
.map(|dt| dt.and_utc().timestamp_millis())
})
.or_else(|_| {
chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S")
.map(|dt| dt.and_utc().timestamp_millis())
})
.unwrap_or_else(|_| timestamp_ms())
}
pub(super) fn workflow_graph_to_comfy_workflow(graph: &WorkflowGraph) -> Value {
let nodes = graph
.nodes
.iter()
.map(|node| {
json!({
"id": node.id,
"type": node.node_type,
"pos": [
node.position.get("x").and_then(Value::as_f64).unwrap_or(80.0),
node.position.get("y").and_then(Value::as_f64).unwrap_or(120.0)
],
"widgets_values": node.params,
"properties": { "Node name for S&R": node.node_type },
})
})
.collect::<Vec<_>>();
json!({
"id": graph.name.clone().unwrap_or_else(|| "boosapi-workflow".to_string()),
"version": 0.4,
"nodes": nodes,
"links": [],
"groups": [],
"config": {},
"extra": {},
})
}
fn run_status_messages(run: &crate::workflow::models::WorkflowRunSummary) -> Vec<Value> {
let mut messages = vec![json!(["execution_start", {
"prompt_id": run.public_id,
"timestamp": workflow_timestamp_ms(run.started_at.as_deref().unwrap_or(&run.created_at))
}])];
if run.status == "running" {
let completed = run
.node_runs
.iter()
.filter(|node| node.status == "completed")
.count();
let total = run.node_runs.len().max(1);
messages.push(json!(["progress", {
"value": completed,
"max": total
}]));
if let Some(current) = run
.node_runs
.iter()
.find(|node| node.status == "running")
.or_else(|| run.node_runs.last())
{
let label = workflow_node_label(&current.node_type, &current.node_id);
messages.push(json!(["executing", {
"prompt_id": run.public_id,
"node": current.node_id,
"node_type": current.node_type,
"display_node": label,
"label": label,
}]));
}
} else if run.status == "failed" {
let failed_node = run
.node_runs
.iter()
.find(|node| node.status == "failed")
.or_else(|| run.node_runs.last());
messages.push(json!(["execution_error", {
"prompt_id": run.public_id,
"timestamp": workflow_timestamp_ms(run.completed_at.as_deref().unwrap_or(&run.created_at)),
"node_id": failed_node.map(|node| node.node_id.as_str()).unwrap_or("workflow"),
"node_type": failed_node.map(|node| node.node_type.as_str()).unwrap_or("Workflow"),
"executed": run.node_runs.iter().filter(|node| node.status == "completed").map(|node| node.node_id.clone()).collect::<Vec<_>>(),
"exception_message": run.error_message.clone().unwrap_or_else(|| "Workflow execution failed".to_string()),
"exception_type": "BoosAPIWorkflowError",
"traceback": [],
"current_inputs": failed_node.map(|node| node.inputs.clone()).unwrap_or_else(|| json!({})),
"current_outputs": failed_node.and_then(|node| node.outputs.clone()).unwrap_or_else(|| json!({})),
}]));
} else {
messages.push(json!(["execution_success", {
"prompt_id": run.public_id,
"timestamp": workflow_timestamp_ms(run.completed_at.as_deref().unwrap_or(&run.created_at))
}]));
}
messages
}
pub(super) fn run_outputs_for_comfy(run: &crate::workflow::models::WorkflowRunSummary) -> Value {
let mut outputs = Map::new();
for node in &run.node_runs {
if let Some(node_outputs) = &node.outputs {
let media = node_output_for_comfy(node_outputs)
.unwrap_or_else(|| json!({ "text": [workflow_text_output(node_outputs)] }));
outputs.insert(node.node_id.clone(), media);
}
}
Value::Object(outputs)
}
pub(super) fn node_output_for_comfy(node_outputs: &Value) -> Option<Value> {
let mut media = Map::new();
if let Some(videos) = comfy_media_items(node_outputs, "comfy_videos")
.or_else(|| comfy_media_items(node_outputs, "videos"))
.or_else(|| comfy_media_items(node_outputs, "video"))
{
media.insert("videos".to_string(), videos);
}
if let Some(images) = comfy_media_items(node_outputs, "comfy_images")
.or_else(|| comfy_media_items(node_outputs, "image"))
.or_else(|| comfy_media_items(node_outputs, "images"))
.or_else(|| comfy_media_items(node_outputs, "IMAGE"))
{
media.insert("images".to_string(), images);
}
if media.is_empty() {
None
} else {
Some(Value::Object(media))
}
}
fn comfy_media_items(node_outputs: &Value, key: &str) -> Option<Value> {
let value = node_outputs.get(key)?;
let items = if let Some(array) = value.as_array() {
array.clone()
} else {
vec![value.clone()]
};
let normalized = items
.into_iter()
.filter_map(normalize_comfy_media_item)
.collect::<Vec<_>>();
if normalized.is_empty() {
None
} else {
Some(Value::Array(normalized))
}
}
fn normalize_comfy_media_item(value: Value) -> Option<Value> {
let object = value.as_object()?;
let filename = object
.get("filename")
.and_then(Value::as_str)
.or_else(|| object.get("public_id").and_then(Value::as_str))
.or_else(|| object.get("publicId").and_then(Value::as_str))
.or_else(|| object.get("file_name").and_then(Value::as_str))
.or_else(|| object.get("fileName").and_then(Value::as_str))
.filter(|value| !value.trim().is_empty())?;
let file_type = object
.get("type")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.unwrap_or("output");
let subfolder = object
.get("subfolder")
.and_then(Value::as_str)
.unwrap_or("");
let mut normalized = Map::new();
normalized.insert("filename".to_string(), json!(filename));
normalized.insert("subfolder".to_string(), json!(subfolder));
normalized.insert("type".to_string(), json!(file_type));
if let Some(format) = object.get("format").and_then(Value::as_str) {
normalized.insert("format".to_string(), json!(format));
}
if let Some(frame_rate) = object.get("frame_rate").or_else(|| object.get("frameRate")) {
normalized.insert("frame_rate".to_string(), frame_rate.clone());
}
Some(Value::Object(normalized))
}
fn workflow_text_output(value: &Value) -> Value {
for key in ["text", "prompt", "task_id", "file"] {
if let Some(text) = value.get(key).and_then(Value::as_str) {
if !text.trim().is_empty() {
return json!({
"filename": text.chars().take(80).collect::<String>(),
"subfolder": "",
"type": "text",
});
}
}
}
json!({
"filename": "workflow-result",
"subfolder": "",
"type": "text",
})
}
fn workflow_run_to_prompt_tuple(run: &crate::workflow::models::WorkflowRunSummary) -> Value {
let workflow_name = run
.workflow_name
.as_deref()
.or(run.workflow_public_id.as_deref())
.unwrap_or("BoosAPI workflow");
let workflow = json!({ "name": workflow_name, "nodes": [], "links": [] });
let prompt = run
.node_runs
.iter()
.map(|node| {
(
node.node_id.clone(),
json!({
"inputs": node.inputs,
"class_type": node.node_type,
}),
)
})
.collect::<Map<_, _>>();
json!([
run.id,
run.public_id,
prompt,
{
"extra_pnginfo": { "workflow": workflow },
"client_id": "boosapi-workflow-studio"
},
run.node_runs.iter().map(|node| node.node_id.clone()).collect::<Vec<_>>()
])
}
+305
View File
@@ -0,0 +1,305 @@
use crate::{error::AppResult, AppState};
use axum::{
extract::{Query, State},
http::{HeaderMap, Method},
Json,
};
use serde_json::{json, Value};
use std::collections::HashMap;
use super::auth::{auth_caller, caller_user_id};
use super::models::{combo_fields_from_node, workflow_route_models};
use super::{
build_query_string, field_names_are_equivalent, inject_boosapi_object_info,
is_input_file_field, is_modelish_field, native_workflow_json_cached,
native_workflow_metadata_ttl, native_workflow_request, safe_view_path, user_home,
workflow_linked_node_ids, workflow_node_defs_with_models, workflow_validation_hints,
workflow_widget_inputs, workflow_widget_string, WorkflowCaller, WorkflowValidateQuery,
};
pub(super) async fn validate_workflow(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<WorkflowValidateQuery>,
Json(workflow): Json<Value>,
) -> AppResult<Json<Value>> {
let caller = auth_caller(&state, &headers).await?;
let object_info = if state.config.comfyui.uses_native_backend() {
let mut value = native_workflow_json_cached(
&state,
Method::GET,
"/object_info",
None,
native_workflow_metadata_ttl(&state),
)
.await?
.0;
let route_models = workflow_route_models(&state).await.unwrap_or_default();
inject_boosapi_object_info(&mut value, &route_models);
value
} else {
let route_models = workflow_route_models(&state).await.unwrap_or_default();
Value::Object(workflow_node_defs_with_models(&route_models, true))
};
let report = validate_workflow_against_object_info(
&state,
&caller,
&workflow,
&object_info,
query.check_files,
)
.await;
Ok(Json(report))
}
async fn validate_workflow_against_object_info(
state: &AppState,
caller: &WorkflowCaller,
workflow: &Value,
object_info: &Value,
check_files: bool,
) -> Value {
let nodes = workflow
.get("nodes")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let links = workflow
.get("links")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let linked_node_ids = workflow_linked_node_ids(&links);
let mut missing_node_types = Vec::<Value>::new();
let mut empty_model_fields = Vec::<Value>::new();
let mut missing_model_choices = Vec::<Value>::new();
let mut unknown_model_fields = Vec::<Value>::new();
let mut input_files = Vec::<Value>::new();
let mut node_type_counts = HashMap::<String, usize>::new();
for node in &nodes {
let node_id = node.get("id").and_then(Value::as_i64).unwrap_or_default();
let node_type = node
.get("type")
.and_then(Value::as_str)
.unwrap_or("")
.trim()
.to_string();
if node_type.is_empty() {
continue;
}
*node_type_counts.entry(node_type.clone()).or_default() += 1;
let connected = linked_node_ids.contains(&node_id);
let Some(node_def) = object_info.get(&node_type) else {
missing_node_types.push(json!({
"nodeId": node_id,
"nodeType": node_type,
"connected": connected,
"severity": if connected { "error" } else { "warning" },
}));
continue;
};
let widgets = node
.get("widgets_values")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let combo_fields = combo_fields_from_node(object_info, &node_type);
let widget_inputs = node
.get("inputs")
.and_then(Value::as_array)
.map(|inputs| workflow_widget_inputs(inputs))
.unwrap_or_default();
let mut widget_index = 0usize;
for input in widget_inputs {
let field = input
.get("name")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let input_type = input
.get("type")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let Some(widget_value) = widgets.get(widget_index) else {
break;
};
widget_index += 1;
if !is_modelish_field(&field, &input_type, widget_value) {
continue;
}
let Some(selected) = workflow_widget_string(widget_value) else {
continue;
};
if selected.trim().is_empty() {
continue;
}
if is_input_file_field(&field, &input_type, &selected) {
if check_files {
input_files.push(
validate_workflow_input_file(state, caller, node_id, &node_type, &selected)
.await,
);
} else {
input_files.push(json!({
"nodeId": node_id,
"nodeType": node_type,
"filename": selected,
"checked": false,
}));
}
continue;
}
let choices = combo_fields
.iter()
.find(|(name, _)| name == &field)
.map(|(_, choices)| choices.clone())
.or_else(|| {
combo_fields
.iter()
.find(|(name, _)| field_names_are_equivalent(name, &field))
.map(|(_, choices)| choices.clone())
});
match choices {
Some(choices) if choices.is_empty() => empty_model_fields.push(json!({
"nodeId": node_id,
"nodeType": node_type,
"field": field,
"selected": selected,
"severity": if connected { "error" } else { "warning" },
"message": "后端这个模型/LoRA字段返回空列表,当前选择无法确认可用。",
})),
Some(choices) if !choices.iter().any(|choice| choice == &selected) => {
let near = choices
.iter()
.filter(|choice| {
let a = choice.to_ascii_lowercase();
let b = selected.to_ascii_lowercase();
a.contains(&b) || b.contains(&a)
})
.take(8)
.cloned()
.collect::<Vec<_>>();
missing_model_choices.push(json!({
"nodeId": node_id,
"nodeType": node_type,
"field": field,
"selected": selected,
"near": near,
"availableCount": choices.len(),
"severity": if connected { "error" } else { "warning" },
}));
}
Some(_) => {}
None => {
if node_def.get("input").is_some() {
unknown_model_fields.push(json!({
"nodeId": node_id,
"nodeType": node_type,
"field": field,
"selected": selected,
"severity": "info",
}));
}
}
}
}
}
let errors = missing_node_types
.iter()
.chain(empty_model_fields.iter())
.chain(missing_model_choices.iter())
.filter(|item| {
item.get("severity")
.and_then(Value::as_str)
.is_some_and(|severity| severity == "error")
})
.count();
let warnings = missing_node_types
.iter()
.chain(empty_model_fields.iter())
.chain(missing_model_choices.iter())
.filter(|item| {
item.get("severity")
.and_then(Value::as_str)
.is_some_and(|severity| severity == "warning")
})
.count();
json!({
"success": true,
"compatible": errors == 0,
"summary": {
"nodes": nodes.len(),
"links": links.len(),
"nodeTypes": node_type_counts.len(),
"errors": errors,
"warnings": warnings,
},
"missingNodeTypes": missing_node_types,
"emptyModelFields": empty_model_fields,
"missingModelChoices": missing_model_choices,
"unknownModelFields": unknown_model_fields,
"inputFiles": input_files,
"hints": workflow_validation_hints(errors, warnings),
})
}
async fn validate_workflow_input_file(
state: &AppState,
caller: &WorkflowCaller,
node_id: i64,
node_type: &str,
filename: &str,
) -> Value {
if state.config.comfyui.uses_native_backend() {
let mut params = HashMap::new();
params.insert("filename".to_string(), filename.to_string());
params.insert("type".to_string(), "input".to_string());
let path = format!("/view{}", build_query_string(&params));
let result = native_workflow_request(state, Method::GET, &path, None)
.header(reqwest::header::RANGE, "bytes=0-0")
.send()
.await;
return match result {
Ok(response) => json!({
"nodeId": node_id,
"nodeType": node_type,
"filename": filename,
"checked": true,
"reachable": response.status().is_success(),
"status": response.status().as_u16(),
"contentType": response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or(""),
}),
Err(err) => json!({
"nodeId": node_id,
"nodeType": node_type,
"filename": filename,
"checked": true,
"reachable": false,
"error": err.to_string(),
}),
};
}
let user_id = caller_user_id(caller);
let path = user_home(user_id)
.join("userdata")
.join("input")
.join(safe_view_path(filename));
json!({
"nodeId": node_id,
"nodeType": node_type,
"filename": filename,
"checked": true,
"reachable": tokio::fs::metadata(&path).await.is_ok(),
"localPath": path,
})
}
+494
View File
@@ -0,0 +1,494 @@
use crate::workflow::executor::{WorkflowProgressCallback, WorkflowProgressEvent};
use crate::{error::AppResult, AppState};
use axum::{
extract::{
ws::{Message, WebSocketUpgrade},
Query, State,
},
http::HeaderMap,
response::Response,
};
use once_cell::sync::Lazy;
use serde_json::{json, Value};
use std::{
collections::HashMap,
sync::{mpsc, Arc},
};
use tokio::sync::broadcast;
use tokio_tungstenite::{connect_async, tungstenite};
use tungstenite::client::IntoClientRequest;
use super::workflow_node_label;
use super::{
build_query_string, native_workflow_base, native_workflow_enabled, node_output_for_comfy,
timestamp_ms,
};
use super::auth::auth_caller;
static WORKFLOW_WS_SESSIONS: Lazy<parking_lot::Mutex<HashMap<String, broadcast::Sender<Value>>>> =
Lazy::new(|| parking_lot::Mutex::new(HashMap::new()));
fn workflow_session_sender(session_id: &str) -> broadcast::Sender<Value> {
let mut sessions = WORKFLOW_WS_SESSIONS.lock();
if let Some(sender) = sessions.get(session_id) {
return sender.clone();
}
let (sender, _) = broadcast::channel(128);
sessions.insert(session_id.to_string(), sender.clone());
sender
}
fn publish_workflow_ws_event(session_id: Option<&str>, event_type: &str, data: Value) {
let Some(session_id) = session_id.map(str::trim).filter(|value| !value.is_empty()) else {
return;
};
let sender = workflow_session_sender(session_id);
let _ = sender.send(json!({
"type": event_type,
"data": data,
}));
}
pub(super) fn workflow_progress_callback_with_run_sender(
session_id: Option<String>,
run_sender: Option<mpsc::SyncSender<String>>,
) -> Option<WorkflowProgressCallback> {
let session_id = session_id.map(|value| value.trim().to_string())?;
if session_id.is_empty() {
return None;
}
let run_sender = Arc::new(parking_lot::Mutex::new(run_sender));
Some(Arc::new(move |event| match event {
WorkflowProgressEvent::Started { prompt_id, total } => {
if let Some(sender) = run_sender.lock().take() {
let _ = sender.send(prompt_id.clone());
}
publish_workflow_ws_event(
Some(&session_id),
"status",
json!({ "status": { "exec_info": { "queue_remaining": 1 } } }),
);
publish_workflow_ws_event(
Some(&session_id),
"execution_start",
json!({
"prompt_id": prompt_id,
"timestamp": timestamp_ms(),
}),
);
publish_workflow_ws_event(
Some(&session_id),
"progress",
json!({ "value": 0, "max": total.max(1) }),
);
}
WorkflowProgressEvent::NodeStarted {
prompt_id,
node_id,
node_type,
index,
total,
} => {
let label = workflow_node_label(&node_type, &node_id);
publish_workflow_ws_event(
Some(&session_id),
"executing",
json!({
"prompt_id": prompt_id,
"node": node_id,
"node_type": node_type,
"display_node": label,
"label": label,
}),
);
publish_workflow_ws_event(
Some(&session_id),
"progress",
json!({ "value": index, "max": total.max(1) }),
);
}
WorkflowProgressEvent::NodeCompleted {
prompt_id,
node_id,
node_type,
index,
total,
output,
} => {
let label = workflow_node_label(&node_type, &node_id);
let output = node_output_for_comfy(&output).unwrap_or(output);
publish_workflow_ws_event(
Some(&session_id),
"executed",
json!({
"prompt_id": prompt_id,
"node": node_id,
"node_type": node_type,
"display_node": label,
"label": label,
"output": output,
}),
);
publish_workflow_ws_event(
Some(&session_id),
"progress",
json!({ "value": index, "max": total.max(1) }),
);
}
WorkflowProgressEvent::NodeProgress {
prompt_id,
node_id,
node_type,
value,
max,
message,
} => {
let label = workflow_node_label(&node_type, &node_id);
publish_workflow_ws_event(
Some(&session_id),
"executing",
json!({
"prompt_id": prompt_id,
"node": node_id,
"node_type": node_type,
"display_node": label,
"label": label,
"message": message,
}),
);
publish_workflow_ws_event(
Some(&session_id),
"progress",
json!({ "value": value, "max": max.max(1), "message": message }),
);
}
WorkflowProgressEvent::Finished {
prompt_id,
success,
error,
} => {
publish_workflow_ws_event(
Some(&session_id),
"executing",
json!({
"prompt_id": prompt_id,
"node": Value::Null,
"display_node": Value::Null,
}),
);
publish_workflow_ws_event(
Some(&session_id),
"status",
json!({ "status": { "exec_info": { "queue_remaining": 0 } } }),
);
publish_workflow_ws_event(
Some(&session_id),
if success {
"execution_success"
} else {
"execution_error"
},
if success {
json!({ "prompt_id": prompt_id, "timestamp": timestamp_ms() })
} else {
json!({
"prompt_id": prompt_id,
"timestamp": timestamp_ms(),
"node_id": "workflow",
"node_type": "Workflow",
"exception_message": error.unwrap_or_else(|| "Workflow execution failed".to_string()),
"exception_type": "BoosAPIWorkflowError",
"traceback": [],
})
},
);
}
}))
}
// APPEND_MARKER_PUBLISH_RUN_EVENTS
pub(super) fn publish_run_events(
session_id: Option<&str>,
run: &crate::workflow::models::WorkflowRunSummary,
outputs: &Value,
) {
let Some(session_id) = session_id.map(str::trim).filter(|value| !value.is_empty()) else {
return;
};
publish_workflow_ws_event(
Some(session_id),
"execution_start",
json!({
"prompt_id": run.public_id,
"timestamp": timestamp_ms(),
}),
);
for node in &run.node_runs {
let label = workflow_node_label(&node.node_type, &node.node_id);
publish_workflow_ws_event(
Some(session_id),
"executing",
json!({
"prompt_id": run.public_id,
"node": node.node_id,
"node_type": node.node_type,
"display_node": label,
"label": label,
}),
);
if let Some(output) = outputs.get(&node.node_id) {
publish_workflow_ws_event(
Some(session_id),
"executed",
json!({
"prompt_id": run.public_id,
"node": node.node_id,
"node_type": node.node_type,
"display_node": label,
"label": label,
"output": output,
}),
);
}
}
publish_workflow_ws_event(
Some(session_id),
"executing",
json!({
"prompt_id": run.public_id,
"node": Value::Null,
"display_node": Value::Null,
}),
);
publish_workflow_ws_event(
Some(session_id),
if run.status == "failed" {
"execution_error"
} else {
"execution_success"
},
if run.status == "failed" {
json!({
"prompt_id": run.public_id,
"timestamp": timestamp_ms(),
"node_id": run.node_runs.last().map(|node| node.node_id.as_str()).unwrap_or("workflow"),
"node_type": run.node_runs.last().map(|node| node.node_type.as_str()).unwrap_or("Workflow"),
"exception_message": run.error_message.clone().unwrap_or_else(|| "Workflow execution failed".to_string()),
"exception_type": "BoosAPIWorkflowError",
"traceback": [],
})
} else {
json!({
"prompt_id": run.public_id,
"timestamp": timestamp_ms(),
})
},
);
}
// APPEND_MARKER_WS_HANDLERS
pub(super) async fn workflow_ws(
State(state): State<AppState>,
headers: HeaderMap,
ws: WebSocketUpgrade,
Query(query): Query<HashMap<String, String>>,
) -> AppResult<Response> {
let _ = auth_caller(&state, &headers).await?;
let sid = query
.get("clientId")
.or_else(|| query.get("client_id"))
.cloned()
.unwrap_or_else(|| "boosapi-workflow".to_string());
if native_workflow_enabled(&state) && !state.config.comfyui.uses_runninghub_backend() {
return Ok(
ws.on_upgrade(move |socket| native_workflow_ws_bridge(state, socket, query, sid))
);
}
Ok(ws.on_upgrade(move |socket| async move {
use tokio::time::{interval, Duration};
let mut socket = socket;
let mut receiver = workflow_session_sender(&sid).subscribe();
let mut ticker = interval(Duration::from_secs(20));
let _ = socket
.send(Message::Text(
json!({
"type": "status",
"data": {
"status": { "exec_info": { "queue_remaining": 0 } },
"sid": sid
}
})
.to_string()
.into(),
))
.await;
loop {
tokio::select! {
event = receiver.recv() => {
match event {
Ok(event) => {
if socket.send(Message::Text(event.to_string().into())).await.is_err() {
break;
}
}
Err(broadcast::error::RecvError::Lagged(_)) => {}
Err(broadcast::error::RecvError::Closed) => break,
}
}
_ = ticker.tick() => {
if socket.send(Message::Text(json!({"type":"status","data":{"status":{"exec_info":{"queue_remaining":0}},"sid":sid}}).to_string().into())).await.is_err() {
break;
}
}
next = socket.recv() => {
match next {
Some(Ok(Message::Close(_))) | None => break,
Some(Ok(_)) => {}
Some(Err(_)) => break,
}
}
}
}
}))
}
// APPEND_MARKER_BRIDGE
async fn native_workflow_ws_bridge(
state: AppState,
mut client_socket: axum::extract::ws::WebSocket,
mut query: HashMap<String, String>,
sid: String,
) {
let mut ws_url = native_workflow_base(&state)
.replacen("https://", "wss://", 1)
.replacen("http://", "ws://", 1);
query.entry("clientId".to_string()).or_insert(sid.clone());
if let Some(api_key) = state
.config
.comfyui
.api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
query
.entry("token".to_string())
.or_insert_with(|| api_key.to_string());
}
ws_url.push_str("/ws");
ws_url.push_str(&build_query_string(&query));
let request = match ws_url.as_str().into_client_request() {
Ok(mut request) => {
if let Some(api_key) = state
.config
.comfyui
.api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
if let Ok(value) = tungstenite::http::HeaderValue::from_str(api_key) {
request.headers_mut().insert("X-API-Key", value);
}
if let Ok(value) =
tungstenite::http::HeaderValue::from_str(&format!("Bearer {api_key}"))
{
request
.headers_mut()
.insert(tungstenite::http::header::AUTHORIZATION, value);
}
}
request
}
Err(err) => {
let _ = client_socket
.send(Message::Text(
json!({"type":"execution_error","data":{"exception_message":format!("invalid ComfyUI websocket URL: {err}")}})
.to_string()
.into(),
))
.await;
return;
}
};
let upstream_target = ws_url
.split('?')
.next()
.unwrap_or(ws_url.as_str())
.to_string();
let (upstream_socket, _) = match connect_async(request).await {
Ok(value) => value,
Err(err) => {
tracing::warn!(target = %upstream_target, error = %err, "ComfyUI native websocket connect failed");
let _ = client_socket
.send(Message::Text(
json!({"type":"execution_error","data":{"exception_message":format!("ComfyUI websocket connect failed: {err}")}})
.to_string()
.into(),
))
.await;
return;
}
};
use futures::{SinkExt, StreamExt};
let (mut upstream_write, mut upstream_read) = upstream_socket.split();
loop {
tokio::select! {
from_upstream = upstream_read.next() => {
match from_upstream {
Some(Ok(message)) => {
let outgoing = match message {
tungstenite::Message::Text(text) => Message::Text(text.to_string().into()),
tungstenite::Message::Binary(bytes) => Message::Binary(bytes.to_vec().into()),
tungstenite::Message::Ping(bytes) => Message::Ping(bytes.to_vec().into()),
tungstenite::Message::Pong(bytes) => Message::Pong(bytes.to_vec().into()),
tungstenite::Message::Close(frame) => {
let _ = client_socket.send(Message::Close(frame.map(|frame| axum::extract::ws::CloseFrame {
code: frame.code.into(),
reason: frame.reason.to_string().into(),
}))).await;
break;
}
tungstenite::Message::Frame(_) => continue,
};
if client_socket.send(outgoing).await.is_err() {
break;
}
}
Some(Err(_)) | None => break,
}
}
from_client = client_socket.recv() => {
match from_client {
Some(Ok(message)) => {
let outgoing = match message {
Message::Text(text) => tungstenite::Message::Text(text.to_string().into()),
Message::Binary(bytes) => tungstenite::Message::Binary(bytes.to_vec().into()),
Message::Ping(bytes) => tungstenite::Message::Ping(bytes.to_vec().into()),
Message::Pong(bytes) => tungstenite::Message::Pong(bytes.to_vec().into()),
Message::Close(frame) => {
let _ = upstream_write.send(tungstenite::Message::Close(frame.map(|frame| tungstenite::protocol::CloseFrame {
code: frame.code.into(),
reason: frame.reason.to_string().into(),
}))).await;
break;
}
};
if upstream_write.send(outgoing).await.is_err() {
break;
}
}
Some(Err(_)) | None => break,
}
}
}
}
}
+38 -36
View File
@@ -218,25 +218,25 @@ async fn list_assets(
LIMIT ? OFFSET ?"
);
let items = sqlx::query(&list_sql)
.bind(is_admin)
.bind(scoped_user_id)
.bind(scoped_user_id)
.bind(is_admin)
.bind(scoped_user_id)
.bind(&source)
.bind(&source)
.bind(&kind)
.bind(&kind)
.bind(&q)
.bind(&q)
.bind(&q)
.bind(&q)
.bind(&q)
.bind(page_size)
.bind(offset)
.map(row_to_json)
.fetch_all(&*state.db)
.await?;
.bind(is_admin)
.bind(scoped_user_id)
.bind(scoped_user_id)
.bind(is_admin)
.bind(scoped_user_id)
.bind(&source)
.bind(&source)
.bind(&kind)
.bind(&kind)
.bind(&q)
.bind(&q)
.bind(&q)
.bind(&q)
.bind(&q)
.bind(page_size)
.bind(offset)
.map(row_to_json)
.fetch_all(&*state.db)
.await?;
let count_sql = format!(
"SELECT COUNT(*)
@@ -255,22 +255,22 @@ async fn list_assets(
)"
);
let total: i64 = sqlx::query_scalar(&count_sql)
.bind(is_admin)
.bind(scoped_user_id)
.bind(scoped_user_id)
.bind(is_admin)
.bind(scoped_user_id)
.bind(&source)
.bind(&source)
.bind(&kind)
.bind(&kind)
.bind(&q)
.bind(&q)
.bind(&q)
.bind(&q)
.bind(&q)
.fetch_one(&*state.db)
.await?;
.bind(is_admin)
.bind(scoped_user_id)
.bind(scoped_user_id)
.bind(is_admin)
.bind(scoped_user_id)
.bind(&source)
.bind(&source)
.bind(&kind)
.bind(&kind)
.bind(&q)
.bind(&q)
.bind(&q)
.bind(&q)
.bind(&q)
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({
"success": true,
@@ -398,7 +398,9 @@ async fn delete_assets_batch(
.execute(&*state.db)
.await?;
if result.rows_affected() > 0 {
if let Err(err) = crate::services::workspace::project_deleted_asset(&state, &public_id).await {
if let Err(err) =
crate::services::workspace::project_deleted_asset(&state, &public_id).await
{
tracing::warn!(%err, %public_id, "failed to update workspace asset batch delete projection");
}
deleted_ids.push(public_id);
+3
View File
@@ -0,0 +1,3 @@
//! Comfy Cloud 工作流执行适配器 service 层。
pub mod site;
pub mod translate;
+53
View File
@@ -0,0 +1,53 @@
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(_))));
}
}
+55
View File
@@ -0,0 +1,55 @@
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);
}
}
+2
View File
@@ -13,3 +13,5 @@ pub mod runtime_auth;
pub mod runtime_logs;
pub mod shared;
pub mod workspace;
pub mod comfy_cloud;
pub mod pixelle_connection;
+4 -4
View File
@@ -113,9 +113,7 @@ impl NotificationService {
let config = self.load_config().await?;
let channels = self.enabled_channels(&config);
if channels.is_empty() {
return Err(AppError::BadRequest(
"请至少配置并启用一个通知通道".into(),
));
return Err(AppError::BadRequest("请至少配置并启用一个通知通道".into()));
}
let notif = Notification {
@@ -466,7 +464,9 @@ fn setting(map: &HashMap<String, String>, key: &str) -> Option<String> {
}
fn setting_bool(map: &HashMap<String, String>, key: &str, default: bool) -> bool {
map.get(key).map(|value| parse_bool(value)).unwrap_or(default)
map.get(key)
.map(|value| parse_bool(value))
.unwrap_or(default)
}
fn parse_bool(value: &str) -> bool {
+116
View File
@@ -0,0 +1,116 @@
use crate::error::{AppError, AppResult};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PixelleConnection {
pub runninghub_api_key: Option<String>,
pub comfyui_url: Option<String>,
pub comfyui_api_key: Option<String>,
}
const KEY_RH: &str = "pixelle.runninghub_api_key";
const KEY_URL: &str = "pixelle.comfyui_url";
const KEY_CK: &str = "pixelle.comfyui_api_key";
pub async fn read_connection(db: &SqlitePool) -> AppResult<PixelleConnection> {
async fn get(db: &SqlitePool, k: &str) -> AppResult<Option<String>> {
Ok(sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
.bind(k).fetch_optional(db).await?)
}
Ok(PixelleConnection {
runninghub_api_key: get(db, KEY_RH).await?,
comfyui_url: get(db, KEY_URL).await?,
comfyui_api_key: get(db, KEY_CK).await?,
})
}
pub async fn save_connection(db: &SqlitePool, conn: &PixelleConnection) -> AppResult<()> {
async fn put(db: &SqlitePool, k: &str, v: &Option<String>) -> AppResult<()> {
if let Some(val) = v {
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.bind(k).bind(val).execute(db).await?;
}
Ok(())
}
put(db, KEY_RH, &conn.runninghub_api_key).await?;
put(db, KEY_URL, &conn.comfyui_url).await?;
put(db, KEY_CK, &conn.comfyui_api_key).await?;
Ok(())
}
/// 改 config.yaml 的 comfyui.{runninghub_api_key,comfyui_url,comfyui_api_key} 三键,其余保留。
/// 改前备份到 <path>.bak-<ts>。返回备份路径。
pub fn sync_to_pixelle(config_path: &str, conn: &PixelleConnection) -> AppResult<String> {
let content = std::fs::read_to_string(config_path)
.map_err(|e| AppError::ServiceUnavailable(format!("读取 Pixelle config 失败: {e}")))?;
let mut doc: serde_yaml::Value = serde_yaml::from_str(&content)
.map_err(|e| AppError::ServiceUnavailable(format!("解析 config.yaml 失败: {e}")))?;
let comfyui = doc.get_mut("comfyui")
.ok_or_else(|| AppError::ServiceUnavailable("config.yaml 缺少 comfyui 段".into()))?;
let map = comfyui.as_mapping_mut()
.ok_or_else(|| AppError::ServiceUnavailable("comfyui 段不是映射".into()))?;
if let Some(v) = &conn.runninghub_api_key {
map.insert("runninghub_api_key".into(), serde_yaml::Value::String(v.clone()));
}
if let Some(v) = &conn.comfyui_url {
map.insert("comfyui_url".into(), serde_yaml::Value::String(v.clone()));
}
if let Some(v) = &conn.comfyui_api_key {
map.insert("comfyui_api_key".into(), serde_yaml::Value::String(v.clone()));
}
let ts = chrono::Utc::now().format("%Y%m%d%H%M%S").to_string();
let backup = format!("{config_path}.bak-{ts}");
std::fs::copy(config_path, &backup)
.map_err(|e| AppError::ServiceUnavailable(format!("备份失败: {e}")))?;
let out = serde_yaml::to_string(&doc)
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
std::fs::write(config_path, out)
.map_err(|e| AppError::ServiceUnavailable(format!("写入 config 失败: {e}")))?;
Ok(backup)
}
pub async fn restart_pixelle(compose_dir: &str) -> AppResult<()> {
let output = tokio::process::Command::new("docker")
.args(["compose", "restart", "api"])
.current_dir(compose_dir)
.output()
.await
.map_err(|e| AppError::ServiceUnavailable(format!("docker compose restart 启动失败: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(AppError::ServiceUnavailable(format!(
"Pixelle 重启失败: {}", stderr.trim()
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sync_updates_three_keys_preserves_rest() {
let dir = std::env::temp_dir().join(format!("pxtest_{}", uuid::Uuid::new_v4().simple()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.yaml");
std::fs::write(&path,
"project_name: Pixelle-Video\ncomfyui:\n comfyui_url: https://old\n runninghub_api_key: oldkey\n comfyui_api_key: oldck\n runninghub_concurrent_limit: 1\n").unwrap();
let conn = PixelleConnection {
runninghub_api_key: Some("newkey".into()),
comfyui_url: Some("http://new".into()),
comfyui_api_key: Some("newck".into()),
};
let backup = sync_to_pixelle(path.to_str().unwrap(), &conn).unwrap();
assert!(std::path::Path::new(&backup).exists());
let after = std::fs::read_to_string(&path).unwrap();
assert!(after.contains("newkey"));
assert!(after.contains("http://new"));
assert!(after.contains("newck"));
assert!(after.contains("runninghub_concurrent_limit"));
assert!(after.contains("project_name"));
std::fs::remove_dir_all(&dir).ok();
}
}
+5 -6
View File
@@ -1,5 +1,5 @@
use sqlx::SqlitePool;
use parking_lot::RwLock;
use sqlx::SqlitePool;
pub const PROXY_TOKEN_SETTING_KEY: &str = "runtime.auth.proxy_token";
@@ -20,11 +20,10 @@ impl RuntimeAuthSettings {
}
pub async fn load(pool: &SqlitePool, default_proxy_token: String) -> anyhow::Result<Self> {
let stored: Option<String> =
sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
.bind(PROXY_TOKEN_SETTING_KEY)
.fetch_optional(pool)
.await?;
let stored: Option<String> = sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
.bind(PROXY_TOKEN_SETTING_KEY)
.fetch_optional(pool)
.await?;
let proxy_token = stored
.as_deref()
.map(str::trim)
+7 -2
View File
@@ -188,7 +188,10 @@ async fn insert_asset(
fn should_project_workspace_asset(status: &str, metadata: &Value) -> bool {
status == "completed"
&& (metadata.pointer("/workspaceAsset/role").and_then(Value::as_str) == Some("output")
&& (metadata
.pointer("/workspaceAsset/role")
.and_then(Value::as_str)
== Some("output")
|| metadata.pointer("/workflowUi/type").and_then(Value::as_str) == Some("output")
|| metadata.get("responseItem").is_some()
|| metadata.get("moneyPrinter").is_some()
@@ -398,7 +401,9 @@ pub async fn project_deleted_asset(state: &AppState, public_id: &str) -> Result<
&source,
&kind,
size_bytes,
file_path.as_deref().is_some_and(|value| !value.trim().is_empty()),
file_path
.as_deref()
.is_some_and(|value| !value.trim().is_empty()),
-1,
)
.await
+2 -2
View File
@@ -1,7 +1,7 @@
export interface SiteInitializationPreset {
id: string;
label: string;
platform: 'openai' | 'claude';
platform: 'openai' | 'claude' | 'comfy_cloud';
defaultUrl: string;
initialSegment: 'session' | 'apikey';
description?: string;
@@ -12,5 +12,5 @@ export declare function getSiteInitializationPreset(id?: string | null): SiteIni
export declare function listSiteInitializationPresets(): SiteInitializationPreset[];
export declare function detectSiteInitializationPreset(url: string, knownPlatform?: string): {
id: string;
platform: 'openai' | 'claude';
platform: 'openai' | 'claude' | 'comfy_cloud';
} | null;
+11
View File
@@ -1,4 +1,14 @@
const presets = [
{
id: 'comfy-cloud',
label: 'Comfy Cloud (工作流执行)',
platform: 'comfy_cloud',
defaultUrl: 'https://cloud.comfy.org',
initialSegment: 'apikey',
description: 'Comfy Cloud 工作流执行 API;API Key 填 X-API-Key,经 BoosAPI /comfy-cloud 适配端点供 Pixelle 等调用。',
recommendedSkipModelFetch: true,
recommendedModels: [],
},
{
id: 'codingplan-openai',
label: '阿里云 Coding Plan (OpenAI)',
@@ -145,6 +155,7 @@ const urlMatchers = [
{ urlPrefix: 'https://api-inference.modelscope.cn', id: 'modelscope-claude', platform: 'claude' },
{ urlPrefix: 'https://ark.cn-beijing.volces.com/api/coding/v3', id: 'doubao-coding-openai', platform: 'openai' },
{ urlPrefix: 'https://ark.cn-beijing.volces.com/api/v3', id: 'volcengine-ark-openai', platform: 'openai', stripTrailingSlash: true },
{ urlPrefix: 'https://cloud.comfy.org', id: 'comfy-cloud', platform: 'comfy_cloud', stripTrailingSlash: true },
];
// Canonical root → preset mapping for reselection when platform is known
const rootReselect = [
+14 -3
View File
@@ -1,7 +1,7 @@
export interface SiteInitializationPreset {
id: string;
label: string;
platform: 'openai' | 'claude';
platform: 'openai' | 'claude' | 'comfy_cloud';
defaultUrl: string;
initialSegment: 'session' | 'apikey';
description?: string;
@@ -10,6 +10,16 @@ export interface SiteInitializationPreset {
}
const presets: SiteInitializationPreset[] = [
{
id: 'comfy-cloud',
label: 'Comfy Cloud (工作流执行)',
platform: 'comfy_cloud',
defaultUrl: 'https://cloud.comfy.org',
initialSegment: 'apikey',
description: 'Comfy Cloud 工作流执行 API;API Key 填 X-API-Key,经 BoosAPI /comfy-cloud 适配端点供 Pixelle 等调用。',
recommendedSkipModelFetch: true,
recommendedModels: [],
},
{
id: 'codingplan-openai',
label: '阿里云 Coding Plan (OpenAI)',
@@ -146,7 +156,7 @@ const presets: SiteInitializationPreset[] = [
const urlMatchers: Array<{
urlPrefix: string;
id: string;
platform: 'openai' | 'claude';
platform: 'openai' | 'claude' | 'comfy_cloud';
stripTrailingSlash?: boolean;
}> = [
{ urlPrefix: 'https://coding.dashscope.aliyuncs.com/v1', id: 'codingplan-openai', platform: 'openai' },
@@ -162,6 +172,7 @@ const urlMatchers: Array<{
{ urlPrefix: 'https://api-inference.modelscope.cn', id: 'modelscope-claude', platform: 'claude' },
{ urlPrefix: 'https://ark.cn-beijing.volces.com/api/coding/v3', id: 'doubao-coding-openai', platform: 'openai' },
{ urlPrefix: 'https://ark.cn-beijing.volces.com/api/v3', id: 'volcengine-ark-openai', platform: 'openai', stripTrailingSlash: true },
{ urlPrefix: 'https://cloud.comfy.org', id: 'comfy-cloud', platform: 'comfy_cloud', stripTrailingSlash: true },
];
// Canonical root → preset mapping for reselection when platform is known
@@ -183,7 +194,7 @@ export function listSiteInitializationPresets(): SiteInitializationPreset[] {
export function detectSiteInitializationPreset(
url: string,
knownPlatform?: string,
): { id: string; platform: 'openai' | 'claude' } | null {
): { id: string; platform: 'openai' | 'claude' | 'comfy_cloud' } | null {
// Try exact URL prefix matching
const normalizedUrl = url.replace(/\/+$/, '');
for (const matcher of urlMatchers) {
+5 -1
View File
@@ -24,7 +24,11 @@ fn template_proxy_url(config: &AppConfig) -> Option<String> {
.as_deref()
.and_then(non_empty)
.map(str::to_string)
.or_else(|| std::env::var("BOOSAPI_TEMPLATE_PROXY_URL").ok().and_then(|value| non_empty_owned(value)))
.or_else(|| {
std::env::var("BOOSAPI_TEMPLATE_PROXY_URL")
.ok()
.and_then(|value| non_empty_owned(value))
})
}
fn non_empty(value: &str) -> Option<&str> {
+10 -13
View File
@@ -250,10 +250,9 @@ describe('Login surface', () => {
}
});
it('rejects admin API Key login on the user login page', async () => {
it('promotes admin-role API Key login into both audiences and navigates to /dashboard', async () => {
const storage = createMemoryStorage();
vi.stubGlobal('localStorage', storage);
commitSession('user', 'stale-user-jwt', undefined, storage);
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
@@ -298,17 +297,16 @@ describe('Login surface', () => {
method: 'POST',
body: JSON.stringify({ apiKey: 'sk-admin-user-key' }),
}));
expect(getSessionToken('admin', storage)).toBeNull();
expect(getSessionToken('user', storage)).toBe('stale-user-jwt');
expect(onLogin).not.toHaveBeenCalled();
expect(collectText(root.root)).toContain('管理员请使用管理员 API Token 登录。');
expect(paths.at(-1)).toBe('/user/login');
expect(getSessionToken('admin', storage)).toBe('jwt-admin-from-key');
expect(getSessionToken('user', storage)).toBe('jwt-admin-from-key');
expect(onLogin).toHaveBeenCalledWith('admin');
expect(paths.at(-1)).toBe('/dashboard');
} finally {
root?.unmount();
}
});
it('rejects API Key login when JWT role falls back to admin', async () => {
it('promotes admin role from JWT fallback when API Key response omits user.role', async () => {
const storage = createMemoryStorage();
vi.stubGlobal('localStorage', storage);
const adminJwt = unsignedJwt({ sub: '5', role: 'admin', exp: 9_999_999_999 });
@@ -355,11 +353,10 @@ describe('Login surface', () => {
});
});
expect(getSessionToken('admin', storage)).toBeNull();
expect(getSessionToken('user', storage)).toBeNull();
expect(onLogin).not.toHaveBeenCalled();
expect(collectText(root.root)).toContain('管理员请使用管理员 API Token 登录。');
expect(paths.at(-1)).toBe('/user/login');
expect(getSessionToken('admin', storage)).toBe(adminJwt);
expect(getSessionToken('user', storage)).toBe(adminJwt);
expect(onLogin).toHaveBeenCalledWith('admin');
expect(paths.at(-1)).toBe('/dashboard');
} finally {
root?.unmount();
}
+15
View File
@@ -200,4 +200,19 @@ describe('App sidebar config', () => {
expect(appSource).toContain("{ to: '/user/recharge', label: '账户充值'");
expect(appSource).toContain('<Route path="/user/recharge" element={<UserRecharge />} />');
});
it('adds admin payment management navigation and route without fallback static checkout links', () => {
const appSource = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
const paymentSource = readFileSync(resolve(process.cwd(), 'src/web/pages/PaymentManagement.tsx'), 'utf8');
const opsGroupSource = appSource.slice(
appSource.indexOf("label: '运营管理'"),
appSource.indexOf("label: '接入管理'"),
);
expect(appSource).toContain("const PaymentManagement = lazy(() => import('./pages/PaymentManagement.js'));");
expect(appSource).toContain('payment: <svg className="sidebar-item-icon"');
expect(opsGroupSource).toContain("{ to: '/payment', label: '支付管理', icon: navIcons.payment }");
expect(appSource).toContain('<Route path="/payment" element={<PaymentManagement />} />');
expect(paymentSource).not.toContain('fallbackStaticUrlsSupported');
});
});
+9 -4
View File
@@ -51,6 +51,8 @@ const OpsMonitor = lazy(() => import('./pages/OpsMonitor.js'));
const IpManagement = lazy(() => import('./pages/IpManagement.js'));
const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));
const SiteAnnouncements = lazy(() => import('./pages/SiteAnnouncements.js'));
const PaymentManagement = lazy(() => import('./pages/PaymentManagement.js'));
const UserBalances = lazy(() => import('./pages/UserBalances.js'));
const UserRegister = lazy(() => import('./pages/UserRegister.js'));
const UserDashboard = lazy(() => import('./pages/UserDashboard.js'));
const UsageRecords = lazy(() => import('./pages/UsageRecords.js'));
@@ -565,6 +567,7 @@ const navIcons = {
ops: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 19V9m4 10V5m4 14v-7m4 7V8m4 11V4M3 19h18" /></svg>,
users: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" /></svg>,
announcement: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 8h10M7 12h10M7 16h6M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg>,
payment: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M3 8h18M5 5h14a2 2 0 012 2v10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2zm3 9h4" /></svg>,
channels: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 7h16M4 12h10m-10 5h16M17 9l3 3-3 3" /></svg>,
sites: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>,
accounts: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg>,
@@ -599,6 +602,8 @@ export const sidebarGroups: SidebarGroup[] = [
items: [
{ to: '/users', label: '用户管理', icon: navIcons.users },
{ to: '/site-announcements', label: '用户公告', icon: navIcons.announcement },
{ to: '/payment', label: '支付管理', icon: navIcons.payment },
{ to: '/user-balances', label: '用户余额', icon: navIcons.payment },
],
},
{
@@ -618,8 +623,7 @@ export const sidebarGroups: SidebarGroup[] = [
label: '创作应用',
items: [
{ to: '/picture-agent', label: '生图助手', icon: navIcons.image },
{ to: '/money-printer', label: '短视频工厂', icon: navIcons.video },
{ to: '/digital-human', label: '数字人', icon: navIcons.digitalHuman },
{ to: '/digital-human', label: '短视频工厂/数字人', icon: navIcons.digitalHuman },
{ to: '/workflows', label: '工作流', icon: navIcons.workflow },
{ to: '/comfyui-agent', label: '视频助手', icon: navIcons.assistant },
{ to: '/workspace', label: '工作空间', icon: navIcons.workspace },
@@ -651,8 +655,7 @@ export const userSidebarGroups = [
label: '创作应用',
items: [
{ to: '/user/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
{ to: '/user/money-printer', label: '短视频工厂', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 4h10a2 2 0 012 2v12a2 2 0 01-2 2H7a2 2 0 01-2-2V6a2 2 0 012-2z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M10 9l5 3-5 3V9zM8 20l2-4m6 4l-2-4" /></svg> },
{ to: '/user/digital-human', label: '数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> },
{ to: '/user/digital-human', label: '短视频工厂/数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> },
{ to: '/user/workflows', label: '工作流', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg> },
{ to: '/user/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
{ to: '/user/workspace', label: '工作空间', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 7a2 2 0 012-2h4l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H6a2 2 0 01-2-2V7z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 13h8M8 16h5" /></svg> },
@@ -1667,6 +1670,8 @@ function AppShell() {
<Route path="/workspace" element={<Workspace />} />
<Route path="/about" element={<About />} />
<Route path="/users" element={<UserManagement />} />
<Route path="/payment" element={<PaymentManagement />} />
<Route path="/user-balances" element={<UserBalances />} />
{/* User specific pages */}
<Route
+87 -5
View File
@@ -52,7 +52,7 @@ function sanitizeErrorText(status: number, text: string): string {
|| trimmed.includes("</html>")
|| trimmed.includes("<!--[if");
if (looksLikeHtml) {
return `HTTP ${status}: 上游返回 HTML 错误页,请检查支付/上游 Token 配置`;
return `HTTP ${status}: 上游返回 HTML 错误页,请检查对应上游服务配置`;
}
return `HTTP ${status}: ${trimmed.slice(0, 120)}`;
}
@@ -1476,6 +1476,7 @@ export type DigitalHumanJob = {
progress: number;
progressTotal: number;
result?: any;
reusePayload?: any;
createdAt?: string | null;
updatedAt?: string | null;
startedAt?: string | null;
@@ -2017,13 +2018,45 @@ export const api = {
"/api/digital-human/pixelle/bgm",
{ preserveAuthErrors: true },
),
uploadAsset: (file: File, purpose = "reference_audio") => {
const formData = new FormData();
formData.set("file", file);
formData.set("purpose", purpose);
return request<{
success: boolean;
asset: {
publicId: string;
kind: string;
status: string;
fileUrl: string;
pixellePath?: string;
workspaceFileUrl?: string;
};
}>(
"/api/digital-human/upload",
{
method: "POST",
body: formData,
preserveAuthErrors: true,
timeoutMs: 120_000,
},
);
},
getTemplateParams: (template: string) =>
request<{ template: string; media_width?: number; media_height?: number; params?: any }>(
`/api/digital-human/pixelle/template-params?template=${encodeURIComponent(template)}`,
{ preserveAuthErrors: true },
),
previewImage: (data: { prompt: string; width?: number; height?: number; workflow?: string }) =>
request<{ image_path: string }>(
previewImage: (data: {
prompt: string;
width?: number;
height?: number;
workflow?: string;
workflow_key?: string;
media_workflow?: string;
backend_provider?: string;
}) =>
request<any>(
"/api/digital-human/pixelle/preview-image",
{
method: "POST",
@@ -2033,7 +2066,7 @@ export const api = {
},
),
previewTts: (data: { text: string; workflow?: string; ref_audio?: string }) =>
request<{ audio_path: string; duration?: number }>(
request<any>(
"/api/digital-human/pixelle/preview-tts",
{
method: "POST",
@@ -2069,7 +2102,22 @@ export const api = {
{ preserveAuthErrors: true },
),
fileUrl: (path: string) => {
const trimmed = String(path || "").replace(/^\/+/, "");
if (/^(blob:|data:)/i.test(path) || path.startsWith("/api/")) return path;
let trimmed = String(path || "").trim();
if (/^https?:/i.test(trimmed)) {
try {
const url = new URL(trimmed);
if ((url.hostname === "127.0.0.1" || url.hostname === "localhost") && url.pathname.startsWith("/api/files/")) {
trimmed = url.pathname.replace(/^\/api\/files\/?/, "");
} else {
return trimmed;
}
} catch {
return trimmed;
}
}
trimmed = trimmed.replace(/^\/+/, "");
trimmed = trimmed.replace(/^api\/files\/?/, "");
const segments = trimmed.split("/").map((segment) => encodeURIComponent(segment));
return `/api/digital-human/pixelle/files/${segments.join("/")}`;
},
@@ -2858,6 +2906,8 @@ export const api = {
request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}`),
cancelDigitalHumanJob: (id: string) =>
request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
retryDigitalHumanJob: (id: string) =>
request<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/retry`, { method: "POST" }),
clearDigitalHumanJobs: () =>
request<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }),
getMoneyPrinterStatus: () =>
@@ -2876,6 +2926,13 @@ export const api = {
request<MoneyPrinterJobResponse>(`/api/money-printer/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
clearMoneyPrinterJobs: () =>
request<{ success: boolean; cleared: number }>("/api/money-printer/jobs/clear", { method: "POST" }),
getPixelleConnection: () =>
request<{ success: boolean; connection: { comfyuiUrl?: string | null; runninghubApiKey?: string | null; comfyuiApiKey?: string | null } }>("/api/admin/pixelle/connection"),
savePixelleConnection: (data: { runninghubApiKey?: string; comfyuiUrl?: string; comfyuiApiKey?: string }) =>
request<{ success: boolean; backup?: string; restarted?: boolean; warning?: string }>("/api/admin/pixelle/connection", {
method: "PUT",
body: JSON.stringify(data),
}),
};
export const userApi = {
register: (data: {
@@ -2977,6 +3034,29 @@ export const userApi = {
userRequest<{ success: boolean; run: WorkflowRunSummary }>(`/api/user/workflow-runs/${encodeURIComponent(publicId)}`),
getDigitalHumanStatus: () =>
userRequest<DigitalHumanStatusResponse>("/api/digital-human/status"),
uploadDigitalHumanAsset: (file: File, purpose = "reference_audio") => {
const formData = new FormData();
formData.set("file", file);
formData.set("purpose", purpose);
return userRequest<{
success: boolean;
asset: {
publicId: string;
kind: string;
status: string;
fileUrl: string;
pixellePath?: string;
workspaceFileUrl?: string;
};
}>(
"/api/digital-human/upload",
{
method: "POST",
body: formData,
timeoutMs: 120_000,
},
);
},
createDigitalHumanJob: (data: any) =>
userRequest<DigitalHumanJobResponse>("/api/digital-human/jobs", {
method: "POST",
@@ -2989,6 +3069,8 @@ export const userApi = {
userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}`),
cancelDigitalHumanJob: (id: string) =>
userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
retryDigitalHumanJob: (id: string) =>
userRequest<DigitalHumanJobResponse>(`/api/digital-human/jobs/${encodeURIComponent(id)}/retry`, { method: "POST" }),
clearDigitalHumanJobs: () =>
userRequest<{ success: boolean; cleared: number }>("/api/digital-human/jobs/clear", { method: "POST" }),
getMoneyPrinterStatus: () =>
@@ -1,5 +1,6 @@
import {
USER_RECHARGE_CONFIG,
formatRechargePoints,
isUserRechargeOptionPayable,
type UserRechargeOption,
type UserRechargeCheckoutOption,
@@ -47,8 +48,10 @@ export default function UserRechargeOptions({
title={option.unavailableReason || '该额度支付链接未配置'}
disabled
>
{option.recommended ? <small>推荐</small> : null}
<strong>{option.label}</strong>
<span>{option.title}</span>
{option.amountUsd ? <b>{formatRechargePoints(option.amountUsd)}</b> : null}
<em>待配置</em>
</button>
);
@@ -62,8 +65,10 @@ export default function UserRechargeOptions({
className={className}
aria-label={`前往系统内充值页确认 ${option.label}`}
>
{option.recommended ? <small>推荐</small> : null}
<strong>{option.label}</strong>
<span>{option.title}</span>
{option.amountUsd ? <b>{formatRechargePoints(option.amountUsd)}</b> : null}
</a>
);
}
@@ -79,8 +84,10 @@ export default function UserRechargeOptions({
disabled={pending}
data-checkout-option-id={option.id}
>
{option.recommended ? <small>推荐</small> : null}
<strong>{option.label}</strong>
<span>{option.title}</span>
{option.amountUsd ? <b>{formatRechargePoints(option.amountUsd)}</b> : null}
{pending ? <em>创建记录中</em> : null}
</button>
);
+310 -48
View File
@@ -2439,7 +2439,7 @@ body {
.program-logs-table {
table-layout: fixed;
min-width: 1120px;
min-width: 1420px;
}
.program-logs-filter-bar {
@@ -2474,8 +2474,21 @@ body {
color: var(--color-text-secondary);
}
.program-logs-table td:nth-child(6),
.program-logs-table td:nth-child(7) {
.program-logs-table .program-logs-account-cell,
.program-logs-table .program-logs-ip-cell {
overflow-wrap: anywhere;
word-break: normal;
line-height: 1.5;
}
.program-logs-table .program-logs-ip-cell {
font-family: var(--font-mono);
font-size: 12px;
color: var(--color-text-secondary);
}
.program-logs-table td:nth-child(8),
.program-logs-table td:nth-child(9) {
white-space: nowrap;
}
@@ -4418,6 +4431,11 @@ textarea {
box-shadow: var(--shadow-sm);
}
.picture-agent-tabs + [role="tabpanel"] {
display: grid;
gap: 14px;
}
.picture-agent-field {
display: flex;
flex-direction: column;
@@ -4482,22 +4500,24 @@ textarea {
.picture-agent-grid-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
gap: 12px;
}
.picture-agent-size-row {
display: grid;
grid-template-columns: 1fr 1fr minmax(92px, 0.8fr);
gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
align-items: end;
}
.picture-agent-size-preview {
min-height: 38px;
display: grid;
align-content: center;
gap: 2px;
padding: 6px 10px;
grid-column: 1 / -1;
min-height: 46px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-info) 7%, var(--color-bg-card));
@@ -4511,21 +4531,21 @@ textarea {
.picture-agent-size-preview strong {
color: var(--color-info);
font-size: 12px;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picture-agent-presets {
display: flex;
flex-wrap: wrap;
gap: 8px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.picture-agent-presets button {
min-height: 30px;
padding: 0 10px;
min-height: 34px;
padding: 0 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
@@ -4590,9 +4610,9 @@ textarea {
}
.picture-agent-submit {
min-height: 40px;
min-height: 44px;
width: 100%;
margin-top: auto;
margin-top: 2px;
}
.picture-agent-submit-hint {
@@ -5773,6 +5793,11 @@ textarea {
grid-template-columns: 1fr;
}
.picture-agent-presets {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.picture-agent-size-row {
grid-template-columns: 1fr 1fr;
}
@@ -12959,6 +12984,63 @@ textarea {
resize: vertical;
}
.digital-human-upload-note {
margin-top: -2px;
color: var(--color-success);
font-size: 12px;
font-weight: 650;
}
.digital-human-file-picker {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 8px;
min-height: 42px;
border: 1px solid var(--color-border);
background: color-mix(in srgb, var(--color-bg-card) 76%, var(--color-bg));
border-radius: var(--radius-sm);
padding: 6px;
}
.digital-human-file-picker input {
position: absolute;
inline-size: 1px;
block-size: 1px;
opacity: 0;
pointer-events: none;
}
.digital-human-file-picker label {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 30px;
padding: 0 12px;
border-radius: calc(var(--radius-sm) - 2px);
background: var(--color-primary);
color: var(--color-primary-contrast);
font-size: 12px;
font-weight: 750;
cursor: pointer;
white-space: nowrap;
}
.digital-human-file-picker input:disabled + label {
cursor: wait;
opacity: 0.72;
}
.digital-human-file-picker span {
min-width: 0;
overflow: hidden;
color: var(--color-text-muted);
font-size: 12px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.digital-human-panel h2 {
margin: 0 0 12px;
font-size: 14px;
@@ -13069,6 +13151,35 @@ textarea {
line-height: 1.45;
}
.digital-human-backend-switch {
display: inline-grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 2px;
width: min(360px, 100%);
padding: 3px;
border: 1px solid color-mix(in srgb, var(--color-primary) 16%, var(--color-border));
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-bg) 86%, var(--color-primary));
}
.digital-human-backend-switch button {
min-height: 34px;
border: 0;
border-radius: calc(var(--radius-sm) - 3px);
background: transparent;
color: var(--color-text-secondary);
font: inherit;
font-size: 12px;
font-weight: 760;
cursor: pointer;
}
.digital-human-backend-switch button.is-active {
background: var(--color-primary);
color: var(--color-primary-contrast);
box-shadow: 0 8px 18px color-mix(in srgb, var(--color-primary) 22%, transparent);
}
.digital-human-upload-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -13418,7 +13529,8 @@ textarea {
}
.money-printer-summary,
.digital-human-summary {
.digital-human-summary,
.picture-agent-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
@@ -13426,7 +13538,8 @@ textarea {
}
.money-printer-summary-card,
.digital-human-summary-card {
.digital-human-summary-card,
.picture-agent-summary-card {
min-height: 92px;
display: grid;
align-content: center;
@@ -13443,14 +13556,17 @@ textarea {
.money-printer-summary-card span,
.money-printer-summary-card em,
.digital-human-summary-card span,
.digital-human-summary-card em {
.digital-human-summary-card em,
.picture-agent-summary-card span,
.picture-agent-summary-card em {
color: var(--color-text-muted);
font-size: 12px;
line-height: 1.35;
}
.money-printer-summary-card strong,
.digital-human-summary-card strong {
.digital-human-summary-card strong,
.picture-agent-summary-card strong {
overflow: hidden;
color: var(--color-text-primary);
font-size: 18px;
@@ -13460,7 +13576,8 @@ textarea {
}
.money-printer-summary-card em,
.digital-human-summary-card em {
.digital-human-summary-card em,
.picture-agent-summary-card em {
font-style: normal;
}
@@ -13937,6 +14054,7 @@ textarea {
@media (max-width: 1180px) {
.money-printer-summary,
.digital-human-summary,
.picture-agent-summary,
.money-printer-presets {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -13949,6 +14067,7 @@ textarea {
@media (max-width: 720px) {
.money-printer-summary,
.digital-human-summary,
.picture-agent-summary,
.money-printer-presets {
grid-template-columns: 1fr;
}
@@ -14769,22 +14888,38 @@ textarea {
}
.user-dashboard-recharge-panel {
display: flex;
position: relative;
display: grid;
grid-template-columns: minmax(260px, 1fr) minmax(440px, 0.78fr);
align-items: center;
justify-content: space-between;
gap: 18px;
gap: 22px;
overflow: hidden;
margin-bottom: 18px;
padding: 16px 18px;
padding: 18px 20px;
border: 1px solid color-mix(in srgb, var(--color-primary) 24%, var(--console-line));
border-radius: 18px;
background:
radial-gradient(circle at 92% 12%, color-mix(in srgb, var(--color-primary) 18%, transparent), transparent 34%),
linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card)), color-mix(in srgb, var(--color-bg-card) 86%, var(--color-bg)));
box-shadow: var(--shadow-card);
}
.user-dashboard-recharge-panel::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background:
linear-gradient(90deg, color-mix(in srgb, var(--color-bg-card) 54%, transparent), transparent 58%),
repeating-linear-gradient(90deg, color-mix(in srgb, var(--color-primary) 7%, transparent) 0 1px, transparent 1px 44px);
opacity: 0.5;
}
.user-dashboard-recharge-panel>div {
position: relative;
z-index: 1;
display: grid;
gap: 5px;
gap: 6px;
min-width: 0;
}
@@ -14796,9 +14931,9 @@ textarea {
.user-dashboard-recharge-panel strong {
color: var(--color-text-primary);
font-size: 16px;
font-weight: 820;
line-height: 1.35;
font-size: clamp(18px, 2.1vw, 24px);
font-weight: 860;
line-height: 1.18;
}
.user-dashboard-recharge-panel p {
@@ -14897,10 +15032,12 @@ textarea {
.user-recharge-options {
display: grid;
flex: 0 1 520px;
grid-template-columns: repeat(4, minmax(92px, 1fr));
gap: 8px;
min-width: min(520px, 100%);
position: relative;
z-index: 1;
flex: 0 1 560px;
grid-template-columns: repeat(4, minmax(98px, 1fr));
gap: 10px;
min-width: min(560px, 100%);
}
.user-recharge-options.is-compact {
@@ -14910,18 +15047,37 @@ textarea {
}
.user-recharge-option {
position: relative;
display: grid;
gap: 2px;
align-content: center;
gap: 4px;
min-width: 0;
min-height: 64px;
padding: 10px 12px;
min-height: 76px;
overflow: hidden;
padding: 12px 14px;
border: 1px solid color-mix(in srgb, var(--color-primary) 18%, var(--console-line));
border-radius: 14px;
background: color-mix(in srgb, var(--color-bg-card) 92%, var(--color-primary));
border-radius: 16px;
background:
linear-gradient(180deg, color-mix(in srgb, var(--color-bg-card) 94%, var(--color-primary)), color-mix(in srgb, var(--color-bg-card) 86%, var(--color-bg)));
color: var(--color-text-primary);
text-decoration: none;
box-shadow: 0 10px 24px color-mix(in srgb, var(--color-primary) 8%, transparent);
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 46%, transparent),
0 12px 28px color-mix(in srgb, var(--color-primary) 8%, transparent);
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease, box-shadow 160ms ease;
}
.user-recharge-option::after {
content: "→";
position: absolute;
right: 12px;
bottom: 10px;
color: color-mix(in srgb, var(--color-primary) 62%, var(--color-text-muted));
font-size: 13px;
font-weight: 800;
opacity: 0;
transform: translateX(-4px);
transition: opacity 160ms ease, transform 160ms ease;
}
button.user-recharge-option {
@@ -14934,9 +15090,18 @@ button.user-recharge-option {
.user-recharge-option:focus-visible {
border-color: color-mix(in srgb, var(--color-primary) 54%, var(--console-line));
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-bg-card));
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 50%, transparent),
0 16px 34px color-mix(in srgb, var(--color-primary) 14%, transparent);
transform: translateY(-1px);
}
.user-recharge-option:hover::after,
.user-recharge-option:focus-visible::after {
opacity: 1;
transform: translateX(0);
}
.user-recharge-option.is-disabled,
.user-recharge-option.is-disabled:hover,
.user-recharge-option.is-disabled:focus-visible {
@@ -14948,14 +15113,35 @@ button.user-recharge-option {
transform: none;
}
.user-recharge-option.is-disabled::after {
display: none;
}
.user-recharge-option.is-recommended {
border-color: color-mix(in srgb, var(--color-primary) 64%, var(--console-line));
background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 22%, var(--color-bg-card)), color-mix(in srgb, var(--color-bg-card) 84%, var(--color-bg)));
background:
radial-gradient(circle at 86% 18%, color-mix(in srgb, var(--color-primary) 24%, transparent), transparent 38%),
linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 20%, var(--color-bg-card)), color-mix(in srgb, var(--color-bg-card) 84%, var(--color-bg)));
}
.user-recharge-option small {
position: absolute;
top: 8px;
right: 9px;
max-width: calc(100% - 18px);
padding: 2px 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-bg-card));
color: var(--color-primary);
font-size: 10px;
font-weight: 820;
line-height: 1.2;
white-space: nowrap;
}
.user-recharge-option strong {
overflow: hidden;
font-size: 17px;
font-size: 20px;
font-weight: 860;
line-height: 1.15;
text-overflow: ellipsis;
@@ -14965,13 +15151,23 @@ button.user-recharge-option {
.user-recharge-option span {
overflow: hidden;
color: var(--color-text-secondary);
font-size: 11px;
font-weight: 650;
font-size: 12px;
font-weight: 680;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-recharge-option b {
overflow: hidden;
color: var(--color-primary);
font-size: 11px;
font-weight: 820;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-recharge-option em {
align-self: end;
color: var(--color-text-muted);
@@ -15083,6 +15279,68 @@ button.user-recharge-option {
width: 100%;
}
.user-recharge-custom {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(170px, 0.28fr) auto;
align-items: center;
gap: 14px;
padding: 16px;
border: 1px solid color-mix(in srgb, var(--color-primary) 22%, var(--console-line));
border-radius: 16px;
background:
linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 8%, transparent), transparent 58%),
var(--color-bg-muted);
}
.user-recharge-custom>div {
display: grid;
gap: 5px;
min-width: 0;
}
.user-recharge-custom span {
color: var(--color-primary);
font-size: 12px;
font-weight: 820;
}
.user-recharge-custom strong {
color: var(--color-text-primary);
font-size: 20px;
font-weight: 860;
line-height: 1.2;
}
.user-recharge-custom p {
margin: 0;
color: var(--color-text-secondary);
font-size: 12px;
line-height: 1.5;
}
.user-recharge-custom label {
display: grid;
gap: 6px;
}
.user-recharge-custom input {
width: 100%;
min-height: 42px;
border: 1px solid var(--console-line);
border-radius: 12px;
background: var(--color-bg-card);
color: var(--color-text-primary);
font: inherit;
font-weight: 760;
padding: 8px 12px;
}
.user-recharge-custom .btn {
min-height: 42px;
justify-content: center;
white-space: nowrap;
}
.user-recharge-info-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -15574,7 +15832,7 @@ button.user-recharge-option {
.user-dashboard-recharge-panel {
align-items: stretch;
flex-direction: column;
grid-template-columns: 1fr;
}
.user-dashboard-recharge-panel .btn,
@@ -15603,6 +15861,10 @@ button.user-recharge-option {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.user-recharge-custom {
grid-template-columns: 1fr;
}
.user-recharge-info-grid {
grid-template-columns: 1fr;
}
+64
View File
@@ -203,6 +203,36 @@ export default function Accounts() {
const lastRebindTargetRef = useRef<any | null>(null);
const modelModalRequestSeqRef = useRef(0);
const toast = useToast();
const [pxConn, setPxConn] = useState({ comfyuiUrl: "", runninghubApiKey: "", comfyuiApiKey: "" });
const [pxSaving, setPxSaving] = useState(false);
useEffect(() => {
api
.getPixelleConnection()
.then((r) =>
setPxConn({
comfyuiUrl: r.connection?.comfyuiUrl || "",
runninghubApiKey: r.connection?.runninghubApiKey || "",
comfyuiApiKey: r.connection?.comfyuiApiKey || "",
}),
)
.catch(() => {});
}, []);
const savePxConn = async () => {
setPxSaving(true);
try {
const res = await api.savePixelleConnection({
runninghubApiKey: pxConn.runninghubApiKey || undefined,
comfyuiUrl: pxConn.comfyuiUrl || undefined,
comfyuiApiKey: pxConn.comfyuiApiKey || undefined,
});
if (res.warning) toast.error(res.warning);
else toast.success("已保存并重启 Pixelle,约数秒生效");
} catch (e: any) {
toast.error(e?.message || "保存失败");
} finally {
setPxSaving(false);
}
};
if (rebindTarget) lastRebindTargetRef.current = rebindTarget;
const activeRebindTarget = rebindTarget || lastRebindTargetRef.current;
const isRebindSub2Api =
@@ -3677,6 +3707,40 @@ export default function Accounts() {
}
onAddManualModels={handleAddManualModels}
/>
<div className="card" style={{ marginTop: 16, padding: 16 }}>
<h3>Pixelle 连接</h3>
<label style={{ display: "block", marginBottom: 8 }}>
<span>ComfyUI URL</span>
<input
value={pxConn.comfyuiUrl}
onChange={(e) => setPxConn((s) => ({ ...s, comfyuiUrl: e.target.value }))}
placeholder="http://172.17.0.1:4000/comfy-cloud"
style={{ width: "100%" }}
/>
</label>
<label style={{ display: "block", marginBottom: 8 }}>
<span>RunningHub API Key</span>
<input
value={pxConn.runninghubApiKey}
onChange={(e) => setPxConn((s) => ({ ...s, runninghubApiKey: e.target.value }))}
placeholder="留空或 •••• 表示不改"
style={{ width: "100%" }}
/>
</label>
<label style={{ display: "block", marginBottom: 8 }}>
<span>Comfy Cloud API Key</span>
<input
value={pxConn.comfyuiApiKey}
onChange={(e) => setPxConn((s) => ({ ...s, comfyuiApiKey: e.target.value }))}
placeholder="留空或 •••• 表示不改"
style={{ width: "100%" }}
/>
</label>
<button type="button" className="btn btn-primary" disabled={pxSaving} onClick={savePxConn}>
{pxSaving ? "保存中..." : "保存并重启 Pixelle"}
</button>
</div>
</div>
);
}
+32 -3
View File
@@ -576,6 +576,16 @@ function angleLabel(angle?: string) {
return angle || '图像';
}
function toolLabel(tool?: string) {
switch (tool) {
case 'generate_character_images': return '生成角色四视图';
case 'generate_scene_image': return '生成场景图';
case 'generate_tts': return '生成语音';
case 'build_comfyui_workflow': return '生成工作流';
default: return tool || '工具';
}
}
function appendLine(current: string, line: string) {
if (!line) return current;
return current ? `${current}\n${line}` : line;
@@ -1027,11 +1037,11 @@ export default function ComfyUIAgent() {
next.images.push({
url: String(event.url || ''),
character: event.character,
angle: event.angle,
angle: event.angle || event.view,
});
next.content = appendLine(
next.content,
`已生成 ${event.character || '角色'} ${angleLabel(event.angle)}。`,
`已生成 ${event.character || '角色'} ${angleLabel(event.angle || event.view)}。`,
);
} else if (event.type === 'scene') {
next.scenes.push({
@@ -1055,8 +1065,27 @@ export default function ComfyUIAgent() {
`已生成 ${event.character || '角色'} 的语音。`,
);
} else if (event.type === 'comfyui_workflow') {
next.workflowJson = parseWorkflow(String(event.content || ''));
// New agent path sends a parsed object in `workflow`; legacy path sends a
// JSON string in `content`.
if (event.workflow !== undefined && event.workflow !== null) {
next.workflowJson = event.workflow;
} else {
next.workflowJson = parseWorkflow(String(event.content || ''));
}
next.content = appendLine(next.content, 'ComfyUI 工作流 JSON 已就绪。');
} else if (event.type === 'tool_call') {
next.content = appendLine(
next.content,
`🛠 调用工具:${toolLabel(event.tool)}`,
);
} else if (event.type === 'tool_progress') {
if (event.detail) {
next.content = appendLine(next.content, `· ${event.detail}`);
}
} else if (event.type === 'tool_result') {
if (event.result) {
next.content = appendLine(next.content, `✓ ${String(event.result)}`);
}
} else if (event.type === 'error') {
next.content = appendLine(next.content, `错误:${event.message || '生成失败'}`);
}
-964
View File
@@ -1,964 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import DigitalHuman from './DigitalHuman.js';
const { apiMock, userApiMock } = vi.hoisted(() => {
const createBase = () => ({
getWorkflows: vi.fn(),
getWorkflowModelFolder: vi.fn(),
getDigitalHumanStatus: vi.fn(),
getDigitalHumanJobs: vi.fn(),
getWorkspaceAssets: vi.fn(),
createDigitalHumanJob: vi.fn(),
cancelDigitalHumanJob: vi.fn(),
clearDigitalHumanJobs: vi.fn(),
});
return {
apiMock: createBase(),
userApiMock: createBase(),
};
});
vi.mock('../api.js', () => ({
api: apiMock,
userApi: userApiMock,
fetchSessionResponse: vi.fn(),
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
}
function findButton(root: ReactTestRenderer, label: string) {
return root.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes(label)
))[0];
}
function findComfyModelInput(root: ReactTestRenderer) {
return root.root.find((node) => (
node.type === 'input'
&& node.props.list === 'digital-human-comfy-api-models'
));
}
function findDatalistOptionValues(root: ReactTestRenderer, id: string) {
const datalist = root.root.find((node) => node.type === 'datalist' && node.props.id === id);
return datalist.findAll((node) => node.type === 'option').map((node) => node.props.value);
}
function linkTarget(node: ReactTestInstance): string {
return String(node.props.href || node.props.to || '');
}
async function renderDigitalHuman(initialPath = '/digital-human') {
let root!: ReactTestRenderer;
await act(async () => {
root = create(
<MemoryRouter initialEntries={[initialPath]}>
<DigitalHuman />
</MemoryRouter>,
);
});
await flushMicrotasks();
return root;
}
describe('DigitalHuman page', () => {
let documentListeners!: Map<string, EventListener[]>;
let activeElementMock!: HTMLElement & { focus: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.clearAllMocks();
documentListeners = new Map();
class MockHTMLElement {
isConnected = true;
focus = vi.fn();
}
activeElementMock = new MockHTMLElement() as HTMLElement & { focus: ReturnType<typeof vi.fn> };
vi.stubGlobal('HTMLElement', MockHTMLElement);
vi.stubGlobal('document', {
activeElement: activeElementMock,
body: {
style: {
overflow: '',
},
},
addEventListener: vi.fn((type: string, listener: EventListener) => {
documentListeners.set(type, [...(documentListeners.get(type) || []), listener]);
}),
removeEventListener: vi.fn((type: string, listener: EventListener) => {
documentListeners.set(type, (documentListeners.get(type) || []).filter((item) => item !== listener));
}),
});
vi.stubGlobal('window', {
setInterval,
clearInterval,
});
apiMock.getWorkflows.mockResolvedValue({ success: true, items: [] });
apiMock.getWorkflowModelFolder.mockResolvedValue({
success: true,
items: [{ name: 'wan2.1-digital-human' }, { name: 'seedance-1-0-pro-t2v-250528' }],
names: ['wan2.1-digital-human', 'seedance-1-0-pro-t2v-250528'],
});
apiMock.getDigitalHumanStatus.mockResolvedValue({
success: true,
mode: 'comfy-native',
keyName: 'comfyui-agent',
capabilities: ['product_explainer_video', 'workspace_archive'],
comfy: {
configured: true,
backend: 'cloud',
baseUrl: 'https://comfy.example.com',
},
});
apiMock.getDigitalHumanJobs.mockResolvedValue({ success: true, jobs: [] });
apiMock.getWorkspaceAssets.mockResolvedValue({
success: true,
items: [
{
publicId: 'asset-audio-1',
title: '参考音频',
kind: 'audio',
source: 'workspace',
hasLocalFile: true,
},
],
});
userApiMock.getWorkflows.mockResolvedValue({ success: true, items: [] });
userApiMock.getWorkflowModelFolder.mockResolvedValue({
success: true,
items: [{ name: 'wan2.1-digital-human' }],
names: ['wan2.1-digital-human'],
});
userApiMock.getDigitalHumanStatus.mockResolvedValue({
success: true,
mode: 'boosapi-video-route',
keyName: 'user-default',
capabilities: [],
comfy: {
configured: false,
},
});
userApiMock.getDigitalHumanJobs.mockResolvedValue({ success: true, jobs: [] });
userApiMock.getWorkspaceAssets.mockResolvedValue({ success: true, items: [] });
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('opens the workspace asset picker as a labelled dialog and dismisses it with Escape', async () => {
let root!: ReactTestRenderer;
try {
root = await renderDigitalHuman();
await act(async () => {
findButton(root, '工作空间').props.onClick();
});
await flushMicrotasks();
const dialog = root.root.find((node) => node.props.role === 'dialog');
expect(dialog.props['aria-modal']).toBe('true');
expect(dialog.props['aria-labelledby']).toBeTruthy();
expect(dialog.props.tabIndex).toBe(-1);
expect(collectText(dialog)).toContain('选择人物素材');
expect(document.body.style.overflow).toBe('hidden');
expect(apiMock.getWorkspaceAssets).toHaveBeenCalledWith({ kind: undefined, pageSize: 60 });
const keydownListeners = documentListeners.get('keydown') || [];
expect(keydownListeners.length).toBeGreaterThan(0);
const tabEvent = {
key: 'Tab',
preventDefault: vi.fn(),
shiftKey: false,
};
await act(async () => {
keydownListeners.at(-1)?.(tabEvent as unknown as KeyboardEvent);
});
expect(root.root.findAll((node) => node.props.role === 'dialog')).toHaveLength(1);
await act(async () => {
keydownListeners.at(-1)?.({ key: 'Escape' } as KeyboardEvent);
});
await flushMicrotasks();
expect(root.root.findAll((node) => node.props.role === 'dialog')).toHaveLength(0);
expect(document.body.style.overflow).toBe('');
expect(documentListeners.get('keydown') || []).toHaveLength(0);
expect(activeElementMock.focus).toHaveBeenCalledWith({ preventScroll: true });
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
}
});
it('shows the active digital human backend, key, and Comfy status', async () => {
let root!: ReactTestRenderer;
try {
root = await renderDigitalHuman();
const text = collectText(root.root);
expect(apiMock.getDigitalHumanStatus).toHaveBeenCalled();
expect(text).toContain('当前后端');
expect(text).toContain('Comfy API');
expect(text).toContain('使用 comfyui-agent');
expect(text).toContain('Comfy 状态');
expect(text).toContain('cloud');
expect(text).toContain('https://comfy.example.com');
expect(text).toContain('能力');
expect(text).toContain('2 项');
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
}
});
it('allows generation when only optional output mapping is missing', async () => {
apiMock.getWorkflows.mockResolvedValue({
success: true,
items: [
{
id: 1,
publicId: 'tpl_digital_human',
name: '数字人模板',
visibility: 'template',
isTemplate: true,
graph: {
metadata: {
digitalHuman: {
nodeMapping: {
prompt: { nodeId: '1', field: 'prompt' },
script: { nodeId: '2', field: 'text' },
model: { nodeId: '5', field: 'model' },
avatar: { nodeId: '3', field: 'image' },
referenceAudio: { nodeId: '4', field: 'audio' },
output: { nodeId: '', field: 'video' },
},
},
},
prompt: {
'5': {
class_type: 'ByteDanceDigitalHumanNode',
inputs: { model: 'seedance-1-0-lite-t2v-250428' },
},
},
},
},
],
});
apiMock.createDigitalHumanJob.mockResolvedValue({
success: true,
job: {
id: 'dh_done',
type: 'digital-human-generation',
title: '数字人模板',
status: 'running',
progress: 1,
progressTotal: 5,
result: {},
},
});
let root!: ReactTestRenderer;
try {
root = await renderDigitalHuman();
const text = collectText(root.root);
expect(text).toContain('必需节点映射已准备好');
expect(text).not.toContain('缺少映射:输出节点');
const avatarInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '或粘贴图片/视频 URL'
));
const audioInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '或粘贴音频 URL'
));
await act(async () => {
avatarInput.props.onChange({ target: { value: 'https://cdn.example.com/avatar.png' } });
audioInput.props.onChange({ target: { value: 'https://cdn.example.com/voice.wav' } });
});
const modelInput = findComfyModelInput(root);
expect(modelInput.props.value).toBe('seedance-1-0-lite-t2v-250428');
expect(modelInput.props.list).toBe('digital-human-comfy-api-models');
const modelOptions = findDatalistOptionValues(root, 'digital-human-comfy-api-models');
expect(modelOptions).toContain('wan2.1-digital-human');
expect(modelOptions).toContain('seedance-1-0-lite-t2v-250428');
await act(async () => {
modelInput.props.onChange({ target: { value: 'seedance-1-0-pro-t2v-250528' } });
});
const generateButton = findButton(root, '生成讲解视频');
expect(generateButton.props.disabled).toBe(false);
await act(async () => {
generateButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.createDigitalHumanJob).toHaveBeenCalledWith(expect.objectContaining({
templateId: 'tpl_digital_human',
avatarUrl: 'https://cdn.example.com/avatar.png',
referenceAudioUrl: 'https://cdn.example.com/voice.wav',
comfyApiModel: 'seedance-1-0-pro-t2v-250528',
nodeMapping: expect.objectContaining({
model: { nodeId: '5', field: 'model' },
output: { nodeId: '', field: 'video' },
}),
}));
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
}
});
it('shows internal recharge guidance when a user digital human task hits quota', async () => {
userApiMock.getWorkflows.mockResolvedValue({
success: true,
items: [
{
id: 1,
publicId: 'tpl_digital_human_quota',
name: '数字人模板',
visibility: 'template',
isTemplate: true,
graph: {
metadata: {
digitalHuman: {
nodeMapping: {
prompt: { nodeId: '1', field: 'prompt' },
script: { nodeId: '2', field: 'text' },
avatar: { nodeId: '3', field: 'image' },
referenceAudio: { nodeId: '4', field: 'audio' },
},
},
},
prompt: {},
},
},
],
});
const quotaError = new Error('下游密钥成本额度已用完') as any;
quotaError.status = 429;
quotaError.billing = {
rechargePath: '/user/recharge',
usagePath: '/user/usage',
};
userApiMock.createDigitalHumanJob.mockRejectedValue(quotaError);
let root!: ReactTestRenderer;
try {
root = await renderDigitalHuman('/user/digital-human');
const avatarInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '或粘贴图片/视频 URL'
));
const audioInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '或粘贴音频 URL'
));
await act(async () => {
avatarInput.props.onChange({ target: { value: 'https://cdn.example.com/avatar.png' } });
audioInput.props.onChange({ target: { value: 'https://cdn.example.com/voice.wav' } });
});
await act(async () => {
findButton(root, '生成讲解视频').props.onClick();
});
await flushMicrotasks();
const text = collectText(root.root);
const links = root.root.findAll((node) => node.type === 'a');
expect(text).toContain('账户额度不足');
expect(text).toContain('去充值');
expect(links.some((link) => linkTarget(link) === '/user/recharge')).toBe(true);
expect(links.some((link) => linkTarget(link) === '/user/usage')).toBe(true);
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
}
});
it('uses the template Comfy API model when video_models is empty', async () => {
apiMock.getWorkflowModelFolder.mockResolvedValue({
success: true,
items: [],
names: [],
});
apiMock.getWorkflows.mockResolvedValue({
success: true,
items: [
{
id: 1,
publicId: 'tpl_digital_human_template_model',
name: '模板内模型数字人',
visibility: 'template',
isTemplate: true,
graph: {
metadata: {
digitalHuman: {
nodeMapping: {
prompt: { nodeId: '1', field: 'prompt' },
script: { nodeId: '2', field: 'text' },
model: { nodeId: '5', field: 'model' },
avatar: { nodeId: '3', field: 'image' },
referenceAudio: { nodeId: '4', field: 'audio' },
},
},
},
prompt: {
'1': { class_type: 'PromptTextNode', inputs: { prompt: '' } },
'2': { class_type: 'TTSVoiceNode', inputs: { text: '' } },
'3': { class_type: 'LoadImage', inputs: { image: '' } },
'4': { class_type: 'LoadAudio', inputs: { audio: '' } },
'5': {
class_type: 'ByteDanceDigitalHumanNode',
inputs: { model: 'seedance-1-0-lite-t2v' },
},
},
},
},
],
});
apiMock.createDigitalHumanJob.mockResolvedValue({
success: true,
job: {
id: 'dh_template_model',
type: 'digital-human-generation',
title: '数字人讲解任务',
status: 'running',
progress: 1,
progressTotal: 5,
result: {},
},
});
let root!: ReactTestRenderer;
try {
root = await renderDigitalHuman();
const modelInput = findComfyModelInput(root);
expect(modelInput.props.value).toBe('seedance-1-0-lite-t2v-250428');
expect(collectText(root.root)).toContain('已使用模板内 Comfy API 模型 seedance-1-0-lite-t2v-250428');
const modelOptions = findDatalistOptionValues(root, 'digital-human-comfy-api-models');
expect(modelOptions).toEqual(['seedance-1-0-lite-t2v-250428']);
const avatarInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '或粘贴图片/视频 URL'
));
const audioInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '或粘贴音频 URL'
));
await act(async () => {
avatarInput.props.onChange({ target: { value: 'https://cdn.example.com/avatar.png' } });
audioInput.props.onChange({ target: { value: 'https://cdn.example.com/voice.wav' } });
});
const generateButton = findButton(root, '生成讲解视频');
expect(generateButton.props.disabled).toBe(false);
await act(async () => {
generateButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.createDigitalHumanJob).toHaveBeenCalledWith(expect.objectContaining({
templateId: 'tpl_digital_human_template_model',
comfyApiModel: 'seedance-1-0-lite-t2v-250428',
}));
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
}
});
it('requires a Comfy API video model when neither video_models nor the template provide one', async () => {
apiMock.getWorkflowModelFolder.mockResolvedValue({
success: true,
items: [],
names: [],
});
apiMock.getWorkflows.mockResolvedValue({
success: true,
items: [
{
id: 1,
publicId: 'tpl_digital_human_no_model',
name: '无模型字段数字人模板',
visibility: 'template',
isTemplate: true,
graph: {
metadata: {
digitalHuman: {
nodeMapping: {
prompt: { nodeId: '1', field: 'prompt' },
script: { nodeId: '2', field: 'text' },
avatar: { nodeId: '3', field: 'image' },
referenceAudio: { nodeId: '4', field: 'audio' },
},
},
},
prompt: {
'1': { class_type: 'PromptTextNode', inputs: { prompt: '' } },
'2': { class_type: 'TTSVoiceNode', inputs: { text: '' } },
'3': { class_type: 'LoadImage', inputs: { image: '' } },
'4': { class_type: 'LoadAudio', inputs: { audio: '' } },
},
},
},
],
});
apiMock.createDigitalHumanJob.mockResolvedValue({
success: true,
job: {
id: 'dh_default_model',
type: 'digital-human-generation',
title: '数字人讲解任务',
status: 'running',
progress: 1,
progressTotal: 5,
result: {},
},
});
let root!: ReactTestRenderer;
try {
root = await renderDigitalHuman();
const modelInput = root.root.find((node) => (
node.type === 'input'
&& node.props.list === 'digital-human-comfy-api-models'
));
expect(modelInput.props.value).toBe('');
expect(collectText(root.root)).toContain('未读取到 Comfy API 视频模型,请先在 Comfy 模板或 video_models 中配置模型');
const modelOptions = findDatalistOptionValues(root, 'digital-human-comfy-api-models');
expect(modelOptions).toEqual([]);
const avatarInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '或粘贴图片/视频 URL'
));
const audioInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '或粘贴音频 URL'
));
await act(async () => {
avatarInput.props.onChange({ target: { value: 'https://cdn.example.com/avatar.png' } });
audioInput.props.onChange({ target: { value: 'https://cdn.example.com/voice.wav' } });
});
const generateButton = findButton(root, '生成讲解视频');
expect(generateButton.props.disabled).toBe(true);
await act(async () => {
generateButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.createDigitalHumanJob).not.toHaveBeenCalled();
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
}
});
it('filters non-video route aliases out of the Comfy API model picker', async () => {
apiMock.getWorkflowModelFolder.mockResolvedValue({
success: true,
items: [
{ name: 'gpt-4o' },
{ name: 'seedance-1-0-pro-t2v-250528' },
{ name: 'gpt-image-2' },
],
names: ['gpt-4o', 'seedance-1-0-pro-t2v-250528', 'gpt-image-2'],
});
let root!: ReactTestRenderer;
try {
root = await renderDigitalHuman();
const modelOptions = findDatalistOptionValues(root, 'digital-human-comfy-api-models');
expect(modelOptions).toContain('seedance-1-0-pro-t2v-250528');
expect(modelOptions).not.toContain('seedance-1-0-lite-t2v-250428');
expect(modelOptions).not.toContain('gpt-4o');
expect(modelOptions).not.toContain('gpt-image-2');
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
}
});
it('reads Comfy API video models from item payloads and normalizes old Seedance aliases', async () => {
apiMock.getWorkflowModelFolder.mockResolvedValue({
success: true,
items: [
{ value: 'seedance-1-0-lite-t2v' },
{ filename: 'wan2.2-digital-human' },
{ model: 'gpt-image-2' },
],
});
let root!: ReactTestRenderer;
try {
root = await renderDigitalHuman();
const modelInput = findComfyModelInput(root);
expect(modelInput.props.value).toBe('seedance-1-0-lite-t2v-250428');
const modelOptions = findDatalistOptionValues(root, 'digital-human-comfy-api-models');
expect(modelOptions).toContain('seedance-1-0-lite-t2v-250428');
expect(modelOptions).toContain('wan2.2-digital-human');
expect(modelOptions).not.toContain('seedance-1-0-lite-t2v');
expect(modelOptions).not.toContain('gpt-image-2');
expect(collectText(root.root)).toContain('优先使用 Comfy API video_models;已读取 2 个视频模型');
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
}
});
it('keeps a manually selected Comfy API video model after data refresh', async () => {
apiMock.getWorkflows.mockResolvedValue({
success: true,
items: [
{
id: 1,
publicId: 'tpl_digital_human',
name: '数字人模板',
visibility: 'template',
isTemplate: true,
graph: {
metadata: {
digitalHuman: {
nodeMapping: {
prompt: { nodeId: '1', field: 'prompt' },
script: { nodeId: '2', field: 'text' },
avatar: { nodeId: '3', field: 'image' },
referenceAudio: { nodeId: '4', field: 'audio' },
},
},
},
prompt: {
'5': {
class_type: 'ByteDanceDigitalHumanNode',
inputs: { model: 'seedance-1-0-lite-t2v-250428' },
},
},
},
},
],
});
let root!: ReactTestRenderer;
try {
root = await renderDigitalHuman();
const modelInput = findComfyModelInput(root);
expect(modelInput.props.value).toBe('seedance-1-0-lite-t2v-250428');
await act(async () => {
modelInput.props.onChange({ target: { value: 'seedance-1-0-pro-t2v-250528' } });
});
await act(async () => {
findButton(root, '刷新').props.onClick();
});
await flushMicrotasks();
const refreshedModelInput = findComfyModelInput(root);
expect(refreshedModelInput.props.value).toBe('seedance-1-0-pro-t2v-250528');
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
}
});
it('defaults to digital-human capable templates instead of generic workflows', async () => {
apiMock.getWorkflows.mockResolvedValue({
success: true,
items: [
{
id: 1,
publicId: 'tpl_generic_image',
name: '普通生图模板',
visibility: 'template',
isTemplate: true,
graph: {
prompt: {
'1': { class_type: 'CLIPTextEncode', inputs: { text: '' } },
'2': { class_type: 'KSampler', inputs: {} },
},
},
},
{
id: 2,
publicId: 'tpl_digital_human_ready',
name: '数字人可用模板',
visibility: 'public',
isTemplate: false,
graph: {
prompt: {
'1': { class_type: 'PromptTextNode', inputs: { prompt: '' } },
'2': { class_type: 'TTSVoiceNode', inputs: { text: '' } },
'3': { class_type: 'LoadImage', inputs: { image: '' } },
'4': { class_type: 'LoadAudio', inputs: { audio: '' } },
'5': { class_type: 'VHS_VideoCombine', inputs: { filename_prefix: 'dh' } },
},
},
},
],
});
let root!: ReactTestRenderer;
try {
root = await renderDigitalHuman();
const text = collectText(root.root);
expect(text).toContain('数字人可用模板');
expect(text).not.toContain('普通生图模板');
const templateSelect = root.root.find((node) => (
node.type === 'select'
&& node.props.value === 'tpl_digital_human_ready'
));
expect(templateSelect).toBeTruthy();
expect(collectText(root.root)).toContain('必需节点映射已准备好');
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
}
});
it('auto-infers required mappings from a plain Comfy workflow graph', async () => {
apiMock.getWorkflows.mockResolvedValue({
success: true,
items: [
{
id: 2,
publicId: 'tpl_plain_comfy',
name: '普通 Comfy 数字人',
visibility: 'public',
isTemplate: false,
graph: {
prompt: {
'1': { class_type: 'PromptTextNode', inputs: { prompt: '' } },
'2': { class_type: 'TTSVoiceNode', inputs: { text: '' } },
'3': { class_type: 'LoadImage', inputs: { image: '' } },
'4': { class_type: 'LoadAudio', inputs: { audio: '' } },
'5': { class_type: 'VHS_VideoCombine', inputs: { filename_prefix: 'dh' } },
},
},
},
],
});
let root!: ReactTestRenderer;
try {
root = await renderDigitalHuman();
expect(collectText(root.root)).toContain('必需节点映射已准备好');
expect(collectText(root.root)).not.toContain('缺少映射');
const avatarInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '或粘贴图片/视频 URL'
));
const audioInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '或粘贴音频 URL'
));
await act(async () => {
avatarInput.props.onChange({ target: { value: 'https://cdn.example.com/avatar.png' } });
audioInput.props.onChange({ target: { value: 'https://cdn.example.com/voice.wav' } });
});
const generateButton = findButton(root, '生成讲解视频');
expect(generateButton.props.disabled).toBe(false);
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
}
});
it('surfaces remote Comfy outputs and workspace assets on completed jobs', async () => {
apiMock.getDigitalHumanJobs.mockResolvedValue({
success: true,
jobs: [
{
id: 'dh_remote_result',
type: 'digital-human-generation',
title: '数字人讲解任务',
status: 'succeeded',
progress: 5,
progressTotal: 5,
createdAt: '2026-06-08T05:00:00Z',
result: {
promptId: 'prompt-remote-123456',
keyId: 88,
keyName: 'comfyui-agent',
templateName: '数字人模板',
script: 'BossAI 数字人讲解脚本',
request: {
comfyApiModel: 'seedance-1-0-pro-t2v-250528',
},
assets: [
{
kind: 'video',
status: 'ready',
remoteUrl: 'https://cdn.example.com/digital-human/output.mp4',
},
{
publicId: 'asset_audio_result',
kind: 'audio',
status: 'ready',
},
],
},
},
],
});
let root!: ReactTestRenderer;
try {
root = await renderDigitalHuman();
const text = collectText(root.root);
expect(text).toContain('Prompt prompt-remot');
expect(text).toContain('数字人模板');
expect(text).toContain('comfyui-agent');
expect(text).toContain('远端产物 · 视频');
expect(text).toContain('工作空间 · 音频 · asset_audio_result');
const remoteLink = root.root.find((node) => (
node.type === 'a'
&& node.props.href === 'https://cdn.example.com/digital-human/output.mp4'
));
expect(remoteLink.props.target).toBe('_blank');
expect(remoteLink.props.rel).toBe('noreferrer');
const workspaceLink = root.root.find((node) => (
node.type === 'a'
&& node.props.href === '/workspace?q=asset_audio_result'
));
expect(workspaceLink.props.target).toBeUndefined();
const traceLinks = root.root.findAll((node) => node.type === 'a');
expect(traceLinks.some((link) => linkTarget(link) === '/logs?q=seedance-1-0-pro-t2v-250528&status=success&downstreamKeyId=88')).toBe(true);
expect(traceLinks.some((link) => linkTarget(link) === '/logs?q=seedance-1-0-pro-t2v-250528&status=failed&downstreamKeyId=88')).toBe(true);
expect(traceLinks.some((link) => linkTarget(link) === '/model-tester?model=seedance-1-0-pro-t2v-250528')).toBe(true);
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
}
});
it('links user digital human tasks back to self-service usage records', async () => {
userApiMock.getDigitalHumanJobs.mockResolvedValue({
success: true,
jobs: [
{
id: 'dh_user_done',
type: 'digital-human-generation',
title: '数字人讲解任务',
status: 'succeeded',
progress: 5,
progressTotal: 5,
createdAt: '2026-06-08T05:00:00Z',
result: {
keyId: 88,
keyName: '讲解 Key',
templateName: '数字人模板',
script: 'BossAI 数字人讲解脚本',
request: {
comfyApiModel: 'seedance-1-0-lite-t2v-250428',
},
},
},
],
});
let root!: ReactTestRenderer;
try {
root = await renderDigitalHuman('/user/digital-human');
const traceLinks = root.root.findAll((node) => node.type === 'a');
expect(userApiMock.getDigitalHumanJobs).toHaveBeenCalled();
expect(traceLinks.some((link) => linkTarget(link) === '/user/usage?q=BossAI+%E6%95%B0%E5%AD%97%E4%BA%BA%E8%AE%B2%E8%A7%A3%E8%84%9A%E6%9C%AC&key_id=88&key=%E8%AE%B2%E8%A7%A3+Key&status=success')).toBe(true);
expect(traceLinks.some((link) => linkTarget(link) === '/user/usage?q=BossAI+%E6%95%B0%E5%AD%97%E4%BA%BA%E8%AE%B2%E8%A7%A3%E8%84%9A%E6%9C%AC&key_id=88&key=%E8%AE%B2%E8%A7%A3+Key&status=failed')).toBe(true);
expect(traceLinks.some((link) => linkTarget(link) === '/model-tester?model=seedance-1-0-lite-t2v-250428')).toBe(true);
expect(traceLinks.some((link) => linkTarget(link).startsWith('/logs'))).toBe(false);
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
}
});
it('keeps side JSON panels bounded on mobile viewports', () => {
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
expect(css).not.toContain('max-height: 280px;');
expect(css).toContain('.digital-human-side pre {\n max-height: min(280px, calc(100dvh - var(--topbar-height) - 320px));');
expect(css).toContain('overscroll-behavior: contain;');
expect(css).toContain('.digital-human-side pre {\n max-height: calc(100dvh - var(--topbar-height) - 360px);');
});
it('keeps template, picker, and upload control grids shrink-safe on phones', () => {
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
expect(css).toContain('grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr));');
expect(css).toContain('.digital-human-template-grid {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(min(210px, 100%), 1fr));');
expect(css).toContain('.digital-human-upload-actions {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(min(132px, 100%), 1fr));');
expect(css).toContain('.digital-human-picker-grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(min(160px, 100%), 1fr));');
expect(css).not.toContain('grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));');
});
});
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -466,7 +466,7 @@ export default function MoneyPrinter() {
const [topic, setTopic] = useState('帮我生成一个 30 秒的 AI 应用平台 BossAI 商品介绍短视频');
const [scriptModel, setScriptModel] = useState('gpt-5.4');
const [videoModel, setVideoModel] = useState(DEFAULT_BOOSAPI_VIDEO_MODEL);
const [videoBackend, setVideoBackend] = useState<'boosapi' | 'comfy'>('comfy');
const [videoBackend, setVideoBackend] = useState<'boosapi' | 'comfy'>('boosapi');
const [style, setStyle] = useState(STYLE_PRESETS[0]);
const [audienceText, setAudienceText] = useState('AI 工具用户、内容创作者、企业运营');
const [duration, setDuration] = useState(30);
@@ -493,6 +493,7 @@ export default function MoneyPrinter() {
const activeJobFilterTabId = `money-printer-filter-tab-${jobFilter}`;
const activeJobFilterPanelId = `money-printer-filter-panel-${jobFilter}`;
const isCloudComfy = status?.comfy?.backend === 'cloud' && status?.builtinCloudTemplate?.enabled !== false;
const moneyPrinterDisabled = Boolean(status?.disabled);
const cloudDurationMin = status?.builtinCloudTemplate?.duration?.min ?? 3;
const cloudDurationMax = status?.builtinCloudTemplate?.duration?.max ?? 12;
const cloudDefaultDuration = status?.builtinCloudTemplate?.duration?.default ?? DEFAULT_BUILTIN_CLOUD_DURATION;
@@ -602,6 +603,7 @@ export default function MoneyPrinter() {
const hasSelectedComfyApiModel = Boolean(comfyApiModel.trim());
const canSubmit = Boolean(topic.trim())
&& !submitting
&& !moneyPrinterDisabled
&& (videoBackend !== 'comfy' || (comfyHasTemplate && hasSelectedComfyApiModel));
const comfyApiModelHint = templateComfyApiModel
? (hasConfiguredComfyApiVideoModels
@@ -844,6 +846,11 @@ export default function MoneyPrinter() {
) : error ? (
<div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>
) : null}
{moneyPrinterDisabled ? (
<div className="alert alert-warning" style={{ marginBottom: 12 }}>
{status?.disabledReason || '短视频工厂暂时只支持本地模型部署。'}
</div>
) : null}
<div className="money-printer-summary">
<div className="money-printer-summary-card">
+70
View File
@@ -0,0 +1,70 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
buildRechargeRecordsCsv,
formatComfyBalanceMicros,
formatDurationSeconds,
formatRechargeRecordTime,
paymentFieldLabel,
} from './PaymentManagement.js';
describe('PaymentManagement Comfy balance formatting', () => {
it('formats normalized Comfy balance micros as USD', () => {
expect(formatComfyBalanceMicros(49_800_000, 'usd')).toBe('49.8 usd');
});
it('localizes Comfy balance and diagnostics field labels', () => {
expect(paymentFieldLabel('amountMicros')).toBe('账户余额');
expect(paymentFieldLabel('effectiveBalanceMicros')).toBe('可用余额');
expect(paymentFieldLabel('prepaidBalanceMicros')).toBe('预付余额');
expect(paymentFieldLabel('cloudCreditBalanceMicros')).toBe('云积分余额');
expect(paymentFieldLabel('pendingChargesMicros')).toBe('待扣费用');
expect(paymentFieldLabel('currency')).toBe('币种');
expect(paymentFieldLabel('diagnostics.httpStatus')).toBe('HTTP 状态');
});
it('formats sqlite UTC timestamps into local display time', () => {
expect(formatRechargeRecordTime('2026-06-16 05:02:16', 'Asia/Shanghai')).toBe('2026/06/16 13:02:16');
});
it('formats live Comfy login cache countdown', () => {
expect(formatDurationSeconds(2064)).toBe('34 分 24 秒');
expect(formatDurationSeconds(9)).toBe('9 秒');
expect(formatDurationSeconds(0)).toBe('需重新换取');
});
it('exports recharge records as csv with readable timestamps', () => {
const csv = buildRechargeRecordsCsv([
{
publicId: 'rch_test',
userId: 7,
userEmail: 'user@example.com',
userName: '测试用户',
amountUsd: 50,
provider: 'comfy_cloud_checkout',
status: 'completed',
note: '管理员已确认到账',
createdAt: '2026-06-16 04:59:05',
completedAt: '2026-06-16 05:01:00',
},
], 'Asia/Shanghai');
expect(csv).toContain('"测试用户","user@example.com","50","completed"');
expect(csv).toContain('"2026/06/16 12:59:05"');
expect(csv).toContain('"2026/06/16 13:01:00"');
expect(csv).toContain('"rch_test"');
});
it('keeps admin recharge actions explicit and compact', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/PaymentManagement.tsx'), 'utf8');
expect(source).toContain('取消支付');
expect(source).toContain('adminCancelRecharge');
expect(source).toContain('onClick={exportRechargeRecords}');
expect(source).toContain('setLiveCacheRemainingSecs');
expect(source).toContain('lastTokenRefreshAttemptRef');
expect(source).toContain('导出');
expect(source).not.toContain('导出 CSV');
});
});
+916
View File
@@ -0,0 +1,916 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
api,
type AdminComfyCloudBalanceResponse,
type ComfyCloudBalance,
type ComfyCloudDiagnostics,
type PaymentConfigResponse,
} from '../api.js';
import PageHeader from '../components/PageHeader.js';
function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message) return error.message;
if (typeof error === 'string' && error.trim()) return error.trim();
const message = (error as any)?.message;
return typeof message === 'string' && message.trim() ? message.trim() : fallback;
}
function formatBoolean(value: boolean | undefined): string {
if (value === true) return '已启用';
if (value === false) return '未启用';
return '--';
}
function formatNumber(value: number | null | undefined): string {
return typeof value === 'number' && Number.isFinite(value) ? String(value) : '--';
}
function formatDurationSeconds(value: number | null | undefined): string {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return '需重新换取';
}
const total = Math.max(0, Math.floor(value));
const minutes = Math.floor(total / 60);
const seconds = total % 60;
if (minutes <= 0) return `${seconds} 秒`;
return `${minutes} 分 ${seconds} 秒`;
}
const PAYMENT_FIELD_LABELS: Record<string, string> = {
amountMicros: '账户余额',
effectiveBalanceMicros: '可用余额',
prepaidBalanceMicros: '预付余额',
cloudCreditBalanceMicros: '云积分余额',
pendingChargesMicros: '待扣费用',
currency: '币种',
'diagnostics.tokenKind': 'Token 类型',
'diagnostics.tokenSource': 'Token 来源',
'diagnostics.httpStatus': 'HTTP 状态',
'diagnostics.apiBaseUrl': '上游 API 地址',
'diagnostics.jwtIssuer': 'JWT 签发方',
'diagnostics.jwtAudience': 'JWT 受众',
'diagnostics.upstreamMessage': '上游返回信息',
};
export function paymentFieldLabel(label: string): string {
return PAYMENT_FIELD_LABELS[label] || label;
}
export { formatDurationSeconds };
type RechargeRecord = {
publicId: string;
userId: number;
userEmail?: string | null;
userName?: string | null;
amountUsd?: number | null;
provider: string;
status: string;
label?: string | null;
title?: string | null;
note?: string | null;
createdAt?: string | null;
completedAt?: string | null;
};
export function formatComfyBalanceMicros(value: number | null | undefined, currency: string): string {
if (typeof value !== 'number' || !Number.isFinite(value)) return '--';
const amount = value / 1_000_000;
return `${amount.toLocaleString('zh-CN', {
minimumFractionDigits: 0,
maximumFractionDigits: 6,
})} ${currency || ''}`.trim();
}
export function formatRechargeRecordTime(value?: string | null, timeZone?: string): string {
const raw = String(value || '').trim();
if (!raw) return '--';
const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(raw)
? `${raw.replace(' ', 'T')}Z`
: raw;
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) return raw;
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
...(timeZone ? { timeZone } : {}),
}).format(date);
}
function csvCell(value: unknown): string {
const text = value == null ? '' : String(value);
return `"${text.replace(/"/g, '""')}"`;
}
export function buildRechargeRecordsCsv(records: RechargeRecord[], timeZone?: string): string {
const header = ['用户', '邮箱', '金额USD', '状态', 'Provider', '创建时间', '完成时间', '记录ID', '备注'];
const rows = records.map((record) => [
record.userName || record.userEmail || `用户 #${record.userId}`,
record.userEmail || '',
typeof record.amountUsd === 'number' && Number.isFinite(record.amountUsd) ? record.amountUsd : '',
record.status,
record.provider,
formatRechargeRecordTime(record.createdAt, timeZone),
formatRechargeRecordTime(record.completedAt, timeZone),
record.publicId,
record.note || '',
]);
return [header, ...rows].map((row) => row.map(csvCell).join(',')).join('\n');
}
function Field({ label, value }: { label: string; value: string }) {
return (
<div style={{
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
padding: 14,
background: 'var(--color-surface)',
minWidth: 0,
}}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>{paymentFieldLabel(label)}</div>
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--color-text-primary)', wordBreak: 'break-word' }}>
{value}
</div>
</div>
);
}
function BalanceField({
label,
value,
currency,
}: {
label: string;
value: number | null | undefined;
currency: string;
}) {
return <Field label={label} value={formatComfyBalanceMicros(value, currency)} />;
}
export default function PaymentManagement() {
const [config, setConfig] = useState<PaymentConfigResponse | null>(null);
const [balanceResponse, setBalanceResponse] = useState<AdminComfyCloudBalanceResponse | null>(null);
const [diagnostics, setDiagnostics] = useState<ComfyCloudDiagnostics | null>(null);
const [loadingConfig, setLoadingConfig] = useState(true);
const [loadingBalance, setLoadingBalance] = useState(false);
const [savingSettings, setSavingSettings] = useState(false);
const [configError, setConfigError] = useState('');
const [balanceError, setBalanceError] = useState('');
const [settingsMessage, setSettingsMessage] = useState('');
const [settingsError, setSettingsError] = useState('');
const [customerTokenInput, setCustomerTokenInput] = useState('');
const [customerTokenEndpointInput, setCustomerTokenEndpointInput] = useState('');
const [firebaseApiKeyInput, setFirebaseApiKeyInput] = useState('');
const [googleOAuthClientIdInput, setGoogleOAuthClientIdInput] = useState('');
const [googleOAuthClientSecretInput, setGoogleOAuthClientSecretInput] = useState('');
const [firebaseRefreshTokenInput, setFirebaseRefreshTokenInput] = useState('');
const [checkoutEndpointInput, setCheckoutEndpointInput] = useState('');
const [rechargeAmount, setRechargeAmount] = useState('10');
const [rechargeError, setRechargeError] = useState('');
const [rechargeMessage, setRechargeMessage] = useState('');
const [rechargeLoading, setRechargeLoading] = useState(false);
const [rechargeRecords, setRechargeRecords] = useState<RechargeRecord[]>([]);
const [recordsLoading, setRecordsLoading] = useState(false);
const [recordsError, setRecordsError] = useState('');
const [confirmingId, setConfirmingId] = useState('');
const [cancellingId, setCancellingId] = useState('');
const [clearingRecords, setClearingRecords] = useState(false);
const [liveCacheRemainingSecs, setLiveCacheRemainingSecs] = useState<number | null>(null);
const tokenRefreshInFlightRef = useRef(false);
const lastTokenRefreshAttemptRef = useRef(0);
const loadConfig = useCallback(async () => {
setLoadingConfig(true);
setConfigError('');
try {
const payload = await api.getPaymentConfig();
setConfig(payload);
} catch (error) {
setConfigError(getErrorMessage(error, '支付配置加载失败'));
} finally {
setLoadingConfig(false);
}
}, []);
const refreshBalance = useCallback(async () => {
setLoadingBalance(true);
setBalanceError('');
try {
const payload = await api.getComfyCloudBalance();
setBalanceResponse(payload);
setDiagnostics(null);
} catch (error) {
setBalanceError(getErrorMessage(error, 'Comfy Cloud 余额查询失败'));
try {
const result = await api.getComfyCloudDiagnostics();
setDiagnostics(result.diagnostics || null);
} catch {
setDiagnostics(null);
}
} finally {
setLoadingBalance(false);
}
}, []);
useEffect(() => {
void loadConfig();
}, [loadConfig]);
const payment = config?.payment;
const checkout = config?.comfyCloud?.checkout;
const customerTokenConfigured = checkout?.customerTokenConfigured;
const customerTokenEndpoint = typeof checkout?.customerTokenEndpoint === 'string' ? checkout.customerTokenEndpoint : '';
const checkoutEndpoint = typeof checkout?.checkoutEndpoint === 'string' ? checkout.checkoutEndpoint : '';
const firebaseLogin = checkout?.firebaseLogin;
const balance: ComfyCloudBalance | null = balanceResponse?.balance || null;
const balanceCurrency = String(balance?.currency || payment?.currency || '').trim();
const finishedRechargeCount = useMemo(
() => rechargeRecords.filter((record) => ['completed', 'cancelled', 'failed'].includes(record.status)).length,
[rechargeRecords],
);
useEffect(() => {
setCheckoutEndpointInput(checkoutEndpoint || '');
}, [checkoutEndpoint]);
useEffect(() => {
setCustomerTokenEndpointInput(customerTokenEndpoint || '');
}, [customerTokenEndpoint]);
useEffect(() => {
if (customerTokenConfigured === true) {
void refreshBalance();
}
}, [customerTokenConfigured, refreshBalance]);
useEffect(() => {
setLiveCacheRemainingSecs(
typeof firebaseLogin?.cacheRemainingSecs === 'number'
? firebaseLogin.cacheRemainingSecs
: null,
);
}, [firebaseLogin?.cacheRemainingSecs, firebaseLogin?.lastRefreshAtUnix]);
useEffect(() => {
if (!firebaseLogin?.configured || firebaseLogin.invalidGrant) return undefined;
const timer = window.setInterval(() => {
setLiveCacheRemainingSecs((current) => {
if (typeof current !== 'number' || !Number.isFinite(current)) return current;
return Math.max(0, current - 1);
});
}, 1000);
return () => window.clearInterval(timer);
}, [firebaseLogin?.configured, firebaseLogin?.invalidGrant]);
useEffect(() => {
if (!firebaseLogin?.configured || firebaseLogin.invalidGrant || firebaseLogin.lastRefreshOk === false) {
return;
}
if (typeof liveCacheRemainingSecs !== 'number' || liveCacheRemainingSecs > 60) {
return;
}
const now = Date.now();
if (tokenRefreshInFlightRef.current || now - lastTokenRefreshAttemptRef.current < 30_000) {
return;
}
tokenRefreshInFlightRef.current = true;
lastTokenRefreshAttemptRef.current = now;
void (async () => {
try {
await refreshBalance();
await loadConfig();
} finally {
tokenRefreshInFlightRef.current = false;
}
})();
}, [
firebaseLogin?.configured,
firebaseLogin?.invalidGrant,
firebaseLogin?.lastRefreshOk,
liveCacheRemainingSecs,
loadConfig,
refreshBalance,
]);
useEffect(() => {
if (!firebaseLogin?.configured || firebaseLogin.invalidGrant) return undefined;
const timer = window.setInterval(() => {
void loadConfig();
}, 20_000);
return () => window.clearInterval(timer);
}, [firebaseLogin?.configured, firebaseLogin?.invalidGrant, loadConfig]);
const saveSettings = useCallback(async () => {
setSavingSettings(true);
setSettingsError('');
setSettingsMessage('');
try {
await api.updateComfyCloudPaymentSettings({
customerToken: customerTokenInput.trim() || undefined,
customerTokenEndpoint: customerTokenEndpointInput.trim(),
firebaseApiKey: firebaseApiKeyInput.trim() || undefined,
googleOAuthClientId: googleOAuthClientIdInput.trim() || undefined,
googleOAuthClientSecret: googleOAuthClientSecretInput.trim() || undefined,
firebaseRefreshToken: firebaseRefreshTokenInput.trim() || undefined,
checkoutEndpoint: checkoutEndpointInput.trim(),
});
setCustomerTokenInput('');
setFirebaseApiKeyInput('');
setGoogleOAuthClientSecretInput('');
setFirebaseRefreshTokenInput('');
setSettingsMessage('Comfy 支付配置已保存,余额诊断和用户充值会立即使用新配置。');
await loadConfig();
} catch (error) {
setSettingsError(getErrorMessage(error, 'Comfy 支付配置保存失败'));
} finally {
setSavingSettings(false);
}
}, [checkoutEndpointInput, customerTokenEndpointInput, customerTokenInput, firebaseApiKeyInput, firebaseRefreshTokenInput, googleOAuthClientIdInput, googleOAuthClientSecretInput, loadConfig]);
const clearCustomerToken = useCallback(async () => {
setSavingSettings(true);
setSettingsError('');
setSettingsMessage('');
try {
await api.updateComfyCloudPaymentSettings({
clearCustomerToken: true,
customerTokenEndpoint: customerTokenEndpointInput.trim(),
checkoutEndpoint: checkoutEndpointInput.trim(),
});
setCustomerTokenInput('');
setSettingsMessage('已清空数据库中的 Comfy Customer Token 覆盖;如 config/env 有默认值会继续生效。');
await loadConfig();
} catch (error) {
setSettingsError(getErrorMessage(error, 'Comfy Customer Token 清空失败'));
} finally {
setSavingSettings(false);
}
}, [checkoutEndpointInput, customerTokenEndpointInput, loadConfig]);
const startOAuth = useCallback(async () => {
setSavingSettings(true);
setSettingsError('');
setSettingsMessage('');
try {
// Save any pending Firebase API key first so the login page mints with it.
if (firebaseApiKeyInput.trim()) {
await api.updateComfyCloudPaymentSettings({
firebaseApiKey: firebaseApiKeyInput.trim() || undefined,
customerTokenEndpoint: customerTokenEndpointInput.trim(),
checkoutEndpoint: checkoutEndpointInput.trim(),
});
}
const loginPath = '/api/admin/payment/comfy-cloud/firebase-login';
if (window.location.hostname === 'localhost') {
// 已在本机 localhost:直接同源跳登录页(Firebase 信任 localhost)。
window.location.href = loginPath;
} else {
// 线上域名(tybossai.com 等):Firebase 不信任此域,跳本机 localhost
// 登录页并带回执地址,登录成功后自动跳回本页。需本机已建 SSH 隧道。
const port = window.location.port || '4000';
const returnTo = encodeURIComponent(window.location.href);
window.location.href = `http://localhost:${port}${loginPath}?return_to=${returnTo}`;
}
} catch (error) {
setSettingsError(getErrorMessage(error, 'Comfy Cloud Google 登录失败'));
setSavingSettings(false);
}
}, [checkoutEndpointInput, customerTokenEndpointInput, firebaseApiKeyInput]);
const startRecharge = useCallback(async () => {
setRechargeError('');
setRechargeMessage('');
const amount = Number(rechargeAmount);
if (!Number.isFinite(amount) || amount <= 0) {
setRechargeError('请输入有效的金额(USD)');
return;
}
setRechargeLoading(true);
try {
const result = await api.adminComfyCloudRecharge(amount);
const url = result?.checkout?.url;
if (!url) {
throw new Error('充值会话已创建,但未返回 Checkout URL');
}
window.open(url, '_blank', 'noopener,noreferrer');
setRechargeMessage(`已生成 $${amount} 的 Comfy Cloud Checkout 链接,请在新窗口完成付款后点「刷新 Comfy Cloud 余额」。`);
} catch (error) {
setRechargeError(getErrorMessage(error, 'Comfy 充值失败'));
} finally {
setRechargeLoading(false);
}
}, [rechargeAmount]);
const loadRechargeRecords = useCallback(async () => {
setRecordsLoading(true);
setRecordsError('');
try {
const result = await api.adminListRechargeRecords(100);
setRechargeRecords(Array.isArray(result?.records) ? result.records : []);
} catch (error) {
setRecordsError(getErrorMessage(error, '充值记录加载失败'));
} finally {
setRecordsLoading(false);
}
}, []);
useEffect(() => {
void loadRechargeRecords();
}, [loadRechargeRecords]);
const confirmRecord = useCallback(async (publicId: string) => {
setConfirmingId(publicId);
setRecordsError('');
try {
await api.adminConfirmRecharge(publicId);
await loadRechargeRecords();
} catch (error) {
setRecordsError(getErrorMessage(error, '确认到账失败'));
} finally {
setConfirmingId('');
}
}, [loadRechargeRecords]);
const cancelRecord = useCallback(async (publicId: string) => {
if (cancellingId) return;
const ok = window.confirm('确认取消这笔待支付记录吗?取消后不会给用户增加额度。');
if (!ok) return;
setCancellingId(publicId);
setRecordsError('');
try {
await api.adminCancelRecharge(publicId);
await loadRechargeRecords();
setRechargeMessage('已取消待支付记录。');
} catch (error) {
setRecordsError(getErrorMessage(error, '取消支付失败'));
} finally {
setCancellingId('');
}
}, [cancellingId, loadRechargeRecords]);
const exportRechargeRecords = useCallback(() => {
const csv = buildRechargeRecordsCsv(rechargeRecords);
const blob = new Blob([`\uFEFF${csv}`], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const stamp = new Date().toISOString().slice(0, 10);
link.href = url;
link.download = `boosapi-recharge-records-${stamp}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}, [rechargeRecords]);
const clearFinishedRechargeRecords = useCallback(async () => {
if (finishedRechargeCount <= 0 || clearingRecords) return;
const ok = window.confirm(`将清理 ${finishedRechargeCount} 条已结束充值记录,待确认记录会保留。继续吗?`);
if (!ok) return;
setClearingRecords(true);
setRecordsError('');
try {
const result = await api.adminClearRechargeRecords();
await loadRechargeRecords();
setRechargeMessage(`已清理 ${result.cleared || 0} 条已结束充值记录,待确认记录已保留。`);
} catch (error) {
setRecordsError(getErrorMessage(error, '清理充值记录失败'));
} finally {
setClearingRecords(false);
}
}, [clearingRecords, finishedRechargeCount, loadRechargeRecords]);
return (
<div className="animate-fade-in admin-ops-page payment-ops-page" style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<PageHeader
title="支付管理"
subtitle="Comfy Cloud 登录、充值与到账确认。"
actions={(
<button type="button" className="btn btn-ghost" onClick={() => void loadConfig()} disabled={loadingConfig}>
{loadingConfig ? '加载中...' : '刷新配置'}
</button>
)}
/>
{configError && <div className="alert alert-error">{configError}</div>}
<section style={{
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
background: 'var(--color-panel)',
padding: 18,
}} aria-label="Comfy Cloud Token 持久化">
{customerTokenConfigured === false && (
<div className="alert alert-warning" style={{ marginTop: 16 }}>
Comfy Cloud 登录凭证未配置。点下方「Google 登录 Comfy Cloud」使用你的 Google 账号直接授权(无需注册 OAuth 应用),系统会自动保存 Firebase Refresh Token;也可以直接粘贴从 cloud.comfy.org 抓取的 Firebase Refresh Token 或 Bearer JWT。
</div>
)}
{firebaseLogin?.configured && (
<div
style={{
marginTop: 16,
border: `1px solid ${firebaseLogin.invalidGrant ? 'var(--color-danger, #dc2626)' : 'var(--color-border)'}`,
borderRadius: 'var(--radius-sm)',
padding: 14,
background: firebaseLogin.invalidGrant
? 'color-mix(in srgb, var(--color-danger, #dc2626) 8%, var(--color-surface))'
: 'var(--color-surface)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8, flexWrap: 'wrap' }}>
<strong style={{ fontSize: 14, color: 'var(--color-text-primary)' }}>Comfy 登录态</strong>
<span
className={
firebaseLogin.invalidGrant
? 'badge badge-warning'
: firebaseLogin.lastRefreshOk === false
? 'badge badge-warning'
: 'badge badge-success'
}
>
{firebaseLogin.invalidGrant
? '已失效,需重新登录'
: firebaseLogin.lastRefreshOk === false
? '上次刷新失败'
: firebaseLogin.lastRefreshOk === true
? '正常'
: '已配置'}
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 10 }}>
<Field label="Refresh Token" value={firebaseLogin.configured ? '已保存' : '未配置'} />
<Field
label="ID Token 缓存剩余"
value={formatDurationSeconds(liveCacheRemainingSecs)}
/>
<Field
label="上次刷新"
value={
firebaseLogin.lastRefreshAtUnix
? new Date(firebaseLogin.lastRefreshAtUnix * 1000).toLocaleString('zh-CN')
: '尚未刷新'
}
/>
</div>
{firebaseLogin.invalidGrant && (
<div className="alert alert-error" style={{ marginTop: 12, marginBottom: 0 }}>
登录态已失效(可能在 cloud.comfy.org 主动登出或改密)。请点下方「Google 登录 Comfy Cloud」通过 localhost 重新登录。
{firebaseLogin.lastRefreshDetail ? ` 详情:${firebaseLogin.lastRefreshDetail}` : ''}
</div>
)}
{!firebaseLogin.invalidGrant && firebaseLogin.lastRefreshOk === false && firebaseLogin.lastRefreshDetail && (
<div className="alert alert-warning" style={{ marginTop: 12, marginBottom: 0 }}>
{firebaseLogin.lastRefreshDetail}
</div>
)}
</div>
)}
<div style={{
marginTop: 16,
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
padding: 14,
background: 'var(--color-surface)',
}}>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--color-text-primary)', marginBottom: 6 }}>
Comfy Customer Token 持久化
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
点「Google 登录 Comfy Cloud」直接用你的 Google 账号授权,无需注册任何 OAuth 应用。登录成功后系统自动保存 Firebase Refresh Token,之后充值和查余额全程自动换取 token。
<br/>
<strong style={{ color: 'var(--color-text-primary)' }}>注意:</strong> Firebase 只允许 localhost 和 Comfy 自己的域名走弹窗登录。如果你在 tybossai.com 等线上域名访问遇到「unauthorized-domain」,请改用以下任一方式:
<br/>① 通过 <code>http://localhost:4000/payment</code> 访问 BoosAPI,登录一次后 refresh token 会持久保存到数据库,线上域名也会自动使用;
<br/>② 直接从 cloud.comfy.org 浏览器 IndexedDB(<code>firebaseLocalStorageDb</code> → 找 <code>stsTokenManager.refreshToken</code>)复制后粘到下方「Firebase Refresh Token」字段。
</div>
{settingsError && <div className="alert alert-error" style={{ marginBottom: 12 }}>{settingsError}</div>}
{settingsMessage && <div className="alert alert-success" style={{ marginBottom: 12 }}>{settingsMessage}</div>}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
gap: 12,
}}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>Comfy Session / Bearer JWT</span>
<input
className="input"
value={customerTokenInput}
onChange={(event) => setCustomerTokenInput(event.target.value)}
placeholder="session=eyJ... 或 Bearer eyJ..."
type="password"
autoComplete="off"
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>Firebase Web API Key(可选)</span>
<input
className="input"
value={firebaseApiKeyInput}
onChange={(event) => setFirebaseApiKeyInput(event.target.value)}
placeholder="留空使用 Comfy Cloud 默认 Firebase Key"
type="password"
autoComplete="off"
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>Google OAuth Client ID</span>
<input
className="input"
value={googleOAuthClientIdInput}
onChange={(event) => setGoogleOAuthClientIdInput(event.target.value)}
placeholder="xxx.apps.googleusercontent.com"
autoComplete="off"
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>Google OAuth Client Secret</span>
<input
className="input"
value={googleOAuthClientSecretInput}
onChange={(event) => setGoogleOAuthClientSecretInput(event.target.value)}
placeholder="GOCSPX-..."
type="password"
autoComplete="off"
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>Firebase Refresh Token</span>
<input
className="input"
value={firebaseRefreshTokenInput}
onChange={(event) => setFirebaseRefreshTokenInput(event.target.value)}
placeholder="可选:粘贴后即可自动充值/查余额,无需 Google 授权"
type="password"
autoComplete="off"
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>Token Provider Endpoint</span>
<input
className="input"
value={customerTokenEndpointInput}
onChange={(event) => setCustomerTokenEndpointInput(event.target.value)}
placeholder="https://your-service.example/comfy-token"
autoComplete="off"
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>Checkout Endpoint</span>
<input
className="input"
value={checkoutEndpointInput}
onChange={(event) => setCheckoutEndpointInput(event.target.value)}
placeholder="/customers/credit"
autoComplete="off"
/>
</label>
</div>
<div style={{ display: 'flex', gap: 10, marginTop: 12, flexWrap: 'wrap' }}>
<button type="button" className="btn btn-primary" onClick={() => void saveSettings()} disabled={savingSettings}>
{savingSettings ? '保存中...' : '保存 Comfy 配置'}
</button>
<button type="button" className="btn btn-secondary" onClick={() => void startOAuth()} disabled={savingSettings}>
Google 登录 Comfy Cloud
</button>
<button type="button" className="btn btn-ghost" onClick={() => void clearCustomerToken()} disabled={savingSettings}>
清空运行时 Token
</button>
</div>
</div>
</section>
<section style={{
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
background: 'var(--color-panel)',
padding: 18,
}} aria-label="Comfy Cloud 余额">
<div style={{
display: 'flex',
justifyContent: 'space-between',
gap: 12,
alignItems: 'center',
marginBottom: 16,
flexWrap: 'wrap',
}}>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>Comfy Cloud</div>
<strong style={{ fontSize: 18, color: 'var(--color-text-primary)' }}>余额诊断</strong>
</div>
<button type="button" className="btn btn-primary" onClick={() => void refreshBalance()} disabled={loadingBalance}>
{loadingBalance ? '查询中...' : '刷新 Comfy Cloud 余额'}
</button>
</div>
{balanceError && <div className="alert alert-error" style={{ marginBottom: 16 }}>{balanceError}</div>}
{diagnostics && (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(190px, 1fr))',
gap: 12,
marginBottom: 16,
}}>
<Field label="diagnostics.tokenKind" value={String(diagnostics.tokenKind || '--')} />
<Field label="diagnostics.tokenSource" value={String(diagnostics.tokenSource || '--')} />
<Field label="diagnostics.httpStatus" value={formatNumber(diagnostics.httpStatus)} />
<Field label="diagnostics.apiBaseUrl" value={String(diagnostics.apiBaseUrl || '--')} />
<Field label="diagnostics.jwtIssuer" value={String(diagnostics.jwtIssuer || '--')} />
<Field label="diagnostics.jwtAudience" value={String(diagnostics.jwtAudience || '--')} />
<Field label="diagnostics.upstreamMessage" value={String(diagnostics.upstreamMessage || '--')} />
</div>
)}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(190px, 1fr))',
gap: 12,
}}>
<BalanceField label="amountMicros" value={balance?.amountMicros} currency={balanceCurrency} />
<BalanceField label="effectiveBalanceMicros" value={balance?.effectiveBalanceMicros} currency={balanceCurrency} />
<BalanceField label="prepaidBalanceMicros" value={balance?.prepaidBalanceMicros} currency={balanceCurrency} />
<BalanceField label="cloudCreditBalanceMicros" value={balance?.cloudCreditBalanceMicros} currency={balanceCurrency} />
<BalanceField label="pendingChargesMicros" value={balance?.pendingChargesMicros} currency={balanceCurrency} />
<Field label="currency" value={balanceCurrency || '--'} />
</div>
</section>
<section style={{
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
background: 'var(--color-panel)',
padding: 18,
}} aria-label="Comfy Cloud 充值">
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>Comfy Cloud</div>
<strong style={{ fontSize: 18, color: 'var(--color-text-primary)' }}>充值积分</strong>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 6 }}>
直接调用 Comfy Cloud 的 customer credit API 创建一笔充值会话,新窗口打开后用绑定的支付方式付款即可到账。需要 Comfy Cloud 登录凭证已配置(上方 Refresh Token / Customer Token / Token Endpoint 任一)。
</div>
</div>
{rechargeError && <div className="alert alert-error" style={{ marginBottom: 12 }}>{rechargeError}</div>}
{rechargeMessage && <div className="alert alert-success" style={{ marginBottom: 12 }}>{rechargeMessage}</div>}
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 180 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>金额(USD)</span>
<input
className="input"
type="number"
min="1"
step="1"
value={rechargeAmount}
onChange={(event) => setRechargeAmount(event.target.value)}
placeholder="例如 10"
/>
</label>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{[5, 10, 20, 50, 100].map((preset) => (
<button
key={preset}
type="button"
className="btn btn-ghost"
onClick={() => setRechargeAmount(String(preset))}
disabled={rechargeLoading}
>
${preset}
</button>
))}
</div>
<button
type="button"
className="btn btn-primary"
onClick={() => void startRecharge()}
disabled={rechargeLoading || !customerTokenConfigured}
>
{rechargeLoading ? '生成 Checkout 链接...' : '立即充值'}
</button>
</div>
{customerTokenConfigured === false && (
<div className="alert alert-warning" style={{ marginTop: 12 }}>
还没配置 Comfy Cloud 登录凭证,无法创建充值会话。请先回到上方完成 Google 登录或粘贴 Refresh Token / Customer Token。
</div>
)}
</section>
<section style={{
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
background: 'var(--color-panel)',
padding: 18,
}} aria-label="用户充值记录">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>用户充值</div>
<strong style={{ fontSize: 18, color: 'var(--color-text-primary)' }}>充值记录与确认到账</strong>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 6 }}>
Comfy Cloud 支付无自动回调。用户付款后,在此点「确认到账」给对应用户的 API Key 增加额度(独立计费池)。
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<button
type="button"
className="btn btn-ghost"
onClick={() => void loadRechargeRecords()}
disabled={recordsLoading || clearingRecords}
>
{recordsLoading ? '加载中...' : '刷新记录'}
</button>
<button
type="button"
className="btn btn-ghost"
onClick={exportRechargeRecords}
disabled={rechargeRecords.length === 0}
>
导出
</button>
<button
type="button"
className="btn btn-ghost"
onClick={() => void clearFinishedRechargeRecords()}
disabled={finishedRechargeCount === 0 || clearingRecords}
>
{clearingRecords ? '清理中...' : `清理已结束 ${finishedRechargeCount}`}
</button>
</div>
</div>
{recordsError && <div className="alert alert-error" style={{ marginBottom: 12 }}>{recordsError}</div>}
{rechargeRecords.length === 0 ? (
<div className="empty-state" style={{ minHeight: 120 }}>
<div className="empty-state-title">暂无充值记录</div>
<div className="empty-state-desc">用户在充值页发起充值后会出现在这里。</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{rechargeRecords.map((rec) => {
const pending = rec.status === 'pending';
const tone = rec.status === 'completed' ? 'badge badge-success'
: rec.status === 'cancelled' ? 'badge badge-warning' : 'badge';
return (
<div key={rec.publicId} style={{
border: '1px solid var(--color-border-light)',
borderRadius: 'var(--radius-sm)',
background: 'var(--color-surface)',
padding: 12,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
flexWrap: 'wrap',
}}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>
{rec.userName || rec.userEmail || `用户 #${rec.userId}`}
<span style={{ marginLeft: 8, fontSize: 13, color: 'var(--color-primary)' }}>
${typeof rec.amountUsd === 'number' ? rec.amountUsd : '--'}
</span>
<span className={tone} style={{ marginLeft: 8 }}>{rec.status}</span>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
{rec.provider} · 创建 {formatRechargeRecordTime(rec.createdAt)}
{rec.completedAt ? ` · 完成 ${formatRechargeRecordTime(rec.completedAt)}` : ''}
{' · '}
<code style={{ fontSize: 11 }}>{rec.publicId}</code>
</div>
</div>
{pending ? (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<button
type="button"
className="btn btn-primary"
onClick={() => void confirmRecord(rec.publicId)}
disabled={confirmingId === rec.publicId || cancellingId === rec.publicId}
>
{confirmingId === rec.publicId ? '确认中...' : '确认到账'}
</button>
<button
type="button"
className="btn btn-ghost"
onClick={() => void cancelRecord(rec.publicId)}
disabled={confirmingId === rec.publicId || cancellingId === rec.publicId}
>
{cancellingId === rec.publicId ? '取消中...' : '取消支付'}
</button>
</div>
) : (
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{rec.status === 'completed' ? '已到账' : '—'}
</span>
)}
</div>
);
})}
</div>
)}
</section>
</div>
);
}
+43 -7
View File
@@ -985,20 +985,56 @@ export default function PictureAgent() {
return (
<div className="picture-agent-page">
<div className="page-header">
<div>
<h1 className="page-title">生图助手</h1>
<p className="page-subtitle">
{modeLabel} · {status?.upstreamLabel || 'BoosAPI 本地图片路由'} · {status?.keyName || '加载密钥中'}
</p>
<section className="console-hero">
<div className="console-hero-copy">
<span className="console-kicker">Picture Studio</span>
<h1>生图助手</h1>
<p>{modeLabel} · {status?.upstreamLabel || 'BoosAPI 本地图片路由'} · {status?.keyName || '加载密钥中'}</p>
</div>
<div className="page-actions">
<div className="console-hero-rail" aria-label="生图助手摘要">
<div>
<span>队列</span>
<strong>{activeJobs.length}</strong>
</div>
<div>
<span>已完成</span>
<strong>{jobs.filter((job) => job.status === 'succeeded').length}</strong>
</div>
<div>
<span>图库</span>
<strong>{images.length}</strong>
</div>
</div>
<div className="console-hero-actions">
<span className="picture-agent-status-pill">{audience === 'user' ? '普通用户' : '管理员'}</span>
<select className="picture-agent-select" value={model} onChange={(event) => setModel(event.target.value)}>
{models.length === 0 && <option value={model || 'gpt-image-2'}>{model || 'gpt-image-2'}</option>}
{models.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</div>
</section>
<div className="picture-agent-summary">
<div className="picture-agent-summary-card">
<span>当前模型</span>
<strong>{model || '默认'}</strong>
<em>{status?.upstreamLabel || 'BoosAPI 图片路由'}</em>
</div>
<div className="picture-agent-summary-card">
<span>进行中</span>
<strong>{activeJobs.length} 个</strong>
<em>{activeJobs.length > 0 ? '生成中…' : '队列空闲'}</em>
</div>
<div className="picture-agent-summary-card">
<span>已完成</span>
<strong>{jobs.filter((job) => job.status === 'succeeded').length} 个</strong>
<em>{jobs.filter((job) => job.status === 'failed' || job.status === 'cancelled').length} 个失败/取消</em>
</div>
<div className="picture-agent-summary-card">
<span>图库</span>
<strong>{images.length} 张</strong>
<em>最多保留 48 张</em>
</div>
</div>
<div className="picture-agent-shell">
+52 -12
View File
@@ -81,6 +81,7 @@ type RuntimeSettings = {
logCleanupProgramLogsEnabled: boolean;
logCleanupRetentionDays: number;
modelAvailabilityProbeEnabled: boolean;
registrationDisabled: boolean;
codexUpstreamWebsocketEnabled: boolean;
responsesCompactFallbackToResponsesEnabled: boolean;
disableCrossProtocolFallback: boolean;
@@ -363,6 +364,7 @@ export default function Settings() {
logCleanupProgramLogsEnabled: false,
logCleanupRetentionDays: 30,
modelAvailabilityProbeEnabled: false,
registrationDisabled: false,
codexUpstreamWebsocketEnabled: false,
responsesCompactFallbackToResponsesEnabled: false,
disableCrossProtocolFallback: false,
@@ -386,6 +388,7 @@ export default function Settings() {
const [savingToken, setSavingToken] = useState(false);
const [savingSystemProxy, setSavingSystemProxy] = useState(false);
const [savingModelAvailabilityProbe, setSavingModelAvailabilityProbe] = useState(false);
const [savingRegistrationDisabled, setSavingRegistrationDisabled] = useState(false);
const [savingProxyTransport, setSavingProxyTransport] = useState(false);
const [testingSystemProxy, setTestingSystemProxy] = useState(false);
const [systemProxyTestState, setSystemProxyTestState] = useState<SystemProxyTestState>(null);
@@ -748,7 +751,7 @@ export default function Settings() {
? `${runtime.adminIpAllowlist.length} 条白名单`
: '未限制 IP';
const modelGovernanceSummary = [
blockedBrands.length > 0 ? `${blockedBrands.length} 个品牌屏蔽` : '',
blockedBrands.length > 0 ? `${blockedBrands.length} 个推荐屏蔽词` : '',
allowedModels.length > 0 ? `${allowedModels.length} 个模型白名单` : '',
].filter(Boolean).join(' / ') || '全部模型开放';
const settingsSections: SettingsNavItem[] = [
@@ -861,6 +864,7 @@ export default function Settings() {
? Math.trunc(Number(runtimeInfo.logCleanupRetentionDays))
: 30,
modelAvailabilityProbeEnabled: !!runtimeInfo.modelAvailabilityProbeEnabled,
registrationDisabled: !!runtimeInfo.registrationDisabled,
codexUpstreamWebsocketEnabled: !!runtimeInfo.codexUpstreamWebsocketEnabled,
responsesCompactFallbackToResponsesEnabled: !!runtimeInfo.responsesCompactFallbackToResponsesEnabled,
disableCrossProtocolFallback: !!runtimeInfo.disableCrossProtocolFallback,
@@ -1061,6 +1065,24 @@ export default function Settings() {
}
};
const persistRegistrationDisabled = async (disabled: boolean) => {
setSavingRegistrationDisabled(true);
// Optimistic toggle so the checkbox responds immediately.
setRuntime((prev) => ({ ...prev, registrationDisabled: disabled }));
try {
const res = await api.updateRuntimeSettings({ registrationDisabled: disabled });
const next = typeof res?.registrationDisabled === 'boolean' ? res.registrationDisabled : disabled;
setRuntime((prev) => ({ ...prev, registrationDisabled: next }));
toast.success(next ? '已禁止用户注册' : '已允许用户注册');
} catch (err: any) {
// Revert on failure.
setRuntime((prev) => ({ ...prev, registrationDisabled: !disabled }));
toast.error(err?.message || '保存失败');
} finally {
setSavingRegistrationDisabled(false);
}
};
const saveModelAvailabilityProbeSettings = async () => {
if (runtime.modelAvailabilityProbeEnabled === savedModelAvailabilityProbeEnabled) {
toast.info('批量测活设置未变化');
@@ -1277,15 +1299,15 @@ export default function Settings() {
const resolved = Array.isArray(res?.globalBlockedBrands) ? res.globalBlockedBrands : blockedBrands;
setRuntime((prev) => ({ ...prev, globalBlockedBrands: resolved }));
setBlockedBrands(resolved);
toast.success('品牌屏蔽设置已保存');
toast.success('模型品牌推荐屏蔽词已保存');
try {
await api.rebuildRoutes(false);
toast.success('路由已重建');
} catch {
toast.error('品牌屏蔽已保存,但路由重建失败,请手动重建');
toast.error('模型品牌推荐屏蔽词已保存,但路由重建失败,请手动重建');
}
} catch (err: any) {
toast.error(err?.message || '保存品牌屏蔽设置失败');
toast.error(err?.message || '保存模型品牌推荐屏蔽词失败');
} finally {
setSavingBrandFilter(false);
}
@@ -2572,17 +2594,17 @@ export default function Settings() {
</div>
</div>
{/* Global Brand Filter */}
{/* Model Brand Recommendation Filter */}
<div id="settings-governance" className="card animate-slide-up stagger-6" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }}>
<div style={settingsModernHeaderStyle}>
<div style={settingsModernTitleBlockStyle}>
<div style={settingsModernTitleStyle}>全局品牌屏蔽</div>
<div style={settingsModernTitleStyle}>模型品牌推荐屏蔽词</div>
<div style={settingsModernDescriptionStyle}>
屏蔽选定品牌后,路由重建时将自动跳过匹配该品牌的所有模型。点击品牌切换屏蔽状态,保存后自动触发路由重建。
用于 LLM 对话推荐和模型路由推荐:选中某个模型品牌后,路由重建会跳过匹配该品牌的模型。这里不是屏蔽 BoosAPI 平台品牌。
</div>
</div>
<span style={getSettingsPillStyle(blockedBrands.length > 0 ? 'warning' : 'neutral')}>
{blockedBrands.length > 0 ? `已屏蔽 ${blockedBrands.length}` : '未屏蔽'}
{blockedBrands.length > 0 ? `已设置 ${blockedBrands.length}` : '未设置'}
</span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
@@ -2612,19 +2634,19 @@ export default function Settings() {
);
})}
{allBrandNames === null && (
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>加载品牌列表中...</span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>加载模型品牌中...</span>
)}
{allBrandNames !== null && allBrandNames.length === 0 && (
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无可用品牌</span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无可用模型品牌</span>
)}
</div>
{blockedBrands.length > 0 && (
<div style={{ fontSize: 12, color: 'var(--color-warning)', marginBottom: 10 }}>
已屏蔽 {blockedBrands.length} 个品牌:{blockedBrands.join('、')}
已设置 {blockedBrands.length} 个模型品牌推荐屏蔽词:{blockedBrands.join('、')}
</div>
)}
<button onClick={handleSaveBrandFilter} disabled={savingBrandFilter} className="btn btn-primary" style={{ fontSize: 12, padding: '6px 16px' }}>
{savingBrandFilter ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存品牌屏蔽'}
{savingBrandFilter ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存模型品牌推荐屏蔽词'}
</button>
</div>
@@ -2970,6 +2992,24 @@ export default function Settings() {
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
登录会话默认 12 小时自动过期。可选配置管理端 IP 白名单,支持每行一个 IP 或 IPv4 CIDR 网段。
</div>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 10, padding: '12px 14px', background: 'var(--color-bg)', borderRadius: 'var(--radius-sm)', border: '1px solid var(--color-border-light)', marginBottom: 14, cursor: 'pointer' }}>
<input
type="checkbox"
checked={runtime.registrationDisabled}
disabled={savingRegistrationDisabled}
onChange={(e) => void persistRegistrationDisabled(e.target.checked)}
style={{ marginTop: 2 }}
/>
<span>
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--color-text-primary)' }}>禁止用户自助注册</span>
<span style={{ display: 'block', fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4, lineHeight: 1.6 }}>
开启后 <code style={{ fontFamily: 'var(--font-mono)' }}>/api/users/register</code> 会直接拒绝(403),仅管理员可在后台手动创建用户。已注册用户不受影响。
{savingRegistrationDisabled ? ' 保存中...' : ''}
</span>
</span>
</label>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
当前识别到的管理端 IP(由服务端判定):
</div>
+10 -2
View File
@@ -706,6 +706,7 @@ const SITE_PLATFORM_OPTIONS = [
{ value: 'claude', label: 'claude', description: '通用 Claude / Anthropic 兼容接口' },
{ value: 'gemini', label: 'gemini', description: '通用 Gemini / Google AI 兼容接口' },
{ value: 'cliproxyapi', label: 'cliproxyapi', description: 'CPA接入口' },
{ value: 'comfy_cloud', label: 'Comfy Cloud', description: '工作流执行 API(数字人/图片视频)' },
];
export default function Sites() {
@@ -959,8 +960,15 @@ export default function Sites() {
return;
}
setEditor({ mode: 'add' });
setForm(createEmptySiteForm());
setSelectedInitializationPresetId(null);
const base = createEmptySiteForm();
const comfyPreset = getSiteInitializationPreset('comfy-cloud');
if (comfyPreset) {
setForm({ ...base, platform: comfyPreset.platform, url: comfyPreset.defaultUrl });
setSelectedInitializationPresetId(comfyPreset.id);
} else {
setForm(base);
setSelectedInitializationPresetId(null);
}
scrollToEditorTop();
};
+193
View File
@@ -0,0 +1,193 @@
import { useCallback, useEffect, useState } from 'react';
import { api } from '../api.js';
import PageHeader from '../components/PageHeader.js';
type BalanceRow = {
userId: number;
username?: string | null;
email?: string | null;
role?: string | null;
status?: string | null;
keyCount: number;
usedCost: number;
maxCost: number | null;
remainingCost: number | null;
unlimitedKeyCount: number;
};
function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message) return error.message;
if (typeof error === 'string' && error.trim()) return error.trim();
return fallback;
}
function fmtCost(value: number | null | undefined): string {
if (value == null || !Number.isFinite(value)) return '不限';
return `$${value.toFixed(6)}`;
}
export default function UserBalances() {
const [rows, setRows] = useState<BalanceRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [q, setQ] = useState('');
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [adjustingId, setAdjustingId] = useState<number | null>(null);
const [adjustInputs, setAdjustInputs] = useState<Record<number, string>>({});
const [message, setMessage] = useState('');
const load = useCallback(async (targetPage: number, query: string) => {
setLoading(true);
setError('');
try {
const res = await api.listUserBalances({ page: targetPage, pageSize: 50, q: query || undefined });
setRows(Array.isArray(res?.data) ? res.data : []);
setTotalPages(res?.meta?.totalPages || 1);
setPage(res?.meta?.page || targetPage);
} catch (err) {
setError(getErrorMessage(err, '加载用户余额失败'));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load(1, '');
}, [load]);
const applyAdjust = useCallback(async (userId: number, sign: 1 | -1) => {
const raw = (adjustInputs[userId] || '').trim();
const amount = Number(raw);
if (!Number.isFinite(amount) || amount <= 0) {
setError('请输入正数金额');
return;
}
setAdjustingId(userId);
setError('');
setMessage('');
try {
const res = await api.adjustUserBalance(userId, sign * amount);
setMessage(`用户 #${userId} 额度已${sign > 0 ? '增加' : '扣减'} $${amount},当前剩余 ${fmtCost(res.data.remainingCost)}`);
setAdjustInputs((prev) => ({ ...prev, [userId]: '' }));
await load(page, q);
} catch (err) {
setError(getErrorMessage(err, '调整额度失败'));
} finally {
setAdjustingId(null);
}
}, [adjustInputs, load, page, q]);
return (
<div className="animate-fade-in admin-ops-page user-balances-ops-page" style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<PageHeader
title="用户余额管理"
subtitle="查看每个用户的额度池(API Key 的 max_cost − used_cost),可直接增减额度。"
actions={(
<div className="page-actions user-balances-page-actions" style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<input
className="input"
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') void load(1, q); }}
placeholder="搜索用户名 / 邮箱 / ID"
style={{ minWidth: 220 }}
/>
<button type="button" className="btn btn-ghost" onClick={() => void load(1, q)} disabled={loading}>
{loading ? '加载中...' : '搜索'}
</button>
</div>
)}
/>
{error && <div className="alert alert-error">{error}</div>}
{message && <div className="alert alert-success">{message}</div>}
<section style={{
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
background: 'var(--color-panel)',
padding: 18,
}}>
{rows.length === 0 ? (
<div className="empty-state" style={{ minHeight: 160 }}>
<div className="empty-state-title">{loading ? '加载中...' : '没有用户'}</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{rows.map((row) => (
<div key={row.userId} style={{
border: '1px solid var(--color-border-light)',
borderRadius: 'var(--radius-sm)',
background: 'var(--color-surface)',
padding: 12,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
flexWrap: 'wrap',
}}>
<div style={{ minWidth: 200 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>
{row.username || row.email || `用户 #${row.userId}`}
<span style={{ marginLeft: 8, fontSize: 12, color: 'var(--color-text-muted)' }}>#{row.userId}</span>
{row.role === 'admin' && <span className="badge" style={{ marginLeft: 8 }}>管理员</span>}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
{row.email || ''} · {row.keyCount} 个 Key
</div>
</div>
<div style={{ display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>剩余 / 上限</div>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--color-primary)' }}>
{fmtCost(row.remainingCost)} / {fmtCost(row.maxCost)}
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>已用 {fmtCost(row.usedCost)}</div>
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input
className="input"
type="number"
min="0"
step="1"
value={adjustInputs[row.userId] || ''}
onChange={(e) => setAdjustInputs((prev) => ({ ...prev, [row.userId]: e.target.value }))}
placeholder="$"
style={{ width: 90 }}
disabled={adjustingId === row.userId}
/>
<button
type="button"
className="btn btn-primary"
onClick={() => void applyAdjust(row.userId, 1)}
disabled={adjustingId === row.userId}
>
{adjustingId === row.userId ? '...' : '加额度'}
</button>
<button
type="button"
className="btn btn-ghost"
onClick={() => void applyAdjust(row.userId, -1)}
disabled={adjustingId === row.userId}
>
扣减
</button>
</div>
</div>
</div>
))}
</div>
)}
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 14 }}>
<button type="button" className="btn btn-ghost" disabled={page <= 1 || loading} onClick={() => void load(page - 1, q)}>上一页</button>
<span style={{ fontSize: 13, color: 'var(--color-text-muted)', alignSelf: 'center' }}>{page} / {totalPages}</span>
<button type="button" className="btn btn-ghost" disabled={page >= totalPages || loading} onClick={() => void load(page + 1, q)}>下一页</button>
</div>
)}
</section>
</div>
);
}
+11
View File
@@ -80,6 +80,15 @@ describe('UserDashboard recharge entry', () => {
successRequests: 4,
totalCost: 0.12,
},
billing: {
keyCount: 1,
usedCost: 0.12,
maxCost: 50,
remainingCost: 49.88,
unlimitedKeyCount: 0,
rechargePath: '/user/recharge',
usagePath: '/user/usage',
},
recentLogs: [],
});
});
@@ -107,6 +116,8 @@ describe('UserDashboard recharge entry', () => {
expect(text).toContain('用户自助工作台');
expect(text).toContain('密钥、额度、使用日志一处处理');
expect(text).toContain('1/1 个密钥启用');
expect(text).toContain('剩余 10,525 积分');
expect(text).toContain('当前剩余:10,525 积分');
expect(text).toContain('已消耗 $0.120000');
expect(text).toContain('调用状态正常');
expect(root.root.findAll((node) => isInternalLinkTo(node, '/user/dashboard/keys')).length).toBeGreaterThanOrEqual(1);
+11 -4
View File
@@ -1,9 +1,10 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { userApi } from '../api.js';
import { userApi, type UserBillingSummary } from '../api.js';
import { clearSession, getSessionToken } from '../authSession.js';
import UserAvatar from '../components/UserAvatar.js';
import UserRechargeOptions from '../components/UserRechargeOptions.js';
import { formatRechargePointBalance } from '../userRecharge.js';
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
interface UserProfile {
@@ -61,6 +62,7 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
const [profile, setProfile] = useState<UserProfile | null>(null);
const [keys, setKeys] = useState<ApiKey[]>([]);
const [summary, setSummary] = useState<UserSummary | null>(null);
const [billing, setBilling] = useState<UserBillingSummary | null>(null);
const [recentLogs, setRecentLogs] = useState<LogItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -82,6 +84,7 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
setProfile(dashboardRes.user || null);
setKeys(Array.isArray(dashboardRes.keys) ? dashboardRes.keys : []);
setSummary(dashboardRes.summary || { totalRequests: 0, successRequests: 0, totalCost: 0 });
setBilling(dashboardRes.billing || null);
setRecentLogs(Array.isArray(dashboardRes.recentLogs) ? dashboardRes.recentLogs : []);
} catch (err: any) {
setError(err?.message || '加载失败');
@@ -91,6 +94,7 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
}
setKeys([]);
setSummary({ totalRequests: 0, successRequests: 0, totalCost: 0 });
setBilling(null);
setRecentLogs([]);
} finally {
setLoading(false);
@@ -139,6 +143,9 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
const failedRequests = Math.max(0, (summary?.totalRequests || 0) - (summary?.successRequests || 0));
const activeKeyCount = keys.filter((key) => key.enabled).length;
const lastActivityLabel = recentLogs[0] ? formatDateLabel(recentLogs[0].created_at) : '暂无调用';
const pointBalanceLabel = billing
? formatRechargePointBalance(billing.remainingCost, billing.maxCost)
: '等待余额同步';
const primarySuggestion = activeKeyCount === 0
? {
title: '先创建一个 API 密钥',
@@ -208,8 +215,8 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
},
{
label: '额度',
title: `已消耗 ${formatMoney(summary?.totalCost)}`,
detail: '系统内选择充值档位,再跳转 Comfy Checkout 完成付款。',
title: `剩余 ${pointBalanceLabel}`,
detail: `已消耗 ${formatMoney(summary?.totalCost)},系统内选择充值档位后跳转 Comfy Checkout。`,
to: '/user/recharge',
action: '去充值',
},
@@ -271,7 +278,7 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
<div>
<span>账户充值</span>
<strong>添加积分后继续调用模型与 Comfy API</strong>
<p>进入系统内充值页确认额度后,再跳转到 Comfy Checkout 完成付款。</p>
<p>当前剩余:{pointBalanceLabel}。进入系统内充值页确认额度后,再跳转到 Comfy Checkout 完成付款。</p>
</div>
<UserRechargeOptions internalOnly />
</section>
+1
View File
@@ -127,6 +127,7 @@ describe('UserKeys billing center', () => {
expect(text).toContain('20 / 100');
expect(text).toContain('$0.120000 / $1.500');
expect(text).toContain('账单累计 $0.456000');
expect(text).toContain('当前剩余 291 积分');
expect(text).toContain('1,280 tokens');
expect(text).toContain('计费运行建议');
expect(text).toContain('额度状态正常');
+8 -2
View File
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { userApi } from '../api.js';
import UserRechargeOptions from '../components/UserRechargeOptions.js';
import { USER_RECHARGE_CONFIG } from '../userRecharge.js';
import { USER_RECHARGE_CONFIG, formatRechargePointBalance } from '../userRecharge.js';
import { buildUserUsageHref } from './helpers/userUsageLinks.js';
interface ApiKey {
@@ -421,6 +421,9 @@ export default function UserKeys() {
const usedCost = keys.reduce((sum, item) => sum + asNumber(item.usedCost), 0);
const maxCost = keys.reduce((sum, item) => sum + (hasPositiveLimit(item.maxCost) ? Number(item.maxCost) : 0), 0);
const hasUnlimitedCost = keys.some((item) => !hasPositiveLimit(item.maxCost));
const limitedCostKeys = keys.filter((item) => hasPositiveLimit(item.maxCost));
const limitedCostLimit = limitedCostKeys.reduce((sum, item) => sum + Number(item.maxCost), 0);
const limitedCostUsed = limitedCostKeys.reduce((sum, item) => sum + asNumber(item.usedCost), 0);
const totalLogs = asNumber(usageSummary?.total, recentLogs.length);
const failedLogs = asNumber(usageSummary?.failed, recentLogs.filter((log) => log.status !== 'success').length);
const totalCost = asNumber(usageSummary?.total_cost ?? usageSummary?.totalCost, usedCost);
@@ -438,6 +441,8 @@ export default function UserKeys() {
? `${formatCount(usedRequests)} / 不限`
: `${formatCount(usedRequests)} / ${formatCount(maxRequests)}`,
usedCost,
remainingCost: limitedCostLimit > 0 ? Math.max(0, limitedCostLimit - limitedCostUsed) : null,
maxCost: limitedCostLimit > 0 ? limitedCostLimit : null,
costLabel: hasUnlimitedCost || !maxCost
? `${formatMoney(usedCost)} / 不限`
: `${formatMoney(usedCost)} / ${formatMoney(maxCost)}`,
@@ -474,7 +479,8 @@ export default function UserKeys() {
<section className="user-keys-recharge-panel" aria-label="账户充值">
<div>
<span>账户余额</span>
<strong>需要更多额度时,可跳转到 {recharge.providerLabel} 添加积分。</strong>
<strong>当前剩余 {formatRechargePointBalance(overview.remainingCost, overview.maxCost)}</strong>
<p>需要更多额度时,可跳转到 {recharge.providerLabel} 添加积分。</p>
</div>
<UserRechargeOptions compact internalOnly />
</section>
+50 -2
View File
@@ -125,15 +125,19 @@ describe('UserRecharge page', () => {
));
const primaryButton = root!.root.find((node) => (
node.type === 'button'
&& collectText(node).includes('创建记录并支付')
&& String(node.props.className || '').includes('user-recharge-primary-action')
));
expect(text).toContain('账户充值');
expect(text).toContain('为模型和 Comfy API 添加积分');
expect(text).toContain('$1 = 211 积分');
expect(text).toContain('10,550 积分');
expect(text).toContain(USER_RECHARGE_CONFIG.providerLabel);
expect(text).toContain('充值记录');
expect(text).toContain('rch_test_1');
expect(text).toContain('付款后由管理员核对到账。');
expect(text).toContain('291 积分');
expect(text).toContain('已用 $0.1200 / 剩余 $1.3800');
expect(optionButtons).toHaveLength(payableOptions.length);
expect(text).not.toContain('待配置');
expect(disabledOptions).toHaveLength(0);
@@ -195,6 +199,50 @@ describe('UserRecharge page', () => {
}
});
it('calculates points for a custom amount before creating checkout', async () => {
let root: ReturnType<typeof create>;
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/user/recharge']}>
<UserRecharge />
</MemoryRouter>,
);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
try {
const amountInput = root!.root.find((node) => (
node.type === 'input'
&& node.props['aria-label'] === '自定义充值金额,单位美元'
));
await act(async () => {
amountInput.props.onChange({ target: { value: '12' } });
});
expect(collectText(root!.root)).toContain('2,532 积分');
const customButton = root!.root.find((node) => (
node.type === 'button'
&& collectText(node).includes('按自定义金额支付')
));
await act(async () => {
customButton.props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(userApiMock.createRechargeCheckout).toHaveBeenCalledWith({
optionId: 'custom-usd-12',
amountUsd: 12,
});
} finally {
root!.unmount();
}
});
it('falls back to current-tab navigation when the checkout popup is blocked', async () => {
(window.open as any).mockReturnValueOnce(null);
let root: ReturnType<typeof create>;
@@ -213,7 +261,7 @@ describe('UserRecharge page', () => {
try {
const primaryButton = root!.root.find((node) => (
node.type === 'button'
&& collectText(node).includes('创建记录并支付')
&& String(node.props.className || '').includes('user-recharge-primary-action')
));
await act(async () => {
+63 -6
View File
@@ -2,7 +2,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { userApi, type UserRechargeRecord } from '../api.js';
import UserRechargeOptions from '../components/UserRechargeOptions.js';
import { USER_RECHARGE_CONFIG, type UserRechargeCheckoutOption } from '../userRecharge.js';
import {
USER_RECHARGE_CONFIG,
USER_RECHARGE_POINTS_PER_USD,
calculateRechargePoints,
formatRechargePointBalance,
formatRechargePoints,
type UserRechargeCheckoutOption,
} from '../userRecharge.js';
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
function formatUsd(value?: number | null) {
@@ -11,6 +18,15 @@ function formatUsd(value?: number | null) {
: '不限';
}
function customRechargeOption(amountUsd: number): UserRechargeCheckoutOption {
return {
id: `custom-usd-${amountUsd}`,
label: `$${amountUsd.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`,
title: '自定义金额',
amountUsd,
};
}
function formatTime(value?: string | null) {
return value ? formatDateTimeLocal(value) : '--';
}
@@ -52,6 +68,7 @@ export default function UserRecharge() {
const [cancellingId, setCancellingId] = useState<string | null>(null);
const [clearing, setClearing] = useState(false);
const [error, setError] = useState('');
const [customAmount, setCustomAmount] = useState('10');
const refresh = useCallback(async () => {
setLoading(true);
@@ -76,6 +93,10 @@ export default function UserRecharge() {
const remaining = billing.remainingCost == null ? '不限' : formatUsd(billing.remainingCost);
return `已用 ${formatUsd(billing.usedCost)} / 剩余 ${remaining}`;
}, [billing]);
const pointBalanceText = useMemo(() => {
if (!billing) return '等待余额同步';
return formatRechargePointBalance(billing.remainingCost, billing.maxCost);
}, [billing]);
const openCheckout = async (option: UserRechargeCheckoutOption) => {
if (pendingOptionId) return;
@@ -146,6 +167,16 @@ export default function UserRecharge() {
};
const defaultOption = recharge.defaultOption;
const customAmountValue = Number(customAmount);
const customAmountValid = Number.isFinite(customAmountValue) && customAmountValue > 0;
const customPoints = customAmountValid ? calculateRechargePoints(customAmountValue) : 0;
const openCustomCheckout = () => {
if (!customAmountValid) {
setError('请输入有效的充值金额(USD)。');
return;
}
void openCheckout(customRechargeOption(Number(customAmountValue.toFixed(2))));
};
return (
<div className="page-container user-recharge-page animate-fade-in">
@@ -155,7 +186,7 @@ export default function UserRecharge() {
<h1>为模型和 Comfy API 添加积分</h1>
<p>
先创建系统内充值记录,再跳转到 {recharge.providerLabel} 完成付款。
充值记录会和使用记录一起保留,便于到账核对。
按 $1 = {USER_RECHARGE_POINTS_PER_USD} 积分换算,充值记录会和使用记录一起保留,便于到账核对。
</p>
</div>
<button
@@ -165,7 +196,7 @@ export default function UserRecharge() {
disabled={pendingOptionId === defaultOption.id}
onClick={() => void openCheckout(defaultOption)}
>
{pendingOptionId === defaultOption.id ? '创建记录中...' : '创建记录并支付'}
{pendingOptionId === defaultOption.id ? '创建记录中...' : `支付 ${defaultOption.label} · ${formatRechargePoints(defaultOption.amountUsd)}`}
</button>
</header>
@@ -175,7 +206,7 @@ export default function UserRecharge() {
<div className="user-recharge-section-head">
<div>
<span>充值额度</span>
<strong>先落库记录,再打开 Comfy Checkout</strong>
<strong>$1 = {USER_RECHARGE_POINTS_PER_USD} 积分,付款前先确认预计到账</strong>
</div>
<small>{recharge.providerLabel}</small>
</div>
@@ -184,13 +215,39 @@ export default function UserRecharge() {
onPay={openCheckout}
pendingOptionId={pendingOptionId}
/>
<div className="user-recharge-custom" aria-label="自定义充值金额">
<div>
<span>自定义金额</span>
<strong>{customAmountValid ? `${customPoints.toLocaleString('zh-CN')} 积分` : '输入金额后计算'}</strong>
<p>按美元金额实时换算,提交后同样会先创建系统充值记录。</p>
</div>
<label>
<span>USD</span>
<input
type="number"
min="1"
step="1"
value={customAmount}
onChange={(event) => setCustomAmount(event.target.value)}
aria-label="自定义充值金额,单位美元"
/>
</label>
<button
type="button"
className="btn btn-primary"
disabled={!customAmountValid || Boolean(pendingOptionId)}
onClick={openCustomCheckout}
>
{pendingOptionId?.startsWith('custom-usd-') ? '创建记录中...' : '按自定义金额支付'}
</button>
</div>
</section>
<section className="user-recharge-info-grid" aria-label="充值说明">
<div>
<span>余额状态</span>
<strong>{billingText}</strong>
<p>余额不足时,创作任务会提示回到此页创建充值记录。</p>
<strong>{pointBalanceText}</strong>
<p>{billingText}。余额不足时,创作任务会提示回到此页创建充值记录。</p>
</div>
<div>
<span>到账说明</span>
+10 -11
View File
@@ -56,9 +56,16 @@ function commitLoginSession(
navigate: ReturnType<typeof useNavigate>,
) {
commitSession('user', payload.token);
clearSession('admin');
onLogin?.('user');
navigate('/user/dashboard');
if (payload.role === 'admin') {
// Admin-role user gets both audiences so /admin/* routes work without re-login.
commitSession('admin', payload.token);
onLogin?.('admin');
navigate('/dashboard');
} else {
clearSession('admin');
onLogin?.('user');
navigate('/user/dashboard');
}
}
export default function UserLoginPage({ t, onLogin }: UserLoginPageProps) {
@@ -91,10 +98,6 @@ export default function UserLoginPage({ t, onLogin }: UserLoginPageProps) {
setError(t(resolveLoginErrorMessage(401, '')));
return;
}
if (payload.role === 'admin') {
setError(t('管理员请使用管理员 API Token 登录。'));
return;
}
commitLoginSession(payload, onLogin, navigate);
} catch {
setError(t(resolveLoginErrorMessage('network', '')));
@@ -124,10 +127,6 @@ export default function UserLoginPage({ t, onLogin }: UserLoginPageProps) {
setError(t(resolveLoginErrorMessage(401, '')));
return;
}
if (payload.role === 'admin') {
setError(t('管理员请使用管理员 API Token 登录。'));
return;
}
commitLoginSession(payload, onLogin, navigate);
} catch {
setError(t(resolveLoginErrorMessage('network', '')));
@@ -1,75 +0,0 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('DigitalHuman template picker accessibility', () => {
it('exposes the active workflow template card as a pressed button', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/DigitalHuman.tsx'), 'utf8').replace(/\r\n/g, '\n');
expect(source).toContain("className={`digital-human-template-card${active ? ' is-active' : ''}`}");
expect(source).toContain('aria-pressed={active}');
expect(source).toContain('onClick={() => setTemplateId(workflow.publicId)}');
});
it('keeps the workspace asset picker accessible and keyboard dismissible', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/DigitalHuman.tsx'), 'utf8').replace(/\r\n/g, '\n');
expect(source).toContain('const assetPickerTitleId = useId();');
expect(source).toContain('const assetPickerDialogRef = useRef<HTMLDivElement | null>(null);');
expect(source).toContain('const lastAssetPickerActiveElementRef = useRef<HTMLElement | null>(null);');
expect(source).toContain('const closeAssetPicker = useCallback(() => {');
expect(source).toContain('document.body.style.overflow = ');
expect(source).toContain('dialog.focus({ preventScroll: true });');
expect(source).toContain("event.key === 'Escape'");
expect(source).toContain("import { trapFocusInContainer } from '../components/focusTrap.js';");
expect(source).toContain('trapFocusInContainer(event, assetPickerDialogRef.current);');
expect(source).toContain('lastAssetPickerActiveElementRef.current?.focus({ preventScroll: true });');
expect(source).toContain('role="dialog"');
expect(source).toContain('aria-modal="true"');
expect(source).toContain('aria-labelledby={assetPickerTitleId}');
expect(source).toContain('ref={assetPickerDialogRef}');
expect(source).toContain('tabIndex={-1}');
expect(source).toContain('<h3 id={assetPickerTitleId}>');
});
it('treats output mapping as optional and auto-infers mappings for plain workflow graphs', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/DigitalHuman.tsx'), 'utf8').replace(/\r\n/g, '\n');
expect(source).toContain("const REQUIRED_MAPPING_KEYS: MappingKey[] = ['prompt', 'script', 'avatar', 'referenceAudio'];");
expect(source).toContain('return REQUIRED_MAPPING_KEYS.every((key) => {');
expect(source).not.toContain("['prompt', 'script', 'avatar', 'referenceAudio', 'output'].every");
expect(source).not.toContain("['输出节点', mapped.output.nodeId]");
expect(source).toContain('|| inferMappingFromWorkflow(workflow || null);');
expect(source).toContain("node.inputs && typeof node.inputs === 'object'");
expect(source).toContain("node.params && typeof node.params === 'object'");
expect(source).toContain("['output', '输出视频节点(可选)'],");
expect(source).toContain('输出节点可选,系统会从 Comfy 历史中自动识别产物。');
});
it('links remote Comfy outputs directly while preserving workspace asset links', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/DigitalHuman.tsx'), 'utf8').replace(/\r\n/g, '\n');
expect(source).toContain('function jobAssetHref(asset: any, workspacePath: string) {');
expect(source).toContain('if (asset?.remoteUrl) return String(asset.remoteUrl);');
expect(source).toContain("if (asset?.publicId) return `${workspacePath}?q=${encodeURIComponent(asset.publicId)}`;");
expect(source).toContain("if (asset?.remoteUrl) return `远端产物 · ${kind}`;");
expect(source).toContain("target={asset.remoteUrl ? '_blank' : undefined}");
expect(source).toContain("rel={asset.remoteUrl ? 'noreferrer' : undefined}");
expect(source).not.toContain('href={`${workspacePath}?q=${encodeURIComponent(asset.publicId)}`}');
});
it('surfaces the digital human Comfy status contract in the page and API client', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/DigitalHuman.tsx'), 'utf8').replace(/\r\n/g, '\n');
const api = readFileSync(resolve(process.cwd(), 'src/web/api.ts'), 'utf8').replace(/\r\n/g, '\n');
expect(api).toContain('export type DigitalHumanStatusResponse = {');
expect(api).toContain('request<DigitalHumanStatusResponse>("/api/digital-human/status")');
expect(api).toContain('userRequest<DigitalHumanStatusResponse>("/api/digital-human/status")');
expect(source).toContain('type DigitalHumanStatusResponse');
expect(source).toContain('const [status, setStatus] = useState<DigitalHumanStatusResponse | null>(null);');
expect(source).toContain('client.getDigitalHumanStatus(),');
expect(source).toContain('<div className="digital-human-summary">');
expect(source).toContain('Comfy 状态');
expect(source).toContain('status?.keyName ? `使用 ${status.keyName}` :');
});
});
@@ -21,4 +21,16 @@ describe('PictureAgent mobile layout', () => {
expect(css).not.toContain('min-height: 340px;\n padding: 10px;');
expect(css).not.toContain('min-height: 300px;\n display: grid;');
});
it('keeps generation controls breathable in narrow side panels', () => {
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
expect(css).toContain('.picture-agent-tabs + [role="tabpanel"] {\n display: grid;\n gap: 14px;');
expect(css).toContain('.picture-agent-presets {\n display: grid;\n grid-template-columns: repeat(4, minmax(0, 1fr));');
expect(css).toContain('.picture-agent-size-row {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));');
expect(css).toContain('.picture-agent-size-preview {\n grid-column: 1 / -1;');
expect(css).toContain('.picture-agent-submit {\n min-height: 44px;');
expect(css).toMatch(/@media \(max-width: 640px\) \{[\s\S]*?\.picture-agent-presets \{[\s\S]*?grid-template-columns: repeat\(2, minmax\(0, 1fr\)\);/);
expect(css).not.toContain('grid-template-columns: 1fr 1fr minmax(92px, 0.8fr);');
});
});
@@ -0,0 +1,15 @@
import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
describe('Settings model brand recommendation filter copy', () => {
it('frames brand filtering as model-brand recommendation terms instead of platform branding', () => {
const source = fs.readFileSync(path.join(__dirname, 'Settings.tsx'), 'utf8');
expect(source).toContain('模型品牌推荐屏蔽词');
expect(source).toContain('LLM 对话推荐');
expect(source).toContain('这里不是屏蔽 BoosAPI 平台品牌');
expect(source).not.toContain('全局品牌屏蔽');
expect(source).not.toContain('保存品牌屏蔽');
});
});
+13
View File
@@ -110,6 +110,13 @@ describe('Sites detect race handling', () => {
});
await flushMicrotasks();
// 新增表单现在默认预选 Comfy Cloud 预设;本用例测试空平台下的检测竞态,
// 先把平台重置为空(等价于操作员清空平台选择),恢复检测前置条件。
await act(async () => {
findPlatformSelect(root).props.onChange('');
});
await flushMicrotasks();
const urlInput = findPrimarySiteUrlInput(root);
await act(async () => {
urlInput.props.onChange({ target: { value: 'https://stale.example.com/v1' } });
@@ -190,6 +197,12 @@ describe('Sites detect race handling', () => {
});
await flushMicrotasks();
// 重置默认 Comfy Cloud 预选,恢复空平台检测前置条件。
await act(async () => {
findPlatformSelect(root).props.onChange('');
});
await flushMicrotasks();
const urlInput = findPrimarySiteUrlInput(root);
await act(async () => {
urlInput.props.onChange({ target: { value: 'https://blur.example.com/v1' } });
+24
View File
@@ -27,6 +27,7 @@ export type UserRechargeConfig = {
};
const UNCONFIGURED_CHECKOUT_REASON = '该额度支付链接未配置';
export const USER_RECHARGE_POINTS_PER_USD = 211;
const DEFAULT_USER_RECHARGE_AMOUNTS: UserRechargeCheckoutOption[] = [
{ id: 'usd-5', label: '$5', title: '轻量试用', amountUsd: 5 },
@@ -44,6 +45,29 @@ export function isUserRechargeOptionPayable(option: UserRechargeOption): option
return !option.disabled && (Boolean(checkoutUrl) || hasValidAmount(option));
}
export function calculateRechargePoints(amountUsd?: number | null): number {
if (typeof amountUsd !== 'number' || !Number.isFinite(amountUsd) || amountUsd <= 0) {
return 0;
}
return Math.round(amountUsd * USER_RECHARGE_POINTS_PER_USD);
}
export function formatRechargePoints(amountUsd?: number | null): string {
return `${calculateRechargePoints(amountUsd).toLocaleString('zh-CN')} 积分`;
}
export function formatRechargePointBalance(
remainingCost?: number | null,
maxCost?: number | null,
): string {
if (remainingCost == null) {
return '不限积分';
}
const balance = Math.max(0, remainingCost);
const points = formatRechargePoints(balance);
return maxCost == null ? points : points;
}
function normalizeRechargeOption(value: any, index: number): UserRechargeOption | null {
if (!value || typeof value !== 'object') return null;
const checkoutUrl = String(value.checkoutUrl || value.checkout_url || value.url || '').trim();
+7 -3
View File
@@ -26,6 +26,7 @@ describe('website GSAP motion contract', () => {
expect(source).toContain('matchMedia.add("(prefers-reduced-motion: reduce)"');
expect(source).toContain('matchMedia.add("(prefers-reduced-motion: no-preference) and (max-width: 640px)"');
expect(source).toContain('matchMedia.add("(prefers-reduced-motion: no-preference) and (min-width: 641px)"');
expect(source).toContain('ScrollTrigger.config({ ignoreMobileResize: true });');
expect(source).toContain('matchMedia.revert();');
});
@@ -86,9 +87,12 @@ describe('website GSAP motion contract', () => {
expect(source).toContain('const activeMobileLink = links.find((link) => (');
expect(source).toContain('link.getAttribute("href") === `#${id}` && Boolean(link.closest(".mobile-nav-rail"))');
expect(source).not.toContain('CSS.escape');
expect(source).toContain('activeMobileLink?.scrollIntoView({');
expect(source).toContain('block: "nearest",');
expect(source).toContain('inline: "center",');
expect(source).toContain('centerMobileNavLink(activeMobileLink);');
expect(source).toContain('function centerMobileNavLink(link: HTMLAnchorElement) {');
expect(source).toContain('const rail = link.closest<HTMLElement>(".mobile-nav-rail");');
expect(source).toContain('rail.scrollTo({');
expect(source).toContain('left: Math.max(0, nextScrollLeft),');
expect(source).toContain('behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",');
expect(source).toContain('const syncActiveFromHash = () => {');
expect(source).toContain('window.location.hash.slice(1)');
expect(source).toContain('const ANCHOR_NAV_EVENT = "bossai:anchor-nav";');
+24 -2
View File
@@ -1,4 +1,4 @@
import { readFileSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
@@ -23,6 +23,11 @@ describe('website /new experimental page', () => {
expect(home).not.toContain('new-acid-page');
expect(routes).toContain('path == "/new"');
expect(routes).toContain('path.starts_with("/new/")');
expect(routes).not.toContain('path == "/new1"');
expect(routes).not.toContain('path == "/new2"');
expect(routes).not.toContain('path == "/new3"');
expect(routes).not.toContain('path == "/new4"');
expect(routes).not.toContain('path == "/new5"');
});
it('uses Three.js as a client-only lightweight background with mobile and reduced-motion fallback', () => {
@@ -32,7 +37,8 @@ describe('website /new experimental page', () => {
expect(pkg).toContain('"three"');
expect(pkg).toContain('"lenis"');
expect(pkg).not.toContain('"@react-three/fiber"');
expect(source).not.toContain('@react-three/fiber');
expect(source).not.toContain('@react-three/drei');
expect(source).toContain('"use client";');
expect(source).toContain('import("three")');
expect(source).toContain('import("gsap")');
@@ -59,4 +65,20 @@ describe('website /new experimental page', () => {
expect(css).toContain('@media (max-width: 640px)');
expect(css).toContain('@media (prefers-reduced-motion: reduce)');
});
it('removes the retired /new1-/new5 comparison experiments and their extra R3F dependencies', () => {
const routes = read('src/routes/mod.rs');
const css = read('website/app/globals.css');
const pkg = read('website/package.json');
for (const route of ['new1', 'new2', 'new3', 'new4', 'new5']) {
expect(existsSync(resolve(process.cwd(), `website/app/${route}`))).toBe(false);
expect(routes).not.toContain(`path == "/${route}"`);
expect(routes).not.toContain(`path.starts_with("/${route}/")`);
expect(routes).not.toContain(`return "${route}.html".to_string();`);
expect(css).not.toContain(`.${route}-`);
}
expect(pkg).not.toContain('"@react-three/fiber"');
expect(pkg).not.toContain('"@react-three/drei"');
});
});