288 lines
8.8 KiB
JavaScript
288 lines
8.8 KiB
JavaScript
#!/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);
|
|
});
|