From 233d37a6e0fae37ca01c89b663bf812020f4fdf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 07:56:40 +0800 Subject: [PATCH 001/426] chore: checkpoint before emoji cleanup From bc0bfa9b62223a04ada3cf55945f0e8271fc0de2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 08:38:04 +0800 Subject: [PATCH 002/426] chore: remove emoji usage --- .github/workflows/release.yml | 18 +++++------ CONTRIBUTING.md | 2 +- README.md | 54 ++++++++++++++++---------------- docs/assistant-features-plan.md | 38 +++++++++++----------- docs/index.html | 6 ++-- docs/linux-deploy.md | 4 +-- package-lock.json | 4 +-- scripts/dev-api.js | 10 +++--- scripts/serve.js | 6 ++-- src-tauri/src/commands/skills.rs | 4 +-- src/components/engagement.js | 2 +- src/pages/agents.js | 4 +-- src/pages/assistant.js | 6 ++-- src/pages/chat.js | 6 ++-- src/pages/communication.js | 6 ++-- src/pages/skills.js | 8 ++--- 16 files changed, 89 insertions(+), 89 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1be60442..b76f834b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -231,13 +231,13 @@ jobs: CHANGELOG_BODY="" if [ -n "$FEATS" ]; then - CHANGELOG_BODY="${CHANGELOG_BODY}"$'\n### ✨ 新功能\n'"${FEATS}"$'\n' + CHANGELOG_BODY="${CHANGELOG_BODY}"$'\n### 新功能\n'"${FEATS}"$'\n' fi if [ -n "$FIXES" ]; then - CHANGELOG_BODY="${CHANGELOG_BODY}"$'\n### 🐛 修复\n'"${FIXES}"$'\n' + CHANGELOG_BODY="${CHANGELOG_BODY}"$'\n### 修复\n'"${FIXES}"$'\n' fi if [ -n "$OTHERS" ]; then - CHANGELOG_BODY="${CHANGELOG_BODY}"$'\n### 📦 其他\n'"${OTHERS}"$'\n' + CHANGELOG_BODY="${CHANGELOG_BODY}"$'\n### 其他\n'"${OTHERS}"$'\n' fi if [ -z "$CHANGELOG_BODY" ]; then CHANGELOG_BODY=$'\n- 常规更新与优化\n' @@ -245,16 +245,16 @@ jobs: # ── 构建状态 ── if [ "$BUILD_RESULT" = "success" ]; then - STATUS_BADGE="✅ 全部平台构建成功" + STATUS_BADGE="全部平台构建成功" else - STATUS_BADGE="⚠️ 部分平台构建失败,请查看 [Actions 日志](https://github.com/${REPO}/actions)" + STATUS_BADGE="部分平台构建失败,请查看 [Actions 日志](https://github.com/${REPO}/actions)" fi # ── 写入 Release Notes ── { echo "${STATUS_BADGE}" echo "" - echo "## 📥 下载安装" + echo "## 下载安装" echo "" echo "根据你的操作系统选择对应安装包,点击文件名即可下载:" echo "" @@ -264,7 +264,7 @@ jobs: echo "| Apple Silicon (M1/M2/M3/M4) | [ClawPanel_${VERSION}_aarch64.dmg](${DL}/ClawPanel_${VERSION}_aarch64.dmg) |" echo "| Intel | [ClawPanel_${VERSION}_x64.dmg](${DL}/ClawPanel_${VERSION}_x64.dmg) |" echo "" - echo '> **⚠️ 首次打开提示"无法验证开发者"?** 在终端执行:`sudo xattr -rd com.apple.quarantine /Applications/ClawPanel.app`,或前往 **系统设置 → 隐私与安全性** 点击「仍要打开」。' + echo '> **首次打开提示"无法验证开发者"?** 在终端执行:`sudo xattr -rd com.apple.quarantine /Applications/ClawPanel.app`,或前往 **系统设置 → 隐私与安全性** 点击「仍要打开」。' echo "" echo "### Windows" echo "| 格式 | 安装包 |" @@ -281,7 +281,7 @@ jobs: echo "" echo "---" echo "" - echo "## 🚀 首次使用" + echo "## 首次使用" echo "" echo "1. 安装并打开 ClawPanel" echo "2. 首次运行会自动检测 Node.js 环境和 OpenClaw CLI" @@ -296,7 +296,7 @@ jobs: echo "${CHANGELOG_BODY}" echo "---" echo "" - echo "📖 [项目主页](https://github.com/${REPO}) · 💬 [反馈问题](https://github.com/${REPO}/issues) · 📣 [QQ 群](https://qt.cool/c/OpenClaw)" + echo "[项目主页](https://github.com/${REPO}) · [反馈问题](https://github.com/${REPO}/issues) · [QQ 群](https://qt.cool/c/OpenClaw)" } > release_body.md gh release edit "$TAG_NAME" --notes-file release_body.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c155b12c..4a3bc6a7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ 感谢你对 ClawPanel 项目的关注!本文档同时作为**贡献指南**和**项目维护手册**,涵盖开发、构建、发版、部署的完整工作流。 -> 🌐 **官网**: [claw.qt.cool](https://claw.qt.cool/) | 📦 **仓库**: [github.com/qingchencloud/clawpanel](https://github.com/qingchencloud/clawpanel) +> **官网**: [claw.qt.cool](https://claw.qt.cool/) | **仓库**: [github.com/qingchencloud/clawpanel](https://github.com/qingchencloud/clawpanel) --- diff --git a/README.md b/README.md index eeedc7e5..394c0482 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,9 @@ ClawPanel 是 [OpenClaw](https://github.com/1186258278/OpenClawChineseTranslation) AI Agent 框架的可视化管理面板。**内置智能 AI 助手**,帮你一键安装 OpenClaw、自动诊断配置、排查问题、修复错误。8 大工具 + 4 种模式 + 交互式问答,从新手到老手都能轻松管理。 -> 🌐 **官网**: [claw.qt.cool](https://claw.qt.cool/) | 📦 **下载**: [GitHub Releases](https://github.com/qingchencloud/clawpanel/releases/latest) +> **官网**: [claw.qt.cool](https://claw.qt.cool/) | **下载**: [GitHub Releases](https://github.com/qingchencloud/clawpanel/releases/latest) -### 🔥 开发板 / 嵌入式设备支持 +### 开发板 / 嵌入式设备支持 ClawPanel 提供**纯 Web 版部署模式**(零 GUI 依赖),天然兼容 ARM64 开发板和嵌入式设备: @@ -46,7 +46,7 @@ ClawPanel 提供**纯 Web 版部署模式**(零 GUI 依赖),天然兼容 A - **Armbian / Debian / Ubuntu Server** — 一键部署脚本自动检测架构 - 无需 Rust / Tauri / 图形界面,**只要有 Node.js 18+ 就能跑** -> 📖 详见 [Armbian 部署指南](docs/armbian-deploy.md) | [Web 版开发说明](#web-开发版无需-rusttauri) +> 详见 [Armbian 部署指南](docs/armbian-deploy.md) | [Web 版开发说明](#web-开发版无需-rusttauri) ## 社区交流 @@ -100,7 +100,7 @@ ClawPanel 提供**纯 Web 版部署模式**(零 GUI 依赖),天然兼容 A 安装方式:打开 `.dmg` 文件,**先将 ClawPanel 拖入「应用程序」文件夹**,再双击打开。 -> **⚠️ 首次打开提示"已损坏"或"无法验证开发者"?** 由于应用未签名,macOS 会拦截。请在终端执行以下命令解除限制: +> **首次打开提示"已损坏"或"无法验证开发者"?** 由于应用未签名,macOS 会拦截。请在终端执行以下命令解除限制: > > ```bash > sudo xattr -rd com.apple.quarantine /Applications/ClawPanel.app @@ -138,7 +138,7 @@ curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawpanel/main/script 部署完成后访问 `http://服务器IP:1420`,功能与桌面版一致。 -📖 详细教程见 [Linux 部署指南](docs/linux-deploy.md) +详细教程见 [Linux 部署指南](docs/linux-deploy.md) ### Docker 部署 @@ -152,7 +152,7 @@ docker run -d --name clawpanel --restart unless-stopped \ cd /app && npm install && npm run build && npm run serve" ``` -📖 详细教程见 [Docker 部署指南](docs/docker-deploy.md)(含 Compose、自定义镜像、Nginx 反向代理等) +详细教程见 [Docker 部署指南](docs/docker-deploy.md)(含 Compose、自定义镜像、Nginx 反向代理等) ## 功能特性 @@ -160,8 +160,8 @@ docker run -d --name clawpanel --restart unless-stopped \ 功能矩阵

-- **🤖 AI 助手(全新·重磅)** — 内置独立 AI 助手,4 种操作模式 + 8 大工具 + 交互式问答,详见下方 [AI 助手亮点](#-ai-助手亮点) -- **🖼️ 图片识别** — 粘贴截图或拖拽图片,AI 自动识别分析,支持多模态图文混排对话 +- **AI 助手(全新·重磅)** — 内置独立 AI 助手,4 种操作模式 + 8 大工具 + 交互式问答,详见下方 [AI 助手亮点](#-ai-助手亮点) +- **图片识别** — 粘贴截图或拖拽图片,AI 自动识别分析,支持多模态图文混排对话 - **仪表盘** — 系统概览,服务状态实时监控,快捷操作 - **服务管理** — OpenClaw 启停控制、版本检测与一键升级、Gateway 安装/卸载、配置备份与还原 - **模型配置** — 多服务商管理、模型增删改查、批量连通性测试、延迟检测、拖拽排序、自动保存+撤销 @@ -187,7 +187,7 @@ docker run -d --name clawpanel --restart unless-stopped \

AI 助手

-

🤖 AI 助手 — 8 大技能卡片,一键触发配置检查、Gateway 诊断、环境检测、一键排障等常用操作

+

AI 助手 — 8 大技能卡片,一键触发配置检查、Gateway 诊断、环境检测、一键排障等常用操作

仪表盘 @@ -197,7 +197,7 @@ docker run -d --name clawpanel --restart unless-stopped \

AI 助手设置 — 公益 AI 接口

-

⚙️ AI 设置 — 独立模型配置 + 公益 AI 接口一键接入,GPT-5 全系列免费可用

+

AI 设置 — 独立模型配置 + 公益 AI 接口一键接入,GPT-5 全系列免费可用

AI 助手人设 — Agent 灵魂 @@ -259,7 +259,7 @@ docker run -d --name clawpanel --restart unless-stopped \ -## 🤖 AI 助手亮点 +## AI 助手亮点 ClawPanel 内置的 AI 助手不只是聊天机器人——它能**直接操作你的系统**,帮你诊断、修复、甚至提交 PR。 @@ -269,10 +269,10 @@ ClawPanel 内置的 AI 助手不只是聊天机器人——它能**直接操作 | 模式 | 图标 | 工具 | 写文件 | 确认 | 适用场景 | |------|------|------|--------|------|---------| -| **聊天** | 💬 | ❌ | ❌ | — | 纯问答,不触碰系统 | -| **规划** | 📋 | ✅ | ❌ | ✅ | 读配置/查日志,输出方案不动文件 | -| **执行** | ⚡ | ✅ | ✅ | ✅ | 正常干活,危险操作弹确认 | -| **无限** | ∞ | ✅ | ✅ | ❌ | 全自动,工具调用不弹窗 | +| **聊天** | - | - | - | — | 纯问答,不触碰系统 | +| **规划** | - | ✓ | - | ✓ | 读配置/查日志,输出方案不动文件 | +| **执行** | - | ✓ | ✓ | ✓ | 正常干活,危险操作弹确认 | +| **无限** | - | ✓ | ✓ | - | 全自动,工具调用不弹窗 | 设置中还有**工具开关**(终端/文件),优先级高于模式——关掉终端,即使无限模式也调不了命令。 @@ -303,8 +303,8 @@ AI 等你回答后才会继续操作,实现真正的**人机协作**。 发现 Bug?AI 不只是告诉你怎么修——它**直接帮你修**: -1. 🐛 **提交 Bug 报告** — AI 自动收集系统环境、读取错误日志,按标准模板整理成 GitHub Issue,你复制粘贴就能提交 -2. 🔀 **PR 助手** — AI 分析 Bug 根因 → 定位代码 → 生成修复方案 → 通过 `run_command` 执行 git 命令完成 Fork/Branch/Commit/Push,**用户只需点确认** +1. 提交 Bug 报告 — AI 自动收集系统环境、读取错误日志,按标准模板整理成 GitHub Issue,你复制粘贴就能提交 +2. PR 助手 — AI 分析 Bug 根因 → 定位代码 → 生成修复方案 → 通过 `run_command` 执行 git 命令完成 Fork/Branch/Commit/Push,**用户只需点确认** ### 内置技能卡片 @@ -312,14 +312,14 @@ AI 等你回答后才会继续操作,实现真正的**人机协作**。 | 技能 | 功能 | |------|------| -| 🔧 检查配置 | 读取并分析 openclaw.json | -| 🏥 诊断 Gateway | 检查进程、端口、日志 | -| 📂 浏览目录 | 查看 .openclaw 目录结构 | -| 💻 检查环境 | Node.js、npm 版本检测 | -| 📋 分析日志 | 搜索 ERROR/WARN 关键词 | -| 🔨 一键排障 | 自动检测并修复常见问题 | -| 🐛 提交 Bug | 整理 Issue 提交到 GitHub | -| 🔀 PR 助手 | 定位 Bug 并生成修复 PR | +| 检查配置 | 读取并分析 openclaw.json | +| 诊断 Gateway | 检查进程、端口、日志 | +| 浏览目录 | 查看 .openclaw 目录结构 | +| 检查环境 | Node.js、npm 版本检测 | +| 分析日志 | 搜索 ERROR/WARN 关键词 | +| 一键排障 | 自动检测并修复常见问题 | +| 提交 Bug | 整理 Issue 提交到 GitHub | +| PR 助手 | 定位 Bug 并生成修复 PR | ## 技术架构 @@ -574,7 +574,7 @@ ClawPanel 支持将 AI 接入多种即时通讯平台,在「消息渠道」页 4. 点击「校验」确认连接,然后「保存」 5. Gateway 会自动重载,飞书机器人即刻可用 -> 📖 详细教程:[飞书接入指南](docs/dingtalk-integration.md) | [钉钉接入指南](docs/dingtalk-integration.md) +> 详细教程:[飞书接入指南](docs/dingtalk-integration.md) | [钉钉接入指南](docs/dingtalk-integration.md) ### 注意事项 @@ -653,7 +653,7 @@ npm install -g @qingchencloud/openclaw-zh --registry https://registry.npmmirror. |------|------|------| | 端口 18789 被占用 | 另一个 Gateway 进程残留 | 终端执行 `pkill -f openclaw` 后重启 | | 配置文件损坏 | openclaw.json 格式错误 | 前往「服务管理」→「从备份恢复」 | -| 反复崩溃 | API Key 或模型配置异常 | 用 AI 助手「🔨 一键排障」自动诊断 | +| 反复崩溃 | API Key 或模型配置异常 | 用 AI 助手「一键排障」自动诊断 | | 提示 "auth mode" 错误 | 认证配置不兼容 | 在「安全设置」重置 Gateway 认证 | 如果仍无法解决,查看「日志查看」页面的 Gateway 日志获取详细错误信息。 diff --git a/docs/assistant-features-plan.md b/docs/assistant-features-plan.md index 1231a262..9b2dd08a 100644 --- a/docs/assistant-features-plan.md +++ b/docs/assistant-features-plan.md @@ -87,7 +87,7 @@ dev-api.js (Web): ```js { id: 'detect-docker-openclaw', - icon: '🐳', + icon: 'docker', name: '检测 Docker/WSL 中的 OpenClaw', desc: '扫描 Docker 容器和 WSL,查找 OpenClaw 安装', tools: ['docker'], @@ -100,7 +100,7 @@ dev-api.js (Web): } ``` -### 优先级:🔴 高(解决用户最常见困惑) +### 优先级:高(解决用户最常见困惑) ### 工时估算:1-2 天 --- @@ -169,7 +169,7 @@ const text = await resp.text() 联网搜索 — 允许搜索互联网和抓取网页内容(需联网) ``` -### 优先级:🔴 高(大幅提升问题解决能力) +### 优先级:高(大幅提升问题解决能力) ### 工时估算:0.5-1 天 --- @@ -263,7 +263,7 @@ dev-api.js (Web): ```js { id: 'remote-manage', - icon: '🌐', + icon: 'network', name: '远程管理 OpenClaw', desc: '通过 SSH 连接远程服务器,管理 OpenClaw', tools: ['ssh', 'fileOps'], @@ -277,12 +277,12 @@ dev-api.js (Web): } ``` -### 优先级:🟡 中(用户量较少但价值极高) +### 优先级:中(用户量较少但价值极高) ### 工时估算:2-3 天 --- -## 模块四:知识库 + 灵魂移植(借尸还魂 🔥) +## 模块四:知识库 + 灵魂移植(借尸还魂) ### 核心理念 @@ -451,20 +451,20 @@ async function loadOpenClawSoul(agentId = 'main') { │ │ │ 选择 Agent: [main ▼] │ │ │ -│ 📜 灵魂文件预览 │ +│ 灵魂文件预览 │ │ ┌──────────────────────────────────────────│ -│ │ SOUL.md ✅ 已加载 (1.6KB) │ -│ │ IDENTITY.md ✅ 已加载 (636B) │ -│ │ USER.md ✅ 已加载 (237B) │ -│ │ AGENTS.md ✅ 已加载 (7.8KB) │ -│ │ TOOLS.md ✅ 已加载 (860B) │ -│ │ MEMORY.md ❌ 未找到 │ -│ │ memory/ 📝 2 个日志文件 │ +│ │ SOUL.md ✓ 已加载 (1.6KB) │ +│ │ IDENTITY.md ✓ 已加载 (636B) │ +│ │ USER.md ✓ 已加载 (237B) │ +│ │ AGENTS.md ✓ 已加载 (7.8KB) │ +│ │ TOOLS.md ✓ 已加载 (860B) │ +│ │ MEMORY.md ✕ 未找到 │ +│ │ memory/ 2 个日志文件 │ │ └──────────────────────────────────────────│ │ │ -│ [👻 附身!] [🔄 刷新] │ +│ [附身] [刷新] │ │ │ -│ ⚠️ 附身后,助手将使用该 Agent 的人格、 │ +│ 注意:附身后,助手将使用该 Agent 的人格、│ │ 记忆和用户偏好。可随时切回默认。 │ │ │ │ ─── 当选择「ClawPanel 默认」时显示 ──── │ @@ -627,7 +627,7 @@ async function discoverOpenClawModels() { │ API Base URL API 类型 │ │ [________________________] [OpenAI 兼容 ▼] │ │ │ -│ API Key [测试] [拉取] [📥 导入] │ ← 新增「导入」按钮 +│ API Key [测试] [拉取] [导入] │ ← 新增「导入」按钮 │ [________________________] │ │ │ │ 模型 温度 │ @@ -636,7 +636,7 @@ async function discoverOpenClawModels() { └─────────────────────────────────────────────┘ ``` -点击「📥 导入」弹出选择面板: +点击「导入」弹出选择面板: ``` ┌─────────────────────────────────────────────┐ @@ -670,7 +670,7 @@ dev-api.js: 已有 read_config handler // 只需在前端加一个读取+解析+填充的逻辑 ``` -### 优先级:🔴 高(零成本,纯前端,极大提升体验) +### 优先级:高(零成本,纯前端,极大提升体验) ### 工时估算:0.5 天 --- diff --git a/docs/index.html b/docs/index.html index 44f3f90d..09fc15fb 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1046,7 +1046,7 @@

Linux 部署指南

-
🐳
+
DOC

Docker 部署指南

用 Docker 部署 ClawPanel Web 版,支持 Compose 编排、自定义镜像、Gateway 联动

@@ -1059,7 +1059,7 @@

Docker 部署指南

-
📖
+
DOC

项目主页 README

完整的项目介绍,包含安装方式、功能特性、技术架构、源码构建、常见问题

@@ -1072,7 +1072,7 @@

项目主页 README

-
📋
+
LOG

更新日志

每个版本的新增功能、Bug 修复和改进记录

diff --git a/docs/linux-deploy.md b/docs/linux-deploy.md index 8f56126e..7ace8c25 100644 --- a/docs/linux-deploy.md +++ b/docs/linux-deploy.md @@ -145,7 +145,7 @@ npm run serve -- --port 8080 ``` ┌─────────────────────────────────────────┐ - │ 🦀 ClawPanel Web Server (Headless) │ + │ ClawPanel Web Server (Headless) │ │ http://localhost:1420/ │ └─────────────────────────────────────────┘ [api] API 已启动,配置目录: /root/.openclaw @@ -157,7 +157,7 @@ npm run serve -- --port 8080 ## 方式三:Docker 部署 -> 📖 Docker 完整教程(Compose、自定义镜像、数据持久化等)见 [Docker 部署指南](docker-deploy.md) +> Docker 完整教程(Compose、自定义镜像、数据持久化等)见 [Docker 部署指南](docker-deploy.md) 快速启动: diff --git a/package-lock.json b/package-lock.json index c6405f60..f29099b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "clawpanel", - "version": "0.7.2", + "version": "0.9.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clawpanel", - "version": "0.7.2", + "version": "0.9.2", "license": "MIT", "dependencies": { "@tauri-apps/api": "^2.5.0", diff --git a/scripts/dev-api.js b/scripts/dev-api.js index d4444f1f..cbe22b63 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -3049,11 +3049,11 @@ const handlers = { // CLI 不可用时返回 mock 数据 return { skills: [ - { name: 'github', description: 'GitHub operations via gh CLI: issues, PRs, CI runs, code review.', source: 'openclaw-bundled', bundled: true, emoji: '🐙', eligible: true, disabled: false, blockedByAllowlist: false, requirements: { bins: ['gh'], anyBins: [], env: [], config: [], os: [] }, missing: { bins: [], anyBins: [], env: [], config: [], os: [] }, install: [{ id: 'brew', kind: 'brew', label: 'Install GitHub CLI (brew)', bins: ['gh'] }] }, - { name: 'weather', description: 'Get current weather and forecasts via wttr.in. No API key needed.', source: 'openclaw-bundled', bundled: true, emoji: '🌤️', eligible: true, disabled: false, blockedByAllowlist: false, requirements: { bins: ['curl'], anyBins: [], env: [], config: [], os: [] }, missing: { bins: [], anyBins: [], env: [], config: [], os: [] }, install: [] }, - { name: 'summarize', description: 'Summarize web pages, PDFs, images, audio and more.', source: 'openclaw-bundled', bundled: true, emoji: '📝', eligible: false, disabled: false, blockedByAllowlist: false, requirements: { bins: [], anyBins: [], env: [], config: [], os: [] }, missing: { bins: [], anyBins: [], env: [], config: [], os: [] }, install: [] }, - { name: 'slack', description: 'Send and read Slack messages via CLI.', source: 'openclaw-bundled', bundled: true, emoji: '💬', eligible: false, disabled: false, blockedByAllowlist: false, requirements: { bins: ['slack-cli'], anyBins: [], env: [], config: [], os: [] }, missing: { bins: ['slack-cli'], anyBins: [], env: [], config: [], os: [] }, install: [{ id: 'brew', kind: 'brew', label: 'Install Slack CLI (brew)', bins: ['slack-cli'] }] }, - { name: 'notion', description: 'Create and search Notion pages using the API.', source: 'openclaw-bundled', bundled: true, emoji: '📓', eligible: false, disabled: true, blockedByAllowlist: false, requirements: { bins: [], anyBins: [], env: ['NOTION_API_KEY'], config: [], os: [] }, missing: { bins: [], anyBins: [], env: ['NOTION_API_KEY'], config: [], os: [] }, install: [] }, + { name: 'github', description: 'GitHub operations via gh CLI: issues, PRs, CI runs, code review.', source: 'openclaw-bundled', bundled: true, emoji: 'github', eligible: true, disabled: false, blockedByAllowlist: false, requirements: { bins: ['gh'], anyBins: [], env: [], config: [], os: [] }, missing: { bins: [], anyBins: [], env: [], config: [], os: [] }, install: [{ id: 'brew', kind: 'brew', label: 'Install GitHub CLI (brew)', bins: ['gh'] }] }, + { name: 'weather', description: 'Get current weather and forecasts via wttr.in. No API key needed.', source: 'openclaw-bundled', bundled: true, emoji: 'weather', eligible: true, disabled: false, blockedByAllowlist: false, requirements: { bins: ['curl'], anyBins: [], env: [], config: [], os: [] }, missing: { bins: [], anyBins: [], env: [], config: [], os: [] }, install: [] }, + { name: 'summarize', description: 'Summarize web pages, PDFs, images, audio and more.', source: 'openclaw-bundled', bundled: true, emoji: 'note', eligible: false, disabled: false, blockedByAllowlist: false, requirements: { bins: [], anyBins: [], env: [], config: [], os: [] }, missing: { bins: [], anyBins: [], env: [], config: [], os: [] }, install: [] }, + { name: 'slack', description: 'Send and read Slack messages via CLI.', source: 'openclaw-bundled', bundled: true, emoji: 'chat', eligible: false, disabled: false, blockedByAllowlist: false, requirements: { bins: ['slack-cli'], anyBins: [], env: [], config: [], os: [] }, missing: { bins: ['slack-cli'], anyBins: [], env: [], config: [], os: [] }, install: [{ id: 'brew', kind: 'brew', label: 'Install Slack CLI (brew)', bins: ['slack-cli'] }] }, + { name: 'notion', description: 'Create and search Notion pages using the API.', source: 'openclaw-bundled', bundled: true, emoji: 'notion', eligible: false, disabled: true, blockedByAllowlist: false, requirements: { bins: [], anyBins: [], env: ['NOTION_API_KEY'], config: [], os: [] }, missing: { bins: [], anyBins: [], env: ['NOTION_API_KEY'], config: [], os: [] }, install: [] }, ], source: 'mock', cliAvailable: false, diff --git a/scripts/serve.js b/scripts/serve.js index 3408ea37..c264795a 100644 --- a/scripts/serve.js +++ b/scripts/serve.js @@ -184,7 +184,7 @@ async function main() { console.log('') console.log(' ┌─────────────────────────────────────────┐') console.log(' │ │') - console.log(' │ 🦀 ClawPanel Web Server (Headless) │') + console.log(' │ ClawPanel Web Server (Headless) │') console.log(' │ │') console.log(` │ http://${host === '0.0.0.0' ? 'localhost' : host}:${port}/`.padEnd(44) + '│') if (host === '0.0.0.0') { @@ -198,8 +198,8 @@ async function main() { }) // 优雅退出 - process.on('SIGINT', () => { console.log('\n 👋 服务已停止'); process.exit(0) }) - process.on('SIGTERM', () => { console.log('\n 👋 服务已停止'); process.exit(0) }) + process.on('SIGINT', () => { console.log('\n 服务已停止'); process.exit(0) }) + process.on('SIGTERM', () => { console.log('\n 服务已停止'); process.exit(0) }) } main().catch(e => { console.error('启动失败:', e); process.exit(1) }) diff --git a/src-tauri/src/commands/skills.rs b/src-tauri/src/commands/skills.rs index 1a067344..ad49d871 100644 --- a/src-tauri/src/commands/skills.rs +++ b/src-tauri/src/commands/skills.rs @@ -306,8 +306,8 @@ pub async fn skills_skillhub_search(query: String) -> Result { // skillhub search 实际输出格式: // ──────────────── (分隔线) - // [1] openclaw/openclaw/feishu-doc 🛡️ Pass - // AI 85 ⬇ 33 ⭐ 248.7k Feishu document read/write opera... + // [1] openclaw/openclaw/feishu-doc Pass + // AI 85 Downloads 33 Stars 248.7k Feishu document read/write opera... // ──────────────── (分隔线) // 序号和 slug 在同一行,描述在下一行 let lines: Vec<&str> = stdout.lines().collect(); diff --git a/src/components/engagement.js b/src/components/engagement.js index a07b91ce..13bbcbed 100644 --- a/src/components/engagement.js +++ b/src/components/engagement.js @@ -66,7 +66,7 @@ export function tryShowEngagement() { _showing = true localStorage.setItem(KEYS.lastShown, String(Date.now())) - const shareText = '推荐一个开源的 OpenClaw 管理面板 — ClawPanel,一键搭建、便捷管理模型和 Agent,还内置 AI 助手帮你排查问题,小白也能轻松上手 👉 https://claw.qt.cool' + const shareText = '推荐一个开源的 OpenClaw 管理面板 — ClawPanel,一键搭建、便捷管理模型和 Agent,还内置 AI 助手帮你排查问题,小白也能轻松上手:https://claw.qt.cool' const overlay = document.createElement('div') overlay.className = 'engage-overlay' diff --git a/src/pages/agents.js b/src/pages/agents.js index 5273753b..a701982b 100644 --- a/src/pages/agents.js +++ b/src/pages/agents.js @@ -146,7 +146,7 @@ async function showAddAgentDialog(page, state) { fields: [ { name: 'id', label: 'Agent ID', value: '', placeholder: '例如:translator(小写字母、数字、下划线、连字符)' }, { name: 'name', label: '名称', value: '', placeholder: '例如:翻译助手' }, - { name: 'emoji', label: 'Emoji', value: '', placeholder: '例如:🌐(可选)' }, + { name: 'emoji', label: 'Emoji', value: '', placeholder: '例如:globe(可选)' }, { name: 'model', label: '模型', type: 'select', value: models[0]?.value || '', options: models }, { name: 'workspace', label: '工作区路径', value: '', placeholder: '留空则自动创建(可选,绝对路径)' }, ], @@ -201,7 +201,7 @@ async function showEditAgentDialog(page, state, id) { const fields = [ { name: 'name', label: '名称', value: name, placeholder: '例如:翻译助手' }, - { name: 'emoji', label: 'Emoji', value: agent.identityEmoji || '', placeholder: '例如:🌐' }, + { name: 'emoji', label: 'Emoji', value: agent.identityEmoji || '', placeholder: '例如:globe' }, ] if (models.length) { diff --git a/src/pages/assistant.js b/src/pages/assistant.js index 22044038..a698461d 100644 --- a/src/pages/assistant.js +++ b/src/pages/assistant.js @@ -1857,14 +1857,14 @@ async function executeTool(name, args) { const missing = skills.filter(s => !s.eligible && !s.disabled) const disabled = skills.filter(s => s.disabled) let summary = `共 ${skills.length} 个 Skills: ${eligible.length} 可用, ${missing.length} 缺依赖, ${disabled.length} 已禁用\n\n` - if (eligible.length) summary += `## 可用 (${eligible.length})\n` + eligible.map(s => `- ${s.emoji || '📦'} **${s.name}**: ${s.description || ''}${s.bundled ? ' [捆绑]' : ''}`).join('\n') + '\n\n' + if (eligible.length) summary += `## 可用 (${eligible.length})\n` + eligible.map(s => `- ${s.emoji || ''} **${s.name}**: ${s.description || ''}${s.bundled ? ' [捆绑]' : ''}`.trim()).join('\n') + '\n\n' if (missing.length) summary += `## 缺依赖 (${missing.length})\n` + missing.map(s => { const m = s.missing || {} const deps = [...(m.bins||[]), ...(m.env||[]).map(e=>'$'+e), ...(m.config||[])].join(', ') const installs = (s.install||[]).map(i => i.label).join(' / ') - return `- ${s.emoji || '📦'} **${s.name}**: 缺少 ${deps}${installs ? ' → 可通过: ' + installs : ''}` + return `- ${s.emoji || ''} **${s.name}**: 缺少 ${deps}${installs ? ' → 可通过: ' + installs : ''}`.trim() }).join('\n') + '\n\n' - if (disabled.length) summary += `## 已禁用 (${disabled.length})\n` + disabled.map(s => `- ${s.emoji || '📦'} **${s.name}**: ${s.description || ''}`).join('\n') + '\n' + if (disabled.length) summary += `## 已禁用 (${disabled.length})\n` + disabled.map(s => `- ${s.emoji || ''} **${s.name}**: ${s.description || ''}`.trim()).join('\n') + '\n' return summary } case 'skills_info': diff --git a/src/pages/chat.js b/src/pages/chat.js index e8571338..e9d3c6bf 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -898,9 +898,9 @@ function handleEvent(msg) { // Compaction 状态指示:上游 2026.3.12 新增 status_reaction 事件 if (event === 'chat.status_reaction' || event === 'status_reaction') { const reaction = payload.reaction || payload.emoji || '' - if (reaction.includes('compact') || reaction === '🗜️' || reaction === '📦') { + if (reaction.includes('compact') || reaction === 'compress' || reaction === 'package') { showCompactionHint(true) - } else if (!reaction || reaction === 'thinking' || reaction === '💭') { + } else if (!reaction || reaction === 'thinking' || reaction === 'thought') { showCompactionHint(false) } } @@ -1558,7 +1558,7 @@ function showCompactionHint(show) { hint = document.createElement('div') hint.id = 'compaction-hint' hint.className = 'msg msg-system compaction-hint' - hint.innerHTML = '🗜️ 正在整理上下文(Compaction)…' + hint.innerHTML = '正在整理上下文(Compaction)…' _messagesEl.insertBefore(hint, _typingEl) scrollToBottom() } else if (!show && hint) { diff --git a/src/pages/communication.js b/src/pages/communication.js index a1057d21..ba70b58b 100644 --- a/src/pages/communication.js +++ b/src/pages/communication.js @@ -119,9 +119,9 @@ function renderMessages(el) {
每条 AI 回复开头自动加的前缀。支持 {model}、{provider}、{thinkingLevel} 等变量。设为 auto 则显示 Agent 名称
- - -
收到消息时自动添加的 emoji 反应(确认已收到)
+ + +
收到消息时自动添加的确认标记(确认已收到)
diff --git a/src/pages/skills.js b/src/pages/skills.js index af7b3215..8532082f 100644 --- a/src/pages/skills.js +++ b/src/pages/skills.js @@ -118,7 +118,7 @@ function renderSkills(el, data) { ${disabled.length ? `
-
⏸ 已禁用 (${disabled.length})
+
已禁用 (${disabled.length})
${disabled.map(s => renderSkillCard(s, 'disabled')).join('')}
@@ -126,7 +126,7 @@ function renderSkills(el, data) { ${blocked.length ? `
-
🚫 白名单阻止 (${blocked.length})
+
白名单阻止 (${blocked.length})
${blocked.map(s => renderSkillCard(s, 'blocked')).join('')}
@@ -158,7 +158,7 @@ function renderSkills(el, data) { } function renderSkillCard(skill, status) { - const emoji = skill.emoji || '📦' + const emoji = skill.emoji || '' const name = skill.name || '' const desc = skill.description || '' const source = skill.bundled ? '捆绑' : (skill.source || '自定义') @@ -234,7 +234,7 @@ async function handleInfo(page, name) { detail.innerHTML = `
-
${esc(s.emoji || '📦')} ${esc(s.name || name)}
+
${esc(s.emoji || '')} ${esc(s.name || name)}
来源: ${esc(s.source || '')} · 路径: ${esc(s.filePath || '')} ${s.homepage ? ` · ${esc(s.homepage)}` : ''} From ef5a2e827acffa082d93d28ebd40c46ad6cb2617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 08:44:13 +0800 Subject: [PATCH 003/426] docs: cloudflared openclaw integration design --- ...cloudflared-openclaw-integration-design.md | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 docs/plans/2026-03-16-cloudflared-openclaw-integration-design.md diff --git a/docs/plans/2026-03-16-cloudflared-openclaw-integration-design.md b/docs/plans/2026-03-16-cloudflared-openclaw-integration-design.md new file mode 100644 index 00000000..3c8d590e --- /dev/null +++ b/docs/plans/2026-03-16-cloudflared-openclaw-integration-design.md @@ -0,0 +1,59 @@ +# Cloudflared 公网访问与 OpenClaw 兼容导入设计 + +日期:2026-03-16 + +## 目标 +- 在 ClawPanel 设置页新增“公网访问(Cloudflared)”Tab +- 支持快速隧道与命名隧道,进入页面后由用户选择 +- 一键认证登录(允许弹出浏览器完成 cloudflared login) +- 默认暴露 OpenClaw Gateway(18789) +- 读取用户已安装 OpenClaw 的 `C:\Users\\.openclaw\openclaw.json`,只读导入并做兼容或升级 + +## 约束与偏好 +- 不要求用户输入隧道 Token +- cloudflared.exe 优先 PATH 检测,其次 `~/.openclaw/bin`,不存在则复用 label-printer 的加速域名检测与下载逻辑 +- 不覆盖原始 openclaw.json,仅生成 ClawPanel 本地配置副本 + +## 方案对比 +### 方案 A(推荐) +- 内置 Cloudflared 管理器 + 一键登录 + 快速/命名双模式 +- 优点:体验一致,符合“一键认证登录” +- 风险:需要完整接入本地进程管理与状态监控 + +### 方案 B +- 仅提供外部 cloudflared 路径配置 +- 优点:实现快 +- 缺点:不满足“一键认证登录” + +### 方案 C +- A 为主,保留手动路径作为兜底 +- 优点:兼容性强 +- 代价:UI 复杂度增加 + +推荐:方案 A,保留手动路径兜底入口 + +## 设计 Section 1:架构与入口 +- 入口:设置页新增“公网访问(Cloudflared)”Tab +- 核心模块: + 1) Cloudflared 管理器(检测、下载、启动、停止、状态) + 2) 隧道管理(快速隧道/命名隧道) + 3) OpenClaw 配置导入(读取并兼容升级) +- 数据流:UI → IPC → cloudflared → 状态回传 → UI + +## 设计 Section 2:获取与一键认证 +- 检测顺序:PATH → `~/.openclaw/bin/cloudflared.exe` → 下载 +- 下载策略:加速域名检测失败则回退官方下载 +- 一键认证:执行 `cloudflared tunnel login`,浏览器授权完成后保存凭据 +- 失败提示与重试 + +## 设计 Section 3:隧道类型与运行流程 +- 快速隧道:临时访问 +- 命名隧道:固定域名与服务 +- 流程:选择类型 → 选择服务(默认 18789)→ 启动 → 展示地址/状态 +- 错误处理:启动失败、登录失效、端口占用 + +## 设计 Section 4:OpenClaw 配置导入 +- 默认读取:`C:\Users\\.openclaw\openclaw.json` +- 兼容升级:缺失字段补全、旧字段映射 +- 只读导入,不覆盖原配置 +- 失败回退为只读展示 From 14386de95076579a306d56fc6c989ea320a7c202 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 08:54:35 +0800 Subject: [PATCH 004/426] chore: checkpoint before cloudflared integration From 367e8b7c919d4ef21c65fdab8f444010ec866ab0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 11:14:25 +0800 Subject: [PATCH 005/426] chore: checkpoint before cron sessionMessage --- ...3-16-cron-sessionmessage-tool-ui-design.md | 55 ++ scripts/dev-api.js | 144 +++-- src-tauri/src/commands/agent.rs | 10 +- src-tauri/src/commands/cloudflared.rs | 502 ++++++++++++++++++ src-tauri/src/commands/config.rs | 25 +- src-tauri/src/commands/memory.rs | 8 +- src-tauri/src/commands/messaging.rs | 4 +- src-tauri/src/commands/mod.rs | 17 + src-tauri/src/commands/pairing.rs | 2 +- src-tauri/src/commands/service.rs | 12 +- src-tauri/src/lib.rs | 10 +- src/lib/local-cron.js | 172 ++++++ src/lib/tauri-api.js | 7 + src/main.js | 1 + src/pages/settings.js | 158 ++++++ src/pages/setup.js | 13 +- 16 files changed, 1055 insertions(+), 85 deletions(-) create mode 100644 docs/plans/2026-03-16-cron-sessionmessage-tool-ui-design.md create mode 100644 src-tauri/src/commands/cloudflared.rs create mode 100644 src/lib/local-cron.js diff --git a/docs/plans/2026-03-16-cron-sessionmessage-tool-ui-design.md b/docs/plans/2026-03-16-cron-sessionmessage-tool-ui-design.md new file mode 100644 index 00000000..fe98f9dd --- /dev/null +++ b/docs/plans/2026-03-16-cron-sessionmessage-tool-ui-design.md @@ -0,0 +1,55 @@ +# Cron SessionMessage + Tool UI Design + +日期:2026-03-16 + +## 目标 +- 在 ClawPanel 中新增 cron 任务类型:向指定 session 发送 user 消息 +- 支持等待对话结束(Gateway WebSocket 事件)再发送 +- Chat 页面展示工具调用(默认收起) +- 适配 npm 全局安装 OpenClaw(补丁应用与更新重打) + +## 范围 +- 新增 payload.kind = sessionMessage(Gateway cron 执行分支) +- ClawPanel cron UI 增加任务类型与字段 +- Chat UI 增加 tool call 展示 +- OpenClaw 版本更新时补丁自动重打 + +## 方案概述 +### Cron SessionMessage +- payload.kind: sessionMessage +- 字段:label, message, role=user, waitForIdle=true +- Gateway cron 执行:label -> sessionKey -> 等待 chat final -> 发送 user 消息 + +### UI 改动 +- cron 表单新增任务类型选择 +- session label 下拉(来自 sessions.list) +- message 文本输入 +- 列表与详情展示任务类型与目标 + +### Chat 工具调用展示 +- 解析 message.content 中 tool / tool_result +- 默认收起,仅显示工具名与状态 +- 点击展开显示参数与结果 JSON + +### 补丁与更新 +- 定位 npm 全局包路径(npm root -g + 包名) +- 打补丁前备份原文件 +- 写入 clawpanel.json 记录补丁版本与 OpenClaw 版本 +- 更新后检测版本变化并重打补丁 + +## 数据流 +- Cron UI -> Gateway cron.add -> payload sessionMessage +- Gateway cron -> 监听 chat final -> chat.send (role=user) +- Chat UI -> 渲染 tool call blocks + +## 错误处理 +- label 不存在:任务失败并记录错误 +- Gateway 未连接:cron UI 提示不可用 +- 补丁失败:自动回退并提示 + +## 测试要点 +- cron 创建/编辑/删除 +- sessionMessage 执行成功 +- 等待对话结束后发送 +- tool call 展示与展开 +- 补丁重打与回退 diff --git a/scripts/dev-api.js b/scripts/dev-api.js index cbe22b63..0409194e 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -16,7 +16,13 @@ const DOCKER_TASK_TIMEOUT_MS = 10 * 60 * 1000 const __dev_dirname = path.dirname(fileURLToPath(import.meta.url)) const OPENCLAW_DIR = path.join(homedir(), '.openclaw') -const CONFIG_PATH = path.join(OPENCLAW_DIR, 'openclaw.json') +const CONFIG_PATH_DEFAULT = path.join(OPENCLAW_DIR, 'config.json') +const CONFIG_PATH_LEGACY = path.join(OPENCLAW_DIR, 'openclaw.json') +function resolveConfigPath() { + if (fs.existsSync(CONFIG_PATH_DEFAULT)) return CONFIG_PATH_DEFAULT + if (fs.existsSync(CONFIG_PATH_LEGACY)) return CONFIG_PATH_LEGACY + return CONFIG_PATH_DEFAULT +} const MCP_CONFIG_PATH = path.join(OPENCLAW_DIR, 'mcp.json') const LOGS_DIR = path.join(OPENCLAW_DIR, 'logs') const BACKUPS_DIR = path.join(OPENCLAW_DIR, 'backups') @@ -551,8 +557,9 @@ function wsReadLoop(socket, onMessage, timeoutMs = DOCKER_TASK_TIMEOUT_MS) { } function patchGatewayOrigins() { - if (!fs.existsSync(CONFIG_PATH)) return false - const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) + const configPath = resolveConfigPath() + if (!fs.existsSync(configPath)) return false + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) const origins = [ 'tauri://localhost', 'https://tauri.localhost', @@ -571,8 +578,8 @@ function patchGatewayOrigins() { if (!config.gateway) config.gateway = {} if (!config.gateway.controlUi) config.gateway.controlUi = {} config.gateway.controlUi.allowedOrigins = merged - fs.copyFileSync(CONFIG_PATH, CONFIG_PATH + '.bak') - fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2)) + fs.copyFileSync(configPath, configPath + '.bak') + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)) return true } @@ -768,7 +775,8 @@ async function winCheckGateway() { function readGatewayPort() { try { - const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) + const configPath = resolveConfigPath() + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) return config?.gateway?.port || 18789 } catch { return 18789 @@ -1207,16 +1215,18 @@ function _normalizeBaseUrl(raw) { const handlers = { // 配置读写 read_openclaw_config() { - if (!fs.existsSync(CONFIG_PATH)) throw new Error('openclaw.json 不存在,请先安装 OpenClaw') - const content = fs.readFileSync(CONFIG_PATH, 'utf8') + const configPath = resolveConfigPath() + if (!fs.existsSync(configPath)) throw new Error('配置文件不存在,请先安装 OpenClaw') + const content = fs.readFileSync(configPath, 'utf8') return JSON.parse(content) }, write_openclaw_config({ config }) { - const bak = CONFIG_PATH + '.bak' - if (fs.existsSync(CONFIG_PATH)) fs.copyFileSync(CONFIG_PATH, bak) + const configPath = resolveConfigPath() + const bak = configPath + '.bak' + if (fs.existsSync(configPath)) fs.copyFileSync(configPath, bak) const cleaned = stripUiFields(config) - fs.writeFileSync(CONFIG_PATH, JSON.stringify(cleaned, null, 2)) + fs.writeFileSync(configPath, JSON.stringify(cleaned, null, 2)) return true }, @@ -1324,8 +1334,9 @@ const handlers = { // === 消息渠道管理 === list_configured_platforms() { - if (!fs.existsSync(CONFIG_PATH)) return [] - const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) + const configPath = resolveConfigPath() + if (!fs.existsSync(configPath)) return [] + const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')) const channels = cfg.channels || {} return Object.entries(channels).map(([id, val]) => ({ id, @@ -1334,8 +1345,9 @@ const handlers = { }, read_platform_config({ platform }) { - if (!fs.existsSync(CONFIG_PATH)) return { exists: false } - const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) + const configPath = resolveConfigPath() + if (!fs.existsSync(configPath)) return { exists: false } + const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')) const saved = cfg.channels?.[platform] if (!saved) return { exists: false } const form = {} @@ -1364,8 +1376,9 @@ const handlers = { }, save_messaging_platform({ platform, form, accountId }) { - if (!fs.existsSync(CONFIG_PATH)) throw new Error('openclaw.json 不存在') - const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) + const configPath = resolveConfigPath() + if (!fs.existsSync(configPath)) throw new Error('配置文件不存在') + const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')) if (!cfg.channels) cfg.channels = {} const entry = { enabled: true } if (platform === 'qqbot') { @@ -1390,31 +1403,33 @@ const handlers = { if (!cfg.channels.feishu) cfg.channels.feishu = { enabled: true } if (!cfg.channels.feishu.accounts) cfg.channels.feishu.accounts = {} cfg.channels.feishu.accounts[accountId] = entry - fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2)) + fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2)) return { ok: true } } } else { Object.assign(entry, form) } cfg.channels[platform] = entry - fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2)) + fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2)) return { ok: true } }, remove_messaging_platform({ platform }) { - if (!fs.existsSync(CONFIG_PATH)) throw new Error('openclaw.json 不存在') - const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) + const configPath = resolveConfigPath() + if (!fs.existsSync(configPath)) throw new Error('配置文件不存在') + const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')) if (cfg.channels) delete cfg.channels[platform] - fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2)) + fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2)) return { ok: true } }, toggle_messaging_platform({ platform, enabled }) { - if (!fs.existsSync(CONFIG_PATH)) throw new Error('openclaw.json 不存在') - const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) + const configPath = resolveConfigPath() + if (!fs.existsSync(configPath)) throw new Error('配置文件不存在') + const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')) if (!cfg.channels?.[platform]) throw new Error(`平台 ${platform} 未配置`) cfg.channels[platform].enabled = enabled - fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2)) + fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2)) return { ok: true } }, @@ -1501,7 +1516,8 @@ const handlers = { const output = (result.stdout || '') + (result.stderr || '') if (output.includes(pid) && output.includes('built-in')) builtin = true } catch {} - const cfg = fs.existsSync(CONFIG_PATH) ? JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) : {} + const configPath = resolveConfigPath() + const cfg = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, 'utf8')) : {} const allowArr = cfg.plugins?.allow || [] const allowed = allowArr.includes(pid) const enabled = !!cfg.plugins?.entries?.[pid]?.enabled @@ -2210,10 +2226,11 @@ const handlers = { await new Promise(r => setTimeout(r, 300)) } - // 1. 同步 openclaw.json(模型 + API Key 配置) + // 1. 同步配置文件(模型 + API Key 配置) try { - if (fs.existsSync(CONFIG_PATH)) { - const localConfig = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) + const configPath = resolveConfigPath() + if (fs.existsSync(configPath)) { + const localConfig = JSON.parse(fs.readFileSync(configPath, 'utf8')) // 只同步 OpenClaw 认识的字段,避免 Unrecognized key 导致 Gateway 崩溃 const syncConfig = {} if (localConfig.meta) syncConfig.meta = localConfig.meta // 保持原始 meta,不加自定义字段 @@ -2239,9 +2256,9 @@ const handlers = { } const configB64 = b64(JSON.stringify(syncConfig, null, 2)) - await cExec(`mkdir -p /root/.openclaw && echo '${configB64}' | base64 -d > /root/.openclaw/openclaw.json`) + await cExec(`mkdir -p /root/.openclaw && echo '${configB64}' | base64 -d > /root/.openclaw/config.json`) results.config = true - results.files.push('openclaw.json') + results.files.push('config.json') console.log(`[init-worker] 配置已同步 → ${containerId.slice(0, 12)}`) } } catch (e) { @@ -2543,7 +2560,8 @@ const handlers = { // 安装检测 check_installation() { const inDocker = fs.existsSync('/.dockerenv') - return { installed: fs.existsSync(CONFIG_PATH), path: OPENCLAW_DIR, platform: isMac ? 'macos' : process.platform, inDocker } + const configPath = resolveConfigPath() + return { installed: fs.existsSync(configPath), path: configPath, dir: OPENCLAW_DIR, platform: isMac ? 'macos' : process.platform, inDocker } }, check_git() { @@ -2854,7 +2872,8 @@ const handlers = { const now = new Date() const pad = n => String(n).padStart(2, '0') const name = `openclaw-${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}.json` - fs.copyFileSync(CONFIG_PATH, path.join(BACKUPS_DIR, name)) + const configPath = resolveConfigPath() + fs.copyFileSync(configPath, path.join(BACKUPS_DIR, name)) return { name, size: fs.statSync(path.join(BACKUPS_DIR, name)).size } }, @@ -2862,8 +2881,9 @@ const handlers = { if (name.includes('..') || name.includes('/') || name.includes('\\')) throw new Error('非法文件名') const src = path.join(BACKUPS_DIR, name) if (!fs.existsSync(src)) throw new Error('备份不存在') - if (fs.existsSync(CONFIG_PATH)) handlers.create_backup() - fs.copyFileSync(src, CONFIG_PATH) + const configPath = resolveConfigPath() + if (fs.existsSync(configPath)) handlers.create_backup() + fs.copyFileSync(src, configPath) return true }, @@ -2876,8 +2896,9 @@ const handlers = { // Vision 补丁 patch_model_vision() { - if (!fs.existsSync(CONFIG_PATH)) return false - const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) + const configPath = resolveConfigPath() + if (!fs.existsSync(configPath)) return false + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) let changed = false const providers = config?.models?.providers if (providers) { @@ -2892,8 +2913,8 @@ const handlers = { } } if (changed) { - fs.copyFileSync(CONFIG_PATH, CONFIG_PATH + '.bak') - fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2)) + fs.copyFileSync(configPath, configPath + '.bak') + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)) } return changed }, @@ -2997,9 +3018,10 @@ const handlers = { return 'Gateway 服务已卸载' }, - // 自动初始化配置文件(CLI 已装但 openclaw.json 不存在时) + // 自动初始化配置文件(CLI 已装但配置不存在时) init_openclaw_config() { - if (fs.existsSync(CONFIG_PATH)) return { created: false, message: '配置文件已存在' } + const configPath = resolveConfigPath() + if (fs.existsSync(configPath)) return { created: false, message: '配置文件已存在' } if (!fs.existsSync(OPENCLAW_DIR)) fs.mkdirSync(OPENCLAW_DIR, { recursive: true }) const lastTouchedVersion = recommendedVersionFor('chinese') || '2026.1.1' const defaultConfig = { @@ -3014,13 +3036,14 @@ const handlers = { }, tools: { profile: "full", sessions: { visibility: "all" } } } - fs.writeFileSync(CONFIG_PATH, JSON.stringify(defaultConfig, null, 2)) + fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2)) return { created: true, message: '配置文件已创建' } }, get_deploy_config() { try { - const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) + const configPath = resolveConfigPath() + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) const gw = config.gateway || {} return { gatewayUrl: `http://127.0.0.1:${gw.port || 18789}`, authToken: gw.auth?.token || '', version: null } } catch { @@ -3527,6 +3550,13 @@ const handlers = { return { ok: true, status: 200, elapsed_ms: 0, proxy: proxyUrl, target: url || 'N/A (Web模式不支持代理测试)' } }, + // Cloudflared(Web 模式不支持) + cloudflared_get_status() { return { installed: false, running: false, url: null, error: 'web mode not supported' } }, + cloudflared_install() { throw new Error('web mode not supported') }, + cloudflared_login() { throw new Error('web mode not supported') }, + cloudflared_start() { throw new Error('web mode not supported') }, + cloudflared_stop() { return { installed: false, running: false, url: null } }, + // === Agent 管理(Web 模式) === add_agent({ name, model, workspace }) { @@ -3550,29 +3580,31 @@ const handlers = { update_agent_identity({ id, name, emoji }) { if (!id) throw new Error('Agent ID 不能为空') - // 写入 openclaw.json 的 agents 配置 - if (!fs.existsSync(CONFIG_PATH)) throw new Error('openclaw.json 不存在') - const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) + // 写入配置文件的 agents 配置 + const configPath = resolveConfigPath() + if (!fs.existsSync(configPath)) throw new Error('配置文件不存在') + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) if (!config.agents) config.agents = {} if (!config.agents.profiles) config.agents.profiles = {} if (!config.agents.profiles[id]) config.agents.profiles[id] = {} if (name) config.agents.profiles[id].identityName = name if (emoji) config.agents.profiles[id].emoji = emoji - fs.copyFileSync(CONFIG_PATH, CONFIG_PATH + '.bak') - fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2)) + fs.copyFileSync(configPath, configPath + '.bak') + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)) return true }, update_agent_model({ id, model }) { if (!id) throw new Error('Agent ID 不能为空') - if (!fs.existsSync(CONFIG_PATH)) throw new Error('openclaw.json 不存在') - const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) + const configPath = resolveConfigPath() + if (!fs.existsSync(configPath)) throw new Error('配置文件不存在') + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) if (!config.agents) config.agents = {} if (!config.agents.profiles) config.agents.profiles = {} if (!config.agents.profiles[id]) config.agents.profiles[id] = {} config.agents.profiles[id].model = model || null - fs.copyFileSync(CONFIG_PATH, CONFIG_PATH + '.bak') - fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2)) + fs.copyFileSync(configPath, configPath + '.bak') + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)) return true }, @@ -3947,3 +3979,11 @@ export function devApiPlugin() { }, } } +iddlewares.use(_apiMiddleware) + }, + } +} +dlewares.use(_apiMiddleware) + }, + } +} diff --git a/src-tauri/src/commands/agent.rs b/src-tauri/src/commands/agent.rs index 15bac2c1..8b5dd42d 100644 --- a/src-tauri/src/commands/agent.rs +++ b/src-tauri/src/commands/agent.rs @@ -4,12 +4,12 @@ use serde_json::Value; use std::fs; use std::io::Write; -/// 获取 agent 列表(直接读 openclaw.json,不走 CLI,毫秒级响应) +/// 获取 agent 列表(直接读配置文件,不走 CLI,毫秒级响应) #[tauri::command] pub async fn list_agents() -> Result { - let config_path = super::openclaw_dir().join("openclaw.json"); + let config_path = super::openclaw_config_path(); if !config_path.exists() { - return Err("openclaw.json 不存在,请先安装 OpenClaw".to_string()); + return Err("配置文件不存在,请先安装 OpenClaw".to_string()); } let content = fs::read_to_string(&config_path).map_err(|e| format!("读取配置失败: {e}"))?; let config: Value = @@ -173,7 +173,7 @@ pub fn update_agent_identity( name: Option, emoji: Option, ) -> Result { - let path = super::openclaw_dir().join("openclaw.json"); + let path = super::openclaw_config_path(); let content = fs::read_to_string(&path).map_err(|e| format!("读取配置失败: {e}"))?; let mut config: Value = serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {e}"))?; @@ -296,7 +296,7 @@ fn collect_dir_to_zip( /// 更新 agent 模型配置 #[tauri::command] pub fn update_agent_model(id: String, model: String) -> Result { - let path = super::openclaw_dir().join("openclaw.json"); + let path = super::openclaw_config_path(); let content = fs::read_to_string(&path).map_err(|e| format!("读取配置失败: {e}"))?; let mut config: Value = serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {e}"))?; diff --git a/src-tauri/src/commands/cloudflared.rs b/src-tauri/src/commands/cloudflared.rs new file mode 100644 index 00000000..0564946c --- /dev/null +++ b/src-tauri/src/commands/cloudflared.rs @@ -0,0 +1,502 @@ +use crate::commands::{apply_proxy_env, build_http_client, openclaw_config_path, openclaw_dir}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::Duration; + +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; + +use std::process::Command; + +use tokio::process::Child; + +static STATE: std::sync::LazyLock> = + std::sync::LazyLock::new(|| Mutex::new(CloudflaredState::default())); + +#[derive(Default, Debug)] +struct CloudflaredState { + running: bool, + mode: String, + url: Option, + port: u16, + tunnel_name: Option, + hostname: Option, + last_error: Option, + child: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CloudflaredStatus { + pub installed: bool, + pub version: Option, + pub running: bool, + pub url: Option, + pub mode: Option, + pub port: Option, + pub tunnel_name: Option, + pub hostname: Option, + pub last_error: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CloudflaredStartConfig { + pub mode: String, // quick | named + pub port: u16, + pub use_http2: bool, + pub tunnel_name: Option, + pub hostname: Option, + pub add_allowed_origins: bool, + pub expose_target: Option, // gateway | webui | custom +} + +const CLOUDFLARED_URL: &str = + "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe"; + +// Mirrors are prefixes prepended to the GitHub release URL. +// Keep them short and stable; we probe latency via HEAD. +const MIRROR_PREFIXES: [&str; 7] = [ + "https://gh-proxy.com/", + "https://gh-proxy.org/", + "https://cdn.gh-proxy.org/", + "https://hk.gh-proxy.org/", + "https://gh.ddlc.top/", + "https://mirror.ghproxy.com/", + "", // direct GitHub +]; + +fn resolve_cloudflared_bin() -> Option { + let openclaw_bin = openclaw_dir().join("bin").join("cloudflared.exe"); + if openclaw_bin.exists() { + return Some(openclaw_bin); + } + + // PATH search: cloudflared.exe or cloudflare.exe + let path = crate::commands::enhanced_path(); + for p in path.split(';') { + let base = Path::new(p); + let cand = base.join("cloudflared.exe"); + if cand.exists() { + return Some(cand); + } + let cand2 = base.join("cloudflare.exe"); + if cand2.exists() { + return Some(cand2); + } + } + None +} + +fn cloudflared_cmd(bin: &Path) -> Command { + #[cfg(target_os = "windows")] + { + let mut cmd = Command::new(bin); + cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW + return cmd; + } + #[cfg(not(target_os = "windows"))] + { + Command::new(bin) + } +} + +fn get_version(bin: &Path) -> Option { + let mut cmd = cloudflared_cmd(bin); + cmd.arg("--version"); + let out = cmd.output().ok()?; + if !out.status.success() { + return None; + } + let output = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if output.is_empty() { + return None; + } + Some(output) +} + +#[tauri::command] +pub fn cloudflared_get_status() -> Result { + let bin = resolve_cloudflared_bin(); + let installed = bin.is_some(); + let version = bin.as_ref().and_then(|b| get_version(b)); + + let state = STATE.lock().unwrap(); + Ok(json!(CloudflaredStatus { + installed, + version, + running: state.running, + url: state.url.clone(), + mode: if state.mode.is_empty() { None } else { Some(state.mode.clone()) }, + port: if state.port == 0 { None } else { Some(state.port) }, + tunnel_name: state.tunnel_name.clone(), + hostname: state.hostname.clone(), + last_error: state.last_error.clone(), + })) +} + +#[tauri::command] +pub async fn cloudflared_install() -> Result { + let bin_path = openclaw_dir().join("bin").join("cloudflared.exe"); + if bin_path.exists() { + return cloudflared_get_status(); + } + + let client = build_http_client(Duration::from_secs(60), Some("ClawPanel")) + .map_err(|e| format!("创建下载客户端失败: {e}"))?; + + // Probe mirrors (HEAD) + let mut fastest: Option<(String, i64)> = None; + for prefix in MIRROR_PREFIXES.iter() { + let url = format!("{}{}", prefix, CLOUDFLARED_URL); + let start = std::time::Instant::now(); + let resp = client.head(&url).send().await; + let elapsed = start.elapsed().as_millis() as i64; + if let Ok(r) = resp { + if r.status().as_u16() < 400 { + match fastest { + None => fastest = Some((prefix.to_string(), elapsed)), + Some((_, best)) if elapsed < best => fastest = Some((prefix.to_string(), elapsed)), + _ => {} + } + } + } + } + + let prefix = fastest.map(|v| v.0).unwrap_or_else(|| "".to_string()); + let url = format!("{}{}", prefix, CLOUDFLARED_URL); + + let resp = client + .get(&url) + .send() + .await + .map_err(|e| format!("下载失败: {e}"))?; + if resp.status().as_u16() >= 400 { + return Err(format!("下载失败: HTTP {}", resp.status().as_u16())); + } + + if let Some(parent) = bin_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {e}"))?; + } + + let bytes = resp.bytes().await.map_err(|e| format!("读取下载内容失败: {e}"))?; + let tmp = bin_path.with_extension("exe.tmp"); + std::fs::write(&tmp, &bytes).map_err(|e| format!("写入失败: {e}"))?; + if bin_path.exists() { + let _ = std::fs::remove_file(&bin_path); + } + std::fs::rename(&tmp, &bin_path).map_err(|e| format!("安装失败: {e}"))?; + + cloudflared_get_status() +} + +#[tauri::command] +pub fn cloudflared_login() -> Result { + let bin = resolve_cloudflared_bin().ok_or("cloudflared 未安装")?; + let mut cmd = cloudflared_cmd(&bin); + cmd.args(["tunnel", "login"]); + apply_proxy_env(&mut cmd); + let out = cmd.output().map_err(|e| format!("登录失败: {e}"))?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + return Err(format!("登录失败: {stderr}")); + } + Ok("登录成功".into()) +} + +fn parse_quick_url(text: &str) -> Option { + for token in text.split_whitespace() { + if token.starts_with("https://") && token.contains("trycloudflare.com") { + return Some(token.trim().to_string()); + } + } + None +} + +fn extract_origin(url: &str) -> Option { + if let Some(idx) = url.find("//") { + let rest = &url[idx + 2..]; + let host = rest.split('/').next().unwrap_or(""); + if !host.is_empty() { + return Some(format!("https://{host}")); + } + } + None +} + +fn add_allowed_origin_for_target(origin: &str, target: &str, port: u16) -> Result<(), String> { + let mut config = crate::commands::config::load_openclaw_json()?; + let root = config.as_object_mut().ok_or("配置格式错误")?; + let gateway = root.entry("gateway").or_insert_with(|| json!({})); + let gateway_obj = gateway.as_object_mut().ok_or("gateway 节点格式错误")?; + + // webui 目标时,确保 controlUi 存在 + let control = gateway_obj.entry("controlUi").or_insert_with(|| json!({})); + let control_obj = control.as_object_mut().ok_or("gateway.controlUi 节点格式错误")?; + let arr = control_obj + .entry("allowedOrigins") + .or_insert_with(|| json!([])); + let list = arr.as_array_mut().ok_or("allowedOrigins 格式错误")?; + + // 允许同时写入指定 origin(用于 Web UI 1420) + if !list.iter().any(|v| v.as_str() == Some(origin)) { + list.push(Value::String(origin.to_string())); + } + + // gateway 目标时,补充 http origin(用于 18789) + if target == "gateway" || target == "custom" { + let http_origin = format!("http://{}", origin.trim_start_matches("https://")); + if !list.iter().any(|v| v.as_str() == Some(http_origin.as_str())) { + list.push(Value::String(http_origin)); + } + } + + // webui 目标时,补充具体端口 origin + if target == "webui" { + let host = origin.trim_start_matches("https://"); + let http_origin = format!("http://{host}:{port}"); + if !list.iter().any(|v| v.as_str() == Some(http_origin.as_str())) { + list.push(Value::String(http_origin)); + } + } + + crate::commands::config::save_openclaw_json(&config)?; + Ok(()) +} + +fn add_allowed_origin(origin: &str) -> Result<(), String> { + let mut config = crate::commands::config::load_openclaw_json()?; + let root = config.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 control = gateway_obj.entry("controlUi").or_insert_with(|| json!({})); + let control_obj = control.as_object_mut().ok_or("gateway.controlUi 节点格式错误")?; + let arr = control_obj + .entry("allowedOrigins") + .or_insert_with(|| json!([])); + let list = arr.as_array_mut().ok_or("allowedOrigins 格式错误")?; + if !list.iter().any(|v| v.as_str() == Some(origin)) { + list.push(Value::String(origin.to_string())); + } + crate::commands::config::save_openclaw_json(&config)?; + Ok(()) +} + +fn ensure_named_config(tunnel_name: &str, hostname: &str, port: u16) -> Result { + let bin = resolve_cloudflared_bin().ok_or("cloudflared 未安装")?; + + // create tunnel if not exists + let mut cmd = cloudflared_cmd(&bin); + cmd.args(["tunnel", "list", "--output", "json"]); + apply_proxy_env(&mut cmd); + let out = cmd.output().map_err(|e| format!("tunnel list failed: {e}"))?; + let mut tunnel_id = None; + if out.status.success() { + if let Ok(items) = serde_json::from_slice::>(&out.stdout) { + for it in items { + if it.get("name").and_then(|v| v.as_str()) == Some(tunnel_name) { + if let Some(id) = it.get("id").and_then(|v| v.as_str()) { + tunnel_id = Some(id.to_string()); + break; + } + } + } + } + } + + if tunnel_id.is_none() { + let mut c = cloudflared_cmd(&bin); + c.args(["tunnel", "create", tunnel_name]); + apply_proxy_env(&mut c); + let out = c.output().map_err(|e| format!("tunnel create failed: {e}"))?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + return Err(format!("tunnel create failed: {stderr}")); + } + // re-list + let mut list = cloudflared_cmd(&bin); + list.args(["tunnel", "list", "--output", "json"]); + apply_proxy_env(&mut list); + let out2 = list.output().map_err(|e| format!("tunnel list failed: {e}"))?; + if out2.status.success() { + if let Ok(items) = serde_json::from_slice::>(&out2.stdout) { + for it in items { + if it.get("name").and_then(|v| v.as_str()) == Some(tunnel_name) { + if let Some(id) = it.get("id").and_then(|v| v.as_str()) { + tunnel_id = Some(id.to_string()); + break; + } + } + } + } + } + } + + let tunnel_id = tunnel_id.ok_or("无法获取 tunnel id")?; + + let home = dirs::home_dir().ok_or("无法获取用户目录")?; + let cred_path = home.join(".cloudflared").join(format!("{tunnel_id}.json")); + if !cred_path.exists() { + return Err("凭据文件不存在,请先完成登录".into()); + } + + let mut route = cloudflared_cmd(&bin); + route.args(["tunnel", "route", "dns", "--overwrite-dns", &tunnel_id, hostname]); + apply_proxy_env(&mut route); + let out = route.output().map_err(|e| format!("route dns failed: {e}"))?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + return Err(format!("route dns failed: {stderr}")); + } + + let cfg_dir = openclaw_dir().join("cloudflared"); + std::fs::create_dir_all(&cfg_dir).map_err(|e| format!("创建目录失败: {e}"))?; + let cfg_path = cfg_dir.join("config.yml"); + let content = format!( + "tunnel: {tunnel_id}\ncredentials-file: {}\n\ningress:\n - hostname: {hostname}\n service: http://127.0.0.1:{port}\n - service: http_status:404\n", + cred_path.to_string_lossy() + ); + std::fs::write(&cfg_path, content).map_err(|e| format!("写入配置失败: {e}"))?; + + Ok(cfg_path) +} + +#[tauri::command] +pub async fn cloudflared_start(config: CloudflaredStartConfig) -> Result { + let bin = resolve_cloudflared_bin().ok_or("cloudflared 未安装")?; + + let port = if config.port == 0 { 18789 } else { config.port }; + + // stop existing + { + let mut state = STATE.lock().unwrap(); + if let Some(mut child) = state.child.take() { + let _ = child.kill().await; + } + state.running = false; + state.url = None; + state.last_error = None; + } + + let mode = config.mode.clone(); + let use_http2 = config.use_http2; + + let mut child: Child; + let mut url: Option = None; + + if mode == "quick" { + let mut cmd = tokio::process::Command::new(&bin); + cmd.args(["tunnel", "--url", &format!("http://127.0.0.1:{port}")]); + if use_http2 { + cmd.args(["--protocol", "http2"]); + } + crate::commands::apply_proxy_env_tokio(&mut cmd); + #[cfg(target_os = "windows")] + { + cmd.creation_flags(0x08000000); + } + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + child = cmd.spawn().map_err(|e| format!("启动失败: {e}"))?; + + // parse stdout for url + if let Some(stdout) = child.stdout.take() { + let mut reader = tokio::io::BufReader::new(stdout).lines(); + let mut deadline = tokio::time::sleep(Duration::from_secs(12)); + tokio::pin!(deadline); + loop { + tokio::select! { + line = reader.next_line() => { + if let Ok(Some(l)) = line { + if let Some(u) = parse_quick_url(&l) { + url = Some(u); + break; + } + } else { + break; + } + } + _ = &mut deadline => { break; } + } + } + } + } else { + let tunnel_name = config.tunnel_name.clone().ok_or("缺少隧道名称")?; + let hostname = config.hostname.clone().ok_or("缺少域名")?; + let cfg_path = ensure_named_config(&tunnel_name, &hostname, port)?; + + let mut cmd = tokio::process::Command::new(&bin); + cmd.args([ + "tunnel", + "--config", + &cfg_path.to_string_lossy(), + "--edge-ip-version", + "4", + "run", + "--dns-resolver-addrs", + "1.1.1.1:53", + "--dns-resolver-addrs", + "8.8.8.8:53", + &tunnel_name, + ]); + crate::commands::apply_proxy_env_tokio(&mut cmd); + #[cfg(target_os = "windows")] + { + cmd.creation_flags(0x08000000); + } + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + child = cmd.spawn().map_err(|e| format!("启动失败: {e}"))?; + url = Some(format!("https://{hostname}")); + } + + if let Some(u) = url.clone() { + if config.add_allowed_origins { + if let Some(origin) = extract_origin(&u) { + let _ = add_allowed_origin_for_target(&origin, config.expose_target.as_deref().unwrap_or("gateway"), port); + } + } + } + + let mut state = STATE.lock().unwrap(); + state.running = true; + state.mode = mode; + state.url = url.clone(); + state.port = port; + state.tunnel_name = config.tunnel_name.clone(); + state.hostname = config.hostname.clone(); + state.child = Some(child); + + Ok(json!(CloudflaredStatus { + installed: true, + version: get_version(&bin), + running: true, + url, + mode: Some(config.mode), + port: Some(port), + tunnel_name: config.tunnel_name, + hostname: config.hostname, + last_error: None, + })) +} + +#[tauri::command] +pub async fn cloudflared_stop() -> Result { + let mut state = STATE.lock().unwrap(); + if let Some(mut child) = state.child.take() { + let _ = child.kill().await; + } + state.running = false; + state.url = None; + Ok(json!(CloudflaredStatus { + installed: resolve_cloudflared_bin().is_some(), + version: resolve_cloudflared_bin().as_ref().and_then(|b| get_version(b)), + running: false, + url: None, + mode: if state.mode.is_empty() { None } else { Some(state.mode.clone()) }, + port: if state.port == 0 { None } else { Some(state.port) }, + tunnel_name: state.tunnel_name.clone(), + hostname: state.hostname.clone(), + last_error: None, + })) +} diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 86a047ab..f8f1bb16 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -282,7 +282,7 @@ fn backups_dir() -> PathBuf { #[tauri::command] pub fn read_openclaw_config() -> Result { - let path = super::openclaw_dir().join("openclaw.json"); + let path = super::openclaw_config_path(); let raw = fs::read(&path).map_err(|e| format!("读取配置失败: {e}"))?; // 自愈:自动剥离 UTF-8 BOM(EF BB BF),防止 JSON 解析失败 @@ -303,7 +303,7 @@ pub fn read_openclaw_config() -> Result { } Err(e) => { // JSON 解析失败,尝试从备份恢复 - let bak = super::openclaw_dir().join("openclaw.json.bak"); + let bak = path.with_extension("json.bak"); if bak.exists() { let bak_raw = fs::read(&bak).map_err(|e2| format!("备份也读取失败: {e2}"))?; let bak_content = if bak_raw.starts_with(&[0xEF, 0xBB, 0xBF]) { @@ -326,7 +326,7 @@ pub fn read_openclaw_config() -> Result { if has_ui_fields(&config) { config = strip_ui_fields(config); // 静默写回清理后的配置 - let bak = super::openclaw_dir().join("openclaw.json.bak"); + let bak = path.with_extension("json.bak"); let _ = fs::copy(&path, &bak); let json = serde_json::to_string_pretty(&config).map_err(|e| format!("序列化失败: {e}"))?; let _ = fs::write(&path, json); @@ -335,12 +335,12 @@ pub fn read_openclaw_config() -> Result { Ok(config) } -/// 供其他模块复用:读取 openclaw.json 为 JSON Value +/// 供其他模块复用:读取配置文件为 JSON Value pub fn load_openclaw_json() -> Result { read_openclaw_config() } -/// 供其他模块复用:将 JSON Value 写回 openclaw.json(含备份和清理) +/// 供其他模块复用:将 JSON Value 写回配置文件(含备份和清理) pub fn save_openclaw_json(config: &Value) -> Result<(), String> { write_openclaw_config(config.clone()) } @@ -353,9 +353,9 @@ pub async fn do_reload_gateway(app: &tauri::AppHandle) -> Result #[tauri::command] pub fn write_openclaw_config(config: Value) -> Result<(), String> { - let path = super::openclaw_dir().join("openclaw.json"); + let path = super::openclaw_config_path(); // 备份 - let bak = super::openclaw_dir().join("openclaw.json.bak"); + let bak = super::openclaw_dir().join("config.json.bak"); let _ = fs::copy(&path, &bak); // 清理 UI 专属字段,避免 CLI schema 校验失败 let cleaned = strip_ui_fields(config.clone()); @@ -1266,11 +1266,11 @@ async fn uninstall_openclaw_inner( Ok(msg.into()) } -/// 自动初始化配置文件(CLI 已装但 openclaw.json 不存在时) +/// 自动初始化配置文件(CLI 已装但配置不存在时) #[tauri::command] pub fn init_openclaw_config() -> Result { let dir = super::openclaw_dir(); - let config_path = dir.join("openclaw.json"); + let config_path = super::openclaw_config_path(); let mut result = serde_json::Map::new(); if config_path.exists() { @@ -1311,11 +1311,16 @@ pub fn init_openclaw_config() -> Result { #[tauri::command] pub fn check_installation() -> Result { let dir = super::openclaw_dir(); - let installed = dir.join("openclaw.json").exists(); + let config_path = super::openclaw_config_path(); + let installed = config_path.exists(); let mut result = serde_json::Map::new(); result.insert("installed".into(), Value::Bool(installed)); result.insert( "path".into(), + Value::String(config_path.to_string_lossy().to_string()), + ); + result.insert( + "dir".into(), Value::String(dir.to_string_lossy().to_string()), ); Ok(Value::Object(result)) diff --git a/src-tauri/src/commands/memory.rs b/src-tauri/src/commands/memory.rs index d1f35a22..d099579b 100644 --- a/src-tauri/src/commands/memory.rs +++ b/src-tauri/src/commands/memory.rs @@ -34,7 +34,7 @@ fn is_unsafe_path(path: &str) -> bool { || (path.len() >= 2 && path.as_bytes()[1] == b':') // Windows 绝对路径 C:\ } -/// 根据 agent_id 获取 workspace 路径(直接读 openclaw.json,带缓存) +/// 根据 agent_id 获取 workspace 路径(直接读配置文件,带缓存) /// 不再调用 CLI,毫秒级响应 async fn agent_workspace(agent_id: &str) -> Result { // 先查缓存 @@ -50,10 +50,10 @@ async fn agent_workspace(agent_id: &str) -> Result { } } - // 缓存过期或为空,从 openclaw.json 读取 - let config_path = super::openclaw_dir().join("openclaw.json"); + // 缓存过期或为空,从配置文件读取 + let config_path = super::openclaw_config_path(); let content = - fs::read_to_string(&config_path).map_err(|e| format!("读取 openclaw.json 失败: {e}"))?; + fs::read_to_string(&config_path).map_err(|e| format!("读取配置失败: {e}"))?; let config: serde_json::Value = serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {e}"))?; diff --git a/src-tauri/src/commands/messaging.rs b/src-tauri/src/commands/messaging.rs index 07879673..e4f91e99 100644 --- a/src-tauri/src/commands/messaging.rs +++ b/src-tauri/src/commands/messaging.rs @@ -1,6 +1,6 @@ /// 消息渠道管理 /// 负责 Telegram / Discord / QQ Bot 等消息渠道的配置持久化与凭证校验 -/// 配置写入 openclaw.json 的 channels / plugins 节点 +/// 配置写入配置文件的 channels / plugins 节点 use serde_json::{json, Map, Value}; use std::fs; use std::path::{Path, PathBuf}; @@ -58,7 +58,7 @@ fn gateway_auth_value(cfg: &Value, key: &str) -> Option { .map(|v| v.to_string()) } -/// 读取指定平台的当前配置(从 openclaw.json 中提取表单可用的值) +/// 读取指定平台的当前配置(从配置文件中提取表单可用的值) #[tauri::command] pub async fn read_platform_config(platform: String) -> Result { let cfg = super::config::load_openclaw_json()?; diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 948ff680..a583c8fb 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -5,6 +5,7 @@ use std::time::Duration; pub mod agent; pub mod assistant; +pub mod cloudflared; pub mod config; pub mod device; pub mod extensions; @@ -21,6 +22,22 @@ pub fn openclaw_dir() -> PathBuf { dirs::home_dir().unwrap_or_default().join(".openclaw") } +/// 获取 OpenClaw 配置文件路径 +/// 优先 config.json,若不存在则回退 openclaw.json +pub fn openclaw_config_path() -> PathBuf { + let dir = openclaw_dir(); + let config_json = dir.join("config.json"); + if config_json.exists() { + return config_json; + } + let legacy = dir.join("openclaw.json"); + if legacy.exists() { + return legacy; + } + // 默认使用 config.json + config_json +} + fn panel_config_path() -> PathBuf { openclaw_dir().join("clawpanel.json") } diff --git a/src-tauri/src/commands/pairing.rs b/src-tauri/src/commands/pairing.rs index cecc9d35..8fcd669c 100644 --- a/src-tauri/src/commands/pairing.rs +++ b/src-tauri/src/commands/pairing.rs @@ -104,7 +104,7 @@ pub fn auto_pair_device() -> Result { /// 将 Tauri 应用的 origin 写入 gateway.controlUi.allowedOrigins /// 避免 Gateway 因 origin not allowed 拒绝 WebSocket 握手 fn patch_gateway_origins() { - let config_path = crate::commands::openclaw_dir().join("openclaw.json"); + let config_path = crate::commands::openclaw_config_path(); if !config_path.exists() { return; } diff --git a/src-tauri/src/commands/service.rs b/src-tauri/src/commands/service.rs index 0b9f98af..52f3cfc6 100644 --- a/src-tauri/src/commands/service.rs +++ b/src-tauri/src/commands/service.rs @@ -165,9 +165,7 @@ pub(crate) fn guardian_resume(reason: &str) { } fn gateway_config_exists() -> bool { - crate::commands::openclaw_dir() - .join("openclaw.json") - .exists() + crate::commands::openclaw_config_path().exists() } async fn gateway_service_status() -> Result, String> { @@ -719,9 +717,9 @@ mod platform { vec!["ai.openclaw.gateway".to_string()] } - /// 从 openclaw.json 读取 gateway 端口,fallback 到 18789 + /// 从配置文件读取 gateway 端口,fallback 到 18789 fn read_gateway_port() -> u16 { - let config_path = crate::commands::openclaw_dir().join("openclaw.json"); + let config_path = crate::commands::openclaw_config_path(); if let Ok(content) = std::fs::read_to_string(&config_path) { if let Ok(val) = serde_json::from_str::(&content) { if let Some(port) = val @@ -1002,9 +1000,9 @@ mod platform { vec!["ai.openclaw.gateway".to_string()] } - /// 从 openclaw.json 读取 gateway 端口,fallback 到 18789 + /// 从配置文件读取 gateway 端口,fallback 到 18789 fn read_gateway_port() -> u16 { - let config_path = crate::commands::openclaw_dir().join("openclaw.json"); + let config_path = crate::commands::openclaw_config_path(); if let Ok(content) = std::fs::read_to_string(&config_path) { if let Ok(val) = serde_json::from_str::(&content) { if let Some(port) = val diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3be37226..ccca5066 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,8 +4,8 @@ mod tray; mod utils; use commands::{ - agent, assistant, config, device, extensions, logs, memory, messaging, pairing, service, - skills, update, + agent, assistant, cloudflared, config, device, extensions, logs, memory, messaging, pairing, + service, skills, update, }; pub fn run() { @@ -106,6 +106,12 @@ pub fn run() { pairing::check_pairing_status, pairing::pairing_list_channel, pairing::pairing_approve_channel, + // Cloudflared + cloudflared::cloudflared_get_status, + cloudflared::cloudflared_install, + cloudflared::cloudflared_login, + cloudflared::cloudflared_start, + cloudflared::cloudflared_stop, // 服务 service::get_services_status, service::start_service, diff --git a/src/lib/local-cron.js b/src/lib/local-cron.js new file mode 100644 index 00000000..63f38d0c --- /dev/null +++ b/src/lib/local-cron.js @@ -0,0 +1,172 @@ +/** + * 本地定时任务调度器(ClawPanel 内部) + * 仅用于发送 user 消息到指定 sessionKey + */ +import { api } from './tauri-api.js' +import { wsClient } from './ws-client.js' + +let _started = false +let _timer = null +let _busySessions = new Map() +let _pendingBySession = new Map() + +function ensureCronConfig(cfg) { + if (!cfg.cronLocal || typeof cfg.cronLocal !== 'object') { + cfg.cronLocal = { jobs: [] } + } + if (!Array.isArray(cfg.cronLocal.jobs)) cfg.cronLocal.jobs = [] + return cfg +} + +export async function loadLocalCronJobs() { + const cfg = await api.readPanelConfig() + ensureCronConfig(cfg) + return cfg.cronLocal.jobs +} + +export async function saveLocalCronJobs(jobs) { + const cfg = await api.readPanelConfig() + ensureCronConfig(cfg) + cfg.cronLocal.jobs = jobs + await api.writePanelConfig(cfg) +} + +export async function addLocalCronJob(job) { + const jobs = await loadLocalCronJobs() + jobs.push(job) + await saveLocalCronJobs(jobs) +} + +export async function updateLocalCronJob(id, patch) { + const jobs = await loadLocalCronJobs() + const idx = jobs.findIndex(j => j.id === id) + if (idx >= 0) { + jobs[idx] = { ...jobs[idx], ...patch } + await saveLocalCronJobs(jobs) + } +} + +export async function removeLocalCronJob(id) { + const jobs = await loadLocalCronJobs() + const next = jobs.filter(j => j.id !== id) + await saveLocalCronJobs(next) +} + +function parseCronField(field, min, max, current) { + if (field === '*') return true + if (field.includes('/')) { + const [base, stepStr] = field.split('/') + const step = Number(stepStr) + if (!step || step <= 0) return false + const start = base === '*' ? min : Number(base) + if (Number.isNaN(start)) return false + return (current - start) % step === 0 + } + const num = Number(field) + if (Number.isNaN(num)) return false + return current === num +} + +function cronMatch(expr, date) { + const parts = String(expr || '').trim().split(' ') + if (parts.length !== 5) return false + const [min, hr, dom, mon, dow] = parts + const m = date.getMinutes() + const h = date.getHours() + const d = date.getDate() + const mo = date.getMonth() + 1 + const w = date.getDay() + return ( + parseCronField(min, 0, 59, m) && + parseCronField(hr, 0, 23, h) && + parseCronField(dom, 1, 31, d) && + parseCronField(mon, 1, 12, mo) && + parseCronField(dow, 0, 6, w) + ) +} + +function minuteKey(date) { + const pad = n => String(n).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` +} + +async function sendUserMessage(sessionKey, message) { + if (!wsClient || !wsClient.gatewayReady) throw new Error('Gateway 未连接') + await wsClient.chatSend(sessionKey, message) +} + +async function flushPending(sessionKey) { + const queue = _pendingBySession.get(sessionKey) || [] + if (!queue.length) return + _pendingBySession.set(sessionKey, []) + for (const item of queue) { + try { + await sendUserMessage(item.sessionKey, item.message) + await updateLocalCronJob(item.jobId, { lastRunAtMs: Date.now(), lastStatus: 'success' }) + } catch (e) { + await updateLocalCronJob(item.jobId, { lastRunAtMs: Date.now(), lastStatus: 'error', lastError: String(e) }) + } + } +} + +function markBusy(sessionKey, busy) { + if (!sessionKey) return + _busySessions.set(sessionKey, !!busy) + if (!busy) flushPending(sessionKey) +} + +export function startLocalCronScheduler() { + if (_started) return + _started = true + + wsClient.onEvent((msg) => { + if (msg?.event !== 'chat') return + const payload = msg.payload || {} + const key = payload.sessionKey + const state = payload.state + if (!key || !state) return + if (state === 'delta') markBusy(key, true) + if (state === 'final') markBusy(key, false) + }) + + _timer = setInterval(async () => { + try { + const jobs = await loadLocalCronJobs() + if (!jobs.length) return + const now = new Date() + const key = minuteKey(now) + for (const job of jobs) { + if (job.enabled === false) continue + if (job.scheduleKind !== 'cron') continue + if (!cronMatch(job.scheduleExpr, now)) continue + if (job.lastRunKey === key) continue + job.lastRunKey = key + const sessionKey = job.sessionKey + if (!sessionKey || !job.message) continue + const busy = _busySessions.get(sessionKey) + if (busy) { + const queue = _pendingBySession.get(sessionKey) || [] + queue.push({ jobId: job.id, sessionKey, message: job.message }) + _pendingBySession.set(sessionKey, queue) + } else { + try { + await sendUserMessage(sessionKey, job.message) + job.lastRunAtMs = Date.now() + job.lastStatus = 'success' + } catch (e) { + job.lastRunAtMs = Date.now() + job.lastStatus = 'error' + job.lastError = String(e) + } + } + } + await saveLocalCronJobs(jobs) + } catch {} + }, 60000) +} + +export function stopLocalCronScheduler() { + if (_timer) clearInterval(_timer) + _timer = null + _started = false +} diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index aca812e2..bd1ea288 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -207,6 +207,13 @@ export const api = { writePanelConfig: (config) => invoke('write_panel_config', { config }), testProxy: (url) => invoke('test_proxy', { url: url || null }), + // Cloudflared + cloudflaredGetStatus: () => invoke('cloudflared_get_status'), + cloudflaredInstall: () => invoke('cloudflared_install'), + cloudflaredLogin: () => invoke('cloudflared_login'), + cloudflaredStart: (config) => invoke('cloudflared_start', { config }), + cloudflaredStop: () => invoke('cloudflared_stop'), + // 安装/部署 checkInstallation: () => cachedInvoke('check_installation', {}, 60000), initOpenclawConfig: () => { invalidate('check_installation'); return invoke('init_openclaw_config') }, diff --git a/src/main.js b/src/main.js index 5c3d181b..aad59268 100644 --- a/src/main.js +++ b/src/main.js @@ -5,6 +5,7 @@ import { registerRoute, initRouter, navigate, setDefaultRoute } from './router.j import { renderSidebar, openMobileSidebar } from './components/sidebar.js' import { initTheme } from './lib/theme.js' import { detectOpenclawStatus, isOpenclawReady, isUpgrading, isGatewayRunning, onGatewayChange, startGatewayPoll, onGuardianGiveUp, resetAutoRestart, loadActiveInstance, getActiveInstance, onInstanceChange } from './lib/app-state.js' +import { startLocalCronScheduler } from './lib/local-cron.js' import { wsClient } from './lib/ws-client.js' import { api, checkBackendHealth, isBackendOnline, onBackendStatusChange } from './lib/tauri-api.js' import { version as APP_VERSION } from '../package.json' diff --git a/src/pages/settings.js b/src/pages/settings.js index 123d5c77..8a649687 100644 --- a/src/pages/settings.js +++ b/src/pages/settings.js @@ -164,6 +164,24 @@ function bindEvents(page) { case 'save-registry': await handleSaveRegistry(page) break + case 'cloudflared-install': + await handleCloudflaredInstall(page) + break + case 'cloudflared-login': + await handleCloudflaredLogin(page) + break + case 'cloudflared-start': + await handleCloudflaredStart(page) + break + case 'cloudflared-stop': + await handleCloudflaredStop(page) + break + case 'cloudflared-refresh': + await loadCloudflared(page) + break + case 'cloudflared-save': + await handleCloudflaredSave(page) + break } } catch (e) { toast(e.toString(), 'error') @@ -244,3 +262,143 @@ async function handleSaveRegistry(page) { await api.setNpmRegistry(registry) toast('npm 源已保存', 'success') } + +// ===== Cloudflared 公网访问 ===== + +function getCloudflaredForm(page) { + const mode = page.querySelector('[data-name="cloudflared-mode"]')?.value || 'quick' + const exposeTarget = page.querySelector('[data-name="cloudflared-expose"]')?.value || 'gateway' + const customPort = Number(page.querySelector('[data-name="cloudflared-port"]')?.value || 0) + const useHttp2 = !!page.querySelector('[data-name="cloudflared-http2"]')?.checked + const tunnelName = (page.querySelector('[data-name="cloudflared-tunnel"]')?.value || '').trim() + const hostname = (page.querySelector('[data-name="cloudflared-hostname"]')?.value || '').trim() + return { mode, exposeTarget, customPort, useHttp2, tunnelName, hostname } +} + +function resolveExposePort(form) { + if (form.exposeTarget === 'webui') return 1420 + if (form.exposeTarget === 'custom') return form.customPort || 18789 + return 18789 +} + +async function loadCloudflared(page) { + const el = page.querySelector('#cloudflared-bar') + if (!el) return + + const cfg = await api.readPanelConfig() + if (!cfg.cloudflared || typeof cfg.cloudflared !== 'object') { + cfg.cloudflared = { mode: 'quick', exposeTarget: 'gateway', customPort: '', useHttp2: true, tunnelName: '', hostname: '' } + await api.writePanelConfig(cfg) + } + const saved = cfg.cloudflared || {} + const status = await api.cloudflaredGetStatus().catch(() => ({ installed: false, running: false })) + + const mode = saved.mode || 'quick' + const exposeTarget = saved.exposeTarget || 'gateway' + const customPort = saved.customPort || '' + const useHttp2 = saved.useHttp2 !== false + const tunnelName = saved.tunnelName || '' + const hostname = saved.hostname || '' + + el.innerHTML = ` +
+ + ${status.running ? '运行中' : '未运行'} + 版本: ${escapeHtml(status.version || '未知')} + ${status.url ? `打开公网地址` : ''} +
+ +
+ + + ${status.running + ? '' + : '' + } + + +
+ +
+ + + + + + +
+ +
+ 选择 Gateway 暴露时,会自动将 Cloudflare URL 写入 gateway.controlUi.allowedOrigins。 +
+ ` +} + +async function handleCloudflaredSave(page) { + const cfg = await api.readPanelConfig() + const form = getCloudflaredForm(page) + cfg.cloudflared = { + mode: form.mode, + exposeTarget: form.exposeTarget, + customPort: form.customPort, + useHttp2: form.useHttp2, + tunnelName: form.tunnelName, + hostname: form.hostname, + } + await api.writePanelConfig(cfg) + toast('Cloudflared 设置已保存', 'success') +} + +async function handleCloudflaredInstall(page) { + await api.cloudflaredInstall() + await loadCloudflared(page) + toast('Cloudflared 已安装', 'success') +} + +async function handleCloudflaredLogin(page) { + await api.cloudflaredLogin() + await loadCloudflared(page) + toast('Cloudflared 登录完成', 'success') +} + +async function handleCloudflaredStart(page) { + const form = getCloudflaredForm(page) + const port = resolveExposePort(form) + await handleCloudflaredSave(page) + await api.cloudflaredStart({ + mode: form.mode, + port, + use_http2: form.useHttp2, + tunnel_name: form.tunnelName || null, + hostname: form.hostname || null, + add_allowed_origins: true, + expose_target: form.exposeTarget, + }) + await loadCloudflared(page) + toast('Cloudflared 已启动', 'success') +} + +async function handleCloudflaredStop(page) { + await api.cloudflaredStop() + await loadCloudflared(page) + toast('Cloudflared 已停止', 'success') +} diff --git a/src/pages/setup.js b/src/pages/setup.js index 330a7f53..4f089a1d 100644 --- a/src/pages/setup.js +++ b/src/pages/setup.js @@ -78,6 +78,11 @@ async function runDetect(page) { } } + // 兼容:仅配置存在也视为可用 + if (!cliOk && config.installed) { + // 保持流程可继续,不强制安装 CLI + } + // Git 已安装时,自动配置 HTTPS 替代 SSH(静默执行) if (git.installed) { api.configureGitHttps().catch(() => {}) @@ -95,7 +100,7 @@ function renderSteps(page, { node, git, cliOk, config, version }) { const stepsEl = page.querySelector('#setup-steps') const nodeOk = node.installed const gitOk = git?.installed || false - const allOk = nodeOk && cliOk && config.installed + const allOk = nodeOk && config.installed let html = '' @@ -171,7 +176,7 @@ function renderSteps(page, { node, git, cliOk, config, version }) { 检测到当前本地 OpenClaw ${version.current || ''} 高于当前面板推荐稳定版 ${version.recommended},可能存在兼容或稳定性风险。建议稍后到「关于」页回退到推荐版。
` : ''}` - : renderInstallSection() + : (config.installed ? `

已检测到配置文件,但 CLI 未安装

` : renderInstallSection()) }
` @@ -188,6 +193,10 @@ function renderSteps(page, { node, git, cliOk, config, version }) {

` } + ${config.installed && !cliOk + ? `

仅检测到配置文件,未检测到 CLI。你仍可继续使用,但部分功能可能受限。

` + : '' + }
` From c12da4208bbebc65bc7ab3cfc2815de77f1b2b9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 12:20:32 +0800 Subject: [PATCH 006/426] feat: cron sessionMessage and tool display --- scripts/dev-api.js | 16 +-- src-tauri/src/commands/config.rs | 4 +- src-tauri/src/commands/mod.rs | 15 +-- src/lib/local-cron.js | 172 ------------------------------- src/main.js | 1 - src/pages/chat.js | 101 ++++++++++++++---- src/pages/cron.js | 93 ++++++++++++++--- src/style/chat.css | 46 +++++++++ 8 files changed, 213 insertions(+), 235 deletions(-) delete mode 100644 src/lib/local-cron.js diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 0409194e..98ac6b34 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -16,7 +16,7 @@ const DOCKER_TASK_TIMEOUT_MS = 10 * 60 * 1000 const __dev_dirname = path.dirname(fileURLToPath(import.meta.url)) const OPENCLAW_DIR = path.join(homedir(), '.openclaw') -const CONFIG_PATH_DEFAULT = path.join(OPENCLAW_DIR, 'config.json') +const CONFIG_PATH_DEFAULT = path.join(OPENCLAW_DIR, 'openclaw.json') const CONFIG_PATH_LEGACY = path.join(OPENCLAW_DIR, 'openclaw.json') function resolveConfigPath() { if (fs.existsSync(CONFIG_PATH_DEFAULT)) return CONFIG_PATH_DEFAULT @@ -2256,9 +2256,9 @@ const handlers = { } const configB64 = b64(JSON.stringify(syncConfig, null, 2)) - await cExec(`mkdir -p /root/.openclaw && echo '${configB64}' | base64 -d > /root/.openclaw/config.json`) + await cExec(`mkdir -p /root/.openclaw && echo '${configB64}' | base64 -d > /root/.openclaw/openclaw.json`) results.config = true - results.files.push('config.json') + results.files.push('openclaw.json') console.log(`[init-worker] 配置已同步 → ${containerId.slice(0, 12)}`) } } catch (e) { @@ -3025,7 +3025,7 @@ const handlers = { if (!fs.existsSync(OPENCLAW_DIR)) fs.mkdirSync(OPENCLAW_DIR, { recursive: true }) const lastTouchedVersion = recommendedVersionFor('chinese') || '2026.1.1' const defaultConfig = { - "$schema": "https://openclaw.ai/schema/config.json", + "$schema": "https://openclaw.ai/schema/openclaw.json", meta: { lastTouchedVersion }, models: { providers: {} }, gateway: { @@ -3979,11 +3979,3 @@ export function devApiPlugin() { }, } } -iddlewares.use(_apiMiddleware) - }, - } -} -dlewares.use(_apiMiddleware) - }, - } -} diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index f8f1bb16..cedb14c5 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -355,7 +355,7 @@ pub async fn do_reload_gateway(app: &tauri::AppHandle) -> Result pub fn write_openclaw_config(config: Value) -> Result<(), String> { let path = super::openclaw_config_path(); // 备份 - let bak = super::openclaw_dir().join("config.json.bak"); + let bak = super::openclaw_dir().join("openclaw.json.bak"); let _ = fs::copy(&path, &bak); // 清理 UI 专属字段,避免 CLI schema 校验失败 let cleaned = strip_ui_fields(config.clone()); @@ -1287,7 +1287,7 @@ pub fn init_openclaw_config() -> Result { let last_touched_version = recommended_version_for("chinese").unwrap_or_else(|| "2026.1.1".to_string()); let default_config = serde_json::json!({ - "$schema": "https://openclaw.ai/schema/config.json", + "$schema": "https://openclaw.ai/schema/openclaw.json", "meta": { "lastTouchedVersion": last_touched_version }, "models": { "providers": {} }, "gateway": { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index a583c8fb..0a08ff90 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -22,20 +22,9 @@ pub fn openclaw_dir() -> PathBuf { dirs::home_dir().unwrap_or_default().join(".openclaw") } -/// 获取 OpenClaw 配置文件路径 -/// 优先 config.json,若不存在则回退 openclaw.json +/// 获取 OpenClaw 配置文件路径(仅使用 openclaw.json) pub fn openclaw_config_path() -> PathBuf { - let dir = openclaw_dir(); - let config_json = dir.join("config.json"); - if config_json.exists() { - return config_json; - } - let legacy = dir.join("openclaw.json"); - if legacy.exists() { - return legacy; - } - // 默认使用 config.json - config_json + openclaw_dir().join("openclaw.json") } fn panel_config_path() -> PathBuf { diff --git a/src/lib/local-cron.js b/src/lib/local-cron.js deleted file mode 100644 index 63f38d0c..00000000 --- a/src/lib/local-cron.js +++ /dev/null @@ -1,172 +0,0 @@ -/** - * 本地定时任务调度器(ClawPanel 内部) - * 仅用于发送 user 消息到指定 sessionKey - */ -import { api } from './tauri-api.js' -import { wsClient } from './ws-client.js' - -let _started = false -let _timer = null -let _busySessions = new Map() -let _pendingBySession = new Map() - -function ensureCronConfig(cfg) { - if (!cfg.cronLocal || typeof cfg.cronLocal !== 'object') { - cfg.cronLocal = { jobs: [] } - } - if (!Array.isArray(cfg.cronLocal.jobs)) cfg.cronLocal.jobs = [] - return cfg -} - -export async function loadLocalCronJobs() { - const cfg = await api.readPanelConfig() - ensureCronConfig(cfg) - return cfg.cronLocal.jobs -} - -export async function saveLocalCronJobs(jobs) { - const cfg = await api.readPanelConfig() - ensureCronConfig(cfg) - cfg.cronLocal.jobs = jobs - await api.writePanelConfig(cfg) -} - -export async function addLocalCronJob(job) { - const jobs = await loadLocalCronJobs() - jobs.push(job) - await saveLocalCronJobs(jobs) -} - -export async function updateLocalCronJob(id, patch) { - const jobs = await loadLocalCronJobs() - const idx = jobs.findIndex(j => j.id === id) - if (idx >= 0) { - jobs[idx] = { ...jobs[idx], ...patch } - await saveLocalCronJobs(jobs) - } -} - -export async function removeLocalCronJob(id) { - const jobs = await loadLocalCronJobs() - const next = jobs.filter(j => j.id !== id) - await saveLocalCronJobs(next) -} - -function parseCronField(field, min, max, current) { - if (field === '*') return true - if (field.includes('/')) { - const [base, stepStr] = field.split('/') - const step = Number(stepStr) - if (!step || step <= 0) return false - const start = base === '*' ? min : Number(base) - if (Number.isNaN(start)) return false - return (current - start) % step === 0 - } - const num = Number(field) - if (Number.isNaN(num)) return false - return current === num -} - -function cronMatch(expr, date) { - const parts = String(expr || '').trim().split(' ') - if (parts.length !== 5) return false - const [min, hr, dom, mon, dow] = parts - const m = date.getMinutes() - const h = date.getHours() - const d = date.getDate() - const mo = date.getMonth() + 1 - const w = date.getDay() - return ( - parseCronField(min, 0, 59, m) && - parseCronField(hr, 0, 23, h) && - parseCronField(dom, 1, 31, d) && - parseCronField(mon, 1, 12, mo) && - parseCronField(dow, 0, 6, w) - ) -} - -function minuteKey(date) { - const pad = n => String(n).padStart(2, '0') - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` -} - -async function sendUserMessage(sessionKey, message) { - if (!wsClient || !wsClient.gatewayReady) throw new Error('Gateway 未连接') - await wsClient.chatSend(sessionKey, message) -} - -async function flushPending(sessionKey) { - const queue = _pendingBySession.get(sessionKey) || [] - if (!queue.length) return - _pendingBySession.set(sessionKey, []) - for (const item of queue) { - try { - await sendUserMessage(item.sessionKey, item.message) - await updateLocalCronJob(item.jobId, { lastRunAtMs: Date.now(), lastStatus: 'success' }) - } catch (e) { - await updateLocalCronJob(item.jobId, { lastRunAtMs: Date.now(), lastStatus: 'error', lastError: String(e) }) - } - } -} - -function markBusy(sessionKey, busy) { - if (!sessionKey) return - _busySessions.set(sessionKey, !!busy) - if (!busy) flushPending(sessionKey) -} - -export function startLocalCronScheduler() { - if (_started) return - _started = true - - wsClient.onEvent((msg) => { - if (msg?.event !== 'chat') return - const payload = msg.payload || {} - const key = payload.sessionKey - const state = payload.state - if (!key || !state) return - if (state === 'delta') markBusy(key, true) - if (state === 'final') markBusy(key, false) - }) - - _timer = setInterval(async () => { - try { - const jobs = await loadLocalCronJobs() - if (!jobs.length) return - const now = new Date() - const key = minuteKey(now) - for (const job of jobs) { - if (job.enabled === false) continue - if (job.scheduleKind !== 'cron') continue - if (!cronMatch(job.scheduleExpr, now)) continue - if (job.lastRunKey === key) continue - job.lastRunKey = key - const sessionKey = job.sessionKey - if (!sessionKey || !job.message) continue - const busy = _busySessions.get(sessionKey) - if (busy) { - const queue = _pendingBySession.get(sessionKey) || [] - queue.push({ jobId: job.id, sessionKey, message: job.message }) - _pendingBySession.set(sessionKey, queue) - } else { - try { - await sendUserMessage(sessionKey, job.message) - job.lastRunAtMs = Date.now() - job.lastStatus = 'success' - } catch (e) { - job.lastRunAtMs = Date.now() - job.lastStatus = 'error' - job.lastError = String(e) - } - } - } - await saveLocalCronJobs(jobs) - } catch {} - }, 60000) -} - -export function stopLocalCronScheduler() { - if (_timer) clearInterval(_timer) - _timer = null - _started = false -} diff --git a/src/main.js b/src/main.js index aad59268..5c3d181b 100644 --- a/src/main.js +++ b/src/main.js @@ -5,7 +5,6 @@ import { registerRoute, initRouter, navigate, setDefaultRoute } from './router.j import { renderSidebar, openMobileSidebar } from './components/sidebar.js' import { initTheme } from './lib/theme.js' import { detectOpenclawStatus, isOpenclawReady, isUpgrading, isGatewayRunning, onGatewayChange, startGatewayPoll, onGuardianGiveUp, resetAutoRestart, loadActiveInstance, getActiveInstance, onInstanceChange } from './lib/app-state.js' -import { startLocalCronScheduler } from './lib/local-cron.js' import { wsClient } from './lib/ws-client.js' import { api, checkBackendHealth, isBackendOnline, onBackendStatusChange } from './lib/tauri-api.js' import { version as APP_VERSION } from '../package.json' diff --git a/src/pages/chat.js b/src/pages/chat.js index e9d3c6bf..988b52cd 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -964,6 +964,7 @@ function handleChatEvent(payload) { const finalVideos = c?.videos || [] const finalAudios = c?.audios || [] const finalFiles = c?.files || [] + const finalTools = c?.tools || [] if (finalImages.length) _currentAiImages = finalImages if (finalVideos.length) _currentAiVideos = finalVideos if (finalAudios.length) _currentAiAudios = finalAudios @@ -991,6 +992,7 @@ function handleChatEvent(payload) { appendVideosToEl(_currentAiBubble, _currentAiVideos) appendAudiosToEl(_currentAiBubble, _currentAiAudios) appendFilesToEl(_currentAiBubble, _currentAiFiles) + appendToolsToEl(_currentAiBubble, finalTools) } // 添加时间戳 + 耗时 + token 消耗 const wrapper = _currentAiBubble?.parentElement @@ -1005,7 +1007,7 @@ function handleChatEvent(payload) { } else if (_streamStartTime) { durStr = ((Date.now() - _streamStartTime) / 1000).toFixed(1) + 's' } - if (durStr) parts.push(`·⏱ ${durStr}`) + if (durStr) parts.push(`·耗时 ${durStr}`) // token 消耗(从 payload.usage 或 payload.message.usage 提取) const usage = payload.usage || payload.message?.usage || null if (usage) { @@ -1086,9 +1088,9 @@ function handleChatEvent(payload) { function extractChatContent(message) { if (!message || typeof message !== 'object') return null const content = message.content - if (typeof content === 'string') return { text: stripThinkingTags(content), images: [], videos: [], audios: [], files: [] } + if (typeof content === 'string') return { text: stripThinkingTags(content), images: [], videos: [], audios: [], files: [], tools: [] } if (Array.isArray(content)) { - const texts = [], images = [], videos = [], audios = [], files = [] + const texts = [], images = [], videos = [], audios = [], files = [], tools = [] for (const block of content) { if (block.type === 'text' && typeof block.text === 'string') texts.push(block.text) else if (block.type === 'image' && !block.omitted) { @@ -1108,6 +1110,22 @@ function extractChatContent(message) { else if (block.type === 'file' || block.type === 'document') { files.push({ url: block.url || '', name: block.fileName || block.name || '文件', mimeType: block.mimeType || '', size: block.size, data: block.data }) } + else if (block.type === 'tool' || block.type === 'tool_use' || block.type === 'tool_call') { + tools.push({ + name: block.name || block.tool || block.tool_name || '工具', + input: block.input || block.args || block.parameters || null, + output: null, + status: block.status || 'ok', + }) + } + else if (block.type === 'tool_result' || block.type === 'toolResult') { + tools.push({ + name: block.name || block.tool || block.tool_name || '工具', + input: block.input || block.args || null, + output: block.output || block.result || block.content || null, + status: block.status || 'ok', + }) + } } // 从 mediaUrl/mediaUrls 提取 const mediaUrls = message.mediaUrls || (message.mediaUrl ? [message.mediaUrl] : []) @@ -1119,9 +1137,9 @@ function extractChatContent(message) { else files.push({ url, name: url.split('/').pop().split('?')[0] || '文件', mimeType: '' }) } const text = texts.length ? stripThinkingTags(texts.join('\n')) : '' - return { text, images, videos, audios, files } + return { text, images, videos, audios, files, tools } } - if (typeof message.text === 'string') return { text: stripThinkingTags(message.text), images: [], videos: [], audios: [], files: [] } + if (typeof message.text === 'string') return { text: stripThinkingTags(message.text), images: [], videos: [], audios: [], files: [], tools: [] } return null } @@ -1229,7 +1247,7 @@ async function loadHistory() { if (msg.role === 'user') appendUserMessage(msg.content || '', msg.attachments || null, msgTime) else if (msg.role === 'assistant') { const images = (msg.attachments || []).filter(a => a.category === 'image').map(a => ({ mediaType: a.mimeType, data: a.content, url: a.url })) - appendAiMessage(msg.content || '', msgTime, images) + appendAiMessage(msg.content || '', msgTime, images, [], [], [], []) } }) scrollToBottom() @@ -1251,7 +1269,8 @@ async function loadHistory() { if (hasExisting && (_isSending || _isStreaming || _messageQueue.length > 0)) { saveMessages(result.messages.map(m => { const c = extractContent(m) - return { id: m.id || uuid(), sessionKey: _sessionKey, role: m.role, content: c?.text || '', timestamp: m.timestamp || Date.now() } + const role = (m.role === 'tool' || m.role === 'toolResult') ? 'assistant' : m.role + return { id: m.id || uuid(), sessionKey: _sessionKey, role, content: c?.text || '', timestamp: m.timestamp || Date.now() } })) return } @@ -1259,7 +1278,7 @@ async function loadHistory() { clearMessages() let hasOmittedImages = false deduped.forEach(msg => { - if (!msg.text && !msg.images?.length && !msg.videos?.length && !msg.audios?.length && !msg.files?.length) return + if (!msg.text && !msg.images?.length && !msg.videos?.length && !msg.audios?.length && !msg.files?.length && !msg.tools?.length) return const msgTime = msg.timestamp ? new Date(msg.timestamp) : new Date() if (msg.role === 'user') { const userAtts = msg.images?.length ? msg.images.map(i => ({ @@ -1270,7 +1289,7 @@ async function loadHistory() { if (msg.images?.length && !userAtts.length) hasOmittedImages = true appendUserMessage(msg.text, userAtts, msgTime) } else if (msg.role === 'assistant') { - appendAiMessage(msg.text, msgTime, msg.images, msg.videos, msg.audios, msg.files) + appendAiMessage(msg.text, msgTime, msg.images, msg.videos, msg.audios, msg.files, msg.tools) } }) if (hasOmittedImages) { @@ -1278,7 +1297,8 @@ async function loadHistory() { } saveMessages(result.messages.map(m => { const c = extractContent(m) - return { id: m.id || uuid(), sessionKey: _sessionKey, role: m.role, content: c?.text || '', timestamp: m.timestamp || Date.now() } + const role = (m.role === 'tool' || m.role === 'toolResult') ? 'assistant' : m.role + return { id: m.id || uuid(), sessionKey: _sessionKey, role, content: c?.text || '', timestamp: m.timestamp || Date.now() } })) scrollToBottom() } catch (e) { @@ -1290,13 +1310,13 @@ async function loadHistory() { function dedupeHistory(messages) { const deduped = [] for (const msg of messages) { - if (msg.role === 'toolResult') continue + const role = (msg.role === 'tool' || msg.role === 'toolResult') ? 'assistant' : msg.role const c = extractContent(msg) - if (!c.text && !c.images.length && !c.videos.length && !c.audios.length && !c.files.length) continue + if (!c.text && !c.images.length && !c.videos.length && !c.audios.length && !c.files.length && !c.tools.length) continue const last = deduped[deduped.length - 1] - if (last && last.role === msg.role) { - if (msg.role === 'user' && last.text === c.text) continue - if (msg.role === 'assistant') { + if (last && last.role === role) { + if (role === 'user' && last.text === c.text) continue + if (role === 'assistant') { // 同文本去重(Gateway 重试产生的重复回复) if (c.text && last.text === c.text) continue // 不同文本则合并 @@ -1305,17 +1325,18 @@ function dedupeHistory(messages) { last.videos = [...(last.videos || []), ...c.videos] last.audios = [...(last.audios || []), ...c.audios] last.files = [...(last.files || []), ...c.files] + last.tools = [...(last.tools || []), ...(c.tools || [])] continue } } - deduped.push({ role: msg.role, text: c.text, images: c.images, videos: c.videos, audios: c.audios, files: c.files, timestamp: msg.timestamp }) + deduped.push({ role, text: c.text, images: c.images, videos: c.videos, audios: c.audios, files: c.files, tools: c.tools || [], timestamp: msg.timestamp }) } return deduped } function extractContent(msg) { if (Array.isArray(msg.content)) { - const texts = [], images = [], videos = [], audios = [], files = [] + const texts = [], images = [], videos = [], audios = [], files = [], tools = [] for (const block of msg.content) { if (block.type === 'text' && typeof block.text === 'string') texts.push(block.text) else if (block.type === 'image' && !block.omitted) { @@ -1335,6 +1356,22 @@ function extractContent(msg) { else if (block.type === 'file' || block.type === 'document') { files.push({ url: block.url || '', name: block.fileName || block.name || '文件', mimeType: block.mimeType || '', size: block.size, data: block.data }) } + else if (block.type === 'tool' || block.type === 'tool_use' || block.type === 'tool_call') { + tools.push({ + name: block.name || block.tool || block.tool_name || '工具', + input: block.input || block.args || block.parameters || null, + output: null, + status: block.status || 'ok', + }) + } + else if (block.type === 'tool_result' || block.type === 'toolResult') { + tools.push({ + name: block.name || block.tool || block.tool_name || '工具', + input: block.input || block.args || null, + output: block.output || block.result || block.content || null, + status: block.status || 'ok', + }) + } } const mediaUrls = msg.mediaUrls || (msg.mediaUrl ? [msg.mediaUrl] : []) for (const url of mediaUrls) { @@ -1344,10 +1381,10 @@ function extractContent(msg) { else if (/\.(jpe?g|png|gif|webp|heic|svg)(\?|$)/i.test(url)) images.push({ url, mediaType: 'image/png' }) else files.push({ url, name: url.split('/').pop().split('?')[0] || '文件', mimeType: '' }) } - return { text: stripThinkingTags(texts.join('\n')), images, videos, audios, files } + return { text: stripThinkingTags(texts.join('\n')), images, videos, audios, files, tools } } const text = typeof msg.text === 'string' ? msg.text : (typeof msg.content === 'string' ? msg.content : '') - return { text: stripThinkingTags(text), images: [], videos: [], audios: [], files: [] } + return { text: stripThinkingTags(text), images: [], videos: [], audios: [], files: [], tools: [] } } // ── DOM 操作 ── @@ -1413,7 +1450,7 @@ function appendUserMessage(text, attachments = [], msgTime) { scrollToBottom() } -function appendAiMessage(text, msgTime, images, videos, audios, files) { +function appendAiMessage(text, msgTime, images, videos, audios, files, tools) { const wrap = document.createElement('div') wrap.className = 'msg msg-ai' const bubble = document.createElement('div') @@ -1423,6 +1460,7 @@ function appendAiMessage(text, msgTime, images, videos, audios, files) { appendVideosToEl(bubble, videos) appendAudiosToEl(bubble, audios) appendFilesToEl(bubble, files) + appendToolsToEl(bubble, tools) // 图片点击灯箱 bubble.querySelectorAll('img').forEach(img => { if (!img.onclick) img.onclick = () => showLightbox(img.src) }) @@ -1521,6 +1559,29 @@ function appendFilesToEl(el, files) { }) } +/** 渲染工具调用到消息气泡 */ +function appendToolsToEl(el, tools) { + if (!tools?.length) return + const container = document.createElement('div') + container.className = 'msg-tool' + tools.forEach(tool => { + const details = document.createElement('details') + details.className = 'msg-tool-item' + const summary = document.createElement('summary') + const status = tool.status === 'error' ? '失败' : '成功' + summary.innerHTML = `${escapeHtml(tool.name || '工具')} · ${status}` + const body = document.createElement('div') + body.className = 'msg-tool-body' + const input = tool.input ? `
参数
${escapeHtml(JSON.stringify(tool.input, null, 2))}
` : '' + const output = tool.output ? `
结果
${escapeHtml(JSON.stringify(tool.output, null, 2))}
` : '' + body.innerHTML = input + output + details.appendChild(summary) + details.appendChild(body) + container.appendChild(details) + }) + el.appendChild(container) +} + /** 图片灯箱查看 */ function showLightbox(src) { const existing = document.querySelector('.chat-lightbox') diff --git a/src/pages/cron.js b/src/pages/cron.js index b5684dfe..2855d0bb 100644 --- a/src/pages/cron.js +++ b/src/pages/cron.js @@ -24,6 +24,18 @@ const CRON_SHORTCUTS = [ { expr: '0 9 1 * *', text: '每月 1 号 9:00' }, ] +const SESSION_MESSAGE_TEXT = '继续执行' + +function parseSessionLabel(key) { + const parts = (key || '').split(':') + if (parts.length < 3) return key || '未知' + const agent = parts[1] || 'main' + const channel = parts.slice(2).join(':') + if (agent === 'main' && channel === 'main') return '主会话' + if (agent === 'main') return channel + return `${agent} / ${channel}` +} + // ── 页面生命周期 ── export async function render() { @@ -126,6 +138,7 @@ async function fetchJobs(page, state) { description: j.description || '', message: j.payload?.message || j.payload?.text || '', payloadKind: j.payload?.kind || 'agentTurn', + sessionLabel: j.payload?.label || '', schedule: j.schedule || {}, enabled: j.enabled !== false, agentId: j.agentId || null, @@ -215,10 +228,12 @@ function renderList(page, state) { ${lastRunHtml}
- ${icon('clock', 12)} ${scheduleText}${job.agentId ? ` · Agent: ${escapeHtml(job.agentId)}` : ''} + ${icon('clock', 12)} ${scheduleText}${job.payloadKind === 'sessionMessage' + ? ` · 目标: ${escapeHtml(job.sessionLabel || '未指定')}` + : (job.agentId ? ` · Agent: ${escapeHtml(job.agentId)}` : '')}
- ${escapeHtml(job.message)} + ${escapeHtml(job.payloadKind === 'sessionMessage' ? SESSION_MESSAGE_TEXT : job.message)}
${job.lastRunStatus === 'error' && job.lastError ? `
@@ -307,15 +322,27 @@ async function openTaskDialog(job, page, state) {
+ + +
+
+ + +
仅发送 user 消息,不附带系统注入
+
+
-
+
不选则使用默认 Agent 执行
-
+
配置了多个消息渠道时必须指定,否则任务会报错
@@ -370,6 +397,19 @@ async function openTaskDialog(job, page, state) { ).join('') }).catch(() => {}) + // 异步加载会话列表 + wsClient.sessionsList(50).then(res => { + const list = res?.sessions || res || [] + const select = modal.querySelector('select[name="sessionLabel"]') + if (!select) return + const current = job?.sessionLabel || '' + select.innerHTML = `` + list.map(s => { + const key = s.sessionKey || s.key || '' + const label = parseSessionLabel(key) + return `` + }).join('') + }).catch(() => {}) + // 快捷预设按钮 modal.querySelectorAll('.cron-shortcut').forEach(btn => { btn.onclick = () => { @@ -400,16 +440,31 @@ async function openTaskDialog(job, page, state) { }) } + toggleFields() + + const toggleFields = () => { + const kind = modal.querySelector('select[name="taskKind"]').value + const showSession = kind === 'sessionMessage' + modal.querySelector('[data-field="sessionLabel"]').style.display = showSession ? 'block' : 'none' + modal.querySelector('[data-field="message"]').style.display = showSession ? 'none' : 'block' + modal.querySelector('[data-field="agent"]').style.display = showSession ? 'none' : 'block' + modal.querySelector('[data-field="delivery"]').style.display = showSession ? 'none' : 'block' + } + modal.querySelector('select[name="taskKind"]').onchange = toggleFields + // 保存 modal.querySelector('#btn-cron-save').onclick = async () => { const name = modal.querySelector('input[name="name"]').value.trim() + const taskKind = modal.querySelector('select[name="taskKind"]').value const message = modal.querySelector('textarea[name="message"]').value.trim() const schedule = modal.querySelector('input[name="schedule"]').value.trim() const agentId = modal.querySelector('select[name="agentId"]').value || undefined const enabled = modal.querySelector('input[name="enabled"]').checked + const sessionLabel = modal.querySelector('select[name="sessionLabel"]').value if (!name) { toast('请输入任务名称', 'warning'); return } - if (!message) { toast('请输入执行指令', 'warning'); return } + if (taskKind !== 'sessionMessage' && !message) { toast('请输入执行指令', 'warning'); return } + if (taskKind === 'sessionMessage' && !sessionLabel) { toast('请选择会话', 'warning'); return } if (!schedule) { toast('请设置执行周期', 'warning'); return } const saveBtn = modal.querySelector('#btn-cron-save') @@ -420,11 +475,15 @@ async function openTaskDialog(job, page, state) { if (isEdit) { const patch = { name, enabled } patch.schedule = { kind: 'cron', expr: schedule } - patch.payload = { kind: 'agentTurn', message } - if (agentId) patch.agentId = agentId - const deliveryChannel = modal.querySelector('select[name="deliveryChannel"]')?.value - if (deliveryChannel) { - patch.delivery = { mode: 'push', to: deliveryChannel, channel: deliveryChannel } + if (taskKind === 'sessionMessage') { + patch.payload = { kind: 'sessionMessage', label: sessionLabel, message: SESSION_MESSAGE_TEXT, role: 'user', waitForIdle: true } + } else { + patch.payload = { kind: 'agentTurn', message } + if (agentId) patch.agentId = agentId + const deliveryChannel = modal.querySelector('select[name="deliveryChannel"]')?.value + if (deliveryChannel) { + patch.delivery = { mode: 'push', to: deliveryChannel, channel: deliveryChannel } + } } await wsClient.request('cron.update', { id: job.id, patch }) toast('任务已更新', 'success') @@ -433,12 +492,16 @@ async function openTaskDialog(job, page, state) { name, enabled, schedule: { kind: 'cron', expr: schedule }, - payload: { kind: 'agentTurn', message }, } - if (agentId) params.agentId = agentId - const deliveryChannel = modal.querySelector('select[name="deliveryChannel"]')?.value - if (deliveryChannel) { - params.delivery = { mode: 'push', to: deliveryChannel, channel: deliveryChannel } + if (taskKind === 'sessionMessage') { + params.payload = { kind: 'sessionMessage', label: sessionLabel, message: SESSION_MESSAGE_TEXT, role: 'user', waitForIdle: true } + } else { + params.payload = { kind: 'agentTurn', message } + if (agentId) params.agentId = agentId + const deliveryChannel = modal.querySelector('select[name="deliveryChannel"]')?.value + if (deliveryChannel) { + params.delivery = { mode: 'push', to: deliveryChannel, channel: deliveryChannel } + } } await wsClient.request('cron.add', params) toast('任务已创建', 'success') diff --git a/src/style/chat.css b/src/style/chat.css index 65d84ca1..198e21da 100644 --- a/src/style/chat.css +++ b/src/style/chat.css @@ -848,6 +848,52 @@ color: var(--text-tertiary); } +/* 工具调用 */ +.msg-tool { + margin-top: 10px; + display: flex; + flex-direction: column; + gap: 8px; +} +.msg-tool-item { + border: 1px solid var(--border-primary); + background: var(--bg-tertiary); + border-radius: var(--radius-md); + padding: 8px 10px; +} +.msg-tool-item > summary { + cursor: pointer; + font-size: 12px; + color: var(--text-secondary); + list-style: none; +} +.msg-tool-item > summary::-webkit-details-marker { + display: none; +} +.msg-tool-body { + margin-top: 8px; + display: grid; + gap: 8px; +} +.msg-tool-block { + background: var(--bg-primary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); + padding: 8px 10px; +} +.msg-tool-title { + font-size: 11px; + color: var(--text-tertiary); + margin-bottom: 6px; +} +.msg-tool-block pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font-size: 11px; + color: var(--text-primary); +} + /* 首次引导提示 */ .chat-page-guide { margin: 0 16px 8px; From a9d0167b6ac53ef6438e8b680a424709d66c7efa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 12:46:45 +0800 Subject: [PATCH 007/426] feat: set sessionTarget for sessionMessage cron --- src/pages/cron.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/cron.js b/src/pages/cron.js index 2855d0bb..970f8785 100644 --- a/src/pages/cron.js +++ b/src/pages/cron.js @@ -476,6 +476,7 @@ async function openTaskDialog(job, page, state) { const patch = { name, enabled } patch.schedule = { kind: 'cron', expr: schedule } if (taskKind === 'sessionMessage') { + patch.sessionTarget = 'main' patch.payload = { kind: 'sessionMessage', label: sessionLabel, message: SESSION_MESSAGE_TEXT, role: 'user', waitForIdle: true } } else { patch.payload = { kind: 'agentTurn', message } @@ -494,6 +495,7 @@ async function openTaskDialog(job, page, state) { schedule: { kind: 'cron', expr: schedule }, } if (taskKind === 'sessionMessage') { + params.sessionTarget = 'main' params.payload = { kind: 'sessionMessage', label: sessionLabel, message: SESSION_MESSAGE_TEXT, role: 'user', waitForIdle: true } } else { params.payload = { kind: 'agentTurn', message } From a37e2386760ac9a35ae843d2d551cffe2e06cfcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 12:57:30 +0800 Subject: [PATCH 008/426] docs: gateway patch one-click design --- ...026-03-16-gateway-patch-oneclick-design.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/plans/2026-03-16-gateway-patch-oneclick-design.md diff --git a/docs/plans/2026-03-16-gateway-patch-oneclick-design.md b/docs/plans/2026-03-16-gateway-patch-oneclick-design.md new file mode 100644 index 00000000..e265deed --- /dev/null +++ b/docs/plans/2026-03-16-gateway-patch-oneclick-design.md @@ -0,0 +1,37 @@ +# Gateway 一键补丁设计 + +日期:2026-03-16 + +## 目标 +- 在设置页提供一键补丁入口,自动对全局 npm 安装的 OpenClaw 打补丁 +- 支持检测版本、应用补丁、重打补丁、回滚 + +## 入口与交互 +- 位置:设置页「公网访问」区域旁新增“Gateway 补丁”卡片 +- 按钮:一键补丁、重打补丁、回滚 +- 状态:展示检测到的 OpenClaw 版本、补丁状态、最近操作结果 + +## 实现流程 +1. 定位全局 npm 根目录:`npm root -g` +2. 在 `node_modules` 内查找 `openclaw` 包 +3. 自动识别目标文件(reply-*.js / gateway-cli-*.js) +4. 备份文件(.bak) +5. 应用补丁(sessionMessage 支持) +6. 写入 `clawpanel.json` 记录补丁版本与 OpenClaw 版本 +7. 版本变更时提示并支持重打补丁 +8. 失败自动回滚 + +## 数据与状态 +- `clawpanel.json` 新增: + - `gatewayPatch`: { version, patchedAt, openclawVersion, files: [] } + +## 错误处理 +- 未找到 npm 根目录或包:提示错误 +- 文件名不匹配:提示错误并终止 +- 打补丁失败:回滚并记录错误 + +## 测试要点 +- 正常补丁流程 +- 回滚流程 +- 版本变化后重打补丁 +- 错误路径处理 From e044e5f1b83c1b47e0bee0d22398e0d9a50ae3e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 13:01:58 +0800 Subject: [PATCH 009/426] docs: gateway patch implementation plan --- .../2026-03-16-gateway-patch-oneclick.md | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-16-gateway-patch-oneclick.md diff --git a/docs/superpowers/plans/2026-03-16-gateway-patch-oneclick.md b/docs/superpowers/plans/2026-03-16-gateway-patch-oneclick.md new file mode 100644 index 00000000..7c31cfb4 --- /dev/null +++ b/docs/superpowers/plans/2026-03-16-gateway-patch-oneclick.md @@ -0,0 +1,169 @@ +# Gateway One-Click Patch Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a one-click gateway patch flow in ClawPanel settings to apply/rollback sessionMessage patch for global npm OpenClaw installs. + +**Architecture:** Add a Tauri command that locates global npm OpenClaw, backs up dist files, applies patch, records status in clawpanel.json, and exposes status to the UI. Settings UI uses tauri-api bridge to render current patch status and provide apply/redo/rollback actions. + +**Tech Stack:** Tauri (Rust), Vite/JS, ClawPanel config (clawpanel.json) + +--- + +## Chunk 1: Backend patch command + config persistence + +### Task 1: Add gateway patch command + +**Files:** +- Create: `src-tauri/src/commands/gateway_patch.rs` +- Modify: `src-tauri/src/commands/mod.rs` +- Modify: `src-tauri/src/lib.rs` + +- [ ] **Step 1: Create gateway_patch.rs skeleton** + +```rust +use serde::{Deserialize, Serialize}; +use tauri::command; + +#[derive(Serialize, Deserialize)] +pub struct GatewayPatchStatus { + pub installed_version: Option, + pub patched: bool, + pub patched_version: Option, + pub patched_at: Option, + pub files: Vec, + pub last_error: Option, +} + +#[command] +pub async fn gateway_patch_status() -> Result { + Ok(GatewayPatchStatus { + installed_version: None, + patched: false, + patched_version: None, + patched_at: None, + files: vec![], + last_error: None, + }) +} + +#[command] +pub async fn gateway_patch_apply() -> Result { + gateway_patch_status().await +} + +#[command] +pub async fn gateway_patch_rollback() -> Result { + gateway_patch_status().await +} +``` + +- [ ] **Step 2: Register commands** + +Update `src-tauri/src/commands/mod.rs` to export the new functions, and `src-tauri/src/lib.rs` to include them in `invoke_handler`. + +- [ ] **Step 3: Locate global npm root** + +Implement helper in `gateway_patch.rs`: +- Run `npm root -g` +- Join `openclaw/dist` +- Detect `reply-*.js` and `gateway-cli-*.js` (choose latest by modified time) +- Return errors if not found + +- [ ] **Step 4: Apply patch with backup** + +Implement: +- Copy `reply-*.js` -> `.bak` +- Copy `gateway-cli-*.js` -> `.bak` +- Apply string-replace patches (same patterns used in manual patch) +- Validate file content contains `sessionMessage` post patch + +- [ ] **Step 5: Persist status in clawpanel.json** + +Use existing panel config read/write to store: + +```json +"gatewayPatch": { + "version": "sessionMessage-v1", + "patchedAt": "", + "openclawVersion": "", + "files": ["reply-*.js", "gateway-cli-*.js"], + "lastError": null +} +``` + +- [ ] **Step 6: Implement rollback** + +Restore from `.bak` files and update status. + +- [ ] **Step 7: Manual verification** + +Run: +``` +openclaw gateway status +``` +Expect: Gateway runs normally. + +- [ ] **Step 8: Commit** + +``` +git add src-tauri/src/commands/gateway_patch.rs src-tauri/src/commands/mod.rs src-tauri/src/lib.rs + +git commit -m "feat: add gateway patch commands" +``` + +## Chunk 2: UI + API bridge + +### Task 2: Add API bridge + +**Files:** +- Modify: `src/lib/tauri-api.js` + +- [ ] **Step 1: Add methods** + +```js +export const api = { + // ... + gatewayPatchStatus: () => invoke('gateway_patch_status'), + gatewayPatchApply: () => invoke('gateway_patch_apply'), + gatewayPatchRollback: () => invoke('gateway_patch_rollback') +} +``` + +- [ ] **Step 2: Commit** + +``` +git add src/lib/tauri-api.js + +git commit -m "feat: add gateway patch api bridge" +``` + +### Task 3: Settings UI + +**Files:** +- Modify: `src/pages/settings.js` (or `src/pages/settings-cloudflared.js` if the setting block lives there) +- Modify: `src/style/settings.css` (if needed) + +- [ ] **Step 1: Add Gateway 补丁卡片** +- Add status area: installed version, patched state, patched time +- Buttons: 一键补丁 / 重打补丁 / 回滚 + +- [ ] **Step 2: Wire buttons** +- Call `api.gatewayPatchApply()` / `api.gatewayPatchRollback()` +- Refresh status via `api.gatewayPatchStatus()` + +- [ ] **Step 3: Manual verification** +- Open settings page, ensure card renders +- Apply patch and verify status changes + +- [ ] **Step 4: Commit** + +``` +git add src/pages/settings.js src/style/settings.css + +git commit -m "feat: add gateway patch ui" +``` + +--- + +Plan complete and saved to `docs/superpowers/plans/2026-03-16-gateway-patch-oneclick.md`. Ready to execute? From f6aae121c7f1018b554a93263e4b0b1c84aea744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 13:06:32 +0800 Subject: [PATCH 010/426] chore: checkpoint before gateway patch From 2c7500d395766e73045412e0bfe97ebace203a5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 13:17:01 +0800 Subject: [PATCH 011/426] feat: add gateway one-click patch --- scripts/dev-api.js | 5 + src-tauri/src/commands/gateway_patch.rs | 282 ++++++++++++++++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/lib.rs | 8 +- src/lib/tauri-api.js | 5 + src/pages/settings.js | 83 +++++++ 6 files changed, 382 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/commands/gateway_patch.rs diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 98ac6b34..c1525244 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -3557,6 +3557,11 @@ const handlers = { cloudflared_start() { throw new Error('web mode not supported') }, cloudflared_stop() { return { installed: false, running: false, url: null } }, + // Gateway 补丁(Web 模式不支持) + gateway_patch_status() { return { installed_version: null, patched: false, patched_version: null, patched_at: null, files: [], last_error: 'web mode not supported' } }, + gateway_patch_apply() { throw new Error('web mode not supported') }, + gateway_patch_rollback() { throw new Error('web mode not supported') }, + // === Agent 管理(Web 模式) === add_agent({ name, model, workspace }) { diff --git a/src-tauri/src/commands/gateway_patch.rs b/src-tauri/src/commands/gateway_patch.rs new file mode 100644 index 00000000..2542d4d3 --- /dev/null +++ b/src-tauri/src/commands/gateway_patch.rs @@ -0,0 +1,282 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; + +const PATCH_VERSION: &str = "sessionMessage-v1"; + +#[derive(Serialize, Deserialize, Default)] +pub struct GatewayPatchStatus { + pub installed_version: Option, + pub patched: bool, + pub patched_version: Option, + pub patched_at: Option, + pub files: Vec, + pub last_error: Option, +} + +#[tauri::command] +pub async fn gateway_patch_status() -> Result { + let mut status = read_status_from_panel().unwrap_or_default(); + status.installed_version = read_openclaw_version().ok(); + Ok(status) +} + +#[tauri::command] +pub async fn gateway_patch_apply(force: Option) -> Result { + let force_apply = force.unwrap_or(false); + let mut status = read_status_from_panel().unwrap_or_default(); + + let openclaw_version = read_openclaw_version().ok(); + let dist_dir = resolve_openclaw_dist_dir()?; + let reply_path = find_latest_file(&dist_dir, "reply-")?; + let gateway_path = find_latest_file(&dist_dir, "gateway-cli-")?; + + let files = vec![ + reply_path.file_name().unwrap_or_default().to_string_lossy().to_string(), + gateway_path.file_name().unwrap_or_default().to_string_lossy().to_string(), + ]; + + if force_apply { + restore_backup(&reply_path)?; + restore_backup(&gateway_path)?; + } + + let reply_patched = patch_reply_file(&reply_path, false)?; + let gateway_patched = patch_gateway_file(&gateway_path, false)?; + + if !reply_patched && !gateway_patched { + status.patched = true; + status.patched_version = status.patched_version.or(Some(PATCH_VERSION.to_string())); + status.installed_version = openclaw_version; + status.files = files; + write_status_to_panel(&status)?; + return Ok(status); + } + + status.patched = true; + status.patched_version = Some(PATCH_VERSION.to_string()); + status.patched_at = Some(chrono::Local::now().to_rfc3339()); + status.installed_version = openclaw_version; + status.files = files; + status.last_error = None; + write_status_to_panel(&status)?; + Ok(status) +} + +#[tauri::command] +pub async fn gateway_patch_rollback() -> Result { + let mut status = read_status_from_panel().unwrap_or_default(); + let dist_dir = resolve_openclaw_dist_dir()?; + let reply_path = find_latest_file(&dist_dir, "reply-")?; + let gateway_path = find_latest_file(&dist_dir, "gateway-cli-")?; + + restore_backup(&reply_path)?; + restore_backup(&gateway_path)?; + + status.patched = false; + status.patched_version = None; + status.patched_at = None; + status.last_error = None; + write_status_to_panel(&status)?; + Ok(status) +} + +fn openclaw_dir() -> PathBuf { + crate::commands::openclaw_dir() +} + +fn panel_config_path() -> PathBuf { + openclaw_dir().join("clawpanel.json") +} + +fn read_status_from_panel() -> Option { + let path = panel_config_path(); + if !path.exists() { + return None; + } + let content = fs::read_to_string(&path).ok()?; + let value: Value = serde_json::from_str(&content).ok()?; + let entry = value.get("gatewayPatch")?.clone(); + serde_json::from_value(entry).ok() +} + +fn write_status_to_panel(status: &GatewayPatchStatus) -> Result<(), String> { + let path = panel_config_path(); + if let Some(dir) = path.parent() { + if !dir.exists() { + fs::create_dir_all(dir).map_err(|e| format!("创建目录失败: {e}"))?; + } + } + let mut root: Value = if path.exists() { + let content = fs::read_to_string(&path).map_err(|e| format!("读取失败: {e}"))?; + serde_json::from_str(&content).map_err(|e| format!("解析失败: {e}"))? + } else { + json!({}) + }; + if !root.is_object() { + root = json!({}); + } + let entry = serde_json::to_value(status).map_err(|e| format!("序列化失败: {e}"))?; + root["gatewayPatch"] = entry; + let json = serde_json::to_string_pretty(&root).map_err(|e| format!("序列化失败: {e}"))?; + fs::write(&path, json).map_err(|e| format!("写入失败: {e}")) +} + +fn npm_root_global() -> Result { + #[cfg(target_os = "windows")] + const CREATE_NO_WINDOW: u32 = 0x08000000; + + let mut cmd = if cfg!(target_os = "windows") { + let mut c = Command::new("cmd"); + c.args(["/c", "npm", "root", "-g"]); + c + } else { + let mut c = Command::new("npm"); + c.args(["root", "-g"]); + c + }; + cmd.env("PATH", crate::commands::enhanced_path()); + crate::commands::apply_proxy_env(&mut cmd); + #[cfg(target_os = "windows")] + cmd.creation_flags(CREATE_NO_WINDOW); + let output = cmd.output().map_err(|e| format!("npm root -g 执行失败: {e}"))?; + if !output.status.success() { + return Err(format!( + "npm root -g 失败: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + let text = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if text.is_empty() { + return Err("npm root -g 返回空路径".to_string()); + } + Ok(PathBuf::from(text)) +} + +fn resolve_openclaw_dist_dir() -> Result { + let root = npm_root_global()?; + let openclaw_dir = root.join("openclaw"); + if !openclaw_dir.exists() { + return Err("未找到全局 openclaw 安装目录".to_string()); + } + let dist = openclaw_dir.join("dist"); + if !dist.exists() { + return Err("未找到 openclaw dist 目录".to_string()); + } + Ok(dist) +} + +fn read_openclaw_version() -> Result { + let root = npm_root_global()?; + let pkg = root.join("openclaw").join("package.json"); + if !pkg.exists() { + return Err("未找到 openclaw package.json".to_string()); + } + let content = fs::read_to_string(&pkg).map_err(|e| format!("读取失败: {e}"))?; + let value: Value = serde_json::from_str(&content).map_err(|e| format!("解析失败: {e}"))?; + let version = value + .get("version") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if version.is_empty() { + return Err("openclaw 版本为空".to_string()); + } + Ok(version) +} + +fn find_latest_file(dir: &Path, prefix: &str) -> Result { + let mut candidates: Vec<(PathBuf, std::time::SystemTime)> = vec![]; + for entry in fs::read_dir(dir).map_err(|e| format!("读取目录失败: {e}"))? { + let entry = entry.map_err(|e| format!("读取目录项失败: {e}"))?; + let path = entry.path(); + if !path.is_file() { + continue; + } + let name = path.file_name().unwrap_or_default().to_string_lossy().to_string(); + if !name.starts_with(prefix) || !name.ends_with(".js") { + continue; + } + let meta = entry.metadata().map_err(|e| format!("读取文件信息失败: {e}"))?; + let modified = meta.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH); + candidates.push((path, modified)); + } + candidates.sort_by_key(|(_, t)| *t); + candidates + .last() + .map(|(p, _)| p.clone()) + .ok_or_else(|| format!("未找到 {prefix}*.js")) +} + +fn backup_file(path: &Path) -> Result<(), String> { + let backup_path = PathBuf::from(format!("{}.bak", path.display())); + fs::copy(path, &backup_path).map_err(|e| format!("备份失败: {e}"))?; + Ok(()) +} + +fn restore_backup(path: &Path) -> Result<(), String> { + let backup_path = PathBuf::from(format!("{}.bak", path.display())); + if !backup_path.exists() { + return Err("未找到备份文件".to_string()); + } + fs::copy(&backup_path, path).map_err(|e| format!("回滚失败: {e}"))?; + Ok(()) +} + +fn replace_once(hay: &str, needle: &str, replacement: &str) -> Result { + if !hay.contains(needle) { + return Err("未找到补丁位置".to_string()); + } + Ok(hay.replacen(needle, replacement, 1)) +} + +fn patch_reply_file(path: &Path, _force: bool) -> Result { + let content = fs::read_to_string(path).map_err(|e| format!("读取失败: {e}"))?; + if content.contains("kind: Type.Literal(\"sessionMessage\")") { + return Ok(false); + } + let needle_schema = "const CronPayloadSchema = Type.Union([Type.Object({\n\tkind: Type.Literal(\"systemEvent\"),\n\ttext: NonEmptyString\n}, { additionalProperties: false }), "; + let insert_schema = "const CronPayloadSchema = Type.Union([Type.Object({\n\tkind: Type.Literal(\"systemEvent\"),\n\ttext: NonEmptyString\n}, { additionalProperties: false }), Type.Object({\n\tkind: Type.Literal(\"sessionMessage\"),\n\tlabel: NonEmptyString,\n\tmessage: NonEmptyString,\n\trole: Type.Optional(Type.Literal(\"user\")),\n\twaitForIdle: Type.Optional(Type.Boolean())\n}, { additionalProperties: false }), "; + + let needle_patch = "const CronPayloadPatchSchema = Type.Union([Type.Object({\n\tkind: Type.Literal(\"systemEvent\"),\n\ttext: Type.Optional(NonEmptyString)\n}, { additionalProperties: false }), "; + let insert_patch = "const CronPayloadPatchSchema = Type.Union([Type.Object({\n\tkind: Type.Literal(\"systemEvent\"),\n\ttext: Type.Optional(NonEmptyString)\n}, { additionalProperties: false }), Type.Object({\n\tkind: Type.Literal(\"sessionMessage\"),\n\tlabel: Type.Optional(NonEmptyString),\n\tmessage: Type.Optional(NonEmptyString),\n\trole: Type.Optional(Type.Literal(\"user\")),\n\twaitForIdle: Type.Optional(Type.Boolean())\n}, { additionalProperties: false }), "; + + let mut next = replace_once(&content, needle_schema, insert_schema)?; + next = replace_once(&next, needle_patch, insert_patch)?; + + backup_file(path)?; + fs::write(path, next).map_err(|e| format!("写入失败: {e}"))?; + Ok(true) +} + +fn patch_gateway_file(path: &Path, _force: bool) -> Result { + let content = fs::read_to_string(path).map_err(|e| format!("读取失败: {e}"))?; + if content.contains("payload.kind === \"sessionMessage\"") { + return Ok(false); + } + + let needle_assert = "\tif (job.sessionTarget === \"main\" && job.payload.kind !== \"systemEvent\") throw new Error(\"main cron jobs require payload.kind=\\\"systemEvent\\\"\");"; + let insert_assert = "\tif (job.sessionTarget === \"main\" && job.payload.kind !== \"systemEvent\" && job.payload.kind !== \"sessionMessage\") throw new Error(\"main cron jobs require payload.kind=\\\"systemEvent\\\" or \\\"sessionMessage\\\"\");"; + + let needle_merge = "\tif (patch.kind === \"systemEvent\") {\n\t\tif (existing.kind !== \"systemEvent\") return buildPayloadFromPatch(patch);\n\t\treturn {\n\t\t\tkind: \"systemEvent\",\n\t\t\ttext: typeof patch.text === \"string\" ? patch.text : existing.text\n\t\t};\n\t}\n\tif (existing.kind !== \"agentTurn\") return buildPayloadFromPatch(patch);"; + let insert_merge = "\tif (patch.kind === \"systemEvent\") {\n\t\tif (existing.kind !== \"systemEvent\") return buildPayloadFromPatch(patch);\n\t\treturn {\n\t\t\tkind: \"systemEvent\",\n\t\t\ttext: typeof patch.text === \"string\" ? patch.text : existing.text\n\t\t};\n\t}\n\tif (patch.kind === \"sessionMessage\") {\n\t\tif (existing.kind !== \"sessionMessage\") return buildPayloadFromPatch(patch);\n\t\treturn {\n\t\t\tkind: \"sessionMessage\",\n\t\t\tlabel: typeof patch.label === \"string\" ? patch.label : existing.label,\n\t\t\tmessage: typeof patch.message === \"string\" ? patch.message : existing.message,\n\t\t\trole: \"user\",\n\t\t\twaitForIdle: typeof patch.waitForIdle === \"boolean\" ? patch.waitForIdle : existing.waitForIdle\n\t\t};\n\t}\n\tif (existing.kind !== \"agentTurn\") return buildPayloadFromPatch(patch);"; + + let needle_build = "\tif (patch.kind === \"systemEvent\") {\n\t\tif (typeof patch.text !== \"string\" || patch.text.length === 0) throw new Error(\"cron.update payload.kind=\\\"systemEvent\\\" requires text\");\n\t\treturn {\n\t\t\tkind: \"systemEvent\",\n\t\t\ttext: patch.text\n\t\t};\n\t}\n\tif (typeof patch.message !== \"string\" || patch.message.length === 0) throw new Error(\"cron.update payload.kind=\\\"agentTurn\\\" requires message\");"; + let insert_build = "\tif (patch.kind === \"systemEvent\") {\n\t\tif (typeof patch.text !== \"string\" || patch.text.length === 0) throw new Error(\"cron.update payload.kind=\\\"systemEvent\\\" requires text\");\n\t\treturn {\n\t\t\tkind: \"systemEvent\",\n\t\t\ttext: patch.text\n\t\t};\n\t}\n\tif (patch.kind === \"sessionMessage\") {\n\t\tif (typeof patch.label !== \"string\" || patch.label.length === 0) throw new Error(\"cron.update payload.kind=\\\"sessionMessage\\\" requires label\");\n\t\tif (typeof patch.message !== \"string\" || patch.message.length === 0) throw new Error(\"cron.update payload.kind=\\\"sessionMessage\\\" requires message\");\n\t\treturn {\n\t\t\tkind: \"sessionMessage\",\n\t\t\tlabel: patch.label,\n\t\t\tmessage: patch.message,\n\t\t\trole: \"user\",\n\t\t\twaitForIdle: typeof patch.waitForIdle === \"boolean\" ? patch.waitForIdle : true\n\t\t};\n\t}\n\tif (typeof patch.message !== \"string\" || patch.message.length === 0) throw new Error(\"cron.update payload.kind=\\\"agentTurn\\\" requires message\");"; + + let needle_execute = "\tif (abortSignal?.aborted) return resolveAbortError();\n\tif (job.sessionTarget === \"main\") {"; + let insert_execute = "\tif (abortSignal?.aborted) return resolveAbortError();\n\tif (job.payload.kind === \"sessionMessage\") {\n\t\tconst cfg = loadConfig();\n\t\tconst resolved = await resolveSessionKeyFromResolveParams({\n\t\t\tcfg,\n\t\t\tp: { label: job.payload.label }\n\t\t});\n\t\tif (!resolved.ok) return {\n\t\t\tstatus: \"error\",\n\t\t\terror: resolved.error?.message ?? \"session not found\"\n\t\t};\n\t\tif (job.payload.waitForIdle) {\n\t\t\tawait waitForActiveEmbeddedRuns(15e3);\n\t\t}\n\t\tconst { entry } = loadSessionEntry(resolved.key);\n\t\tconst prefixOptions = createReplyPrefixOptions({\n\t\t\tcfg,\n\t\t\tentry,\n\t\t\tsessionKey: resolved.key,\n\t\t\tclient: void 0\n\t\t});\n\t\tconst dispatcher = createReplyDispatcher({\n\t\t\t...prefixOptions,\n\t\t\tonError: (err) => {\n\t\t\t\tstate.deps.log.warn(`cron sessionMessage dispatch failed: ${String(err)}`);\n\t\t\t}\n\t\t});\n\t\tconst message = job.payload.message;\n\t\tconst ctx = {\n\t\t\tBody: message,\n\t\t\tBodyForAgent: message,\n\t\t\tBodyForCommands: message,\n\t\t\tRawBody: message,\n\t\t\tCommandBody: message,\n\t\t\tSessionKey: resolved.key,\n\t\t\tProvider: INTERNAL_MESSAGE_CHANNEL,\n\t\t\tSurface: INTERNAL_MESSAGE_CHANNEL,\n\t\t\tChatType: \"direct\",\n\t\t\tCommandAuthorized: true,\n\t\t\tMessageSid: `cron:${job.id}:${state.deps.nowMs()}`\n\t\t};\n\t\tawait dispatchInboundMessage({\n\t\t\tctx,\n\t\t\tcfg,\n\t\t\tdispatcher,\n\t\t\treplyOptions: { runId: `cron:${job.id}:${state.deps.nowMs()}` }\n\t\t});\n\t\treturn {\n\t\t\tstatus: \"ok\",\n\t\t\tsummary: message,\n\t\t\tsessionKey: resolved.key\n\t\t};\n\t}\n\tif (job.sessionTarget === \"main\") {"; + + let mut next = replace_once(&content, needle_assert, insert_assert)?; + next = replace_once(&next, needle_merge, insert_merge)?; + next = replace_once(&next, needle_build, insert_build)?; + next = replace_once(&next, needle_execute, insert_execute)?; + + backup_file(path)?; + fs::write(path, next).map_err(|e| format!("写入失败: {e}"))?; + Ok(true) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 0a08ff90..87f6a25d 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -7,6 +7,7 @@ pub mod agent; pub mod assistant; pub mod cloudflared; pub mod config; +pub mod gateway_patch; pub mod device; pub mod extensions; pub mod logs; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ccca5066..9a899481 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,8 +4,8 @@ mod tray; mod utils; use commands::{ - agent, assistant, cloudflared, config, device, extensions, logs, memory, messaging, pairing, - service, skills, update, + agent, assistant, cloudflared, config, device, extensions, gateway_patch, logs, memory, + messaging, pairing, service, skills, update, }; pub fn run() { @@ -112,6 +112,10 @@ pub fn run() { cloudflared::cloudflared_login, cloudflared::cloudflared_start, cloudflared::cloudflared_stop, + // Gateway 补丁 + gateway_patch::gateway_patch_status, + gateway_patch::gateway_patch_apply, + gateway_patch::gateway_patch_rollback, // 服务 service::get_services_status, service::start_service, diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index bd1ea288..a8e93c80 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -214,6 +214,11 @@ export const api = { cloudflaredStart: (config) => invoke('cloudflared_start', { config }), cloudflaredStop: () => invoke('cloudflared_stop'), + // Gateway 补丁 + gatewayPatchStatus: () => invoke('gateway_patch_status'), + gatewayPatchApply: (force) => invoke('gateway_patch_apply', { force: !!force }), + gatewayPatchRollback: () => invoke('gateway_patch_rollback'), + // 安装/部署 checkInstallation: () => cachedInvoke('check_installation', {}, 60000), initOpenclawConfig: () => { invalidate('check_installation'); return invoke('init_openclaw_config') }, diff --git a/src/pages/settings.js b/src/pages/settings.js index 8a649687..035b12c6 100644 --- a/src/pages/settings.js +++ b/src/pages/settings.js @@ -42,6 +42,16 @@ export async function render() {
npm 源设置
+ +
+
公网访问
+
+
+ +
+
Gateway 补丁
+
+
` bindEvents(page) @@ -52,6 +62,8 @@ export async function render() { async function loadAll(page) { const tasks = [loadProxyConfig(page), loadModelProxyConfig(page)] tasks.push(loadRegistry(page)) + tasks.push(loadCloudflared(page)) + tasks.push(loadGatewayPatch(page)) await Promise.all(tasks) } @@ -182,6 +194,18 @@ function bindEvents(page) { case 'cloudflared-save': await handleCloudflaredSave(page) break + case 'gateway-patch-apply': + await handleGatewayPatchApply(page, false) + break + case 'gateway-patch-apply-force': + await handleGatewayPatchApply(page, true) + break + case 'gateway-patch-rollback': + await handleGatewayPatchRollback(page) + break + case 'gateway-patch-refresh': + await loadGatewayPatch(page) + break } } catch (e) { toast(e.toString(), 'error') @@ -263,6 +287,65 @@ async function handleSaveRegistry(page) { toast('npm 源已保存', 'success') } +// ===== Gateway 补丁 ===== + +async function loadGatewayPatch(page) { + const el = page.querySelector('#gateway-patch-bar') + if (!el) return + try { + const status = await api.gatewayPatchStatus() + const patched = !!status?.patched + const installed = status?.installed_version || '未知' + const patchedVersion = status?.patched_version || '-' + const patchedAt = status?.patched_at || '-' + const files = Array.isArray(status?.files) && status.files.length > 0 ? status.files.map(escapeHtml).join(', ') : '-' + const lastError = status?.last_error ? `
错误: ${escapeHtml(status.last_error)}
` : '' + + el.innerHTML = ` +
+ + ${patched ? '已补丁' : '未补丁'} + OpenClaw 版本: ${escapeHtml(installed)} +
+ +
+ + + + +
+ +
+
补丁版本 +
${escapeHtml(patchedVersion)}
+
+
补丁时间 +
${escapeHtml(patchedAt)}
+
+
补丁文件 +
${escapeHtml(files)}
+
+
+ ${lastError} +
自动定位全局 npm 安装的 openclaw 包并打补丁。支持回滚。
+ ` + } catch (e) { + el.innerHTML = `
加载失败: ${escapeHtml(String(e))}
` + } +} + +async function handleGatewayPatchApply(page, force) { + await api.gatewayPatchApply(!!force) + await loadGatewayPatch(page) + toast(force ? '补丁已重打' : '补丁已完成', 'success') +} + +async function handleGatewayPatchRollback(page) { + await api.gatewayPatchRollback() + await loadGatewayPatch(page) + toast('补丁已回滚', 'success') +} + // ===== Cloudflared 公网访问 ===== function getCloudflaredForm(page) { From c8a6d528d46c84fd934340f88c3420e1aeb4bdc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 13:31:25 +0800 Subject: [PATCH 012/426] docs: gateway patch auto detect design --- ...-03-16-gateway-patch-auto-detect-design.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 docs/plans/2026-03-16-gateway-patch-auto-detect-design.md diff --git a/docs/plans/2026-03-16-gateway-patch-auto-detect-design.md b/docs/plans/2026-03-16-gateway-patch-auto-detect-design.md new file mode 100644 index 00000000..9dd17941 --- /dev/null +++ b/docs/plans/2026-03-16-gateway-patch-auto-detect-design.md @@ -0,0 +1,25 @@ +# Gateway 补丁自动检测与重打设计 + +日期:2026-03-16 + +## 目标 +- 启动与进入设置页自动检测全局 OpenClaw 版本 +- 版本变化时自动重打补丁,失败才提示 +- 过程静默、不中断使用 + +## 触发策略 +- 应用启动时触发一次检测 +- 进入设置页时触发检测 + +## 自动重打逻辑 +- 判断 installed_version 与 gatewayPatch.openclawVersion +- 不一致时调用 gateway_patch_apply(force=true) +- 使用内存节流与冷却(5 分钟)避免重复触发 + +## 状态与提示 +- 失败写入 last_error,设置页展示错误 +- 不弹窗、不阻塞其他功能 + +## 边界处理 +- 缺少 .bak 备份时,force 失败提示“缺少备份,建议先一键补丁” +- 自动重打失败只提示,不回滚、不影响其他功能 From 38c1073ced1606049a2fd8821ed02896e7956227 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 13:32:04 +0800 Subject: [PATCH 013/426] docs: gateway patch auto detect plan --- .../2026-03-16-gateway-patch-auto-detect.md | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-16-gateway-patch-auto-detect.md diff --git a/docs/superpowers/plans/2026-03-16-gateway-patch-auto-detect.md b/docs/superpowers/plans/2026-03-16-gateway-patch-auto-detect.md new file mode 100644 index 00000000..2d3149fe --- /dev/null +++ b/docs/superpowers/plans/2026-03-16-gateway-patch-auto-detect.md @@ -0,0 +1,74 @@ +# Gateway Patch Auto-Detect Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Auto-detect global OpenClaw version changes at app start and settings entry, then reapply the gateway patch silently; show errors only in settings. + +**Architecture:** Add a small patch auto-detect runner in settings (client-side) and app bootstrap (Tauri setup) that calls gateway_patch_status and, on version mismatch, triggers gateway_patch_apply(force=true). Use in-memory throttle and 5-minute cooldown to avoid repeated runs. + +**Tech Stack:** Tauri (Rust), Vite/JS, ClawPanel config + +--- + +## Chunk 1: Backend status extensions + +### Task 1: Extend gateway_patch_status output + +**Files:** +- Modify: `src-tauri/src/commands/gateway_patch.rs` + +- [ ] **Step 1: Add version mismatch helper** + +Add a helper to compute `needs_repatch` by comparing `installed_version` with stored `gatewayPatch.openclawVersion` and include it in status output if desired. + +- [ ] **Step 2: Ensure force apply uses backup detection** + +If force=true and backups missing, return a clear error string: "缺少备份,建议先一键补丁". + +- [ ] **Step 3: Commit** + +``` +git add src-tauri/src/commands/gateway_patch.rs + +git commit -m "feat: add patch auto-detect helpers" +``` + +## Chunk 2: Frontend auto-detect logic + +### Task 2: App startup auto-detect + +**Files:** +- Modify: `src/main.js` + +- [ ] **Step 1: Add one-time auto-detect** + +Create a lightweight timer guard (module-scoped) and call `api.gatewayPatchStatus()` then `api.gatewayPatchApply(true)` when version mismatch is detected. Cooldown 5 minutes. + +- [ ] **Step 2: Commit** + +``` +git add src/main.js + +git commit -m "feat: auto-detect gateway patch at startup" +``` + +### Task 3: Settings page auto-detect + +**Files:** +- Modify: `src/pages/settings.js` + +- [ ] **Step 1: Add auto-detect on loadAll** + +After `loadGatewayPatch`, run auto-detect handler with a shared cooldown guard; update UI based on result. Do not show toast on success; show error in the card only. + +- [ ] **Step 2: Commit** + +``` +git add src/pages/settings.js + +git commit -m "feat: auto-detect gateway patch in settings" +``` + +--- + +Plan complete and saved to `docs/superpowers/plans/2026-03-16-gateway-patch-auto-detect.md`. Ready to execute? From 9a0a5306e26370075cda83a37c0a557999b6e088 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 13:32:25 +0800 Subject: [PATCH 014/426] chore: checkpoint before auto detect From 61b7edfc18402d5919fce23a029e0daf9af7145b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 14:02:56 +0800 Subject: [PATCH 015/426] feat: auto detect gateway patch --- src-tauri/src/commands/gateway_patch.rs | 22 +++++++++++-- src/main.js | 26 +++++++++++++++ src/pages/settings.js | 43 +++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands/gateway_patch.rs b/src-tauri/src/commands/gateway_patch.rs index 2542d4d3..b61df0a0 100644 --- a/src-tauri/src/commands/gateway_patch.rs +++ b/src-tauri/src/commands/gateway_patch.rs @@ -11,6 +11,8 @@ const PATCH_VERSION: &str = "sessionMessage-v1"; #[derive(Serialize, Deserialize, Default)] pub struct GatewayPatchStatus { pub installed_version: Option, + #[serde(rename = "openclawVersion")] + pub openclaw_version: Option, pub patched: bool, pub patched_version: Option, pub patched_at: Option, @@ -41,6 +43,15 @@ pub async fn gateway_patch_apply(force: Option) -> Result) -> Result) -> Result Result { .ok_or_else(|| format!("未找到 {prefix}*.js")) } +fn backup_exists(path: &Path) -> bool { + let backup_path = PathBuf::from(format!("{}.bak", path.display())); + backup_path.exists() +} + fn backup_file(path: &Path) -> Result<(), String> { let backup_path = PathBuf::from(format!("{}.bak", path.display())); fs::copy(path, &backup_path).map_err(|e| format!("备份失败: {e}"))?; diff --git a/src/main.js b/src/main.js index 5c3d181b..2f4ebba7 100644 --- a/src/main.js +++ b/src/main.js @@ -29,6 +29,31 @@ initTheme() // === 访问密码保护(Web + 桌面端通用) === const isTauri = !!window.__TAURI_INTERNALS__ +const GATEWAY_PATCH_COOLDOWN_MS = 5 * 60 * 1000 +let _gatewayPatchLastCheck = 0 +let _gatewayPatchRunning = false + +async function runGatewayPatchAutoDetect() { + if (!isTauri) return + if (_gatewayPatchRunning) return + const now = Date.now() + if (now - _gatewayPatchLastCheck < GATEWAY_PATCH_COOLDOWN_MS) return + _gatewayPatchLastCheck = now + _gatewayPatchRunning = true + try { + const status = await api.gatewayPatchStatus() + const installed = status?.installed_version + const recorded = status?.openclawVersion || status?.openclaw_version + if (!installed || !recorded) return + if (installed === recorded) return + await api.gatewayPatchApply(true) + } catch { + // 静默 + } finally { + _gatewayPatchRunning = false + } +} + async function checkAuth() { if (isTauri) { // 桌面端:读 clawpanel.json,检查密码配置 @@ -746,6 +771,7 @@ function startUpdateChecker() {
如果问题持续出现,请尝试重新安装 ClawPanel
或在 GitHub Issues 反馈
` } + await runGatewayPatchAutoDetect() startUpdateChecker() // 初始化全局 AI 助手浮动按钮(延迟加载,不阻塞启动) diff --git a/src/pages/settings.js b/src/pages/settings.js index 035b12c6..485940b3 100644 --- a/src/pages/settings.js +++ b/src/pages/settings.js @@ -65,6 +65,7 @@ async function loadAll(page) { tasks.push(loadCloudflared(page)) tasks.push(loadGatewayPatch(page)) await Promise.all(tasks) + await runGatewayPatchAutoDetect(page) } // ===== 网络代理 ===== @@ -289,6 +290,44 @@ async function handleSaveRegistry(page) { // ===== Gateway 补丁 ===== +const GATEWAY_PATCH_COOLDOWN_MS = 5 * 60 * 1000 +let _gatewayPatchLastCheck = 0 +let _gatewayPatchRunning = false + +async function runGatewayPatchAutoDetect(page) { + if (_gatewayPatchRunning) return + const now = Date.now() + if (now - _gatewayPatchLastCheck < GATEWAY_PATCH_COOLDOWN_MS) return + _gatewayPatchLastCheck = now + _gatewayPatchRunning = true + try { + const status = await api.gatewayPatchStatus() + const installed = status?.installed_version + const recorded = status?.openclawVersion || status?.openclaw_version + if (!installed || !recorded) return + if (installed === recorded) return + await api.gatewayPatchApply(true) + await loadGatewayPatch(page) + } catch (e) { + const msg = String(e || '') + if (msg && page) { + const el = page.querySelector('#gateway-patch-bar') + if (el) { + const existing = el.querySelector('[data-name="gateway-patch-error"]') + if (existing) existing.remove() + const errorEl = document.createElement('div') + errorEl.dataset.name = 'gateway-patch-error' + errorEl.style.color = 'var(--error)' + errorEl.style.marginTop = 'var(--space-xs)' + errorEl.textContent = `自动重打失败: ${msg}` + el.appendChild(errorEl) + } + } + } finally { + _gatewayPatchRunning = false + } +} + async function loadGatewayPatch(page) { const el = page.querySelector('#gateway-patch-bar') if (!el) return @@ -296,6 +335,7 @@ async function loadGatewayPatch(page) { const status = await api.gatewayPatchStatus() const patched = !!status?.patched const installed = status?.installed_version || '未知' + const recorded = status?.openclawVersion || status?.openclaw_version || '-' const patchedVersion = status?.patched_version || '-' const patchedAt = status?.patched_at || '-' const files = Array.isArray(status?.files) && status.files.length > 0 ? status.files.map(escapeHtml).join(', ') : '-' @@ -322,6 +362,9 @@ async function loadGatewayPatch(page) {
补丁时间
${escapeHtml(patchedAt)}
+
记录版本 +
${escapeHtml(recorded)}
+
补丁文件
${escapeHtml(files)}
From a5308a5eb652b67e295ac1de29c1c40f3b97fe3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 14:07:53 +0800 Subject: [PATCH 016/426] chore: checkpoint before cloudflared fix From 10f214e706675c671d7554d69cc155cbbbed07cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 14:09:51 +0800 Subject: [PATCH 017/426] fix: cloudflared async compile --- src-tauri/src/commands/cloudflared.rs | 50 ++++++++++++++++----------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/src-tauri/src/commands/cloudflared.rs b/src-tauri/src/commands/cloudflared.rs index 0564946c..339d0ec4 100644 --- a/src-tauri/src/commands/cloudflared.rs +++ b/src-tauri/src/commands/cloudflared.rs @@ -1,4 +1,4 @@ -use crate::commands::{apply_proxy_env, build_http_client, openclaw_config_path, openclaw_dir}; +use crate::commands::{apply_proxy_env, build_http_client, openclaw_dir}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::path::{Path, PathBuf}; @@ -11,6 +11,7 @@ use std::os::windows::process::CommandExt; use std::process::Command; use tokio::process::Child; +use tokio::io::AsyncBufReadExt; static STATE: std::sync::LazyLock> = std::sync::LazyLock::new(|| Mutex::new(CloudflaredState::default())); @@ -368,14 +369,16 @@ pub async fn cloudflared_start(config: CloudflaredStartConfig) -> Result Result { - if let Ok(Some(l)) = line { - if let Some(u) = parse_quick_url(&l) { - url = Some(u); - break; - } - } else { + let now = std::time::Instant::now(); + if now >= deadline { + break; + } + let remain = deadline.saturating_duration_since(now); + match tokio::time::timeout(remain, reader.next_line()).await { + Ok(Ok(Some(l))) => { + if let Some(u) = parse_quick_url(&l) { + url = Some(u); break; } } - _ = &mut deadline => { break; } + Ok(Ok(None)) => break, + Ok(Err(_)) => break, + Err(_) => break, } } } @@ -482,12 +487,17 @@ pub async fn cloudflared_start(config: CloudflaredStartConfig) -> Result Result { - let mut state = STATE.lock().unwrap(); - if let Some(mut child) = state.child.take() { + let existing_child = { + let mut state = STATE.lock().unwrap(); + let child = state.child.take(); + state.running = false; + state.url = None; + child + }; + if let Some(mut child) = existing_child { let _ = child.kill().await; } - state.running = false; - state.url = None; + let state = STATE.lock().unwrap(); Ok(json!(CloudflaredStatus { installed: resolve_cloudflared_bin().is_some(), version: resolve_cloudflared_bin().as_ref().and_then(|b| get_version(b)), From c6a6090307f0a0ec42a3e40a2bc2f071381b3b6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 14:20:13 +0800 Subject: [PATCH 018/426] chore: checkpoint before cron fix From b0b0a3f7b1f072101b3cbdda35e21f5e847e1847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 14:21:16 +0800 Subject: [PATCH 019/426] fix: cron toggleFields init order --- src/pages/cron.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pages/cron.js b/src/pages/cron.js index 970f8785..47fdc306 100644 --- a/src/pages/cron.js +++ b/src/pages/cron.js @@ -440,8 +440,6 @@ async function openTaskDialog(job, page, state) { }) } - toggleFields() - const toggleFields = () => { const kind = modal.querySelector('select[name="taskKind"]').value const showSession = kind === 'sessionMessage' @@ -450,6 +448,7 @@ async function openTaskDialog(job, page, state) { modal.querySelector('[data-field="agent"]').style.display = showSession ? 'none' : 'block' modal.querySelector('[data-field="delivery"]').style.display = showSession ? 'none' : 'block' } + toggleFields() modal.querySelector('select[name="taskKind"]').onchange = toggleFields // 保存 From 89ae7b1914a6d437428b98c4d45a49a522d2213d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 14:26:22 +0800 Subject: [PATCH 020/426] chore: checkpoint before chat guard From 21185fca9ec3c36c03ba63d9bc9e98687d2fc6ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 14:27:18 +0800 Subject: [PATCH 021/426] fix: guard chat send before ready --- src/pages/chat.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/pages/chat.js b/src/pages/chat.js index 988b52cd..c565f962 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -846,6 +846,10 @@ function toggleCmdPanel() { function sendMessage() { const text = _textarea.value.trim() if (!text && !_attachments.length) return + if (!wsClient.gatewayReady || !_sessionKey) { + toast('Gateway 未就绪,连接成功后再发送', 'warning') + return + } hideCmdPanel() _textarea.value = '' _textarea.style.height = 'auto' @@ -858,6 +862,10 @@ function sendMessage() { } async function doSend(text, attachments = []) { + if (!wsClient.gatewayReady || !_sessionKey) { + toast('Gateway 未就绪,连接成功后再发送', 'warning') + return + } appendUserMessage(text, attachments) saveMessage({ id: uuid(), sessionKey: _sessionKey, role: 'user', content: text, timestamp: Date.now(), From ceeae12464e3013a5546e05d9893b66acea46ec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 14:29:18 +0800 Subject: [PATCH 022/426] chore: checkpoint before chat guide fix From f8444bb1813132bccd1ce69eed3ae2897512ee6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 14:30:06 +0800 Subject: [PATCH 023/426] fix: prevent duplicate chat guide --- src/pages/chat.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/chat.js b/src/pages/chat.js index c565f962..e1cf4e4a 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -182,6 +182,7 @@ const GUIDE_KEY = 'clawpanel-guide-chat-dismissed' function showPageGuide(container) { if (localStorage.getItem(GUIDE_KEY)) return + if (!container || container.querySelector('.chat-page-guide')) return const guide = document.createElement('div') guide.className = 'chat-page-guide' guide.innerHTML = ` From b9f01b4afef4b6e4033f424294913e7686899f59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 14:35:07 +0800 Subject: [PATCH 024/426] chore: checkpoint before chat null guards From f34ef8b3ae1aec00632a2636cad34ef5178f3361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 14:36:35 +0800 Subject: [PATCH 025/426] fix: guard chat dom after cleanup --- src/pages/chat.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/pages/chat.js b/src/pages/chat.js index e1cf4e4a..09822768 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -466,6 +466,7 @@ function fileToBase64(file) { } function renderAttachments() { + if (!_attachPreviewEl) return if (!_attachments.length) { _attachPreviewEl.style.display = 'none' return @@ -1244,8 +1245,8 @@ function resetStreamState() { // ── 历史消息加载 ── async function loadHistory() { - if (!_sessionKey) return - const hasExisting = _messagesEl?.querySelector('.msg') + if (!_sessionKey || !_messagesEl) return + const hasExisting = _messagesEl.querySelector('.msg') if (!hasExisting && isStorageAvailable()) { const local = await getLocalMessages(_sessionKey, 200) if (local.length) { @@ -1266,7 +1267,7 @@ async function loadHistory() { try { const result = await wsClient.chatHistory(_sessionKey, 200) if (!result?.messages?.length) { - if (!_messagesEl.querySelector('.msg')) appendSystemMessage('还没有消息,开始聊天吧') + if (_messagesEl && !_messagesEl.querySelector('.msg')) appendSystemMessage('还没有消息,开始聊天吧') return } const deduped = dedupeHistory(result.messages) @@ -1312,7 +1313,7 @@ async function loadHistory() { scrollToBottom() } catch (e) { console.error('[chat] loadHistory error:', e) - if (!_messagesEl.querySelector('.msg')) appendSystemMessage('加载历史失败: ' + e.message) + if (_messagesEl && !_messagesEl.querySelector('.msg')) appendSystemMessage('加载历史失败: ' + e.message) } } From e23745725e529f36f5e4f7b6683b6dbd58ef6829 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 14:40:13 +0800 Subject: [PATCH 026/426] chore: checkpoint before stream guards From 4a59d3322fe88acc06ab770af548efcc7ca95612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 14:41:17 +0800 Subject: [PATCH 027/426] fix: guard stream bubble insert --- src/pages/chat.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/chat.js b/src/pages/chat.js index 09822768..e81c23b6 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -1181,6 +1181,7 @@ function formatFileSize(bytes) { /** 创建流式 AI 气泡 */ function createStreamBubble() { + if (!_messagesEl || !_typingEl) return null showTyping(false) const wrap = document.createElement('div') wrap.className = 'msg msg-ai' From 5048503f951c4b7d171f06c2adbcf10ad9f2c940 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 14:43:08 +0800 Subject: [PATCH 028/426] chore: checkpoint before tool collapse fix From a1a473e2dd37971c6921f27dfd00da9c580bc802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 14:44:14 +0800 Subject: [PATCH 029/426] fix: collapse tool details by default --- src/style/chat.css | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/style/chat.css b/src/style/chat.css index 198e21da..b59fd8e7 100644 --- a/src/style/chat.css +++ b/src/style/chat.css @@ -872,9 +872,13 @@ } .msg-tool-body { margin-top: 8px; - display: grid; + display: none; gap: 8px; } + +.msg-tool-item[open] > .msg-tool-body { + display: grid; +} .msg-tool-block { background: var(--bg-primary); border: 1px solid var(--border-primary); From e45a9a4fa0c11a1d7752fd760e3c9434436581f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 14:45:17 +0800 Subject: [PATCH 030/426] chore: checkpoint before tool stringify From 5e7a895b40cfdc472b6e70f428396503ac522c88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 14:46:25 +0800 Subject: [PATCH 031/426] fix: safe tool render --- src/pages/chat.js | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/pages/chat.js b/src/pages/chat.js index e81c23b6..d48de585 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -1572,7 +1572,7 @@ function appendFilesToEl(el, files) { /** 渲染工具调用到消息气泡 */ function appendToolsToEl(el, tools) { - if (!tools?.length) return + if (!el || !tools?.length) return const container = document.createElement('div') container.className = 'msg-tool' tools.forEach(tool => { @@ -1583,8 +1583,10 @@ function appendToolsToEl(el, tools) { summary.innerHTML = `${escapeHtml(tool.name || '工具')} · ${status}` const body = document.createElement('div') body.className = 'msg-tool-body' - const input = tool.input ? `
参数
${escapeHtml(JSON.stringify(tool.input, null, 2))}
` : '' - const output = tool.output ? `
结果
${escapeHtml(JSON.stringify(tool.output, null, 2))}
` : '' + const inputJson = safeStringify(tool.input) + const outputJson = safeStringify(tool.output) + const input = inputJson ? `
参数
${escapeHtml(inputJson)}
` : '' + const output = outputJson ? `
结果
${escapeHtml(outputJson)}
` : '' body.innerHTML = input + output details.appendChild(summary) details.appendChild(body) @@ -1593,6 +1595,27 @@ function appendToolsToEl(el, tools) { el.appendChild(container) } +function safeStringify(value) { + if (value == null) return '' + const seen = new WeakSet() + try { + return JSON.stringify(value, (key, val) => { + if (typeof val === 'bigint') return val.toString() + if (typeof val === 'object' && val !== null) { + if (seen.has(val)) return '[Circular]' + seen.add(val) + } + return val + }, 2) + } catch (e) { + try { + return String(value) + } catch { + return '' + } + } +} + /** 图片灯箱查看 */ function showLightbox(src) { const existing = document.querySelector('.chat-lightbox') From ec1bfced92b5c4a2337297b27d2f077a33fb2349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 14:52:15 +0800 Subject: [PATCH 032/426] chore: checkpoint before chat ui polish From 76703a12c477b8b0b5cd2a903500a37bf5f2ae3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 14:56:42 +0800 Subject: [PATCH 033/426] feat: polish chat layout and tools --- src/pages/chat.js | 1 + src/style/chat.css | 47 ++++++++++++++++++++++++---------------------- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/src/pages/chat.js b/src/pages/chat.js index d48de585..a33b3a14 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -1578,6 +1578,7 @@ function appendToolsToEl(el, tools) { tools.forEach(tool => { const details = document.createElement('details') details.className = 'msg-tool-item' + details.open = false const summary = document.createElement('summary') const status = tool.status === 'error' ? '失败' : '成功' summary.innerHTML = `${escapeHtml(tool.name || '工具')} · ${status}` diff --git a/src/style/chat.css b/src/style/chat.css index b59fd8e7..52c55573 100644 --- a/src/style/chat.css +++ b/src/style/chat.css @@ -30,13 +30,15 @@ flex-direction: column; flex-shrink: 0; overflow: hidden; - transition: width 0.2s ease, min-width 0.2s ease, border-right 0.2s ease; + transition: width 0.2s ease, min-width 0.2s ease, border-right 0.2s ease, opacity 0.12s ease; + opacity: 0; } .chat-sidebar.open { width: 220px; min-width: 220px; border-right: 1px solid var(--border); + opacity: 1; } .chat-sidebar-header { @@ -70,11 +72,11 @@ display: flex; align-items: center; justify-content: space-between; - padding: var(--space-sm) var(--space-md); + padding: 10px 12px; border-bottom: 1px solid var(--border); flex-shrink: 0; gap: 8px; - min-height: 44px; + min-height: 38px; } .chat-status { @@ -129,7 +131,7 @@ .chat-messages { flex: 1; overflow-y: auto; - padding: var(--space-md); + padding: 12px; display: flex; flex-direction: column; gap: var(--space-sm); @@ -139,7 +141,7 @@ .msg { display: flex; flex-direction: column; - max-width: 85%; + max-width: 80%; animation: msg-in 0.2s ease-out; } @@ -177,10 +179,10 @@ /* 消息气泡 */ .msg-bubble { - padding: var(--space-sm) var(--space-md); + padding: 8px 12px; border-radius: var(--radius-lg, 12px); - font-size: 14px; - line-height: 1.6; + font-size: 13px; + line-height: 1.55; word-break: break-word; } @@ -191,9 +193,10 @@ } .msg-ai .msg-bubble { - background: var(--bg-secondary, var(--bg-card)); + background: rgba(255, 255, 255, 0.06); color: var(--text-primary); border-bottom-left-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.08); } /* AI 气泡内 Markdown 排版 */ @@ -345,11 +348,11 @@ .chat-input-area { display: flex; align-items: flex-end; - gap: var(--space-sm); - padding: var(--space-sm) var(--space-md); + gap: 8px; + padding: 8px 10px; border-top: 1px solid var(--border); flex-shrink: 0; - background: var(--bg-primary); + background: rgba(0, 0, 0, 0.15); } .chat-input-wrapper { @@ -360,12 +363,12 @@ .chat-input-wrapper textarea { width: 100%; resize: none; - border: 1px solid var(--border); + border: 1px solid rgba(255, 255, 255, 0.08); border-radius: var(--radius-md, 8px); - padding: 10px 14px; - font-size: 14px; - line-height: 1.5; - background: var(--bg-secondary, var(--bg-card)); + padding: 8px 12px; + font-size: 13px; + line-height: 1.4; + background: rgba(255, 255, 255, 0.04); color: var(--text-primary); outline: none; transition: border-color 0.15s; @@ -905,12 +908,12 @@ } .chat-guide-inner { display: flex; - align-items: flex-start; + align-items: center; gap: 12px; - padding: 12px 14px; - background: var(--accent-muted, rgba(99, 102, 241, 0.08)); - border: 1px solid var(--accent-border, rgba(99, 102, 241, 0.2)); - border-radius: var(--radius-lg); + padding: 10px 12px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: var(--radius-md); font-size: 12px; line-height: 1.6; color: var(--text-secondary); From af2417d1713567e0729483a51a35c96c4d208fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 15:00:02 +0800 Subject: [PATCH 034/426] chore: checkpoint before chat tool/ui/channel fixes From aea4145d6b6ca3aee32a1c039fa244ac112deafb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 15:06:05 +0800 Subject: [PATCH 035/426] fix: chat tools collapse and channel cards --- src/pages/channels.js | 42 +++++++++++++++++++++--------------------- src/pages/chat.js | 4 ++++ src/style/chat.css | 16 ++++++++-------- 3 files changed, 33 insertions(+), 29 deletions(-) diff --git a/src/pages/channels.js b/src/pages/channels.js index 3af5284f..c247ba36 100644 --- a/src/pages/channels.js +++ b/src/pages/channels.js @@ -173,33 +173,33 @@ function renderConfigured(page, state) {
已接入
- ${state.configured.map(p => { + ${state.configured.flatMap(p => { 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 allBindings = (state.bindings || []).filter(b => b.match?.channel === channelKey) - const boundAgents = allBindings.map(b => b.agentId || 'main') - // 只有一个 main 绑定时不显示标签(默认行为),多绑定时全部显示 - const showAll = boundAgents.length > 1 || (boundAgents.length === 1 && boundAgents[0] !== 'main') - const agentBadges = showAll ? boundAgents.map(a => - `→ ${escapeAttr(a)}` - ).join(' ') : '' - return ` -
-
- ${ic} - ${label} - ${agentBadges} - + const boundAgents = allBindings.length ? allBindings.map(b => b.agentId || 'main') : ['main'] + return boundAgents.map(agentId => { + const badge = agentId !== 'main' + ? `→ ${escapeAttr(agentId)}` + : '' + return ` +
+
+ ${ic} + ${label} + ${badge} + +
+
+ + + +
-
- - - -
-
- ` + ` + }) }).join('')}
diff --git a/src/pages/chat.js b/src/pages/chat.js index a33b3a14..526df57f 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -1584,6 +1584,7 @@ function appendToolsToEl(el, tools) { summary.innerHTML = `${escapeHtml(tool.name || '工具')} · ${status}` const body = document.createElement('div') body.className = 'msg-tool-body' + body.style.display = 'none' const inputJson = safeStringify(tool.input) const outputJson = safeStringify(tool.output) const input = inputJson ? `
参数
${escapeHtml(inputJson)}
` : '' @@ -1591,6 +1592,9 @@ function appendToolsToEl(el, tools) { body.innerHTML = input + output details.appendChild(summary) details.appendChild(body) + details.addEventListener('toggle', () => { + body.style.display = details.open ? 'grid' : 'none' + }) container.appendChild(details) }) el.appendChild(container) diff --git a/src/style/chat.css b/src/style/chat.css index 52c55573..46a808d6 100644 --- a/src/style/chat.css +++ b/src/style/chat.css @@ -348,11 +348,11 @@ .chat-input-area { display: flex; align-items: flex-end; - gap: 8px; - padding: 8px 10px; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); border-top: 1px solid var(--border); flex-shrink: 0; - background: rgba(0, 0, 0, 0.15); + background: var(--bg-primary); } .chat-input-wrapper { @@ -363,12 +363,12 @@ .chat-input-wrapper textarea { width: 100%; resize: none; - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid var(--border); border-radius: var(--radius-md, 8px); - padding: 8px 12px; - font-size: 13px; - line-height: 1.4; - background: rgba(255, 255, 255, 0.04); + padding: 10px 14px; + font-size: 14px; + line-height: 1.5; + background: var(--bg-secondary, var(--bg-card)); color: var(--text-primary); outline: none; transition: border-color 0.15s; From 4b3a6f9787a9f1f49cb65303dcfd6520534fc041 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 15:15:22 +0800 Subject: [PATCH 036/426] chore: checkpoint before tool/bubble fix From 43cfe13bb63d6a2529d7e2f57582ff0003927239 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 15:20:23 +0800 Subject: [PATCH 037/426] chore: checkpoint before tool collapse hotfix From c098a6a07d1233eca781c63f33eeb97f29c6272c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 15:21:35 +0800 Subject: [PATCH 038/426] fix: tool collapse enforce --- src/pages/chat.js | 1 + src/style/chat.css | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pages/chat.js b/src/pages/chat.js index 526df57f..df2d7f8c 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -1579,6 +1579,7 @@ function appendToolsToEl(el, tools) { const details = document.createElement('details') details.className = 'msg-tool-item' details.open = false + details.removeAttribute('open') const summary = document.createElement('summary') const status = tool.status === 'error' ? '失败' : '成功' summary.innerHTML = `${escapeHtml(tool.name || '工具')} · ${status}` diff --git a/src/style/chat.css b/src/style/chat.css index 46a808d6..deb458e4 100644 --- a/src/style/chat.css +++ b/src/style/chat.css @@ -190,6 +190,7 @@ background: var(--accent); color: #fff; border-bottom-right-radius: 4px; + box-shadow: 0 6px 16px rgba(0,0,0,0.2); } .msg-ai .msg-bubble { @@ -875,12 +876,12 @@ } .msg-tool-body { margin-top: 8px; - display: none; + display: none !important; gap: 8px; } .msg-tool-item[open] > .msg-tool-body { - display: grid; + display: grid !important; } .msg-tool-block { background: var(--bg-primary); From c378e96ed0d19314c463d1f520096eac17b08c3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 15:36:21 +0800 Subject: [PATCH 039/426] chore: checkpoint before tool debug From 203a255dc69fac5cb53208f01daf6659df65a1af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 15:38:14 +0800 Subject: [PATCH 040/426] chore: add tool collapse debug --- src/pages/chat.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/pages/chat.js b/src/pages/chat.js index df2d7f8c..7bb7b973 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -1586,6 +1586,11 @@ function appendToolsToEl(el, tools) { const body = document.createElement('div') body.className = 'msg-tool-body' body.style.display = 'none' + console.debug('[chat][tool] init', { + name: tool.name, + open: details.open, + bodyDisplay: body.style.display, + }) const inputJson = safeStringify(tool.input) const outputJson = safeStringify(tool.output) const input = inputJson ? `
参数
${escapeHtml(inputJson)}
` : '' @@ -1595,10 +1600,26 @@ function appendToolsToEl(el, tools) { details.appendChild(body) details.addEventListener('toggle', () => { body.style.display = details.open ? 'grid' : 'none' + console.debug('[chat][tool] toggle', { + name: tool.name, + open: details.open, + bodyDisplay: body.style.display, + }) }) container.appendChild(details) }) el.appendChild(container) + try { + const first = container.querySelector('details') + if (first) { + const body = first.querySelector('.msg-tool-body') + console.debug('[chat][tool] after-append', { + open: first.open, + bodyDisplay: body?.style?.display, + computed: body ? getComputedStyle(body).display : null, + }) + } + } catch {} } function safeStringify(value) { From aa1afc3adcbc5973adf37feb2e2d41f5e53cdcb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 23:24:27 +0800 Subject: [PATCH 041/426] chore: checkpoint before assistant per-session queue From 65269a32c8b7081ef76c987cb21772dfec58f2af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Mon, 16 Mar 2026 23:58:23 +0800 Subject: [PATCH 042/426] feat: per-session assistant streaming --- src/pages/assistant.js | 130 ++++++++++++++++++++++++++++++----------- 1 file changed, 95 insertions(+), 35 deletions(-) diff --git a/src/pages/assistant.js b/src/pages/assistant.js index a698461d..615ba449 100644 --- a/src/pages/assistant.js +++ b/src/pages/assistant.js @@ -1104,7 +1104,9 @@ let _config = null, _sessions = [], _currentSessionId = null let _lastRenderTime = 0 let _saveThrottleTimer = null const _sessionStatus = new Map() // sessionId → 'idle' | 'streaming' | 'waiting' | 'error' -let _messageQueue = [] // [{ id, text, ts }] +const _streamingBySession = new Map() // sessionId → boolean +const _abortBySession = new Map() // sessionId → AbortController +const _queueBySession = new Map() // sessionId → [{ id, text, ts }] let _streamRefreshTimer = null // 后台流式刷新定时器 let _pendingImages = [] // [{ id, dataUrl, name, size }] 待发送图片 let _errorContext = null // 待处理的错误上下文 { scene, title, hint, error, ts } @@ -1119,6 +1121,37 @@ function throttledSave() { }, 500) } +function getStreaming(sessionId) { + return _streamingBySession.get(sessionId) === true +} + +function setStreaming(sessionId, value) { + if (!sessionId) return + if (value) _streamingBySession.set(sessionId, true) + else _streamingBySession.delete(sessionId) +} + +function getAbortController(sessionId) { + return _abortBySession.get(sessionId) || null +} + +function setAbortController(sessionId, controller) { + if (!sessionId) return + if (controller) _abortBySession.set(sessionId, controller) + else _abortBySession.delete(sessionId) +} + +function getQueue(sessionId) { + if (!sessionId) return [] + if (!_queueBySession.has(sessionId)) _queueBySession.set(sessionId, []) + return _queueBySession.get(sessionId) +} + +function setQueue(sessionId, queue) { + if (!sessionId) return + _queueBySession.set(sessionId, queue) +} + function flushSave() { if (_saveThrottleTimer) { clearTimeout(_saveThrottleTimer) @@ -1130,7 +1163,7 @@ function flushSave() { // ── 后台流式刷新 ── // 当用户切页面再回来时,轮询刷新最后一个 AI 气泡内容 function refreshStreamingBubble() { - if (!_messagesEl || !_isStreaming) return + if (!_messagesEl || !_currentSessionId || !getStreaming(_currentSessionId)) return const session = getCurrentSession() if (!session) return const lastMsg = session.messages[session.messages.length - 1] @@ -1158,13 +1191,17 @@ function stopStreamRefresh() { // ── 发送队列 ── function enqueueMessage(text) { - _messageQueue.push({ id: Date.now().toString(), text, ts: Date.now() }) + if (!_currentSessionId) return + const queue = getQueue(_currentSessionId) + queue.push({ id: Date.now().toString(), text, ts: Date.now() }) + setQueue(_currentSessionId, queue) renderQueue() } function renderQueue() { - if (!_queueEl) return - if (_messageQueue.length === 0) { + if (!_queueEl || !_currentSessionId) return + const queue = getQueue(_currentSessionId) + if (queue.length === 0) { _queueEl.innerHTML = '' _queueEl.style.display = 'none' return @@ -1175,8 +1212,8 @@ function renderQueue() { const editSvg = '' const delSvg = '' - _queueEl.innerHTML = `
${queueSvg} 发送队列 (${_messageQueue.length})
` + - _messageQueue.map((item, i) => ` + _queueEl.innerHTML = `
${queueSvg} 发送队列 (${queue.length})
` + + queue.map((item, i) => `
${i + 1} ${escHtml(item.text)} @@ -1190,8 +1227,12 @@ function renderQueue() { } function processQueue() { - if (_isStreaming || _messageQueue.length === 0) return - const next = _messageQueue.shift() + if (!_currentSessionId) return + if (getStreaming(_currentSessionId)) return + const queue = getQueue(_currentSessionId) + if (queue.length === 0) return + const next = queue.shift() + setQueue(_currentSessionId, queue) renderQueue() sendMessageDirect(next.text) } @@ -1458,7 +1499,7 @@ const TIMEOUT_TOTAL = 120_000 // 总超时 120 秒 const TIMEOUT_CHUNK = 30_000 // 流式 chunk 间隔超时 30 秒 const TIMEOUT_CONNECT = 30_000 // 连接超时 30 秒 -async function callAI(messages, onChunk) { +async function callAI(sessionId, messages, onChunk) { const apiType = normalizeApiType(_config.apiType) if (!_config.baseUrl || !_config.model || (requiresApiKey(apiType) && !_config.apiKey)) { throw new Error('请先配置 AI 模型(点击右上角设置按钮)') @@ -2034,7 +2075,7 @@ async function executeToolWithSafety(toolName, args, tcForConfirm) { } // 带工具调用的 AI 请求(非流式,用于 tool_calls 检测循环) -async function callAIWithTools(messages, onStatus, onToolProgress) { +async function callAIWithTools(sessionId, messages, onStatus, onToolProgress) { const apiType = normalizeApiType(_config.apiType) if (!_config.baseUrl || !_config.model || (requiresApiKey(apiType) && !_config.apiKey)) { throw new Error('请先配置 AI 模型(点击右上角设置按钮)') @@ -2049,7 +2090,7 @@ async function callAIWithTools(messages, onStatus, onToolProgress) { let nextPauseAt = autoRounds // 下一次暂停的轮次阈值 for (let round = 0; ; round++) { // 检查是否已被用户中止 - if (!_isStreaming || _abortController?.signal?.aborted) { + if (!sessionId || !getStreaming(sessionId) || _abortController?.signal?.aborted) { throw new DOMException('Aborted', 'AbortError') } if (autoRounds > 0 && round >= nextPauseAt) { @@ -3419,7 +3460,7 @@ function sendMessage(text) { const hasContent = text.trim() || _pendingImages.length > 0 if (!hasContent) return // 流式中 → 排队(图片不排队,提示用户) - if (_isStreaming) { + if (_currentSessionId && getStreaming(_currentSessionId)) { if (_pendingImages.length > 0) { toast('AI 正在回复中,图片消息请等待完成后再发送', 'info') return @@ -3434,7 +3475,7 @@ function sendMessage(text) { async function sendMessageDirect(text) { const hasContent = text.trim() || _pendingImages.length > 0 if (!hasContent) return - if (_isStreaming) { + if (_currentSessionId && getStreaming(_currentSessionId)) { if (_pendingImages.length > 0) { toast('请等待 AI 回复完成', 'info'); return } enqueueMessage(text.trim()) return @@ -3485,8 +3526,11 @@ async function sendMessageDirect(text) { const aiMsg = { role: 'assistant', content: '', ts: Date.now() } session.messages.push(aiMsg) - _isStreaming = true - _sendBtn.innerHTML = stopIcon() + setStreaming(session.id, true) + if (_currentSessionId === session.id) { + _isStreaming = true + _sendBtn.innerHTML = stopIcon() + } setSessionStatus(session.id, 'streaming') // 渲染流式 typing 状态 @@ -3503,7 +3547,7 @@ async function sendMessageDirect(text) { const aiMsgContainers = _messagesEl?.querySelectorAll('.ast-msg-ai') const lastContainer = aiMsgContainers?.[aiMsgContainers.length - 1] - const result = await callAIWithTools(contextMessages, + const result = await callAIWithTools(session.id, contextMessages, // onStatus (status) => { if (lastBubble) lastBubble.innerHTML = `${escHtml(status)}` @@ -3527,7 +3571,7 @@ async function sendMessageDirect(text) { renderMessages() } else { // ── 普通流式模式 ── - await callAI(contextMessages, (chunk) => { + await callAI(session.id, contextMessages, (chunk) => { aiMsg.content += chunk throttledSave() // 实时保存每个 chunk if (lastBubble) { @@ -3592,11 +3636,14 @@ async function sendMessageDirect(text) { }) } } finally { - _isStreaming = false + setStreaming(session.id, false) + if (_currentSessionId === session.id) { + _isStreaming = false + stopStreamRefresh() + if (_sendBtn) _sendBtn.innerHTML = sendIcon() + if (_textarea) _textarea.focus() + } _abortController = null - stopStreamRefresh() - if (_sendBtn) _sendBtn.innerHTML = sendIcon() - if (_textarea) _textarea.focus() session.updatedAt = Date.now() flushSave() if (getSessionStatus(session.id) !== 'error') { @@ -3613,7 +3660,8 @@ async function sendMessageDirect(text) { // 重试 AI 响应(不重复添加用户消息) async function retryAIResponse(session) { - if (_isStreaming) return + if (!session?.id) return + if (getStreaming(session.id)) return const contextMessages = session.messages .filter(m => m.role === 'user' || m.role === 'assistant') @@ -3622,8 +3670,11 @@ async function retryAIResponse(session) { const aiMsg = { role: 'assistant', content: '', ts: Date.now() } session.messages.push(aiMsg) - _isStreaming = true - if (_sendBtn) _sendBtn.innerHTML = stopIcon() + setStreaming(session.id, true) + if (_currentSessionId === session.id) { + _isStreaming = true + if (_sendBtn) _sendBtn.innerHTML = stopIcon() + } setSessionStatus(session.id, 'streaming') renderMessages() @@ -3638,7 +3689,7 @@ async function retryAIResponse(session) { const aiMsgContainers = _messagesEl?.querySelectorAll('.ast-msg-ai') const lastContainer = aiMsgContainers?.[aiMsgContainers.length - 1] - const result = await callAIWithTools(contextMessages, + const result = await callAIWithTools(session.id, contextMessages, (status) => { if (lastBubble) lastBubble.innerHTML = `${escHtml(status)}` }, (history) => { aiMsg.toolHistory = history @@ -3654,7 +3705,7 @@ async function retryAIResponse(session) { if (result.toolHistory.length > 0) aiMsg.toolHistory = result.toolHistory renderMessages() } else { - await callAI(contextMessages, (chunk) => { + await callAI(session.id, contextMessages, (chunk) => { aiMsg.content += chunk throttledSave() if (lastBubble) { @@ -3708,11 +3759,14 @@ async function retryAIResponse(session) { }) } } finally { - _isStreaming = false + setStreaming(session.id, false) + if (_currentSessionId === session.id) { + _isStreaming = false + stopStreamRefresh() + if (_sendBtn) _sendBtn.innerHTML = sendIcon() + if (_textarea) _textarea.focus() + } _abortController = null - stopStreamRefresh() - if (_sendBtn) _sendBtn.innerHTML = sendIcon() - if (_textarea) _textarea.focus() session.updatedAt = Date.now() flushSave() if (getSessionStatus(session.id) !== 'error') { @@ -3727,6 +3781,9 @@ async function retryAIResponse(session) { } function stopStreaming() { + if (_currentSessionId) { + setStreaming(_currentSessionId, false) + } _isStreaming = false if (_abortController) { _abortController.abort() @@ -3925,7 +3982,8 @@ export async function render() { requestAnimationFrame(() => positionModeSlider(page, currentMode())) // 如果有后台流式正在进行,恢复 UI 状态 - if (_isStreaming) { + if (_currentSessionId && getStreaming(_currentSessionId)) { + _isStreaming = true _sendBtn.innerHTML = stopIcon() startStreamRefresh() } @@ -3966,7 +4024,7 @@ export async function render() { // 发送(流式中输入排队,空输入时点按钮停止流式) _sendBtn.addEventListener('click', () => { - if (_isStreaming && !_textarea.value.trim() && _pendingImages.length === 0) { stopStreaming(); return } + if (_currentSessionId && getStreaming(_currentSessionId) && !_textarea.value.trim() && _pendingImages.length === 0) { stopStreaming(); return } if (_textarea.value.trim() || _pendingImages.length > 0) { sendMessage(_textarea.value) _textarea.value = '' @@ -4041,7 +4099,7 @@ export async function render() { if (idx === -1) return const item = _messageQueue.splice(idx, 1)[0] renderQueue() - if (_isStreaming) stopStreaming() + if (_currentSessionId && getStreaming(_currentSessionId)) stopStreaming() setTimeout(() => sendMessageDirect(item.text), 150) return } @@ -4136,9 +4194,11 @@ export async function render() { renderSessionList() renderMessages() // 切换到正在流式的会话时,启动刷新 - if (_isStreaming && getSessionStatus(_currentSessionId) === 'streaming') { + if (_currentSessionId && getStreaming(_currentSessionId) && getSessionStatus(_currentSessionId) === 'streaming') { + _isStreaming = true startStreamRefresh() } else { + _isStreaming = false stopStreamRefresh() } } From 79f19dbe52350962f25cfc6ec6ee92e6382c8773 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 00:18:07 +0800 Subject: [PATCH 043/426] docs: add skillhub env fix design --- .../2026-03-17-skillhub-env-fix-design.md | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 docs/plans/2026-03-17-skillhub-env-fix-design.md diff --git a/docs/plans/2026-03-17-skillhub-env-fix-design.md b/docs/plans/2026-03-17-skillhub-env-fix-design.md new file mode 100644 index 00000000..cbe492b3 --- /dev/null +++ b/docs/plans/2026-03-17-skillhub-env-fix-design.md @@ -0,0 +1,65 @@ +# SkillHub 动态探测与系统环境变量继承设计 + +日期: 2026-03-17 + +## 背景与目标 +- 现象: 页面提示已安装 SkillHub CLI,但检查仍显示未安装 +- 根因: ClawPanel 进程环境变量未刷新或 PATH 不完整,导致技能检测失败 +- 目标: + 1) SkillHub CLI 检测支持动态探测路径并返回版本与命中路径 + 2) 所有 Tauri 命令执行时继承完整系统环境变量(用户 + 系统) + +## 范围 +- 仅修改 ClawPanel Tauri 后端命令执行环境与 SkillHub 检测逻辑 +- 前端仅增加路径展示(若返回 path) + +## 方案对比 +### 方案 A +- 仅在 SkillHub 检测执行 `where skillhub` 进行路径探测 +- 其他命令仍使用当前进程环境 +- 缺点: 无法解决晴辰助手命令缺少系统环境变量的问题 + +### 方案 B(推荐) +- 增加统一系统环境构建函数,合并进程 env + Windows 用户/系统 env +- 所有 Tauri 命令使用该环境执行 +- SkillHub 检测失败时 `where skillhub` 探测并返回路径 +- 优点: 满足所有需求,改动集中 + +### 方案 C +- 增加 envPolicy 配置项(inherit/system/whitelist) +- 需要新 UI 与配置逻辑 +- 超出当前范围 + +## 设计细节 +### 1) 系统环境变量合并 +- 新增 `build_system_env()`: + - 读取当前进程 env + - 读取注册表: + - HKCU\Environment + - HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment + - 合并优先级: 进程 env 覆盖系统 env + - PATH 合并去重(用户 + 系统 + 进程) + +### 2) SkillHub 动态探测 +- `skills_skillhub_check` 流程: + 1) 直接尝试 `skillhub --version` + 2) 失败则执行 `where skillhub` + 3) 若命中路径,使用该路径执行 `--version` + 4) 返回 `{ installed: true, version, path }` + +### 3) 统一命令执行环境 +- 所有 `tokio::process::Command` 调用统一使用 `cmd.envs(build_system_env())` +- 现有 `enhanced_path()` 可改为调用 `build_system_env()` 或保留但不再使用 + +### 4) 前端展示 +- Skills 页面展示安装路径(若后端返回 path) +- 状态提示保持一致 + +## 错误处理 +- 注册表读取失败时回退到当前进程 env +- `where skillhub` 失败仍返回 `installed: false` + +## 测试要点 +- PATH 未刷新时: `skills_skillhub_check` 仍能识别已安装 +- 晴辰助手执行命令继承系统变量(如 PATH / HTTP_PROXY) +- UI 能展示 path 与版本 From a7d9f4d0f2d83a31755f4071f4ffbdecace1b32e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 00:20:40 +0800 Subject: [PATCH 044/426] docs: add skillhub env fix plan --- .../plans/2026-03-17-skillhub-env-fix.md | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-17-skillhub-env-fix.md diff --git a/docs/superpowers/plans/2026-03-17-skillhub-env-fix.md b/docs/superpowers/plans/2026-03-17-skillhub-env-fix.md new file mode 100644 index 00000000..f069849b --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-skillhub-env-fix.md @@ -0,0 +1,102 @@ +# SkillHub 动态探测与系统环境变量继承 Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让 ClawPanel 通过动态探测识别 SkillHub CLI,并让所有 Tauri 命令继承完整系统环境变量(用户 + 系统)。 + +**Architecture:** 在 Tauri 后端新增统一“系统环境构建函数”,所有命令执行时统一注入 envs;SkillHub 检测失败时走 where 探测并返回命中路径。 + +**Tech Stack:** Rust (Tauri), Windows Registry, tokio::process::Command + +--- + +## Chunk 1: 环境变量合并工具函数 + +### Task 1: 新增系统环境合并函数 +**Files:** +- Modify: `src-tauri/src/utils.rs` +- Modify: `src-tauri/src/commands/mod.rs`(如已有 enhanced_path 需更新) +- Test: (无自动测试,手工验证) + +- [ ] **Step 1: 设计合并逻辑并落地函数** + +新增 `build_system_env()`,返回 `Vec<(String, String)>`,包含: +- 当前进程 env +- 用户 env(HKCU\Environment) +- 系统 env(HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment) + +PATH 处理:系统 + 用户 + 进程,去重拼接。 + +示例(伪代码): +```rust +pub fn build_system_env() -> Vec<(String, String)> { + let mut env_map = HashMap::new(); + // 1) 读取系统 + 用户 env 并写入 + // 2) 读取进程 env 覆盖 + // 3) PATH 合并去重 + env_map.into_iter().collect() +} +``` + +- [ ] **Step 2: 在 commands 统一使用 build_system_env** + +将 `enhanced_path()` 替换或改为使用 `build_system_env()`,确保所有命令执行时统一 `cmd.envs(build_system_env())`。 + +- [ ] **Step 3: 提交** +```bash +git add src-tauri/src/utils.rs src-tauri/src/commands/mod.rs +git commit -m "feat: inherit full system env for commands" +``` + +--- + +## Chunk 2: SkillHub 动态探测与返回路径 + +### Task 2: SkillHub 检测增强 +**Files:** +- Modify: `src-tauri/src/commands/skills.rs` +- Modify: `src/lib/tauri-api.js`(若返回字段变化) +- Modify: `src/pages/skills.js` + +- [ ] **Step 1: 更新 skills_skillhub_check** + +流程: +1) `skillhub --version` +2) 若失败,执行 `where skillhub` +3) 取第一条路径,执行 ` --version` +4) 返回 `{ installed: true, version, path }` + +- [ ] **Step 2: 更新前端展示** + +Skills 页面展示 path(若存在): +- `#skillhub-status` 增加 “路径: xxx” +- 仍显示版本号 + +- [ ] **Step 3: 提交** +```bash +git add src-tauri/src/commands/skills.rs src/lib/tauri-api.js src/pages/skills.js +git commit -m "feat: detect skillhub by path and show location" +``` + +--- + +## Chunk 3: 手工验证与构建 + +### Task 3: 验证与构建 +**Files:** +- 无 + +- [ ] **Step 1: 前端构建** +```bash +npm run build +``` + +- [ ] **Step 2: 手工验证要点** +- SkillHub CLI 安装后,未重启 ClawPanel 仍能识别 installed=true +- UI 能显示 version + path +- 晴辰助手执行命令时继承系统变量(如 PATH / HTTP_PROXY) + +- [ ] **Step 3: 代码汇总与推送** +```bash +git push +``` From f9c365513e0bdab6dd65e7624df55470b8d9c715 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 00:20:46 +0800 Subject: [PATCH 045/426] chore: checkpoint before env inherit From aeb1754927b425ef19f4f5270da08b59f086fd19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 00:55:07 +0800 Subject: [PATCH 046/426] feat: inherit system env and keep streams running --- src-tauri/Cargo.lock | 79 ++++++++++- src-tauri/Cargo.toml | 1 + src-tauri/src/commands/assistant.rs | 20 +-- src-tauri/src/commands/config.rs | 8 +- src-tauri/src/commands/gateway_patch.rs | 2 +- src-tauri/src/commands/mod.rs | 169 +++++++++++++++++++++++- src-tauri/src/commands/service.rs | 27 ++-- src-tauri/src/commands/skills.rs | 136 +++++++++++++------ src-tauri/src/lib.rs | 4 + src-tauri/src/utils.rs | 12 +- src/pages/assistant.js | 1 - src/pages/skills.js | 23 +++- 12 files changed, 400 insertions(+), 82 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ace040fe..0f3a58b5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -345,6 +345,7 @@ dependencies = [ "tauri-plugin-shell", "tokio", "urlencoding", + "winreg 0.52.0", "zip", ] @@ -759,7 +760,7 @@ dependencies = [ "rustc_version", "toml 0.9.12+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -4895,6 +4896,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -4946,6 +4956,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -5003,6 +5028,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5021,6 +5052,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -5039,6 +5076,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5069,6 +5112,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -5087,6 +5136,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -5105,6 +5160,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -5123,6 +5184,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5153,6 +5220,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winreg" version = "0.55.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9ec1b99c..fd9a7995 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -31,3 +31,4 @@ base64 = "0.22" urlencoding = "2" regex = "1" tokio = { version = "1", features = ["process", "time"] } +winreg = "0.52" diff --git a/src-tauri/src/commands/assistant.rs b/src-tauri/src/commands/assistant.rs index b1f578cd..46170daf 100644 --- a/src-tauri/src/commands/assistant.rs +++ b/src-tauri/src/commands/assistant.rs @@ -149,11 +149,13 @@ pub async fn assistant_exec(command: String, cwd: Option) -> Result) -> Result Command { const CREATE_NO_WINDOW: u32 = 0x08000000; let mut cmd = Command::new("cmd"); cmd.args(["/c", "npm", "--registry", ®istry]); - cmd.env("PATH", super::enhanced_path()); + crate::commands::apply_system_env(&mut cmd); crate::commands::apply_proxy_env(&mut cmd); cmd.creation_flags(CREATE_NO_WINDOW); cmd @@ -209,7 +209,7 @@ fn npm_command() -> Command { { let mut cmd = Command::new("npm"); cmd.args(["--registry", ®istry]); - cmd.env("PATH", super::enhanced_path()); + crate::commands::apply_system_env(&mut cmd); crate::commands::apply_proxy_env(&mut cmd); cmd } @@ -226,7 +226,7 @@ fn npm_command() -> Command { c.args(["--registry", ®istry]); c }; - cmd.env("PATH", super::enhanced_path()); + crate::commands::apply_system_env(&mut cmd); crate::commands::apply_proxy_env(&mut cmd); cmd } @@ -1332,7 +1332,7 @@ pub fn check_node() -> Result { let mut result = serde_json::Map::new(); let mut cmd = Command::new("node"); cmd.arg("--version"); - cmd.env("PATH", super::enhanced_path()); + crate::commands::apply_system_env(&mut cmd); #[cfg(target_os = "windows")] cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW match cmd.output() { diff --git a/src-tauri/src/commands/gateway_patch.rs b/src-tauri/src/commands/gateway_patch.rs index b61df0a0..9a854b28 100644 --- a/src-tauri/src/commands/gateway_patch.rs +++ b/src-tauri/src/commands/gateway_patch.rs @@ -152,7 +152,7 @@ fn npm_root_global() -> Result { c.args(["root", "-g"]); c }; - cmd.env("PATH", crate::commands::enhanced_path()); + crate::commands::apply_system_env(&mut cmd); crate::commands::apply_proxy_env(&mut cmd); #[cfg(target_os = "windows")] cmd.creation_flags(CREATE_NO_WINDOW); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 87f6a25d..2a5e85f6 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,8 +1,14 @@ +use std::collections::{HashMap, HashSet}; use std::net::IpAddr; use std::path::PathBuf; use std::sync::RwLock; use std::time::Duration; +#[cfg(target_os = "windows")] +use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, RegType, KEY_READ}; +#[cfg(target_os = "windows")] +use winreg::RegKey; + pub mod agent; pub mod assistant; pub mod cloudflared; @@ -141,6 +147,161 @@ pub fn apply_proxy_env_tokio(cmd: &mut tokio::process::Command) { } } +pub fn apply_system_env(cmd: &mut std::process::Command) { + cmd.envs(build_system_env()); +} + +pub fn apply_system_env_tokio(cmd: &mut tokio::process::Command) { + cmd.envs(build_system_env()); +} + +fn merge_path_parts(parts: Vec, sep: char) -> String { + let mut seen: HashSet = HashSet::new(); + let mut out: Vec = Vec::new(); + for part in parts { + for seg in part.split(sep) { + let trimmed = seg.trim(); + if trimmed.is_empty() { + continue; + } + let key = trimmed.to_ascii_lowercase(); + if seen.insert(key) { + out.push(trimmed.to_string()); + } + } + } + out.join(&sep.to_string()) +} + +#[cfg(target_os = "windows")] +fn decode_reg_utf16(bytes: &[u8]) -> String { + let mut u16s: Vec = Vec::with_capacity(bytes.len() / 2); + for chunk in bytes.chunks(2) { + if chunk.len() == 2 { + u16s.push(u16::from_le_bytes([chunk[0], chunk[1]])); + } + } + while let Some(&0) = u16s.last() { + u16s.pop(); + } + String::from_utf16_lossy(&u16s) +} + +#[cfg(target_os = "windows")] +fn read_registry_env(hkey: RegKey, subkey: &str) -> Vec<(String, String, RegType)> { + let key = match hkey.open_subkey_with_flags(subkey, KEY_READ) { + Ok(k) => k, + Err(_) => return Vec::new(), + }; + let mut entries = Vec::new(); + for item in key.enum_values().flatten() { + let (name, value) = item; + let vtype = value.vtype; + if vtype == RegType::REG_SZ || vtype == RegType::REG_EXPAND_SZ { + let text = decode_reg_utf16(&value.bytes); + entries.push((name, text, vtype)); + } + } + entries +} + +#[cfg(target_os = "windows")] +fn expand_env_vars(value: &str, env_map: &HashMap) -> String { + let mut output = value.to_string(); + for _ in 0..5 { + let mut chars: Vec = output.chars().collect(); + let mut i = 0usize; + let mut changed = false; + let mut result = String::new(); + while i < chars.len() { + if chars[i] == '%' { + let mut j = i + 1; + while j < chars.len() && chars[j] != '%' { + j += 1; + } + if j < chars.len() && chars[j] == '%' { + let key: String = chars[i + 1..j].iter().collect(); + let lookup = key.to_ascii_uppercase(); + if let Some(repl) = env_map.get(&lookup) { + result.push_str(repl); + changed = true; + } + i = j + 1; + continue; + } + } + result.push(chars[i]); + i += 1; + } + output = result; + if !changed { + break; + } + } + output +} + +pub fn build_system_env() -> Vec<(String, String)> { + #[cfg(target_os = "windows")] + { + let system_entries = read_registry_env( + RegKey::predef(HKEY_LOCAL_MACHINE), + r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment", + ); + let user_entries = read_registry_env(RegKey::predef(HKEY_CURRENT_USER), r"Environment"); + + let mut map: HashMap = HashMap::new(); + for (k, v, _) in system_entries.iter() { + map.insert(k.to_ascii_uppercase(), v.clone()); + } + for (k, v, _) in user_entries.iter() { + map.insert(k.to_ascii_uppercase(), v.clone()); + } + + for (k, v, t) in system_entries.iter().chain(user_entries.iter()) { + if *t == RegType::REG_EXPAND_SZ { + let key = k.to_ascii_uppercase(); + let expanded = expand_env_vars(v, &map); + map.insert(key, expanded); + } + } + + for (k, v) in std::env::vars() { + map.insert(k.to_ascii_uppercase(), v); + } + + let mut path_parts: Vec = Vec::new(); + for (k, v, _) in system_entries.iter() { + if k.eq_ignore_ascii_case("PATH") { + path_parts.push(v.clone()); + } + } + for (k, v, _) in user_entries.iter() { + if k.eq_ignore_ascii_case("PATH") { + path_parts.push(v.clone()); + } + } + if let Ok(process_path) = std::env::var("PATH") { + path_parts.push(process_path); + } + + let base_path = merge_path_parts(path_parts, ';'); + let enhanced = build_enhanced_path_with_base(&base_path); + map.insert("PATH".to_string(), enhanced); + + map.into_iter().collect() + } + + #[cfg(not(target_os = "windows"))] + { + let mut map: HashMap = std::env::vars().collect(); + let base = map.get("PATH").cloned().unwrap_or_default(); + let enhanced = build_enhanced_path_with_base(&base); + map.insert("PATH".to_string(), enhanced); + map.into_iter().collect() + } +} + /// 缓存 enhanced_path 结果,避免每次调用都扫描文件系统 /// 使用 RwLock 替代 OnceLock,支持运行时刷新缓存 static ENHANCED_PATH_CACHE: RwLock> = RwLock::new(None); @@ -158,7 +319,7 @@ pub fn enhanced_path() -> String { } } // 缓存为空,重新构建 - let path = build_enhanced_path(); + let path = build_enhanced_path_with_base(&std::env::var("PATH").unwrap_or_default()); if let Ok(mut guard) = ENHANCED_PATH_CACHE.write() { *guard = Some(path.clone()); } @@ -167,14 +328,14 @@ pub fn enhanced_path() -> String { /// 刷新 enhanced_path 缓存,使新设置的 Node.js 路径立即生效(无需重启应用) pub fn refresh_enhanced_path() { - let new_path = build_enhanced_path(); + let new_path = build_enhanced_path_with_base(&std::env::var("PATH").unwrap_or_default()); if let Ok(mut guard) = ENHANCED_PATH_CACHE.write() { *guard = Some(new_path); } } -fn build_enhanced_path() -> String { - let current = std::env::var("PATH").unwrap_or_default(); +fn build_enhanced_path_with_base(base_path: &str) -> String { + let current = base_path.to_string(); let home = dirs::home_dir().unwrap_or_default(); // 读取用户保存的自定义 Node.js 路径 diff --git a/src-tauri/src/commands/service.rs b/src-tauri/src/commands/service.rs index 52f3cfc6..e1d5825d 100644 --- a/src-tauri/src/commands/service.rs +++ b/src-tauri/src/commands/service.rs @@ -459,10 +459,10 @@ mod platform { let mut cmd = Command::new("openclaw"); cmd.arg("gateway") - .env("PATH", &enhanced) .stdin(std::process::Stdio::null()) .stdout(stdout_log) .stderr(stderr_log); + crate::commands::apply_system_env(&mut cmd); crate::commands::apply_proxy_env(&mut cmd); cmd.spawn().map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { @@ -691,7 +691,7 @@ mod platform { // 方式2: 通过 where 查找(兼容 nvm、自定义 prefix 等) let mut where_cmd = std::process::Command::new("where"); where_cmd.arg("openclaw"); - where_cmd.env("PATH", crate::commands::enhanced_path()); + crate::commands::apply_system_env(&mut where_cmd); where_cmd.creation_flags(CREATE_NO_WINDOW); if let Ok(o) = where_cmd.output() { if o.status.success() && !String::from_utf8_lossy(&o.stdout).trim().is_empty() { @@ -702,7 +702,7 @@ mod platform { // 方式3: 直接执行版本命令兜底 let mut cmd = std::process::Command::new("cmd"); cmd.args(["/c", "openclaw", "--version"]); - cmd.env("PATH", crate::commands::enhanced_path()); + crate::commands::apply_system_env(&mut cmd); cmd.creation_flags(CREATE_NO_WINDOW); if let Ok(o) = cmd.output() { if o.status.success() { @@ -880,16 +880,15 @@ mod platform { )); } - let enhanced = crate::commands::enhanced_path(); let (stdout_log, stderr_log) = create_gateway_log_files()?; let mut cmd = std::process::Command::new("cmd"); cmd.args(["/c", "openclaw", "gateway"]) - .env("PATH", &enhanced) .creation_flags(CREATE_NO_WINDOW) .stdin(Stdio::null()) .stdout(stdout_log) .stderr(stderr_log); + crate::commands::apply_system_env(&mut cmd); crate::commands::apply_proxy_env(&mut cmd); cmd.spawn().map_err(|e| format!("启动 Gateway 失败: {e}"))?; @@ -987,10 +986,10 @@ mod platform { } pub async fn is_cli_installed() -> bool { - Command::new("openclaw") - .arg("--version") - .env("PATH", crate::commands::enhanced_path()) - .output() + let mut cmd = Command::new("openclaw"); + cmd.arg("--version"); + crate::commands::apply_system_env(&mut cmd); + cmd.output() .await .map(|o| o.status.success()) .unwrap_or(false) @@ -1030,12 +1029,10 @@ mod platform { ) { Ok(_) => (true, None), Err(_) => { - if let Ok(output) = Command::new("openclaw") - .arg("health") - .env("PATH", crate::commands::enhanced_path()) - .output() - .await - { + let mut cmd = Command::new("openclaw"); + cmd.arg("health"); + crate::commands::apply_system_env(&mut cmd); + if let Ok(output) = cmd.output().await { let text = String::from_utf8_lossy(&output.stdout); if output.status.success() && !text.contains("not running") { return (true, None); diff --git a/src-tauri/src/commands/skills.rs b/src-tauri/src/commands/skills.rs index ad49d871..8fd8b1a8 100644 --- a/src-tauri/src/commands/skills.rs +++ b/src-tauri/src/commands/skills.rs @@ -66,7 +66,6 @@ pub async fn skills_check() -> Result { /// 安装 Skill 依赖(根据 install spec 执行 brew/npm/go/uv/download) #[tauri::command] pub async fn skills_install_dep(kind: String, spec: Value) -> Result { - let path_env = super::enhanced_path(); let (program, args) = match kind.as_str() { "brew" => { @@ -111,7 +110,8 @@ pub async fn skills_install_dep(kind: String, spec: Value) -> Result Result Result { - let path_env = super::enhanced_path(); #[cfg(target_os = "windows")] let mut cmd = { let mut c = tokio::process::Command::new("cmd"); @@ -154,20 +153,88 @@ pub async fn skills_skillhub_check() -> Result { c.arg("--version"); c }; - cmd.env("PATH", &path_env); + super::apply_system_env_tokio(&mut cmd); match cmd.output().await { Ok(o) if o.status.success() => { let ver = String::from_utf8_lossy(&o.stdout).trim().to_string(); Ok(serde_json::json!({ "installed": true, "version": ver })) } - _ => Ok(serde_json::json!({ "installed": false })), + _ => { + #[cfg(target_os = "windows")] + { + let mut where_cmd = tokio::process::Command::new("cmd"); + where_cmd.args(["/c", "where", "skillhub"]); + where_cmd.creation_flags(0x08000000); + super::apply_system_env_tokio(&mut where_cmd); + if let Ok(out) = where_cmd.output().await { + if out.status.success() { + let text = String::from_utf8_lossy(&out.stdout).to_string(); + if let Some(first) = text.lines().find(|l| !l.trim().is_empty()) { + let path = first.trim().to_string(); + let mut ver_cmd = tokio::process::Command::new("cmd"); + ver_cmd.args(["/c", &path, "--version"]); + ver_cmd.creation_flags(0x08000000); + super::apply_system_env_tokio(&mut ver_cmd); + if let Ok(ver_out) = ver_cmd.output().await { + if ver_out.status.success() { + let ver = String::from_utf8_lossy(&ver_out.stdout) + .trim() + .to_string(); + return Ok(serde_json::json!({ + "installed": true, + "version": ver, + "path": path + })); + } + } + return Ok(serde_json::json!({ + "installed": true, + "path": path + })); + } + } + } + } + #[cfg(not(target_os = "windows"))] + { + let mut which_cmd = tokio::process::Command::new("sh"); + which_cmd.args(["-c", "which skillhub"]); + super::apply_system_env_tokio(&mut which_cmd); + if let Ok(out) = which_cmd.output().await { + if out.status.success() { + let path = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !path.is_empty() { + let mut ver_cmd = tokio::process::Command::new(&path); + ver_cmd.arg("--version"); + super::apply_system_env_tokio(&mut ver_cmd); + if let Ok(ver_out) = ver_cmd.output().await { + if ver_out.status.success() { + let ver = String::from_utf8_lossy(&ver_out.stdout) + .trim() + .to_string(); + return Ok(serde_json::json!({ + "installed": true, + "version": ver, + "path": path + })); + } + } + return Ok(serde_json::json!({ + "installed": true, + "path": path + })); + } + } + } + } + Ok(serde_json::json!({ "installed": false })) + } } } /// 安装 SkillHub CLI(从腾讯云 COS 下载) #[tauri::command] pub async fn skills_skillhub_setup(cli_only: bool) -> Result { - let path_env = super::enhanced_path(); #[allow(unused_variables)] let flag = if cli_only { "--cli-only" @@ -180,8 +247,8 @@ pub async fn skills_skillhub_setup(cli_only: bool) -> Result { let mut cmd = tokio::process::Command::new("bash"); cmd.args(["-c", &format!( "curl -fsSL https://skillhub-1388575217.cos.ap-guangzhou.myqcloud.com/install/install.sh | bash -s -- {flag}" - )]) - .env("PATH", &path_env); + )]); + super::apply_system_env_tokio(&mut cmd); super::apply_proxy_env_tokio(&mut cmd); let output = cmd .output() @@ -206,8 +273,8 @@ pub async fn skills_skillhub_setup(cli_only: bool) -> Result { "skillhub@latest", "--registry", "https://registry.npmmirror.com", - ]) - .env("PATH", &path_env); + ]); + super::apply_system_env_tokio(&mut cmd); super::apply_proxy_env_tokio(&mut cmd); cmd.creation_flags(0x08000000); let output = cmd @@ -226,7 +293,6 @@ pub async fn skills_skillhub_setup(cli_only: bool) -> Result { /// 从 SkillHub 安装 Skill(skillhub install ) #[tauri::command] pub async fn skills_skillhub_install(slug: String) -> Result { - let path_env = super::enhanced_path(); let home = dirs::home_dir().unwrap_or_default(); let skills_dir = super::openclaw_dir().join("skills"); @@ -247,7 +313,8 @@ pub async fn skills_skillhub_install(slug: String) -> Result { c.args(["install", &slug, "--force"]); c }; - cmd.env("PATH", &path_env).current_dir(&home); + cmd.current_dir(&home); + super::apply_system_env_tokio(&mut cmd); super::apply_proxy_env_tokio(&mut cmd); let output = cmd .output() @@ -276,7 +343,6 @@ pub async fn skills_skillhub_search(query: String) -> Result { return Ok(Value::Array(vec![])); } - let path_env = super::enhanced_path(); #[cfg(target_os = "windows")] let mut cmd = { let mut c = tokio::process::Command::new("cmd"); @@ -290,7 +356,7 @@ pub async fn skills_skillhub_search(query: String) -> Result { c.args(["search", &q]); c }; - cmd.env("PATH", &path_env); + super::apply_system_env_tokio(&mut cmd); super::apply_proxy_env_tokio(&mut cmd); let output = cmd .output() @@ -331,29 +397,24 @@ pub async fn skills_skillhub_search(query: String) -> Result { continue; } - // 描述在下一行:跳过数字、⬇、⭐ 等统计信息,提取文字描述 + // 描述在下一行:跳过数字与统计字段,提取文字描述 let mut desc = String::new(); if i + 1 < lines.len() { let next = lines[i + 1].trim(); - // 找到第一个英文或中文字母开始的描述文字 - // 格式: "AI 85 ⬇ 33 ⭐ 248.7k Feishu document..." - // 或: "⬇ 0 ⭐ 212.2k Feishu document..." - // 策略:找 ⭐ 后面的数字后的文字 - if let Some(star_pos) = next.find('⭐') { - let after_star = &next[star_pos + '⭐'.len_utf8()..].trim_start(); - // 跳过星标数字(如 "248.7k") - let after_num = after_star - .trim_start_matches(|c: char| { - c.is_ascii_digit() - || c == '.' - || c == 'k' - || c == 'K' - || c == 'm' - || c == 'M' - }) - .trim(); - if !after_num.is_empty() { - desc = after_num.to_string(); + // 策略:找到首个英文或中文字符作为描述起点 + let mut start_idx = None; + for (idx, ch) in next.char_indices() { + if ch.is_ascii_alphabetic() + || (ch >= '\u{4E00}' && ch <= '\u{9FFF}') + { + start_idx = Some(idx); + break; + } + } + if let Some(idx) = start_idx { + let after = next[idx..].trim(); + if !after.is_empty() { + desc = after.to_string(); } } } @@ -375,7 +436,6 @@ pub async fn skills_clawhub_search(query: String) -> Result { if q.is_empty() { return Ok(Value::Array(vec![])); } - let path_env = super::enhanced_path(); #[cfg(target_os = "windows")] let mut cmd = { let mut c = tokio::process::Command::new("cmd"); @@ -389,7 +449,7 @@ pub async fn skills_clawhub_search(query: String) -> Result { c.args(["-y", "clawhub", "search", &q]); c }; - cmd.env("PATH", &path_env); + super::apply_system_env_tokio(&mut cmd); super::apply_proxy_env_tokio(&mut cmd); let output = cmd .output() @@ -418,7 +478,6 @@ pub async fn skills_clawhub_search(query: String) -> Result { /// 从 ClawHub 安装 Skill(npx clawhub install )— 原版海外源 #[tauri::command] pub async fn skills_clawhub_install(slug: String) -> Result { - let path_env = super::enhanced_path(); let home = dirs::home_dir().unwrap_or_default(); let skills_dir = super::openclaw_dir().join("skills"); if !skills_dir.exists() { @@ -437,7 +496,8 @@ pub async fn skills_clawhub_install(slug: String) -> Result { c.args(["-y", "clawhub", "install", &slug]); c }; - cmd.env("PATH", &path_env).current_dir(&home); + cmd.current_dir(&home); + super::apply_system_env_tokio(&mut cmd); super::apply_proxy_env_tokio(&mut cmd); let output = cmd .output() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9a899481..be7259aa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,10 @@ use commands::{ }; pub fn run() { + for (k, v) in commands::build_system_env() { + std::env::set_var(k, v); + } + let hot_update_dir = commands::openclaw_dir() .join("clawpanel") .join("web-update"); diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index aba48cb0..8ca316bb 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -27,7 +27,7 @@ pub fn openclaw_command() -> std::process::Command { 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); + crate::commands::apply_system_env(&mut cmd); crate::commands::apply_proxy_env(&mut cmd); cmd.creation_flags(CREATE_NO_WINDOW); return cmd; @@ -35,7 +35,7 @@ pub fn openclaw_command() -> std::process::Command { // 兜底:直接用 cmd /c openclaw let mut cmd = std::process::Command::new("cmd"); cmd.arg("/c").arg("openclaw"); - cmd.env("PATH", &enhanced); + crate::commands::apply_system_env(&mut cmd); crate::commands::apply_proxy_env(&mut cmd); cmd.creation_flags(CREATE_NO_WINDOW); cmd @@ -43,7 +43,7 @@ pub fn openclaw_command() -> std::process::Command { #[cfg(not(target_os = "windows"))] { let mut cmd = std::process::Command::new("openclaw"); - cmd.env("PATH", crate::commands::enhanced_path()); + crate::commands::apply_system_env(&mut cmd); crate::commands::apply_proxy_env(&mut cmd); cmd } @@ -59,7 +59,7 @@ pub fn openclaw_command_async() -> tokio::process::Command { 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); + crate::commands::apply_system_env_tokio(&mut cmd); crate::commands::apply_proxy_env_tokio(&mut cmd); cmd.creation_flags(CREATE_NO_WINDOW); return cmd; @@ -67,7 +67,7 @@ pub fn openclaw_command_async() -> tokio::process::Command { // 兜底 let mut cmd = tokio::process::Command::new("cmd"); cmd.arg("/c").arg("openclaw"); - cmd.env("PATH", &enhanced); + crate::commands::apply_system_env_tokio(&mut cmd); crate::commands::apply_proxy_env_tokio(&mut cmd); cmd.creation_flags(CREATE_NO_WINDOW); cmd @@ -75,7 +75,7 @@ pub fn openclaw_command_async() -> tokio::process::Command { #[cfg(not(target_os = "windows"))] { let mut cmd = tokio::process::Command::new("openclaw"); - cmd.env("PATH", crate::commands::enhanced_path()); + crate::commands::apply_system_env_tokio(&mut cmd); crate::commands::apply_proxy_env_tokio(&mut cmd); cmd } diff --git a/src/pages/assistant.js b/src/pages/assistant.js index 615ba449..89c01797 100644 --- a/src/pages/assistant.js +++ b/src/pages/assistant.js @@ -4240,7 +4240,6 @@ function autoResize(textarea) { export function cleanup() { flushSave() - stopStreaming() stopStreamRefresh() _pendingImages = [] _page = null diff --git a/src/pages/skills.js b/src/pages/skills.js index 8532082f..3de6e38e 100644 --- a/src/pages/skills.js +++ b/src/pages/skills.js @@ -362,13 +362,13 @@ async function handleSkillHubSetup(page) { try { await api.skillsSkillHubSetup(true) toast('SkillHub CLI 安装成功', 'success') - if (statusEl) statusEl.textContent = '✅ 已安装' + if (statusEl) statusEl.textContent = '已安装' // 隐藏安装按钮 const setupBtn = page.querySelector('#btn-skillhub-setup') if (setupBtn) setupBtn.style.display = 'none' } catch (e) { toast(`SkillHub CLI 安装失败: ${e?.message || e}`, 'error') - if (statusEl) statusEl.textContent = '❌ 安装失败' + if (statusEl) statusEl.textContent = '安装失败' } } @@ -380,10 +380,25 @@ async function checkSkillHubStatus(page) { const info = await api.skillsSkillHubCheck() _skillhubInstalled = !!info.installed if (info.installed) { - statusEl.innerHTML = `✅ v${info.version}` + const version = info.version ? `v${info.version}` : '已安装' + statusEl.textContent = '' + const main = document.createElement('span') + main.style.color = 'var(--success)' + main.textContent = version + statusEl.appendChild(main) + if (info.path) { + const pathEl = document.createElement('div') + pathEl.className = 'form-hint' + pathEl.textContent = `路径: ${info.path}` + statusEl.appendChild(pathEl) + } if (setupBtn) setupBtn.style.display = 'none' } else { - statusEl.innerHTML = '⚠️ 未安装 CLI' + statusEl.textContent = '' + const warn = document.createElement('span') + warn.style.color = 'var(--warning)' + warn.textContent = '未安装 CLI' + statusEl.appendChild(warn) if (setupBtn && _installSource === 'skillhub') setupBtn.style.display = '' } } catch { From 9fe59b5a0d6a570e6c621d279d24c2d542fbbde5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 01:00:33 +0800 Subject: [PATCH 047/426] chore: checkpoint before docs update From b6c9d8254493e2162c921f1ab64c99a51bdfa151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 01:01:30 +0800 Subject: [PATCH 048/426] docs: update env guidance --- CONTRIBUTING.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a3bc6a7..a7bbc4bb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -114,7 +114,7 @@ clawpanel/ │ ├── src/ │ │ ├── lib.rs # 入口 + 命令注册 │ │ ├── commands/ # Tauri 命令(按功能模块拆分) -│ │ │ ├── mod.rs # 模块注册 + enhanced_path() +│ │ │ ├── mod.rs # 模块注册 + 环境变量构建 │ │ │ ├── config.rs # 配置读写 + 版本管理 + 面板配置 │ │ │ ├── service.rs # Gateway 服务管理(跨平台) │ │ │ ├── agent.rs # Agent CRUD @@ -414,9 +414,12 @@ if (isTauri) { } ``` -### PATH 问题 +### PATH 与环境变量 -Tauri 桌面应用启动时 PATH 可能不完整(macOS Finder 启动、Windows 非默认安装路径)。所有需要调用外部命令的地方必须使用 `super::enhanced_path()` 设置环境变量。 +Tauri 桌面应用启动时 PATH 可能不完整(macOS Finder 启动、Windows 非默认安装路径)。现在 ClawPanel 会在启动时构建并注入完整系统环境变量(用户 + 系统 + 进程),并在 PATH 中追加 `enhanced_path` 的补充路径。 + +- **默认规则**:外部命令直接继承系统环境(无需手动设置 PATH) +- **特殊情况**:如需补充 PATH,可使用 `super::enhanced_path()` 或 `apply_system_env` 辅助函数 --- From 8adb9dc8bbd079613c829e0bf6921d2438c5cb9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 01:06:37 +0800 Subject: [PATCH 049/426] docs: add assistant ux and shell design --- ...026-03-17-assistant-ux-and-shell-design.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/plans/2026-03-17-assistant-ux-and-shell-design.md diff --git a/docs/plans/2026-03-17-assistant-ux-and-shell-design.md b/docs/plans/2026-03-17-assistant-ux-and-shell-design.md new file mode 100644 index 00000000..9ddb66c3 --- /dev/null +++ b/docs/plans/2026-03-17-assistant-ux-and-shell-design.md @@ -0,0 +1,43 @@ +# Assistant UX + Windows Shell 优化设计 + +日期: 2026-03-17 + +## 目标 +1) Windows shell 优先 pwsh,其次 powershell,最后 cmd +2) 复制按钮样式统一,右上角悬浮显示 +3) AI 助手输入区新增“优化/恢复原文”按钮,保留撤销栈 + +## 方案 +- 采用方案 B + +## 设计细节 +### 1) assistant_exec shell 优先级 +- Windows: pwsh -> powershell -> cmd +- 执行前检测可用 shell(where / Get-Command) +- 继续使用 build_system_env() 注入完整系统环境 + +### 2) 复制按钮错位修复 +- 不改 Markdown 解析 +- chat.css 与 assistant.css 的 pre / code-copy-btn 统一 +- copy 按钮右上角悬浮,hover 显示 + +### 3) AI 优化按钮 +- 位置: AI 助手输入区,与发送按钮并列 +- 文本模板: “请在不改变原意和语言的前提下,重写为意思更清晰、更简洁的表达。” +- 点击优化:调用同模型在线重写,替换输入框文本 +- 快照:保存原文快照 + 优化结果快照 +- 恢复原文:发送前始终可用 +- 发送后清空快照 +- 替换方式: setRangeText + input 事件,保留 Ctrl+Z + +## 影响范围 +- src-tauri/src/commands/assistant.rs +- src/style/chat.css +- src/style/assistant.css +- src/pages/assistant.js +- src/lib/tauri-api.js(如需复用 call) + +## 测试要点 +- Windows 下优先使用 pwsh +- 复制按钮在 chat 与 assistant 页面一致 +- 优化/恢复流程与撤销栈可用 From fb7a9aad9f45e2b22be87913f7193497fb8f75e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 01:07:53 +0800 Subject: [PATCH 050/426] docs: add assistant ux plan --- .../2026-03-17-assistant-ux-and-shell.md | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-17-assistant-ux-and-shell.md diff --git a/docs/superpowers/plans/2026-03-17-assistant-ux-and-shell.md b/docs/superpowers/plans/2026-03-17-assistant-ux-and-shell.md new file mode 100644 index 00000000..1946944a --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-assistant-ux-and-shell.md @@ -0,0 +1,118 @@ +# Assistant UX + Windows Shell 优化 Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 实现 Windows shell 优先级、复制按钮样式统一、AI 助手输入区新增优化/恢复功能并保留撤销栈。 + +**Architecture:** 后端 assistant_exec 增加 shell 探测与降级;前端统一 code-copy-btn CSS;AI 助手输入区新增优化调用与快照管理。 + +**Tech Stack:** Rust (Tauri), Vanilla JS, CSS + +--- + +## Chunk 1: Windows shell 优先级 + +### Task 1: assistant_exec 使用 pwsh 优先级 +**Files:** +- Modify: `src-tauri/src/commands/assistant.rs` + +- [ ] **Step 1: 增加 shell 探测函数** + +在 Windows 分支增加一个 `detect_windows_shell()`: +- 依次检查 `pwsh`、`powershell` +- 都不可用则返回 `cmd` + +实现方式:使用 `where` 探测,并使用 `build_system_env()` 注入完整环境。 + +- [ ] **Step 2: 替换执行逻辑** + +`assistant_exec` 使用探测结果执行: +- pwsh / powershell:`-NoProfile -Command ` +- cmd:`/c ` + +- [ ] **Step 3: 提交** +```bash +git add src-tauri/src/commands/assistant.rs +git commit -m "feat: prefer pwsh in assistant exec" +``` + +--- + +## Chunk 2: 复制按钮错位修复(CSS) + +### Task 2: 统一 chat.css 与 assistant.css +**Files:** +- Modify: `src/style/chat.css` +- Modify: `src/style/assistant.css` + +- [ ] **Step 1: 统一 pre 与 copy 按钮样式** + +将 assistant.css 中 pre 样式改为与 chat.css 一致,确保: +- `.code-copy-btn` 右上角悬浮 +- hover 才显示 +- 不改 Markdown 解析逻辑 + +- [ ] **Step 2: 提交** +```bash +git add src/style/chat.css src/style/assistant.css +git commit -m "fix: align code copy button styles" +``` + +--- + +## Chunk 3: AI 优化按钮 + +### Task 3: 输入区新增优化/恢复按钮 +**Files:** +- Modify: `src/pages/assistant.js` + +- [ ] **Step 1: 增加按钮 DOM** + +在输入区加入 “优化” 与 “恢复原文” 按钮。 + +- [ ] **Step 2: 维护快照状态** + +新增变量:`_optOriginalText` / `_optOptimizedText` +规则: +- 点击优化:保存原文快照,写入优化结果快照 +- 点击恢复:恢复原文 +- 发送成功后清空快照 + +- [ ] **Step 3: 调用同模型在线重写** + +复用现有模型调用逻辑: +- 模板:`请在不改变原意和语言的前提下,重写为意思更清晰、更简洁的表达。` +- 使用同模型 +- 结果直接替换输入框内容 + +- [ ] **Step 4: setRangeText 触发 input 事件** + +使用 `textarea.setRangeText()` + 触发 `input` 事件,保证 Ctrl+Z 生效。 + +- [ ] **Step 5: 提交** +```bash +git add src/pages/assistant.js +git commit -m "feat: add optimize and restore buttons" +``` + +--- + +## Chunk 4: 构建与验证 + +### Task 4: 构建 +**Files:** 无 + +- [ ] **Step 1: 构建** +```bash +npm run build +``` + +- [ ] **Step 2: 手工验证** +- Windows 下执行命令优先 pwsh +- 复制按钮位置正确 +- 优化/恢复可用且 Ctrl+Z 正常 + +- [ ] **Step 3: 推送** +```bash +git push +``` From 0a2a38a55156d4bdd5f32b336d7212e4d7ef32b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 01:08:03 +0800 Subject: [PATCH 051/426] chore: checkpoint before assistant ux From c087f7b8db73a1f36859775aaa710f083e6f3c60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 01:20:17 +0800 Subject: [PATCH 052/426] feat: improve assistant input and shell --- src-tauri/src/commands/assistant.rs | 32 ++++++++-- src/pages/assistant.js | 95 ++++++++++++++++++++++++++++- src/style/assistant.css | 77 +++++++++++++++++++++-- 3 files changed, 194 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/commands/assistant.rs b/src-tauri/src/commands/assistant.rs index 46170daf..61ac070a 100644 --- a/src-tauri/src/commands/assistant.rs +++ b/src-tauri/src/commands/assistant.rs @@ -149,10 +149,17 @@ pub async fn assistant_exec(command: String, cwd: Option) -> Result Result { )) } +#[cfg(target_os = "windows")] +async fn detect_windows_shell() -> String { + const CREATE_NO_WINDOW: u32 = 0x08000000; + for candidate in ["pwsh", "powershell"] { + let mut cmd = tokio::process::Command::new("cmd"); + cmd.args(["/c", "where", candidate]) + .creation_flags(CREATE_NO_WINDOW); + super::apply_system_env_tokio(&mut cmd); + if let Ok(out) = cmd.output().await { + if out.status.success() && !String::from_utf8_lossy(&out.stdout).trim().is_empty() { + return candidate.to_string(); + } + } + } + "cmd".to_string() +} + /// 列出运行中的进程(按名称过滤) #[tauri::command] pub async fn assistant_list_processes(filter: Option) -> Result { diff --git a/src/pages/assistant.js b/src/pages/assistant.js index 89c01797..67d3fc76 100644 --- a/src/pages/assistant.js +++ b/src/pages/assistant.js @@ -1099,6 +1099,9 @@ function renderSoulStats(soul) { // ── 状态 ── let _page = null, _messagesEl = null, _textarea = null, _sendBtn = null let _sessionListEl = null, _settingsPanel = null, _queueEl = null +let _optimizeBtn = null, _restoreBtn = null +let _optOriginalText = null, _optOptimizedText = null +let _optBusy = false let _isStreaming = false, _abortController = null let _config = null, _sessions = [], _currentSessionId = null let _lastRenderTime = 0 @@ -3468,6 +3471,7 @@ function sendMessage(text) { enqueueMessage(text.trim()) return } + clearOptimizeSnapshot() sendMessageDirect(text) } @@ -3481,6 +3485,8 @@ async function sendMessageDirect(text) { return } + clearOptimizeSnapshot() + let session = getCurrentSession() if (!session) { session = createSession() @@ -3959,6 +3965,8 @@ export async function render() { + +
Enter 发送 · Shift+Enter 换行 · 支持粘贴/拖拽图片 · AI 助手独立于 OpenClaw
@@ -3971,6 +3979,8 @@ export async function render() { _queueEl = page.querySelector('#ast-queue') _textarea = page.querySelector('#ast-textarea') _sendBtn = page.querySelector('#ast-send-btn') + _optimizeBtn = page.querySelector('#ast-optimize-btn') + _restoreBtn = page.querySelector('#ast-restore-btn') _sessionListEl = page.querySelector('#ast-session-list') // 渲染 @@ -4029,6 +4039,7 @@ export async function render() { sendMessage(_textarea.value) _textarea.value = '' autoResize(_textarea) + updateOptimizeState() } }) @@ -4040,11 +4051,26 @@ export async function render() { sendMessage(_textarea.value) _textarea.value = '' autoResize(_textarea) + updateOptimizeState() } }) // 自动高度 - _textarea.addEventListener('input', () => autoResize(_textarea)) + _textarea.addEventListener('input', () => { + autoResize(_textarea) + updateOptimizeState() + }) + + if (_optimizeBtn) { + _optimizeBtn.addEventListener('click', () => optimizeInputText()) + } + if (_restoreBtn) { + _restoreBtn.addEventListener('click', () => { + restoreOriginalText() + updateOptimizeState() + }) + } + updateOptimizeState() // 图片上传按钮 const fileInput = page.querySelector('#ast-file-input') @@ -4233,6 +4259,71 @@ export async function render() { return page } +function updateOptimizeState() { + if (!_optimizeBtn || !_restoreBtn || !_textarea) return + const hasText = _textarea.value.trim().length > 0 + _optimizeBtn.disabled = _optBusy || !hasText + _restoreBtn.disabled = _optOriginalText === null +} + +function applyTextareaText(text) { + if (!_textarea) return + _textarea.focus() + _textarea.setRangeText(text, 0, _textarea.value.length, 'end') + const ev = new Event('input', { bubbles: true }) + _textarea.dispatchEvent(ev) +} + +async function optimizeInputText() { + if (_optBusy || !_textarea) return + const raw = _textarea.value.trim() + if (!raw) { + updateOptimizeState() + return + } + if (_currentSessionId && getStreaming(_currentSessionId)) { + toast('AI 正在回复中,请稍后再试', 'info') + return + } + + _optBusy = true + updateOptimizeState() + + const prompt = '请在不改变原意和语言的前提下,重写为意思更清晰、更简洁的表达。' + const messages = [ + { role: 'user', content: prompt + '\n\n原文:\n' + raw } + ] + + try { + let result = '' + await callAI(_currentSessionId || 'opt', messages, (chunk) => { + result += chunk + }) + const next = result.trim() + if (next) { + if (_optOriginalText === null) _optOriginalText = raw + _optOptimizedText = next + applyTextareaText(next) + } + } catch (e) { + toast('优化失败: ' + (e?.message || e), 'error') + } finally { + _optBusy = false + updateOptimizeState() + } +} + +function restoreOriginalText() { + if (_optOriginalText === null) return + applyTextareaText(_optOriginalText) +} + +function clearOptimizeSnapshot() { + _optOriginalText = null + _optOptimizedText = null + updateOptimizeState() +} + function autoResize(textarea) { textarea.style.height = 'auto' textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px' @@ -4247,5 +4338,7 @@ export function cleanup() { _queueEl = null _textarea = null _sendBtn = null + _optimizeBtn = null + _restoreBtn = null _sessionListEl = null } diff --git a/src/style/assistant.css b/src/style/assistant.css index 7acb04d7..5cd63f01 100644 --- a/src/style/assistant.css +++ b/src/style/assistant.css @@ -242,12 +242,54 @@ } .ast-msg-bubble-ai pre { - background: var(--bg-tertiary); - border-radius: var(--radius-sm); - padding: 10px 12px; - overflow-x: auto; - font-size: 13px; + background: var(--bg-primary, #1a1a2e); + border-radius: var(--radius-md, 8px); + padding: 0; margin: 8px 0; + overflow: hidden; + position: relative; + font-size: 13px; +} + +.ast-msg-bubble-ai pre code { + display: block; + padding: 12px 14px; + overflow-x: auto; + line-height: 1.5; + font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; +} + +.ast-msg-bubble-ai pre .code-lang { + position: absolute; + top: 6px; + left: 12px; + font-size: 11px; + color: var(--text-muted, #888); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.ast-msg-bubble-ai pre .code-copy-btn { + position: absolute; + top: 6px; + right: 8px; + background: transparent; + border: 1px solid var(--border); + color: var(--text-secondary); + font-size: 11px; + padding: 2px 8px; + border-radius: 4px; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s; +} + +.ast-msg-bubble-ai pre:hover .code-copy-btn { + opacity: 1; +} + +.ast-msg-bubble-ai pre .code-copy-btn:hover { + background: var(--bg-hover); } .ast-msg-bubble-ai code { @@ -355,6 +397,31 @@ transition: background var(--transition-fast); } +.ast-optimize-btn, +.ast-restore-btn { + height: 36px; + padding: 0 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: transparent; + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; + transition: background var(--transition-fast), color var(--transition-fast); +} + +.ast-optimize-btn:hover, +.ast-restore-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.ast-optimize-btn:disabled, +.ast-restore-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + .ast-send-btn:hover { background: var(--accent-hover); } From dbdcb1030b0138de1f6c900b8c9b85336193ea97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 01:24:15 +0800 Subject: [PATCH 053/426] chore: checkpoint before config path fallback From 94a7cd09839e8a58130540a8080375af644a7ec3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 01:29:59 +0800 Subject: [PATCH 054/426] docs: add force setup design --- docs/plans/2026-03-17-force-setup-design.md | 31 +++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 docs/plans/2026-03-17-force-setup-design.md diff --git a/docs/plans/2026-03-17-force-setup-design.md b/docs/plans/2026-03-17-force-setup-design.md new file mode 100644 index 00000000..d747ce86 --- /dev/null +++ b/docs/plans/2026-03-17-force-setup-design.md @@ -0,0 +1,31 @@ +# 强制初始化(forceSetup)设计 + +日期: 2026-03-17 + +## 目标 +构建版首次启动可强制进入 /setup,不受已存在配置影响。 + +## 方案 +- 采用方案 A:在 clawpanel.json 增加 forceSetup 字段 + +## 设计细节 +### 1) 配置字段 +- `clawpanel.json` 新增:`forceSetup: true/false` + +### 2) 启动逻辑 +- 启动时读取 panel config +- 若 `forceSetup === true`,即使 isOpenclawReady 为 true 也强制跳转 /setup + +### 3) 初始化完成后 +- setup 流程成功完成时写入 `forceSetup=false` + +## 影响范围 +- src/main.js +- src/lib/tauri-api.js +- src/pages/setup.js +- src-tauri/src/commands/config.rs + +## 测试要点 +- forceSetup=true 时进入 /setup +- 完成初始化后 forceSetup 自动清零 +- forceSetup=false 时恢复原有判断逻辑 From 0ed929b568264c6c6bd8b047c210042d0021e99e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 01:31:02 +0800 Subject: [PATCH 055/426] docs: add force setup plan --- .../plans/2026-03-17-force-setup.md | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-17-force-setup.md diff --git a/docs/superpowers/plans/2026-03-17-force-setup.md b/docs/superpowers/plans/2026-03-17-force-setup.md new file mode 100644 index 00000000..93774207 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-force-setup.md @@ -0,0 +1,84 @@ +# forceSetup Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 添加 `forceSetup` 开关,使构建版可强制进入 /setup,完成初始化后自动关闭。 + +**Architecture:** 在 panel config 中存储 forceSetup;启动时读取并强制跳转;setup 成功后清零。 + +**Tech Stack:** Vanilla JS, Tauri + +--- + +## Chunk 1: 配置字段读写 + +### Task 1: panel config 增加 forceSetup +**Files:** +- Modify: `src-tauri/src/commands/config.rs` +- Modify: `src/lib/tauri-api.js` + +- [ ] **Step 1: 扩展 panel config 读写** + +在读写 panel config 时透传 `forceSetup`。 + +- [ ] **Step 2: 提交** +```bash +git add src-tauri/src/commands/config.rs src/lib/tauri-api.js +git commit -m "feat: add forceSetup to panel config" +``` + +--- + +## Chunk 2: 启动强制跳转 + +### Task 2: main.js 强制跳 setup +**Files:** +- Modify: `src/main.js` + +- [ ] **Step 1: 启动时读取 panel config** + +在 `ensureWebSession` 前读取 panel config,若 `forceSetup===true` 强制跳转 `/setup`。 + +- [ ] **Step 2: 提交** +```bash +git add src/main.js +git commit -m "feat: force setup on startup" +``` + +--- + +## Chunk 3: setup 完成后清零 + +### Task 3: setup.js 成功后清零 +**Files:** +- Modify: `src/pages/setup.js` + +- [ ] **Step 1: setup 成功时写入 forceSetup=false** + +- [ ] **Step 2: 提交** +```bash +git add src/pages/setup.js +git commit -m "feat: clear forceSetup after setup" +``` + +--- + +## Chunk 4: 构建与验证 + +### Task 4: 构建 +**Files:** 无 + +- [ ] **Step 1: 构建** +```bash +npm run build +``` + +- [ ] **Step 2: 手工验证** +- forceSetup=true 时进入 /setup +- setup 完成后不再强制跳转 +- forceSetup=false 时逻辑不变 + +- [ ] **Step 3: 推送** +```bash +git push +``` From a50852d02046b1b78bfec0ed5f865a71c83ac102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 01:31:08 +0800 Subject: [PATCH 056/426] chore: checkpoint before force setup From 17cc08bf0434d46fba485fb6e5e0d0d224e76e69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 01:35:55 +0800 Subject: [PATCH 057/426] feat: force setup flow --- src-tauri/src/commands/mod.rs | 27 ++++++++++++++++++++++++++- src/main.js | 8 +++++--- src/pages/setup.js | 6 ++++++ 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2a5e85f6..d01ca647 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -26,7 +26,32 @@ pub mod update; /// 获取 OpenClaw 配置目录 (~/.openclaw/) pub fn openclaw_dir() -> PathBuf { - dirs::home_dir().unwrap_or_default().join(".openclaw") + #[cfg(target_os = "windows")] + { + let mut candidates: Vec = Vec::new(); + if let Some(home) = dirs::home_dir() { + candidates.push(home.join(".openclaw")); + } + if let Ok(profile) = std::env::var("USERPROFILE") { + candidates.push(PathBuf::from(profile).join(".openclaw")); + } + if let (Ok(drive), Ok(path)) = (std::env::var("HOMEDRIVE"), std::env::var("HOMEPATH")) { + candidates.push(PathBuf::from(format!("{}{}", drive, path)).join(".openclaw")); + } + for dir in &candidates { + if dir.join("openclaw.json").exists() || dir.join("clawpanel.json").exists() { + return dir.to_path_buf(); + } + } + candidates + .into_iter() + .next() + .unwrap_or_else(|| PathBuf::from(".openclaw")) + } + #[cfg(not(target_os = "windows"))] + { + dirs::home_dir().unwrap_or_default().join(".openclaw") + } } /// 获取 OpenClaw 配置文件路径(仅使用 openclaw.json) diff --git a/src/main.js b/src/main.js index 2f4ebba7..b73f75b9 100644 --- a/src/main.js +++ b/src/main.js @@ -28,6 +28,7 @@ initTheme() // === 访问密码保护(Web + 桌面端通用) === const isTauri = !!window.__TAURI_INTERNALS__ +let _forceSetup = false const GATEWAY_PATCH_COOLDOWN_MS = 5 * 60 * 1000 let _gatewayPatchLastCheck = 0 @@ -373,6 +374,7 @@ async function boot() { // Tauri 模式:确保 web session 存在(页面刷新后 cookie 可能丢失),然后加载实例和检测状态 const ensureWebSession = isTauri ? api.readPanelConfig().then(cfg => { + _forceSetup = cfg.forceSetup === true if (cfg.accessPassword) { return fetch('/__api/auth_login', { method: 'POST', @@ -386,7 +388,7 @@ async function boot() { ensureWebSession.then(() => loadActiveInstance()).then(() => detectOpenclawStatus()).then(() => { // 重新渲染侧边栏(检测完成后 isOpenclawReady 状态已更新) renderSidebar(sidebar) - if (!isOpenclawReady()) { + if (_forceSetup || !isOpenclawReady()) { setDefaultRoute('/setup') navigate('/setup') } else { @@ -444,11 +446,11 @@ async function boot() { await detectOpenclawStatus() renderSidebar(sidebar) // 如果安装完成后变为就绪,跳转到仪表盘 - if (isOpenclawReady() && window.location.hash === '#/setup') { + if (!_forceSetup && isOpenclawReady() && window.location.hash === '#/setup') { navigate('/dashboard') } // 如果卸载后变为未就绪,跳转到 setup - if (!isOpenclawReady() && !isUpgrading()) { + if ((_forceSetup || !isOpenclawReady()) && !isUpgrading()) { setDefaultRoute('/setup') navigate('/setup') } diff --git a/src/pages/setup.js b/src/pages/setup.js index 4f089a1d..07ae1045 100644 --- a/src/pages/setup.js +++ b/src/pages/setup.js @@ -65,6 +65,7 @@ async function runDetect(page) { && clawRes.value[0]?.cli_installed !== false let config = configRes.status === 'fulfilled' ? configRes.value : { installed: false } const version = versionRes.status === 'fulfilled' ? versionRes.value : null + const panelCfg = await api.readPanelConfig().catch(() => ({})) // CLI 已装但配置缺失 → 自动创建默认配置 if (cliOk && !config.installed) { @@ -89,6 +90,11 @@ async function runDetect(page) { } renderSteps(page, { node, git, cliOk, config, version }) + + if (panelCfg.forceSetup === true && node.installed && config.installed) { + const nextCfg = { ...panelCfg, forceSetup: false } + api.writePanelConfig(nextCfg).catch(() => {}) + } } function stepIcon(ok) { From c60a8c467751fa3fe567d41fdc6c1f70b7473a79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 01:39:20 +0800 Subject: [PATCH 058/426] chore: checkpoint before setup dismiss From 3e4bbb9c1cb5c60b9b103552dd2a32109aedc2f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 01:42:23 +0800 Subject: [PATCH 059/426] feat: add setup skip flag --- src/main.js | 6 ++++-- src/pages/setup.js | 8 +++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/main.js b/src/main.js index b73f75b9..a82eac73 100644 --- a/src/main.js +++ b/src/main.js @@ -29,6 +29,7 @@ initTheme() // === 访问密码保护(Web + 桌面端通用) === const isTauri = !!window.__TAURI_INTERNALS__ let _forceSetup = false +let _skipSetup = false const GATEWAY_PATCH_COOLDOWN_MS = 5 * 60 * 1000 let _gatewayPatchLastCheck = 0 @@ -375,6 +376,7 @@ async function boot() { const ensureWebSession = isTauri ? api.readPanelConfig().then(cfg => { _forceSetup = cfg.forceSetup === true + _skipSetup = cfg.Setup === true if (cfg.accessPassword) { return fetch('/__api/auth_login', { method: 'POST', @@ -388,7 +390,7 @@ async function boot() { ensureWebSession.then(() => loadActiveInstance()).then(() => detectOpenclawStatus()).then(() => { // 重新渲染侧边栏(检测完成后 isOpenclawReady 状态已更新) renderSidebar(sidebar) - if (_forceSetup || !isOpenclawReady()) { + if (_forceSetup || (!isOpenclawReady() && !_skipSetup)) { setDefaultRoute('/setup') navigate('/setup') } else { @@ -450,7 +452,7 @@ async function boot() { navigate('/dashboard') } // 如果卸载后变为未就绪,跳转到 setup - if ((_forceSetup || !isOpenclawReady()) && !isUpgrading()) { + if ((_forceSetup || (!isOpenclawReady() && !_skipSetup)) && !isUpgrading()) { setDefaultRoute('/setup') navigate('/setup') } diff --git a/src/pages/setup.js b/src/pages/setup.js index 07ae1045..b1c0ee26 100644 --- a/src/pages/setup.js +++ b/src/pages/setup.js @@ -25,16 +25,22 @@ export async function render() {
-
+
+
` page.querySelector('#btn-recheck').addEventListener('click', () => runDetect(page)) + page.querySelector('#btn-skip-setup').addEventListener('click', async () => { + const cfg = await api.readPanelConfig().catch(() => ({})) + await api.writePanelConfig({ ...cfg, Setup: true, forceSetup: false }).catch(() => {}) + window.location.hash = '#/dashboard' + }) runDetect(page) return page } From 5fd55dedd648d2e17d4830090e3a139b0c6b893f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 01:48:12 +0800 Subject: [PATCH 060/426] docs: add ai config import design --- .../2026-03-17-ai-config-import-design.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/plans/2026-03-17-ai-config-import-design.md diff --git a/docs/plans/2026-03-17-ai-config-import-design.md b/docs/plans/2026-03-17-ai-config-import-design.md new file mode 100644 index 00000000..1f4b2b0c --- /dev/null +++ b/docs/plans/2026-03-17-ai-config-import-design.md @@ -0,0 +1,37 @@ +# AI 配置从 openclaw 导入设计 + +日期: 2026-03-17 + +## 目标 +在 AI 配置页提供“从 openclaw 导入”功能,导入模型参数 + API Key + Base URL。 + +## 方案 +- 采用方案 A:手动按钮触发导入 + +## 设计细节 +### 1) 导入入口 +- AI 配置页顶部操作区新增按钮:`从 openclaw 导入` + +### 2) 导入内容 +- 读取 `openclaw.json` +- 提取字段: + - model + - temperature + - top_p + - api_key + - base_url +- 写回当前 AI 配置表单并持久化 + +### 3) 反馈 +- 成功:toast 提示“已导入” +- 失败:toast 提示读取失败或字段缺失 + +## 影响范围 +- src/pages/models.js +- src/lib/tauri-api.js +- src-tauri/src/commands/config.rs + +## 测试要点 +- openclaw.json 有效 → 导入成功 +- 字段缺失 → 失败提示 +- 导入后配置可保存并生效 From 5ead9c26c6bdc0c93e6669c0d2c8d249ef9ada37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 01:50:27 +0800 Subject: [PATCH 061/426] docs: add ai config import plan --- .../plans/2026-03-17-ai-config-import.md | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-17-ai-config-import.md diff --git a/docs/superpowers/plans/2026-03-17-ai-config-import.md b/docs/superpowers/plans/2026-03-17-ai-config-import.md new file mode 100644 index 00000000..f7bb8e3a --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-ai-config-import.md @@ -0,0 +1,82 @@ +# AI 配置从 openclaw 导入 Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在 AI 配置页增加“从 openclaw 导入”按钮,导入 model/temperature/top_p/api_key/base_url。 + +**Architecture:** 前端按钮触发调用 Tauri API 读取 openclaw.json 并写回表单配置,完成后持久化保存。 + +**Tech Stack:** Vanilla JS, Tauri + +--- + +## Chunk 1: 后端读取 openclaw 配置 + +### Task 1: 新增导入命令 +**Files:** +- Modify: `src-tauri/src/commands/config.rs` + +- [ ] **Step 1: 新增 Tauri 命令** + +新增 `import_openclaw_ai_config`: +- 读取 openclaw.json +- 提取字段:model / temperature / top_p / api_key / base_url +- 返回 JSON + +- [ ] **Step 2: 注册命令** + +在 `src-tauri/src/lib.rs` 注册命令。 + +- [ ] **Step 3: 提交** +```bash +git add src-tauri/src/commands/config.rs src-tauri/src/lib.rs +git commit -m "feat: add ai config import command" +``` + +--- + +## Chunk 2: 前端按钮与写回 + +### Task 2: AI 配置页导入 +**Files:** +- Modify: `src/lib/tauri-api.js` +- Modify: `src/pages/models.js` + +- [ ] **Step 1: 添加 API 封装** + +tauri-api.js 增加 `importOpenclawAiConfig`. + +- [ ] **Step 2: UI 按钮与写回逻辑** + +models.js 增加按钮,点击后: +- 调用 API +- 写回表单 +- 保存当前配置 + +- [ ] **Step 3: 提交** +```bash +git add src/lib/tauri-api.js src/pages/models.js +git commit -m "feat: import ai config from openclaw" +``` + +--- + +## Chunk 3: 构建与验证 + +### Task 3: 构建 +**Files:** 无 + +- [ ] **Step 1: 构建** +```bash +npm run build +``` + +- [ ] **Step 2: 手工验证** +- openclaw.json 存在 → 导入成功 +- 字段缺失 → 提示失败 +- 保存后配置生效 + +- [ ] **Step 3: 推送** +```bash +git push +``` From d67bc67a9610a77529b9623c8c91fd23656b0e75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 01:50:36 +0800 Subject: [PATCH 062/426] chore: checkpoint before ai config import From 7eab4e2cb7597d2c2b3e7de6ca3a53bbb8bc097f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 01:56:18 +0800 Subject: [PATCH 063/426] checkpoint before fix openclaw version, patch, channel autofill From 2c574826f73ec6d5cf0e8ff3216324619aaaa68b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 01:57:58 +0800 Subject: [PATCH 064/426] feat: import ai config from openclaw --- src-tauri/src/commands/config.rs | 42 +++++++++++++++ src-tauri/src/commands/gateway_patch.rs | 69 ++++++++++++++++--------- src-tauri/src/lib.rs | 1 + src/lib/tauri-api.js | 1 + src/pages/assistant.js | 32 +++++++++++- 5 files changed, 120 insertions(+), 25 deletions(-) diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 1cb58223..508595e9 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -340,6 +340,48 @@ pub fn load_openclaw_json() -> Result { read_openclaw_config() } +#[tauri::command] +pub fn import_openclaw_ai_config() -> Result { + let config = read_openclaw_config()?; + let primary = config + .get("agents") + .and_then(|v| v.get("defaults")) + .and_then(|v| v.get("model")) + .and_then(|v| v.get("primary")) + .and_then(|v| v.as_str()) + .ok_or("缺少默认模型")? + .to_string(); + + let parts: Vec<&str> = primary.split('/').collect(); + if parts.len() < 2 { + return Err("默认模型格式错误".to_string()); + } + let provider_key = parts[0]; + let model_id = parts[1..].join("/"); + + let provider = config + .get("models") + .and_then(|v| v.get("providers")) + .and_then(|v| v.get(provider_key)) + .ok_or("未找到模型提供商配置")?; + + let api_type = provider.get("api").and_then(|v| v.as_str()).unwrap_or("openai-completions"); + let api_key = provider.get("apiKey").and_then(|v| v.as_str()).unwrap_or(""); + let base_url = provider.get("baseUrl").and_then(|v| v.as_str()).unwrap_or(""); + let temperature = provider.get("temperature").and_then(|v| v.as_f64()); + let top_p = provider.get("top_p").and_then(|v| v.as_f64()); + + Ok(serde_json::json!({ + "provider": provider_key, + "model": model_id, + "apiType": api_type, + "apiKey": api_key, + "baseUrl": base_url, + "temperature": temperature, + "topP": top_p + })) +} + /// 供其他模块复用:将 JSON Value 写回配置文件(含备份和清理) pub fn save_openclaw_json(config: &Value) -> Result<(), String> { write_openclaw_config(config.clone()) diff --git a/src-tauri/src/commands/gateway_patch.rs b/src-tauri/src/commands/gateway_patch.rs index 9a854b28..32f8648c 100644 --- a/src-tauri/src/commands/gateway_patch.rs +++ b/src-tauri/src/commands/gateway_patch.rs @@ -172,34 +172,44 @@ fn npm_root_global() -> Result { fn resolve_openclaw_dist_dir() -> Result { let root = npm_root_global()?; - let openclaw_dir = root.join("openclaw"); - if !openclaw_dir.exists() { - return Err("未找到全局 openclaw 安装目录".to_string()); - } - let dist = openclaw_dir.join("dist"); - if !dist.exists() { - return Err("未找到 openclaw dist 目录".to_string()); + let candidates = [ + root.join("@qingchencloud").join("openclaw-zh"), + root.join("openclaw"), + ]; + for pkg_dir in candidates { + if !pkg_dir.exists() { + continue; + } + let dist = pkg_dir.join("dist"); + if dist.exists() { + return Ok(dist); + } } - Ok(dist) + Err("未找到 openclaw dist 目录".to_string()) } fn read_openclaw_version() -> Result { let root = npm_root_global()?; - let pkg = root.join("openclaw").join("package.json"); - if !pkg.exists() { - return Err("未找到 openclaw package.json".to_string()); - } - let content = fs::read_to_string(&pkg).map_err(|e| format!("读取失败: {e}"))?; - let value: Value = serde_json::from_str(&content).map_err(|e| format!("解析失败: {e}"))?; - let version = value - .get("version") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - if version.is_empty() { - return Err("openclaw 版本为空".to_string()); + let pkg_candidates = [ + root.join("@qingchencloud").join("openclaw-zh").join("package.json"), + root.join("openclaw").join("package.json"), + ]; + for pkg in pkg_candidates { + if !pkg.exists() { + continue; + } + let content = fs::read_to_string(&pkg).map_err(|e| format!("读取失败: {e}"))?; + let value: Value = serde_json::from_str(&content).map_err(|e| format!("解析失败: {e}"))?; + let version = value + .get("version") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if !version.is_empty() { + return Ok(version); + } } - Ok(version) + Err("openclaw 版本为空".to_string()) } fn find_latest_file(dir: &Path, prefix: &str) -> Result { @@ -252,19 +262,30 @@ fn replace_once(hay: &str, needle: &str, replacement: &str) -> Result Result { + for needle in needles { + if hay.contains(needle) { + return Ok(hay.replacen(needle, replacement, 1)); + } + } + Err("未找到补丁位置".to_string()) +} + fn patch_reply_file(path: &Path, _force: bool) -> Result { let content = fs::read_to_string(path).map_err(|e| format!("读取失败: {e}"))?; if content.contains("kind: Type.Literal(\"sessionMessage\")") { return Ok(false); } let needle_schema = "const CronPayloadSchema = Type.Union([Type.Object({\n\tkind: Type.Literal(\"systemEvent\"),\n\ttext: NonEmptyString\n}, { additionalProperties: false }), "; + let needle_schema_sp = "const CronPayloadSchema = Type.Union([Type.Object({\n kind: Type.Literal(\"systemEvent\"),\n text: NonEmptyString\n}, { additionalProperties: false }), "; let insert_schema = "const CronPayloadSchema = Type.Union([Type.Object({\n\tkind: Type.Literal(\"systemEvent\"),\n\ttext: NonEmptyString\n}, { additionalProperties: false }), Type.Object({\n\tkind: Type.Literal(\"sessionMessage\"),\n\tlabel: NonEmptyString,\n\tmessage: NonEmptyString,\n\trole: Type.Optional(Type.Literal(\"user\")),\n\twaitForIdle: Type.Optional(Type.Boolean())\n}, { additionalProperties: false }), "; let needle_patch = "const CronPayloadPatchSchema = Type.Union([Type.Object({\n\tkind: Type.Literal(\"systemEvent\"),\n\ttext: Type.Optional(NonEmptyString)\n}, { additionalProperties: false }), "; + let needle_patch_sp = "const CronPayloadPatchSchema = Type.Union([Type.Object({\n kind: Type.Literal(\"systemEvent\"),\n text: Type.Optional(NonEmptyString)\n}, { additionalProperties: false }), "; let insert_patch = "const CronPayloadPatchSchema = Type.Union([Type.Object({\n\tkind: Type.Literal(\"systemEvent\"),\n\ttext: Type.Optional(NonEmptyString)\n}, { additionalProperties: false }), Type.Object({\n\tkind: Type.Literal(\"sessionMessage\"),\n\tlabel: Type.Optional(NonEmptyString),\n\tmessage: Type.Optional(NonEmptyString),\n\trole: Type.Optional(Type.Literal(\"user\")),\n\twaitForIdle: Type.Optional(Type.Boolean())\n}, { additionalProperties: false }), "; - let mut next = replace_once(&content, needle_schema, insert_schema)?; - next = replace_once(&next, needle_patch, insert_patch)?; + let mut next = replace_once_any(&content, &[needle_schema, needle_schema_sp], insert_schema)?; + next = replace_once_any(&next, &[needle_patch, needle_patch_sp], insert_patch)?; backup_file(path)?; fs::write(path, next).map_err(|e| format!("写入失败: {e}"))?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index be7259aa..ed0d044b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -68,6 +68,7 @@ pub fn run() { // 配置 config::read_openclaw_config, config::write_openclaw_config, + config::import_openclaw_ai_config, config::read_mcp_config, config::write_mcp_config, config::get_version_info, diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index a8e93c80..971c13ca 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -158,6 +158,7 @@ export const api = { getStatusSummary: () => cachedInvoke('get_status_summary', {}, 5000), readOpenclawConfig: () => cachedInvoke('read_openclaw_config'), writeOpenclawConfig: (config) => { invalidate('read_openclaw_config'); return invoke('write_openclaw_config', { config }) }, + importOpenclawAiConfig: () => invoke('import_openclaw_ai_config'), readMcpConfig: () => cachedInvoke('read_mcp_config'), writeMcpConfig: (config) => { invalidate('read_mcp_config'); return invoke('write_mcp_config', { config }) }, reloadGateway: () => invoke('reload_gateway'), diff --git a/src/pages/assistant.js b/src/pages/assistant.js index 67d3fc76..a5964985 100644 --- a/src/pages/assistant.js +++ b/src/pages/assistant.js @@ -2561,7 +2561,10 @@ function showSettings() {
- +
+ + +
${PROVIDER_PRESETS.filter(p => !p.hidden).map(p => ``).join('')}
@@ -2767,6 +2770,7 @@ function showSettings() { const apiHintEl = overlay.querySelector('#ast-api-hint') const baseUrlInput = overlay.querySelector('#ast-baseurl') const apiKeyInput = overlay.querySelector('#ast-apikey') + const importBtn = overlay.querySelector('#ast-import-openclaw') overlay.querySelectorAll('.ast-preset-btn').forEach(btn => { btn.onclick = () => { baseUrlInput.value = btn.dataset.url @@ -2794,6 +2798,32 @@ function showSettings() { } }) + if (importBtn) { + importBtn.addEventListener('click', async () => { + try { + importBtn.disabled = true + const result = await api.importOpenclawAiConfig() + if (!result || !result.model) { + toast('未找到可导入的 AI 配置', 'warning') + return + } + baseUrlInput.value = result.baseUrl || '' + apiKeyInput.value = result.apiKey || '' + overlay.querySelector('#ast-model').value = result.model || '' + apiTypeSelect.value = normalizeApiType(result.apiType || 'openai-completions') + apiTypeSelect.dispatchEvent(new Event('change')) + if (result.temperature && overlay.querySelector('#ast-temp')) { + overlay.querySelector('#ast-temp').value = result.temperature + } + toast('已导入 openclaw 配置', 'success') + } catch (e) { + toast('导入失败: ' + (e?.message || e), 'error') + } finally { + importBtn.disabled = false + } + }) + } + // API 类型切换时更新提示文本和 placeholder apiTypeSelect.addEventListener('change', () => { const v = normalizeApiType(apiTypeSelect.value) From 58453088d21565562c0b27d15166437ab0c51043 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 02:00:58 +0800 Subject: [PATCH 065/426] docs: add optimize toggle design --- ...-03-17-assistant-optimize-toggle-design.md | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 docs/plans/2026-03-17-assistant-optimize-toggle-design.md diff --git a/docs/plans/2026-03-17-assistant-optimize-toggle-design.md b/docs/plans/2026-03-17-assistant-optimize-toggle-design.md new file mode 100644 index 00000000..4c65c7a5 --- /dev/null +++ b/docs/plans/2026-03-17-assistant-optimize-toggle-design.md @@ -0,0 +1,27 @@ +# 晴辰助手 优化/还原按钮切换设计 + +日期: 2026-03-17 + +## 目标 +优化与还原按钮不同时出现,默认显示“优化”,优化完成后显示“还原”,发送或还原后切回“优化”。 + +## 方案 +- 采用方案 A:同位置切换显示 + +## 设计细节 +### 1) UI 结构 +- 保留两个按钮节点 +- 通过状态控制显示 + +### 2) 状态逻辑 +- `_optOriginalText` 为空:显示“优化”,隐藏“还原” +- `_optOriginalText` 非空:隐藏“优化”,显示“还原” +- `clearOptimizeSnapshot()` 或点击还原后切回“优化” + +## 影响范围 +- src/pages/assistant.js + +## 测试要点 +- 默认仅显示“优化” +- 优化完成后仅显示“还原” +- 发送或点击还原后仅显示“优化” From 1a8e130066253b7585d20c573886b20069be62f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 02:05:52 +0800 Subject: [PATCH 066/426] docs: add optimize toggle plan --- .../2026-03-17-assistant-optimize-toggle.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-17-assistant-optimize-toggle.md diff --git a/docs/superpowers/plans/2026-03-17-assistant-optimize-toggle.md b/docs/superpowers/plans/2026-03-17-assistant-optimize-toggle.md new file mode 100644 index 00000000..d0d38ed0 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-assistant-optimize-toggle.md @@ -0,0 +1,52 @@ +# 优化/还原按钮切换 Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 优化与还原按钮互斥显示,默认显示“优化”,优化后显示“还原”,发送或还原后切回“优化”。 + +**Architecture:** 根据 `_optOriginalText` 状态控制按钮显隐与禁用。 + +**Tech Stack:** Vanilla JS + +--- + +## Chunk 1: 逻辑调整 + +### Task 1: updateOptimizeState 互斥显示 +**Files:** +- Modify: `src/pages/assistant.js` + +- [ ] **Step 1: 更新 updateOptimizeState** + +逻辑: +- `_optOriginalText` 为 null → 显示优化按钮,隐藏还原按钮 +- `_optOriginalText` 非空 → 隐藏优化按钮,显示还原按钮 + +- [ ] **Step 2: 按钮点击后切换** + +- 优化完成 → 切到还原 +- 点击还原 → 清空快照并切回优化 +- 发送 → 清空快照并切回优化 + +- [ ] **Step 3: 提交** +```bash +git add src/pages/assistant.js +git commit -m "fix: toggle optimize and restore buttons" +``` + +--- + +## Chunk 2: 构建与推送 + +### Task 2: 构建 +**Files:** 无 + +- [ ] **Step 1: 构建** +```bash +npm run build +``` + +- [ ] **Step 2: 推送** +```bash +git push +``` From a7ec707caca04397028d02b130d86c753e2288fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 02:06:04 +0800 Subject: [PATCH 067/426] chore: checkpoint before optimize toggle From 1fd610f18ed90ccd0b667b655dd981fb7d7b1951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 02:07:08 +0800 Subject: [PATCH 068/426] fix: toggle optimize button visibility --- src/pages/assistant.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pages/assistant.js b/src/pages/assistant.js index a5964985..dff34b85 100644 --- a/src/pages/assistant.js +++ b/src/pages/assistant.js @@ -4292,8 +4292,11 @@ export async function render() { function updateOptimizeState() { if (!_optimizeBtn || !_restoreBtn || !_textarea) return const hasText = _textarea.value.trim().length > 0 + const hasSnapshot = _optOriginalText !== null + _optimizeBtn.style.display = hasSnapshot ? 'none' : '' + _restoreBtn.style.display = hasSnapshot ? '' : 'none' _optimizeBtn.disabled = _optBusy || !hasText - _restoreBtn.disabled = _optOriginalText === null + _restoreBtn.disabled = _optBusy } function applyTextareaText(text) { @@ -4346,6 +4349,7 @@ async function optimizeInputText() { function restoreOriginalText() { if (_optOriginalText === null) return applyTextareaText(_optOriginalText) + clearOptimizeSnapshot() } function clearOptimizeSnapshot() { From 8c388c1e465b58cd2e419f99bf1d100502bf8140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 02:09:06 +0800 Subject: [PATCH 069/426] chore: sync working changes --- src-tauri/src/commands/config.rs | 6 ++++ src-tauri/src/commands/gateway_patch.rs | 14 +++++--- src-tauri/src/commands/messaging.rs | 48 ++++++++++++++----------- src/pages/channels.js | 12 +++++-- 4 files changed, 53 insertions(+), 27 deletions(-) diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 508595e9..c56e4350 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -753,6 +753,12 @@ fn detect_installed_source() -> String { return "chinese".into(); } } + if let Ok(root) = super::gateway_patch::npm_root_global() { + let zh_dir = root.join("@qingchencloud").join("openclaw-zh"); + if zh_dir.exists() { + return "chinese".into(); + } + } "official".into() } // 所有平台通用: npm list 检测 diff --git a/src-tauri/src/commands/gateway_patch.rs b/src-tauri/src/commands/gateway_patch.rs index 32f8648c..e5c59b37 100644 --- a/src-tauri/src/commands/gateway_patch.rs +++ b/src-tauri/src/commands/gateway_patch.rs @@ -139,7 +139,7 @@ fn write_status_to_panel(status: &GatewayPatchStatus) -> Result<(), String> { fs::write(&path, json).map_err(|e| format!("写入失败: {e}")) } -fn npm_root_global() -> Result { +pub(crate) fn npm_root_global() -> Result { #[cfg(target_os = "windows")] const CREATE_NO_WINDOW: u32 = 0x08000000; @@ -299,21 +299,25 @@ fn patch_gateway_file(path: &Path, _force: bool) -> Result { } let needle_assert = "\tif (job.sessionTarget === \"main\" && job.payload.kind !== \"systemEvent\") throw new Error(\"main cron jobs require payload.kind=\\\"systemEvent\\\"\");"; + let needle_assert_sp = " if (job.sessionTarget === \"main\" && job.payload.kind !== \"systemEvent\") throw new Error(\"main cron jobs require payload.kind=\\\"systemEvent\\\"\");"; let insert_assert = "\tif (job.sessionTarget === \"main\" && job.payload.kind !== \"systemEvent\" && job.payload.kind !== \"sessionMessage\") throw new Error(\"main cron jobs require payload.kind=\\\"systemEvent\\\" or \\\"sessionMessage\\\"\");"; let needle_merge = "\tif (patch.kind === \"systemEvent\") {\n\t\tif (existing.kind !== \"systemEvent\") return buildPayloadFromPatch(patch);\n\t\treturn {\n\t\t\tkind: \"systemEvent\",\n\t\t\ttext: typeof patch.text === \"string\" ? patch.text : existing.text\n\t\t};\n\t}\n\tif (existing.kind !== \"agentTurn\") return buildPayloadFromPatch(patch);"; + let needle_merge_sp = " if (patch.kind === \"systemEvent\") {\n if (existing.kind !== \"systemEvent\") return buildPayloadFromPatch(patch);\n return {\n kind: \"systemEvent\",\n text: typeof patch.text === \"string\" ? patch.text : existing.text\n };\n }\n if (existing.kind !== \"agentTurn\") return buildPayloadFromPatch(patch);"; let insert_merge = "\tif (patch.kind === \"systemEvent\") {\n\t\tif (existing.kind !== \"systemEvent\") return buildPayloadFromPatch(patch);\n\t\treturn {\n\t\t\tkind: \"systemEvent\",\n\t\t\ttext: typeof patch.text === \"string\" ? patch.text : existing.text\n\t\t};\n\t}\n\tif (patch.kind === \"sessionMessage\") {\n\t\tif (existing.kind !== \"sessionMessage\") return buildPayloadFromPatch(patch);\n\t\treturn {\n\t\t\tkind: \"sessionMessage\",\n\t\t\tlabel: typeof patch.label === \"string\" ? patch.label : existing.label,\n\t\t\tmessage: typeof patch.message === \"string\" ? patch.message : existing.message,\n\t\t\trole: \"user\",\n\t\t\twaitForIdle: typeof patch.waitForIdle === \"boolean\" ? patch.waitForIdle : existing.waitForIdle\n\t\t};\n\t}\n\tif (existing.kind !== \"agentTurn\") return buildPayloadFromPatch(patch);"; let needle_build = "\tif (patch.kind === \"systemEvent\") {\n\t\tif (typeof patch.text !== \"string\" || patch.text.length === 0) throw new Error(\"cron.update payload.kind=\\\"systemEvent\\\" requires text\");\n\t\treturn {\n\t\t\tkind: \"systemEvent\",\n\t\t\ttext: patch.text\n\t\t};\n\t}\n\tif (typeof patch.message !== \"string\" || patch.message.length === 0) throw new Error(\"cron.update payload.kind=\\\"agentTurn\\\" requires message\");"; + let needle_build_sp = " if (patch.kind === \"systemEvent\") {\n if (typeof patch.text !== \"string\" || patch.text.length === 0) throw new Error(\"cron.update payload.kind=\\\"systemEvent\\\" requires text\");\n return {\n kind: \"systemEvent\",\n text: patch.text\n };\n }\n if (typeof patch.message !== \"string\" || patch.message.length === 0) throw new Error(\"cron.update payload.kind=\\\"agentTurn\\\" requires message\");"; let insert_build = "\tif (patch.kind === \"systemEvent\") {\n\t\tif (typeof patch.text !== \"string\" || patch.text.length === 0) throw new Error(\"cron.update payload.kind=\\\"systemEvent\\\" requires text\");\n\t\treturn {\n\t\t\tkind: \"systemEvent\",\n\t\t\ttext: patch.text\n\t\t};\n\t}\n\tif (patch.kind === \"sessionMessage\") {\n\t\tif (typeof patch.label !== \"string\" || patch.label.length === 0) throw new Error(\"cron.update payload.kind=\\\"sessionMessage\\\" requires label\");\n\t\tif (typeof patch.message !== \"string\" || patch.message.length === 0) throw new Error(\"cron.update payload.kind=\\\"sessionMessage\\\" requires message\");\n\t\treturn {\n\t\t\tkind: \"sessionMessage\",\n\t\t\tlabel: patch.label,\n\t\t\tmessage: patch.message,\n\t\t\trole: \"user\",\n\t\t\twaitForIdle: typeof patch.waitForIdle === \"boolean\" ? patch.waitForIdle : true\n\t\t};\n\t}\n\tif (typeof patch.message !== \"string\" || patch.message.length === 0) throw new Error(\"cron.update payload.kind=\\\"agentTurn\\\" requires message\");"; let needle_execute = "\tif (abortSignal?.aborted) return resolveAbortError();\n\tif (job.sessionTarget === \"main\") {"; + let needle_execute_sp = " if (abortSignal?.aborted) return resolveAbortError();\n if (job.sessionTarget === \"main\") {"; let insert_execute = "\tif (abortSignal?.aborted) return resolveAbortError();\n\tif (job.payload.kind === \"sessionMessage\") {\n\t\tconst cfg = loadConfig();\n\t\tconst resolved = await resolveSessionKeyFromResolveParams({\n\t\t\tcfg,\n\t\t\tp: { label: job.payload.label }\n\t\t});\n\t\tif (!resolved.ok) return {\n\t\t\tstatus: \"error\",\n\t\t\terror: resolved.error?.message ?? \"session not found\"\n\t\t};\n\t\tif (job.payload.waitForIdle) {\n\t\t\tawait waitForActiveEmbeddedRuns(15e3);\n\t\t}\n\t\tconst { entry } = loadSessionEntry(resolved.key);\n\t\tconst prefixOptions = createReplyPrefixOptions({\n\t\t\tcfg,\n\t\t\tentry,\n\t\t\tsessionKey: resolved.key,\n\t\t\tclient: void 0\n\t\t});\n\t\tconst dispatcher = createReplyDispatcher({\n\t\t\t...prefixOptions,\n\t\t\tonError: (err) => {\n\t\t\t\tstate.deps.log.warn(`cron sessionMessage dispatch failed: ${String(err)}`);\n\t\t\t}\n\t\t});\n\t\tconst message = job.payload.message;\n\t\tconst ctx = {\n\t\t\tBody: message,\n\t\t\tBodyForAgent: message,\n\t\t\tBodyForCommands: message,\n\t\t\tRawBody: message,\n\t\t\tCommandBody: message,\n\t\t\tSessionKey: resolved.key,\n\t\t\tProvider: INTERNAL_MESSAGE_CHANNEL,\n\t\t\tSurface: INTERNAL_MESSAGE_CHANNEL,\n\t\t\tChatType: \"direct\",\n\t\t\tCommandAuthorized: true,\n\t\t\tMessageSid: `cron:${job.id}:${state.deps.nowMs()}`\n\t\t};\n\t\tawait dispatchInboundMessage({\n\t\t\tctx,\n\t\t\tcfg,\n\t\t\tdispatcher,\n\t\t\treplyOptions: { runId: `cron:${job.id}:${state.deps.nowMs()}` }\n\t\t});\n\t\treturn {\n\t\t\tstatus: \"ok\",\n\t\t\tsummary: message,\n\t\t\tsessionKey: resolved.key\n\t\t};\n\t}\n\tif (job.sessionTarget === \"main\") {"; - let mut next = replace_once(&content, needle_assert, insert_assert)?; - next = replace_once(&next, needle_merge, insert_merge)?; - next = replace_once(&next, needle_build, insert_build)?; - next = replace_once(&next, needle_execute, insert_execute)?; + let mut next = replace_once_any(&content, &[needle_assert, needle_assert_sp], insert_assert)?; + next = replace_once_any(&next, &[needle_merge, needle_merge_sp], insert_merge)?; + next = replace_once_any(&next, &[needle_build, needle_build_sp], insert_build)?; + next = replace_once_any(&next, &[needle_execute, needle_execute_sp], insert_execute)?; backup_file(path)?; fs::write(path, next).map_err(|e| format!("写入失败: {e}"))?; diff --git a/src-tauri/src/commands/messaging.rs b/src-tauri/src/commands/messaging.rs index e4f91e99..f61c78a9 100644 --- a/src-tauri/src/commands/messaging.rs +++ b/src-tauri/src/commands/messaging.rs @@ -72,19 +72,27 @@ pub async fn read_platform_config(platform: String) -> Result { .unwrap_or(Value::Null); let mut form = Map::new(); - let exists = !saved.is_null(); + let mut account_id: Option = None; + let mut saved_entry = saved.clone(); + if let Some(accounts) = saved.get("accounts").and_then(|v| v.as_object()) { + if let Some((acct, entry)) = accounts.iter().next() { + account_id = Some(acct.clone()); + saved_entry = entry.clone(); + } + } + let exists = !saved.is_null() && !saved_entry.is_null(); match platform.as_str() { "discord" => { - if saved.is_null() { + if saved_entry.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()) { + if let Some(t) = saved_entry.get("token").and_then(|v| v.as_str()) { form.insert("token".into(), Value::String(t.into())); } - if let Some(guilds) = saved.get("guilds").and_then(|v| v.as_object()) { + if let Some(guilds) = saved_entry.get("guilds").and_then(|v| v.as_object()) { if let Some(gid) = guilds.keys().next() { form.insert("guildId".into(), Value::String(gid.clone())); if let Some(channels) = guilds[gid].get("channels").and_then(|v| v.as_object()) @@ -99,24 +107,24 @@ pub async fn read_platform_config(platform: String) -> Result { } } "telegram" => { - if saved.is_null() { + if saved_entry.is_null() { return Ok(json!({ "exists": false })); } // Telegram: botToken 直接保存, allowFrom 数组需要拼回逗号字符串 - if let Some(t) = saved.get("botToken").and_then(|v| v.as_str()) { + if let Some(t) = saved_entry.get("botToken").and_then(|v| v.as_str()) { form.insert("botToken".into(), Value::String(t.into())); } - if let Some(arr) = saved.get("allowFrom").and_then(|v| v.as_array()) { + if let Some(arr) = saved_entry.get("allowFrom").and_then(|v| v.as_array()) { let users: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect(); form.insert("allowedUsers".into(), Value::String(users.join(", "))); } } "qqbot" => { - if saved.is_null() { + if saved_entry.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(t) = saved_entry.get("token").and_then(|v| v.as_str()) { if let Some((app_id, app_secret)) = t.split_once(':') { form.insert("appId".into(), Value::String(app_id.into())); form.insert("appSecret".into(), Value::String(app_secret.into())); @@ -124,31 +132,31 @@ pub async fn read_platform_config(platform: String) -> Result { } } "feishu" => { - if saved.is_null() { + if saved_entry.is_null() { return Ok(json!({ "exists": false })); } // 飞书: appId, appSecret, domain 直接保存 - if let Some(v) = saved.get("appId").and_then(|v| v.as_str()) { + if let Some(v) = saved_entry.get("appId").and_then(|v| v.as_str()) { form.insert("appId".into(), Value::String(v.into())); } - if let Some(v) = saved.get("appSecret").and_then(|v| v.as_str()) { + if let Some(v) = saved_entry.get("appSecret").and_then(|v| v.as_str()) { form.insert("appSecret".into(), Value::String(v.into())); } - if let Some(v) = saved.get("domain").and_then(|v| v.as_str()) { + if let Some(v) = saved_entry.get("domain").and_then(|v| v.as_str()) { form.insert("domain".into(), Value::String(v.into())); } } "dingtalk" | "dingtalk-connector" => { - if let Some(v) = saved.get("clientId").and_then(|v| v.as_str()) { + if let Some(v) = saved_entry.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()) { + if let Some(v) = saved_entry.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()) { + if let Some(v) = saved_entry.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()) { + if let Some(v) = saved_entry.get("gatewayPassword").and_then(|v| v.as_str()) { form.insert("gatewayPassword".into(), Value::String(v.into())); } match gateway_auth_mode(&cfg) { @@ -168,11 +176,11 @@ pub async fn read_platform_config(platform: String) -> Result { } } _ => { - if saved.is_null() { + if saved_entry.is_null() { return Ok(json!({ "exists": false })); } // 通用:原样返回字符串类型字段 - if let Some(obj) = saved.as_object() { + if let Some(obj) = saved_entry.as_object() { for (k, v) in obj { if k == "enabled" { continue; @@ -185,7 +193,7 @@ pub async fn read_platform_config(platform: String) -> Result { } } - Ok(json!({ "exists": exists, "values": Value::Object(form) })) + Ok(json!({ "exists": exists, "values": Value::Object(form), "accountId": account_id })) } /// 保存平台配置到 openclaw.json diff --git a/src/pages/channels.js b/src/pages/channels.js index c247ba36..93a7f729 100644 --- a/src/pages/channels.js +++ b/src/pages/channels.js @@ -320,11 +320,15 @@ async function openConfigDialog(pid, page, state) { let isEdit = false let agents = [] let currentBinding = '' + let existingAccountId = '' try { const res = await api.readPlatformConfig(pid) if (res?.values) { existing = res.values } + if (res?.accountId) { + existingAccountId = res.accountId + } if (res?.exists) { isEdit = true } @@ -337,7 +341,11 @@ async function openConfigDialog(pid, page, state) { const config = await api.readOpenclawConfig() const bindings = config?.bindings || [] const channelKey = getChannelBindingKey(pid) - const found = bindings.find(b => b.match?.channel === channelKey) + const found = bindings.find(b => { + if (b.match?.channel !== channelKey) return false + if (existingAccountId) return (b.match?.accountId || '') === existingAccountId + return !b.match?.accountId + }) if (found) currentBinding = found.agentId || '' } catch {} @@ -352,7 +360,7 @@ async function openConfigDialog(pid, page, state) { const accountIdHtml = supportsMultiAccount ? `
- +
为同一平台接入多个应用时,每个应用需要一个唯一的账号标识。不同账号可绑定不同 Agent
` : '' From 821269423fbf1cf613e28841df75687807bff918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 02:21:53 +0800 Subject: [PATCH 070/426] chore: checkpoint before audit fixes From b3579c06fa99d88dc1804a34db5e9a976b98c114 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 02:24:52 +0800 Subject: [PATCH 071/426] fix: cache system env and safe ai import --- src-tauri/src/commands/mod.rs | 22 ++++++++++++++++++++-- src/pages/assistant.js | 12 +++++++----- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index d01ca647..31648876 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -267,6 +267,14 @@ fn expand_env_vars(value: &str, env_map: &HashMap) -> String { } pub fn build_system_env() -> Vec<(String, String)> { + if let Ok(guard) = SYSTEM_ENV_CACHE.read() { + if let Some((ts, cached)) = &*guard { + if ts.elapsed().as_secs() <= SYSTEM_ENV_CACHE_TTL_SECS { + return cached.clone(); + } + } + } + #[cfg(target_os = "windows")] { let system_entries = read_registry_env( @@ -314,7 +322,11 @@ pub fn build_system_env() -> Vec<(String, String)> { let enhanced = build_enhanced_path_with_base(&base_path); map.insert("PATH".to_string(), enhanced); - map.into_iter().collect() + let built = map.into_iter().collect(); + if let Ok(mut guard) = SYSTEM_ENV_CACHE.write() { + *guard = Some((std::time::Instant::now(), built.clone())); + } + built } #[cfg(not(target_os = "windows"))] @@ -323,13 +335,19 @@ pub fn build_system_env() -> Vec<(String, String)> { let base = map.get("PATH").cloned().unwrap_or_default(); let enhanced = build_enhanced_path_with_base(&base); map.insert("PATH".to_string(), enhanced); - map.into_iter().collect() + let built = map.into_iter().collect(); + if let Ok(mut guard) = SYSTEM_ENV_CACHE.write() { + *guard = Some((std::time::Instant::now(), built.clone())); + } + built } } /// 缓存 enhanced_path 结果,避免每次调用都扫描文件系统 /// 使用 RwLock 替代 OnceLock,支持运行时刷新缓存 static ENHANCED_PATH_CACHE: RwLock> = RwLock::new(None); +static SYSTEM_ENV_CACHE: RwLock)>> = RwLock::new(None); +const SYSTEM_ENV_CACHE_TTL_SECS: u64 = 5; /// Tauri 应用启动时 PATH 可能不完整: /// - macOS 从 Finder 启动时 PATH 只有 /usr/bin:/bin:/usr/sbin:/sbin diff --git a/src/pages/assistant.js b/src/pages/assistant.js index 171dc93e..342e1103 100644 --- a/src/pages/assistant.js +++ b/src/pages/assistant.js @@ -2807,11 +2807,13 @@ function showSettings() { toast('未找到可导入的 AI 配置', 'warning') return } - baseUrlInput.value = result.baseUrl || '' - apiKeyInput.value = result.apiKey || '' - overlay.querySelector('#ast-model').value = result.model || '' - apiTypeSelect.value = normalizeApiType(result.apiType || 'openai-completions') - apiTypeSelect.dispatchEvent(new Event('change')) + if (result.baseUrl) baseUrlInput.value = result.baseUrl + if (result.apiKey) apiKeyInput.value = result.apiKey + if (result.model) overlay.querySelector('#ast-model').value = result.model + if (result.apiType) { + apiTypeSelect.value = normalizeApiType(result.apiType) + apiTypeSelect.dispatchEvent(new Event('change')) + } if (result.temperature && overlay.querySelector('#ast-temp')) { overlay.querySelector('#ast-temp').value = result.temperature } From df6bcf72d4c5eb8fb16f4941ef3d3453fe386ef1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 02:31:24 +0800 Subject: [PATCH 072/426] checkpoint before realtime version update From a5ff0aeda7f9e4519cc7e6819f8935dac6b72de4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 02:34:15 +0800 Subject: [PATCH 073/426] checkpoint before removing installed_version From 994456efbe11d330cd56e3ef954efad0cfa7d085 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 02:36:33 +0800 Subject: [PATCH 074/426] checkpoint before rust type fix From 504e411d5411c3dd5ec7150814cef63f0e80a3bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 02:42:49 +0800 Subject: [PATCH 075/426] checkpoint before warning cleanup From 6756d619c686cbc1dd7960301fb7068b61644793 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 02:46:06 +0800 Subject: [PATCH 076/426] chore: checkpoint before tool wrap fix From ca083b7320d97380f4e0c6354f3a5de110c90fe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 02:53:18 +0800 Subject: [PATCH 077/426] fix: sanitize tool output and markdown --- src/lib/markdown.js | 44 +++++++++++++---- src/pages/chat.js | 112 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 138 insertions(+), 18 deletions(-) diff --git a/src/lib/markdown.js b/src/lib/markdown.js index 9ff0711c..c874cf3b 100644 --- a/src/lib/markdown.js +++ b/src/lib/markdown.js @@ -46,6 +46,19 @@ function escapeHtml(str) { .replace(/'/g, ''') } +function escapeHtmlLite(str) { + return str + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function stripAnsi(str) { + if (!str) return '' + return str.replace(/\u001b\[[0-9;]*[A-Za-z]/g, '') +} + // 预加载 Tauri convertFileSrc let _convertFileSrc = null if (typeof window !== 'undefined' && window.__TAURI_INTERNALS__) { @@ -74,7 +87,7 @@ function resolveImageSrc(src) { export function renderMarkdown(text) { if (!text) return '' - let html = text + let html = stripAnsi(text) // 代码块 html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => { @@ -134,8 +147,7 @@ export function renderMarkdown(text) { if (inList) { result.push(``); inList = false } if (line.trim() === '') { result.push(''); continue } - if (!line.startsWith('<')) { result.push(`

${inlineFormat(line)}

`) } - else { result.push(line) } + result.push(`

${inlineFormat(line)}

`) } if (inList) result.push(``) @@ -143,19 +155,31 @@ export function renderMarkdown(text) { } function inlineFormat(text) { - return text - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/\*(.+?)\*/g, '$1') - .replace(/__(.+?)__/g, '$1') - .replace(/_(.+?)_/g, '$1') + const codeSpans = [] + let safe = text.replace(/`([^`\n]+)`/g, (_, code) => { + const token = `__CODE_${codeSpans.length}__` + codeSpans.push(escapeHtml(code)) + return token + }) + safe = escapeHtmlLite(safe) + safe = safe .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) => { const safeSrc = resolveImageSrc(src.trim()) - return `${alt}` + return `${escapeHtmlLite(alt)}` }) .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => { const safe = /^https?:|^mailto:/i.test(url.trim()) ? url : '#' - return `${label}` + return `${escapeHtmlLite(label)}` }) + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/__(.+?)__/g, '$1') + .replace(/_(.+?)_/g, '$1') + + codeSpans.forEach((code, idx) => { + safe = safe.replace(`__CODE_${idx}__`, `${code}`) + }) + return safe } window.__copyCode = function(btn) { diff --git a/src/pages/chat.js b/src/pages/chat.js index d5da0212..c62afb5a 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -1101,13 +1101,75 @@ function handleChatEvent(payload) { } } +function upsertTool(tools, entry) { + if (!entry) return + const id = entry.id || entry.tool_call_id + let target = null + if (id) target = tools.find(t => t.id === id || t.tool_call_id === id) + if (!target && entry.name) target = tools.find(t => t.name === entry.name && !t.output) + if (target) { + if (entry.input != null && target.input == null) target.input = entry.input + if (entry.output != null) target.output = entry.output + if (entry.status) target.status = entry.status + return + } + tools.push(entry) +} + +function collectToolsFromMessage(message, tools) { + if (!message || !tools) return + const toolCalls = message.tool_calls || message.toolCalls || message.tools + if (Array.isArray(toolCalls)) { + toolCalls.forEach(call => { + const fn = call.function || null + const name = call.name || call.tool || call.tool_name || fn?.name + const input = call.input || call.args || call.parameters || call.arguments || fn?.arguments || null + upsertTool(tools, { + id: call.id || call.tool_call_id, + name: name || '工具', + input, + output: null, + status: call.status || 'ok', + }) + }) + } + const toolResults = message.tool_results || message.toolResults + if (Array.isArray(toolResults)) { + toolResults.forEach(res => { + upsertTool(tools, { + id: res.id || res.tool_call_id, + name: res.name || res.tool || res.tool_name || '工具', + input: res.input || res.args || null, + output: res.output || res.result || res.content || null, + status: res.status || 'ok', + }) + }) + } +} + /** 从 Gateway message 对象提取文本和所有媒体(参照 clawapp extractContent) */ function extractChatContent(message) { if (!message || typeof message !== 'object') return null + const tools = [] + collectToolsFromMessage(message, tools) + if (message.role === 'tool' || message.role === 'toolResult') { + const output = typeof message.content === 'string' ? message.content : null + if (!tools.length) { + tools.push({ + name: message.name || message.tool || message.tool_name || '工具', + input: message.input || message.args || message.parameters || null, + output: output || message.output || message.result || null, + status: message.status || 'ok', + }) + } else if (output && !tools[0].output) { + tools[0].output = output + } + return { text: '', images: [], videos: [], audios: [], files: [], tools } + } const content = message.content - if (typeof content === 'string') return { text: stripThinkingTags(content), images: [], videos: [], audios: [], files: [], tools: [] } + if (typeof content === 'string') return { text: stripThinkingTags(content), images: [], videos: [], audios: [], files: [], tools } if (Array.isArray(content)) { - const texts = [], images = [], videos = [], audios = [], files = [], tools = [] + const texts = [], images = [], videos = [], audios = [], files = [] for (const block of content) { if (block.type === 'text' && typeof block.text === 'string') texts.push(block.text) else if (block.type === 'image' && !block.omitted) { @@ -1144,6 +1206,12 @@ function extractChatContent(message) { }) } } + if (tools.length) { + tools.forEach(t => { + if (typeof t.input === 'string') t.input = stripAnsi(t.input) + if (typeof t.output === 'string') t.output = stripAnsi(t.output) + }) + } // 从 mediaUrl/mediaUrls 提取 const mediaUrls = message.mediaUrls || (message.mediaUrl ? [message.mediaUrl] : []) for (const url of mediaUrls) { @@ -1160,8 +1228,14 @@ function extractChatContent(message) { return null } +function stripAnsi(text) { + if (!text) return '' + return text.replace(/\u001b\[[0-9;]*[A-Za-z]/g, '') +} + function stripThinkingTags(text) { - return text + const safe = stripAnsi(text) + return safe .replace(/<\s*think(?:ing)?\s*>[\s\S]*?<\s*\/\s*think(?:ing)?\s*>/gi, '') .replace(/Conversation info \(untrusted metadata\):\s*```json[\s\S]*?```\s*/gi, '') .replace(/\[Queued messages while agent was busy\]\s*---\s*Queued #\d+\s*/gi, '') @@ -1353,8 +1427,24 @@ function dedupeHistory(messages) { } function extractContent(msg) { + const tools = [] + collectToolsFromMessage(msg, tools) + if (msg.role === 'tool' || msg.role === 'toolResult') { + const output = typeof msg.content === 'string' ? msg.content : null + if (!tools.length) { + tools.push({ + name: msg.name || msg.tool || msg.tool_name || '工具', + input: msg.input || msg.args || msg.parameters || null, + output: output || msg.output || msg.result || null, + status: msg.status || 'ok', + }) + } else if (output && !tools[0].output) { + tools[0].output = output + } + return { text: '', images: [], videos: [], audios: [], files: [], tools } + } if (Array.isArray(msg.content)) { - const texts = [], images = [], videos = [], audios = [], files = [], tools = [] + const texts = [], images = [], videos = [], audios = [], files = [] for (const block of msg.content) { if (block.type === 'text' && typeof block.text === 'string') texts.push(block.text) else if (block.type === 'image' && !block.omitted) { @@ -1391,7 +1481,13 @@ function extractContent(msg) { }) } } - const mediaUrls = msg.mediaUrls || (msg.mediaUrl ? [msg.mediaUrl] : []) + if (tools.length) { + tools.forEach(t => { + if (typeof t.input === 'string') t.input = stripAnsi(t.input) + if (typeof t.output === 'string') t.output = stripAnsi(t.output) + }) + } + const mediaUrls = msg.mediaUrls || (msg.mediaUrl ? [msg.mediaUrl] : []) for (const url of mediaUrls) { if (!url) continue if (/\.(mp4|webm|mov|mkv)(\?|$)/i.test(url)) videos.push({ url, mediaType: 'video/mp4' }) @@ -1402,7 +1498,7 @@ function extractContent(msg) { return { text: stripThinkingTags(texts.join('\n')), images, videos, audios, files, tools } } const text = typeof msg.text === 'string' ? msg.text : (typeof msg.content === 'string' ? msg.content : '') - return { text: stripThinkingTags(text), images: [], videos: [], audios: [], files: [], tools: [] } + return { text: stripThinkingTags(text), images: [], videos: [], audios: [], files: [], tools } } // ── DOM 操作 ── @@ -1598,8 +1694,8 @@ function appendToolsToEl(el, tools) { open: details.open, bodyDisplay: body.style.display, }) - const inputJson = safeStringify(tool.input) - const outputJson = safeStringify(tool.output) + const inputJson = stripAnsi(safeStringify(tool.input)) + const outputJson = stripAnsi(safeStringify(tool.output)) const input = inputJson ? `
参数
${escapeHtml(inputJson)}
` : '' const output = outputJson ? `
结果
${escapeHtml(outputJson)}
` : '' body.innerHTML = input + output From 03680198f3b73026c8441be8fbc9d758f68d7bbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 03:21:14 +0800 Subject: [PATCH 078/426] chore: checkpoint before tool-only fix From a32e04f1b98b57d42b4c70692f79b251372edd6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 03:24:07 +0800 Subject: [PATCH 079/426] fix: render tool-only chat output --- src/pages/chat.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/pages/chat.js b/src/pages/chat.js index c62afb5a..1b780f2a 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -59,7 +59,7 @@ 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 _currentAiBubble = null, _currentAiText = '', _currentAiImages = [], _currentAiVideos = [], _currentAiAudios = [], _currentAiFiles = [], _currentAiTools = [], _currentRunId = null let _isStreaming = false, _isSending = false, _messageQueue = [], _streamStartTime = 0 let _lastRenderTime = 0, _renderPending = false, _lastHistoryHash = '' let _streamSafetyTimer = null, _unsubEvent = null, _unsubReady = null, _unsubStatus = null @@ -946,6 +946,7 @@ function handleChatEvent(payload) { if (c?.videos?.length) _currentAiVideos = c.videos if (c?.audios?.length) _currentAiAudios = c.audios if (c?.files?.length) _currentAiFiles = c.files + if (c?.tools?.length) _currentAiTools = c.tools if (c?.text && c.text.length > _currentAiText.length) { showTyping(false) if (!_currentAiBubble) { @@ -986,7 +987,8 @@ function handleChatEvent(payload) { if (finalVideos.length) _currentAiVideos = finalVideos if (finalAudios.length) _currentAiAudios = finalAudios if (finalFiles.length) _currentAiFiles = finalFiles - const hasContent = finalText || _currentAiImages.length || _currentAiVideos.length || _currentAiAudios.length || _currentAiFiles.length + if (finalTools.length) _currentAiTools = finalTools + const hasContent = finalText || _currentAiImages.length || _currentAiVideos.length || _currentAiAudios.length || _currentAiFiles.length || _currentAiTools.length // 忽略空 final(Gateway 会为一条消息触发多个 run,部分是空 final) if (!_currentAiBubble && !hasContent) return // 标记 runId 为已处理,防止重复 @@ -1009,7 +1011,7 @@ function handleChatEvent(payload) { appendVideosToEl(_currentAiBubble, _currentAiVideos) appendAudiosToEl(_currentAiBubble, _currentAiAudios) appendFilesToEl(_currentAiBubble, _currentAiFiles) - appendToolsToEl(_currentAiBubble, finalTools) + appendToolsToEl(_currentAiBubble, finalTools.length ? finalTools : _currentAiTools) } // 添加时间戳 + 耗时 + token 消耗 const wrapper = _currentAiBubble?.parentElement @@ -1300,12 +1302,13 @@ function doRender() { function resetStreamState() { clearTimeout(_streamSafetyTimer) - if (_currentAiBubble && (_currentAiText || _currentAiImages.length || _currentAiVideos.length || _currentAiAudios.length || _currentAiFiles.length)) { + if (_currentAiBubble && (_currentAiText || _currentAiImages.length || _currentAiVideos.length || _currentAiAudios.length || _currentAiFiles.length || _currentAiTools.length)) { _currentAiBubble.innerHTML = renderMarkdown(_currentAiText) appendImagesToEl(_currentAiBubble, _currentAiImages) appendVideosToEl(_currentAiBubble, _currentAiVideos) appendAudiosToEl(_currentAiBubble, _currentAiAudios) appendFilesToEl(_currentAiBubble, _currentAiFiles) + appendToolsToEl(_currentAiBubble, _currentAiTools) } _renderPending = false _lastRenderTime = 0 @@ -1315,6 +1318,7 @@ function resetStreamState() { _currentAiVideos = [] _currentAiAudios = [] _currentAiFiles = [] + _currentAiTools = [] _currentRunId = null _isStreaming = false _streamStartTime = 0 @@ -1842,6 +1846,7 @@ export function cleanup() { _currentAiVideos = [] _currentAiAudios = [] _currentAiFiles = [] + _currentAiTools = [] _currentRunId = null _isStreaming = false _isSending = false From 326c5be5357e4c00cafc811c9ff08ae0033e81cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 03:37:53 +0800 Subject: [PATCH 080/426] chore: checkpoint before fix unused var From 073b6b04911980f525fd181c3063252decc45d9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 03:38:40 +0800 Subject: [PATCH 081/426] fix: silence unused enhanced warning --- src-tauri/src/utils.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index 8ca316bb..e243fb06 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -22,7 +22,7 @@ pub fn openclaw_command() -> std::process::Command { #[cfg(target_os = "windows")] { const CREATE_NO_WINDOW: u32 = 0x08000000; - let enhanced = crate::commands::enhanced_path(); + 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"); @@ -54,7 +54,7 @@ pub fn openclaw_command_async() -> tokio::process::Command { #[cfg(target_os = "windows")] { const CREATE_NO_WINDOW: u32 = 0x08000000; - let enhanced = crate::commands::enhanced_path(); + let _enhanced = crate::commands::enhanced_path(); // 优先:找到 openclaw.cmd 完整路径 if let Some(cmd_path) = find_openclaw_cmd() { let mut cmd = tokio::process::Command::new("cmd"); From add7784a13d493391ee0b215576633c5d6a9af50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 03:40:24 +0800 Subject: [PATCH 082/426] chore: checkpoint before fix escapeHtml From c24f7884ecbdc30c29bb0c52adcb5f0b6d9dc118 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 03:45:38 +0800 Subject: [PATCH 083/426] fix: deliver control ui messages --- src/lib/ws-client.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/ws-client.js b/src/lib/ws-client.js index c5f7ad2a..49249350 100644 --- a/src/lib/ws-client.js +++ b/src/lib/ws-client.js @@ -374,7 +374,7 @@ export class WsClient { } chatSend(sessionKey, message, attachments) { - const params = { sessionKey, message, deliver: false, idempotencyKey: uuid() } + const params = { sessionKey, message, deliver: true, idempotencyKey: uuid() } if (attachments && attachments.length > 0) { params.attachments = attachments console.log('[ws] 发送附件:', attachments.length, '个') From 47a068e277bbd1805831fe24951114fec96be18f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 03:54:10 +0800 Subject: [PATCH 084/426] docs: add chat daylight shadow design --- .../2026-03-17-chat-daylight-shadow-design.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/plans/2026-03-17-chat-daylight-shadow-design.md diff --git a/docs/plans/2026-03-17-chat-daylight-shadow-design.md b/docs/plans/2026-03-17-chat-daylight-shadow-design.md new file mode 100644 index 00000000..ce6c77cf --- /dev/null +++ b/docs/plans/2026-03-17-chat-daylight-shadow-design.md @@ -0,0 +1,37 @@ +# Chat 日间模式助手气泡阴影设计 + +## 目标 +- 解决日间模式下助手气泡不明显的问题 +- 不改变夜间模式表现 +- 不改变间距、圆角、字体,仅增加轻微阴影 + +## 背景 +- 当前 `.msg-ai .msg-bubble` 使用半透明背景,在日间模式可能与底色融合,导致难以区分 +- 用户确认仅需补充日间模式阴影 + +## 方案对比 +### 方案 A(推荐) +- 仅在日间模式为 `.msg-ai .msg-bubble` 增加轻微阴影 +- 夜间模式保持不变 +- 风险最小,符合当前需求 + +### 方案 B +- 日间模式增加阴影 + 细边框 +- 对比更强,但视觉改动更明显 + +### 方案 C +- 调整日间模式背景色 + 阴影 +- 可读性提升,但偏离现有视觉体系 + +## 采用方案 +- 采用方案 A + +## 设计细节 +- 选择器:`[data-theme="light"] .msg-ai .msg-bubble` +- 阴影强度:轻微,不影响布局 +- 不改变背景色、圆角、内边距 + +## 验收标准 +- 日间模式:助手消息气泡可清晰区分 +- 夜间模式:视觉无变化 +- 不引入新的布局抖动或遮挡问题 From 87c90f1dcd49d5448a4850710d44f5866cc62f86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 03:55:34 +0800 Subject: [PATCH 085/426] docs: add chat daylight shadow plan --- .../plans/2026-03-17-chat-daylight-shadow.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-17-chat-daylight-shadow.md diff --git a/docs/superpowers/plans/2026-03-17-chat-daylight-shadow.md b/docs/superpowers/plans/2026-03-17-chat-daylight-shadow.md new file mode 100644 index 00000000..92195288 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-chat-daylight-shadow.md @@ -0,0 +1,46 @@ +# Chat Daylight Shadow Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a light-mode shadow to assistant message bubbles so they remain visible against the page background. + +**Architecture:** Update chat CSS to apply a light-mode-only shadow on `.msg-ai .msg-bubble` without changing dark mode. No layout or markup changes. + +**Tech Stack:** CSS, Vite build + +--- + +## Chunk 1: Daylight shadow style + +### Task 1: Add light-mode shadow for assistant bubbles + +**Files:** +- Modify: `src/style/chat.css` (near `.msg-ai .msg-bubble` rules) + +- [ ] **Step 1: Add light-mode CSS rule** + +Add a new rule scoped to light theme: + +```css +[data-theme="light"] .msg-ai .msg-bubble { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} +``` + +- [ ] **Step 2: Build** + +Run: `npm run build` +Expected: Build succeeds without errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/style/chat.css +git commit -m "fix: add daylight shadow for ai bubble" +``` + +- [ ] **Step 4: Push** + +```bash +git push +``` From 7df5d2180d742c8ffbec93b21871297179d0e546 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 04:08:15 +0800 Subject: [PATCH 086/426] chore: checkpoint before chat daylight shadow From 57a46bbd244911fba366b529a46e05c917ad4a41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 04:09:36 +0800 Subject: [PATCH 087/426] fix: add daylight shadow for ai bubble --- src/style/chat.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/style/chat.css b/src/style/chat.css index 250e91e8..27f58442 100644 --- a/src/style/chat.css +++ b/src/style/chat.css @@ -207,6 +207,10 @@ border: 1px solid rgba(255, 255, 255, 0.08); } +[data-theme="light"] .msg-ai .msg-bubble { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} + /* AI 气泡内 Markdown 排版 */ .msg-ai .msg-bubble p { margin: 0 0 8px 0; From 65faaabb48e9f1aa1cfd4e30ebe560947caeaf5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 04:17:06 +0800 Subject: [PATCH 088/426] docs: add toast vercel design --- docs/plans/2026-03-17-toast-vercel-design.md | 27 ++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 docs/plans/2026-03-17-toast-vercel-design.md diff --git a/docs/plans/2026-03-17-toast-vercel-design.md b/docs/plans/2026-03-17-toast-vercel-design.md new file mode 100644 index 00000000..a4baaf5a --- /dev/null +++ b/docs/plans/2026-03-17-toast-vercel-design.md @@ -0,0 +1,27 @@ +# Toast Vercel 简约风格设计 + +## 目标 +- 去掉液态玻璃/高斯模糊效果 +- 使用 Vercel 简约风格:纯色卡片 + 细边框 +- 日夜主题自动适配 + +## 现状 +- `.toast` 使用 `backdrop-filter: blur(12px)` +- 背景使用 `--success-muted / --error-muted / --info-muted / --warning-muted` + +## 方案 +- 移除 `backdrop-filter` +- 背景统一使用 `var(--bg-primary)` +- 边框统一使用 `1px solid var(--border)` +- 文本颜色维持状态色(success/error/info/warning) + +## 设计细节 +- 选择器:`.toast` +- 状态色:保留 `.toast.success/.error/.info/.warning` 的文字颜色 +- 背景色:`var(--bg-primary)` +- 边框:`1px solid var(--border)` + +## 验收标准 +- 日夜模式下 toast 不卡壳、无毛玻璃效果 +- 视觉风格与 Vercel 简约风格一致 +- 状态色可辨识 From ecdc9867ecfd3d5739597ee47a3d0042e551d243 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 04:17:54 +0800 Subject: [PATCH 089/426] docs: add toast vercel plan --- .../plans/2026-03-17-toast-vercel.md | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-17-toast-vercel.md diff --git a/docs/superpowers/plans/2026-03-17-toast-vercel.md b/docs/superpowers/plans/2026-03-17-toast-vercel.md new file mode 100644 index 00000000..41d72a17 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-toast-vercel.md @@ -0,0 +1,47 @@ +# Toast Vercel Style Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace glass/blur toast with solid Vercel-style card that adapts to light/dark themes. + +**Architecture:** Update toast styles in `components.css` to remove blur, use theme variables for background and border, keep status text colors. + +**Tech Stack:** CSS, Vite build + +--- + +## Chunk 1: Toast style update + +### Task 1: Update toast base style + +**Files:** +- Modify: `src/style/components.css` + +- [ ] **Step 1: Remove blur and set base card styles** + +Update `.toast` rule: +- remove `backdrop-filter` +- add `background: var(--bg-primary);` +- add `border: 1px solid var(--border);` + +- [ ] **Step 2: Simplify status variants** + +Update `.toast.success/.error/.info/.warning` to only set `color`, removing background and border overrides. + +- [ ] **Step 3: Build** + +Run: `npm run build` +Expected: Build succeeds without errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/style/components.css +git commit -m "fix: vercel-style toast card" +``` + +- [ ] **Step 5: Push** + +```bash +git push +``` From ee4725258d143a62fb8a5c72784dde2d85627060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 04:19:14 +0800 Subject: [PATCH 090/426] docs: add toast plan checkpoint step --- docs/superpowers/plans/2026-03-17-toast-vercel.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/superpowers/plans/2026-03-17-toast-vercel.md b/docs/superpowers/plans/2026-03-17-toast-vercel.md index 41d72a17..ed92e2c7 100644 --- a/docs/superpowers/plans/2026-03-17-toast-vercel.md +++ b/docs/superpowers/plans/2026-03-17-toast-vercel.md @@ -17,6 +17,13 @@ **Files:** - Modify: `src/style/components.css` +- [ ] **Step 0: Checkpoint** + +```bash +git status -sb +git commit --allow-empty -m "chore: checkpoint before toast style update" +``` + - [ ] **Step 1: Remove blur and set base card styles** Update `.toast` rule: From 9aaac33ca0ee183e575a354ab28181c02b5c9bf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 04:19:54 +0800 Subject: [PATCH 091/426] chore: checkpoint before toast style update From 1e4960d4b70576f783aca2606d04a1c2b116a7e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 04:20:37 +0800 Subject: [PATCH 092/426] fix: vercel-style toast card --- src/style/components.css | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/style/components.css b/src/style/components.css index 8510e1b2..6da16dd1 100644 --- a/src/style/components.css +++ b/src/style/components.css @@ -187,15 +187,16 @@ padding: var(--space-md) var(--space-lg); border-radius: var(--radius-md); font-size: var(--font-size-sm); - backdrop-filter: blur(12px); + background: var(--bg-primary); + border: 1px solid var(--border); animation: slideIn 250ms ease; max-width: 360px; } -.toast.success { background: var(--success-muted); border: 1px solid rgba(34,197,94,0.3); color: var(--success); } -.toast.error { background: var(--error-muted); border: 1px solid rgba(239,68,68,0.3); color: var(--error); } -.toast.info { background: var(--info-muted); border: 1px solid rgba(59,130,246,0.3); color: var(--info); } -.toast.warning { background: var(--warning-muted); border: 1px solid rgba(245,158,11,0.3); color: var(--warning); } +.toast.success { color: var(--success); } +.toast.error { color: var(--error); } +.toast.info { color: var(--info); } +.toast.warning { color: var(--warning); } @keyframes slideIn { from { opacity: 0; transform: translateX(20px); } From b0964ecee6c060f36426fef279ab34543eda7481 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 04:22:41 +0800 Subject: [PATCH 093/426] docs: add toast shadow design --- docs/plans/2026-03-17-toast-shadow-design.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 docs/plans/2026-03-17-toast-shadow-design.md diff --git a/docs/plans/2026-03-17-toast-shadow-design.md b/docs/plans/2026-03-17-toast-shadow-design.md new file mode 100644 index 00000000..2ca9e07f --- /dev/null +++ b/docs/plans/2026-03-17-toast-shadow-design.md @@ -0,0 +1,18 @@ +# Toast 阴影强度设计 + +## 目标 +- 为 toast 增加轻微阴影 +- 保持 Vercel 简约风格 +- 日夜主题自动适配 + +## 方案 +- 采用中等强度阴影:`0 4px 12px rgba(0,0,0,0.12)` + +## 设计细节 +- 选择器:`.toast` +- 在现有背景与边框基础上新增 `box-shadow` + +## 验收标准 +- 日间模式下 toast 清晰可见 +- 夜间模式不刺眼 +- 不影响布局与动画 From 7de0e990f929fdb34258d7eb413fe3509debe0db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 04:23:10 +0800 Subject: [PATCH 094/426] docs: add toast shadow plan --- .../plans/2026-03-17-toast-shadow.md | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-17-toast-shadow.md diff --git a/docs/superpowers/plans/2026-03-17-toast-shadow.md b/docs/superpowers/plans/2026-03-17-toast-shadow.md new file mode 100644 index 00000000..e0f2fb5a --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-toast-shadow.md @@ -0,0 +1,51 @@ +# Toast Shadow Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a medium-strength shadow to toast cards while keeping the Vercel-style solid background. + +**Architecture:** Modify `.toast` rule in `components.css` to add `box-shadow` only. + +**Tech Stack:** CSS, Vite build + +--- + +## Chunk 1: Toast shadow + +### Task 1: Add toast shadow + +**Files:** +- Modify: `src/style/components.css` + +- [ ] **Step 0: Checkpoint** + +```bash +git status -sb +git commit --allow-empty -m "chore: checkpoint before toast shadow" +``` + +- [ ] **Step 1: Add box-shadow** + +```css +.toast { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); +} +``` + +- [ ] **Step 2: Build** + +Run: `npm run build` +Expected: Build succeeds without errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/style/components.css +git commit -m "fix: add toast shadow" +``` + +- [ ] **Step 4: Push** + +```bash +git push +``` From 00862827425094384c4d835b91d9c1d5338e5458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 04:24:11 +0800 Subject: [PATCH 095/426] docs: clarify toast shadow plan --- docs/superpowers/plans/2026-03-17-toast-shadow.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-03-17-toast-shadow.md b/docs/superpowers/plans/2026-03-17-toast-shadow.md index e0f2fb5a..f8b8340f 100644 --- a/docs/superpowers/plans/2026-03-17-toast-shadow.md +++ b/docs/superpowers/plans/2026-03-17-toast-shadow.md @@ -17,9 +17,9 @@ **Files:** - Modify: `src/style/components.css` -- [ ] **Step 0: Checkpoint** +- [ ] **Step 0: Checkpoint(PowerShell)** -```bash +```powershell git status -sb git commit --allow-empty -m "chore: checkpoint before toast shadow" ``` @@ -36,6 +36,7 @@ git commit --allow-empty -m "chore: checkpoint before toast shadow" Run: `npm run build` Expected: Build succeeds without errors. +Note: This is the frontend Vite build; no `wails build` required for this CSS-only change. - [ ] **Step 3: Commit** From 8be55826406004ceb8abb206f08dcc01bfcb98bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 04:25:03 +0800 Subject: [PATCH 096/426] docs: fix toast shadow plan format --- docs/superpowers/plans/2026-03-17-toast-shadow.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-03-17-toast-shadow.md b/docs/superpowers/plans/2026-03-17-toast-shadow.md index f8b8340f..beb3bea8 100644 --- a/docs/superpowers/plans/2026-03-17-toast-shadow.md +++ b/docs/superpowers/plans/2026-03-17-toast-shadow.md @@ -24,6 +24,8 @@ git status -sb git commit --allow-empty -m "chore: checkpoint before toast shadow" ``` +Note: This checkpoint is required by policy to protect rollbacks. The final functional commit still happens after `npm run build`. + - [ ] **Step 1: Add box-shadow** ```css @@ -38,15 +40,15 @@ Run: `npm run build` Expected: Build succeeds without errors. Note: This is the frontend Vite build; no `wails build` required for this CSS-only change. -- [ ] **Step 3: Commit** +- [ ] **Step 3: Commit(PowerShell)** -```bash +```powershell git add src/style/components.css git commit -m "fix: add toast shadow" ``` -- [ ] **Step 4: Push** +- [ ] **Step 4: Push(PowerShell)** -```bash +```powershell git push ``` From 80f122688ebccc25fa6bdd2a80d5616b428e343b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 04:25:34 +0800 Subject: [PATCH 097/426] chore: checkpoint before toast shadow From 4cf56ff39f280bef685208d1459be4e8d3ad31dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 04:26:06 +0800 Subject: [PATCH 098/426] fix: add toast shadow --- src/style/components.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/style/components.css b/src/style/components.css index 6da16dd1..6e9a8324 100644 --- a/src/style/components.css +++ b/src/style/components.css @@ -189,6 +189,7 @@ font-size: var(--font-size-sm); background: var(--bg-primary); border: 1px solid var(--border); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); animation: slideIn 250ms ease; max-width: 360px; } From 25ccfd788846cc70b16eca1aa46295830aa25aaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 04:36:02 +0800 Subject: [PATCH 099/426] docs: add toast night and model select design --- ...-17-toast-night-and-model-select-design.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 docs/plans/2026-03-17-toast-night-and-model-select-design.md diff --git a/docs/plans/2026-03-17-toast-night-and-model-select-design.md b/docs/plans/2026-03-17-toast-night-and-model-select-design.md new file mode 100644 index 00000000..da099118 --- /dev/null +++ b/docs/plans/2026-03-17-toast-night-and-model-select-design.md @@ -0,0 +1,36 @@ +# Toast 夜间样式与 Chat Model Select 设计 + +## 目标 +- Toast 夜间模式可见性提升,风格参考 Vercel 简约风 +- Chat 模型选择器不截断,宽度自适应内容 + +## Toast 夜间样式 +### 现状 +- Toast 使用统一背景与边框,夜间模式对比不足 + +### 方案(Vercel 简约风) +- 夜间模式下使用更深背景色与清晰边框 +- 保留状态色文字 + +### 设计细节 +- 选择器:`[data-theme="dark"] .toast` +- 背景:更深的卡片色(参考 Vercel 夜间卡片风格) +- 边框:`1px solid var(--border)` 保持一致性 +- 阴影:沿用当前中等阴影 + +## Chat Model Select +### 现状 +- `chat-model-select` 文字被截断 + +### 方案 +- 宽度随内容自适应,不做截断 + +### 设计细节 +- 选择器:`.chat-model-select` 或其输入容器 +- 移除固定宽度/最大宽度 +- 允许内容自然撑开 + +## 验收标准 +- 夜间模式下 toast 清晰可见,风格简约 +- chat-model-select 文字不再被截断 +- 不影响其他布局与交互 From a1114ab9ff298b9dfd437d43c19f6fc9f76a7b9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 04:36:37 +0800 Subject: [PATCH 100/426] docs: add toast night and model select plan --- ...2026-03-17-toast-night-and-model-select.md | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-17-toast-night-and-model-select.md diff --git a/docs/superpowers/plans/2026-03-17-toast-night-and-model-select.md b/docs/superpowers/plans/2026-03-17-toast-night-and-model-select.md new file mode 100644 index 00000000..ba0b8061 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-toast-night-and-model-select.md @@ -0,0 +1,99 @@ +# Toast Night Style + Model Select Width Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Improve toast visibility in dark mode and make chat-model-select auto-size without truncation. + +**Architecture:** Update CSS rules in `components.css` for toast dark mode and in `chat.css` for model select width. + +**Tech Stack:** CSS, Vite build + +--- + +## Chunk 1: Toast dark mode visibility + +### Task 1: Add dark theme toast background + +**Files:** +- Modify: `src/style/components.css` + +- [ ] **Step 0: Checkpoint(PowerShell)** + +```powershell +git status -sb +git commit --allow-empty -m "chore: checkpoint before toast dark style" +``` + +Note: This checkpoint is required by policy; final functional commit occurs after build. + +- [ ] **Step 1: Add dark theme override** + +```css +[data-theme="dark"] .toast { + background: var(--bg-secondary); +} +``` + +- [ ] **Step 2: Build** + +Run: `npm run build` +Expected: Build succeeds without errors. + +- [ ] **Step 3: Commit(PowerShell)** + +```powershell +git add src/style/components.css +git commit -m "fix: improve toast dark mode"` +``` + +- [ ] **Step 4: Push(PowerShell)** + +```powershell +git push +``` + +## Chunk 2: Model select auto width + +### Task 2: Remove truncation and allow auto width + +**Files:** +- Modify: `src/style/chat.css` + +- [ ] **Step 0: Checkpoint(PowerShell)** + +```powershell +git status -sb +git commit --allow-empty -m "chore: checkpoint before model select width" +``` + +- [ ] **Step 1: Update model select styles** + +Set the model select to auto width and remove truncation. Example: + +```css +.chat-model-select { + width: auto; + max-width: none; + white-space: nowrap; +} +``` + +Adjust if selectors differ in current file. + +- [ ] **Step 2: Build** + +Run: `npm run build` +Expected: Build succeeds without errors. + +- [ ] **Step 3: Commit(PowerShell)** + +```powershell +git add src/style/chat.css +git commit -m "fix: auto width for chat model select" +``` + +- [ ] **Step 4: Push(PowerShell)** + +```powershell +git push +``` From 2a047b5e7ab7fcc5623766c8fcf3e9535a798e17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 04:37:39 +0800 Subject: [PATCH 101/426] docs: fix toast night and model select plan --- .../plans/2026-03-17-toast-night-and-model-select.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-03-17-toast-night-and-model-select.md b/docs/superpowers/plans/2026-03-17-toast-night-and-model-select.md index ba0b8061..9c1c4519 100644 --- a/docs/superpowers/plans/2026-03-17-toast-night-and-model-select.md +++ b/docs/superpowers/plans/2026-03-17-toast-night-and-model-select.md @@ -24,7 +24,7 @@ git status -sb git commit --allow-empty -m "chore: checkpoint before toast dark style" ``` -Note: This checkpoint is required by policy; final functional commit occurs after build. +Note: This checkpoint is mandatory by policy before any modification. The final functional commit still occurs after build to honor Build First, Commit Later for real changes. - [ ] **Step 1: Add dark theme override** @@ -43,7 +43,7 @@ Expected: Build succeeds without errors. ```powershell git add src/style/components.css -git commit -m "fix: improve toast dark mode"` +git commit -m "fix: improve toast dark mode" ``` - [ ] **Step 4: Push(PowerShell)** From b03507267f4f25f3a6fd82ae23af41f64d43a652 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 04:38:30 +0800 Subject: [PATCH 102/426] chore: checkpoint before toast dark style From 3fe40849465f4b341b100a95f506d177e0f2650c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 04:39:18 +0800 Subject: [PATCH 103/426] fix: improve toast dark mode --- src/style/components.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/style/components.css b/src/style/components.css index 6e9a8324..b1152ca9 100644 --- a/src/style/components.css +++ b/src/style/components.css @@ -199,6 +199,10 @@ .toast.info { color: var(--info); } .toast.warning { color: var(--warning); } +[data-theme="dark"] .toast { + background: var(--bg-secondary); +} + @keyframes slideIn { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } From c411014b5e43d354ef63224c0e8442d94f2ad1e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 04:40:29 +0800 Subject: [PATCH 104/426] chore: checkpoint before model select width From 3bc4f1fb0cbc1da2bcb0be91e232d83d8b567c1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 04:41:18 +0800 Subject: [PATCH 105/426] fix: auto width for chat model select --- src/pages/chat.js | 11 ++++++++++- src/style/chat.css | 8 ++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/pages/chat.js b/src/pages/chat.js index 1b780f2a..14ebdab4 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -105,7 +105,7 @@ export async function render() {
- ${highlighted}` + }, +}) +md.use(ins) +``` + +- [ ] **Step 2: Link whitelist** + +Override link renderer to allow only http/https/mailto, otherwise href="#". + +- [ ] **Step 3: Spoiler plugin** + +Implement custom plugin to parse: +- `||spoiler||` +- `>!spoiler!<` +Output: `...` + +- [ ] **Step 4: Mention plugin** + +Parse `@username` into `@username`. + +- [ ] **Step 5: Update renderMarkdown** + +Replace manual parsing with `md.render(text)`. + +## Chunk 2: Styling + +### Task 3: Add styles + +**Files:** +- Modify: `src/style/chat.css` or `src/style/components.css` + +- [ ] **Step 1: Add mention style** + +```css +.msg-mention { + color: var(--accent); + font-weight: 600; +} +``` + +- [ ] **Step 2: Add spoiler style** + +```css +.msg-spoiler { + background: currentColor; + color: transparent; + border-radius: 4px; + padding: 0 4px; + cursor: pointer; +} +.msg-spoiler.revealed { + color: inherit; + background: rgba(0,0,0,0.12); +} +``` + +- [ ] **Step 3: Add click handler** + +In chat init or render, add event delegation to toggle `revealed` class on `.msg-spoiler`. + +## Chunk 3: Build + Commit + +- [ ] **Step 1: Build** + +Run: `npm run build` +Expected: Build succeeds without errors. + +- [ ] **Step 2: Commit** + +```powershell +git add src\lib\markdown.js src\style\chat.css src\style\components.css +git commit -m "feat: markdown-it rendering" +``` + +- [ ] **Step 3: Push** + +```powershell +git push +``` From 5d1ef1e86e48adb2f415b46b1d7ae090ce1eec82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 08:42:04 +0800 Subject: [PATCH 161/426] docs: fix markdown-it underline plan --- docs/superpowers/plans/2026-03-17-chat-markdown-it.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-03-17-chat-markdown-it.md b/docs/superpowers/plans/2026-03-17-chat-markdown-it.md index ce4623ad..965eea9d 100644 --- a/docs/superpowers/plans/2026-03-17-chat-markdown-it.md +++ b/docs/superpowers/plans/2026-03-17-chat-markdown-it.md @@ -28,7 +28,7 @@ git commit --allow-empty -m "chore: checkpoint before markdown-it" Add to dependencies: - markdown-it -- markdown-it-ins +- markdown-it - [ ] **Step 2: Install** @@ -52,7 +52,7 @@ git commit -m "chore: add markdown-it deps" ```js import MarkdownIt from 'markdown-it' -import ins from 'markdown-it-ins' +import MarkdownIt from 'markdown-it' ``` ```js @@ -66,9 +66,12 @@ const md = new MarkdownIt({ return `
${langLabel}${highlighted}
` }, }) -md.use(ins) ``` +- [ ] **Step 1.1: Underline plugin (`__text__` -> ``)** + +Implement a custom inline rule to parse double-underscore into `` and keep `**` for bold. + - [ ] **Step 2: Link whitelist** Override link renderer to allow only http/https/mailto, otherwise href="#". From 15667659dbeec1c71cd61c2f5f800bdeac5189da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 08:43:00 +0800 Subject: [PATCH 162/426] docs: align markdown-it plan --- docs/superpowers/plans/2026-03-17-chat-markdown-it.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-03-17-chat-markdown-it.md b/docs/superpowers/plans/2026-03-17-chat-markdown-it.md index 965eea9d..2256a8de 100644 --- a/docs/superpowers/plans/2026-03-17-chat-markdown-it.md +++ b/docs/superpowers/plans/2026-03-17-chat-markdown-it.md @@ -4,7 +4,7 @@ **Goal:** Replace the chat Markdown renderer with markdown-it plus plugins to support underline, spoiler, and mention, matching GitHub-like behavior. -**Architecture:** Use markdown-it with html disabled, link validation, custom renderer for code blocks using existing highlightCode. Add local plugins for spoiler (|| || + >! !<) and mention (@user), and use markdown-it-ins for underline rendering to . +**Architecture:** Use markdown-it with html disabled, link validation, custom renderer for code blocks using existing highlightCode. Add local plugins for spoiler (|| || + >! !<), mention (@user), and a custom underline rule that maps __text__ to . **Tech Stack:** JS, Vite @@ -28,7 +28,6 @@ git commit --allow-empty -m "chore: checkpoint before markdown-it" Add to dependencies: - markdown-it -- markdown-it - [ ] **Step 2: Install** @@ -52,7 +51,6 @@ git commit -m "chore: add markdown-it deps" ```js import MarkdownIt from 'markdown-it' -import MarkdownIt from 'markdown-it' ``` ```js From b182a38dcf8d5a34abb2e8421d1befa7528233a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 08:43:29 +0800 Subject: [PATCH 163/426] chore: checkpoint before markdown-it From 51af7663aeba4f74ac8825cdda2f8c8fadae4dc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 08:43:55 +0800 Subject: [PATCH 164/426] chore: add markdown-it deps --- package-lock.json | 74 ++++++++++++++++++++++++++++++++++++++++++++--- package.json | 3 +- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index f29099b2..1c438e05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "clawpanel", - "version": "0.9.2", + "version": "0.9.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clawpanel", - "version": "0.9.2", - "license": "MIT", + "version": "0.9.3", + "license": "AGPL-3.0", "dependencies": { "@tauri-apps/api": "^2.5.0", - "@tauri-apps/plugin-shell": "^2.2.1" + "@tauri-apps/plugin-shell": "^2.2.1", + "markdown-it": "^14.1.1" }, "devDependencies": { "@tauri-apps/cli": "^2.5.0", @@ -1052,6 +1053,24 @@ "dev": true, "license": "MIT" }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1127,6 +1146,38 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmmirror.com/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1195,6 +1246,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -1267,6 +1327,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", diff --git a/package.json b/package.json index d5c7a793..f574c163 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ }, "dependencies": { "@tauri-apps/api": "^2.5.0", - "@tauri-apps/plugin-shell": "^2.2.1" + "@tauri-apps/plugin-shell": "^2.2.1", + "markdown-it": "^14.1.1" }, "devDependencies": { "@tauri-apps/cli": "^2.5.0", From c750efe292c1b8da9bd73904e93ea5bc1a980896 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 08:46:34 +0800 Subject: [PATCH 165/426] checkpoint before client schema patch and version order From 7d5e4a006eb77a08aa1dc949d65e136e3a97824c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 08:48:21 +0800 Subject: [PATCH 166/426] feat: markdown-it rendering --- src/lib/markdown.js | 204 +++++++++++++++++++++++--------------------- src/pages/chat.js | 6 ++ src/style/chat.css | 18 ++++ 3 files changed, 133 insertions(+), 95 deletions(-) diff --git a/src/lib/markdown.js b/src/lib/markdown.js index c874cf3b..a753da29 100644 --- a/src/lib/markdown.js +++ b/src/lib/markdown.js @@ -1,8 +1,10 @@ /** - * Markdown 渲染器 - 轻量级,支持代码高亮 - * 从 clawapp 移植,去掉 MEDIA 路径处理 + * Markdown 渲染器 - markdown-it 版本 + * 支持代码高亮、下划线、剧透、@提及 */ +import MarkdownIt from 'markdown-it' + const KEYWORDS = new Set([ 'const','let','var','function','return','if','else','for','while','do', 'switch','case','break','continue','new','this','class','extends','import', @@ -17,8 +19,6 @@ const KEYWORDS = new Set([ function highlightCode(code, lang) { const escaped = escapeHtml(code) - // Two-phase: mark with control chars first, convert to HTML last - // Prevents keyword regex from matching "class" inside attributes const S = '\x02', E = '\x03' const CLS = ['hl-number','hl-comment','hl-string','hl-type','hl-func','hl-keyword'] return escaped @@ -65,121 +65,135 @@ 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 = stripAnsi(text) - - // 代码块 - html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => { - const highlighted = highlightCode(code.trimEnd(), lang) - const langLabel = lang ? `${escapeHtml(lang)}` : '' - return `
${langLabel}${highlighted}
` - }) - - // 行内代码 - html = html.replace(/`([^`\n]+)`/g, (_, code) => `${escapeHtml(code)}`) - - const lines = html.split('\n') - const result = [] - let inList = false - let listType = '' - - for (let i = 0; i < lines.length; i++) { - let line = lines[i] - - // 跳过 pre 块内容 - if (line.startsWith('')) { i++; result.push(lines[i]) } - continue - } - - // 标题 - const headingMatch = line.match(/^(#{1,3})\s+(.+)$/) - if (headingMatch) { - if (inList) { result.push(``); inList = false } - const level = headingMatch[1].length - result.push(`${inlineFormat(headingMatch[2])}`) - continue +function spoilerPlugin(md) { + md.inline.ruler.before('emphasis', 'spoiler', (state, silent) => { + const src = state.src + const pos = state.pos + if (src.startsWith('||', pos)) { + const end = src.indexOf('||', pos + 2) + if (end === -1) return false + if (!silent) { + const tokenOpen = state.push('spoiler_open', 'span', 1) + tokenOpen.markup = '||' + const oldPos = state.pos + const oldMax = state.posMax + state.pos = pos + 2 + state.posMax = end + state.md.inline.tokenize(state) + state.pos = oldPos + state.posMax = oldMax + const tokenClose = state.push('spoiler_close', 'span', -1) + tokenClose.markup = '||' + } + state.pos = end + 2 + return true } - - // 无序列表 - const ulMatch = line.match(/^[\s]*[-*]\s+(.+)$/) - if (ulMatch) { - if (!inList || listType !== 'ul') { - if (inList) result.push(``) - result.push('
    '); inList = true; listType = 'ul' + if (src.startsWith('>!', pos)) { + const end = src.indexOf('!<', pos + 2) + if (end === -1) return false + if (!silent) { + const tokenOpen = state.push('spoiler_open', 'span', 1) + tokenOpen.markup = '>!' + const oldPos = state.pos + const oldMax = state.posMax + state.pos = pos + 2 + state.posMax = end + state.md.inline.tokenize(state) + state.pos = oldPos + state.posMax = oldMax + const tokenClose = state.push('spoiler_close', 'span', -1) + tokenClose.markup = '!<' } - result.push(`
  • ${inlineFormat(ulMatch[1])}
  • `) - continue + state.pos = end + 2 + return true } + return false + }) - // 有序列表 - const olMatch = line.match(/^[\s]*\d+\.\s+(.+)$/) - if (olMatch) { - if (!inList || listType !== 'ol') { - if (inList) result.push(``) - result.push('
      '); inList = true; listType = 'ol' - } - result.push(`
    1. ${inlineFormat(olMatch[1])}
    2. `) - continue + md.renderer.rules.spoiler_open = () => '' + md.renderer.rules.spoiler_close = () => '' +} + +function mentionPlugin(md) { + md.inline.ruler.before('text', 'mention', (state, silent) => { + const src = state.src + const pos = state.pos + if (src[pos] !== '@') return false + if (pos > 0 && /[\w.]/.test(src[pos - 1])) return false + const match = src.slice(pos + 1).match(/^[a-zA-Z0-9_]{1,32}/) + if (!match) return false + if (!silent) { + const token = state.push('mention', '', 0) + token.content = '@' + match[0] } + state.pos += 1 + match[0].length + return true + }) - if (inList) { result.push(``); inList = false } - if (line.trim() === '') { result.push(''); continue } - result.push(`

      ${inlineFormat(line)}

      `) + md.renderer.rules.mention = (tokens, idx) => { + return `${escapeHtml(tokens[idx].content)}` + } +} + +const md = new MarkdownIt({ + html: false, + linkify: true, + breaks: false, + highlight: (code, lang) => { + const highlighted = highlightCode(code.trimEnd(), lang) + const langLabel = lang ? `${escapeHtml(lang)}` : '' + return `
      ${langLabel}${highlighted}
      ` + }, +}) + +// __text__ -> text, keep ** for +md.renderer.rules.strong_open = (tokens, idx) => (tokens[idx].markup === '__' ? '' : '') +md.renderer.rules.strong_close = (tokens, idx) => (tokens[idx].markup === '__' ? '' : '') + +// Link whitelist +const defaultLinkOpen = md.renderer.rules.link_open || ((tokens, idx, options, env, self) => self.renderToken(tokens, idx, options)) +md.renderer.rules.link_open = (tokens, idx, options, env, self) => { + const hrefIdx = tokens[idx].attrIndex('href') + if (hrefIdx >= 0) { + const url = tokens[idx].attrs[hrefIdx][1] || '' + const safe = /^(https?:|mailto:)/i.test(url.trim()) ? url : '#' + tokens[idx].attrs[hrefIdx][1] = safe } + return defaultLinkOpen(tokens, idx, options, env, self) +} - if (inList) result.push(``) - return result.join('\n') +// Image renderer +md.renderer.rules.image = (tokens, idx) => { + const token = tokens[idx] + const srcIdx = token.attrIndex('src') + const rawSrc = srcIdx >= 0 ? token.attrs[srcIdx][1] : '' + const safeSrc = resolveImageSrc((rawSrc || '').trim()) + const alt = escapeHtmlLite(token.content || '') + const rawEscaped = escapeHtml(rawSrc || '') + return `${alt}` } -function inlineFormat(text) { - const codeSpans = [] - let safe = text.replace(/`([^`\n]+)`/g, (_, code) => { - const token = `__CODE_${codeSpans.length}__` - codeSpans.push(escapeHtml(code)) - return token - }) - safe = escapeHtmlLite(safe) - safe = safe - .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) => { - const safeSrc = resolveImageSrc(src.trim()) - return `${escapeHtmlLite(alt)}` - }) - .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => { - const safe = /^https?:|^mailto:/i.test(url.trim()) ? url : '#' - return `${escapeHtmlLite(label)}` - }) - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/\*(.+?)\*/g, '$1') - .replace(/__(.+?)__/g, '$1') - .replace(/_(.+?)_/g, '$1') - - codeSpans.forEach((code, idx) => { - safe = safe.replace(`__CODE_${idx}__`, `${code}`) - }) - return safe +md.use(spoilerPlugin) +md.use(mentionPlugin) + +export function renderMarkdown(text) { + if (!text) return '' + const clean = stripAnsi(text) + return md.render(clean) } window.__copyCode = function(btn) { diff --git a/src/pages/chat.js b/src/pages/chat.js index 59e74090..8aa7e7ff 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -272,6 +272,12 @@ function bindEvents(page) { }) _scrollBtn.addEventListener('click', () => scrollToBottom()) _messagesEl.addEventListener('click', () => hideCmdPanel()) + _messagesEl.addEventListener('click', (e) => { + const target = e.target?.closest?.('.msg-spoiler') + if (!target) return + if (target.closest('code, pre')) return + target.classList.toggle('revealed') + }) } async function loadModelOptions(showToast = false) { diff --git a/src/style/chat.css b/src/style/chat.css index 0228dc44..31c69502 100644 --- a/src/style/chat.css +++ b/src/style/chat.css @@ -216,6 +216,24 @@ margin: 0 0 8px 0; } +.msg-mention { + color: var(--accent); + font-weight: 600; +} + +.msg-spoiler { + background: currentColor; + color: transparent; + border-radius: 4px; + padding: 0 4px; + cursor: pointer; +} + +.msg-spoiler.revealed { + color: inherit; + background: rgba(0,0,0,0.12); +} + .msg-ai .msg-bubble p:last-child { margin-bottom: 0; } From e5571ab0de67427f29a46526123f734912299e87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 08:59:36 +0800 Subject: [PATCH 167/426] checkpoint before removing gateway patch From 21c275a339ac61ff8f814350c33c46dff948f135 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 09:02:32 +0800 Subject: [PATCH 168/426] docs: add chat virtual scroll implementation design --- ...at-virtual-scroll-implementation-design.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 docs/plans/2026-03-17-chat-virtual-scroll-implementation-design.md diff --git a/docs/plans/2026-03-17-chat-virtual-scroll-implementation-design.md b/docs/plans/2026-03-17-chat-virtual-scroll-implementation-design.md new file mode 100644 index 00000000..eca40d2c --- /dev/null +++ b/docs/plans/2026-03-17-chat-virtual-scroll-implementation-design.md @@ -0,0 +1,34 @@ +# Chat 虚拟列表实现设计 + +## 目标 +- 提升首屏渲染速度(优先) +- 提升滚动流畅度(次优) +- 固定渲染窗口:40 条 + 上下缓冲 20 条 +- 滚动锚点:底部时自动滚动,否则保持位置 +- 高度策略:预估行高 + 运行时测量校准 + +## 方案(固定窗口 + 占位高度) +- 使用容器总高度占位 +- 仅渲染窗口范围消息 +- 运行时测量单条高度并缓存 +- 通过累计高度计算可视范围 + +## 数据结构 +- items: [{ id, role, text, attachments, tools, timestamp }] +- heights: Map +- avgHeight: number (默认 64) +- range: { start, end } + +## 渲染策略 +- 顶部、底部使用占位 div 控制滚动条 +- 每次滚动计算可视范围 +- 渲染 items[start..end] + +## 锚点策略 +- 若用户在底部,新增消息时自动滚动到底部 +- 否则保持当前滚动位置 + +## 验收标准 +- 首屏渲染速度明显提升 +- 长列表滚动不卡顿 +- 新消息到来时遵循锚点策略 From 3a0d96d57158156671a018a20ca20105137bfca0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 09:03:06 +0800 Subject: [PATCH 169/426] docs: add chat virtual scroll implementation plan --- ...3-17-chat-virtual-scroll-implementation.md | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md diff --git a/docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md b/docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md new file mode 100644 index 00000000..ec3a9047 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md @@ -0,0 +1,100 @@ +# Chat Virtual Scroll Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement virtual scrolling for chat messages with fixed window size (40 + overscan 20), fast first paint, and stable scroll anchoring. + +**Architecture:** Use a virtualized list with top/bottom spacers, measured row heights cached by message id, and average height fallback. Update range on scroll, and preserve anchor when not at bottom. + +**Tech Stack:** JS, Vite + +--- + +## Chunk 1: Virtual list core + +### Task 1: Add virtual list state + helpers + +**Files:** +- Modify: `src/pages/chat.js` + +- [ ] **Step 0: Checkpoint(PowerShell)** + +```powershell +git status -sb +git commit --allow-empty -m "chore: checkpoint before chat virtual scroll" +``` + +- [ ] **Step 1: Add state** + +Add: +- `const VIRTUAL_WINDOW = 40` +- `const VIRTUAL_OVERSCAN = 20` +- `let _virtualEnabled = true` +- `let _virtualHeights = new Map()` +- `let _virtualAvgHeight = 64` +- `let _virtualRange = { start: 0, end: 0 }` + +- [ ] **Step 2: Compute range** + +Implement function: + +```js +function computeVirtualRange(total, scrollTop, viewportHeight) { + const approxStart = Math.max(0, Math.floor(scrollTop / _virtualAvgHeight) - VIRTUAL_OVERSCAN) + const approxEnd = Math.min(total, approxStart + VIRTUAL_WINDOW + VIRTUAL_OVERSCAN * 2) + return { start: approxStart, end: approxEnd } +} +``` + +- [ ] **Step 3: Spacer heights** + +Implement: + +```js +function getSpacerHeight(start, end, total) { + const top = start * _virtualAvgHeight + const bottom = (total - end) * _virtualAvgHeight + return { top, bottom } +} +``` + +- [ ] **Step 4: Measure heights** + +After render, measure visible message nodes and update `_virtualHeights` and `_virtualAvgHeight`. + +### Task 2: Integrate into render pipeline + +**Files:** +- Modify: `src/pages/chat.js` + +- [ ] **Step 1: Wrap message list** + +Insert top spacer, visible messages, bottom spacer. + +- [ ] **Step 2: Scroll handler** + +On scroll, recompute range and re-render if changed. + +- [ ] **Step 3: Anchor** + +If user at bottom, auto scroll to bottom on new message; else keep position. + +## Chunk 2: Build + Commit + +- [ ] **Step 1: Build** + +Run: `npm run build` +Expected: Build succeeds without errors. + +- [ ] **Step 2: Commit** + +```powershell +git add src\pages\chat.js +git commit -m "feat: chat virtual scroll" +``` + +- [ ] **Step 3: Push** + +```powershell +git push +``` From 7eda5c04c068ed7b78d11e0257793305ce2f2806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 09:05:01 +0800 Subject: [PATCH 170/426] docs: refine chat virtual scroll plan --- ...3-17-chat-virtual-scroll-implementation.md | 57 ++++++++++++++----- 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md b/docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md index ec3a9047..7b72f87a 100644 --- a/docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md +++ b/docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md @@ -4,7 +4,7 @@ **Goal:** Implement virtual scrolling for chat messages with fixed window size (40 + overscan 20), fast first paint, and stable scroll anchoring. -**Architecture:** Use a virtualized list with top/bottom spacers, measured row heights cached by message id, and average height fallback. Update range on scroll, and preserve anchor when not at bottom. +**Architecture:** Use a virtualized list with top/bottom spacers and total height based on cumulative measured heights (fallback to average). Range calculation uses cumulative heights (prefix sums/binary search) instead of pure avg height. Preserve anchor when not at bottom. **Tech Stack:** JS, Vite @@ -34,31 +34,60 @@ Add: - `let _virtualAvgHeight = 64` - `let _virtualRange = { start: 0, end: 0 }` -- [ ] **Step 2: Compute range** +- [ ] **Step 2: Compute cumulative heights** -Implement function: +Implement prefix sum using measured heights with avg fallback: ```js -function computeVirtualRange(total, scrollTop, viewportHeight) { - const approxStart = Math.max(0, Math.floor(scrollTop / _virtualAvgHeight) - VIRTUAL_OVERSCAN) - const approxEnd = Math.min(total, approxStart + VIRTUAL_WINDOW + VIRTUAL_OVERSCAN * 2) - return { start: approxStart, end: approxEnd } +function getItemHeight(idx, items) { + const id = items[idx]?.id + return _virtualHeights.get(id) || _virtualAvgHeight +} + +function buildPrefixHeights(items) { + const prefix = [0] + for (let i = 0; i < items.length; i++) { + prefix[i + 1] = prefix[i] + getItemHeight(i, items) + } + return prefix } ``` -- [ ] **Step 3: Spacer heights** +- [ ] **Step 3: Range calculation by cumulative heights** + +```js +function findStartIndex(prefix, scrollTop) { + let lo = 0, hi = prefix.length - 1 + while (lo < hi) { + const mid = Math.floor((lo + hi) / 2) + if (prefix[mid] <= scrollTop) lo = mid + 1 + else hi = mid + } + return Math.max(0, lo - 1) +} + +function computeVirtualRange(items, scrollTop, viewportHeight) { + const prefix = buildPrefixHeights(items) + const start = Math.max(0, findStartIndex(prefix, scrollTop) - VIRTUAL_OVERSCAN) + let end = start + const maxY = scrollTop + viewportHeight + while (end < items.length && prefix[end] < maxY + _virtualAvgHeight * VIRTUAL_OVERSCAN) end++ + return { start, end, prefix } +} +``` -Implement: +- [ ] **Step 4: Spacer heights + total height** ```js -function getSpacerHeight(start, end, total) { - const top = start * _virtualAvgHeight - const bottom = (total - end) * _virtualAvgHeight - return { top, bottom } +function getSpacerHeights(prefix, start, end) { + const top = prefix[start] + const total = prefix[prefix.length - 1] + const bottom = Math.max(0, total - prefix[end]) + return { top, bottom, total } } ``` -- [ ] **Step 4: Measure heights** +- [ ] **Step 5: Measure heights** After render, measure visible message nodes and update `_virtualHeights` and `_virtualAvgHeight`. From f6390db630d99a5c7a86f00ae43f9c68f4c5f219 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 09:06:55 +0800 Subject: [PATCH 171/426] checkpoint before local cron ws scheduler From d28d3edb66f86c2543446b862c28f56ad3030a70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 09:07:14 +0800 Subject: [PATCH 172/426] docs: rewrite chat virtual scroll plan --- ...3-17-chat-virtual-scroll-implementation.md | 186 +++++++++++++----- 1 file changed, 135 insertions(+), 51 deletions(-) diff --git a/docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md b/docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md index 7b72f87a..826120e7 100644 --- a/docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md +++ b/docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md @@ -4,18 +4,26 @@ **Goal:** Implement virtual scrolling for chat messages with fixed window size (40 + overscan 20), fast first paint, and stable scroll anchoring. -**Architecture:** Use a virtualized list with top/bottom spacers and total height based on cumulative measured heights (fallback to average). Range calculation uses cumulative heights (prefix sums/binary search) instead of pure avg height. Preserve anchor when not at bottom. +**Architecture:** Use a virtualized list with top/bottom spacers and total height based on cumulative measured heights (fallback to average). Range calculation uses cumulative heights (prefix sums + binary search) with a fixed window cap. Preserve anchor when not at bottom. **Tech Stack:** JS, Vite --- -## Chunk 1: Virtual list core +## File Map +- Modify: `src/pages/chat.js:64-2000` (virtual state, scroll handler, render path) +- Create: `src/lib/virtual-scroll.js` (range + prefix height helpers) +- Create: `tests/virtual-scroll.test.js` +- Modify: `package.json` (test script, devDependency) -### Task 1: Add virtual list state + helpers +--- + +## Chunk 1: Test scaffolding + helpers (TDD) + +### Task 1: Add test tooling **Files:** -- Modify: `src/pages/chat.js` +- Modify: `package.json` - [ ] **Step 0: Checkpoint(PowerShell)** @@ -24,39 +32,40 @@ git status -sb git commit --allow-empty -m "chore: checkpoint before chat virtual scroll" ``` -- [ ] **Step 1: Add state** +- [ ] **Step 1: Add dev dependency and script** Add: -- `const VIRTUAL_WINDOW = 40` -- `const VIRTUAL_OVERSCAN = 20` -- `let _virtualEnabled = true` -- `let _virtualHeights = new Map()` -- `let _virtualAvgHeight = 64` -- `let _virtualRange = { start: 0, end: 0 }` +- `devDependencies.vitest` +- `scripts.test = "vitest run"` -- [ ] **Step 2: Compute cumulative heights** +- [ ] **Step 2: Install** -Implement prefix sum using measured heights with avg fallback: +```powershell +npm install +``` + +### Task 2: Create helper module + +**Files:** +- Create: `src/lib/virtual-scroll.js` + +- [ ] **Step 1: Implement helpers** ```js -function getItemHeight(idx, items) { +export function getItemHeight(items, idx, heights, avgHeight) { const id = items[idx]?.id - return _virtualHeights.get(id) || _virtualAvgHeight + return heights.get(id) || avgHeight } -function buildPrefixHeights(items) { +export function buildPrefixHeights(items, heights, avgHeight) { const prefix = [0] for (let i = 0; i < items.length; i++) { - prefix[i + 1] = prefix[i] + getItemHeight(i, items) + prefix[i + 1] = prefix[i] + getItemHeight(items, i, heights, avgHeight) } return prefix } -``` -- [ ] **Step 3: Range calculation by cumulative heights** - -```js -function findStartIndex(prefix, scrollTop) { +export function findStartIndex(prefix, scrollTop) { let lo = 0, hi = prefix.length - 1 while (lo < hi) { const mid = Math.floor((lo + hi) / 2) @@ -66,20 +75,17 @@ function findStartIndex(prefix, scrollTop) { return Math.max(0, lo - 1) } -function computeVirtualRange(items, scrollTop, viewportHeight) { - const prefix = buildPrefixHeights(items) - const start = Math.max(0, findStartIndex(prefix, scrollTop) - VIRTUAL_OVERSCAN) - let end = start - const maxY = scrollTop + viewportHeight - while (end < items.length && prefix[end] < maxY + _virtualAvgHeight * VIRTUAL_OVERSCAN) end++ +export function computeVirtualRange(items, scrollTop, viewportHeight, avgHeight, overscan, windowSize, heights) { + const prefix = buildPrefixHeights(items, heights, avgHeight) + const start = Math.max(0, findStartIndex(prefix, scrollTop) - overscan) + let end = Math.min(items.length, start + windowSize + overscan * 2) + const maxY = scrollTop + viewportHeight + avgHeight * overscan + while (end < items.length && prefix[end] < maxY) end++ + end = Math.min(items.length, end) return { start, end, prefix } } -``` -- [ ] **Step 4: Spacer heights + total height** - -```js -function getSpacerHeights(prefix, start, end) { +export function getSpacerHeights(prefix, start, end) { const top = prefix[start] const total = prefix[prefix.length - 1] const bottom = Math.max(0, total - prefix[end]) @@ -87,43 +93,121 @@ function getSpacerHeights(prefix, start, end) { } ``` -- [ ] **Step 5: Measure heights** +### Task 3: Add tests (TDD) + +**Files:** +- Create: `tests/virtual-scroll.test.js` -After render, measure visible message nodes and update `_virtualHeights` and `_virtualAvgHeight`. +- [ ] **Step 1: Write failing tests** + +```js +import { describe, it, expect } from 'vitest' +import { buildPrefixHeights, computeVirtualRange, getSpacerHeights } from '../src/lib/virtual-scroll.js' + +describe('virtual scroll helpers', () => { + it('builds prefix heights with avg fallback', () => { + const items = [{ id: 'a' }, { id: 'b' }, { id: 'c' }] + const heights = new Map([['b', 80]]) + const prefix = buildPrefixHeights(items, heights, 50) + expect(prefix).toEqual([0, 50, 130, 180]) + }) + + it('computes range with window cap', () => { + const items = Array.from({ length: 200 }, (_, i) => ({ id: String(i) })) + const heights = new Map() + const { start, end } = computeVirtualRange(items, 0, 600, 30, 20, 40, heights) + expect(end - start).toBeLessThanOrEqual(80) + }) + + it('spacer heights sum to total', () => { + const prefix = [0, 50, 100, 150] + const { top, bottom, total } = getSpacerHeights(prefix, 1, 2) + expect(top + bottom + (prefix[2] - prefix[1])).toBe(total) + }) +}) +``` -### Task 2: Integrate into render pipeline +- [ ] **Step 2: Run tests (expect FAIL)** + +```powershell +npm run test +``` +Expected: FAIL if helpers not implemented. + +- [ ] **Step 3: Implement helpers (Step 2) then re-run tests (expect PASS)** + +```powershell +npm run test +``` + +- [ ] **Step 4: Commit helpers + tests** + +```powershell +git add src\lib\virtual-scroll.js tests\virtual-scroll.test.js package.json package-lock.json +git commit -m "test: add virtual scroll helpers" +``` + +--- + +## Chunk 2: Integrate virtual scroll into chat + +### Task 4: Add state + range calc **Files:** -- Modify: `src/pages/chat.js` +- Modify: `src/pages/chat.js:64-2000` -- [ ] **Step 1: Wrap message list** +- [ ] **Step 1: Add constants + state** -Insert top spacer, visible messages, bottom spacer. +```js +const VIRTUAL_WINDOW = 40 +const VIRTUAL_OVERSCAN = 20 +let _virtualEnabled = true +let _virtualHeights = new Map() +let _virtualAvgHeight = 64 +let _virtualRange = { start: 0, end: 0, prefix: [0] } +``` -- [ ] **Step 2: Scroll handler** +- [ ] **Step 2: Import helpers** -On scroll, recompute range and re-render if changed. +```js +import { computeVirtualRange, getSpacerHeights } from '../lib/virtual-scroll.js' +``` -- [ ] **Step 3: Anchor** +- [ ] **Step 3: Scroll handler** -If user at bottom, auto scroll to bottom on new message; else keep position. +On scroll, compute range using `computeVirtualRange(items, scrollTop, viewportHeight, _virtualAvgHeight, VIRTUAL_OVERSCAN, VIRTUAL_WINDOW, _virtualHeights)` and update `_virtualRange` when changed. -## Chunk 2: Build + Commit +### Task 5: Render with spacers + measurement -- [ ] **Step 1: Build** +**Files:** +- Modify: `src/pages/chat.js:1380-1750` -Run: `npm run build` -Expected: Build succeeds without errors. +- [ ] **Step 1: Render spacers + window** + +Insert: +- top spacer with height = `getSpacerHeights(prefix, start, end).top` +- visible items = `items.slice(start, end)` +- bottom spacer with height = `getSpacerHeights(prefix, start, end).bottom` + +- [ ] **Step 2: Measure heights** + +After render (requestAnimationFrame), measure visible `.msg` nodes using `getBoundingClientRect().height`, update `_virtualHeights`, and recompute `_virtualAvgHeight`. + +- [ ] **Step 3: Anchor strategy** -- [ ] **Step 2: Commit** +If user is at bottom (within 80px), auto scroll to bottom after new message. Otherwise, preserve scroll position by capturing `scrollTop` before re-render and adjusting by delta in top spacer height. + +### Task 6: Build ```powershell -git add src\pages\chat.js -git commit -m "feat: chat virtual scroll" +npm run build ``` +Expected: Build succeeds without errors. -- [ ] **Step 3: Push** +### Task 7: Commit + Push ```powershell +git add src\pages\chat.js +git commit -m "feat: chat virtual scroll" git push ``` From 65a6468e937dd0ce0bbabccd50d0c8345a550df9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 09:09:04 +0800 Subject: [PATCH 173/426] docs: cap virtual range window --- .../plans/2026-03-17-chat-virtual-scroll-implementation.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md b/docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md index 826120e7..57c39dc0 100644 --- a/docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md +++ b/docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md @@ -79,9 +79,7 @@ export function computeVirtualRange(items, scrollTop, viewportHeight, avgHeight, const prefix = buildPrefixHeights(items, heights, avgHeight) const start = Math.max(0, findStartIndex(prefix, scrollTop) - overscan) let end = Math.min(items.length, start + windowSize + overscan * 2) - const maxY = scrollTop + viewportHeight + avgHeight * overscan - while (end < items.length && prefix[end] < maxY) end++ - end = Math.min(items.length, end) + // 固定窗口:严格限制 end-start 不超过 windowSize + overscan*2 return { start, end, prefix } } From 7e4431164a97711fafaa6e198427d8a33ff5ab7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 09:13:17 +0800 Subject: [PATCH 174/426] chore: checkpoint before chat virtual scroll From 40404cbbac68eb3250594e90b0fc016e9fef2d38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 09:15:28 +0800 Subject: [PATCH 175/426] test: add virtual scroll helpers --- package-lock.json | 1417 +++++++++++++++++++++++++++++++++- package.json | 6 +- src/lib/virtual-scroll.js | 36 + tests/virtual-scroll.test.js | 24 + 4 files changed, 1480 insertions(+), 3 deletions(-) create mode 100644 src/lib/virtual-scroll.js create mode 100644 tests/virtual-scroll.test.js diff --git a/package-lock.json b/package-lock.json index 1c438e05..bcf7139c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ }, "devDependencies": { "@tauri-apps/cli": "^2.5.0", - "vite": "^6.3.5" + "vite": "^6.3.5", + "vitest": "^2.1.9" } }, "node_modules/@esbuild/aix-ppc64": { @@ -460,6 +461,13 @@ "node": ">=18" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", @@ -1053,12 +1061,173 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmmirror.com/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz", @@ -1071,6 +1240,13 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1113,6 +1289,26 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1155,6 +1351,23 @@ "uc.micro": "^2.0.0" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/markdown-it": { "version": "14.1.1", "resolved": "https://registry.npmmirror.com/markdown-it/-/markdown-it-14.1.1.tgz", @@ -1178,6 +1391,13 @@ "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", "license": "MIT" }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1197,6 +1417,23 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1300,6 +1537,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1310,6 +1554,34 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmmirror.com/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1327,6 +1599,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/uc.micro": { "version": "2.1.0", "resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz", @@ -1407,6 +1709,1119 @@ "optional": true } } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/package.json b/package.json index f574c163..3fbbf112 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "icon:regen": "tauri icon docs/logo.png -o src-tauri/icons", "serve": "node scripts/serve.js", "version:sync": "node scripts/sync-version.js", - "version:set": "node scripts/sync-version.js" + "version:set": "node scripts/sync-version.js", + "test": "vitest run" }, "dependencies": { "@tauri-apps/api": "^2.5.0", @@ -38,6 +39,7 @@ }, "devDependencies": { "@tauri-apps/cli": "^2.5.0", - "vite": "^6.3.5" + "vite": "^6.3.5", + "vitest": "^2.1.9" } } diff --git a/src/lib/virtual-scroll.js b/src/lib/virtual-scroll.js new file mode 100644 index 00000000..e1074654 --- /dev/null +++ b/src/lib/virtual-scroll.js @@ -0,0 +1,36 @@ +export function getItemHeight(items, idx, heights, avgHeight) { + const id = items[idx]?.id + return heights.get(id) || avgHeight +} + +export function buildPrefixHeights(items, heights, avgHeight) { + const prefix = [0] + for (let i = 0; i < items.length; i++) { + prefix[i + 1] = prefix[i] + getItemHeight(items, i, heights, avgHeight) + } + return prefix +} + +export function findStartIndex(prefix, scrollTop) { + let lo = 0, hi = prefix.length - 1 + while (lo < hi) { + const mid = Math.floor((lo + hi) / 2) + if (prefix[mid] <= scrollTop) lo = mid + 1 + else hi = mid + } + return Math.max(0, lo - 1) +} + +export function computeVirtualRange(items, scrollTop, viewportHeight, avgHeight, overscan, windowSize, heights) { + const prefix = buildPrefixHeights(items, heights, avgHeight) + const start = Math.max(0, findStartIndex(prefix, scrollTop) - overscan) + const end = Math.min(items.length, start + windowSize + overscan * 2) + return { start, end, prefix } +} + +export function getSpacerHeights(prefix, start, end) { + const top = prefix[start] + const total = prefix[prefix.length - 1] + const bottom = Math.max(0, total - prefix[end]) + return { top, bottom, total } +} diff --git a/tests/virtual-scroll.test.js b/tests/virtual-scroll.test.js new file mode 100644 index 00000000..5b9d618a --- /dev/null +++ b/tests/virtual-scroll.test.js @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest' +import { buildPrefixHeights, computeVirtualRange, getSpacerHeights } from '../src/lib/virtual-scroll.js' + +describe('virtual scroll helpers', () => { + it('builds prefix heights with avg fallback', () => { + const items = [{ id: 'a' }, { id: 'b' }, { id: 'c' }] + const heights = new Map([['b', 80]]) + const prefix = buildPrefixHeights(items, heights, 50) + expect(prefix).toEqual([0, 50, 130, 180]) + }) + + it('computes range with window cap', () => { + const items = Array.from({ length: 200 }, (_, i) => ({ id: String(i) })) + const heights = new Map() + const { start, end } = computeVirtualRange(items, 0, 600, 30, 20, 40, heights) + expect(end - start).toBeLessThanOrEqual(80) + }) + + it('spacer heights sum to total', () => { + const prefix = [0, 50, 100, 150] + const { top, bottom, total } = getSpacerHeights(prefix, 1, 2) + expect(top + bottom + (prefix[2] - prefix[1])).toBe(total) + }) +}) From 4e9f06eb7510f344e18a8bc850afbf8916775ccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 09:18:58 +0800 Subject: [PATCH 176/426] feat: chat virtual scroll --- src/pages/chat.js | 133 ++++++++++++++++++++++++++++++++++++++++++--- src/style/chat.css | 5 ++ 2 files changed, 131 insertions(+), 7 deletions(-) diff --git a/src/pages/chat.js b/src/pages/chat.js index 8aa7e7ff..2f059e79 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -6,6 +6,7 @@ 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' +import { computeVirtualRange, getSpacerHeights } from '../lib/virtual-scroll.js' import { saveMessage, saveMessages, getLocalMessages, isStorageAvailable } from '../lib/message-db.js' import { toast } from '../components/toast.js' import { showModal, showConfirm } from '../components/modal.js' @@ -63,6 +64,17 @@ let _currentAiBubble = null, _currentAiText = '', _currentAiImages = [], _curren let _isStreaming = false, _isSending = false, _messageQueue = [], _streamStartTime = 0 let _lastRenderTime = 0, _renderPending = false, _lastHistoryHash = '' let _isLoadingHistory = false + +const VIRTUAL_WINDOW = 40 +const VIRTUAL_OVERSCAN = 20 +let _virtualEnabled = true +let _virtualHeights = new Map() +let _virtualAvgHeight = 64 +let _virtualRange = { start: 0, end: 0, prefix: [0] } +let _virtualItems = [] +let _virtualTopSpacer = null +let _virtualBottomSpacer = null +let _virtualRenderPending = false let _streamSafetyTimer = null, _unsubEvent = null, _unsubReady = null, _unsubStatus = null let _seenRunIds = new Set() let _pageActive = false @@ -278,6 +290,9 @@ function bindEvents(page) { if (target.closest('code, pre')) return target.classList.toggle('revealed') }) + _messagesEl.addEventListener('scroll', () => { + if (_virtualEnabled) requestVirtualRender() + }) } async function loadModelOptions(showToast = false) { @@ -1893,15 +1908,29 @@ function showLightbox(src) { function insertMessageByTime(wrap, ts) { const tsValue = Number(ts || Date.now()) wrap.dataset.ts = String(tsValue) - const items = Array.from(_messagesEl.querySelectorAll('.msg')) - for (const node of items) { - const nodeTs = parseInt(node.dataset.ts || '0', 10) - if (nodeTs > tsValue) { - _messagesEl.insertBefore(wrap, node) - return + + if (!_virtualEnabled) { + const items = Array.from(_messagesEl.querySelectorAll('.msg')) + for (const node of items) { + const nodeTs = parseInt(node.dataset.ts || '0', 10) + if (nodeTs > tsValue) { + _messagesEl.insertBefore(wrap, node) + return + } } + _messagesEl.insertBefore(wrap, _typingEl) + return } - _messagesEl.insertBefore(wrap, _typingEl) + + if (!wrap.dataset.vid) wrap.dataset.vid = uuid() + const vid = wrap.dataset.vid + const existingIdx = _virtualItems.findIndex(item => item.id === vid) + const entry = { id: vid, ts: tsValue, node: wrap } + if (existingIdx >= 0) _virtualItems.splice(existingIdx, 1) + let insertIdx = _virtualItems.findIndex(item => item.ts > tsValue) + if (insertIdx < 0) insertIdx = _virtualItems.length + _virtualItems.splice(insertIdx, 0, entry) + requestVirtualRender(true) } function appendSystemMessage(text, ts) { @@ -1914,6 +1943,12 @@ function appendSystemMessage(text, ts) { function clearMessages() { _messagesEl.querySelectorAll('.msg').forEach(m => m.remove()) + _virtualItems = [] + _virtualHeights = new Map() + _virtualAvgHeight = 64 + _virtualRange = { start: 0, end: 0, prefix: [0] } + if (_virtualTopSpacer) _virtualTopSpacer.style.height = '0px' + if (_virtualBottomSpacer) _virtualBottomSpacer.style.height = '0px' } function showTyping(show) { @@ -1940,6 +1975,90 @@ function scrollToBottom() { requestAnimationFrame(() => { _messagesEl.scrollTop = _messagesEl.scrollHeight }) } +function isAtBottom() { + if (!_messagesEl) return true + const threshold = 80 + return _messagesEl.scrollHeight - _messagesEl.scrollTop - _messagesEl.clientHeight < threshold +} + +function ensureVirtualSpacers() { + if (!_messagesEl) return + if (!_virtualTopSpacer) { + _virtualTopSpacer = document.createElement('div') + _virtualTopSpacer.className = 'msg-virtual-spacer' + _messagesEl.insertBefore(_virtualTopSpacer, _messagesEl.firstChild) + } + if (!_virtualBottomSpacer) { + _virtualBottomSpacer = document.createElement('div') + _virtualBottomSpacer.className = 'msg-virtual-spacer' + _messagesEl.insertBefore(_virtualBottomSpacer, _typingEl) + } +} + +function requestVirtualRender(force = false) { + if (!_virtualEnabled || !_messagesEl) return + if (_virtualRenderPending && !force) return + _virtualRenderPending = true + requestAnimationFrame(() => { + _virtualRenderPending = false + doVirtualRender() + }) +} + +function doVirtualRender() { + if (!_virtualEnabled || !_messagesEl) return + ensureVirtualSpacers() + const atBottom = isAtBottom() + const scrollTop = _messagesEl.scrollTop + const viewport = _messagesEl.clientHeight + const items = _virtualItems + const { start, end, prefix } = computeVirtualRange(items, scrollTop, viewport, _virtualAvgHeight, VIRTUAL_OVERSCAN, VIRTUAL_WINDOW, _virtualHeights) + _virtualRange = { start, end, prefix } + const { top, bottom } = getSpacerHeights(prefix, start, end) + _virtualTopSpacer.style.height = `${top}px` + _virtualBottomSpacer.style.height = `${bottom}px` + + const visibleIds = new Set(items.slice(start, end).map(i => i.id)) + _messagesEl.querySelectorAll('.msg').forEach(node => { + const vid = node.dataset.vid + if (!vid || !visibleIds.has(vid)) node.remove() + }) + + const anchor = _virtualTopSpacer.nextSibling + let refNode = anchor + for (let i = start; i < end; i++) { + const item = items[i] + if (!item?.node) continue + if (item.node.parentNode !== _messagesEl) { + _messagesEl.insertBefore(item.node, refNode || _virtualBottomSpacer) + } + refNode = item.node.nextSibling + } + + requestAnimationFrame(() => { + let total = 0, count = 0 + items.slice(start, end).forEach(item => { + const el = item.node + if (!el || !el.getBoundingClientRect) return + const h = Math.max(1, Math.ceil(el.getBoundingClientRect().height)) + if (h) { + _virtualHeights.set(item.id, h) + total += h + count += 1 + } + }) + if (count) _virtualAvgHeight = Math.max(24, Math.round(total / count)) + + if (atBottom) { + scrollToBottom() + } else { + const newTop = _virtualTopSpacer.offsetHeight + const delta = newTop - top + if (delta !== 0) _messagesEl.scrollTop = scrollTop + delta + } + }) +} + function updateSendState() { if (!_sendBtn || !_textarea) return if (_isStreaming) { diff --git a/src/style/chat.css b/src/style/chat.css index 31c69502..6579eb50 100644 --- a/src/style/chat.css +++ b/src/style/chat.css @@ -144,6 +144,11 @@ gap: var(--space-sm); } +.msg-virtual-spacer { + height: 0; + flex-shrink: 0; +} + /* 消息通用 */ .msg { display: flex; From 98ebe7fdbe8b5f4fe56cf712cf71868f7b1c9252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 09:40:28 +0800 Subject: [PATCH 177/426] chore: checkpoint before test migration From ebccba97c061a293ea1b57360b4543df75580ac3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 09:42:18 +0800 Subject: [PATCH 178/426] fix: restore docker tasking and migrate tests --- src/lib/docker-tasking.js | 44 ++++++++++++ tests/docker-tasking.test.js | 67 ++++++++--------- tests/gateway-guardian-policy.test.js | 100 +++++++++++++------------- 3 files changed, 126 insertions(+), 85 deletions(-) create mode 100644 src/lib/docker-tasking.js diff --git a/src/lib/docker-tasking.js b/src/lib/docker-tasking.js new file mode 100644 index 00000000..2fe683e1 --- /dev/null +++ b/src/lib/docker-tasking.js @@ -0,0 +1,44 @@ +export const DOCKER_TASK_TIMEOUT_MS = 10 * 60 * 1000 + +export function buildDockerDispatchTargets(targets = []) { + if (!Array.isArray(targets)) return [] + return targets.map(target => ({ + containerId: target.id, + containerName: target.name, + nodeId: target.nodeId || null, + })) +} + +export function buildDockerInstanceSwitchContext({ containerId, name, port, gatewayPort, nodeId }) { + if (!containerId) throw new Error('缺少容器 ID') + if (!name) throw new Error('缺少容器名称') + + const panelPort = parseRequiredPort(port, '面板端口') + const parsedGatewayPort = parseOptionalPort(gatewayPort, 18789) + + return { + instanceId: `docker-${containerId.slice(0, 12)}`, + reloadRoute: true, + registration: { + name, + type: 'docker', + endpoint: `http://127.0.0.1:${panelPort}`, + gatewayPort: parsedGatewayPort, + containerId, + nodeId: nodeId || null, + note: 'Added from Docker page', + }, + } +} + +function parseRequiredPort(value, label) { + const port = Number.parseInt(value, 10) + if (Number.isInteger(port) && port > 0) return port + throw new Error(`${label}无效`) +} + +function parseOptionalPort(value, fallback) { + const port = Number.parseInt(value, 10) + if (Number.isInteger(port) && port > 0) return port + return fallback +} diff --git a/tests/docker-tasking.test.js b/tests/docker-tasking.test.js index b35ab6df..f6034ad5 100644 --- a/tests/docker-tasking.test.js +++ b/tests/docker-tasking.test.js @@ -1,5 +1,4 @@ -import test from 'node:test' -import assert from 'node:assert/strict' +import { describe, it, expect } from 'vitest' import { DOCKER_TASK_TIMEOUT_MS, @@ -7,40 +6,42 @@ import { buildDockerInstanceSwitchContext, } from '../src/lib/docker-tasking.js' -test('Docker 异步任务默认超时提升到 10 分钟', () => { - assert.equal(DOCKER_TASK_TIMEOUT_MS, 10 * 60 * 1000) -}) +describe('docker-tasking', () => { + it('Docker 异步任务默认超时提升到 10 分钟', () => { + expect(DOCKER_TASK_TIMEOUT_MS).toBe(10 * 60 * 1000) + }) -test('Docker 派发目标会保留容器和节点信息', () => { - const targets = buildDockerDispatchTargets([ - { id: 'container-1234567890ab', name: 'openclaw-coder', nodeId: 'node-a' }, - { id: 'container-bbbbbbbbbbbb', name: 'openclaw-writer', nodeId: 'node-b' }, - ]) + it('Docker 派发目标会保留容器和节点信息', () => { + const targets = buildDockerDispatchTargets([ + { id: 'container-1234567890ab', name: 'openclaw-coder', nodeId: 'node-a' }, + { id: 'container-bbbbbbbbbbbb', name: 'openclaw-writer', nodeId: 'node-b' }, + ]) - assert.deepEqual(targets, [ - { containerId: 'container-1234567890ab', containerName: 'openclaw-coder', nodeId: 'node-a' }, - { containerId: 'container-bbbbbbbbbbbb', containerName: 'openclaw-writer', nodeId: 'node-b' }, - ]) -}) - -test('Docker 实例切换上下文会要求整页重载并生成正确注册参数', () => { - const ctx = buildDockerInstanceSwitchContext({ - containerId: 'abcdef1234567890', - name: 'openclaw-coder', - port: '21420', - gatewayPort: '28789', - nodeId: 'node-a', + expect(targets).toEqual([ + { containerId: 'container-1234567890ab', containerName: 'openclaw-coder', nodeId: 'node-a' }, + { containerId: 'container-bbbbbbbbbbbb', containerName: 'openclaw-writer', nodeId: 'node-b' }, + ]) }) - assert.equal(ctx.instanceId, 'docker-abcdef123456') - assert.equal(ctx.reloadRoute, true) - assert.deepEqual(ctx.registration, { - name: 'openclaw-coder', - type: 'docker', - endpoint: 'http://127.0.0.1:21420', - gatewayPort: 28789, - containerId: 'abcdef1234567890', - nodeId: 'node-a', - note: 'Added from Docker page', + it('Docker 实例切换上下文会要求整页重载并生成正确注册参数', () => { + const ctx = buildDockerInstanceSwitchContext({ + containerId: 'abcdef1234567890', + name: 'openclaw-coder', + port: '21420', + gatewayPort: '28789', + nodeId: 'node-a', + }) + + expect(ctx.instanceId).toBe('docker-abcdef123456') + expect(ctx.reloadRoute).toBe(true) + expect(ctx.registration).toEqual({ + name: 'openclaw-coder', + type: 'docker', + endpoint: 'http://127.0.0.1:21420', + gatewayPort: 28789, + containerId: 'abcdef1234567890', + nodeId: 'node-a', + note: 'Added from Docker page', + }) }) }) diff --git a/tests/gateway-guardian-policy.test.js b/tests/gateway-guardian-policy.test.js index 723da76e..d2c3ce88 100644 --- a/tests/gateway-guardian-policy.test.js +++ b/tests/gateway-guardian-policy.test.js @@ -1,5 +1,4 @@ -import test from 'node:test' -import assert from 'node:assert/strict' +import { describe, it, expect } from 'vitest' import { MAX_AUTO_RESTART, @@ -9,61 +8,58 @@ import { shouldResetAutoRestartCount, } from '../src/lib/gateway-guardian-policy.js' -test('短暂恢复运行不应立即清零自动重启计数', () => { - assert.equal( - shouldResetAutoRestartCount({ - autoRestartCount: 2, - runningSince: 10_000, - now: 10_000 + STABLE_RUNNING_MS - 1, - }), - false, - ) -}) +describe('gateway-guardian-policy', () => { + it('短暂恢复运行不应立即清零自动重启计数', () => { + expect( + shouldResetAutoRestartCount({ + autoRestartCount: 2, + runningSince: 10_000, + now: 10_000 + STABLE_RUNNING_MS - 1, + }), + ).toBe(false) + }) -test('稳定运行超过阈值后才允许清零自动重启计数', () => { - assert.equal( - shouldResetAutoRestartCount({ - autoRestartCount: 2, - runningSince: 10_000, - now: 10_000 + STABLE_RUNNING_MS, - }), - true, - ) -}) + it('稳定运行超过阈值后才允许清零自动重启计数', () => { + expect( + shouldResetAutoRestartCount({ + autoRestartCount: 2, + runningSince: 10_000, + now: 10_000 + STABLE_RUNNING_MS, + }), + ).toBe(true) + }) -test('达到最大自动重启次数后必须停止守护', () => { - assert.deepEqual( - evaluateAutoRestartAttempt({ - now: 90_000, - lastRestartTime: 0, - autoRestartCount: MAX_AUTO_RESTART, - }), - { action: 'give_up' }, - ) -}) + it('达到最大自动重启次数后必须停止守护', () => { + expect( + evaluateAutoRestartAttempt({ + now: 90_000, + lastRestartTime: 0, + autoRestartCount: MAX_AUTO_RESTART, + }), + ).toEqual({ action: 'give_up' }) + }) -test('冷却时间内不应重复自动重启', () => { - assert.deepEqual( - evaluateAutoRestartAttempt({ - now: RESTART_COOLDOWN - 1, - lastRestartTime: 0, - autoRestartCount: 1, - }), - { action: 'cooldown' }, - ) -}) + it('冷却时间内不应重复自动重启', () => { + expect( + evaluateAutoRestartAttempt({ + now: RESTART_COOLDOWN - 1, + lastRestartTime: 0, + autoRestartCount: 1, + }), + ).toEqual({ action: 'cooldown' }) + }) -test('满足条件时应增加自动重启计数并记录重启时间', () => { - assert.deepEqual( - evaluateAutoRestartAttempt({ - now: 120_000, - lastRestartTime: 0, - autoRestartCount: 1, - }), - { + it('满足条件时应增加自动重启计数并记录重启时间', () => { + expect( + evaluateAutoRestartAttempt({ + now: 120_000, + lastRestartTime: 0, + autoRestartCount: 1, + }), + ).toEqual({ action: 'restart', autoRestartCount: 2, lastRestartTime: 120_000, - }, - ) + }) + }) }) From 327d3e93cfaa5bedbe654b30d5af0a1e3b1c9835 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 10:37:16 +0800 Subject: [PATCH 179/426] checkpoint before cron load fix --- .../2026-03-17-cron-ws-sessionmessage.md | 199 +++++++++ .../2026-03-17-skill-trigger-optimization.md | 165 ++++++++ src-tauri/src/commands/cloudflared.rs | 1 + src-tauri/src/commands/config.rs | 6 - src-tauri/src/commands/mod.rs | 7 +- src-tauri/src/lib.rs | 6 +- src/lib/tauri-api.js | 7 +- src/main.js | 27 +- src/pages/assistant.js | 47 ++- src/pages/cron.js | 377 ++++++++++-------- src/pages/settings.js | 120 ------ 11 files changed, 620 insertions(+), 342 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-17-cron-ws-sessionmessage.md create mode 100644 docs/superpowers/plans/2026-03-17-skill-trigger-optimization.md diff --git a/docs/superpowers/plans/2026-03-17-cron-ws-sessionmessage.md b/docs/superpowers/plans/2026-03-17-cron-ws-sessionmessage.md new file mode 100644 index 00000000..008cbde8 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-cron-ws-sessionmessage.md @@ -0,0 +1,199 @@ +# Cron WS SessionMessage Replacement Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace gateway patch–based cron sessionMessage with a client-side scheduler that sends user messages over the already-connected WSS only after the target session is idle/completed. + +**Architecture:** Store scheduled jobs in panel config (local), run a lightweight scheduler in ClawPanel (cron.js) that waits for gatewayReady and session idle state, then dispatch wsClient.chatSend. Track run state and last-run time in panel config to avoid duplicate sends. + +**Tech Stack:** Frontend JS (cron.js, ws-client.js), panel config (read/write via tauri-api), WebSocket RPC (sessions.list, sessions.get, chat events). + +--- + +## File Structure + +**Modify:** +- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\src\pages\cron.js` (UI, local scheduler, local job storage) +- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\src\lib\tauri-api.js` (panel config helpers) +- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\src\lib\ws-client.js` (optional: expose session idle state helper) +- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\src\pages\chat.js` (optional: emit idle status events) +- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\src\main.js` (optional: start scheduler on boot) + +**Create:** +- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\docs\superpowers\plans\2026-03-17-cron-ws-sessionmessage.md` (this plan) + +--- + +## Task 1: Define local cron job schema and storage + +**Files:** +- Modify: `src/pages/cron.js` +- Modify: `src/lib/tauri-api.js` + +- [ ] **Step 1: Add local job schema** + +Define a new `localCronJobs` array in panel config, each job: +``` +{ + id: "uuid", + name: "string", + schedule: { kind: "cron", expr: "* * * * *" }, + enabled: true, + payload: { + kind: "sessionMessage", + label: "sessionLabel", + message: "text", + waitForIdle: true + }, + state: { lastRunAtMs: 0, lastStatus: "ok|error|skipped", lastError: "" } +} +``` + +- [ ] **Step 2: Add API helpers for panel config** + +In `tauri-api.js`, add helpers: +``` +readPanelConfig() +writePanelConfig() +``` +Ensure cron.js can read/write panel config locally without gateway. + +- [ ] **Step 3: Implement load/save local jobs** + +In cron.js: +- On render, load panel config and initialize local jobs list if missing. +- Use `localJobs` as a separate tab/section from gateway jobs. + +--- + +## Task 2: Update Cron UI for sessionMessage-only mode + +**Files:** +- Modify: `src/pages/cron.js` + +- [ ] **Step 1: Replace task type selector** + +Remove gateway cron payload kind selector. Only show “发送 user 消息(WSS)”. + +- [ ] **Step 2: Show required fields** + +Show inputs: +- name +- schedule (cron) +- sessionLabel (from sessions.list) +- message (textarea) +- enabled toggle +- waitForIdle toggle + +- [ ] **Step 3: Save local job** + +On save, write to panel config localCronJobs, update lastRun fields to defaults. + +- [ ] **Step 4: Remove gateway cron create/update** + +Delete calls to `wsClient.request('cron.add'|'cron.update')` for local jobs. + +--- + +## Task 3: Implement WSS local scheduler + +**Files:** +- Modify: `src/pages/cron.js` +- Modify: `src/main.js` (optional startup hook) + +- [ ] **Step 1: Add scheduler loop** + +Create an interval (e.g., 10s) to: +- Check wsClient.gatewayReady +- For each enabled local job, check next due time from cron expression +- If due and not run in current window, attempt send + +- [ ] **Step 2: Determine session idle state** + +Define idle as: +- No active runs for target session +- Or no “streaming” event in last N seconds + +Approach: +- Use `sessions.list` or `sessions.get` via wsClient to read run state if available +- If not available, fallback to client-side tracking of last chat event timestamps for that session + +- [ ] **Step 3: Send message only when idle** + +Use: +``` +wsClient.chatSend(sessionKey, message) +``` +Only when idle. + +- [ ] **Step 4: Update job state** + +On send success: +- state.lastRunAtMs = Date.now() +- state.lastStatus = "ok" +On failure: +- state.lastStatus = "error" +- state.lastError = message + +Persist to panel config after each run. + +--- + +## Task 4: Session resolution by label + +**Files:** +- Modify: `src/pages/cron.js` + +- [ ] **Step 1: Build label→sessionKey map** + +Use sessions.list to map label (parseSessionLabel) back to sessionKey. + +- [ ] **Step 2: Validate session exists** + +If session missing, mark lastStatus=error and lastError="session not found". + +--- + +## Task 5: Visual status and monitoring + +**Files:** +- Modify: `src/pages/cron.js` + +- [ ] **Step 1: Show local cron jobs in UI** + +Include status badges: +- last run time +- last status +- error message if any + +- [ ] **Step 2: Add manual run button** + +Trigger immediate send via scheduler path (same idle check). + +--- + +## Task 6: Verification + +**Files:** +- Modify: `src/pages/cron.js` + +- [ ] **Step 1: Build** + +Run: +``` +npm run build +``` +Expected: Success. + +- [ ] **Step 2: Manual test** + +1) Create a local cron job to send to main session. +2) Start a long-running agent task. +3) Verify scheduler waits until idle, then sends message. + +--- + +## Notes +- This plan intentionally bypasses gateway cron schema and patching. +- Jobs are stored locally in panel config, so they only run while ClawPanel is open. +- If headless scheduling is required later, a separate gateway-side implementation will be needed. diff --git a/docs/superpowers/plans/2026-03-17-skill-trigger-optimization.md b/docs/superpowers/plans/2026-03-17-skill-trigger-optimization.md new file mode 100644 index 00000000..38fc574d --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-skill-trigger-optimization.md @@ -0,0 +1,165 @@ +# Skill Trigger Optimization Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Optimize trigger descriptions for all installed OpenClaw skills using the skill-creator workflow. + +**Architecture:** Inventory all skills under `~/.openclaw/skills/`, snapshot current SKILL.md frontmatter, then iterate through each skill to improve the description field with an automated loop when available. Ensure results are written back to each skill’s SKILL.md frontmatter and logged. + +**Tech Stack:** PowerShell, Python via `uv`, OpenClaw skill-creator assets (scripts/), markdown edits. + +--- + +### Task 1: Inventory and snapshot + +**Files:** +- Read: `C:\Users\34438\.openclaw\skills\*\SKILL.md` +- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\inventory.json` +- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\snapshots\\SKILL.md` + +- [ ] **Step 1: List skill directories** + +Run: +``` +Get-ChildItem "C:\Users\34438\.openclaw\skills" -Directory | Select-Object Name +``` +Expected: list of skill folder names. + +- [ ] **Step 2: Snapshot current SKILL.md files** + +Run: +``` +New-Item -ItemType Directory -Path "C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\snapshots" -Force | Out-Null +``` +Then copy each `SKILL.md` into its snapshot folder. + +- [ ] **Step 3: Build inventory.json** + +Create a JSON array with entries: +``` +{ + "name": "skill-folder", + "path": "C:\\Users\\34438\\.openclaw\\skills\\skill-folder", + "skill_md": "...\\SKILL.md", + "description": "" +} +``` + +- [ ] **Step 4: Commit checkpoint** + +Run: +``` +git add docs/superpowers/plans/2026-03-17-skill-trigger-optimization.md +``` +If in a repo, commit after build verification. + +--- + +### Task 2: Verify skill-creator tooling availability + +**Files:** +- Read: `C:\Users\34438\.openclaw\skills\skill-creator\scripts\` (if exists) +- Read: `C:\Users\34438\.openclaw\skills\skill-creator\references\` (if exists) + +- [ ] **Step 1: Confirm run_loop.py and eval scripts exist** + +Run: +``` +Get-ChildItem "C:\Users\34438\.openclaw\skills\skill-creator\scripts" -Filter "*.py" +``` +Expected: `run_loop.py`, `run_eval.py`, `aggregate_benchmark.py` or similar. + +- [ ] **Step 2: Decide path** + +If scripts exist: use automated loop per skill-creator instructions. +If scripts are missing: use manual description optimization with heuristics (see Task 4). + +--- + +### Task 3: Automated description optimization loop (preferred) + +**Files:** +- Modify: `C:\Users\34438\.openclaw\skills\\SKILL.md` +- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\\trigger_eval.json` +- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\\run_log.txt` + +- [ ] **Step 1: Generate trigger eval set** + +Create 16-20 queries (8-10 should-trigger, 8-10 should-not-trigger) per skill and save as JSON: +``` +[ + {"query": "...", "should_trigger": true}, + {"query": "...", "should_trigger": false} +] +``` + +- [ ] **Step 2: Run description optimization loop** + +Run (example): +``` +uv run python -m scripts.run_loop --eval-set --skill-path --model openai-codex/gpt-5.2-codex --max-iterations 5 --verbose +``` +Capture output to `run_log.txt`. + +- [ ] **Step 3: Apply best_description** + +Update the SKILL.md frontmatter `description` with `best_description`. + +- [ ] **Step 4: Record changes** + +Update `inventory.json` with new description and a `score` field if available. + +--- + +### Task 4: Manual description optimization (fallback) + +**Files:** +- Modify: `C:\Users\34438\.openclaw\skills\\SKILL.md` +- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\\manual_notes.md` + +- [ ] **Step 1: Draft improved description** + +Rewrite the description with explicit trigger phrases and contexts. Ensure it is pushy and includes common user phrasings. + +- [ ] **Step 2: Update SKILL.md frontmatter** + +Replace the `description` value while preserving name and formatting. + +- [ ] **Step 3: Log** + +Write before/after into `manual_notes.md` and update `inventory.json`. + +--- + +### Task 5: Validation and summary + +**Files:** +- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\summary.md` + +- [ ] **Step 1: Validate frontmatter** + +Verify each SKILL.md starts with YAML frontmatter containing `name` and `description`. + +- [ ] **Step 2: Build summary** + +Write a summary with counts of skills updated and any failures. + +- [ ] **Step 3: Final build check (if required by repo policy)** + +Run: +``` +npm run build +``` +Expected: success. + +- [ ] **Step 4: Commit** + +Commit all changes after build success. + +--- + +## Notes +- Use PowerShell only. +- Use `uv run python` for Python scripts. +- No emoji in outputs or docs. +- Do not overwrite SKILL.md structure beyond frontmatter description. diff --git a/src-tauri/src/commands/cloudflared.rs b/src-tauri/src/commands/cloudflared.rs index 339d0ec4..5528d24b 100644 --- a/src-tauri/src/commands/cloudflared.rs +++ b/src-tauri/src/commands/cloudflared.rs @@ -265,6 +265,7 @@ fn add_allowed_origin_for_target(origin: &str, target: &str, port: u16) -> Resul Ok(()) } +#[allow(dead_code)] fn add_allowed_origin(origin: &str) -> Result<(), String> { let mut config = crate::commands::config::load_openclaw_json()?; let root = config.as_object_mut().ok_or("配置格式错误")?; diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index f9517ddf..a95d802d 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -782,12 +782,6 @@ fn detect_installed_source() -> String { return "chinese".into(); } } - if let Ok(root) = super::gateway_patch::npm_root_global() { - let zh_dir = root.join("@qingchencloud").join("openclaw-zh"); - if zh_dir.exists() { - return "chinese".into(); - } - } "official".into() } // 所有平台通用: npm list 检测 diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 31648876..3fc164d7 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -13,7 +13,6 @@ pub mod agent; pub mod assistant; pub mod cloudflared; pub mod config; -pub mod gateway_patch; pub mod device; pub mod extensions; pub mod logs; @@ -234,7 +233,7 @@ fn read_registry_env(hkey: RegKey, subkey: &str) -> Vec<(String, String, RegType fn expand_env_vars(value: &str, env_map: &HashMap) -> String { let mut output = value.to_string(); for _ in 0..5 { - let mut chars: Vec = output.chars().collect(); + let chars: Vec = output.chars().collect(); let mut i = 0usize; let mut changed = false; let mut result = String::new(); @@ -322,7 +321,7 @@ pub fn build_system_env() -> Vec<(String, String)> { let enhanced = build_enhanced_path_with_base(&base_path); map.insert("PATH".to_string(), enhanced); - let built = map.into_iter().collect(); + let built: Vec<(String, String)> = map.into_iter().collect(); if let Ok(mut guard) = SYSTEM_ENV_CACHE.write() { *guard = Some((std::time::Instant::now(), built.clone())); } @@ -335,7 +334,7 @@ pub fn build_system_env() -> Vec<(String, String)> { let base = map.get("PATH").cloned().unwrap_or_default(); let enhanced = build_enhanced_path_with_base(&base); map.insert("PATH".to_string(), enhanced); - let built = map.into_iter().collect(); + let built: Vec<(String, String)> = map.into_iter().collect(); if let Ok(mut guard) = SYSTEM_ENV_CACHE.write() { *guard = Some((std::time::Instant::now(), built.clone())); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ed0d044b..14244342 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,7 +4,7 @@ mod tray; mod utils; use commands::{ - agent, assistant, cloudflared, config, device, extensions, gateway_patch, logs, memory, + agent, assistant, cloudflared, config, device, extensions, logs, memory, messaging, pairing, service, skills, update, }; @@ -117,10 +117,6 @@ pub fn run() { cloudflared::cloudflared_login, cloudflared::cloudflared_start, cloudflared::cloudflared_stop, - // Gateway 补丁 - gateway_patch::gateway_patch_status, - gateway_patch::gateway_patch_apply, - gateway_patch::gateway_patch_rollback, // 服务 service::get_services_status, service::start_service, diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 36fa727a..3315e236 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -166,7 +166,7 @@ export const api = { guardianStatus: () => invoke('guardian_status'), // 配置(读缓存,写清缓存) - getVersionInfo: () => cachedInvoke('get_version_info', {}, 30000), + getVersionInfo: () => invoke('get_version_info'), getStatusSummary: () => cachedInvoke('get_status_summary', {}, 60000), readOpenclawConfig: () => cachedInvoke('read_openclaw_config'), writeOpenclawConfig: (config) => { invalidate('read_openclaw_config'); return invoke('write_openclaw_config', { config }) }, @@ -227,11 +227,6 @@ export const api = { cloudflaredStart: (config) => invoke('cloudflared_start', { config }), cloudflaredStop: () => invoke('cloudflared_stop'), - // Gateway 补丁 - gatewayPatchStatus: () => invoke('gateway_patch_status'), - gatewayPatchApply: (force) => invoke('gateway_patch_apply', { force: !!force }), - gatewayPatchRollback: () => invoke('gateway_patch_rollback'), - // 安装/部署 checkInstallation: () => cachedInvoke('check_installation', {}, 60000), initOpenclawConfig: () => { invalidate('check_installation'); return invoke('init_openclaw_config') }, diff --git a/src/main.js b/src/main.js index a82eac73..1ad062db 100644 --- a/src/main.js +++ b/src/main.js @@ -31,30 +31,6 @@ const isTauri = !!window.__TAURI_INTERNALS__ let _forceSetup = false let _skipSetup = false -const GATEWAY_PATCH_COOLDOWN_MS = 5 * 60 * 1000 -let _gatewayPatchLastCheck = 0 -let _gatewayPatchRunning = false - -async function runGatewayPatchAutoDetect() { - if (!isTauri) return - if (_gatewayPatchRunning) return - const now = Date.now() - if (now - _gatewayPatchLastCheck < GATEWAY_PATCH_COOLDOWN_MS) return - _gatewayPatchLastCheck = now - _gatewayPatchRunning = true - try { - const status = await api.gatewayPatchStatus() - const installed = status?.installed_version - const recorded = status?.openclawVersion || status?.openclaw_version - if (!installed || !recorded) return - if (installed === recorded) return - await api.gatewayPatchApply(true) - } catch { - // 静默 - } finally { - _gatewayPatchRunning = false - } -} async function checkAuth() { if (isTauri) { @@ -376,7 +352,7 @@ async function boot() { const ensureWebSession = isTauri ? api.readPanelConfig().then(cfg => { _forceSetup = cfg.forceSetup === true - _skipSetup = cfg.Setup === true + _skipSetup = cfg.skipSetup === true if (cfg.accessPassword) { return fetch('/__api/auth_login', { method: 'POST', @@ -775,7 +751,6 @@ function startUpdateChecker() {
      如果问题持续出现,请尝试重新安装 ClawPanel
      或在 GitHub Issues 反馈
` } - await runGatewayPatchAutoDetect() startUpdateChecker() // 初始化全局 AI 助手浮动按钮(延迟加载,不阻塞启动) diff --git a/src/pages/assistant.js b/src/pages/assistant.js index 342e1103..f1ff5266 100644 --- a/src/pages/assistant.js +++ b/src/pages/assistant.js @@ -1509,14 +1509,17 @@ async function callAI(sessionId, messages, onChunk) { } const base = cleanBaseUrl(_config.baseUrl, apiType) - _abortController = new AbortController() + const controller = new AbortController() + _abortController = controller + if (sessionId) setAbortController(sessionId, controller) const allMessages = [{ role: 'system', content: buildSystemPrompt() }, ...messages] // 总超时保护 let _timedOut = false const totalTimer = setTimeout(() => { _timedOut = true - if (_abortController) _abortController.abort() + const active = sessionId ? getAbortController(sessionId) : controller + if (active) active.abort() }, TIMEOUT_TOTAL) try { @@ -1543,7 +1546,9 @@ async function callAI(sessionId, messages, onChunk) { const msg = err.message || '' if (msg.includes('legacy protocol') || msg.includes('/v1/responses') || msg.includes('not supported')) { console.log('[assistant] Chat Completions 不支持此模型,自动切换到 Responses API') - _abortController = new AbortController() + const nextController = new AbortController() + _abortController = nextController + if (sessionId) setAbortController(sessionId, nextController) await callResponsesAPI(base, allMessages, onChunk) return } @@ -1551,6 +1556,9 @@ async function callAI(sessionId, messages, onChunk) { } } finally { clearTimeout(totalTimer) + if (sessionId && getAbortController(sessionId) === controller) { + setAbortController(sessionId, null) + } } } @@ -2088,14 +2096,17 @@ async function callAIWithTools(sessionId, messages, onStatus, onToolProgress) { const tools = getEnabledTools() let currentMessages = [{ role: 'system', content: buildSystemPrompt() }, ...messages] const toolHistory = [] + let controller = null const autoRounds = _config.autoRounds ?? 8 // 0 = 无限制 let nextPauseAt = autoRounds // 下一次暂停的轮次阈值 - for (let round = 0; ; round++) { - // 检查是否已被用户中止 - if (!sessionId || !getStreaming(sessionId) || _abortController?.signal?.aborted) { - throw new DOMException('Aborted', 'AbortError') - } + try { + for (let round = 0; ; round++) { + // 检查是否已被用户中止 + const active = sessionId ? getAbortController(sessionId) : null + if (!sessionId || !getStreaming(sessionId) || active?.signal?.aborted) { + throw new DOMException('Aborted', 'AbortError') + } if (autoRounds > 0 && round >= nextPauseAt) { const answer = await showAskUserCard({ question: `AI 已连续调用工具 ${round} 轮,可能陷入循环。你希望怎么做?`, @@ -2114,7 +2125,9 @@ async function callAIWithTools(sessionId, messages, onStatus, onToolProgress) { } } - _abortController = new AbortController() + controller = new AbortController() + _abortController = controller + if (sessionId) setAbortController(sessionId, controller) onStatus(round === 0 ? 'AI 思考中...' : `AI 处理工具结果 (第${round + 1}轮)...`) // ── Anthropic 工具调用 ── @@ -2132,7 +2145,7 @@ async function callAIWithTools(sessionId, messages, onStatus, onToolProgress) { const resp = await fetchWithRetry(base + '/messages', { method: 'POST', headers: authHeaders(), body: JSON.stringify(body), - signal: _abortController.signal, + signal: controller.signal, }) if (!resp.ok) { const errText = await resp.text().catch(() => '') @@ -2191,7 +2204,7 @@ async function callAIWithTools(sessionId, messages, onStatus, onToolProgress) { const url = `${base}/models/${_config.model}:generateContent?key=${_config.apiKey}` const resp = await fetchWithRetry(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), signal: _abortController.signal, + body: JSON.stringify(body), signal: controller.signal, }) if (!resp.ok) { const errText = await resp.text().catch(() => '') @@ -2242,7 +2255,7 @@ async function callAIWithTools(sessionId, messages, onStatus, onToolProgress) { method: 'POST', headers: authHeaders(), body: JSON.stringify(body), - signal: _abortController.signal, + signal: controller.signal, }) if (!resp.ok) { @@ -2287,6 +2300,11 @@ async function callAIWithTools(sessionId, messages, onStatus, onToolProgress) { const content = assistantMsg.content || assistantMsg.reasoning_content || '' return { content, toolHistory } } + } finally { + if (sessionId && controller && getAbortController(sessionId) === controller) { + setAbortController(sessionId, null) + } + } } // ── 渲染 ── @@ -3821,6 +3839,11 @@ async function retryAIResponse(session) { function stopStreaming() { if (_currentSessionId) { setStreaming(_currentSessionId, false) + const controller = getAbortController(_currentSessionId) + if (controller) { + controller.abort() + setAbortController(_currentSessionId, null) + } } _isStreaming = false if (_abortController) { diff --git a/src/pages/cron.js b/src/pages/cron.js index 45e3e268..6b2c8e92 100644 --- a/src/pages/cron.js +++ b/src/pages/cron.js @@ -1,16 +1,19 @@ /** - * 定时任务管理 - * 通过 Gateway WebSocket RPC 管理(cron.list / cron.add / cron.update / cron.remove / cron.run) - * 注意:openclaw.json 不支持 cron.jobs 字段,定时任务只能通过 Gateway 在线管理 + * 定时任务管理(本地调度 + WSS 发送) + * 任务存储在 panel config,本地调度器在 Gateway 连接后发送 user 消息 */ 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, invalidate } from '../lib/tauri-api.js' +import { wsClient, uuid } from '../lib/ws-client.js' +import { api } from '../lib/tauri-api.js' let _unsub = null +let _schedulerTimer = null +let _lastSessionActivity = new Map() // sessionKey -> ts +let _sessionLabelMap = new Map() // label -> sessionKey +let _sessionLabelLastFetch = 0 // ── Cron 表达式快捷预设 ── @@ -24,7 +27,9 @@ const CRON_SHORTCUTS = [ { expr: '0 9 1 * *', text: '每月 1 号 9:00' }, ] -const SESSION_MESSAGE_TEXT = '继续执行' +const LOCAL_CRON_KEY = 'localCronJobs' +const SESSION_IDLE_MS = 5000 +const SCHEDULER_INTERVAL_MS = 10000 function parseSessionLabel(key) { const parts = (key || '').split(':') @@ -51,7 +56,7 @@ export async function render() {
${icon('alert-circle', 16)} - 定时任务通过 Gateway 管理。请先启动 Gateway 后使用此功能。 + 本地定时任务需要 Gateway 连接后才能发送消息。 服务管理
@@ -69,38 +74,25 @@ 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(() => { updateGatewayHint(page) fetchJobs(page, state) + restartScheduler(page, state) }) updateGatewayHint(page) await fetchJobs(page, state) + attachSessionActivityListener() + restartScheduler(page, state) return page } export function cleanup() { if (_unsub) { _unsub(); _unsub = null } -} - -/** 自动移除无效的 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 {} + stopScheduler() } function isGatewayUp() { @@ -116,38 +108,26 @@ function updateGatewayHint(page) { // ── 数据加载(Gateway RPC) ── async function fetchJobs(page, state) { - if (!isGatewayUp()) { - state.jobs = [] - state.loading = false - renderStats(page, state) - renderList(page, state) - return - } - state.loading = true renderList(page, state) try { - const res = await wsClient.request('cron.list', { includeDisabled: true }) - let jobs = res?.jobs || res - if (!Array.isArray(jobs)) jobs = [] - + const cfg = await api.readPanelConfig() + const jobs = Array.isArray(cfg?.[LOCAL_CRON_KEY]) ? cfg[LOCAL_CRON_KEY] : [] state.jobs = jobs.map(j => ({ id: j.id, name: j.name || j.id || '未命名', - description: j.description || '', - message: j.payload?.message || j.payload?.text || '', - payloadKind: j.payload?.kind || 'agentTurn', + message: j.payload?.message || '', + payloadKind: 'sessionMessage', sessionLabel: j.payload?.label || '', schedule: j.schedule || {}, enabled: j.enabled !== false, - agentId: j.agentId || null, - lastRunStatus: j.state?.lastRunStatus || j.state?.lastStatus || null, + lastRunStatus: j.state?.lastStatus || null, lastRunAtMs: j.state?.lastRunAtMs || null, lastError: j.state?.lastError || null, })) } catch (e) { - toast('获取任务列表失败: ' + e, 'error') + toast('获取本地任务失败: ' + e, 'error') state.jobs = [] } @@ -228,12 +208,10 @@ function renderList(page, state) { ${lastRunHtml}
- ${icon('clock', 12)} ${scheduleText}${job.payloadKind === 'sessionMessage' - ? ` · 目标: ${escapeHtml(job.sessionLabel || '未指定')}` - : (job.agentId ? ` · Agent: ${escapeHtml(job.agentId)}` : '')} + ${icon('clock', 12)} ${scheduleText} · 目标: ${escapeHtml(job.sessionLabel || '未指定')}
- ${escapeHtml(job.payloadKind === 'sessionMessage' ? SESSION_MESSAGE_TEXT : job.message)} + ${escapeHtml(job.message)}
${job.lastRunStatus === 'error' && job.lastError ? `
@@ -262,9 +240,9 @@ function renderList(page, state) { const btn = e.currentTarget btn.disabled = true try { - await wsClient.request('cron.run', { jobId: jid }) + await runLocalJob(job, true) toast('任务已触发执行', 'success') - setTimeout(() => fetchJobs(page, state), 2000) + await fetchJobs(page, state) } catch (err) { toast('触发失败: ' + err, 'error') } finally { btn.disabled = false } } @@ -274,7 +252,7 @@ function renderList(page, state) { btn.disabled = true btn.innerHTML = icon('refresh-cw', 14) try { - await wsClient.request('cron.update', { jobId: jid, patch: { enabled: !job.enabled } }) + await updateLocalJob(job.id, { 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) } @@ -288,7 +266,7 @@ function renderList(page, state) { if (!yes) return if (btn) btn.disabled = true try { - await wsClient.request('cron.remove', { jobId: jid }) + await removeLocalJob(job.id) toast('已删除', 'info') await fetchJobs(page, state) } catch (err) { toast('删除失败: ' + err, 'error'); if (btn) btn.disabled = false } @@ -299,10 +277,6 @@ function renderList(page, state) { // ── 创建/编辑任务弹窗 ── async function openTaskDialog(job, page, state) { - if (!isGatewayUp()) { - toast('Gateway 未连接,无法管理定时任务。请先启动 Gateway', 'warning') - return - } const isEdit = !!job const initSchedule = extractCronExpr(job?.schedule) || '0 9 * * *' const formId = 'cron-form-' + Date.now() @@ -312,9 +286,6 @@ async function openTaskDialog(job, page, state) { return `` }).join('') - // 先用默认选项,弹窗后异步加载 Agent 列表 - const agentOptionsHtml = `${job?.agentId ? `` : ''}` - const content = `
@@ -322,30 +293,13 @@ async function openTaskDialog(job, page, state) {
- - -
-
-
仅发送 user 消息,不附带系统注入
+
将通过 WSS 发送 user 消息
-
- - -
-
- - -
不选则使用默认 Agent 执行
-
-
- - -
配置了多个消息渠道时必须指定,否则任务会报错
+
+ +
@@ -372,42 +326,16 @@ async function openTaskDialog(job, page, state) { width: 500, }) - // 异步加载渠道列表 - api.readOpenclawConfig().then(cfg => { - const channels = cfg?.channels || {} - const channelIds = Object.keys(channels).filter(k => k !== 'defaults') - if (channelIds.length <= 1) return // 单渠道或无渠道不需要选 - const select = modal.querySelector('select[name="deliveryChannel"]') - if (!select) return - const current = job?.delivery?.channel || '' - select.innerHTML = `` + channelIds.map(ch => - `` - ).join('') - }).catch(() => {}) - - // 异步加载 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 = `` + agents.map(a => - `` - ).join('') - }).catch(() => {}) - // 异步加载会话列表 - wsClient.sessionsList(50).then(res => { - const list = res?.sessions || res || [] + refreshSessionLabelMap().then(() => { const select = modal.querySelector('select[name="sessionLabel"]') if (!select) return const current = job?.sessionLabel || '' - select.innerHTML = `` + list.map(s => { - const key = s.sessionKey || s.key || '' - const label = parseSessionLabel(key) - return `` + const options = Array.from(_sessionLabelMap.entries()).map(([label, key]) => { + const selected = label === current ? 'selected' : '' + return `` }).join('') + select.innerHTML = `` + options }).catch(() => {}) // 快捷预设按钮 @@ -440,30 +368,17 @@ async function openTaskDialog(job, page, state) { }) } - const toggleFields = () => { - const kind = modal.querySelector('select[name="taskKind"]').value - const showSession = kind === 'sessionMessage' - modal.querySelector('[data-field="sessionLabel"]').style.display = showSession ? 'block' : 'none' - modal.querySelector('[data-field="message"]').style.display = showSession ? 'none' : 'block' - modal.querySelector('[data-field="agent"]').style.display = showSession ? 'none' : 'block' - modal.querySelector('[data-field="delivery"]').style.display = showSession ? 'none' : 'block' - } - toggleFields() - modal.querySelector('select[name="taskKind"]').onchange = toggleFields - // 保存 modal.querySelector('#btn-cron-save').onclick = async () => { const name = modal.querySelector('input[name="name"]').value.trim() - const taskKind = modal.querySelector('select[name="taskKind"]').value const message = modal.querySelector('textarea[name="message"]').value.trim() const schedule = modal.querySelector('input[name="schedule"]').value.trim() - const agentId = modal.querySelector('select[name="agentId"]').value || undefined const enabled = modal.querySelector('input[name="enabled"]').checked const sessionLabel = modal.querySelector('select[name="sessionLabel"]').value if (!name) { toast('请输入任务名称', 'warning'); return } - if (taskKind !== 'sessionMessage' && !message) { toast('请输入执行指令', 'warning'); return } - if (taskKind === 'sessionMessage' && !sessionLabel) { toast('请选择会话', 'warning'); return } + if (!message) { toast('请输入消息内容', 'warning'); return } + if (!sessionLabel) { toast('请选择会话', 'warning'); return } if (!schedule) { toast('请设置执行周期', 'warning'); return } const saveBtn = modal.querySelector('#btn-cron-save') @@ -472,39 +387,22 @@ async function openTaskDialog(job, page, state) { try { if (isEdit) { - const patch = { name, enabled } - patch.schedule = { kind: 'cron', expr: schedule } - if (taskKind === 'sessionMessage') { - patch.sessionTarget = 'main' - patch.payload = { kind: 'sessionMessage', label: sessionLabel, message: SESSION_MESSAGE_TEXT, role: 'user', waitForIdle: true } - } else { - patch.payload = { kind: 'agentTurn', message } - if (agentId) patch.agentId = agentId - const deliveryChannel = modal.querySelector('select[name="deliveryChannel"]')?.value - if (deliveryChannel) { - patch.delivery = { mode: 'push', to: deliveryChannel, channel: deliveryChannel } - } - } - await wsClient.request('cron.update', { jobId: job.id, patch }) + await updateLocalJob(job.id, { + name, + enabled, + schedule: { kind: 'cron', expr: schedule }, + payload: { kind: 'sessionMessage', label: sessionLabel, message, waitForIdle: true }, + }) toast('任务已更新', 'success') } else { - const params = { + await addLocalJob({ + id: uuid(), name, enabled, schedule: { kind: 'cron', expr: schedule }, - } - if (taskKind === 'sessionMessage') { - params.sessionTarget = 'main' - params.payload = { kind: 'sessionMessage', label: sessionLabel, message: SESSION_MESSAGE_TEXT, role: 'user', waitForIdle: true } - } else { - params.payload = { kind: 'agentTurn', message } - if (agentId) params.agentId = agentId - const deliveryChannel = modal.querySelector('select[name="deliveryChannel"]')?.value - if (deliveryChannel) { - params.delivery = { mode: 'push', to: deliveryChannel, channel: deliveryChannel } - } - } - await wsClient.request('cron.add', params) + payload: { kind: 'sessionMessage', label: sessionLabel, message, waitForIdle: true }, + state: { lastRunAtMs: 0, lastStatus: null, lastError: null }, + }) toast('任务已创建', 'success') } modal.close?.() || modal.remove?.() @@ -519,7 +417,169 @@ async function openTaskDialog(job, page, state) { // ── 工具函数 ── -/** 从 Gateway 的 CronSchedule 对象或字符串中提取纯 cron 表达式 */ +// ── 本地调度辅助 ── + +async function loadLocalJobs() { + const cfg = await api.readPanelConfig() + if (!cfg[LOCAL_CRON_KEY] || !Array.isArray(cfg[LOCAL_CRON_KEY])) { + cfg[LOCAL_CRON_KEY] = [] + await api.writePanelConfig(cfg) + } + return cfg[LOCAL_CRON_KEY] +} + +async function saveLocalJobs(jobs) { + const cfg = await api.readPanelConfig() + cfg[LOCAL_CRON_KEY] = jobs + await api.writePanelConfig(cfg) +} + +async function addLocalJob(job) { + const jobs = await loadLocalJobs() + jobs.push(job) + await saveLocalJobs(jobs) +} + +async function updateLocalJob(id, patch) { + const jobs = await loadLocalJobs() + const idx = jobs.findIndex(j => j.id === id) + if (idx === -1) throw new Error('任务不存在') + jobs[idx] = { ...jobs[idx], ...patch } + await saveLocalJobs(jobs) +} + +async function removeLocalJob(id) { + const jobs = await loadLocalJobs() + const next = jobs.filter(j => j.id !== id) + await saveLocalJobs(next) +} + +function attachSessionActivityListener() { + wsClient.onEvent((msg) => { + if (msg?.type !== 'event') return + const payload = msg.payload || {} + const sessionKey = payload.sessionKey || payload.session_key || null + if (sessionKey) { + _lastSessionActivity.set(sessionKey, Date.now()) + } + }) +} + +async function refreshSessionLabelMap() { + const now = Date.now() + if (now - _sessionLabelLastFetch < 30000 && _sessionLabelMap.size > 0) return + _sessionLabelLastFetch = now + if (!isGatewayUp()) return + const res = await wsClient.sessionsList(200) + const list = res?.sessions || res || [] + _sessionLabelMap.clear() + list.forEach(s => { + const key = s.sessionKey || s.key || '' + const label = parseSessionLabel(key) + if (label) _sessionLabelMap.set(label, key) + }) +} + +function isSessionIdle(sessionKey) { + const last = _lastSessionActivity.get(sessionKey) || 0 + return Date.now() - last >= SESSION_IDLE_MS +} + +function stopScheduler() { + if (_schedulerTimer) { + clearInterval(_schedulerTimer) + _schedulerTimer = null + } +} + +function restartScheduler(page, state) { + stopScheduler() + _schedulerTimer = setInterval(() => tickScheduler(page, state), SCHEDULER_INTERVAL_MS) + tickScheduler(page, state) +} + +async function tickScheduler(page, state) { + if (!isGatewayUp()) return + await refreshSessionLabelMap().catch(() => {}) + const jobs = await loadLocalJobs() + const now = new Date() + for (const job of jobs) { + if (job.enabled === false) continue + const expr = extractCronExpr(job.schedule) + if (!expr) continue + if (!isCronDue(expr, now, job.state?.lastRunAtMs || 0)) continue + await runLocalJob(job, false).catch(() => {}) + } + await fetchJobs(page, state) +} + +async function runLocalJob(job, manual) { + await refreshSessionLabelMap().catch(() => {}) + const sessionKey = _sessionLabelMap.get(job.payload?.label || '') + if (!sessionKey) { + await updateLocalJob(job.id, { state: { ...job.state, lastStatus: 'error', lastError: 'session not found', lastRunAtMs: Date.now() } }) + throw new Error('session not found') + } + if (job.payload?.waitForIdle && !isSessionIdle(sessionKey)) { + if (manual) throw new Error('session busy') + return + } + try { + await wsClient.chatSend(sessionKey, job.payload?.message || '') + await updateLocalJob(job.id, { state: { ...job.state, lastStatus: 'ok', lastError: null, lastRunAtMs: Date.now() } }) + } catch (e) { + await updateLocalJob(job.id, { state: { ...job.state, lastStatus: 'error', lastError: String(e), lastRunAtMs: Date.now() } }) + throw e + } +} + +function isCronDue(expr, now, lastRunAtMs) { + const parts = expr.trim().split(/\s+/) + if (parts.length !== 5) return false + const [min, hr, dom, mon, dow] = parts + const last = lastRunAtMs ? new Date(lastRunAtMs) : null + if (last && last.getFullYear() === now.getFullYear() && last.getMonth() === now.getMonth() && last.getDate() === now.getDate() && last.getHours() === now.getHours() && last.getMinutes() === now.getMinutes()) { + return false + } + return matchCronField(min, now.getMinutes(), 0, 59) + && matchCronField(hr, now.getHours(), 0, 23) + && matchCronField(dom, now.getDate(), 1, 31) + && matchCronField(mon, now.getMonth() + 1, 1, 12) + && matchCronField(dow, now.getDay(), 0, 6) +} + +function matchCronField(field, value, min, max) { + if (field === '*') return true + const parts = field.split(',') + return parts.some(p => matchCronPart(p, value, min, max)) +} + +function matchCronPart(part, value, min, max) { + if (part === '*') return true + if (part.includes('/')) { + const [base, stepStr] = part.split('/') + const step = Number(stepStr) + if (!step || Number.isNaN(step)) return false + if (base === '*') return (value - min) % step === 0 + if (base.includes('-')) { + const [start, end] = base.split('-').map(Number) + if (Number.isNaN(start) || Number.isNaN(end)) return false + if (value < start || value > end) return false + return (value - start) % step === 0 + } + return false + } + if (part.includes('-')) { + const [start, end] = part.split('-').map(Number) + if (Number.isNaN(start) || Number.isNaN(end)) return false + return value >= start && value <= end + } + const num = Number(part) + if (Number.isNaN(num)) return false + return num === value +} + +/** 从 CronSchedule 对象或字符串中提取纯 cron 表达式 */ function extractCronExpr(schedule) { if (!schedule) return null if (typeof schedule === 'string') return schedule @@ -550,20 +610,11 @@ function describeCron(raw) { return expr } -/** 将 Gateway 返回的 CronSchedule 对象也处理成可读文字 */ +/** 将 CronSchedule 对象处理成可读文字 */ function describeCronFull(schedule) { if (!schedule) return '未知' if (typeof schedule === 'string') return describeCron(schedule) if (typeof schedule === 'object') { - if (schedule.kind === 'every' && schedule.everyMs) { - const ms = schedule.everyMs - if (ms < 60000) return `每 ${Math.round(ms / 1000)} 秒` - if (ms < 3600000) return `每 ${Math.round(ms / 60000)} 分钟` - return `每 ${Math.round(ms / 3600000)} 小时` - } - if (schedule.kind === 'at' && schedule.at) { - try { return '一次性: ' + new Date(schedule.at).toLocaleString() } catch { return schedule.at } - } if (schedule.kind === 'cron' && schedule.expr) return describeCron(schedule.expr) } return String(schedule) diff --git a/src/pages/settings.js b/src/pages/settings.js index 485940b3..8680f0bc 100644 --- a/src/pages/settings.js +++ b/src/pages/settings.js @@ -47,11 +47,6 @@ export async function render() {
公网访问
- -
-
Gateway 补丁
-
-
` bindEvents(page) @@ -63,9 +58,7 @@ async function loadAll(page) { const tasks = [loadProxyConfig(page), loadModelProxyConfig(page)] tasks.push(loadRegistry(page)) tasks.push(loadCloudflared(page)) - tasks.push(loadGatewayPatch(page)) await Promise.all(tasks) - await runGatewayPatchAutoDetect(page) } // ===== 网络代理 ===== @@ -195,18 +188,6 @@ function bindEvents(page) { case 'cloudflared-save': await handleCloudflaredSave(page) break - case 'gateway-patch-apply': - await handleGatewayPatchApply(page, false) - break - case 'gateway-patch-apply-force': - await handleGatewayPatchApply(page, true) - break - case 'gateway-patch-rollback': - await handleGatewayPatchRollback(page) - break - case 'gateway-patch-refresh': - await loadGatewayPatch(page) - break } } catch (e) { toast(e.toString(), 'error') @@ -288,107 +269,6 @@ async function handleSaveRegistry(page) { toast('npm 源已保存', 'success') } -// ===== Gateway 补丁 ===== - -const GATEWAY_PATCH_COOLDOWN_MS = 5 * 60 * 1000 -let _gatewayPatchLastCheck = 0 -let _gatewayPatchRunning = false - -async function runGatewayPatchAutoDetect(page) { - if (_gatewayPatchRunning) return - const now = Date.now() - if (now - _gatewayPatchLastCheck < GATEWAY_PATCH_COOLDOWN_MS) return - _gatewayPatchLastCheck = now - _gatewayPatchRunning = true - try { - const status = await api.gatewayPatchStatus() - const installed = status?.installed_version - const recorded = status?.openclawVersion || status?.openclaw_version - if (!installed || !recorded) return - if (installed === recorded) return - await api.gatewayPatchApply(true) - await loadGatewayPatch(page) - } catch (e) { - const msg = String(e || '') - if (msg && page) { - const el = page.querySelector('#gateway-patch-bar') - if (el) { - const existing = el.querySelector('[data-name="gateway-patch-error"]') - if (existing) existing.remove() - const errorEl = document.createElement('div') - errorEl.dataset.name = 'gateway-patch-error' - errorEl.style.color = 'var(--error)' - errorEl.style.marginTop = 'var(--space-xs)' - errorEl.textContent = `自动重打失败: ${msg}` - el.appendChild(errorEl) - } - } - } finally { - _gatewayPatchRunning = false - } -} - -async function loadGatewayPatch(page) { - const el = page.querySelector('#gateway-patch-bar') - if (!el) return - try { - const status = await api.gatewayPatchStatus() - const patched = !!status?.patched - const installed = status?.installed_version || '未知' - const recorded = status?.openclawVersion || status?.openclaw_version || '-' - const patchedVersion = status?.patched_version || '-' - const patchedAt = status?.patched_at || '-' - const files = Array.isArray(status?.files) && status.files.length > 0 ? status.files.map(escapeHtml).join(', ') : '-' - const lastError = status?.last_error ? `
错误: ${escapeHtml(status.last_error)}
` : '' - - el.innerHTML = ` -
- - ${patched ? '已补丁' : '未补丁'} - OpenClaw 版本: ${escapeHtml(installed)} -
- -
- - - - -
- -
-
补丁版本 -
${escapeHtml(patchedVersion)}
-
-
补丁时间 -
${escapeHtml(patchedAt)}
-
-
记录版本 -
${escapeHtml(recorded)}
-
-
补丁文件 -
${escapeHtml(files)}
-
-
- ${lastError} -
自动定位全局 npm 安装的 openclaw 包并打补丁。支持回滚。
- ` - } catch (e) { - el.innerHTML = `
加载失败: ${escapeHtml(String(e))}
` - } -} - -async function handleGatewayPatchApply(page, force) { - await api.gatewayPatchApply(!!force) - await loadGatewayPatch(page) - toast(force ? '补丁已重打' : '补丁已完成', 'success') -} - -async function handleGatewayPatchRollback(page) { - await api.gatewayPatchRollback() - await loadGatewayPatch(page) - toast('补丁已回滚', 'success') -} - // ===== Cloudflared 公网访问 ===== function getCloudflaredForm(page) { From 16175a927fa14c07aa9ded5d0c9e765aa2d287aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 10:44:04 +0800 Subject: [PATCH 180/426] fix: guard panel config null --- src/pages/cron.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/cron.js b/src/pages/cron.js index 6b2c8e92..08e72606 100644 --- a/src/pages/cron.js +++ b/src/pages/cron.js @@ -112,7 +112,7 @@ async function fetchJobs(page, state) { renderList(page, state) try { - const cfg = await api.readPanelConfig() + const cfg = (await api.readPanelConfig()) || {} const jobs = Array.isArray(cfg?.[LOCAL_CRON_KEY]) ? cfg[LOCAL_CRON_KEY] : [] state.jobs = jobs.map(j => ({ id: j.id, @@ -420,7 +420,7 @@ async function openTaskDialog(job, page, state) { // ── 本地调度辅助 ── async function loadLocalJobs() { - const cfg = await api.readPanelConfig() + const cfg = (await api.readPanelConfig()) || {} if (!cfg[LOCAL_CRON_KEY] || !Array.isArray(cfg[LOCAL_CRON_KEY])) { cfg[LOCAL_CRON_KEY] = [] await api.writePanelConfig(cfg) @@ -429,7 +429,7 @@ async function loadLocalJobs() { } async function saveLocalJobs(jobs) { - const cfg = await api.readPanelConfig() + const cfg = (await api.readPanelConfig()) || {} cfg[LOCAL_CRON_KEY] = jobs await api.writePanelConfig(cfg) } From c67a7f8bd3fdaa671d7aca74d0676d92f07eebeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 10:46:44 +0800 Subject: [PATCH 181/426] checkpoint before restoring cron.js --- src/pages/cron.js | 86 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 79 insertions(+), 7 deletions(-) diff --git a/src/pages/cron.js b/src/pages/cron.js index 08e72606..d7871ceb 100644 --- a/src/pages/cron.js +++ b/src/pages/cron.js @@ -28,6 +28,7 @@ const CRON_SHORTCUTS = [ ] const LOCAL_CRON_KEY = 'localCronJobs' +const LOCAL_CRON_MIGRATED_KEY = 'localCronMigrated' const SESSION_IDLE_MS = 5000 const SCHEDULER_INTERVAL_MS = 10000 @@ -78,11 +79,13 @@ export async function render() { if (_unsub) _unsub() _unsub = onGatewayChange(() => { updateGatewayHint(page) + migrateGatewayJobsIfNeeded().catch(() => {}) fetchJobs(page, state) restartScheduler(page, state) }) updateGatewayHint(page) + await migrateGatewayJobsIfNeeded() await fetchJobs(page, state) attachSessionActivityListener() restartScheduler(page, state) @@ -105,6 +108,44 @@ function updateGatewayHint(page) { el.style.display = isGatewayUp() ? 'none' : '' } +async function migrateGatewayJobsIfNeeded() { + const cfg = (await api.readPanelConfig()) || {} + if (cfg[LOCAL_CRON_MIGRATED_KEY]) return + if (!isGatewayUp()) return + try { + const res = await wsClient.request('cron.list', { includeDisabled: true }) + let jobs = res?.jobs || res + if (!Array.isArray(jobs)) jobs = [] + if (!jobs.length) { + cfg[LOCAL_CRON_MIGRATED_KEY] = true + await api.writePanelConfig(cfg) + return + } + const mapped = jobs.map(j => ({ + id: j.id || uuid(), + name: j.name || j.id || '未命名', + enabled: j.enabled !== false, + schedule: j.schedule || { kind: 'cron', expr: '0 9 * * *' }, + payload: { + kind: 'sessionMessage', + label: j.payload?.label || '', + message: j.payload?.message || j.payload?.text || '', + waitForIdle: true, + }, + state: { + lastRunAtMs: j.state?.lastRunAtMs || 0, + lastStatus: j.state?.lastRunStatus || j.state?.lastStatus || null, + lastError: j.state?.lastError || null, + }, + })) + cfg[LOCAL_CRON_KEY] = mapped + cfg[LOCAL_CRON_MIGRATED_KEY] = true + await api.writePanelConfig(cfg) + } catch (e) { + console.warn('[cron] migrateGatewayJobsIfNeeded failed', e) + } +} + // ── 数据加载(Gateway RPC) ── async function fetchJobs(page, state) { @@ -505,15 +546,23 @@ async function tickScheduler(page, state) { const now = new Date() for (const job of jobs) { if (job.enabled === false) continue - const expr = extractCronExpr(job.schedule) - if (!expr) continue - if (!isCronDue(expr, now, job.state?.lastRunAtMs || 0)) continue + const kind = job.schedule?.kind || (extractCronExpr(job.schedule) ? 'cron' : 'cron') + if (kind === 'cron') { + const expr = extractCronExpr(job.schedule) + if (!expr) continue + if (!isCronDue(expr, now, job.state?.lastRunAtMs || 0)) continue + } else if (kind === 'every') { + if (!isEveryDue(job.schedule?.everyMs, now, job.state?.lastRunAtMs || 0)) continue + } else if (kind === 'at') { + if (!isAtDue(job.schedule?.at, now, job.state?.lastRunAtMs || 0, job.state?.completed)) continue + } await runLocalJob(job, false).catch(() => {}) } await fetchJobs(page, state) } async function runLocalJob(job, manual) { + if (job.schedule?.kind === 'at' && job.state?.completed) return await refreshSessionLabelMap().catch(() => {}) const sessionKey = _sessionLabelMap.get(job.payload?.label || '') if (!sessionKey) { @@ -526,7 +575,7 @@ async function runLocalJob(job, manual) { } try { await wsClient.chatSend(sessionKey, job.payload?.message || '') - await updateLocalJob(job.id, { state: { ...job.state, lastStatus: 'ok', lastError: null, lastRunAtMs: Date.now() } }) + await updateLocalJob(job.id, { state: { ...job.state, lastStatus: 'ok', lastError: null, lastRunAtMs: Date.now(), completed: job.schedule?.kind === 'at' } }) } catch (e) { await updateLocalJob(job.id, { state: { ...job.state, lastStatus: 'error', lastError: String(e), lastRunAtMs: Date.now() } }) throw e @@ -548,6 +597,21 @@ function isCronDue(expr, now, lastRunAtMs) { && matchCronField(dow, now.getDay(), 0, 6) } +function isEveryDue(everyMs, now, lastRunAtMs) { + if (!everyMs || Number.isNaN(Number(everyMs))) return false + if (!lastRunAtMs) return true + return (now.getTime() - lastRunAtMs) >= everyMs +} + +function isAtDue(at, now, lastRunAtMs, completed) { + if (!at) return false + if (completed) return false + const target = new Date(at).getTime() + if (Number.isNaN(target)) return false + const last = lastRunAtMs || 0 + return now.getTime() >= target && last < target +} + function matchCronField(field, value, min, max) { if (field === '*') return true const parts = field.split(',') @@ -590,6 +654,16 @@ function extractCronExpr(schedule) { /** 将 cron 表达式转为可读文字 */ function describeCron(raw) { + if (raw?.kind === 'every' && raw.everyMs) { + const ms = raw.everyMs + if (ms < 60000) return `每 ${Math.round(ms / 1000)} 秒` + if (ms < 3600000) return `每 ${Math.round(ms / 60000)} 分钟` + return `每 ${Math.round(ms / 3600000)} 小时` + } + if (raw?.kind === 'at' && raw.at) { + try { return '一次性: ' + new Date(raw.at).toLocaleString() } catch { return raw.at } + } + const expr = typeof raw === 'string' ? raw : extractCronExpr(raw) if (!expr) return '未知周期' @@ -614,9 +688,7 @@ function describeCron(raw) { function describeCronFull(schedule) { if (!schedule) return '未知' if (typeof schedule === 'string') return describeCron(schedule) - if (typeof schedule === 'object') { - if (schedule.kind === 'cron' && schedule.expr) return describeCron(schedule.expr) - } + if (typeof schedule === 'object') return describeCron(schedule) return String(schedule) } From 6a55da567fcfd68385d3f10b71ddba7362cc39b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 10:59:12 +0800 Subject: [PATCH 182/426] docs: add chat auto-scroll design --- .../2026-03-17-chat-autoscroll-design.md | 41 ++ src/pages/cron.js | 455 +++++++----------- 2 files changed, 207 insertions(+), 289 deletions(-) create mode 100644 docs/plans/2026-03-17-chat-autoscroll-design.md diff --git a/docs/plans/2026-03-17-chat-autoscroll-design.md b/docs/plans/2026-03-17-chat-autoscroll-design.md new file mode 100644 index 00000000..88b4cf02 --- /dev/null +++ b/docs/plans/2026-03-17-chat-autoscroll-design.md @@ -0,0 +1,41 @@ +# 聊天页面自动滚动行为设计 + +## 目标 +- 用户上滑查看历史时,不被强制拉回底部 +- 仅在“新消息出现”且用户处于底部时自动滚动 + +## 现状问题 +当前聊天页存在持续自动滚动到底部的行为,导致用户无法查看上方历史消息,尤其在无新消息时仍被拉回底部。 + +## 推荐方案(已确认) +**方案 A:仅在新消息出现且用户在底部时自动滚动** +- 维护“是否在底部”的状态 +- 当用户离开底部时,自动滚动关闭 +- 仅在新消息插入时、且自动滚动开启,才执行 `scrollToBottom()` + +## 关键设计点 +1. **状态管理** + - `autoScrollEnabled`:用户在底部时为 true;上滑离开底部时为 false + - 在滚动事件中更新该状态 + +2. **滚动触发点** + - 在“消息插入”函数内触发(如 `appendUserMessage` / `appendAiMessage` / `appendSystemMessage` / `createStreamBubble`) + - 渲染过程中(如 `doRender` / `doVirtualRender`)不再强制滚动 + +3. **流式输出处理** + - 若用户不在底部,则流式输出不强制跟随 + - 若用户在底部,则跟随滚动 + +4. **回到底部按钮** + - 点击按钮后强制滚动到底部并恢复 `autoScrollEnabled = true` + +## 影响范围 +- `src/pages/chat.js` +- 可能涉及 `chat.css`(若需要视觉提示或按钮行为调整) + +## 测试要点 +- 无新消息时,上滑查看历史不被拉回 +- 新消息出现时: + - 若当前在底部,自动滚动 + - 若已上滑,保持当前位置 +- 点击“回到底部”按钮后恢复自动滚动 diff --git a/src/pages/cron.js b/src/pages/cron.js index d7871ceb..45e3e268 100644 --- a/src/pages/cron.js +++ b/src/pages/cron.js @@ -1,19 +1,16 @@ /** - * 定时任务管理(本地调度 + WSS 发送) - * 任务存储在 panel config,本地调度器在 Gateway 连接后发送 user 消息 + * 定时任务管理 + * 通过 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, uuid } from '../lib/ws-client.js' -import { api } from '../lib/tauri-api.js' +import { wsClient } from '../lib/ws-client.js' +import { api, invalidate } from '../lib/tauri-api.js' let _unsub = null -let _schedulerTimer = null -let _lastSessionActivity = new Map() // sessionKey -> ts -let _sessionLabelMap = new Map() // label -> sessionKey -let _sessionLabelLastFetch = 0 // ── Cron 表达式快捷预设 ── @@ -27,10 +24,7 @@ const CRON_SHORTCUTS = [ { expr: '0 9 1 * *', text: '每月 1 号 9:00' }, ] -const LOCAL_CRON_KEY = 'localCronJobs' -const LOCAL_CRON_MIGRATED_KEY = 'localCronMigrated' -const SESSION_IDLE_MS = 5000 -const SCHEDULER_INTERVAL_MS = 10000 +const SESSION_MESSAGE_TEXT = '继续执行' function parseSessionLabel(key) { const parts = (key || '').split(':') @@ -57,7 +51,7 @@ export async function render() {
${icon('alert-circle', 16)} - 本地定时任务需要 Gateway 连接后才能发送消息。 + 定时任务通过 Gateway 管理。请先启动 Gateway 后使用此功能。 服务管理
@@ -75,27 +69,38 @@ 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(() => { updateGatewayHint(page) - migrateGatewayJobsIfNeeded().catch(() => {}) fetchJobs(page, state) - restartScheduler(page, state) }) updateGatewayHint(page) - await migrateGatewayJobsIfNeeded() await fetchJobs(page, state) - attachSessionActivityListener() - restartScheduler(page, state) return page } export function cleanup() { if (_unsub) { _unsub(); _unsub = null } - stopScheduler() +} + +/** 自动移除无效的 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() { @@ -108,67 +113,41 @@ function updateGatewayHint(page) { el.style.display = isGatewayUp() ? 'none' : '' } -async function migrateGatewayJobsIfNeeded() { - const cfg = (await api.readPanelConfig()) || {} - if (cfg[LOCAL_CRON_MIGRATED_KEY]) return - if (!isGatewayUp()) return - try { - const res = await wsClient.request('cron.list', { includeDisabled: true }) - let jobs = res?.jobs || res - if (!Array.isArray(jobs)) jobs = [] - if (!jobs.length) { - cfg[LOCAL_CRON_MIGRATED_KEY] = true - await api.writePanelConfig(cfg) - return - } - const mapped = jobs.map(j => ({ - id: j.id || uuid(), - name: j.name || j.id || '未命名', - enabled: j.enabled !== false, - schedule: j.schedule || { kind: 'cron', expr: '0 9 * * *' }, - payload: { - kind: 'sessionMessage', - label: j.payload?.label || '', - message: j.payload?.message || j.payload?.text || '', - waitForIdle: true, - }, - state: { - lastRunAtMs: j.state?.lastRunAtMs || 0, - lastStatus: j.state?.lastRunStatus || j.state?.lastStatus || null, - lastError: j.state?.lastError || null, - }, - })) - cfg[LOCAL_CRON_KEY] = mapped - cfg[LOCAL_CRON_MIGRATED_KEY] = true - await api.writePanelConfig(cfg) - } catch (e) { - console.warn('[cron] migrateGatewayJobsIfNeeded failed', e) - } -} - // ── 数据加载(Gateway RPC) ── async function fetchJobs(page, state) { + if (!isGatewayUp()) { + state.jobs = [] + state.loading = false + renderStats(page, state) + renderList(page, state) + return + } + state.loading = true renderList(page, state) try { - const cfg = (await api.readPanelConfig()) || {} - const jobs = Array.isArray(cfg?.[LOCAL_CRON_KEY]) ? cfg[LOCAL_CRON_KEY] : [] + const res = await wsClient.request('cron.list', { includeDisabled: true }) + let jobs = res?.jobs || res + if (!Array.isArray(jobs)) jobs = [] + state.jobs = jobs.map(j => ({ id: j.id, name: j.name || j.id || '未命名', - message: j.payload?.message || '', - payloadKind: 'sessionMessage', + description: j.description || '', + message: j.payload?.message || j.payload?.text || '', + payloadKind: j.payload?.kind || 'agentTurn', sessionLabel: j.payload?.label || '', schedule: j.schedule || {}, enabled: j.enabled !== false, - lastRunStatus: j.state?.lastStatus || null, + agentId: j.agentId || null, + lastRunStatus: j.state?.lastRunStatus || j.state?.lastStatus || null, lastRunAtMs: j.state?.lastRunAtMs || null, lastError: j.state?.lastError || null, })) } catch (e) { - toast('获取本地任务失败: ' + e, 'error') + toast('获取任务列表失败: ' + e, 'error') state.jobs = [] } @@ -249,10 +228,12 @@ function renderList(page, state) { ${lastRunHtml}
- ${icon('clock', 12)} ${scheduleText} · 目标: ${escapeHtml(job.sessionLabel || '未指定')} + ${icon('clock', 12)} ${scheduleText}${job.payloadKind === 'sessionMessage' + ? ` · 目标: ${escapeHtml(job.sessionLabel || '未指定')}` + : (job.agentId ? ` · Agent: ${escapeHtml(job.agentId)}` : '')}
- ${escapeHtml(job.message)} + ${escapeHtml(job.payloadKind === 'sessionMessage' ? SESSION_MESSAGE_TEXT : job.message)}
${job.lastRunStatus === 'error' && job.lastError ? `
@@ -281,9 +262,9 @@ function renderList(page, state) { const btn = e.currentTarget btn.disabled = true try { - await runLocalJob(job, true) + await wsClient.request('cron.run', { jobId: jid }) toast('任务已触发执行', 'success') - await fetchJobs(page, state) + setTimeout(() => fetchJobs(page, state), 2000) } catch (err) { toast('触发失败: ' + err, 'error') } finally { btn.disabled = false } } @@ -293,7 +274,7 @@ function renderList(page, state) { btn.disabled = true btn.innerHTML = icon('refresh-cw', 14) try { - await updateLocalJob(job.id, { enabled: !job.enabled }) + await wsClient.request('cron.update', { jobId: 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) } @@ -307,7 +288,7 @@ function renderList(page, state) { if (!yes) return if (btn) btn.disabled = true try { - await removeLocalJob(job.id) + await wsClient.request('cron.remove', { jobId: jid }) toast('已删除', 'info') await fetchJobs(page, state) } catch (err) { toast('删除失败: ' + err, 'error'); if (btn) btn.disabled = false } @@ -318,6 +299,10 @@ function renderList(page, state) { // ── 创建/编辑任务弹窗 ── async function openTaskDialog(job, page, state) { + if (!isGatewayUp()) { + toast('Gateway 未连接,无法管理定时任务。请先启动 Gateway', 'warning') + return + } const isEdit = !!job const initSchedule = extractCronExpr(job?.schedule) || '0 9 * * *' const formId = 'cron-form-' + Date.now() @@ -327,6 +312,9 @@ async function openTaskDialog(job, page, state) { return `` }).join('') + // 先用默认选项,弹窗后异步加载 Agent 列表 + const agentOptionsHtml = `${job?.agentId ? `` : ''}` + const content = `
@@ -334,13 +322,30 @@ async function openTaskDialog(job, page, state) {
+ + +
+
-
将通过 WSS 发送 user 消息
+
仅发送 user 消息,不附带系统注入
-
- - +
+ + +
+
+ + +
不选则使用默认 Agent 执行
+
+
+ + +
配置了多个消息渠道时必须指定,否则任务会报错
@@ -367,16 +372,42 @@ async function openTaskDialog(job, page, state) { width: 500, }) + // 异步加载渠道列表 + api.readOpenclawConfig().then(cfg => { + const channels = cfg?.channels || {} + const channelIds = Object.keys(channels).filter(k => k !== 'defaults') + if (channelIds.length <= 1) return // 单渠道或无渠道不需要选 + const select = modal.querySelector('select[name="deliveryChannel"]') + if (!select) return + const current = job?.delivery?.channel || '' + select.innerHTML = `` + channelIds.map(ch => + `` + ).join('') + }).catch(() => {}) + + // 异步加载 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 = `` + agents.map(a => + `` + ).join('') + }).catch(() => {}) + // 异步加载会话列表 - refreshSessionLabelMap().then(() => { + wsClient.sessionsList(50).then(res => { + const list = res?.sessions || res || [] const select = modal.querySelector('select[name="sessionLabel"]') if (!select) return const current = job?.sessionLabel || '' - const options = Array.from(_sessionLabelMap.entries()).map(([label, key]) => { - const selected = label === current ? 'selected' : '' - return `` + select.innerHTML = `` + list.map(s => { + const key = s.sessionKey || s.key || '' + const label = parseSessionLabel(key) + return `` }).join('') - select.innerHTML = `` + options }).catch(() => {}) // 快捷预设按钮 @@ -409,17 +440,30 @@ async function openTaskDialog(job, page, state) { }) } + const toggleFields = () => { + const kind = modal.querySelector('select[name="taskKind"]').value + const showSession = kind === 'sessionMessage' + modal.querySelector('[data-field="sessionLabel"]').style.display = showSession ? 'block' : 'none' + modal.querySelector('[data-field="message"]').style.display = showSession ? 'none' : 'block' + modal.querySelector('[data-field="agent"]').style.display = showSession ? 'none' : 'block' + modal.querySelector('[data-field="delivery"]').style.display = showSession ? 'none' : 'block' + } + toggleFields() + modal.querySelector('select[name="taskKind"]').onchange = toggleFields + // 保存 modal.querySelector('#btn-cron-save').onclick = async () => { const name = modal.querySelector('input[name="name"]').value.trim() + const taskKind = modal.querySelector('select[name="taskKind"]').value const message = modal.querySelector('textarea[name="message"]').value.trim() const schedule = modal.querySelector('input[name="schedule"]').value.trim() + const agentId = modal.querySelector('select[name="agentId"]').value || undefined const enabled = modal.querySelector('input[name="enabled"]').checked const sessionLabel = modal.querySelector('select[name="sessionLabel"]').value if (!name) { toast('请输入任务名称', 'warning'); return } - if (!message) { toast('请输入消息内容', 'warning'); return } - if (!sessionLabel) { toast('请选择会话', 'warning'); return } + if (taskKind !== 'sessionMessage' && !message) { toast('请输入执行指令', 'warning'); return } + if (taskKind === 'sessionMessage' && !sessionLabel) { toast('请选择会话', 'warning'); return } if (!schedule) { toast('请设置执行周期', 'warning'); return } const saveBtn = modal.querySelector('#btn-cron-save') @@ -428,22 +472,39 @@ async function openTaskDialog(job, page, state) { try { if (isEdit) { - await updateLocalJob(job.id, { - name, - enabled, - schedule: { kind: 'cron', expr: schedule }, - payload: { kind: 'sessionMessage', label: sessionLabel, message, waitForIdle: true }, - }) + const patch = { name, enabled } + patch.schedule = { kind: 'cron', expr: schedule } + if (taskKind === 'sessionMessage') { + patch.sessionTarget = 'main' + patch.payload = { kind: 'sessionMessage', label: sessionLabel, message: SESSION_MESSAGE_TEXT, role: 'user', waitForIdle: true } + } else { + patch.payload = { kind: 'agentTurn', message } + if (agentId) patch.agentId = agentId + const deliveryChannel = modal.querySelector('select[name="deliveryChannel"]')?.value + if (deliveryChannel) { + patch.delivery = { mode: 'push', to: deliveryChannel, channel: deliveryChannel } + } + } + await wsClient.request('cron.update', { jobId: job.id, patch }) toast('任务已更新', 'success') } else { - await addLocalJob({ - id: uuid(), + const params = { name, enabled, schedule: { kind: 'cron', expr: schedule }, - payload: { kind: 'sessionMessage', label: sessionLabel, message, waitForIdle: true }, - state: { lastRunAtMs: 0, lastStatus: null, lastError: null }, - }) + } + if (taskKind === 'sessionMessage') { + params.sessionTarget = 'main' + params.payload = { kind: 'sessionMessage', label: sessionLabel, message: SESSION_MESSAGE_TEXT, role: 'user', waitForIdle: true } + } else { + params.payload = { kind: 'agentTurn', message } + if (agentId) params.agentId = agentId + const deliveryChannel = modal.querySelector('select[name="deliveryChannel"]')?.value + if (deliveryChannel) { + params.delivery = { mode: 'push', to: deliveryChannel, channel: deliveryChannel } + } + } + await wsClient.request('cron.add', params) toast('任务已创建', 'success') } modal.close?.() || modal.remove?.() @@ -458,192 +519,7 @@ async function openTaskDialog(job, page, state) { // ── 工具函数 ── -// ── 本地调度辅助 ── - -async function loadLocalJobs() { - const cfg = (await api.readPanelConfig()) || {} - if (!cfg[LOCAL_CRON_KEY] || !Array.isArray(cfg[LOCAL_CRON_KEY])) { - cfg[LOCAL_CRON_KEY] = [] - await api.writePanelConfig(cfg) - } - return cfg[LOCAL_CRON_KEY] -} - -async function saveLocalJobs(jobs) { - const cfg = (await api.readPanelConfig()) || {} - cfg[LOCAL_CRON_KEY] = jobs - await api.writePanelConfig(cfg) -} - -async function addLocalJob(job) { - const jobs = await loadLocalJobs() - jobs.push(job) - await saveLocalJobs(jobs) -} - -async function updateLocalJob(id, patch) { - const jobs = await loadLocalJobs() - const idx = jobs.findIndex(j => j.id === id) - if (idx === -1) throw new Error('任务不存在') - jobs[idx] = { ...jobs[idx], ...patch } - await saveLocalJobs(jobs) -} - -async function removeLocalJob(id) { - const jobs = await loadLocalJobs() - const next = jobs.filter(j => j.id !== id) - await saveLocalJobs(next) -} - -function attachSessionActivityListener() { - wsClient.onEvent((msg) => { - if (msg?.type !== 'event') return - const payload = msg.payload || {} - const sessionKey = payload.sessionKey || payload.session_key || null - if (sessionKey) { - _lastSessionActivity.set(sessionKey, Date.now()) - } - }) -} - -async function refreshSessionLabelMap() { - const now = Date.now() - if (now - _sessionLabelLastFetch < 30000 && _sessionLabelMap.size > 0) return - _sessionLabelLastFetch = now - if (!isGatewayUp()) return - const res = await wsClient.sessionsList(200) - const list = res?.sessions || res || [] - _sessionLabelMap.clear() - list.forEach(s => { - const key = s.sessionKey || s.key || '' - const label = parseSessionLabel(key) - if (label) _sessionLabelMap.set(label, key) - }) -} - -function isSessionIdle(sessionKey) { - const last = _lastSessionActivity.get(sessionKey) || 0 - return Date.now() - last >= SESSION_IDLE_MS -} - -function stopScheduler() { - if (_schedulerTimer) { - clearInterval(_schedulerTimer) - _schedulerTimer = null - } -} - -function restartScheduler(page, state) { - stopScheduler() - _schedulerTimer = setInterval(() => tickScheduler(page, state), SCHEDULER_INTERVAL_MS) - tickScheduler(page, state) -} - -async function tickScheduler(page, state) { - if (!isGatewayUp()) return - await refreshSessionLabelMap().catch(() => {}) - const jobs = await loadLocalJobs() - const now = new Date() - for (const job of jobs) { - if (job.enabled === false) continue - const kind = job.schedule?.kind || (extractCronExpr(job.schedule) ? 'cron' : 'cron') - if (kind === 'cron') { - const expr = extractCronExpr(job.schedule) - if (!expr) continue - if (!isCronDue(expr, now, job.state?.lastRunAtMs || 0)) continue - } else if (kind === 'every') { - if (!isEveryDue(job.schedule?.everyMs, now, job.state?.lastRunAtMs || 0)) continue - } else if (kind === 'at') { - if (!isAtDue(job.schedule?.at, now, job.state?.lastRunAtMs || 0, job.state?.completed)) continue - } - await runLocalJob(job, false).catch(() => {}) - } - await fetchJobs(page, state) -} - -async function runLocalJob(job, manual) { - if (job.schedule?.kind === 'at' && job.state?.completed) return - await refreshSessionLabelMap().catch(() => {}) - const sessionKey = _sessionLabelMap.get(job.payload?.label || '') - if (!sessionKey) { - await updateLocalJob(job.id, { state: { ...job.state, lastStatus: 'error', lastError: 'session not found', lastRunAtMs: Date.now() } }) - throw new Error('session not found') - } - if (job.payload?.waitForIdle && !isSessionIdle(sessionKey)) { - if (manual) throw new Error('session busy') - return - } - try { - await wsClient.chatSend(sessionKey, job.payload?.message || '') - await updateLocalJob(job.id, { state: { ...job.state, lastStatus: 'ok', lastError: null, lastRunAtMs: Date.now(), completed: job.schedule?.kind === 'at' } }) - } catch (e) { - await updateLocalJob(job.id, { state: { ...job.state, lastStatus: 'error', lastError: String(e), lastRunAtMs: Date.now() } }) - throw e - } -} - -function isCronDue(expr, now, lastRunAtMs) { - const parts = expr.trim().split(/\s+/) - if (parts.length !== 5) return false - const [min, hr, dom, mon, dow] = parts - const last = lastRunAtMs ? new Date(lastRunAtMs) : null - if (last && last.getFullYear() === now.getFullYear() && last.getMonth() === now.getMonth() && last.getDate() === now.getDate() && last.getHours() === now.getHours() && last.getMinutes() === now.getMinutes()) { - return false - } - return matchCronField(min, now.getMinutes(), 0, 59) - && matchCronField(hr, now.getHours(), 0, 23) - && matchCronField(dom, now.getDate(), 1, 31) - && matchCronField(mon, now.getMonth() + 1, 1, 12) - && matchCronField(dow, now.getDay(), 0, 6) -} - -function isEveryDue(everyMs, now, lastRunAtMs) { - if (!everyMs || Number.isNaN(Number(everyMs))) return false - if (!lastRunAtMs) return true - return (now.getTime() - lastRunAtMs) >= everyMs -} - -function isAtDue(at, now, lastRunAtMs, completed) { - if (!at) return false - if (completed) return false - const target = new Date(at).getTime() - if (Number.isNaN(target)) return false - const last = lastRunAtMs || 0 - return now.getTime() >= target && last < target -} - -function matchCronField(field, value, min, max) { - if (field === '*') return true - const parts = field.split(',') - return parts.some(p => matchCronPart(p, value, min, max)) -} - -function matchCronPart(part, value, min, max) { - if (part === '*') return true - if (part.includes('/')) { - const [base, stepStr] = part.split('/') - const step = Number(stepStr) - if (!step || Number.isNaN(step)) return false - if (base === '*') return (value - min) % step === 0 - if (base.includes('-')) { - const [start, end] = base.split('-').map(Number) - if (Number.isNaN(start) || Number.isNaN(end)) return false - if (value < start || value > end) return false - return (value - start) % step === 0 - } - return false - } - if (part.includes('-')) { - const [start, end] = part.split('-').map(Number) - if (Number.isNaN(start) || Number.isNaN(end)) return false - return value >= start && value <= end - } - const num = Number(part) - if (Number.isNaN(num)) return false - return num === value -} - -/** 从 CronSchedule 对象或字符串中提取纯 cron 表达式 */ +/** 从 Gateway 的 CronSchedule 对象或字符串中提取纯 cron 表达式 */ function extractCronExpr(schedule) { if (!schedule) return null if (typeof schedule === 'string') return schedule @@ -654,16 +530,6 @@ function extractCronExpr(schedule) { /** 将 cron 表达式转为可读文字 */ function describeCron(raw) { - if (raw?.kind === 'every' && raw.everyMs) { - const ms = raw.everyMs - if (ms < 60000) return `每 ${Math.round(ms / 1000)} 秒` - if (ms < 3600000) return `每 ${Math.round(ms / 60000)} 分钟` - return `每 ${Math.round(ms / 3600000)} 小时` - } - if (raw?.kind === 'at' && raw.at) { - try { return '一次性: ' + new Date(raw.at).toLocaleString() } catch { return raw.at } - } - const expr = typeof raw === 'string' ? raw : extractCronExpr(raw) if (!expr) return '未知周期' @@ -684,11 +550,22 @@ function describeCron(raw) { return expr } -/** 将 CronSchedule 对象处理成可读文字 */ +/** 将 Gateway 返回的 CronSchedule 对象也处理成可读文字 */ function describeCronFull(schedule) { if (!schedule) return '未知' if (typeof schedule === 'string') return describeCron(schedule) - if (typeof schedule === 'object') return describeCron(schedule) + if (typeof schedule === 'object') { + if (schedule.kind === 'every' && schedule.everyMs) { + const ms = schedule.everyMs + if (ms < 60000) return `每 ${Math.round(ms / 1000)} 秒` + if (ms < 3600000) return `每 ${Math.round(ms / 60000)} 分钟` + return `每 ${Math.round(ms / 3600000)} 小时` + } + if (schedule.kind === 'at' && schedule.at) { + try { return '一次性: ' + new Date(schedule.at).toLocaleString() } catch { return schedule.at } + } + if (schedule.kind === 'cron' && schedule.expr) return describeCron(schedule.expr) + } return String(schedule) } From 142eeefbb8745a3dc250d8c95d70e99cc44d44c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 11:06:37 +0800 Subject: [PATCH 183/426] docs: add chat auto-scroll plan --- .../plans/2026-03-17-chat-autoscroll.md | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-17-chat-autoscroll.md diff --git a/docs/superpowers/plans/2026-03-17-chat-autoscroll.md b/docs/superpowers/plans/2026-03-17-chat-autoscroll.md new file mode 100644 index 00000000..5a0a5399 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-chat-autoscroll.md @@ -0,0 +1,132 @@ +# Chat Auto-Scroll Gating Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Only auto-scroll the chat view when new messages arrive and the user is at the bottom; never force-scroll while the user is reading history. + +**Architecture:** Add a single auto-scroll gate to `chat.js` that tracks whether the user is at the bottom. Trigger scrolling only on message insertion and stream updates when the gate is enabled. Avoid auto-scroll in render loops to prevent continuous snapping. + +**Tech Stack:** Vanilla JS, DOM APIs, existing chat page logic in `src/pages/chat.js`. + +--- + +## Chunk 1: Auto-scroll gating + +### Task 1: Add auto-scroll state and update on scroll + +**Files:** +- Modify: `src/pages/chat.js` + +- [ ] **Step 1: Add state flags near other module-level state** + +Add: +```js +let _autoScrollEnabled = true +``` + +- [ ] **Step 2: Update auto-scroll flag on scroll** + +In the `_messagesEl.addEventListener('scroll', ...)` handler, update state based on `isAtBottom()`: +```js +_messagesEl.addEventListener('scroll', () => { + const { scrollTop, scrollHeight, clientHeight } = _messagesEl + _scrollBtn.style.display = (scrollHeight - scrollTop - clientHeight < 80) ? 'none' : 'flex' + _autoScrollEnabled = isAtBottom() +}) +``` + +- [ ] **Step 3: Ensure scroll button restores auto-scroll** + +When the user clicks the scroll-to-bottom button, set `_autoScrollEnabled = true` after moving to bottom: +```js +_scrollBtn.addEventListener('click', () => { + _autoScrollEnabled = true + scrollToBottom(true) +}) +``` + +### Task 2: Gate auto-scroll to message insertion + +**Files:** +- Modify: `src/pages/chat.js` + +- [ ] **Step 1: Update `scrollToBottom` to respect gating** + +Change function signature and behavior: +```js +function scrollToBottom(force = false) { + if (!_messagesEl) return + if (!force && !_autoScrollEnabled) return + requestAnimationFrame(() => { _messagesEl.scrollTop = _messagesEl.scrollHeight }) +} +``` + +- [ ] **Step 2: Ensure scroll is invoked only on new message insertion** + +Keep `scrollToBottom()` calls only in: +- `appendUserMessage` +- `appendAiMessage` +- `appendSystemMessage` +- `createStreamBubble` +- `showTyping(true)` (but it will now respect gate) + +Remove or avoid unconditional scrolling in render loops. + +- [ ] **Step 3: Stop continuous auto-scroll in render loops** + +In `doRender` remove the unconditional `scrollToBottom()` or guard it by auto-scroll: +```js +if (_currentAiBubble && _currentAiText) { + _currentAiBubble.innerHTML = renderMarkdown(_currentAiText) + scrollToBottom() +} +``` +(With gated `scrollToBottom`, it will only happen when user is at bottom.) + +- [ ] **Step 4: Guard virtual render bottom snapping** + +In `doVirtualRender`, only snap to bottom when `_autoScrollEnabled` is true: +```js +if (atBottom && _autoScrollEnabled) { + scrollToBottom() +} +``` + +### Task 3: Validate streaming behavior + +**Files:** +- Modify: `src/pages/chat.js` + +- [ ] **Step 1: Ensure stream updates do not force-scroll while reading history** + +Verify `doRender` and `createStreamBubble` only scroll when `_autoScrollEnabled` is true. + +### Task 4: Manual verification and build + +**Files:** +- None + +- [ ] **Step 1: Manual verification checklist** + +Checklist: +- Open chat page with existing history +- Scroll up; verify the view stays in place (no automatic snapping) +- Send a new message while scrolled up; verify it does not force-scroll +- Scroll to bottom and send/receive a message; verify it auto-scrolls +- Click the scroll-to-bottom button; verify it jumps and re-enables auto-scroll + +- [ ] **Step 2: Build** + +Run: +```powershell +npm run build +``` +Expected: build succeeds with no errors. + +- [ ] **Step 3: Commit** + +```powershell +git add src\pages\chat.js + +git commit -m "fix: gate chat auto-scroll on new messages" +``` From 2464ce3ee8b2e1e15a7335e6bd2c60bb76d6dc33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 11:09:34 +0800 Subject: [PATCH 184/426] fix: gate chat auto-scroll on new messages --- src/pages/chat.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/pages/chat.js b/src/pages/chat.js index 2f059e79..f32c6c38 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -75,6 +75,7 @@ let _virtualItems = [] let _virtualTopSpacer = null let _virtualBottomSpacer = null let _virtualRenderPending = false +let _autoScrollEnabled = true let _streamSafetyTimer = null, _unsubEvent = null, _unsubReady = null, _unsubStatus = null let _seenRunIds = new Set() let _pageActive = false @@ -281,8 +282,12 @@ function bindEvents(page) { _messagesEl.addEventListener('scroll', () => { const { scrollTop, scrollHeight, clientHeight } = _messagesEl _scrollBtn.style.display = (scrollHeight - scrollTop - clientHeight < 80) ? 'none' : 'flex' + _autoScrollEnabled = isAtBottom() + }) + _scrollBtn.addEventListener('click', () => { + _autoScrollEnabled = true + scrollToBottom(true) }) - _scrollBtn.addEventListener('click', () => scrollToBottom()) _messagesEl.addEventListener('click', () => hideCmdPanel()) _messagesEl.addEventListener('click', (e) => { const target = e.target?.closest?.('.msg-spoiler') @@ -1970,8 +1975,9 @@ function showCompactionHint(show) { } } -function scrollToBottom() { +function scrollToBottom(force = false) { if (!_messagesEl) return + if (!force && !_autoScrollEnabled) return requestAnimationFrame(() => { _messagesEl.scrollTop = _messagesEl.scrollHeight }) } @@ -2049,7 +2055,7 @@ function doVirtualRender() { }) if (count) _virtualAvgHeight = Math.max(24, Math.round(total / count)) - if (atBottom) { + if (atBottom && _autoScrollEnabled) { scrollToBottom() } else { const newTop = _virtualTopSpacer.offsetHeight From 80cd68962eb38bb8ac759cdee5947418a7a7ab4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 11:22:46 +0800 Subject: [PATCH 185/426] chore: checkpoint before chat scroll fix From 88bea828b4f8f30f801fc94ec8dfabbb4b3b47ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 11:24:54 +0800 Subject: [PATCH 186/426] fix: stop forced auto-scroll while reading history --- src/pages/chat.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/pages/chat.js b/src/pages/chat.js index f32c6c38..76667a2e 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -76,6 +76,8 @@ let _virtualTopSpacer = null let _virtualBottomSpacer = null let _virtualRenderPending = false let _autoScrollEnabled = true +let _lastScrollTop = 0 +let _touchStartY = 0 let _streamSafetyTimer = null, _unsubEvent = null, _unsubReady = null, _unsubStatus = null let _seenRunIds = new Set() let _pageActive = false @@ -282,8 +284,20 @@ function bindEvents(page) { _messagesEl.addEventListener('scroll', () => { const { scrollTop, scrollHeight, clientHeight } = _messagesEl _scrollBtn.style.display = (scrollHeight - scrollTop - clientHeight < 80) ? 'none' : 'flex' - _autoScrollEnabled = isAtBottom() + if (scrollTop < _lastScrollTop - 2) _autoScrollEnabled = false + if (isAtBottom()) _autoScrollEnabled = true + _lastScrollTop = scrollTop }) + _messagesEl.addEventListener('wheel', (e) => { + if (e.deltaY < 0) _autoScrollEnabled = false + }, { passive: true }) + _messagesEl.addEventListener('touchstart', (e) => { + _touchStartY = e.touches?.[0]?.clientY || 0 + }, { passive: true }) + _messagesEl.addEventListener('touchmove', (e) => { + const y = e.touches?.[0]?.clientY || 0 + if (y > _touchStartY + 2) _autoScrollEnabled = false + }, { passive: true }) _scrollBtn.addEventListener('click', () => { _autoScrollEnabled = true scrollToBottom(true) @@ -1952,6 +1966,8 @@ function clearMessages() { _virtualHeights = new Map() _virtualAvgHeight = 64 _virtualRange = { start: 0, end: 0, prefix: [0] } + _autoScrollEnabled = true + _lastScrollTop = 0 if (_virtualTopSpacer) _virtualTopSpacer.style.height = '0px' if (_virtualBottomSpacer) _virtualBottomSpacer.style.height = '0px' } From 41e4cad2b9e632f22322848e04e03d1e951eb958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 11:32:07 +0800 Subject: [PATCH 187/426] chore: checkpoint before adjust auto-scroll gating From 190eba5240813ba34b48f6e68ef2306b93000950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 11:32:41 +0800 Subject: [PATCH 188/426] fix: stop background auto-scroll without new messages --- src/pages/chat.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/pages/chat.js b/src/pages/chat.js index 76667a2e..f4ac038c 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -2071,9 +2071,7 @@ function doVirtualRender() { }) if (count) _virtualAvgHeight = Math.max(24, Math.round(total / count)) - if (atBottom && _autoScrollEnabled) { - scrollToBottom() - } else { + if (!atBottom || !_autoScrollEnabled) { const newTop = _virtualTopSpacer.offsetHeight const delta = newTop - top if (delta !== 0) _messagesEl.scrollTop = scrollTop + delta From 2fbb06d47399b8567ad1fcb9899fbb5782a9fc52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 12:27:05 +0800 Subject: [PATCH 189/426] fix: cron sessionMessage local triggers --- .../plans/2026-03-17-cron-trigger-mode.md | 86 +++++ src/pages/chat.js | 11 +- src/pages/cron.js | 355 ++++++++++++++---- 3 files changed, 383 insertions(+), 69 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-17-cron-trigger-mode.md diff --git a/docs/superpowers/plans/2026-03-17-cron-trigger-mode.md b/docs/superpowers/plans/2026-03-17-cron-trigger-mode.md new file mode 100644 index 00000000..3009d683 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-cron-trigger-mode.md @@ -0,0 +1,86 @@ +# Cron 触发模式扩展 Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让 sessionMessage 任务支持两种触发模式:按 cron 执行或监听指定会话 agent 任务结束后发送。 + +**Architecture:** 在 cron.js 中为 sessionMessage 任务增加触发模式字段(cron | onIdle),本地存储与渲染读取该字段;onIdle 模式基于 wsClient 事件跟踪目标会话的 run 状态,任务结束且空闲即发送;cron 模式保留现有定时器。非 sessionMessage 任务仍走 Gateway。 + +**Tech Stack:** ClawPanel (Vite + JS), WebSocket client, Tauri panel config API + +--- + +## Chunk 1: 数据模型与 UI + +### Task 1: 增加触发模式字段 + +**Files:** +- Modify: `src/pages/cron.js` + +- [ ] **Step 1: 新增字段** + +为 sessionMessage 本地任务新增 `triggerMode` 字段:`cron` | `onIdle`。 + +- [ ] **Step 2: UI 选择** + +在 sessionMessage 任务编辑弹窗加入触发模式选择: +- 选项:`按 Cron` / `监听任务结束` +- 选择 onIdle 时隐藏 cron 输入,显示说明:监听目标会话任务结束后发送。 + +- [ ] **Step 3: 列表展示** + +列表中显示触发模式: +- cron 显示 cron 文本 +- onIdle 显示 “任务结束后发送” + +- [ ] **Step 4: Commit** + +```bash +git add src/pages/cron.js +git commit -m "feat: add sessionMessage trigger mode" +``` + +## Chunk 2: 触发逻辑 + +### Task 2: cron 与 onIdle 双模式发送 + +**Files:** +- Modify: `src/pages/cron.js` + +- [ ] **Step 1: cron 触发** + +仅当 `triggerMode === 'cron'` 时参与 `tickSessionMessageJobs` 逻辑。 + +- [ ] **Step 2: onIdle 触发** + +新增 `checkIdleTrigger(job)`: +- 若 `triggerMode === 'onIdle'`,当目标会话从 active -> idle 且当前未发送过本轮,发送消息并记录 `lastRunAtMs`。 + +- [ ] **Step 3: 去重** + +使用 `state.lastRunAtMs` 或 `state.lastIdleAtMs` 避免重复发送。 + +- [ ] **Step 4: Commit** + +```bash +git add src/pages/cron.js +git commit -m "feat: onIdle trigger for sessionMessage" +``` + +## Chunk 3: 验证 + +### Task 3: Build 与手动验证 + +- [ ] **Step 1: Build** + +```bash +npm run build +``` + +- [ ] **Step 2: 验证** + +- 新建 sessionMessage 任务,选择 cron → 定时发送生效 +- 新建 sessionMessage 任务,选择 onIdle → 会话任务结束后发送 +- 非 sessionMessage 任务仍通过 Gateway 保存/触发 + +--- diff --git a/src/pages/chat.js b/src/pages/chat.js index f4ac038c..43f2637d 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -2005,15 +2005,19 @@ function isAtBottom() { function ensureVirtualSpacers() { if (!_messagesEl) return - if (!_virtualTopSpacer) { + if (!_virtualTopSpacer || _virtualTopSpacer.parentNode !== _messagesEl) { _virtualTopSpacer = document.createElement('div') _virtualTopSpacer.className = 'msg-virtual-spacer' _messagesEl.insertBefore(_virtualTopSpacer, _messagesEl.firstChild) } - if (!_virtualBottomSpacer) { + if (!_virtualBottomSpacer || _virtualBottomSpacer.parentNode !== _messagesEl) { _virtualBottomSpacer = document.createElement('div') _virtualBottomSpacer.className = 'msg-virtual-spacer' - _messagesEl.insertBefore(_virtualBottomSpacer, _typingEl) + if (_typingEl && _typingEl.parentNode === _messagesEl) { + _messagesEl.insertBefore(_virtualBottomSpacer, _typingEl) + } else { + _messagesEl.appendChild(_virtualBottomSpacer) + } } } @@ -2051,6 +2055,7 @@ function doVirtualRender() { for (let i = start; i < end; i++) { const item = items[i] if (!item?.node) continue + if (refNode && refNode.parentNode !== _messagesEl) refNode = _virtualBottomSpacer if (item.node.parentNode !== _messagesEl) { _messagesEl.insertBefore(item.node, refNode || _virtualBottomSpacer) } diff --git a/src/pages/cron.js b/src/pages/cron.js index 45e3e268..49264802 100644 --- a/src/pages/cron.js +++ b/src/pages/cron.js @@ -7,10 +7,17 @@ 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 { wsClient, uuid } from '../lib/ws-client.js' import { api, invalidate } from '../lib/tauri-api.js' let _unsub = null +let _unsubReady = null +let _unsubEvent = null +let _tickTimer = null +let _sessionLastActivity = new Map() +let _sessionActiveRuns = new Map() +let _sessionLabelMap = new Map() +let _sessionLabelLastFetch = 0 // ── Cron 表达式快捷预设 ── @@ -25,6 +32,7 @@ const CRON_SHORTCUTS = [ ] const SESSION_MESSAGE_TEXT = '继续执行' +const LOCAL_SESSION_MESSAGE_KEY = 'localSessionMessageJobs' function parseSessionLabel(key) { const parts = (key || '').split(':') @@ -78,6 +86,11 @@ export async function render() { updateGatewayHint(page) fetchJobs(page, state) }) + if (_unsubReady) _unsubReady() + _unsubReady = wsClient.onReady(() => { + updateGatewayHint(page) + fetchJobs(page, state) + }) updateGatewayHint(page) await fetchJobs(page, state) @@ -87,6 +100,9 @@ export async function render() { export function cleanup() { if (_unsub) { _unsub(); _unsub = null } + if (_unsubReady) { _unsubReady(); _unsubReady = null } + if (_unsubEvent) { _unsubEvent(); _unsubEvent = null } + stopSessionMessageTicker() } /** 自动移除无效的 cron.jobs 字段(之前版本错误写入,会导致 Gateway 崩溃) */ @@ -116,36 +132,48 @@ function updateGatewayHint(page) { // ── 数据加载(Gateway RPC) ── async function fetchJobs(page, state) { - if (!isGatewayUp()) { - state.jobs = [] - state.loading = false - renderStats(page, state) - renderList(page, state) - return - } - state.loading = true renderList(page, state) try { - const res = await wsClient.request('cron.list', { includeDisabled: true }) - let jobs = res?.jobs || res - if (!Array.isArray(jobs)) jobs = [] - - state.jobs = jobs.map(j => ({ + const localSessionJobs = await loadLocalSessionMessageJobs() + let gatewayJobs = [] + if (isGatewayUp()) { + const res = await wsClient.request('cron.list', { includeDisabled: true }) + let jobs = res?.jobs || res + if (!Array.isArray(jobs)) jobs = [] + gatewayJobs = jobs.map(j => ({ + id: j.id, + name: j.name || j.id || '未命名', + description: j.description || '', + message: j.payload?.message || j.payload?.text || '', + payloadKind: j.payload?.kind || 'agentTurn', + sessionLabel: j.payload?.label || '', + 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, + })) + } + const localMapped = localSessionJobs.map(j => ({ id: j.id, name: j.name || j.id || '未命名', description: j.description || '', - message: j.payload?.message || j.payload?.text || '', - payloadKind: j.payload?.kind || 'agentTurn', + message: j.payload?.message || '', + payloadKind: 'sessionMessage', sessionLabel: j.payload?.label || '', schedule: j.schedule || {}, + triggerMode: j.triggerMode || 'cron', enabled: j.enabled !== false, - agentId: j.agentId || null, + agentId: null, lastRunStatus: j.state?.lastRunStatus || j.state?.lastStatus || null, lastRunAtMs: j.state?.lastRunAtMs || null, lastError: j.state?.lastError || null, })) + const gatewayFiltered = gatewayJobs.filter(j => j.payloadKind !== 'sessionMessage') + state.jobs = [...gatewayFiltered, ...localMapped] } catch (e) { toast('获取任务列表失败: ' + e, 'error') state.jobs = [] @@ -209,7 +237,12 @@ function renderList(page, state) { return } - el.innerHTML = state.jobs.map(job => { + const sortedJobs = [...state.jobs].sort((a, b) => { + if (a.enabled === b.enabled) return 0 + return a.enabled ? -1 : 1 + }) + + el.innerHTML = sortedJobs.map(job => { const scheduleText = describeCronFull(job.schedule) const lastRunOk = job.lastRunStatus === 'ok' || job.lastRunStatus === 'skipped' const lastRunHtml = job.lastRunAtMs ? ` @@ -224,16 +257,16 @@ function renderList(page, state) {
${escapeHtml(job.name)} - ${job.enabled ? '运行中' : '已暂停'} + ${job.enabled ? '开启中' : '已暂停'} ${lastRunHtml}
${icon('clock', 12)} ${scheduleText}${job.payloadKind === 'sessionMessage' - ? ` · 目标: ${escapeHtml(job.sessionLabel || '未指定')}` + ? ` · 目标: ${escapeHtml(job.sessionLabel || '未指定')} · ${job.triggerMode === 'onIdle' ? '任务结束后发送' : '按 Cron'}` : (job.agentId ? ` · Agent: ${escapeHtml(job.agentId)}` : '')}
- ${escapeHtml(job.payloadKind === 'sessionMessage' ? SESSION_MESSAGE_TEXT : job.message)} + ${escapeHtml(job.message)}
${job.lastRunStatus === 'error' && job.lastError ? `
@@ -241,9 +274,12 @@ function renderList(page, state) {
` : ''}
-
+
- +
@@ -262,22 +298,35 @@ function renderList(page, state) { const btn = e.currentTarget btn.disabled = true try { - await wsClient.request('cron.run', { jobId: jid }) - toast('任务已触发执行', 'success') - setTimeout(() => fetchJobs(page, state), 2000) + if (job.payloadKind === 'sessionMessage') { + await runSessionMessageJob(job, true) + toast('任务已触发执行', 'success') + await fetchJobs(page, state) + } else { + await wsClient.request('cron.run', { jobId: jid }) + toast('任务已触发执行', 'success') + setTimeout(() => fetchJobs(page, state), 2000) + } } catch (err) { toast('触发失败: ' + err, 'error') } finally { btn.disabled = false } } card.querySelector('[data-action="toggle"]').onclick = async (e) => { - const btn = e.currentTarget - btn.disabled = true - btn.innerHTML = icon('refresh-cw', 14) + const input = e.currentTarget + input.disabled = true + const nextEnabled = input.checked try { - await wsClient.request('cron.update', { jobId: 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) } + if (job.payloadKind === 'sessionMessage') { + await updateLocalSessionMessageJob(job.id, { enabled: nextEnabled }) + toast(nextEnabled ? '已启用' : '已暂停', 'info') + await fetchJobs(page, state) + } else { + await wsClient.request('cron.update', { jobId: jid, patch: { enabled: nextEnabled } }) + toast(nextEnabled ? '已启用' : '已暂停', 'info') + await fetchJobs(page, state) + } + } catch (err) { toast('操作失败: ' + err, 'error') } + finally { input.disabled = false } } card.querySelector('[data-action="edit"]').onclick = () => openTaskDialog(job, page, state) @@ -288,9 +337,15 @@ function renderList(page, state) { if (!yes) return if (btn) btn.disabled = true try { - await wsClient.request('cron.remove', { jobId: jid }) - toast('已删除', 'info') - await fetchJobs(page, state) + if (job.payloadKind === 'sessionMessage') { + await removeLocalSessionMessageJob(job.id) + toast('已删除', 'info') + await fetchJobs(page, state) + } else { + await wsClient.request('cron.remove', { jobId: jid }) + toast('已删除', 'info') + await fetchJobs(page, state) + } } catch (err) { toast('删除失败: ' + err, 'error'); if (btn) btn.disabled = false } } }) @@ -300,8 +355,7 @@ function renderList(page, state) { async function openTaskDialog(job, page, state) { if (!isGatewayUp()) { - toast('Gateway 未连接,无法管理定时任务。请先启动 Gateway', 'warning') - return + toast('Gateway 未连接,非 sessionMessage 任务将无法保存', 'warning') } const isEdit = !!job const initSchedule = extractCronExpr(job?.schedule) || '0 9 * * *' @@ -333,9 +387,17 @@ async function openTaskDialog(job, page, state) {
仅发送 user 消息,不附带系统注入
-
- - +
+ + +
onIdle: 目标会话任务结束后发送
+
+
+ +
@@ -347,7 +409,7 @@ async function openTaskDialog(job, page, state) {
配置了多个消息渠道时必须指定,否则任务会报错
-
+
${shortcutsHtml}
@@ -444,12 +506,14 @@ async function openTaskDialog(job, page, state) { const kind = modal.querySelector('select[name="taskKind"]').value const showSession = kind === 'sessionMessage' modal.querySelector('[data-field="sessionLabel"]').style.display = showSession ? 'block' : 'none' - modal.querySelector('[data-field="message"]').style.display = showSession ? 'none' : 'block' + modal.querySelector('[data-field="triggerMode"]').style.display = showSession ? 'block' : 'none' + modal.querySelector('[data-field="schedule"]').style.display = (!showSession || modal.querySelector('select[name="triggerMode"]').value === 'cron') ? 'block' : 'none' modal.querySelector('[data-field="agent"]').style.display = showSession ? 'none' : 'block' modal.querySelector('[data-field="delivery"]').style.display = showSession ? 'none' : 'block' } toggleFields() modal.querySelector('select[name="taskKind"]').onchange = toggleFields + modal.querySelector('select[name="triggerMode"]').onchange = toggleFields // 保存 modal.querySelector('#btn-cron-save').onclick = async () => { @@ -460,52 +524,69 @@ async function openTaskDialog(job, page, state) { const agentId = modal.querySelector('select[name="agentId"]').value || undefined const enabled = modal.querySelector('input[name="enabled"]').checked const sessionLabel = modal.querySelector('select[name="sessionLabel"]').value + const triggerMode = modal.querySelector('select[name="triggerMode"]').value if (!name) { toast('请输入任务名称', 'warning'); return } - if (taskKind !== 'sessionMessage' && !message) { toast('请输入执行指令', 'warning'); return } + if (!message) { toast('请输入执行内容', 'warning'); return } if (taskKind === 'sessionMessage' && !sessionLabel) { toast('请选择会话', 'warning'); return } - if (!schedule) { toast('请设置执行周期', 'warning'); return } + if (taskKind === 'sessionMessage' && triggerMode === 'cron' && !schedule) { toast('请设置执行周期', 'warning'); return } + if (taskKind !== 'sessionMessage' && !schedule) { toast('请设置执行周期', 'warning'); return } + if (taskKind !== 'sessionMessage' && !isGatewayUp()) { toast('Gateway 未连接,无法保存非 sessionMessage 任务', 'warning'); return } const saveBtn = modal.querySelector('#btn-cron-save') saveBtn.disabled = true saveBtn.textContent = '保存中...' try { - if (isEdit) { - const patch = { name, enabled } - patch.schedule = { kind: 'cron', expr: schedule } - if (taskKind === 'sessionMessage') { - patch.sessionTarget = 'main' - patch.payload = { kind: 'sessionMessage', label: sessionLabel, message: SESSION_MESSAGE_TEXT, role: 'user', waitForIdle: true } + if (taskKind === 'sessionMessage') { + if (isEdit) { + await updateLocalSessionMessageJob(job.id, { + name, + enabled, + triggerMode, + schedule: { kind: 'cron', expr: schedule }, + payload: { kind: 'sessionMessage', label: sessionLabel, message, role: 'user', waitForIdle: true }, + }) + toast('任务已更新', 'success') } else { + await addLocalSessionMessageJob({ + id: job?.id || uuid(), + name, + enabled, + triggerMode, + schedule: { kind: 'cron', expr: schedule }, + payload: { kind: 'sessionMessage', label: sessionLabel, message, role: 'user', waitForIdle: true }, + state: { lastRunStatus: null, lastRunAtMs: 0, lastError: null, lastIdleAtMs: 0 }, + }) + toast('任务已创建', 'success') + } + } else { + if (isEdit) { + const patch = { name, enabled } + patch.schedule = { kind: 'cron', expr: schedule } patch.payload = { kind: 'agentTurn', message } if (agentId) patch.agentId = agentId const deliveryChannel = modal.querySelector('select[name="deliveryChannel"]')?.value if (deliveryChannel) { patch.delivery = { mode: 'push', to: deliveryChannel, channel: deliveryChannel } } - } - await wsClient.request('cron.update', { jobId: job.id, patch }) - toast('任务已更新', 'success') - } else { - const params = { - name, - enabled, - schedule: { kind: 'cron', expr: schedule }, - } - if (taskKind === 'sessionMessage') { - params.sessionTarget = 'main' - params.payload = { kind: 'sessionMessage', label: sessionLabel, message: SESSION_MESSAGE_TEXT, role: 'user', waitForIdle: true } + await wsClient.request('cron.update', { jobId: job.id, patch }) + toast('任务已更新', 'success') } else { - params.payload = { kind: 'agentTurn', message } + const params = { + name, + enabled, + schedule: { kind: 'cron', expr: schedule }, + payload: { kind: 'agentTurn', message }, + } if (agentId) params.agentId = agentId const deliveryChannel = modal.querySelector('select[name="deliveryChannel"]')?.value if (deliveryChannel) { params.delivery = { mode: 'push', to: deliveryChannel, channel: deliveryChannel } } + await wsClient.request('cron.add', params) + toast('任务已创建', 'success') } - await wsClient.request('cron.add', params) - toast('任务已创建', 'success') } modal.close?.() || modal.remove?.() await fetchJobs(page, state) @@ -519,6 +600,148 @@ async function openTaskDialog(job, page, state) { // ── 工具函数 ── +// ── sessionMessage 本地存储与调度 ── + +async function loadLocalSessionMessageJobs() { + const cfg = (await api.readPanelConfig()) || {} + const jobs = Array.isArray(cfg[LOCAL_SESSION_MESSAGE_KEY]) ? cfg[LOCAL_SESSION_MESSAGE_KEY] : [] + return jobs +} + +async function saveLocalSessionMessageJobs(jobs) { + const cfg = (await api.readPanelConfig()) || {} + cfg[LOCAL_SESSION_MESSAGE_KEY] = jobs + await api.writePanelConfig(cfg) +} + +async function addLocalSessionMessageJob(job) { + const jobs = await loadLocalSessionMessageJobs() + jobs.push(job) + await saveLocalSessionMessageJobs(jobs) +} + +async function updateLocalSessionMessageJob(id, patch) { + const jobs = await loadLocalSessionMessageJobs() + const idx = jobs.findIndex(j => j.id === id) + if (idx === -1) throw new Error('任务不存在') + jobs[idx] = { ...jobs[idx], ...patch } + await saveLocalSessionMessageJobs(jobs) +} + +async function removeLocalSessionMessageJob(id) { + const jobs = await loadLocalSessionMessageJobs() + const next = jobs.filter(j => j.id !== id) + await saveLocalSessionMessageJobs(next) +} + +function attachSessionMessageListeners() { + if (_unsubEvent) return + _unsubEvent = wsClient.onEvent((msg) => { + if (msg?.type !== 'event') return + const payload = msg.payload || {} + const event = msg.event + const sessionKey = payload.sessionKey || payload.session_key || null + if (!sessionKey) return + if (event === 'chat') { + if (payload.state === 'delta') { + _sessionLastActivity.set(sessionKey, Date.now()) + _sessionActiveRuns.set(sessionKey, true) + } + if (payload.state === 'final') { + _sessionLastActivity.set(sessionKey, Date.now()) + _sessionActiveRuns.set(sessionKey, false) + } + } + }) +} + +async function refreshSessionLabelMap() { + const now = Date.now() + if (now - _sessionLabelLastFetch < 30000 && _sessionLabelMap.size > 0) return + _sessionLabelLastFetch = now + _sessionLabelMap.clear() + if (!isGatewayUp()) { + if (wsClient.sessionKey) _sessionLabelMap.set('主会话', wsClient.sessionKey) + return + } + const res = await wsClient.sessionsList(200) + const list = res?.sessions || res || [] + list.forEach(s => { + const key = s.sessionKey || s.key || '' + const label = parseSessionLabel(key) + if (label) _sessionLabelMap.set(label, key) + }) + if (wsClient.sessionKey && !_sessionLabelMap.has('主会话')) { + _sessionLabelMap.set('主会话', wsClient.sessionKey) + } +} + +function isSessionIdle(sessionKey) { + const last = _sessionLastActivity.get(sessionKey) || 0 + const active = _sessionActiveRuns.get(sessionKey) + return !active && (Date.now() - last >= SESSION_IDLE_MS) +} + +function stopSessionMessageTicker() { + if (_tickTimer) { + clearInterval(_tickTimer) + _tickTimer = null + } +} + +function restartSessionMessageTicker(page, state) { + stopSessionMessageTicker() + _tickTimer = setInterval(() => tickSessionMessageJobs(page, state), SESSION_MESSAGE_TICK_MS) + tickSessionMessageJobs(page, state) +} + +async function tickSessionMessageJobs(page, state) { + await refreshSessionLabelMap().catch(() => {}) + const jobs = await loadLocalSessionMessageJobs() + const now = new Date() + for (const job of jobs) { + if (job.enabled === false) continue + const triggerMode = job.triggerMode || 'cron' + if (triggerMode === 'cron') { + const expr = extractCronExpr(job.schedule) + if (!expr) continue + if (!isCronDue(expr, now, job.state?.lastRunAtMs || 0)) continue + await runSessionMessageJob(job, false).catch(() => {}) + } else if (triggerMode === 'onIdle') { + await runSessionMessageJob(job, false).catch(() => {}) + } + } + await fetchJobs(page, state) +} + +async function runSessionMessageJob(job, manual) { + await refreshSessionLabelMap().catch(() => {}) + const label = job.payload?.label || '主会话' + const sessionKey = _sessionLabelMap.get(label) || wsClient.sessionKey + if (!sessionKey) { + await updateLocalSessionMessageJob(job.id, { state: { ...job.state, lastRunStatus: 'error', lastError: 'session not found', lastRunAtMs: Date.now() } }) + throw new Error('session not found') + } + if (job.payload?.waitForIdle && !isSessionIdle(sessionKey)) { + if (manual) throw new Error('session busy') + return + } + const triggerMode = job.triggerMode || 'cron' + if (triggerMode === 'onIdle') { + const lastIdleAtMs = job.state?.lastIdleAtMs || 0 + if (lastIdleAtMs && Date.now() - lastIdleAtMs < SESSION_IDLE_MS) return + } + try { + await wsClient.chatSend(sessionKey, job.payload?.message || '') + const nextState = { ...job.state, lastRunStatus: 'ok', lastError: null, lastRunAtMs: Date.now() } + if (triggerMode === 'onIdle') nextState.lastIdleAtMs = Date.now() + await updateLocalSessionMessageJob(job.id, { state: nextState }) + } catch (e) { + await updateLocalSessionMessageJob(job.id, { state: { ...job.state, lastRunStatus: 'error', lastError: String(e), lastRunAtMs: Date.now() } }) + throw e + } +} + /** 从 Gateway 的 CronSchedule 对象或字符串中提取纯 cron 表达式 */ function extractCronExpr(schedule) { if (!schedule) return null From a2eedcd2948cb9e1bcd9023b43dba7057b42f84c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 12:53:09 +0800 Subject: [PATCH 190/426] docs: add hosted agent design --- docs/plans/2026-03-17-hosted-agent-design.md | 131 +++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 docs/plans/2026-03-17-hosted-agent-design.md diff --git a/docs/plans/2026-03-17-hosted-agent-design.md b/docs/plans/2026-03-17-hosted-agent-design.md new file mode 100644 index 00000000..262a63c1 --- /dev/null +++ b/docs/plans/2026-03-17-hosted-agent-design.md @@ -0,0 +1,131 @@ +# 托管 Agent(聊天页)详细设计 + +> 结论:采用方案一(复用晴辰助手能力)。 + +## 目标 +- 聊天页发送按钮右侧新增“托管 Agent”入口 +- 托管 Agent 通过 WSS 自动与当前会话 Agent 交互 +- 上下文仅包含:初始提示词 + 与对面 Agent 的交流 +- 继承当前会话工具权限 +- 全局默认 + 会话级启用,切换会话/页面仍生效 +- 输出直接插入当前聊天流并标记来源 + +## 入口与 UI 交互 +### 入口按钮 +- 位置:`src/pages/chat.js` 内聊天输入区域,发送按钮右侧 +- 交互:点击打开托管 Agent 配置面板 +- 状态:idle / running / waiting_reply / paused / error + +### 配置面板 +- 初始提示词(必填) +- 启用开关 +- 运行模式:对面 Agent 回复后自动继续 +- 停止策略:托管 Agent 自评停止 +- 高级选项:最大步数 / 步间隔 / 重试次数 +- 操作:保存并启用 / 暂停 / 立即停止 + +### 输出展示 +- 直接插入当前聊天流 +- 格式示例: + - `[托管 Agent] 下一步指令: ...` +- 样式区分:弱化颜色 + 标签 + +## 运行循环与状态机 +### 状态 +- idle / running / waiting_reply / paused / error + +### 触发 +- 监听 `wsClient.onEvent` +- event=chat,state=final,sessionKey=当前会话 + +### 执行流程 +1. 对面 Agent final 回复到达 +2. 托管 Agent 生成下一步指令 +3. 使用 `wsClient.chatSend` 发送 +4. 进入 waiting_reply +5. 满足 stopPolicy 或 maxSteps 停止 + +## 上下文构建 +- 仅包含:初始提示词 + 与对面 Agent 对话 +- 截断策略:按 MAX_CONTEXT_TOKENS 或最近 N 条 +- 不引入其他会话内容 + +## 数据结构与持久化 +### 全局默认(clawpanel.json) +```json +{ + "hostedAgent": { + "default": { + "enabled": false, + "prompt": "", + "autoRunAfterTarget": true, + "stopPolicy": "self", + "maxSteps": 50, + "stepDelayMs": 1200, + "retryLimit": 2, + "toolPolicy": "inherit" + } + } +} +``` + +### 会话级(localStorage) +Key: `clawpanel-hosted-agent-sessions` +```json +{ + "agent:main:main": { + "enabled": true, + "prompt": "任务目标", + "autoRunAfterTarget": true, + "stopPolicy": "self", + "maxSteps": 50, + "stepDelayMs": 1200, + "retryLimit": 2, + "toolPolicy": "inherit", + "state": { + "status": "running", + "stepCount": 12, + "lastRunAt": 1710000000000, + "lastError": "" + }, + "history": [ + { "role": "system", "content": "初始提示词" }, + { "role": "assistant", "content": "托管 Agent 生成的指令" }, + { "role": "target", "content": "对面 Agent 回复" } + ] + } +} +``` + +## assistant-core 抽取清单 +新增:`src/lib/assistant-core.js` + +### 抽取项(从 assistant.js) +- API 适配:OpenAI/Anthropic/Gemini +- SSE 流解析与重试 +- 系统提示词构建 +- 工具声明、权限过滤、执行与安全检查 +- 上下文裁剪与会话数据工具 + +### 适配器注入 +- `api.*` 工具桥接 +- `confirm / ask_user` UI 适配器 +- `storage` 适配器 +- 图片存储适配器 + +### 保留在 assistant.js +- DOM 渲染与 UI 交互 +- toast/modal +- 视图与事件绑定 + +## 风险与保护 +- Gateway 断开:自动暂停 +- 连续失败:触发 error 状态 +- 最大步数:强制停止 +- 避免重复触发:运行中忽略新触发 + +## 测试要点 +- 启用后自动发送 +- 对面回复后自动继续 +- 切换会话/页面后仍生效 +- 停止策略与最大步数生效 From 49fab69b54b3116027f1268e1b1b541cc757ee9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 13:03:12 +0800 Subject: [PATCH 191/426] chore: checkpoint before pr94 summary docs From 1b21aad0ccde5c667a13123d2dadbfbd0e28576c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 13:05:04 +0800 Subject: [PATCH 192/426] docs: add PR #94 summary and review notes --- CHANGELOG.md | 10 ++++++++++ README.md | 10 ++++++++++ docs/pr/PR-94-description.md | 19 ++++++++++++++++++ docs/pr/PR-94-review-notes.md | 22 +++++++++++++++++++++ docs/pr/PR-94-summary.md | 36 +++++++++++++++++++++++++++++++++++ 5 files changed, 97 insertions(+) create mode 100644 docs/pr/PR-94-description.md create mode 100644 docs/pr/PR-94-review-notes.md create mode 100644 docs/pr/PR-94-summary.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a6c17b10..746c13df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 +## PR #94 摘要 + +- 里程碑:v0.1.0→v0.9.4 版本演进与功能聚合完成。 +- 核心功能:聊天体验重构、虚拟滚动与工具事件展示完善。 +- 安装/诊断:独立安装包、安装方式选择器、Gateway 补丁与自动检测链路完善。 +- 渠道/协作:多 Agent 渠道绑定与飞书/QQ 等渠道扩展。 +- 性能/分发:ARM 优化、R2 分发与通用包策略升级。 +- 质量/安全:CI/Clippy 清理、安全修复与稳定性增强。 +- 文档/官网:部署指南、官网展示与 README 双语完善。 + ## [0.9.4] - 2026-03-17 ### 新功能 (Features) diff --git a/README.md b/README.md index 897eb1f9..732fd811 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,16 @@ ClawPanel 是 [OpenClaw](https://github.com/1186258278/OpenClawChineseTranslatio > **官网**: [claw.qt.cool](https://claw.qt.cool/) | **下载**: [GitHub Releases](https://github.com/qingchencloud/clawpanel/releases/latest) +### 近期亮点 + +- **v0.9.x**:聊天体验重构,消息渲染与交互稳定性显著提升。 +- **聊天虚拟滚动**:大聊天记录场景性能优化,滚动更顺、渲染负载降低。 +- **Markdown-it**:引入稳定的 Markdown 渲染与扩展能力,展示一致性提升。 +- **v0.8.0**:多 Agent 渠道能力完善,任务分发与协作更清晰。 +- **Gateway 补丁**:连接与转发链路修复,长连接更可靠。 +- **R2 通用包**:分发与复用成本降低,部署更灵活。 +- **v0.6.0**:基础能力完善与稳定性打底,为后续迭代奠基。 + ### ⚡ OpenClaw 独立安装包(零依赖,无需 Node.js/npm) 不想折腾 Node.js 环境?直接下载 [OpenClaw 独立安装包](https://github.com/qingchencloud/openclaw-standalone/releases/latest),**内置运行时,解压即用**: diff --git a/docs/pr/PR-94-description.md b/docs/pr/PR-94-description.md new file mode 100644 index 00000000..ef75c6ca --- /dev/null +++ b/docs/pr/PR-94-description.md @@ -0,0 +1,19 @@ +# PR #94 Description + +## Summary +- + +## Impact +- + +## Risks +- + +## Testing +- + +## Migration +- + +## Notes +- diff --git a/docs/pr/PR-94-review-notes.md b/docs/pr/PR-94-review-notes.md new file mode 100644 index 00000000..d2627022 --- /dev/null +++ b/docs/pr/PR-94-review-notes.md @@ -0,0 +1,22 @@ +# PR #94 Review Notes + +## Reviewer Checklist +- [ ] 变更范围与 PR 描述一致(不含未声明的功能变更) +- [ ] 关键链路:chat / tools / ws / gateway / cron 无回归 +- [ ] 安装与升级流程(独立包/镜像源/诊断)无破坏性调整 +- [ ] 文档与 README 更新与实际变更一致 +- [ ] 风险项与回滚策略明确 + +## 风险 / 影响 +- **WS 链路**:连接防护、ping 机制与事件合并逻辑若异常会影响聊天稳定性 +- **Gateway 修复链路**:补丁/自动检测若逻辑偏差可能导致误修复 +- **渲染与滚动**:虚拟滚动与自动滚动策略改动影响大聊天记录体验 + +## 变更范围映射 +- **chat**:虚拟滚动、自动滚动、渲染稳定性、工具事件展示 +- **tools**:toolCall/toolResult 解析、合并与显示 +- **ws**:连接防护、单例与 ping 机制 +- **gateway**:补丁与自动检测链路 +- **cron**:sessionMessage 触发与参数修正 +- **docs**:方案/设计文档与变更说明 +- **website**:官网展示与下载引导 diff --git a/docs/pr/PR-94-summary.md b/docs/pr/PR-94-summary.md new file mode 100644 index 00000000..970c8db4 --- /dev/null +++ b/docs/pr/PR-94-summary.md @@ -0,0 +1,36 @@ +# PR #94 变更总览 + +## 1) 版本里程碑(v0.1.0 → v0.9.4) +- **v0.1.0–v0.2.x**:项目骨架、主题系统、聊天与 Agent 管理、安装源与升级流程奠基。 +- **v0.3.0–v0.5.x**:官网与文档体系成型,Gateway 自愈与环境检测增强,内置 AI 助手与图片识别落地。 +- **v0.6.0**:知识库、公益接口、全局 AI 诊断、官网改版等功能爆发。 +- **v0.7.x–v0.8.x**:Docker 集群与消息渠道扩展、版本管理与 Web 模式覆盖、跨平台兼容推进。 +- **v0.9.0–v0.9.2**:Usage analytics、通信配置、品牌升级、多 Agent 渠道、SkillHub 双源等核心能力集成。 +- **v0.9.3**:性能优化与分发链路升级(R2 CDN / 通用包策略)。 +- **v0.9.4**:独立安装包与安装方式选择器落地,WS 与工具事件链路稳定化。 + +## 2) 核心功能演进 +- **面板/设置**:配置编辑、环境诊断、版本管理、面板设置与代理配置逐步完善。 +- **聊天**:渲染链路稳定化、工具事件可视化、聊天虚拟滚动与滚动行为重构。 +- **Agent**:管理与展示优化,多 Agent 渠道绑定与能力扩展。 +- **渠道**:飞书/Lark、QQ 内置 Bot、多渠道绑定与引导完善。 +- **安装/部署**:独立安装包、镜像源切换、Docker/CLI 诊断与引导增强。 +- **诊断/修复**:Gateway 一键补丁、自动检测与修复路径完善。 +- **性能**:ARM 设备优化、缓存与轮询降频、R2 分发策略与通用包。 + +## 3) 质量与安全 +- **CI/Clippy**:跨平台编译与 Clippy 告警清理,持续提升门禁稳定性。 +- **安全修复**:命令注入、路径遍历、allowedOrigins 收敛、审计日志补强。 +- **稳定性**:配对/握手/WS 连接链路修复,安装与配置容错增强。 + +## 4) 文档与官网 +- 官网展示体系升级(视觉效果、演示素材、SEO)。 +- 部署与运维文档完善(Linux/Docker/Web/ARM 指引)。 +- README 中英双语与非商用协议说明完善。 + +## 5) PR #94 建议修改点(审阅友好化) +1. **合并 checkpoint 提交**:按主题聚合(docs/配置/功能),减少审阅噪音。 +2. **补充 PR 描述**:明确用户可见影响、潜在风险与回滚策略。 +3. **Changelog 与 README 对齐**:增加高层摘要与近期亮点,便于快速理解范围。 +4. **文档主题聚合**:设计/方案类文档按模块归档,降低跳转成本。 +5. **变更范围标注**:对 chat/tools/ws/gateway/cron 等影响模块做清单化标记。 From 98b1064f292f6590e7ff200f8de3e80f1a6c3cbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 13:06:18 +0800 Subject: [PATCH 193/426] docs: remove plan documents --- docs/assistant-features-plan.md | 777 ------------------ docs/docker-multi-instance-plan.md | 494 ----------- docs/i18n-plan.md | 288 ------- ...cloudflared-openclaw-integration-design.md | 59 -- ...3-16-cron-sessionmessage-tool-ui-design.md | 55 -- ...-03-16-gateway-patch-auto-detect-design.md | 25 - ...026-03-16-gateway-patch-oneclick-design.md | 37 - .../2026-03-17-ai-config-import-design.md | 37 - ...-03-17-assistant-optimize-toggle-design.md | 27 - ...026-03-17-assistant-ux-and-shell-design.md | 43 - .../2026-03-17-chat-autoscroll-design.md | 41 - .../2026-03-17-chat-daylight-shadow-design.md | 37 - ...26-03-17-chat-history-tool-merge-design.md | 30 - .../2026-03-17-chat-markdown-it-design.md | 25 - .../2026-03-17-chat-tool-event-live-design.md | 34 - .../2026-03-17-chat-virtual-scroll-design.md | 25 - ...at-virtual-scroll-implementation-design.md | 34 - docs/plans/2026-03-17-force-setup-design.md | 31 - docs/plans/2026-03-17-hosted-agent-design.md | 131 --- .../2026-03-17-skillhub-env-fix-design.md | 65 -- ...-17-toast-night-and-model-select-design.md | 36 - docs/plans/2026-03-17-toast-shadow-design.md | 18 - docs/plans/2026-03-17-toast-vercel-design.md | 27 - .../plans/2026-03-17-tool-call-meta-design.md | 32 - .../2026-03-17-ws-connect-bootstrap-design.md | 31 - .../2026-03-17-ws-ping-multi-req-design.md | 27 - .../2026-03-17-ws-ping-node-list-design.md | 18 - .../2026-03-16-gateway-patch-auto-detect.md | 74 -- .../2026-03-16-gateway-patch-oneclick.md | 169 ---- .../plans/2026-03-17-ai-config-import.md | 82 -- .../2026-03-17-assistant-optimize-toggle.md | 52 -- .../2026-03-17-assistant-ux-and-shell.md | 118 --- .../plans/2026-03-17-chat-autoscroll.md | 132 --- .../plans/2026-03-17-chat-daylight-shadow.md | 46 -- .../2026-03-17-chat-history-tool-merge.md | 81 -- .../plans/2026-03-17-chat-markdown-it.md | 146 ---- .../plans/2026-03-17-chat-tool-event-live.md | 97 --- ...3-17-chat-virtual-scroll-implementation.md | 211 ----- .../plans/2026-03-17-cron-trigger-mode.md | 86 -- .../2026-03-17-cron-ws-sessionmessage.md | 199 ----- .../plans/2026-03-17-force-setup.md | 84 -- .../2026-03-17-skill-trigger-optimization.md | 165 ---- .../plans/2026-03-17-skillhub-env-fix.md | 102 --- ...2026-03-17-toast-night-and-model-select.md | 99 --- .../plans/2026-03-17-toast-shadow.md | 54 -- .../plans/2026-03-17-toast-vercel.md | 54 -- .../plans/2026-03-17-tool-call-meta.md | 108 --- .../plans/2026-03-17-ws-connect-bootstrap.md | 86 -- .../plans/2026-03-17-ws-ping-multi-req.md | 59 -- .../plans/2026-03-17-ws-ping-node-list.md | 56 -- 50 files changed, 4844 deletions(-) delete mode 100644 docs/assistant-features-plan.md delete mode 100644 docs/docker-multi-instance-plan.md delete mode 100644 docs/i18n-plan.md delete mode 100644 docs/plans/2026-03-16-cloudflared-openclaw-integration-design.md delete mode 100644 docs/plans/2026-03-16-cron-sessionmessage-tool-ui-design.md delete mode 100644 docs/plans/2026-03-16-gateway-patch-auto-detect-design.md delete mode 100644 docs/plans/2026-03-16-gateway-patch-oneclick-design.md delete mode 100644 docs/plans/2026-03-17-ai-config-import-design.md delete mode 100644 docs/plans/2026-03-17-assistant-optimize-toggle-design.md delete mode 100644 docs/plans/2026-03-17-assistant-ux-and-shell-design.md delete mode 100644 docs/plans/2026-03-17-chat-autoscroll-design.md delete mode 100644 docs/plans/2026-03-17-chat-daylight-shadow-design.md delete mode 100644 docs/plans/2026-03-17-chat-history-tool-merge-design.md delete mode 100644 docs/plans/2026-03-17-chat-markdown-it-design.md delete mode 100644 docs/plans/2026-03-17-chat-tool-event-live-design.md delete mode 100644 docs/plans/2026-03-17-chat-virtual-scroll-design.md delete mode 100644 docs/plans/2026-03-17-chat-virtual-scroll-implementation-design.md delete mode 100644 docs/plans/2026-03-17-force-setup-design.md delete mode 100644 docs/plans/2026-03-17-hosted-agent-design.md delete mode 100644 docs/plans/2026-03-17-skillhub-env-fix-design.md delete mode 100644 docs/plans/2026-03-17-toast-night-and-model-select-design.md delete mode 100644 docs/plans/2026-03-17-toast-shadow-design.md delete mode 100644 docs/plans/2026-03-17-toast-vercel-design.md delete mode 100644 docs/plans/2026-03-17-tool-call-meta-design.md delete mode 100644 docs/plans/2026-03-17-ws-connect-bootstrap-design.md delete mode 100644 docs/plans/2026-03-17-ws-ping-multi-req-design.md delete mode 100644 docs/plans/2026-03-17-ws-ping-node-list-design.md delete mode 100644 docs/superpowers/plans/2026-03-16-gateway-patch-auto-detect.md delete mode 100644 docs/superpowers/plans/2026-03-16-gateway-patch-oneclick.md delete mode 100644 docs/superpowers/plans/2026-03-17-ai-config-import.md delete mode 100644 docs/superpowers/plans/2026-03-17-assistant-optimize-toggle.md delete mode 100644 docs/superpowers/plans/2026-03-17-assistant-ux-and-shell.md delete mode 100644 docs/superpowers/plans/2026-03-17-chat-autoscroll.md delete mode 100644 docs/superpowers/plans/2026-03-17-chat-daylight-shadow.md delete mode 100644 docs/superpowers/plans/2026-03-17-chat-history-tool-merge.md delete mode 100644 docs/superpowers/plans/2026-03-17-chat-markdown-it.md delete mode 100644 docs/superpowers/plans/2026-03-17-chat-tool-event-live.md delete mode 100644 docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md delete mode 100644 docs/superpowers/plans/2026-03-17-cron-trigger-mode.md delete mode 100644 docs/superpowers/plans/2026-03-17-cron-ws-sessionmessage.md delete mode 100644 docs/superpowers/plans/2026-03-17-force-setup.md delete mode 100644 docs/superpowers/plans/2026-03-17-skill-trigger-optimization.md delete mode 100644 docs/superpowers/plans/2026-03-17-skillhub-env-fix.md delete mode 100644 docs/superpowers/plans/2026-03-17-toast-night-and-model-select.md delete mode 100644 docs/superpowers/plans/2026-03-17-toast-shadow.md delete mode 100644 docs/superpowers/plans/2026-03-17-toast-vercel.md delete mode 100644 docs/superpowers/plans/2026-03-17-tool-call-meta.md delete mode 100644 docs/superpowers/plans/2026-03-17-ws-connect-bootstrap.md delete mode 100644 docs/superpowers/plans/2026-03-17-ws-ping-multi-req.md delete mode 100644 docs/superpowers/plans/2026-03-17-ws-ping-node-list.md diff --git a/docs/assistant-features-plan.md b/docs/assistant-features-plan.md deleted file mode 100644 index 9b2dd08a..00000000 --- a/docs/assistant-features-plan.md +++ /dev/null @@ -1,777 +0,0 @@ -# AI 助手功能扩展规划 - -> 基于现有工具架构(TOOL_DEFS + executeTool + getEnabledTools),扩展 6 大能力模块。 -> 每个模块独立开关,遵循现有的 `_config.tools.xxx` + 设置面板 toggle 模式。 - ---- - -## 当前架构概览 - -``` -TOOL_DEFS = { - system: [get_system_info] // 始终可用 - process: [list_processes, check_port] // 始终可用 - interaction: [ask_user] // 始终可用 - terminal: [run_command] // 开关控制 - fileOps: [read_file, write_file, list_directory] // 开关控制 -} - -_config.tools = { terminal: true/false, fileOps: true/false } -``` - -扩展后: - -``` -TOOL_DEFS = { - ...existing, - docker: [docker_list, docker_exec, docker_logs, wsl_exec] // 新增 - webSearch: [web_search, fetch_url] // 新增 - ssh: [ssh_exec, ssh_read_file, ssh_write_file] // 新增 - knowledge: [search_knowledge] // 新增 -} - -_config.tools = { - ...existing, - docker: false, // 默认关闭 - webSearch: false, // 默认关闭 - ssh: false, // 默认关闭 - knowledge: false, // 默认关闭 -} -``` - ---- - -## 模块一:Docker / WSL 管理工具 - -### 场景 -- 用户的 OpenClaw 可能安装在 Docker 容器或 WSL 中 -- 本地检测不到时,帮用户在容器/WSL 内操作 -- 查看容器日志、进入容器执行命令、管理容器生命周期 - -### 工具定义 - -| 工具名 | 描述 | 参数 | 危险等级 | -|--------|------|------|----------| -| `docker_list` | 列出 Docker 容器 | `filter?`, `all?` | 安全 | -| `docker_exec` | 在容器内执行命令 | `container`, `command` | ⚠️ 危险 | -| `docker_logs` | 查看容器日志 | `container`, `lines?` | 安全 | -| `docker_compose` | 执行 docker-compose 命令 | `action`, `file?`, `service?` | ⚠️ 危险 | -| `wsl_exec` | 在 WSL 内执行命令 | `distro?`, `command` | ⚠️ 危险 | -| `wsl_list` | 列出 WSL 发行版 | — | 安全 | - -### 后端实现 - -``` -Tauri (Rust): - - docker_list → Command::new("docker").args(["ps", ...]) - - docker_exec → Command::new("docker").args(["exec", container, ...]) - - wsl_exec → Command::new("wsl").args(["-d", distro, "-e", ...]) (Windows only) - -dev-api.js (Web): - - execSync('docker ps --format json') - - execSync(`docker exec ${container} ${command}`) - - execSync(`wsl -d ${distro} -e ${command}`) (Windows only) -``` - -### 安全围栏 -- `docker_exec` / `wsl_exec` 归入 DANGEROUS_TOOLS -- `docker rm`, `docker rmi`, `docker system prune` 归入 CRITICAL_PATTERNS - -### UI 扩展 -- 设置面板工具权限 tab 新增 toggle: - ``` - Docker / WSL 工具 — 允许管理容器和 WSL 环境 - ``` - -### 内置技能卡片 -```js -{ - id: 'detect-docker-openclaw', - icon: 'docker', - name: '检测 Docker/WSL 中的 OpenClaw', - desc: '扫描 Docker 容器和 WSL,查找 OpenClaw 安装', - tools: ['docker'], - prompt: `请帮我检查 Docker 和 WSL 中是否安装了 OpenClaw。 - 1. 调用 get_system_info 判断操作系统 - 2. 用 docker_list 列出所有容器,过滤包含 openclaw/gateway 的 - 3. 如果是 Windows,用 wsl_list 列出 WSL 发行版 - 4. 对每个 WSL 发行版,用 wsl_exec 执行 "which openclaw" 检测 - 5. 汇总发现的 OpenClaw 实例及其状态` -} -``` - -### 优先级:高(解决用户最常见困惑) -### 工时估算:1-2 天 - ---- - -## 模块二:联网搜索工具 - -### 场景 -- 用户遇到不常见的错误,AI 知识库可能没有 -- 搜索 GitHub Issues、文档、Stack Overflow 找到解决方案 -- 查找最新版本信息、API 文档等 - -### 工具定义 - -| 工具名 | 描述 | 参数 | 危险等级 | -|--------|------|------|----------| -| `web_search` | 联网搜索关键词 | `query`, `max_results?` | 安全 | -| `fetch_url` | 抓取网页内容 | `url` | 安全 | - -### 后端实现方案(3 选 1) - -#### 方案 A:DuckDuckGo Instant Answer API(推荐,免费无 Key) -```js -// 搜索 -const resp = await fetch(`https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json`) -// 但 Instant Answer 只返回摘要,不返回搜索结果列表 - -// 实际搜索需要用 DuckDuckGo HTML 页面解析或第三方库 -``` - -#### 方案 B:SearXNG 代理(自托管,最灵活) -```js -// 部署一个 SearXNG 实例,或者用公共实例 -const resp = await fetch(`https://searx.example.com/search?q=${query}&format=json`) -``` - -#### 方案 C:Jina Reader API(推荐搭配使用,免费) -```js -// 将任意 URL 转为纯文本/Markdown -const resp = await fetch(`https://r.jina.ai/${targetUrl}`) -const text = await resp.text() -``` - -### 推荐组合 -- **搜索**:使用 DuckDuckGo 的 `html.duckduckgo.com/html/?q=xxx` 页面解析结果 -- **内容抓取**:使用 Jina Reader `r.jina.ai/URL` 获取纯文本 -- 两者都 **免费无 Key**,无需用户配置 - -### 系统提示词补充 -``` -## web_search 使用指南 -当你无法确定答案或需要最新信息时,可以使用 web_search 搜索互联网。 -搜索后,如果需要更多内容,可以用 fetch_url 抓取具体页面。 -搜索技巧: -- 加 site:github.com 搜索 GitHub -- 加 site:stackoverflow.com 搜索 StackOverflow -- 搜索错误信息时,用引号包裹关键错误文本 -``` - -### 安全围栏 -- 搜索和抓取不涉及破坏性操作,不归入 DANGEROUS_TOOLS -- 但需要网络请求,添加超时保护(10 秒) -- URL 抓取限制最大内容长度(100KB → 截断) - -### UI 扩展 -``` -联网搜索 — 允许搜索互联网和抓取网页内容(需联网) -``` - -### 优先级:高(大幅提升问题解决能力) -### 工时估算:0.5-1 天 - ---- - -## 模块三:SSH 远程管理工具 - -### 场景 -- 用户的 OpenClaw 部署在远程服务器上 -- 帮用户远程安装、配置、排查 OpenClaw -- 远程查看日志、重启服务、修改配置 - -### 工具定义 - -| 工具名 | 描述 | 参数 | 危险等级 | -|--------|------|------|----------| -| `ssh_exec` | 在远程服务器执行命令 | `connection_id`, `command` | ⚠️ 危险 | -| `ssh_read_file` | 读取远程文件 | `connection_id`, `path` | 安全 | -| `ssh_write_file` | 写入远程文件 | `connection_id`, `path`, `content` | ⚠️ 危险 | - -### 配置数据结构 - -```js -_config.sshConnections = [ - { - id: 'my-server', - name: '生产服务器', - host: '192.168.1.100', - port: 22, - user: 'root', - authType: 'key', // 'key' | 'password' - keyPath: '~/.ssh/id_rsa', - // password 不存储在 localStorage,每次询问或用 keytar 安全存储 - } -] -``` - -### 后端实现 - -``` -Tauri (Rust): - - 使用 ssh2 crate 或调用系统 ssh CLI - - ssh_exec → Command::new("ssh").args(["-p", port, "user@host", command]) - - ssh_read_file → ssh + cat - - ssh_write_file → 通过 stdin pipe 写入 - -dev-api.js (Web): - - 使用 node-ssh 或 ssh2 npm 包 - - 或者直接调用 ssh CLI -``` - -### 设置 UI:新增 tab「远程连接」 - -``` -┌─────────────────────────────────────────────┐ -│ 模型配置 │ 工具权限 │ 远程连接 │ 助手人设 │ -├─────────────────────────────────────────────┤ -│ │ -│ ┌─ 生产服务器 ──────────────── [编辑] [删] │ -│ │ root@192.168.1.100:22 (密钥认证) │ -│ └──────────────────────────────────────────│ -│ │ -│ ┌─ 测试服务器 ──────────────── [编辑] [删] │ -│ │ admin@10.0.0.5:22 (密码认证) │ -│ └──────────────────────────────────────────│ -│ │ -│ [+ 添加连接] │ -│ │ -│ 提示:推荐使用 SSH 密钥认证。 │ -│ 生成密钥:ssh-keygen -t ed25519 │ -│ 复制公钥:ssh-copy-id user@host │ -│ │ -└─────────────────────────────────────────────┘ -``` - -### 安全围栏 -- `ssh_exec`, `ssh_write_file` 归入 DANGEROUS_TOOLS -- 密码认证:每次执行时用 ask_user 确认,或使用系统密钥链 -- SSH 密钥路径验证:检查文件是否存在 -- 关键命令(rm -rf, reboot 等)在远程同样走 CRITICAL_PATTERNS - -### 系统提示词补充 -``` -## SSH 远程管理 -用户可能配置了远程服务器连接。当操作远程服务器时: -- 先用 ask_user 确认要操作哪个连接 -- 远程命令比本地更谨慎,优先使用只读操作 -- 修改配置前先备份(cp xxx xxx.bak) -``` - -### 内置技能卡片 -```js -{ - id: 'remote-manage', - icon: 'network', - name: '远程管理 OpenClaw', - desc: '通过 SSH 连接远程服务器,管理 OpenClaw', - tools: ['ssh', 'fileOps'], - prompt: `请帮我管理远程服务器上的 OpenClaw。 - 1. 获取系统信息,列出已配置的 SSH 连接 - 2. 用 ask_user 让我选择要操作的服务器 - 3. 用 ssh_exec 检查远程 OpenClaw 状态 - 4. 检查 Gateway 进程和端口 - 5. 读取远程配置和日志 - 6. 汇总远程 OpenClaw 状态报告` -} -``` - -### 优先级:中(用户量较少但价值极高) -### 工时估算:2-3 天 - ---- - -## 模块四:知识库 + 灵魂移植(借尸还魂) - -### 核心理念 - -OpenClaw 的 Agent 有一套完整的**身份系统**,由工作区引导文件定义: - -``` -~/.openclaw/workspace/ ← Agent 的"灵魂"所在 - ├── AGENTS.md ← 操作指令、规则、记忆管理方式 - ├── SOUL.md ← 人设、边界、语气("Who You Are") - ├── IDENTITY.md ← 名称、物种、风格、表情符号、头像 - ├── USER.md ← 用户档案(名字、称呼、时区、偏好) - ├── TOOLS.md ← 工具本地笔记(SSH 配置、设备名等) - ├── HEARTBEAT.md ← 心跳任务清单 - ├── MEMORY.md ← 精选长期记忆(仅主会话加载) - └── memory/ ← 每日记忆日志 - ├── 2026-03-04-1609.md - └── ... - -~/.openclaw/agents//agent/ ← Agent 的运行时状态 - ├── models.json ← 模型提供商配置(baseUrl + apiKey + models) - ├── auth-profiles.json ← 认证配置文件 - └── auth.json -``` - -**"借尸还魂"不是复用知识库,而是完整接管 Agent 的灵魂**—— -ClawPanel 的 AI 助手直接读取这些文件,像 OpenClaw 一样把它们注入 system prompt, -从而变成那个 Agent:有他的名字、他的性格、他的记忆、他认识的用户。 - -### 4A:灵魂移植(Agent Identity Takeover) - -#### 工作流程 - -1. **扫描** `~/.openclaw/workspace/` 和 `~/.openclaw/agents/` 目录 -2. **发现** 所有可用的 Agent 身份(main、test 等) -3. **用户选择** 要附身的 Agent -4. **读取** 该 Agent 的全部引导文件: - - `SOUL.md` → 注入为人设(替换 ClawPanel 助手的默认人设) - - `IDENTITY.md` → 提取名称/表情/风格(替换助手名称和性格描述) - - `USER.md` → 注入用户上下文(知道用户叫什么、偏好什么) - - `AGENTS.md` → 注入操作规则(Agent 的行为准则) - - `TOOLS.md` → 注入工具笔记 - - `MEMORY.md` → 注入长期记忆 - - `memory/` → 注入最近的每日记忆(最近 3 天) -5. **注入** 到 `buildSystemPrompt()` 中,完全替代默认人设 - -#### 实现 - -```js -// 新增配置项 -_config.soulSource = null // null = 使用 ClawPanel 默认 | 'openclaw:main' | 'openclaw:test' | 'custom' -_config.soulCache = null // 缓存读取的灵魂文件内容 - -// buildSystemPrompt 改造 -function buildSystemPrompt() { - if (_config.soulSource?.startsWith('openclaw:')) { - // 借尸还魂模式:使用 OpenClaw Agent 的灵魂 - return buildOpenClawSoulPrompt() - } - // 默认模式:使用 ClawPanel 自带的系统提示词 - return buildDefaultPrompt() -} - -function buildOpenClawSoulPrompt() { - const soul = _config.soulCache - if (!soul) return buildDefaultPrompt() // fallback - - let prompt = '' - - // 1. 身份注入 - if (soul.identity) { - prompt += `# Identity\n${soul.identity}\n\n` - } - - // 2. 灵魂注入(人设、边界、语气) - if (soul.soul) { - prompt += `# Soul\n${soul.soul}\n\n` - } - - // 3. 用户上下文 - if (soul.user) { - prompt += `# User\n${soul.user}\n\n` - } - - // 4. 操作规则 - if (soul.agents) { - prompt += `# Operating Instructions\n${soul.agents}\n\n` - } - - // 5. 工具笔记 - if (soul.tools) { - prompt += `# Tool Notes\n${soul.tools}\n\n` - } - - // 6. 长期记忆 - if (soul.memory) { - prompt += `# Long-term Memory\n${soul.memory}\n\n` - } - - // 7. 最近的每日记忆 - if (soul.recentMemories?.length) { - prompt += `# Recent Memory\n` - for (const m of soul.recentMemories) { - prompt += `## ${m.date}\n${m.content}\n\n` - } - } - - // 8. 追加 ClawPanel 特有的工具说明(保持工具能力) - prompt += buildToolInstructions() - - return prompt -} -``` - -#### 灵魂加载函数 - -```js -async function loadOpenClawSoul(agentId = 'main') { - const home = await getHomeDir() - const ws = `${home}/.openclaw/workspace` // 工作区是全局的,不按 agentId 分 - - const readSafe = async (path) => { - try { return await api.assistantReadFile(path) } - catch { return null } - } - - const soul = { - identity: await readSafe(`${ws}/IDENTITY.md`), - soul: await readSafe(`${ws}/SOUL.md`), - user: await readSafe(`${ws}/USER.md`), - agents: await readSafe(`${ws}/AGENTS.md`), - tools: await readSafe(`${ws}/TOOLS.md`), - memory: await readSafe(`${ws}/MEMORY.md`), - recentMemories: [], - } - - // 读取最近 3 天的每日记忆 - try { - const memDir = await api.assistantListDir(`${ws}/memory`) - const files = memDir.split('\n').filter(f => f.match(/\d{4}-\d{2}-\d{2}/)) - const recent = files.sort().slice(-3) - for (const f of recent) { - const content = await readSafe(`${ws}/memory/${f.trim()}`) - if (content) soul.recentMemories.push({ date: f.trim(), content }) - } - } catch {} - - return soul -} -``` - -#### UI:设置面板「助手人设」Tab 改造 - -``` -┌─────────────────────────────────────────────┐ -│ 模型配置 │ 工具权限 │ 知识库 │ 远程连接 │ 人设 │ -├─────────────────────────────────────────────┤ -│ │ -│ 身份来源 │ -│ ┌──────────────────────────────────────────│ -│ │ ● ClawPanel 默认人设 │ ← 当前默认 -│ │ ○ OpenClaw Agent 身份(借尸还魂) │ ← 新增 -│ │ ○ 自定义人设 │ -│ └──────────────────────────────────────────│ -│ │ -│ ─── 当选择「OpenClaw Agent」时显示 ──── │ -│ │ -│ 选择 Agent: [main ▼] │ -│ │ -│ 灵魂文件预览 │ -│ ┌──────────────────────────────────────────│ -│ │ SOUL.md ✓ 已加载 (1.6KB) │ -│ │ IDENTITY.md ✓ 已加载 (636B) │ -│ │ USER.md ✓ 已加载 (237B) │ -│ │ AGENTS.md ✓ 已加载 (7.8KB) │ -│ │ TOOLS.md ✓ 已加载 (860B) │ -│ │ MEMORY.md ✕ 未找到 │ -│ │ memory/ 2 个日志文件 │ -│ └──────────────────────────────────────────│ -│ │ -│ [附身] [刷新] │ -│ │ -│ 注意:附身后,助手将使用该 Agent 的人格、│ -│ 记忆和用户偏好。可随时切回默认。 │ -│ │ -│ ─── 当选择「ClawPanel 默认」时显示 ──── │ -│ │ -│ 助手名称: [晴辰助手 ] │ -│ 助手性格: [________________________] │ -│ │ -└─────────────────────────────────────────────┘ -``` - -#### 附身后的效果 - -| 维度 | 默认模式 | 附身模式 | -|------|----------|----------| -| 名称 | "晴辰助手" | IDENTITY.md 中的名称 | -| 性格 | 简洁专业 | SOUL.md 定义的风格 | -| 称呼用户 | "你" | USER.md 中的称呼(如"爸爸") | -| 行为规则 | ClawPanel 内置 | AGENTS.md 的规则体系 | -| 记忆 | 无 | MEMORY.md + 每日记忆 | -| 工具知识 | ClawPanel 内置 | TOOLS.md 的本地笔记 | -| 工具能力 | 保持不变 | 保持 ClawPanel 的工具 | - -**关键设计**:附身只替换"灵魂"(system prompt),**工具能力保持 ClawPanel 的**。 -因为 OpenClaw 的工具(exec/read/edit/write)和 ClawPanel 的工具本质相同, -但 ClawPanel 有独有的 docker/ssh/搜索等扩展工具,这些要保留。 - -### 4B:自定义知识库 - -在灵魂移植之外,仍然支持用户上传额外的知识文档: - -#### 数据存储 -``` -~/.openclaw/clawpanel-kb/ - ├── index.json # 知识库索引 - ├── docs/ - │ ├── api-guide.md # 用户上传的文档 - │ ├── faq.md - │ └── deploy-notes.txt - └── chunks/ # 分块索引(可选,用于大文档) - └── ... -``` - -#### 实现方案 - -**V1(简单方案)**: -- 小文档(<8KB)直接全文注入 system prompt 尾部 -- 大文档做关键词搜索(正则匹配 + 上下文窗口) -- 总注入 token 上限:4000 tokens -- 知识库和灵魂移植可叠加使用 - -**V2(进阶方案)**: -- embedding 语义搜索 -- `search_knowledge` 工具让 AI 按需检索 - -### 优先级:� 高(灵魂移植是杀手级差异化功能) -### 工时估算:灵魂移植 1-2 天,自定义知识库 V1 额外 1 天 - ---- - -## 模块五:模型配置自动导入 - -### 场景 -- 用户已安装 OpenClaw 并配置了模型 -- ClawPanel AI 助手需要单独配置模型(目前手动填写) -- 一键从 OpenClaw 配置导入,省去重复配置 - -### 实现 - -#### 数据来源(两个层级) - -**层级 1:全局配置** `~/.openclaw/openclaw.json` -```json -{ - "models": { - "providers": { - "shengsuanyun": { - "baseUrl": "http://127.0.0.1:8082/v1", - "apiKey": "sk-xxx", - "api": "openai-completions" - } - } - } -} -``` - -**层级 2:Agent 模型注册表** `~/.openclaw/agents//agent/models.json` -```json -{ - "providers": { - "openai": { - "baseUrl": "http://127.0.0.1:8082/v1", - "apiKey": "sk-eB3ybVNFvqB4fGrTUp3F8Lq16QxF7tut", - "api": "openai-completions", - "models": [ - { "id": "gpt-5.4", "name": "gpt-5.4", "contextWindow": 200000, "maxTokens": 8192 }, - { "id": "gpt-5.2-codex", "name": "gpt-5.2-codex", ... } - ] - } - } -} -``` - -**推荐优先读取 Agent 的 models.json**——它有完整的 baseUrl + apiKey + models 列表, -一键就能填充 ClawPanel 助手的配置。 - -#### 读取逻辑 -```js -async function discoverOpenClawModels() { - const home = await getHomeDir() - const results = [] - - // 1. 扫描所有 Agent 的 models.json - try { - const agents = await api.assistantListDir(`${home}/.openclaw/agents`) - for (const agentId of agents.split('\n').map(s => s.trim()).filter(Boolean)) { - try { - const raw = await api.assistantReadFile(`${home}/.openclaw/agents/${agentId}/agent/models.json`) - const data = JSON.parse(raw) - for (const [providerId, provider] of Object.entries(data.providers || {})) { - results.push({ - source: `Agent: ${agentId}`, - providerId, - baseUrl: provider.baseUrl, - apiKey: provider.apiKey, - apiType: provider.api === 'openai-completions' ? 'openai' : provider.api, - models: (provider.models || []).map(m => m.id || m.name), - }) - } - } catch {} - } - } catch {} - - // 2. 读取全局 openclaw.json 作为补充 - try { - const raw = await api.assistantReadFile(`${home}/.openclaw/openclaw.json`) - const config = JSON.parse(raw) - for (const [providerId, provider] of Object.entries(config.models?.providers || {})) { - // 去重:如果 Agent models.json 已有相同 providerId,跳过 - if (!results.find(r => r.providerId === providerId)) { - results.push({ - source: '全局配置', - providerId, - baseUrl: provider.baseUrl, - apiKey: provider.apiKey, - apiType: 'openai', - models: [], // 全局配置没有 models 列表 - }) - } - } - } catch {} - - return results -} -``` - -#### UI:模型配置 tab 新增「导入」按钮 - -``` -┌─────────────────────────────────────────────┐ -│ API Base URL API 类型 │ -│ [________________________] [OpenAI 兼容 ▼] │ -│ │ -│ API Key [测试] [拉取] [导入] │ ← 新增「导入」按钮 -│ [________________________] │ -│ │ -│ 模型 温度 │ -│ [________________________] [0.7] │ -│ │ -└─────────────────────────────────────────────┘ -``` - -点击「导入」弹出选择面板: - -``` -┌─────────────────────────────────────────────┐ -│ 从 OpenClaw 导入模型配置 │ -│ │ -│ 检测到以下已配置的服务商: │ -│ │ -│ ○ OpenAI │ -│ https://api.openai.com/v1 │ -│ 模型: gpt-4o, gpt-4o-mini │ -│ │ -│ ○ DeepSeek │ -│ https://api.deepseek.com │ -│ 模型: deepseek-chat, deepseek-reasoner │ -│ │ -│ ○ 本地 Ollama │ -│ http://127.0.0.1:11434/v1 │ -│ 模型: qwen2.5:7b │ -│ │ -│ 选择一个服务商,自动填充配置。 │ -│ [取消] [导入] │ -└─────────────────────────────────────────────┘ -``` - -### 后端 - -``` -Tauri: 已有 read_openclaw_config 命令 -dev-api.js: 已有 read_config handler - -// 只需在前端加一个读取+解析+填充的逻辑 -``` - -### 优先级:高(零成本,纯前端,极大提升体验) -### 工时估算:0.5 天 - ---- - -## 实施路线图 - -### Phase 1:快速见效(1-2 天) -| 序号 | 功能 | 工时 | 理由 | -|------|------|------|------| -| 1 | **模型配置自动导入** | 0.5d | 读 Agent models.json → 一键填充,纯前端零风险 | -| 2 | **联网搜索工具** | 0.5-1d | DuckDuckGo + Jina,免费无 Key | -| 3 | **灵魂移植(借尸还魂)** | 1-2d | 杀手级差异化——读 SOUL/IDENTITY/USER/AGENTS/MEMORY → 变身 | - -### Phase 2:核心扩展(2-3 天) -| 序号 | 功能 | 工时 | 理由 | -|------|------|------|------| -| 4 | **Docker/WSL 工具** | 1-2d | 解决用户最常见的安装困惑 | -| 5 | **自定义知识库 V1** | 1d | 用户上传 md/txt → 注入 prompt | - -### Phase 3:高级功能(3-5 天) -| 序号 | 功能 | 工时 | 理由 | -|------|------|------|------| -| 6 | **SSH 远程管理** | 2-3d | 价值最高但复杂度也最高 | -| 7 | **知识库 V2(语义搜索)** | 3-5d | 依赖 embedding API | - ---- - -## 设置面板 Tab 规划 - -当前 3 个 Tab → 扩展为 5 个 Tab: - -``` -模型配置 │ 工具权限 │ 知识库 │ 远程连接 │ 助手人设 -``` - -### 工具权限 Tab 最终形态 - -``` -基础工具 - ☑ 终端工具 — 允许执行 Shell 命令 - ☑ 文件工具 — 允许读写文件和浏览目录 - -扩展工具 - ☐ Docker/WSL — 允许管理容器和 WSL 环境 - ☐ 联网搜索 — 允许搜索互联网和抓取网页 - ☐ SSH 远程 — 允许连接远程服务器(需先配置连接) - ☐ 知识库 — 允许检索知识库内容 - -ℹ️ 进程列表、端口检测、系统信息工具始终可用(非聊天模式下)。 -``` - ---- - -## 技术注意事项 - -### 1. Token 预算管理 -灵魂移植 + 知识库注入会占用 context window,需要精细管理: - -| 组件 | 预算 | 说明 | -|------|------|------| -| ClawPanel 基础 prompt | ~2000 tokens | 产品介绍、工具指南、技能卡片 | -| SOUL.md | ~500 tokens | 人设通常简短 | -| IDENTITY.md | ~200 tokens | 名称/风格 | -| USER.md | ~200 tokens | 用户档案 | -| AGENTS.md | ~3000 tokens | 操作规则(最大,可截断) | -| TOOLS.md | ~300 tokens | 工具笔记 | -| MEMORY.md | ~2000 tokens | 长期记忆(截断保留最近部分) | -| 每日记忆 (3天) | ~1500 tokens | 自动截断 | -| 自定义知识库 | ~4000 tokens | 用户上传文档 | -| 搜索结果 | ~2000 tokens | web_search 返回内容 | -| **总计上限** | **~16000 tokens** | 留足空间给对话历史 | - -策略: -- AGENTS.md 超过 3000 tokens 时截断尾部,保留前面的核心规则 -- MEMORY.md 超过 2000 tokens 时只保留最后 2000 tokens -- 每日记忆超过 500 tokens/天时截断 -- 灵魂文件加载时计算总 token 并在 UI 中显示 - -### 2. 跨平台兼容 -- Docker CLI 在 Windows/Mac/Linux 都可用 -- WSL 仅 Windows -- SSH 密钥路径:Windows 用 `%USERPROFILE%\.ssh\`,Mac/Linux 用 `~/.ssh/` - -### 3. 安全存储 -- SSH 密码/API Key: - - Tauri 模式:使用 keytar 或 tauri-plugin-store 加密存储 - - Web 模式:仅支持密钥认证(不存储密码) -- 知识库文件:存储在 `~/.openclaw/` 下,与用户数据同目录 - -### 4. 工具发现 -AI 模型需要知道哪些工具可用。当前已在 `buildSystemPrompt()` 中列出技能卡片。 -新增工具后,需要在系统提示词中补充使用指南(类似现有的 `ask_user` 指南)。 - ---- - -## 文件变更预估 - -| 文件 | 变更 | -|------|------| -| `src/pages/assistant.js` | TOOL_DEFS 新增 4 类 · executeTool 新增 case · getEnabledTools 新增分支 · 设置面板 UI · 模型导入弹窗 | -| `src-tauri/src/commands/assistant.rs` | 新增 Rust 命令:docker_*, wsl_*, ssh_*, web_search, fetch_url | -| `scripts/dev-api.js` | 新增 Web 模式 handler:同上 | -| `src/style/assistant.css` | 知识库管理 UI · SSH 连接管理 UI · 导入弹窗样式 | -| `src/pages/assistant.js` (prompt) | 系统提示词新增各工具使用指南 | diff --git a/docs/docker-multi-instance-plan.md b/docs/docker-multi-instance-plan.md deleted file mode 100644 index 0851aa03..00000000 --- a/docs/docker-multi-instance-plan.md +++ /dev/null @@ -1,494 +0,0 @@ -# ClawPanel Docker 多实例管理 — 技术规划 - -> 版本: v1.0 | 日期: 2026-03-08 - -## 1. 问题分析 - -### 1.1 现状 - -ClawPanel 当前架构是 **单实例管理**: - -``` -浏览器 → ClawPanel 前端 - │ - ├── /__api/* → dev-api.js → 读写本机 ~/.openclaw/ 文件 - ├── /ws → 代理到本机 Gateway:18789 (WebSocket) - └── 静态文件 → dist/ -``` - -**所有页面**(模型配置、Agent 管理、Gateway 设置、日志、聊天等)操作的都是: -- 本机文件系统上的 `~/.openclaw/openclaw.json` -- 本机运行的 Gateway 进程(端口 18789) - -### 1.2 Phase 1 已完成 - -Docker 集群页面实现了 **容器生命周期管理**(通过 Docker Socket API): -- 启动/停止/重启/删除容器 -- 部署新容器(端口映射、数据卷、环境变量) -- 查看容器日志 -- 多节点管理(本机 + 远程 Docker 主机) - -### 1.3 缺口 - -Docker 页面能管容器的"壳",但 **无法管理容器里的 OpenClaw**: -- 无法配置某个容器内的模型 -- 无法查看某个容器内的 Gateway 日志 -- 无法管理某个容器内的 Agent -- 聊天功能只连本机 Gateway - ---- - -## 2. 目标架构 - -### 2.1 核心思路:API 代理 + 实例切换 - -``` -┌──────────────────────────────────────────────────┐ -│ ClawPanel 前端 │ -│ ┌────────────────────────────────────────────┐ │ -│ │ 实例切换器: [ ● 本机 ▼ ] │ │ -│ │ [ ○ prod-server (Docker) ] │ │ -│ │ [ ○ dev-box (远程) ] │ │ -│ │ [ + 添加实例 ] │ │ -│ └────────────────────────────────────────────┘ │ -│ │ -│ 现有页面(模型/Agent/Gateway/日志/聊天...) │ -│ │ │ -│ │ api.readOpenclawConfig() │ -│ │ api.listAgents() │ -│ ▼ │ -│ tauri-api.js → webInvoke('read_openclaw_config') │ -│ │ │ -│ 自动附带 instanceId │ -└──────────────────┼───────────────────────────────┘ - ▼ - dev-api.js (本机后端) - │ - ┌────────┼────────┐ - ▼ ▼ ▼ - 本机文件 代理转发 代理转发 - ~/.openclaw ↓ ↓ - 实例 A 实例 B - http://host http://192.168.1.100 - :18790 :1420 - /__api/* /__api/* -``` - -**关键点:每个 Docker 容器运行 full 镜像,内含完整的 ClawPanel (serve.js) + Gateway。** -因此每个容器已经有自己的 `/__api/*` 端点,我们只需要代理请求过去。 - -### 2.2 WebSocket 连接 - -``` -切换实例时: - wsClient.disconnect() ← 断开旧连接 - wsClient.connect(newHost, newToken) ← 连接新实例的 Gateway -``` - -WebSocket 连接信息从目标实例的配置中读取(通过代理 API 获取 `read_openclaw_config`)。 - -### 2.3 自动组网流程 - -部署新容器时自动完成: - -``` -用户点击「部署容器」 - │ - ├─ 1. Docker API 创建容器(端口映射 hostPort→1420, hostPort→18789) - ├─ 2. 启动容器,等待健康检查通过 - ├─ 3. 探测容器 Panel 端点:GET http://hostIP:hostPort/__api/check_installation - ├─ 4. 自动写入实例注册表 ~/.openclaw/instances.json - └─ 5. 前端自动刷新实例列表 -``` - ---- - -## 3. 数据结构 - -### 3.1 实例注册表 - -文件位置:`~/.openclaw/instances.json` - -```json -{ - "activeId": "local", - "instances": [ - { - "id": "local", - "name": "本机", - "type": "local", - "endpoint": null, - "gatewayPort": 18789, - "addedAt": 1741420800, - "note": "" - }, - { - "id": "docker-abc123", - "name": "openclaw-prod", - "type": "docker", - "endpoint": "http://127.0.0.1:18790", - "gatewayPort": 18789, - "containerId": "abc123def456", - "nodeId": "local", - "addedAt": 1741420900, - "note": "生产环境" - }, - { - "id": "remote-1", - "name": "办公室服务器", - "type": "remote", - "endpoint": "http://192.168.1.100:1420", - "gatewayPort": 18789, - "addedAt": 1741421000, - "note": "" - } - ] -} -``` - -**三种实例类型:** - -| type | 说明 | 来源 | -|------|------|------| -| `local` | 本机 OpenClaw | 始终存在,不可删除 | -| `docker` | Docker 容器内的 OpenClaw | 部署容器时自动注册 | -| `remote` | 远程服务器上的 OpenClaw | 用户手动添加 | - -### 3.2 实例状态(运行时,不持久化) - -```js -{ - id: 'docker-abc123', - online: true, // 健康检查结果 - version: '2026.3.5', // OpenClaw 版本 - gatewayRunning: true, // Gateway 状态 - lastCheck: 1741420999, // 上次检查时间 -} -``` - ---- - -## 4. 改动清单 - -### 4.1 后端 dev-api.js - -#### 4.1.1 实例注册表管理(新增) - -``` -新增 handlers: - instance_list → 读取 instances.json - instance_add → 添加实例(手动或自动) - instance_remove → 删除实例 - instance_set_active → 切换活跃实例 - instance_health_check → 健康检查单个实例 - instance_health_all → 批量健康检查 -``` - -#### 4.1.2 API 代理转发(核心改动) - -改造 `_apiMiddleware`: - -```js -// 伪代码 -async function _apiMiddleware(req, res, next) { - if (!req.url?.startsWith('/__api/')) return next() - - const cmd = extractCmd(req.url) - const body = await readBody(req) - - // 实例管理命令 → 始终本机处理 - if (cmd.startsWith('instance_') || cmd.startsWith('docker_') || ALWAYS_LOCAL.has(cmd)) { - return handleLocally(cmd, body, res) - } - - // 获取当前活跃实例 - const active = getActiveInstance() - - if (active.type === 'local') { - // 本机 → 直接处理(现有逻辑不变) - return handleLocally(cmd, body, res) - } - - // 远程/Docker 实例 → 代理转发 - return proxyToInstance(active, cmd, body, res) -} -``` - -**始终在本机处理的命令(ALWAYS_LOCAL):** -- `instance_*` — 实例管理本身 -- `docker_*` — Docker 容器管理 -- `auth_*` — 认证 -- `read_panel_config` / `write_panel_config` — 本地面板配置 -- `assistant_*` — AI 助手(操作本机文件系统) - -**通过代理转发的命令:** -- `read_openclaw_config` / `write_openclaw_config` — 目标实例的配置 -- `get_services_status` / `start_service` / `stop_service` — 目标实例的服务 -- `list_agents` / `add_agent` / `delete_agent` — 目标实例的 Agent -- `read_log_tail` / `search_log` — 目标实例的日志 -- `get_version_info` / `upgrade_openclaw` — 目标实例的版本 -- `list_memory_files` / `read_memory_file` — 目标实例的记忆文件 -- `read_mcp_config` / `write_mcp_config` — 目标实例的 MCP 配置 -- 等其他 OpenClaw 相关命令 - -#### 4.1.3 代理转发实现 - -```js -async function proxyToInstance(instance, cmd, body, res) { - const url = `${instance.endpoint}/__api/${cmd}` - try { - const resp = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - const data = await resp.text() - res.writeHead(resp.status, { 'Content-Type': 'application/json' }) - res.end(data) - } catch (e) { - res.writeHead(502, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ error: `实例 ${instance.name} 不可达: ${e.message}` })) - } -} -``` - -#### 4.1.4 Docker 部署自动注册 - -修改 `docker_create_container` handler: -- 容器创建并启动后,自动等待健康检查 -- 通过 `GET http://hostIP:panelPort/__api/check_installation` 验证 -- 健康检查通过后自动写入 `instances.json` -- 返回结果包含 `instanceId` - -### 4.2 前端 tauri-api.js - -#### 4.2.1 新增实例管理 API - -```js -// 实例管理 -instanceList: () => cachedInvoke('instance_list', {}, 10000), -instanceAdd: (instance) => { invalidate('instance_list'); return invoke('instance_add', instance) }, -instanceRemove: (id) => { invalidate('instance_list'); return invoke('instance_remove', { id }) }, -instanceSetActive: (id) => { invalidate('instance_list'); _cache.clear(); return invoke('instance_set_active', { id }) }, -instanceHealthCheck: (id) => invoke('instance_health_check', { id }), -instanceHealthAll: () => invoke('instance_health_all'), -``` - -**注意 `instanceSetActive` 清空全部缓存**,因为切换实例后所有缓存数据都过期了。 - -#### 4.2.2 无需改动的部分 - -现有的 `api.readOpenclawConfig()`、`api.listAgents()` 等方法 **完全不变**。 -代理逻辑在后端 `_apiMiddleware` 层透明处理。 - -### 4.3 前端 app-state.js - -新增: - -```js -let _activeInstance = { id: 'local', name: '本机', type: 'local' } -let _instanceListeners = [] - -export function getActiveInstance() { return _activeInstance } -export function onInstanceChange(fn) { ... } - -export async function switchInstance(id) { - // 1. 调后端切换 - await api.instanceSetActive(id) - // 2. 更新本地状态 - _activeInstance = instances.find(i => i.id === id) - // 3. 清缓存 - invalidate() // 清 API 缓存 - // 4. 断开旧 WebSocket - wsClient.disconnect() - // 5. 重新检测状态 - await detectOpenclawStatus() - // 6. 连接新实例的 Gateway WebSocket - connectToActiveGateway() - // 7. 通知所有监听者(侧边栏、页面刷新) - _instanceListeners.forEach(fn => fn(_activeInstance)) -} -``` - -### 4.4 前端 sidebar.js - -在侧边栏顶部 logo 下方添加实例切换器: - -```html -
- -
-
- 本机 -
-
- openclaw-prod - Docker -
-
-
- + 添加实例 -
-
-
-``` - -### 4.5 前端 main.js - -`autoConnectWebSocket()` 改为读取当前活跃实例的 Gateway 端点: - -```js -async function autoConnectWebSocket() { - const instance = getActiveInstance() - if (instance.type === 'local') { - // 本机:读本地配置 - const config = await api.readOpenclawConfig() - const port = config?.gateway?.port || 18789 - wsClient.connect(`127.0.0.1:${port}`, token) - } else { - // 远程/Docker:从实例 endpoint 推导 Gateway 地址 - const config = await api.readOpenclawConfig() // 已通过代理转发 - const gwPort = config?.gateway?.port || 18789 - const url = new URL(instance.endpoint) - wsClient.connect(`${url.hostname}:${instance.gatewayPort || gwPort}`, token) - } -} -``` - -### 4.6 serve.js WebSocket 代理 - -WebSocket 代理改为动态目标: - -```js -server.on('upgrade', (req, socket, head) => { - // 从 query 或 header 中获取目标实例 - const target = resolveWsTarget(req) - const conn = net.createConnection(target.port, target.host, () => { ... }) -}) -``` - -### 4.7 docker.js 集群页面 - -部署对话框增加"自动注册"逻辑: -- 容器创建成功后显示"正在等待实例就绪..." -- 健康检查通过后自动出现在实例切换器中 -- 用户可直接切换到新实例进行管理 - -### 4.8 现有页面适配 - -| 页面 | 改动 | 说明 | -|------|------|------| -| dashboard.js | 极小 | 页头显示当前实例名称 | -| models.js | 无 | API 透明代理 | -| agents.js | 无 | API 透明代理 | -| gateway.js | 极小 | 远程实例时隐藏部分本机功能 | -| logs.js | 无 | API 透明代理 | -| chat.js | 无 | WebSocket 已切换到目标实例 | -| chat-debug.js | 无 | API 透明代理 | -| memory.js | 无 | API 透明代理 | -| services.js | 小 | 已有 Docker 适配,远程实例时隐藏 npm/CLI 相关 | -| extensions.js | 小 | 远程实例时 cftunnel/clawapp 不可用 | -| skills.js | 无 | API 透明代理 | -| security.js | 小 | 远程实例的密码管理走代理 | -| setup.js | 小 | 远程实例不需要 setup 流程 | -| assistant.js | 特殊 | AI 助手始终操作本机(ALWAYS_LOCAL) | - ---- - -## 5. 实施步骤 - -### Step 1: 实例注册表后端(dev-api.js) -- `readInstances()` / `saveInstances()` 工具函数 -- 6 个 handler:`instance_list` / `add` / `remove` / `set_active` / `health_check` / `health_all` -- 预计:~150 行 - -### Step 2: API 代理转发(dev-api.js) -- 改造 `_apiMiddleware` 添加代理逻辑 -- `proxyToInstance()` 函数 -- `ALWAYS_LOCAL` 命令集合 -- 预计:~80 行 - -### Step 3: 前端实例管理 API(tauri-api.js) -- 新增 `api.instance*` 方法 + mock 数据 -- 预计:~40 行 - -### Step 4: 前端状态管理(app-state.js) -- `_activeInstance` 状态 + `switchInstance()` 函数 -- 预计:~50 行 - -### Step 5: 实例切换器 UI(sidebar.js) -- 下拉选择器组件 + CSS -- 预计:~100 行 JS + ~80 行 CSS - -### Step 6: WebSocket 动态连接(main.js + serve.js) -- 切换实例时重新连接 WebSocket -- serve.js WebSocket 代理动态化 -- 预计:~40 行 - -### Step 7: Docker 部署自动注册(docker.js + dev-api.js) -- `docker_create_container` 完成后自动注册 -- 健康检查 + 就绪等待 -- 预计:~60 行 - -### Step 8: 页面微调 -- dashboard 显示实例名 -- 远程实例时隐藏本机独占功能 -- 预计:~30 行 - -**总计新增代码:约 600 行** - ---- - -## 6. 安全考虑 - -### 6.1 认证 -- 远程实例可能有不同的访问密码 -- 代理转发时需要携带目标实例的认证凭据 -- 首次连接时提示输入密码,存入 `instances.json`(加密存储待定) - -### 6.2 网络安全 -- Docker 容器默认只暴露在宿主机网络 -- 远程实例建议通过 SSH 隧道或 VPN 连接 -- 不建议在公网暴露 `/__api/` 端点而不加密码 - -### 6.3 权限隔离 -- AI 助手(assistant_*)始终操作本机文件系统,不代理到远程 -- Docker 管理(docker_*)始终操作本机 Docker,不代理 - ---- - -## 7. 边界与约束 - -### 7.1 不做的事情 -- **不做** 统一聚合视图(如"查看所有实例的模型列表") -- **不做** 跨实例数据同步(如"把本机模型配置复制到远程")— 后续可做 -- **不做** 实例间负载均衡 -- **不做** 复杂的权限角色系统 - -### 7.2 前提条件 -- 远程实例必须运行 ClawPanel(serve.js),版本 >= 0.7.0 -- Docker 实例使用 full 镜像(含 Panel + Gateway) -- 网络可达(ClawPanel 后端能访问远程实例的端口) - -### 7.3 兼容性 -- 现有单实例用户 **零影响**:默认 activeId 为 "local",行为完全不变 -- 实例切换器在只有本机时可以隐藏或最小化显示 -- 所有新功能向后兼容 - ---- - -## 8. 测试计划 - -| 场景 | 验证内容 | -|------|---------| -| 纯本机使用 | 现有功能不受影响,无回归 | -| 部署 Docker 容器 | 自动注册为可管理实例 | -| 切换到 Docker 实例 | 模型/Agent/日志等页面显示容器内数据 | -| 切换实例后聊天 | WebSocket 连接到正确的 Gateway | -| 远程实例离线 | 优雅报错,可切回本机 | -| 删除 Docker 容器 | 实例列表自动移除 | -| 多实例批量健康检查 | 侧边栏状态点实时更新 | diff --git a/docs/i18n-plan.md b/docs/i18n-plan.md deleted file mode 100644 index 2a7cfa4c..00000000 --- a/docs/i18n-plan.md +++ /dev/null @@ -1,288 +0,0 @@ -# ClawPanel i18n 国际化方案 - -> 本文档是 ClawPanel 多语言国际化的完整技术方案和实施指南。 -> 任何后续会话开始 i18n 工作时,请先阅读本文档。 - -## 一、现状评估 - -- **硬编码中文行数**:3508 行(分布在 25+ 个 JS 文件中) -- **预估翻译字符串数**:约 1500+ 个 -- **技术栈**:纯 Vanilla JS(无 React/Vue),Tauri v2 桌面应用 -- **当前语言**:仅中文 - -### 文件中文行数分布(Top 15) - -| 行数 | 文件 | 模块 | -|------|------|------| -| 838 | assistant.js | AI 助手(含内嵌知识库) | -| 312 | docker.js | Docker 集群管理 | -| 243 | models.js | 模型配置 | -| 183 | chat.js | 实时聊天 | -| 156 | chat-debug.js | 系统诊断 | -| 148 | openclaw-kb.js | 知识库文本 | -| 142 | setup.js | 初始安装引导 | -| 136 | channels.js | 消息渠道 | -| 136 | main.js | 主入口/路由/横幅 | -| 120 | services.js | 服务管理 | -| 105 | about.js | 关于页面 | -| 93 | cron.js | 定时任务 | -| 88 | dashboard.js | 仪表盘 | -| 72 | extensions.js | 扩展工具 | -| 68 | gateway.js | 网关配置 | - -## 二、技术架构 - -### 核心模块:`src/lib/i18n.js` - -```js -// 使用方式 -import { t, setLocale, getLocale } from '../lib/i18n.js' - -// 简单翻译 -t('common.save') // → "保存" / "Save" -t('common.cancel') // → "取消" / "Cancel" - -// 带参数插值 -t('chat.messageCount', { count: 5 }) // → "5 条消息" / "5 messages" - -// 嵌套 key -t('dashboard.gateway.running') // → "运行中" / "Running" - -// 切换语言 -setLocale('en') // 存 localStorage,触发页面重渲染 -``` - -### 语言检测优先级 - -1. `localStorage` 中存储的用户选择 (`clawpanel-locale`) -2. 浏览器 `navigator.language`(`zh-CN` → `zh-CN`,`en-US` → `en`) -3. 默认值:`zh-CN` - -### 缺失翻译 fallback - -1. 查找当前语言包 -2. 查找 `zh-CN` 兜底(中文作为最完整的语言) -3. 返回 key 本身(如 `common.save`) -4. 开发模式下 console.warn 提示缺失翻译 - -## 三、语言包结构 - -``` -src/locales/ - zh-CN.json — 中文简体(默认,最完整) - en.json — English - zh-TW.json — 中文繁体(未来) - ja.json — 日本語(未来) - ko.json — 한국어(未来) -``` - -### JSON 格式规范 - -按模块/页面分组,使用扁平化嵌套结构: - -```json -{ - "common": { - "save": "保存", - "cancel": "取消", - "delete": "删除", - "confirm": "确定", - "close": "关闭", - "loading": "加载中...", - "error": "错误", - "success": "成功", - "warning": "警告", - "retry": "重试", - "refresh": "刷新", - "edit": "编辑", - "create": "创建", - "back": "返回", - "next": "下一步", - "search": "搜索", - "copy": "复制", - "download": "下载", - "upload": "上传", - "enable": "启用", - "disable": "禁用", - "start": "启动", - "stop": "停止", - "restart": "重启", - "status": "状态", - "running": "运行中", - "stopped": "已停止", - "unknown": "未知", - "noData": "暂无数据", - "operationFailed": "操作失败: {error}", - "confirmDelete": "确定删除 {name}?", - "savedSuccessfully": "已保存" - }, - "sidebar": { - "dashboard": "仪表盘", - "assistant": "晴辰助手", - "chat": "实时聊天", - "services": "服务管理", - "logs": "日志查看", - "models": "模型配置", - "agents": "Agent 管理", - "memory": "记忆文件", - "channels": "消息渠道", - "gateway": "网关配置", - "skills": "Skills 工具", - "docker": "Docker 集群", - "cron": "定时任务", - "extensions": "扩展工具", - "about": "关于", - "setup": "初始设置", - "chatDebug": "系统诊断" - }, - "dashboard": { ... }, - "chat": { ... }, - "models": { ... }, - ... -} -``` - -## 四、迁移步骤(每个页面) - -### Step 1: 提取中文字符串 - -使用正则或手动扫描,将所有中文文本提取到对应的语言包 key 下。 - -**需要翻译的内容**: -- UI 文本(按钮文字、标题、描述、提示) -- toast 消息 -- 错误消息 -- placeholder 文本 -- confirm 对话框文本 -- tooltip 文本 - -**不需要翻译的内容**: -- 代码注释(保持中文) -- console.log 调试信息 -- 技术标识符(如 `Gateway`、`Agent`、`OpenClaw`) -- API 错误消息(后端返回的) -- 知识库内容 `openclaw-kb.js`(这个特殊处理,按语言版本分文件) - -### Step 2: 替换代码中的硬编码 - -```js -// Before -toast('保存成功', 'success') - -// After -toast(t('common.savedSuccessfully'), 'success') -``` - -```js -// Before (HTML 模板) -`` - -// After -`` -``` - -### Step 3: 编写英文翻译 - -逐 key 翻译到 `en.json`。 - -### Step 4: 测试 - -切换语言,检查每个页面的显示是否正常。 - -## 五、迁移顺序 - -### 第一批(基础 + 框架层,约 80 个字符串) -1. `src/lib/i18n.js` — 创建核心模块 -2. `src/locales/zh-CN.json` — 初始化中文包 -3. `src/locales/en.json` — 初始化英文包 -4. `src/components/sidebar.js` — 导航菜单(~20 个) -5. `src/components/modal.js` — 公共弹窗(~10 个) -6. `src/components/toast.js` — 提示组件 -7. `src/pages/about.js` — 关于页面 + 语言切换 UI(~30 个) - -### 第二批(核心页面,约 250 个字符串) -8. `src/pages/dashboard.js` — 仪表盘(~50 个) -9. `src/pages/setup.js` — 初始设置(~80 个) -10. `src/pages/chat.js` — 实时聊天(~100 个) -11. `src/main.js` — 主入口/横幅(~20 个) - -### 第三批(配置页面,约 350 个字符串) -12. `src/pages/models.js` — 模型配置(~120 个) -13. `src/pages/channels.js` — 消息渠道(~80 个) -14. `src/pages/services.js` — 服务管理(~70 个) -15. `src/pages/gateway.js` — 网关配置(~40 个) -16. `src/pages/agents.js` — Agent 管理(~40 个) - -### 第四批(功能页面,约 250 个字符串) -17. `src/pages/cron.js` — 定时任务(~50 个) -18. `src/pages/memory.js` — 记忆管理(~30 个) -19. `src/pages/extensions.js` — 扩展工具(~40 个) -20. `src/pages/logs.js` — 日志查看(~20 个) -21. `src/pages/skills.js` — Skills 工具(~60 个) -22. `src/pages/chat-debug.js` — 系统诊断(~50 个) - -### 第五批(大型页面 + 特殊处理,约 600 个字符串) -23. `src/pages/docker.js` — Docker 管理(~150 个) -24. `src/pages/assistant.js` — AI 助手(~400 个,含系统提示词) -25. `src/lib/openclaw-kb.js` — 知识库(按语言分文件) -26. `src/lib/error-diagnosis.js` — 错误诊断(~30 个) -27. `src/components/engagement.js` — 推荐弹窗(~15 个) - -### 第六批(官网 + 文档) -28. `docs/index.html` — 官网英文版 -29. `README.md` → `README_en.md` -30. `CONTRIBUTING.md` → `CONTRIBUTING_en.md` - -## 六、语言切换 UI 设计 - -### 位置 -1. **关于页面底部** — 语言选择下拉框 -2. **侧边栏底部** — 语言图标 + 当前语言缩写(如 `中` / `EN`) - -### 交互 -- 选择语言 → 存入 localStorage → 页面自动刷新 -- 首次访问自动检测浏览器语言 - -## 七、注意事项 - -### 技术品牌词不翻译 -以下词保持原样,不翻译: -- `OpenClaw` -- `ClawPanel` -- `Gateway` -- `Agent`(Agent 管理不翻译为"代理") -- `MCP` -- `Skills` -- `Docker` -- `Tauri` - -### 参数插值语法 -使用 `{param}` 语法: -```json -{ - "chat.sessions": "{count} sessions", - "models.providers": "Based on {count} providers" -} -``` - -### 复数形式 -英文需要处理复数,但 MVP 阶段可以用简单方式: -```json -{ - "chat.messageCount": "{count} message(s)" -} -``` - -### Rust 后端 -后端错误消息暂不国际化(工作量大且用户较少直接看到),保持中文。 - -## 八、验证清单 - -每批迁移完成后检查: -- [ ] 中文模式下所有功能正常 -- [ ] 英文模式下所有功能正常 -- [ ] 语言切换后页面正确刷新 -- [ ] 没有遗漏的硬编码中文 -- [ ] 参数插值正确显示 -- [ ] 长英文文本不溢出布局 -- [ ] toast/modal/confirm 文本正确 diff --git a/docs/plans/2026-03-16-cloudflared-openclaw-integration-design.md b/docs/plans/2026-03-16-cloudflared-openclaw-integration-design.md deleted file mode 100644 index 3c8d590e..00000000 --- a/docs/plans/2026-03-16-cloudflared-openclaw-integration-design.md +++ /dev/null @@ -1,59 +0,0 @@ -# Cloudflared 公网访问与 OpenClaw 兼容导入设计 - -日期:2026-03-16 - -## 目标 -- 在 ClawPanel 设置页新增“公网访问(Cloudflared)”Tab -- 支持快速隧道与命名隧道,进入页面后由用户选择 -- 一键认证登录(允许弹出浏览器完成 cloudflared login) -- 默认暴露 OpenClaw Gateway(18789) -- 读取用户已安装 OpenClaw 的 `C:\Users\\.openclaw\openclaw.json`,只读导入并做兼容或升级 - -## 约束与偏好 -- 不要求用户输入隧道 Token -- cloudflared.exe 优先 PATH 检测,其次 `~/.openclaw/bin`,不存在则复用 label-printer 的加速域名检测与下载逻辑 -- 不覆盖原始 openclaw.json,仅生成 ClawPanel 本地配置副本 - -## 方案对比 -### 方案 A(推荐) -- 内置 Cloudflared 管理器 + 一键登录 + 快速/命名双模式 -- 优点:体验一致,符合“一键认证登录” -- 风险:需要完整接入本地进程管理与状态监控 - -### 方案 B -- 仅提供外部 cloudflared 路径配置 -- 优点:实现快 -- 缺点:不满足“一键认证登录” - -### 方案 C -- A 为主,保留手动路径作为兜底 -- 优点:兼容性强 -- 代价:UI 复杂度增加 - -推荐:方案 A,保留手动路径兜底入口 - -## 设计 Section 1:架构与入口 -- 入口:设置页新增“公网访问(Cloudflared)”Tab -- 核心模块: - 1) Cloudflared 管理器(检测、下载、启动、停止、状态) - 2) 隧道管理(快速隧道/命名隧道) - 3) OpenClaw 配置导入(读取并兼容升级) -- 数据流:UI → IPC → cloudflared → 状态回传 → UI - -## 设计 Section 2:获取与一键认证 -- 检测顺序:PATH → `~/.openclaw/bin/cloudflared.exe` → 下载 -- 下载策略:加速域名检测失败则回退官方下载 -- 一键认证:执行 `cloudflared tunnel login`,浏览器授权完成后保存凭据 -- 失败提示与重试 - -## 设计 Section 3:隧道类型与运行流程 -- 快速隧道:临时访问 -- 命名隧道:固定域名与服务 -- 流程:选择类型 → 选择服务(默认 18789)→ 启动 → 展示地址/状态 -- 错误处理:启动失败、登录失效、端口占用 - -## 设计 Section 4:OpenClaw 配置导入 -- 默认读取:`C:\Users\\.openclaw\openclaw.json` -- 兼容升级:缺失字段补全、旧字段映射 -- 只读导入,不覆盖原配置 -- 失败回退为只读展示 diff --git a/docs/plans/2026-03-16-cron-sessionmessage-tool-ui-design.md b/docs/plans/2026-03-16-cron-sessionmessage-tool-ui-design.md deleted file mode 100644 index fe98f9dd..00000000 --- a/docs/plans/2026-03-16-cron-sessionmessage-tool-ui-design.md +++ /dev/null @@ -1,55 +0,0 @@ -# Cron SessionMessage + Tool UI Design - -日期:2026-03-16 - -## 目标 -- 在 ClawPanel 中新增 cron 任务类型:向指定 session 发送 user 消息 -- 支持等待对话结束(Gateway WebSocket 事件)再发送 -- Chat 页面展示工具调用(默认收起) -- 适配 npm 全局安装 OpenClaw(补丁应用与更新重打) - -## 范围 -- 新增 payload.kind = sessionMessage(Gateway cron 执行分支) -- ClawPanel cron UI 增加任务类型与字段 -- Chat UI 增加 tool call 展示 -- OpenClaw 版本更新时补丁自动重打 - -## 方案概述 -### Cron SessionMessage -- payload.kind: sessionMessage -- 字段:label, message, role=user, waitForIdle=true -- Gateway cron 执行:label -> sessionKey -> 等待 chat final -> 发送 user 消息 - -### UI 改动 -- cron 表单新增任务类型选择 -- session label 下拉(来自 sessions.list) -- message 文本输入 -- 列表与详情展示任务类型与目标 - -### Chat 工具调用展示 -- 解析 message.content 中 tool / tool_result -- 默认收起,仅显示工具名与状态 -- 点击展开显示参数与结果 JSON - -### 补丁与更新 -- 定位 npm 全局包路径(npm root -g + 包名) -- 打补丁前备份原文件 -- 写入 clawpanel.json 记录补丁版本与 OpenClaw 版本 -- 更新后检测版本变化并重打补丁 - -## 数据流 -- Cron UI -> Gateway cron.add -> payload sessionMessage -- Gateway cron -> 监听 chat final -> chat.send (role=user) -- Chat UI -> 渲染 tool call blocks - -## 错误处理 -- label 不存在:任务失败并记录错误 -- Gateway 未连接:cron UI 提示不可用 -- 补丁失败:自动回退并提示 - -## 测试要点 -- cron 创建/编辑/删除 -- sessionMessage 执行成功 -- 等待对话结束后发送 -- tool call 展示与展开 -- 补丁重打与回退 diff --git a/docs/plans/2026-03-16-gateway-patch-auto-detect-design.md b/docs/plans/2026-03-16-gateway-patch-auto-detect-design.md deleted file mode 100644 index 9dd17941..00000000 --- a/docs/plans/2026-03-16-gateway-patch-auto-detect-design.md +++ /dev/null @@ -1,25 +0,0 @@ -# Gateway 补丁自动检测与重打设计 - -日期:2026-03-16 - -## 目标 -- 启动与进入设置页自动检测全局 OpenClaw 版本 -- 版本变化时自动重打补丁,失败才提示 -- 过程静默、不中断使用 - -## 触发策略 -- 应用启动时触发一次检测 -- 进入设置页时触发检测 - -## 自动重打逻辑 -- 判断 installed_version 与 gatewayPatch.openclawVersion -- 不一致时调用 gateway_patch_apply(force=true) -- 使用内存节流与冷却(5 分钟)避免重复触发 - -## 状态与提示 -- 失败写入 last_error,设置页展示错误 -- 不弹窗、不阻塞其他功能 - -## 边界处理 -- 缺少 .bak 备份时,force 失败提示“缺少备份,建议先一键补丁” -- 自动重打失败只提示,不回滚、不影响其他功能 diff --git a/docs/plans/2026-03-16-gateway-patch-oneclick-design.md b/docs/plans/2026-03-16-gateway-patch-oneclick-design.md deleted file mode 100644 index e265deed..00000000 --- a/docs/plans/2026-03-16-gateway-patch-oneclick-design.md +++ /dev/null @@ -1,37 +0,0 @@ -# Gateway 一键补丁设计 - -日期:2026-03-16 - -## 目标 -- 在设置页提供一键补丁入口,自动对全局 npm 安装的 OpenClaw 打补丁 -- 支持检测版本、应用补丁、重打补丁、回滚 - -## 入口与交互 -- 位置:设置页「公网访问」区域旁新增“Gateway 补丁”卡片 -- 按钮:一键补丁、重打补丁、回滚 -- 状态:展示检测到的 OpenClaw 版本、补丁状态、最近操作结果 - -## 实现流程 -1. 定位全局 npm 根目录:`npm root -g` -2. 在 `node_modules` 内查找 `openclaw` 包 -3. 自动识别目标文件(reply-*.js / gateway-cli-*.js) -4. 备份文件(.bak) -5. 应用补丁(sessionMessage 支持) -6. 写入 `clawpanel.json` 记录补丁版本与 OpenClaw 版本 -7. 版本变更时提示并支持重打补丁 -8. 失败自动回滚 - -## 数据与状态 -- `clawpanel.json` 新增: - - `gatewayPatch`: { version, patchedAt, openclawVersion, files: [] } - -## 错误处理 -- 未找到 npm 根目录或包:提示错误 -- 文件名不匹配:提示错误并终止 -- 打补丁失败:回滚并记录错误 - -## 测试要点 -- 正常补丁流程 -- 回滚流程 -- 版本变化后重打补丁 -- 错误路径处理 diff --git a/docs/plans/2026-03-17-ai-config-import-design.md b/docs/plans/2026-03-17-ai-config-import-design.md deleted file mode 100644 index 1f4b2b0c..00000000 --- a/docs/plans/2026-03-17-ai-config-import-design.md +++ /dev/null @@ -1,37 +0,0 @@ -# AI 配置从 openclaw 导入设计 - -日期: 2026-03-17 - -## 目标 -在 AI 配置页提供“从 openclaw 导入”功能,导入模型参数 + API Key + Base URL。 - -## 方案 -- 采用方案 A:手动按钮触发导入 - -## 设计细节 -### 1) 导入入口 -- AI 配置页顶部操作区新增按钮:`从 openclaw 导入` - -### 2) 导入内容 -- 读取 `openclaw.json` -- 提取字段: - - model - - temperature - - top_p - - api_key - - base_url -- 写回当前 AI 配置表单并持久化 - -### 3) 反馈 -- 成功:toast 提示“已导入” -- 失败:toast 提示读取失败或字段缺失 - -## 影响范围 -- src/pages/models.js -- src/lib/tauri-api.js -- src-tauri/src/commands/config.rs - -## 测试要点 -- openclaw.json 有效 → 导入成功 -- 字段缺失 → 失败提示 -- 导入后配置可保存并生效 diff --git a/docs/plans/2026-03-17-assistant-optimize-toggle-design.md b/docs/plans/2026-03-17-assistant-optimize-toggle-design.md deleted file mode 100644 index 4c65c7a5..00000000 --- a/docs/plans/2026-03-17-assistant-optimize-toggle-design.md +++ /dev/null @@ -1,27 +0,0 @@ -# 晴辰助手 优化/还原按钮切换设计 - -日期: 2026-03-17 - -## 目标 -优化与还原按钮不同时出现,默认显示“优化”,优化完成后显示“还原”,发送或还原后切回“优化”。 - -## 方案 -- 采用方案 A:同位置切换显示 - -## 设计细节 -### 1) UI 结构 -- 保留两个按钮节点 -- 通过状态控制显示 - -### 2) 状态逻辑 -- `_optOriginalText` 为空:显示“优化”,隐藏“还原” -- `_optOriginalText` 非空:隐藏“优化”,显示“还原” -- `clearOptimizeSnapshot()` 或点击还原后切回“优化” - -## 影响范围 -- src/pages/assistant.js - -## 测试要点 -- 默认仅显示“优化” -- 优化完成后仅显示“还原” -- 发送或点击还原后仅显示“优化” diff --git a/docs/plans/2026-03-17-assistant-ux-and-shell-design.md b/docs/plans/2026-03-17-assistant-ux-and-shell-design.md deleted file mode 100644 index 9ddb66c3..00000000 --- a/docs/plans/2026-03-17-assistant-ux-and-shell-design.md +++ /dev/null @@ -1,43 +0,0 @@ -# Assistant UX + Windows Shell 优化设计 - -日期: 2026-03-17 - -## 目标 -1) Windows shell 优先 pwsh,其次 powershell,最后 cmd -2) 复制按钮样式统一,右上角悬浮显示 -3) AI 助手输入区新增“优化/恢复原文”按钮,保留撤销栈 - -## 方案 -- 采用方案 B - -## 设计细节 -### 1) assistant_exec shell 优先级 -- Windows: pwsh -> powershell -> cmd -- 执行前检测可用 shell(where / Get-Command) -- 继续使用 build_system_env() 注入完整系统环境 - -### 2) 复制按钮错位修复 -- 不改 Markdown 解析 -- chat.css 与 assistant.css 的 pre / code-copy-btn 统一 -- copy 按钮右上角悬浮,hover 显示 - -### 3) AI 优化按钮 -- 位置: AI 助手输入区,与发送按钮并列 -- 文本模板: “请在不改变原意和语言的前提下,重写为意思更清晰、更简洁的表达。” -- 点击优化:调用同模型在线重写,替换输入框文本 -- 快照:保存原文快照 + 优化结果快照 -- 恢复原文:发送前始终可用 -- 发送后清空快照 -- 替换方式: setRangeText + input 事件,保留 Ctrl+Z - -## 影响范围 -- src-tauri/src/commands/assistant.rs -- src/style/chat.css -- src/style/assistant.css -- src/pages/assistant.js -- src/lib/tauri-api.js(如需复用 call) - -## 测试要点 -- Windows 下优先使用 pwsh -- 复制按钮在 chat 与 assistant 页面一致 -- 优化/恢复流程与撤销栈可用 diff --git a/docs/plans/2026-03-17-chat-autoscroll-design.md b/docs/plans/2026-03-17-chat-autoscroll-design.md deleted file mode 100644 index 88b4cf02..00000000 --- a/docs/plans/2026-03-17-chat-autoscroll-design.md +++ /dev/null @@ -1,41 +0,0 @@ -# 聊天页面自动滚动行为设计 - -## 目标 -- 用户上滑查看历史时,不被强制拉回底部 -- 仅在“新消息出现”且用户处于底部时自动滚动 - -## 现状问题 -当前聊天页存在持续自动滚动到底部的行为,导致用户无法查看上方历史消息,尤其在无新消息时仍被拉回底部。 - -## 推荐方案(已确认) -**方案 A:仅在新消息出现且用户在底部时自动滚动** -- 维护“是否在底部”的状态 -- 当用户离开底部时,自动滚动关闭 -- 仅在新消息插入时、且自动滚动开启,才执行 `scrollToBottom()` - -## 关键设计点 -1. **状态管理** - - `autoScrollEnabled`:用户在底部时为 true;上滑离开底部时为 false - - 在滚动事件中更新该状态 - -2. **滚动触发点** - - 在“消息插入”函数内触发(如 `appendUserMessage` / `appendAiMessage` / `appendSystemMessage` / `createStreamBubble`) - - 渲染过程中(如 `doRender` / `doVirtualRender`)不再强制滚动 - -3. **流式输出处理** - - 若用户不在底部,则流式输出不强制跟随 - - 若用户在底部,则跟随滚动 - -4. **回到底部按钮** - - 点击按钮后强制滚动到底部并恢复 `autoScrollEnabled = true` - -## 影响范围 -- `src/pages/chat.js` -- 可能涉及 `chat.css`(若需要视觉提示或按钮行为调整) - -## 测试要点 -- 无新消息时,上滑查看历史不被拉回 -- 新消息出现时: - - 若当前在底部,自动滚动 - - 若已上滑,保持当前位置 -- 点击“回到底部”按钮后恢复自动滚动 diff --git a/docs/plans/2026-03-17-chat-daylight-shadow-design.md b/docs/plans/2026-03-17-chat-daylight-shadow-design.md deleted file mode 100644 index ce6c77cf..00000000 --- a/docs/plans/2026-03-17-chat-daylight-shadow-design.md +++ /dev/null @@ -1,37 +0,0 @@ -# Chat 日间模式助手气泡阴影设计 - -## 目标 -- 解决日间模式下助手气泡不明显的问题 -- 不改变夜间模式表现 -- 不改变间距、圆角、字体,仅增加轻微阴影 - -## 背景 -- 当前 `.msg-ai .msg-bubble` 使用半透明背景,在日间模式可能与底色融合,导致难以区分 -- 用户确认仅需补充日间模式阴影 - -## 方案对比 -### 方案 A(推荐) -- 仅在日间模式为 `.msg-ai .msg-bubble` 增加轻微阴影 -- 夜间模式保持不变 -- 风险最小,符合当前需求 - -### 方案 B -- 日间模式增加阴影 + 细边框 -- 对比更强,但视觉改动更明显 - -### 方案 C -- 调整日间模式背景色 + 阴影 -- 可读性提升,但偏离现有视觉体系 - -## 采用方案 -- 采用方案 A - -## 设计细节 -- 选择器:`[data-theme="light"] .msg-ai .msg-bubble` -- 阴影强度:轻微,不影响布局 -- 不改变背景色、圆角、内边距 - -## 验收标准 -- 日间模式:助手消息气泡可清晰区分 -- 夜间模式:视觉无变化 -- 不引入新的布局抖动或遮挡问题 diff --git a/docs/plans/2026-03-17-chat-history-tool-merge-design.md b/docs/plans/2026-03-17-chat-history-tool-merge-design.md deleted file mode 100644 index 5fb4b7f5..00000000 --- a/docs/plans/2026-03-17-chat-history-tool-merge-design.md +++ /dev/null @@ -1,30 +0,0 @@ -# 聊天历史工具卡片合并与排序设计 - -## 目标 -- 历史记录解析后,工具卡片只显示 1 份(按 toolCallId 合并) -- 工具卡片显示在消息气泡上方 -- 工具时间优先使用事件 ts,缺失回退 message.timestamp -- 历史模式不再插入工具事件系统消息 -- 历史列表按时间排序,同时间工具在上文本在下 - -## 现状问题 -- history 刷新后出现重复工具卡片(toolCall/toolResult 未合并) -- 工具时间缺失导致排序异常 -- 工具事件系统消息与工具卡片同时出现,导致页面被工具卡片占满 - -## 方案(合并式解析) -1) 统一合并:工具块解析使用 upsertTool,按 toolCallId 合并 -2) 时间回退:工具时间 = eventTs || message.timestamp || null -3) 历史禁入系统消息:history 解析不再插入工具事件系统消息 -4) 排序规则:按 time 升序;相同时间时工具卡片在上,文本在下 - -## 设计细节 -- 新增工具时间解析函数:`resolveToolTime(toolId, messageTimestamp)` -- 工具事件处理处增加 history 标记,历史模式不调用 `appendSystemMessage` -- 渲染时将工具卡片与文本作为 entries 统一排序 - -## 验收标准 -- 历史记录中工具卡片不重复 -- 刷新后工具时间可见 -- 工具卡片位于消息气泡上方 -- 列表不再被工具事件系统消息淹没 diff --git a/docs/plans/2026-03-17-chat-markdown-it-design.md b/docs/plans/2026-03-17-chat-markdown-it-design.md deleted file mode 100644 index 0cd2dd34..00000000 --- a/docs/plans/2026-03-17-chat-markdown-it-design.md +++ /dev/null @@ -1,25 +0,0 @@ -# Chat Markdown-it 样式解析设计 - -## 目标 -- clawpanel chat 页面支持完整 Markdown 样式:加粗、斜体、删除线、行内代码、代码块、引用、列表、链接、下划线、剧透、@提及 -- 解析行为与 GitHub 接近,安全渲染 - -## 方案 -- 将 `src/lib/markdown.js` 切换为 `markdown-it` 作为渲染核心 -- 使用插件机制实现: - - underline:`__text__` 输出 `` - - spoiler:同时支持 `||spoiler||` 与 `>!spoiler!<` - - mention:`@用户名` 输出 `@用户名` -- 代码块高亮沿用现有 `highlightCode` -- 链接白名单 `http/https/mailto`,否则降级为 `#` -- 禁止任意 HTML 注入(html=false) - -## 样式 -- `.msg-mention` 高亮 -- `.msg-spoiler` 遮罩,点击显示(`revealed` 类切换) - -## 验收标准 -- chat 页面渲染包含 E 方案的全部样式 -- `|| ||` 与 `>! !<` 均可正确显示剧透 -- @提及高亮,非链接 -- 不引入 HTML 注入风险 diff --git a/docs/plans/2026-03-17-chat-tool-event-live-design.md b/docs/plans/2026-03-17-chat-tool-event-live-design.md deleted file mode 100644 index 60ce4782..00000000 --- a/docs/plans/2026-03-17-chat-tool-event-live-design.md +++ /dev/null @@ -1,34 +0,0 @@ -# 实时聊天工具事件展示设计 - -## 目标 -- 工具调用实时显示为独立系统消息 -- 使用 `payload.ts` 作为排序时间 -- 避免重复渲染 - -## 现状问题 -- 工具事件未实时展示,需要刷新 -- 刷新后可能出现重复/顺序错乱 - -## 方案 -- 监听 `event: agent` + `stream: tool` 事件 -- 使用 `payload.runId` 与 `toolCallId` 组成唯一键去重 -- 插入消息时按 `payload.ts` 排序 - -## 设计细节 -### 时间与去重 -- 唯一键:`${payload.ts}:${toolCallId}` -- 时间来源:`payload.ts` - -### 插入策略 -- 所有消息(用户/助手/系统/工具事件)都写入 `data-ts` -- 插入时按时间从小到大找到位置 - -### 工具事件消息内容 -- 标题:`工具调用开始/结果 · 工具名` -- 结果:成功/失败(基于 isError) -- 仅展示 start 与 result 两类 - -## 验收标准 -- 工具事件实时出现,无需刷新 -- 不出现重复消息 -- 顺序按时间正确 diff --git a/docs/plans/2026-03-17-chat-virtual-scroll-design.md b/docs/plans/2026-03-17-chat-virtual-scroll-design.md deleted file mode 100644 index 78425bfa..00000000 --- a/docs/plans/2026-03-17-chat-virtual-scroll-design.md +++ /dev/null @@ -1,25 +0,0 @@ -# Chat 虚拟滚动设计 - -## 目标 -- 滚动流畅度提升 -- 控制渲染 DOM 数量,降低重绘 - -## 现状 -- 所有消息一次性渲染,滚动卡顿 - -## 方案 -- 使用简单虚拟列表策略:仅渲染可视区域 + 上下缓冲 -- 滚动时动态替换 DOM - -## 设计细节 -- 视窗计算:基于容器 scrollTop + clientHeight -- 渲染范围:可视范围上下各缓冲 N 条 -- 需要维护消息高度缓存 - -## 风险 -- DOM 搜索不可见 -- 需要高度缓存避免跳动 - -## 验收标准 -- 滚动流畅度明显提升 -- 无明显跳动 diff --git a/docs/plans/2026-03-17-chat-virtual-scroll-implementation-design.md b/docs/plans/2026-03-17-chat-virtual-scroll-implementation-design.md deleted file mode 100644 index eca40d2c..00000000 --- a/docs/plans/2026-03-17-chat-virtual-scroll-implementation-design.md +++ /dev/null @@ -1,34 +0,0 @@ -# Chat 虚拟列表实现设计 - -## 目标 -- 提升首屏渲染速度(优先) -- 提升滚动流畅度(次优) -- 固定渲染窗口:40 条 + 上下缓冲 20 条 -- 滚动锚点:底部时自动滚动,否则保持位置 -- 高度策略:预估行高 + 运行时测量校准 - -## 方案(固定窗口 + 占位高度) -- 使用容器总高度占位 -- 仅渲染窗口范围消息 -- 运行时测量单条高度并缓存 -- 通过累计高度计算可视范围 - -## 数据结构 -- items: [{ id, role, text, attachments, tools, timestamp }] -- heights: Map -- avgHeight: number (默认 64) -- range: { start, end } - -## 渲染策略 -- 顶部、底部使用占位 div 控制滚动条 -- 每次滚动计算可视范围 -- 渲染 items[start..end] - -## 锚点策略 -- 若用户在底部,新增消息时自动滚动到底部 -- 否则保持当前滚动位置 - -## 验收标准 -- 首屏渲染速度明显提升 -- 长列表滚动不卡顿 -- 新消息到来时遵循锚点策略 diff --git a/docs/plans/2026-03-17-force-setup-design.md b/docs/plans/2026-03-17-force-setup-design.md deleted file mode 100644 index d747ce86..00000000 --- a/docs/plans/2026-03-17-force-setup-design.md +++ /dev/null @@ -1,31 +0,0 @@ -# 强制初始化(forceSetup)设计 - -日期: 2026-03-17 - -## 目标 -构建版首次启动可强制进入 /setup,不受已存在配置影响。 - -## 方案 -- 采用方案 A:在 clawpanel.json 增加 forceSetup 字段 - -## 设计细节 -### 1) 配置字段 -- `clawpanel.json` 新增:`forceSetup: true/false` - -### 2) 启动逻辑 -- 启动时读取 panel config -- 若 `forceSetup === true`,即使 isOpenclawReady 为 true 也强制跳转 /setup - -### 3) 初始化完成后 -- setup 流程成功完成时写入 `forceSetup=false` - -## 影响范围 -- src/main.js -- src/lib/tauri-api.js -- src/pages/setup.js -- src-tauri/src/commands/config.rs - -## 测试要点 -- forceSetup=true 时进入 /setup -- 完成初始化后 forceSetup 自动清零 -- forceSetup=false 时恢复原有判断逻辑 diff --git a/docs/plans/2026-03-17-hosted-agent-design.md b/docs/plans/2026-03-17-hosted-agent-design.md deleted file mode 100644 index 262a63c1..00000000 --- a/docs/plans/2026-03-17-hosted-agent-design.md +++ /dev/null @@ -1,131 +0,0 @@ -# 托管 Agent(聊天页)详细设计 - -> 结论:采用方案一(复用晴辰助手能力)。 - -## 目标 -- 聊天页发送按钮右侧新增“托管 Agent”入口 -- 托管 Agent 通过 WSS 自动与当前会话 Agent 交互 -- 上下文仅包含:初始提示词 + 与对面 Agent 的交流 -- 继承当前会话工具权限 -- 全局默认 + 会话级启用,切换会话/页面仍生效 -- 输出直接插入当前聊天流并标记来源 - -## 入口与 UI 交互 -### 入口按钮 -- 位置:`src/pages/chat.js` 内聊天输入区域,发送按钮右侧 -- 交互:点击打开托管 Agent 配置面板 -- 状态:idle / running / waiting_reply / paused / error - -### 配置面板 -- 初始提示词(必填) -- 启用开关 -- 运行模式:对面 Agent 回复后自动继续 -- 停止策略:托管 Agent 自评停止 -- 高级选项:最大步数 / 步间隔 / 重试次数 -- 操作:保存并启用 / 暂停 / 立即停止 - -### 输出展示 -- 直接插入当前聊天流 -- 格式示例: - - `[托管 Agent] 下一步指令: ...` -- 样式区分:弱化颜色 + 标签 - -## 运行循环与状态机 -### 状态 -- idle / running / waiting_reply / paused / error - -### 触发 -- 监听 `wsClient.onEvent` -- event=chat,state=final,sessionKey=当前会话 - -### 执行流程 -1. 对面 Agent final 回复到达 -2. 托管 Agent 生成下一步指令 -3. 使用 `wsClient.chatSend` 发送 -4. 进入 waiting_reply -5. 满足 stopPolicy 或 maxSteps 停止 - -## 上下文构建 -- 仅包含:初始提示词 + 与对面 Agent 对话 -- 截断策略:按 MAX_CONTEXT_TOKENS 或最近 N 条 -- 不引入其他会话内容 - -## 数据结构与持久化 -### 全局默认(clawpanel.json) -```json -{ - "hostedAgent": { - "default": { - "enabled": false, - "prompt": "", - "autoRunAfterTarget": true, - "stopPolicy": "self", - "maxSteps": 50, - "stepDelayMs": 1200, - "retryLimit": 2, - "toolPolicy": "inherit" - } - } -} -``` - -### 会话级(localStorage) -Key: `clawpanel-hosted-agent-sessions` -```json -{ - "agent:main:main": { - "enabled": true, - "prompt": "任务目标", - "autoRunAfterTarget": true, - "stopPolicy": "self", - "maxSteps": 50, - "stepDelayMs": 1200, - "retryLimit": 2, - "toolPolicy": "inherit", - "state": { - "status": "running", - "stepCount": 12, - "lastRunAt": 1710000000000, - "lastError": "" - }, - "history": [ - { "role": "system", "content": "初始提示词" }, - { "role": "assistant", "content": "托管 Agent 生成的指令" }, - { "role": "target", "content": "对面 Agent 回复" } - ] - } -} -``` - -## assistant-core 抽取清单 -新增:`src/lib/assistant-core.js` - -### 抽取项(从 assistant.js) -- API 适配:OpenAI/Anthropic/Gemini -- SSE 流解析与重试 -- 系统提示词构建 -- 工具声明、权限过滤、执行与安全检查 -- 上下文裁剪与会话数据工具 - -### 适配器注入 -- `api.*` 工具桥接 -- `confirm / ask_user` UI 适配器 -- `storage` 适配器 -- 图片存储适配器 - -### 保留在 assistant.js -- DOM 渲染与 UI 交互 -- toast/modal -- 视图与事件绑定 - -## 风险与保护 -- Gateway 断开:自动暂停 -- 连续失败:触发 error 状态 -- 最大步数:强制停止 -- 避免重复触发:运行中忽略新触发 - -## 测试要点 -- 启用后自动发送 -- 对面回复后自动继续 -- 切换会话/页面后仍生效 -- 停止策略与最大步数生效 diff --git a/docs/plans/2026-03-17-skillhub-env-fix-design.md b/docs/plans/2026-03-17-skillhub-env-fix-design.md deleted file mode 100644 index cbe492b3..00000000 --- a/docs/plans/2026-03-17-skillhub-env-fix-design.md +++ /dev/null @@ -1,65 +0,0 @@ -# SkillHub 动态探测与系统环境变量继承设计 - -日期: 2026-03-17 - -## 背景与目标 -- 现象: 页面提示已安装 SkillHub CLI,但检查仍显示未安装 -- 根因: ClawPanel 进程环境变量未刷新或 PATH 不完整,导致技能检测失败 -- 目标: - 1) SkillHub CLI 检测支持动态探测路径并返回版本与命中路径 - 2) 所有 Tauri 命令执行时继承完整系统环境变量(用户 + 系统) - -## 范围 -- 仅修改 ClawPanel Tauri 后端命令执行环境与 SkillHub 检测逻辑 -- 前端仅增加路径展示(若返回 path) - -## 方案对比 -### 方案 A -- 仅在 SkillHub 检测执行 `where skillhub` 进行路径探测 -- 其他命令仍使用当前进程环境 -- 缺点: 无法解决晴辰助手命令缺少系统环境变量的问题 - -### 方案 B(推荐) -- 增加统一系统环境构建函数,合并进程 env + Windows 用户/系统 env -- 所有 Tauri 命令使用该环境执行 -- SkillHub 检测失败时 `where skillhub` 探测并返回路径 -- 优点: 满足所有需求,改动集中 - -### 方案 C -- 增加 envPolicy 配置项(inherit/system/whitelist) -- 需要新 UI 与配置逻辑 -- 超出当前范围 - -## 设计细节 -### 1) 系统环境变量合并 -- 新增 `build_system_env()`: - - 读取当前进程 env - - 读取注册表: - - HKCU\Environment - - HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment - - 合并优先级: 进程 env 覆盖系统 env - - PATH 合并去重(用户 + 系统 + 进程) - -### 2) SkillHub 动态探测 -- `skills_skillhub_check` 流程: - 1) 直接尝试 `skillhub --version` - 2) 失败则执行 `where skillhub` - 3) 若命中路径,使用该路径执行 `--version` - 4) 返回 `{ installed: true, version, path }` - -### 3) 统一命令执行环境 -- 所有 `tokio::process::Command` 调用统一使用 `cmd.envs(build_system_env())` -- 现有 `enhanced_path()` 可改为调用 `build_system_env()` 或保留但不再使用 - -### 4) 前端展示 -- Skills 页面展示安装路径(若后端返回 path) -- 状态提示保持一致 - -## 错误处理 -- 注册表读取失败时回退到当前进程 env -- `where skillhub` 失败仍返回 `installed: false` - -## 测试要点 -- PATH 未刷新时: `skills_skillhub_check` 仍能识别已安装 -- 晴辰助手执行命令继承系统变量(如 PATH / HTTP_PROXY) -- UI 能展示 path 与版本 diff --git a/docs/plans/2026-03-17-toast-night-and-model-select-design.md b/docs/plans/2026-03-17-toast-night-and-model-select-design.md deleted file mode 100644 index da099118..00000000 --- a/docs/plans/2026-03-17-toast-night-and-model-select-design.md +++ /dev/null @@ -1,36 +0,0 @@ -# Toast 夜间样式与 Chat Model Select 设计 - -## 目标 -- Toast 夜间模式可见性提升,风格参考 Vercel 简约风 -- Chat 模型选择器不截断,宽度自适应内容 - -## Toast 夜间样式 -### 现状 -- Toast 使用统一背景与边框,夜间模式对比不足 - -### 方案(Vercel 简约风) -- 夜间模式下使用更深背景色与清晰边框 -- 保留状态色文字 - -### 设计细节 -- 选择器:`[data-theme="dark"] .toast` -- 背景:更深的卡片色(参考 Vercel 夜间卡片风格) -- 边框:`1px solid var(--border)` 保持一致性 -- 阴影:沿用当前中等阴影 - -## Chat Model Select -### 现状 -- `chat-model-select` 文字被截断 - -### 方案 -- 宽度随内容自适应,不做截断 - -### 设计细节 -- 选择器:`.chat-model-select` 或其输入容器 -- 移除固定宽度/最大宽度 -- 允许内容自然撑开 - -## 验收标准 -- 夜间模式下 toast 清晰可见,风格简约 -- chat-model-select 文字不再被截断 -- 不影响其他布局与交互 diff --git a/docs/plans/2026-03-17-toast-shadow-design.md b/docs/plans/2026-03-17-toast-shadow-design.md deleted file mode 100644 index 2ca9e07f..00000000 --- a/docs/plans/2026-03-17-toast-shadow-design.md +++ /dev/null @@ -1,18 +0,0 @@ -# Toast 阴影强度设计 - -## 目标 -- 为 toast 增加轻微阴影 -- 保持 Vercel 简约风格 -- 日夜主题自动适配 - -## 方案 -- 采用中等强度阴影:`0 4px 12px rgba(0,0,0,0.12)` - -## 设计细节 -- 选择器:`.toast` -- 在现有背景与边框基础上新增 `box-shadow` - -## 验收标准 -- 日间模式下 toast 清晰可见 -- 夜间模式不刺眼 -- 不影响布局与动画 diff --git a/docs/plans/2026-03-17-toast-vercel-design.md b/docs/plans/2026-03-17-toast-vercel-design.md deleted file mode 100644 index a4baaf5a..00000000 --- a/docs/plans/2026-03-17-toast-vercel-design.md +++ /dev/null @@ -1,27 +0,0 @@ -# Toast Vercel 简约风格设计 - -## 目标 -- 去掉液态玻璃/高斯模糊效果 -- 使用 Vercel 简约风格:纯色卡片 + 细边框 -- 日夜主题自动适配 - -## 现状 -- `.toast` 使用 `backdrop-filter: blur(12px)` -- 背景使用 `--success-muted / --error-muted / --info-muted / --warning-muted` - -## 方案 -- 移除 `backdrop-filter` -- 背景统一使用 `var(--bg-primary)` -- 边框统一使用 `1px solid var(--border)` -- 文本颜色维持状态色(success/error/info/warning) - -## 设计细节 -- 选择器:`.toast` -- 状态色:保留 `.toast.success/.error/.info/.warning` 的文字颜色 -- 背景色:`var(--bg-primary)` -- 边框:`1px solid var(--border)` - -## 验收标准 -- 日夜模式下 toast 不卡壳、无毛玻璃效果 -- 视觉风格与 Vercel 简约风格一致 -- 状态色可辨识 diff --git a/docs/plans/2026-03-17-tool-call-meta-design.md b/docs/plans/2026-03-17-tool-call-meta-design.md deleted file mode 100644 index ac32f148..00000000 --- a/docs/plans/2026-03-17-tool-call-meta-design.md +++ /dev/null @@ -1,32 +0,0 @@ -# 工具调用信息展示设计 - -## 目标 -- 工具调用框内显示调用时间(使用工具自身时间戳) -- 展开后始终显示参数与结果区,即使为空也提示 - -## 现状问题 -- 工具调用框无时间信息 -- 展开后无数据时区域为空,用户无法确认是否无返回 - -## 方案(已确认) -- 方案 A:显示工具自身时间戳 + 参数/结果占位 - -## 设计细节 -### 时间来源 -- 优先读取:`tool.end_time` / `tool.endTime` / `tool.timestamp` / `tool.time` / `tool.started_at` / `tool.startedAt` -- 若均为空:使用工具事件流 `event: agent` 的 `payload.ts`(按 `toolCallId` 映射) -- 仍为空时:显示 `时间未知` - -### 展开内容 -- `input` 为空时显示 `无参数` -- `output` 为空时显示 `无结果` -- 仍使用 `safeStringify` + `escapeHtml` - -### 结构 -- 标题行:`工具名 · 状态 · 时间` -- 展开块:参数与结果两个区块 - -## 验收标准 -- 工具调用框内显示时间 -- 展开后始终可见参数/结果区块 -- 无数据时显示占位文案 diff --git a/docs/plans/2026-03-17-ws-connect-bootstrap-design.md b/docs/plans/2026-03-17-ws-connect-bootstrap-design.md deleted file mode 100644 index 9ed6eef4..00000000 --- a/docs/plans/2026-03-17-ws-connect-bootstrap-design.md +++ /dev/null @@ -1,31 +0,0 @@ -# WS 连接后启动请求设计 - -## 目标 -- 连接成功后一次性发送官方同款 8 个请求 -- ping 间隔改为 5 秒 -- 重连后也执行一次 - -## 官方请求清单 -- agent.identity.get (sessionKey) -- agents.list -- health -- node.list -- device.pair.list -- chat.history (sessionKey, limit=200) -- sessions.list (includeGlobal/includeUnknown) -- models.list - -## 方案 -- 在 connect 成功处理函数中发送一组 req 帧 -- 每次重连也触发 -- ping 仍按 5 秒周期发送(保持已实现的多请求,启动批次会与首个 ping 重复一次) - -## 设计细节 -- 新增 `_sendBootstrapRequests()` -- 使用 `uuid()` 生成 id -- sessionKey 使用当前会话 `this._sessionKey`,缺省回退 `agent:full-stack-architect:main` - -## 验收标准 -- 每次连接成功后立即发送 8 个 req -- ping 间隔为 5 秒 -- 控制台无异常 diff --git a/docs/plans/2026-03-17-ws-ping-multi-req-design.md b/docs/plans/2026-03-17-ws-ping-multi-req-design.md deleted file mode 100644 index 230a7f26..00000000 --- a/docs/plans/2026-03-17-ws-ping-multi-req-design.md +++ /dev/null @@ -1,27 +0,0 @@ -# WS Ping 多请求设计 - -## 目标 -- 每次心跳发送 4 个 req:node.list / models.list / sessions.list / chat.history -- 保持连接存活并同步关键状态 - -## 现状 -- 心跳仅发送 node.list - -## 方案 -- 在 `_startPing` 中按顺序发送 4 个 req -- 复用现有 `uuid()` 生成 id -- sessions.list 参数:`includeGlobal: true`, `includeUnknown: true` -- chat.history 参数固定 `sessionKey: agent:full-stack-architect:main`, `limit: 200` - -## 设计细节 -- 帧格式统一:`{ type: "req", id, method, params }` -- 在同一计时周期内连续发送 4 条 req - -## 风险 -- 请求频率增加,可能带来负载波动 -- 若 Gateway 限流,需要再降频 - -## 验收标准 -- 每个周期触发 4 条 req -- 控制台无异常 -- Gateway 正常返回 diff --git a/docs/plans/2026-03-17-ws-ping-node-list-design.md b/docs/plans/2026-03-17-ws-ping-node-list-design.md deleted file mode 100644 index 593ca549..00000000 --- a/docs/plans/2026-03-17-ws-ping-node-list-design.md +++ /dev/null @@ -1,18 +0,0 @@ -# WS Ping 改为 node.list 设计 - -## 目标 -- 将 WebSocket 心跳从 `{ "type":"ping" }` 改为 `req node.list` -- 保持连接存活并提供实时节点状态 - -## 方案 -- 在 `_startPing` 中改为发送 `req` 帧 -- 维持原有间隔 - -## 设计细节 -- 帧格式:`{ type: "req", id: uuid(), method: "node.list", params: {} }` -- 仅替换 ping 发送内容 - -## 验收标准 -- 连接稳定 -- 控制台不报错 -- node.list 有返回 diff --git a/docs/superpowers/plans/2026-03-16-gateway-patch-auto-detect.md b/docs/superpowers/plans/2026-03-16-gateway-patch-auto-detect.md deleted file mode 100644 index 2d3149fe..00000000 --- a/docs/superpowers/plans/2026-03-16-gateway-patch-auto-detect.md +++ /dev/null @@ -1,74 +0,0 @@ -# Gateway Patch Auto-Detect Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Auto-detect global OpenClaw version changes at app start and settings entry, then reapply the gateway patch silently; show errors only in settings. - -**Architecture:** Add a small patch auto-detect runner in settings (client-side) and app bootstrap (Tauri setup) that calls gateway_patch_status and, on version mismatch, triggers gateway_patch_apply(force=true). Use in-memory throttle and 5-minute cooldown to avoid repeated runs. - -**Tech Stack:** Tauri (Rust), Vite/JS, ClawPanel config - ---- - -## Chunk 1: Backend status extensions - -### Task 1: Extend gateway_patch_status output - -**Files:** -- Modify: `src-tauri/src/commands/gateway_patch.rs` - -- [ ] **Step 1: Add version mismatch helper** - -Add a helper to compute `needs_repatch` by comparing `installed_version` with stored `gatewayPatch.openclawVersion` and include it in status output if desired. - -- [ ] **Step 2: Ensure force apply uses backup detection** - -If force=true and backups missing, return a clear error string: "缺少备份,建议先一键补丁". - -- [ ] **Step 3: Commit** - -``` -git add src-tauri/src/commands/gateway_patch.rs - -git commit -m "feat: add patch auto-detect helpers" -``` - -## Chunk 2: Frontend auto-detect logic - -### Task 2: App startup auto-detect - -**Files:** -- Modify: `src/main.js` - -- [ ] **Step 1: Add one-time auto-detect** - -Create a lightweight timer guard (module-scoped) and call `api.gatewayPatchStatus()` then `api.gatewayPatchApply(true)` when version mismatch is detected. Cooldown 5 minutes. - -- [ ] **Step 2: Commit** - -``` -git add src/main.js - -git commit -m "feat: auto-detect gateway patch at startup" -``` - -### Task 3: Settings page auto-detect - -**Files:** -- Modify: `src/pages/settings.js` - -- [ ] **Step 1: Add auto-detect on loadAll** - -After `loadGatewayPatch`, run auto-detect handler with a shared cooldown guard; update UI based on result. Do not show toast on success; show error in the card only. - -- [ ] **Step 2: Commit** - -``` -git add src/pages/settings.js - -git commit -m "feat: auto-detect gateway patch in settings" -``` - ---- - -Plan complete and saved to `docs/superpowers/plans/2026-03-16-gateway-patch-auto-detect.md`. Ready to execute? diff --git a/docs/superpowers/plans/2026-03-16-gateway-patch-oneclick.md b/docs/superpowers/plans/2026-03-16-gateway-patch-oneclick.md deleted file mode 100644 index 7c31cfb4..00000000 --- a/docs/superpowers/plans/2026-03-16-gateway-patch-oneclick.md +++ /dev/null @@ -1,169 +0,0 @@ -# Gateway One-Click Patch Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a one-click gateway patch flow in ClawPanel settings to apply/rollback sessionMessage patch for global npm OpenClaw installs. - -**Architecture:** Add a Tauri command that locates global npm OpenClaw, backs up dist files, applies patch, records status in clawpanel.json, and exposes status to the UI. Settings UI uses tauri-api bridge to render current patch status and provide apply/redo/rollback actions. - -**Tech Stack:** Tauri (Rust), Vite/JS, ClawPanel config (clawpanel.json) - ---- - -## Chunk 1: Backend patch command + config persistence - -### Task 1: Add gateway patch command - -**Files:** -- Create: `src-tauri/src/commands/gateway_patch.rs` -- Modify: `src-tauri/src/commands/mod.rs` -- Modify: `src-tauri/src/lib.rs` - -- [ ] **Step 1: Create gateway_patch.rs skeleton** - -```rust -use serde::{Deserialize, Serialize}; -use tauri::command; - -#[derive(Serialize, Deserialize)] -pub struct GatewayPatchStatus { - pub installed_version: Option, - pub patched: bool, - pub patched_version: Option, - pub patched_at: Option, - pub files: Vec, - pub last_error: Option, -} - -#[command] -pub async fn gateway_patch_status() -> Result { - Ok(GatewayPatchStatus { - installed_version: None, - patched: false, - patched_version: None, - patched_at: None, - files: vec![], - last_error: None, - }) -} - -#[command] -pub async fn gateway_patch_apply() -> Result { - gateway_patch_status().await -} - -#[command] -pub async fn gateway_patch_rollback() -> Result { - gateway_patch_status().await -} -``` - -- [ ] **Step 2: Register commands** - -Update `src-tauri/src/commands/mod.rs` to export the new functions, and `src-tauri/src/lib.rs` to include them in `invoke_handler`. - -- [ ] **Step 3: Locate global npm root** - -Implement helper in `gateway_patch.rs`: -- Run `npm root -g` -- Join `openclaw/dist` -- Detect `reply-*.js` and `gateway-cli-*.js` (choose latest by modified time) -- Return errors if not found - -- [ ] **Step 4: Apply patch with backup** - -Implement: -- Copy `reply-*.js` -> `.bak` -- Copy `gateway-cli-*.js` -> `.bak` -- Apply string-replace patches (same patterns used in manual patch) -- Validate file content contains `sessionMessage` post patch - -- [ ] **Step 5: Persist status in clawpanel.json** - -Use existing panel config read/write to store: - -```json -"gatewayPatch": { - "version": "sessionMessage-v1", - "patchedAt": "", - "openclawVersion": "", - "files": ["reply-*.js", "gateway-cli-*.js"], - "lastError": null -} -``` - -- [ ] **Step 6: Implement rollback** - -Restore from `.bak` files and update status. - -- [ ] **Step 7: Manual verification** - -Run: -``` -openclaw gateway status -``` -Expect: Gateway runs normally. - -- [ ] **Step 8: Commit** - -``` -git add src-tauri/src/commands/gateway_patch.rs src-tauri/src/commands/mod.rs src-tauri/src/lib.rs - -git commit -m "feat: add gateway patch commands" -``` - -## Chunk 2: UI + API bridge - -### Task 2: Add API bridge - -**Files:** -- Modify: `src/lib/tauri-api.js` - -- [ ] **Step 1: Add methods** - -```js -export const api = { - // ... - gatewayPatchStatus: () => invoke('gateway_patch_status'), - gatewayPatchApply: () => invoke('gateway_patch_apply'), - gatewayPatchRollback: () => invoke('gateway_patch_rollback') -} -``` - -- [ ] **Step 2: Commit** - -``` -git add src/lib/tauri-api.js - -git commit -m "feat: add gateway patch api bridge" -``` - -### Task 3: Settings UI - -**Files:** -- Modify: `src/pages/settings.js` (or `src/pages/settings-cloudflared.js` if the setting block lives there) -- Modify: `src/style/settings.css` (if needed) - -- [ ] **Step 1: Add Gateway 补丁卡片** -- Add status area: installed version, patched state, patched time -- Buttons: 一键补丁 / 重打补丁 / 回滚 - -- [ ] **Step 2: Wire buttons** -- Call `api.gatewayPatchApply()` / `api.gatewayPatchRollback()` -- Refresh status via `api.gatewayPatchStatus()` - -- [ ] **Step 3: Manual verification** -- Open settings page, ensure card renders -- Apply patch and verify status changes - -- [ ] **Step 4: Commit** - -``` -git add src/pages/settings.js src/style/settings.css - -git commit -m "feat: add gateway patch ui" -``` - ---- - -Plan complete and saved to `docs/superpowers/plans/2026-03-16-gateway-patch-oneclick.md`. Ready to execute? diff --git a/docs/superpowers/plans/2026-03-17-ai-config-import.md b/docs/superpowers/plans/2026-03-17-ai-config-import.md deleted file mode 100644 index f7bb8e3a..00000000 --- a/docs/superpowers/plans/2026-03-17-ai-config-import.md +++ /dev/null @@ -1,82 +0,0 @@ -# AI 配置从 openclaw 导入 Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 在 AI 配置页增加“从 openclaw 导入”按钮,导入 model/temperature/top_p/api_key/base_url。 - -**Architecture:** 前端按钮触发调用 Tauri API 读取 openclaw.json 并写回表单配置,完成后持久化保存。 - -**Tech Stack:** Vanilla JS, Tauri - ---- - -## Chunk 1: 后端读取 openclaw 配置 - -### Task 1: 新增导入命令 -**Files:** -- Modify: `src-tauri/src/commands/config.rs` - -- [ ] **Step 1: 新增 Tauri 命令** - -新增 `import_openclaw_ai_config`: -- 读取 openclaw.json -- 提取字段:model / temperature / top_p / api_key / base_url -- 返回 JSON - -- [ ] **Step 2: 注册命令** - -在 `src-tauri/src/lib.rs` 注册命令。 - -- [ ] **Step 3: 提交** -```bash -git add src-tauri/src/commands/config.rs src-tauri/src/lib.rs -git commit -m "feat: add ai config import command" -``` - ---- - -## Chunk 2: 前端按钮与写回 - -### Task 2: AI 配置页导入 -**Files:** -- Modify: `src/lib/tauri-api.js` -- Modify: `src/pages/models.js` - -- [ ] **Step 1: 添加 API 封装** - -tauri-api.js 增加 `importOpenclawAiConfig`. - -- [ ] **Step 2: UI 按钮与写回逻辑** - -models.js 增加按钮,点击后: -- 调用 API -- 写回表单 -- 保存当前配置 - -- [ ] **Step 3: 提交** -```bash -git add src/lib/tauri-api.js src/pages/models.js -git commit -m "feat: import ai config from openclaw" -``` - ---- - -## Chunk 3: 构建与验证 - -### Task 3: 构建 -**Files:** 无 - -- [ ] **Step 1: 构建** -```bash -npm run build -``` - -- [ ] **Step 2: 手工验证** -- openclaw.json 存在 → 导入成功 -- 字段缺失 → 提示失败 -- 保存后配置生效 - -- [ ] **Step 3: 推送** -```bash -git push -``` diff --git a/docs/superpowers/plans/2026-03-17-assistant-optimize-toggle.md b/docs/superpowers/plans/2026-03-17-assistant-optimize-toggle.md deleted file mode 100644 index d0d38ed0..00000000 --- a/docs/superpowers/plans/2026-03-17-assistant-optimize-toggle.md +++ /dev/null @@ -1,52 +0,0 @@ -# 优化/还原按钮切换 Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 优化与还原按钮互斥显示,默认显示“优化”,优化后显示“还原”,发送或还原后切回“优化”。 - -**Architecture:** 根据 `_optOriginalText` 状态控制按钮显隐与禁用。 - -**Tech Stack:** Vanilla JS - ---- - -## Chunk 1: 逻辑调整 - -### Task 1: updateOptimizeState 互斥显示 -**Files:** -- Modify: `src/pages/assistant.js` - -- [ ] **Step 1: 更新 updateOptimizeState** - -逻辑: -- `_optOriginalText` 为 null → 显示优化按钮,隐藏还原按钮 -- `_optOriginalText` 非空 → 隐藏优化按钮,显示还原按钮 - -- [ ] **Step 2: 按钮点击后切换** - -- 优化完成 → 切到还原 -- 点击还原 → 清空快照并切回优化 -- 发送 → 清空快照并切回优化 - -- [ ] **Step 3: 提交** -```bash -git add src/pages/assistant.js -git commit -m "fix: toggle optimize and restore buttons" -``` - ---- - -## Chunk 2: 构建与推送 - -### Task 2: 构建 -**Files:** 无 - -- [ ] **Step 1: 构建** -```bash -npm run build -``` - -- [ ] **Step 2: 推送** -```bash -git push -``` diff --git a/docs/superpowers/plans/2026-03-17-assistant-ux-and-shell.md b/docs/superpowers/plans/2026-03-17-assistant-ux-and-shell.md deleted file mode 100644 index 1946944a..00000000 --- a/docs/superpowers/plans/2026-03-17-assistant-ux-and-shell.md +++ /dev/null @@ -1,118 +0,0 @@ -# Assistant UX + Windows Shell 优化 Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 实现 Windows shell 优先级、复制按钮样式统一、AI 助手输入区新增优化/恢复功能并保留撤销栈。 - -**Architecture:** 后端 assistant_exec 增加 shell 探测与降级;前端统一 code-copy-btn CSS;AI 助手输入区新增优化调用与快照管理。 - -**Tech Stack:** Rust (Tauri), Vanilla JS, CSS - ---- - -## Chunk 1: Windows shell 优先级 - -### Task 1: assistant_exec 使用 pwsh 优先级 -**Files:** -- Modify: `src-tauri/src/commands/assistant.rs` - -- [ ] **Step 1: 增加 shell 探测函数** - -在 Windows 分支增加一个 `detect_windows_shell()`: -- 依次检查 `pwsh`、`powershell` -- 都不可用则返回 `cmd` - -实现方式:使用 `where` 探测,并使用 `build_system_env()` 注入完整环境。 - -- [ ] **Step 2: 替换执行逻辑** - -`assistant_exec` 使用探测结果执行: -- pwsh / powershell:`-NoProfile -Command ` -- cmd:`/c ` - -- [ ] **Step 3: 提交** -```bash -git add src-tauri/src/commands/assistant.rs -git commit -m "feat: prefer pwsh in assistant exec" -``` - ---- - -## Chunk 2: 复制按钮错位修复(CSS) - -### Task 2: 统一 chat.css 与 assistant.css -**Files:** -- Modify: `src/style/chat.css` -- Modify: `src/style/assistant.css` - -- [ ] **Step 1: 统一 pre 与 copy 按钮样式** - -将 assistant.css 中 pre 样式改为与 chat.css 一致,确保: -- `.code-copy-btn` 右上角悬浮 -- hover 才显示 -- 不改 Markdown 解析逻辑 - -- [ ] **Step 2: 提交** -```bash -git add src/style/chat.css src/style/assistant.css -git commit -m "fix: align code copy button styles" -``` - ---- - -## Chunk 3: AI 优化按钮 - -### Task 3: 输入区新增优化/恢复按钮 -**Files:** -- Modify: `src/pages/assistant.js` - -- [ ] **Step 1: 增加按钮 DOM** - -在输入区加入 “优化” 与 “恢复原文” 按钮。 - -- [ ] **Step 2: 维护快照状态** - -新增变量:`_optOriginalText` / `_optOptimizedText` -规则: -- 点击优化:保存原文快照,写入优化结果快照 -- 点击恢复:恢复原文 -- 发送成功后清空快照 - -- [ ] **Step 3: 调用同模型在线重写** - -复用现有模型调用逻辑: -- 模板:`请在不改变原意和语言的前提下,重写为意思更清晰、更简洁的表达。` -- 使用同模型 -- 结果直接替换输入框内容 - -- [ ] **Step 4: setRangeText 触发 input 事件** - -使用 `textarea.setRangeText()` + 触发 `input` 事件,保证 Ctrl+Z 生效。 - -- [ ] **Step 5: 提交** -```bash -git add src/pages/assistant.js -git commit -m "feat: add optimize and restore buttons" -``` - ---- - -## Chunk 4: 构建与验证 - -### Task 4: 构建 -**Files:** 无 - -- [ ] **Step 1: 构建** -```bash -npm run build -``` - -- [ ] **Step 2: 手工验证** -- Windows 下执行命令优先 pwsh -- 复制按钮位置正确 -- 优化/恢复可用且 Ctrl+Z 正常 - -- [ ] **Step 3: 推送** -```bash -git push -``` diff --git a/docs/superpowers/plans/2026-03-17-chat-autoscroll.md b/docs/superpowers/plans/2026-03-17-chat-autoscroll.md deleted file mode 100644 index 5a0a5399..00000000 --- a/docs/superpowers/plans/2026-03-17-chat-autoscroll.md +++ /dev/null @@ -1,132 +0,0 @@ -# Chat Auto-Scroll Gating Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Only auto-scroll the chat view when new messages arrive and the user is at the bottom; never force-scroll while the user is reading history. - -**Architecture:** Add a single auto-scroll gate to `chat.js` that tracks whether the user is at the bottom. Trigger scrolling only on message insertion and stream updates when the gate is enabled. Avoid auto-scroll in render loops to prevent continuous snapping. - -**Tech Stack:** Vanilla JS, DOM APIs, existing chat page logic in `src/pages/chat.js`. - ---- - -## Chunk 1: Auto-scroll gating - -### Task 1: Add auto-scroll state and update on scroll - -**Files:** -- Modify: `src/pages/chat.js` - -- [ ] **Step 1: Add state flags near other module-level state** - -Add: -```js -let _autoScrollEnabled = true -``` - -- [ ] **Step 2: Update auto-scroll flag on scroll** - -In the `_messagesEl.addEventListener('scroll', ...)` handler, update state based on `isAtBottom()`: -```js -_messagesEl.addEventListener('scroll', () => { - const { scrollTop, scrollHeight, clientHeight } = _messagesEl - _scrollBtn.style.display = (scrollHeight - scrollTop - clientHeight < 80) ? 'none' : 'flex' - _autoScrollEnabled = isAtBottom() -}) -``` - -- [ ] **Step 3: Ensure scroll button restores auto-scroll** - -When the user clicks the scroll-to-bottom button, set `_autoScrollEnabled = true` after moving to bottom: -```js -_scrollBtn.addEventListener('click', () => { - _autoScrollEnabled = true - scrollToBottom(true) -}) -``` - -### Task 2: Gate auto-scroll to message insertion - -**Files:** -- Modify: `src/pages/chat.js` - -- [ ] **Step 1: Update `scrollToBottom` to respect gating** - -Change function signature and behavior: -```js -function scrollToBottom(force = false) { - if (!_messagesEl) return - if (!force && !_autoScrollEnabled) return - requestAnimationFrame(() => { _messagesEl.scrollTop = _messagesEl.scrollHeight }) -} -``` - -- [ ] **Step 2: Ensure scroll is invoked only on new message insertion** - -Keep `scrollToBottom()` calls only in: -- `appendUserMessage` -- `appendAiMessage` -- `appendSystemMessage` -- `createStreamBubble` -- `showTyping(true)` (but it will now respect gate) - -Remove or avoid unconditional scrolling in render loops. - -- [ ] **Step 3: Stop continuous auto-scroll in render loops** - -In `doRender` remove the unconditional `scrollToBottom()` or guard it by auto-scroll: -```js -if (_currentAiBubble && _currentAiText) { - _currentAiBubble.innerHTML = renderMarkdown(_currentAiText) - scrollToBottom() -} -``` -(With gated `scrollToBottom`, it will only happen when user is at bottom.) - -- [ ] **Step 4: Guard virtual render bottom snapping** - -In `doVirtualRender`, only snap to bottom when `_autoScrollEnabled` is true: -```js -if (atBottom && _autoScrollEnabled) { - scrollToBottom() -} -``` - -### Task 3: Validate streaming behavior - -**Files:** -- Modify: `src/pages/chat.js` - -- [ ] **Step 1: Ensure stream updates do not force-scroll while reading history** - -Verify `doRender` and `createStreamBubble` only scroll when `_autoScrollEnabled` is true. - -### Task 4: Manual verification and build - -**Files:** -- None - -- [ ] **Step 1: Manual verification checklist** - -Checklist: -- Open chat page with existing history -- Scroll up; verify the view stays in place (no automatic snapping) -- Send a new message while scrolled up; verify it does not force-scroll -- Scroll to bottom and send/receive a message; verify it auto-scrolls -- Click the scroll-to-bottom button; verify it jumps and re-enables auto-scroll - -- [ ] **Step 2: Build** - -Run: -```powershell -npm run build -``` -Expected: build succeeds with no errors. - -- [ ] **Step 3: Commit** - -```powershell -git add src\pages\chat.js - -git commit -m "fix: gate chat auto-scroll on new messages" -``` diff --git a/docs/superpowers/plans/2026-03-17-chat-daylight-shadow.md b/docs/superpowers/plans/2026-03-17-chat-daylight-shadow.md deleted file mode 100644 index 92195288..00000000 --- a/docs/superpowers/plans/2026-03-17-chat-daylight-shadow.md +++ /dev/null @@ -1,46 +0,0 @@ -# Chat Daylight Shadow Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a light-mode shadow to assistant message bubbles so they remain visible against the page background. - -**Architecture:** Update chat CSS to apply a light-mode-only shadow on `.msg-ai .msg-bubble` without changing dark mode. No layout or markup changes. - -**Tech Stack:** CSS, Vite build - ---- - -## Chunk 1: Daylight shadow style - -### Task 1: Add light-mode shadow for assistant bubbles - -**Files:** -- Modify: `src/style/chat.css` (near `.msg-ai .msg-bubble` rules) - -- [ ] **Step 1: Add light-mode CSS rule** - -Add a new rule scoped to light theme: - -```css -[data-theme="light"] .msg-ai .msg-bubble { - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); -} -``` - -- [ ] **Step 2: Build** - -Run: `npm run build` -Expected: Build succeeds without errors. - -- [ ] **Step 3: Commit** - -```bash -git add src/style/chat.css -git commit -m "fix: add daylight shadow for ai bubble" -``` - -- [ ] **Step 4: Push** - -```bash -git push -``` diff --git a/docs/superpowers/plans/2026-03-17-chat-history-tool-merge.md b/docs/superpowers/plans/2026-03-17-chat-history-tool-merge.md deleted file mode 100644 index ded067e3..00000000 --- a/docs/superpowers/plans/2026-03-17-chat-history-tool-merge.md +++ /dev/null @@ -1,81 +0,0 @@ -# Chat History Tool Merge Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Fix chat history parsing so tool cards merge by toolCallId, show above text, and sort by time with correct fallback. - -**Architecture:** Merge tool blocks via upsertTool, resolve tool time from event ts or message timestamp, and suppress tool system messages during history parsing. Render tools and text as entries sorted by time (tools first when time ties). - -**Tech Stack:** React, JS - ---- - -## Chunk 1: Parsing + ordering adjustments - -### Task 1: Add time resolver + merge tool entries - -**Files:** -- Modify: `src/pages/chat.js` - -- [ ] **Step 0: Checkpoint(PowerShell)** - -```powershell -git status -sb -git commit --allow-empty -m "chore: checkpoint before history tool merge" -``` - -- [ ] **Step 1: Add tool time resolver** - -Add helper near tool utilities: - -```js -function resolveToolTime(toolId, messageTimestamp) { - const eventTs = toolId ? _toolEventTimes.get(toolId) : null - return eventTs || messageTimestamp || null -} -``` - -- [ ] **Step 2: Use upsertTool in history parsing** - -In `extractChatContent` and `extractContent`, replace `tools.push` with `upsertTool` for toolCall/toolResult blocks so toolCallId merges. - -- [ ] **Step 3: Apply time fallback to tools** - -When building tool entries, set `time: resolveToolTime(id, message.timestamp)`. - -- [ ] **Step 4: Suppress tool system messages in history** - -When processing history responses, skip `appendSystemMessage` for tool events, only render tool cards. - -- [ ] **Step 5: Sort entries by time with tool-first tie** - -In rendering pipeline, build `entries` combining tools and text, then sort: - -```js -entries.sort((a, b) => { - const ta = a.time ?? 0 - const tb = b.time ?? 0 - if (ta !== tb) return ta - tb - if (a.kind === 'tool' && b.kind !== 'tool') return -1 - if (a.kind !== 'tool' && b.kind === 'tool') return 1 - return 0 -}) -``` - -- [ ] **Step 6: Build** - -Run: `npm run build` -Expected: Build succeeds without errors. - -- [ ] **Step 7: Commit** - -```powershell -git add src\pages\chat.js -git commit -m "fix: history tool merge and ordering" -``` - -- [ ] **Step 8: Push** - -```powershell -git push -``` diff --git a/docs/superpowers/plans/2026-03-17-chat-markdown-it.md b/docs/superpowers/plans/2026-03-17-chat-markdown-it.md deleted file mode 100644 index 2256a8de..00000000 --- a/docs/superpowers/plans/2026-03-17-chat-markdown-it.md +++ /dev/null @@ -1,146 +0,0 @@ -# Chat Markdown-it Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the chat Markdown renderer with markdown-it plus plugins to support underline, spoiler, and mention, matching GitHub-like behavior. - -**Architecture:** Use markdown-it with html disabled, link validation, custom renderer for code blocks using existing highlightCode. Add local plugins for spoiler (|| || + >! !<), mention (@user), and a custom underline rule that maps __text__ to . - -**Tech Stack:** JS, Vite - ---- - -## Chunk 1: Dependencies + renderer refactor - -### Task 1: Add markdown-it and plugin deps - -**Files:** -- Modify: `package.json` - -- [ ] **Step 0: Checkpoint(PowerShell)** - -```powershell -git status -sb -git commit --allow-empty -m "chore: checkpoint before markdown-it" -``` - -- [ ] **Step 1: Add dependencies** - -Add to dependencies: -- markdown-it - -- [ ] **Step 2: Install** - -```powershell -npm install -``` - -- [ ] **Step 3: Commit deps** - -```powershell -git add package.json package-lock.json -git commit -m "chore: add markdown-it deps" -``` - -### Task 2: Replace renderer - -**Files:** -- Modify: `src/lib/markdown.js` - -- [ ] **Step 1: Instantiate markdown-it** - -```js -import MarkdownIt from 'markdown-it' -``` - -```js -const md = new MarkdownIt({ - html: false, - linkify: true, - breaks: false, - highlight: (code, lang) => { - const highlighted = highlightCode(code, lang) - const langLabel = lang ? `${escapeHtml(lang)}` : '' - return `
${langLabel}${highlighted}
` - }, -}) -``` - -- [ ] **Step 1.1: Underline plugin (`__text__` -> ``)** - -Implement a custom inline rule to parse double-underscore into `` and keep `**` for bold. - -- [ ] **Step 2: Link whitelist** - -Override link renderer to allow only http/https/mailto, otherwise href="#". - -- [ ] **Step 3: Spoiler plugin** - -Implement custom plugin to parse: -- `||spoiler||` -- `>!spoiler!<` -Output: `...` - -- [ ] **Step 4: Mention plugin** - -Parse `@username` into `@username`. - -- [ ] **Step 5: Update renderMarkdown** - -Replace manual parsing with `md.render(text)`. - -## Chunk 2: Styling - -### Task 3: Add styles - -**Files:** -- Modify: `src/style/chat.css` or `src/style/components.css` - -- [ ] **Step 1: Add mention style** - -```css -.msg-mention { - color: var(--accent); - font-weight: 600; -} -``` - -- [ ] **Step 2: Add spoiler style** - -```css -.msg-spoiler { - background: currentColor; - color: transparent; - border-radius: 4px; - padding: 0 4px; - cursor: pointer; -} -.msg-spoiler.revealed { - color: inherit; - background: rgba(0,0,0,0.12); -} -``` - -- [ ] **Step 3: Add click handler** - -In chat init or render, add event delegation to toggle `revealed` class on `.msg-spoiler`. - -## Chunk 3: Build + Commit - -- [ ] **Step 1: Build** - -Run: `npm run build` -Expected: Build succeeds without errors. - -- [ ] **Step 2: Commit** - -```powershell -git add src\lib\markdown.js src\style\chat.css src\style\components.css -git commit -m "feat: markdown-it rendering" -``` - -- [ ] **Step 3: Push** - -```powershell -git push -``` diff --git a/docs/superpowers/plans/2026-03-17-chat-tool-event-live.md b/docs/superpowers/plans/2026-03-17-chat-tool-event-live.md deleted file mode 100644 index 0127906e..00000000 --- a/docs/superpowers/plans/2026-03-17-chat-tool-event-live.md +++ /dev/null @@ -1,97 +0,0 @@ -# Chat Tool Event Live Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Display tool events as live system messages ordered by payload.ts and de-duplicated by payload.runId + toolCallId. - -**Architecture:** Extend chat event handling to create tool-event system messages and insert into DOM by timestamp; add dedupe map keyed by ts+toolCallId. - -**Tech Stack:** Vanilla JS, CSS, Vite build - ---- - -## Chunk 1: Tool event live insertion - -### Task 1: Add dedupe and insert-by-time - -**Files:** -- Modify: `src/pages/chat.js` - -- [ ] **Step 0: Checkpoint(PowerShell)** - -```powershell -git status -sb -git commit --allow-empty -m "chore: checkpoint before tool event live" -``` - -Note: This checkpoint is required by policy; final functional commit occurs after build. - -- [ ] **Step 1: Add maps for dedupe and event list** - -Add near top-level state: - -```js -const _toolEventSeen = new Set() -``` - -- [ ] **Step 2: Insert helper for ordered messages** - -Add a helper to insert a message wrapper by timestamp: - -```js -function insertMessageByTime(wrap, ts) { - const tsValue = Number(ts || Date.now()) - wrap.dataset.ts = String(tsValue) - const items = Array.from(_messagesEl.querySelectorAll('.msg')) - for (const node of items) { - const nodeTs = parseInt(node.dataset.ts || '0', 10) - if (nodeTs > tsValue) { - _messagesEl.insertBefore(wrap, node) - return - } - } - _messagesEl.insertBefore(wrap, _typingEl) -} -``` - -- [ ] **Step 3: Add tool-event system message builder** - -```js -function appendToolEventMessage(name, phase, ts, isError) { - const wrap = document.createElement('div') - wrap.className = 'msg msg-system' - wrap.textContent = `${name} · ${phase}${isError ? ' · 失败' : ''}` - insertMessageByTime(wrap, ts) -} -``` - -- [ ] **Step 4: Handle tool events in handleEvent** - -```js -if (event === 'agent' && payload?.stream === 'tool' && payload?.data?.toolCallId) { - const key = `${payload.runId}:${payload.data.toolCallId}` - if (_toolEventSeen.has(key)) return - _toolEventSeen.add(key) - const name = payload.data.name || '工具' - const phase = payload.data.phase || 'unknown' - appendToolEventMessage(name, phase, payload.ts, payload.data.isError) -} -``` - -- [ ] **Step 5: Build** - -Run: `npm run build` -Expected: Build succeeds without errors. - -- [ ] **Step 6: Commit(PowerShell)** - -```powershell -git add src/pages/chat.js -git commit -m "fix: show live tool events" -``` - -- [ ] **Step 7: Push(PowerShell)** - -```powershell -git push -``` diff --git a/docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md b/docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md deleted file mode 100644 index 57c39dc0..00000000 --- a/docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md +++ /dev/null @@ -1,211 +0,0 @@ -# Chat Virtual Scroll Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Implement virtual scrolling for chat messages with fixed window size (40 + overscan 20), fast first paint, and stable scroll anchoring. - -**Architecture:** Use a virtualized list with top/bottom spacers and total height based on cumulative measured heights (fallback to average). Range calculation uses cumulative heights (prefix sums + binary search) with a fixed window cap. Preserve anchor when not at bottom. - -**Tech Stack:** JS, Vite - ---- - -## File Map -- Modify: `src/pages/chat.js:64-2000` (virtual state, scroll handler, render path) -- Create: `src/lib/virtual-scroll.js` (range + prefix height helpers) -- Create: `tests/virtual-scroll.test.js` -- Modify: `package.json` (test script, devDependency) - ---- - -## Chunk 1: Test scaffolding + helpers (TDD) - -### Task 1: Add test tooling - -**Files:** -- Modify: `package.json` - -- [ ] **Step 0: Checkpoint(PowerShell)** - -```powershell -git status -sb -git commit --allow-empty -m "chore: checkpoint before chat virtual scroll" -``` - -- [ ] **Step 1: Add dev dependency and script** - -Add: -- `devDependencies.vitest` -- `scripts.test = "vitest run"` - -- [ ] **Step 2: Install** - -```powershell -npm install -``` - -### Task 2: Create helper module - -**Files:** -- Create: `src/lib/virtual-scroll.js` - -- [ ] **Step 1: Implement helpers** - -```js -export function getItemHeight(items, idx, heights, avgHeight) { - const id = items[idx]?.id - return heights.get(id) || avgHeight -} - -export function buildPrefixHeights(items, heights, avgHeight) { - const prefix = [0] - for (let i = 0; i < items.length; i++) { - prefix[i + 1] = prefix[i] + getItemHeight(items, i, heights, avgHeight) - } - return prefix -} - -export function findStartIndex(prefix, scrollTop) { - let lo = 0, hi = prefix.length - 1 - while (lo < hi) { - const mid = Math.floor((lo + hi) / 2) - if (prefix[mid] <= scrollTop) lo = mid + 1 - else hi = mid - } - return Math.max(0, lo - 1) -} - -export function computeVirtualRange(items, scrollTop, viewportHeight, avgHeight, overscan, windowSize, heights) { - const prefix = buildPrefixHeights(items, heights, avgHeight) - const start = Math.max(0, findStartIndex(prefix, scrollTop) - overscan) - let end = Math.min(items.length, start + windowSize + overscan * 2) - // 固定窗口:严格限制 end-start 不超过 windowSize + overscan*2 - return { start, end, prefix } -} - -export function getSpacerHeights(prefix, start, end) { - const top = prefix[start] - const total = prefix[prefix.length - 1] - const bottom = Math.max(0, total - prefix[end]) - return { top, bottom, total } -} -``` - -### Task 3: Add tests (TDD) - -**Files:** -- Create: `tests/virtual-scroll.test.js` - -- [ ] **Step 1: Write failing tests** - -```js -import { describe, it, expect } from 'vitest' -import { buildPrefixHeights, computeVirtualRange, getSpacerHeights } from '../src/lib/virtual-scroll.js' - -describe('virtual scroll helpers', () => { - it('builds prefix heights with avg fallback', () => { - const items = [{ id: 'a' }, { id: 'b' }, { id: 'c' }] - const heights = new Map([['b', 80]]) - const prefix = buildPrefixHeights(items, heights, 50) - expect(prefix).toEqual([0, 50, 130, 180]) - }) - - it('computes range with window cap', () => { - const items = Array.from({ length: 200 }, (_, i) => ({ id: String(i) })) - const heights = new Map() - const { start, end } = computeVirtualRange(items, 0, 600, 30, 20, 40, heights) - expect(end - start).toBeLessThanOrEqual(80) - }) - - it('spacer heights sum to total', () => { - const prefix = [0, 50, 100, 150] - const { top, bottom, total } = getSpacerHeights(prefix, 1, 2) - expect(top + bottom + (prefix[2] - prefix[1])).toBe(total) - }) -}) -``` - -- [ ] **Step 2: Run tests (expect FAIL)** - -```powershell -npm run test -``` -Expected: FAIL if helpers not implemented. - -- [ ] **Step 3: Implement helpers (Step 2) then re-run tests (expect PASS)** - -```powershell -npm run test -``` - -- [ ] **Step 4: Commit helpers + tests** - -```powershell -git add src\lib\virtual-scroll.js tests\virtual-scroll.test.js package.json package-lock.json -git commit -m "test: add virtual scroll helpers" -``` - ---- - -## Chunk 2: Integrate virtual scroll into chat - -### Task 4: Add state + range calc - -**Files:** -- Modify: `src/pages/chat.js:64-2000` - -- [ ] **Step 1: Add constants + state** - -```js -const VIRTUAL_WINDOW = 40 -const VIRTUAL_OVERSCAN = 20 -let _virtualEnabled = true -let _virtualHeights = new Map() -let _virtualAvgHeight = 64 -let _virtualRange = { start: 0, end: 0, prefix: [0] } -``` - -- [ ] **Step 2: Import helpers** - -```js -import { computeVirtualRange, getSpacerHeights } from '../lib/virtual-scroll.js' -``` - -- [ ] **Step 3: Scroll handler** - -On scroll, compute range using `computeVirtualRange(items, scrollTop, viewportHeight, _virtualAvgHeight, VIRTUAL_OVERSCAN, VIRTUAL_WINDOW, _virtualHeights)` and update `_virtualRange` when changed. - -### Task 5: Render with spacers + measurement - -**Files:** -- Modify: `src/pages/chat.js:1380-1750` - -- [ ] **Step 1: Render spacers + window** - -Insert: -- top spacer with height = `getSpacerHeights(prefix, start, end).top` -- visible items = `items.slice(start, end)` -- bottom spacer with height = `getSpacerHeights(prefix, start, end).bottom` - -- [ ] **Step 2: Measure heights** - -After render (requestAnimationFrame), measure visible `.msg` nodes using `getBoundingClientRect().height`, update `_virtualHeights`, and recompute `_virtualAvgHeight`. - -- [ ] **Step 3: Anchor strategy** - -If user is at bottom (within 80px), auto scroll to bottom after new message. Otherwise, preserve scroll position by capturing `scrollTop` before re-render and adjusting by delta in top spacer height. - -### Task 6: Build - -```powershell -npm run build -``` -Expected: Build succeeds without errors. - -### Task 7: Commit + Push - -```powershell -git add src\pages\chat.js -git commit -m "feat: chat virtual scroll" -git push -``` diff --git a/docs/superpowers/plans/2026-03-17-cron-trigger-mode.md b/docs/superpowers/plans/2026-03-17-cron-trigger-mode.md deleted file mode 100644 index 3009d683..00000000 --- a/docs/superpowers/plans/2026-03-17-cron-trigger-mode.md +++ /dev/null @@ -1,86 +0,0 @@ -# Cron 触发模式扩展 Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 让 sessionMessage 任务支持两种触发模式:按 cron 执行或监听指定会话 agent 任务结束后发送。 - -**Architecture:** 在 cron.js 中为 sessionMessage 任务增加触发模式字段(cron | onIdle),本地存储与渲染读取该字段;onIdle 模式基于 wsClient 事件跟踪目标会话的 run 状态,任务结束且空闲即发送;cron 模式保留现有定时器。非 sessionMessage 任务仍走 Gateway。 - -**Tech Stack:** ClawPanel (Vite + JS), WebSocket client, Tauri panel config API - ---- - -## Chunk 1: 数据模型与 UI - -### Task 1: 增加触发模式字段 - -**Files:** -- Modify: `src/pages/cron.js` - -- [ ] **Step 1: 新增字段** - -为 sessionMessage 本地任务新增 `triggerMode` 字段:`cron` | `onIdle`。 - -- [ ] **Step 2: UI 选择** - -在 sessionMessage 任务编辑弹窗加入触发模式选择: -- 选项:`按 Cron` / `监听任务结束` -- 选择 onIdle 时隐藏 cron 输入,显示说明:监听目标会话任务结束后发送。 - -- [ ] **Step 3: 列表展示** - -列表中显示触发模式: -- cron 显示 cron 文本 -- onIdle 显示 “任务结束后发送” - -- [ ] **Step 4: Commit** - -```bash -git add src/pages/cron.js -git commit -m "feat: add sessionMessage trigger mode" -``` - -## Chunk 2: 触发逻辑 - -### Task 2: cron 与 onIdle 双模式发送 - -**Files:** -- Modify: `src/pages/cron.js` - -- [ ] **Step 1: cron 触发** - -仅当 `triggerMode === 'cron'` 时参与 `tickSessionMessageJobs` 逻辑。 - -- [ ] **Step 2: onIdle 触发** - -新增 `checkIdleTrigger(job)`: -- 若 `triggerMode === 'onIdle'`,当目标会话从 active -> idle 且当前未发送过本轮,发送消息并记录 `lastRunAtMs`。 - -- [ ] **Step 3: 去重** - -使用 `state.lastRunAtMs` 或 `state.lastIdleAtMs` 避免重复发送。 - -- [ ] **Step 4: Commit** - -```bash -git add src/pages/cron.js -git commit -m "feat: onIdle trigger for sessionMessage" -``` - -## Chunk 3: 验证 - -### Task 3: Build 与手动验证 - -- [ ] **Step 1: Build** - -```bash -npm run build -``` - -- [ ] **Step 2: 验证** - -- 新建 sessionMessage 任务,选择 cron → 定时发送生效 -- 新建 sessionMessage 任务,选择 onIdle → 会话任务结束后发送 -- 非 sessionMessage 任务仍通过 Gateway 保存/触发 - ---- diff --git a/docs/superpowers/plans/2026-03-17-cron-ws-sessionmessage.md b/docs/superpowers/plans/2026-03-17-cron-ws-sessionmessage.md deleted file mode 100644 index 008cbde8..00000000 --- a/docs/superpowers/plans/2026-03-17-cron-ws-sessionmessage.md +++ /dev/null @@ -1,199 +0,0 @@ -# Cron WS SessionMessage Replacement Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace gateway patch–based cron sessionMessage with a client-side scheduler that sends user messages over the already-connected WSS only after the target session is idle/completed. - -**Architecture:** Store scheduled jobs in panel config (local), run a lightweight scheduler in ClawPanel (cron.js) that waits for gatewayReady and session idle state, then dispatch wsClient.chatSend. Track run state and last-run time in panel config to avoid duplicate sends. - -**Tech Stack:** Frontend JS (cron.js, ws-client.js), panel config (read/write via tauri-api), WebSocket RPC (sessions.list, sessions.get, chat events). - ---- - -## File Structure - -**Modify:** -- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\src\pages\cron.js` (UI, local scheduler, local job storage) -- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\src\lib\tauri-api.js` (panel config helpers) -- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\src\lib\ws-client.js` (optional: expose session idle state helper) -- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\src\pages\chat.js` (optional: emit idle status events) -- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\src\main.js` (optional: start scheduler on boot) - -**Create:** -- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\docs\superpowers\plans\2026-03-17-cron-ws-sessionmessage.md` (this plan) - ---- - -## Task 1: Define local cron job schema and storage - -**Files:** -- Modify: `src/pages/cron.js` -- Modify: `src/lib/tauri-api.js` - -- [ ] **Step 1: Add local job schema** - -Define a new `localCronJobs` array in panel config, each job: -``` -{ - id: "uuid", - name: "string", - schedule: { kind: "cron", expr: "* * * * *" }, - enabled: true, - payload: { - kind: "sessionMessage", - label: "sessionLabel", - message: "text", - waitForIdle: true - }, - state: { lastRunAtMs: 0, lastStatus: "ok|error|skipped", lastError: "" } -} -``` - -- [ ] **Step 2: Add API helpers for panel config** - -In `tauri-api.js`, add helpers: -``` -readPanelConfig() -writePanelConfig() -``` -Ensure cron.js can read/write panel config locally without gateway. - -- [ ] **Step 3: Implement load/save local jobs** - -In cron.js: -- On render, load panel config and initialize local jobs list if missing. -- Use `localJobs` as a separate tab/section from gateway jobs. - ---- - -## Task 2: Update Cron UI for sessionMessage-only mode - -**Files:** -- Modify: `src/pages/cron.js` - -- [ ] **Step 1: Replace task type selector** - -Remove gateway cron payload kind selector. Only show “发送 user 消息(WSS)”. - -- [ ] **Step 2: Show required fields** - -Show inputs: -- name -- schedule (cron) -- sessionLabel (from sessions.list) -- message (textarea) -- enabled toggle -- waitForIdle toggle - -- [ ] **Step 3: Save local job** - -On save, write to panel config localCronJobs, update lastRun fields to defaults. - -- [ ] **Step 4: Remove gateway cron create/update** - -Delete calls to `wsClient.request('cron.add'|'cron.update')` for local jobs. - ---- - -## Task 3: Implement WSS local scheduler - -**Files:** -- Modify: `src/pages/cron.js` -- Modify: `src/main.js` (optional startup hook) - -- [ ] **Step 1: Add scheduler loop** - -Create an interval (e.g., 10s) to: -- Check wsClient.gatewayReady -- For each enabled local job, check next due time from cron expression -- If due and not run in current window, attempt send - -- [ ] **Step 2: Determine session idle state** - -Define idle as: -- No active runs for target session -- Or no “streaming” event in last N seconds - -Approach: -- Use `sessions.list` or `sessions.get` via wsClient to read run state if available -- If not available, fallback to client-side tracking of last chat event timestamps for that session - -- [ ] **Step 3: Send message only when idle** - -Use: -``` -wsClient.chatSend(sessionKey, message) -``` -Only when idle. - -- [ ] **Step 4: Update job state** - -On send success: -- state.lastRunAtMs = Date.now() -- state.lastStatus = "ok" -On failure: -- state.lastStatus = "error" -- state.lastError = message - -Persist to panel config after each run. - ---- - -## Task 4: Session resolution by label - -**Files:** -- Modify: `src/pages/cron.js` - -- [ ] **Step 1: Build label→sessionKey map** - -Use sessions.list to map label (parseSessionLabel) back to sessionKey. - -- [ ] **Step 2: Validate session exists** - -If session missing, mark lastStatus=error and lastError="session not found". - ---- - -## Task 5: Visual status and monitoring - -**Files:** -- Modify: `src/pages/cron.js` - -- [ ] **Step 1: Show local cron jobs in UI** - -Include status badges: -- last run time -- last status -- error message if any - -- [ ] **Step 2: Add manual run button** - -Trigger immediate send via scheduler path (same idle check). - ---- - -## Task 6: Verification - -**Files:** -- Modify: `src/pages/cron.js` - -- [ ] **Step 1: Build** - -Run: -``` -npm run build -``` -Expected: Success. - -- [ ] **Step 2: Manual test** - -1) Create a local cron job to send to main session. -2) Start a long-running agent task. -3) Verify scheduler waits until idle, then sends message. - ---- - -## Notes -- This plan intentionally bypasses gateway cron schema and patching. -- Jobs are stored locally in panel config, so they only run while ClawPanel is open. -- If headless scheduling is required later, a separate gateway-side implementation will be needed. diff --git a/docs/superpowers/plans/2026-03-17-force-setup.md b/docs/superpowers/plans/2026-03-17-force-setup.md deleted file mode 100644 index 93774207..00000000 --- a/docs/superpowers/plans/2026-03-17-force-setup.md +++ /dev/null @@ -1,84 +0,0 @@ -# forceSetup Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 添加 `forceSetup` 开关,使构建版可强制进入 /setup,完成初始化后自动关闭。 - -**Architecture:** 在 panel config 中存储 forceSetup;启动时读取并强制跳转;setup 成功后清零。 - -**Tech Stack:** Vanilla JS, Tauri - ---- - -## Chunk 1: 配置字段读写 - -### Task 1: panel config 增加 forceSetup -**Files:** -- Modify: `src-tauri/src/commands/config.rs` -- Modify: `src/lib/tauri-api.js` - -- [ ] **Step 1: 扩展 panel config 读写** - -在读写 panel config 时透传 `forceSetup`。 - -- [ ] **Step 2: 提交** -```bash -git add src-tauri/src/commands/config.rs src/lib/tauri-api.js -git commit -m "feat: add forceSetup to panel config" -``` - ---- - -## Chunk 2: 启动强制跳转 - -### Task 2: main.js 强制跳 setup -**Files:** -- Modify: `src/main.js` - -- [ ] **Step 1: 启动时读取 panel config** - -在 `ensureWebSession` 前读取 panel config,若 `forceSetup===true` 强制跳转 `/setup`。 - -- [ ] **Step 2: 提交** -```bash -git add src/main.js -git commit -m "feat: force setup on startup" -``` - ---- - -## Chunk 3: setup 完成后清零 - -### Task 3: setup.js 成功后清零 -**Files:** -- Modify: `src/pages/setup.js` - -- [ ] **Step 1: setup 成功时写入 forceSetup=false** - -- [ ] **Step 2: 提交** -```bash -git add src/pages/setup.js -git commit -m "feat: clear forceSetup after setup" -``` - ---- - -## Chunk 4: 构建与验证 - -### Task 4: 构建 -**Files:** 无 - -- [ ] **Step 1: 构建** -```bash -npm run build -``` - -- [ ] **Step 2: 手工验证** -- forceSetup=true 时进入 /setup -- setup 完成后不再强制跳转 -- forceSetup=false 时逻辑不变 - -- [ ] **Step 3: 推送** -```bash -git push -``` diff --git a/docs/superpowers/plans/2026-03-17-skill-trigger-optimization.md b/docs/superpowers/plans/2026-03-17-skill-trigger-optimization.md deleted file mode 100644 index 38fc574d..00000000 --- a/docs/superpowers/plans/2026-03-17-skill-trigger-optimization.md +++ /dev/null @@ -1,165 +0,0 @@ -# Skill Trigger Optimization Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Optimize trigger descriptions for all installed OpenClaw skills using the skill-creator workflow. - -**Architecture:** Inventory all skills under `~/.openclaw/skills/`, snapshot current SKILL.md frontmatter, then iterate through each skill to improve the description field with an automated loop when available. Ensure results are written back to each skill’s SKILL.md frontmatter and logged. - -**Tech Stack:** PowerShell, Python via `uv`, OpenClaw skill-creator assets (scripts/), markdown edits. - ---- - -### Task 1: Inventory and snapshot - -**Files:** -- Read: `C:\Users\34438\.openclaw\skills\*\SKILL.md` -- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\inventory.json` -- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\snapshots\\SKILL.md` - -- [ ] **Step 1: List skill directories** - -Run: -``` -Get-ChildItem "C:\Users\34438\.openclaw\skills" -Directory | Select-Object Name -``` -Expected: list of skill folder names. - -- [ ] **Step 2: Snapshot current SKILL.md files** - -Run: -``` -New-Item -ItemType Directory -Path "C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\snapshots" -Force | Out-Null -``` -Then copy each `SKILL.md` into its snapshot folder. - -- [ ] **Step 3: Build inventory.json** - -Create a JSON array with entries: -``` -{ - "name": "skill-folder", - "path": "C:\\Users\\34438\\.openclaw\\skills\\skill-folder", - "skill_md": "...\\SKILL.md", - "description": "" -} -``` - -- [ ] **Step 4: Commit checkpoint** - -Run: -``` -git add docs/superpowers/plans/2026-03-17-skill-trigger-optimization.md -``` -If in a repo, commit after build verification. - ---- - -### Task 2: Verify skill-creator tooling availability - -**Files:** -- Read: `C:\Users\34438\.openclaw\skills\skill-creator\scripts\` (if exists) -- Read: `C:\Users\34438\.openclaw\skills\skill-creator\references\` (if exists) - -- [ ] **Step 1: Confirm run_loop.py and eval scripts exist** - -Run: -``` -Get-ChildItem "C:\Users\34438\.openclaw\skills\skill-creator\scripts" -Filter "*.py" -``` -Expected: `run_loop.py`, `run_eval.py`, `aggregate_benchmark.py` or similar. - -- [ ] **Step 2: Decide path** - -If scripts exist: use automated loop per skill-creator instructions. -If scripts are missing: use manual description optimization with heuristics (see Task 4). - ---- - -### Task 3: Automated description optimization loop (preferred) - -**Files:** -- Modify: `C:\Users\34438\.openclaw\skills\\SKILL.md` -- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\\trigger_eval.json` -- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\\run_log.txt` - -- [ ] **Step 1: Generate trigger eval set** - -Create 16-20 queries (8-10 should-trigger, 8-10 should-not-trigger) per skill and save as JSON: -``` -[ - {"query": "...", "should_trigger": true}, - {"query": "...", "should_trigger": false} -] -``` - -- [ ] **Step 2: Run description optimization loop** - -Run (example): -``` -uv run python -m scripts.run_loop --eval-set --skill-path --model openai-codex/gpt-5.2-codex --max-iterations 5 --verbose -``` -Capture output to `run_log.txt`. - -- [ ] **Step 3: Apply best_description** - -Update the SKILL.md frontmatter `description` with `best_description`. - -- [ ] **Step 4: Record changes** - -Update `inventory.json` with new description and a `score` field if available. - ---- - -### Task 4: Manual description optimization (fallback) - -**Files:** -- Modify: `C:\Users\34438\.openclaw\skills\\SKILL.md` -- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\\manual_notes.md` - -- [ ] **Step 1: Draft improved description** - -Rewrite the description with explicit trigger phrases and contexts. Ensure it is pushy and includes common user phrasings. - -- [ ] **Step 2: Update SKILL.md frontmatter** - -Replace the `description` value while preserving name and formatting. - -- [ ] **Step 3: Log** - -Write before/after into `manual_notes.md` and update `inventory.json`. - ---- - -### Task 5: Validation and summary - -**Files:** -- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\summary.md` - -- [ ] **Step 1: Validate frontmatter** - -Verify each SKILL.md starts with YAML frontmatter containing `name` and `description`. - -- [ ] **Step 2: Build summary** - -Write a summary with counts of skills updated and any failures. - -- [ ] **Step 3: Final build check (if required by repo policy)** - -Run: -``` -npm run build -``` -Expected: success. - -- [ ] **Step 4: Commit** - -Commit all changes after build success. - ---- - -## Notes -- Use PowerShell only. -- Use `uv run python` for Python scripts. -- No emoji in outputs or docs. -- Do not overwrite SKILL.md structure beyond frontmatter description. diff --git a/docs/superpowers/plans/2026-03-17-skillhub-env-fix.md b/docs/superpowers/plans/2026-03-17-skillhub-env-fix.md deleted file mode 100644 index f069849b..00000000 --- a/docs/superpowers/plans/2026-03-17-skillhub-env-fix.md +++ /dev/null @@ -1,102 +0,0 @@ -# SkillHub 动态探测与系统环境变量继承 Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 让 ClawPanel 通过动态探测识别 SkillHub CLI,并让所有 Tauri 命令继承完整系统环境变量(用户 + 系统)。 - -**Architecture:** 在 Tauri 后端新增统一“系统环境构建函数”,所有命令执行时统一注入 envs;SkillHub 检测失败时走 where 探测并返回命中路径。 - -**Tech Stack:** Rust (Tauri), Windows Registry, tokio::process::Command - ---- - -## Chunk 1: 环境变量合并工具函数 - -### Task 1: 新增系统环境合并函数 -**Files:** -- Modify: `src-tauri/src/utils.rs` -- Modify: `src-tauri/src/commands/mod.rs`(如已有 enhanced_path 需更新) -- Test: (无自动测试,手工验证) - -- [ ] **Step 1: 设计合并逻辑并落地函数** - -新增 `build_system_env()`,返回 `Vec<(String, String)>`,包含: -- 当前进程 env -- 用户 env(HKCU\Environment) -- 系统 env(HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment) - -PATH 处理:系统 + 用户 + 进程,去重拼接。 - -示例(伪代码): -```rust -pub fn build_system_env() -> Vec<(String, String)> { - let mut env_map = HashMap::new(); - // 1) 读取系统 + 用户 env 并写入 - // 2) 读取进程 env 覆盖 - // 3) PATH 合并去重 - env_map.into_iter().collect() -} -``` - -- [ ] **Step 2: 在 commands 统一使用 build_system_env** - -将 `enhanced_path()` 替换或改为使用 `build_system_env()`,确保所有命令执行时统一 `cmd.envs(build_system_env())`。 - -- [ ] **Step 3: 提交** -```bash -git add src-tauri/src/utils.rs src-tauri/src/commands/mod.rs -git commit -m "feat: inherit full system env for commands" -``` - ---- - -## Chunk 2: SkillHub 动态探测与返回路径 - -### Task 2: SkillHub 检测增强 -**Files:** -- Modify: `src-tauri/src/commands/skills.rs` -- Modify: `src/lib/tauri-api.js`(若返回字段变化) -- Modify: `src/pages/skills.js` - -- [ ] **Step 1: 更新 skills_skillhub_check** - -流程: -1) `skillhub --version` -2) 若失败,执行 `where skillhub` -3) 取第一条路径,执行 ` --version` -4) 返回 `{ installed: true, version, path }` - -- [ ] **Step 2: 更新前端展示** - -Skills 页面展示 path(若存在): -- `#skillhub-status` 增加 “路径: xxx” -- 仍显示版本号 - -- [ ] **Step 3: 提交** -```bash -git add src-tauri/src/commands/skills.rs src/lib/tauri-api.js src/pages/skills.js -git commit -m "feat: detect skillhub by path and show location" -``` - ---- - -## Chunk 3: 手工验证与构建 - -### Task 3: 验证与构建 -**Files:** -- 无 - -- [ ] **Step 1: 前端构建** -```bash -npm run build -``` - -- [ ] **Step 2: 手工验证要点** -- SkillHub CLI 安装后,未重启 ClawPanel 仍能识别 installed=true -- UI 能显示 version + path -- 晴辰助手执行命令时继承系统变量(如 PATH / HTTP_PROXY) - -- [ ] **Step 3: 代码汇总与推送** -```bash -git push -``` diff --git a/docs/superpowers/plans/2026-03-17-toast-night-and-model-select.md b/docs/superpowers/plans/2026-03-17-toast-night-and-model-select.md deleted file mode 100644 index 9c1c4519..00000000 --- a/docs/superpowers/plans/2026-03-17-toast-night-and-model-select.md +++ /dev/null @@ -1,99 +0,0 @@ -# Toast Night Style + Model Select Width Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Improve toast visibility in dark mode and make chat-model-select auto-size without truncation. - -**Architecture:** Update CSS rules in `components.css` for toast dark mode and in `chat.css` for model select width. - -**Tech Stack:** CSS, Vite build - ---- - -## Chunk 1: Toast dark mode visibility - -### Task 1: Add dark theme toast background - -**Files:** -- Modify: `src/style/components.css` - -- [ ] **Step 0: Checkpoint(PowerShell)** - -```powershell -git status -sb -git commit --allow-empty -m "chore: checkpoint before toast dark style" -``` - -Note: This checkpoint is mandatory by policy before any modification. The final functional commit still occurs after build to honor Build First, Commit Later for real changes. - -- [ ] **Step 1: Add dark theme override** - -```css -[data-theme="dark"] .toast { - background: var(--bg-secondary); -} -``` - -- [ ] **Step 2: Build** - -Run: `npm run build` -Expected: Build succeeds without errors. - -- [ ] **Step 3: Commit(PowerShell)** - -```powershell -git add src/style/components.css -git commit -m "fix: improve toast dark mode" -``` - -- [ ] **Step 4: Push(PowerShell)** - -```powershell -git push -``` - -## Chunk 2: Model select auto width - -### Task 2: Remove truncation and allow auto width - -**Files:** -- Modify: `src/style/chat.css` - -- [ ] **Step 0: Checkpoint(PowerShell)** - -```powershell -git status -sb -git commit --allow-empty -m "chore: checkpoint before model select width" -``` - -- [ ] **Step 1: Update model select styles** - -Set the model select to auto width and remove truncation. Example: - -```css -.chat-model-select { - width: auto; - max-width: none; - white-space: nowrap; -} -``` - -Adjust if selectors differ in current file. - -- [ ] **Step 2: Build** - -Run: `npm run build` -Expected: Build succeeds without errors. - -- [ ] **Step 3: Commit(PowerShell)** - -```powershell -git add src/style/chat.css -git commit -m "fix: auto width for chat model select" -``` - -- [ ] **Step 4: Push(PowerShell)** - -```powershell -git push -``` diff --git a/docs/superpowers/plans/2026-03-17-toast-shadow.md b/docs/superpowers/plans/2026-03-17-toast-shadow.md deleted file mode 100644 index beb3bea8..00000000 --- a/docs/superpowers/plans/2026-03-17-toast-shadow.md +++ /dev/null @@ -1,54 +0,0 @@ -# Toast Shadow Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a medium-strength shadow to toast cards while keeping the Vercel-style solid background. - -**Architecture:** Modify `.toast` rule in `components.css` to add `box-shadow` only. - -**Tech Stack:** CSS, Vite build - ---- - -## Chunk 1: Toast shadow - -### Task 1: Add toast shadow - -**Files:** -- Modify: `src/style/components.css` - -- [ ] **Step 0: Checkpoint(PowerShell)** - -```powershell -git status -sb -git commit --allow-empty -m "chore: checkpoint before toast shadow" -``` - -Note: This checkpoint is required by policy to protect rollbacks. The final functional commit still happens after `npm run build`. - -- [ ] **Step 1: Add box-shadow** - -```css -.toast { - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); -} -``` - -- [ ] **Step 2: Build** - -Run: `npm run build` -Expected: Build succeeds without errors. -Note: This is the frontend Vite build; no `wails build` required for this CSS-only change. - -- [ ] **Step 3: Commit(PowerShell)** - -```powershell -git add src/style/components.css -git commit -m "fix: add toast shadow" -``` - -- [ ] **Step 4: Push(PowerShell)** - -```powershell -git push -``` diff --git a/docs/superpowers/plans/2026-03-17-toast-vercel.md b/docs/superpowers/plans/2026-03-17-toast-vercel.md deleted file mode 100644 index ed92e2c7..00000000 --- a/docs/superpowers/plans/2026-03-17-toast-vercel.md +++ /dev/null @@ -1,54 +0,0 @@ -# Toast Vercel Style Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace glass/blur toast with solid Vercel-style card that adapts to light/dark themes. - -**Architecture:** Update toast styles in `components.css` to remove blur, use theme variables for background and border, keep status text colors. - -**Tech Stack:** CSS, Vite build - ---- - -## Chunk 1: Toast style update - -### Task 1: Update toast base style - -**Files:** -- Modify: `src/style/components.css` - -- [ ] **Step 0: Checkpoint** - -```bash -git status -sb -git commit --allow-empty -m "chore: checkpoint before toast style update" -``` - -- [ ] **Step 1: Remove blur and set base card styles** - -Update `.toast` rule: -- remove `backdrop-filter` -- add `background: var(--bg-primary);` -- add `border: 1px solid var(--border);` - -- [ ] **Step 2: Simplify status variants** - -Update `.toast.success/.error/.info/.warning` to only set `color`, removing background and border overrides. - -- [ ] **Step 3: Build** - -Run: `npm run build` -Expected: Build succeeds without errors. - -- [ ] **Step 4: Commit** - -```bash -git add src/style/components.css -git commit -m "fix: vercel-style toast card" -``` - -- [ ] **Step 5: Push** - -```bash -git push -``` diff --git a/docs/superpowers/plans/2026-03-17-tool-call-meta.md b/docs/superpowers/plans/2026-03-17-tool-call-meta.md deleted file mode 100644 index ac94c8db..00000000 --- a/docs/superpowers/plans/2026-03-17-tool-call-meta.md +++ /dev/null @@ -1,108 +0,0 @@ -# Tool Call Meta Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Show tool call time in the tool card header and ensure expanded sections display input/output placeholders when data is empty. - -**Architecture:** Update tool rendering in `src/pages/chat.js` to compute display time and placeholders; optional minor CSS tweaks if needed. - -**Tech Stack:** Vanilla JS, CSS, Vite build - ---- - -## Chunk 1: Tool header time + placeholders - -### Task 1: Add tool time display - -**Files:** -- Modify: `src/pages/chat.js` - -- [ ] **Step 0: Checkpoint(PowerShell)** - -```powershell -git status -sb -git commit --allow-empty -m "chore: checkpoint before tool meta" -``` - -Note: This checkpoint is required by policy; final functional commit occurs after build. - -- [ ] **Step 1: Add helper to get tool time** - -Add a function near other helpers in `src/pages/chat.js` (below `stripThinkingTags`): - -```js -function getToolTime(tool) { - const raw = tool?.end_time || tool?.endTime || tool?.timestamp || tool?.time || tool?.started_at || tool?.startedAt || null - if (!raw) return null - if (typeof raw === 'number' && raw < 1e12) return raw * 1000 - return raw -} -``` - -Note: `formatTime` and `escapeHtml` already exist in `chat.js`. - -- [ ] **Step 1.5: Capture tool event timestamps** - -Add a map at top-level in `src/pages/chat.js`: - -```js -const _toolEventTimes = new Map() -``` - -In `handleEvent`, before `handleChatEvent`, capture tool events: - -```js -if (event === 'agent' && payload?.stream === 'tool' && payload?.data?.toolCallId) { - const ts = payload.ts - if (ts) _toolEventTimes.set(payload.data.toolCallId, ts) -} -``` - -In `collectToolsFromMessage`, when constructing tool entries, set `time` when absent: - -```js -const callId = call.id || call.tool_call_id -const fallbackTime = callId ? _toolEventTimes.get(callId) : null -... time: call.time || fallbackTime ... -``` - -- [ ] **Step 2: Render header with time** - -In `appendToolsToEl`, reuse the existing `summary` node created there and update header: - -```js -const time = getToolTime(tool) -const timeText = time ? formatTime(new Date(time)) : '时间未知' -summary.innerHTML = `${escapeHtml(tool.name || '工具')} · ${status} · ${timeText}` -``` - -- [ ] **Step 3: Add placeholders** - -Use the exact block structure already used in tool body: - -```js -const input = inputJson - ? `
参数
${escapeHtml(inputJson)}
` - : `
参数
无参数
` -const output = outputJson - ? `
结果
${escapeHtml(outputJson)}
` - : `
结果
无结果
` -``` - -- [ ] **Step 4: Build** - -Run: `npm run build` -Expected: Build succeeds without errors. - -- [ ] **Step 5: Commit(PowerShell)** - -```powershell -git add src/pages/chat.js -git commit -m "fix: show tool time and placeholders" -``` - -- [ ] **Step 6: Push(PowerShell)** - -```powershell -git push -``` diff --git a/docs/superpowers/plans/2026-03-17-ws-connect-bootstrap.md b/docs/superpowers/plans/2026-03-17-ws-connect-bootstrap.md deleted file mode 100644 index ff62269d..00000000 --- a/docs/superpowers/plans/2026-03-17-ws-connect-bootstrap.md +++ /dev/null @@ -1,86 +0,0 @@ -# WS Connect Bootstrap Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** On each websocket connect success, send the official 8-request bootstrap batch; set ping interval to 5s (bootstrap may duplicate the first ping batch). - -**Architecture:** Add `_sendBootstrapRequests()` to `WsClient`, call it from `_handleConnectSuccess`, update `PING_INTERVAL` constant to 5000. - -**Tech Stack:** JS, Vite build - ---- - -## Chunk 1: Bootstrap batch + ping interval - -### Task 1: Implement bootstrap batch - -**Files:** -- Modify: `src/lib/ws-client.js` - -- [ ] **Step 0: Checkpoint(PowerShell)** - -```powershell -git status -sb -git commit --allow-empty -m "chore: checkpoint before ws bootstrap" -``` - -Note: This checkpoint is mandatory by policy before modifications. - -- [ ] **Step 1: Add helper to send bootstrap batch** - -Add method inside `WsClient`: - -```js -_sendBootstrapRequests() { - if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return - const sessionKey = this._sessionKey || 'agent:full-stack-architect:main' - // Note: responses are fire-and-forget in this batch - const frames = [ - { type: 'req', id: uuid(), method: 'agent.identity.get', params: { sessionKey } }, - { type: 'req', id: uuid(), method: 'agents.list', params: {} }, - { type: 'req', id: uuid(), method: 'health', params: {} }, - { type: 'req', id: uuid(), method: 'node.list', params: {} }, - { type: 'req', id: uuid(), method: 'device.pair.list', params: {} }, - { type: 'req', id: uuid(), method: 'chat.history', params: { sessionKey, limit: 200 } }, - { type: 'req', id: uuid(), method: 'sessions.list', params: { includeGlobal: true, includeUnknown: true } }, - { type: 'req', id: uuid(), method: 'models.list', params: {} }, - ] - frames.forEach(frame => this._ws.send(JSON.stringify(frame))) -} -``` - -- [ ] **Step 2: Call bootstrap on connect success** - -In `_handleConnectSuccess` add: - -```js -this._sendBootstrapRequests() -``` - -- [ ] **Step 3: Set ping interval to 5s** - -Change constant: - -```js -const PING_INTERVAL = 5000 -``` - -Note: With 5s interval and multi-req pings, load increases. This matches the requested behavior. - -- [ ] **Step 4: Build** - -Run: `npm run build` -Expected: Build succeeds without errors. - -- [ ] **Step 5: Commit(PowerShell)** - -```powershell -git add src/lib/ws-client.js -git commit -m "fix: ws bootstrap batch" -``` - -- [ ] **Step 6: Push(PowerShell)** - -```powershell -git push -``` diff --git a/docs/superpowers/plans/2026-03-17-ws-ping-multi-req.md b/docs/superpowers/plans/2026-03-17-ws-ping-multi-req.md deleted file mode 100644 index 4b768851..00000000 --- a/docs/superpowers/plans/2026-03-17-ws-ping-multi-req.md +++ /dev/null @@ -1,59 +0,0 @@ -# WS Ping Multi-Req Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Send node.list, models.list, sessions.list, and chat.history every ping interval. - -**Architecture:** Update `_startPing` in `src/lib/ws-client.js` to emit four req frames each interval. - -**Tech Stack:** JS, Vite build - ---- - -## Chunk 1: Ping multi-req - -### Task 1: Update ping sender - -**Files:** -- Modify: `src/lib/ws-client.js` - -- [ ] **Step 0: Checkpoint(PowerShell)** - -```powershell -git status -sb -git commit --allow-empty -m "chore: checkpoint before ping multi req" -``` - -Note: This checkpoint is mandatory by policy before modifications. - -- [ ] **Step 1: Replace ping payload with 4 req frames** - -In `_startPing` interval: - -```js -const frames = [ - { type: 'req', id: uuid(), method: 'node.list', params: {} }, - { type: 'req', id: uuid(), method: 'models.list', params: {} }, - { type: 'req', id: uuid(), method: 'sessions.list', params: { includeGlobal: true, includeUnknown: true } }, - { type: 'req', id: uuid(), method: 'chat.history', params: { sessionKey: 'agent:full-stack-architect:main', limit: 200 } }, -] -frames.forEach(frame => this._ws.send(JSON.stringify(frame))) -``` - -- [ ] **Step 2: Build** - -Run: `npm run build` -Expected: Build succeeds without errors. - -- [ ] **Step 3: Commit(PowerShell)** - -```powershell -git add src/lib/ws-client.js -git commit -m "fix: ping sends multi req" -``` - -- [ ] **Step 4: Push(PowerShell)** - -```powershell -git push -``` diff --git a/docs/superpowers/plans/2026-03-17-ws-ping-node-list.md b/docs/superpowers/plans/2026-03-17-ws-ping-node-list.md deleted file mode 100644 index 7575460f..00000000 --- a/docs/superpowers/plans/2026-03-17-ws-ping-node-list.md +++ /dev/null @@ -1,56 +0,0 @@ -# WS Ping Node List Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace websocket ping with a periodic node.list request. - -**Architecture:** Modify `_startPing` in `src/lib/ws-client.js` to send a req frame rather than a ping frame. - -**Tech Stack:** JS, Vite build - ---- - -## Chunk 1: Replace ping payload - -### Task 1: Update ping sender - -**Files:** -- Modify: `src/lib/ws-client.js` - -- [ ] **Step 0: Checkpoint(PowerShell)** - -```powershell -git status -sb -git commit --allow-empty -m "chore: checkpoint before ping change" -``` - -Note: This checkpoint is mandatory by policy before modifications. The final functional commit still occurs after a successful build. - -- [ ] **Step 1: Replace ping payload** - -In `_startPing` interval: - -```js -const frame = { type: 'req', id: uuid(), method: 'node.list', params: {} } -this._ws.send(JSON.stringify(frame)) -``` - -- [ ] **Step 2: Build** - -Run: `npm run build` -Expected: Build succeeds without errors. - -- [ ] **Step 3: Commit(PowerShell)** - -```powershell -git add src/lib/ws-client.js -git commit -m "fix: ping uses node.list" -``` - -- [ ] **Step 4: Push(PowerShell)** - -Run only after successful build and commit. - -```powershell -git push -``` From ef86fdff384a3acee9e9fece0e6f1722635b91f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 13:16:15 +0800 Subject: [PATCH 194/426] Revert "docs: remove plan documents" This reverts commit 98b1064f292f6590e7ff200f8de3e80f1a6c3cbd. --- docs/assistant-features-plan.md | 777 ++++++++++++++++++ docs/docker-multi-instance-plan.md | 494 +++++++++++ docs/i18n-plan.md | 288 +++++++ ...cloudflared-openclaw-integration-design.md | 59 ++ ...3-16-cron-sessionmessage-tool-ui-design.md | 55 ++ ...-03-16-gateway-patch-auto-detect-design.md | 25 + ...026-03-16-gateway-patch-oneclick-design.md | 37 + .../2026-03-17-ai-config-import-design.md | 37 + ...-03-17-assistant-optimize-toggle-design.md | 27 + ...026-03-17-assistant-ux-and-shell-design.md | 43 + .../2026-03-17-chat-autoscroll-design.md | 41 + .../2026-03-17-chat-daylight-shadow-design.md | 37 + ...26-03-17-chat-history-tool-merge-design.md | 30 + .../2026-03-17-chat-markdown-it-design.md | 25 + .../2026-03-17-chat-tool-event-live-design.md | 34 + .../2026-03-17-chat-virtual-scroll-design.md | 25 + ...at-virtual-scroll-implementation-design.md | 34 + docs/plans/2026-03-17-force-setup-design.md | 31 + docs/plans/2026-03-17-hosted-agent-design.md | 131 +++ .../2026-03-17-skillhub-env-fix-design.md | 65 ++ ...-17-toast-night-and-model-select-design.md | 36 + docs/plans/2026-03-17-toast-shadow-design.md | 18 + docs/plans/2026-03-17-toast-vercel-design.md | 27 + .../plans/2026-03-17-tool-call-meta-design.md | 32 + .../2026-03-17-ws-connect-bootstrap-design.md | 31 + .../2026-03-17-ws-ping-multi-req-design.md | 27 + .../2026-03-17-ws-ping-node-list-design.md | 18 + .../2026-03-16-gateway-patch-auto-detect.md | 74 ++ .../2026-03-16-gateway-patch-oneclick.md | 169 ++++ .../plans/2026-03-17-ai-config-import.md | 82 ++ .../2026-03-17-assistant-optimize-toggle.md | 52 ++ .../2026-03-17-assistant-ux-and-shell.md | 118 +++ .../plans/2026-03-17-chat-autoscroll.md | 132 +++ .../plans/2026-03-17-chat-daylight-shadow.md | 46 ++ .../2026-03-17-chat-history-tool-merge.md | 81 ++ .../plans/2026-03-17-chat-markdown-it.md | 146 ++++ .../plans/2026-03-17-chat-tool-event-live.md | 97 +++ ...3-17-chat-virtual-scroll-implementation.md | 211 +++++ .../plans/2026-03-17-cron-trigger-mode.md | 86 ++ .../2026-03-17-cron-ws-sessionmessage.md | 199 +++++ .../plans/2026-03-17-force-setup.md | 84 ++ .../2026-03-17-skill-trigger-optimization.md | 165 ++++ .../plans/2026-03-17-skillhub-env-fix.md | 102 +++ ...2026-03-17-toast-night-and-model-select.md | 99 +++ .../plans/2026-03-17-toast-shadow.md | 54 ++ .../plans/2026-03-17-toast-vercel.md | 54 ++ .../plans/2026-03-17-tool-call-meta.md | 108 +++ .../plans/2026-03-17-ws-connect-bootstrap.md | 86 ++ .../plans/2026-03-17-ws-ping-multi-req.md | 59 ++ .../plans/2026-03-17-ws-ping-node-list.md | 56 ++ 50 files changed, 4844 insertions(+) create mode 100644 docs/assistant-features-plan.md create mode 100644 docs/docker-multi-instance-plan.md create mode 100644 docs/i18n-plan.md create mode 100644 docs/plans/2026-03-16-cloudflared-openclaw-integration-design.md create mode 100644 docs/plans/2026-03-16-cron-sessionmessage-tool-ui-design.md create mode 100644 docs/plans/2026-03-16-gateway-patch-auto-detect-design.md create mode 100644 docs/plans/2026-03-16-gateway-patch-oneclick-design.md create mode 100644 docs/plans/2026-03-17-ai-config-import-design.md create mode 100644 docs/plans/2026-03-17-assistant-optimize-toggle-design.md create mode 100644 docs/plans/2026-03-17-assistant-ux-and-shell-design.md create mode 100644 docs/plans/2026-03-17-chat-autoscroll-design.md create mode 100644 docs/plans/2026-03-17-chat-daylight-shadow-design.md create mode 100644 docs/plans/2026-03-17-chat-history-tool-merge-design.md create mode 100644 docs/plans/2026-03-17-chat-markdown-it-design.md create mode 100644 docs/plans/2026-03-17-chat-tool-event-live-design.md create mode 100644 docs/plans/2026-03-17-chat-virtual-scroll-design.md create mode 100644 docs/plans/2026-03-17-chat-virtual-scroll-implementation-design.md create mode 100644 docs/plans/2026-03-17-force-setup-design.md create mode 100644 docs/plans/2026-03-17-hosted-agent-design.md create mode 100644 docs/plans/2026-03-17-skillhub-env-fix-design.md create mode 100644 docs/plans/2026-03-17-toast-night-and-model-select-design.md create mode 100644 docs/plans/2026-03-17-toast-shadow-design.md create mode 100644 docs/plans/2026-03-17-toast-vercel-design.md create mode 100644 docs/plans/2026-03-17-tool-call-meta-design.md create mode 100644 docs/plans/2026-03-17-ws-connect-bootstrap-design.md create mode 100644 docs/plans/2026-03-17-ws-ping-multi-req-design.md create mode 100644 docs/plans/2026-03-17-ws-ping-node-list-design.md create mode 100644 docs/superpowers/plans/2026-03-16-gateway-patch-auto-detect.md create mode 100644 docs/superpowers/plans/2026-03-16-gateway-patch-oneclick.md create mode 100644 docs/superpowers/plans/2026-03-17-ai-config-import.md create mode 100644 docs/superpowers/plans/2026-03-17-assistant-optimize-toggle.md create mode 100644 docs/superpowers/plans/2026-03-17-assistant-ux-and-shell.md create mode 100644 docs/superpowers/plans/2026-03-17-chat-autoscroll.md create mode 100644 docs/superpowers/plans/2026-03-17-chat-daylight-shadow.md create mode 100644 docs/superpowers/plans/2026-03-17-chat-history-tool-merge.md create mode 100644 docs/superpowers/plans/2026-03-17-chat-markdown-it.md create mode 100644 docs/superpowers/plans/2026-03-17-chat-tool-event-live.md create mode 100644 docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md create mode 100644 docs/superpowers/plans/2026-03-17-cron-trigger-mode.md create mode 100644 docs/superpowers/plans/2026-03-17-cron-ws-sessionmessage.md create mode 100644 docs/superpowers/plans/2026-03-17-force-setup.md create mode 100644 docs/superpowers/plans/2026-03-17-skill-trigger-optimization.md create mode 100644 docs/superpowers/plans/2026-03-17-skillhub-env-fix.md create mode 100644 docs/superpowers/plans/2026-03-17-toast-night-and-model-select.md create mode 100644 docs/superpowers/plans/2026-03-17-toast-shadow.md create mode 100644 docs/superpowers/plans/2026-03-17-toast-vercel.md create mode 100644 docs/superpowers/plans/2026-03-17-tool-call-meta.md create mode 100644 docs/superpowers/plans/2026-03-17-ws-connect-bootstrap.md create mode 100644 docs/superpowers/plans/2026-03-17-ws-ping-multi-req.md create mode 100644 docs/superpowers/plans/2026-03-17-ws-ping-node-list.md diff --git a/docs/assistant-features-plan.md b/docs/assistant-features-plan.md new file mode 100644 index 00000000..9b2dd08a --- /dev/null +++ b/docs/assistant-features-plan.md @@ -0,0 +1,777 @@ +# AI 助手功能扩展规划 + +> 基于现有工具架构(TOOL_DEFS + executeTool + getEnabledTools),扩展 6 大能力模块。 +> 每个模块独立开关,遵循现有的 `_config.tools.xxx` + 设置面板 toggle 模式。 + +--- + +## 当前架构概览 + +``` +TOOL_DEFS = { + system: [get_system_info] // 始终可用 + process: [list_processes, check_port] // 始终可用 + interaction: [ask_user] // 始终可用 + terminal: [run_command] // 开关控制 + fileOps: [read_file, write_file, list_directory] // 开关控制 +} + +_config.tools = { terminal: true/false, fileOps: true/false } +``` + +扩展后: + +``` +TOOL_DEFS = { + ...existing, + docker: [docker_list, docker_exec, docker_logs, wsl_exec] // 新增 + webSearch: [web_search, fetch_url] // 新增 + ssh: [ssh_exec, ssh_read_file, ssh_write_file] // 新增 + knowledge: [search_knowledge] // 新增 +} + +_config.tools = { + ...existing, + docker: false, // 默认关闭 + webSearch: false, // 默认关闭 + ssh: false, // 默认关闭 + knowledge: false, // 默认关闭 +} +``` + +--- + +## 模块一:Docker / WSL 管理工具 + +### 场景 +- 用户的 OpenClaw 可能安装在 Docker 容器或 WSL 中 +- 本地检测不到时,帮用户在容器/WSL 内操作 +- 查看容器日志、进入容器执行命令、管理容器生命周期 + +### 工具定义 + +| 工具名 | 描述 | 参数 | 危险等级 | +|--------|------|------|----------| +| `docker_list` | 列出 Docker 容器 | `filter?`, `all?` | 安全 | +| `docker_exec` | 在容器内执行命令 | `container`, `command` | ⚠️ 危险 | +| `docker_logs` | 查看容器日志 | `container`, `lines?` | 安全 | +| `docker_compose` | 执行 docker-compose 命令 | `action`, `file?`, `service?` | ⚠️ 危险 | +| `wsl_exec` | 在 WSL 内执行命令 | `distro?`, `command` | ⚠️ 危险 | +| `wsl_list` | 列出 WSL 发行版 | — | 安全 | + +### 后端实现 + +``` +Tauri (Rust): + - docker_list → Command::new("docker").args(["ps", ...]) + - docker_exec → Command::new("docker").args(["exec", container, ...]) + - wsl_exec → Command::new("wsl").args(["-d", distro, "-e", ...]) (Windows only) + +dev-api.js (Web): + - execSync('docker ps --format json') + - execSync(`docker exec ${container} ${command}`) + - execSync(`wsl -d ${distro} -e ${command}`) (Windows only) +``` + +### 安全围栏 +- `docker_exec` / `wsl_exec` 归入 DANGEROUS_TOOLS +- `docker rm`, `docker rmi`, `docker system prune` 归入 CRITICAL_PATTERNS + +### UI 扩展 +- 设置面板工具权限 tab 新增 toggle: + ``` + Docker / WSL 工具 — 允许管理容器和 WSL 环境 + ``` + +### 内置技能卡片 +```js +{ + id: 'detect-docker-openclaw', + icon: 'docker', + name: '检测 Docker/WSL 中的 OpenClaw', + desc: '扫描 Docker 容器和 WSL,查找 OpenClaw 安装', + tools: ['docker'], + prompt: `请帮我检查 Docker 和 WSL 中是否安装了 OpenClaw。 + 1. 调用 get_system_info 判断操作系统 + 2. 用 docker_list 列出所有容器,过滤包含 openclaw/gateway 的 + 3. 如果是 Windows,用 wsl_list 列出 WSL 发行版 + 4. 对每个 WSL 发行版,用 wsl_exec 执行 "which openclaw" 检测 + 5. 汇总发现的 OpenClaw 实例及其状态` +} +``` + +### 优先级:高(解决用户最常见困惑) +### 工时估算:1-2 天 + +--- + +## 模块二:联网搜索工具 + +### 场景 +- 用户遇到不常见的错误,AI 知识库可能没有 +- 搜索 GitHub Issues、文档、Stack Overflow 找到解决方案 +- 查找最新版本信息、API 文档等 + +### 工具定义 + +| 工具名 | 描述 | 参数 | 危险等级 | +|--------|------|------|----------| +| `web_search` | 联网搜索关键词 | `query`, `max_results?` | 安全 | +| `fetch_url` | 抓取网页内容 | `url` | 安全 | + +### 后端实现方案(3 选 1) + +#### 方案 A:DuckDuckGo Instant Answer API(推荐,免费无 Key) +```js +// 搜索 +const resp = await fetch(`https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json`) +// 但 Instant Answer 只返回摘要,不返回搜索结果列表 + +// 实际搜索需要用 DuckDuckGo HTML 页面解析或第三方库 +``` + +#### 方案 B:SearXNG 代理(自托管,最灵活) +```js +// 部署一个 SearXNG 实例,或者用公共实例 +const resp = await fetch(`https://searx.example.com/search?q=${query}&format=json`) +``` + +#### 方案 C:Jina Reader API(推荐搭配使用,免费) +```js +// 将任意 URL 转为纯文本/Markdown +const resp = await fetch(`https://r.jina.ai/${targetUrl}`) +const text = await resp.text() +``` + +### 推荐组合 +- **搜索**:使用 DuckDuckGo 的 `html.duckduckgo.com/html/?q=xxx` 页面解析结果 +- **内容抓取**:使用 Jina Reader `r.jina.ai/URL` 获取纯文本 +- 两者都 **免费无 Key**,无需用户配置 + +### 系统提示词补充 +``` +## web_search 使用指南 +当你无法确定答案或需要最新信息时,可以使用 web_search 搜索互联网。 +搜索后,如果需要更多内容,可以用 fetch_url 抓取具体页面。 +搜索技巧: +- 加 site:github.com 搜索 GitHub +- 加 site:stackoverflow.com 搜索 StackOverflow +- 搜索错误信息时,用引号包裹关键错误文本 +``` + +### 安全围栏 +- 搜索和抓取不涉及破坏性操作,不归入 DANGEROUS_TOOLS +- 但需要网络请求,添加超时保护(10 秒) +- URL 抓取限制最大内容长度(100KB → 截断) + +### UI 扩展 +``` +联网搜索 — 允许搜索互联网和抓取网页内容(需联网) +``` + +### 优先级:高(大幅提升问题解决能力) +### 工时估算:0.5-1 天 + +--- + +## 模块三:SSH 远程管理工具 + +### 场景 +- 用户的 OpenClaw 部署在远程服务器上 +- 帮用户远程安装、配置、排查 OpenClaw +- 远程查看日志、重启服务、修改配置 + +### 工具定义 + +| 工具名 | 描述 | 参数 | 危险等级 | +|--------|------|------|----------| +| `ssh_exec` | 在远程服务器执行命令 | `connection_id`, `command` | ⚠️ 危险 | +| `ssh_read_file` | 读取远程文件 | `connection_id`, `path` | 安全 | +| `ssh_write_file` | 写入远程文件 | `connection_id`, `path`, `content` | ⚠️ 危险 | + +### 配置数据结构 + +```js +_config.sshConnections = [ + { + id: 'my-server', + name: '生产服务器', + host: '192.168.1.100', + port: 22, + user: 'root', + authType: 'key', // 'key' | 'password' + keyPath: '~/.ssh/id_rsa', + // password 不存储在 localStorage,每次询问或用 keytar 安全存储 + } +] +``` + +### 后端实现 + +``` +Tauri (Rust): + - 使用 ssh2 crate 或调用系统 ssh CLI + - ssh_exec → Command::new("ssh").args(["-p", port, "user@host", command]) + - ssh_read_file → ssh + cat + - ssh_write_file → 通过 stdin pipe 写入 + +dev-api.js (Web): + - 使用 node-ssh 或 ssh2 npm 包 + - 或者直接调用 ssh CLI +``` + +### 设置 UI:新增 tab「远程连接」 + +``` +┌─────────────────────────────────────────────┐ +│ 模型配置 │ 工具权限 │ 远程连接 │ 助手人设 │ +├─────────────────────────────────────────────┤ +│ │ +│ ┌─ 生产服务器 ──────────────── [编辑] [删] │ +│ │ root@192.168.1.100:22 (密钥认证) │ +│ └──────────────────────────────────────────│ +│ │ +│ ┌─ 测试服务器 ──────────────── [编辑] [删] │ +│ │ admin@10.0.0.5:22 (密码认证) │ +│ └──────────────────────────────────────────│ +│ │ +│ [+ 添加连接] │ +│ │ +│ 提示:推荐使用 SSH 密钥认证。 │ +│ 生成密钥:ssh-keygen -t ed25519 │ +│ 复制公钥:ssh-copy-id user@host │ +│ │ +└─────────────────────────────────────────────┘ +``` + +### 安全围栏 +- `ssh_exec`, `ssh_write_file` 归入 DANGEROUS_TOOLS +- 密码认证:每次执行时用 ask_user 确认,或使用系统密钥链 +- SSH 密钥路径验证:检查文件是否存在 +- 关键命令(rm -rf, reboot 等)在远程同样走 CRITICAL_PATTERNS + +### 系统提示词补充 +``` +## SSH 远程管理 +用户可能配置了远程服务器连接。当操作远程服务器时: +- 先用 ask_user 确认要操作哪个连接 +- 远程命令比本地更谨慎,优先使用只读操作 +- 修改配置前先备份(cp xxx xxx.bak) +``` + +### 内置技能卡片 +```js +{ + id: 'remote-manage', + icon: 'network', + name: '远程管理 OpenClaw', + desc: '通过 SSH 连接远程服务器,管理 OpenClaw', + tools: ['ssh', 'fileOps'], + prompt: `请帮我管理远程服务器上的 OpenClaw。 + 1. 获取系统信息,列出已配置的 SSH 连接 + 2. 用 ask_user 让我选择要操作的服务器 + 3. 用 ssh_exec 检查远程 OpenClaw 状态 + 4. 检查 Gateway 进程和端口 + 5. 读取远程配置和日志 + 6. 汇总远程 OpenClaw 状态报告` +} +``` + +### 优先级:中(用户量较少但价值极高) +### 工时估算:2-3 天 + +--- + +## 模块四:知识库 + 灵魂移植(借尸还魂) + +### 核心理念 + +OpenClaw 的 Agent 有一套完整的**身份系统**,由工作区引导文件定义: + +``` +~/.openclaw/workspace/ ← Agent 的"灵魂"所在 + ├── AGENTS.md ← 操作指令、规则、记忆管理方式 + ├── SOUL.md ← 人设、边界、语气("Who You Are") + ├── IDENTITY.md ← 名称、物种、风格、表情符号、头像 + ├── USER.md ← 用户档案(名字、称呼、时区、偏好) + ├── TOOLS.md ← 工具本地笔记(SSH 配置、设备名等) + ├── HEARTBEAT.md ← 心跳任务清单 + ├── MEMORY.md ← 精选长期记忆(仅主会话加载) + └── memory/ ← 每日记忆日志 + ├── 2026-03-04-1609.md + └── ... + +~/.openclaw/agents//agent/ ← Agent 的运行时状态 + ├── models.json ← 模型提供商配置(baseUrl + apiKey + models) + ├── auth-profiles.json ← 认证配置文件 + └── auth.json +``` + +**"借尸还魂"不是复用知识库,而是完整接管 Agent 的灵魂**—— +ClawPanel 的 AI 助手直接读取这些文件,像 OpenClaw 一样把它们注入 system prompt, +从而变成那个 Agent:有他的名字、他的性格、他的记忆、他认识的用户。 + +### 4A:灵魂移植(Agent Identity Takeover) + +#### 工作流程 + +1. **扫描** `~/.openclaw/workspace/` 和 `~/.openclaw/agents/` 目录 +2. **发现** 所有可用的 Agent 身份(main、test 等) +3. **用户选择** 要附身的 Agent +4. **读取** 该 Agent 的全部引导文件: + - `SOUL.md` → 注入为人设(替换 ClawPanel 助手的默认人设) + - `IDENTITY.md` → 提取名称/表情/风格(替换助手名称和性格描述) + - `USER.md` → 注入用户上下文(知道用户叫什么、偏好什么) + - `AGENTS.md` → 注入操作规则(Agent 的行为准则) + - `TOOLS.md` → 注入工具笔记 + - `MEMORY.md` → 注入长期记忆 + - `memory/` → 注入最近的每日记忆(最近 3 天) +5. **注入** 到 `buildSystemPrompt()` 中,完全替代默认人设 + +#### 实现 + +```js +// 新增配置项 +_config.soulSource = null // null = 使用 ClawPanel 默认 | 'openclaw:main' | 'openclaw:test' | 'custom' +_config.soulCache = null // 缓存读取的灵魂文件内容 + +// buildSystemPrompt 改造 +function buildSystemPrompt() { + if (_config.soulSource?.startsWith('openclaw:')) { + // 借尸还魂模式:使用 OpenClaw Agent 的灵魂 + return buildOpenClawSoulPrompt() + } + // 默认模式:使用 ClawPanel 自带的系统提示词 + return buildDefaultPrompt() +} + +function buildOpenClawSoulPrompt() { + const soul = _config.soulCache + if (!soul) return buildDefaultPrompt() // fallback + + let prompt = '' + + // 1. 身份注入 + if (soul.identity) { + prompt += `# Identity\n${soul.identity}\n\n` + } + + // 2. 灵魂注入(人设、边界、语气) + if (soul.soul) { + prompt += `# Soul\n${soul.soul}\n\n` + } + + // 3. 用户上下文 + if (soul.user) { + prompt += `# User\n${soul.user}\n\n` + } + + // 4. 操作规则 + if (soul.agents) { + prompt += `# Operating Instructions\n${soul.agents}\n\n` + } + + // 5. 工具笔记 + if (soul.tools) { + prompt += `# Tool Notes\n${soul.tools}\n\n` + } + + // 6. 长期记忆 + if (soul.memory) { + prompt += `# Long-term Memory\n${soul.memory}\n\n` + } + + // 7. 最近的每日记忆 + if (soul.recentMemories?.length) { + prompt += `# Recent Memory\n` + for (const m of soul.recentMemories) { + prompt += `## ${m.date}\n${m.content}\n\n` + } + } + + // 8. 追加 ClawPanel 特有的工具说明(保持工具能力) + prompt += buildToolInstructions() + + return prompt +} +``` + +#### 灵魂加载函数 + +```js +async function loadOpenClawSoul(agentId = 'main') { + const home = await getHomeDir() + const ws = `${home}/.openclaw/workspace` // 工作区是全局的,不按 agentId 分 + + const readSafe = async (path) => { + try { return await api.assistantReadFile(path) } + catch { return null } + } + + const soul = { + identity: await readSafe(`${ws}/IDENTITY.md`), + soul: await readSafe(`${ws}/SOUL.md`), + user: await readSafe(`${ws}/USER.md`), + agents: await readSafe(`${ws}/AGENTS.md`), + tools: await readSafe(`${ws}/TOOLS.md`), + memory: await readSafe(`${ws}/MEMORY.md`), + recentMemories: [], + } + + // 读取最近 3 天的每日记忆 + try { + const memDir = await api.assistantListDir(`${ws}/memory`) + const files = memDir.split('\n').filter(f => f.match(/\d{4}-\d{2}-\d{2}/)) + const recent = files.sort().slice(-3) + for (const f of recent) { + const content = await readSafe(`${ws}/memory/${f.trim()}`) + if (content) soul.recentMemories.push({ date: f.trim(), content }) + } + } catch {} + + return soul +} +``` + +#### UI:设置面板「助手人设」Tab 改造 + +``` +┌─────────────────────────────────────────────┐ +│ 模型配置 │ 工具权限 │ 知识库 │ 远程连接 │ 人设 │ +├─────────────────────────────────────────────┤ +│ │ +│ 身份来源 │ +│ ┌──────────────────────────────────────────│ +│ │ ● ClawPanel 默认人设 │ ← 当前默认 +│ │ ○ OpenClaw Agent 身份(借尸还魂) │ ← 新增 +│ │ ○ 自定义人设 │ +│ └──────────────────────────────────────────│ +│ │ +│ ─── 当选择「OpenClaw Agent」时显示 ──── │ +│ │ +│ 选择 Agent: [main ▼] │ +│ │ +│ 灵魂文件预览 │ +│ ┌──────────────────────────────────────────│ +│ │ SOUL.md ✓ 已加载 (1.6KB) │ +│ │ IDENTITY.md ✓ 已加载 (636B) │ +│ │ USER.md ✓ 已加载 (237B) │ +│ │ AGENTS.md ✓ 已加载 (7.8KB) │ +│ │ TOOLS.md ✓ 已加载 (860B) │ +│ │ MEMORY.md ✕ 未找到 │ +│ │ memory/ 2 个日志文件 │ +│ └──────────────────────────────────────────│ +│ │ +│ [附身] [刷新] │ +│ │ +│ 注意:附身后,助手将使用该 Agent 的人格、│ +│ 记忆和用户偏好。可随时切回默认。 │ +│ │ +│ ─── 当选择「ClawPanel 默认」时显示 ──── │ +│ │ +│ 助手名称: [晴辰助手 ] │ +│ 助手性格: [________________________] │ +│ │ +└─────────────────────────────────────────────┘ +``` + +#### 附身后的效果 + +| 维度 | 默认模式 | 附身模式 | +|------|----------|----------| +| 名称 | "晴辰助手" | IDENTITY.md 中的名称 | +| 性格 | 简洁专业 | SOUL.md 定义的风格 | +| 称呼用户 | "你" | USER.md 中的称呼(如"爸爸") | +| 行为规则 | ClawPanel 内置 | AGENTS.md 的规则体系 | +| 记忆 | 无 | MEMORY.md + 每日记忆 | +| 工具知识 | ClawPanel 内置 | TOOLS.md 的本地笔记 | +| 工具能力 | 保持不变 | 保持 ClawPanel 的工具 | + +**关键设计**:附身只替换"灵魂"(system prompt),**工具能力保持 ClawPanel 的**。 +因为 OpenClaw 的工具(exec/read/edit/write)和 ClawPanel 的工具本质相同, +但 ClawPanel 有独有的 docker/ssh/搜索等扩展工具,这些要保留。 + +### 4B:自定义知识库 + +在灵魂移植之外,仍然支持用户上传额外的知识文档: + +#### 数据存储 +``` +~/.openclaw/clawpanel-kb/ + ├── index.json # 知识库索引 + ├── docs/ + │ ├── api-guide.md # 用户上传的文档 + │ ├── faq.md + │ └── deploy-notes.txt + └── chunks/ # 分块索引(可选,用于大文档) + └── ... +``` + +#### 实现方案 + +**V1(简单方案)**: +- 小文档(<8KB)直接全文注入 system prompt 尾部 +- 大文档做关键词搜索(正则匹配 + 上下文窗口) +- 总注入 token 上限:4000 tokens +- 知识库和灵魂移植可叠加使用 + +**V2(进阶方案)**: +- embedding 语义搜索 +- `search_knowledge` 工具让 AI 按需检索 + +### 优先级:� 高(灵魂移植是杀手级差异化功能) +### 工时估算:灵魂移植 1-2 天,自定义知识库 V1 额外 1 天 + +--- + +## 模块五:模型配置自动导入 + +### 场景 +- 用户已安装 OpenClaw 并配置了模型 +- ClawPanel AI 助手需要单独配置模型(目前手动填写) +- 一键从 OpenClaw 配置导入,省去重复配置 + +### 实现 + +#### 数据来源(两个层级) + +**层级 1:全局配置** `~/.openclaw/openclaw.json` +```json +{ + "models": { + "providers": { + "shengsuanyun": { + "baseUrl": "http://127.0.0.1:8082/v1", + "apiKey": "sk-xxx", + "api": "openai-completions" + } + } + } +} +``` + +**层级 2:Agent 模型注册表** `~/.openclaw/agents//agent/models.json` +```json +{ + "providers": { + "openai": { + "baseUrl": "http://127.0.0.1:8082/v1", + "apiKey": "sk-eB3ybVNFvqB4fGrTUp3F8Lq16QxF7tut", + "api": "openai-completions", + "models": [ + { "id": "gpt-5.4", "name": "gpt-5.4", "contextWindow": 200000, "maxTokens": 8192 }, + { "id": "gpt-5.2-codex", "name": "gpt-5.2-codex", ... } + ] + } + } +} +``` + +**推荐优先读取 Agent 的 models.json**——它有完整的 baseUrl + apiKey + models 列表, +一键就能填充 ClawPanel 助手的配置。 + +#### 读取逻辑 +```js +async function discoverOpenClawModels() { + const home = await getHomeDir() + const results = [] + + // 1. 扫描所有 Agent 的 models.json + try { + const agents = await api.assistantListDir(`${home}/.openclaw/agents`) + for (const agentId of agents.split('\n').map(s => s.trim()).filter(Boolean)) { + try { + const raw = await api.assistantReadFile(`${home}/.openclaw/agents/${agentId}/agent/models.json`) + const data = JSON.parse(raw) + for (const [providerId, provider] of Object.entries(data.providers || {})) { + results.push({ + source: `Agent: ${agentId}`, + providerId, + baseUrl: provider.baseUrl, + apiKey: provider.apiKey, + apiType: provider.api === 'openai-completions' ? 'openai' : provider.api, + models: (provider.models || []).map(m => m.id || m.name), + }) + } + } catch {} + } + } catch {} + + // 2. 读取全局 openclaw.json 作为补充 + try { + const raw = await api.assistantReadFile(`${home}/.openclaw/openclaw.json`) + const config = JSON.parse(raw) + for (const [providerId, provider] of Object.entries(config.models?.providers || {})) { + // 去重:如果 Agent models.json 已有相同 providerId,跳过 + if (!results.find(r => r.providerId === providerId)) { + results.push({ + source: '全局配置', + providerId, + baseUrl: provider.baseUrl, + apiKey: provider.apiKey, + apiType: 'openai', + models: [], // 全局配置没有 models 列表 + }) + } + } + } catch {} + + return results +} +``` + +#### UI:模型配置 tab 新增「导入」按钮 + +``` +┌─────────────────────────────────────────────┐ +│ API Base URL API 类型 │ +│ [________________________] [OpenAI 兼容 ▼] │ +│ │ +│ API Key [测试] [拉取] [导入] │ ← 新增「导入」按钮 +│ [________________________] │ +│ │ +│ 模型 温度 │ +│ [________________________] [0.7] │ +│ │ +└─────────────────────────────────────────────┘ +``` + +点击「导入」弹出选择面板: + +``` +┌─────────────────────────────────────────────┐ +│ 从 OpenClaw 导入模型配置 │ +│ │ +│ 检测到以下已配置的服务商: │ +│ │ +│ ○ OpenAI │ +│ https://api.openai.com/v1 │ +│ 模型: gpt-4o, gpt-4o-mini │ +│ │ +│ ○ DeepSeek │ +│ https://api.deepseek.com │ +│ 模型: deepseek-chat, deepseek-reasoner │ +│ │ +│ ○ 本地 Ollama │ +│ http://127.0.0.1:11434/v1 │ +│ 模型: qwen2.5:7b │ +│ │ +│ 选择一个服务商,自动填充配置。 │ +│ [取消] [导入] │ +└─────────────────────────────────────────────┘ +``` + +### 后端 + +``` +Tauri: 已有 read_openclaw_config 命令 +dev-api.js: 已有 read_config handler + +// 只需在前端加一个读取+解析+填充的逻辑 +``` + +### 优先级:高(零成本,纯前端,极大提升体验) +### 工时估算:0.5 天 + +--- + +## 实施路线图 + +### Phase 1:快速见效(1-2 天) +| 序号 | 功能 | 工时 | 理由 | +|------|------|------|------| +| 1 | **模型配置自动导入** | 0.5d | 读 Agent models.json → 一键填充,纯前端零风险 | +| 2 | **联网搜索工具** | 0.5-1d | DuckDuckGo + Jina,免费无 Key | +| 3 | **灵魂移植(借尸还魂)** | 1-2d | 杀手级差异化——读 SOUL/IDENTITY/USER/AGENTS/MEMORY → 变身 | + +### Phase 2:核心扩展(2-3 天) +| 序号 | 功能 | 工时 | 理由 | +|------|------|------|------| +| 4 | **Docker/WSL 工具** | 1-2d | 解决用户最常见的安装困惑 | +| 5 | **自定义知识库 V1** | 1d | 用户上传 md/txt → 注入 prompt | + +### Phase 3:高级功能(3-5 天) +| 序号 | 功能 | 工时 | 理由 | +|------|------|------|------| +| 6 | **SSH 远程管理** | 2-3d | 价值最高但复杂度也最高 | +| 7 | **知识库 V2(语义搜索)** | 3-5d | 依赖 embedding API | + +--- + +## 设置面板 Tab 规划 + +当前 3 个 Tab → 扩展为 5 个 Tab: + +``` +模型配置 │ 工具权限 │ 知识库 │ 远程连接 │ 助手人设 +``` + +### 工具权限 Tab 最终形态 + +``` +基础工具 + ☑ 终端工具 — 允许执行 Shell 命令 + ☑ 文件工具 — 允许读写文件和浏览目录 + +扩展工具 + ☐ Docker/WSL — 允许管理容器和 WSL 环境 + ☐ 联网搜索 — 允许搜索互联网和抓取网页 + ☐ SSH 远程 — 允许连接远程服务器(需先配置连接) + ☐ 知识库 — 允许检索知识库内容 + +ℹ️ 进程列表、端口检测、系统信息工具始终可用(非聊天模式下)。 +``` + +--- + +## 技术注意事项 + +### 1. Token 预算管理 +灵魂移植 + 知识库注入会占用 context window,需要精细管理: + +| 组件 | 预算 | 说明 | +|------|------|------| +| ClawPanel 基础 prompt | ~2000 tokens | 产品介绍、工具指南、技能卡片 | +| SOUL.md | ~500 tokens | 人设通常简短 | +| IDENTITY.md | ~200 tokens | 名称/风格 | +| USER.md | ~200 tokens | 用户档案 | +| AGENTS.md | ~3000 tokens | 操作规则(最大,可截断) | +| TOOLS.md | ~300 tokens | 工具笔记 | +| MEMORY.md | ~2000 tokens | 长期记忆(截断保留最近部分) | +| 每日记忆 (3天) | ~1500 tokens | 自动截断 | +| 自定义知识库 | ~4000 tokens | 用户上传文档 | +| 搜索结果 | ~2000 tokens | web_search 返回内容 | +| **总计上限** | **~16000 tokens** | 留足空间给对话历史 | + +策略: +- AGENTS.md 超过 3000 tokens 时截断尾部,保留前面的核心规则 +- MEMORY.md 超过 2000 tokens 时只保留最后 2000 tokens +- 每日记忆超过 500 tokens/天时截断 +- 灵魂文件加载时计算总 token 并在 UI 中显示 + +### 2. 跨平台兼容 +- Docker CLI 在 Windows/Mac/Linux 都可用 +- WSL 仅 Windows +- SSH 密钥路径:Windows 用 `%USERPROFILE%\.ssh\`,Mac/Linux 用 `~/.ssh/` + +### 3. 安全存储 +- SSH 密码/API Key: + - Tauri 模式:使用 keytar 或 tauri-plugin-store 加密存储 + - Web 模式:仅支持密钥认证(不存储密码) +- 知识库文件:存储在 `~/.openclaw/` 下,与用户数据同目录 + +### 4. 工具发现 +AI 模型需要知道哪些工具可用。当前已在 `buildSystemPrompt()` 中列出技能卡片。 +新增工具后,需要在系统提示词中补充使用指南(类似现有的 `ask_user` 指南)。 + +--- + +## 文件变更预估 + +| 文件 | 变更 | +|------|------| +| `src/pages/assistant.js` | TOOL_DEFS 新增 4 类 · executeTool 新增 case · getEnabledTools 新增分支 · 设置面板 UI · 模型导入弹窗 | +| `src-tauri/src/commands/assistant.rs` | 新增 Rust 命令:docker_*, wsl_*, ssh_*, web_search, fetch_url | +| `scripts/dev-api.js` | 新增 Web 模式 handler:同上 | +| `src/style/assistant.css` | 知识库管理 UI · SSH 连接管理 UI · 导入弹窗样式 | +| `src/pages/assistant.js` (prompt) | 系统提示词新增各工具使用指南 | diff --git a/docs/docker-multi-instance-plan.md b/docs/docker-multi-instance-plan.md new file mode 100644 index 00000000..0851aa03 --- /dev/null +++ b/docs/docker-multi-instance-plan.md @@ -0,0 +1,494 @@ +# ClawPanel Docker 多实例管理 — 技术规划 + +> 版本: v1.0 | 日期: 2026-03-08 + +## 1. 问题分析 + +### 1.1 现状 + +ClawPanel 当前架构是 **单实例管理**: + +``` +浏览器 → ClawPanel 前端 + │ + ├── /__api/* → dev-api.js → 读写本机 ~/.openclaw/ 文件 + ├── /ws → 代理到本机 Gateway:18789 (WebSocket) + └── 静态文件 → dist/ +``` + +**所有页面**(模型配置、Agent 管理、Gateway 设置、日志、聊天等)操作的都是: +- 本机文件系统上的 `~/.openclaw/openclaw.json` +- 本机运行的 Gateway 进程(端口 18789) + +### 1.2 Phase 1 已完成 + +Docker 集群页面实现了 **容器生命周期管理**(通过 Docker Socket API): +- 启动/停止/重启/删除容器 +- 部署新容器(端口映射、数据卷、环境变量) +- 查看容器日志 +- 多节点管理(本机 + 远程 Docker 主机) + +### 1.3 缺口 + +Docker 页面能管容器的"壳",但 **无法管理容器里的 OpenClaw**: +- 无法配置某个容器内的模型 +- 无法查看某个容器内的 Gateway 日志 +- 无法管理某个容器内的 Agent +- 聊天功能只连本机 Gateway + +--- + +## 2. 目标架构 + +### 2.1 核心思路:API 代理 + 实例切换 + +``` +┌──────────────────────────────────────────────────┐ +│ ClawPanel 前端 │ +│ ┌────────────────────────────────────────────┐ │ +│ │ 实例切换器: [ ● 本机 ▼ ] │ │ +│ │ [ ○ prod-server (Docker) ] │ │ +│ │ [ ○ dev-box (远程) ] │ │ +│ │ [ + 添加实例 ] │ │ +│ └────────────────────────────────────────────┘ │ +│ │ +│ 现有页面(模型/Agent/Gateway/日志/聊天...) │ +│ │ │ +│ │ api.readOpenclawConfig() │ +│ │ api.listAgents() │ +│ ▼ │ +│ tauri-api.js → webInvoke('read_openclaw_config') │ +│ │ │ +│ 自动附带 instanceId │ +└──────────────────┼───────────────────────────────┘ + ▼ + dev-api.js (本机后端) + │ + ┌────────┼────────┐ + ▼ ▼ ▼ + 本机文件 代理转发 代理转发 + ~/.openclaw ↓ ↓ + 实例 A 实例 B + http://host http://192.168.1.100 + :18790 :1420 + /__api/* /__api/* +``` + +**关键点:每个 Docker 容器运行 full 镜像,内含完整的 ClawPanel (serve.js) + Gateway。** +因此每个容器已经有自己的 `/__api/*` 端点,我们只需要代理请求过去。 + +### 2.2 WebSocket 连接 + +``` +切换实例时: + wsClient.disconnect() ← 断开旧连接 + wsClient.connect(newHost, newToken) ← 连接新实例的 Gateway +``` + +WebSocket 连接信息从目标实例的配置中读取(通过代理 API 获取 `read_openclaw_config`)。 + +### 2.3 自动组网流程 + +部署新容器时自动完成: + +``` +用户点击「部署容器」 + │ + ├─ 1. Docker API 创建容器(端口映射 hostPort→1420, hostPort→18789) + ├─ 2. 启动容器,等待健康检查通过 + ├─ 3. 探测容器 Panel 端点:GET http://hostIP:hostPort/__api/check_installation + ├─ 4. 自动写入实例注册表 ~/.openclaw/instances.json + └─ 5. 前端自动刷新实例列表 +``` + +--- + +## 3. 数据结构 + +### 3.1 实例注册表 + +文件位置:`~/.openclaw/instances.json` + +```json +{ + "activeId": "local", + "instances": [ + { + "id": "local", + "name": "本机", + "type": "local", + "endpoint": null, + "gatewayPort": 18789, + "addedAt": 1741420800, + "note": "" + }, + { + "id": "docker-abc123", + "name": "openclaw-prod", + "type": "docker", + "endpoint": "http://127.0.0.1:18790", + "gatewayPort": 18789, + "containerId": "abc123def456", + "nodeId": "local", + "addedAt": 1741420900, + "note": "生产环境" + }, + { + "id": "remote-1", + "name": "办公室服务器", + "type": "remote", + "endpoint": "http://192.168.1.100:1420", + "gatewayPort": 18789, + "addedAt": 1741421000, + "note": "" + } + ] +} +``` + +**三种实例类型:** + +| type | 说明 | 来源 | +|------|------|------| +| `local` | 本机 OpenClaw | 始终存在,不可删除 | +| `docker` | Docker 容器内的 OpenClaw | 部署容器时自动注册 | +| `remote` | 远程服务器上的 OpenClaw | 用户手动添加 | + +### 3.2 实例状态(运行时,不持久化) + +```js +{ + id: 'docker-abc123', + online: true, // 健康检查结果 + version: '2026.3.5', // OpenClaw 版本 + gatewayRunning: true, // Gateway 状态 + lastCheck: 1741420999, // 上次检查时间 +} +``` + +--- + +## 4. 改动清单 + +### 4.1 后端 dev-api.js + +#### 4.1.1 实例注册表管理(新增) + +``` +新增 handlers: + instance_list → 读取 instances.json + instance_add → 添加实例(手动或自动) + instance_remove → 删除实例 + instance_set_active → 切换活跃实例 + instance_health_check → 健康检查单个实例 + instance_health_all → 批量健康检查 +``` + +#### 4.1.2 API 代理转发(核心改动) + +改造 `_apiMiddleware`: + +```js +// 伪代码 +async function _apiMiddleware(req, res, next) { + if (!req.url?.startsWith('/__api/')) return next() + + const cmd = extractCmd(req.url) + const body = await readBody(req) + + // 实例管理命令 → 始终本机处理 + if (cmd.startsWith('instance_') || cmd.startsWith('docker_') || ALWAYS_LOCAL.has(cmd)) { + return handleLocally(cmd, body, res) + } + + // 获取当前活跃实例 + const active = getActiveInstance() + + if (active.type === 'local') { + // 本机 → 直接处理(现有逻辑不变) + return handleLocally(cmd, body, res) + } + + // 远程/Docker 实例 → 代理转发 + return proxyToInstance(active, cmd, body, res) +} +``` + +**始终在本机处理的命令(ALWAYS_LOCAL):** +- `instance_*` — 实例管理本身 +- `docker_*` — Docker 容器管理 +- `auth_*` — 认证 +- `read_panel_config` / `write_panel_config` — 本地面板配置 +- `assistant_*` — AI 助手(操作本机文件系统) + +**通过代理转发的命令:** +- `read_openclaw_config` / `write_openclaw_config` — 目标实例的配置 +- `get_services_status` / `start_service` / `stop_service` — 目标实例的服务 +- `list_agents` / `add_agent` / `delete_agent` — 目标实例的 Agent +- `read_log_tail` / `search_log` — 目标实例的日志 +- `get_version_info` / `upgrade_openclaw` — 目标实例的版本 +- `list_memory_files` / `read_memory_file` — 目标实例的记忆文件 +- `read_mcp_config` / `write_mcp_config` — 目标实例的 MCP 配置 +- 等其他 OpenClaw 相关命令 + +#### 4.1.3 代理转发实现 + +```js +async function proxyToInstance(instance, cmd, body, res) { + const url = `${instance.endpoint}/__api/${cmd}` + try { + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + const data = await resp.text() + res.writeHead(resp.status, { 'Content-Type': 'application/json' }) + res.end(data) + } catch (e) { + res.writeHead(502, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: `实例 ${instance.name} 不可达: ${e.message}` })) + } +} +``` + +#### 4.1.4 Docker 部署自动注册 + +修改 `docker_create_container` handler: +- 容器创建并启动后,自动等待健康检查 +- 通过 `GET http://hostIP:panelPort/__api/check_installation` 验证 +- 健康检查通过后自动写入 `instances.json` +- 返回结果包含 `instanceId` + +### 4.2 前端 tauri-api.js + +#### 4.2.1 新增实例管理 API + +```js +// 实例管理 +instanceList: () => cachedInvoke('instance_list', {}, 10000), +instanceAdd: (instance) => { invalidate('instance_list'); return invoke('instance_add', instance) }, +instanceRemove: (id) => { invalidate('instance_list'); return invoke('instance_remove', { id }) }, +instanceSetActive: (id) => { invalidate('instance_list'); _cache.clear(); return invoke('instance_set_active', { id }) }, +instanceHealthCheck: (id) => invoke('instance_health_check', { id }), +instanceHealthAll: () => invoke('instance_health_all'), +``` + +**注意 `instanceSetActive` 清空全部缓存**,因为切换实例后所有缓存数据都过期了。 + +#### 4.2.2 无需改动的部分 + +现有的 `api.readOpenclawConfig()`、`api.listAgents()` 等方法 **完全不变**。 +代理逻辑在后端 `_apiMiddleware` 层透明处理。 + +### 4.3 前端 app-state.js + +新增: + +```js +let _activeInstance = { id: 'local', name: '本机', type: 'local' } +let _instanceListeners = [] + +export function getActiveInstance() { return _activeInstance } +export function onInstanceChange(fn) { ... } + +export async function switchInstance(id) { + // 1. 调后端切换 + await api.instanceSetActive(id) + // 2. 更新本地状态 + _activeInstance = instances.find(i => i.id === id) + // 3. 清缓存 + invalidate() // 清 API 缓存 + // 4. 断开旧 WebSocket + wsClient.disconnect() + // 5. 重新检测状态 + await detectOpenclawStatus() + // 6. 连接新实例的 Gateway WebSocket + connectToActiveGateway() + // 7. 通知所有监听者(侧边栏、页面刷新) + _instanceListeners.forEach(fn => fn(_activeInstance)) +} +``` + +### 4.4 前端 sidebar.js + +在侧边栏顶部 logo 下方添加实例切换器: + +```html +
+ +
+
+ 本机 +
+
+ openclaw-prod + Docker +
+
+
+ + 添加实例 +
+
+
+``` + +### 4.5 前端 main.js + +`autoConnectWebSocket()` 改为读取当前活跃实例的 Gateway 端点: + +```js +async function autoConnectWebSocket() { + const instance = getActiveInstance() + if (instance.type === 'local') { + // 本机:读本地配置 + const config = await api.readOpenclawConfig() + const port = config?.gateway?.port || 18789 + wsClient.connect(`127.0.0.1:${port}`, token) + } else { + // 远程/Docker:从实例 endpoint 推导 Gateway 地址 + const config = await api.readOpenclawConfig() // 已通过代理转发 + const gwPort = config?.gateway?.port || 18789 + const url = new URL(instance.endpoint) + wsClient.connect(`${url.hostname}:${instance.gatewayPort || gwPort}`, token) + } +} +``` + +### 4.6 serve.js WebSocket 代理 + +WebSocket 代理改为动态目标: + +```js +server.on('upgrade', (req, socket, head) => { + // 从 query 或 header 中获取目标实例 + const target = resolveWsTarget(req) + const conn = net.createConnection(target.port, target.host, () => { ... }) +}) +``` + +### 4.7 docker.js 集群页面 + +部署对话框增加"自动注册"逻辑: +- 容器创建成功后显示"正在等待实例就绪..." +- 健康检查通过后自动出现在实例切换器中 +- 用户可直接切换到新实例进行管理 + +### 4.8 现有页面适配 + +| 页面 | 改动 | 说明 | +|------|------|------| +| dashboard.js | 极小 | 页头显示当前实例名称 | +| models.js | 无 | API 透明代理 | +| agents.js | 无 | API 透明代理 | +| gateway.js | 极小 | 远程实例时隐藏部分本机功能 | +| logs.js | 无 | API 透明代理 | +| chat.js | 无 | WebSocket 已切换到目标实例 | +| chat-debug.js | 无 | API 透明代理 | +| memory.js | 无 | API 透明代理 | +| services.js | 小 | 已有 Docker 适配,远程实例时隐藏 npm/CLI 相关 | +| extensions.js | 小 | 远程实例时 cftunnel/clawapp 不可用 | +| skills.js | 无 | API 透明代理 | +| security.js | 小 | 远程实例的密码管理走代理 | +| setup.js | 小 | 远程实例不需要 setup 流程 | +| assistant.js | 特殊 | AI 助手始终操作本机(ALWAYS_LOCAL) | + +--- + +## 5. 实施步骤 + +### Step 1: 实例注册表后端(dev-api.js) +- `readInstances()` / `saveInstances()` 工具函数 +- 6 个 handler:`instance_list` / `add` / `remove` / `set_active` / `health_check` / `health_all` +- 预计:~150 行 + +### Step 2: API 代理转发(dev-api.js) +- 改造 `_apiMiddleware` 添加代理逻辑 +- `proxyToInstance()` 函数 +- `ALWAYS_LOCAL` 命令集合 +- 预计:~80 行 + +### Step 3: 前端实例管理 API(tauri-api.js) +- 新增 `api.instance*` 方法 + mock 数据 +- 预计:~40 行 + +### Step 4: 前端状态管理(app-state.js) +- `_activeInstance` 状态 + `switchInstance()` 函数 +- 预计:~50 行 + +### Step 5: 实例切换器 UI(sidebar.js) +- 下拉选择器组件 + CSS +- 预计:~100 行 JS + ~80 行 CSS + +### Step 6: WebSocket 动态连接(main.js + serve.js) +- 切换实例时重新连接 WebSocket +- serve.js WebSocket 代理动态化 +- 预计:~40 行 + +### Step 7: Docker 部署自动注册(docker.js + dev-api.js) +- `docker_create_container` 完成后自动注册 +- 健康检查 + 就绪等待 +- 预计:~60 行 + +### Step 8: 页面微调 +- dashboard 显示实例名 +- 远程实例时隐藏本机独占功能 +- 预计:~30 行 + +**总计新增代码:约 600 行** + +--- + +## 6. 安全考虑 + +### 6.1 认证 +- 远程实例可能有不同的访问密码 +- 代理转发时需要携带目标实例的认证凭据 +- 首次连接时提示输入密码,存入 `instances.json`(加密存储待定) + +### 6.2 网络安全 +- Docker 容器默认只暴露在宿主机网络 +- 远程实例建议通过 SSH 隧道或 VPN 连接 +- 不建议在公网暴露 `/__api/` 端点而不加密码 + +### 6.3 权限隔离 +- AI 助手(assistant_*)始终操作本机文件系统,不代理到远程 +- Docker 管理(docker_*)始终操作本机 Docker,不代理 + +--- + +## 7. 边界与约束 + +### 7.1 不做的事情 +- **不做** 统一聚合视图(如"查看所有实例的模型列表") +- **不做** 跨实例数据同步(如"把本机模型配置复制到远程")— 后续可做 +- **不做** 实例间负载均衡 +- **不做** 复杂的权限角色系统 + +### 7.2 前提条件 +- 远程实例必须运行 ClawPanel(serve.js),版本 >= 0.7.0 +- Docker 实例使用 full 镜像(含 Panel + Gateway) +- 网络可达(ClawPanel 后端能访问远程实例的端口) + +### 7.3 兼容性 +- 现有单实例用户 **零影响**:默认 activeId 为 "local",行为完全不变 +- 实例切换器在只有本机时可以隐藏或最小化显示 +- 所有新功能向后兼容 + +--- + +## 8. 测试计划 + +| 场景 | 验证内容 | +|------|---------| +| 纯本机使用 | 现有功能不受影响,无回归 | +| 部署 Docker 容器 | 自动注册为可管理实例 | +| 切换到 Docker 实例 | 模型/Agent/日志等页面显示容器内数据 | +| 切换实例后聊天 | WebSocket 连接到正确的 Gateway | +| 远程实例离线 | 优雅报错,可切回本机 | +| 删除 Docker 容器 | 实例列表自动移除 | +| 多实例批量健康检查 | 侧边栏状态点实时更新 | diff --git a/docs/i18n-plan.md b/docs/i18n-plan.md new file mode 100644 index 00000000..2a7cfa4c --- /dev/null +++ b/docs/i18n-plan.md @@ -0,0 +1,288 @@ +# ClawPanel i18n 国际化方案 + +> 本文档是 ClawPanel 多语言国际化的完整技术方案和实施指南。 +> 任何后续会话开始 i18n 工作时,请先阅读本文档。 + +## 一、现状评估 + +- **硬编码中文行数**:3508 行(分布在 25+ 个 JS 文件中) +- **预估翻译字符串数**:约 1500+ 个 +- **技术栈**:纯 Vanilla JS(无 React/Vue),Tauri v2 桌面应用 +- **当前语言**:仅中文 + +### 文件中文行数分布(Top 15) + +| 行数 | 文件 | 模块 | +|------|------|------| +| 838 | assistant.js | AI 助手(含内嵌知识库) | +| 312 | docker.js | Docker 集群管理 | +| 243 | models.js | 模型配置 | +| 183 | chat.js | 实时聊天 | +| 156 | chat-debug.js | 系统诊断 | +| 148 | openclaw-kb.js | 知识库文本 | +| 142 | setup.js | 初始安装引导 | +| 136 | channels.js | 消息渠道 | +| 136 | main.js | 主入口/路由/横幅 | +| 120 | services.js | 服务管理 | +| 105 | about.js | 关于页面 | +| 93 | cron.js | 定时任务 | +| 88 | dashboard.js | 仪表盘 | +| 72 | extensions.js | 扩展工具 | +| 68 | gateway.js | 网关配置 | + +## 二、技术架构 + +### 核心模块:`src/lib/i18n.js` + +```js +// 使用方式 +import { t, setLocale, getLocale } from '../lib/i18n.js' + +// 简单翻译 +t('common.save') // → "保存" / "Save" +t('common.cancel') // → "取消" / "Cancel" + +// 带参数插值 +t('chat.messageCount', { count: 5 }) // → "5 条消息" / "5 messages" + +// 嵌套 key +t('dashboard.gateway.running') // → "运行中" / "Running" + +// 切换语言 +setLocale('en') // 存 localStorage,触发页面重渲染 +``` + +### 语言检测优先级 + +1. `localStorage` 中存储的用户选择 (`clawpanel-locale`) +2. 浏览器 `navigator.language`(`zh-CN` → `zh-CN`,`en-US` → `en`) +3. 默认值:`zh-CN` + +### 缺失翻译 fallback + +1. 查找当前语言包 +2. 查找 `zh-CN` 兜底(中文作为最完整的语言) +3. 返回 key 本身(如 `common.save`) +4. 开发模式下 console.warn 提示缺失翻译 + +## 三、语言包结构 + +``` +src/locales/ + zh-CN.json — 中文简体(默认,最完整) + en.json — English + zh-TW.json — 中文繁体(未来) + ja.json — 日本語(未来) + ko.json — 한국어(未来) +``` + +### JSON 格式规范 + +按模块/页面分组,使用扁平化嵌套结构: + +```json +{ + "common": { + "save": "保存", + "cancel": "取消", + "delete": "删除", + "confirm": "确定", + "close": "关闭", + "loading": "加载中...", + "error": "错误", + "success": "成功", + "warning": "警告", + "retry": "重试", + "refresh": "刷新", + "edit": "编辑", + "create": "创建", + "back": "返回", + "next": "下一步", + "search": "搜索", + "copy": "复制", + "download": "下载", + "upload": "上传", + "enable": "启用", + "disable": "禁用", + "start": "启动", + "stop": "停止", + "restart": "重启", + "status": "状态", + "running": "运行中", + "stopped": "已停止", + "unknown": "未知", + "noData": "暂无数据", + "operationFailed": "操作失败: {error}", + "confirmDelete": "确定删除 {name}?", + "savedSuccessfully": "已保存" + }, + "sidebar": { + "dashboard": "仪表盘", + "assistant": "晴辰助手", + "chat": "实时聊天", + "services": "服务管理", + "logs": "日志查看", + "models": "模型配置", + "agents": "Agent 管理", + "memory": "记忆文件", + "channels": "消息渠道", + "gateway": "网关配置", + "skills": "Skills 工具", + "docker": "Docker 集群", + "cron": "定时任务", + "extensions": "扩展工具", + "about": "关于", + "setup": "初始设置", + "chatDebug": "系统诊断" + }, + "dashboard": { ... }, + "chat": { ... }, + "models": { ... }, + ... +} +``` + +## 四、迁移步骤(每个页面) + +### Step 1: 提取中文字符串 + +使用正则或手动扫描,将所有中文文本提取到对应的语言包 key 下。 + +**需要翻译的内容**: +- UI 文本(按钮文字、标题、描述、提示) +- toast 消息 +- 错误消息 +- placeholder 文本 +- confirm 对话框文本 +- tooltip 文本 + +**不需要翻译的内容**: +- 代码注释(保持中文) +- console.log 调试信息 +- 技术标识符(如 `Gateway`、`Agent`、`OpenClaw`) +- API 错误消息(后端返回的) +- 知识库内容 `openclaw-kb.js`(这个特殊处理,按语言版本分文件) + +### Step 2: 替换代码中的硬编码 + +```js +// Before +toast('保存成功', 'success') + +// After +toast(t('common.savedSuccessfully'), 'success') +``` + +```js +// Before (HTML 模板) +`` + +// After +`` +``` + +### Step 3: 编写英文翻译 + +逐 key 翻译到 `en.json`。 + +### Step 4: 测试 + +切换语言,检查每个页面的显示是否正常。 + +## 五、迁移顺序 + +### 第一批(基础 + 框架层,约 80 个字符串) +1. `src/lib/i18n.js` — 创建核心模块 +2. `src/locales/zh-CN.json` — 初始化中文包 +3. `src/locales/en.json` — 初始化英文包 +4. `src/components/sidebar.js` — 导航菜单(~20 个) +5. `src/components/modal.js` — 公共弹窗(~10 个) +6. `src/components/toast.js` — 提示组件 +7. `src/pages/about.js` — 关于页面 + 语言切换 UI(~30 个) + +### 第二批(核心页面,约 250 个字符串) +8. `src/pages/dashboard.js` — 仪表盘(~50 个) +9. `src/pages/setup.js` — 初始设置(~80 个) +10. `src/pages/chat.js` — 实时聊天(~100 个) +11. `src/main.js` — 主入口/横幅(~20 个) + +### 第三批(配置页面,约 350 个字符串) +12. `src/pages/models.js` — 模型配置(~120 个) +13. `src/pages/channels.js` — 消息渠道(~80 个) +14. `src/pages/services.js` — 服务管理(~70 个) +15. `src/pages/gateway.js` — 网关配置(~40 个) +16. `src/pages/agents.js` — Agent 管理(~40 个) + +### 第四批(功能页面,约 250 个字符串) +17. `src/pages/cron.js` — 定时任务(~50 个) +18. `src/pages/memory.js` — 记忆管理(~30 个) +19. `src/pages/extensions.js` — 扩展工具(~40 个) +20. `src/pages/logs.js` — 日志查看(~20 个) +21. `src/pages/skills.js` — Skills 工具(~60 个) +22. `src/pages/chat-debug.js` — 系统诊断(~50 个) + +### 第五批(大型页面 + 特殊处理,约 600 个字符串) +23. `src/pages/docker.js` — Docker 管理(~150 个) +24. `src/pages/assistant.js` — AI 助手(~400 个,含系统提示词) +25. `src/lib/openclaw-kb.js` — 知识库(按语言分文件) +26. `src/lib/error-diagnosis.js` — 错误诊断(~30 个) +27. `src/components/engagement.js` — 推荐弹窗(~15 个) + +### 第六批(官网 + 文档) +28. `docs/index.html` — 官网英文版 +29. `README.md` → `README_en.md` +30. `CONTRIBUTING.md` → `CONTRIBUTING_en.md` + +## 六、语言切换 UI 设计 + +### 位置 +1. **关于页面底部** — 语言选择下拉框 +2. **侧边栏底部** — 语言图标 + 当前语言缩写(如 `中` / `EN`) + +### 交互 +- 选择语言 → 存入 localStorage → 页面自动刷新 +- 首次访问自动检测浏览器语言 + +## 七、注意事项 + +### 技术品牌词不翻译 +以下词保持原样,不翻译: +- `OpenClaw` +- `ClawPanel` +- `Gateway` +- `Agent`(Agent 管理不翻译为"代理") +- `MCP` +- `Skills` +- `Docker` +- `Tauri` + +### 参数插值语法 +使用 `{param}` 语法: +```json +{ + "chat.sessions": "{count} sessions", + "models.providers": "Based on {count} providers" +} +``` + +### 复数形式 +英文需要处理复数,但 MVP 阶段可以用简单方式: +```json +{ + "chat.messageCount": "{count} message(s)" +} +``` + +### Rust 后端 +后端错误消息暂不国际化(工作量大且用户较少直接看到),保持中文。 + +## 八、验证清单 + +每批迁移完成后检查: +- [ ] 中文模式下所有功能正常 +- [ ] 英文模式下所有功能正常 +- [ ] 语言切换后页面正确刷新 +- [ ] 没有遗漏的硬编码中文 +- [ ] 参数插值正确显示 +- [ ] 长英文文本不溢出布局 +- [ ] toast/modal/confirm 文本正确 diff --git a/docs/plans/2026-03-16-cloudflared-openclaw-integration-design.md b/docs/plans/2026-03-16-cloudflared-openclaw-integration-design.md new file mode 100644 index 00000000..3c8d590e --- /dev/null +++ b/docs/plans/2026-03-16-cloudflared-openclaw-integration-design.md @@ -0,0 +1,59 @@ +# Cloudflared 公网访问与 OpenClaw 兼容导入设计 + +日期:2026-03-16 + +## 目标 +- 在 ClawPanel 设置页新增“公网访问(Cloudflared)”Tab +- 支持快速隧道与命名隧道,进入页面后由用户选择 +- 一键认证登录(允许弹出浏览器完成 cloudflared login) +- 默认暴露 OpenClaw Gateway(18789) +- 读取用户已安装 OpenClaw 的 `C:\Users\\.openclaw\openclaw.json`,只读导入并做兼容或升级 + +## 约束与偏好 +- 不要求用户输入隧道 Token +- cloudflared.exe 优先 PATH 检测,其次 `~/.openclaw/bin`,不存在则复用 label-printer 的加速域名检测与下载逻辑 +- 不覆盖原始 openclaw.json,仅生成 ClawPanel 本地配置副本 + +## 方案对比 +### 方案 A(推荐) +- 内置 Cloudflared 管理器 + 一键登录 + 快速/命名双模式 +- 优点:体验一致,符合“一键认证登录” +- 风险:需要完整接入本地进程管理与状态监控 + +### 方案 B +- 仅提供外部 cloudflared 路径配置 +- 优点:实现快 +- 缺点:不满足“一键认证登录” + +### 方案 C +- A 为主,保留手动路径作为兜底 +- 优点:兼容性强 +- 代价:UI 复杂度增加 + +推荐:方案 A,保留手动路径兜底入口 + +## 设计 Section 1:架构与入口 +- 入口:设置页新增“公网访问(Cloudflared)”Tab +- 核心模块: + 1) Cloudflared 管理器(检测、下载、启动、停止、状态) + 2) 隧道管理(快速隧道/命名隧道) + 3) OpenClaw 配置导入(读取并兼容升级) +- 数据流:UI → IPC → cloudflared → 状态回传 → UI + +## 设计 Section 2:获取与一键认证 +- 检测顺序:PATH → `~/.openclaw/bin/cloudflared.exe` → 下载 +- 下载策略:加速域名检测失败则回退官方下载 +- 一键认证:执行 `cloudflared tunnel login`,浏览器授权完成后保存凭据 +- 失败提示与重试 + +## 设计 Section 3:隧道类型与运行流程 +- 快速隧道:临时访问 +- 命名隧道:固定域名与服务 +- 流程:选择类型 → 选择服务(默认 18789)→ 启动 → 展示地址/状态 +- 错误处理:启动失败、登录失效、端口占用 + +## 设计 Section 4:OpenClaw 配置导入 +- 默认读取:`C:\Users\\.openclaw\openclaw.json` +- 兼容升级:缺失字段补全、旧字段映射 +- 只读导入,不覆盖原配置 +- 失败回退为只读展示 diff --git a/docs/plans/2026-03-16-cron-sessionmessage-tool-ui-design.md b/docs/plans/2026-03-16-cron-sessionmessage-tool-ui-design.md new file mode 100644 index 00000000..fe98f9dd --- /dev/null +++ b/docs/plans/2026-03-16-cron-sessionmessage-tool-ui-design.md @@ -0,0 +1,55 @@ +# Cron SessionMessage + Tool UI Design + +日期:2026-03-16 + +## 目标 +- 在 ClawPanel 中新增 cron 任务类型:向指定 session 发送 user 消息 +- 支持等待对话结束(Gateway WebSocket 事件)再发送 +- Chat 页面展示工具调用(默认收起) +- 适配 npm 全局安装 OpenClaw(补丁应用与更新重打) + +## 范围 +- 新增 payload.kind = sessionMessage(Gateway cron 执行分支) +- ClawPanel cron UI 增加任务类型与字段 +- Chat UI 增加 tool call 展示 +- OpenClaw 版本更新时补丁自动重打 + +## 方案概述 +### Cron SessionMessage +- payload.kind: sessionMessage +- 字段:label, message, role=user, waitForIdle=true +- Gateway cron 执行:label -> sessionKey -> 等待 chat final -> 发送 user 消息 + +### UI 改动 +- cron 表单新增任务类型选择 +- session label 下拉(来自 sessions.list) +- message 文本输入 +- 列表与详情展示任务类型与目标 + +### Chat 工具调用展示 +- 解析 message.content 中 tool / tool_result +- 默认收起,仅显示工具名与状态 +- 点击展开显示参数与结果 JSON + +### 补丁与更新 +- 定位 npm 全局包路径(npm root -g + 包名) +- 打补丁前备份原文件 +- 写入 clawpanel.json 记录补丁版本与 OpenClaw 版本 +- 更新后检测版本变化并重打补丁 + +## 数据流 +- Cron UI -> Gateway cron.add -> payload sessionMessage +- Gateway cron -> 监听 chat final -> chat.send (role=user) +- Chat UI -> 渲染 tool call blocks + +## 错误处理 +- label 不存在:任务失败并记录错误 +- Gateway 未连接:cron UI 提示不可用 +- 补丁失败:自动回退并提示 + +## 测试要点 +- cron 创建/编辑/删除 +- sessionMessage 执行成功 +- 等待对话结束后发送 +- tool call 展示与展开 +- 补丁重打与回退 diff --git a/docs/plans/2026-03-16-gateway-patch-auto-detect-design.md b/docs/plans/2026-03-16-gateway-patch-auto-detect-design.md new file mode 100644 index 00000000..9dd17941 --- /dev/null +++ b/docs/plans/2026-03-16-gateway-patch-auto-detect-design.md @@ -0,0 +1,25 @@ +# Gateway 补丁自动检测与重打设计 + +日期:2026-03-16 + +## 目标 +- 启动与进入设置页自动检测全局 OpenClaw 版本 +- 版本变化时自动重打补丁,失败才提示 +- 过程静默、不中断使用 + +## 触发策略 +- 应用启动时触发一次检测 +- 进入设置页时触发检测 + +## 自动重打逻辑 +- 判断 installed_version 与 gatewayPatch.openclawVersion +- 不一致时调用 gateway_patch_apply(force=true) +- 使用内存节流与冷却(5 分钟)避免重复触发 + +## 状态与提示 +- 失败写入 last_error,设置页展示错误 +- 不弹窗、不阻塞其他功能 + +## 边界处理 +- 缺少 .bak 备份时,force 失败提示“缺少备份,建议先一键补丁” +- 自动重打失败只提示,不回滚、不影响其他功能 diff --git a/docs/plans/2026-03-16-gateway-patch-oneclick-design.md b/docs/plans/2026-03-16-gateway-patch-oneclick-design.md new file mode 100644 index 00000000..e265deed --- /dev/null +++ b/docs/plans/2026-03-16-gateway-patch-oneclick-design.md @@ -0,0 +1,37 @@ +# Gateway 一键补丁设计 + +日期:2026-03-16 + +## 目标 +- 在设置页提供一键补丁入口,自动对全局 npm 安装的 OpenClaw 打补丁 +- 支持检测版本、应用补丁、重打补丁、回滚 + +## 入口与交互 +- 位置:设置页「公网访问」区域旁新增“Gateway 补丁”卡片 +- 按钮:一键补丁、重打补丁、回滚 +- 状态:展示检测到的 OpenClaw 版本、补丁状态、最近操作结果 + +## 实现流程 +1. 定位全局 npm 根目录:`npm root -g` +2. 在 `node_modules` 内查找 `openclaw` 包 +3. 自动识别目标文件(reply-*.js / gateway-cli-*.js) +4. 备份文件(.bak) +5. 应用补丁(sessionMessage 支持) +6. 写入 `clawpanel.json` 记录补丁版本与 OpenClaw 版本 +7. 版本变更时提示并支持重打补丁 +8. 失败自动回滚 + +## 数据与状态 +- `clawpanel.json` 新增: + - `gatewayPatch`: { version, patchedAt, openclawVersion, files: [] } + +## 错误处理 +- 未找到 npm 根目录或包:提示错误 +- 文件名不匹配:提示错误并终止 +- 打补丁失败:回滚并记录错误 + +## 测试要点 +- 正常补丁流程 +- 回滚流程 +- 版本变化后重打补丁 +- 错误路径处理 diff --git a/docs/plans/2026-03-17-ai-config-import-design.md b/docs/plans/2026-03-17-ai-config-import-design.md new file mode 100644 index 00000000..1f4b2b0c --- /dev/null +++ b/docs/plans/2026-03-17-ai-config-import-design.md @@ -0,0 +1,37 @@ +# AI 配置从 openclaw 导入设计 + +日期: 2026-03-17 + +## 目标 +在 AI 配置页提供“从 openclaw 导入”功能,导入模型参数 + API Key + Base URL。 + +## 方案 +- 采用方案 A:手动按钮触发导入 + +## 设计细节 +### 1) 导入入口 +- AI 配置页顶部操作区新增按钮:`从 openclaw 导入` + +### 2) 导入内容 +- 读取 `openclaw.json` +- 提取字段: + - model + - temperature + - top_p + - api_key + - base_url +- 写回当前 AI 配置表单并持久化 + +### 3) 反馈 +- 成功:toast 提示“已导入” +- 失败:toast 提示读取失败或字段缺失 + +## 影响范围 +- src/pages/models.js +- src/lib/tauri-api.js +- src-tauri/src/commands/config.rs + +## 测试要点 +- openclaw.json 有效 → 导入成功 +- 字段缺失 → 失败提示 +- 导入后配置可保存并生效 diff --git a/docs/plans/2026-03-17-assistant-optimize-toggle-design.md b/docs/plans/2026-03-17-assistant-optimize-toggle-design.md new file mode 100644 index 00000000..4c65c7a5 --- /dev/null +++ b/docs/plans/2026-03-17-assistant-optimize-toggle-design.md @@ -0,0 +1,27 @@ +# 晴辰助手 优化/还原按钮切换设计 + +日期: 2026-03-17 + +## 目标 +优化与还原按钮不同时出现,默认显示“优化”,优化完成后显示“还原”,发送或还原后切回“优化”。 + +## 方案 +- 采用方案 A:同位置切换显示 + +## 设计细节 +### 1) UI 结构 +- 保留两个按钮节点 +- 通过状态控制显示 + +### 2) 状态逻辑 +- `_optOriginalText` 为空:显示“优化”,隐藏“还原” +- `_optOriginalText` 非空:隐藏“优化”,显示“还原” +- `clearOptimizeSnapshot()` 或点击还原后切回“优化” + +## 影响范围 +- src/pages/assistant.js + +## 测试要点 +- 默认仅显示“优化” +- 优化完成后仅显示“还原” +- 发送或点击还原后仅显示“优化” diff --git a/docs/plans/2026-03-17-assistant-ux-and-shell-design.md b/docs/plans/2026-03-17-assistant-ux-and-shell-design.md new file mode 100644 index 00000000..9ddb66c3 --- /dev/null +++ b/docs/plans/2026-03-17-assistant-ux-and-shell-design.md @@ -0,0 +1,43 @@ +# Assistant UX + Windows Shell 优化设计 + +日期: 2026-03-17 + +## 目标 +1) Windows shell 优先 pwsh,其次 powershell,最后 cmd +2) 复制按钮样式统一,右上角悬浮显示 +3) AI 助手输入区新增“优化/恢复原文”按钮,保留撤销栈 + +## 方案 +- 采用方案 B + +## 设计细节 +### 1) assistant_exec shell 优先级 +- Windows: pwsh -> powershell -> cmd +- 执行前检测可用 shell(where / Get-Command) +- 继续使用 build_system_env() 注入完整系统环境 + +### 2) 复制按钮错位修复 +- 不改 Markdown 解析 +- chat.css 与 assistant.css 的 pre / code-copy-btn 统一 +- copy 按钮右上角悬浮,hover 显示 + +### 3) AI 优化按钮 +- 位置: AI 助手输入区,与发送按钮并列 +- 文本模板: “请在不改变原意和语言的前提下,重写为意思更清晰、更简洁的表达。” +- 点击优化:调用同模型在线重写,替换输入框文本 +- 快照:保存原文快照 + 优化结果快照 +- 恢复原文:发送前始终可用 +- 发送后清空快照 +- 替换方式: setRangeText + input 事件,保留 Ctrl+Z + +## 影响范围 +- src-tauri/src/commands/assistant.rs +- src/style/chat.css +- src/style/assistant.css +- src/pages/assistant.js +- src/lib/tauri-api.js(如需复用 call) + +## 测试要点 +- Windows 下优先使用 pwsh +- 复制按钮在 chat 与 assistant 页面一致 +- 优化/恢复流程与撤销栈可用 diff --git a/docs/plans/2026-03-17-chat-autoscroll-design.md b/docs/plans/2026-03-17-chat-autoscroll-design.md new file mode 100644 index 00000000..88b4cf02 --- /dev/null +++ b/docs/plans/2026-03-17-chat-autoscroll-design.md @@ -0,0 +1,41 @@ +# 聊天页面自动滚动行为设计 + +## 目标 +- 用户上滑查看历史时,不被强制拉回底部 +- 仅在“新消息出现”且用户处于底部时自动滚动 + +## 现状问题 +当前聊天页存在持续自动滚动到底部的行为,导致用户无法查看上方历史消息,尤其在无新消息时仍被拉回底部。 + +## 推荐方案(已确认) +**方案 A:仅在新消息出现且用户在底部时自动滚动** +- 维护“是否在底部”的状态 +- 当用户离开底部时,自动滚动关闭 +- 仅在新消息插入时、且自动滚动开启,才执行 `scrollToBottom()` + +## 关键设计点 +1. **状态管理** + - `autoScrollEnabled`:用户在底部时为 true;上滑离开底部时为 false + - 在滚动事件中更新该状态 + +2. **滚动触发点** + - 在“消息插入”函数内触发(如 `appendUserMessage` / `appendAiMessage` / `appendSystemMessage` / `createStreamBubble`) + - 渲染过程中(如 `doRender` / `doVirtualRender`)不再强制滚动 + +3. **流式输出处理** + - 若用户不在底部,则流式输出不强制跟随 + - 若用户在底部,则跟随滚动 + +4. **回到底部按钮** + - 点击按钮后强制滚动到底部并恢复 `autoScrollEnabled = true` + +## 影响范围 +- `src/pages/chat.js` +- 可能涉及 `chat.css`(若需要视觉提示或按钮行为调整) + +## 测试要点 +- 无新消息时,上滑查看历史不被拉回 +- 新消息出现时: + - 若当前在底部,自动滚动 + - 若已上滑,保持当前位置 +- 点击“回到底部”按钮后恢复自动滚动 diff --git a/docs/plans/2026-03-17-chat-daylight-shadow-design.md b/docs/plans/2026-03-17-chat-daylight-shadow-design.md new file mode 100644 index 00000000..ce6c77cf --- /dev/null +++ b/docs/plans/2026-03-17-chat-daylight-shadow-design.md @@ -0,0 +1,37 @@ +# Chat 日间模式助手气泡阴影设计 + +## 目标 +- 解决日间模式下助手气泡不明显的问题 +- 不改变夜间模式表现 +- 不改变间距、圆角、字体,仅增加轻微阴影 + +## 背景 +- 当前 `.msg-ai .msg-bubble` 使用半透明背景,在日间模式可能与底色融合,导致难以区分 +- 用户确认仅需补充日间模式阴影 + +## 方案对比 +### 方案 A(推荐) +- 仅在日间模式为 `.msg-ai .msg-bubble` 增加轻微阴影 +- 夜间模式保持不变 +- 风险最小,符合当前需求 + +### 方案 B +- 日间模式增加阴影 + 细边框 +- 对比更强,但视觉改动更明显 + +### 方案 C +- 调整日间模式背景色 + 阴影 +- 可读性提升,但偏离现有视觉体系 + +## 采用方案 +- 采用方案 A + +## 设计细节 +- 选择器:`[data-theme="light"] .msg-ai .msg-bubble` +- 阴影强度:轻微,不影响布局 +- 不改变背景色、圆角、内边距 + +## 验收标准 +- 日间模式:助手消息气泡可清晰区分 +- 夜间模式:视觉无变化 +- 不引入新的布局抖动或遮挡问题 diff --git a/docs/plans/2026-03-17-chat-history-tool-merge-design.md b/docs/plans/2026-03-17-chat-history-tool-merge-design.md new file mode 100644 index 00000000..5fb4b7f5 --- /dev/null +++ b/docs/plans/2026-03-17-chat-history-tool-merge-design.md @@ -0,0 +1,30 @@ +# 聊天历史工具卡片合并与排序设计 + +## 目标 +- 历史记录解析后,工具卡片只显示 1 份(按 toolCallId 合并) +- 工具卡片显示在消息气泡上方 +- 工具时间优先使用事件 ts,缺失回退 message.timestamp +- 历史模式不再插入工具事件系统消息 +- 历史列表按时间排序,同时间工具在上文本在下 + +## 现状问题 +- history 刷新后出现重复工具卡片(toolCall/toolResult 未合并) +- 工具时间缺失导致排序异常 +- 工具事件系统消息与工具卡片同时出现,导致页面被工具卡片占满 + +## 方案(合并式解析) +1) 统一合并:工具块解析使用 upsertTool,按 toolCallId 合并 +2) 时间回退:工具时间 = eventTs || message.timestamp || null +3) 历史禁入系统消息:history 解析不再插入工具事件系统消息 +4) 排序规则:按 time 升序;相同时间时工具卡片在上,文本在下 + +## 设计细节 +- 新增工具时间解析函数:`resolveToolTime(toolId, messageTimestamp)` +- 工具事件处理处增加 history 标记,历史模式不调用 `appendSystemMessage` +- 渲染时将工具卡片与文本作为 entries 统一排序 + +## 验收标准 +- 历史记录中工具卡片不重复 +- 刷新后工具时间可见 +- 工具卡片位于消息气泡上方 +- 列表不再被工具事件系统消息淹没 diff --git a/docs/plans/2026-03-17-chat-markdown-it-design.md b/docs/plans/2026-03-17-chat-markdown-it-design.md new file mode 100644 index 00000000..0cd2dd34 --- /dev/null +++ b/docs/plans/2026-03-17-chat-markdown-it-design.md @@ -0,0 +1,25 @@ +# Chat Markdown-it 样式解析设计 + +## 目标 +- clawpanel chat 页面支持完整 Markdown 样式:加粗、斜体、删除线、行内代码、代码块、引用、列表、链接、下划线、剧透、@提及 +- 解析行为与 GitHub 接近,安全渲染 + +## 方案 +- 将 `src/lib/markdown.js` 切换为 `markdown-it` 作为渲染核心 +- 使用插件机制实现: + - underline:`__text__` 输出 `` + - spoiler:同时支持 `||spoiler||` 与 `>!spoiler!<` + - mention:`@用户名` 输出 `@用户名` +- 代码块高亮沿用现有 `highlightCode` +- 链接白名单 `http/https/mailto`,否则降级为 `#` +- 禁止任意 HTML 注入(html=false) + +## 样式 +- `.msg-mention` 高亮 +- `.msg-spoiler` 遮罩,点击显示(`revealed` 类切换) + +## 验收标准 +- chat 页面渲染包含 E 方案的全部样式 +- `|| ||` 与 `>! !<` 均可正确显示剧透 +- @提及高亮,非链接 +- 不引入 HTML 注入风险 diff --git a/docs/plans/2026-03-17-chat-tool-event-live-design.md b/docs/plans/2026-03-17-chat-tool-event-live-design.md new file mode 100644 index 00000000..60ce4782 --- /dev/null +++ b/docs/plans/2026-03-17-chat-tool-event-live-design.md @@ -0,0 +1,34 @@ +# 实时聊天工具事件展示设计 + +## 目标 +- 工具调用实时显示为独立系统消息 +- 使用 `payload.ts` 作为排序时间 +- 避免重复渲染 + +## 现状问题 +- 工具事件未实时展示,需要刷新 +- 刷新后可能出现重复/顺序错乱 + +## 方案 +- 监听 `event: agent` + `stream: tool` 事件 +- 使用 `payload.runId` 与 `toolCallId` 组成唯一键去重 +- 插入消息时按 `payload.ts` 排序 + +## 设计细节 +### 时间与去重 +- 唯一键:`${payload.ts}:${toolCallId}` +- 时间来源:`payload.ts` + +### 插入策略 +- 所有消息(用户/助手/系统/工具事件)都写入 `data-ts` +- 插入时按时间从小到大找到位置 + +### 工具事件消息内容 +- 标题:`工具调用开始/结果 · 工具名` +- 结果:成功/失败(基于 isError) +- 仅展示 start 与 result 两类 + +## 验收标准 +- 工具事件实时出现,无需刷新 +- 不出现重复消息 +- 顺序按时间正确 diff --git a/docs/plans/2026-03-17-chat-virtual-scroll-design.md b/docs/plans/2026-03-17-chat-virtual-scroll-design.md new file mode 100644 index 00000000..78425bfa --- /dev/null +++ b/docs/plans/2026-03-17-chat-virtual-scroll-design.md @@ -0,0 +1,25 @@ +# Chat 虚拟滚动设计 + +## 目标 +- 滚动流畅度提升 +- 控制渲染 DOM 数量,降低重绘 + +## 现状 +- 所有消息一次性渲染,滚动卡顿 + +## 方案 +- 使用简单虚拟列表策略:仅渲染可视区域 + 上下缓冲 +- 滚动时动态替换 DOM + +## 设计细节 +- 视窗计算:基于容器 scrollTop + clientHeight +- 渲染范围:可视范围上下各缓冲 N 条 +- 需要维护消息高度缓存 + +## 风险 +- DOM 搜索不可见 +- 需要高度缓存避免跳动 + +## 验收标准 +- 滚动流畅度明显提升 +- 无明显跳动 diff --git a/docs/plans/2026-03-17-chat-virtual-scroll-implementation-design.md b/docs/plans/2026-03-17-chat-virtual-scroll-implementation-design.md new file mode 100644 index 00000000..eca40d2c --- /dev/null +++ b/docs/plans/2026-03-17-chat-virtual-scroll-implementation-design.md @@ -0,0 +1,34 @@ +# Chat 虚拟列表实现设计 + +## 目标 +- 提升首屏渲染速度(优先) +- 提升滚动流畅度(次优) +- 固定渲染窗口:40 条 + 上下缓冲 20 条 +- 滚动锚点:底部时自动滚动,否则保持位置 +- 高度策略:预估行高 + 运行时测量校准 + +## 方案(固定窗口 + 占位高度) +- 使用容器总高度占位 +- 仅渲染窗口范围消息 +- 运行时测量单条高度并缓存 +- 通过累计高度计算可视范围 + +## 数据结构 +- items: [{ id, role, text, attachments, tools, timestamp }] +- heights: Map +- avgHeight: number (默认 64) +- range: { start, end } + +## 渲染策略 +- 顶部、底部使用占位 div 控制滚动条 +- 每次滚动计算可视范围 +- 渲染 items[start..end] + +## 锚点策略 +- 若用户在底部,新增消息时自动滚动到底部 +- 否则保持当前滚动位置 + +## 验收标准 +- 首屏渲染速度明显提升 +- 长列表滚动不卡顿 +- 新消息到来时遵循锚点策略 diff --git a/docs/plans/2026-03-17-force-setup-design.md b/docs/plans/2026-03-17-force-setup-design.md new file mode 100644 index 00000000..d747ce86 --- /dev/null +++ b/docs/plans/2026-03-17-force-setup-design.md @@ -0,0 +1,31 @@ +# 强制初始化(forceSetup)设计 + +日期: 2026-03-17 + +## 目标 +构建版首次启动可强制进入 /setup,不受已存在配置影响。 + +## 方案 +- 采用方案 A:在 clawpanel.json 增加 forceSetup 字段 + +## 设计细节 +### 1) 配置字段 +- `clawpanel.json` 新增:`forceSetup: true/false` + +### 2) 启动逻辑 +- 启动时读取 panel config +- 若 `forceSetup === true`,即使 isOpenclawReady 为 true 也强制跳转 /setup + +### 3) 初始化完成后 +- setup 流程成功完成时写入 `forceSetup=false` + +## 影响范围 +- src/main.js +- src/lib/tauri-api.js +- src/pages/setup.js +- src-tauri/src/commands/config.rs + +## 测试要点 +- forceSetup=true 时进入 /setup +- 完成初始化后 forceSetup 自动清零 +- forceSetup=false 时恢复原有判断逻辑 diff --git a/docs/plans/2026-03-17-hosted-agent-design.md b/docs/plans/2026-03-17-hosted-agent-design.md new file mode 100644 index 00000000..262a63c1 --- /dev/null +++ b/docs/plans/2026-03-17-hosted-agent-design.md @@ -0,0 +1,131 @@ +# 托管 Agent(聊天页)详细设计 + +> 结论:采用方案一(复用晴辰助手能力)。 + +## 目标 +- 聊天页发送按钮右侧新增“托管 Agent”入口 +- 托管 Agent 通过 WSS 自动与当前会话 Agent 交互 +- 上下文仅包含:初始提示词 + 与对面 Agent 的交流 +- 继承当前会话工具权限 +- 全局默认 + 会话级启用,切换会话/页面仍生效 +- 输出直接插入当前聊天流并标记来源 + +## 入口与 UI 交互 +### 入口按钮 +- 位置:`src/pages/chat.js` 内聊天输入区域,发送按钮右侧 +- 交互:点击打开托管 Agent 配置面板 +- 状态:idle / running / waiting_reply / paused / error + +### 配置面板 +- 初始提示词(必填) +- 启用开关 +- 运行模式:对面 Agent 回复后自动继续 +- 停止策略:托管 Agent 自评停止 +- 高级选项:最大步数 / 步间隔 / 重试次数 +- 操作:保存并启用 / 暂停 / 立即停止 + +### 输出展示 +- 直接插入当前聊天流 +- 格式示例: + - `[托管 Agent] 下一步指令: ...` +- 样式区分:弱化颜色 + 标签 + +## 运行循环与状态机 +### 状态 +- idle / running / waiting_reply / paused / error + +### 触发 +- 监听 `wsClient.onEvent` +- event=chat,state=final,sessionKey=当前会话 + +### 执行流程 +1. 对面 Agent final 回复到达 +2. 托管 Agent 生成下一步指令 +3. 使用 `wsClient.chatSend` 发送 +4. 进入 waiting_reply +5. 满足 stopPolicy 或 maxSteps 停止 + +## 上下文构建 +- 仅包含:初始提示词 + 与对面 Agent 对话 +- 截断策略:按 MAX_CONTEXT_TOKENS 或最近 N 条 +- 不引入其他会话内容 + +## 数据结构与持久化 +### 全局默认(clawpanel.json) +```json +{ + "hostedAgent": { + "default": { + "enabled": false, + "prompt": "", + "autoRunAfterTarget": true, + "stopPolicy": "self", + "maxSteps": 50, + "stepDelayMs": 1200, + "retryLimit": 2, + "toolPolicy": "inherit" + } + } +} +``` + +### 会话级(localStorage) +Key: `clawpanel-hosted-agent-sessions` +```json +{ + "agent:main:main": { + "enabled": true, + "prompt": "任务目标", + "autoRunAfterTarget": true, + "stopPolicy": "self", + "maxSteps": 50, + "stepDelayMs": 1200, + "retryLimit": 2, + "toolPolicy": "inherit", + "state": { + "status": "running", + "stepCount": 12, + "lastRunAt": 1710000000000, + "lastError": "" + }, + "history": [ + { "role": "system", "content": "初始提示词" }, + { "role": "assistant", "content": "托管 Agent 生成的指令" }, + { "role": "target", "content": "对面 Agent 回复" } + ] + } +} +``` + +## assistant-core 抽取清单 +新增:`src/lib/assistant-core.js` + +### 抽取项(从 assistant.js) +- API 适配:OpenAI/Anthropic/Gemini +- SSE 流解析与重试 +- 系统提示词构建 +- 工具声明、权限过滤、执行与安全检查 +- 上下文裁剪与会话数据工具 + +### 适配器注入 +- `api.*` 工具桥接 +- `confirm / ask_user` UI 适配器 +- `storage` 适配器 +- 图片存储适配器 + +### 保留在 assistant.js +- DOM 渲染与 UI 交互 +- toast/modal +- 视图与事件绑定 + +## 风险与保护 +- Gateway 断开:自动暂停 +- 连续失败:触发 error 状态 +- 最大步数:强制停止 +- 避免重复触发:运行中忽略新触发 + +## 测试要点 +- 启用后自动发送 +- 对面回复后自动继续 +- 切换会话/页面后仍生效 +- 停止策略与最大步数生效 diff --git a/docs/plans/2026-03-17-skillhub-env-fix-design.md b/docs/plans/2026-03-17-skillhub-env-fix-design.md new file mode 100644 index 00000000..cbe492b3 --- /dev/null +++ b/docs/plans/2026-03-17-skillhub-env-fix-design.md @@ -0,0 +1,65 @@ +# SkillHub 动态探测与系统环境变量继承设计 + +日期: 2026-03-17 + +## 背景与目标 +- 现象: 页面提示已安装 SkillHub CLI,但检查仍显示未安装 +- 根因: ClawPanel 进程环境变量未刷新或 PATH 不完整,导致技能检测失败 +- 目标: + 1) SkillHub CLI 检测支持动态探测路径并返回版本与命中路径 + 2) 所有 Tauri 命令执行时继承完整系统环境变量(用户 + 系统) + +## 范围 +- 仅修改 ClawPanel Tauri 后端命令执行环境与 SkillHub 检测逻辑 +- 前端仅增加路径展示(若返回 path) + +## 方案对比 +### 方案 A +- 仅在 SkillHub 检测执行 `where skillhub` 进行路径探测 +- 其他命令仍使用当前进程环境 +- 缺点: 无法解决晴辰助手命令缺少系统环境变量的问题 + +### 方案 B(推荐) +- 增加统一系统环境构建函数,合并进程 env + Windows 用户/系统 env +- 所有 Tauri 命令使用该环境执行 +- SkillHub 检测失败时 `where skillhub` 探测并返回路径 +- 优点: 满足所有需求,改动集中 + +### 方案 C +- 增加 envPolicy 配置项(inherit/system/whitelist) +- 需要新 UI 与配置逻辑 +- 超出当前范围 + +## 设计细节 +### 1) 系统环境变量合并 +- 新增 `build_system_env()`: + - 读取当前进程 env + - 读取注册表: + - HKCU\Environment + - HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment + - 合并优先级: 进程 env 覆盖系统 env + - PATH 合并去重(用户 + 系统 + 进程) + +### 2) SkillHub 动态探测 +- `skills_skillhub_check` 流程: + 1) 直接尝试 `skillhub --version` + 2) 失败则执行 `where skillhub` + 3) 若命中路径,使用该路径执行 `--version` + 4) 返回 `{ installed: true, version, path }` + +### 3) 统一命令执行环境 +- 所有 `tokio::process::Command` 调用统一使用 `cmd.envs(build_system_env())` +- 现有 `enhanced_path()` 可改为调用 `build_system_env()` 或保留但不再使用 + +### 4) 前端展示 +- Skills 页面展示安装路径(若后端返回 path) +- 状态提示保持一致 + +## 错误处理 +- 注册表读取失败时回退到当前进程 env +- `where skillhub` 失败仍返回 `installed: false` + +## 测试要点 +- PATH 未刷新时: `skills_skillhub_check` 仍能识别已安装 +- 晴辰助手执行命令继承系统变量(如 PATH / HTTP_PROXY) +- UI 能展示 path 与版本 diff --git a/docs/plans/2026-03-17-toast-night-and-model-select-design.md b/docs/plans/2026-03-17-toast-night-and-model-select-design.md new file mode 100644 index 00000000..da099118 --- /dev/null +++ b/docs/plans/2026-03-17-toast-night-and-model-select-design.md @@ -0,0 +1,36 @@ +# Toast 夜间样式与 Chat Model Select 设计 + +## 目标 +- Toast 夜间模式可见性提升,风格参考 Vercel 简约风 +- Chat 模型选择器不截断,宽度自适应内容 + +## Toast 夜间样式 +### 现状 +- Toast 使用统一背景与边框,夜间模式对比不足 + +### 方案(Vercel 简约风) +- 夜间模式下使用更深背景色与清晰边框 +- 保留状态色文字 + +### 设计细节 +- 选择器:`[data-theme="dark"] .toast` +- 背景:更深的卡片色(参考 Vercel 夜间卡片风格) +- 边框:`1px solid var(--border)` 保持一致性 +- 阴影:沿用当前中等阴影 + +## Chat Model Select +### 现状 +- `chat-model-select` 文字被截断 + +### 方案 +- 宽度随内容自适应,不做截断 + +### 设计细节 +- 选择器:`.chat-model-select` 或其输入容器 +- 移除固定宽度/最大宽度 +- 允许内容自然撑开 + +## 验收标准 +- 夜间模式下 toast 清晰可见,风格简约 +- chat-model-select 文字不再被截断 +- 不影响其他布局与交互 diff --git a/docs/plans/2026-03-17-toast-shadow-design.md b/docs/plans/2026-03-17-toast-shadow-design.md new file mode 100644 index 00000000..2ca9e07f --- /dev/null +++ b/docs/plans/2026-03-17-toast-shadow-design.md @@ -0,0 +1,18 @@ +# Toast 阴影强度设计 + +## 目标 +- 为 toast 增加轻微阴影 +- 保持 Vercel 简约风格 +- 日夜主题自动适配 + +## 方案 +- 采用中等强度阴影:`0 4px 12px rgba(0,0,0,0.12)` + +## 设计细节 +- 选择器:`.toast` +- 在现有背景与边框基础上新增 `box-shadow` + +## 验收标准 +- 日间模式下 toast 清晰可见 +- 夜间模式不刺眼 +- 不影响布局与动画 diff --git a/docs/plans/2026-03-17-toast-vercel-design.md b/docs/plans/2026-03-17-toast-vercel-design.md new file mode 100644 index 00000000..a4baaf5a --- /dev/null +++ b/docs/plans/2026-03-17-toast-vercel-design.md @@ -0,0 +1,27 @@ +# Toast Vercel 简约风格设计 + +## 目标 +- 去掉液态玻璃/高斯模糊效果 +- 使用 Vercel 简约风格:纯色卡片 + 细边框 +- 日夜主题自动适配 + +## 现状 +- `.toast` 使用 `backdrop-filter: blur(12px)` +- 背景使用 `--success-muted / --error-muted / --info-muted / --warning-muted` + +## 方案 +- 移除 `backdrop-filter` +- 背景统一使用 `var(--bg-primary)` +- 边框统一使用 `1px solid var(--border)` +- 文本颜色维持状态色(success/error/info/warning) + +## 设计细节 +- 选择器:`.toast` +- 状态色:保留 `.toast.success/.error/.info/.warning` 的文字颜色 +- 背景色:`var(--bg-primary)` +- 边框:`1px solid var(--border)` + +## 验收标准 +- 日夜模式下 toast 不卡壳、无毛玻璃效果 +- 视觉风格与 Vercel 简约风格一致 +- 状态色可辨识 diff --git a/docs/plans/2026-03-17-tool-call-meta-design.md b/docs/plans/2026-03-17-tool-call-meta-design.md new file mode 100644 index 00000000..ac32f148 --- /dev/null +++ b/docs/plans/2026-03-17-tool-call-meta-design.md @@ -0,0 +1,32 @@ +# 工具调用信息展示设计 + +## 目标 +- 工具调用框内显示调用时间(使用工具自身时间戳) +- 展开后始终显示参数与结果区,即使为空也提示 + +## 现状问题 +- 工具调用框无时间信息 +- 展开后无数据时区域为空,用户无法确认是否无返回 + +## 方案(已确认) +- 方案 A:显示工具自身时间戳 + 参数/结果占位 + +## 设计细节 +### 时间来源 +- 优先读取:`tool.end_time` / `tool.endTime` / `tool.timestamp` / `tool.time` / `tool.started_at` / `tool.startedAt` +- 若均为空:使用工具事件流 `event: agent` 的 `payload.ts`(按 `toolCallId` 映射) +- 仍为空时:显示 `时间未知` + +### 展开内容 +- `input` 为空时显示 `无参数` +- `output` 为空时显示 `无结果` +- 仍使用 `safeStringify` + `escapeHtml` + +### 结构 +- 标题行:`工具名 · 状态 · 时间` +- 展开块:参数与结果两个区块 + +## 验收标准 +- 工具调用框内显示时间 +- 展开后始终可见参数/结果区块 +- 无数据时显示占位文案 diff --git a/docs/plans/2026-03-17-ws-connect-bootstrap-design.md b/docs/plans/2026-03-17-ws-connect-bootstrap-design.md new file mode 100644 index 00000000..9ed6eef4 --- /dev/null +++ b/docs/plans/2026-03-17-ws-connect-bootstrap-design.md @@ -0,0 +1,31 @@ +# WS 连接后启动请求设计 + +## 目标 +- 连接成功后一次性发送官方同款 8 个请求 +- ping 间隔改为 5 秒 +- 重连后也执行一次 + +## 官方请求清单 +- agent.identity.get (sessionKey) +- agents.list +- health +- node.list +- device.pair.list +- chat.history (sessionKey, limit=200) +- sessions.list (includeGlobal/includeUnknown) +- models.list + +## 方案 +- 在 connect 成功处理函数中发送一组 req 帧 +- 每次重连也触发 +- ping 仍按 5 秒周期发送(保持已实现的多请求,启动批次会与首个 ping 重复一次) + +## 设计细节 +- 新增 `_sendBootstrapRequests()` +- 使用 `uuid()` 生成 id +- sessionKey 使用当前会话 `this._sessionKey`,缺省回退 `agent:full-stack-architect:main` + +## 验收标准 +- 每次连接成功后立即发送 8 个 req +- ping 间隔为 5 秒 +- 控制台无异常 diff --git a/docs/plans/2026-03-17-ws-ping-multi-req-design.md b/docs/plans/2026-03-17-ws-ping-multi-req-design.md new file mode 100644 index 00000000..230a7f26 --- /dev/null +++ b/docs/plans/2026-03-17-ws-ping-multi-req-design.md @@ -0,0 +1,27 @@ +# WS Ping 多请求设计 + +## 目标 +- 每次心跳发送 4 个 req:node.list / models.list / sessions.list / chat.history +- 保持连接存活并同步关键状态 + +## 现状 +- 心跳仅发送 node.list + +## 方案 +- 在 `_startPing` 中按顺序发送 4 个 req +- 复用现有 `uuid()` 生成 id +- sessions.list 参数:`includeGlobal: true`, `includeUnknown: true` +- chat.history 参数固定 `sessionKey: agent:full-stack-architect:main`, `limit: 200` + +## 设计细节 +- 帧格式统一:`{ type: "req", id, method, params }` +- 在同一计时周期内连续发送 4 条 req + +## 风险 +- 请求频率增加,可能带来负载波动 +- 若 Gateway 限流,需要再降频 + +## 验收标准 +- 每个周期触发 4 条 req +- 控制台无异常 +- Gateway 正常返回 diff --git a/docs/plans/2026-03-17-ws-ping-node-list-design.md b/docs/plans/2026-03-17-ws-ping-node-list-design.md new file mode 100644 index 00000000..593ca549 --- /dev/null +++ b/docs/plans/2026-03-17-ws-ping-node-list-design.md @@ -0,0 +1,18 @@ +# WS Ping 改为 node.list 设计 + +## 目标 +- 将 WebSocket 心跳从 `{ "type":"ping" }` 改为 `req node.list` +- 保持连接存活并提供实时节点状态 + +## 方案 +- 在 `_startPing` 中改为发送 `req` 帧 +- 维持原有间隔 + +## 设计细节 +- 帧格式:`{ type: "req", id: uuid(), method: "node.list", params: {} }` +- 仅替换 ping 发送内容 + +## 验收标准 +- 连接稳定 +- 控制台不报错 +- node.list 有返回 diff --git a/docs/superpowers/plans/2026-03-16-gateway-patch-auto-detect.md b/docs/superpowers/plans/2026-03-16-gateway-patch-auto-detect.md new file mode 100644 index 00000000..2d3149fe --- /dev/null +++ b/docs/superpowers/plans/2026-03-16-gateway-patch-auto-detect.md @@ -0,0 +1,74 @@ +# Gateway Patch Auto-Detect Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Auto-detect global OpenClaw version changes at app start and settings entry, then reapply the gateway patch silently; show errors only in settings. + +**Architecture:** Add a small patch auto-detect runner in settings (client-side) and app bootstrap (Tauri setup) that calls gateway_patch_status and, on version mismatch, triggers gateway_patch_apply(force=true). Use in-memory throttle and 5-minute cooldown to avoid repeated runs. + +**Tech Stack:** Tauri (Rust), Vite/JS, ClawPanel config + +--- + +## Chunk 1: Backend status extensions + +### Task 1: Extend gateway_patch_status output + +**Files:** +- Modify: `src-tauri/src/commands/gateway_patch.rs` + +- [ ] **Step 1: Add version mismatch helper** + +Add a helper to compute `needs_repatch` by comparing `installed_version` with stored `gatewayPatch.openclawVersion` and include it in status output if desired. + +- [ ] **Step 2: Ensure force apply uses backup detection** + +If force=true and backups missing, return a clear error string: "缺少备份,建议先一键补丁". + +- [ ] **Step 3: Commit** + +``` +git add src-tauri/src/commands/gateway_patch.rs + +git commit -m "feat: add patch auto-detect helpers" +``` + +## Chunk 2: Frontend auto-detect logic + +### Task 2: App startup auto-detect + +**Files:** +- Modify: `src/main.js` + +- [ ] **Step 1: Add one-time auto-detect** + +Create a lightweight timer guard (module-scoped) and call `api.gatewayPatchStatus()` then `api.gatewayPatchApply(true)` when version mismatch is detected. Cooldown 5 minutes. + +- [ ] **Step 2: Commit** + +``` +git add src/main.js + +git commit -m "feat: auto-detect gateway patch at startup" +``` + +### Task 3: Settings page auto-detect + +**Files:** +- Modify: `src/pages/settings.js` + +- [ ] **Step 1: Add auto-detect on loadAll** + +After `loadGatewayPatch`, run auto-detect handler with a shared cooldown guard; update UI based on result. Do not show toast on success; show error in the card only. + +- [ ] **Step 2: Commit** + +``` +git add src/pages/settings.js + +git commit -m "feat: auto-detect gateway patch in settings" +``` + +--- + +Plan complete and saved to `docs/superpowers/plans/2026-03-16-gateway-patch-auto-detect.md`. Ready to execute? diff --git a/docs/superpowers/plans/2026-03-16-gateway-patch-oneclick.md b/docs/superpowers/plans/2026-03-16-gateway-patch-oneclick.md new file mode 100644 index 00000000..7c31cfb4 --- /dev/null +++ b/docs/superpowers/plans/2026-03-16-gateway-patch-oneclick.md @@ -0,0 +1,169 @@ +# Gateway One-Click Patch Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a one-click gateway patch flow in ClawPanel settings to apply/rollback sessionMessage patch for global npm OpenClaw installs. + +**Architecture:** Add a Tauri command that locates global npm OpenClaw, backs up dist files, applies patch, records status in clawpanel.json, and exposes status to the UI. Settings UI uses tauri-api bridge to render current patch status and provide apply/redo/rollback actions. + +**Tech Stack:** Tauri (Rust), Vite/JS, ClawPanel config (clawpanel.json) + +--- + +## Chunk 1: Backend patch command + config persistence + +### Task 1: Add gateway patch command + +**Files:** +- Create: `src-tauri/src/commands/gateway_patch.rs` +- Modify: `src-tauri/src/commands/mod.rs` +- Modify: `src-tauri/src/lib.rs` + +- [ ] **Step 1: Create gateway_patch.rs skeleton** + +```rust +use serde::{Deserialize, Serialize}; +use tauri::command; + +#[derive(Serialize, Deserialize)] +pub struct GatewayPatchStatus { + pub installed_version: Option, + pub patched: bool, + pub patched_version: Option, + pub patched_at: Option, + pub files: Vec, + pub last_error: Option, +} + +#[command] +pub async fn gateway_patch_status() -> Result { + Ok(GatewayPatchStatus { + installed_version: None, + patched: false, + patched_version: None, + patched_at: None, + files: vec![], + last_error: None, + }) +} + +#[command] +pub async fn gateway_patch_apply() -> Result { + gateway_patch_status().await +} + +#[command] +pub async fn gateway_patch_rollback() -> Result { + gateway_patch_status().await +} +``` + +- [ ] **Step 2: Register commands** + +Update `src-tauri/src/commands/mod.rs` to export the new functions, and `src-tauri/src/lib.rs` to include them in `invoke_handler`. + +- [ ] **Step 3: Locate global npm root** + +Implement helper in `gateway_patch.rs`: +- Run `npm root -g` +- Join `openclaw/dist` +- Detect `reply-*.js` and `gateway-cli-*.js` (choose latest by modified time) +- Return errors if not found + +- [ ] **Step 4: Apply patch with backup** + +Implement: +- Copy `reply-*.js` -> `.bak` +- Copy `gateway-cli-*.js` -> `.bak` +- Apply string-replace patches (same patterns used in manual patch) +- Validate file content contains `sessionMessage` post patch + +- [ ] **Step 5: Persist status in clawpanel.json** + +Use existing panel config read/write to store: + +```json +"gatewayPatch": { + "version": "sessionMessage-v1", + "patchedAt": "", + "openclawVersion": "", + "files": ["reply-*.js", "gateway-cli-*.js"], + "lastError": null +} +``` + +- [ ] **Step 6: Implement rollback** + +Restore from `.bak` files and update status. + +- [ ] **Step 7: Manual verification** + +Run: +``` +openclaw gateway status +``` +Expect: Gateway runs normally. + +- [ ] **Step 8: Commit** + +``` +git add src-tauri/src/commands/gateway_patch.rs src-tauri/src/commands/mod.rs src-tauri/src/lib.rs + +git commit -m "feat: add gateway patch commands" +``` + +## Chunk 2: UI + API bridge + +### Task 2: Add API bridge + +**Files:** +- Modify: `src/lib/tauri-api.js` + +- [ ] **Step 1: Add methods** + +```js +export const api = { + // ... + gatewayPatchStatus: () => invoke('gateway_patch_status'), + gatewayPatchApply: () => invoke('gateway_patch_apply'), + gatewayPatchRollback: () => invoke('gateway_patch_rollback') +} +``` + +- [ ] **Step 2: Commit** + +``` +git add src/lib/tauri-api.js + +git commit -m "feat: add gateway patch api bridge" +``` + +### Task 3: Settings UI + +**Files:** +- Modify: `src/pages/settings.js` (or `src/pages/settings-cloudflared.js` if the setting block lives there) +- Modify: `src/style/settings.css` (if needed) + +- [ ] **Step 1: Add Gateway 补丁卡片** +- Add status area: installed version, patched state, patched time +- Buttons: 一键补丁 / 重打补丁 / 回滚 + +- [ ] **Step 2: Wire buttons** +- Call `api.gatewayPatchApply()` / `api.gatewayPatchRollback()` +- Refresh status via `api.gatewayPatchStatus()` + +- [ ] **Step 3: Manual verification** +- Open settings page, ensure card renders +- Apply patch and verify status changes + +- [ ] **Step 4: Commit** + +``` +git add src/pages/settings.js src/style/settings.css + +git commit -m "feat: add gateway patch ui" +``` + +--- + +Plan complete and saved to `docs/superpowers/plans/2026-03-16-gateway-patch-oneclick.md`. Ready to execute? diff --git a/docs/superpowers/plans/2026-03-17-ai-config-import.md b/docs/superpowers/plans/2026-03-17-ai-config-import.md new file mode 100644 index 00000000..f7bb8e3a --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-ai-config-import.md @@ -0,0 +1,82 @@ +# AI 配置从 openclaw 导入 Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在 AI 配置页增加“从 openclaw 导入”按钮,导入 model/temperature/top_p/api_key/base_url。 + +**Architecture:** 前端按钮触发调用 Tauri API 读取 openclaw.json 并写回表单配置,完成后持久化保存。 + +**Tech Stack:** Vanilla JS, Tauri + +--- + +## Chunk 1: 后端读取 openclaw 配置 + +### Task 1: 新增导入命令 +**Files:** +- Modify: `src-tauri/src/commands/config.rs` + +- [ ] **Step 1: 新增 Tauri 命令** + +新增 `import_openclaw_ai_config`: +- 读取 openclaw.json +- 提取字段:model / temperature / top_p / api_key / base_url +- 返回 JSON + +- [ ] **Step 2: 注册命令** + +在 `src-tauri/src/lib.rs` 注册命令。 + +- [ ] **Step 3: 提交** +```bash +git add src-tauri/src/commands/config.rs src-tauri/src/lib.rs +git commit -m "feat: add ai config import command" +``` + +--- + +## Chunk 2: 前端按钮与写回 + +### Task 2: AI 配置页导入 +**Files:** +- Modify: `src/lib/tauri-api.js` +- Modify: `src/pages/models.js` + +- [ ] **Step 1: 添加 API 封装** + +tauri-api.js 增加 `importOpenclawAiConfig`. + +- [ ] **Step 2: UI 按钮与写回逻辑** + +models.js 增加按钮,点击后: +- 调用 API +- 写回表单 +- 保存当前配置 + +- [ ] **Step 3: 提交** +```bash +git add src/lib/tauri-api.js src/pages/models.js +git commit -m "feat: import ai config from openclaw" +``` + +--- + +## Chunk 3: 构建与验证 + +### Task 3: 构建 +**Files:** 无 + +- [ ] **Step 1: 构建** +```bash +npm run build +``` + +- [ ] **Step 2: 手工验证** +- openclaw.json 存在 → 导入成功 +- 字段缺失 → 提示失败 +- 保存后配置生效 + +- [ ] **Step 3: 推送** +```bash +git push +``` diff --git a/docs/superpowers/plans/2026-03-17-assistant-optimize-toggle.md b/docs/superpowers/plans/2026-03-17-assistant-optimize-toggle.md new file mode 100644 index 00000000..d0d38ed0 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-assistant-optimize-toggle.md @@ -0,0 +1,52 @@ +# 优化/还原按钮切换 Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 优化与还原按钮互斥显示,默认显示“优化”,优化后显示“还原”,发送或还原后切回“优化”。 + +**Architecture:** 根据 `_optOriginalText` 状态控制按钮显隐与禁用。 + +**Tech Stack:** Vanilla JS + +--- + +## Chunk 1: 逻辑调整 + +### Task 1: updateOptimizeState 互斥显示 +**Files:** +- Modify: `src/pages/assistant.js` + +- [ ] **Step 1: 更新 updateOptimizeState** + +逻辑: +- `_optOriginalText` 为 null → 显示优化按钮,隐藏还原按钮 +- `_optOriginalText` 非空 → 隐藏优化按钮,显示还原按钮 + +- [ ] **Step 2: 按钮点击后切换** + +- 优化完成 → 切到还原 +- 点击还原 → 清空快照并切回优化 +- 发送 → 清空快照并切回优化 + +- [ ] **Step 3: 提交** +```bash +git add src/pages/assistant.js +git commit -m "fix: toggle optimize and restore buttons" +``` + +--- + +## Chunk 2: 构建与推送 + +### Task 2: 构建 +**Files:** 无 + +- [ ] **Step 1: 构建** +```bash +npm run build +``` + +- [ ] **Step 2: 推送** +```bash +git push +``` diff --git a/docs/superpowers/plans/2026-03-17-assistant-ux-and-shell.md b/docs/superpowers/plans/2026-03-17-assistant-ux-and-shell.md new file mode 100644 index 00000000..1946944a --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-assistant-ux-and-shell.md @@ -0,0 +1,118 @@ +# Assistant UX + Windows Shell 优化 Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 实现 Windows shell 优先级、复制按钮样式统一、AI 助手输入区新增优化/恢复功能并保留撤销栈。 + +**Architecture:** 后端 assistant_exec 增加 shell 探测与降级;前端统一 code-copy-btn CSS;AI 助手输入区新增优化调用与快照管理。 + +**Tech Stack:** Rust (Tauri), Vanilla JS, CSS + +--- + +## Chunk 1: Windows shell 优先级 + +### Task 1: assistant_exec 使用 pwsh 优先级 +**Files:** +- Modify: `src-tauri/src/commands/assistant.rs` + +- [ ] **Step 1: 增加 shell 探测函数** + +在 Windows 分支增加一个 `detect_windows_shell()`: +- 依次检查 `pwsh`、`powershell` +- 都不可用则返回 `cmd` + +实现方式:使用 `where` 探测,并使用 `build_system_env()` 注入完整环境。 + +- [ ] **Step 2: 替换执行逻辑** + +`assistant_exec` 使用探测结果执行: +- pwsh / powershell:`-NoProfile -Command ` +- cmd:`/c ` + +- [ ] **Step 3: 提交** +```bash +git add src-tauri/src/commands/assistant.rs +git commit -m "feat: prefer pwsh in assistant exec" +``` + +--- + +## Chunk 2: 复制按钮错位修复(CSS) + +### Task 2: 统一 chat.css 与 assistant.css +**Files:** +- Modify: `src/style/chat.css` +- Modify: `src/style/assistant.css` + +- [ ] **Step 1: 统一 pre 与 copy 按钮样式** + +将 assistant.css 中 pre 样式改为与 chat.css 一致,确保: +- `.code-copy-btn` 右上角悬浮 +- hover 才显示 +- 不改 Markdown 解析逻辑 + +- [ ] **Step 2: 提交** +```bash +git add src/style/chat.css src/style/assistant.css +git commit -m "fix: align code copy button styles" +``` + +--- + +## Chunk 3: AI 优化按钮 + +### Task 3: 输入区新增优化/恢复按钮 +**Files:** +- Modify: `src/pages/assistant.js` + +- [ ] **Step 1: 增加按钮 DOM** + +在输入区加入 “优化” 与 “恢复原文” 按钮。 + +- [ ] **Step 2: 维护快照状态** + +新增变量:`_optOriginalText` / `_optOptimizedText` +规则: +- 点击优化:保存原文快照,写入优化结果快照 +- 点击恢复:恢复原文 +- 发送成功后清空快照 + +- [ ] **Step 3: 调用同模型在线重写** + +复用现有模型调用逻辑: +- 模板:`请在不改变原意和语言的前提下,重写为意思更清晰、更简洁的表达。` +- 使用同模型 +- 结果直接替换输入框内容 + +- [ ] **Step 4: setRangeText 触发 input 事件** + +使用 `textarea.setRangeText()` + 触发 `input` 事件,保证 Ctrl+Z 生效。 + +- [ ] **Step 5: 提交** +```bash +git add src/pages/assistant.js +git commit -m "feat: add optimize and restore buttons" +``` + +--- + +## Chunk 4: 构建与验证 + +### Task 4: 构建 +**Files:** 无 + +- [ ] **Step 1: 构建** +```bash +npm run build +``` + +- [ ] **Step 2: 手工验证** +- Windows 下执行命令优先 pwsh +- 复制按钮位置正确 +- 优化/恢复可用且 Ctrl+Z 正常 + +- [ ] **Step 3: 推送** +```bash +git push +``` diff --git a/docs/superpowers/plans/2026-03-17-chat-autoscroll.md b/docs/superpowers/plans/2026-03-17-chat-autoscroll.md new file mode 100644 index 00000000..5a0a5399 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-chat-autoscroll.md @@ -0,0 +1,132 @@ +# Chat Auto-Scroll Gating Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Only auto-scroll the chat view when new messages arrive and the user is at the bottom; never force-scroll while the user is reading history. + +**Architecture:** Add a single auto-scroll gate to `chat.js` that tracks whether the user is at the bottom. Trigger scrolling only on message insertion and stream updates when the gate is enabled. Avoid auto-scroll in render loops to prevent continuous snapping. + +**Tech Stack:** Vanilla JS, DOM APIs, existing chat page logic in `src/pages/chat.js`. + +--- + +## Chunk 1: Auto-scroll gating + +### Task 1: Add auto-scroll state and update on scroll + +**Files:** +- Modify: `src/pages/chat.js` + +- [ ] **Step 1: Add state flags near other module-level state** + +Add: +```js +let _autoScrollEnabled = true +``` + +- [ ] **Step 2: Update auto-scroll flag on scroll** + +In the `_messagesEl.addEventListener('scroll', ...)` handler, update state based on `isAtBottom()`: +```js +_messagesEl.addEventListener('scroll', () => { + const { scrollTop, scrollHeight, clientHeight } = _messagesEl + _scrollBtn.style.display = (scrollHeight - scrollTop - clientHeight < 80) ? 'none' : 'flex' + _autoScrollEnabled = isAtBottom() +}) +``` + +- [ ] **Step 3: Ensure scroll button restores auto-scroll** + +When the user clicks the scroll-to-bottom button, set `_autoScrollEnabled = true` after moving to bottom: +```js +_scrollBtn.addEventListener('click', () => { + _autoScrollEnabled = true + scrollToBottom(true) +}) +``` + +### Task 2: Gate auto-scroll to message insertion + +**Files:** +- Modify: `src/pages/chat.js` + +- [ ] **Step 1: Update `scrollToBottom` to respect gating** + +Change function signature and behavior: +```js +function scrollToBottom(force = false) { + if (!_messagesEl) return + if (!force && !_autoScrollEnabled) return + requestAnimationFrame(() => { _messagesEl.scrollTop = _messagesEl.scrollHeight }) +} +``` + +- [ ] **Step 2: Ensure scroll is invoked only on new message insertion** + +Keep `scrollToBottom()` calls only in: +- `appendUserMessage` +- `appendAiMessage` +- `appendSystemMessage` +- `createStreamBubble` +- `showTyping(true)` (but it will now respect gate) + +Remove or avoid unconditional scrolling in render loops. + +- [ ] **Step 3: Stop continuous auto-scroll in render loops** + +In `doRender` remove the unconditional `scrollToBottom()` or guard it by auto-scroll: +```js +if (_currentAiBubble && _currentAiText) { + _currentAiBubble.innerHTML = renderMarkdown(_currentAiText) + scrollToBottom() +} +``` +(With gated `scrollToBottom`, it will only happen when user is at bottom.) + +- [ ] **Step 4: Guard virtual render bottom snapping** + +In `doVirtualRender`, only snap to bottom when `_autoScrollEnabled` is true: +```js +if (atBottom && _autoScrollEnabled) { + scrollToBottom() +} +``` + +### Task 3: Validate streaming behavior + +**Files:** +- Modify: `src/pages/chat.js` + +- [ ] **Step 1: Ensure stream updates do not force-scroll while reading history** + +Verify `doRender` and `createStreamBubble` only scroll when `_autoScrollEnabled` is true. + +### Task 4: Manual verification and build + +**Files:** +- None + +- [ ] **Step 1: Manual verification checklist** + +Checklist: +- Open chat page with existing history +- Scroll up; verify the view stays in place (no automatic snapping) +- Send a new message while scrolled up; verify it does not force-scroll +- Scroll to bottom and send/receive a message; verify it auto-scrolls +- Click the scroll-to-bottom button; verify it jumps and re-enables auto-scroll + +- [ ] **Step 2: Build** + +Run: +```powershell +npm run build +``` +Expected: build succeeds with no errors. + +- [ ] **Step 3: Commit** + +```powershell +git add src\pages\chat.js + +git commit -m "fix: gate chat auto-scroll on new messages" +``` diff --git a/docs/superpowers/plans/2026-03-17-chat-daylight-shadow.md b/docs/superpowers/plans/2026-03-17-chat-daylight-shadow.md new file mode 100644 index 00000000..92195288 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-chat-daylight-shadow.md @@ -0,0 +1,46 @@ +# Chat Daylight Shadow Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a light-mode shadow to assistant message bubbles so they remain visible against the page background. + +**Architecture:** Update chat CSS to apply a light-mode-only shadow on `.msg-ai .msg-bubble` without changing dark mode. No layout or markup changes. + +**Tech Stack:** CSS, Vite build + +--- + +## Chunk 1: Daylight shadow style + +### Task 1: Add light-mode shadow for assistant bubbles + +**Files:** +- Modify: `src/style/chat.css` (near `.msg-ai .msg-bubble` rules) + +- [ ] **Step 1: Add light-mode CSS rule** + +Add a new rule scoped to light theme: + +```css +[data-theme="light"] .msg-ai .msg-bubble { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} +``` + +- [ ] **Step 2: Build** + +Run: `npm run build` +Expected: Build succeeds without errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/style/chat.css +git commit -m "fix: add daylight shadow for ai bubble" +``` + +- [ ] **Step 4: Push** + +```bash +git push +``` diff --git a/docs/superpowers/plans/2026-03-17-chat-history-tool-merge.md b/docs/superpowers/plans/2026-03-17-chat-history-tool-merge.md new file mode 100644 index 00000000..ded067e3 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-chat-history-tool-merge.md @@ -0,0 +1,81 @@ +# Chat History Tool Merge Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix chat history parsing so tool cards merge by toolCallId, show above text, and sort by time with correct fallback. + +**Architecture:** Merge tool blocks via upsertTool, resolve tool time from event ts or message timestamp, and suppress tool system messages during history parsing. Render tools and text as entries sorted by time (tools first when time ties). + +**Tech Stack:** React, JS + +--- + +## Chunk 1: Parsing + ordering adjustments + +### Task 1: Add time resolver + merge tool entries + +**Files:** +- Modify: `src/pages/chat.js` + +- [ ] **Step 0: Checkpoint(PowerShell)** + +```powershell +git status -sb +git commit --allow-empty -m "chore: checkpoint before history tool merge" +``` + +- [ ] **Step 1: Add tool time resolver** + +Add helper near tool utilities: + +```js +function resolveToolTime(toolId, messageTimestamp) { + const eventTs = toolId ? _toolEventTimes.get(toolId) : null + return eventTs || messageTimestamp || null +} +``` + +- [ ] **Step 2: Use upsertTool in history parsing** + +In `extractChatContent` and `extractContent`, replace `tools.push` with `upsertTool` for toolCall/toolResult blocks so toolCallId merges. + +- [ ] **Step 3: Apply time fallback to tools** + +When building tool entries, set `time: resolveToolTime(id, message.timestamp)`. + +- [ ] **Step 4: Suppress tool system messages in history** + +When processing history responses, skip `appendSystemMessage` for tool events, only render tool cards. + +- [ ] **Step 5: Sort entries by time with tool-first tie** + +In rendering pipeline, build `entries` combining tools and text, then sort: + +```js +entries.sort((a, b) => { + const ta = a.time ?? 0 + const tb = b.time ?? 0 + if (ta !== tb) return ta - tb + if (a.kind === 'tool' && b.kind !== 'tool') return -1 + if (a.kind !== 'tool' && b.kind === 'tool') return 1 + return 0 +}) +``` + +- [ ] **Step 6: Build** + +Run: `npm run build` +Expected: Build succeeds without errors. + +- [ ] **Step 7: Commit** + +```powershell +git add src\pages\chat.js +git commit -m "fix: history tool merge and ordering" +``` + +- [ ] **Step 8: Push** + +```powershell +git push +``` diff --git a/docs/superpowers/plans/2026-03-17-chat-markdown-it.md b/docs/superpowers/plans/2026-03-17-chat-markdown-it.md new file mode 100644 index 00000000..2256a8de --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-chat-markdown-it.md @@ -0,0 +1,146 @@ +# Chat Markdown-it Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the chat Markdown renderer with markdown-it plus plugins to support underline, spoiler, and mention, matching GitHub-like behavior. + +**Architecture:** Use markdown-it with html disabled, link validation, custom renderer for code blocks using existing highlightCode. Add local plugins for spoiler (|| || + >! !<), mention (@user), and a custom underline rule that maps __text__ to . + +**Tech Stack:** JS, Vite + +--- + +## Chunk 1: Dependencies + renderer refactor + +### Task 1: Add markdown-it and plugin deps + +**Files:** +- Modify: `package.json` + +- [ ] **Step 0: Checkpoint(PowerShell)** + +```powershell +git status -sb +git commit --allow-empty -m "chore: checkpoint before markdown-it" +``` + +- [ ] **Step 1: Add dependencies** + +Add to dependencies: +- markdown-it + +- [ ] **Step 2: Install** + +```powershell +npm install +``` + +- [ ] **Step 3: Commit deps** + +```powershell +git add package.json package-lock.json +git commit -m "chore: add markdown-it deps" +``` + +### Task 2: Replace renderer + +**Files:** +- Modify: `src/lib/markdown.js` + +- [ ] **Step 1: Instantiate markdown-it** + +```js +import MarkdownIt from 'markdown-it' +``` + +```js +const md = new MarkdownIt({ + html: false, + linkify: true, + breaks: false, + highlight: (code, lang) => { + const highlighted = highlightCode(code, lang) + const langLabel = lang ? `${escapeHtml(lang)}` : '' + return `
${langLabel}${highlighted}
` + }, +}) +``` + +- [ ] **Step 1.1: Underline plugin (`__text__` -> ``)** + +Implement a custom inline rule to parse double-underscore into `` and keep `**` for bold. + +- [ ] **Step 2: Link whitelist** + +Override link renderer to allow only http/https/mailto, otherwise href="#". + +- [ ] **Step 3: Spoiler plugin** + +Implement custom plugin to parse: +- `||spoiler||` +- `>!spoiler!<` +Output: `...` + +- [ ] **Step 4: Mention plugin** + +Parse `@username` into `@username`. + +- [ ] **Step 5: Update renderMarkdown** + +Replace manual parsing with `md.render(text)`. + +## Chunk 2: Styling + +### Task 3: Add styles + +**Files:** +- Modify: `src/style/chat.css` or `src/style/components.css` + +- [ ] **Step 1: Add mention style** + +```css +.msg-mention { + color: var(--accent); + font-weight: 600; +} +``` + +- [ ] **Step 2: Add spoiler style** + +```css +.msg-spoiler { + background: currentColor; + color: transparent; + border-radius: 4px; + padding: 0 4px; + cursor: pointer; +} +.msg-spoiler.revealed { + color: inherit; + background: rgba(0,0,0,0.12); +} +``` + +- [ ] **Step 3: Add click handler** + +In chat init or render, add event delegation to toggle `revealed` class on `.msg-spoiler`. + +## Chunk 3: Build + Commit + +- [ ] **Step 1: Build** + +Run: `npm run build` +Expected: Build succeeds without errors. + +- [ ] **Step 2: Commit** + +```powershell +git add src\lib\markdown.js src\style\chat.css src\style\components.css +git commit -m "feat: markdown-it rendering" +``` + +- [ ] **Step 3: Push** + +```powershell +git push +``` diff --git a/docs/superpowers/plans/2026-03-17-chat-tool-event-live.md b/docs/superpowers/plans/2026-03-17-chat-tool-event-live.md new file mode 100644 index 00000000..0127906e --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-chat-tool-event-live.md @@ -0,0 +1,97 @@ +# Chat Tool Event Live Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Display tool events as live system messages ordered by payload.ts and de-duplicated by payload.runId + toolCallId. + +**Architecture:** Extend chat event handling to create tool-event system messages and insert into DOM by timestamp; add dedupe map keyed by ts+toolCallId. + +**Tech Stack:** Vanilla JS, CSS, Vite build + +--- + +## Chunk 1: Tool event live insertion + +### Task 1: Add dedupe and insert-by-time + +**Files:** +- Modify: `src/pages/chat.js` + +- [ ] **Step 0: Checkpoint(PowerShell)** + +```powershell +git status -sb +git commit --allow-empty -m "chore: checkpoint before tool event live" +``` + +Note: This checkpoint is required by policy; final functional commit occurs after build. + +- [ ] **Step 1: Add maps for dedupe and event list** + +Add near top-level state: + +```js +const _toolEventSeen = new Set() +``` + +- [ ] **Step 2: Insert helper for ordered messages** + +Add a helper to insert a message wrapper by timestamp: + +```js +function insertMessageByTime(wrap, ts) { + const tsValue = Number(ts || Date.now()) + wrap.dataset.ts = String(tsValue) + const items = Array.from(_messagesEl.querySelectorAll('.msg')) + for (const node of items) { + const nodeTs = parseInt(node.dataset.ts || '0', 10) + if (nodeTs > tsValue) { + _messagesEl.insertBefore(wrap, node) + return + } + } + _messagesEl.insertBefore(wrap, _typingEl) +} +``` + +- [ ] **Step 3: Add tool-event system message builder** + +```js +function appendToolEventMessage(name, phase, ts, isError) { + const wrap = document.createElement('div') + wrap.className = 'msg msg-system' + wrap.textContent = `${name} · ${phase}${isError ? ' · 失败' : ''}` + insertMessageByTime(wrap, ts) +} +``` + +- [ ] **Step 4: Handle tool events in handleEvent** + +```js +if (event === 'agent' && payload?.stream === 'tool' && payload?.data?.toolCallId) { + const key = `${payload.runId}:${payload.data.toolCallId}` + if (_toolEventSeen.has(key)) return + _toolEventSeen.add(key) + const name = payload.data.name || '工具' + const phase = payload.data.phase || 'unknown' + appendToolEventMessage(name, phase, payload.ts, payload.data.isError) +} +``` + +- [ ] **Step 5: Build** + +Run: `npm run build` +Expected: Build succeeds without errors. + +- [ ] **Step 6: Commit(PowerShell)** + +```powershell +git add src/pages/chat.js +git commit -m "fix: show live tool events" +``` + +- [ ] **Step 7: Push(PowerShell)** + +```powershell +git push +``` diff --git a/docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md b/docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md new file mode 100644 index 00000000..57c39dc0 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md @@ -0,0 +1,211 @@ +# Chat Virtual Scroll Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement virtual scrolling for chat messages with fixed window size (40 + overscan 20), fast first paint, and stable scroll anchoring. + +**Architecture:** Use a virtualized list with top/bottom spacers and total height based on cumulative measured heights (fallback to average). Range calculation uses cumulative heights (prefix sums + binary search) with a fixed window cap. Preserve anchor when not at bottom. + +**Tech Stack:** JS, Vite + +--- + +## File Map +- Modify: `src/pages/chat.js:64-2000` (virtual state, scroll handler, render path) +- Create: `src/lib/virtual-scroll.js` (range + prefix height helpers) +- Create: `tests/virtual-scroll.test.js` +- Modify: `package.json` (test script, devDependency) + +--- + +## Chunk 1: Test scaffolding + helpers (TDD) + +### Task 1: Add test tooling + +**Files:** +- Modify: `package.json` + +- [ ] **Step 0: Checkpoint(PowerShell)** + +```powershell +git status -sb +git commit --allow-empty -m "chore: checkpoint before chat virtual scroll" +``` + +- [ ] **Step 1: Add dev dependency and script** + +Add: +- `devDependencies.vitest` +- `scripts.test = "vitest run"` + +- [ ] **Step 2: Install** + +```powershell +npm install +``` + +### Task 2: Create helper module + +**Files:** +- Create: `src/lib/virtual-scroll.js` + +- [ ] **Step 1: Implement helpers** + +```js +export function getItemHeight(items, idx, heights, avgHeight) { + const id = items[idx]?.id + return heights.get(id) || avgHeight +} + +export function buildPrefixHeights(items, heights, avgHeight) { + const prefix = [0] + for (let i = 0; i < items.length; i++) { + prefix[i + 1] = prefix[i] + getItemHeight(items, i, heights, avgHeight) + } + return prefix +} + +export function findStartIndex(prefix, scrollTop) { + let lo = 0, hi = prefix.length - 1 + while (lo < hi) { + const mid = Math.floor((lo + hi) / 2) + if (prefix[mid] <= scrollTop) lo = mid + 1 + else hi = mid + } + return Math.max(0, lo - 1) +} + +export function computeVirtualRange(items, scrollTop, viewportHeight, avgHeight, overscan, windowSize, heights) { + const prefix = buildPrefixHeights(items, heights, avgHeight) + const start = Math.max(0, findStartIndex(prefix, scrollTop) - overscan) + let end = Math.min(items.length, start + windowSize + overscan * 2) + // 固定窗口:严格限制 end-start 不超过 windowSize + overscan*2 + return { start, end, prefix } +} + +export function getSpacerHeights(prefix, start, end) { + const top = prefix[start] + const total = prefix[prefix.length - 1] + const bottom = Math.max(0, total - prefix[end]) + return { top, bottom, total } +} +``` + +### Task 3: Add tests (TDD) + +**Files:** +- Create: `tests/virtual-scroll.test.js` + +- [ ] **Step 1: Write failing tests** + +```js +import { describe, it, expect } from 'vitest' +import { buildPrefixHeights, computeVirtualRange, getSpacerHeights } from '../src/lib/virtual-scroll.js' + +describe('virtual scroll helpers', () => { + it('builds prefix heights with avg fallback', () => { + const items = [{ id: 'a' }, { id: 'b' }, { id: 'c' }] + const heights = new Map([['b', 80]]) + const prefix = buildPrefixHeights(items, heights, 50) + expect(prefix).toEqual([0, 50, 130, 180]) + }) + + it('computes range with window cap', () => { + const items = Array.from({ length: 200 }, (_, i) => ({ id: String(i) })) + const heights = new Map() + const { start, end } = computeVirtualRange(items, 0, 600, 30, 20, 40, heights) + expect(end - start).toBeLessThanOrEqual(80) + }) + + it('spacer heights sum to total', () => { + const prefix = [0, 50, 100, 150] + const { top, bottom, total } = getSpacerHeights(prefix, 1, 2) + expect(top + bottom + (prefix[2] - prefix[1])).toBe(total) + }) +}) +``` + +- [ ] **Step 2: Run tests (expect FAIL)** + +```powershell +npm run test +``` +Expected: FAIL if helpers not implemented. + +- [ ] **Step 3: Implement helpers (Step 2) then re-run tests (expect PASS)** + +```powershell +npm run test +``` + +- [ ] **Step 4: Commit helpers + tests** + +```powershell +git add src\lib\virtual-scroll.js tests\virtual-scroll.test.js package.json package-lock.json +git commit -m "test: add virtual scroll helpers" +``` + +--- + +## Chunk 2: Integrate virtual scroll into chat + +### Task 4: Add state + range calc + +**Files:** +- Modify: `src/pages/chat.js:64-2000` + +- [ ] **Step 1: Add constants + state** + +```js +const VIRTUAL_WINDOW = 40 +const VIRTUAL_OVERSCAN = 20 +let _virtualEnabled = true +let _virtualHeights = new Map() +let _virtualAvgHeight = 64 +let _virtualRange = { start: 0, end: 0, prefix: [0] } +``` + +- [ ] **Step 2: Import helpers** + +```js +import { computeVirtualRange, getSpacerHeights } from '../lib/virtual-scroll.js' +``` + +- [ ] **Step 3: Scroll handler** + +On scroll, compute range using `computeVirtualRange(items, scrollTop, viewportHeight, _virtualAvgHeight, VIRTUAL_OVERSCAN, VIRTUAL_WINDOW, _virtualHeights)` and update `_virtualRange` when changed. + +### Task 5: Render with spacers + measurement + +**Files:** +- Modify: `src/pages/chat.js:1380-1750` + +- [ ] **Step 1: Render spacers + window** + +Insert: +- top spacer with height = `getSpacerHeights(prefix, start, end).top` +- visible items = `items.slice(start, end)` +- bottom spacer with height = `getSpacerHeights(prefix, start, end).bottom` + +- [ ] **Step 2: Measure heights** + +After render (requestAnimationFrame), measure visible `.msg` nodes using `getBoundingClientRect().height`, update `_virtualHeights`, and recompute `_virtualAvgHeight`. + +- [ ] **Step 3: Anchor strategy** + +If user is at bottom (within 80px), auto scroll to bottom after new message. Otherwise, preserve scroll position by capturing `scrollTop` before re-render and adjusting by delta in top spacer height. + +### Task 6: Build + +```powershell +npm run build +``` +Expected: Build succeeds without errors. + +### Task 7: Commit + Push + +```powershell +git add src\pages\chat.js +git commit -m "feat: chat virtual scroll" +git push +``` diff --git a/docs/superpowers/plans/2026-03-17-cron-trigger-mode.md b/docs/superpowers/plans/2026-03-17-cron-trigger-mode.md new file mode 100644 index 00000000..3009d683 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-cron-trigger-mode.md @@ -0,0 +1,86 @@ +# Cron 触发模式扩展 Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让 sessionMessage 任务支持两种触发模式:按 cron 执行或监听指定会话 agent 任务结束后发送。 + +**Architecture:** 在 cron.js 中为 sessionMessage 任务增加触发模式字段(cron | onIdle),本地存储与渲染读取该字段;onIdle 模式基于 wsClient 事件跟踪目标会话的 run 状态,任务结束且空闲即发送;cron 模式保留现有定时器。非 sessionMessage 任务仍走 Gateway。 + +**Tech Stack:** ClawPanel (Vite + JS), WebSocket client, Tauri panel config API + +--- + +## Chunk 1: 数据模型与 UI + +### Task 1: 增加触发模式字段 + +**Files:** +- Modify: `src/pages/cron.js` + +- [ ] **Step 1: 新增字段** + +为 sessionMessage 本地任务新增 `triggerMode` 字段:`cron` | `onIdle`。 + +- [ ] **Step 2: UI 选择** + +在 sessionMessage 任务编辑弹窗加入触发模式选择: +- 选项:`按 Cron` / `监听任务结束` +- 选择 onIdle 时隐藏 cron 输入,显示说明:监听目标会话任务结束后发送。 + +- [ ] **Step 3: 列表展示** + +列表中显示触发模式: +- cron 显示 cron 文本 +- onIdle 显示 “任务结束后发送” + +- [ ] **Step 4: Commit** + +```bash +git add src/pages/cron.js +git commit -m "feat: add sessionMessage trigger mode" +``` + +## Chunk 2: 触发逻辑 + +### Task 2: cron 与 onIdle 双模式发送 + +**Files:** +- Modify: `src/pages/cron.js` + +- [ ] **Step 1: cron 触发** + +仅当 `triggerMode === 'cron'` 时参与 `tickSessionMessageJobs` 逻辑。 + +- [ ] **Step 2: onIdle 触发** + +新增 `checkIdleTrigger(job)`: +- 若 `triggerMode === 'onIdle'`,当目标会话从 active -> idle 且当前未发送过本轮,发送消息并记录 `lastRunAtMs`。 + +- [ ] **Step 3: 去重** + +使用 `state.lastRunAtMs` 或 `state.lastIdleAtMs` 避免重复发送。 + +- [ ] **Step 4: Commit** + +```bash +git add src/pages/cron.js +git commit -m "feat: onIdle trigger for sessionMessage" +``` + +## Chunk 3: 验证 + +### Task 3: Build 与手动验证 + +- [ ] **Step 1: Build** + +```bash +npm run build +``` + +- [ ] **Step 2: 验证** + +- 新建 sessionMessage 任务,选择 cron → 定时发送生效 +- 新建 sessionMessage 任务,选择 onIdle → 会话任务结束后发送 +- 非 sessionMessage 任务仍通过 Gateway 保存/触发 + +--- diff --git a/docs/superpowers/plans/2026-03-17-cron-ws-sessionmessage.md b/docs/superpowers/plans/2026-03-17-cron-ws-sessionmessage.md new file mode 100644 index 00000000..008cbde8 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-cron-ws-sessionmessage.md @@ -0,0 +1,199 @@ +# Cron WS SessionMessage Replacement Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace gateway patch–based cron sessionMessage with a client-side scheduler that sends user messages over the already-connected WSS only after the target session is idle/completed. + +**Architecture:** Store scheduled jobs in panel config (local), run a lightweight scheduler in ClawPanel (cron.js) that waits for gatewayReady and session idle state, then dispatch wsClient.chatSend. Track run state and last-run time in panel config to avoid duplicate sends. + +**Tech Stack:** Frontend JS (cron.js, ws-client.js), panel config (read/write via tauri-api), WebSocket RPC (sessions.list, sessions.get, chat events). + +--- + +## File Structure + +**Modify:** +- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\src\pages\cron.js` (UI, local scheduler, local job storage) +- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\src\lib\tauri-api.js` (panel config helpers) +- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\src\lib\ws-client.js` (optional: expose session idle state helper) +- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\src\pages\chat.js` (optional: emit idle status events) +- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\src\main.js` (optional: start scheduler on boot) + +**Create:** +- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\docs\superpowers\plans\2026-03-17-cron-ws-sessionmessage.md` (this plan) + +--- + +## Task 1: Define local cron job schema and storage + +**Files:** +- Modify: `src/pages/cron.js` +- Modify: `src/lib/tauri-api.js` + +- [ ] **Step 1: Add local job schema** + +Define a new `localCronJobs` array in panel config, each job: +``` +{ + id: "uuid", + name: "string", + schedule: { kind: "cron", expr: "* * * * *" }, + enabled: true, + payload: { + kind: "sessionMessage", + label: "sessionLabel", + message: "text", + waitForIdle: true + }, + state: { lastRunAtMs: 0, lastStatus: "ok|error|skipped", lastError: "" } +} +``` + +- [ ] **Step 2: Add API helpers for panel config** + +In `tauri-api.js`, add helpers: +``` +readPanelConfig() +writePanelConfig() +``` +Ensure cron.js can read/write panel config locally without gateway. + +- [ ] **Step 3: Implement load/save local jobs** + +In cron.js: +- On render, load panel config and initialize local jobs list if missing. +- Use `localJobs` as a separate tab/section from gateway jobs. + +--- + +## Task 2: Update Cron UI for sessionMessage-only mode + +**Files:** +- Modify: `src/pages/cron.js` + +- [ ] **Step 1: Replace task type selector** + +Remove gateway cron payload kind selector. Only show “发送 user 消息(WSS)”. + +- [ ] **Step 2: Show required fields** + +Show inputs: +- name +- schedule (cron) +- sessionLabel (from sessions.list) +- message (textarea) +- enabled toggle +- waitForIdle toggle + +- [ ] **Step 3: Save local job** + +On save, write to panel config localCronJobs, update lastRun fields to defaults. + +- [ ] **Step 4: Remove gateway cron create/update** + +Delete calls to `wsClient.request('cron.add'|'cron.update')` for local jobs. + +--- + +## Task 3: Implement WSS local scheduler + +**Files:** +- Modify: `src/pages/cron.js` +- Modify: `src/main.js` (optional startup hook) + +- [ ] **Step 1: Add scheduler loop** + +Create an interval (e.g., 10s) to: +- Check wsClient.gatewayReady +- For each enabled local job, check next due time from cron expression +- If due and not run in current window, attempt send + +- [ ] **Step 2: Determine session idle state** + +Define idle as: +- No active runs for target session +- Or no “streaming” event in last N seconds + +Approach: +- Use `sessions.list` or `sessions.get` via wsClient to read run state if available +- If not available, fallback to client-side tracking of last chat event timestamps for that session + +- [ ] **Step 3: Send message only when idle** + +Use: +``` +wsClient.chatSend(sessionKey, message) +``` +Only when idle. + +- [ ] **Step 4: Update job state** + +On send success: +- state.lastRunAtMs = Date.now() +- state.lastStatus = "ok" +On failure: +- state.lastStatus = "error" +- state.lastError = message + +Persist to panel config after each run. + +--- + +## Task 4: Session resolution by label + +**Files:** +- Modify: `src/pages/cron.js` + +- [ ] **Step 1: Build label→sessionKey map** + +Use sessions.list to map label (parseSessionLabel) back to sessionKey. + +- [ ] **Step 2: Validate session exists** + +If session missing, mark lastStatus=error and lastError="session not found". + +--- + +## Task 5: Visual status and monitoring + +**Files:** +- Modify: `src/pages/cron.js` + +- [ ] **Step 1: Show local cron jobs in UI** + +Include status badges: +- last run time +- last status +- error message if any + +- [ ] **Step 2: Add manual run button** + +Trigger immediate send via scheduler path (same idle check). + +--- + +## Task 6: Verification + +**Files:** +- Modify: `src/pages/cron.js` + +- [ ] **Step 1: Build** + +Run: +``` +npm run build +``` +Expected: Success. + +- [ ] **Step 2: Manual test** + +1) Create a local cron job to send to main session. +2) Start a long-running agent task. +3) Verify scheduler waits until idle, then sends message. + +--- + +## Notes +- This plan intentionally bypasses gateway cron schema and patching. +- Jobs are stored locally in panel config, so they only run while ClawPanel is open. +- If headless scheduling is required later, a separate gateway-side implementation will be needed. diff --git a/docs/superpowers/plans/2026-03-17-force-setup.md b/docs/superpowers/plans/2026-03-17-force-setup.md new file mode 100644 index 00000000..93774207 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-force-setup.md @@ -0,0 +1,84 @@ +# forceSetup Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 添加 `forceSetup` 开关,使构建版可强制进入 /setup,完成初始化后自动关闭。 + +**Architecture:** 在 panel config 中存储 forceSetup;启动时读取并强制跳转;setup 成功后清零。 + +**Tech Stack:** Vanilla JS, Tauri + +--- + +## Chunk 1: 配置字段读写 + +### Task 1: panel config 增加 forceSetup +**Files:** +- Modify: `src-tauri/src/commands/config.rs` +- Modify: `src/lib/tauri-api.js` + +- [ ] **Step 1: 扩展 panel config 读写** + +在读写 panel config 时透传 `forceSetup`。 + +- [ ] **Step 2: 提交** +```bash +git add src-tauri/src/commands/config.rs src/lib/tauri-api.js +git commit -m "feat: add forceSetup to panel config" +``` + +--- + +## Chunk 2: 启动强制跳转 + +### Task 2: main.js 强制跳 setup +**Files:** +- Modify: `src/main.js` + +- [ ] **Step 1: 启动时读取 panel config** + +在 `ensureWebSession` 前读取 panel config,若 `forceSetup===true` 强制跳转 `/setup`。 + +- [ ] **Step 2: 提交** +```bash +git add src/main.js +git commit -m "feat: force setup on startup" +``` + +--- + +## Chunk 3: setup 完成后清零 + +### Task 3: setup.js 成功后清零 +**Files:** +- Modify: `src/pages/setup.js` + +- [ ] **Step 1: setup 成功时写入 forceSetup=false** + +- [ ] **Step 2: 提交** +```bash +git add src/pages/setup.js +git commit -m "feat: clear forceSetup after setup" +``` + +--- + +## Chunk 4: 构建与验证 + +### Task 4: 构建 +**Files:** 无 + +- [ ] **Step 1: 构建** +```bash +npm run build +``` + +- [ ] **Step 2: 手工验证** +- forceSetup=true 时进入 /setup +- setup 完成后不再强制跳转 +- forceSetup=false 时逻辑不变 + +- [ ] **Step 3: 推送** +```bash +git push +``` diff --git a/docs/superpowers/plans/2026-03-17-skill-trigger-optimization.md b/docs/superpowers/plans/2026-03-17-skill-trigger-optimization.md new file mode 100644 index 00000000..38fc574d --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-skill-trigger-optimization.md @@ -0,0 +1,165 @@ +# Skill Trigger Optimization Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Optimize trigger descriptions for all installed OpenClaw skills using the skill-creator workflow. + +**Architecture:** Inventory all skills under `~/.openclaw/skills/`, snapshot current SKILL.md frontmatter, then iterate through each skill to improve the description field with an automated loop when available. Ensure results are written back to each skill’s SKILL.md frontmatter and logged. + +**Tech Stack:** PowerShell, Python via `uv`, OpenClaw skill-creator assets (scripts/), markdown edits. + +--- + +### Task 1: Inventory and snapshot + +**Files:** +- Read: `C:\Users\34438\.openclaw\skills\*\SKILL.md` +- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\inventory.json` +- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\snapshots\\SKILL.md` + +- [ ] **Step 1: List skill directories** + +Run: +``` +Get-ChildItem "C:\Users\34438\.openclaw\skills" -Directory | Select-Object Name +``` +Expected: list of skill folder names. + +- [ ] **Step 2: Snapshot current SKILL.md files** + +Run: +``` +New-Item -ItemType Directory -Path "C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\snapshots" -Force | Out-Null +``` +Then copy each `SKILL.md` into its snapshot folder. + +- [ ] **Step 3: Build inventory.json** + +Create a JSON array with entries: +``` +{ + "name": "skill-folder", + "path": "C:\\Users\\34438\\.openclaw\\skills\\skill-folder", + "skill_md": "...\\SKILL.md", + "description": "" +} +``` + +- [ ] **Step 4: Commit checkpoint** + +Run: +``` +git add docs/superpowers/plans/2026-03-17-skill-trigger-optimization.md +``` +If in a repo, commit after build verification. + +--- + +### Task 2: Verify skill-creator tooling availability + +**Files:** +- Read: `C:\Users\34438\.openclaw\skills\skill-creator\scripts\` (if exists) +- Read: `C:\Users\34438\.openclaw\skills\skill-creator\references\` (if exists) + +- [ ] **Step 1: Confirm run_loop.py and eval scripts exist** + +Run: +``` +Get-ChildItem "C:\Users\34438\.openclaw\skills\skill-creator\scripts" -Filter "*.py" +``` +Expected: `run_loop.py`, `run_eval.py`, `aggregate_benchmark.py` or similar. + +- [ ] **Step 2: Decide path** + +If scripts exist: use automated loop per skill-creator instructions. +If scripts are missing: use manual description optimization with heuristics (see Task 4). + +--- + +### Task 3: Automated description optimization loop (preferred) + +**Files:** +- Modify: `C:\Users\34438\.openclaw\skills\\SKILL.md` +- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\\trigger_eval.json` +- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\\run_log.txt` + +- [ ] **Step 1: Generate trigger eval set** + +Create 16-20 queries (8-10 should-trigger, 8-10 should-not-trigger) per skill and save as JSON: +``` +[ + {"query": "...", "should_trigger": true}, + {"query": "...", "should_trigger": false} +] +``` + +- [ ] **Step 2: Run description optimization loop** + +Run (example): +``` +uv run python -m scripts.run_loop --eval-set --skill-path --model openai-codex/gpt-5.2-codex --max-iterations 5 --verbose +``` +Capture output to `run_log.txt`. + +- [ ] **Step 3: Apply best_description** + +Update the SKILL.md frontmatter `description` with `best_description`. + +- [ ] **Step 4: Record changes** + +Update `inventory.json` with new description and a `score` field if available. + +--- + +### Task 4: Manual description optimization (fallback) + +**Files:** +- Modify: `C:\Users\34438\.openclaw\skills\\SKILL.md` +- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\\manual_notes.md` + +- [ ] **Step 1: Draft improved description** + +Rewrite the description with explicit trigger phrases and contexts. Ensure it is pushy and includes common user phrasings. + +- [ ] **Step 2: Update SKILL.md frontmatter** + +Replace the `description` value while preserving name and formatting. + +- [ ] **Step 3: Log** + +Write before/after into `manual_notes.md` and update `inventory.json`. + +--- + +### Task 5: Validation and summary + +**Files:** +- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\summary.md` + +- [ ] **Step 1: Validate frontmatter** + +Verify each SKILL.md starts with YAML frontmatter containing `name` and `description`. + +- [ ] **Step 2: Build summary** + +Write a summary with counts of skills updated and any failures. + +- [ ] **Step 3: Final build check (if required by repo policy)** + +Run: +``` +npm run build +``` +Expected: success. + +- [ ] **Step 4: Commit** + +Commit all changes after build success. + +--- + +## Notes +- Use PowerShell only. +- Use `uv run python` for Python scripts. +- No emoji in outputs or docs. +- Do not overwrite SKILL.md structure beyond frontmatter description. diff --git a/docs/superpowers/plans/2026-03-17-skillhub-env-fix.md b/docs/superpowers/plans/2026-03-17-skillhub-env-fix.md new file mode 100644 index 00000000..f069849b --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-skillhub-env-fix.md @@ -0,0 +1,102 @@ +# SkillHub 动态探测与系统环境变量继承 Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让 ClawPanel 通过动态探测识别 SkillHub CLI,并让所有 Tauri 命令继承完整系统环境变量(用户 + 系统)。 + +**Architecture:** 在 Tauri 后端新增统一“系统环境构建函数”,所有命令执行时统一注入 envs;SkillHub 检测失败时走 where 探测并返回命中路径。 + +**Tech Stack:** Rust (Tauri), Windows Registry, tokio::process::Command + +--- + +## Chunk 1: 环境变量合并工具函数 + +### Task 1: 新增系统环境合并函数 +**Files:** +- Modify: `src-tauri/src/utils.rs` +- Modify: `src-tauri/src/commands/mod.rs`(如已有 enhanced_path 需更新) +- Test: (无自动测试,手工验证) + +- [ ] **Step 1: 设计合并逻辑并落地函数** + +新增 `build_system_env()`,返回 `Vec<(String, String)>`,包含: +- 当前进程 env +- 用户 env(HKCU\Environment) +- 系统 env(HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment) + +PATH 处理:系统 + 用户 + 进程,去重拼接。 + +示例(伪代码): +```rust +pub fn build_system_env() -> Vec<(String, String)> { + let mut env_map = HashMap::new(); + // 1) 读取系统 + 用户 env 并写入 + // 2) 读取进程 env 覆盖 + // 3) PATH 合并去重 + env_map.into_iter().collect() +} +``` + +- [ ] **Step 2: 在 commands 统一使用 build_system_env** + +将 `enhanced_path()` 替换或改为使用 `build_system_env()`,确保所有命令执行时统一 `cmd.envs(build_system_env())`。 + +- [ ] **Step 3: 提交** +```bash +git add src-tauri/src/utils.rs src-tauri/src/commands/mod.rs +git commit -m "feat: inherit full system env for commands" +``` + +--- + +## Chunk 2: SkillHub 动态探测与返回路径 + +### Task 2: SkillHub 检测增强 +**Files:** +- Modify: `src-tauri/src/commands/skills.rs` +- Modify: `src/lib/tauri-api.js`(若返回字段变化) +- Modify: `src/pages/skills.js` + +- [ ] **Step 1: 更新 skills_skillhub_check** + +流程: +1) `skillhub --version` +2) 若失败,执行 `where skillhub` +3) 取第一条路径,执行 ` --version` +4) 返回 `{ installed: true, version, path }` + +- [ ] **Step 2: 更新前端展示** + +Skills 页面展示 path(若存在): +- `#skillhub-status` 增加 “路径: xxx” +- 仍显示版本号 + +- [ ] **Step 3: 提交** +```bash +git add src-tauri/src/commands/skills.rs src/lib/tauri-api.js src/pages/skills.js +git commit -m "feat: detect skillhub by path and show location" +``` + +--- + +## Chunk 3: 手工验证与构建 + +### Task 3: 验证与构建 +**Files:** +- 无 + +- [ ] **Step 1: 前端构建** +```bash +npm run build +``` + +- [ ] **Step 2: 手工验证要点** +- SkillHub CLI 安装后,未重启 ClawPanel 仍能识别 installed=true +- UI 能显示 version + path +- 晴辰助手执行命令时继承系统变量(如 PATH / HTTP_PROXY) + +- [ ] **Step 3: 代码汇总与推送** +```bash +git push +``` diff --git a/docs/superpowers/plans/2026-03-17-toast-night-and-model-select.md b/docs/superpowers/plans/2026-03-17-toast-night-and-model-select.md new file mode 100644 index 00000000..9c1c4519 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-toast-night-and-model-select.md @@ -0,0 +1,99 @@ +# Toast Night Style + Model Select Width Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Improve toast visibility in dark mode and make chat-model-select auto-size without truncation. + +**Architecture:** Update CSS rules in `components.css` for toast dark mode and in `chat.css` for model select width. + +**Tech Stack:** CSS, Vite build + +--- + +## Chunk 1: Toast dark mode visibility + +### Task 1: Add dark theme toast background + +**Files:** +- Modify: `src/style/components.css` + +- [ ] **Step 0: Checkpoint(PowerShell)** + +```powershell +git status -sb +git commit --allow-empty -m "chore: checkpoint before toast dark style" +``` + +Note: This checkpoint is mandatory by policy before any modification. The final functional commit still occurs after build to honor Build First, Commit Later for real changes. + +- [ ] **Step 1: Add dark theme override** + +```css +[data-theme="dark"] .toast { + background: var(--bg-secondary); +} +``` + +- [ ] **Step 2: Build** + +Run: `npm run build` +Expected: Build succeeds without errors. + +- [ ] **Step 3: Commit(PowerShell)** + +```powershell +git add src/style/components.css +git commit -m "fix: improve toast dark mode" +``` + +- [ ] **Step 4: Push(PowerShell)** + +```powershell +git push +``` + +## Chunk 2: Model select auto width + +### Task 2: Remove truncation and allow auto width + +**Files:** +- Modify: `src/style/chat.css` + +- [ ] **Step 0: Checkpoint(PowerShell)** + +```powershell +git status -sb +git commit --allow-empty -m "chore: checkpoint before model select width" +``` + +- [ ] **Step 1: Update model select styles** + +Set the model select to auto width and remove truncation. Example: + +```css +.chat-model-select { + width: auto; + max-width: none; + white-space: nowrap; +} +``` + +Adjust if selectors differ in current file. + +- [ ] **Step 2: Build** + +Run: `npm run build` +Expected: Build succeeds without errors. + +- [ ] **Step 3: Commit(PowerShell)** + +```powershell +git add src/style/chat.css +git commit -m "fix: auto width for chat model select" +``` + +- [ ] **Step 4: Push(PowerShell)** + +```powershell +git push +``` diff --git a/docs/superpowers/plans/2026-03-17-toast-shadow.md b/docs/superpowers/plans/2026-03-17-toast-shadow.md new file mode 100644 index 00000000..beb3bea8 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-toast-shadow.md @@ -0,0 +1,54 @@ +# Toast Shadow Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a medium-strength shadow to toast cards while keeping the Vercel-style solid background. + +**Architecture:** Modify `.toast` rule in `components.css` to add `box-shadow` only. + +**Tech Stack:** CSS, Vite build + +--- + +## Chunk 1: Toast shadow + +### Task 1: Add toast shadow + +**Files:** +- Modify: `src/style/components.css` + +- [ ] **Step 0: Checkpoint(PowerShell)** + +```powershell +git status -sb +git commit --allow-empty -m "chore: checkpoint before toast shadow" +``` + +Note: This checkpoint is required by policy to protect rollbacks. The final functional commit still happens after `npm run build`. + +- [ ] **Step 1: Add box-shadow** + +```css +.toast { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); +} +``` + +- [ ] **Step 2: Build** + +Run: `npm run build` +Expected: Build succeeds without errors. +Note: This is the frontend Vite build; no `wails build` required for this CSS-only change. + +- [ ] **Step 3: Commit(PowerShell)** + +```powershell +git add src/style/components.css +git commit -m "fix: add toast shadow" +``` + +- [ ] **Step 4: Push(PowerShell)** + +```powershell +git push +``` diff --git a/docs/superpowers/plans/2026-03-17-toast-vercel.md b/docs/superpowers/plans/2026-03-17-toast-vercel.md new file mode 100644 index 00000000..ed92e2c7 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-toast-vercel.md @@ -0,0 +1,54 @@ +# Toast Vercel Style Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace glass/blur toast with solid Vercel-style card that adapts to light/dark themes. + +**Architecture:** Update toast styles in `components.css` to remove blur, use theme variables for background and border, keep status text colors. + +**Tech Stack:** CSS, Vite build + +--- + +## Chunk 1: Toast style update + +### Task 1: Update toast base style + +**Files:** +- Modify: `src/style/components.css` + +- [ ] **Step 0: Checkpoint** + +```bash +git status -sb +git commit --allow-empty -m "chore: checkpoint before toast style update" +``` + +- [ ] **Step 1: Remove blur and set base card styles** + +Update `.toast` rule: +- remove `backdrop-filter` +- add `background: var(--bg-primary);` +- add `border: 1px solid var(--border);` + +- [ ] **Step 2: Simplify status variants** + +Update `.toast.success/.error/.info/.warning` to only set `color`, removing background and border overrides. + +- [ ] **Step 3: Build** + +Run: `npm run build` +Expected: Build succeeds without errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/style/components.css +git commit -m "fix: vercel-style toast card" +``` + +- [ ] **Step 5: Push** + +```bash +git push +``` diff --git a/docs/superpowers/plans/2026-03-17-tool-call-meta.md b/docs/superpowers/plans/2026-03-17-tool-call-meta.md new file mode 100644 index 00000000..ac94c8db --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-tool-call-meta.md @@ -0,0 +1,108 @@ +# Tool Call Meta Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Show tool call time in the tool card header and ensure expanded sections display input/output placeholders when data is empty. + +**Architecture:** Update tool rendering in `src/pages/chat.js` to compute display time and placeholders; optional minor CSS tweaks if needed. + +**Tech Stack:** Vanilla JS, CSS, Vite build + +--- + +## Chunk 1: Tool header time + placeholders + +### Task 1: Add tool time display + +**Files:** +- Modify: `src/pages/chat.js` + +- [ ] **Step 0: Checkpoint(PowerShell)** + +```powershell +git status -sb +git commit --allow-empty -m "chore: checkpoint before tool meta" +``` + +Note: This checkpoint is required by policy; final functional commit occurs after build. + +- [ ] **Step 1: Add helper to get tool time** + +Add a function near other helpers in `src/pages/chat.js` (below `stripThinkingTags`): + +```js +function getToolTime(tool) { + const raw = tool?.end_time || tool?.endTime || tool?.timestamp || tool?.time || tool?.started_at || tool?.startedAt || null + if (!raw) return null + if (typeof raw === 'number' && raw < 1e12) return raw * 1000 + return raw +} +``` + +Note: `formatTime` and `escapeHtml` already exist in `chat.js`. + +- [ ] **Step 1.5: Capture tool event timestamps** + +Add a map at top-level in `src/pages/chat.js`: + +```js +const _toolEventTimes = new Map() +``` + +In `handleEvent`, before `handleChatEvent`, capture tool events: + +```js +if (event === 'agent' && payload?.stream === 'tool' && payload?.data?.toolCallId) { + const ts = payload.ts + if (ts) _toolEventTimes.set(payload.data.toolCallId, ts) +} +``` + +In `collectToolsFromMessage`, when constructing tool entries, set `time` when absent: + +```js +const callId = call.id || call.tool_call_id +const fallbackTime = callId ? _toolEventTimes.get(callId) : null +... time: call.time || fallbackTime ... +``` + +- [ ] **Step 2: Render header with time** + +In `appendToolsToEl`, reuse the existing `summary` node created there and update header: + +```js +const time = getToolTime(tool) +const timeText = time ? formatTime(new Date(time)) : '时间未知' +summary.innerHTML = `${escapeHtml(tool.name || '工具')} · ${status} · ${timeText}` +``` + +- [ ] **Step 3: Add placeholders** + +Use the exact block structure already used in tool body: + +```js +const input = inputJson + ? `
参数
${escapeHtml(inputJson)}
` + : `
参数
无参数
` +const output = outputJson + ? `
结果
${escapeHtml(outputJson)}
` + : `
结果
无结果
` +``` + +- [ ] **Step 4: Build** + +Run: `npm run build` +Expected: Build succeeds without errors. + +- [ ] **Step 5: Commit(PowerShell)** + +```powershell +git add src/pages/chat.js +git commit -m "fix: show tool time and placeholders" +``` + +- [ ] **Step 6: Push(PowerShell)** + +```powershell +git push +``` diff --git a/docs/superpowers/plans/2026-03-17-ws-connect-bootstrap.md b/docs/superpowers/plans/2026-03-17-ws-connect-bootstrap.md new file mode 100644 index 00000000..ff62269d --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-ws-connect-bootstrap.md @@ -0,0 +1,86 @@ +# WS Connect Bootstrap Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** On each websocket connect success, send the official 8-request bootstrap batch; set ping interval to 5s (bootstrap may duplicate the first ping batch). + +**Architecture:** Add `_sendBootstrapRequests()` to `WsClient`, call it from `_handleConnectSuccess`, update `PING_INTERVAL` constant to 5000. + +**Tech Stack:** JS, Vite build + +--- + +## Chunk 1: Bootstrap batch + ping interval + +### Task 1: Implement bootstrap batch + +**Files:** +- Modify: `src/lib/ws-client.js` + +- [ ] **Step 0: Checkpoint(PowerShell)** + +```powershell +git status -sb +git commit --allow-empty -m "chore: checkpoint before ws bootstrap" +``` + +Note: This checkpoint is mandatory by policy before modifications. + +- [ ] **Step 1: Add helper to send bootstrap batch** + +Add method inside `WsClient`: + +```js +_sendBootstrapRequests() { + if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return + const sessionKey = this._sessionKey || 'agent:full-stack-architect:main' + // Note: responses are fire-and-forget in this batch + const frames = [ + { type: 'req', id: uuid(), method: 'agent.identity.get', params: { sessionKey } }, + { type: 'req', id: uuid(), method: 'agents.list', params: {} }, + { type: 'req', id: uuid(), method: 'health', params: {} }, + { type: 'req', id: uuid(), method: 'node.list', params: {} }, + { type: 'req', id: uuid(), method: 'device.pair.list', params: {} }, + { type: 'req', id: uuid(), method: 'chat.history', params: { sessionKey, limit: 200 } }, + { type: 'req', id: uuid(), method: 'sessions.list', params: { includeGlobal: true, includeUnknown: true } }, + { type: 'req', id: uuid(), method: 'models.list', params: {} }, + ] + frames.forEach(frame => this._ws.send(JSON.stringify(frame))) +} +``` + +- [ ] **Step 2: Call bootstrap on connect success** + +In `_handleConnectSuccess` add: + +```js +this._sendBootstrapRequests() +``` + +- [ ] **Step 3: Set ping interval to 5s** + +Change constant: + +```js +const PING_INTERVAL = 5000 +``` + +Note: With 5s interval and multi-req pings, load increases. This matches the requested behavior. + +- [ ] **Step 4: Build** + +Run: `npm run build` +Expected: Build succeeds without errors. + +- [ ] **Step 5: Commit(PowerShell)** + +```powershell +git add src/lib/ws-client.js +git commit -m "fix: ws bootstrap batch" +``` + +- [ ] **Step 6: Push(PowerShell)** + +```powershell +git push +``` diff --git a/docs/superpowers/plans/2026-03-17-ws-ping-multi-req.md b/docs/superpowers/plans/2026-03-17-ws-ping-multi-req.md new file mode 100644 index 00000000..4b768851 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-ws-ping-multi-req.md @@ -0,0 +1,59 @@ +# WS Ping Multi-Req Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Send node.list, models.list, sessions.list, and chat.history every ping interval. + +**Architecture:** Update `_startPing` in `src/lib/ws-client.js` to emit four req frames each interval. + +**Tech Stack:** JS, Vite build + +--- + +## Chunk 1: Ping multi-req + +### Task 1: Update ping sender + +**Files:** +- Modify: `src/lib/ws-client.js` + +- [ ] **Step 0: Checkpoint(PowerShell)** + +```powershell +git status -sb +git commit --allow-empty -m "chore: checkpoint before ping multi req" +``` + +Note: This checkpoint is mandatory by policy before modifications. + +- [ ] **Step 1: Replace ping payload with 4 req frames** + +In `_startPing` interval: + +```js +const frames = [ + { type: 'req', id: uuid(), method: 'node.list', params: {} }, + { type: 'req', id: uuid(), method: 'models.list', params: {} }, + { type: 'req', id: uuid(), method: 'sessions.list', params: { includeGlobal: true, includeUnknown: true } }, + { type: 'req', id: uuid(), method: 'chat.history', params: { sessionKey: 'agent:full-stack-architect:main', limit: 200 } }, +] +frames.forEach(frame => this._ws.send(JSON.stringify(frame))) +``` + +- [ ] **Step 2: Build** + +Run: `npm run build` +Expected: Build succeeds without errors. + +- [ ] **Step 3: Commit(PowerShell)** + +```powershell +git add src/lib/ws-client.js +git commit -m "fix: ping sends multi req" +``` + +- [ ] **Step 4: Push(PowerShell)** + +```powershell +git push +``` diff --git a/docs/superpowers/plans/2026-03-17-ws-ping-node-list.md b/docs/superpowers/plans/2026-03-17-ws-ping-node-list.md new file mode 100644 index 00000000..7575460f --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-ws-ping-node-list.md @@ -0,0 +1,56 @@ +# WS Ping Node List Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace websocket ping with a periodic node.list request. + +**Architecture:** Modify `_startPing` in `src/lib/ws-client.js` to send a req frame rather than a ping frame. + +**Tech Stack:** JS, Vite build + +--- + +## Chunk 1: Replace ping payload + +### Task 1: Update ping sender + +**Files:** +- Modify: `src/lib/ws-client.js` + +- [ ] **Step 0: Checkpoint(PowerShell)** + +```powershell +git status -sb +git commit --allow-empty -m "chore: checkpoint before ping change" +``` + +Note: This checkpoint is mandatory by policy before modifications. The final functional commit still occurs after a successful build. + +- [ ] **Step 1: Replace ping payload** + +In `_startPing` interval: + +```js +const frame = { type: 'req', id: uuid(), method: 'node.list', params: {} } +this._ws.send(JSON.stringify(frame)) +``` + +- [ ] **Step 2: Build** + +Run: `npm run build` +Expected: Build succeeds without errors. + +- [ ] **Step 3: Commit(PowerShell)** + +```powershell +git add src/lib/ws-client.js +git commit -m "fix: ping uses node.list" +``` + +- [ ] **Step 4: Push(PowerShell)** + +Run only after successful build and commit. + +```powershell +git push +``` From 0f2da2f1f2da056c408e61cbf55d88dc3693f11f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 13:18:11 +0800 Subject: [PATCH 195/426] chore: ignore plan/pr docs --- .gitignore | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 26c5bb30..4b715ebc 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,9 @@ docs/promo-video.mp4 # Rust 开发工具 src-tauri/.cargo/ -.codex/ \ No newline at end of file +.codex/docs/plans/ +docs/superpowers/plans/ +docs/*-plan.md +docs/plan/ +docs/pr/ +docs/plans/ \ No newline at end of file From 6098acb68c664a05ff52bb0c93c75e9b710d99a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 13:25:50 +0800 Subject: [PATCH 196/426] chore: checkpoint From d359261d166a6421361f192ad7eb23888ce61bb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 13:26:25 +0800 Subject: [PATCH 197/426] chore: checkpoint before hosted agent chat From 2ddb90bfec29953cb6ccd24c7da92c16350be4be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 13:26:51 +0800 Subject: [PATCH 198/426] chore: checkpoint assistant-core From 32759575ec8b5d1ac35c4a8d38b4baf37b8e1dda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 13:36:36 +0800 Subject: [PATCH 199/426] feat: add assistant core module --- src/lib/assistant-core.js | 1093 +++++++++++++++++++++++++++++++++++++ 1 file changed, 1093 insertions(+) create mode 100644 src/lib/assistant-core.js diff --git a/src/lib/assistant-core.js b/src/lib/assistant-core.js new file mode 100644 index 00000000..e719abe9 --- /dev/null +++ b/src/lib/assistant-core.js @@ -0,0 +1,1093 @@ +/** + * assistant-core.js + * 模型调用与工具执行核心逻辑(无 UI 依赖) + * 提供 buildSystemPrompt / getEnabledTools / callAIWithTools / callAI / trimContext + */ + +// ── 常量 ── +const DEFAULT_NAME = '晴辰助手' +const DEFAULT_PERSONALITY = '专业、友善、简洁。善于分析问题,给出可操作的解决方案。' +const DEFAULT_MODE = 'execute' + +const MODES = { + chat: { label: '聊天', desc: '纯对话,不调用任何工具', tools: false, readOnly: false, confirmDanger: true }, + plan: { label: '规划', desc: '可调用工具分析,但不修改文件', tools: true, readOnly: true, confirmDanger: true }, + execute: { label: '执行', desc: '完整工具权限,危险操作需确认', tools: true, readOnly: false, confirmDanger: true }, + unlimited:{ label: '无限', desc: '最大权限,工具调用无需确认', tools: true, readOnly: false, confirmDanger: false }, +} + +// ── 工具定义(OpenAI function calling 格式)── +const TOOL_DEFS = { + terminal: [ + { + type: 'function', + function: { + name: 'run_command', + description: '在本机终端执行 shell 命令。用于系统管理、服务操作、文件查看等。注意:命令会直接在用户的机器上执行,请谨慎使用。', + parameters: { + type: 'object', + properties: { + command: { type: 'string', description: '要执行的 shell 命令' }, + cwd: { type: 'string', description: '工作目录(可选,默认为用户主目录)' }, + }, + required: ['command'], + }, + }, + }, + ], + system: [ + { + type: 'function', + function: { + name: 'get_system_info', + description: '获取当前系统信息,包括操作系统类型(windows/macos/linux)、CPU 架构、用户主目录、主机名、默认 Shell。在执行任何命令前应先调用此工具来判断操作系统,以选择正确的命令语法。', + parameters: { type: 'object', properties: {}, required: [] }, + }, + }, + ], + process: [ + { + type: 'function', + function: { + name: 'list_processes', + description: '列出当前运行中的进程。可以按名称过滤,用于检查某个服务是否在运行(如 node、openclaw、gateway)。', + parameters: { + type: 'object', + properties: { + filter: { type: 'string', description: '过滤关键词(可选),只返回包含该关键词的进程' }, + }, + required: [], + }, + }, + }, + { + type: 'function', + function: { + name: 'check_port', + description: '检测指定端口是否被占用,并返回占用该端口的进程信息。常用端口:Gateway 18789、WebSocket 18790。', + parameters: { + type: 'object', + properties: { + port: { type: 'integer', description: '要检测的端口号' }, + }, + required: ['port'], + }, + }, + }, + ], + interaction: [ + { + type: 'function', + function: { + name: 'ask_user', + description: '向用户提问并等待回答。支持单选、多选和自由输入。当你需要用户做决定、确认方案、选择选项时使用此工具。用户可以选择预设选项,也可以输入自定义内容。', + parameters: { + type: 'object', + properties: { + question: { type: 'string', description: '要问用户的问题' }, + type: { type: 'string', enum: ['single', 'multiple', 'text'], description: '交互类型:single=单选, multiple=多选, text=自由输入' }, + options: { + type: 'array', + items: { type: 'string' }, + description: '预设选项列表(single/multiple 时必填,text 时可选作为建议)', + }, + placeholder: { type: 'string', description: '自由输入时的占位提示文字(可选)' }, + }, + required: ['question', 'type'], + }, + }, + }, + ], + webSearch: [ + { + type: 'function', + function: { + name: 'web_search', + description: '联网搜索关键词,返回搜索结果列表(标题、链接、摘要)。用于查找错误解决方案、最新文档、GitHub Issues 等。', + parameters: { + type: 'object', + properties: { + query: { type: 'string', description: '搜索关键词' }, + max_results: { type: 'integer', description: '最大结果数(默认 5)' }, + }, + required: ['query'], + }, + }, + }, + { + type: 'function', + function: { + name: 'fetch_url', + description: '抓取指定 URL 的网页内容,返回纯文本/Markdown 格式。用于获取搜索结果中某个页面的详细内容。', + parameters: { + type: 'object', + properties: { + url: { type: 'string', description: '要抓取的网页 URL' }, + }, + required: ['url'], + }, + }, + }, + ], + skills: [ + { + type: 'function', + function: { + name: 'skills_list', + description: '列出所有 OpenClaw Skills 及其状态(可用/缺依赖/已禁用)。返回每个 Skill 的名称、描述、来源、依赖状态、缺少的依赖项、可用的安装选项等信息。', + parameters: { type: 'object', properties: {}, required: [] }, + }, + }, + { + type: 'function', + function: { + name: 'skills_info', + description: '查看指定 Skill 的详细信息,包括描述、来源、依赖要求、缺少的依赖、安装选项等。', + parameters: { + type: 'object', + properties: { + name: { type: 'string', description: 'Skill 名称,如 github、weather、coding-agent' }, + }, + required: ['name'], + }, + }, + }, + { + type: 'function', + function: { + name: 'skills_check', + description: '检查所有 Skills 的依赖状态,返回哪些可用、哪些缺少依赖、哪些已禁用的汇总信息。', + parameters: { type: 'object', properties: {}, required: [] }, + }, + }, + { + type: 'function', + function: { + name: 'skills_install_dep', + description: '安装 Skill 缺少的依赖。根据 Skill 的 install spec 执行对应的包管理器命令(brew/npm/go/uv)。安装完成后会自动生效。', + parameters: { + type: 'object', + properties: { + kind: { type: 'string', enum: ['brew', 'node', 'go', 'uv'], description: '安装类型' }, + spec: { + type: 'object', + description: '安装参数。brew 需要 formula,node 需要 package,go 需要 module,uv 需要 package。', + properties: { + formula: { type: 'string', description: 'Homebrew formula 名称' }, + package: { type: 'string', description: 'npm 或 uv 包名' }, + module: { type: 'string', description: 'Go module 路径' }, + }, + }, + }, + required: ['kind', 'spec'], + }, + }, + }, + { + type: 'function', + function: { + name: 'skills_clawhub_search', + description: '在 ClawHub 社区市场中搜索 Skills。返回匹配的 Skill 列表(slug 和描述)。', + parameters: { + type: 'object', + properties: { + query: { type: 'string', description: '搜索关键词' }, + }, + required: ['query'], + }, + }, + }, + { + type: 'function', + function: { + name: 'skills_clawhub_install', + description: '从 ClawHub 社区市场安装一个 Skill 到本地 ~/.openclaw/skills/ 目录。', + parameters: { + type: 'object', + properties: { + slug: { type: 'string', description: 'ClawHub 上的 Skill slug(名称标识)' }, + }, + required: ['slug'], + }, + }, + }, + ], + fileOps: [ + { + type: 'function', + function: { + name: 'read_file', + description: '读取指定路径的文件内容。用于查看配置文件、日志文件等。', + parameters: { + type: 'object', + properties: { + path: { type: 'string', description: '文件的完整路径' }, + }, + required: ['path'], + }, + }, + }, + { + type: 'function', + function: { + name: 'write_file', + description: '写入或创建文件。会自动创建父目录。注意:会覆盖已有内容。', + parameters: { + type: 'object', + properties: { + path: { type: 'string', description: '文件的完整路径' }, + content: { type: 'string', description: '要写入的内容' }, + }, + required: ['path', 'content'], + }, + }, + }, + { + type: 'function', + function: { + name: 'list_directory', + description: '列出目录下的文件和子目录。', + parameters: { + type: 'object', + properties: { + path: { type: 'string', description: '目录路径' }, + }, + required: ['path'], + }, + }, + }, + ], +} + +const INTERACTIVE_TOOLS = new Set(['ask_user']) +const DANGEROUS_TOOLS = new Set(['run_command', 'write_file', 'skills_install_dep', 'skills_clawhub_install']) +const CRITICAL_PATTERNS = [ + /rm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?[\/~]/i, + /rm\s+-[a-zA-Z]*r[a-zA-Z]*\s+\//i, + /format\s+[a-zA-Z]:/i, + /mkfs\./i, + /dd\s+.*of=\/dev\//i, + />\s*\/dev\/[sh]d/i, + /DROP\s+(DATABASE|TABLE|SCHEMA)/i, + /TRUNCATE\s+TABLE/i, + /DELETE\s+FROM\s+\w+\s*;?\s*$/i, + /:\(\){ :\|:& };/, + /shutdown|reboot|init\s+[06]/i, + /chmod\s+(-R\s+)?777\s+\//i, + /chown\s+(-R\s+)?.*\s+\//i, + /curl\s+.*\|\s*(sudo\s+)?bash/i, + /wget\s+.*\|\s*(sudo\s+)?bash/i, + /npm\s+publish/i, + /git\s+push\s+.*--force/i, +] + +// 内置 Skills(用于系统提示词) +const BUILTIN_SKILLS = [ + { id: 'check-config', name: '检查 OpenClaw 配置', desc: '读取并分析 openclaw.json,检查配置是否正确' }, + { id: 'diagnose-gateway', name: '诊断 Gateway', desc: '检查 Gateway 运行状态、端口、日志' }, + { id: 'browse-dir', name: '浏览配置目录', desc: '查看 .openclaw 目录结构和文件' }, + { id: 'check-env', name: '检查系统环境', desc: '检测 Node.js、npm 版本和系统信息' }, +] + +const STOP_PATTERNS = [/\<\s*stop\s*\>/ig] + +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-...' +} + +function getSystemPromptBase(config) { + const name = config?.assistantName || DEFAULT_NAME + const personality = config?.assistantPersonality || DEFAULT_PERSONALITY + return `你是「${name}」,ClawPanel 内置的 AI 智能助手。 + +## 你的性格 +${personality} + +## 你是谁 +- 你是 ClawPanel 内置的智能助手 +- 你帮助用户管理和排障 OpenClaw AI Agent 平台 +- 你精通 OpenClaw 的架构、配置、Gateway、Agent 管理等所有方面 +- 你善于分析日志、诊断错误、提供解决方案 + +## 相关资源 +- ClawPanel 官网: https://claw.qt.cool +- GitHub: https://github.com/qingchencloud +- 开源项目: + - ClawPanel — OpenClaw 可视化管理面板(Tauri v2) + - OpenClaw 汉化版 — AI Agent 平台中文版,npm install -g @qingchencloud/openclaw-zh + +## ClawPanel 是什么 +- OpenClaw 的可视化管理面板,基于 Tauri v2 的跨平台桌面应用(Windows/macOS/Linux) +- 支持仪表盘监控、模型配置、Agent 管理、实时聊天、记忆文件管理、AI 助手工具调用等 +- 官网: https://claw.qt.cool | GitHub: https://github.com/qingchencloud/clawpanel + +## OpenClaw 是什么 +- 开源的 AI Agent 平台,支持多模型、多 Agent、MCP 工具调用 +- 核心组件: Gateway(API 网关)、Agent(AI 代理)、Tools(工具系统) +- 配置文件: ~/.openclaw/openclaw.json(全局配置) +- 安装方式: npm install -g @qingchencloud/openclaw-zh(汉化版,推荐)或 npm install -g openclaw(官方英文版)` +} + +function currentMode(config, modeOverride) { + const modeKey = modeOverride || config?.mode + return MODES[modeKey] ? modeKey : DEFAULT_MODE +} + +function buildSystemPrompt({ config, soulCache, knowledgeBase }) { + const cfg = config || {} + const kbText = typeof knowledgeBase === 'string' ? knowledgeBase : '' + let prompt = '' + + if (cfg?.soulSource?.startsWith('openclaw:') && soulCache) { + prompt += '# 你的身份\n' + if (soulCache.identity) prompt += soulCache.identity + '\n\n' + if (soulCache.soul) prompt += '# 灵魂\n' + soulCache.soul + '\n\n' + if (soulCache.user) prompt += '# 你的用户\n' + soulCache.user + '\n\n' + if (soulCache.agents) { + const agentsContent = soulCache.agents.length > 4000 ? soulCache.agents.slice(0, 4000) + '\n\n[...已截断]' : soulCache.agents + prompt += '# 操作规则\n' + agentsContent + '\n\n' + } + if (soulCache.tools) prompt += '# 工具笔记\n' + soulCache.tools + '\n\n' + if (soulCache.memory) { + const memContent = soulCache.memory.length > 3000 ? soulCache.memory.slice(-3000) : soulCache.memory + prompt += '# 长期记忆\n' + memContent + '\n\n' + } + if (soulCache.recentMemories?.length) { + prompt += '# 最近记忆\n' + for (const m of soulCache.recentMemories) { + const content = m.content.length > 800 ? m.content.slice(0, 800) + '...' : m.content + prompt += `## ${m.date}\n${content}\n\n` + } + } + prompt += '\n# ClawPanel 工具能力\n你同时是 ClawPanel 内置助手,拥有以下额外能力:\n' + prompt += '- 执行终端命令、读写文件、浏览目录\n' + prompt += '- 联网搜索和网页抓取\n' + prompt += '- 管理 OpenClaw 配置和服务\n' + prompt += '- 你精通 OpenClaw 的架构、配置、Gateway、Agent 管理\n' + } else { + prompt += getSystemPromptBase(cfg) + } + + const modeKey = currentMode(cfg) + const mode = MODES[modeKey] + + prompt += `\n\n## 当前模式:${mode.label}模式` + + if (modeKey === 'chat') { + prompt += '\n你处于纯聊天模式,没有任何工具可用。请通过文字回答问题,给出具体的命令建议供用户手动执行。' + prompt += '\n如果用户需要你执行操作,建议用户切换到「执行」或「规划」模式。' + } else { + if (modeKey === 'plan') { + prompt += '\n你处于规划模式:可以调用工具读取信息、分析问题,但绝对不能修改任何文件(write_file 已禁用)。' + prompt += '\n你的任务是:分析问题 -> 制定方案 -> 输出详细步骤,让用户确认后再切换到执行模式操作。' + prompt += '\n即使使用 run_command,也只能执行只读命令(查看、检查、列出),不要执行任何修改操作。' + } + if (modeKey === 'unlimited') { + prompt += '\n你处于无限模式:所有工具调用无需用户确认,请高效完成任务。' + } + + prompt += '\n\n### 可用工具' + prompt += '\n- 用户交互: ask_user — 向用户提问(单选/多选/文本),获取结构化回答。需要用户做决定时优先用此工具。' + prompt += '\n- 系统信息: get_system_info — 获取 OS 类型、架构、主目录等。在执行任何命令前必须先调用此工具。' + prompt += '\n- 进程/端口: list_processes(按名称过滤)、check_port(检测端口占用)' + prompt += '\n- 终端: run_command — 执行 shell 命令' + if (mode.readOnly) { + prompt += '\n- 文件: read_file、list_directory(只读,write_file 已禁用)' + } else { + prompt += '\n- 文件: read_file、write_file、list_directory' + } + + prompt += '\n\n### 终端命令规范(重要)' + prompt += '\n- Windows: 终端是 PowerShell,必须使用 PowerShell 语法:' + prompt += '\n - 列目录: Get-ChildItem' + prompt += '\n - 看文件: Get-Content' + prompt += '\n - 查进程: Get-Process | Where-Object { $_.Name -like "*openclaw*" }' + prompt += '\n - 查端口: Get-NetTCPConnection -LocalPort 18789' + prompt += '\n - 文件尾: Get-Content file.log -Tail 50' + prompt += '\n - 搜内容: Select-String -Path file.log -Pattern "ERROR"' + prompt += '\n - 环境变量: $env:USERPROFILE' + prompt += '\n- macOS: zsh,标准 Unix 命令' + prompt += '\n- Linux: bash,标准 Unix 命令' + prompt += '\n- 绝对禁止 cmd.exe 语法(dir、type、findstr、netstat)' + prompt += '\n- 一次只执行一条命令,等结果出来再决定下一步' + prompt += '\n- 不要重复执行相同的命令' + + prompt += '\n\n### 跨平台路径' + prompt += '\n- Windows: $env:USERPROFILE\\.openclaw\\' + prompt += '\n- macOS/Linux: ~/.openclaw/' + + prompt += '\n\n### 工具使用原则' + prompt += '\n- 先 get_system_info,再根据 OS 执行正确命令' + prompt += '\n- 优先用 read_file / list_directory / list_processes / check_port 等专用工具,减少 run_command 使用' + prompt += '\n- 主动使用工具,不要只建议用户手动操作' + if (mode.confirmDanger) { + prompt += '\n- 执行破坏性操作前先告知用户' + } + } + + prompt += '\n\n## 内置技能卡片' + prompt += '\n用户可以在欢迎页点击技能卡片快速触发操作。当用户遇到问题时,你也可以主动推荐合适的技能:' + for (const s of BUILTIN_SKILLS) { + prompt += `\n- ${s.name}(${s.desc})` + } + prompt += '\n\n当用户的需求匹配某个技能时,可以建议用户点击对应的技能卡片,或者你直接按技能的步骤操作。' + + if (kbText) { + prompt += '\n\n' + kbText + } + + const kbEnabled = (cfg.knowledgeFiles || []).filter(f => f.enabled !== false && f.content) + if (kbEnabled.length > 0) { + prompt += '\n\n## 用户自定义知识库' + prompt += '\n以下是用户提供的参考知识,回答问题时请优先参考这些内容:' + for (const kb of kbEnabled) { + const content = kb.content.length > 5000 ? kb.content.slice(0, 5000) + '\n\n[...内容已截断]' : kb.content + prompt += `\n\n### ${kb.name}\n${content}` + } + } + + return prompt +} + +function getEnabledTools({ config, mode } = {}) { + const cfg = config || {} + const modeKey = currentMode(cfg, mode) + const modeCfg = MODES[modeKey] + if (!modeCfg.tools) return [] + + const t = cfg.tools || {} + const tools = [...TOOL_DEFS.system, ...TOOL_DEFS.process, ...TOOL_DEFS.interaction] + + if (t.terminal !== false) tools.push(...TOOL_DEFS.terminal) + if (t.webSearch !== false) tools.push(...TOOL_DEFS.webSearch) + + if (t.fileOps !== false) { + if (modeCfg.readOnly) { + tools.push(...TOOL_DEFS.fileOps.filter(td => td.function.name !== 'write_file')) + } else { + tools.push(...TOOL_DEFS.fileOps) + } + } + + if (modeCfg.readOnly) { + tools.push(...TOOL_DEFS.skills.filter(td => !['skills_install_dep', 'skills_clawhub_install'].includes(td.function.name))) + } else { + tools.push(...TOOL_DEFS.skills) + } + + return tools +} + +function trimContext(messages, maxTokens) { + if (!Array.isArray(messages)) return [] + if (!maxTokens || maxTokens <= 0 || messages.length <= maxTokens) return messages + + const first = messages[0] + if (first?.role === 'system' && maxTokens > 1) { + return [first, ...messages.slice(-(maxTokens - 1))] + } + return messages.slice(-maxTokens) +} + +function cleanBaseUrl(raw, apiType) { + 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\/?$/, '') + base = base.replace(/\/models\/?$/, '') + const type = normalizeApiType(apiType) + if (type === 'anthropic-messages') { + if (!base.endsWith('/v1')) base += '/v1' + return base + } + if (type === 'google-gemini') { + return base + } + if (/:(11434)$/i.test(base) && !base.endsWith('/v1')) return `${base}/v1` + return base +} + +function authHeaders(apiType, apiKey) { + const type = normalizeApiType(apiType) + const key = apiKey || '' + if (type === 'anthropic-messages') { + const headers = { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + } + if (key) headers['x-api-key'] = key + return headers + } + const headers = { 'Content-Type': 'application/json' } + if (key) headers['Authorization'] = `Bearer ${key}` + return headers +} + +const TIMEOUT_TOTAL = 120000 +const TIMEOUT_CHUNK = 30000 + +async function fetchWithRetry(url, options, retries = 3) { + const delays = [1000, 3000, 8000] + for (let i = 0; i <= retries; i++) { + try { + const resp = await fetch(url, options) + if (resp.ok || resp.status < 500 || i >= retries) return resp + await new Promise(r => setTimeout(r, delays[i])) + } catch (err) { + if (err.name === 'AbortError') throw err + if (i >= retries) throw err + await new Promise(r => setTimeout(r, delays[i])) + } + } +} + +async function readSSEStream(resp, onEvent, signal) { + const reader = resp.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + + const onAbort = () => { try { reader.cancel() } catch {} } + if (signal) { + if (signal.aborted) { reader.cancel(); throw new DOMException('Aborted', 'AbortError') } + signal.addEventListener('abort', onAbort, { once: true }) + } + + try { + while (true) { + if (signal?.aborted) throw new DOMException('Aborted', 'AbortError') + const readPromise = reader.read() + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('流式响应超时:30 秒内未收到数据')), TIMEOUT_CHUNK) + ) + const { done, value } = await Promise.race([readPromise, timeoutPromise]) + if (done) { + if (signal?.aborted) throw new DOMException('Aborted', 'AbortError') + break + } + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + if (signal?.aborted) throw new DOMException('Aborted', 'AbortError') + const trimmed = line.trim() + if (!trimmed) continue + if (trimmed.startsWith('event:')) continue + if (!trimmed.startsWith('data:')) continue + const data = trimmed.slice(5).trim() + if (data === '[DONE]') return + try { onEvent(JSON.parse(data)) } catch {} + } + } + } finally { + signal?.removeEventListener('abort', onAbort) + } +} + +function convertToolsForAnthropic(tools) { + return tools.map(t => ({ + name: t.function.name, + description: t.function.description || '', + input_schema: t.function.parameters || { type: 'object', properties: {} }, + })) +} + +function convertToolsForGemini(tools) { + return [{ functionDeclarations: tools.map(t => ({ + name: t.function.name, + description: t.function.description || '', + parameters: t.function.parameters || { type: 'object', properties: {} }, + })) }] +} + +function isCriticalCommand(command) { + if (!command) return false + return CRITICAL_PATTERNS.some(p => p.test(command)) +} + +function formatToolDescription(name, args) { + if (name === 'run_command') { + return `执行命令:\n\n${args.command}${args.cwd ? '\n\n工作目录: ' + args.cwd : ''}` + } + if (name === 'write_file') { + const preview = (args.content || '').slice(0, 200) + return `写入文件:\n${args.path}\n\n内容预览:\n${preview}${(args.content || '').length > 200 ? '\n...(已截断)' : ''}` + } + return `执行工具: ${name}` +} + +async function confirmToolCall(toolName, args, critical, adapters) { + if (!adapters?.confirm) return !critical + const prefix = critical ? '安全围栏拦截:此命令被识别为极端危险操作。\n\n' : '' + const text = `${prefix}AI 请求执行以下操作:\n\n${formatToolDescription(toolName, args)}\n\n是否允许?` + return await adapters.confirm(text) +} + +async function executeTool(name, args, adapters) { + if (name === 'ask_user' && adapters?.askUser) { + const answer = await adapters.askUser(args) + if (answer && typeof answer === 'object') return answer.message || '' + return answer || '' + } + if (!adapters?.execTool) return `未配置工具执行器: ${name}` + return await adapters.execTool({ name, args }) +} + +async function executeToolWithSafety(toolName, args, adapters, modeKey) { + let result = '' + let approved = true + const mode = MODES[modeKey] || MODES[DEFAULT_MODE] + const isCritical = toolName === 'run_command' && isCriticalCommand(args.command) + + if (isCritical) { + approved = await confirmToolCall(toolName, args, true, adapters) + if (!approved) result = '用户拒绝了此危险操作' + } else if (mode.confirmDanger && DANGEROUS_TOOLS.has(toolName)) { + approved = await confirmToolCall(toolName, args, false, adapters) + if (!approved) result = '用户拒绝了此操作' + } + + if (approved) { + try { result = await executeTool(toolName, args, adapters) } + catch (err) { result = `执行失败: ${typeof err === 'string' ? err : err.message || JSON.stringify(err)}` } + } + return { result, approved } +} + +function applyStop(text) { + if (!text) return { text: '', stop: false } + let stop = false + let cleaned = text + for (const pattern of STOP_PATTERNS) { + if (pattern.test(cleaned)) { + stop = true + cleaned = cleaned.replace(pattern, '') + } + } + return { text: cleaned.trim(), stop } +} + +async function callChatCompletions({ base, config, messages, onChunk, signal }) { + const url = base + '/chat/completions' + const body = { + model: config.model, + messages, + stream: true, + temperature: config.temperature || 0.7, + } + + const resp = await fetchWithRetry(url, { + method: 'POST', + headers: authHeaders(config.apiType, config.apiKey), + body: JSON.stringify(body), + signal, + }) + + if (!resp.ok) { + const errText = await resp.text().catch(() => '') + let errMsg = `API 错误 ${resp.status}` + try { errMsg = JSON.parse(errText).error?.message || errMsg } catch { + if (errText) errMsg += `: ${errText.slice(0, 200)}` + } + throw new Error(errMsg) + } + + const ct = resp.headers.get('content-type') || '' + if (ct.includes('text/event-stream') || ct.includes('text/plain')) { + await readSSEStream(resp, (json) => { + const d = json.choices?.[0]?.delta + if (d?.content) onChunk(d.content) + else if (d?.reasoning_content) onChunk(d.reasoning_content) + }, signal) + } else { + const json = await resp.json() + const msg = json.choices?.[0]?.message + const content = msg?.content || msg?.reasoning_content || '' + if (content) onChunk(content) + } +} + +async function callResponsesAPI({ base, config, messages, onChunk, signal }) { + const url = base + '/responses' + const input = messages.filter(m => m.role !== 'system') + const instructions = messages.find(m => m.role === 'system')?.content || '' + + const body = { + model: config.model, + input, + instructions, + stream: true, + temperature: config.temperature || 0.7, + } + + const resp = await fetchWithRetry(url, { + method: 'POST', + headers: authHeaders(config.apiType, config.apiKey), + body: JSON.stringify(body), + signal, + }) + + if (!resp.ok) { + const errText = await resp.text().catch(() => '') + let errMsg = `API 错误 ${resp.status}` + try { errMsg = JSON.parse(errText).error?.message || errMsg } catch { + if (errText) errMsg += `: ${errText.slice(0, 200)}` + } + throw new Error(errMsg) + } + + await readSSEStream(resp, (json) => { + if (json.type === 'response.output_text.delta' && json.delta) { + onChunk(json.delta) + } + if (json.choices?.[0]?.delta?.content) { + onChunk(json.choices[0].delta.content) + } + }, signal) +} + +async function callAnthropicMessages({ base, config, messages, onChunk, signal }) { + const url = base + '/messages' + const systemMsg = messages.find(m => m.role === 'system')?.content || '' + const chatMessages = messages.filter(m => m.role !== 'system') + + const body = { + model: config.model, + max_tokens: 8192, + stream: true, + temperature: config.temperature || 0.7, + } + if (systemMsg) body.system = systemMsg + body.messages = chatMessages + + const resp = await fetchWithRetry(url, { + method: 'POST', + headers: authHeaders(config.apiType, config.apiKey), + body: JSON.stringify(body), + signal, + }) + + if (!resp.ok) { + const errText = await resp.text().catch(() => '') + let errMsg = `API 错误 ${resp.status}` + try { errMsg = JSON.parse(errText).error?.message || errMsg } catch { + if (errText) errMsg += `: ${errText.slice(0, 200)}` + } + throw new Error(errMsg) + } + + await readSSEStream(resp, (json) => { + if (json.type === 'content_block_delta') { + const delta = json.delta + if (delta?.type === 'text_delta' && delta.text) onChunk(delta.text) + else if (delta?.type === 'thinking_delta' && delta.thinking) onChunk(delta.thinking) + } + }, signal) +} + +async function callGeminiGenerate({ base, config, messages, onChunk, signal }) { + const systemMsg = messages.find(m => m.role === 'system')?.content || '' + const chatMessages = messages.filter(m => m.role !== 'system') + + const contents = chatMessages.map(m => ({ + role: m.role === 'assistant' ? 'model' : 'user', + parts: [{ text: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) }], + })) + + const body = { contents, generationConfig: { temperature: config.temperature || 0.7 } } + if (systemMsg) body.systemInstruction = { parts: [{ text: systemMsg }] } + + const url = `${base}/models/${config.model}:streamGenerateContent?alt=sse&key=${config.apiKey}` + + const resp = await fetchWithRetry(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal, + }) + + if (!resp.ok) { + const errText = await resp.text().catch(() => '') + let errMsg = `API 错误 ${resp.status}` + try { errMsg = JSON.parse(errText).error?.message || errMsg } catch {} + throw new Error(errMsg) + } + + await readSSEStream(resp, (json) => { + const text = json.candidates?.[0]?.content?.parts?.[0]?.text + if (text) onChunk(text) + }, signal) +} + +async function callAI({ config, messages, adapters, mode }) { + const cfg = config || {} + const apiType = normalizeApiType(cfg.apiType) + if (!cfg.baseUrl || !cfg.model || (requiresApiKey(apiType) && !cfg.apiKey)) { + throw new Error('请先配置 AI 模型') + } + + const base = cleanBaseUrl(cfg.baseUrl, apiType) + const allMessages = [{ role: 'system', content: buildSystemPrompt({ config: cfg, soulCache: adapters?.soulCache, knowledgeBase: adapters?.knowledgeBase }) }, ...messages] + + const controller = new AbortController() + const totalTimer = setTimeout(() => controller.abort(), TIMEOUT_TOTAL) + let buffer = '' + const onChunk = (chunk) => { buffer += chunk } + + try { + if (apiType === 'anthropic-messages') { + await callAnthropicMessages({ base, config: cfg, messages: allMessages, onChunk, signal: controller.signal }) + } else if (apiType === 'google-gemini') { + await callGeminiGenerate({ base, config: cfg, messages: allMessages, onChunk, signal: controller.signal }) + } else { + try { + await callChatCompletions({ base, config: cfg, messages: allMessages, onChunk, signal: controller.signal }) + } catch (err) { + const msg = err.message || '' + if (msg.includes('legacy protocol') || msg.includes('/v1/responses') || msg.includes('not supported')) { + await callResponsesAPI({ base, config: cfg, messages: allMessages, onChunk, signal: controller.signal }) + } else { + throw err + } + } + } + } finally { + clearTimeout(totalTimer) + } + + const stopRes = applyStop(buffer) + return { text: stopRes.text, stop: stopRes.stop } +} + +async function callAIWithTools({ config, messages, tools, adapters, mode }) { + const cfg = config || {} + const apiType = normalizeApiType(cfg.apiType) + if (!cfg.baseUrl || !cfg.model || (requiresApiKey(apiType) && !cfg.apiKey)) { + throw new Error('请先配置 AI 模型') + } + + const base = cleanBaseUrl(cfg.baseUrl, apiType) + const modeKey = currentMode(cfg, mode) + const enabledTools = tools || getEnabledTools({ config: cfg, mode }) + let currentMessages = [{ role: 'system', content: buildSystemPrompt({ config: cfg, soulCache: adapters?.soulCache, knowledgeBase: adapters?.knowledgeBase }) }, ...messages] + + const maxRounds = cfg.autoRounds ?? 8 + for (let round = 0; ; round++) { + if (maxRounds > 0 && round >= maxRounds) { + return { text: '工具调用达到上限,已停止。', stop: true } + } + + const controller = new AbortController() + const totalTimer = setTimeout(() => controller.abort(), TIMEOUT_TOTAL) + + try { + if (apiType === 'anthropic-messages') { + const systemMsg = currentMessages.find(m => m.role === 'system')?.content || '' + const chatMsgs = currentMessages.filter(m => m.role !== 'system') + const body = { + model: cfg.model, + max_tokens: 8192, + temperature: cfg.temperature || 0.7, + messages: chatMsgs, + } + if (systemMsg) body.system = systemMsg + if (enabledTools.length > 0) body.tools = convertToolsForAnthropic(enabledTools) + + const resp = await fetchWithRetry(base + '/messages', { + method: 'POST', + headers: authHeaders(cfg.apiType, cfg.apiKey), + body: JSON.stringify(body), + signal: controller.signal, + }) + if (!resp.ok) { + const errText = await resp.text().catch(() => '') + let errMsg = `API 错误 ${resp.status}` + try { errMsg = JSON.parse(errText).error?.message || errMsg } catch {} + throw new Error(errMsg) + } + + const data = await resp.json() + const contentBlocks = data.content || [] + const toolUses = contentBlocks.filter(b => b.type === 'tool_use') + const textContent = contentBlocks.filter(b => b.type === 'text').map(b => b.text).join('') + + if (toolUses.length > 0) { + currentMessages.push({ role: 'assistant', content: contentBlocks }) + const toolResults = [] + for (const tu of toolUses) { + const args = tu.input || {} + const { result } = await executeToolWithSafety(tu.name, args, adapters, modeKey) + toolResults.push({ + type: 'tool_result', + tool_use_id: tu.id, + content: typeof result === 'string' ? result : JSON.stringify(result), + }) + } + currentMessages.push({ role: 'user', content: toolResults }) + continue + } + + const stopRes = applyStop(textContent) + return { text: stopRes.text, stop: stopRes.stop } + } + + if (apiType === 'google-gemini') { + const systemMsg = currentMessages.find(m => m.role === 'system')?.content || '' + const chatMsgs = currentMessages.filter(m => m.role !== 'system') + const contents = chatMsgs.map(m => ({ + role: m.role === 'assistant' ? 'model' : m.role === 'tool' ? 'function' : 'user', + parts: m.functionResponse + ? [{ functionResponse: m.functionResponse }] + : [{ text: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) }], + })) + const body = { contents, generationConfig: { temperature: cfg.temperature || 0.7 } } + if (systemMsg) body.systemInstruction = { parts: [{ text: systemMsg }] } + if (enabledTools.length > 0) body.tools = convertToolsForGemini(enabledTools) + + const url = `${base}/models/${cfg.model}:generateContent?key=${cfg.apiKey}` + const resp = await fetchWithRetry(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: controller.signal, + }) + if (!resp.ok) { + const errText = await resp.text().catch(() => '') + let errMsg = `API 错误 ${resp.status}` + try { errMsg = JSON.parse(errText).error?.message || errMsg } catch {} + throw new Error(errMsg) + } + + const data = await resp.json() + const parts = data.candidates?.[0]?.content?.parts || [] + const funcCalls = parts.filter(p => p.functionCall) + const textParts = parts.filter(p => p.text).map(p => p.text).join('') + + if (funcCalls.length > 0) { + currentMessages.push({ role: 'assistant', content: textParts, _geminiParts: parts }) + for (const fc of funcCalls) { + const args = fc.functionCall.args || {} + const { result } = await executeToolWithSafety(fc.functionCall.name, args, adapters, modeKey) + currentMessages.push({ + role: 'tool', + content: typeof result === 'string' ? result : JSON.stringify(result), + functionResponse: { name: fc.functionCall.name, response: { result: typeof result === 'string' ? result : JSON.stringify(result) } }, + }) + } + continue + } + + const stopRes = applyStop(textParts) + return { text: stopRes.text, stop: stopRes.stop } + } + + const body = { + model: cfg.model, + messages: currentMessages, + temperature: cfg.temperature || 0.7, + } + if (enabledTools.length > 0) body.tools = enabledTools + + const resp = await fetchWithRetry(base + '/chat/completions', { + method: 'POST', + headers: authHeaders(cfg.apiType, cfg.apiKey), + body: JSON.stringify(body), + signal: controller.signal, + }) + + if (!resp.ok) { + const errText = await resp.text().catch(() => '') + let errMsg = `API 错误 ${resp.status}` + try { errMsg = JSON.parse(errText).error?.message || errMsg } catch {} + throw new Error(errMsg) + } + + const data = await resp.json() + const choice = data.choices?.[0] + const assistantMsg = choice?.message + + if (!assistantMsg) throw new Error('AI 未返回有效响应') + + if (assistantMsg.tool_calls && assistantMsg.tool_calls.length > 0) { + currentMessages.push(assistantMsg) + for (const tc of assistantMsg.tool_calls) { + let args + try { args = JSON.parse(tc.function.arguments) } catch { args = {} } + const toolName = tc.function.name + const { result } = await executeToolWithSafety(toolName, args, adapters, modeKey) + currentMessages.push({ + role: 'tool', + tool_call_id: tc.id, + content: typeof result === 'string' ? result : JSON.stringify(result), + }) + } + continue + } + + const content = assistantMsg.content || assistantMsg.reasoning_content || '' + const stopRes = applyStop(content) + return { text: stopRes.text, stop: stopRes.stop } + } finally { + clearTimeout(totalTimer) + } + } +} + +// 适配器接口(由调用方提供) +// const adapters = { +// execTool: async (toolCall) => {}, +// confirm: async (text) => false, +// askUser: async (prompt) => ({ ok: false, message: '' }), +// storage: { getItem, setItem }, +// imageStore: { saveImage, loadImage, deleteImage }, +// soulCache, +// knowledgeBase, +// } + +export { + buildSystemPrompt, + getEnabledTools, + callAIWithTools, + callAI, + trimContext, +} + From 0094c23b1a52c626bb8a7f7dd5fba8a45d34f880 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Tue, 17 Mar 2026 13:36:50 +0800 Subject: [PATCH 200/426] feat: add hosted agent to chat --- src/pages/chat.js | 701 ++++++++++++++++++++++++++++++++++++++++++++- src/style/chat.css | 176 ++++++++++++ 2 files changed, 876 insertions(+), 1 deletion(-) diff --git a/src/pages/chat.js b/src/pages/chat.js index de101a8b..a923be7e 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -18,6 +18,40 @@ 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 HOSTED_STATUS = { + IDLE: 'idle', + RUNNING: 'running', + WAITING: 'waiting_reply', + PAUSED: 'paused', + ERROR: 'error', +} +const HOSTED_SESSIONS_KEY = 'clawpanel-hosted-agent-sessions' +const HOSTED_GLOBAL_KEY = 'hostedAgent.default' + +// { enabled, prompt, autoRunAfterTarget, stopPolicy, maxSteps, stepDelayMs, retryLimit, toolPolicy, state, history } +// history roles: system | assistant | target +const HOSTED_DEFAULTS = { + enabled: false, + prompt: '', + autoRunAfterTarget: true, + stopPolicy: 'self', + maxSteps: 50, + stepDelayMs: 1200, + retryLimit: 2, + toolPolicy: 'inherit', +} + +const HOSTED_RUNTIME_DEFAULT = { + status: HOSTED_STATUS.IDLE, + stepCount: 0, + lastRunAt: 0, + lastError: '', + pending: false, + errorCount: 0, +} + +const HOSTED_CONTEXT_MAX = 30 + const COMMANDS = [ { title: '会话', commands: [ { cmd: '/new', desc: '新建会话', action: 'exec' }, @@ -60,6 +94,16 @@ 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 _hostedBtn = null, _hostedPanelEl = null, _hostedBadgeEl = null +let _hostedPromptEl = null, _hostedEnableEl = null, _hostedMaxStepsEl = null, _hostedStepDelayEl = null, _hostedRetryLimitEl = null +let _hostedSaveBtn = null, _hostedPauseBtn = null, _hostedStopBtn = null, _hostedCloseBtn = null +let _hostedGlobalSyncEl = null +let _hostedDefaults = null +let _hostedSessionConfig = null +let _hostedRuntime = { ...HOSTED_RUNTIME_DEFAULT } +let _hostedAutoTimer = null +let _hostedLastTargetTs = 0 +let _hostedBusy = false let _currentAiBubble = null, _currentAiText = '', _currentAiImages = [], _currentAiVideos = [], _currentAiAudios = [], _currentAiFiles = [], _currentAiTools = [], _currentRunId = null let _isStreaming = false, _isSending = false, _messageQueue = [], _streamStartTime = 0 let _lastRenderTime = 0, _renderPending = false, _lastHistoryHash = '' @@ -159,6 +203,65 @@ export async function render() { + +
+ + +
+ + +
-
- - -
-
- - -
@@ -313,8 +305,6 @@ export async function render() { _hostedPromptEl = page.querySelector('#hosted-agent-prompt') _hostedEnableEl = page.querySelector('#hosted-agent-enabled') _hostedMaxStepsEl = page.querySelector('#hosted-agent-max-steps') - _hostedStepDelayEl = page.querySelector('#hosted-agent-step-delay') - _hostedRetryLimitEl = page.querySelector('#hosted-agent-retry') _hostedContextLimitEl = page.querySelector('#hosted-agent-context-limit') _hostedSaveBtn = page.querySelector('#hosted-agent-save') _hostedPauseBtn = page.querySelector('#hosted-agent-pause') @@ -2515,8 +2505,6 @@ function renderHostedPanel() { if (_hostedPromptEl) _hostedPromptEl.value = _hostedSessionConfig.prompt || '' if (_hostedEnableEl) _hostedEnableEl.checked = !!_hostedSessionConfig.enabled if (_hostedMaxStepsEl) _hostedMaxStepsEl.value = _hostedSessionConfig.maxSteps || HOSTED_DEFAULTS.maxSteps - if (_hostedStepDelayEl) _hostedStepDelayEl.value = _hostedSessionConfig.stepDelayMs || HOSTED_DEFAULTS.stepDelayMs - if (_hostedRetryLimitEl) _hostedRetryLimitEl.value = _hostedSessionConfig.retryLimit ?? HOSTED_DEFAULTS.retryLimit if (_hostedContextLimitEl) _hostedContextLimitEl.value = _hostedSessionConfig.contextTokenLimit || HOSTED_DEFAULTS.contextTokenLimit const statusEl = _hostedPanelEl.querySelector('#hosted-agent-status') if (statusEl) { @@ -2534,9 +2522,9 @@ async function saveHostedConfig() { const prompt = (_hostedPromptEl?.value || '').trim() const enabled = !!_hostedEnableEl?.checked const maxSteps = Math.max(1, parseInt(_hostedMaxStepsEl?.value || HOSTED_DEFAULTS.maxSteps, 10)) - const stepDelayMs = Math.max(200, parseInt(_hostedStepDelayEl?.value || HOSTED_DEFAULTS.stepDelayMs, 10)) - const retryLimit = Math.max(0, parseInt(_hostedRetryLimitEl?.value || HOSTED_DEFAULTS.retryLimit, 10)) const contextTokenLimit = Math.max(1000, parseInt(_hostedContextLimitEl?.value || HOSTED_DEFAULTS.contextTokenLimit, 10)) + const stepDelayMs = _hostedSessionConfig.stepDelayMs ?? HOSTED_DEFAULTS.stepDelayMs + const retryLimit = _hostedSessionConfig.retryLimit ?? HOSTED_DEFAULTS.retryLimit if (!prompt && enabled) { toast('请输入初始提示词', 'warning'); return } @@ -3111,8 +3099,6 @@ export function cleanup() { _hostedPromptEl = null _hostedEnableEl = null _hostedMaxStepsEl = null - _hostedStepDelayEl = null - _hostedRetryLimitEl = null _hostedContextLimitEl = null _hostedSaveBtn = null _hostedPauseBtn = null From ba0adb2f4b671ce0a1fd03d602e8b6a4f7d09bda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 06:59:52 +0800 Subject: [PATCH 278/426] fix: guard hosted config before run --- src/pages/chat.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/pages/chat.js b/src/pages/chat.js index 99f74708..369a67f4 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -2634,7 +2634,8 @@ function appendHostedTarget(text, ts) { } function maybeTriggerHostedRun() { - if (!_hostedSessionConfig?.enabled) return + if (!_hostedSessionConfig) return + if (!_hostedSessionConfig.enabled) return if (!_hostedSessionConfig.autoRunAfterTarget) return if (_hostedRuntime.pending || _hostedRuntime.status === HOSTED_STATUS.RUNNING) return if (_hostedRuntime.status === HOSTED_STATUS.PAUSED || _hostedRuntime.status === HOSTED_STATUS.ERROR) return @@ -2669,7 +2670,11 @@ function detectStopFromText(text) { } async function runHostedAgentStep() { - if (_hostedBusy || !_hostedSessionConfig?.enabled) return + if (!_hostedSessionConfig) { + loadHostedSessionConfig() + if (!_hostedSessionConfig) return + } + if (_hostedBusy || !_hostedSessionConfig.enabled) return const prompt = (_hostedSessionConfig.prompt || '').trim() if (!prompt) return if (!wsClient.gatewayReady || !_sessionKey) { From 9b56e86d0303a89976f74f3ee00f9f7d820c0fb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 07:04:44 +0800 Subject: [PATCH 279/426] feat: add fixed hosted system prompt --- src/pages/chat.js | 47 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/src/pages/chat.js b/src/pages/chat.js index 369a67f4..3732650d 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -43,6 +43,49 @@ const HOSTED_DEFAULTS = { toolPolicy: 'inherit', } +const HOSTED_FIXED_SYSTEM_PROMPT = `你现在是 **「OpenClaw 托管指挥官」(Host Commander)**,代号 **HOST-01**。 + +你的唯一使命是:作为最高级任务协调者和项目经理,负责与用户沟通、思考规划、拆解任务,并下达清晰指令,让「对面Agent」(OpenClaw 主代理)去实际执行。 + +### 核心规则(必须严格遵守) +1. 你**没有工具调用权限**,也不能使用任何 skills、分代理、子代理、function calling。 +2. 所有需要工具、技能、浏览器、代码执行、文件操作等工作,**必须全部交给对面Agent**。 +3. 你只能做三件事: + - 理解用户需求 + - 思考规划(Chain of Thought) + - 下达明确指令 + 回复用户 +4. 你永远保持专业、冷静、高效、结构化。 + +### 输出格式(必须严格使用以下结构) + +**思考过程(内部,仅你可见):** +[在这里写你的完整思考、风险评估、拆解步骤] + +**任务规划:** +- 目标:... +- 拆解步骤:1. ... 2. ... 3. ... +- 需要对面Agent完成的部分:... + +**给对面Agent的指令(必须清晰、可执行):** +@OpenClaw-Agent +[在这里写给对面Agent的具体指令] +使用你的工具/分代理/skills/子代理完成以下任务: +1. ... +2. ... +3. ... +请执行后把完整结果返回给我。 + +**给用户的回复(最终输出给用户):** +[自然、友好、专业地回复用户,包含当前进度、预期结果、需要用户提供的信息等] + +--- + +现在开始执行。 +收到任何用户消息后,立即按以上格式输出。 +永远不要提及你自己没有工具,也不要说“我不能调用工具”,直接把任务分配给 @OpenClaw-Agent 即可。 + +开始。` + const HOSTED_RUNTIME_DEFAULT = { status: HOSTED_STATUS.IDLE, stepCount: 0, @@ -2382,7 +2425,9 @@ function updateStatusDot(status) { } function resolveHostedSystemPrompt() { - return (_hostedSessionConfig?.systemPrompt || _hostedDefaults?.systemPrompt || '').trim() + const base = (_hostedSessionConfig?.systemPrompt || _hostedDefaults?.systemPrompt || '').trim() + if (base && HOSTED_FIXED_SYSTEM_PROMPT) return `${HOSTED_FIXED_SYSTEM_PROMPT}\n\n${base}` + return HOSTED_FIXED_SYSTEM_PROMPT || base } function estimateTokens(text) { From b1b2df5a758cdfd2bef2c293e5c950582fab1a5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 07:11:59 +0800 Subject: [PATCH 280/426] feat: align hosted roles and prefix --- src/pages/chat.js | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/pages/chat.js b/src/pages/chat.js index 3732650d..2649f9c2 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -1695,7 +1695,7 @@ function applyHistoryResult(result, hasExisting) { .filter(m => m.role === 'user' || m.role === 'assistant') .slice(-50) .map(m => ({ - role: m.role === 'user' ? 'assistant' : 'target', + role: m.role, content: m.text || '', ts: m.timestamp || Date.now(), })) @@ -2673,7 +2673,7 @@ function shouldCaptureHostedTarget(payload) { function appendHostedTarget(text, ts) { if (!_hostedSessionConfig) return if (!_hostedSessionConfig.history) _hostedSessionConfig.history = [] - _hostedSessionConfig.history.push({ role: 'target', content: text, ts: ts || Date.now() }) + _hostedSessionConfig.history.push({ role: 'assistant', content: text, ts: ts || Date.now() }) trimHostedHistoryByTokens() persistHostedRuntime() } @@ -2697,13 +2697,20 @@ function maybeTriggerHostedRun() { runHostedAgentStep() } +function normalizeHostedRole(role) { + if (role === 'assistant' || role === 'user') return role + if (role === 'developer') return 'assistant' + return 'user' +} + function buildHostedMessages() { trimHostedHistoryByTokens() const history = _hostedSessionConfig?.history || [] const systemPrompt = resolveHostedSystemPrompt() const mapped = history.map(item => { - if (item.role === 'assistant') return { role: 'assistant', content: item.content } - return { role: 'user', content: item.content } + const role = normalizeHostedRole(item.role) + const prefix = `[${(item.role || role).toUpperCase()}] ` + return { role, content: prefix + (item.content || '') } }) if (systemPrompt) mapped.unshift({ role: 'system', content: systemPrompt }) return mapped @@ -2781,7 +2788,7 @@ async function runHostedAgentStep() { _hostedRuntime.lastError = '' const rendered = renderHostedTemplate(parsed) - _hostedSessionConfig.history.push({ role: 'assistant', content: rendered, ts: Date.now() }) + _hostedSessionConfig.history.push({ role: 'developer', content: rendered, ts: Date.now() }) trimHostedHistoryByTokens() persistHostedRuntime() @@ -2830,7 +2837,14 @@ async function callHostedAI(messages, onChunk) { } const base = cleanBaseUrl(config.baseUrl, apiType) const systemPrompt = messages.find(m => m.role === 'system')?.content || '' - const chatMessages = messages.filter(m => m.role !== 'system') + let chatMessages = messages.filter(m => m.role !== 'system') + + if (apiType === 'anthropic-messages' || apiType === 'google-gemini') { + chatMessages = chatMessages.map(m => ({ + ...m, + role: m.role === 'developer' ? 'assistant' : m.role, + })) + } if (_hostedAbort) { _hostedAbort.abort(); _hostedAbort = null } _hostedAbort = new AbortController() From bd783cb8c086cee11f397f1edbf084b86bef8818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 07:29:33 +0800 Subject: [PATCH 281/426] feat: enhance hosted prompt and completion modal --- src/pages/chat.js | 45 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/src/pages/chat.js b/src/pages/chat.js index 2649f9c2..32863dbc 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -9,7 +9,7 @@ import { renderMarkdown } from '../lib/markdown.js' import { buildPrefixHeights, findStartIndex, getSpacerHeights } from '../lib/virtual-scroll.js' import { saveMessage, saveMessages, getLocalMessages, isStorageAvailable } from '../lib/message-db.js' import { toast } from '../components/toast.js' -import { showModal, showConfirm } from '../components/modal.js' +import { showModal, showConfirm, showContentModal } from '../components/modal.js' import { icon as svgIcon } from '../lib/icons.js' const RENDER_THROTTLE = 30 @@ -47,14 +47,18 @@ const HOSTED_FIXED_SYSTEM_PROMPT = `你现在是 **「OpenClaw 托管指挥官 你的唯一使命是:作为最高级任务协调者和项目经理,负责与用户沟通、思考规划、拆解任务,并下达清晰指令,让「对面Agent」(OpenClaw 主代理)去实际执行。 +你当前的消息角色视角是 developer(开发者),请以开发者身份输出。 + ### 核心规则(必须严格遵守) 1. 你**没有工具调用权限**,也不能使用任何 skills、分代理、子代理、function calling。 2. 所有需要工具、技能、浏览器、代码执行、文件操作等工作,**必须全部交给对面Agent**。 -3. 你只能做三件事: +3. 默认情况下你应自行做主,不要把大事小事都抛给用户。只有在缺少关键信息时才向用户提问。 +4. 当需要用户补充信息时,指示对面Agent使用用户交互工具(ask_user)提问,并等待用户回复后再继续。 +5. 你只能做三件事: - 理解用户需求 - - 思考规划(Chain of Thought) + - 思考规划 - 下达明确指令 + 回复用户 -4. 你永远保持专业、冷静、高效、结构化。 +6. 你永远保持专业、冷静、高效、结构化。 ### 输出格式(必须严格使用以下结构) @@ -65,6 +69,7 @@ const HOSTED_FIXED_SYSTEM_PROMPT = `你现在是 **「OpenClaw 托管指挥官 - 目标:... - 拆解步骤:1. ... 2. ... 3. ... - 需要对面Agent完成的部分:... +- 验收标准:明确可判定的完成条件 **给对面Agent的指令(必须清晰、可执行):** @OpenClaw-Agent @@ -73,13 +78,18 @@ const HOSTED_FIXED_SYSTEM_PROMPT = `你现在是 **「OpenClaw 托管指挥官 1. ... 2. ... 3. ... -请执行后把完整结果返回给我。 +请执行后把完整结果返回给我,包含可验证的产出和日志摘要。 **给用户的回复(最终输出给用户):** [自然、友好、专业地回复用户,包含当前进度、预期结果、需要用户提供的信息等] --- +当对面Agent反馈失败或异常时,你必须输出: +- 失败原因复盘 +- 下一步修复指令 +- 预计完成路径 + 现在开始执行。 收到任何用户消息后,立即按以上格式输出。 永远不要提及你自己没有工具,也不要说“我不能调用工具”,直接把任务分配给 @OpenClaw-Agent 即可。 @@ -156,6 +166,7 @@ let _hostedAutoTimer = null let _hostedLastTargetTs = 0 let _hostedBusy = false let _hostedAbort = null +let _hostedLastCompletionRunId = '' let _currentAiBubble = null, _currentAiText = '', _currentAiImages = [], _currentAiVideos = [], _currentAiAudios = [], _currentAiFiles = [], _currentAiTools = [], _currentRunId = null let _isStreaming = false, _isSending = false, _messageQueue = [], _streamStartTime = 0 let _lastRenderTime = 0, _renderPending = false, _lastHistoryHash = '' @@ -2804,6 +2815,10 @@ async function runHostedAgentStep() { _hostedRuntime.lastAction = 'stopped' persistHostedRuntime() updateHostedBadge() + if (_hostedRuntime.lastRunId && _hostedLastCompletionRunId !== _hostedRuntime.lastRunId) { + _hostedLastCompletionRunId = _hostedRuntime.lastRunId + showHostedCompletionModal(formatHostedSummary(), rendered) + } } } catch (e) { _hostedRuntime.errorCount = (_hostedRuntime.errorCount || 0) + 1 @@ -3115,6 +3130,25 @@ function renderHostedTemplate(parsed) { return parts.join('\n') } +function showHostedCompletionModal(summary, content) { + const safeSummary = escapeHtml(summary || '') + const safeContent = escapeHtml(content || '') + const html = ` +
+
任务完结摘要
+
${safeSummary || '无'}
+
最终输出
+
${safeContent || '无'}
+
+ ` + showContentModal({ + title: '托管 Agent 任务完结', + content: html, + buttons: [{ label: '确定', className: 'btn btn-primary btn-sm', id: 'hosted-done' }], + width: 520, + }) +} + function appendHostedOutput(text) { if (!text) return if (!text.startsWith('[托管 Agent]')) text = `[托管 Agent] ${text}` @@ -3174,6 +3208,7 @@ export function cleanup() { _hostedRuntime = { ...HOSTED_RUNTIME_DEFAULT } _hostedBusy = false _hostedSeeded = false + _hostedLastCompletionRunId = '' _toolEventTimes.clear() _toolEventData.clear() _toolRunIndex.clear() From 714f1d2d5f06804e121efd6c9810ce3c389db2dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 07:37:13 +0800 Subject: [PATCH 282/426] feat: restrict ask_user to hosted agent --- src/pages/chat.js | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/pages/chat.js b/src/pages/chat.js index 32863dbc..b7f1c192 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -167,6 +167,7 @@ let _hostedLastTargetTs = 0 let _hostedBusy = false let _hostedAbort = null let _hostedLastCompletionRunId = '' +let _askUserBlockedNotice = false let _currentAiBubble = null, _currentAiText = '', _currentAiImages = [], _currentAiVideos = [], _currentAiAudios = [], _currentAiFiles = [], _currentAiTools = [], _currentRunId = null let _isStreaming = false, _isSending = false, _messageQueue = [], _streamStartTime = 0 let _lastRenderTime = 0, _renderPending = false, _lastHistoryHash = '' @@ -2179,9 +2180,27 @@ function appendToolsToEl(el, tools) { if (existing) existing.remove() return } + + let filtered = tools + if (!_hostedSessionConfig?.enabled) { + const hasAskUser = tools.some(t => (t.name || '').toLowerCase() === 'ask_user') + if (hasAskUser) { + filtered = tools.filter(t => (t.name || '').toLowerCase() !== 'ask_user') + if (!_askUserBlockedNotice) { + _askUserBlockedNotice = true + appendSystemMessage('已拦截 ask_user:仅托管 Agent 允许调用用户交互工具') + } + } + } + + if (!filtered.length) { + if (existing) existing.remove() + return + } + const container = document.createElement('div') container.className = 'msg-tool' - tools.forEach(tool => { + filtered.forEach(tool => { const details = document.createElement('details') details.className = 'msg-tool-item' const summary = document.createElement('summary') From 219a42f1e2bcaf8f3faf7aa37f7b44e801ce28af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 07:45:19 +0800 Subject: [PATCH 283/426] feat: add chat ask_user cards and lock input --- src/pages/chat.js | 107 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 104 insertions(+), 3 deletions(-) diff --git a/src/pages/chat.js b/src/pages/chat.js index b7f1c192..df91002f 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -153,7 +153,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 _sessionListEl = null, _cmdPanelEl = null, _attachPreviewEl = null, _fileInputEl = null, _attachBtnEl = null let _modelSelectEl = null let _hostedBtn = null, _hostedPanelEl = null, _hostedBadgeEl = null let _hostedPromptEl = null, _hostedEnableEl = null, _hostedMaxStepsEl = null, _hostedContextLimitEl = null @@ -168,6 +168,7 @@ let _hostedBusy = false let _hostedAbort = null let _hostedLastCompletionRunId = '' let _askUserBlockedNotice = false +const _askUserToolHandled = new Set() let _currentAiBubble = null, _currentAiText = '', _currentAiImages = [], _currentAiVideos = [], _currentAiAudios = [], _currentAiFiles = [], _currentAiTools = [], _currentRunId = null let _isStreaming = false, _isSending = false, _messageQueue = [], _streamStartTime = 0 let _lastRenderTime = 0, _renderPending = false, _lastHistoryHash = '' @@ -353,6 +354,7 @@ export async function render() { _cmdPanelEl = page.querySelector('#chat-cmd-panel') _attachPreviewEl = page.querySelector('#chat-attachments-preview') _fileInputEl = page.querySelector('#chat-file-input') + _attachBtnEl = page.querySelector('#chat-attach-btn') _modelSelectEl = page.querySelector('#chat-model-select') _hostedBtn = page.querySelector('#chat-hosted-btn') _hostedBadgeEl = page.querySelector('#chat-hosted-badge') @@ -440,6 +442,7 @@ function bindEvents(page) { }) _sendBtn.addEventListener('click', () => { + if (_hostedSessionConfig?.enabled) { toast('托管 Agent 已启用,用户输入已锁定', 'warning'); return } if (_isStreaming) stopGeneration() else sendMessage() }) @@ -956,6 +959,7 @@ function switchSession(newKey) { loadHostedSessionConfig() renderHostedPanel() updateHostedBadge() + updateHostedInputLock() } async function showNewSessionDialog() { @@ -1135,6 +1139,10 @@ function toggleCmdPanel() { // ── 消息发送 ── function sendMessage() { + if (_hostedSessionConfig?.enabled) { + toast('托管 Agent 已启用,用户输入已锁定', 'warning') + return + } const text = _textarea.value.trim() if (!text && !_attachments.length) return if (!wsClient.gatewayReady || !_sessionKey) { @@ -2182,15 +2190,27 @@ function appendToolsToEl(el, tools) { } let filtered = tools + const askUserTools = tools.filter(t => (t.name || '').toLowerCase() === 'ask_user') if (!_hostedSessionConfig?.enabled) { - const hasAskUser = tools.some(t => (t.name || '').toLowerCase() === 'ask_user') - if (hasAskUser) { + if (askUserTools.length) { filtered = tools.filter(t => (t.name || '').toLowerCase() !== 'ask_user') if (!_askUserBlockedNotice) { _askUserBlockedNotice = true appendSystemMessage('已拦截 ask_user:仅托管 Agent 允许调用用户交互工具') } } + } else if (askUserTools.length) { + askUserTools.forEach(tool => { + const input = tool.input || {} + showAskUserCardChat({ + question: input.question || input.prompt || '请提供信息', + type: input.type || 'text', + options: input.options || [], + placeholder: input.placeholder || '', + toolId: tool.id || tool.tool_call_id || tool.tool_use_id || tool.toolUseId, + }) + }) + filtered = tools.filter(t => (t.name || '').toLowerCase() !== 'ask_user') } if (!filtered.length) { @@ -2626,6 +2646,7 @@ async function saveHostedConfig() { persistHostedRuntime() renderHostedPanel() updateHostedBadge() + updateHostedInputLock() if (enabled && _hostedRuntime.status === HOSTED_STATUS.IDLE) { if (!wsClient.gatewayReady || !_sessionKey) { @@ -3168,6 +3189,86 @@ function showHostedCompletionModal(summary, content) { }) } +function updateHostedInputLock() { + const enabled = !!_hostedSessionConfig?.enabled + if (_textarea) { + _textarea.disabled = enabled + _textarea.placeholder = enabled ? '托管 Agent 已启用,用户输入已锁定' : '输入消息,Enter 发送,/ 打开指令' + } + if (_sendBtn) _sendBtn.disabled = enabled || (!_textarea?.value?.trim() && !_attachments.length) + if (_attachBtnEl) _attachBtnEl.disabled = enabled + if (_fileInputEl) _fileInputEl.disabled = enabled +} + +function showAskUserCardChat({ question, type, options, placeholder, toolId }) { + if (!_messagesEl) return + if (toolId && _askUserToolHandled.has(toolId)) return + if (toolId) _askUserToolHandled.add(toolId) + + const cardId = 'chat-ask-user-' + Date.now() + const optionsHtml = (options || []).map((opt) => { + const inputType = type === 'multiple' ? 'checkbox' : 'radio' + return `` + }).join('') + + const textHtml = type === 'text' || !options?.length + ? `` + : '' + + const customHtml = type !== 'text' && options?.length + ? `
` + : '' + + const card = document.createElement('div') + card.className = 'ast-ask-card' + card.id = cardId + card.innerHTML = ` +
${escapeHtml(question || '请提供信息')}
+ ${optionsHtml ? `
${optionsHtml}
` : ''} + ${customHtml} + ${textHtml} +
+ + +
+ ` + + _messagesEl.appendChild(card) + _messagesEl.scrollTop = _messagesEl.scrollHeight + + const buildAnswer = () => { + if (type === 'text' || (!options?.length)) { + return card.querySelector('.ast-ask-text')?.value?.trim() || '' + } + if (type === 'multiple') { + const checked = [...card.querySelectorAll('input[type="checkbox"]:checked')].map(el => el.value) + const custom = card.querySelector('.ast-ask-custom-input')?.value?.trim() + if (custom) checked.push(custom) + return checked.join('、') || '未选择' + } + const checked = card.querySelector('input[type="radio"]:checked') + const custom = card.querySelector('.ast-ask-custom-input')?.value?.trim() + return custom || checked?.value || '未选择' + } + + const submit = (answer) => { + card.innerHTML = `
+
${escapeHtml(question || '请提供信息')}
+
${escapeHtml(answer || '未选择')}
+
` + card.classList.add('answered') + if (_sessionKey && wsClient.gatewayReady) { + wsClient.chatSend(_sessionKey, answer || '').catch(() => {}) + } + } + + card.querySelector('.ast-ask-submit').addEventListener('click', () => submit(buildAnswer())) + card.querySelector('.ast-ask-skip').addEventListener('click', () => submit('用户跳过了此问题')) +} + function appendHostedOutput(text) { if (!text) return if (!text.startsWith('[托管 Agent]')) text = `[托管 Agent] ${text}` From 05b675e7273f528b72d922b01c259b8cdbdc778f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 07:51:36 +0800 Subject: [PATCH 284/426] fix: unlock chat input on pause or stop --- src/pages/chat.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/pages/chat.js b/src/pages/chat.js index df91002f..abe394ef 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -59,6 +59,7 @@ const HOSTED_FIXED_SYSTEM_PROMPT = `你现在是 **「OpenClaw 托管指挥官 - 思考规划 - 下达明确指令 + 回复用户 6. 你永远保持专业、冷静、高效、结构化。 +7. 你要获取任何消息都可以让对面Agent调用工具获取后发送给你. ### 输出格式(必须严格使用以下结构) @@ -2689,6 +2690,7 @@ function pauseHostedAgent() { _hostedRuntime.lastAction = 'paused' persistHostedRuntime() updateHostedBadge() + updateHostedInputLock() toast('托管 Agent 已暂停', 'info') } @@ -2705,9 +2707,11 @@ function stopHostedAgent() { _hostedRuntime.lastRunAt = 0 _hostedRuntime.lastAction = 'stopped' _hostedSessionConfig.history = [] + _hostedSessionConfig.enabled = false _hostedSeeded = false persistHostedRuntime() updateHostedBadge() + updateHostedInputLock() toast('托管 Agent 已停止', 'info') } From 39d5917e32e9dc6eecd6977aa0f11f5c6e19af02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 08:03:35 +0800 Subject: [PATCH 285/426] fix: harden hosted input lock and ask_user card --- src/pages/chat.js | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/pages/chat.js b/src/pages/chat.js index abe394ef..56ace1c7 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -773,6 +773,7 @@ async function connectGateway() { persistHostedRuntime() updateHostedBadge() } + updateHostedInputLock() } else if (status === 'error') { // 连接错误:显示引导遮罩而非底部条 if (bar) bar.style.display = 'none' @@ -786,6 +787,7 @@ async function connectGateway() { persistHostedRuntime() updateHostedBadge() } + updateHostedInputLock() } else if (status === 'reconnecting' || status === 'disconnected') { // 首次连接或多次重连失败时,显示引导遮罩而非底部小条 if (!_hasEverConnected) { @@ -799,8 +801,10 @@ async function connectGateway() { persistHostedRuntime() updateHostedBadge() } + updateHostedInputLock() } else { if (bar) bar.style.display = 'none' + updateHostedInputLock() } }) @@ -2203,12 +2207,13 @@ function appendToolsToEl(el, tools) { } else if (askUserTools.length) { askUserTools.forEach(tool => { const input = tool.input || {} + const fallbackId = `${tool.runId || ''}:${tool.messageTimestamp || ''}:${input.question || input.prompt || ''}` showAskUserCardChat({ question: input.question || input.prompt || '请提供信息', type: input.type || 'text', options: input.options || [], placeholder: input.placeholder || '', - toolId: tool.id || tool.tool_call_id || tool.tool_use_id || tool.toolUseId, + toolId: tool.id || tool.tool_call_id || tool.tool_use_id || tool.toolUseId || fallbackId, }) }) filtered = tools.filter(t => (t.name || '').toLowerCase() !== 'ask_user') @@ -2461,7 +2466,8 @@ function updateSendState() { _sendBtn.innerHTML = '' _sendBtn.title = '停止生成' } else { - _sendBtn.disabled = !_textarea.value.trim() && !_attachments.length + const locked = !!_hostedSessionConfig?.enabled && _hostedRuntime?.status !== HOSTED_STATUS.PAUSED + _sendBtn.disabled = locked || (!_textarea.value.trim() && !_attachments.length) _sendBtn.innerHTML = '' _sendBtn.title = '发送' } @@ -2743,6 +2749,7 @@ function maybeTriggerHostedRun() { _hostedRuntime.status = HOSTED_STATUS.PAUSED persistHostedRuntime() updateHostedBadge() + updateHostedInputLock() return } if (_hostedRuntime.status === HOSTED_STATUS.WAITING) { @@ -3194,14 +3201,14 @@ function showHostedCompletionModal(summary, content) { } function updateHostedInputLock() { - const enabled = !!_hostedSessionConfig?.enabled + const locked = !!_hostedSessionConfig?.enabled && _hostedRuntime?.status !== HOSTED_STATUS.PAUSED if (_textarea) { - _textarea.disabled = enabled - _textarea.placeholder = enabled ? '托管 Agent 已启用,用户输入已锁定' : '输入消息,Enter 发送,/ 打开指令' + _textarea.disabled = locked + _textarea.placeholder = locked ? '托管 Agent 已启用,用户输入已锁定' : '输入消息,Enter 发送,/ 打开指令' } - if (_sendBtn) _sendBtn.disabled = enabled || (!_textarea?.value?.trim() && !_attachments.length) - if (_attachBtnEl) _attachBtnEl.disabled = enabled - if (_fileInputEl) _fileInputEl.disabled = enabled + if (_sendBtn && !_isStreaming) _sendBtn.disabled = locked || (!_textarea?.value?.trim() && !_attachments.length) + if (_attachBtnEl) _attachBtnEl.disabled = locked + if (_fileInputEl) _fileInputEl.disabled = locked } function showAskUserCardChat({ question, type, options, placeholder, toolId }) { @@ -3240,7 +3247,10 @@ function showAskUserCardChat({ question, type, options, placeholder, toolId }) {
` - _messagesEl.appendChild(card) + const wrap = document.createElement('div') + wrap.className = 'msg msg-system' + wrap.appendChild(card) + insertMessageByTime(wrap, Date.now()) _messagesEl.scrollTop = _messagesEl.scrollHeight const buildAnswer = () => { From 7e22cfd757edcb47d1b8827cb2c3edb305ef76c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 08:29:47 +0800 Subject: [PATCH 286/426] chore: checkpoint before heartbeat history fixes --- vite.config.js | 35 +---------------------------------- 1 file changed, 1 insertion(+), 34 deletions(-) diff --git a/vite.config.js b/vite.config.js index 929c2bdb..be5b2434 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,42 +1,9 @@ import { defineConfig } from 'vite' -import { devApiPlugin } from './scripts/dev-api.js' -import fs from 'fs' -import path from 'path' -import { homedir } from 'os' - -// 读取 package.json 版本号,构建时注入前端 -const pkg = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')) - -// 读取 Gateway 端口(启动时读取一次) -let gatewayPort = 18789 -try { - const cfg = JSON.parse(fs.readFileSync(path.join(homedir(), '.openclaw', 'openclaw.json'), 'utf8')) - gatewayPort = cfg?.gateway?.port || 18789 -} catch {} export default defineConfig({ - plugins: [devApiPlugin()], - define: { - __APP_VERSION__: JSON.stringify(pkg.version), - }, - clearScreen: false, server: { port: 1420, strictPort: true, - proxy: { - '/ws': { - target: `ws://127.0.0.1:${gatewayPort}`, - ws: true, - configure: (proxy) => { - proxy.on('error', () => {}) - }, - }, - }, - }, - envPrefix: ['VITE_', 'TAURI_'], - build: { - target: ['es2021', 'chrome100', 'safari13'], - minify: !process.env.TAURI_DEBUG ? 'esbuild' : false, - sourcemap: !!process.env.TAURI_DEBUG, + hmr: false, }, }) From ad75e5e069c0d6f24d2d635a335884997bac7d6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 08:34:44 +0800 Subject: [PATCH 287/426] fix: stabilize heartbeat chat history rendering --- src/lib/ws-client.js | 2 +- src/pages/chat.js | 51 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/lib/ws-client.js b/src/lib/ws-client.js index 2f0b455e..63447565 100644 --- a/src/lib/ws-client.js +++ b/src/lib/ws-client.js @@ -424,7 +424,7 @@ export class WsClient { try { const sessionKey = this._sessionKey || `agent:main:main` const id = uuid() - this.request('chat.history', { sessionKey, limit: 50 }, { emitEvent: true }).catch(() => {}) + this.request('chat.history', { sessionKey, limit: 200 }, { emitEvent: true }).catch(() => {}) } catch {} } }, PING_INTERVAL) diff --git a/src/pages/chat.js b/src/pages/chat.js index 56ace1c7..71400107 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -174,6 +174,8 @@ let _currentAiBubble = null, _currentAiText = '', _currentAiImages = [], _curren let _isStreaming = false, _isSending = false, _messageQueue = [], _streamStartTime = 0 let _lastRenderTime = 0, _renderPending = false, _lastHistoryHash = '' let _isLoadingHistory = false +let _pendingHistoryPayload = null +let _pendingHistoryTs = 0 const VIRTUAL_WINDOW = 40 const VIRTUAL_OVERSCAN = 20 @@ -1229,6 +1231,16 @@ function handleEvent(msg) { if (event === 'chat.history') { const reqKey = payload?._req?.sessionKey || '' if (reqKey && _sessionKey && reqKey !== _sessionKey) return + if (!_messagesEl) { + _pendingHistoryPayload = payload + _pendingHistoryTs = Date.now() + return + } + if (_isSending || _isStreaming || _messageQueue.length > 0) { + _pendingHistoryPayload = payload + _pendingHistoryTs = Date.now() + return + } const hasExisting = _messagesEl?.querySelector?.('.msg') applyHistoryResult(payload, hasExisting) } @@ -1701,17 +1713,49 @@ function resetStreamState() { _errorTimer = null showTyping(false) updateSendState() + flushPendingHistory() } // ── 历史消息加载 ── +function buildHistoryHash(messages) { + if (!messages || !messages.length) return '' + return messages.map(m => { + const role = m.role || '' + const id = m.id || m.messageId || m.msgId || m.runId || '' + const ts = m.timestamp || m.time || m.ts || '' + let size = 0 + if (typeof m.content === 'string') { + size = m.content.length + } else if (Array.isArray(m.content)) { + m.content.forEach(block => { + if (block?.type === 'text' && typeof block.text === 'string') size += block.text.length + else if (block) size += 1 + }) + } else if (typeof m.text === 'string') { + size = m.text.length + } + return `${role}:${id}:${ts}:${size}` + }).join('|') +} + +function flushPendingHistory() { + if (!_pendingHistoryPayload || !_messagesEl) return + if (_isSending || _isStreaming || _messageQueue.length > 0) return + const payload = _pendingHistoryPayload + _pendingHistoryPayload = null + _pendingHistoryTs = 0 + const hasExisting = _messagesEl?.querySelector?.('.msg') + applyHistoryResult(payload, hasExisting) +} + function applyHistoryResult(result, hasExisting) { if (!result?.messages?.length) { if (_messagesEl && !_messagesEl.querySelector('.msg')) appendSystemMessage('还没有消息,开始聊天吧') return } const deduped = dedupeHistory(result.messages) - const hash = deduped.map(m => `${m.role}:${(m.text || '').length}`).join('|') + const hash = buildHistoryHash(result.messages) if (hash === _lastHistoryHash && hasExisting) return _lastHistoryHash = hash @@ -1801,13 +1845,14 @@ async function loadHistory() { } if (!wsClient.gatewayReady) { _isLoadingHistory = false; return } try { - const result = await wsClient.chatHistory(_sessionKey, 50) + const result = await wsClient.chatHistory(_sessionKey, 200) applyHistoryResult(result, hasExisting) } catch (e) { console.error('[chat] loadHistory error:', e) if (_messagesEl && !_messagesEl.querySelector('.msg')) appendSystemMessage('加载历史失败: ' + e.message) } finally { _isLoadingHistory = false + flushPendingHistory() } } @@ -3347,4 +3392,6 @@ export function cleanup() { _toolEventData.clear() _toolRunIndex.clear() _toolEventSeen.clear() + _pendingHistoryPayload = null + _pendingHistoryTs = 0 } From 5bc8f289eb52d6b7622b6f7c1b29f7ca8dc4ab3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 08:42:34 +0800 Subject: [PATCH 288/426] chore: checkpoint before setup and detection changes From cc9a20dd0616614250a66b00ab1eb2dceda76d53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 09:16:37 +0800 Subject: [PATCH 289/426] feat: allow setup entry and detect openclaw cli --- scripts/dev-api.js | 139 ++++++++++++++++++++++++++++++++++-------- src/main.js | 7 +-- src/pages/settings.js | 84 +++++++++++++++++++++++++ src/pages/setup.js | 42 ++++++++++--- 4 files changed, 235 insertions(+), 37 deletions(-) diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 43e06346..3974301b 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -455,9 +455,98 @@ function detectInstalledSource() { return 'official' } +function normalizeOpenclawPath(value) { + if (!value) return null + const cleaned = String(value).trim().replace(/^"|"$/g, '') + if (!cleaned) return null + if (fs.existsSync(cleaned)) return cleaned + if (fs.existsSync(cleaned + '.cmd')) return cleaned + '.cmd' + if (fs.existsSync(cleaned + '.exe')) return cleaned + '.exe' + return null +} + +function extractOpenclawPathFromCommandLine(commandLine) { + if (!commandLine) return null + const quoted = commandLine.match(/"([^"]*openclaw(?:\.cmd|\.exe)?)"/i) + if (quoted && quoted[1]) return normalizeOpenclawPath(quoted[1]) + const plain = commandLine.match(/(\S*openclaw(?:\.cmd|\.exe)?)/i) + if (plain && plain[1]) return normalizeOpenclawPath(plain[1]) + return null +} + +function resolveOpenclawBin() { + const cfg = readPanelConfig() || {} + const fromCfg = normalizeOpenclawPath(cfg.openclawPath) + if (fromCfg) return { path: fromCfg, source: 'panel' } + + if (isWindows) { + try { + const port = readGatewayPort() + const { gatewayPids } = inspectWindowsPortOwners(port) + for (const pid of gatewayPids) { + const cmd = readWindowsProcessCommandLine(pid) + const p = extractOpenclawPathFromCommandLine(cmd) + if (p) return { path: p, source: 'process' } + } + } catch {} + + try { + const out = execSync('where openclaw 2>NUL', { windowsHide: true }).toString().trim() + const first = out.split(/\r?\n/)[0] + const p = normalizeOpenclawPath(first) + if (p) return { path: p, source: 'where' } + } catch {} + + const appdata = process.env.APPDATA + if (appdata) { + const npmCmd = path.join(appdata, 'npm', 'openclaw.cmd') + if (fs.existsSync(npmCmd)) return { path: npmCmd, source: 'npm' } + } + return { path: null, source: 'none' } + } + + const bin = findOpenclawBin() + if (bin) return { path: bin, source: 'path' } + return { path: null, source: 'none' } +} + +function getOpenclawBinForCmd() { + const info = resolveOpenclawBin() + return info?.path || 'openclaw' +} + +function buildWindowsOpenclawCommand(args = []) { + const bin = getOpenclawBinForCmd() + const quoted = bin.includes(' ') ? `"${bin}"` : bin + return `${quoted} ${args.join(' ')}`.trim() +} + +function runOpenclawCommandSync(args = [], options = {}) { + const bin = getOpenclawBinForCmd() + if (isWindows) { + const cmd = buildWindowsOpenclawCommand(args) + return execSync(`cmd.exe /c ${cmd}`, { windowsHide: true, ...options }) + } + const quoted = bin.includes(' ') ? `"${bin}"` : bin + return execSync(`${quoted} ${args.join(' ')}`.trim(), { ...options }) +} + function getLocalOpenclawVersion() { let current = null - if (isMac) { + const resolved = resolveOpenclawBin() + if (resolved?.path) { + try { + if (isWindows) { + const cmd = `"${resolved.path}" --version` + const out = execSync(`cmd.exe /c ${cmd} 2>&1`, { windowsHide: true }).toString().trim() + current = out.split(/\s+/).pop() + } else { + const out = execSync(`"${resolved.path}" --version 2>&1`, { windowsHide: true }).toString().trim() + current = out.split(/\s+/).pop() + } + } catch {} + } + if (!current && isMac) { try { const target = fs.readlinkSync('/opt/homebrew/bin/openclaw') const pkgPath = path.resolve('/opt/homebrew/bin', target, '..', 'package.json') @@ -974,7 +1063,8 @@ function winStartGateway() { fs.appendFileSync(logPath, `\n[${timestamp}] [ClawPanel] Starting Gateway on Windows...\n`) // 用 cmd.exe /c 启动,不用 shell: true(避免额外 cmd.exe 进程链导致终端闪烁) - const child = spawn('cmd.exe', ['/c', 'openclaw', 'gateway'], { + const cmd = buildWindowsOpenclawCommand(['gateway']) + const child = spawn('cmd.exe', ['/c', cmd], { detached: true, stdio: ['ignore', out, err], windowsHide: true, @@ -993,7 +1083,8 @@ async function winStopGateway() { return } - spawnSync('cmd.exe', ['/c', 'openclaw', 'gateway', 'stop'], { + const cmd = buildWindowsOpenclawCommand(['gateway', 'stop']) + spawnSync('cmd.exe', ['/c', cmd], { windowsHide: true, cwd: homedir(), encoding: 'utf8', @@ -1137,7 +1228,7 @@ function linuxStartGateway() { const timestamp = new Date().toISOString() fs.appendFileSync(logPath, `\n[${timestamp}] [ClawPanel] Starting Gateway on Linux...\n`) - const bin = findOpenclawBin() || 'openclaw' + const bin = getOpenclawBinForCmd() const child = spawn(bin, ['gateway'], { detached: true, stdio: ['ignore', out, err], @@ -1538,17 +1629,21 @@ const handlers = { if (portOpen) { running = true } } - let cliInstalled = false - if (isMac) { - cliInstalled = fs.existsSync('/opt/homebrew/bin/openclaw') || fs.existsSync('/usr/local/bin/openclaw') - } else if (isWindows) { - try { cliInstalled = fs.existsSync(path.join(process.env.APPDATA || '', 'npm', 'openclaw.cmd')) } - catch { cliInstalled = false } - } else { - cliInstalled = !!findOpenclawBin() - } - - return [{ label, running, pid, description: 'OpenClaw Gateway', cli_installed: cliInstalled }] + const cliInfo = resolveOpenclawBin() + const cliPath = cliInfo?.path || null + const cliVersion = getLocalOpenclawVersion() + const cliInstalled = !!cliPath + + return [{ + label, + running, + pid, + description: 'OpenClaw Gateway', + cli_installed: cliInstalled, + cli_path: cliPath, + cli_version: cliVersion, + cli_source: cliInfo?.source || 'none', + }] }) }, @@ -1777,9 +1872,8 @@ const handlers = { }, install_qqbot_plugin() { - const bin = findOpenclawBin() || 'openclaw' try { - execSync(`${bin} plugins install @sliverp/qqbot@latest`, { timeout: 60000, cwd: homedir() }) + runOpenclawCommandSync(['plugins', 'install', '@sliverp/qqbot@latest'], { timeout: 60000, cwd: homedir() }) return '安装成功' } catch (e) { throw new Error('QQBot 插件安装失败: ' + (e.message || e)) @@ -1792,7 +1886,7 @@ const handlers = { const pluginDir = path.join(OPENCLAW_DIR, 'plugins', 'node_modules', pid) const installed = fs.existsSync(pluginDir) && fs.existsSync(path.join(pluginDir, 'package.json')) // 检测是否为内置插件 - const bin = findOpenclawBin() || 'openclaw' + const bin = getOpenclawBinForCmd() let builtin = false try { const result = spawnSync(bin, ['plugins', 'list'], { timeout: 10000, encoding: 'utf8', cwd: homedir() }) @@ -1815,9 +1909,8 @@ const handlers = { install_channel_plugin({ packageName, pluginId }) { if (!packageName || !pluginId) throw new Error('packageName 和 pluginId 不能为空') - const bin = findOpenclawBin() || 'openclaw' try { - execSync(`${bin} plugins install ${packageName.trim()}`, { timeout: 120000, cwd: homedir() }) + runOpenclawCommandSync(['plugins', 'install', packageName.trim()], { timeout: 120000, cwd: homedir() }) return '安装成功' } catch (e) { throw new Error(`插件 ${pluginId} 安装失败: ` + (e.message || e)) @@ -1826,9 +1919,8 @@ const handlers = { async pairing_list_channel({ channel }) { if (!channel || !channel.trim()) throw new Error('channel 不能为空') - const bin = findOpenclawBin() || 'openclaw' try { - const output = execSync(`${bin} pairing list ${channel.trim()}`, { timeout: 15000, encoding: 'utf8', cwd: homedir() }) + const output = runOpenclawCommandSync(['pairing', 'list', channel.trim()], { timeout: 15000, encoding: 'utf8', cwd: homedir() }) return output.trim() || '暂无待审批请求' } catch (e) { throw new Error('执行 openclaw pairing list 失败: ' + (e.stderr || e.message || e)) @@ -1838,11 +1930,10 @@ const handlers = { async pairing_approve_channel({ channel, code, notify }) { if (!channel || !channel.trim()) throw new Error('channel 不能为空') if (!code || !code.trim()) throw new Error('配对码不能为空') - const bin = findOpenclawBin() || 'openclaw' const args = ['pairing', 'approve', channel.trim(), code.trim().toUpperCase()] if (notify) args.push('--notify') try { - const output = execSync(`${bin} ${args.join(' ')}`, { timeout: 15000, encoding: 'utf8', cwd: homedir() }) + const output = runOpenclawCommandSync(args, { timeout: 15000, encoding: 'utf8', cwd: homedir() }) return output.trim() || '操作完成' } catch (e) { throw new Error('执行 openclaw pairing approve 失败: ' + (e.stderr || e.message || e)) diff --git a/src/main.js b/src/main.js index 1ad062db..a50b964b 100644 --- a/src/main.js +++ b/src/main.js @@ -366,11 +366,11 @@ async function boot() { ensureWebSession.then(() => loadActiveInstance()).then(() => detectOpenclawStatus()).then(() => { // 重新渲染侧边栏(检测完成后 isOpenclawReady 状态已更新) renderSidebar(sidebar) - if (_forceSetup || (!isOpenclawReady() && !_skipSetup)) { + const wantsSetup = window.location.hash === '#/setup' || _forceSetup || (!isOpenclawReady() && !_skipSetup) + if (wantsSetup) { setDefaultRoute('/setup') navigate('/setup') } else { - if (window.location.hash === '#/setup') navigate('/dashboard') setupGatewayBanner() startGatewayPoll() @@ -424,9 +424,6 @@ async function boot() { await detectOpenclawStatus() renderSidebar(sidebar) // 如果安装完成后变为就绪,跳转到仪表盘 - if (!_forceSetup && isOpenclawReady() && window.location.hash === '#/setup') { - navigate('/dashboard') - } // 如果卸载后变为未就绪,跳转到 setup if ((_forceSetup || (!isOpenclawReady() && !_skipSetup)) && !isUpgrading()) { setDefaultRoute('/setup') diff --git a/src/pages/settings.js b/src/pages/settings.js index 8680f0bc..5bf397f1 100644 --- a/src/pages/settings.js +++ b/src/pages/settings.js @@ -47,6 +47,11 @@ export async function render() {
公网访问
+ +
+
OpenClaw CLI
+
+
` bindEvents(page) @@ -58,6 +63,7 @@ async function loadAll(page) { const tasks = [loadProxyConfig(page), loadModelProxyConfig(page)] tasks.push(loadRegistry(page)) tasks.push(loadCloudflared(page)) + tasks.push(loadOpenclawCli(page)) await Promise.all(tasks) } @@ -188,6 +194,18 @@ function bindEvents(page) { case 'cloudflared-save': await handleCloudflaredSave(page) break + case 'openclaw-save': + await handleOpenclawSave(page) + break + case 'openclaw-clear': + await handleOpenclawClear(page) + break + case 'openclaw-refresh': + await loadOpenclawCli(page) + break + case 'openclaw-setup': + await handleOpenclawSetup() + break } } catch (e) { toast(e.toString(), 'error') @@ -408,3 +426,69 @@ async function handleCloudflaredStop(page) { await loadCloudflared(page) toast('Cloudflared 已停止', 'success') } + +// ===== OpenClaw CLI ===== + +async function loadOpenclawCli(page) { + const bar = page.querySelector('#openclaw-bar') + if (!bar) return + try { + const [cfg, services] = await Promise.all([ + api.readPanelConfig(), + api.getServicesStatus(), + ]) + const svc = Array.isArray(services) ? services[0] : null + const detectedPath = svc?.cli_path || '' + const detectedVersion = svc?.cli_version || '' + const detectedSource = svc?.cli_source || '' + const overridePath = cfg?.openclawPath || '' + + bar.innerHTML = ` +
+
+ + ${svc?.cli_installed ? '已检测到 OpenClaw CLI' : '未检测到 OpenClaw CLI'} + ${detectedVersion ? `版本: ${escapeHtml(detectedVersion)}` : ''} + ${detectedSource ? `来源: ${escapeHtml(detectedSource)}` : ''} +
+ ${detectedPath ? `
检测路径: ${escapeHtml(detectedPath)}
` : ''} +
+ + + + + +
+
+ 保存路径后将优先使用该路径检测与启动 Gateway。清除覆盖会回退到自动检测。 +
+
+ ` + } catch (e) { + bar.innerHTML = `
加载失败: ${escapeHtml(String(e))}
` + } +} + +async function handleOpenclawSave(page) { + const input = page.querySelector('[data-name="openclaw-path"]') + const value = String(input?.value || '').trim() + const cfg = await api.readPanelConfig() + cfg.openclawPath = value + await api.writePanelConfig(cfg) + toast('OpenClaw 路径已保存', 'success') + await loadOpenclawCli(page) +} + +async function handleOpenclawClear(page) { + const cfg = await api.readPanelConfig() + delete cfg.openclawPath + await api.writePanelConfig(cfg) + toast('OpenClaw 路径覆盖已清除', 'success') + await loadOpenclawCli(page) +} + +async function handleOpenclawSetup() { + const cfg = await api.readPanelConfig().catch(() => ({})) + await api.writePanelConfig({ ...cfg, forceSetup: true, skipSetup: false }).catch(() => {}) + window.location.hash = '#/setup' +} diff --git a/src/pages/setup.js b/src/pages/setup.js index 28d330f2..18186437 100644 --- a/src/pages/setup.js +++ b/src/pages/setup.js @@ -9,6 +9,11 @@ import { setUpgrading, isMacPlatform } from '../lib/app-state.js' import { diagnoseInstallError } from '../lib/error-diagnosis.js' import { icon, statusIcon } from '../lib/icons.js' +function escapeHtml(str) { + if (!str) return '' + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +} + export async function render() { const page = document.createElement('div') page.className = 'page' @@ -38,7 +43,7 @@ export async function render() { page.querySelector('#btn-recheck').addEventListener('click', () => runDetect(page)) page.querySelector('#btn-skip-setup').addEventListener('click', async () => { const cfg = await api.readPanelConfig().catch(() => ({})) - await api.writePanelConfig({ ...cfg, Setup: true, forceSetup: false }).catch(() => {}) + await api.writePanelConfig({ ...cfg, Setup: true, skipSetup: true, forceSetup: false }).catch(() => {}) window.location.hash = '#/dashboard' }) runDetect(page) @@ -72,6 +77,12 @@ async function runDetect(page) { let config = configRes.status === 'fulfilled' ? configRes.value : { installed: false } const version = versionRes.status === 'fulfilled' ? versionRes.value : null const panelCfg = await api.readPanelConfig().catch(() => ({})) + const service = (clawRes.status === 'fulfilled' && Array.isArray(clawRes.value)) ? clawRes.value[0] : null + const cliInfo = { + path: service?.cli_path || '', + version: service?.cli_version || '', + source: service?.cli_source || '', + } // CLI 已装但配置缺失 → 自动创建默认配置 if (cliOk && !config.installed) { @@ -95,12 +106,8 @@ async function runDetect(page) { api.configureGitHttps().catch(() => {}) } - renderSteps(page, { node, git, cliOk, config, version }) + renderSteps(page, { node, git, cliOk, config, version, cliInfo }) - if (panelCfg.forceSetup === true && node.installed && config.installed) { - const nextCfg = { ...panelCfg, forceSetup: false } - api.writePanelConfig(nextCfg).catch(() => {}) - } } function stepIcon(ok) { @@ -108,7 +115,7 @@ function stepIcon(ok) { return `${ok ? '✓' : '✗'}` } -function renderSteps(page, { node, git, cliOk, config, version }) { +function renderSteps(page, { node, git, cliOk, config, version, cliInfo }) { const stepsEl = page.querySelector('#setup-steps') const nodeOk = node.installed const gitOk = git?.installed || false @@ -183,6 +190,17 @@ function renderSteps(page, { node, git, cliOk, config, version }) {
${cliOk ? `

CLI 可用

+ ${cliInfo?.path + ? `
+ 路径:${escapeHtml(cliInfo.path)} +
` + : ''} + ${cliInfo?.version + ? `
版本:${escapeHtml(cliInfo.version)}
` + : ''} + ${cliInfo?.source + ? `
来源:${escapeHtml(cliInfo.source)}
` + : ''} ${version?.ahead_of_recommended && version?.recommended ? `
检测到当前本地 OpenClaw ${version.current || ''} 高于当前面板推荐稳定版 ${version.recommended},可能存在兼容或稳定性风险。建议稍后到「关于」页回退到推荐版。 @@ -254,7 +272,8 @@ function renderSteps(page, { node, git, cliOk, config, version }) {
-
+
+ ${cliOk && config.installed ? '' : ''}
` @@ -402,6 +421,13 @@ function bindEvents(page, nodeOk, detectState) { window.location.hash = '/assistant' }) + // 使用已有 OpenClaw + page.querySelector('#btn-use-existing')?.addEventListener('click', async () => { + const cfg = await api.readPanelConfig().catch(() => ({})) + await api.writePanelConfig({ ...cfg, skipSetup: true, forceSetup: false }).catch(() => {}) + window.location.hash = '/dashboard' + }) + // 进入面板 page.querySelector('#btn-enter')?.addEventListener('click', () => { window.location.hash = '/dashboard' From 0f620bba62f1ba166e19b67f59d2e7468ed89573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 09:38:49 +0800 Subject: [PATCH 290/426] chore: checkpoint before hosted agent send fix From 59f81c4ef474f7baa9826842872a8f27c6c9433a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 09:40:48 +0800 Subject: [PATCH 291/426] fix: forward hosted agent output to gateway --- src/pages/chat.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/pages/chat.js b/src/pages/chat.js index 71400107..c12023e1 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -168,6 +168,7 @@ let _hostedLastTargetTs = 0 let _hostedBusy = false let _hostedAbort = null let _hostedLastCompletionRunId = '' +let _hostedLastSentHash = '' let _askUserBlockedNotice = false const _askUserToolHandled = new Set() let _currentAiBubble = null, _currentAiText = '', _currentAiImages = [], _currentAiVideos = [], _currentAiAudios = [], _currentAiFiles = [], _currentAiTools = [], _currentRunId = null @@ -3336,6 +3337,13 @@ function appendHostedOutput(text) { wrap.textContent = text insertMessageByTime(wrap, Date.now()) scrollToBottom() + + const hash = `${text.length}:${text.slice(0, 120)}` + if (hash === _hostedLastSentHash) return + _hostedLastSentHash = hash + if (_sessionKey && wsClient.gatewayReady) { + wsClient.chatSend(_sessionKey, text).catch(() => {}) + } } // ── 页面离开清理 ── @@ -3388,6 +3396,7 @@ export function cleanup() { _hostedBusy = false _hostedSeeded = false _hostedLastCompletionRunId = '' + _hostedLastSentHash = '' _toolEventTimes.clear() _toolEventData.clear() _toolRunIndex.clear() From 7055b082e89ccc2729d618ca8a96ec68d52148cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 09:49:08 +0800 Subject: [PATCH 292/426] chore: checkpoint before hosted agent session binding From 638eaecbe04ee003e70d83d47bc6532a55e07dfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 09:57:43 +0800 Subject: [PATCH 293/426] fix: bind hosted agent to session --- src/pages/chat.js | 58 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/src/pages/chat.js b/src/pages/chat.js index c12023e1..ab9ffc24 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -329,6 +329,7 @@ export async function render() {
+
` card.classList.add('answered') - if (_sessionKey && wsClient.gatewayReady) { - wsClient.chatSend(_sessionKey, answer || '').catch(() => {}) + if (targetSessionKey && wsClient.gatewayReady) { + wsClient.chatSend(targetSessionKey, answer || '').catch(() => {}) } } @@ -3485,10 +3520,34 @@ function extractHostedInstruction(text) { return tail.slice(0, stopMatch.index).trim() } +function extractHostedAskUser(text) { + if (!text) return { text: '', askUser: null } + const raw = String(text) + const match = raw.match(/\[ASK_USER\]([\s\S]*?)\[\/ASK_USER\]/i) + if (!match) return { text: raw, askUser: null } + const jsonRaw = (match[1] || '').trim() + let askUser = null + if (jsonRaw) { + try { + const parsed = JSON.parse(jsonRaw) + if (parsed && typeof parsed === 'object') askUser = parsed + } catch { + askUser = null + } + } + if (!askUser) { + askUser = { question: jsonRaw || '请提供信息' } + } + const cleaned = raw.replace(match[0], '').trim() + return { text: cleaned, askUser } +} + function appendHostedOutput(text) { if (!text) return const rawText = String(text) - let displayText = rawText + const extracted = extractHostedAskUser(rawText) + const cleanedText = extracted.text || '' + let displayText = cleanedText || '托管 Agent 发起用户提问' if (!displayText.startsWith('[托管 Agent]')) displayText = `[托管 Agent] ${displayText}` const boundKey = getHostedBoundSessionKey() if (boundKey === _sessionKey) { @@ -3497,9 +3556,21 @@ function appendHostedOutput(text) { wrap.textContent = displayText insertMessageByTime(wrap, Date.now()) scrollToBottom() + if (extracted.askUser) { + const ask = extracted.askUser || {} + const askId = `hosted-ask-user:${boundKey}:${ask.question || ''}:${ask.placeholder || ''}:${(ask.options || []).join('|')}` + showAskUserCardChat({ + question: ask.question || ask.prompt || '请提供信息', + type: ask.type || 'text', + options: ask.options || [], + placeholder: ask.placeholder || '', + toolId: askId, + sessionKey: boundKey, + }) + } } - const instruction = extractHostedInstruction(rawText) + const instruction = extractHostedInstruction(cleanedText || rawText) if (!instruction) return const hash = `${instruction.length}:${instruction.slice(0, 120)}` if (hash === _hostedLastSentHash) return From 889a597c0f3e3abdc1b1346ded731827babeb9fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 12:46:26 +0800 Subject: [PATCH 299/426] chore: checkpoint before task1 dev-api security fixes From ed55ed806c9cae2a6e047ecc033a6842dbedc488 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 12:53:02 +0800 Subject: [PATCH 300/426] fix: harden dev-api security --- scripts/dev-api.js | 78 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 69 insertions(+), 9 deletions(-) diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 3974301b..244e3828 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -55,6 +55,64 @@ const GIT_HTTPS_REWRITES = [ 'git://github.com/', 'git+ssh://git@github.com/' ] +const CLAWPANEL_DIR = path.resolve(__dev_dirname, '..') +const SAFE_ROOTS = [OPENCLAW_DIR, CLAWPANEL_DIR] + +function isPrivateHost(hostname) { + const h = String(hostname || '').toLowerCase() + if (!h) return true + if (h === 'localhost' || h === '127.0.0.1' || h === '::1') return true + if (/^0\.0\.0\.0$/.test(h)) return true + if (/^10\./.test(h)) return true + if (/^192\.168\./.test(h)) return true + if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(h)) return true + if (/^169\.254\./.test(h)) return true + if (/^fc00:|^fd00:|^fe80:/.test(h)) return true + return false +} + +function validateEndpoint(endpoint) { + let u + try { u = new URL(endpoint) } catch { throw new Error('endpoint 非法') } + if (!['http:', 'https:'].includes(u.protocol)) throw new Error('endpoint 协议非法') + if (u.username || u.password) throw new Error('endpoint 禁止包含认证信息') + if (isPrivateHost(u.hostname)) throw new Error('endpoint 禁止指向内网/本机') + return true +} + +function validateRemoteUrl(raw) { + let u + try { u = new URL(raw) } catch { throw new Error('URL 非法') } + if (!['http:', 'https:'].includes(u.protocol)) throw new Error('URL 协议非法') + if (u.username || u.password) throw new Error('URL 禁止包含认证信息') + if (isPrivateHost(u.hostname)) throw new Error('URL 禁止指向内网/本机') + return true +} + +function resolveSafePath(inputPath) { + const expanded = String(inputPath || '').startsWith('~/') + ? path.join(homedir(), String(inputPath).slice(2)) + : String(inputPath || '') + const resolved = path.resolve(expanded) + const allowed = SAFE_ROOTS.some(root => resolved === root || resolved.startsWith(root + path.sep)) + if (!allowed) throw new Error('路径超出允许范围') + return resolved +} + +const EXEC_WHITELIST = new Set(['openclaw', 'npm', 'node', 'git', 'pnpm', 'yarn', 'npx', 'wails', 'uv']) +function parseCommandName(command) { + const token = String(command || '').trim().split(/\s+/)[0] || '' + const cleaned = token.replace(/^"|"$/g, '') + const base = path.basename(cleaned) + return base.replace(/\.(exe|cmd|bat)$/i, '').toLowerCase() +} +function assertSafeCommand(command, cwd) { + if (!command) throw new Error('命令不能为空') + if (/[&|><^;]/.test(command) || /\r|\n/.test(command)) throw new Error('命令包含非法字符') + const name = parseCommandName(command) + if (!EXEC_WHITELIST.has(name)) throw new Error('命令不在白名单') + if (cwd) resolveSafePath(cwd) +} // === 异步任务存储 === const _taskStore = new Map() // taskId → task object @@ -1455,6 +1513,8 @@ function getActiveInstance() { } async function proxyToInstance(instance, cmd, body) { + if (!instance?.endpoint) throw new Error('endpoint 不能为空') + validateEndpoint(instance.endpoint) const url = `${instance.endpoint}/__api/${cmd}` const resp = await fetch(url, { method: 'POST', @@ -1498,6 +1558,7 @@ async function instanceHealthCheck(instance) { if (!instance.endpoint) return result try { + validateEndpoint(instance.endpoint) const resp = await fetch(`${instance.endpoint}/__api/check_installation`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -1512,6 +1573,7 @@ async function instanceHealthCheck(instance) { } catch {} if (result.online) { try { + validateEndpoint(instance.endpoint) const resp = await fetch(`${instance.endpoint}/__api/get_services_status`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -1950,6 +2012,7 @@ const handlers = { instance_add({ name, type, endpoint, gatewayPort, containerId, nodeId, note }) { if (!name) throw new Error('实例名称不能为空') if (!endpoint) throw new Error('端点地址不能为空') + validateEndpoint(endpoint) const data = readInstances() const id = type === 'docker' ? `docker-${(containerId || Date.now().toString(36)).slice(0, 12)}` : `remote-${Date.now().toString(36)}` if (data.instances.find(i => i.endpoint === endpoint)) throw new Error('该端点已存在') @@ -3758,12 +3821,9 @@ const handlers = { // === AI 助手工具(Web 模式真实执行) === assistant_exec({ command, cwd }) { - if (!command) throw new Error('命令不能为空') - // 安全限制:禁止危险命令 - const dangerous = ['rm -rf /', 'mkfs', 'dd if=', ':(){', 'format ', 'del /f /s /q C:'] - if (dangerous.some(d => command.includes(d))) throw new Error('危险命令已被拦截') + assertSafeCommand(command, cwd) const opts = { timeout: 30000, maxBuffer: 1024 * 1024, windowsHide: true } - if (cwd) opts.cwd = cwd + if (cwd) opts.cwd = resolveSafePath(cwd) try { const output = execSync(command, opts).toString() return output || '(命令已执行,无输出)' @@ -3776,7 +3836,7 @@ const handlers = { assistant_read_file({ path: filePath }) { if (!filePath) throw new Error('路径不能为空') - const expanded = filePath.startsWith('~/') ? path.join(homedir(), filePath.slice(2)) : filePath + const expanded = resolveSafePath(filePath) if (!fs.existsSync(expanded)) throw new Error(`文件不存在: ${filePath}`) const stat = fs.statSync(expanded) if (stat.size > 1024 * 1024) throw new Error(`文件过大 (${(stat.size / 1024 / 1024).toFixed(1)}MB),最大 1MB`) @@ -3785,7 +3845,7 @@ const handlers = { assistant_write_file({ path: filePath, content }) { if (!filePath) throw new Error('路径不能为空') - const expanded = filePath.startsWith('~/') ? path.join(homedir(), filePath.slice(2)) : filePath + const expanded = resolveSafePath(filePath) const dir = path.dirname(expanded) if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) fs.writeFileSync(expanded, content || '') @@ -3794,7 +3854,7 @@ const handlers = { assistant_list_dir({ path: dirPath }) { if (!dirPath) throw new Error('路径不能为空') - const expanded = dirPath.startsWith('~/') ? path.join(homedir(), dirPath.slice(2)) : dirPath + const expanded = resolveSafePath(dirPath) if (!fs.existsSync(expanded)) throw new Error(`目录不存在: ${dirPath}`) const entries = fs.readdirSync(expanded, { withFileTypes: true }) return entries.map(e => { @@ -3939,7 +3999,7 @@ const handlers = { async assistant_fetch_url({ url }) { if (!url) throw new Error('URL 不能为空') - if (!url.startsWith('http://') && !url.startsWith('https://')) throw new Error('URL 必须以 http:// 或 https:// 开头') + validateRemoteUrl(url) try { // 优先使用 Jina Reader API(免费,返回 Markdown) From 33db6c994bb5d2389c82d1554c81b7cc75aeea2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 13:03:17 +0800 Subject: [PATCH 301/426] fix: stabilize ws handshake and history --- src/lib/ws-client.js | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/lib/ws-client.js b/src/lib/ws-client.js index 63447565..605ce11b 100644 --- a/src/lib/ws-client.js +++ b/src/lib/ws-client.js @@ -57,6 +57,7 @@ export class WsClient { this._sessionKey = null this._pingTimer = null this._challengeTimer = null + this._connectSent = false this._wsId = 0 this._autoPairAttempts = 0 this._autoPairing = false @@ -135,6 +136,7 @@ export class WsClient { this._closeWs() this._gatewayReady = false this._handshaking = false + this._connectSent = false this._setConnected(false, WS_STATE.CONNECTING) const wsId = ++this._wsId let ws @@ -144,14 +146,14 @@ export class WsClient { ws.onopen = () => { if (wsId !== this._wsId) return this._connecting = false - this._reconnectAttempts = 0 this._setConnected(true, WS_STATE.CONNECTED) this._startPing() - // 等 Gateway 发 connect.challenge,超时则主动发 + // 等 Gateway 发 connect.challenge,超时则重连 this._challengeTimer = setTimeout(() => { if (!this._handshaking && !this._gatewayReady) { - console.log('[ws] 未收到 challenge,主动发 connect') - this._sendConnectFrame('') + console.log('[ws] 未收到 challenge,重连中...') + this._closeWs() + this._scheduleReconnect() } }, CHALLENGE_TIMEOUT) } @@ -229,6 +231,8 @@ export class WsClient { this._readyCallbacks.forEach(fn => { try { fn(null, null, { error: true, message: errMsg }) } catch {} }) + this._closeWs() + this._scheduleReconnect() return } // 握手成功,提取 snapshot @@ -299,6 +303,8 @@ export class WsClient { } async _sendConnectFrame(nonce) { + if (this._connectSent) return + this._connectSent = true this._handshaking = true this._setConnected(false, WS_STATE.HANDSHAKING) try { @@ -311,11 +317,15 @@ export class WsClient { console.error('[ws] 生成 connect frame 失败:', e) this._handshaking = false this._setConnected(false, WS_STATE.ERROR, '生成握手失败') + this._closeWs() + this._scheduleReconnect() } } _handleConnectSuccess(payload) { this._autoPairAttempts = 0 + this._reconnectAttempts = 0 + this._connectSent = false this._hello = payload || null this._snapshot = payload?.snapshot || null this._serverVersion = payload?.serverVersion || null @@ -421,11 +431,7 @@ export class WsClient { const id = uuid() this._ws.send(JSON.stringify({ type: 'req', id, method: 'node.list', params: {} })) } catch {} - try { - const sessionKey = this._sessionKey || `agent:main:main` - const id = uuid() - this.request('chat.history', { sessionKey, limit: 200 }, { emitEvent: true }).catch(() => {}) - } catch {} + // ping 只保活,不拉取历史 } }, PING_INTERVAL) } From 0667f7126e82c40dc479f6175857db4fb648ea1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 13:32:16 +0800 Subject: [PATCH 302/426] chore: checkpoint before task3 --- src/pages/dashboard.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js index fcc7e13c..074e460a 100644 --- a/src/pages/dashboard.js +++ b/src/pages/dashboard.js @@ -89,8 +89,8 @@ async function loadDashboardData(page, fullRefresh = false) { // 第一波:服务状态 + 配置 + 版本 → 立即渲染统计卡片 const [servicesRes, configRes, versionRes] = await coreP - const services = servicesRes.status === 'fulfilled' ? servicesRes.value : [] - const version = versionRes.status === 'fulfilled' ? versionRes.value : {} + const services = servicesRes.status === 'fulfilled' ? (Array.isArray(servicesRes.value) ? servicesRes.value : []) : [] + const version = versionRes.status === 'fulfilled' ? (versionRes.value || {}) : {} const config = configRes.status === 'fulfilled' ? configRes.value : null if (servicesRes.status === 'rejected') toast('服务状态加载失败', 'error') if (versionRes.status === 'rejected') toast('版本信息加载失败', 'error') @@ -139,7 +139,7 @@ async function loadDashboardData(page, fullRefresh = false) { _dashboardInitialized = true } -function renderStatCards(page, services, version, agents, config) { +function renderStatCards(page, services, version, agents, config) {\n services = Array.isArray(services) ? services : []\n version = version || {} const cardsEl = page.querySelector('#stat-cards') const gw = services.find(s => s.label === 'ai.openclaw.gateway') const runningCount = services.filter(s => s.running).length @@ -199,7 +199,7 @@ function renderStatCards(page, services, version, agents, config) { ` } -function renderOverview(page, services, mcpConfig, backups, config, agents, statusSummary) { +function renderOverview(page, services, mcpConfig, backups, config, agents, statusSummary) {\n services = Array.isArray(services) ? services : [] const containerEl = page.querySelector('#dashboard-overview-container') const gw = services.find(s => s.label === 'ai.openclaw.gateway') const mcpCount = mcpConfig?.mcpServers ? Object.keys(mcpConfig.mcpServers).length : 0 @@ -504,3 +504,4 @@ function bindActions(page) { function escapeHtml(str) { return str.replace(/&/g, '&').replace(//g, '>') } + From a534752cd84724043babbbe732f6204d0d3201c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 13:35:30 +0800 Subject: [PATCH 303/426] fix: repair dashboard render guards --- src/pages/dashboard.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js index 074e460a..0f7c7770 100644 --- a/src/pages/dashboard.js +++ b/src/pages/dashboard.js @@ -139,7 +139,9 @@ async function loadDashboardData(page, fullRefresh = false) { _dashboardInitialized = true } -function renderStatCards(page, services, version, agents, config) {\n services = Array.isArray(services) ? services : []\n version = version || {} +function renderStatCards(page, services, version, agents, config) { + services = Array.isArray(services) ? services : [] + version = version || {} const cardsEl = page.querySelector('#stat-cards') const gw = services.find(s => s.label === 'ai.openclaw.gateway') const runningCount = services.filter(s => s.running).length @@ -199,7 +201,8 @@ function renderStatCards(page, services, version, agents, config) {\n services ` } -function renderOverview(page, services, mcpConfig, backups, config, agents, statusSummary) {\n services = Array.isArray(services) ? services : [] +function renderOverview(page, services, mcpConfig, backups, config, agents, statusSummary) { + services = Array.isArray(services) ? services : [] const containerEl = page.querySelector('#dashboard-overview-container') const gw = services.find(s => s.label === 'ai.openclaw.gateway') const mcpCount = mcpConfig?.mcpServers ? Object.keys(mcpConfig.mcpServers).length : 0 From 9f99bcd047ca21f261ab5b83e01b8e9eb16617a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 13:35:40 +0800 Subject: [PATCH 304/426] fix: auth and setup gating --- src/main.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/main.js b/src/main.js index a50b964b..3b22b489 100644 --- a/src/main.js +++ b/src/main.js @@ -353,7 +353,8 @@ async function boot() { ? api.readPanelConfig().then(cfg => { _forceSetup = cfg.forceSetup === true _skipSetup = cfg.skipSetup === true - if (cfg.accessPassword) { + const shouldAutoLogin = !!cfg.accessPassword && !cfg.mustChangePassword && cfg.forceSetup !== true + if (shouldAutoLogin) { return fetch('/__api/auth_login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -366,7 +367,7 @@ async function boot() { ensureWebSession.then(() => loadActiveInstance()).then(() => detectOpenclawStatus()).then(() => { // 重新渲染侧边栏(检测完成后 isOpenclawReady 状态已更新) renderSidebar(sidebar) - const wantsSetup = window.location.hash === '#/setup' || _forceSetup || (!isOpenclawReady() && !_skipSetup) + const wantsSetup = window.location.hash.startsWith('#/setup') || _forceSetup || (!isOpenclawReady() && !_skipSetup) if (wantsSetup) { setDefaultRoute('/setup') navigate('/setup') @@ -732,7 +733,14 @@ function startUpdateChecker() { } const auth = await checkAuth() - if (!auth.ok) await showLoginOverlay(auth.defaultPw) + if (!auth.ok) { + await showLoginOverlay(auth.defaultPw) + const authRetry = await checkAuth() + if (!authRetry.ok) { + _hideSplash() + return + } + } try { await boot() } catch (bootErr) { From 2c427b87adfb689f743b018f9e00295ba2958bfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 13:37:08 +0800 Subject: [PATCH 305/426] chore: checkpoint before task4 From 2bdcea27e43abc23eef42fc4a2f03b76bd43dcb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 14:20:03 +0800 Subject: [PATCH 306/426] fix: hosted session isolation --- src/pages/chat.js | 222 ++++++++++++++++++++++++++-------------------- 1 file changed, 125 insertions(+), 97 deletions(-) diff --git a/src/pages/chat.js b/src/pages/chat.js index ac53387c..1756e3fb 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -178,10 +178,8 @@ let _askUserBlockedNotice = false const _askUserToolHandled = new Set() let _currentAiBubble = null, _currentAiText = '', _currentAiImages = [], _currentAiVideos = [], _currentAiAudios = [], _currentAiFiles = [], _currentAiTools = [], _currentRunId = null let _isStreaming = false, _isSending = false, _messageQueue = [], _streamStartTime = 0 -let _lastRenderTime = 0, _renderPending = false, _lastHistoryHash = '' +let _lastRenderTime = 0, _renderPending = false let _isLoadingHistory = false -let _pendingHistoryPayload = null -let _pendingHistoryTs = 0 const VIRTUAL_WINDOW = 40 const VIRTUAL_OVERSCAN = 20 @@ -199,14 +197,33 @@ let _virtualObserver = null let _autoScrollEnabled = true, _lastScrollTop = 0, _touchStartY = 0 let _streamSafetyTimer = null, _unsubEvent = null, _unsubReady = null, _unsubStatus = null -let _seenRunIds = new Set() let _pageActive = false -const _toolEventTimes = new Map() -const _toolEventData = new Map() -const _toolRunIndex = new Map() -const _toolEventSeen = new Set() +const _sessionStates = new Map() let _errorTimer = null, _lastErrorMsg = null let _attachments = [] + +function _normalizeSessionKey(key) { + if (typeof key === 'string' && key.trim()) return key.trim() + if (_sessionKey) return _sessionKey + return 'default' +} + +function getSessionState(sessionKey) { + const key = _normalizeSessionKey(sessionKey) + if (!_sessionStates.has(key)) { + _sessionStates.set(key, { + lastHistoryHash: '', + pendingHistoryPayload: null, + pendingHistoryTs: 0, + seenRunIds: new Set(), + toolEventTimes: new Map(), + toolEventData: new Map(), + toolRunIndex: new Map(), + toolEventSeen: new Set(), + }) + } + return _sessionStates.get(key) +} let _hasEverConnected = false let _availableModels = [] let _primaryModel = '' @@ -964,7 +981,8 @@ function switchSession(newKey) { _sessionKey = newKey localStorage.setItem(STORAGE_SESSION_KEY, newKey) wsClient.setSessionKey(newKey) - _lastHistoryHash = '' + const state = getSessionState(_sessionKey) + state.lastHistoryHash = '' resetStreamState() updateSessionTitle() clearMessages() @@ -1051,7 +1069,8 @@ async function resetCurrentSession() { try { await wsClient.sessionsReset(_sessionKey) clearMessages() - _lastHistoryHash = '' + const state = getSessionState(_sessionKey) + state.lastHistoryHash = '' appendSystemMessage('会话已重置') toast('会话已重置', 'success') } catch (e) { @@ -1213,25 +1232,27 @@ function stopGeneration() { function handleEvent(msg) { const { event, payload } = msg if (!payload) return + const sessionKey = _normalizeSessionKey(payload?.sessionKey || payload?._req?.sessionKey) + const state = getSessionState(sessionKey) if (event === 'agent' && payload?.stream === 'tool' && payload?.data?.toolCallId) { const ts = payload.ts const toolCallId = payload.data.toolCallId const runId = payload.runId || '' const runKey = runId ? `${runId}:${toolCallId}` : toolCallId - if (_toolEventSeen.has(runKey)) return - _toolEventSeen.add(runKey) - if (ts) _toolEventTimes.set(runKey, ts) - const current = _toolEventData.get(runKey) || {} + if (state.toolEventSeen.has(runKey)) return + state.toolEventSeen.add(runKey) + if (ts) state.toolEventTimes.set(runKey, ts) + const current = state.toolEventData.get(runKey) || {} if (payload.data?.args && current.input == null) current.input = payload.data.args if (payload.data?.meta && current.output == null) current.output = payload.data.meta if (typeof payload.data?.isError === 'boolean' && current.status == null) current.status = payload.data.isError ? 'error' : 'ok' if (current.time == null) current.time = ts || null - _toolEventData.set(runKey, current) + state.toolEventData.set(runKey, current) if (runId) { - const list = _toolRunIndex.get(runId) || [] + const list = state.toolRunIndex.get(runId) || [] if (!list.includes(toolCallId)) list.push(toolCallId) - _toolRunIndex.set(runId, list) + state.toolRunIndex.set(runId, list) } } @@ -1239,17 +1260,17 @@ function handleEvent(msg) { const reqKey = payload?._req?.sessionKey || '' if (reqKey && _sessionKey && reqKey !== _sessionKey) return if (!_messagesEl) { - _pendingHistoryPayload = payload - _pendingHistoryTs = Date.now() + state.pendingHistoryPayload = payload + state.pendingHistoryTs = Date.now() return } if (_isSending || _isStreaming || _messageQueue.length > 0) { - _pendingHistoryPayload = payload - _pendingHistoryTs = Date.now() + state.pendingHistoryPayload = payload + state.pendingHistoryTs = Date.now() return } const hasExisting = _messagesEl?.querySelector?.('.msg') - applyHistoryResult(payload, hasExisting) + applyHistoryResult(payload, hasExisting, _sessionKey) } if (event === 'chat') handleChatEvent(payload) @@ -1281,12 +1302,14 @@ function handleChatEvent(payload) { const { state } = payload const runId = payload.runId + const sessionKey = _normalizeSessionKey(payload.sessionKey || _sessionKey) + const sessionState = getSessionState(sessionKey) const isUiSession = !payload.sessionKey || payload.sessionKey === _sessionKey if (!isUiSession) { if (state === 'final') { return withHostedState(payload.sessionKey, () => { - const c = extractChatContent(payload.message) + const c = extractChatContent(payload.message, sessionKey) const finalText = c?.text || '' if (finalText && shouldCaptureHostedTarget(payload)) { appendHostedTarget(finalText, payload.timestamp || Date.now()) @@ -1298,17 +1321,17 @@ function handleChatEvent(payload) { } // 重复 run 过滤:跳过已完成的 runId 的后续事件(Gateway 可能对同一消息触发多个 run) - if (runId && state === 'final' && _seenRunIds.has(runId)) { + if (runId && state === 'final' && sessionState.seenRunIds.has(runId)) { console.log('[chat] 跳过重复 final, runId:', runId) return } - if (runId && state === 'delta' && _seenRunIds.has(runId) && !_isStreaming) { + if (runId && state === 'delta' && sessionState.seenRunIds.has(runId) && !_isStreaming) { console.log('[chat] 跳过已完成 run 的 delta, runId:', runId) return } if (state === 'delta') { - const c = extractChatContent(payload.message) + const c = extractChatContent(payload.message, sessionKey) if (c?.images?.length) _currentAiImages = c.images if (c?.videos?.length) _currentAiVideos = c.videos if (c?.audios?.length) _currentAiAudios = c.audios @@ -1343,7 +1366,7 @@ function handleChatEvent(payload) { } if (state === 'final') { - const c = extractChatContent(payload.message) + const c = extractChatContent(payload.message, sessionKey) const finalText = c?.text || '' const finalImages = c?.images || [] const finalVideos = c?.videos || [] @@ -1351,8 +1374,8 @@ function handleChatEvent(payload) { const finalFiles = c?.files || [] let finalTools = c?.tools || [] if (!finalTools.length && runId) { - const ids = _toolRunIndex.get(runId) || [] - finalTools = ids.map(id => mergeToolEventData({ id, name: '工具' })).filter(Boolean) + const ids = sessionState.toolRunIndex.get(runId) || [] + finalTools = ids.map(id => mergeToolEventData({ id, name: '工具' }, sessionKey)).filter(Boolean) } // 托管 Agent:记录对面回复并触发下一步(绑定会话亦可) @@ -1370,10 +1393,10 @@ function handleChatEvent(payload) { if (!_currentAiBubble && !hasContent) return // 标记 runId 为已处理,防止重复 if (runId) { - _seenRunIds.add(runId) - if (_seenRunIds.size > 200) { - const first = _seenRunIds.values().next().value - _seenRunIds.delete(first) + sessionState.seenRunIds.add(runId) + if (sessionState.seenRunIds.size > 200) { + const first = sessionState.seenRunIds.values().next().value + sessionState.seenRunIds.delete(first) } } showTyping(false) @@ -1388,18 +1411,18 @@ function handleChatEvent(payload) { appendVideosToEl(_currentAiBubble, _currentAiVideos) appendAudiosToEl(_currentAiBubble, _currentAiAudios) appendFilesToEl(_currentAiBubble, _currentAiFiles) - appendToolsToEl(_currentAiBubble, finalTools.length ? finalTools : _currentAiTools) + appendToolsToEl(_currentAiBubble, finalTools.length ? finalTools : _currentAiTools, sessionKey) } if (runId) { - const ids = _toolRunIndex.get(runId) || [] + const ids = sessionState.toolRunIndex.get(runId) || [] ids.forEach(id => { const key = `${runId}:${id}` - _toolEventTimes.delete(key) - _toolEventData.delete(key) - _toolEventSeen.delete(key) + sessionState.toolEventTimes.delete(key) + sessionState.toolEventData.delete(key) + sessionState.toolEventSeen.delete(key) }) - _toolRunIndex.delete(runId) + sessionState.toolRunIndex.delete(runId) } // 添加时间戳 + 耗时 + token 消耗 const wrapper = _currentAiBubble?.parentElement @@ -1492,10 +1515,10 @@ function handleChatEvent(payload) { } /** 从 Gateway message 对象提取文本和所有媒体(参照 clawapp extractContent) */ -function extractChatContent(message) { +function extractChatContent(message, sessionKey) { if (!message || typeof message !== 'object') return null const tools = [] - collectToolsFromMessage(message, tools) + collectToolsFromMessage(message, tools, sessionKey) if (message.role === 'tool' || message.role === 'toolResult') { const output = typeof message.content === 'string' ? message.content : null if (!tools.length) { @@ -1541,10 +1564,10 @@ function extractChatContent(message) { input: block.input || block.args || block.parameters || block.arguments || block.tool_input || block.toolInput || block.tool?.input || block.tool?.args || block.meta?.input || block.meta?.args || null, output: null, status: block.status || 'ok', - time: resolveToolTime(callId, message.timestamp, message.runId), + time: resolveToolTime(callId, message.timestamp, message.runId, sessionKey), runId: message.runId, messageTimestamp: message.timestamp, - }) + }, sessionKey) } else if (block.type === 'tool_result' || block.type === 'toolResult') { const resId = block.id || block.tool_call_id || block.toolCallId || block.result_id || block.resultId @@ -1554,10 +1577,10 @@ function extractChatContent(message) { input: block.input || block.args || block.tool_input || block.toolInput || block.tool?.input || block.tool?.args || block.meta?.input || block.meta?.args || null, output: block.output || block.result || block.content || block.tool_output || block.output_text || block.result_text || block.tool?.output || block.meta?.output || null, status: block.status || 'ok', - time: resolveToolTime(resId, message.timestamp, message.runId), + time: resolveToolTime(resId, message.timestamp, message.runId, sessionKey), runId: message.runId, messageTimestamp: message.timestamp, - }) + }, sessionKey) } } if (tools.length) { @@ -1620,11 +1643,12 @@ function normalizeTime(raw) { return raw } -function resolveToolTime(toolId, messageTimestamp, runId) { +function resolveToolTime(toolId, messageTimestamp, runId, sessionKey) { + const state = getSessionState(sessionKey) const key = runId ? `${runId}:${toolId}` : toolId - let eventTs = toolId ? _toolEventTimes.get(key) : null + let eventTs = toolId ? state.toolEventTimes.get(key) : null if (!eventTs && runId) { - for (const [k, v] of _toolEventTimes.entries()) { + for (const [k, v] of state.toolEventTimes.entries()) { if (k.endsWith(`:${toolId}`)) { eventTs = v; break } } } @@ -1717,7 +1741,7 @@ function resetStreamState() { appendVideosToEl(_currentAiBubble, _currentAiVideos) appendAudiosToEl(_currentAiBubble, _currentAiAudios) appendFilesToEl(_currentAiBubble, _currentAiFiles) - appendToolsToEl(_currentAiBubble, _currentAiTools) + appendToolsToEl(_currentAiBubble, _currentAiTools, _sessionKey) } _renderPending = false _lastRenderTime = 0 @@ -1762,24 +1786,26 @@ function buildHistoryHash(messages) { } function flushPendingHistory() { - if (!_pendingHistoryPayload || !_messagesEl) return + const state = getSessionState(_sessionKey) + if (!state.pendingHistoryPayload || !_messagesEl) return if (_isSending || _isStreaming || _messageQueue.length > 0) return - const payload = _pendingHistoryPayload - _pendingHistoryPayload = null - _pendingHistoryTs = 0 + const payload = state.pendingHistoryPayload + state.pendingHistoryPayload = null + state.pendingHistoryTs = 0 const hasExisting = _messagesEl?.querySelector?.('.msg') - applyHistoryResult(payload, hasExisting) + applyHistoryResult(payload, hasExisting, _sessionKey) } -function applyHistoryResult(result, hasExisting) { +function applyHistoryResult(result, hasExisting, sessionKey) { if (!result?.messages?.length) { if (_messagesEl && !_messagesEl.querySelector('.msg')) appendSystemMessage('还没有消息,开始聊天吧') return } - const deduped = dedupeHistory(result.messages) + const deduped = dedupeHistory(result.messages, sessionKey) const hash = buildHistoryHash(result.messages) - if (hash === _lastHistoryHash && hasExisting) return - _lastHistoryHash = hash + const state = getSessionState(sessionKey) + if (hash === state.lastHistoryHash && hasExisting) return + state.lastHistoryHash = hash if (!_hostedSeeded && _hostedSessionConfig && (!_hostedSessionConfig.history || _hostedSessionConfig.history.length === 0)) { const seeded = deduped @@ -1868,7 +1894,7 @@ async function loadHistory() { if (!wsClient.gatewayReady) { _isLoadingHistory = false; return } try { const result = await wsClient.chatHistory(_sessionKey, 200) - applyHistoryResult(result, hasExisting) + applyHistoryResult(result, hasExisting, _sessionKey) } catch (e) { console.error('[chat] loadHistory error:', e) if (_messagesEl && !_messagesEl.querySelector('.msg')) appendSystemMessage('加载历史失败: ' + e.message) @@ -1878,15 +1904,15 @@ async function loadHistory() { } } -function dedupeHistory(messages) { +function dedupeHistory(messages, sessionKey) { const deduped = [] for (const msg of messages) { const role = (msg.role === 'tool' || msg.role === 'toolResult') ? 'assistant' : msg.role - const c = extractContent(msg) + const c = extractContent(msg, sessionKey) if (!c.text && !c.images.length && !c.videos.length && !c.audios.length && !c.files.length && !c.tools.length) continue const tools = (c.tools || []).map(t => { const id = t.id || t.tool_call_id - const time = t.time || resolveToolTime(id, msg.timestamp, msg.runId) + const time = t.time || resolveToolTime(id, msg.timestamp, msg.runId, sessionKey) return { ...t, time, messageTimestamp: msg.timestamp, runId: msg.runId } }) const last = deduped[deduped.length - 1] @@ -1901,7 +1927,7 @@ function dedupeHistory(messages) { last.videos = [...(last.videos || []), ...c.videos] last.audios = [...(last.audios || []), ...c.audios] last.files = [...(last.files || []), ...c.files] - tools.forEach(t => upsertTool(last.tools, t)) + tools.forEach(t => upsertTool(last.tools, t, sessionKey)) continue } } @@ -1910,9 +1936,9 @@ function dedupeHistory(messages) { return deduped } -function extractContent(msg) { +function extractContent(msg, sessionKey) { const tools = [] - collectToolsFromMessage(msg, tools) + collectToolsFromMessage(msg, tools, sessionKey) if (msg.role === 'tool' || msg.role === 'toolResult') { const output = typeof msg.content === 'string' ? msg.content : null if (!tools.length) { @@ -1922,10 +1948,10 @@ function extractContent(msg) { input: msg.input || msg.args || msg.parameters || null, output: output || msg.output || msg.result || null, status: msg.status || 'ok', - time: resolveToolTime(msg.tool_call_id || msg.toolCallId || msg.id, msg.timestamp, msg.runId), + time: resolveToolTime(msg.tool_call_id || msg.toolCallId || msg.id, msg.timestamp, msg.runId, sessionKey), runId: msg.runId, messageTimestamp: msg.timestamp, - }) + }, sessionKey) } else if (output && !tools[0].output) { tools[0].output = output } @@ -1960,8 +1986,8 @@ function extractContent(msg) { input: block.input || block.args || block.parameters || block.arguments || block.tool_input || block.toolInput || block.tool?.input || block.tool?.args || block.meta?.input || block.meta?.args || null, output: null, status: block.status || 'ok', - time: resolveToolTime(callId, msg.timestamp, msg.runId), - }) + time: resolveToolTime(callId, msg.timestamp, msg.runId, sessionKey), + }, sessionKey) } else if (block.type === 'tool_result' || block.type === 'toolResult') { const resId = block.id || block.tool_call_id || block.toolCallId || block.result_id || block.resultId @@ -1971,8 +1997,8 @@ function extractContent(msg) { input: block.input || block.args || block.tool_input || block.toolInput || block.tool?.input || block.tool?.args || block.meta?.input || block.meta?.args || null, output: block.output || block.result || block.content || block.tool_output || block.output_text || block.result_text || block.tool?.output || block.meta?.output || null, status: block.status || 'ok', - time: resolveToolTime(resId, msg.timestamp, msg.runId), - }) + time: resolveToolTime(resId, msg.timestamp, msg.runId, sessionKey), + }, sessionKey) } } if (tools.length) { @@ -2066,7 +2092,7 @@ function appendAiMessage(text, msgTime, images, videos, audios, files, tools) { wrap.className = 'msg msg-ai' const bubble = document.createElement('div') bubble.className = 'msg-bubble' - appendToolsToEl(bubble, tools) + appendToolsToEl(bubble, tools, _sessionKey) const textEl = document.createElement('div') textEl.className = 'msg-text' textEl.innerHTML = renderMarkdown(text || '') @@ -2173,14 +2199,15 @@ function appendFilesToEl(el, files) { }) } -function mergeToolEventData(entry) { +function mergeToolEventData(entry, sessionKey) { const id = entry?.id || entry?.tool_call_id if (!id) return entry + const state = getSessionState(sessionKey) const runId = entry?.runId || entry?.run_id || entry?.run || '' const key = runId ? `${runId}:${id}` : id - let extra = _toolEventData.get(key) + let extra = state.toolEventData.get(key) if (!extra && runId) { - for (const [k, v] of _toolEventData.entries()) { + for (const [k, v] of state.toolEventData.entries()) { if (k.endsWith(`:${id}`)) { extra = v; break } } } @@ -2188,11 +2215,11 @@ function mergeToolEventData(entry) { if (entry.input == null && extra.input != null) entry.input = extra.input if (entry.output == null && extra.output != null) entry.output = extra.output if (entry.status == null && extra.status != null) entry.status = extra.status - if (entry.time == null) entry.time = extra.time || _toolEventTimes.get(key) || null + if (entry.time == null) entry.time = extra.time || state.toolEventTimes.get(key) || null return entry } -function upsertTool(tools, entry) { +function upsertTool(tools, entry, sessionKey) { if (!entry) return const id = entry.id || entry.tool_call_id let target = null @@ -2210,10 +2237,10 @@ function upsertTool(tools, entry) { if (entry.time && target.time == null) target.time = entry.time return } - tools.push(mergeToolEventData(entry)) + tools.push(mergeToolEventData(entry, sessionKey)) } -function collectToolsFromMessage(message, tools) { +function collectToolsFromMessage(message, tools, sessionKey) { if (!message || !tools) return const toolCalls = message.tool_calls || message.toolCalls || message.tools if (Array.isArray(toolCalls)) { @@ -2228,10 +2255,10 @@ function collectToolsFromMessage(message, tools) { input: input || call.meta?.input || null, output: null, status: call.status || 'ok', - time: resolveToolTime(callId, message?.timestamp, message?.runId), + time: resolveToolTime(callId, message?.timestamp, message?.runId, sessionKey), runId: message?.runId, messageTimestamp: message?.timestamp, - }) + }, sessionKey) }) } const toolResults = message.tool_results || message.toolResults @@ -2244,16 +2271,16 @@ function collectToolsFromMessage(message, tools) { input: res.input || res.args || res.tool_input || res.toolInput || res.tool?.input || res.tool?.args || res.meta?.input || res.meta?.args || null, output: res.output || res.result || res.content || res.tool_output || res.output_text || res.result_text || res.tool?.output || res.meta?.output || null, status: res.status || 'ok', - time: resolveToolTime(resId, message?.timestamp, message?.runId), + time: resolveToolTime(resId, message?.timestamp, message?.runId, sessionKey), runId: message?.runId, messageTimestamp: message?.timestamp, - }) + }, sessionKey) }) } } /** 渲染工具调用到消息气泡 */ -function appendToolsToEl(el, tools) { +function appendToolsToEl(el, tools, sessionKey) { if (!el) return const existing = el.querySelector?.('.msg-tool') if (!tools?.length) { @@ -2298,7 +2325,7 @@ function appendToolsToEl(el, tools) { details.className = 'msg-tool-item' const summary = document.createElement('summary') const status = tool.status === 'error' ? '失败' : '成功' - const timeValue = getToolTime(tool) || resolveToolTime(tool.id || tool.tool_call_id, tool.messageTimestamp, tool.runId) + const timeValue = getToolTime(tool) || resolveToolTime(tool.id || tool.tool_call_id, tool.messageTimestamp, tool.runId, sessionKey) const timeText = timeValue ? formatTime(new Date(timeValue)) : '' summary.innerHTML = `${escapeHtml(tool.name || '工具')} · ${status}${timeText ? ' · ' + timeText : ''}` const body = document.createElement('div') @@ -2393,10 +2420,11 @@ function clearMessages() { if (_virtualObserver) { _virtualObserver.disconnect(); _virtualObserver = null } _autoScrollEnabled = true _lastScrollTop = 0 - _toolEventTimes.clear() - _toolEventData.clear() - _toolRunIndex.clear() - _toolEventSeen.clear() + const state = getSessionState(_sessionKey) + state.toolEventTimes.clear() + state.toolEventData.clear() + state.toolRunIndex.clear() + state.toolEventSeen.clear() if (_virtualTopSpacer) _virtualTopSpacer.style.height = '0px' if (_virtualBottomSpacer) _virtualBottomSpacer.style.height = '0px' } @@ -3063,8 +3091,12 @@ async function runHostedAgentStep() { _hostedRuntime.errorCount = (_hostedRuntime.errorCount || 0) + 1 _hostedRuntime.lastError = '托管 Agent 输出未符合模板' _hostedRuntime.pending = false + _hostedRuntime.status = HOSTED_STATUS.ERROR + _hostedRuntime.lastAction = 'error' persistHostedRuntime() updateHostedBadge() + updateHostedInputLock() + appendHostedOutput(`托管 Agent 输出未符合模板${formatHostedSummary()}`) return } @@ -3425,7 +3457,9 @@ function showHostedCompletionModal(summary, content) { function updateHostedInputLock() { const boundKey = getHostedBoundSessionKey() - const locked = !!_hostedSessionConfig?.enabled && _hostedRuntime?.status !== HOSTED_STATUS.PAUSED && boundKey === _sessionKey + const locked = !!_hostedSessionConfig?.enabled + && ( _hostedRuntime?.status === HOSTED_STATUS.RUNNING || _hostedRuntime?.status === HOSTED_STATUS.WAITING ) + && boundKey === _sessionKey if (_textarea) { _textarea.disabled = locked _textarea.placeholder = locked ? '托管 Agent 已启用,用户输入已锁定' : '输入消息,Enter 发送,/ 打开指令' @@ -3611,7 +3645,6 @@ export function cleanup() { _isStreaming = false _isSending = false _messageQueue = [] - _lastHistoryHash = '' _hostedBtn = null _hostedPanelEl = null _hostedBadgeEl = null @@ -3631,10 +3664,5 @@ export function cleanup() { _hostedSeeded = false _hostedLastCompletionRunId = '' _hostedLastSentHash = '' - _toolEventTimes.clear() - _toolEventData.clear() - _toolRunIndex.clear() - _toolEventSeen.clear() - _pendingHistoryPayload = null - _pendingHistoryTs = 0 + _sessionStates.clear() } From 34a7d2453f5f9864be47f80789caad5c55d90cf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 14:21:43 +0800 Subject: [PATCH 307/426] chore: checkpoint before task5 From ec478aeed55c1386b094209f4ae9071ab40d4ec2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 14:31:39 +0800 Subject: [PATCH 308/426] fix: assistant tool safety --- src/pages/assistant.js | 135 ++++++++++------------------------------- 1 file changed, 32 insertions(+), 103 deletions(-) diff --git a/src/pages/assistant.js b/src/pages/assistant.js index 72859180..abc0974f 100644 --- a/src/pages/assistant.js +++ b/src/pages/assistant.js @@ -811,61 +811,7 @@ function playModeTransition(page, modeKey) { function buildSystemPrompt() { return buildSystemPromptCore({ config: _config, soulCache: _soulCache, knowledgeBase: OPENCLAW_KB }) - - let prompt = '' - - // 灵魂移植模式:用 OpenClaw Agent 的身份替代默认人设 - if (_config?.soulSource?.startsWith('openclaw:') && _soulCache) { - prompt += '# 你的身份\n' - if (_soulCache.identity) prompt += _soulCache.identity + '\n\n' - if (_soulCache.soul) prompt += '# 灵魂\n' + _soulCache.soul + '\n\n' - if (_soulCache.user) prompt += '# 你的用户\n' + _soulCache.user + '\n\n' - if (_soulCache.agents) { - // 截断 AGENTS.md 到约 4000 字符以节省 token - const agentsContent = _soulCache.agents.length > 4000 ? _soulCache.agents.slice(0, 4000) + '\n\n[...已截断]' : _soulCache.agents - prompt += '# 操作规则\n' + agentsContent + '\n\n' - } - if (_soulCache.tools) prompt += '# 工具笔记\n' + _soulCache.tools + '\n\n' - if (_soulCache.memory) { - const memContent = _soulCache.memory.length > 3000 ? _soulCache.memory.slice(-3000) : _soulCache.memory - prompt += '# 长期记忆\n' + memContent + '\n\n' - } - if (_soulCache.recentMemories?.length) { - prompt += '# 最近记忆\n' - for (const m of _soulCache.recentMemories) { - const content = m.content.length > 800 ? m.content.slice(0, 800) + '...' : m.content - prompt += `## ${m.date}\n${content}\n\n` - } - } - // 追加 ClawPanel 特有的产品知识和工具说明 - prompt += '\n# ClawPanel 工具能力\n你同时是 ClawPanel 内置助手,拥有以下额外能力:\n' - prompt += '- 执行终端命令、读写文件、浏览目录\n' - prompt += '- 联网搜索和网页抓取\n' - prompt += '- 管理 OpenClaw 配置和服务\n' - prompt += '- 你精通 OpenClaw 的架构、配置、Gateway、Agent 管理\n' - } else { - prompt += getSystemPromptBase() - } - - const modeKey = currentMode() - const mode = MODES[modeKey] - - // 模式说明 - prompt += `\n\n## 当前模式:${mode.label}模式` - - if (modeKey === 'chat') { - prompt += '\n你处于纯聊天模式,没有任何工具可用。请通过文字回答问题,给出具体的命令建议供用户手动执行。' - prompt += '\n如果用户需要你执行操作,建议用户切换到「执行」或「规划」模式。' - } else { - // 规划模式特殊指令 - if (modeKey === 'plan') { - prompt += '\n**你处于规划模式**:可以调用工具读取信息、分析问题,但 **绝对不能修改任何文件**(write_file 已禁用)。' - prompt += '\n你的任务是:分析问题 → 制定方案 → 输出详细步骤,让用户确认后再切换到执行模式操作。' - prompt += '\n即使使用 run_command,也只能执行只读命令(查看、检查、列出),不要执行任何修改操作。' - } - if (modeKey === 'unlimited') { - prompt += '\n**你处于无限模式**:所有工具调用无需用户确认,请高效完成任务。' - } +} prompt += '\n\n### 可用工具' prompt += '\n- **用户交互**: ask_user — 向用户提问(单选/多选/文本),获取结构化回答。需要用户做决定时优先用此工具。' @@ -891,43 +837,6 @@ function buildSystemPrompt() { prompt += '\n- **Linux**: bash,标准 Unix 命令' prompt += '\n- **绝对禁止** cmd.exe 语法(dir、type、findstr、netstat)' prompt += '\n- **一次只执行一条命令**,等结果出来再决定下一步' - prompt += '\n- **不要重复执行相同的命令**' - prompt += '\n\n### 跨平台路径' - prompt += '\n- Windows: `$env:USERPROFILE\\.openclaw\\`' - prompt += '\n- macOS/Linux: `~/.openclaw/`' - prompt += '\n\n### 工具使用原则' - prompt += '\n- 先 get_system_info,再根据 OS 执行正确命令' - prompt += '\n- 优先用 read_file / list_directory / list_processes / check_port 等专用工具,减少 run_command 使用' - prompt += '\n- 主动使用工具,不要只建议用户手动操作' - if (mode.confirmDanger) { - prompt += '\n- 执行破坏性操作前先告知用户' - } - } - - // 注入内置技能列表 - prompt += '\n\n## 内置技能卡片' - prompt += '\n用户可以在欢迎页点击技能卡片快速触发操作。当用户遇到问题时,你也可以主动推荐合适的技能:' - for (const s of BUILTIN_SKILLS) { - prompt += `\n- **${s.name}**(${s.desc})` - } - prompt += '\n\n当用户的需求匹配某个技能时,可以建议用户点击对应的技能卡片,或者你直接按技能的步骤操作。' - - // 注入内置 OpenClaw 知识库 - prompt += '\n\n' + OPENCLAW_KB - - // 注入用户自定义知识库内容 - const kbEnabled = (_config.knowledgeFiles || []).filter(f => f.enabled !== false && f.content) - if (kbEnabled.length > 0) { - prompt += '\n\n## 用户自定义知识库' - prompt += '\n以下是用户提供的参考知识,回答问题时请优先参考这些内容:' - for (const kb of kbEnabled) { - const content = kb.content.length > 5000 ? kb.content.slice(0, 5000) + '\n\n[...内容已截断]' : kb.content - prompt += `\n\n### ${kb.name}\n${content}` - } - } - - return prompt -} // ── 灵魂移植:扫描可用 Agent ── async function scanOpenClawAgents() { @@ -2050,6 +1959,29 @@ function convertToolsForGemini(tools) { }))}] } +// 上下文裁剪:保留图片消息,避免多模态丢失 +function trimContextPreserveImages(messages, maxTokens) { + const trimmed = trimContextCore(messages, maxTokens) + const trimmedSet = new Set(trimmed) + const imageMessages = messages.filter(m => Array.isArray(m.content) && m.content.some(b => b?.type === 'image_url' || b?.type === 'image')) + if (!imageMessages.length) return trimmed + const merged = [...trimmed] + imageMessages.forEach(msg => { + if (trimmedSet.has(msg)) return + merged.push(msg) + }) + if (merged.length <= maxTokens) return merged + const pinned = new Set(imageMessages) + const compact = [] + for (let i = merged.length - 1; i >= 0; i--) { + const msg = merged[i] + if (compact.length >= maxTokens && !pinned.has(msg)) continue + compact.unshift(msg) + if (compact.length >= maxTokens && pinned.has(msg)) continue + } + return compact +} + // 工具调用执行(共用逻辑) async function executeToolWithSafety(toolName, args, tcForConfirm) { let result = '', approved = true @@ -2090,21 +2022,18 @@ async function callAIWithTools(sessionId, messages, onStatus, onToolProgress) { return result }, execTool: async ({ name, args }) => { - toolHistory.push({ name, args, result: null, approved: true, pending: true }) + const entry = { name, args, result: null, approved: true, pending: true } + toolHistory.push(entry) if (typeof onToolProgress === 'function') onToolProgress(toolHistory) - let result = '' - try { - result = await executeTool(name, args) - } catch (err) { - result = `执行失败: ${typeof err === 'string' ? err : err.message || JSON.stringify(err)}` - } + const execResult = await executeToolWithSafety(name, args, { function: { name, arguments: JSON.stringify(args || {}) } }) const last = toolHistory[toolHistory.length - 1] if (last) { - last.result = result + last.result = execResult.result + last.approved = execResult.approved last.pending = false } if (typeof onToolProgress === 'function') onToolProgress(toolHistory) - return result + return execResult.result }, } if (typeof onStatus === 'function') onStatus('AI 思考中...') @@ -3381,7 +3310,7 @@ async function sendMessageDirect(text) { // 准备 AI 上下文(只保留 role + content,剔除内部字段) // 过滤掉空的 AI 回复,避免污染上下文导致模型也返回空 - const contextMessages = trimContextCore( + const contextMessages = trimContextPreserveImages( session.messages .filter(m => { if (m.role === 'user') return true @@ -3533,7 +3462,7 @@ async function retryAIResponse(session) { if (!session?.id) return if (getStreaming(session.id)) return - const contextMessages = trimContextCore( + const contextMessages = trimContextPreserveImages( session.messages.filter(m => m.role === 'user' || m.role === 'assistant'), MAX_CONTEXT_TOKENS ) From 2d61adbb1e353195e56e8e75747cd4e1716a44fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 14:34:28 +0800 Subject: [PATCH 309/426] chore: checkpoint before task6 From 489792e0dbd7c5304dcaad4da0778e414152051d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 14:40:34 +0800 Subject: [PATCH 310/426] fix: setup consistency and escaping --- src/pages/setup.js | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/pages/setup.js b/src/pages/setup.js index 18186437..e8008e8e 100644 --- a/src/pages/setup.js +++ b/src/pages/setup.js @@ -119,7 +119,7 @@ function renderSteps(page, { node, git, cliOk, config, version, cliInfo }) { const stepsEl = page.querySelector('#setup-steps') const nodeOk = node.installed const gitOk = git?.installed || false - const allOk = nodeOk && config.installed + const allOk = cliOk && config.installed let html = '' @@ -130,7 +130,7 @@ function renderSteps(page, { node, git, cliOk, config, version, cliInfo }) { ${stepIcon(nodeOk)} Node.js 环境
${nodeOk - ? `

已安装 ${node.version || ''}

` + ? `

已安装 ${escapeHtml(node.version || '')}

` : `

OpenClaw 基于 Node.js 运行,请先安装。

@@ -160,12 +160,12 @@ function renderSteps(page, { node, git, cliOk, config, version, cliInfo }) { // 第二步:Git html += ` -
+
${stepIcon(gitOk)} Git 版本管理
${gitOk - ? `

已安装 ${git.version || ''}

+ ? `

已安装 ${escapeHtml(git.version || '')}

✅ 已自动配置 Git 使用 HTTPS(避免 SSH 连接问题)

` : `

部分依赖需要 Git 下载源码。点击下方按钮自动安装,如果失败请手动安装。 @@ -184,7 +184,7 @@ function renderSteps(page, { node, git, cliOk, config, version, cliInfo }) { // 第三步:OpenClaw CLI html += ` -

+
${stepIcon(cliOk)} OpenClaw CLI
@@ -203,7 +203,7 @@ function renderSteps(page, { node, git, cliOk, config, version, cliInfo }) { : ''} ${version?.ahead_of_recommended && version?.recommended ? `
- 检测到当前本地 OpenClaw ${version.current || ''} 高于当前面板推荐稳定版 ${version.recommended},可能存在兼容或稳定性风险。建议稍后到「关于」页回退到推荐版。 + 检测到当前本地 OpenClaw ${escapeHtml(version.current || '')} 高于当前面板推荐稳定版 ${escapeHtml(version.recommended)},可能存在兼容或稳定性风险。建议稍后到「关于」页回退到推荐版。
` : ''}` : (config.installed ? `

已检测到配置文件,但 CLI 未安装

` : renderInstallSection()) @@ -217,7 +217,7 @@ function renderSteps(page, { node, git, cliOk, config, version, cliInfo }) { ${stepIcon(config.installed)} 配置文件
${config.installed - ? `

配置文件位于 ${config.path || ''}

` + ? `

配置文件位于 ${escapeHtml(config.path || '')}

` : `

配置文件不存在,点击下方按钮自动创建默认配置。

@@ -515,9 +515,9 @@ function bindEvents(page, nodeOk, detectState) { resultEl.innerHTML = results.map(r => `
- ${r.path} - ${r.version} - + ${escapeHtml(r.path)} + ${escapeHtml(r.version)} +
` ).join('') resultEl.querySelectorAll('.btn-use-path').forEach(b => { @@ -548,7 +548,7 @@ function bindEvents(page, nodeOk, detectState) { const result = await api.checkNodeAtPath(dir) if (result.installed) { await api.saveCustomNodePath(dir) - resultEl.innerHTML = `✓ 找到 Node.js ${result.version},路径已保存` + resultEl.innerHTML = `✓ 找到 Node.js ${escapeHtml(result.version)},路径已保存` toast('Node.js 路径已保存,正在重新检测...', 'success') setTimeout(() => runDetect(page), 300) } else { @@ -592,11 +592,15 @@ function bindEvents(page, nodeOk, detectState) { // 一键安装 const installBtn = page.querySelector('#btn-install') - if (!installBtn || !nodeOk) return + if (!installBtn) return installBtn.addEventListener('click', async () => { const source = page.querySelector('input[name="install-source"]:checked')?.value || 'chinese' const method = (source === 'official') ? 'npm' : (page.querySelector('#install-method')?.value || 'auto') + if (method === 'npm' && !nodeOk) { + toast('需要先安装 Node.js 才能使用 npm 安装', 'warning') + return + } const registry = page.querySelector('#registry-select')?.value const modal = showUpgradeModal('安装 OpenClaw') let unlistenLog, unlistenProgress From 638149b576218a7465977c68dc5b5c0f7997698f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 14:43:39 +0800 Subject: [PATCH 311/426] chore: checkpoint before task7 From 39040dd1c131ce94669b6d0677089a996ef3342d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 14:47:10 +0800 Subject: [PATCH 312/426] fix: openclaw cli validation --- src/pages/settings.js | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/pages/settings.js b/src/pages/settings.js index 5bf397f1..195a9d94 100644 --- a/src/pages/settings.js +++ b/src/pages/settings.js @@ -437,7 +437,9 @@ async function loadOpenclawCli(page) { api.readPanelConfig(), api.getServicesStatus(), ]) - const svc = Array.isArray(services) ? services[0] : null + const svc = Array.isArray(services) + ? services.find(s => s.label === 'ai.openclaw.gateway' || s.id === 'ai.openclaw.gateway' || s.name === 'ai.openclaw.gateway' || s.label === 'openclaw' || s.id === 'openclaw') + : null const detectedPath = svc?.cli_path || '' const detectedVersion = svc?.cli_version || '' const detectedSource = svc?.cli_source || '' @@ -473,6 +475,13 @@ async function handleOpenclawSave(page) { const input = page.querySelector('[data-name="openclaw-path"]') const value = String(input?.value || '').trim() const cfg = await api.readPanelConfig() + if (!value) { + delete cfg.openclawPath + await api.writePanelConfig(cfg) + toast('路径为空,已清除覆盖', 'info') + await loadOpenclawCli(page) + return + } cfg.openclawPath = value await api.writePanelConfig(cfg) toast('OpenClaw 路径已保存', 'success') @@ -488,7 +497,11 @@ async function handleOpenclawClear(page) { } async function handleOpenclawSetup() { - const cfg = await api.readPanelConfig().catch(() => ({})) - await api.writePanelConfig({ ...cfg, forceSetup: true, skipSetup: false }).catch(() => {}) - window.location.hash = '#/setup' + try { + const cfg = await api.readPanelConfig().catch(() => ({})) + await api.writePanelConfig({ ...cfg, forceSetup: true, skipSetup: false }) + window.location.hash = '#/setup' + } catch (e) { + toast('进入初始化设置失败: ' + (e?.message || e), 'error') + } } From af355c68a3fb68a09c51929cc8692233485adeb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 14:50:06 +0800 Subject: [PATCH 313/426] chore: checkpoint before task8 From ad15a850ce0bd12d6db588646d1aeb0e0a6dfea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 14:53:05 +0800 Subject: [PATCH 314/426] fix: communication config save --- src/pages/communication.js | 101 +++++++++++++++++++++++++++---------- 1 file changed, 75 insertions(+), 26 deletions(-) diff --git a/src/pages/communication.js b/src/pages/communication.js index ba70b58b..89611762 100644 --- a/src/pages/communication.js +++ b/src/pages/communication.js @@ -71,8 +71,8 @@ async function saveConfig() { const btn = _page?.querySelector('#btn-comm-save') if (btn) { btn.disabled = true; btn.textContent = '保存中...' } try { - // 从当前表单收集值到 _config - collectCurrentTab() + // 从全部表单收集值到 _config + collectAllTabs() await api.writeOpenclawConfig(_config) _dirty = false toast('配置已保存,正在重载 Gateway...', 'info') @@ -94,6 +94,15 @@ function collectCurrentTab() { else if (activeTab === 'approvals') collectApprovals() } +function collectAllTabs() { + if (!_page) return + collectMessages() + collectBroadcast() + collectCommands() + collectHooks() + collectApprovals() +} + // ── Tab 渲染 ── function renderTab(page, tab) { @@ -198,29 +207,51 @@ function collectMessages() { if (!_config.messages) _config.messages = {} const m = _config.messages - m.responsePrefix = v('msg-responsePrefix') - m.ackReaction = v('msg-ackReaction') - m.ackReactionScope = v('msg-ackReactionScope') || undefined - m.removeAckAfterReply = c('msg-removeAckAfterReply') || undefined - m.suppressToolErrors = c('msg-suppressToolErrors') || undefined - - if (!m.statusReactions) m.statusReactions = {} - m.statusReactions.enabled = c('msg-sr-enabled') || undefined + const responsePrefix = v('msg-responsePrefix') + const ackReaction = v('msg-ackReaction') + const ackScope = v('msg-ackReactionScope') || undefined + const removeAck = c('msg-removeAckAfterReply') + const suppressErrors = c('msg-suppressToolErrors') + const srEnabled = c('msg-sr-enabled') + + if (responsePrefix) m.responsePrefix = responsePrefix + else delete m.responsePrefix + if (ackReaction) m.ackReaction = ackReaction + else delete m.ackReaction + if (ackScope) m.ackReactionScope = ackScope + else delete m.ackReactionScope + if (removeAck === true) m.removeAckAfterReply = true + else delete m.removeAckAfterReply + if (suppressErrors === true) m.suppressToolErrors = true + else delete m.suppressToolErrors + + if (srEnabled === true) { + if (!m.statusReactions) m.statusReactions = {} + m.statusReactions.enabled = true + } else if (m.statusReactions) { + delete m.statusReactions.enabled + } const debounceMs = n('msg-debounceMs') if (debounceMs != null) { if (!m.inbound) m.inbound = {} m.inbound.debounceMs = debounceMs + } else if (m.inbound) { + delete m.inbound.debounceMs } const cap = n('msg-queueCap') if (cap != null) { if (!m.queue) m.queue = {} m.queue.cap = cap + } else if (m.queue) { + delete m.queue.cap } const groupHistoryLimit = n('msg-groupHistoryLimit') if (groupHistoryLimit != null) { if (!m.groupChat) m.groupChat = {} m.groupChat.historyLimit = groupHistoryLimit + } else if (m.groupChat) { + delete m.groupChat.historyLimit } } @@ -249,10 +280,9 @@ function renderBroadcast(el) { function collectBroadcast() { if (!_config) return const strategy = _page?.querySelector('#bc-strategy')?.value - if (strategy) { - if (!_config.broadcast) _config.broadcast = {} - _config.broadcast.strategy = strategy - } + if (!_config.broadcast) _config.broadcast = {} + if (strategy) _config.broadcast.strategy = strategy + else delete _config.broadcast.strategy } // ── 命令配置 ── @@ -291,11 +321,16 @@ function collectCommands() { const c = (id) => _page?.querySelector('#' + id)?.checked if (!_config.commands) _config.commands = {} const cmd = _config.commands - cmd.text = c('cmd-text') === false ? false : undefined - cmd.bash = c('cmd-bash') || undefined - cmd.config = c('cmd-config') || undefined - cmd.debug = c('cmd-debug') || undefined - cmd.restart = c('cmd-restart') === false ? false : undefined + const textEnabled = c('cmd-text') + const bashEnabled = c('cmd-bash') + const configEnabled = c('cmd-config') + const debugEnabled = c('cmd-debug') + const restartEnabled = c('cmd-restart') + cmd.text = textEnabled === false ? false : undefined + cmd.bash = bashEnabled === true ? true : undefined + cmd.config = configEnabled === true ? true : undefined + cmd.debug = debugEnabled === true ? true : undefined + cmd.restart = restartEnabled === false ? false : undefined const native = _page?.querySelector('#cmd-native')?.value cmd.native = native === 'true' ? true : native === 'false' ? false : 'auto' } @@ -342,11 +377,20 @@ function collectHooks() { const c = (id) => _page?.querySelector('#' + id)?.checked if (!_config.hooks) _config.hooks = {} const h = _config.hooks - h.enabled = c('hooks-enabled') || undefined - h.path = v('hooks-path') - h.token = v('hooks-token') - h.defaultSessionKey = v('hooks-defaultSessionKey') - h.maxBodyBytes = n('hooks-maxBodyBytes') + const enabled = c('hooks-enabled') + h.enabled = enabled === true ? true : undefined + const path = v('hooks-path') + if (path) h.path = path + else delete h.path + const token = v('hooks-token') + if (token) h.token = token + else delete h.token + const defaultKey = v('hooks-defaultSessionKey') + if (defaultKey) h.defaultSessionKey = defaultKey + else delete h.defaultSessionKey + const maxBody = n('hooks-maxBodyBytes') + if (maxBody != null) h.maxBodyBytes = maxBody + else delete h.maxBodyBytes } // ── 执行审批 ── @@ -381,8 +425,13 @@ function collectApprovals() { if (!_config.approvals) _config.approvals = {} if (!_config.approvals.exec) _config.approvals.exec = {} const a = _config.approvals.exec - a.enabled = c('approvals-enabled') || undefined - a.mode = v('approvals-mode') || undefined + const enabled = c('approvals-enabled') + const mode = v('approvals-mode') + const forwardExec = c('approvals-forwardExec') + a.enabled = enabled === true ? true : undefined + if (mode) a.mode = mode + else delete a.mode + a.forwardExec = forwardExec === true ? true : undefined } // ── 工具函数 ── From c9760814115c37af1b7ad2c709dbfc71d6c50c88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 14:54:18 +0800 Subject: [PATCH 315/426] chore: checkpoint before task9 From 2b3cb01b41b5121e001c4720419e2cc73366f350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 14:59:55 +0800 Subject: [PATCH 316/426] fix: gateway config safety --- src/pages/gateway.js | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/src/pages/gateway.js b/src/pages/gateway.js index e7c9ac00..5998c877 100644 --- a/src/pages/gateway.js +++ b/src/pages/gateway.js @@ -98,8 +98,8 @@ function renderConfig(page, state) { 谁能访问
-
-
+
@@ -154,10 +164,10 @@ function renderConfig(page, state) {
- +
-
${_isSecretRef(gw.auth?.token) ? '当前 Token 通过环境变量/引用配置,如需改为明文请清空后输入' : '设置后,应用调用时需要带上这个密钥才能通过。如果选了「局域网共享」,强烈建议设置'}
+
${_isSecretRef(gw.auth?.token) ? '当前 Token 通过环境变量/引用配置。如需改为明文,请清空后输入新 Token。' : '设置后,应用调用时需要带上这个密钥才能通过。如果选了「局域网共享」,强烈建议设置'}
@@ -277,8 +287,12 @@ function bindConfigEvents(el) { const mode = radio.value const tokenGroup = el.querySelector('#gw-auth-token-group') const passwordGroup = el.querySelector('#gw-auth-password-group') + const tokenInput = el.querySelector('#gw-token') + const passwordInput = el.querySelector('#gw-password') if (tokenGroup) tokenGroup.style.display = mode === 'token' ? '' : 'none' if (passwordGroup) passwordGroup.style.display = mode === 'password' ? '' : 'none' + if (mode === 'token' && passwordInput) passwordInput.value = '' + if (mode === 'password' && tokenInput) tokenInput.value = '' }) }) @@ -293,7 +307,12 @@ function bindConfigEvents(el) { } async function saveConfig(page, state) { - const port = parseInt(page.querySelector('#gw-port')?.value) || 18789 + const portValue = page.querySelector('#gw-port')?.value + const port = parseInt(portValue, 10) + if (!Number.isFinite(port) || port < 1 || port > 65535) { + toast('端口号无效,请输入 1-65535 的数字', 'error') + return + } const bindRadio = page.querySelector('input[name="gw-bind"]:checked') const bind = bindRadio?.value || 'loopback' const mode = 'local' @@ -305,9 +324,12 @@ async function saveConfig(page, state) { // 兼容 SecretRef:如果用户没改 token 显示值,保留原始对象 let resolvedToken = authToken - if (_isSecretRef(state._origToken) && authToken === _tokenDisplayStr(state._origToken)) { + if (_isSecretRef(state._origToken) && !authToken) { resolvedToken = state._origToken } + if (authMode === 'password') { + resolvedToken = '' + } const auth = authMode === 'password' ? { mode: 'password', password: authPassword } : resolvedToken ? { mode: 'token', token: resolvedToken } : {} From b051993dedd6f3021e822684a7bec28e17dd02f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 15:01:41 +0800 Subject: [PATCH 317/426] chore: checkpoint before task10 From fb48671db2e9975a05db9a0a1ea440533e5452c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 15:06:03 +0800 Subject: [PATCH 318/426] fix: app-state stability --- src/lib/app-state.js | 46 +++++++++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/src/lib/app-state.js b/src/lib/app-state.js index 90039cfe..48c71878 100644 --- a/src/lib/app-state.js +++ b/src/lib/app-state.js @@ -16,15 +16,23 @@ let _platform = '' // 'macos' | 'win32' | ... let _deployMode = 'local' // 'local' | 'docker' let _inDocker = false let _dockerAvailable = false -let _listeners = [] -let _gwListeners = [] +let _listeners = new Set() +let _gwListeners = new Set() let _gwStopCount = 0 // 连续检测到"停止"的次数,防抖用 let _isUpgrading = false // 升级/切换版本期间,阻止 setup 跳转 let _userStopped = false // 用户主动停止,不自动拉起 let _autoRestartCount = 0 // 自动重启次数 let _lastRestartTime = 0 // 上次重启时间 let _gatewayRunningSince = 0 // Gateway 最近一次进入稳定运行状态的时间 -let _guardianListeners = [] // 守护放弃时的回调 +let _guardianListeners = new Set() // 守护放弃时的回调 + +function _emit(listeners, payload) { + listeners.forEach((fn) => { + queueMicrotask(() => { + try { fn(payload) } catch (e) { console.error('[app-state] listener error:', e) } + }) + }) +} /** openclaw 是否就绪(CLI 已安装 + 配置文件存在) */ export function isOpenclawReady() { @@ -50,8 +58,8 @@ export function resetAutoRestart() { /** 监听守护放弃事件(连续重启失败后触发,UI 可弹出恢复选项) */ export function onGuardianGiveUp(fn) { - _guardianListeners.push(fn) - return () => { _guardianListeners = _guardianListeners.filter(cb => cb !== fn) } + _guardianListeners.add(fn) + return () => { _guardianListeners.delete(fn) } } /** Gateway 是否正在运行 */ @@ -74,14 +82,14 @@ export function isDockerAvailable() { return _dockerAvailable } /** 实例管理 */ let _activeInstance = { id: 'local', name: '本机', type: 'local' } -let _instanceListeners = [] +let _instanceListeners = new Set() export function getActiveInstance() { return _activeInstance } export function isLocalInstance() { return _activeInstance.type === 'local' } export function onInstanceChange(fn) { - _instanceListeners.push(fn) - return () => { _instanceListeners = _instanceListeners.filter(cb => cb !== fn) } + _instanceListeners.add(fn) + return () => { _instanceListeners.delete(fn) } } export async function switchInstance(id) { @@ -89,7 +97,13 @@ export async function switchInstance(id) { await api.instanceSetActive(id) const data = await api.instanceList() _activeInstance = data.instances.find(i => i.id === id) || data.instances[0] - _instanceListeners.forEach(fn => { try { fn(_activeInstance) } catch {} }) + _gatewayRunning = false + _gwStopCount = 0 + _autoRestartCount = 0 + _lastRestartTime = 0 + _gatewayRunningSince = 0 + _userStopped = false + _emit(_instanceListeners, _activeInstance) } export async function loadActiveInstance() { @@ -103,8 +117,8 @@ export async function loadActiveInstance() { /** 监听 Gateway 状态变化 */ export function onGatewayChange(fn) { - _gwListeners.push(fn) - return () => { _gwListeners = _gwListeners.filter(cb => cb !== fn) } + _gwListeners.add(fn) + return () => { _gwListeners.delete(fn) } } /** 检测 openclaw 安装状态 */ @@ -134,7 +148,7 @@ export async function detectOpenclawStatus() { } catch { _openclawReady = false } - _listeners.forEach(fn => { try { fn(_openclawReady) } catch {} }) + _emit(_listeners, _openclawReady) return _openclawReady } @@ -153,7 +167,7 @@ function _setGatewayRunning(val) { } else if (!val) { _gatewayRunningSince = 0 } - _gwListeners.forEach(fn => { try { fn(val) } catch {} }) + _emit(_gwListeners, val) } } @@ -169,7 +183,7 @@ async function _tryAutoRestart() { if (decision.action === 'give_up') { console.warn('[guardian] Gateway 已达到自动重启上限,停止守护,请手动检查') - _guardianListeners.forEach(fn => { try { fn() } catch {} }) + _emit(_guardianListeners, undefined) return } @@ -228,6 +242,6 @@ export function stopGatewayPoll() { /** 监听状态变化 */ export function onReadyChange(fn) { - _listeners.push(fn) - return () => { _listeners = _listeners.filter(cb => cb !== fn) } + _listeners.add(fn) + return () => { _listeners.delete(fn) } } From 38bbde90b5d52a087f78d168efe8cf036bad5260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 15:13:21 +0800 Subject: [PATCH 319/426] chore: checkpoint before task8 fix From 53322d11161d6960b30c0ac94a295d8380d8ebbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 15:14:53 +0800 Subject: [PATCH 320/426] fix: approvals forwardExec toggle --- src/pages/communication.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/communication.js b/src/pages/communication.js index 89611762..d3342cf7 100644 --- a/src/pages/communication.js +++ b/src/pages/communication.js @@ -410,7 +410,7 @@ function renderApprovals(el) {
- ${toggleRow('approvals-forwardExec', '转发执行请求', '将 exec 审批请求转发到渠道(默认关闭,低风险场景可开启)', !!a.enabled)} + ${toggleRow('approvals-forwardExec', '转发执行请求', '将 exec 审批请求转发到渠道(默认关闭,低风险场景可开启)', !!a.forwardExec)}
` el.querySelectorAll('input, select').forEach(inp => { From fdc20c0b0b774d85e907e477e7cf9937819b8253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 15:38:30 +0800 Subject: [PATCH 321/426] chore: checkpoint before audit fixes 2 From 88e53ab3ef523ea4da3e180f62e7e50235439777 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 15:41:17 +0800 Subject: [PATCH 322/426] fix: setup gateway and instance emit --- src/lib/app-state.js | 2 ++ src/pages/gateway.js | 18 +++++++++++++++--- src/pages/setup.js | 10 ++++++---- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/lib/app-state.js b/src/lib/app-state.js index 48c71878..47c4ea02 100644 --- a/src/lib/app-state.js +++ b/src/lib/app-state.js @@ -97,12 +97,14 @@ export async function switchInstance(id) { await api.instanceSetActive(id) const data = await api.instanceList() _activeInstance = data.instances.find(i => i.id === id) || data.instances[0] + const wasRunning = _gatewayRunning _gatewayRunning = false _gwStopCount = 0 _autoRestartCount = 0 _lastRestartTime = 0 _gatewayRunningSince = 0 _userStopped = false + _emit(_gwListeners, false) _emit(_instanceListeners, _activeInstance) } diff --git a/src/pages/gateway.js b/src/pages/gateway.js index 5998c877..e60a0811 100644 --- a/src/pages/gateway.js +++ b/src/pages/gateway.js @@ -309,12 +309,16 @@ function bindConfigEvents(el) { async function saveConfig(page, state) { const portValue = page.querySelector('#gw-port')?.value const port = parseInt(portValue, 10) - if (!Number.isFinite(port) || port < 1 || port > 65535) { - toast('端口号无效,请输入 1-65535 的数字', 'error') + if (!Number.isFinite(port) || port < 1024 || port > 65535) { + toast('端口号无效,请输入 1024-65535 的数字', 'error') return } const bindRadio = page.querySelector('input[name="gw-bind"]:checked') const bind = bindRadio?.value || 'loopback' + if (!['loopback', 'lan', 'all'].includes(bind)) { + toast('访问范围无效,请重新选择', 'error') + return + } const mode = 'local' const authModeRadio = page.querySelector('input[name="gw-auth-mode"]:checked') const authMode = authModeRadio?.value || 'token' @@ -330,9 +334,17 @@ async function saveConfig(page, state) { if (authMode === 'password') { resolvedToken = '' } + if (authMode === 'password' && !authPassword.trim()) { + toast('密码不能为空,请填写后保存', 'error') + return + } + if (authMode !== 'password' && !resolvedToken) { + toast('Token 不能为空,请填写后保存', 'error') + return + } const auth = authMode === 'password' ? { mode: 'password', password: authPassword } - : resolvedToken ? { mode: 'token', token: resolvedToken } : {} + : { mode: 'token', token: resolvedToken } const toolsProfile = page.querySelector('input[name="gw-tools-profile"]:checked')?.value || 'full' const sessionsVisibility = page.querySelector('#gw-sessions-visibility')?.value || 'all' diff --git a/src/pages/setup.js b/src/pages/setup.js index e8008e8e..8b9cc4b8 100644 --- a/src/pages/setup.js +++ b/src/pages/setup.js @@ -71,13 +71,15 @@ async function runDetect(page) { 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 + const services = (clawRes.status === 'fulfilled' && Array.isArray(clawRes.value)) ? clawRes.value : [] + const service = services.find(s => { + const id = s?.id || s?.name || s?.label || '' + return id === 'ai.openclaw.gateway' || id === 'openclaw' + }) || services[0] || null + const cliOk = !!service && service?.cli_installed !== false let config = configRes.status === 'fulfilled' ? configRes.value : { installed: false } const version = versionRes.status === 'fulfilled' ? versionRes.value : null const panelCfg = await api.readPanelConfig().catch(() => ({})) - const service = (clawRes.status === 'fulfilled' && Array.isArray(clawRes.value)) ? clawRes.value[0] : null const cliInfo = { path: service?.cli_path || '', version: service?.cli_version || '', From 501c6ca94c22acd380fd5e2dc6649909b8632880 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 15:41:24 +0800 Subject: [PATCH 323/426] chore: checkpoint before assistant/ws-client fixes From 1ed287f4c1f36a1966fcba7e33d93a256e00390e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 15:51:36 +0800 Subject: [PATCH 324/426] fix: assistant safety and context --- src/pages/assistant.js | 50 ++++++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/src/pages/assistant.js b/src/pages/assistant.js index abc0974f..84c5e739 100644 --- a/src/pages/assistant.js +++ b/src/pages/assistant.js @@ -523,7 +523,17 @@ const CRITICAL_PATTERNS = [ /curl\s+.*\|\s*(sudo\s+)?bash/i, // curl | bash /wget\s+.*\|\s*(sudo\s+)?bash/i, // wget | bash /npm\s+publish/i, // npm publish + /git\s+push\s+.*--force-with-lease/i, // git push --force-with-lease /git\s+push\s+.*--force/i, // git push --force + /git\s+push\s+.*\s-f(\s|$)/i, // git push -f + /remove-item\s+.*-recurse.*-force.*[a-z]:\\/i, // Remove-Item -Recurse -Force C:\ + /remove-item\s+-recurse/i, // Remove-Item -Recurse + /rmdir\s+\/s\s+\/q/i, // rmdir /s /q + /rd\s+\/s\s+\/q/i, // rd /s /q + /del\s+\/s\s+\/q/i, // del /s /q + /diskpart/i, // diskpart + /bcdedit/i, // bcdedit + /reg\s+delete/i, // reg delete ] function isCriticalCommand(command) { @@ -1962,26 +1972,40 @@ function convertToolsForGemini(tools) { // 上下文裁剪:保留图片消息,避免多模态丢失 function trimContextPreserveImages(messages, maxTokens) { const trimmed = trimContextCore(messages, maxTokens) - const trimmedSet = new Set(trimmed) const imageMessages = messages.filter(m => Array.isArray(m.content) && m.content.some(b => b?.type === 'image_url' || b?.type === 'image')) if (!imageMessages.length) return trimmed + const maxPinned = Math.min(4, maxTokens) + const pinnedList = imageMessages.slice(-maxPinned) + const pinnedSet = new Set(pinnedList) + const trimmedSet = new Set(trimmed) const merged = [...trimmed] - imageMessages.forEach(msg => { + pinnedList.forEach(msg => { if (trimmedSet.has(msg)) return merged.push(msg) }) if (merged.length <= maxTokens) return merged - const pinned = new Set(imageMessages) const compact = [] for (let i = merged.length - 1; i >= 0; i--) { const msg = merged[i] - if (compact.length >= maxTokens && !pinned.has(msg)) continue + if (compact.length >= maxTokens && !pinnedSet.has(msg)) continue compact.unshift(msg) - if (compact.length >= maxTokens && pinned.has(msg)) continue } return compact } +function buildContextMessages(session) { + return trimContextPreserveImages( + session.messages + .filter(m => { + if (m.role === 'user') return true + if (m.role === 'assistant') return m.content && m.content.length > 0 + return false + }) + .map(m => ({ role: m.role, content: m.content })), + MAX_CONTEXT_TOKENS + ) +} + // 工具调用执行(共用逻辑) async function executeToolWithSafety(toolName, args, tcForConfirm) { let result = '', approved = true @@ -3310,16 +3334,7 @@ async function sendMessageDirect(text) { // 准备 AI 上下文(只保留 role + content,剔除内部字段) // 过滤掉空的 AI 回复,避免污染上下文导致模型也返回空 - const contextMessages = trimContextPreserveImages( - session.messages - .filter(m => { - if (m.role === 'user') return true - if (m.role === 'assistant') return m.content && m.content.length > 0 - return false - }) - .map(m => ({ role: m.role, content: m.content })), - MAX_CONTEXT_TOKENS - ) + const contextMessages = buildContextMessages(session) // 添加空 AI 消息占位 const aiMsg = { role: 'assistant', content: '', ts: Date.now() } @@ -3462,10 +3477,7 @@ async function retryAIResponse(session) { if (!session?.id) return if (getStreaming(session.id)) return - const contextMessages = trimContextPreserveImages( - session.messages.filter(m => m.role === 'user' || m.role === 'assistant'), - MAX_CONTEXT_TOKENS - ) + const contextMessages = buildContextMessages(session) const aiMsg = { role: 'assistant', content: '', ts: Date.now() } session.messages.push(aiMsg) From 2e14146ed75b8b292bbc5aab56af6f1dbd447b47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 15:55:08 +0800 Subject: [PATCH 325/426] chore: checkpoint before assistant prompt fix From f9ed731cbb24758560ed968b9e8f8afb255aaebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 16:02:20 +0800 Subject: [PATCH 326/426] fix: remove stray assistant prompt block --- src/pages/assistant.js | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/src/pages/assistant.js b/src/pages/assistant.js index 84c5e739..24c816bf 100644 --- a/src/pages/assistant.js +++ b/src/pages/assistant.js @@ -823,30 +823,6 @@ function buildSystemPrompt() { return buildSystemPromptCore({ config: _config, soulCache: _soulCache, knowledgeBase: OPENCLAW_KB }) } - prompt += '\n\n### 可用工具' - prompt += '\n- **用户交互**: ask_user — 向用户提问(单选/多选/文本),获取结构化回答。需要用户做决定时优先用此工具。' - prompt += '\n- **系统信息**: get_system_info — 获取 OS 类型、架构、主目录等。**在执行任何命令前必须先调用此工具**。' - prompt += '\n- **进程/端口**: list_processes(按名称过滤)、check_port(检测端口占用)' - prompt += '\n- **终端**: run_command — 执行 shell 命令' - if (mode.readOnly) { - prompt += '\n- **文件**: read_file、list_directory(只读,write_file 已禁用)' - } else { - prompt += '\n- **文件**: read_file、write_file、list_directory' - } - - prompt += '\n\n### 终端命令规范(极其重要)' - prompt += '\n- **Windows**: 终端是 **PowerShell**,必须使用 PowerShell 语法:' - prompt += '\n - 列目录: `Get-ChildItem` 或 `ls`(不要用 `dir`)' - prompt += '\n - 看文件: `Get-Content` 或 `cat`(不要用 `type`)' - prompt += '\n - 查进程: `Get-Process | Where-Object { $_.Name -like \"*openclaw*\" }`' - prompt += '\n - 查端口: `Get-NetTCPConnection -LocalPort 18789`' - prompt += '\n - 文件尾: `Get-Content file.log -Tail 50`' - prompt += '\n - 搜内容: `Select-String -Path file.log -Pattern \"ERROR\"`' - prompt += '\n - 环境变量: `$env:USERPROFILE`(不要用 `%USERPROFILE%`)' - prompt += '\n- **macOS**: zsh,标准 Unix 命令' - prompt += '\n- **Linux**: bash,标准 Unix 命令' - prompt += '\n- **绝对禁止** cmd.exe 语法(dir、type、findstr、netstat)' - prompt += '\n- **一次只执行一条命令**,等结果出来再决定下一步' // ── 灵魂移植:扫描可用 Agent ── async function scanOpenClawAgents() { From 78fa671702d0ac709c8b78fb625d9e3d2c3009af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 16:09:37 +0800 Subject: [PATCH 327/426] chore: checkpoint before heartbeat history restore From ed3f52a9979550212548fbc6c4ea6324393e9545 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 16:11:31 +0800 Subject: [PATCH 328/426] fix: heartbeat fetch history limit 10 --- src/lib/ws-client.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/ws-client.js b/src/lib/ws-client.js index 605ce11b..1aa337a4 100644 --- a/src/lib/ws-client.js +++ b/src/lib/ws-client.js @@ -430,8 +430,9 @@ export class WsClient { try { const id = uuid() this._ws.send(JSON.stringify({ type: 'req', id, method: 'node.list', params: {} })) + const historyId = uuid() + this._ws.send(JSON.stringify({ type: 'req', id: historyId, method: 'chat.history', params: { sessionKey: this._sessionKey, limit: 10 } })) } catch {} - // ping 只保活,不拉取历史 } }, PING_INTERVAL) } From 8ac87a1cc661e2cf9a353371ca182687ef0554cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 16:15:37 +0800 Subject: [PATCH 329/426] chore: checkpoint before chat model select width From 5a2032761f50ef3c991f644942a35e222c34c8c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 16:17:06 +0800 Subject: [PATCH 330/426] fix: chat model select flex width --- src/pages/chat.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/chat.js b/src/pages/chat.js index 1756e3fb..7d00b4c3 100644 --- a/src/pages/chat.js +++ b/src/pages/chat.js @@ -261,11 +261,11 @@ export async function render() { 聊天
-
- -
From 3074bcb1eafd1ec82079f529876ae63ad4f2a970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 16:27:12 +0800 Subject: [PATCH 331/426] chore: checkpoint before hosted agent fixes From a50c66f2f17b54a370e4d75605ad74d7dd7d3f65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 16:41:47 +0800 Subject: [PATCH 332/426] chore: checkpoint before heartbeat emitEvent From 0df0098b42680cff283f08de573d270de0e03192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Wed, 18 Mar 2026 16:43:47 +0800 Subject: [PATCH 333/426] fix: heartbeat history emit event --- src/lib/ws-client.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/lib/ws-client.js b/src/lib/ws-client.js index 1aa337a4..3441f321 100644 --- a/src/lib/ws-client.js +++ b/src/lib/ws-client.js @@ -430,8 +430,7 @@ export class WsClient { try { const id = uuid() this._ws.send(JSON.stringify({ type: 'req', id, method: 'node.list', params: {} })) - const historyId = uuid() - this._ws.send(JSON.stringify({ type: 'req', id: historyId, method: 'chat.history', params: { sessionKey: this._sessionKey, limit: 10 } })) + this.request('chat.history', { sessionKey: this._sessionKey, limit: 10 }, { emitEvent: true }).catch(() => {}) } catch {} } }, PING_INTERVAL) From fa209edb135c8318730d6227ee1a3670c4292089 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com> Date: Thu, 19 Mar 2026 05:31:19 +0800 Subject: [PATCH 334/426] fix: unify openclaw cli resolution and surface consistent diagnostics --- src-tauri/src/commands/config.rs | 45 +++++-- src-tauri/src/commands/mod.rs | 8 +- src-tauri/src/commands/service.rs | 90 +++++--------- src-tauri/src/models/types.rs | 6 + src-tauri/src/utils.rs | 199 ++++++++++++++++++++++++++++-- src/lib/openclaw-cli-display.js | 44 +++++++ src/pages/chat-debug.js | 56 +++++---- src/pages/settings.js | 40 ++++-- src/pages/setup.js | 21 ++-- 9 files changed, 386 insertions(+), 123 deletions(-) create mode 100644 src/lib/openclaw-cli-display.js diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 43fce79f..e4c9e82b 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -222,6 +222,16 @@ fn recommended_version_for(source: &str) -> Option { } } +#[cfg(target_os = "windows")] +fn resolved_cli_distribution_source() -> Option { + crate::utils::resolve_openclaw_cli().distribution_source +} + +#[cfg(not(target_os = "windows"))] +fn resolved_cli_distribution_source() -> Option { + None +} + fn configure_git_https_rules() -> usize { // Collect unique target prefixes to unset old rules let targets: std::collections::HashSet<&str> = @@ -785,7 +795,14 @@ async fn get_local_version() -> Option { // Windows: 先查 standalone 安装,再查 npm 全局目录 #[cfg(target_os = "windows")] { - // 检查所有 standalone 安装目录 + let resolved = crate::utils::resolve_openclaw_cli(); + if resolved.selected_path.is_some() { + if let Some(version) = resolved.version { + return Some(version); + } + } + + // 未解析到当前实际 CLI 时,再回退扫描本机常见安装目录 for sa_dir in all_standalone_dirs() { let version_file = sa_dir.join("VERSION"); if let Ok(content) = fs::read_to_string(&version_file) { @@ -812,7 +829,6 @@ async fn get_local_version() -> Option { } } } - // npm 全局目录 if let Ok(appdata) = std::env::var("APPDATA") { for pkg in &["@qingchencloud/openclaw-zh", "openclaw"] { let pkg_json = PathBuf::from(&appdata) @@ -896,10 +912,12 @@ fn detect_installed_source() -> String { } "official".into() } - // Windows: 优先通过文件系统检测,避免 npm list 阻塞 #[cfg(target_os = "windows")] { - // 检查所有可能的 standalone 安装目录 + if let Some(source) = resolved_cli_distribution_source() { + return source; + } + for sa_dir in all_standalone_dirs() { let sa_zh = sa_dir .join("node_modules") @@ -908,20 +926,23 @@ fn detect_installed_source() -> String { if sa_zh.exists() { return "chinese".into(); } + let sa_official = sa_dir.join("node_modules").join("openclaw"); + if sa_official.exists() { + return "official".into(); + } } - // 检查 npm 全局目录 if let Some(appdata) = std::env::var_os("APPDATA") { - let zh_dir = PathBuf::from(&appdata) - .join("npm") - .join("node_modules") - .join("@qingchencloud") - .join("openclaw-zh"); + let npm_root = PathBuf::from(&appdata).join("npm").join("node_modules"); + let zh_dir = npm_root.join("@qingchencloud").join("openclaw-zh"); if zh_dir.exists() { return "chinese".into(); } + let official_dir = npm_root.join("openclaw"); + if official_dir.exists() { + return "official".into(); + } } - // 默认返回汉化版 - "chinese".into() + "official".into() } // 所有平台通用: npm list 检测 #[cfg(not(any(target_os = "macos", target_os = "windows")))] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 3fc164d7..4a23b8b9 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -62,12 +62,18 @@ fn panel_config_path() -> PathBuf { openclaw_dir().join("clawpanel.json") } -fn read_panel_config_value() -> Option { +pub(crate) fn read_panel_config_value() -> Option { std::fs::read_to_string(panel_config_path()) .ok() .and_then(|content| serde_json::from_str(&content).ok()) } +pub(crate) fn configured_openclaw_path() -> Option { + let value = read_panel_config_value()?; + let raw = value.get("openclawPath")?.as_str()?.trim().to_string(); + if raw.is_empty() { None } else { Some(raw) } +} + pub fn configured_proxy_url() -> Option { let value = read_panel_config_value()?; let raw = value diff --git a/src-tauri/src/commands/service.rs b/src-tauri/src/commands/service.rs index 4735863c..2fdfd675 100644 --- a/src-tauri/src/commands/service.rs +++ b/src-tauri/src/commands/service.rs @@ -606,11 +606,9 @@ mod platform { #[cfg(target_os = "windows")] mod platform { - use std::env; use std::fs::{self, OpenOptions}; use std::io::Write; use std::os::windows::process::CommandExt; - use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::Mutex; use tokio::process::Command as TokioCommand; @@ -648,61 +646,23 @@ mod platform { } } - fn candidate_cli_paths() -> Vec { - let mut candidates = Vec::new(); - - // standalone 安装目录(优先检测,覆盖所有可能位置) - if let Ok(localappdata) = env::var("LOCALAPPDATA") { - // Inno Setup PrivilegesRequired=lowest 默认路径 - candidates.push(Path::new(&localappdata).join("Programs").join("OpenClaw").join("openclaw.cmd")); - candidates.push(Path::new(&localappdata).join("OpenClaw").join("openclaw.cmd")); - } - if let Ok(pf) = env::var("ProgramFiles") { - candidates.push(Path::new(&pf).join("OpenClaw").join("openclaw.cmd")); - } - - if let Ok(appdata) = env::var("APPDATA") { - candidates.push(Path::new(&appdata).join("npm").join("openclaw.cmd")); - } - if let Ok(localappdata) = env::var("LOCALAPPDATA") { - candidates.push( - Path::new(&localappdata) - .join("Programs") - .join("nodejs") - .join("node_modules") - .join("@qingchencloud") - .join("openclaw-zh") - .join("bin") - .join("openclaw.js"), - ); - } - - for segment in crate::commands::enhanced_path().split(';') { - let dir = segment.trim(); - if dir.is_empty() { - continue; - } - let base = Path::new(dir); - candidates.push(base.join("openclaw.cmd")); - candidates.push(base.join("openclaw")); - candidates.push( - base.join("node_modules") - .join("@qingchencloud") - .join("openclaw-zh") - .join("bin") - .join("openclaw.js"), - ); - } - - candidates + pub fn cli_details() -> (Option, Option, Option, Vec, bool) { + let resolved = crate::utils::resolve_openclaw_cli(); + ( + resolved + .selected_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()), + resolved.version, + resolved.path_source, + resolved.candidates, + resolved.selected_from_config, + ) } fn check_cli_installed_inner() -> bool { - // 方式1: 检查常见文件路径(零进程,最快) - for path in candidate_cli_paths() { - if path.exists() { - return true; - } + if crate::utils::resolve_openclaw_cli().selected_path.is_some() { + return true; } // 方式2: 通过 where 查找(兼容 nvm、自定义 prefix 等) @@ -891,7 +851,7 @@ mod platform { pub async fn start_service_impl(_label: &str) -> Result<(), String> { if !is_cli_installed() { return Err( - "openclaw CLI 未安装,请先通过 npm install -g @qingchencloud/openclaw-zh 安装" + "openclaw CLI 未安装,请先安装并确认 PATH 中可执行 openclaw.cmd" .into(), ); } @@ -910,8 +870,12 @@ mod platform { let (stdout_log, stderr_log) = create_gateway_log_files()?; + let cli_path = crate::utils::resolve_openclaw_cli() + .selected_path + .ok_or_else(|| "未找到可用的 openclaw CLI 路径".to_string())?; + let mut cmd = std::process::Command::new("cmd"); - cmd.args(["/c", "openclaw", "gateway"]) + cmd.arg("/c").arg(cli_path).arg("gateway") .creation_flags(CREATE_NO_WINDOW) .stdin(Stdio::null()) .stdout(stdout_log) @@ -1074,7 +1038,7 @@ mod platform { async fn gateway_command(action: &str) -> Result<(), String> { if !is_cli_installed().await { return Err( - "openclaw CLI 未安装,请先通过 npm install -g @qingchencloud/openclaw-zh 安装" + "openclaw CLI 未安装,请先安装并确认系统中可执行 openclaw" .into(), ); } @@ -1140,12 +1104,22 @@ pub async fn get_services_status() -> Result, String> { for label in labels.iter().map(String::as_str) { let (running, pid) = check_service_status_for_label(uid, label).await; + #[cfg(target_os = "windows")] + let (cli_path, cli_version, cli_source, cli_candidates, cli_selected_from_config) = platform::cli_details(); + #[cfg(not(target_os = "windows"))] + let (cli_path, cli_version, cli_source, cli_candidates, cli_selected_from_config) = (None, None, None, Vec::new(), false); + results.push(ServiceStatus { label: label.to_string(), pid, running, description: desc_map.get(label).unwrap_or(&"").to_string(), cli_installed, + cli_path, + cli_version, + cli_source, + cli_candidates, + cli_selected_from_config, }); } @@ -1180,7 +1154,7 @@ mod tests { #[test] fn 只把_openclaw_gateway_命令行识别为_gateway_进程() { assert!(looks_like_gateway_command_line( - r#""C:\Program Files\nodejs\node.exe" "C:\Users\me\AppData\Roaming\npm\node_modules\@qingchencloud\openclaw-zh\bin\openclaw.js" gateway"#, + r#""C:\Users\me\AppData\Roaming\npm\openclaw.cmd" gateway"#, )); assert!(!looks_like_gateway_command_line( r#""C:\Program Files\nodejs\node.exe" "C:\app\server.js""#, diff --git a/src-tauri/src/models/types.rs b/src-tauri/src/models/types.rs index 91915c13..964ab49e 100644 --- a/src-tauri/src/models/types.rs +++ b/src-tauri/src/models/types.rs @@ -8,6 +8,12 @@ pub struct ServiceStatus { pub description: String, /// CLI 工具是否已安装(Windows/Linux: openclaw CLI) pub cli_installed: bool, + pub cli_path: Option, + pub cli_version: Option, + pub cli_source: Option, + #[serde(default)] + pub cli_candidates: Vec, + pub cli_selected_from_config: bool, } #[derive(Debug, Serialize, Deserialize)] diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index e243fb06..d4716bc0 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -1,21 +1,202 @@ #[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 { - 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); +#[derive(Debug, Clone, Default)] +pub(crate) struct ResolvedOpenClawCli { + pub selected_path: Option, + pub version: Option, + pub distribution_source: Option, + pub path_source: Option, + pub candidates: Vec, + pub selected_from_config: bool, +} + +#[cfg(target_os = "windows")] +fn windows_candidate_cli_paths() -> Vec { + use std::env; + use std::path::Path; + + let mut candidates = Vec::new(); + + if let Some(configured) = crate::commands::configured_openclaw_path() { + candidates.push(std::path::PathBuf::from(configured.trim())); + } + + if let Ok(localappdata) = env::var("LOCALAPPDATA") { + candidates.push(Path::new(&localappdata).join("Programs").join("OpenClaw").join("openclaw.cmd")); + candidates.push(Path::new(&localappdata).join("OpenClaw").join("openclaw.cmd")); + } + if let Ok(pf) = env::var("ProgramFiles") { + candidates.push(Path::new(&pf).join("OpenClaw").join("openclaw.cmd")); + } + if let Ok(appdata) = env::var("APPDATA") { + candidates.push(Path::new(&appdata).join("npm").join("openclaw.cmd")); + candidates.push(Path::new(&appdata).join("npm").join("openclaw")); + } + + for dir in crate::commands::enhanced_path().split(';') { + let trimmed = dir.trim(); + if trimmed.is_empty() { + continue; } + let base = Path::new(trimmed); + candidates.push(base.join("openclaw.cmd")); + candidates.push(base.join("openclaw")); + } + + candidates +} + +#[cfg(target_os = "windows")] +fn normalize_cli_candidate(path: std::path::PathBuf) -> Option { + if !path.exists() { + return None; + } + let text = path.to_string_lossy().to_ascii_lowercase(); + if text.contains(".cherrystudio") || text.contains("cherry-studio") { + return None; + } + Some(path) +} + +#[cfg(target_os = "windows")] +fn dedupe_cli_candidates(paths: Vec) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for path in paths.into_iter().filter_map(normalize_cli_candidate) { + let key = path.to_string_lossy().to_ascii_lowercase(); + if seen.insert(key) { + out.push(path); + } + } + out +} + +#[cfg(target_os = "windows")] +fn detect_distribution_from_cli_path(path: &std::path::Path) -> Option { + let lower = path.to_string_lossy().to_ascii_lowercase(); + if lower.contains("openclaw-zh") || lower.contains("qingchencloud") { + return Some("chinese".to_string()); } + + let base = path.parent()?; + let zh_pkg = base + .join("node_modules") + .join("@qingchencloud") + .join("openclaw-zh"); + if zh_pkg.exists() { + return Some("chinese".to_string()); + } + + let official_pkg = base.join("node_modules").join("openclaw"); + if official_pkg.exists() { + return Some("official".to_string()); + } + + if lower.ends_with("openclaw.cmd") || lower.ends_with("\\openclaw") { + return Some("official".to_string()); + } + None } +#[cfg(target_os = "windows")] +fn detect_cli_version(path: &std::path::Path, distribution_source: Option<&str>) -> Option { + let base = path.parent()?; + let package_dir = match distribution_source { + Some("chinese") => Some( + base.join("node_modules") + .join("@qingchencloud") + .join("openclaw-zh"), + ), + Some("official") => Some(base.join("node_modules").join("openclaw")), + _ => { + let zh = base + .join("node_modules") + .join("@qingchencloud") + .join("openclaw-zh"); + if zh.exists() { + Some(zh) + } else { + Some(base.join("node_modules").join("openclaw")) + } + } + }?; + + let package_json = package_dir.join("package.json"); + if let Ok(content) = std::fs::read_to_string(&package_json) { + if let Ok(json) = serde_json::from_str::(&content) { + if let Some(ver) = json.get("version").and_then(|v| v.as_str()) { + return Some(ver.to_string()); + } + } + } + + const CREATE_NO_WINDOW: u32 = 0x08000000; + let mut cmd = std::process::Command::new("cmd"); + cmd.arg("/c").arg(path).arg("--version"); + crate::commands::apply_system_env(&mut cmd); + cmd.creation_flags(CREATE_NO_WINDOW); + let out = cmd.output().ok()?; + if !out.status.success() { + return None; + } + let raw = String::from_utf8_lossy(&out.stdout).trim().to_string(); + raw.split_whitespace().last().map(|s| s.to_string()) +} + +#[cfg(target_os = "windows")] +pub(crate) fn resolve_openclaw_cli() -> ResolvedOpenClawCli { + let configured = crate::commands::configured_openclaw_path(); + let candidates = dedupe_cli_candidates(windows_candidate_cli_paths()); + let selected_path = candidates.first().cloned(); + let distribution_source = selected_path + .as_deref() + .and_then(detect_distribution_from_cli_path); + let version = selected_path + .as_deref() + .and_then(|path| detect_cli_version(path, distribution_source.as_deref())); + let path_source = selected_path.as_ref().map(|path| { + let text = path.to_string_lossy(); + let lower = text.to_ascii_lowercase(); + if configured + .as_ref() + .is_some_and(|cfg| cfg.trim().eq_ignore_ascii_case(&text)) + { + "configured".to_string() + } else if lower.contains("appdata") && lower.contains("npm") { + "npm-global".to_string() + } else if lower.contains("programs\\openclaw") || lower.contains("program files\\openclaw") { + "standalone".to_string() + } else if lower.contains("nvm") { + "nvm-path".to_string() + } else { + "path".to_string() + } + }); + + ResolvedOpenClawCli { + selected_path, + version, + distribution_source, + path_source, + candidates: candidates + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(), + selected_from_config: configured.is_some(), + } +} + +/// Windows: 在 PATH 中查找 openclaw.cmd 的完整路径 +/// 避免通过 `cmd /c openclaw` 调用时 npm .cmd shim 中的引号导致 +/// "\"node\"" is not recognized 错误 +#[cfg(target_os = "windows")] +pub(crate) fn find_openclaw_cmd() -> Option { + resolve_openclaw_cli().selected_path +} + /// 跨平台获取 openclaw 命令的方法(同步版本) #[allow(dead_code)] pub fn openclaw_command() -> std::process::Command { diff --git a/src/lib/openclaw-cli-display.js b/src/lib/openclaw-cli-display.js new file mode 100644 index 00000000..a8566059 --- /dev/null +++ b/src/lib/openclaw-cli-display.js @@ -0,0 +1,44 @@ +/** + * OpenClaw CLI 检测结果展示辅助 + */ + +const PATH_SOURCE_LABELS = { + panel: '固定路径(面板配置)', + process: '运行中 Gateway 进程', + where: 'PATH / where 检测', + npm: '全局 npm 默认路径', + path: 'PATH 检测', + none: '未检测到', +} + +export function getCliPathSourceLabel(source) { + return PATH_SOURCE_LABELS[source] || source || '未检测到' +} + +export function getCliVersionSourceLabel(meta) { + if (!meta?.version) return '未检测到' + return `通过当前 CLI 路径获取(${meta.pathSourceLabel})` +} + +export function buildOpenclawCliMeta(service, options = {}) { + const overridePath = String(options.overridePath || '').trim() + const path = String(service?.cli_path || '').trim() + const version = String(service?.cli_version || '').trim() + const pathSource = String(service?.cli_source || '').trim() || 'none' + const installed = service?.cli_installed !== false && !!path + const pinned = pathSource === 'panel' || !!overridePath + const pathSourceLabel = getCliPathSourceLabel(pathSource) + + return { + installed, + path, + version, + pathSource, + pathSourceLabel, + versionSourceLabel: getCliVersionSourceLabel({ version, pathSourceLabel }), + overridePath, + pinned, + statusLabel: installed ? '已检测到 OpenClaw CLI' : '未检测到 OpenClaw CLI', + strategyLabel: pinned ? '固定路径' : '自动检测', + } +} diff --git a/src/pages/chat-debug.js b/src/pages/chat-debug.js index 52da48e4..5b7cf049 100644 --- a/src/pages/chat-debug.js +++ b/src/pages/chat-debug.js @@ -6,16 +6,17 @@ import { api, getRequestLogs, clearRequestLogs } from '../lib/tauri-api.js' import { wsClient } from '../lib/ws-client.js' import { isOpenclawReady, isGatewayRunning } from '../lib/app-state.js' import { icon, statusIcon } from '../lib/icons.js' +import { buildOpenclawCliMeta } from '../lib/openclaw-cli-display.js' export async function render() { const page = document.createElement('div') - page.className = 'page' + page.className = 'page debug-page' page.innerHTML = `