forked from boos/BoosAPI
feat: add workflow studio and postgres support
This commit is contained in:
@@ -0,0 +1,925 @@
|
||||
use crate::{
|
||||
error::{AppError, AppResult},
|
||||
services::workspace,
|
||||
workflow::{
|
||||
graph,
|
||||
models::{NodeExecution, NodeOutputStore, RunContext, WorkflowGraph, WorkflowNode},
|
||||
storage,
|
||||
},
|
||||
AppState,
|
||||
};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum WorkflowProgressEvent {
|
||||
Started {
|
||||
prompt_id: String,
|
||||
total: usize,
|
||||
},
|
||||
NodeStarted {
|
||||
prompt_id: String,
|
||||
node_id: String,
|
||||
index: usize,
|
||||
total: usize,
|
||||
},
|
||||
NodeCompleted {
|
||||
prompt_id: String,
|
||||
node_id: String,
|
||||
index: usize,
|
||||
total: usize,
|
||||
output: Value,
|
||||
},
|
||||
Finished {
|
||||
prompt_id: String,
|
||||
success: bool,
|
||||
error: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
pub type WorkflowProgressCallback = Arc<dyn Fn(WorkflowProgressEvent) + Send + Sync>;
|
||||
|
||||
pub async fn run_workflow(
|
||||
state: &AppState,
|
||||
workflow_id: i64,
|
||||
graph_value: Value,
|
||||
input_overrides: Value,
|
||||
ctx: RunContext,
|
||||
) -> AppResult<String> {
|
||||
run_workflow_with_progress(state, workflow_id, graph_value, input_overrides, ctx, None).await
|
||||
}
|
||||
|
||||
pub async fn run_workflow_with_progress(
|
||||
state: &AppState,
|
||||
workflow_id: i64,
|
||||
graph_value: Value,
|
||||
input_overrides: Value,
|
||||
ctx: RunContext,
|
||||
progress: Option<WorkflowProgressCallback>,
|
||||
) -> AppResult<String> {
|
||||
let graph: WorkflowGraph = serde_json::from_value(graph_value.clone())
|
||||
.map_err(|err| AppError::BadRequest(format!("invalid workflow graph: {err}")))?;
|
||||
let order = graph::validate_and_sort(&graph)?;
|
||||
let total = order.len().max(1);
|
||||
let (run_db_id, run_public_id) =
|
||||
storage::insert_run(state, workflow_id, ctx.user_id, &input_overrides).await?;
|
||||
emit_progress(
|
||||
&progress,
|
||||
WorkflowProgressEvent::Started {
|
||||
prompt_id: run_public_id.clone(),
|
||||
total,
|
||||
},
|
||||
);
|
||||
let nodes = graph
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|node| (node.id.clone(), node.clone()))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let mut store = NodeOutputStore::new();
|
||||
|
||||
for (index, node_id) in order.into_iter().enumerate() {
|
||||
let Some(node) = nodes.get(&node_id) else {
|
||||
continue;
|
||||
};
|
||||
emit_progress(
|
||||
&progress,
|
||||
WorkflowProgressEvent::NodeStarted {
|
||||
prompt_id: run_public_id.clone(),
|
||||
node_id: node.id.clone(),
|
||||
index,
|
||||
total,
|
||||
},
|
||||
);
|
||||
let inputs = collect_inputs(&graph, &store, node, &input_overrides)?;
|
||||
let node_run_id =
|
||||
storage::insert_node_run_started(state, run_db_id, &node.id, &node.node_type, &inputs)
|
||||
.await?;
|
||||
match execute_node(state, &ctx, node, inputs.clone()).await {
|
||||
Ok(result) => {
|
||||
storage::complete_node_run(state, node_run_id, &result.outputs).await?;
|
||||
emit_progress(
|
||||
&progress,
|
||||
WorkflowProgressEvent::NodeCompleted {
|
||||
prompt_id: run_public_id.clone(),
|
||||
node_id: node.id.clone(),
|
||||
index: index + 1,
|
||||
total,
|
||||
output: result.outputs.clone(),
|
||||
},
|
||||
);
|
||||
store.insert(node.id.clone(), result.outputs);
|
||||
}
|
||||
Err(err) => {
|
||||
let message = err.to_string();
|
||||
let _ = storage::fail_node_run(state, node_run_id, &message).await;
|
||||
let _ = storage::fail_run(state, run_db_id, &message).await;
|
||||
emit_progress(
|
||||
&progress,
|
||||
WorkflowProgressEvent::Finished {
|
||||
prompt_id: run_public_id.clone(),
|
||||
success: false,
|
||||
error: Some(message),
|
||||
},
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let final_outputs = json!({ "nodes": store });
|
||||
storage::complete_run(state, run_db_id, &final_outputs).await?;
|
||||
emit_progress(
|
||||
&progress,
|
||||
WorkflowProgressEvent::Finished {
|
||||
prompt_id: run_public_id.clone(),
|
||||
success: true,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
Ok(run_public_id)
|
||||
}
|
||||
|
||||
fn emit_progress(progress: &Option<WorkflowProgressCallback>, event: WorkflowProgressEvent) {
|
||||
if let Some(callback) = progress {
|
||||
callback(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_inputs(
|
||||
graph: &WorkflowGraph,
|
||||
store: &NodeOutputStore,
|
||||
node: &WorkflowNode,
|
||||
input_overrides: &Value,
|
||||
) -> AppResult<Value> {
|
||||
let mut inputs = Map::new();
|
||||
for edge in &graph.edges {
|
||||
if graph::endpoint_node(&edge.to)? != node.id {
|
||||
continue;
|
||||
}
|
||||
let from_node = graph::endpoint_node(&edge.from)?;
|
||||
let from_port = graph::endpoint_port(&edge.from)?;
|
||||
let to_port = graph::endpoint_port(&edge.to)?;
|
||||
if let Some(value) = store
|
||||
.get(from_node)
|
||||
.and_then(|outputs| outputs.get(from_port))
|
||||
{
|
||||
inputs.insert(to_port.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
if let Some(overrides) = input_overrides.as_object() {
|
||||
for (key, value) in overrides {
|
||||
if let Some((override_node, port)) = key.split_once('.') {
|
||||
if override_node == node.id {
|
||||
inputs.insert(port.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Value::Object(inputs))
|
||||
}
|
||||
|
||||
async fn execute_node(
|
||||
state: &AppState,
|
||||
ctx: &RunContext,
|
||||
node: &WorkflowNode,
|
||||
inputs: Value,
|
||||
) -> AppResult<NodeExecution> {
|
||||
match node.node_type.as_str() {
|
||||
"input.text" => {
|
||||
let text = inputs
|
||||
.get("value")
|
||||
.or_else(|| node.params.get("value"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({ "text": text }),
|
||||
})
|
||||
}
|
||||
"boosapi.chat_completion" => chat_completion(state, ctx, node, inputs, false).await,
|
||||
"boosapi.comfyui_workflow" => comfyui_workflow(ctx, node, inputs).await,
|
||||
"comfyui.submit_workflow" => submit_comfyui_workflow(state, node, inputs).await,
|
||||
"boosapi.prompt_rewrite" => chat_completion(state, ctx, node, inputs, true).await,
|
||||
"boosapi.text_to_image" => text_to_image(state, ctx, node, inputs).await,
|
||||
"boosapi.text_to_video" => text_to_video(ctx, node, inputs).await,
|
||||
"boosapi.image_to_video" => image_to_video(ctx, node, inputs).await,
|
||||
"boosapi.video_status" => video_status(ctx, node, inputs).await,
|
||||
"CheckpointLoaderSimple" => {
|
||||
let model = param_str(node, "ckpt_name", "gpt-image-2");
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({
|
||||
"MODEL": { "model": model },
|
||||
"CLIP": { "model": model },
|
||||
"VAE": { "model": model }
|
||||
}),
|
||||
})
|
||||
}
|
||||
"CLIPTextEncode" => {
|
||||
let text = param_str(node, "text", "");
|
||||
let model = inputs
|
||||
.pointer("/clip/model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("gpt-image-2")
|
||||
.to_string();
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({ "CONDITIONING": { "text": text, "model": model } }),
|
||||
})
|
||||
}
|
||||
"EmptyLatentImage" => Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({
|
||||
"LATENT": {
|
||||
"width": param_i64(node, "width", 1024),
|
||||
"height": param_i64(node, "height", 1024),
|
||||
"batch_size": param_i64(node, "batch_size", 1)
|
||||
}
|
||||
}),
|
||||
}),
|
||||
"KSampler" => {
|
||||
let model = inputs
|
||||
.pointer("/model/model")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| inputs.pointer("/positive/model").and_then(Value::as_str))
|
||||
.unwrap_or("gpt-image-2")
|
||||
.to_string();
|
||||
let prompt = inputs
|
||||
.pointer("/positive/text")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let negative_prompt = inputs
|
||||
.pointer("/negative/text")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let width = inputs
|
||||
.pointer("/latent_image/width")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(1024);
|
||||
let height = inputs
|
||||
.pointer("/latent_image/height")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(1024);
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({
|
||||
"LATENT": {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"negative_prompt": negative_prompt,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"steps": param_i64(node, "steps", 20),
|
||||
"cfg": param_number(node, "cfg", 8.0),
|
||||
"seed": param_i64(node, "seed", 0)
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
"VAEDecode" => vae_decode_to_image(state, ctx, inputs).await,
|
||||
"SaveImage" | "PreviewImage" => {
|
||||
let image = inputs
|
||||
.get("images")
|
||||
.cloned()
|
||||
.or_else(|| inputs.get("image").cloned())
|
||||
.or_else(|| inputs.get("IMAGE").cloned())
|
||||
.ok_or_else(|| {
|
||||
AppError::BadRequest(format!("{} missing images input", node.id))
|
||||
})?;
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({ "IMAGE": image, "image": image }),
|
||||
})
|
||||
}
|
||||
"output.preview_image" => {
|
||||
let image = inputs
|
||||
.get("image")
|
||||
.cloned()
|
||||
.or_else(|| inputs.get("images").cloned())
|
||||
.or_else(|| inputs.get("IMAGE").cloned())
|
||||
.ok_or_else(|| AppError::BadRequest(format!("{} missing image input", node.id)))?;
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({ "image": image }),
|
||||
})
|
||||
}
|
||||
"output.preview_video" => {
|
||||
let video = inputs
|
||||
.get("video")
|
||||
.cloned()
|
||||
.or_else(|| inputs.get("videos").cloned())
|
||||
.or_else(|| inputs.get("VIDEO").cloned())
|
||||
.ok_or_else(|| AppError::BadRequest(format!("{} missing video input", node.id)))?;
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({ "video": video }),
|
||||
})
|
||||
}
|
||||
"output.save_file" => {
|
||||
let file = inputs
|
||||
.get("file")
|
||||
.cloned()
|
||||
.or_else(|| inputs.get("image").cloned())
|
||||
.or_else(|| inputs.get("images").and_then(|images| images.get(0)).cloned())
|
||||
.ok_or_else(|| AppError::BadRequest(format!("{} missing file input", node.id)))?;
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({ "file": file }),
|
||||
})
|
||||
}
|
||||
"util.text_join" => {
|
||||
let prefix = param_str(node, "prefix", "");
|
||||
let suffix = param_str(node, "suffix", "");
|
||||
let separator = param_str(node, "separator", "\n");
|
||||
let text = value_to_string(inputs.get("text"));
|
||||
let joined = [prefix, text, suffix]
|
||||
.into_iter()
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(&separator);
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({ "text": joined }),
|
||||
})
|
||||
}
|
||||
"util.json_extract" => {
|
||||
let mut current = inputs.get("json").cloned().unwrap_or(Value::Null);
|
||||
for segment in param_str(node, "path", "")
|
||||
.split('.')
|
||||
.filter(|v| !v.is_empty())
|
||||
{
|
||||
current = if let Ok(index) = segment.parse::<usize>() {
|
||||
current.get(index).cloned().unwrap_or(Value::Null)
|
||||
} else {
|
||||
current.get(segment).cloned().unwrap_or(Value::Null)
|
||||
};
|
||||
}
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({ "value": current, "text": value_to_string(Some(¤t)) }),
|
||||
})
|
||||
}
|
||||
_ => Err(AppError::BadRequest(format!(
|
||||
"unsupported node type: {}",
|
||||
node.node_type
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn comfyui_workflow(
|
||||
ctx: &RunContext,
|
||||
node: &WorkflowNode,
|
||||
inputs: Value,
|
||||
) -> AppResult<NodeExecution> {
|
||||
let text = value_to_string(inputs.get("text"));
|
||||
if text.trim().is_empty() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"{} missing text input",
|
||||
node.id
|
||||
)));
|
||||
}
|
||||
let system = param_str(
|
||||
node,
|
||||
"system",
|
||||
"Generate a valid ComfyUI workflow JSON object. Return JSON only.",
|
||||
);
|
||||
let body = json!({
|
||||
"model": param_str(node, "model", "gpt-5.4"),
|
||||
"messages": [
|
||||
{ "role": "system", "content": system },
|
||||
{ "role": "user", "content": text }
|
||||
],
|
||||
"stream": false,
|
||||
"temperature": param_number(node, "temperature", 0.3)
|
||||
});
|
||||
let raw = post_json(ctx, "/chat/completions", &body).await?;
|
||||
let content = raw
|
||||
.pointer("/choices/0/message/content")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let workflow = parse_jsonish(&content).unwrap_or_else(|| {
|
||||
json!({
|
||||
"boosapi": {
|
||||
"kind": "comfyui-workflow-draft",
|
||||
"prompt": text,
|
||||
"raw": content
|
||||
},
|
||||
"nodes": {}
|
||||
})
|
||||
});
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({ "workflow": workflow, "text": content, "raw": raw }),
|
||||
})
|
||||
}
|
||||
|
||||
async fn submit_comfyui_workflow(
|
||||
state: &AppState,
|
||||
node: &WorkflowNode,
|
||||
inputs: Value,
|
||||
) -> AppResult<NodeExecution> {
|
||||
let workflow = inputs
|
||||
.get("workflow")
|
||||
.cloned()
|
||||
.ok_or_else(|| AppError::BadRequest(format!("{} missing workflow input", node.id)))?;
|
||||
if param_bool(node, "dry_run", true) {
|
||||
return Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({
|
||||
"prompt_id": null,
|
||||
"response": { "dryRun": true },
|
||||
"workflow": workflow,
|
||||
}),
|
||||
});
|
||||
}
|
||||
let body = json!({
|
||||
"client_id": param_str(node, "client_id", "boosapi-workflow-studio"),
|
||||
"prompt": workflow,
|
||||
});
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{}/api/comfyui/prompt", ctx_server_base(state)))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
|
||||
let value = serde_json::from_str::<Value>(&text).unwrap_or_else(|_| json!({ "body": text }));
|
||||
if !status.is_success() {
|
||||
return Err(AppError::UpstreamError(value.to_string()));
|
||||
}
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({
|
||||
"prompt_id": value.get("prompt_id").and_then(Value::as_str),
|
||||
"response": value,
|
||||
"workflow": body.get("prompt").cloned().unwrap_or(Value::Null),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async fn chat_completion(
|
||||
_state: &AppState,
|
||||
ctx: &RunContext,
|
||||
node: &WorkflowNode,
|
||||
inputs: Value,
|
||||
rewrite: bool,
|
||||
) -> AppResult<NodeExecution> {
|
||||
let text = value_to_string(inputs.get("text"));
|
||||
if text.trim().is_empty() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"{} missing text input",
|
||||
node.id
|
||||
)));
|
||||
}
|
||||
let model = param_str(node, "model", "gpt-5.4");
|
||||
let system = if rewrite {
|
||||
format!(
|
||||
"Rewrite the user input as a concise, high quality prompt. Style requirement: {}. Return only the rewritten prompt.",
|
||||
param_str(node, "style", "cinematic English image prompt")
|
||||
)
|
||||
} else {
|
||||
param_str(node, "system", "")
|
||||
};
|
||||
let mut messages = Vec::new();
|
||||
if !system.trim().is_empty() {
|
||||
messages.push(json!({ "role": "system", "content": system }));
|
||||
}
|
||||
messages.push(json!({ "role": "user", "content": text }));
|
||||
let body = json!({
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": false,
|
||||
"temperature": param_number(node, "temperature", 0.7)
|
||||
});
|
||||
let raw = post_json(ctx, "/chat/completions", &body).await?;
|
||||
let content = raw
|
||||
.pointer("/choices/0/message/content")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let outputs = if rewrite {
|
||||
json!({ "prompt": content, "raw": raw })
|
||||
} else {
|
||||
json!({ "text": content, "raw": raw })
|
||||
};
|
||||
Ok(NodeExecution { inputs, outputs })
|
||||
}
|
||||
|
||||
async fn text_to_image(
|
||||
state: &AppState,
|
||||
ctx: &RunContext,
|
||||
node: &WorkflowNode,
|
||||
inputs: Value,
|
||||
) -> AppResult<NodeExecution> {
|
||||
let prompt = value_to_string(inputs.get("prompt"));
|
||||
if prompt.trim().is_empty() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"{} missing prompt input",
|
||||
node.id
|
||||
)));
|
||||
}
|
||||
let width = param_i64(node, "width", 1024);
|
||||
let height = param_i64(node, "height", 1024);
|
||||
let size = param_str(node, "size", "");
|
||||
let body = json!({
|
||||
"model": param_str(node, "model", "gpt-image-2"),
|
||||
"prompt": prompt,
|
||||
"n": 1,
|
||||
"size": if size.trim().is_empty() { format!("{width}x{height}") } else { size },
|
||||
});
|
||||
execute_image_generation(state, ctx, inputs, body).await
|
||||
}
|
||||
|
||||
async fn text_to_video(
|
||||
ctx: &RunContext,
|
||||
node: &WorkflowNode,
|
||||
inputs: Value,
|
||||
) -> AppResult<NodeExecution> {
|
||||
let prompt = value_to_string(inputs.get("prompt"));
|
||||
if prompt.trim().is_empty() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"{} missing prompt input",
|
||||
node.id
|
||||
)));
|
||||
}
|
||||
let body = video_request_body(node, prompt, None);
|
||||
execute_video_generation(ctx, inputs, body).await
|
||||
}
|
||||
|
||||
async fn image_to_video(
|
||||
ctx: &RunContext,
|
||||
node: &WorkflowNode,
|
||||
inputs: Value,
|
||||
) -> AppResult<NodeExecution> {
|
||||
let prompt = value_to_string(inputs.get("prompt"));
|
||||
if prompt.trim().is_empty() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"{} missing prompt input",
|
||||
node.id
|
||||
)));
|
||||
}
|
||||
let image_url = param_str(node, "image_url", "");
|
||||
let image_url = if image_url.trim().is_empty() {
|
||||
extract_media_url(inputs.get("image"))
|
||||
} else {
|
||||
Some(image_url)
|
||||
};
|
||||
let body = video_request_body(node, prompt, image_url);
|
||||
execute_video_generation(ctx, inputs, body).await
|
||||
}
|
||||
|
||||
async fn video_status(
|
||||
ctx: &RunContext,
|
||||
node: &WorkflowNode,
|
||||
inputs: Value,
|
||||
) -> AppResult<NodeExecution> {
|
||||
let task_id = value_to_string(inputs.get("task_id"));
|
||||
if task_id.trim().is_empty() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"{} missing task_id input",
|
||||
node.id
|
||||
)));
|
||||
}
|
||||
let model = param_str(node, "model", "seedance-1-0-lite-t2v");
|
||||
let path = format!(
|
||||
"/videos/{}?model={}",
|
||||
simple_urlencode(task_id.trim()),
|
||||
simple_urlencode(model.trim())
|
||||
);
|
||||
let raw = get_json(ctx, &path).await?;
|
||||
let video = video_output_from_response(&raw);
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({ "video": video, "raw": raw }),
|
||||
})
|
||||
}
|
||||
|
||||
fn video_request_body(node: &WorkflowNode, prompt: String, image_url: Option<String>) -> Value {
|
||||
let mut body = Map::new();
|
||||
body.insert("model".to_string(), json!(param_str(node, "model", "seedance-1-0-lite-t2v")));
|
||||
body.insert("prompt".to_string(), json!(prompt));
|
||||
for key in ["ratio", "resolution"] {
|
||||
let value = param_str(node, key, "");
|
||||
if !value.trim().is_empty() {
|
||||
body.insert(key.to_string(), json!(value));
|
||||
}
|
||||
}
|
||||
for key in ["duration", "fps"] {
|
||||
if let Some(value) = node.params.get(key).and_then(Value::as_i64) {
|
||||
body.insert(key.to_string(), json!(value));
|
||||
} else if let Some(value) = node.params.get(key).and_then(Value::as_f64) {
|
||||
body.insert(key.to_string(), json!(value));
|
||||
}
|
||||
}
|
||||
if let Some(url) = image_url.filter(|value| !value.trim().is_empty()) {
|
||||
body.insert("image_url".to_string(), json!(url));
|
||||
}
|
||||
Value::Object(body)
|
||||
}
|
||||
|
||||
async fn execute_video_generation(
|
||||
ctx: &RunContext,
|
||||
inputs: Value,
|
||||
body: Value,
|
||||
) -> AppResult<NodeExecution> {
|
||||
let raw = post_json(ctx, "/videos", &body).await?;
|
||||
let task_id = extract_task_id(&raw);
|
||||
let video = video_output_from_response(&raw);
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({
|
||||
"task_id": task_id,
|
||||
"video": video,
|
||||
"raw": raw
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async fn vae_decode_to_image(
|
||||
state: &AppState,
|
||||
ctx: &RunContext,
|
||||
inputs: Value,
|
||||
) -> AppResult<NodeExecution> {
|
||||
let samples = inputs.get("samples").cloned().unwrap_or(Value::Null);
|
||||
let vae = inputs.get("vae").cloned().unwrap_or(Value::Null);
|
||||
let prompt = samples
|
||||
.get("prompt")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
if prompt.trim().is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"VAEDecode missing positive prompt from KSampler".into(),
|
||||
));
|
||||
}
|
||||
let model = samples
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| vae.get("model").and_then(Value::as_str))
|
||||
.unwrap_or("gpt-image-2");
|
||||
let width = samples
|
||||
.get("width")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(1024);
|
||||
let height = samples
|
||||
.get("height")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(1024);
|
||||
let mut prompt_text = prompt;
|
||||
if let Some(negative) = samples.get("negative_prompt").and_then(Value::as_str) {
|
||||
if !negative.trim().is_empty() {
|
||||
prompt_text.push_str("\nNegative prompt: ");
|
||||
prompt_text.push_str(negative.trim());
|
||||
}
|
||||
}
|
||||
let body = json!({
|
||||
"model": model,
|
||||
"prompt": prompt_text,
|
||||
"n": 1,
|
||||
"size": format!("{width}x{height}"),
|
||||
});
|
||||
execute_image_generation(state, ctx, inputs, body).await
|
||||
}
|
||||
|
||||
async fn execute_image_generation(
|
||||
state: &AppState,
|
||||
ctx: &RunContext,
|
||||
inputs: Value,
|
||||
body: Value,
|
||||
) -> AppResult<NodeExecution> {
|
||||
let raw = post_json(ctx, "/images/generations", &body).await?;
|
||||
let assets = workspace::record_image_assets_from_response(
|
||||
state,
|
||||
ctx.user_id,
|
||||
"workflow-studio",
|
||||
body.get("prompt")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
body.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
&raw,
|
||||
)
|
||||
.await;
|
||||
let images = raw.get("data").cloned().unwrap_or_else(|| json!([]));
|
||||
let first = images.get(0).cloned().unwrap_or(Value::Null);
|
||||
let comfy_images = assets
|
||||
.iter()
|
||||
.map(|asset| {
|
||||
json!({
|
||||
"filename": asset.file_name.as_deref().unwrap_or(&asset.public_id),
|
||||
"subfolder": "",
|
||||
"type": "output",
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(NodeExecution {
|
||||
inputs,
|
||||
outputs: json!({
|
||||
"image": first,
|
||||
"images": images,
|
||||
"comfy_images": comfy_images,
|
||||
"raw": raw
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async fn post_json(ctx: &RunContext, path: &str, body: &Value) -> AppResult<Value> {
|
||||
let url = format!("{}{}", ctx.base_url.trim_end_matches('/'), path);
|
||||
let response = reqwest::Client::new()
|
||||
.post(url)
|
||||
.bearer_auth(&ctx.bearer)
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
|
||||
let json = serde_json::from_str::<Value>(&text).map_err(|err| {
|
||||
AppError::UpstreamError(format!(
|
||||
"invalid JSON from BoosAPI workflow node: {err}; body: {}",
|
||||
text.chars().take(500).collect::<String>()
|
||||
))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(AppError::UpstreamError(json.to_string()));
|
||||
}
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
async fn get_json(ctx: &RunContext, path: &str) -> AppResult<Value> {
|
||||
let url = format!("{}{}", ctx.base_url.trim_end_matches('/'), path);
|
||||
let response = reqwest::Client::new()
|
||||
.get(url)
|
||||
.bearer_auth(&ctx.bearer)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
|
||||
let json = serde_json::from_str::<Value>(&text).map_err(|err| {
|
||||
AppError::UpstreamError(format!(
|
||||
"invalid JSON from BoosAPI workflow node: {err}; body: {}",
|
||||
text.chars().take(500).collect::<String>()
|
||||
))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(AppError::UpstreamError(json.to_string()));
|
||||
}
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
fn extract_task_id(value: &Value) -> Value {
|
||||
for pointer in ["/id", "/task_id", "/data/id", "/data/task_id", "/result/id", "/result/task_id"] {
|
||||
if let Some(id) = value.pointer(pointer).and_then(Value::as_str) {
|
||||
if !id.trim().is_empty() {
|
||||
return json!(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Null
|
||||
}
|
||||
|
||||
fn video_output_from_response(value: &Value) -> Value {
|
||||
if let Some(url) = find_url_by_extension(value, &[".mp4", ".webm", ".mov"])
|
||||
.or_else(|| find_url_key(value, &["video_url", "videoUrl", "url"]))
|
||||
{
|
||||
json!({ "url": url })
|
||||
} else {
|
||||
Value::Null
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_media_url(value: Option<&Value>) -> Option<String> {
|
||||
let value = value?;
|
||||
find_url_key(value, &["url", "remote_url", "remoteUrl", "image_url", "imageUrl"])
|
||||
}
|
||||
|
||||
fn find_url_key(value: &Value, keys: &[&str]) -> Option<String> {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
for key in keys {
|
||||
if let Some(url) = map
|
||||
.get(*key)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|url| url.starts_with("http://") || url.starts_with("https://"))
|
||||
{
|
||||
return Some(url.to_string());
|
||||
}
|
||||
}
|
||||
map.values().find_map(|child| find_url_key(child, keys))
|
||||
}
|
||||
Value::Array(items) => items.iter().find_map(|child| find_url_key(child, keys)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn find_url_by_extension(value: &Value, extensions: &[&str]) -> Option<String> {
|
||||
match value {
|
||||
Value::String(text)
|
||||
if (text.starts_with("http://") || text.starts_with("https://"))
|
||||
&& extensions.iter().any(|ext| text.to_ascii_lowercase().contains(ext)) =>
|
||||
{
|
||||
Some(text.clone())
|
||||
}
|
||||
Value::Object(map) => map
|
||||
.values()
|
||||
.find_map(|child| find_url_by_extension(child, extensions)),
|
||||
Value::Array(items) => items
|
||||
.iter()
|
||||
.find_map(|child| find_url_by_extension(child, extensions)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn simple_urlencode(value: &str) -> String {
|
||||
value
|
||||
.replace('%', "%25")
|
||||
.replace(' ', "%20")
|
||||
.replace('?', "%3F")
|
||||
.replace('&', "%26")
|
||||
.replace('#', "%23")
|
||||
.replace('/', "%2F")
|
||||
}
|
||||
|
||||
fn param_str(node: &WorkflowNode, key: &str, default: &str) -> String {
|
||||
node.params
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(default)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn param_i64(node: &WorkflowNode, key: &str, default: i64) -> i64 {
|
||||
node.params
|
||||
.get(key)
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn param_number(node: &WorkflowNode, key: &str, default: f64) -> f64 {
|
||||
node.params
|
||||
.get(key)
|
||||
.and_then(Value::as_f64)
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn param_bool(node: &WorkflowNode, key: &str, default: bool) -> bool {
|
||||
node.params
|
||||
.get(key)
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn parse_jsonish(text: &str) -> Option<Value> {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let stripped = trimmed
|
||||
.strip_prefix("```json")
|
||||
.or_else(|| trimmed.strip_prefix("```"))
|
||||
.unwrap_or(trimmed)
|
||||
.trim()
|
||||
.strip_suffix("```")
|
||||
.unwrap_or_else(|| {
|
||||
trimmed
|
||||
.strip_prefix("```json")
|
||||
.or_else(|| trimmed.strip_prefix("```"))
|
||||
.unwrap_or(trimmed)
|
||||
.trim()
|
||||
})
|
||||
.trim();
|
||||
serde_json::from_str(stripped).ok()
|
||||
}
|
||||
|
||||
fn ctx_server_base(state: &AppState) -> String {
|
||||
let host = if state.config.server.host == "0.0.0.0" || state.config.server.host == "::" {
|
||||
"127.0.0.1".to_string()
|
||||
} else {
|
||||
state.config.server.host.clone()
|
||||
};
|
||||
format!("http://{}:{}", host, state.config.server.port)
|
||||
}
|
||||
|
||||
fn value_to_string(value: Option<&Value>) -> String {
|
||||
match value {
|
||||
Some(Value::String(text)) => text.clone(),
|
||||
Some(Value::Null) | None => String::new(),
|
||||
Some(other) => other.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
use crate::{
|
||||
error::{AppError, AppResult},
|
||||
workflow::{models::WorkflowGraph, registry},
|
||||
};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
pub fn validate_and_sort(graph: &WorkflowGraph) -> AppResult<Vec<String>> {
|
||||
if graph.nodes.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"workflow must contain at least one node".into(),
|
||||
));
|
||||
}
|
||||
let mut indegree: HashMap<String, usize> = HashMap::new();
|
||||
let mut outgoing: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for node in &graph.nodes {
|
||||
let node_id = node.id.trim();
|
||||
if node_id.is_empty() {
|
||||
return Err(AppError::BadRequest("node id is required".into()));
|
||||
}
|
||||
if indegree.insert(node.id.clone(), 0).is_some() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"duplicate node id: {}",
|
||||
node.id
|
||||
)));
|
||||
}
|
||||
if !registry::is_known_node(&node.node_type) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"unknown node type: {}",
|
||||
node.node_type
|
||||
)));
|
||||
}
|
||||
}
|
||||
for edge in &graph.edges {
|
||||
let from = endpoint_node(&edge.from)?;
|
||||
let to = endpoint_node(&edge.to)?;
|
||||
if !indegree.contains_key(from) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"edge references missing node: {from}"
|
||||
)));
|
||||
}
|
||||
if !indegree.contains_key(to) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"edge references missing node: {to}"
|
||||
)));
|
||||
}
|
||||
outgoing
|
||||
.entry(from.to_string())
|
||||
.or_default()
|
||||
.push(to.to_string());
|
||||
*indegree.entry(to.to_string()).or_default() += 1;
|
||||
}
|
||||
|
||||
let mut queue = graph
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|node| indegree.get(&node.id).copied().unwrap_or(0) == 0)
|
||||
.map(|node| node.id.clone())
|
||||
.collect::<VecDeque<_>>();
|
||||
let mut order = Vec::with_capacity(graph.nodes.len());
|
||||
while let Some(node_id) = queue.pop_front() {
|
||||
order.push(node_id.clone());
|
||||
if let Some(next) = outgoing.get(&node_id) {
|
||||
for target in next {
|
||||
let entry = indegree.entry(target.clone()).or_default();
|
||||
*entry = entry.saturating_sub(1);
|
||||
if *entry == 0 {
|
||||
queue.push_back(target.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if order.len() != graph.nodes.len() {
|
||||
return Err(AppError::BadRequest(
|
||||
"workflow graph contains a cycle".into(),
|
||||
));
|
||||
}
|
||||
Ok(order)
|
||||
}
|
||||
|
||||
pub fn endpoint_node(endpoint: &str) -> AppResult<&str> {
|
||||
endpoint
|
||||
.split_once('.')
|
||||
.map(|(node, _)| node)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| AppError::BadRequest(format!("invalid endpoint: {endpoint}")))
|
||||
}
|
||||
|
||||
pub fn endpoint_port(endpoint: &str) -> AppResult<&str> {
|
||||
endpoint
|
||||
.split_once('.')
|
||||
.map(|(_, port)| port)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| AppError::BadRequest(format!("invalid endpoint: {endpoint}")))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod executor;
|
||||
pub mod graph;
|
||||
pub mod models;
|
||||
pub mod registry;
|
||||
pub mod routes;
|
||||
pub mod storage;
|
||||
@@ -0,0 +1,155 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkflowGraph {
|
||||
#[serde(default = "default_version")]
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub nodes: Vec<WorkflowNode>,
|
||||
#[serde(default)]
|
||||
pub edges: Vec<WorkflowEdge>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkflowNode {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub node_type: String,
|
||||
#[serde(default)]
|
||||
pub position: Value,
|
||||
#[serde(default)]
|
||||
pub params: Map<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkflowEdge {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorkflowSummary {
|
||||
pub id: i64,
|
||||
pub public_id: String,
|
||||
pub user_id: Option<i64>,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub graph: Value,
|
||||
pub is_template: bool,
|
||||
pub visibility: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorkflowRunSummary {
|
||||
pub id: i64,
|
||||
pub public_id: String,
|
||||
pub workflow_id: i64,
|
||||
pub workflow_public_id: Option<String>,
|
||||
pub user_id: Option<i64>,
|
||||
pub status: String,
|
||||
pub input_overrides: Value,
|
||||
pub final_outputs: Option<Value>,
|
||||
pub error_message: Option<String>,
|
||||
pub started_at: Option<String>,
|
||||
pub completed_at: Option<String>,
|
||||
pub created_at: String,
|
||||
pub node_runs: Vec<WorkflowNodeRunSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorkflowNodeRunSummary {
|
||||
pub id: i64,
|
||||
pub node_id: String,
|
||||
pub node_type: String,
|
||||
pub status: String,
|
||||
pub inputs: Value,
|
||||
pub outputs: Option<Value>,
|
||||
pub error_message: Option<String>,
|
||||
pub started_at: Option<String>,
|
||||
pub completed_at: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct NodeTypeDefinition {
|
||||
#[serde(rename = "type")]
|
||||
pub node_type: &'static str,
|
||||
pub title: &'static str,
|
||||
pub category: &'static str,
|
||||
pub description: &'static str,
|
||||
pub inputs: Value,
|
||||
pub params: Value,
|
||||
pub outputs: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RunContext {
|
||||
pub user_id: Option<i64>,
|
||||
pub bearer: String,
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NodeExecution {
|
||||
pub inputs: Value,
|
||||
pub outputs: Value,
|
||||
}
|
||||
|
||||
pub type NodeOutputStore = HashMap<String, Value>;
|
||||
|
||||
pub fn default_graph(name: &str) -> Value {
|
||||
json!({
|
||||
"version": default_version(),
|
||||
"name": name,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "input_1",
|
||||
"type": "input.text",
|
||||
"position": { "x": 80, "y": 120 },
|
||||
"params": { "value": "生成一个电影感雨夜城市短镜头,包含一张 1024x1024 的起始画面" }
|
||||
},
|
||||
{
|
||||
"id": "workflow_1",
|
||||
"type": "boosapi.comfyui_workflow",
|
||||
"position": { "x": 360, "y": 120 },
|
||||
"params": {
|
||||
"model": "gpt-5.4",
|
||||
"system": "Generate a valid ComfyUI workflow JSON object. Return JSON only."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "submit_1",
|
||||
"type": "comfyui.submit_workflow",
|
||||
"position": { "x": 660, "y": 120 },
|
||||
"params": {
|
||||
"client_id": "boosapi-workflow-studio",
|
||||
"dry_run": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "save_1",
|
||||
"type": "output.save_file",
|
||||
"position": { "x": 960, "y": 120 },
|
||||
"params": {}
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "input_1.text", "to": "workflow_1.text" },
|
||||
{ "from": "workflow_1.workflow", "to": "submit_1.workflow" },
|
||||
{ "from": "submit_1.workflow", "to": "save_1.file" }
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
fn default_version() -> String {
|
||||
"1.0".to_string()
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
use crate::workflow::models::NodeTypeDefinition;
|
||||
use serde_json::json;
|
||||
|
||||
fn core_node_types() -> Vec<NodeTypeDefinition> {
|
||||
vec![
|
||||
NodeTypeDefinition {
|
||||
node_type: "CheckpointLoaderSimple",
|
||||
title: "CheckpointLoaderSimple",
|
||||
category: "loaders",
|
||||
description: "Loads a checkpoint model and provides model, clip and vae outputs.",
|
||||
inputs: json!({}),
|
||||
params: json!({
|
||||
"ckpt_name": { "ui": "combo", "type": "COMBO", "default": "gpt-image-2" }
|
||||
}),
|
||||
outputs: json!({
|
||||
"MODEL": { "type": "MODEL" },
|
||||
"CLIP": { "type": "CLIP" },
|
||||
"VAE": { "type": "VAE" }
|
||||
}),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "CLIPTextEncode",
|
||||
title: "CLIPTextEncode",
|
||||
category: "conditioning",
|
||||
description: "Encodes text into conditioning.",
|
||||
inputs: json!({
|
||||
"clip": { "type": "CLIP", "required": false }
|
||||
}),
|
||||
params: json!({
|
||||
"text": { "ui": "textarea", "type": "STRING", "default": "" }
|
||||
}),
|
||||
outputs: json!({ "CONDITIONING": { "type": "CONDITIONING" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "EmptyLatentImage",
|
||||
title: "EmptyLatentImage",
|
||||
category: "latent",
|
||||
description: "Creates an empty latent image.",
|
||||
inputs: json!({}),
|
||||
params: json!({
|
||||
"width": { "ui": "number", "type": "INT", "default": 512, "min": 64, "max": 4096 },
|
||||
"height": { "ui": "number", "type": "INT", "default": 512, "min": 64, "max": 4096 },
|
||||
"batch_size": { "ui": "number", "type": "INT", "default": 1, "min": 1, "max": 64 }
|
||||
}),
|
||||
outputs: json!({ "LATENT": { "type": "LATENT" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "KSampler",
|
||||
title: "KSampler",
|
||||
category: "sampling",
|
||||
description: "Samples latent images with a diffusion model.",
|
||||
inputs: json!({
|
||||
"model": { "type": "MODEL", "required": true },
|
||||
"positive": { "type": "CONDITIONING", "required": true },
|
||||
"negative": { "type": "CONDITIONING", "required": true },
|
||||
"latent_image": { "type": "LATENT", "required": true }
|
||||
}),
|
||||
params: json!({
|
||||
"seed": { "ui": "number", "type": "INT", "default": 0 },
|
||||
"control_after_generate": { "ui": "boolean", "type": "BOOLEAN", "default": true },
|
||||
"steps": { "ui": "number", "type": "INT", "default": 20, "min": 1, "max": 100 },
|
||||
"cfg": { "ui": "number", "type": "FLOAT", "default": 8, "min": 0, "max": 50 },
|
||||
"sampler_name": { "ui": "text", "type": "STRING", "default": "euler" },
|
||||
"scheduler": { "ui": "text", "type": "STRING", "default": "normal" },
|
||||
"denoise": { "ui": "number", "type": "FLOAT", "default": 1, "min": 0, "max": 1 }
|
||||
}),
|
||||
outputs: json!({ "LATENT": { "type": "LATENT" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "VAEDecode",
|
||||
title: "VAEDecode",
|
||||
category: "latent",
|
||||
description: "Decodes latent samples to images.",
|
||||
inputs: json!({
|
||||
"samples": { "type": "LATENT", "required": true },
|
||||
"vae": { "type": "VAE", "required": true }
|
||||
}),
|
||||
params: json!({}),
|
||||
outputs: json!({ "IMAGE": { "type": "IMAGE" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "SaveImage",
|
||||
title: "SaveImage",
|
||||
category: "image",
|
||||
description: "Saves images to disk.",
|
||||
inputs: json!({
|
||||
"images": { "type": "IMAGE", "required": true }
|
||||
}),
|
||||
params: json!({
|
||||
"filename_prefix": { "ui": "text", "type": "STRING", "default": "ComfyUI" }
|
||||
}),
|
||||
outputs: json!({ "IMAGE": { "type": "IMAGE" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "PreviewImage",
|
||||
title: "PreviewImage",
|
||||
category: "image",
|
||||
description: "Previews generated images.",
|
||||
inputs: json!({
|
||||
"images": { "type": "IMAGE", "required": true }
|
||||
}),
|
||||
params: json!({}),
|
||||
outputs: json!({ "IMAGE": { "type": "IMAGE" } }),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub fn node_types() -> Vec<NodeTypeDefinition> {
|
||||
let mut nodes = core_node_types();
|
||||
nodes.extend(vec![
|
||||
NodeTypeDefinition {
|
||||
node_type: "input.text",
|
||||
title: "文本输入",
|
||||
category: "Input",
|
||||
description: "输入脚本、提示词或任意文本。",
|
||||
inputs: json!({}),
|
||||
params: json!({
|
||||
"value": { "ui": "textarea", "type": "STRING", "default": "一个电影感的雨夜城市镜头" }
|
||||
}),
|
||||
outputs: json!({ "text": { "type": "STRING" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "boosapi.comfyui_workflow",
|
||||
title: "生成 ComfyUI 工作流",
|
||||
category: "ComfyUI",
|
||||
description: "用 BoosAPI 模型把文本需求改写为 ComfyUI workflow JSON。",
|
||||
inputs: json!({ "text": { "type": "STRING", "required": true } }),
|
||||
params: json!({
|
||||
"model": { "ui": "text", "type": "STRING", "default": "gpt-5.4" },
|
||||
"system": { "ui": "textarea", "type": "STRING", "default": "You generate valid ComfyUI workflow JSON. Return JSON only." },
|
||||
"temperature": { "ui": "number", "type": "NUMBER", "default": 0.3, "min": 0, "max": 2 }
|
||||
}),
|
||||
outputs: json!({ "workflow": { "type": "JSON" }, "text": { "type": "STRING" }, "raw": { "type": "JSON" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "comfyui.submit_workflow",
|
||||
title: "提交到外部 ComfyUI",
|
||||
category: "ComfyUI",
|
||||
description: "把 workflow JSON 提交到已配置的外部 ComfyUI /prompt。",
|
||||
inputs: json!({ "workflow": { "type": "JSON", "required": true } }),
|
||||
params: json!({
|
||||
"client_id": { "ui": "text", "type": "STRING", "default": "boosapi-workflow-studio" },
|
||||
"dry_run": { "ui": "boolean", "type": "BOOLEAN", "default": true }
|
||||
}),
|
||||
outputs: json!({ "prompt_id": { "type": "STRING" }, "response": { "type": "JSON" }, "workflow": { "type": "JSON" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "boosapi.chat_completion",
|
||||
title: "BoosAPI Chat Completion",
|
||||
category: "BoosAPI/Text",
|
||||
description: "调用 BoosAPI OpenAI 兼容聊天模型。",
|
||||
inputs: json!({ "text": { "type": "STRING", "required": true } }),
|
||||
params: json!({
|
||||
"model": { "ui": "text", "type": "STRING", "default": "gpt-5.4" },
|
||||
"system": { "ui": "textarea", "type": "STRING", "default": "" },
|
||||
"temperature": { "ui": "number", "type": "NUMBER", "default": 0.7, "min": 0, "max": 2 }
|
||||
}),
|
||||
outputs: json!({ "text": { "type": "STRING" }, "raw": { "type": "JSON" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "boosapi.text_to_image",
|
||||
title: "文生图",
|
||||
category: "BoosAPI/Image",
|
||||
description: "通过 /v1/images/generations 生成图片,并归档到工作空间。",
|
||||
inputs: json!({ "prompt": { "type": "STRING", "required": true } }),
|
||||
params: json!({
|
||||
"model": { "ui": "text", "type": "STRING", "default": "gpt-image-2" },
|
||||
"width": { "ui": "number", "type": "NUMBER", "default": 1024, "min": 64, "max": 4096 },
|
||||
"height": { "ui": "number", "type": "NUMBER", "default": 1024, "min": 64, "max": 4096 },
|
||||
"size": { "ui": "text", "type": "STRING", "default": "" }
|
||||
}),
|
||||
outputs: json!({
|
||||
"image": { "type": "IMAGE" },
|
||||
"images": { "type": "IMAGE", "is_list": true },
|
||||
"raw": { "type": "JSON" }
|
||||
}),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "boosapi.text_to_video",
|
||||
title: "文生视频",
|
||||
category: "BoosAPI/Video",
|
||||
description: "通过 /v1/videos 创建文生视频任务。",
|
||||
inputs: json!({ "prompt": { "type": "STRING", "required": true } }),
|
||||
params: json!({
|
||||
"model": { "ui": "text", "type": "STRING", "default": "seedance-1-0-lite-t2v" },
|
||||
"ratio": { "ui": "text", "type": "STRING", "default": "16:9" },
|
||||
"duration": { "ui": "number", "type": "NUMBER", "default": 5, "min": 1, "max": 30 },
|
||||
"resolution": { "ui": "text", "type": "STRING", "default": "720p" },
|
||||
"fps": { "ui": "number", "type": "NUMBER", "default": 24, "min": 1, "max": 60 }
|
||||
}),
|
||||
outputs: json!({
|
||||
"task_id": { "type": "STRING" },
|
||||
"video": { "type": "VIDEO" },
|
||||
"raw": { "type": "JSON" }
|
||||
}),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "boosapi.image_to_video",
|
||||
title: "图生视频",
|
||||
category: "BoosAPI/Video",
|
||||
description: "通过 /v1/videos 创建图生视频任务。",
|
||||
inputs: json!({
|
||||
"prompt": { "type": "STRING", "required": true },
|
||||
"image": { "type": "IMAGE", "required": false }
|
||||
}),
|
||||
params: json!({
|
||||
"model": { "ui": "text", "type": "STRING", "default": "seedance-1-0-lite-i2v" },
|
||||
"image_url": { "ui": "text", "type": "STRING", "default": "" },
|
||||
"ratio": { "ui": "text", "type": "STRING", "default": "16:9" },
|
||||
"duration": { "ui": "number", "type": "NUMBER", "default": 5, "min": 1, "max": 30 },
|
||||
"resolution": { "ui": "text", "type": "STRING", "default": "720p" },
|
||||
"fps": { "ui": "number", "type": "NUMBER", "default": 24, "min": 1, "max": 60 }
|
||||
}),
|
||||
outputs: json!({
|
||||
"task_id": { "type": "STRING" },
|
||||
"video": { "type": "VIDEO" },
|
||||
"raw": { "type": "JSON" }
|
||||
}),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "boosapi.video_status",
|
||||
title: "查询视频任务",
|
||||
category: "BoosAPI/Video",
|
||||
description: "查询 /v1/videos/{id} 视频任务结果。",
|
||||
inputs: json!({ "task_id": { "type": "STRING", "required": true } }),
|
||||
params: json!({
|
||||
"model": { "ui": "text", "type": "STRING", "default": "seedance-1-0-lite-t2v" }
|
||||
}),
|
||||
outputs: json!({
|
||||
"video": { "type": "VIDEO" },
|
||||
"raw": { "type": "JSON" }
|
||||
}),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "output.preview_image",
|
||||
title: "图片预览",
|
||||
category: "Output",
|
||||
description: "收集图片输出,供运行结果面板展示。",
|
||||
inputs: json!({ "image": { "type": "IMAGE", "required": true } }),
|
||||
params: json!({}),
|
||||
outputs: json!({ "image": { "type": "IMAGE" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "output.preview_video",
|
||||
title: "视频预览",
|
||||
category: "Output",
|
||||
description: "收集视频输出,供运行结果面板展示。",
|
||||
inputs: json!({ "video": { "type": "VIDEO", "required": true } }),
|
||||
params: json!({}),
|
||||
outputs: json!({ "video": { "type": "VIDEO" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "output.save_file",
|
||||
title: "保存文件",
|
||||
category: "Output",
|
||||
description: "标记上游文件为最终输出。",
|
||||
inputs: json!({ "file": { "type": "FILE", "required": true } }),
|
||||
params: json!({ "name": { "ui": "text", "type": "STRING", "default": "" } }),
|
||||
outputs: json!({ "file": { "type": "FILE" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "util.text_join",
|
||||
title: "文本拼接",
|
||||
category: "Util",
|
||||
description: "拼接输入文本和静态文本。",
|
||||
inputs: json!({ "text": { "type": "STRING", "required": false } }),
|
||||
params: json!({
|
||||
"prefix": { "ui": "textarea", "type": "STRING", "default": "" },
|
||||
"suffix": { "ui": "textarea", "type": "STRING", "default": "" },
|
||||
"separator": { "ui": "text", "type": "STRING", "default": "\\n" }
|
||||
}),
|
||||
outputs: json!({ "text": { "type": "STRING" } }),
|
||||
},
|
||||
NodeTypeDefinition {
|
||||
node_type: "util.json_extract",
|
||||
title: "JSON 提取",
|
||||
category: "Util",
|
||||
description: "从 JSON 对象中按点路径提取字段。",
|
||||
inputs: json!({ "json": { "type": "JSON", "required": true } }),
|
||||
params: json!({ "path": { "ui": "text", "type": "STRING", "default": "data.0.url" } }),
|
||||
outputs: json!({ "value": { "type": "JSON" }, "text": { "type": "STRING" } }),
|
||||
},
|
||||
]);
|
||||
nodes
|
||||
}
|
||||
|
||||
pub fn is_known_node(node_type: &str) -> bool {
|
||||
node_types()
|
||||
.iter()
|
||||
.any(|definition| definition.node_type == node_type)
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
use crate::{
|
||||
auth::jwt::validate_jwt,
|
||||
db::models::downstream_api_key::DownstreamApiKey,
|
||||
error::{AppError, AppResult},
|
||||
workflow::{
|
||||
executor, graph,
|
||||
models::{default_graph, RunContext, WorkflowGraph},
|
||||
registry, storage,
|
||||
},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{header, HeaderMap, StatusCode},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum WorkflowCaller {
|
||||
Admin,
|
||||
User { user_id: i64 },
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WorkflowListQuery {
|
||||
#[serde(default)]
|
||||
include_public: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RunListQuery {
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SaveWorkflowPayload {
|
||||
name: Option<String>,
|
||||
description: Option<String>,
|
||||
graph: Option<Value>,
|
||||
visibility: Option<String>,
|
||||
is_template: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RunWorkflowPayload {
|
||||
inputs: Option<Value>,
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/workflow/node-types", get(node_types))
|
||||
.route("/api/workflows", get(list_workflows).post(create_workflow))
|
||||
.route(
|
||||
"/api/workflows/{id}",
|
||||
get(get_workflow)
|
||||
.put(update_workflow)
|
||||
.delete(delete_workflow),
|
||||
)
|
||||
.route("/api/workflows/{id}/run", post(run_workflow))
|
||||
.route("/api/workflow-runs", get(list_runs))
|
||||
.route("/api/workflow-runs/{id}", get(get_run))
|
||||
.route("/api/workflow-runs/{id}/outputs", get(get_run_outputs))
|
||||
.route("/api/user/workflow/node-types", get(node_types))
|
||||
.route(
|
||||
"/api/user/workflows",
|
||||
get(list_workflows).post(create_workflow),
|
||||
)
|
||||
.route(
|
||||
"/api/user/workflows/{id}",
|
||||
get(get_workflow)
|
||||
.put(update_workflow)
|
||||
.delete(delete_workflow),
|
||||
)
|
||||
.route("/api/user/workflows/{id}/run", post(run_workflow))
|
||||
.route("/api/user/workflow-runs", get(list_runs))
|
||||
.route("/api/user/workflow-runs/{id}", get(get_run))
|
||||
.route("/api/user/workflow-runs/{id}/outputs", get(get_run_outputs))
|
||||
}
|
||||
|
||||
fn bearer(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 "))
|
||||
})
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn resolve_caller(headers: &HeaderMap, state: &AppState) -> AppResult<WorkflowCaller> {
|
||||
let token = bearer(headers)
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
|
||||
return Ok(WorkflowCaller::Admin);
|
||||
}
|
||||
let claims = validate_jwt(&state.config.auth.jwt_secret, token)
|
||||
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
|
||||
if claims.role == "admin" {
|
||||
return Ok(WorkflowCaller::Admin);
|
||||
}
|
||||
if claims.role == "user" {
|
||||
let user_id = claims
|
||||
.sub
|
||||
.parse::<i64>()
|
||||
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))?;
|
||||
return Ok(WorkflowCaller::User { user_id });
|
||||
}
|
||||
Err(AppError::Forbidden("Workflow access denied".into()))
|
||||
}
|
||||
|
||||
fn caller_user_id(caller: &WorkflowCaller) -> Option<i64> {
|
||||
match caller {
|
||||
WorkflowCaller::Admin => None,
|
||||
WorkflowCaller::User { user_id } => Some(*user_id),
|
||||
}
|
||||
}
|
||||
|
||||
fn caller_is_admin(caller: &WorkflowCaller) -> bool {
|
||||
matches!(caller, WorkflowCaller::Admin)
|
||||
}
|
||||
|
||||
async fn user_downstream_key(state: &AppState, user_id: i64) -> AppResult<DownstreamApiKey> {
|
||||
sqlx::query_as::<_, DownstreamApiKey>(
|
||||
"SELECT *
|
||||
FROM downstream_api_keys
|
||||
WHERE user_id = ?
|
||||
AND COALESCE(enabled, 1) = 1
|
||||
AND (
|
||||
expires_at IS NULL
|
||||
OR expires_at = ''
|
||||
OR datetime(expires_at) > datetime('now')
|
||||
)
|
||||
ORDER BY updated_at DESC, created_at DESC, id DESC
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::Forbidden("当前用户没有可用的 API Key,请先在用户中心创建或启用 API Key".into())
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_context(state: &AppState, caller: &WorkflowCaller) -> AppResult<RunContext> {
|
||||
let bearer = match caller {
|
||||
WorkflowCaller::Admin => state.builtin_keys.comfyui_agent.1.clone(),
|
||||
WorkflowCaller::User { user_id } => user_downstream_key(state, *user_id).await?.key,
|
||||
};
|
||||
let host = if state.config.server.host == "0.0.0.0" || state.config.server.host == "::" {
|
||||
"127.0.0.1".to_string()
|
||||
} else {
|
||||
state.config.server.host.clone()
|
||||
};
|
||||
Ok(RunContext {
|
||||
user_id: caller_user_id(caller),
|
||||
bearer,
|
||||
base_url: format!("http://{}:{}/v1", host, state.config.server.port),
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_graph(value: Option<Value>, name: &str) -> AppResult<Value> {
|
||||
let graph = value.unwrap_or_else(|| default_graph(name));
|
||||
let parsed: WorkflowGraph = serde_json::from_value(graph.clone())
|
||||
.map_err(|err| AppError::BadRequest(format!("invalid workflow graph: {err}")))?;
|
||||
graph::validate_and_sort(&parsed)?;
|
||||
Ok(graph)
|
||||
}
|
||||
|
||||
fn normalize_visibility(raw: Option<String>, caller: &WorkflowCaller) -> String {
|
||||
let value = raw.unwrap_or_else(|| "private".into());
|
||||
match value.as_str() {
|
||||
"public" | "template" if caller_is_admin(caller) => value,
|
||||
_ => "private".into(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn node_types() -> Json<Value> {
|
||||
Json(json!({ "success": true, "nodeTypes": registry::node_types() }))
|
||||
}
|
||||
|
||||
async fn list_workflows(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<WorkflowListQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let caller = resolve_caller(&headers, &state)?;
|
||||
let items = storage::list_workflows(
|
||||
&state.db,
|
||||
caller_user_id(&caller),
|
||||
query.include_public || caller_is_admin(&caller),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "success": true, "items": items })))
|
||||
}
|
||||
|
||||
async fn create_workflow(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<SaveWorkflowPayload>,
|
||||
) -> AppResult<(StatusCode, Json<Value>)> {
|
||||
let caller = resolve_caller(&headers, &state)?;
|
||||
let name = payload
|
||||
.name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("未命名工作流");
|
||||
let graph = normalize_graph(payload.graph, name)?;
|
||||
let visibility = normalize_visibility(payload.visibility, &caller);
|
||||
let workflow = storage::insert_workflow(
|
||||
&state,
|
||||
caller_user_id(&caller),
|
||||
name,
|
||||
payload.description.as_deref(),
|
||||
&graph,
|
||||
&visibility,
|
||||
payload.is_template.unwrap_or(false) && caller_is_admin(&caller),
|
||||
)
|
||||
.await?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(json!({ "success": true, "workflow": workflow })),
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_workflow(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let caller = resolve_caller(&headers, &state)?;
|
||||
let workflow = storage::get_workflow(
|
||||
&state.db,
|
||||
&id,
|
||||
caller_user_id(&caller),
|
||||
caller_is_admin(&caller),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "success": true, "workflow": workflow })))
|
||||
}
|
||||
|
||||
async fn update_workflow(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
Json(payload): Json<SaveWorkflowPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let caller = resolve_caller(&headers, &state)?;
|
||||
let current = storage::get_workflow(
|
||||
&state.db,
|
||||
&id,
|
||||
caller_user_id(&caller),
|
||||
caller_is_admin(&caller),
|
||||
)
|
||||
.await?;
|
||||
let name = payload
|
||||
.name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(¤t.name);
|
||||
let graph = normalize_graph(payload.graph.or(Some(current.graph)), name)?;
|
||||
let visibility = normalize_visibility(payload.visibility.or(Some(current.visibility)), &caller);
|
||||
let workflow = storage::update_workflow(
|
||||
&state,
|
||||
&id,
|
||||
caller_user_id(&caller),
|
||||
caller_is_admin(&caller),
|
||||
name,
|
||||
payload
|
||||
.description
|
||||
.as_deref()
|
||||
.or(current.description.as_deref()),
|
||||
&graph,
|
||||
&visibility,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "success": true, "workflow": workflow })))
|
||||
}
|
||||
|
||||
async fn delete_workflow(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<StatusCode> {
|
||||
let caller = resolve_caller(&headers, &state)?;
|
||||
storage::delete_workflow(
|
||||
&state,
|
||||
&id,
|
||||
caller_user_id(&caller),
|
||||
caller_is_admin(&caller),
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn run_workflow(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
Json(payload): Json<RunWorkflowPayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let caller = resolve_caller(&headers, &state)?;
|
||||
let workflow = storage::get_workflow(
|
||||
&state.db,
|
||||
&id,
|
||||
caller_user_id(&caller),
|
||||
caller_is_admin(&caller),
|
||||
)
|
||||
.await?;
|
||||
let ctx = run_context(&state, &caller).await?;
|
||||
let run_id = executor::run_workflow(
|
||||
&state,
|
||||
workflow.id,
|
||||
workflow.graph,
|
||||
payload.inputs.unwrap_or_else(|| json!({})),
|
||||
ctx,
|
||||
)
|
||||
.await?;
|
||||
let run = storage::get_run(
|
||||
&state.db,
|
||||
&run_id,
|
||||
caller_user_id(&caller),
|
||||
caller_is_admin(&caller),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(
|
||||
json!({ "success": true, "runId": run_id, "run": run }),
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_runs(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<RunListQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let caller = resolve_caller(&headers, &state)?;
|
||||
let items = storage::list_runs(
|
||||
&state.db,
|
||||
caller_user_id(&caller),
|
||||
caller_is_admin(&caller),
|
||||
query.limit.unwrap_or(30),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "success": true, "items": items })))
|
||||
}
|
||||
|
||||
async fn get_run(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let caller = resolve_caller(&headers, &state)?;
|
||||
let run = storage::get_run(
|
||||
&state.db,
|
||||
&id,
|
||||
caller_user_id(&caller),
|
||||
caller_is_admin(&caller),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "success": true, "run": run })))
|
||||
}
|
||||
|
||||
async fn get_run_outputs(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let caller = resolve_caller(&headers, &state)?;
|
||||
let run = storage::get_run(
|
||||
&state.db,
|
||||
&id,
|
||||
caller_user_id(&caller),
|
||||
caller_is_admin(&caller),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"runId": run.public_id,
|
||||
"outputs": run.final_outputs.unwrap_or_else(|| json!({}))
|
||||
})))
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
use crate::{
|
||||
error::{AppError, AppResult},
|
||||
workflow::models::{WorkflowNodeRunSummary, WorkflowRunSummary, WorkflowSummary},
|
||||
AppState,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn new_workflow_id() -> String {
|
||||
format!("wf_{}", Uuid::new_v4().simple())
|
||||
}
|
||||
|
||||
pub fn new_run_id() -> String {
|
||||
format!("run_{}", Uuid::new_v4().simple())
|
||||
}
|
||||
|
||||
pub fn parse_json_text(raw: Option<String>, fallback: Value) -> Value {
|
||||
raw.and_then(|value| serde_json::from_str::<Value>(&value).ok())
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
fn row_to_workflow(row: sqlx::sqlite::SqliteRow) -> WorkflowSummary {
|
||||
let graph_raw: String = row.try_get("graph").unwrap_or_else(|_| "{}".to_string());
|
||||
WorkflowSummary {
|
||||
id: row.try_get("id").unwrap_or_default(),
|
||||
public_id: row.try_get("public_id").unwrap_or_default(),
|
||||
user_id: row.try_get::<Option<i64>, _>("user_id").ok().flatten(),
|
||||
name: row.try_get("name").unwrap_or_default(),
|
||||
description: row.try_get("description").ok(),
|
||||
graph: serde_json::from_str(&graph_raw).unwrap_or_else(|_| json!({})),
|
||||
is_template: row
|
||||
.try_get::<i64, _>("is_template")
|
||||
.map(|value| value != 0)
|
||||
.unwrap_or(false),
|
||||
visibility: row
|
||||
.try_get("visibility")
|
||||
.unwrap_or_else(|_| "private".into()),
|
||||
created_at: row.try_get("created_at").unwrap_or_default(),
|
||||
updated_at: row.try_get("updated_at").unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_node_run(row: sqlx::sqlite::SqliteRow) -> WorkflowNodeRunSummary {
|
||||
WorkflowNodeRunSummary {
|
||||
id: row.try_get("id").unwrap_or_default(),
|
||||
node_id: row.try_get("node_id").unwrap_or_default(),
|
||||
node_type: row.try_get("node_type").unwrap_or_default(),
|
||||
status: row.try_get("status").unwrap_or_default(),
|
||||
inputs: parse_json_text(row.try_get("inputs").ok(), json!({})),
|
||||
outputs: row
|
||||
.try_get::<Option<String>, _>("outputs")
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|raw| serde_json::from_str(&raw).ok()),
|
||||
error_message: row.try_get("error_message").ok(),
|
||||
started_at: row.try_get("started_at").ok(),
|
||||
completed_at: row.try_get("completed_at").ok(),
|
||||
created_at: row.try_get("created_at").unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_run(
|
||||
row: sqlx::sqlite::SqliteRow,
|
||||
node_runs: Vec<WorkflowNodeRunSummary>,
|
||||
) -> WorkflowRunSummary {
|
||||
WorkflowRunSummary {
|
||||
id: row.try_get("id").unwrap_or_default(),
|
||||
public_id: row.try_get("public_id").unwrap_or_default(),
|
||||
workflow_id: row.try_get("workflow_id").unwrap_or_default(),
|
||||
workflow_public_id: row.try_get("workflow_public_id").ok(),
|
||||
user_id: row.try_get::<Option<i64>, _>("user_id").ok().flatten(),
|
||||
status: row.try_get("status").unwrap_or_default(),
|
||||
input_overrides: parse_json_text(row.try_get("input_overrides").ok(), json!({})),
|
||||
final_outputs: row
|
||||
.try_get::<Option<String>, _>("final_outputs")
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|raw| serde_json::from_str(&raw).ok()),
|
||||
error_message: row.try_get("error_message").ok(),
|
||||
started_at: row.try_get("started_at").ok(),
|
||||
completed_at: row.try_get("completed_at").ok(),
|
||||
created_at: row.try_get("created_at").unwrap_or_default(),
|
||||
node_runs,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_workflows(
|
||||
pool: &SqlitePool,
|
||||
user_id: Option<i64>,
|
||||
include_public: bool,
|
||||
) -> AppResult<Vec<WorkflowSummary>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT *
|
||||
FROM workflows
|
||||
WHERE deleted_at IS NULL
|
||||
AND (
|
||||
? IS NULL
|
||||
OR user_id = ?
|
||||
OR (? = 1 AND visibility IN ('public', 'template'))
|
||||
)
|
||||
ORDER BY datetime(updated_at) DESC, id DESC",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(user_id)
|
||||
.bind(if include_public { 1_i64 } else { 0_i64 })
|
||||
.map(row_to_workflow)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn get_workflow(
|
||||
pool: &SqlitePool,
|
||||
public_id: &str,
|
||||
user_id: Option<i64>,
|
||||
allow_admin: bool,
|
||||
) -> AppResult<WorkflowSummary> {
|
||||
let row = sqlx::query(
|
||||
"SELECT *
|
||||
FROM workflows
|
||||
WHERE public_id = ?
|
||||
AND deleted_at IS NULL
|
||||
AND (? = 1 OR user_id = ? OR visibility IN ('public', 'template'))
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(public_id)
|
||||
.bind(if allow_admin { 1_i64 } else { 0_i64 })
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Workflow not found".into()))?;
|
||||
Ok(row_to_workflow(row))
|
||||
}
|
||||
|
||||
pub async fn get_workflow_by_db_id(pool: &SqlitePool, id: i64) -> AppResult<WorkflowSummary> {
|
||||
let row = sqlx::query("SELECT * FROM workflows WHERE id = ? AND deleted_at IS NULL LIMIT 1")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Workflow not found".into()))?;
|
||||
Ok(row_to_workflow(row))
|
||||
}
|
||||
|
||||
pub async fn get_run(
|
||||
pool: &SqlitePool,
|
||||
public_id: &str,
|
||||
user_id: Option<i64>,
|
||||
allow_admin: bool,
|
||||
) -> AppResult<WorkflowRunSummary> {
|
||||
let row = sqlx::query(
|
||||
"SELECT workflow_runs.*, workflows.public_id AS workflow_public_id
|
||||
FROM workflow_runs
|
||||
JOIN workflows ON workflows.id = workflow_runs.workflow_id
|
||||
WHERE workflow_runs.public_id = ?
|
||||
AND (? = 1 OR workflow_runs.user_id = ?)
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(public_id)
|
||||
.bind(if allow_admin { 1_i64 } else { 0_i64 })
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Workflow run not found".into()))?;
|
||||
let run_id: i64 = row.try_get("id").unwrap_or_default();
|
||||
let node_runs = sqlx::query(
|
||||
"SELECT *
|
||||
FROM workflow_node_runs
|
||||
WHERE run_id = ?
|
||||
ORDER BY id ASC",
|
||||
)
|
||||
.bind(run_id)
|
||||
.map(row_to_node_run)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(row_to_run(row, node_runs))
|
||||
}
|
||||
|
||||
pub async fn list_runs(
|
||||
pool: &SqlitePool,
|
||||
user_id: Option<i64>,
|
||||
allow_admin: bool,
|
||||
limit: i64,
|
||||
) -> AppResult<Vec<WorkflowRunSummary>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT workflow_runs.*, workflows.public_id AS workflow_public_id
|
||||
FROM workflow_runs
|
||||
JOIN workflows ON workflows.id = workflow_runs.workflow_id
|
||||
WHERE (? = 1 OR workflow_runs.user_id = ?)
|
||||
ORDER BY datetime(workflow_runs.created_at) DESC, workflow_runs.id DESC
|
||||
LIMIT ?",
|
||||
)
|
||||
.bind(if allow_admin { 1_i64 } else { 0_i64 })
|
||||
.bind(user_id)
|
||||
.bind(limit.clamp(1, 100))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut runs = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let run_id: i64 = row.try_get("id").unwrap_or_default();
|
||||
let node_runs = sqlx::query(
|
||||
"SELECT *
|
||||
FROM workflow_node_runs
|
||||
WHERE run_id = ?
|
||||
ORDER BY id ASC",
|
||||
)
|
||||
.bind(run_id)
|
||||
.map(row_to_node_run)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
runs.push(row_to_run(row, node_runs));
|
||||
}
|
||||
Ok(runs)
|
||||
}
|
||||
|
||||
pub async fn insert_workflow(
|
||||
state: &AppState,
|
||||
user_id: Option<i64>,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
graph: &Value,
|
||||
visibility: &str,
|
||||
is_template: bool,
|
||||
) -> AppResult<WorkflowSummary> {
|
||||
let public_id = new_workflow_id();
|
||||
sqlx::query(
|
||||
"INSERT INTO workflows
|
||||
(public_id, user_id, name, description, graph, visibility, is_template)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&public_id)
|
||||
.bind(user_id)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(graph.to_string())
|
||||
.bind(visibility)
|
||||
.bind(if is_template { 1_i64 } else { 0_i64 })
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
get_workflow(&state.db, &public_id, user_id, true).await
|
||||
}
|
||||
|
||||
pub async fn update_workflow(
|
||||
state: &AppState,
|
||||
public_id: &str,
|
||||
user_id: Option<i64>,
|
||||
allow_admin: bool,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
graph: &Value,
|
||||
visibility: &str,
|
||||
) -> AppResult<WorkflowSummary> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE workflows
|
||||
SET name = ?, description = ?, graph = ?, visibility = ?, updated_at = datetime('now')
|
||||
WHERE public_id = ?
|
||||
AND deleted_at IS NULL
|
||||
AND (? = 1 OR user_id = ?)",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(graph.to_string())
|
||||
.bind(visibility)
|
||||
.bind(public_id)
|
||||
.bind(if allow_admin { 1_i64 } else { 0_i64 })
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("Workflow not found".into()));
|
||||
}
|
||||
get_workflow(&state.db, public_id, user_id, allow_admin).await
|
||||
}
|
||||
|
||||
pub async fn delete_workflow(
|
||||
state: &AppState,
|
||||
public_id: &str,
|
||||
user_id: Option<i64>,
|
||||
allow_admin: bool,
|
||||
) -> AppResult<()> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE workflows
|
||||
SET deleted_at = datetime('now'), updated_at = datetime('now')
|
||||
WHERE public_id = ?
|
||||
AND deleted_at IS NULL
|
||||
AND (? = 1 OR user_id = ?)",
|
||||
)
|
||||
.bind(public_id)
|
||||
.bind(if allow_admin { 1_i64 } else { 0_i64 })
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("Workflow not found".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn insert_run(
|
||||
state: &AppState,
|
||||
workflow_id: i64,
|
||||
user_id: Option<i64>,
|
||||
input_overrides: &Value,
|
||||
) -> AppResult<(i64, String)> {
|
||||
let public_id = new_run_id();
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO workflow_runs
|
||||
(public_id, workflow_id, user_id, status, input_overrides, started_at)
|
||||
VALUES (?, ?, ?, 'running', ?, datetime('now'))",
|
||||
)
|
||||
.bind(&public_id)
|
||||
.bind(workflow_id)
|
||||
.bind(user_id)
|
||||
.bind(input_overrides.to_string())
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
let id = result.last_insert_rowid();
|
||||
Ok((id, public_id))
|
||||
}
|
||||
|
||||
pub async fn insert_node_run_started(
|
||||
state: &AppState,
|
||||
run_id: i64,
|
||||
node_id: &str,
|
||||
node_type: &str,
|
||||
inputs: &Value,
|
||||
) -> AppResult<i64> {
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO workflow_node_runs
|
||||
(run_id, node_id, node_type, status, inputs, started_at)
|
||||
VALUES (?, ?, ?, 'running', ?, datetime('now'))",
|
||||
)
|
||||
.bind(run_id)
|
||||
.bind(node_id)
|
||||
.bind(node_type)
|
||||
.bind(inputs.to_string())
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
let id = result.last_insert_rowid();
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn complete_node_run(
|
||||
state: &AppState,
|
||||
node_run_id: i64,
|
||||
outputs: &Value,
|
||||
) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"UPDATE workflow_node_runs
|
||||
SET status = 'completed', outputs = ?, completed_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(outputs.to_string())
|
||||
.bind(node_run_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn fail_node_run(state: &AppState, node_run_id: i64, error: &str) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"UPDATE workflow_node_runs
|
||||
SET status = 'failed', error_message = ?, completed_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(error)
|
||||
.bind(node_run_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn complete_run(state: &AppState, run_id: i64, outputs: &Value) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"UPDATE workflow_runs
|
||||
SET status = 'completed', final_outputs = ?, completed_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(outputs.to_string())
|
||||
.bind(run_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn fail_run(state: &AppState, run_id: i64, error: &str) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"UPDATE workflow_runs
|
||||
SET status = 'failed', error_message = ?, completed_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(error)
|
||||
.bind(run_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_runs(
|
||||
state: &AppState,
|
||||
public_ids: &[String],
|
||||
user_id: Option<i64>,
|
||||
allow_admin: bool,
|
||||
) -> AppResult<usize> {
|
||||
let mut deleted = 0_usize;
|
||||
for public_id in public_ids {
|
||||
sqlx::query(
|
||||
"DELETE FROM workflow_node_runs
|
||||
WHERE run_id IN (
|
||||
SELECT id
|
||||
FROM workflow_runs
|
||||
WHERE public_id = ?
|
||||
AND (? = 1 OR user_id = ?)
|
||||
)",
|
||||
)
|
||||
.bind(public_id)
|
||||
.bind(if allow_admin { 1_i64 } else { 0_i64 })
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM workflow_runs
|
||||
WHERE public_id = ?
|
||||
AND (? = 1 OR user_id = ?)",
|
||||
)
|
||||
.bind(public_id)
|
||||
.bind(if allow_admin { 1_i64 } else { 0_i64 })
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
deleted += result.rows_affected() as usize;
|
||||
}
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
pub async fn clear_runs(
|
||||
state: &AppState,
|
||||
user_id: Option<i64>,
|
||||
allow_admin: bool,
|
||||
) -> AppResult<u64> {
|
||||
sqlx::query(
|
||||
"DELETE FROM workflow_node_runs
|
||||
WHERE run_id IN (
|
||||
SELECT id
|
||||
FROM workflow_runs
|
||||
WHERE (? = 1 OR user_id = ?)
|
||||
)",
|
||||
)
|
||||
.bind(if allow_admin { 1_i64 } else { 0_i64 })
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM workflow_runs
|
||||
WHERE (? = 1 OR user_id = ?)",
|
||||
)
|
||||
.bind(if allow_admin { 1_i64 } else { 0_i64 })
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
Reference in New Issue
Block a user