feat: v0.9.1 — 面板设置页、网络代理、后台安装、模型服务商扩展、多项修复
新功能: - 新增独立面板设置页面(网络代理 + 代理测试 + 模型代理开关 + npm源) - 网络代理支持:下载类操作走代理,自动绕过内网地址 - 安装/升级/卸载改为后台执行,不再阻塞界面 - 全局任务状态栏:关闭弹窗后顶部显示进度,可重新查看日志 - 安装/卸载完成后自动刷新界面状态 - 新增多个模型服务商快捷配置(硅基流动、火山引擎、阿里云百炼、智谱AI、MiniMax、NVIDIA NIM、胜算云) - AI助手浮动按钮恢复,首次提示可拖动,实时聊天页隐藏 修复: - 修复版本更新误判(本地版本高于远端不再误弹更新) - 修复Windows下nvm/自定义Node路径CLI检测 - 修复npm EEXIST文件冲突(--force + 安装前自动清理) - 修复汉化版-zh.x后缀版本比较错误 - 修复模型URL自动拼接/v1问题 - 修复切换版本后Gateway重装失败(PATH缓存刷新) - 修复切换助手服务商时旧模型名残留 优化: - macOS图标改用docs/logo.png统一生成 - 内置推荐版本号更新到OpenClaw 2026.3.13 - 错误诊断增强(EEXIST识别) - 弹窗标题根据操作类型显示 - 新增版本维护文档
@@ -34,7 +34,7 @@
|
||||
"description": "OpenClaw AI Agent 可视化管理面板,基于 Tauri v2 的跨平台桌面应用。支持仪表盘监控、多模型配置、消息渠道管理、内置 QQ 机器人、实时 AI 聊天、记忆管理、Agent 管理、网关配置、内网穿透等功能。",
|
||||
"url": "https://claw.qt.cool/",
|
||||
"downloadUrl": "https://github.com/qingchencloud/clawpanel/releases/latest",
|
||||
"softwareVersion": "0.8.6",
|
||||
"softwareVersion": "0.9.1",
|
||||
"author": {
|
||||
"@type": "Organization",
|
||||
"name": "晴辰云 QingchenCloud",
|
||||
@@ -1133,7 +1133,7 @@
|
||||
<div class="orb orb-2" style="top:auto;bottom:-100px"></div>
|
||||
<div class="container-sm" style="position:relative;z-index:10">
|
||||
<div class="section-header">
|
||||
<div class="reveal download-version"><span class="pulse"></span> v0.8.6 最新版</div>
|
||||
<div class="reveal download-version"><span class="pulse"></span> v0.9.1 最新版</div>
|
||||
<h2 class="reveal section-title"><span class="gradient-text">下载安装</span></h2>
|
||||
<p class="reveal section-desc">选择你的操作系统,一键下载安装</p>
|
||||
</div>
|
||||
@@ -1143,11 +1143,11 @@
|
||||
<h3>macOS</h3>
|
||||
<p class="dl-desc">支持 Apple Silicon 和 Intel 芯片</p>
|
||||
<div class="dl-links">
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.8.6_aarch64.dmg" target="_blank" rel="noopener">
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.1_aarch64.dmg" target="_blank" rel="noopener">
|
||||
Apple Silicon (M1/M2/M3/M4)
|
||||
<span class="dl-format">.dmg</span>
|
||||
</a>
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.8.6_x64.dmg" target="_blank" rel="noopener">
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.1_x64.dmg" target="_blank" rel="noopener">
|
||||
Intel 芯片
|
||||
<span class="dl-format">.dmg</span>
|
||||
</a>
|
||||
@@ -1165,11 +1165,11 @@
|
||||
<h3>Windows</h3>
|
||||
<p class="dl-desc">支持 Windows 10 及以上版本</p>
|
||||
<div class="dl-links">
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.8.6_x64-setup.exe" target="_blank" rel="noopener">
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.1_x64-setup.exe" target="_blank" rel="noopener">
|
||||
安装程序
|
||||
<span class="dl-format">.exe</span>
|
||||
</a>
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.8.6_x64_en-US.msi" target="_blank" rel="noopener">
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.1_x64_en-US.msi" target="_blank" rel="noopener">
|
||||
MSI 安装包
|
||||
<span class="dl-format">.msi</span>
|
||||
</a>
|
||||
@@ -1180,11 +1180,11 @@
|
||||
<h3>Linux</h3>
|
||||
<p class="dl-desc">支持主流 Linux 发行版</p>
|
||||
<div class="dl-links">
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.8.6_amd64.AppImage" target="_blank" rel="noopener">
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.1_amd64.AppImage" target="_blank" rel="noopener">
|
||||
通用版
|
||||
<span class="dl-format">.AppImage</span>
|
||||
</a>
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.8.6_amd64.deb" target="_blank" rel="noopener">
|
||||
<a class="dl-link" href="https://claw.qt.cool/proxy/dl/github.com/qingchencloud/clawpanel/releases/latest/download/ClawPanel_0.9.1_amd64.deb" target="_blank" rel="noopener">
|
||||
Debian / Ubuntu
|
||||
<span class="dl-format">.deb</span>
|
||||
</a>
|
||||
|
||||
@@ -332,24 +332,31 @@ sudo systemctl restart clawpanel # 或 pm2 restart clawpanel
|
||||
|
||||
**方式一:在 ClawPanel 面板中操作**(推荐)
|
||||
|
||||
打开「关于」页面 → 点击「检查更新」→ 按提示升级。面板会自动处理 sudo 权限和镜像源。
|
||||
打开「关于」页面 → 点击版本管理,优先切换到当前面板绑定的推荐稳定版。面板会自动处理 sudo 权限、镜像源与 Git HTTPS 兼容。
|
||||
|
||||
> **版本策略说明**:ClawPanel 会按面板版本绑定一组 OpenClaw 推荐稳定版,避免老面板直接管理最新版带来的兼容性风险。如需尝试最新版,请在「关于」页手动切换版本,并自行验证兼容性。
|
||||
|
||||
**方式二:命令行手动升级**
|
||||
|
||||
```bash
|
||||
# 非 root 用户需要 sudo
|
||||
sudo npm install -g @qingchencloud/openclaw-zh@latest --registry https://registry.npmmirror.com
|
||||
# 汉化优化版(示例:ClawPanel 0.9.0 推荐版)
|
||||
sudo npm install -g @qingchencloud/openclaw-zh@2026.3.7-zh.2 --registry https://registry.npmmirror.com
|
||||
|
||||
# 淘宝源安装失败?换官方源重试(可能需要翻墙或代理)
|
||||
sudo npm install -g @qingchencloud/openclaw-zh@latest --registry https://registry.npmjs.org
|
||||
# 官方原版(示例:ClawPanel 0.9.0 推荐版)
|
||||
sudo npm install -g openclaw@2026.3.11 --registry https://registry.npmjs.org
|
||||
|
||||
# 国内镜像失败时,再切 npm 官方源重试
|
||||
sudo npm install -g @qingchencloud/openclaw-zh@2026.3.7-zh.2 --registry https://registry.npmjs.org
|
||||
```
|
||||
|
||||
> **权限说明**:Linux 全局 npm 包安装需要 root 权限。ClawPanel v0.8.1+ 已自动检测非 root 用户并加 sudo。如仍遇权限问题,手动加 `sudo` 即可。
|
||||
> **维护说明**:如果你是 ClawPanel 维护者,后续只需要更新仓库根目录的 `openclaw-version-policy.json`,即可统一调整不同面板版本对应的推荐 OpenClaw 版本。程序版本号、热更新清单、桌面图标的维护方式见 `docs/version-maintenance.md`。
|
||||
|
||||
> **权限说明**:Linux 全局 npm 包安装需要 root 权限。ClawPanel 现已自动检测非 root 用户并加 sudo,同时会自动补 GitHub HTTPS rewrite 规则;如仍遇权限问题,手动加 `sudo` 即可。
|
||||
|
||||
### 更新频率
|
||||
|
||||
- **ClawPanel**:`git pull` 获取最新代码,无需重新安装依赖(除非 package.json 变了)
|
||||
- **OpenClaw**:通过 npm 全局升级,面板会自动检测新版本并提示
|
||||
- **OpenClaw**:优先通过面板切换到推荐稳定版;如需尝试其它版本,请在「关于」页手动切换
|
||||
- **前端热更新**:面板支持前端热更新(不需要 git pull),在「关于」页面点击「热更新」按钮即可
|
||||
|
||||
---
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"version": "0.8.6",
|
||||
"minAppVersion": "0.8.6",
|
||||
"hash": "sha256:a6c28d0607c4226ccffa8199c4aa21f1bd7075ed7b85e24be1fb31f105ae983d",
|
||||
"url": "https://github.com/qingchencloud/clawpanel/releases/download/v0.8.6/web-0.8.6.zip",
|
||||
"size": 1967473,
|
||||
"changelog": "",
|
||||
"releasedAt": "2026-03-13T09:29:07Z"
|
||||
"version": "0.9.1",
|
||||
"minAppVersion": "0.9.0",
|
||||
"hash": "",
|
||||
"url": "https://github.com/qingchencloud/clawpanel/releases/download/v0.9.1/web-0.9.1.zip",
|
||||
"size": 0,
|
||||
"changelog": "新增面板设置页、网络代理、后台安装、模型服务商扩展等",
|
||||
"releasedAt": "2026-03-14T18:00:00Z"
|
||||
}
|
||||
|
||||
134
docs/version-maintenance.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# ClawPanel 版本维护说明
|
||||
|
||||
这份文档面向 ClawPanel 维护者,说明后续如何维护:
|
||||
|
||||
- ClawPanel 自身版本号
|
||||
- OpenClaw 推荐稳定版映射
|
||||
- 热更新清单 `latest.json`
|
||||
- 桌面端图标资源
|
||||
- 本地回归检查
|
||||
|
||||
## 一、维护入口速查
|
||||
|
||||
- **改 OpenClaw 推荐稳定版**:编辑仓库根目录 `openclaw-version-policy.json`
|
||||
- **改 ClawPanel 程序版本号**:执行 `npm run version:set 0.x.y`
|
||||
- **改前端热更新清单**:编辑 `docs/update/latest.json`
|
||||
- **重生成桌面图标**:执行 `npm run icon:regen`
|
||||
- **本地回归**:执行 `npm run build`、`cargo check --manifest-path src-tauri/Cargo.toml`
|
||||
|
||||
## 二、如何调整 OpenClaw 推荐稳定版
|
||||
|
||||
ClawPanel 现在使用仓库根目录的 `openclaw-version-policy.json` 作为统一版本策略文件。
|
||||
|
||||
当前结构示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"default": {
|
||||
"official": { "recommended": "2026.3.11" },
|
||||
"chinese": { "recommended": "2026.3.7-zh.2" }
|
||||
},
|
||||
"panels": {
|
||||
"0.9.0": {
|
||||
"official": { "recommended": "2026.3.11" },
|
||||
"chinese": { "recommended": "2026.3.7-zh.2" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
维护建议:
|
||||
|
||||
1. **默认推荐版**:改 `default`
|
||||
2. **某个面板版本的推荐版**:改 `panels.<panel_version>`
|
||||
3. 如果新面板版本需要绑定独立推荐版,新增一个新的 `panels.<new_version>` 节点
|
||||
4. 如果没有单独配置某个面板版本,会回退到 `default`
|
||||
|
||||
改完这个文件后,Rust 后端和 Web dev 后端都会读取同一份策略,前端各页面也会自动显示新的推荐版本和风险提示。
|
||||
|
||||
## 三、如何调整 ClawPanel 程序版本号
|
||||
|
||||
ClawPanel 现在以 `package.json` 作为主版本源,并通过脚本同步到其他文件。
|
||||
|
||||
推荐用法:
|
||||
|
||||
```bash
|
||||
npm run version:set 0.9.1
|
||||
```
|
||||
|
||||
这条命令会同步以下文件:
|
||||
|
||||
- `package.json`
|
||||
- `src-tauri/tauri.conf.json`
|
||||
- `src-tauri/Cargo.toml`
|
||||
- `docs/index.html`
|
||||
|
||||
如果你只是想重新同步,不改版本号,也可以执行:
|
||||
|
||||
```bash
|
||||
npm run version:sync
|
||||
```
|
||||
|
||||
## 四、什么时候需要更新 `docs/update/latest.json`
|
||||
|
||||
`docs/update/latest.json` 用于桌面端前端热更新提示。
|
||||
|
||||
常见维护规则:
|
||||
|
||||
1. 发布了新的前端热更新包后,需要同步更新:
|
||||
- `version`
|
||||
- `minAppVersion`
|
||||
- `url`
|
||||
- `hash`
|
||||
- `releasedAt`
|
||||
2. 如果 `latest.json` 落后于当前程序版本,ClawPanel 现在**不会再误报有更新**,但用户也看不到最新发布提示,所以仍然建议及时维护
|
||||
3. 如果热更新资源还没准备好,不要提前把 `latest.json.version` 指到新版本
|
||||
|
||||
## 五、如何重生成桌面图标
|
||||
|
||||
ClawPanel 桌面端图标源现在使用 `docs/logo.png`。
|
||||
|
||||
重生成命令:
|
||||
|
||||
```bash
|
||||
npm run icon:regen
|
||||
```
|
||||
|
||||
它会重生成 `src-tauri/icons` 下的一整套图标资源,包括:
|
||||
|
||||
- `icon.icns`
|
||||
- `icon.ico`
|
||||
- `32x32.png`
|
||||
- `128x128.png`
|
||||
- 其他平台尺寸图标
|
||||
|
||||
如果后续只更新 Logo,重新执行一次即可,不需要手动逐个改图标文件。
|
||||
|
||||
## 六、本地回归检查建议
|
||||
|
||||
每次维护版本策略、程序版本号、热更新清单或桌面图标后,至少执行:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
cargo check --manifest-path src-tauri/Cargo.toml
|
||||
```
|
||||
|
||||
如果本次改动涉及安装/检测链路,建议额外确认:
|
||||
|
||||
- Windows 下自定义 Node 路径后,CLI 状态能立即刷新
|
||||
- “关于”页 / “服务管理”页能正确显示推荐稳定版
|
||||
- 本地版本高于推荐版时,风险提示仍然正确
|
||||
- 当 `docs/update/latest.json` 版本低于本地版本时,不会再误弹更新提示
|
||||
|
||||
## 七、推荐维护顺序
|
||||
|
||||
推荐按下面顺序维护:
|
||||
|
||||
1. 确认本次要发布的 ClawPanel 版本
|
||||
2. 执行 `npm run version:set x.y.z`
|
||||
3. 如有必要,更新 `openclaw-version-policy.json`
|
||||
4. 重新构建前端 / 检查 Rust 编译
|
||||
5. 如桌面图标有调整,执行 `npm run icon:regen`
|
||||
6. 如有前端热更新包,最后再更新 `docs/update/latest.json`
|
||||
|
||||
这样可以最大限度避免版本号、推荐版映射和更新清单不一致。
|
||||
28
openclaw-version-policy.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"default": {
|
||||
"official": {
|
||||
"recommended": "2026.3.13"
|
||||
},
|
||||
"chinese": {
|
||||
"recommended": "2026.3.13-zh.1"
|
||||
}
|
||||
},
|
||||
"panels": {
|
||||
"0.9.0": {
|
||||
"official": {
|
||||
"recommended": "2026.3.13"
|
||||
},
|
||||
"chinese": {
|
||||
"recommended": "2026.3.13-zh.1"
|
||||
}
|
||||
},
|
||||
"0.9.1": {
|
||||
"official": {
|
||||
"recommended": "2026.3.13"
|
||||
},
|
||||
"chinese": {
|
||||
"recommended": "2026.3.13-zh.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawpanel",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"private": true,
|
||||
"description": "ClawPanel - OpenClaw 可视化管理面板,基于 Tauri v2 的跨平台桌面应用",
|
||||
"type": "module",
|
||||
@@ -26,6 +26,7 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"icon:regen": "tauri icon docs/logo.png -o src-tauri/icons",
|
||||
"serve": "node scripts/serve.js",
|
||||
"version:sync": "node scripts/sync-version.js",
|
||||
"version:set": "node scripts/sync-version.js"
|
||||
|
||||
@@ -33,6 +33,22 @@ const DOCKER_NODES_PATH = path.join(OPENCLAW_DIR, 'docker-nodes.json')
|
||||
const INSTANCES_PATH = path.join(OPENCLAW_DIR, 'instances.json')
|
||||
const DOCKER_SOCKET = process.platform === 'win32' ? '//./pipe/docker_engine' : '/var/run/docker.sock'
|
||||
const OPENCLAW_IMAGE = 'ghcr.io/qingchencloud/openclaw'
|
||||
const PANEL_VERSION = (() => {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path.join(__dev_dirname, '..', 'package.json'), 'utf8')).version || '0.0.0'
|
||||
} catch {
|
||||
return '0.0.0'
|
||||
}
|
||||
})()
|
||||
const VERSION_POLICY_PATH = path.join(__dev_dirname, '..', 'openclaw-version-policy.json')
|
||||
const GIT_HTTPS_REWRITES = [
|
||||
'ssh://git@github.com/',
|
||||
'ssh://git@github.com',
|
||||
'ssh://git@://github.com/',
|
||||
'git@github.com:',
|
||||
'git://github.com/',
|
||||
'git+ssh://git@github.com/'
|
||||
]
|
||||
|
||||
// === 异步任务存储 ===
|
||||
const _taskStore = new Map() // taskId → task object
|
||||
@@ -64,21 +80,175 @@ function createTask(containerId, containerName, nodeId, message) {
|
||||
}
|
||||
|
||||
// 语义化版本比较
|
||||
function versionGe(a, b) {
|
||||
const pa = a.split('.').map(Number), pb = b.split('.').map(Number)
|
||||
function parseVersion(value) {
|
||||
return String(value || '').split(/[^0-9]/).filter(Boolean).map(Number)
|
||||
}
|
||||
function versionCompare(a, b) {
|
||||
const pa = parseVersion(a), pb = parseVersion(b)
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
if ((pa[i] || 0) > (pb[i] || 0)) return true
|
||||
if ((pa[i] || 0) < (pb[i] || 0)) return false
|
||||
if ((pa[i] || 0) > (pb[i] || 0)) return 1
|
||||
if ((pa[i] || 0) < (pb[i] || 0)) return -1
|
||||
}
|
||||
return true
|
||||
return 0
|
||||
}
|
||||
function versionGe(a, b) {
|
||||
return versionCompare(a, b) >= 0
|
||||
}
|
||||
function versionGt(a, b) {
|
||||
const pa = a.split('.').map(Number), pb = b.split('.').map(Number)
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
if ((pa[i] || 0) > (pb[i] || 0)) return true
|
||||
if ((pa[i] || 0) < (pb[i] || 0)) return false
|
||||
return versionCompare(a, b) > 0
|
||||
}
|
||||
|
||||
// 提取基础版本号(去掉 -zh.x / -nightly.xxx 等后缀)
|
||||
function baseVersion(v) {
|
||||
return String(v || '').split('-')[0]
|
||||
}
|
||||
|
||||
// 判断 CLI 版本是否与推荐版匹配(考虑汉化版 -zh.x 后缀差异)
|
||||
function versionsMatch(cliVer, recommended) {
|
||||
if (cliVer === recommended) return true
|
||||
return baseVersion(cliVer) === baseVersion(recommended)
|
||||
}
|
||||
|
||||
// 判断推荐版是否真的比当前版本更新(忽略 -zh.x 后缀)
|
||||
function recommendedIsNewer(recommended, current) {
|
||||
return versionGt(baseVersion(recommended), baseVersion(current))
|
||||
}
|
||||
|
||||
function loadVersionPolicy() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(VERSION_POLICY_PATH, 'utf8'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function recommendedVersionFor(source = 'chinese') {
|
||||
const policy = loadVersionPolicy()
|
||||
return policy?.panels?.[PANEL_VERSION]?.[source]?.recommended
|
||||
|| policy?.default?.[source]?.recommended
|
||||
|| null
|
||||
}
|
||||
|
||||
function npmPackageName(source = 'chinese') {
|
||||
return source === 'official' ? 'openclaw' : '@qingchencloud/openclaw-zh'
|
||||
}
|
||||
|
||||
function getConfiguredNpmRegistry() {
|
||||
const regFile = path.join(OPENCLAW_DIR, 'npm-registry.txt')
|
||||
try {
|
||||
if (fs.existsSync(regFile)) {
|
||||
const value = fs.readFileSync(regFile, 'utf8').trim()
|
||||
if (value) return value
|
||||
}
|
||||
} catch {}
|
||||
return 'https://registry.npmmirror.com'
|
||||
}
|
||||
|
||||
function pickRegistryForPackage(pkg) {
|
||||
const configured = getConfiguredNpmRegistry()
|
||||
if (pkg.includes('openclaw-zh')) {
|
||||
if (configured.includes('npmmirror.com') || configured.includes('npmjs.org')) return configured
|
||||
return 'https://registry.npmjs.org'
|
||||
}
|
||||
return configured
|
||||
}
|
||||
|
||||
function configureGitHttpsRules() {
|
||||
try { execSync('git config --global --unset-all url.https://github.com/.insteadOf 2>&1', { timeout: 5000, windowsHide: true }) } catch {}
|
||||
let success = 0
|
||||
for (const from of GIT_HTTPS_REWRITES) {
|
||||
try {
|
||||
execSync(`git config --global --add url.https://github.com/.insteadOf "${from}"`, { timeout: 5000, windowsHide: true })
|
||||
success++
|
||||
} catch {}
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
function buildGitInstallEnv() {
|
||||
const env = {
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o IdentitiesOnly=yes',
|
||||
GIT_ALLOW_PROTOCOL: 'https:http:file',
|
||||
GIT_CONFIG_COUNT: String(GIT_HTTPS_REWRITES.length),
|
||||
}
|
||||
GIT_HTTPS_REWRITES.forEach((from, idx) => {
|
||||
env[`GIT_CONFIG_KEY_${idx}`] = 'url.https://github.com/.insteadOf'
|
||||
env[`GIT_CONFIG_VALUE_${idx}`] = from
|
||||
})
|
||||
return env
|
||||
}
|
||||
|
||||
function detectInstalledSource() {
|
||||
if (isMac) {
|
||||
try {
|
||||
const target = fs.readlinkSync('/opt/homebrew/bin/openclaw')
|
||||
if (String(target).includes('openclaw-zh')) return 'chinese'
|
||||
return 'official'
|
||||
} catch {}
|
||||
}
|
||||
if (isWindows) {
|
||||
try {
|
||||
const appdata = process.env.APPDATA
|
||||
if (appdata) {
|
||||
const zhDir = path.join(appdata, 'npm', 'node_modules', '@qingchencloud', 'openclaw-zh')
|
||||
if (fs.existsSync(zhDir)) return 'chinese'
|
||||
}
|
||||
} catch {}
|
||||
return 'official'
|
||||
}
|
||||
try {
|
||||
const npmBin = isWindows ? 'npm.cmd' : 'npm'
|
||||
const out = execSync(`${npmBin} list -g @qingchencloud/openclaw-zh --depth=0 2>&1`, { timeout: 10000, windowsHide: true }).toString()
|
||||
if (out.includes('openclaw-zh@')) return 'chinese'
|
||||
} catch {}
|
||||
return 'official'
|
||||
}
|
||||
|
||||
function getLocalOpenclawVersion() {
|
||||
let current = null
|
||||
if (isMac) {
|
||||
try {
|
||||
const target = fs.readlinkSync('/opt/homebrew/bin/openclaw')
|
||||
const pkgPath = path.resolve('/opt/homebrew/bin', target, '..', 'package.json')
|
||||
current = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version
|
||||
} catch {}
|
||||
}
|
||||
if (!current && isWindows) {
|
||||
try {
|
||||
const appdata = process.env.APPDATA
|
||||
if (appdata) {
|
||||
for (const pkg of [path.join('@qingchencloud', 'openclaw-zh'), 'openclaw']) {
|
||||
const pkgPath = path.join(appdata, 'npm', 'node_modules', pkg, 'package.json')
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
current = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version
|
||||
if (current) break
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (!current) {
|
||||
try { current = execSync('openclaw --version 2>&1', { windowsHide: true }).toString().trim().split(/\s+/).pop() } catch {}
|
||||
}
|
||||
return current || null
|
||||
}
|
||||
|
||||
async function getLatestVersionFor(source = 'chinese') {
|
||||
const pkg = npmPackageName(source)
|
||||
const encodedPkg = pkg.replace('/', '%2F').replace('@', '%40')
|
||||
const firstRegistry = pickRegistryForPackage(pkg)
|
||||
const registries = [...new Set([firstRegistry, 'https://registry.npmjs.org'])]
|
||||
for (const registry of registries) {
|
||||
try {
|
||||
const resp = await fetch(`${registry}/${encodedPkg}/latest`, { headers: { 'Accept': 'application/json' }, signal: AbortSignal.timeout(10000) })
|
||||
if (!resp.ok) continue
|
||||
const data = await resp.json()
|
||||
if (data?.version) return data.version
|
||||
} catch {}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// === 访问密码 & Session 管理 ===
|
||||
@@ -2356,13 +2526,9 @@ const handlers = {
|
||||
|
||||
configure_git_https() {
|
||||
try {
|
||||
const cmds = [
|
||||
'git config --global url."https://github.com/".insteadOf "git@github.com:"',
|
||||
'git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"',
|
||||
'git config --global url."https://github.com/".insteadOf "git://github.com/"',
|
||||
]
|
||||
for (const cmd of cmds) execSync(cmd, { timeout: 5000, windowsHide: true })
|
||||
return '已配置 Git HTTPS 替代 SSH'
|
||||
const success = configureGitHttpsRules()
|
||||
if (!success) throw new Error('Git 未安装或写入失败')
|
||||
return `已配置 Git HTTPS 替代 SSH(${success}/${GIT_HTTPS_REWRITES.length} 条规则)`
|
||||
} catch (e) {
|
||||
throw new Error('配置失败: ' + (e.message || e))
|
||||
}
|
||||
@@ -2408,19 +2574,22 @@ const handlers = {
|
||||
},
|
||||
|
||||
// 版本信息
|
||||
get_version_info() {
|
||||
let current = null
|
||||
if (isMac) {
|
||||
try {
|
||||
const target = fs.readlinkSync('/opt/homebrew/bin/openclaw')
|
||||
const pkgPath = path.resolve('/opt/homebrew/bin', target, '..', 'package.json')
|
||||
current = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version
|
||||
} catch {}
|
||||
async get_version_info() {
|
||||
const source = detectInstalledSource()
|
||||
const current = getLocalOpenclawVersion()
|
||||
const latest = await getLatestVersionFor(source)
|
||||
const recommended = recommendedVersionFor(source)
|
||||
return {
|
||||
current,
|
||||
latest,
|
||||
recommended,
|
||||
update_available: current && recommended ? recommendedIsNewer(recommended, current) : !!recommended,
|
||||
latest_update_available: current && latest ? recommendedIsNewer(latest, current) : !!latest,
|
||||
is_recommended: !!current && !!recommended && versionsMatch(current, recommended),
|
||||
ahead_of_recommended: !!current && !!recommended && recommendedIsNewer(current, recommended),
|
||||
panel_version: PANEL_VERSION,
|
||||
source
|
||||
}
|
||||
if (!current) {
|
||||
try { current = execSync('openclaw --version 2>&1', { windowsHide: true }).toString().trim().split(/\s+/).pop() } catch {}
|
||||
}
|
||||
return { current, latest: null, update_available: false, source: 'chinese' }
|
||||
},
|
||||
|
||||
// 模型测试
|
||||
@@ -2429,8 +2598,8 @@ const handlers = {
|
||||
: apiType === 'google-gemini' ? 'google-gemini'
|
||||
: 'openai-completions'
|
||||
let base = _normalizeBaseUrl(baseUrl)
|
||||
// 仅 Anthropic 强制补 /v1,OpenAI 兼容类不强制(火山引擎等用 /v3)
|
||||
if (type === 'anthropic-messages' && !/\/v1$/i.test(base)) base += '/v1'
|
||||
else if (type === 'openai-completions' && !/\/v1$/i.test(base)) base += '/v1'
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 30000)
|
||||
try {
|
||||
@@ -2499,8 +2668,8 @@ const handlers = {
|
||||
: apiType === 'google-gemini' ? 'google-gemini'
|
||||
: 'openai-completions'
|
||||
let base = _normalizeBaseUrl(baseUrl)
|
||||
// 仅 Anthropic 强制补 /v1,OpenAI 兼容类不强制(火山引擎等用 /v3)
|
||||
if (type === 'anthropic-messages' && !/\/v1$/i.test(base)) base += '/v1'
|
||||
else if (type === 'openai-completions' && !/\/v1$/i.test(base)) base += '/v1'
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 15000)
|
||||
try {
|
||||
@@ -2699,49 +2868,73 @@ const handlers = {
|
||||
},
|
||||
|
||||
async list_openclaw_versions({ source = 'chinese' } = {}) {
|
||||
const pkg = source === 'official' ? 'openclaw' : '@qingchencloud/openclaw-zh'
|
||||
const encodedPkg = pkg.replace('/', '%2F')
|
||||
const registry = 'https://registry.npmmirror.com'
|
||||
try {
|
||||
const resp = await fetch(`${registry}/${encodedPkg}`, { headers: { 'Accept': 'application/json' }, signal: AbortSignal.timeout(10000) })
|
||||
const data = await resp.json()
|
||||
const versions = Object.keys(data.versions || {})
|
||||
versions.sort((a, b) => {
|
||||
const pa = a.split(/[^0-9]/).filter(Boolean).map(Number)
|
||||
const pb = b.split(/[^0-9]/).filter(Boolean).map(Number)
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
if ((pb[i] || 0) !== (pa[i] || 0)) return (pb[i] || 0) - (pa[i] || 0)
|
||||
const pkg = npmPackageName(source)
|
||||
const encodedPkg = pkg.replace('/', '%2F').replace('@', '%40')
|
||||
const firstRegistry = pickRegistryForPackage(pkg)
|
||||
const registries = [...new Set([firstRegistry, 'https://registry.npmjs.org'])]
|
||||
let lastError = null
|
||||
for (const registry of registries) {
|
||||
try {
|
||||
const resp = await fetch(`${registry}/${encodedPkg}`, { headers: { 'Accept': 'application/json' }, signal: AbortSignal.timeout(10000) })
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
const versions = Object.keys(data.versions || {})
|
||||
versions.sort((a, b) => versionCompare(b, a))
|
||||
const recommended = recommendedVersionFor(source)
|
||||
if (recommended) {
|
||||
const pos = versions.indexOf(recommended)
|
||||
if (pos >= 0) {
|
||||
versions.splice(pos, 1)
|
||||
versions.unshift(recommended)
|
||||
} else {
|
||||
versions.unshift(recommended)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
})
|
||||
return versions
|
||||
} catch (e) {
|
||||
throw new Error('查询版本失败: ' + e.message)
|
||||
return versions
|
||||
} catch (e) {
|
||||
lastError = e
|
||||
}
|
||||
}
|
||||
throw new Error('查询版本失败: ' + (lastError?.message || lastError || 'unknown error'))
|
||||
},
|
||||
|
||||
upgrade_openclaw({ source = 'chinese', version } = {}) {
|
||||
const OPENCLAW_DIR = path.join(homedir(), '.openclaw')
|
||||
const pkg = source === 'official' ? 'openclaw' : '@qingchencloud/openclaw-zh'
|
||||
const ver = version || 'latest'
|
||||
const currentSource = detectInstalledSource()
|
||||
const pkg = npmPackageName(source)
|
||||
const recommended = recommendedVersionFor(source)
|
||||
const ver = version || recommended || 'latest'
|
||||
const oldPkg = npmPackageName(currentSource)
|
||||
const needUninstallOld = currentSource !== source
|
||||
const npmBin = isWindows ? 'npm.cmd' : 'npm'
|
||||
// Configure Git HTTPS before npm install to prevent SSH auth failures
|
||||
try { execSync('git config --global --unset-all url.https://github.com/.insteadOf 2>&1', { windowsHide: true }) } catch {}
|
||||
for (const from of ['ssh://git@github.com/', 'git@github.com:', 'git://github.com/', 'git+ssh://git@github.com/']) {
|
||||
try { execSync(`git config --global --add url.https://github.com/.insteadOf "${from}"`, { windowsHide: true }) } catch {}
|
||||
}
|
||||
const gitEnv = {
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
GIT_CONFIG_COUNT: '4',
|
||||
GIT_CONFIG_KEY_0: 'url.https://github.com/.insteadOf', GIT_CONFIG_VALUE_0: 'ssh://git@github.com/',
|
||||
GIT_CONFIG_KEY_1: 'url.https://github.com/.insteadOf', GIT_CONFIG_VALUE_1: 'git@github.com:',
|
||||
GIT_CONFIG_KEY_2: 'url.https://github.com/.insteadOf', GIT_CONFIG_VALUE_2: 'git://github.com/',
|
||||
GIT_CONFIG_KEY_3: 'url.https://github.com/.insteadOf', GIT_CONFIG_VALUE_3: 'git+ssh://git@github.com/',
|
||||
const registry = pickRegistryForPackage(pkg)
|
||||
const gitConfigured = configureGitHttpsRules()
|
||||
const gitEnv = buildGitInstallEnv()
|
||||
const logs = []
|
||||
if (!version && recommended) {
|
||||
logs.push(`ClawPanel ${PANEL_VERSION} 默认绑定 OpenClaw 稳定版: ${recommended}`)
|
||||
}
|
||||
logs.push(`Git HTTPS 规则已就绪 (${gitConfigured}/${GIT_HTTPS_REWRITES.length})`)
|
||||
const runInstall = (targetRegistry) => execSync(
|
||||
`${npmBin} install -g ${pkg}@${ver} --force --registry ${targetRegistry} --verbose 2>&1`,
|
||||
{ timeout: 120000, windowsHide: true, env: gitEnv }
|
||||
).toString()
|
||||
try {
|
||||
const out = execSync(`${npmBin} install -g ${pkg}@${ver} --registry https://registry.npmmirror.com 2>&1`, { timeout: 120000, windowsHide: true, env: { ...process.env, ...gitEnv } }).toString()
|
||||
const action = ver === 'latest' ? '升级' : '安装'
|
||||
return `${action}完成 (${pkg}@${ver})\n${out.slice(-200)}`
|
||||
let out
|
||||
try {
|
||||
out = runInstall(registry)
|
||||
} catch (e) {
|
||||
if (registry !== 'https://registry.npmjs.org') {
|
||||
logs.push('镜像源安装失败,自动切换到 npm 官方源重试...')
|
||||
out = runInstall('https://registry.npmjs.org')
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
if (needUninstallOld) {
|
||||
try { execSync(`${npmBin} uninstall -g ${oldPkg} 2>&1`, { timeout: 60000, windowsHide: true }) } catch {}
|
||||
}
|
||||
logs.push(`安装完成 (${pkg}@${ver})`)
|
||||
return `${logs.join('\n')}\n${out.slice(-400)}`
|
||||
} catch (e) {
|
||||
throw new Error('安装失败: ' + (e.stderr?.toString() || e.message).slice(-300))
|
||||
}
|
||||
@@ -2771,9 +2964,10 @@ const handlers = {
|
||||
init_openclaw_config() {
|
||||
if (fs.existsSync(CONFIG_PATH)) return { created: false, message: '配置文件已存在' }
|
||||
if (!fs.existsSync(OPENCLAW_DIR)) fs.mkdirSync(OPENCLAW_DIR, { recursive: true })
|
||||
const lastTouchedVersion = recommendedVersionFor('chinese') || '2026.1.1'
|
||||
const defaultConfig = {
|
||||
"$schema": "https://openclaw.ai/schema/config.json",
|
||||
meta: { lastTouchedVersion: "2026.1.1" },
|
||||
meta: { lastTouchedVersion },
|
||||
models: { providers: {} },
|
||||
gateway: {
|
||||
mode: "local",
|
||||
@@ -3219,6 +3413,13 @@ const handlers = {
|
||||
return true
|
||||
},
|
||||
|
||||
test_proxy({ url }) {
|
||||
const cfg = readPanelConfig()
|
||||
const proxyUrl = cfg?.networkProxy?.url
|
||||
if (!proxyUrl) throw new Error('未配置代理地址')
|
||||
return { ok: true, status: 200, elapsed_ms: 0, proxy: proxyUrl, target: url || 'N/A (Web模式不支持代理测试)' }
|
||||
},
|
||||
|
||||
// === Agent 管理(Web 模式) ===
|
||||
|
||||
add_agent({ name, model, workspace }) {
|
||||
|
||||
2
src-tauri/Cargo.lock
generated
@@ -328,7 +328,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clawpanel"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "clawpanel"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
edition = "2021"
|
||||
description = "ClawPanel - OpenClaw 可视化管理面板"
|
||||
authors = ["qingchencloud"]
|
||||
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 154 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 81 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 202 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 895 B |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 469 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
@@ -382,11 +382,11 @@ pub async fn assistant_web_search(
|
||||
urlencoding::encode(&query)
|
||||
);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?;
|
||||
let client = super::build_http_client(
|
||||
std::time::Duration::from_secs(10),
|
||||
Some("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"),
|
||||
)
|
||||
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?;
|
||||
|
||||
let html = client
|
||||
.get(&url)
|
||||
@@ -454,10 +454,7 @@ pub async fn assistant_fetch_url(url: String) -> Result<String, String> {
|
||||
}
|
||||
|
||||
let jina_url = format!("https://r.jina.ai/{}", url);
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("Mozilla/5.0")
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
let client = super::build_http_client(std::time::Duration::from_secs(15), Some("Mozilla/5.0"))
|
||||
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?;
|
||||
|
||||
let content = client
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
use crate::utils::openclaw_command;
|
||||
/// 配置读写命令
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
@@ -29,6 +31,136 @@ impl Drop for GuardianPause {
|
||||
|
||||
/// 预设 npm 源列表
|
||||
const DEFAULT_REGISTRY: &str = "https://registry.npmmirror.com";
|
||||
const GIT_HTTPS_REWRITES: [&str; 6] = [
|
||||
"ssh://git@github.com/",
|
||||
"ssh://git@github.com",
|
||||
"ssh://git@://github.com/",
|
||||
"git@github.com:",
|
||||
"git://github.com/",
|
||||
"git+ssh://git@github.com/",
|
||||
];
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct VersionPolicySource {
|
||||
recommended: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct VersionPolicyEntry {
|
||||
#[serde(default)]
|
||||
official: VersionPolicySource,
|
||||
#[serde(default)]
|
||||
chinese: VersionPolicySource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct VersionPolicy {
|
||||
#[serde(default)]
|
||||
default: VersionPolicyEntry,
|
||||
#[serde(default)]
|
||||
panels: HashMap<String, VersionPolicyEntry>,
|
||||
}
|
||||
|
||||
fn panel_version() -> &'static str {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
|
||||
fn parse_version(value: &str) -> Vec<u32> {
|
||||
value
|
||||
.split(|c: char| !c.is_ascii_digit())
|
||||
.filter_map(|s| s.parse().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 提取基础版本号(去掉 -zh.x / -nightly.xxx 等后缀,只保留主版本数字部分)
|
||||
/// "2026.3.13-zh.1" → "2026.3.13", "2026.3.13" → "2026.3.13"
|
||||
fn base_version(v: &str) -> String {
|
||||
// 在第一个 '-' 处截断
|
||||
let base = v.split('-').next().unwrap_or(v);
|
||||
base.to_string()
|
||||
}
|
||||
|
||||
/// 判断 CLI 报告的版本是否与推荐版匹配(考虑汉化版 -zh.x 后缀差异)
|
||||
fn versions_match(cli_version: &str, recommended: &str) -> bool {
|
||||
if cli_version == recommended {
|
||||
return true;
|
||||
}
|
||||
// CLI 报告 "2026.3.13",推荐版 "2026.3.13-zh.1" → 基础版本相同即视为匹配
|
||||
base_version(cli_version) == base_version(recommended)
|
||||
}
|
||||
|
||||
/// 判断推荐版是否真的比当前版本更新(忽略 -zh.x 后缀)
|
||||
fn recommended_is_newer(recommended: &str, current: &str) -> bool {
|
||||
let r = parse_version(&base_version(recommended));
|
||||
let c = parse_version(&base_version(current));
|
||||
r > c
|
||||
}
|
||||
|
||||
fn load_version_policy() -> VersionPolicy {
|
||||
serde_json::from_str(include_str!("../../../openclaw-version-policy.json")).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn recommended_version_for(source: &str) -> Option<String> {
|
||||
let policy = load_version_policy();
|
||||
let panel_entry = policy.panels.get(panel_version());
|
||||
match source {
|
||||
"official" => panel_entry
|
||||
.and_then(|entry| entry.official.recommended.clone())
|
||||
.or(policy.default.official.recommended),
|
||||
_ => panel_entry
|
||||
.and_then(|entry| entry.chinese.recommended.clone())
|
||||
.or(policy.default.chinese.recommended),
|
||||
}
|
||||
}
|
||||
|
||||
fn configure_git_https_rules() -> usize {
|
||||
let mut unset = Command::new("git");
|
||||
unset.args([
|
||||
"config",
|
||||
"--global",
|
||||
"--unset-all",
|
||||
"url.https://github.com/.insteadOf",
|
||||
]);
|
||||
#[cfg(target_os = "windows")]
|
||||
unset.creation_flags(0x08000000);
|
||||
let _ = unset.output();
|
||||
|
||||
let mut success = 0;
|
||||
for from in GIT_HTTPS_REWRITES {
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.args([
|
||||
"config",
|
||||
"--global",
|
||||
"--add",
|
||||
"url.https://github.com/.insteadOf",
|
||||
from,
|
||||
]);
|
||||
#[cfg(target_os = "windows")]
|
||||
cmd.creation_flags(0x08000000);
|
||||
if cmd.output().map(|o| o.status.success()).unwrap_or(false) {
|
||||
success += 1;
|
||||
}
|
||||
}
|
||||
success
|
||||
}
|
||||
|
||||
fn apply_git_install_env(cmd: &mut Command) {
|
||||
crate::commands::apply_proxy_env(cmd);
|
||||
cmd.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env(
|
||||
"GIT_SSH_COMMAND",
|
||||
"ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o IdentitiesOnly=yes",
|
||||
)
|
||||
.env("GIT_ALLOW_PROTOCOL", "https:http:file");
|
||||
cmd.env("GIT_CONFIG_COUNT", GIT_HTTPS_REWRITES.len().to_string());
|
||||
for (idx, from) in GIT_HTTPS_REWRITES.iter().enumerate() {
|
||||
cmd.env(
|
||||
format!("GIT_CONFIG_KEY_{idx}"),
|
||||
"url.https://github.com/.insteadOf",
|
||||
)
|
||||
.env(format!("GIT_CONFIG_VALUE_{idx}"), from);
|
||||
}
|
||||
}
|
||||
|
||||
/// Linux: 检测是否以 root 身份运行(避免 unsafe libc 调用)
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -60,6 +192,7 @@ fn npm_command() -> Command {
|
||||
let mut cmd = Command::new("cmd");
|
||||
cmd.args(["/c", "npm", "--registry", ®istry]);
|
||||
cmd.env("PATH", super::enhanced_path());
|
||||
crate::commands::apply_proxy_env(&mut cmd);
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
cmd
|
||||
}
|
||||
@@ -68,6 +201,7 @@ fn npm_command() -> Command {
|
||||
let mut cmd = Command::new("npm");
|
||||
cmd.args(["--registry", ®istry]);
|
||||
cmd.env("PATH", super::enhanced_path());
|
||||
crate::commands::apply_proxy_env(&mut cmd);
|
||||
cmd
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -76,7 +210,7 @@ fn npm_command() -> Command {
|
||||
let need_sudo = !nix_is_root();
|
||||
let mut cmd = if need_sudo {
|
||||
let mut c = Command::new("sudo");
|
||||
c.args(["npm", "--registry", ®istry]);
|
||||
c.args(["-E", "npm", "--registry", ®istry]);
|
||||
c
|
||||
} else {
|
||||
let mut c = Command::new("npm");
|
||||
@@ -84,10 +218,53 @@ fn npm_command() -> Command {
|
||||
c
|
||||
};
|
||||
cmd.env("PATH", super::enhanced_path());
|
||||
crate::commands::apply_proxy_env(&mut cmd);
|
||||
cmd
|
||||
}
|
||||
}
|
||||
|
||||
/// 安装/升级前的清理工作:停止 Gateway、清理 npm 全局 bin 下的 openclaw 残留文件
|
||||
/// 解决 Windows 上 EEXIST(文件已存在)和文件被占用的问题
|
||||
fn pre_install_cleanup() {
|
||||
// 1. 停止 Gateway 进程,释放 openclaw 相关文件锁
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
// 杀死所有 openclaw gateway 相关的 node 进程
|
||||
let _ = Command::new("taskkill")
|
||||
.args(["/f", "/im", "node.exe", "/fi", "WINDOWTITLE eq OpenClaw*"])
|
||||
.creation_flags(0x08000000)
|
||||
.output();
|
||||
// 等文件锁释放
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let uid = get_uid().unwrap_or(501);
|
||||
let _ = Command::new("launchctl")
|
||||
.args(["bootout", &format!("gui/{uid}/ai.openclaw.gateway")])
|
||||
.output();
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let _ = Command::new("pkill").args(["-f", "openclaw.*gateway"]).output();
|
||||
}
|
||||
|
||||
// 2. 清理 npm 全局 bin 目录下的 openclaw 残留文件(Windows EEXIST 根因)
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Ok(appdata) = std::env::var("APPDATA") {
|
||||
let npm_bin = std::path::Path::new(&appdata).join("npm");
|
||||
for name in &["openclaw", "openclaw.cmd", "openclaw.ps1"] {
|
||||
let p = npm_bin.join(name);
|
||||
if p.exists() {
|
||||
let _ = fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn backups_dir() -> PathBuf {
|
||||
super::openclaw_dir().join("backups")
|
||||
}
|
||||
@@ -480,10 +657,7 @@ async fn get_local_version() -> Option<String> {
|
||||
|
||||
/// 从 npm registry 获取最新版本号,超时 5 秒
|
||||
async fn get_latest_version_for(source: &str) -> Option<String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(2))
|
||||
.build()
|
||||
.ok()?;
|
||||
let client = crate::commands::build_http_client(std::time::Duration::from_secs(2), None).ok()?;
|
||||
let pkg = npm_package_name(source)
|
||||
.replace('/', "%2F")
|
||||
.replace('@', "%40");
|
||||
@@ -547,19 +721,34 @@ pub async fn get_version_info() -> Result<VersionInfo, String> {
|
||||
let current = get_local_version().await;
|
||||
let source = detect_installed_source();
|
||||
let latest = get_latest_version_for(&source).await;
|
||||
let parse_ver = |v: &str| -> Vec<u32> {
|
||||
v.split(|c: char| !c.is_ascii_digit())
|
||||
.filter_map(|s| s.parse().ok())
|
||||
.collect()
|
||||
let recommended = recommended_version_for(&source);
|
||||
let update_available = match (¤t, &recommended) {
|
||||
(Some(c), Some(r)) => recommended_is_newer(r, c),
|
||||
(None, Some(_)) => true,
|
||||
_ => false,
|
||||
};
|
||||
let update_available = match (¤t, &latest) {
|
||||
(Some(c), Some(l)) => parse_ver(l) > parse_ver(c),
|
||||
let latest_update_available = match (¤t, &latest) {
|
||||
(Some(c), Some(l)) => recommended_is_newer(l, c),
|
||||
(None, Some(_)) => true,
|
||||
_ => false,
|
||||
};
|
||||
let is_recommended = match (¤t, &recommended) {
|
||||
(Some(c), Some(r)) => versions_match(c, r),
|
||||
_ => false,
|
||||
};
|
||||
let ahead_of_recommended = match (¤t, &recommended) {
|
||||
(Some(c), Some(r)) => recommended_is_newer(c, r),
|
||||
_ => false,
|
||||
};
|
||||
Ok(VersionInfo {
|
||||
current,
|
||||
latest,
|
||||
recommended,
|
||||
update_available,
|
||||
latest_update_available,
|
||||
is_recommended,
|
||||
ahead_of_recommended,
|
||||
panel_version: panel_version().to_string(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
@@ -599,9 +788,7 @@ fn npm_package_name(source: &str) -> &'static str {
|
||||
/// 获取指定源的所有可用版本列表(从 npm registry 查询)
|
||||
#[tauri::command]
|
||||
pub async fn list_openclaw_versions(source: String) -> Result<Vec<String>, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
let client = crate::commands::build_http_client(std::time::Duration::from_secs(10), None)
|
||||
.map_err(|e| format!("HTTP 初始化失败: {e}"))?;
|
||||
let pkg = npm_package_name(&source).replace('/', "%2F");
|
||||
let registry = get_configured_registry();
|
||||
@@ -616,35 +803,54 @@ pub async fn list_openclaw_versions(source: String) -> Result<Vec<String>, Strin
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("解析响应失败: {e}"))?;
|
||||
let versions = json
|
||||
let mut versions = json
|
||||
.get("versions")
|
||||
.and_then(|v| v.as_object())
|
||||
.map(|obj| {
|
||||
let mut vers: Vec<String> = obj.keys().cloned().collect();
|
||||
// 按版本号排序(新版本在前)
|
||||
vers.sort_by(|a, b| {
|
||||
let pa: Vec<u32> = a
|
||||
.split(|c: char| !c.is_ascii_digit())
|
||||
.filter_map(|s| s.parse().ok())
|
||||
.collect();
|
||||
let pb: Vec<u32> = b
|
||||
.split(|c: char| !c.is_ascii_digit())
|
||||
.filter_map(|s| s.parse().ok())
|
||||
.collect();
|
||||
let pa = parse_version(a);
|
||||
let pb = parse_version(b);
|
||||
pb.cmp(&pa)
|
||||
});
|
||||
vers
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if let Some(recommended) = recommended_version_for(&source) {
|
||||
if let Some(pos) = versions.iter().position(|v| v == &recommended) {
|
||||
let version = versions.remove(pos);
|
||||
versions.insert(0, version);
|
||||
} else {
|
||||
versions.insert(0, recommended);
|
||||
}
|
||||
}
|
||||
Ok(versions)
|
||||
}
|
||||
|
||||
/// 执行 npm 全局安装/升级/降级 openclaw(流式推送日志)
|
||||
/// 执行 npm 全局安装/升级/降级 openclaw(后台执行,通过 event 推送进度)
|
||||
/// 立即返回,不阻塞前端。完成后 emit "upgrade-done" 或 "upgrade-error"。
|
||||
#[tauri::command]
|
||||
pub async fn upgrade_openclaw(
|
||||
app: tauri::AppHandle,
|
||||
source: String,
|
||||
version: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let app2 = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
use tauri::Emitter;
|
||||
let result = upgrade_openclaw_inner(app2.clone(), source, version).await;
|
||||
match result {
|
||||
Ok(msg) => { let _ = app2.emit("upgrade-done", &msg); }
|
||||
Err(err) => { let _ = app2.emit("upgrade-error", &err); }
|
||||
}
|
||||
});
|
||||
Ok("任务已启动".into())
|
||||
}
|
||||
|
||||
async fn upgrade_openclaw_inner(
|
||||
app: tauri::AppHandle,
|
||||
source: String,
|
||||
version: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::process::Stdio;
|
||||
@@ -653,7 +859,12 @@ pub async fn upgrade_openclaw(
|
||||
|
||||
let current_source = detect_installed_source();
|
||||
let pkg_name = npm_package_name(&source);
|
||||
let ver = version.as_deref().unwrap_or("latest");
|
||||
let requested_version = version.clone();
|
||||
let recommended_version = recommended_version_for(&source);
|
||||
let ver = requested_version
|
||||
.as_deref()
|
||||
.or(recommended_version.as_deref())
|
||||
.unwrap_or("latest");
|
||||
let pkg = format!("{}@{}", pkg_name, ver);
|
||||
|
||||
// 切换源时需要卸载旧包,但为避免安装失败导致 CLI 丢失,
|
||||
@@ -661,35 +872,35 @@ pub async fn upgrade_openclaw(
|
||||
let old_pkg = npm_package_name(¤t_source);
|
||||
let need_uninstall_old = current_source != source;
|
||||
|
||||
// 自动配置 git 全面使用 HTTPS 替代 SSH/git 协议,避免用户没配 SSH Key 导致依赖安装失败
|
||||
let _ = app.emit("upgrade-log", "配置 Git HTTPS 模式...");
|
||||
// 先清除旧的 insteadOf 规则,再逐条添加(git config 不带 --add 会覆盖,只保留最后一条)
|
||||
let _ = Command::new("git")
|
||||
.args([
|
||||
"config",
|
||||
"--global",
|
||||
"--unset-all",
|
||||
"url.https://github.com/.insteadOf",
|
||||
])
|
||||
.output();
|
||||
for from in &[
|
||||
"ssh://git@github.com/",
|
||||
"git@github.com:",
|
||||
"git://github.com/",
|
||||
"git+ssh://git@github.com/",
|
||||
] {
|
||||
let _ = Command::new("git")
|
||||
.args([
|
||||
"config",
|
||||
"--global",
|
||||
"--add",
|
||||
"url.https://github.com/.insteadOf",
|
||||
from,
|
||||
])
|
||||
.output();
|
||||
if requested_version.is_none() {
|
||||
if let Some(recommended) = &recommended_version {
|
||||
let _ = app.emit(
|
||||
"upgrade-log",
|
||||
format!(
|
||||
"ClawPanel {} 默认绑定 OpenClaw 稳定版: {}",
|
||||
panel_version(),
|
||||
recommended
|
||||
),
|
||||
);
|
||||
} else {
|
||||
let _ = app.emit("upgrade-log", "未找到绑定稳定版,将回退到 latest");
|
||||
}
|
||||
}
|
||||
let configured_rules = configure_git_https_rules();
|
||||
let _ = app.emit(
|
||||
"upgrade-log",
|
||||
format!(
|
||||
"Git HTTPS 规则已就绪 ({}/{})",
|
||||
configured_rules,
|
||||
GIT_HTTPS_REWRITES.len()
|
||||
),
|
||||
);
|
||||
|
||||
let _ = app.emit("upgrade-log", format!("$ npm install -g {pkg}"));
|
||||
// 安装前:停止 Gateway 并清理可能冲突的 bin 文件
|
||||
let _ = app.emit("upgrade-log", "正在停止 Gateway 并清理旧文件...");
|
||||
pre_install_cleanup();
|
||||
|
||||
let _ = app.emit("upgrade-log", format!("$ npm install -g {pkg} --force"));
|
||||
let _ = app.emit("upgrade-progress", 10);
|
||||
|
||||
// 汉化版只支持官方源和淘宝源
|
||||
@@ -708,24 +919,10 @@ pub async fn upgrade_openclaw(
|
||||
configured_registry.as_str()
|
||||
};
|
||||
|
||||
let mut child = npm_command()
|
||||
.args(["install", "-g", &pkg, "--registry", registry, "--verbose"])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env(
|
||||
"GIT_SSH_COMMAND",
|
||||
"ssh -o BatchMode=yes -o StrictHostKeyChecking=no",
|
||||
)
|
||||
// Force HTTPS insteadOf via env vars — ensures npm-spawned git subprocesses also use HTTPS
|
||||
// even if global git config didn't take effect (e.g. git not in PATH, or Windows permission issues)
|
||||
.env("GIT_CONFIG_COUNT", "4")
|
||||
.env("GIT_CONFIG_KEY_0", "url.https://github.com/.insteadOf")
|
||||
.env("GIT_CONFIG_VALUE_0", "ssh://git@github.com/")
|
||||
.env("GIT_CONFIG_KEY_1", "url.https://github.com/.insteadOf")
|
||||
.env("GIT_CONFIG_VALUE_1", "git@github.com:")
|
||||
.env("GIT_CONFIG_KEY_2", "url.https://github.com/.insteadOf")
|
||||
.env("GIT_CONFIG_VALUE_2", "git://github.com/")
|
||||
.env("GIT_CONFIG_KEY_3", "url.https://github.com/.insteadOf")
|
||||
.env("GIT_CONFIG_VALUE_3", "git+ssh://git@github.com/")
|
||||
let mut install_cmd = npm_command();
|
||||
install_cmd.args(["install", "-g", &pkg, "--force", "--registry", registry, "--verbose"]);
|
||||
apply_git_install_env(&mut install_cmd);
|
||||
let mut child = install_cmd
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
@@ -778,22 +975,10 @@ pub async fn upgrade_openclaw(
|
||||
let _ = app.emit("upgrade-log", "⚠️ 镜像源安装失败,自动切换到官方源重试...");
|
||||
let _ = app.emit("upgrade-progress", 15);
|
||||
let fallback = "https://registry.npmjs.org";
|
||||
let mut child2 = npm_command()
|
||||
.args(["install", "-g", &pkg, "--registry", fallback, "--verbose"])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env(
|
||||
"GIT_SSH_COMMAND",
|
||||
"ssh -o BatchMode=yes -o StrictHostKeyChecking=no",
|
||||
)
|
||||
.env("GIT_CONFIG_COUNT", "4")
|
||||
.env("GIT_CONFIG_KEY_0", "url.https://github.com/.insteadOf")
|
||||
.env("GIT_CONFIG_VALUE_0", "ssh://git@github.com/")
|
||||
.env("GIT_CONFIG_KEY_1", "url.https://github.com/.insteadOf")
|
||||
.env("GIT_CONFIG_VALUE_1", "git@github.com:")
|
||||
.env("GIT_CONFIG_KEY_2", "url.https://github.com/.insteadOf")
|
||||
.env("GIT_CONFIG_VALUE_2", "git://github.com/")
|
||||
.env("GIT_CONFIG_KEY_3", "url.https://github.com/.insteadOf")
|
||||
.env("GIT_CONFIG_VALUE_3", "git+ssh://git@github.com/")
|
||||
let mut install_cmd2 = npm_command();
|
||||
install_cmd2.args(["install", "-g", &pkg, "--force", "--registry", fallback, "--verbose"]);
|
||||
apply_git_install_env(&mut install_cmd2);
|
||||
let mut child2 = install_cmd2
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
@@ -872,6 +1057,11 @@ pub async fn upgrade_openclaw(
|
||||
// 切换源后重装 Gateway 服务
|
||||
if need_uninstall_old {
|
||||
let _ = app.emit("upgrade-log", "正在重装 Gateway 服务(更新启动路径)...");
|
||||
|
||||
// 刷新 PATH 缓存和 CLI 检测缓存,确保找到新安装的二进制
|
||||
super::refresh_enhanced_path();
|
||||
crate::commands::service::invalidate_cli_detection_cache();
|
||||
|
||||
// 先停掉旧的
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
@@ -884,7 +1074,7 @@ pub async fn upgrade_openclaw(
|
||||
{
|
||||
let _ = openclaw_command().args(["gateway", "stop"]).output();
|
||||
}
|
||||
// 重新安装
|
||||
// 重新安装(刷新后的 PATH 会找到新二进制)
|
||||
use crate::utils::openclaw_command_async;
|
||||
let gw_out = openclaw_command_async()
|
||||
.args(["gateway", "install"])
|
||||
@@ -904,17 +1094,33 @@ pub async fn upgrade_openclaw(
|
||||
}
|
||||
|
||||
let new_ver = get_local_version().await.unwrap_or_else(|| "未知".into());
|
||||
let action = if ver == "latest" { "升级" } else { "安装" };
|
||||
let msg = format!("✅ {action}成功,当前版本: {new_ver}");
|
||||
let msg = format!("✅ 安装完成,当前版本: {new_ver}");
|
||||
let _ = app.emit("upgrade-log", &msg);
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
/// 卸载 OpenClaw(npm uninstall + 可选清理配置)
|
||||
/// 卸载 OpenClaw(后台执行,通过 event 推送进度)
|
||||
/// 立即返回,不阻塞前端。完成后 emit "upgrade-done" 或 "upgrade-error"。
|
||||
#[tauri::command]
|
||||
pub async fn uninstall_openclaw(
|
||||
app: tauri::AppHandle,
|
||||
clean_config: bool,
|
||||
) -> Result<String, String> {
|
||||
let app2 = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
use tauri::Emitter;
|
||||
let result = uninstall_openclaw_inner(app2.clone(), clean_config).await;
|
||||
match result {
|
||||
Ok(msg) => { let _ = app2.emit("upgrade-done", &msg); }
|
||||
Err(err) => { let _ = app2.emit("upgrade-error", &err); }
|
||||
}
|
||||
});
|
||||
Ok("任务已启动".into())
|
||||
}
|
||||
|
||||
async fn uninstall_openclaw_inner(
|
||||
app: tauri::AppHandle,
|
||||
clean_config: bool,
|
||||
) -> Result<String, String> {
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::process::Stdio;
|
||||
@@ -1042,9 +1248,11 @@ pub fn init_openclaw_config() -> Result<Value, String> {
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("创建目录失败: {e}"))?;
|
||||
}
|
||||
|
||||
let last_touched_version =
|
||||
recommended_version_for("chinese").unwrap_or_else(|| "2026.1.1".to_string());
|
||||
let default_config = serde_json::json!({
|
||||
"$schema": "https://openclaw.ai/schema/config.json",
|
||||
"meta": { "lastTouchedVersion": "2026.1.1" },
|
||||
"meta": { "lastTouchedVersion": last_touched_version },
|
||||
"models": { "providers": {} },
|
||||
"gateway": {
|
||||
"mode": "local",
|
||||
@@ -1230,6 +1438,7 @@ pub fn save_custom_node_path(node_dir: String) -> Result<(), String> {
|
||||
std::fs::write(&config_path, json).map_err(|e| format!("写入配置失败: {e}"))?;
|
||||
// 立即刷新 PATH 缓存,使新路径生效(无需重启应用)
|
||||
super::refresh_enhanced_path();
|
||||
crate::commands::service::invalidate_cli_detection_cache();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1490,9 +1699,7 @@ pub async fn test_model(
|
||||
let api_type = normalize_model_api_type(api_type.as_deref().unwrap_or("openai-completions"));
|
||||
let base = normalize_base_url_for_api(&base_url, api_type);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
let client = crate::commands::build_http_client_no_proxy(std::time::Duration::from_secs(30), None)
|
||||
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?;
|
||||
|
||||
let resp = match api_type {
|
||||
@@ -1636,9 +1843,7 @@ pub async fn list_remote_models(
|
||||
let api_type = normalize_model_api_type(api_type.as_deref().unwrap_or("openai-completions"));
|
||||
let base = normalize_base_url_for_api(&base_url, api_type);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
let client = crate::commands::build_http_client_no_proxy(std::time::Duration::from_secs(15), None)
|
||||
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?;
|
||||
|
||||
let resp = match api_type {
|
||||
@@ -1840,11 +2045,9 @@ pub fn patch_model_vision() -> Result<bool, String> {
|
||||
/// 检查 ClawPanel 自身是否有新版本(GitHub → Gitee 自动降级)
|
||||
#[tauri::command]
|
||||
pub async fn check_panel_update() -> Result<Value, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(8))
|
||||
.user_agent("ClawPanel")
|
||||
.build()
|
||||
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?;
|
||||
let client =
|
||||
crate::commands::build_http_client(std::time::Duration::from_secs(8), Some("ClawPanel"))
|
||||
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?;
|
||||
|
||||
// 先尝试 GitHub,失败后降级 Gitee
|
||||
let sources = [
|
||||
@@ -1931,6 +2134,39 @@ pub fn write_panel_config(config: Value) -> Result<(), String> {
|
||||
fs::write(&path, json).map_err(|e| format!("写入失败: {e}"))
|
||||
}
|
||||
|
||||
/// 测试代理连通性:通过配置的代理访问指定 URL,返回状态码和耗时
|
||||
#[tauri::command]
|
||||
pub async fn test_proxy(url: Option<String>) -> Result<Value, String> {
|
||||
let proxy_url = crate::commands::configured_proxy_url()
|
||||
.ok_or("未配置代理地址,请先在面板设置中保存代理地址")?;
|
||||
|
||||
let target = url.unwrap_or_else(|| "https://registry.npmjs.org/-/ping".to_string());
|
||||
|
||||
let client = crate::commands::build_http_client(std::time::Duration::from_secs(10), Some("ClawPanel"))
|
||||
.map_err(|e| format!("创建代理客户端失败: {e}"))?;
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let resp = client
|
||||
.get(&target)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let elapsed = start.elapsed().as_millis();
|
||||
format!("代理连接失败 ({elapsed}ms): {e}")
|
||||
})?;
|
||||
|
||||
let elapsed = start.elapsed().as_millis();
|
||||
let status = resp.status().as_u16();
|
||||
|
||||
Ok(json!({
|
||||
"ok": status < 500,
|
||||
"status": status,
|
||||
"elapsed_ms": elapsed,
|
||||
"proxy": proxy_url,
|
||||
"target": target,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_npm_registry() -> Result<String, String> {
|
||||
Ok(get_configured_registry())
|
||||
@@ -2126,23 +2362,12 @@ pub async fn auto_install_git(app: tauri::AppHandle) -> Result<String, String> {
|
||||
/// 配置 Git 使用 HTTPS 替代 SSH,解决国内用户 SSH 不通的问题
|
||||
#[tauri::command]
|
||||
pub fn configure_git_https() -> Result<String, String> {
|
||||
let mut success = 0;
|
||||
let configs = [
|
||||
("url.https://github.com/.insteadOf", "ssh://git@github.com/"),
|
||||
("url.https://github.com/.insteadOf", "git@github.com:"),
|
||||
("url.https://github.com/.insteadOf", "git://github.com/"),
|
||||
];
|
||||
for (key, value) in &configs {
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.args(["config", "--global", key, value]);
|
||||
#[cfg(target_os = "windows")]
|
||||
cmd.creation_flags(0x08000000);
|
||||
if cmd.output().map(|o| o.status.success()).unwrap_or(false) {
|
||||
success += 1;
|
||||
}
|
||||
}
|
||||
let success = configure_git_https_rules();
|
||||
if success > 0 {
|
||||
Ok(format!("已配置 Git 使用 HTTPS({success} 条规则)"))
|
||||
Ok(format!(
|
||||
"已配置 Git 使用 HTTPS({success}/{} 条规则)",
|
||||
GIT_HTTPS_REWRITES.len()
|
||||
))
|
||||
} else {
|
||||
Err("Git 未安装或配置失败".to_string())
|
||||
}
|
||||
@@ -2152,5 +2377,6 @@ pub fn configure_git_https() -> Result<String, String> {
|
||||
#[tauri::command]
|
||||
pub fn invalidate_path_cache() -> Result<(), String> {
|
||||
super::refresh_enhanced_path();
|
||||
crate::commands::service::invalidate_cli_detection_cache();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -482,9 +482,7 @@ pub async fn toggle_messaging_platform(
|
||||
#[tauri::command]
|
||||
pub async fn verify_bot_token(platform: String, form: Value) -> Result<Value, String> {
|
||||
let form_obj = form.as_object().ok_or("表单数据格式错误")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
let client = super::build_http_client(std::time::Duration::from_secs(15), None)
|
||||
.map_err(|e| format!("HTTP 客户端初始化失败: {}", e))?;
|
||||
|
||||
match platform.as_str() {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::net::IpAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::RwLock;
|
||||
use std::time::Duration;
|
||||
|
||||
pub mod agent;
|
||||
pub mod assistant;
|
||||
@@ -19,6 +21,119 @@ pub fn openclaw_dir() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_default().join(".openclaw")
|
||||
}
|
||||
|
||||
fn panel_config_path() -> PathBuf {
|
||||
openclaw_dir().join("clawpanel.json")
|
||||
}
|
||||
|
||||
fn read_panel_config_value() -> Option<serde_json::Value> {
|
||||
std::fs::read_to_string(panel_config_path())
|
||||
.ok()
|
||||
.and_then(|content| serde_json::from_str(&content).ok())
|
||||
}
|
||||
|
||||
pub fn configured_proxy_url() -> Option<String> {
|
||||
let value = read_panel_config_value()?;
|
||||
let raw = value
|
||||
.get("networkProxy")
|
||||
.and_then(|entry| {
|
||||
if let Some(obj) = entry.as_object() {
|
||||
obj.get("url").and_then(|v| v.as_str())
|
||||
} else {
|
||||
entry.as_str()
|
||||
}
|
||||
})?
|
||||
.trim()
|
||||
.to_string();
|
||||
if raw.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(raw)
|
||||
}
|
||||
}
|
||||
|
||||
fn should_bypass_proxy_host(host: &str) -> bool {
|
||||
let lower = host.trim().to_ascii_lowercase();
|
||||
if lower.is_empty() || lower == "localhost" || lower.ends_with(".local") {
|
||||
return true;
|
||||
}
|
||||
if let Ok(ip) = lower.parse::<IpAddr>() {
|
||||
return match ip {
|
||||
IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local(),
|
||||
IpAddr::V6(v6) => {
|
||||
v6.is_loopback() || v6.is_unique_local() || v6.is_unicast_link_local()
|
||||
}
|
||||
};
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 构建 HTTP 客户端,use_proxy=true 时走用户配置的代理
|
||||
pub fn build_http_client(
|
||||
timeout: Duration,
|
||||
user_agent: Option<&str>,
|
||||
) -> Result<reqwest::Client, String> {
|
||||
build_http_client_opt(timeout, user_agent, true)
|
||||
}
|
||||
|
||||
/// 构建模型请求用的 HTTP 客户端
|
||||
/// 默认不走代理;用户在面板设置中开启 proxyModelRequests 后才走代理
|
||||
pub fn build_http_client_no_proxy(
|
||||
timeout: Duration,
|
||||
user_agent: Option<&str>,
|
||||
) -> Result<reqwest::Client, String> {
|
||||
let use_proxy = read_panel_config_value()
|
||||
.and_then(|v| v.get("networkProxy")?.get("proxyModelRequests")?.as_bool())
|
||||
.unwrap_or(false);
|
||||
build_http_client_opt(timeout, user_agent, use_proxy)
|
||||
}
|
||||
|
||||
fn build_http_client_opt(
|
||||
timeout: Duration,
|
||||
user_agent: Option<&str>,
|
||||
use_proxy: bool,
|
||||
) -> Result<reqwest::Client, String> {
|
||||
let mut builder = reqwest::Client::builder().timeout(timeout);
|
||||
if let Some(ua) = user_agent {
|
||||
builder = builder.user_agent(ua);
|
||||
}
|
||||
if use_proxy {
|
||||
if let Some(proxy_url) = configured_proxy_url() {
|
||||
let proxy_value = proxy_url.clone();
|
||||
builder = builder.proxy(reqwest::Proxy::custom(move |url| {
|
||||
let host = url.host_str().unwrap_or("");
|
||||
if should_bypass_proxy_host(host) {
|
||||
None
|
||||
} else {
|
||||
Some(proxy_value.clone())
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
builder.build().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn apply_proxy_env(cmd: &mut std::process::Command) {
|
||||
if let Some(proxy_url) = configured_proxy_url() {
|
||||
cmd.env("HTTP_PROXY", &proxy_url)
|
||||
.env("HTTPS_PROXY", &proxy_url)
|
||||
.env("http_proxy", &proxy_url)
|
||||
.env("https_proxy", &proxy_url)
|
||||
.env("NO_PROXY", "localhost,127.0.0.1,::1")
|
||||
.env("no_proxy", "localhost,127.0.0.1,::1");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_proxy_env_tokio(cmd: &mut tokio::process::Command) {
|
||||
if let Some(proxy_url) = configured_proxy_url() {
|
||||
cmd.env("HTTP_PROXY", &proxy_url)
|
||||
.env("HTTPS_PROXY", &proxy_url)
|
||||
.env("http_proxy", &proxy_url)
|
||||
.env("https_proxy", &proxy_url)
|
||||
.env("NO_PROXY", "localhost,127.0.0.1,::1")
|
||||
.env("no_proxy", "localhost,127.0.0.1,::1");
|
||||
}
|
||||
}
|
||||
|
||||
/// 缓存 enhanced_path 结果,避免每次调用都扫描文件系统
|
||||
/// 使用 RwLock 替代 OnceLock,支持运行时刷新缓存
|
||||
static ENHANCED_PATH_CACHE: RwLock<Option<String>> = RwLock::new(None);
|
||||
|
||||
@@ -459,20 +459,20 @@ mod platform {
|
||||
.open(log_dir.join("gateway.err.log"))
|
||||
.map_err(|e| format!("创建错误日志文件失败: {e}"))?;
|
||||
|
||||
Command::new("openclaw")
|
||||
.arg("gateway")
|
||||
let mut cmd = Command::new("openclaw");
|
||||
cmd.arg("gateway")
|
||||
.env("PATH", &enhanced)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(stdout_log)
|
||||
.stderr(stderr_log)
|
||||
.spawn()
|
||||
.map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
"OpenClaw CLI 未找到,请确认已安装并重启 ClawPanel。".to_string()
|
||||
} else {
|
||||
format!("启动 Gateway 失败: {e}")
|
||||
}
|
||||
})?;
|
||||
.stderr(stderr_log);
|
||||
crate::commands::apply_proxy_env(&mut cmd);
|
||||
cmd.spawn().map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
"OpenClaw CLI 未找到,请确认已安装并重启 ClawPanel。".to_string()
|
||||
} else {
|
||||
format!("启动 Gateway 失败: {e}")
|
||||
}
|
||||
})?;
|
||||
|
||||
// 等 Gateway 初始化
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
@@ -601,9 +601,11 @@ mod platform {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod platform {
|
||||
use std::env;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::Mutex;
|
||||
use tokio::process::Command as TokioCommand;
|
||||
@@ -635,17 +637,65 @@ mod platform {
|
||||
result
|
||||
}
|
||||
|
||||
pub fn invalidate_cli_cache() {
|
||||
if let Ok(mut guard) = CLI_CACHE.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate_cli_paths() -> Vec<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
if let Ok(appdata) = env::var("APPDATA") {
|
||||
candidates.push(Path::new(&appdata).join("npm").join("openclaw.cmd"));
|
||||
}
|
||||
if let Ok(localappdata) = env::var("LOCALAPPDATA") {
|
||||
candidates.push(
|
||||
Path::new(&localappdata)
|
||||
.join("Programs")
|
||||
.join("nodejs")
|
||||
.join("node_modules")
|
||||
.join("@qingchencloud")
|
||||
.join("openclaw-zh")
|
||||
.join("bin")
|
||||
.join("openclaw.js"),
|
||||
);
|
||||
}
|
||||
|
||||
for segment in crate::commands::enhanced_path().split(';') {
|
||||
let dir = segment.trim();
|
||||
if dir.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let base = Path::new(dir);
|
||||
candidates.push(base.join("openclaw.cmd"));
|
||||
candidates.push(base.join("openclaw"));
|
||||
candidates.push(base.join("node_modules").join("@qingchencloud").join("openclaw-zh").join("bin").join("openclaw.js"));
|
||||
}
|
||||
|
||||
candidates
|
||||
}
|
||||
|
||||
fn check_cli_installed_inner() -> bool {
|
||||
// 方式1: 检查常见文件路径(零进程,最快)
|
||||
if let Ok(appdata) = std::env::var("APPDATA") {
|
||||
let cmd_path = std::path::Path::new(&appdata)
|
||||
.join("npm")
|
||||
.join("openclaw.cmd");
|
||||
if cmd_path.exists() {
|
||||
for path in candidate_cli_paths() {
|
||||
if path.exists() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// 方式2: 通过 PATH 查找(兼容 nvm、自定义 prefix 等)
|
||||
|
||||
// 方式2: 通过 where 查找(兼容 nvm、自定义 prefix 等)
|
||||
let mut where_cmd = std::process::Command::new("where");
|
||||
where_cmd.arg("openclaw");
|
||||
where_cmd.env("PATH", crate::commands::enhanced_path());
|
||||
where_cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
if let Ok(o) = where_cmd.output() {
|
||||
if o.status.success() && !String::from_utf8_lossy(&o.stdout).trim().is_empty() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 方式3: 直接执行版本命令兜底
|
||||
let mut cmd = std::process::Command::new("cmd");
|
||||
cmd.args(["/c", "openclaw", "--version"]);
|
||||
cmd.env("PATH", crate::commands::enhanced_path());
|
||||
@@ -829,15 +879,15 @@ mod platform {
|
||||
let enhanced = crate::commands::enhanced_path();
|
||||
let (stdout_log, stderr_log) = create_gateway_log_files()?;
|
||||
|
||||
std::process::Command::new("cmd")
|
||||
.args(["/c", "openclaw", "gateway"])
|
||||
let mut cmd = std::process::Command::new("cmd");
|
||||
cmd.args(["/c", "openclaw", "gateway"])
|
||||
.env("PATH", &enhanced)
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(stdout_log)
|
||||
.stderr(stderr_log)
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动 Gateway 失败: {e}"))?;
|
||||
.stderr(stderr_log);
|
||||
crate::commands::apply_proxy_env(&mut cmd);
|
||||
cmd.spawn().map_err(|e| format!("启动 Gateway 失败: {e}"))?;
|
||||
|
||||
for _ in 0..50 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
@@ -1025,6 +1075,14 @@ mod platform {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn invalidate_cli_detection_cache() {
|
||||
platform::invalidate_cli_cache();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn invalidate_cli_detection_cache() {}
|
||||
|
||||
// ===== 跨平台公共接口 =====
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -112,6 +112,7 @@ pub async fn skills_install_dep(kind: String, spec: Value) -> Result<Value, Stri
|
||||
|
||||
let mut cmd = tokio::process::Command::new(&program);
|
||||
cmd.args(&args).env("PATH", &path_env);
|
||||
super::apply_proxy_env_tokio(&mut cmd);
|
||||
#[cfg(target_os = "windows")]
|
||||
cmd.creation_flags(0x08000000);
|
||||
let output = cmd
|
||||
@@ -152,6 +153,7 @@ pub async fn skills_clawhub_install(slug: String) -> Result<Value, String> {
|
||||
cmd.args(["-y", "clawhub", "install", &slug])
|
||||
.env("PATH", &path_env)
|
||||
.current_dir(&home);
|
||||
super::apply_proxy_env_tokio(&mut cmd);
|
||||
#[cfg(target_os = "windows")]
|
||||
cmd.creation_flags(0x08000000);
|
||||
let output = cmd
|
||||
@@ -185,6 +187,7 @@ pub async fn skills_clawhub_search(query: String) -> Result<Value, String> {
|
||||
let mut cmd = tokio::process::Command::new("npx");
|
||||
cmd.args(["-y", "clawhub", "search", &q])
|
||||
.env("PATH", &path_env);
|
||||
super::apply_proxy_env_tokio(&mut cmd);
|
||||
#[cfg(target_os = "windows")]
|
||||
cmd.creation_flags(0x08000000);
|
||||
let output = cmd
|
||||
|
||||
@@ -15,10 +15,7 @@ const LATEST_JSON_URL: &str = "https://claw.qt.cool/update/latest.json";
|
||||
/// 检查前端是否有新版本可用
|
||||
#[tauri::command]
|
||||
pub async fn check_frontend_update() -> Result<Value, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.user_agent("ClawPanel")
|
||||
.build()
|
||||
let client = super::build_http_client(std::time::Duration::from_secs(10), Some("ClawPanel"))
|
||||
.map_err(|e| format!("HTTP 客户端错误: {e}"))?;
|
||||
|
||||
let resp = client
|
||||
@@ -48,8 +45,9 @@ pub async fn check_frontend_update() -> Result<Value, String> {
|
||||
.unwrap_or("0.0.0");
|
||||
|
||||
let compatible = version_ge(current, min_app);
|
||||
let has_update = !latest.is_empty() && latest != current && compatible;
|
||||
let update_ready = update_dir().join("index.html").exists();
|
||||
let remote_newer = !latest.is_empty() && compatible && version_gt(&latest, current);
|
||||
let update_ready = remote_newer && update_dir().join("index.html").exists();
|
||||
let has_update = remote_newer && !update_ready;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"currentVersion": current,
|
||||
@@ -64,10 +62,7 @@ pub async fn check_frontend_update() -> Result<Value, String> {
|
||||
/// 下载并解压前端更新包
|
||||
#[tauri::command]
|
||||
pub async fn download_frontend_update(url: String, expected_hash: String) -> Result<Value, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.user_agent("ClawPanel")
|
||||
.build()
|
||||
let client = super::build_http_client(std::time::Duration::from_secs(120), Some("ClawPanel"))
|
||||
.map_err(|e| format!("HTTP 客户端错误: {e}"))?;
|
||||
|
||||
let resp = client
|
||||
@@ -194,6 +189,10 @@ fn version_ge(current: &str, required: &str) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn version_gt(left: &str, right: &str) -> bool {
|
||||
version_ge(left, right) && !version_ge(right, left)
|
||||
}
|
||||
|
||||
/// 根据文件扩展名推断 MIME 类型
|
||||
pub fn mime_from_path(path: &str) -> &'static str {
|
||||
match path.rsplit('.').next().unwrap_or("") {
|
||||
|
||||
@@ -91,6 +91,7 @@ pub fn run() {
|
||||
config::check_panel_update,
|
||||
config::read_panel_config,
|
||||
config::write_panel_config,
|
||||
config::test_proxy,
|
||||
config::get_npm_registry,
|
||||
config::set_npm_registry,
|
||||
config::check_git,
|
||||
|
||||
@@ -14,6 +14,11 @@ pub struct ServiceStatus {
|
||||
pub struct VersionInfo {
|
||||
pub current: Option<String>,
|
||||
pub latest: Option<String>,
|
||||
pub recommended: Option<String>,
|
||||
pub update_available: bool,
|
||||
pub latest_update_available: bool,
|
||||
pub is_recommended: bool,
|
||||
pub ahead_of_recommended: bool,
|
||||
pub panel_version: String,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ pub fn openclaw_command() -> std::process::Command {
|
||||
let mut cmd = std::process::Command::new("cmd");
|
||||
cmd.arg("/c").arg(cmd_path);
|
||||
cmd.env("PATH", &enhanced);
|
||||
crate::commands::apply_proxy_env(&mut cmd);
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
return cmd;
|
||||
}
|
||||
@@ -35,6 +36,7 @@ pub fn openclaw_command() -> std::process::Command {
|
||||
let mut cmd = std::process::Command::new("cmd");
|
||||
cmd.arg("/c").arg("openclaw");
|
||||
cmd.env("PATH", &enhanced);
|
||||
crate::commands::apply_proxy_env(&mut cmd);
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
cmd
|
||||
}
|
||||
@@ -42,6 +44,7 @@ pub fn openclaw_command() -> std::process::Command {
|
||||
{
|
||||
let mut cmd = std::process::Command::new("openclaw");
|
||||
cmd.env("PATH", crate::commands::enhanced_path());
|
||||
crate::commands::apply_proxy_env(&mut cmd);
|
||||
cmd
|
||||
}
|
||||
}
|
||||
@@ -57,6 +60,7 @@ pub fn openclaw_command_async() -> tokio::process::Command {
|
||||
let mut cmd = tokio::process::Command::new("cmd");
|
||||
cmd.arg("/c").arg(cmd_path);
|
||||
cmd.env("PATH", &enhanced);
|
||||
crate::commands::apply_proxy_env_tokio(&mut cmd);
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
return cmd;
|
||||
}
|
||||
@@ -64,6 +68,7 @@ pub fn openclaw_command_async() -> tokio::process::Command {
|
||||
let mut cmd = tokio::process::Command::new("cmd");
|
||||
cmd.arg("/c").arg("openclaw");
|
||||
cmd.env("PATH", &enhanced);
|
||||
crate::commands::apply_proxy_env_tokio(&mut cmd);
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
cmd
|
||||
}
|
||||
@@ -71,6 +76,7 @@ pub fn openclaw_command_async() -> tokio::process::Command {
|
||||
{
|
||||
let mut cmd = tokio::process::Command::new("openclaw");
|
||||
cmd.env("PATH", crate::commands::enhanced_path());
|
||||
crate::commands::apply_proxy_env_tokio(&mut cmd);
|
||||
cmd
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
|
||||
"productName": "ClawPanel",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"identifier": "ai.openclaw.clawpanel",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
const BOT_ICON = '<svg viewBox="0 0 24 24"><path d="M12 8V4H8"/><rect x="5" y="8" width="14" height="12" rx="2"/><path d="M9 13h0"/><path d="M15 13h0"/><path d="M10 17h4"/></svg>'
|
||||
|
||||
const POS_KEY = 'clawpanel-fab-pos'
|
||||
const ENABLE_AI_FAB = true
|
||||
|
||||
// ── 页面上下文收集器注册表 ──
|
||||
const _contextProviders = {}
|
||||
@@ -25,8 +26,14 @@ let _fab = null
|
||||
|
||||
/** 初始化 FAB */
|
||||
export function initAIFab() {
|
||||
if (!ENABLE_AI_FAB) {
|
||||
document.querySelectorAll('.ai-fab').forEach(el => el.remove())
|
||||
_fab = null
|
||||
return null
|
||||
}
|
||||
if (_fab) return _fab
|
||||
_fab = createFab()
|
||||
showDragHintOnce(_fab.el)
|
||||
return _fab
|
||||
}
|
||||
|
||||
@@ -42,8 +49,13 @@ export function openAIDrawerWithError(errorCtx) {
|
||||
// 不自动导航 — FAB 按钮会出现红点提示,用户主动点击时跳转
|
||||
// 如果用户已在助手页,也会实时检测到
|
||||
if (getCurrentRoute() !== '/assistant') {
|
||||
// 让 FAB 显示红点
|
||||
if (_fab?.el) _fab.el.classList.add('has-error')
|
||||
if (_fab?.el) {
|
||||
_fab.el.classList.add('has-error')
|
||||
} else {
|
||||
import('./toast.js')
|
||||
.then(({ toast }) => toast('已保存诊断上下文,可从侧边栏进入「晴辰助手」继续处理', 'info'))
|
||||
.catch(() => {})
|
||||
}
|
||||
} else {
|
||||
// 已在助手页 → 直接触发 banner 显示
|
||||
window.dispatchEvent(new CustomEvent('assistant-error-injected'))
|
||||
@@ -172,10 +184,11 @@ function createFab() {
|
||||
window.location.hash = '#/assistant'
|
||||
}
|
||||
|
||||
// ── 路由变化时隐藏/显示 ──
|
||||
// ── 路由变化时隐藏/显示(助手页和实时聊天页隐藏) ──
|
||||
const HIDE_ROUTES = ['/assistant', '/chat']
|
||||
function updateVisibility() {
|
||||
const route = getCurrentRoute()
|
||||
fab.style.display = route === '/assistant' ? 'none' : 'flex'
|
||||
fab.style.display = HIDE_ROUTES.includes(route) ? 'none' : 'flex'
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', updateVisibility)
|
||||
@@ -209,3 +222,14 @@ function restorePosition(fab) {
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const HINT_KEY = 'clawpanel-fab-hint-shown'
|
||||
function showDragHintOnce(el) {
|
||||
if (!el || localStorage.getItem(HINT_KEY)) return
|
||||
const tip = document.createElement('div')
|
||||
tip.className = 'ai-fab-hint'
|
||||
tip.textContent = '长按可拖动'
|
||||
el.appendChild(tip)
|
||||
localStorage.setItem(HINT_KEY, '1')
|
||||
setTimeout(() => tip.remove(), 4000)
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ export function showUpgradeModal(title) {
|
||||
</div>
|
||||
<div class="upgrade-log-box"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary btn-sm" data-action="close" disabled>关闭</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="close">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
@@ -210,9 +210,51 @@ export function showUpgradeModal(title) {
|
||||
const _logLines = []
|
||||
|
||||
let _onClose = null
|
||||
closeBtn.onclick = () => { overlay.remove(); _onClose?.() }
|
||||
let _finished = false
|
||||
let _taskBar = null
|
||||
|
||||
// 重新打开弹窗(从任务状态栏点击时)
|
||||
function reopenModal() {
|
||||
if (_taskBar) { _taskBar.remove(); _taskBar = null }
|
||||
document.body.appendChild(overlay)
|
||||
}
|
||||
|
||||
// 关闭弹窗:未完成时显示任务状态栏
|
||||
function closeModal() {
|
||||
overlay.remove()
|
||||
if (!_finished) {
|
||||
showTaskBar()
|
||||
} else {
|
||||
if (_taskBar) { _taskBar.remove(); _taskBar = null }
|
||||
_onClose?.()
|
||||
}
|
||||
}
|
||||
|
||||
// 全局任务状态栏:关闭弹窗后显示在页面顶部
|
||||
function showTaskBar() {
|
||||
if (_taskBar) return
|
||||
_taskBar = document.createElement('div')
|
||||
_taskBar.className = 'upgrade-task-bar'
|
||||
_taskBar.innerHTML = `
|
||||
<span class="upgrade-task-bar-text">${text.textContent}</span>
|
||||
<button class="btn btn-sm upgrade-task-bar-open">查看详情</button>
|
||||
<button class="btn btn-sm btn-ghost upgrade-task-bar-dismiss">×</button>
|
||||
`
|
||||
_taskBar.querySelector('.upgrade-task-bar-open').onclick = reopenModal
|
||||
_taskBar.querySelector('.upgrade-task-bar-dismiss').onclick = () => { _taskBar.remove(); _taskBar = null }
|
||||
document.body.appendChild(_taskBar)
|
||||
}
|
||||
|
||||
function updateTaskBar(statusText) {
|
||||
if (_taskBar) {
|
||||
const span = _taskBar.querySelector('.upgrade-task-bar-text')
|
||||
if (span) span.textContent = statusText
|
||||
}
|
||||
}
|
||||
|
||||
closeBtn.onclick = closeModal
|
||||
overlay.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !closeBtn.disabled) { overlay.remove(); _onClose?.() }
|
||||
if (e.key === 'Escape') closeModal()
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -233,25 +275,33 @@ export function showUpgradeModal(title) {
|
||||
getLogText() { return _logLines.join('\n') },
|
||||
setProgress(pct) {
|
||||
fill.style.width = pct + '%'
|
||||
if (pct >= 100) text.textContent = '完成'
|
||||
else if (pct >= 75) text.textContent = '正在安装...'
|
||||
else if (pct >= 30) text.textContent = '正在下载依赖...'
|
||||
else text.textContent = '准备中...'
|
||||
let statusText
|
||||
if (pct >= 100) statusText = '完成'
|
||||
else if (pct >= 75) statusText = '正在安装...'
|
||||
else if (pct >= 30) statusText = '正在下载依赖...'
|
||||
else statusText = '准备中...'
|
||||
text.textContent = statusText
|
||||
updateTaskBar(statusText)
|
||||
},
|
||||
setDone(msg) {
|
||||
_finished = true
|
||||
text.textContent = msg || '升级完成'
|
||||
fill.style.width = '100%'
|
||||
fill.classList.add('done')
|
||||
closeBtn.disabled = false
|
||||
if (_taskBar) { _taskBar.remove(); _taskBar = null }
|
||||
closeBtn.focus()
|
||||
},
|
||||
setError(msg) {
|
||||
_finished = true
|
||||
text.textContent = msg || '升级失败'
|
||||
fill.classList.add('error')
|
||||
closeBtn.disabled = false
|
||||
if (_taskBar) {
|
||||
const span = _taskBar.querySelector('.upgrade-task-bar-text')
|
||||
if (span) { span.textContent = msg || '升级失败'; span.style.color = 'var(--error)' }
|
||||
}
|
||||
closeBtn.focus()
|
||||
},
|
||||
onClose(fn) { _onClose = fn },
|
||||
destroy() { overlay.remove(); _onClose?.() },
|
||||
destroy() { overlay.remove(); if (_taskBar) { _taskBar.remove(); _taskBar = null } _onClose?.() },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ const NAV_ITEMS_FULL = [
|
||||
{
|
||||
section: '',
|
||||
items: [
|
||||
{ route: '/settings', label: '面板设置', icon: 'settings' },
|
||||
{ route: '/chat-debug', label: '系统诊断', icon: 'debug' },
|
||||
{ route: '/about', label: '关于', icon: 'about' },
|
||||
]
|
||||
@@ -64,6 +65,7 @@ const NAV_ITEMS_SETUP = [
|
||||
{
|
||||
section: '',
|
||||
items: [
|
||||
{ route: '/settings', label: '面板设置', icon: 'settings' },
|
||||
{ route: '/chat-debug', label: '系统诊断', icon: 'debug' },
|
||||
{ route: '/about', label: '关于', icon: 'about' },
|
||||
]
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
*/
|
||||
|
||||
const NPM_CMD = 'npm install -g @qingchencloud/openclaw-zh --registry https://registry.npmmirror.com'
|
||||
const GIT_HTTPS_CMD = 'git config --global url."https://github.com/".insteadOf ssh://git@github.com/ && git config --global --add url."https://github.com/".insteadOf ssh://git@github.com && git config --global --add url."https://github.com/".insteadOf ssh://git@://github.com/ && git config --global --add url."https://github.com/".insteadOf git@github.com: && git config --global --add url."https://github.com/".insteadOf git://github.com/ && git config --global --add url."https://github.com/".insteadOf git+ssh://git@github.com/'
|
||||
const GIT_HTTPS_ROOT_CMD = 'sudo git config --global url."https://github.com/".insteadOf ssh://git@github.com/ && sudo git config --global --add url."https://github.com/".insteadOf ssh://git@github.com && sudo git config --global --add url."https://github.com/".insteadOf ssh://git@://github.com/ && sudo git config --global --add url."https://github.com/".insteadOf git@github.com: && sudo git config --global --add url."https://github.com/".insteadOf git://github.com/ && sudo git config --global --add url."https://github.com/".insteadOf git+ssh://git@github.com/'
|
||||
|
||||
/**
|
||||
* @param {string} errStr - npm 错误输出(可含流式日志)
|
||||
@@ -11,6 +13,11 @@ const NPM_CMD = 'npm install -g @qingchencloud/openclaw-zh --registry https://re
|
||||
*/
|
||||
export function diagnoseInstallError(errStr) {
|
||||
const s = errStr.toLowerCase()
|
||||
const rootNpm = s.includes('/root/.npm/') || s.includes('/root/.config/') || s.includes('sudo npm')
|
||||
const gitFixCommand = rootNpm ? GIT_HTTPS_ROOT_CMD : GIT_HTTPS_CMD
|
||||
const gitFixHint = rootNpm
|
||||
? 'GitHub SSH 认证失败。检测到本次安装实际由 root/sudo 执行,请先为 root 用户配置 HTTPS 替代规则后重试:'
|
||||
: 'GitHub SSH 认证失败。ClawPanel 已尝试自动配置 HTTPS 替代,但可能未生效。请在终端手动执行:'
|
||||
|
||||
// ===== 1. Git 相关 =====
|
||||
|
||||
@@ -18,8 +25,8 @@ export function diagnoseInstallError(errStr) {
|
||||
if (s.includes('permission denied (publickey)') || s.includes('host key verification failed')) {
|
||||
return {
|
||||
title: '安装失败 — Git SSH 认证被拒绝',
|
||||
hint: 'GitHub SSH 认证失败。ClawPanel 已尝试自动配置 HTTPS 替代,但可能未生效。请在终端手动执行:',
|
||||
command: 'git config --global url."https://github.com/".insteadOf ssh://git@github.com/ && git config --global --add url."https://github.com/".insteadOf git@github.com: && git config --global --add url."https://github.com/".insteadOf git://github.com/ && git config --global --add url."https://github.com/".insteadOf git+ssh://git@github.com/',
|
||||
hint: gitFixHint,
|
||||
command: gitFixCommand,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,14 +35,14 @@ export function diagnoseInstallError(errStr) {
|
||||
if (s.includes('permission denied') || s.includes('publickey') || s.includes('host key verification')) {
|
||||
return {
|
||||
title: '安装失败 — Git SSH 认证被拒绝',
|
||||
hint: 'GitHub SSH 认证失败。ClawPanel 已尝试自动配置 HTTPS 替代,但可能未生效。请在终端手动执行后重试:',
|
||||
command: 'git config --global url."https://github.com/".insteadOf ssh://git@github.com/ && git config --global --add url."https://github.com/".insteadOf git@github.com: && git config --global --add url."https://github.com/".insteadOf git://github.com/ && git config --global --add url."https://github.com/".insteadOf git+ssh://git@github.com/',
|
||||
hint: rootNpm ? 'GitHub SSH 认证失败。检测到本次安装由 root/sudo 执行,请先为 root 用户配置 HTTPS 替代规则后重试:' : 'GitHub SSH 认证失败。ClawPanel 已尝试自动配置 HTTPS 替代,但可能未生效。请在终端手动执行后重试:',
|
||||
command: gitFixCommand,
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: '安装失败 — Git 拉取依赖错误',
|
||||
hint: 'Git 操作失败(exit 128)。可能是网络问题或 SSH 认证失败。请先确认网络正常,然后在终端手动执行以下命令后重试:',
|
||||
command: 'git config --global url."https://github.com/".insteadOf ssh://git@github.com/ && git config --global --add url."https://github.com/".insteadOf git@github.com: && git config --global --add url."https://github.com/".insteadOf git+ssh://git@github.com/',
|
||||
hint: rootNpm ? 'Git 操作失败(exit 128)。检测到本次安装由 root/sudo 执行,请先确认网络正常,再为 root 用户执行以下 HTTPS 替代规则后重试:' : 'Git 操作失败(exit 128)。可能是网络问题或 SSH 认证失败。请先确认网络正常,然后在终端手动执行以下命令后重试:',
|
||||
command: gitFixCommand,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +66,15 @@ export function diagnoseInstallError(errStr) {
|
||||
}
|
||||
}
|
||||
|
||||
// EEXIST(文件已存在,切换版本/源时常见)
|
||||
if (s.includes('eexist') || s.includes('file already exists') || s.includes('file exists')) {
|
||||
return {
|
||||
title: '安装失败 — 文件冲突',
|
||||
hint: '旧版本的 openclaw 命令文件仍然存在。ClawPanel 已尝试自动清理,如仍失败请手动处理后重试:',
|
||||
command: 'npm install -g @qingchencloud/openclaw-zh --force --registry https://registry.npmmirror.com',
|
||||
}
|
||||
}
|
||||
|
||||
// ENOENT(文件找不到 / -4058)
|
||||
if (s.includes('enoent') || s.includes('-4058') || s.includes('code -4058')) {
|
||||
// 尝试从日志中提取具体缺失的路径
|
||||
|
||||
@@ -14,11 +14,17 @@ export const API_TYPES = [
|
||||
// 服务商快捷预设(晴辰云官方置顶)
|
||||
export const PROVIDER_PRESETS = [
|
||||
{ key: 'qtcool', label: '晴辰云', badge: '官方', baseUrl: 'https://gpt.qt.cool/v1', api: 'openai-completions', site: 'https://gpt.qt.cool/', desc: 'GPT-5 全系列开箱即用,更多模型持续接入中。每日签到送额度 · 邀请送余额 · 充值最低 3 折消耗 · 未消耗包退' },
|
||||
{ key: 'shengsuanyun', label: '胜算云', hidden: true, baseUrl: 'https://router.shengsuanyun.com/api/v1', api: 'openai-completions', site: 'https://www.shengsuanyun.com/?from=CH_4BVI0BM2', desc: '国内知名 AI 模型聚合平台,支持多种主流模型' },
|
||||
{ key: 'shengsuanyun', label: '胜算云', baseUrl: 'https://router.shengsuanyun.com/api/v1', api: 'openai-completions', site: 'https://www.shengsuanyun.com/?from=CH_4BVI0BM2', desc: '国内知名 AI 模型聚合平台,支持多种主流模型' },
|
||||
{ key: 'siliconflow', label: '硅基流动', baseUrl: 'https://api.siliconflow.cn/v1', api: 'openai-completions', site: 'https://cloud.siliconflow.cn/i/PFrw2an5', desc: '高性价比推理平台,支持 DeepSeek、Qwen 等开源模型' },
|
||||
{ key: 'volcengine', label: '火山引擎', baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', api: 'openai-completions', site: 'https://volcengine.com/L/Ph1OP5I3_GY', desc: '字节跳动旗下云平台,支持豆包等模型' },
|
||||
{ key: 'aliyun', label: '阿里云百炼', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', api: 'openai-completions', site: 'https://www.aliyun.com/benefit/ai/aistar?userCode=keahn2zr&clubBiz=subTask..12435175..10263..', desc: '阿里云 AI 大模型平台,支持通义千问全系列' },
|
||||
{ key: 'zhipu', label: '智谱 AI', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', api: 'openai-completions', site: 'https://www.bigmodel.cn/glm-coding?ic=3F6F9XYKTS', desc: '国产大模型领军企业,支持 GLM-4 全系列' },
|
||||
{ key: 'minimax', label: 'MiniMax', baseUrl: 'https://api.minimax.chat/v1', api: 'openai-completions', site: 'https://platform.minimaxi.com/subscribe/coding-plan?code=7pUc5oLo4K&source=link', desc: '国产多模态大模型,支持 MiniMax-Text 系列' },
|
||||
{ key: 'openai', label: 'OpenAI 官方', baseUrl: 'https://api.openai.com/v1', api: 'openai-completions' },
|
||||
{ key: 'anthropic', label: 'Anthropic 官方', baseUrl: 'https://api.anthropic.com', api: 'anthropic-messages' },
|
||||
{ key: 'deepseek', label: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1', api: 'openai-completions' },
|
||||
{ key: 'google', label: 'Google Gemini', baseUrl: 'https://generativelanguage.googleapis.com/v1beta', api: 'google-gemini' },
|
||||
{ key: 'nvidia', label: 'NVIDIA NIM', baseUrl: 'https://integrate.api.nvidia.com/v1', api: 'openai-completions', desc: '英伟达推理平台,支持 Llama、Mistral 等模型' },
|
||||
{ key: 'ollama', label: 'Ollama (本地)', baseUrl: 'http://127.0.0.1:11434/v1', api: 'openai-completions' },
|
||||
]
|
||||
|
||||
|
||||
@@ -205,6 +205,7 @@ export const api = {
|
||||
// 面板配置 (clawpanel.json)
|
||||
readPanelConfig: () => invoke('read_panel_config'),
|
||||
writePanelConfig: (config) => invoke('write_panel_config', { config }),
|
||||
testProxy: (url) => invoke('test_proxy', { url: url || null }),
|
||||
|
||||
// 安装/部署
|
||||
checkInstallation: () => cachedInvoke('check_installation', {}, 60000),
|
||||
@@ -212,7 +213,7 @@ export const api = {
|
||||
checkNode: () => cachedInvoke('check_node', {}, 60000),
|
||||
checkNodeAtPath: (nodeDir) => invoke('check_node_at_path', { nodeDir }),
|
||||
scanNodePaths: () => invoke('scan_node_paths'),
|
||||
saveCustomNodePath: (nodeDir) => invoke('save_custom_node_path', { nodeDir }).then(r => { invalidate('check_node'); invoke('invalidate_path_cache').catch(() => {}); return r }),
|
||||
saveCustomNodePath: (nodeDir) => invoke('save_custom_node_path', { nodeDir }).then(r => { invalidate('check_node', 'get_services_status'); invoke('invalidate_path_cache').catch(() => {}); return r }),
|
||||
invalidatePathCache: () => invoke('invalidate_path_cache'),
|
||||
checkGit: () => cachedInvoke('check_git', {}, 60000),
|
||||
autoInstallGit: () => invoke('auto_install_git'),
|
||||
|
||||
29
src/main.js
@@ -4,7 +4,7 @@
|
||||
import { registerRoute, initRouter, navigate, setDefaultRoute } from './router.js'
|
||||
import { renderSidebar, openMobileSidebar } from './components/sidebar.js'
|
||||
import { initTheme } from './lib/theme.js'
|
||||
import { detectOpenclawStatus, isOpenclawReady, isGatewayRunning, onGatewayChange, startGatewayPoll, onGuardianGiveUp, resetAutoRestart, loadActiveInstance, getActiveInstance, onInstanceChange } from './lib/app-state.js'
|
||||
import { detectOpenclawStatus, isOpenclawReady, isUpgrading, isGatewayRunning, onGatewayChange, startGatewayPoll, onGuardianGiveUp, resetAutoRestart, loadActiveInstance, getActiveInstance, onInstanceChange } from './lib/app-state.js'
|
||||
import { wsClient } from './lib/ws-client.js'
|
||||
import { api, checkBackendHealth, isBackendOnline, onBackendStatusChange } from './lib/tauri-api.js'
|
||||
import { version as APP_VERSION } from '../package.json'
|
||||
@@ -306,6 +306,7 @@ async function boot() {
|
||||
registerRoute('/cron', () => import('./pages/cron.js'))
|
||||
registerRoute('/usage', () => import('./pages/usage.js'))
|
||||
registerRoute('/communication', () => import('./pages/communication.js'))
|
||||
registerRoute('/settings', () => import('./pages/settings.js'))
|
||||
|
||||
renderSidebar(sidebar)
|
||||
initRouter(content)
|
||||
@@ -407,6 +408,30 @@ async function boot() {
|
||||
if (isGatewayRunning()) autoConnectWebSocket()
|
||||
})
|
||||
}
|
||||
|
||||
// 全局监听后台任务完成/失败事件,自动刷新安装状态和侧边栏
|
||||
if (window.__TAURI_INTERNALS__) {
|
||||
import('@tauri-apps/api/event').then(async ({ listen }) => {
|
||||
const refreshAfterTask = async () => {
|
||||
// 清除 API 缓存,确保拿到最新状态
|
||||
const { invalidate } = await import('./lib/tauri-api.js')
|
||||
invalidate('check_installation', 'get_services_status', 'get_version_info')
|
||||
await detectOpenclawStatus()
|
||||
renderSidebar(sidebar)
|
||||
// 如果安装完成后变为就绪,跳转到仪表盘
|
||||
if (isOpenclawReady() && window.location.hash === '#/setup') {
|
||||
navigate('/dashboard')
|
||||
}
|
||||
// 如果卸载后变为未就绪,跳转到 setup
|
||||
if (!isOpenclawReady() && !isUpgrading()) {
|
||||
setDefaultRoute('/setup')
|
||||
navigate('/setup')
|
||||
}
|
||||
}
|
||||
await listen('upgrade-done', refreshAfterTask)
|
||||
await listen('upgrade-error', refreshAfterTask)
|
||||
}).catch(() => {})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -729,7 +754,7 @@ function startUpdateChecker() {
|
||||
} catch {}
|
||||
try {
|
||||
const ver = await api.getVersionInfo()
|
||||
lines.push(`- 版本: ${ver?.current || '?'} → ${ver?.latest || '?'}`)
|
||||
lines.push(`- 版本: 当前 ${ver?.current || '?'} / 推荐 ${ver?.recommended || '?'} / 最新 ${ver?.latest || '?'}${ver?.ahead_of_recommended ? ' / 当前版本高于推荐版' : ''}`)
|
||||
} catch {}
|
||||
return { detail: lines.join('\n') }
|
||||
})
|
||||
|
||||
@@ -84,6 +84,12 @@ async function loadData(page) {
|
||||
const isInstalled = !!version.current
|
||||
const sourceLabel = version.source === 'official' ? '官方版' : '汉化版'
|
||||
const btnSm = 'padding:2px 8px;font-size:var(--font-size-xs)'
|
||||
const hasRecommended = !!version.recommended
|
||||
const aheadOfRecommended = isInstalled && hasRecommended && !!version.ahead_of_recommended
|
||||
const driftFromRecommended = isInstalled && hasRecommended && !version.is_recommended && !aheadOfRecommended
|
||||
const policyRiskHint = aheadOfRecommended
|
||||
? `检测到你本地安装的是高于推荐稳定版的 ${version.current},可能存在接口、事件或配置兼容性问题。建议回退到 ${version.recommended};如果你要继续使用高版本,请自行验证兼容性并关注 issue / release。`
|
||||
: '当前面板默认只保证推荐稳定版的兼容性;如果你要尝试其他版本或预览版,请自行验证兼容性。若希望面板尽快支持最新版特性,欢迎提交 issue 告诉我们。'
|
||||
|
||||
cards.innerHTML = `
|
||||
<div class="stat-card">
|
||||
@@ -95,15 +101,24 @@ async function loadData(page) {
|
||||
<div class="stat-card-header"><span class="stat-card-label">OpenClaw · ${sourceLabel}</span></div>
|
||||
<div class="stat-card-value">${version.current || '未安装'}</div>
|
||||
<div class="stat-card-meta" style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||
${isInstalled ? (version.update_available
|
||||
? `<span style="color:var(--accent)">新版本: ${version.latest}</span>
|
||||
<button class="btn btn-primary btn-sm" id="btn-upgrade-latest" style="${btnSm}">升级到最新</button>`
|
||||
: '<span style="color:var(--success)">已是最新</span>') : ''}
|
||||
${isInstalled && hasRecommended
|
||||
? (aheadOfRecommended
|
||||
? `<span style="color:var(--warning,#f59e0b)">当前版本高于推荐稳定版: ${version.recommended}</span>
|
||||
<button class="btn btn-primary btn-sm" id="btn-apply-recommended" style="${btnSm}">回退到推荐版</button>`
|
||||
: driftFromRecommended
|
||||
? `<span style="color:var(--accent)">推荐稳定版: ${version.recommended}</span>
|
||||
<button class="btn btn-primary btn-sm" id="btn-apply-recommended" style="${btnSm}">切换到推荐版</button>`
|
||||
: '<span style="color:var(--success)">已是推荐稳定版</span>')
|
||||
: ''}
|
||||
${version.latest_update_available && version.latest ? `<span style="color:var(--text-tertiary)">最新上游: ${version.latest}</span>` : ''}
|
||||
<button class="btn btn-${isInstalled ? 'secondary' : 'primary'} btn-sm" id="btn-version-mgmt" style="${btnSm}">
|
||||
${isInstalled ? '切换版本' : '安装 OpenClaw'}
|
||||
</button>
|
||||
${isInstalled ? `<button class="btn btn-secondary btn-sm" id="btn-uninstall" style="${btnSm};color:var(--error)">卸载</button>` : ''}
|
||||
</div>
|
||||
<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary);line-height:1.6">
|
||||
${policyRiskHint}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header"><span class="stat-card-label">安装路径</span></div>
|
||||
@@ -112,10 +127,9 @@ async function loadData(page) {
|
||||
</div>
|
||||
`
|
||||
|
||||
// 升级到最新
|
||||
const upgLatestBtn = cards.querySelector('#btn-upgrade-latest')
|
||||
if (upgLatestBtn) {
|
||||
upgLatestBtn.onclick = () => doInstall(page, '升级 OpenClaw', version.source, null)
|
||||
const applyRecommendedBtn = cards.querySelector('#btn-apply-recommended')
|
||||
if (applyRecommendedBtn && version.recommended) {
|
||||
applyRecommendedBtn.onclick = () => doInstall(page, aheadOfRecommended ? '回退到推荐稳定版' : '切换到推荐稳定版', version.source, version.recommended)
|
||||
}
|
||||
|
||||
// 版本管理 / 安装
|
||||
@@ -133,22 +147,25 @@ async function loadData(page) {
|
||||
const modal = showUpgradeModal('卸载 OpenClaw')
|
||||
modal.onClose(() => loadData(page))
|
||||
modal.appendLog('开始卸载 OpenClaw...')
|
||||
let unlistenLog, unlistenProgress
|
||||
let unlistenLog, unlistenProgress, unlistenDone, unlistenError
|
||||
const cleanup = () => { unlistenLog?.(); unlistenProgress?.(); unlistenDone?.(); unlistenError?.() }
|
||||
try {
|
||||
if (window.__TAURI_INTERNALS__) {
|
||||
try {
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
|
||||
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
|
||||
} catch {}
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
|
||||
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
|
||||
unlistenDone = await listen('upgrade-done', (e) => { cleanup(); modal.setDone(typeof e.payload === 'string' ? e.payload : '卸载完成') })
|
||||
unlistenError = await listen('upgrade-error', (e) => { cleanup(); modal.setError('卸载失败: ' + (e.payload || '未知错误')) })
|
||||
await api.uninstallOpenclaw(false)
|
||||
modal.appendLog('后台卸载任务已启动...')
|
||||
} else {
|
||||
const msg = await api.uninstallOpenclaw(false)
|
||||
modal.setDone(typeof msg === 'string' ? msg : '卸载完成')
|
||||
cleanup()
|
||||
}
|
||||
const msg = await api.uninstallOpenclaw(false)
|
||||
modal.setDone(typeof msg === 'string' ? msg : '卸载完成')
|
||||
} catch (e) {
|
||||
cleanup()
|
||||
modal.setError('卸载失败: ' + (e?.message || e))
|
||||
} finally {
|
||||
unlistenLog?.()
|
||||
unlistenProgress?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,6 +204,9 @@ async function showVersionPicker(page, currentVersion) {
|
||||
<option value="">加载中...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);line-height:1.6;padding:10px 12px;border-radius:8px;background:var(--bg-tertiary)">
|
||||
默认建议使用当前面板绑定的推荐稳定版。若手动切换到其它版本,尤其是预览版/最新版,请自行验证兼容性;如果你希望面板优先适配最新版功能,欢迎提交 issue。
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;min-height:18px">
|
||||
<div id="oc-action-hint" style="font-size:var(--font-size-xs);color:var(--text-tertiary)"></div>
|
||||
<div id="nightly-toggle" style="display:none"></div>
|
||||
@@ -227,19 +247,20 @@ async function showVersionPicker(page, currentVersion) {
|
||||
const targetSource = currentSelect
|
||||
const targetVer = select.value
|
||||
if (!targetVer || targetVer === '') { hintEl.textContent = ''; confirmBtn.disabled = true; return }
|
||||
const targetTag = select.selectedIndex === 0 ? '(推荐稳定版)' : '(需自测兼容性)'
|
||||
|
||||
const sameSource = targetSource === (currentVersion.source === 'official' ? 'official' : 'chinese')
|
||||
|
||||
if (!isInstalled) {
|
||||
confirmBtn.textContent = '安装'
|
||||
hintEl.textContent = `将安装 ${targetSource === 'official' ? '原版' : '汉化版'} ${targetVer}`
|
||||
hintEl.textContent = `将安装 ${targetSource === 'official' ? '原版' : '汉化版'} ${targetVer}${targetTag}`
|
||||
confirmBtn.disabled = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!sameSource) {
|
||||
confirmBtn.textContent = '切换'
|
||||
hintEl.innerHTML = `当前: <strong>${currentVersion.source === 'official' ? '原版' : '汉化版'} ${currentVersion.current}</strong> → <strong>${targetSource === 'official' ? '原版' : '汉化版'} ${targetVer}</strong>`
|
||||
hintEl.innerHTML = `当前: <strong>${currentVersion.source === 'official' ? '原版' : '汉化版'} ${currentVersion.current}</strong> → <strong>${targetSource === 'official' ? '原版' : '汉化版'} ${targetVer}</strong>${targetTag}`
|
||||
confirmBtn.disabled = false
|
||||
return
|
||||
}
|
||||
@@ -256,15 +277,15 @@ async function showVersionPicker(page, currentVersion) {
|
||||
|
||||
if (cmp === 0) {
|
||||
confirmBtn.textContent = '重新安装'
|
||||
hintEl.textContent = `当前已是 ${targetVer}`
|
||||
hintEl.textContent = `当前已是 ${targetVer}${targetTag}`
|
||||
confirmBtn.disabled = false
|
||||
} else if (cmp > 0) {
|
||||
confirmBtn.textContent = '升级'
|
||||
hintEl.innerHTML = `<span style="color:var(--accent)">${currentVersion.current} → ${targetVer}</span>`
|
||||
hintEl.innerHTML = `<span style="color:var(--accent)">${currentVersion.current} → ${targetVer}${targetTag}</span>`
|
||||
confirmBtn.disabled = false
|
||||
} else {
|
||||
confirmBtn.textContent = '降级'
|
||||
hintEl.innerHTML = `<span style="color:var(--warning,#f59e0b)">${currentVersion.current} → ${targetVer}</span>`
|
||||
hintEl.innerHTML = `<span style="color:var(--warning,#f59e0b)">${currentVersion.current} → ${targetVer}${targetTag}</span>`
|
||||
confirmBtn.disabled = false
|
||||
}
|
||||
}
|
||||
@@ -287,9 +308,9 @@ async function showVersionPicker(page, currentVersion) {
|
||||
const stable = allVersions.filter(v => !v.includes('nightly') && !v.includes('canary') && !v.includes('alpha') && !v.includes('beta') && !v.includes('rc') && !v.includes('dev') && !v.includes('next'))
|
||||
const versions = showNightly ? allVersions : (stable.length > 0 ? stable : allVersions)
|
||||
const nightlyCount = allVersions.length - stable.length
|
||||
select.innerHTML = versions.map(v => {
|
||||
select.innerHTML = versions.map((v, idx) => {
|
||||
const isCurrent = isInstalled && v === currentVersion.current && source === (currentVersion.source === 'official' ? 'official' : 'chinese')
|
||||
return `<option value="${v}">${v}${isCurrent ? ' (当前)' : ''}</option>`
|
||||
return `<option value="${v}">${v}${idx === 0 ? ' (推荐)' : ''}${isCurrent ? ' (当前)' : ''}</option>`
|
||||
}).join('')
|
||||
// nightly 切换提示
|
||||
const toggleEl = overlay.querySelector('#nightly-toggle')
|
||||
@@ -338,42 +359,57 @@ async function showVersionPicker(page, currentVersion) {
|
||||
async function doInstall(page, title, source, version) {
|
||||
const modal = showUpgradeModal(title)
|
||||
modal.onClose(() => loadData(page))
|
||||
let unlistenLog, unlistenProgress
|
||||
let unlistenLog, unlistenProgress, unlistenDone, unlistenError
|
||||
setUpgrading(true)
|
||||
|
||||
const cleanup = () => {
|
||||
setUpgrading(false)
|
||||
unlistenLog?.(); unlistenProgress?.(); unlistenDone?.(); unlistenError?.()
|
||||
}
|
||||
|
||||
try {
|
||||
if (window.__TAURI_INTERNALS__) {
|
||||
try {
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
|
||||
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
|
||||
} catch {}
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
|
||||
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
|
||||
|
||||
unlistenDone = await listen('upgrade-done', (e) => {
|
||||
cleanup()
|
||||
modal.setDone(typeof e.payload === 'string' ? e.payload : '操作完成')
|
||||
})
|
||||
|
||||
unlistenError = await listen('upgrade-error', async (e) => {
|
||||
cleanup()
|
||||
const errStr = String(e.payload || '未知错误')
|
||||
modal.appendLog(errStr)
|
||||
const { diagnoseInstallError } = await import('../lib/error-diagnosis.js')
|
||||
const fullLog = modal.getLogText() + '\n' + errStr
|
||||
const diagnosis = diagnoseInstallError(fullLog)
|
||||
modal.setError(diagnosis.title)
|
||||
if (diagnosis.hint) modal.appendLog('')
|
||||
if (diagnosis.hint) modal.appendHtmlLog(`${statusIcon('info', 14)} ${diagnosis.hint}`)
|
||||
if (diagnosis.command) modal.appendHtmlLog(`${icon('clipboard', 14)} ${diagnosis.command}`)
|
||||
if (window.__openAIDrawerWithError) {
|
||||
window.__openAIDrawerWithError({ title: diagnosis.title, error: fullLog, scene: title, hint: diagnosis.hint })
|
||||
}
|
||||
})
|
||||
|
||||
await api.upgradeOpenclaw(source, version)
|
||||
modal.appendLog('后台任务已启动,请等待完成...')
|
||||
} else {
|
||||
modal.appendLog('Web 模式:安装过程日志不可用,请等待完成...')
|
||||
const msg = await api.upgradeOpenclaw(source, version)
|
||||
modal.setDone(typeof msg === 'string' ? msg : (msg?.message || '操作完成'))
|
||||
cleanup()
|
||||
}
|
||||
const msg = await api.upgradeOpenclaw(source, version)
|
||||
modal.setDone(typeof msg === 'string' ? msg : (msg?.message || '操作完成'))
|
||||
} catch (e) {
|
||||
cleanup()
|
||||
const errStr = String(e)
|
||||
modal.appendLog(errStr)
|
||||
const { diagnoseInstallError } = await import('../lib/error-diagnosis.js')
|
||||
const fullLog = modal.getLogText() + '\n' + errStr
|
||||
const diagnosis = diagnoseInstallError(fullLog)
|
||||
modal.setError(diagnosis.title)
|
||||
if (diagnosis.hint) modal.appendLog('')
|
||||
if (diagnosis.hint) modal.appendHtmlLog(`${statusIcon('info', 14)} ${diagnosis.hint}`)
|
||||
if (diagnosis.command) modal.appendHtmlLog(`${icon('clipboard', 14)} ${diagnosis.command}`)
|
||||
if (window.__openAIDrawerWithError) {
|
||||
window.__openAIDrawerWithError({
|
||||
title: diagnosis.title,
|
||||
error: fullLog,
|
||||
scene: title,
|
||||
hint: diagnosis.hint,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setUpgrading(false)
|
||||
unlistenLog?.()
|
||||
unlistenProgress?.()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2734,6 +2734,11 @@ function showSettings() {
|
||||
baseUrlInput.value = btn.dataset.url
|
||||
apiTypeSelect.value = btn.dataset.api
|
||||
apiTypeSelect.dispatchEvent(new Event('change'))
|
||||
// 切换服务商时清空模型和下拉列表,让用户重新选择或拉取
|
||||
const modelInput = overlay.querySelector('#ast-model')
|
||||
const modelDropdown = overlay.querySelector('#ast-model-dropdown')
|
||||
if (modelInput) modelInput.value = ''
|
||||
if (modelDropdown) { modelDropdown.innerHTML = ''; modelDropdown.style.display = 'none' }
|
||||
// 高亮选中
|
||||
overlay.querySelectorAll('.ast-preset-btn').forEach(b => b.style.opacity = '0.5')
|
||||
btn.style.opacity = '1'
|
||||
|
||||
@@ -158,8 +158,11 @@ function renderDebugInfo(el, info) {
|
||||
} else if (info.version) {
|
||||
html += `<table class="debug-table">
|
||||
<tr><td>当前版本</td><td>${info.version.current || '(未知)'}</td></tr>
|
||||
<tr><td>最新版本</td><td>${info.version.latest || '(未检测)'}</td></tr>
|
||||
<tr><td>更新可用</td><td>${info.version.update_available ? `${statusIcon('warn')} 有新版本` : `${statusIcon('ok')} 已是最新`}</td></tr>
|
||||
<tr><td>推荐稳定版</td><td>${info.version.recommended || '(未检测)'}</td></tr>
|
||||
<tr><td>面板版本</td><td>${info.version.panel_version || '(未知)'}</td></tr>
|
||||
<tr><td>最新上游</td><td>${info.version.latest || '(未检测)'}</td></tr>
|
||||
<tr><td>偏离推荐版</td><td>${info.version.ahead_of_recommended ? `${statusIcon('warn')} 当前版本过高,建议回退` : info.version.is_recommended ? `${statusIcon('ok')} 已对齐` : `${statusIcon('warn')} 需要切换`}</td></tr>
|
||||
<tr><td>最新上游可用</td><td>${info.version.latest_update_available ? `${statusIcon('warn')} 有更新` : `${statusIcon('ok')} 无更新`}</td></tr>
|
||||
</table>`
|
||||
}
|
||||
html += `</div>`
|
||||
|
||||
@@ -117,6 +117,9 @@ function renderStatCards(page, services, version, agents, config) {
|
||||
const cardsEl = page.querySelector('#stat-cards')
|
||||
const gw = services.find(s => s.label === 'ai.openclaw.gateway')
|
||||
const runningCount = services.filter(s => s.running).length
|
||||
const versionMeta = version.recommended
|
||||
? `${version.ahead_of_recommended ? `当前版本高于推荐稳定版 ${version.recommended},可能不稳定` : version.is_recommended ? '稳定版 ' + version.recommended : '推荐稳定版 ' + version.recommended}${version.latest_update_available && version.latest ? ' · 最新上游 ' + version.latest : ''}`
|
||||
: (version.latest_update_available && version.latest ? '最新上游: ' + version.latest : '版本信息未获取')
|
||||
|
||||
const defaultAgent = agents.find(a => a.id === 'main')?.name || 'main'
|
||||
const modelCount = config?.models?.providers ? Object.values(config.models.providers).reduce((acc, p) => acc + (p.models?.length || 0), 0) : 0
|
||||
@@ -136,7 +139,7 @@ function renderStatCards(page, services, version, agents, config) {
|
||||
<span class="stat-card-label">版本 · ${version.source === 'official' ? '官方' : '汉化'}</span>
|
||||
</div>
|
||||
<div class="stat-card-value">${version.current || '未知'}</div>
|
||||
<div class="stat-card-meta">${version.update_available ? '有新版本: ' + version.latest : '已是最新'}</div>
|
||||
<div class="stat-card-meta">${versionMeta}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header">
|
||||
@@ -439,10 +442,14 @@ function bindActions(page) {
|
||||
btnUpdate.textContent = '检查中...'
|
||||
try {
|
||||
const info = await api.getVersionInfo()
|
||||
if (info.update_available) {
|
||||
toast(`发现新版本: ${info.latest}`, 'info')
|
||||
if (info.ahead_of_recommended && info.recommended) {
|
||||
toast(`当前本地版本 ${info.current || ''} 高于推荐稳定版 ${info.recommended},可能存在兼容风险`, 'warning')
|
||||
} else if (info.update_available && info.recommended) {
|
||||
toast(`发现推荐稳定版: ${info.recommended}`, 'info')
|
||||
} else if (info.latest_update_available && info.latest) {
|
||||
toast(`已对齐推荐稳定版,最新上游为 ${info.latest}`, 'info')
|
||||
} else {
|
||||
toast('已是最新版本', 'success')
|
||||
toast('已对齐推荐稳定版', 'success')
|
||||
}
|
||||
} catch (e) {
|
||||
toast('检查更新失败: ' + e, 'error')
|
||||
|
||||
@@ -30,10 +30,6 @@ export async function render() {
|
||||
</div>
|
||||
<div id="version-bar"><div class="stat-card loading-placeholder" style="height:80px;margin-bottom:var(--space-lg)"></div></div>
|
||||
<div id="services-list"><div class="stat-card loading-placeholder" style="height:64px"></div></div>
|
||||
<div class="config-section" id="registry-section">
|
||||
<div class="config-section-title">npm 源设置</div>
|
||||
<div id="registry-bar"></div>
|
||||
</div>
|
||||
<div class="config-section" id="config-editor-section" style="display:none">
|
||||
<div class="config-section-title">配置文件编辑</div>
|
||||
<div class="form-hint" style="margin-bottom:var(--space-sm)">直接编辑 <code>openclaw.json</code> 主配置文件。保存前会自动创建备份,修改后可能需要重启 Gateway 生效。</div>
|
||||
@@ -55,12 +51,6 @@ export async function render() {
|
||||
</div>
|
||||
`
|
||||
|
||||
// Docker 模式下隐藏 npm 源设置
|
||||
if (isInDocker()) {
|
||||
const regSection = page.querySelector('#registry-section')
|
||||
if (regSection) regSection.style.display = 'none'
|
||||
}
|
||||
|
||||
bindEvents(page)
|
||||
loadAll(page)
|
||||
return page
|
||||
@@ -68,7 +58,6 @@ export async function render() {
|
||||
|
||||
async function loadAll(page) {
|
||||
const tasks = [loadVersion(page), loadServices(page), loadBackups(page), loadConfigEditor(page)]
|
||||
if (!isInDocker()) tasks.push(loadRegistry(page))
|
||||
await Promise.all(tasks)
|
||||
}
|
||||
|
||||
@@ -76,18 +65,25 @@ async function loadAll(page) {
|
||||
|
||||
// 后端检测到的当前安装源
|
||||
let detectedSource = 'chinese'
|
||||
let lastVersionInfo = null
|
||||
|
||||
async function loadVersion(page) {
|
||||
const bar = page.querySelector('#version-bar')
|
||||
try {
|
||||
const info = await api.getVersionInfo()
|
||||
lastVersionInfo = info
|
||||
detectedSource = info.source || 'chinese'
|
||||
const ver = info.current || '未知'
|
||||
const hasUpdate = info.update_available
|
||||
const hasRecommended = !!info.recommended
|
||||
const aheadOfRecommended = !!info.current && hasRecommended && !!info.ahead_of_recommended
|
||||
const driftFromRecommended = !!info.current && hasRecommended && !info.is_recommended && !aheadOfRecommended
|
||||
const isChinese = detectedSource === 'chinese'
|
||||
const sourceTag = isChinese ? '汉化优化版' : '官方原版'
|
||||
const switchLabel = isChinese ? '切换到官方版' : '切换到汉化版'
|
||||
const switchTarget = isChinese ? 'official' : 'chinese'
|
||||
const policyNote = aheadOfRecommended
|
||||
? `检测到当前本地版本 ${ver} 高于面板推荐稳定版 ${info.recommended},继续使用可能存在兼容或稳定性风险,建议尽快回退到推荐版。`
|
||||
: '默认只建议当前面板已验证的推荐稳定版。如需尝试其它版本或最新特性,请到「关于」页手动切换版本并自行验证兼容性;若希望面板优先适配最新版,欢迎提交 issue。'
|
||||
|
||||
if (isInDocker()) {
|
||||
bar.innerHTML = `
|
||||
@@ -97,8 +93,8 @@ async function loadVersion(page) {
|
||||
<span class="stat-card-label">当前版本 · <span style="color:var(--accent)">Docker 部署</span></span>
|
||||
</div>
|
||||
<div class="stat-card-value">${ver}</div>
|
||||
<div class="stat-card-meta">${hasUpdate ? '新版本: ' + info.latest + '(请拉取新镜像更新)' : '已是最新版本'}</div>
|
||||
${hasUpdate ? `<div style="margin-top:var(--space-sm)">
|
||||
<div class="stat-card-meta">${info.latest_update_available ? '最新上游: ' + info.latest + '(请拉取新镜像更新)' : '已是当前镜像版本'}</div>
|
||||
${info.latest_update_available ? `<div style="margin-top:var(--space-sm)">
|
||||
<code style="font-size:var(--font-size-xs);background:var(--bg-tertiary);padding:4px 8px;border-radius:4px;user-select:all">docker pull ghcr.io/qingchencloud/openclaw:latest</code>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
@@ -112,11 +108,19 @@ async function loadVersion(page) {
|
||||
<span class="stat-card-label">当前版本 · <span style="color:var(--accent)">${sourceTag}</span></span>
|
||||
</div>
|
||||
<div class="stat-card-value">${ver}</div>
|
||||
<div class="stat-card-meta">${hasUpdate ? '新版本: ' + info.latest : '已是最新版本'}</div>
|
||||
<div class="stat-card-meta">
|
||||
${hasRecommended
|
||||
? (aheadOfRecommended ? `当前版本高于推荐稳定版: ${info.recommended}` : driftFromRecommended ? `推荐稳定版: ${info.recommended}` : `已对齐推荐稳定版: ${info.recommended}`)
|
||||
: '未获取到推荐稳定版'}
|
||||
${info.latest_update_available && info.latest ? ` · 最新上游: ${info.latest}` : ''}
|
||||
</div>
|
||||
<div style="display:flex;gap:var(--space-sm);margin-top:var(--space-sm);flex-wrap:wrap">
|
||||
${hasUpdate ? '<button class="btn btn-primary btn-sm" data-action="upgrade">升级到最新版</button>' : ''}
|
||||
${aheadOfRecommended ? '<button class="btn btn-primary btn-sm" data-action="upgrade">回退到推荐版</button>' : driftFromRecommended ? '<button class="btn btn-primary btn-sm" data-action="upgrade">切换到推荐版</button>' : ''}
|
||||
<button class="btn btn-secondary btn-sm" data-action="switch-source" data-source="${switchTarget}">${switchLabel}</button>
|
||||
</div>
|
||||
<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary);line-height:1.6">
|
||||
${policyNote}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
@@ -126,41 +130,6 @@ async function loadVersion(page) {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== npm 源设置 =====
|
||||
|
||||
const REGISTRIES = [
|
||||
{ label: '淘宝镜像 (推荐)', value: 'https://registry.npmmirror.com' },
|
||||
{ label: 'npm 官方源', value: 'https://registry.npmjs.org' },
|
||||
{ label: '华为云镜像', value: 'https://repo.huaweicloud.com/repository/npm/' },
|
||||
]
|
||||
|
||||
async function loadRegistry(page) {
|
||||
const bar = page.querySelector('#registry-bar')
|
||||
try {
|
||||
const current = await api.getNpmRegistry()
|
||||
const isPreset = REGISTRIES.some(r => r.value === current)
|
||||
bar.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:var(--space-sm);flex-wrap:wrap">
|
||||
<select class="form-input" data-name="registry" style="max-width:320px">
|
||||
${REGISTRIES.map(r => `<option value="${r.value}" ${r.value === current ? 'selected' : ''}>${r.label}</option>`).join('')}
|
||||
<option value="custom" ${!isPreset ? 'selected' : ''}>自定义</option>
|
||||
</select>
|
||||
<input class="form-input" data-name="custom-registry" placeholder="https://..." value="${isPreset ? '' : escapeHtml(current)}" style="max-width:320px;${isPreset ? 'display:none' : ''}">
|
||||
<button class="btn btn-primary btn-sm" data-action="save-registry">保存</button>
|
||||
</div>
|
||||
<div class="form-hint" style="margin-top:var(--space-xs)">升级和版本检测使用此源下载 npm 包,国内用户推荐淘宝镜像</div>
|
||||
`
|
||||
// 切换预设/自定义
|
||||
const select = bar.querySelector('[data-name="registry"]')
|
||||
const customInput = bar.querySelector('[data-name="custom-registry"]')
|
||||
select.onchange = () => {
|
||||
customInput.style.display = select.value === 'custom' ? '' : 'none'
|
||||
}
|
||||
} catch (e) {
|
||||
bar.innerHTML = `<div style="color:var(--error)">加载失败: ${escapeHtml(String(e))}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 服务列表 =====
|
||||
|
||||
async function loadServices(page) {
|
||||
@@ -313,9 +282,6 @@ function bindEvents(page) {
|
||||
case 'refresh-services':
|
||||
await loadServices(page)
|
||||
break
|
||||
case 'save-registry':
|
||||
await handleSaveRegistry(btn, page)
|
||||
break
|
||||
}
|
||||
} catch (e) {
|
||||
toast(e.toString(), 'error')
|
||||
@@ -539,60 +505,86 @@ async function handleSaveConfig(page, restart) {
|
||||
|
||||
// ===== 升级操作 =====
|
||||
|
||||
async function doUpgradeWithModal(source, page) {
|
||||
const modal = showUpgradeModal()
|
||||
let unlistenLog, unlistenProgress
|
||||
async function doUpgradeWithModal(source, page, version = null) {
|
||||
const modal = showUpgradeModal('升级 / 切换版本')
|
||||
let unlistenLog, unlistenProgress, unlistenDone, unlistenError
|
||||
setUpgrading(true)
|
||||
|
||||
// 清理所有监听
|
||||
const cleanup = () => {
|
||||
setUpgrading(false)
|
||||
unlistenLog?.()
|
||||
unlistenProgress?.()
|
||||
unlistenDone?.()
|
||||
unlistenError?.()
|
||||
}
|
||||
|
||||
try {
|
||||
// Tauri 环境下监听实时日志;Web 模式跳过
|
||||
if (window.__TAURI_INTERNALS__) {
|
||||
try {
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
|
||||
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
|
||||
} catch { /* Web 模式无 Tauri event */ }
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
|
||||
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
|
||||
|
||||
// 后台任务完成事件
|
||||
unlistenDone = await listen('upgrade-done', (e) => {
|
||||
cleanup()
|
||||
modal.setDone(typeof e.payload === 'string' ? e.payload : '操作完成')
|
||||
loadVersion(page)
|
||||
})
|
||||
|
||||
// 后台任务失败事件
|
||||
unlistenError = await listen('upgrade-error', (e) => {
|
||||
cleanup()
|
||||
const errStr = String(e.payload || '未知错误')
|
||||
modal.appendLog(errStr)
|
||||
const fullLog = modal.getLogText() + '\n' + errStr
|
||||
const diagnosis = diagnoseInstallError(fullLog)
|
||||
modal.setError(diagnosis.title)
|
||||
if (diagnosis.hint) modal.appendLog('')
|
||||
if (diagnosis.hint) modal.appendHtmlLog(`${statusIcon('info', 14)} ${diagnosis.hint}`)
|
||||
if (diagnosis.command) modal.appendHtmlLog(`${icon('clipboard', 14)} ${diagnosis.command}`)
|
||||
if (window.__openAIDrawerWithError) {
|
||||
window.__openAIDrawerWithError({ title: diagnosis.title, error: fullLog, scene: '升级 OpenClaw', hint: diagnosis.hint })
|
||||
}
|
||||
})
|
||||
|
||||
// 发起后台任务(立即返回)
|
||||
await api.upgradeOpenclaw(source, version)
|
||||
modal.appendLog('后台任务已启动,请等待完成...')
|
||||
} else {
|
||||
// Web 模式:仍然同步等待(dev-api 后端没有 spawn)
|
||||
modal.appendLog('Web 模式:升级过程日志不可用,请等待完成...')
|
||||
const msg = await api.upgradeOpenclaw(source, version)
|
||||
modal.setDone(typeof msg === 'string' ? msg : (msg?.message || '升级完成'))
|
||||
await loadVersion(page)
|
||||
cleanup()
|
||||
}
|
||||
const msg = await api.upgradeOpenclaw(source)
|
||||
modal.setDone(typeof msg === 'string' ? msg : (msg?.message || '升级完成'))
|
||||
await loadVersion(page)
|
||||
} catch (e) {
|
||||
cleanup()
|
||||
const errStr = String(e)
|
||||
modal.appendLog(errStr)
|
||||
const fullLog = modal.getLogText() + '\n' + errStr
|
||||
const diagnosis = diagnoseInstallError(fullLog)
|
||||
modal.setError(diagnosis.title)
|
||||
if (diagnosis.hint) modal.appendLog('')
|
||||
if (diagnosis.hint) modal.appendHtmlLog(`${statusIcon('info', 14)} ${diagnosis.hint}`)
|
||||
if (diagnosis.command) modal.appendHtmlLog(`${icon('clipboard', 14)} ${diagnosis.command}`)
|
||||
if (window.__openAIDrawerWithError) {
|
||||
window.__openAIDrawerWithError({
|
||||
title: diagnosis.title,
|
||||
error: fullLog,
|
||||
scene: '升级 OpenClaw',
|
||||
hint: diagnosis.hint,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setUpgrading(false)
|
||||
unlistenLog?.()
|
||||
unlistenProgress?.()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpgrade(btn, page) {
|
||||
const sourceLabel = detectedSource === 'official' ? '官方原版' : '汉化优化版'
|
||||
const yes = await showConfirm(`确定要升级 OpenClaw 到最新${sourceLabel}吗?\n升级过程中 Gateway 会短暂中断。`)
|
||||
const recommended = lastVersionInfo?.recommended
|
||||
const yes = await showConfirm(`确定要将 OpenClaw 切换到当前面板推荐的稳定${sourceLabel}${recommended ? `(${recommended})` : ''}吗?\n切换过程中 Gateway 会短暂中断。\n如果你想尝试最新版,请到「关于」页手动切换版本并自测兼容性。`)
|
||||
if (!yes) return
|
||||
await doUpgradeWithModal(detectedSource, page)
|
||||
await doUpgradeWithModal(detectedSource, page, recommended || null)
|
||||
}
|
||||
|
||||
async function handleSwitchSource(target, page) {
|
||||
const targetLabel = target === 'official' ? '官方原版' : '汉化优化版'
|
||||
const yes = await showConfirm(`确定要切换到${targetLabel}吗?\n这会安装对应的 npm 包,配置数据不受影响。`)
|
||||
const recommended = target === 'official'
|
||||
? (lastVersionInfo?.source === 'official' ? lastVersionInfo?.recommended : null)
|
||||
: (lastVersionInfo?.source === 'chinese' ? lastVersionInfo?.recommended : null)
|
||||
const yes = await showConfirm(`确定要切换到${targetLabel}${recommended ? `(推荐稳定版 ${recommended})` : '(将自动选择该来源的推荐稳定版)'}吗?\n这会安装对应的 npm 包,配置数据不受影响。\n如需尝试最新版,请到「关于」页手动切换版本。`)
|
||||
if (!yes) return
|
||||
await doUpgradeWithModal(target, page)
|
||||
await doUpgradeWithModal(target, page, null)
|
||||
}
|
||||
|
||||
// ===== Gateway 安装/卸载 =====
|
||||
@@ -626,13 +618,3 @@ async function handleUninstallGateway(btn, page) {
|
||||
btn.textContent = '卸载'
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveRegistry(btn, page) {
|
||||
const section = page.querySelector('#registry-section')
|
||||
const select = section.querySelector('[data-name="registry"]')
|
||||
const customInput = section.querySelector('[data-name="custom-registry"]')
|
||||
const registry = select.value === 'custom' ? customInput.value.trim() : select.value
|
||||
if (!registry) { toast('请输入源地址', 'error'); return }
|
||||
await api.setNpmRegistry(registry)
|
||||
toast('npm 源已保存', 'success')
|
||||
}
|
||||
|
||||
246
src/pages/settings.js
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* 面板设置页面
|
||||
* 统一管理 ClawPanel 的网络代理、npm 源、模型代理等配置
|
||||
*/
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
|
||||
const isTauri = !!window.__TAURI_INTERNALS__
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return ''
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
const REGISTRIES = [
|
||||
{ label: '淘宝镜像 (推荐)', value: 'https://registry.npmmirror.com' },
|
||||
{ label: 'npm 官方源', value: 'https://registry.npmjs.org' },
|
||||
{ label: '华为云镜像', value: 'https://repo.huaweicloud.com/repository/npm/' },
|
||||
]
|
||||
|
||||
export async function render() {
|
||||
const page = document.createElement('div')
|
||||
page.className = 'page'
|
||||
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">面板设置</h1>
|
||||
<p class="page-desc">管理 ClawPanel 的网络、代理和下载源配置</p>
|
||||
</div>
|
||||
|
||||
<div class="config-section" id="proxy-section">
|
||||
<div class="config-section-title">网络代理</div>
|
||||
<div id="proxy-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="config-section" id="model-proxy-section">
|
||||
<div class="config-section-title">模型请求代理</div>
|
||||
<div id="model-proxy-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="config-section" id="registry-section">
|
||||
<div class="config-section-title">npm 源设置</div>
|
||||
<div id="registry-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
|
||||
</div>
|
||||
`
|
||||
|
||||
bindEvents(page)
|
||||
loadAll(page)
|
||||
return page
|
||||
}
|
||||
|
||||
async function loadAll(page) {
|
||||
const tasks = [loadProxyConfig(page), loadModelProxyConfig(page)]
|
||||
tasks.push(loadRegistry(page))
|
||||
await Promise.all(tasks)
|
||||
}
|
||||
|
||||
// ===== 网络代理 =====
|
||||
|
||||
async function loadProxyConfig(page) {
|
||||
const bar = page.querySelector('#proxy-bar')
|
||||
if (!bar) return
|
||||
try {
|
||||
const cfg = await api.readPanelConfig()
|
||||
const proxyUrl = cfg?.networkProxy?.url || ''
|
||||
bar.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:var(--space-sm);flex-wrap:wrap">
|
||||
<input class="form-input" data-name="proxy-url" placeholder="http://127.0.0.1:7897" value="${escapeHtml(proxyUrl)}" style="max-width:360px">
|
||||
<button class="btn btn-primary btn-sm" data-action="save-proxy">保存</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="test-proxy" ${proxyUrl ? '' : 'disabled'}>测试连通</button>
|
||||
<button class="btn btn-secondary btn-sm" data-action="clear-proxy" ${proxyUrl ? '' : 'disabled'}>关闭代理</button>
|
||||
</div>
|
||||
<div id="proxy-test-result" style="margin-top:var(--space-xs);font-size:var(--font-size-xs);min-height:20px"></div>
|
||||
<div class="form-hint" style="margin-top:var(--space-xs)">
|
||||
设置后,npm 安装/升级、版本检测、GitHub/Gitee 更新检查、ClawHub Skills 等下载类操作会走此代理。自动绕过 localhost 和内网地址。保存后新请求立即生效;如 Gateway 正在运行,建议重启一次服务。
|
||||
</div>
|
||||
`
|
||||
} catch (e) {
|
||||
bar.innerHTML = `<div style="color:var(--error)">加载失败: ${escapeHtml(String(e))}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 模型请求代理 =====
|
||||
|
||||
async function loadModelProxyConfig(page) {
|
||||
const bar = page.querySelector('#model-proxy-bar')
|
||||
if (!bar) return
|
||||
try {
|
||||
const cfg = await api.readPanelConfig()
|
||||
const proxyUrl = cfg?.networkProxy?.url || ''
|
||||
const modelProxy = !!cfg?.networkProxy?.proxyModelRequests
|
||||
const hasProxy = !!proxyUrl
|
||||
|
||||
bar.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:var(--space-sm);flex-wrap:wrap">
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:var(--font-size-sm);cursor:pointer">
|
||||
<input type="checkbox" data-name="model-proxy-toggle" ${modelProxy ? 'checked' : ''} ${hasProxy ? '' : 'disabled'}>
|
||||
模型测试和模型列表请求也走代理
|
||||
</label>
|
||||
<button class="btn btn-primary btn-sm" data-action="save-model-proxy">保存</button>
|
||||
</div>
|
||||
<div class="form-hint" style="margin-top:var(--space-xs)">
|
||||
${hasProxy
|
||||
? '默认关闭。部分用户的模型 API 地址本身就是国内中转或内网地址,走代理反而会连接失败。只有当你的模型服务商需要翻墙访问时才建议开启。'
|
||||
: '请先在上方设置网络代理地址后,才能启用此选项。'
|
||||
}
|
||||
</div>
|
||||
`
|
||||
} catch (e) {
|
||||
bar.innerHTML = `<div style="color:var(--error)">加载失败: ${escapeHtml(String(e))}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
// ===== npm 源设置 =====
|
||||
|
||||
async function loadRegistry(page) {
|
||||
const bar = page.querySelector('#registry-bar')
|
||||
try {
|
||||
const current = await api.getNpmRegistry()
|
||||
const isPreset = REGISTRIES.some(r => r.value === current)
|
||||
bar.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:var(--space-sm);flex-wrap:wrap">
|
||||
<select class="form-input" data-name="registry" style="max-width:320px">
|
||||
${REGISTRIES.map(r => `<option value="${r.value}" ${r.value === current ? 'selected' : ''}>${r.label}</option>`).join('')}
|
||||
<option value="custom" ${!isPreset ? 'selected' : ''}>自定义</option>
|
||||
</select>
|
||||
<input class="form-input" data-name="custom-registry" placeholder="https://..." value="${isPreset ? '' : escapeHtml(current)}" style="max-width:320px;${isPreset ? 'display:none' : ''}">
|
||||
<button class="btn btn-primary btn-sm" data-action="save-registry">保存</button>
|
||||
</div>
|
||||
<div class="form-hint" style="margin-top:var(--space-xs)">升级和版本检测使用此源下载 npm 包,国内用户推荐淘宝镜像</div>
|
||||
`
|
||||
const select = bar.querySelector('[data-name="registry"]')
|
||||
const customInput = bar.querySelector('[data-name="custom-registry"]')
|
||||
select.onchange = () => {
|
||||
customInput.style.display = select.value === 'custom' ? '' : 'none'
|
||||
}
|
||||
} catch (e) {
|
||||
bar.innerHTML = `<div style="color:var(--error)">加载失败: ${escapeHtml(String(e))}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 事件绑定 =====
|
||||
|
||||
function bindEvents(page) {
|
||||
page.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('[data-action]')
|
||||
if (!btn) return
|
||||
const action = btn.dataset.action
|
||||
btn.disabled = true
|
||||
try {
|
||||
switch (action) {
|
||||
case 'save-proxy':
|
||||
await handleSaveProxy(page)
|
||||
break
|
||||
case 'test-proxy':
|
||||
await handleTestProxy(page)
|
||||
break
|
||||
case 'clear-proxy':
|
||||
await handleClearProxy(page)
|
||||
break
|
||||
case 'save-model-proxy':
|
||||
await handleSaveModelProxy(page)
|
||||
break
|
||||
case 'save-registry':
|
||||
await handleSaveRegistry(page)
|
||||
break
|
||||
}
|
||||
} catch (e) {
|
||||
toast(e.toString(), 'error')
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeProxyUrl(value) {
|
||||
const url = String(value || '').trim()
|
||||
if (!url) return ''
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
throw new Error('代理地址必须以 http:// 或 https:// 开头')
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
async function handleTestProxy(page) {
|
||||
const resultEl = page.querySelector('#proxy-test-result')
|
||||
if (resultEl) resultEl.innerHTML = '<span style="color:var(--text-tertiary)">正在测试代理连通性...</span>'
|
||||
try {
|
||||
const r = await api.testProxy()
|
||||
if (resultEl) {
|
||||
resultEl.innerHTML = r.ok
|
||||
? `<span style="color:var(--success)">✓ 代理连通(HTTP ${r.status},耗时 ${r.elapsed_ms}ms)→ ${escapeHtml(r.target)}</span>`
|
||||
: `<span style="color:var(--warning)">⚠ 代理可达但返回异常(HTTP ${r.status},${r.elapsed_ms}ms)</span>`
|
||||
}
|
||||
} catch (e) {
|
||||
if (resultEl) resultEl.innerHTML = `<span style="color:var(--error)">✗ ${escapeHtml(String(e))}</span>`
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveProxy(page) {
|
||||
const input = page.querySelector('[data-name="proxy-url"]')
|
||||
const proxyUrl = normalizeProxyUrl(input?.value || '')
|
||||
if (!proxyUrl) {
|
||||
toast('请输入代理地址,或点击"关闭代理"', 'error')
|
||||
return
|
||||
}
|
||||
const cfg = await api.readPanelConfig()
|
||||
if (!cfg.networkProxy || typeof cfg.networkProxy !== 'object') {
|
||||
cfg.networkProxy = {}
|
||||
}
|
||||
cfg.networkProxy.url = proxyUrl
|
||||
await api.writePanelConfig(cfg)
|
||||
toast('网络代理已保存;如 Gateway 正在运行,建议重启服务', 'success')
|
||||
await loadProxyConfig(page)
|
||||
await loadModelProxyConfig(page)
|
||||
}
|
||||
|
||||
async function handleClearProxy(page) {
|
||||
const cfg = await api.readPanelConfig()
|
||||
delete cfg.networkProxy
|
||||
await api.writePanelConfig(cfg)
|
||||
toast('网络代理已关闭', 'success')
|
||||
await loadProxyConfig(page)
|
||||
await loadModelProxyConfig(page)
|
||||
}
|
||||
|
||||
async function handleSaveModelProxy(page) {
|
||||
const toggle = page.querySelector('[data-name="model-proxy-toggle"]')
|
||||
const checked = toggle?.checked || false
|
||||
const cfg = await api.readPanelConfig()
|
||||
if (!cfg.networkProxy || typeof cfg.networkProxy !== 'object') {
|
||||
cfg.networkProxy = {}
|
||||
}
|
||||
cfg.networkProxy.proxyModelRequests = checked
|
||||
await api.writePanelConfig(cfg)
|
||||
toast(checked ? '模型请求将走代理' : '模型请求已关闭代理', 'success')
|
||||
}
|
||||
|
||||
async function handleSaveRegistry(page) {
|
||||
const select = page.querySelector('[data-name="registry"]')
|
||||
const customInput = page.querySelector('[data-name="custom-registry"]')
|
||||
const registry = select.value === 'custom' ? customInput.value.trim() : select.value
|
||||
if (!registry) { toast('请输入源地址', 'error'); return }
|
||||
await api.setNpmRegistry(registry)
|
||||
toast('npm 源已保存', 'success')
|
||||
}
|
||||
@@ -48,13 +48,14 @@ async function runDetect(page) {
|
||||
<div class="stat-card loading-placeholder" style="height:48px;margin-top:8px"></div>
|
||||
`
|
||||
// 清除缓存,确保拿到最新检测结果
|
||||
invalidate('check_node', 'check_git', 'get_services_status', 'check_installation')
|
||||
invalidate('get_version_info', 'check_node', 'check_git', 'get_services_status', 'check_installation')
|
||||
// 并行检测 Node.js、Git、OpenClaw CLI、配置文件
|
||||
const [nodeRes, gitRes, clawRes, configRes] = await Promise.allSettled([
|
||||
const [nodeRes, gitRes, clawRes, configRes, versionRes] = await Promise.allSettled([
|
||||
api.checkNode(),
|
||||
api.checkGit(),
|
||||
api.getServicesStatus(),
|
||||
api.checkInstallation(),
|
||||
api.getVersionInfo(),
|
||||
])
|
||||
|
||||
const node = nodeRes.status === 'fulfilled' ? nodeRes.value : { installed: false }
|
||||
@@ -63,6 +64,7 @@ async function runDetect(page) {
|
||||
&& clawRes.value?.length > 0
|
||||
&& clawRes.value[0]?.cli_installed !== false
|
||||
let config = configRes.status === 'fulfilled' ? configRes.value : { installed: false }
|
||||
const version = versionRes.status === 'fulfilled' ? versionRes.value : null
|
||||
|
||||
// CLI 已装但配置缺失 → 自动创建默认配置
|
||||
if (cliOk && !config.installed) {
|
||||
@@ -81,7 +83,7 @@ async function runDetect(page) {
|
||||
api.configureGitHttps().catch(() => {})
|
||||
}
|
||||
|
||||
renderSteps(page, { node, git, cliOk, config })
|
||||
renderSteps(page, { node, git, cliOk, config, version })
|
||||
}
|
||||
|
||||
function stepIcon(ok) {
|
||||
@@ -89,7 +91,7 @@ function stepIcon(ok) {
|
||||
return `<span style="color:${color};font-weight:700;width:18px;display:inline-block">${ok ? '✓' : '✗'}</span>`
|
||||
}
|
||||
|
||||
function renderSteps(page, { node, git, cliOk, config }) {
|
||||
function renderSteps(page, { node, git, cliOk, config, version }) {
|
||||
const stepsEl = page.querySelector('#setup-steps')
|
||||
const nodeOk = node.installed
|
||||
const gitOk = git?.installed || false
|
||||
@@ -163,7 +165,12 @@ function renderSteps(page, { node, git, cliOk, config }) {
|
||||
${stepIcon(cliOk)} OpenClaw CLI
|
||||
</div>
|
||||
${cliOk
|
||||
? `<p style="color:var(--success);font-size:var(--font-size-sm)">CLI 可用</p>`
|
||||
? `<p style="color:var(--success);font-size:var(--font-size-sm)">CLI 可用</p>
|
||||
${version?.ahead_of_recommended && version?.recommended
|
||||
? `<div style="margin-top:8px;padding:8px 12px;background:var(--bg-tertiary);border-radius:var(--radius-sm);font-size:var(--font-size-xs);color:var(--warning,#f59e0b);line-height:1.6">
|
||||
检测到当前本地 OpenClaw ${version.current || ''} 高于当前面板推荐稳定版 ${version.recommended},可能存在兼容或稳定性风险。建议稍后到「关于」页回退到推荐版。
|
||||
</div>`
|
||||
: ''}`
|
||||
: renderInstallSection()
|
||||
}
|
||||
</div>
|
||||
@@ -297,7 +304,10 @@ function renderInstallSection() {
|
||||
|
||||
return `
|
||||
<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm)">
|
||||
选择版本后点击安装,将自动执行 npm 全局安装。
|
||||
点击安装后,将默认安装当前 ClawPanel 版本绑定的推荐稳定版;如需升降级,可稍后到「关于」页面切换版本。
|
||||
</p>
|
||||
<p style="color:var(--text-tertiary);font-size:var(--font-size-xs);line-height:1.6;margin:-4px 0 var(--space-sm)">
|
||||
如果你是为了体验最新版功能,建议先安装推荐稳定版再手动切换;若希望面板优先适配最新版,欢迎提交 issue。
|
||||
</p>
|
||||
<div style="display:flex;gap:var(--space-sm);margin-bottom:var(--space-sm)">
|
||||
<label class="setup-source-option" style="flex:1;cursor:pointer">
|
||||
@@ -499,90 +509,116 @@ function bindEvents(page, nodeOk, detectState) {
|
||||
installBtn.addEventListener('click', async () => {
|
||||
const source = page.querySelector('input[name="install-source"]:checked')?.value || 'chinese'
|
||||
const registry = page.querySelector('#registry-select')?.value
|
||||
const modal = showUpgradeModal()
|
||||
const modal = showUpgradeModal('安装 OpenClaw')
|
||||
let unlistenLog, unlistenProgress
|
||||
|
||||
setUpgrading(true)
|
||||
try {
|
||||
if (window.__TAURI_INTERNALS__) {
|
||||
try {
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
|
||||
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
|
||||
} catch { /* Web 模式无 Tauri event */ }
|
||||
} else {
|
||||
modal.appendLog('Web 模式:安装日志不可用,请等待完成...')
|
||||
}
|
||||
|
||||
// 先设置镜像源
|
||||
if (registry) {
|
||||
modal.appendLog(`设置 npm 镜像源: ${registry}`)
|
||||
try { await api.setNpmRegistry(registry) } catch {}
|
||||
}
|
||||
|
||||
const msg = await api.upgradeOpenclaw(source)
|
||||
modal.setDone(msg)
|
||||
|
||||
// 安装成功后自动安装 Gateway
|
||||
modal.appendLog('正在安装 Gateway 服务...')
|
||||
try {
|
||||
await api.installGateway()
|
||||
modal.appendHtmlLog(`${statusIcon('ok', 14)} Gateway 服务已安装`)
|
||||
} catch (e) {
|
||||
modal.appendHtmlLog(`${statusIcon('warn', 14)} Gateway 安装失败: ${e}`)
|
||||
}
|
||||
|
||||
// 确保 openclaw.json 有关键默认值,否则 Gateway 启动不了或功能受限
|
||||
try {
|
||||
const config = await api.readOpenclawConfig()
|
||||
if (config) {
|
||||
let patched = false
|
||||
if (!config.gateway) config.gateway = {}
|
||||
if (!config.gateway.mode) {
|
||||
config.gateway.mode = 'local'
|
||||
patched = true
|
||||
modal.appendHtmlLog(`${statusIcon('ok', 14)} 已设置 Gateway 运行模式为 local`)
|
||||
}
|
||||
if (!config.tools || config.tools.profile !== 'full') {
|
||||
config.tools = { profile: 'full', sessions: { visibility: 'all' }, ...(config.tools || {}) }
|
||||
config.tools.profile = 'full'
|
||||
if (!config.tools.sessions) config.tools.sessions = {}
|
||||
config.tools.sessions.visibility = 'all'
|
||||
patched = true
|
||||
modal.appendHtmlLog(`${statusIcon('ok', 14)} 已开启 Agent 工具全部权限`)
|
||||
}
|
||||
if (patched) await api.writeOpenclawConfig(config)
|
||||
}
|
||||
} catch (e) {
|
||||
modal.appendHtmlLog(`${statusIcon('warn', 14)} 自动配置失败: ${e}`)
|
||||
}
|
||||
|
||||
toast('OpenClaw 安装成功', 'success')
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
} catch (e) {
|
||||
const errStr = String(e)
|
||||
modal.appendLog(errStr)
|
||||
// 等待 Tauri 事件队列中残留的 npm 日志行被 JS 处理完毕,
|
||||
// 确保 getLogText() 包含完整输出(含 exit code / ENOENT 等关键行)
|
||||
await new Promise(r => setTimeout(r, 150))
|
||||
const fullLog = modal.getLogText() + '\n' + errStr
|
||||
const diagnosis = diagnoseInstallError(fullLog)
|
||||
modal.setError(diagnosis.title)
|
||||
if (diagnosis.hint) modal.appendLog('')
|
||||
if (diagnosis.hint) modal.appendHtmlLog(`${statusIcon('info', 14)} ${diagnosis.hint}`)
|
||||
if (diagnosis.command) modal.appendHtmlLog(`${icon('clipboard', 14)} ${diagnosis.command}`)
|
||||
if (window.__openAIDrawerWithError) {
|
||||
window.__openAIDrawerWithError({
|
||||
title: diagnosis.title,
|
||||
error: fullLog,
|
||||
scene: '初始安装 OpenClaw',
|
||||
hint: diagnosis.hint,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
const cleanup = () => {
|
||||
setUpgrading(false)
|
||||
unlistenLog?.()
|
||||
unlistenProgress?.()
|
||||
unlistenDone?.()
|
||||
unlistenError?.()
|
||||
}
|
||||
|
||||
let unlistenDone, unlistenError
|
||||
|
||||
try {
|
||||
if (window.__TAURI_INTERNALS__) {
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
|
||||
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
|
||||
|
||||
// 后台任务完成:继续安装 Gateway + 自动配置
|
||||
unlistenDone = await listen('upgrade-done', async (e) => {
|
||||
cleanup()
|
||||
modal.setDone(typeof e.payload === 'string' ? e.payload : '安装完成')
|
||||
|
||||
// 安装成功后自动安装 Gateway
|
||||
modal.appendLog('正在安装 Gateway 服务...')
|
||||
try {
|
||||
await api.installGateway()
|
||||
modal.appendHtmlLog(`${statusIcon('ok', 14)} Gateway 服务已安装`)
|
||||
} catch (ge) {
|
||||
modal.appendHtmlLog(`${statusIcon('warn', 14)} Gateway 安装失败: ${ge}`)
|
||||
}
|
||||
|
||||
// 确保 openclaw.json 有关键默认值
|
||||
try {
|
||||
const config = await api.readOpenclawConfig()
|
||||
if (config) {
|
||||
let patched = false
|
||||
if (!config.gateway) config.gateway = {}
|
||||
if (!config.gateway.mode) {
|
||||
config.gateway.mode = 'local'
|
||||
patched = true
|
||||
modal.appendHtmlLog(`${statusIcon('ok', 14)} 已设置 Gateway 运行模式为 local`)
|
||||
}
|
||||
if (!config.tools || config.tools.profile !== 'full') {
|
||||
config.tools = { profile: 'full', sessions: { visibility: 'all' }, ...(config.tools || {}) }
|
||||
config.tools.profile = 'full'
|
||||
if (!config.tools.sessions) config.tools.sessions = {}
|
||||
config.tools.sessions.visibility = 'all'
|
||||
patched = true
|
||||
modal.appendHtmlLog(`${statusIcon('ok', 14)} 已开启 Agent 工具全部权限`)
|
||||
}
|
||||
if (patched) await api.writeOpenclawConfig(config)
|
||||
}
|
||||
} catch (ce) {
|
||||
modal.appendHtmlLog(`${statusIcon('warn', 14)} 自动配置失败: ${ce}`)
|
||||
}
|
||||
|
||||
toast('OpenClaw 安装成功', 'success')
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
})
|
||||
|
||||
// 后台任务失败
|
||||
unlistenError = await listen('upgrade-error', async (e) => {
|
||||
cleanup()
|
||||
const errStr = String(e.payload || '未知错误')
|
||||
modal.appendLog(errStr)
|
||||
await new Promise(r => setTimeout(r, 150))
|
||||
const fullLog = modal.getLogText() + '\n' + errStr
|
||||
const diagnosis = diagnoseInstallError(fullLog)
|
||||
modal.setError(diagnosis.title)
|
||||
if (diagnosis.hint) modal.appendLog('')
|
||||
if (diagnosis.hint) modal.appendHtmlLog(`${statusIcon('info', 14)} ${diagnosis.hint}`)
|
||||
if (diagnosis.command) modal.appendHtmlLog(`${icon('clipboard', 14)} ${diagnosis.command}`)
|
||||
if (window.__openAIDrawerWithError) {
|
||||
window.__openAIDrawerWithError({ title: diagnosis.title, error: fullLog, scene: '初始安装 OpenClaw', hint: diagnosis.hint })
|
||||
}
|
||||
})
|
||||
|
||||
// 先设置镜像源
|
||||
if (registry) {
|
||||
modal.appendLog(`设置 npm 镜像源: ${registry}`)
|
||||
try { await api.setNpmRegistry(registry) } catch {}
|
||||
}
|
||||
|
||||
// 发起后台任务(立即返回)
|
||||
await api.upgradeOpenclaw(source)
|
||||
modal.appendLog('后台安装任务已启动,请等待完成...')
|
||||
} else {
|
||||
// Web 模式:同步等待
|
||||
modal.appendLog('Web 模式:安装日志不可用,请等待完成...')
|
||||
if (registry) {
|
||||
modal.appendLog(`设置 npm 镜像源: ${registry}`)
|
||||
try { await api.setNpmRegistry(registry) } catch {}
|
||||
}
|
||||
const msg = await api.upgradeOpenclaw(source)
|
||||
modal.setDone(msg)
|
||||
toast('OpenClaw 安装成功', 'success')
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
cleanup()
|
||||
}
|
||||
} catch (e) {
|
||||
cleanup()
|
||||
const errStr = String(e)
|
||||
modal.appendLog(errStr)
|
||||
const fullLog = modal.getLogText() + '\n' + errStr
|
||||
const diagnosis = diagnoseInstallError(fullLog)
|
||||
modal.setError(diagnosis.title)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -154,3 +154,25 @@
|
||||
.ast-error-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* FAB 首次拖动提示 */
|
||||
.ai-fab-hint {
|
||||
position: absolute;
|
||||
top: -32px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
animation: fab-hint-fade 4s ease forwards;
|
||||
}
|
||||
@keyframes fab-hint-fade {
|
||||
0% { opacity: 0; transform: translateX(-50%) translateY(4px); }
|
||||
15% { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
75% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
@@ -725,6 +725,51 @@
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* 全局任务状态栏:关闭弹窗后显示在顶部 */
|
||||
.upgrade-task-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 20px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
animation: task-bar-slide-in 0.3s ease;
|
||||
}
|
||||
.upgrade-task-bar-text {
|
||||
flex: 1;
|
||||
}
|
||||
.upgrade-task-bar .btn {
|
||||
color: #fff;
|
||||
border-color: rgba(255,255,255,0.3);
|
||||
background: rgba(255,255,255,0.15);
|
||||
font-size: 12px;
|
||||
padding: 2px 10px;
|
||||
}
|
||||
.upgrade-task-bar .btn:hover {
|
||||
background: rgba(255,255,255,0.25);
|
||||
}
|
||||
.upgrade-task-bar .btn-ghost {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
padding: 0 4px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.upgrade-task-bar .btn-ghost:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
@keyframes task-bar-slide-in {
|
||||
from { transform: translateY(-100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Gateway 配置页 — 选项卡片 */
|
||||
.gw-option-cards {
|
||||
display: grid;
|
||||
|
||||