diff --git a/CLAUDE.md b/CLAUDE.md index eb05443..54af706 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,9 +126,24 @@ Pure-Node, zero-dependency `.mjs` modules (use `fetch` + `node:zlib` only). All | File | Purpose | |------|---------| | `check-update.mjs` / `publish-latest.mjs` | Portable self-update: check installed vs latest `OPENCLAW_VERSION`; publish helper. | -| `wait-gateway.bat` | Windows helper: poll until gateway port is LISTENING before opening the dashboard (fixes startup race). | +| `portable-cache.mjs` | **启动加速核心**:把"重 IO、可重建"的缓存从 U 盘搬到本机硬盘。算出本机缓存根(win `%LOCALAPPDATA%\U-Claw\` / mac `~/Library/Caches/U-Claw` / linux `$XDG_CACHE_HOME`,UUID 隔离让换盘符仍复用),输出 `NODE_COMPILE_CACHE` 路径,并把 `data/.openclaw/browser` 做成 junction(win)/symlink(mac) 指向本机盘——浏览器 user-data 的海量随机小写不再砸 U 盘。CLI 打印 `KEY=VALUE` 供启动脚本 source。静默失败:取不到就缓存留 U 盘照常启动。 | +| `prewarm.mjs` | gateway 首轮预热:端口就绪后后台静默 GET `/ready`→`/status`→`/models`(带 `x-openclaw-token`),把 config/model 子系统在 runtime 内存里热起来,用户首次点发送不再等。零依赖、短超时、后台 detach。 | +| `loading.html` | 启动首屏(splash):双击启动后立刻打开,给即时反馈消除"黑窗假死"。本页每秒 fetch `/ready`,gateway 真就绪后自动 `location.replace` 跳 Dashboard——天然规避"gateway 没起就开 Dashboard 拒连"(issue #46/#48)。端口经 `?port=` 传入。 | +| `wait-gateway.bat` | Windows 兜底:现由 `loading.html` 首屏轮询并自动跳转;本脚本退居兜底——万一首屏 `file://` fetch 被浏览器拦,仍轮询端口、就绪后开 Dashboard。 | | `maintain.sh` | Maintenance/diagnostics script. | +### 启动加速(吸收自 v2 u-clawx 4.0 的工程经验,2026-06-17) + +便携版从 U 盘启动慢,瓶颈在 **U 盘随机小写 IO** + **首屏无反馈** + **首轮冷启动**。移植 4.0 的 4 个可在纯脚本层复刻的手段: + +1. **缓存搬本机**(`portable-cache.mjs`):浏览器 user-data(OpenClaw 硬编码在 `CONFIG_DIR/browser/`,无单独环境变量,故用 junction/symlink 重定向)+ V8 编译缓存(`NODE_COMPILE_CACHE`)落本机 SSD。业务数据(`openclaw.json`、`memory`、账号)仍留 U 盘,便携性不变。UUID 隔离让 D:→E: 换盘符仍命中同一份本机缓存。 +2. **Node 编译缓存**:`openclaw.mjs` 本就调 `module.enableCompileCache()`,但默认落系统 temp(可能被清)。启动脚本显式把 `NODE_COMPILE_CACHE` 指向本机固定目录,二次启动稳定命中。 +3. **启动首屏**(`loading.html`):双击即弹,自轮询自跳转。 +4. **首轮预热**(`prewarm.mjs`):后台唤醒 config/model。 +5. **动态探测**:Windows 把写死的 `timeout /t 2`(等 config-server)改成轮询 18788,省掉白等。 + +> 注:OpenClaw 自身的临时/lock/chrome-mcp 文件已走 `os.tmpdir()`(系统 temp,**不在 U 盘**),无需处理;真正落 U 盘的只有 `OPENCLAW_HOME=data/` 下的内容。 + > **纯开源,无追踪**: 这个开源版**不含**设备指纹 (`fingerprint.mjs`)、自动开户 (`bootstrap-xiapan.mjs`/`xiapan-client.mjs`)、崩溃上报 (`report-bug.mjs`) 等商业版逻辑——这些已在 2026-06-17 移除。U-Claw 不绑定设备、不打指纹、不向 `api.u-claw.org` 上传任何数据。 ### 模型配置 ("选模型填 Key") diff --git a/portable/Mac-Start.command b/portable/Mac-Start.command index 815c22d..9d25864 100755 --- a/portable/Mac-Start.command +++ b/portable/Mac-Start.command @@ -70,6 +70,19 @@ echo "" # ---- 4. Init data directories ---- mkdir -p "$STATE_DIR" "$DATA_DIR/memory" "$DATA_DIR/backups" "$DATA_DIR/logs" +# ---- 4b. 加速:把"重 IO、可重建"的缓存从 U 盘搬到本机硬盘 ---- +# portable-cache.mjs 算出本机缓存目录(~/Library/Caches/U-Claw/slot,UUID 隔离, +# 换盘符仍复用),并把 .openclaw/browser 做成 symlink 指向本机盘。 +# 浏览器 user-data(几百 MB 随机小写)和 V8 编译缓存因此落本机盘,不再拖慢 U 盘。 +# 静默失败:取不到就跳过,缓存留 U 盘,照常启动。 +while IFS='=' read -r _k _v; do + case "$_k" in + UCLAW_COMPILE_CACHE_DIR) export NODE_COMPILE_CACHE="$_v" ;; + UCLAW_CACHE_ROOT) UCLAW_CACHE_ROOT="$_v" ;; + esac +done < <("$NODE_BIN" "$UCLAW_DIR/lib/portable-cache.mjs" "$STATE_DIR" "$UCLAW_DIR" 2>/dev/null) +[ -n "$NODE_COMPILE_CACHE" ] && echo -e " ${GREEN}Cache on local disk:${NC} $UCLAW_CACHE_ROOT" + # ---- 5. Default config ---- if [ ! -f "$CONFIG_FILE" ]; then if [ -f "$DATA_DIR/config.json" ]; then @@ -160,32 +173,34 @@ if grep -q '"providers"' "$CONFIG_FILE" 2>/dev/null; then MODEL_CONFIGURED=1 fi -# ---- 12. Wait for gateway, then open browser ---- -# 首次启动会 staging ~35 个 bundled deps,慢盘上实测可达 90 秒以上,期间端口还没 -# LISTENING。轮询上限必须覆盖这段,否则浏览器在 gateway ready 前就放弃打开,用户 -# 看到"拒绝连接"以为坏了(同 Windows issue #46/#48)。最多等 ~3 分钟(180×1s)。 +# ---- 12. 立刻打开"启动首屏",给用户即时反馈(移植自 4.0 splash)---- +# 首屏 loading.html 自己轮询 /ready,gateway 真就绪后自动跳 Dashboard——天然解决 +# "gateway 没起就开 Dashboard 拒连"的问题(同 Windows issue #46/#48)。 echo -e " ${YELLOW}首次启动需准备运行环境,约 30-90 秒,请稍候...${NC}" -GATEWAY_READY=0 -for i in $(seq 1 180); do - if curl -s -o /dev/null "http://127.0.0.1:$PORT/" 2>/dev/null; then - GATEWAY_READY=1 - if [ "$MODEL_CONFIGURED" = "1" ]; then - # 已配置:只开 Dashboard - open "http://127.0.0.1:$PORT/#token=uclaw" 2>/dev/null || true - else - # 首次:开 Config Center 引导填 Key - open "http://127.0.0.1:18788/" 2>/dev/null || true - fi - break - fi - sleep 1 -done -if [ "$GATEWAY_READY" != "1" ]; then - # 超时回退:gateway 还没就绪也别让用户干等,先开 Config Center - echo -e " ${YELLOW}Gateway 启动较慢,先打开配置中心...${NC}" +# 用 file:// URL 确保 query string(?port=)能传给浏览器;裸路径 open 会把整串当文件名。 +open "file://$UCLAW_DIR/lib/loading.html?port=$PORT&token=uclaw" 2>/dev/null || true +# 首次未配置:再开 Config Center 引导填 Key +if [ "$MODEL_CONFIGURED" != "1" ]; then open "http://127.0.0.1:18788/" 2>/dev/null || true fi +# ---- 12b. gateway 首轮预热(后台、静默、非阻塞)---- +# 就绪后先唤醒 config/model 子系统,用户首次点发送时不再等。移植自 4.0 first-turn-prewarm。 +"$NODE_BIN" "$UCLAW_DIR/lib/prewarm.mjs" "$PORT" uclaw >/dev/null 2>&1 & + +# ---- 12c. 兜底:万一首屏页的 file:// fetch 被浏览器拦,仍轮询端口后开 Dashboard ---- +# 慢盘首启可达 90s+,轮询上限覆盖这段。最多 ~3 分钟(180×1s)。 +( + for i in $(seq 1 180); do + if curl -s -o /dev/null "http://127.0.0.1:$PORT/" 2>/dev/null; then + # 首屏页通常已自己跳转;这里仅作兜底,open 同一 URL 浏览器会复用已有标签。 + [ "$MODEL_CONFIGURED" = "1" ] && open "http://127.0.0.1:$PORT/#token=uclaw" 2>/dev/null || true + exit 0 + fi + sleep 1 + done +) & + echo -e " ${GREEN}════════════════════════════════${NC}" echo -e " ${GREEN}🦞 U-Claw is running!${NC}" echo -e " ${GREEN} Dashboard: http://127.0.0.1:$PORT/#token=uclaw${NC}" diff --git a/portable/Windows-Start.bat b/portable/Windows-Start.bat index 044e7d5..94d7cfd 100644 --- a/portable/Windows-Start.bat +++ b/portable/Windows-Start.bat @@ -49,6 +49,17 @@ if not exist "%DATA_DIR%\memory" mkdir "%DATA_DIR%\memory" if not exist "%DATA_DIR%\backups" mkdir "%DATA_DIR%\backups" if not exist "%DATA_DIR%\logs" mkdir "%DATA_DIR%\logs" +REM ── 加速:把"重 IO、可重建"的缓存从 U 盘搬到本机硬盘 ────────────────── +REM portable-cache.mjs 算出本机缓存目录(%LOCALAPPDATA%\U-Claw\slot,UUID 隔离, +REM 换盘符仍复用),并把 .openclaw\browser 做成 junction 指向本机盘。 +REM 浏览器 user-data(几百 MB 随机小写)和 V8 编译缓存因此落本机 SSD,不再拖慢 U 盘。 +REM 静默失败:取不到就跳过,缓存留 U 盘,照常启动。 +for /f "usebackq tokens=1,* delims==" %%a in (`""%NODE_BIN%" "%UCLAW_DIR%lib\portable-cache.mjs" "%STATE_DIR%" "%UCLAW_DIR%" 2^>nul"`) do ( + if "%%a"=="UCLAW_COMPILE_CACHE_DIR" set "NODE_COMPILE_CACHE=%%b" + if "%%a"=="UCLAW_CACHE_ROOT" set "UCLAW_CACHE_ROOT=%%b" +) +if defined NODE_COMPILE_CACHE echo Cache on local disk: %UCLAW_CACHE_ROOT% + REM Default config (migrate legacy if present, otherwise create) if not exist "%STATE_DIR%\openclaw.json" ( if exist "%DATA_DIR%\config.json" ( @@ -133,19 +144,51 @@ echo Starting Config Center on port 18788... set "CONFIG_SERVER=%UCLAW_DIR%config-server" start /B "" "%NODE_BIN%" "%CONFIG_SERVER%\server.js" >nul 2>&1 -REM Wait for config server to start -timeout /t 2 /nobreak >nul +REM 等 Config Server 就绪 —— 动态探测而非写死等待。 +REM 它通常 <1s 就起来,写死 timeout /t 2 是白等。改为每 ~0.3s 探测 18788, +REM 最多 ~6s(20 次)兜底。监听到就立刻往下走。 +set /a CFG_TRIES=0 +:wait_config +netstat -an | findstr ":18788 " | findstr "LISTENING" >nul 2>&1 +if %errorlevel%==0 goto :config_ready +set /a CFG_TRIES+=1 +if %CFG_TRIES% geq 20 goto :config_ready +ping -n 1 -w 300 127.0.0.1 >nul 2>&1 +goto :wait_config +:config_ready REM IMPORTANT: 不要在 gateway 启动前就开 Dashboard 浏览器! REM 慢 U 盘上 OpenClaw 首次启动要 staging bundled deps(几十秒), REM 过早打开 http://127.0.0.1:18789 会"拒绝连接",是 issue #46/#48 的根因。 -REM 改为后台等待器:轮询端口,gateway 真正 LISTENING 后再开 Dashboard。 -echo Opening Config Center... -start "" http://127.0.0.1:18788/ +REM 方案:立刻打开本地"启动首屏"loading.html,给用户即时反馈(移植自 4.0 splash)。 +REM 首屏自己轮询 /ready,gateway 真就绪后自动跳 Dashboard——天然解决拒连。 -REM 后台等待器:每 2s 探测 %PORT%,最多 ~5 分钟(150 次),监听到就开 Dashboard +REM 是否已配置模型?openclaw.json 含 providers 即视为已配置 (issue #24)。 +REM 未配置(首次):先开 Config Center 引导填 Key。 +set "MODEL_CONFIGURED=" +findstr /C:"providers" "%STATE_DIR%\openclaw.json" >nul 2>&1 +if %errorlevel%==0 set "MODEL_CONFIGURED=1" + +REM 立刻开启动首屏(带 port 参数,就绪后自动跳 Dashboard) +REM 用 file:/// URL(正斜杠)+ 转义 & ,确保 query string 能传给浏览器。 +echo Opening startup screen... +set "LOADING_PATH=%UCLAW_DIR%lib\loading.html" +set "LOADING_URL=file:///%LOADING_PATH:\=/%?port=%PORT%&token=uclaw" +start "" "%LOADING_URL%" + +if not defined MODEL_CONFIGURED ( + echo First-time setup - opening Config Center... + start "" http://127.0.0.1:18788/ +) + +REM 后台等待器兜底:万一首屏页没起作用(浏览器拦本地 fetch 等), +REM 仍轮询端口,gateway LISTENING 后开 Dashboard。 start /B "" cmd /c ""%UCLAW_DIR%lib\wait-gateway.bat" %PORT%" +REM gateway 首轮预热(后台、静默、非阻塞):就绪后先唤醒 config/model 子系统, +REM 用户首次点发送时不再等。移植自 4.0 first-turn-prewarm。 +start /B "" "%NODE_BIN%" "%UCLAW_DIR%lib\prewarm.mjs" %PORT% uclaw >nul 2>&1 + echo. echo ======================================== echo Starting OpenClaw Gateway on port %PORT%... diff --git a/portable/lib/loading.html b/portable/lib/loading.html new file mode 100644 index 0000000..cef42db --- /dev/null +++ b/portable/lib/loading.html @@ -0,0 +1,138 @@ + + + + + +U-Claw 正在启动… + + + + +
+ +

U-Claw 正在启动

+
首次从 U 盘启动需要展开内置组件,
稍等片刻,就绪后会自动打开。
+
+
+
正在等待 OpenClaw 网关…
+
+
+ 慢 U 盘首次启动通常 30–90 秒。
+ 如果迟迟没反应,可手动打开 + 控制台。 +
+
+ + + + diff --git a/portable/lib/portable-cache.mjs b/portable/lib/portable-cache.mjs new file mode 100644 index 0000000..d8c3162 --- /dev/null +++ b/portable/lib/portable-cache.mjs @@ -0,0 +1,185 @@ +// portable-cache.mjs — 把"重 IO、可重建"的缓存从 U 盘搬到本机硬盘 +// +// 背景(U 盘启动慢的最大根因): +// - OpenClaw 的浏览器 user-data 落在 OPENCLAW_STATE_DIR/browser//user-data, +// 即 U 盘 data/.openclaw/ 下。Chromium 会对它做海量随机小写,慢 U 盘上极致拖累。 +// - Node 的 V8 编译缓存(module.enableCompileCache)默认落系统 temp,可能被清而每次重编译。 +// +// 方案(移植自 v2 u-clawx 4.0 的 portable-session-data.ts 思路): +// 把这两类"可重建、不需便携"的缓存重定向到本机硬盘的固定位置: +// Windows: %LOCALAPPDATA%\U-Claw\... +// macOS: ~/Library/Caches/U-Claw/... +// Linux: $XDG_CACHE_HOME/U-Claw 或 ~/.cache/U-Claw +// 业务数据(openclaw.json、memory、账号)仍留在 U 盘 data/,便携性不变。 +// +// 浏览器 user-data 的重定向手法: +// OpenClaw 把浏览器 profile 硬编码在 CONFIG_DIR/browser/(无单独环境变量可改), +// 只有 OPENCLAW_STATE_DIR 能整体搬走——但那会连 openclaw.json 一起搬,破坏便携。 +// 所以这里用"目录联接/符号链接":把 U 盘的 data/.openclaw/browser 做成一个 +// junction(Windows) / symlink(mac/linux),指向本机硬盘缓存。OpenClaw 照常写 +// CONFIG_DIR/browser,字节却落在本机盘上。配置仍在 U 盘,便携性不受影响。 +// +// UUID 隔离(移植自 4.0): +// 缓存子目录名 = sha256("portable-id:") 前 16 hex。UUID 存在 U 盘 STATE_DIR 里, +// 所以同一支 U 盘从 D: 插到 E: 仍命中同一份本机缓存,不必重新热身。 +// +// 设计原则:静默失败。任何一步出错都回退到"用 U 盘内目录",绝不阻断启动。 +// +// CLI 用法(供 .bat / .command source): +// node portable-cache.mjs +// 输出(每行 KEY=VALUE,路径已 mkdir): +// UCLAW_COMPILE_CACHE_DIR=... +// UCLAW_BROWSER_USER_DATA_DIR=... +// UCLAW_CACHE_ROOT=... + +import { createHash, randomUUID } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, writeFileSync, lstatSync, readlinkSync, symlinkSync, readdirSync, rmdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir, tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; + +const CACHE_ID_FILE = 'portable-cache-id'; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +// 本机缓存根:各平台的"用户缓存"约定位置。绝不放 U 盘。 +function systemCacheRoot(platform = process.platform, env = process.env) { + if (platform === 'win32') { + return env.LOCALAPPDATA?.trim() || join(homedir() || tmpdir(), 'AppData', 'Local'); + } + if (platform === 'darwin') { + return join(homedir() || tmpdir(), 'Library', 'Caches'); + } + return env.XDG_CACHE_HOME?.trim() || join(homedir() || tmpdir(), '.cache'); +} + +// 读/建 U 盘上的稳定 UUID,使缓存身份与盘符解耦。 +function readOrCreateCacheId(stateDir) { + if (!stateDir) return null; + const idPath = join(stateDir, CACHE_ID_FILE); + try { + if (existsSync(idPath)) { + const existing = readFileSync(idPath, 'utf8').trim(); + if (UUID_RE.test(existing)) return existing.toLowerCase(); + } + const next = randomUUID(); + mkdirSync(stateDir, { recursive: true }); + writeFileSync(idPath, `${next}\n`, { encoding: 'utf8', mode: 0o600 }); + return next; + } catch { + return null; + } +} + +// 解析本机缓存目录集合。stateDir 缺失/不可写时回退到 U 盘内目录。 +export function resolvePortableCache({ + stateDir, + usbRoot, + platform = process.platform, + env = process.env, +} = {}) { + const cacheId = readOrCreateCacheId(stateDir); + // 有 UUID 用 UUID,否则退而用 U 盘路径做身份(仍稳定,只是换盘符会换目录) + const identity = cacheId ? `portable-id:${cacheId}` : String(usbRoot || stateDir || 'u-claw').toLowerCase(); + const slot = createHash('sha256').update(identity).digest('hex').slice(0, 16); + + let root; + try { + root = join(systemCacheRoot(platform, env), 'U-Claw', slot); + mkdirSync(root, { recursive: true }); + } catch { + // 本机缓存根不可写 → 整体回退到 U 盘内(保证不报错,只是没加速) + root = stateDir ? join(stateDir, 'cache') : join(tmpdir(), 'u-claw-cache', slot); + try { mkdirSync(root, { recursive: true }); } catch { /* 实在不行就让调用方拿到路径自己兜底 */ } + } + + const compileCacheDir = join(root, 'node-compile-cache'); + const browserUserDataDir = join(root, 'browser'); + for (const d of [compileCacheDir, browserUserDataDir]) { + try { mkdirSync(d, { recursive: true }); } catch { /* 静默 */ } + } + + // 把 U 盘的 .openclaw/browser 链到本机缓存。失败就不链——OpenClaw 退回原地写 U 盘,只是没加速。 + let browserLinked = false; + if (stateDir) { + browserLinked = linkBrowserDir(join(stateDir, 'browser'), browserUserDataDir, platform); + } + + return { root, compileCacheDir, browserUserDataDir, browserLinked, cacheId }; +} + +// 判断一个路径是否"已是指向 target 的链接"。 +function isLinkTo(linkPath, target) { + try { + const st = lstatSync(linkPath); + if (!st.isSymbolicLink()) { + // Windows junction 在 lstat 下 isDirectory()=true、isSymbolicLink()=false, + // 用 readlink 兜底判断:能 readlink 成功且指向 target 就算已链。 + try { + const resolved = readlinkSync(linkPath); + return resolved && target && resolved.replace(/[\\/]+$/, '') === target.replace(/[\\/]+$/, ''); + } catch { + return false; + } + } + const resolved = readlinkSync(linkPath); + return resolved.replace(/[\\/]+$/, '') === target.replace(/[\\/]+$/, ''); + } catch { + return false; // 不存在 + } +} + +// 把 linkPath(U 盘 .openclaw/browser)做成指向 target(本机缓存/browser)的链接。 +// 返回 true=已链好。已存在真实目录且非空则不动(保住用户既有 profile,宁可慢也不丢数据)。 +function linkBrowserDir(linkPath, target, platform) { + try { + if (existsSync(linkPath)) { + if (isLinkTo(linkPath, target)) return true; // 之前已链好 + // 是真实目录:若为空可以安全替换成链接;非空则保守不动。 + let entries = []; + try { entries = readdirSync(linkPath); } catch { return false; } + if (entries.length > 0) return false; // 有既有 profile,不冒险,留在 U 盘 + // 空目录:删掉再链 + try { rmdirCompat(linkPath); } catch { return false; } + } + if (platform === 'win32') { + // mklink /J 目录联接:无需管理员权限,行为最接近"同一目录" + execFileSync('cmd', ['/c', 'mklink', '/J', linkPath, target], { stdio: 'ignore' }); + } else { + symlinkSync(target, linkPath, 'dir'); + } + return isLinkTo(linkPath, target); + } catch { + return false; + } +} + +function rmdirCompat(dir) { + // 只删空目录,避免误删用户数据 + rmdirSync(dir); +} + +// CLI:打印 KEY=VALUE,供启动脚本逐行 set / export。 +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 stateDir = process.argv[2] || process.env.OPENCLAW_STATE_DIR; + const usbRoot = process.argv[3] || process.env.UCLAW_DIR; + try { + const c = resolvePortableCache({ stateDir, usbRoot }); + process.stdout.write( + `UCLAW_CACHE_ROOT=${c.root}\n` + + `UCLAW_COMPILE_CACHE_DIR=${c.compileCacheDir}\n` + + `UCLAW_BROWSER_USER_DATA_DIR=${c.browserUserDataDir}\n` + + `UCLAW_BROWSER_LINKED=${c.browserLinked ? '1' : '0'}\n`, + ); + process.exit(0); + } catch (err) { + // 静默失败:不输出任何 KEY,启动脚本会按"未设置"继续(缓存留 U 盘) + process.stderr.write(`[portable-cache] ${err && err.message}\n`); + process.exit(1); + } +} diff --git a/portable/lib/prewarm.mjs b/portable/lib/prewarm.mjs new file mode 100644 index 0000000..6cccbb1 --- /dev/null +++ b/portable/lib/prewarm.mjs @@ -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 [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); }); +} diff --git a/portable/lib/wait-gateway.bat b/portable/lib/wait-gateway.bat index 32d5186..e8850c0 100644 --- a/portable/lib/wait-gateway.bat +++ b/portable/lib/wait-gateway.bat @@ -3,6 +3,10 @@ REM wait-gateway.bat - open Dashboard only after the Gateway is actually listeni REM Fixes issue #46/#48: on slow USB drives the gateway needs tens of seconds to REM stage bundled deps on first run; opening http://127.0.0.1:PORT before it is REM LISTENING shows "connection refused" and users think it is broken. +REM +REM 角色变更:现在 Windows-Start.bat 会先开 lib\loading.html 启动首屏,由首屏 +REM 自己轮询 /ready 并自动跳 Dashboard。本脚本退居"兜底"——万一首屏页的 +REM file:// fetch 被浏览器拦住没跳成,这里仍轮询端口、就绪后开 Dashboard。 REM Usage (called in background by Windows-Start.bat): wait-gateway.bat PORT REM Polls every 2s, up to 150 tries (~5 min). Opens the browser once ready.