feat(portable): 内网/局域网可用性工具包 + 启动绕代理 (v2.1.10)

便携版在内网/离线/受控浏览器环境下"配不上、用不了、查不清"的三个痛点工具化,
Win + Mac 双端,纯 Node 零依赖脚本,中文提示由 node 打印(.bat 保持纯 ASCII,
.command 保持 LF),三端真机验证(本机 Win / Mac mini / 客户机 pc-7512)。

新增工具:
- lib/intranet-check.mjs (+ Windows-IntranetFix.bat / Mac-IntranetFix.command)
  一键体检: 代理env + 直连可达 + 真发一条对话,分清"网络不通 vs 配置错"。
- lib/setup-local-model.mjs (+ Windows-LocalModel.bat / Mac-LocalModel.command)
  纯命令行配 Ollama / newapi 并当场实测,绕开打不开的 Control UI;写前自动备份+merge。
- lib/resolve-no-proxy.mjs 接入 Windows-Start.bat / Mac-Start.command:
  把配置里的模型 host 写进 NO_PROXY,避免系统代理劫持内网模型请求
  (OpenClaw 自身只 bypass loopback CDP,不管模型 host)。
- OpenClaw-Doctor.bat / Mac-OpenClaw-Doctor.command: 官方 doctor 的隔离进阶入口
  (只读、非交互;doctor 实跑慢且 TTY-only,故不进客户一键流程)。

测试: tests/windows-launchers.test.mjs 新增回归
- 客户面 .bat 必须纯 ASCII (UTF-8 中文会被 GBK cmd 读乱,报 usebackq 不是命令)
- macOS .command 必须 LF-only (CRLF 触发 bad interpreter: /bin/bash^M)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hfshfg
2026-06-22 18:51:16 +08:00
parent 7805ee01f7
commit 3439db32ad
13 changed files with 815 additions and 2 deletions

View File

@@ -0,0 +1,33 @@
#!/bin/bash
# ============================================================
# U-Claw - 内网体检 / Intranet Check (macOS)
# 双击运行代理env + 直连可达 + 真发一条对话
# ============================================================
UCLAW_DIR="$(cd "$(dirname "$0")" && pwd)"
APP_DIR="$UCLAW_DIR/app"
CONFIG_FILE="$UCLAW_DIR/data/.openclaw/openclaw.json"
ARCH=$(uname -m)
case "$ARCH" in
arm64) NODE_BIN="$APP_DIR/runtime/node-mac-arm64/bin/node" ;;
x86_64) NODE_BIN="$APP_DIR/runtime/node-mac-x64/bin/node" ;;
*) NODE_BIN="" ;;
esac
# 退而求其次:用系统 node
if [ ! -x "$NODE_BIN" ]; then NODE_BIN="$(command -v node)"; fi
if [ -z "$NODE_BIN" ] || [ ! -x "$NODE_BIN" ]; then
echo " [错误] 找不到 Node 运行环境。请先正常启动一次 U-Claw。"
read -p " 按回车关闭..."
exit 1
fi
# 去掉 macOS 隔离属性,避免 Gatekeeper 拦截
xattr -rd com.apple.quarantine "$UCLAW_DIR" 2>/dev/null || true
"$NODE_BIN" "$UCLAW_DIR/lib/intranet-check.mjs" "$CONFIG_FILE"
echo ""
echo " 把整个窗口截图发给技术支持即可。"
echo ""
read -p " 按回车关闭..."

29
portable/Mac-LocalModel.command Executable file
View File

@@ -0,0 +1,29 @@
#!/bin/bash
# ============================================================
# U-Claw - 内网/本地模型一键配置 (macOS)
# 双击运行:命令行配 Ollama / newapi 并当场实测(不碰网页设置)
# ============================================================
UCLAW_DIR="$(cd "$(dirname "$0")" && pwd)"
APP_DIR="$UCLAW_DIR/app"
CONFIG_FILE="$UCLAW_DIR/data/.openclaw/openclaw.json"
ARCH=$(uname -m)
case "$ARCH" in
arm64) NODE_BIN="$APP_DIR/runtime/node-mac-arm64/bin/node" ;;
x86_64) NODE_BIN="$APP_DIR/runtime/node-mac-x64/bin/node" ;;
*) NODE_BIN="" ;;
esac
if [ ! -x "$NODE_BIN" ]; then NODE_BIN="$(command -v node)"; fi
if [ -z "$NODE_BIN" ] || [ ! -x "$NODE_BIN" ]; then
echo " [错误] 找不到 Node 运行环境。请先正常启动一次 U-Claw。"
read -p " 按回车关闭..."
exit 1
fi
xattr -rd com.apple.quarantine "$UCLAW_DIR" 2>/dev/null || true
"$NODE_BIN" "$UCLAW_DIR/lib/setup-local-model.mjs" "$CONFIG_FILE"
echo ""
read -p " 按回车关闭..."

View File

@@ -0,0 +1,52 @@
#!/bin/bash
# ============================================================
# U-Claw - OpenClaw Doctor (官方完整体检, 进阶, 英文)
# 注意:先启动 U-Claw 再跑本工具,否则 doctor 会卡在探测未启动的 gateway 上。
# 只读:故意不传 --fix/--repair/--force。卡住可按 Ctrl+C 安全中断。
# ============================================================
UCLAW_DIR="$(cd "$(dirname "$0")" && pwd)"
APP_DIR="$UCLAW_DIR/app"
CORE_DIR="$APP_DIR/core"
DATA_DIR="$UCLAW_DIR/data"
STATE_DIR="$DATA_DIR/.openclaw"
OPENCLAW_MJS="$CORE_DIR/node_modules/openclaw/openclaw.mjs"
ARCH=$(uname -m)
case "$ARCH" in
arm64) NODE_BIN="$APP_DIR/runtime/node-mac-arm64/bin/node" ;;
x86_64) NODE_BIN="$APP_DIR/runtime/node-mac-x64/bin/node" ;;
*) NODE_BIN="" ;;
esac
if [ ! -x "$NODE_BIN" ]; then NODE_BIN="$(command -v node)"; fi
if [ -z "$NODE_BIN" ] || [ ! -x "$NODE_BIN" ]; then
echo " [错误] 找不到 Node 运行环境。"
read -p " 按回车关闭..."
exit 1
fi
if [ ! -f "$OPENCLAW_MJS" ]; then
echo " [错误] 找不到 OpenClaw 运行时 (app/core/node_modules/openclaw)。"
read -p " 按回车关闭..."
exit 1
fi
export OPENCLAW_HOME="$DATA_DIR"
export OPENCLAW_STATE_DIR="$STATE_DIR"
export OPENCLAW_CONFIG_PATH="$STATE_DIR/openclaw.json"
export OPENCLAW_DISABLE_BONJOUR=1
xattr -rd com.apple.quarantine "$UCLAW_DIR" 2>/dev/null || true
echo ""
echo " ========================================"
echo " OpenClaw Doctor (官方体检, 英文, 较慢)"
echo " ========================================"
echo " 请确保 U-Claw 已经在运行。卡住可 Ctrl+C 中断(只读,安全)。"
echo " 想要快速的中文体检请改用 Mac-IntranetFix.command。"
echo ""
read -p " 按回车开始..."
"$NODE_BIN" "$OPENCLAW_MJS" doctor --non-interactive
echo ""
read -p " 按回车关闭..."

View File

@@ -138,6 +138,21 @@ if [ -f "$VERSION_FILE" ]; then
"$NODE_BIN" "$UCLAW_DIR/lib/check-update.mjs" "$VERSION_FILE" "$STATE_DIR" >/dev/null 2>&1 &
fi
# ---- 7c. Intranet/self-hosted model fix ----
# Keep the configured model host(s) off any corporate HTTP_PROXY/HTTPS_PROXY.
# OpenClaw routes ALL fetch through the env proxy when it is set, which breaks
# calls to internal model endpoints (http://10.x / 192.168.x / a machine-room IP).
# Add those hosts + loopback to NO_PROXY so they connect directly.
# Silent no-op when no proxy/model is configured.
NO_PROXY_LINE="$("$NODE_BIN" "$UCLAW_DIR/lib/resolve-no-proxy.mjs" "$CONFIG_FILE" 2>/dev/null)"
case "$NO_PROXY_LINE" in
UCLAW_NO_PROXY=*)
export NO_PROXY="${NO_PROXY_LINE#UCLAW_NO_PROXY=}"
export no_proxy="$NO_PROXY"
echo " Direct-connect (NO_PROXY): $NO_PROXY"
;;
esac
# ---- 8. Find available port ----
PORT=18789
while lsof -i :$PORT >/dev/null 2>&1; do

View File

@@ -0,0 +1,54 @@
@echo off
chcp 65001 >nul 2>&1
title U-Claw - OpenClaw Doctor (advanced)
setlocal
REM Official OpenClaw health check (English, advanced users).
REM NOTE: run this AFTER U-Claw is already running (double-click Windows-Start.bat
REM first), otherwise doctor stalls while probing a gateway that is not up yet.
REM Read-only: we deliberately do NOT pass --fix/--repair/--force.
set "UCLAW_DIR=%~dp0"
set "APP_DIR=%UCLAW_DIR%app"
set "CORE_DIR=%APP_DIR%\core"
set "DATA_DIR=%UCLAW_DIR%data"
set "STATE_DIR=%DATA_DIR%\.openclaw"
set "NODE_BIN=%APP_DIR%\runtime\node-win-x64\node.exe"
set "OPENCLAW_MJS=%CORE_DIR%\node_modules\openclaw\openclaw.mjs"
set "OPENCLAW_HOME=%DATA_DIR%"
set "OPENCLAW_STATE_DIR=%STATE_DIR%"
set "OPENCLAW_CONFIG_PATH=%STATE_DIR%\openclaw.json"
set "OPENCLAW_DISABLE_BONJOUR=1"
if not exist "%NODE_BIN%" (
echo [ERROR] Node runtime not found. Put this file inside your U-Claw folder.
echo.
pause
exit /b 1
)
if not exist "%OPENCLAW_MJS%" (
echo [ERROR] OpenClaw runtime not found ^(app\core\node_modules\openclaw^).
echo.
pause
exit /b 1
)
echo.
echo ========================================
echo OpenClaw Doctor (official, English)
echo ========================================
echo This is the upstream deep health check. It can take a while and is in
echo English. Make sure U-Claw is ALREADY running first.
echo If it seems stuck, press Ctrl+C to stop - it is safe (read-only).
echo.
pause
"%NODE_BIN%" "%OPENCLAW_MJS%" doctor --non-interactive
echo.
echo ----------------------------------------
echo Doctor finished. For a quick Chinese check of your model connection,
echo use Windows-IntranetFix.bat instead.
echo.
pause

View File

@@ -0,0 +1,23 @@
@echo off
chcp 65001 >nul 2>&1
title U-Claw - Intranet Check
setlocal
set "UCLAW_DIR=%~dp0"
set "NODE_BIN=%UCLAW_DIR%app\runtime\node-win-x64\node.exe"
set "CONFIG_PATH=%UCLAW_DIR%data\.openclaw\openclaw.json"
if not exist "%NODE_BIN%" (
echo [ERROR] Node runtime not found.
echo Put this file inside your U-Claw folder, next to Windows-Start.bat, then run again.
echo.
pause
exit /b 1
)
"%NODE_BIN%" "%UCLAW_DIR%lib\intranet-check.mjs" "%CONFIG_PATH%"
echo.
echo Please screenshot the whole window and send it to support.
echo.
pause

View File

@@ -0,0 +1,21 @@
@echo off
chcp 65001 >nul 2>&1
title U-Claw - Local / Intranet Model Setup
setlocal
set "UCLAW_DIR=%~dp0"
set "NODE_BIN=%UCLAW_DIR%app\runtime\node-win-x64\node.exe"
set "CONFIG_PATH=%UCLAW_DIR%data\.openclaw\openclaw.json"
if not exist "%NODE_BIN%" (
echo [ERROR] Node runtime not found.
echo Put this file inside your U-Claw folder, next to Windows-Start.bat, then run again.
echo.
pause
exit /b 1
)
"%NODE_BIN%" "%UCLAW_DIR%lib\setup-local-model.mjs" "%CONFIG_PATH%"
echo.
pause

View File

@@ -75,7 +75,7 @@ if not exist "%STATE_DIR%\openclaw.json" (
)
REM Check dependencies
REM Note: avoid unescaped parens inside this block cmd.exe treats ) as block-end.
REM Note: avoid unescaped parens inside this block -- cmd.exe treats ) as block-end.
if not exist "%CORE_DIR%\node_modules" (
echo ========================================
echo [WARN] node_modules not found
@@ -97,6 +97,19 @@ if not exist "%CORE_DIR%\node_modules" (
echo.
)
REM Intranet/self-hosted model fix: keep the configured model host(s) off any
REM corporate HTTP_PROXY/HTTPS_PROXY. OpenClaw routes ALL fetch through the env
REM proxy when it is set, which breaks calls to internal model endpoints
REM (e.g. http://10.x / 192.168.x / a machine-room IP). Add those hosts + loopback
REM to NO_PROXY so they connect directly. Silent no-op when no proxy/model is set.
for /f "usebackq tokens=1,* delims==" %%a in (`""%NODE_BIN%" "%UCLAW_DIR%lib\resolve-no-proxy.mjs" "%STATE_DIR%\openclaw.json" 2^>nul"`) do (
if "%%a"=="UCLAW_NO_PROXY" set "NO_PROXY=%%b"
)
if defined NO_PROXY (
set "no_proxy=%NO_PROXY%"
echo Direct-connect (NO_PROXY): %NO_PROXY%
)
REM Async update check (non-blocking, 5s timeout, silent failure)
REM Writes data\.openclaw\update-available.json if a newer version is on OSS.
REM Welcome.html / Config.html read this file and show a banner.

View File

@@ -0,0 +1,202 @@
// intranet-check.mjs — 内网一体化体检(代理 + 可达性 + 真发对话)
//
// 把"代理环境 / NO_PROXY 建议 / 直连可达 / 端到端发一条对话"全做在一个 Node 脚本里,
// 这样 .bat 只需一行 `node intranet-check.mjs <cfg>`,没有任何 cmd 解析坑中文、for/f、
// 转义都不沾),最稳。所有中文提示由 Node 打印chcp 65001 下正常显示)。
//
// 用法node intranet-check.mjs <CONFIG_PATH>
import { readFileSync } from 'node:fs';
import http from 'node:http';
import https from 'node:https';
const REACH_TIMEOUT_MS = 8000;
const CHAT_TIMEOUT_MS = 30000;
const ALWAYS = ['localhost', '127.0.0.1', '::1'];
function line(s = '') { process.stdout.write(s + '\n'); }
function applyNoProxy(hosts) {
const existing = (process.env.NO_PROXY || process.env.no_proxy || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const merged = Array.from(new Set([...existing, ...ALWAYS, ...hosts]));
process.env.NO_PROXY = merged.join(',');
process.env.no_proxy = process.env.NO_PROXY;
return merged;
}
function withScheme(raw) {
return /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `http://${raw}`;
}
function hostOf(raw) {
try { return new URL(withScheme(String(raw).trim())).hostname || null; } catch { return null; }
}
function collectProviders(models) {
const out = [];
const providers = models?.providers;
if (providers && typeof providers === 'object') {
for (const [name, p] of Object.entries(providers)) {
const baseUrl = p?.baseUrl || p?.baseURL;
if (typeof baseUrl === 'string' && baseUrl.trim()) {
out.push({ name, baseUrl: baseUrl.trim(), apiKey: typeof p?.apiKey === 'string' ? p.apiKey : '' });
}
}
}
return out;
}
function pickTarget(config, providers) {
const primary = config?.agents?.defaults?.model?.primary;
if (typeof primary === 'string' && primary.includes('/')) {
const provName = primary.slice(0, primary.indexOf('/'));
const modelId = primary.slice(primary.indexOf('/') + 1);
const p = config?.models?.providers?.[provName];
if (p?.baseUrl || p?.baseURL) {
return { provName, modelId, baseUrl: (p.baseUrl || p.baseURL).trim(), apiKey: p.apiKey || '' };
}
}
for (const pr of providers) {
const p = config?.models?.providers?.[pr.name];
const modelId = Array.isArray(p?.models) && p.models[0]?.id ? p.models[0].id : undefined;
if (modelId) return { provName: pr.name, modelId, baseUrl: pr.baseUrl, apiKey: pr.apiKey };
}
return null;
}
async function reachProbe(baseUrl, apiKey) {
const url = withScheme(baseUrl).replace(/\/+$/, '') + '/models';
const started = Date.now();
try {
const res = await requestText(url, { headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, timeoutMs: REACH_TIMEOUT_MS });
return { ok: true, status: res.status, ms: Date.now() - started };
} catch (err) {
const ms = Date.now() - started;
if (err?.code === 'ETIMEDOUT') return { ok: false, error: `ETIMEDOUT(>${REACH_TIMEOUT_MS / 1000}s)`, ms };
return { ok: false, error: err?.cause?.code || err?.code || err?.name || 'ERR', ms };
}
}
async function chatProbe(t) {
const url = withScheme(t.baseUrl).replace(/\/+$/, '') + '/chat/completions';
const started = Date.now();
try {
const res = await requestText(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(t.apiKey ? { Authorization: `Bearer ${t.apiKey}` } : {}) },
body: JSON.stringify({ model: t.modelId, messages: [{ role: 'user', content: '请回复四个字:连接成功' }], max_tokens: 64, stream: false }),
timeoutMs: CHAT_TIMEOUT_MS,
});
const ms = Date.now() - started;
const text = res.body;
if (!res.ok) return { ok: false, status: res.status, body: text.slice(0, 300), ms };
let reply = '';
try { const j = JSON.parse(text); reply = j?.choices?.[0]?.message?.content ?? j?.choices?.[0]?.text ?? ''; }
catch { reply = text.slice(0, 200); }
return { ok: true, reply: String(reply).trim(), ms };
} catch (err) {
const ms = Date.now() - started;
if (err?.code === 'ETIMEDOUT') return { ok: false, error: `ETIMEDOUT(>${CHAT_TIMEOUT_MS / 1000}s)`, ms };
return { ok: false, error: `${err?.cause?.code || err?.code || err?.name || 'ERR'}: ${err?.message || ''}`, ms };
}
}
function requestText(rawUrl, { method = 'GET', headers = {}, body, timeoutMs }) {
return new Promise((resolve, reject) => {
const u = new URL(rawUrl);
const client = u.protocol === 'https:' ? https : http;
const req = client.request(u, { method, headers }, (res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, body: text });
});
});
req.setTimeout(timeoutMs, () => {
const err = new Error('request timed out');
err.code = 'ETIMEDOUT';
req.destroy(err);
});
req.on('error', reject);
if (body) req.write(body);
req.end();
});
}
async function main() {
const configPath = process.argv[2] || process.env.OPENCLAW_CONFIG_PATH;
line('========================================');
line(' U-Claw 内网体检 / Intranet Check');
line(` Node ${process.version}`);
line('========================================');
line('');
// 1) 代理环境
line('【1】代理环境检查');
const proxyKeys = ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'http_proxy', 'https_proxy', 'all_proxy'];
const setProxies = proxyKeys.filter((k) => process.env[k]);
if (setProxies.length) {
for (const k of setProxies) line(` ${k} = ${process.env[k]}`);
line(' → 检测到系统代理。发往内网模型的请求可能被它劫持(最常见的内网故障源)。');
} else {
line(' (未检测到系统代理)');
}
line('');
if (!configPath) { line('未提供配置路径,结束。'); return; }
let config;
try { config = JSON.parse(readFileSync(configPath, 'utf8')); }
catch (e) { line(`读取配置失败:${e?.message || e}`); return; }
const providers = collectProviders(config?.models);
if (!providers.length) { line('配置里没有任何模型地址baseUrl。先配好模型再来跑。'); return; }
// 2) NO_PROXY 建议
const hosts = Array.from(new Set(providers.map((p) => hostOf(p.baseUrl)).filter(Boolean)));
const noProxy = applyNoProxy(hosts);
line('【2】建议的 NO_PROXY让这些地址直连、绕开代理');
line(` ${noProxy.join(',')}`);
line(' (新版 Windows-Start.bat 已会自动设置,无需手动操作)');
line('');
// 3) 直连可达
line('【3】直连测试绕过代理看能否摸到模型服务');
for (const p of providers) {
const r = await reachProbe(p.baseUrl, p.apiKey);
if (r.ok) line(` [${p.name}] ${p.baseUrl} → ✓ 可达 HTTP ${r.status} (${r.ms}ms)`);
else line(` [${p.name}] ${p.baseUrl} → ✗ 失败 ${r.error} (${r.ms}ms)`);
}
line('');
// 4) 端到端实测
line('【4】实测真发一条对话给模型');
const t = pickTarget(config, providers);
if (!t) { line(' 找不到可测的模型 id跳过。'); }
else {
line(` 模型:${t.provName} / ${t.modelId}`);
line(' 发送中,请稍候...');
const c = await chatProbe(t);
if (c.ok) {
line('');
line(` ✓✓ 跑通了!模型回复 (${c.ms}ms)${c.reply.slice(0, 120) || '(空回复但请求成功)'}`);
} else if (c.status) {
line('');
line(` ✗ 服务端 HTTP ${c.status} (${c.ms}ms)${c.body}`);
} else {
line('');
line(` ✗ 直连失败:${c.error} (${c.ms}ms)`);
}
}
line('');
// 结论
line('========================================');
line(' 怎么看结果:');
line(' · 第4步「跑通了」 → 一切正常,以后双击 Windows-Start.bat 即可。');
line(' · 第3步可达但程序里用不了 → 是系统代理在劫持新版启动脚本已自动绕开NO_PROXY。');
line(' · 第3步「直连失败/超时」 → 地址错 / 内网不通 / 防火墙 / 模型服务没起,找机房管理员。');
line(' · 出现 401/403 → 网络是通的,只是 API Key 不对。');
line('========================================');
}
main();

View File

@@ -0,0 +1,87 @@
// resolve-no-proxy.mjs — 让"内网/自建模型地址"绕开系统代理
//
// 背景(内网环境最大的坑):
// 很多公司/机房的机器设置了 HTTP_PROXY / HTTPS_PROXY 环境变量(为了上外网)。
// OpenClaw 启动时若检测到这两个变量,会 setGlobalDispatcher(new EnvHttpProxyAgent())
// 于是"所有" fetch——包括调用用户自己填的模型 baseUrl——都被塞进公司代理。
// 当模型部署在内网(如 http://10.x / 192.168.x / 某机房 IP代理够不着那台机器
// 请求直接失败。表现互联网能连公网模型、reasonix/copilot 也能连内网,唯独本程序连不上。
// 见 openclaw dist/auth-profiles-*.js 的 ensureGlobalUndiciEnvProxyDispatcher()。
//
// 方案undici 的 EnvHttpProxyAgent 认 NO_PROXY。把用户配置里所有模型 baseUrl 的主机名
// IP 或域名)+ 本机回环地址,统一写进 NO_PROXY让这些地址"直连不走代理"。
// 纯增量、绝对安全:自建/内网模型本就不该走代理;没设代理时 NO_PROXY 也无副作用。
//
// 设计原则:静默失败。任何一步出错就不输出,启动照常(只是少了这层保护)。
//
// CLI 用法(供 .bat / .command source
// node resolve-no-proxy.mjs <CONFIG_PATH>
// 输出(无代理需要保护时不输出任何内容):
// UCLAW_NO_PROXY=localhost,127.0.0.1,::1,15.151.114.142,...
import { readFileSync } from 'node:fs';
// 始终直连的本机地址。
const ALWAYS = ['localhost', '127.0.0.1', '::1'];
// 从一个 baseUrl 字符串里抽出主机名IP 或域名)。容错:解析不了就忽略。
function hostOf(raw) {
if (typeof raw !== 'string') return null;
const trimmed = raw.trim();
if (!trimmed) return null;
try {
// 补协议URL() 才能解析 "host:port/v1" 这种缺协议的写法。
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
const host = new URL(withScheme).hostname; // 自动去掉 IPv6 的方括号
return host || null;
} catch {
return null;
}
}
// 递归收集对象里所有 baseUrl 字段的主机名providers 可能嵌套/命名各异,宽松收集最稳)。
function collectHosts(node, out) {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node)) {
for (const item of node) collectHosts(item, out);
return;
}
for (const [key, value] of Object.entries(node)) {
if (key === 'baseUrl' || key === 'baseURL') {
const h = hostOf(value);
if (h) out.add(h);
} else if (value && typeof value === 'object') {
collectHosts(value, out);
}
}
}
function main() {
const configPath = process.argv[2] || process.env.OPENCLAW_CONFIG_PATH;
if (!configPath) return;
let config;
try {
config = JSON.parse(readFileSync(configPath, 'utf8'));
} catch {
return; // 配置不存在/坏了:不输出,启动照常
}
const hosts = new Set();
collectHosts(config?.models, hosts);
// 合并已有的 NO_PROXY避免覆盖用户/系统已有设置。
const existing = (process.env.NO_PROXY || process.env.no_proxy || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const merged = Array.from(new Set([...existing, ...ALWAYS, ...hosts]));
// 没有任何模型主机、又没有已有 NO_PROXY 时,只剩本机回环,输出也无害——但为简洁,
// 仅当收集到了真实模型主机时才输出(本机回环本就不会被代理误伤到业务)。
if (hosts.size === 0 && existing.length === 0) return;
process.stdout.write(`UCLAW_NO_PROXY=${merged.join(',')}\n`);
}
main();

View File

@@ -0,0 +1,217 @@
// setup-local-model.mjs — 内网/本地模型一键配置(不碰 Control UI
//
// 给"内网/离线"场景:用纯命令行问几个问题,直接写好 openclaw.json
// 再当场实测能不能连上、能不能回话。全程不依赖会挂的 dashboard 网页。
// 支持两类本地/内网模型:
// 1) Ollama本机http://127.0.0.1:11434
// 2) newapi / 任意 OpenAI 兼容中转(内网 IP + token
//
// 写入只 merge 模型相关字段,保留 gateway 等原有配置;写前自动备份。
//
// 用法node setup-local-model.mjs <CONFIG_PATH>
import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { createInterface } from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
import http from 'node:http';
import https from 'node:https';
// 输入抽象:真控制台(TTY)走交互式 readline被管道喂入(测试/脚本)则一次读完按行出队。
function makePrompter() {
if (input.isTTY) {
const rl = createInterface({ input, output });
return {
ask: async (q, def) => {
const a = (await rl.question(`${q}${def ? ` [${def}]` : ''}: `)).trim();
return a || def || '';
},
close: () => rl.close(),
};
}
let queued = [];
try { queued = readFileSync(0, 'utf8').split(/\r?\n/); } catch {}
let i = 0;
return {
ask: async (q, def) => {
const raw = (queued[i++] ?? '').trim();
const val = raw || def || '';
output.write(`${q}${def ? ` [${def}]` : ''}: ${val}\n`);
return val;
},
close: () => {},
};
}
const CHAT_TIMEOUT_MS = 30000;
function line(s = '') { output.write(s + '\n'); }
function withScheme(raw) { return /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `http://${raw}`; }
function hostOf(raw) {
try { return new URL(withScheme(String(raw).trim())).hostname || null; } catch { return null; }
}
function applyNoProxyFor(baseUrl) {
const host = hostOf(baseUrl);
const existing = (process.env.NO_PROXY || process.env.no_proxy || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const merged = Array.from(new Set([...existing, 'localhost', '127.0.0.1', '::1', ...(host ? [host] : [])]));
process.env.NO_PROXY = merged.join(',');
process.env.no_proxy = process.env.NO_PROXY;
}
async function chatTest(baseUrl, apiKey, modelId) {
applyNoProxyFor(baseUrl);
const url = withScheme(baseUrl).replace(/\/+$/, '') + '/chat/completions';
const started = Date.now();
try {
const res = await requestText(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}) },
body: JSON.stringify({ model: modelId, messages: [{ role: 'user', content: '请回复四个字:连接成功' }], max_tokens: 64, stream: false }),
timeoutMs: CHAT_TIMEOUT_MS,
});
const ms = Date.now() - started;
const text = res.body;
if (!res.ok) return { ok: false, status: res.status, body: text.slice(0, 300), ms };
let reply = '';
try { const j = JSON.parse(text); reply = j?.choices?.[0]?.message?.content ?? j?.choices?.[0]?.text ?? ''; }
catch { reply = text.slice(0, 200); }
return { ok: true, reply: String(reply).trim(), ms };
} catch (err) {
const ms = Date.now() - started;
if (err?.code === 'ETIMEDOUT') return { ok: false, error: `ETIMEDOUT(>${CHAT_TIMEOUT_MS / 1000}s)`, ms };
return { ok: false, error: `${err?.cause?.code || err?.code || err?.name || 'ERR'}: ${err?.message || ''}`, ms };
}
}
function requestText(rawUrl, { method = 'GET', headers = {}, body, timeoutMs }) {
return new Promise((resolve, reject) => {
const u = new URL(rawUrl);
const client = u.protocol === 'https:' ? https : http;
const req = client.request(u, { method, headers }, (res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, body: text });
});
});
req.setTimeout(timeoutMs, () => {
const err = new Error('request timed out');
err.code = 'ETIMEDOUT';
req.destroy(err);
});
req.on('error', reject);
if (body) req.write(body);
req.end();
});
}
async function main() {
const configPath = process.argv[2] || process.env.OPENCLAW_CONFIG_PATH;
if (!configPath) { line('用法: node setup-local-model.mjs <CONFIG_PATH>'); process.exitCode = 2; return; }
line('========================================');
line(' U-Claw 内网/本地模型 一键配置');
line('========================================');
line('');
const rl = makePrompter();
const ask = rl.ask;
try {
line('选择模型类型:');
line(' 1) Ollama本机部署http://127.0.0.1:11434');
line(' 2) newapi / 其它 OpenAI 兼容中转(内网 IP + token');
const kind = await ask('输入 1 或 2', '1');
let providerKey, baseUrl, apiKey, modelId;
if (kind === '2') {
providerKey = 'newapi';
line('');
line('提示baseUrl 通常形如 http://192.168.1.50:3000/v1注意大多要带 /v1');
baseUrl = await ask('newapi 地址 baseUrl', 'http://192.168.1.50:3000/v1');
apiKey = await ask('token / API Key', '');
modelId = await ask('模型 ID管理员给的如 deepseek-v3', '');
} else {
providerKey = 'ollama';
line('');
baseUrl = await ask('Ollama 地址(一般本机默认即可)', 'http://127.0.0.1:11434/v1');
// Ollama 的 OpenAI 兼容端点在 /v1自动补上
if (!/\/v1\/?$/.test(baseUrl)) baseUrl = baseUrl.replace(/\/+$/, '') + '/v1';
apiKey = 'ollama'; // 本地占位 key任意值即可
modelId = await ask('模型名(先用 ollama list 查,如 qwen2.5 / llama3.1', 'qwen2.5');
}
if (!baseUrl || !modelId) { line(''); line('地址或模型 ID 为空,已取消。'); process.exitCode = 2; return; }
// 读取并合并现有配置(保留 gateway 等),写前备份
let config = {};
if (existsSync(configPath)) {
try { config = JSON.parse(readFileSync(configPath, 'utf8')); }
catch { config = {}; }
try { copyFileSync(configPath, configPath + '.bak'); } catch {}
} else {
try { mkdirSync(dirname(configPath), { recursive: true }); } catch {}
}
config.gateway ||= { mode: 'local', auth: { token: 'uclaw' } };
config.models ||= {};
config.models.mode = 'merge';
config.models.providers ||= {};
config.models.providers[providerKey] = {
baseUrl,
apiKey,
api: 'openai-completions',
models: [{ id: modelId, name: modelId }],
};
config.agents ||= {};
config.agents.defaults ||= {};
config.agents.defaults.model ||= {};
config.agents.defaults.model.primary = `${providerKey}/${modelId}`;
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
line('');
line(`✓ 已写入配置:${configPath}`);
line(` provider=${providerKey} baseUrl=${baseUrl} model=${modelId}`);
if (existsSync(configPath + '.bak')) line(` (原配置已备份为 openclaw.json.bak`);
line('');
// 当场实测
line('正在实测:发一条对话给模型...');
const r = await chatTest(baseUrl, apiKey, modelId);
line('');
if (r.ok) {
line(`✓✓ 跑通了!模型回复 (${r.ms}ms)${r.reply.slice(0, 120) || '(空回复但请求成功)'}`);
line('');
line('配置完成。现在双击 Windows-Start.bat 即可正常使用(对话可走 CLI 或 Dashboard。');
} else if (r.status) {
line(`✗ 服务端 HTTP ${r.status} (${r.ms}ms)${r.body}`);
line('');
if (r.status === 401 || r.status === 403) line('→ 网络通,但 token / key 不对Ollama 可忽略鉴权newapi 请核对 token。');
else if (r.status === 404) line('→ 网络通,但路径或模型 ID 不对(检查 baseUrl 是否要带 /v1、模型名是否正确。');
else line('→ 网络通,服务端报错,把上面内容发管理员。');
} else {
const host = (() => { try { return new URL(withScheme(baseUrl)).hostname; } catch { return baseUrl; } })();
line(`✗ 连不上:${r.error} (${r.ms}ms)`);
line('');
if (providerKey === 'ollama') {
line('→ 本机 Ollama 没连上,多半是 Ollama 没启动或模型没拉。请在本机执行:');
line(' ollama serve (启动服务,若已是后台服务可跳过)');
line(` ollama pull ${modelId} (把模型拉到本地,离线需提前准备好)`);
line(' 然后重新运行本工具。');
} else {
line('→ 是地址错 / 内网不通 / 防火墙 / 模型服务没起。');
line(' 在这台机器上自测:');
line(` ping ${host}`);
line(' 让机房管理员确认 IP、端口、防火墙放行。');
}
}
} finally {
rl.close();
}
}
main();