diff --git a/linkease-common-bin/Makefile b/linkease-common-bin/Makefile index d64e61251..dcb1bfb1c 100644 --- a/linkease-common-bin/Makefile +++ b/linkease-common-bin/Makefile @@ -12,7 +12,7 @@ PKG_ARCH_LINKEASE:=$(ARCH) PKG_NAME:=linkease-common-bin # use PKG_SOURCE_DATE instead of PKG_VERSION for compitable PKG_SOURCE_DATE:=1.7.5 -PKG_RELEASE:=15 +PKG_RELEASE:=16 ARCH_HEXCODE:= ifeq ($(ARCH),x86_64) @@ -28,7 +28,7 @@ else ifeq ($(ARCH),mipsel) ARCH_HEXCODE=1b0c endif -PKG_SOURCE_VERSION:=b0d9136d243ac1a5c5290190ffa6f66265aa7fba +PKG_SOURCE_VERSION:=29037745f8dba998fb03142d5c349700672b8531 PKG_SOURCE:=linkease-common-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE).tar.gz PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkease-runtime-v$(PKG_SOURCE_DATE)/ PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-common-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE) diff --git a/linkease/Makefile b/linkease/Makefile index 79ba36396..ba786aca1 100644 --- a/linkease/Makefile +++ b/linkease/Makefile @@ -12,7 +12,7 @@ PKG_ARCH_LINKEASE:=$(ARCH) PKG_NAME:=linkease # use PKG_SOURCE_DATE instead of PKG_VERSION for compitable PKG_SOURCE_DATE:=1.7.5 -PKG_RELEASE:=18 +PKG_RELEASE:=19 ARCH_HEXCODE:= ifeq ($(ARCH),x86_64) @@ -28,7 +28,7 @@ else ifeq ($(ARCH),mipsel) ARCH_HEXCODE=1b0c endif -PKG_SOURCE_VERSION:=b0d9136d243ac1a5c5290190ffa6f66265aa7fba +PKG_SOURCE_VERSION:=29037745f8dba998fb03142d5c349700672b8531 PKG_SOURCE:=linkease-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE).tar.gz PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkease-runtime-v$(PKG_SOURCE_DATE)/ PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE) diff --git a/linkeasefull/Makefile b/linkeasefull/Makefile index 79729b7cd..93e0a3588 100644 --- a/linkeasefull/Makefile +++ b/linkeasefull/Makefile @@ -11,7 +11,7 @@ PKG_ARCH_LINKEASE:=$(ARCH) PKG_NAME:=linkeasefull # use PKG_SOURCE_DATE instead of PKG_VERSION for compitable PKG_SOURCE_DATE:=3.0.13 -PKG_RELEASE:=17 +PKG_RELEASE:=18 ARCH_HEXCODE:= ifeq ($(ARCH),x86_64) @@ -24,7 +24,7 @@ LINKEASE_RUNTIME_ARCH:=arm64 PKG_HASH:=skip endif -PKG_SOURCE_VERSION:=b0d9136d243ac1a5c5290190ffa6f66265aa7fba +PKG_SOURCE_VERSION:=29037745f8dba998fb03142d5c349700672b8531 PKG_SOURCE:=linkease-runtime-$(PKG_SOURCE_DATE)-linux-$(LINKEASE_RUNTIME_ARCH).tar.gz PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkeasefull-runtime-v$(PKG_SOURCE_DATE)/ PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-runtime-$(PKG_SOURCE_DATE)-linux-$(LINKEASE_RUNTIME_ARCH) diff --git a/luci-app-linkeasefull/Makefile b/luci-app-linkeasefull/Makefile index 68f7ab2d5..c31cafd46 100644 --- a/luci-app-linkeasefull/Makefile +++ b/luci-app-linkeasefull/Makefile @@ -7,11 +7,11 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=LuCI support for linkeasefull -LUCI_DEPENDS:=+linkeasefull +luci-lib-linkeasefile +LUCI_DEPENDS:=+linkeasefull +luci-lib-linkeaseauth +luci-lib-linkeasefile LUCI_PKGARCH:=all PKG_VERSION:=1.0.0 -PKG_RELEASE:=4 +PKG_RELEASE:=5 include $(TOPDIR)/feeds/luci/luci.mk diff --git a/luci-app-linkeasefull/luasrc/controller/linkeasefull.lua b/luci-app-linkeasefull/luasrc/controller/linkeasefull.lua index 1db6cc9d2..cd80ed2b6 100644 --- a/luci-app-linkeasefull/luasrc/controller/linkeasefull.lua +++ b/luci-app-linkeasefull/luasrc/controller/linkeasefull.lua @@ -2,7 +2,21 @@ module("luci.controller.linkeasefull", package.seeall) function index() entry({"admin", "services", "linkeasefull_status"}, call("linkeasefull_status")) - entry({"admin", "services", "linkeasefull", "auth"}, call("linkeasefull_auth")).leaf = true + local open = entry({"admin", "services", "linkeasefull", "open"}, call("linkeasefull_open")) + open.leaf = true + open.dependent = false + open.sysauth = "root" + open.sysauth_authenticator = "htmlauth" + local auth = entry({"admin", "services", "linkeasefull", "auth"}, call("linkeasefull_auth")) + auth.leaf = true + auth.dependent = false + auth.sysauth = "root" + auth.sysauth_authenticator = "htmlauth" + local auth_finish = entry({"admin", "services", "linkeasefull", "auth_finish"}, call("linkeasefull_auth_finish")) + auth_finish.leaf = true + auth_finish.dependent = false + auth_finish.sysauth = "root" + auth_finish.sysauth_authenticator = "htmlauth" if not nixio.fs.access("/etc/config/linkeasefull") then return @@ -23,10 +37,18 @@ local function uhttpd_has_apps_proxy_prefix() return false end +local function uhttpd_supports_proxy_prefix() + local sys = require "luci.sys" + return sys.call("grep -qr 'proxy_prefix' /etc/init.d/uhttpd /lib/functions /usr/share/uhttpd 2>/dev/null") == 0 +end + +local function uhttpd_apps_proxy_available() + return uhttpd_supports_proxy_prefix() and uhttpd_has_apps_proxy_prefix() +end + function linkeasefull_status() local sys = require "luci.sys" local uci = require "luci.model.uci".cursor() - local linkease_enabled = (uci:get_first("linkease", "linkease", "enabled") == "1") local status = { running = (sys.call("pidof linkease-full >/dev/null") == 0), @@ -34,88 +56,76 @@ function linkeasefull_status() legacy_port = 8897, base_path = "/apps/", lan_ip = uci:get("network", "lan", "ipaddr") or "", - proxy_prefix_enabled = uhttpd_has_apps_proxy_prefix(), - conflict = linkease_enabled, - conflict_service = linkease_enabled and "linkease" or "" + proxy_prefix_supported = uhttpd_supports_proxy_prefix(), + proxy_prefix_enabled = uhttpd_apps_proxy_available(), + conflict = false, + conflict_service = "" } luci.http.prepare_content("application/json") luci.http.write_json(status) end -local function retrieve_luci_session() - local http = require "luci.http" - local util = require "luci.util" - - for _, key in ipairs({"sysauth_https", "sysauth_http", "sysauth"}) do - local sid = http.getcookie(key) - if sid and sid ~= "" then - local sdat = util.ubus("session", "get", { ubus_rpc_session = sid }) - if sdat and type(sdat.values) == "table" then - return sid - end - end - end - return nil +local function cookie_encode(value) + return tostring(value or ""):gsub("([^A-Za-z0-9._~-])", function(char) + return string.format("%%%02X", char:byte()) + end) end -local function valid_apps_return(value) - if not value or value == "" then - return false +local function authority_host(authority) + if not authority or authority == "" then + return "" end - local function valid_path(path) - if path == "/apps" then - return true - end - local prefix = path:sub(1, 6) - return prefix == "/apps/" or prefix == "/apps?" or prefix == "/apps#" + if authority:sub(1, 1) == "[" then + return authority:match("^%[([^%]]+)%]") or "" end - if value:sub(1, 1) == "/" then - return valid_path(value) - end - - local scheme, authority, path = value:match("^(https?://)([^/]+)(/.*)$") - if not scheme or not authority or not valid_path(path) then - return false - end - - local http = require "luci.http" - local uci = require "luci.model.uci".cursor() - local request_host = http.getenv("HTTP_HOST") or "" - local lan_host = uci:get("network", "lan", "ipaddr") or "" - local allowed_authorities = { - request_host, - request_host .. ":19290", - lan_host, - lan_host .. ":19290" - } - - for _, host in ipairs(allowed_authorities) do - if host ~= "" and authority == host then - return true - end - end - return false + return authority:match("^([^:]+)") or authority end -local function valid_cookie_value(value) - return value and value:match("^[A-Za-z0-9._%-_]+$") ~= nil +local function url_authority(host, port) + if not host or host == "" then + host = "127.0.0.1" + end + if host:find(":") and host:sub(1, 1) ~= "[" then + host = "[" .. host .. "]" + end + return host .. ":" .. tostring(port) +end + +local function request_or_lan_host() + local http = require "luci.http" + local uci = require "luci.model.uci".cursor() + local host = authority_host(http.getenv("HTTP_HOST") or "") + if host ~= "" then + return host + end + return uci:get("network", "lan", "ipaddr") or "127.0.0.1" +end + +local function linkease_auth_url(name) + local dispatcher = require "luci.dispatcher" + return dispatcher.build_url("admin", "services", "linkease_auth", name) +end + +local function linkeasefull_entry_url() + if uhttpd_apps_proxy_available() then + return "/apps/" + end + return "http://" .. url_authority(request_or_lan_host(), 19290) .. "/apps/" +end + +function linkeasefull_open() + local http = require "luci.http" + http.redirect(linkease_auth_url("auth") .. "?return=" .. cookie_encode(linkeasefull_entry_url())) end function linkeasefull_auth() local http = require "luci.http" - local sid = retrieve_luci_session() - - if not valid_cookie_value(sid) then - http.status(403, "Forbidden") - return - end - local target = http.formvalue("return") or "/apps/" - if not valid_apps_return(target) then - target = "/apps/" - end + http.redirect(linkease_auth_url("auth") .. "?return=" .. cookie_encode(target)) +end - http.header("Set-Cookie", "linkease_openwrt_sid=" .. sid .. "; Path=/apps; HttpOnly; SameSite=Lax") - http.redirect(target) +function linkeasefull_auth_finish() + local http = require "luci.http" + http.redirect(linkease_auth_url("auth_finish")) end diff --git a/luci-app-linkeasefull/luasrc/view/linkeasefull_status.htm b/luci-app-linkeasefull/luasrc/view/linkeasefull_status.htm index b23484116..4d0a83a36 100644 --- a/luci-app-linkeasefull/luasrc/view/linkeasefull_status.htm +++ b/luci-app-linkeasefull/luasrc/view/linkeasefull_status.htm @@ -10,9 +10,7 @@ ? window.location.protocol + "//" + window.location.host + "/apps/" : "http://" + lanHost + ":" + (st.full_port || 19290) + "/apps/"; var legacyUrl = "http://" + window.location.hostname + ":" + (st.legacy_port || 8897) + "/"; - var overallStatus = st.conflict - ? '
<%:LinkEase is enabled. Please disable LinkEase before starting LinkEase Full.%>' - : st.running + var overallStatus = st.running ? '
<%:The LinkEase Full service is running.%>' : '
<%:The LinkEase Full service is not running.%>'; tb.innerHTML = overallStatus; diff --git a/luci-app-oaf/Makefile b/luci-app-oaf/Makefile index 0ee310214..a6fd9cc1a 100644 --- a/luci-app-oaf/Makefile +++ b/luci-app-oaf/Makefile @@ -6,7 +6,7 @@ LUCI_DEPENDS:=+appfilter +kmod-oaf +luci-compat +luci-lib-jsonc PKG_NAME:=luci-app-oaf PKG_VERSION:=7.0 -PKG_RELEASE:=4 +PKG_RELEASE:=5 include $(TOPDIR)/feeds/luci/luci.mk diff --git a/luci-app-oaf/luasrc/controller/oaf_app_filter.lua b/luci-app-oaf/luasrc/controller/oaf_app_filter.lua index 9cff55c46..3704db9be 100644 --- a/luci-app-oaf/luasrc/controller/oaf_app_filter.lua +++ b/luci-app-oaf/luasrc/controller/oaf_app_filter.lua @@ -1,9 +1,9 @@ module("luci.controller.oaf_app_filter", package.seeall) local utl = require "luci.util" -local nixio = require "nixio" function index() - if not nixio.fs.access("/etc/config/appfilter") then + local fs = require "nixio.fs" + if not fs.access("/etc/config/appfilter") then return end entry({"admin", "services", "oaf", "app_filter"}, alias("admin", "services", "oaf", "app_filter", "rules"), _("App Filter"), 30).dependent = true diff --git a/luci-app-oaf/luasrc/controller/oaf_mac_filter.lua b/luci-app-oaf/luasrc/controller/oaf_mac_filter.lua index e9e5b2d82..8270d8c59 100644 --- a/luci-app-oaf/luasrc/controller/oaf_mac_filter.lua +++ b/luci-app-oaf/luasrc/controller/oaf_mac_filter.lua @@ -1,9 +1,9 @@ module("luci.controller.oaf_mac_filter", package.seeall) local utl = require "luci.util" -local nixio = require "nixio" function index() - if not nixio.fs.access("/etc/config/macfilter") then + local fs = require "nixio.fs" + if not fs.access("/etc/config/macfilter") then return end diff --git a/luci-app-oaf/luasrc/controller/oaf_whitelist.lua b/luci-app-oaf/luasrc/controller/oaf_whitelist.lua index 63ce59fbf..e7ca10b33 100644 --- a/luci-app-oaf/luasrc/controller/oaf_whitelist.lua +++ b/luci-app-oaf/luasrc/controller/oaf_whitelist.lua @@ -1,6 +1,5 @@ module("luci.controller.oaf_whitelist", package.seeall) -local nixio = require "nixio" local util = require "luci.util" local jsonc = require "luci.jsonc" @@ -30,9 +29,10 @@ local function get_record_whitelist_page(page, page_size) end function index() - local has_app_filter = nixio.fs.access("/etc/config/appfilter") - local has_access_control = nixio.fs.access("/etc/config/macfilter") - local has_record = nixio.fs.access("/etc/config/fwx_record") + local fs = require "nixio.fs" + local has_app_filter = fs.access("/etc/config/appfilter") + local has_access_control = fs.access("/etc/config/macfilter") + local has_record = fs.access("/etc/config/fwx_record") if not has_app_filter and not has_access_control and not has_record then return @@ -178,4 +178,4 @@ function get_all_users() end write_json({code = 2000, data = {list = all_list}, message = "success"}) -end \ No newline at end of file +end diff --git a/luci-app-openclaw/CHANGELOG.md b/luci-app-openclaw/CHANGELOG.md index ef57a82f6..56335fb95 100644 --- a/luci-app-openclaw/CHANGELOG.md +++ b/luci-app-openclaw/CHANGELOG.md @@ -4,6 +4,116 @@ 格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/)。 +## [2.1.1] - 2026-08-27 + +本次维护以 OpenClaw 2026.7.1-2 为基准,逐项核对配置写入与上游 schema 的一致性。 +当前适配版本已等于 npm `latest`,因此**未升级 OpenClaw 版本**,修的是项目自身 +与现行 schema 的既有偏离。 + +> 版本号说明:`v2.1.0` 标签(2026-07-27)出自 `release/v2.1.0-openclaw-2026.6.33` +> 分支,适配的是更旧的 OpenClaw 2026.6.33,与 main 的 2.0.12 语义倒挂。 +> 为避免版本号回退,本次直接发布 2.1.1 并明确适配 2026.7.1-2。 + +### 修复配置丢失(严重) + +- **配置管理界面写入可能清空整份配置**:`readConfig()` 在 `JSON.parse` 失败时返回 `{}`, + 调用方随后 `writeConfig()` 把空对象写回磁盘。实测一份仅多出尾随逗号的配置 + (OpenClaw 自身的 JSON5 解析器可容忍),用户只改一个网关端口,文件即从 124 字节 + (含 apiKey)变成 40 字节,`models.providers` 与 API Key 全部丢失。 + 现改为解析失败即中止写入,并给出 `doctor --fix` / `config validate` / `last-good` 恢复指引。 +- **服务启动时同样会清空配置**:`init.d` 的 `sync_uci_to_json()` 有同类缺陷,且位于 + `start_service` 路径上自动执行,不需要用户操作。实测一次普通的 + `/etc/init.d/openclaw start` 就会让 apiKey 与 `channels.openclaw-weixin` 消失。 + 现改为解析失败即跳过同步,配置原样保留。 +- 配置写入改为原子操作:写前备份(`.luci-pre-write`,不占用 OpenClaw 自己的 `.bak` 轮转链) + → 临时文件 → 回读校验 → `rename` 替换,并保留原文件权限位。 + +### 修复配置类型与键名(严重) + +- **所有配置值都被写成字符串**:`json_set` 原实现"读取值并作为字符串保存", + 而上游严格校验类型,实测拒绝 `gateway.port: Invalid input`、 + `acp.dispatch.enabled: Invalid input (allowed: true, false)`、 + `channels.telegram.enabled: must be boolean`。现按 schema 类型表自动判定 + number/boolean/string/json。注意 `init.d` 只在冷启动修正这些字段,而 + `restart_gateway` 走 SIGUSR1 快速重载不经过修正——这正是"改完配置重启后网关起不来"的路径。 +- **日志级别设置从未生效**:`gateway.logLevel` 不在 schema 中 + (`gateway.additionalProperties: false`),`config set` 直接报 `Unrecognized key`, + 手写进文件则被静默忽略。改为正确键 `logging.level`,菜单补齐上游 7 档枚举 + (silent/fatal/error/warn/info/debug/trace),并清理旧配置里的错误键。 +- **绑定地址提供了上游不接受的值**:`gateway.bind=all` 实测被拒 + (允许 auto/lan/loopback/custom/tailnet)。菜单改用上游枚举,旧值 `all` + 自动映射为 `custom` + `customBindHost=0.0.0.0`。 +- 修复一类"假成功":写入失败时界面仍打印 ✅。`--set` 与端口/模式/ACP/绑定地址 + 菜单现在都检查返回码;端口另加 1–65535 校验;仅在 JSON 写入成功后才同步 UCI。 + +### 修复功能不可达 + +- **备份与重置菜单有三个选项打不开**:菜单打印 1–5,`case` 分支却只有 + `1) 2) 3) c) d)`,导致「查看备份列表」「从最新备份恢复」「完全恢复出厂」 + 按提示操作只会得到"无效选择",真正的逻辑挂在从不显示的 `c`/`d` 上。已修正键位。 + +### 修复 Telegram 配对(issue #98) + +- 「Telegram 配对助手」调用的 `openclaw models auth login-telegram-bot` 在 2026.6+ + 已移除,实测报 `Too many arguments for this command`。改为对齐上游 + `openclaw pairing list` / `pairing approve`,与 shell 侧早已正确实现的流程一致。 + 配对与 Bot Token 配置是两件不同的事,本次明确区分,不合并为同一入口。 + +### 修复打包与 feeds 集成(issue #60) + +- OpenWrt 25.x 下 `Ignoring feed 'openclaw' - index missing`:不再依赖 `luci.mk` + 的隐式 Package 生成,统一 `include package.mk` 并显式定义 `Package/...`。 +- Makefile 漏装交互式菜单:`oc-config-interactive.js` 与 `oc-menu-engine.js` 只在 + build 脚本里被 `cp *.js` 带上,走 feeds/SDK 编译的包会缺这两个文件,此时 + `can_use_interactive()` 静默回落到功能较少的传统菜单——用户看不到报错, + 只会觉得界面与教程不一致。现三条打包路径清单一致。 +- 保留 `libstdcpp6` 依赖(issue #28:缺失会导致 Node.js 无法运行)。 + +### AI Provider / Model 清单改造 + +- **不再硬编码易过期的模型 ID**。用 OpenClaw 2026.7.1-2 实测核对,原菜单里 + `openai/gpt-5.2`、`gpt-5-mini`、`gpt-4.1`、`o3`、`o4-mini`、 + `claude-sonnet-4-20250514`、`claude-opus-4-20250514`、`claude-sonnet-4.5`、 + `xai/grok-4`、`grok-3`、`deepseek/deepseek-r1`、`meta-llama/llama-4-maverick`、 + `01-ai/Yi-1.5-34B-Chat-16K`、`Qwen/Qwen2.5-*`、`THUDM/glm-4-9b-chat`、 + `github-copilot/gpt-4.1`、`gpt-4o` 等在上游 catalog 中均已不存在。 +- 改为三层架构:精选预设 `model-presets.json`(shell 与 JS 共读的唯一数据源, + 14 provider / 44 模型,32 条 builtin 预设已逐条核对存在)→ 动态发现 + `openclaw models list --provider `(带 6s 超时,失败静默回落)→ 手动输入 + (永久保留的兼容出口)。上游模型迭代时不必再改本插件。 +- SiliconFlow 非 OpenClaw 内置/官方插件 provider,无法用上游 catalog 核实模型 ID, + 改为引导用户从官方模型广场复制当前 ID——与其留一份会腐坏的列表,不如给可靠来源。 + +### 安全 + +- **状态变更端点缺少 CSRF 保护**:`service_ctl`(启停服务)、`uninstall`(删除运行环境)、 + `plugin_upgrade`(下载执行 .run)、`backup`(create/restore/delete)、 + `get_token`(返回网关与 PTY 凭据)原为 `call()`,允许 GET 触发且不校验 token, + 诱导已登录管理员访问一个链接即可卸载环境或读出凭据。现改为 `post()`, + 前端 8 处调用同步改为带 CSRF token 的 POST。只读端点保持 `call()` 不变。 +- **Web PTY 头部过度开放**:移除 `Access-Control-Allow-Origin: *` 与 + `X-Frame-Options: ALLOWALL`,CSP 从 `default-src *` 收紧到 `'self'` + (保留 `unsafe-inline`——页面确有内联块;去掉 `unsafe-eval`), + 新增 `Referrer-Policy: no-referrer`。WebSocket token 校验未削弱。 +- **PTY token 泄漏**:页面原先把含 token 的 WebSocket URL 写进可见调试文本与 console。 + 现读取后立即用 `history.replaceState` 从地址栏移除,展示与日志一律用脱敏 URL。 +- **恢复 profile 隔离能力**:`openclaw-env` 生成的 CLI wrapper 无条件 export + `OPENCLAW_HOME` 等变量,把官方文档承诺的 per-instance 环境变量与 + `openclaw --profile` 全部覆盖,导致无法按官方文档跑多实例网关、 + 也无法做隔离的配置写入测试。改为仅在调用方未提供时填默认值。 + +### 测试 + +- 新增测试 runner `tests/run_all.sh`,测试数从 7 项增至 16 项,全部通过。 +- 新增契约测试:配置写入安全、配置类型、菜单按键覆盖、打包清单一致性 + (含真实构建 `.ipk` 并解包校验)、模型预设架构、Telegram 配对语义、 + init.d 启动同步安全、LuCI CSRF、Web PTY 安全。 +- 上游 schema 关键字段类型固化为 `tests/fixtures/openclaw-schema-types.tsv`。 +- 每项契约测试均用变异测试反向验证过:故意退化实现后测试必须失败, + 确保护栏不是"永远通过"的空壳。 + +--- + ## [2.0.12] - 2026-08-22 ### 适配 OpenClaw v2026.7.1-2 diff --git a/luci-app-openclaw/Makefile b/luci-app-openclaw/Makefile index 7ea54ecbe..3b2870706 100644 --- a/luci-app-openclaw/Makefile +++ b/luci-app-openclaw/Makefile @@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-openclaw PKG_VERSION:=$(strip $(shell cat $(CURDIR)/VERSION 2>/dev/null || echo "1.0.0")) -PKG_RELEASE:=25 +PKG_RELEASE:=26 PKG_MAINTAINER:=10000ge10000 <10000ge10000@users.noreply.github.com> PKG_LICENSE:=GPL-3.0 @@ -16,35 +16,42 @@ LUCI_TITLE:=OpenClaw AI 网关 LuCI 管理插件 LUCI_DEPENDS:=+luci-compat +luci-base +curl +openssl-util +script-utils +tar +libstdcpp6 LUCI_PKGARCH:=all -# 优先使用 luci.mk (feeds 模式), 不可用时回退 package.mk -ifeq ($(wildcard $(TOPDIR)/feeds/luci/luci.mk),) +# 始终使用 package.mk 并显式定义 Package/...。 +# +# 历史实现在检测到 feeds/luci/luci.mk 时改走 luci.mk 分支,且不定义 +# Package/...,依赖 luci.mk 由 LUCI_* 变量自动生成。该机制在 OpenWrt 25.x +# 的 feeds 布局下失效,表现为 (issue #60): +# Ignoring feed 'openclaw' - index missing +# 且 menuconfig 的 LuCI → Applications 下找不到本插件。 +# +# 显式声明对两种集成方式都有效,不再依赖 luci.mk 的隐式行为: +# 1. 作为 feeds 源: echo "src-git openclaw ..." >> feeds.conf.default +# 2. 直接放入 package/ 目录 +include $(INCLUDE_DIR)/package.mk - include $(INCLUDE_DIR)/package.mk +define Package/$(PKG_NAME) + SECTION:=luci + CATEGORY:=LuCI + SUBMENU:=3. Applications + TITLE:=$(LUCI_TITLE) + DEPENDS:=$(LUCI_DEPENDS) + PKGARCH:=all +endef - define Package/$(PKG_NAME) - SECTION:=luci - CATEGORY:=LuCI - SUBMENU:=3. Applications - TITLE:=$(LUCI_TITLE) - DEPENDS:=$(LUCI_DEPENDS) - PKGARCH:=all - endef - - define Package/$(PKG_NAME)/description - OpenClaw AI Gateway 的 LuCI 管理插件。 - 支持 12+ AI 模型提供商和 Telegram/Discord 等多种消息渠道。 - endef - -else - - include $(TOPDIR)/feeds/luci/luci.mk - -endif +define Package/$(PKG_NAME)/description + OpenClaw AI Gateway 的 LuCI 管理插件。 + 支持 12+ AI 模型提供商和 Telegram/Discord 等多种消息渠道。 +endef define Package/$(PKG_NAME)/conffiles /etc/config/openclaw endef +# 安装清单必须与 scripts/build_ipk.sh / scripts/build_run.sh 保持一致。 +# 特别是 /usr/share/openclaw 下的 oc-config-interactive.js 与 oc-menu-engine.js: +# 缺失时 oc-config.sh 的 can_use_interactive() 会静默回落到功能较少的传统 +# 数字菜单,用户只会觉得"界面和教程不一样",不会看到任何报错。 +# tests/test_packaging_parity.sh 会校验三条打包路径的清单一致性。 define Package/$(PKG_NAME)/install $(INSTALL_DIR) $(1)/etc/config $(INSTALL_CONF) ./root/etc/config/openclaw $(1)/etc/config/openclaw @@ -77,6 +84,9 @@ define Package/$(PKG_NAME)/install $(INSTALL_DATA) ./VERSION $(1)/usr/share/openclaw/VERSION $(INSTALL_BIN) ./root/usr/share/openclaw/oc-config.sh $(1)/usr/share/openclaw/oc-config.sh $(INSTALL_DATA) ./root/usr/share/openclaw/web-pty.js $(1)/usr/share/openclaw/web-pty.js + $(INSTALL_BIN) ./root/usr/share/openclaw/oc-config-interactive.js $(1)/usr/share/openclaw/oc-config-interactive.js + $(INSTALL_DATA) ./root/usr/share/openclaw/oc-menu-engine.js $(1)/usr/share/openclaw/oc-menu-engine.js + $(INSTALL_DATA) ./root/usr/share/openclaw/model-presets.json $(1)/usr/share/openclaw/model-presets.json $(INSTALL_DIR) $(1)/usr/share/openclaw/ui $(CP) ./root/usr/share/openclaw/ui/* $(1)/usr/share/openclaw/ui/ endef diff --git a/luci-app-openclaw/README.md b/luci-app-openclaw/README.md index 030e0823a..430b7b317 100644 --- a/luci-app-openclaw/README.md +++ b/luci-app-openclaw/README.md @@ -140,9 +140,25 @@ opkg install python3-light - 插件注册缺失:重新安装插件,启动时也会自动补齐 `openclaw-weixin` 配置。 - 目录权限错误:按页面日志提示修复 OpenClaw 数据目录权限。 +## AI 模型选择机制 + +配置菜单里的模型清单分三层,避免硬编码的模型 ID 随上游迭代而过期: + +1. **精选模型** —— 内置于 `model-presets.json`,每个 Provider 给推荐/最强/均衡/快速四档。 + 离线可用、首屏即显示。 +2. **从 OpenClaw 获取完整模型列表** —— 调用 `openclaw models list --provider ` + 动态发现当前实际可用的模型(带 6 秒超时,失败或超时自动回落到精选列表)。 + 未安装对应 Provider 插件、或未配置 API Key 时可能返回空,属正常情况。 +3. **手动输入模型 ID** —— 永久保留。上游发布新模型时无需等待本插件更新。 + +因此 GPT / Claude / Gemini 等更新后,通常不必升级本插件即可使用新模型。 + ## 已知说明 - OpenClaw 的 diagnostic heartbeat 可能在日志中出现类似周期性探测记录。它不是一次真实用户对话请求;如需降低噪音,优先在 OpenClaw 配置或日志采集侧降低诊断日志级别,不建议直接修改模型调用逻辑。 +- 配置管理界面依赖 `oc-config-interactive.js`;若该文件缺失(例如自行裁剪打包清单), + 界面会静默回落到功能较少的传统数字菜单。`tests/test_packaging_parity.sh` 会校验 + 三条打包路径的文件清单一致性。 - 当前仓库提供源码、OpenWrt feeds 集成方式、本地 `.run` / `.ipk` 构建脚本入口;推送 `v*` 标签后 CI 会自动构建并发布 Release 产物。 ## 📂 目录结构 @@ -167,12 +183,19 @@ luci-app-openclaw/ │ └── usr/ │ ├── libexec/ # 共享 shell helper │ ├── bin/openclaw-env # 环境管理工具 -│ └── share/openclaw/ # 配置终端资源 +│ └── share/openclaw/ +│ ├── oc-config.sh # 配置管理入口(无 TTY 时的回退菜单) +│ ├── oc-config-interactive.js # 交互式配置菜单(默认路径) +│ ├── oc-menu-engine.js # 方向键菜单引擎 +│ ├── model-presets.json # 精选模型预设(shell 与 JS 共读) +│ ├── web-pty.js # Web PTY 服务 +│ └── ui/ # 配置终端前端资源 ├── scripts/ │ ├── build_ipk.sh # 本地 IPK 构建 │ ├── build_run.sh # .run 安装包构建 │ ├── gen-release-body.sh # Release 说明生成 │ └── build-node-musl.sh # 编译 Node.js musl 静态链接版本 +├── tests/ # 契约测试(sh tests/run_all.sh) └── .github/workflows/ ├── build.yml # 在线构建 + 发布 └── build-node-musl.yml # Node.js musl 构建 diff --git a/luci-app-openclaw/VERSION b/luci-app-openclaw/VERSION index 280a1e336..3e3c2f1e5 100644 --- a/luci-app-openclaw/VERSION +++ b/luci-app-openclaw/VERSION @@ -1 +1 @@ -2.0.12 +2.1.1 diff --git a/luci-app-openclaw/luasrc/controller/openclaw.lua b/luci-app-openclaw/luasrc/controller/openclaw.lua index d45775503..d139638ac 100644 --- a/luci-app-openclaw/luasrc/controller/openclaw.lua +++ b/luci-app-openclaw/luasrc/controller/openclaw.lua @@ -325,7 +325,10 @@ function index() entry({"admin", "services", "openclaw", "status_api"}, call("action_status"), nil).leaf = true -- 服务控制 API - entry({"admin", "services", "openclaw", "service_ctl"}, call("action_service_ctl"), nil).leaf = true + -- 会改状态的端点必须用 post(): LuCI 的 test_post_security() 同时要求 + -- POST 方法与匹配的 CSRF token,call() 允许 GET 触发, + -- 诱导已登录管理员访问一个链接即可启停服务。 + entry({"admin", "services", "openclaw", "service_ctl"}, post("action_service_ctl"), nil).leaf = true -- 安装/升级日志 API (轮询) entry({"admin", "services", "openclaw", "setup_log"}, call("action_setup_log"), nil).leaf = true @@ -333,20 +336,22 @@ function index() -- 版本检查 API (仅检查插件版本) entry({"admin", "services", "openclaw", "check_update"}, call("action_check_update"), nil).leaf = true - -- 卸载运行环境 API - entry({"admin", "services", "openclaw", "uninstall"}, call("action_uninstall"), nil).leaf = true + -- 卸载运行环境 API (破坏性操作,必须 POST + CSRF) + entry({"admin", "services", "openclaw", "uninstall"}, post("action_uninstall"), nil).leaf = true - -- 获取网关 Token API (仅认证用户可访问) - entry({"admin", "services", "openclaw", "get_token"}, call("action_get_token"), nil).leaf = true + -- 获取网关 Token API (返回凭据,必须 POST + CSRF 防止被第三方页面读取) + entry({"admin", "services", "openclaw", "get_token"}, post("action_get_token"), nil).leaf = true - -- 插件升级 API - entry({"admin", "services", "openclaw", "plugin_upgrade"}, call("action_plugin_upgrade"), nil).leaf = true + -- 插件升级 API (会下载并执行 .run,必须 POST + CSRF) + entry({"admin", "services", "openclaw", "plugin_upgrade"}, post("action_plugin_upgrade"), nil).leaf = true -- 插件升级日志 API (轮询) entry({"admin", "services", "openclaw", "plugin_upgrade_log"}, call("action_plugin_upgrade_log"), nil).leaf = true -- 配置备份 API (v2026.3.8+: openclaw backup create/verify) - entry({"admin", "services", "openclaw", "backup"}, call("action_backup"), nil).leaf = true + -- 含 create/restore/delete 等破坏性动作,必须 POST + CSRF。 + -- 只读的 list 也走同一入口,一并要求 POST 以保持调用方式统一。 + entry({"admin", "services", "openclaw", "backup"}, post("action_backup"), nil).leaf = true -- 系统配置检测 API (安装前检测) entry({"admin", "services", "openclaw", "check_system"}, call("action_check_system"), nil).leaf = true @@ -1949,8 +1954,22 @@ fs.writeFileSync(p, JSON.stringify(d, null, 2)); -- 清理临时文件 sys.exec("rm -f /tmp/openclaw-wechat-*.log /tmp/openclaw-wechat-*.pid /tmp/openclaw-wechat-*.exit /tmp/openclaw-wechat-qrcode.txt") + -- 重启网关让卸载真正生效。 + -- + -- 不重启会留下登录态残留: 网关进程仍在内存中持有已加载的微信渠道, + -- 会把会话游标(get_updates_buf, 内含账号标识)写回刚被删除的 + -- .openclaw/openclaw-weixin/accounts/ 目录,于是目录被重新创建。 + -- 实测卸载后该文件 mtime 与卸载时刻相同 —— 是删除后被重建,而非漏删。 + -- + -- action_wechat_login / action_wechat_logout 本来就会重启网关, + -- 只有卸载遗漏了这一步,属实现不一致。 + sys.exec("/etc/init.d/openclaw restart &") + + -- 重启是异步的,等待一小段时间让网关放开插件文件句柄后再清理残留状态目录。 + sys.exec("(sleep 6; rm -rf " .. shellquote(wechat_state_dir) .. " 2>/dev/null) &") + http.prepare_content("application/json") - http.write_json({ status = "ok", message = "微信插件已卸载" }) + http.write_json({ status = "ok", message = "微信插件已卸载,网关正在重启" }) end -- ═══════════════════════════════════════════ @@ -1961,7 +1980,12 @@ function action_wechat_check_upgrade() local sys = require "luci.sys" local install_path = get_install_path() - local npx_bin = install_path .. "/node/bin/npx" + -- view 是 npm 的子命令, 不是 npx 的。 + -- 历史实现用 npx view,npx 会把 view 当成待执行的包去解析并失败: + -- npm error could not determine executable to run + -- 该错误被 2>/dev/null 吞掉后 latest_version 恒为空, + -- is_newer_version("" , x) 恒为 false —— 升级检测从未真正工作过。 + local npm_bin = install_path .. "/node/bin/npm" local oc_data = install_path .. "/data" -- 获取当前已安装版本 @@ -1975,17 +1999,24 @@ function action_wechat_check_upgrade() current_version = content:match('"version"%s*:%s*"([^"]+)"') or "" end - -- 检测最新版本 (通过 npm view) + -- 检测最新版本 (npm view) local latest_version = "" + local check_err = "" local env_prefix = string.format( "HOME=%s PATH=%s/node/bin:%s/global/bin:$PATH", oc_data, install_path, install_path ) + -- 保留 stderr 以便查询失败时能给出可诊断的提示,而不是静默显示"已是最新" local check_cmd = string.format( - "%s %s view @tencent-weixin/openclaw-weixin version 2>/dev/null", - env_prefix, npx_bin + "%s %s view @tencent-weixin/openclaw-weixin version 2>&1", + env_prefix, npm_bin ) - latest_version = sys.exec(check_cmd):gsub("%s+", "") + local raw = sys.exec(check_cmd) or "" + -- npm 输出可能夹带告警行,取最后一个形如 x.y.z 的 token + latest_version = raw:match("(%d+%.%d+%.%d+[%w%.%-]*)%s*$") or "" + if latest_version == "" then + check_err = raw:gsub("%s+$", ""):sub(1, 200) + end local has_upgrade = false if is_newer_version(latest_version, current_version) then @@ -1993,11 +2024,15 @@ function action_wechat_check_upgrade() end http.prepare_content("application/json") + -- 查询失败时必须显式区分"已是最新"与"查不到", + -- 否则用户永远看到"已是最新版"而不知道检测其实没跑通。 http.write_json({ - status = "ok", + status = (latest_version ~= "") and "ok" or "error", current_version = current_version, latest_version = latest_version, - has_upgrade = has_upgrade + has_upgrade = has_upgrade, + message = (latest_version ~= "") and "" or + ("无法查询最新版本 (请检查网络或 npm 源): " .. check_err) }) end diff --git a/luci-app-openclaw/luasrc/model/cbi/openclaw/basic.lua b/luci-app-openclaw/luasrc/model/cbi/openclaw/basic.lua index c630b915a..058b4fea6 100644 --- a/luci-app-openclaw/luasrc/model/cbi/openclaw/basic.lua +++ b/luci-app-openclaw/luasrc/model/cbi/openclaw/basic.lua @@ -28,8 +28,14 @@ act.cfgvalue = function(self, section) local plugin_upgrade_url = luci.dispatcher.build_url("admin", "services", "openclaw", "plugin_upgrade") local plugin_upgrade_log_url = luci.dispatcher.build_url("admin", "services", "openclaw", "plugin_upgrade_log") local check_system_url = luci.dispatcher.build_url("admin", "services", "openclaw", "check_system") + -- CSRF token: 会改状态或返回凭据的端点已改为 post(), + -- LuCI 的 test_post_security() 要求表单里带上与会话匹配的 token。 + local csrf_token = luci.dispatcher.context.authtoken or "" local html = {} + html[#html+1] = '' + -- 按钮区域 html[#html+1] = '
' html[#html+1] = '' @@ -182,7 +188,7 @@ act.cfgvalue = function(self, section) html[#html+1] = 'logEl.textContent+="════════════════════════════════════════\\n";' html[#html+1] = 'logEl.textContent+="安装路径: "+install_path+"\\n";' html[#html+1] = 'logEl.textContent+="正在启动安装...\\n";' - html[#html+1] = '(new XHR()).get("' .. ctl_url .. '?action=setup&version="+encodeURIComponent(version)+"&install_path="+encodeURIComponent(install_path),null,function(x){' + html[#html+1] = '(new XHR()).post("' .. ctl_url .. '?action=setup&version="+encodeURIComponent(version)+"&install_path="+encodeURIComponent(install_path),{token:ocCsrfToken},function(x){' html[#html+1] = 'try{JSON.parse(x.responseText);}catch(e){}' html[#html+1] = 'ocPollSetupLog();' html[#html+1] = '});' @@ -307,7 +313,7 @@ act.cfgvalue = function(self, section) html[#html+1] = 'function ocServiceCtl(action){' html[#html+1] = 'var el=document.getElementById("action-result");' html[#html+1] = 'el.innerHTML="⏳ 正在执行...";' - html[#html+1] = '(new XHR()).get("' .. ctl_url .. '?action="+action,null,function(x){' + html[#html+1] = '(new XHR()).post("' .. ctl_url .. '?action="+action,{token:ocCsrfToken},function(x){' html[#html+1] = 'try{var r=JSON.parse(x.responseText);' html[#html+1] = 'if(r.status==="ok"){el.innerHTML="✅ "+action+" 已完成";}' html[#html+1] = 'else{el.innerHTML="❌ "+(r.message||"失败")+"";}' @@ -407,7 +413,7 @@ act.cfgvalue = function(self, section) html[#html+1] = 'titleEl.textContent="📋 插件升级日志";' html[#html+1] = 'statusEl.innerHTML="⏳ 插件升级中...";' html[#html+1] = 'resultEl.style.display="none";' - html[#html+1] = '(new XHR()).get("' .. plugin_upgrade_url .. '?version="+encodeURIComponent(ver),null,function(x){' + html[#html+1] = '(new XHR()).post("' .. plugin_upgrade_url .. '?version="+encodeURIComponent(ver),{token:ocCsrfToken},function(x){' html[#html+1] = 'try{JSON.parse(x.responseText);}catch(e){}' html[#html+1] = 'ocPollPluginUpgradeLog();' html[#html+1] = '});' @@ -481,7 +487,7 @@ act.cfgvalue = function(self, section) html[#html+1] = 'var el=document.getElementById("action-result");' html[#html+1] = 'btn.disabled=true;btn.textContent="⏳ 正在卸载...";' html[#html+1] = 'el.innerHTML="正在停止服务并清理文件...";' - html[#html+1] = '(new XHR()).get("' .. uninstall_url .. '",null,function(x){' + html[#html+1] = '(new XHR()).post("' .. uninstall_url .. '",{token:ocCsrfToken},function(x){' html[#html+1] = 'btn.disabled=false;btn.textContent="🗑️ 卸载环境";' html[#html+1] = 'try{var r=JSON.parse(x.responseText);' html[#html+1] = 'if(r.status==="ok"){' @@ -542,7 +548,7 @@ act.cfgvalue = function(self, section) html[#html+1] = 'function ocLoadBackupList(){' html[#html+1] = 'var el=document.getElementById("oc-backup-list");' html[#html+1] = 'el.innerHTML="
⏳ 加载备份列表...
";' - html[#html+1] = '(new XHR()).get("' .. backup_url .. '?action=list",null,function(x){' + html[#html+1] = '(new XHR()).post("' .. backup_url .. '?action=list",{token:ocCsrfToken},function(x){' html[#html+1] = 'try{var r=JSON.parse(x.responseText);' html[#html+1] = 'if(r.status==="ok"&&r.backups&&r.backups.length>0){' html[#html+1] = 'var h="";' @@ -584,7 +590,7 @@ act.cfgvalue = function(self, section) html[#html+1] = 'btnC.disabled=true;btnF.disabled=true;' html[#html+1] = 'resEl.style.display="block";' html[#html+1] = 'resEl.innerHTML="
⏳ 正在创建备份..."+(onlyConfig?"(仅配置)":"(完整备份,可能需要较长时间)")+"
";' - html[#html+1] = '(new XHR()).get("' .. backup_url .. '?action=create&only_config="+onlyConfig,null,function(x){' + html[#html+1] = '(new XHR()).post("' .. backup_url .. '?action=create&only_config="+onlyConfig,{token:ocCsrfToken},function(x){' html[#html+1] = 'btnC.disabled=false;btnF.disabled=false;' html[#html+1] = 'try{var r=JSON.parse(x.responseText);' html[#html+1] = 'if(r.status==="ok"){' @@ -603,7 +609,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=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 would otherwise be announced as a graphic in its own right. + aria-hidden, because each icon sits beside its own label; an unlabelled would be announced + as a graphic in its own right. Licensed to the public under the Apache License 2.0. -#} diff --git a/luci-theme-footstrap/ucode/template/themes/footstrap/partials/logout.ut b/luci-theme-footstrap/ucode/template/themes/footstrap/partials/logout.ut index 19efa5a66..4477f31c6 100644 --- a/luci-theme-footstrap/ucode/template/themes/footstrap/partials/logout.ut +++ b/luci-theme-footstrap/ucode/template/themes/footstrap/partials/logout.ut @@ -1,19 +1,15 @@ {# - The logout control (guarded on the node being dispatchable). ONE markup for every layout, - which CSS renders as a square icon button in the bar and the rail: ONE markup for every layout, - because a layout is a presentation choice and must not fork the markup. So the old `with_label` - parameter and .fs-top-logout are gone. - It is also the ONLY logout the theme renders: menu-footstrap.js drops the menu tree's own - admin/logout node, so the bar carries no duplicate "Log out" item beside this one. + The logout control, guarded on the node being dispatchable. One markup for every layout, since a + layout is a presentation choice and must not fork the markup. It is also the only logout the theme + renders: menu-footstrap.js drops the menu tree's own admin/logout node. Licensed to the public under the Apache License 2.0. -#} {% if (dispatcher.lookup("admin/logout")): %} {# - MIRRORED ABOUT x=12, i.e. the glyph reads LEAVING rather than entering: the doorway sits on - the left and the arrow points out of it, to the right. Every path is the same path reflected - (each x becomes 24-x, and each arc's sweep flag flips with it), so the stroke widths, the - corner radii and the 24x24 box are untouched — nothing here is a redrawn icon. + Mirrored about x=12, so the glyph reads LEAVING rather than entering: each x becomes 24-x and + each arc's sweep flag flips with it, leaving stroke widths, corner radii and the 24x24 box + untouched. -#} {% include('themes/footstrap/partials/icon', { body: '' }) %} {{ _('Log out') }} diff --git a/luci-theme-footstrap/ucode/template/themes/footstrap/partials/notice.ut b/luci-theme-footstrap/ucode/template/themes/footstrap/partials/notice.ut index 7f15c1ea3..349ad31b0 100644 --- a/luci-theme-footstrap/ucode/template/themes/footstrap/partials/notice.ut +++ b/luci-theme-footstrap/ucode/template/themes/footstrap/partials/notice.ut @@ -1,15 +1,14 @@ {# - ONE top-of-content warning notice: heading, sentence, and an optional "go fix it" button. The - three notices in notices.ut were three copies of this markup — and the button half was written - out twice down to the `dispatcher.lookup` guard around it, which is the part that must not drift: - the guard is what keeps the button from pointing at a page this user's ACLs do not grant. + One top-of-content warning notice: heading, sentence and an optional "go fix it" button. The + `dispatcher.lookup` guard is the part that must not drift — it keeps the button from pointing at a + page this user's ACLs do not grant. include(…, { title, text }) for a plain notice; add { path, label } for the button. `path` is a - dispatcher path ("admin/system/admin"); it is looked up before it is linked, so a build with that - page absent — or a user without rights to it — gets the warning without a dead link. + dispatcher path, looked up before it is linked, so a build without that page — or a user without + rights to it — gets the warning and no dead link. - title/text are emitted with `{{ }}`, i.e. unescaped, exactly as the three copies did: they are - `_()` results over strings this theme ships, not request data. + title/text are emitted unescaped: they are `_()` results over strings this theme ships, not + request data. Licensed to the public under the Apache License 2.0. -#}
diff --git a/luci-theme-footstrap/ucode/template/themes/footstrap/partials/notices.ut b/luci-theme-footstrap/ucode/template/themes/footstrap/partials/notices.ut index 187dfde30..e643c7c4c 100644 --- a/luci-theme-footstrap/ucode/template/themes/footstrap/partials/notices.ut +++ b/luci-theme-footstrap/ucode/template/themes/footstrap/partials/notices.ut @@ -1,11 +1,10 @@ {# - Top-of-content notices: no-root-password warning, initramfs recovery warning, the
+
+ + + + + + diff --git a/luci-theme-istorenas/root/etc/uci-defaults/31_luci-istorenas-login b/luci-theme-istorenas/root/etc/uci-defaults/31_luci-istorenas-login new file mode 100644 index 000000000..5a07ea1e3 --- /dev/null +++ b/luci-theme-istorenas/root/etc/uci-defaults/31_luci-istorenas-login @@ -0,0 +1,12 @@ +#!/bin/sh + +if [ "$PKG_UPGRADE" != 1 ]; then + uci get luci.themes.iStoreNAS >/dev/null 2>&1 || \ + uci batch <<-EOF + set luci.themes.iStoreNAS=/luci-static/istorenas + set luci.main.mediaurlbase=/luci-static/istorenas + commit luci + EOF +fi + +exit 0 diff --git a/wwand/Makefile b/wwand/Makefile index 6b7712090..e9946628e 100644 --- a/wwand/Makefile +++ b/wwand/Makefile @@ -30,12 +30,12 @@ include $(TOPDIR)/rules.mk PKG_NAME:=wwand -PKG_RELEASE:=9 +PKG_RELEASE:=10 PKG_SOURCE_PROTO:=git PKG_SOURCE_URL:=https://github.com/ddimension/wwand.git -PKG_SOURCE_VERSION:=4134174ed37d2e17a9e1cb5b41c8e6c1f4c73675 -PKG_SOURCE_DATE:=2026-08-24 +PKG_SOURCE_VERSION:=648283c23470d602459ce2c54a60adf9109370da +PKG_SOURCE_DATE:=2026-08-26 PKG_MIRROR_HASH:=skip PKG_LICENSE:=GPL-2.0-only