refactor(workflow_ui): 抽出 queue 子模块

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
boos
2026-06-18 15:15:17 +08:00
parent 7a02545fb8
commit a6c4db64c4
2 changed files with 473 additions and 431 deletions
+38 -431
View File
@@ -4,7 +4,7 @@ use crate::{
services::workspace::{self, WorkspaceAssetInput}, services::workspace::{self, WorkspaceAssetInput},
workflow::{ workflow::{
models::{RunContext, WorkflowGraph}, models::{RunContext, WorkflowGraph},
registry, storage, registry,
}, },
AppState, AppState,
}; };
@@ -16,7 +16,7 @@ use axum::{
DefaultBodyLimit, FromRequest, Path, Query, State, DefaultBodyLimit, FromRequest, Path, Query, State,
}, },
http::{header, HeaderMap, HeaderValue, Method, StatusCode, Uri}, http::{header, HeaderMap, HeaderValue, Method, StatusCode, Uri},
response::{Html, IntoResponse, Response}, response::{IntoResponse, Response},
routing::{any, get, post}, routing::{any, get, post},
Json, Router, Json, Router,
}; };
@@ -24,7 +24,6 @@ use include_dir::{include_dir, Dir};
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value}; use serde_json::{json, Map, Value};
use sqlx::Row;
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
path::{Path as StdPath, PathBuf}, path::{Path as StdPath, PathBuf},
@@ -63,22 +62,22 @@ struct CachedJson {
} }
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
struct PromptPayload { pub(super) struct PromptPayload {
prompt: Value, pub(super) prompt: Value,
#[serde(default)] #[serde(default)]
extra_data: Value, pub(super) extra_data: Value,
#[serde(default)] #[serde(default)]
client_id: Option<String>, pub(super) client_id: Option<String>,
#[serde(default)] #[serde(default)]
front: Option<bool>, pub(super) front: Option<bool>,
#[serde(default)] #[serde(default)]
number: Option<i64>, pub(super) number: Option<i64>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct HistoryQuery { pub(super) struct HistoryQuery {
#[serde(default = "default_history_limit")] #[serde(default = "default_history_limit")]
max_items: i64, pub(super) max_items: i64,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -106,11 +105,11 @@ struct UploadQuery {
} }
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
struct QueueMutation { pub(super) struct QueueMutation {
#[serde(default)] #[serde(default)]
delete: Vec<String>, pub(super) delete: Vec<String>,
#[serde(default)] #[serde(default)]
clear: bool, pub(super) clear: bool,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -124,7 +123,7 @@ use auth::{auth_caller, caller_from_headers, caller_is_admin, caller_user_id};
mod node_defs; mod node_defs;
use node_defs::{ use node_defs::{
core_node_definitions, core_workflow_template_index, default_boosapi_workflow_graph, core_node_definitions, core_workflow_template_index, default_boosapi_workflow_graph,
node_type_def_to_comfy, normalize_prompt_payload, text_to_video_workflow_graph, node_type_def_to_comfy, text_to_video_workflow_graph,
}; };
mod models; mod models;
use models::{ use models::{
@@ -145,6 +144,11 @@ use comfy_proxy::{
}; };
mod validate; mod validate;
use validate::validate_workflow; use validate::validate_workflow;
mod queue;
use queue::{
history, history_by_id, history_mutate, interrupt, internal_logs, internal_logs_raw, queue,
queue_mutate, queue_prompt, queue_status, repair_native_prompt_payload, view,
};
pub fn routes() -> Router<AppState> { pub fn routes() -> Router<AppState> {
Router::new() Router::new()
@@ -252,7 +256,7 @@ pub(super) fn user_home(user_id: Option<i64>) -> PathBuf {
) )
} }
fn guess_mime_from_path(path: &PathBuf) -> &'static str { pub(super) fn guess_mime_from_path(path: &PathBuf) -> &'static str {
match path match path
.extension() .extension()
.and_then(|ext| ext.to_str()) .and_then(|ext| ext.to_str())
@@ -572,7 +576,7 @@ fn normalize_model_folder_payload(value: &mut Value) {
} }
} }
fn prompt_contains_boosapi_nodes(prompt: &Value) -> bool { pub(super) fn prompt_contains_boosapi_nodes(prompt: &Value) -> bool {
prompt prompt
.as_object() .as_object()
.map(|nodes| { .map(|nodes| {
@@ -889,137 +893,7 @@ pub(super) fn workflow_validation_hints(errors: usize, warnings: usize) -> Vec<S
hints hints
} }
async fn queue_status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> { pub(super) fn workflow_run_to_history(run: crate::workflow::models::WorkflowRunSummary) -> Value {
let _ = auth_caller_optional(&state, &headers);
if state.config.comfyui.uses_native_backend() {
return native_workflow_json(&state, Method::GET, "/prompt", None).await;
}
Ok(Json(json!({
"status_str": "success",
"completed": true,
"messages": []
})))
}
async fn queue(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
if state.config.comfyui.uses_native_backend() {
let _ = auth_caller_optional(&state, &headers);
return native_workflow_json(&state, Method::GET, "/queue", None).await;
}
let caller =
auth_caller_optional(&state, &headers).unwrap_or(WorkflowCaller::User { user_id: 0 });
let rows = storage::list_runs(
&state.db,
caller_user_id(&caller),
caller_is_admin(&caller),
25,
)
.await?;
let running = rows
.into_iter()
.filter(|run| run.status == "running")
.map(workflow_run_to_queue_task)
.collect::<Vec<_>>();
Ok(Json(json!({
"Running": running,
"Pending": []
})))
}
async fn queue_mutate(
State(state): State<AppState>,
headers: HeaderMap,
Json(_payload): Json<QueueMutation>,
) -> AppResult<Json<Value>> {
let _ = auth_caller(&state, &headers).await?;
if state.config.comfyui.uses_native_backend() {
let value = serde_json::to_value(&_payload).unwrap_or_else(|_| json!({}));
return native_workflow_json(&state, Method::POST, "/queue", Some(value)).await;
}
Ok(Json(json!({
"success": true,
"Running": [],
"Pending": []
})))
}
async fn history(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<HistoryQuery>,
) -> AppResult<Json<Value>> {
if state.config.comfyui.uses_native_backend() {
let _ = auth_caller_optional(&state, &headers);
let path = format!("/history?max_items={}", query.max_items);
return native_workflow_json(&state, Method::GET, &path, None).await;
}
let caller =
auth_caller_optional(&state, &headers).unwrap_or(WorkflowCaller::User { user_id: 0 });
let rows = storage::list_runs(
&state.db,
caller_user_id(&caller),
caller_is_admin(&caller),
query.max_items,
)
.await?;
let history = rows
.into_iter()
.map(workflow_run_to_history_task)
.collect::<Vec<_>>();
Ok(Json(json!({ "History": history })))
}
async fn history_mutate(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<QueueMutation>,
) -> AppResult<Json<Value>> {
let caller = auth_caller(&state, &headers).await?;
if state.config.comfyui.uses_native_backend() {
let value = serde_json::to_value(&payload).unwrap_or_else(|_| json!({}));
return native_workflow_json(&state, Method::POST, "/history", Some(value)).await;
}
let affected = if payload.clear {
storage::clear_runs(&state, caller_user_id(&caller), caller_is_admin(&caller)).await?
as usize
} else {
storage::delete_runs(
&state,
&payload.delete,
caller_user_id(&caller),
caller_is_admin(&caller),
)
.await?
};
Ok(Json(json!({
"success": true,
"deleted": affected
})))
}
async fn history_by_id(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
if state.config.comfyui.uses_native_backend() {
let _ = auth_caller_optional(&state, &headers);
let path = format!("/history/{}", simple_urlencode(&id));
return native_workflow_json(&state, Method::GET, &path, None).await;
}
let caller =
auth_caller_optional(&state, &headers).unwrap_or(WorkflowCaller::User { user_id: 0 });
let run = storage::get_run(
&state.db,
&id,
caller_user_id(&caller),
caller_is_admin(&caller),
)
.await?;
Ok(Json(workflow_run_to_history(run)))
}
fn workflow_run_to_history(run: crate::workflow::models::WorkflowRunSummary) -> Value {
let outputs = run_outputs_for_comfy(&run); let outputs = run_outputs_for_comfy(&run);
let status_str = if run.status == "failed" { let status_str = if run.status == "failed" {
"error" "error"
@@ -1038,7 +912,7 @@ fn workflow_run_to_history(run: crate::workflow::models::WorkflowRunSummary) ->
}) })
} }
fn workflow_run_to_history_task(run: crate::workflow::models::WorkflowRunSummary) -> Value { pub(super) fn workflow_run_to_history_task(run: crate::workflow::models::WorkflowRunSummary) -> Value {
let history = workflow_run_to_history(run); let history = workflow_run_to_history(run);
json!({ json!({
"taskType": "History", "taskType": "History",
@@ -1048,7 +922,7 @@ fn workflow_run_to_history_task(run: crate::workflow::models::WorkflowRunSummary
}) })
} }
fn workflow_run_to_queue_task(run: crate::workflow::models::WorkflowRunSummary) -> Value { pub(super) fn workflow_run_to_queue_task(run: crate::workflow::models::WorkflowRunSummary) -> Value {
json!({ json!({
"taskType": "Running", "taskType": "Running",
"prompt": workflow_run_to_prompt_tuple(&run), "prompt": workflow_run_to_prompt_tuple(&run),
@@ -1061,7 +935,7 @@ fn workflow_run_to_queue_task(run: crate::workflow::models::WorkflowRunSummary)
}) })
} }
fn prompt_error_response(message: impl Into<String>, details: impl Into<String>) -> Response { pub(super) fn prompt_error_response(message: impl Into<String>, details: impl Into<String>) -> Response {
let message = message.into(); let message = message.into();
let details = details.into(); let details = details.into();
( (
@@ -1085,58 +959,6 @@ fn prompt_error_response(message: impl Into<String>, details: impl Into<String>)
.into_response() .into_response()
} }
async fn queue_prompt(
State(state): State<AppState>,
headers: HeaderMap,
Json(mut payload): Json<PromptPayload>,
) -> AppResult<Response> {
let caller = auth_caller(&state, &headers).await?;
if state.config.comfyui.uses_native_backend() {
if state.config.comfyui.uses_cloud_backend() {
repair_native_prompt_payload(&state, &mut payload).await;
return native_workflow_response(
&state,
Method::POST,
"/prompt",
Some(serde_json::to_value(payload).unwrap_or_else(|_| json!({}))),
)
.await;
}
if prompt_contains_boosapi_nodes(&payload.prompt) {
return queue_boosapi_prompt(state, caller, payload).await;
}
repair_native_prompt_payload(&state, &mut payload).await;
return native_workflow_response(
&state,
Method::POST,
"/prompt",
Some(serde_json::to_value(payload).unwrap_or_else(|_| json!({}))),
)
.await;
}
queue_boosapi_prompt(state, caller, payload).await
}
async fn repair_native_prompt_payload(state: &AppState, payload: &mut PromptPayload) {
inject_native_prompt_cloud_api_key(state, payload);
let object_info = native_workflow_json_cached(
state,
Method::GET,
"/object_info",
None,
native_workflow_metadata_ttl(state),
)
.await
.ok()
.map(|value| value.0);
if let Some(object_info) = object_info.as_ref() {
repair_dynamic_combo_nodes(&mut payload.prompt, object_info);
}
repair_resize_image_mask_nodes(&mut payload.prompt, &payload.extra_data);
repair_text_generate_ltx2_prompt_nodes(&mut payload.prompt);
repair_wan2_api_nodes(&mut payload.prompt);
}
fn inject_native_prompt_cloud_api_key(state: &AppState, payload: &mut PromptPayload) { fn inject_native_prompt_cloud_api_key(state: &AppState, payload: &mut PromptPayload) {
if !state.config.comfyui.uses_cloud_backend() { if !state.config.comfyui.uses_cloud_backend() {
return; return;
@@ -1154,7 +976,7 @@ fn inject_native_prompt_cloud_api_key(state: &AppState, payload: &mut PromptPayl
); );
} }
fn repair_resize_image_mask_nodes(prompt: &mut Value, extra_data: &Value) { pub(super) fn repair_resize_image_mask_nodes(prompt: &mut Value, extra_data: &Value) {
let Some(prompt_nodes) = prompt.as_object_mut() else { let Some(prompt_nodes) = prompt.as_object_mut() else {
return; return;
}; };
@@ -1253,7 +1075,7 @@ fn repair_resize_image_mask_node_inputs_without_workflow(
); );
} }
fn repair_text_generate_ltx2_prompt_nodes(prompt: &mut Value) { pub(super) fn repair_text_generate_ltx2_prompt_nodes(prompt: &mut Value) {
let Some(prompt_nodes) = prompt.as_object_mut() else { let Some(prompt_nodes) = prompt.as_object_mut() else {
return; return;
}; };
@@ -1305,7 +1127,7 @@ struct DynamicComboField {
max: Option<f64>, max: Option<f64>,
} }
fn repair_dynamic_combo_nodes(prompt: &mut Value, object_info: &Value) { pub(super) fn repair_dynamic_combo_nodes(prompt: &mut Value, object_info: &Value) {
let Some(prompt_nodes) = prompt.as_object_mut() else { let Some(prompt_nodes) = prompt.as_object_mut() else {
return; return;
}; };
@@ -1591,7 +1413,7 @@ fn clamp_f64_to_field(value: f64, field: &DynamicComboField) -> f64 {
value.clamp(min, max) value.clamp(min, max)
} }
fn repair_wan2_api_nodes(prompt: &mut Value) { pub(super) fn repair_wan2_api_nodes(prompt: &mut Value) {
let Some(prompt_nodes) = prompt.as_object_mut() else { let Some(prompt_nodes) = prompt.as_object_mut() else {
return; return;
}; };
@@ -1777,221 +1599,6 @@ fn id_to_prompt_key(id: &Value) -> String {
id.as_str().unwrap_or_default().to_string() id.as_str().unwrap_or_default().to_string()
} }
async fn queue_boosapi_prompt(
state: AppState,
caller: WorkflowCaller,
payload: PromptPayload,
) -> AppResult<Response> {
let prompt = payload.prompt.clone();
let graph = match normalize_prompt_payload(&prompt) {
Ok(graph) => graph,
Err(err) => {
return Ok(prompt_error_response("工作流无法执行", err.to_string()));
}
};
if graph.nodes.is_empty() {
return Ok(prompt_error_response(
"画布里还没有可执行节点",
"请先从左侧节点库添加 BoosAPI 节点并连接后再执行。",
));
}
let name = payload
.extra_data
.pointer("/extra_pnginfo/workflow/name")
.and_then(Value::as_str)
.unwrap_or("ComfyUI workflow");
let comfy_workflow = payload
.extra_data
.pointer("/extra_pnginfo/workflow")
.cloned()
.unwrap_or_else(|| workflow_graph_to_comfy_workflow(&graph));
let workflow = storage::insert_workflow(
&state,
caller_user_id(&caller),
name,
Some("Imported from ComfyUI frontend"),
&serde_json::to_value(&graph).unwrap_or_else(|_| json!({})),
"private",
false,
)
.await?;
let ctx = workflow_context(&state, &caller).await?;
let progress_session_id = payload
.client_id
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "boosapi-workflow".to_string());
let (run_tx, run_rx) = mpsc::sync_channel::<String>(1);
let progress =
workflow_progress_callback_with_run_sender(Some(progress_session_id.clone()), Some(run_tx));
let state_for_task = state.clone();
let graph_value = serde_json::to_value(&graph).unwrap_or_else(|_| json!({}));
let task_graph_value = graph_value.clone();
let user_id = caller_user_id(&caller);
let allow_admin = caller_is_admin(&caller);
let session_for_task = progress_session_id.clone();
tokio::spawn(async move {
let result = crate::workflow::executor::run_workflow_with_progress(
&state_for_task,
workflow.id,
task_graph_value,
json!({}),
ctx,
progress,
)
.await;
match result {
Ok(run_id) => {
if let Ok(run) =
storage::get_run(&state_for_task.db, &run_id, user_id, allow_admin).await
{
let outputs = run_outputs_for_comfy(&run);
publish_run_events(Some(session_for_task.as_str()), &run, &outputs);
}
}
Err(err) => {
tracing::warn!("workflow background execution failed: {err}");
}
}
});
let run_id = match tokio::task::spawn_blocking(move || run_rx.recv()).await {
Ok(Ok(run_id)) => run_id,
Ok(Err(_)) | Err(_) => {
return Ok(prompt_error_response(
"工作流执行失败",
"后台任务没有成功进入队列,请稍后重试。",
));
}
};
Ok(Json(json!({
"prompt_id": run_id,
"number": payload.number.unwrap_or(0),
"node_errors": [],
"client_id": payload.client_id,
"front": payload.front.unwrap_or(false),
"outputs": {},
"workflow": comfy_workflow
}))
.into_response())
}
async fn interrupt(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
let _ = auth_caller_optional(&state, &headers);
if state.config.comfyui.uses_native_backend() {
return native_workflow_json(&state, Method::POST, "/interrupt", Some(json!({}))).await;
}
Ok(Json(json!({ "success": true })))
}
async fn view(
State(state): State<AppState>,
headers: HeaderMap,
Query(params): Query<HashMap<String, String>>,
) -> AppResult<Response> {
let caller = auth_caller(&state, &headers).await?;
if state.config.comfyui.uses_native_backend() {
return native_workflow_view_response(&state, &caller, &params).await;
}
let file = params.get("filename").cloned().unwrap_or_default();
if file.is_empty() {
return Err(AppError::BadRequest("filename is required".into()));
}
let file_type = params
.get("type")
.cloned()
.unwrap_or_else(|| "input".to_string());
let subfolder = params.get("subfolder").cloned().unwrap_or_default();
let user_root = user_home(caller_user_id(&caller))
.join("userdata")
.join(&file_type);
let local_path = if subfolder.trim().is_empty() {
user_root.join(&file)
} else {
user_root.join(safe_view_path(&subfolder)).join(&file)
};
if tokio::fs::metadata(&local_path).await.is_ok() {
let bytes = tokio::fs::read(&local_path)
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
return Ok((
StatusCode::OK,
[(
axum::http::header::CONTENT_TYPE,
guess_mime_from_path(&local_path),
)],
bytes,
)
.into_response());
}
let row = sqlx::query(
"SELECT file_path, remote_url, mime_type
FROM workspace_assets
WHERE file_name = ? OR public_id = ?
ORDER BY id DESC
LIMIT 1",
)
.bind(&file)
.bind(&file)
.fetch_optional(&*state.db)
.await?;
let Some(row) = row else {
return Err(AppError::NotFound("Workspace asset not found".into()));
};
let file_path: Option<String> = row.try_get("file_path").ok();
if let Some(path) = file_path {
let path_buf = PathBuf::from(path);
match tokio::fs::read(&path_buf).await {
Ok(bytes) => {
return Ok((
StatusCode::OK,
[(
axum::http::header::CONTENT_TYPE,
guess_mime_from_path(&path_buf),
)],
bytes,
)
.into_response())
}
Err(err) => return Err(AppError::UpstreamError(err.to_string())),
}
}
let remote_url: Option<String> = row.try_get("remote_url").ok();
if let Some(url) = remote_url
.as_deref()
.filter(|value| !value.trim().is_empty())
{
return Ok((
StatusCode::TEMPORARY_REDIRECT,
[(axum::http::header::LOCATION, url.to_string())],
)
.into_response());
}
Err(AppError::NotFound(
remote_url.unwrap_or_else(|| "asset unavailable".into()),
))
}
async fn internal_logs(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Html<String>> {
let _ = auth_caller(&state, &headers).await?;
Ok(Html("".to_string()))
}
async fn internal_logs_raw(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let _ = auth_caller(&state, &headers).await?;
Ok(Json(
json!({ "size": { "cols": 0, "row": 0 }, "entries": [] }),
))
}
async fn internal_logs_subscribe( async fn internal_logs_subscribe(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
@@ -2221,7 +1828,7 @@ pub(super) fn native_workflow_request(
request request
} }
async fn native_workflow_json( pub(super) async fn native_workflow_json(
state: &AppState, state: &AppState,
method: Method, method: Method,
path: &str, path: &str,
@@ -2309,7 +1916,7 @@ fn workflow_cache_kind_from_key(key: &str) -> &'static str {
} }
} }
async fn native_workflow_response( pub(super) async fn native_workflow_response(
state: &AppState, state: &AppState,
method: Method, method: Method,
path: &str, path: &str,
@@ -2338,7 +1945,7 @@ async fn native_workflow_response(
.into_response()) .into_response())
} }
async fn native_workflow_view_response( pub(super) async fn native_workflow_view_response(
state: &AppState, state: &AppState,
caller: &WorkflowCaller, caller: &WorkflowCaller,
params: &HashMap<String, String>, params: &HashMap<String, String>,
@@ -2586,7 +2193,7 @@ pub(super) fn build_query_string(params: &HashMap<String, String>) -> String {
format!("?{}", parts.join("&")) format!("?{}", parts.join("&"))
} }
fn simple_urlencode(value: &str) -> String { pub(super) fn simple_urlencode(value: &str) -> String {
value value
.replace('%', "%25") .replace('%', "%25")
.replace(' ', "%20") .replace(' ', "%20")
@@ -2614,7 +2221,7 @@ fn workflow_timestamp_ms(value: &str) -> i64 {
.unwrap_or_else(|_| timestamp_ms()) .unwrap_or_else(|_| timestamp_ms())
} }
fn workflow_graph_to_comfy_workflow(graph: &WorkflowGraph) -> Value { pub(super) fn workflow_graph_to_comfy_workflow(graph: &WorkflowGraph) -> Value {
let nodes = graph let nodes = graph
.nodes .nodes
.iter() .iter()
@@ -2701,7 +2308,7 @@ fn run_status_messages(run: &crate::workflow::models::WorkflowRunSummary) -> Vec
messages messages
} }
fn run_outputs_for_comfy(run: &crate::workflow::models::WorkflowRunSummary) -> Value { pub(super) fn run_outputs_for_comfy(run: &crate::workflow::models::WorkflowRunSummary) -> Value {
let mut outputs = Map::new(); let mut outputs = Map::new();
for node in &run.node_runs { for node in &run.node_runs {
if let Some(node_outputs) = &node.outputs { if let Some(node_outputs) = &node.outputs {
@@ -2865,7 +2472,7 @@ fn workflow_node_label(node_type: &str, node_id: &str) -> String {
} }
} }
fn workflow_progress_callback_with_run_sender( pub(super) fn workflow_progress_callback_with_run_sender(
session_id: Option<String>, session_id: Option<String>,
run_sender: Option<mpsc::SyncSender<String>>, run_sender: Option<mpsc::SyncSender<String>>,
) -> Option<WorkflowProgressCallback> { ) -> Option<WorkflowProgressCallback> {
@@ -3022,7 +2629,7 @@ fn workflow_progress_callback_with_run_sender(
})) }))
} }
fn publish_run_events( pub(super) fn publish_run_events(
session_id: Option<&str>, session_id: Option<&str>,
run: &crate::workflow::models::WorkflowRunSummary, run: &crate::workflow::models::WorkflowRunSummary,
outputs: &Value, outputs: &Value,
@@ -3551,7 +3158,7 @@ async fn native_workflow_ws_bridge(
} }
} }
async fn workflow_context(state: &AppState, caller: &WorkflowCaller) -> AppResult<RunContext> { pub(super) async fn workflow_context(state: &AppState, caller: &WorkflowCaller) -> AppResult<RunContext> {
let bearer = match caller { let bearer = match caller {
WorkflowCaller::Admin => state.builtin_keys.comfyui_agent.1.clone(), WorkflowCaller::Admin => state.builtin_keys.comfyui_agent.1.clone(),
WorkflowCaller::User { user_id } => { WorkflowCaller::User { user_id } => {
+435
View File
@@ -0,0 +1,435 @@
use crate::{
error::{AppError, AppResult},
workflow::storage,
AppState,
};
use axum::{
extract::{Path, Query, State},
http::{HeaderMap, Method, StatusCode},
response::{Html, IntoResponse, Response},
Json,
};
use serde_json::{json, Value};
use sqlx::Row;
use std::{collections::HashMap, path::PathBuf, sync::mpsc};
use super::auth::{auth_caller, caller_is_admin, caller_user_id};
use super::node_defs::normalize_prompt_payload;
use super::{
auth_caller_optional, guess_mime_from_path, inject_native_prompt_cloud_api_key,
native_workflow_json, native_workflow_json_cached, native_workflow_metadata_ttl,
native_workflow_response, native_workflow_view_response, prompt_contains_boosapi_nodes,
prompt_error_response, publish_run_events, repair_dynamic_combo_nodes,
repair_resize_image_mask_nodes, repair_text_generate_ltx2_prompt_nodes, repair_wan2_api_nodes,
run_outputs_for_comfy, safe_view_path, simple_urlencode, user_home, workflow_context,
workflow_graph_to_comfy_workflow, workflow_progress_callback_with_run_sender,
workflow_run_to_history, workflow_run_to_history_task, workflow_run_to_queue_task,
HistoryQuery, PromptPayload, QueueMutation, WorkflowCaller,
};
pub(super) async fn queue_status(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let _ = auth_caller_optional(&state, &headers);
if state.config.comfyui.uses_native_backend() {
return native_workflow_json(&state, Method::GET, "/prompt", None).await;
}
Ok(Json(json!({
"status_str": "success",
"completed": true,
"messages": []
})))
}
pub(super) async fn queue(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
if state.config.comfyui.uses_native_backend() {
let _ = auth_caller_optional(&state, &headers);
return native_workflow_json(&state, Method::GET, "/queue", None).await;
}
let caller =
auth_caller_optional(&state, &headers).unwrap_or(WorkflowCaller::User { user_id: 0 });
let rows = storage::list_runs(
&state.db,
caller_user_id(&caller),
caller_is_admin(&caller),
25,
)
.await?;
let running = rows
.into_iter()
.filter(|run| run.status == "running")
.map(workflow_run_to_queue_task)
.collect::<Vec<_>>();
Ok(Json(json!({
"Running": running,
"Pending": []
})))
}
pub(super) async fn queue_mutate(
State(state): State<AppState>,
headers: HeaderMap,
Json(_payload): Json<QueueMutation>,
) -> AppResult<Json<Value>> {
let _ = auth_caller(&state, &headers).await?;
if state.config.comfyui.uses_native_backend() {
let value = serde_json::to_value(&_payload).unwrap_or_else(|_| json!({}));
return native_workflow_json(&state, Method::POST, "/queue", Some(value)).await;
}
Ok(Json(json!({
"success": true,
"Running": [],
"Pending": []
})))
}
pub(super) async fn history(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<HistoryQuery>,
) -> AppResult<Json<Value>> {
if state.config.comfyui.uses_native_backend() {
let _ = auth_caller_optional(&state, &headers);
let path = format!("/history?max_items={}", query.max_items);
return native_workflow_json(&state, Method::GET, &path, None).await;
}
let caller =
auth_caller_optional(&state, &headers).unwrap_or(WorkflowCaller::User { user_id: 0 });
let rows = storage::list_runs(
&state.db,
caller_user_id(&caller),
caller_is_admin(&caller),
query.max_items,
)
.await?;
let history = rows
.into_iter()
.map(workflow_run_to_history_task)
.collect::<Vec<_>>();
Ok(Json(json!({ "History": history })))
}
pub(super) async fn history_mutate(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<QueueMutation>,
) -> AppResult<Json<Value>> {
let caller = auth_caller(&state, &headers).await?;
if state.config.comfyui.uses_native_backend() {
let value = serde_json::to_value(&payload).unwrap_or_else(|_| json!({}));
return native_workflow_json(&state, Method::POST, "/history", Some(value)).await;
}
let affected = if payload.clear {
storage::clear_runs(&state, caller_user_id(&caller), caller_is_admin(&caller)).await?
as usize
} else {
storage::delete_runs(
&state,
&payload.delete,
caller_user_id(&caller),
caller_is_admin(&caller),
)
.await?
};
Ok(Json(json!({
"success": true,
"deleted": affected
})))
}
pub(super) async fn history_by_id(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
if state.config.comfyui.uses_native_backend() {
let _ = auth_caller_optional(&state, &headers);
let path = format!("/history/{}", simple_urlencode(&id));
return native_workflow_json(&state, Method::GET, &path, None).await;
}
let caller =
auth_caller_optional(&state, &headers).unwrap_or(WorkflowCaller::User { user_id: 0 });
let run = storage::get_run(
&state.db,
&id,
caller_user_id(&caller),
caller_is_admin(&caller),
)
.await?;
Ok(Json(workflow_run_to_history(run)))
}
pub(super) async fn queue_prompt(
State(state): State<AppState>,
headers: HeaderMap,
Json(mut payload): Json<PromptPayload>,
) -> AppResult<Response> {
let caller = auth_caller(&state, &headers).await?;
if state.config.comfyui.uses_native_backend() {
if state.config.comfyui.uses_cloud_backend() {
repair_native_prompt_payload(&state, &mut payload).await;
return native_workflow_response(
&state,
Method::POST,
"/prompt",
Some(serde_json::to_value(payload).unwrap_or_else(|_| json!({}))),
)
.await;
}
if prompt_contains_boosapi_nodes(&payload.prompt) {
return queue_boosapi_prompt(state, caller, payload).await;
}
repair_native_prompt_payload(&state, &mut payload).await;
return native_workflow_response(
&state,
Method::POST,
"/prompt",
Some(serde_json::to_value(payload).unwrap_or_else(|_| json!({}))),
)
.await;
}
queue_boosapi_prompt(state, caller, payload).await
}
pub(super) async fn repair_native_prompt_payload(state: &AppState, payload: &mut PromptPayload) {
inject_native_prompt_cloud_api_key(state, payload);
let object_info = native_workflow_json_cached(
state,
Method::GET,
"/object_info",
None,
native_workflow_metadata_ttl(state),
)
.await
.ok()
.map(|value| value.0);
if let Some(object_info) = object_info.as_ref() {
repair_dynamic_combo_nodes(&mut payload.prompt, object_info);
}
repair_resize_image_mask_nodes(&mut payload.prompt, &payload.extra_data);
repair_text_generate_ltx2_prompt_nodes(&mut payload.prompt);
repair_wan2_api_nodes(&mut payload.prompt);
}
pub(super) async fn queue_boosapi_prompt(
state: AppState,
caller: WorkflowCaller,
payload: PromptPayload,
) -> AppResult<Response> {
let prompt = payload.prompt.clone();
let graph = match normalize_prompt_payload(&prompt) {
Ok(graph) => graph,
Err(err) => {
return Ok(prompt_error_response("工作流无法执行", err.to_string()));
}
};
if graph.nodes.is_empty() {
return Ok(prompt_error_response(
"画布里还没有可执行节点",
"请先从左侧节点库添加 BoosAPI 节点并连接后再执行。",
));
}
let name = payload
.extra_data
.pointer("/extra_pnginfo/workflow/name")
.and_then(Value::as_str)
.unwrap_or("ComfyUI workflow");
let comfy_workflow = payload
.extra_data
.pointer("/extra_pnginfo/workflow")
.cloned()
.unwrap_or_else(|| workflow_graph_to_comfy_workflow(&graph));
let workflow = storage::insert_workflow(
&state,
caller_user_id(&caller),
name,
Some("Imported from ComfyUI frontend"),
&serde_json::to_value(&graph).unwrap_or_else(|_| json!({})),
"private",
false,
)
.await?;
let ctx = workflow_context(&state, &caller).await?;
let progress_session_id = payload
.client_id
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "boosapi-workflow".to_string());
let (run_tx, run_rx) = mpsc::sync_channel::<String>(1);
let progress =
workflow_progress_callback_with_run_sender(Some(progress_session_id.clone()), Some(run_tx));
let state_for_task = state.clone();
let graph_value = serde_json::to_value(&graph).unwrap_or_else(|_| json!({}));
let task_graph_value = graph_value.clone();
let user_id = caller_user_id(&caller);
let allow_admin = caller_is_admin(&caller);
let session_for_task = progress_session_id.clone();
tokio::spawn(async move {
let result = crate::workflow::executor::run_workflow_with_progress(
&state_for_task,
workflow.id,
task_graph_value,
json!({}),
ctx,
progress,
)
.await;
match result {
Ok(run_id) => {
if let Ok(run) =
storage::get_run(&state_for_task.db, &run_id, user_id, allow_admin).await
{
let outputs = run_outputs_for_comfy(&run);
publish_run_events(Some(session_for_task.as_str()), &run, &outputs);
}
}
Err(err) => {
tracing::warn!("workflow background execution failed: {err}");
}
}
});
let run_id = match tokio::task::spawn_blocking(move || run_rx.recv()).await {
Ok(Ok(run_id)) => run_id,
Ok(Err(_)) | Err(_) => {
return Ok(prompt_error_response(
"工作流执行失败",
"后台任务没有成功进入队列,请稍后重试。",
));
}
};
Ok(Json(json!({
"prompt_id": run_id,
"number": payload.number.unwrap_or(0),
"node_errors": [],
"client_id": payload.client_id,
"front": payload.front.unwrap_or(false),
"outputs": {},
"workflow": comfy_workflow
}))
.into_response())
}
pub(super) async fn interrupt(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let _ = auth_caller_optional(&state, &headers);
if state.config.comfyui.uses_native_backend() {
return native_workflow_json(&state, Method::POST, "/interrupt", Some(json!({}))).await;
}
Ok(Json(json!({ "success": true })))
}
pub(super) async fn view(
State(state): State<AppState>,
headers: HeaderMap,
Query(params): Query<HashMap<String, String>>,
) -> AppResult<Response> {
let caller = auth_caller(&state, &headers).await?;
if state.config.comfyui.uses_native_backend() {
return native_workflow_view_response(&state, &caller, &params).await;
}
let file = params.get("filename").cloned().unwrap_or_default();
if file.is_empty() {
return Err(AppError::BadRequest("filename is required".into()));
}
let file_type = params
.get("type")
.cloned()
.unwrap_or_else(|| "input".to_string());
let subfolder = params.get("subfolder").cloned().unwrap_or_default();
let user_root = user_home(caller_user_id(&caller))
.join("userdata")
.join(&file_type);
let local_path = if subfolder.trim().is_empty() {
user_root.join(&file)
} else {
user_root.join(safe_view_path(&subfolder)).join(&file)
};
if tokio::fs::metadata(&local_path).await.is_ok() {
let bytes = tokio::fs::read(&local_path)
.await
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
return Ok((
StatusCode::OK,
[(
axum::http::header::CONTENT_TYPE,
guess_mime_from_path(&local_path),
)],
bytes,
)
.into_response());
}
let row = sqlx::query(
"SELECT file_path, remote_url, mime_type
FROM workspace_assets
WHERE file_name = ? OR public_id = ?
ORDER BY id DESC
LIMIT 1",
)
.bind(&file)
.bind(&file)
.fetch_optional(&*state.db)
.await?;
let Some(row) = row else {
return Err(AppError::NotFound("Workspace asset not found".into()));
};
let file_path: Option<String> = row.try_get("file_path").ok();
if let Some(path) = file_path {
let path_buf = PathBuf::from(path);
match tokio::fs::read(&path_buf).await {
Ok(bytes) => {
return Ok((
StatusCode::OK,
[(
axum::http::header::CONTENT_TYPE,
guess_mime_from_path(&path_buf),
)],
bytes,
)
.into_response())
}
Err(err) => return Err(AppError::UpstreamError(err.to_string())),
}
}
let remote_url: Option<String> = row.try_get("remote_url").ok();
if let Some(url) = remote_url
.as_deref()
.filter(|value| !value.trim().is_empty())
{
return Ok((
StatusCode::TEMPORARY_REDIRECT,
[(axum::http::header::LOCATION, url.to_string())],
)
.into_response());
}
Err(AppError::NotFound(
remote_url.unwrap_or_else(|| "asset unavailable".into()),
))
}
pub(super) async fn internal_logs(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Html<String>> {
let _ = auth_caller(&state, &headers).await?;
Ok(Html("".to_string()))
}
pub(super) async fn internal_logs_raw(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let _ = auth_caller(&state, &headers).await?;
Ok(Json(
json!({ "size": { "cols": 0, "row": 0 }, "entries": [] }),
))
}