#!/usr/bin/env node /** * 同时热启动前端、FastAPI 与 Celery。 * 可使用以下环境变量调整行为: * - FRONTEND_CMD:覆盖前端启动命令,默认为 "pnpm dev" * - BACKEND_CMD:覆盖 FastAPI 启动命令,默认为 "python -m uvicorn app.main:app --reload --port 8000" * - CELERY_CMD:覆盖 Celery 启动命令,默认为 "celery -A app.workers.celery_app worker --loglevel=info" * - PYTHON_BIN:只在 BACKEND_CMD 未覆盖时,设置 Python 可执行文件,默认 "python" * - CELERY_BIN:只在 CELERY_CMD 未覆盖时,设置 Celery 可执行文件,默认 "celery" * - REDIS_URL:仅用于探测 Redis 是否就绪,默认 "redis://localhost:6379/0" * - SKIP_CELERY:设为 "1" or "true" 可跳过 Celery。 */ const { spawn } = require("child_process"); const path = require("path"); const net = require("net"); const { URL } = require("url"); const fs = require("fs"); const rootDir = path.resolve(__dirname, ".."); const frontendDir = path.join(rootDir, "wolai-frontend"); const backendDir = path.join(rootDir, "wolai-backend"); const mineruDir = path.join(rootDir, "services", "mineru"); const baseEnv = { ...process.env }; const frontendEnv = { ...baseEnv }; const backendEnv = { ...baseEnv }; const mineruEnv = { ...baseEnv }; function loadEnvFile(filePath, targetEnv, { overrideSupabase = false } = {}) { if (!fs.existsSync(filePath)) { return; } const content = fs.readFileSync(filePath, "utf8"); content.split(/\r?\n/).forEach((line) => { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) return; const eqIndex = trimmed.indexOf("="); if (eqIndex <= 0) return; const key = trimmed.slice(0, eqIndex).trim(); const value = trimmed.slice(eqIndex + 1).trim(); const hasValue = Boolean(targetEnv[key]); const shouldForce = overrideSupabase && key.startsWith("SUPABASE_") && (!hasValue || targetEnv[key].includes("supabase.co")); if (!hasValue || shouldForce) { targetEnv[key] = value; } }); } // 强制后端优先使用本地 .env 中的 Supabase 配置,避免系统环境变量指向线上实例。 loadEnvFile(path.join(backendDir, ".env"), backendEnv, { overrideSupabase: true }); loadEnvFile(path.join(rootDir, ".env.local"), backendEnv); loadEnvFile(path.join(rootDir, ".env.local"), frontendEnv); loadEnvFile(path.join(rootDir, ".env.local"), mineruEnv); function resolvePythonBin() { const suffixes = process.platform === "win32" ? [ ["venv", "Scripts", "python.exe"], [".venv", "Scripts", "python.exe"], ] : [ ["venv", "bin", "python3"], ["venv", "bin", "python"], [".venv", "bin", "python3"], [".venv", "bin", "python"], ]; const candidates = suffixes.map((parts) => path.join(backendDir, ...parts)); for (const candidate of candidates) { if (fs.existsSync(candidate)) { return candidate; } } return null; } const pythonBin = process.env.PYTHON_BIN || resolvePythonBin() || "python"; const celeryBin = process.env.CELERY_BIN || "celery"; const skipCelery = (process.env.SKIP_CELERY || "").toLowerCase() === "1" || (process.env.SKIP_CELERY || "").toLowerCase() === "true"; const celeryCmdFromEnv = process.env.CELERY_CMD; const redisUrl = process.env.REDIS_URL || "redis://localhost:6379/0"; const tasks = [ { name: "frontend", command: process.env.FRONTEND_CMD || "pnpm dev", cwd: frontendDir, env: frontendEnv, }, { name: "backend", command: process.env.BACKEND_CMD || `${pythonBin} -m uvicorn app.main:app --reload --port 8000`, cwd: backendDir, env: backendEnv, }, ]; function resolveMineru() { // 优先使用带 ROCm 的全局 venv,找不到再回退 services/mineru/.venv const rocmPython = path.join("F:\\", "rocm", ".venv-rocm312", "Scripts", "python.exe"); const localVenvPython = path.join(mineruDir, ".venv", "Scripts", "python.exe"); const pythonCandidates = [rocmPython, localVenvPython].filter((p) => fs.existsSync(p)); const venvPython = pythonCandidates[0]; const srcDir = path.join(mineruDir, "src"); if (!venvPython || !fs.existsSync(srcDir)) { logPrefix( "mineru", "未找到 MinerU 虚拟环境或源码,已跳过自动启动。请先运行 services/mineru/setup.ps1", ); return null; } const endpoint = mineruEnv.MINERU_ENDPOINT || "http://127.0.0.1:18888"; const url = new URL(endpoint); const host = url.hostname || "127.0.0.1"; const port = url.port || "18888"; const command = process.env.MINERU_CMD || `${venvPython} -m uvicorn mineru.cli.fast_api:app --host ${host} --port ${port}`; const modelPath = path.join(mineruDir, "models", "MinerU2.5-2509-1.2B"); const configPath = path.join(mineruDir, "mineru.json"); return { name: "mineru", command, cwd: srcDir, env: { ...mineruEnv, PYTHONPATH: srcDir, MINERU_ENDPOINT: endpoint, // 默认使用本地 VLM 模型 MINERU_TOOLS_CONFIG_JSON: configPath, MINERU_MODEL_SOURCE: "local", MINERU_MODEL_PATH: modelPath, MINERU_DEFAULT_BACKEND: "vlm-transformers", MINERU_TIMEOUT_SECONDS: mineruEnv.MINERU_TIMEOUT_SECONDS || "600", }, }; } const mineruTask = resolveMineru(); if (mineruTask) { tasks.unshift(mineruTask); backendEnv.MINERU_ENDPOINT = mineruTask.env.MINERU_ENDPOINT; } console.log(`[system] Python 解释器:${pythonBin}`); const children = []; let shuttingDown = false; function logPrefix(name, message) { console.log(`[${name}] ${message}`); } function startTask(task) { logPrefix(task.name, `启动命令:${task.command}`); const child = spawn(task.command, { cwd: task.cwd, stdio: "inherit", shell: true, env: task.env ? { ...task.env } : { ...process.env }, }); child.on("exit", (code, signal) => { if (shuttingDown) { return; } const status = signal !== null ? `因信号 ${signal} 退出` : `退出码 ${code ?? "null"}`; logPrefix(task.name, `进程结束(${status}),准备清理其它任务。`); shutdown(code ?? 0); }); child.on("error", (err) => { logPrefix(task.name, `启动失败:${err.message}`); shutdown(1); }); children.push(child); } function shutdown(code) { if (shuttingDown) { return; } shuttingDown = true; logPrefix("system", "收到终止信号,正在关闭所有子进程…"); for (const child of children) { if (!child.killed) { child.kill("SIGINT"); setTimeout(() => { if (!child.killed) { child.kill("SIGTERM"); } }, 5000); } } setTimeout(() => process.exit(code), 200); } process.on("SIGINT", () => shutdown(0)); process.on("SIGTERM", () => shutdown(0)); async function checkRedisReachable(urlString, timeoutMs = 2000) { try { const url = new URL(urlString); const host = url.hostname || "localhost"; const port = Number(url.port) || 6379; return await new Promise((resolve) => { const socket = net.createConnection({ host, port }); const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs); socket.once("connect", () => { clearTimeout(timer); socket.end(); resolve(true); }); socket.once("error", () => { clearTimeout(timer); resolve(false); }); }); } catch (error) { logPrefix("celery", `REDIS_URL (${urlString}) 解析失败:${error.message},跳过连通性检查。`); return true; } } async function main() { let celeryScheduled = false; if (!skipCelery) { const celeryTask = { name: "celery", command: celeryCmdFromEnv || `${celeryBin} -A app.workers.celery_app worker --loglevel=info`, cwd: backendDir, }; if (celeryCmdFromEnv) { tasks.push(celeryTask); celeryScheduled = true; } else if (await checkRedisReachable(redisUrl)) { tasks.push(celeryTask); celeryScheduled = true; } else { // Redis 未就绪时直接跳过 Celery,避免热调试流程整体退出。 logPrefix( "celery", `检测到 ${redisUrl} 无法连接,自动跳过 Celery。请先启动 Redis 或设置 SKIP_CELERY=1 显式跳过。`, ); } } if (!celeryScheduled) { backendEnv.CELERY_TASK_ALWAYS_EAGER = "1"; backendEnv.CELERY_TASK_EAGER_PROPAGATES = backendEnv.CELERY_TASK_EAGER_PROPAGATES || "1"; logPrefix( "backend", "未启动 Celery,自动启用 task_always_eager 模式用于同步执行后台任务。", ); } if (tasks.length === 0) { console.error("未配置任何可运行的任务,检查环境变量设置。"); process.exit(1); } for (const task of tasks) { startTask(task); } } main().catch((error) => { logPrefix("system", `启动失败:${error.message}`); process.exit(1); });