便携版从 U 盘启动慢,瓶颈在 U 盘随机小写 IO + 首屏无反馈 + 首轮冷启动。 移植 v2 u-clawx 4.0 的 4 个可在纯脚本层复刻的手段(均零依赖、静默失败): - portable-cache.mjs: 把浏览器 user-data(几百 MB 随机小写)+ V8 编译缓存 从 U 盘搬到本机 SSD(win junction / mac symlink 重定向 .openclaw/browser, NODE_COMPILE_CACHE 落本机)。UUID 隔离让换盘符仍命中同一份缓存。 业务数据(openclaw.json/memory/账号)仍留 U 盘,便携性不变。 - loading.html: 双击即弹的启动首屏,自轮询 /ready,gateway 真就绪后自动跳 Dashboard——天然规避"gateway 没起就开 Dashboard 拒连"(issue #46/#48)。 - prewarm.mjs: gateway 首轮预热,后台静默唤醒 config/model 子系统。 - Windows-Start.bat: 等 config-server 改写死 timeout 为动态轮询 18788。 - wait-gateway.bat 退居兜底(首屏页负责主跳转)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
92 lines
3.6 KiB
JavaScript
92 lines
3.6 KiB
JavaScript
// prewarm.mjs — gateway 首轮预热(移植自 v2 u-clawx 4.0 的 first-turn-prewarm 思路)
|
||
//
|
||
// 问题:用户首次点"发送"时,gateway 才去加载配置、解析 provider 鉴权、拉模型列表,
|
||
// 慢 U 盘 + 冷启动下这一下要等好几秒。
|
||
// 方案:gateway 端口 LISTENING 后,后台静默地把这些"首轮才会触发"的子系统先唤醒一遍:
|
||
// 轮询 /ready → 命中后依次 GET /status、/models(带 token),让 config/model
|
||
// 缓存在 runtime 内存里热起来。用户真正发第一条消息时就不再等。
|
||
//
|
||
// 设计原则(与 lib/ 其它脚本一致):
|
||
// - 纯 Node、零依赖(只用 fetch)
|
||
// - 静默失败:任何错误都不抛、不影响 gateway
|
||
// - 后台 detach 跑(启动脚本用 start /B 或 &),绝不阻塞
|
||
// - 短超时、有上限,不会挂着不退
|
||
//
|
||
// CLI 用法(启动脚本后台调用):
|
||
// node prewarm.mjs <PORT> [TOKEN]
|
||
// 默认 PORT=18789, TOKEN=uclaw
|
||
|
||
const READY_TIMEOUT_MS = 90_000; // 最多等 gateway 就绪 90s(慢 U 盘首启可能要这么久)
|
||
const POLL_INTERVAL_MS = 1_000;
|
||
const STEP_TIMEOUT_MS = 8_000;
|
||
|
||
function log(msg) {
|
||
// 写 stderr,不污染可能被采集的 stdout;启动脚本一般 >nul 丢弃
|
||
try { process.stderr.write(`[prewarm] ${msg}\n`); } catch { /* 静默 */ }
|
||
}
|
||
|
||
async function httpGet(url, token, timeoutMs) {
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||
try {
|
||
const res = await fetch(url, {
|
||
signal: controller.signal,
|
||
headers: token ? { 'x-openclaw-token': token } : {},
|
||
});
|
||
// 读掉 body 让对端真正干完活(否则可能短路)
|
||
await res.text().catch(() => {});
|
||
return res.ok || res.status === 401; // 401 也算"服务在",说明端口活了
|
||
} catch {
|
||
return false;
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
async function waitReady(base, token, deadline) {
|
||
while (Date.now() < deadline) {
|
||
if (await httpGet(`${base}/ready`, token, 3_000)) return true;
|
||
if (await httpGet(`${base}/healthz`, token, 3_000)) return true;
|
||
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
||
}
|
||
return false;
|
||
}
|
||
|
||
export async function prewarm({ port = 18789, token = 'uclaw' } = {}) {
|
||
const base = `http://127.0.0.1:${port}`;
|
||
const deadline = Date.now() + READY_TIMEOUT_MS;
|
||
|
||
const ready = await waitReady(base, token, deadline);
|
||
if (!ready) {
|
||
log(`gateway not ready within ${READY_TIMEOUT_MS}ms, skip`);
|
||
return { ok: false, reason: 'not-ready' };
|
||
}
|
||
|
||
// 依次唤醒:status(gateway/config)→ models(provider 鉴权 + 模型目录)
|
||
const steps = ['/status', '/models'];
|
||
const failed = [];
|
||
const started = Date.now();
|
||
for (const path of steps) {
|
||
const t0 = Date.now();
|
||
const ok = await httpGet(`${base}${path}`, token, STEP_TIMEOUT_MS);
|
||
log(`step ${path} ${ok ? 'ok' : 'failed'} ${Date.now() - t0}ms`);
|
||
if (!ok) failed.push(path);
|
||
}
|
||
log(`done ${Date.now() - started}ms failed=${failed.length ? failed.join(',') : 'none'}`);
|
||
return { ok: true, failed };
|
||
}
|
||
|
||
import { pathToFileURL } from 'node:url';
|
||
const isMain = (() => {
|
||
try { return !!process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; }
|
||
catch { return false; }
|
||
})();
|
||
|
||
if (isMain) {
|
||
const port = parseInt(process.argv[2], 10) || parseInt(process.env.UCLAW_GATEWAY_PORT, 10) || 18789;
|
||
const token = process.argv[3] || process.env.UCLAW_GATEWAY_TOKEN || 'uclaw';
|
||
prewarm({ port, token })
|
||
.then(() => process.exit(0))
|
||
.catch((err) => { log(`fatal ${err && err.message}`); process.exit(0); });
|
||
}
|