mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-10 17:42:49 +08:00
v0.8.0: Ollama兼容、Git自动安装、Gitee镜像、会话重命名、消息渠道Agent绑定、仪表盘重设计、环境检测实时生效、#44修复
This commit is contained in:
37
CHANGELOG.md
37
CHANGELOG.md
@@ -5,6 +5,43 @@
|
||||
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
|
||||
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
||||
|
||||
## [0.8.0] - 2026-03-12
|
||||
|
||||
### 新功能 (Features)
|
||||
|
||||
- **Ollama 本地模型兼容** — 自动规范化 Ollama baseUrl(追加 `/v1`),打开模型配置页时自动修复存量配置,解决 HTTP 404 问题
|
||||
- **Git 自动检测与安装** — 初始化引导新增 Git 检测步骤,支持一键安装(Windows winget / macOS xcode-select / Linux apt/yum/dnf/pacman),安装失败提供分平台手动安装指引
|
||||
- **Git SSH→HTTPS 自动配置** — 检测到 Git 已安装后自动配置 HTTPS 替代 SSH(3 条 insteadOf 规则),彻底解决国内用户 SSH 不通导致依赖安装失败的问题
|
||||
- **Gitee 国内镜像** — 部署脚本、项目链接、贡献页面全面接入 Gitee 镜像(gitee.com/QtCodeCreators/clawpanel),国内用户无需翻墙
|
||||
- **实时聊天会话重命名** — 双击会话名称可内联编辑,本地缓存不影响 Gateway 数据,顶部标题同步更新
|
||||
- **刷新模型按钮** — 聊天页面模型选择器旁新增刷新按钮,手动刷新模型列表
|
||||
- **本地图片渲染** — AI 发送的本地文件路径图片(如截图)在 Tauri 环境下通过 asset protocol 正确加载
|
||||
|
||||
### 修复 (Fixes)
|
||||
|
||||
- **环境检测实时生效** — 保存自定义 Node.js 路径后无需重启应用,PATH 缓存从 OnceLock 改为 RwLock 支持运行时刷新
|
||||
- **Windows 自定义路径优先级** — 修复用户指定的 Node.js 路径被系统 PATH 覆盖的问题(自定义路径现在排最前)
|
||||
- **模型加载超时兜底** — 读取模型配置增加 8 秒超时,不再无限停在"加载模型中..."
|
||||
- **版本更新检测降级** — GitHub API 失败时自动降级到 Gitee API,检测失败显示"前往官网下载"按钮
|
||||
- **重置会话确认框** — 点击重置按钮弹出确认对话框,防止误操作清空聊天记录
|
||||
|
||||
### 改进 (Improvements)
|
||||
|
||||
- **卡片式会话列表** — 会话列表从简单文本改为卡片式布局,显示 Agent 标签、消息数量、相对时间(如"3 分钟前")
|
||||
- **当前会话高亮** — 活跃会话改为 accent 色边框 + 加粗文字,辨识度大幅提升
|
||||
- **聊天顶部栏防溢出** — 长标题自动截断显示省略号,操作区不被挤压
|
||||
- **术语统一** — "智能体" 统一为 "Agent"(聊天/Agent 管理页面)
|
||||
- **侧边栏重命名** — "AI 助手" 改为 "晴辰助手"
|
||||
- **baseUrl 自动规范化** — 保存模型配置时自动清理尾部端点路径、追加 /v1,兼容用户粘贴完整 URL
|
||||
- **官网下载引导** — 版本更新提示统一引导到 claw.qt.cool 官网
|
||||
- **消息渠道 Agent 绑定** — 每个消息渠道配置弹窗新增 Agent 绑定选择器,通过 openclaw.json `bindings` 配置路由消息到指定 Agent
|
||||
- **仪表盘概览重设计** — 从双列列表改为 3×2 卡片网格,含主模型/MCP/备份/Agent/配置,点击可跳转对应页面
|
||||
- **仪表盘 Control UI 卡片** — 新增 OpenClaw 原生面板入口,点击在浏览器中打开 Gateway Web 界面
|
||||
- **推荐弹窗优化** — 每天最多弹一次,不在聊天/助手页面弹出,弹窗加宽至 500px,4 个社群二维码 Grid 均匀排列
|
||||
- **Gateway 横幅美化** — 渐变背景色 + 精简文案 + 启动失败显示错误详情和排查入口
|
||||
- **公益站模型动态获取** — 移除硬编码模型 ID,始终从 API 实时拉取最新模型列表
|
||||
- **定时任务 cron.jobs 自动修复** — 打开定时任务页面时自动检测并清除无效的 cron.jobs 配置字段
|
||||
|
||||
## [0.7.4] - 2026-03-11
|
||||
|
||||
### 新功能 (Features)
|
||||
|
||||
359
docs/dingtalk-integration.md
Normal file
359
docs/dingtalk-integration.md
Normal file
@@ -0,0 +1,359 @@
|
||||
# ClawPanel 钉钉接入指南
|
||||
|
||||
本文面向 **ClawPanel / OpenClaw** 用户,说明如何把钉钉企业内部应用接入为消息渠道,并完成最小可用联调。
|
||||
|
||||
## 适用方案
|
||||
|
||||
当前 ClawPanel 走的是 **钉钉企业内部应用 + 机器人能力 + Stream 模式 + `dingtalk-connector` 插件** 方案。
|
||||
|
||||
这不是自定义 Webhook 机器人方案,也不是 DEAP Agent 方案。
|
||||
|
||||
## 前置条件
|
||||
|
||||
在开始前,请确认:
|
||||
|
||||
- 你已经安装并初始化 OpenClaw
|
||||
- Gateway 可以正常运行
|
||||
- ClawPanel 已能读写 `~/.openclaw/openclaw.json`
|
||||
- 你拥有钉钉企业内部应用的创建和发布权限
|
||||
|
||||
## 一、在钉钉开放平台创建应用
|
||||
|
||||
1. 打开钉钉开放平台
|
||||
2. 进入 **应用开发**
|
||||
3. 选择 **企业内部开发**
|
||||
4. 创建一个新的企业内部应用
|
||||
|
||||
建议先准备好应用名称、图标和应用描述,便于后续在钉钉侧识别机器人。
|
||||
|
||||
## 二、给应用添加机器人能力
|
||||
|
||||
在应用能力中添加 **机器人**。
|
||||
|
||||
关键点:
|
||||
|
||||
- 消息接收方式必须选择 **Stream 模式**
|
||||
- 不要使用 Webhook 模式
|
||||
|
||||
如果这里选错,插件即使安装成功,机器人通常也不会正常收发消息。
|
||||
|
||||
## 三、配置权限
|
||||
|
||||
至少确认已开通下列权限:
|
||||
|
||||
- `Card.Streaming.Write`
|
||||
- `Card.Instance.Write`
|
||||
- `qyapi_robot_sendmsg`
|
||||
|
||||
如果你后续还想使用文档相关能力,再补充文档 API 所需权限。
|
||||
|
||||
## 四、获取凭证
|
||||
|
||||
在钉钉应用的 **凭证与基础信息** 页面,记录:
|
||||
|
||||
- `Client ID`
|
||||
- `Client Secret`
|
||||
|
||||
在 ClawPanel 中的字段映射如下:
|
||||
|
||||
| ClawPanel 字段 | 钉钉后台字段 | 说明 |
|
||||
|---|---|---|
|
||||
| `clientId` | Client ID / AppKey | 必填 |
|
||||
| `clientSecret` | Client Secret / AppSecret | 必填 |
|
||||
| `gatewayToken` | `gateway.auth.token` | Gateway 开启 token 鉴权时填写 |
|
||||
| `gatewayPassword` | `gateway.auth.password` | Gateway 开启 password 鉴权时填写 |
|
||||
|
||||
## 五、发布应用版本
|
||||
|
||||
这一步非常重要。
|
||||
|
||||
在你完成机器人能力、权限和基础信息配置后,需要 **发布应用版本**。
|
||||
|
||||
如果没有发布,常见现象包括:
|
||||
|
||||
- 插件已经安装成功,但机器人在钉钉里没有响应
|
||||
- 卡片能力不生效
|
||||
- 某些权限看起来已配置,但实际上线上不可用
|
||||
|
||||
## 六、在 ClawPanel 中接入钉钉
|
||||
|
||||
打开 **消息渠道** 页面,选择 **钉钉**。
|
||||
|
||||
填写:
|
||||
|
||||
- `Client ID`
|
||||
- `Client Secret`
|
||||
- `Gateway Token` 或 `Gateway Password`(仅在 Gateway 启用了鉴权时填写)
|
||||
|
||||
填写规则:
|
||||
|
||||
- 如果 `gateway.auth.mode = token`,填写 `Gateway Token`
|
||||
- 如果 `gateway.auth.mode = password`,填写 `Gateway Password`
|
||||
- 如果 Gateway 未开启鉴权,这两个都可以留空
|
||||
|
||||
从当前版本开始,ClawPanel 在打开钉钉配置弹窗时会自动读取 `openclaw.json` 中的 `gateway.auth`:
|
||||
|
||||
- 如果 `gateway.auth.mode = token`,会自动带出 `Gateway Token`
|
||||
- 如果 `gateway.auth.mode = password`,会自动带出 `Gateway Password`
|
||||
|
||||
建议先点击 **校验凭证**,确认 `Client ID / Client Secret` 可用后,再点击保存。
|
||||
|
||||
## 七、ClawPanel 保存时会自动做什么
|
||||
|
||||
保存钉钉渠道时,ClawPanel 会自动完成以下动作:
|
||||
|
||||
- 写入 `channels.dingtalk-connector`
|
||||
- 自动补齐 `plugins.allow`
|
||||
- 自动启用 `gateway.http.endpoints.chatCompletions.enabled = true`
|
||||
- 首次缺少插件时自动安装 `@dingtalk-real-ai/dingtalk-connector`
|
||||
|
||||
从当前版本开始:
|
||||
|
||||
- **首次保存**:如果检测到插件未安装,会自动安装插件
|
||||
- **后续保存**:如果插件已经存在,只更新配置,不会重复安装
|
||||
|
||||
## 八、手动配置时的最小示例
|
||||
|
||||
如果你不通过 ClawPanel,而是手改 `~/.openclaw/openclaw.json`,最小示例如下:
|
||||
|
||||
```json5
|
||||
{
|
||||
"channels": {
|
||||
"dingtalk-connector": {
|
||||
"enabled": true,
|
||||
"clientId": "你的 Client ID / AppKey",
|
||||
"clientSecret": "你的 Client Secret / AppSecret",
|
||||
"gatewayToken": "如果 gateway.auth.mode=token 则填这里",
|
||||
"gatewayPassword": ""
|
||||
}
|
||||
},
|
||||
"gateway": {
|
||||
"http": {
|
||||
"endpoints": {
|
||||
"chatCompletions": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
注意:不要整段覆盖已有的 `gateway` 节点,而是将 `http.endpoints.chatCompletions.enabled` 追加到已有配置中。
|
||||
|
||||
## 九、保存后建议执行的检查
|
||||
|
||||
### 1. 检查插件是否已加载
|
||||
|
||||
```bash
|
||||
openclaw plugins list
|
||||
```
|
||||
|
||||
确认输出中存在:
|
||||
|
||||
```text
|
||||
dingtalk-connector
|
||||
```
|
||||
|
||||
### 2. 检查 Gateway 是否运行
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:18789/health
|
||||
```
|
||||
|
||||
### 3. 如有需要,重启 Gateway
|
||||
|
||||
```bash
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
## 十、私聊与群聊测试方法
|
||||
|
||||
### 1. 私聊机器人
|
||||
|
||||
先确认以下前置条件:
|
||||
|
||||
- 应用版本已经发布
|
||||
- 应用可见范围包含你当前的测试账号
|
||||
- 你是该企业/组织内成员
|
||||
|
||||
推荐操作路径:
|
||||
|
||||
1. 在钉钉客户端搜索你的应用名或机器人名
|
||||
2. 如果搜索不到,再到 **工作台 / 全部应用** 中查找该应用
|
||||
3. 打开后发一条简单消息,例如“你好”
|
||||
|
||||
说明:
|
||||
|
||||
- 不同客户端版本中,私聊入口文案可能略有差异
|
||||
- 如果机器人已发布但依然完全搜不到,优先检查 **可见范围** 和 **应用发布状态**
|
||||
- 如果首次私聊收到的是 **配对码**,请在终端执行 `openclaw pairing approve dingtalk-connector <配对码>` 完成授权;如需先查看待审批项,可执行 `openclaw pairing list dingtalk-connector`
|
||||
|
||||
### 2. 添加到群聊并测试
|
||||
|
||||
根据钉钉开放平台“添加机器人到钉钉群”的使用说明,常见路径如下:
|
||||
|
||||
1. 打开目标群聊
|
||||
2. 进入 **群设置**
|
||||
3. 找到 **智能群助手**、**机器人** 或相近入口
|
||||
4. 点击 **添加机器人**
|
||||
5. 搜索你的机器人名称并添加
|
||||
6. 返回群聊,先发送 `@机器人 你好` 做测试
|
||||
|
||||
说明:
|
||||
|
||||
- 不同客户端里入口名称可能是“智能群助手”或“机器人”
|
||||
- 企业内部应用机器人一般只在组织内部可见,外部群或不在可见范围内的成员可能搜不到
|
||||
- 群里建议优先使用 `@机器人` 触发,便于判断消息是否被正确路由到机器人
|
||||
- 如果已经加群但仍不响应,请继续检查连接器配置里的 `groupPolicy` 是否被设为 `disabled`
|
||||
|
||||
## 十一、建议的联调顺序
|
||||
|
||||
建议按下面顺序测试:
|
||||
|
||||
1. 先在钉钉里 **私聊机器人**,发一条简单消息,例如“你好”
|
||||
2. 确认私聊通了之后,再把机器人拉入群聊
|
||||
3. 再测试群聊消息和卡片回包
|
||||
|
||||
这样更容易判断问题到底在:
|
||||
|
||||
- 应用基础配置
|
||||
- 消息接收模式
|
||||
- 群聊权限
|
||||
- 会话隔离策略
|
||||
|
||||
## 十二、常见问题
|
||||
|
||||
### Q1: 出现 405 错误
|
||||
|
||||
通常说明 `chatCompletions` 端点未启用。
|
||||
|
||||
检查 `openclaw.json` 中是否存在:
|
||||
|
||||
```json5
|
||||
{
|
||||
"gateway": {
|
||||
"http": {
|
||||
"endpoints": {
|
||||
"chatCompletions": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Q2: 出现 401 错误
|
||||
|
||||
通常说明钉钉连接器填写的 `gatewayToken` / `gatewayPassword` 与 Gateway 实际鉴权配置不一致。
|
||||
|
||||
重点检查:
|
||||
|
||||
- `gateway.auth.mode`
|
||||
- `gateway.auth.token`
|
||||
- `gateway.auth.password`
|
||||
|
||||
### Q3: 机器人无响应
|
||||
|
||||
按顺序检查:
|
||||
|
||||
1. Gateway 是否运行
|
||||
2. 机器人消息接收方式是否为 **Stream 模式**
|
||||
3. `Client ID / Client Secret` 是否正确
|
||||
4. 钉钉应用版本是否已经发布
|
||||
5. 应用可见范围是否包含当前测试账号
|
||||
6. 首次私聊是否还在等待配对审批
|
||||
|
||||
### Q4: AI Card 不显示,只能看到纯文本
|
||||
|
||||
通常是权限未开齐。
|
||||
|
||||
至少检查:
|
||||
|
||||
- `Card.Streaming.Write`
|
||||
- `Card.Instance.Write`
|
||||
|
||||
修改权限后,记得重新发布应用版本。
|
||||
|
||||
### Q5: 搜不到机器人,没法私聊,也没法加到群里
|
||||
|
||||
优先检查:
|
||||
|
||||
- 应用是否已经发布
|
||||
- 应用可见范围是否包含当前测试人
|
||||
- 当前测试人是否属于该企业/组织
|
||||
- 机器人是否是企业内部应用机器人,而不是另一个同名应用
|
||||
|
||||
如果是群聊场景,还要确认:
|
||||
|
||||
- 目标群是你当前组织内可用的群
|
||||
- 加群入口中搜索的是机器人/应用当前发布名称
|
||||
|
||||
### Q6: 机器人在群里已添加,但还是不响应
|
||||
|
||||
优先检查:
|
||||
|
||||
- 是否已经把机器人真正添加进该群
|
||||
- 发消息时是否使用了 `@机器人`
|
||||
- `groupPolicy` 是否被设为 `disabled`
|
||||
- Gateway 日志里是否能看到群消息进入
|
||||
|
||||
### Q7: 保存时为什么以前总是重复安装插件?
|
||||
|
||||
旧逻辑在每次保存渠道配置时都会执行插件安装。
|
||||
|
||||
当前版本已经修复为:
|
||||
|
||||
- 检测插件已安装时,直接更新配置
|
||||
- 仅在插件缺失时执行安装
|
||||
|
||||
### Q8: 为什么会看到“dangerous code patterns”警告?
|
||||
|
||||
这是 OpenClaw 对插件代码的静态审计提示,不一定等于本次安装失败的根因。
|
||||
|
||||
它表示插件中检测到了如下模式之一:
|
||||
|
||||
- `child_process`
|
||||
- 环境变量读取 + 网络发送
|
||||
|
||||
是否接受该插件,仍需要你根据插件来源和使用场景自行判断。
|
||||
|
||||
### Q9: 为什么会出现 duplicate plugin id detected?
|
||||
|
||||
这是旧版本安装器把临时备份目录放在 `~/.openclaw/extensions/` 下导致的。
|
||||
|
||||
当前版本已经改为:
|
||||
|
||||
- 把备份目录移动到 `~/.openclaw/backups/plugin-installs/`
|
||||
- 保存配置和安装插件时会顺手清理旧的 `.__clawpanel_backup` 遗留目录
|
||||
|
||||
## 十三、高级配置
|
||||
|
||||
`dingtalk-connector` 还支持一些高级项,当前 P0 页面未全部暴露到 UI,可以在 `openclaw.json` 中手工添加,例如:
|
||||
|
||||
- `separateSessionByConversation`
|
||||
- `groupSessionScope`
|
||||
- `sharedMemoryAcrossConversations`
|
||||
- `asyncMode`
|
||||
- `ackText`
|
||||
|
||||
这些适合后续做更细的群聊/私聊隔离、异步回执和高级会话策略。
|
||||
|
||||
## 十四、当前可完成与仍需人工完成的部分
|
||||
|
||||
ClawPanel 当前已经可以帮助你完成:
|
||||
|
||||
- 配置钉钉渠道
|
||||
- 校验 `Client ID / Client Secret`
|
||||
- 自动安装插件
|
||||
- 自动补齐 OpenClaw 关键配置
|
||||
|
||||
但以下动作仍必须由你在钉钉侧人工完成:
|
||||
|
||||
- 创建企业内部应用
|
||||
- 添加机器人能力
|
||||
- 选择 Stream 模式
|
||||
- 配置权限
|
||||
- 发布应用版本
|
||||
- 在钉钉里把机器人拉入私聊或群聊并发起真实测试
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawpanel",
|
||||
"version": "0.7.4",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"description": "ClawPanel - OpenClaw 可视化管理面板,基于 Tauri v2 的跨平台桌面应用",
|
||||
"type": "module",
|
||||
|
||||
@@ -997,8 +997,10 @@ const ALWAYS_LOCAL = new Set([
|
||||
// 清理 base URL:去掉尾部斜杠和已知端点路径,防止路径重复
|
||||
function _normalizeBaseUrl(raw) {
|
||||
let base = (raw || '').replace(/\/+$/, '')
|
||||
base = base.replace(/\/(chat\/completions|completions|responses|messages|models)\/?$/, '')
|
||||
return base.replace(/\/+$/, '')
|
||||
base = base.replace(/\/(api\/chat|api\/generate|api\/tags|api|chat\/completions|completions|responses|messages|models)\/?$/, '')
|
||||
base = base.replace(/\/+$/, '')
|
||||
if (/:11434$/i.test(base)) return `${base}/v1`
|
||||
return base
|
||||
}
|
||||
|
||||
// === API Handlers ===
|
||||
@@ -2301,31 +2303,69 @@ const handlers = {
|
||||
},
|
||||
|
||||
// 模型测试
|
||||
async test_model({ baseUrl, apiKey, modelId }) {
|
||||
const url = `${_normalizeBaseUrl(baseUrl)}/chat/completions`
|
||||
const body = JSON.stringify({
|
||||
model: modelId,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
max_tokens: 16,
|
||||
stream: false
|
||||
})
|
||||
async test_model({ baseUrl, apiKey, modelId, apiType = 'openai-completions' }) {
|
||||
const type = ['anthropic', 'anthropic-messages'].includes(apiType) ? 'anthropic-messages'
|
||||
: apiType === 'google-gemini' ? 'google-gemini'
|
||||
: 'openai-completions'
|
||||
let base = _normalizeBaseUrl(baseUrl)
|
||||
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 {
|
||||
const headers = { 'Content-Type': 'application/json' }
|
||||
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
|
||||
const resp = await fetch(url, { method: 'POST', headers, body, signal: controller.signal })
|
||||
let resp
|
||||
if (type === 'anthropic-messages') {
|
||||
const headers = { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' }
|
||||
if (apiKey) headers['x-api-key'] = apiKey
|
||||
resp = await fetch(`${base}/messages`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: modelId,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
max_tokens: 16,
|
||||
}),
|
||||
signal: controller.signal
|
||||
})
|
||||
} else if (type === 'google-gemini') {
|
||||
resp = await fetch(`${base}/models/${encodeURIComponent(modelId)}:generateContent?key=${encodeURIComponent(apiKey || '')}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ contents: [{ role: 'user', parts: [{ text: 'Hi' }] }] }),
|
||||
signal: controller.signal
|
||||
})
|
||||
} else {
|
||||
const headers = { 'Content-Type': 'application/json' }
|
||||
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
|
||||
resp = await fetch(`${base}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: modelId,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
max_tokens: 16,
|
||||
stream: false
|
||||
}),
|
||||
signal: controller.signal
|
||||
})
|
||||
}
|
||||
clearTimeout(timeout)
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text()
|
||||
let msg = `HTTP ${resp.status}`
|
||||
try { msg = JSON.parse(text).error?.message || msg } catch {}
|
||||
throw new Error(msg)
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
msg = parsed.error?.message || parsed.message || msg
|
||||
} catch {}
|
||||
if (resp.status === 401 || resp.status === 403) throw new Error(msg)
|
||||
return `⚠ 连接正常(API 返回 ${resp.status},部分模型对简单测试不兼容,不影响实际使用)`
|
||||
}
|
||||
const data = await resp.json()
|
||||
const anthropicText = (data.content || []).filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
const geminiText = data.candidates?.[0]?.content?.parts?.map?.(p => p.text).filter(Boolean).join('') || ''
|
||||
const content = data.choices?.[0]?.message?.content
|
||||
const reasoning = data.choices?.[0]?.message?.reasoning_content
|
||||
return content || (reasoning ? `[reasoning] ${reasoning}` : '(无回复内容)')
|
||||
return anthropicText || geminiText || content || (reasoning ? `[reasoning] ${reasoning}` : '(无回复内容)')
|
||||
} catch (e) {
|
||||
clearTimeout(timeout)
|
||||
if (e.name === 'AbortError') throw new Error('请求超时 (30s)')
|
||||
@@ -2333,18 +2373,43 @@ const handlers = {
|
||||
}
|
||||
},
|
||||
|
||||
async list_remote_models({ baseUrl, apiKey }) {
|
||||
const url = `${_normalizeBaseUrl(baseUrl)}/models`
|
||||
const headers = {}
|
||||
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
|
||||
async list_remote_models({ baseUrl, apiKey, apiType = 'openai-completions' }) {
|
||||
const type = ['anthropic', 'anthropic-messages'].includes(apiType) ? 'anthropic-messages'
|
||||
: apiType === 'google-gemini' ? 'google-gemini'
|
||||
: 'openai-completions'
|
||||
let base = _normalizeBaseUrl(baseUrl)
|
||||
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 {
|
||||
const resp = await fetch(url, { headers, signal: controller.signal })
|
||||
let resp
|
||||
if (type === 'anthropic-messages') {
|
||||
const headers = { 'anthropic-version': '2023-06-01' }
|
||||
if (apiKey) headers['x-api-key'] = apiKey
|
||||
resp = await fetch(`${base}/models`, { headers, signal: controller.signal })
|
||||
} else if (type === 'google-gemini') {
|
||||
resp = await fetch(`${base}/models?key=${encodeURIComponent(apiKey || '')}`, { signal: controller.signal })
|
||||
} else {
|
||||
const headers = {}
|
||||
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
|
||||
resp = await fetch(`${base}/models`, { headers, signal: controller.signal })
|
||||
}
|
||||
clearTimeout(timeout)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => '')
|
||||
let msg = `HTTP ${resp.status}`
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
msg = parsed.error?.message || parsed.message || msg
|
||||
} catch {}
|
||||
throw new Error(msg)
|
||||
}
|
||||
const data = await resp.json()
|
||||
const ids = (data.data || []).map(m => m.id).sort()
|
||||
const ids = (data.data || []).map(m => m.id)
|
||||
.concat((data.models || []).map(m => (m.name || '').replace(/^models\//, '')))
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
if (!ids.length) throw new Error('该服务商返回了空的模型列表')
|
||||
return ids
|
||||
} catch (e) {
|
||||
|
||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -328,7 +328,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clawpanel"
|
||||
version = "0.7.4"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "clawpanel"
|
||||
version = "0.7.4"
|
||||
version = "0.8.0"
|
||||
edition = "2021"
|
||||
description = "ClawPanel - OpenClaw 可视化管理面板"
|
||||
authors = ["qingchencloud"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
use crate::utils::openclaw_command;
|
||||
/// 配置读写命令
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
@@ -205,6 +205,13 @@ fn sync_providers_to_agent_models(config: &Value) {
|
||||
|
||||
let mut changed = false;
|
||||
|
||||
if models_json.get("providers").and_then(|p| p.as_object()).is_none() {
|
||||
if let Some(root) = models_json.as_object_mut() {
|
||||
root.insert("providers".into(), json!({}));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 同步 providers
|
||||
if let Some(dst_providers) = models_json
|
||||
.get_mut("providers")
|
||||
@@ -222,6 +229,13 @@ fn sync_providers_to_agent_models(config: &Value) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
for (provider_name, src_provider) in src.iter() {
|
||||
if !dst_providers.contains_key(provider_name) {
|
||||
dst_providers.insert(provider_name.clone(), src_provider.clone());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 同步存在的 provider 的 baseUrl/apiKey/api + 清理已删除的 models
|
||||
for (provider_name, src_provider) in src.iter() {
|
||||
if let Some(dst_provider) = dst_providers.get_mut(provider_name) {
|
||||
@@ -1058,6 +1072,8 @@ pub fn save_custom_node_path(node_dir: String) -> Result<(), String> {
|
||||
let json = serde_json::to_string_pretty(&Value::Object(config))
|
||||
.map_err(|e| format!("序列化失败: {e}"))?;
|
||||
std::fs::write(&config_path, json).map_err(|e| format!("写入配置失败: {e}"))?;
|
||||
// 立即刷新 PATH 缓存,使新路径生效(无需重启应用)
|
||||
super::refresh_enhanced_path();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1245,6 +1261,10 @@ pub async fn restart_gateway() -> Result<String, String> {
|
||||
fn normalize_base_url(raw: &str) -> String {
|
||||
let mut base = raw.trim_end_matches('/').to_string();
|
||||
for suffix in &[
|
||||
"/api/chat",
|
||||
"/api/generate",
|
||||
"/api/tags",
|
||||
"/api",
|
||||
"/chat/completions",
|
||||
"/completions",
|
||||
"/responses",
|
||||
@@ -1256,7 +1276,56 @@ fn normalize_base_url(raw: &str) -> String {
|
||||
break;
|
||||
}
|
||||
}
|
||||
base.trim_end_matches('/').to_string()
|
||||
base = base.trim_end_matches('/').to_string();
|
||||
if base.ends_with(":11434") {
|
||||
return format!("{base}/v1");
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
fn normalize_model_api_type(raw: &str) -> &'static str {
|
||||
match raw.trim() {
|
||||
"anthropic" | "anthropic-messages" => "anthropic-messages",
|
||||
"google-gemini" => "google-gemini",
|
||||
"openai" | "openai-completions" | "openai-responses" | "" => "openai-completions",
|
||||
_ => "openai-completions",
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_base_url_for_api(raw: &str, api_type: &str) -> String {
|
||||
let mut base = normalize_base_url(raw);
|
||||
match normalize_model_api_type(api_type) {
|
||||
"anthropic-messages" => {
|
||||
if !base.ends_with("/v1") {
|
||||
base.push_str("/v1");
|
||||
}
|
||||
base
|
||||
}
|
||||
"google-gemini" => base,
|
||||
_ => {
|
||||
if !base.ends_with("/v1") {
|
||||
if let Some(idx) = base.find("/v1/") {
|
||||
base.truncate(idx + 3);
|
||||
} else {
|
||||
base.push_str("/v1");
|
||||
}
|
||||
}
|
||||
base
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_error_message(text: &str, status: reqwest::StatusCode) -> String {
|
||||
serde_json::from_str::<serde_json::Value>(text)
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
v.get("error")
|
||||
.and_then(|e| e.get("message"))
|
||||
.and_then(|m| m.as_str())
|
||||
.map(String::from)
|
||||
.or_else(|| v.get("message").and_then(|m| m.as_str()).map(String::from))
|
||||
})
|
||||
.unwrap_or_else(|| format!("HTTP {status}"))
|
||||
}
|
||||
|
||||
/// 测试模型连通性:向 provider 发送一个简单的 chat completion 请求
|
||||
@@ -1265,27 +1334,57 @@ pub async fn test_model(
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
model_id: String,
|
||||
api_type: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let url = format!("{}/chat/completions", normalize_base_url(&base_url));
|
||||
|
||||
let body = serde_json::json!({
|
||||
"model": model_id,
|
||||
"messages": [{"role": "user", "content": "Hi"}],
|
||||
"max_tokens": 16,
|
||||
"stream": false
|
||||
});
|
||||
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()
|
||||
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?;
|
||||
|
||||
let mut req = client.post(&url).json(&body);
|
||||
if !api_key.is_empty() {
|
||||
req = req.header("Authorization", format!("Bearer {api_key}"));
|
||||
let resp = match api_type {
|
||||
"anthropic-messages" => {
|
||||
let url = format!("{}/messages", base);
|
||||
let body = json!({
|
||||
"model": model_id,
|
||||
"messages": [{"role": "user", "content": "Hi"}],
|
||||
"max_tokens": 16,
|
||||
});
|
||||
let mut req = client
|
||||
.post(&url)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.json(&body);
|
||||
if !api_key.is_empty() {
|
||||
req = req.header("x-api-key", api_key.clone());
|
||||
}
|
||||
req.send()
|
||||
}
|
||||
"google-gemini" => {
|
||||
let url = format!("{}/models/{}:generateContent?key={}", base, model_id, api_key);
|
||||
let body = json!({
|
||||
"contents": [{"role": "user", "parts": [{"text": "Hi"}]}]
|
||||
});
|
||||
client.post(&url).json(&body).send()
|
||||
}
|
||||
_ => {
|
||||
let url = format!("{}/chat/completions", base);
|
||||
let body = json!({
|
||||
"model": model_id,
|
||||
"messages": [{"role": "user", "content": "Hi"}],
|
||||
"max_tokens": 16,
|
||||
"stream": false
|
||||
});
|
||||
let mut req = client.post(&url).json(&body);
|
||||
if !api_key.is_empty() {
|
||||
req = req.header("Authorization", format!("Bearer {api_key}"));
|
||||
}
|
||||
req.send()
|
||||
}
|
||||
}
|
||||
|
||||
let resp = req.send().await.map_err(|e| {
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e.is_timeout() {
|
||||
"请求超时 (30s)".to_string()
|
||||
} else if e.is_connect() {
|
||||
@@ -1299,16 +1398,7 @@ pub async fn test_model(
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
// 尝试提取错误信息
|
||||
let msg = serde_json::from_str::<serde_json::Value>(&text)
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
v.get("error")
|
||||
.and_then(|e| e.get("message"))
|
||||
.and_then(|m| m.as_str())
|
||||
.map(String::from)
|
||||
})
|
||||
.unwrap_or_else(|| format!("HTTP {status}"));
|
||||
let msg = extract_error_message(&text, status);
|
||||
// 401/403 是认证错误,一定要报错
|
||||
if status.as_u16() == 401 || status.as_u16() == 403 {
|
||||
return Err(msg);
|
||||
@@ -1324,6 +1414,29 @@ pub async fn test_model(
|
||||
let reply = serde_json::from_str::<serde_json::Value>(&text)
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
if let Some(arr) = v.get("content").and_then(|c| c.as_array()) {
|
||||
let text = arr
|
||||
.iter()
|
||||
.filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text"))
|
||||
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
if !text.is_empty() {
|
||||
return Some(text);
|
||||
}
|
||||
}
|
||||
if let Some(t) = v
|
||||
.get("candidates")
|
||||
.and_then(|c| c.get(0))
|
||||
.and_then(|c| c.get("content"))
|
||||
.and_then(|c| c.get("parts"))
|
||||
.and_then(|p| p.get(0))
|
||||
.and_then(|p| p.get("text"))
|
||||
.and_then(|t| t.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
return Some(t.to_string());
|
||||
}
|
||||
// 标准 OpenAI 格式: choices[0].message.content
|
||||
if let Some(msg) = v
|
||||
.get("choices")
|
||||
@@ -1361,20 +1474,43 @@ pub async fn test_model(
|
||||
|
||||
/// 获取服务商的远程模型列表(调用 /models 接口)
|
||||
#[tauri::command]
|
||||
pub async fn list_remote_models(base_url: String, api_key: String) -> Result<Vec<String>, String> {
|
||||
let url = format!("{}/models", normalize_base_url(&base_url));
|
||||
pub async fn list_remote_models(
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
api_type: Option<String>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
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()
|
||||
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?;
|
||||
|
||||
let mut req = client.get(&url);
|
||||
if !api_key.is_empty() {
|
||||
req = req.header("Authorization", format!("Bearer {api_key}"));
|
||||
let resp = match api_type {
|
||||
"anthropic-messages" => {
|
||||
let url = format!("{}/models", base);
|
||||
let mut req = client.get(&url).header("anthropic-version", "2023-06-01");
|
||||
if !api_key.is_empty() {
|
||||
req = req.header("x-api-key", api_key.clone());
|
||||
}
|
||||
req.send()
|
||||
}
|
||||
"google-gemini" => {
|
||||
let url = format!("{}/models?key={}", base, api_key);
|
||||
client.get(&url).send()
|
||||
}
|
||||
_ => {
|
||||
let url = format!("{}/models", base);
|
||||
let mut req = client.get(&url);
|
||||
if !api_key.is_empty() {
|
||||
req = req.header("Authorization", format!("Bearer {api_key}"));
|
||||
}
|
||||
req.send()
|
||||
}
|
||||
}
|
||||
|
||||
let resp = req.send().await.map_err(|e| {
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e.is_timeout() {
|
||||
"请求超时 (15s),该服务商可能不支持模型列表接口".to_string()
|
||||
} else if e.is_connect() {
|
||||
@@ -1388,27 +1524,29 @@ pub async fn list_remote_models(base_url: String, api_key: String) -> Result<Vec
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = serde_json::from_str::<serde_json::Value>(&text)
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
v.get("error")
|
||||
.and_then(|e| e.get("message"))
|
||||
.and_then(|m| m.as_str())
|
||||
.map(String::from)
|
||||
})
|
||||
.unwrap_or_else(|| format!("HTTP {status}"));
|
||||
let msg = extract_error_message(&text, status);
|
||||
return Err(format!("获取模型列表失败: {msg}"));
|
||||
}
|
||||
|
||||
// 解析 OpenAI 格式的 /models 响应
|
||||
// 解析 OpenAI / Anthropic / Gemini 格式的 /models 响应
|
||||
let ids = serde_json::from_str::<serde_json::Value>(&text)
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
let data = v.get("data")?.as_array()?;
|
||||
let mut ids: Vec<String> = data
|
||||
.iter()
|
||||
.filter_map(|m| m.get("id").and_then(|id| id.as_str()).map(String::from))
|
||||
.collect();
|
||||
let mut ids: Vec<String> = if let Some(data) = v.get("data").and_then(|d| d.as_array()) {
|
||||
data.iter()
|
||||
.filter_map(|m| m.get("id").and_then(|id| id.as_str()).map(String::from))
|
||||
.collect()
|
||||
} else if let Some(data) = v.get("models").and_then(|d| d.as_array()) {
|
||||
data.iter()
|
||||
.filter_map(|m| {
|
||||
m.get("name")
|
||||
.and_then(|id| id.as_str())
|
||||
.map(|s| s.trim_start_matches("models/").to_string())
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
ids.sort();
|
||||
Some(ids)
|
||||
})
|
||||
@@ -1544,47 +1682,75 @@ pub fn patch_model_vision() -> Result<bool, String> {
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// 检查 ClawPanel 自身是否有新版本(通过 GitHub releases API)
|
||||
/// 检查 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(5))
|
||||
.timeout(std::time::Duration::from_secs(8))
|
||||
.user_agent("ClawPanel")
|
||||
.build()
|
||||
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?;
|
||||
|
||||
let url = "https://api.github.com/repos/qingchencloud/clawpanel/releases/latest";
|
||||
let resp = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("请求失败: {e}"))?;
|
||||
// 先尝试 GitHub,失败后降级 Gitee
|
||||
let sources = [
|
||||
(
|
||||
"https://api.github.com/repos/qingchencloud/clawpanel/releases/latest",
|
||||
"https://github.com/qingchencloud/clawpanel/releases",
|
||||
"github",
|
||||
),
|
||||
(
|
||||
"https://gitee.com/api/v5/repos/QtCodeCreators/clawpanel/releases/latest",
|
||||
"https://gitee.com/QtCodeCreators/clawpanel/releases",
|
||||
"gitee",
|
||||
),
|
||||
];
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("GitHub API 返回 {}", resp.status()));
|
||||
let mut last_err = String::new();
|
||||
for (api_url, releases_url, source) in &sources {
|
||||
match client.get(*api_url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let json: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("解析响应失败: {e}"))?;
|
||||
|
||||
let tag = json
|
||||
.get("tag_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim_start_matches('v')
|
||||
.to_string();
|
||||
|
||||
if tag.is_empty() {
|
||||
last_err = format!("{source}: 未找到版本号");
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut result = serde_json::Map::new();
|
||||
result.insert("latest".into(), Value::String(tag));
|
||||
result.insert(
|
||||
"url".into(),
|
||||
json.get("html_url").cloned().unwrap_or(Value::String(
|
||||
releases_url.to_string(),
|
||||
)),
|
||||
);
|
||||
result.insert("source".into(), Value::String(source.to_string()));
|
||||
result.insert(
|
||||
"downloadUrl".into(),
|
||||
Value::String("https://claw.qt.cool".into()),
|
||||
);
|
||||
return Ok(Value::Object(result));
|
||||
}
|
||||
Ok(resp) => {
|
||||
last_err = format!("{source}: HTTP {}", resp.status());
|
||||
}
|
||||
Err(e) => {
|
||||
last_err = format!("{source}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let json: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("解析响应失败: {e}"))?;
|
||||
|
||||
let tag = json
|
||||
.get("tag_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim_start_matches('v')
|
||||
.to_string();
|
||||
|
||||
let mut result = serde_json::Map::new();
|
||||
result.insert("latest".into(), Value::String(tag));
|
||||
result.insert(
|
||||
"url".into(),
|
||||
json.get("html_url").cloned().unwrap_or(Value::String(
|
||||
"https://github.com/qingchencloud/clawpanel/releases".into(),
|
||||
)),
|
||||
);
|
||||
Ok(Value::Object(result))
|
||||
Err(last_err)
|
||||
}
|
||||
|
||||
// === 面板配置 (clawpanel.json) ===
|
||||
@@ -1620,3 +1786,175 @@ pub fn set_npm_registry(registry: String) -> Result<(), String> {
|
||||
let path = super::openclaw_dir().join("npm-registry.txt");
|
||||
fs::write(&path, registry.trim()).map_err(|e| format!("保存失败: {e}"))
|
||||
}
|
||||
|
||||
/// 检测 Git 是否已安装
|
||||
#[tauri::command]
|
||||
pub fn check_git() -> Result<Value, String> {
|
||||
let mut result = serde_json::Map::new();
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.arg("--version");
|
||||
#[cfg(target_os = "windows")]
|
||||
cmd.creation_flags(0x08000000);
|
||||
match cmd.output() {
|
||||
Ok(o) if o.status.success() => {
|
||||
let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||||
result.insert("installed".into(), Value::Bool(true));
|
||||
result.insert("version".into(), Value::String(ver));
|
||||
}
|
||||
_ => {
|
||||
result.insert("installed".into(), Value::Bool(false));
|
||||
result.insert("version".into(), Value::Null);
|
||||
}
|
||||
}
|
||||
Ok(Value::Object(result))
|
||||
}
|
||||
|
||||
/// 尝试自动安装 Git(Windows: winget; macOS: xcode-select; Linux: apt/yum)
|
||||
#[tauri::command]
|
||||
pub async fn auto_install_git(app: tauri::AppHandle) -> Result<String, String> {
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::process::Stdio;
|
||||
use tauri::Emitter;
|
||||
|
||||
let _ = app.emit("upgrade-log", "正在尝试自动安装 Git...");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// 尝试 winget
|
||||
let _ = app.emit("upgrade-log", "尝试使用 winget 安装 Git...");
|
||||
let mut child = Command::new("winget")
|
||||
.args(["install", "--id", "Git.Git", "-e", "--source", "winget", "--accept-package-agreements", "--accept-source-agreements"])
|
||||
.creation_flags(0x08000000)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("winget 不可用,请手动安装 Git: {e}"))?;
|
||||
|
||||
let stderr = child.stderr.take();
|
||||
let stdout = child.stdout.take();
|
||||
let app2 = app.clone();
|
||||
let handle = std::thread::spawn(move || {
|
||||
if let Some(pipe) = stderr {
|
||||
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
|
||||
let _ = app2.emit("upgrade-log", &line);
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(pipe) = stdout {
|
||||
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
|
||||
let _ = app.emit("upgrade-log", &line);
|
||||
}
|
||||
}
|
||||
let _ = handle.join();
|
||||
let status = child.wait().map_err(|e| format!("等待 winget 完成失败: {e}"))?;
|
||||
if status.success() {
|
||||
let _ = app.emit("upgrade-log", "Git 安装成功!");
|
||||
return Ok("Git 已通过 winget 安装".to_string());
|
||||
}
|
||||
return Err("winget 安装 Git 失败,请手动下载安装: https://git-scm.com/downloads".to_string());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = app.emit("upgrade-log", "尝试通过 xcode-select 安装 Git...");
|
||||
let mut child = Command::new("xcode-select")
|
||||
.arg("--install")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("xcode-select 不可用: {e}"))?;
|
||||
let status = child.wait().map_err(|e| format!("等待安装完成失败: {e}"))?;
|
||||
if status.success() {
|
||||
let _ = app.emit("upgrade-log", "Git 安装已触发,请在弹出的窗口中确认安装。");
|
||||
return Ok("已触发 xcode-select 安装,请在弹窗中确认".to_string());
|
||||
}
|
||||
return Err("xcode-select 安装失败,请手动安装 Xcode Command Line Tools 或 brew install git".to_string());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// 检测包管理器
|
||||
let pkg_mgr = if Command::new("apt-get").arg("--version").output().map(|o| o.status.success()).unwrap_or(false) {
|
||||
"apt"
|
||||
} else if Command::new("yum").arg("--version").output().map(|o| o.status.success()).unwrap_or(false) {
|
||||
"yum"
|
||||
} else if Command::new("dnf").arg("--version").output().map(|o| o.status.success()).unwrap_or(false) {
|
||||
"dnf"
|
||||
} else if Command::new("pacman").arg("--version").output().map(|o| o.status.success()).unwrap_or(false) {
|
||||
"pacman"
|
||||
} else {
|
||||
return Err("未找到包管理器,请手动安装 Git: sudo apt install git 或 sudo yum install git".to_string());
|
||||
};
|
||||
|
||||
let (cmd_name, args): (&str, Vec<&str>) = match pkg_mgr {
|
||||
"apt" => ("sudo", vec!["apt-get", "install", "-y", "git"]),
|
||||
"yum" => ("sudo", vec!["yum", "install", "-y", "git"]),
|
||||
"dnf" => ("sudo", vec!["dnf", "install", "-y", "git"]),
|
||||
"pacman" => ("sudo", vec!["pacman", "-S", "--noconfirm", "git"]),
|
||||
_ => return Err("不支持的包管理器".to_string()),
|
||||
};
|
||||
|
||||
let _ = app.emit("upgrade-log", format!("执行: {} {}", cmd_name, args.join(" ")));
|
||||
let mut child = Command::new(cmd_name)
|
||||
.args(&args)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("安装命令执行失败: {e}"))?;
|
||||
|
||||
let stderr = child.stderr.take();
|
||||
let stdout = child.stdout.take();
|
||||
let app2 = app.clone();
|
||||
let handle = std::thread::spawn(move || {
|
||||
if let Some(pipe) = stderr {
|
||||
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
|
||||
let _ = app2.emit("upgrade-log", &line);
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(pipe) = stdout {
|
||||
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
|
||||
let _ = app.emit("upgrade-log", &line);
|
||||
}
|
||||
}
|
||||
let _ = handle.join();
|
||||
let status = child.wait().map_err(|e| format!("等待安装完成失败: {e}"))?;
|
||||
if status.success() {
|
||||
let _ = app.emit("upgrade-log", "Git 安装成功!");
|
||||
return Ok("Git 已安装".to_string());
|
||||
}
|
||||
return Err("Git 安装失败,请手动执行: sudo apt install git".to_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;
|
||||
}
|
||||
}
|
||||
if success > 0 {
|
||||
Ok(format!("已配置 Git 使用 HTTPS({success} 条规则)"))
|
||||
} else {
|
||||
Err("Git 未安装或配置失败".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新 enhanced_path 缓存,使新设置的 Node.js 路径立即生效
|
||||
#[tauri::command]
|
||||
pub fn invalidate_path_cache() -> Result<(), String> {
|
||||
super::refresh_enhanced_path();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2,27 +2,83 @@
|
||||
/// 负责 Telegram / Discord / QQ Bot 等消息渠道的配置持久化与凭证校验
|
||||
/// 配置写入 openclaw.json 的 channels / plugins 节点
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn platform_storage_key(platform: &str) -> &str {
|
||||
match platform {
|
||||
"dingtalk" | "dingtalk-connector" => "dingtalk-connector",
|
||||
_ => platform,
|
||||
}
|
||||
}
|
||||
|
||||
fn platform_list_id(platform: &str) -> &str {
|
||||
match platform {
|
||||
"dingtalk-connector" => "dingtalk",
|
||||
_ => platform,
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_chat_completions_enabled(cfg: &mut Value) -> Result<(), String> {
|
||||
let root = cfg.as_object_mut().ok_or("配置格式错误")?;
|
||||
let gateway = root.entry("gateway").or_insert_with(|| json!({}));
|
||||
let gateway_obj = gateway.as_object_mut().ok_or("gateway 节点格式错误")?;
|
||||
let http = gateway_obj.entry("http").or_insert_with(|| json!({}));
|
||||
let http_obj = http.as_object_mut().ok_or("gateway.http 节点格式错误")?;
|
||||
let endpoints = http_obj.entry("endpoints").or_insert_with(|| json!({}));
|
||||
let endpoints_obj = endpoints
|
||||
.as_object_mut()
|
||||
.ok_or("gateway.http.endpoints 节点格式错误")?;
|
||||
let chat = endpoints_obj
|
||||
.entry("chatCompletions")
|
||||
.or_insert_with(|| json!({}));
|
||||
let chat_obj = chat
|
||||
.as_object_mut()
|
||||
.ok_or("gateway.http.endpoints.chatCompletions 节点格式错误")?;
|
||||
chat_obj.insert("enabled".into(), Value::Bool(true));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn gateway_auth_mode(cfg: &Value) -> Option<&str> {
|
||||
cfg.get("gateway")
|
||||
.and_then(|g| g.get("auth"))
|
||||
.and_then(|a| a.get("mode"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
fn gateway_auth_value(cfg: &Value, key: &str) -> Option<String> {
|
||||
cfg.get("gateway")
|
||||
.and_then(|g| g.get("auth"))
|
||||
.and_then(|a| a.get(key))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(|v| v.to_string())
|
||||
}
|
||||
|
||||
/// 读取指定平台的当前配置(从 openclaw.json 中提取表单可用的值)
|
||||
#[tauri::command]
|
||||
pub async fn read_platform_config(platform: String) -> Result<Value, String> {
|
||||
let cfg = super::config::load_openclaw_json()?;
|
||||
let storage_key = platform_storage_key(&platform);
|
||||
|
||||
// 从已有配置中提取用户可编辑字段
|
||||
let saved = cfg
|
||||
.get("channels")
|
||||
.and_then(|c| c.get(&platform))
|
||||
.and_then(|c| c.get(storage_key))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
|
||||
if saved.is_null() {
|
||||
return Ok(json!({ "exists": false }));
|
||||
}
|
||||
|
||||
let mut form = Map::new();
|
||||
let exists = !saved.is_null();
|
||||
|
||||
match platform.as_str() {
|
||||
"discord" => {
|
||||
if saved.is_null() {
|
||||
return Ok(json!({ "exists": false }));
|
||||
}
|
||||
// Discord 配置在 openclaw.json 中是展开的 guilds 结构
|
||||
// 需要反向提取成表单字段:token, guildId, channelId
|
||||
if let Some(t) = saved.get("token").and_then(|v| v.as_str()) {
|
||||
@@ -43,6 +99,9 @@ pub async fn read_platform_config(platform: String) -> Result<Value, String> {
|
||||
}
|
||||
}
|
||||
"telegram" => {
|
||||
if saved.is_null() {
|
||||
return Ok(json!({ "exists": false }));
|
||||
}
|
||||
// Telegram: botToken 直接保存, allowFrom 数组需要拼回逗号字符串
|
||||
if let Some(t) = saved.get("botToken").and_then(|v| v.as_str()) {
|
||||
form.insert("botToken".into(), Value::String(t.into()));
|
||||
@@ -53,6 +112,9 @@ pub async fn read_platform_config(platform: String) -> Result<Value, String> {
|
||||
}
|
||||
}
|
||||
"qqbot" => {
|
||||
if saved.is_null() {
|
||||
return Ok(json!({ "exists": false }));
|
||||
}
|
||||
// QQ Bot: token 格式为 "AppID:AppSecret",拆分回表单字段
|
||||
if let Some(t) = saved.get("token").and_then(|v| v.as_str()) {
|
||||
if let Some((app_id, app_secret)) = t.split_once(':') {
|
||||
@@ -62,6 +124,9 @@ pub async fn read_platform_config(platform: String) -> Result<Value, String> {
|
||||
}
|
||||
}
|
||||
"feishu" => {
|
||||
if saved.is_null() {
|
||||
return Ok(json!({ "exists": false }));
|
||||
}
|
||||
// 飞书: appId, appSecret, domain 直接保存
|
||||
if let Some(v) = saved.get("appId").and_then(|v| v.as_str()) {
|
||||
form.insert("appId".into(), Value::String(v.into()));
|
||||
@@ -73,7 +138,39 @@ pub async fn read_platform_config(platform: String) -> Result<Value, String> {
|
||||
form.insert("domain".into(), Value::String(v.into()));
|
||||
}
|
||||
}
|
||||
"dingtalk" | "dingtalk-connector" => {
|
||||
if let Some(v) = saved.get("clientId").and_then(|v| v.as_str()) {
|
||||
form.insert("clientId".into(), Value::String(v.into()));
|
||||
}
|
||||
if let Some(v) = saved.get("clientSecret").and_then(|v| v.as_str()) {
|
||||
form.insert("clientSecret".into(), Value::String(v.into()));
|
||||
}
|
||||
if let Some(v) = saved.get("gatewayToken").and_then(|v| v.as_str()) {
|
||||
form.insert("gatewayToken".into(), Value::String(v.into()));
|
||||
}
|
||||
if let Some(v) = saved.get("gatewayPassword").and_then(|v| v.as_str()) {
|
||||
form.insert("gatewayPassword".into(), Value::String(v.into()));
|
||||
}
|
||||
match gateway_auth_mode(&cfg) {
|
||||
Some("token") => {
|
||||
if let Some(v) = gateway_auth_value(&cfg, "token") {
|
||||
form.insert("gatewayToken".into(), Value::String(v));
|
||||
}
|
||||
form.remove("gatewayPassword");
|
||||
}
|
||||
Some("password") => {
|
||||
if let Some(v) = gateway_auth_value(&cfg, "password") {
|
||||
form.insert("gatewayPassword".into(), Value::String(v));
|
||||
}
|
||||
form.remove("gatewayToken");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if saved.is_null() {
|
||||
return Ok(json!({ "exists": false }));
|
||||
}
|
||||
// 通用:原样返回字符串类型字段
|
||||
if let Some(obj) = saved.as_object() {
|
||||
for (k, v) in obj {
|
||||
@@ -88,7 +185,7 @@ pub async fn read_platform_config(platform: String) -> Result<Value, String> {
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({ "exists": true, "values": Value::Object(form) }))
|
||||
Ok(json!({ "exists": exists, "values": Value::Object(form) }))
|
||||
}
|
||||
|
||||
/// 保存平台配置到 openclaw.json
|
||||
@@ -100,6 +197,7 @@ pub async fn save_messaging_platform(
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<Value, String> {
|
||||
let mut cfg = super::config::load_openclaw_json()?;
|
||||
let storage_key = platform_storage_key(&platform).to_string();
|
||||
|
||||
let channels = cfg
|
||||
.as_object_mut()
|
||||
@@ -213,6 +311,8 @@ pub async fn save_messaging_platform(
|
||||
entry.insert("enabled".into(), Value::Bool(true));
|
||||
|
||||
channels_map.insert("qqbot".into(), Value::Object(entry));
|
||||
ensure_plugin_allowed(&mut cfg, "qqbot")?;
|
||||
let _ = cleanup_legacy_plugin_backup_dir("qqbot");
|
||||
}
|
||||
"feishu" => {
|
||||
let app_id = form_obj
|
||||
@@ -250,6 +350,54 @@ pub async fn save_messaging_platform(
|
||||
}
|
||||
|
||||
channels_map.insert("feishu".into(), Value::Object(entry));
|
||||
ensure_plugin_allowed(&mut cfg, "feishu")?;
|
||||
let _ = cleanup_legacy_plugin_backup_dir("feishu");
|
||||
}
|
||||
"dingtalk" | "dingtalk-connector" => {
|
||||
let client_id = form_obj
|
||||
.get("clientId")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
let client_secret = form_obj
|
||||
.get("clientSecret")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if client_id.is_empty() || client_secret.is_empty() {
|
||||
return Err("Client ID 和 Client Secret 不能为空".into());
|
||||
}
|
||||
|
||||
let mut entry = Map::new();
|
||||
entry.insert("clientId".into(), Value::String(client_id));
|
||||
entry.insert("clientSecret".into(), Value::String(client_secret));
|
||||
entry.insert("enabled".into(), Value::Bool(true));
|
||||
|
||||
let gateway_token = form_obj
|
||||
.get("gatewayToken")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if !gateway_token.is_empty() {
|
||||
entry.insert("gatewayToken".into(), Value::String(gateway_token.into()));
|
||||
}
|
||||
|
||||
let gateway_password = form_obj
|
||||
.get("gatewayPassword")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if !gateway_password.is_empty() {
|
||||
entry.insert("gatewayPassword".into(), Value::String(gateway_password.into()));
|
||||
}
|
||||
|
||||
channels_map.insert(storage_key, Value::Object(entry));
|
||||
ensure_plugin_allowed(&mut cfg, "dingtalk-connector")?;
|
||||
ensure_chat_completions_enabled(&mut cfg)?;
|
||||
let _ = cleanup_legacy_plugin_backup_dir("dingtalk-connector");
|
||||
}
|
||||
_ => {
|
||||
// 通用平台:直接保存表单字段
|
||||
@@ -258,7 +406,7 @@ pub async fn save_messaging_platform(
|
||||
entry.insert(k.clone(), v.clone());
|
||||
}
|
||||
entry.insert("enabled".into(), Value::Bool(true));
|
||||
channels_map.insert(platform.clone(), Value::Object(entry));
|
||||
channels_map.insert(storage_key, Value::Object(entry));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,9 +426,10 @@ pub async fn remove_messaging_platform(
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<Value, String> {
|
||||
let mut cfg = super::config::load_openclaw_json()?;
|
||||
let storage_key = platform_storage_key(&platform);
|
||||
|
||||
if let Some(channels) = cfg.get_mut("channels").and_then(|c| c.as_object_mut()) {
|
||||
channels.remove(&platform);
|
||||
channels.remove(storage_key);
|
||||
}
|
||||
|
||||
super::config::save_openclaw_json(&cfg)?;
|
||||
@@ -297,10 +446,11 @@ pub async fn toggle_messaging_platform(
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<Value, String> {
|
||||
let mut cfg = super::config::load_openclaw_json()?;
|
||||
let storage_key = platform_storage_key(&platform);
|
||||
|
||||
if let Some(entry) = cfg
|
||||
.get_mut("channels")
|
||||
.and_then(|c| c.get_mut(&platform))
|
||||
.and_then(|c| c.get_mut(storage_key))
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
entry.insert("enabled".into(), Value::Bool(enabled));
|
||||
@@ -328,6 +478,7 @@ pub async fn verify_bot_token(platform: String, form: Value) -> Result<Value, St
|
||||
"telegram" => verify_telegram(&client, form_obj).await,
|
||||
"qqbot" => verify_qqbot(&client, form_obj).await,
|
||||
"feishu" => verify_feishu(&client, form_obj).await,
|
||||
"dingtalk" | "dingtalk-connector" => verify_dingtalk(&client, form_obj).await,
|
||||
_ => Ok(json!({
|
||||
"valid": true,
|
||||
"warnings": ["该平台暂不支持在线校验"]
|
||||
@@ -345,7 +496,7 @@ pub async fn list_configured_platforms() -> Result<Value, String> {
|
||||
for (name, val) in channels {
|
||||
let enabled = val.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
result.push(json!({
|
||||
"id": name,
|
||||
"id": platform_list_id(name),
|
||||
"enabled": enabled
|
||||
}));
|
||||
}
|
||||
@@ -354,6 +505,41 @@ pub async fn list_configured_platforms() -> Result<Value, String> {
|
||||
Ok(json!(result))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_channel_plugin_status(plugin_id: String) -> Result<Value, String> {
|
||||
let plugin_id = plugin_id.trim();
|
||||
if plugin_id.is_empty() {
|
||||
return Err("plugin_id 不能为空".into());
|
||||
}
|
||||
|
||||
let plugin_dir = generic_plugin_dir(plugin_id);
|
||||
let installed = plugin_dir.is_dir() && plugin_install_marker_exists(&plugin_dir);
|
||||
let legacy_backup_detected = legacy_plugin_backup_dir(plugin_id).exists();
|
||||
|
||||
let cfg = super::config::load_openclaw_json().unwrap_or_else(|_| json!({}));
|
||||
let allowed = cfg
|
||||
.get("plugins")
|
||||
.and_then(|p| p.get("allow"))
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().any(|v| v.as_str() == Some(plugin_id)))
|
||||
.unwrap_or(false);
|
||||
let enabled = cfg
|
||||
.get("plugins")
|
||||
.and_then(|p| p.get("entries"))
|
||||
.and_then(|e| e.get(plugin_id))
|
||||
.and_then(|entry| entry.get("enabled"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok(json!({
|
||||
"installed": installed,
|
||||
"path": plugin_dir.to_string_lossy(),
|
||||
"allowed": allowed,
|
||||
"enabled": enabled,
|
||||
"legacyBackupDetected": legacy_backup_detected
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Discord 凭证校验 ──────────────────────────────────────
|
||||
|
||||
async fn verify_discord(
|
||||
@@ -500,23 +686,412 @@ async fn verify_qqbot(
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_plugin_allowed(cfg: &mut Value, plugin_id: &str) -> Result<(), String> {
|
||||
let root = cfg.as_object_mut().ok_or("配置格式错误")?;
|
||||
let plugins = root.entry("plugins").or_insert_with(|| json!({}));
|
||||
let plugins_map = plugins.as_object_mut().ok_or("plugins 节点格式错误")?;
|
||||
|
||||
let allow = plugins_map.entry("allow").or_insert_with(|| json!([]));
|
||||
let allow_arr = allow.as_array_mut().ok_or("plugins.allow 节点格式错误")?;
|
||||
if !allow_arr.iter().any(|v| v.as_str() == Some(plugin_id)) {
|
||||
allow_arr.push(Value::String(plugin_id.to_string()));
|
||||
}
|
||||
|
||||
let entries = plugins_map.entry("entries").or_insert_with(|| json!({}));
|
||||
let entries_map = entries
|
||||
.as_object_mut()
|
||||
.ok_or("plugins.entries 节点格式错误")?;
|
||||
let entry = entries_map
|
||||
.entry(plugin_id.to_string())
|
||||
.or_insert_with(|| json!({}));
|
||||
let entry_obj = entry
|
||||
.as_object_mut()
|
||||
.ok_or("plugins.entries 条目格式错误")?;
|
||||
entry_obj.insert("enabled".into(), Value::Bool(true));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn plugin_backup_root() -> PathBuf {
|
||||
super::openclaw_dir().join("backups").join("plugin-installs")
|
||||
}
|
||||
|
||||
fn qqbot_plugin_dir() -> PathBuf {
|
||||
super::openclaw_dir().join("extensions").join("qqbot")
|
||||
}
|
||||
|
||||
fn qqbot_backup_dir() -> PathBuf {
|
||||
plugin_backup_root().join("qqbot.__clawpanel_backup")
|
||||
}
|
||||
|
||||
fn qqbot_config_backup_path() -> PathBuf {
|
||||
plugin_backup_root().join("openclaw.qqbot-install.bak")
|
||||
}
|
||||
|
||||
fn legacy_plugin_backup_dir(plugin_id: &str) -> PathBuf {
|
||||
super::openclaw_dir()
|
||||
.join("extensions")
|
||||
.join(format!("{plugin_id}.__clawpanel_backup"))
|
||||
}
|
||||
|
||||
fn cleanup_legacy_plugin_backup_dir(plugin_id: &str) -> Result<bool, String> {
|
||||
let legacy_backup = legacy_plugin_backup_dir(plugin_id);
|
||||
if !legacy_backup.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
if legacy_backup.is_dir() {
|
||||
fs::remove_dir_all(&legacy_backup).map_err(|e| format!("清理旧版插件备份失败: {e}"))?;
|
||||
} else {
|
||||
fs::remove_file(&legacy_backup).map_err(|e| format!("清理旧版插件备份失败: {e}"))?;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn plugin_install_marker_exists(plugin_dir: &Path) -> bool {
|
||||
plugin_dir.join("package.json").is_file()
|
||||
|| plugin_dir.join("plugin.ts").is_file()
|
||||
|| plugin_dir.join("index.js").is_file()
|
||||
|| plugin_dir.join("dist").join("index.js").is_file()
|
||||
}
|
||||
|
||||
fn path_to_plugin_entry(path: &Path) -> String {
|
||||
let mut normalized = path.to_string_lossy().replace('\\', "/");
|
||||
while normalized.starts_with("./") {
|
||||
normalized = normalized[2..].to_string();
|
||||
}
|
||||
format!("./{}", normalized.trim_start_matches('/'))
|
||||
}
|
||||
|
||||
fn plugin_entry_exists(plugin_dir: &Path, entry: &str) -> bool {
|
||||
plugin_dir.join(entry.trim_start_matches("./")).is_file()
|
||||
}
|
||||
|
||||
fn synthesize_qqbot_runtime_entry(plugin_dir: &Path) -> Result<String, String> {
|
||||
let channel = plugin_dir.join("src").join("channel.js");
|
||||
let runtime = plugin_dir.join("src").join("runtime.js");
|
||||
if !channel.is_file() || !runtime.is_file() {
|
||||
return Err("QQBot 插件缺少运行时文件,无法自动修复".into());
|
||||
}
|
||||
let dist_dir = plugin_dir.join("dist");
|
||||
fs::create_dir_all(&dist_dir).map_err(|e| format!("创建 dist 目录失败: {e}"))?;
|
||||
let dist_entry = dist_dir.join("index.js");
|
||||
let code = r#"import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import { qqbotPlugin } from "../src/channel.js";
|
||||
import { setQQBotRuntime } from "../src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "qqbot",
|
||||
name: "QQ Bot",
|
||||
description: "QQ Bot channel plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api) {
|
||||
setQQBotRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: qqbotPlugin });
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
"#;
|
||||
fs::write(&dist_entry, code).map_err(|e| format!("写入 dist/index.js 失败: {e}"))?;
|
||||
Ok("./dist/index.js".into())
|
||||
}
|
||||
|
||||
fn repair_qqbot_package_manifest(plugin_dir: &Path) -> Result<String, String> {
|
||||
let package_path = plugin_dir.join("package.json");
|
||||
if !package_path.is_file() {
|
||||
return Err("QQBot 插件缺少 package.json".into());
|
||||
}
|
||||
|
||||
let raw = fs::read_to_string(&package_path).map_err(|e| format!("读取 package.json 失败: {e}"))?;
|
||||
let mut pkg: Value =
|
||||
serde_json::from_str(&raw).map_err(|e| format!("解析 package.json 失败: {e}"))?;
|
||||
|
||||
let desired_entry = if let Some(main) = pkg.get("main").and_then(|v| v.as_str()) {
|
||||
let candidate = path_to_plugin_entry(Path::new(main));
|
||||
if plugin_entry_exists(plugin_dir, &candidate) {
|
||||
candidate
|
||||
} else if main.replace('\\', "/") == "dist/index.js" {
|
||||
synthesize_qqbot_runtime_entry(plugin_dir)?
|
||||
} else {
|
||||
return Err(format!("插件入口文件不存在: {main}"));
|
||||
}
|
||||
} else if plugin_entry_exists(plugin_dir, "./index.js") {
|
||||
"./index.js".into()
|
||||
} else if plugin_dir.join("index.ts").is_file() {
|
||||
synthesize_qqbot_runtime_entry(plugin_dir)?
|
||||
} else {
|
||||
return Err("未找到可用的 QQBot 插件入口".into());
|
||||
};
|
||||
|
||||
for field in ["openclaw", "clawdbot", "moltbot"] {
|
||||
if let Some(obj) = pkg.get_mut(field).and_then(|v| v.as_object_mut()) {
|
||||
obj.insert("extensions".into(), json!([desired_entry.clone()]));
|
||||
}
|
||||
}
|
||||
|
||||
let serialized =
|
||||
serde_json::to_string_pretty(&pkg).map_err(|e| format!("序列化 package.json 失败: {e}"))?;
|
||||
fs::write(&package_path, serialized).map_err(|e| format!("写入 package.json 失败: {e}"))?;
|
||||
Ok(desired_entry)
|
||||
}
|
||||
|
||||
fn restore_path(backup: &Path, target: &Path) -> Result<(), String> {
|
||||
if target.exists() {
|
||||
if target.is_dir() {
|
||||
fs::remove_dir_all(target).map_err(|e| format!("清理目录失败: {e}"))?;
|
||||
} else {
|
||||
fs::remove_file(target).map_err(|e| format!("清理文件失败: {e}"))?;
|
||||
}
|
||||
}
|
||||
if backup.exists() {
|
||||
fs::rename(backup, target).map_err(|e| format!("恢复备份失败: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cleanup_failed_qqbot_install(
|
||||
had_plugin_backup: bool,
|
||||
had_config_backup: bool,
|
||||
) -> Result<(), String> {
|
||||
let plugin_dir = qqbot_plugin_dir();
|
||||
let plugin_backup = qqbot_backup_dir();
|
||||
let config_path = super::openclaw_dir().join("openclaw.json");
|
||||
let config_backup = qqbot_config_backup_path();
|
||||
|
||||
if plugin_dir.exists() {
|
||||
fs::remove_dir_all(&plugin_dir).map_err(|e| format!("清理坏插件目录失败: {e}"))?;
|
||||
}
|
||||
if had_plugin_backup {
|
||||
restore_path(&plugin_backup, &plugin_dir)?;
|
||||
} else if plugin_backup.exists() {
|
||||
fs::remove_dir_all(&plugin_backup).map_err(|e| format!("清理插件备份失败: {e}"))?;
|
||||
}
|
||||
|
||||
if had_config_backup {
|
||||
restore_path(&config_backup, &config_path)?;
|
||||
} else if config_backup.exists() {
|
||||
fs::remove_file(&config_backup).map_err(|e| format!("清理配置备份失败: {e}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generic_plugin_dir(plugin_id: &str) -> PathBuf {
|
||||
super::openclaw_dir().join("extensions").join(plugin_id)
|
||||
}
|
||||
|
||||
fn generic_plugin_backup_dir(plugin_id: &str) -> PathBuf {
|
||||
plugin_backup_root().join(format!("{plugin_id}.__clawpanel_backup"))
|
||||
}
|
||||
|
||||
fn generic_plugin_config_backup_path(plugin_id: &str) -> PathBuf {
|
||||
plugin_backup_root().join(format!("openclaw.{plugin_id}-install.bak"))
|
||||
}
|
||||
|
||||
fn cleanup_failed_plugin_install(
|
||||
plugin_id: &str,
|
||||
had_plugin_backup: bool,
|
||||
had_config_backup: bool,
|
||||
) -> Result<(), String> {
|
||||
let plugin_dir = generic_plugin_dir(plugin_id);
|
||||
let plugin_backup = generic_plugin_backup_dir(plugin_id);
|
||||
let config_path = super::openclaw_dir().join("openclaw.json");
|
||||
let config_backup = generic_plugin_config_backup_path(plugin_id);
|
||||
|
||||
if plugin_dir.exists() {
|
||||
fs::remove_dir_all(&plugin_dir).map_err(|e| format!("清理坏插件目录失败: {e}"))?;
|
||||
}
|
||||
if had_plugin_backup {
|
||||
restore_path(&plugin_backup, &plugin_dir)?;
|
||||
} else if plugin_backup.exists() {
|
||||
fs::remove_dir_all(&plugin_backup).map_err(|e| format!("清理插件备份失败: {e}"))?;
|
||||
}
|
||||
|
||||
if had_config_backup {
|
||||
restore_path(&config_backup, &config_path)?;
|
||||
} else if config_backup.exists() {
|
||||
fs::remove_file(&config_backup).map_err(|e| format!("清理配置备份失败: {e}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── QQ Bot 插件安装(带日志流) ──────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_channel_plugin(
|
||||
app: tauri::AppHandle,
|
||||
package_name: String,
|
||||
plugin_id: String,
|
||||
) -> Result<String, String> {
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::process::Stdio;
|
||||
use tauri::Emitter;
|
||||
|
||||
let package_name = package_name.trim();
|
||||
let plugin_id = plugin_id.trim();
|
||||
if package_name.is_empty() || plugin_id.is_empty() {
|
||||
return Err("package_name 和 plugin_id 不能为空".into());
|
||||
}
|
||||
let plugin_dir = generic_plugin_dir(plugin_id);
|
||||
let plugin_backup = generic_plugin_backup_dir(plugin_id);
|
||||
let config_path = super::openclaw_dir().join("openclaw.json");
|
||||
let config_backup = generic_plugin_config_backup_path(plugin_id);
|
||||
let had_existing_plugin = plugin_dir.exists();
|
||||
let had_existing_config = config_path.exists();
|
||||
|
||||
let _ = app.emit("plugin-log", format!("正在安装插件 {} ...", package_name));
|
||||
let _ = app.emit("plugin-progress", 10);
|
||||
|
||||
fs::create_dir_all(plugin_backup_root()).map_err(|e| format!("创建插件备份目录失败: {e}"))?;
|
||||
if cleanup_legacy_plugin_backup_dir(plugin_id)? {
|
||||
let _ = app.emit("plugin-log", "已清理旧版插件备份目录");
|
||||
}
|
||||
|
||||
if plugin_backup.exists() {
|
||||
let _ = fs::remove_dir_all(&plugin_backup);
|
||||
}
|
||||
if had_existing_plugin {
|
||||
fs::rename(&plugin_dir, &plugin_backup).map_err(|e| format!("备份旧插件失败: {e}"))?;
|
||||
let _ = app.emit("plugin-log", format!("检测到旧插件目录,已备份 {}", plugin_dir.display()));
|
||||
}
|
||||
|
||||
if config_backup.exists() {
|
||||
let _ = fs::remove_file(&config_backup);
|
||||
}
|
||||
if had_existing_config {
|
||||
fs::copy(&config_path, &config_backup).map_err(|e| format!("备份配置失败: {e}"))?;
|
||||
}
|
||||
|
||||
let spawn_result = crate::utils::openclaw_command()
|
||||
.args(["plugins", "install", package_name])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn();
|
||||
let mut child = match spawn_result {
|
||||
Ok(child) => child,
|
||||
Err(e) => {
|
||||
let _ = cleanup_failed_plugin_install(plugin_id, had_existing_plugin, had_existing_config);
|
||||
return Err(format!("启动 openclaw 失败: {}", e));
|
||||
}
|
||||
};
|
||||
|
||||
let stderr = child.stderr.take();
|
||||
let app2 = app.clone();
|
||||
let handle = std::thread::spawn(move || {
|
||||
if let Some(pipe) = stderr {
|
||||
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
|
||||
let _ = app2.emit("plugin-log", &line);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let _ = app.emit("plugin-progress", 30);
|
||||
let mut progress = 30;
|
||||
if let Some(pipe) = child.stdout.take() {
|
||||
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
|
||||
let _ = app.emit("plugin-log", &line);
|
||||
if progress < 90 {
|
||||
progress += 10;
|
||||
let _ = app.emit("plugin-progress", progress);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = handle.join();
|
||||
let _ = app.emit("plugin-progress", 95);
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|e| format!("等待安装进程失败: {}", e))?;
|
||||
if !status.success() {
|
||||
let rollback_err = cleanup_failed_plugin_install(plugin_id, had_existing_plugin, had_existing_config)
|
||||
.err()
|
||||
.unwrap_or_default();
|
||||
let _ = app.emit("plugin-log", format!("插件 {} 安装失败,已回退", package_name));
|
||||
return if rollback_err.is_empty() {
|
||||
Err(format!("插件安装失败:{}", package_name))
|
||||
} else {
|
||||
Err(format!("插件安装失败:{};回退失败:{}", package_name, rollback_err))
|
||||
};
|
||||
}
|
||||
|
||||
let finalize = (|| -> Result<(), String> {
|
||||
let mut cfg = super::config::load_openclaw_json()?;
|
||||
ensure_plugin_allowed(&mut cfg, plugin_id)?;
|
||||
super::config::save_openclaw_json(&cfg)?;
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
if let Err(err) = finalize {
|
||||
let rollback_err = cleanup_failed_plugin_install(plugin_id, had_existing_plugin, had_existing_config)
|
||||
.err()
|
||||
.unwrap_or_default();
|
||||
let _ = app.emit("plugin-log", format!("插件 {} 安装后收尾失败,已回退: {}", package_name, err));
|
||||
return if rollback_err.is_empty() {
|
||||
Err(format!("插件安装失败:{err}"))
|
||||
} else {
|
||||
Err(format!("插件安装失败:{err};回退失败:{rollback_err}"))
|
||||
};
|
||||
}
|
||||
|
||||
if plugin_backup.exists() {
|
||||
let _ = fs::remove_dir_all(&plugin_backup);
|
||||
}
|
||||
if config_backup.exists() {
|
||||
let _ = fs::remove_file(&config_backup);
|
||||
}
|
||||
let _ = app.emit("plugin-progress", 100);
|
||||
let _ = app.emit("plugin-log", format!("插件 {} 安装完成", package_name));
|
||||
Ok("安装成功".into())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_qqbot_plugin(app: tauri::AppHandle) -> Result<String, String> {
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::process::Stdio;
|
||||
use tauri::Emitter;
|
||||
|
||||
let plugin_dir = qqbot_plugin_dir();
|
||||
let plugin_backup = qqbot_backup_dir();
|
||||
let config_path = super::openclaw_dir().join("openclaw.json");
|
||||
let config_backup = qqbot_config_backup_path();
|
||||
let had_existing_plugin = plugin_dir.exists();
|
||||
let had_existing_config = config_path.exists();
|
||||
|
||||
let _ = app.emit("plugin-log", "正在安装 QQBot 社区插件 @sliverp/qqbot ...");
|
||||
let _ = app.emit("plugin-progress", 10);
|
||||
|
||||
let mut child = crate::utils::openclaw_command()
|
||||
fs::create_dir_all(plugin_backup_root()).map_err(|e| format!("创建插件备份目录失败: {e}"))?;
|
||||
if cleanup_legacy_plugin_backup_dir("qqbot")? {
|
||||
let _ = app.emit("plugin-log", "已清理旧版 QQBot 插件备份目录");
|
||||
}
|
||||
|
||||
if plugin_backup.exists() {
|
||||
let _ = fs::remove_dir_all(&plugin_backup);
|
||||
}
|
||||
if had_existing_plugin {
|
||||
fs::rename(&plugin_dir, &plugin_backup).map_err(|e| format!("备份旧 QQBot 插件失败: {e}"))?;
|
||||
}
|
||||
|
||||
if config_backup.exists() {
|
||||
let _ = fs::remove_file(&config_backup);
|
||||
}
|
||||
if had_existing_config {
|
||||
fs::copy(&config_path, &config_backup).map_err(|e| format!("备份配置失败: {e}"))?;
|
||||
}
|
||||
|
||||
let spawn_result = crate::utils::openclaw_command()
|
||||
.args(["plugins", "install", "@sliverp/qqbot@latest"])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动 openclaw 失败: {}", e))?;
|
||||
.spawn();
|
||||
let mut child = match spawn_result {
|
||||
Ok(child) => child,
|
||||
Err(e) => {
|
||||
let _ = cleanup_failed_qqbot_install(had_existing_plugin, had_existing_config);
|
||||
return Err(format!("启动 openclaw 失败: {}", e));
|
||||
}
|
||||
};
|
||||
|
||||
let stderr = child.stderr.take();
|
||||
let app2 = app.clone();
|
||||
@@ -547,15 +1122,48 @@ pub async fn install_qqbot_plugin(app: tauri::AppHandle) -> Result<String, Strin
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|e| format!("等待安装进程失败: {}", e))?;
|
||||
let _ = app.emit("plugin-progress", 100);
|
||||
|
||||
if !status.success() {
|
||||
let _ = app.emit("plugin-log", "QQBot 插件安装失败");
|
||||
return Err("插件安装失败,请查看日志".into());
|
||||
let finalize = (|| -> Result<(), String> {
|
||||
if !status.success() {
|
||||
let _ = app.emit("plugin-log", "安装器返回失败,正在尝试自动修复 QQBot 插件...");
|
||||
}
|
||||
|
||||
let entry = repair_qqbot_package_manifest(&plugin_dir)?;
|
||||
let _ = app.emit("plugin-log", format!("已修正 QQBot 插件入口: {entry}"));
|
||||
|
||||
let mut cfg = super::config::load_openclaw_json()?;
|
||||
ensure_plugin_allowed(&mut cfg, "qqbot")?;
|
||||
super::config::save_openclaw_json(&cfg)?;
|
||||
let _ = app.emit("plugin-log", "已补齐 plugins.allow 与 entries.qqbot.enabled");
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
match finalize {
|
||||
Ok(()) => {
|
||||
let _ = app.emit("plugin-progress", 100);
|
||||
if plugin_backup.exists() {
|
||||
let _ = fs::remove_dir_all(&plugin_backup);
|
||||
}
|
||||
if config_backup.exists() {
|
||||
let _ = fs::remove_file(&config_backup);
|
||||
}
|
||||
let _ = app.emit("plugin-log", "QQBot 插件安装完成");
|
||||
Ok("安装成功".into())
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = app.emit("plugin-log", format!("自动修复失败,正在回退: {err}"));
|
||||
let rollback_err = cleanup_failed_qqbot_install(had_existing_plugin, had_existing_config)
|
||||
.err()
|
||||
.unwrap_or_default();
|
||||
let _ = app.emit("plugin-progress", 100);
|
||||
let _ = app.emit("plugin-log", "QQBot 插件安装失败,已自动回退到安装前状态");
|
||||
if rollback_err.is_empty() {
|
||||
Err(format!("插件安装失败:{err}"))
|
||||
} else {
|
||||
Err(format!("插件安装失败:{err};回退失败:{rollback_err}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = app.emit("plugin-log", "QQBot 插件安装完成");
|
||||
Ok("安装成功".into())
|
||||
}
|
||||
|
||||
// ── Telegram 凭证校验 ─────────────────────────────────────
|
||||
@@ -689,3 +1297,75 @@ async fn verify_feishu(
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// ── 钉钉凭证校验 ──────────────────────────────────────
|
||||
|
||||
async fn verify_dingtalk(
|
||||
client: &reqwest::Client,
|
||||
form: &Map<String, Value>,
|
||||
) -> Result<Value, String> {
|
||||
let client_id = form
|
||||
.get("clientId")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
let client_secret = form
|
||||
.get("clientSecret")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
|
||||
if client_id.is_empty() {
|
||||
return Ok(json!({ "valid": false, "errors": ["Client ID 不能为空"] }));
|
||||
}
|
||||
if client_secret.is_empty() {
|
||||
return Ok(json!({ "valid": false, "errors": ["Client Secret 不能为空"] }));
|
||||
}
|
||||
|
||||
let resp = client
|
||||
.post("https://api.dingtalk.com/v1.0/oauth2/accessToken")
|
||||
.json(&json!({
|
||||
"appKey": client_id,
|
||||
"appSecret": client_secret
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("钉钉 API 连接失败: {}", e))?;
|
||||
|
||||
let body: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("解析响应失败: {}", e))?;
|
||||
|
||||
if body
|
||||
.get("accessToken")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|v| !v.is_empty())
|
||||
.is_some()
|
||||
|| body
|
||||
.get("access_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|v| !v.is_empty())
|
||||
.is_some()
|
||||
{
|
||||
Ok(json!({
|
||||
"valid": true,
|
||||
"errors": [],
|
||||
"details": [
|
||||
format!("AppKey: {}", client_id),
|
||||
"已通过 accessToken 接口校验".to_string()
|
||||
]
|
||||
}))
|
||||
} else {
|
||||
let msg = body
|
||||
.get("message")
|
||||
.or_else(|| body.get("msg"))
|
||||
.or_else(|| body.get("errmsg"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("凭证无效,请检查 Client ID 和 Client Secret");
|
||||
Ok(json!({
|
||||
"valid": false,
|
||||
"errors": [msg]
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::RwLock;
|
||||
|
||||
pub mod agent;
|
||||
pub mod assistant;
|
||||
@@ -20,7 +20,8 @@ pub fn openclaw_dir() -> PathBuf {
|
||||
}
|
||||
|
||||
/// 缓存 enhanced_path 结果,避免每次调用都扫描文件系统
|
||||
static ENHANCED_PATH_CACHE: OnceLock<String> = OnceLock::new();
|
||||
/// 使用 RwLock 替代 OnceLock,支持运行时刷新缓存
|
||||
static ENHANCED_PATH_CACHE: RwLock<Option<String>> = RwLock::new(None);
|
||||
|
||||
/// Tauri 应用启动时 PATH 可能不完整:
|
||||
/// - macOS 从 Finder 启动时 PATH 只有 /usr/bin:/bin:/usr/sbin:/sbin
|
||||
@@ -28,7 +29,26 @@ static ENHANCED_PATH_CACHE: OnceLock<String> = OnceLock::new();
|
||||
///
|
||||
/// 补充 Node.js / npm 常见安装路径
|
||||
pub fn enhanced_path() -> String {
|
||||
ENHANCED_PATH_CACHE.get_or_init(build_enhanced_path).clone()
|
||||
// 先尝试读缓存
|
||||
if let Ok(guard) = ENHANCED_PATH_CACHE.read() {
|
||||
if let Some(ref cached) = *guard {
|
||||
return cached.clone();
|
||||
}
|
||||
}
|
||||
// 缓存为空,重新构建
|
||||
let path = build_enhanced_path();
|
||||
if let Ok(mut guard) = ENHANCED_PATH_CACHE.write() {
|
||||
*guard = Some(path.clone());
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// 刷新 enhanced_path 缓存,使新设置的 Node.js 路径立即生效(无需重启应用)
|
||||
pub fn refresh_enhanced_path() {
|
||||
let new_path = build_enhanced_path();
|
||||
if let Ok(mut guard) = ENHANCED_PATH_CACHE.write() {
|
||||
*guard = Some(new_path);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_enhanced_path() -> String {
|
||||
@@ -242,17 +262,20 @@ fn build_enhanced_path() -> String {
|
||||
}
|
||||
|
||||
let mut parts: Vec<&str> = vec![];
|
||||
if !current.is_empty() {
|
||||
parts.push(¤t);
|
||||
}
|
||||
// 用户自定义路径优先级最高
|
||||
if let Some(ref cp) = custom_path {
|
||||
parts.push(cp.as_str());
|
||||
}
|
||||
// 然后是默认扫描到的路径
|
||||
for p in &extra {
|
||||
if std::path::Path::new(p).exists() {
|
||||
parts.push(p.as_str());
|
||||
}
|
||||
}
|
||||
// 最后是系统 PATH
|
||||
if !current.is_empty() {
|
||||
parts.push(¤t);
|
||||
}
|
||||
parts.join(";")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,3 +191,70 @@ pub fn check_pairing_status() -> Result<bool, String> {
|
||||
|
||||
Ok(paired.get(device_id).is_some())
|
||||
}
|
||||
|
||||
async fn run_pairing_command(args: Vec<String>) -> Result<String, String> {
|
||||
let mut cmd = crate::utils::openclaw_command_async();
|
||||
cmd.args(args);
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("执行 openclaw 失败: {e}"))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let message = match (stdout.is_empty(), stderr.is_empty()) {
|
||||
(false, false) => format!("{stdout}\n{stderr}"),
|
||||
(false, true) => stdout,
|
||||
(true, false) => stderr,
|
||||
(true, true) => String::new(),
|
||||
};
|
||||
|
||||
if output.status.success() {
|
||||
Ok(if message.is_empty() {
|
||||
"操作完成".into()
|
||||
} else {
|
||||
message
|
||||
})
|
||||
} else {
|
||||
Err(if message.is_empty() {
|
||||
format!("命令执行失败: {}", output.status)
|
||||
} else {
|
||||
message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn pairing_list_channel(channel: String) -> Result<String, String> {
|
||||
let channel = channel.trim();
|
||||
if channel.is_empty() {
|
||||
return Err("channel 不能为空".into());
|
||||
}
|
||||
run_pairing_command(vec!["pairing".into(), "list".into(), channel.into()]).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn pairing_approve_channel(
|
||||
channel: String,
|
||||
code: String,
|
||||
notify: bool,
|
||||
) -> Result<String, String> {
|
||||
let channel = channel.trim();
|
||||
let code = code.trim();
|
||||
if channel.is_empty() {
|
||||
return Err("channel 不能为空".into());
|
||||
}
|
||||
if code.is_empty() {
|
||||
return Err("配对码不能为空".into());
|
||||
}
|
||||
let mut args = vec![
|
||||
"pairing".into(),
|
||||
"approve".into(),
|
||||
channel.into(),
|
||||
code.into(),
|
||||
];
|
||||
if notify {
|
||||
args.push("--notify".into());
|
||||
}
|
||||
run_pairing_command(args).await
|
||||
}
|
||||
|
||||
@@ -93,11 +93,17 @@ pub fn run() {
|
||||
config::write_panel_config,
|
||||
config::get_npm_registry,
|
||||
config::set_npm_registry,
|
||||
config::check_git,
|
||||
config::auto_install_git,
|
||||
config::configure_git_https,
|
||||
config::invalidate_path_cache,
|
||||
// 设备密钥 + Gateway 握手
|
||||
device::create_connect_frame,
|
||||
// 设备配对
|
||||
pairing::auto_pair_device,
|
||||
pairing::check_pairing_status,
|
||||
pairing::pairing_list_channel,
|
||||
pairing::pairing_approve_channel,
|
||||
// 服务
|
||||
service::get_services_status,
|
||||
service::start_service,
|
||||
@@ -149,6 +155,8 @@ pub fn run() {
|
||||
messaging::toggle_messaging_platform,
|
||||
messaging::verify_bot_token,
|
||||
messaging::list_configured_platforms,
|
||||
messaging::get_channel_plugin_status,
|
||||
messaging::install_channel_plugin,
|
||||
messaging::install_qqbot_plugin,
|
||||
// Skills 管理(openclaw skills CLI)
|
||||
skills::skills_list,
|
||||
|
||||
@@ -1,16 +1,40 @@
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
/// Windows: 在 PATH 中查找 openclaw.cmd 的完整路径
|
||||
/// 避免通过 `cmd /c openclaw` 调用时 npm .cmd shim 中的引号导致
|
||||
/// "\"node\"" is not recognized 错误
|
||||
#[cfg(target_os = "windows")]
|
||||
fn find_openclaw_cmd() -> Option<std::path::PathBuf> {
|
||||
let path = crate::commands::enhanced_path();
|
||||
for dir in path.split(';') {
|
||||
let candidate = std::path::Path::new(dir).join("openclaw.cmd");
|
||||
if candidate.exists() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 跨平台获取 openclaw 命令的方法(同步版本)
|
||||
/// 在 Windows 上使用 `cmd /c openclaw` 以兼容全局 npm 路径下的 `.cmd` 脚本
|
||||
#[allow(dead_code)]
|
||||
pub fn openclaw_command() -> std::process::Command {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
let enhanced = crate::commands::enhanced_path();
|
||||
// 优先:找到 openclaw.cmd 完整路径,用 cmd /c "完整路径" 避免引号问题
|
||||
if let Some(cmd_path) = find_openclaw_cmd() {
|
||||
let mut cmd = std::process::Command::new("cmd");
|
||||
cmd.arg("/c").arg(cmd_path);
|
||||
cmd.env("PATH", &enhanced);
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
return cmd;
|
||||
}
|
||||
// 兜底:直接用 cmd /c openclaw
|
||||
let mut cmd = std::process::Command::new("cmd");
|
||||
cmd.arg("/c").arg("openclaw");
|
||||
cmd.env("PATH", crate::commands::enhanced_path());
|
||||
cmd.env("PATH", &enhanced);
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
cmd
|
||||
}
|
||||
@@ -27,9 +51,19 @@ pub fn openclaw_command_async() -> tokio::process::Command {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
let enhanced = crate::commands::enhanced_path();
|
||||
// 优先:找到 openclaw.cmd 完整路径
|
||||
if let Some(cmd_path) = find_openclaw_cmd() {
|
||||
let mut cmd = tokio::process::Command::new("cmd");
|
||||
cmd.arg("/c").arg(cmd_path);
|
||||
cmd.env("PATH", &enhanced);
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
return cmd;
|
||||
}
|
||||
// 兜底
|
||||
let mut cmd = tokio::process::Command::new("cmd");
|
||||
cmd.arg("/c").arg("openclaw");
|
||||
cmd.env("PATH", crate::commands::enhanced_path());
|
||||
cmd.env("PATH", &enhanced);
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
cmd
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
|
||||
"productName": "ClawPanel",
|
||||
"version": "0.7.4",
|
||||
"version": "0.8.0",
|
||||
"identifier": "ai.openclaw.clawpanel",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
* 社区引导浮窗 — 适时提醒用户加群 & Star
|
||||
*
|
||||
* 触发条件(全部满足才弹出):
|
||||
* 1. 累计打开 ≥ 3 次
|
||||
* 2. 首次打开距今 ≥ 3 天
|
||||
* 3. 上次弹出距今 ≥ 14 天
|
||||
* 1. 累计打开 ≥ 2 次
|
||||
* 2. 首次打开距今 ≥ 1 天
|
||||
* 3. 今天未关闭过(每天最多弹一次)
|
||||
* 4. 未被永久关闭
|
||||
* 5. 由外部在"正向时机"主动调用 tryShow()
|
||||
* 5. 由外部在"正向时机"主动调用 tryShow()(如保存配置成功、Gateway 启动成功)
|
||||
* 6. 不在聊天/助手页面时触发(避免打断对话)
|
||||
*/
|
||||
|
||||
const KEYS = {
|
||||
@@ -14,12 +15,13 @@ const KEYS = {
|
||||
openCount: 'clawpanel_open_count',
|
||||
lastShown: 'clawpanel_engage_shown',
|
||||
never: 'clawpanel_engage_never',
|
||||
todayDismiss: 'clawpanel_engage_today',
|
||||
}
|
||||
|
||||
const DAY = 86400000
|
||||
const MIN_OPENS = 3
|
||||
const MIN_DAYS = 3
|
||||
const COOLDOWN_DAYS = 14
|
||||
const MIN_OPENS = 2
|
||||
const MIN_DAYS = 1
|
||||
const COOLDOWN_DAYS = 1
|
||||
const AUTO_DISMISS_MS = 25000
|
||||
|
||||
// 启动时记录打开次数
|
||||
@@ -33,14 +35,22 @@ function _track() {
|
||||
}
|
||||
_track()
|
||||
|
||||
function _todayKey() {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`
|
||||
}
|
||||
|
||||
function _canShow() {
|
||||
if (localStorage.getItem(KEYS.never) === '1') return false
|
||||
const count = parseInt(localStorage.getItem(KEYS.openCount) || '0')
|
||||
if (count < MIN_OPENS) return false
|
||||
const first = parseInt(localStorage.getItem(KEYS.firstOpen) || '0')
|
||||
if (Date.now() - first < MIN_DAYS * DAY) return false
|
||||
const last = parseInt(localStorage.getItem(KEYS.lastShown) || '0')
|
||||
if (Date.now() - last < COOLDOWN_DAYS * DAY) return false
|
||||
// 今天已经弹过/关闭过 → 不再弹
|
||||
if (localStorage.getItem(KEYS.todayDismiss) === _todayKey()) return false
|
||||
// 避免在聊天/助手页面打断对话
|
||||
const hash = location.hash || ''
|
||||
if (hash.includes('/chat') || hash.includes('/assistant')) return false
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -117,24 +127,22 @@ export function tryShowEngagement() {
|
||||
</div>
|
||||
|
||||
<div class="engage-footer">
|
||||
<span class="engage-never">不再提醒</span>
|
||||
<span class="engage-today-dismiss">今日不再弹窗</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
document.body.appendChild(overlay)
|
||||
requestAnimationFrame(() => overlay.classList.add('engage-visible'))
|
||||
|
||||
function dismiss() {
|
||||
function dismiss(markToday = true) {
|
||||
if (markToday) localStorage.setItem(KEYS.todayDismiss, _todayKey())
|
||||
overlay.classList.remove('engage-visible')
|
||||
setTimeout(() => { overlay.remove(); _showing = false }, 250)
|
||||
}
|
||||
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) dismiss() })
|
||||
overlay.querySelector('.engage-close').onclick = dismiss
|
||||
overlay.querySelector('.engage-never').onclick = () => {
|
||||
localStorage.setItem(KEYS.never, '1')
|
||||
dismiss()
|
||||
}
|
||||
overlay.querySelector('.engage-close').onclick = () => dismiss()
|
||||
overlay.querySelector('.engage-today-dismiss').onclick = () => dismiss(true)
|
||||
overlay.querySelector('[data-action="copy-share"]').onclick = () => {
|
||||
navigator.clipboard.writeText(shareText).then(() => {
|
||||
const desc = overlay.querySelector('[data-action="copy-share"] .engage-action-desc')
|
||||
|
||||
@@ -13,7 +13,7 @@ const NAV_ITEMS_FULL = [
|
||||
section: '概览',
|
||||
items: [
|
||||
{ route: '/dashboard', label: '仪表盘', icon: 'dashboard' },
|
||||
{ route: '/assistant', label: 'AI 助手', icon: 'assistant' },
|
||||
{ route: '/assistant', label: '晴辰助手', icon: 'assistant' },
|
||||
{ route: '/chat', label: '实时聊天', icon: 'chat' },
|
||||
{ route: '/services', label: '服务管理', icon: 'services' },
|
||||
{ route: '/logs', label: '日志查看', icon: 'logs' },
|
||||
@@ -62,7 +62,7 @@ const NAV_ITEMS_SETUP = [
|
||||
section: '',
|
||||
items: [
|
||||
{ route: '/setup', label: '初始设置', icon: 'setup' },
|
||||
{ route: '/assistant', label: 'AI 助手', icon: 'assistant' },
|
||||
{ route: '/assistant', label: '晴辰助手', icon: 'assistant' },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -46,6 +46,32 @@ function escapeHtml(str) {
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
// 预加载 Tauri convertFileSrc
|
||||
let _convertFileSrc = null
|
||||
if (typeof window !== 'undefined' && window.__TAURI_INTERNALS__) {
|
||||
import('@tauri-apps/api/core').then(m => { _convertFileSrc = m.convertFileSrc }).catch(() => {})
|
||||
}
|
||||
|
||||
/** 将本地文件路径转换为可加载的 URL */
|
||||
function resolveImageSrc(src) {
|
||||
if (!src) return src
|
||||
// 已经是 http/https/data URL → 直接返回
|
||||
if (/^(https?|data|blob):/.test(src)) return src
|
||||
// Windows 绝对路径 (C:\... or C:/...)
|
||||
const isWinPath = /^[A-Za-z]:[\\/]/.test(src)
|
||||
// Unix 绝对路径 (/Users/... /home/... /tmp/...)
|
||||
const isUnixPath = /^\/[^/]/.test(src)
|
||||
if (isWinPath || isUnixPath) {
|
||||
// Tauri 环境:使用 convertFileSrc 转换为 asset protocol URL
|
||||
if (_convertFileSrc) {
|
||||
try { return _convertFileSrc(src) } catch {}
|
||||
}
|
||||
// Tauri 未就绪或 Web 模式:返回原始路径(onerror 会处理显示)
|
||||
return src
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
export function renderMarkdown(text) {
|
||||
if (!text) return ''
|
||||
let html = text
|
||||
@@ -122,7 +148,10 @@ function inlineFormat(text) {
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/__(.+?)__/g, '<strong>$1</strong>')
|
||||
.replace(/_(.+?)_/g, '<em>$1</em>')
|
||||
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" class="msg-img" />')
|
||||
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) => {
|
||||
const safeSrc = resolveImageSrc(src.trim())
|
||||
return `<img src="${safeSrc}" alt="${alt}" class="msg-img" onerror="this.onerror=null;this.style.display='none';this.insertAdjacentHTML('afterend','<span style=\\'color:var(--text-tertiary);font-size:12px\\'>[图片无法加载: ${escapeHtml(src)}]</span>')" />`
|
||||
})
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
|
||||
const safe = /^https?:|^mailto:/i.test(url.trim()) ? url : '#'
|
||||
return `<a href="${safe}" target="_blank" rel="noopener">${label}</a>`
|
||||
|
||||
113
src/lib/mirror-urls.js
Normal file
113
src/lib/mirror-urls.js
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* GitHub / Gitee 镜像 URL 管理
|
||||
* 国内用户自动使用 Gitee 镜像,解决 GitHub 访问慢/不可达的问题
|
||||
*/
|
||||
|
||||
const GITHUB_ORG = 'https://github.com/qingchencloud'
|
||||
const GITEE_ORG = 'https://gitee.com/QtCodeCreators'
|
||||
const GITHUB_RAW = 'https://raw.githubusercontent.com/qingchencloud'
|
||||
const GITEE_RAW = 'https://gitee.com/QtCodeCreators'
|
||||
|
||||
// 仓库名映射(GitHub → Gitee,名称不同时需映射)
|
||||
const REPO_MAP = {
|
||||
clawpanel: 'clawpanel',
|
||||
clawapp: 'clawapp',
|
||||
cftunnel: 'cftunnel',
|
||||
'openclaw-zh': 'openclaw-zh',
|
||||
}
|
||||
|
||||
/**
|
||||
* 探测 GitHub 是否可达(3s 超时)
|
||||
* 结果缓存 5 分钟
|
||||
*/
|
||||
let _githubReachable = null
|
||||
let _lastCheck = 0
|
||||
const CHECK_TTL = 300000 // 5min
|
||||
|
||||
async function isGithubReachable() {
|
||||
const now = Date.now()
|
||||
if (_githubReachable !== null && now - _lastCheck < CHECK_TTL) return _githubReachable
|
||||
try {
|
||||
const ctrl = new AbortController()
|
||||
const timer = setTimeout(() => ctrl.abort(), 3000)
|
||||
await fetch('https://github.com/favicon.ico', { method: 'HEAD', mode: 'no-cors', signal: ctrl.signal })
|
||||
clearTimeout(timer)
|
||||
_githubReachable = true
|
||||
} catch {
|
||||
_githubReachable = false
|
||||
}
|
||||
_lastCheck = now
|
||||
return _githubReachable
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取仓库 URL(优先 GitHub,不可达时用 Gitee)
|
||||
* @param {string} repo - 仓库名,如 'clawpanel'
|
||||
* @param {string} [path] - 可选路径,如 '/releases'、'/issues/new'
|
||||
*/
|
||||
export async function repoUrl(repo, path = '') {
|
||||
const giteeRepo = REPO_MAP[repo] || repo
|
||||
if (await isGithubReachable()) {
|
||||
return `${GITHUB_ORG}/${repo}${path}`
|
||||
}
|
||||
return `${GITEE_ORG}/${giteeRepo}${path}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步版本:同时返回 GitHub 和 Gitee URL,让 UI 可以展示两个链接
|
||||
* @param {string} repo
|
||||
* @param {string} [path]
|
||||
*/
|
||||
export function repoBothUrls(repo, path = '') {
|
||||
const giteeRepo = REPO_MAP[repo] || repo
|
||||
return {
|
||||
github: `${GITHUB_ORG}/${repo}${path}`,
|
||||
gitee: `${GITEE_ORG}/${giteeRepo}${path}`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 raw 文件 URL(用于 deploy.sh 等脚本下载)
|
||||
* GitHub: raw.githubusercontent.com/org/repo/branch/file
|
||||
* Gitee: gitee.com/org/repo/raw/branch/file
|
||||
* @param {string} repo
|
||||
* @param {string} branch
|
||||
* @param {string} filePath
|
||||
*/
|
||||
export async function rawFileUrl(repo, branch, filePath) {
|
||||
const giteeRepo = REPO_MAP[repo] || repo
|
||||
if (await isGithubReachable()) {
|
||||
return `${GITHUB_RAW}/${repo}/${branch}/${filePath}`
|
||||
}
|
||||
return `${GITEE_RAW}/${giteeRepo}/raw/${branch}/${filePath}`
|
||||
}
|
||||
|
||||
/**
|
||||
* deploy.sh 下载命令(国内用户自动切换为 Gitee 源)
|
||||
*/
|
||||
export function deployCommand() {
|
||||
return {
|
||||
github: `curl -fsSL ${GITHUB_RAW}/clawpanel/main/deploy.sh | bash`,
|
||||
gitee: `curl -fsSL ${GITEE_RAW}/clawpanel/raw/main/deploy.sh | bash`,
|
||||
}
|
||||
}
|
||||
|
||||
/** 强制标记 GitHub 不可达(用户手动切换时调用) */
|
||||
export function forceGiteeMirror() {
|
||||
_githubReachable = false
|
||||
_lastCheck = Date.now()
|
||||
}
|
||||
|
||||
/** 强制标记 GitHub 可达 */
|
||||
export function forceGithubDirect() {
|
||||
_githubReachable = true
|
||||
_lastCheck = Date.now()
|
||||
}
|
||||
|
||||
/** 当前是否使用 Gitee 镜像 */
|
||||
export function isUsingGitee() {
|
||||
return _githubReachable === false
|
||||
}
|
||||
|
||||
/** 手动触发一次 GitHub 可达性检测 */
|
||||
export { isGithubReachable as checkGithubReachable }
|
||||
@@ -170,8 +170,8 @@ export const api = {
|
||||
uninstallGateway: () => invoke('uninstall_gateway'),
|
||||
getNpmRegistry: () => cachedInvoke('get_npm_registry', {}, 30000),
|
||||
setNpmRegistry: (registry) => { invalidate('get_npm_registry'); return invoke('set_npm_registry', { registry }) },
|
||||
testModel: (baseUrl, apiKey, modelId) => invoke('test_model', { baseUrl, apiKey, modelId }),
|
||||
listRemoteModels: (baseUrl, apiKey) => invoke('list_remote_models', { baseUrl, apiKey }),
|
||||
testModel: (baseUrl, apiKey, modelId, apiType = null) => invoke('test_model', { baseUrl, apiKey, modelId, apiType }),
|
||||
listRemoteModels: (baseUrl, apiKey, apiType = null) => invoke('list_remote_models', { baseUrl, apiKey, apiType }),
|
||||
|
||||
// Agent 管理
|
||||
listAgents: () => cachedInvoke('list_agents'),
|
||||
@@ -199,7 +199,9 @@ export const api = {
|
||||
toggleMessagingPlatform: (platform, enabled) => { invalidate('list_configured_platforms'); return invoke('toggle_messaging_platform', { platform, enabled }) },
|
||||
verifyBotToken: (platform, form) => invoke('verify_bot_token', { platform, form }),
|
||||
listConfiguredPlatforms: () => cachedInvoke('list_configured_platforms', {}, 5000),
|
||||
getChannelPluginStatus: (pluginId) => invoke('get_channel_plugin_status', { pluginId }),
|
||||
installQqbotPlugin: () => invoke('install_qqbot_plugin'),
|
||||
installChannelPlugin: (packageName, pluginId) => invoke('install_channel_plugin', { packageName, pluginId }),
|
||||
|
||||
// 面板配置 (clawpanel.json)
|
||||
readPanelConfig: () => invoke('read_panel_config'),
|
||||
@@ -211,7 +213,11 @@ 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 }),
|
||||
saveCustomNodePath: (nodeDir) => invoke('save_custom_node_path', { nodeDir }).then(r => { invalidate('check_node'); invoke('invalidate_path_cache').catch(() => {}); return r }),
|
||||
invalidatePathCache: () => invoke('invalidate_path_cache'),
|
||||
checkGit: () => cachedInvoke('check_git', {}, 60000),
|
||||
autoInstallGit: () => invoke('auto_install_git'),
|
||||
configureGitHttps: () => invoke('configure_git_https'),
|
||||
getDeployConfig: () => cachedInvoke('get_deploy_config'),
|
||||
patchModelVision: () => invoke('patch_model_vision'),
|
||||
checkPanelUpdate: () => invoke('check_panel_update'),
|
||||
@@ -229,6 +235,8 @@ export const api = {
|
||||
// 设备配对
|
||||
autoPairDevice: () => invoke('auto_pair_device'),
|
||||
checkPairingStatus: () => invoke('check_pairing_status'),
|
||||
pairingListChannel: (channel) => invoke('pairing_list_channel', { channel }),
|
||||
pairingApproveChannel: (channel, code, notify = false) => invoke('pairing_approve_channel', { channel, code, notify }),
|
||||
|
||||
// AI 助手工具
|
||||
assistantExec: (command, cwd) => invoke('assistant_exec', { command, cwd: cwd || null }),
|
||||
|
||||
17
src/main.js
17
src/main.js
@@ -486,8 +486,9 @@ function setupGatewayBanner() {
|
||||
banner.innerHTML = `
|
||||
<div class="gw-banner-content">
|
||||
<span class="gw-banner-icon">${statusIcon('warn', 16)}</span>
|
||||
<span>Gateway 未启动,部分功能不可用</span>
|
||||
<button class="btn btn-sm btn-primary" id="btn-gw-start">启动 Gateway</button>
|
||||
<span>Gateway 未运行</span>
|
||||
<button class="btn btn-sm btn-primary" id="btn-gw-start" style="margin-left:auto">启动</button>
|
||||
<a class="btn btn-sm btn-ghost" href="#/services" style="color:inherit;font-size:12px">服务管理</a>
|
||||
<button class="gw-banner-close" id="btn-gw-dismiss" title="关闭提示">×</button>
|
||||
</div>
|
||||
`
|
||||
@@ -503,14 +504,16 @@ function setupGatewayBanner() {
|
||||
try {
|
||||
await api.startService('ai.openclaw.gateway')
|
||||
} catch (err) {
|
||||
const errMsg = err.message || String(err)
|
||||
const errMsg = (err.message || String(err)).slice(0, 120)
|
||||
banner.innerHTML = `
|
||||
<div class="gw-banner-content">
|
||||
<div class="gw-banner-content" style="flex-wrap:wrap">
|
||||
<span class="gw-banner-icon">${statusIcon('warn', 16)}</span>
|
||||
<span>启动失败: ${errMsg}</span>
|
||||
<button class="btn btn-sm btn-primary" id="btn-gw-start">重试</button>
|
||||
<a class="btn btn-sm btn-ghost" href="#/logs" style="color:inherit;text-decoration:underline">查看日志</a>
|
||||
<span>启动失败</span>
|
||||
<button class="btn btn-sm btn-primary" id="btn-gw-start" style="margin-left:auto">重试</button>
|
||||
<a class="btn btn-sm btn-ghost" href="#/services" style="color:inherit;font-size:12px">服务管理</a>
|
||||
<a class="btn btn-sm btn-ghost" href="#/logs" style="color:inherit;font-size:12px">查看日志</a>
|
||||
</div>
|
||||
<div style="font-size:11px;opacity:0.7;margin-top:4px;font-family:monospace;word-break:break-all">${errMsg}</div>
|
||||
`
|
||||
update(false)
|
||||
return
|
||||
|
||||
@@ -402,19 +402,14 @@ async function checkHotUpdate(cards, panelVersion) {
|
||||
}
|
||||
})
|
||||
} else if (!info.compatible) {
|
||||
meta.innerHTML = '<span style="color:var(--text-tertiary)">需要更新完整安装包</span> <a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawpanel/releases" target="_blank" rel="noopener" style="padding:2px 8px;font-size:var(--font-size-xs)">下载</a>'
|
||||
meta.innerHTML = '<span style="color:var(--text-tertiary)">需要更新完整安装包</span> <a class="btn btn-primary btn-sm" href="https://claw.qt.cool" target="_blank" rel="noopener" style="padding:2px 8px;font-size:var(--font-size-xs)">前往官网下载</a> <a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawpanel/releases" target="_blank" rel="noopener" style="padding:2px 8px;font-size:var(--font-size-xs)">GitHub</a>'
|
||||
} else {
|
||||
meta.innerHTML = '<span style="color:var(--success)">已是最新</span>'
|
||||
}
|
||||
} catch (err) {
|
||||
const meta = el()
|
||||
if (!meta) return
|
||||
const msg = String(err?.message || err || '')
|
||||
if (msg.includes('403') || msg.includes('404') || msg.includes('rate limit')) {
|
||||
meta.innerHTML = '<span style="color:var(--text-tertiary)">暂无法检查更新</span>'
|
||||
} else {
|
||||
meta.innerHTML = '<span style="color:var(--text-tertiary)">检查更新失败</span>'
|
||||
}
|
||||
meta.innerHTML = `<span style="color:var(--text-tertiary)">暂无法检查更新</span> <a class="btn btn-secondary btn-sm" href="https://claw.qt.cool" target="_blank" rel="noopener" style="padding:2px 8px;font-size:var(--font-size-xs)">前往官网下载</a>`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,6 +477,7 @@ const PROJECTS = [
|
||||
name: 'ClawPanel',
|
||||
desc: 'OpenClaw 可视化管理面板,Tauri v2 桌面应用',
|
||||
url: 'https://github.com/qingchencloud/clawpanel',
|
||||
gitee: 'https://gitee.com/QtCodeCreators/clawpanel',
|
||||
},
|
||||
{
|
||||
name: 'ClawApp',
|
||||
@@ -507,6 +503,7 @@ function renderProjects(page) {
|
||||
</div>
|
||||
<div class="service-actions">
|
||||
<a class="btn btn-secondary btn-sm" href="${p.url}" target="_blank" rel="noopener">GitHub</a>
|
||||
${p.gitee ? `<a class="btn btn-secondary btn-sm" href="${p.gitee}" target="_blank" rel="noopener">国内镜像</a>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')
|
||||
@@ -531,6 +528,9 @@ function renderContribute(page) {
|
||||
<a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawpanel/blob/main/CONTRIBUTING.md" target="_blank" rel="noopener">贡献指南</a>
|
||||
<a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawpanel/issues" target="_blank" rel="noopener">查看 Issues</a>
|
||||
</div>
|
||||
<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">
|
||||
国内镜像:<a href="https://gitee.com/QtCodeCreators/clawpanel" target="_blank" rel="noopener" style="color:var(--accent)">Gitee</a>(无法访问 GitHub 时可用)
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export async function render() {
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Agent 管理</h1>
|
||||
<p class="page-desc">创建和管理 OpenClaw 智能体,配置身份、模型和工作区</p>
|
||||
<p class="page-desc">创建和管理 OpenClaw Agent,配置身份、模型和工作区</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="btn btn-primary" id="btn-add-agent">+ 新建 Agent</button>
|
||||
|
||||
@@ -22,16 +22,7 @@ const QTCOOL = {
|
||||
defaultKey: 'sk-0JDu7hyc51ZKD4iNebpFu07EUEhXmVVc',
|
||||
site: 'https://gpt.qt.cool/',
|
||||
usageUrl: 'https://gpt.qt.cool/user?key=',
|
||||
models: [
|
||||
{ id: 'gpt-5.2-codex', name: 'GPT-5.2 Codex', hot: true },
|
||||
{ id: 'gpt-5.2', name: 'GPT-5.2' },
|
||||
{ id: 'gpt-5.1-codex-max', name: 'GPT-5.1 Codex Max' },
|
||||
{ id: 'gpt-5.1-codex-mini', name: 'GPT-5.1 Codex Mini' },
|
||||
{ id: 'gpt-5.1-codex', name: 'GPT-5.1 Codex' },
|
||||
{ id: 'gpt-5.1', name: 'GPT-5.1' },
|
||||
{ id: 'gpt-5-codex', name: 'GPT-5 Codex' },
|
||||
{ id: 'gpt-5', name: 'GPT-5' },
|
||||
]
|
||||
models: [] // 始终从 API 动态获取最新模型列表
|
||||
}
|
||||
|
||||
// ── 图片文件存储(通过 Tauri 后端持久化到 ~/.openclaw/clawpanel/images/)──
|
||||
@@ -64,11 +55,48 @@ const DEFAULT_MODE = 'execute'
|
||||
|
||||
// ── API 类型 ──
|
||||
const API_TYPES = [
|
||||
{ value: 'openai', label: 'OpenAI 兼容 (最常用)' },
|
||||
{ value: 'anthropic', label: 'Anthropic 原生' },
|
||||
{ value: 'openai-completions', label: 'OpenAI 兼容 (最常用)' },
|
||||
{ value: 'anthropic-messages', label: 'Anthropic 原生' },
|
||||
{ value: 'google-gemini', label: 'Google Gemini' },
|
||||
]
|
||||
|
||||
function normalizeApiType(raw) {
|
||||
const type = (raw || '').trim()
|
||||
if (type === 'anthropic' || type === 'anthropic-messages') return 'anthropic-messages'
|
||||
if (type === 'google-gemini') return 'google-gemini'
|
||||
if (type === 'openai' || type === 'openai-completions' || type === 'openai-responses') return 'openai-completions'
|
||||
return 'openai-completions'
|
||||
}
|
||||
|
||||
function requiresApiKey(apiType) {
|
||||
const type = normalizeApiType(apiType)
|
||||
return type === 'anthropic-messages' || type === 'google-gemini'
|
||||
}
|
||||
|
||||
function apiHintText(apiType) {
|
||||
return {
|
||||
'openai-completions': '自动兼容 Chat Completions 和 Responses API;Ollama 可留空 API Key',
|
||||
'anthropic-messages': '使用 Anthropic Messages API(/v1/messages)',
|
||||
'google-gemini': '使用 Gemini generateContent API',
|
||||
}[normalizeApiType(apiType)] || '自动兼容 Chat Completions 和 Responses API;Ollama 可留空 API Key'
|
||||
}
|
||||
|
||||
function apiBasePlaceholder(apiType) {
|
||||
return {
|
||||
'openai-completions': 'https://api.openai.com/v1 或 http://127.0.0.1:11434',
|
||||
'anthropic-messages': 'https://api.anthropic.com',
|
||||
'google-gemini': 'https://generativelanguage.googleapis.com/v1beta',
|
||||
}[normalizeApiType(apiType)] || 'https://api.openai.com/v1'
|
||||
}
|
||||
|
||||
function apiKeyPlaceholder(apiType) {
|
||||
return {
|
||||
'openai-completions': 'sk-...(Ollama 可留空)',
|
||||
'anthropic-messages': 'sk-ant-...',
|
||||
'google-gemini': 'AIza...',
|
||||
}[normalizeApiType(apiType)] || 'sk-...'
|
||||
}
|
||||
|
||||
// ── 系统提示词 ──
|
||||
const DEFAULT_NAME = '晴辰助手'
|
||||
const DEFAULT_PERSONALITY = '专业、友善、简洁。善于分析问题,给出可操作的解决方案。'
|
||||
@@ -1315,7 +1343,7 @@ function loadConfig() {
|
||||
if (!_config.assistantPersonality) _config.assistantPersonality = DEFAULT_PERSONALITY
|
||||
if (!_config.tools) _config.tools = { terminal: false, fileOps: false, webSearch: false }
|
||||
if (!_config.mode) _config.mode = DEFAULT_MODE
|
||||
if (!_config.apiType) _config.apiType = 'openai'
|
||||
_config.apiType = normalizeApiType(_config.apiType)
|
||||
if (_config.autoRounds === undefined) _config.autoRounds = 8
|
||||
if (!Array.isArray(_config.knowledgeFiles)) _config.knowledgeFiles = []
|
||||
return _config
|
||||
@@ -1397,13 +1425,18 @@ function autoTitle(session) {
|
||||
// ── AI API 调用(自动兼容 Chat Completions + Responses API)──
|
||||
|
||||
function cleanBaseUrl(raw, apiType) {
|
||||
let base = raw.replace(/\/+$/, '')
|
||||
let base = (raw || '').replace(/\/+$/, '')
|
||||
base = base.replace(/\/api\/chat\/?$/, '')
|
||||
base = base.replace(/\/api\/generate\/?$/, '')
|
||||
base = base.replace(/\/api\/tags\/?$/, '')
|
||||
base = base.replace(/\/api\/?$/, '')
|
||||
base = base.replace(/\/chat\/completions\/?$/, '')
|
||||
base = base.replace(/\/completions\/?$/, '')
|
||||
base = base.replace(/\/responses\/?$/, '')
|
||||
base = base.replace(/\/messages\/?$/, '')
|
||||
const type = apiType || _config.apiType || 'openai'
|
||||
if (type === 'anthropic') {
|
||||
base = base.replace(/\/models\/?$/, '')
|
||||
const type = normalizeApiType(apiType || _config.apiType)
|
||||
if (type === 'anthropic-messages') {
|
||||
// Anthropic: https://api.anthropic.com/v1
|
||||
if (!base.endsWith('/v1')) base += '/v1'
|
||||
return base
|
||||
@@ -1412,24 +1445,30 @@ function cleanBaseUrl(raw, apiType) {
|
||||
// Gemini: https://generativelanguage.googleapis.com/v1beta
|
||||
return base
|
||||
}
|
||||
if (!base.endsWith('/v1')) base = base.replace(/\/v1\/.*$/, '/v1')
|
||||
if (/:(11434)$/i.test(base)) return `${base}/v1`
|
||||
if (!base.endsWith('/v1')) {
|
||||
if (/\/v1\/.+/.test(base)) base = base.replace(/\/v1\/.*$/, '/v1')
|
||||
else base += '/v1'
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
function authHeaders(apiType, apiKey) {
|
||||
const type = apiType || _config.apiType || 'openai'
|
||||
const type = normalizeApiType(apiType || _config.apiType)
|
||||
const key = apiKey || _config.apiKey || ''
|
||||
if (type === 'anthropic') {
|
||||
return {
|
||||
if (type === 'anthropic-messages') {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': key,
|
||||
'anthropic-version': '2023-06-01',
|
||||
}
|
||||
if (key) headers['x-api-key'] = key
|
||||
return headers
|
||||
}
|
||||
return {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${key}`,
|
||||
}
|
||||
if (key) headers['Authorization'] = `Bearer ${key}`
|
||||
return headers
|
||||
}
|
||||
|
||||
// 超时常量
|
||||
@@ -1438,11 +1477,12 @@ const TIMEOUT_CHUNK = 30_000 // 流式 chunk 间隔超时 30 秒
|
||||
const TIMEOUT_CONNECT = 30_000 // 连接超时 30 秒
|
||||
|
||||
async function callAI(messages, onChunk) {
|
||||
if (!_config.baseUrl || !_config.apiKey || !_config.model) {
|
||||
const apiType = normalizeApiType(_config.apiType)
|
||||
if (!_config.baseUrl || !_config.model || (requiresApiKey(apiType) && !_config.apiKey)) {
|
||||
throw new Error('请先配置 AI 模型(点击右上角设置按钮)')
|
||||
}
|
||||
|
||||
const base = cleanBaseUrl(_config.baseUrl)
|
||||
const base = cleanBaseUrl(_config.baseUrl, apiType)
|
||||
_abortController = new AbortController()
|
||||
const allMessages = [{ role: 'system', content: buildSystemPrompt() }, ...messages]
|
||||
|
||||
@@ -1454,9 +1494,7 @@ async function callAI(messages, onChunk) {
|
||||
}, TIMEOUT_TOTAL)
|
||||
|
||||
try {
|
||||
const apiType = _config.apiType || 'openai'
|
||||
|
||||
if (apiType === 'anthropic') {
|
||||
if (apiType === 'anthropic-messages') {
|
||||
await callAnthropicMessages(base, allMessages, onChunk)
|
||||
return
|
||||
}
|
||||
@@ -2015,12 +2053,12 @@ async function executeToolWithSafety(toolName, args, tcForConfirm) {
|
||||
|
||||
// 带工具调用的 AI 请求(非流式,用于 tool_calls 检测循环)
|
||||
async function callAIWithTools(messages, onStatus, onToolProgress) {
|
||||
if (!_config.baseUrl || !_config.apiKey || !_config.model) {
|
||||
const apiType = normalizeApiType(_config.apiType)
|
||||
if (!_config.baseUrl || !_config.model || (requiresApiKey(apiType) && !_config.apiKey)) {
|
||||
throw new Error('请先配置 AI 模型(点击右上角设置按钮)')
|
||||
}
|
||||
|
||||
const apiType = _config.apiType || 'openai'
|
||||
const base = cleanBaseUrl(_config.baseUrl)
|
||||
const base = cleanBaseUrl(_config.baseUrl, apiType)
|
||||
const tools = getEnabledTools()
|
||||
let currentMessages = [{ role: 'system', content: buildSystemPrompt() }, ...messages]
|
||||
const toolHistory = []
|
||||
@@ -2054,7 +2092,7 @@ async function callAIWithTools(messages, onStatus, onToolProgress) {
|
||||
onStatus(round === 0 ? 'AI 思考中...' : `AI 处理工具结果 (第${round + 1}轮)...`)
|
||||
|
||||
// ── Anthropic 工具调用 ──
|
||||
if (apiType === 'anthropic') {
|
||||
if (apiType === 'anthropic-messages') {
|
||||
const systemMsg = currentMessages.find(m => m.role === 'system')?.content || ''
|
||||
const chatMsgs = currentMessages.filter(m => m.role !== 'system')
|
||||
const body = {
|
||||
@@ -2499,7 +2537,7 @@ function showSettings() {
|
||||
<div style="display:flex;gap:10px">
|
||||
<div class="form-group" style="flex:1">
|
||||
<label class="form-label">API Base URL</label>
|
||||
<input class="form-input" id="ast-baseurl" value="${escHtml(c.baseUrl)}" placeholder="https://api.openai.com/v1">
|
||||
<input class="form-input" id="ast-baseurl" value="${escHtml(c.baseUrl)}" placeholder="${escHtml(apiBasePlaceholder(c.apiType))}">
|
||||
</div>
|
||||
<div class="form-group" style="width:170px">
|
||||
<label class="form-label">API 类型</label>
|
||||
@@ -2511,7 +2549,7 @@ function showSettings() {
|
||||
<div style="display:flex;gap:10px;align-items:flex-end">
|
||||
<div class="form-group" style="flex:1;margin-bottom:0">
|
||||
<label class="form-label">API Key</label>
|
||||
<input class="form-input" id="ast-apikey" type="password" value="${escHtml(c.apiKey)}" placeholder="sk-...">
|
||||
<input class="form-input" id="ast-apikey" type="password" value="${escHtml(c.apiKey)}" placeholder="${escHtml(apiKeyPlaceholder(c.apiType))}">
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;padding-bottom:1px">
|
||||
<button class="btn btn-sm btn-secondary" id="ast-btn-test" title="测试连通性">测试</button>
|
||||
@@ -2533,11 +2571,7 @@ function showSettings() {
|
||||
<input class="form-input" id="ast-temp" type="number" value="${c.temperature || 0.7}" min="0" max="2" step="0.1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-hint" id="ast-api-hint" style="margin-top:-4px">${{
|
||||
openai: '自动兼容 Chat Completions 和 Responses API',
|
||||
anthropic: '使用 Anthropic Messages API(/v1/messages)',
|
||||
'google-gemini': '使用 Gemini generateContent API',
|
||||
}[c.apiType || 'openai']}</div>
|
||||
<div class="form-hint" id="ast-api-hint" style="margin-top:-4px">${apiHintText(c.apiType)}</div>
|
||||
|
||||
<div id="ast-qtcool-promo" style="margin-top:14px;border-radius:12px;background:linear-gradient(135deg,#0f0c29 0%,#302b63 50%,#24243e 100%);color:#fff;position:relative;overflow:hidden;box-shadow:0 4px 20px rgba(48,43,99,0.3)">
|
||||
<div style="position:absolute;top:-40px;right:-40px;width:160px;height:160px;border-radius:50%;background:radial-gradient(circle,rgba(99,102,241,0.15) 0%,transparent 70%);pointer-events:none"></div>
|
||||
@@ -2704,13 +2738,10 @@ function showSettings() {
|
||||
const baseUrlInput = overlay.querySelector('#ast-baseurl')
|
||||
const apiKeyInput = overlay.querySelector('#ast-apikey')
|
||||
apiTypeSelect.addEventListener('change', () => {
|
||||
const v = apiTypeSelect.value
|
||||
const hints = { openai: '自动兼容 Chat Completions 和 Responses API', anthropic: '使用 Anthropic Messages API(/v1/messages)', 'google-gemini': '使用 Gemini generateContent API' }
|
||||
const placeholders = { openai: 'https://api.openai.com/v1', anthropic: 'https://api.anthropic.com', 'google-gemini': 'https://generativelanguage.googleapis.com/v1beta' }
|
||||
const keyPlaceholders = { openai: 'sk-...', anthropic: 'sk-ant-...', 'google-gemini': 'AIza...' }
|
||||
apiHintEl.textContent = hints[v] || hints.openai
|
||||
baseUrlInput.placeholder = placeholders[v] || placeholders.openai
|
||||
apiKeyInput.placeholder = keyPlaceholders[v] || keyPlaceholders.openai
|
||||
const v = normalizeApiType(apiTypeSelect.value)
|
||||
apiHintEl.textContent = apiHintText(v)
|
||||
baseUrlInput.placeholder = apiBasePlaceholder(v)
|
||||
apiKeyInput.placeholder = apiKeyPlaceholder(v)
|
||||
})
|
||||
|
||||
// 灵魂来源切换
|
||||
@@ -2969,7 +3000,7 @@ function showSettings() {
|
||||
overlay.querySelector('#ast-baseurl').value = QTCOOL.baseUrl
|
||||
overlay.querySelector('#ast-apikey').value = key
|
||||
overlay.querySelector('#ast-model').value = selectedModel
|
||||
overlay.querySelector('#ast-apitype').value = 'openai'
|
||||
overlay.querySelector('#ast-apitype').value = 'openai-completions'
|
||||
qtcoolStatus.innerHTML = `<span style="color:#34d399">${statusIcon('ok', 14)} 助手已配置为 ${selectedModel}</span>`
|
||||
toast('助手已接入 gpt.qt.cool — ' + selectedModel, 'success')
|
||||
|
||||
@@ -2992,7 +3023,7 @@ function showSettings() {
|
||||
baseUrl: QTCOOL.baseUrl,
|
||||
apiKey: key,
|
||||
api: 'openai-completions',
|
||||
models: QTCOOL.models.map(m => ({ id: m.id, name: m.name, contextWindow: 128000, reasoning: m.id.includes('codex') }))
|
||||
models: [{ id: selectedModel, name: selectedModel, contextWindow: 128000, reasoning: selectedModel.includes('codex') }]
|
||||
}
|
||||
} else {
|
||||
config.models.providers.qtcool.apiKey = key
|
||||
@@ -3029,9 +3060,9 @@ function showSettings() {
|
||||
const baseUrl = overlay.querySelector('#ast-baseurl').value.trim()
|
||||
const apiKey = overlay.querySelector('#ast-apikey').value.trim()
|
||||
const model = overlay.querySelector('#ast-model').value.trim()
|
||||
const selApiType = overlay.querySelector('#ast-apitype').value || 'openai'
|
||||
if (!baseUrl || !apiKey) {
|
||||
resultEl.innerHTML = '<span style="color:var(--warning)">请先填写 Base URL 和 API Key</span>'
|
||||
const selApiType = normalizeApiType(overlay.querySelector('#ast-apitype').value || 'openai-completions')
|
||||
if (!baseUrl || (requiresApiKey(selApiType) && !apiKey)) {
|
||||
resultEl.innerHTML = '<span style="color:var(--warning)">' + escHtml(requiresApiKey(selApiType) ? '请先填写 Base URL 和 API Key' : '请先填写 Base URL') + '</span>'
|
||||
return
|
||||
}
|
||||
if (!model) {
|
||||
@@ -3048,7 +3079,7 @@ function showSettings() {
|
||||
let respStatus = 0, respBody = '', reply = '', usedApi = '', reqUrl = '', reqBody = {}
|
||||
|
||||
try {
|
||||
if (selApiType === 'anthropic') {
|
||||
if (selApiType === 'anthropic-messages') {
|
||||
usedApi = 'Anthropic Messages'
|
||||
reqUrl = base + '/messages'
|
||||
reqBody = { model, messages: [{ role: 'user', content: '你好,请用一句话回复' }], max_tokens: 200 }
|
||||
@@ -3120,20 +3151,20 @@ function showSettings() {
|
||||
const btn = e.target
|
||||
const baseUrl = overlay.querySelector('#ast-baseurl').value.trim()
|
||||
const apiKey = overlay.querySelector('#ast-apikey').value.trim()
|
||||
if (!baseUrl || !apiKey) {
|
||||
resultEl.innerHTML = '<span style="color:var(--warning)">请先填写 Base URL 和 API Key</span>'
|
||||
const selApiType = normalizeApiType(overlay.querySelector('#ast-apitype').value || 'openai-completions')
|
||||
if (!baseUrl || (requiresApiKey(selApiType) && !apiKey)) {
|
||||
resultEl.innerHTML = '<span style="color:var(--warning)">' + escHtml(requiresApiKey(selApiType) ? '请先填写 Base URL 和 API Key' : '请先填写 Base URL') + '</span>'
|
||||
return
|
||||
}
|
||||
btn.disabled = true
|
||||
btn.textContent = '获取中...'
|
||||
resultEl.innerHTML = '<span style="color:var(--text-tertiary)">正在获取模型列表...</span>'
|
||||
const selApiType = overlay.querySelector('#ast-apitype').value || 'openai'
|
||||
try {
|
||||
const base = cleanBaseUrl(baseUrl, selApiType)
|
||||
const hdrs = authHeaders(selApiType, apiKey)
|
||||
let models = []
|
||||
|
||||
if (selApiType === 'anthropic') {
|
||||
if (selApiType === 'anthropic-messages') {
|
||||
// Anthropic: GET /v1/models
|
||||
const resp = await fetch(base + '/models', { headers: hdrs, signal: AbortSignal.timeout(10000) })
|
||||
if (!resp.ok) {
|
||||
@@ -3211,13 +3242,13 @@ function showSettings() {
|
||||
const raw = await api.assistantReadFile(home + '/.openclaw/agents/' + agentId + '/agent/models.json')
|
||||
const data = JSON.parse(raw)
|
||||
for (const [pid, p] of Object.entries(data.providers || {})) {
|
||||
if (p.baseUrl && p.apiKey) {
|
||||
if (p.baseUrl) {
|
||||
providers.push({
|
||||
source: 'Agent: ' + agentId,
|
||||
name: pid,
|
||||
baseUrl: p.baseUrl,
|
||||
apiKey: p.apiKey,
|
||||
apiType: 'openai',
|
||||
apiKey: p.apiKey || '',
|
||||
apiType: normalizeApiType(p.api),
|
||||
models: (p.models || []).map(m => m.id || m.name).filter(Boolean),
|
||||
})
|
||||
}
|
||||
@@ -3231,14 +3262,14 @@ function showSettings() {
|
||||
const raw = await api.assistantReadFile(home + '/.openclaw/openclaw.json')
|
||||
const config = JSON.parse(raw)
|
||||
for (const [pid, p] of Object.entries(config.models?.providers || {})) {
|
||||
if (p.baseUrl && p.apiKey && !providers.find(x => x.name === pid)) {
|
||||
if (p.baseUrl && !providers.find(x => x.name === pid)) {
|
||||
providers.push({
|
||||
source: '全局配置',
|
||||
name: pid,
|
||||
baseUrl: p.baseUrl,
|
||||
apiKey: p.apiKey,
|
||||
apiType: 'openai',
|
||||
models: [],
|
||||
apiKey: p.apiKey || '',
|
||||
apiType: normalizeApiType(p.api),
|
||||
models: (p.models || []).map(m => m.id || m.name).filter(Boolean),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3323,7 +3354,7 @@ function showSettings() {
|
||||
_config.apiKey = overlay.querySelector('#ast-apikey').value.trim()
|
||||
_config.model = overlay.querySelector('#ast-model').value.trim()
|
||||
_config.temperature = parseFloat(overlay.querySelector('#ast-temp').value) || 0.7
|
||||
_config.apiType = overlay.querySelector('#ast-apitype').value || 'openai'
|
||||
_config.apiType = normalizeApiType(overlay.querySelector('#ast-apitype').value || 'openai-completions')
|
||||
// 工具开关
|
||||
_config.tools.terminal = overlay.querySelector('#ast-tool-terminal').checked
|
||||
_config.tools.fileOps = overlay.querySelector('#ast-tool-fileops').checked
|
||||
|
||||
@@ -53,7 +53,7 @@ const PLATFORM_REGISTRY = {
|
||||
'进入<b>权限管理</b>,参照 <a href="https://open.larkoffice.com/document/server-docs/application-scope/scope-list" target="_blank" style="color:var(--accent);text-decoration:underline">权限列表</a> 开通所需权限(<code>im:message</code> 等)',
|
||||
'进入<b>事件订阅</b>,选择<b>使用长连接(WebSocket)</b>模式,订阅<b>接收消息</b>和<b>卡片回调</b>事件。如有 user access token 开关请打开',
|
||||
'将 App ID 和 App Secret 填入下方表单,校验后保存。ClawPanel 会自动安装飞书插件并写入配置',
|
||||
'保存后在飞书中向机器人发消息,获取配对码,然后在终端执行 <code>openclaw pairing approve feishu <配对码> --notify</code> 完成绑定',
|
||||
'保存后在飞书中向机器人发消息,获取配对码;你可以直接在下方“配对审批”区域粘贴配对码完成绑定,也可以在终端执行 <code>openclaw pairing approve feishu <配对码> --notify</code>',
|
||||
],
|
||||
guideFooter: '<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">国际版 Lark 用户请将域名切换为 <b>lark</b>。详细教程:<a href="https://www.feishu.cn/content/article/7613711414611463386" target="_blank" style="color:var(--accent);text-decoration:underline">OpenClaw 飞书官方插件使用指南</a></div>',
|
||||
fields: [
|
||||
@@ -62,6 +62,35 @@ const PLATFORM_REGISTRY = {
|
||||
{ key: 'domain', label: '域名', placeholder: 'feishu(国际版选 lark)', required: false },
|
||||
],
|
||||
pluginRequired: '@openclaw/feishu@latest',
|
||||
pluginId: 'feishu',
|
||||
pairingChannel: 'feishu',
|
||||
pairingNotify: true,
|
||||
},
|
||||
dingtalk: {
|
||||
label: '钉钉',
|
||||
iconName: 'message-square',
|
||||
desc: '钉钉企业内部应用 + 机器人 Stream 模式接入',
|
||||
guide: [
|
||||
'前往 <a href="https://open-dev.dingtalk.com/" target="_blank" style="color:var(--accent);text-decoration:underline">钉钉开放平台</a> 创建企业内部应用,并添加<b>机器人</b>能力',
|
||||
'消息接收模式必须选择 <b>Stream 模式</b>,不要选 Webhook',
|
||||
'在<b>凭证与基础信息</b>页面复制 <b>Client ID</b> 和 <b>Client Secret</b>;如 Gateway 开启了鉴权,请按 <code>gateway.auth.mode</code> 填写 <b>Gateway Token</b> 或 <b>Gateway Password</b>',
|
||||
'在<b>权限管理</b>中至少确认已开通 <code>Card.Streaming.Write</code>、<code>Card.Instance.Write</code>、<code>qyapi_robot_sendmsg</code>,如需文档能力再补文档相关权限',
|
||||
'先在钉钉侧<b>发布应用版本</b>,并确认<b>应用可见范围</b>包含你自己和测试成员;否则私聊或加群时可能搜不到机器人',
|
||||
'回到 ClawPanel 保存。首次保存会自动安装插件,后续保存只更新配置;如果本机已配置 Gateway 鉴权,系统会自动带出对应的 Token 或 Password',
|
||||
'私聊测试时,可在钉钉客户端搜索应用/机器人名称,或从工作台进入应用后发起对话;若找不到,优先检查“已发布”和“可见范围”',
|
||||
'如果机器人首次私聊返回的是<b>配对码</b>,你可以直接在下方“配对审批”区域粘贴配对码完成授权,也可以在终端执行 <code>openclaw pairing approve dingtalk-connector <配对码></code>',
|
||||
'群聊测试时,先进入目标群 → <b>群设置</b> → <b>智能群助手 / 机器人</b> → <b>添加机器人</b>,搜索并添加该机器人;回群后建议用 <code>@机器人</code> 再发消息,如仍不响应再检查连接器的 <code>groupPolicy</code> 是否被设为 <code>disabled</code>',
|
||||
],
|
||||
guideFooter: '<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">参考资料:<a href="https://open.dingtalk.com/document/dingstart/install-openclaw-locally" target="_blank" style="color:var(--accent);text-decoration:underline">本地安装 OpenClaw</a>、<a href="https://open.dingtalk.com/document/orgapp/use-group-robots" target="_blank" style="color:var(--accent);text-decoration:underline">添加机器人到钉钉群</a>。排障重点:405 通常是 <code>chatCompletions</code> 未启用,401 通常是 Gateway 鉴权字段不匹配。</div>',
|
||||
fields: [
|
||||
{ key: 'clientId', label: 'Client ID', placeholder: 'dingxxxxxxxxxx', required: true },
|
||||
{ key: 'clientSecret', label: 'Client Secret', placeholder: '应用密钥', secret: true, required: true },
|
||||
{ key: 'gatewayToken', label: 'Gateway Token', placeholder: '如已开启 Gateway token 鉴权则填写', required: false },
|
||||
{ key: 'gatewayPassword', label: 'Gateway Password', placeholder: '与 token 二选一,可选', secret: true, required: false },
|
||||
],
|
||||
pluginRequired: '@dingtalk-real-ai/dingtalk-connector',
|
||||
pluginId: 'dingtalk-connector',
|
||||
pairingChannel: 'dingtalk-connector',
|
||||
},
|
||||
discord: {
|
||||
label: 'Discord',
|
||||
@@ -90,7 +119,7 @@ export async function render() {
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">消息渠道</h1>
|
||||
<p class="page-desc">内置 QQ 机器人,并支持 Telegram、Discord 等外部消息渠道接入</p>
|
||||
<p class="page-desc">支持 QQ、Telegram、Discord、飞书、钉钉等消息渠道接入</p>
|
||||
</div>
|
||||
<div id="platforms-configured" style="margin-bottom:var(--space-lg)"></div>
|
||||
<div class="config-section">
|
||||
@@ -117,6 +146,11 @@ async function loadPlatforms(page, state) {
|
||||
toast('加载平台列表失败: ' + e, 'error')
|
||||
state.configured = []
|
||||
}
|
||||
// 加载 bindings 信息
|
||||
try {
|
||||
const config = await api.readOpenclawConfig()
|
||||
state.bindings = Array.isArray(config?.bindings) ? config.bindings : []
|
||||
} catch { state.bindings = [] }
|
||||
renderConfigured(page, state)
|
||||
renderAvailable(page, state)
|
||||
}
|
||||
@@ -138,11 +172,15 @@ function renderConfigured(page, state) {
|
||||
const reg = PLATFORM_REGISTRY[p.id]
|
||||
const label = reg?.label || p.id
|
||||
const ic = icon(reg?.iconName || 'radio', 22)
|
||||
const channelKey = getChannelBindingKey(p.id)
|
||||
const binding = (state.bindings || []).find(b => b.match?.channel === channelKey)
|
||||
const boundAgent = binding?.agentId || 'main'
|
||||
return `
|
||||
<div class="platform-card ${p.enabled ? 'active' : 'inactive'}" data-pid="${p.id}">
|
||||
<div class="platform-card-header">
|
||||
<span class="platform-emoji">${ic}</span>
|
||||
<span class="platform-name">${label}</span>
|
||||
${boundAgent !== 'main' ? `<span style="font-size:var(--font-size-xs);color:var(--accent);background:var(--accent-muted);padding:1px 6px;border-radius:10px">→ ${escapeAttr(boundAgent)}</span>` : ''}
|
||||
<span class="platform-status-dot ${p.enabled ? 'on' : 'off'}"></span>
|
||||
</div>
|
||||
<div class="platform-card-actions">
|
||||
@@ -214,16 +252,47 @@ async function openConfigDialog(pid, page, state) {
|
||||
// 尝试加载已有配置
|
||||
let existing = {}
|
||||
let isEdit = false
|
||||
let agents = []
|
||||
let currentBinding = ''
|
||||
try {
|
||||
const res = await api.readPlatformConfig(pid)
|
||||
if (res?.exists && res.values) {
|
||||
if (res?.values) {
|
||||
existing = res.values
|
||||
}
|
||||
if (res?.exists) {
|
||||
isEdit = true
|
||||
}
|
||||
} catch {}
|
||||
// 加载 Agent 列表和当前 binding
|
||||
try {
|
||||
agents = await api.listAgents()
|
||||
} catch {}
|
||||
try {
|
||||
const config = await api.readOpenclawConfig()
|
||||
const bindings = config?.bindings || []
|
||||
const channelKey = getChannelBindingKey(pid)
|
||||
const found = bindings.find(b => b.match?.channel === channelKey)
|
||||
if (found) currentBinding = found.agentId || ''
|
||||
} catch {}
|
||||
|
||||
const formId = 'platform-form-' + Date.now()
|
||||
|
||||
// Agent 绑定选择器
|
||||
const agentOptions = agents.map(a => {
|
||||
const label = a.identityName ? a.identityName.split(',')[0].trim() : a.id
|
||||
return `<option value="${escapeAttr(a.id)}" ${a.id === currentBinding ? 'selected' : ''}>${a.id}${a.id !== label ? ' — ' + label : ''}</option>`
|
||||
}).join('')
|
||||
const agentBindingHtml = `
|
||||
<div class="form-group">
|
||||
<label class="form-label">绑定 Agent</label>
|
||||
<select class="form-input" name="__agentBinding">
|
||||
<option value="" ${!currentBinding ? 'selected' : ''}>默认(main)</option>
|
||||
${agentOptions}
|
||||
</select>
|
||||
<div class="form-hint">选择该渠道消息路由到哪个 Agent 处理。留空则使用默认 Agent(main)</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
const fieldsHtml = reg.fields.map((f, i) => {
|
||||
const val = existing[f.key] || ''
|
||||
return `
|
||||
@@ -249,12 +318,28 @@ async function openConfigDialog(pid, page, state) {
|
||||
</details>
|
||||
` : ''
|
||||
|
||||
const pairingHtml = reg.pairingChannel ? `
|
||||
<div style="margin-top:var(--space-md);padding:12px 14px;background:var(--bg-tertiary);border-radius:var(--radius-md)">
|
||||
<div style="font-weight:600;font-size:var(--font-size-sm);margin-bottom:6px">配对审批</div>
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-secondary);line-height:1.7;margin-bottom:8px">当机器人提示 <code>access not configured</code>、<code>Pairing code</code> 或要求执行 <code>openclaw pairing approve</code> 时,可直接在这里完成批准。</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<input class="form-input" name="pairingCode" placeholder="例如 R3ZFPWZP" style="flex:1;min-width:180px">
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="btn-pairing-list">查看待审批</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" id="btn-pairing-approve">批准配对码</button>
|
||||
</div>
|
||||
<div id="pairing-result" style="margin-top:8px"></div>
|
||||
</div>
|
||||
` : ''
|
||||
|
||||
const content = `
|
||||
${guideHtml}
|
||||
${!isEdit && (existing.gatewayToken || existing.gatewayPassword) ? `<div style="background:var(--bg-tertiary);color:var(--text-secondary);padding:8px 14px;border-radius:var(--radius-md);font-size:var(--font-size-sm);margin-bottom:var(--space-md)">已从当前 Gateway 鉴权配置中自动带出 ${existing.gatewayToken ? 'Token' : 'Password'},通常无需手填</div>` : ''}
|
||||
${isEdit ? `<div style="background:var(--accent-muted);color:var(--accent);padding:8px 14px;border-radius:var(--radius-md);font-size:var(--font-size-sm);margin-bottom:var(--space-md)">当前已有配置,修改后点击保存即可覆盖</div>` : ''}
|
||||
<form id="${formId}">
|
||||
${fieldsHtml}
|
||||
${agentBindingHtml}
|
||||
</form>
|
||||
${pairingHtml}
|
||||
<div id="verify-result" style="margin-top:var(--space-sm)"></div>
|
||||
`
|
||||
|
||||
@@ -304,6 +389,60 @@ async function openConfigDialog(pid, page, state) {
|
||||
const btnVerify = modal.querySelector('#btn-verify')
|
||||
const btnSave = modal.querySelector('#btn-save')
|
||||
const resultEl = modal.querySelector('#verify-result')
|
||||
const pairingInput = modal.querySelector('input[name="pairingCode"]')
|
||||
const pairingResultEl = modal.querySelector('#pairing-result')
|
||||
const btnPairingList = modal.querySelector('#btn-pairing-list')
|
||||
const btnPairingApprove = modal.querySelector('#btn-pairing-approve')
|
||||
|
||||
if (btnPairingList && pairingResultEl) {
|
||||
btnPairingList.onclick = async () => {
|
||||
btnPairingList.disabled = true
|
||||
btnPairingList.textContent = '读取中...'
|
||||
pairingResultEl.innerHTML = ''
|
||||
try {
|
||||
const output = await api.pairingListChannel(reg.pairingChannel)
|
||||
pairingResultEl.innerHTML = `
|
||||
<div style="background:var(--bg-secondary);border:1px solid var(--border-primary);border-radius:var(--radius-md);padding:10px 12px">
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-bottom:6px">待审批请求</div>
|
||||
<pre style="margin:0;white-space:pre-wrap;word-break:break-word;font-size:12px;color:var(--text-secondary);font-family:var(--font-mono)">${escapeAttr(output || '暂无待审批请求')}</pre>
|
||||
</div>`
|
||||
} catch (e) {
|
||||
pairingResultEl.innerHTML = `<div style="color:var(--error);font-size:var(--font-size-sm)">读取失败: ${escapeAttr(String(e))}</div>`
|
||||
} finally {
|
||||
btnPairingList.disabled = false
|
||||
btnPairingList.textContent = '查看待审批'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (btnPairingApprove && pairingInput && pairingResultEl) {
|
||||
btnPairingApprove.onclick = async () => {
|
||||
const code = pairingInput.value.trim().toUpperCase()
|
||||
if (!code) {
|
||||
toast('请输入配对码', 'warning')
|
||||
pairingInput.focus()
|
||||
return
|
||||
}
|
||||
btnPairingApprove.disabled = true
|
||||
btnPairingApprove.textContent = '批准中...'
|
||||
pairingResultEl.innerHTML = ''
|
||||
try {
|
||||
const output = await api.pairingApproveChannel(reg.pairingChannel, code, !!reg.pairingNotify)
|
||||
pairingResultEl.innerHTML = `
|
||||
<div style="background:var(--success-muted);color:var(--success);padding:10px 14px;border-radius:var(--radius-md);font-size:var(--font-size-sm)">
|
||||
${icon('check', 14)} 配对已批准
|
||||
<div style="margin-top:6px;font-size:12px;white-space:pre-wrap;word-break:break-word;color:var(--text-secondary)">${escapeAttr(output || '操作完成')}</div>
|
||||
</div>`
|
||||
pairingInput.value = ''
|
||||
toast('配对已批准', 'success')
|
||||
} catch (e) {
|
||||
pairingResultEl.innerHTML = `<div style="background:var(--error-muted, #fee2e2);color:var(--error);padding:10px 14px;border-radius:var(--radius-md);font-size:var(--font-size-sm)">批准失败: ${escapeAttr(String(e))}</div>`
|
||||
} finally {
|
||||
btnPairingApprove.disabled = false
|
||||
btnPairingApprove.textContent = '批准配对码'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
btnVerify.onclick = async () => {
|
||||
const form = collectForm()
|
||||
@@ -356,57 +495,77 @@ async function openConfigDialog(pid, page, state) {
|
||||
try {
|
||||
// 如果需要安装插件,先安装并显示日志
|
||||
if (reg.pluginRequired) {
|
||||
btnSave.textContent = '安装插件中...'
|
||||
resultEl.innerHTML = `
|
||||
<div style="background:var(--bg-tertiary);border-radius:var(--radius-md);padding:12px;margin-top:var(--space-sm)">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||
${icon('download', 14)}
|
||||
<span style="font-size:var(--font-size-sm);font-weight:600">安装插件</span>
|
||||
<span id="plugin-progress-text" style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-left:auto">0%</span>
|
||||
const pluginId = reg.pluginId || pid
|
||||
const pluginStatus = await api.getChannelPluginStatus(pluginId)
|
||||
if (!pluginStatus?.installed) {
|
||||
btnSave.textContent = '安装插件中...'
|
||||
resultEl.innerHTML = `
|
||||
<div style="background:var(--bg-tertiary);border-radius:var(--radius-md);padding:12px;margin-top:var(--space-sm)">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||
${icon('download', 14)}
|
||||
<span style="font-size:var(--font-size-sm);font-weight:600">安装插件</span>
|
||||
<span id="plugin-progress-text" style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-left:auto">0%</span>
|
||||
</div>
|
||||
<div style="height:4px;background:var(--bg-secondary);border-radius:2px;overflow:hidden;margin-bottom:8px">
|
||||
<div id="plugin-progress-bar" style="height:100%;background:var(--accent);width:0%;transition:width 0.3s"></div>
|
||||
</div>
|
||||
<div id="plugin-log-box" style="font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);max-height:120px;overflow-y:auto;line-height:1.6;white-space:pre-wrap;word-break:break-all"></div>
|
||||
</div>
|
||||
<div style="height:4px;background:var(--bg-secondary);border-radius:2px;overflow:hidden;margin-bottom:8px">
|
||||
<div id="plugin-progress-bar" style="height:100%;background:var(--accent);width:0%;transition:width 0.3s"></div>
|
||||
</div>
|
||||
<div id="plugin-log-box" style="font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);max-height:120px;overflow-y:auto;line-height:1.6;white-space:pre-wrap;word-break:break-all"></div>
|
||||
</div>
|
||||
`
|
||||
const logBox = resultEl.querySelector('#plugin-log-box')
|
||||
const progressBar = resultEl.querySelector('#plugin-progress-bar')
|
||||
const progressText = resultEl.querySelector('#plugin-progress-text')
|
||||
`
|
||||
const logBox = resultEl.querySelector('#plugin-log-box')
|
||||
const progressBar = resultEl.querySelector('#plugin-progress-bar')
|
||||
const progressText = resultEl.querySelector('#plugin-progress-text')
|
||||
let unlistenLog, unlistenProgress
|
||||
try {
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
unlistenLog = await listen('plugin-log', (e) => {
|
||||
logBox.textContent += e.payload + '\n'
|
||||
logBox.scrollTop = logBox.scrollHeight
|
||||
})
|
||||
unlistenProgress = await listen('plugin-progress', (e) => {
|
||||
const pct = e.payload
|
||||
progressBar.style.width = pct + '%'
|
||||
progressText.textContent = pct + '%'
|
||||
})
|
||||
} catch {}
|
||||
|
||||
// 监听 Tauri 事件
|
||||
let unlistenLog, unlistenProgress
|
||||
try {
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
unlistenLog = await listen('plugin-log', (e) => {
|
||||
logBox.textContent += e.payload + '\n'
|
||||
logBox.scrollTop = logBox.scrollHeight
|
||||
})
|
||||
unlistenProgress = await listen('plugin-progress', (e) => {
|
||||
const pct = e.payload
|
||||
progressBar.style.width = pct + '%'
|
||||
progressText.textContent = pct + '%'
|
||||
})
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
await api.installQqbotPlugin()
|
||||
} catch (e) {
|
||||
toast('插件安装失败: ' + e, 'error')
|
||||
btnSave.disabled = false
|
||||
btnVerify.disabled = false
|
||||
btnSave.textContent = isEdit ? '保存' : '接入并保存'
|
||||
try {
|
||||
if (pid === 'qqbot') {
|
||||
await api.installQqbotPlugin()
|
||||
} else {
|
||||
await api.installChannelPlugin(reg.pluginRequired, pluginId)
|
||||
}
|
||||
} catch (e) {
|
||||
toast('插件安装失败: ' + e, 'error')
|
||||
btnSave.disabled = false
|
||||
btnVerify.disabled = false
|
||||
btnSave.textContent = isEdit ? '保存' : '接入并保存'
|
||||
if (unlistenLog) unlistenLog()
|
||||
if (unlistenProgress) unlistenProgress()
|
||||
return
|
||||
}
|
||||
if (unlistenLog) unlistenLog()
|
||||
if (unlistenProgress) unlistenProgress()
|
||||
return
|
||||
} else {
|
||||
resultEl.innerHTML = `
|
||||
<div style="background:var(--accent-muted);color:var(--accent);padding:10px 14px;border-radius:var(--radius-md);font-size:var(--font-size-sm)">
|
||||
${icon('check', 14)} 已检测到插件,无需重复安装,本次仅更新配置
|
||||
</div>`
|
||||
}
|
||||
if (unlistenLog) unlistenLog()
|
||||
if (unlistenProgress) unlistenProgress()
|
||||
}
|
||||
|
||||
// 写入配置
|
||||
btnSave.textContent = '写入配置...'
|
||||
await api.saveMessagingPlatform(pid, form)
|
||||
|
||||
// 写入 Agent 绑定到 openclaw.json bindings
|
||||
const selectedAgent = modal.querySelector('select[name="__agentBinding"]')?.value || ''
|
||||
try {
|
||||
await saveChannelBinding(pid, selectedAgent)
|
||||
} catch (e) {
|
||||
console.warn('[channels] 保存 Agent 绑定失败:', e)
|
||||
}
|
||||
|
||||
toast(`${reg.label} 配置已保存,Gateway 正在重载`, 'success')
|
||||
modal.close?.() || modal.remove?.()
|
||||
await loadPlatforms(page, state)
|
||||
@@ -420,6 +579,37 @@ async function openConfigDialog(pid, page, state) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 将平台 ID 映射为 openclaw bindings 中的 channel key */
|
||||
function getChannelBindingKey(pid) {
|
||||
const map = {
|
||||
qqbot: 'qqbot',
|
||||
telegram: 'telegram',
|
||||
discord: 'discord',
|
||||
feishu: 'feishu',
|
||||
dingtalk: 'dingtalk-connector',
|
||||
}
|
||||
return map[pid] || pid
|
||||
}
|
||||
|
||||
/** 保存渠道→Agent 绑定到 openclaw.json 的 bindings 数组 */
|
||||
async function saveChannelBinding(pid, agentId) {
|
||||
const config = await api.readOpenclawConfig()
|
||||
if (!config) return
|
||||
const channelKey = getChannelBindingKey(pid)
|
||||
let bindings = Array.isArray(config.bindings) ? [...config.bindings] : []
|
||||
|
||||
// 移除该渠道的旧绑定
|
||||
bindings = bindings.filter(b => b.match?.channel !== channelKey)
|
||||
|
||||
// 如果选了非空 Agent 且不是 main,添加新绑定
|
||||
if (agentId && agentId !== 'main') {
|
||||
bindings.push({ match: { channel: channelKey }, agentId })
|
||||
}
|
||||
|
||||
config.bindings = bindings
|
||||
await api.writeOpenclawConfig(config)
|
||||
}
|
||||
|
||||
function escapeAttr(str) {
|
||||
return (str || '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 聊天页面 - 完整版,对接 OpenClaw Gateway
|
||||
* 支持:流式响应、Markdown 渲染、会话管理、Agent 选择、快捷指令
|
||||
*/
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { api, invalidate } from '../lib/tauri-api.js'
|
||||
import { navigate } from '../router.js'
|
||||
import { wsClient, uuid } from '../lib/ws-client.js'
|
||||
import { renderMarkdown } from '../lib/markdown.js'
|
||||
@@ -13,6 +13,9 @@ import { icon as svgIcon } from '../lib/icons.js'
|
||||
|
||||
const RENDER_THROTTLE = 30
|
||||
const STORAGE_SESSION_KEY = 'clawpanel-last-session'
|
||||
const STORAGE_MODEL_KEY = 'clawpanel-chat-selected-model'
|
||||
const STORAGE_SIDEBAR_KEY = 'clawpanel-chat-sidebar-open'
|
||||
const STORAGE_SESSION_NAMES_KEY = 'clawpanel-chat-session-names'
|
||||
|
||||
const COMMANDS = [
|
||||
{ title: '会话', commands: [
|
||||
@@ -41,6 +44,7 @@ const COMMANDS = [
|
||||
let _sessionKey = null, _page = null, _messagesEl = null, _textarea = null
|
||||
let _sendBtn = null, _statusDot = null, _typingEl = null, _scrollBtn = null
|
||||
let _sessionListEl = null, _cmdPanelEl = null, _attachPreviewEl = null, _fileInputEl = null
|
||||
let _modelSelectEl = null
|
||||
let _currentAiBubble = null, _currentAiText = '', _currentAiImages = [], _currentAiVideos = [], _currentAiAudios = [], _currentAiFiles = [], _currentRunId = null
|
||||
let _isStreaming = false, _isSending = false, _messageQueue = [], _streamStartTime = 0
|
||||
let _lastRenderTime = 0, _renderPending = false, _lastHistoryHash = ''
|
||||
@@ -49,6 +53,10 @@ let _pageActive = false
|
||||
let _errorTimer = null, _lastErrorMsg = null
|
||||
let _attachments = []
|
||||
let _hasEverConnected = false
|
||||
let _availableModels = []
|
||||
let _primaryModel = ''
|
||||
let _selectedModel = ''
|
||||
let _isApplyingModel = false
|
||||
|
||||
export async function render() {
|
||||
const page = document.createElement('div')
|
||||
@@ -76,6 +84,14 @@ export async function render() {
|
||||
<span class="chat-title" id="chat-title">聊天</span>
|
||||
</div>
|
||||
<div class="chat-header-actions">
|
||||
<div class="chat-model-group">
|
||||
<select class="form-input" id="chat-model-select" title="切换当前会话模型" style="width:200px;max-width:28vw;padding:6px 10px;font-size:var(--font-size-xs)">
|
||||
<option value="">加载模型中...</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-ghost" id="btn-refresh-models" title="刷新模型列表">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-ghost" id="btn-cmd" title="快捷指令">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M18 3a3 3 0 00-3 3v12a3 3 0 003 3 3 3 0 003-3 3 3 0 00-3-3H6a3 3 0 00-3 3 3 3 0 003 3 3 3 0 003-3V6a3 3 0 00-3-3 3 3 0 00-3 3 3 3 0 003 3h12a3 3 0 003-3 3 3 0 00-3-3z"/></svg>
|
||||
</button>
|
||||
@@ -132,6 +148,8 @@ export async function render() {
|
||||
_cmdPanelEl = page.querySelector('#chat-cmd-panel')
|
||||
_attachPreviewEl = page.querySelector('#chat-attachments-preview')
|
||||
_fileInputEl = page.querySelector('#chat-file-input')
|
||||
_modelSelectEl = page.querySelector('#chat-model-select')
|
||||
page.querySelector('#chat-sidebar')?.classList.toggle('open', getSidebarOpen())
|
||||
|
||||
bindEvents(page)
|
||||
bindConnectOverlay(page)
|
||||
@@ -139,6 +157,7 @@ export async function render() {
|
||||
// 首次使用引导提示
|
||||
showPageGuide(_messagesEl)
|
||||
|
||||
loadModelOptions()
|
||||
// 非阻塞:先返回 DOM,后台连接 Gateway
|
||||
connectGateway()
|
||||
return page
|
||||
@@ -173,6 +192,15 @@ function showPageGuide(container) {
|
||||
// ── 事件绑定 ──
|
||||
|
||||
function bindEvents(page) {
|
||||
if (_modelSelectEl) {
|
||||
_modelSelectEl.addEventListener('change', () => {
|
||||
_selectedModel = _modelSelectEl.value
|
||||
if (_selectedModel) localStorage.setItem(STORAGE_MODEL_KEY, _selectedModel)
|
||||
else localStorage.removeItem(STORAGE_MODEL_KEY)
|
||||
applySelectedModel()
|
||||
})
|
||||
}
|
||||
|
||||
_textarea.addEventListener('input', () => {
|
||||
_textarea.style.height = 'auto'
|
||||
_textarea.style.height = Math.min(_textarea.scrollHeight, 150) + 'px'
|
||||
@@ -193,11 +221,16 @@ function bindEvents(page) {
|
||||
})
|
||||
|
||||
page.querySelector('#btn-toggle-sidebar').addEventListener('click', () => {
|
||||
page.querySelector('#chat-sidebar').classList.toggle('open')
|
||||
const sidebar = page.querySelector('#chat-sidebar')
|
||||
if (!sidebar) return
|
||||
const nextOpen = !sidebar.classList.contains('open')
|
||||
sidebar.classList.toggle('open', nextOpen)
|
||||
setSidebarOpen(nextOpen)
|
||||
})
|
||||
page.querySelector('#btn-new-session').addEventListener('click', () => showNewSessionDialog())
|
||||
page.querySelector('#btn-cmd').addEventListener('click', () => toggleCmdPanel())
|
||||
page.querySelector('#btn-reset-session').addEventListener('click', () => resetCurrentSession())
|
||||
page.querySelector('#btn-refresh-models')?.addEventListener('click', () => loadModelOptions(true))
|
||||
|
||||
// 文件上传
|
||||
page.querySelector('#chat-attach-btn').addEventListener('click', () => _fileInputEl.click())
|
||||
@@ -213,6 +246,113 @@ function bindEvents(page) {
|
||||
_messagesEl.addEventListener('click', () => hideCmdPanel())
|
||||
}
|
||||
|
||||
async function loadModelOptions(showToast = false) {
|
||||
if (!_modelSelectEl) return
|
||||
// 显示加载状态
|
||||
_modelSelectEl.innerHTML = '<option value="">加载模型中...</option>'
|
||||
_modelSelectEl.disabled = true
|
||||
try {
|
||||
invalidate('read_openclaw_config')
|
||||
const configPromise = api.readOpenclawConfig()
|
||||
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('读取超时(8s),请检查配置文件')), 8000))
|
||||
const config = await Promise.race([configPromise, timeoutPromise])
|
||||
const providers = config?.models?.providers || {}
|
||||
_primaryModel = config?.agents?.defaults?.model?.primary || ''
|
||||
const models = []
|
||||
const seen = new Set()
|
||||
if (_primaryModel) {
|
||||
seen.add(_primaryModel)
|
||||
models.push(_primaryModel)
|
||||
}
|
||||
for (const [providerKey, provider] of Object.entries(providers)) {
|
||||
for (const item of (provider?.models || [])) {
|
||||
const modelId = typeof item === 'string' ? item : item?.id
|
||||
if (!modelId) continue
|
||||
const full = `${providerKey}/${modelId}`
|
||||
if (seen.has(full)) continue
|
||||
seen.add(full)
|
||||
models.push(full)
|
||||
}
|
||||
}
|
||||
_availableModels = models
|
||||
const saved = localStorage.getItem(STORAGE_MODEL_KEY) || ''
|
||||
_selectedModel = models.includes(saved) ? saved : (_primaryModel || models[0] || '')
|
||||
renderModelSelect()
|
||||
if (showToast) toast(`已刷新,共 ${models.length} 个模型`, 'success')
|
||||
} catch (e) {
|
||||
_availableModels = []
|
||||
_primaryModel = ''
|
||||
_selectedModel = ''
|
||||
renderModelSelect(`加载失败: ${e.message || e}`)
|
||||
if (showToast) toast('加载模型失败: ' + (e.message || e), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function renderModelSelect(errorText = '') {
|
||||
if (!_modelSelectEl) return
|
||||
if (!_availableModels.length) {
|
||||
_modelSelectEl.innerHTML = `<option value="">${escapeAttr(errorText || '未配置模型')}</option>`
|
||||
_modelSelectEl.disabled = true
|
||||
_modelSelectEl.title = errorText || '请先到模型配置页面添加模型'
|
||||
return
|
||||
}
|
||||
_modelSelectEl.disabled = _isApplyingModel
|
||||
_modelSelectEl.innerHTML = _availableModels.map(full => {
|
||||
const suffix = full === _primaryModel ? '(主模型)' : ''
|
||||
return `<option value="${escapeAttr(full)}" ${full === _selectedModel ? 'selected' : ''}>${full}${suffix}</option>`
|
||||
}).join('')
|
||||
_modelSelectEl.title = _selectedModel ? `切换当前会话模型:${_selectedModel}` : '切换当前会话模型'
|
||||
}
|
||||
|
||||
function escapeAttr(str) {
|
||||
return (str || '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
/** 本地会话别名缓存 */
|
||||
function getSessionNames() {
|
||||
try { return JSON.parse(localStorage.getItem(STORAGE_SESSION_NAMES_KEY) || '{}') } catch { return {} }
|
||||
}
|
||||
function setSessionName(key, name) {
|
||||
const names = getSessionNames()
|
||||
if (name) names[key] = name
|
||||
else delete names[key]
|
||||
localStorage.setItem(STORAGE_SESSION_NAMES_KEY, JSON.stringify(names))
|
||||
}
|
||||
function getDisplayLabel(key) {
|
||||
const custom = getSessionNames()[key]
|
||||
return custom || parseSessionLabel(key)
|
||||
}
|
||||
|
||||
function getSidebarOpen() {
|
||||
return localStorage.getItem(STORAGE_SIDEBAR_KEY) === '1'
|
||||
}
|
||||
|
||||
function setSidebarOpen(open) {
|
||||
localStorage.setItem(STORAGE_SIDEBAR_KEY, open ? '1' : '0')
|
||||
}
|
||||
|
||||
async function applySelectedModel() {
|
||||
if (!_selectedModel) {
|
||||
toast('请先选择模型', 'warning')
|
||||
return
|
||||
}
|
||||
if (!wsClient.gatewayReady || !_sessionKey) {
|
||||
toast('Gateway 未就绪,连接成功后再切换模型', 'warning')
|
||||
return
|
||||
}
|
||||
_isApplyingModel = true
|
||||
renderModelSelect()
|
||||
try {
|
||||
await wsClient.chatSend(_sessionKey, `/model ${_selectedModel}`)
|
||||
toast(`已切换当前会话模型为 ${_selectedModel}`, 'success')
|
||||
} catch (e) {
|
||||
toast('切换模型失败: ' + (e.message || e), 'error')
|
||||
} finally {
|
||||
_isApplyingModel = false
|
||||
renderModelSelect()
|
||||
}
|
||||
}
|
||||
|
||||
// ── 连接引导遮罩 ──
|
||||
|
||||
function bindConnectOverlay(page) {
|
||||
@@ -450,9 +590,21 @@ function renderSessionList(sessions) {
|
||||
const key = s.sessionKey || s.key || ''
|
||||
const active = key === _sessionKey ? ' active' : ''
|
||||
const label = parseSessionLabel(key)
|
||||
return `<div class="chat-session-item${active}" data-key="${key}">
|
||||
<span class="chat-session-label">${label}</span>
|
||||
<button class="chat-session-del" data-del="${key}" title="删除">×</button>
|
||||
const ts = s.updatedAt || s.lastActivity || s.createdAt || 0
|
||||
const timeStr = ts ? formatSessionTime(ts) : ''
|
||||
const msgCount = s.messageCount || s.messages || 0
|
||||
const agentId = parseSessionAgent(key)
|
||||
const displayLabel = getDisplayLabel(key) || label
|
||||
return `<div class="chat-session-card${active}" data-key="${escapeAttr(key)}">
|
||||
<div class="chat-session-card-header">
|
||||
<span class="chat-session-label" title="双击重命名">${escapeAttr(displayLabel)}</span>
|
||||
<button class="chat-session-del" data-del="${escapeAttr(key)}" title="删除">×</button>
|
||||
</div>
|
||||
<div class="chat-session-card-meta">
|
||||
${agentId && agentId !== 'main' ? `<span class="chat-session-agent">${escapeAttr(agentId)}</span>` : ''}
|
||||
${msgCount > 0 ? `<span>${msgCount} 条消息</span>` : ''}
|
||||
${timeStr ? `<span>${timeStr}</span>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
}).join('')
|
||||
|
||||
@@ -462,6 +614,31 @@ function renderSessionList(sessions) {
|
||||
const item = e.target.closest('[data-key]')
|
||||
if (item) switchSession(item.dataset.key)
|
||||
}
|
||||
_sessionListEl.ondblclick = (e) => {
|
||||
const labelEl = e.target.closest('.chat-session-label')
|
||||
if (!labelEl) return
|
||||
const card = labelEl.closest('[data-key]')
|
||||
if (!card) return
|
||||
e.stopPropagation()
|
||||
renameSession(card.dataset.key, labelEl)
|
||||
}
|
||||
}
|
||||
|
||||
function formatSessionTime(ts) {
|
||||
const d = new Date(typeof ts === 'number' && ts < 1e12 ? ts * 1000 : ts)
|
||||
if (isNaN(d.getTime())) return ''
|
||||
const now = new Date()
|
||||
const diffMs = now - d
|
||||
if (diffMs < 60000) return '刚刚'
|
||||
if (diffMs < 3600000) return Math.floor(diffMs / 60000) + ' 分钟前'
|
||||
if (diffMs < 86400000) return Math.floor(diffMs / 3600000) + ' 小时前'
|
||||
if (diffMs < 604800000) return Math.floor(diffMs / 86400000) + ' 天前'
|
||||
return `${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function parseSessionAgent(key) {
|
||||
const parts = (key || '').split(':')
|
||||
return parts.length >= 2 ? parts[1] : ''
|
||||
}
|
||||
|
||||
function parseSessionLabel(key) {
|
||||
@@ -499,7 +676,7 @@ async function showNewSessionDialog() {
|
||||
title: '新建会话',
|
||||
fields: [
|
||||
{ name: 'name', label: '会话名称', value: '', placeholder: '例如:翻译助手' },
|
||||
{ name: 'agent', label: '智能体', type: 'select', value: defaultAgent, options: initialOptions },
|
||||
{ name: 'agent', label: 'Agent', type: 'select', value: defaultAgent, options: initialOptions },
|
||||
],
|
||||
onConfirm: (result) => {
|
||||
const name = (result.name || '').trim()
|
||||
@@ -555,6 +732,9 @@ async function deleteSession(key) {
|
||||
|
||||
async function resetCurrentSession() {
|
||||
if (!_sessionKey) return
|
||||
const label = getDisplayLabel(_sessionKey)
|
||||
const yes = await showConfirm(`确定要重置会话「${label}」吗?\n\n重置后将清空该会话的所有聊天记录,此操作不可撤销。`)
|
||||
if (!yes) return
|
||||
try {
|
||||
await wsClient.sessionsReset(_sessionKey)
|
||||
clearMessages()
|
||||
@@ -568,7 +748,42 @@ async function resetCurrentSession() {
|
||||
|
||||
function updateSessionTitle() {
|
||||
const el = _page?.querySelector('#chat-title')
|
||||
if (el) el.textContent = parseSessionLabel(_sessionKey)
|
||||
if (el) el.textContent = getDisplayLabel(_sessionKey)
|
||||
}
|
||||
|
||||
function renameSession(key, labelEl) {
|
||||
const current = getDisplayLabel(key)
|
||||
const input = document.createElement('input')
|
||||
input.type = 'text'
|
||||
input.value = current
|
||||
input.className = 'chat-session-rename-input'
|
||||
input.style.cssText = 'width:100%;padding:2px 6px;border:1px solid var(--accent);border-radius:4px;background:var(--bg-secondary);color:var(--text-primary);font-size:12px;outline:none'
|
||||
const originalText = labelEl.textContent
|
||||
labelEl.textContent = ''
|
||||
labelEl.appendChild(input)
|
||||
input.focus()
|
||||
input.select()
|
||||
|
||||
let done = false
|
||||
const finish = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
const newName = input.value.trim()
|
||||
if (newName && newName !== parseSessionLabel(key)) {
|
||||
setSessionName(key, newName)
|
||||
toast('会话已重命名', 'success')
|
||||
} else if (!newName || newName === parseSessionLabel(key)) {
|
||||
setSessionName(key, '') // clear custom name
|
||||
}
|
||||
labelEl.textContent = getDisplayLabel(key)
|
||||
// 如果是当前会话,同步更新顶部标题
|
||||
if (key === _sessionKey) updateSessionTitle()
|
||||
}
|
||||
input.addEventListener('blur', finish)
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); input.blur() }
|
||||
if (e.key === 'Escape') { input.value = originalText; input.blur() }
|
||||
})
|
||||
}
|
||||
|
||||
// ── 快捷指令面板 ──
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* 定时任务管理
|
||||
* 通过 Gateway WebSocket RPC 直接管理计划任务(cron.list / cron.add / cron.update / cron.remove / cron.run)
|
||||
* 通过 Gateway WebSocket RPC 管理(cron.list / cron.add / cron.update / cron.remove / cron.run)
|
||||
* 注意:openclaw.json 不支持 cron.jobs 字段,定时任务只能通过 Gateway 在线管理
|
||||
*/
|
||||
import { toast } from '../components/toast.js'
|
||||
import { showContentModal, showConfirm } from '../components/modal.js'
|
||||
import { icon } from '../lib/icons.js'
|
||||
import { onGatewayChange } from '../lib/app-state.js'
|
||||
import { wsClient } from '../lib/ws-client.js'
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { api, invalidate } from '../lib/tauri-api.js'
|
||||
|
||||
let _unsub = null
|
||||
|
||||
@@ -34,7 +35,15 @@ export async function render() {
|
||||
<h1 class="page-title">定时任务</h1>
|
||||
<p class="page-desc">创建计划任务,让 AI 按设定时间自动执行指令</p>
|
||||
</div>
|
||||
<div id="cron-gw-warn" style="display:none"></div>
|
||||
<div id="cron-gw-hint" style="display:none;margin-bottom:var(--space-md)">
|
||||
<div class="config-section" style="border-left:3px solid var(--warning);padding:12px 16px">
|
||||
<div style="display:flex;align-items:center;gap:8px;color:var(--text-secondary);font-size:var(--font-size-sm)">
|
||||
${icon('alert-circle', 16)}
|
||||
<span>定时任务通过 Gateway 管理。请先启动 Gateway 后使用此功能。</span>
|
||||
<a href="#/services" class="btn btn-sm btn-secondary" style="margin-left:auto;font-size:11px">服务管理</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cron-stats" class="stat-cards" style="margin-bottom:var(--space-lg)"></div>
|
||||
<div class="config-actions" style="margin-bottom:var(--space-md)">
|
||||
<button class="btn btn-primary btn-sm" id="btn-new-task">+ 创建任务</button>
|
||||
@@ -48,14 +57,17 @@ export async function render() {
|
||||
page.querySelector('#btn-new-task').onclick = () => openTaskDialog(null, page, state)
|
||||
page.querySelector('#btn-refresh-tasks').onclick = () => fetchJobs(page, state)
|
||||
|
||||
// 自动修复:移除可能被写入的无效 cron.jobs 字段
|
||||
fixInvalidCronConfig()
|
||||
|
||||
// 监听 Gateway 状态变化
|
||||
if (_unsub) _unsub()
|
||||
_unsub = onGatewayChange(() => {
|
||||
updateGatewayWarning(page)
|
||||
updateGatewayHint(page)
|
||||
fetchJobs(page, state)
|
||||
})
|
||||
|
||||
updateGatewayWarning(page)
|
||||
updateGatewayHint(page)
|
||||
await fetchJobs(page, state)
|
||||
|
||||
return page
|
||||
@@ -65,35 +77,36 @@ export function cleanup() {
|
||||
if (_unsub) { _unsub(); _unsub = null }
|
||||
}
|
||||
|
||||
// ── Gateway 连接检查 ──
|
||||
/** 自动移除无效的 cron.jobs 字段(之前版本错误写入,会导致 Gateway 崩溃) */
|
||||
async function fixInvalidCronConfig() {
|
||||
try {
|
||||
invalidate('read_openclaw_config')
|
||||
const config = await api.readOpenclawConfig()
|
||||
if (config?.cron?.jobs) {
|
||||
delete config.cron.jobs
|
||||
if (Object.keys(config.cron).length === 0) delete config.cron
|
||||
await api.writeOpenclawConfig(config)
|
||||
toast('已自动修复配置(移除无效的 cron.jobs)', 'info')
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function isGatewayUp() {
|
||||
return wsClient && wsClient._gatewayReady
|
||||
return wsClient && wsClient.gatewayReady
|
||||
}
|
||||
|
||||
function updateGatewayWarning(page) {
|
||||
const el = page.querySelector('#cron-gw-warn')
|
||||
function updateGatewayHint(page) {
|
||||
const el = page.querySelector('#cron-gw-hint')
|
||||
if (!el) return
|
||||
if (isGatewayUp()) {
|
||||
el.style.display = 'none'
|
||||
} else {
|
||||
el.style.display = ''
|
||||
el.innerHTML = `
|
||||
<div class="config-section" style="border-color:var(--warning);margin-bottom:var(--space-md)">
|
||||
<div style="display:flex;align-items:center;gap:8px;color:var(--warning);font-size:var(--font-size-sm)">
|
||||
${icon('alert-circle', 16)}
|
||||
Gateway 未连接,定时任务功能需要 Gateway 在线才能使用
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
el.style.display = isGatewayUp() ? 'none' : ''
|
||||
}
|
||||
|
||||
// ── 数据加载(直连 Gateway RPC) ──
|
||||
// ── 数据加载(Gateway RPC) ──
|
||||
|
||||
async function fetchJobs(page, state) {
|
||||
if (!isGatewayUp()) {
|
||||
state.jobs = []
|
||||
state.loading = false
|
||||
renderStats(page, state)
|
||||
renderList(page, state)
|
||||
return
|
||||
@@ -107,24 +120,18 @@ async function fetchJobs(page, state) {
|
||||
let jobs = res?.jobs || res
|
||||
if (!Array.isArray(jobs)) jobs = []
|
||||
|
||||
// 映射 Gateway CronJob 格式到 UI 格式
|
||||
state.jobs = jobs.map(j => ({
|
||||
id: j.id,
|
||||
name: j.name || '未命名',
|
||||
id: j.name || j.id,
|
||||
name: j.name || j.id || '未命名',
|
||||
description: j.description || '',
|
||||
message: j.payload?.message || j.payload?.text || '',
|
||||
payloadKind: j.payload?.kind || 'agentTurn',
|
||||
schedule: j.schedule || {},
|
||||
enabled: j.enabled !== false,
|
||||
agentId: j.agentId || null,
|
||||
// 运行状态
|
||||
lastRunStatus: j.state?.lastRunStatus || j.state?.lastStatus || null,
|
||||
lastRunAtMs: j.state?.lastRunAtMs || null,
|
||||
lastError: j.state?.lastError || null,
|
||||
lastDurationMs: j.state?.lastDurationMs || null,
|
||||
nextRunAtMs: j.state?.nextRunAtMs || null,
|
||||
consecutiveErrors: j.state?.consecutiveErrors || 0,
|
||||
updatedAtMs: j.updatedAtMs || null,
|
||||
}))
|
||||
} catch (e) {
|
||||
toast('获取任务列表失败: ' + e, 'error')
|
||||
@@ -240,7 +247,7 @@ function renderList(page, state) {
|
||||
const btn = e.currentTarget
|
||||
btn.disabled = true
|
||||
try {
|
||||
await wsClient.request('cron.run', { id: jid, mode: 'force' })
|
||||
await wsClient.request('cron.run', { name: jid })
|
||||
toast('任务已触发执行', 'success')
|
||||
setTimeout(() => fetchJobs(page, state), 2000)
|
||||
} catch (err) { toast('触发失败: ' + err, 'error') }
|
||||
@@ -252,7 +259,7 @@ function renderList(page, state) {
|
||||
btn.disabled = true
|
||||
btn.innerHTML = icon('refresh-cw', 14)
|
||||
try {
|
||||
await wsClient.request('cron.update', { id: jid, patch: { enabled: !job.enabled } })
|
||||
await wsClient.request('cron.update', { name: jid, patch: { enabled: !job.enabled } })
|
||||
toast(job.enabled ? '已暂停' : '已启用', 'info')
|
||||
await fetchJobs(page, state)
|
||||
} catch (err) { toast('操作失败: ' + err, 'error'); btn.disabled = false; btn.innerHTML = job.enabled ? icon('pause', 14) : icon('play', 14) }
|
||||
@@ -260,16 +267,16 @@ function renderList(page, state) {
|
||||
|
||||
card.querySelector('[data-action="edit"]').onclick = () => openTaskDialog(job, page, state)
|
||||
|
||||
card.querySelector('[data-action="delete"]').onclick = async (e) => {
|
||||
card.querySelector('[data-action="delete"]').onclick = async function() {
|
||||
const btn = this
|
||||
const yes = await showConfirm(`确定删除任务「${job.name}」?`)
|
||||
if (!yes) return
|
||||
const btn = e.currentTarget
|
||||
btn.disabled = true
|
||||
if (btn) btn.disabled = true
|
||||
try {
|
||||
await wsClient.request('cron.remove', { id: jid })
|
||||
await wsClient.request('cron.remove', { name: jid })
|
||||
toast('已删除', 'info')
|
||||
await fetchJobs(page, state)
|
||||
} catch (err) { toast('删除失败: ' + err, 'error'); btn.disabled = false }
|
||||
} catch (err) { toast('删除失败: ' + err, 'error'); if (btn) btn.disabled = false }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -278,10 +285,9 @@ function renderList(page, state) {
|
||||
|
||||
async function openTaskDialog(job, page, state) {
|
||||
if (!isGatewayUp()) {
|
||||
toast('Gateway 未连接,无法管理任务', 'warning')
|
||||
toast('Gateway 未连接,无法管理定时任务。请先启动 Gateway', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
const isEdit = !!job
|
||||
const initSchedule = extractCronExpr(job?.schedule) || '0 9 * * *'
|
||||
const formId = 'cron-form-' + Date.now()
|
||||
@@ -291,18 +297,8 @@ async function openTaskDialog(job, page, state) {
|
||||
return `<button type="button" class="btn btn-sm ${selected ? 'btn-primary' : 'btn-secondary'} cron-shortcut" data-expr="${s.expr}">${s.text}</button>`
|
||||
}).join('')
|
||||
|
||||
// 加载 agent 列表用于选择器
|
||||
let agents = []
|
||||
try {
|
||||
const res = await api.listAgents()
|
||||
agents = Array.isArray(res) ? res : (res?.agents || [])
|
||||
} catch {}
|
||||
|
||||
const agentOptionsHtml = agents.length
|
||||
? `<option value="">默认 Agent</option>` + agents.map(a =>
|
||||
`<option value="${escapeAttr(a.id)}" ${job?.agentId === a.id ? 'selected' : ''}>${escapeHtml(a.name || a.id)}</option>`
|
||||
).join('')
|
||||
: `<option value="">默认 Agent(未检测到其他 Agent)</option>`
|
||||
// 先用默认选项,弹窗后异步加载 Agent 列表
|
||||
const agentOptionsHtml = `<option value="" ${!job?.agentId ? 'selected' : ''}>默认 Agent</option>${job?.agentId ? `<option value="${escapeAttr(job.agentId)}" selected>${escapeHtml(job.agentId)}</option>` : ''}`
|
||||
|
||||
const content = `
|
||||
<form id="${formId}" style="display:flex;flex-direction:column;gap:var(--space-md)">
|
||||
@@ -344,6 +340,18 @@ async function openTaskDialog(job, page, state) {
|
||||
width: 500,
|
||||
})
|
||||
|
||||
// 异步加载 Agent 列表并更新下拉框(不阻塞弹窗显示)
|
||||
api.listAgents().then(res => {
|
||||
const agents = Array.isArray(res) ? res : (res?.agents || [])
|
||||
if (!agents.length) return
|
||||
const select = modal.querySelector('select[name="agentId"]')
|
||||
if (!select) return
|
||||
const currentVal = select.value
|
||||
select.innerHTML = `<option value="">默认 Agent</option>` + agents.map(a =>
|
||||
`<option value="${escapeAttr(a.id)}" ${a.id === (job?.agentId || currentVal) ? 'selected' : ''}>${escapeHtml(a.name || a.id)}</option>`
|
||||
).join('')
|
||||
}).catch(() => {})
|
||||
|
||||
// 快捷预设按钮
|
||||
modal.querySelectorAll('.cron-shortcut').forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
@@ -396,7 +404,7 @@ async function openTaskDialog(job, page, state) {
|
||||
patch.schedule = { kind: 'cron', expr: schedule }
|
||||
patch.payload = { kind: 'agentTurn', message }
|
||||
if (agentId) patch.agentId = agentId
|
||||
await wsClient.request('cron.update', { id: job.id, patch })
|
||||
await wsClient.request('cron.update', { name: job.id, patch })
|
||||
toast('任务已更新', 'success')
|
||||
} else {
|
||||
const params = {
|
||||
|
||||
@@ -157,6 +157,14 @@ function renderStatCards(page, services, version, agents, config) {
|
||||
<div class="stat-card-value">${runningCount}/${services.length}</div>
|
||||
<div class="stat-card-meta">存活率 ${services.length ? Math.round(runningCount / services.length * 100) : 0}%</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card-clickable" id="card-control-ui" title="打开 OpenClaw 原生控制面板">
|
||||
<div class="stat-card-header">
|
||||
<span class="stat-card-label">Control UI</span>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="opacity:0.5"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
</div>
|
||||
<div class="stat-card-value" style="font-size:var(--font-size-sm)">OpenClaw 原生面板</div>
|
||||
<div class="stat-card-meta">${gw?.running ? '点击打开浏览器' : 'Gateway 未运行'}</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -178,75 +186,95 @@ function renderOverview(page, services, mcpConfig, backups, config, agents) {
|
||||
const latestBackup = backups.length > 0 ? backups.sort((a,b) => b.created_at - a.created_at)[0] : null
|
||||
const lastUpdate = config?.meta?.lastTouchedVersion || '未知'
|
||||
|
||||
const gwPort = config?.gateway?.port || 18789
|
||||
const primaryModel = config?.agents?.defaults?.model?.primary || '未设置'
|
||||
|
||||
containerEl.innerHTML = `
|
||||
<div class="dashboard-overview">
|
||||
<div class="overview-section">
|
||||
<div class="overview-item">
|
||||
<div class="overview-label">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
|
||||
Gateway 核心网关
|
||||
<div class="overview-grid">
|
||||
<div class="overview-card" data-nav="/gateway">
|
||||
<div class="overview-card-icon" style="color:${gw?.running ? 'var(--success)' : 'var(--error)'}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||
</div>
|
||||
<div class="overview-actions">
|
||||
<span class="overview-status" style="color: ${gw?.running ? 'var(--success)' : 'var(--error)'}">
|
||||
${gw?.running ? '运行中' : '已停止'}
|
||||
</span>
|
||||
<div class="overview-card-body">
|
||||
<div class="overview-card-title">Gateway</div>
|
||||
<div class="overview-card-value" style="color:${gw?.running ? 'var(--success)' : 'var(--error)'}">${gw?.running ? '运行中' : '已停止'}</div>
|
||||
<div class="overview-card-meta">端口 ${gwPort} ${gw?.pid ? '· PID ' + gw.pid : ''}</div>
|
||||
</div>
|
||||
<div class="overview-card-actions">
|
||||
${gw?.running
|
||||
? '<button class="btn btn-danger btn-xs" data-action="stop-gw">停止</button><button class="btn btn-secondary btn-xs" data-action="restart-gw">重启</button>'
|
||||
: '<button class="btn btn-primary btn-xs" data-action="start-gw">启动</button>'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="overview-item">
|
||||
<div class="overview-label">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"></polygon><polyline points="2 17 12 22 22 17"></polyline><polyline points="2 12 12 17 22 12"></polyline></svg>
|
||||
MCP 扩展工具
|
||||
</div>
|
||||
<div class="overview-value">
|
||||
${mcpCount} 个已挂载
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overview-section">
|
||||
<div class="overview-item">
|
||||
<div class="overview-label">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>
|
||||
最近备份
|
||||
<div class="overview-card" data-nav="/models">
|
||||
<div class="overview-card-icon" style="color:var(--accent)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/></svg>
|
||||
</div>
|
||||
<div class="overview-value">
|
||||
${latestBackup ? formatDate(latestBackup.created_at) : '从无备份'}
|
||||
<div class="overview-card-body">
|
||||
<div class="overview-card-title">主模型</div>
|
||||
<div class="overview-card-value" style="font-size:var(--font-size-sm)">${primaryModel}</div>
|
||||
<div class="overview-card-meta">并发上限 ${config?.agents?.defaults?.maxConcurrent || 4}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overview-item">
|
||||
<div class="overview-label">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
|
||||
配置版本标识
|
||||
|
||||
<div class="overview-card" data-nav="/skills">
|
||||
<div class="overview-card-icon" style="color:var(--warning)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>
|
||||
</div>
|
||||
<div class="overview-value">
|
||||
${lastUpdate}
|
||||
<div class="overview-card-body">
|
||||
<div class="overview-card-title">MCP 工具</div>
|
||||
<div class="overview-card-value">${mcpCount} 个</div>
|
||||
<div class="overview-card-meta">已挂载扩展</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overview-item">
|
||||
<div class="overview-label">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
|
||||
并行推理队列最大值
|
||||
|
||||
<div class="overview-card" data-nav="/services">
|
||||
<div class="overview-card-icon" style="color:var(--text-tertiary)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
</div>
|
||||
<div class="overview-value">
|
||||
${config?.agents?.defaults?.maxConcurrent || 4}
|
||||
<div class="overview-card-body">
|
||||
<div class="overview-card-title">最近备份</div>
|
||||
<div class="overview-card-value" style="font-size:var(--font-size-sm)">${latestBackup ? formatDate(latestBackup.created_at) : '从无备份'}</div>
|
||||
<div class="overview-card-meta">${backups.length} 个备份文件</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overview-item">
|
||||
<div class="overview-label">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="9" y1="3" x2="9" y2="21"></line></svg>
|
||||
工作区文件隔离
|
||||
|
||||
<div class="overview-card" data-nav="/agents">
|
||||
<div class="overview-card-icon" style="color:var(--success)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
|
||||
</div>
|
||||
<div class="overview-value" style="color: ${agents.some(a => a.workspace) ? 'var(--success)' : 'var(--text-tertiary)'}">
|
||||
${agents.filter(a => a.workspace).length} 个 Agent 启用
|
||||
<div class="overview-card-body">
|
||||
<div class="overview-card-title">Agent 舰队</div>
|
||||
<div class="overview-card-value">${agents.length} 个</div>
|
||||
<div class="overview-card-meta">${agents.filter(a => a.workspace).length} 个独立工作区</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overview-card">
|
||||
<div class="overview-card-icon" style="color:var(--text-tertiary)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</div>
|
||||
<div class="overview-card-body">
|
||||
<div class="overview-card-title">配置版本</div>
|
||||
<div class="overview-card-value" style="font-size:var(--font-size-sm)">${lastUpdate}</div>
|
||||
<div class="overview-card-meta">openclaw.json</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// 概览卡片点击导航
|
||||
containerEl.querySelectorAll('[data-nav]').forEach(card => {
|
||||
card.style.cursor = 'pointer'
|
||||
card.addEventListener('click', (e) => {
|
||||
if (e.target.closest('button')) return
|
||||
navigate(card.dataset.nav)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function renderLogs(page, logs) {
|
||||
@@ -265,6 +293,31 @@ function bindActions(page) {
|
||||
const btnUpdate = page.querySelector('#btn-check-update')
|
||||
const btnCreateBackup = page.querySelector('#btn-create-backup')
|
||||
|
||||
// Control UI 卡片点击 → 打开 OpenClaw 原生面板(用事件委托,因为卡片是动态渲染的)
|
||||
page.addEventListener('click', async (e) => {
|
||||
const card = e.target.closest('#card-control-ui')
|
||||
if (!card) return
|
||||
if (e.target.closest('button')) return
|
||||
try {
|
||||
const config = await api.readOpenclawConfig()
|
||||
const port = config?.gateway?.port || 18789
|
||||
const url = `http://127.0.0.1:${port}`
|
||||
// 尝试多种方式打开浏览器
|
||||
if (window.__TAURI_INTERNALS__) {
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-shell')
|
||||
await open(url)
|
||||
} catch {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
} else {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
} catch (e2) {
|
||||
toast('打开 Control UI 失败: ' + (e2.message || e2), 'error')
|
||||
}
|
||||
})
|
||||
|
||||
// 概览区域的 Gateway 启动/停止/重启 + ClawApp 导航
|
||||
page.addEventListener('click', async (e) => {
|
||||
const actionBtn = e.target.closest('[data-action]')
|
||||
|
||||
@@ -21,6 +21,7 @@ const PROVIDER_PRESETS = [
|
||||
{ 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: 'ollama', label: 'Ollama (本地)', baseUrl: 'http://127.0.0.1:11434/v1', api: 'openai-completions' },
|
||||
]
|
||||
|
||||
// gpt.qt.cool 推广配置
|
||||
@@ -31,16 +32,7 @@ const QTCOOL = {
|
||||
usageUrl: 'https://gpt.qt.cool/user?key=',
|
||||
providerKey: 'qtcool',
|
||||
api: 'openai-completions',
|
||||
models: [
|
||||
{ id: 'gpt-5.2-codex', name: 'GPT-5.2 Codex', contextWindow: 128000, reasoning: true },
|
||||
{ id: 'gpt-5.2', name: 'GPT-5.2', contextWindow: 128000 },
|
||||
{ id: 'gpt-5.1-codex-max', name: 'GPT-5.1 Codex Max', contextWindow: 128000, reasoning: true },
|
||||
{ id: 'gpt-5.1-codex-mini', name: 'GPT-5.1 Codex Mini', contextWindow: 128000, reasoning: true },
|
||||
{ id: 'gpt-5.1-codex', name: 'GPT-5.1 Codex', contextWindow: 128000, reasoning: true },
|
||||
{ id: 'gpt-5.1', name: 'GPT-5.1', contextWindow: 128000 },
|
||||
{ id: 'gpt-5-codex', name: 'GPT-5 Codex', contextWindow: 128000, reasoning: true },
|
||||
{ id: 'gpt-5', name: 'GPT-5', contextWindow: 128000 },
|
||||
]
|
||||
models: [] // 不使用硬编码模型列表,始终从 API 动态获取最新列表
|
||||
}
|
||||
|
||||
// 常用模型预设(按服务商分组)
|
||||
@@ -62,6 +54,11 @@ const MODEL_PRESETS = {
|
||||
{ id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', contextWindow: 1000000, reasoning: true },
|
||||
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', contextWindow: 1000000 },
|
||||
],
|
||||
ollama: [
|
||||
{ id: 'qwen2.5:7b', name: 'Qwen 2.5 7B', contextWindow: 32768 },
|
||||
{ id: 'llama3.2', name: 'Llama 3.2', contextWindow: 8192 },
|
||||
{ id: 'gemma3', name: 'Gemma 3', contextWindow: 32768 },
|
||||
],
|
||||
}
|
||||
|
||||
export async function render() {
|
||||
@@ -133,6 +130,15 @@ async function loadConfig(page, state) {
|
||||
const listEl = page.querySelector('#providers-list')
|
||||
try {
|
||||
state.config = await api.readOpenclawConfig()
|
||||
// 自动修复现有配置中的 baseUrl(如 Ollama 缺少 /v1),一次性迁移
|
||||
const before = JSON.stringify(state.config?.models?.providers || {})
|
||||
normalizeProviderUrls(state.config)
|
||||
const after = JSON.stringify(state.config?.models?.providers || {})
|
||||
if (before !== after) {
|
||||
console.log('[models] 自动修复了服务商 baseUrl,正在保存...')
|
||||
await api.writeOpenclawConfig(state.config)
|
||||
toast('已自动修复模型接口地址(如 Ollama /v1)', 'info')
|
||||
}
|
||||
renderDefaultBar(page, state)
|
||||
renderProviders(page, state)
|
||||
} catch (e) {
|
||||
@@ -408,11 +414,41 @@ function autoSave(state) {
|
||||
_saveTimer = setTimeout(() => doAutoSave(state), 300)
|
||||
}
|
||||
|
||||
/** 保存前规范化所有服务商的 baseUrl,确保 Gateway 能正确调用 */
|
||||
function normalizeProviderUrls(config) {
|
||||
const providers = config?.models?.providers
|
||||
if (!providers) return
|
||||
for (const [, p] of Object.entries(providers)) {
|
||||
if (!p.baseUrl) continue
|
||||
let url = p.baseUrl.replace(/\/+$/, '')
|
||||
// 去掉尾部的已知端点路径(用户可能粘贴了完整 URL)
|
||||
for (const suffix of ['/api/chat', '/api/generate', '/api/tags', '/api', '/chat/completions', '/completions', '/responses', '/messages', '/models']) {
|
||||
if (url.endsWith(suffix)) { url = url.slice(0, -suffix.length); break }
|
||||
}
|
||||
url = url.replace(/\/+$/, '')
|
||||
const apiType = (p.api || 'openai-completions').toLowerCase()
|
||||
if (apiType === 'anthropic-messages') {
|
||||
if (!url.endsWith('/v1')) url += '/v1'
|
||||
} else if (apiType !== 'google-gemini') {
|
||||
// Ollama 端口检测:11434 默认需要加 /v1
|
||||
if (/:11434$/.test(url)) url += '/v1'
|
||||
// 其他 OpenAI 兼容: 确保有 /v1
|
||||
if (!url.endsWith('/v1')) {
|
||||
const idx = url.indexOf('/v1/')
|
||||
if (idx >= 0) url = url.slice(0, idx + 3)
|
||||
else url += '/v1'
|
||||
}
|
||||
}
|
||||
p.baseUrl = url
|
||||
}
|
||||
}
|
||||
|
||||
// 仅保存配置,不重启 Gateway(用于测试结果等元数据持久化)
|
||||
async function saveConfigOnly(state) {
|
||||
try {
|
||||
const primary = getCurrentPrimary(state.config)
|
||||
if (primary) applyDefaultModel(state)
|
||||
normalizeProviderUrls(state.config)
|
||||
await api.writeOpenclawConfig(state.config)
|
||||
} catch (e) {
|
||||
toast('保存失败: ' + e, 'error')
|
||||
@@ -423,6 +459,7 @@ async function doAutoSave(state) {
|
||||
try {
|
||||
const primary = getCurrentPrimary(state.config)
|
||||
if (primary) applyDefaultModel(state)
|
||||
normalizeProviderUrls(state.config)
|
||||
await api.writeOpenclawConfig(state.config)
|
||||
|
||||
// 重启 Gateway 使配置生效(Gateway 不支持 SIGHUP 热重载)
|
||||
@@ -770,6 +807,11 @@ function bindTopActions(page, state) {
|
||||
btn.innerHTML = `${icon('zap', 14)} 一键添加全部模型`
|
||||
btn.disabled = false
|
||||
|
||||
if (!models.length) {
|
||||
toast('无法获取模型列表,请检查网络或稍后重试', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
pushUndo(state)
|
||||
if (!state.config.models) state.config.models = {}
|
||||
if (!state.config.models.providers) state.config.models.providers = {}
|
||||
@@ -832,7 +874,7 @@ function addProvider(page, state) {
|
||||
<div class="form-group">
|
||||
<label class="form-label">接口地址</label>
|
||||
<input class="form-input" data-name="baseUrl" placeholder="https://api.openai.com/v1">
|
||||
<div class="form-hint">模型服务的 API 地址,通常以 /v1 结尾</div>
|
||||
<div class="form-hint">模型服务的 API 地址,通常以 /v1 结尾;Ollama 可直接填 http://127.0.0.1:11434</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">密钥 (API Key)</label>
|
||||
@@ -844,7 +886,7 @@ function addProvider(page, state) {
|
||||
<select class="form-input" data-name="api">
|
||||
${API_TYPES.map(t => `<option value="${t.value}">${t.label}</option>`).join('')}
|
||||
</select>
|
||||
<div class="form-hint">大多数中转站选「OpenAI 兼容」即可</div>
|
||||
<div class="form-hint">大多数中转站和 Ollama 选「OpenAI 兼容」即可</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary btn-sm" data-action="cancel">取消</button>
|
||||
@@ -903,12 +945,12 @@ function editProvider(page, state, providerKey) {
|
||||
showModal({
|
||||
title: `编辑服务商: ${providerKey}`,
|
||||
fields: [
|
||||
{ name: 'baseUrl', label: '接口地址', value: p.baseUrl || '', hint: '模型服务的 API 地址,通常以 /v1 结尾' },
|
||||
{ name: 'baseUrl', label: '接口地址', value: p.baseUrl || '', hint: '模型服务的 API 地址,通常以 /v1 结尾;Ollama 可直接填 http://127.0.0.1:11434' },
|
||||
{ name: 'apiKey', label: '密钥 (API Key)', value: p.apiKey || '', hint: '修改后自动保存生效' },
|
||||
{
|
||||
name: 'api', label: '接口类型', type: 'select', value: p.api || 'openai-completions',
|
||||
options: API_TYPES,
|
||||
hint: '大多数中转站选「OpenAI 兼容」即可',
|
||||
hint: '大多数中转站和 Ollama 选「OpenAI 兼容」即可',
|
||||
},
|
||||
],
|
||||
onConfirm: ({ baseUrl, apiKey, api: apiType }) => {
|
||||
@@ -1157,7 +1199,7 @@ async function handleBatchTest(section, state, providerKey) {
|
||||
|
||||
const start = Date.now()
|
||||
try {
|
||||
await api.testModel(provider.baseUrl, provider.apiKey || '', modelId)
|
||||
await api.testModel(provider.baseUrl, provider.apiKey || '', modelId, provider.api || 'openai-completions')
|
||||
const elapsed = Date.now() - start
|
||||
if (model && typeof model === 'object') {
|
||||
model.latency = elapsed
|
||||
@@ -1215,7 +1257,7 @@ async function fetchRemoteModels(btn, page, state, providerKey) {
|
||||
btn.textContent = '获取中...'
|
||||
|
||||
try {
|
||||
const remoteIds = await api.listRemoteModels(provider.baseUrl, provider.apiKey || '')
|
||||
const remoteIds = await api.listRemoteModels(provider.baseUrl, provider.apiKey || '', provider.api || 'openai-completions')
|
||||
btn.disabled = false
|
||||
btn.textContent = '获取列表'
|
||||
|
||||
@@ -1315,7 +1357,7 @@ async function testModel(btn, state, providerKey, idx) {
|
||||
|
||||
const start = Date.now()
|
||||
try {
|
||||
const reply = await api.testModel(provider.baseUrl, provider.apiKey || '', modelId)
|
||||
const reply = await api.testModel(provider.baseUrl, provider.apiKey || '', modelId, provider.api || 'openai-completions')
|
||||
const elapsed = Date.now() - start
|
||||
// 记录到模型对象
|
||||
if (typeof model === 'object') {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 初始设置页面 — openclaw 未安装时的引导
|
||||
* 自动检测环境 → 版本选择 → 一键安装 → 自动跳转
|
||||
*/
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { api, invalidate } from '../lib/tauri-api.js'
|
||||
import { showUpgradeModal } from '../components/modal.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
import { setUpgrading, isMacPlatform } from '../lib/app-state.js'
|
||||
@@ -45,15 +45,20 @@ async function runDetect(page) {
|
||||
<div class="stat-card loading-placeholder" style="height:48px"></div>
|
||||
<div class="stat-card loading-placeholder" style="height:48px;margin-top:8px"></div>
|
||||
<div class="stat-card loading-placeholder" style="height:48px;margin-top:8px"></div>
|
||||
<div class="stat-card loading-placeholder" style="height:48px;margin-top:8px"></div>
|
||||
`
|
||||
// 并行检测 Node.js、OpenClaw CLI、配置文件
|
||||
const [nodeRes, clawRes, configRes] = await Promise.allSettled([
|
||||
// 清除缓存,确保拿到最新检测结果
|
||||
invalidate('check_node', 'check_git', 'get_services_status', 'check_installation')
|
||||
// 并行检测 Node.js、Git、OpenClaw CLI、配置文件
|
||||
const [nodeRes, gitRes, clawRes, configRes] = await Promise.allSettled([
|
||||
api.checkNode(),
|
||||
api.checkGit(),
|
||||
api.getServicesStatus(),
|
||||
api.checkInstallation(),
|
||||
])
|
||||
|
||||
const node = nodeRes.status === 'fulfilled' ? nodeRes.value : { installed: false }
|
||||
const git = gitRes.status === 'fulfilled' ? gitRes.value : { installed: false }
|
||||
const cliOk = clawRes.status === 'fulfilled'
|
||||
&& clawRes.value?.length > 0
|
||||
&& clawRes.value[0]?.cli_installed !== false
|
||||
@@ -64,7 +69,6 @@ async function runDetect(page) {
|
||||
try {
|
||||
const initResult = await api.initOpenclawConfig()
|
||||
if (initResult?.created) {
|
||||
// 重新检测配置
|
||||
config = await api.checkInstallation()
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -72,7 +76,12 @@ async function runDetect(page) {
|
||||
}
|
||||
}
|
||||
|
||||
renderSteps(page, { node, cliOk, config })
|
||||
// Git 已安装时,自动配置 HTTPS 替代 SSH(静默执行)
|
||||
if (git.installed) {
|
||||
api.configureGitHttps().catch(() => {})
|
||||
}
|
||||
|
||||
renderSteps(page, { node, git, cliOk, config })
|
||||
}
|
||||
|
||||
function stepIcon(ok) {
|
||||
@@ -80,9 +89,10 @@ function stepIcon(ok) {
|
||||
return `<span style="color:${color};font-weight:700;width:18px;display:inline-block">${ok ? '✓' : '✗'}</span>`
|
||||
}
|
||||
|
||||
function renderSteps(page, { node, cliOk, config }) {
|
||||
function renderSteps(page, { node, git, cliOk, config }) {
|
||||
const stepsEl = page.querySelector('#setup-steps')
|
||||
const nodeOk = node.installed
|
||||
const gitOk = git?.installed || false
|
||||
const allOk = nodeOk && cliOk && config.installed
|
||||
|
||||
let html = ''
|
||||
@@ -105,7 +115,7 @@ function renderSteps(page, { node, cliOk, config }) {
|
||||
${isMacPlatform()
|
||||
? `macOS 上从 Finder 启动可能找不到 Node.js。试试关掉 ClawPanel 后从终端启动:<br>
|
||||
<code style="background:var(--bg-secondary);padding:2px 6px;border-radius:3px;user-select:all">open /Applications/ClawPanel.app</code>`
|
||||
: `安装 Node.js 后需要<strong>重启 ClawPanel</strong>,新的环境变量才能生效。`
|
||||
: `安装 Node.js 后点击「重新检测」或使用下方「自动扫描」,无需重启。`
|
||||
}
|
||||
<div style="margin-top:8px;display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
||||
<button class="btn btn-secondary btn-sm" id="btn-scan-node" style="font-size:11px;padding:3px 10px">${icon('search', 12)} 自动扫描</button>
|
||||
@@ -122,7 +132,31 @@ function renderSteps(page, { node, cliOk, config }) {
|
||||
</div>
|
||||
`
|
||||
|
||||
// 第二步:OpenClaw CLI
|
||||
// 第二步:Git
|
||||
html += `
|
||||
<div class="config-section" style="text-align:left;${nodeOk ? '' : 'opacity:0.4;pointer-events:none'}">
|
||||
<div class="config-section-title" style="display:flex;align-items:center;gap:4px">
|
||||
${stepIcon(gitOk)} Git 版本管理
|
||||
</div>
|
||||
${gitOk
|
||||
? `<p style="color:var(--success);font-size:var(--font-size-sm)">已安装 ${git.version || ''}</p>
|
||||
<p style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-top:4px">✅ 已自动配置 Git 使用 HTTPS(避免 SSH 连接问题)</p>`
|
||||
: `<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm);line-height:1.5">
|
||||
部分依赖需要 Git 下载源码。点击下方按钮自动安装,如果失败请手动安装。
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn btn-primary btn-sm" id="btn-auto-install-git">一键安装 Git</button>
|
||||
<a class="btn btn-secondary btn-sm" href="https://git-scm.com/downloads" target="_blank" rel="noopener">手动下载</a>
|
||||
</div>
|
||||
<div id="git-install-result" style="margin-top:var(--space-sm);display:none"></div>
|
||||
<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary);line-height:1.5">
|
||||
<strong>没有 Git 也能安装?</strong> 大部分情况下可以,但个别依赖可能需要 Git。建议安装以避免问题。
|
||||
</div>`
|
||||
}
|
||||
</div>
|
||||
`
|
||||
|
||||
// 第三步:OpenClaw CLI
|
||||
html += `
|
||||
<div class="config-section" style="text-align:left;${nodeOk ? '' : 'opacity:0.4;pointer-events:none'}">
|
||||
<div class="config-section-title" style="display:flex;align-items:center;gap:4px">
|
||||
@@ -134,7 +168,7 @@ function renderSteps(page, { node, cliOk, config }) {
|
||||
}
|
||||
</div>
|
||||
`
|
||||
// 第三步:配置文件
|
||||
// 第四步:配置文件
|
||||
html += `
|
||||
<div class="config-section" style="text-align:left;${cliOk ? '' : 'opacity:0.4;pointer-events:none'}">
|
||||
<div class="config-section-title" style="display:flex;align-items:center;gap:4px">
|
||||
@@ -176,6 +210,22 @@ function renderSteps(page, { node, cliOk, config }) {
|
||||
// 全部就绪 → 进入面板
|
||||
if (allOk) {
|
||||
html += `
|
||||
<div class="config-section" style="text-align:left;margin-top:var(--space-md)">
|
||||
<div class="config-section-title">下一步建议</div>
|
||||
<div style="color:var(--text-secondary);font-size:var(--font-size-sm);line-height:1.7">
|
||||
当前仅表示运行环境已经就绪,并不代表已经可以直接聊天。通常还需要继续完成以下步骤:
|
||||
<ol style="margin:8px 0 0 18px;padding:0">
|
||||
<li>前往「模型配置」添加至少一个可用模型,并确认主模型已设置</li>
|
||||
<li>前往「Gateway」确认服务已启动</li>
|
||||
<li>如需飞书、钉钉、QQ 等消息渠道,请到「消息渠道」完成接入与配对</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:10px">
|
||||
<button class="btn btn-secondary btn-sm" id="btn-goto-models">配置模型</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-goto-gateway">Gateway 设置</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-goto-channels">消息渠道</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:var(--space-lg)">
|
||||
<button class="btn btn-primary" id="btn-enter" style="min-width:200px">进入面板</button>
|
||||
</div>
|
||||
@@ -183,7 +233,7 @@ function renderSteps(page, { node, cliOk, config }) {
|
||||
}
|
||||
|
||||
stepsEl.innerHTML = html
|
||||
bindEvents(page, nodeOk, { node, cliOk, config })
|
||||
bindEvents(page, nodeOk, { node, git, cliOk, config })
|
||||
}
|
||||
|
||||
function renderInstallSection() {
|
||||
@@ -220,6 +270,7 @@ function renderInstallSection() {
|
||||
<div style="font-weight:600;margin-bottom:4px">WSL 中使用 Web 版:</div>
|
||||
<div style="margin-bottom:2px;opacity:0.8">打开 WSL 终端,一键部署 ClawPanel Web 版:</div>
|
||||
<code style="display:block;background:var(--bg-secondary);padding:6px 10px;border-radius:4px;user-select:all;word-break:break-all">curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawpanel/main/deploy.sh | bash</code>
|
||||
<div style="margin-top:4px;opacity:0.7">国内用户如无法访问 GitHub:<code style="background:var(--bg-secondary);padding:2px 4px;border-radius:3px;user-select:all">curl -fsSL https://gitee.com/QtCodeCreators/clawpanel/raw/main/deploy.sh | bash</code></div>
|
||||
<div style="margin-top:4px;opacity:0.7">部署后在浏览器访问 WSL 的 IP 即可管理。</div>
|
||||
</div>
|
||||
` : ''}
|
||||
@@ -228,11 +279,13 @@ function renderInstallSection() {
|
||||
<div style="margin-bottom:2px;opacity:0.8">在容器内安装 OpenClaw + ClawPanel Web 版:</div>
|
||||
<code style="display:block;background:var(--bg-secondary);padding:6px 10px;border-radius:4px;user-select:all;word-break:break-all;margin-bottom:4px">npm i -g @qingchencloud/openclaw-zh</code>
|
||||
<code style="display:block;background:var(--bg-secondary);padding:6px 10px;border-radius:4px;user-select:all;word-break:break-all">curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawpanel/main/deploy.sh | bash</code>
|
||||
<div style="margin-top:4px;opacity:0.7">国内镜像:<code style="background:var(--bg-secondary);padding:2px 4px;border-radius:3px;user-select:all">curl -fsSL https://gitee.com/QtCodeCreators/clawpanel/raw/main/deploy.sh | bash</code></div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight:600;margin-bottom:4px">远程服务器:</div>
|
||||
<div style="margin-bottom:2px;opacity:0.8">SSH 登录服务器后执行:</div>
|
||||
<code style="display:block;background:var(--bg-secondary);padding:6px 10px;border-radius:4px;user-select:all;word-break:break-all">curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawpanel/main/deploy.sh | bash</code>
|
||||
<div style="margin-top:4px;opacity:0.7">国内镜像:<code style="background:var(--bg-secondary);padding:2px 4px;border-radius:3px;user-select:all">curl -fsSL https://gitee.com/QtCodeCreators/clawpanel/raw/main/deploy.sh | bash</code></div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
@@ -275,10 +328,12 @@ function renderInstallSection() {
|
||||
`
|
||||
}
|
||||
|
||||
function buildSetupProblemPrompt({ node, cliOk, config }) {
|
||||
function buildSetupProblemPrompt({ node, git, cliOk, config }) {
|
||||
const problems = []
|
||||
if (!node.installed) problems.push('- Node.js 未安装或未检测到')
|
||||
else problems.push(`- Node.js 已安装: ${node.version || '版本未知'}`)
|
||||
if (!git?.installed) problems.push('- Git 未安装')
|
||||
else problems.push(`- Git 已安装: ${git.version || '版本未知'}`)
|
||||
if (!cliOk) problems.push('- OpenClaw CLI 未安装')
|
||||
else problems.push('- OpenClaw CLI 已安装')
|
||||
if (!config.installed) problems.push('- 配置文件不存在')
|
||||
@@ -310,6 +365,52 @@ function bindEvents(page, nodeOk, detectState) {
|
||||
page.querySelector('#btn-enter')?.addEventListener('click', () => {
|
||||
window.location.hash = '/dashboard'
|
||||
})
|
||||
page.querySelector('#btn-goto-models')?.addEventListener('click', () => {
|
||||
window.location.hash = '/models'
|
||||
})
|
||||
page.querySelector('#btn-goto-gateway')?.addEventListener('click', () => {
|
||||
window.location.hash = '/gateway'
|
||||
})
|
||||
page.querySelector('#btn-goto-channels')?.addEventListener('click', () => {
|
||||
window.location.hash = '/channels'
|
||||
})
|
||||
|
||||
// 一键安装 Git
|
||||
page.querySelector('#btn-auto-install-git')?.addEventListener('click', async () => {
|
||||
const btn = page.querySelector('#btn-auto-install-git')
|
||||
const resultEl = page.querySelector('#git-install-result')
|
||||
btn.disabled = true
|
||||
btn.textContent = '安装中...'
|
||||
if (resultEl) {
|
||||
resultEl.style.display = 'block'
|
||||
resultEl.innerHTML = '<span style="color:var(--text-tertiary)">正在安装 Git,请稍候...</span>'
|
||||
}
|
||||
try {
|
||||
const msg = await api.autoInstallGit()
|
||||
if (resultEl) resultEl.innerHTML = `<span style="color:var(--success)">✓ ${msg}</span>`
|
||||
toast('Git 安装成功', 'success')
|
||||
// 安装成功后自动配置 HTTPS
|
||||
api.configureGitHttps().catch(() => {})
|
||||
setTimeout(() => runDetect(page), 1000)
|
||||
} catch (e) {
|
||||
const errMsg = String(e.message || e)
|
||||
if (resultEl) {
|
||||
resultEl.innerHTML = `<div>
|
||||
<span style="color:var(--danger)">自动安装失败: ${errMsg}</span>
|
||||
<p style="margin-top:6px;font-size:var(--font-size-xs);color:var(--text-secondary);line-height:1.5">
|
||||
请手动安装 Git:<br>
|
||||
<strong>Windows:</strong> 下载 <a href="https://git-scm.com/downloads" target="_blank" style="color:var(--accent)">git-scm.com</a> 安装包<br>
|
||||
<strong>macOS:</strong> 在终端执行 <code style="background:var(--bg-secondary);padding:2px 4px;border-radius:3px">xcode-select --install</code> 或 <code style="background:var(--bg-secondary);padding:2px 4px;border-radius:3px">brew install git</code><br>
|
||||
<strong>Linux:</strong> <code style="background:var(--bg-secondary);padding:2px 4px;border-radius:3px">sudo apt install git</code> 或 <code style="background:var(--bg-secondary);padding:2px 4px;border-radius:3px">sudo yum install git</code>
|
||||
</p>
|
||||
</div>`
|
||||
}
|
||||
toast('Git 自动安装失败,请手动安装', 'warning')
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
btn.textContent = '一键安装 Git'
|
||||
}
|
||||
})
|
||||
|
||||
// 一键初始化配置
|
||||
page.querySelector('#btn-init-config')?.addEventListener('click', async () => {
|
||||
@@ -356,7 +457,7 @@ function bindEvents(page, nodeOk, detectState) {
|
||||
b.addEventListener('click', async () => {
|
||||
await api.saveCustomNodePath(b.dataset.path)
|
||||
toast('Node.js 路径已保存,正在重新检测...', 'success')
|
||||
setTimeout(() => window.location.reload(), 500)
|
||||
setTimeout(() => runDetect(page), 300)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -382,7 +483,7 @@ function bindEvents(page, nodeOk, detectState) {
|
||||
await api.saveCustomNodePath(dir)
|
||||
resultEl.innerHTML = `<span style="color:var(--success)">✓ 找到 Node.js ${result.version},路径已保存</span>`
|
||||
toast('Node.js 路径已保存,正在重新检测...', 'success')
|
||||
setTimeout(() => window.location.reload(), 500)
|
||||
setTimeout(() => runDetect(page), 300)
|
||||
} else {
|
||||
resultEl.innerHTML = `<span style="color:var(--warning)">该目录下未找到 node 可执行文件,请确认路径正确。</span>`
|
||||
}
|
||||
|
||||
@@ -73,18 +73,28 @@
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.chat-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 状态指示点 */
|
||||
@@ -478,35 +488,70 @@
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chat-session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
/* 卡片式会话条目 */
|
||||
.chat-session-card {
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-md, 6px);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.12s;
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
}
|
||||
|
||||
.chat-session-item:hover {
|
||||
.chat-session-card:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.chat-session-item.active {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
.chat-session-card.active {
|
||||
background: var(--accent-muted, rgba(99, 102, 241, 0.1));
|
||||
border-color: var(--accent-border, rgba(99, 102, 241, 0.3));
|
||||
}
|
||||
|
||||
.chat-session-label {
|
||||
.chat-session-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chat-session-card .chat-session-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.chat-session-card.active .chat-session-label {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chat-session-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-session-card-meta span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-session-agent {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.chat-session-del {
|
||||
@@ -519,9 +564,10 @@
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-session-item:hover .chat-session-del {
|
||||
.chat-session-card:hover .chat-session-del {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -536,11 +582,19 @@
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* 模型选择组 */
|
||||
.chat-model-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* 头部操作区 */
|
||||
.chat-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-toggle-sidebar {
|
||||
|
||||
@@ -78,6 +78,15 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-card-clickable {
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.stat-card-clickable:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.stat-card-meta {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
@@ -448,8 +457,8 @@ mark {
|
||||
background: #ffffff;
|
||||
border-radius: 20px;
|
||||
padding: 28px 28px 20px;
|
||||
width: 460px;
|
||||
max-width: 92vw;
|
||||
width: 500px;
|
||||
max-width: 94vw;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,.2);
|
||||
@@ -576,9 +585,9 @@ mark {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.engage-qrcodes {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.engage-qr-item {
|
||||
@@ -594,8 +603,9 @@ mark {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.engage-qr-item img {
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
width: 100%;
|
||||
max-width: 100px;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-primary);
|
||||
background: #fff;
|
||||
@@ -609,17 +619,21 @@ mark {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.engage-footer {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
border-top: 1px solid var(--border-primary, #e5e7eb);
|
||||
padding-top: 12px;
|
||||
}
|
||||
.engage-today-dismiss,
|
||||
.engage-never {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
opacity: 0.6;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
.engage-today-dismiss:hover,
|
||||
.engage-never:hover {
|
||||
opacity: 1;
|
||||
text-decoration: underline;
|
||||
|
||||
@@ -402,14 +402,14 @@
|
||||
|
||||
/* Gateway 未启动引导横幅 */
|
||||
.gw-banner {
|
||||
background: var(--warning, #f59e0b);
|
||||
color: #000;
|
||||
background: linear-gradient(90deg, #fbbf24 0%, #f59e0b 100%);
|
||||
color: #78350f;
|
||||
padding: 8px 16px;
|
||||
font-size: var(--font-size-sm);
|
||||
z-index: 100;
|
||||
transition: all 300ms ease;
|
||||
overflow: hidden;
|
||||
max-height: 50px;
|
||||
max-height: 80px;
|
||||
}
|
||||
.gw-banner-hidden {
|
||||
max-height: 0;
|
||||
|
||||
@@ -7,67 +7,72 @@
|
||||
}
|
||||
|
||||
.dashboard-overview {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-xl);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.overview-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.overview-item {
|
||||
.overview-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.overview-item:hover {
|
||||
.overview-card:hover {
|
||||
background: var(--bg-card-hover);
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
|
||||
.overview-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
.overview-card[data-nav]:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.overview-label svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
.overview-card-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.overview-card-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.overview-card-title {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.overview-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.overview-status {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.overview-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-mono);
|
||||
.overview-card-value {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overview-card-meta {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.overview-card-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/* 服务卡片 */
|
||||
@@ -1508,13 +1513,12 @@ details.docker-other-section[open] > .docker-other-toggle::before {
|
||||
|
||||
/* === 移动端响应式 === */
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-overview {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-md);
|
||||
.overview-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
.overview-item {
|
||||
.overview-card {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: 13px;
|
||||
}
|
||||
.overview-value {
|
||||
font-size: 13px;
|
||||
@@ -1568,10 +1572,11 @@ details.docker-other-section[open] > .docker-other-toggle::before {
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.overview-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
.overview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.overview-card {
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user