feat: Codex 工作区改动迁移(支付管理/用户余额/创作页等)

将 Codex 在工作区累积的改动整体落到 refactor/rust-backend:
支付管理与用户余额页、各创作 surface、website 等。排除 boosapi.db/config.toml 等敏感文件。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
boos
2026-06-17 22:28:14 +08:00
parent bfd804f719
commit e8508cb056
61 changed files with 6468 additions and 5228 deletions
+6 -3
View File
@@ -305,8 +305,7 @@ fn account_response_value(
route_id,
channel_id,
log_id,
)) =
runtime_health
)) = runtime_health
{
let ok = status == "success";
let is_degraded_latency =
@@ -314,7 +313,11 @@ fn account_response_value(
let model = model_actual
.as_ref()
.filter(|value| !value.trim().is_empty())
.or_else(|| model_requested.as_ref().filter(|value| !value.trim().is_empty()));
.or_else(|| {
model_requested
.as_ref()
.filter(|value| !value.trim().is_empty())
});
json!({
"state": if !ok {
"unhealthy"
+167 -34
View File
@@ -20,7 +20,7 @@ use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use sqlx::Row;
use std::collections::HashMap;
use std::collections::{BTreeSet, HashMap};
static PROXY_TEST_JOBS: Lazy<RwLock<HashMap<String, ProxyTestJob>>> =
Lazy::new(|| RwLock::new(HashMap::new()));
@@ -1242,6 +1242,14 @@ async fn runtime_settings(State(state): State<AppState>) -> AppResult<Json<Value
false,
)),
);
body.insert(
"registrationDisabled".to_string(),
json!(setting_bool(
&settings,
"runtime.registration_disabled",
false,
)),
);
body.insert(
"codexUpstreamWebsocketEnabled".to_string(),
json!(setting_bool(
@@ -1541,6 +1549,7 @@ struct RuntimeSettingsUpdateRequest {
system_proxy_url: Option<String>,
payload_rules: Option<Value>,
model_availability_probe_enabled: Option<bool>,
registration_disabled: Option<bool>,
codex_upstream_websocket_enabled: Option<bool>,
responses_compact_fallback_to_responses_enabled: Option<bool>,
disable_cross_protocol_fallback: Option<bool>,
@@ -1624,6 +1633,14 @@ async fn update_runtime_settings(
)
.await?;
}
if let Some(disabled) = req.registration_disabled {
upsert_setting(
&state,
"runtime.registration_disabled",
bool_setting_value(disabled),
)
.await?;
}
if let Some(enabled) = req.codex_upstream_websocket_enabled {
upsert_setting(
&state,
@@ -1870,8 +1887,102 @@ async fn update_runtime_settings(
Ok(Json(body))
}
async fn brand_list() -> Json<Value> {
Json(json!({ "brands": ["BoosAPI"], "current": "BoosAPI" }))
async fn brand_list(State(state): State<AppState>) -> AppResult<Json<Value>> {
let mut brands = BTreeSet::new();
for model in load_model_brand_source_names(&state).await? {
if let Some(brand) = infer_model_brand(&model) {
brands.insert(brand);
}
}
Ok(Json(json!({
"brands": brands.into_iter().collect::<Vec<_>>(),
"current": Value::Null,
"scope": "model-brand",
})))
}
async fn load_model_brand_source_names(state: &AppState) -> AppResult<Vec<String>> {
let rows: Vec<(String,)> = sqlx::query_as(
r#"
SELECT model_name AS model FROM model_availability
UNION
SELECT model_name AS model FROM token_model_availability
UNION
SELECT model_pattern AS model FROM token_routes
UNION
SELECT COALESCE(model_mapping, '') AS model FROM token_routes
UNION
SELECT COALESCE(source_model, '') AS model FROM route_channels
"#,
)
.fetch_all(&*state.db)
.await?;
Ok(rows
.into_iter()
.map(|(model,)| model.trim().to_string())
.filter(|model| !model.is_empty() && !model.contains('*'))
.collect())
}
fn infer_model_brand(model: &str) -> Option<String> {
let normalized = model.trim();
if normalized.is_empty() {
return None;
}
let lower = normalized.to_ascii_lowercase();
let brand = if lower.starts_with("openai/")
|| lower.starts_with("gpt-")
|| lower.starts_with("o1")
|| lower.starts_with("o3")
|| lower.starts_with("o4")
{
"OpenAI"
} else if lower.starts_with("anthropic/") || lower.starts_with("claude") {
"Anthropic"
} else if lower.starts_with("google/") || lower.starts_with("gemini") {
"Google"
} else if lower.starts_with("deepseek") || lower.starts_with("deepseek-ai/") {
"DeepSeek"
} else if lower.starts_with("qwen")
|| lower.starts_with("qwen/")
|| lower.starts_with("alibaba/")
|| lower.starts_with("alibaba-cloud/")
{
"Qwen"
} else if lower.starts_with("glm")
|| lower.starts_with("zhipu")
|| lower.starts_with("bigmodel/")
{
"GLM"
} else if lower.starts_with("moonshot") || lower.starts_with("kimi") {
"Moonshot"
} else if lower.starts_with("minimax") || lower.starts_with("abab") {
"MiniMax"
} else if lower.starts_with("doubao")
|| lower.starts_with("seedance")
|| lower.starts_with("seedream")
|| lower.starts_with("volcengine/")
|| lower.starts_with("bytedance/")
{
"Doubao"
} else if lower.starts_with("meta-llama/") || lower.starts_with("llama") {
"Llama"
} else if lower.starts_with("mistral") || lower.starts_with("mixtral") {
"Mistral"
} else if lower.starts_with("x-ai/") || lower.starts_with("grok") {
"xAI"
} else if let Some((prefix, _)) = normalized.split_once('/') {
let prefix = prefix.trim();
if prefix.eq_ignore_ascii_case("boosapi") || prefix.eq_ignore_ascii_case("comfy") {
return None;
}
return Some(prefix.to_string());
} else {
return None;
};
Some(brand.to_string())
}
async fn test_system_proxy() -> Json<Value> {
@@ -1952,7 +2063,9 @@ fn normalize_proxy_test_path(path: &str) -> AppResult<String> {
));
}
if !normalized.starts_with('/') {
return Err(AppError::BadRequest("proxy test path must start with /".into()));
return Err(AppError::BadRequest(
"proxy test path must start with /".into(),
));
}
let base_path = proxy_test_base_path(normalized);
let allowed = [
@@ -1968,7 +2081,8 @@ fn normalize_proxy_test_path(path: &str) -> AppResult<String> {
"/v1/videos",
];
let allowed_exact = allowed.iter().any(|item| base_path == *item);
let allowed_video_task = base_path.starts_with("/v1/videos/") && base_path.len() > "/v1/videos/".len();
let allowed_video_task =
base_path.starts_with("/v1/videos/") && base_path.len() > "/v1/videos/".len();
let allowed_gemini = is_gemini_proxy_test_path(normalized);
if allowed_exact || allowed_video_task || allowed_gemini {
return Ok(strip_legacy_gemini_proxy_prefix(normalized).to_string());
@@ -1988,9 +2102,9 @@ fn path_suffix_for_proxy_test(path: &str) -> AppResult<String> {
if is_gemini_proxy_test_path(without_query) {
return Ok(without_query.trim_start_matches('/').to_string());
}
let suffix = without_query
.strip_prefix("/v1")
.ok_or_else(|| AppError::BadRequest(format!("unsupported proxy test path: {without_query}")))?;
let suffix = without_query.strip_prefix("/v1").ok_or_else(|| {
AppError::BadRequest(format!("unsupported proxy test path: {without_query}"))
})?;
Ok(if suffix.is_empty() {
"/".to_string()
} else {
@@ -2089,7 +2203,9 @@ fn native_gemini_proxy_test_url(base_url: &str, path: &str) -> AppResult<String>
let normalized = normalize_proxy_test_path(path)?;
let trimmed_base = base_url.trim().trim_end_matches('/');
if trimmed_base.is_empty() {
return Err(AppError::BadRequest("Gemini upstream base URL is empty".into()));
return Err(AppError::BadRequest(
"Gemini upstream base URL is empty".into(),
));
}
let base = if trimmed_base.ends_with("/v1beta") || trimmed_base.contains("/v1beta/") {
trimmed_base.to_string()
@@ -2281,7 +2397,10 @@ async fn execute_proxy_test(
) -> Result<Value, Value> {
if envelope.request_kind == "multipart"
|| envelope.multipart_fields.is_some()
|| envelope.multipart_files.as_ref().is_some_and(|files| !files.is_empty())
|| envelope
.multipart_files
.as_ref()
.is_some_and(|files| !files.is_empty())
{
return Err(error_json_for_proxy_test(
&envelope,
@@ -2403,11 +2522,9 @@ async fn stream_proxy_test(
Json(json!({ "error": error_json_for_proxy_test(&envelope, err) })),
)
})?;
let stream = response.bytes_stream().map(|chunk| {
chunk
.map_err(std::io::Error::other)
.map(bytes::Bytes::from)
});
let stream = response
.bytes_stream()
.map(|chunk| chunk.map_err(std::io::Error::other).map(bytes::Bytes::from));
return Response::builder()
.header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
.header(header::CACHE_CONTROL, "no-cache, no-transform")
@@ -2441,11 +2558,9 @@ async fn stream_proxy_test(
)
})?;
let stream = response.bytes_stream().map(|chunk| {
chunk
.map_err(std::io::Error::other)
.map(bytes::Bytes::from)
});
let stream = response
.bytes_stream()
.map(|chunk| chunk.map_err(std::io::Error::other).map(bytes::Bytes::from));
Response::builder()
.header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
.header(header::CACHE_CONTROL, "no-cache, no-transform")
@@ -2840,6 +2955,8 @@ mod tests {
template_proxy_url: None,
},
comfyui: Default::default(),
pixelle_video: Default::default(),
payment: Default::default(),
},
pool,
BuiltinKeys {
@@ -2852,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!(
@@ -2981,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]
@@ -3058,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]
@@ -3130,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);
}
}
+2
View File
@@ -412,6 +412,8 @@ mod tests {
template_proxy_url: None,
},
comfyui: Default::default(),
pixelle_video: Default::default(),
payment: Default::default(),
},
pool,
BuiltinKeys {
+53 -18
View File
@@ -913,9 +913,14 @@ async fn probe_site_model(
match response {
Ok(_) if latency_exceeded => {
if let Err(err) =
update_model_probe_latency(state, selected.account_id, &model.name, latency_ms, true)
.await
if let Err(err) = update_model_probe_latency(
state,
selected.account_id,
&model.name,
latency_ms,
true,
)
.await
{
warn!(error = %err, model = %model.name, "failed to save probe latency");
}
@@ -924,7 +929,10 @@ async fn probe_site_model(
status: "unsupported".to_string(),
latency_ms: Some(latency_ms),
latency_exceeded: true,
reason: Some(format!("响应延迟 {}ms 超过阈值 {}ms", latency_ms, plan.latency_threshold_ms)),
reason: Some(format!(
"响应延迟 {}ms 超过阈值 {}ms",
latency_ms, plan.latency_threshold_ms
)),
http_status: None,
stage: Some("model"),
source: Some("probe"),
@@ -947,9 +955,14 @@ async fn probe_site_model(
warn!(channel_id, error = %err, "failed to record probe success");
}
}
if let Err(err) =
update_model_probe_latency(state, selected.account_id, &model.name, latency_ms, true)
.await
if let Err(err) = update_model_probe_latency(
state,
selected.account_id,
&model.name,
latency_ms,
true,
)
.await
{
warn!(error = %err, model = %model.name, "failed to save probe latency");
}
@@ -982,9 +995,14 @@ async fn probe_site_model(
warn!(channel_id, error = %record_err, "failed to record probe failure");
}
}
if let Err(update_err) =
update_model_probe_latency(state, selected.account_id, &model.name, latency_ms, false)
.await
if let Err(update_err) = update_model_probe_latency(
state,
selected.account_id,
&model.name,
latency_ms,
false,
)
.await
{
warn!(error = %update_err, model = %model.name, "failed to save failed probe state");
}
@@ -1041,7 +1059,8 @@ async fn resolve_probe_channel(
.bind(plan.site.id)
.fetch_optional(&*state.db)
.await?;
fallback.ok_or_else(|| AppError::BadRequest("当前站点没有启用中的账号,无法探测".into()))?
fallback
.ok_or_else(|| AppError::BadRequest("当前站点没有启用中的账号,无法探测".into()))?
}
};
@@ -1067,7 +1086,9 @@ async fn resolve_probe_channel(
};
if api_key.trim().is_empty() {
return Err(AppError::BadRequest("当前站点账号缺少可用 Token,无法探测".into()));
return Err(AppError::BadRequest(
"当前站点账号缺少可用 Token,无法探测".into(),
));
}
Ok(crate::proxy::conductor::SelectedChannel {
@@ -1165,8 +1186,10 @@ fn send_probe_sse(
tx.send(Ok(Event::default()
.event(event_type)
.json_data(payload)
.unwrap_or_else(|_| Event::default().event(event_type).data("{}"))))
.is_ok()
.unwrap_or_else(|_| {
Event::default().event(event_type).data("{}")
})))
.is_ok()
}
fn probe_error_message(err: &AppError) -> String {
@@ -1516,8 +1539,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 +1557,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")
+206 -166
View File
@@ -19,6 +19,9 @@ use serde_json::{json, Value};
use std::net::{IpAddr, SocketAddr};
use tracing::{info, warn};
/// Credit (in USD) granted to a newly self-registered user's default API key.
pub const DEFAULT_NEW_USER_MAX_COST_USD: f64 = 1.0;
// ── Request / Response types ─────────────────────────────────────────
#[derive(Debug, Deserialize)]
@@ -108,6 +111,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(),
@@ -177,11 +195,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?;
@@ -237,72 +256,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()));
}
@@ -315,16 +328,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!({
@@ -365,16 +381,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()));
}
@@ -390,87 +409,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);
@@ -482,16 +500,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!({
@@ -747,7 +768,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
{
@@ -864,17 +889,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
}
@@ -1018,6 +1043,7 @@ mod tests {
template_proxy_url: None,
},
comfyui: Default::default(),
pixelle_video: Default::default(),
payment: Default::default(),
},
pool,
@@ -1055,7 +1081,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(
@@ -1069,8 +1095,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 {
@@ -1079,9 +1105,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]
@@ -1147,7 +1176,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>"));
@@ -1160,7 +1190,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(
@@ -1186,8 +1216,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 {
@@ -1195,14 +1225,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(
@@ -1234,8 +1267,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 {
@@ -1243,9 +1276,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);
}
@@ -1333,7 +1369,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>"));
@@ -1363,7 +1400,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(),
+5 -4
View File
@@ -60,10 +60,9 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
// Downstream API key
if auth.starts_with("sk-") {
let proxy_token = state.runtime_proxy_token();
let result =
authorize_downstream_token(auth, &state.db, Some(&proxy_token))
.await
.map_err(AppError::Unauthorized)?;
let result = authorize_downstream_token(auth, &state.db, Some(&proxy_token))
.await
.map_err(AppError::Unauthorized)?;
match result {
DownstreamAuthResult::Managed { key, .. } => {
@@ -127,6 +126,8 @@ mod tests {
template_proxy_url: None,
},
comfyui: ComfyUIConfig::default(),
pixelle_video: Default::default(),
payment: Default::default(),
},
pool,
BuiltinKeys {
+288 -39
View File
@@ -16,47 +16,30 @@ use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
use tokio_stream::{wrappers::UnboundedReceiverStream, StreamExt};
use tracing::info;
const SYSTEM_PROMPT: &str = r#"你是 BoosAPI 的 AI 视频生成助手,帮用户通过 AI 制作视频。
const SYSTEM_PROMPT: &str = r#"你是 BoosAPI 的 AI 视频生成助手(智能体),帮用户通过 AI 制作视频。
## 规则
1. 先用中文与用户讨论脚本构思、视觉风格、角色设定
2. 用户确认后(说"开始制作"或"生成"),再分步输出结构化内容
3. **分两步走:第一步输出角色+场景+TTS,第二步输出 WORKFLOW**(不要一次全出)
## 工作方式
1. 先用中文与用户讨论脚本构思、视觉风格、角色设定。
2. 用户确认后(说"开始制作"或"生成"),调用工具逐步产出素材。
3. 你拥有以下工具,应主动按需调用,并根据每次工具返回的结果决定下一步:
- generate_character_images:为角色生成正/背/左/右四视图参考图
- generate_scene_image:生成单张场景/分镜图
- generate_tts:为一句台词合成语音
- build_comfyui_workflow:在素材就绪后交付最终可执行的 ComfyUI 工作流 JSON
## 结构化标记格式
严格按照以下格式输出,系统会自动解析并生成图片/TTS:
## 调用原则
- 一次可调用一个或多个工具;每个角色/场景/台词分别调用对应工具。
- 收到工具结果后继续推进,直到所有素材生成完毕,最后调用 build_comfyui_workflow 交付。
- 图像 prompt 用英文,包含 主体 + 视角 + 细节 + 画质标签。
- 没有用户明确要求时,不要自作主张批量生成;先沟通确认。
【CHARACTER】
name: 林澈
description: 女宇航员,30岁,冷静坚毅,短发收在头盔内
prompt_front: cinematic full body front view of a female astronaut, white EVA suit, photorealistic
prompt_back: cinematic full body back view of a female astronaut, life support backpack
prompt_left: cinematic full body left side view of a female astronaut, side utility pouches
prompt_right: cinematic full body right side view of a female astronaut, helmet visor reflection
【/CHARACTER】
【SCENE】
id: 1
description: 飞船驾驶舱内景
prompt: interior of a sci-fi spacecraft cockpit, holographic displays, cold blue lighting, cinematic
【/SCENE】
【TTS】
character: 林澈
text: 我们准备出舱检查
emotion: 平静
【/TTS】
【WORKFLOW】
```json
{"comfyui_workflow": {}}
```
【/WORKFLOW】
## 关键要求
- prompt_* 用英文,同时包含主体 + 视角 + 细节 + 画质标签
- 不要一次输出太多,先输角色和场景,下次再输工作流
- 如果没有用户明确要求,切勿自作主张输出全部内容
## 兼容降级
若你所在的模型不支持工具调用,可改用结构化标记输出(系统会解析):
【CHARACTER】name/description/prompt_front/prompt_back/prompt_left/prompt_right【/CHARACTER】
【SCENE】id/description/prompt【/SCENE】
【TTS】character/text/emotion【/TTS】
【WORKFLOW】```json {...} ```【/WORKFLOW】
但只要支持工具调用,就优先使用工具,不要输出这些标记。
"#;
const AGENT_CHAT_BODY_LIMIT_BYTES: usize = 64 * 1024 * 1024;
@@ -683,6 +666,190 @@ fn send_sse(tx: &UnboundedSender<Result<Event, Infallible>>, payload: Value) ->
.is_ok()
}
/// OpenAI-style tool definitions advertised to the upstream model so the
/// agent can request image / scene / TTS / workflow generation step by step.
fn agent_tool_definitions() -> Value {
json!([
{
"type": "function",
"function": {
"name": "generate_character_images",
"description": "为一个角色生成四视图(正/背/左/右)参考图。当需要确定角色形象时调用。",
"parameters": {
"type": "object",
"properties": {
"character_name": { "type": "string", "description": "角色名称" },
"description": { "type": "string", "description": "角色整体描述" },
"prompt_front": { "type": "string", "description": "正面视角的图像提示词" },
"prompt_back": { "type": "string", "description": "背面视角的图像提示词" },
"prompt_left": { "type": "string", "description": "左侧视角的图像提示词" },
"prompt_right": { "type": "string", "description": "右侧视角的图像提示词" }
},
"required": ["character_name", "prompt_front"]
}
}
},
{
"type": "function",
"function": {
"name": "generate_scene_image",
"description": "生成一张场景/分镜图。在规划好分镜后逐个调用。",
"parameters": {
"type": "object",
"properties": {
"scene_id": { "type": "integer", "description": "场景编号" },
"description": { "type": "string", "description": "场景描述" },
"prompt": { "type": "string", "description": "场景图像提示词" }
},
"required": ["prompt"]
}
}
},
{
"type": "function",
"function": {
"name": "generate_tts",
"description": "为一句台词生成语音。",
"parameters": {
"type": "object",
"properties": {
"character": { "type": "string", "description": "说话角色" },
"text": { "type": "string", "description": "台词文本" },
"emotion": { "type": "string", "description": "情绪:平静/激动/愤怒/悲伤/恐惧" }
},
"required": ["text"]
}
}
},
{
"type": "function",
"function": {
"name": "build_comfyui_workflow",
"description": "输出最终可执行的 ComfyUI 工作流 JSON。在素材准备完成后调用以交付工作流。",
"parameters": {
"type": "object",
"properties": {
"workflow_json": { "type": "string", "description": "完整的 ComfyUI 工作流 JSON 字符串" }
},
"required": ["workflow_json"]
}
}
}
])
}
/// Map an emotion label to an OpenAI TTS voice.
fn voice_for_emotion(emotion: &str) -> &'static str {
match emotion {
"激动" | "愤怒" => "nova",
"悲伤" | "恐惧" => "shimmer",
_ => "alloy",
}
}
/// Execute one tool call requested by the model. Emits SSE side-effects
/// (image/scene/tts/comfyui_workflow events) and returns a short text result
/// that is fed back to the model as the tool message content.
async fn execute_agent_tool(
tx: &UnboundedSender<Result<Event, Infallible>>,
api_base: &str,
api_key: &str,
image_model: &str,
tool_name: &str,
args: &Value,
) -> String {
match tool_name {
"generate_character_images" => {
let name = args.get("character_name").and_then(Value::as_str).unwrap_or("角色");
let views: [(&str, &str); 4] = [
("front", "prompt_front"),
("back", "prompt_back"),
("left", "prompt_left"),
("right", "prompt_right"),
];
let mut ok = 0;
for (view, key) in views {
let Some(prompt) = args.get(key).and_then(Value::as_str).filter(|p| !p.is_empty()) else {
continue;
};
send_sse(tx, json!({ "type": "tool_progress", "tool": tool_name, "detail": format!("生成 {} 的 {} 视图", name, view) }));
match call_image_gen(api_base, api_key, image_model, prompt).await {
Ok(url) if !url.is_empty() => {
ok += 1;
send_sse(tx, json!({ "type": "image", "character": name, "view": view, "url": url }));
}
Ok(_) => {}
Err(e) => {
send_sse(tx, json!({ "type": "tool_progress", "tool": tool_name, "detail": format!("{} 的 {} 视图生成失败: {}", name, view, e) }));
}
}
}
format!("已为角色「{}」生成 {} 张视图。", name, ok)
}
"generate_scene_image" => {
let scene_id = args.get("scene_id").and_then(Value::as_i64).unwrap_or(0);
let desc = args.get("description").and_then(Value::as_str).unwrap_or("");
let Some(prompt) = args.get("prompt").and_then(Value::as_str).filter(|p| !p.is_empty()) else {
return "缺少 prompt,未生成场景图。".to_string();
};
send_sse(tx, json!({ "type": "tool_progress", "tool": tool_name, "detail": format!("生成场景 {}", scene_id) }));
match call_image_gen(api_base, api_key, image_model, prompt).await {
Ok(url) if !url.is_empty() => {
send_sse(tx, json!({ "type": "scene", "id": scene_id, "description": desc, "prompt": prompt, "url": url }));
format!("场景 {} 已生成。", scene_id)
}
Ok(_) => format!("场景 {} 上游未返回图像。", scene_id),
Err(e) => format!("场景 {} 生成失败: {}", scene_id, e),
}
}
"generate_tts" => {
let character = args.get("character").and_then(Value::as_str).unwrap_or("旁白");
let emotion = args.get("emotion").and_then(Value::as_str).unwrap_or("平静");
let Some(text) = args.get("text").and_then(Value::as_str).filter(|t| !t.is_empty()) else {
return "缺少台词文本,未生成语音。".to_string();
};
send_sse(tx, json!({ "type": "tool_progress", "tool": tool_name, "detail": format!("为 {} 合成语音", character) }));
match call_tts(api_base, api_key, text, voice_for_emotion(emotion)).await {
Ok(audio_bytes) => {
let audio_path = format!(
"/tmp/comfyui_tts_{}_{}.mp3",
character.replace(' ', "_"),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
);
if tokio::fs::write(&audio_path, &audio_bytes).await.is_ok() {
send_sse(tx, json!({
"type": "tts",
"character": character,
"text": text,
"url": format!("/audio/{}", audio_path.trim_start_matches("/tmp/comfyui_tts_")),
}));
format!("已为「{}」生成语音。", character)
} else {
"语音生成成功但写入失败。".to_string()
}
}
Err(e) => format!("语音生成失败: {}", e),
}
}
"build_comfyui_workflow" => {
let Some(raw) = args.get("workflow_json").and_then(Value::as_str).filter(|w| !w.is_empty()) else {
return "缺少 workflow_json。".to_string();
};
match serde_json::from_str::<Value>(raw) {
Ok(parsed) => {
send_sse(tx, json!({ "type": "comfyui_workflow", "workflow": parsed }));
"ComfyUI 工作流已交付。".to_string()
}
Err(e) => format!("工作流 JSON 无效: {}", e),
}
}
other => format!("未知工具: {}", other),
}
}
fn string_field(value: &Value, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|key| value.get(*key).and_then(Value::as_str))
@@ -910,6 +1077,15 @@ async fn chat_handler(
let client = Client::new();
// Agentic loop: each round may produce tool_calls; we execute them,
// feed results back, and re-query until the model returns a final text
// answer (no tool_calls) or the round budget is exhausted.
const MAX_AGENT_ROUNDS: usize = 6;
let mut agent_round = 0usize;
let mut accumulated_text = String::new();
loop {
agent_round += 1;
info!("comfyui-agent: sending chat request to upstream");
let response = match client
@@ -920,6 +1096,8 @@ async fn chat_handler(
"messages": upstream_messages,
"temperature": 0.7,
"stream": true,
"tools": agent_tool_definitions(),
"tool_choice": "auto",
}))
.send()
.await
@@ -952,7 +1130,10 @@ async fn chat_handler(
let mut stream = response.bytes_stream();
let mut buffer: Vec<u8> = Vec::new();
let mut accumulated_text = String::new();
accumulated_text.clear();
// Accumulate streamed tool_calls: index -> (id, name, arguments-buffer).
let mut tool_calls_acc: std::collections::BTreeMap<i64, (String, String, String)> =
std::collections::BTreeMap::new();
// Phase 1: Stream the LLM text response
loop {
@@ -1048,9 +1229,75 @@ async fn chat_handler(
return;
}
}
// Accumulate streamed tool_calls fragments.
if let Some(tcs) = delta.get("tool_calls").and_then(Value::as_array) {
for tc in tcs {
let idx = tc.get("index").and_then(Value::as_i64).unwrap_or(0);
let entry = tool_calls_acc.entry(idx).or_default();
if let Some(id) = tc.get("id").and_then(Value::as_str) {
if !id.is_empty() {
entry.0 = id.to_string();
}
}
if let Some(name) = tc.pointer("/function/name").and_then(Value::as_str) {
if !name.is_empty() {
entry.1 = name.to_string();
}
}
if let Some(args) = tc.pointer("/function/arguments").and_then(Value::as_str) {
entry.2.push_str(args);
}
}
}
}
}
// If the model requested tools this round, execute them, append the
// assistant tool_calls + tool results to the conversation, and loop
// for another round so the model can react to the results.
if !tool_calls_acc.is_empty() && agent_round < MAX_AGENT_ROUNDS {
// Reconstruct the assistant message carrying the tool_calls.
let assistant_tool_calls: Vec<Value> = tool_calls_acc
.iter()
.map(|(_, (id, name, args))| {
json!({
"id": id,
"type": "function",
"function": { "name": name, "arguments": args },
})
})
.collect();
upstream_messages.push(json!({
"role": "assistant",
"content": if accumulated_text.is_empty() { Value::Null } else { Value::from(accumulated_text.clone()) },
"tool_calls": assistant_tool_calls,
}));
for (_, (id, name, args)) in tool_calls_acc.iter() {
let parsed_args: Value = serde_json::from_str(args).unwrap_or(json!({}));
send_sse(&tx, json!({ "type": "tool_call", "tool": name, "arguments": parsed_args }));
let result = execute_agent_tool(
&tx, &api_base, &api_key, &image_model, name, &parsed_args,
).await;
send_sse(&tx, json!({ "type": "tool_result", "tool": name, "result": result }));
upstream_messages.push(json!({
"role": "tool",
"tool_call_id": id,
"content": result,
}));
}
// Continue to the next agent round.
continue;
}
// No tool calls → final answer. Leave the agentic loop and fall through
// to the legacy structured-section parsing as a fallback for models
// that emit 【...】 markers instead of tool calls.
break;
} // end agentic loop
// Phase 2: Parse structured sections from accumulated text
info!("comfyui-agent: text streaming complete, parsing sections");
let sections = parse_sections(&accumulated_text);
@@ -1305,6 +1552,8 @@ mod tests {
template_proxy_url: None,
},
comfyui: Default::default(),
pixelle_video: Default::default(),
payment: Default::default(),
},
pool,
BuiltinKeys {
File diff suppressed because it is too large Load Diff
+49 -3
View File
@@ -439,6 +439,12 @@ struct EventListItem {
event_type: String,
title: String,
message: Option<String>,
#[sqlx(default)]
#[serde(skip_serializing_if = "Option::is_none")]
account: Option<String>,
#[sqlx(default)]
#[serde(skip_serializing_if = "Option::is_none")]
client_ip: Option<String>,
level: String,
#[serde(rename = "read")]
is_read: bool,
@@ -449,11 +455,15 @@ struct EventListItem {
impl From<Event> for EventListItem {
fn from(event: Event) -> Self {
let account = event_account(&event.event_type, &event.title, event.message.as_deref());
let client_ip = event_client_ip(event.message.as_deref());
Self {
id: event.id,
event_type: event.event_type,
title: event.title,
message: event.message,
account,
client_ip,
level: event.level.unwrap_or_else(|| "info".to_string()),
is_read: event.is_read.unwrap_or(false),
related_id: event.related_id,
@@ -470,6 +480,8 @@ impl From<runtime_logs::RuntimeLogEntry> for EventListItem {
event_type: entry.event_type,
title: entry.title,
message: entry.message,
account: None,
client_ip: None,
level: entry.level,
is_read: entry.is_read,
related_id: entry.related_id,
@@ -479,6 +491,38 @@ impl From<runtime_logs::RuntimeLogEntry> for EventListItem {
}
}
fn event_account(event_type: &str, title: &str, message: Option<&str>) -> Option<String> {
if event_type != "user_login" {
return None;
}
message
.and_then(|value| keyed_message_value(value, "账号"))
.or_else(|| message.and_then(|value| keyed_message_value(value, "邮箱")))
.or_else(|| message.and_then(|value| keyed_message_value(value, "标识")))
.or_else(|| {
title
.split_once(':')
.map(|(_, value)| value.trim().to_string())
})
.filter(|value| !value.is_empty())
}
fn event_client_ip(message: Option<&str>) -> Option<String> {
message
.and_then(|value| keyed_message_value(value, "IP"))
.filter(|value| !value.is_empty())
}
fn keyed_message_value(message: &str, key: &str) -> Option<String> {
let prefix = format!("{key}=");
message
.split('·')
.map(str::trim)
.find_map(|part| part.strip_prefix(&prefix).map(str::trim))
.filter(|value| !value.is_empty())
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -487,11 +531,11 @@ mod tests {
config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig},
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
};
use axum::extract::{Query, State};
use axum::{
body::Body,
http::{Request, StatusCode},
};
use axum::extract::{Query, State};
use sqlx::sqlite::SqlitePoolOptions;
use tower::ServiceExt as _;
@@ -536,6 +580,8 @@ mod tests {
template_proxy_url: None,
},
comfyui: Default::default(),
pixelle_video: Default::default(),
payment: Default::default(),
},
pool,
BuiltinKeys {
@@ -631,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(
+14 -7
View File
@@ -16,8 +16,10 @@ 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;
@@ -30,6 +32,7 @@ mod health;
mod money_printer;
mod oauth;
mod organizations;
mod payment;
mod picture_agent;
mod proxy;
mod search;
@@ -65,6 +68,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 +103,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 +286,12 @@ mod tests {
assert_eq!(embedded_asset_path("/new"), "new.html");
assert_eq!(embedded_asset_path("/new/"), "new.html");
assert_eq!(embedded_asset_path("/new/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]
+349 -110
View File
@@ -108,7 +108,11 @@ fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer ").or_else(|| value.strip_prefix("bearer ")))
.and_then(|value| {
value
.strip_prefix("Bearer ")
.or_else(|| value.strip_prefix("bearer "))
})
.map(str::trim)
.filter(|token| !token.is_empty())
}
@@ -181,25 +185,28 @@ async fn resolve_auth(headers: &HeaderMap, state: &AppState) -> Result<MoneyPrin
async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
let auth = resolve_auth(&headers, &state).await?;
let cloud_backend = state.config.comfyui.uses_cloud_backend();
let local_only_enabled = state.config.comfyui.uses_docker_backend();
Ok(Json(json!({
"success": true,
"mode": "boosapi-native",
"mode": if local_only_enabled { "local-comfy" } else { "disabled" },
"disabled": !local_only_enabled,
"disabledReason": if local_only_enabled { Value::Null } else { json!("短视频工厂暂时只支持本地模型部署;请启用本地 ComfyUI/Docker 后再创建任务。") },
"keyName": auth.key_name,
"capabilities": [
"topic_to_script",
"boosapi_chat_completion",
"comfy_prompt",
"boosapi_text_to_video",
"local_comfy_prompt",
"local_text_to_video",
"workspace_archive",
"background_jobs"
],
"comfy": {
"enabled": state.config.comfyui.uses_native_backend(),
"enabled": local_only_enabled,
"backend": format!("{:?}", state.config.comfyui.backend).to_ascii_lowercase(),
"baseUrl": state.config.comfyui.target_base_url()
},
"builtinCloudTemplate": {
"enabled": cloud_backend,
"enabled": cloud_backend && local_only_enabled,
"label": "内置 Cloud Seedance 文生视频",
"nodeType": "ByteDanceTextToVideoNode",
"model": "seedance-1-0-lite-t2v-250428",
@@ -276,7 +283,12 @@ async fn load_job(state: &AppState, id: &str, auth: &MoneyPrinterAuth) -> AppRes
Ok(task_to_json(task))
}
async fn update_progress(state: &AppState, id: &str, progress: i64, message: &str) -> AppResult<()> {
async fn update_progress(
state: &AppState,
id: &str,
progress: i64,
message: &str,
) -> AppResult<()> {
sqlx::query(
"UPDATE background_tasks
SET progress = ?, message = ?, updated_at = datetime('now')
@@ -308,7 +320,8 @@ fn http_client(timeout_secs: u64) -> Client {
fn script_prompt(topic: &str, body: &Value) -> String {
let language = string_from(body, &["language"]).unwrap_or_else(|| "中文".to_string());
let style = string_from(body, &["style"]).unwrap_or_else(|| "爆款短视频,节奏紧凑,适合口播".to_string());
let style = string_from(body, &["style"])
.unwrap_or_else(|| "爆款短视频,节奏紧凑,适合口播".to_string());
let duration = string_from(body, &["duration"]).unwrap_or_else(|| "30".to_string());
let audience = string_from(body, &["audience"]).unwrap_or_else(|| "泛大众用户".to_string());
let extra = string_from(body, &["requirements", "extra"]).unwrap_or_default();
@@ -334,9 +347,8 @@ async fn generate_script(
auth: &MoneyPrinterAuth,
body: &Value,
) -> AppResult<Value> {
let topic = string_from(body, &["topic", "prompt"]).ok_or_else(|| {
AppError::BadRequest("topic is required".into())
})?;
let topic = string_from(body, &["topic", "prompt"])
.ok_or_else(|| AppError::BadRequest("topic is required".into()))?;
let script_model = string_from(body, &["scriptModel", "script_model"])
.unwrap_or_else(|| "gpt-5.4".to_string());
let payload = json!({
@@ -452,7 +464,11 @@ fn native_path(state: &AppState, path: &str) -> String {
}
fn native_url(state: &AppState, path: &str) -> String {
format!("{}{}", state.config.comfyui.target_base_url(), native_path(state, path))
format!(
"{}{}",
state.config.comfyui.target_base_url(),
native_path(state, path)
)
}
fn native_request(state: &AppState, method: Method, path: &str) -> reqwest::RequestBuilder {
@@ -559,7 +575,12 @@ fn mapping_value<'a>(mapping: &'a Value, keys: &[&str]) -> Option<&'a Value> {
fn mapping_node_id(mapping: &Value, keys: &[&str]) -> Option<String> {
mapping_value(mapping, keys).and_then(|value| {
if value.is_object() {
scalar_string(value.get("nodeId").or_else(|| value.get("node_id")).or(Some(value)))
scalar_string(
value
.get("nodeId")
.or_else(|| value.get("node_id"))
.or(Some(value)),
)
} else {
scalar_string(Some(value))
}
@@ -621,21 +642,38 @@ fn graph_node_params(node: &Value) -> serde_json::Map<String, Value> {
fn normalize_comfy_value(class_type: &str, field: &str, value: Value) -> Value {
match (class_type, field) {
("KSampler", "cfg") => value.as_f64().filter(|v| (0.0..=50.0).contains(v)).map_or(json!(8.0), |v| json!(v)),
("KSampler", "denoise") => value.as_f64().map(|v| if v > 1.0 { v / 100.0 } else { v }).filter(|v| (0.0..=1.0).contains(v)).map_or(json!(1.0), |v| json!(v)),
("KSampler", "cfg") => value
.as_f64()
.filter(|v| (0.0..=50.0).contains(v))
.map_or(json!(8.0), |v| json!(v)),
("KSampler", "denoise") => value
.as_f64()
.map(|v| if v > 1.0 { v / 100.0 } else { v })
.filter(|v| (0.0..=1.0).contains(v))
.map_or(json!(1.0), |v| json!(v)),
("KSampler", "sampler_name") => {
if value.is_string() { value } else { json!("euler") }
if value.is_string() {
value
} else {
json!("euler")
}
}
("KSampler", "scheduler") => {
if value.is_string() { value } else { json!("normal") }
if value.is_string() {
value
} else {
json!("normal")
}
}
("KSampler", "seed") => value.as_i64().map_or(json!(0), |v| json!(v.max(0))),
("EmptyLatentImage", "width") | ("EmptyLatentImage", "height") => {
value.as_i64().filter(|v| (64..=4096).contains(v)).map_or(json!(1024), |v| json!(v))
}
("EmptyLatentImage", "batch_size") => {
value.as_i64().filter(|v| (1..=16).contains(v)).map_or(json!(1), |v| json!(v))
}
("EmptyLatentImage", "width") | ("EmptyLatentImage", "height") => value
.as_i64()
.filter(|v| (64..=4096).contains(v))
.map_or(json!(1024), |v| json!(v)),
("EmptyLatentImage", "batch_size") => value
.as_i64()
.filter(|v| (1..=16).contains(v))
.map_or(json!(1), |v| json!(v)),
_ => value,
}
}
@@ -656,18 +694,37 @@ fn graph_to_comfy_prompt(graph: &Value) -> Option<Value> {
}
let mut inputs = serde_json::Map::new();
for (field, value) in graph_node_params(node) {
inputs.insert(field.clone(), normalize_comfy_value(&class_type, &field, value));
inputs.insert(
field.clone(),
normalize_comfy_value(&class_type, &field, value),
);
}
prompt.insert(id, json!({
"class_type": class_type,
"inputs": inputs,
}));
prompt.insert(
id,
json!({
"class_type": class_type,
"inputs": inputs,
}),
);
}
for edge in graph.get("edges").and_then(Value::as_array).into_iter().flatten() {
let Some(from) = edge.get("from").and_then(Value::as_str) else { continue; };
let Some(to) = edge.get("to").and_then(Value::as_str) else { continue; };
let Some((from_id, from_port)) = split_endpoint(from) else { continue; };
let Some((to_id, to_field)) = split_endpoint(to) else { continue; };
for edge in graph
.get("edges")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let Some(from) = edge.get("from").and_then(Value::as_str) else {
continue;
};
let Some(to) = edge.get("to").and_then(Value::as_str) else {
continue;
};
let Some((from_id, from_port)) = split_endpoint(from) else {
continue;
};
let Some((to_id, to_field)) = split_endpoint(to) else {
continue;
};
if let Some(inputs) = prompt
.get_mut(to_id)
.and_then(Value::as_object_mut)
@@ -684,7 +741,10 @@ fn graph_to_comfy_prompt(graph: &Value) -> Option<Value> {
Some(Value::Object(prompt))
}
fn first_node_by_class(prompt: &serde_json::Map<String, Value>, class_type: &str) -> Option<String> {
fn first_node_by_class(
prompt: &serde_json::Map<String, Value>,
class_type: &str,
) -> Option<String> {
prompt
.iter()
.find(|(_, node)| node.get("class_type").and_then(Value::as_str) == Some(class_type))
@@ -716,9 +776,13 @@ fn prompt_contains_class_type(prompt: &Value, class_type: &str) -> bool {
.get("class_type")
.and_then(Value::as_str)
.is_some_and(|value| value == class_type)
|| object.values().any(|value| prompt_contains_class_type(value, class_type))
|| object
.values()
.any(|value| prompt_contains_class_type(value, class_type))
}
Value::Array(items) => items.iter().any(|value| prompt_contains_class_type(value, class_type)),
Value::Array(items) => items
.iter()
.any(|value| prompt_contains_class_type(value, class_type)),
_ => false,
}
}
@@ -740,11 +804,36 @@ fn infer_comfy_mapping(prompt: &Value) -> Value {
.or_else(|| first_node_by_class(object, "KSampler"))
.or_else(|| first_node_by_class(object, "EmptyLatentImage"))
.unwrap_or_default();
let prompt_field = first_existing_input_field(object, &prompt_node, &["prompt", "positive", "text"], "text");
let ratio_field = first_existing_input_field(object, &video_node, &["ratio", "model.ratio", "aspect_ratio"], "ratio");
let resolution_field = first_existing_input_field(object, &video_node, &["model.resolution", "resolution", "size"], "resolution");
let duration_field = first_existing_input_field(object, &video_node, &["model.duration", "duration", "length"], "duration");
let model_field = first_existing_input_field(object, &video_node, &["model", "model_name", "ckpt_name"], "model");
let prompt_field = first_existing_input_field(
object,
&prompt_node,
&["prompt", "positive", "text"],
"text",
);
let ratio_field = first_existing_input_field(
object,
&video_node,
&["ratio", "model.ratio", "aspect_ratio"],
"ratio",
);
let resolution_field = first_existing_input_field(
object,
&video_node,
&["model.resolution", "resolution", "size"],
"resolution",
);
let duration_field = first_existing_input_field(
object,
&video_node,
&["model.duration", "duration", "length"],
"duration",
);
let model_field = first_existing_input_field(
object,
&video_node,
&["model", "model_name", "ckpt_name"],
"model",
);
json!({
"prompt": {
"nodeId": prompt_node,
@@ -776,7 +865,9 @@ fn infer_comfy_mapping(prompt: &Value) -> Value {
fn clip_text_nodes(prompt: &serde_json::Map<String, Value>) -> Vec<String> {
prompt
.iter()
.filter(|(_, node)| node.get("class_type").and_then(Value::as_str) == Some("CLIPTextEncode"))
.filter(|(_, node)| {
node.get("class_type").and_then(Value::as_str) == Some("CLIPTextEncode")
})
.map(|(id, _)| id.clone())
.collect()
}
@@ -826,7 +917,8 @@ fn repair_common_comfy_prompt_links(prompt: &mut serde_json::Map<String, Value>)
}
fn workflow_prompt_from_graph(graph: &Value) -> Value {
graph.get("prompt")
graph
.get("prompt")
.cloned()
.or_else(|| graph.get("workflow").cloned())
.or_else(|| graph.get("comfyPrompt").cloned())
@@ -858,23 +950,24 @@ async fn workflow_prompt_from_body(
auth: &MoneyPrinterAuth,
body: &Value,
) -> AppResult<Value> {
if let Some(value) = body.get("comfyPrompt")
if let Some(value) = body
.get("comfyPrompt")
.or_else(|| body.get("comfy_prompt"))
.or_else(|| body.get("promptWorkflow"))
.or_else(|| body.get("prompt_workflow"))
.or_else(|| body.get("workflowPrompt"))
.or_else(|| body.get("workflow_prompt"))
.cloned() {
.cloned()
{
return Ok(value);
}
if let Some(template_id) = string_from(body, &["templateId", "template_id", "workflowId", "workflow_id"]) {
let workflow = storage::get_workflow(
&state.db,
&template_id,
auth.user_id,
auth.role == "admin",
)
.await?;
if let Some(template_id) = string_from(
body,
&["templateId", "template_id", "workflowId", "workflow_id"],
) {
let workflow =
storage::get_workflow(&state.db, &template_id, auth.user_id, auth.role == "admin")
.await?;
return Ok(workflow_prompt_from_graph(&workflow.graph));
}
if state.config.comfyui.uses_cloud_backend() {
@@ -890,10 +983,14 @@ fn apply_node_input(prompt: &mut Value, node_id: &str, field: &str, value: Value
return Err(AppError::BadRequest("Comfy 节点映射包含空 nodeId".into()));
}
let Some(node) = prompt.get_mut(node_id) else {
return Err(AppError::BadRequest(format!("Comfy prompt missing node {node_id}")));
return Err(AppError::BadRequest(format!(
"Comfy prompt missing node {node_id}"
)));
};
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
return Err(AppError::BadRequest(format!("Comfy node {node_id} has no inputs")));
return Err(AppError::BadRequest(format!(
"Comfy node {node_id} has no inputs"
)));
};
inputs.insert(field.to_string(), value);
Ok(())
@@ -927,12 +1024,20 @@ fn apply_optional_node_input(
fn value_to_duration_seconds(value: &Value) -> Option<i64> {
value
.as_i64()
.or_else(|| value.as_str().and_then(|text| text.trim().parse::<i64>().ok()))
.or_else(|| {
value
.as_str()
.and_then(|text| text.trim().parse::<i64>().ok())
})
.map(|duration| duration.clamp(2, 120))
}
fn normalize_resolution_for_comfy(value: &Value) -> Value {
let text = value.as_str().unwrap_or_default().trim().to_ascii_lowercase();
let text = value
.as_str()
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
match text.as_str() {
"720p" | "720" => json!("720P"),
"1080p" | "1080" => json!("1080P"),
@@ -940,7 +1045,12 @@ fn normalize_resolution_for_comfy(value: &Value) -> Value {
}
}
fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value, plan: &Value) -> AppResult<String> {
fn inject_comfy_video_inputs(
prompt: &mut Value,
mapping: &Value,
body: &Value,
plan: &Value,
) -> AppResult<String> {
let video_prompt = string_from(body, &["videoPrompt", "video_prompt"]).unwrap_or_else(|| {
plan.get("video_prompt")
.and_then(Value::as_str)
@@ -956,20 +1066,35 @@ fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value,
apply_node_input(
prompt,
&prompt_node,
&mapping_field(mapping, &["prompt", "positive", "text", "promptNode"], "prompt"),
&mapping_field(
mapping,
&["prompt", "positive", "text", "promptNode"],
"prompt",
),
json!(video_prompt),
)?;
if let Some(negative_node) = mapping_node_id(mapping, &["negativePrompt", "negative", "negativeNode"]) {
if let Some(negative_node) =
mapping_node_id(mapping, &["negativePrompt", "negative", "negativeNode"])
{
let negative = string_from(body, &["negativePrompt", "negative_prompt", "negative"])
.unwrap_or_else(|| "low quality, blurry, distorted, watermark, text artifacts".to_string());
.unwrap_or_else(|| {
"low quality, blurry, distorted, watermark, text artifacts".to_string()
});
apply_node_input(
prompt,
&negative_node,
&mapping_field(mapping, &["negativePrompt", "negative", "negativeNode"], "text"),
&mapping_field(
mapping,
&["negativePrompt", "negative", "negativeNode"],
"text",
),
json!(negative),
)?;
}
if let Some(model) = string_from(body, &["comfyApiModel", "comfy_api_model", "apiModel", "api_model"]) {
if let Some(model) = string_from(
body,
&["comfyApiModel", "comfy_api_model", "apiModel", "api_model"],
) {
apply_optional_node_input(
prompt,
mapping,
@@ -978,10 +1103,16 @@ fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value,
json!(normalize_video_model(&model)),
);
}
if let Some(value) = ["ratio", "aspectRatio", "aspect_ratio"].iter().find_map(|key| body.get(*key).cloned()) {
if let Some(value) = ["ratio", "aspectRatio", "aspect_ratio"]
.iter()
.find_map(|key| body.get(*key).cloned())
{
apply_optional_node_input(prompt, mapping, &["ratio", "ratioNode"], "ratio", value);
}
if let Some(value) = ["resolution", "size"].iter().find_map(|key| body.get(*key).cloned()) {
if let Some(value) = ["resolution", "size"]
.iter()
.find_map(|key| body.get(*key).cloned())
{
apply_optional_node_input(
prompt,
mapping,
@@ -990,8 +1121,18 @@ fn inject_comfy_video_inputs(prompt: &mut Value, mapping: &Value, body: &Value,
normalize_resolution_for_comfy(&value),
);
}
if let Some(value) = ["duration"].iter().find_map(|key| body.get(*key)).and_then(value_to_duration_seconds) {
apply_optional_node_input(prompt, mapping, &["duration", "durationNode"], "duration", json!(value));
if let Some(value) = ["duration"]
.iter()
.find_map(|key| body.get(*key))
.and_then(value_to_duration_seconds)
{
apply_optional_node_input(
prompt,
mapping,
&["duration", "durationNode"],
"duration",
json!(value),
);
}
repair_money_printer_comfy_prompt(prompt);
Ok(video_prompt)
@@ -1009,8 +1150,14 @@ fn repair_bytedance_video_nodes(prompt: &mut Value) {
return;
};
for (_node_id, node) in nodes.iter_mut() {
let class_type = node.get("class_type").and_then(Value::as_str).unwrap_or_default();
if !matches!(class_type, "ByteDanceTextToVideoNode" | "ByteDance2TextToVideoNode") {
let class_type = node
.get("class_type")
.and_then(Value::as_str)
.unwrap_or_default();
if !matches!(
class_type,
"ByteDanceTextToVideoNode" | "ByteDance2TextToVideoNode"
) {
continue;
}
let Some(inputs) = node.get_mut("inputs").and_then(Value::as_object_mut) else {
@@ -1019,16 +1166,20 @@ fn repair_bytedance_video_nodes(prompt: &mut Value) {
if let Some(duration) = inputs.get("duration").and_then(value_to_duration_seconds) {
inputs.insert("duration".to_string(), json!(duration.clamp(3, 12)));
}
if let Some(duration) = inputs.get("model.duration").and_then(value_to_duration_seconds) {
if let Some(duration) = inputs
.get("model.duration")
.and_then(value_to_duration_seconds)
{
inputs.insert("model.duration".to_string(), json!(duration.clamp(4, 15)));
}
if inputs
.get("resolution")
.is_some_and(|value| {
let normalized = value.as_str().unwrap_or_default().trim().to_ascii_lowercase();
!matches!(normalized.as_str(), "480p" | "720p" | "1080p")
})
{
if inputs.get("resolution").is_some_and(|value| {
let normalized = value
.as_str()
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
!matches!(normalized.as_str(), "480p" | "720p" | "1080p")
}) {
inputs.insert("resolution".to_string(), json!("480p"));
} else if let Some(normalized) = inputs
.get("resolution")
@@ -1037,10 +1188,12 @@ fn repair_bytedance_video_nodes(prompt: &mut Value) {
{
inputs.insert("resolution".to_string(), json!(normalized));
}
if inputs
.get("aspect_ratio")
.is_some_and(|value| !matches!(value.as_str(), Some("16:9" | "4:3" | "1:1" | "3:4" | "9:16" | "21:9")))
{
if inputs.get("aspect_ratio").is_some_and(|value| {
!matches!(
value.as_str(),
Some("16:9" | "4:3" | "1:1" | "3:4" | "9:16" | "21:9")
)
}) {
inputs.insert("aspect_ratio".to_string(), json!("9:16"));
}
}
@@ -1061,7 +1214,10 @@ fn repair_resize_image_mask_nodes(prompt: &mut Value) {
.get("resize_type.crop")
.is_some_and(|value| !matches!(value.as_str(), Some("disabled" | "center")))
{
inputs.insert("resize_type.crop".to_string(), Value::String("center".to_string()));
inputs.insert(
"resize_type.crop".to_string(),
Value::String("center".to_string()),
);
}
}
}
@@ -1088,7 +1244,9 @@ fn repair_text_generate_ltx2_prompt_nodes(prompt: &mut Value) {
inputs.insert("sampling_mode.top_p".to_string(), json!(0.95));
inputs.insert("sampling_mode.min_p".to_string(), json!(0.05));
inputs.insert("sampling_mode.repetition_penalty".to_string(), json!(1.05));
inputs.entry("sampling_mode.seed".to_string()).or_insert_with(|| json!(0));
inputs
.entry("sampling_mode.seed".to_string())
.or_insert_with(|| json!(0));
inputs.insert("sampling_mode.presence_penalty".to_string(), json!(0.0));
}
}
@@ -1099,7 +1257,10 @@ fn repair_wan2_api_nodes(prompt: &mut Value) {
return;
};
for (_node_id, node) in nodes.iter_mut() {
let class_type = node.get("class_type").and_then(Value::as_str).unwrap_or_default();
let class_type = node
.get("class_type")
.and_then(Value::as_str)
.unwrap_or_default();
if !class_type.starts_with("Wan2") || !class_type.ends_with("Api") {
continue;
}
@@ -1117,7 +1278,10 @@ fn repair_wan2_model_group(inputs: &mut Map<String, Value>) {
inputs.insert("model".to_string(), model);
}
if let Some(resolution) = items.get(1).cloned() {
inputs.insert("model.resolution".to_string(), normalize_resolution_for_comfy(&resolution));
inputs.insert(
"model.resolution".to_string(),
normalize_resolution_for_comfy(&resolution),
);
}
if let Some(duration) = items.get(2).and_then(value_to_duration_seconds) {
inputs.insert("model.duration".to_string(), json!(duration));
@@ -1129,7 +1293,10 @@ fn repair_wan2_model_group(inputs: &mut Map<String, Value>) {
{
inputs.insert("model.resolution".to_string(), json!("1080P"));
}
if let Some(duration) = inputs.get("model.duration").and_then(value_to_duration_seconds) {
if let Some(duration) = inputs
.get("model.duration")
.and_then(value_to_duration_seconds)
{
inputs.insert("model.duration".to_string(), json!(duration));
}
}
@@ -1164,7 +1331,10 @@ fn comfy_failure_message(value: &Value) -> Option<String> {
let status = scalar_string(value.get("status").or_else(|| value.get("state")))
.unwrap_or_default()
.to_ascii_lowercase();
if matches!(status.as_str(), "failed" | "error" | "cancelled" | "canceled") {
if matches!(
status.as_str(),
"failed" | "error" | "cancelled" | "canceled"
) {
return Some(
scalar_string(value.pointer("/execution_error/exception_message"))
.or_else(|| scalar_string(value.get("error")))
@@ -1182,7 +1352,10 @@ fn comfy_output_objects(value: &Value) -> Vec<Value> {
let mut out = Vec::new();
fn push_unique(out: &mut Vec<Value>, item: Value) {
let identity = output_identity(&item);
if out.iter().any(|existing| output_identity(existing) == identity) {
if out
.iter()
.any(|existing| output_identity(existing) == identity)
{
return;
}
out.push(item);
@@ -1229,7 +1402,10 @@ fn output_identity(item: &Value) -> String {
fn looks_like_media_reference(value: &str) -> bool {
let trimmed = value.trim();
if !(trimmed.starts_with("http://") || trimmed.starts_with("https://") || trimmed.starts_with('/')) {
if !(trimmed.starts_with("http://")
|| trimmed.starts_with("https://")
|| trimmed.starts_with('/'))
{
return false;
}
let lower = trimmed
@@ -1328,7 +1504,8 @@ fn output_kind(item: &Value) -> &'static str {
|| filename.ends_with(".gif")
{
"video"
} else if filename.ends_with(".mp3") || filename.ends_with(".wav") || filename.ends_with(".m4a") {
} else if filename.ends_with(".mp3") || filename.ends_with(".wav") || filename.ends_with(".m4a")
{
"audio"
} else {
"image"
@@ -1390,7 +1567,11 @@ async fn archive_comfy_output(
}
let query = output_query(item)?;
let kind = output_kind(item).to_string();
let path = if kind == "video" { "/viewvideo" } else { "/view" };
let path = if kind == "video" {
"/viewvideo"
} else {
"/view"
};
let response = native_request(state, Method::GET, &format!("{path}{query}"))
.send()
.await
@@ -1455,9 +1636,12 @@ async fn submit_comfy_video(
}
let video_prompt = inject_comfy_video_inputs(&mut prompt, &mapping, body, plan)?;
let (prompt_id, response) = submit_comfy_prompt(state, prompt).await?;
let model = string_from(body, &["comfyApiModel", "comfy_api_model", "apiModel", "api_model"])
.map(|value| normalize_video_model(&value))
.unwrap_or_else(|| "comfy-api".to_string());
let model = string_from(
body,
&["comfyApiModel", "comfy_api_model", "apiModel", "api_model"],
)
.map(|value| normalize_video_model(&value))
.unwrap_or_else(|| "comfy-api".to_string());
Ok(json!({
"backend": "comfy",
"promptId": prompt_id,
@@ -1488,7 +1672,8 @@ async fn submit_video(
return Err(AppError::BadRequest("video prompt is empty".into()));
}
let size = string_from(body, &["size", "resolution"]).unwrap_or_else(|| "1080p".to_string());
let ratio = string_from(body, &["ratio", "aspectRatio", "aspect_ratio"]).unwrap_or_else(|| "9:16".to_string());
let ratio = string_from(body, &["ratio", "aspectRatio", "aspect_ratio"])
.unwrap_or_else(|| "9:16".to_string());
let duration = body
.get("duration")
.and_then(Value::as_i64)
@@ -1559,7 +1744,10 @@ async fn poll_video(
.and_then(Value::as_str)
.unwrap_or_default()
.to_ascii_lowercase();
if matches!(state_text.as_str(), "failed" | "error" | "cancelled" | "canceled") {
if matches!(
state_text.as_str(),
"failed" | "error" | "cancelled" | "canceled"
) {
return Err(AppError::UpstreamError(value.to_string()));
}
tokio::time::sleep(Duration::from_secs(VIDEO_POLL_INTERVAL_SECS)).await;
@@ -1617,8 +1805,15 @@ async fn create_job(
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let auth = resolve_auth(&headers, &state).await?;
if !state.config.comfyui.uses_docker_backend() {
return Err(AppError::BadRequest(
"短视频工厂暂时只支持本地模型部署;当前 Comfy/RunningHub/Cloud API 路径已禁用。".into(),
));
}
if let Some(key_id) = auth.key_id {
UsageTracker::new(state.db.clone()).check_rate_limit(key_id).await?;
UsageTracker::new(state.db.clone())
.check_rate_limit(key_id)
.await?;
}
let topic = string_from(&body, &["topic", "prompt"])
.ok_or_else(|| AppError::BadRequest("topic is required".into()))?;
@@ -1656,7 +1851,15 @@ async fn create_job(
.to_ascii_lowercase();
let failure_model = string_from(
&failure_body,
&["comfyApiModel", "comfy_api_model", "apiModel", "api_model", "videoModel", "video_model", "model"],
&[
"comfyApiModel",
"comfy_api_model",
"apiModel",
"api_model",
"videoModel",
"video_model",
"model",
],
)
.map(|value| normalize_video_model(&value))
.unwrap_or_else(|| {
@@ -2021,7 +2224,10 @@ async fn clear_jobs(State(state): State<AppState>, headers: HeaderMap) -> AppRes
mod tests {
use super::*;
use crate::{
config::{AppConfig, AuthConfig, ComfyUIBackend, ComfyUIConfig, DbConfig, ProxyConfig, ServerConfig},
config::{
AppConfig, AuthConfig, ComfyUIBackend, ComfyUIConfig, DbConfig, ProxyConfig,
ServerConfig,
},
services::{builtin_keys::BuiltinKeys, runtime_auth::RuntimeAuthSettings},
};
use axum::http::HeaderValue;
@@ -2071,6 +2277,8 @@ mod tests {
template_proxy_url: None,
},
comfyui,
pixelle_video: Default::default(),
payment: Default::default(),
},
pool,
BuiltinKeys {
@@ -2081,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(
@@ -2093,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"
@@ -2132,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"));
}
@@ -2152,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]
@@ -2161,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]
@@ -2199,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)
);
}
}
+2
View File
@@ -2550,6 +2550,8 @@ mod tests {
template_proxy_url: None,
},
comfyui: ComfyUIConfig::default(),
pixelle_video: Default::default(),
payment: Default::default(),
},
pool,
BuiltinKeys {
+4 -5
View File
@@ -1,21 +1,21 @@
use crate::{
AppState,
auth::jwt::validate_jwt,
db::models::background_task::BackgroundTask,
db::models::downstream_api_key::DownstreamApiKey,
error::{AppError, AppResult},
AppState,
};
use axum::{
Json, Router,
body::Body,
extract::{Multipart, Path, Query, State},
http::{HeaderMap, Response, StatusCode, header},
http::{header, HeaderMap, Response, StatusCode},
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use reqwest::Client;
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
use std::time::Duration;
const TEMPLATE_GALLERY_PAGE_URL: &str =
@@ -291,7 +291,6 @@ mod tests {
assert!(!is_picture_model_pattern("deepseek-chat"));
assert!(!is_picture_model_pattern("qwen-plus"));
}
}
fn prompt_title(prompt: &str, post_number: Option<i64>) -> String {
+2
View File
@@ -2121,6 +2121,8 @@ mod tests {
template_proxy_url: None,
},
comfyui: Default::default(),
pixelle_video: Default::default(),
payment: Default::default(),
},
pool,
crate::services::builtin_keys::BuiltinKeys {
+2
View File
@@ -696,6 +696,8 @@ mod tests {
template_proxy_url: None,
},
comfyui: ComfyUIConfig::default(),
pixelle_video: Default::default(),
payment: Default::default(),
},
pool,
BuiltinKeys {
+306
View File
@@ -5616,6 +5616,292 @@ fn inject_enhanced_bootstrap(html: String) -> String {
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));
@@ -5773,6 +6059,10 @@ fn inject_enhanced_bootstrap(html: String) -> String {
installBoosApiFetchAuth();
patchWorkflowConnectionTypes();
startWorkflowSlotNormalizer();
installWorkflowConnectionMenuMemory();
installWorkflowConnectionMenuObserver();
boostWorkflowSlotDefaults();
enhanceWorkflowConnectionMenu();
patchModelLibraryClicks();
installWorkflowValidationButton();
localizeWorkflowMenus();
@@ -5872,4 +6162,20 @@ mod tests {
"public, max-age=604800"
);
}
#[test]
fn enhanced_bootstrap_prioritizes_connection_slot_suggestions() {
let html = inject_enhanced_bootstrap("<html><head></head><body></body></html>".to_string());
assert!(html.contains("workflowConnectionQuickNodes"));
assert!(html.contains("installWorkflowConnectionMenuMemory"));
assert!(html.contains("最相关"));
assert!(html.contains("PreviewImage"));
assert!(html.contains("boostWorkflowSlotDefaults"));
assert!(html.contains("fallbackWorkflowConnectionItems"));
assert!(html.contains("KSampler"));
assert!(html.contains("CLIPTextEncode"));
assert!(html.contains("MaskToImage"));
assert!(html.contains("VHS_VideoCombine"));
}
}
+38 -36
View File
@@ -218,25 +218,25 @@ async fn list_assets(
LIMIT ? OFFSET ?"
);
let items = sqlx::query(&list_sql)
.bind(is_admin)
.bind(scoped_user_id)
.bind(scoped_user_id)
.bind(is_admin)
.bind(scoped_user_id)
.bind(&source)
.bind(&source)
.bind(&kind)
.bind(&kind)
.bind(&q)
.bind(&q)
.bind(&q)
.bind(&q)
.bind(&q)
.bind(page_size)
.bind(offset)
.map(row_to_json)
.fetch_all(&*state.db)
.await?;
.bind(is_admin)
.bind(scoped_user_id)
.bind(scoped_user_id)
.bind(is_admin)
.bind(scoped_user_id)
.bind(&source)
.bind(&source)
.bind(&kind)
.bind(&kind)
.bind(&q)
.bind(&q)
.bind(&q)
.bind(&q)
.bind(&q)
.bind(page_size)
.bind(offset)
.map(row_to_json)
.fetch_all(&*state.db)
.await?;
let count_sql = format!(
"SELECT COUNT(*)
@@ -255,22 +255,22 @@ async fn list_assets(
)"
);
let total: i64 = sqlx::query_scalar(&count_sql)
.bind(is_admin)
.bind(scoped_user_id)
.bind(scoped_user_id)
.bind(is_admin)
.bind(scoped_user_id)
.bind(&source)
.bind(&source)
.bind(&kind)
.bind(&kind)
.bind(&q)
.bind(&q)
.bind(&q)
.bind(&q)
.bind(&q)
.fetch_one(&*state.db)
.await?;
.bind(is_admin)
.bind(scoped_user_id)
.bind(scoped_user_id)
.bind(is_admin)
.bind(scoped_user_id)
.bind(&source)
.bind(&source)
.bind(&kind)
.bind(&kind)
.bind(&q)
.bind(&q)
.bind(&q)
.bind(&q)
.bind(&q)
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({
"success": true,
@@ -398,7 +398,9 @@ async fn delete_assets_batch(
.execute(&*state.db)
.await?;
if result.rows_affected() > 0 {
if let Err(err) = crate::services::workspace::project_deleted_asset(&state, &public_id).await {
if let Err(err) =
crate::services::workspace::project_deleted_asset(&state, &public_id).await
{
tracing::warn!(%err, %public_id, "failed to update workspace asset batch delete projection");
}
deleted_ids.push(public_id);