Embed workflow UI and isolate workspace assets
This commit is contained in:
+114
-8
@@ -20,6 +20,8 @@ BACKEND_ONLY=0
|
||||
NO_RUN=0
|
||||
FORCE_FRONTEND=0
|
||||
SKIP_FRONTEND=0
|
||||
PRODUCTION_BUNDLE=0
|
||||
PRODUCTION_DIR="${BOOSAPI_PRODUCTION_DIR:-$ROOT_DIR/dist/production}"
|
||||
CARGO_FEATURES="${BOOSAPI_CARGO_FEATURES:-}"
|
||||
FRONTEND_PM="${BOOSAPI_FRONTEND_PM:-auto}"
|
||||
|
||||
@@ -32,21 +34,28 @@ usage() {
|
||||
--release 使用 release 模式编译后端(更慢但性能好)
|
||||
--fast-release 使用 fast-release 模式编译后端(比 release 快,性能接近生产)
|
||||
--profile <name> 指定构建 profile:dev / release / fast-release
|
||||
--production 生产编译:先构建前端并嵌入二进制,再输出 dist/production/{boosapi,config.toml}
|
||||
--prod 同 --production
|
||||
--production-dir <dir>
|
||||
指定生产产物目录(默认:dist/production)
|
||||
--frontend-only 只构建前端
|
||||
--backend-only 只构建后端
|
||||
--force-frontend 强制重建前端,忽略缓存
|
||||
--skip-frontend 跳过前端构建(沿用现有 dist/web)
|
||||
--bun 主前端使用 Bun 执行 Vite 构建(Workflow UI 子模块仍使用 pnpm)
|
||||
--bun 主前端使用 Bun 执行 Vite 构建(Workflow UI 子模块会自动准备 pnpm)
|
||||
--no-run 构建后不启动服务
|
||||
BOOSAPI_BUILD_PROFILE 可设置默认 profile(dev / release / fast-release)
|
||||
BOOSAPI_CARGO_FEATURES 可覆盖后端 Cargo features(默认:空)
|
||||
BOOSAPI_FRONTEND_PM 可设置主前端构建器:auto / npm / bun(默认:auto)
|
||||
BOOSAPI_PRODUCTION_DIR 可设置 --production 默认产物目录
|
||||
-h, --help 显示帮助
|
||||
|
||||
示例:
|
||||
./build-and-run.sh --release
|
||||
./build-and-run.sh --fast-release
|
||||
./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
|
||||
EOF
|
||||
}
|
||||
@@ -56,6 +65,16 @@ while [ $# -gt 0 ]; do
|
||||
--dev) BUILD_PROFILE="dev" ;;
|
||||
--release) BUILD_PROFILE="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)
|
||||
shift
|
||||
[ $# -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 不能同时使用"
|
||||
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
|
||||
dev|debug) BUILD_PROFILE="dev" ;;
|
||||
release|fast-release) ;;
|
||||
@@ -166,6 +193,36 @@ stop_previous_server() {
|
||||
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() {
|
||||
if [ "$SKIP_FRONTEND" -eq 1 ]; then
|
||||
log "跳过前端构建(--skip-frontend)"
|
||||
@@ -185,12 +242,7 @@ build_frontend() {
|
||||
*) fail "未知 BOOSAPI_FRONTEND_PM: $frontend_pm(可选:auto / npm / bun)" ;;
|
||||
esac
|
||||
|
||||
if [ "$frontend_pm" = "bun" ]; then
|
||||
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
|
||||
install_frontend_dependencies "$frontend_pm"
|
||||
|
||||
local fingerprint_file="$ROOT_DIR/dist/web/.boosapi-build-fingerprint"
|
||||
local fingerprint
|
||||
@@ -223,13 +275,36 @@ build_frontend() {
|
||||
if [ "$frontend_pm" = "bun" ]; then
|
||||
bunx --bun vite build
|
||||
else
|
||||
npx vite build
|
||||
pnpm exec vite build
|
||||
fi
|
||||
mkdir -p "$ROOT_DIR/dist/web"
|
||||
printf '%s\n' "$fingerprint" > "$fingerprint_file"
|
||||
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() {
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
fail "未找到 cargo,请先安装 Rust 工具链"
|
||||
@@ -264,6 +339,29 @@ build_backend() {
|
||||
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() {
|
||||
if [ -n "${STARTED_PID:-}" ] && is_running_pid "$STARTED_PID"; then
|
||||
log "停止本次启动的后端进程: $STARTED_PID"
|
||||
@@ -282,10 +380,18 @@ fi
|
||||
|
||||
if [ "$BACKEND_ONLY" -ne 1 ]; then
|
||||
build_frontend
|
||||
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
|
||||
ensure_workflow_ui_for_embedding
|
||||
fi
|
||||
fi
|
||||
|
||||
build_backend
|
||||
|
||||
if [ "$PRODUCTION_BUNDLE" -eq 1 ]; then
|
||||
build_production_bundle
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$NO_RUN" -eq 1 ]; then
|
||||
log "跳过启动(--no-run)"
|
||||
exit 0
|
||||
|
||||
+3
-1
@@ -35,7 +35,9 @@
|
||||
"docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173",
|
||||
"docs:build": "vitepress build docs",
|
||||
"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:watch": "vitest --root ."
|
||||
},
|
||||
|
||||
@@ -4,10 +4,34 @@ import { spawn } from 'child_process';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
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 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 = {}) {
|
||||
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);
|
||||
const DIR = REPO ? CACHE_DIR : SUBMODULE_DIR;
|
||||
if (REPO) {
|
||||
function selectedVariants() {
|
||||
if (VARIANT === 'all') return Object.entries(variants);
|
||||
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.rm(CACHE_DIR, { recursive: true, force: true });
|
||||
await run('git', ['clone', '--depth', '1', '--branch', REF, REPO, CACHE_DIR]);
|
||||
} else if (!hasSubmodule) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
|
||||
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']);
|
||||
}
|
||||
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 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, config] of selectedVariants()) {
|
||||
await buildVariant(name, config);
|
||||
}
|
||||
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
@@ -22,6 +22,7 @@ use axum::{
|
||||
routing::{any, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use include_dir::{include_dir, Dir};
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
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>>>> =
|
||||
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)]
|
||||
enum WorkflowCaller {
|
||||
@@ -136,7 +142,6 @@ pub fn routes() -> Router<AppState> {
|
||||
"/workflow-ui/api/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/history",
|
||||
@@ -157,6 +162,7 @@ pub fn routes() -> Router<AppState> {
|
||||
"/workflow-ui/api/view_metadata/{folder}",
|
||||
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/internal/logs", get(internal_logs))
|
||||
.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))
|
||||
}
|
||||
|
||||
fn workflow_ui_root() -> PathBuf {
|
||||
std::env::var("BOOSAPI_WORKFLOW_UI_DIR")
|
||||
fn workflow_ui_root_for_config(config: &crate::config::ComfyUIConfig) -> PathBuf {
|
||||
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)
|
||||
.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> {
|
||||
@@ -192,6 +219,7 @@ async fn workflow_ui_runtime(State(state): State<AppState>) -> Json<Value> {
|
||||
Json(json!({
|
||||
"mode": mode,
|
||||
"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 {
|
||||
let choices = if options.is_empty() {
|
||||
vec![fallback.to_string()]
|
||||
@@ -1078,6 +1157,7 @@ fn builtin_model_folders() -> Vec<&'static str> {
|
||||
|
||||
fn inject_boosapi_model_folders(value: &mut Value) {
|
||||
let Some(items) = value.as_array_mut() else {
|
||||
*value = workflow_model_folder_payload();
|
||||
return;
|
||||
};
|
||||
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 {
|
||||
prompt
|
||||
.as_object()
|
||||
@@ -1120,12 +1228,19 @@ fn prompt_contains_boosapi_nodes(prompt: &Value) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn workflow_ui_index() -> Html<String> {
|
||||
let dir = workflow_ui_root();
|
||||
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)),
|
||||
Err(_) => Html(
|
||||
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>
|
||||
@@ -1143,18 +1258,22 @@ async fn workflow_ui_index() -> Html<String> {
|
||||
<div class="wrap">
|
||||
<div class="box">
|
||||
<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>
|
||||
</body>
|
||||
</html>"#
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn workflow_ui_static(Path(path): Path<String>) -> AppResult<Response> {
|
||||
let dir = workflow_ui_root();
|
||||
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 == "templates/default.json" {
|
||||
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() {
|
||||
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().await.into_response());
|
||||
return Ok(workflow_ui_index(State(state)).await.into_response());
|
||||
}
|
||||
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> {
|
||||
match tokio::fs::read(&path).await {
|
||||
Ok(bytes) => {
|
||||
@@ -2107,9 +2264,26 @@ async fn native_workflow_view_response(
|
||||
state: &AppState,
|
||||
caller: &WorkflowCaller,
|
||||
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> {
|
||||
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()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(format!("ComfyUI backend unreachable: {err}")))?;
|
||||
@@ -2125,7 +2299,8 @@ async fn native_workflow_view_response(
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
|
||||
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((
|
||||
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(
|
||||
state: &AppState,
|
||||
caller: &WorkflowCaller,
|
||||
path: &str,
|
||||
params: &HashMap<String, String>,
|
||||
content_type: &str,
|
||||
bytes: &[u8],
|
||||
@@ -2159,13 +2335,17 @@ async fn archive_native_workflow_asset(
|
||||
"SELECT COUNT(*)
|
||||
FROM workspace_assets
|
||||
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 json_extract(metadata, '$.workflowUi.filename') = ?
|
||||
AND json_extract(metadata, '$.workflowUi.type') = ?
|
||||
AND json_extract(metadata, '$.workflowUi.subfolder') = ?",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(user_id.is_none())
|
||||
.bind(user_id.is_none())
|
||||
.bind(user_id)
|
||||
.bind(source)
|
||||
.bind(filename)
|
||||
@@ -2191,6 +2371,7 @@ async fn archive_native_workflow_asset(
|
||||
"filename": filename,
|
||||
"type": params.get("type").map(String::as_str).unwrap_or("output"),
|
||||
"subfolder": params.get("subfolder").map(String::as_str).unwrap_or(""),
|
||||
"endpoint": path,
|
||||
}
|
||||
}),
|
||||
};
|
||||
@@ -2778,20 +2959,17 @@ async fn experiment_models(
|
||||
) -> AppResult<Json<Value>> {
|
||||
if state.config.comfyui.uses_native_backend() {
|
||||
let _ = auth_caller_optional(&state, &headers);
|
||||
let mut value = native_workflow_json(&state, Method::GET, "/experiment/models", None)
|
||||
.await?
|
||||
.0;
|
||||
inject_boosapi_model_folders(&mut value);
|
||||
let value =
|
||||
match native_workflow_json(&state, Method::GET, "/experiment/models", None).await {
|
||||
Ok(value) => native_model_folder_payload_or_fallback(value.0),
|
||||
Err(_) => workflow_model_folder_payload(),
|
||||
};
|
||||
return Ok(Json(value));
|
||||
}
|
||||
let caller =
|
||||
auth_caller_optional(&state, &headers).unwrap_or(WorkflowCaller::User { user_id: 0 });
|
||||
let _ = caller;
|
||||
let items = builtin_model_folders()
|
||||
.into_iter()
|
||||
.map(|name| json!({ "name": name }))
|
||||
.collect::<Vec<_>>();
|
||||
Ok(Json(Value::Array(items)))
|
||||
Ok(Json(workflow_model_folder_payload()))
|
||||
}
|
||||
|
||||
async fn experiment_models_in_folder(
|
||||
@@ -2811,8 +2989,24 @@ async fn experiment_models_in_folder(
|
||||
.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));
|
||||
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 =
|
||||
auth_caller_optional(&state, &headers).unwrap_or(WorkflowCaller::User { user_id: 0 });
|
||||
@@ -2889,6 +3083,19 @@ async fn view_video(
|
||||
Query(query): Query<ViewQuery>,
|
||||
) -> AppResult<Response> {
|
||||
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, ¶ms).await;
|
||||
}
|
||||
let user_id = caller_user_id(&caller);
|
||||
let filename = query.filename.unwrap_or_default();
|
||||
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">
|
||||
.boosapi-hidden-comfy-item { display: none !important; }
|
||||
</style>
|
||||
<script>
|
||||
(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 = () => {
|
||||
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);
|
||||
@@ -3311,20 +3486,6 @@ fn inject_bootstrap(html: String) -> String {
|
||||
});
|
||||
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 normalized = String(value || '').trim().toLowerCase();
|
||||
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;
|
||||
const patchWorkflowShell = () => {
|
||||
shellPatchQueued = false;
|
||||
hideComfyUiAboutItems();
|
||||
patchComfyUiCommands();
|
||||
patchWorkflowConnectionTypes();
|
||||
startWorkflowSlotNormalizer();
|
||||
patchModelLibraryClicks();
|
||||
|
||||
+19
-4
@@ -168,12 +168,13 @@ async fn list_assets(
|
||||
WorkspaceCaller::Admin => requested_user_id,
|
||||
WorkspaceCaller::User { user_id } => Some(user_id),
|
||||
};
|
||||
let is_admin = matches!(caller, WorkspaceCaller::Admin);
|
||||
|
||||
let items = sqlx::query(
|
||||
"SELECT *
|
||||
FROM workspace_assets
|
||||
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 kind = ?)
|
||||
AND (
|
||||
@@ -186,8 +187,11 @@ async fn list_assets(
|
||||
ORDER BY datetime(created_at) DESC, id DESC
|
||||
LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(is_admin)
|
||||
.bind(scoped_user_id)
|
||||
.bind(scoped_user_id)
|
||||
.bind(is_admin)
|
||||
.bind(scoped_user_id)
|
||||
.bind(&source)
|
||||
.bind(&source)
|
||||
.bind(&kind)
|
||||
@@ -207,7 +211,7 @@ async fn list_assets(
|
||||
"SELECT COUNT(*)
|
||||
FROM workspace_assets
|
||||
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 kind = ?)
|
||||
AND (
|
||||
@@ -218,8 +222,11 @@ async fn list_assets(
|
||||
OR source LIKE ?
|
||||
)",
|
||||
)
|
||||
.bind(is_admin)
|
||||
.bind(scoped_user_id)
|
||||
.bind(scoped_user_id)
|
||||
.bind(is_admin)
|
||||
.bind(scoped_user_id)
|
||||
.bind(&source)
|
||||
.bind(&source)
|
||||
.bind(&kind)
|
||||
@@ -250,17 +257,21 @@ async fn get_asset_row(
|
||||
WorkspaceCaller::Admin => None,
|
||||
WorkspaceCaller::User { user_id } => Some(user_id),
|
||||
};
|
||||
let is_admin = matches!(caller, WorkspaceCaller::Admin);
|
||||
let row = sqlx::query(
|
||||
"SELECT *
|
||||
FROM workspace_assets
|
||||
WHERE public_id = ?
|
||||
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",
|
||||
)
|
||||
.bind(public_id)
|
||||
.bind(is_admin)
|
||||
.bind(scoped_user_id)
|
||||
.bind(scoped_user_id)
|
||||
.bind(is_admin)
|
||||
.bind(scoped_user_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Workspace asset not found".into()))?;
|
||||
@@ -287,16 +298,20 @@ async fn delete_asset(
|
||||
WorkspaceCaller::Admin => None,
|
||||
WorkspaceCaller::User { user_id } => Some(user_id),
|
||||
};
|
||||
let is_admin = matches!(caller, WorkspaceCaller::Admin);
|
||||
let result = sqlx::query(
|
||||
"UPDATE workspace_assets
|
||||
SET deleted_at = datetime('now'), updated_at = datetime('now')
|
||||
WHERE public_id = ?
|
||||
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(is_admin)
|
||||
.bind(scoped_user_id)
|
||||
.bind(scoped_user_id)
|
||||
.bind(is_admin)
|
||||
.bind(scoped_user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
|
||||
@@ -138,6 +138,20 @@ function resolveStoredProfile(): UserProfile {
|
||||
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({
|
||||
open,
|
||||
profile,
|
||||
@@ -560,6 +574,35 @@ function AppShell() {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [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(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
|
||||
|
||||
@@ -425,7 +425,9 @@ export default function Workspace() {
|
||||
</button>
|
||||
)}
|
||||
<div style={detailMetaStyle}>
|
||||
{audience !== 'user' && (
|
||||
<div><span>用户</span><strong>{selected.userId ?? '管理员/系统'}</strong></div>
|
||||
)}
|
||||
<div><span>模型</span><strong>{selected.model || '未记录'}</strong></div>
|
||||
<div><span>大小</span><strong>{formatBytes(selected.sizeBytes)}</strong></div>
|
||||
<div><span>文件</span><strong>{selected.hasLocalFile ? '本地归档' : '远程引用'}</strong></div>
|
||||
|
||||
Reference in New Issue
Block a user