Embed workflow UI and isolate workspace assets

This commit is contained in:
boos
2026-05-26 17:00:10 +08:00
parent 0f9fe4a81d
commit e0bd9b4194
7 changed files with 529 additions and 148 deletions
+114 -8
View File
@@ -20,6 +20,8 @@ BACKEND_ONLY=0
NO_RUN=0 NO_RUN=0
FORCE_FRONTEND=0 FORCE_FRONTEND=0
SKIP_FRONTEND=0 SKIP_FRONTEND=0
PRODUCTION_BUNDLE=0
PRODUCTION_DIR="${BOOSAPI_PRODUCTION_DIR:-$ROOT_DIR/dist/production}"
CARGO_FEATURES="${BOOSAPI_CARGO_FEATURES:-}" CARGO_FEATURES="${BOOSAPI_CARGO_FEATURES:-}"
FRONTEND_PM="${BOOSAPI_FRONTEND_PM:-auto}" FRONTEND_PM="${BOOSAPI_FRONTEND_PM:-auto}"
@@ -32,21 +34,28 @@ usage() {
--release 使用 release 模式编译后端(更慢但性能好) --release 使用 release 模式编译后端(更慢但性能好)
--fast-release 使用 fast-release 模式编译后端(比 release 快,性能接近生产) --fast-release 使用 fast-release 模式编译后端(比 release 快,性能接近生产)
--profile <name> 指定构建 profile:dev / release / fast-release --profile <name> 指定构建 profile:dev / release / fast-release
--production 生产编译:先构建前端并嵌入二进制,再输出 dist/production/{boosapi,config.toml}
--prod 同 --production
--production-dir <dir>
指定生产产物目录(默认:dist/production)
--frontend-only 只构建前端 --frontend-only 只构建前端
--backend-only 只构建后端 --backend-only 只构建后端
--force-frontend 强制重建前端,忽略缓存 --force-frontend 强制重建前端,忽略缓存
--skip-frontend 跳过前端构建(沿用现有 dist/web) --skip-frontend 跳过前端构建(沿用现有 dist/web)
--bun 主前端使用 Bun 执行 Vite 构建(Workflow UI 子模块仍使用 pnpm) --bun 主前端使用 Bun 执行 Vite 构建(Workflow UI 子模块会自动准备 pnpm)
--no-run 构建后不启动服务 --no-run 构建后不启动服务
BOOSAPI_BUILD_PROFILE 可设置默认 profile(dev / release / fast-release) BOOSAPI_BUILD_PROFILE 可设置默认 profile(dev / release / fast-release)
BOOSAPI_CARGO_FEATURES 可覆盖后端 Cargo features(默认:空) BOOSAPI_CARGO_FEATURES 可覆盖后端 Cargo features(默认:空)
BOOSAPI_FRONTEND_PM 可设置主前端构建器:auto / npm / bun(默认:auto) BOOSAPI_FRONTEND_PM 可设置主前端构建器:auto / npm / bun(默认:auto)
BOOSAPI_PRODUCTION_DIR 可设置 --production 默认产物目录
-h, --help 显示帮助 -h, --help 显示帮助
示例: 示例:
./build-and-run.sh --release ./build-and-run.sh --release
./build-and-run.sh --fast-release ./build-and-run.sh --fast-release
./build-and-run.sh --bun ./build-and-run.sh --bun
./build-and-run.sh --production
./build-and-run.sh --production --production-dir /opt/boosapi-release
BOOSAPI_BUILD_PROFILE=release ./build-and-run.sh BOOSAPI_BUILD_PROFILE=release ./build-and-run.sh
EOF EOF
} }
@@ -56,6 +65,16 @@ while [ $# -gt 0 ]; do
--dev) BUILD_PROFILE="dev" ;; --dev) BUILD_PROFILE="dev" ;;
--release) BUILD_PROFILE="release" ;; --release) BUILD_PROFILE="release" ;;
--fast-release) BUILD_PROFILE="fast-release" ;; --fast-release) BUILD_PROFILE="fast-release" ;;
--production|--prod)
PRODUCTION_BUNDLE=1
BUILD_PROFILE="release"
NO_RUN=1
;;
--production-dir)
shift
[ $# -gt 0 ] || fail "--production-dir 需要目录参数"
PRODUCTION_DIR="$1"
;;
--profile) --profile)
shift shift
[ $# -gt 0 ] || fail "--profile 需要参数:dev / release / fast-release" [ $# -gt 0 ] || fail "--profile 需要参数:dev / release / fast-release"
@@ -78,6 +97,14 @@ if [ "$FRONTEND_ONLY" -eq 1 ] && [ "$BACKEND_ONLY" -eq 1 ]; then
fail "--frontend-only 和 --backend-only 不能同时使用" fail "--frontend-only 和 --backend-only 不能同时使用"
fi fi
if [ "$PRODUCTION_BUNDLE" -eq 1 ] && [ "$FRONTEND_ONLY" -eq 1 ]; then
fail "--production 不能和 --frontend-only 同时使用"
fi
if [ "$PRODUCTION_BUNDLE" -eq 1 ] && [ "$BACKEND_ONLY" -eq 1 ]; then
fail "--production 需要先构建前端以嵌入 web,不能和 --backend-only 同时使用"
fi
case "$BUILD_PROFILE" in case "$BUILD_PROFILE" in
dev|debug) BUILD_PROFILE="dev" ;; dev|debug) BUILD_PROFILE="dev" ;;
release|fast-release) ;; release|fast-release) ;;
@@ -166,6 +193,36 @@ stop_previous_server() {
fi fi
} }
ensure_pnpm() {
if command -v pnpm >/dev/null 2>&1; then
return 0
fi
command -v corepack >/dev/null 2>&1 || fail "未找到 pnpm/corepack,无法自动准备 pnpm。请安装 Node.js 22+ 或 pnpm"
log "未找到 pnpm,尝试通过 corepack 自动启用..."
corepack enable
corepack prepare pnpm@latest --activate
command -v pnpm >/dev/null 2>&1 || fail "pnpm 自动准备失败,请手动安装 pnpm"
}
install_frontend_dependencies() {
local frontend_pm="$1"
if [ "$frontend_pm" = "bun" ]; then
command -v bun >/dev/null 2>&1 || fail "未找到 bun,请安装 Bun 或移除 --bun"
if [ ! -d "$ROOT_DIR/node_modules" ] || [ "$FORCE_FRONTEND" -eq 1 ]; then
log "安装主前端依赖 (bun install)..."
bun install
fi
return 0
fi
command -v node >/dev/null 2>&1 || fail "未找到 node,请先安装 Node.js"
ensure_pnpm
if [ ! -d "$ROOT_DIR/node_modules" ] || [ "$FORCE_FRONTEND" -eq 1 ]; then
log "安装主前端依赖 (pnpm install)..."
pnpm install --frozen-lockfile
fi
}
build_frontend() { build_frontend() {
if [ "$SKIP_FRONTEND" -eq 1 ]; then if [ "$SKIP_FRONTEND" -eq 1 ]; then
log "跳过前端构建(--skip-frontend)" log "跳过前端构建(--skip-frontend)"
@@ -185,12 +242,7 @@ build_frontend() {
*) fail "未知 BOOSAPI_FRONTEND_PM: $frontend_pm(可选:auto / npm / bun)" ;; *) fail "未知 BOOSAPI_FRONTEND_PM: $frontend_pm(可选:auto / npm / bun)" ;;
esac esac
if [ "$frontend_pm" = "bun" ]; then install_frontend_dependencies "$frontend_pm"
command -v bun >/dev/null 2>&1 || fail "未找到 bun,请安装 Bun 或移除 --bun"
else
command -v node >/dev/null 2>&1 || fail "未找到 node,请先安装 Node.js"
command -v npx >/dev/null 2>&1 || fail "未找到 npx,请先安装 Node.js"
fi
local fingerprint_file="$ROOT_DIR/dist/web/.boosapi-build-fingerprint" local fingerprint_file="$ROOT_DIR/dist/web/.boosapi-build-fingerprint"
local fingerprint local fingerprint
@@ -223,13 +275,36 @@ build_frontend() {
if [ "$frontend_pm" = "bun" ]; then if [ "$frontend_pm" = "bun" ]; then
bunx --bun vite build bunx --bun vite build
else else
npx vite build pnpm exec vite build
fi fi
mkdir -p "$ROOT_DIR/dist/web" mkdir -p "$ROOT_DIR/dist/web"
printf '%s\n' "$fingerprint" > "$fingerprint_file" printf '%s\n' "$fingerprint" > "$fingerprint_file"
ok "前端构建完成 → dist/web/" ok "前端构建完成 → dist/web/"
} }
ensure_workflow_ui_for_embedding() {
if [ "$SKIP_FRONTEND" -eq 1 ]; then
log "跳过 Workflow UI 构建检查(--skip-frontend)"
return 0
fi
if [ -f "$ROOT_DIR/dist/workflow-ui-external/index.html" ] \
&& [ -f "$ROOT_DIR/dist/workflow-ui-cloud/index.html" ] \
&& [ -f "$ROOT_DIR/dist/workflow-ui/index.html" ] \
&& [ "$FORCE_FRONTEND" -eq 0 ]; then
ok "Workflow UI 已存在,跳过构建 → dist/workflow-ui-{external,cloud}/"
return 0
fi
command -v git >/dev/null 2>&1 || fail "未找到 git,无法构建 Workflow UI"
command -v node >/dev/null 2>&1 || fail "未找到 node,无法构建 Workflow UI"
ensure_pnpm
log "构建 Workflow UI 双前端(external + cloud,用于嵌入二进制)..."
pnpm run workflow-ui:install
ok "Workflow UI 构建完成 → dist/workflow-ui-external/ dist/workflow-ui-cloud/"
}
build_backend() { build_backend() {
if ! command -v cargo >/dev/null 2>&1; then if ! command -v cargo >/dev/null 2>&1; then
fail "未找到 cargo,请先安装 Rust 工具链" fail "未找到 cargo,请先安装 Rust 工具链"
@@ -264,6 +339,29 @@ build_backend() {
fi fi
} }
build_production_bundle() {
[ -n "${BINARY:-}" ] || fail "生产打包失败:后端二进制路径为空"
[ -f "$BINARY" ] || fail "生产打包失败:未找到二进制 $BINARY"
[ -f "$ROOT_DIR/config.toml" ] || fail "生产打包失败:未找到 config.toml"
rm -rf "$PRODUCTION_DIR"
mkdir -p "$PRODUCTION_DIR"
cp "$BINARY" "$PRODUCTION_DIR/boosapi"
cp "$ROOT_DIR/config.toml" "$PRODUCTION_DIR/config.toml"
chmod +x "$PRODUCTION_DIR/boosapi"
if command -v strip >/dev/null 2>&1; then
log "压缩生产二进制符号表 (strip)..."
strip "$PRODUCTION_DIR/boosapi" 2>/dev/null || log "strip 失败,保留未压缩二进制"
fi
ok "生产产物已生成 → $PRODUCTION_DIR"
log "产物内容:"
(cd "$PRODUCTION_DIR" && ls -lh boosapi config.toml)
log "部署时只需要复制该目录内的 boosapi 和 config.toml;主 Web 前端已嵌入二进制。"
}
cleanup_started_server() { cleanup_started_server() {
if [ -n "${STARTED_PID:-}" ] && is_running_pid "$STARTED_PID"; then if [ -n "${STARTED_PID:-}" ] && is_running_pid "$STARTED_PID"; then
log "停止本次启动的后端进程: $STARTED_PID" log "停止本次启动的后端进程: $STARTED_PID"
@@ -282,10 +380,18 @@ fi
if [ "$BACKEND_ONLY" -ne 1 ]; then if [ "$BACKEND_ONLY" -ne 1 ]; then
build_frontend build_frontend
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
ensure_workflow_ui_for_embedding
fi
fi fi
build_backend build_backend
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
build_production_bundle
exit 0
fi
if [ "$NO_RUN" -eq 1 ]; then if [ "$NO_RUN" -eq 1 ]; then
log "跳过启动(--no-run)" log "跳过启动(--no-run)"
exit 0 exit 0
+3 -1
View File
@@ -35,7 +35,9 @@
"docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173", "docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173",
"docs:build": "vitepress build docs", "docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173", "docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173",
"workflow-ui:install": "node scripts/workflow-ui/install.mjs", "workflow-ui:install": "node scripts/workflow-ui/install.mjs all",
"workflow-ui:install:external": "node scripts/workflow-ui/install.mjs external",
"workflow-ui:install:cloud": "node scripts/workflow-ui/install.mjs cloud",
"test": "vitest run --root .", "test": "vitest run --root .",
"test:watch": "vitest --root ." "test:watch": "vitest --root ."
}, },
+72 -18
View File
@@ -4,10 +4,34 @@ import { spawn } from 'child_process';
const ROOT = process.cwd(); const ROOT = process.cwd();
const REPO = process.env.BOOSAPI_WORKFLOW_UI_REPO || ''; const REPO = process.env.BOOSAPI_WORKFLOW_UI_REPO || '';
const REF = process.env.BOOSAPI_WORKFLOW_UI_REF || 'v1.46.1'; const VARIANT = (process.env.BOOSAPI_WORKFLOW_UI_VARIANT || process.argv[2] || 'all').toLowerCase();
const DEFAULT_EXTERNAL_REF = 'v1.14.6';
const DEFAULT_CLOUD_REF = 'v1.46.1';
const SUBMODULE_DIR = path.join(ROOT, 'vendor', 'ComfyUI_frontend'); const SUBMODULE_DIR = path.join(ROOT, 'vendor', 'ComfyUI_frontend');
const CACHE_DIR = path.join(ROOT, '.cache', 'comfyui-frontend'); const CACHE_DIR = path.join(ROOT, '.cache', 'comfyui-frontend');
const DIST = path.join(ROOT, 'dist', 'workflow-ui'); const DEFAULT_DIST = path.join(ROOT, 'dist', 'workflow-ui');
// External/RunningHub often exposes older ComfyUI servers; v1.14.x matches
// ComfyUI 0.3.27 much better than the newer Cloud-oriented frontend.
const variants = {
external: {
ref: process.env.BOOSAPI_WORKFLOW_UI_EXTERNAL_REF
|| process.env.BOOSAPI_WORKFLOW_UI_REF
|| DEFAULT_EXTERNAL_REF,
dist: process.env.BOOSAPI_WORKFLOW_UI_EXTERNAL_DIST
|| path.join(ROOT, 'dist', 'workflow-ui-external'),
description: 'External/RunningHub compatible frontend',
},
cloud: {
ref: process.env.BOOSAPI_WORKFLOW_UI_CLOUD_REF
|| process.env.BOOSAPI_WORKFLOW_UI_REF
|| DEFAULT_CLOUD_REF,
dist: process.env.BOOSAPI_WORKFLOW_UI_CLOUD_DIST
|| path.join(ROOT, 'dist', 'workflow-ui-cloud'),
description: 'Comfy Cloud frontend',
},
};
function run(command, args, options = {}) { function run(command, args, options = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -25,23 +49,53 @@ function run(command, args, options = {}) {
}); });
} }
const hasSubmodule = await fs.access(path.join(SUBMODULE_DIR, 'package.json')).then(() => true, () => false); function selectedVariants() {
const DIR = REPO ? CACHE_DIR : SUBMODULE_DIR; if (VARIANT === 'all') return Object.entries(variants);
if (REPO) { if (variants[VARIANT]) return [[VARIANT, variants[VARIANT]]];
throw new Error(`Unknown workflow-ui variant: ${VARIANT}. Use external, cloud, or all.`);
}
async function buildVariant(name, config) {
const ref = config.ref;
const dist = config.dist;
const dir = path.join(CACHE_DIR, `${name}-${ref.replace(/[^a-zA-Z0-9._-]/g, '_')}`);
console.log(`\n==> Building workflow-ui:${name} (${config.description}) from ${ref}`);
await fs.mkdir(path.dirname(CACHE_DIR), { recursive: true }); await fs.mkdir(path.dirname(CACHE_DIR), { recursive: true });
await fs.rm(CACHE_DIR, { recursive: true, force: true }); await fs.rm(dir, { recursive: true, force: true });
await run('git', ['clone', '--depth', '1', '--branch', REF, REPO, CACHE_DIR]);
} else if (!hasSubmodule) { if (REPO) {
await run('git', ['clone', '--depth', '1', '--branch', ref, REPO, dir]);
} else {
const hasSubmodule = await fs.access(path.join(SUBMODULE_DIR, 'package.json')).then(() => true, () => false);
if (!hasSubmodule) {
await run('git', ['submodule', 'update', '--init', '--recursive', 'vendor/ComfyUI_frontend']); await run('git', ['submodule', 'update', '--init', '--recursive', 'vendor/ComfyUI_frontend']);
}
await run('git', ['clone', '--shared', SUBMODULE_DIR, dir]);
await run('git', ['-c', 'advice.detachedHead=false', 'checkout', '--quiet', ref], { cwd: dir });
await run('git', ['submodule', 'update', '--init', '--recursive'], { cwd: dir });
}
const hasPnpmLock = await fs.access(path.join(dir, 'pnpm-lock.yaml')).then(() => true, () => false);
if (hasPnpmLock) {
await run('pnpm', ['install', '--frozen-lockfile'], { cwd: dir });
await run('pnpm', ['exec', 'vite', 'build', '--config', 'vite.config.mts'], { cwd: dir });
} else {
await run('npm', ['ci'], { cwd: dir });
await run('npm', ['run', 'build'], { cwd: dir });
}
await fs.rm(dist, { recursive: true, force: true });
await fs.cp(path.join(dir, 'dist'), dist, { recursive: true });
console.log(`workflow-ui:${name} installed to ${dist}`);
if (name === 'cloud') {
await fs.rm(DEFAULT_DIST, { recursive: true, force: true });
await fs.cp(dist, DEFAULT_DIST, { recursive: true });
console.log(`workflow-ui default compatibility copy installed to ${DEFAULT_DIST}`);
}
} }
const hasPnpmLock = await fs.access(path.join(DIR, 'pnpm-lock.yaml')).then(() => true, () => false);
if (hasPnpmLock) { for (const [name, config] of selectedVariants()) {
await run('pnpm', ['install', '--frozen-lockfile'], { cwd: DIR }); await buildVariant(name, config);
await run('pnpm', ['exec', 'vite', 'build', '--config', 'vite.config.mts'], { cwd: DIR });
} else {
await run('npm', ['ci'], { cwd: DIR });
await run('npm', ['run', 'build'], { cwd: DIR });
} }
await fs.rm(DIST, { recursive: true, force: true });
await fs.cp(path.join(DIR, 'dist'), DIST, { recursive: true });
console.log(`workflow-ui installed to ${DIST}`);
+271 -112
View File
@@ -22,6 +22,7 @@ use axum::{
routing::{any, get, post}, routing::{any, get, post},
Json, Router, Json, Router,
}; };
use include_dir::{include_dir, Dir};
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value}; use serde_json::{json, Map, Value};
@@ -38,6 +39,11 @@ use tungstenite::client::IntoClientRequest;
static WORKFLOW_WS_SESSIONS: Lazy<parking_lot::Mutex<HashMap<String, broadcast::Sender<Value>>>> = static WORKFLOW_WS_SESSIONS: Lazy<parking_lot::Mutex<HashMap<String, broadcast::Sender<Value>>>> =
Lazy::new(|| parking_lot::Mutex::new(HashMap::new())); Lazy::new(|| parking_lot::Mutex::new(HashMap::new()));
static EMBEDDED_WORKFLOW_UI: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/workflow-ui");
static EMBEDDED_WORKFLOW_UI_EXTERNAL: Dir<'_> =
include_dir!("$CARGO_MANIFEST_DIR/dist/workflow-ui-external");
static EMBEDDED_WORKFLOW_UI_CLOUD: Dir<'_> =
include_dir!("$CARGO_MANIFEST_DIR/dist/workflow-ui-cloud");
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
enum WorkflowCaller { enum WorkflowCaller {
@@ -136,7 +142,6 @@ pub fn routes() -> Router<AppState> {
"/workflow-ui/api/prompt", "/workflow-ui/api/prompt",
get(queue_status).post(queue_prompt), get(queue_status).post(queue_prompt),
) )
.route("/workflow-ui/api/{*path}", any(native_workflow_api_proxy))
.route("/workflow-ui/api/queue", get(queue).post(queue_mutate)) .route("/workflow-ui/api/queue", get(queue).post(queue_mutate))
.route( .route(
"/workflow-ui/api/history", "/workflow-ui/api/history",
@@ -157,6 +162,7 @@ pub fn routes() -> Router<AppState> {
"/workflow-ui/api/view_metadata/{folder}", "/workflow-ui/api/view_metadata/{folder}",
get(view_metadata), get(view_metadata),
) )
.route("/workflow-ui/api/{*path}", any(native_workflow_api_proxy))
.route("/workflow-ui/api/i18n", get(custom_nodes_i18n)) .route("/workflow-ui/api/i18n", get(custom_nodes_i18n))
.route("/workflow-ui/internal/logs", get(internal_logs)) .route("/workflow-ui/internal/logs", get(internal_logs))
.route("/workflow-ui/internal/logs/raw", get(internal_logs_raw)) .route("/workflow-ui/internal/logs/raw", get(internal_logs_raw))
@@ -173,10 +179,31 @@ pub fn routes() -> Router<AppState> {
.route("/workflow-ui/{*path}", any(workflow_ui_static)) .route("/workflow-ui/{*path}", any(workflow_ui_static))
} }
fn workflow_ui_root() -> PathBuf { fn workflow_ui_root_for_config(config: &crate::config::ComfyUIConfig) -> PathBuf {
std::env::var("BOOSAPI_WORKFLOW_UI_DIR") if let Ok(path) = std::env::var("BOOSAPI_WORKFLOW_UI_DIR") {
return PathBuf::from(path);
}
if config.uses_cloud_backend() {
std::env::var("BOOSAPI_WORKFLOW_UI_CLOUD_DIR")
.map(PathBuf::from) .map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("dist/workflow-ui")) .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")
}
}
fn embedded_workflow_ui_for_config(config: &crate::config::ComfyUIConfig) -> &'static Dir<'static> {
if config.uses_cloud_backend() {
&EMBEDDED_WORKFLOW_UI_CLOUD
} else if config.uses_native_backend() {
&EMBEDDED_WORKFLOW_UI_EXTERNAL
} else {
&EMBEDDED_WORKFLOW_UI
}
} }
async fn workflow_ui_runtime(State(state): State<AppState>) -> Json<Value> { async fn workflow_ui_runtime(State(state): State<AppState>) -> Json<Value> {
@@ -192,6 +219,7 @@ async fn workflow_ui_runtime(State(state): State<AppState>) -> Json<Value> {
Json(json!({ Json(json!({
"mode": mode, "mode": mode,
"baseUrl": state.config.comfyui.target_base_url(), "baseUrl": state.config.comfyui.target_base_url(),
"frontendDir": workflow_ui_root_for_config(&state.config.comfyui),
})) }))
} }
@@ -966,6 +994,57 @@ fn workflow_folder_models(folder: &str, models: &[String]) -> Vec<String> {
} }
} }
fn combo_choices_from_node_input(
object_info: &Value,
node_type: &str,
input_name: &str,
) -> Vec<String> {
object_info
.get(node_type)
.and_then(|node| {
node.pointer(&format!("/input/required/{input_name}"))
.or_else(|| node.pointer(&format!("/input/optional/{input_name}")))
})
.and_then(Value::as_array)
.and_then(|input| input.first())
.and_then(Value::as_array)
.map(|choices| {
choices
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty() && *value != "None")
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
fn native_folder_models_from_object_info(folder: &str, object_info: &Value) -> Vec<String> {
let normalized = folder.trim().to_ascii_lowercase();
match normalized.as_str() {
"checkpoints" | "image_models" => {
combo_choices_from_node_input(object_info, "CheckpointLoaderSimple", "ckpt_name")
}
"loras" => combo_choices_from_node_input(object_info, "LoraLoader", "lora_name"),
"vae" => combo_choices_from_node_input(object_info, "VAELoader", "vae_name"),
"clip" | "clip_models" => {
combo_choices_from_node_input(object_info, "CLIPLoader", "clip_name")
}
"unet" | "diffusion_models" => {
combo_choices_from_node_input(object_info, "UNETLoader", "unet_name")
}
"controlnet" | "controlnets" => {
combo_choices_from_node_input(object_info, "ControlNetLoader", "control_net_name")
}
"upscale_models" => {
combo_choices_from_node_input(object_info, "UpscaleModelLoader", "model_name")
}
"embeddings" => combo_choices_from_node_input(object_info, "EmbeddingPicker", "embedding"),
_ => Vec::new(),
}
}
fn comfy_combo_input(options: &[String], fallback: &str) -> Value { fn comfy_combo_input(options: &[String], fallback: &str) -> Value {
let choices = if options.is_empty() { let choices = if options.is_empty() {
vec![fallback.to_string()] vec![fallback.to_string()]
@@ -1078,6 +1157,7 @@ fn builtin_model_folders() -> Vec<&'static str> {
fn inject_boosapi_model_folders(value: &mut Value) { fn inject_boosapi_model_folders(value: &mut Value) {
let Some(items) = value.as_array_mut() else { let Some(items) = value.as_array_mut() else {
*value = workflow_model_folder_payload();
return; return;
}; };
let mut seen = items let mut seen = items
@@ -1095,6 +1175,34 @@ fn inject_boosapi_model_folders(value: &mut Value) {
} }
} }
fn workflow_model_folder_payload() -> Value {
Value::Array(
builtin_model_folders()
.into_iter()
.map(|name| Value::String(name.to_string()))
.collect(),
)
}
fn native_model_folder_payload_or_fallback(value: Value) -> Value {
match value {
Value::Array(_) => {
let mut value = value;
inject_boosapi_model_folders(&mut value);
value
}
Value::Object(object)
if object
.get("code")
.and_then(Value::as_i64)
.is_some_and(|code| code == 404) =>
{
workflow_model_folder_payload()
}
_ => workflow_model_folder_payload(),
}
}
fn prompt_contains_boosapi_nodes(prompt: &Value) -> bool { fn prompt_contains_boosapi_nodes(prompt: &Value) -> bool {
prompt prompt
.as_object() .as_object()
@@ -1120,12 +1228,19 @@ fn prompt_contains_boosapi_nodes(prompt: &Value) -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
async fn workflow_ui_index() -> Html<String> { async fn workflow_ui_index(State(state): State<AppState>) -> Html<String> {
let dir = workflow_ui_root(); let dir = workflow_ui_root_for_config(&state.config.comfyui);
let index = dir.join("index.html"); let index = dir.join("index.html");
match tokio::fs::read_to_string(&index).await { match tokio::fs::read_to_string(&index).await {
Ok(html) => Html(inject_bootstrap(html)), Ok(html) => Html(inject_bootstrap(html, &state.config.comfyui)),
Err(_) => Html( 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> r#"<!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
@@ -1143,18 +1258,22 @@ async fn workflow_ui_index() -> Html<String> {
<div class="wrap"> <div class="wrap">
<div class="box"> <div class="box">
<h1>Workflow Studio 前端未构建</h1> <h1>Workflow Studio 前端未构建</h1>
<p>官方 ComfyUI 前端会从 <code>dist/workflow-ui</code> 加载。请先运行构建脚本生成该目录,然后刷新此页面。</p> <p>官方 ComfyUI 前端会按后端类型从 <code>dist/workflow-ui-external</code> 或 <code>dist/workflow-ui-cloud</code> 加载。请先运行 <code>npm run workflow-ui:install</code>,然后刷新此页面。</p>
</div> </div>
</div> </div>
</body> </body>
</html>"# </html>"#
.to_string(), .to_string(),
), )
}
} }
} }
async fn workflow_ui_static(Path(path): Path<String>) -> AppResult<Response> { async fn workflow_ui_static(
let dir = workflow_ui_root(); 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(); let path = path.trim_start_matches('/').to_string();
if path == "templates/default.json" { if path == "templates/default.json" {
return Ok(Json(default_boosapi_workflow_graph()).into_response()); return Ok(Json(default_boosapi_workflow_graph()).into_response());
@@ -1177,12 +1296,50 @@ async fn workflow_ui_static(Path(path): Path<String>) -> AppResult<Response> {
if tokio::fs::metadata(&file_path).await.is_ok() { if tokio::fs::metadata(&file_path).await.is_ok() {
return serve_file(file_path).await; 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() { if PathBuf::from(&path).extension().is_none() {
return Ok(workflow_ui_index().await.into_response()); return Ok(workflow_ui_index(State(state)).await.into_response());
} }
serve_file(file_path).await serve_file(file_path).await
} }
fn embedded_workflow_ui_response(
config: &crate::config::ComfyUIConfig,
path: &str,
is_index: bool,
) -> Option<Response> {
let asset_path = if is_index || path.trim().is_empty() || path.ends_with('/') {
format!("{}index.html", path.trim_start_matches('/'))
} else {
path.trim_start_matches('/').to_string()
};
let file = embedded_workflow_ui_for_config(config).get_file(&asset_path)?;
let content_type = content_type_for_path(&PathBuf::from(&asset_path));
if asset_path.ends_with("index.html") {
let html = String::from_utf8_lossy(file.contents()).to_string();
return Some(
(
StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, content_type)],
inject_bootstrap(html, config),
)
.into_response(),
);
}
Some(
(
StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, content_type)],
file.contents().to_vec(),
)
.into_response(),
)
}
async fn serve_file(path: PathBuf) -> AppResult<Response> { async fn serve_file(path: PathBuf) -> AppResult<Response> {
match tokio::fs::read(&path).await { match tokio::fs::read(&path).await {
Ok(bytes) => { Ok(bytes) => {
@@ -2107,9 +2264,26 @@ async fn native_workflow_view_response(
state: &AppState, state: &AppState,
caller: &WorkflowCaller, caller: &WorkflowCaller,
params: &HashMap<String, String>, params: &HashMap<String, String>,
) -> AppResult<Response> {
native_workflow_file_response(state, caller, "/view", params).await
}
async fn native_workflow_video_response(
state: &AppState,
caller: &WorkflowCaller,
params: &HashMap<String, String>,
) -> AppResult<Response> {
native_workflow_file_response(state, caller, "/viewvideo", params).await
}
async fn native_workflow_file_response(
state: &AppState,
caller: &WorkflowCaller,
path: &str,
params: &HashMap<String, String>,
) -> AppResult<Response> { ) -> AppResult<Response> {
let query = build_query_string(params); let query = build_query_string(params);
let response = native_workflow_request(state, Method::GET, &format!("/view{query}"), None) let response = native_workflow_request(state, Method::GET, &format!("{path}{query}"), None)
.send() .send()
.await .await
.map_err(|err| AppError::UpstreamError(format!("ComfyUI backend unreachable: {err}")))?; .map_err(|err| AppError::UpstreamError(format!("ComfyUI backend unreachable: {err}")))?;
@@ -2125,7 +2299,8 @@ async fn native_workflow_view_response(
.await .await
.map_err(|err| AppError::UpstreamError(err.to_string()))?; .map_err(|err| AppError::UpstreamError(err.to_string()))?;
if status.is_success() { if status.is_success() {
archive_native_workflow_asset(state, caller, params, &content_type, bytes.as_ref()).await; archive_native_workflow_asset(state, caller, path, params, &content_type, bytes.as_ref())
.await;
} }
Ok(( Ok((
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY), StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY),
@@ -2138,6 +2313,7 @@ async fn native_workflow_view_response(
async fn archive_native_workflow_asset( async fn archive_native_workflow_asset(
state: &AppState, state: &AppState,
caller: &WorkflowCaller, caller: &WorkflowCaller,
path: &str,
params: &HashMap<String, String>, params: &HashMap<String, String>,
content_type: &str, content_type: &str,
bytes: &[u8], bytes: &[u8],
@@ -2159,13 +2335,17 @@ async fn archive_native_workflow_asset(
"SELECT COUNT(*) "SELECT COUNT(*)
FROM workspace_assets FROM workspace_assets
WHERE deleted_at IS NULL WHERE deleted_at IS NULL
AND (? IS NULL OR user_id = ?) AND (
(? = 1 AND user_id IS NULL)
OR (? = 0 AND user_id = ?)
)
AND source = ? AND source = ?
AND json_extract(metadata, '$.workflowUi.filename') = ? AND json_extract(metadata, '$.workflowUi.filename') = ?
AND json_extract(metadata, '$.workflowUi.type') = ? AND json_extract(metadata, '$.workflowUi.type') = ?
AND json_extract(metadata, '$.workflowUi.subfolder') = ?", AND json_extract(metadata, '$.workflowUi.subfolder') = ?",
) )
.bind(user_id) .bind(user_id.is_none())
.bind(user_id.is_none())
.bind(user_id) .bind(user_id)
.bind(source) .bind(source)
.bind(filename) .bind(filename)
@@ -2191,6 +2371,7 @@ async fn archive_native_workflow_asset(
"filename": filename, "filename": filename,
"type": params.get("type").map(String::as_str).unwrap_or("output"), "type": params.get("type").map(String::as_str).unwrap_or("output"),
"subfolder": params.get("subfolder").map(String::as_str).unwrap_or(""), "subfolder": params.get("subfolder").map(String::as_str).unwrap_or(""),
"endpoint": path,
} }
}), }),
}; };
@@ -2778,20 +2959,17 @@ async fn experiment_models(
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
if state.config.comfyui.uses_native_backend() { if state.config.comfyui.uses_native_backend() {
let _ = auth_caller_optional(&state, &headers); let _ = auth_caller_optional(&state, &headers);
let mut value = native_workflow_json(&state, Method::GET, "/experiment/models", None) let value =
.await? match native_workflow_json(&state, Method::GET, "/experiment/models", None).await {
.0; Ok(value) => native_model_folder_payload_or_fallback(value.0),
inject_boosapi_model_folders(&mut value); Err(_) => workflow_model_folder_payload(),
};
return Ok(Json(value)); return Ok(Json(value));
} }
let caller = let caller =
auth_caller_optional(&state, &headers).unwrap_or(WorkflowCaller::User { user_id: 0 }); auth_caller_optional(&state, &headers).unwrap_or(WorkflowCaller::User { user_id: 0 });
let _ = caller; let _ = caller;
let items = builtin_model_folders() Ok(Json(workflow_model_folder_payload()))
.into_iter()
.map(|name| json!({ "name": name }))
.collect::<Vec<_>>();
Ok(Json(Value::Array(items)))
} }
async fn experiment_models_in_folder( async fn experiment_models_in_folder(
@@ -2811,8 +2989,24 @@ async fn experiment_models_in_folder(
.collect(), .collect(),
))); )));
} }
if let Ok(object_info) =
native_workflow_json(&state, Method::GET, "/object_info", None).await
{
let native_models = native_folder_models_from_object_info(&folder, &object_info.0);
if !native_models.is_empty() {
return Ok(Json(Value::Array(
native_models
.into_iter()
.map(|name| json!({ "name": name, "pathIndex": 0 }))
.collect(),
)));
}
}
let path = format!("/experiment/models/{}", simple_urlencode(&folder)); let path = format!("/experiment/models/{}", simple_urlencode(&folder));
return native_workflow_json(&state, Method::GET, &path, None).await; return match native_workflow_json(&state, Method::GET, &path, None).await {
Ok(value) => Ok(value),
Err(_) => Ok(Json(Value::Array(Vec::new()))),
};
} }
let caller = let caller =
auth_caller_optional(&state, &headers).unwrap_or(WorkflowCaller::User { user_id: 0 }); auth_caller_optional(&state, &headers).unwrap_or(WorkflowCaller::User { user_id: 0 });
@@ -2889,6 +3083,19 @@ async fn view_video(
Query(query): Query<ViewQuery>, Query(query): Query<ViewQuery>,
) -> AppResult<Response> { ) -> AppResult<Response> {
let caller = auth_caller(&state, &headers).await?; let caller = auth_caller(&state, &headers).await?;
if state.config.comfyui.uses_native_backend() {
let mut params = HashMap::new();
if let Some(filename) = query.filename {
params.insert("filename".to_string(), filename);
}
if let Some(file_type) = query.file_type {
params.insert("type".to_string(), file_type);
}
if let Some(subfolder) = query.subfolder {
params.insert("subfolder".to_string(), subfolder);
}
return native_workflow_video_response(&state, &caller, &params).await;
}
let user_id = caller_user_id(&caller); let user_id = caller_user_id(&caller);
let filename = query.filename.unwrap_or_default(); let filename = query.filename.unwrap_or_default();
if filename.is_empty() { if filename.is_empty() {
@@ -3138,79 +3345,47 @@ async fn workflow_context(state: &AppState, caller: &WorkflowCaller) -> AppResul
}) })
} }
fn inject_bootstrap(html: String) -> String { fn inject_bootstrap(html: String, config: &crate::config::ComfyUIConfig) -> String {
if config.uses_native_backend() && !config.uses_cloud_backend() {
return inject_external_bootstrap(html);
}
inject_enhanced_bootstrap(html)
}
fn inject_external_bootstrap(html: String) -> String {
let bootstrap = r#"<script>
(function () {
const renameComfyUiBrand = () => {
document.title = document.title.replace(/ComfyUI/g, 'BoosAI');
const candidates = Array.from(document.querySelectorAll('header *, nav *, .comfy-menu-header *, .comfyui-menu *, .p-menubar *')).slice(0, 80);
for (const el of candidates) {
if (el.childNodes.length === 1 && el.firstChild && el.firstChild.nodeType === Node.TEXT_NODE) {
const value = String(el.textContent || '').trim();
if (value === 'ComfyUI' || value === 'Comfy UI') el.textContent = 'BoosAI';
}
}
};
try {
const userJwt = localStorage.getItem('user_jwt_token');
const adminToken = localStorage.getItem('auth_token');
const token = adminToken || userJwt;
if (token) {
localStorage.setItem('auth_token', token);
document.cookie = 'boosapi_workflow_token=' + encodeURIComponent(token) + '; Path=/workflow-ui; SameSite=Lax';
}
} catch (err) {}
window.setTimeout(renameComfyUiBrand, 250);
window.setTimeout(renameComfyUiBrand, 1200);
})();
</script>"#;
html.replace("</head>", &format!("{bootstrap}\n</head>"))
}
fn inject_enhanced_bootstrap(html: String) -> String {
let bootstrap = r#"<style id="boosapi-workflow-shell-style"> let bootstrap = r#"<style id="boosapi-workflow-shell-style">
.boosapi-hidden-comfy-item { display: none !important; }
</style> </style>
<script> <script>
(function () { (function () {
const shouldHideComfyUiItem = (text) => {
const value = String(text || '').replace(/\s+/g, ' ').trim().toLowerCase();
if (!value) return false;
return value === 'about'
|| value === '关于'
|| value.includes('about comfyui')
|| value.includes('关于comfyui')
|| value.includes('关于 comfyui');
};
const hideComfyUiAboutItems = () => {
const selectors = [
'button',
'a',
'[role="menuitem"]',
'.p-menuitem',
'.p-menuitem-content',
'.p-tab',
'.p-tablist [role="tab"]',
'.p-dialog [role="tab"]',
'.p-dialog li',
'.p-dialog [class*="item"]',
'.p-dialog [class*="tab"]',
'.p-dialog [class*="category"]'
];
const roots = [
document.querySelector('.p-menu'),
document.querySelector('.p-menubar'),
document.querySelector('.p-tieredmenu'),
document.querySelector('.p-contextmenu'),
document.querySelector('.p-dialog')
].filter(Boolean);
for (const root of roots) for (const el of root.querySelectorAll(selectors.join(','))) {
if (shouldHideComfyUiItem(el.textContent)) {
const row = el.closest('li,.p-menuitem,[role="menuitem"],button,a,.p-tab,[role="tab"]') || el;
row.classList.add('boosapi-hidden-comfy-item');
row.setAttribute('aria-hidden', 'true');
}
}
for (const dialog of document.querySelectorAll('.p-dialog,[role="dialog"]')) {
const aboutNodes = Array.from(dialog.querySelectorAll('h1,h2,h3,h4,section,main,article,div')).filter((el) => {
const value = String(el.textContent || '').replace(/\s+/g, ' ').trim().toLowerCase();
return value === 'about'
|| value === '关于'
|| value.startsWith('关于 comfyui')
|| value.startsWith('about comfyui')
|| value.includes('comfyui_frontend')
|| value.includes('comfyorg');
});
for (const el of aboutNodes) {
const row = el.closest('[role="tabpanel"],section,main,article,.p-tabpanel') || el;
row.classList.add('boosapi-hidden-comfy-item');
row.setAttribute('aria-hidden', 'true');
}
const activeAbout = Array.from(dialog.querySelectorAll('.boosapi-hidden-comfy-item')).some((el) => {
const value = String(el.textContent || '').replace(/\s+/g, ' ').trim().toLowerCase();
return value === 'about' || value === '关于';
});
if (activeAbout) {
const fallback = Array.from(dialog.querySelectorAll('button,[role="tab"],li,[class*="item"]')).find((el) => {
if (el.classList.contains('boosapi-hidden-comfy-item')) return false;
const value = String(el.textContent || '').replace(/\s+/g, ' ').trim();
return value && !shouldHideComfyUiItem(value);
});
if (fallback && typeof fallback.click === 'function') fallback.click();
}
}
};
const renameComfyUiBrand = () => { const renameComfyUiBrand = () => {
document.title = document.title.replace(/ComfyUI/g, 'BoosAI'); document.title = document.title.replace(/ComfyUI/g, 'BoosAI');
const candidates = Array.from(document.querySelectorAll('header *, nav *, .comfy-menu-header *, .comfyui-menu *, .p-menubar *')).slice(0, 120); const candidates = Array.from(document.querySelectorAll('header *, nav *, .comfy-menu-header *, .comfyui-menu *, .p-menubar *')).slice(0, 120);
@@ -3311,20 +3486,6 @@ fn inject_bootstrap(html: String) -> String {
}); });
observer.observe(document.body, { childList: true, subtree: true }); observer.observe(document.body, { childList: true, subtree: true });
}; };
const patchComfyUiCommands = () => {
try {
const api = window.comfyAPI || {};
const commands = api.commands || api.command || window.app?.commands;
const store = commands?.commands || commands?._commands || commands?.commandMap;
if (store && typeof store === 'object') {
for (const key of Object.keys(store)) {
if (String(key).toLowerCase().includes('aboutcomfyui')) {
delete store[key];
}
}
}
} catch (err) {}
};
const normalizeWorkflowPortType = (value) => { const normalizeWorkflowPortType = (value) => {
const normalized = String(value || '').trim().toLowerCase(); const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'conditioning' || normalized === 'condition' || normalized === 'cond' || normalized === '条件' || normalized === '条件化' || normalized === '正面条件' || normalized === '负面条件') return 'conditioning'; if (normalized === 'conditioning' || normalized === 'condition' || normalized === 'cond' || normalized === '条件' || normalized === '条件化' || normalized === '正面条件' || normalized === '负面条件') return 'conditioning';
@@ -3511,8 +3672,6 @@ fn inject_bootstrap(html: String) -> String {
let shellPatchQueued = false; let shellPatchQueued = false;
const patchWorkflowShell = () => { const patchWorkflowShell = () => {
shellPatchQueued = false; shellPatchQueued = false;
hideComfyUiAboutItems();
patchComfyUiCommands();
patchWorkflowConnectionTypes(); patchWorkflowConnectionTypes();
startWorkflowSlotNormalizer(); startWorkflowSlotNormalizer();
patchModelLibraryClicks(); patchModelLibraryClicks();
+19 -4
View File
@@ -168,12 +168,13 @@ async fn list_assets(
WorkspaceCaller::Admin => requested_user_id, WorkspaceCaller::Admin => requested_user_id,
WorkspaceCaller::User { user_id } => Some(user_id), WorkspaceCaller::User { user_id } => Some(user_id),
}; };
let is_admin = matches!(caller, WorkspaceCaller::Admin);
let items = sqlx::query( let items = sqlx::query(
"SELECT * "SELECT *
FROM workspace_assets FROM workspace_assets
WHERE deleted_at IS NULL WHERE deleted_at IS NULL
AND (? IS NULL OR user_id = ?) AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))
AND (? IS NULL OR source = ?) AND (? IS NULL OR source = ?)
AND (? IS NULL OR kind = ?) AND (? IS NULL OR kind = ?)
AND ( AND (
@@ -186,8 +187,11 @@ async fn list_assets(
ORDER BY datetime(created_at) DESC, id DESC ORDER BY datetime(created_at) DESC, id DESC
LIMIT ? OFFSET ?", LIMIT ? OFFSET ?",
) )
.bind(is_admin)
.bind(scoped_user_id) .bind(scoped_user_id)
.bind(scoped_user_id) .bind(scoped_user_id)
.bind(is_admin)
.bind(scoped_user_id)
.bind(&source) .bind(&source)
.bind(&source) .bind(&source)
.bind(&kind) .bind(&kind)
@@ -207,7 +211,7 @@ async fn list_assets(
"SELECT COUNT(*) "SELECT COUNT(*)
FROM workspace_assets FROM workspace_assets
WHERE deleted_at IS NULL WHERE deleted_at IS NULL
AND (? IS NULL OR user_id = ?) AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))
AND (? IS NULL OR source = ?) AND (? IS NULL OR source = ?)
AND (? IS NULL OR kind = ?) AND (? IS NULL OR kind = ?)
AND ( AND (
@@ -218,8 +222,11 @@ async fn list_assets(
OR source LIKE ? OR source LIKE ?
)", )",
) )
.bind(is_admin)
.bind(scoped_user_id) .bind(scoped_user_id)
.bind(scoped_user_id) .bind(scoped_user_id)
.bind(is_admin)
.bind(scoped_user_id)
.bind(&source) .bind(&source)
.bind(&source) .bind(&source)
.bind(&kind) .bind(&kind)
@@ -250,17 +257,21 @@ async fn get_asset_row(
WorkspaceCaller::Admin => None, WorkspaceCaller::Admin => None,
WorkspaceCaller::User { user_id } => Some(user_id), WorkspaceCaller::User { user_id } => Some(user_id),
}; };
let is_admin = matches!(caller, WorkspaceCaller::Admin);
let row = sqlx::query( let row = sqlx::query(
"SELECT * "SELECT *
FROM workspace_assets FROM workspace_assets
WHERE public_id = ? WHERE public_id = ?
AND deleted_at IS NULL AND deleted_at IS NULL
AND (? IS NULL OR user_id = ?) AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))
LIMIT 1", LIMIT 1",
) )
.bind(public_id) .bind(public_id)
.bind(is_admin)
.bind(scoped_user_id) .bind(scoped_user_id)
.bind(scoped_user_id) .bind(scoped_user_id)
.bind(is_admin)
.bind(scoped_user_id)
.fetch_optional(&*state.db) .fetch_optional(&*state.db)
.await? .await?
.ok_or_else(|| AppError::NotFound("Workspace asset not found".into()))?; .ok_or_else(|| AppError::NotFound("Workspace asset not found".into()))?;
@@ -287,16 +298,20 @@ async fn delete_asset(
WorkspaceCaller::Admin => None, WorkspaceCaller::Admin => None,
WorkspaceCaller::User { user_id } => Some(user_id), WorkspaceCaller::User { user_id } => Some(user_id),
}; };
let is_admin = matches!(caller, WorkspaceCaller::Admin);
let result = sqlx::query( let result = sqlx::query(
"UPDATE workspace_assets "UPDATE workspace_assets
SET deleted_at = datetime('now'), updated_at = datetime('now') SET deleted_at = datetime('now'), updated_at = datetime('now')
WHERE public_id = ? WHERE public_id = ?
AND deleted_at IS NULL AND deleted_at IS NULL
AND (? IS NULL OR user_id = ?)", AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))",
) )
.bind(public_id) .bind(public_id)
.bind(is_admin)
.bind(scoped_user_id) .bind(scoped_user_id)
.bind(scoped_user_id) .bind(scoped_user_id)
.bind(is_admin)
.bind(scoped_user_id)
.execute(&*state.db) .execute(&*state.db)
.await?; .await?;
if result.rows_affected() == 0 { if result.rows_affected() == 0 {
+43
View File
@@ -138,6 +138,20 @@ function resolveStoredProfile(): UserProfile {
return { name: '管理员', avatarSeed, avatarStyle: pickDicebearStyle(avatarSeed) }; return { name: '管理员', avatarSeed, avatarStyle: pickDicebearStyle(avatarSeed) };
} }
} }
function profileFromUsername(username: string, current?: UserProfile): UserProfile {
const name = username.trim();
const avatarSeed = current?.avatarSeed?.trim() || createRandomAvatarSeed();
const avatarStyle = current?.avatarStyle?.trim() || pickDicebearStyle(avatarSeed);
return {
name: name || current?.name || '用户',
avatarSeed,
avatarStyle: DICEBEAR_STYLES.includes(avatarStyle as DicebearStyle)
? avatarStyle
: pickDicebearStyle(avatarSeed),
};
}
function UserProfileModal({ function UserProfileModal({
open, open,
profile, profile,
@@ -560,6 +574,35 @@ function AppShell() {
cancelled = true; cancelled = true;
}; };
}, [location.pathname, userAuthed]); }, [location.pathname, userAuthed]);
useEffect(() => {
if (
!userAuthed
|| !location.pathname.startsWith('/user/')
|| ['/user/login', '/user/register'].includes(location.pathname)
) return;
let cancelled = false;
const syncUserProfile = async () => {
try {
const response = await userApi.me();
if (cancelled) return;
const user = (response as any)?.user || (response as any)?.data || {};
const username = typeof user.username === 'string' ? user.username.trim() : '';
if (!username) return;
setUserProfile((current) => {
if ((current.name || '').trim() === username) return current;
const next = profileFromUsername(username, current);
localStorage.setItem(USER_PROFILE_STORAGE_KEY, JSON.stringify(next));
return next;
});
} catch {
// Keep the local profile if the user profile endpoint is temporarily unavailable.
}
};
void syncUserProfile();
return () => {
cancelled = true;
};
}, [location.pathname, userAuthed]);
useEffect(() => { useEffect(() => {
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) { if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
+2
View File
@@ -425,7 +425,9 @@ export default function Workspace() {
</button> </button>
)} )}
<div style={detailMetaStyle}> <div style={detailMetaStyle}>
{audience !== 'user' && (
<div><span>用户</span><strong>{selected.userId ?? '管理员/系统'}</strong></div> <div><span>用户</span><strong>{selected.userId ?? '管理员/系统'}</strong></div>
)}
<div><span>模型</span><strong>{selected.model || '未记录'}</strong></div> <div><span>模型</span><strong>{selected.model || '未记录'}</strong></div>
<div><span>大小</span><strong>{formatBytes(selected.sizeBytes)}</strong></div> <div><span>大小</span><strong>{formatBytes(selected.sizeBytes)}</strong></div>
<div><span>文件</span><strong>{selected.hasLocalFile ? '本地归档' : '远程引用'}</strong></div> <div><span>文件</span><strong>{selected.hasLocalFile ? '本地归档' : '远程引用'}</strong></div>