diff --git a/portable/Mac-IntranetFix.command b/portable/Mac-IntranetFix.command new file mode 100755 index 0000000..99e02da --- /dev/null +++ b/portable/Mac-IntranetFix.command @@ -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 " 按回车关闭..." diff --git a/portable/Mac-LocalModel.command b/portable/Mac-LocalModel.command new file mode 100755 index 0000000..c8b530a --- /dev/null +++ b/portable/Mac-LocalModel.command @@ -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 " 按回车关闭..." diff --git a/portable/Mac-OpenClaw-Doctor.command b/portable/Mac-OpenClaw-Doctor.command new file mode 100755 index 0000000..433fe12 --- /dev/null +++ b/portable/Mac-OpenClaw-Doctor.command @@ -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 " 按回车关闭..." diff --git a/portable/Mac-Start.command b/portable/Mac-Start.command index a1ad5e4..78b0531 100755 --- a/portable/Mac-Start.command +++ b/portable/Mac-Start.command @@ -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 diff --git a/portable/OpenClaw-Doctor.bat b/portable/OpenClaw-Doctor.bat new file mode 100644 index 0000000..19963f3 --- /dev/null +++ b/portable/OpenClaw-Doctor.bat @@ -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 diff --git a/portable/Windows-IntranetFix.bat b/portable/Windows-IntranetFix.bat new file mode 100644 index 0000000..3072b64 --- /dev/null +++ b/portable/Windows-IntranetFix.bat @@ -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 diff --git a/portable/Windows-LocalModel.bat b/portable/Windows-LocalModel.bat new file mode 100644 index 0000000..2705588 --- /dev/null +++ b/portable/Windows-LocalModel.bat @@ -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 diff --git a/portable/Windows-Start.bat b/portable/Windows-Start.bat index 7790045..f81a6bc 100644 --- a/portable/Windows-Start.bat +++ b/portable/Windows-Start.bat @@ -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. diff --git a/portable/lib/intranet-check.mjs b/portable/lib/intranet-check.mjs new file mode 100644 index 0000000..cb8db8e --- /dev/null +++ b/portable/lib/intranet-check.mjs @@ -0,0 +1,202 @@ +// intranet-check.mjs — 内网一体化体检(代理 + 可达性 + 真发对话) +// +// 把"代理环境 / NO_PROXY 建议 / 直连可达 / 端到端发一条对话"全做在一个 Node 脚本里, +// 这样 .bat 只需一行 `node intranet-check.mjs `,没有任何 cmd 解析坑(中文、for/f、 +// 转义都不沾),最稳。所有中文提示由 Node 打印(chcp 65001 下正常显示)。 +// +// 用法:node intranet-check.mjs + +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(); diff --git a/portable/lib/resolve-no-proxy.mjs b/portable/lib/resolve-no-proxy.mjs new file mode 100644 index 0000000..6a94c56 --- /dev/null +++ b/portable/lib/resolve-no-proxy.mjs @@ -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 +// 输出(无代理需要保护时不输出任何内容): +// 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(); diff --git a/portable/lib/setup-local-model.mjs b/portable/lib/setup-local-model.mjs new file mode 100644 index 0000000..59658dd --- /dev/null +++ b/portable/lib/setup-local-model.mjs @@ -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 + +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 '); 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(); diff --git a/tests/windows-launchers.test.mjs b/tests/windows-launchers.test.mjs index 2b05a08..2389e1e 100644 --- a/tests/windows-launchers.test.mjs +++ b/tests/windows-launchers.test.mjs @@ -80,6 +80,73 @@ test('Windows gateway fallback does not force-open Dashboard', () => { ); }); +test('portable launchers route configured model hosts around the system proxy', () => { + const winStart = readRepoFile('portable', 'Windows-Start.bat'); + assert.match( + winStart, + /resolve-no-proxy\.mjs[\s\S]*UCLAW_NO_PROXY[\s\S]*set "NO_PROXY=/, + 'Windows-Start.bat should set NO_PROXY from resolve-no-proxy.mjs', + ); + + const macStart = readRepoFile('portable', 'Mac-Start.command'); + assert.match( + macStart, + /resolve-no-proxy\.mjs[\s\S]*export NO_PROXY=/, + 'Mac-Start.command should export NO_PROXY from resolve-no-proxy.mjs', + ); +}); + +test('customer-facing .bat launchers are pure ASCII (cmd.exe mis-parses UTF-8 Chinese on GBK Windows)', () => { + // Non-ASCII bytes in a .bat get read as GBK by Chinese Windows cmd.exe, which + // garbles parsing ("usebackq is not a command"). Chinese UX must live in the + // node tools' stdout (rendered fine under chcp 65001), never in the .bat itself. + for (const name of [ + 'Windows-Start.bat', + 'Windows-IntranetFix.bat', + 'Windows-LocalModel.bat', + 'OpenClaw-Doctor.bat', + ]) { + const bytes = readFileSync(join(repoRoot, 'portable', name)); + const offending = bytes.findIndex((b) => b > 0x7f); + assert.equal(offending, -1, `${name} has a non-ASCII byte at offset ${offending}`); + assert.ok(bytes.includes(0x0d), `${name} must use CRLF line endings`); + } +}); + +test('macOS .command launchers are LF-only (CRLF breaks #!/bin/bash on macOS)', () => { + for (const name of [ + 'Mac-Start.command', + 'Mac-IntranetFix.command', + 'Mac-LocalModel.command', + 'Mac-OpenClaw-Doctor.command', + ]) { + const bytes = readFileSync(join(repoRoot, 'portable', name)); + const cr = bytes.indexOf(0x0d); + assert.equal(cr, -1, `${name} has a CR byte at offset ${cr} (must be LF-only)`); + assert.ok(bytes.toString('utf8').startsWith('#!/bin/bash'), `${name} must start with a clean shebang`); + } +}); + +test('macOS local-model / intranet launchers call the shared cross-platform scripts', () => { + assert.match(readRepoFile('portable', 'Mac-IntranetFix.command'), /lib\/intranet-check\.mjs/); + assert.match(readRepoFile('portable', 'Mac-LocalModel.command'), /lib\/setup-local-model\.mjs/); + assert.match(readRepoFile('portable', 'Mac-OpenClaw-Doctor.command'), /doctor --non-interactive/); +}); + +test('local-model setup launcher calls setup-local-model.mjs', () => { + const bat = readRepoFile('portable', 'Windows-LocalModel.bat'); + assert.match(bat, /lib\\setup-local-model\.mjs/); +}); + +test('OpenClaw doctor launcher is read-only (no destructive repair flags)', () => { + const bat = readRepoFile('portable', 'OpenClaw-Doctor.bat'); + assert.match(bat, /OPENCLAW_MJS%" doctor --non-interactive/); + // Must not auto-apply repairs that could overwrite user config/state. + assert.doesNotMatch(bat, /doctor[^\n]*--fix/); + assert.doesNotMatch(bat, /doctor[^\n]*--repair/); + assert.doesNotMatch(bat, /doctor[^\n]*--force/); +}); + test('PowerShell installer generated start.bat disables OpenClaw bonjour discovery', () => { const script = readRepoFile('install', 'install.ps1'); diff --git a/u-claw-app/package.json b/u-claw-app/package.json index dd78767..f72e758 100644 --- a/u-claw-app/package.json +++ b/u-claw-app/package.json @@ -1,6 +1,6 @@ { "name": "u-claw", - "version": "2.1.9", + "version": "2.1.10", "description": "U-Claw - AI 助手桌面版,插上 U 盘就能用", "main": "src/main.js", "author": "U-Claw (https://u-claw.org)",