Embed workflow UI and isolate workspace assets
This commit is contained in:
+114
-8
@@ -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
@@ -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 ."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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', ['submodule', 'update', '--init', '--recursive', 'vendor/ComfyUI_frontend']);
|
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', ['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}`);
|
|
||||||
|
|||||||
+274
-115
@@ -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") {
|
||||||
.map(PathBuf::from)
|
return PathBuf::from(path);
|
||||||
.unwrap_or_else(|_| PathBuf::from("dist/workflow-ui"))
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,13 +1228,20 @@ 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(_) => {
|
||||||
r#"<!DOCTYPE html>
|
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">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
@@ -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, ¶ms).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
@@ -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 {
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -425,7 +425,9 @@ export default function Workspace() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div style={detailMetaStyle}>
|
<div style={detailMetaStyle}>
|
||||||
<div><span>用户</span><strong>{selected.userId ?? '管理员/系统'}</strong></div>
|
{audience !== 'user' && (
|
||||||
|
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user