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

View File

@@ -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\<slot>` / 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-dataOpenClaw 硬编码在 `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")

View File

@@ -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/slotUUID 隔离,
# 换盘符仍复用),并把 .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 自己轮询 /readygateway 真就绪后自动跳 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}"

View File

@@ -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\slotUUID 隔离,
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 最多 ~6s20 次)兜底。监听到就立刻往下走。
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 首屏自己轮询 /readygateway 真就绪后自动跳 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%...

138
portable/lib/loading.html Normal file
View File

@@ -0,0 +1,138 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>U-Claw 正在启动…</title>
<!--
启动首屏(移植自 v2 u-clawx 4.0 的 splash 思路)。
双击启动脚本后立刻打开本页,给用户即时反馈,消除"黑窗假死"体感。
本页每秒自查 gateway 是否就绪fetch /ready就绪后自动跳转 Dashboard。
端口通过 URL 查询参数传入loading.html?port=18789
(启动脚本打开时拼好。默认 18789。
-->
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
font-family: -apple-system, "Microsoft YaHei", "PingFang SC", sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.box {
text-align: center;
max-width: 460px;
padding: 40px;
}
.logo { font-size: 64px; line-height: 1; margin-bottom: 18px; }
h1 { font-size: 1.7em; font-weight: 600; margin-bottom: 8px; }
.tip { opacity: .85; font-size: 1.02em; line-height: 1.6; margin-bottom: 28px; }
.spinner {
width: 46px; height: 46px;
margin: 0 auto 24px;
border: 4px solid rgba(255,255,255,.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin .9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.bar {
height: 6px; width: 100%;
background: rgba(255,255,255,.2);
border-radius: 999px;
overflow: hidden;
margin-bottom: 14px;
}
.bar > i {
display: block; height: 100%; width: 30%;
background: #fff; border-radius: 999px;
animation: slide 1.6s ease-in-out infinite;
}
@keyframes slide {
0% { margin-left: -30%; }
100% { margin-left: 100%; }
}
.status { font-size: .95em; opacity: .9; min-height: 1.4em; }
.elapsed { font-size: .82em; opacity: .6; margin-top: 6px; }
.note {
margin-top: 30px; font-size: .82em; opacity: .65; line-height: 1.6;
}
a.manual { color: #fff; text-decoration: underline; opacity: .9; }
</style>
</head>
<body>
<div class="box">
<div class="logo">🦞</div>
<h1>U-Claw 正在启动</h1>
<div class="tip">首次从 U 盘启动需要展开内置组件,<br>稍等片刻,就绪后会自动打开。</div>
<div class="spinner"></div>
<div class="bar"><i></i></div>
<div class="status" id="status">正在等待 OpenClaw 网关…</div>
<div class="elapsed" id="elapsed"></div>
<div class="note">
慢 U 盘首次启动通常 3090 秒。<br>
如果迟迟没反应,可手动打开
<a class="manual" id="manual" href="#">控制台</a>
</div>
</div>
<script>
(function () {
var params = new URLSearchParams(location.search);
var port = parseInt(params.get('port'), 10) || 18789;
var token = params.get('token') || 'uclaw';
var base = 'http://127.0.0.1:' + port;
var dashboard = base + '/#token=' + encodeURIComponent(token);
var configCenter = 'http://127.0.0.1:18788/';
document.getElementById('manual').href = dashboard;
var startedAt = Date.now();
var redirected = false;
function fmt(ms) {
var s = Math.floor(ms / 1000);
return '已等待 ' + s + ' 秒';
}
function tickElapsed() {
document.getElementById('elapsed').textContent = fmt(Date.now() - startedAt);
}
setInterval(tickElapsed, 1000);
tickElapsed();
function goDashboard() {
if (redirected) return;
redirected = true;
document.getElementById('status').textContent = '网关已就绪,正在打开控制台…';
setTimeout(function () { location.replace(dashboard); }, 400);
}
function poll() {
if (redirected) return;
// /ready 就绪即跳。no-cors 下拿不到 status但 fetch 成功 resolve 就说明端口活了。
fetch(base + '/ready', { mode: 'no-cors', cache: 'no-store' })
.then(function () { goDashboard(); })
.catch(function () {
// 端口还没起,继续等
setTimeout(poll, 1000);
});
}
poll();
// 兜底5 分钟还没起来,提示去控制台
setTimeout(function () {
if (!redirected) {
document.getElementById('status').innerHTML =
'启动较慢,可先打开 <a class="manual" href="' + configCenter + '">配置中心</a> 检查';
}
}, 300000);
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,185 @@
// portable-cache.mjs — 把"重 IO、可重建"的缓存从 U 盘搬到本机硬盘
//
// 背景U 盘启动慢的最大根因):
// - OpenClaw 的浏览器 user-data 落在 OPENCLAW_STATE_DIR/browser/<profile>/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:<UUID>") 前 16 hex。UUID 存在 U 盘 STATE_DIR 里,
// 所以同一支 U 盘从 D: 插到 E: 仍命中同一份本机缓存,不必重新热身。
//
// 设计原则:静默失败。任何一步出错都回退到"用 U 盘内目录",绝不阻断启动。
//
// CLI 用法(供 .bat / .command source
// node portable-cache.mjs <STATE_DIR> <USB_ROOT>
// 输出(每行 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; // 不存在
}
}
// 把 linkPathU 盘 .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);
}
}

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); });
}

View File

@@ -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.