⏳ 正在恢复配置...
";'
- html[#html+1] = '(new XHR()).get("' .. backup_url .. '?action=restore&file="+encodeURIComponent(filename),null,function(x){'
+ html[#html+1] = '(new XHR()).post("' .. backup_url .. '?action=restore&file="+encodeURIComponent(filename),{token:ocCsrfToken},function(x){'
html[#html+1] = 'try{var r=JSON.parse(x.responseText);'
html[#html+1] = 'if(r.status==="ok"){'
html[#html+1] = 'resEl.innerHTML=""+'
@@ -622,7 +628,7 @@ act.cfgvalue = function(self, section)
html[#html+1] = 'var resEl=document.getElementById("oc-backup-result");'
html[#html+1] = 'resEl.style.display="block";'
html[#html+1] = 'resEl.innerHTML="
⏳ 正在删除...
";'
- html[#html+1] = '(new XHR()).get("' .. backup_url .. '?action=delete&file="+encodeURIComponent(filename),null,function(x){'
+ html[#html+1] = '(new XHR()).post("' .. backup_url .. '?action=delete&file="+encodeURIComponent(filename),{token:ocCsrfToken},function(x){'
html[#html+1] = 'try{var r=JSON.parse(x.responseText);'
html[#html+1] = 'if(r.status==="ok"){'
html[#html+1] = 'resEl.innerHTML="
"+'
diff --git a/luci-app-openclaw/luasrc/view/openclaw/advanced.htm b/luci-app-openclaw/luasrc/view/openclaw/advanced.htm
index 94a17d4a0..615aaa3d7 100644
--- a/luci-app-openclaw/luasrc/view/openclaw/advanced.htm
+++ b/luci-app-openclaw/luasrc/view/openclaw/advanced.htm
@@ -47,13 +47,15 @@ local pty_port = uci:get("openclaw", "main", "pty_port") or "18793"
var ptyPort = '<%=pty_port%>';
var statusUrl = '<%=luci.dispatcher.build_url("admin", "services", "openclaw", "status_api")%>';
var tokenUrl = '<%=luci.dispatcher.build_url("admin", "services", "openclaw", "get_token")%>';
+ // get_token 返回凭据,已改为 post(),需带 CSRF token
+ var ocCsrfToken = '<%=token%>';
var container = document.getElementById('oc-terminal-container');
var loading = document.getElementById('oc-terminal-loading');
var ptyToken = '';
function checkAndLoadTerminal() {
// 先获取 PTY token
- (new XHR()).get(tokenUrl, null, function(tx) {
+ (new XHR()).post(tokenUrl, { token: ocCsrfToken }, function(tx) {
try {
var td = JSON.parse(tx.responseText);
ptyToken = td.pty_token || '';
diff --git a/luci-app-openclaw/luasrc/view/openclaw/console.htm b/luci-app-openclaw/luasrc/view/openclaw/console.htm
index ea45ec465..b20865efe 100644
--- a/luci-app-openclaw/luasrc/view/openclaw/console.htm
+++ b/luci-app-openclaw/luasrc/view/openclaw/console.htm
@@ -97,6 +97,8 @@ local port = uci:get("openclaw", "main", "port") or "18789"
var gwToken = '';
var statusUrl = '<%=luci.dispatcher.build_url("admin", "services", "openclaw", "status_api")%>';
var tokenUrl = '<%=luci.dispatcher.build_url("admin", "services", "openclaw", "get_token")%>';
+ // get_token 返回凭据,已改为 post(),需带 CSRF token
+ var ocCsrfToken = '<%=token%>';
var container = document.getElementById('oc-console-container');
var loading = document.getElementById('oc-console-loading');
var addrEl = document.getElementById('oc-console-addr');
@@ -116,7 +118,7 @@ local port = uci:get("openclaw", "main", "port") or "18789"
addrEl.textContent = window.location.hostname + ':' + gwPort;
// 先获取 token,再检查状态
- (new XHR()).get(tokenUrl, null, function(tx) {
+ (new XHR()).post(tokenUrl, { token: ocCsrfToken }, function(tx) {
try {
var td = JSON.parse(tx.responseText);
gwToken = td.token || '';
diff --git a/luci-app-openclaw/luasrc/view/openclaw/wechat.htm b/luci-app-openclaw/luasrc/view/openclaw/wechat.htm
index 02fc4af31..1d1cf09ff 100644
--- a/luci-app-openclaw/luasrc/view/openclaw/wechat.htm
+++ b/luci-app-openclaw/luasrc/view/openclaw/wechat.htm
@@ -834,10 +834,17 @@ local pty_port = uci:get("openclaw", "main", "pty_port") or "18793"
'当前版本: ' + d.current_version + '
' +
'' +
'
';
- } else if (d.current_version) {
- resultEl.innerHTML = '
✅ 当前已是最新版本: ' + d.current_version + '
';
- } else {
+ } else if (!d.current_version) {
resultEl.innerHTML = '
❌ 插件未安装,请先安装
';
+ } else if (d.status !== 'ok' || !d.latest_version) {
+ // 查询失败不能显示"已是最新": 否则真有新版时用户也看不到。
+ // 后端在 latest_version 为空时会返回 status=error 与 message。
+ resultEl.innerHTML = '
⚠️ 无法确认最新版本' +
+ '
当前已安装: ' + d.current_version +
+ (d.message ? '
' + d.message + '' : '') +
+ '
';
+ } else {
+ resultEl.innerHTML = '
✅ 当前已是最新版本: ' + d.current_version + '
';
}
} catch(e) {
document.getElementById('oc-upgrade-progress').style.display = 'none';
diff --git a/luci-app-openclaw/root/etc/init.d/openclaw b/luci-app-openclaw/root/etc/init.d/openclaw
index 5d92ef608..68068c6e5 100755
--- a/luci-app-openclaw/root/etc/init.d/openclaw
+++ b/luci-app-openclaw/root/etc/init.d/openclaw
@@ -285,7 +285,24 @@ OC_SYNC_PORT="$port" OC_SYNC_BIND="$bind" OC_SYNC_TOKEN="$token" OC_SYNC_FILE="$
const fs=require('fs');
const f=process.env.OC_SYNC_FILE;
let d={};
-try{d=JSON.parse(fs.readFileSync(f,'utf8'));}catch(e){}
+// 配置存在但解析失败时必须中止,绝不能用 {} 继续往下写。
+// 否则本函数会把空对象连同下面的默认值一起落盘,用户的
+// models.providers(含 apiKey)、channels、plugins 全部丢失。
+// 本函数在每次 start_service 时自动执行,一旦退化影响面比手工操作大得多。
+let raw='';
+try{raw=fs.readFileSync(f,'utf8');}catch(e){raw='';}
+if(raw.trim()!==''){
+ try{
+ d=JSON.parse(raw);
+ }catch(e){
+ console.error('openclaw: 配置文件解析失败,已跳过 UCI 同步以避免覆盖: '+e.message);
+ process.exit(3);
+ }
+ if(d===null||typeof d!=='object'||Array.isArray(d)){
+ console.error('openclaw: 配置根节点不是对象,已跳过 UCI 同步');
+ process.exit(3);
+ }
+}
if(!d.gateway)d.gateway={};
d.gateway.port=parseInt(process.env.OC_SYNC_PORT)||18789;
d.gateway.bind=process.env.OC_SYNC_BIND||'lan';
diff --git a/luci-app-openclaw/root/usr/bin/openclaw-env b/luci-app-openclaw/root/usr/bin/openclaw-env
index be3506122..641dc1610 100755
--- a/luci-app-openclaw/root/usr/bin/openclaw-env
+++ b/luci-app-openclaw/root/usr/bin/openclaw-env
@@ -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
` 也随之失效 —— 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
diff --git a/luci-app-openclaw/root/usr/share/openclaw/model-presets.json b/luci-app-openclaw/root/usr/share/openclaw/model-presets.json
new file mode 100644
index 000000000..55d3c41bf
--- /dev/null
+++ b/luci-app-openclaw/root/usr/share/openclaw/model-presets.json
@@ -0,0 +1,156 @@
+{
+ "_comment": [
+ "精选模型预设 —— shell 与 JS 两侧共读的唯一数据源。",
+ "",
+ "架构分三层 (见 docs/notes/):",
+ " 1. 本文件的精选列表: 离线可用、首屏快,每个 provider 给 4 档",
+ " (推荐 / 最强 / 均衡 / 快速低价)。",
+ " 2. 动态发现: 菜单里的「从 OpenClaw 获取模型列表」调用",
+ " openclaw models list --provider --plain,带超时并在失败时",
+ " 静默回落到本文件。",
+ " 3. 手动输入: 永久保留的兼容出口。",
+ "",
+ "维护约定:",
+ " - model 字段必须是 provider 侧的裸模型 ID (不含 'provider/' 前缀)。",
+ " - 只收录已用 openclaw models list --provider 核实存在的 ID,",
+ " 不要凭文档或印象填写。",
+ " - verified 记录核实日期与 OpenClaw 版本,便于判断是否过期。",
+ " - catalogSource=builtin 表示上游内置 catalog 可直接发现该 provider;",
+ " plugin 表示需先安装官方 provider 插件,模型列表以插件为准。"
+ ],
+ "verified": {
+ "date": "2026-08-26",
+ "openclawVersion": "2026.7.1-2",
+ "method": "openclaw models list --provider --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 预览版" }
+ ]
+ }
+ }
+}
diff --git a/luci-app-openclaw/root/usr/share/openclaw/oc-config-interactive.js b/luci-app-openclaw/root/usr/share/openclaw/oc-config-interactive.js
index 0a2b7a81d..c1cba33bf 100644
--- a/luci-app-openclaw/root/usr/share/openclaw/oc-config-interactive.js
+++ b/luci-app-openclaw/root/usr/share/openclaw/oc-config-interactive.js
@@ -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 --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 --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);
});
diff --git a/luci-app-openclaw/root/usr/share/openclaw/oc-config.sh b/luci-app-openclaw/root/usr/share/openclaw/oc-config.sh
index 3a24ad236..8c843256b 100755
--- a/luci-app-openclaw/root/usr/share/openclaw/oc-config.sh
+++ b/luci-app-openclaw/root/usr/share/openclaw/oc-config.sh
@@ -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 的精选模型,每行 "modeldesc"
+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 — 动态发现当前可用模型 (失败/超时输出空)
+# 上游行为: 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 <默认模型> — 交互式选择模型,结果写入全局 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&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 "
+ echo "用法: oc-config.sh --set [类型]"
+ echo "类型可选: string | number | boolean | json (默认按 schema 自动判定)"
fi
;;
--get)
diff --git a/luci-app-openclaw/root/usr/share/openclaw/ui/index.html b/luci-app-openclaw/root/usr/share/openclaw/ui/index.html
index 30bfa9fa4..e36e74d61 100644
--- a/luci-app-openclaw/root/usr/share/openclaw/ui/index.html
+++ b/luci-app-openclaw/root/usr/share/openclaw/ui/index.html
@@ -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=' : '');
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) {
diff --git a/luci-app-openclaw/root/usr/share/openclaw/web-pty.js b/luci-app-openclaw/root/usr/share/openclaw/web-pty.js
index ca82f316a..5335bcac4 100644
--- a/luci-app-openclaw/root/usr/share/openclaw/web-pty.js
+++ b/luci-app-openclaw/root/usr/share/openclaw/web-pty.js
@@ -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
+// - 存在内联
{% endif %}
- {# THE ROUTER ON A HOME SCREEN. A phone is where LuCI is opened when something is wrong with
- the network, and a browser tab is a poor place to keep it: the manifest lets the admin
- install this page as an app, which gets it an icon, its own window and no address bar.
+ {# The manifest lets an admin install this page as an app: an icon, its own window and no
+ address bar, which is worth more than a tab on the phone LuCI is opened from when the
+ network is broken.
- Both files are STATIC and cannot be otherwise — a theme may not register a dispatcher node,
- so there is nothing here that could render them per request. That fixes two values:
+ Both files are static and cannot be otherwise, a theme having no dispatcher node to render
+ them per request. That fixes two values:
- start_url/scope — `/cgi-bin/luci/`, uhttpd's own default and the path this template is
- being served from on every stock install. An admin who moved LuCI elsewhere gets an
- installed app that opens the standard path; the browser tab is unaffected either way.
- background/theme_color — the DEFAULT palette's page colour (#f6f8fa). The manifest is
- read once at install time and cannot follow a live Appearance change, so it carries
- the default rather than a value that would be wrong for everyone who changed it. It
- only ever paints the splash and the window chrome, never the page.
+ start_url/scope — `/cgi-bin/luci/`, uhttpd's default and the path this template is
+ served from on a stock install. An admin who moved LuCI gets an installed app that
+ opens the standard path; the browser tab is unaffected.
+ background/theme_color — the default palette's page colour. The manifest is read once
+ at install time and cannot follow a live Appearance change, and it only paints the
+ splash and the window chrome.
Chrome offers its install prompt on a secure context only, so over plain HTTP this is
- iOS's Add to Home Screen (which reads the manifest and this apple-touch-icon) plus the
- icons themselves. Nothing here is on the page's critical path: a manifest is fetched at
- idle priority and never blocks a paint.
+ iOS's Add to Home Screen plus the icons. Nothing here is on the critical path.
- `.json`, NOT the conventional `.webmanifest`, and that is the server's doing: uhttpd's
- built-in MIME map has no entry for that extension. Measured on the stand — the same bytes
- served as `app.webmanifest` come back `application/octet-stream`, as `t.manifest`
- `text/cache-manifest`, and only as `.json` do they come back with a JSON type, which is
- what every browser accepts a manifest as. There is no per-extension setting to fix from
- here: the map is compiled into uhttpd. #}
+ `.json`, not the conventional `.webmanifest`: uhttpd's built-in MIME map has no entry for
+ that extension, so the same bytes come back `application/octet-stream` there and with a
+ JSON type only as `.json`. The map is compiled into uhttpd. #}
- {# iOS reads THIS link and never the manifest's icons array — and it is happy with the 512
- square the manifest also uses, which it scales. One raster serves both, see
- tools/build-icons.mjs. #}
+ {# iOS reads this link and never the manifest's icons array, and it scales the 512 square the
+ manifest also uses. One raster serves both — tools/build-icons.mjs. #}
{% if (dispatched?.css): %}
- {# The `css` property of the dispatched menu.d node — an app naming a stylesheet the server
- should link for its page, instead of the view injecting one at module eval (which is what
- fs-sheets.js spends its existence containing).
+ {# The `css` property of the dispatched menu.d node: an app naming a stylesheet the server
+ should link for its page, instead of the view injecting one at module eval.
- `dispatched`, not `node`: the dispatcher exports the resolved node as `dispatched`
- (`runtime.env.dispatched = resolved.node`) and never binds a `node`. Every ucode theme
- carried the same typo until it was fixed upstream in openwrt/luci#8920, so the link had
- never once been emitted. Requires `css: 'string'` in the dispatcher's node schema, added
- by that same commit — on a luci-base without it the property is filtered out of the tree
- and this stays false, which is what 24.10 and 25.12 do.
+ `dispatched`, not `node`: the dispatcher exports the resolved node as `dispatched` and
+ never binds a `node` — every ucode theme carried that typo until openwrt/luci#8920, so the
+ link had never once been emitted. It also requires `css: 'string'` in the dispatcher's node
+ schema, added by the same commit; without it the property is filtered out of the tree and
+ this stays false, as on 24.10 and 25.12.
- uhttpd sends no Cache-Control, so without a version key the browser applies HEURISTIC
- freshness and can serve a stale app stylesheet for days after an upgrade.
+ uhttpd sends no Cache-Control, so without a version key a stale app stylesheet can be
+ served for days after an upgrade.
- Deliberately NOT `data-fs-shell`: this sheet belongs to one page, so the SPA router must
- judge and contain it like any other app sheet rather than let it ride along. #}
+ Deliberately not `data-fs-shell`: this sheet belongs to one page, so the SPA router must
+ judge and contain it like any other app sheet. #}
{% endif %}
{% if (css): %}
- {# no `title` attribute: on a
{% endif %}
- {# The catalogue is a CGI SPAWN on the critical path — 30 ms measured on the router — and at
- lang=en it spends them to deliver 13 bytes (`window.TR={};`): there is no en catalogue,
- because the msgids already ARE English. The process is the cost, not the data. So emit
- those 13 bytes inline when this language has no catalogue, and keep the tag when it has.
- The probe MIRRORS the server: determine_request_language() (dispatcher.uc) loads this
- request's catalogue with load_catalog(lang, '/usr/lib/lua/luci/i18n'), which takes every
- `*..lmo` in that directory — so a router that does ship an en catalogue still gets
- the tag. Deciding by language NAME would silently drop it.
- Fails OPEN (a throwing probe keeps the tag): a missing catalogue makes every `_()` render
- English and report nothing, which is the one failure this must never risk. #}
+ {# The catalogue is a CGI spawn on the critical path — 30 ms on the router — and at lang=en it
+ spends them to deliver 13 bytes, there being no en catalogue because the msgids already
+ are English. The process is the cost, not the data, so emit those bytes inline when this
+ language has no catalogue and keep the tag when it has.
+ The probe mirrors the server: determine_request_language() loads the catalogue with
+ load_catalog(lang, '/usr/lib/lua/luci/i18n'), i.e. every `*..lmo` in that directory,
+ so a router that does ship an en catalogue still gets the tag. Deciding by language NAME
+ would silently drop it.
+ Fails open — a throwing probe keeps the tag — because a missing catalogue makes every
+ `_()` render English and report nothing. #}
{%- let has_catalog = true;
try { has_catalog = length(require('fs').glob(`/usr/lib/lua/luci/i18n/*.${dispatcher.lang}.lmo`)) > 0; } catch (e) {} %}
{% if (has_catalog): %}
@@ -510,10 +434,9 @@
{% else %}
{% endif %}
- {# the one asset stock LuCI leaves without a cache key. Must stay SYNCHRONOUS and
- un-deferred: luci.js captures `window.cbi_init` in its constructor. `defer` is not an
- option for the catalogue above either — footer.ut runs `L.require('menu-footstrap')`
- inline while the parser is still running, so a module's `_()` would race a deferred
- window.TR and win silently. #}
+ {# the one asset stock LuCI leaves without a cache key. Must stay synchronous and un-deferred:
+ luci.js captures `window.cbi_init` in its constructor, and `defer` is not an option for the
+ catalogue above either — footer.ut runs `L.require('menu-footstrap')` inline while the
+ parser is still running, so a module's `_()` would race a deferred window.TR. #}
diff --git a/luci-theme-footstrap/ucode/template/themes/footstrap/partials/icon.ut b/luci-theme-footstrap/ucode/template/themes/footstrap/partials/icon.ut
index 04e5ad3dd..20288acac 100644
--- a/luci-theme-footstrap/ucode/template/themes/footstrap/partials/icon.ut
+++ b/luci-theme-footstrap/ucode/template/themes/footstrap/partials/icon.ut
@@ -1,18 +1,14 @@
{#
- The chrome's ONE inline-SVG wrapper, server side. Every icon the templates draw is the same 24x24
- stroked outline and differs only in its path data, but the wrapper was written out at each call
- site — header.ut's rail toggle plus search and logout — so `stroke-width` and the two
- linecap attributes were independent copies of a value that is meant to make one icon SET.
- fs-widgets.js's svgIcon() is the same wrapper for the icons the JS builds; the two are the server
- and client halves of one look.
+ The chrome's one inline-SVG wrapper, server side: every icon the templates draw is the same 24x24
+ stroked outline differing only in path data, so stroke width and linecaps are stated once and
+ cannot drift between call sites. fs-widgets.js's svgIcon() is the client half of the same look.
- `body` is the shape, and it is emitted RAW on purpose: it is author-written markup from the caller
- (paths and circles), never anything a request can influence, and `{{ }}` in a ucode template does
- not escape — escaping here would print the path data as visible text. `cls` is optional and
+ `body` is the shape and is emitted raw on purpose: it is author-written markup from the caller,
+ never anything a request can influence, and escaping it would print the path data as text. `cls`
defaults to the icon class every chrome control uses.
- aria-hidden: each of these sits beside its own label (or in a control that carries one), and an
- unlabelled