refactor(workflow_ui): 抽出 assets 子模块
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user