feat: add workflow studio and postgres support

This commit is contained in:
boos
2026-05-25 16:03:07 +08:00
parent 7cdb02ef1d
commit cb35d5fc6e
96 changed files with 12414 additions and 15548 deletions
+925
View File
@@ -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(&current)) }),
})
}
_ => 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(),
}
}
+94
View File
@@ -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}")))
}
+6
View File
@@ -0,0 +1,6 @@
pub mod executor;
pub mod graph;
pub mod models;
pub mod registry;
pub mod routes;
pub mod storage;
+155
View File
@@ -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()
}
+291
View File
@@ -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)
}
+389
View File
@@ -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(&current.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!({}))
})))
}
+463
View File
@@ -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())
}