refactor(workflow_ui): 抽出 models 子模块

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
boos
2026-06-18 14:23:17 +08:00
parent 7b1167bd75
commit 334ed04a6a
2 changed files with 308 additions and 297 deletions
+8 -297
View File
@@ -127,6 +127,12 @@ use node_defs::{
inject_boosapi_workflow_template_payload, node_type_def_to_comfy, normalize_prompt_payload, inject_boosapi_workflow_template_payload, node_type_def_to_comfy, normalize_prompt_payload,
text_to_video_workflow_graph, workflow_template_payload, text_to_video_workflow_graph, workflow_template_payload,
}; };
mod models;
use models::{
combo_fields_from_node, image_model_choices, is_video_workflow_model,
native_folder_models_from_object_info, workflow_folder_has_route_models,
workflow_folder_is_api_only, workflow_folder_route_model_items, workflow_route_models,
};
pub fn routes() -> Router<AppState> { pub fn routes() -> Router<AppState> {
Router::new() Router::new()
@@ -383,302 +389,6 @@ fn parse_boolish(input: Option<&str>) -> bool {
) )
} }
fn is_exact_workflow_model(value: &str) -> bool {
let normalized = value.trim();
!normalized.is_empty()
&& !normalized.to_ascii_lowercase().starts_with("re:")
&& !normalized.contains('*')
&& !normalized.contains('?')
}
fn is_image_workflow_model(value: &str) -> bool {
if !is_exact_workflow_model(value) {
return false;
}
let normalized = value.trim().to_ascii_lowercase();
let non_image_keywords = [
"tts",
"voice",
"audio",
"embedding",
"embed",
"seedance",
"wan2",
"t2v",
"i2v",
"flf2v",
"seaweed",
"3d",
"hitem3d",
"hyper3d",
];
if non_image_keywords
.iter()
.any(|keyword| normalized.contains(keyword))
{
return false;
}
let image_keywords = [
"gpt-image",
"dall-e",
"dalle",
"t2i",
"i2i",
"midjourney",
"recraft",
"kolors",
"cogview",
"seedream",
"sdxl",
"stable-diffusion",
"stablediffusion",
"pixart",
"playground",
"firefly",
"imagen",
"flux",
"image",
"img",
"canvas",
"painting",
"draw",
];
image_keywords
.iter()
.any(|keyword| normalized.contains(keyword))
}
fn is_video_workflow_model(value: &str) -> bool {
let normalized = value.trim().to_ascii_lowercase();
[
"seedance", "wan2", "t2v", "i2v", "flf2v", "video", "seaweed",
]
.iter()
.any(|keyword| normalized.contains(keyword))
}
fn is_audio_workflow_model(value: &str) -> bool {
let normalized = value.trim().to_ascii_lowercase();
["tts", "voice", "audio", "speech"]
.iter()
.any(|keyword| normalized.contains(keyword))
}
fn is_embedding_workflow_model(value: &str) -> bool {
let normalized = value.trim().to_ascii_lowercase();
normalized.contains("embedding") || normalized.contains("embed")
}
fn is_3d_workflow_model(value: &str) -> bool {
let normalized = value.trim().to_ascii_lowercase();
["3d", "hitem3d", "hyper3d"]
.iter()
.any(|keyword| normalized.contains(keyword))
}
fn is_vision_workflow_model(value: &str) -> bool {
let normalized = value.trim().to_ascii_lowercase();
["vision", "vl", "omni", "ui-tars"]
.iter()
.any(|keyword| normalized.contains(keyword))
&& !is_image_workflow_model(value)
&& !is_video_workflow_model(value)
&& !is_embedding_workflow_model(value)
}
fn is_chat_workflow_model(value: &str) -> bool {
is_exact_workflow_model(value)
&& !is_image_workflow_model(value)
&& !is_video_workflow_model(value)
&& !is_audio_workflow_model(value)
&& !is_embedding_workflow_model(value)
&& !is_3d_workflow_model(value)
}
async fn workflow_route_models(state: &AppState) -> AppResult<Vec<String>> {
let rows = sqlx::query_scalar::<_, String>(
"SELECT DISTINCT model_pattern
FROM token_routes
WHERE COALESCE(enabled, 1) = 1
AND model_pattern IS NOT NULL
AND model_pattern <> ''
ORDER BY model_pattern COLLATE NOCASE",
)
.fetch_all(&*state.db)
.await?;
let mut seen = std::collections::HashSet::new();
Ok(rows
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| is_exact_workflow_model(value))
.filter(|value| seen.insert(value.clone()))
.collect())
}
fn image_model_choices(models: &[String]) -> Vec<String> {
let image_models = models
.iter()
.filter(|model| is_image_workflow_model(model))
.cloned()
.collect::<Vec<_>>();
if image_models.is_empty() {
models.to_vec()
} else {
image_models
}
}
fn workflow_folder_models(folder: &str, models: &[String]) -> Vec<String> {
let normalized = folder.trim().to_ascii_lowercase();
match normalized.as_str() {
"boosapi_models" | "boosapi" | "api_models" => models.to_vec(),
"image_models" => image_model_choices(models),
"chat_models" => models
.iter()
.filter(|model| is_chat_workflow_model(model))
.cloned()
.collect(),
"clip" => models.to_vec(),
// Comfy's video_models folder is a native Comfy API model folder. Keep it
// separate from BoosAPI route models so video apps submit real Comfy API
// model names instead of OpenAI-compatible route aliases.
"video_models" => Vec::new(),
"audio_tts_models" => models
.iter()
.filter(|model| is_audio_workflow_model(model))
.cloned()
.collect(),
"embeddings" => models
.iter()
.filter(|model| is_embedding_workflow_model(model))
.cloned()
.collect(),
"3d_models" => models
.iter()
.filter(|model| is_3d_workflow_model(model))
.cloned()
.collect(),
"vision_models" => models
.iter()
.filter(|model| is_vision_workflow_model(model))
.cloned()
.collect(),
_ => Vec::new(),
}
}
fn workflow_folder_is_api_only(folder: &str) -> bool {
matches!(
folder.trim().to_ascii_lowercase().as_str(),
"boosapi" | "boosapi_models" | "api_models"
)
}
fn workflow_folder_has_route_models(folder: &str) -> bool {
matches!(
folder.trim().to_ascii_lowercase().as_str(),
"boosapi"
| "boosapi_models"
| "api_models"
| "image_models"
| "chat_models"
| "audio_tts_models"
| "embeddings"
| "3d_models"
| "vision_models"
)
}
fn workflow_folder_route_model_items(folder: &str, route_models: &[String]) -> Vec<Value> {
model_payload_items_from_names(workflow_folder_models(folder, route_models), 0)
}
fn combo_choices_from_node_input(
object_info: &Value,
node_type: &str,
input_name: &str,
) -> Vec<String> {
object_info
.get(node_type)
.and_then(|node| {
node.pointer(&format!("/input/required/{input_name}"))
.or_else(|| node.pointer(&format!("/input/optional/{input_name}")))
})
.and_then(Value::as_array)
.and_then(|input| input.first())
.and_then(Value::as_array)
.map(|choices| {
choices
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty() && *value != "None")
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
fn combo_fields_from_node(object_info: &Value, node_type: &str) -> Vec<(String, Vec<String>)> {
let mut fields = Vec::new();
let Some(node) = object_info.get(node_type) else {
return fields;
};
for section in ["required", "optional"] {
if let Some(inputs) = node
.pointer(&format!("/input/{section}"))
.and_then(Value::as_object)
{
for (name, value) in inputs {
let Some(choices) = value
.as_array()
.and_then(|input| input.first())
.and_then(Value::as_array)
else {
continue;
};
let choices = choices
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty() && *value != "None")
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
fields.push((name.clone(), choices));
}
}
}
fields
}
fn native_folder_models_from_object_info(folder: &str, object_info: &Value) -> Vec<String> {
let normalized = folder.trim().to_ascii_lowercase();
match normalized.as_str() {
"checkpoints" | "image_models" => {
combo_choices_from_node_input(object_info, "CheckpointLoaderSimple", "ckpt_name")
}
"loras" => combo_choices_from_node_input(object_info, "LoraLoader", "lora_name"),
"vae" => combo_choices_from_node_input(object_info, "VAELoader", "vae_name"),
"clip" | "clip_models" | "text_encoders" => {
combo_choices_from_node_input(object_info, "CLIPLoader", "clip_name")
}
"unet" | "diffusion_models" | "diffusion_models_files" => {
combo_choices_from_node_input(object_info, "UNETLoader", "unet_name")
}
"controlnet" | "controlnets" => {
combo_choices_from_node_input(object_info, "ControlNetLoader", "control_net_name")
}
"upscale_models" => {
combo_choices_from_node_input(object_info, "UpscaleModelLoader", "model_name")
}
"embeddings" => combo_choices_from_node_input(object_info, "EmbeddingPicker", "embedding"),
_ => Vec::new(),
}
}
fn comfy_combo_input(options: &[String], fallback: &str) -> Value { fn comfy_combo_input(options: &[String], fallback: &str) -> Value {
let choices = if options.is_empty() { let choices = if options.is_empty() {
vec![fallback.to_string()] vec![fallback.to_string()]
@@ -850,7 +560,7 @@ fn native_model_folder_payload_or_fallback(value: Value) -> Value {
} }
} }
fn model_payload_items_from_names(names: Vec<String>, path_index: i64) -> Vec<Value> { pub(super) fn model_payload_items_from_names(names: Vec<String>, path_index: i64) -> Vec<Value> {
names names
.into_iter() .into_iter()
.map(|name| json!({ "name": name, "pathIndex": path_index })) .map(|name| json!({ "name": name, "pathIndex": path_index }))
@@ -5537,6 +5247,7 @@ pub(super) fn parse_workflow_cookie(raw: &str) -> Option<&str> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use super::models::workflow_folder_models;
#[test] #[test]
fn video_model_folder_does_not_use_route_video_models() { fn video_model_folder_does_not_use_route_video_models() {
+300
View File
@@ -0,0 +1,300 @@
use super::model_payload_items_from_names;
use crate::error::AppResult;
use crate::AppState;
use serde_json::Value;
pub(super) fn is_exact_workflow_model(value: &str) -> bool {
let normalized = value.trim();
!normalized.is_empty()
&& !normalized.to_ascii_lowercase().starts_with("re:")
&& !normalized.contains('*')
&& !normalized.contains('?')
}
pub(super) fn is_image_workflow_model(value: &str) -> bool {
if !is_exact_workflow_model(value) {
return false;
}
let normalized = value.trim().to_ascii_lowercase();
let non_image_keywords = [
"tts",
"voice",
"audio",
"embedding",
"embed",
"seedance",
"wan2",
"t2v",
"i2v",
"flf2v",
"seaweed",
"3d",
"hitem3d",
"hyper3d",
];
if non_image_keywords
.iter()
.any(|keyword| normalized.contains(keyword))
{
return false;
}
let image_keywords = [
"gpt-image",
"dall-e",
"dalle",
"t2i",
"i2i",
"midjourney",
"recraft",
"kolors",
"cogview",
"seedream",
"sdxl",
"stable-diffusion",
"stablediffusion",
"pixart",
"playground",
"firefly",
"imagen",
"flux",
"image",
"img",
"canvas",
"painting",
"draw",
];
image_keywords
.iter()
.any(|keyword| normalized.contains(keyword))
}
pub(super) fn is_video_workflow_model(value: &str) -> bool {
let normalized = value.trim().to_ascii_lowercase();
[
"seedance", "wan2", "t2v", "i2v", "flf2v", "video", "seaweed",
]
.iter()
.any(|keyword| normalized.contains(keyword))
}
pub(super) fn is_audio_workflow_model(value: &str) -> bool {
let normalized = value.trim().to_ascii_lowercase();
["tts", "voice", "audio", "speech"]
.iter()
.any(|keyword| normalized.contains(keyword))
}
pub(super) fn is_embedding_workflow_model(value: &str) -> bool {
let normalized = value.trim().to_ascii_lowercase();
normalized.contains("embedding") || normalized.contains("embed")
}
pub(super) fn is_3d_workflow_model(value: &str) -> bool {
let normalized = value.trim().to_ascii_lowercase();
["3d", "hitem3d", "hyper3d"]
.iter()
.any(|keyword| normalized.contains(keyword))
}
pub(super) fn is_vision_workflow_model(value: &str) -> bool {
let normalized = value.trim().to_ascii_lowercase();
["vision", "vl", "omni", "ui-tars"]
.iter()
.any(|keyword| normalized.contains(keyword))
&& !is_image_workflow_model(value)
&& !is_video_workflow_model(value)
&& !is_embedding_workflow_model(value)
}
pub(super) fn is_chat_workflow_model(value: &str) -> bool {
is_exact_workflow_model(value)
&& !is_image_workflow_model(value)
&& !is_video_workflow_model(value)
&& !is_audio_workflow_model(value)
&& !is_embedding_workflow_model(value)
&& !is_3d_workflow_model(value)
}
pub(super) async fn workflow_route_models(state: &AppState) -> AppResult<Vec<String>> {
let rows = sqlx::query_scalar::<_, String>(
"SELECT DISTINCT model_pattern
FROM token_routes
WHERE COALESCE(enabled, 1) = 1
AND model_pattern IS NOT NULL
AND model_pattern <> ''
ORDER BY model_pattern COLLATE NOCASE",
)
.fetch_all(&*state.db)
.await?;
let mut seen = std::collections::HashSet::new();
Ok(rows
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| is_exact_workflow_model(value))
.filter(|value| seen.insert(value.clone()))
.collect())
}
pub(super) fn image_model_choices(models: &[String]) -> Vec<String> {
let image_models = models
.iter()
.filter(|model| is_image_workflow_model(model))
.cloned()
.collect::<Vec<_>>();
if image_models.is_empty() {
models.to_vec()
} else {
image_models
}
}
pub(super) fn workflow_folder_models(folder: &str, models: &[String]) -> Vec<String> {
let normalized = folder.trim().to_ascii_lowercase();
match normalized.as_str() {
"boosapi_models" | "boosapi" | "api_models" => models.to_vec(),
"image_models" => image_model_choices(models),
"chat_models" => models
.iter()
.filter(|model| is_chat_workflow_model(model))
.cloned()
.collect(),
"clip" => models.to_vec(),
// Comfy's video_models folder is a native Comfy API model folder. Keep it
// separate from BoosAPI route models so video apps submit real Comfy API
// model names instead of OpenAI-compatible route aliases.
"video_models" => Vec::new(),
"audio_tts_models" => models
.iter()
.filter(|model| is_audio_workflow_model(model))
.cloned()
.collect(),
"embeddings" => models
.iter()
.filter(|model| is_embedding_workflow_model(model))
.cloned()
.collect(),
"3d_models" => models
.iter()
.filter(|model| is_3d_workflow_model(model))
.cloned()
.collect(),
"vision_models" => models
.iter()
.filter(|model| is_vision_workflow_model(model))
.cloned()
.collect(),
_ => Vec::new(),
}
}
pub(super) fn workflow_folder_is_api_only(folder: &str) -> bool {
matches!(
folder.trim().to_ascii_lowercase().as_str(),
"boosapi" | "boosapi_models" | "api_models"
)
}
pub(super) fn workflow_folder_has_route_models(folder: &str) -> bool {
matches!(
folder.trim().to_ascii_lowercase().as_str(),
"boosapi"
| "boosapi_models"
| "api_models"
| "image_models"
| "chat_models"
| "audio_tts_models"
| "embeddings"
| "3d_models"
| "vision_models"
)
}
pub(super) fn workflow_folder_route_model_items(folder: &str, route_models: &[String]) -> Vec<Value> {
model_payload_items_from_names(workflow_folder_models(folder, route_models), 0)
}
pub(super) fn combo_choices_from_node_input(
object_info: &Value,
node_type: &str,
input_name: &str,
) -> Vec<String> {
object_info
.get(node_type)
.and_then(|node| {
node.pointer(&format!("/input/required/{input_name}"))
.or_else(|| node.pointer(&format!("/input/optional/{input_name}")))
})
.and_then(Value::as_array)
.and_then(|input| input.first())
.and_then(Value::as_array)
.map(|choices| {
choices
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty() && *value != "None")
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
pub(super) fn combo_fields_from_node(object_info: &Value, node_type: &str) -> Vec<(String, Vec<String>)> {
let mut fields = Vec::new();
let Some(node) = object_info.get(node_type) else {
return fields;
};
for section in ["required", "optional"] {
if let Some(inputs) = node
.pointer(&format!("/input/{section}"))
.and_then(Value::as_object)
{
for (name, value) in inputs {
let Some(choices) = value
.as_array()
.and_then(|input| input.first())
.and_then(Value::as_array)
else {
continue;
};
let choices = choices
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty() && *value != "None")
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
fields.push((name.clone(), choices));
}
}
}
fields
}
pub(super) fn native_folder_models_from_object_info(folder: &str, object_info: &Value) -> Vec<String> {
let normalized = folder.trim().to_ascii_lowercase();
match normalized.as_str() {
"checkpoints" | "image_models" => {
combo_choices_from_node_input(object_info, "CheckpointLoaderSimple", "ckpt_name")
}
"loras" => combo_choices_from_node_input(object_info, "LoraLoader", "lora_name"),
"vae" => combo_choices_from_node_input(object_info, "VAELoader", "vae_name"),
"clip" | "clip_models" | "text_encoders" => {
combo_choices_from_node_input(object_info, "CLIPLoader", "clip_name")
}
"unet" | "diffusion_models" | "diffusion_models_files" => {
combo_choices_from_node_input(object_info, "UNETLoader", "unet_name")
}
"controlnet" | "controlnets" => {
combo_choices_from_node_input(object_info, "ControlNetLoader", "control_net_name")
}
"upscale_models" => {
combo_choices_from_node_input(object_info, "UpscaleModelLoader", "model_name")
}
"embeddings" => combo_choices_from_node_input(object_info, "EmbeddingPicker", "embedding"),
_ => Vec::new(),
}
}