refactor(workflow_ui): 抽出 validate 子模块
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+16
-299
@@ -114,9 +114,9 @@ struct QueueMutation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct WorkflowValidateQuery {
|
pub(super) struct WorkflowValidateQuery {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
check_files: bool,
|
pub(super) check_files: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
@@ -128,9 +128,9 @@ use node_defs::{
|
|||||||
};
|
};
|
||||||
mod models;
|
mod models;
|
||||||
use models::{
|
use models::{
|
||||||
combo_fields_from_node, image_model_choices, is_video_workflow_model,
|
image_model_choices, is_video_workflow_model, native_folder_models_from_object_info,
|
||||||
native_folder_models_from_object_info, workflow_folder_has_route_models,
|
workflow_folder_has_route_models, workflow_folder_is_api_only, workflow_folder_route_model_items,
|
||||||
workflow_folder_is_api_only, workflow_folder_route_model_items, workflow_route_models,
|
workflow_route_models,
|
||||||
};
|
};
|
||||||
mod assets;
|
mod assets;
|
||||||
use assets::{
|
use assets::{
|
||||||
@@ -143,6 +143,8 @@ use comfy_proxy::{
|
|||||||
object_info, store_setting, store_settings, system_stats, userdata_delete, userdata_get,
|
object_info, store_setting, store_settings, system_stats, userdata_delete, userdata_get,
|
||||||
userdata_list, userdata_store, workflow_templates,
|
userdata_list, userdata_store, workflow_templates,
|
||||||
};
|
};
|
||||||
|
mod validate;
|
||||||
|
use validate::validate_workflow;
|
||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
@@ -792,237 +794,7 @@ async fn workflow_cache_status(
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn validate_workflow(
|
pub(super) fn workflow_linked_node_ids(links: &[Value]) -> HashSet<i64> {
|
||||||
State(state): State<AppState>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
Query(query): Query<WorkflowValidateQuery>,
|
|
||||||
Json(workflow): Json<Value>,
|
|
||||||
) -> AppResult<Json<Value>> {
|
|
||||||
let caller = auth_caller(&state, &headers).await?;
|
|
||||||
let object_info = if state.config.comfyui.uses_native_backend() {
|
|
||||||
let mut value = native_workflow_json_cached(
|
|
||||||
&state,
|
|
||||||
Method::GET,
|
|
||||||
"/object_info",
|
|
||||||
None,
|
|
||||||
native_workflow_metadata_ttl(&state),
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
.0;
|
|
||||||
let route_models = workflow_route_models(&state).await.unwrap_or_default();
|
|
||||||
inject_boosapi_object_info(&mut value, &route_models);
|
|
||||||
value
|
|
||||||
} else {
|
|
||||||
let route_models = workflow_route_models(&state).await.unwrap_or_default();
|
|
||||||
Value::Object(workflow_node_defs_with_models(&route_models, true))
|
|
||||||
};
|
|
||||||
let report = validate_workflow_against_object_info(
|
|
||||||
&state,
|
|
||||||
&caller,
|
|
||||||
&workflow,
|
|
||||||
&object_info,
|
|
||||||
query.check_files,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
Ok(Json(report))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn validate_workflow_against_object_info(
|
|
||||||
state: &AppState,
|
|
||||||
caller: &WorkflowCaller,
|
|
||||||
workflow: &Value,
|
|
||||||
object_info: &Value,
|
|
||||||
check_files: bool,
|
|
||||||
) -> Value {
|
|
||||||
let nodes = workflow
|
|
||||||
.get("nodes")
|
|
||||||
.and_then(Value::as_array)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_default();
|
|
||||||
let links = workflow
|
|
||||||
.get("links")
|
|
||||||
.and_then(Value::as_array)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_default();
|
|
||||||
let linked_node_ids = workflow_linked_node_ids(&links);
|
|
||||||
let mut missing_node_types = Vec::<Value>::new();
|
|
||||||
let mut empty_model_fields = Vec::<Value>::new();
|
|
||||||
let mut missing_model_choices = Vec::<Value>::new();
|
|
||||||
let mut unknown_model_fields = Vec::<Value>::new();
|
|
||||||
let mut input_files = Vec::<Value>::new();
|
|
||||||
let mut node_type_counts = HashMap::<String, usize>::new();
|
|
||||||
|
|
||||||
for node in &nodes {
|
|
||||||
let node_id = node.get("id").and_then(Value::as_i64).unwrap_or_default();
|
|
||||||
let node_type = node
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim()
|
|
||||||
.to_string();
|
|
||||||
if node_type.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
*node_type_counts.entry(node_type.clone()).or_default() += 1;
|
|
||||||
let connected = linked_node_ids.contains(&node_id);
|
|
||||||
let Some(node_def) = object_info.get(&node_type) else {
|
|
||||||
missing_node_types.push(json!({
|
|
||||||
"nodeId": node_id,
|
|
||||||
"nodeType": node_type,
|
|
||||||
"connected": connected,
|
|
||||||
"severity": if connected { "error" } else { "warning" },
|
|
||||||
}));
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let widgets = node
|
|
||||||
.get("widgets_values")
|
|
||||||
.and_then(Value::as_array)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_default();
|
|
||||||
let combo_fields = combo_fields_from_node(object_info, &node_type);
|
|
||||||
let widget_inputs = node
|
|
||||||
.get("inputs")
|
|
||||||
.and_then(Value::as_array)
|
|
||||||
.map(|inputs| workflow_widget_inputs(inputs))
|
|
||||||
.unwrap_or_default();
|
|
||||||
let mut widget_index = 0usize;
|
|
||||||
for input in widget_inputs {
|
|
||||||
let field = input
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
let input_type = input
|
|
||||||
.get("type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
let Some(widget_value) = widgets.get(widget_index) else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
widget_index += 1;
|
|
||||||
if !is_modelish_field(&field, &input_type, widget_value) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Some(selected) = workflow_widget_string(widget_value) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if selected.trim().is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if is_input_file_field(&field, &input_type, &selected) {
|
|
||||||
if check_files {
|
|
||||||
input_files.push(
|
|
||||||
validate_workflow_input_file(state, caller, node_id, &node_type, &selected)
|
|
||||||
.await,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
input_files.push(json!({
|
|
||||||
"nodeId": node_id,
|
|
||||||
"nodeType": node_type,
|
|
||||||
"filename": selected,
|
|
||||||
"checked": false,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let choices = combo_fields
|
|
||||||
.iter()
|
|
||||||
.find(|(name, _)| name == &field)
|
|
||||||
.map(|(_, choices)| choices.clone())
|
|
||||||
.or_else(|| {
|
|
||||||
combo_fields
|
|
||||||
.iter()
|
|
||||||
.find(|(name, _)| field_names_are_equivalent(name, &field))
|
|
||||||
.map(|(_, choices)| choices.clone())
|
|
||||||
});
|
|
||||||
match choices {
|
|
||||||
Some(choices) if choices.is_empty() => empty_model_fields.push(json!({
|
|
||||||
"nodeId": node_id,
|
|
||||||
"nodeType": node_type,
|
|
||||||
"field": field,
|
|
||||||
"selected": selected,
|
|
||||||
"severity": if connected { "error" } else { "warning" },
|
|
||||||
"message": "后端这个模型/LoRA字段返回空列表,当前选择无法确认可用。",
|
|
||||||
})),
|
|
||||||
Some(choices) if !choices.iter().any(|choice| choice == &selected) => {
|
|
||||||
let near = choices
|
|
||||||
.iter()
|
|
||||||
.filter(|choice| {
|
|
||||||
let a = choice.to_ascii_lowercase();
|
|
||||||
let b = selected.to_ascii_lowercase();
|
|
||||||
a.contains(&b) || b.contains(&a)
|
|
||||||
})
|
|
||||||
.take(8)
|
|
||||||
.cloned()
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
missing_model_choices.push(json!({
|
|
||||||
"nodeId": node_id,
|
|
||||||
"nodeType": node_type,
|
|
||||||
"field": field,
|
|
||||||
"selected": selected,
|
|
||||||
"near": near,
|
|
||||||
"availableCount": choices.len(),
|
|
||||||
"severity": if connected { "error" } else { "warning" },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
Some(_) => {}
|
|
||||||
None => {
|
|
||||||
if node_def.get("input").is_some() {
|
|
||||||
unknown_model_fields.push(json!({
|
|
||||||
"nodeId": node_id,
|
|
||||||
"nodeType": node_type,
|
|
||||||
"field": field,
|
|
||||||
"selected": selected,
|
|
||||||
"severity": "info",
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let errors = missing_node_types
|
|
||||||
.iter()
|
|
||||||
.chain(empty_model_fields.iter())
|
|
||||||
.chain(missing_model_choices.iter())
|
|
||||||
.filter(|item| {
|
|
||||||
item.get("severity")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.is_some_and(|severity| severity == "error")
|
|
||||||
})
|
|
||||||
.count();
|
|
||||||
let warnings = missing_node_types
|
|
||||||
.iter()
|
|
||||||
.chain(empty_model_fields.iter())
|
|
||||||
.chain(missing_model_choices.iter())
|
|
||||||
.filter(|item| {
|
|
||||||
item.get("severity")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.is_some_and(|severity| severity == "warning")
|
|
||||||
})
|
|
||||||
.count();
|
|
||||||
|
|
||||||
json!({
|
|
||||||
"success": true,
|
|
||||||
"compatible": errors == 0,
|
|
||||||
"summary": {
|
|
||||||
"nodes": nodes.len(),
|
|
||||||
"links": links.len(),
|
|
||||||
"nodeTypes": node_type_counts.len(),
|
|
||||||
"errors": errors,
|
|
||||||
"warnings": warnings,
|
|
||||||
},
|
|
||||||
"missingNodeTypes": missing_node_types,
|
|
||||||
"emptyModelFields": empty_model_fields,
|
|
||||||
"missingModelChoices": missing_model_choices,
|
|
||||||
"unknownModelFields": unknown_model_fields,
|
|
||||||
"inputFiles": input_files,
|
|
||||||
"hints": workflow_validation_hints(errors, warnings),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn workflow_linked_node_ids(links: &[Value]) -> HashSet<i64> {
|
|
||||||
let mut ids = HashSet::new();
|
let mut ids = HashSet::new();
|
||||||
for link in links {
|
for link in links {
|
||||||
let Some(parts) = link.as_array() else {
|
let Some(parts) = link.as_array() else {
|
||||||
@@ -1038,7 +810,7 @@ fn workflow_linked_node_ids(links: &[Value]) -> HashSet<i64> {
|
|||||||
ids
|
ids
|
||||||
}
|
}
|
||||||
|
|
||||||
fn workflow_widget_inputs(inputs: &[Value]) -> Vec<Value> {
|
pub(super) fn workflow_widget_inputs(inputs: &[Value]) -> Vec<Value> {
|
||||||
inputs
|
inputs
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|input| input.get("widget").is_some())
|
.filter(|input| input.get("widget").is_some())
|
||||||
@@ -1046,7 +818,7 @@ fn workflow_widget_inputs(inputs: &[Value]) -> Vec<Value> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn workflow_widget_string(value: &Value) -> Option<String> {
|
pub(super) fn workflow_widget_string(value: &Value) -> Option<String> {
|
||||||
match value {
|
match value {
|
||||||
Value::String(value) => Some(value.clone()),
|
Value::String(value) => Some(value.clone()),
|
||||||
Value::Number(value) => Some(value.to_string()),
|
Value::Number(value) => Some(value.to_string()),
|
||||||
@@ -1054,7 +826,7 @@ fn workflow_widget_string(value: &Value) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn field_names_are_equivalent(a: &str, b: &str) -> bool {
|
pub(super) fn field_names_are_equivalent(a: &str, b: &str) -> bool {
|
||||||
let a = a.trim().to_ascii_lowercase();
|
let a = a.trim().to_ascii_lowercase();
|
||||||
let b = b.trim().to_ascii_lowercase();
|
let b = b.trim().to_ascii_lowercase();
|
||||||
a == b
|
a == b
|
||||||
@@ -1064,7 +836,7 @@ fn field_names_are_equivalent(a: &str, b: &str) -> bool {
|
|||||||
|| (b.ends_with("_name") && b.trim_end_matches("_name") == a)
|
|| (b.ends_with("_name") && b.trim_end_matches("_name") == a)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_modelish_field(field: &str, input_type: &str, value: &Value) -> bool {
|
pub(super) fn is_modelish_field(field: &str, input_type: &str, value: &Value) -> bool {
|
||||||
let field = field.to_ascii_lowercase();
|
let field = field.to_ascii_lowercase();
|
||||||
let input_type = input_type.to_ascii_uppercase();
|
let input_type = input_type.to_ascii_uppercase();
|
||||||
if matches!(value, Value::String(_)) {
|
if matches!(value, Value::String(_)) {
|
||||||
@@ -1084,7 +856,7 @@ fn is_modelish_field(field: &str, input_type: &str, value: &Value) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_input_file_field(field: &str, input_type: &str, selected: &str) -> bool {
|
pub(super) fn is_input_file_field(field: &str, input_type: &str, selected: &str) -> bool {
|
||||||
let field = field.to_ascii_lowercase();
|
let field = field.to_ascii_lowercase();
|
||||||
let input_type = input_type.to_ascii_uppercase();
|
let input_type = input_type.to_ascii_uppercase();
|
||||||
let selected = selected.to_ascii_lowercase();
|
let selected = selected.to_ascii_lowercase();
|
||||||
@@ -1105,62 +877,7 @@ fn is_input_file_field(field: &str, input_type: &str, selected: &str) -> bool {
|
|||||||
|| selected.ends_with(".webp")
|
|| selected.ends_with(".webp")
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn validate_workflow_input_file(
|
pub(super) fn workflow_validation_hints(errors: usize, warnings: usize) -> Vec<String> {
|
||||||
state: &AppState,
|
|
||||||
caller: &WorkflowCaller,
|
|
||||||
node_id: i64,
|
|
||||||
node_type: &str,
|
|
||||||
filename: &str,
|
|
||||||
) -> Value {
|
|
||||||
if state.config.comfyui.uses_native_backend() {
|
|
||||||
let mut params = HashMap::new();
|
|
||||||
params.insert("filename".to_string(), filename.to_string());
|
|
||||||
params.insert("type".to_string(), "input".to_string());
|
|
||||||
let path = format!("/view{}", build_query_string(¶ms));
|
|
||||||
let result = native_workflow_request(state, Method::GET, &path, None)
|
|
||||||
.header(reqwest::header::RANGE, "bytes=0-0")
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
return match result {
|
|
||||||
Ok(response) => json!({
|
|
||||||
"nodeId": node_id,
|
|
||||||
"nodeType": node_type,
|
|
||||||
"filename": filename,
|
|
||||||
"checked": true,
|
|
||||||
"reachable": response.status().is_success(),
|
|
||||||
"status": response.status().as_u16(),
|
|
||||||
"contentType": response
|
|
||||||
.headers()
|
|
||||||
.get(reqwest::header::CONTENT_TYPE)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.unwrap_or(""),
|
|
||||||
}),
|
|
||||||
Err(err) => json!({
|
|
||||||
"nodeId": node_id,
|
|
||||||
"nodeType": node_type,
|
|
||||||
"filename": filename,
|
|
||||||
"checked": true,
|
|
||||||
"reachable": false,
|
|
||||||
"error": err.to_string(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
let user_id = caller_user_id(caller);
|
|
||||||
let path = user_home(user_id)
|
|
||||||
.join("userdata")
|
|
||||||
.join("input")
|
|
||||||
.join(safe_view_path(filename));
|
|
||||||
json!({
|
|
||||||
"nodeId": node_id,
|
|
||||||
"nodeType": node_type,
|
|
||||||
"filename": filename,
|
|
||||||
"checked": true,
|
|
||||||
"reachable": tokio::fs::metadata(&path).await.is_ok(),
|
|
||||||
"localPath": path,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn workflow_validation_hints(errors: usize, warnings: usize) -> Vec<String> {
|
|
||||||
let mut hints = Vec::new();
|
let mut hints = Vec::new();
|
||||||
if errors > 0 {
|
if errors > 0 {
|
||||||
hints.push("存在已连接主链路的缺失节点或模型选择,直接执行大概率会失败。".to_string());
|
hints.push("存在已连接主链路的缺失节点或模型选择,直接执行大概率会失败。".to_string());
|
||||||
@@ -2471,7 +2188,7 @@ fn native_workflow_http_path(state: &AppState, path: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn native_workflow_request(
|
pub(super) fn native_workflow_request(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
method: Method,
|
method: Method,
|
||||||
path: &str,
|
path: &str,
|
||||||
@@ -2857,7 +2574,7 @@ async fn repair_native_prompt_body_value(state: &AppState, value: &mut Value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_query_string(params: &HashMap<String, String>) -> String {
|
pub(super) fn build_query_string(params: &HashMap<String, String>) -> String {
|
||||||
if params.is_empty() {
|
if params.is_empty() {
|
||||||
return String::new();
|
return String::new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
use crate::{error::AppResult, AppState};
|
||||||
|
use axum::{
|
||||||
|
extract::{Query, State},
|
||||||
|
http::{HeaderMap, Method},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use super::auth::{auth_caller, caller_user_id};
|
||||||
|
use super::models::{combo_fields_from_node, workflow_route_models};
|
||||||
|
use super::{
|
||||||
|
build_query_string, field_names_are_equivalent, inject_boosapi_object_info,
|
||||||
|
is_input_file_field, is_modelish_field, native_workflow_json_cached,
|
||||||
|
native_workflow_metadata_ttl, native_workflow_request, safe_view_path, user_home,
|
||||||
|
workflow_linked_node_ids, workflow_node_defs_with_models, workflow_validation_hints,
|
||||||
|
workflow_widget_inputs, workflow_widget_string, WorkflowCaller, WorkflowValidateQuery,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(super) async fn validate_workflow(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(query): Query<WorkflowValidateQuery>,
|
||||||
|
Json(workflow): Json<Value>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let caller = auth_caller(&state, &headers).await?;
|
||||||
|
let object_info = if state.config.comfyui.uses_native_backend() {
|
||||||
|
let mut value = native_workflow_json_cached(
|
||||||
|
&state,
|
||||||
|
Method::GET,
|
||||||
|
"/object_info",
|
||||||
|
None,
|
||||||
|
native_workflow_metadata_ttl(&state),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.0;
|
||||||
|
let route_models = workflow_route_models(&state).await.unwrap_or_default();
|
||||||
|
inject_boosapi_object_info(&mut value, &route_models);
|
||||||
|
value
|
||||||
|
} else {
|
||||||
|
let route_models = workflow_route_models(&state).await.unwrap_or_default();
|
||||||
|
Value::Object(workflow_node_defs_with_models(&route_models, true))
|
||||||
|
};
|
||||||
|
let report = validate_workflow_against_object_info(
|
||||||
|
&state,
|
||||||
|
&caller,
|
||||||
|
&workflow,
|
||||||
|
&object_info,
|
||||||
|
query.check_files,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Ok(Json(report))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate_workflow_against_object_info(
|
||||||
|
state: &AppState,
|
||||||
|
caller: &WorkflowCaller,
|
||||||
|
workflow: &Value,
|
||||||
|
object_info: &Value,
|
||||||
|
check_files: bool,
|
||||||
|
) -> Value {
|
||||||
|
let nodes = workflow
|
||||||
|
.get("nodes")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let links = workflow
|
||||||
|
.get("links")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let linked_node_ids = workflow_linked_node_ids(&links);
|
||||||
|
let mut missing_node_types = Vec::<Value>::new();
|
||||||
|
let mut empty_model_fields = Vec::<Value>::new();
|
||||||
|
let mut missing_model_choices = Vec::<Value>::new();
|
||||||
|
let mut unknown_model_fields = Vec::<Value>::new();
|
||||||
|
let mut input_files = Vec::<Value>::new();
|
||||||
|
let mut node_type_counts = HashMap::<String, usize>::new();
|
||||||
|
|
||||||
|
for node in &nodes {
|
||||||
|
let node_id = node.get("id").and_then(Value::as_i64).unwrap_or_default();
|
||||||
|
let node_type = node
|
||||||
|
.get("type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
if node_type.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
*node_type_counts.entry(node_type.clone()).or_default() += 1;
|
||||||
|
let connected = linked_node_ids.contains(&node_id);
|
||||||
|
let Some(node_def) = object_info.get(&node_type) else {
|
||||||
|
missing_node_types.push(json!({
|
||||||
|
"nodeId": node_id,
|
||||||
|
"nodeType": node_type,
|
||||||
|
"connected": connected,
|
||||||
|
"severity": if connected { "error" } else { "warning" },
|
||||||
|
}));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let widgets = node
|
||||||
|
.get("widgets_values")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let combo_fields = combo_fields_from_node(object_info, &node_type);
|
||||||
|
let widget_inputs = node
|
||||||
|
.get("inputs")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|inputs| workflow_widget_inputs(inputs))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut widget_index = 0usize;
|
||||||
|
for input in widget_inputs {
|
||||||
|
let field = input
|
||||||
|
.get("name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let input_type = input
|
||||||
|
.get("type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let Some(widget_value) = widgets.get(widget_index) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
widget_index += 1;
|
||||||
|
if !is_modelish_field(&field, &input_type, widget_value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(selected) = workflow_widget_string(widget_value) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if selected.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if is_input_file_field(&field, &input_type, &selected) {
|
||||||
|
if check_files {
|
||||||
|
input_files.push(
|
||||||
|
validate_workflow_input_file(state, caller, node_id, &node_type, &selected)
|
||||||
|
.await,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
input_files.push(json!({
|
||||||
|
"nodeId": node_id,
|
||||||
|
"nodeType": node_type,
|
||||||
|
"filename": selected,
|
||||||
|
"checked": false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let choices = combo_fields
|
||||||
|
.iter()
|
||||||
|
.find(|(name, _)| name == &field)
|
||||||
|
.map(|(_, choices)| choices.clone())
|
||||||
|
.or_else(|| {
|
||||||
|
combo_fields
|
||||||
|
.iter()
|
||||||
|
.find(|(name, _)| field_names_are_equivalent(name, &field))
|
||||||
|
.map(|(_, choices)| choices.clone())
|
||||||
|
});
|
||||||
|
match choices {
|
||||||
|
Some(choices) if choices.is_empty() => empty_model_fields.push(json!({
|
||||||
|
"nodeId": node_id,
|
||||||
|
"nodeType": node_type,
|
||||||
|
"field": field,
|
||||||
|
"selected": selected,
|
||||||
|
"severity": if connected { "error" } else { "warning" },
|
||||||
|
"message": "后端这个模型/LoRA字段返回空列表,当前选择无法确认可用。",
|
||||||
|
})),
|
||||||
|
Some(choices) if !choices.iter().any(|choice| choice == &selected) => {
|
||||||
|
let near = choices
|
||||||
|
.iter()
|
||||||
|
.filter(|choice| {
|
||||||
|
let a = choice.to_ascii_lowercase();
|
||||||
|
let b = selected.to_ascii_lowercase();
|
||||||
|
a.contains(&b) || b.contains(&a)
|
||||||
|
})
|
||||||
|
.take(8)
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
missing_model_choices.push(json!({
|
||||||
|
"nodeId": node_id,
|
||||||
|
"nodeType": node_type,
|
||||||
|
"field": field,
|
||||||
|
"selected": selected,
|
||||||
|
"near": near,
|
||||||
|
"availableCount": choices.len(),
|
||||||
|
"severity": if connected { "error" } else { "warning" },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
|
None => {
|
||||||
|
if node_def.get("input").is_some() {
|
||||||
|
unknown_model_fields.push(json!({
|
||||||
|
"nodeId": node_id,
|
||||||
|
"nodeType": node_type,
|
||||||
|
"field": field,
|
||||||
|
"selected": selected,
|
||||||
|
"severity": "info",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let errors = missing_node_types
|
||||||
|
.iter()
|
||||||
|
.chain(empty_model_fields.iter())
|
||||||
|
.chain(missing_model_choices.iter())
|
||||||
|
.filter(|item| {
|
||||||
|
item.get("severity")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|severity| severity == "error")
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
let warnings = missing_node_types
|
||||||
|
.iter()
|
||||||
|
.chain(empty_model_fields.iter())
|
||||||
|
.chain(missing_model_choices.iter())
|
||||||
|
.filter(|item| {
|
||||||
|
item.get("severity")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|severity| severity == "warning")
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"success": true,
|
||||||
|
"compatible": errors == 0,
|
||||||
|
"summary": {
|
||||||
|
"nodes": nodes.len(),
|
||||||
|
"links": links.len(),
|
||||||
|
"nodeTypes": node_type_counts.len(),
|
||||||
|
"errors": errors,
|
||||||
|
"warnings": warnings,
|
||||||
|
},
|
||||||
|
"missingNodeTypes": missing_node_types,
|
||||||
|
"emptyModelFields": empty_model_fields,
|
||||||
|
"missingModelChoices": missing_model_choices,
|
||||||
|
"unknownModelFields": unknown_model_fields,
|
||||||
|
"inputFiles": input_files,
|
||||||
|
"hints": workflow_validation_hints(errors, warnings),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate_workflow_input_file(
|
||||||
|
state: &AppState,
|
||||||
|
caller: &WorkflowCaller,
|
||||||
|
node_id: i64,
|
||||||
|
node_type: &str,
|
||||||
|
filename: &str,
|
||||||
|
) -> Value {
|
||||||
|
if state.config.comfyui.uses_native_backend() {
|
||||||
|
let mut params = HashMap::new();
|
||||||
|
params.insert("filename".to_string(), filename.to_string());
|
||||||
|
params.insert("type".to_string(), "input".to_string());
|
||||||
|
let path = format!("/view{}", build_query_string(¶ms));
|
||||||
|
let result = native_workflow_request(state, Method::GET, &path, None)
|
||||||
|
.header(reqwest::header::RANGE, "bytes=0-0")
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
return match result {
|
||||||
|
Ok(response) => json!({
|
||||||
|
"nodeId": node_id,
|
||||||
|
"nodeType": node_type,
|
||||||
|
"filename": filename,
|
||||||
|
"checked": true,
|
||||||
|
"reachable": response.status().is_success(),
|
||||||
|
"status": response.status().as_u16(),
|
||||||
|
"contentType": response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.unwrap_or(""),
|
||||||
|
}),
|
||||||
|
Err(err) => json!({
|
||||||
|
"nodeId": node_id,
|
||||||
|
"nodeType": node_type,
|
||||||
|
"filename": filename,
|
||||||
|
"checked": true,
|
||||||
|
"reachable": false,
|
||||||
|
"error": err.to_string(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let user_id = caller_user_id(caller);
|
||||||
|
let path = user_home(user_id)
|
||||||
|
.join("userdata")
|
||||||
|
.join("input")
|
||||||
|
.join(safe_view_path(filename));
|
||||||
|
json!({
|
||||||
|
"nodeId": node_id,
|
||||||
|
"nodeType": node_type,
|
||||||
|
"filename": filename,
|
||||||
|
"checked": true,
|
||||||
|
"reachable": tokio::fs::metadata(&path).await.is_ok(),
|
||||||
|
"localPath": path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user