🎄 Sync 2026-08-27 02:13:09
Merge-upstream / merge (push) Canceled after 0s

This commit is contained in:
github-actions[bot] committed 2026-08-27 02:13:09 +08:00
1 parent f99c0304cd
commit 410d762592
154 files changed
+9225 -8782

No files matched your search

+16 -5
View File
@@ -247,13 +247,24 @@ install_openclaw_cli_wrapper() {
# npm 默认生成的 openclaw 可能是指向 openclaw.mjs 的 symlink。
# 写 wrapper 前必须先 unlink,否则重定向会顺着 symlink 覆盖真实 CLI 入口。
rm -f "$OC_GLOBAL/bin/openclaw" 2>/dev/null || true
# 用 : "${VAR:=default}" 而非 export VAR=... 赋默认值。
#
# 历史实现无条件 export,把 OpenClaw 官方文档承诺的 per-instance 环境变量
# (OPENCLAW_HOME / OPENCLAW_STATE_DIR / OPENCLAW_CONFIG_PATH) 全部覆盖,
# 连 `openclaw --profile <name>` 也随之失效 —— profile 会解析回同一份
# 生产配置。后果是无法做隔离的写入测试,也无法按官方文档跑多实例网关。
#
# 现改为"仅当调用方未设置时才填默认值",默认行为不变,但允许覆盖。
cat > "$OC_GLOBAL/bin/openclaw" << EOF
#!/bin/sh
export HOME="${OC_DATA}"
export OPENCLAW_HOME="${OC_DATA}"
export OPENCLAW_STATE_DIR="${OC_DATA}/.openclaw"
export OPENCLAW_CONFIG_PATH="${OC_DATA}/.openclaw/openclaw.json"
export NODE_ICU_DATA="${NODE_BASE}/share/icu"
# luci-app-openclaw 生成的 OpenClaw CLI 包装器。
# 只在调用方未提供时设置默认值,保留官方的 per-instance / --profile 隔离能力。
: "\${HOME:=${OC_DATA}}"
: "\${OPENCLAW_HOME:=${OC_DATA}}"
: "\${OPENCLAW_STATE_DIR:=\${OPENCLAW_HOME}/.openclaw}"
: "\${OPENCLAW_CONFIG_PATH:=\${OPENCLAW_STATE_DIR}/openclaw.json}"
: "\${NODE_ICU_DATA:=${NODE_BASE}/share/icu}"
export HOME OPENCLAW_HOME OPENCLAW_STATE_DIR OPENCLAW_CONFIG_PATH NODE_ICU_DATA
export PATH="${NODE_BASE}/bin:${OC_GLOBAL}/bin:\$PATH"
exec "${NODE_BIN}" "${oc_entry}" "\$@"
EOF
@@ -0,0 +1,156 @@
{
"_comment": [
"精选模型预设 —— shell 与 JS 两侧共读的唯一数据源。",
"",
"架构分三层 (见 docs/notes/):",
" 1. 本文件的精选列表: 离线可用、首屏快,每个 provider 给 4 档",
" (推荐 / 最强 / 均衡 / 快速低价)。",
" 2. 动态发现: 菜单里的「从 OpenClaw 获取模型列表」调用",
" openclaw models list --provider <id> --plain,带超时并在失败时",
" 静默回落到本文件。",
" 3. 手动输入: 永久保留的兼容出口。",
"",
"维护约定:",
" - model 字段必须是 provider 侧的裸模型 ID (不含 'provider/' 前缀)。",
" - 只收录已用 openclaw models list --provider <id> 核实存在的 ID,",
" 不要凭文档或印象填写。",
" - verified 记录核实日期与 OpenClaw 版本,便于判断是否过期。",
" - catalogSource=builtin 表示上游内置 catalog 可直接发现该 provider;",
" plugin 表示需先安装官方 provider 插件,模型列表以插件为准。"
],
"verified": {
"date": "2026-08-26",
"openclawVersion": "2026.7.1-2",
"method": "openclaw models list --provider <id> --plain"
},
"providers": {
"openai": {
"label": "OpenAI",
"catalogSource": "builtin",
"models": [
{ "model": "gpt-5.6-sol", "tier": "recommended", "desc": "GPT-5.6 Sol 旗舰 (推荐)" },
{ "model": "gpt-5.6", "tier": "strongest", "desc": "GPT-5.6 通用最强" },
{ "model": "gpt-5.5", "tier": "balanced", "desc": "GPT-5.5 均衡" },
{ "model": "gpt-5.4-mini", "tier": "fast", "desc": "GPT-5.4 mini 快速低价" }
]
},
"anthropic": {
"label": "Anthropic",
"catalogSource": "builtin",
"models": [
{ "model": "claude-sonnet-5", "tier": "recommended", "desc": "Claude Sonnet 5 (推荐, 1M 上下文)" },
{ "model": "claude-opus-4-8", "tier": "strongest", "desc": "Claude Opus 4.8 顶级推理" },
{ "model": "claude-sonnet-4-6", "tier": "balanced", "desc": "Claude Sonnet 4.6 均衡" },
{ "model": "claude-haiku-4-5", "tier": "fast", "desc": "Claude Haiku 4.5 轻量快速" }
]
},
"google": {
"label": "Google Gemini",
"catalogSource": "builtin",
"models": [
{ "model": "gemini-3.1-pro-preview", "tier": "recommended", "desc": "Gemini 3.1 Pro 预览 (推荐)" },
{ "model": "gemini-2.5-pro", "tier": "strongest", "desc": "Gemini 2.5 Pro 稳定旗舰" },
{ "model": "gemini-3.5-flash", "tier": "balanced", "desc": "Gemini 3.5 Flash 均衡" },
{ "model": "gemini-3.1-flash-lite", "tier": "fast", "desc": "Gemini 3.1 Flash-Lite 极速低价" }
]
},
"openrouter": {
"label": "OpenRouter",
"catalogSource": "builtin",
"models": [
{ "model": "auto", "tier": "recommended", "desc": "自动路由 (推荐)" },
{ "model": "moonshotai/kimi-k2.6", "tier": "balanced", "desc": "Kimi K2.6" }
]
},
"github-copilot": {
"label": "GitHub Copilot",
"catalogSource": "builtin",
"models": [
{ "model": "gpt-5.5", "tier": "recommended", "desc": "GPT-5.5 (推荐, 400k 上下文)" },
{ "model": "claude-opus-4.8", "tier": "strongest", "desc": "Claude Opus 4.8" },
{ "model": "claude-sonnet-4.6", "tier": "balanced", "desc": "Claude Sonnet 4.6" },
{ "model": "gpt-5.4-mini", "tier": "fast", "desc": "GPT-5.4 mini" }
]
},
"xai": {
"label": "xAI Grok",
"catalogSource": "builtin",
"models": [
{ "model": "grok-4.3", "tier": "recommended", "desc": "Grok 4.3 (推荐)" },
{ "model": "grok-4.20-beta-latest-reasoning", "tier": "strongest", "desc": "Grok 4.20 推理版" },
{ "model": "grok-4.20-beta-latest-non-reasoning", "tier": "fast", "desc": "Grok 4.20 非推理版" }
]
},
"mistral": {
"label": "Mistral",
"catalogSource": "builtin",
"models": [
{ "model": "mistral-large-latest", "tier": "recommended", "desc": "Mistral Large (推荐)" },
{ "model": "mistral-medium-3-5", "tier": "balanced", "desc": "Mistral Medium 3.5" },
{ "model": "codestral-latest", "tier": "strongest", "desc": "Codestral 代码专用" },
{ "model": "mistral-small-latest", "tier": "fast", "desc": "Mistral Small 快速" }
]
},
"deepseek": {
"label": "DeepSeek",
"catalogSource": "builtin",
"models": [
{ "model": "deepseek-chat", "tier": "recommended", "desc": "DeepSeek Chat (推荐)" },
{ "model": "deepseek-reasoner", "tier": "strongest", "desc": "DeepSeek Reasoner 推理" }
]
},
"moonshot": {
"label": "Moonshot Kimi",
"catalogSource": "builtin",
"models": [
{ "model": "kimi-k2.6", "tier": "recommended", "desc": "Kimi K2.6 (推荐)" },
{ "model": "kimi-k2.7-code", "tier": "strongest", "desc": "Kimi K2.7 Code 代码专用" }
]
},
"minimax": {
"label": "MiniMax",
"catalogSource": "builtin",
"models": [
{ "model": "MiniMax-M3", "tier": "recommended", "desc": "MiniMax M3 (推荐)" },
{ "model": "MiniMax-M2.7", "tier": "balanced", "desc": "MiniMax M2.7" },
{ "model": "MiniMax-M2.7-highspeed", "tier": "fast", "desc": "MiniMax M2.7 高速版" }
]
},
"zai": {
"label": "智谱 GLM / Z.AI",
"catalogSource": "plugin",
"models": [
{ "model": "glm-5.2", "tier": "recommended", "desc": "GLM-5.2 (推荐)" },
{ "model": "glm-5.1", "tier": "strongest", "desc": "GLM-5.1" },
{ "model": "glm-4.7", "tier": "balanced", "desc": "GLM-4.7 均衡" },
{ "model": "glm-4.7-flash", "tier": "fast", "desc": "GLM-4.7 Flash 快速" }
]
},
"qwen": {
"label": "阿里云通义千问 Qwen",
"catalogSource": "plugin",
"models": [
{ "model": "qwen3.5-plus", "tier": "recommended", "desc": "Qwen3.5 Plus (推荐)" },
{ "model": "qwen3.6-plus", "tier": "strongest", "desc": "Qwen3.6 Plus" },
{ "model": "qwen3-coder-next", "tier": "balanced", "desc": "Qwen3 Coder Next 代码专用" },
{ "model": "qwen3-coder-plus", "tier": "fast", "desc": "Qwen3 Coder Plus" }
]
},
"qianfan": {
"label": "百度千帆",
"catalogSource": "plugin",
"models": [
{ "model": "ernie-5.0-thinking-preview", "tier": "recommended", "desc": "ERNIE 5.0 Thinking 预览 (推荐)" },
{ "model": "deepseek-v3.2", "tier": "balanced", "desc": "DeepSeek V3.2 (千帆托管)" }
]
},
"tencent-tokenhub": {
"label": "腾讯混元 TokenHub",
"catalogSource": "plugin",
"models": [
{ "model": "hy3", "tier": "recommended", "desc": "混元 HY3 (推荐)" },
{ "model": "hy3-preview", "tier": "strongest", "desc": "混元 HY3 预览版" }
]
}
}
}
@@ -33,6 +33,18 @@ const CONFIG_FILE = process.env.OPENCLAW_CONFIG_PATH || `${OC_STATE_DIR}/opencla
const NODE_BIN = `${NODE_BASE}/bin/node`;
const PERMISSIONS_HELPER = '/usr/libexec/openclaw-permissions.sh';
// 写前备份的后缀。特意不用 .bak: OpenClaw 自身维护 openclaw.json.bak
// 及 .bak.1~.bak.4 轮转,占用同名文件会破坏上游备份链。
const CONFIG_BACKUP_SUFFIX = '.luci-pre-write';
// 精选模型预设 (shell 与 JS 共读的唯一数据源)
const MODEL_PRESETS_FILE = process.env.OC_MODEL_PRESETS
|| '/usr/share/openclaw/model-presets.json';
// 动态发现模型列表的超时。OpenWrt 上要避免菜单长时间卡住,
// 超时或失败时回落到本地精选预设。
const MODEL_DISCOVERY_TIMEOUT_MS = 6000;
// ═══════════════════════════════════════════════════════════════════════════
// 辅助函数 (与 oc-config.sh 逻辑对应)
// ═══════════════════════════════════════════════════════════════════════════
@@ -84,26 +96,135 @@ function fixStatePermissions() {
}
}
function readConfig() {
try {
if (fs.existsSync(CONFIG_FILE)) {
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
}
} catch (e) {
console.error(`${C.red}读取配置失败:${C.reset} ${e.message}`);
/**
* 配置文件损坏时抛出的专用错误。
* 用于把“文件存在但内容不是合法 JSON 对象”与“文件不存在”区分开。
*/
class ConfigParseError extends Error {
constructor(message) {
super(message);
this.name = 'ConfigParseError';
}
return {};
}
/**
* 写入 JSON 配置文件
* 配置损坏时给用户的恢复指引。
* 不直接自动改文件,避免在用户不知情的情况下丢配置。
*/
function configRecoveryHint() {
const lines = [
`${C.yellow}配置文件可能已损坏,为避免覆盖后丢失全部配置,本次操作已中止。${C.reset}`,
`${C.yellow}文件:${C.reset} ${CONFIG_FILE}`,
'',
`${C.cyan}可尝试的恢复方式:${C.reset}`,
` 1. 让 OpenClaw 自行修复: openclaw doctor --fix`,
` 2. 查看具体语法错误: openclaw config validate`,
];
const lastGood = `${CONFIG_FILE}.last-good`;
if (fs.existsSync(lastGood)) {
lines.push(` 3. 从上次正常配置恢复: cp ${lastGood} ${CONFIG_FILE}`);
}
const preWrite = `${CONFIG_FILE}${CONFIG_BACKUP_SUFFIX}`;
if (fs.existsSync(preWrite)) {
lines.push(` 4. 从上次修改前的副本恢复: cp ${preWrite} ${CONFIG_FILE}`);
}
return lines.join('\n');
}
/**
* 读取 JSON 配置文件。
*
* 语义 (与旧实现的关键差异):
* - 文件不存在 / 内容为空 → 返回 {} (首次安装属正常情况)
* - 文件存在但解析失败 → 抛出 ConfigParseError,绝不返回 {}
*
* 旧实现在解析失败时返回 {},调用方随后 writeConfig() 会把这个空对象
* 写回磁盘,导致 models.providers / apiKey / channels 等全部配置被静默清空。
* 因此这里必须 fail closed,让写入路径中止。
*/
function readConfig() {
if (!fs.existsSync(CONFIG_FILE)) return {};
let raw;
try {
raw = fs.readFileSync(CONFIG_FILE, 'utf8');
} catch (e) {
throw new ConfigParseError(`无法读取配置文件: ${e.message}`);
}
if (raw.trim() === '') return {};
let parsed;
try {
parsed = JSON.parse(raw);
} catch (e) {
throw new ConfigParseError(`配置文件不是合法 JSON: ${e.message}`);
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new ConfigParseError('配置根节点必须是 JSON 对象');
}
return parsed;
}
/**
* 只读场景下的安全读取: 配置损坏时返回 {} 而不抛错。
* 仅供“显示当前值”这类不会回写磁盘的路径使用 (如菜单标题里的当前配置)。
* 任何会写回磁盘的逻辑都必须用 readConfig()。
*/
function readConfigForDisplay() {
try {
return readConfig();
} catch (e) {
if (e instanceof ConfigParseError) return {};
throw e;
}
}
/**
* 写入 JSON 配置文件 (原子写 + 写前备份 + 写后校验)。
*
* 旧实现直接 fs.writeFileSync 覆盖目标文件,写入中断会留下截断的 JSON,
* 且没有任何备份可回退。这里改为:
* 1. 先把现有配置复制为 .luci-pre-write 备份
* 2. 写入同目录临时文件
* 3. 回读校验临时文件可被 JSON.parse
* 4. rename 原子替换目标文件
*
* 备份后缀特意不用 .bak: OpenClaw 自身维护 openclaw.json.bak 及 .bak.1~.bak.4
* 轮转,占用同名文件会破坏上游的备份链。
*/
function writeConfig(config) {
if (config === null || typeof config !== 'object' || Array.isArray(config)) {
throw new Error('拒绝写入非对象配置');
}
const dir = path.dirname(CONFIG_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true }); try { execSync(`chown openclaw:openclaw "${dir}"`, { stdio: "ignore" }); } catch {}
}
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
// 保留原文件权限位,避免 rename 后权限被临时文件的 0600 覆盖
let mode = 0o644;
if (fs.existsSync(CONFIG_FILE)) {
try { mode = fs.statSync(CONFIG_FILE).mode & 0o777; } catch {}
// 写前备份 (best effort: 备份失败不应阻止用户修改配置)
try { fs.copyFileSync(CONFIG_FILE, `${CONFIG_FILE}${CONFIG_BACKUP_SUFFIX}`); } catch {}
}
const payload = `${JSON.stringify(config, null, 2)}\n`;
const tmpFile = `${CONFIG_FILE}.tmp-${process.pid}`;
try {
fs.writeFileSync(tmpFile, payload, { mode: 0o600 });
// 回读校验: 确认落盘内容可解析,避免把坏内容 rename 成正式配置
JSON.parse(fs.readFileSync(tmpFile, 'utf8'));
try { fs.chmodSync(tmpFile, mode); } catch {}
fs.renameSync(tmpFile, CONFIG_FILE);
} catch (e) {
try { if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); } catch {}
throw new Error(`写入配置失败,原配置未被修改: ${e.message}`);
}
try {
execSync(`chown openclaw:openclaw "${CONFIG_FILE}"`, { stdio: 'ignore' });
fixStatePermissions();
@@ -112,9 +233,12 @@ function writeConfig(config) {
/**
* 获取 JSON 配置值 (对应 json_get)
*
* 只读路径: 配置损坏时返回 null 而不抛错,保证菜单仍能打开并显示恢复提示。
* 需要回写磁盘的逻辑必须直接用 readConfig(),以便在损坏时中止写入。
*/
function jsonGet(keyPath) {
const config = readConfig();
const config = readConfigForDisplay();
const keys = keyPath.split('.');
let value = config;
for (const key of keys) {
@@ -148,7 +272,8 @@ function jsonSet(keyPath, value) {
function getCurrentModel() {
const primary = jsonGet('agents.defaults.model.primary');
if (primary) return primary;
const config = readConfig();
// 只读路径: 与 jsonGet 保持一致,配置损坏时不抛错
const config = readConfigForDisplay();
if (config.models?.defaultModel) return config.models.defaultModel;
return null;
}
@@ -192,6 +317,207 @@ async function ocCmd(...args) {
});
}
/**
* 静默执行 OpenClaw CLI 并返回输出。
*
* ocCmd() 会把子进程输出直接透传到终端,适合交互式命令;
* 但解析 `pairing list --json` 这类结果时需要拿到纯输出且不污染界面,
* 因此单独提供同步捕获版本。
*
* 返回 { ok, stdout }: 命令不存在或非零退出时 ok=false,不抛异常。
*/
function ocCmdCapture(...args) {
const ocEntry = findOpenClawEntry();
if (!ocEntry) return { ok: false, stdout: '' };
try {
const stdout = execFileSync(NODE_BIN, [ocEntry, ...args], {
encoding: 'utf8',
timeout: 20000,
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
OPENCLAW_HOME: OC_DATA,
OPENCLAW_CONFIG_PATH: CONFIG_FILE,
OPENCLAW_STATE_DIR: OC_STATE_DIR,
HOME: OC_DATA,
},
});
return { ok: true, stdout: stdout || '' };
} catch (e) {
// 保留 stdout: 部分命令失败时仍会输出有用信息
return { ok: false, stdout: (e && (e.stdout || '')) ? String(e.stdout) : '' };
}
}
/**
* 从 `openclaw pairing list <channel> --json` 的输出中提取配对码。
* 优先按 JSON 解析;输出夹带非 JSON 前后缀时回退到正则扫描。
*/
function parsePairingCodes(raw) {
if (!raw || !raw.trim()) return [];
const codes = [];
const pushFrom = (node) => {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node)) { node.forEach(pushFrom); return; }
if (typeof node.code === 'string' && node.code.trim()) codes.push(node.code.trim());
Object.keys(node).forEach((k) => pushFrom(node[k]));
};
try {
pushFrom(JSON.parse(raw));
} catch {
const re = /"code"\s*:\s*"([^"]+)"/g;
let m;
while ((m = re.exec(raw)) !== null) codes.push(m[1]);
}
return [...new Set(codes)];
}
/**
* 读取精选模型预设 (shell 与 JS 共读的唯一数据源)。
* 文件缺失或损坏时返回空结构,调用方回落到"手动输入"。
*/
function loadModelPresets() {
try {
const raw = fs.readFileSync(MODEL_PRESETS_FILE, 'utf8');
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && parsed.providers) return parsed;
} catch {}
return { providers: {} };
}
/**
* 动态发现某个 provider 当前实际可用的模型。
*
* 调 `openclaw models list --provider <id> --plain`,输出形如
* "openai/gpt-5.5" 每行一个,这里剥掉 provider 前缀只留裸模型 ID。
*
* 注意上游行为 (已实测): `models list --all` 不是各 provider 的超集,
* 必须按 provider 查询才能拿到完整列表;未安装对应插件的 provider
* 会返回 "No models found."。因此失败/空结果都属正常,调用方回落预设。
*/
function discoverProviderModels(providerId) {
const ocEntry = findOpenClawEntry();
if (!ocEntry) return [];
try {
const stdout = execFileSync(
NODE_BIN,
[ocEntry, 'models', 'list', '--provider', providerId, '--plain'],
{
encoding: 'utf8',
timeout: MODEL_DISCOVERY_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
env: {
...process.env,
OPENCLAW_HOME: OC_DATA,
OPENCLAW_CONFIG_PATH: CONFIG_FILE,
OPENCLAW_STATE_DIR: OC_STATE_DIR,
HOME: OC_DATA,
},
}
);
const prefix = `${providerId}/`;
return [...new Set(
String(stdout || '')
.split('\n')
.map((l) => l.trim())
.filter((l) => l.startsWith(prefix))
.map((l) => l.slice(prefix.length))
.filter(Boolean)
)];
} catch {
// 超时 / 命令不可用 / provider 无模型: 均回落到精选预设
return [];
}
}
/**
* 构建模型选择菜单项: 精选预设 + 动态发现 + 手动输入。
*
* 三层设计的取舍 (架构决定):
* - 完全硬编码: 上游更新后必然过期。本项目此前正因如此积累了
* openai/gpt-5.2、claude-sonnet-4、grok-4 等一批上游已不存在的 ID。
* - 完全动态: 依赖 CLI 可用且已配置 Key,未装插件时返回 0 条,
* 且逐 provider 调用在路由器上较慢,不能作为唯一来源。
* - 精选 + 动态 + 手动: 首屏快、能自动跟进、离线仍可用。
*/
function buildModelMenuItems(providerId, opts = {}) {
const presets = loadModelPresets();
const entry = presets.providers[providerId] || {};
const presetModels = Array.isArray(entry.models) ? entry.models : [];
const keys = 'abcdefghijkmnpqrstuvwxyz'.split('');
const items = [];
let ki = 0;
if (presetModels.length) {
items.push({ label: `${C.bold}── 精选模型 ──${C.reset}`, disabled: true });
for (const m of presetModels) {
items.push({
key: keys[ki++] || undefined,
label: m.model,
desc: m.desc || '',
value: m.model,
});
}
}
// 动态发现: 只展示不在精选列表里的,避免重复
if (opts.discovered && opts.discovered.length) {
const known = new Set(presetModels.map((m) => m.model));
const extra = opts.discovered.filter((m) => !known.has(m));
if (extra.length) {
items.push({ label: `${C.bold}── OpenClaw 当前可用 ──${C.reset}`, disabled: true });
for (const m of extra) {
items.push({ key: keys[ki++] || undefined, label: m, desc: '', value: m });
}
}
}
items.push({ label: '', disabled: true });
if (!opts.discovered) {
items.push({ key: 'r', label: '从 OpenClaw 获取完整模型列表', desc: '动态发现当前可用模型', value: '__discover__' });
}
// 手动输入是永久保留的兼容出口: 上游新增模型时无需等插件更新
items.push({ key: 'z', label: '手动输入模型 ID', desc: '', value: '__custom__' });
return items;
}
/**
* 统一的模型选择流程: 处理动态发现与手动输入两个特殊分支。
* 返回裸模型 ID (不含 provider 前缀),取消时返回 null。
*/
async function selectProviderModel(providerId, title, fallbackDefault) {
let discovered = null;
for (let round = 0; round < 2; round++) {
const choice = await select({
title,
showSearch: true,
items: buildModelMenuItems(providerId, discovered ? { discovered } : {}),
});
if (!choice) return null;
if (choice.value === '__discover__') {
console.log(`\n${C.cyan}正在从 OpenClaw 获取模型列表...${C.reset}`);
discovered = discoverProviderModels(providerId);
if (!discovered.length) {
console.log(`${C.yellow}未获取到模型列表 (该 Provider 可能需要先安装插件或配置 API Key),`
+ `已显示内置精选列表。${C.reset}\n`);
discovered = [];
} else {
console.log(`${C.green}获取到 ${discovered.length} 个模型${C.reset}\n`);
}
continue;
}
if (choice.value === '__custom__') {
const manual = await input({ prompt: '请输入模型 ID', defaultValue: fallbackDefault || '' });
const trimmed = manual ? String(manual).trim() : '';
return trimmed || null;
}
return choice.value;
}
return null;
}
/**
* 注册模型并设为默认 (对应 register_and_set_model)
*/
@@ -485,12 +811,12 @@ async function showChannelsMenu() {
{ label: `${C.dim}提示: 微信配置请使用 LuCI 界面「微信配置」菜单${C.reset}`, disabled: true },
{ label: '', disabled: true },
{ key: '1', label: 'QQ 机器人', desc: '腾讯QQ', value: 'qq' },
{ key: '2', label: 'Telegram', desc: `${C.green}最常用${C.reset}`, value: 'telegram' },
{ key: '2', label: 'Telegram', desc: `${C.green}最常用${C.reset} — 配置 Bot Token`, value: 'telegram' },
{ key: '3', label: 'Discord', desc: '', value: 'discord' },
{ key: '4', label: '飞书 (Feishu)', desc: '', value: 'feishu' },
{ key: '5', label: 'Slack', desc: '', value: 'slack' },
{ key: '6', label: 'WhatsApp', desc: '需通过 Web 控制台扫码', value: 'whatsapp' },
{ key: '7', label: 'Telegram 配对助手', desc: '', value: 'telegram-pairing' },
{ key: '7', label: 'Telegram 配对助手', desc: '审批用户配对请求 (需先配置 Token)', value: 'telegram-pairing' },
{ key: '8', label: '官方完整渠道配置向导', desc: '', value: 'wizard' },
{ label: '', disabled: true },
{ key: '0', label: '返回', desc: '', value: 'back' },
@@ -508,7 +834,9 @@ async function showAdvancedMenu() {
const gwPort = jsonGet('gateway.port') || '18789';
const gwBind = jsonGet('gateway.bind') || 'lan';
const gwMode = jsonGet('gateway.mode') || 'local';
const logLevel = jsonGet('gateway.logLevel') || '未设置';
// 正确键是顶层 logging.level;gateway.logLevel 不在 OpenClaw schema 中
// (gateway.additionalProperties=false),仅为显示旧配置残留值做兼容读取。
const logLevel = jsonGet('logging.level') || jsonGet('gateway.logLevel') || '未设置';
const acpDispatch = jsonGet('acp.dispatch.enabled') || 'false';
const result = await select({
@@ -590,26 +918,8 @@ async function configureOpenAI() {
const apiKey = await input({ prompt: '请输入 OpenAI API Key (sk-...)', placeholder: 'sk-...' });
if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
const modelChoice = await select({
title: 'OpenAI 模型选择',
showSearch: false,
items: [
{ key: 'a', label: 'gpt-5.2', desc: '最强编程与代理旗舰 (推荐)', value: 'gpt-5.2' },
{ key: 'b', label: 'gpt-5-mini', desc: '高性价比推理', value: 'gpt-5-mini' },
{ key: 'c', label: 'gpt-5-nano', desc: '极速低成本', value: 'gpt-5-nano' },
{ key: 'd', label: 'gpt-4.1', desc: '最强非推理模型', value: 'gpt-4.1' },
{ key: 'e', label: 'o3', desc: '推理模型', value: 'o3' },
{ key: 'f', label: 'o4-mini', desc: '推理轻量', value: 'o4-mini' },
{ key: 'g', label: '手动输入模型名', desc: '', value: 'custom' },
],
});
if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
let modelName = modelChoice.value;
if (modelChoice.value === 'custom') {
modelName = await input({ prompt: '请输入模型名称', defaultValue: 'gpt-5.2' });
if (!modelName) return false;
}
const modelName = await selectProviderModel('openai', 'OpenAI 模型选择', 'gpt-5.6-sol');
if (!modelName) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
authSetApikey('openai', apiKey);
registerAndSetModel(`openai/${modelName}`);
@@ -625,25 +935,8 @@ async function configureAnthropic() {
const apiKey = await input({ prompt: '请输入 Anthropic API Key (sk-ant-...)', placeholder: 'sk-ant-...' });
if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
const modelChoice = await select({
title: 'Anthropic 模型选择',
showSearch: false,
items: [
{ key: 'a', label: 'claude-sonnet-4-20250514', desc: 'Claude Sonnet 4 (推荐)', value: 'claude-sonnet-4-20250514' },
{ key: 'b', label: 'claude-opus-4-20250514', desc: 'Claude Opus 4 顶级推理', value: 'claude-opus-4-20250514' },
{ key: 'c', label: 'claude-haiku-4-5', desc: 'Claude Haiku 4.5 轻量快速', value: 'claude-haiku-4-5' },
{ key: 'd', label: 'claude-sonnet-4.5', desc: 'Claude Sonnet 4.5', value: 'claude-sonnet-4.5' },
{ key: 'e', label: 'claude-sonnet-4.6', desc: 'Claude Sonnet 4.6', value: 'claude-sonnet-4.6' },
{ key: 'f', label: '手动输入模型名', desc: '', value: 'custom' },
],
});
if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
let modelName = modelChoice.value;
if (modelChoice.value === 'custom') {
modelName = await input({ prompt: '请输入模型名称', defaultValue: 'claude-sonnet-4-20250514' });
if (!modelName) return false;
}
const modelName = await selectProviderModel('anthropic', 'Anthropic 模型选择', 'claude-sonnet-5');
if (!modelName) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
authSetApikey('anthropic', apiKey);
registerAndSetModel(`anthropic/${modelName}`);
@@ -659,25 +952,8 @@ async function configureGoogle() {
const apiKey = await input({ prompt: '请输入 Google AI API Key', placeholder: 'AIza...' });
if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
const modelChoice = await select({
title: 'Google Gemini 模型选择',
showSearch: false,
items: [
{ key: 'a', label: 'gemini-2.5-pro', desc: '旗舰推理 (推荐)', value: 'gemini-2.5-pro' },
{ key: 'b', label: 'gemini-2.5-flash', desc: '快速均衡', value: 'gemini-2.5-flash' },
{ key: 'c', label: 'gemini-2.5-flash-lite', desc: '极速低成本', value: 'gemini-2.5-flash-lite' },
{ key: 'd', label: 'gemini-3-flash-preview', desc: 'Gemini 3 Flash 预览', value: 'gemini-3-flash-preview' },
{ key: 'e', label: 'gemini-3-pro-preview', desc: 'Gemini 3 Pro 预览', value: 'gemini-3-pro-preview' },
{ key: 'f', label: '手动输入模型名', desc: '', value: 'custom' },
],
});
if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
let modelName = modelChoice.value;
if (modelChoice.value === 'custom') {
modelName = await input({ prompt: '请输入模型名称', defaultValue: 'gemini-2.5-pro' });
if (!modelName) return false;
}
const modelName = await selectProviderModel('google', 'Google Gemini 模型选择', 'gemini-2.5-pro');
if (!modelName) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
authSetApikey('google', apiKey);
registerAndSetModel(`google/${modelName}`);
@@ -694,26 +970,9 @@ async function configureOpenRouter() {
const apiKey = await input({ prompt: '请输入 OpenRouter API Key', placeholder: 'sk-or-...' });
if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
const modelChoice = await select({
title: 'OpenRouter 常用模型',
showSearch: false,
items: [
{ key: 'a', label: 'anthropic/claude-sonnet-4', desc: 'Claude Sonnet 4 (推荐)', value: 'anthropic/claude-sonnet-4' },
{ key: 'b', label: 'anthropic/claude-opus-4', desc: 'Claude Opus 4', value: 'anthropic/claude-opus-4' },
{ key: 'c', label: 'openai/gpt-5.2', desc: 'GPT-5.2', value: 'openai/gpt-5.2' },
{ key: 'd', label: 'google/gemini-2.5-pro', desc: 'Gemini 2.5 Pro', value: 'google/gemini-2.5-pro' },
{ key: 'e', label: 'deepseek/deepseek-r1', desc: 'DeepSeek R1', value: 'deepseek/deepseek-r1' },
{ key: 'f', label: 'meta-llama/llama-4-maverick', desc: 'Meta Llama 4', value: 'meta-llama/llama-4-maverick' },
{ key: 'g', label: '手动输入模型名', desc: '', value: 'custom' },
],
});
if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
let modelName = modelChoice.value;
if (modelChoice.value === 'custom') {
modelName = await input({ prompt: '请输入模型名称 (格式: provider/model)', defaultValue: 'anthropic/claude-sonnet-4' });
if (!modelName) return false;
}
// OpenRouter 的模型 ID 本身带上游 provider 前缀 (如 moonshotai/kimi-k2.6)
const modelName = await selectProviderModel('openrouter', 'OpenRouter 模型选择', 'auto');
if (!modelName) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
authSetApikey('openrouter', apiKey);
registerCustomProvider('openrouter', 'https://openrouter.ai/api/v1', apiKey, modelName, modelName);
@@ -734,31 +993,9 @@ async function configureCopilot() {
await ocCmd('models', 'auth', 'login-github-copilot', '--yes');
console.log(`\n${C.green}✅ GitHub Copilot OAuth 认证成功${C.reset}\n`);
const modelChoice = await select({
title: 'GitHub Copilot 模型选择',
showSearch: false,
items: [
{ key: 'a', label: 'gpt-4.1', desc: 'GPT-4.1 (推荐)', value: 'gpt-4.1' },
{ key: 'b', label: 'gpt-4o', desc: 'GPT-4o', value: 'gpt-4o' },
{ key: 'c', label: 'gpt-5', desc: 'GPT-5', value: 'gpt-5' },
{ key: 'd', label: 'gpt-5-mini', desc: 'GPT-5 mini', value: 'gpt-5-mini' },
{ key: 'e', label: 'gpt-5.1', desc: 'GPT-5.1', value: 'gpt-5.1' },
{ key: 'f', label: 'gpt-5.2', desc: 'GPT-5.2', value: 'gpt-5.2' },
{ key: 'g', label: 'gpt-5.2-codex', desc: 'GPT-5.2 Codex', value: 'gpt-5.2-codex' },
{ key: 'h', label: 'claude-sonnet-4', desc: 'Claude Sonnet 4', value: 'claude-sonnet-4' },
{ key: 'i', label: 'claude-sonnet-4.5', desc: 'Claude Sonnet 4.5', value: 'claude-sonnet-4.5' },
{ key: 'j', label: 'claude-sonnet-4.6', desc: 'Claude Sonnet 4.6', value: 'claude-sonnet-4.6' },
{ key: 'k', label: 'gemini-2.5-pro', desc: 'Gemini 2.5 Pro', value: 'gemini-2.5-pro' },
{ key: 'm', label: '手动输入模型名', desc: '', value: 'custom' },
],
});
if (modelChoice) {
let modelName = modelChoice.value;
if (modelChoice.value === 'custom') {
modelName = await input({ prompt: '请输入模型名称', defaultValue: 'gpt-4.1' });
if (!modelName) return false;
}
// 登录成功后动态发现通常可用,因此这里默认就尝试拉取一次
const modelName = await selectProviderModel('github-copilot', 'GitHub Copilot 模型选择', 'gpt-5.5');
if (modelName) {
registerAndSetModel(`github-copilot/${modelName}`);
console.log(`\n${C.green}✅ 活跃模型已设置: github-copilot/${modelName}${C.reset}\n`);
}
@@ -777,26 +1014,8 @@ async function configureXAI() {
const apiKey = await input({ prompt: '请输入 xAI API Key', placeholder: '' });
if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
const modelChoice = await select({
title: 'xAI Grok 模型选择',
showSearch: false,
items: [
{ key: 'a', label: 'grok-4', desc: 'Grok 4 旗舰 (推荐)', value: 'grok-4' },
{ key: 'b', label: 'grok-4-fast', desc: 'Grok 4 Fast', value: 'grok-4-fast' },
{ key: 'c', label: 'grok-3', desc: 'Grok 3', value: 'grok-3' },
{ key: 'd', label: 'grok-3-fast', desc: 'Grok 3 Fast', value: 'grok-3-fast' },
{ key: 'e', label: 'grok-3-mini', desc: 'Grok 3 Mini', value: 'grok-3-mini' },
{ key: 'f', label: 'grok-3-mini-fast', desc: 'Grok 3 Mini Fast', value: 'grok-3-mini-fast' },
{ key: 'g', label: '手动输入模型名', desc: '', value: 'custom' },
],
});
if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
let modelName = modelChoice.value;
if (modelChoice.value === 'custom') {
modelName = await input({ prompt: '请输入模型名称', defaultValue: 'grok-4' });
if (!modelName) return false;
}
const modelName = await selectProviderModel('xai', 'xAI Grok 模型选择', 'grok-4.3');
if (!modelName) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
authSetApikey('xai', apiKey);
registerAndSetModel(`xai/${modelName}`);
@@ -916,34 +1135,19 @@ async function configureSiliconFlow() {
const apiKey = await input({ prompt: '请输入 SiliconFlow API Key', placeholder: '' });
if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
console.log(`\n${C.cyan}可用模型分类说明:${C.reset}`);
console.log(`${C.yellow}* Pro模型: 仅支持充值余额支付${C.reset}`);
console.log(`${C.yellow}* 非Pro模型: 支持代金券/免费额度${C.reset}\n`);
// SiliconFlow 不是 OpenClaw 内置/官方插件 provider,走 OpenAI 兼容接入,
// 因此 `openclaw models list --provider siliconflow` 拿不到列表,
// 也无法用上游 catalog 核实模型 ID。
//
// 此处不再维护硬编码清单: 原列表里的 Qwen2.5-7B/72B、Yi-1.5-34B-Chat-16K、
// glm-4-9b-chat 均已明显过期,而我们无法自动判断哪些仍然有效。
// 让用户从官方模型广场复制当前 ID 更可靠,也不会随时间腐坏。
console.log(`\n${C.cyan}请从官方模型广场复制模型 ID:${C.reset}`);
console.log(` ${C.cyan}https://cloud.siliconflow.cn/models${C.reset}`);
console.log(`${C.dim}格式形如 deepseek-ai/DeepSeek-V3.2;Pro/ 前缀的模型仅支持充值余额支付${C.reset}\n`);
const modelChoice = await select({
title: 'SiliconFlow 模型选择',
showSearch: false,
items: [
{ label: `${C.cyan}── 非Pro模型 (支持代金券) ──${C.reset}`, disabled: true },
{ key: '1', label: 'deepseek-ai/DeepSeek-V3', desc: 'DeepSeek-V3 (推荐)', value: 'deepseek-ai/DeepSeek-V3' },
{ key: '2', label: 'deepseek-ai/DeepSeek-R1', desc: 'DeepSeek-R1 推理模型', value: 'deepseek-ai/DeepSeek-R1' },
{ key: '3', label: 'Qwen/Qwen2.5-72B-Instruct', desc: '通义千问 2.5 72B', value: 'Qwen/Qwen2.5-72B-Instruct' },
{ key: '4', label: 'Qwen/Qwen2.5-7B-Instruct', desc: '通义千问 2.5 7B', value: 'Qwen/Qwen2.5-7B-Instruct' },
{ key: '5', label: 'THUDM/glm-4-9b-chat', desc: '智谱 GLM-4 9B', value: 'THUDM/glm-4-9b-chat' },
{ key: '6', label: '01-ai/Yi-1.5-34B-Chat-16K', desc: '零一万物 Yi-1.5', value: '01-ai/Yi-1.5-34B-Chat-16K' },
{ label: `${C.cyan}── Pro模型 (仅支持充值余额) ──${C.reset}`, disabled: true },
{ key: '7', label: 'Pro/deepseek-ai/DeepSeek-V3', desc: 'DeepSeek-V3 (Pro)', value: 'Pro/deepseek-ai/DeepSeek-V3' },
{ key: '8', label: 'Pro/zai-org/GLM-5', desc: '智谱 GLM-5', value: 'Pro/zai-org/GLM-5' },
{ key: '0', label: '手动输入其他模型名称', desc: '', value: 'custom' },
],
});
if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
let modelName = modelChoice.value;
if (modelChoice.value === 'custom') {
modelName = await input({ prompt: '请输入模型详细名称', defaultValue: 'deepseek-ai/DeepSeek-V3' });
if (!modelName) return false;
}
const modelName = await input({ prompt: '请输入模型 ID', placeholder: 'deepseek-ai/DeepSeek-V3.2' });
if (!modelName) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
authSetApikey('siliconflow', apiKey);
registerCustomProvider('siliconflow', 'https://api.siliconflow.cn/v1', apiKey, modelName, modelName);
@@ -1001,24 +1205,8 @@ async function configureBaidu() {
const apiKey = await input({ prompt: '请输入百度千帆 API Key (Access Token)', placeholder: '' });
if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
const modelChoice = await select({
title: '百度千帆模型选择',
showSearch: false,
items: [
{ key: 'a', label: 'ernie-4.0-8k', desc: '文心一言 4.0 (推荐)', value: 'ernie-4.0-8k' },
{ key: 'b', label: 'ernie-3.5-8k', desc: '文心一言 3.5', value: 'ernie-3.5-8k' },
{ key: 'c', label: 'ernie-4.0-turbo-8k', desc: '文心一言 4.0 Turbo', value: 'ernie-4.0-turbo-8k' },
{ key: 'd', label: 'ernie-speed-8k', desc: '文心一言 Speed 极速', value: 'ernie-speed-8k' },
{ key: 'e', label: '手动输入模型名', desc: '', value: 'custom' },
],
});
if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
let modelName = modelChoice.value;
if (modelChoice.value === 'custom') {
modelName = await input({ prompt: '请输入模型名称', defaultValue: 'ernie-4.0-8k' });
if (!modelName) return false;
}
const modelName = await selectProviderModel('qianfan', '百度千帆模型选择', 'ernie-5.0-thinking-preview');
if (!modelName) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
authSetApikey('qianfan', apiKey);
registerCustomProvider('qianfan', 'https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop', apiKey, modelName, modelName);
@@ -1065,26 +1253,8 @@ async function configureZhipu() {
const apiKey = await input({ prompt: '请输入智谱 API Key', placeholder: '' });
if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
const modelChoice = await select({
title: '智谱 GLM 模型选择',
showSearch: false,
items: [
{ key: 'a', label: 'glm-5.1', desc: 'GLM-5.1 (推荐)', value: 'glm-5.1' },
{ key: 'b', label: 'glm-5', desc: 'GLM-5', value: 'glm-5' },
{ key: 'c', label: 'glm-4.7', desc: 'GLM-4.7', value: 'glm-4.7' },
{ key: 'd', label: 'glm-4.7-flash', desc: 'GLM-4.7 Flash', value: 'glm-4.7-flash' },
{ key: 'e', label: 'glm-4.5', desc: 'GLM-4.5', value: 'glm-4.5' },
{ key: 'f', label: 'glm-4.5-flash', desc: 'GLM-4.5 Flash (免费)', value: 'glm-4.5-flash' },
{ key: 'g', label: '手动输入模型名', desc: '', value: 'custom' },
],
});
if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
let modelName = modelChoice.value;
if (modelChoice.value === 'custom') {
modelName = await input({ prompt: '请输入模型名称', defaultValue: 'glm-5.1' });
if (!modelName) return false;
}
const modelName = await selectProviderModel('zai', '智谱 GLM 模型选择', 'glm-5.2');
if (!modelName) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
// 智谱 GLM 使用原生 zai provider (OpenClaw 内置支持)
registerCustomProvider('zai', baseUrl, apiKey, modelName, modelName, 128000, 4096);
@@ -1219,22 +1389,10 @@ async function configureCustomAnthropic() {
const apiKey = await input({ prompt: 'API Key', placeholder: 'sk-ant-...' });
if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
const modelChoice = await select({
title: 'Anthropic 模型选择',
showSearch: false,
items: [
{ key: 'a', label: 'claude-sonnet-4-20250514', desc: 'Claude Sonnet 4 (推荐)', value: 'claude-sonnet-4-20250514' },
{ key: 'b', label: 'claude-opus-4-20250514', desc: 'Claude Opus 4', value: 'claude-opus-4-20250514' },
{ key: 'c', label: '手动输入模型名', desc: '', value: 'custom' },
],
});
if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
let modelName = modelChoice.value;
if (modelChoice.value === 'custom') {
modelName = await input({ prompt: '请输入模型名称', defaultValue: 'claude-sonnet-4-20250514' });
if (!modelName) return false;
}
// 复用 anthropic 的精选预设作为候选 (第三方兼容服务通常沿用同名模型 ID),
// 但保留手动输入出口: 自建/代理服务的模型名可能完全不同。
const modelName = await selectProviderModel('anthropic', '模型选择 (可手动输入)', 'claude-sonnet-5');
if (!modelName) { console.log(`${C.yellow}已取消${C.reset}`); return false; }
const config = readConfig();
if (!config.models) config.models = {};
@@ -1375,7 +1533,7 @@ async function handleSetActiveModel() {
if (choice.value === 'manual') {
const manualModel = await input({
prompt: '请输入模型 ID',
placeholder: 'openai/gpt-4o',
placeholder: 'openai/gpt-5.6-sol',
defaultValue: currentModel || '',
});
if (manualModel) {
@@ -1531,10 +1689,100 @@ async function handleChannels() {
break;
}
case 'telegram-pairing': {
console.log(`\n${C.cyan}启动 Telegram 配对助手...${C.reset}\n`);
// 配对 (pairing) 与 Bot Token 配置是两件不同的事:
// - Token 配置 -> 上面的 'telegram' 分支 / channels add --bot-token
// - 用户配对 -> openclaw pairing list / pairing approve
// 旧实现调用的 `models auth login-telegram-bot` 在 2026.6+ 已不存在
// (实测报 Too many arguments for this command),这里改为对齐上游
// pairing 命令,与 oc-config.sh 的 telegram_pairing() 行为保持一致。
console.log(`\n${C.bold}🤝 Telegram 配对助手${C.reset}\n`);
const tgToken = jsonGet('channels.telegram.botToken');
if (!tgToken) {
console.log(`${C.yellow}未检测到 Telegram Bot Token,请先在「Telegram」中配置 Token。${C.reset}\n`);
await input({ prompt: '按回车返回', defaultValue: '' });
break;
}
// 先确认 Token 与网络可用,避免用户在配对环节盲等
console.log(`${C.cyan}诊断 Telegram API 连通性...${C.reset}`);
let botName = '';
try {
await ocCmd('models', 'auth', 'login-telegram-bot');
} catch (e) {}
const probe = execSync(
`curl -s --connect-timeout 5 --max-time 10 "https://api.telegram.org/bot${tgToken}/getMe"`,
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
);
if (/"ok"\s*:\s*true/.test(probe)) {
const m = probe.match(/"username"\s*:\s*"([^"]+)"/);
botName = m ? m[1] : '';
console.log(`${C.green}✅ Telegram API 连通正常${botName ? ` — @${botName}` : ''}${C.reset}`);
} else {
console.log(`${C.red}❌ Telegram API 连通检测未通过${C.reset}`);
console.log(`${C.yellow} 可能原因: Token 不正确、网络不通 或 Telegram 被屏蔽${C.reset}`);
console.log(`${C.cyan} 建议: 重新配置 Token 或检查代理/网络设置${C.reset}\n`);
await input({ prompt: '按回车返回', defaultValue: '' });
break;
}
} catch {
console.log(`${C.yellow}⚠️ 无法完成连通性检测,继续尝试配对${C.reset}`);
}
console.log('');
console.log(`${C.green}请在 Telegram 中向 Bot 发送 /start,然后回到这里继续${C.reset}`);
console.log('');
const go = await input({ prompt: '发送 /start 后按回车继续 (输入 q 退出)', defaultValue: '' });
if (String(go).toLowerCase() === 'q') break;
const approveCode = (code) => {
const res = ocCmdCapture('pairing', 'approve', 'telegram', code);
return res.ok || /approved|success|ok/i.test(res.stdout);
};
let paired = false;
for (let attempt = 1; attempt <= 3 && !paired; attempt++) {
console.log(`${C.cyan}检测配对请求... (第 ${attempt}/3 轮)${C.reset}`);
const listed = ocCmdCapture('pairing', 'list', 'telegram', '--json');
const codes = parsePairingCodes(listed.stdout);
for (const code of codes) {
console.log(`${C.cyan}发现配对请求: ${code}${C.reset}`);
if (approveCode(code)) {
console.log(`\n${C.green}${C.bold}🎉 Telegram 配对成功!${C.reset}`);
paired = true;
} else {
console.log(`${C.yellow}配对码 ${code} 处理失败${C.reset}`);
}
}
if (!paired && attempt < 3) {
console.log(`${C.yellow}未检测到,等待 8 秒后重试...${C.reset}`);
await new Promise((r) => setTimeout(r, 8000));
}
}
if (!paired) {
console.log('');
console.log(`${C.yellow}未自动检测到配对请求。${C.reset}`);
console.log(`${C.cyan}如果 Bot 已回复配对码,可手动输入 (回车跳过):${C.reset}`);
const manual = await input({ prompt: '配对码', defaultValue: '' });
if (manual) {
if (approveCode(String(manual).trim())) {
console.log(`${C.green}${C.bold}🎉 Telegram 配对成功!${C.reset}`);
paired = true;
} else {
console.log(`${C.yellow}配对失败,请确认配对码是否正确或重新发送 /start${C.reset}`);
}
}
}
if (paired) {
console.log(`\n${C.cyan}正在重启 Gateway 使配对生效...${C.reset}`);
await restartGateway();
console.log(`${C.green}✅ 现在可以在 Telegram 中与 Bot 对话了!${C.reset}\n`);
} else {
console.log('');
await input({ prompt: '按回车返回', defaultValue: '' });
}
break;
}
case 'wizard': {
@@ -1673,12 +1921,21 @@ async function handleAdvancedConfig() {
title: '绑定地址选项',
showSearch: false,
items: [
// 取值必须与 schema 的 gateway.bind 枚举一致:
// auto/lan/loopback/custom/tailnet。旧选项 all 不被上游接受
// (实测: gateway.bind: Invalid input),监听所有接口用 custom。
{ key: '1', label: 'lan', desc: '仅 LAN 接口 (推荐)', value: 'lan' },
{ key: '2', label: 'loopback', desc: '仅本机访问', value: 'loopback' },
{ key: '3', label: 'all', desc: '所有接口 (0.0.0.0)', value: 'all' },
{ key: '3', label: 'auto', desc: '自动选择', value: 'auto' },
{ key: '4', label: 'custom', desc: '自定义地址 (0.0.0.0 = 所有接口)', value: 'custom' },
{ key: '5', label: 'tailnet', desc: 'Tailscale 网络', value: 'tailnet' },
],
});
if (bindChoice) {
if (bindChoice.value === 'custom') {
const host = await input({ prompt: '监听地址', defaultValue: jsonGet('gateway.customBindHost') || '0.0.0.0' });
if (host) jsonSet('gateway.customBindHost', host);
}
jsonSet('gateway.bind', bindChoice.value);
try { execSync(`uci set openclaw.main.bind="${bindChoice.value}" && uci commit openclaw`, { stdio: 'ignore' }); } catch {}
console.log(`\n${C.green}✅ 绑定地址已设置为 ${bindChoice.value}${C.reset}\n`);
@@ -1707,14 +1964,28 @@ async function handleAdvancedConfig() {
title: '日志级别选项',
showSearch: false,
items: [
{ key: '1', label: 'debug', desc: '详细调试', value: 'debug' },
{ key: '2', label: 'info', desc: '常规信息', value: 'info' },
{ key: '3', label: 'warn', desc: '警告及以上', value: 'warn' },
{ key: '4', label: 'error', desc: '仅错误', value: 'error' },
// 取值与 schema 的 logging.level 枚举一致 (7 档)
{ key: '1', label: 'info', desc: '常规信息 (默认)', value: 'info' },
{ key: '2', label: 'warn', desc: '警告及以上', value: 'warn' },
{ key: '3', label: 'error', desc: '仅错误', value: 'error' },
{ key: '4', label: 'debug', desc: '详细调试', value: 'debug' },
{ key: '5', label: 'trace', desc: '最详细 (排障用)', value: 'trace' },
{ key: '6', label: 'fatal', desc: '仅致命错误', value: 'fatal' },
{ key: '7', label: 'silent', desc: '完全静默', value: 'silent' },
],
});
if (levelChoice) {
jsonSet('gateway.logLevel', levelChoice.value);
// 正确键是顶层 logging.level;gateway.logLevel 不在 schema 中,
// 写入会被上游忽略,表现为"显示已设置但从未生效"。
jsonSet('logging.level', levelChoice.value);
// 清理旧配置里可能残留的错误键,避免界面回显到失效值
try {
const cfg = readConfig();
if (cfg.gateway && Object.prototype.hasOwnProperty.call(cfg.gateway, 'logLevel')) {
delete cfg.gateway.logLevel;
writeConfig(cfg);
}
} catch {}
console.log(`\n${C.green}✅ 日志级别已设置为 ${levelChoice.value}${C.reset}\n`);
await askRestart();
}
@@ -2076,6 +2347,13 @@ async function main() {
}
main().catch(e => {
if (e instanceof ConfigParseError) {
// 配置损坏: 给出可直接执行的恢复步骤,而不是丢一句解析错误了事
console.error(`\n${C.red}配置读取失败:${C.reset} ${e.message}\n`);
console.error(configRecoveryHint());
console.error('');
process.exit(2);
}
console.error(`${C.red}错误:${C.reset}`, e.message);
process.exit(1);
});
@@ -126,18 +126,166 @@ oc_doctor_as_openclaw() {
fi
}
# ── 精选模型预设 (与 JS 侧共读同一份数据源) ──
# 见 root/usr/share/openclaw/model-presets.json。
# 三层架构: 精选预设 -> 动态发现 (openclaw models list --provider) -> 手动输入。
OC_MODEL_PRESETS="${OC_MODEL_PRESETS:-/usr/share/openclaw/model-presets.json}"
OC_MODEL_DISCOVERY_TIMEOUT="${OC_MODEL_DISCOVERY_TIMEOUT:-6}"
# oc_preset_models <provider> — 输出该 provider 的精选模型,每行 "model<TAB>desc"
oc_preset_models() {
[ -f "$OC_MODEL_PRESETS" ] || return 0
[ -x "$NODE_BIN" ] || return 0
_OC_PP="$1" _OC_PF="$OC_MODEL_PRESETS" "$NODE_BIN" -e '
const fs=require("fs");
try{
const j=JSON.parse(fs.readFileSync(process.env._OC_PF,"utf8"));
const p=(j.providers||{})[process.env._OC_PP];
if(!p||!Array.isArray(p.models))process.exit(0);
for(const m of p.models){
if(m&&m.model)console.log(m.model+"\t"+(m.desc||""));
}
}catch(e){}
' 2>/dev/null
}
# oc_discover_models <provider> — 动态发现当前可用模型 (失败/超时输出空)
# 上游行为: models list --all 不是各 provider 的超集,必须按 provider 查询;
# 未安装对应插件时返回 "No models found.",属正常情况。
# 带 timeout: 避免在 OpenWrt 上把菜单卡死。
oc_discover_models() {
local provider="$1"
[ -x "$NODE_BIN" ] || return 0
[ -n "$OC_ENTRY" ] || return 0
timeout "$OC_MODEL_DISCOVERY_TIMEOUT" "$NODE_BIN" "$OC_ENTRY" models list \
--provider "$provider" --plain 2>/dev/null \
| sed -n "s|^${provider}/||p" \
| grep -v '^[[:space:]]*$'
}
# oc_pick_model <provider> <默认模型> — 交互式选择模型,结果写入全局 OC_PICKED_MODEL
# 菜单: 精选预设 (数字) / d 动态发现 / m 手动输入
oc_pick_model() {
local provider="$1" fallback="$2"
local list idx line model desc choice
OC_PICKED_MODEL=""
list="$(oc_preset_models "$provider")"
echo ""
if [ -n "$list" ]; then
echo -e " ${CYAN}精选模型:${NC}"
idx=1
printf '%s\n' "$list" | while IFS="$(printf '\t')" read -r model desc; do
[ -n "$model" ] || continue
echo -e " ${CYAN}${idx})${NC} ${model}${desc:+ — ${desc}}"
idx=$((idx + 1))
done
else
echo -e " ${YELLOW}该 Provider 无内置精选列表${NC}"
fi
echo -e " ${CYAN}d)${NC} 从 OpenClaw 获取完整模型列表"
echo -e " ${CYAN}m)${NC} 手动输入模型 ID"
echo ""
prompt_with_default "请选择模型" "1" choice
case "$choice" in
d|D)
echo -e " ${CYAN}正在获取模型列表...${NC}"
local found
found="$(oc_discover_models "$provider")"
if [ -z "$found" ]; then
echo -e " ${YELLOW}未获取到模型列表 (可能需先安装插件或配置 API Key)${NC}"
prompt_with_default "请手动输入模型 ID" "$fallback" OC_PICKED_MODEL
else
echo ""
idx=1
printf '%s\n' "$found" | while read -r model; do
[ -n "$model" ] || continue
echo -e " ${CYAN}${idx})${NC} ${model}"
idx=$((idx + 1))
done
echo ""
prompt_with_default "请输入序号或直接输入模型 ID" "1" choice
case "$choice" in
''|*[!0-9]*) OC_PICKED_MODEL="$choice" ;;
*) OC_PICKED_MODEL="$(printf '%s\n' "$found" | sed -n "${choice}p")" ;;
esac
fi
;;
m|M)
prompt_with_default "请输入模型 ID" "$fallback" OC_PICKED_MODEL
;;
''|*[!0-9]*)
# 非数字直接当作手动输入的模型 ID
OC_PICKED_MODEL="$choice"
;;
*)
OC_PICKED_MODEL="$(printf '%s\n' "$list" | sed -n "${choice}p" | cut -f1)"
;;
esac
[ -n "$OC_PICKED_MODEL" ] || OC_PICKED_MODEL="$fallback"
[ -n "$OC_PICKED_MODEL" ]
}
# ── JSON 读写 (使用 Node.js) ──
json_get() {
if [ ! -f "$CONFIG_FILE" ]; then echo ""; return; fi
_JS_KEY="$1" "$NODE_BIN" -e "
const fs=require('fs');
_OC_JS_KEY="$1" _OC_JS_CONFIG="$CONFIG_FILE" "$NODE_BIN" -e '
const fs=require("fs");
try{
const d=JSON.parse(fs.readFileSync('${CONFIG_FILE}','utf8'));
const ks=process.env._JS_KEY.split('.');let v=d;
for(const k of ks){v=v[k];if(v===undefined){console.log('');process.exit(0);}}
if(typeof v==='object')console.log(JSON.stringify(v));else console.log(v);
}catch(e){console.log('');}
" 2>/dev/null
const d=JSON.parse(fs.readFileSync(process.env._OC_JS_CONFIG,"utf8"));
const ks=process.env._OC_JS_KEY.split(".");let v=d;
for(const k of ks){v=v[k];if(v===undefined||v===null){console.log("");process.exit(0);}}
if(typeof v==="object")console.log(JSON.stringify(v));else console.log(v);
}catch(e){console.log("");}
' 2>/dev/null
}
# ── 配置写入类型表 ──
# 依据: OpenClaw 官方 schema,基线见 tests/fixtures/openclaw-schema-types.tsv
#
# 上游对配置类型做严格校验: gateway.port 必须是 integer、
# acp.dispatch.enabled / channels.*.enabled 必须是 boolean。
# 写成字符串会导致 openclaw config validate 失败,进而网关拒绝启动
# (实测报错: gateway.port: Invalid input / acp.dispatch.enabled: Invalid input)。
#
# 用集中表而非在每个调用点传类型,保证现有与将来新增的调用点都默认正确。
oc_schema_type_for_key() {
case "$1" in
gateway.port|gateway.handshakeTimeoutMs|gateway.channelHealthCheckMinutes)
echo "number" ;;
gateway.channelStaleEventThresholdMinutes|gateway.channelMaxRestartsPerHour)
echo "number" ;;
acp.dispatch.enabled|update.checkOnStart|plugins.enabled)
echo "boolean" ;;
gateway.controlUi.allowInsecureAuth)
echo "boolean" ;;
gateway.controlUi.dangerouslyDisableDeviceAuth)
echo "boolean" ;;
gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback)
echo "boolean" ;;
gateway.allowRealIpFallback)
echo "boolean" ;;
channels.*.enabled)
echo "boolean" ;;
*)
echo "string" ;;
esac
}
# 枚举白名单。写入前校验,避免把上游不接受的值(如 gateway.bind=all)落盘。
oc_schema_enum_for_key() {
case "$1" in
gateway.bind) echo "auto lan loopback custom tailnet" ;;
gateway.mode) echo "local remote" ;;
gateway.auth.mode) echo "none token password trusted-proxy" ;;
logging.level) echo "silent fatal error warn info debug trace" ;;
tools.profile) echo "minimal coding messaging full" ;;
models.mode) echo "merge replace" ;;
*) echo "" ;;
esac
}
json_set() {
@@ -188,53 +336,121 @@ json_set() {
return 1
fi
# 步骤4: 使用临时文件传递值,避免环境变量转义问题
# 步骤4: 解析目标类型并校验枚举
# 第三个参数可显式指定类型 (string/number/boolean/json),
# 未指定时按 schema 类型表自动判定。
local val_type="${3:-}"
[ -n "$val_type" ] || val_type="$(oc_schema_type_for_key "$key")"
local allowed
allowed="$(oc_schema_enum_for_key "$key")"
if [ -n "$allowed" ]; then
local matched=0 a
for a in $allowed; do
[ "$value" = "$a" ] && { matched=1; break; }
done
if [ "$matched" -ne 1 ]; then
echo "ERROR: $key 的值 '$value' 不被 OpenClaw 接受" >&2
echo "HINT: 允许的值: $allowed" >&2
return 1
fi
fi
# 步骤5: 使用临时文件传递值,避免环境变量转义问题
local tmp_val_file="/tmp/.oc_json_val_$$"
if ! printf '%s' "$value" > "$tmp_val_file" 2>/dev/null; then
echo "ERROR: 无法创建临时文件 $tmp_val_file" >&2
return 1
fi
# 步骤5: 执行 JSON 写入
_JS_KEY="$key" _JS_DEBUG="${OC_CONFIG_DEBUG:-0}" "$NODE_BIN" -e "
const fs=require('fs');let d={};
const debug=process.env._JS_DEBUG==='1';
try{
const content=fs.readFileSync('${CONFIG_FILE}','utf8');
d=JSON.parse(content);
}catch(e){
if(debug)console.error('JSON parse warning:',e.message);
# 步骤6: 执行 JSON 写入
# 与 JS 侧同样的安全要求:
# - 配置存在但解析失败时必须中止,不能用 {} 覆盖 (否则整份配置连同
# models.providers 里的 apiKey 一起丢失)
# - 按 schema 类型落值,不能一律写成字符串
# - 原子写: 临时文件 -> 回读校验 -> rename
_OC_JS_KEY="$key" _OC_JS_TYPE="$val_type" _OC_JS_VALFILE="$tmp_val_file" \
_OC_JS_CONFIG="$CONFIG_FILE" _OC_JS_DEBUG="${OC_CONFIG_DEBUG:-0}" "$NODE_BIN" -e '
const fs=require("fs");
const debug=process.env._OC_JS_DEBUG==="1";
const target=process.env._OC_JS_CONFIG;
let d={};
let raw="";
try{ raw=fs.readFileSync(target,"utf8"); }catch(e){ raw=""; }
if(raw.trim()!==""){
try{
d=JSON.parse(raw);
}catch(e){
console.error("ERROR: 配置文件不是合法 JSON: "+e.message);
console.error("HINT: 为避免覆盖后丢失全部配置,本次写入已中止。");
console.error("HINT: 可尝试 openclaw doctor --fix 或 openclaw config validate 定位问题。");
process.exit(2);
}
if(d===null||typeof d!=="object"||Array.isArray(d)){
console.error("ERROR: 配置根节点必须是 JSON 对象,本次写入已中止。");
process.exit(2);
}
}
const ks=process.env._JS_KEY.split('.');let o=d;
const rawVal=fs.readFileSync(process.env._OC_JS_VALFILE,"utf8");
const wantType=process.env._OC_JS_TYPE||"string";
let v;
if(wantType==="number"){
v=Number(rawVal.trim());
if(!Number.isFinite(v)){
console.error("ERROR: "+process.env._OC_JS_KEY+" 需要数字,收到: "+rawVal.trim());
process.exit(3);
}
}else if(wantType==="boolean"){
const t=rawVal.trim().toLowerCase();
if(t==="true"||t==="1")v=true;
else if(t==="false"||t==="0")v=false;
else{
console.error("ERROR: "+process.env._OC_JS_KEY+" 需要 true/false,收到: "+rawVal.trim());
process.exit(3);
}
}else if(wantType==="json"){
try{ v=JSON.parse(rawVal); }catch(e){
console.error("ERROR: "+process.env._OC_JS_KEY+" 需要合法 JSON: "+e.message);
process.exit(3);
}
}else{
v=rawVal;
}
const ks=process.env._OC_JS_KEY.split(".");let o=d;
for(let i=0;i<ks.length-1;i++){
if(!o[ks[i]]||typeof o[ks[i]]!=='object')o[ks[i]]={};
if(!o[ks[i]]||typeof o[ks[i]]!=="object"||Array.isArray(o[ks[i]]))o[ks[i]]={};
o=o[ks[i]];
}
// 读取值并作为字符串保存
let v=fs.readFileSync('${tmp_val_file}','utf8');
o[ks[ks.length-1]]=v;
let mode=0o644;
try{ if(fs.existsSync(target)) mode=fs.statSync(target).mode & 0o777; }catch(e){}
const tmp=target+".tmp-"+process.pid;
try{
fs.writeFileSync('${CONFIG_FILE}',JSON.stringify(d,null,2));
if(debug)console.log('JSON saved successfully');
fs.writeFileSync(tmp,JSON.stringify(d,null,2)+"\n",{mode:0o600});
JSON.parse(fs.readFileSync(tmp,"utf8"));
try{ fs.chmodSync(tmp,mode); }catch(e){}
fs.renameSync(tmp,target);
if(debug)console.log("JSON saved successfully ("+wantType+")");
}catch(e){
console.error('ERROR: Failed to write config:',e.message);
try{ if(fs.existsSync(tmp)) fs.unlinkSync(tmp); }catch(e2){}
console.error("ERROR: Failed to write config: "+e.message);
process.exit(1);
}
" 2>&1
' 2>&1
local _js_rc=$?
# 清理临时文件
rm -f "$tmp_val_file" 2>/dev/null
# 步骤6: 检查执行结果
# 步骤7: 检查执行结果
if [ $_js_rc -ne 0 ]; then
echo "ERROR: JSON 写入失败 (exit code: $_js_rc)" >&2
return 1
fi
# 步骤7: 修复文件所有权
# 步骤8: 修复文件所有权
chown openclaw:openclaw "$CONFIG_FILE" 2>/dev/null || true
return 0
}
@@ -765,26 +981,10 @@ configure_model() {
if [ -n "$api_key" ]; then
auth_set_apikey openai "$api_key"
echo ""
echo -e " ${CYAN}可用模型:${NC}"
echo -e " ${CYAN}a)${NC} gpt-5.2 — 最强编程与代理旗舰 (推荐)"
echo -e " ${CYAN}b)${NC} gpt-5-mini — 高性价比推理"
echo -e " ${CYAN}c)${NC} gpt-5-nano — 极速低成本"
echo -e " ${CYAN}d)${NC} gpt-4.1 — 最强非推理模型"
echo -e " ${CYAN}e)${NC} o3 — 推理模型"
echo -e " ${CYAN}f)${NC} o4-mini — 推理轻量"
echo -e " ${CYAN}g)${NC} 手动输入模型名"
echo ""
prompt_with_default "请选择模型" "a" model_choice
case "$model_choice" in
a) model_name="gpt-5.2" ;;
b) model_name="gpt-5-mini" ;;
c) model_name="gpt-5-nano" ;;
d) model_name="gpt-4.1" ;;
e) model_name="o3" ;;
f) model_name="o4-mini" ;;
g) prompt_with_default "请输入模型名称" "gpt-5.2" model_name ;;
*) model_name="gpt-5.2" ;;
esac
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
oc_pick_model openai "gpt-5.6-sol"
model_name="$OC_PICKED_MODEL"
register_and_set_model "openai/${model_name}"
echo -e " ${GREEN}✅ OpenAI 已配置,活跃模型: openai/${model_name}${NC}"
fi
@@ -798,24 +998,10 @@ configure_model() {
if [ -n "$api_key" ]; then
auth_set_apikey anthropic "$api_key"
echo ""
echo -e " ${CYAN}可用模型:${NC}"
echo -e " ${CYAN}a)${NC} claude-sonnet-4-20250514 — Claude Sonnet 4 (推荐)"
echo -e " ${CYAN}b)${NC} claude-opus-4-20250514 — Claude Opus 4 顶级推理"
echo -e " ${CYAN}c)${NC} claude-haiku-4-5 — Claude Haiku 4.5 轻量快速"
echo -e " ${CYAN}d)${NC} claude-sonnet-4.5 — Claude Sonnet 4.5"
echo -e " ${CYAN}e)${NC} claude-sonnet-4.6 — Claude Sonnet 4.6"
echo -e " ${CYAN}f)${NC} 手动输入模型名"
echo ""
prompt_with_default "请选择模型" "a" model_choice
case "$model_choice" in
a) model_name="claude-sonnet-4-20250514" ;;
b) model_name="claude-opus-4-20250514" ;;
c) model_name="claude-haiku-4-5" ;;
d) model_name="claude-sonnet-4-5" ;;
e) model_name="claude-sonnet-4-6" ;;
f) prompt_with_default "请输入模型名称" "claude-sonnet-4-20250514" model_name ;;
*) model_name="claude-sonnet-4-20250514" ;;
esac
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
oc_pick_model anthropic "claude-sonnet-5"
model_name="$OC_PICKED_MODEL"
register_and_set_model "anthropic/${model_name}"
echo -e " ${GREEN}✅ Anthropic 已配置,活跃模型: anthropic/${model_name}${NC}"
fi
@@ -829,24 +1015,10 @@ configure_model() {
if [ -n "$api_key" ]; then
auth_set_apikey google "$api_key"
echo ""
echo -e " ${CYAN}可用模型:${NC}"
echo -e " ${CYAN}a)${NC} gemini-2.5-pro — 旗舰推理 (推荐)"
echo -e " ${CYAN}b)${NC} gemini-2.5-flash — 快速均衡"
echo -e " ${CYAN}c)${NC} gemini-2.5-flash-lite — 极速低成本"
echo -e " ${CYAN}d)${NC} gemini-3-flash-preview — Gemini 3 Flash 预览"
echo -e " ${CYAN}e)${NC} gemini-3-pro-preview — Gemini 3 Pro 预览"
echo -e " ${CYAN}f)${NC} 手动输入模型名"
echo ""
prompt_with_default "请选择模型" "a" model_choice
case "$model_choice" in
a) model_name="gemini-2.5-pro" ;;
b) model_name="gemini-2.5-flash" ;;
c) model_name="gemini-2.5-flash-lite" ;;
d) model_name="gemini-3-flash-preview" ;;
e) model_name="gemini-3-pro-preview" ;;
f) prompt_with_default "请输入模型名称" "gemini-2.5-pro" model_name ;;
*) model_name="gemini-2.5-pro" ;;
esac
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
oc_pick_model google "gemini-2.5-pro"
model_name="$OC_PICKED_MODEL"
register_and_set_model "google/${model_name}"
echo -e " ${GREEN}✅ Google Gemini 已配置,活跃模型: google/${model_name}${NC}"
fi
@@ -861,26 +1033,10 @@ configure_model() {
if [ -n "$api_key" ]; then
auth_set_apikey openrouter "$api_key"
echo ""
echo -e " ${CYAN}常用模型 (格式: provider/model):${NC}"
echo -e " ${CYAN}a)${NC} anthropic/claude-sonnet-4 — Claude Sonnet 4 (推荐)"
echo -e " ${CYAN}b)${NC} anthropic/claude-opus-4 — Claude Opus 4"
echo -e " ${CYAN}c)${NC} openai/gpt-5.2 — GPT-5.2"
echo -e " ${CYAN}d)${NC} google/gemini-2.5-pro — Gemini 2.5 Pro"
echo -e " ${CYAN}e)${NC} deepseek/deepseek-r1 — DeepSeek R1"
echo -e " ${CYAN}f)${NC} meta-llama/llama-4-maverick — Meta Llama 4"
echo -e " ${CYAN}g)${NC} 手动输入模型名"
echo ""
prompt_with_default "请选择模型" "a" model_choice
case "$model_choice" in
a) model_name="anthropic/claude-sonnet-4" ;;
b) model_name="anthropic/claude-opus-4" ;;
c) model_name="openai/gpt-5.2" ;;
d) model_name="google/gemini-2.5-pro" ;;
e) model_name="deepseek/deepseek-r1" ;;
f) model_name="meta-llama/llama-4-maverick" ;;
g) prompt_with_default "请输入模型名称" "anthropic/claude-sonnet-4" model_name ;;
*) model_name="anthropic/claude-sonnet-4" ;;
esac
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
oc_pick_model openrouter "auto"
model_name="$OC_PICKED_MODEL"
register_and_set_model "openrouter/${model_name}"
echo -e " ${GREEN}✅ OpenRouter 已配置,活跃模型: openrouter/${model_name}${NC}"
fi
@@ -900,40 +1056,10 @@ configure_model() {
echo -e " ${CYAN}选择默认模型:${NC}"
echo ""
echo -e " ${CYAN}── GPT 系列 ──${NC}"
echo -e " ${CYAN}a)${NC} github-copilot/gpt-4.1 — GPT-4.1 ${GREEN}(推荐)${NC}"
echo -e " ${CYAN}b)${NC} github-copilot/gpt-4o — GPT-4o"
echo -e " ${CYAN}c)${NC} github-copilot/gpt-5 — GPT-5"
echo -e " ${CYAN}d)${NC} github-copilot/gpt-5-mini — GPT-5 mini"
echo -e " ${CYAN}e)${NC} github-copilot/gpt-5.1 — GPT-5.1"
echo -e " ${CYAN}f)${NC} github-copilot/gpt-5.2 — GPT-5.2"
echo -e " ${CYAN}g)${NC} github-copilot/gpt-5.2-codex — GPT-5.2 Codex"
echo ""
echo -e " ${CYAN}── Claude 系列 ──${NC}"
echo -e " ${CYAN}h)${NC} github-copilot/claude-sonnet-4 — Claude Sonnet 4"
echo -e " ${CYAN}i)${NC} github-copilot/claude-sonnet-4.5 — Claude Sonnet 4.5"
echo -e " ${CYAN}j)${NC} github-copilot/claude-sonnet-4.6 — Claude Sonnet 4.6"
echo ""
echo -e " ${CYAN}── Gemini 系列 ──${NC}"
echo -e " ${CYAN}k)${NC} github-copilot/gemini-2.5-pro — Gemini 2.5 Pro"
echo ""
echo -e " ${CYAN}m)${NC} 手动输入模型名"
echo ""
prompt_with_default "请选择模型" "a" model_choice
case "$model_choice" in
a) model_name="github-copilot/gpt-4.1" ;;
b) model_name="github-copilot/gpt-4o" ;;
c) model_name="github-copilot/gpt-5" ;;
d) model_name="github-copilot/gpt-5-mini" ;;
e) model_name="github-copilot/gpt-5.1" ;;
f) model_name="github-copilot/gpt-5.2" ;;
g) model_name="github-copilot/gpt-5.2-codex" ;;
h) model_name="github-copilot/claude-sonnet-4" ;;
i) model_name="github-copilot/claude-sonnet-4.5" ;;
j) model_name="github-copilot/claude-sonnet-4.6" ;;
k) model_name="github-copilot/gemini-2.5-pro" ;;
m) prompt_with_default "请输入模型名称" "github-copilot/gpt-4.1" model_name ;;
*) model_name="github-copilot/gpt-4.1" ;;
esac
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
oc_pick_model github-copilot "gpt-5.5"
model_name="github-copilot/$OC_PICKED_MODEL"
register_and_set_model "$model_name"
echo -e " ${GREEN}✅ 活跃模型已设置: ${model_name}${NC}"
else
@@ -973,50 +1099,10 @@ configure_model() {
if [ -n "$api_key" ]; then
echo ""
echo -e " ${CYAN}── 千问商业版 ──${NC}"
echo -e " ${CYAN}a)${NC} qwen-max — 千问Max 旗舰模型 (推荐)"
echo -e " ${CYAN}b)${NC} qwen-plus — 千问Plus 均衡之选 (已升级Qwen3.5)"
echo -e " ${CYAN}c)${NC} qwen-flash — 千问Flash 速度最快 (已升级Qwen3.5)"
echo -e " ${CYAN}d)${NC} qwen-turbo — 千问Turbo 经济实惠"
echo -e " ${CYAN}e)${NC} qwen-long — 千问Long 超长上下文 (1000万Token)"
echo -e " ${CYAN}── 千问Coder ──${NC}"
echo -e " ${CYAN}f)${NC} qwen3-coder-plus — 代码专用旗舰 (100万上下文)"
echo -e " ${CYAN}g)${NC} qwen3-coder-flash — 代码专用极速"
echo -e " ${CYAN}── 推理模型 ──${NC}"
echo -e " ${CYAN}h)${NC} qwq-plus — QwQ推理模型 (数学/代码强化)"
echo -e " ${CYAN}── 千问开源版 ──${NC}"
echo -e " ${CYAN}i)${NC} qwen3-235b-a22b — Qwen3 235B MoE"
echo -e " ${CYAN}j)${NC} qwen3-32b — Qwen3 32B"
echo -e " ${CYAN}k)${NC} qwen3-30b-a3b — Qwen3 30B MoE"
echo -e " ${CYAN}── 第三方模型 ──${NC}"
echo -e " ${CYAN}l)${NC} deepseek-r1 — DeepSeek R1 推理"
echo -e " ${CYAN}m)${NC} deepseek-v3 — DeepSeek V3"
echo -e " ${CYAN}n)${NC} kimi-k2.5 — Kimi K2.5"
echo -e " ${CYAN}o)${NC} glm-5 — 智谱 GLM-5"
echo -e " ${CYAN}p)${NC} MiniMax-M2.5 — MiniMax M2.5"
echo -e " ${CYAN}────────────${NC}"
echo -e " ${CYAN}z)${NC} 手动输入模型名"
echo ""
prompt_with_default "请选择模型" "a" model_choice
case "$model_choice" in
a) model_name="qwen-max" ;;
b) model_name="qwen-plus" ;;
c) model_name="qwen-flash" ;;
d) model_name="qwen-turbo" ;;
e) model_name="qwen-long" ;;
f) model_name="qwen3-coder-plus" ;;
g) model_name="qwen3-coder-flash" ;;
h) model_name="qwq-plus" ;;
i) model_name="qwen3-235b-a22b" ;;
j) model_name="qwen3-32b" ;;
k) model_name="qwen3-30b-a3b" ;;
l) model_name="deepseek-r1" ;;
m) model_name="deepseek-v3" ;;
n) model_name="kimi-k2.5" ;;
o) model_name="glm-5" ;;
p) model_name="MiniMax-M2.5" ;;
z) prompt_with_default "请输入模型名称" "qwen-max" model_name ;;
*) model_name="qwen-max" ;;
esac
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
oc_pick_model qwen "qwen3.5-plus"
model_name="$OC_PICKED_MODEL"
auth_set_apikey dashscope "$api_key"
register_custom_provider dashscope "https://dashscope.aliyuncs.com/compatible-mode/v1" "$api_key" "$model_name" "$model_name"
register_and_set_model "dashscope/${model_name}"
@@ -1075,26 +1161,10 @@ configure_model() {
prompt_with_default "请输入 xAI API Key" "" api_key
if [ -n "$api_key" ]; then
echo ""
echo -e " ${CYAN}可用模型:${NC}"
echo -e " ${CYAN}a)${NC} grok-4 — Grok 4 旗舰 (推荐)"
echo -e " ${CYAN}b)${NC} grok-4-fast — Grok 4 Fast"
echo -e " ${CYAN}c)${NC} grok-3 — Grok 3"
echo -e " ${CYAN}d)${NC} grok-3-fast — Grok 3 Fast"
echo -e " ${CYAN}e)${NC} grok-3-mini — Grok 3 Mini"
echo -e " ${CYAN}f)${NC} grok-3-mini-fast — Grok 3 Mini Fast"
echo -e " ${CYAN}g)${NC} 手动输入模型名"
echo ""
prompt_with_default "请选择模型" "a" model_choice
case "$model_choice" in
a) model_name="grok-4" ;;
b) model_name="grok-4-fast" ;;
c) model_name="grok-3" ;;
d) model_name="grok-3-fast" ;;
e) model_name="grok-3-mini" ;;
f) model_name="grok-3-mini-fast" ;;
g) prompt_with_default "请输入模型名称" "grok-4" model_name ;;
*) model_name="grok-4" ;;
esac
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
oc_pick_model xai "grok-4.3"
model_name="$OC_PICKED_MODEL"
auth_set_apikey xai "$api_key"
register_and_set_model "xai/${model_name}"
echo -e " ${GREEN}✅ xAI Grok 已配置,活跃模型: xai/${model_name}${NC}"
@@ -1117,30 +1187,15 @@ configure_model() {
echo -e " ${CYAN}── 非Pro模型 (支持代金券/免费额度) ──${NC}"
echo -e " ${CYAN}1)${NC} deepseek-ai/DeepSeek-V3 — DeepSeek-V3 (推荐)"
echo -e " ${CYAN}2)${NC} deepseek-ai/DeepSeek-R1 — DeepSeek-R1 (推理模型)"
echo -e " ${CYAN}3)${NC} Qwen/Qwen2.5-72B-Instruct — 通义千问 2.5 72B"
echo -e " ${CYAN}4)${NC} Qwen/Qwen2.5-7B-Instruct — 通义千问 2.5 7B"
echo -e " ${CYAN}5)${NC} THUDM/glm-4-9b-chat — 智谱 GLM-4 9B"
echo -e " ${CYAN}6)${NC} 01-ai/Yi-1.5-34B-Chat-16K — 零一万物 Yi-1.5 34B"
# SiliconFlow 不是 OpenClaw 内置/官方插件 provider (走 OpenAI 兼容接入),
# 无法用上游 catalog 核实模型 ID。原硬编码清单里的 Qwen2.5-7B/72B、
# Yi-1.5-34B-Chat-16K、glm-4-9b-chat 均已明显过期,因此不再维护,
# 改为引导用户从官方模型广场复制当前 ID。
echo -e " ${CYAN}请从官方模型广场复制模型 ID:${NC}"
echo -e " ${CYAN}https://cloud.siliconflow.cn/models${NC}"
echo -e " ${DIM}格式形如 deepseek-ai/DeepSeek-V3.2;Pro/ 前缀模型仅支持充值余额${NC}"
echo ""
echo -e " ${CYAN}── Pro模型 (仅支持充值余额) ──${NC}"
echo -e " ${CYAN}7)${NC} Pro/deepseek-ai/DeepSeek-V3 — DeepSeek-V3 (Pro增强侧)"
echo -e " ${CYAN}8)${NC} Pro/zai-org/GLM-5 — 智谱 GLM-5"
echo ""
echo -e " ${CYAN}0)${NC} 手动输入其他任意模型名称"
echo ""
prompt_with_default "请选择模型 [0-8]" "1" model_choice
case "$model_choice" in
1) model_name="deepseek-ai/DeepSeek-V3" ;;
2) model_name="deepseek-ai/DeepSeek-R1" ;;
3) model_name="Qwen/Qwen2.5-72B-Instruct" ;;
4) model_name="Qwen/Qwen2.5-7B-Instruct" ;;
5) model_name="THUDM/glm-4-9b-chat" ;;
6) model_name="01-ai/Yi-1.5-34B-Chat-16K" ;;
7) model_name="Pro/deepseek-ai/DeepSeek-V3" ;;
8) model_name="Pro/zai-org/GLM-5" ;;
0) prompt_with_default "请输入模型详细名称" "deepseek-ai/DeepSeek-V3" model_name ;;
*) model_name="deepseek-ai/DeepSeek-V3" ;;
esac
prompt_with_default "请输入模型 ID" "" model_name
auth_set_apikey siliconflow "$api_key"
register_custom_provider siliconflow "https://api.siliconflow.cn/v1" "$api_key" "$model_name" "$model_name"
register_and_set_model "siliconflow/${model_name}"
@@ -1276,33 +1331,10 @@ configure_model() {
echo ""
echo -e " ${CYAN}可用模型 (Coding Plan 套餐内):${NC}"
echo -e " ${CYAN}── 智能推荐 ──${NC}"
echo -e " ${CYAN}a)${NC} tc-code-latest — 自动路由 (由平台选择最佳模型) ${GREEN}★ 推荐${NC}"
echo -e " ${CYAN}── 推理模型 ──${NC}"
echo -e " ${CYAN}b)${NC} hunyuan-t1 — 混元 T1 深度推理"
echo -e " ${CYAN}c)${NC} hunyuan-2.0-thinking — 混元 2.0 Thinking"
echo -e " ${CYAN}── 旗舰模型 ──${NC}"
echo -e " ${CYAN}d)${NC} hunyuan-turbos — 混元 TurboS 旗舰"
echo -e " ${CYAN}e)${NC} hunyuan-2.0-instruct — 混元 2.0 Instruct"
echo -e " ${CYAN}── 第三方模型 ──${NC}"
echo -e " ${CYAN}f)${NC} glm-5 — 智谱 GLM-5"
echo -e " ${CYAN}g)${NC} kimi-k2.5 — Moonshot Kimi K2.5"
echo -e " ${CYAN}h)${NC} minimax-m2.5 — MiniMax M2.5"
echo -e " ${CYAN}────────────${NC}"
echo -e " ${CYAN}z)${NC} 手动输入模型名"
echo ""
prompt_with_default "请选择默认模型" "a" model_choice
case "$model_choice" in
a) model_name="tc-code-latest" ;;
b) model_name="hunyuan-t1" ;;
c) model_name="hunyuan-2.0-thinking" ;;
d) model_name="hunyuan-turbos" ;;
e) model_name="hunyuan-2.0-instruct" ;;
f) model_name="glm-5" ;;
g) model_name="kimi-k2.5" ;;
h) model_name="minimax-m2.5" ;;
z) prompt_with_default "请输入模型名称" "tc-code-latest" model_name ;;
*) model_name="tc-code-latest" ;;
esac
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
oc_pick_model tencent-tokenhub "hy3"
model_name="$OC_PICKED_MODEL"
echo ""
echo -e " ${CYAN}正在注册腾讯云 Coding Plan 提供商 (含全部套餐模型)...${NC}"
auth_set_apikey lkeap "$api_key"
@@ -1324,21 +1356,10 @@ configure_model() {
auth_set_apikey qianfan "$api_key"
echo ""
echo -e " ${CYAN}可用模型:${NC}"
echo -e " ${CYAN}a)${NC} ernie-4.0-8k — 文心一言 4.0 (推荐)"
echo -e " ${CYAN}b)${NC} ernie-3.5-8k — 文心一言 3.5"
echo -e " ${CYAN}c)${NC} ernie-4.0-turbo-8k — 文心一言 4.0 Turbo"
echo -e " ${CYAN}d)${NC} ernie-speed-8k — 文心一言 Speed 极速"
echo -e " ${CYAN}e)${NC} 手动输入模型名"
echo ""
prompt_with_default "请选择模型" "a" model_choice
case "$model_choice" in
a) model_name="ernie-4.0-8k" ;;
b) model_name="ernie-3.5-8k" ;;
c) model_name="ernie-4.0-turbo-8k" ;;
d) model_name="ernie-speed-8k" ;;
e) prompt_with_default "请输入模型名称" "ernie-4.0-8k" model_name ;;
*) model_name="ernie-4.0-8k" ;;
esac
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
oc_pick_model qianfan "ernie-5.0-thinking-preview"
model_name="$OC_PICKED_MODEL"
# 百度千帆使用 OpenAI 兼容接口
# 注: OpenClaw v2026.3.28+ 支持 qianfan 原生 provider
register_custom_provider qianfan "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop" "$api_key" "$model_name" "$model_name"
@@ -1382,25 +1403,10 @@ configure_model() {
if [ -n "$api_key" ]; then
echo ""
echo -e " ${CYAN}可用模型:${NC}"
echo -e " ${CYAN}a)${NC} glm-5.1 — GLM-5.1 (推荐)"
echo -e " ${CYAN}b)${NC} glm-5 — GLM-5"
echo -e " ${CYAN}c)${NC} glm-4.7 — GLM-4.7"
echo -e " ${CYAN}d)${NC} glm-4.7-flash — GLM-4.7 Flash"
echo -e " ${CYAN}e)${NC} glm-4.5 — GLM-4.5"
echo -e " ${CYAN}f)${NC} glm-4.5-flash — GLM-4.5 Flash (免费额度)"
echo -e " ${CYAN}g)${NC} 手动输入模型名"
echo ""
prompt_with_default "请选择模型" "a" model_choice
case "$model_choice" in
a) model_name="glm-5.1" ;;
b) model_name="glm-5" ;;
c) model_name="glm-4.7" ;;
d) model_name="glm-4.7-flash" ;;
e) model_name="glm-4.5" ;;
f) model_name="glm-4.5-flash" ;;
g) prompt_with_default "请输入模型名称" "glm-5.1" model_name ;;
*) model_name="glm-5.1" ;;
esac
# 模型清单来自 model-presets.json (与 JS 侧共读同一数据源),
# 并支持 d 动态发现 / m 手动输入,不再硬编码易过期的模型 ID。
oc_pick_model zai "glm-5.2"
model_name="$OC_PICKED_MODEL"
# 智谱 GLM 使用原生 zai provider (OpenClaw 内置支持)
auth_set_apikey zai "$api_key"
register_custom_provider zai "$zai_base_url" "$api_key" "$model_name" "$model_name" "128000" "4096"
@@ -1431,7 +1437,7 @@ configure_model() {
echo ""
prompt_with_default "API Base URL (如 https://api.anthropic.com)" "" base_url
prompt_with_default "API Key" "" api_key
prompt_with_default "模型名称" "claude-sonnet-4-20250514" model_name
prompt_with_default "模型名称" "claude-sonnet-5" model_name
if [ -n "$base_url" ] && [ -n "$api_key" ] && [ -n "$model_name" ]; then
_ACP_URL="${base_url%/}" _ACP_KEY="$api_key" _ACP_MID="$model_name" "$NODE_BIN" -e "
const fs=require('fs');
@@ -2345,7 +2351,7 @@ reset_to_defaults() {
echo -e " ${CYAN}已取消${NC}"
fi
;;
c)
4)
echo ""
echo -e " ${RED}╔══════════════════════════════════════════════════════╗${NC}"
echo -e " ${RED}║ ⚠️ 完全恢复出厂设置 ║${NC}"
@@ -2527,7 +2533,7 @@ backup_restore_menu() {
oc_cmd backup verify "$latest" 2>&1
fi
;;
c)
4)
echo ""
if [ -d "$backup_dir" ]; then
local count=$(ls "${backup_dir}"/*-openclaw-backup.tar.gz 2>/dev/null | wc -l)
@@ -2545,7 +2551,7 @@ backup_restore_menu() {
echo ""
echo -e " ${DIM}备份目录: ${backup_dir}${NC}"
;;
d)
5)
local latest=$(ls -t "${backup_dir}"/*-openclaw-backup.tar.gz 2>/dev/null | head -1)
if [ -z "$latest" ]; then
echo -e " ${YELLOW}未找到备份文件,请先创建备份${NC}"
@@ -2713,7 +2719,9 @@ advanced_menu() {
gw_port=$(json_get "gateway.port" 2>/dev/null || echo "18789")
gw_bind=$(json_get "gateway.bind" 2>/dev/null || echo "lan")
gw_mode=$(json_get "gateway.mode" 2>/dev/null || echo "local")
log_level=$(json_get "gateway.logLevel" 2>/dev/null || echo "")
# 读 logging.level (正确键);兼容旧配置里残留的 gateway.logLevel 以便显示
log_level=$(json_get "logging.level" 2>/dev/null || echo "")
[ -n "$log_level" ] || log_level=$(json_get "gateway.logLevel" 2>/dev/null || echo "")
acp_dispatch=$(json_get "acp.dispatch.enabled" 2>/dev/null || echo "false")
echo ""
@@ -2738,32 +2746,57 @@ advanced_menu() {
echo ""
prompt_with_default "请输入 Gateway 端口" "$gw_port" new_port
if [ -n "$new_port" ] && [ "$new_port" != "$gw_port" ]; then
json_set "gateway.port" "$new_port"
# 同步到 UCI
uci set openclaw.main.port="$new_port" 2>/dev/null
uci commit openclaw 2>/dev/null
echo -e " ${GREEN}✅ 端口已设置为 ${new_port}${NC}"
ask_restart
# 端口必须是 1-65535 的整数,先自行校验再写入,
# 避免把非法值交给 json_set 后只得到一条底层报错。
case "$new_port" in
''|*[!0-9]*)
echo -e " ${YELLOW}无效端口: 必须是数字${NC}"
;;
*)
if [ "$new_port" -lt 1 ] || [ "$new_port" -gt 65535 ]; then
echo -e " ${YELLOW}无效端口: 需在 1-65535 之间${NC}"
elif json_set "gateway.port" "$new_port"; then
# 仅在 JSON 写入成功后才同步 UCI,避免两边不一致
uci set openclaw.main.port="$new_port" 2>/dev/null
uci commit openclaw 2>/dev/null
echo -e " ${GREEN}✅ 端口已设置为 ${new_port}${NC}"
ask_restart
fi
;;
esac
fi
;;
2)
echo ""
# 取值必须与 OpenClaw schema 的 gateway.bind 枚举一致:
# auto/lan/loopback/custom/tailnet。历史上这里提供的 all
# 不被上游接受 (实测: gateway.bind: Invalid input),
# 想监听所有接口应使用 custom + customBindHost。
echo -e " ${CYAN}绑定地址选项:${NC}"
echo " lan - 仅 LAN 接口 (推荐)"
echo " loopback - 仅本机访问"
echo " all - 所有接口 (0.0.0.0)"
echo " auto - 自动选择"
echo " custom - 自定义地址 (可配合 0.0.0.0 监听所有接口)"
echo " tailnet - Tailscale 网络"
echo ""
prompt_with_default "请输入绑定地址" "$gw_bind" new_bind
if [ -n "$new_bind" ]; then
# 兼容旧值: 用户/旧配置里的 all 等价于 custom + 0.0.0.0
if [ "$new_bind" = "all" ]; then
echo -e " ${YELLOW}提示: OpenClaw 已不接受 all,已改用 custom + 0.0.0.0${NC}"
new_bind="custom"
json_set "gateway.customBindHost" "0.0.0.0" || true
fi
case "$new_bind" in
lan|loopback|all)
json_set "gateway.bind" "$new_bind"
uci set openclaw.main.bind="$new_bind" 2>/dev/null
uci commit openclaw 2>/dev/null
echo -e " ${GREEN}✅ 绑定地址已设置为 ${new_bind}${NC}"
ask_restart
auto|lan|loopback|custom|tailnet)
if json_set "gateway.bind" "$new_bind"; then
uci set openclaw.main.bind="$new_bind" 2>/dev/null
uci commit openclaw 2>/dev/null
echo -e " ${GREEN}✅ 绑定地址已设置为 ${new_bind}${NC}"
ask_restart
fi
;;
*) echo -e " ${YELLOW}无效选项${NC}" ;;
*) echo -e " ${YELLOW}无效选项 (允许: auto/lan/loopback/custom/tailnet)${NC}" ;;
esac
fi
;;
@@ -2775,21 +2808,27 @@ advanced_menu() {
echo ""
prompt_with_default "请输入运行模式" "$gw_mode" new_mode
if [ -n "$new_mode" ] && [ "$new_mode" != "$gw_mode" ]; then
json_set "gateway.mode" "$new_mode"
echo -e " ${GREEN}✅ 运行模式已设置为 ${new_mode}${NC}"
ask_restart
if json_set "gateway.mode" "$new_mode"; then
echo -e " ${GREEN}✅ 运行模式已设置为 ${new_mode}${NC}"
ask_restart
fi
fi
;;
4)
echo ""
# 正确键是顶层 logging.level。gateway.logLevel 不存在于
# OpenClaw schema (gateway.additionalProperties=false),
# 实测 config set 报 Unrecognized key,手写进文件则被静默忽略,
# 表现为"界面显示已设置但从未生效"。
echo -e " ${CYAN}日志级别选项:${NC}"
echo " debug, info, warn, error"
echo " silent, fatal, error, warn, info, debug, trace"
echo ""
prompt_with_default "请输入日志级别" "${log_level:-info}" new_level
if [ -n "$new_level" ]; then
json_set "gateway.logLevel" "$new_level"
echo -e " ${GREEN}✅ 日志级别已设置为 ${new_level}${NC}"
ask_restart
if json_set "logging.level" "$new_level"; then
echo -e " ${GREEN}✅ 日志级别已设置为 ${new_level}${NC}"
ask_restart
fi
fi
;;
5)
@@ -2801,11 +2840,12 @@ advanced_menu() {
prompt_with_default "请输入设置" "$acp_dispatch" new_acp
case "$new_acp" in
true|false)
json_set "acp.dispatch.enabled" "$new_acp"
echo -e " ${GREEN}✅ ACP Dispatch 已设置为 ${new_acp}${NC}"
ask_restart
if json_set "acp.dispatch.enabled" "$new_acp"; then
echo -e " ${GREEN}✅ ACP Dispatch 已设置为 ${new_acp}${NC}"
ask_restart
fi
;;
*) echo -e " ${YELLOW}无效选项${NC}" ;;
*) echo -e " ${YELLOW}无效选项 (只接受 true / false)${NC}" ;;
esac
;;
6)
@@ -2891,11 +2931,19 @@ case "${1:-}" in
;;
--set)
if [ -n "${2:-}" ] && [ -n "${3:-}" ]; then
json_set "$2" "$3"
chown openclaw:openclaw "$CONFIG_FILE" 2>/dev/null || true
echo -e "${GREEN}✅ 已设置 $2${NC}"
# 必须检查 json_set 的返回码: 类型/枚举校验失败或配置损坏时
# 写入会被拒绝,此时不能再报告成功 (否则就是"界面显示已设置、
# 实际未生效"的假成功)。
if json_set "$2" "$3"; then
chown openclaw:openclaw "$CONFIG_FILE" 2>/dev/null || true
echo -e "${GREEN}✅ 已设置 $2${NC}"
else
echo -e "${RED}❌ 设置失败: $2${NC}" >&2
exit 1
fi
else
echo "用法: oc-config.sh --set <key> <value>"
echo "用法: oc-config.sh --set <key> <value> [类型]"
echo "类型可选: string | number | boolean | json (默认按 schema 自动判定)"
fi
;;
--get)
@@ -131,7 +131,19 @@
// 从 URL 参数或 hash 获取 PTY token 用于 WebSocket 认证
const urlParams = new URLSearchParams(location.search);
const ptyToken = urlParams.get('pty_token') || '';
// 取到 token 后立刻从地址栏移除: 否则它会留在浏览器历史、
// 以及任何 Referer/截图里。内存中的 ptyToken 仍可用于建连。
if (ptyToken && window.history && history.replaceState) {
try {
const clean = new URL(location.href);
clean.searchParams.delete('pty_token');
history.replaceState(null, '', clean.pathname + (clean.search || '') + clean.hash);
} catch (e) {}
}
const wsUrl = proto + '//' + location.host + '/ws' + (ptyToken ? '?token=' + encodeURIComponent(ptyToken) : '');
// 展示用 URL: 隐去 token。真实 wsUrl 只用于建立连接,
// 不写入页面文本或 console —— 否则 token 会留在截图、录屏与浏览器历史里。
const wsUrlSafe = proto + '//' + location.host + '/ws' + (ptyToken ? '?token=<hidden>' : '');
let ws = null;
let connected = false;
let retryCount = 0;
@@ -142,8 +154,8 @@
console.log('[oc-config] protocol:', location.protocol);
console.log('[oc-config] host:', location.host, 'port:', location.port);
console.log('[oc-config] WebSocket URL:', wsUrl);
loadingDebug.textContent = wsUrl;
console.log('[oc-config] WebSocket URL:', wsUrlSafe);
loadingDebug.textContent = wsUrlSafe;
function connect() {
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
@@ -151,11 +163,11 @@
if (ws) { try { ws.close(); } catch(e){} ws = null; }
retryCount++;
console.log('[oc-config] Connecting to', wsUrl, '(attempt', retryCount + ')');
console.log('[oc-config] Connecting to', wsUrlSafe, '(attempt', retryCount + ')');
loadingMsg.textContent = retryCount > 1
? '正在重新连接... (第 ' + retryCount + ' 次)'
: '正在连接终端...';
loadingDebug.textContent = wsUrl;
loadingDebug.textContent = wsUrlSafe;
// 先做一个 HTTP 预检确认服务器可达
fetch('/health').then(function(r) {
@@ -75,10 +75,33 @@ function getMimeType(ext) {
return types[ext] || 'application/octet-stream';
}
// 供 LuCI 页面以 iframe 嵌入本服务所需的响应头。
//
// 原实现是 ACAO:* + X-Frame-Options:ALLOWALL + CSP default-src:*,
// 等于对任意站点开放。收紧依据(已逐项核实):
// - 页面内所有资源都是本地的 /lib/*.js|css,无任何外链 -> default-src 可收到 'self'
// - 唯一的 fetch 是 /health,与 iframe 自身同源 -> 不需要 Access-Control-Allow-Origin
// - 存在内联 <style> 与 <script> 块 -> 必须保留 'unsafe-inline'
// - xterm.js 不需要 eval -> 去掉 'unsafe-eval'
// - LuCI 与本服务端口不同(80 vs 18793)属跨源嵌入,
// 跨源 iframe 只需 frame-ancestors 放行,与 CORS 无关
//
// frame-ancestors 无法在此处限定到具体 LAN 地址(服务端拿不到父页面 origin,
// 且用户访问 LuCI 的主机名/IP 各不相同),因此保留通配但去掉 X-Frame-Options:
// 两者同时存在时旧浏览器会以更宽松的 ALLOWALL 为准。
// WebSocket 升级仍由 token 校验保护(无 token / 错误 token 均返回 403)。
const IFRAME_HEADERS = {
'Access-Control-Allow-Origin': '*',
'X-Frame-Options': 'ALLOWALL',
'Content-Security-Policy': "default-src * 'unsafe-inline' 'unsafe-eval' data: blob: ws: wss:; frame-ancestors *",
'Content-Security-Policy': [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self' data:",
"connect-src 'self' ws: wss:",
'frame-ancestors *',
].join('; '),
'Referrer-Policy': 'no-referrer',
'X-Content-Type-Options': 'nosniff',
};
// ── WebSocket 帧处理 (RFC 6455) ──
@@ -269,11 +292,12 @@ function handleRequest(req, res) {
let fp = url.pathname;
if (req.method === 'OPTIONS') {
res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Headers': '*' });
// 本服务只被同源页面 fetch(/health),不需要放开 CORS
res.writeHead(204, { 'Allow': 'GET, OPTIONS' });
return res.end();
}
if (fp === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
return res.end(JSON.stringify({ status: 'ok', port: PORT, uptime: process.uptime() }));
}
if (fp === '/' || fp === '') fp = '/index.html';