perf(portable): U盘启动加速 — 缓存搬本机 + 启动首屏 + 首轮预热

便携版从 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>
This commit is contained in:
hfshfg
2026-06-17 16:38:05 +08:00
parent 29238fc423
commit f715aef9c9
7 changed files with 520 additions and 29 deletions

91
portable/lib/prewarm.mjs Normal file
View File

@@ -0,0 +1,91 @@
// 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' };
}
// 依次唤醒statusgateway/config→ modelsprovider 鉴权 + 模型目录)
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); });
}