Files
mnote/scripts/desktop-hot.js
T
lix-2026 3f43020603 feat(mnote-web): add local file read support to mnote.doc.fetch + WS push design doc
## mnote.doc.fetch: local file path support
- Detect local file paths (starting with `/` or `./`) and bypass Convex aggregate
- Read .md file directly via fs::read_to_string
- Return source: "local_fs" in response
- Support maxChars truncation for local files
- Online Convex path unchanged

## Design: WS push migration (3-14)
- New: design/03-rust-web/process/3-14-rust-web-tree-realtime-ws-push-v1.md
- Documents commit 9d8e361e WebSocket push migration rationale,
  architecture, and verification

## Updated design doc references
- 3-3: Mark SSE as fallback transport (WS push is primary)
- 08 checklist: Note WS push in Tree Realtime section
- AGENTS.md: Update tree/realtime references to reflect WS push

## Housekeeping
- desktop-hot.js: remove 3 stale log messages about disabled services
- page_ai_workflow.rs: fix 5 warnings (unused import, dead_code)
2026-05-17 15:57:40 +08:00

641 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* 热启动 mnote-web 单入口,以及按需启用的 FastAPI / Celery。
* 可使用以下环境变量调整行为:
* - FRONTEND_CMD:覆盖历史 Next 启动命令,仅在显式启用 legacy compat 或跳过 Rust gateway 时生效
* - ENABLE_BACKEND:设为 "1" or "true" 时启用默认 FastAPI 后端
* - BACKEND_CMD:覆盖 FastAPI 启动命令;设置后即视为显式启用后端
* - SKIP_BACKEND:设为 "1" or "true" 可强制跳过 FastAPI 后端
* - ENABLE_CELERY:设为 "1" or "true" 时启用默认 Celery worker
* - CELERY_CMD:覆盖 Celery 启动命令;设置后即视为显式启用 Celery
* - CELERY_POOL:只在 CELERY_CMD 未覆盖时生效,设置 Celery worker poolWindows 默认 "solo",其他平台默认使用 Celery 自身默认值
* - 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。
* - MNOTE_WEB_SKIP_GATEWAY:设为 "1" or "true" 临时恢复旧 Next 3000 入口。
* - MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT:已废弃;desktop:hot 默认不再启动 Next legacy upstream。
* - NEXT_LEGACY_PORT:显式启用 legacy compat 时的 Next upstream 端口,默认 3100。
* - SKIP_NEXT_LEGACY:兼容旧环境变量;设为 "1" or "true" 时强制只启动 Rust gateway。
*/
const { spawn, execSync } = 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");
function resolveBackendExecutable(envName, fallbackName) {
const fromEnv = (process.env[envName] || "").trim();
if (fromEnv) return fromEnv;
const isWin = process.platform === "win32";
const candidates = isWin
? [
path.join(backendDir, ".venv", "Scripts", `${fallbackName}.exe`),
path.join(backendDir, ".venv312", "Scripts", `${fallbackName}.exe`),
path.join(backendDir, "venv", "Scripts", `${fallbackName}.exe`),
]
: [
path.join(backendDir, ".venv-linux", "bin", fallbackName),
path.join(backendDir, ".venv", "bin", fallbackName),
path.join(backendDir, "venv", "bin", fallbackName),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return fallbackName;
}
const pythonBin = resolveBackendExecutable("PYTHON_BIN", "python");
const celeryBin = resolveBackendExecutable("CELERY_BIN", "celery");
const celeryPoolFromEnv = (process.env.CELERY_POOL || "").trim();
const celeryCmdFromEnv = (process.env.CELERY_CMD || "").trim();
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379/0";
const backendPortFromEnv = Number(process.env.BACKEND_PORT || 8000);
function isEnabledEnv(value) {
const normalized = String(value || "").toLowerCase();
return normalized === "1" || normalized === "true";
}
function shouldStartCelery(env = process.env) {
if (isEnabledEnv(env.SKIP_CELERY)) return false;
if (String(env.CELERY_CMD || "").trim()) return true;
return isEnabledEnv(env.ENABLE_CELERY);
}
function shouldStartBackend(env = process.env) {
if (isEnabledEnv(env.SKIP_BACKEND)) return false;
if (String(env.BACKEND_CMD || "").trim()) return true;
return isEnabledEnv(env.ENABLE_BACKEND);
}
function resolveRuntimePlan(env = process.env) {
const frontendPort = Number(env.FRONTEND_PORT || 3000);
const nextLegacyPort = Number(env.NEXT_LEGACY_PORT || 3100);
const skipGateway = isEnabledEnv(env.MNOTE_WEB_SKIP_GATEWAY);
const skipNextLegacy = !skipGateway;
const publicPort = Number.isFinite(frontendPort) ? Math.floor(frontendPort) : 3000;
const legacyPort = Number.isFinite(nextLegacyPort) ? Math.floor(nextLegacyPort) : 3100;
const legacyUrl = `http://127.0.0.1:${legacyPort}`;
const legacyCompatEnabled = "0";
return {
skipGateway,
skipNextLegacy,
publicPort,
legacyPort,
publicUrl: `http://localhost:${publicPort}`,
legacyUrl,
frontendTaskName: skipGateway ? "frontend" : null,
frontendCommand: env.FRONTEND_CMD || `pnpm dev -p ${skipGateway ? publicPort : legacyPort}`,
mnoteWebCommand: env.MNOTE_WEB_CMD || "cargo run -p mnote-web --bin mnote-web",
mnoteWebEnv: skipGateway
? {}
: {
MNOTE_WEB_BIND: env.MNOTE_WEB_BIND || `0.0.0.0:${publicPort}`,
MNOTE_WEB_PUBLIC_BIND: env.MNOTE_WEB_PUBLIC_BIND || `127.0.0.1:${publicPort}`,
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: legacyCompatEnabled,
},
};
}
const runtimePlan = resolveRuntimePlan(process.env);
const skipMnoteWebGateway = runtimePlan.skipGateway;
const frontendTasks = runtimePlan.frontendTaskName
? [
{
name: runtimePlan.frontendTaskName,
command: runtimePlan.frontendCommand,
cwd: frontendDir,
},
]
: [];
const tasks = [
...frontendTasks,
...(skipMnoteWebGateway
? []
: [
{
name: "mnote-web",
command:
process.env.MNOTE_WEB_CMD ||
runtimePlan.mnoteWebCommand,
cwd: path.join(rootDir, "rust"),
},
]),
...(shouldStartBackend(process.env)
? [
{
name: "backend",
command:
process.env.BACKEND_CMD ||
`${pythonBin} -m uvicorn app.main:app --reload --port 8000`,
cwd: backendDir,
},
]
: []),
];
function findTask(name) {
return tasks.find((task) => task.name === name);
}
const children = [];
let shuttingDown = false;
async function isPortFree(host, port, timeoutMs = 400) {
return await new Promise((resolve) => {
const socket = net.createConnection({ host, port });
const timer = setTimeout(() => {
socket.destroy();
resolve(true);
}, timeoutMs);
socket.once("connect", () => {
clearTimeout(timer);
socket.end();
resolve(false);
});
socket.once("error", () => {
clearTimeout(timer);
resolve(true);
});
});
}
async function findFreePort(startPort) {
let port = Number.isFinite(startPort) ? Math.floor(startPort) : 3000;
port = Math.max(1, Math.min(65535, port));
// 最多尝试 20 个端口,避免无限循环。
for (let i = 0; i < 20; i += 1) {
// eslint-disable-next-line no-await-in-loop
const free = await isPortFree("127.0.0.1", port);
if (free) return port;
port += 1;
}
return startPort;
}
function getListeningPidsByPort(port) {
const pids = new Set();
if (process.platform !== "win32") {
try {
const out = execSync(`ss -ltnp 'sport = :${port}'`, { encoding: "utf8" });
for (const match of out.matchAll(/pid=(\d+)/g)) {
const pid = Number(match[1]);
if (Number.isFinite(pid) && pid > 0) {
pids.add(pid);
}
}
} catch {
// ignore
}
if (pids.size > 0) {
return [...pids];
}
try {
const out = execSync(`lsof -nP -iTCP:${port} -sTCP:LISTEN -t`, { encoding: "utf8" });
for (const line of out.split(/\r?\n/)) {
const pid = Number(line.trim());
if (Number.isFinite(pid) && pid > 0) {
pids.add(pid);
}
}
} catch {
// ignore
}
return [...pids];
}
try {
// 说明:netstat 输出示例:
// TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 12345
const out = execSync("netstat -ano -p tcp", { encoding: "utf8" });
for (const line of out.split(/\r?\n/)) {
if (!line.includes(`:${port}`)) continue;
if (!/LISTENING/i.test(line)) continue;
const parts = line.trim().split(/\s+/);
const pid = Number(parts[parts.length - 1]);
if (Number.isFinite(pid) && pid > 0) {
pids.add(pid);
}
}
return [...pids];
} catch {
return [];
}
}
function getProcessNameByPid(pid) {
if (process.platform !== "win32") {
try {
return execSync(`ps -p ${pid} -o comm=`, { encoding: "utf8" }).trim();
} catch {
return "";
}
}
try {
// 输出为 CSV,示例:
// "Image Name","PID","Session Name","Session#","Mem Usage"
// "python.exe","12345","Console","1","12,345 K"
const out = execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, {
encoding: "utf8",
}).trim();
if (!out || /No tasks are running/i.test(out)) return "";
const firstLine = out.split(/\r?\n/)[0].trim();
if (!firstLine) return "";
const cells = firstLine
.split('","')
.map((s) => s.replace(/^"/, "").replace(/"$/, ""));
return (cells[0] || "").trim();
} catch {
return "";
}
}
function terminatePid(pid) {
if (process.platform === "win32") {
execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore" });
return;
}
try {
process.kill(pid, "SIGTERM");
} catch {
return;
}
}
function forceKillPid(pid) {
if (process.platform === "win32") {
execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore" });
return;
}
try {
process.kill(pid, "SIGKILL");
} catch {
// 进程可能已经退出。
}
}
async function ensurePortFree(port, nameForLog) {
const free = await isPortFree("127.0.0.1", port);
if (free) return true;
const pids = getListeningPidsByPort(port);
if (pids.length === 0) {
logPrefix(nameForLog, `检测到端口 ${port} 被占用,但无法定位 PID。`);
return false;
}
let killPids = pids;
// 安全策略:后端默认只结束 python 进程(避免误杀其他服务)。
if (nameForLog === "backend") {
killPids = pids.filter((pid) => {
const procName = getProcessNameByPid(pid).toLowerCase();
return procName.includes("python");
});
if (killPids.length === 0) {
logPrefix(
nameForLog,
`检测到端口 ${port} 被占用,但没有可安全结束的 python 进程(PIDs=${pids.join(", ")})。请手动释放端口后重试。`,
);
return false;
}
}
logPrefix(nameForLog, `检测到端口 ${port} 被占用,准备重启(结束旧进程):${killPids.join(", ")}`);
for (const pid of killPids) {
try {
terminatePid(pid);
} catch {
// ignore
}
}
// 等待端口释放
for (let i = 0; i < 20; i += 1) {
// eslint-disable-next-line no-await-in-loop
const ok = await isPortFree("127.0.0.1", port, 250);
if (ok) return true;
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => setTimeout(r, 150));
}
if (process.platform !== "win32") {
for (const pid of killPids) {
forceKillPid(pid);
}
for (let i = 0; i < 10; i += 1) {
// eslint-disable-next-line no-await-in-loop
const ok = await isPortFree("127.0.0.1", port, 250);
if (ok) return true;
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => setTimeout(r, 150));
}
}
logPrefix(nameForLog, `端口 ${port} 仍未释放,可能有其他程序占用。`);
return false;
}
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return {};
const content = fs.readFileSync(filePath, "utf8");
return content
.split(/\r?\n/)
.filter((line) => line.trim() && !line.trim().startsWith("#"))
.reduce((acc, line) => {
const idx = line.indexOf("=");
if (idx === -1) return acc;
const key = line.slice(0, idx).trim();
const value = line.slice(idx + 1).trim();
acc[key] = value;
return acc;
}, {});
}
// 约定:全局仅使用仓库根目录的 .env.all 作为配置来源(生产优先)。
const envAllPath = path.join(rootDir, ".env.all");
if (!fs.existsSync(envAllPath)) {
console.error("[system] 缺少 .env.all:请在仓库根目录创建全局唯一 env 文件后重试。");
process.exit(1);
}
const envFromAll = loadEnvFile(envAllPath);
const mergedEnv = {
...process.env,
...envFromAll,
};
function logPrefix(name, message) {
console.log(`[${name}] ${message}`);
}
function getDefaultCeleryPool() {
if (celeryPoolFromEnv) return celeryPoolFromEnv;
if (process.platform === "win32") return "solo";
return "";
}
function buildDefaultCeleryCommand() {
const pool = getDefaultCeleryPool();
const poolArg = pool ? ` --pool=${pool}` : "";
return `${celeryBin} -A app.workers.celery_app worker --loglevel=info${poolArg}`;
}
function startTask(task) {
logPrefix(task.name, `启动命令:${task.command}`);
const child = spawn(task.command, {
cwd: task.cwd,
stdio: "inherit",
shell: true,
env: mergedEnv,
});
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.pid) continue;
// Windows 下,shell 子进程常常无法可靠传播 SIGINT/SIGTERM 到孙进程(例如 uvicorn --reload)。
// 这里优先用 taskkill /T /F 确保整个进程树被结束,避免残留占用端口导致下次启动失败。
if (process.platform === "win32") {
try {
execSync(`taskkill /PID ${child.pid} /T /F`, { stdio: "ignore" });
continue;
} catch {
// fallback to signals
}
}
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() {
if (runtimePlan.frontendTaskName) {
// 说明:Next dev 在异常退出时可能残留 `.next/dev/lock`,会导致后续启动直接失败。
// 只有显式启动历史 Next 时才清理该 lock,避免默认热启动继续触碰旧前端目录。
const nextDevLockPath = path.join(frontendDir, ".next", "dev", "lock");
try {
if (fs.existsSync(nextDevLockPath)) {
fs.rmSync(nextDevLockPath, { force: true });
logPrefix("frontend", `检测到残留的 Next dev lock,已移除:${nextDevLockPath}`);
}
} catch (error) {
logPrefix("frontend", `尝试移除 Next dev lock 失败:${error.message}`);
}
}
// 说明:你外网绑定了 3000 端口,这里默认强制使用 3000。
// 如果检测到 3000 被占用,则自动结束旧进程后重启,以保证始终跑在 3000。
const desiredFrontendPort = runtimePlan.publicPort;
const frontendOwnerName = skipMnoteWebGateway ? "frontend" : "mnote-web";
const frontendPortOk = await ensurePortFree(desiredFrontendPort, frontendOwnerName);
if (!frontendPortOk) {
console.error(`前端端口 ${desiredFrontendPort} 无法释放,已中止启动。`);
process.exit(1);
}
const frontendPort = desiredFrontendPort;
const frontendUrl = `http://localhost:${frontendPort}`;
let nextLegacyPort = null;
if (runtimePlan.frontendTaskName === "next-legacy") {
nextLegacyPort = runtimePlan.legacyPort;
const nextLegacyPortOk = await ensurePortFree(nextLegacyPort, "next-legacy");
if (!nextLegacyPortOk) {
console.error(`Next legacy 端口 ${nextLegacyPort} 无法释放,已中止启动。`);
process.exit(1);
}
}
const frontendTask = runtimePlan.frontendTaskName ? findTask(runtimePlan.frontendTaskName) : null;
if (runtimePlan.frontendTaskName && !frontendTask) {
throw new Error(`缺少前端任务配置:${runtimePlan.frontendTaskName}`);
}
if (frontendTask) {
// 说明:在 Windows 的 cmd.exe 下,`pnpm dev -- -p 3000` 会把 `--` 原样传给 next,导致 next 把 `-p` 误当成目录。
// 用 `pnpm dev -p 3000` 在 PowerShell/cmd.exe 下都能正确传参。
frontendTask.command = runtimePlan.frontendCommand;
logPrefix(frontendTask.name, `前端目录:${frontendDir}`);
}
if (skipMnoteWebGateway) {
logPrefix("frontend", `前端地址:${frontendUrl}`);
} else {
logPrefix("mnote-web", `Rust gateway 公开入口:${frontendUrl}`);
if (runtimePlan.frontendTaskName === "next-legacy") {
logPrefix("next-legacy", `Next legacy upstream${runtimePlan.legacyUrl}`);
} else {
}
const gatewayTask = tasks.find((task) => task.name === "mnote-web");
if (gatewayTask) {
gatewayTask.command = runtimePlan.mnoteWebCommand;
}
Object.assign(mergedEnv, runtimePlan.mnoteWebEnv);
}
const desiredBackendPort = backendPortFromEnv;
if (shouldStartBackend(process.env) && !process.env.BACKEND_CMD) {
const backendPortOk = await ensurePortFree(desiredBackendPort, "backend");
if (!backendPortOk) {
console.error(`后端端口 ${desiredBackendPort} 无法释放,已中止启动。`);
process.exit(1);
}
const backendTask = findTask("backend");
if (!backendTask) {
throw new Error("缺少后端任务配置");
}
backendTask.command = `${pythonBin} -m uvicorn app.main:app --reload --port ${desiredBackendPort}`;
} else if (isEnabledEnv(process.env.SKIP_BACKEND)) {
logPrefix("backend", "已跳过 FastAPI 后端(SKIP_BACKEND=1)。");
} else if (!shouldStartBackend(process.env)) {
}
if (shouldStartCelery(process.env)) {
const defaultCeleryCommand = buildDefaultCeleryCommand();
const celeryTask = {
name: "celery",
command: celeryCmdFromEnv || defaultCeleryCommand,
cwd: backendDir,
};
if (celeryCmdFromEnv) {
tasks.push(celeryTask);
} else if (await checkRedisReachable(redisUrl)) {
const defaultPool = getDefaultCeleryPool();
if (defaultPool) {
logPrefix("celery", `未显式设置 CELERY_CMD,当前平台默认使用 worker pool${defaultPool}`);
}
tasks.push(celeryTask);
} else {
// Redis 未就绪时直接跳过 Celery,避免热调试流程整体退出。
logPrefix(
"celery",
`检测到 ${redisUrl} 无法连接,自动跳过 Celery。请先启动 Redis,或继续保持当前默认关闭策略。`,
);
}
} else if (isEnabledEnv(process.env.SKIP_CELERY)) {
logPrefix("celery", "已跳过 CelerySKIP_CELERY=1)。");
} else {
}
if (tasks.length === 0) {
console.error("未配置任何可运行的任务,检查环境变量设置。");
process.exit(1);
}
for (const task of tasks) {
startTask(task);
}
}
if (require.main === module) {
main().catch((error) => {
logPrefix("system", `启动失败:${error.message}`);
process.exit(1);
});
}
module.exports = {
ensurePortFree,
getListeningPidsByPort,
getProcessNameByPid,
isPortFree,
resolveRuntimePlan,
resolveBackendExecutable,
shouldStartBackend,
shouldStartCelery,
terminatePid,
};