便携版在内网/离线/受控浏览器环境下"配不上、用不了、查不清"的三个痛点工具化, 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>
218 lines
9.0 KiB
JavaScript
218 lines
9.0 KiB
JavaScript
// 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();
|