release: v1.0.0 — LuCI 管理界面、一键安装、12+ AI 模型提供商

This commit is contained in:
10000ge10000
2026-03-02 16:23:52 +08:00
commit c1c3151a9f
28 changed files with 5260 additions and 0 deletions

114
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,114 @@
name: Build & Release
on:
workflow_dispatch:
inputs:
version:
description: '版本号 (留空自动生成,格式: 1.0.0)'
required: false
default: ''
create_release:
description: '是否创建 Release'
required: false
type: boolean
default: true
jobs:
build:
name: Build packages
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Determine version
id: version
run: |
INPUT_VER="${{ github.event.inputs.version }}"
if [ -n "$INPUT_VER" ]; then
VER="$INPUT_VER"
elif [ -f VERSION ]; then
VER="$(cat VERSION | tr -d '[:space:]')"
else
VER="$(date -u +%Y.%m.%d)"
fi
echo "version=$VER" >> "$GITHUB_OUTPUT"
echo "tag=v$VER" >> "$GITHUB_OUTPUT"
echo "Version: $VER"
- name: Inject version
run: |
VER="${{ steps.version.outputs.version }}"
echo "$VER" > VERSION
# 同步到 Makefile
sed -i "s/^PKG_VERSION:=.*/PKG_VERSION:=$VER/" Makefile
- name: Build .run installer
run: |
chmod +x scripts/build_run.sh
sh scripts/build_run.sh dist
- name: Build .ipk package
run: |
chmod +x scripts/build_ipk.sh
sh scripts/build_ipk.sh dist
- name: List outputs
run: ls -lh dist/
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: luci-app-openclaw-${{ steps.version.outputs.version }}
path: dist/*
- name: Create Release
if: ${{ github.event.inputs.create_release == 'true' }}
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag }}
name: luci-app-openclaw ${{ steps.version.outputs.tag }}
files: dist/*
draft: false
prerelease: false
body: |
## luci-app-openclaw ${{ steps.version.outputs.tag }}
OpenClaw AI 网关的 OpenWrt LuCI 管理插件。
### 下载说明
| 文件 | 说明 |
|------|------|
| `luci-app-openclaw_*.run` | 自解压安装包,适用于已运行的 OpenWrt / iStoreOS 系统 |
| `luci-app-openclaw_*.ipk` | 标准 opkg 安装包 |
### 安装方法
**方式一:.run 安装包(推荐)**
```bash
# 上传到路由器后执行
sh luci-app-openclaw_${{ steps.version.outputs.version }}.run
```
**方式二:.ipk 安装**
```bash
opkg install luci-app-openclaw_${{ steps.version.outputs.version }}-1_all.ipk
```
### 安装后使用
1. 打开 LuCI → 服务 → OpenClaw
2. 点击「安装运行环境」,等待安装完成后服务会自动启动
3. 进入「Web 控制台」添加 AI 模型的 API Key 即可使用
### 兼容性
- 系统OpenWrt / iStoreOS / ImmortalWrt / KWRT 23.05+
- 架构x86_64、aarch64
- C 库glibc、musl自动识别
---
[使用文档](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/)

18
.gitignore vendored Normal file
View File

@@ -0,0 +1,18 @@
# Node
node_modules/
*.tgz
# Build artifacts
dist/
*.ipk
*.run
# OS
.DS_Store
Thumbs.db
# Editor
.vscode/
*.swp
*.swo
*~

39
CHANGELOG.md Normal file
View File

@@ -0,0 +1,39 @@
# Changelog
本项目所有重大变更都将记录在此文件中。
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/)。
## [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
LICENSE Normal file
View 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).

92
Makefile Normal file
View File

@@ -0,0 +1,92 @@
# 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:=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
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_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)))

155
README.md Normal file
View File

@@ -0,0 +1,155 @@
# luci-app-openclaw
[![Bilibili](https://img.shields.io/badge/B%E7%AB%99-59438380-00a1d6?logo=bilibili)](https://space.bilibili.com/59438380)
[![Blog](https://img.shields.io/badge/Blog-910501.xyz-orange)](https://blog.910501.xyz/)
[![Build & Release](https://github.com/10000ge10000/luci-app-openclaw/actions/workflows/build.yml/badge.svg)](https://github.com/10000ge10000/luci-app-openclaw/actions/workflows/build.yml)
[![License: GPL-3.0](https://img.shields.io/badge/License-GPL--3.0-blue.svg)](LICENSE)
[OpenClaw](https://github.com/nicepkg/openclaw) AI 网关的 OpenWrt LuCI 管理插件。
在路由器上运行 OpenClaw通过 LuCI 管理界面完成安装、配置和服务管理。
**系统要求**
| 项目 | 要求 |
|------|------|
| 架构 | x86_64 或 aarch64 |
| C 库 | glibc 或 musl自动检测 |
| 依赖 | luci-compat, luci-base, curl, openssl-util |
| 存储 | 1.5GB 以上可用空间 |
| 内存 | 推荐 2GB 及以上 |
## 📦 安装
### 方式一:.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 安装包构建
└── .github/workflows/build.yml # GitHub Actions
```
## ❓ 常见问题
**安装后 LuCI 菜单没有出现**
```bash
rm -f /tmp/luci-indexcache /tmp/luci-modulecache/*
```
刷新浏览器即可。
**提示缺少依赖 luci-compat**
```bash
opkg update && opkg install luci-compat
```
**Node.js 下载失败**
网络问题,可指定国内镜像:
```bash
NODE_MIRROR=https://npmmirror.com/mirrors/node openclaw-env setup
```
**是否支持 ARM 路由器**
支持 aarch64ARM64。不支持 32 位 ARMNode.js 22 没有 32 位预编译包。
## 🤝 贡献
欢迎提交 Issue 和 Pull Request
## 📄 License
[GPL-3.0](LICENSE)

1
VERSION Normal file
View File

@@ -0,0 +1 @@
1.0.0

View File

@@ -0,0 +1,384 @@
-- luci-app-openclaw — LuCI Controller
module("luci.controller.openclaw", package.seeall)
-- 公共辅助: 获取 OpenClaw 版本号
local function get_openclaw_version()
local sys = require "luci.sys"
local ver = sys.exec("[ -x /opt/openclaw/node/bin/node ] && for d in /opt/openclaw/global/lib/node_modules/openclaw /opt/openclaw/global/node_modules/openclaw /opt/openclaw/global/*/node_modules/openclaw; do [ -f \"$d/openclaw.mjs\" ] && /opt/openclaw/node/bin/node \"$d/openclaw.mjs\" --version 2>/dev/null && break; [ -f \"$d/dist/cli.js\" ] && /opt/openclaw/node/bin/node \"$d/dist/cli.js\" --version 2>/dev/null && break; done"):gsub("%s+", "")
return ver
end
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", "do_update"}, call("action_do_update"), nil).leaf = true
-- 升级日志 API (轮询)
entry({"admin", "services", "openclaw", "upgrade_log"}, call("action_upgrade_log"), 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
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,
pty_running = false,
pid = "",
memory_kb = 0,
uptime = "",
node_version = "",
openclaw_version = "",
}
-- 检查 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 版本
local oc_ver = get_openclaw_version()
if oc_ver and oc_ver ~= "" then
result.openclaw_version = "v" .. oc_ver
end
-- 网关端口检查
local gw_check = sys.exec("netstat -tlnp 2>/dev/null | grep -c ':" .. port .. " ' || echo 0"):gsub("%s+", "")
result.gateway_running = (tonumber(gw_check) or 0) > 0
-- PTY 端口检查
local pty_check = sys.exec("netstat -tlnp 2>/dev/null | grep -c ':" .. pty_port .. " ' || echo 0"):gsub("%s+", "")
result.pty_running = (tonumber(pty_check) or 0) > 0
-- PID 和内存
if result.gateway_running then
local pid = sys.exec("netstat -tlnp 2>/dev/null | grep ':" .. port .. " ' | head -1 | sed 's|.* \\([0-9]*\\)/.*|\\1|'"):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")
elseif action == "restart" then
sys.exec("/etc/init.d/openclaw restart >/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 -einit_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"
-- 当前版本
local current = get_openclaw_version()
-- 最新版本 (从 npm registry 查询)
local latest = sys.exec("PATH=/opt/openclaw/node/bin:/opt/openclaw/global/bin:$PATH npm view openclaw version 2>/dev/null"):gsub("%s+", "")
local has_update = false
if current ~= "" and latest ~= "" and current ~= latest then
has_update = true
end
http.prepare_content("application/json")
http.write_json({
status = "ok",
current = current,
latest = latest,
has_update = has_update
})
end
-- ═══════════════════════════════════════════
-- 执行升级 API (后台执行 + 日志轮询)
-- ═══════════════════════════════════════════
function action_do_update()
local http = require "luci.http"
local sys = require "luci.sys"
-- 清理旧日志和状态
sys.exec("rm -f /tmp/openclaw-upgrade.log /tmp/openclaw-upgrade.pid /tmp/openclaw-upgrade.exit")
-- 后台执行升级,升级完成后自动重启服务
sys.exec("( /usr/bin/openclaw-env upgrade > /tmp/openclaw-upgrade.log 2>&1; RC=$?; echo $RC > /tmp/openclaw-upgrade.exit; if [ $RC -eq 0 ]; then echo '' >> /tmp/openclaw-upgrade.log; echo '正在重启服务...' >> /tmp/openclaw-upgrade.log; /etc/init.d/openclaw restart >> /tmp/openclaw-upgrade.log 2>&1; echo ' [✓] 服务已重启' >> /tmp/openclaw-upgrade.log; fi ) & echo $! > /tmp/openclaw-upgrade.pid")
http.prepare_content("application/json")
http.write_json({
status = "ok",
message = "升级已在后台启动,请查看升级日志..."
})
end
-- ═══════════════════════════════════════════
-- 升级日志轮询 API
-- ═══════════════════════════════════════════
function action_upgrade_log()
local http = require "luci.http"
local sys = require "luci.sys"
-- 读取日志内容
local log = ""
local f = io.open("/tmp/openclaw-upgrade.log", "r")
if f then
log = f:read("*a") or ""
f:close()
end
-- 检查进程是否还在运行
local running = false
local pid_file = io.open("/tmp/openclaw-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-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({
state = state,
exit_code = exit_code,
log = log
})
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

View File

@@ -0,0 +1,366 @@
-- luci-app-openclaw — 基本设置 CBI Model
local sys = require "luci.sys"
m = Map("openclaw", "OpenClaw AI 网关",
"OpenClaw 是一个 AI 编程代理网关,支持 GitHub Copilot、Claude、GPT、Gemini 等大模型以及 Telegram、Discord 等多种消息渠道。")
-- 隐藏底部的「保存并应用」「保存」「复位」按钮 (本页无可编辑的 UCI 选项)
m.submit = false
m.reset = 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 update_url = luci.dispatcher.build_url("admin", "services", "openclaw", "do_update")
local upgrade_log_url = luci.dispatcher.build_url("admin", "services", "openclaw", "upgrade_log")
local uninstall_url = luci.dispatcher.build_url("admin", "services", "openclaw", "uninstall")
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] = '<button class="btn cbi-button cbi-button-action" type="button" onclick="ocCheckUpdate()" id="btn-check-update">🔍 检测升级</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/>&nbsp;&nbsp;💡 解决: 检查 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/>&nbsp;&nbsp;💡 解决: 运行 <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/>&nbsp;&nbsp;💡 当前设备架构可能是 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/>&nbsp;&nbsp;💡 解决: 尝试手动安装 <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/>&nbsp;&nbsp;💡 解决: 运行 <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/>&nbsp;&nbsp;💡 解决: 删除缓存重试 <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/>&nbsp;&nbsp;💡 可能是 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/>&nbsp;&nbsp;💡 您也可以尝试手动执行: <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] = 'try{var r=JSON.parse(x.responseText);'
html[#html+1] = 'if(!r.current){el.innerHTML="<span style=\\"color:#999\\">⚠️ OpenClaw 未安装</span>";return;}'
html[#html+1] = 'if(r.has_update){'
html[#html+1] = 'el.innerHTML="<span style=\\"color:#e36209\\">📦 当前: v"+r.current+" → 最新: v"+r.latest+"</span>";'
html[#html+1] = 'act.style.display="block";'
html[#html+1] = 'act.innerHTML=\'<button class="btn cbi-button cbi-button-apply" type="button" onclick="ocDoUpdate()" id="btn-do-update">⬆️ 立即升级</button>\';'
html[#html+1] = '}else{'
html[#html+1] = 'el.innerHTML="<span style=\\"color:green\\">✅ 已是最新版本 (v"+r.current+")</span>";'
html[#html+1] = '}'
html[#html+1] = '}catch(e){el.innerHTML="<span style=\\"color:red\\">❌ 检测失败</span>";}'
html[#html+1] = '});}'
-- 执行升级 (带实时日志, 和安装一样的体验)
html[#html+1] = 'var _upgradeTimer=null;'
html[#html+1] = 'function ocDoUpdate(){'
html[#html+1] = 'var btn=document.getElementById("btn-do-update");'
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] = 'if(!confirm("确定要升级 OpenClaw升级期间服务将短暂中断。"))return;'
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="正在启动升级...\\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("' .. update_url .. '",null,function(x){'
html[#html+1] = 'try{JSON.parse(x.responseText);}catch(e){}'
html[#html+1] = 'ocPollUpgradeLog();'
html[#html+1] = '});'
html[#html+1] = '}'
-- 轮询升级日志
html[#html+1] = 'function ocPollUpgradeLog(){'
html[#html+1] = 'if(_upgradeTimer)clearInterval(_upgradeTimer);'
html[#html+1] = '_upgradeTimer=setInterval(function(){'
html[#html+1] = '(new XHR()).get("' .. upgrade_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(_upgradeTimer);_upgradeTimer=null;'
html[#html+1] = 'ocUpgradeDone(true);'
html[#html+1] = '}else if(r.state==="failed"){'
html[#html+1] = 'clearInterval(_upgradeTimer);_upgradeTimer=null;'
html[#html+1] = 'ocUpgradeDone(false);'
html[#html+1] = '}'
html[#html+1] = '}catch(e){}'
html[#html+1] = '});'
html[#html+1] = '},1500);'
html[#html+1] = '}'
-- 升级完成处理
html[#html+1] = 'function ocUpgradeDone(ok){'
html[#html+1] = 'var btn=document.getElementById("btn-do-update");'
html[#html+1] = 'var statusEl=document.getElementById("setup-log-status");'
html[#html+1] = 'var resultEl=document.getElementById("setup-log-result");'
html[#html+1] = 'var actEl=document.getElementById("oc-update-action");'
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;\\">点击下方按钮刷新页面查看最新状态。</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] = 'actEl.style.display="none";'
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-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] = '});}'
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>「Web 控制台」</b> 配置 AI 模型、消息渠道,直接开始对话<br/>'
html[#html+1] = '③ 进入 <b>「配置管理」</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

View File

@@ -0,0 +1,121 @@
<%#
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-info-box {
padding: 12px 16px;
margin-bottom: 16px;
background: #f6f8fa;
border: 1px solid #e1e4e8;
border-left: 4px solid #4a90d9;
border-radius: 4px;
font-size: 13px;
line-height: 1.7;
color: #555;
}
.oc-info-box ul { margin: 6px 0 0 0; padding-left: 18px; list-style: none; }
.oc-info-box li { margin-bottom: 2px; }
.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 模型配置、消息渠道设置、健康检查等。</p>
</div>
<div class="oc-info-box">
<strong>💡 菜单功能说明:</strong>
<ul>
<li><strong>1)</strong> 查看当前配置 &nbsp; <strong>2)</strong> 配置 AI 模型提供商 &nbsp; <strong>3)</strong> 设定当前活跃模型</li>
<li><strong>4)</strong> 配置消息渠道 &nbsp; <strong>5)</strong> Telegram 配对向导 &nbsp; <strong>6)</strong> 健康检查 / 诊断</li>
<li><strong>7)</strong> 重启网关 &nbsp; <strong>8)</strong> 查看/编辑原始配置 &nbsp; <strong>9)</strong> 恢复默认配置</li>
</ul>
</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');
container.appendChild(iframe);
}
checkAndLoadTerminal();
})();
//]]>
</script>
<%+footer%>

View File

@@ -0,0 +1,168 @@
<%#
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 模型、消息渠道,直接与 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 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 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.gateway_running) {
statusTextEl.innerHTML = '<span style="color:#1a7f37;">● 网关运行中</span>';
openBtn.href = url;
openBtn.style.display = '';
showIframe(url);
} 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%>

View File

@@ -0,0 +1,138 @@
<%#
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-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">🦞 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>进程 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-ocver">-</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 {
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 {
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 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-ocver').textContent = d.openclaw_version || '未安装';
} catch(e) {
document.getElementById('oc-st-status').innerHTML = '<span class="oc-badge oc-badge-unknown">查询失败</span>';
}
});
}
updateStatus();
setInterval(updateStatus, 5000);
})();
//]]>
</script>

482
po/zh-cn/openclaw.po Normal file
View 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 等大模型以及多种消息渠道。"
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 模型提供商"

6
root/etc/config/openclaw Normal file
View File

@@ -0,0 +1,6 @@
config openclaw 'main'
option enabled '0'
option port '18789'
option bind 'lan'
option token ''
option pty_port '18793'

330
root/etc/init.d/openclaw Executable file
View File

@@ -0,0 +1,330 @@
#!/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"
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
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
}
patch_iframe_headers() {
# 移除 OpenClaw 网关的 X-Frame-Options 和 frame-ancestors 限制,允许 LuCI iframe 嵌入
local gw_js
for f in $(find "${OC_GLOBAL}" -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
}
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 "")
# 确保配置目录和文件存在
mkdir -p "$(dirname "$CONFIG_FILE")"
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 || echo "auto_$(date +%s)")
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;
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
# 启动 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_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 5 0
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 用于 WebSocket 认证
local pty_token
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
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_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 会自动处理进程停止
return 0
}
service_triggers() {
procd_add_reload_trigger "openclaw"
}
reload_service() {
stop
start
}
setup() {
echo "正在调用 openclaw-env setup..."
/usr/bin/openclaw-env setup
}
restart_gateway() {
# 仅重启 Gateway procd 实例,不影响 Web PTY
# 使用 stop+start 而非 kill可重置 procd crash loop 计数器
local port
port=$(uci -q get openclaw.main.port || echo "18789")
# 停止 gateway 实例 (通过 ubus不影响 PTY 实例)
ubus call service stop '{"name":"openclaw","instance":"gateway"}' 2>/dev/null || true
# 等待端口释放
local i=0
while [ $i -lt 8 ]; do
netstat -tln 2>/dev/null | grep -q ":${port} " || break
sleep 1; i=$((i+1))
done
# 重新启动整个服务 (procd 会重建 gateway 实例,同时保留 PTY 实例)
# procd 的 start_service 幂等:已运行的实例不会重复启动
/etc/init.d/openclaw start >/dev/null 2>&1
}
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 -tlnp 2>/dev/null | grep -q ":${p} "
else
netstat -tlnp 2>/dev/null | grep -q ":${p} "
fi
}
_get_pid_by_port() {
local p="$1"
if command -v ss >/dev/null 2>&1; then
ss -tlnp 2>/dev/null | grep ":${p} " | head -1 | sed -n 's/.*pid=\([0-9]*\).*/\1/p'
else
netstat -tlnp 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
else
echo "网关: 已停止"
fi
# PTY port
if _check_port "$pty_port"; then
echo "Web PTY: 运行中 (端口 $pty_port)"
else
echo "Web PTY: 已停止"
fi
}

View File

@@ -0,0 +1,41 @@
#!/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
# 创建数据目录
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 || openssl rand -hex 24 2>/dev/null || echo "changeme_$(date +%s)")
uci set openclaw.main.token="$TOKEN"
uci commit openclaw
fi
exit 0

478
root/usr/bin/openclaw-env Executable file
View File

@@ -0,0 +1,478 @@
#!/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.16.0}"
# 经过验证的 OpenClaw 稳定版本 (更新此值需同步测试)
OC_TESTED_VERSION="2026.3.1"
# 用户可通过 OC_VERSION 环境变量覆盖安装版本
OC_VERSION="${OC_VERSION:-}"
NODE_BASE="/opt/openclaw/node"
OC_GLOBAL="/opt/openclaw/global"
OC_DATA="/opt/openclaw/data"
NODE_BIN="${NODE_BASE}/bin/node"
NPM_BIN="${NODE_BASE}/bin/npm"
PNPM_BIN="${OC_GLOBAL}/bin/pnpm"
# 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"
export PATH="${NODE_BASE}/bin:${OC_GLOBAL}/bin:$PATH"
log_info() { echo " [✓] $1"; }
log_warn() { echo " [!] $1"; }
log_error() { echo " [✗] $1"; }
# 检测 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)
local tarball=""
local url="" url_fallback=""
if [ "$libc_type" = "musl" ]; then
tarball="node-v${node_ver}-${node_arch}-musl.tar.xz"
url="${NODE_MUSL_MIRROR}/v${node_ver}/${tarball}"
url_fallback=""
echo ""
echo "=== 下载 Node.js v${node_ver} (${node_arch}, musl libc) ==="
else
tarball="node-v${node_ver}-${node_arch}.tar.xz"
url="${NODE_MIRROR}/v${node_ver}/${tarball}"
url_fallback="${NODE_MIRROR_CN}/v${node_ver}/${tarball}"
echo ""
echo "=== 下载 Node.js v${node_ver} (${node_arch}, glibc) ==="
fi
local tmp_file="/tmp/${tarball}"
# 如果已存在且版本正确, 跳过
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
log_warn "当前 Node.js v${current_ver}, 将更新到 v${node_ver}"
fi
# 下载 (带重试)
local downloaded=0
local mirror_list="$url"
[ -n "$url_fallback" ] && mirror_list="$url $url_fallback"
for mirror_url in $mirror_list; do
echo " 正在从 ${mirror_url} 下载..."
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
downloaded=1
break
fi
log_warn "下载失败, 尝试备用镜像..."
done
if [ "$downloaded" -eq 0 ]; then
log_error "所有镜像均下载失败"
rm -f "$tmp_file"
exit 1
fi
# 解压
echo " 正在解压到 ${NODE_BASE}..."
rm -rf "$NODE_BASE"
mkdir -p "$NODE_BASE"
tar xf "$tmp_file" -C "$NODE_BASE" --strip-components=1
rm -f "$tmp_file"
# 验证
if [ -x "$NODE_BIN" ]; then
log_info "Node.js $($NODE_BIN --version) 安装成功"
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 到全局目录
mkdir -p "$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
mkdir -p "$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
mkdir -p "$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
}
init_openclaw() {
echo ""
echo "=== 初始化 OpenClaw ==="
# 创建数据目录
mkdir -p "$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 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
}
# ── 主入口 ──
case "${1:-}" in
setup)
do_setup
;;
check)
do_check
;;
upgrade)
do_upgrade
;;
node)
download_node "$NODE_VERSION"
;;
*)
echo "用法: openclaw-env {setup|check|upgrade|node}"
echo ""
echo " setup — 完整安装 (下载 Node.js + pnpm + OpenClaw)"
echo " check — 检查环境状态"
echo " upgrade — 升级 OpenClaw 到最新版"
echo " node — 仅下载/更新 Node.js"
exit 1
;;
esac

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,280 @@
<!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 = 20;
let retryTimer = null;
let wasEverConnected = false;
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 (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;
wasEverConnected = true;
retryCount = 0;
loadingEl.classList.add('hidden');
reconnectEl.classList.remove('show');
statusEl.textContent = '● 已连接';
statusEl.className = 'status connected';
// 连接后重新 fit确保尺寸正确
setTimeout(doFit, 50);
term.focus();
try {
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
} catch(e) {}
};
ws.onmessage = function(ev) {
if (typeof ev.data === 'string') {
term.write(ev.data, function() { term.scrollToBottom(); });
} else if (ev.data instanceof Blob) {
ev.data.text().then(function(text) { term.write(text, function() { term.scrollToBottom(); }); });
}
};
ws.onclose = function(ev) {
clearTimeout(connectTimeout);
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>

View 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

View 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

View 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 */

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,285 @@
#!/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.luci_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;
activeSessions++;
console.log(`[oc-config] Session created (active: ${activeSessions}/${MAX_SESSIONS})`);
this._setupWSReader();
this._spawnPty();
}
_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));
}
});
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){} }
}
} 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`,
};
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 });
this.proc.stdout.on('data', (d) => { if (this.alive) this.socket.write(encodeWSFrame(d, 0x01)); });
this.proc.stderr.on('data', (d) => { if (this.alive) this.socket.write(encodeWSFrame(d, 0x01)); });
this.proc.on('close', (code) => {
if (!this.alive) return;
console.log(`[oc-config] Script exited with code ${code}, auto-restarting...`);
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) => {
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;
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
if (AUTH_TOKEN) {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const clientToken = url.searchParams.get('token') || '';
if (clientToken !== AUTH_TOKEN) {
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);
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)) {
const 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); });
process.on('SIGTERM', () => { console.log('[oc-config] Shutdown'); httpServer.close(); process.exit(0); });
process.on('SIGINT', () => { httpServer.close(); process.exit(0); });

143
scripts/build_ipk.sh Executable file
View File

@@ -0,0 +1,143 @@
#!/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/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
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/* 2>/dev/null
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"

235
scripts/build_run.sh Executable file
View File

@@ -0,0 +1,235 @@
#!/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
mkdir -p "$dest/etc/config"
cp "$PKG_DIR/root/etc/config/openclaw" "$dest/etc/config/"
# 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/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
# 注册到 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
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
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
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 卸载时使用)
FILE_LIST=$(cd "$STAGING/payload" && find . -type f | sed 's|^\./|/|' | 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"