Files
u-claw/portable/lib/intranet-check.mjs
zheng b076815171
Some checks failed
Tests / test (push) Has been cancelled
feat: 海外化改造(新加坡市场)—— 阶段 0-3
按 U-Claw-海外化改造方案.md 与 范围决策记录.md 实施。这是 fork,不回上游:
海外版删掉的正是上游的中国市场默认值。

阶段 0 地基
- 下载源全部改国际:脚本/CI 61 处 + lockfile 880 条 npmmirror URL 归零
  (lockfile 那 880 条是 npm 的 resolved 字段,脚本层参数化根本绕不过它)
- 移除 install.ps1 里三个第三方 GitHub 加速代理,bundle 改直连 + SHA256 校验
  (原来只检查"文件大于 1MB"就解压运行)
- 技能内容与分发分离:skills/manifest.json 单一来源,install.sh 1170→658 行、
  install.ps1 721→546 行,两者技能内容归零
  实测原来是三份不一致:skills-cn 完整、install.sh 约 40%、install.ps1 约 17%,
  且 7 个通用技能只有 U 盘版有 —— 一键安装的用户一个能用的技能都没有
- Node 版本三种(v22.14/16/22.1)统一,新建 NODE_VERSION 单一来源
- Config 页三份合一。portable/Config.html 用根相对路径调 API 却只从 file:// 打开,
  保存功能已静默失效两个月;现缩为 120 行重定向壳
- 测试接入 CI(此前 node --test 无人运行,所有断言形同虚设)

阶段 1 双语可用
- 浏览器侧 i18n:JSON 为源、生成经典 script(file:// 下 fetch 本地 JSON 被拦)
  语言跟盘走不跟机器走:启动器写 data/.openclaw/locale.js
- 8 处硬编码 lang="zh-CN" 归零,data-i18n 覆盖 213 处,词条 en/zh 各 279 条
- B3 单框 Key:12 张模型卡 → 一个输入框,前缀识别 provider,
  服务端 /api/test-key 发 1-token 请求实测,错误映射成人话
  Key 填错到得知:从"直到对话失败"降到 ≤1 秒
- 区域格式 SG:DD/MM/YYYY、12 小时、S$、Asia/Singapore
  (ICU 在 en-SG 下把 SGD 渲染成裸 $,与美元无法区分,故自行拼 S$)
- README 内容分叉而非翻译,§1.3 证据清单逐条清零

阶段 2 降门槛
- 启动逻辑上移 lib/start.mjs:Windows-Start.bat 220→28 行、
  Mac-Start.command 235→33 行
  修掉 Mac 侧两个 bug:控制台端口硬编码 18788(回落时打开死页)、
  微信插件从未在 Mac 上安装
- U 盘根目录 23 → 3 个可点文件,其余进 advanced/
- 首启向导:语言 → 用途(7 角色,manifest 驱动)→ 密钥,答过不再问
- 三档界面,Simple 档隐藏一切技术名词
- 自动自愈:启动失败先自查自修,修不好导出脱敏诊断包
  (Doctor 从"用户要知道去点的工具"变成后台机制)

阶段 3 技能库
- 19 个英文技能,planned 归零。sg-weather / sg-transport 的端点均实测过
- SkillHub 从 56 张手写第三方卡片改为 manifest 生成:703→125 行,中文归零

其他
- origin.json 收拢所有运行时地址,tests/origin.test.mjs 保证迁移不会漏
- portable/ 下用户可见中文归零(由断言保证)
- 82 项测试

未验证(本机无 Windows / 无 pwsh):
- install.ps1、setup.ps1 约 210 行改动从未经 PowerShell 解析器
- 完整启动路径仅在假 node + 假 openclaw 上冒烟
- 8 个 .bat 的盘根推导仅静态断言
详见 U盘实测清单.md

受阻:
- 隐藏黑窗口 —— 需代码签名证书(.vbs 已被 Windows 弃用,替代方案都要签名)
- 场景卡 —— OpenClaw 上游 Dashboard 无预填 prompt 接口
- 官网 36 条 —— 上游 2026-04-14 拆到私有仓库,无权限
2026-08-17 18:37:49 +08:00

204 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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: 'Reply with exactly: connection ok' }], 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 — corporate network check');
line(` Node ${process.version}`);
line('========================================');
line('');
// 1) 代理环境
line('[1] System proxy');
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(' A system proxy is set. Requests to an internal model endpoint may be routed through it — this is the single most common cause of a self-hosted model not working.');
} else {
line(' No system proxy set.');
}
line('');
if (!configPath) { line('No config path given — nothing to check.'); return; }
let config;
try { config = JSON.parse(readFileSync(configPath, 'utf8')); }
catch (e) { line(`Could not read the config file: ${e?.message || e}`); return; }
const providers = collectProviders(config?.models);
if (!providers.length) { line('No model endpoint is configured yet. Set one up in Settings first, then run this again.'); return; }
// 2) NO_PROXY 建议
const hosts = Array.from(new Set(providers.map((p) => hostOf(p.baseUrl)).filter(Boolean)));
const noProxy = applyNoProxy(hosts);
line('[2] Suggested NO_PROXY — hosts that should bypass the proxy');
line(` ${noProxy.join(',')}`);
line(' U-Claw already sets this for you at startup. Shown here in case you need it elsewhere.');
line('');
// 3) 直连可达
line('[3] Direct connection — can this machine reach the model at all, proxy bypassed');
for (const p of providers) {
const r = await reachProbe(p.baseUrl, p.apiKey);
if (r.ok) line(` [${p.name}] ${p.baseUrl} -> reachable, HTTP ${r.status} (${r.ms}ms)`);
else line(` [${p.name}] ${p.baseUrl} -> FAILED ${r.error} (${r.ms}ms)`);
}
line('');
// 4) 端到端实测
line('[4] Real request — actually asking the model something');
const t = pickTarget(config, providers);
if (!t) { line(' No model ID to test with — skipped.'); }
else {
line(` Model: ${t.provName} / ${t.modelId}`);
line(' Sending…');
const c = await chatProbe(t);
if (c.ok) {
line('');
line(` It works. The model replied in ${c.ms}ms: ${c.reply.slice(0, 120) || '(empty reply, but the request succeeded)'}`);
} else if (c.status) {
line('');
line(` The server answered with HTTP ${c.status} in ${c.ms}ms: ${c.body}`);
} else {
line('');
line(` Could not connect: ${c.error} (${c.ms}ms)`);
}
}
line('');
// 结论
line('========================================');
line(' What the results mean:');
line(' Step 4 worked Everything is fine. Start U-Claw normally.');
line(' Step 3 reachable but A proxy is intercepting the request. U-Claw already')
line(' the app cannot use it bypasses it at startup via NO_PROXY.');
line(' Step 3 failed Wrong address, blocked network, firewall, or the service is down. Ask whoever runs the server.');
line(' 401 or 403 The network is fine — the key is wrong.');
line('========================================');
}
main();