forked from boos/BoosAPI
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:
@@ -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"
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
+1738
-99
File diff suppressed because it is too large
Load Diff
+47
-3
@@ -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
@@ -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
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>"))
|
||||
}
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()],
|
||||
}
|
||||
}
|
||||
@@ -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, ¶ms).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": [] }),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(¤t.node_type, ¤t.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<_>>()
|
||||
])
|
||||
}
|
||||
@@ -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(¶ms));
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user