0.2 在线版本打通

This commit is contained in:
liaibo
2026-01-15 20:54:21 +08:00
parent 725a60d3aa
commit 94957dc361
1596 changed files with 153254 additions and 309 deletions
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env node
/**
* 一键启动:wolai-backend + 桌面端(Electron + 内置 Next standalone
*
* 目的:
* - 本地联调无需反复安装 NSIS 安装包
* - Electron 走内置 Next(更接近安装包运行方式)
*
* 可选环境变量:
* - SKIP_DESKTOP_NEXT_BUILD=1:跳过 build:desktop:next(仅启动)
* - PYTHON_BIN:默认 python
* - BACKEND_CMD:覆盖后端启动命令
* - ELECTRON_CMD:覆盖 Electron 启动命令
* - MNOTE_DATA_DIR:覆盖桌面端 data 目录(默认 <repo>/data-desktop-test
*/
const { spawnSync, spawn } = require("child_process");
const fs = require("fs");
const net = require("net");
const path = require("path");
const rootDir = path.resolve(__dirname, "..");
const backendDir = path.join(rootDir, "wolai-backend");
const pythonBin = process.env.PYTHON_BIN || "python";
const dataDir = process.env.MNOTE_DATA_DIR || path.join(rootDir, "data-desktop-test");
const backendCmd =
process.env.BACKEND_CMD || `${pythonBin} -m uvicorn app.main:app --reload --host 127.0.0.1 --port 8000`;
const electronCmd = process.env.ELECTRON_CMD || "electron desktop-electron/main.js";
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;
}, {});
}
function runOrExit(command, cwd, env) {
const r = spawnSync(command, {
cwd,
env,
stdio: "inherit",
shell: true,
});
if (r.status !== 0) process.exit(r.status ?? 1);
}
async function waitForPort(host, port, timeoutMs = 20_000) {
const start = Date.now();
// eslint-disable-next-line no-constant-condition
while (true) {
const ok = await new Promise((resolve) => {
const socket = net.createConnection({ host, port });
const timer = setTimeout(() => {
socket.destroy();
resolve(false);
}, 500);
socket.once("connect", () => {
clearTimeout(timer);
socket.end();
resolve(true);
});
socket.once("error", () => {
clearTimeout(timer);
resolve(false);
});
});
if (ok) return;
if (Date.now() - start > timeoutMs) {
throw new Error(`等待端口超时:${host}:${port}`);
}
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => setTimeout(r, 120));
}
}
async function main() {
const envMerged = {
...process.env,
...loadEnvFile(path.join(rootDir, ".env.local")),
...loadEnvFile(path.join(rootDir, ".env")),
...loadEnvFile(path.join(backendDir, ".env")),
PYTHONUTF8: "1",
MNOTE_DATA_DIR: dataDir,
};
const skipBuild =
(process.env.SKIP_DESKTOP_NEXT_BUILD || "").toLowerCase() === "1" ||
(process.env.SKIP_DESKTOP_NEXT_BUILD || "").toLowerCase() === "true";
if (!skipBuild) {
console.log("[desktop-local] 构建桌面端内置 Nextstandalone)…");
runOrExit("pnpm run build:desktop:next", rootDir, envMerged);
} else {
console.log("[desktop-local] 已跳过 build:desktop:nextSKIP_DESKTOP_NEXT_BUILD=1");
}
console.log(`[desktop-local] data 目录:${dataDir}`);
console.log(`[desktop-local] 后端命令:${backendCmd}`);
console.log(`[desktop-local] Electron 命令:${electronCmd}`);
const children = [];
let shuttingDown = false;
const shutdown = () => {
if (shuttingDown) return;
shuttingDown = true;
console.log("[desktop-local] 正在关闭子进程…");
for (const child of children) {
try {
if (!child.killed) child.kill("SIGINT");
} catch {}
}
setTimeout(() => process.exit(0), 200);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
const backend = spawn(backendCmd, {
cwd: backendDir,
env: envMerged,
stdio: "inherit",
shell: true,
});
children.push(backend);
backend.on("exit", () => shutdown());
await waitForPort("127.0.0.1", 8000).catch(() => {
console.log("[desktop-local] 提示:未检测到后端 8000 端口就绪(可能仍在启动或启动失败)。");
});
const electron = spawn(electronCmd, {
cwd: rootDir,
env: envMerged,
stdio: "inherit",
shell: true,
});
children.push(electron);
electron.on("exit", () => shutdown());
}
main().catch((err) => {
console.error(err instanceof Error ? err.stack : String(err));
process.exit(1);
});
+56 -5
View File
@@ -157,8 +157,35 @@ function requireFile(filePath, hint) {
}
}
function ensureStandaloneStatic(frontendDir) {
// 说明:当 next.config.ts 配置了 output: "standalone" 时,推荐使用
// `node .next/standalone/server.js` 启动。
// 但 Next 不会自动把 `.next/static` 放入 `.next/standalone/.next/static`
// 若缺失会导致 `/_next/static/*` 404(远程访问尤其明显)。
const standaloneServer = path.join(frontendDir, ".next", "standalone", "server.js");
if (!fs.existsSync(standaloneServer)) return false;
const src = path.join(frontendDir, ".next", "static");
const dest = path.join(frontendDir, ".next", "standalone", ".next", "static");
if (!fs.existsSync(src)) return true;
try {
fs.mkdirSync(path.dirname(dest), { recursive: true });
// Node 16+fs.cpSync
fs.cpSync(src, dest, { recursive: true });
return true;
} catch (err) {
logPrefix(
"frontend",
`复制 .next/static 到 standalone 失败:${err instanceof Error ? err.message : String(err)}`,
);
// 失败时不强制退出,仍尝试启动(方便你自行排查)
return true;
}
}
async function main() {
const frontendProdEnv = path.join(frontendDir, ".env.production.local");
const frontendProdEnv = path.join(frontendDir, ".env.production.local");
// 说明:Next.js 在 production 也会加载 wolai-frontend/.env.local。
// 为避免被本地开发环境(可能指向远端 Supabase)的配置污染,这里把“服务端专用”的关键 env 显式注入。
@@ -181,6 +208,21 @@ async function main() {
NEXT_TELEMETRY_DISABLED: "1",
};
// Next 的 API Route 也会用到这些“内部服务”环境变量(Search / MinerU / LightRAG 等)。
// 规则:从仓库根目录 .env.local/.env 读取并注入,避免 production server 缺少配置。
const passthroughKeys = [
"MINERU_ENDPOINT",
"LIGHTRAG_URL",
"LIGHTRAG_API_KEY",
"SEARXNG_BASE_URL",
"SEARXNG_API_TOKEN",
];
for (const key of passthroughKeys) {
if (envRoot[key] && !envFrontend[key]) {
envFrontend[key] = envRoot[key];
}
}
// Next 服务端(API Route)需要 service role 才能生成签名 URL、清理资源等。
if (envRoot.SUPABASE_SERVICE_ROLE_KEY) {
envFrontend.SUPABASE_SERVICE_ROLE_KEY = envRoot.SUPABASE_SERVICE_ROLE_KEY;
@@ -225,6 +267,8 @@ async function main() {
logPrefix("frontend", "已跳过 buildSKIP_FRONTEND_BUILD=1");
}
const useStandalone = ensureStandaloneStatic(frontendDir);
// 预检查:提醒必要端口是否已就绪(不强制退出,避免误伤已有进程)
const portChecks = [
{ name: "frontend", port: frontendPort },
@@ -244,12 +288,19 @@ async function main() {
}
// 启动前端(production
const envFrontendServer = {
...envFrontend,
PORT: String(frontendPort),
HOSTNAME: "0.0.0.0",
};
spawnTask({
name: "frontend",
// Windows 下 pnpm 对 `start -- ...` 的参数转发偶发不稳定;用 `pnpm exec next start` 更可靠
command: `pnpm -C wolai-frontend exec next start -p ${frontendPort} -H 0.0.0.0`,
cwd: rootDir,
env: envFrontend,
// 说明:next.config.ts 配置了 output: "standalone" 时,应使用 standalone server 启动,避免 next start 行为不一致
command: useStandalone
? "node .next/standalone/server.js"
: `pnpm -C wolai-frontend exec next start -p ${frontendPort} -H 0.0.0.0`,
cwd: useStandalone ? frontendDir : rootDir,
env: envFrontendServer,
});
// 启动 wolai-backendFastAPI
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env node
/**
* 生成桌面端内置的 Next standalone 产物到 `desktop-electron/desktop-next`。
*
* 目标:
* - 打包时不依赖用户机器上的 pnpm / node 环境即可运行(Electron 自身携带 Node)。
* - 运行期文件读写落到安装目录的 data/(由 Electron 注入 MNOTE_DATA_DIR)。
*
* 注意:需要 wolai-frontend 的 next.config.ts 开启 output: "standalone"。
*/
const { spawnSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const rootDir = path.resolve(__dirname, "..");
const frontendDir = path.join(rootDir, "wolai-frontend");
const outDir = path.join(rootDir, "desktop-electron", "desktop-next");
function parseDotenv(filePath) {
if (!fs.existsSync(filePath)) return {};
const raw = fs.readFileSync(filePath, { encoding: "utf8" });
const out = {};
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const idx = trimmed.indexOf("=");
if (idx === -1) continue;
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
if (!key) continue;
out[key] = value;
}
return out;
}
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function rmDir(dir) {
fs.rmSync(dir, { recursive: true, force: true });
}
function run(command, cwd, env) {
const r = spawnSync(command, {
cwd,
env: { ...process.env, ...env },
stdio: "inherit",
shell: true,
});
if (r.status !== 0) {
process.exit(r.status ?? 1);
}
}
function assertExists(filePath, hint) {
if (!fs.existsSync(filePath)) {
console.error(`\n[prepare-desktop-next] 缺少:${filePath}`);
if (hint) console.error(`[prepare-desktop-next] 提示:${hint}`);
process.exit(1);
}
}
function copyDir(from, to) {
// 关键:Next standalone 在 pnpm 环境下可能包含大量 symlink/junction。
// electron-builder 在复制 extraResources 时对链接目录处理不稳定,
// 因此这里强制“解引用复制”,把真实文件复制出来,确保运行期能 require('next')。
fs.cpSync(from, to, { recursive: true, force: true, dereference: true });
}
function main() {
console.log("[prepare-desktop-next] 开始构建 wolai-frontendstandalone)…");
// 说明:桌面端默认不走 Cloudflare(避免 NEXT_PUBLIC_* 写死导致“像网页版一样卡”)。
// 优先读取 wolai-frontend/.env.local,并允许用 wolai-frontend/.env.desktop.local 覆盖。
const desktopEnv = {
...parseDotenv(path.join(frontendDir, ".env.local")),
...parseDotenv(path.join(frontendDir, ".env.desktop.local")),
};
console.log("[prepare-desktop-next] 桌面端 env 摘要:", {
NEXT_PUBLIC_SUPABASE_URL: desktopEnv.NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_BACKEND_URL: desktopEnv.NEXT_PUBLIC_BACKEND_URL,
NEXT_PUBLIC_ONLYOFFICE_BASE_URL: desktopEnv.NEXT_PUBLIC_ONLYOFFICE_BASE_URL,
});
run("pnpm -C wolai-frontend build", rootDir, {
NODE_ENV: "production",
NEXT_TELEMETRY_DISABLED: "1",
...desktopEnv,
});
const standaloneDir = path.join(frontendDir, ".next", "standalone");
const staticDir = path.join(frontendDir, ".next", "static");
const publicDir = path.join(frontendDir, "public");
assertExists(
path.join(standaloneDir, "server.js"),
'请在 wolai-frontend/next.config.ts 增加 `output: "standalone"` 后重试。',
);
assertExists(staticDir, "Next build 未生成 .next/static(可能构建失败或目录异常)。");
assertExists(publicDir, "wolai-frontend/public 不存在。");
console.log(`[prepare-desktop-next] 清理旧产物:${outDir}`);
rmDir(outDir);
ensureDir(outDir);
console.log("[prepare-desktop-next] 复制 standalone …");
copyDir(standaloneDir, outDir);
console.log("[prepare-desktop-next] 补齐 .next/static …");
ensureDir(path.join(outDir, ".next"));
copyDir(staticDir, path.join(outDir, ".next", "static"));
console.log("[prepare-desktop-next] 补齐 public …");
copyDir(publicDir, path.join(outDir, "public"));
console.log("[prepare-desktop-next] 完成。");
}
main();