mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-07-27 10:31:38 +08:00
✨ Sync 2026-03-14 19:52:12
This commit is contained in:
parent
aa7946ff93
commit
80bd1ecded
@ -5,12 +5,12 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=cloudreve
|
||||
PKG_VERSION:=4.14.1
|
||||
PKG_RELEASE:=1
|
||||
PKG_VERSION:=4.15.0
|
||||
PKG_RELEASE:=2
|
||||
|
||||
PKG_SOURCE_PROTO:=git
|
||||
PKG_SOURCE_URL:=https://github.com/cloudreve/Cloudreve.git
|
||||
PKG_SOURCE_VERSION:=95865add542f5b7ac0e774b3232ee01d72ea7daa
|
||||
PKG_SOURCE_VERSION:=006680c5df8b529ab52656133fc220ad73ab57ff
|
||||
PKG_MIRROR_HASH:=skip
|
||||
|
||||
PKG_LICENSE:=GPL-3.0-only
|
||||
|
||||
@ -6,8 +6,8 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=gowebdav
|
||||
PKG_VERSION:=5.11.1
|
||||
PKG_RELEASE:=1
|
||||
PKG_VERSION:=5.11.2
|
||||
PKG_RELEASE:=2
|
||||
|
||||
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
|
||||
PKG_SOURCE_URL:=https://codeload.github.com/hacdias/webdav/tar.gz/v$(PKG_VERSION)?
|
||||
|
||||
@ -11,8 +11,8 @@ PKG_ARCH_ISTOREENHANCE:=$(ARCH)
|
||||
|
||||
PKG_NAME:=istoreenhance
|
||||
# use PKG_SOURCE_DATE instead of PKG_VERSION for compitable
|
||||
PKG_SOURCE_DATE:=0.7.0
|
||||
PKG_RELEASE:=1
|
||||
PKG_SOURCE_DATE:=0.7.3
|
||||
PKG_RELEASE:=2
|
||||
ARCH_HEXCODE:=
|
||||
ifeq ($(ARCH),x86_64)
|
||||
ARCH_HEXCODE=8664
|
||||
|
||||
23
luci-app-openclaw/.gitignore
vendored
Normal file
23
luci-app-openclaw/.gitignore
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
# Node
|
||||
node_modules/
|
||||
*.tgz
|
||||
|
||||
# Build artifacts
|
||||
dist/
|
||||
*.ipk
|
||||
*.run
|
||||
|
||||
# Offline bundle cache (generated by scripts/download_deps.sh)
|
||||
|
||||
# Runtime data (contains sensitive API keys / tokens)
|
||||
opt/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
348
luci-app-openclaw/CHANGELOG.md
Normal file
348
luci-app-openclaw/CHANGELOG.md
Normal file
@ -0,0 +1,348 @@
|
||||
# Changelog
|
||||
|
||||
本项目所有重大变更都将记录在此文件中。
|
||||
|
||||
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/)。
|
||||
|
||||
## [1.0.15] - 2026-03-13
|
||||
|
||||
### 修复
|
||||
- **QQBot 插件 3 层死锁修复**: 解决插件安装后因 uid 权限→安全策略阻止→配置校验失败的连锁问题
|
||||
- 自动检测插件 blocked/loaded/目录存在 3 种状态
|
||||
- 插件安装后自动 `chown root:root` 修复权限
|
||||
- 安装失败但目录存在时不再阻断配置流程
|
||||
|
||||
### 新增
|
||||
- **覆盖安装防护**: 离线安装器在覆盖安装前先停止已有服务,避免文件被占用
|
||||
- **离线 .run 安装包**: 构建包含 Node.js + OpenClaw + LuCI 插件的全合一自解压包,用户**无需联网**即可完成安装
|
||||
- **musl 架构支持**: 离线包支持 x86_64-musl、aarch64-musl 两种架构 (OpenWrt/iStoreOS 均使用 musl)
|
||||
- **依赖预下载脚本** (`scripts/download_deps.sh`): 在构建机上预下载所有离线依赖
|
||||
- **node_modules 精简**: 自动删除文档、测试、TypeScript 源码等非必要文件,减小 30%+ 体积
|
||||
- **磁盘空间预检查**: 安装前检测可用空间是否满足 500MB 最低要求
|
||||
- **架构/libc 自动检测**: 安装时自动校验当前设备是否匹配安装包架构
|
||||
|
||||
### 文档
|
||||
- **README**: 添加离线安装方式(无需联网),更新目录结构
|
||||
|
||||
### 变更
|
||||
- **离线包不依赖 curl/openssl/git**: 离线安装模式下 opkg 注册的依赖简化为 luci-compat + luci-base
|
||||
|
||||
## [1.0.14] - 2026-03-12
|
||||
|
||||
### 备份管理增强 & QQ 机器人支持
|
||||
|
||||
#### 新增
|
||||
- **备份列表可视化**: LuCI「💾 备份/恢复」对话框现在展示所有备份的结构化列表:
|
||||
- 📄 仅配置 / 📦 完整备份 类型标签(从 manifest.json 读取 `onlyConfig` 字段精确判断)
|
||||
- 备份时间、文件大小
|
||||
- 每个备份支持**单独恢复**和**删除**操作
|
||||
- 创建/删除备份后列表自动刷新
|
||||
- **备份删除 API**: Controller 新增 `action=delete` 操作(含路径穿越安全校验)
|
||||
- **QQ 机器人配置**: `oc-config.sh` 渠道菜单新增「QQ 机器人」选项(选项 1,推荐国内用户),支持:
|
||||
- 自动安装 `@tencent-connect/openclaw-qqbot` 插件
|
||||
- App ID / App Secret 输入校验
|
||||
- 通过 `openclaw channels add` CLI 一键配置
|
||||
- **消息渠道状态显示**: 状态面板新增「消息渠道」行,自动检测已配置的渠道(QQ、Telegram、Discord、飞书、Slack)
|
||||
|
||||
#### 变更
|
||||
- **备份恢复**: 从"从最新备份恢复"改为在列表中选择任意备份进行恢复
|
||||
- **描述文本**: 各页面描述新增"QQ"渠道说明
|
||||
|
||||
#### 修复
|
||||
- **JS 语法错误导致所有按钮失效**: 备份对话框 HTML 被错误地插入 `<script>` 标签内部,导致 JavaScript 语法错误。修复: 在对话框 HTML 前后正确分割 `<script>` 标签
|
||||
|
||||
## [1.0.13] - 2026-03-12
|
||||
|
||||
### 适配 OpenClaw v2026.3.8 & 新增备份/恢复功能
|
||||
|
||||
#### 新增
|
||||
- **配置备份/恢复**: LuCI 基本设置页「💾 备份/恢复」按钮,弹出对话框支持:
|
||||
- 📄 仅配置文件备份(~2KB,包含模型、渠道、插件设置)
|
||||
- 📦 完整备份(配置 + 状态数据)
|
||||
- 🔄 从最新备份恢复配置(自动重启服务)
|
||||
- **Shell 备份菜单**: `oc-config.sh` 主菜单新增「8) 💾 备份/还原配置」,支持:
|
||||
- 创建仅配置 / 完整备份
|
||||
- 验证备份完整性 (`openclaw backup verify`)
|
||||
- 列出已有备份文件
|
||||
- 从最新备份恢复配置(交互确认 + 自动重启)
|
||||
- **命令行备份**: `oc-config.sh --backup` 快捷参数,适合 cron 定时任务
|
||||
- **OpenClaw 版本显示**: 状态面板新增 OpenClaw 版本行(从 package.json 读取)
|
||||
|
||||
#### 修复
|
||||
- **备份文件路径**: 备份文件统一保存到 `~/.openclaw/backups/` 目录(OpenClaw CLI 默认输出到 CWD)
|
||||
- **完整备份失败**: 含未注册插件的 channel ID 时,`backup create` 因 config invalid 失败;改用 `--no-include-workspace` 跳过工作区发现
|
||||
|
||||
#### 变更
|
||||
- **OC_TESTED_VERSION**: 2026.3.2 → 2026.3.8
|
||||
- **Control UI iframe 资源查找**: `patch_iframe_headers()` 扩展搜索路径,覆盖 `$NODE_BASE/lib/node_modules`、pnpm 全局存储,并使用 `readlink -f` 解析 v2026.3.8 新增的符号链接资源路径
|
||||
- **Gateway 入口查找**: `get_oc_entry()` 新增 `readlink -f` 符号链接解析,兼容 v2026.3.8 的 bundled 插件优先级调整
|
||||
- **配置同步清理**: `sync_uci_to_json()` 自动删除 v2026.3.7/3.8 已废弃的配置字段:
|
||||
- `gateway.controlUi.dangerouslyAllowCors`
|
||||
- `gateway.controlUi.dangerouslyAllowRemoteConnections`
|
||||
- `commands.ownerDisplay`
|
||||
|
||||
#### 兼容性说明
|
||||
- **v2026.3.7 BREAKING CHANGE**: `gateway.auth.mode` 必须显式指定(不再有默认值)。本插件已在 v1.0.3 起始终写入 `"mode": "token"`,无需用户操作
|
||||
- **v2026.3.8**: Control UI 资源分发改为符号链接方式,本版本已完整适配
|
||||
|
||||
## [1.0.12] - 2026-03-11
|
||||
|
||||
### 移除 OpenClaw 版本检测 & 修复 BusyBox tar 兼容性
|
||||
|
||||
#### 变更
|
||||
- **「检测升级」按钮**: 不再检查 OpenClaw (npm) 版本,仅检查插件 (luci-app-openclaw) 是否有新版本
|
||||
- **「检测升级」显示更新内容**: 检测到新插件版本时,直接展示该版本的 Release Notes,告知用户升级了什么
|
||||
- **状态面板**: 移除「OpenClaw」版本显示行,保留 Node.js 和插件版本
|
||||
- **内部清理**: 移除 `get_openclaw_version()` 函数、`action_do_update`、`action_upgrade_log` 等已废弃后端 API
|
||||
|
||||
#### 修复
|
||||
- **BusyBox tar 兼容性** (#18, #30): `openclaw-env` 安装 Node.js 时的解压命令优先使用 GNU tar `--strip-components=1`;若不支持则自动回退到 BusyBox tar 兼容方式(解压到临时目录后移动),无需用户手动安装 `tar`
|
||||
- **插件升级网络错误提示**: 下载后检测文件内容,若 GitHub 返回 `Not Found`(GFW 拦截等情况)则显示明确提示,并附手动下载链接
|
||||
|
||||
## [1.0.11] - 2026-03-09
|
||||
|
||||
### 修复 Telegram 配对后无法使用的严重 Bug
|
||||
|
||||
#### 修复
|
||||
- **Telegram 配置流程**: Token 保存后强制重启网关(不再可选),确保 Bot Token 立即生效后再进入配对流程
|
||||
- **Telegram 配对流程**: 配对成功后自动重启网关,确保配对关系立即生效,用户可以直接开始对话
|
||||
|
||||
## [1.0.10] - 2026-03-08
|
||||
|
||||
### 新增腾讯云大模型 Coding Plan 套餐支持
|
||||
|
||||
#### 新增
|
||||
- **腾讯云 Coding Plan 套餐**: 新增菜单选项 13,支持一键配置腾讯云大模型 Coding Plan 套餐
|
||||
- Base URL: `https://api.lkeap.cloud.tencent.com/coding/v3`,Provider: `lkeap`
|
||||
- 支持全部 8 个套餐模型: tc-code-latest (智能路由)、hunyuan-t1、hunyuan-turbos、hunyuan-2.0-thinking、hunyuan-2.0-instruct、glm-5、kimi-k2.5、minimax-m2.5
|
||||
- 按类别分组展示: 智能推荐 / 推理模型 / 旗舰模型 / 第三方模型
|
||||
|
||||
#### 修复
|
||||
- **Coding Plan 配置信息修正**: 订阅地址更正为官方页面,移除不必要的 Base URL 显示
|
||||
|
||||
## [1.0.9] - 2026-03-08
|
||||
|
||||
### 插件一键升级 & 百炼模型列表扩充
|
||||
|
||||
#### 新增
|
||||
- **插件一键升级**: LuCI 界面"检测升级"发现新版后,可直接点击"⬆️ 升级插件"按钮完成在线升级
|
||||
- 后台自动从 GitHub Releases 下载 `.run` 安装包并执行
|
||||
- 实时升级日志显示,带容错处理 (安装过程替换 LuCI 文件导致 API 暂时不可用时自动判定成功)
|
||||
- 同时保留"📥 手动下载"链接作为备选
|
||||
- **百炼按量付费模型列表扩充**: 从 4 个模型扩充至 16 个,按类别分组显示
|
||||
- 千问商业版: qwen-max、qwen-plus (Qwen3.5)、qwen-flash (Qwen3.5)、qwen-turbo、qwen-long (1000万Token上下文)
|
||||
- 千问Coder: qwen3-coder-plus (100万上下文)、qwen3-coder-flash
|
||||
- 推理模型: qwq-plus
|
||||
- 千问开源版: qwen3-235b-a22b、qwen3-32b、qwen3-30b-a3b
|
||||
- 第三方模型: deepseek-r1、deepseek-v3、kimi-k2.5、glm-5、MiniMax-M2.5
|
||||
|
||||
#### 修复
|
||||
- **CBI 底部按钮未隐藏**: "保存并应用/保存/复位"按钮在基本设置页仍然显示
|
||||
- 根因: `m.submit = false` 和 `m.reset = false` 不被 CBI 框架识别
|
||||
- 修复: 改为 `m.pageaction = false` (dispatcher.lua 第 294 行检查的正确属性)
|
||||
- **插件升级后配置管理无法连接**: 升级后 PTY WebSocket 一直转圈 "等待服务就绪"
|
||||
- 根因: `.run` 安装器覆盖 `/etc/config/openclaw` 导致 `pty_token` 丢失,PTY 认证失败
|
||||
- 修复: 升级时保留用户 UCI 配置 (仅首次安装部署默认配置);安装后自动重启 PTY 服务
|
||||
|
||||
## [1.0.8] - 2026-03-07
|
||||
|
||||
### 修复第三方模型配置导致 Gateway 崩溃 & 新增 Coding Plan 套餐支持
|
||||
|
||||
#### 修复
|
||||
- **EACCES 权限错误** (#8): Web PTY 以 root 运行,创建的目录 (`sessions/`, `auth-profiles.json` 等) 归 root 所有,Gateway 以 `openclaw` 用户运行时无法写入,报 `EACCES: permission denied, mkdir`
|
||||
- `oc-config.sh`: `auth_set_apikey`、`json_set`、备份目录创建后均执行 `chown openclaw:openclaw`
|
||||
- `web-pty.js`: 子进程退出时自动 `chown -R openclaw:openclaw` 整个数据目录
|
||||
- **第三方模型 404/405 错误** (#11, #13, #14, #15): DeepSeek、xAI Grok、Groq 等 OpenAI 兼容提供商配置后返回 404/405
|
||||
- 根因: 这些提供商缺少 `register_custom_provider` 调用,未写入 `baseUrl` 导致 Gateway 请求发到错误地址
|
||||
- 修复: 为 DeepSeek、xAI、Groq 快速配置补充 `register_custom_provider` 调用
|
||||
- **API 类型错误导致 Gateway 崩溃**: `register_custom_provider` 中 `api` 值设为 `openai-chat-completions`,但该值在 OpenClaw v2026.3.2 中不存在
|
||||
- 正确值为 `openai-completions`,错误值会导致 Gateway 启动时 JSON schema 校验失败,进入 crash loop
|
||||
- 有效 api 类型: `openai-completions` | `openai-responses` | `openai-codex-responses` | `anthropic-messages` | `google-generative-ai` | `github-copilot` | `bedrock-converse-stream` | `ollama`
|
||||
|
||||
#### 新增
|
||||
- **阿里云 Coding Plan 套餐快速配置**: 千问配置菜单新增 Coding Plan 选项 (选项 c,默认推荐)
|
||||
- Provider: `bailian`,Base URL: `https://coding.dashscope.aliyuncs.com/v1`
|
||||
- 一键注册套餐内全部模型: qwen3.5-plus、qwen3-coder-plus、qwen3-coder-next、qwen3-max、MiniMax-M2.5、glm-5、glm-4.7、kimi-k2.5
|
||||
- contextWindow / maxTokens 按阿里云官方文档设定 (最大 100万上下文)
|
||||
- 参考: [阿里云 Coding Plan 文档](https://help.aliyun.com/zh/model-studio/openclaw-coding-plan)
|
||||
|
||||
#### 变更
|
||||
- **千问配置菜单重构**: 从 2 种模式扩展为 3 种
|
||||
- `a)` Qwen Portal OAuth (官方向导)
|
||||
- `b)` 百炼按量付费 API Key (`sk-xxx` + `dashscope.aliyuncs.com`)
|
||||
- `c)` Coding Plan 套餐 (`sk-sp-xxx` + `coding.dashscope.aliyuncs.com`) ★ 默认推荐
|
||||
- 明确提示两套 API Key / Base URL 不互通
|
||||
- **`register_custom_provider` 增强**: 新增可选参数 `context_window` (默认 128000) 和 `max_tokens` (默认 32000)
|
||||
|
||||
## [1.0.7] - 2026-03-06
|
||||
|
||||
### 修复依赖包名错误 & 补充 GNU tar 依赖 (感谢 [@esir](https://github.com/esirplayground) 建议)
|
||||
|
||||
#### 修复
|
||||
- **依赖包名修正**: `util-linux-script` 在 OpenWrt/iStoreOS 软件源中不存在,正确的包名是 `script-utils` (提供 `/usr/bin/script` 命令)。此错误会导致通过 iStore/opkg 安装插件时依赖解析失败
|
||||
- **补充 GNU tar 依赖**: `openclaw-env` 安装脚本使用 `tar --strip-components=1` 解压 Node.js,但 busybox 内置的 tar 不支持该参数。新增 `tar` (GNU tar) 为必需依赖,确保解压操作正常
|
||||
|
||||
#### 变更
|
||||
- `Makefile`: `LUCI_DEPENDS` 中 `+util-linux-script` → `+script-utils +tar`
|
||||
- `scripts/build_ipk.sh`: 同步更新 Depends 字段
|
||||
- `scripts/build_run.sh`: 同步更新 Depends 字段
|
||||
|
||||
### 修复重启服务时 Gateway crash loop 端口冲突
|
||||
|
||||
#### 修复
|
||||
- **端口冲突 crash loop**: OpenClaw gateway 的架构是主进程 (`openclaw`) fork 出子进程 (`openclaw-gateway`) 监听端口,restart 时 procd 只杀主进程,子进程退出慢导致新实例端口冲突反复崩溃
|
||||
- `stop_service()`: 从空函数改为主动清理 `openclaw-gateway` 子进程 + 等待端口释放 (最长 8 秒)
|
||||
- `start_service()`: 启动前预检查端口,清理残留进程后再注册 procd 实例
|
||||
- `reload_service()`: stop 和 start 之间增加等待确保内核回收端口
|
||||
- LuCI controller: restart 改为先同步 stop 等端口释放,再后台 start
|
||||
- procd respawn 间隔从 5s → 10s,降低连续端口冲突概率
|
||||
|
||||
## [1.0.6] - 2026-03-06
|
||||
|
||||
### 修复 Docker 环境下安装失败 "mkdir: can't create directory: Directory not empty"
|
||||
|
||||
#### 修复
|
||||
- **OverlayFS 兼容性**: iStoreOS/OpenWrt 安装 Docker 后,Docker 的 bind mount (`/overlay/upper/opt/docker`) 导致 OverlayFS 合并视图中 `/opt` 目录完全不可写,所有 `mkdir`/`touch`/`ln` 操作均报 "Directory not empty"
|
||||
- 新增 `_oc_fix_opt()` 检测函数,自动检测 `/opt` 是否可写
|
||||
- 不可写时自动执行 `mount --bind /overlay/upper/opt /opt` 绕过 OverlayFS 冲突
|
||||
- 三重保障: `uci-defaults` (首次安装)、`init.d` (每次开机)、`openclaw-env` (手动操作) 均包含修复逻辑
|
||||
- 正常系统 (无 Docker) 不受影响,检测到可写后直接跳过
|
||||
- **openclaw-env**: 新增 `ensure_mkdir()` 安全目录创建函数,替代所有裸 `mkdir -p` 调用
|
||||
|
||||
## [1.0.5] - 2026-03-05
|
||||
|
||||
### 修复配置管理页面 "spawn script ENOENT" 启动失败 (#3, #4)
|
||||
|
||||
#### 修复
|
||||
- **Web PTY 启动失败**: `web-pty.js` 硬编码依赖 `script` 命令 (来自 `util-linux-script`),但部分 OpenWrt 固件默认不包含该命令,导致 `spawn script ENOENT` 错误并无限循环重启
|
||||
- 新增 `script` 命令自动检测,不存在时回退到 `sh` 直接执行 `oc-config.sh`
|
||||
- 新增连续失败计数器 (最多 5 次),防止启动失败时的无限重试循环
|
||||
- 失败时向用户终端显示明确的错误提示和修复命令
|
||||
- **Makefile 依赖补全**: `LUCI_DEPENDS` 新增 `+util-linux-script`,确保新安装自动拉取 `script` 命令
|
||||
|
||||
## [1.0.4] - 2026-03-05
|
||||
|
||||
### 适配 OpenClaw 2026.3.2
|
||||
|
||||
#### 破坏性变更修复
|
||||
- **tools.profile 默认值变更**: 2026.3.2 将 `tools.profile` 默认从 `coding` 改为 `messaging`
|
||||
- `sync_uci_to_json()` 每次启动强制写入 `tools.profile=coding`
|
||||
- `openclaw-env init_openclaw()` onboard 命令添加 `--tools-profile coding`
|
||||
- `openclaw-env do_factory_reset()` onboard 命令添加 `--tools-profile coding`
|
||||
- `oc-config.sh` 工厂重置 onboard 命令添加 `--tools-profile coding`
|
||||
- `oc-config.sh` 工厂重置配置写入新增 `tools.profile=coding`
|
||||
- **ACP dispatch 默认启用**: 2026.3.2 默认开启 ACP dispatch,路由器内存有限可能导致 OOM
|
||||
- `sync_uci_to_json()` 每次启动强制写入 `acp.dispatch.enabled=false`
|
||||
- `openclaw-env do_factory_reset()` 配置写入新增 `acp.dispatch.enabled=false`
|
||||
- `oc-config.sh` 工厂重置配置写入新增 `acp.dispatch.enabled=false`
|
||||
|
||||
#### 新增
|
||||
- 健康检查集成 `openclaw config validate --json` 官方配置验证命令
|
||||
- 健康检查新增 `gateway health --json` CLI 深度检查 (v2026.3.2 HTTP `/health` 已被 SPA 接管)
|
||||
|
||||
#### 修复
|
||||
- **Ollama 配置适配**: `api` 从废弃的 `openai-chat-completions` 改为原生 `ollama` API 类型
|
||||
- **Ollama baseUrl 格式**: 去掉 `/v1` 后缀,使用官方原生地址格式 (`http://host:11434`)
|
||||
- **Ollama apiKey 对齐**: 从 `ollama` 改为官方默认值 `ollama-local`
|
||||
- **启动自动迁移**: `sync_uci_to_json` 自动将旧版 Ollama 配置迁移到 v2026.3.2 格式
|
||||
|
||||
#### 改进
|
||||
- 配置管理页面移除「菜单功能说明」信息框,减少视觉干扰
|
||||
- `OC_TESTED_VERSION` 更新至 `2026.3.2`
|
||||
|
||||
## [1.0.3] - 2026-03-05
|
||||
|
||||
### 修复
|
||||
- **P0** 配置管理写入错误的 JSON 路径导致 Gateway 崩溃且无法恢复 (#1)
|
||||
- `json_set models.openai.apiKey` 在 `openclaw.json` 创建了非法的顶层 `models` 键
|
||||
- OpenClaw 2026.3.1 严格校验配置 schema,拒绝启动并报 `Unknown config keys: models.openai`
|
||||
- 修复: API Key 改写入 `auth-profiles.json`,模型注册到 `agents.defaults.models`
|
||||
- 影响: 所有 11 个供应商的快速配置 (OpenAI/Anthropic/Gemini/OpenRouter/DeepSeek/GitHub Copilot/Qwen/xAI/Groq/SiliconFlow/自定义)
|
||||
- **P0** 恢复默认配置 → "清除模型配置" 未清理 `auth-profiles.json` 认证信息
|
||||
- **P1** 健康检查新增自动修复: 检测并移除旧版错误写入的顶层 `models` 无效键
|
||||
- **P1** `set_active_model` 手动切换模型时未注册到 `agents.defaults.models`
|
||||
|
||||
### 新增
|
||||
- **Ollama 本地模型支持**: 快速配置菜单新增 Ollama 选项 (12),支持 localhost/局域网连接、自动检测连通性、自动列出已安装模型、兼容 OpenAI chat completions 格式
|
||||
- `openclaw-env factory-reset` 非交互式恢复出厂设置命令
|
||||
- `auth_set_apikey` 函数: 正确写入 API Key 到 `auth-profiles.json`
|
||||
- `register_and_set_model` 函数: 注册模型到 `agents.defaults.models` 并设为默认
|
||||
- `register_custom_provider` 函数: 为需要 `baseUrl` 的 OpenAI 兼容供应商注册 `models.providers`
|
||||
- 「检测升级」同时检查 OpenClaw 和**插件版本** (通过 GitHub API 获取最新 release)
|
||||
- 页面加载时自动静默检查更新,有新版本时「检测升级」按钮显示橙色小红点提醒
|
||||
- 状态面板显示当前安装的插件版本号
|
||||
- 构建/安装流程部署 `VERSION` 文件到 `/usr/share/openclaw/VERSION`
|
||||
- `openclaw-env setup` 安装环境时自动安装 Gemini CLI (Google OAuth 依赖)
|
||||
|
||||
### 改进
|
||||
- 使用指南顺序调整: ② 配置管理 → ③ Web 控制台 (首次使用更合理的引导顺序)
|
||||
- Gemini CLI 安装从配置向导选项 1 移至环境安装阶段,避免进入向导时临时等待
|
||||
|
||||
## [1.0.2] - 2026-03-02
|
||||
|
||||
### 修复
|
||||
- **P0** ARM64 musl: Gateway 崩溃循环 — `process.execPath` 返回 musl 链接器路径导致 `child_process.fork()` 失败
|
||||
- 使用 `patchelf` 直接修改 node ELF 二进制的 interpreter 和 rpath,替代 ld-musl wrapper 方案
|
||||
- 子进程通过 `process.execPath` fork 时可正确找到 node 二进制
|
||||
- **P0** ARM64 musl: Unicode property escapes 正则失败 (`\p{Emoji_Presentation}`) — 缺少 `NODE_ICU_DATA` 环境变量
|
||||
- init.d、openclaw-env、oc-config.sh 所有入口均添加 `NODE_ICU_DATA` 环境变量
|
||||
|
||||
### 改进
|
||||
- `build-node-musl.sh` 构建验证阶段新增 `process.execPath` 输出检查
|
||||
|
||||
## [1.0.1] - 2026-03-02
|
||||
|
||||
### 修复
|
||||
- **P0** web-pty.js `loadAuthToken` 读取错误的 UCI key `luci_token` → `pty_token`
|
||||
- **P0** init.d `get_oc_entry()` 管道子 shell 导致返回值丢失,改用临时文件重定向
|
||||
- **P1** Gateway procd respawn 无限重试 (`3600 5 0`) → 限制最多 5 次 (`3600 5 5`)
|
||||
- **P1** Telegram 配对流程管道子 shell 变量丢失,改用临时文件避免子 shell
|
||||
- **P1** `openclaw.lua` PID 提取 `sed` 正则不可靠,改用 `awk` + `split`
|
||||
- **P2** init.d 和 uci-defaults 弱 token fallback (`echo "auto_$(date +%s)"`) → `dd if=/dev/urandom`
|
||||
- **P2** `oc-config.sh` 恢复出厂 `timeout` 命令可能不存在,添加 `command -v` 检查和降级方案
|
||||
- **P2** web-pty.js SIGTERM 不清理 HTTPS server,统一 `shutdown()` 函数
|
||||
|
||||
### 新增
|
||||
- GitHub Copilot 配置新增 OAuth 授权登录方式 (通过 `copilot-proxy` 插件)
|
||||
- `uci-defaults` 首次安装时自动生成 `pty_token`
|
||||
- Web 控制台和状态面板显示当前活跃模型名称
|
||||
|
||||
### 改进
|
||||
- Qwen 使用 `models.dashscope` 键名、SiliconFlow 使用 `models.siliconflow`,避免 `models.custom` 键冲突
|
||||
- `get_openclaw_version()` 从 `package.json` 读取版本号,不再每次启动 Node.js 进程
|
||||
- PTY 终端 WebSocket 重连策略改为无限重连 (`MAX_RETRY=Infinity`)
|
||||
- Makefile `PKG_VERSION` 从 `VERSION` 文件动态读取
|
||||
|
||||
## [1.0.0] - 2026-03-02
|
||||
|
||||
### 新增
|
||||
- LuCI 管理界面:基本设置、配置管理(Web 终端)、Web 控制台
|
||||
- 一键安装 Node.js + OpenClaw 运行环境
|
||||
- 支持 x86_64 和 aarch64 架构,glibc / musl 自动检测
|
||||
- 支持 12+ AI 模型提供商配置向导
|
||||
- 支持 Telegram / Discord / 飞书 / Slack 消息渠道
|
||||
- `.run` 自解压包和 `.ipk` 安装包两种分发方式
|
||||
- OpenWrt SDK feeds 集成支持
|
||||
- GitHub Actions 自动构建与发布
|
||||
|
||||
### 安全
|
||||
- WebSocket PTY 服务添加 token 认证
|
||||
- WebSocket 最大并发会话限制(默认 5)
|
||||
- PTY 服务默认绑定 127.0.0.1,不对外暴露
|
||||
- Token 不再嵌入 HTML 源码,改为 AJAX 动态获取
|
||||
- sync_uci_to_json 通过环境变量传递 token,避免 ps 泄露
|
||||
- 所有渠道 Token 输入统一 sanitize_input 清洗
|
||||
|
||||
### 修复
|
||||
- Telegram Bot Token 粘贴时被 bracketed paste 转义序列污染
|
||||
- Web PTY 终端粘贴包含 ANSI 转义序列问题
|
||||
- 恢复出厂配置流程异常退出
|
||||
- Gemini CLI OAuth 登录在 OpenWrt 上失败
|
||||
- init.d status_service() 在无 netstat 的系统上报错
|
||||
- Makefile 损坏导致 OpenWrt SDK 编译失败
|
||||
|
||||
### 改进
|
||||
- 所有 AI 提供商模型列表更新到最新版本
|
||||
- UID/GID 动态分配,避免与已有系统用户冲突
|
||||
- 版本号统一由 VERSION 文件管理
|
||||
- README.md 完善安装说明、FAQ 和项目结构
|
||||
26
luci-app-openclaw/LICENSE
Normal file
26
luci-app-openclaw/LICENSE
Normal file
@ -0,0 +1,26 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (c) 2025-2026 10000ge10000
|
||||
Project: luci-app-openclaw — OpenClaw AI Gateway LuCI Plugin for OpenWrt
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
The full text of the GNU General Public License version 3 is available at:
|
||||
https://www.gnu.org/licenses/gpl-3.0.html
|
||||
SOFTWARE.
|
||||
|
||||
This project integrates with OpenClaw (https://github.com/nicepkg/openclaw),
|
||||
which is licensed under its own terms. Node.js runtime downloaded during setup
|
||||
is subject to the Node.js license (https://github.com/nodejs/node/blob/main/LICENSE).
|
||||
93
luci-app-openclaw/Makefile
Normal file
93
luci-app-openclaw/Makefile
Normal file
@ -0,0 +1,93 @@
|
||||
# luci-app-openclaw — OpenWrt package Makefile
|
||||
# 兼容两种集成方式:
|
||||
# 1. 作为 feeds 源: echo "src-git openclaw ..." >> feeds.conf.default
|
||||
# 2. 直接放入 package/ 目录: git clone ... package/luci-app-openclaw
|
||||
|
||||
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:=1
|
||||
|
||||
PKG_MAINTAINER:=10000ge10000 <10000ge10000@users.noreply.github.com>
|
||||
PKG_LICENSE:=GPL-3.0
|
||||
|
||||
LUCI_TITLE:=OpenClaw AI 网关 LuCI 管理插件
|
||||
LUCI_DEPENDS:=+luci-compat +luci-base +curl +openssl-util +script-utils +tar
|
||||
LUCI_PKGARCH:=all
|
||||
|
||||
# 优先使用 luci.mk (feeds 模式), 不可用时回退 package.mk
|
||||
ifeq ($(wildcard $(TOPDIR)/feeds/luci/luci.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)/description
|
||||
OpenClaw AI Gateway 的 LuCI 管理插件。
|
||||
支持 12+ AI 模型提供商和 Telegram/Discord 等多种消息渠道。
|
||||
endef
|
||||
|
||||
else
|
||||
|
||||
include $(TOPDIR)/feeds/luci/luci.mk
|
||||
|
||||
endif
|
||||
|
||||
define Package/$(PKG_NAME)/conffiles
|
||||
/etc/config/openclaw
|
||||
endef
|
||||
|
||||
define Package/$(PKG_NAME)/install
|
||||
$(INSTALL_DIR) $(1)/etc/config
|
||||
$(INSTALL_CONF) ./root/etc/config/openclaw $(1)/etc/config/openclaw
|
||||
$(INSTALL_DIR) $(1)/etc/uci-defaults
|
||||
$(INSTALL_BIN) ./root/etc/uci-defaults/99-openclaw $(1)/etc/uci-defaults/99-openclaw
|
||||
$(INSTALL_DIR) $(1)/etc/init.d
|
||||
$(INSTALL_BIN) ./root/etc/init.d/openclaw $(1)/etc/init.d/openclaw
|
||||
$(INSTALL_DIR) $(1)/usr/bin
|
||||
$(INSTALL_BIN) ./root/usr/bin/openclaw-env $(1)/usr/bin/openclaw-env
|
||||
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/controller
|
||||
$(INSTALL_DATA) ./luasrc/controller/openclaw.lua $(1)/usr/lib/lua/luci/controller/openclaw.lua
|
||||
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/model/cbi/openclaw
|
||||
$(INSTALL_DATA) ./luasrc/model/cbi/openclaw/basic.lua $(1)/usr/lib/lua/luci/model/cbi/openclaw/basic.lua
|
||||
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/view/openclaw
|
||||
$(INSTALL_DATA) ./luasrc/view/openclaw/status.htm $(1)/usr/lib/lua/luci/view/openclaw/status.htm
|
||||
$(INSTALL_DATA) ./luasrc/view/openclaw/advanced.htm $(1)/usr/lib/lua/luci/view/openclaw/advanced.htm
|
||||
$(INSTALL_DATA) ./luasrc/view/openclaw/console.htm $(1)/usr/lib/lua/luci/view/openclaw/console.htm
|
||||
$(INSTALL_DIR) $(1)/usr/share/openclaw
|
||||
$(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_DIR) $(1)/usr/share/openclaw/ui
|
||||
$(CP) ./root/usr/share/openclaw/ui/* $(1)/usr/share/openclaw/ui/
|
||||
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/i18n
|
||||
if [ -f ./po/zh-cn/openclaw.po ]; then \
|
||||
po2lmo ./po/zh-cn/openclaw.po $(1)/usr/lib/lua/luci/i18n/openclaw.zh-cn.lmo 2>/dev/null || true; \
|
||||
fi
|
||||
endef
|
||||
|
||||
define Package/$(PKG_NAME)/postinst
|
||||
#!/bin/sh
|
||||
[ -n "$${IPKG_INSTROOT}" ] || {
|
||||
( . /etc/uci-defaults/99-openclaw ) && rm -f /etc/uci-defaults/99-openclaw
|
||||
rm -f /tmp/luci-indexcache /tmp/luci-modulecache/* 2>/dev/null
|
||||
exit 0
|
||||
}
|
||||
endef
|
||||
|
||||
define Package/$(PKG_NAME)/postrm
|
||||
#!/bin/sh
|
||||
[ -n "$${IPKG_INSTROOT}" ] || {
|
||||
rm -f /tmp/luci-indexcache /tmp/luci-modulecache/* 2>/dev/null
|
||||
}
|
||||
endef
|
||||
|
||||
$(eval $(call BuildPackage,$(PKG_NAME)))
|
||||
137
luci-app-openclaw/README.md
Normal file
137
luci-app-openclaw/README.md
Normal file
@ -0,0 +1,137 @@
|
||||
# luci-app-openclaw
|
||||
|
||||
[](https://space.bilibili.com/59438380)
|
||||
[](https://blog.910501.xyz/)
|
||||
[](https://github.com/10000ge10000/luci-app-openclaw/actions/workflows/build.yml)
|
||||
[](LICENSE)
|
||||
|
||||
[OpenClaw](https://github.com/nicepkg/openclaw) AI 网关的 OpenWrt LuCI 管理插件。
|
||||
|
||||
在路由器上运行 OpenClaw,通过 LuCI 管理界面完成安装、配置和服务管理。
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/images/1.png" alt="OpenClaw LuCI 管理界面" width="800" style="border-radius:8px;" />
|
||||
</div>
|
||||
|
||||
**系统要求**
|
||||
|
||||
| 项目 | 要求 |
|
||||
|------|------|
|
||||
| 架构 | x86_64 或 aarch64 (ARM64) |
|
||||
| C 库 | musl(自动检测;离线包仅支持 musl) |
|
||||
| 依赖 | luci-compat, luci-base, curl, openssl-util |
|
||||
| 存储 | **1.5GB 以上可用空间** |
|
||||
| 内存 | 推荐 1GB 及以上 |
|
||||
|
||||
## 📦 安装
|
||||
|
||||
### 方式一:.run 自解压包(推荐)
|
||||
|
||||
无需 SDK,适用于已安装好的系统。
|
||||
|
||||
```bash
|
||||
wget https://github.com/10000ge10000/luci-app-openclaw/releases/latest/download/luci-app-openclaw.run
|
||||
sh luci-app-openclaw.run
|
||||
```
|
||||
|
||||
### 方式二:.ipk 安装
|
||||
|
||||
```bash
|
||||
wget https://github.com/10000ge10000/luci-app-openclaw/releases/latest/download/luci-app-openclaw.ipk
|
||||
opkg install luci-app-openclaw.ipk
|
||||
```
|
||||
|
||||
### 方式三:集成到固件编译
|
||||
|
||||
适用于自行编译固件或使用在线编译平台的用户。
|
||||
|
||||
```bash
|
||||
cd /path/to/openwrt
|
||||
|
||||
# 添加 feeds
|
||||
echo "src-git openclaw https://github.com/10000ge10000/luci-app-openclaw.git" >> feeds.conf.default
|
||||
|
||||
# 更新安装
|
||||
./scripts/feeds update -a
|
||||
./scripts/feeds install -a
|
||||
|
||||
# 选择插件
|
||||
make menuconfig
|
||||
# LuCI → Applications → luci-app-openclaw
|
||||
|
||||
# 编译
|
||||
make package/luci-app-openclaw/compile V=s
|
||||
```
|
||||
|
||||
使用 OpenWrt SDK 单独编译:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/10000ge10000/luci-app-openclaw.git package/luci-app-openclaw
|
||||
make defconfig
|
||||
make package/luci-app-openclaw/compile V=s
|
||||
find bin/ -name "luci-app-openclaw*.ipk"
|
||||
```
|
||||
|
||||
### 方式四:手动安装
|
||||
|
||||
```bash
|
||||
git clone https://github.com/10000ge10000/luci-app-openclaw.git
|
||||
cd luci-app-openclaw
|
||||
|
||||
cp -r root/* /
|
||||
mkdir -p /usr/lib/lua/luci/controller /usr/lib/lua/luci/model/cbi/openclaw /usr/lib/lua/luci/view/openclaw
|
||||
cp luasrc/controller/openclaw.lua /usr/lib/lua/luci/controller/
|
||||
cp luasrc/model/cbi/openclaw/*.lua /usr/lib/lua/luci/model/cbi/openclaw/
|
||||
cp luasrc/view/openclaw/*.htm /usr/lib/lua/luci/view/openclaw/
|
||||
|
||||
chmod +x /etc/init.d/openclaw /usr/bin/openclaw-env /usr/share/openclaw/oc-config.sh
|
||||
sh /etc/uci-defaults/99-openclaw
|
||||
rm -f /tmp/luci-indexcache /tmp/luci-modulecache/*
|
||||
```
|
||||
|
||||
|
||||
## 🔰 首次使用
|
||||
|
||||
1. 打开 LuCI → 服务 → OpenClaw,点击「安装运行环境」
|
||||
2. 安装完成后服务会自动启动,点击「刷新页面」查看状态
|
||||
3. 进入「Web 控制台」添加 AI 模型和 API Key
|
||||
4. 进入「配置管理」可使用向导配置消息渠道
|
||||
|
||||
## 📂 目录结构
|
||||
|
||||
```
|
||||
luci-app-openclaw/
|
||||
├── Makefile # OpenWrt 包定义
|
||||
├── luasrc/
|
||||
│ ├── controller/openclaw.lua # LuCI 路由和 API
|
||||
│ ├── model/cbi/openclaw/basic.lua # 主页面
|
||||
│ └── view/openclaw/
|
||||
│ ├── status.htm # 状态面板
|
||||
│ ├── advanced.htm # 配置管理(终端)
|
||||
│ └── console.htm # Web 控制台
|
||||
├── root/
|
||||
│ ├── etc/
|
||||
│ │ ├── config/openclaw # UCI 配置
|
||||
│ │ ├── init.d/openclaw # 服务脚本
|
||||
│ │ └── uci-defaults/99-openclaw # 初始化脚本
|
||||
│ └── usr/
|
||||
│ ├── bin/openclaw-env # 环境管理工具
|
||||
│ └── share/openclaw/ # 配置终端资源
|
||||
├── scripts/
|
||||
│ ├── build_ipk.sh # 本地 IPK 构建
|
||||
│ ├── build_run.sh # .run 安装包构建
|
||||
│ ├── download_deps.sh # 下载离线依赖 (Node.js + OpenClaw)
|
||||
│ ├── upload_openlist.sh # 上传到网盘 (OpenList)
|
||||
│ └── build-node-musl.sh # 编译 Node.js musl 静态链接版本
|
||||
└── .github/workflows/
|
||||
├── build.yml # 在线构建 + 发布
|
||||
└── build-node-musl.yml # Node.js musl 构建
|
||||
```
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
|
||||
## 📄 License
|
||||
|
||||
[GPL-3.0](LICENSE)
|
||||
1
luci-app-openclaw/VERSION
Normal file
1
luci-app-openclaw/VERSION
Normal file
@ -0,0 +1 @@
|
||||
1.0.15
|
||||
BIN
luci-app-openclaw/docs/images/1.png
Normal file
BIN
luci-app-openclaw/docs/images/1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 298 KiB |
801
luci-app-openclaw/luasrc/controller/openclaw.lua
Normal file
801
luci-app-openclaw/luasrc/controller/openclaw.lua
Normal file
@ -0,0 +1,801 @@
|
||||
-- luci-app-openclaw — LuCI Controller
|
||||
module("luci.controller.openclaw", package.seeall)
|
||||
|
||||
function index()
|
||||
-- 主入口: 服务 → OpenClaw (🧠 作为菜单图标)
|
||||
local page = entry({"admin", "services", "openclaw"}, alias("admin", "services", "openclaw", "basic"), _("OpenClaw"), 90)
|
||||
page.dependent = false
|
||||
|
||||
-- 基本设置 (CBI)
|
||||
entry({"admin", "services", "openclaw", "basic"}, cbi("openclaw/basic"), _("基本设置"), 10).leaf = true
|
||||
|
||||
-- 配置管理 (View — 嵌入 oc-config Web 终端)
|
||||
entry({"admin", "services", "openclaw", "advanced"}, template("openclaw/advanced"), _("配置管理"), 20).leaf = true
|
||||
|
||||
-- Web 控制台 (View — 嵌入 OpenClaw Web UI)
|
||||
entry({"admin", "services", "openclaw", "console"}, template("openclaw/console"), _("Web 控制台"), 30).leaf = true
|
||||
|
||||
-- 状态 API (AJAX 接口, 供前端 XHR 调用)
|
||||
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
|
||||
|
||||
-- 安装/升级日志 API (轮询)
|
||||
entry({"admin", "services", "openclaw", "setup_log"}, call("action_setup_log"), nil).leaf = true
|
||||
|
||||
-- 版本检查 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
|
||||
|
||||
-- 获取网关 Token API (仅认证用户可访问)
|
||||
entry({"admin", "services", "openclaw", "get_token"}, call("action_get_token"), nil).leaf = true
|
||||
|
||||
-- 插件升级 API
|
||||
entry({"admin", "services", "openclaw", "plugin_upgrade"}, call("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
|
||||
end
|
||||
|
||||
-- ═══════════════════════════════════════════
|
||||
-- 状态查询 API: 返回 JSON
|
||||
-- ═══════════════════════════════════════════
|
||||
function action_status()
|
||||
local http = require "luci.http"
|
||||
local sys = require "luci.sys"
|
||||
local uci = require "luci.model.uci".cursor()
|
||||
|
||||
local port = uci:get("openclaw", "main", "port") or "18789"
|
||||
local pty_port = uci:get("openclaw", "main", "pty_port") or "18793"
|
||||
local enabled = uci:get("openclaw", "main", "enabled") or "0"
|
||||
|
||||
-- 验证端口值为纯数字,防止命令注入
|
||||
if not port:match("^%d+$") then port = "18789" end
|
||||
if not pty_port:match("^%d+$") then pty_port = "18793" end
|
||||
|
||||
local result = {
|
||||
enabled = enabled,
|
||||
port = port,
|
||||
pty_port = pty_port,
|
||||
gateway_running = false,
|
||||
gateway_starting = false,
|
||||
pty_running = false,
|
||||
pid = "",
|
||||
memory_kb = 0,
|
||||
uptime = "",
|
||||
node_version = "",
|
||||
oc_version = "",
|
||||
plugin_version = "",
|
||||
}
|
||||
|
||||
-- 插件版本
|
||||
local pvf = io.open("/usr/share/openclaw/VERSION", "r")
|
||||
if pvf then
|
||||
result.plugin_version = pvf:read("*a"):gsub("%s+", "")
|
||||
pvf:close()
|
||||
end
|
||||
|
||||
-- 安装方式检测 (离线 / 在线)
|
||||
|
||||
-- 检查 Node.js
|
||||
local node_bin = "/opt/openclaw/node/bin/node"
|
||||
local f = io.open(node_bin, "r")
|
||||
if f then
|
||||
f:close()
|
||||
local node_ver = sys.exec(node_bin .. " --version 2>/dev/null"):gsub("%s+", "")
|
||||
result.node_version = node_ver
|
||||
end
|
||||
|
||||
-- OpenClaw 版本 (从 package.json 读取)
|
||||
local oc_dirs = {
|
||||
"/opt/openclaw/global/lib/node_modules/openclaw",
|
||||
"/opt/openclaw/global/node_modules/openclaw",
|
||||
"/opt/openclaw/node/lib/node_modules/openclaw",
|
||||
}
|
||||
for _, d in ipairs(oc_dirs) do
|
||||
local pf = io.open(d .. "/package.json", "r")
|
||||
if pf then
|
||||
local pj = pf:read("*a")
|
||||
pf:close()
|
||||
local ver = pj:match('"version"%s*:%s*"([^"]+)"')
|
||||
if ver and ver ~= "" then
|
||||
result.oc_version = ver
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- 网关端口检查
|
||||
local gw_check_cmd = "if command -v ss >/dev/null 2>&1; then ss -tulnp 2>/dev/null | grep -c ':" .. port .. " ' || echo 0; else netstat -tulnp 2>/dev/null | grep -c ':" .. port .. " ' || echo 0; fi"
|
||||
local gw_check = sys.exec(gw_check_cmd):gsub("%s+", "")
|
||||
result.gateway_running = (tonumber(gw_check) or 0) > 0
|
||||
|
||||
-- 如果端口未监听但 procd 进程存在,说明正在启动中 (gateway 初始化需要数分钟)
|
||||
if not result.gateway_running and enabled == "1" then
|
||||
local procd_pid = sys.exec("pgrep -f 'openclaw.*gateway' 2>/dev/null | head -1"):gsub("%s+", "")
|
||||
if procd_pid ~= "" then
|
||||
result.gateway_starting = true
|
||||
end
|
||||
end
|
||||
|
||||
-- PTY 端口检查
|
||||
local pty_check = sys.exec("netstat -tulnp 2>/dev/null | grep -c ':" .. pty_port .. " ' || echo 0"):gsub("%s+", "")
|
||||
result.pty_running = (tonumber(pty_check) or 0) > 0
|
||||
|
||||
-- 读取当前活跃模型
|
||||
local config_file = "/opt/openclaw/data/.openclaw/openclaw.json"
|
||||
local cf = io.open(config_file, "r")
|
||||
if cf then
|
||||
local content = cf:read("*a")
|
||||
cf:close()
|
||||
-- 简单正则提取 "primary": "xxx"
|
||||
local model = content:match('"primary"%s*:%s*"([^"]+)"')
|
||||
if model and model ~= "" then
|
||||
result.active_model = model
|
||||
end
|
||||
|
||||
-- 读取已配置的渠道列表
|
||||
local channels = {}
|
||||
if content:match('"qqbot"%s*:%s*{') and content:match('"appId"%s*:%s*"[^"]+"') then
|
||||
channels[#channels+1] = "QQ"
|
||||
end
|
||||
if content:match('"telegram"%s*:%s*{') and content:match('"botToken"%s*:%s*"[^"]+"') then
|
||||
channels[#channels+1] = "Telegram"
|
||||
end
|
||||
if content:match('"discord"%s*:%s*{') then
|
||||
channels[#channels+1] = "Discord"
|
||||
end
|
||||
if content:match('"feishu"%s*:%s*{') then
|
||||
channels[#channels+1] = "飞书"
|
||||
end
|
||||
if content:match('"slack"%s*:%s*{') then
|
||||
channels[#channels+1] = "Slack"
|
||||
end
|
||||
if #channels > 0 then
|
||||
result.channels = table.concat(channels, ", ")
|
||||
end
|
||||
end
|
||||
|
||||
-- PID 和内存
|
||||
if result.gateway_running then
|
||||
local pid = sys.exec("netstat -tulnp 2>/dev/null | awk '/:" .. port .. " /{split($NF,a,\"/\");print a[1];exit}'"):gsub("%s+", "")
|
||||
if pid and pid ~= "" then
|
||||
result.pid = pid
|
||||
-- 内存 (VmRSS from /proc)
|
||||
local rss = sys.exec("awk '/VmRSS/{print $2}' /proc/" .. pid .. "/status 2>/dev/null"):gsub("%s+", "")
|
||||
result.memory_kb = tonumber(rss) or 0
|
||||
-- 运行时间
|
||||
local stat_time = sys.exec("stat -c %Y /proc/" .. pid .. " 2>/dev/null"):gsub("%s+", "")
|
||||
local start_ts = tonumber(stat_time) or 0
|
||||
if start_ts > 0 then
|
||||
local uptime_s = os.time() - start_ts
|
||||
local hours = math.floor(uptime_s / 3600)
|
||||
local mins = math.floor((uptime_s % 3600) / 60)
|
||||
local secs = uptime_s % 60
|
||||
if hours > 0 then
|
||||
result.uptime = string.format("%dh %dm %ds", hours, mins, secs)
|
||||
elseif mins > 0 then
|
||||
result.uptime = string.format("%dm %ds", mins, secs)
|
||||
else
|
||||
result.uptime = string.format("%ds", secs)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
http.prepare_content("application/json")
|
||||
http.write_json(result)
|
||||
end
|
||||
|
||||
-- ═══════════════════════════════════════════
|
||||
-- 服务控制 API: start/stop/restart/setup
|
||||
-- ═══════════════════════════════════════════
|
||||
function action_service_ctl()
|
||||
local http = require "luci.http"
|
||||
local sys = require "luci.sys"
|
||||
|
||||
local action = http.formvalue("action") or ""
|
||||
|
||||
if action == "start" then
|
||||
sys.exec("/etc/init.d/openclaw start >/dev/null 2>&1 &")
|
||||
elseif action == "stop" then
|
||||
sys.exec("/etc/init.d/openclaw stop >/dev/null 2>&1")
|
||||
-- stop 后额外等待确保端口释放
|
||||
sys.exec("sleep 2")
|
||||
elseif action == "restart" then
|
||||
-- 先完整 stop (确保端口释放),再后台 start
|
||||
sys.exec("/etc/init.d/openclaw stop >/dev/null 2>&1")
|
||||
sys.exec("sleep 2")
|
||||
sys.exec("/etc/init.d/openclaw start >/dev/null 2>&1 &")
|
||||
elseif action == "enable" then
|
||||
sys.exec("/etc/init.d/openclaw enable 2>/dev/null")
|
||||
elseif action == "disable" then
|
||||
sys.exec("/etc/init.d/openclaw disable 2>/dev/null")
|
||||
elseif action == "setup" then
|
||||
-- 先清理旧日志和状态
|
||||
sys.exec("rm -f /tmp/openclaw-setup.log /tmp/openclaw-setup.pid /tmp/openclaw-setup.exit")
|
||||
-- 获取用户选择的版本 (stable=指定版本, latest=最新版)
|
||||
local version = http.formvalue("version") or ""
|
||||
local env_prefix = ""
|
||||
if version == "stable" then
|
||||
-- 稳定版: 读取 openclaw-env 中定义的 OC_TESTED_VERSION
|
||||
local tested_ver = sys.exec("grep '^OC_TESTED_VERSION=' /usr/bin/openclaw-env 2>/dev/null | cut -d'\"' -f2"):gsub("%s+", "")
|
||||
if tested_ver ~= "" then
|
||||
env_prefix = "OC_VERSION=" .. tested_ver .. " "
|
||||
end
|
||||
elseif version ~= "" and version ~= "latest" then
|
||||
-- 校验版本号格式 (仅允许数字、点、横线、字母)
|
||||
if version:match("^[%d%.%-a-zA-Z]+$") then
|
||||
env_prefix = "OC_VERSION=" .. version .. " "
|
||||
end
|
||||
end
|
||||
-- 后台安装,成功后自动启用并启动服务
|
||||
-- 注: openclaw-env 脚本有 set -e,init_openclaw 中的非关键失败不应阻止启动
|
||||
sys.exec("( " .. env_prefix .. "/usr/bin/openclaw-env setup > /tmp/openclaw-setup.log 2>&1; RC=$?; echo $RC > /tmp/openclaw-setup.exit; if [ $RC -eq 0 ]; then uci set openclaw.main.enabled=1; uci commit openclaw; /etc/init.d/openclaw enable 2>/dev/null; sleep 1; /etc/init.d/openclaw start >> /tmp/openclaw-setup.log 2>&1; fi ) & echo $! > /tmp/openclaw-setup.pid")
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({ status = "ok", message = "安装已启动,请查看安装日志..." })
|
||||
return
|
||||
else
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({ status = "error", message = "未知操作: " .. action })
|
||||
return
|
||||
end
|
||||
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({ status = "ok", action = action })
|
||||
end
|
||||
|
||||
-- ═══════════════════════════════════════════
|
||||
-- 安装日志轮询 API
|
||||
-- ═══════════════════════════════════════════
|
||||
function action_setup_log()
|
||||
local http = require "luci.http"
|
||||
local sys = require "luci.sys"
|
||||
|
||||
-- 读取日志内容
|
||||
local log = ""
|
||||
local f = io.open("/tmp/openclaw-setup.log", "r")
|
||||
if f then
|
||||
log = f:read("*a") or ""
|
||||
f:close()
|
||||
end
|
||||
|
||||
-- 检查进程是否还在运行
|
||||
local running = false
|
||||
local pid_file = io.open("/tmp/openclaw-setup.pid", "r")
|
||||
if pid_file then
|
||||
local pid = pid_file:read("*a"):gsub("%s+", "")
|
||||
pid_file:close()
|
||||
if pid ~= "" then
|
||||
local check = sys.exec("kill -0 " .. pid .. " 2>/dev/null && echo yes || echo no"):gsub("%s+", "")
|
||||
running = (check == "yes")
|
||||
end
|
||||
end
|
||||
|
||||
-- 读取退出码
|
||||
local exit_code = -1
|
||||
if not running then
|
||||
local exit_file = io.open("/tmp/openclaw-setup.exit", "r")
|
||||
if exit_file then
|
||||
local code = exit_file:read("*a"):gsub("%s+", "")
|
||||
exit_file:close()
|
||||
exit_code = tonumber(code) or -1
|
||||
end
|
||||
end
|
||||
|
||||
-- 判断状态
|
||||
local state = "idle"
|
||||
if running then
|
||||
state = "running"
|
||||
elseif exit_code == 0 then
|
||||
state = "success"
|
||||
elseif exit_code > 0 then
|
||||
state = "failed"
|
||||
end
|
||||
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({
|
||||
state = state,
|
||||
exit_code = exit_code,
|
||||
log = log
|
||||
})
|
||||
end
|
||||
|
||||
-- ═══════════════════════════════════════════
|
||||
-- 版本检查 API
|
||||
-- ═══════════════════════════════════════════
|
||||
function action_check_update()
|
||||
local http = require "luci.http"
|
||||
local sys = require "luci.sys"
|
||||
|
||||
-- 插件版本检查 (从 GitHub API 获取最新 release tag + release notes)
|
||||
local plugin_current = ""
|
||||
local pf = io.open("/usr/share/openclaw/VERSION", "r")
|
||||
or io.open("/root/luci-app-openclaw/VERSION", "r")
|
||||
if pf then
|
||||
plugin_current = pf:read("*a"):gsub("%s+", "")
|
||||
pf:close()
|
||||
end
|
||||
|
||||
local plugin_latest = ""
|
||||
local release_notes = ""
|
||||
local plugin_has_update = false
|
||||
|
||||
-- 使用 GitHub API 获取最新 release (tag + body)
|
||||
local gh_json = sys.exec("curl -sf --connect-timeout 5 --max-time 10 'https://api.github.com/repos/10000ge10000/luci-app-openclaw/releases/latest' 2>/dev/null")
|
||||
if gh_json and gh_json ~= "" then
|
||||
-- 提取 tag_name
|
||||
local tag = gh_json:match('"tag_name"%s*:%s*"([^"]+)"')
|
||||
if tag and tag ~= "" then
|
||||
plugin_latest = tag:gsub("^v", ""):gsub("%s+", "")
|
||||
end
|
||||
-- 提取 body (release notes), 处理 JSON 转义
|
||||
-- 结束引号后可能紧跟 \n、空格、, 或 },用宽松匹配
|
||||
local body = gh_json:match('"body"%s*:%s*"(.-)"[,}%]\n ]')
|
||||
if body and body ~= "" then
|
||||
-- 还原 JSON 转义: \n \r \" \\
|
||||
body = body:gsub("\\n", "\n"):gsub("\\r", ""):gsub('\\"', '"'):gsub("\\\\", "\\")
|
||||
release_notes = body
|
||||
end
|
||||
end
|
||||
|
||||
if plugin_current ~= "" and plugin_latest ~= "" and plugin_current ~= plugin_latest then
|
||||
plugin_has_update = true
|
||||
end
|
||||
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({
|
||||
status = "ok",
|
||||
plugin_current = plugin_current,
|
||||
plugin_latest = plugin_latest,
|
||||
plugin_has_update = plugin_has_update,
|
||||
release_notes = release_notes
|
||||
})
|
||||
end
|
||||
|
||||
-- ═══════════════════════════════════════════
|
||||
-- 卸载运行环境 API
|
||||
-- ═══════════════════════════════════════════
|
||||
function action_uninstall()
|
||||
local http = require "luci.http"
|
||||
local sys = require "luci.sys"
|
||||
|
||||
-- 停止服务
|
||||
sys.exec("/etc/init.d/openclaw stop >/dev/null 2>&1")
|
||||
-- 禁用开机启动
|
||||
sys.exec("/etc/init.d/openclaw disable 2>/dev/null")
|
||||
-- 设置 UCI enabled=0
|
||||
sys.exec("uci set openclaw.main.enabled=0; uci commit openclaw 2>/dev/null")
|
||||
-- 删除 Node.js + OpenClaw 运行环境
|
||||
sys.exec("rm -rf /opt/openclaw")
|
||||
-- 清理临时文件
|
||||
sys.exec("rm -f /tmp/openclaw-setup.* /tmp/openclaw-update.log /var/run/openclaw*.pid")
|
||||
-- 删除 openclaw 系统用户
|
||||
sys.exec("sed -i '/^openclaw:/d' /etc/passwd /etc/shadow /etc/group 2>/dev/null")
|
||||
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({
|
||||
status = "ok",
|
||||
message = "运行环境已卸载。Node.js、OpenClaw 及相关数据已清理。"
|
||||
})
|
||||
end
|
||||
|
||||
-- ═══════════════════════════════════════════
|
||||
-- 获取 Token API
|
||||
-- 仅通过 LuCI 认证后可调用,避免 Token 嵌入 HTML 源码
|
||||
-- 返回网关 Token 和 PTY Token
|
||||
-- ═══════════════════════════════════════════
|
||||
function action_get_token()
|
||||
local http = require "luci.http"
|
||||
local uci = require "luci.model.uci".cursor()
|
||||
local token = uci:get("openclaw", "main", "token") or ""
|
||||
local pty_token = uci:get("openclaw", "main", "pty_token") or ""
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({ token = token, pty_token = pty_token })
|
||||
end
|
||||
|
||||
-- ═══════════════════════════════════════════
|
||||
-- 插件升级 API (后台下载 .run 并执行)
|
||||
-- 参数: version — 目标版本号 (如 1.0.8)
|
||||
-- ═══════════════════════════════════════════
|
||||
function action_plugin_upgrade()
|
||||
local http = require "luci.http"
|
||||
local sys = require "luci.sys"
|
||||
|
||||
local version = http.formvalue("version") or ""
|
||||
if version == "" then
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({ status = "error", message = "缺少版本号参数" })
|
||||
return
|
||||
end
|
||||
|
||||
-- 安全检查: version 只允许数字和点
|
||||
if not version:match("^[%d%.]+$") then
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({ status = "error", message = "版本号格式无效" })
|
||||
return
|
||||
end
|
||||
|
||||
-- 清理旧日志和状态
|
||||
sys.exec("rm -f /tmp/openclaw-plugin-upgrade.log /tmp/openclaw-plugin-upgrade.pid /tmp/openclaw-plugin-upgrade.exit")
|
||||
|
||||
-- 后台执行: 下载 .run 并执行安装
|
||||
local run_url = "https://github.com/10000ge10000/luci-app-openclaw/releases/download/v" .. version .. "/luci-app-openclaw_" .. version .. ".run"
|
||||
-- 使用 curl 下载 (-L 跟随重定向), 然后 sh 执行
|
||||
sys.exec(string.format(
|
||||
"( echo '正在下载插件 v%s ...' > /tmp/openclaw-plugin-upgrade.log; " ..
|
||||
"curl -sL --connect-timeout 15 --max-time 120 -o /tmp/luci-app-openclaw-update.run '%s' >> /tmp/openclaw-plugin-upgrade.log 2>&1; " ..
|
||||
"RC=$?; " ..
|
||||
"if [ $RC -ne 0 ]; then " ..
|
||||
" echo '下载失败 (curl exit: '$RC')' >> /tmp/openclaw-plugin-upgrade.log; " ..
|
||||
" echo '如果无法访问 GitHub,请手动下载: %s' >> /tmp/openclaw-plugin-upgrade.log; " ..
|
||||
" echo $RC > /tmp/openclaw-plugin-upgrade.exit; " ..
|
||||
"else " ..
|
||||
" FSIZE=$(wc -c < /tmp/luci-app-openclaw-update.run 2>/dev/null | tr -d ' '); " ..
|
||||
" echo \"下载完成 (${FSIZE} bytes)\" >> /tmp/openclaw-plugin-upgrade.log; " ..
|
||||
" FHEAD=$(head -c 9 /tmp/luci-app-openclaw-update.run 2>/dev/null); " ..
|
||||
" if [ \"$FSIZE\" -lt 10000 ] 2>/dev/null; then " ..
|
||||
" if [ \"$FHEAD\" = 'Not Found' ]; then " ..
|
||||
" echo '❌ GitHub 返回 \"Not Found\",可能是网络被拦截(GFW)或 Release 资产不存在' >> /tmp/openclaw-plugin-upgrade.log; " ..
|
||||
" else " ..
|
||||
" echo '❌ 文件过小,可能 GitHub 访问受限或网络异常' >> /tmp/openclaw-plugin-upgrade.log; " ..
|
||||
" fi; " ..
|
||||
" echo '请检查路由器是否能访问 github.com,或手动下载后安装: %s' >> /tmp/openclaw-plugin-upgrade.log; " ..
|
||||
" echo 1 > /tmp/openclaw-plugin-upgrade.exit; " ..
|
||||
" else " ..
|
||||
" echo '' >> /tmp/openclaw-plugin-upgrade.log; " ..
|
||||
" echo '正在安装...' >> /tmp/openclaw-plugin-upgrade.log; " ..
|
||||
" sh /tmp/luci-app-openclaw-update.run >> /tmp/openclaw-plugin-upgrade.log 2>&1; " ..
|
||||
" RC2=$?; echo $RC2 > /tmp/openclaw-plugin-upgrade.exit; " ..
|
||||
" if [ $RC2 -eq 0 ]; then " ..
|
||||
" echo '' >> /tmp/openclaw-plugin-upgrade.log; " ..
|
||||
" echo '✅ 插件升级完成!请刷新浏览器页面。' >> /tmp/openclaw-plugin-upgrade.log; " ..
|
||||
" else " ..
|
||||
" echo '安装执行失败 (exit: '$RC2')' >> /tmp/openclaw-plugin-upgrade.log; " ..
|
||||
" fi; " ..
|
||||
" fi; " ..
|
||||
" rm -f /tmp/luci-app-openclaw-update.run; " ..
|
||||
"fi " ..
|
||||
") & echo $! > /tmp/openclaw-plugin-upgrade.pid",
|
||||
version, run_url, run_url, run_url
|
||||
))
|
||||
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({
|
||||
status = "ok",
|
||||
message = "插件升级已在后台启动..."
|
||||
})
|
||||
end
|
||||
|
||||
-- ═══════════════════════════════════════════
|
||||
-- 插件升级日志轮询 API
|
||||
-- ═══════════════════════════════════════════
|
||||
function action_plugin_upgrade_log()
|
||||
local http = require "luci.http"
|
||||
local sys = require "luci.sys"
|
||||
|
||||
local log = ""
|
||||
local f = io.open("/tmp/openclaw-plugin-upgrade.log", "r")
|
||||
if f then
|
||||
log = f:read("*a") or ""
|
||||
f:close()
|
||||
end
|
||||
|
||||
local running = false
|
||||
local pid_file = io.open("/tmp/openclaw-plugin-upgrade.pid", "r")
|
||||
if pid_file then
|
||||
local pid = pid_file:read("*a"):gsub("%s+", "")
|
||||
pid_file:close()
|
||||
if pid ~= "" then
|
||||
local check = sys.exec("kill -0 " .. pid .. " 2>/dev/null && echo yes || echo no"):gsub("%s+", "")
|
||||
running = (check == "yes")
|
||||
end
|
||||
end
|
||||
|
||||
local exit_code = -1
|
||||
if not running then
|
||||
local exit_file = io.open("/tmp/openclaw-plugin-upgrade.exit", "r")
|
||||
if exit_file then
|
||||
local code = exit_file:read("*a"):gsub("%s+", "")
|
||||
exit_file:close()
|
||||
exit_code = tonumber(code) or -1
|
||||
end
|
||||
end
|
||||
|
||||
local state = "idle"
|
||||
if running then
|
||||
state = "running"
|
||||
elseif exit_code == 0 then
|
||||
state = "success"
|
||||
elseif exit_code > 0 then
|
||||
state = "failed"
|
||||
end
|
||||
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({
|
||||
status = "ok",
|
||||
log = log,
|
||||
state = state,
|
||||
running = running,
|
||||
exit_code = exit_code
|
||||
})
|
||||
end
|
||||
|
||||
-- ═══════════════════════════════════════════
|
||||
-- 配置备份 API (v2026.3.8+)
|
||||
-- action=create: 创建配置备份
|
||||
-- action=verify: 验证最新备份
|
||||
-- action=list: 列出现有备份(含类型/大小)
|
||||
-- action=delete: 删除指定备份文件
|
||||
-- ═══════════════════════════════════════════
|
||||
function action_backup()
|
||||
local http = require "luci.http"
|
||||
local sys = require "luci.sys"
|
||||
local action = http.formvalue("action") or "create"
|
||||
|
||||
local node_bin = "/opt/openclaw/node/bin/node"
|
||||
local oc_entry = ""
|
||||
|
||||
-- 查找 openclaw 入口
|
||||
local search_dirs = {
|
||||
"/opt/openclaw/global/lib/node_modules/openclaw",
|
||||
"/opt/openclaw/global/node_modules/openclaw",
|
||||
"/opt/openclaw/node/lib/node_modules/openclaw",
|
||||
}
|
||||
for _, d in ipairs(search_dirs) do
|
||||
if nixio.fs.stat(d .. "/openclaw.mjs", "type") then
|
||||
oc_entry = d .. "/openclaw.mjs"
|
||||
break
|
||||
elseif nixio.fs.stat(d .. "/dist/cli.js", "type") then
|
||||
oc_entry = d .. "/dist/cli.js"
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if oc_entry == "" then
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({ status = "error", message = "OpenClaw 未安装,无法执行备份操作" })
|
||||
return
|
||||
end
|
||||
|
||||
local env_prefix = string.format(
|
||||
"HOME=/opt/openclaw/data OPENCLAW_HOME=/opt/openclaw/data " ..
|
||||
"OPENCLAW_STATE_DIR=/opt/openclaw/data/.openclaw " ..
|
||||
"OPENCLAW_CONFIG_PATH=/opt/openclaw/data/.openclaw/openclaw.json " ..
|
||||
"PATH=/opt/openclaw/node/bin:/opt/openclaw/global/bin:$PATH "
|
||||
)
|
||||
|
||||
-- 备份目录 (openclaw backup create 输出到 CWD,需要 cd)
|
||||
local backup_dir = "/opt/openclaw/data/.openclaw/backups"
|
||||
local cd_prefix = "mkdir -p " .. backup_dir .. " && cd " .. backup_dir .. " && "
|
||||
|
||||
-- ── 辅助: 解析单个备份文件的 manifest 信息 ──
|
||||
local function parse_backup_info(filepath)
|
||||
local filename = filepath:match("([^/]+)$") or filepath
|
||||
-- 文件大小
|
||||
local st = nixio.fs.stat(filepath)
|
||||
local size = st and st.size or 0
|
||||
-- 从文件名提取时间戳: 2026-03-11T18-28-43.149Z-openclaw-backup.tar.gz
|
||||
local ts = filename:match("^(%d%d%d%d%-%d%d%-%d%dT%d%d%-%d%d%-%d%d%.%d+Z)")
|
||||
local display_time = ""
|
||||
if ts then
|
||||
-- 2026-03-11T18-28-43.149Z -> 2026-03-11 18:28:43
|
||||
display_time = ts:gsub("T", " "):gsub("(%d%d)%-(%d%d)%-(%d%d)%.%d+Z", "%1:%2:%3")
|
||||
end
|
||||
-- 读取 manifest.json 判断备份类型
|
||||
local backup_type = "unknown"
|
||||
local manifest_json = sys.exec(
|
||||
"tar --wildcards -xzf " .. filepath .. " '*/manifest.json' -O 2>/dev/null"
|
||||
)
|
||||
if manifest_json and manifest_json ~= "" then
|
||||
-- 简单字符串匹配,避免依赖 JSON 库
|
||||
if manifest_json:match('"onlyConfig"%s*:%s*true') then
|
||||
backup_type = "config"
|
||||
elseif manifest_json:match('"onlyConfig"%s*:%s*false') then
|
||||
backup_type = "full"
|
||||
end
|
||||
else
|
||||
-- 无法读取 manifest,通过文件大小推断
|
||||
if size < 50000 then
|
||||
backup_type = "config"
|
||||
else
|
||||
backup_type = "full"
|
||||
end
|
||||
end
|
||||
-- 格式化大小
|
||||
local size_str
|
||||
if size >= 1073741824 then
|
||||
size_str = string.format("%.1f GB", size / 1073741824)
|
||||
elseif size >= 1048576 then
|
||||
size_str = string.format("%.1f MB", size / 1048576)
|
||||
elseif size >= 1024 then
|
||||
size_str = string.format("%.1f KB", size / 1024)
|
||||
else
|
||||
size_str = tostring(size) .. " B"
|
||||
end
|
||||
return {
|
||||
filename = filename,
|
||||
filepath = filepath,
|
||||
size = size,
|
||||
size_str = size_str,
|
||||
time = display_time,
|
||||
backup_type = backup_type
|
||||
}
|
||||
end
|
||||
|
||||
if action == "create" then
|
||||
local only_config = http.formvalue("only_config") or "1"
|
||||
local backup_cmd
|
||||
if only_config == "1" then
|
||||
backup_cmd = cd_prefix .. env_prefix .. node_bin .. " " .. oc_entry .. " backup create --only-config --no-include-workspace 2>&1"
|
||||
else
|
||||
backup_cmd = cd_prefix .. "HOME=" .. backup_dir .. " " .. env_prefix .. node_bin .. " " .. oc_entry .. " backup create --no-include-workspace 2>&1"
|
||||
end
|
||||
local output = sys.exec(backup_cmd)
|
||||
-- 完整备份可能输出到 HOME,移动到 backup_dir
|
||||
sys.exec("mv /opt/openclaw/data/*-openclaw-backup.tar.gz " .. backup_dir .. "/ 2>/dev/null")
|
||||
-- 提取备份文件路径
|
||||
local backup_path = output:match("([%S]+%.tar%.gz)")
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({
|
||||
status = "ok",
|
||||
action = "create",
|
||||
output = output,
|
||||
backup_path = backup_path or ""
|
||||
})
|
||||
elseif action == "verify" then
|
||||
-- 找到最新的备份文件
|
||||
local latest = sys.exec("ls -t " .. backup_dir .. "/*-openclaw-backup.tar.gz 2>/dev/null | head -1"):gsub("%s+", "")
|
||||
if latest == "" then
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({ status = "error", message = "未找到备份文件,请先创建备份" })
|
||||
return
|
||||
end
|
||||
local output = sys.exec(env_prefix .. node_bin .. " " .. oc_entry .. " backup verify " .. latest .. " 2>&1")
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({
|
||||
status = "ok",
|
||||
action = "verify",
|
||||
output = output,
|
||||
backup_path = latest
|
||||
})
|
||||
elseif action == "restore" then
|
||||
-- 支持指定文件名,不指定则用最新
|
||||
local target_file = http.formvalue("file") or ""
|
||||
local restore_path = ""
|
||||
if target_file ~= "" then
|
||||
-- 安全: 只允许文件名,不允许路径穿越
|
||||
target_file = target_file:match("([^/]+)$") or ""
|
||||
if target_file:match("%-openclaw%-backup%.tar%.gz$") then
|
||||
restore_path = backup_dir .. "/" .. target_file
|
||||
end
|
||||
end
|
||||
if restore_path == "" or not nixio.fs.stat(restore_path, "type") then
|
||||
-- fallback 到最新
|
||||
restore_path = sys.exec("ls -t " .. backup_dir .. "/*-openclaw-backup.tar.gz 2>/dev/null | head -1"):gsub("%s+", "")
|
||||
end
|
||||
if restore_path == "" then
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({ status = "error", message = "未找到备份文件,请先创建备份" })
|
||||
return
|
||||
end
|
||||
local oc_data_dir = "/opt/openclaw/data/.openclaw"
|
||||
local config_path = oc_data_dir .. "/openclaw.json"
|
||||
|
||||
-- 1) 先验证备份中的 openclaw.json 是否有效
|
||||
local check_cmd = "tar -xzf " .. restore_path .. " --wildcards '*/openclaw.json' -O 2>/dev/null"
|
||||
local json_content = sys.exec(check_cmd)
|
||||
if not json_content or json_content == "" then
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({ status = "error", message = "备份文件中未找到 openclaw.json" })
|
||||
return
|
||||
end
|
||||
-- 写入临时文件并用 node 验证
|
||||
local tmpfile = "/tmp/oc-restore-check.json"
|
||||
local f = io.open(tmpfile, "w")
|
||||
if f then f:write(json_content); f:close() end
|
||||
local check = sys.exec(node_bin .. " -e \"try{JSON.parse(require('fs').readFileSync('" .. tmpfile .. "','utf8'));console.log('OK')}catch(e){console.log('FAIL')}\" 2>/dev/null"):gsub("%s+", "")
|
||||
os.remove(tmpfile)
|
||||
if check ~= "OK" then
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({ status = "error", message = "备份文件中的配置无效,恢复已取消" })
|
||||
return
|
||||
end
|
||||
|
||||
-- 2) 备份当前配置
|
||||
sys.exec("cp -f " .. config_path .. " " .. config_path .. ".pre-restore 2>/dev/null")
|
||||
|
||||
-- 3) 获取备份名前缀 (如: 2026-03-11T18-21-17.209Z-openclaw-backup)
|
||||
-- 备份结构: <backup_name>/payload/posix/<绝对路径>
|
||||
local first_entry = sys.exec("tar -tzf " .. restore_path .. " 2>/dev/null | head -1"):gsub("%s+", "")
|
||||
local backup_name = first_entry:match("^([^/]+)/") or ""
|
||||
if backup_name == "" then
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({ status = "error", message = "备份文件格式无法识别" })
|
||||
return
|
||||
end
|
||||
local payload_prefix = backup_name .. "/payload/posix/"
|
||||
-- strip 3 层: <backup_name> / payload / posix
|
||||
local strip_count = 3
|
||||
|
||||
-- 4) 停止服务
|
||||
sys.exec("/etc/init.d/openclaw stop >/dev/null 2>&1")
|
||||
-- 等待端口释放
|
||||
sys.exec("sleep 2")
|
||||
|
||||
-- 5) 提取 payload 文件到根目录 (还原到原始绝对路径)
|
||||
-- 注: --wildcards 与 --strip-components 组合在某些 tar 版本不兼容
|
||||
-- 使用精确路径前缀代替 wildcards
|
||||
local extract_cmd = string.format(
|
||||
"tar -xzf %s --strip-components=%d -C / '%s' 2>&1",
|
||||
restore_path, strip_count, payload_prefix
|
||||
)
|
||||
local extract_out = sys.exec(extract_cmd)
|
||||
|
||||
-- 6) 修复权限
|
||||
sys.exec("chown -R openclaw:openclaw " .. oc_data_dir .. " 2>/dev/null")
|
||||
|
||||
-- 7) 重启服务
|
||||
sys.exec("/etc/init.d/openclaw start >/dev/null 2>&1 &")
|
||||
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({
|
||||
status = "ok",
|
||||
action = "restore",
|
||||
message = "已从备份完整恢复所有配置和数据,服务正在重启。原配置已保存为 openclaw.json.pre-restore",
|
||||
backup_path = restore_path,
|
||||
extract_output = extract_out or ""
|
||||
})
|
||||
elseif action == "list" then
|
||||
-- 返回结构化的备份文件列表(含类型/大小/时间)
|
||||
local files_raw = sys.exec("ls -t " .. backup_dir .. "/*-openclaw-backup.tar.gz 2>/dev/null"):gsub("%s+$", "")
|
||||
local backups = {}
|
||||
if files_raw ~= "" then
|
||||
for fpath in files_raw:gmatch("[^\n]+") do
|
||||
fpath = fpath:gsub("%s+", "")
|
||||
if fpath ~= "" then
|
||||
backups[#backups + 1] = parse_backup_info(fpath)
|
||||
end
|
||||
-- 最多返回 20 条
|
||||
if #backups >= 20 then break end
|
||||
end
|
||||
end
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({
|
||||
status = "ok",
|
||||
action = "list",
|
||||
backups = backups
|
||||
})
|
||||
elseif action == "delete" then
|
||||
local target_file = http.formvalue("file") or ""
|
||||
-- 安全: 只允许文件名,不允许路径穿越
|
||||
target_file = target_file:match("([^/]+)$") or ""
|
||||
if target_file == "" or not target_file:match("%-openclaw%-backup%.tar%.gz$") then
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({ status = "error", message = "无效的备份文件名" })
|
||||
return
|
||||
end
|
||||
local del_path = backup_dir .. "/" .. target_file
|
||||
if not nixio.fs.stat(del_path, "type") then
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({ status = "error", message = "备份文件不存在" })
|
||||
return
|
||||
end
|
||||
os.remove(del_path)
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({
|
||||
status = "ok",
|
||||
action = "delete",
|
||||
message = "已删除备份: " .. target_file
|
||||
})
|
||||
else
|
||||
http.prepare_content("application/json")
|
||||
http.write_json({ status = "error", message = "未知备份操作: " .. action })
|
||||
end
|
||||
end
|
||||
546
luci-app-openclaw/luasrc/model/cbi/openclaw/basic.lua
Normal file
546
luci-app-openclaw/luasrc/model/cbi/openclaw/basic.lua
Normal file
@ -0,0 +1,546 @@
|
||||
-- luci-app-openclaw — 基本设置 CBI Model
|
||||
local sys = require "luci.sys"
|
||||
|
||||
m = Map("openclaw", "OpenClaw AI 网关",
|
||||
"OpenClaw 是一个 AI 编程代理网关,支持 GitHub Copilot、Claude、GPT、Gemini 等大模型以及 QQ、Telegram、Discord 等多种消息渠道。")
|
||||
|
||||
-- 隐藏底部的「保存并应用」「保存」「复位」按钮 (本页无可编辑的 UCI 选项)
|
||||
m.pageaction = false
|
||||
|
||||
-- ═══════════════════════════════════════════
|
||||
-- 状态面板
|
||||
-- ═══════════════════════════════════════════
|
||||
m:section(SimpleSection).template = "openclaw/status"
|
||||
|
||||
-- ═══════════════════════════════════════════
|
||||
-- 快捷操作
|
||||
-- ═══════════════════════════════════════════
|
||||
s3 = m:section(SimpleSection, nil, "快捷操作")
|
||||
s3.template = "cbi/nullsection"
|
||||
|
||||
act = s3:option(DummyValue, "_actions")
|
||||
act.rawhtml = true
|
||||
act.cfgvalue = function(self, section)
|
||||
local ctl_url = luci.dispatcher.build_url("admin", "services", "openclaw", "service_ctl")
|
||||
local log_url = luci.dispatcher.build_url("admin", "services", "openclaw", "setup_log")
|
||||
local check_url = luci.dispatcher.build_url("admin", "services", "openclaw", "check_update")
|
||||
local uninstall_url = luci.dispatcher.build_url("admin", "services", "openclaw", "uninstall")
|
||||
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 html = {}
|
||||
|
||||
-- 按钮区域
|
||||
html[#html+1] = '<div style="display:flex;gap:10px;flex-wrap:wrap;margin:10px 0;">'
|
||||
html[#html+1] = '<button class="btn cbi-button cbi-button-apply" type="button" onclick="ocShowSetupDialog()" id="btn-setup" title="下载 Node.js 并安装 OpenClaw">📦 安装运行环境</button>'
|
||||
html[#html+1] = '<button class="btn cbi-button cbi-button-action" type="button" onclick="ocServiceCtl(\'restart\')">🔄 重启服务</button>'
|
||||
html[#html+1] = '<button class="btn cbi-button cbi-button-action" type="button" onclick="ocServiceCtl(\'stop\')">⏹️ 停止服务</button>'
|
||||
html[#html+1] = '<span style="position:relative;display:inline-block;" id="btn-check-update-wrap"><button class="btn cbi-button cbi-button-action" type="button" onclick="ocCheckUpdate()" id="btn-check-update">🔍 检测升级</button><span id="update-dot" style="display:none;position:absolute;top:-2px;right:-2px;width:10px;height:10px;background:#e36209;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 1px #e36209;"></span></span>'
|
||||
html[#html+1] = '<button class="btn cbi-button cbi-button-action" type="button" onclick="ocBackupRestore()" id="btn-backup" title="备份或恢复 OpenClaw 配置">💾 备份/恢复</button>'
|
||||
html[#html+1] = '<button class="btn cbi-button cbi-button-remove" type="button" onclick="ocUninstall()" id="btn-uninstall" title="删除 Node.js、OpenClaw 运行环境及相关数据">🗑️ 卸载环境</button>'
|
||||
html[#html+1] = '</div>'
|
||||
html[#html+1] = '<div id="action-result" style="margin-top:8px;"></div>'
|
||||
html[#html+1] = '<div id="oc-update-action" style="margin-top:8px;display:none;"></div>'
|
||||
|
||||
-- 版本选择对话框 (默认隐藏)
|
||||
html[#html+1] = '<div id="oc-setup-dialog" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:10000;align-items:center;justify-content:center;">'
|
||||
html[#html+1] = '<div style="background:#fff;border-radius:12px;padding:24px 28px;max-width:480px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,0.2);">'
|
||||
html[#html+1] = '<h3 style="margin:0 0 16px 0;font-size:16px;color:#333;">📦 选择安装版本</h3>'
|
||||
html[#html+1] = '<div style="display:flex;flex-direction:column;gap:12px;">'
|
||||
-- 稳定版选项
|
||||
html[#html+1] = '<label style="display:flex;align-items:flex-start;gap:10px;padding:14px 16px;border:2px solid #4a90d9;border-radius:8px;cursor:pointer;background:#f0f7ff;" id="oc-opt-stable">'
|
||||
html[#html+1] = '<input type="radio" name="oc-ver-choice" value="stable" checked style="margin-top:2px;">'
|
||||
html[#html+1] = '<div><strong style="color:#333;">✅ 稳定版 (推荐)</strong>'
|
||||
html[#html+1] = '<div style="font-size:12px;color:#666;margin-top:4px;">版本 v' .. luci.sys.exec("sed -n 's/^OC_TESTED_VERSION=\"\\(.*\\)\"/\\1/p' /usr/bin/openclaw-env 2>/dev/null"):gsub("%s+", "") .. ',已经过完整测试,兼容性良好。</div>'
|
||||
html[#html+1] = '</div></label>'
|
||||
-- 最新版选项
|
||||
html[#html+1] = '<label style="display:flex;align-items:flex-start;gap:10px;padding:14px 16px;border:2px solid #e0e0e0;border-radius:8px;cursor:pointer;background:#fff;" id="oc-opt-latest">'
|
||||
html[#html+1] = '<input type="radio" name="oc-ver-choice" value="latest" style="margin-top:2px;">'
|
||||
html[#html+1] = '<div><strong style="color:#333;">🆕 最新版</strong>'
|
||||
html[#html+1] = '<div style="font-size:12px;color:#e36209;margin-top:4px;">⚠️ 安装 npm 上的最新发布版本,可能存在未经验证的兼容性问题。</div>'
|
||||
html[#html+1] = '</div></label>'
|
||||
html[#html+1] = '</div>'
|
||||
-- 按钮区
|
||||
html[#html+1] = '<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:20px;">'
|
||||
html[#html+1] = '<button class="btn cbi-button" type="button" onclick="ocCloseSetupDialog()" style="min-width:80px;">取消</button>'
|
||||
html[#html+1] = '<button class="btn cbi-button cbi-button-apply" type="button" onclick="ocConfirmSetup()" style="min-width:80px;">开始安装</button>'
|
||||
html[#html+1] = '</div>'
|
||||
html[#html+1] = '</div></div>'
|
||||
|
||||
-- 安装日志面板 (默认隐藏)
|
||||
html[#html+1] = '<div id="setup-log-panel" style="display:none;margin-top:12px;">'
|
||||
html[#html+1] = '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">'
|
||||
html[#html+1] = '<span id="setup-log-title" style="font-weight:600;font-size:14px;">📋 安装日志</span>'
|
||||
html[#html+1] = '<span id="setup-log-status" style="font-size:12px;color:#999;"></span>'
|
||||
html[#html+1] = '</div>'
|
||||
html[#html+1] = '<pre id="setup-log-content" style="background:#1a1b26;color:#a9b1d6;padding:14px 16px;border-radius:6px;font-size:12px;line-height:1.6;max-height:400px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;border:1px solid #2d333b;margin:0;"></pre>'
|
||||
html[#html+1] = '<div id="setup-log-result" style="margin-top:10px;display:none;"></div>'
|
||||
html[#html+1] = '</div>'
|
||||
|
||||
-- JavaScript
|
||||
html[#html+1] = '<script type="text/javascript">'
|
||||
|
||||
-- 版本选择对话框逻辑
|
||||
html[#html+1] = 'var _setupTimer=null;'
|
||||
html[#html+1] = 'function ocShowSetupDialog(){'
|
||||
html[#html+1] = 'var dlg=document.getElementById("oc-setup-dialog");'
|
||||
html[#html+1] = 'dlg.style.display="flex";'
|
||||
html[#html+1] = 'var radios=document.getElementsByName("oc-ver-choice");'
|
||||
html[#html+1] = 'for(var i=0;i<radios.length;i++){if(radios[i].value==="stable")radios[i].checked=true;}'
|
||||
html[#html+1] = '}'
|
||||
html[#html+1] = 'function ocCloseSetupDialog(){'
|
||||
html[#html+1] = 'document.getElementById("oc-setup-dialog").style.display="none";'
|
||||
html[#html+1] = '}'
|
||||
html[#html+1] = 'function ocConfirmSetup(){'
|
||||
html[#html+1] = 'ocCloseSetupDialog();'
|
||||
html[#html+1] = 'var radios=document.getElementsByName("oc-ver-choice");'
|
||||
html[#html+1] = 'var choice="stable";'
|
||||
html[#html+1] = 'for(var i=0;i<radios.length;i++){if(radios[i].checked){choice=radios[i].value;break;}}'
|
||||
html[#html+1] = 'var verParam=(choice==="stable")?"stable":"latest";'
|
||||
html[#html+1] = 'ocSetup(verParam);'
|
||||
html[#html+1] = '}'
|
||||
|
||||
-- 安装运行环境 (带实时日志)
|
||||
html[#html+1] = 'function ocSetup(version){'
|
||||
html[#html+1] = 'var btn=document.getElementById("btn-setup");'
|
||||
html[#html+1] = 'var panel=document.getElementById("setup-log-panel");'
|
||||
html[#html+1] = 'var logEl=document.getElementById("setup-log-content");'
|
||||
html[#html+1] = 'var titleEl=document.getElementById("setup-log-title");'
|
||||
html[#html+1] = 'var statusEl=document.getElementById("setup-log-status");'
|
||||
html[#html+1] = 'var resultEl=document.getElementById("setup-log-result");'
|
||||
html[#html+1] = 'var actionEl=document.getElementById("action-result");'
|
||||
html[#html+1] = 'btn.disabled=true;btn.textContent="⏳ 安装中...";'
|
||||
html[#html+1] = 'actionEl.textContent="";'
|
||||
html[#html+1] = 'panel.style.display="block";'
|
||||
html[#html+1] = 'logEl.textContent="正在启动安装 ("+((version==="stable")?"稳定版":"最新版")+")...\\n";'
|
||||
html[#html+1] = 'titleEl.textContent="📋 安装日志";'
|
||||
html[#html+1] = 'statusEl.innerHTML="<span style=\\"color:#7aa2f7;\\">⏳ 安装进行中...</span>";'
|
||||
html[#html+1] = 'resultEl.style.display="none";'
|
||||
html[#html+1] = '(new XHR()).get("' .. ctl_url .. '?action=setup&version="+encodeURIComponent(version),null,function(x){'
|
||||
html[#html+1] = 'try{JSON.parse(x.responseText);}catch(e){}'
|
||||
html[#html+1] = 'ocPollSetupLog();'
|
||||
html[#html+1] = '});'
|
||||
html[#html+1] = '}'
|
||||
|
||||
-- 轮询安装日志
|
||||
html[#html+1] = 'function ocPollSetupLog(){'
|
||||
html[#html+1] = 'if(_setupTimer)clearInterval(_setupTimer);'
|
||||
html[#html+1] = '_setupTimer=setInterval(function(){'
|
||||
html[#html+1] = '(new XHR()).get("' .. log_url .. '",null,function(x){'
|
||||
html[#html+1] = 'try{'
|
||||
html[#html+1] = 'var r=JSON.parse(x.responseText);'
|
||||
html[#html+1] = 'var logEl=document.getElementById("setup-log-content");'
|
||||
html[#html+1] = 'var statusEl=document.getElementById("setup-log-status");'
|
||||
html[#html+1] = 'if(r.log)logEl.textContent=r.log;'
|
||||
html[#html+1] = 'logEl.scrollTop=logEl.scrollHeight;'
|
||||
html[#html+1] = 'if(r.state==="running"){'
|
||||
html[#html+1] = 'statusEl.innerHTML="<span style=\\"color:#7aa2f7;\\">⏳ 安装进行中...</span>";'
|
||||
html[#html+1] = '}else if(r.state==="success"){'
|
||||
html[#html+1] = 'clearInterval(_setupTimer);_setupTimer=null;'
|
||||
html[#html+1] = 'ocSetupDone(true,r.log);'
|
||||
html[#html+1] = '}else if(r.state==="failed"){'
|
||||
html[#html+1] = 'clearInterval(_setupTimer);_setupTimer=null;'
|
||||
html[#html+1] = 'ocSetupDone(false,r.log);'
|
||||
html[#html+1] = '}'
|
||||
html[#html+1] = '}catch(e){}'
|
||||
html[#html+1] = '});'
|
||||
html[#html+1] = '},1500);'
|
||||
html[#html+1] = '}'
|
||||
|
||||
-- 安装完成处理
|
||||
html[#html+1] = 'function ocSetupDone(ok,log){'
|
||||
html[#html+1] = 'var btn=document.getElementById("btn-setup");'
|
||||
html[#html+1] = 'var statusEl=document.getElementById("setup-log-status");'
|
||||
html[#html+1] = 'var resultEl=document.getElementById("setup-log-result");'
|
||||
html[#html+1] = 'btn.disabled=false;btn.textContent="📦 安装运行环境";'
|
||||
html[#html+1] = 'resultEl.style.display="block";'
|
||||
html[#html+1] = 'if(ok){'
|
||||
html[#html+1] = 'statusEl.innerHTML="<span style=\\"color:#1a7f37;\\">✅ 安装完成</span>";'
|
||||
html[#html+1] = 'resultEl.innerHTML="<div style=\\"border:1px solid #c6e9c9;background:#e6f7e9;padding:12px 16px;border-radius:6px;\\">"+'
|
||||
html[#html+1] = '"<strong style=\\"color:#1a7f37;font-size:14px;\\">🎉 恭喜!OpenClaw 运行环境安装成功!</strong><br/>"+'
|
||||
html[#html+1] = '"<span style=\\"color:#555;font-size:13px;line-height:1.8;\\">服务已自动启用并启动,点击下方按钮刷新页面查看运行状态。</span><br/>"+'
|
||||
html[#html+1] = '"<button class=\\"btn cbi-button cbi-button-apply\\" type=\\"button\\" onclick=\\"location.reload()\\" style=\\"margin-top:10px;\\">🔄 刷新页面</button></div>";'
|
||||
html[#html+1] = '}else{'
|
||||
html[#html+1] = 'statusEl.innerHTML="<span style=\\"color:#cf222e;\\">❌ 安装失败</span>";'
|
||||
-- 分析失败原因
|
||||
html[#html+1] = 'var reasons=ocAnalyzeFailure(log);'
|
||||
html[#html+1] = 'resultEl.innerHTML="<div style=\\"border:1px solid #f5c6cb;background:#ffeef0;padding:12px 16px;border-radius:6px;\\">"+'
|
||||
html[#html+1] = '"<strong style=\\"color:#cf222e;font-size:14px;\\">❌ 安装失败</strong><br/>"+'
|
||||
html[#html+1] = '"<div style=\\"margin:8px 0;padding:10px 14px;background:#fff5f5;border-radius:4px;font-size:13px;line-height:1.8;\\">"+'
|
||||
html[#html+1] = '"<strong>🔍 可能的失败原因:</strong><br/>"+reasons+"</div>"+'
|
||||
html[#html+1] = '"<div style=\\"margin-top:8px;font-size:12px;color:#666;\\">💡 完整日志见上方终端输出,也可在终端查看:<code>cat /tmp/openclaw-setup.log</code></div></div>";'
|
||||
html[#html+1] = '}'
|
||||
html[#html+1] = '}'
|
||||
|
||||
-- 分析失败原因
|
||||
html[#html+1] = 'function ocAnalyzeFailure(log){'
|
||||
html[#html+1] = 'var reasons=[];'
|
||||
html[#html+1] = 'if(!log)return"未知错误,请检查日志。";'
|
||||
html[#html+1] = 'var ll=log.toLowerCase();'
|
||||
-- 网络问题
|
||||
html[#html+1] = 'if(ll.indexOf("could not resolve")>=0||ll.indexOf("connection timed out")>=0||ll.indexOf("curl")>=0&&ll.indexOf("fail")>=0||ll.indexOf("wget")>=0&&ll.indexOf("fail")>=0||ll.indexOf("所有镜像均下载失败")>=0){'
|
||||
html[#html+1] = 'reasons.push("🌐 <b>网络连接失败</b> — 无法下载 Node.js。请检查路由器是否能访问外网。<br/> 💡 解决: 检查 DNS 设置和网络连接,或手动指定镜像: <code>NODE_MIRROR=https://npmmirror.com/mirrors/node openclaw-env setup</code>");'
|
||||
html[#html+1] = '}'
|
||||
-- 磁盘空间
|
||||
html[#html+1] = 'if(ll.indexOf("no space")>=0||ll.indexOf("disk full")>=0||ll.indexOf("enospc")>=0){'
|
||||
html[#html+1] = 'reasons.push("💾 <b>磁盘空间不足</b> — Node.js + OpenClaw 需要约 200MB 空间。<br/> 💡 解决: 运行 <code>df -h</code> 检查可用空间,清理不需要的文件或使用外部存储。");'
|
||||
html[#html+1] = '}'
|
||||
-- 架构不支持
|
||||
html[#html+1] = 'if(ll.indexOf("不支持的 cpu 架构")>=0||ll.indexOf("不支持的架构")>=0){'
|
||||
html[#html+1] = 'reasons.push("🔧 <b>CPU 架构不支持</b> — 仅支持 x86_64 和 aarch64 (ARM64)。<br/> 💡 当前设备架构可能是 32 位 ARM 或 MIPS,无法运行 Node.js 22。");'
|
||||
html[#html+1] = '}'
|
||||
-- npm 安装失败
|
||||
html[#html+1] = 'if(ll.indexOf("npm err")>=0||ll.indexOf("npm warn")>=0&&ll.indexOf("openclaw 安装验证失败")>=0){'
|
||||
html[#html+1] = 'reasons.push("📦 <b>npm 安装 OpenClaw 失败</b> — npm 包下载或安装出错。<br/> 💡 解决: 尝试手动安装 <code>PATH=/opt/openclaw/node/bin:$PATH npm install -g openclaw@latest --prefix=/opt/openclaw/global</code>");'
|
||||
html[#html+1] = '}'
|
||||
-- 权限问题
|
||||
html[#html+1] = 'if(ll.indexOf("permission denied")>=0||ll.indexOf("eacces")>=0){'
|
||||
html[#html+1] = 'reasons.push("🔒 <b>权限不足</b> — 文件或目录权限问题。<br/> 💡 解决: 运行 <code>chown -R openclaw:openclaw /opt/openclaw</code> 或以 root 用户重试。");'
|
||||
html[#html+1] = '}'
|
||||
-- tar 解压失败
|
||||
html[#html+1] = 'if(ll.indexOf("tar")>=0&&(ll.indexOf("error")>=0||ll.indexOf("fail")>=0)){'
|
||||
html[#html+1] = 'reasons.push("📂 <b>解压失败</b> — Node.js 安装包可能下载不完整。<br/> 💡 解决: 删除缓存重试 <code>rm -rf /opt/openclaw/node && openclaw-env setup</code>");'
|
||||
html[#html+1] = '}'
|
||||
-- 验证失败
|
||||
html[#html+1] = 'if(ll.indexOf("安装验证失败")>=0){'
|
||||
html[#html+1] = 'reasons.push("⚠️ <b>安装验证失败</b> — 程序已下载但无法正常运行。<br/> 💡 可能是 glibc/musl 不兼容,请确认系统 C 库类型: <code>ldd --version 2>&1 | head -1</code>");'
|
||||
html[#html+1] = '}'
|
||||
-- 兜底
|
||||
html[#html+1] = 'if(reasons.length===0){'
|
||||
html[#html+1] = 'reasons.push("⚠️ <b>未识别的错误</b> — 请查看上方完整日志分析具体原因。<br/> 💡 您也可以尝试手动执行: <code>openclaw-env setup</code> 查看详细输出。");'
|
||||
html[#html+1] = '}'
|
||||
html[#html+1] = 'return reasons.join("<br/><br/>");'
|
||||
html[#html+1] = '}'
|
||||
|
||||
-- 普通服务操作 (restart/stop)
|
||||
html[#html+1] = 'function ocServiceCtl(action){'
|
||||
html[#html+1] = 'var el=document.getElementById("action-result");'
|
||||
html[#html+1] = 'el.innerHTML="<span style=\\"color:#999\\">⏳ 正在执行...</span>";'
|
||||
html[#html+1] = '(new XHR()).get("' .. ctl_url .. '?action="+action,null,function(x){'
|
||||
html[#html+1] = 'try{var r=JSON.parse(x.responseText);'
|
||||
html[#html+1] = 'if(r.status==="ok"){el.innerHTML="<span style=\\"color:green\\">✅ "+action+" 已完成</span>";}'
|
||||
html[#html+1] = 'else{el.innerHTML="<span style=\\"color:red\\">❌ "+(r.message||"失败")+"</span>";}'
|
||||
html[#html+1] = '}catch(e){el.innerHTML="<span style=\\"color:red\\">❌ 错误</span>";}'
|
||||
html[#html+1] = '});}'
|
||||
|
||||
-- 检测升级 (只检查插件版本,有新版本时显示更新内容)
|
||||
html[#html+1] = 'function ocCheckUpdate(){'
|
||||
html[#html+1] = 'var btn=document.getElementById("btn-check-update");'
|
||||
html[#html+1] = 'var el=document.getElementById("action-result");'
|
||||
html[#html+1] = 'var act=document.getElementById("oc-update-action");'
|
||||
html[#html+1] = 'btn.disabled=true;btn.textContent="⏳ 正在检测...";el.textContent="";act.style.display="none";'
|
||||
html[#html+1] = '(new XHR()).get("' .. check_url .. '",null,function(x){'
|
||||
html[#html+1] = 'btn.disabled=false;btn.textContent="🔍 检测升级";'
|
||||
html[#html+1] = 'var dot=document.getElementById("update-dot");if(dot)dot.style.display="none";'
|
||||
html[#html+1] = 'try{var r=JSON.parse(x.responseText);'
|
||||
html[#html+1] = 'var msgs=[];'
|
||||
-- 插件版本检查
|
||||
html[#html+1] = 'if(r.plugin_current){'
|
||||
html[#html+1] = 'if(r.plugin_has_update){msgs.push("<span style=\\"color:#e36209\\">🔌 插件: v"+r.plugin_current+" → v"+r.plugin_latest+" (有新版本)</span>");}'
|
||||
html[#html+1] = 'else if(r.plugin_latest){msgs.push("<span style=\\"color:green\\">✅ 插件: v"+r.plugin_current+" (已是最新)</span>");}'
|
||||
html[#html+1] = 'else{msgs.push("<span style=\\"color:#999\\">🔌 插件: v"+r.plugin_current+" (无法检查最新版本)</span>");}'
|
||||
html[#html+1] = '}'
|
||||
html[#html+1] = 'if(msgs.length===0)msgs.push("<span style=\\"color:#999\\">无法获取版本信息</span>");'
|
||||
html[#html+1] = 'el.innerHTML=msgs.join("<br/>");'
|
||||
-- 插件有更新时: release notes + 一键升级按钮 + GitHub 下载链接
|
||||
html[#html+1] = 'if(r.plugin_has_update){'
|
||||
html[#html+1] = 'act.style.display="block";'
|
||||
html[#html+1] = 'window._pluginLatestVer=r.plugin_latest;'
|
||||
html[#html+1] = 'var notesHtml="";'
|
||||
html[#html+1] = 'if(r.release_notes){'
|
||||
html[#html+1] = 'var escaped=r.release_notes.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");'
|
||||
html[#html+1] = 'notesHtml=\'<div style="margin:10px 0 8px;padding:10px 14px;background:#fffbf0;border:1px solid #f0c040;border-radius:6px;">\''
|
||||
html[#html+1] = '+\'<div style="font-size:12px;font-weight:600;color:#8a6a00;margin-bottom:6px;">📋 v\'+r.plugin_latest+\' 更新内容</div>\''
|
||||
html[#html+1] = '+\'<pre style="margin:0;font-size:12px;color:#444;white-space:pre-wrap;word-break:break-word;line-height:1.6;">\'+escaped+\'</pre>\''
|
||||
html[#html+1] = '+\'</div>\';'
|
||||
html[#html+1] = '}'
|
||||
html[#html+1] = 'act.innerHTML=notesHtml'
|
||||
html[#html+1] = '+\'<button class="btn cbi-button cbi-button-apply" type="button" onclick="ocPluginUpgrade()" id="btn-plugin-upgrade">⬆️ 升级插件 v\'+r.plugin_latest+\'</button>\''
|
||||
html[#html+1] = '+\' <a href="https://github.com/10000ge10000/luci-app-openclaw/releases/latest" target="_blank" rel="noopener" class="btn cbi-button cbi-button-action" style="text-decoration:none;">📥 手动下载</a>\';'
|
||||
html[#html+1] = '}'
|
||||
html[#html+1] = '}catch(e){el.innerHTML="<span style=\\"color:red\\">❌ 检测失败</span>";}'
|
||||
html[#html+1] = '});}'
|
||||
|
||||
-- ═══ 插件一键升级 ═══
|
||||
html[#html+1] = 'var _pluginUpgradeTimer=null;'
|
||||
|
||||
html[#html+1] = 'function ocPluginUpgrade(){'
|
||||
html[#html+1] = 'var ver=window._pluginLatestVer;'
|
||||
html[#html+1] = 'if(!ver){alert("无法获取最新版本号");return;}'
|
||||
html[#html+1] = 'if(!confirm("确定要升级插件到 v"+ver+"?\\n\\n升级会替换插件文件并清除 LuCI 缓存,不会影响正在运行的 OpenClaw 服务。"))return;'
|
||||
html[#html+1] = 'var btn=document.getElementById("btn-plugin-upgrade");'
|
||||
html[#html+1] = 'var panel=document.getElementById("setup-log-panel");'
|
||||
html[#html+1] = 'var logEl=document.getElementById("setup-log-content");'
|
||||
html[#html+1] = 'var titleEl=document.getElementById("setup-log-title");'
|
||||
html[#html+1] = 'var statusEl=document.getElementById("setup-log-status");'
|
||||
html[#html+1] = 'var resultEl=document.getElementById("setup-log-result");'
|
||||
html[#html+1] = 'btn.disabled=true;btn.textContent="⏳ 正在升级插件...";'
|
||||
html[#html+1] = 'panel.style.display="block";'
|
||||
html[#html+1] = 'logEl.textContent="正在启动插件升级...\\n";'
|
||||
html[#html+1] = 'titleEl.textContent="📋 插件升级日志";'
|
||||
html[#html+1] = 'statusEl.innerHTML="<span style=\\"color:#7aa2f7;\\">⏳ 插件升级中...</span>";'
|
||||
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] = 'try{JSON.parse(x.responseText);}catch(e){}'
|
||||
html[#html+1] = 'ocPollPluginUpgradeLog();'
|
||||
html[#html+1] = '});'
|
||||
html[#html+1] = '}'
|
||||
|
||||
-- 轮询插件升级日志 (带容错: 安装时文件被替换可能导致API暂时不可用)
|
||||
html[#html+1] = 'var _pluginPollErrors=0;'
|
||||
html[#html+1] = 'function ocPollPluginUpgradeLog(){'
|
||||
html[#html+1] = 'if(_pluginUpgradeTimer)clearInterval(_pluginUpgradeTimer);'
|
||||
html[#html+1] = '_pluginPollErrors=0;'
|
||||
html[#html+1] = '_pluginUpgradeTimer=setInterval(function(){'
|
||||
html[#html+1] = '(new XHR()).get("' .. plugin_upgrade_log_url .. '",null,function(x){'
|
||||
html[#html+1] = 'try{'
|
||||
html[#html+1] = 'var r=JSON.parse(x.responseText);'
|
||||
html[#html+1] = '_pluginPollErrors=0;'
|
||||
html[#html+1] = 'var logEl=document.getElementById("setup-log-content");'
|
||||
html[#html+1] = 'var statusEl=document.getElementById("setup-log-status");'
|
||||
html[#html+1] = 'if(r.log)logEl.textContent=r.log;'
|
||||
html[#html+1] = 'logEl.scrollTop=logEl.scrollHeight;'
|
||||
html[#html+1] = 'if(r.state==="running"){'
|
||||
html[#html+1] = 'statusEl.innerHTML="<span style=\\"color:#7aa2f7;\\">⏳ 插件升级中...</span>";'
|
||||
html[#html+1] = '}else if(r.state==="success"){'
|
||||
html[#html+1] = 'clearInterval(_pluginUpgradeTimer);_pluginUpgradeTimer=null;'
|
||||
html[#html+1] = 'ocPluginUpgradeDone(true);'
|
||||
html[#html+1] = '}else if(r.state==="failed"){'
|
||||
html[#html+1] = 'clearInterval(_pluginUpgradeTimer);_pluginUpgradeTimer=null;'
|
||||
html[#html+1] = 'ocPluginUpgradeDone(false);'
|
||||
html[#html+1] = '}'
|
||||
html[#html+1] = '}catch(e){'
|
||||
html[#html+1] = '_pluginPollErrors++;'
|
||||
html[#html+1] = 'if(_pluginPollErrors>=8){'
|
||||
html[#html+1] = 'clearInterval(_pluginUpgradeTimer);_pluginUpgradeTimer=null;'
|
||||
html[#html+1] = 'ocPluginUpgradeDone(true);'
|
||||
html[#html+1] = '}'
|
||||
html[#html+1] = '}'
|
||||
html[#html+1] = '});'
|
||||
html[#html+1] = '},2000);'
|
||||
html[#html+1] = '}'
|
||||
|
||||
-- 插件升级完成处理
|
||||
html[#html+1] = 'function ocPluginUpgradeDone(ok){'
|
||||
html[#html+1] = 'var btn=document.getElementById("btn-plugin-upgrade");'
|
||||
html[#html+1] = 'var statusEl=document.getElementById("setup-log-status");'
|
||||
html[#html+1] = 'var resultEl=document.getElementById("setup-log-result");'
|
||||
html[#html+1] = 'if(btn){btn.disabled=false;btn.textContent="⬆️ 升级插件";}'
|
||||
html[#html+1] = 'resultEl.style.display="block";'
|
||||
html[#html+1] = 'if(ok){'
|
||||
html[#html+1] = 'statusEl.innerHTML="<span style=\\"color:#1a7f37;\\">✅ 插件升级完成</span>";'
|
||||
html[#html+1] = 'resultEl.innerHTML="<div style=\\"border:1px solid #c6e9c9;background:#e6f7e9;padding:12px 16px;border-radius:6px;\\">"+'
|
||||
html[#html+1] = '"<strong style=\\"color:#1a7f37;font-size:14px;\\">🎉 插件升级成功!</strong><br/>"+'
|
||||
html[#html+1] = '"<span style=\\"color:#555;font-size:13px;line-height:1.8;\\">插件文件已更新,OpenClaw 服务不受影响。请刷新页面加载新版界面。</span><br/>"+'
|
||||
html[#html+1] = '"<button class=\\"btn cbi-button cbi-button-apply\\" type=\\"button\\" onclick=\\"location.reload()\\" style=\\"margin-top:10px;\\">🔄 刷新页面</button></div>";'
|
||||
html[#html+1] = '}else{'
|
||||
html[#html+1] = 'statusEl.innerHTML="<span style=\\"color:#cf222e;\\">❌ 插件升级失败</span>";'
|
||||
html[#html+1] = 'resultEl.innerHTML="<div style=\\"border:1px solid #f5c6cb;background:#ffeef0;padding:12px 16px;border-radius:6px;\\">"+'
|
||||
html[#html+1] = '"<strong style=\\"color:#cf222e;font-size:14px;\\">❌ 插件升级失败</strong><br/>"+'
|
||||
html[#html+1] = '"<span style=\\"color:#555;font-size:13px;\\">请查看上方日志了解详情。也可手动执行:<code>cat /tmp/openclaw-plugin-upgrade.log</code></span><br/>"+'
|
||||
html[#html+1] = '"<button class=\\"btn cbi-button cbi-button-apply\\" type=\\"button\\" onclick=\\"location.reload()\\" style=\\"margin-top:10px;\\">🔄 刷新页面</button></div>";'
|
||||
html[#html+1] = '}'
|
||||
html[#html+1] = '}'
|
||||
|
||||
-- 卸载运行环境
|
||||
html[#html+1] = 'function ocUninstall(){'
|
||||
html[#html+1] = 'if(!confirm("确定要卸载 OpenClaw 运行环境?\\n\\n将删除 Node.js、OpenClaw 程序及配置数据(/opt/openclaw 目录),服务将停止运行。\\n\\n插件本身不会被删除,之后可重新安装运行环境。"))return;'
|
||||
html[#html+1] = 'var btn=document.getElementById("btn-uninstall");'
|
||||
html[#html+1] = 'var el=document.getElementById("action-result");'
|
||||
html[#html+1] = 'btn.disabled=true;btn.textContent="⏳ 正在卸载...";'
|
||||
html[#html+1] = 'el.innerHTML="<span style=\\"color:#999\\">正在停止服务并清理文件...</span>";'
|
||||
html[#html+1] = '(new XHR()).get("' .. uninstall_url .. '",null,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"){'
|
||||
html[#html+1] = 'el.innerHTML="<div style=\\"border:1px solid #d0d7de;background:#f6f8fa;padding:12px 16px;border-radius:6px;\\">"+'
|
||||
html[#html+1] = '"<strong style=\\"color:#1a7f37;\\">✅ 卸载完成</strong><br/>"+'
|
||||
html[#html+1] = '"<span style=\\"color:#555;font-size:13px;\\">"+r.message+"</span><br/>"+'
|
||||
html[#html+1] = '"<button class=\\"btn cbi-button cbi-button-apply\\" type=\\"button\\" onclick=\\"location.reload()\\" style=\\"margin-top:8px;\\">🔄 刷新页面</button></div>";'
|
||||
html[#html+1] = '}else{el.innerHTML="<span style=\\"color:red\\">❌ "+(r.message||"卸载失败")+"</span>";}'
|
||||
html[#html+1] = '}catch(e){el.innerHTML="<span style=\\"color:red\\">❌ 请求失败</span>";}'
|
||||
html[#html+1] = '});}'
|
||||
|
||||
-- ═══ 备份/恢复 对话框 + 功能 (v2026.3.8+ openclaw backup) ═══
|
||||
local backup_url = luci.dispatcher.build_url("admin", "services", "openclaw", "backup")
|
||||
-- 先关闭 script,插入对话框 HTML,再重新打开 script
|
||||
html[#html+1] = '</script>'
|
||||
-- 对话框 HTML (附加到按钮区域后面)
|
||||
html[#html+1] = '<div id="oc-backup-dialog" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:10000;align-items:center;justify-content:center;">'
|
||||
html[#html+1] = '<div style="background:#fff;border-radius:12px;padding:24px 28px;max-width:520px;width:92%;box-shadow:0 8px 32px rgba(0,0,0,0.2);">'
|
||||
html[#html+1] = '<h3 style="margin:0 0 16px 0;font-size:16px;color:#333;">💾 备份 / 恢复配置</h3>'
|
||||
-- 备份操作区
|
||||
html[#html+1] = '<div style="margin-bottom:16px;">'
|
||||
html[#html+1] = '<div style="font-weight:600;font-size:13px;color:#555;margin-bottom:8px;">📤 创建备份</div>'
|
||||
html[#html+1] = '<div style="display:flex;gap:10px;">'
|
||||
html[#html+1] = '<button class="btn cbi-button cbi-button-apply" type="button" onclick="ocDoBackup(1)" id="btn-bk-config" style="font-size:12px;">📄 仅配置文件</button>'
|
||||
html[#html+1] = '<button class="btn cbi-button cbi-button-action" type="button" onclick="ocDoBackup(0)" id="btn-bk-full" style="font-size:12px;">📦 配置 + 状态数据</button>'
|
||||
html[#html+1] = '</div>'
|
||||
html[#html+1] = '<div style="font-size:11px;color:#888;margin-top:6px;">仅配置文件 (~2KB) 包含模型、渠道、插件设置;完整备份含会话历史等状态数据(可能较大)</div>'
|
||||
html[#html+1] = '</div>'
|
||||
-- 备份列表区(恢复/删除在这里动态渲染)
|
||||
html[#html+1] = '<div style="border-top:1px solid #eee;padding-top:14px;margin-bottom:16px;">'
|
||||
html[#html+1] = '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">'
|
||||
html[#html+1] = '<div style="font-weight:600;font-size:13px;color:#555;">📥 现有备份</div>'
|
||||
html[#html+1] = '<button class="btn cbi-button" type="button" onclick="ocLoadBackupList()" style="font-size:11px;padding:2px 10px;">🔄 刷新</button>'
|
||||
html[#html+1] = '</div>'
|
||||
html[#html+1] = '<div id="oc-backup-list" style="max-height:260px;overflow-y:auto;"></div>'
|
||||
html[#html+1] = '</div>'
|
||||
-- 操作结果提示区
|
||||
html[#html+1] = '<div id="oc-backup-result" style="margin-bottom:14px;display:none;"></div>'
|
||||
-- 关闭按钮
|
||||
html[#html+1] = '<div style="display:flex;justify-content:flex-end;">'
|
||||
html[#html+1] = '<button class="btn cbi-button" type="button" onclick="document.getElementById(\'oc-backup-dialog\').style.display=\'none\';" style="min-width:80px;">关闭</button>'
|
||||
html[#html+1] = '</div>'
|
||||
html[#html+1] = '</div></div>'
|
||||
|
||||
-- 重新打开 script 继续 JS 函数
|
||||
html[#html+1] = '<script type="text/javascript">'
|
||||
|
||||
-- 打开备份/恢复对话框并加载列表
|
||||
html[#html+1] = 'function ocBackupRestore(){'
|
||||
html[#html+1] = 'var dlg=document.getElementById("oc-backup-dialog");'
|
||||
html[#html+1] = 'dlg.style.display="flex";'
|
||||
html[#html+1] = 'document.getElementById("oc-backup-result").style.display="none";'
|
||||
html[#html+1] = 'ocLoadBackupList();'
|
||||
html[#html+1] = '}'
|
||||
|
||||
-- 加载备份文件列表
|
||||
html[#html+1] = 'function ocLoadBackupList(){'
|
||||
html[#html+1] = 'var el=document.getElementById("oc-backup-list");'
|
||||
html[#html+1] = 'el.innerHTML="<div style=\\"color:#7aa2f7;font-size:12px;padding:8px;\\">⏳ 加载备份列表...</div>";'
|
||||
html[#html+1] = '(new XHR()).get("' .. backup_url .. '?action=list",null,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="<table style=\\"width:100%;border-collapse:collapse;font-size:12px;\\">";'
|
||||
html[#html+1] = 'h+="<tr style=\\"background:#f6f8fa;border-bottom:2px solid #d0d7de;\\">"+'
|
||||
html[#html+1] = '"<th style=\\"padding:6px 8px;text-align:left;\\">类型</th>"+'
|
||||
html[#html+1] = '"<th style=\\"padding:6px 8px;text-align:left;\\">备份时间</th>"+'
|
||||
html[#html+1] = '"<th style=\\"padding:6px 8px;text-align:right;\\">大小</th>"+'
|
||||
html[#html+1] = '"<th style=\\"padding:6px 8px;text-align:center;\\">操作</th></tr>";'
|
||||
html[#html+1] = 'for(var i=0;i<r.backups.length;i++){'
|
||||
html[#html+1] = 'var b=r.backups[i];'
|
||||
html[#html+1] = 'var typeBadge=b.backup_type==="config"?'
|
||||
html[#html+1] = '"<span style=\\"background:#ddf4ff;color:#0969da;padding:2px 6px;border-radius:3px;font-size:11px;white-space:nowrap;\\">📄 仅配置</span>":'
|
||||
html[#html+1] = '"<span style=\\"background:#fff8c5;color:#9a6700;padding:2px 6px;border-radius:3px;font-size:11px;white-space:nowrap;\\">📦 完整备份</span>";'
|
||||
html[#html+1] = 'var rowBg=i%2===0?"#fff":"#f6f8fa";'
|
||||
html[#html+1] = 'h+="<tr style=\\"border-bottom:1px solid #eee;background:"+rowBg+";\\">"+' -- 行开始
|
||||
html[#html+1] = '"<td style=\\"padding:7px 8px;\\">"+typeBadge+"</td>"+' -- 类型
|
||||
html[#html+1] = '"<td style=\\"padding:7px 8px;color:#555;white-space:nowrap;\\">"+b.time+"</td>"+' -- 时间
|
||||
html[#html+1] = '"<td style=\\"padding:7px 8px;text-align:right;color:#666;white-space:nowrap;\\">"+b.size_str+"</td>"+' -- 大小
|
||||
html[#html+1] = '"<td style=\\"padding:5px 8px;text-align:center;white-space:nowrap;\\">"+'
|
||||
html[#html+1] = '"<button class=\\"btn cbi-button cbi-button-action\\" style=\\"font-size:11px;padding:1px 8px;margin-right:4px;\\" onclick=\\"ocRestoreBackup(\\x27"+b.filename+"\\x27)\\">恢复</button>"+'
|
||||
html[#html+1] = '"<button class=\\"btn cbi-button cbi-button-remove\\" style=\\"font-size:11px;padding:1px 8px;\\" onclick=\\"ocDeleteBackup(\\x27"+b.filename+"\\x27)\\">删除</button>"+'
|
||||
html[#html+1] = '"</td></tr>";'
|
||||
html[#html+1] = '}'
|
||||
html[#html+1] = 'h+="</table>";'
|
||||
html[#html+1] = 'el.innerHTML=h;'
|
||||
html[#html+1] = '}else if(r.status==="ok"){'
|
||||
html[#html+1] = 'el.innerHTML="<div style=\\"color:#888;font-size:12px;padding:8px;text-align:center;\\">暂无备份,请先创建备份</div>";'
|
||||
html[#html+1] = '}else{'
|
||||
html[#html+1] = 'el.innerHTML="<div style=\\"color:#e36209;font-size:12px;padding:8px;\\">⚠️ "+(r.message||"获取列表失败")+"</div>";'
|
||||
html[#html+1] = '}'
|
||||
html[#html+1] = '}catch(e){el.innerHTML="<div style=\\"color:#e36209;font-size:12px;padding:8px;\\">⚠️ 无法加载列表</div>";}'
|
||||
html[#html+1] = '});}'
|
||||
|
||||
-- 创建备份(创建完成后刷新列表)
|
||||
html[#html+1] = 'function ocDoBackup(onlyConfig){'
|
||||
html[#html+1] = 'var resEl=document.getElementById("oc-backup-result");'
|
||||
html[#html+1] = 'var btnC=document.getElementById("btn-bk-config");'
|
||||
html[#html+1] = 'var btnF=document.getElementById("btn-bk-full");'
|
||||
html[#html+1] = 'btnC.disabled=true;btnF.disabled=true;'
|
||||
html[#html+1] = 'resEl.style.display="block";'
|
||||
html[#html+1] = 'resEl.innerHTML="<div style=\\"color:#7aa2f7;font-size:12px;padding:8px;\\">⏳ 正在创建备份..."+(onlyConfig?"(仅配置)":"(完整备份,可能需要较长时间)")+"</div>";'
|
||||
html[#html+1] = '(new XHR()).get("' .. backup_url .. '?action=create&only_config="+onlyConfig,null,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"){'
|
||||
html[#html+1] = 'resEl.innerHTML="<div style=\\"border:1px solid #c6e9c9;background:#e6f7e9;padding:10px 14px;border-radius:6px;font-size:12px;\\">"+'
|
||||
html[#html+1] = '"<strong style=\\"color:#1a7f37;\\">✅ 备份完成</strong></div>";'
|
||||
html[#html+1] = 'ocLoadBackupList();'
|
||||
html[#html+1] = '}else{'
|
||||
html[#html+1] = 'resEl.innerHTML="<div style=\\"color:#e36209;font-size:12px;padding:8px;\\">⚠️ "+(r.message||"备份失败")+"</div>";'
|
||||
html[#html+1] = '}'
|
||||
html[#html+1] = '}catch(e){resEl.innerHTML="<div style=\\"color:#e36209;font-size:12px;padding:8px;\\">⚠️ 备份功能需要 OpenClaw v2026.3.8+</div>";}'
|
||||
html[#html+1] = '});}'
|
||||
|
||||
-- 恢复指定备份
|
||||
html[#html+1] = 'function ocRestoreBackup(filename){'
|
||||
html[#html+1] = 'if(!confirm("确定要从此备份恢复配置?\\n\\n"+filename+"\\n\\n当前 openclaw.json 将被备份中的版本覆盖,服务将自动重启。"))return;'
|
||||
html[#html+1] = 'var resEl=document.getElementById("oc-backup-result");'
|
||||
html[#html+1] = 'resEl.style.display="block";'
|
||||
html[#html+1] = 'resEl.innerHTML="<div style=\\"color:#7aa2f7;font-size:12px;padding:8px;\\">⏳ 正在恢复配置...</div>";'
|
||||
html[#html+1] = '(new XHR()).get("' .. backup_url .. '?action=restore&file="+encodeURIComponent(filename),null,function(x){'
|
||||
html[#html+1] = 'try{var r=JSON.parse(x.responseText);'
|
||||
html[#html+1] = 'if(r.status==="ok"){'
|
||||
html[#html+1] = 'resEl.innerHTML="<div style=\\"border:1px solid #c6e9c9;background:#e6f7e9;padding:10px 14px;border-radius:6px;font-size:12px;\\">"+'
|
||||
html[#html+1] = '"<strong style=\\"color:#1a7f37;\\">✅ 配置已恢复</strong><br/>"+'
|
||||
html[#html+1] = '"<span style=\\"color:#555;\\">"+r.message+"</span><br/>"+'
|
||||
html[#html+1] = '"<button class=\\"btn cbi-button cbi-button-apply\\" type=\\"button\\" onclick=\\"location.reload()\\" style=\\"margin-top:6px;font-size:12px;\\">🔄 刷新页面</button></div>";'
|
||||
html[#html+1] = '}else{'
|
||||
html[#html+1] = 'resEl.innerHTML="<div style=\\"color:#cf222e;font-size:12px;padding:8px;\\">❌ "+(r.message||"恢复失败")+"</div>";'
|
||||
html[#html+1] = '}'
|
||||
html[#html+1] = '}catch(e){resEl.innerHTML="<div style=\\"color:#cf222e;font-size:12px;padding:8px;\\">❌ 恢复失败,请检查日志</div>";}'
|
||||
html[#html+1] = '});}'
|
||||
|
||||
-- 删除指定备份
|
||||
html[#html+1] = 'function ocDeleteBackup(filename){'
|
||||
html[#html+1] = 'if(!confirm("确定要删除此备份?\\n\\n"+filename+"\\n\\n删除后无法恢复。"))return;'
|
||||
html[#html+1] = 'var resEl=document.getElementById("oc-backup-result");'
|
||||
html[#html+1] = 'resEl.style.display="block";'
|
||||
html[#html+1] = 'resEl.innerHTML="<div style=\\"color:#7aa2f7;font-size:12px;padding:8px;\\">⏳ 正在删除...</div>";'
|
||||
html[#html+1] = '(new XHR()).get("' .. backup_url .. '?action=delete&file="+encodeURIComponent(filename),null,function(x){'
|
||||
html[#html+1] = 'try{var r=JSON.parse(x.responseText);'
|
||||
html[#html+1] = 'if(r.status==="ok"){'
|
||||
html[#html+1] = 'resEl.innerHTML="<div style=\\"border:1px solid #c6e9c9;background:#e6f7e9;padding:10px 14px;border-radius:6px;font-size:12px;\\">"+'
|
||||
html[#html+1] = '"<strong style=\\"color:#1a7f37;\\">✅ "+r.message+"</strong></div>";'
|
||||
html[#html+1] = 'ocLoadBackupList();'
|
||||
html[#html+1] = '}else{'
|
||||
html[#html+1] = 'resEl.innerHTML="<div style=\\"color:#cf222e;font-size:12px;padding:8px;\\">❌ "+(r.message||"删除失败")+"</div>";'
|
||||
html[#html+1] = '}'
|
||||
html[#html+1] = '}catch(e){resEl.innerHTML="<div style=\\"color:#cf222e;font-size:12px;padding:8px;\\">❌ 删除失败</div>";}'
|
||||
html[#html+1] = '});}'
|
||||
|
||||
-- 页面加载时静默检查是否有更新 (仅显示小红点提示)
|
||||
html[#html+1] = '(function(){'
|
||||
html[#html+1] = 'setTimeout(function(){'
|
||||
html[#html+1] = '(new XHR()).get("' .. check_url .. '",null,function(x){'
|
||||
html[#html+1] = 'try{var r=JSON.parse(x.responseText);'
|
||||
html[#html+1] = 'if(r.plugin_has_update){'
|
||||
html[#html+1] = 'var dot=document.getElementById("update-dot");'
|
||||
html[#html+1] = 'if(dot)dot.style.display="block";'
|
||||
html[#html+1] = '}'
|
||||
html[#html+1] = '}catch(e){}'
|
||||
html[#html+1] = '});'
|
||||
html[#html+1] = '},2000);'
|
||||
html[#html+1] = '})();'
|
||||
|
||||
html[#html+1] = '</script>'
|
||||
return table.concat(html, "\n")
|
||||
end
|
||||
|
||||
-- ═══════════════════════════════════════════
|
||||
-- 使用指南
|
||||
-- ═══════════════════════════════════════════
|
||||
s4 = m:section(SimpleSection, nil)
|
||||
s4.template = "cbi/nullsection"
|
||||
guide = s4:option(DummyValue, "_guide")
|
||||
guide.rawhtml = true
|
||||
guide.cfgvalue = function()
|
||||
local html = {}
|
||||
html[#html+1] = '<div style="border:1px solid #d0e8ff;background:#f0f7ff;padding:14px 18px;border-radius:6px;margin-top:12px;line-height:1.8;font-size:13px;">'
|
||||
html[#html+1] = '<strong style="font-size:14px;">📖 使用指南</strong><br/>'
|
||||
html[#html+1] = '<span style="color:#555;">'
|
||||
html[#html+1] = '① 首次使用请点击 <b>「安装运行环境」</b>,安装完成后服务会自动启动<br/>'
|
||||
html[#html+1] = '② 进入 <b>「配置管理」</b> 使用交互式向导快速配置 AI 模型和 API Key<br/>'
|
||||
html[#html+1] = '③ 进入 <b>「Web 控制台」</b> 配置消息渠道,直接开始对话</span>'
|
||||
html[#html+1] = '<div style="margin-top:10px;padding-top:10px;border-top:1px solid #d0e8ff;">'
|
||||
html[#html+1] = '<span style="color:#888;">有疑问?请关注B站并留言:</span>'
|
||||
html[#html+1] = '<a href="https://space.bilibili.com/59438380" target="_blank" rel="noopener" style="color:#00a1d6;font-weight:bold;text-decoration:none;">'
|
||||
html[#html+1] = '🔗 space.bilibili.com/59438380</a>'
|
||||
html[#html+1] = '<span style="margin-left:16px;color:#888;">GitHub 项目:</span>'
|
||||
html[#html+1] = '<a href="https://github.com/10000ge10000/luci-app-openclaw" target="_blank" rel="noopener" style="color:#24292f;font-weight:bold;text-decoration:none;">'
|
||||
html[#html+1] = '🐙 10000ge10000/luci-app-openclaw</a></div></div>'
|
||||
return table.concat(html, "\n")
|
||||
end
|
||||
|
||||
return m
|
||||
122
luci-app-openclaw/luasrc/view/openclaw/advanced.htm
Normal file
122
luci-app-openclaw/luasrc/view/openclaw/advanced.htm
Normal file
@ -0,0 +1,122 @@
|
||||
<%#
|
||||
luci-app-openclaw — 终端配置页面 (简洁版)
|
||||
-%>
|
||||
<%+header%>
|
||||
|
||||
<%
|
||||
local uci = require "luci.model.uci".cursor()
|
||||
local pty_port = uci:get("openclaw", "main", "pty_port") or "18793"
|
||||
%>
|
||||
|
||||
<style type="text/css">
|
||||
.oc-page-header { margin: 0 0 16px 0; }
|
||||
.oc-page-header h2 { font-size: 18px; font-weight: 600; color: #333; margin: 0 0 6px 0; }
|
||||
.oc-page-header p { font-size: 13px; color: #666; margin: 0; line-height: 1.6; }
|
||||
|
||||
.oc-terminal-wrap {
|
||||
border: 2px solid #2d333b;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #1a1b26;
|
||||
}
|
||||
|
||||
#oc-terminal-iframe { width: 100%; height: 600px; border: none; display: block; }
|
||||
|
||||
.oc-terminal-loading {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 200px; color: #7aa2f7; font-size: 14px; background: #1a1b26;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="oc-page-header">
|
||||
<h2>⚙️ 终端配置</h2>
|
||||
<p>通过内嵌的交互式终端 (oc-config) 进行 OpenClaw 的完整配置管理。支持 AI 模型配置、消息渠道设置(QQ、Telegram、Discord 等)、健康检查等。</p>
|
||||
</div>
|
||||
|
||||
<div class="oc-terminal-wrap">
|
||||
<div id="oc-terminal-container">
|
||||
<div class="oc-terminal-loading" id="oc-terminal-loading">
|
||||
⏳ 正在连接配置终端...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
(function() {
|
||||
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")%>';
|
||||
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) {
|
||||
try {
|
||||
var td = JSON.parse(tx.responseText);
|
||||
ptyToken = td.pty_token || '';
|
||||
} catch(e) {}
|
||||
|
||||
(new XHR()).get(statusUrl, null, function(x) {
|
||||
try {
|
||||
var d = JSON.parse(x.responseText);
|
||||
if (d.pty_running) {
|
||||
showIframe();
|
||||
} else {
|
||||
loading.innerHTML = '❌ 配置终端未运行<br/>' +
|
||||
'<span style="font-size:12px;color:#999;">请先在「基本设置」中启用并启动服务。</span>';
|
||||
}
|
||||
} catch(e) {
|
||||
loading.textContent = '检查终端状态失败';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showIframe() {
|
||||
var proto = window.location.protocol;
|
||||
var host = window.location.hostname;
|
||||
var url = proto + '//' + host + ':' + ptyPort + '/';
|
||||
if (ptyToken) url += '?pty_token=' + encodeURIComponent(ptyToken);
|
||||
|
||||
loading.style.display = 'none';
|
||||
|
||||
var iframe = document.createElement('iframe');
|
||||
iframe.id = 'oc-terminal-iframe';
|
||||
iframe.src = url;
|
||||
iframe.setAttribute('allowfullscreen', 'true');
|
||||
iframe.setAttribute('allow', 'clipboard-read; clipboard-write');
|
||||
// 防止 LuCI 父页面键盘事件干扰 iframe 内的终端输入
|
||||
iframe.addEventListener('load', function() {
|
||||
try { iframe.contentWindow.focus(); } catch(e) {}
|
||||
});
|
||||
container.appendChild(iframe);
|
||||
// 点击 iframe 区域时确保 focus 在 iframe 内
|
||||
container.addEventListener('click', function() {
|
||||
try { iframe.contentWindow.focus(); } catch(e) {}
|
||||
});
|
||||
|
||||
// 当 iframe 获得焦点时,阻止 LuCI 父页面的键盘事件处理
|
||||
// 防止按键(如 Enter/数字键)触发 LuCI 的导航或表单提交
|
||||
var termWrap = document.querySelector('.oc-terminal-wrap');
|
||||
if (termWrap) {
|
||||
termWrap.addEventListener('keydown', function(e) { e.stopPropagation(); }, true);
|
||||
termWrap.addEventListener('keypress', function(e) { e.stopPropagation(); }, true);
|
||||
termWrap.addEventListener('keyup', function(e) { e.stopPropagation(); }, true);
|
||||
}
|
||||
|
||||
// 停止 LuCI 的 XHR.poll (如果存在),防止 session 超时检测导致页面跳转
|
||||
// LuCI 的 footer.htm 可能启动了 uci.changes 轮询
|
||||
if (window.XHR && XHR.halt) {
|
||||
try { XHR.halt(); } catch(e) {}
|
||||
}
|
||||
}
|
||||
|
||||
checkAndLoadTerminal();
|
||||
})();
|
||||
//]]>
|
||||
</script>
|
||||
|
||||
<%+footer%>
|
||||
183
luci-app-openclaw/luasrc/view/openclaw/console.htm
Normal file
183
luci-app-openclaw/luasrc/view/openclaw/console.htm
Normal file
@ -0,0 +1,183 @@
|
||||
<%#
|
||||
luci-app-openclaw — Web 控制台页面
|
||||
嵌入 OpenClaw 官方 Web UI,用户可在此配置模型/渠道并直接对话
|
||||
-%>
|
||||
<%+header%>
|
||||
|
||||
<%
|
||||
local uci = require "luci.model.uci".cursor()
|
||||
local port = uci:get("openclaw", "main", "port") or "18789"
|
||||
%>
|
||||
|
||||
<style type="text/css">
|
||||
.oc-page-header { margin: 0 0 16px 0; }
|
||||
.oc-page-header h2 { font-size: 18px; font-weight: 600; color: #333; margin: 0 0 6px 0; }
|
||||
.oc-page-header p { font-size: 13px; color: #666; margin: 0; line-height: 1.6; }
|
||||
|
||||
.oc-console-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
margin-bottom: 16px;
|
||||
background: #f6f8fa;
|
||||
border: 1px solid #e1e4e8;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.oc-console-info .label { color: #888; }
|
||||
.oc-console-info .value { font-family: monospace; color: #333; font-weight: 500; }
|
||||
.oc-console-info .sep { color: #ddd; }
|
||||
.oc-console-info .btn-open {
|
||||
margin-left: auto;
|
||||
padding: 4px 14px;
|
||||
background: #4a90d9;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.oc-console-info .btn-open:hover { background: #357abd; }
|
||||
|
||||
.oc-console-wrap {
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
#oc-console-iframe { width: 100%; height: 700px; border: none; display: block; }
|
||||
|
||||
.oc-console-loading {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
height: 300px; color: #666; font-size: 14px; background: #fafafa;
|
||||
}
|
||||
.oc-console-loading .spinner {
|
||||
width: 32px; height: 32px; border: 3px solid #e0e0e0; border-top: 3px solid #4a90d9;
|
||||
border-radius: 50%; animation: oc-spin .8s linear infinite; margin-bottom: 12px;
|
||||
}
|
||||
@keyframes oc-spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
|
||||
<div class="oc-page-header">
|
||||
<h2>🖥️ Web 控制台</h2>
|
||||
<p>OpenClaw 官方 Web 管理界面 — 在这里可以配置 AI 模型、消息渠道(QQ、Telegram、Discord 等),直接与 AI 进行对话,以及管理所有功能。</p>
|
||||
</div>
|
||||
|
||||
<div class="oc-console-info">
|
||||
<span class="label">网关地址:</span>
|
||||
<span class="value" id="oc-console-addr">-</span>
|
||||
<span class="sep">|</span>
|
||||
<span class="label">活跃模型:</span>
|
||||
<span class="value" id="oc-console-model" style="color:#555;">-</span>
|
||||
<span class="sep">|</span>
|
||||
<span class="label">状态:</span>
|
||||
<span id="oc-console-status-text">检查中...</span>
|
||||
<a id="oc-console-open-btn" class="btn-open" href="#" target="_blank" rel="noopener" style="display:none;">
|
||||
↗ 新窗口打开
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="oc-console-wrap">
|
||||
<div id="oc-console-container">
|
||||
<div class="oc-console-loading" id="oc-console-loading">
|
||||
<div class="spinner"></div>
|
||||
<span>正在连接 OpenClaw 控制台...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
(function() {
|
||||
var gwPort = '<%=port%>';
|
||||
var gwToken = '';
|
||||
var statusUrl = '<%=luci.dispatcher.build_url("admin", "services", "openclaw", "status_api")%>';
|
||||
var tokenUrl = '<%=luci.dispatcher.build_url("admin", "services", "openclaw", "get_token")%>';
|
||||
var container = document.getElementById('oc-console-container');
|
||||
var loading = document.getElementById('oc-console-loading');
|
||||
var addrEl = document.getElementById('oc-console-addr');
|
||||
var modelEl = document.getElementById('oc-console-model');
|
||||
var statusTextEl = document.getElementById('oc-console-status-text');
|
||||
var openBtn = document.getElementById('oc-console-open-btn');
|
||||
|
||||
function getConsoleUrl() {
|
||||
var proto = window.location.protocol;
|
||||
var host = window.location.hostname;
|
||||
var url = proto + '//' + host + ':' + gwPort + '/';
|
||||
if (gwToken) url += '#token=' + encodeURIComponent(gwToken);
|
||||
return url;
|
||||
}
|
||||
|
||||
function checkAndLoad() {
|
||||
addrEl.textContent = window.location.hostname + ':' + gwPort;
|
||||
|
||||
// 先获取 token,再检查状态
|
||||
(new XHR()).get(tokenUrl, null, function(tx) {
|
||||
try {
|
||||
var td = JSON.parse(tx.responseText);
|
||||
gwToken = td.token || '';
|
||||
} catch(e) {}
|
||||
|
||||
(new XHR()).get(statusUrl, null, function(x) {
|
||||
try {
|
||||
var d = JSON.parse(x.responseText);
|
||||
var url = getConsoleUrl();
|
||||
if (d.active_model) {
|
||||
modelEl.textContent = d.active_model;
|
||||
}
|
||||
if (d.gateway_running) {
|
||||
statusTextEl.innerHTML = '<span style="color:#1a7f37;">● 网关运行中</span>';
|
||||
openBtn.href = url;
|
||||
openBtn.style.display = '';
|
||||
showIframe(url);
|
||||
} else if (d.gateway_starting) {
|
||||
statusTextEl.innerHTML = '<span style="color:#9a6700;">⏳ 网关正在启动</span>';
|
||||
openBtn.style.display = 'none';
|
||||
loading.innerHTML = '<div style="text-align:center;color:#666;">' +
|
||||
'<div style="font-size:40px;margin-bottom:12px;">⏳</div>' +
|
||||
'<div style="font-size:15px;margin-bottom:6px;">OpenClaw 网关正在启动中...</div>' +
|
||||
'<div style="font-size:12px;color:#999;">首次启动可能需要 2~5 分钟,请耐心等待,页面会自动刷新。</div>' +
|
||||
'</div>';
|
||||
} else {
|
||||
statusTextEl.innerHTML = '<span style="color:#cf222e;">● 网关未运行</span>';
|
||||
openBtn.style.display = 'none';
|
||||
loading.innerHTML = '<div style="text-align:center;color:#666;">' +
|
||||
'<div style="font-size:40px;margin-bottom:12px;">🧠</div>' +
|
||||
'<div style="font-size:15px;margin-bottom:6px;">OpenClaw 网关未运行</div>' +
|
||||
'<div style="font-size:12px;color:#999;">请先在「基本设置」页面启用服务并启动。</div>' +
|
||||
'</div>';
|
||||
}
|
||||
} catch(e) {
|
||||
statusTextEl.textContent = '查询失败';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showIframe(url) {
|
||||
loading.style.display = 'none';
|
||||
var existing = document.getElementById('oc-console-iframe');
|
||||
if (existing) return;
|
||||
|
||||
var iframe = document.createElement('iframe');
|
||||
iframe.id = 'oc-console-iframe';
|
||||
iframe.src = url;
|
||||
iframe.style.width = '100%';
|
||||
iframe.style.height = '700px';
|
||||
iframe.style.border = 'none';
|
||||
iframe.setAttribute('allowfullscreen', 'true');
|
||||
container.appendChild(iframe);
|
||||
}
|
||||
|
||||
checkAndLoad();
|
||||
})();
|
||||
//]]>
|
||||
</script>
|
||||
|
||||
<%+footer%>
|
||||
160
luci-app-openclaw/luasrc/view/openclaw/status.htm
Normal file
160
luci-app-openclaw/luasrc/view/openclaw/status.htm
Normal file
@ -0,0 +1,160 @@
|
||||
<%#
|
||||
luci-app-openclaw — 运行状态面板 (全面汉化 + 界面优化)
|
||||
-%>
|
||||
|
||||
<style type="text/css">
|
||||
#oc-status-panel {
|
||||
margin: 0 0 20px 0;
|
||||
padding: 0;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
|
||||
}
|
||||
#oc-status-panel .panel-title {
|
||||
background: linear-gradient(135deg, #4a90d9, #357abd);
|
||||
color: #fff;
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
#oc-status-panel .panel-body {
|
||||
padding: 0;
|
||||
}
|
||||
#oc-status-panel table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
#oc-status-panel td {
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid #f2f2f2;
|
||||
font-size: 13px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
#oc-status-panel tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
#oc-status-panel td:first-child {
|
||||
width: 120px;
|
||||
color: #888;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#oc-status-panel td:last-child {
|
||||
color: #333;
|
||||
}
|
||||
.oc-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.oc-badge-running { background: #e6f7e9; color: #1a7f37; }
|
||||
.oc-badge-stopped { background: #ffeef0; color: #cf222e; }
|
||||
.oc-badge-starting { background: #fff8c5; color: #9a6700; }
|
||||
.oc-badge-disabled { background: #f0f0f0; color: #656d76; }
|
||||
.oc-badge-unknown { background: #fff8c5; color: #9a6700; }
|
||||
.oc-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: middle; }
|
||||
.oc-dot-green { background: #1a7f37; }
|
||||
.oc-dot-red { background: #cf222e; }
|
||||
.oc-dot-gray { background: #999; }
|
||||
</style>
|
||||
|
||||
<div id="oc-status-panel">
|
||||
<div class="panel-title" id="oc-panel-title">🦞 OpenClaw 服务状态</div>
|
||||
<div class="panel-body">
|
||||
<table>
|
||||
<tr><td>运行状态</td><td id="oc-st-status"><span class="oc-badge oc-badge-unknown">加载中...</span></td></tr>
|
||||
<tr><td>网关服务</td><td id="oc-st-gateway">-</td></tr>
|
||||
<tr><td>配置终端</td><td id="oc-st-pty">-</td></tr>
|
||||
<tr><td>活跃模型</td><td id="oc-st-model">-</td></tr>
|
||||
<tr><td>消息渠道</td><td id="oc-st-channels">-</td></tr>
|
||||
<tr><td>进程 PID</td><td id="oc-st-pid">-</td></tr>
|
||||
<tr><td>内存占用</td><td id="oc-st-mem">-</td></tr>
|
||||
<tr><td>运行时间</td><td id="oc-st-uptime">-</td></tr>
|
||||
<tr><td>Node.js</td><td id="oc-st-node">-</td></tr>
|
||||
<tr><td>OpenClaw</td><td id="oc-st-oc-ver">-</td></tr>
|
||||
<tr><td>插件版本</td><td id="oc-st-plugin">-</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
(function() {
|
||||
var statusUrl = '<%=luci.dispatcher.build_url("admin", "services", "openclaw", "status_api")%>';
|
||||
|
||||
function updateStatus() {
|
||||
(new XHR()).get(statusUrl, null, function(x) {
|
||||
try {
|
||||
var d = JSON.parse(x.responseText);
|
||||
|
||||
var stEl = document.getElementById('oc-st-status');
|
||||
if (d.enabled !== '1') {
|
||||
stEl.innerHTML = '<span class="oc-badge oc-badge-disabled">已禁用</span>';
|
||||
} else if (d.gateway_running) {
|
||||
stEl.innerHTML = '<span class="oc-badge oc-badge-running">运行中</span>';
|
||||
} else if (d.gateway_starting) {
|
||||
stEl.innerHTML = '<span class="oc-badge oc-badge-starting">⏳ 正在启动...</span>';
|
||||
} else {
|
||||
stEl.innerHTML = '<span class="oc-badge oc-badge-stopped">已停止</span>';
|
||||
}
|
||||
|
||||
var gwEl = document.getElementById('oc-st-gateway');
|
||||
if (d.gateway_running) {
|
||||
gwEl.innerHTML = '<span class="oc-dot oc-dot-green"></span>监听中 :' + d.port;
|
||||
} else if (d.gateway_starting) {
|
||||
gwEl.innerHTML = '<span class="oc-dot oc-dot-gray"></span>初始化中,首次启动可能需要 2~5 分钟...';
|
||||
} else {
|
||||
gwEl.innerHTML = '<span class="oc-dot oc-dot-red"></span>未监听';
|
||||
}
|
||||
|
||||
var ptyEl = document.getElementById('oc-st-pty');
|
||||
if (d.pty_running) {
|
||||
ptyEl.innerHTML = '<span class="oc-dot oc-dot-green"></span>监听中 :' + d.pty_port;
|
||||
} else {
|
||||
ptyEl.innerHTML = '<span class="oc-dot oc-dot-gray"></span>未监听';
|
||||
}
|
||||
|
||||
document.getElementById('oc-st-pid').textContent = d.pid || '-';
|
||||
|
||||
var channelsEl = document.getElementById('oc-st-channels');
|
||||
if (d.channels) {
|
||||
channelsEl.innerHTML = '<span style="color:#1a7f37;font-weight:500;">' + d.channels + '</span>';
|
||||
} else {
|
||||
channelsEl.textContent = '未配置';
|
||||
}
|
||||
|
||||
var modelEl = document.getElementById('oc-st-model');
|
||||
if (d.active_model) {
|
||||
modelEl.innerHTML = '<code style="padding:2px 8px;background:#f0f3f6;border-radius:4px;font-size:12px;">' + d.active_model + '</code>';
|
||||
} else {
|
||||
modelEl.textContent = '未配置';
|
||||
}
|
||||
|
||||
var memEl = document.getElementById('oc-st-mem');
|
||||
if (d.memory_kb > 0) {
|
||||
var mb = (d.memory_kb / 1024).toFixed(1);
|
||||
memEl.textContent = mb + ' MB';
|
||||
} else {
|
||||
memEl.textContent = '-';
|
||||
}
|
||||
|
||||
document.getElementById('oc-st-uptime').textContent = d.uptime || '-';
|
||||
document.getElementById('oc-st-node').textContent = d.node_version || '未安装';
|
||||
document.getElementById('oc-st-oc-ver').textContent = d.oc_version ? ('v' + d.oc_version) : '未安装';
|
||||
document.getElementById('oc-st-plugin').textContent = d.plugin_version ? ('v' + d.plugin_version) : '-';
|
||||
} catch(e) {
|
||||
document.getElementById('oc-st-status').innerHTML = '<span class="oc-badge oc-badge-unknown">查询失败</span>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateStatus();
|
||||
setInterval(updateStatus, 5000);
|
||||
})();
|
||||
//]]>
|
||||
</script>
|
||||
482
luci-app-openclaw/po/zh-cn/openclaw.po
Normal file
482
luci-app-openclaw/po/zh-cn/openclaw.po
Normal file
@ -0,0 +1,482 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Language: zh-cn\n"
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# controller/openclaw.lua — 菜单 & API
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
msgid "OpenClaw"
|
||||
msgstr "OpenClaw AI 网关"
|
||||
|
||||
msgid "OpenClaw AI Gateway"
|
||||
msgstr "OpenClaw AI 网关"
|
||||
|
||||
msgid "Basic Settings"
|
||||
msgstr "基本设置"
|
||||
|
||||
msgid "Model Settings"
|
||||
msgstr "模型设置"
|
||||
|
||||
msgid "Channel Settings"
|
||||
msgstr "渠道设置"
|
||||
|
||||
msgid "Advanced"
|
||||
msgstr "高级配置"
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# basic.lua — 基本设置页
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
msgid "OpenClaw is an AI coding agent gateway that supports GitHub Copilot, Claude, GPT, Gemini and various messaging channels."
|
||||
msgstr "OpenClaw 是一个 AI 编程代理网关,支持 GitHub Copilot、Claude、GPT、Gemini 等大模型以及 QQ、Telegram、Discord 等多种消息渠道。"
|
||||
|
||||
msgid "Enable"
|
||||
msgstr "启用"
|
||||
|
||||
msgid "Enable OpenClaw AI Gateway"
|
||||
msgstr "启用 OpenClaw AI 网关"
|
||||
|
||||
msgid "Gateway Port"
|
||||
msgstr "网关端口"
|
||||
|
||||
msgid "OpenClaw API listening port"
|
||||
msgstr "OpenClaw API 监听端口"
|
||||
|
||||
msgid "Bind Interface"
|
||||
msgstr "绑定接口"
|
||||
|
||||
msgid "Network binding mode"
|
||||
msgstr "网络绑定模式"
|
||||
|
||||
msgid "LAN only"
|
||||
msgstr "仅局域网"
|
||||
|
||||
msgid "Loopback only"
|
||||
msgstr "仅本地回环"
|
||||
|
||||
msgid "All interfaces (WAN accessible)"
|
||||
msgstr "所有接口(WAN 可访问)"
|
||||
|
||||
msgid "Auto"
|
||||
msgstr "自动"
|
||||
|
||||
msgid "Config Terminal Port"
|
||||
msgstr "配置终端端口"
|
||||
|
||||
msgid "Web PTY terminal port for oc-config"
|
||||
msgstr "oc-config Web PTY 终端端口"
|
||||
|
||||
msgid "Node.js Version"
|
||||
msgstr "Node.js 版本"
|
||||
|
||||
msgid "Node.js version to download (requires v22+)"
|
||||
msgstr "要下载的 Node.js 版本(需要 v22+)"
|
||||
|
||||
msgid "Authentication"
|
||||
msgstr "认证"
|
||||
|
||||
msgid "Gateway Token"
|
||||
msgstr "网关令牌"
|
||||
|
||||
msgid "Authentication token for API access. Keep it secret."
|
||||
msgstr "API 访问认证令牌,请妥善保管。"
|
||||
|
||||
msgid "Reset Token"
|
||||
msgstr "重置令牌"
|
||||
|
||||
msgid "Generate New Token"
|
||||
msgstr "生成新令牌"
|
||||
|
||||
msgid "Are you sure you want to generate a new token? Current connections will be disconnected."
|
||||
msgstr "确定要生成新令牌吗?当前连接将会断开。"
|
||||
|
||||
msgid "Generating..."
|
||||
msgstr "正在生成..."
|
||||
|
||||
msgid "Token updated"
|
||||
msgstr "令牌已更新"
|
||||
|
||||
msgid "Quick Actions"
|
||||
msgstr "快捷操作"
|
||||
|
||||
msgid "Download Node.js and install OpenClaw"
|
||||
msgstr "下载 Node.js 并安装 OpenClaw"
|
||||
|
||||
msgid "Install Environment"
|
||||
msgstr "安装环境"
|
||||
|
||||
msgid "Restart Service"
|
||||
msgstr "重启服务"
|
||||
|
||||
msgid "Stop Service"
|
||||
msgstr "停止服务"
|
||||
|
||||
msgid "Advanced Configuration"
|
||||
msgstr "高级配置"
|
||||
|
||||
msgid "Executing..."
|
||||
msgstr "正在执行..."
|
||||
|
||||
msgid "completed"
|
||||
msgstr "已完成"
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# model.lua — 模型设置页
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
msgid "Configure your AI model provider and API credentials. After saving, the configuration will be synced to OpenClaw."
|
||||
msgstr "配置您的 AI 模型提供商和 API 凭证。保存后配置将自动同步到 OpenClaw。"
|
||||
|
||||
msgid "AI Model Provider"
|
||||
msgstr "AI 模型提供商"
|
||||
|
||||
msgid "Provider"
|
||||
msgstr "提供商"
|
||||
|
||||
msgid "Select your AI model provider"
|
||||
msgstr "选择您的 AI 模型提供商"
|
||||
|
||||
msgid "-- Not configured --"
|
||||
msgstr "-- 未配置 --"
|
||||
|
||||
msgid "Multi-provider aggregator"
|
||||
msgstr "多提供商聚合器"
|
||||
|
||||
msgid "Custom OpenAI-compatible API"
|
||||
msgstr "自定义 OpenAI 兼容 API"
|
||||
|
||||
msgid "API Key"
|
||||
msgstr "API 密钥"
|
||||
|
||||
msgid "Your provider API key"
|
||||
msgstr "您的提供商 API 密钥"
|
||||
|
||||
msgid "API Base URL"
|
||||
msgstr "API 基础地址"
|
||||
|
||||
msgid "Custom OpenAI-compatible API endpoint"
|
||||
msgstr "自定义 OpenAI 兼容 API 端点"
|
||||
|
||||
msgid "Active Model"
|
||||
msgstr "当前模型"
|
||||
|
||||
msgid "Model"
|
||||
msgstr "模型"
|
||||
|
||||
msgid "Multimodal flagship (recommended)"
|
||||
msgstr "多模态旗舰(推荐)"
|
||||
|
||||
msgid "Cost effective"
|
||||
msgstr "经济实惠"
|
||||
|
||||
msgid "Reasoning flagship"
|
||||
msgstr "推理旗舰"
|
||||
|
||||
msgid "Reasoning lightweight"
|
||||
msgstr "轻量推理"
|
||||
|
||||
msgid "Reasoning classic"
|
||||
msgstr "经典推理"
|
||||
|
||||
msgid "Latest Sonnet (recommended)"
|
||||
msgstr "最新 Sonnet(推荐)"
|
||||
|
||||
msgid "Top reasoning"
|
||||
msgstr "顶级推理"
|
||||
|
||||
msgid "Fast"
|
||||
msgstr "快速"
|
||||
|
||||
msgid "Flagship reasoning (recommended)"
|
||||
msgstr "旗舰推理(推荐)"
|
||||
|
||||
msgid "Fast balanced"
|
||||
msgstr "快速均衡"
|
||||
|
||||
msgid "Low latency"
|
||||
msgstr "低延迟"
|
||||
|
||||
msgid "General chat"
|
||||
msgstr "通用对话"
|
||||
|
||||
msgid "Deep reasoning"
|
||||
msgstr "深度推理"
|
||||
|
||||
msgid "(ultra fast)"
|
||||
msgstr "(超快)"
|
||||
|
||||
msgid "Model Name"
|
||||
msgstr "模型名称"
|
||||
|
||||
msgid "For multi-provider setup, advanced model allowlist, or OAuth login (Qwen Portal, Gemini CLI), use the"
|
||||
msgstr "如需多提供商配置、高级模型白名单或 OAuth 登录(Qwen Portal、Gemini CLI),请使用"
|
||||
|
||||
msgid "page which provides the full interactive wizard."
|
||||
msgstr "页面,其中提供完整的交互式配置向导。"
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# channel.lua — 渠道设置页
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
msgid "Configure messaging channels for OpenClaw. Telegram is most popular."
|
||||
msgstr "配置 OpenClaw 的消息渠道。Telegram 是最常用的渠道。"
|
||||
|
||||
msgid "Bot Token"
|
||||
msgstr "Bot 令牌"
|
||||
|
||||
msgid "Get from @BotFather on Telegram: /newbot → copy token"
|
||||
msgstr "从 Telegram 的 @BotFather 获取:/newbot → 复制令牌"
|
||||
|
||||
msgid "After setting Bot Token, restart the service, then send /start to your Bot on Telegram."
|
||||
msgstr "设置 Bot 令牌后,重启服务,然后在 Telegram 上向您的 Bot 发送 /start。"
|
||||
|
||||
msgid "For pairing wizard, go to"
|
||||
msgstr "如需配对向导,请前往"
|
||||
|
||||
msgid "Get from discord.com/developers/applications → Bot → Reset Token"
|
||||
msgstr "从 discord.com/developers/applications → Bot → Reset Token 获取"
|
||||
|
||||
msgid "Feishu (Lark)"
|
||||
msgstr "飞书"
|
||||
|
||||
msgid "App ID"
|
||||
msgstr "应用 ID"
|
||||
|
||||
msgid "Get from open.feishu.cn → Create App"
|
||||
msgstr "从 open.feishu.cn → 创建应用 获取"
|
||||
|
||||
msgid "App Secret"
|
||||
msgstr "应用密钥"
|
||||
|
||||
msgid "Get from api.slack.com/apps → Create App (xoxb-...)"
|
||||
msgstr "从 api.slack.com/apps → Create App 获取(xoxb-...)"
|
||||
|
||||
msgid "WhatsApp requires QR code pairing through the Web console."
|
||||
msgstr "WhatsApp 需要通过 Web 控制台扫码配对。"
|
||||
|
||||
msgid "After starting the service, visit:"
|
||||
msgstr "启动服务后,请访问:"
|
||||
|
||||
msgid "Then go to Channels → WhatsApp to scan QR code."
|
||||
msgstr "然后进入 Channels → WhatsApp 扫描二维码。"
|
||||
|
||||
msgid "For official interactive channel wizard, Telegram pairing helper, or Signal/Line/MS Teams configuration, use the"
|
||||
msgstr "如需官方交互式渠道向导、Telegram 配对助手或 Signal/Line/MS Teams 配置,请使用"
|
||||
|
||||
msgid "page."
|
||||
msgstr "页面。"
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# status.htm — 状态面板
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
msgid "Service Status"
|
||||
msgstr "服务状态"
|
||||
|
||||
msgid "Status"
|
||||
msgstr "状态"
|
||||
|
||||
msgid "Loading..."
|
||||
msgstr "加载中..."
|
||||
|
||||
msgid "Gateway"
|
||||
msgstr "网关"
|
||||
|
||||
msgid "Config Terminal"
|
||||
msgstr "配置终端"
|
||||
|
||||
msgid "Memory"
|
||||
msgstr "内存"
|
||||
|
||||
msgid "Uptime"
|
||||
msgstr "运行时间"
|
||||
|
||||
msgid "Disabled"
|
||||
msgstr "已禁用"
|
||||
|
||||
msgid "Running"
|
||||
msgstr "运行中"
|
||||
|
||||
msgid "Stopped"
|
||||
msgstr "已停止"
|
||||
|
||||
msgid "Listening"
|
||||
msgstr "监听中"
|
||||
|
||||
msgid "Not listening"
|
||||
msgstr "未监听"
|
||||
|
||||
msgid "Not installed"
|
||||
msgstr "未安装"
|
||||
|
||||
msgid "Error"
|
||||
msgstr "错误"
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# advanced.htm — 高级配置页
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
msgid "What is this?"
|
||||
msgstr "这是什么?"
|
||||
|
||||
msgid "This page embeds the OpenClaw interactive configuration terminal (oc-config). It provides the full-featured management menu including:"
|
||||
msgstr "此页面嵌入了 OpenClaw 交互式配置终端(oc-config),提供完整的管理菜单,包括:"
|
||||
|
||||
msgid "Multi-provider AI model setup with OAuth support"
|
||||
msgstr "支持 OAuth 的多提供商 AI 模型配置"
|
||||
|
||||
msgid "Telegram pairing wizard"
|
||||
msgstr "Telegram 配对向导"
|
||||
|
||||
msgid "Health check and diagnostics"
|
||||
msgstr "健康检查和诊断"
|
||||
|
||||
msgid "Raw configuration file editor"
|
||||
msgstr "原始配置文件编辑器"
|
||||
|
||||
msgid "Factory reset"
|
||||
msgstr "恢复出厂设置"
|
||||
|
||||
msgid "Configuration Terminal"
|
||||
msgstr "配置终端"
|
||||
|
||||
msgid "Checking..."
|
||||
msgstr "检查中..."
|
||||
|
||||
msgid "Reload"
|
||||
msgstr "刷新"
|
||||
|
||||
msgid "Connecting to configuration terminal..."
|
||||
msgstr "正在连接配置终端..."
|
||||
|
||||
msgid "Available menu options in terminal"
|
||||
msgstr "终端中可用的菜单选项"
|
||||
|
||||
msgid "View current config"
|
||||
msgstr "查看当前配置"
|
||||
|
||||
msgid "Configure AI model provider"
|
||||
msgstr "配置 AI 模型提供商"
|
||||
|
||||
msgid "Set active model"
|
||||
msgstr "设置当前模型"
|
||||
|
||||
msgid "Configure channels"
|
||||
msgstr "配置消息渠道"
|
||||
|
||||
msgid "pairing wizard"
|
||||
msgstr "配对向导"
|
||||
|
||||
msgid "Health check"
|
||||
msgstr "健康检查"
|
||||
|
||||
msgid "Restart Gateway"
|
||||
msgstr "重启网关"
|
||||
|
||||
msgid "View/edit raw config"
|
||||
msgstr "查看/编辑原始配置"
|
||||
|
||||
msgid "Reset to defaults"
|
||||
msgstr "恢复默认设置"
|
||||
|
||||
msgid "Connected"
|
||||
msgstr "已连接"
|
||||
|
||||
msgid "Terminal not running"
|
||||
msgstr "终端未运行"
|
||||
|
||||
msgid "Web PTY terminal is not running."
|
||||
msgstr "Web PTY 终端未运行。"
|
||||
|
||||
msgid "Please enable and start the service first from Basic Settings page."
|
||||
msgstr "请先在基本设置页面启用并启动服务。"
|
||||
|
||||
msgid "Failed to check terminal status"
|
||||
msgstr "检查终端状态失败"
|
||||
|
||||
msgid "Reconnecting..."
|
||||
msgstr "正在重新连接..."
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# 其他
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
msgid "Port"
|
||||
msgstr "端口"
|
||||
|
||||
msgid "PID"
|
||||
msgstr "进程 ID"
|
||||
|
||||
msgid "Node.js"
|
||||
msgstr "Node.js"
|
||||
|
||||
msgid "Web Console"
|
||||
msgstr "Web 控制台"
|
||||
|
||||
msgid "Verify Token"
|
||||
msgstr "验证令牌"
|
||||
|
||||
msgid "Allow insecure auth"
|
||||
msgstr "允许不安全认证"
|
||||
|
||||
msgid "Disable device auth"
|
||||
msgstr "禁用设备认证"
|
||||
|
||||
msgid "Allow host header fallback"
|
||||
msgstr "允许主机头回退"
|
||||
|
||||
msgid "Docker sandbox"
|
||||
msgstr "Docker 沙箱"
|
||||
|
||||
msgid "Enable Docker sandbox for code execution"
|
||||
msgstr "启用 Docker 沙箱执行代码"
|
||||
|
||||
msgid "Node.js Mirror"
|
||||
msgstr "Node.js 镜像源"
|
||||
|
||||
msgid "China mirror (npmmirror.com)"
|
||||
msgstr "中国镜像 (npmmirror.com)"
|
||||
|
||||
msgid "Official (nodejs.org)"
|
||||
msgstr "官方 (nodejs.org)"
|
||||
|
||||
msgid "Check Environment"
|
||||
msgstr "检查环境"
|
||||
|
||||
msgid "Install / Upgrade"
|
||||
msgstr "安装 / 升级"
|
||||
|
||||
msgid "Telegram"
|
||||
msgstr "Telegram"
|
||||
|
||||
msgid "Discord"
|
||||
msgstr "Discord"
|
||||
|
||||
msgid "Feishu"
|
||||
msgstr "飞书"
|
||||
|
||||
msgid "Slack"
|
||||
msgstr "Slack"
|
||||
|
||||
msgid "WhatsApp"
|
||||
msgstr "WhatsApp"
|
||||
|
||||
msgid "Regenerate Token"
|
||||
msgstr "重新生成令牌"
|
||||
|
||||
msgid "Custom API Base URL"
|
||||
msgstr "自定义 API 基础地址"
|
||||
|
||||
msgid "Auth Token"
|
||||
msgstr "认证令牌"
|
||||
|
||||
msgid "Gateway authentication token"
|
||||
msgstr "网关认证令牌"
|
||||
|
||||
msgid "Web PTY terminal port for oc-config"
|
||||
msgstr "oc-config Web PTY 终端端口"
|
||||
|
||||
msgid "Select the AI model provider to use"
|
||||
msgstr "选择要使用的 AI 模型提供商"
|
||||
1
luci-app-openclaw/po/zh_Hans
Symbolic link
1
luci-app-openclaw/po/zh_Hans
Symbolic link
@ -0,0 +1 @@
|
||||
zh-cn
|
||||
6
luci-app-openclaw/root/etc/config/openclaw
Normal file
6
luci-app-openclaw/root/etc/config/openclaw
Normal file
@ -0,0 +1,6 @@
|
||||
config openclaw 'main'
|
||||
option enabled '0'
|
||||
option port '18789'
|
||||
option bind 'lan'
|
||||
option token ''
|
||||
option pty_port '18793'
|
||||
487
luci-app-openclaw/root/etc/init.d/openclaw
Executable file
487
luci-app-openclaw/root/etc/init.d/openclaw
Executable file
@ -0,0 +1,487 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
# luci-app-openclaw — procd init 脚本
|
||||
|
||||
USE_PROCD=1
|
||||
START=99
|
||||
STOP=10
|
||||
|
||||
EXTRA_COMMANDS="setup status_service restart_gateway"
|
||||
EXTRA_HELP=" setup 下载 Node.js 并安装 OpenClaw
|
||||
status_service 显示服务状态
|
||||
restart_gateway 仅重启 Gateway 实例 (不影响 Web PTY)"
|
||||
|
||||
NODE_BASE="/opt/openclaw/node"
|
||||
OC_GLOBAL="/opt/openclaw/global"
|
||||
OC_DATA="/opt/openclaw/data"
|
||||
|
||||
# ── OverlayFS 兼容性修复 ──
|
||||
# Docker bind mount (/overlay/upper/opt/docker) 会导致 /opt 不可写
|
||||
# 解决: bind mount upper 层的 /opt 到合并视图的 /opt
|
||||
_oc_fix_opt() {
|
||||
mkdir -p /opt/openclaw/.probe 2>/dev/null && { rmdir /opt/openclaw/.probe 2>/dev/null; return 0; }
|
||||
if [ -d /overlay/upper/opt ]; then
|
||||
mkdir -p /overlay/upper/opt/openclaw 2>/dev/null
|
||||
mount --bind /overlay/upper/opt /opt 2>/dev/null && return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
_oc_fix_opt
|
||||
|
||||
NODE_BIN="${NODE_BASE}/bin/node"
|
||||
CONFIG_FILE="${OC_DATA}/.openclaw/openclaw.json"
|
||||
|
||||
get_oc_entry() {
|
||||
local search_dirs="${OC_GLOBAL}/lib/node_modules/openclaw
|
||||
${OC_GLOBAL}/node_modules/openclaw
|
||||
${NODE_BASE}/lib/node_modules/openclaw"
|
||||
|
||||
# pnpm 全局安装路径形如: $OC_GLOBAL/5/node_modules/openclaw
|
||||
for ver_dir in "${OC_GLOBAL}"/*/node_modules/openclaw; do
|
||||
[ -d "$ver_dir" ] && search_dirs="$search_dirs
|
||||
$ver_dir"
|
||||
done
|
||||
|
||||
# v2026.3.8: 跟随符号链接解析真实包路径 (pnpm store / symlinked wrappers)
|
||||
for link_dir in "${OC_GLOBAL}/lib/node_modules/openclaw" "${OC_GLOBAL}/node_modules/openclaw"; do
|
||||
[ -L "$link_dir" ] && {
|
||||
local real_dir=$(readlink -f "$link_dir" 2>/dev/null)
|
||||
[ -n "$real_dir" ] && [ -d "$real_dir" ] && search_dirs="$search_dirs
|
||||
$real_dir"
|
||||
}
|
||||
done
|
||||
|
||||
local d _tmpf
|
||||
_tmpf=$(mktemp)
|
||||
echo "$search_dirs" > "$_tmpf"
|
||||
while read -r d; do
|
||||
[ -z "$d" ] && continue
|
||||
if [ -f "${d}/openclaw.mjs" ]; then
|
||||
echo "${d}/openclaw.mjs"
|
||||
rm -f "$_tmpf"
|
||||
return
|
||||
elif [ -f "${d}/dist/cli.js" ]; then
|
||||
echo "${d}/dist/cli.js"
|
||||
rm -f "$_tmpf"
|
||||
return
|
||||
fi
|
||||
done < "$_tmpf"
|
||||
rm -f "$_tmpf"
|
||||
}
|
||||
|
||||
patch_iframe_headers() {
|
||||
# 移除 OpenClaw 网关的 X-Frame-Options 和 frame-ancestors 限制,允许 LuCI iframe 嵌入
|
||||
# v2026.3.8: 资产路径可能通过符号链接解析,需同时搜索 OC_GLOBAL 和 NODE_BASE
|
||||
local gw_js
|
||||
for search_root in "${OC_GLOBAL}" "${NODE_BASE}/lib"; do
|
||||
[ -d "$search_root" ] || continue
|
||||
for f in $(find "$search_root" -name "gateway-cli-*.js" -type f 2>/dev/null); do
|
||||
if grep -q "X-Frame-Options.*DENY" "$f" 2>/dev/null; then
|
||||
sed -i "s|res.setHeader(\"X-Frame-Options\", \"DENY\");|// res.setHeader(\"X-Frame-Options\", \"DENY\"); // patched by luci-app-openclaw|g" "$f"
|
||||
sed -i "s|\"frame-ancestors 'none'\"|\"frame-ancestors *\"|g" "$f"
|
||||
logger -t openclaw "Patched iframe headers in $f"
|
||||
fi
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
sync_uci_to_json() {
|
||||
# 将 UCI 配置同步到 openclaw.json,同时确保 token 双向同步
|
||||
local port bind token
|
||||
port=$(uci -q get openclaw.main.port || echo "18789")
|
||||
bind=$(uci -q get openclaw.main.bind || echo "lan")
|
||||
token=$(uci -q get openclaw.main.token || echo "")
|
||||
|
||||
# 确保配置目录和文件存在 (路径已在脚本开头做 OverlayFS 重映射)
|
||||
mkdir -p "$(dirname "$CONFIG_FILE")" 2>/dev/null
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
echo '{}' > "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
# UCI 没有 token 时,尝试从已有的 JSON 读取
|
||||
if [ -z "$token" ] && [ -x "$NODE_BIN" ]; then
|
||||
token=$("$NODE_BIN" -e "
|
||||
try{const d=JSON.parse(require('fs').readFileSync('${CONFIG_FILE}','utf8'));
|
||||
if(d.gateway&&d.gateway.auth&&d.gateway.auth.token)process.stdout.write(d.gateway.auth.token);}catch(e){}
|
||||
" 2>/dev/null)
|
||||
fi
|
||||
|
||||
# 如果仍然没有 token,生成一个新的
|
||||
if [ -z "$token" ]; then
|
||||
token=$(head -c 24 /dev/urandom | hexdump -e '24/1 "%02x"' 2>/dev/null || openssl rand -hex 24 2>/dev/null || dd if=/dev/urandom bs=24 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n' | head -c 48)
|
||||
fi
|
||||
|
||||
# 确保 token 写回 UCI
|
||||
local uci_token
|
||||
uci_token=$(uci -q get openclaw.main.token || echo "")
|
||||
if [ "$uci_token" != "$token" ]; then
|
||||
uci set openclaw.main.token="$token"
|
||||
uci commit openclaw 2>/dev/null
|
||||
fi
|
||||
|
||||
# 使用 Node.js 写入 JSON (如果可用)
|
||||
if [ -x "$NODE_BIN" ]; then
|
||||
OC_SYNC_PORT="$port" OC_SYNC_BIND="$bind" OC_SYNC_TOKEN="$token" OC_SYNC_FILE="$CONFIG_FILE" \
|
||||
"$NODE_BIN" -e "
|
||||
const fs=require('fs');
|
||||
const f=process.env.OC_SYNC_FILE;
|
||||
let d={};
|
||||
try{d=JSON.parse(fs.readFileSync(f,'utf8'));}catch(e){}
|
||||
if(!d.gateway)d.gateway={};
|
||||
d.gateway.port=parseInt(process.env.OC_SYNC_PORT)||18789;
|
||||
d.gateway.bind=process.env.OC_SYNC_BIND||'lan';
|
||||
d.gateway.mode='local';
|
||||
if(!d.gateway.auth)d.gateway.auth={};
|
||||
d.gateway.auth.mode='token';
|
||||
d.gateway.auth.token=process.env.OC_SYNC_TOKEN||'';
|
||||
if(!d.gateway.controlUi)d.gateway.controlUi={};
|
||||
d.gateway.controlUi.allowInsecureAuth=true;
|
||||
d.gateway.controlUi.dangerouslyDisableDeviceAuth=true;
|
||||
d.gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true;
|
||||
// 清理 v2026.3.1+ 已废弃的字段,避免配置验证失败
|
||||
delete d.gateway.name;
|
||||
delete d.gateway.bonjour;
|
||||
delete d.gateway.plugins;
|
||||
// v2026.3.2: 禁用 ACP dispatch 防止路由器内存溢出
|
||||
if(!d.acp)d.acp={};
|
||||
if(!d.acp.dispatch)d.acp.dispatch={};
|
||||
d.acp.dispatch.enabled=false;
|
||||
// v2026.3.2: tools.profile 默认改为 messaging,路由器场景需强制 coding
|
||||
if(!d.tools)d.tools={};
|
||||
d.tools.profile='coding';
|
||||
// 禁用 OpenClaw 内置更新检查,防止 Control UI 显示升级横幅
|
||||
if(!d.update)d.update={};
|
||||
d.update.checkOnStart=false;
|
||||
// v2026.3.2: 迁移 Ollama provider 到原生 API
|
||||
if(d.models&&d.models.providers&&d.models.providers.ollama){const ol=d.models.providers.ollama;if(ol.api==='openai-chat-completions'||ol.api==='openai-completions')ol.api='ollama';if(ol.baseUrl&&ol.baseUrl.endsWith('/v1'))ol.baseUrl=ol.baseUrl.replace(/\/v1$/,'');if(ol.apiKey==='ollama')ol.apiKey='ollama-local';}
|
||||
// v2026.3.7: 清理已废弃的顶层配置键 (loadConfig() 现在会严格校验)
|
||||
['cli','commands.native','commands.nativeSkills','commands.ownerDisplay'].forEach(k=>{const ks=k.split('.');let o=d;for(let i=0;i<ks.length-1;i++){if(!o[ks[i]])return;o=o[ks[i]];}delete o[ks[ks.length-1]];});
|
||||
fs.writeFileSync(f,JSON.stringify(d,null,2));
|
||||
" 2>/dev/null
|
||||
fi
|
||||
|
||||
chown openclaw:openclaw "$CONFIG_FILE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
start_service() {
|
||||
local enabled port bind pty_port
|
||||
config_load openclaw
|
||||
config_get enabled main enabled "0"
|
||||
config_get port main port "18789"
|
||||
config_get bind main bind "lan"
|
||||
config_get pty_port main pty_port "18793"
|
||||
|
||||
[ "$enabled" = "1" ] || {
|
||||
echo "openclaw 已禁用。请在 /etc/config/openclaw 中设置 enabled 为 1"
|
||||
return 0
|
||||
}
|
||||
|
||||
# 检查 Node.js
|
||||
if [ ! -x "$NODE_BIN" ]; then
|
||||
echo "未找到 Node.js: $NODE_BIN"
|
||||
echo "请运行: openclaw-env setup"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 检查 openclaw 入口
|
||||
local oc_entry
|
||||
oc_entry=$(get_oc_entry)
|
||||
if [ -z "$oc_entry" ]; then
|
||||
echo "OpenClaw 未安装。请运行: openclaw-env setup"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 同步 UCI 到 JSON
|
||||
sync_uci_to_json
|
||||
|
||||
# 修复数据目录权限 (防止 root 用户操作后留下无法读取的文件)
|
||||
chown -R openclaw:openclaw "$OC_DATA" 2>/dev/null || true
|
||||
|
||||
# Patch iframe 安全头,允许 LuCI 嵌入
|
||||
patch_iframe_headers
|
||||
|
||||
# 将 UCI bind 映射到 openclaw gateway --bind 参数
|
||||
local gw_bind="loopback"
|
||||
case "$bind" in
|
||||
lan) gw_bind="lan" ;;
|
||||
loopback) gw_bind="loopback" ;;
|
||||
all) gw_bind="custom" ;; # custom = 0.0.0.0
|
||||
*) gw_bind="$bind" ;;
|
||||
esac
|
||||
|
||||
# 确保网关端口未被残留进程占用 (防止 restart 时 crash loop)
|
||||
_ensure_port_free() {
|
||||
local p="$1" max_wait="${2:-5}" i=0
|
||||
while [ $i -lt $max_wait ]; do
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -tulnp 2>/dev/null | grep -q ":${p} " || return 0
|
||||
else
|
||||
netstat -tulnp 2>/dev/null | grep -q ":${p} " || return 0
|
||||
fi
|
||||
# 尝试杀掉占用端口的 gateway 进程
|
||||
if [ $i -eq 0 ]; then
|
||||
local occ_pid
|
||||
for occ_pid in $(pgrep -f "openclaw-gateway" 2>/dev/null); do
|
||||
kill "$occ_pid" 2>/dev/null
|
||||
done
|
||||
fi
|
||||
sleep 1
|
||||
i=$((i + 1))
|
||||
done
|
||||
# 最后手段: SIGKILL
|
||||
local port_pid=""
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
port_pid=$(ss -tulnp 2>/dev/null | grep ":${p} " | sed -n 's/.*pid=\([0-9]*\).*/\1/p' | head -1)
|
||||
else
|
||||
port_pid=$(netstat -tulnp 2>/dev/null | grep ":${p} " | sed -n 's|.* \([0-9]*\)/.*|\1|p' | head -1)
|
||||
fi
|
||||
[ -n "$port_pid" ] && kill -9 "$port_pid" 2>/dev/null && sleep 1
|
||||
return 0
|
||||
}
|
||||
_ensure_port_free "$port"
|
||||
|
||||
# 启动 OpenClaw Gateway (主服务, 前台运行)
|
||||
procd_open_instance "gateway"
|
||||
procd_set_param command "$NODE_BIN" "$oc_entry" gateway run \
|
||||
--port "$port" --bind "$gw_bind"
|
||||
procd_set_param env \
|
||||
HOME="$OC_DATA" \
|
||||
OPENCLAW_HOME="$OC_DATA" \
|
||||
OPENCLAW_STATE_DIR="${OC_DATA}/.openclaw" \
|
||||
OPENCLAW_CONFIG_PATH="$CONFIG_FILE" \
|
||||
NODE_ICU_DATA="${NODE_BASE}/share/icu" \
|
||||
NODE_BASE="$NODE_BASE" \
|
||||
OC_GLOBAL="$OC_GLOBAL" \
|
||||
OC_DATA="$OC_DATA" \
|
||||
PATH="${NODE_BASE}/bin:${OC_GLOBAL}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
procd_set_param user openclaw
|
||||
procd_set_param respawn 3600 10 5
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
procd_set_param pidfile /var/run/openclaw.pid
|
||||
procd_close_instance
|
||||
|
||||
# 启动 Web PTY 配置终端 (辅助服务)
|
||||
# 复用已有 PTY token,只在不存在时才生成新的
|
||||
# 这样避免服务重启时 PTY WebSocket 连接因 token 变化而断开
|
||||
local pty_token
|
||||
pty_token=$(uci -q get openclaw.main.pty_token || echo "")
|
||||
if [ -z "$pty_token" ]; then
|
||||
pty_token=$(head -c 16 /dev/urandom | hexdump -e '16/1 "%02x"' 2>/dev/null || openssl rand -hex 16 2>/dev/null || echo "pty_$(date +%s)")
|
||||
uci set openclaw.main.pty_token="$pty_token"
|
||||
uci commit openclaw 2>/dev/null
|
||||
fi
|
||||
|
||||
procd_open_instance "pty"
|
||||
procd_set_param command "$NODE_BIN" /usr/share/openclaw/web-pty.js
|
||||
procd_set_param env \
|
||||
OC_CONFIG_PORT="$pty_port" \
|
||||
OC_PTY_TOKEN="$pty_token" \
|
||||
OC_CONFIG_SCRIPT="/usr/share/openclaw/oc-config.sh" \
|
||||
NODE_ICU_DATA="${NODE_BASE}/share/icu" \
|
||||
NODE_BASE="$NODE_BASE" \
|
||||
OC_GLOBAL="$OC_GLOBAL" \
|
||||
OC_DATA="$OC_DATA" \
|
||||
HOME="$OC_DATA" \
|
||||
OPENCLAW_HOME="$OC_DATA" \
|
||||
OPENCLAW_STATE_DIR="${OC_DATA}/.openclaw" \
|
||||
OPENCLAW_CONFIG_PATH="$CONFIG_FILE" \
|
||||
PATH="${NODE_BASE}/bin:${OC_GLOBAL}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
procd_set_param respawn 3600 5 5
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
procd_set_param pidfile /var/run/openclaw-pty.pid
|
||||
procd_close_instance
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
# procd 会自动停止它管理的主进程 (openclaw)
|
||||
# 但 openclaw 会 fork 出 openclaw-gateway 子进程实际监听端口
|
||||
# procd 不一定能正确追踪并杀掉子进程树,需要手动清理
|
||||
|
||||
local port
|
||||
port=$(uci -q get openclaw.main.port || echo "18789")
|
||||
|
||||
# 杀掉所有 openclaw / openclaw-gateway 残留进程
|
||||
# (排除 web-pty.js 和 oc-config.sh,它们由 pty 实例管理)
|
||||
local pid
|
||||
for pid in $(pgrep -f "openclaw-gateway" 2>/dev/null) \
|
||||
$(pgrep -f "openclaw.*gateway.*run" 2>/dev/null); do
|
||||
kill "$pid" 2>/dev/null
|
||||
done
|
||||
|
||||
# 等待端口真正释放 (最多 8 秒)
|
||||
local wait_count=0
|
||||
while [ $wait_count -lt 8 ]; do
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -tulnp 2>/dev/null | grep -q ":${port} " || break
|
||||
else
|
||||
netstat -tulnp 2>/dev/null | grep -q ":${port} " || break
|
||||
fi
|
||||
sleep 1
|
||||
wait_count=$((wait_count + 1))
|
||||
done
|
||||
|
||||
# 如果端口仍被占用,强制杀掉占用者
|
||||
if [ $wait_count -ge 8 ]; then
|
||||
local port_pid=""
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
port_pid=$(ss -tulnp 2>/dev/null | grep ":${port} " | sed -n 's/.*pid=\([0-9]*\).*/\1/p' | head -1)
|
||||
else
|
||||
port_pid=$(netstat -tulnp 2>/dev/null | grep ":${port} " | sed -n 's|.* \([0-9]*\)/.*|\1|p' | head -1)
|
||||
fi
|
||||
[ -n "$port_pid" ] && kill -9 "$port_pid" 2>/dev/null
|
||||
sleep 1
|
||||
fi
|
||||
}
|
||||
|
||||
service_triggers() {
|
||||
procd_add_reload_trigger "openclaw"
|
||||
|
||||
}
|
||||
|
||||
reload_service() {
|
||||
stop
|
||||
# stop_service 已确保端口释放,但额外等待 1 秒让内核回收
|
||||
sleep 1
|
||||
start
|
||||
}
|
||||
|
||||
setup() {
|
||||
echo "正在调用 openclaw-env setup..."
|
||||
/usr/bin/openclaw-env setup
|
||||
}
|
||||
|
||||
restart_gateway() {
|
||||
# 仅重启 Gateway 进程,通过 kill 触发 procd respawn
|
||||
# 绝对不能调用 start_service,否则会重启 PTY 实例
|
||||
|
||||
local port
|
||||
port=$(uci -q get openclaw.main.port || echo "18789")
|
||||
|
||||
# ── 第一步: kill 监听端口的 gateway 子进程 (openclaw-gateway) ──
|
||||
# openclaw 启动后会 fork 出 openclaw-gateway 子进程实际监听端口
|
||||
# 必须先杀子进程释放端口,否则 procd respawn 的新实例会因端口冲突而崩溃
|
||||
local port_pid=""
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
port_pid=$(ss -tulnp 2>/dev/null | grep ":${port} " | sed -n 's/.*pid=\([0-9]*\).*/\1/p' | head -1)
|
||||
else
|
||||
port_pid=$(netstat -tulnp 2>/dev/null | grep ":${port} " | sed -n 's|.* \([0-9]*\)/.*|\1|p' | head -1)
|
||||
fi
|
||||
[ -n "$port_pid" ] && kill "$port_pid" 2>/dev/null
|
||||
|
||||
# ── 第二步: kill procd 管理的 gateway 主进程 (openclaw) ──
|
||||
# procd 追踪的是主进程 PID,kill 它才能触发 respawn
|
||||
local gw_pid=""
|
||||
gw_pid=$(ubus call service list '{"name":"openclaw"}' 2>/dev/null | \
|
||||
jsonfilter -e '$.openclaw.instances.gateway.pid' 2>/dev/null) || true
|
||||
|
||||
if [ -n "$gw_pid" ] && kill -0 "$gw_pid" 2>/dev/null; then
|
||||
kill "$gw_pid" 2>/dev/null
|
||||
fi
|
||||
|
||||
# ── 第三步: 兜底 — kill 所有 openclaw gateway 相关残留进程 ──
|
||||
# 避免任何残留进程占据端口
|
||||
sleep 1
|
||||
for pid in $(pgrep -f "openclaw-gateway" 2>/dev/null) $(pgrep -f "openclaw.*gateway.*run" 2>/dev/null); do
|
||||
kill "$pid" 2>/dev/null
|
||||
done
|
||||
|
||||
# ── 第四步: 等待端口真正释放 (最多 5 秒) ──
|
||||
local wait_count=0
|
||||
while [ $wait_count -lt 5 ]; do
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -tulnp 2>/dev/null | grep -q ":${port} " || break
|
||||
else
|
||||
netstat -tulnp 2>/dev/null | grep -q ":${port} " || break
|
||||
fi
|
||||
sleep 1
|
||||
wait_count=$((wait_count + 1))
|
||||
done
|
||||
|
||||
# ── 第五步: 如果 procd 中没有 gateway 服务注册 (首次/崩溃),调用 start ──
|
||||
if [ -z "$gw_pid" ]; then
|
||||
/etc/init.d/openclaw start >/dev/null 2>&1
|
||||
fi
|
||||
# 如果 gw_pid 存在,procd respawn 会自动重启 gateway
|
||||
}
|
||||
|
||||
status_service() {
|
||||
local port pty_port
|
||||
port=$(uci -q get openclaw.main.port || echo "18789")
|
||||
pty_port=$(uci -q get openclaw.main.pty_port || echo "18793")
|
||||
|
||||
echo "=== OpenClaw 服务状态 ==="
|
||||
|
||||
# Node.js
|
||||
if [ -x "$NODE_BIN" ]; then
|
||||
echo "Node.js: $($NODE_BIN --version 2>/dev/null)"
|
||||
else
|
||||
echo "Node.js: 未安装"
|
||||
fi
|
||||
|
||||
# OpenClaw
|
||||
local oc_entry
|
||||
oc_entry=$(get_oc_entry)
|
||||
if [ -n "$oc_entry" ]; then
|
||||
local ver
|
||||
ver=$("$NODE_BIN" "$oc_entry" --version 2>/dev/null | tr -d '[:space:]')
|
||||
echo "OpenClaw: v${ver:-未知}"
|
||||
else
|
||||
echo "OpenClaw: 未安装"
|
||||
fi
|
||||
|
||||
# 端口检测函数 (ss 优先, 回退 netstat)
|
||||
_check_port() {
|
||||
local p="$1"
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -tulnp 2>/dev/null | grep -q ":${p} "
|
||||
else
|
||||
netstat -tulnp 2>/dev/null | grep -q ":${p} "
|
||||
fi
|
||||
}
|
||||
|
||||
_get_pid_by_port() {
|
||||
local p="$1"
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -tulnp 2>/dev/null | grep ":${p} " | head -1 | sed -n 's/.*pid=\([0-9]*\).*/\1/p'
|
||||
else
|
||||
netstat -tulnp 2>/dev/null | grep ":${p} " | head -1 | sed 's|.* \([0-9]*\)/.*|\1|'
|
||||
fi
|
||||
}
|
||||
|
||||
# Gateway port
|
||||
if _check_port "$port"; then
|
||||
echo "网关: 运行中 (端口 $port)"
|
||||
# PID
|
||||
local pid
|
||||
pid=$(_get_pid_by_port "$port")
|
||||
if [ -n "$pid" ]; then
|
||||
echo "进程ID: $pid"
|
||||
# Memory
|
||||
local rss
|
||||
rss=$(awk '/VmRSS/{print $2}' /proc/$pid/status 2>/dev/null)
|
||||
[ -n "$rss" ] && echo "内存: ${rss} kB"
|
||||
# Uptime
|
||||
local start_time now_time
|
||||
start_time=$(stat -c %Y /proc/$pid 2>/dev/null || echo "0")
|
||||
now_time=$(date +%s)
|
||||
if [ "$start_time" -gt 0 ]; then
|
||||
local uptime=$((now_time - start_time))
|
||||
local hours=$((uptime / 3600))
|
||||
local mins=$(( (uptime % 3600) / 60 ))
|
||||
echo "运行时间: ${hours}小时 ${mins}分钟"
|
||||
fi
|
||||
fi
|
||||
elif pgrep -f "openclaw.*gateway" >/dev/null 2>&1; then
|
||||
echo "网关: 正在启动 (首次启动可能需要 2~5 分钟)"
|
||||
else
|
||||
echo "网关: 已停止"
|
||||
fi
|
||||
|
||||
# PTY port
|
||||
if _check_port "$pty_port"; then
|
||||
echo "Web PTY: 运行中 (端口 $pty_port)"
|
||||
else
|
||||
echo "Web PTY: 已停止"
|
||||
fi
|
||||
}
|
||||
59
luci-app-openclaw/root/etc/uci-defaults/99-openclaw
Executable file
59
luci-app-openclaw/root/etc/uci-defaults/99-openclaw
Executable file
@ -0,0 +1,59 @@
|
||||
#!/bin/sh
|
||||
# luci-app-openclaw — 首次安装初始化脚本
|
||||
|
||||
# 创建 openclaw 系统用户 (无 home, 无 shell)
|
||||
if ! id openclaw >/dev/null 2>&1; then
|
||||
# 动态查找可用 UID/GID (从 1000 开始,避免与已有用户冲突)
|
||||
OC_UID=1000
|
||||
while grep -q "^[^:]*:x:${OC_UID}:" /etc/passwd 2>/dev/null; do
|
||||
OC_UID=$((OC_UID + 1))
|
||||
done
|
||||
OC_GID=$OC_UID
|
||||
while grep -q "^[^:]*:x:${OC_GID}:" /etc/group 2>/dev/null; do
|
||||
OC_GID=$((OC_GID + 1))
|
||||
done
|
||||
# OpenWrt 方式:直接写入 /etc/passwd 和 /etc/shadow
|
||||
if ! grep -q '^openclaw:' /etc/passwd 2>/dev/null; then
|
||||
echo "openclaw:x:${OC_UID}:${OC_GID}:openclaw:/opt/openclaw/data:/bin/false" >> /etc/passwd
|
||||
fi
|
||||
if ! grep -q '^openclaw:' /etc/shadow 2>/dev/null; then
|
||||
echo 'openclaw:x:0:0:99999:7:::' >> /etc/shadow
|
||||
fi
|
||||
if ! grep -q '^openclaw:' /etc/group 2>/dev/null; then
|
||||
echo "openclaw:x:${OC_GID}:" >> /etc/group
|
||||
fi
|
||||
fi
|
||||
|
||||
# 创建数据目录
|
||||
# ── OverlayFS 兼容: Docker bind mount 可能导致 /opt 不可写 ──
|
||||
if ! mkdir -p /opt/openclaw/.probe 2>/dev/null; then
|
||||
if [ -d /overlay/upper/opt ]; then
|
||||
mkdir -p /overlay/upper/opt/openclaw 2>/dev/null
|
||||
mount --bind /overlay/upper/opt /opt 2>/dev/null
|
||||
fi
|
||||
rmdir /opt/openclaw/.probe 2>/dev/null
|
||||
else
|
||||
rmdir /opt/openclaw/.probe 2>/dev/null
|
||||
fi
|
||||
mkdir -p /opt/openclaw/data/.openclaw
|
||||
mkdir -p /opt/openclaw/node
|
||||
mkdir -p /opt/openclaw/global
|
||||
chown -R openclaw:openclaw /opt/openclaw 2>/dev/null || true
|
||||
|
||||
# 生成随机 Token (如果尚未设置)
|
||||
CURRENT_TOKEN=$(uci -q get openclaw.main.token)
|
||||
if [ -z "$CURRENT_TOKEN" ]; then
|
||||
TOKEN=$(head -c 24 /dev/urandom | hexdump -e '24/1 "%02x"' 2>/dev/null || dd if=/dev/urandom bs=24 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n' | head -c 48)
|
||||
uci set openclaw.main.token="$TOKEN"
|
||||
uci commit openclaw
|
||||
fi
|
||||
|
||||
# 生成 PTY Token (如果尚未设置)
|
||||
CURRENT_PTY_TOKEN=$(uci -q get openclaw.main.pty_token)
|
||||
if [ -z "$CURRENT_PTY_TOKEN" ]; then
|
||||
PTY_TOKEN=$(head -c 24 /dev/urandom | hexdump -e '24/1 "%02x"' 2>/dev/null || dd if=/dev/urandom bs=24 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n' | head -c 48)
|
||||
uci set openclaw.main.pty_token="$PTY_TOKEN"
|
||||
uci commit openclaw
|
||||
fi
|
||||
|
||||
exit 0
|
||||
652
luci-app-openclaw/root/usr/bin/openclaw-env
Executable file
652
luci-app-openclaw/root/usr/bin/openclaw-env
Executable file
@ -0,0 +1,652 @@
|
||||
#!/bin/sh
|
||||
# ============================================================================
|
||||
# openclaw-env — Node.js 环境自动检测/下载 + OpenClaw 安装
|
||||
# 用法:
|
||||
# openclaw-env setup — 完整安装 (Node.js + pnpm + OpenClaw)
|
||||
# openclaw-env check — 检查环境状态
|
||||
# openclaw-env upgrade — 升级 OpenClaw 到最新版
|
||||
# openclaw-env node — 仅下载/更新 Node.js
|
||||
# 环境变量:
|
||||
# OC_VERSION — 指定 OpenClaw 版本 (如 2026.3.1),不设置则安装最新版
|
||||
# ============================================================================
|
||||
set -e
|
||||
|
||||
NODE_VERSION="${NODE_VERSION:-22.15.1}"
|
||||
# 经过验证的 OpenClaw 稳定版本 (更新此值需同步测试)
|
||||
OC_TESTED_VERSION="2026.3.8"
|
||||
# 用户可通过 OC_VERSION 环境变量覆盖安装版本
|
||||
OC_VERSION="${OC_VERSION:-}"
|
||||
NODE_BASE="/opt/openclaw/node"
|
||||
OC_GLOBAL="/opt/openclaw/global"
|
||||
OC_DATA="/opt/openclaw/data"
|
||||
|
||||
# ── OverlayFS 兼容性修复 ──
|
||||
# iStoreOS/OpenWrt 上 Docker 的 bind mount (/overlay/upper/opt/docker)
|
||||
# 会导致 OverlayFS 合并视图中 /opt 完全不可写 (mkdir 报 "Directory not empty")。
|
||||
# 解决方案: 将 /overlay/upper/opt bind mount 到 /opt,绕过 OverlayFS 冲突。
|
||||
_oc_fix_opt() {
|
||||
# 如果 /opt 可正常写入,无需修复
|
||||
if mkdir -p /opt/openclaw/.probe 2>/dev/null; then
|
||||
rmdir /opt/openclaw/.probe 2>/dev/null
|
||||
return 0
|
||||
fi
|
||||
# /opt 不可写且 overlay upper 层存在 — 执行 bind mount 修复
|
||||
if [ -d /overlay/upper/opt ]; then
|
||||
# 确保 upper 层有 openclaw 目录
|
||||
mkdir -p /overlay/upper/opt/openclaw 2>/dev/null
|
||||
# 绑定挂载 upper 层的 /opt 到合并视图的 /opt
|
||||
mount --bind /overlay/upper/opt /opt 2>/dev/null && return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
_oc_fix_opt
|
||||
|
||||
NODE_BIN="${NODE_BASE}/bin/node"
|
||||
NPM_BIN="${NODE_BASE}/bin/npm"
|
||||
PNPM_BIN="${OC_GLOBAL}/bin/pnpm"
|
||||
|
||||
# Node.js 官方镜像 + musl 非官方构建
|
||||
# Node.js 官方镜像 + musl 非官方构建
|
||||
NODE_MIRROR="${NODE_MIRROR:-https://nodejs.org/dist}"
|
||||
NODE_MIRROR_CN="https://npmmirror.com/mirrors/node"
|
||||
NODE_MUSL_MIRROR="https://unofficial-builds.nodejs.org/download/release"
|
||||
# 项目自托管 ARM64 musl Node.js (unofficial-builds 仅提供 x64 musl)
|
||||
NODE_SELF_HOST="https://github.com/10000ge10000/luci-app-openclaw/releases/download/node-bins"
|
||||
|
||||
export PATH="${NODE_BASE}/bin:${OC_GLOBAL}/bin:$PATH"
|
||||
export NODE_ICU_DATA="${NODE_BASE}/share/icu"
|
||||
|
||||
log_info() { echo " [✓] $1"; }
|
||||
log_warn() { echo " [!] $1"; }
|
||||
log_error() { echo " [✗] $1"; }
|
||||
|
||||
# 安全创建目录 (会在 _oc_fix_opt 修复 /opt 后使用标准路径)
|
||||
ensure_mkdir() {
|
||||
local target="$1"
|
||||
[ -d "$target" ] && return 0
|
||||
if ! mkdir -p "$target" 2>/dev/null; then
|
||||
log_error "无法创建目录: $target"
|
||||
log_error "如果安装了 Docker,可能需要手动执行: mount --bind /overlay/upper/opt /opt"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 检测 C 运行时类型 (glibc vs musl)
|
||||
detect_libc() {
|
||||
if ldd --version 2>&1 | grep -qi musl; then
|
||||
echo "musl"
|
||||
elif [ -f /lib/ld-musl-*.so.1 ] 2>/dev/null; then
|
||||
echo "musl"
|
||||
elif [ -f /etc/openwrt_release ] || grep -qi "openwrt\|istoreos\|lede" /etc/os-release 2>/dev/null; then
|
||||
echo "musl"
|
||||
else
|
||||
echo "glibc"
|
||||
fi
|
||||
}
|
||||
|
||||
# 在所有可能的位置查找 openclaw 入口文件
|
||||
# pnpm 全局安装路径形如: $OC_GLOBAL/5/node_modules/openclaw
|
||||
find_oc_entry() {
|
||||
local search_dirs="${OC_GLOBAL}/lib/node_modules/openclaw
|
||||
${OC_GLOBAL}/node_modules/openclaw
|
||||
${NODE_BASE}/lib/node_modules/openclaw"
|
||||
|
||||
# 添加 pnpm 版本化目录 (如 /opt/openclaw/global/5/node_modules/openclaw)
|
||||
for ver_dir in "${OC_GLOBAL}"/*/node_modules/openclaw; do
|
||||
[ -d "$ver_dir" ] 2>/dev/null && search_dirs="$search_dirs
|
||||
$ver_dir"
|
||||
done
|
||||
|
||||
local d
|
||||
echo "$search_dirs" | while read -r d; do
|
||||
[ -z "$d" ] && continue
|
||||
if [ -f "${d}/openclaw.mjs" ]; then
|
||||
echo "${d}/openclaw.mjs"
|
||||
return
|
||||
elif [ -f "${d}/dist/cli.js" ]; then
|
||||
echo "${d}/dist/cli.js"
|
||||
return
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
detect_arch() {
|
||||
local arch
|
||||
arch=$(uname -m)
|
||||
case "$arch" in
|
||||
x86_64) echo "linux-x64" ;;
|
||||
aarch64) echo "linux-arm64" ;;
|
||||
armv7l|armv6l)
|
||||
log_error "不支持的 CPU 架构: $arch"
|
||||
log_error "Node.js v22+ 不提供 32 位 ARM 预编译包。"
|
||||
log_error "建议: 使用 aarch64 (ARM64) 版本的固件,或使用 x86_64 设备。"
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
log_error "不支持的 CPU 架构: $arch (仅支持 x86_64/aarch64)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
download_node() {
|
||||
local node_ver="$1"
|
||||
local node_arch
|
||||
node_arch=$(detect_arch)
|
||||
local libc_type
|
||||
libc_type=$(detect_libc)
|
||||
|
||||
# 如果已存在且版本兼容, 跳过
|
||||
if [ -x "$NODE_BIN" ]; then
|
||||
local current_ver
|
||||
current_ver=$("$NODE_BIN" --version 2>/dev/null | sed 's/^v//')
|
||||
if [ "$current_ver" = "$node_ver" ]; then
|
||||
log_info "Node.js v${node_ver} 已安装, 跳过下载"
|
||||
return 0
|
||||
fi
|
||||
# ARM64 musl 使用 Alpine 打包,版本号可能不完全匹配
|
||||
# 只要主版本号相同即认为兼容 (如 22.15.1 vs 22.15.0)
|
||||
local cur_major=$(echo "$current_ver" | cut -d. -f1)
|
||||
local want_major=$(echo "$node_ver" | cut -d. -f1)
|
||||
if [ "$cur_major" = "$want_major" ]; then
|
||||
log_info "Node.js v${current_ver} 已安装 (兼容 v${node_ver}), 跳过下载"
|
||||
return 0
|
||||
fi
|
||||
log_warn "当前 Node.js v${current_ver}, 将更新到 v${node_ver}"
|
||||
fi
|
||||
|
||||
# ── 构建下载 URL 列表 (按优先级排列) ──
|
||||
local mirror_list=""
|
||||
local musl_tarball="node-v${node_ver}-${node_arch}-musl.tar.xz"
|
||||
local glibc_tarball="node-v${node_ver}-${node_arch}.tar.xz"
|
||||
|
||||
if [ "$libc_type" = "musl" ]; then
|
||||
echo ""
|
||||
echo "=== 下载 Node.js v${node_ver} (${node_arch}, musl libc) ==="
|
||||
|
||||
if [ "$node_arch" = "linux-arm64" ]; then
|
||||
# ARM64 musl: unofficial-builds 不提供,从项目自托管下载
|
||||
# 1) 项目自托管 ARM64 musl 构建
|
||||
mirror_list="${NODE_SELF_HOST}/${musl_tarball}"
|
||||
# 2) unofficial-builds (留作将来可能支持)
|
||||
mirror_list="$mirror_list ${NODE_MUSL_MIRROR}/v${node_ver}/${musl_tarball}"
|
||||
else
|
||||
# x64 musl: unofficial-builds 提供
|
||||
# 1) unofficial-builds
|
||||
mirror_list="${NODE_MUSL_MIRROR}/v${node_ver}/${musl_tarball}"
|
||||
# 2) npmmirror 镜像
|
||||
mirror_list="$mirror_list ${NODE_MIRROR_CN}/v${node_ver}/${musl_tarball}"
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo "=== 下载 Node.js v${node_ver} (${node_arch}, glibc) ==="
|
||||
|
||||
mirror_list="${NODE_MIRROR}/v${node_ver}/${glibc_tarball}"
|
||||
mirror_list="$mirror_list ${NODE_MIRROR_CN}/v${node_ver}/${glibc_tarball}"
|
||||
fi
|
||||
|
||||
# ── 逐个尝试下载 ──
|
||||
local downloaded=0
|
||||
local tmp_file="/tmp/node-v${node_ver}.tar.xz"
|
||||
local attempt=0
|
||||
local total=$(echo "$mirror_list" | wc -w)
|
||||
|
||||
for mirror_url in $mirror_list; do
|
||||
attempt=$((attempt + 1))
|
||||
echo " 正在从 ${mirror_url} 下载... (${attempt}/${total})"
|
||||
if curl -fSL --connect-timeout 15 --max-time 300 -o "$tmp_file" "$mirror_url" 2>/dev/null || \
|
||||
wget -q --timeout=15 -O "$tmp_file" "$mirror_url" 2>/dev/null; then
|
||||
# 校验文件大小 (Node.js xz 压缩包至少 5MB; Alpine 精简包约 12MB, 官方完整包约 30MB)
|
||||
local fsize=$(wc -c < "$tmp_file" 2>/dev/null || echo 0)
|
||||
if [ "$fsize" -gt 5000000 ] 2>/dev/null; then
|
||||
downloaded=1
|
||||
break
|
||||
else
|
||||
log_warn "文件大小异常 (${fsize} bytes), 跳过"
|
||||
rm -f "$tmp_file"
|
||||
fi
|
||||
fi
|
||||
log_warn "下载失败, 尝试备用镜像..."
|
||||
done
|
||||
|
||||
if [ "$downloaded" -eq 0 ]; then
|
||||
log_error "所有镜像均下载失败"
|
||||
rm -f "$tmp_file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 解压
|
||||
echo " 正在解压到 ${NODE_BASE}..."
|
||||
# OverlayFS 兼容: rm -rf 后可能因 whiteout 导致 mkdir 失败
|
||||
# 先尝试常规方式,失败则通过 overlay upper 层操作
|
||||
rm -rf "$NODE_BASE" 2>/dev/null
|
||||
if [ -d /overlay/upper ]; then
|
||||
rm -rf "/overlay/upper${NODE_BASE}" 2>/dev/null
|
||||
fi
|
||||
ensure_mkdir "$NODE_BASE"
|
||||
# 兼容 BusyBox tar (不支持 --strip-components) 和 GNU tar
|
||||
# 方法: 先解压到临时目录,再移动顶层子目录内容到目标目录
|
||||
if tar --strip-components=1 -xf "$tmp_file" -C "$NODE_BASE" 2>/dev/null; then
|
||||
: # GNU tar 成功
|
||||
else
|
||||
# BusyBox tar 回退: 解压到临时目录后手动移动
|
||||
local tmp_extract="/tmp/node-extract-$$"
|
||||
ensure_mkdir "$tmp_extract"
|
||||
tar xf "$tmp_file" -C "$tmp_extract"
|
||||
# 找顶层目录 (node-vX.X.X-linux-xxx)
|
||||
local top_dir
|
||||
top_dir=$(ls "$tmp_extract" 2>/dev/null | head -1)
|
||||
if [ -n "$top_dir" ] && [ -d "$tmp_extract/$top_dir" ]; then
|
||||
cp -a "$tmp_extract/$top_dir/." "$NODE_BASE/"
|
||||
else
|
||||
log_error "解压后未找到顶层目录,安装失败"
|
||||
rm -rf "$tmp_extract"
|
||||
exit 1
|
||||
fi
|
||||
rm -rf "$tmp_extract"
|
||||
fi
|
||||
rm -f "$tmp_file"
|
||||
|
||||
# 验证
|
||||
if [ -x "$NODE_BIN" ]; then
|
||||
local installed_ver
|
||||
installed_ver=$("$NODE_BIN" --version 2>/dev/null || echo "unknown")
|
||||
log_info "Node.js ${installed_ver} 安装成功"
|
||||
else
|
||||
log_error "Node.js 安装验证失败"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
install_pnpm() {
|
||||
echo ""
|
||||
echo "=== 安装 pnpm ==="
|
||||
|
||||
if [ -x "$PNPM_BIN" ]; then
|
||||
log_info "pnpm 已安装: $($PNPM_BIN --version 2>/dev/null)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 使用 npm 安装 pnpm 到全局目录
|
||||
ensure_mkdir "$OC_GLOBAL"
|
||||
"$NPM_BIN" install -g pnpm --prefix="$OC_GLOBAL" 2>/dev/null
|
||||
|
||||
if [ -x "$OC_GLOBAL/bin/pnpm" ]; then
|
||||
PNPM_BIN="$OC_GLOBAL/bin/pnpm"
|
||||
log_info "pnpm $($PNPM_BIN --version 2>/dev/null) 安装成功"
|
||||
elif [ -x "$NODE_BASE/bin/pnpm" ]; then
|
||||
PNPM_BIN="$NODE_BASE/bin/pnpm"
|
||||
log_info "pnpm $($PNPM_BIN --version 2>/dev/null) 安装成功"
|
||||
else
|
||||
log_warn "pnpm 安装失败, 将使用 npm 作为回退"
|
||||
fi
|
||||
}
|
||||
|
||||
install_openclaw() {
|
||||
echo ""
|
||||
echo "=== 安装 OpenClaw ==="
|
||||
|
||||
# 确定安装版本
|
||||
local oc_pkg="openclaw"
|
||||
if [ -n "$OC_VERSION" ]; then
|
||||
oc_pkg="openclaw@${OC_VERSION}"
|
||||
log_info "指定版本: v${OC_VERSION}"
|
||||
else
|
||||
oc_pkg="openclaw@latest"
|
||||
log_info "安装最新版本"
|
||||
fi
|
||||
|
||||
local libc_type
|
||||
libc_type=$(detect_libc)
|
||||
|
||||
# musl 系统使用 npm + --ignore-scripts 避免 node-llama-cpp 编译失败
|
||||
# glibc 系统正常安装
|
||||
local install_flags=""
|
||||
if [ "$libc_type" = "musl" ]; then
|
||||
log_warn "检测到 musl libc,将跳过本地编译依赖 (不影响核心功能)"
|
||||
install_flags="--ignore-scripts"
|
||||
fi
|
||||
|
||||
# 检查 git 是否可用 (openclaw 部分依赖可能使用 git:// 协议)
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
log_warn "未检测到 git,正在尝试安装..."
|
||||
opkg update >/dev/null 2>&1
|
||||
opkg install git git-http 2>&1 | tail -3 || true
|
||||
if command -v git >/dev/null 2>&1; then
|
||||
log_info "git 安装成功"
|
||||
else
|
||||
log_warn "git 安装失败,将尝试无 git 模式安装"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 优先用 npm 安装 (pnpm 在 musl 上全局安装可能有路径问题)
|
||||
local npm_ok=0
|
||||
if [ -x "$NPM_BIN" ]; then
|
||||
ensure_mkdir "$OC_GLOBAL"
|
||||
"$NPM_BIN" install -g "$oc_pkg" --prefix="$OC_GLOBAL" $install_flags 2>&1 | tail -10
|
||||
# 检查是否安装成功
|
||||
if [ -n "$(find_oc_entry)" ]; then
|
||||
npm_ok=1
|
||||
else
|
||||
log_warn "首次安装未成功,尝试 --no-optional 模式重试..."
|
||||
"$NPM_BIN" install -g "$oc_pkg" --prefix="$OC_GLOBAL" $install_flags --no-optional 2>&1 | tail -10
|
||||
[ -n "$(find_oc_entry)" ] && npm_ok=1
|
||||
fi
|
||||
elif [ -x "$PNPM_BIN" ]; then
|
||||
ensure_mkdir "$OC_GLOBAL"
|
||||
"$PNPM_BIN" install -g "$oc_pkg" --prefix="$OC_GLOBAL" 2>&1 | tail -5
|
||||
else
|
||||
log_error "npm 和 pnpm 均不可用"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 验证
|
||||
local oc_ver=""
|
||||
local oc_found
|
||||
oc_found=$(find_oc_entry)
|
||||
if [ -n "$oc_found" ]; then
|
||||
oc_ver=$("$NODE_BIN" "$oc_found" --version 2>/dev/null | tr -d '[:space:]')
|
||||
fi
|
||||
|
||||
if [ -n "$oc_ver" ]; then
|
||||
log_info "OpenClaw v${oc_ver} 安装成功"
|
||||
else
|
||||
log_error "OpenClaw 安装验证失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 安装 Gemini CLI (官方模型配置向导的 Google Gemini OAuth 依赖)
|
||||
if [ -x "$NPM_BIN" ]; then
|
||||
echo ""
|
||||
echo "=== 安装 Gemini CLI (Google OAuth 依赖) ==="
|
||||
"$NPM_BIN" install -g @google/gemini-cli --prefix="$OC_GLOBAL" $install_flags 2>&1 | tail -3
|
||||
if command -v gemini >/dev/null 2>&1 || [ -x "$OC_GLOBAL/bin/gemini" ]; then
|
||||
log_info "Gemini CLI 安装成功"
|
||||
else
|
||||
log_warn "Gemini CLI 安装失败 (不影响核心功能,仅影响 Google Gemini OAuth 登录)"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
init_openclaw() {
|
||||
echo ""
|
||||
echo "=== 初始化 OpenClaw ==="
|
||||
|
||||
# 创建数据目录
|
||||
ensure_mkdir "$OC_DATA/.openclaw"
|
||||
|
||||
# 运行 onboard
|
||||
local oc_entry=""
|
||||
oc_entry=$(find_oc_entry)
|
||||
|
||||
if [ -n "$oc_entry" ]; then
|
||||
HOME="$OC_DATA" \
|
||||
OPENCLAW_HOME="$OC_DATA" \
|
||||
OPENCLAW_STATE_DIR="${OC_DATA}/.openclaw" \
|
||||
OPENCLAW_CONFIG_PATH="${OC_DATA}/.openclaw/openclaw.json" \
|
||||
"$NODE_BIN" "$oc_entry" onboard --non-interactive --accept-risk --tools-profile coding 2>/dev/null || true
|
||||
log_info "初始化完成"
|
||||
fi
|
||||
|
||||
# 设置文件权限
|
||||
chown -R openclaw:openclaw "$OC_DATA" 2>/dev/null || true
|
||||
chown -R openclaw:openclaw "$OC_GLOBAL" 2>/dev/null || true
|
||||
chown -R openclaw:openclaw "$NODE_BASE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
do_setup() {
|
||||
local node_ver="$NODE_VERSION"
|
||||
|
||||
# 检查是否已安装
|
||||
if [ -x "$NODE_BIN" ] && [ -n "$(find_oc_entry)" ]; then
|
||||
local oc_ver=""
|
||||
local oc_entry="$(find_oc_entry)"
|
||||
local oc_pkg_dir="$(dirname "$oc_entry")"
|
||||
[ -f "$oc_pkg_dir/package.json" ] && \
|
||||
oc_ver=$("$NODE_BIN" -e "try{console.log(require('$oc_pkg_dir/package.json').version)}catch(e){}" 2>/dev/null) || true
|
||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ ⚠️ OpenClaw 运行环境已安装,无需重复安装 ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo " Node.js: $($NODE_BIN --version 2>/dev/null)"
|
||||
[ -n "$oc_ver" ] && echo " OpenClaw: v${oc_ver}"
|
||||
echo ""
|
||||
echo " 如需升级,请使用: openclaw-env upgrade"
|
||||
echo " 如需重装,请先卸载: 在 LuCI 界面点击「卸载环境」"
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ 一万AI分享 OpenClaw 环境安装 ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo " 架构: $(uname -m)"
|
||||
echo " Node 版本: v${node_ver}"
|
||||
if [ -n "$OC_VERSION" ]; then
|
||||
echo " OpenClaw: v${OC_VERSION} (稳定版)"
|
||||
else
|
||||
echo " OpenClaw: 最新版"
|
||||
fi
|
||||
echo " 安装路径: ${NODE_BASE}"
|
||||
echo " 数据路径: ${OC_DATA}"
|
||||
echo ""
|
||||
|
||||
download_node "$node_ver"
|
||||
install_pnpm
|
||||
install_openclaw
|
||||
init_openclaw
|
||||
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ ✅ 安装完成! ║"
|
||||
echo "║ ║"
|
||||
echo "║ 下一步: ║"
|
||||
echo "║ uci set openclaw.main.enabled=1 ║"
|
||||
echo "║ uci commit openclaw ║"
|
||||
echo "║ /etc/init.d/openclaw enable ║"
|
||||
echo "║ /etc/init.d/openclaw start ║"
|
||||
echo "║ ║"
|
||||
echo "║ 或在 LuCI → 服务 → OpenClaw 中启用 ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||
}
|
||||
|
||||
do_check() {
|
||||
echo "=== OpenClaw 环境检查 ==="
|
||||
echo ""
|
||||
|
||||
# Node.js
|
||||
if [ -x "$NODE_BIN" ]; then
|
||||
echo " Node.js: $($NODE_BIN --version 2>/dev/null)"
|
||||
else
|
||||
echo " Node.js: 未安装"
|
||||
fi
|
||||
|
||||
# pnpm
|
||||
if [ -x "$PNPM_BIN" ]; then
|
||||
echo " pnpm: $($PNPM_BIN --version 2>/dev/null)"
|
||||
elif [ -x "${NODE_BASE}/bin/pnpm" ]; then
|
||||
echo " pnpm: $(${NODE_BASE}/bin/pnpm --version 2>/dev/null)"
|
||||
else
|
||||
echo " pnpm: 未安装"
|
||||
fi
|
||||
|
||||
# OpenClaw
|
||||
local oc_entry=""
|
||||
oc_entry=$(find_oc_entry)
|
||||
if [ -n "$oc_entry" ] && [ -x "$NODE_BIN" ]; then
|
||||
echo " OpenClaw: v$($NODE_BIN $oc_entry --version 2>/dev/null | tr -d '[:space:]')"
|
||||
else
|
||||
echo " OpenClaw: 未安装"
|
||||
fi
|
||||
|
||||
# 配置文件
|
||||
if [ -f "$OC_DATA/.openclaw/openclaw.json" ]; then
|
||||
echo " 配置: $OC_DATA/.openclaw/openclaw.json (存在)"
|
||||
else
|
||||
echo " 配置: 未初始化"
|
||||
fi
|
||||
|
||||
# 磁盘使用
|
||||
local used
|
||||
used=$(du -sh /opt/openclaw 2>/dev/null | awk '{print $1}')
|
||||
echo " 磁盘: ${used:-N/A}"
|
||||
|
||||
# libc 类型
|
||||
echo " C库: $(detect_libc)"
|
||||
}
|
||||
|
||||
do_upgrade() {
|
||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ 一万AI分享 OpenClaw 升级 ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
if [ ! -x "$NODE_BIN" ]; then
|
||||
log_error "Node.js 未安装, 请先运行: openclaw-env setup"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 获取当前版本
|
||||
local current_ver=""
|
||||
local oc_entry=""
|
||||
oc_entry=$(find_oc_entry)
|
||||
if [ -n "$oc_entry" ]; then
|
||||
local oc_pkg_dir="$(dirname "$oc_entry")"
|
||||
[ -f "$oc_pkg_dir/package.json" ] && \
|
||||
current_ver=$("$NODE_BIN" -e "try{console.log(require('$oc_pkg_dir/package.json').version)}catch(e){}" 2>/dev/null) || true
|
||||
fi
|
||||
|
||||
echo " Node.js: $($NODE_BIN --version 2>/dev/null)"
|
||||
[ -n "$current_ver" ] && echo " 当前版本: v${current_ver}"
|
||||
echo ""
|
||||
|
||||
local libc_type
|
||||
libc_type=$(detect_libc)
|
||||
local install_flags=""
|
||||
[ "$libc_type" = "musl" ] && install_flags="--ignore-scripts"
|
||||
|
||||
echo "=== 正在升级 OpenClaw ==="
|
||||
echo ""
|
||||
"$NPM_BIN" install -g openclaw@latest --prefix="$OC_GLOBAL" $install_flags 2>&1
|
||||
|
||||
# 验证升级结果
|
||||
local new_ver=""
|
||||
local new_entry=""
|
||||
new_entry=$(find_oc_entry)
|
||||
if [ -n "$new_entry" ]; then
|
||||
local new_pkg_dir="$(dirname "$new_entry")"
|
||||
[ -f "$new_pkg_dir/package.json" ] && \
|
||||
new_ver=$("$NODE_BIN" -e "try{console.log(require('$new_pkg_dir/package.json').version)}catch(e){}" 2>/dev/null) || true
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [ -n "$new_ver" ]; then
|
||||
if [ "$current_ver" = "$new_ver" ]; then
|
||||
log_info "当前已是最新版本 v${new_ver}"
|
||||
else
|
||||
log_info "升级成功: v${current_ver} → v${new_ver}"
|
||||
fi
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ ✅ 升级完成! ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||
else
|
||||
log_error "升级验证失败,OpenClaw 入口文件未找到"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 恢复出厂设置 (非交互式) ──
|
||||
do_factory_reset() {
|
||||
local config_dir="${OC_DATA}/.openclaw"
|
||||
local config_file="${config_dir}/openclaw.json"
|
||||
local auth_file="${config_dir}/agents/main/agent/auth-profiles.json"
|
||||
|
||||
log_info "恢复出厂设置..."
|
||||
|
||||
# 1. 停止 Gateway
|
||||
log_info "停止 Gateway..."
|
||||
/etc/init.d/openclaw stop >/dev/null 2>&1 || true
|
||||
sleep 2
|
||||
|
||||
# 2. 备份当前配置
|
||||
if [ -f "$config_file" ]; then
|
||||
local backup_dir="${config_dir}/backups"
|
||||
local backup_ts=$(date +%Y%m%d_%H%M%S)
|
||||
ensure_mkdir "$backup_dir"
|
||||
cp "$config_file" "${backup_dir}/openclaw_${backup_ts}.json"
|
||||
log_info "备份已保存: backups/openclaw_${backup_ts}.json"
|
||||
fi
|
||||
|
||||
# 3. 重置配置
|
||||
rm -f "$config_file" "${config_file}.bak" 2>/dev/null || true
|
||||
echo '{}' > "$config_file"
|
||||
chown openclaw:openclaw "$config_file" 2>/dev/null || true
|
||||
|
||||
# 4. 重置认证信息
|
||||
if [ -f "$auth_file" ]; then
|
||||
echo '{"version":1,"profiles":{},"usageStats":{}}' > "$auth_file"
|
||||
chown openclaw:openclaw "$auth_file" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 5. 重新初始化
|
||||
if [ -x "$NODE_BIN" ]; then
|
||||
local oc_entry
|
||||
oc_entry=$(find "$OC_GLOBAL" -name "openclaw.mjs" -path "*/openclaw/openclaw.mjs" 2>/dev/null | head -1)
|
||||
if [ -n "$oc_entry" ]; then
|
||||
log_info "重新初始化..."
|
||||
OPENCLAW_HOME="$OC_DATA" "$NODE_BIN" "$oc_entry" onboard --non-interactive --accept-risk --tools-profile coding >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# 6. 应用 OpenWrt 适配配置
|
||||
if [ -x "$NODE_BIN" ] && [ -f "$config_file" ]; then
|
||||
local new_token
|
||||
new_token=$(head -c 24 /dev/urandom | hexdump -e '24/1 "%02x"' 2>/dev/null || dd if=/dev/urandom bs=24 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n' | head -c 48)
|
||||
_JS_KEY="gateway.port" _JS_VAL="18789" "$NODE_BIN" -e "const fs=require('fs');let d={};try{d=JSON.parse(fs.readFileSync('${config_file}','utf8'));}catch(e){}const ks=process.env._JS_KEY.split('.');let o=d;for(let i=0;i<ks.length-1;i++){if(!o[ks[i]]||typeof o[ks[i]]!=='object')o[ks[i]]={};o=o[ks[i]];}let v=process.env._JS_VAL;try{v=JSON.parse(v);}catch(e){}o[ks[ks.length-1]]=v;fs.writeFileSync('${config_file}',JSON.stringify(d,null,2));" 2>/dev/null
|
||||
for kv in "gateway.bind=lan" "gateway.mode=local" "gateway.auth.mode=token" "gateway.auth.token=${new_token}" "gateway.controlUi.allowInsecureAuth=true" "gateway.controlUi.dangerouslyDisableDeviceAuth=true" "gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true" "gateway.tailscale.mode=off" "acp.dispatch.enabled=false" "tools.profile=coding"; do
|
||||
local k="${kv%%=*}" v="${kv#*=}"
|
||||
_JS_KEY="$k" _JS_VAL="$v" "$NODE_BIN" -e "const fs=require('fs');let d={};try{d=JSON.parse(fs.readFileSync('${config_file}','utf8'));}catch(e){}const ks=process.env._JS_KEY.split('.');let o=d;for(let i=0;i<ks.length-1;i++){if(!o[ks[i]]||typeof o[ks[i]]!=='object')o[ks[i]]={};o=o[ks[i]];}let v=process.env._JS_VAL;try{v=JSON.parse(v);}catch(e){}o[ks[ks.length-1]]=v;fs.writeFileSync('${config_file}',JSON.stringify(d,null,2));" 2>/dev/null
|
||||
done
|
||||
chown openclaw:openclaw "$config_file" 2>/dev/null || true
|
||||
|
||||
# 同步 token 到 UCI
|
||||
. /lib/functions.sh 2>/dev/null || true
|
||||
uci set openclaw.main.token="$new_token" 2>/dev/null || true
|
||||
uci commit openclaw 2>/dev/null || true
|
||||
log_info "新认证令牌: $new_token"
|
||||
fi
|
||||
|
||||
# 7. 重启服务
|
||||
/etc/init.d/openclaw start >/dev/null 2>&1 &
|
||||
log_info "出厂设置已恢复,Gateway 重启中..."
|
||||
}
|
||||
|
||||
# ── 主入口 ──
|
||||
case "${1:-}" in
|
||||
setup)
|
||||
do_setup
|
||||
;;
|
||||
check)
|
||||
do_check
|
||||
;;
|
||||
upgrade)
|
||||
do_upgrade
|
||||
;;
|
||||
node)
|
||||
download_node "$NODE_VERSION"
|
||||
;;
|
||||
factory-reset)
|
||||
do_factory_reset
|
||||
;;
|
||||
*)
|
||||
echo ""
|
||||
echo " setup — 完整安装 (下载 Node.js + pnpm + OpenClaw)"
|
||||
echo " check — 检查环境状态"
|
||||
echo " upgrade — 升级 OpenClaw 到最新版"
|
||||
echo " node — 仅下载/更新 Node.js"
|
||||
echo " factory-reset — 恢复出厂设置 (清除所有配置)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
2186
luci-app-openclaw/root/usr/share/openclaw/oc-config.sh
Executable file
2186
luci-app-openclaw/root/usr/share/openclaw/oc-config.sh
Executable file
File diff suppressed because it is too large
Load Diff
BIN
luci-app-openclaw/root/usr/share/openclaw/ui/images/icon_256.png
Normal file
BIN
luci-app-openclaw/root/usr/share/openclaw/ui/images/icon_256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
BIN
luci-app-openclaw/root/usr/share/openclaw/ui/images/icon_64.png
Normal file
BIN
luci-app-openclaw/root/usr/share/openclaw/ui/images/icon_64.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
301
luci-app-openclaw/root/usr/share/openclaw/ui/index.html
Normal file
301
luci-app-openclaw/root/usr/share/openclaw/ui/index.html
Normal file
@ -0,0 +1,301 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>一万AI分享 OpenClaw 配置管理</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
html,body{width:100%;height:100%;overflow:hidden;background:#1a1b26;font-family:system-ui,-apple-system,sans-serif}
|
||||
#loading{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#7aa2f7;z-index:100;background:#1a1b26;transition:opacity .4s}
|
||||
#loading.hidden{opacity:0;pointer-events:none}
|
||||
#loading .spinner{width:40px;height:40px;border:3px solid #2a2b3d;border-top:3px solid #ff9e64;border-radius:50%;animation:spin .8s linear infinite;margin-bottom:16px}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
#loading h2{font-size:18px;font-weight:500;margin-bottom:8px}
|
||||
#loading p{font-size:13px;color:#565f89}
|
||||
#loading .debug{font-size:11px;color:#3b3d57;margin-top:12px;max-width:90%;word-break:break-all;text-align:center}
|
||||
#terminal-container{width:100%;height:calc(100% - 36px);position:absolute;top:36px;left:0;right:0;bottom:0;padding:4px;overflow:hidden}
|
||||
.xterm{height:100%!important}
|
||||
.xterm-viewport::-webkit-scrollbar{width:8px}
|
||||
.xterm-viewport::-webkit-scrollbar-thumb{background:#2a2b3d;border-radius:4px}
|
||||
.xterm-viewport::-webkit-scrollbar-thumb:hover{background:#3b3d57}
|
||||
#topbar{position:fixed;top:0;left:0;right:0;height:36px;background:#16161e;border-bottom:1px solid #2a2b3d;display:flex;align-items:center;padding:0 12px;z-index:50;gap:8px}
|
||||
#topbar .logo{font-size:14px;color:#ff9e64;font-weight:600}
|
||||
#topbar .status{font-size:11px;padding:2px 8px;border-radius:10px;background:#2a2b3d}
|
||||
#topbar .status.connected{color:#9ece6a}
|
||||
#topbar .status.disconnected{color:#f7768e}
|
||||
#topbar .btn{font-size:12px;color:#7aa2f7;background:none;border:1px solid #3b3d57;border-radius:4px;padding:3px 10px;cursor:pointer;margin-left:auto}
|
||||
#topbar .btn:hover{background:#2a2b3d}
|
||||
#reconnect-overlay{display:none;position:absolute;inset:0;background:rgba(26,27,38,.9);z-index:80;flex-direction:column;align-items:center;justify-content:center;color:#c0caf5}
|
||||
#reconnect-overlay.show{display:flex}
|
||||
#reconnect-overlay button{margin-top:16px;padding:8px 24px;background:#ff9e64;color:#1a1b26;border:none;border-radius:6px;font-size:14px;cursor:pointer;font-weight:600}
|
||||
#reconnect-overlay button:hover{background:#e0884a}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="topbar">
|
||||
<span class="logo">🦞 一万AI分享 OpenClaw 配置管理</span>
|
||||
<span id="status" class="status disconnected">● 连接中...</span>
|
||||
<button class="btn" onclick="location.reload()" title="重新启动配置脚本">🔄 重启</button>
|
||||
</div>
|
||||
|
||||
<div id="loading">
|
||||
<div class="spinner"></div>
|
||||
<h2>🦞 一万AI分享 OpenClaw 配置管理工具</h2>
|
||||
<p id="loading-msg">正在连接终端...</p>
|
||||
<div id="loading-debug" class="debug"></div>
|
||||
</div>
|
||||
|
||||
<div id="terminal-container"></div>
|
||||
|
||||
<div id="reconnect-overlay">
|
||||
<p style="font-size:16px;margin-bottom:4px">⚡ 连接已断开</p>
|
||||
<p style="font-size:13px;color:#565f89">配置脚本已退出或连接中断</p>
|
||||
<button onclick="location.reload()">🔄 重新启动</button>
|
||||
</div>
|
||||
|
||||
<link rel="stylesheet" href="/lib/xterm.min.css">
|
||||
<script src="/lib/xterm.min.js"></script>
|
||||
<script src="/lib/addon-fit.min.js"></script>
|
||||
<script src="/lib/addon-web-links.min.js"></script>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const statusEl = document.getElementById('status');
|
||||
const loadingEl = document.getElementById('loading');
|
||||
const reconnectEl = document.getElementById('reconnect-overlay');
|
||||
const containerEl = document.getElementById('terminal-container');
|
||||
const loadingMsg = document.getElementById('loading-msg');
|
||||
const loadingDebug = document.getElementById('loading-debug');
|
||||
|
||||
// ── 创建终端 ──
|
||||
const term = new window.Terminal({
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'bar',
|
||||
fontSize: 15,
|
||||
fontFamily: '"Cascadia Code", "Fira Code", "JetBrains Mono", "Source Code Pro", Menlo, Monaco, "Courier New", monospace',
|
||||
lineHeight: 1.2,
|
||||
scrollback: 5000,
|
||||
allowProposedApi: true,
|
||||
theme: {
|
||||
background: '#1a1b26',
|
||||
foreground: '#c0caf5',
|
||||
cursor: '#ff9e64',
|
||||
cursorAccent: '#1a1b26',
|
||||
selectionBackground: '#33467c',
|
||||
selectionForeground: '#c0caf5',
|
||||
black: '#15161e',
|
||||
red: '#f7768e',
|
||||
green: '#9ece6a',
|
||||
yellow: '#e0af68',
|
||||
blue: '#7aa2f7',
|
||||
magenta: '#bb9af7',
|
||||
cyan: '#7dcfff',
|
||||
white: '#a9b1d6',
|
||||
brightBlack: '#414868',
|
||||
brightRed: '#f7768e',
|
||||
brightGreen: '#9ece6a',
|
||||
brightYellow: '#e0af68',
|
||||
brightBlue: '#7aa2f7',
|
||||
brightMagenta: '#bb9af7',
|
||||
brightCyan: '#7dcfff',
|
||||
brightWhite: '#c0caf5',
|
||||
}
|
||||
});
|
||||
|
||||
const fitAddon = new window.FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
|
||||
try {
|
||||
const webLinksAddon = new window.WebLinksAddon.WebLinksAddon();
|
||||
term.loadAddon(webLinksAddon);
|
||||
} catch(e) { /* optional */ }
|
||||
|
||||
term.open(containerEl);
|
||||
|
||||
function doFit() {
|
||||
try { fitAddon.fit(); } catch(e) { /* ignore */ }
|
||||
try { term.scrollToBottom(); } catch(e) { /* ignore */ }
|
||||
}
|
||||
doFit();
|
||||
window.addEventListener('resize', () => { setTimeout(doFit, 100); });
|
||||
|
||||
// 监听 iframe 容器大小变化 (FnOS 桌面可能调整窗口大小)
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
new ResizeObserver(function() { setTimeout(doFit, 50); }).observe(containerEl);
|
||||
}
|
||||
|
||||
// ── WebSocket 连接 ──
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
// 从 URL 参数或 hash 获取 PTY token 用于 WebSocket 认证
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
const ptyToken = urlParams.get('pty_token') || '';
|
||||
const wsUrl = proto + '//' + location.host + '/ws' + (ptyToken ? '?token=' + encodeURIComponent(ptyToken) : '');
|
||||
let ws = null;
|
||||
let connected = false;
|
||||
let retryCount = 0;
|
||||
const MAX_RETRY = Infinity;
|
||||
let retryTimer = null;
|
||||
let wasEverConnected = false;
|
||||
let pingTimer = null;
|
||||
|
||||
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;
|
||||
|
||||
function connect() {
|
||||
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
|
||||
if (pingTimer) { clearInterval(pingTimer); pingTimer = null; }
|
||||
if (ws) { try { ws.close(); } catch(e){} ws = null; }
|
||||
|
||||
retryCount++;
|
||||
console.log('[oc-config] Connecting to', wsUrl, '(attempt', retryCount + ')');
|
||||
loadingMsg.textContent = retryCount > 1
|
||||
? '正在重新连接... (第 ' + retryCount + ' 次)'
|
||||
: '正在连接终端...';
|
||||
loadingDebug.textContent = wsUrl;
|
||||
|
||||
// 先做一个 HTTP 预检确认服务器可达
|
||||
fetch('/health').then(function(r) {
|
||||
return r.json();
|
||||
}).then(function(data) {
|
||||
console.log('[oc-config] Health check OK:', JSON.stringify(data));
|
||||
doWebSocket();
|
||||
}).catch(function(err) {
|
||||
console.log('[oc-config] Health check failed:', err.message || err);
|
||||
// 服务器不可达,稍后重试
|
||||
scheduleRetry();
|
||||
});
|
||||
}
|
||||
|
||||
function doWebSocket() {
|
||||
try {
|
||||
ws = new WebSocket(wsUrl);
|
||||
} catch(e) {
|
||||
console.error('[oc-config] WebSocket constructor error:', e);
|
||||
scheduleRetry();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[oc-config] WebSocket created, readyState:', ws.readyState);
|
||||
|
||||
// 连接超时保护: 5秒没连上就重试
|
||||
var connectTimeout = setTimeout(function() {
|
||||
if (ws && ws.readyState !== WebSocket.OPEN) {
|
||||
console.log('[oc-config] Connection timeout, readyState:', ws.readyState);
|
||||
try { ws.close(); } catch(e){}
|
||||
scheduleRetry();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
ws.onopen = function() {
|
||||
clearTimeout(connectTimeout);
|
||||
connected = true;
|
||||
var isReconnect = wasEverConnected;
|
||||
wasEverConnected = true;
|
||||
retryCount = 0;
|
||||
loadingEl.classList.add('hidden');
|
||||
reconnectEl.classList.remove('show');
|
||||
statusEl.textContent = '● 已连接';
|
||||
statusEl.className = 'status connected';
|
||||
// 重连时清屏,避免旧内容和新菜单混在一起
|
||||
if (isReconnect) {
|
||||
term.clear();
|
||||
term.write('\x1b[33m⚡ 已重新连接,配置脚本已重新启动\x1b[0m\r\n\r\n');
|
||||
}
|
||||
// 连接后重新 fit,确保尺寸正确
|
||||
setTimeout(doFit, 50);
|
||||
term.focus();
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||
} catch(e) {}
|
||||
// 客户端心跳: 每 20 秒发送一次,防止连接被浏览器/代理超时关闭
|
||||
if (pingTimer) clearInterval(pingTimer);
|
||||
pingTimer = setInterval(function() {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
try { ws.send(JSON.stringify({ type: 'ping' })); } catch(e) {}
|
||||
}
|
||||
}, 20000);
|
||||
};
|
||||
|
||||
ws.onmessage = function(ev) {
|
||||
if (typeof ev.data === 'string') {
|
||||
// 过滤掉服务端的 pong 心跳消息,不写入终端
|
||||
if (ev.data.indexOf('"type":"pong"') !== -1) return;
|
||||
term.write(ev.data, function() { term.scrollToBottom(); });
|
||||
} else if (ev.data instanceof Blob) {
|
||||
ev.data.text().then(function(text) {
|
||||
if (text.indexOf('"type":"pong"') !== -1) return;
|
||||
term.write(text, function() { term.scrollToBottom(); });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = function(ev) {
|
||||
clearTimeout(connectTimeout);
|
||||
if (pingTimer) { clearInterval(pingTimer); pingTimer = null; }
|
||||
connected = false;
|
||||
statusEl.textContent = '● 已断开';
|
||||
statusEl.className = 'status disconnected';
|
||||
console.log('[oc-config] WebSocket closed, code:', ev.code, 'reason:', ev.reason);
|
||||
if (wasEverConnected) {
|
||||
// 自动重连,不显示断连覆盖层
|
||||
console.log('[oc-config] Auto-reconnecting in 2s...');
|
||||
statusEl.textContent = '● 重连中...';
|
||||
retryCount = 0;
|
||||
setTimeout(function() {
|
||||
connect();
|
||||
}, 2000);
|
||||
} else {
|
||||
// 从未连接成功过,自动重试
|
||||
scheduleRetry();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = function(ev) {
|
||||
clearTimeout(connectTimeout);
|
||||
connected = false;
|
||||
console.error('[oc-config] WebSocket error, will retry');
|
||||
// 不要在这里做UI更新, onclose 会紧跟着触发
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleRetry() {
|
||||
if (retryCount >= MAX_RETRY) {
|
||||
loadingMsg.textContent = '连接失败,请检查服务状态';
|
||||
loadingDebug.textContent = '已重试 ' + MAX_RETRY + ' 次。请刷新页面重试。';
|
||||
statusEl.textContent = '● 连接失败';
|
||||
statusEl.className = 'status disconnected';
|
||||
return;
|
||||
}
|
||||
// 逐步增加重试间隔: 1s, 1s, 2s, 2s, 3s, 3s, ...
|
||||
var delay = Math.min(Math.floor(retryCount / 2) + 1, 5) * 1000;
|
||||
loadingMsg.textContent = '等待服务就绪... ' + Math.ceil(delay/1000) + '秒后重试';
|
||||
console.log('[oc-config] Retry in', delay, 'ms');
|
||||
retryTimer = setTimeout(connect, delay);
|
||||
}
|
||||
|
||||
term.onData(function(data) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'stdin', data: data }));
|
||||
}
|
||||
});
|
||||
|
||||
term.onResize(function(size) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows }));
|
||||
}
|
||||
});
|
||||
|
||||
// 等 DOM 和资源就绪后再连接
|
||||
if (document.readyState === 'complete') {
|
||||
connect();
|
||||
} else {
|
||||
window.addEventListener('load', connect);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
8
luci-app-openclaw/root/usr/share/openclaw/ui/lib/addon-fit.min.js
vendored
Normal file
8
luci-app-openclaw/root/usr/share/openclaw/ui/lib/addon-fit.min.js
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Skipped minification because the original files appears to be already minified.
|
||||
* Original file: /npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||
//# sourceMappingURL=addon-fit.js.map
|
||||
8
luci-app-openclaw/root/usr/share/openclaw/ui/lib/addon-web-links.min.js
vendored
Normal file
8
luci-app-openclaw/root/usr/share/openclaw/ui/lib/addon-web-links.min.js
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Skipped minification because the original files appears to be already minified.
|
||||
* Original file: /npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.js
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.WebLinksAddon=t():e.WebLinksAddon=t()}(self,(()=>(()=>{"use strict";var e={6:(e,t)=>{function n(e){try{const t=new URL(e),n=t.password&&t.username?`${t.protocol}//${t.username}:${t.password}@${t.host}`:t.username?`${t.protocol}//${t.username}@${t.host}`:`${t.protocol}//${t.host}`;return e.toLocaleLowerCase().startsWith(n.toLocaleLowerCase())}catch(e){return!1}}Object.defineProperty(t,"__esModule",{value:!0}),t.LinkComputer=t.WebLinkProvider=void 0,t.WebLinkProvider=class{constructor(e,t,n,o={}){this._terminal=e,this._regex=t,this._handler=n,this._options=o}provideLinks(e,t){const n=o.computeLink(e,this._regex,this._terminal,this._handler);t(this._addCallbacks(n))}_addCallbacks(e){return e.map((e=>(e.leave=this._options.leave,e.hover=(t,n)=>{if(this._options.hover){const{range:o}=e;this._options.hover(t,n,o)}},e)))}};class o{static computeLink(e,t,r,i){const s=new RegExp(t.source,(t.flags||"")+"g"),[a,c]=o._getWindowedLineStrings(e-1,r),l=a.join("");let d;const p=[];for(;d=s.exec(l);){const e=d[0];if(!n(e))continue;const[t,s]=o._mapStrIdx(r,c,0,d.index),[a,l]=o._mapStrIdx(r,t,s,e.length);if(-1===t||-1===s||-1===a||-1===l)continue;const h={start:{x:s+1,y:t+1},end:{x:l,y:a+1}};p.push({range:h,text:e,activate:i})}return p}static _getWindowedLineStrings(e,t){let n,o=e,r=e,i=0,s="";const a=[];if(n=t.buffer.active.getLine(e)){const e=n.translateToString(!0);if(n.isWrapped&&" "!==e[0]){for(i=0;(n=t.buffer.active.getLine(--o))&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),n.isWrapped&&-1===s.indexOf(" ")););a.reverse()}for(a.push(e),i=0;(n=t.buffer.active.getLine(++r))&&n.isWrapped&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),-1===s.indexOf(" ")););}return[a,o]}static _mapStrIdx(e,t,n,o){const r=e.buffer.active,i=r.getNullCell();let s=n;for(;o;){const e=r.getLine(t);if(!e)return[-1,-1];for(let n=s;n<e.length;++n){e.getCell(n,i);const s=i.getChars();if(i.getWidth()&&(o-=s.length||1,n===e.length-1&&""===s)){const e=r.getLine(t+1);e&&e.isWrapped&&(e.getCell(0,i),2===i.getWidth()&&(o+=1))}if(o<0)return[t,n]}t++,s=0}return[t,s]}}t.LinkComputer=o}},t={};function n(o){var r=t[o];if(void 0!==r)return r.exports;var i=t[o]={exports:{}};return e[o](i,i.exports,n),i.exports}var o={};return(()=>{var e=o;Object.defineProperty(e,"__esModule",{value:!0}),e.WebLinksAddon=void 0;const t=n(6),r=/(https?|HTTPS?):[/]{2}[^\s"'!*(){}|\\\^<>`]*[^\s"':,.!?{}|\\\^~\[\]`()<>]/;function i(e,t){const n=window.open();if(n){try{n.opener=null}catch{}n.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}e.WebLinksAddon=class{constructor(e=i,t={}){this._handler=e,this._options=t}activate(e){this._terminal=e;const n=this._options,o=n.urlRegex||r;this._linkProvider=this._terminal.registerLinkProvider(new t.WebLinkProvider(this._terminal,o,this._handler,n))}dispose(){this._linkProvider?.dispose()}}})(),o})()));
|
||||
//# sourceMappingURL=addon-web-links.js.map
|
||||
8
luci-app-openclaw/root/usr/share/openclaw/ui/lib/xterm.min.css
vendored
Normal file
8
luci-app-openclaw/root/usr/share/openclaw/ui/lib/xterm.min.css
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Minified by jsDelivr using clean-css v5.3.3.
|
||||
* Original file: /npm/@xterm/xterm@5.5.0/css/xterm.css
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
.xterm{cursor:text;position:relative;user-select:none;-ms-user-select:none;-webkit-user-select:none}.xterm.focus,.xterm:focus{outline:0}.xterm .xterm-helpers{position:absolute;top:0;z-index:5}.xterm .xterm-helper-textarea{padding:0;border:0;margin:0;position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none}.xterm .composition-view{background:#000;color:#fff;display:none;position:absolute;white-space:nowrap;z-index:1}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{background-color:#000;overflow-y:scroll;cursor:default;position:absolute;right:0;left:0;top:0;bottom:0}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;left:0;top:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{display:inline-block;visibility:hidden;position:absolute;top:0;left:-9999em;line-height:normal}.xterm.enable-mouse-events{cursor:default}.xterm .xterm-cursor-pointer,.xterm.xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{position:absolute;left:0;top:0;bottom:0;right:0;z-index:10;color:transparent;pointer-events:none}.xterm .xterm-accessibility-tree:not(.debug) ::selection{color:transparent}.xterm .xterm-accessibility-tree{user-select:text;white-space:pre}.xterm .live-region{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{text-decoration:double underline}.xterm-underline-3{text-decoration:wavy underline}.xterm-underline-4{text-decoration:dotted underline}.xterm-underline-5{text-decoration:dashed underline}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:overline underline}.xterm-overline.xterm-underline-2{text-decoration:overline double underline}.xterm-overline.xterm-underline-3{text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;position:absolute;top:0;right:0;pointer-events:none}.xterm-decoration-top{z-index:2;position:relative}
|
||||
/*# sourceMappingURL=/sm/97377c0c258e109358121823f5790146c714989366481f90e554c42277efb500.map */
|
||||
8
luci-app-openclaw/root/usr/share/openclaw/ui/lib/xterm.min.js
vendored
Normal file
8
luci-app-openclaw/root/usr/share/openclaw/ui/lib/xterm.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
340
luci-app-openclaw/root/usr/share/openclaw/web-pty.js
Normal file
340
luci-app-openclaw/root/usr/share/openclaw/web-pty.js
Normal file
@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env node
|
||||
// ============================================================================
|
||||
// OpenClaw 配置工具 — Web PTY 服务器
|
||||
// 纯 Node.js 实现,零外部依赖
|
||||
// 通过 WebSocket 将 oc-config.sh 的 TTY 输出推送给浏览器 xterm.js
|
||||
// HTTP 端口 18793, HTTPS 可选端口 18794
|
||||
// ============================================================================
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const crypto = require('crypto');
|
||||
const { spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// ── 配置 (OpenWrt 适配) ──
|
||||
const PORT = parseInt(process.env.OC_CONFIG_PORT || '18793', 10);
|
||||
const HOST = process.env.OC_CONFIG_HOST || '0.0.0.0'; // token 认证保护,可安全绑定所有接口
|
||||
const NODE_BASE = process.env.NODE_BASE || '/opt/openclaw/node';
|
||||
const OC_GLOBAL = process.env.OC_GLOBAL || '/opt/openclaw/global';
|
||||
const OC_DATA = process.env.OC_DATA || '/opt/openclaw/data';
|
||||
const SCRIPT_PATH = process.env.OC_CONFIG_SCRIPT || '/usr/share/openclaw/oc-config.sh';
|
||||
const SSL_CERT = '/etc/uhttpd.crt';
|
||||
const SSL_KEY = '/etc/uhttpd.key';
|
||||
const MAX_SESSIONS = parseInt(process.env.OC_MAX_SESSIONS || '5', 10);
|
||||
|
||||
// ── 认证令牌 (从 UCI 或环境变量读取) ──
|
||||
function loadAuthToken() {
|
||||
try {
|
||||
const { execSync } = require('child_process');
|
||||
const t = execSync('uci -q get openclaw.main.pty_token 2>/dev/null', { encoding: 'utf8', timeout: 3000 }).trim();
|
||||
return t || '';
|
||||
} catch { return ''; }
|
||||
}
|
||||
let AUTH_TOKEN = process.env.OC_PTY_TOKEN || loadAuthToken();
|
||||
|
||||
// ── 会话计数 ──
|
||||
let activeSessions = 0;
|
||||
|
||||
// ── 静态文件 ──
|
||||
const UI_DIR = path.join(__dirname, 'ui');
|
||||
|
||||
function getMimeType(ext) {
|
||||
const types = {
|
||||
'.html': 'text/html; charset=utf-8', '.css': 'text/css',
|
||||
'.js': 'application/javascript', '.png': 'image/png',
|
||||
'.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.json': 'application/json',
|
||||
};
|
||||
return types[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
const IFRAME_HEADERS = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'X-Frame-Options': 'ALLOWALL',
|
||||
'Content-Security-Policy': "default-src * 'unsafe-inline' 'unsafe-eval' data: blob: ws: wss:; frame-ancestors *",
|
||||
};
|
||||
|
||||
// ── WebSocket 帧处理 (RFC 6455) ──
|
||||
function decodeWSFrame(buf) {
|
||||
if (buf.length < 2) return null;
|
||||
const opcode = buf[0] & 0x0f;
|
||||
const masked = !!(buf[1] & 0x80);
|
||||
let payloadLen = buf[1] & 0x7f;
|
||||
let offset = 2;
|
||||
if (payloadLen === 126) {
|
||||
if (buf.length < 4) return null;
|
||||
payloadLen = buf.readUInt16BE(2); offset = 4;
|
||||
} else if (payloadLen === 127) {
|
||||
if (buf.length < 10) return null;
|
||||
payloadLen = Number(buf.readBigUInt64BE(2)); offset = 10;
|
||||
}
|
||||
let mask = null;
|
||||
if (masked) {
|
||||
if (buf.length < offset + 4) return null;
|
||||
mask = buf.slice(offset, offset + 4); offset += 4;
|
||||
}
|
||||
if (buf.length < offset + payloadLen) return null;
|
||||
const data = buf.slice(offset, offset + payloadLen);
|
||||
if (mask) { for (let i = 0; i < data.length; i++) data[i] ^= mask[i & 3]; }
|
||||
return { opcode, data, totalLen: offset + payloadLen };
|
||||
}
|
||||
|
||||
function encodeWSFrame(data, opcode = 0x01) {
|
||||
const payload = typeof data === 'string' ? Buffer.from(data) : data;
|
||||
const len = payload.length;
|
||||
let header;
|
||||
if (len < 126) {
|
||||
header = Buffer.alloc(2); header[0] = 0x80 | opcode; header[1] = len;
|
||||
} else if (len < 65536) {
|
||||
header = Buffer.alloc(4); header[0] = 0x80 | opcode; header[1] = 126; header.writeUInt16BE(len, 2);
|
||||
} else {
|
||||
header = Buffer.alloc(10); header[0] = 0x80 | opcode; header[1] = 127; header.writeBigUInt64BE(BigInt(len), 2);
|
||||
}
|
||||
return Buffer.concat([header, payload]);
|
||||
}
|
||||
|
||||
// ── PTY 进程管理 ──
|
||||
class PtySession {
|
||||
constructor(socket) {
|
||||
this.socket = socket;
|
||||
this.proc = null;
|
||||
this.cols = 80;
|
||||
this.rows = 24;
|
||||
this.buffer = Buffer.alloc(0);
|
||||
this.alive = true;
|
||||
this._spawnFailCount = 0;
|
||||
this._MAX_SPAWN_RETRIES = 5;
|
||||
this._pingTimer = null;
|
||||
this._pongReceived = true;
|
||||
activeSessions++;
|
||||
console.log(`[oc-config] Session created (active: ${activeSessions}/${MAX_SESSIONS})`);
|
||||
this._setupWSReader();
|
||||
this._startPing();
|
||||
this._spawnPty();
|
||||
}
|
||||
|
||||
// WebSocket ping/pong 保活 (每 25 秒发一次 ping)
|
||||
_startPing() {
|
||||
this._pingTimer = setInterval(() => {
|
||||
if (!this.alive) { clearInterval(this._pingTimer); return; }
|
||||
if (!this._pongReceived) {
|
||||
console.log('[oc-config] Pong timeout, closing connection');
|
||||
this._cleanup();
|
||||
return;
|
||||
}
|
||||
this._pongReceived = false;
|
||||
try { this.socket.write(encodeWSFrame(Buffer.alloc(0), 0x09)); } catch(e) { this._cleanup(); }
|
||||
}, 25000);
|
||||
}
|
||||
|
||||
_setupWSReader() {
|
||||
this.socket.on('data', (chunk) => {
|
||||
this.buffer = Buffer.concat([this.buffer, chunk]);
|
||||
while (this.buffer.length > 0) {
|
||||
const frame = decodeWSFrame(this.buffer);
|
||||
if (!frame) break;
|
||||
this.buffer = this.buffer.slice(frame.totalLen);
|
||||
if (frame.opcode === 0x01) this._handleMessage(frame.data.toString());
|
||||
else if (frame.opcode === 0x02 && this.proc && this.proc.stdin.writable) this.proc.stdin.write(frame.data);
|
||||
else if (frame.opcode === 0x08) { console.log('[oc-config] WS close frame received'); this._cleanup(); }
|
||||
else if (frame.opcode === 0x09) this.socket.write(encodeWSFrame(frame.data, 0x0a));
|
||||
else if (frame.opcode === 0x0a) { this._pongReceived = true; }
|
||||
}
|
||||
});
|
||||
this.socket.on('close', (hadError) => { console.log(`[oc-config] Socket closed, hadError=${hadError}`); this._cleanup(); });
|
||||
this.socket.on('error', (err) => { console.log(`[oc-config] Socket error: ${err.message}`); this._cleanup(); });
|
||||
}
|
||||
|
||||
_handleMessage(text) {
|
||||
try {
|
||||
const msg = JSON.parse(text);
|
||||
if (msg.type === 'stdin' && this.proc && this.proc.stdin.writable) {
|
||||
// 去除 bracketed paste 转义序列,避免污染 shell read 输入
|
||||
const cleaned = msg.data.replace(/\x1b\[\?2004[hl]/g, '').replace(/\x1b\[20[01]~/g, '');
|
||||
this.proc.stdin.write(cleaned);
|
||||
}
|
||||
else if (msg.type === 'resize') {
|
||||
this.cols = msg.cols || 80; this.rows = msg.rows || 24;
|
||||
if (this.proc && this.proc.pid) { try { process.kill(-this.proc.pid, 'SIGWINCH'); } catch(e){} }
|
||||
}
|
||||
else if (msg.type === 'ping') {
|
||||
// 应用层心跳: 客户端定期发送 ping,服务端回复 pong 保持连接活跃
|
||||
this.socket.write(encodeWSFrame(JSON.stringify({ type: 'pong' }), 0x01));
|
||||
}
|
||||
} catch(e) { if (this.proc && this.proc.stdin.writable) this.proc.stdin.write(text); }
|
||||
}
|
||||
|
||||
_spawnPty() {
|
||||
const env = {
|
||||
...process.env, TERM: 'xterm-256color', COLUMNS: String(this.cols), LINES: String(this.rows),
|
||||
COLORTERM: 'truecolor', LANG: 'en_US.UTF-8',
|
||||
NODE_BASE, OC_GLOBAL, OC_DATA,
|
||||
HOME: OC_DATA,
|
||||
OPENCLAW_HOME: OC_DATA,
|
||||
OPENCLAW_STATE_DIR: `${OC_DATA}/.openclaw`,
|
||||
OPENCLAW_CONFIG_PATH: `${OC_DATA}/.openclaw/openclaw.json`,
|
||||
PATH: `${NODE_BASE}/bin:${OC_GLOBAL}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`,
|
||||
};
|
||||
// 检测 script 命令是否可用 (OpenWrt 默认不包含 util-linux-script)
|
||||
// 如不可用则回退到直接用 sh 执行,牺牲 PTY 但保证功能可用
|
||||
const hasScript = (() => {
|
||||
try {
|
||||
const { execFileSync } = require('child_process');
|
||||
execFileSync('which', ['script'], { stdio: 'pipe', timeout: 2000 });
|
||||
return true;
|
||||
} catch { return false; }
|
||||
})();
|
||||
if (hasScript) {
|
||||
this.proc = spawn('script', ['-qc', `stty rows ${this.rows} cols ${this.cols} 2>/dev/null; printf '\\e[?2004l'; sh "${SCRIPT_PATH}"`, '/dev/null'],
|
||||
{ stdio: ['pipe', 'pipe', 'pipe'], env, detached: true });
|
||||
} else {
|
||||
console.log('[oc-config] "script" command not found, falling back to sh (install util-linux-script for full PTY support)');
|
||||
this.proc = spawn('sh', [SCRIPT_PATH],
|
||||
{ stdio: ['pipe', 'pipe', 'pipe'], env, detached: true });
|
||||
}
|
||||
|
||||
this.proc.stdout.on('data', (d) => { if (this.alive) { this._spawnFailCount = 0; this.socket.write(encodeWSFrame(d, 0x01)); } });
|
||||
this.proc.stderr.on('data', (d) => { if (this.alive) { this._spawnFailCount = 0; this.socket.write(encodeWSFrame(d, 0x01)); } });
|
||||
this.proc.on('close', (code) => {
|
||||
if (!this.alive) return;
|
||||
// PTY 以 root 运行,子脚本可能创建了 root-owned 的目录
|
||||
// 修复权限,防止以 openclaw 用户运行的 Gateway 遇到 EACCES
|
||||
try { require('child_process').execFileSync('chown', ['-R', 'openclaw:openclaw', OC_DATA], { stdio: 'pipe', timeout: 5000 }); } catch(e) {}
|
||||
this._spawnFailCount++;
|
||||
if (this._spawnFailCount > this._MAX_SPAWN_RETRIES) {
|
||||
console.log(`[oc-config] Script failed ${this._spawnFailCount} times, stopping retries`);
|
||||
this.socket.write(encodeWSFrame(`\r\n\x1b[31m配置脚本连续启动失败 ${this._spawnFailCount} 次,已停止重试。\r\n请检查是否已安装 util-linux-script 包: opkg install coreutils-script\x1b[0m\r\n`, 0x01));
|
||||
this.proc = null;
|
||||
return;
|
||||
}
|
||||
console.log(`[oc-config] Script exited with code ${code}, auto-restarting (attempt ${this._spawnFailCount}/${this._MAX_SPAWN_RETRIES})...`);
|
||||
this.socket.write(encodeWSFrame(`\r\n\x1b[33m配置脚本已退出 (code: ${code}),正在自动重启...\x1b[0m\r\n`, 0x01));
|
||||
this.proc = null;
|
||||
// 自动重启脚本,保持 WebSocket 连接
|
||||
setTimeout(() => {
|
||||
if (this.alive) {
|
||||
this._spawnPty();
|
||||
}
|
||||
}, 1500);
|
||||
});
|
||||
this.proc.on('error', (err) => {
|
||||
this._spawnFailCount++;
|
||||
if (this.alive) this.socket.write(encodeWSFrame(`\r\n\x1b[31m启动失败: ${err.message}\x1b[0m\r\n`, 0x01));
|
||||
});
|
||||
}
|
||||
|
||||
_cleanup() {
|
||||
if (!this.alive) return; this.alive = false;
|
||||
if (this._pingTimer) { clearInterval(this._pingTimer); this._pingTimer = null; }
|
||||
activeSessions = Math.max(0, activeSessions - 1);
|
||||
console.log(`[oc-config] Session ended (active: ${activeSessions}/${MAX_SESSIONS})`);
|
||||
if (this.proc) { try { process.kill(-this.proc.pid, 'SIGTERM'); } catch(e){} try { this.proc.kill('SIGTERM'); } catch(e){} }
|
||||
try { this.socket.destroy(); } catch(e){}
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTTP 请求处理 ──
|
||||
function handleRequest(req, res) {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
let fp = url.pathname;
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Headers': '*' });
|
||||
return res.end();
|
||||
}
|
||||
if (fp === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
||||
return res.end(JSON.stringify({ status: 'ok', port: PORT, uptime: process.uptime() }));
|
||||
}
|
||||
if (fp === '/' || fp === '') fp = '/index.html';
|
||||
|
||||
const fullPath = path.join(UI_DIR, fp);
|
||||
if (!fullPath.startsWith(UI_DIR)) { res.writeHead(403); return res.end('Forbidden'); }
|
||||
|
||||
fs.readFile(fullPath, (err, data) => {
|
||||
if (err) {
|
||||
if (fp !== '/index.html') {
|
||||
fs.readFile(path.join(UI_DIR, 'index.html'), (e2, d2) => {
|
||||
if (e2) { res.writeHead(404); res.end('Not Found'); }
|
||||
else { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', ...IFRAME_HEADERS }); res.end(d2); }
|
||||
});
|
||||
} else { res.writeHead(404); res.end('Not Found'); }
|
||||
return;
|
||||
}
|
||||
const ext = path.extname(fullPath);
|
||||
res.writeHead(200, { 'Content-Type': getMimeType(ext), 'Cache-Control': ext === '.html' ? 'no-cache' : 'max-age=3600', ...IFRAME_HEADERS });
|
||||
res.end(data);
|
||||
});
|
||||
}
|
||||
|
||||
// ── WebSocket Upgrade ──
|
||||
function handleUpgrade(req, socket, head) {
|
||||
console.log(`[oc-config] WS upgrade: ${req.url} remote=${socket.remoteAddress}:${socket.remotePort}`);
|
||||
if (req.url !== '/ws' && !req.url.startsWith('/ws?')) { socket.destroy(); return; }
|
||||
|
||||
// 认证: 验证查询参数中的 token
|
||||
// 每次连接时实时读取 UCI token (安装/升级可能重新生成 token)
|
||||
const currentToken = loadAuthToken() || AUTH_TOKEN;
|
||||
if (currentToken) {
|
||||
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
||||
const clientToken = url.searchParams.get('token') || '';
|
||||
if (clientToken !== currentToken) {
|
||||
console.log(`[oc-config] WS auth failed from ${socket.remoteAddress}`);
|
||||
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 并发会话限制
|
||||
if (activeSessions >= MAX_SESSIONS) {
|
||||
console.log(`[oc-config] Max sessions reached (${activeSessions}/${MAX_SESSIONS}), rejecting`);
|
||||
socket.write('HTTP/1.1 503 Service Unavailable\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const key = req.headers['sec-websocket-key'];
|
||||
if (!key) { console.log('[oc-config] Missing Sec-WebSocket-Key'); socket.destroy(); return; }
|
||||
|
||||
const accept = crypto.createHash('sha1').update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
|
||||
|
||||
socket.setNoDelay(true);
|
||||
socket.setTimeout(0);
|
||||
|
||||
const handshake = 'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ' + accept + '\r\n\r\n';
|
||||
|
||||
socket.write(handshake, () => {
|
||||
if (head && head.length > 0) socket.unshift(head);
|
||||
new PtySession(socket);
|
||||
console.log('[oc-config] PTY session started');
|
||||
});
|
||||
}
|
||||
|
||||
// ── 服务器实例 ──
|
||||
const httpServer = http.createServer(handleRequest);
|
||||
httpServer.on('upgrade', handleUpgrade);
|
||||
let httpsServer = null;
|
||||
|
||||
httpServer.listen(PORT, HOST, () => {
|
||||
console.log(`[oc-config] HTTP listening on ${HOST}:${PORT}`);
|
||||
console.log(`[oc-config] Script: ${SCRIPT_PATH}`);
|
||||
});
|
||||
|
||||
// HTTPS 可选端口 PORT+1
|
||||
const HTTPS_PORT = PORT + 1;
|
||||
try {
|
||||
if (fs.existsSync(SSL_CERT) && fs.existsSync(SSL_KEY)) {
|
||||
httpsServer = https.createServer({ cert: fs.readFileSync(SSL_CERT), key: fs.readFileSync(SSL_KEY) }, handleRequest);
|
||||
httpsServer.on('upgrade', handleUpgrade);
|
||||
httpsServer.listen(HTTPS_PORT, HOST, () => console.log(`[oc-config] HTTPS listening on ${HOST}:${HTTPS_PORT}`));
|
||||
httpsServer.on('error', (e) => console.log(`[oc-config] HTTPS port ${HTTPS_PORT}: ${e.message}`));
|
||||
}
|
||||
} catch (e) { console.log(`[oc-config] SSL init: ${e.message}`); }
|
||||
|
||||
httpServer.on('error', (e) => { console.error(`[oc-config] Fatal: ${e.message}`); process.exit(1); });
|
||||
function shutdown() { console.log('[oc-config] Shutdown'); httpServer.close(); if (httpsServer) httpsServer.close(); process.exit(0); }
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
@ -0,0 +1,11 @@
|
||||
{
|
||||
"luci-app-openclaw": {
|
||||
"description": "Grant UCI access for luci-app-openclaw",
|
||||
"read": {
|
||||
"uci": [ "openclaw" ]
|
||||
},
|
||||
"write": {
|
||||
"uci": [ "openclaw" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
114
luci-app-openclaw/scripts/build-node-musl.sh
Executable file
114
luci-app-openclaw/scripts/build-node-musl.sh
Executable file
@ -0,0 +1,114 @@
|
||||
#!/bin/sh
|
||||
# 在 Alpine ARM64 Docker 容器内运行
|
||||
# 环境变量: NODE_VER (目标版本号), /output (输出目录)
|
||||
#
|
||||
# 打包策略:
|
||||
# 使用 patchelf 修改 node 二进制的 ELF interpreter 和 rpath,
|
||||
# 使其直接使用打包的 musl 链接器和共享库,无需 LD_LIBRARY_PATH。
|
||||
# 这样 process.execPath 返回正确的 node 路径,子进程 fork 也能正常工作。
|
||||
# 安装路径固定为 /opt/openclaw/node (与 openclaw-env 一致)。
|
||||
set -e
|
||||
|
||||
INSTALL_PREFIX="/opt/openclaw/node"
|
||||
|
||||
apk add --no-cache nodejs npm xz icu-data-full patchelf
|
||||
|
||||
ACTUAL_VER=$(node --version | sed 's/^v//')
|
||||
echo "Alpine Node.js version: v${ACTUAL_VER} (requested: v${NODE_VER})"
|
||||
|
||||
# 使用实际版本号作为文件名 (Alpine apk 的 nodejs 版本可能与请求版本不同)
|
||||
if [ "$ACTUAL_VER" != "$NODE_VER" ]; then
|
||||
echo "WARNING: Actual version (${ACTUAL_VER}) differs from requested (${NODE_VER})"
|
||||
echo " Using actual version for package name"
|
||||
fi
|
||||
PKG_NAME="node-v${ACTUAL_VER}-linux-arm64-musl"
|
||||
PKG_DIR="/tmp/${PKG_NAME}"
|
||||
mkdir -p "${PKG_DIR}/bin" "${PKG_DIR}/lib/node_modules" "${PKG_DIR}/include/node"
|
||||
|
||||
# 复制 node 二进制
|
||||
cp "$(which node)" "${PKG_DIR}/bin/node"
|
||||
chmod +x "${PKG_DIR}/bin/node"
|
||||
|
||||
# 收集 node 依赖的所有共享库 (Alpine node 是动态链接的)
|
||||
echo "=== Collecting shared libraries ==="
|
||||
LIB_DIR="${PKG_DIR}/lib"
|
||||
ldd "$(which node)" 2>/dev/null | while read -r line; do
|
||||
# 解析 ldd 输出: libxxx.so => /usr/lib/libxxx.so (0x...)
|
||||
lib_path=$(echo "$line" | grep -oE '/[^ ]+\.so[^ ]*' | head -1)
|
||||
if [ -n "$lib_path" ] && [ -f "$lib_path" ]; then
|
||||
cp -L "$lib_path" "$LIB_DIR/" 2>/dev/null || true
|
||||
echo " + $(basename "$lib_path")"
|
||||
fi
|
||||
done
|
||||
# 确保 musl 动态链接器也在
|
||||
if [ -f /lib/ld-musl-aarch64.so.1 ]; then
|
||||
cp -L /lib/ld-musl-aarch64.so.1 "$LIB_DIR/" 2>/dev/null || true
|
||||
echo " + ld-musl-aarch64.so.1"
|
||||
fi
|
||||
echo "Libraries collected: $(ls "$LIB_DIR"/*.so* 2>/dev/null | wc -l) files"
|
||||
|
||||
# 用 patchelf 修改 node 二进制:
|
||||
# - interpreter 指向打包的 musl 链接器 (绝对路径,对应安装后的位置)
|
||||
# - rpath 指向打包的 lib 目录
|
||||
echo "=== Patching ELF binary ==="
|
||||
patchelf --set-interpreter "${INSTALL_PREFIX}/lib/ld-musl-aarch64.so.1" "${PKG_DIR}/bin/node"
|
||||
patchelf --set-rpath "${INSTALL_PREFIX}/lib" "${PKG_DIR}/bin/node"
|
||||
echo " interpreter: ${INSTALL_PREFIX}/lib/ld-musl-aarch64.so.1"
|
||||
echo " rpath: ${INSTALL_PREFIX}/lib"
|
||||
|
||||
# 复制 ICU 完整数据 (npm 的 Intl.Collator 需要)
|
||||
echo "=== Copying ICU data ==="
|
||||
ICU_DAT=$(find /usr/share/icu -name "icudt*.dat" 2>/dev/null | head -1)
|
||||
if [ -n "$ICU_DAT" ] && [ -f "$ICU_DAT" ]; then
|
||||
mkdir -p "${PKG_DIR}/share/icu"
|
||||
cp "$ICU_DAT" "${PKG_DIR}/share/icu/"
|
||||
echo " + $(basename "$ICU_DAT") ($(du -h "$ICU_DAT" | cut -f1))"
|
||||
else
|
||||
echo " WARNING: ICU data file not found"
|
||||
fi
|
||||
|
||||
# 创建 node wrapper 脚本 (只设置 NODE_ICU_DATA,ELF 层面已解决链接器和库路径)
|
||||
cat > "${PKG_DIR}/bin/node-wrapper" << 'NODEWRAPPER'
|
||||
#!/bin/sh
|
||||
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
export NODE_ICU_DATA="${SELF_DIR}/../share/icu"
|
||||
exec "${SELF_DIR}/node" "$@"
|
||||
NODEWRAPPER
|
||||
chmod +x "${PKG_DIR}/bin/node-wrapper"
|
||||
|
||||
# 复制 npm
|
||||
if [ -d /usr/lib/node_modules/npm ]; then
|
||||
cp -r /usr/lib/node_modules/npm "${PKG_DIR}/lib/node_modules/"
|
||||
fi
|
||||
|
||||
# 创建 npm wrapper (直接调用 patchelf 后的 node,只需设置 ICU)
|
||||
cat > "${PKG_DIR}/bin/npm" << 'NPMWRAPPER'
|
||||
#!/bin/sh
|
||||
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
export NODE_ICU_DATA="${SELF_DIR}/../share/icu"
|
||||
exec "${SELF_DIR}/node" "${SELF_DIR}/../lib/node_modules/npm/bin/npm-cli.js" "$@"
|
||||
NPMWRAPPER
|
||||
# 创建 npx wrapper
|
||||
cat > "${PKG_DIR}/bin/npx" << 'NPXWRAPPER'
|
||||
#!/bin/sh
|
||||
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
export NODE_ICU_DATA="${SELF_DIR}/../share/icu"
|
||||
exec "${SELF_DIR}/node" "${SELF_DIR}/../lib/node_modules/npm/bin/npx-cli.js" "$@"
|
||||
NPXWRAPPER
|
||||
chmod +x "${PKG_DIR}/bin/npm" "${PKG_DIR}/bin/npx"
|
||||
|
||||
# 验证 (需要将打包内容放到目标路径来测试 patchelf 结果)
|
||||
echo "=== Verification ==="
|
||||
mkdir -p "${INSTALL_PREFIX}"
|
||||
cp -a "${PKG_DIR}"/* "${INSTALL_PREFIX}/"
|
||||
"${INSTALL_PREFIX}/bin/node" --version
|
||||
"${INSTALL_PREFIX}/bin/node" -e "console.log('execPath:', process.execPath)"
|
||||
"${INSTALL_PREFIX}/bin/node" -e "console.log(process.arch, process.platform, process.versions.modules)"
|
||||
NODE_ICU_DATA="${INSTALL_PREFIX}/share/icu" "${INSTALL_PREFIX}/bin/npm" --version 2>/dev/null || echo "npm needs ICU data"
|
||||
rm -rf "${INSTALL_PREFIX}"
|
||||
|
||||
# 打包
|
||||
cd /tmp
|
||||
tar cJf "/output/${PKG_NAME}.tar.xz" "${PKG_NAME}"
|
||||
ls -lh "/output/${PKG_NAME}.tar.xz"
|
||||
echo "=== Done: ${PKG_NAME}.tar.xz ==="
|
||||
147
luci-app-openclaw/scripts/build_ipk.sh
Executable file
147
luci-app-openclaw/scripts/build_ipk.sh
Executable file
@ -0,0 +1,147 @@
|
||||
#!/bin/sh
|
||||
# ============================================================================
|
||||
# 本地构建 .ipk 包 (无需 OpenWrt SDK)
|
||||
# 用法: sh scripts/build_ipk.sh [output_dir]
|
||||
# ============================================================================
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
PKG_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
|
||||
OUT_DIR="${1:-$PKG_DIR/dist}"
|
||||
# 确保 OUT_DIR 是绝对路径
|
||||
case "$OUT_DIR" in
|
||||
/*) ;;
|
||||
*) OUT_DIR="$PKG_DIR/$OUT_DIR" ;;
|
||||
esac
|
||||
mkdir -p "$OUT_DIR"
|
||||
PKG_NAME="luci-app-openclaw"
|
||||
PKG_VERSION=$(cat "$PKG_DIR/VERSION" 2>/dev/null | tr -d '[:space:]' || echo "1.0.0")
|
||||
PKG_RELEASE="1"
|
||||
|
||||
echo "=== 构建 ${PKG_NAME} .ipk 包 ==="
|
||||
|
||||
STAGING=$(mktemp -d)
|
||||
trap "rm -rf '$STAGING'" EXIT
|
||||
|
||||
# ── 构建 data.tar.gz ──
|
||||
DATA_DIR="$STAGING/data"
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
# UCI config
|
||||
mkdir -p "$DATA_DIR/etc/config"
|
||||
cp "$PKG_DIR/root/etc/config/openclaw" "$DATA_DIR/etc/config/"
|
||||
|
||||
# UCI defaults
|
||||
mkdir -p "$DATA_DIR/etc/uci-defaults"
|
||||
cp "$PKG_DIR/root/etc/uci-defaults/99-openclaw" "$DATA_DIR/etc/uci-defaults/"
|
||||
chmod +x "$DATA_DIR/etc/uci-defaults/99-openclaw"
|
||||
|
||||
# init.d
|
||||
mkdir -p "$DATA_DIR/etc/init.d"
|
||||
cp "$PKG_DIR/root/etc/init.d/openclaw" "$DATA_DIR/etc/init.d/"
|
||||
chmod +x "$DATA_DIR/etc/init.d/openclaw"
|
||||
|
||||
# bin
|
||||
mkdir -p "$DATA_DIR/usr/bin"
|
||||
cp "$PKG_DIR/root/usr/bin/openclaw-env" "$DATA_DIR/usr/bin/"
|
||||
chmod +x "$DATA_DIR/usr/bin/openclaw-env"
|
||||
|
||||
# LuCI controller
|
||||
mkdir -p "$DATA_DIR/usr/lib/lua/luci/controller"
|
||||
cp "$PKG_DIR/luasrc/controller/openclaw.lua" "$DATA_DIR/usr/lib/lua/luci/controller/"
|
||||
|
||||
# LuCI CBI
|
||||
mkdir -p "$DATA_DIR/usr/lib/lua/luci/model/cbi/openclaw"
|
||||
cp "$PKG_DIR/luasrc/model/cbi/openclaw/"*.lua "$DATA_DIR/usr/lib/lua/luci/model/cbi/openclaw/"
|
||||
|
||||
# LuCI views
|
||||
mkdir -p "$DATA_DIR/usr/lib/lua/luci/view/openclaw"
|
||||
cp "$PKG_DIR/luasrc/view/openclaw/"*.htm "$DATA_DIR/usr/lib/lua/luci/view/openclaw/"
|
||||
|
||||
# oc-config assets
|
||||
mkdir -p "$DATA_DIR/usr/share/openclaw"
|
||||
cp "$PKG_DIR/VERSION" "$DATA_DIR/usr/share/openclaw/VERSION"
|
||||
cp "$PKG_DIR/root/usr/share/openclaw/oc-config.sh" "$DATA_DIR/usr/share/openclaw/"
|
||||
chmod +x "$DATA_DIR/usr/share/openclaw/oc-config.sh"
|
||||
cp "$PKG_DIR/root/usr/share/openclaw/web-pty.js" "$DATA_DIR/usr/share/openclaw/"
|
||||
|
||||
# Web PTY UI
|
||||
cp -r "$PKG_DIR/root/usr/share/openclaw/ui" "$DATA_DIR/usr/share/openclaw/"
|
||||
|
||||
# i18n (po2lmo 可选)
|
||||
mkdir -p "$DATA_DIR/usr/lib/lua/luci/i18n"
|
||||
if command -v po2lmo >/dev/null 2>&1 && [ -f "$PKG_DIR/po/zh-cn/openclaw.po" ]; then
|
||||
po2lmo "$PKG_DIR/po/zh-cn/openclaw.po" "$DATA_DIR/usr/lib/lua/luci/i18n/openclaw.zh-cn.lmo" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 计算安装大小
|
||||
INSTALLED_SIZE=$(du -sk "$DATA_DIR" | awk '{print $1}')
|
||||
|
||||
(cd "$DATA_DIR" && tar czf "$STAGING/data.tar.gz" .)
|
||||
|
||||
# ── 构建 control.tar.gz ──
|
||||
CTRL_DIR="$STAGING/control"
|
||||
mkdir -p "$CTRL_DIR"
|
||||
|
||||
cat > "$CTRL_DIR/control" << EOF
|
||||
Package: ${PKG_NAME}
|
||||
Version: ${PKG_VERSION}-${PKG_RELEASE}
|
||||
Depends: luci-compat, luci-base, curl, openssl-util, script-utils, tar
|
||||
Source: https://github.com/10000ge10000/luci-app-openclaw
|
||||
SourceName: ${PKG_NAME}
|
||||
License: GPL-3.0
|
||||
Section: luci
|
||||
SourceDateEpoch: $(date +%s)
|
||||
Maintainer: 10000ge10000 <10000ge10000@users.noreply.github.com>
|
||||
Architecture: all
|
||||
Installed-Size: ${INSTALLED_SIZE}
|
||||
Description: OpenClaw AI 网关 LuCI 管理插件
|
||||
EOF
|
||||
|
||||
cat > "$CTRL_DIR/postinst" << 'EOF'
|
||||
#!/bin/sh
|
||||
[ -n "${IPKG_INSTROOT}" ] || {
|
||||
( . /etc/uci-defaults/99-openclaw ) && rm -f /etc/uci-defaults/99-openclaw
|
||||
rm -f /tmp/luci-indexcache /tmp/luci-modulecache/* /tmp/luci-indexcache.*.json 2>/dev/null
|
||||
# 重启 Web PTY (使其加载新文件和新 token)
|
||||
PTY_PID=$(pgrep -f 'web-pty.js' 2>/dev/null | head -1)
|
||||
[ -n "$PTY_PID" ] && kill "$PTY_PID" 2>/dev/null || true
|
||||
exit 0
|
||||
}
|
||||
EOF
|
||||
chmod +x "$CTRL_DIR/postinst"
|
||||
|
||||
cat > "$CTRL_DIR/postrm" << 'EOF'
|
||||
#!/bin/sh
|
||||
[ -n "${IPKG_INSTROOT}" ] || {
|
||||
rm -f /tmp/luci-indexcache /tmp/luci-modulecache/* 2>/dev/null
|
||||
}
|
||||
EOF
|
||||
chmod +x "$CTRL_DIR/postrm"
|
||||
|
||||
cat > "$CTRL_DIR/conffiles" << 'EOF'
|
||||
/etc/config/openclaw
|
||||
EOF
|
||||
|
||||
(cd "$CTRL_DIR" && tar czf "$STAGING/control.tar.gz" .)
|
||||
|
||||
# ── 组装 .ipk (ar 格式) ──
|
||||
mkdir -p "$OUT_DIR"
|
||||
IPK_FILE="$OUT_DIR/${PKG_NAME}_${PKG_VERSION}-${PKG_RELEASE}_all.ipk"
|
||||
|
||||
echo "2.0" > "$STAGING/debian-binary"
|
||||
|
||||
# 清理旧文件
|
||||
rm -f "$IPK_FILE"
|
||||
|
||||
# 组装 .ipk — OpenWrt opkg 使用 tar.gz 格式 (非 Debian 的 ar 格式)
|
||||
(cd "$STAGING" && tar czf "$IPK_FILE" debian-binary control.tar.gz data.tar.gz)
|
||||
|
||||
IPK_SIZE=$(wc -c < "$IPK_FILE" | tr -d ' ')
|
||||
echo ""
|
||||
echo "=== 构建完成 ==="
|
||||
echo "输出文件: $IPK_FILE"
|
||||
echo "文件大小: ${IPK_SIZE} bytes"
|
||||
echo "安装大小: ${INSTALLED_SIZE} KB"
|
||||
echo ""
|
||||
echo "安装方法: opkg install ${PKG_NAME}_${PKG_VERSION}-${PKG_RELEASE}_all.ipk"
|
||||
257
luci-app-openclaw/scripts/build_run.sh
Executable file
257
luci-app-openclaw/scripts/build_run.sh
Executable file
@ -0,0 +1,257 @@
|
||||
#!/bin/sh
|
||||
# ============================================================================
|
||||
# iStoreOS .run 自解压包构建脚本
|
||||
# 用法: sh scripts/build_run.sh [output_dir]
|
||||
# ============================================================================
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
PKG_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
|
||||
OUT_DIR="${1:-$PKG_DIR/dist}"
|
||||
# 确保 OUT_DIR 是绝对路径
|
||||
case "$OUT_DIR" in
|
||||
/*) ;;
|
||||
*) OUT_DIR="$PKG_DIR/$OUT_DIR" ;;
|
||||
esac
|
||||
mkdir -p "$OUT_DIR"
|
||||
PKG_NAME="luci-app-openclaw"
|
||||
PKG_VERSION=$(cat "$PKG_DIR/VERSION" 2>/dev/null | tr -d '[:space:]' || echo "1.0.0")
|
||||
|
||||
echo "=== 构建 iStoreOS .run 安装包 ==="
|
||||
echo "源目录: $PKG_DIR"
|
||||
echo "输出到: $OUT_DIR"
|
||||
|
||||
# 创建临时打包目录
|
||||
STAGING=$(mktemp -d)
|
||||
trap "rm -rf '$STAGING'" EXIT
|
||||
|
||||
# 安装文件到暂存区
|
||||
install_files() {
|
||||
local dest="$1"
|
||||
|
||||
# UCI config (仅首次安装时部署默认配置; 升级时保留用户配置)
|
||||
# 安装器会在解压后检测并跳过已有配置, 见 install.sh 中的逻辑
|
||||
mkdir -p "$dest/etc/config"
|
||||
cp "$PKG_DIR/root/etc/config/openclaw" "$dest/etc/config/openclaw.default"
|
||||
|
||||
# UCI defaults
|
||||
mkdir -p "$dest/etc/uci-defaults"
|
||||
cp "$PKG_DIR/root/etc/uci-defaults/99-openclaw" "$dest/etc/uci-defaults/"
|
||||
chmod +x "$dest/etc/uci-defaults/99-openclaw"
|
||||
|
||||
# init.d
|
||||
mkdir -p "$dest/etc/init.d"
|
||||
cp "$PKG_DIR/root/etc/init.d/openclaw" "$dest/etc/init.d/"
|
||||
chmod +x "$dest/etc/init.d/openclaw"
|
||||
|
||||
# bin
|
||||
mkdir -p "$dest/usr/bin"
|
||||
cp "$PKG_DIR/root/usr/bin/openclaw-env" "$dest/usr/bin/"
|
||||
chmod +x "$dest/usr/bin/openclaw-env"
|
||||
|
||||
# LuCI controller
|
||||
mkdir -p "$dest/usr/lib/lua/luci/controller"
|
||||
cp "$PKG_DIR/luasrc/controller/openclaw.lua" "$dest/usr/lib/lua/luci/controller/"
|
||||
|
||||
# LuCI CBI
|
||||
mkdir -p "$dest/usr/lib/lua/luci/model/cbi/openclaw"
|
||||
cp "$PKG_DIR/luasrc/model/cbi/openclaw/"*.lua "$dest/usr/lib/lua/luci/model/cbi/openclaw/"
|
||||
|
||||
# LuCI views
|
||||
mkdir -p "$dest/usr/lib/lua/luci/view/openclaw"
|
||||
cp "$PKG_DIR/luasrc/view/openclaw/"*.htm "$dest/usr/lib/lua/luci/view/openclaw/"
|
||||
|
||||
# oc-config assets
|
||||
mkdir -p "$dest/usr/share/openclaw"
|
||||
cp "$PKG_DIR/VERSION" "$dest/usr/share/openclaw/VERSION"
|
||||
cp "$PKG_DIR/root/usr/share/openclaw/oc-config.sh" "$dest/usr/share/openclaw/"
|
||||
chmod +x "$dest/usr/share/openclaw/oc-config.sh"
|
||||
cp "$PKG_DIR/root/usr/share/openclaw/web-pty.js" "$dest/usr/share/openclaw/"
|
||||
|
||||
# Web PTY UI (recursive copy)
|
||||
cp -r "$PKG_DIR/root/usr/share/openclaw/ui" "$dest/usr/share/openclaw/"
|
||||
}
|
||||
|
||||
# 创建安装器脚本头部
|
||||
create_installer() {
|
||||
cat > "$STAGING/install.sh" << 'INSTALLER_EOF'
|
||||
#!/bin/sh
|
||||
# luci-app-openclaw iStoreOS 安装器
|
||||
set -e
|
||||
|
||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ luci-app-openclaw — OpenClaw AI Gateway 插件 ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
# 检查系统
|
||||
if [ ! -f /etc/openwrt_release ]; then
|
||||
echo "错误: 此安装包仅适用于 OpenWrt/iStoreOS 系统"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查架构
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64|aarch64) ;;
|
||||
*) echo "错误: 不支持的架构 $ARCH (仅支持 x86_64/aarch64)"; exit 1 ;;
|
||||
esac
|
||||
|
||||
# 检查依赖
|
||||
for dep in luci-compat luci-base; do
|
||||
if ! opkg list-installed 2>/dev/null | grep -q "^${dep} "; then
|
||||
echo "警告: 缺少依赖 $dep,尝试安装..."
|
||||
opkg update >/dev/null 2>&1 || true
|
||||
opkg install "$dep" 2>/dev/null || echo " 安装 $dep 失败,请手动安装"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "正在安装文件..."
|
||||
|
||||
# 解压 payload (从 MARKER 行之后)
|
||||
ARCHIVE=$(awk '/^__ARCHIVE_BELOW__/ {print NR + 1; exit 0; }' "$0")
|
||||
tail -n +$ARCHIVE "$0" | tar xzf - -C / 2>/dev/null
|
||||
|
||||
# UCI 配置文件保护: 升级时不覆盖用户已有配置
|
||||
if [ -f /etc/config/openclaw ] && [ -f /etc/config/openclaw.default ]; then
|
||||
# 已有配置, 移除默认文件 (保留用户配置)
|
||||
rm -f /etc/config/openclaw.default
|
||||
elif [ -f /etc/config/openclaw.default ]; then
|
||||
# 首次安装, 使用默认配置
|
||||
mv /etc/config/openclaw.default /etc/config/openclaw
|
||||
fi
|
||||
|
||||
# 注册到 opkg,使 iStore 和 opkg 能识别此包
|
||||
PKG="luci-app-openclaw"
|
||||
PKG_VER="__PKG_VERSION__"
|
||||
INFO_DIR="/usr/lib/opkg/info"
|
||||
STATUS_FILE="/usr/lib/opkg/status"
|
||||
INSTALL_TIME=$(date +%s)
|
||||
|
||||
mkdir -p "$INFO_DIR"
|
||||
|
||||
# 写入 control 文件
|
||||
cat > "$INFO_DIR/$PKG.control" << CTLEOF
|
||||
Package: $PKG
|
||||
Version: $PKG_VER
|
||||
Depends: luci-compat, luci-base, curl, openssl-util, script-utils, tar
|
||||
Section: luci
|
||||
Architecture: all
|
||||
Installed-Size: 0
|
||||
Description: OpenClaw AI Gateway — LuCI 界面
|
||||
CTLEOF
|
||||
|
||||
# 写入文件列表 (payload 中已安装的文件)
|
||||
cat > "$INFO_DIR/$PKG.list" << LISTEOF
|
||||
__FILE_LIST__
|
||||
LISTEOF
|
||||
|
||||
# 写入 prerm 脚本 (卸载前执行)
|
||||
cat > "$INFO_DIR/$PKG.prerm" << 'RMEOF'
|
||||
#!/bin/sh
|
||||
/etc/init.d/openclaw stop 2>/dev/null
|
||||
/etc/init.d/openclaw disable 2>/dev/null
|
||||
exit 0
|
||||
RMEOF
|
||||
chmod +x "$INFO_DIR/$PKG.prerm"
|
||||
|
||||
# 追加到 opkg status 数据库 (先移除旧记录)
|
||||
if [ -f "$STATUS_FILE" ]; then
|
||||
awk -v pkg="$PKG" '
|
||||
BEGIN { skip=0 }
|
||||
/^Package:/ { skip=($2==pkg) }
|
||||
/^$/ { if(skip){skip=0; next} }
|
||||
!skip { print }
|
||||
' "$STATUS_FILE" > "${STATUS_FILE}.tmp"
|
||||
mv "${STATUS_FILE}.tmp" "$STATUS_FILE"
|
||||
fi
|
||||
|
||||
cat >> "$STATUS_FILE" << STEOF
|
||||
|
||||
Package: $PKG
|
||||
Version: $PKG_VER
|
||||
Depends: luci-compat, luci-base, curl, openssl-util, script-utils, tar
|
||||
Status: install user installed
|
||||
Architecture: all
|
||||
Conffiles:
|
||||
/etc/config/openclaw 0
|
||||
Installed-Time: $INSTALL_TIME
|
||||
STEOF
|
||||
|
||||
echo "已注册到 opkg (iStore 可管理)"
|
||||
|
||||
# 执行 uci-defaults
|
||||
if [ -f /etc/uci-defaults/99-openclaw ]; then
|
||||
echo "执行初始化脚本..."
|
||||
( . /etc/uci-defaults/99-openclaw ) && rm -f /etc/uci-defaults/99-openclaw
|
||||
fi
|
||||
|
||||
# 清除 LuCI 缓存
|
||||
rm -f /tmp/luci-indexcache /tmp/luci-modulecache/* 2>/dev/null
|
||||
rm -f /tmp/luci-indexcache.*.json 2>/dev/null
|
||||
|
||||
# 重启 Web PTY 服务 (使其加载新文件和新 token)
|
||||
# PTY 是 procd 管理的实例, kill 后 procd 会自动 respawn
|
||||
PTY_PID=$(pgrep -f 'web-pty.js' 2>/dev/null | head -1)
|
||||
if [ -n "$PTY_PID" ]; then
|
||||
echo "重启配置终端服务..."
|
||||
kill "$PTY_PID" 2>/dev/null
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ 安装完成!"
|
||||
echo ""
|
||||
echo "后续步骤:"
|
||||
echo " 1. 运行 openclaw-env setup — 下载 Node.js 并安装 OpenClaw"
|
||||
echo " 2. 访问 LuCI → 服务 → OpenClaw 进行配置"
|
||||
echo " 3. 或执行 /etc/init.d/openclaw enable && /etc/init.d/openclaw start"
|
||||
echo ""
|
||||
|
||||
exit 0
|
||||
__ARCHIVE_BELOW__
|
||||
INSTALLER_EOF
|
||||
}
|
||||
|
||||
# 构建
|
||||
echo ""
|
||||
echo "[1/4] 安装文件到暂存区..."
|
||||
install_files "$STAGING/payload"
|
||||
|
||||
echo "[2/4] 生成文件列表..."
|
||||
# 生成安装文件列表 (供 opkg 卸载时使用)
|
||||
# 注: openclaw.default 安装后会变为 openclaw, 文件列表中记录最终路径
|
||||
FILE_LIST=$(cd "$STAGING/payload" && find . -type f | sed 's|^\./|/|' | sed 's|/etc/config/openclaw.default|/etc/config/openclaw|' | sort)
|
||||
echo " 共 $(echo "$FILE_LIST" | wc -l | tr -d ' ') 个文件"
|
||||
|
||||
echo "[3/4] 创建安装器..."
|
||||
create_installer
|
||||
|
||||
# 替换安装器中的占位符
|
||||
sed -i "s|__PKG_VERSION__|${PKG_VERSION}|g" "$STAGING/install.sh"
|
||||
# 替换文件列表占位符 — 使用临时文件拼接避免 sed/awk 多行问题
|
||||
{
|
||||
sed '/__FILE_LIST__/,$d' "$STAGING/install.sh"
|
||||
echo "$FILE_LIST"
|
||||
sed '1,/__FILE_LIST__/d' "$STAGING/install.sh"
|
||||
} > "$STAGING/install_final.sh"
|
||||
mv "$STAGING/install_final.sh" "$STAGING/install.sh"
|
||||
|
||||
echo "[4/4] 打包..."
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
# 创建 payload tarball
|
||||
(cd "$STAGING/payload" && tar czf "$STAGING/payload.tar.gz" .)
|
||||
|
||||
# 组合: installer header + payload
|
||||
RUN_FILE="$OUT_DIR/${PKG_NAME}_${PKG_VERSION}.run"
|
||||
cat "$STAGING/install.sh" "$STAGING/payload.tar.gz" > "$RUN_FILE"
|
||||
chmod +x "$RUN_FILE"
|
||||
|
||||
FILE_SIZE=$(wc -c < "$RUN_FILE" | tr -d ' ')
|
||||
echo ""
|
||||
echo "=== 构建完成 ==="
|
||||
echo "输出文件: $RUN_FILE"
|
||||
echo "文件大小: $FILE_SIZE bytes"
|
||||
echo ""
|
||||
echo "安装方法: sh ${PKG_NAME}_${PKG_VERSION}.run"
|
||||
41
luci-app-openclaw/scripts/gen-release-body.sh
Normal file
41
luci-app-openclaw/scripts/gen-release-body.sh
Normal file
@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
# 用法: gen-release-body.sh <版本号> <CHANGELOG路径> <输出目录>
|
||||
# 为指定版本生成 GitHub Release body markdown 文件
|
||||
set -e
|
||||
|
||||
VER="$1"
|
||||
CHANGELOG_FILE="$2"
|
||||
OUT_DIR="$3"
|
||||
|
||||
if [ -z "$VER" ] || [ -z "$CHANGELOG_FILE" ] || [ -z "$OUT_DIR" ]; then
|
||||
echo "用法: $0 <版本号> <CHANGELOG路径> <输出目录>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
# 提取该版本的 changelog
|
||||
CONTENT=$(awk "/^## \\[${VER}\\]/{found=1; next} /^## \\[/{if(found) exit} found{print}" "$CHANGELOG_FILE")
|
||||
if [ -z "$CONTENT" ]; then
|
||||
CONTENT="暂无更新日志"
|
||||
fi
|
||||
|
||||
# 写入文件
|
||||
{
|
||||
printf '%s\n' "$CONTENT"
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
echo '**在线安装** (需联网,自动下载 Node.js + OpenClaw)'
|
||||
echo '```'
|
||||
echo '# iStoreOS'
|
||||
echo "sh luci-app-openclaw_${VER}.run"
|
||||
echo ''
|
||||
echo '# OpenWrt'
|
||||
echo "opkg install luci-app-openclaw_${VER}-1_all.ipk"
|
||||
echo '```'
|
||||
echo ''
|
||||
echo '[使用文档](https://github.com/10000ge10000/luci-app-openclaw#readme) · [问题反馈](https://github.com/10000ge10000/luci-app-openclaw/issues) · [B站](https://space.bilibili.com/59438380) · [博客](https://blog.910501.xyz/)'
|
||||
} > "${OUT_DIR}/${VER}.md"
|
||||
|
||||
echo "✓ ${VER}.md ($(wc -l < "${OUT_DIR}/${VER}.md") 行)"
|
||||
230
luci-app-openclaw/scripts/sync_openlist.sh
Executable file
230
luci-app-openclaw/scripts/sync_openlist.sh
Executable file
@ -0,0 +1,230 @@
|
||||
#!/bin/sh
|
||||
# ============================================================================
|
||||
# OpenList 网盘同步脚本 — 补齐所有历史版本 + 上传更新记录
|
||||
#
|
||||
# 功能:
|
||||
# 1. 从 GitHub Releases 下载所有版本的 .run + .ipk
|
||||
# 2. 从 CHANGELOG.md 提取每个版本的更新记录,生成 更新记录.txt
|
||||
# 3. 上传到 OpenList 网盘的 openclaw-在线安装 目录
|
||||
#
|
||||
# 用法:
|
||||
# sh scripts/sync_openlist.sh
|
||||
# ============================================================================
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
PKG_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
|
||||
|
||||
# ── 配置 ──
|
||||
GITHUB_REPO="10000ge10000/luci-app-openclaw"
|
||||
OPENLIST_URL="http://124.243.178.237:15244"
|
||||
OPENLIST_USER="admin"
|
||||
OPENLIST_PASS="mingmenmama"
|
||||
OPENLIST_ROOT="/Quark"
|
||||
UPLOAD_SUBDIR="openclaw-在线安装"
|
||||
CHANGELOG="$PKG_DIR/CHANGELOG.md"
|
||||
WORK_DIR="/tmp/openlist-sync"
|
||||
|
||||
# 所有已发布的版本 (按时间顺序)
|
||||
ALL_VERSIONS="1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.0.10 1.0.11 1.0.12 1.0.14 1.0.15"
|
||||
|
||||
log_info() { printf " [\033[32m✓\033[0m] %s\n" "$1"; }
|
||||
log_warn() { printf " [\033[33m!\033[0m] %s\n" "$1"; }
|
||||
log_error() { printf " [\033[31m✗\033[0m] %s\n" "$1"; }
|
||||
log_skip() { printf " [\033[36m-\033[0m] %s\n" "$1"; }
|
||||
|
||||
# ── 获取 Token ──
|
||||
get_token() {
|
||||
local resp
|
||||
resp=$(curl -s -X POST "${OPENLIST_URL}/api/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"username\":\"${OPENLIST_USER}\",\"password\":\"${OPENLIST_PASS}\"}")
|
||||
|
||||
local token
|
||||
token=$(echo "$resp" | grep -o '"token":"[^"]*"' | sed 's/"token":"//;s/"//')
|
||||
|
||||
if [ -z "$token" ]; then
|
||||
log_error "OpenList 登录失败"
|
||||
echo " 响应: $resp"
|
||||
exit 1
|
||||
fi
|
||||
echo "$token"
|
||||
}
|
||||
|
||||
# ── 创建远程目录 ──
|
||||
create_remote_dir() {
|
||||
local token="$1"
|
||||
local remote_path="$2"
|
||||
curl -s -X POST "${OPENLIST_URL}/api/fs/mkdir" \
|
||||
-H "Authorization: ${token}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"path\":\"${remote_path}\"}" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# ── 检查远程文件是否存在 ──
|
||||
remote_file_exists() {
|
||||
local token="$1"
|
||||
local remote_path="$2"
|
||||
local filename="$3"
|
||||
|
||||
local resp
|
||||
resp=$(curl -s -X POST "${OPENLIST_URL}/api/fs/list" \
|
||||
-H "Authorization: ${token}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"path\":\"${remote_path}\",\"refresh\":false}")
|
||||
|
||||
echo "$resp" | grep -q "\"name\":\"${filename}\""
|
||||
}
|
||||
|
||||
# ── 上传单个文件 ──
|
||||
upload_file() {
|
||||
local token="$1"
|
||||
local local_file="$2"
|
||||
local remote_path="$3"
|
||||
local filename=$(basename "$local_file")
|
||||
local fsize=$(du -h "$local_file" | cut -f1)
|
||||
|
||||
local resp
|
||||
resp=$(curl -s -X PUT "${OPENLIST_URL}/api/fs/put" \
|
||||
-H "Authorization: ${token}" \
|
||||
-H "File-Path: ${remote_path}/${filename}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@${local_file}" \
|
||||
--max-time 300 2>/dev/null)
|
||||
|
||||
local code=""
|
||||
code=$(echo "$resp" | grep -o '"code":[0-9]*' | grep -o '[0-9]*')
|
||||
|
||||
if [ "$code" = "200" ]; then
|
||||
log_info "${filename} (${fsize}) 上传成功"
|
||||
else
|
||||
log_error "${filename} 上传失败: $resp"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 从 CHANGELOG.md 提取指定版本的更新日志 ──
|
||||
extract_changelog() {
|
||||
local version="$1"
|
||||
local output_file="$2"
|
||||
|
||||
awk "/^## \\[${version}\\]/{found=1; next} /^## \\[/{if(found) exit} found{print}" \
|
||||
"$CHANGELOG" > "$output_file"
|
||||
|
||||
# 去掉首尾空行
|
||||
sed -i '/./,$!d' "$output_file" # 去掉开头空行
|
||||
sed -i ':a; /^[[:space:]]*$/{ $d; N; ba }' "$output_file" # 去掉末尾空行 (GNU sed)
|
||||
|
||||
if [ ! -s "$output_file" ]; then
|
||||
echo "暂无更新日志" > "$output_file"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 从 GitHub 下载文件 ──
|
||||
download_release_file() {
|
||||
local version="$1"
|
||||
local filename="$2"
|
||||
local output="$3"
|
||||
|
||||
local url="https://github.com/${GITHUB_REPO}/releases/download/v${version}/${filename}"
|
||||
|
||||
if [ -f "$output" ]; then
|
||||
log_skip "${filename} 已在本地缓存"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if curl -sL --fail -o "$output" "$url" 2>/dev/null; then
|
||||
# 检查是否为有效文件 (排除 GitHub 返回 Not Found HTML)
|
||||
local size=$(wc -c < "$output")
|
||||
if [ "$size" -lt 1000 ]; then
|
||||
local content=$(cat "$output")
|
||||
if echo "$content" | grep -qi "not found"; then
|
||||
rm -f "$output"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
else
|
||||
rm -f "$output"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 主流程
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
echo ""
|
||||
echo "================================================================"
|
||||
echo " OpenList 网盘同步 — 补齐所有历史版本"
|
||||
echo "================================================================"
|
||||
echo ""
|
||||
|
||||
# 准备工作目录
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$WORK_DIR"
|
||||
|
||||
# 登录
|
||||
echo "正在登录 OpenList..."
|
||||
TOKEN=$(get_token)
|
||||
log_info "登录成功"
|
||||
echo ""
|
||||
|
||||
TOTAL_UPLOADED=0
|
||||
|
||||
for VER in $ALL_VERSIONS; do
|
||||
echo "── v${VER} ──────────────────────────────────────"
|
||||
REMOTE_DIR="${OPENLIST_ROOT}/${UPLOAD_SUBDIR}/v${VER}"
|
||||
VER_DIR="${WORK_DIR}/v${VER}"
|
||||
mkdir -p "$VER_DIR"
|
||||
|
||||
# 创建远程目录
|
||||
create_remote_dir "$TOKEN" "$REMOTE_DIR"
|
||||
|
||||
# 1) 下载 .run
|
||||
RUN_FILE="luci-app-openclaw_${VER}.run"
|
||||
if remote_file_exists "$TOKEN" "$REMOTE_DIR" "$RUN_FILE"; then
|
||||
log_skip "${RUN_FILE} 已存在于网盘"
|
||||
else
|
||||
echo " 下载 ${RUN_FILE}..."
|
||||
if download_release_file "$VER" "$RUN_FILE" "${VER_DIR}/${RUN_FILE}"; then
|
||||
upload_file "$TOKEN" "${VER_DIR}/${RUN_FILE}" "$REMOTE_DIR"
|
||||
TOTAL_UPLOADED=$((TOTAL_UPLOADED + 1))
|
||||
else
|
||||
log_error "${RUN_FILE} 下载失败"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2) 下载 .ipk
|
||||
IPK_FILE="luci-app-openclaw_${VER}-1_all.ipk"
|
||||
if remote_file_exists "$TOKEN" "$REMOTE_DIR" "$IPK_FILE"; then
|
||||
log_skip "${IPK_FILE} 已存在于网盘"
|
||||
else
|
||||
echo " 下载 ${IPK_FILE}..."
|
||||
if download_release_file "$VER" "$IPK_FILE" "${VER_DIR}/${IPK_FILE}"; then
|
||||
upload_file "$TOKEN" "${VER_DIR}/${IPK_FILE}" "$REMOTE_DIR"
|
||||
TOTAL_UPLOADED=$((TOTAL_UPLOADED + 1))
|
||||
else
|
||||
log_error "${IPK_FILE} 下载失败"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3) 生成并上传 更新记录.txt
|
||||
CHANGELOG_FILE="更新记录.txt"
|
||||
if remote_file_exists "$TOKEN" "$REMOTE_DIR" "$CHANGELOG_FILE"; then
|
||||
log_skip "${CHANGELOG_FILE} 已存在于网盘"
|
||||
else
|
||||
extract_changelog "$VER" "${VER_DIR}/${CHANGELOG_FILE}"
|
||||
upload_file "$TOKEN" "${VER_DIR}/${CHANGELOG_FILE}" "$REMOTE_DIR"
|
||||
TOTAL_UPLOADED=$((TOTAL_UPLOADED + 1))
|
||||
fi
|
||||
|
||||
echo ""
|
||||
done
|
||||
|
||||
# 清理
|
||||
rm -rf "$WORK_DIR"
|
||||
|
||||
echo "================================================================"
|
||||
echo " 同步完成!共上传 ${TOTAL_UPLOADED} 个文件"
|
||||
echo "================================================================"
|
||||
echo ""
|
||||
67
luci-app-openclaw/scripts/upload_openlist.sh
Executable file
67
luci-app-openclaw/scripts/upload_openlist.sh
Executable file
@ -0,0 +1,67 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
PKG_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
|
||||
DIST_DIR="${1:-$PKG_DIR/dist}"
|
||||
|
||||
if [ -z "$OPENLIST_URL" ]; then
|
||||
echo "错误: 请设置 OPENLIST_URL 环境变量"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$OPENLIST_TOKEN" ] && { [ -z "$OPENLIST_USER" ] || [ -z "$OPENLIST_PASS" ]; }; then
|
||||
echo "错误: 请设置登录凭据"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
UPLOAD_ROOT="${OPENLIST_PATH:-/}"
|
||||
PKG_VERSION=$(cat "$PKG_DIR/VERSION" 2>/dev/null | tr -d '[:space:]' || echo "unknown")
|
||||
UPLOAD_ROOT="${UPLOAD_ROOT%/}"
|
||||
UPLOAD_SUBDIR="openclaw-在线安装"
|
||||
OPENLIST_URL="${OPENLIST_URL%/}"
|
||||
|
||||
get_token() {
|
||||
if [ -n "$OPENLIST_TOKEN" ]; then echo "$OPENLIST_TOKEN"; return; fi
|
||||
local resp=$(curl -s -X POST "${OPENLIST_URL}/api/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"username\":\"${OPENLIST_USER}\",\"password\":\"${OPENLIST_PASS}\"}")
|
||||
local token=$(echo "$resp" | grep -o '"token":"[^"]*"' | sed 's/"token":"//;s/"//' | head -n 1)
|
||||
if [ -z "$token" ]; then exit 1; fi
|
||||
echo "$token"
|
||||
}
|
||||
|
||||
create_remote_dir() {
|
||||
curl -s -X POST "${OPENLIST_URL}/api/fs/mkdir" \
|
||||
-H "Authorization: ${1}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"path\":\"${2}\"}" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
upload_file() {
|
||||
local filename=$(basename "$2")
|
||||
echo " 上传: ${filename} ..."
|
||||
local resp=$(curl -s -X PUT "${OPENLIST_URL}/api/fs/put" \
|
||||
-H "Authorization: ${1}" \
|
||||
-H "File-Path: ${3}/${filename}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@${2}" \
|
||||
--max-time 3600)
|
||||
local code=$(echo "$resp" | grep -o '"code":[0-9]*' | grep -o '[0-9]*' | head -n 1)
|
||||
if [ "$code" = "200" ]; then echo " [✓] 上传成功"; else echo " [✗] 失败: $resp"; fi
|
||||
}
|
||||
|
||||
UPLOAD_FILES=$(find "$DIST_DIR" -type f -name "*.run" -o -name "*.ipk" 2>/dev/null)
|
||||
if [ -z "$UPLOAD_FILES" ]; then echo "错误: 未找到可上传文件"; exit 1; fi
|
||||
|
||||
TOKEN=$(get_token)
|
||||
REMOTE_DIR="${UPLOAD_ROOT}/${UPLOAD_SUBDIR}/v${PKG_VERSION}"
|
||||
REMOTE_DIR=$(echo "$REMOTE_DIR" | sed 's#^//#/#g')
|
||||
|
||||
echo "创建远程目录: ${REMOTE_DIR}"
|
||||
create_remote_dir "$TOKEN" "$REMOTE_DIR"
|
||||
|
||||
for f in $UPLOAD_FILES; do
|
||||
upload_file "$TOKEN" "$f" "$REMOTE_DIR"
|
||||
done
|
||||
echo "✅ 上传完成!"
|
||||
@ -180,17 +180,15 @@ if has_xray then
|
||||
o:depends("fragment", true)
|
||||
|
||||
o = s_xray:option(Value, "fragment_length", translate("Fragment Length"), translate("Fragmented packet length (byte)"))
|
||||
o.datatype = "or(uinteger,portrange)"
|
||||
o.default = "100-200"
|
||||
o:depends("fragment", true)
|
||||
|
||||
o = s_xray:option(Value, "fragment_interval", translate("Fragment Interval"), translate("Fragmentation interval (ms)"))
|
||||
o.datatype = "or(uinteger,portrange)"
|
||||
o.default = "10-20"
|
||||
o:depends("fragment", true)
|
||||
|
||||
o = s_xray:option(Value, "fragment_maxSplit", translate("Max Split"), translate("Limit the maximum number of splits."))
|
||||
o.default = "100-200"
|
||||
o:depends("fragment", true)
|
||||
|
||||
o = s_xray:option(Flag, "noise", translate("Noise"), translate("UDP noise, Under some circumstances it can bypass some UDP based protocol restrictions."))
|
||||
o.default = 0
|
||||
|
||||
@ -244,11 +242,6 @@ if has_xray then
|
||||
o = s_xray_noise:option(Value, "delay", translate("Delay (ms)"))
|
||||
o.datatype = "or(uinteger,portrange)"
|
||||
o.rmempty = false
|
||||
|
||||
o = s_xray_noise:option(ListValue, "applyTo", translate("IP Type"))
|
||||
o:value("ip", "ALL")
|
||||
o:value("ipv4", "IPv4")
|
||||
o:value("ipv6", "IPv6")
|
||||
end
|
||||
|
||||
if has_singbox then
|
||||
|
||||
@ -14,8 +14,7 @@ local function get_noise_packets()
|
||||
local noise = (n.enabled == "1") and {
|
||||
type = n.type,
|
||||
packet = n.packet,
|
||||
delay = string.find(n.delay, "-") and n.delay or tonumber(n.delay),
|
||||
applyTo = n.applyTo
|
||||
delay = string.find(n.delay, "-") and n.delay or tonumber(n.delay)
|
||||
} or nil
|
||||
table.insert(noises, noise)
|
||||
end)
|
||||
@ -886,7 +885,7 @@ function gen_config(var)
|
||||
end
|
||||
end
|
||||
if is_new_blc_node then
|
||||
local outboundTag = gen_outbound_get_tag(flag, blc_node_id, blc_node_tag, { fragment = xray_settings.fragment == "1" or nil, noise = xray_settings.record_fragment == "1" or nil, run_socks_instance = not no_run })
|
||||
local outboundTag = gen_outbound_get_tag(flag, blc_node_id, blc_node_tag, { fragment = xray_settings.fragment == "1" or nil, noise = xray_settings.noise == "1" or nil, run_socks_instance = not no_run })
|
||||
if outboundTag then
|
||||
valid_nodes[#valid_nodes + 1] = outboundTag
|
||||
end
|
||||
@ -911,7 +910,7 @@ function gen_config(var)
|
||||
local fallback_node = get_node_by_id(fallback_node_id)
|
||||
if fallback_node then
|
||||
if fallback_node.protocol ~= "_balancing" then
|
||||
local outboundTag = gen_outbound_get_tag(flag, fallback_node, fallback_node_id, { fragment = xray_settings.fragment == "1" or nil, noise = xray_settings.record_fragment == "1" or nil, run_socks_instance = not no_run })
|
||||
local outboundTag = gen_outbound_get_tag(flag, fallback_node, fallback_node_id, { fragment = xray_settings.fragment == "1" or nil, noise = xray_settings.noise == "1" or nil, run_socks_instance = not no_run })
|
||||
if outboundTag then
|
||||
fallback_node_tag = outboundTag
|
||||
end
|
||||
@ -1639,8 +1638,7 @@ function gen_config(var)
|
||||
fragment = (xray_settings.fragment == "1") and {
|
||||
packets = (xray_settings.fragment_packets and xray_settings.fragment_packets ~= "") and xray_settings.fragment_packets,
|
||||
length = (xray_settings.fragment_length and xray_settings.fragment_length ~= "") and xray_settings.fragment_length,
|
||||
interval = (xray_settings.fragment_interval and xray_settings.fragment_interval ~= "") and xray_settings.fragment_interval,
|
||||
maxSplit = (xray_settings.fragment_maxSplit and xray_settings.fragment_maxSplit ~= "") and xray_settings.fragment_maxSplit
|
||||
interval = (xray_settings.fragment_interval and xray_settings.fragment_interval ~= "") and xray_settings.fragment_interval
|
||||
} or nil,
|
||||
noises = (xray_settings.noise == "1") and get_noise_packets() or nil
|
||||
},
|
||||
|
||||
@ -1870,12 +1870,6 @@ msgstr "分片间隔"
|
||||
msgid "Fragmentation interval (ms)"
|
||||
msgstr "分片间隔(ms)"
|
||||
|
||||
msgid "Max Split"
|
||||
msgstr "最大分片数"
|
||||
|
||||
msgid "Limit the maximum number of splits."
|
||||
msgstr "限制分片的最大数量。"
|
||||
|
||||
msgid "Split handshake data into multiple TLS records for better censorship evasion. Low overhead. Recommended to enable first."
|
||||
msgstr "将握手数据拆分为多个 TLS 记录,提升抗封锁能力,几乎不增加延迟,建议优先启用。"
|
||||
|
||||
@ -1900,9 +1894,6 @@ msgstr "数据包"
|
||||
msgid "Delay (ms)"
|
||||
msgstr "延迟(ms)"
|
||||
|
||||
msgid "IP Type"
|
||||
msgstr "IP 类型"
|
||||
|
||||
msgid "If is domain name, The requested domain name will be resolved to IP before connect."
|
||||
msgstr "如果是域名,域名将在请求发出之前解析为 IP。"
|
||||
|
||||
|
||||
@ -1775,7 +1775,7 @@ acl_app() {
|
||||
[ -n "$tcp_node" ] && {
|
||||
local protocol=$(config_n_get $tcp_node protocol)
|
||||
[ "$protocol" = "_shunt" ] && [ "$udp_node" != "default" ] && {
|
||||
udp_node = "tcp"
|
||||
udp_node="tcp"
|
||||
}
|
||||
}
|
||||
if [ "$udp_node" = "default" ]; then
|
||||
|
||||
@ -587,7 +587,7 @@ load_acl() {
|
||||
|
||||
[ "$UDP_NO_REDIR_PORTS" != "disable" ] && {
|
||||
nft "add rule $NFTABLE_NAME PSW_MANGLE ip protocol udp $(factor $UDP_NO_REDIR_PORTS "udp dport") counter return comment \"默认\""
|
||||
nft "add rule $NFTABLE_NAME PSW_MANGLE_V6 counter meta l4proto udp $(factor $UDP_NO_REDIR_PORTS "udp dport") counter return comment \"默认\""
|
||||
nft "add rule $NFTABLE_NAME PSW_MANGLE_V6 meta l4proto udp $(factor $UDP_NO_REDIR_PORTS "udp dport") counter return comment \"默认\""
|
||||
if ! has_1_65535 "$UDP_NO_REDIR_PORTS"; then
|
||||
echolog " - ${msg}不代理 UDP 端口[${UDP_NO_REDIR_PORTS}]"
|
||||
else
|
||||
@ -1201,7 +1201,7 @@ add_firewall_rule() {
|
||||
[ "${USE_GFW_LIST}" = "1" ] && nft add rule $NFTABLE_NAME PSW_OUTPUT_MANGLE ip protocol udp ip daddr @$NFTSET_GFW $(factor $UDP_PROXY_DROP_PORTS "udp dport") counter drop
|
||||
[ "${CHN_LIST}" != "0" ] && nft add rule $NFTABLE_NAME PSW_OUTPUT_MANGLE ip protocol udp ip daddr @$NFTSET_CHN $(factor $UDP_PROXY_DROP_PORTS "udp dport") $(get_jump_ipt ${CHN_LIST} "counter drop")
|
||||
[ "${USE_SHUNT_UDP}" = "1" ] && nft add rule $NFTABLE_NAME PSW_OUTPUT_MANGLE ip protocol udp ip daddr @$NFTSET_SHUNT $(factor $UDP_PROXY_DROP_PORTS "udp dport") counter drop
|
||||
[ "${LOCALHOST_UDP_PROXY_MODE}" != "disable" ] && nft add rule $NFTABLE_NAME PSW_OUTPUT_MANGLE counter ip protocol udp $(factor $UDP_PROXY_DROP_PORTS "udp dport") counter drop
|
||||
[ "${LOCALHOST_UDP_PROXY_MODE}" != "disable" ] && nft add rule $NFTABLE_NAME PSW_OUTPUT_MANGLE ip protocol udp $(factor $UDP_PROXY_DROP_PORTS "udp dport") counter drop
|
||||
echolog " - ${msg}屏蔽代理 UDP 端口[${UDP_PROXY_DROP_PORTS}]"
|
||||
}
|
||||
}
|
||||
|
||||
@ -338,7 +338,7 @@ eval_set_val() {
|
||||
eval_unset_val() {
|
||||
for i in $@; do
|
||||
for j in $i; do
|
||||
eval unset j
|
||||
eval unset $j
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user