refactor(workflow_ui): 抽出 assets 子模块

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
boos
2026-06-18 14:34:46 +08:00
parent 334ed04a6a
commit bbf0a3b81a
2 changed files with 316 additions and 291 deletions
+311
View File
@@ -0,0 +1,311 @@
use super::{
cache_control_for_path, content_type_for_path, default_boosapi_workflow_graph,
embedded_workflow_ui_response, inject_bootstrap, local_workflow_template_asset,
native_workflow_base, simple_urlencode, text_to_video_workflow_graph,
workflow_template_not_found_response, EMBEDDED_WORKFLOW_UI, EMBEDDED_WORKFLOW_UI_CLOUD,
EMBEDDED_WORKFLOW_UI_EXTERNAL, OFFICIAL_WORKFLOW_TEMPLATES_BASE,
};
use crate::error::{AppError, AppResult};
use crate::AppState;
use axum::{
extract::{Path, State},
http::{header, StatusCode},
response::{Html, IntoResponse, Response},
Json,
};
use include_dir::Dir;
use serde_json::{json, Value};
use std::path::{Path as StdPath, PathBuf};
pub(super) fn workflow_ui_root_for_config(config: &crate::config::ComfyUIConfig) -> PathBuf {
if let Ok(path) = std::env::var("BOOSAPI_WORKFLOW_UI_DIR") {
return resolve_workflow_ui_dir(PathBuf::from(path));
}
let preferred = resolve_workflow_ui_dir(if config.uses_cloud_backend() {
std::env::var("BOOSAPI_WORKFLOW_UI_CLOUD_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("dist/workflow-ui-cloud"))
} else if config.uses_native_backend() {
std::env::var("BOOSAPI_WORKFLOW_UI_EXTERNAL_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("dist/workflow-ui-external"))
} else {
PathBuf::from("dist/workflow-ui")
});
if workflow_ui_dir_is_usable(&preferred) {
preferred
} else {
resolve_workflow_ui_dir(PathBuf::from("dist/workflow-ui"))
}
}
pub(super) fn resolve_workflow_ui_dir(path: PathBuf) -> PathBuf {
if path.is_absolute() || workflow_ui_dir_is_usable(&path) {
return path;
}
let manifest_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(&path);
if workflow_ui_dir_is_usable(&manifest_path) {
manifest_path
} else {
path
}
}
pub(super) fn embedded_workflow_ui_for_config(
config: &crate::config::ComfyUIConfig,
) -> &'static Dir<'static> {
if config.uses_cloud_backend() && embedded_workflow_ui_is_usable(&EMBEDDED_WORKFLOW_UI_CLOUD) {
&EMBEDDED_WORKFLOW_UI_CLOUD
} else if config.uses_native_backend()
&& embedded_workflow_ui_is_usable(&EMBEDDED_WORKFLOW_UI_EXTERNAL)
{
&EMBEDDED_WORKFLOW_UI_EXTERNAL
} else {
&EMBEDDED_WORKFLOW_UI
}
}
pub(super) fn workflow_ui_dir_is_usable(dir: &PathBuf) -> bool {
let index = dir.join("index.html");
std::fs::read_to_string(index)
.map(|html| !workflow_ui_index_is_placeholder(&html))
.unwrap_or(false)
}
pub(super) fn embedded_workflow_ui_is_usable(dir: &Dir<'static>) -> bool {
dir.get_file("index.html")
.and_then(|file| std::str::from_utf8(file.contents()).ok())
.map(|html| !workflow_ui_index_is_placeholder(html))
.unwrap_or(false)
}
pub(super) fn workflow_ui_index_is_placeholder(html: &str) -> bool {
html.contains("Workflow UI not built")
}
pub(super) async fn workflow_ui_runtime(State(state): State<AppState>) -> Json<Value> {
let mode = if state.config.comfyui.uses_docker_backend() {
"docker"
} else if state.config.comfyui.uses_cloud_backend() {
"cloud"
} else if state.config.comfyui.uses_runninghub_backend() {
"runninghub"
} else if state.config.comfyui.uses_native_backend() {
"external"
} else {
"boosapi"
};
let frontend_dir = workflow_ui_root_for_config(&state.config.comfyui);
Json(json!({
"mode": mode,
"baseUrl": state.config.comfyui.target_base_url(),
"frontendDir": frontend_dir,
"frontendBuild": workflow_ui_build_metadata(&frontend_dir),
"cloudApiKeyConfigured": workflow_cloud_api_key(&state.config.comfyui).is_some(),
}))
}
pub(super) fn workflow_ui_build_metadata(dir: &StdPath) -> Value {
let path = dir.join("boosapi-workflow-ui.json");
std::fs::read_to_string(path)
.ok()
.and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
.unwrap_or(Value::Null)
}
pub(super) fn workflow_cloud_api_key(config: &crate::config::ComfyUIConfig) -> Option<&str> {
config
.api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
}
pub(super) async fn workflow_ui_index(State(state): State<AppState>) -> Html<String> {
let dir = workflow_ui_root_for_config(&state.config.comfyui);
let index = dir.join("index.html");
match tokio::fs::read_to_string(&index).await {
Ok(html) => Html(inject_bootstrap(html, &state.config.comfyui)),
Err(_) => {
if let Some(file) =
embedded_workflow_ui_for_config(&state.config.comfyui).get_file("index.html")
{
let html = String::from_utf8_lossy(file.contents()).to_string();
return Html(inject_bootstrap(html, &state.config.comfyui));
}
Html(
r#"<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BoosAPI Workflow Studio</title>
<style>
html, body { margin: 0; width: 100%; height: 100%; background: #111827; color: #e5e7eb; font-family: system-ui, sans-serif; }
.wrap { display: grid; place-items: center; height: 100%; padding: 24px; text-align: center; }
.box { max-width: 720px; line-height: 1.7; }
code { background: rgba(255,255,255,0.08); padding: 2px 6px; border-radius: 4px; }
</style>
</head>
<body>
<div class="wrap">
<div class="box">
<h1>Workflow Studio 前端未构建</h1>
<p>官方 ComfyUI 前端会按后端类型从 <code>dist/workflow-ui-external</code> 或 <code>dist/workflow-ui-cloud</code> 加载。请先运行 <code>npm run workflow-ui:install</code>,然后刷新此页面。</p>
</div>
</div>
</body>
</html>"#
.to_string(),
)
}
}
}
pub(super) async fn workflow_ui_static(
State(state): State<AppState>,
Path(path): Path<String>,
) -> AppResult<Response> {
let dir = workflow_ui_root_for_config(&state.config.comfyui);
let path = path.trim_start_matches('/').to_string();
if path == "api/workflow_templates/boosapi/文生图示例.json" {
return Ok(Json(default_boosapi_workflow_graph()).into_response());
}
if path == "api/workflow_templates/boosapi/文生视频示例.json" {
return Ok(Json(text_to_video_workflow_graph()).into_response());
}
let file_path = if path.is_empty() {
dir.join("index.html")
} else {
dir.join(&path)
};
if file_path.is_dir() {
return serve_file(file_path.join("index.html")).await;
}
if tokio::fs::metadata(&file_path).await.is_ok() {
return serve_file(file_path).await;
}
if let Some(response) =
embedded_workflow_ui_response(&state.config.comfyui, &path, path.is_empty())
{
return Ok(response);
}
if PathBuf::from(&path).extension().is_none() {
return Ok(workflow_ui_index(State(state)).await.into_response());
}
serve_file(file_path).await
}
pub(super) async fn official_workflow_template_asset(
State(state): State<AppState>,
Path(path): Path<String>,
) -> AppResult<Response> {
let path = path.trim_start_matches('/').replace('\\', "/");
if path.is_empty() || path.contains("..") {
return Err(AppError::BadRequest("invalid template path".into()));
}
if !state.config.comfyui.uses_cloud_backend() {
if let Some(response) = local_workflow_template_asset(&path) {
return Ok(response);
}
}
let encoded_path = path
.split('/')
.map(simple_urlencode)
.collect::<Vec<_>>()
.join("/");
let url = if state.config.comfyui.uses_cloud_backend() {
format!(
"{}/templates/{}",
native_workflow_base(&state),
encoded_path
)
} else {
format!("{}/{}", OFFICIAL_WORKFLOW_TEMPLATES_BASE, encoded_path)
};
let mut request =
crate::template_http::client(&state.config, state.config.proxy.upstream_timeout_secs)
.get(url);
if state.config.comfyui.uses_cloud_backend() {
if let Some(api_key) = state
.config
.comfyui
.api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
request = request
.header("X-API-Key", api_key)
.header("Authorization", format!("Bearer {api_key}"));
}
}
let response = match request.send().await {
Ok(response) => response,
Err(err) => {
tracing::warn!(%err, path, "workflow template remote source unavailable");
if let Some(response) = local_workflow_template_asset(&path) {
return Ok(response);
}
return Ok(workflow_template_not_found_response(&path));
}
};
let status = response.status();
let bytes = match response.bytes().await {
Ok(bytes) => bytes,
Err(err) => {
tracing::warn!(%err, path, "workflow template remote body unavailable");
if let Some(response) = local_workflow_template_asset(&path) {
return Ok(response);
}
return Ok(workflow_template_not_found_response(&path));
}
};
Ok((
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY),
[
(
header::CONTENT_TYPE,
content_type_for_path(&PathBuf::from(&path)),
),
(
header::CACHE_CONTROL,
if path == "index.json" {
"public, max-age=1800"
} else {
"public, max-age=86400"
},
),
],
bytes,
)
.into_response())
}
pub(super) async fn serve_file(path: PathBuf) -> AppResult<Response> {
match tokio::fs::read(&path).await {
Ok(bytes) => {
let content_type = content_type_for_path(&path);
let cache_control = cache_control_for_path(&path);
Ok((
StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, content_type),
(axum::http::header::CACHE_CONTROL, cache_control),
],
bytes,
)
.into_response())
}
Err(_) => Ok((
StatusCode::NOT_FOUND,
[(
axum::http::header::CONTENT_TYPE,
"text/plain; charset=utf-8",
)],
"workflow-ui asset not found".to_string(),
)
.into_response()),
}
}
+5 -291
View File
@@ -133,6 +133,11 @@ use models::{
native_folder_models_from_object_info, workflow_folder_has_route_models, native_folder_models_from_object_info, workflow_folder_has_route_models,
workflow_folder_is_api_only, workflow_folder_route_model_items, workflow_route_models, workflow_folder_is_api_only, workflow_folder_route_model_items, workflow_route_models,
}; };
mod assets;
use assets::{
embedded_workflow_ui_for_config, official_workflow_template_asset, workflow_cloud_api_key,
workflow_ui_index, workflow_ui_runtime, workflow_ui_static,
};
pub fn routes() -> Router<AppState> { pub fn routes() -> Router<AppState> {
Router::new() Router::new()
@@ -221,108 +226,6 @@ pub fn routes() -> Router<AppState> {
.layer(DefaultBodyLimit::max(WORKFLOW_UPLOAD_BODY_LIMIT_BYTES)) .layer(DefaultBodyLimit::max(WORKFLOW_UPLOAD_BODY_LIMIT_BYTES))
} }
fn workflow_ui_root_for_config(config: &crate::config::ComfyUIConfig) -> PathBuf {
if let Ok(path) = std::env::var("BOOSAPI_WORKFLOW_UI_DIR") {
return resolve_workflow_ui_dir(PathBuf::from(path));
}
let preferred = resolve_workflow_ui_dir(if config.uses_cloud_backend() {
std::env::var("BOOSAPI_WORKFLOW_UI_CLOUD_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("dist/workflow-ui-cloud"))
} else if config.uses_native_backend() {
std::env::var("BOOSAPI_WORKFLOW_UI_EXTERNAL_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("dist/workflow-ui-external"))
} else {
PathBuf::from("dist/workflow-ui")
});
if workflow_ui_dir_is_usable(&preferred) {
preferred
} else {
resolve_workflow_ui_dir(PathBuf::from("dist/workflow-ui"))
}
}
fn resolve_workflow_ui_dir(path: PathBuf) -> PathBuf {
if path.is_absolute() || workflow_ui_dir_is_usable(&path) {
return path;
}
let manifest_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(&path);
if workflow_ui_dir_is_usable(&manifest_path) {
manifest_path
} else {
path
}
}
fn embedded_workflow_ui_for_config(config: &crate::config::ComfyUIConfig) -> &'static Dir<'static> {
if config.uses_cloud_backend() && embedded_workflow_ui_is_usable(&EMBEDDED_WORKFLOW_UI_CLOUD) {
&EMBEDDED_WORKFLOW_UI_CLOUD
} else if config.uses_native_backend()
&& embedded_workflow_ui_is_usable(&EMBEDDED_WORKFLOW_UI_EXTERNAL)
{
&EMBEDDED_WORKFLOW_UI_EXTERNAL
} else {
&EMBEDDED_WORKFLOW_UI
}
}
fn workflow_ui_dir_is_usable(dir: &PathBuf) -> bool {
let index = dir.join("index.html");
std::fs::read_to_string(index)
.map(|html| !workflow_ui_index_is_placeholder(&html))
.unwrap_or(false)
}
fn embedded_workflow_ui_is_usable(dir: &Dir<'static>) -> bool {
dir.get_file("index.html")
.and_then(|file| std::str::from_utf8(file.contents()).ok())
.map(|html| !workflow_ui_index_is_placeholder(html))
.unwrap_or(false)
}
fn workflow_ui_index_is_placeholder(html: &str) -> bool {
html.contains("Workflow UI not built")
}
async fn workflow_ui_runtime(State(state): State<AppState>) -> Json<Value> {
let mode = if state.config.comfyui.uses_docker_backend() {
"docker"
} else if state.config.comfyui.uses_cloud_backend() {
"cloud"
} else if state.config.comfyui.uses_runninghub_backend() {
"runninghub"
} else if state.config.comfyui.uses_native_backend() {
"external"
} else {
"boosapi"
};
let frontend_dir = workflow_ui_root_for_config(&state.config.comfyui);
Json(json!({
"mode": mode,
"baseUrl": state.config.comfyui.target_base_url(),
"frontendDir": frontend_dir,
"frontendBuild": workflow_ui_build_metadata(&frontend_dir),
"cloudApiKeyConfigured": workflow_cloud_api_key(&state.config.comfyui).is_some(),
}))
}
fn workflow_ui_build_metadata(dir: &StdPath) -> Value {
let path = dir.join("boosapi-workflow-ui.json");
std::fs::read_to_string(path)
.ok()
.and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
.unwrap_or(Value::Null)
}
fn workflow_cloud_api_key(config: &crate::config::ComfyUIConfig) -> Option<&str> {
config
.api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn default_history_limit() -> i64 { fn default_history_limit() -> i64 {
50 50
} }
@@ -687,168 +590,6 @@ fn prompt_contains_boosapi_nodes(prompt: &Value) -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
async fn workflow_ui_index(State(state): State<AppState>) -> Html<String> {
let dir = workflow_ui_root_for_config(&state.config.comfyui);
let index = dir.join("index.html");
match tokio::fs::read_to_string(&index).await {
Ok(html) => Html(inject_bootstrap(html, &state.config.comfyui)),
Err(_) => {
if let Some(file) =
embedded_workflow_ui_for_config(&state.config.comfyui).get_file("index.html")
{
let html = String::from_utf8_lossy(file.contents()).to_string();
return Html(inject_bootstrap(html, &state.config.comfyui));
}
Html(
r#"<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BoosAPI Workflow Studio</title>
<style>
html, body { margin: 0; width: 100%; height: 100%; background: #111827; color: #e5e7eb; font-family: system-ui, sans-serif; }
.wrap { display: grid; place-items: center; height: 100%; padding: 24px; text-align: center; }
.box { max-width: 720px; line-height: 1.7; }
code { background: rgba(255,255,255,0.08); padding: 2px 6px; border-radius: 4px; }
</style>
</head>
<body>
<div class="wrap">
<div class="box">
<h1>Workflow Studio 前端未构建</h1>
<p>官方 ComfyUI 前端会按后端类型从 <code>dist/workflow-ui-external</code> 或 <code>dist/workflow-ui-cloud</code> 加载。请先运行 <code>npm run workflow-ui:install</code>,然后刷新此页面。</p>
</div>
</div>
</body>
</html>"#
.to_string(),
)
}
}
}
async fn workflow_ui_static(
State(state): State<AppState>,
Path(path): Path<String>,
) -> AppResult<Response> {
let dir = workflow_ui_root_for_config(&state.config.comfyui);
let path = path.trim_start_matches('/').to_string();
if path == "api/workflow_templates/boosapi/文生图示例.json" {
return Ok(Json(default_boosapi_workflow_graph()).into_response());
}
if path == "api/workflow_templates/boosapi/文生视频示例.json" {
return Ok(Json(text_to_video_workflow_graph()).into_response());
}
let file_path = if path.is_empty() {
dir.join("index.html")
} else {
dir.join(&path)
};
if file_path.is_dir() {
return serve_file(file_path.join("index.html")).await;
}
if tokio::fs::metadata(&file_path).await.is_ok() {
return serve_file(file_path).await;
}
if let Some(response) =
embedded_workflow_ui_response(&state.config.comfyui, &path, path.is_empty())
{
return Ok(response);
}
if PathBuf::from(&path).extension().is_none() {
return Ok(workflow_ui_index(State(state)).await.into_response());
}
serve_file(file_path).await
}
async fn official_workflow_template_asset(
State(state): State<AppState>,
Path(path): Path<String>,
) -> AppResult<Response> {
let path = path.trim_start_matches('/').replace('\\', "/");
if path.is_empty() || path.contains("..") {
return Err(AppError::BadRequest("invalid template path".into()));
}
if !state.config.comfyui.uses_cloud_backend() {
if let Some(response) = local_workflow_template_asset(&path) {
return Ok(response);
}
}
let encoded_path = path
.split('/')
.map(simple_urlencode)
.collect::<Vec<_>>()
.join("/");
let url = if state.config.comfyui.uses_cloud_backend() {
format!(
"{}/templates/{}",
native_workflow_base(&state),
encoded_path
)
} else {
format!("{}/{}", OFFICIAL_WORKFLOW_TEMPLATES_BASE, encoded_path)
};
let mut request =
crate::template_http::client(&state.config, state.config.proxy.upstream_timeout_secs)
.get(url);
if state.config.comfyui.uses_cloud_backend() {
if let Some(api_key) = state
.config
.comfyui
.api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
request = request
.header("X-API-Key", api_key)
.header("Authorization", format!("Bearer {api_key}"));
}
}
let response = match request.send().await {
Ok(response) => response,
Err(err) => {
tracing::warn!(%err, path, "workflow template remote source unavailable");
if let Some(response) = local_workflow_template_asset(&path) {
return Ok(response);
}
return Ok(workflow_template_not_found_response(&path));
}
};
let status = response.status();
let bytes = match response.bytes().await {
Ok(bytes) => bytes,
Err(err) => {
tracing::warn!(%err, path, "workflow template remote body unavailable");
if let Some(response) = local_workflow_template_asset(&path) {
return Ok(response);
}
return Ok(workflow_template_not_found_response(&path));
}
};
Ok((
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY),
[
(
header::CONTENT_TYPE,
content_type_for_path(&PathBuf::from(&path)),
),
(
header::CACHE_CONTROL,
if path == "index.json" {
"public, max-age=1800"
} else {
"public, max-age=86400"
},
),
],
bytes,
)
.into_response())
}
fn local_workflow_template_asset(path: &str) -> Option<Response> { fn local_workflow_template_asset(path: &str) -> Option<Response> {
match path { match path {
"index.json" | "index.zh.json" | "index.zh-CN.json" | "index_logo.json" => Some( "index.json" | "index.zh.json" | "index.zh-CN.json" | "index_logo.json" => Some(
@@ -968,33 +709,6 @@ fn embedded_workflow_ui_response(
) )
} }
async fn serve_file(path: PathBuf) -> AppResult<Response> {
match tokio::fs::read(&path).await {
Ok(bytes) => {
let content_type = content_type_for_path(&path);
let cache_control = cache_control_for_path(&path);
Ok((
StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, content_type),
(axum::http::header::CACHE_CONTROL, cache_control),
],
bytes,
)
.into_response())
}
Err(_) => Ok((
StatusCode::NOT_FOUND,
[(
axum::http::header::CONTENT_TYPE,
"text/plain; charset=utf-8",
)],
"workflow-ui asset not found".to_string(),
)
.into_response()),
}
}
fn content_type_for_path(path: &StdPath) -> &'static str { fn content_type_for_path(path: &StdPath) -> &'static str {
match path match path
.extension() .extension()