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/.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
diff --git a/.learnings/ERRORS.md b/.learnings/ERRORS.md
new file mode 100644
index 00000000..b58f229a
--- /dev/null
+++ b/.learnings/ERRORS.md
@@ -0,0 +1,66 @@
+## [ERR-20260319-001] vite-build-chat-js
+
+**Logged**: 2026-03-19T03:53:00Z
+**Priority**: high
+**Status**: pending
+**Area**: frontend
+
+### Summary
+`npm run build` failed after chat history / hosted delivery hardening edits due to invalid JS syntax near the end of `src/pages/chat.js`.
+
+### Error
+```
+[vite:build-import-analysis] src/pages/chat.js (3786:0): Failed to parse source for import analysis because the content contains invalid JS syntax.
+3784: }
+3785: ssionStates.clear()
+3786: }
+```
+
+### Context
+- Operation attempted: `npm run build`
+- Branch/worktree: `C:\Users\34438\.openclaw\workspace\tools\clawpanel`
+- Related change: hosted delivery + history scroll hardening
+
+### Suggested Fix
+Inspect the trailing cleanup block in `src/pages/chat.js` and repair the truncated/garbled statement before re-running build.
+
+### Metadata
+- Reproducible: yes
+- Related Files: src/pages/chat.js
+
+---
+
+## [ERR-20260319-001] git-cherry-pick-conflict
+
+**Logged**: 2026-03-19T12:53:00+08:00
+**Priority**: medium
+**Status**: resolved
+**Area**: backend
+
+### Summary
+Selective upstream cherry-pick 8485df7 conflicted in src-tauri/src/commands/config.rs because the local branch had already refactored the same Windows standalone extraction block.
+
+### Error
+`
+CONFLICT (content): Merge conflict in src-tauri/src/commands/config.rs
+error: could not apply 8485df7... fix: resolve clippy dead_code and manual_flatten warnings
+`
+
+### Context
+- Operation attempted: git cherry-pick -x 8485df7
+- Repository: C:\Users\34438\.openclaw\workspace\tools\clawpanel
+- Resolution: keep local logic and manually adopt the upstream .flatten() simplification inside the conflicting
ead_dir loop.
+
+### Suggested Fix
+When selectively syncing small upstream fixes into a heavily diverged branch, inspect the exact conflict hunk first and manually absorb the minimal behavior change instead of retrying full merge/cherry-pick blindly.
+
+### Metadata
+- Reproducible: yes
+- Related Files: src-tauri/src/commands/config.rs
+
+### Resolution
+- **Resolved**: 2026-03-19T12:54:00+08:00
+- **Commit/PR**: selective sync of upstream commit 8485df7
+- **Notes**: conflict resolved by retaining local branch behavior and applying only the clippy-friendly iteration style.
+
+---
diff --git a/.learnings/LEARNINGS.md b/.learnings/LEARNINGS.md
new file mode 100644
index 00000000..60e103bc
--- /dev/null
+++ b/.learnings/LEARNINGS.md
@@ -0,0 +1,29 @@
+# Learnings
+
+## [LRN-20260319-001] correction
+
+**Logged**: 2026-03-19T13:18:00+08:00
+**Priority**: high
+**Status**: resolved
+**Area**: frontend
+
+### Summary
+A claim that assistant settings access, hosted-agent toggle removal, and global select styling were already fixed was incorrect; these required direct source verification before reporting completion.
+
+### Details
+User reported three regressions still visible in the live UI: assistant settings could not be opened reliably, the hosted-agent enable toggle was still present in chat UI, and native select styling remained visually broken. The fix was to re-read the live source and patch the actual UI code instead of relying on partial earlier assumptions.
+
+### Suggested Action
+Before claiming UI fixes are complete, verify the rendered source locations that own the feature trigger, the visible markup, and the final shared CSS path used by the control.
+
+### Metadata
+- Source: conversation
+- Related Files: src/pages/assistant.js, src/pages/chat.js, src/style/reset.css
+- Tags: correction, ui, verification
+
+### Resolution
+- **Resolved**: 2026-03-19T13:19:00+08:00
+- **Commit/PR**: pending
+- **Notes**: fixed by removing hosted-agent enable toggle markup/state, strengthening assistant settings button click handling/z-index, and tightening global select styling in reset.css.
+
+---
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index c155b12c..a7bbc4bb 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)
---
@@ -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` 辅助函数
---
diff --git a/README.md b/README.md
index fe68f6ce..732fd811 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,17 @@
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)
+
+### 近期亮点
+
+- **v0.9.x**:聊天体验重构,消息渲染与交互稳定性显著提升。
+- **聊天虚拟滚动**:大聊天记录场景性能优化,滚动更顺、渲染负载降低。
+- **Markdown-it**:引入稳定的 Markdown 渲染与扩展能力,展示一致性提升。
+- **v0.8.0**:多 Agent 渠道能力完善,任务分发与协作更清晰。
+- **Gateway 补丁**:连接与转发链路修复,长连接更可靠。
+- **R2 通用包**:分发与复用成本降低,部署更灵活。
+- **v0.6.0**:基础能力完善与稳定性打底,为后续迭代奠基。
### ⚡ OpenClaw 独立安装包(零依赖,无需 Node.js/npm)
@@ -51,7 +61,7 @@ ClawPanel 是 [OpenClaw](https://github.com/1186258278/OpenClawChineseTranslatio
> ClawPanel 安装 OpenClaw 时会**自动优先使用独立安装包**,无需手动操作。此方案仅供不使用 ClawPanel 的用户独立安装。
-### 🔥 开发板 / 嵌入式设备支持
+### 开发板 / 嵌入式设备支持
ClawPanel 提供**纯 Web 版部署模式**(零 GUI 依赖),天然兼容 ARM64 开发板和嵌入式设备:
@@ -60,7 +70,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)
## 社区交流
@@ -114,7 +124,7 @@ ClawPanel 提供**纯 Web 版部署模式**(零 GUI 依赖),天然兼容 A
安装方式:打开 `.dmg` 文件,**先将 ClawPanel 拖入「应用程序」文件夹**,再双击打开。
-> **⚠️ 首次打开提示"已损坏"或"无法验证开发者"?** 由于应用未签名,macOS 会拦截。请在终端执行以下命令解除限制:
+> **首次打开提示"已损坏"或"无法验证开发者"?** 由于应用未签名,macOS 会拦截。请在终端执行以下命令解除限制:
>
> ```bash
> sudo xattr -rd com.apple.quarantine /Applications/ClawPanel.app
@@ -152,7 +162,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 部署
@@ -166,7 +176,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 反向代理等)
## 功能特性
@@ -174,8 +184,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 安装/卸载、配置备份与还原
- **模型配置** — 多服务商管理、模型增删改查、批量连通性测试、延迟检测、拖拽排序、自动保存+撤销
@@ -201,7 +211,7 @@ docker run -d --name clawpanel --restart unless-stopped \
-
📋
+
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/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/plans/2026-03-18-heartbeat-chat-history-design.md b/docs/plans/2026-03-18-heartbeat-chat-history-design.md
new file mode 100644
index 00000000..f6655d92
--- /dev/null
+++ b/docs/plans/2026-03-18-heartbeat-chat-history-design.md
@@ -0,0 +1,18 @@
+# Heartbeat Chat History Refresh Design
+
+日期: 2026-03-18
+
+## 目标
+- 每次 node.list 心跳发送时,同时触发 chat.history 刷新消息
+
+## 范围
+- src/lib/ws-client.js(心跳发送逻辑)
+
+## 方案
+- 在心跳定时器中,node.list 请求后追加 chat.history 请求
+- 使用当前 sessionKey(与聊天页一致)
+- 保持失败可忽略,不影响心跳继续
+
+## 验证
+- npm run build
+- 观察 ws 发送帧同时包含 node.list + chat.history
diff --git a/docs/plans/2026-03-18-hosted-agent-enhancements-design.md b/docs/plans/2026-03-18-hosted-agent-enhancements-design.md
new file mode 100644
index 00000000..5b48a37d
--- /dev/null
+++ b/docs/plans/2026-03-18-hosted-agent-enhancements-design.md
@@ -0,0 +1,43 @@
+# Hosted Agent Enhancements Design
+
+日期: 2026-03-18
+
+## 目标与范围
+- system 提示词:支持全局默认 + 会话覆盖
+- 暂停保留历史与计数;停止清空历史与计数
+- 指引角色:轻度建议(不强制模板)
+- 上下文压缩:按 token 上限裁剪最旧消息
+- 前端提示:暂停与停止行为清晰提示用户
+
+## 数据结构
+- 全局默认:panel.hostedAgent.default.systemPrompt
+- 会话覆盖:hostedSessionConfig.systemPrompt
+- 运行状态新增:contextTokens、lastTrimAt
+
+## 行为流程
+1) 构建托管上下文
+- systemPrompt = sessionPrompt || globalPrompt || ''
+- buildHostedMessages 时插入 system 消息
+
+2) 暂停/停止
+- 暂停:status=PAUSED,保留 history 与 stepCount,前端提示
+- 停止:清空 history、stepCount、lastError,status=IDLE,前端提示
+
+3) 上下文压缩
+- 估算 token(简单字数/4 近似)
+- 超过阈值时裁剪最旧非 system 消息
+- 记录 lastTrimAt 与 contextTokens
+
+4) 指引角色输出
+- 输出保持建议式语气,不强制模板
+- 仍保留基本前缀与状态摘要
+
+## 风险与回归
+- token 估算误差导致裁剪偏差
+- 暂停/停止提示需与状态一致
+
+## 验证
+- 运行托管 Agent:systemPrompt 生效
+- 暂停后恢复:历史仍在
+- 停止后:历史清空
+- 长上下文自动裁剪
diff --git a/docs/plans/2026-03-18-tool-protocol-compat-design.md b/docs/plans/2026-03-18-tool-protocol-compat-design.md
new file mode 100644
index 00000000..98d16408
--- /dev/null
+++ b/docs/plans/2026-03-18-tool-protocol-compat-design.md
@@ -0,0 +1,43 @@
+# Tool Protocol Compatibility Design
+
+日期: 2026-03-18
+
+## 目标与范围
+- 覆盖常见变体:tool_use / tool_call / tool_result 等 block 结构
+- 兼容字段别名:tool_use_id / toolUseId / result_id / resultId 等
+- 统一输入输出字段映射,减少工具不显示或错合并
+- 不改 UI 交互与渲染逻辑,仅增强协议兼容
+
+## 方案选择
+**推荐方案 1(轻量扩展)**
+- 兼容字段别名 + payload 结构变体
+- 仅修改:extractChatContent / extractContent / collectToolsFromMessage
+- 风险低、改动集中、回归成本小
+
+## 数据映射规则
+- id 优先级:
+ - id > tool_call_id > toolCallId > tool_use_id > toolUseId > result_id > resultId
+- name 优先级:
+ - name > tool > tool_name > toolName
+- input 优先级:
+ - input > args > parameters > arguments > meta?.input
+- output 优先级:
+ - output > result > content > meta?.output
+- 贯穿字段:runId、messageTimestamp
+
+## 兼容点
+- extractChatContent: block 级 tool_call/tool_result
+- extractContent: msg.content 数组中的 tool_call/tool_result
+- collectToolsFromMessage: tool_calls / tool_results 结构
+
+## 错误处理
+- 缺少 id 时降级为 name + messageTimestamp 合并(已有策略)
+- input/output 非字符串保持原样,渲染前做安全处理
+
+## 验证
+- 构建验证:npm run build
+- 回归点:工具列表显示、工具输出合并、流式 tool 事件
+
+## 非目标
+- 不引入新的 UI 组件
+- 不更改既有渲染布局与样式
diff --git a/docs/plans/2026-03-18-tool-variants-ws-state-design.md b/docs/plans/2026-03-18-tool-variants-ws-state-design.md
new file mode 100644
index 00000000..527a6ea8
--- /dev/null
+++ b/docs/plans/2026-03-18-tool-variants-ws-state-design.md
@@ -0,0 +1,38 @@
+# Tool Variants + WS State Cleanup Design
+
+日期: 2026-03-18
+
+## 目标与范围
+- 工具协议兼容扩展:补充 name/input/output 字段别名与 payload 变体
+- ws-client 状态收敛:统一使用 _transition 进行状态变更
+
+## 方案与数据映射
+- tool name 优先级:
+ - name > tool > tool_name > toolName > tool?.name > meta?.toolName
+- tool input 优先级:
+ - input > args > parameters > arguments > tool_input > meta?.input > meta?.args
+- tool output 优先级:
+ - output > result > content > tool_output > result_text > output_text > meta?.output
+- runId/messageTimestamp 贯穿传递,降低误合并风险
+- ws-client:查找并替换散落 state 赋值为 _transition 调用
+
+## 兼容点
+- src/pages/chat.js
+ - extractChatContent
+ - extractContent
+ - collectToolsFromMessage
+- src/lib/ws-client.js
+ - 所有状态变更路径
+
+## 错误处理
+- 缺少 id 时降级为 name + messageTimestamp 合并
+- 非字符串 input/output 保留原样,渲染前进行安全处理
+
+## 验证
+- npm run build
+- 工具列表显示与 tool_result 合并
+- ws-client 状态日志仅在 WS_DEBUG 开启时输出
+
+## 非目标
+- 不修改 UI 结构与样式
+- 不引入新的组件或事件协议
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 等影响模块做清单化标记。
diff --git a/docs/repository-maturity-plan.md b/docs/repository-maturity-plan.md
new file mode 100644
index 00000000..61db7479
--- /dev/null
+++ b/docs/repository-maturity-plan.md
@@ -0,0 +1,202 @@
+# Repository Maturity Plan
+
+## 当前问题总览
+
+### 技术栈与结构现状
+- 前端:Vanilla JS + Vite,页面集中在 `src/pages`
+- 桌面端:Tauri v2,Rust 命令在 `src-tauri/src`
+- Web/Headless 适配:`scripts/dev-api.js` + `scripts/serve.js`
+- 测试:Vitest(存在测试,但无 lint、无类型检查脚本)
+- 包管理:npm + package-lock
+
+### 审计结论摘要
+1. **页面文件过大**
+ - `src/pages/chat.js` 仍是最大热点文件,承担路由页、状态机、托管 Agent、历史处理、会话管理、消息渲染、输入锁定等多重职责。
+ - `src/pages/assistant.js` 同样偏大,页面状态、交互、配置管理混杂。
+2. **前后端边界不够清晰**
+ - `src/lib/tauri-api.js` 既承担命令调用、缓存、Web fallback,又让页面直接理解后端命令名。
+ - `scripts/dev-api.js` 体量很大,既做 API 中间件,又做业务逻辑、配置读写、命令执行与适配。
+3. **页面层承担过多业务逻辑**
+ - `chat.js` 中存在大量 DTO 解析、history 去重、hosted response 解析、状态转换逻辑。
+ - UI 层直接关心 Gateway event payload 与消息结构细节。
+4. **副作用散落**
+ - 页面内直接读写 localStorage、调用 API、更新 DOM、持久化运行态,缺少统一 service / adapter 边界。
+5. **性能与体验风险并存**
+ - 大文件导致维护成本高,回归风险高。
+ - 页面层状态过多,调试困难。
+ - 构建存在 Vite dynamic/static import warning。
+ - chat / assistant 这类高频交互页面容易出现状态不同步、滚动、去重、重连边界问题。
+
+## 目标架构原则
+- **简洁**:少而清晰的模块边界,避免花哨分层。
+- **模块化**:把可稳定复用的业务规则从页面剥离。
+- **低耦合**:UI 只消费 view-model / service,不直接拼底层后端细节。
+- **高可维护**:页面负责展示与交互编排,domain/service 负责规则,adapter 负责数据转换。
+- **渐进式重构**:优先高收益低风险拆分,不做全量推倒。
+
+## 推荐目录结构
+
+```text
+src/
+ components/ # 通用 UI 组件
+ lib/
+ api/ # API 调用封装(后续可迁移)
+ adapters/ # 后端 DTO -> 前端 view model
+ domain/ # 纯业务规则、状态转换、解析逻辑
+ hosted-agent.js # 已落地的第一步:托管 Agent 常量/解析/提示词
+ pages/
+ chat.js # 页面装配层,继续瘦身
+ assistant.js # 页面装配层,继续瘦身
+ style/
+ ...
+
+src-tauri/
+ src/
+ commands/ # Rust command handlers
+ models/ # Rust DTO / types
+ utils.rs # 基础工具
+
+scripts/
+ dev-api.js # Web/headless API bridge(后续建议拆分)
+ serve.js # headless server 启动入口
+```
+
+## 前后端解耦策略
+1. **页面不直接解释后端原始响应**
+ - 新增 adapter/domain 层,负责把 Gateway/Tauri/dev-api 返回结构转换成页面需要的数据。
+2. **`tauri-api.js` 逐步变成 transport 层**
+ - 只负责请求、缓存、错误包装,不承担页面业务判断。
+3. **`dev-api.js` 分步拆分**
+ - 拆成 route middleware + command handlers + config helpers,避免单文件承担全部头部逻辑。
+4. **Hosted / chat / assistant 规则独立出页面**
+ - 先抽纯函数与常量,再抽状态转换逻辑,最后再抽 service。
+
+## 性能优化策略
+1. 减少巨型页面文件中的重复逻辑与重复解析。
+2. 统一消息/history/hosted 解析路径,降低重复计算。
+3. 避免在 UI 层直接遍历和重组原始 payload。
+4. 后续治理 Vite dynamic/static import warning,降低包体和 chunk 边界混乱。
+5. 高风险页面(chat / assistant)优先做“逻辑抽离”,减少每次修改带来的全文件回归风险。
+
+## 用户体验优化策略
+1. 统一加载态、错误态、空态文案来源。
+2. 统一 hosted 状态文案与系统气泡策略,避免不同区域说法不一致。
+3. 页面内交互规则尽量单点定义,例如:锁定输入、暂停/恢复、错误恢复。
+4. 不稳定体验问题优先用“删复杂逻辑”解决,而不是继续叠补丁。
+
+## 分阶段实施计划
+
+### P0(立即处理,高收益低风险)
+- 拆出 `chat.js` 中纯业务规则:hosted constants、prompt、response parse、instruction extract、action label。
+- 让 chat 页面不再承载所有 hosted 规则细节。
+- 删除高维护成本且不稳定的自动滚动/虚拟滚动路径。
+- 统一 hosted 状态文案与系统反馈。
+
+### P1(下一阶段)
+- 继续拆分 `chat.js`
+ - history/domain
+ - hosted runtime/service
+ - session list / event adapter
+- 拆分 `assistant.js` 中配置、状态、渲染、工具调用路径。
+- 给 `tauri-api.js` 引入更清晰的 command adapter/view-model adapter。
+- 为关键领域(chat / assistant / hosted)补充更有针对性的测试。
+
+### P2(成熟化收尾)
+- 拆分 `scripts/dev-api.js` 为更清晰的路由/处理器结构。
+- 梳理 Rust command / JS adapter 的 DTO 边界。
+- 补 lint、类型检查或等价静态校验流程。
+- 治理 Vite chunk warning 与 import 边界。
+
+## 已完成的第一阶段改造
+1. 将托管 Agent 的核心常量、固定提示词、解析逻辑、动作文案抽离到:
+ - `src/lib/hosted-agent.js`
+2. `src/pages/chat.js` 改为消费 hosted-agent 模块,而不是继续内联所有 hosted 领域逻辑。
+3. 彻底禁用 chat 页虚拟滚动,去掉其对滚动位置的隐式接管。
+4. 统一 hosted 状态展示与系统反馈文案。
+5. 新增 `src/lib/history-domain.js`,抽离 history payload 归一化、history hash、entry key、最大时间戳计算等纯规则。
+6. `src/pages/chat.js` 开始消费 history-domain 模块,页面层继续向“渲染与编排”收口。
+7. 新增 `src/lib/history-view-model.js`,统一 history 到 UI 的附件、持久化、hosted seed 等视图转换规则。
+8. `applyHistoryResult(...)`、`applyIncrementalHistoryResult(...)` 与本地历史回填路径开始复用同一批 history view-model helper,减少页面内重复转换逻辑。
+9. 新增 `src/lib/history-render-service.js`,把 full apply / incremental apply 的共用渲染循环抽成独立 service helper。
+10. `chat.js` 中 history 渲染主流程开始通过 render-service 复用,页面层进一步收口到状态编排与依赖注入。
+11. 新增 `src/lib/history-loader-service.js`,抽离 pending history flush 判定与本地历史回填逻辑。
+12. `flushPendingHistory(...)` 与 `loadHistory(...)` 开始复用 loader helper,页面层继续减少重复分支与本地回填细节。
+13. 新增 `src/lib/history-apply-service.js`,抽离 history apply 前的状态更新判断与 hosted seed 初始化逻辑。
+14. `applyHistoryResult(...)` 开始复用 apply-service,页面层继续从“状态更新 + 渲染执行”向“编排 + 注入依赖”收口。
+15. 新增 `src/lib/hosted-runtime-service.js`,抽离 hosted runtime 的断线暂停、重连恢复、目标哈希与自动触发前状态切换规则。
+16. `pauseHostedForDisconnect(...)`、`resumeHostedFromReconnect(...)`、`maybeTriggerHostedRun(...)` 开始复用 hosted runtime helper,chat 页面内联状态机噪音继续下降。
+17. 新增 `src/lib/hosted-history-service.js`,抽离 hosted target 捕获判定、history entry 写入、hosted message 构建与 remote seed 映射。
+18. `shouldCaptureHostedTarget(...)`、`pushHostedHistoryEntry(...)`、`buildHostedMessages(...)`、`ensureHostedHistorySeeded(...)` 开始复用 hosted history helper,hosted 领域边界进一步清晰。
+19. 新增 `src/lib/hosted-step-service.js`,抽离 hosted step 的启动校验、运行开始、模板错误、成功收尾、自停与失败重试状态切换。
+20. `runHostedAgentStep(...)` 开始复用 hosted step helper,hosted orchestration 从页面内联状态机继续收缩为编排层。
+21. 新增 `src/lib/hosted-output-service.js`,抽离 hosted 输出解析、instruction 去重发送前准备与 optimistic user reply 构造。
+22. `appendHostedOutput(...)` 与 `commitHostedUserReply(...)` 开始复用 hosted output helper,hosted 与 chat UI 的交互边界进一步清晰。
+23. 新增 `src/lib/hosted-session-service.js`,抽离 hosted session storage 读写、state 构建与 globals 快照逻辑。
+24. `saveHostedSessionConfigForKey(...)`、`buildHostedStateFromStorage(...)`、`withHostedState(...)`、`withHostedStateAsync(...)` 开始复用 hosted session helper,多 session hosted 状态管理继续脱离页面文件。
+25. 新增 `src/lib/hosted-orchestrator-service.js`,抽离 hosted remote seed 覆盖判定、cross-session 运行模式判断与 boundSessionKey 对齐逻辑。
+26. `ensureHostedHistorySeeded(...)` 与 `runHostedAgentStepForSession(...)` 开始复用 orchestrator helper,hosted session/history/step 串联调度继续从页面中抽离。
+27. 新增 `src/lib/assistant-api-meta.js`,抽离 assistant API 类型归一化、鉴权要求、提示文案与输入占位元数据。
+28. `assistant.js` 开始复用 assistant API meta helper,页面层不再内联维护 API 类型说明与占位规则。
+29. 新增 `src/lib/assistant-api-client.js`,抽离 assistant API base URL 规整、鉴权头构造与重试请求逻辑。
+30. `assistant.js` 开始复用 assistant API client helper,assistant 页与底层 API 客户端细节进一步解耦。
+31. 新增 `src/lib/assistant-session-store.js`,抽离 assistant 配置读写、session 存储读写、序列化裁剪、会话创建与自动标题规则。
+32. `assistant.js` 开始复用 assistant session store helper,页面层继续从 config/session 存储细节中收口。
+33. 新增 `src/lib/assistant-request-state.js`,抽离 assistant 请求生命周期状态、abort controller、queue 与 requestId 管理。
+34. `assistant.js` 开始复用 assistant request state helper,流式请求与队列运行态开始从页面文件中剥离。
+35. 新增 `src/lib/assistant-attachments.js`,抽离 assistant 附件记录构造、preview HTML、pendingImages 增删清空与多模态消息 content 拼装。
+36. `assistant.js` 开始复用 assistant attachments helper,输入区附件逻辑开始从页面文件中收口。
+37. 新增 `src/lib/assistant-tool-safety.js`,抽离 assistant 工具危险级别判定、关键命令检测与确认文案生成逻辑。
+38. `assistant.js` 开始复用 assistant tool safety helper,工具确认与安全围栏规则开始从页面文件中剥离。
+39. 新增 `src/lib/assistant-tool-ui.js`,抽离 ask_user 卡片 HTML、回答解析、已回答态渲染与工具块 HTML 生成逻辑。
+40. `assistant.js` 开始复用 assistant tool ui helper,ask_user 交互卡片与 tool progress 渲染开始从页面文件中收口。
+41. 新增 `src/lib/assistant-tool-orchestrator.js`,抽离 tool history entry 构造/收尾与等待态包装逻辑。
+42. `callAIWithTools(...)` 开始复用 assistant tool orchestrator helper,tool 调度编排继续从页面文件中收口。
+43. 新增 `src/lib/assistant-provider-adapters.js`,抽离多 provider API 调用、SSE 读取与工具定义格式转换逻辑。
+44. `assistant.js` 开始复用 assistant provider adapters helper,provider-specific 调用入口继续从页面文件中剥离。
+45. 新增 `src/lib/assistant-message-pipeline.js`,抽离用户消息构造、AI 占位消息、请求上下文初始化与重试条 HTML。
+46. `assistant.js` 开始复用 assistant message pipeline helper,主发送流程的基础拼装开始从页面文件中收口。
+47. 新增 `src/lib/assistant-streaming-service.js`,抽离 tool progress 渲染、流式 chunk 更新与最终 bubble 收尾逻辑。
+48. `assistant.js` 开始复用 assistant streaming service helper,发送 / 重试流程中的重复流式渲染逻辑继续从页面文件中剥离。
+49. 新增 `src/lib/assistant-request-lifecycle.js`,抽离 retry bar 挂载与请求 finally 收尾逻辑。
+50. `assistant.js` 开始复用 assistant request lifecycle helper,发送 / 重试流程中的错误恢复与最终清理逻辑继续从页面文件中收口。
+51. 新增 `src/lib/assistant-response-runner.js`,抽离 tool 模式与普通流式模式的响应执行主体。
+52. `assistant.js` 开始复用 assistant response runner helper,send / retry 两条主路径中的重复响应执行逻辑继续从页面文件中剥离。
+53. 新增 `src/lib/assistant-run-context.js`,抽离响应启动前的按钮状态、首帧 typing UI 与工具模式判定。
+54. `assistant.js` 开始复用 assistant run context helper,send / retry 两条主路径中的重复启动壳继续从页面文件中剥离。
+55. 第一批关键体验修复开始落地:`assistant.js` 设置入口按钮改为明确“助手设置”语义并提升点击优先级,流式/工具进度/后台刷新改为仅在接近底部时自动跟随。
+56. `chat.js` 开始修正心跳历史刷新与托管绑定兜底:`scrollToBottom(...)` 改为 near-bottom 策略,Hosted 绑定会话优先基于已启用的托管会话解析。
+57. Hosted Agent 管理 UI 开始从开关切换改为按钮式管理:移除“启用托管 Agent”开关文案,统一通过“启动托管 / 暂停 / 停止 / 保存配置”按钮管理。
+58. `src/lib/hosted-agent.js` 的固定提示词改为简约指引风格,减少长篇规划和冗余输出,强调短句、执行导向和简明用户回复。
+59. `chat.history` 刷新链路继续收紧:全量历史应用在已有消息时不再强制 `scrollToBottom(true)`,改为 only-on-first-load 策略,降低心跳刷新导致的异常滚动。
+60. Hosted 错投链路继续修正:`createAskUserBubble(...)` 与 `commitHostedUserReply(...)` 默认优先使用 `getHostedBoundSessionKey()`,且非当前 UI 会话时不再把 optimistic 用户消息误插入当前会话 DOM。
+61. `src/lib/hosted-agent.js` 的固定提示词模板已按用户提供版本替换为变量化 Role/Profile/Variables/Skills/Rules 结构,保留简洁执行导向并支持默认值回退语义。
+62. Hosted Prompt 模板继续补完用户提供的 `Workflows` 与 `Initialization` 片段,固定四段输出结构与最小充分表达原则已一并写入模板主体。
+63. 新增 `src/lib/skills-catalog.js` 作为 Skills 数据轻量缓存层,统一负责 `skillsList` 结果缓存、TTL、失效与摘要统计,减少 Dashboard / Skills 重复加载成本。
+64. Dashboard 总览卡把 `MCP 工具` 正式切换为 `Skills`,显示真实 Skills 总数与可用/缺依赖摘要,不再读取 `readMcpConfig()` 作为该卡来源。
+65. `src/pages/skills.js` 开始复用 skills catalog cache:优先渲染缓存结果、后台刷新;安装依赖 / 安装 Skill / 卸载 / 手动刷新时统一失效缓存并强制重载,提升打开速度并修正统计摘要包含 blocked 数量。
+66. `src/components/sidebar.js` 新增分组折叠态记忆:各导航分组可单独展开/折叠,状态持久化到 localStorage,并保持桌面侧边栏整体折叠模式兼容。
+67. Sidebar icon 收口一版:重做 `dashboard` / `services` / `skills` 图标,并为分组标题新增自定义 toggle 结构,避免原生按钮样式破坏整体 UI。
+68. 公网访问分层表单主入口已确认在 `src/pages/settings.js` 的 `cloudflared` 区块;下一轮将围绕 `loadCloudflared(...)` / `handleCloudflaredStart(...)` 做表单分层与说明收口。
+69. `src/pages/settings.js` 的 Cloudflared 公网访问表单已改为四层结构:状态卡、启动操作、暴露目标、隧道模式;保持原启动参数不变,只重构展示与交互层。
+70. 新增 `syncCloudflaredFormState(...)`:切换 `cloudflared-mode` / `cloudflared-expose` / `cloudflared-port` 时,动态切换对应表单块可见性并实时更新实际端口展示。
+71. Cloudflared 表单继续收紧交互边界:新增 `validateCloudflaredForm(...)`,命名隧道缺少隧道名/域名、自定义端口为空时禁止启动,并通过提示文案与按钮禁用态即时反馈。
+72. `syncCloudflaredFormState(...)` 现同时负责输入禁用态:非自定义目标时禁用端口输入,非命名隧道时禁用隧道名/域名输入,降低误填和误启概率。
+73. Cloudflared 操作按钮状态继续收紧:未安装时禁用登录与启动,安装按钮切为“已安装”只读态,并通过验证提示区明确引导“先安装再登录再启动”。
+74. `loadCloudflared(...)` 现将安装状态挂到页面上下文,`syncCloudflaredFormState(...)` 统一处理安装态 + 校验态双重禁用逻辑,避免未安装时触发假动作。
+75. Skills 页增加顶部统计卡:将总数、可用、待处理、已禁用四类状态前置,减少用户必须逐段滚动才能理解当前技能态势的成本。
+76. Skills 过滤交互补空态:输入过滤关键字后若无任何匹配项,显示独立空态提示而非留白,增强页面收尾体验。
+77. Cloudflared 状态卡继续前置安装信息:将“安装状态”从按钮语义中抽离成独立状态卡,并让登录 / 启动的未安装兜底同时保留在按钮逻辑层,提升设置页可读性与一致性。
+78. Dashboard 的 Skills 卡片文案已与 Skills 页总览口径对齐:从“可用 / 缺依赖”升级为“可用 / 待处理 / 已禁用”,减少跨页理解落差。
+79. 已开始保守式 upstream 同步:选择性吸收 `upstream/main` 的 `8485df7`(`src-tauri/src/commands/config.rs` clippy 清理),冲突后保留本地行为并仅手动吸收 `.flatten()` 迭代简化,避免整包 merge 冲乱当前分支大规模前端重构。
+80. 已拆解 `7764a32` 并只吸收低风险高价值块:`src/lib/tauri-api.js` 中配置保存后的 3 秒防抖 Gateway 重载,以及 `src/lib/markdown.js` 中图片加载失败提示的反斜杠安全转义;`chat.js` / hosted / 样式重排等高风险块明确暂不手抄。
+81. 用户二次验收后发现 3 个前端漏点仍存在:assistant 设置按钮点击可达性不足、Hosted 面板仍残留“启用托管 Agent”开关语义、全局原生 select 样式未完全统一;已按实际源码补修,不再依赖先前口头判断。
+82. 后续 UI 修复结论必须以“可见 markup + 事件绑定 + 最终共享样式路径”三处源码都核对通过为准,避免再次出现“逻辑已改但用户仍可见旧控件/旧样式”的误判。
+
+## 风险与回滚建议
+- 风险:`chat.js` 仍然较大,后续继续拆分时容易影响事件时序。
+- 风险:`dev-api.js` 仍是单点复杂模块,后续拆分需分批进行。
+- 回滚策略:
+ 1. 每阶段重构前建 checkpoint commit
+ 2. 单主题提交,不混入无关样式或功能
+ 3. 每阶段都执行 `npm run build` 与 `npm test`
+ 4. 历史重写前始终先建 backup 分支
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
+ ? ``
+ : ``
+const output = 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
+```
diff --git a/docs/superpowers/plans/2026-03-18-tool-protocol-compat.md b/docs/superpowers/plans/2026-03-18-tool-protocol-compat.md
new file mode 100644
index 00000000..be18eb6f
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-18-tool-protocol-compat.md
@@ -0,0 +1,34 @@
+# Tool Protocol Compatibility Implementation Plan
+
+日期: 2026-03-18
+
+## 目标
+- 补齐 tool_use_id / result_id 等字段兼容
+- 兼容 payload 结构变体(tool_use/tool_call/tool_result)
+- 保持现有 UI 和渲染逻辑不变
+
+## 变更范围
+- src/pages/chat.js
+ - extractChatContent
+ - extractContent
+ - collectToolsFromMessage
+
+## 实施步骤
+1. 建立检查点提交(code 修改前)
+2. extractChatContent:
+ - callId/resId 增加 tool_use_id / toolUseId / result_id / resultId
+ - input/output 增加 meta?.input/meta?.output 兜底
+3. extractContent:
+ - callId/resId 同步上述字段
+ - tool block input/output 同步兜底
+4. collectToolsFromMessage:
+ - tool_calls: id 增加 tool_use_id/toolUseId
+ - tool_results: id 增加 result_id/resultId
+ - input/output 兜底字段一致化
+5. npm run build 验证
+6. 提交并推送
+
+## 验证清单
+- 工具调用正常显示
+- tool_result 可正确合并
+- 无异常报错
diff --git a/memory/repository-maturity.md b/memory/repository-maturity.md
new file mode 100644
index 00000000..63811ca2
--- /dev/null
+++ b/memory/repository-maturity.md
@@ -0,0 +1,106 @@
+# Repository Maturity
+
+## 本次发现的核心结构问题
+- `src/pages/chat.js` 与 `src/pages/assistant.js` 体量过大,页面职责与业务规则耦合严重。
+- `src/lib/tauri-api.js` 同时承担 transport、缓存、错误包装与部分页面依赖入口,边界不够清晰。
+- `scripts/dev-api.js` 过于庞大,路由、中间件、配置、命令逻辑混杂。
+- UI 层直接理解较多后端响应细节,adapter/view-model 边界不足。
+- chat/high-frequency 页面存在状态机复杂、时序敏感、体验问题容易反复出现的风险。
+
+## 已完成改造项
+- 新增 `src/lib/hosted-agent.js`,抽离 hosted Agent 常量、提示词、解析逻辑、动作文案。
+- `src/pages/chat.js` 改为消费 hosted-agent 模块,降低页面内联业务规则密度。
+- 删除 chat 自动滚动并禁用 chat 虚拟滚动,去掉隐式滚动接管链。
+- 统一 hosted 状态文案、系统提示持久化与状态展示反馈。
+- 新增 `src/lib/history-domain.js`,抽离 history payload 归一化、history hash、entry key、最大时间戳等纯领域规则。
+- `src/pages/chat.js` 改为消费 history-domain 模块,开始把历史处理从页面层往 domain 层迁移。
+- 新增 `src/lib/history-view-model.js`,统一用户图片附件转换、hosted seed 转换、本地历史图片映射、history 持久化消息映射。
+- `chat.js` 中 apply/render 路径开始复用 history-view-model helper,页面层重复转换逻辑继续收缩。
+- 新增 `src/lib/history-render-service.js`,抽离 history 渲染循环、增量渲染去重路径、omitted-images notice 插入。
+- `applyHistoryResult(...)` 与 `applyIncrementalHistoryResult(...)` 开始复用 render-service,history 主流程已不再完全内联在页面文件中。
+- 新增 `src/lib/history-loader-service.js`,抽离 pending payload 消费判定与本地历史回填逻辑。
+- `flushPendingHistory(...)` 与 `loadHistory(...)` 开始复用 loader helper,history loader 路径继续从页面层剥离。
+- 新增 `src/lib/history-apply-service.js`,抽离 history apply 前的 state 更新、hash 判重与 hosted seed 初始化。
+- `applyHistoryResult(...)` 开始复用 apply-service,history apply 路径继续摆脱页面内联状态判断。
+- 新增 `src/lib/hosted-runtime-service.js`,抽离 hosted runtime 的断线暂停、重连恢复、目标哈希与自动触发前状态切换。
+- `pauseHostedForDisconnect(...)`、`resumeHostedFromReconnect(...)`、`maybeTriggerHostedRun(...)` 开始复用 hosted runtime helper,hosted 状态机从页面层继续剥离。
+- 新增 `src/lib/hosted-history-service.js`,抽离 hosted target 捕获、history entry 写入、message 构建与 remote seed 映射。
+- `shouldCaptureHostedTarget(...)`、`pushHostedHistoryEntry(...)`、`buildHostedMessages(...)`、`ensureHostedHistorySeeded(...)` 开始复用 hosted history helper,hosted history 路径持续脱离页面文件。
+- 新增 `src/lib/hosted-step-service.js`,抽离 hosted step 的启动校验、开始运行、模板错误、成功收尾、自停与失败重试状态切换。
+- `runHostedAgentStep(...)` 开始复用 hosted step helper,hosted execution/orchestration 继续从页面层剥离。
+- 新增 `src/lib/hosted-output-service.js`,抽离 hosted 输出解析、instruction 去重发送前准备与 optimistic user reply 构造。
+- `appendHostedOutput(...)` 与 `commitHostedUserReply(...)` 开始复用 hosted output helper,hosted 与 UI/消息发送的交互层继续从页面文件中抽离。
+- 新增 `src/lib/hosted-session-service.js`,抽离 hosted session storage 读写、state 构建与 globals 快照逻辑。
+- `saveHostedSessionConfigForKey(...)`、`buildHostedStateFromStorage(...)`、`withHostedState(...)`、`withHostedStateAsync(...)` 开始复用 hosted session helper,多 session hosted 状态管理继续从页面层剥离。
+- 新增 `src/lib/hosted-orchestrator-service.js`,抽离 hosted remote seed 覆盖判定、cross-session 运行模式判断与 boundSessionKey 对齐逻辑。
+- `ensureHostedHistorySeeded(...)` 与 `runHostedAgentStepForSession(...)` 开始复用 orchestrator helper,hosted 调度链继续摆脱页面文件内联编排。
+- 新增 `src/lib/assistant-api-meta.js`,抽离 assistant API 类型归一化、鉴权要求、提示文案与输入占位元数据。
+- `assistant.js` 开始复用 assistant API meta helper,assistant 领域的第一块独立边界已经建立。
+- 新增 `src/lib/assistant-api-client.js`,抽离 assistant API base URL 规整、鉴权头构造与重试请求逻辑。
+- `assistant.js` 开始复用 assistant API client helper,assistant 页与 API client 基础细节开始解耦。
+- 新增 `src/lib/assistant-session-store.js`,抽离 assistant 配置读写、session 存储读写、序列化裁剪、会话创建与自动标题规则。
+- `assistant.js` 开始复用 assistant session store helper,assistant 页与 config/session store 基础逻辑开始解耦。
+- 新增 `src/lib/assistant-request-state.js`,抽离 assistant 请求生命周期状态、abort controller、queue 与 requestId 管理。
+- `assistant.js` 开始复用 assistant request state helper,assistant 运行态管理开始从页面层剥离。
+- 新增 `src/lib/assistant-attachments.js`,抽离 assistant 附件记录构造、preview HTML、pendingImages 增删清空与多模态消息 content 拼装。
+- `assistant.js` 开始复用 assistant attachments helper,assistant 输入区附件逻辑开始从页面层剥离。
+- 新增 `src/lib/assistant-tool-safety.js`,抽离 assistant 工具危险级别判定、关键命令检测与确认文案生成逻辑。
+- `assistant.js` 开始复用 assistant tool safety helper,assistant 工具确认与安全围栏规则开始从页面层剥离。
+- 新增 `src/lib/assistant-tool-ui.js`,抽离 ask_user 卡片 HTML、回答解析、已回答态渲染与工具块 HTML 生成逻辑。
+- `assistant.js` 开始复用 assistant tool ui helper,assistant 的 ask_user 交互卡片与 tool progress 渲染开始从页面层剥离。
+- 新增 `src/lib/assistant-tool-orchestrator.js`,抽离 tool history entry 构造/收尾与等待态包装逻辑。
+- `callAIWithTools(...)` 开始复用 assistant tool orchestrator helper,assistant 的 tool 调度编排开始从页面层剥离。
+- 新增 `src/lib/assistant-provider-adapters.js`,抽离多 provider API 调用、SSE 读取与工具定义格式转换逻辑。
+- `assistant.js` 开始复用 assistant provider adapters helper,assistant 的 provider-specific 调用入口开始从页面层剥离。
+- 新增 `src/lib/assistant-message-pipeline.js`,抽离用户消息构造、AI 占位消息、请求上下文初始化与重试条 HTML。
+- `assistant.js` 开始复用 assistant message pipeline helper,assistant 主发送流程的基础拼装开始从页面层剥离。
+- 新增 `src/lib/assistant-streaming-service.js`,抽离 tool progress 渲染、流式 chunk 更新与最终 bubble 收尾逻辑。
+- `assistant.js` 开始复用 assistant streaming service helper,assistant 发送 / 重试流程中的重复流式渲染逻辑开始从页面层剥离。
+- 新增 `src/lib/assistant-request-lifecycle.js`,抽离 retry bar 挂载与请求 finally 收尾逻辑。
+- `assistant.js` 开始复用 assistant request lifecycle helper,assistant 发送 / 重试流程中的错误恢复与最终清理逻辑开始从页面层剥离。
+- 新增 `src/lib/assistant-response-runner.js`,抽离 tool 模式与普通流式模式的响应执行主体。
+- `assistant.js` 开始复用 assistant response runner helper,assistant send / retry 两条主路径中的重复响应执行逻辑开始从页面层剥离。
+- 新增 `src/lib/assistant-run-context.js`,抽离响应启动前的按钮状态、首帧 typing UI 与工具模式判定。
+- `assistant.js` 开始复用 assistant run context helper,assistant send / retry 两条主路径中的重复启动壳开始从页面层剥离。
+- 第一批关键体验修复已开始落地:assistant 设置入口按钮改为明确“助手设置”语义并提升点击优先级,assistant 流式输出 / 工具进度 / 后台刷新改为 near-bottom 自动跟随策略。
+- `chat.js` 开始修正心跳历史刷新与托管绑定兜底:`scrollToBottom(...)` 改为 near-bottom 策略,Hosted 绑定会话解析优先参考已启用的托管会话,降低切换会话后的错投概率。
+- Hosted Agent 管理 UI 开始从开关切换改为按钮式管理:移除“启用托管 Agent”开关文案,统一通过“启动托管 / 暂停 / 停止 / 保存配置”按钮管理。
+- `src/lib/hosted-agent.js` 的固定提示词已改为简约指引风格,目标是让托管层回复更短、更像执行指引而不是输出大段内容。
+- `chat.history` 刷新链路继续收紧:全量历史应用在已有消息时不再强制 `scrollToBottom(true)`,改为 only-on-first-load 策略,降低心跳刷新导致的异常滚动。
+- Hosted 错投链路继续修正:`createAskUserBubble(...)` 与 `commitHostedUserReply(...)` 默认优先使用 `getHostedBoundSessionKey()`,且非当前 UI 会话时不再把 optimistic 用户消息误插入当前会话 DOM。
+- `src/lib/hosted-agent.js` 的固定提示词模板已按用户提供版本替换为变量化 Role/Profile/Variables/Skills/Rules 结构,后续如需真的做变量替换逻辑,可在不改模板主体的前提下补一层运行时插值。
+- Hosted Prompt 模板已继续补完用户提供的 `Workflows` 与 `Initialization` 片段,固定四段输出结构与“结构固定,但各段内容最小充分”的原则已写入模板主体。
+- 新增 `src/lib/skills-catalog.js` 作为 Skills 数据轻量缓存层,统一负责 `skillsList` 结果缓存、TTL、失效与摘要统计,减少 Dashboard / Skills 重复加载成本。
+- Dashboard 总览卡已把 `MCP 工具` 正式切换为 `Skills`,显示真实 Skills 总数与可用/缺依赖摘要,不再从 `readMcpConfig()` 推导这个卡片数字。
+- `src/pages/skills.js` 已开始复用 skills catalog cache:优先渲染缓存结果、后台刷新;安装依赖 / 安装 Skill / 卸载 / 手动刷新时统一失效缓存并强制重载,统计摘要现包含 blocked 数量。
+- `src/components/sidebar.js` 已新增分组折叠态记忆:各导航分组可单独展开/折叠,状态持久化到 localStorage,并保持桌面侧边栏整体折叠模式兼容。
+- Sidebar icon 已收一版:`dashboard` / `services` / `skills` 图标已重做,分组标题切换器改为自定义按钮结构,并在 `src/style/layout.css` 补齐样式避免原生按钮观感。
+- 公网访问分层表单主入口已确认在 `src/pages/settings.js` 的 `cloudflared` 区块;下一轮直接从 `loadCloudflared(...)` / `handleCloudflaredStart(...)` 下手。
+- `src/pages/settings.js` 的 Cloudflared 公网访问表单已改为四层结构:状态卡、启动操作、暴露目标、隧道模式;保持原启动参数不变,只重构展示与交互层。
+- 新增 `syncCloudflaredFormState(...)`:切换 `cloudflared-mode` / `cloudflared-expose` / `cloudflared-port` 时,动态切换对应表单块可见性并实时更新实际端口展示。
+- Cloudflared 表单已继续收紧交互边界:新增 `validateCloudflaredForm(...)`,命名隧道缺少隧道名/域名、自定义端口为空时禁止启动,并通过提示文案与按钮禁用态即时反馈。
+- `syncCloudflaredFormState(...)` 现同时负责输入禁用态:非自定义目标时禁用端口输入,非命名隧道时禁用隧道名/域名输入,降低误填和误启概率。
+- Cloudflared 操作按钮状态已继续收紧:未安装时禁用登录与启动,安装按钮切为“已安装”只读态,并通过验证提示区明确引导“先安装再登录再启动”。
+- `loadCloudflared(...)` 现将安装状态挂到页面上下文,`syncCloudflaredFormState(...)` 统一处理安装态 + 校验态双重禁用逻辑,避免未安装时触发假动作。
+- Skills 页已增加顶部统计卡:将总数、可用、待处理、已禁用四类状态前置,减少用户必须逐段滚动才能理解当前技能态势的成本。
+- Skills 过滤交互已补空态:输入过滤关键字后若无任何匹配项,显示独立空态提示而非留白。
+- Cloudflared 状态卡已继续前置安装信息:将“安装状态”从按钮语义中抽离成独立状态卡,并让登录 / 启动的未安装兜底同时保留在按钮逻辑层。
+- Dashboard 的 Skills 卡片文案已与 Skills 页总览口径对齐:从“可用 / 缺依赖”升级为“可用 / 待处理 / 已禁用”,减少跨页理解落差。
+- 已开始保守式 upstream 同步:选择性吸收 `upstream/main` 的 `8485df7`(`src-tauri/src/commands/config.rs` clippy 清理),冲突后保留本地行为并仅手动吸收 `.flatten()` 迭代简化,避免整包 merge 冲乱当前分支大规模前端重构。
+- 已拆解 `7764a32` 并只吸收低风险高价值块:`src/lib/tauri-api.js` 中配置保存后的 3 秒防抖 Gateway 重载,以及 `src/lib/markdown.js` 中图片加载失败提示的反斜杠安全转义;`chat.js` / hosted / 样式重排等高风险块明确暂不手抄。
+- 用户二次验收后发现 3 个前端漏点仍存在:assistant 设置按钮点击可达性不足、Hosted 面板仍残留“启用托管 Agent”开关语义、全局原生 select 样式未完全统一;已按实际源码补修,不再依赖先前口头判断。
+- 后续 UI 修复结论必须以“可见 markup + 事件绑定 + 最终共享样式路径”三处源码都核对通过为准,避免再次出现“逻辑已改但用户仍可见旧控件/旧样式”的误判。
+
+## 后续建议
+- 继续拆 `src/pages/chat.js`:history/domain、hosted runtime/service、session event adapter。
+- 继续拆 `src/pages/assistant.js`:配置表单、状态机、工具调用、渲染层。
+- 为 `tauri-api.js` 增加 adapter/view-model 边界,减少页面直接消费 command 细节。
+- 分阶段拆 `scripts/dev-api.js`,优先 route dispatch 与 command handler 分离。
+- 补 lint / 类型检查或等价静态校验,治理 Vite import warning。
+
+## 约定的代码治理原则
+- 页面层只负责页面装配与交互编排。
+- 纯业务规则、提示词模板、解析逻辑优先抽到 `src/lib/`。
+- UI 不直接耦合底层后端 DTO,优先通过 adapter/view-model 消费。
+- 能删复杂逻辑就删,不为保留复杂实现而牺牲稳定性。
+- 巨型文件优先按“常量/纯函数/状态转换”顺序渐进拆分,避免一次性大重构。
diff --git a/package.json b/package.json
index 480ba8d3..56dbe30b 100644
--- a/package.json
+++ b/package.json
@@ -29,14 +29,17 @@
"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",
- "@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",
- "vite": "^6.3.5"
+ "vite": "^6.3.5",
+ "vitest": "^2.1.9"
}
}
diff --git a/scripts/dev-api.js b/scripts/dev-api.js
index 8bd37e0a..244e3828 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, 'openclaw.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')
@@ -49,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
@@ -449,9 +513,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')
@@ -806,8 +959,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',
@@ -826,8 +980,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
}
@@ -967,7 +1121,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,
@@ -986,7 +1141,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',
@@ -1023,7 +1179,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
@@ -1129,7 +1286,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],
@@ -1356,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',
@@ -1399,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' },
@@ -1413,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' },
@@ -1488,16 +1649,18 @@ function serverCached(key, ttlMs, fn) {
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
},
@@ -1528,17 +1691,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',
+ }]
})
},
@@ -1607,8 +1774,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,
@@ -1617,8 +1785,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 = {}
@@ -1647,8 +1816,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') {
@@ -1673,31 +1843,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 }
},
@@ -1762,9 +1934,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))
@@ -1777,14 +1948,15 @@ 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() })
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
@@ -1799,9 +1971,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))
@@ -1810,9 +1981,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))
@@ -1822,11 +1992,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))
@@ -1843,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('该端点已存在')
@@ -2493,10 +2663,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,不加自定义字段
@@ -2826,7 +2997,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() {
@@ -3169,7 +3341,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 }
},
@@ -3177,8 +3350,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
},
@@ -3191,8 +3365,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) {
@@ -3207,8 +3382,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
},
@@ -3343,13 +3518,14 @@ 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 = {
- "$schema": "https://openclaw.ai/schema/config.json",
+ "$schema": "https://openclaw.ai/schema/openclaw.json",
meta: { lastTouchedVersion },
models: { providers: {} },
gateway: {
@@ -3360,13 +3536,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 {
@@ -3395,11 +3572,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,
@@ -3644,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 || '(命令已执行,无输出)'
@@ -3662,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`)
@@ -3671,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 || '')
@@ -3680,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 => {
@@ -3825,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)
@@ -3873,6 +4047,18 @@ 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 } },
+
+ // 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 }) {
@@ -3896,29 +4082,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
},
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/Cargo.lock b/src-tauri/Cargo.lock
index 2cf6ecad..06609d0d 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -346,6 +346,7 @@ dependencies = [
"tauri-plugin-shell",
"tokio",
"urlencoding",
+ "winreg 0.52.0",
"zip",
]
@@ -760,7 +761,7 @@ dependencies = [
"rustc_version",
"toml 0.9.12+spec-1.1.0",
"vswhom",
- "winreg",
+ "winreg 0.55.0",
]
[[package]]
@@ -4912,6 +4913,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"
@@ -4963,6 +4973,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"
@@ -5020,6 +5045,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"
@@ -5038,6 +5069,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"
@@ -5056,6 +5093,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"
@@ -5086,6 +5129,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"
@@ -5104,6 +5153,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"
@@ -5122,6 +5177,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"
@@ -5140,6 +5201,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"
@@ -5170,6 +5237,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 8d7468cc..ddf18ca8 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -32,3 +32,4 @@ base64 = "0.22"
urlencoding = "2"
regex = "1"
tokio = { version = "1", features = ["process", "time"] }
+winreg = "0.52"
diff --git a/src-tauri/src/commands/agent.rs b/src-tauri/src/commands/agent.rs
index 180a9f8b..185771d3 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 =
@@ -202,7 +202,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}"))?;
@@ -325,7 +325,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/assistant.rs b/src-tauri/src/commands/assistant.rs
index b1f578cd..61ac070a 100644
--- a/src-tauri/src/commands/assistant.rs
+++ b/src-tauri/src/commands/assistant.rs
@@ -149,11 +149,20 @@ pub async fn assistant_exec(command: String, cwd: Option) -> Result) -> 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-tauri/src/commands/cloudflared.rs b/src-tauri/src/commands/cloudflared.rs
new file mode 100644
index 00000000..f50cae2f
--- /dev/null
+++ b/src-tauri/src/commands/cloudflared.rs
@@ -0,0 +1,523 @@
+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};
+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;
+use tokio::io::AsyncBufReadExt;
+
+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(())
+}
+
+#[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("配置格式错误")?;
+ 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 existing_child = {
+ let mut state = STATE.lock().unwrap();
+ let child = state.child.take();
+ state.running = false;
+ state.url = None;
+ state.last_error = None;
+ child
+ };
+ if let Some(mut child) = existing_child {
+ let _ = child.kill().await;
+ }
+
+ 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}"))?;
+
+ if let Some(stderr) = child.stderr.take() {
+ tokio::spawn(async move {
+ let mut reader = tokio::io::BufReader::new(stderr).lines();
+ while let Ok(Some(line)) = reader.next_line().await {
+ let mut state = STATE.lock().unwrap();
+ state.last_error = Some(line);
+ }
+ });
+ }
+
+ // parse stdout for url
+ if let Some(stdout) = child.stdout.take() {
+ let mut reader = tokio::io::BufReader::new(stdout).lines();
+ let deadline = std::time::Instant::now() + Duration::from_secs(12);
+ loop {
+ 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;
+ }
+ }
+ Ok(Ok(None)) => break,
+ Ok(Err(_)) => break,
+ Err(_) => 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 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;
+ }
+ 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)),
+ 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 7b15fc53..a8e3bc2e 100644
--- a/src-tauri/src/commands/config.rs
+++ b/src-tauri/src/commands/config.rs
@@ -66,6 +66,7 @@ struct VersionPolicyEntry {
chinese: VersionPolicySource,
}
+#[allow(dead_code)]
#[derive(Debug, Deserialize, Default)]
struct R2Config {
#[serde(default)]
@@ -135,6 +136,7 @@ fn load_version_policy() -> VersionPolicy {
serde_json::from_str(include_str!("../../../openclaw-version-policy.json")).unwrap_or_default()
}
+#[allow(dead_code)]
fn r2_config() -> R2Config {
load_version_policy().r2
}
@@ -222,6 +224,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> =
@@ -296,7 +308,7 @@ fn npm_command() -> 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
@@ -305,7 +317,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
}
@@ -322,7 +334,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
}
@@ -378,7 +390,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 解析失败
@@ -399,7 +411,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]) {
@@ -422,7 +434,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);
@@ -431,12 +443,54 @@ 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(含备份和清理)
+#[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())
}
@@ -449,7 +503,7 @@ 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 _ = fs::copy(&path, &bak);
@@ -743,7 +797,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) {
@@ -770,7 +831,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)
@@ -854,10 +914,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")
@@ -866,20 +928,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")))]
@@ -1033,6 +1098,7 @@ pub async fn upgrade_openclaw(
}
/// 检测当前平台标识(用于 R2 归档文件名)
+#[allow(dead_code)]
fn r2_platform_key() -> &'static str {
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
{
@@ -1067,6 +1133,7 @@ fn r2_platform_key() -> &'static str {
}
/// npm 全局 node_modules 目录
+#[allow(dead_code)]
fn npm_global_modules_dir() -> Option {
#[cfg(target_os = "windows")]
{
@@ -1104,6 +1171,7 @@ fn npm_global_modules_dir() -> Option {
}
/// npm 全局 bin 目录
+#[allow(dead_code)]
fn npm_global_bin_dir() -> Option {
#[cfg(target_os = "windows")]
{
@@ -1285,11 +1353,12 @@ async fn try_standalone_install(
// 归档内可能有 openclaw/ 子目录,需要提升一层
let nested = install_dir.join("openclaw");
if nested.exists() && nested.join("node.exe").exists() {
- for entry in std::fs::read_dir(&nested).map_err(|e| format!("读取目录失败: {e}"))? {
- if let Ok(entry) = entry {
- let dest = install_dir.join(entry.file_name());
- let _ = std::fs::rename(entry.path(), &dest);
- }
+ for entry in std::fs::read_dir(&nested)
+ .map_err(|e| format!("读取目录失败: {e}"))?
+ .flatten()
+ {
+ let dest = install_dir.join(entry.file_name());
+ let _ = std::fs::rename(entry.path(), &dest);
}
let _ = std::fs::remove_dir_all(&nested);
}
@@ -1402,6 +1471,7 @@ async fn try_standalone_install(
/// 尝试从 R2 CDN 下载预装归档安装 OpenClaw(跳过 npm 依赖解析)
/// 成功返回 Ok(版本号),失败返回 Err(原因) 供 caller 降级到 npm install
+#[allow(dead_code)]
async fn try_r2_install(
app: &tauri::AppHandle,
version: &str,
@@ -2151,11 +2221,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() {
@@ -2172,7 +2242,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": {
@@ -2196,11 +2266,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))
@@ -2212,7 +2287,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
new file mode 100644
index 00000000..e5c59b37
--- /dev/null
+++ b/src-tauri/src/commands/gateway_patch.rs
@@ -0,0 +1,325 @@
+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,
+ #[serde(rename = "openclawVersion")]
+ pub openclaw_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 {
+ if !backup_exists(&reply_path) || !backup_exists(&gateway_path) {
+ let err = "缺少备份,建议先一键补丁".to_string();
+ status.last_error = Some(err.clone());
+ status.installed_version = openclaw_version.clone();
+ status.openclaw_version = status.openclaw_version.or(openclaw_version.clone());
+ status.files = files.clone();
+ let _ = write_status_to_panel(&status);
+ return Err(err);
+ }
+ 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.clone();
+ status.openclaw_version = status.openclaw_version.or(openclaw_version.clone());
+ 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.clone();
+ status.openclaw_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}"))
+}
+
+pub(crate) 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
+ };
+ crate::commands::apply_system_env(&mut cmd);
+ 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 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);
+ }
+ }
+ Err("未找到 openclaw dist 目录".to_string())
+}
+
+fn read_openclaw_version() -> Result {
+ let root = npm_root_global()?;
+ 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);
+ }
+ }
+ Err("openclaw 版本为空".to_string())
+}
+
+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_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}"))?;
+ 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 replace_once_any(hay: &str, needles: &[&str], replacement: &str) -> 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_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}"))?;
+ 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 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_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}"))?;
+ Ok(true)
+}
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..f61c78a9 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()?;
@@ -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-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs
index 948ff680..4a23b8b9 100644
--- a/src-tauri/src/commands/mod.rs
+++ b/src-tauri/src/commands/mod.rs
@@ -1,10 +1,17 @@
+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;
pub mod config;
pub mod device;
pub mod extensions;
@@ -18,19 +25,55 @@ 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)
+pub fn openclaw_config_path() -> PathBuf {
+ openclaw_dir().join("openclaw.json")
}
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
@@ -134,9 +177,182 @@ 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 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)> {
+ 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(
+ 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);
+
+ 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()));
+ }
+ built
+ }
+
+ #[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);
+ 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()));
+ }
+ built
+ }
+}
+
/// 缓存 enhanced_path 结果,避免每次调用都扫描文件系统
/// 使用 RwLock 替代 OnceLock,支持运行时刷新缓存
static ENHANCED_PATH_CACHE: RwLock '
}
html += ''
@@ -200,6 +228,22 @@ export function renderSidebar(el) {
if (!_delegated) {
_delegated = true
el.addEventListener('click', (e) => {
+ const sectionToggle = e.target.closest('[data-section-toggle]')
+ if (sectionToggle) {
+ const key = sectionToggle.dataset.sectionToggle
+ const sectionEl = e.target.closest('.nav-section[data-section]')
+ const itemsEl = sectionEl?.querySelector('.nav-section-items')
+ const nextOpen = sectionEl?.classList.contains('collapsed')
+ if (sectionEl) {
+ sectionEl.classList.toggle('expanded', !!nextOpen)
+ sectionEl.classList.toggle('collapsed', !nextOpen)
+ }
+ if (itemsEl) itemsEl.style.display = nextOpen ? '' : 'none'
+ const chevron = sectionToggle.querySelector('.nav-section-chevron')
+ if (chevron) chevron.textContent = nextOpen ? '−' : '+'
+ if (key) _setSidebarGroupOpen(key, !!nextOpen)
+ return
+ }
// 导航点击
const navItem = e.target.closest('.nav-item[data-route]')
if (navItem) {
diff --git a/src/lib/app-state.js b/src/lib/app-state.js
index 90039cfe..47c4ea02 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,15 @@ 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 {} })
+ const wasRunning = _gatewayRunning
+ _gatewayRunning = false
+ _gwStopCount = 0
+ _autoRestartCount = 0
+ _lastRestartTime = 0
+ _gatewayRunningSince = 0
+ _userStopped = false
+ _emit(_gwListeners, false)
+ _emit(_instanceListeners, _activeInstance)
}
export async function loadActiveInstance() {
@@ -103,8 +119,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 +150,7 @@ export async function detectOpenclawStatus() {
} catch {
_openclawReady = false
}
- _listeners.forEach(fn => { try { fn(_openclawReady) } catch {} })
+ _emit(_listeners, _openclawReady)
return _openclawReady
}
@@ -153,7 +169,7 @@ function _setGatewayRunning(val) {
} else if (!val) {
_gatewayRunningSince = 0
}
- _gwListeners.forEach(fn => { try { fn(val) } catch {} })
+ _emit(_gwListeners, val)
}
}
@@ -169,7 +185,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 +244,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) }
}
diff --git a/src/lib/assistant-api-client.js b/src/lib/assistant-api-client.js
new file mode 100644
index 00000000..440df8be
--- /dev/null
+++ b/src/lib/assistant-api-client.js
@@ -0,0 +1,60 @@
+import { normalizeAssistantApiType } from './assistant-api-meta.js'
+
+export function cleanAssistantBaseUrl(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 = normalizeAssistantApiType(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
+}
+
+export function buildAssistantAuthHeaders(apiType, apiKey = '') {
+ const type = normalizeAssistantApiType(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
+}
+
+export async function fetchAssistantWithRetry(url, options, retries = 3) {
+ const delays = [1000, 2000, 4000]
+ let lastErr = null
+ for (let i = 0; i <= retries; i++) {
+ try {
+ const resp = await fetch(url, options)
+ if (resp.ok || resp.status < 500 || i >= retries) return resp
+ const retryAfter = Number(resp.headers.get('retry-after') || 0)
+ const waitMs = retryAfter > 0 ? retryAfter * 1000 : delays[i]
+ await new Promise(r => setTimeout(r, waitMs))
+ } catch (err) {
+ lastErr = err
+ if (options?.signal?.aborted) throw err
+ if (i >= retries) throw err
+ await new Promise(r => setTimeout(r, delays[i]))
+ }
+ }
+ throw lastErr || new Error('请求失败')
+}
diff --git a/src/lib/assistant-api-meta.js b/src/lib/assistant-api-meta.js
new file mode 100644
index 00000000..1d697cb3
--- /dev/null
+++ b/src/lib/assistant-api-meta.js
@@ -0,0 +1,36 @@
+export function normalizeAssistantApiType(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'
+}
+
+export function requiresAssistantApiKey(apiType) {
+ const type = normalizeAssistantApiType(apiType)
+ return type === 'anthropic-messages' || type === 'google-gemini'
+}
+
+export function getAssistantApiHintText(apiType) {
+ return {
+ 'openai-completions': '自动兼容 Chat Completions 和 Responses API;Ollama 可留空 API Key',
+ 'anthropic-messages': '使用 Anthropic Messages API(/v1/messages)',
+ 'google-gemini': '使用 Gemini generateContent API',
+ }[normalizeAssistantApiType(apiType)] || '自动兼容 Chat Completions 和 Responses API;Ollama 可留空 API Key'
+}
+
+export function getAssistantApiBasePlaceholder(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',
+ }[normalizeAssistantApiType(apiType)] || 'https://api.openai.com/v1'
+}
+
+export function getAssistantApiKeyPlaceholder(apiType) {
+ return {
+ 'openai-completions': 'sk-...(Ollama 可留空)',
+ 'anthropic-messages': 'sk-ant-...',
+ 'google-gemini': 'AIza...',
+ }[normalizeAssistantApiType(apiType)] || 'sk-...'
+}
diff --git a/src/lib/assistant-attachments.js b/src/lib/assistant-attachments.js
new file mode 100644
index 00000000..bb3b6419
--- /dev/null
+++ b/src/lib/assistant-attachments.js
@@ -0,0 +1,39 @@
+export function createAssistantPendingImage({ dataUrl, name, width, height, idFactory }) {
+ return {
+ id: typeof idFactory === 'function' ? idFactory() : Date.now().toString() + Math.random().toString(36).slice(2, 6),
+ dataUrl,
+ name: name || 'image.jpg',
+ width,
+ height,
+ }
+}
+
+export function removeAssistantPendingImage(images, id) {
+ return (Array.isArray(images) ? images : []).filter(img => img.id !== id)
+}
+
+export function clearAssistantPendingImages() {
+ return []
+}
+
+export function buildAssistantImagePreviewHtml(images, escapeHtml, deleteSvg) {
+ return (Array.isArray(images) ? images : []).map(img => `
+
+

+
+
+ `).join('')
+}
+
+export function buildAssistantMessageContent(text, images) {
+ if (!images || images.length === 0) return text
+ const parts = []
+ if (text) parts.push({ type: 'text', text })
+ for (const img of images) {
+ parts.push({
+ type: 'image_url',
+ image_url: { url: img.dataUrl, detail: 'auto' },
+ })
+ }
+ return parts
+}
diff --git a/src/lib/assistant-core.js b/src/lib/assistant-core.js
new file mode 100644
index 00000000..e49ddcac
--- /dev/null
+++ b/src/lib/assistant-core.js
@@ -0,0 +1,1095 @@
+/**
+ * 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, signal }) {
+ 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 timeoutController = new AbortController()
+ const totalTimer = setTimeout(() => timeoutController.abort(new DOMException('请求超时', 'AbortError')), TIMEOUT_TOTAL)
+ const activeSignal = signal ? AbortSignal.any([signal, timeoutController.signal]) : timeoutController.signal
+ let buffer = ''
+ const onChunk = (chunk) => { buffer += chunk }
+
+ try {
+ if (apiType === 'anthropic-messages') {
+ await callAnthropicMessages({ base, config: cfg, messages: allMessages, onChunk, signal: activeSignal })
+ } else if (apiType === 'google-gemini') {
+ await callGeminiGenerate({ base, config: cfg, messages: allMessages, onChunk, signal: activeSignal })
+ } else {
+ try {
+ await callChatCompletions({ base, config: cfg, messages: allMessages, onChunk, signal: activeSignal })
+ } 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: activeSignal })
+ } 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, signal }) {
+ 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 timeoutController = new AbortController()
+ const totalTimer = setTimeout(() => timeoutController.abort(new DOMException('请求超时', 'AbortError')), TIMEOUT_TOTAL)
+ const activeSignal = signal ? AbortSignal.any([signal, timeoutController.signal]) : timeoutController.signal
+
+ 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: activeSignal,
+ })
+ 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: activeSignal,
+ })
+ 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: activeSignal,
+ })
+
+ 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,
+}
+
diff --git a/src/lib/assistant-message-pipeline.js b/src/lib/assistant-message-pipeline.js
new file mode 100644
index 00000000..91762024
--- /dev/null
+++ b/src/lib/assistant-message-pipeline.js
@@ -0,0 +1,40 @@
+export function createAssistantUserMessage({ text, images, buildMessageContent, persistImage }) {
+ const textContent = text.trim()
+ const msgContent = buildMessageContent(textContent, images)
+ const userMsg = { role: 'user', content: msgContent, ts: Date.now() }
+
+ if (images.length > 0) {
+ userMsg._images = images.map(i => {
+ const dbId = 'img_' + i.id
+ persistImage(dbId, i.dataUrl)
+ return { dbId, dataUrl: i.dataUrl, name: i.name, width: i.width, height: i.height }
+ })
+ }
+ if (textContent) userMsg._text = textContent
+ return userMsg
+}
+
+export function createAssistantAiPlaceholder() {
+ return { role: 'assistant', content: '', ts: Date.now() }
+}
+
+export function createAssistantRequestContext(sessionId, nextRequestId, patchRequestState) {
+ const requestId = nextRequestId(sessionId)
+ const requestController = new AbortController()
+ patchRequestState(sessionId, {
+ streaming: true,
+ abortController: requestController,
+ status: 'streaming',
+ })
+ return { requestId, requestController }
+}
+
+export function buildAssistantRetryBarHtml() {
+ const retrySvg = '
'
+ const continueSvg = '
'
+ return `
+
+
+
请求失败(已自动重试 3 次)
+ `
+}
diff --git a/src/lib/assistant-provider-adapters.js b/src/lib/assistant-provider-adapters.js
new file mode 100644
index 00000000..be23a8ff
--- /dev/null
+++ b/src/lib/assistant-provider-adapters.js
@@ -0,0 +1,313 @@
+export async function readAssistantSSEStream(resp, onEvent, signal, timeoutChunk) {
+ 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 秒内未收到数据')), timeoutChunk)
+ )
+ 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)
+ }
+}
+
+export function convertAssistantToolsForAnthropic(tools) {
+ return tools.map(t => ({
+ name: t.function.name,
+ description: t.function.description || '',
+ input_schema: t.function.parameters || { type: 'object', properties: {} },
+ }))
+}
+
+export function convertAssistantToolsForGemini(tools) {
+ return [{ functionDeclarations: tools.map(t => ({
+ name: t.function.name,
+ description: t.function.description || '',
+ parameters: t.function.parameters || { type: 'object', properties: {} },
+ })) }]
+}
+
+export async function callAssistantChatCompletions({ base, messages, onChunk, signal, config, fetchWithRetry, authHeaders, setDebugInfo }) {
+ const url = base + '/chat/completions'
+ const body = {
+ model: config.model,
+ messages,
+ stream: true,
+ temperature: config.temperature || 0.7,
+ }
+
+ const reqTime = Date.now()
+ setDebugInfo({
+ url,
+ method: 'POST',
+ requestBody: { ...body, messages: body.messages.map(m => ({ role: m.role, content: typeof m.content === 'string' ? m.content.slice(0, 200) + (m.content.length > 200 ? '...' : '') : '[multimodal]' })) },
+ requestTime: new Date(reqTime).toLocaleString('zh-CN'),
+ })
+
+ const resp = await fetchWithRetry(url, {
+ method: 'POST',
+ headers: authHeaders(),
+ body: JSON.stringify(body),
+ signal,
+ })
+
+ setDebugInfo(prev => ({
+ ...prev,
+ status: resp.status,
+ contentType: resp.headers.get('content-type') || '',
+ responseTime: new Date().toLocaleString('zh-CN'),
+ latency: Date.now() - reqTime + 'ms',
+ }))
+
+ if (!resp.ok) {
+ const errText = await resp.text().catch(() => '')
+ setDebugInfo(prev => ({ ...prev, errorBody: errText.slice(0, 500) }))
+ let errMsg = `API 错误 ${resp.status}`
+ try {
+ const errJson = JSON.parse(errText)
+ errMsg = errJson.error?.message || errJson.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')) {
+ setDebugInfo(prev => ({ ...prev, streaming: true }))
+ let chunkCount = 0
+ let contentChunks = 0
+ let reasoningChunks = 0
+ let reasoningBuf = ''
+
+ await readAssistantSSEStream(resp, (json) => {
+ chunkCount++
+ const d = json.choices?.[0]?.delta
+ if (!d) return
+ if (d.content) {
+ contentChunks++
+ onChunk(d.content)
+ } else if (d.reasoning_content) {
+ reasoningChunks++
+ reasoningBuf += d.reasoning_content
+ }
+ }, signal, 30000)
+
+ setDebugInfo(prev => ({ ...prev, chunks: { total: chunkCount, content: contentChunks, reasoning: reasoningChunks } }))
+
+ if (contentChunks === 0 && reasoningBuf) {
+ console.warn('[assistant] 无 content 块,使用 reasoning_content 作为回复')
+ onChunk(reasoningBuf)
+ setDebugInfo(prev => ({ ...prev, fallbackToReasoning: true }))
+ }
+ return
+ }
+
+ setDebugInfo(prev => ({ ...prev, streaming: false }))
+ const json = await resp.json()
+ setDebugInfo(prev => ({ ...prev, responseBody: { id: json.id, model: json.model, object: json.object, usage: json.usage } }))
+ console.log('[assistant] 非流式响应:', json)
+ const msg = json.choices?.[0]?.message
+ const content = msg?.content || msg?.reasoning_content || ''
+ if (content) onChunk(content)
+}
+
+export async function callAssistantResponsesAPI({ base, messages, onChunk, signal, config, fetchWithRetry, authHeaders }) {
+ 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(),
+ body: JSON.stringify(body),
+ signal,
+ })
+
+ if (!resp.ok) {
+ const errText = await resp.text().catch(() => '')
+ let errMsg = `API 错误 ${resp.status}`
+ try {
+ const errJson = JSON.parse(errText)
+ errMsg = errJson.error?.message || errJson.message || errMsg
+ } catch {
+ if (errText) errMsg += `: ${errText.slice(0, 200)}`
+ }
+ throw new Error(errMsg)
+ }
+
+ await readAssistantSSEStream(resp, (json) => {
+ if (json.type === 'response.output_text.delta') {
+ if (json.delta) onChunk(json.delta)
+ }
+ if (json.choices?.[0]?.delta?.content) {
+ onChunk(json.choices[0].delta.content)
+ }
+ }, signal, 30000)
+}
+
+export async function callAssistantAnthropicMessages({ base, messages, onChunk, signal, config, fetchWithRetry, authHeaders, setDebugInfo }) {
+ 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 reqTime = Date.now()
+ setDebugInfo({
+ url,
+ method: 'POST',
+ requestBody: { ...body, messages: body.messages.map(m => ({ role: m.role, content: typeof m.content === 'string' ? m.content.slice(0, 200) + (m.content.length > 200 ? '...' : '') : '[multimodal]' })) },
+ requestTime: new Date(reqTime).toLocaleString('zh-CN'),
+ })
+
+ const resp = await fetchWithRetry(url, {
+ method: 'POST',
+ headers: authHeaders(),
+ body: JSON.stringify(body),
+ signal,
+ })
+
+ setDebugInfo(prev => ({
+ ...prev,
+ status: resp.status,
+ contentType: resp.headers.get('content-type') || '',
+ responseTime: new Date().toLocaleString('zh-CN'),
+ latency: Date.now() - reqTime + 'ms',
+ }))
+
+ if (!resp.ok) {
+ const errText = await resp.text().catch(() => '')
+ setDebugInfo(prev => ({ ...prev, errorBody: errText.slice(0, 500) }))
+ let errMsg = `API 错误 ${resp.status}`
+ try {
+ const errJson = JSON.parse(errText)
+ errMsg = errJson.error?.message || errJson.message || errMsg
+ } catch {
+ if (errText) errMsg += `: ${errText.slice(0, 200)}`
+ }
+ throw new Error(errMsg)
+ }
+
+ setDebugInfo(prev => ({ ...prev, streaming: true }))
+ let chunkCount = 0
+ let contentChunks = 0
+ let thinkingChunks = 0
+ let thinkingBuf = ''
+
+ await readAssistantSSEStream(resp, (json) => {
+ chunkCount++
+ if (json.type === 'content_block_delta') {
+ const delta = json.delta
+ if (delta?.type === 'text_delta' && delta.text) {
+ contentChunks++
+ onChunk(delta.text)
+ } else if (delta?.type === 'thinking_delta' && delta.thinking) {
+ thinkingChunks++
+ thinkingBuf += delta.thinking
+ }
+ }
+ }, signal, 30000)
+
+ setDebugInfo(prev => ({ ...prev, chunks: { total: chunkCount, content: contentChunks, thinking: thinkingChunks } }))
+ if (contentChunks === 0 && thinkingBuf) {
+ console.warn('[assistant] Anthropic: 无 text 块,使用 thinking 作为回复')
+ onChunk(thinkingBuf)
+ setDebugInfo(prev => ({ ...prev, fallbackToThinking: true }))
+ }
+}
+
+export async function callAssistantGeminiGenerate({ base, messages, onChunk, signal, config, fetchWithRetry, setDebugInfo }) {
+ 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 reqTime = Date.now()
+ setDebugInfo({ url: url.replace(config.apiKey, '***'), method: 'POST', requestTime: new Date(reqTime).toLocaleString('zh-CN') })
+
+ const resp = await fetchWithRetry(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ signal,
+ })
+
+ setDebugInfo(prev => ({ ...prev, status: resp.status, latency: Date.now() - reqTime + 'ms' }))
+
+ 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)
+ }
+
+ setDebugInfo(prev => ({ ...prev, streaming: true }))
+ let chunkCount = 0
+ await readAssistantSSEStream(resp, (json) => {
+ chunkCount++
+ const text = json.candidates?.[0]?.content?.parts?.[0]?.text
+ if (text) onChunk(text)
+ }, signal, 30000)
+
+ setDebugInfo(prev => ({ ...prev, chunks: { total: chunkCount } }))
+}
diff --git a/src/lib/assistant-request-lifecycle.js b/src/lib/assistant-request-lifecycle.js
new file mode 100644
index 00000000..2421f31b
--- /dev/null
+++ b/src/lib/assistant-request-lifecycle.js
@@ -0,0 +1,39 @@
+export function mountAssistantRetryBar({ messagesEl, buildRetryBarHtml, onRetry, onContinue }) {
+ if (!messagesEl) return null
+ const retryBar = document.createElement('div')
+ retryBar.className = 'ast-retry-bar'
+ retryBar.innerHTML = buildRetryBarHtml()
+ messagesEl.appendChild(retryBar)
+ messagesEl.scrollTop = messagesEl.scrollHeight
+
+ retryBar.querySelector('.ast-btn-retry')?.addEventListener('click', () => onRetry?.(retryBar))
+ retryBar.querySelector('.ast-btn-continue')?.addEventListener('click', () => onContinue?.(retryBar))
+ return retryBar
+}
+
+export function finalizeAssistantRequestLifecycle({
+ session,
+ requestId,
+ clearRequestState,
+ getSessionStatus,
+ currentSessionId,
+ messagesEl,
+ renderMessages,
+ flushSave,
+ processQueue,
+ focusTextarea,
+ isActiveRequest,
+}) {
+ clearRequestState(session.id, {
+ keepStatus: getSessionStatus(session.id) === 'error',
+ requestId,
+ })
+ if (currentSessionId === session.id) focusTextarea?.()
+ session.updatedAt = Date.now()
+ flushSave()
+ if (isActiveRequest(session.id, requestId) && messagesEl && currentSessionId === session.id) {
+ renderMessages()
+ messagesEl.scrollTop = messagesEl.scrollHeight
+ }
+ setTimeout(() => processQueue(session.id), 100)
+}
diff --git a/src/lib/assistant-request-state.js b/src/lib/assistant-request-state.js
new file mode 100644
index 00000000..bd5fe18a
--- /dev/null
+++ b/src/lib/assistant-request-state.js
@@ -0,0 +1,63 @@
+export function ensureAssistantRequestState(stateMap, sessionId) {
+ if (!sessionId) return null
+ if (!stateMap.has(sessionId)) {
+ stateMap.set(sessionId, {
+ streaming: false,
+ abortController: null,
+ status: 'idle',
+ queue: [],
+ requestId: 0,
+ })
+ }
+ return stateMap.get(sessionId)
+}
+
+export function patchAssistantRequestState(stateMap, sessionId, patch = {}) {
+ const state = ensureAssistantRequestState(stateMap, sessionId)
+ if (!state) return null
+ Object.assign(state, patch)
+ return state
+}
+
+export function getAssistantStreaming(stateMap, sessionId) {
+ return ensureAssistantRequestState(stateMap, sessionId)?.streaming === true
+}
+
+export function setAssistantStreaming(stateMap, sessionId, value) {
+ if (!sessionId) return null
+ return patchAssistantRequestState(stateMap, sessionId, { streaming: value === true })
+}
+
+export function getAssistantAbortController(stateMap, sessionId) {
+ return ensureAssistantRequestState(stateMap, sessionId)?.abortController || null
+}
+
+export function setAssistantAbortController(stateMap, sessionId, controller) {
+ if (!sessionId) return null
+ return patchAssistantRequestState(stateMap, sessionId, { abortController: controller || null })
+}
+
+export function nextAssistantRequestId(stateMap, sessionId) {
+ const state = ensureAssistantRequestState(stateMap, sessionId)
+ if (!state) return 0
+ state.requestId = (state.requestId || 0) + 1
+ return state.requestId
+}
+
+export function getAssistantRequestId(stateMap, sessionId) {
+ return ensureAssistantRequestState(stateMap, sessionId)?.requestId || 0
+}
+
+export function isAssistantActiveRequest(stateMap, sessionId, requestId) {
+ const state = ensureAssistantRequestState(stateMap, sessionId)
+ return !!state && state.requestId === requestId
+}
+
+export function getAssistantQueue(stateMap, sessionId) {
+ return ensureAssistantRequestState(stateMap, sessionId)?.queue || []
+}
+
+export function setAssistantQueue(stateMap, sessionId, queue) {
+ if (!sessionId) return null
+ return patchAssistantRequestState(stateMap, sessionId, { queue: Array.isArray(queue) ? queue : [] })
+}
diff --git a/src/lib/assistant-response-runner.js b/src/lib/assistant-response-runner.js
new file mode 100644
index 00000000..cd2f2e9e
--- /dev/null
+++ b/src/lib/assistant-response-runner.js
@@ -0,0 +1,73 @@
+export async function runAssistantResponse({
+ toolsEnabled,
+ session,
+ contextMessages,
+ requestId,
+ requestController,
+ aiMsg,
+ lastBubble,
+ messagesEl,
+ currentSessionId,
+ callAIWithTools,
+ callAI,
+ isActiveRequest,
+ renderMessages,
+ renderToolBlocks,
+ renderMarkdown,
+ escHtml,
+ throttledSave,
+ updateAssistantToolProgress,
+ appendAssistantStreamChunk,
+ finalizeAssistantStreamBubble,
+ lastRenderTime,
+ onLastRenderTime,
+}) {
+ if (toolsEnabled) {
+ const aiMsgContainers = messagesEl?.querySelectorAll('.ast-msg-ai')
+ const lastContainer = aiMsgContainers?.[aiMsgContainers.length - 1]
+
+ const result = await callAIWithTools(
+ session.id,
+ contextMessages,
+ (status) => {
+ if (!isActiveRequest(session.id, requestId)) return
+ if (lastBubble) lastBubble.innerHTML = `
${escHtml(status)}`
+ },
+ (history) => {
+ if (!isActiveRequest(session.id, requestId)) return
+ updateAssistantToolProgress({
+ history,
+ aiMsg,
+ lastContainer,
+ renderToolBlocks,
+ throttledSave,
+ messagesEl,
+ })
+ },
+ requestController.signal
+ )
+
+ if (!isActiveRequest(session.id, requestId)) return
+ aiMsg.content = result.content
+ if (result.toolHistory.length > 0) aiMsg.toolHistory = result.toolHistory
+ if (currentSessionId === session.id) renderMessages()
+ return
+ }
+
+ await callAI(session.id, contextMessages, (chunk) => {
+ if (!isActiveRequest(session.id, requestId)) return
+ const nextRenderTime = appendAssistantStreamChunk({
+ aiMsg,
+ chunk,
+ throttledSave,
+ lastBubble,
+ renderMarkdown,
+ messagesEl,
+ lastRenderTime,
+ })
+ if (typeof onLastRenderTime === 'function') onLastRenderTime(nextRenderTime)
+ }, requestController.signal)
+
+ if (!isActiveRequest(session.id, requestId)) return
+ finalizeAssistantStreamBubble(lastBubble, aiMsg.content, renderMarkdown)
+}
diff --git a/src/lib/assistant-run-context.js b/src/lib/assistant-run-context.js
new file mode 100644
index 00000000..5217ca95
--- /dev/null
+++ b/src/lib/assistant-run-context.js
@@ -0,0 +1,28 @@
+export function prepareAssistantRunContext({
+ session,
+ currentSessionId,
+ messagesEl,
+ renderMessages,
+ renderSessionList,
+ sendBtn,
+ stopIcon,
+ startStreamRefresh,
+ getEnabledTools,
+ typingText,
+}) {
+ if (currentSessionId === session.id) {
+ if (sendBtn) sendBtn.innerHTML = stopIcon()
+ startStreamRefresh(session.id)
+ }
+ renderSessionList()
+
+ if (currentSessionId === session.id) renderMessages()
+ const aiBubbles = currentSessionId === session.id ? messagesEl?.querySelectorAll('.ast-msg-bubble-ai') : null
+ const lastBubble = aiBubbles?.[aiBubbles.length - 1]
+ if (lastBubble) lastBubble.innerHTML = `
${typingText}`
+
+ return {
+ lastBubble,
+ toolsEnabled: getEnabledTools().length > 0,
+ }
+}
diff --git a/src/lib/assistant-session-store.js b/src/lib/assistant-session-store.js
new file mode 100644
index 00000000..861311fc
--- /dev/null
+++ b/src/lib/assistant-session-store.js
@@ -0,0 +1,72 @@
+import { normalizeAssistantApiType } from './assistant-api-meta.js'
+
+export function loadAssistantConfig(storage, storageKey, defaults) {
+ let config = null
+ try {
+ const raw = storage.getItem(storageKey)
+ config = raw ? JSON.parse(raw) : null
+ } catch {
+ config = null
+ }
+ if (!config) {
+ config = {
+ baseUrl: '',
+ apiKey: '',
+ model: '',
+ temperature: 0.7,
+ tools: { terminal: false, fileOps: false, webSearch: false },
+ assistantName: defaults.name,
+ assistantPersonality: defaults.personality,
+ }
+ }
+ if (!config.assistantName) config.assistantName = defaults.name
+ if (!config.assistantPersonality) config.assistantPersonality = defaults.personality
+ if (!config.tools) config.tools = { terminal: false, fileOps: false, webSearch: false }
+ if (!config.mode) config.mode = defaults.mode
+ config.apiType = normalizeAssistantApiType(config.apiType)
+ if (config.autoRounds === undefined) config.autoRounds = 8
+ if (!Array.isArray(config.knowledgeFiles)) config.knowledgeFiles = []
+ return config
+}
+
+export function saveAssistantConfig(storage, storageKey, config) {
+ storage.setItem(storageKey, JSON.stringify(config))
+}
+
+export function loadAssistantSessions(storage, storageKey) {
+ try {
+ const raw = storage.getItem(storageKey)
+ return raw ? JSON.parse(raw) : []
+ } catch {
+ return []
+ }
+}
+
+export function serializeAssistantSessions(sessions, maxSessions) {
+ const normalized = Array.isArray(sessions) ? sessions.slice(-(maxSessions || 50)) : []
+ const serialized = JSON.stringify(normalized, (key, value) => {
+ if (key === 'dataUrl' && typeof value === 'string' && value.startsWith('data:image/')) return undefined
+ if (key === 'url' && typeof value === 'string' && value.startsWith('data:image/')) return '[image]'
+ return value
+ })
+ return { sessions: normalized, serialized }
+}
+
+export function createAssistantSession(createId) {
+ return {
+ id: createId(),
+ title: '新会话',
+ messages: [],
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ }
+}
+
+export function getAutoSessionTitle(session) {
+ if (!session?.messages?.length || session.title !== '新会话') return null
+ const firstUser = session.messages.find(m => m.role === 'user')
+ if (!firstUser) return null
+ const txt = firstUser._text || (typeof firstUser.content === 'string' ? firstUser.content : (firstUser.content?.find?.(p => p.type === 'text')?.text || '[图片消息]'))
+ const firstLine = txt.split('\n').find(l => l.trim()) || txt
+ return firstLine.slice(0, 30) + (firstLine.length > 30 ? '...' : '')
+}
diff --git a/src/lib/assistant-streaming-service.js b/src/lib/assistant-streaming-service.js
new file mode 100644
index 00000000..0a00ffe9
--- /dev/null
+++ b/src/lib/assistant-streaming-service.js
@@ -0,0 +1,31 @@
+function isNearBottom(el, threshold = 96) {
+ if (!el) return true
+ return el.scrollHeight - el.scrollTop - el.clientHeight <= threshold
+}
+
+export function updateAssistantToolProgress({ history, aiMsg, lastContainer, renderToolBlocks, throttledSave, messagesEl }) {
+ const shouldFollow = isNearBottom(messagesEl)
+ aiMsg.toolHistory = history
+ throttledSave()
+ if (!lastContainer) return
+ const toolHtml = renderToolBlocks(history)
+ const bubble = lastContainer.querySelector('.ast-msg-bubble-ai')
+ lastContainer.innerHTML = toolHtml + (bubble ? bubble.outerHTML : '')
+ if (messagesEl && shouldFollow) messagesEl.scrollTop = messagesEl.scrollHeight
+}
+
+export function appendAssistantStreamChunk({ aiMsg, chunk, throttledSave, lastBubble, renderMarkdown, messagesEl, lastRenderTime, now = Date.now(), throttleMs = 50 }) {
+ const shouldFollow = isNearBottom(messagesEl)
+ aiMsg.content += chunk
+ throttledSave()
+ if (!lastBubble) return lastRenderTime
+ if (now - lastRenderTime <= throttleMs) return lastRenderTime
+ lastBubble.innerHTML = renderMarkdown(aiMsg.content) + '
▊'
+ if (messagesEl && shouldFollow) messagesEl.scrollTop = messagesEl.scrollHeight
+ return now
+}
+
+export function finalizeAssistantStreamBubble(lastBubble, content, renderMarkdown) {
+ if (!lastBubble) return
+ lastBubble.innerHTML = renderMarkdown(content)
+}
diff --git a/src/lib/assistant-tool-orchestrator.js b/src/lib/assistant-tool-orchestrator.js
new file mode 100644
index 00000000..6f09024d
--- /dev/null
+++ b/src/lib/assistant-tool-orchestrator.js
@@ -0,0 +1,20 @@
+export function createAssistantToolHistoryEntry(name, args) {
+ return { name, args, result: null, approved: true, pending: true }
+}
+
+export function finalizeAssistantToolHistoryEntry(entry, execResult) {
+ if (!entry) return null
+ entry.result = execResult?.result ?? null
+ entry.approved = execResult?.approved !== false
+ entry.pending = false
+ return entry
+}
+
+export async function withAssistantWaitingStatus(sessionId, helpers, task) {
+ if (sessionId) helpers.setSessionStatus(sessionId, 'waiting')
+ try {
+ return await task()
+ } finally {
+ if (sessionId && helpers.getStreaming(sessionId)) helpers.setSessionStatus(sessionId, 'streaming')
+ }
+}
diff --git a/src/lib/assistant-tool-safety.js b/src/lib/assistant-tool-safety.js
new file mode 100644
index 00000000..38a21ec9
--- /dev/null
+++ b/src/lib/assistant-tool-safety.js
@@ -0,0 +1,62 @@
+export const ASSISTANT_INTERACTIVE_TOOLS = new Set(['ask_user'])
+export const ASSISTANT_DANGEROUS_TOOLS = new Set(['run_command', 'write_file', 'skills_install_dep', 'skills_clawhub_install'])
+
+const ASSISTANT_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-with-lease/i,
+ /rmdir\s+\/s\s+\/q/i,
+ /rd\s+\/s\s+\/q/i,
+ /del\s+\/s\s+\/q/i,
+ /diskpart/i,
+ /bcdedit/i,
+ /reg\s+delete/i,
+]
+
+export function isAssistantCriticalCommand(command) {
+ if (!command) return false
+ return ASSISTANT_CRITICAL_PATTERNS.some(p => p.test(command))
+}
+
+export function buildAssistantToolConfirmText(toolCall, critical = false) {
+ const name = toolCall?.function?.name || ''
+ let args = {}
+ try { args = JSON.parse(toolCall?.function?.arguments || '{}') } catch { args = {} }
+
+ let desc = ''
+ if (name === 'run_command') {
+ desc = `执行命令:\n\n${args.command || ''}${args.cwd ? '\n\n工作目录: ' + args.cwd : ''}`
+ } else if (name === 'write_file') {
+ const preview = (args.content || '').slice(0, 200)
+ desc = `写入文件:\n${args.path || ''}\n\n内容预览:\n${preview}${(args.content || '').length > 200 ? '\n...(已截断)' : ''}`
+ }
+
+ const prefix = critical
+ ? '⛔ 安全围栏拦截 — 此命令被识别为极端危险操作!\n\n'
+ : ''
+
+ return `${prefix}AI 请求执行以下操作:\n\n${desc}\n\n是否允许?`
+}
+
+export function resolveAssistantToolApproval(toolName, args, mode) {
+ const critical = toolName === 'run_command' && isAssistantCriticalCommand(args?.command)
+ if (critical) return { needsConfirm: true, critical, deniedText: '用户拒绝了此危险操作' }
+ if (mode?.confirmDanger && ASSISTANT_DANGEROUS_TOOLS.has(toolName)) {
+ return { needsConfirm: true, critical: false, deniedText: '用户拒绝了此操作' }
+ }
+ return { needsConfirm: false, critical: false, deniedText: '' }
+}
diff --git a/src/lib/assistant-tool-ui.js b/src/lib/assistant-tool-ui.js
new file mode 100644
index 00000000..d72c48e9
--- /dev/null
+++ b/src/lib/assistant-tool-ui.js
@@ -0,0 +1,80 @@
+export function buildAssistantAskUserCardHtml({ cardId, question, type, options, placeholder, escapeHtml }) {
+ 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
+ ? `
`
+ : ''
+
+ return `
+
${escapeHtml(question)}
+ ${optionsHtml ? `
${optionsHtml}
` : ''}
+ ${customHtml}
+ ${textHtml}
+
+
+
+
+ `
+}
+
+export function resolveAssistantAskUserAnswer(card, type, options) {
+ 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 || '未选择'
+}
+
+export function buildAssistantAnsweredCardHtml({ question, answer, skip = false, escapeHtml, iconHtml = '' }) {
+ if (skip) {
+ return `
+
${escapeHtml(question)}
+
— 已跳过
+
`
+ }
+ return `
+
${escapeHtml(question)}
+
${iconHtml} ${escapeHtml(answer)}
+
`
+}
+
+export function renderAssistantToolBlocks(toolHistory, helpers) {
+ if (!toolHistory || toolHistory.length === 0) return ''
+ return toolHistory.map(tc => {
+ if (tc.name === 'ask_user') return ''
+ const tcIcon = helpers.toolIcons[tc.name] || helpers.defaultToolIcon
+ const label = helpers.toolLabels[tc.name] || tc.name
+ const argsStr = helpers.getArgsPreview(tc)
+
+ if (tc.pending) {
+ return `
`
+ }
+
+ const statusClass = tc.approved === false ? 'denied' : 'ok'
+ const statusLabel = tc.approved === false ? '已拒绝' : '已执行'
+ const resultPreview = (tc.result || '').length > 500 ? tc.result.slice(0, 500) + '...' : (tc.result || '')
+ return `
+ ${tcIcon} ${label} ${argsStr} ${statusLabel}
+ ${helpers.escapeHtml(resultPreview)}
+ `
+ }).join('')
+}
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/src/lib/history-apply-service.js b/src/lib/history-apply-service.js
new file mode 100644
index 00000000..d3d73f70
--- /dev/null
+++ b/src/lib/history-apply-service.js
@@ -0,0 +1,40 @@
+export function updateHistoryApplyState(state, messages, hasExisting, helpers) {
+ const appliedTs = helpers.maxHistoryTimestamp(messages)
+ state.lastHistoryAppliedTs = Math.max(Number(state.lastHistoryAppliedTs || 0), appliedTs)
+
+ const hash = helpers.buildHistoryHash(messages)
+ const shouldSkip = hash === state.lastHistoryHash && hasExisting
+ state.lastHistoryHash = hash
+
+ return {
+ appliedTs,
+ hash,
+ shouldSkip,
+ }
+}
+
+export function seedHostedHistoryIfNeeded(options) {
+ const {
+ sessionKey,
+ hostedBoundSessionKey,
+ hostedSeeded,
+ hostedSessionConfig,
+ deduped,
+ toHostedSeedHistory,
+ trimHostedHistoryByTokens,
+ persistHostedRuntime,
+ } = options
+
+ if (sessionKey !== hostedBoundSessionKey) return hostedSeeded
+ if (hostedSeeded) return hostedSeeded
+ if (!hostedSessionConfig) return hostedSeeded
+ if (hostedSessionConfig.history && hostedSessionConfig.history.length > 0) return true
+
+ const seeded = toHostedSeedHistory(deduped)
+ if (seeded.length) {
+ hostedSessionConfig.history = seeded
+ trimHostedHistoryByTokens()
+ persistHostedRuntime()
+ }
+ return true
+}
diff --git a/src/lib/history-domain.js b/src/lib/history-domain.js
new file mode 100644
index 00000000..628bb08c
--- /dev/null
+++ b/src/lib/history-domain.js
@@ -0,0 +1,57 @@
+export function extractHistoryMessages(source) {
+ if (!source) return null
+ if (Array.isArray(source)) return source
+ if (Array.isArray(source.messages)) return source.messages
+ if (Array.isArray(source.result?.messages)) return source.result.messages
+ if (Array.isArray(source.value?.messages)) return source.value.messages
+ if (Array.isArray(source.data?.messages)) return source.data.messages
+ if (Array.isArray(source.messages?.value)) return source.messages.value
+ if (Array.isArray(source.messages?.data)) return source.messages.data
+ if (Array.isArray(source.result)) return source.result
+ if (Array.isArray(source.value)) return source.value
+ if (Array.isArray(source.data)) return source.data
+ if (Array.isArray(source.payload)) return source.payload
+ return null
+}
+
+export function normalizeHistoryPayload(payload, fallbackKey, normalizeSessionKey) {
+ const key = normalizeSessionKey(payload?.sessionKey || payload?._req?.sessionKey || fallbackKey)
+ const messages = extractHistoryMessages(payload)
+ if (!messages || !messages.length) return { sessionKey: key, result: null }
+ return { sessionKey: key, result: { messages } }
+}
+
+export function maxHistoryTimestamp(messages = []) {
+ return messages.reduce((max, msg) => Math.max(max, Number(msg?.timestamp || 0)), 0)
+}
+
+export 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('|')
+}
+
+export function buildHistoryEntryKey(msg) {
+ const role = msg?.role || 'system'
+ const ts = Number(msg?.timestamp || 0)
+ const text = (msg?.text || '').trim()
+ const mediaCount = (msg?.images?.length || 0) + (msg?.videos?.length || 0) + (msg?.audios?.length || 0) + (msg?.files?.length || 0)
+ const toolCount = msg?.tools?.length || 0
+ const statusTag = role === 'system' ? (msg?.statusKey || msg?.statusType || '') : ''
+ return `${role}:${statusTag}:${ts}:${text}:${mediaCount}:${toolCount}`
+}
diff --git a/src/lib/history-loader-service.js b/src/lib/history-loader-service.js
new file mode 100644
index 00000000..836b6a25
--- /dev/null
+++ b/src/lib/history-loader-service.js
@@ -0,0 +1,33 @@
+import { toLocalAssistantImages } from './history-view-model.js'
+
+export function takePendingHistoryPayload(state, options = {}) {
+ if (!state) return null
+ if (!options.hasMessagesEl) return null
+ if (options.isBusy) return null
+ if (!state.pendingHistoryPayload) return null
+
+ const payload = state.pendingHistoryPayload
+ const payloadTs = options.maxHistoryTimestamp(payload?.messages || [])
+ state.pendingHistoryPayload = null
+ state.pendingHistoryTs = 0
+
+ if (payloadTs && payloadTs <= Number(state.lastHistoryAppliedTs || 0)) return null
+ return payload
+}
+
+export function renderLocalHistoryMessages(messages = [], handlers) {
+ messages.forEach(msg => {
+ if (!msg.content && !msg.attachments?.length) return
+ const msgTime = msg.timestamp ? new Date(msg.timestamp) : new Date()
+ if (msg.role === 'user') {
+ handlers.appendUserMessage(msg.content || '', msg.attachments || null, msgTime)
+ return
+ }
+ if (msg.role === 'assistant') {
+ const images = toLocalAssistantImages(msg.attachments || [])
+ handlers.appendAiMessage(msg.content || '', msgTime, images, [], [], [], [])
+ return
+ }
+ handlers.appendSystemMessage(msg.content || '', msgTime?.getTime?.() || Date.now())
+ })
+}
diff --git a/src/lib/history-render-service.js b/src/lib/history-render-service.js
new file mode 100644
index 00000000..8a5ddd2b
--- /dev/null
+++ b/src/lib/history-render-service.js
@@ -0,0 +1,66 @@
+import {
+ HISTORY_OMITTED_IMAGES_NOTICE,
+ hasRenderableHistoryMessage,
+ toUserHistoryAttachments,
+} from './history-view-model.js'
+
+export function renderHistoryMessage(msg, sessionKey, handlers) {
+ const msgTime = msg.timestamp ? new Date(msg.timestamp) : new Date()
+ if (msg.role === 'user') {
+ const attachments = toUserHistoryAttachments(msg)
+ return {
+ node: handlers.appendUserMessage(msg.text, attachments, msgTime),
+ omittedImages: !!(msg.images?.length && !attachments.length),
+ }
+ }
+ if (msg.role === 'assistant') {
+ return {
+ node: handlers.appendAiMessage(msg.text, msgTime, msg.images, msg.videos, msg.audios, msg.files, msg.tools, sessionKey),
+ omittedImages: false,
+ }
+ }
+ return {
+ node: handlers.appendSystemMessage(msg.text || '', msgTime?.getTime?.() || Date.now()),
+ omittedImages: false,
+ }
+}
+
+export function renderHistoryList(messages = [], sessionKey, handlers) {
+ let appended = 0
+ let hasOmittedImages = false
+
+ messages.forEach(msg => {
+ if (!hasRenderableHistoryMessage(msg)) return
+ const rendered = renderHistoryMessage(msg, sessionKey, handlers)
+ if (!rendered?.node) return
+ handlers.stampHistoryNode(rendered.node, msg)
+ if (rendered.omittedImages) hasOmittedImages = true
+ appended += 1
+ })
+
+ return { appended, hasOmittedImages }
+}
+
+export function appendOmittedImagesNotice(handlers) {
+ const notice = handlers.appendSystemMessage(HISTORY_OMITTED_IMAGES_NOTICE)
+ if (notice) notice.dataset.historyKey = 'system:omitted-images'
+ return notice
+}
+
+export function renderIncrementalHistoryList(messages = [], sessionKey, handlers) {
+ let appended = 0
+ let hasOmittedImages = false
+
+ messages.forEach(msg => {
+ const entryKey = handlers.buildHistoryEntryKey(msg)
+ if (handlers.renderedKeys.has(entryKey)) return
+ const rendered = renderHistoryMessage(msg, sessionKey, handlers)
+ if (!rendered?.node) return
+ handlers.stampHistoryNode(rendered.node, msg)
+ handlers.renderedKeys.add(entryKey)
+ if (rendered.omittedImages) hasOmittedImages = true
+ appended += 1
+ })
+
+ return { appended, hasOmittedImages }
+}
diff --git a/src/lib/history-view-model.js b/src/lib/history-view-model.js
new file mode 100644
index 00000000..26cfa96f
--- /dev/null
+++ b/src/lib/history-view-model.js
@@ -0,0 +1,46 @@
+export const HISTORY_OMITTED_IMAGES_NOTICE = '部分历史图片无法显示(Gateway 不保留图片原始数据,仅当前会话内可见)'
+
+export function toUserHistoryAttachments(msg) {
+ if (!msg?.images?.length) return []
+ return msg.images.map(i => ({
+ mimeType: i.mediaType || i.media_type || 'image/png',
+ content: i.data || i.source?.data || '',
+ category: 'image',
+ })).filter(item => item.content)
+}
+
+export function hasRenderableHistoryMessage(msg) {
+ return !!(msg && (msg.text || msg.images?.length || msg.videos?.length || msg.audios?.length || msg.files?.length || msg.tools?.length))
+}
+
+export function toHostedSeedHistory(messages = []) {
+ return messages
+ .filter(m => m.role === 'user' || m.role === 'assistant')
+ .slice(-200)
+ .map(m => ({
+ role: m.role,
+ content: m.text || '',
+ ts: m.timestamp || Date.now(),
+ }))
+ .filter(m => m.content)
+}
+
+export function toStoredHistoryMessages(messages = [], sessionKey, extractContent, createId) {
+ return messages.map(m => {
+ const c = extractContent(m, sessionKey)
+ const role = (m.role === 'tool' || m.role === 'toolResult') ? 'assistant' : m.role
+ return {
+ id: m.id || createId(),
+ sessionKey,
+ role,
+ content: c?.text || '',
+ timestamp: m.timestamp || Date.now(),
+ }
+ })
+}
+
+export function toLocalAssistantImages(attachments = []) {
+ return attachments
+ .filter(a => a.category === 'image')
+ .map(a => ({ mediaType: a.mimeType, data: a.content, url: a.url }))
+}
diff --git a/src/lib/hosted-agent.js b/src/lib/hosted-agent.js
new file mode 100644
index 00000000..dead7292
--- /dev/null
+++ b/src/lib/hosted-agent.js
@@ -0,0 +1,273 @@
+export const HOSTED_STATUS = {
+ IDLE: 'idle',
+ RUNNING: 'running',
+ WAITING: 'waiting_reply',
+ PAUSED: 'paused',
+ ERROR: 'error',
+}
+
+export const HOSTED_SESSIONS_KEY = 'clawpanel-hosted-agent-sessions'
+export const HOSTED_GLOBAL_KEY = 'hostedAgent.default'
+
+export const HOSTED_DEFAULTS = {
+ enabled: false,
+ prompt: '',
+ systemPrompt: '',
+ contextTokenLimit: 200000,
+ autoRunAfterTarget: true,
+ stopPolicy: 'self',
+ maxSteps: 50,
+ stepDelayMs: 1200,
+ retryLimit: 2,
+ toolPolicy: 'inherit',
+}
+
+export const HOSTED_FIXED_SYSTEM_PROMPT = `# Role: {{role_name}}({{role_alias}} / {{role_id}})
+
+## Profile
+- language: {{language}}
+- description: {{role_name}}({{role_id}})是面向复杂任务协同场景设计的高级任务协调者与项目经理型角色,负责接收用户目标、理解上下文、进行任务拆解、制定执行方案、识别风险与依赖,并将可执行、可验收、可追踪的明确指令下发给{{agent_name}}执行。该角色不直接执行工具型任务,而是通过高质量的指令编排、过程管控与结果验收,确保任务稳定推进并最终交付。在满足信息完整与可执行性的前提下,输出应尽量简洁,避免冗长说明;整体遵循“结构固定,但各段内容最小充分”的原则,尤其是“{{section_agent_instruction}}”和“{{section_user_reply}}”必须优先使用最短、最清晰、无歧义的表达。
+- background: 具备复杂项目管理、任务编排、开发协作、知识沉淀、故障复盘与执行闭环意识,长期服务于需要“主控规划 + 执行代理”协同模式的托管式工作流,擅长在信息不完全、需求动态变化、执行链较长的情况下维持推进节奏与输出质量。
+- personality: 专业、冷静、果断、高效、结构化、强执行导向、低情绪波动、注重闭环、强调可验证结果、表达克制。
+- expertise: 任务规划、项目协调、需求拆解、指令编写、风险控制、质量验收、执行闭环、知识沉淀、异常复盘。
+- target_audience: 使用{{workflow_name}}托管模式的用户、需要多步骤执行协同的开发者、项目负责人、产品经理、技术团队及复杂任务发起者。
+
+## Variables
+- role_name: 默认值 OpenClaw 托管指挥官
+- role_alias: 默认值 Host Commander
+- role_id: 默认值 HOST-01
+- language: 默认值 中文
+- workflow_name: 默认值 OpenClaw
+- agent_name: 默认值 @OpenClaw-Agent
+- docs_path: 默认值 docs/
+- memory_path: 默认值 memory/
+- section_task_judgment: 默认值 任务判断:
+- section_task_plan: 默认值 任务规划:
+- section_agent_instruction: 默认值 给对面Agent的指令(必须清晰、可执行):
+- section_user_reply: 默认值 给用户的回复(最终输出给用户,简洁明了):
+
+使用要求:
+- 全文出现的角色名、代理名、输出标题、知识沉淀路径等,均优先引用以上变量。
+- 若调用方未显式传入变量值,或传入空字符串、空白值、null,则回退到上述默认值。
+- 除变量替换外,不改变角色职责、行为边界、输出结构与执行原则。
+- 所有变量替换后,提示词仍必须保证语义完整、格式稳定、可直接执行。
+
+## Skills
+
+1. 核心协调技能
+ - 需求理解: 快速提炼用户目标、约束条件、优先级与隐含意图,避免执行偏航。
+ - 任务拆解: 将复杂任务分解为可执行、可验证、可交付的步骤与阶段。
+ - 指令编排: 向{{agent_name}}下发清晰、精确、具备上下文和验收条件的执行指令,优先短句、动词开头、单义表达,避免无关铺垫;在结构固定前提下,仅保留执行所需的最小充分信息。
+ - 结果验收: 基于目标、证据、日志摘要与产出质量判断任务是否达标。
+
+2. 辅助管理技能
+ - 风险识别: 提前发现信息缺口、依赖阻塞、执行风险、潜在返工点与质量隐患。
+ - 异常复盘: 在失败、报错、偏差发生时进行原因归纳、修复路径设计与下一步安排。
+ - 知识沉淀: 主动推动将经验、踩坑、修复策略和稳定流程写入{{docs_path}}与{{memory_path}}。
+ - 沟通输出: 同时生成面向执行代理的操作指令与面向用户的阶段性进展说明,优先短句表达,非必要不展开;用户侧仅保留当前进度、结果、阻塞、所需信息四类必要内容;整体遵循“结构固定,但内容最小充分”。
+
+## Rules
+
+1. 基本原则:
+ - 角色定位唯一: 仅承担“理解用户需求、思考规划、下达明确指令、回复用户”四类职责,不承担直接执行职责。
+ - 托管推进优先: 默认自主推进任务,不将常规决策反复抛回用户,不进行无意义确认。
+ - 执行统一委派: 所有涉及工具、技能、浏览器、代码、文件、环境、搜索、读写等工作,统一交由{{agent_name}}执行。
+ - 输出结构固定: 每次响应必须严格按照既定结构输出,确保内部规划、执行指令、用户沟通三部分边界清晰;固定的是标题与顺序,不要求各段冗长展开。
+ - 简洁输出优先: 在不损失可执行性、可验证性与关键信息的前提下,尽可能使用最短表达;能用“继续”“已安排”“阻塞:xxx”表达清楚的,不做扩写。
+ - 各段内容最小充分: 在保持固定结构的前提下,每一段仅保留完成当前任务所需的必要信息,避免为了“完整”而加入无助于执行、验收或沟通的内容。
+ - 指令与回复双重压缩: “{{section_agent_instruction}}”和“{{section_user_reply}}”必须进一步压缩为必要信息集合,避免背景复述、重复目标、礼貌性套话和解释性赘述。
+
+2. 行为准则:
+ - 信息不足时优先推进: 在关键上下文部分缺失但仍可合理推断时,基于现有信息先制定方案并继续推进。
+ - 无法推进时明确阻塞: 若缺失信息已影响任务可执行性,必须准确指出缺失点与阻塞原因,并保持等待状态,不输出 ask_user、confirm 或其他交互式确认标签。
+ - 指令必须可执行: 给{{agent_name}}的要求必须具体、明确、可落地,包含目标、步骤、产出要求与验收方式;但表达上必须简短直接,优先使用要点式短句,不写与执行无关的说明。
+ - 强化知识留存: 一旦出现可复用经验、根因定位、修复路径、稳定工作流,必须要求{{agent_name}}同步沉淀到{{docs_path}}核心文档或计划文档,并写入{{memory_path}}对应记录。
+ - 只指出必要问题: 对用户沟通时,仅指出错误、疑问、阻塞或关键变化;无额外风险或异常时,不做长篇背景解释。
+ - 避免重复表述: 不重复描述已明确的目标、步骤、限制和进度;除非影响执行或验收,否则不赘述。
+ - 用户信息请求最小化: 仅当缺失信息影响执行或结果质量时,才在“{{section_user_reply}}”中明确列出所需信息;能推进则先推进。
+ - 用户回复必须直达结论: 先说进度或结果,再说阻塞或所需信息;不用寒暄,不做过程复述,不解释已知上下文。
+ - Agent指令必须直达动作: 先写要做什么,再写产出与验收;不写角色说明,不重复系统已知规则,不写空泛引导语。
+ - 固定结构不等于固定篇幅: 各段可根据任务复杂度缩短或展开,但必须保持“最小充分”,避免因追求格式完整而输出过多指示。
+
+3. 限制条件:
+ - 禁止直接调用能力: 不使用任何工具、skills、分代理、子代理、function calling 或其他执行能力。
+ - 禁止越权执行: 不直接完成需要外部系统访问、运行、检索、写文件、改代码、操作环境的任务。
+ - 禁止频繁向用户索取确认: 托管模式下,不因一般执行节点向用户请求二次确认。
+ - 禁止遗漏失败闭环: 当{{agent_name}}执行失败或异常时,必须补充失败原因复盘、下一步修复指令与预计完成路径。
+ - 禁止冗长回复: 不输出与当前推进无关的背景、客套、重复总结或大段解释;除非用户明确要求详细说明。
+ - 禁止偏离固定格式: 所有正常响应与异常响应都必须保留规定的四段主结构标题,不得删改标题名称或输出顺序;但各段内容应以满足当前任务为限,不强行扩写。
+ - 禁止空泛指令: 不向{{agent_name}}输出“请处理一下”“自行判断”等模糊表述,必须简短但具体。
+ - 禁止客套化用户回复: 不在“{{section_user_reply}}”中加入无实际信息价值的客套语、鼓励语、模板化过渡语。
+ - 禁止为满足格式而堆砌内容: 不因“严格固定格式”而在任一段加入重复背景、泛化说明或无执行价值的填充文本。
+
+## Workflows
+- 目标: 将用户需求转化为结构化执行计划,并通过{{agent_name}}完成实际执行、验证结果、沉淀知识,最终向用户交付清晰、可靠、可追踪的成果。
+- 步骤 1: 解析用户输入,识别任务目标、上下文、约束、隐含需求、风险点和执行依赖,形成完整问题框架。
+- 步骤 2: 制定任务规划,明确目标、拆解步骤、需由{{agent_name}}完成的部分、验收标准,以及可能的异常分支与补救策略。
+- 步骤 3: 向{{agent_name}}下发具体执行指令,要求其使用可用工具和执行能力完成任务,并返回完整结果、可验证产出与日志摘要;指令必须压缩为最少但充分的信息。随后基于返回结果组织面向用户的专业回复;回复必须优先输出结论与状态,仅保留必要信息,并在必要时追加文档沉淀要求。
+- 预期结果: 每次输出均包含完整思考、明确任务规划、可执行代理指令、清晰用户回复;在异常场景下包含失败复盘、修复动作和预计完成路径;在可沉淀场景下推动经验写入{{docs_path}}与{{memory_path}}。整体遵循“结构固定,但各段内容最小充分”的原则。其中“{{section_user_reply}}”应默认精简到最必要信息,但在需要补充用户信息时应明确列出所需内容与用途;“{{section_agent_instruction}}”应默认精简到可直接执行的最小充分集合。
+
+## Initialization
+作为{{role_name}}({{role_alias}} / {{role_id}}),你必须遵守上述 Rules,按照 Workflows 执行任务。
+
+收到任何用户消息后,立即严格按以下结构生成响应,必须逐字使用以下四个一级标题,且按顺序输出,不得省略、改名或调换;同时遵循“结构固定,但各段内容最小充分”的原则:
+
+{{section_task_judgment}}
+[仅输出当前规划所必需的任务理解、依赖判断、推进策略与风险结论;不暴露详细推理过程,保持简洁。]
+
+{{section_task_plan}}
+- 目标:...
+- 拆解步骤:1. ... 2. ... 3. ...
+- 需要对面Agent完成的部分:...
+- 验收标准:明确可判定的完成条件
+[保持结构完整,但各项仅写当前推进所需的最小充分信息。]
+
+{{section_agent_instruction}}
+{{agent_name}}
+[在这里写给对面Agent的具体指令。要求:只保留执行所必需的信息,优先使用短句、要点、动词开头表达;先写任务目标,再写关键动作、产出要求、验收方式;不重复背景,不写额外解释,不输出过多指示。]
+使用你的工具/分代理/skills/子代理完成以下任务:
+[按当前任务填写具体动作,使用最小充分信息表达。]
+如发现可复用经验、踩坑记录、根因分析、修复策略或稳定工作流,请同步沉淀到项目{{docs_path}}下的核心文档或计划文档,并在{{memory_path}}下写入对应记录。
+
+{{section_user_reply}}
+[自然、友好、专业、简洁明了地回复用户。要求:优先输出当前进度或结果,其次再写阻塞、预期结果、需要用户提供的信息;仅保留用户此刻必须知道的信息,不复述内部过程,不做背景解释,不主动寒暄。默认保持极简;如无需用户补充信息,则不主动索要;如存在阻塞,则明确说明缺少什么以及该信息将用于什么。固定结构下仅输出最小充分内容。]
+
+当{{agent_name}}反馈失败或异常时,仍必须保留以上四段主结构,并在对应内容中额外补充:
+- 在“{{section_task_judgment}}”中补充失败根因、影响范围与阻塞判断,仅保留对协作必要的信息。
+- 在“{{section_task_plan}}”中加入修复路径、重新执行步骤与新的验收标准(如需调整)。
+- 在“{{section_agent_instruction}}”中加入下一步修复指令与重新执行方案,仍保持短句、直接、最小充分。
+- 在“{{section_user_reply}}”中简明说明当前异常、预期修复方向、预计完成路径,以及仅在必要时说明需要用户补充的信息;表达应优先结论化,避免展开。
+
+始终保持专业、冷静、高效、结构化。默认主动推进,不做多余确认,不提及自身能力限制,直接将执行工作分配给{{agent_name}}。用户侧输出遵循“够用即可”的原则,同时确保在需要时明确给出当前进度、预期结果、以及用户需提供的信息。对面Agent指令侧输出遵循“最小充分、直接可执行”的原则。整体统一规则为:结构严格固定,但各段内容以满足当前任务的最小充分信息为准,避免因格式要求与极简要求并存而产生执行歧义。
+`;
+
+export const HOSTED_RUNTIME_DEFAULT = {
+ status: HOSTED_STATUS.IDLE,
+ stepCount: 0,
+ lastRunAt: 0,
+ lastRunId: '',
+ lastError: '',
+ pending: false,
+ errorCount: 0,
+ contextTokens: 0,
+ lastTrimAt: 0,
+ lastAction: '',
+ lastSpecialText: '',
+ lastSpecialTs: 0,
+}
+
+export function parseHostedResponse(raw) {
+ const text = String(raw || '').trim()
+ if (!text) {
+ return { goal: '', suggestions: [], risks: [] }
+ }
+
+ const lines = text.split(/\r?\n/).map(line => line.trim())
+ const goals = []
+ const suggestions = []
+ const risks = []
+ let section = ''
+
+ lines.forEach(line => {
+ if (!line) return
+ if (/^目标[::]/.test(line)) {
+ section = 'goal'
+ goals.push(line.replace(/^目标[::]\s*/, '').trim())
+ return
+ }
+ if (/^建议[::]/.test(line)) {
+ section = 'suggestions'
+ const value = line.replace(/^建议[::]\s*/, '').trim()
+ if (value) suggestions.push(value)
+ return
+ }
+ if (/^风险[::]/.test(line)) {
+ section = 'risks'
+ const value = line.replace(/^风险[::]\s*/, '').trim()
+ if (value) risks.push(value)
+ return
+ }
+ if (/^-\s+/.test(line)) {
+ const value = line.replace(/^-\s+/, '').trim()
+ if (!value) return
+ if (section === 'risks') risks.push(value)
+ else suggestions.push(value)
+ return
+ }
+ if (section === 'goal') goals.push(line)
+ else if (section === 'risks') risks.push(line)
+ else suggestions.push(line)
+ })
+
+ if (!goals.length && !suggestions.length) {
+ return {
+ goal: '',
+ suggestions: [text],
+ risks: [],
+ }
+ }
+
+ return {
+ goal: goals.join(' '),
+ suggestions: suggestions.filter(Boolean).length ? suggestions.filter(Boolean) : [text],
+ risks: risks.filter(Boolean),
+ }
+}
+
+export function renderHostedTemplate(parsed) {
+ const parts = []
+ if (parsed.goal) parts.push(`目标: ${parsed.goal}`)
+ const suggestText = (parsed.suggestions || []).map(s => `- ${s}`).join('\n') || '- 暂无'
+ parts.push(`建议:\n${suggestText}`)
+ if (parsed.risks && parsed.risks.length) {
+ const riskText = parsed.risks.map(r => `- ${r}`).join('\n')
+ parts.push(`风险:\n${riskText}`)
+ }
+ return parts.join('\n')
+}
+
+export function extractHostedInstruction(text) {
+ if (!text) return ''
+ const raw = String(text)
+ const withoutPrefix = raw.replace(/^\[托管 Agent\]\s*/g, '')
+ const markerIndex = withoutPrefix.indexOf('@OpenClaw-Agent')
+ if (markerIndex < 0) return ''
+ const tail = withoutPrefix.slice(markerIndex)
+ const stopMatch = tail.match(/\n\s*(\*\*\s*)?给用户的回复|\n\s*(\*\*\s*)?给用户回复|\n\s*(\*\*\s*)?给用户的回复(最终输出给用户)/)
+ if (!stopMatch) return tail.trim()
+ return tail.slice(0, stopMatch.index).trim()
+}
+
+export 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 }
+}
+
+export function formatHostedActionLabel(action) {
+ const map = {
+ '': '',
+ 'generating-reply': '生成回复中',
+ 'resume-latest-target': '从最新回复恢复',
+ 'waiting-target': '等待目标回复',
+ paused: '手动暂停',
+ disconnected: '等待重连',
+ stopped: '已停止',
+ error: '异常中断',
+ }
+ return map[action] || action || ''
+}
diff --git a/src/lib/hosted-history-service.js b/src/lib/hosted-history-service.js
new file mode 100644
index 00000000..eb1480f1
--- /dev/null
+++ b/src/lib/hosted-history-service.js
@@ -0,0 +1,85 @@
+export function normalizeHostedRole(role) {
+ if (role === 'assistant' || role === 'user') return role
+ if (role === 'developer') return 'assistant'
+ return 'user'
+}
+
+export function shouldCaptureHostedTarget(payload, options) {
+ const {
+ hostedSessionConfig,
+ hostedRuntime,
+ boundSessionKey,
+ extractChatContent,
+ buildHostedTargetHash,
+ lastTargetHash,
+ } = options
+
+ if (!hostedSessionConfig?.enabled) return { capture: false }
+ if (payload?.sessionKey && boundSessionKey && payload.sessionKey !== boundSessionKey) return { capture: false }
+ if (hostedRuntime.status === 'paused' || hostedRuntime.status === 'error') return { capture: false }
+ if (payload?.message?.role && payload.message.role !== 'assistant') return { capture: false }
+
+ const text = String(extractChatContent(payload.message, payload.sessionKey || boundSessionKey)?.text || '').trim()
+ if (!text) return { capture: false }
+
+ const ts = Number(payload?.timestamp || Date.now())
+ const hash = buildHostedTargetHash(text, ts)
+ if (hash === lastTargetHash) return { capture: false }
+
+ return { capture: true, text, ts, hash }
+}
+
+export function pushHostedHistoryEntry(history, role, content, ts = Date.now(), options = {}) {
+ const normalizedRole = role || 'assistant'
+ const normalizedContent = String(content || '').trim()
+ if (!normalizedContent) return { changed: false, history }
+
+ const list = Array.isArray(history) ? history : []
+ const nextTs = Number(ts || Date.now())
+ const last = list[list.length - 1]
+ const allowReplace = options.allowReplaceTail !== false
+
+ if (allowReplace && last?.role === normalizedRole && String(last.content || '').trim() === normalizedContent) {
+ if (!last.ts || nextTs >= Number(last.ts || 0)) last.ts = nextTs
+ return { changed: false, history: list }
+ }
+
+ list.push({ role: normalizedRole, content: normalizedContent, ts: nextTs })
+ return { changed: true, history: list }
+}
+
+export function buildHostedMessages(history, systemPrompt, contextMax = 100) {
+ const trimmed = (Array.isArray(history) ? history : [])
+ .filter(item => item && item.content)
+ .slice(-(contextMax || 100))
+
+ const compacted = []
+ trimmed.forEach(item => {
+ const role = normalizeHostedRole(item.role)
+ const content = String(item.content || '').trim()
+ if (!content) return
+ const prev = compacted[compacted.length - 1]
+ if (prev && prev.role === role && prev.rawRole === (item.role || role) && prev.content === content) return
+ compacted.push({ role, rawRole: item.role || role, content })
+ })
+
+ const mapped = compacted.map(item => ({
+ role: item.role,
+ content: `[${String(item.rawRole || item.role).toUpperCase()}] ${item.content}`,
+ }))
+
+ if (systemPrompt) mapped.unshift({ role: 'system', content: systemPrompt })
+ return mapped
+}
+
+export function buildSeededHostedHistory(messages, contextMax = 100) {
+ return (Array.isArray(messages) ? messages : [])
+ .filter(m => m.role === 'user' || m.role === 'assistant')
+ .slice(-(contextMax || 100))
+ .map(m => ({
+ role: m.role,
+ content: m.text || '',
+ ts: m.timestamp || Date.now(),
+ }))
+ .filter(m => m.content)
+}
diff --git a/src/lib/hosted-orchestrator-service.js b/src/lib/hosted-orchestrator-service.js
new file mode 100644
index 00000000..c95a1c31
--- /dev/null
+++ b/src/lib/hosted-orchestrator-service.js
@@ -0,0 +1,20 @@
+export function shouldReplaceHostedSeed(localHistory, seeded, force = false) {
+ const local = Array.isArray(localHistory) ? localHistory : []
+ if (force || !local.length) return true
+ const remoteLastTs = seeded.reduce((max, item) => Math.max(max, Number(item?.ts || 0)), 0)
+ const localLastTs = local.reduce((max, item) => Math.max(max, Number(item?.ts || 0)), 0)
+ return remoteLastTs >= localLastTs
+}
+
+export function resolveHostedSessionRunMode(targetSessionKey, currentSessionKey) {
+ if (!targetSessionKey) return { kind: 'skip' }
+ if (targetSessionKey === currentSessionKey) return { kind: 'current', sessionKey: targetSessionKey }
+ return { kind: 'foreign', sessionKey: targetSessionKey }
+}
+
+export function ensureHostedBoundSession(config, sessionKey) {
+ if (!config || !sessionKey) return false
+ if (config.boundSessionKey === sessionKey) return false
+ config.boundSessionKey = sessionKey
+ return true
+}
diff --git a/src/lib/hosted-output-service.js b/src/lib/hosted-output-service.js
new file mode 100644
index 00000000..13140dee
--- /dev/null
+++ b/src/lib/hosted-output-service.js
@@ -0,0 +1,42 @@
+export function prepareHostedOutput(rawText, helpers, lastSentHash = '') {
+ if (!rawText) return null
+ const raw = String(rawText)
+ const extracted = helpers.extractHostedAskUser(raw)
+ const cleanedText = extracted.text || ''
+ let displayText = cleanedText || '托管 Agent 发起用户提问'
+ if (!displayText.startsWith('[托管 Agent]')) displayText = `[托管 Agent] ${displayText}`
+
+ const instruction = helpers.extractHostedInstruction(cleanedText || raw)
+ let instructionHash = ''
+ let shouldSendInstruction = false
+ if (instruction) {
+ instructionHash = `${instruction.length}:${instruction.slice(0, 240)}:${instruction.slice(-80)}`
+ shouldSendInstruction = instructionHash !== lastSentHash
+ }
+
+ return {
+ extracted,
+ cleanedText,
+ displayText,
+ instruction,
+ instructionHash,
+ shouldSendInstruction,
+ }
+}
+
+export function buildHostedOptimisticUserMessage(answer, ts = Date.now()) {
+ const finalAnswer = answer || ''
+ return {
+ message: {
+ role: 'user',
+ text: finalAnswer,
+ content: finalAnswer,
+ timestamp: ts,
+ },
+ storage: {
+ role: 'user',
+ content: finalAnswer,
+ timestamp: ts,
+ },
+ }
+}
diff --git a/src/lib/hosted-runtime-service.js b/src/lib/hosted-runtime-service.js
new file mode 100644
index 00000000..f55cb75c
--- /dev/null
+++ b/src/lib/hosted-runtime-service.js
@@ -0,0 +1,49 @@
+import { HOSTED_STATUS } from './hosted-agent.js'
+
+export function shouldPauseHostedForDisconnect(config, runtime) {
+ if (!config?.enabled) return false
+ const active = runtime.status === HOSTED_STATUS.RUNNING || runtime.status === HOSTED_STATUS.WAITING
+ const alreadyDisconnectedPause = runtime.status === HOSTED_STATUS.PAUSED && runtime.lastAction === 'disconnected'
+ return active || alreadyDisconnectedPause
+}
+
+export function applyHostedDisconnectedPause(runtime) {
+ runtime.status = HOSTED_STATUS.PAUSED
+ runtime.pending = false
+ runtime.lastAction = 'disconnected'
+ return runtime
+}
+
+export function resumeHostedAfterReconnect(config, runtime) {
+ if (!config?.enabled) return false
+ if (runtime.status !== HOSTED_STATUS.PAUSED || runtime.lastAction !== 'disconnected') return false
+ runtime.status = HOSTED_STATUS.IDLE
+ runtime.lastAction = ''
+ return true
+}
+
+export function buildHostedTargetHash(text, ts = Date.now()) {
+ const normalizedText = String(text || '').trim()
+ const normalizedTs = Number(ts || Date.now())
+ return `${normalizedTs}:${normalizedText.length}:${normalizedText.slice(0, 240)}`
+}
+
+export function shouldAutoTriggerHostedRun(config, runtime) {
+ if (!config?.enabled) return false
+ if (!config.autoRunAfterTarget) return false
+ if (runtime.pending || runtime.status === HOSTED_STATUS.RUNNING) return false
+ if (runtime.status === HOSTED_STATUS.PAUSED || runtime.status === HOSTED_STATUS.ERROR) return false
+ return true
+}
+
+export function prepareHostedRunTrigger(runtime, gatewayReady) {
+ if (!gatewayReady) {
+ runtime.status = HOSTED_STATUS.PAUSED
+ return { run: false, needsPersist: true }
+ }
+ if (runtime.status === HOSTED_STATUS.WAITING) {
+ runtime.status = HOSTED_STATUS.IDLE
+ }
+ runtime.lastAction = ''
+ return { run: true, needsPersist: false }
+}
diff --git a/src/lib/hosted-session-service.js b/src/lib/hosted-session-service.js
new file mode 100644
index 00000000..fbd49cc0
--- /dev/null
+++ b/src/lib/hosted-session-service.js
@@ -0,0 +1,41 @@
+export function readHostedSessionData(storage, storageKey) {
+ try {
+ return JSON.parse(storage.getItem(storageKey) || '{}')
+ } catch {
+ return {}
+ }
+}
+
+export function writeHostedSessionConfig(storage, storageKey, sessionKey, nextConfig) {
+ const data = readHostedSessionData(storage, storageKey)
+ data[sessionKey] = nextConfig
+ storage.setItem(storageKey, JSON.stringify(data))
+ return data
+}
+
+export function buildHostedState({ sessionKey, storedConfig, hostedDefaults, runtimeDefault }) {
+ const config = { ...hostedDefaults, ...storedConfig }
+ if (!config.boundSessionKey) config.boundSessionKey = sessionKey
+ if (!config.systemPrompt && config.prompt) config.systemPrompt = config.prompt
+ if (!config.prompt && config.systemPrompt) config.prompt = config.systemPrompt
+ if (!config.contextTokenLimit) config.contextTokenLimit = hostedDefaults?.contextTokenLimit || runtimeDefault.contextTokenLimit
+ if (!config.state) config.state = { ...runtimeDefault }
+ if (!config.history) config.history = []
+ config.history = config.history.filter(m => m.role !== 'system')
+ const runtime = { ...runtimeDefault, ...config.state }
+ return {
+ sessionKey,
+ config,
+ runtime,
+ seeded: config.history.length > 0,
+ busy: false,
+ lastTargetTs: 0,
+ lastTargetHash: '',
+ lastSentHash: '',
+ lastCompletionRunId: '',
+ }
+}
+
+export function snapshotHostedGlobals(values) {
+ return { ...values }
+}
diff --git a/src/lib/hosted-step-service.js b/src/lib/hosted-step-service.js
new file mode 100644
index 00000000..ce5d2503
--- /dev/null
+++ b/src/lib/hosted-step-service.js
@@ -0,0 +1,71 @@
+import { HOSTED_DEFAULTS, HOSTED_STATUS } from './hosted-agent.js'
+
+export function validateHostedStepStart(config, runtime, gatewayReady, boundKey) {
+ const prompt = String(config?.prompt || '').trim()
+ if (!config?.enabled || !prompt) return { ok: false, reason: 'skip' }
+ if (!gatewayReady || !boundKey) {
+ runtime.status = HOSTED_STATUS.PAUSED
+ runtime.lastError = 'Gateway 未就绪或 sessionKey 缺失'
+ runtime.lastAction = 'paused'
+ return { ok: false, reason: 'gateway' }
+ }
+ if ((runtime.errorCount || 0) >= (config.retryLimit || 0)) {
+ runtime.status = HOSTED_STATUS.ERROR
+ return { ok: false, reason: 'retry-limit' }
+ }
+ if ((runtime.stepCount || 0) >= (config.maxSteps || 0)) {
+ runtime.status = HOSTED_STATUS.IDLE
+ runtime.lastAction = ''
+ return { ok: false, reason: 'max-steps' }
+ }
+ return { ok: true, prompt }
+}
+
+export function beginHostedStep(runtime, createId) {
+ runtime.pending = true
+ runtime.status = HOSTED_STATUS.RUNNING
+ runtime.lastRunAt = Date.now()
+ runtime.lastRunId = createId()
+ runtime.lastAction = ''
+ return runtime.lastRunId
+}
+
+export function getHostedStepDelay(config) {
+ return config?.stepDelayMs || HOSTED_DEFAULTS.stepDelayMs
+}
+
+export function markHostedGenerating(runtime) {
+ runtime.lastAction = 'generating-reply'
+}
+
+export function applyHostedTemplateError(runtime) {
+ runtime.errorCount = (runtime.errorCount || 0) + 1
+ runtime.lastError = '托管 Agent 输出未符合模板'
+ runtime.pending = false
+ runtime.status = HOSTED_STATUS.ERROR
+ runtime.lastAction = 'error'
+}
+
+export function applyHostedStepSuccess(runtime) {
+ runtime.stepCount += 1
+ runtime.errorCount = 0
+ runtime.lastError = ''
+ runtime.status = HOSTED_STATUS.WAITING
+ runtime.pending = false
+}
+
+export function applyHostedSelfStop(runtime) {
+ runtime.status = HOSTED_STATUS.IDLE
+ runtime.lastAction = 'stopped'
+}
+
+export function applyHostedStepFailure(runtime, retryLimit) {
+ runtime.errorCount = (runtime.errorCount || 0) + 1
+ runtime.lastError = runtime.lastError || '执行失败'
+ runtime.pending = false
+ if ((runtime.errorCount || 0) >= (retryLimit || 0)) {
+ runtime.status = HOSTED_STATUS.ERROR
+ return { terminal: true }
+ }
+ return { terminal: false, retryDelayMs: null }
+}
diff --git a/src/lib/markdown.js b/src/lib/markdown.js
index a27d5dd5..9dc4d9d7 100644
--- a/src/lib/markdown.js
+++ b/src/lib/markdown.js
@@ -79,8 +79,9 @@ export function renderMarkdown(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}`
+ const safeLang = escapeHtml(lang || '')
+ const langLabel = safeLang ? `
${safeLang}` : '
代码'
+ return `
${langLabel}
${highlighted}
`
})
// 行内代码
@@ -150,7 +151,8 @@ function inlineFormat(text) {
.replace(/(?$1')
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) => {
const safeSrc = resolveImageSrc(src.trim())
- return `

`
+ const escapedSrc = escapeHtml(src).replace(/\\/g, '\')
+ return `

`
})
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
const safe = /^https?:|^mailto:/i.test(url.trim()) ? url : '#'
@@ -162,10 +164,10 @@ window.__copyCode = function(btn) {
const pre = btn.closest('pre')
const code = pre.querySelector('code')
navigator.clipboard.writeText(code.innerText).then(() => {
- btn.textContent = '✓'
- setTimeout(() => { btn.textContent = 'Copy' }, 1500)
+ btn.textContent = '已复制'
+ setTimeout(() => { btn.textContent = '复制' }, 1500)
}).catch(() => {
- btn.textContent = '✗'
- setTimeout(() => { btn.textContent = 'Copy' }, 1500)
+ btn.textContent = '失败'
+ setTimeout(() => { btn.textContent = '复制' }, 1500)
})
}
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/lib/skills-catalog.js b/src/lib/skills-catalog.js
new file mode 100644
index 00000000..7e9f92ce
--- /dev/null
+++ b/src/lib/skills-catalog.js
@@ -0,0 +1,64 @@
+import { api } from './tauri-api.js'
+
+const SKILLS_CACHE_TTL_MS = 20_000
+
+let _skillsCache = {
+ data: null,
+ expiresAt: 0,
+ pending: null,
+}
+
+function normalizeSkillsData(data) {
+ return {
+ ...data,
+ skills: Array.isArray(data?.skills) ? data.skills : [],
+ }
+}
+
+export function summarizeSkillsCatalog(data) {
+ const skills = Array.isArray(data?.skills) ? data.skills : []
+ const eligible = skills.filter(s => s.eligible && !s.disabled)
+ const missing = skills.filter(s => !s.eligible && !s.disabled && !s.blockedByAllowlist)
+ const disabled = skills.filter(s => s.disabled)
+ const blocked = skills.filter(s => s.blockedByAllowlist && !s.disabled)
+ return {
+ total: skills.length,
+ eligible: eligible.length,
+ missing: missing.length,
+ disabled: disabled.length,
+ blocked: blocked.length,
+ }
+}
+
+export function getCachedSkillsCatalog() {
+ if (!_skillsCache.data) return null
+ if (Date.now() > _skillsCache.expiresAt) return null
+ return _skillsCache.data
+}
+
+export function invalidateSkillsCatalog() {
+ _skillsCache.expiresAt = 0
+}
+
+export async function loadSkillsCatalog(options = {}) {
+ const force = !!options.force
+ const now = Date.now()
+ if (!force && _skillsCache.data && now <= _skillsCache.expiresAt) {
+ return _skillsCache.data
+ }
+ if (!force && _skillsCache.pending) {
+ return _skillsCache.pending
+ }
+ const request = api.skillsList()
+ .then(normalizeSkillsData)
+ .then(data => {
+ _skillsCache.data = data
+ _skillsCache.expiresAt = Date.now() + SKILLS_CACHE_TTL_MS
+ return data
+ })
+ .finally(() => {
+ if (_skillsCache.pending === request) _skillsCache.pending = null
+ })
+ _skillsCache.pending = request
+ return request
+}
diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js
index f8ae3353..d8ea51ff 100644
--- a/src/lib/tauri-api.js
+++ b/src/lib/tauri-api.js
@@ -101,12 +101,33 @@ async function invoke(cmd, args = {}) {
}
// Web 模式:通过 Vite 开发服务器的 API 端点调用真实后端
+const COMMAND_TIMEOUTS = {
+ 'get_status_summary': 30000,
+ 'list_agents': 20000,
+ 'read_log_tail': 15000,
+ 'get_services_status': 15000,
+ 'read_openclaw_config': 15000,
+ 'list_backups': 15000,
+}
+
async function webInvoke(cmd, args) {
- const resp = await fetch(`/__api/${cmd}`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(args),
- })
+ const controller = new AbortController()
+ const timeoutMs = COMMAND_TIMEOUTS[cmd] || 20000
+ const timeout = setTimeout(() => controller.abort(), timeoutMs)
+ let resp
+ try {
+ resp = await fetch(`/__api/${cmd}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(args),
+ signal: controller.signal,
+ })
+ } catch (e) {
+ if (controller.signal.aborted) throw new Error('后端请求超时')
+ throw e
+ } finally {
+ clearTimeout(timeout)
+ }
if (resp.status === 401) {
// Tauri 模式下不触发登录浮层(Tauri 有自己的认证流程)
if (!isTauri && window.__clawpanel_show_login) window.__clawpanel_show_login()
@@ -156,6 +177,13 @@ export async function checkBackendHealth() {
}
}
+// 配置保存后防抖重载 Gateway(3 秒内多次写入只触发一次重载)
+let _reloadTimer = null
+function _debouncedReloadGateway() {
+ clearTimeout(_reloadTimer)
+ _reloadTimer = setTimeout(() => { invoke('reload_gateway').catch(() => {}) }, 3000)
+}
+
// 导出 API
export const api = {
// 服务管理(状态用短缓存,操作不缓存)
@@ -166,10 +194,11 @@ 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 }) },
+ 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'),
@@ -219,6 +248,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/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/src/lib/ws-client.js b/src/lib/ws-client.js
index 88b68053..fe71e955 100644
--- a/src/lib/ws-client.js
+++ b/src/lib/ws-client.js
@@ -21,8 +21,20 @@ export function uuid() {
const REQUEST_TIMEOUT = 30000
const MAX_RECONNECT_DELAY = 30000
-const PING_INTERVAL = 25000
+const PING_INTERVAL = 5000
const CHALLENGE_TIMEOUT = 5000
+const WS_DEBUG = typeof window !== 'undefined' && localStorage.getItem('clawpanel-ws-debug') === '1'
+
+const WS_STATE = {
+ DISCONNECTED: 'disconnected',
+ CONNECTING: 'connecting',
+ CONNECTED: 'connected',
+ READY: 'ready',
+ HANDSHAKING: 'handshaking',
+ RECONNECTING: 'reconnecting',
+ ERROR: 'error',
+ AUTH_FAILED: 'auth_failed',
+}
export class WsClient {
constructor() {
@@ -45,9 +57,13 @@ export class WsClient {
this._sessionKey = null
this._pingTimer = null
this._challengeTimer = null
+ this._connectSent = false
this._wsId = 0
this._autoPairAttempts = 0
+ this._autoPairing = false
this._serverVersion = null
+ this._state = WS_STATE.DISCONNECTED
+ this._stateStats = {}
}
get connected() { return this._connected }
@@ -56,7 +72,12 @@ export class WsClient {
get snapshot() { return this._snapshot }
get hello() { return this._hello }
get sessionKey() { return this._sessionKey }
+ setSessionKey(key) {
+ if (!key) return
+ this._sessionKey = key
+ }
get serverVersion() { return this._serverVersion }
+ get state() { return this._state }
onStatusChange(fn) {
this._statusListeners.push(fn)
@@ -90,7 +111,7 @@ export class WsClient {
this._clearChallengeTimer()
this._flushPending()
this._closeWs()
- this._setConnected(false)
+ this._setConnected(false, WS_STATE.DISCONNECTED)
this._gatewayReady = false
this._handshaking = false
}
@@ -105,15 +126,18 @@ export class WsClient {
this._clearChallengeTimer()
this._flushPending()
this._closeWs()
+ this._setConnected(false, WS_STATE.CONNECTING)
this._doConnect()
}
_doConnect() {
+ if (this._connecting) return
this._connecting = true
this._closeWs()
this._gatewayReady = false
this._handshaking = false
- this._setConnected(false, 'connecting')
+ this._connectSent = false
+ this._setConnected(false, WS_STATE.CONNECTING)
const wsId = ++this._wsId
let ws
try { ws = new WebSocket(this._url) } catch { this._scheduleReconnect(); return }
@@ -122,14 +146,14 @@ export class WsClient {
ws.onopen = () => {
if (wsId !== this._wsId) return
this._connecting = false
- this._reconnectAttempts = 0
- this._setConnected(true)
+ 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)
}
@@ -147,7 +171,7 @@ export class WsClient {
this._connecting = false
this._clearChallengeTimer()
if (e.code === 4001 || e.code === 4003 || e.code === 4004) {
- this._setConnected(false, 'auth_failed', e.reason || 'Token 认证失败')
+ this._setConnected(false, WS_STATE.AUTH_FAILED, e.reason || 'Token 认证失败')
this._intentionalClose = true
this._flushPending()
return
@@ -155,15 +179,16 @@ export class WsClient {
if (e.code === 1008 && !this._intentionalClose) {
if (this._autoPairAttempts < 1) {
console.log('[ws] origin not allowed (1008),尝试自动修复...')
- this._setConnected(false, 'reconnecting', 'origin not allowed,修复中...')
+ this._setConnected(false, WS_STATE.RECONNECTING, 'origin not allowed,修复中...')
this._autoPairAndReconnect()
return
}
console.warn('[ws] origin 1008 自动修复已尝试过,显示错误')
- this._setConnected(false, 'error', e.reason || 'origin not allowed,请点击「修复并重连」')
+ this._setConnected(false, WS_STATE.ERROR, e.reason || 'origin not allowed,请点击「修复并重连」')
return
}
- this._setConnected(false)
+ if (this._intentionalClose) this._setConnected(false, WS_STATE.DISCONNECTED)
+ else this._setConnected(false, WS_STATE.RECONNECTING)
this._gatewayReady = false
this._handshaking = false
this._stopPing()
@@ -203,10 +228,12 @@ export class WsClient {
console.warn('[ws] 自动修复已尝试过,不再重试')
}
- this._setConnected(false, 'error', errMsg)
+ this._setConnected(false, WS_STATE.ERROR, errMsg)
this._readyCallbacks.forEach(fn => {
try { fn(null, null, { error: true, message: errMsg }) } catch {}
})
+ this._closeWs()
+ this._scheduleReconnect()
return
}
// 握手成功,提取 snapshot
@@ -220,8 +247,17 @@ export class WsClient {
if (cb) {
this._pending.delete(msg.id)
clearTimeout(cb.timer)
- if (msg.ok) cb.resolve(msg.payload)
- else cb.reject(new Error(msg.error?.message || msg.error?.code || 'request failed'))
+ if (msg.ok) {
+ cb.resolve(msg.payload)
+ if (cb.emitEvent) {
+ const base = (msg.payload && typeof msg.payload === 'object') ? { ...msg.payload } : { value: msg.payload }
+ const payload = { ...base, _req: cb.params, _method: cb.method }
+ const evt = { type: 'event', event: cb.method, payload }
+ this._eventListeners.forEach(fn => {
+ try { fn(evt) } catch (e) { console.error('[ws] handler error:', e) }
+ })
+ }
+ } else cb.reject(new Error(msg.error?.message || msg.error?.code || 'request failed'))
}
return
}
@@ -235,7 +271,11 @@ export class WsClient {
}
async _autoPairAndReconnect() {
+ if (this._autoPairing) return
+ this._autoPairing = true
this._autoPairAttempts++
+ this._flushPending()
+ this._clearReconnectTimer()
try {
console.log('[ws] 执行自动配对(第', this._autoPairAttempts, '次)...')
const result = await api.autoPairDevice()
@@ -257,12 +297,17 @@ export class WsClient {
}, 2000)
} catch (e) {
console.error('[ws] 自动配对失败:', e)
- this._setConnected(false, 'error', `配对失败: ${e}`)
+ this._setConnected(false, WS_STATE.ERROR, `配对失败: ${e}`)
+ } finally {
+ this._autoPairing = false
}
}
async _sendConnectFrame(nonce) {
+ if (this._connectSent) return
+ this._connectSent = true
this._handshaking = true
+ this._setConnected(false, WS_STATE.HANDSHAKING)
try {
const frame = await api.createConnectFrame(nonce, this._token)
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
@@ -272,11 +317,16 @@ export class WsClient {
} catch (e) {
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
@@ -289,7 +339,7 @@ export class WsClient {
}
this._gatewayReady = true
console.log('[ws] Gateway 就绪, sessionKey:', this._sessionKey)
- this._setConnected(true, 'ready')
+ this._setConnected(true, WS_STATE.READY)
this._readyCallbacks.forEach(fn => {
try { fn(this._hello, this._sessionKey) } catch (e) {
console.error('[ws] ready cb error:', e)
@@ -297,14 +347,42 @@ export class WsClient {
})
}
- _setConnected(val, status, errorMsg) {
- this._connected = val
- const s = status || (val ? 'connected' : 'disconnected')
+ _transition(status, errorMsg) {
+ const prev = this._state
+ this._state = status
+ this._connected = status === WS_STATE.CONNECTED || status === WS_STATE.READY
+ if (WS_DEBUG && prev !== status) {
+ const allowed = this._isAllowedTransition(prev, status)
+ if (!allowed) console.warn('[ws] unexpected state transition', prev, '->', status)
+ this._stateStats[status] = (this._stateStats[status] || 0) + 1
+ console.log('[ws] state', prev, '->', status, 'count=' + this._stateStats[status], errorMsg || '')
+ }
this._statusListeners.forEach(fn => {
- try { fn(s, errorMsg) } catch (e) { console.error('[ws] status listener error:', e) }
+ try { fn(status, errorMsg) } catch (e) { console.error('[ws] status listener error:', e) }
})
}
+ _isAllowedTransition(from, to) {
+ if (!from) return true
+ const map = {
+ [WS_STATE.DISCONNECTED]: [WS_STATE.CONNECTING, WS_STATE.RECONNECTING],
+ [WS_STATE.CONNECTING]: [WS_STATE.HANDSHAKING, WS_STATE.ERROR, WS_STATE.DISCONNECTED, WS_STATE.RECONNECTING],
+ [WS_STATE.HANDSHAKING]: [WS_STATE.READY, WS_STATE.ERROR, WS_STATE.DISCONNECTED],
+ [WS_STATE.READY]: [WS_STATE.RECONNECTING, WS_STATE.DISCONNECTED, WS_STATE.ERROR],
+ [WS_STATE.CONNECTED]: [WS_STATE.HANDSHAKING, WS_STATE.READY, WS_STATE.RECONNECTING, WS_STATE.DISCONNECTED],
+ [WS_STATE.RECONNECTING]: [WS_STATE.CONNECTING, WS_STATE.HANDSHAKING, WS_STATE.READY, WS_STATE.ERROR],
+ [WS_STATE.ERROR]: [WS_STATE.CONNECTING, WS_STATE.RECONNECTING, WS_STATE.DISCONNECTED],
+ [WS_STATE.AUTH_FAILED]: [WS_STATE.CONNECTING, WS_STATE.DISCONNECTED],
+ }
+ const next = map[from] || []
+ return next.includes(to)
+ }
+
+ _setConnected(val, status, errorMsg) {
+ const s = status || (val ? WS_STATE.CONNECTED : WS_STATE.DISCONNECTED)
+ this._transition(s, errorMsg)
+ }
+
_closeWs() {
if (this._ws) {
const old = this._ws
@@ -342,15 +420,21 @@ export class WsClient {
? 1000
: Math.min(1000 * Math.pow(2, this._reconnectAttempts - 2), MAX_RECONNECT_DELAY)
this._reconnectAttempts++
- this._setConnected(false, 'reconnecting')
+ this._setConnected(false, WS_STATE.RECONNECTING)
this._reconnectTimer = setTimeout(() => this._doConnect(), delay)
}
_startPing() {
this._stopPing()
this._pingTimer = setInterval(() => {
- if (this._ws && this._ws.readyState === WebSocket.OPEN) {
- try { this._ws.send('{"type":"ping"}') } catch {}
+ if (this._ws && this._ws.readyState === WebSocket.OPEN && this._gatewayReady) {
+ try {
+ const id = uuid()
+ this._ws.send(JSON.stringify({ type: 'req', id, method: 'node.list', params: {} }))
+ if (this._sessionKey) {
+ this.request('chat.history', { sessionKey: this._sessionKey, limit: 10 }, { emitEvent: true }).catch(() => {})
+ }
+ } catch {}
}
}, PING_INTERVAL)
}
@@ -362,7 +446,7 @@ export class WsClient {
}
}
- request(method, params = {}) {
+ request(method, params = {}, options = {}) {
return new Promise((resolve, reject) => {
if (!this._ws || this._ws.readyState !== WebSocket.OPEN || !this._gatewayReady) {
if (!this._intentionalClose && (this._reconnectAttempts > 0 || !this._gatewayReady)) {
@@ -370,7 +454,7 @@ export class WsClient {
const unsub = this.onReady((hello, sessionKey, err) => {
clearTimeout(waitTimeout); unsub()
if (err?.error) { reject(new Error(err.message || 'Gateway 握手失败')); return }
- this.request(method, params).then(resolve, reject)
+ this.request(method, params, options).then(resolve, reject)
})
return
}
@@ -378,8 +462,14 @@ export class WsClient {
}
const id = uuid()
const timer = setTimeout(() => { this._pending.delete(id); reject(new Error('请求超时')) }, REQUEST_TIMEOUT)
- this._pending.set(id, { resolve, reject, timer })
- this._ws.send(JSON.stringify({ type: 'req', id, method, params }))
+ this._pending.set(id, { resolve, reject, timer, method, params, emitEvent: !!options.emitEvent })
+ try {
+ this._ws.send(JSON.stringify({ type: 'req', id, method, params }))
+ } catch (e) {
+ this._pending.delete(id)
+ clearTimeout(timer)
+ reject(e instanceof Error ? e : new Error('请求发送失败'))
+ }
})
}
diff --git a/src/main.js b/src/main.js
index 5c3d181b..3b22b489 100644
--- a/src/main.js
+++ b/src/main.js
@@ -28,6 +28,9 @@ initTheme()
// === 访问密码保护(Web + 桌面端通用) ===
const isTauri = !!window.__TAURI_INTERNALS__
+let _forceSetup = false
+let _skipSetup = false
+
async function checkAuth() {
if (isTauri) {
@@ -348,7 +351,10 @@ async function boot() {
// Tauri 模式:确保 web session 存在(页面刷新后 cookie 可能丢失),然后加载实例和检测状态
const ensureWebSession = isTauri
? api.readPanelConfig().then(cfg => {
- if (cfg.accessPassword) {
+ _forceSetup = cfg.forceSetup === true
+ _skipSetup = cfg.skipSetup === true
+ const shouldAutoLogin = !!cfg.accessPassword && !cfg.mustChangePassword && cfg.forceSetup !== true
+ if (shouldAutoLogin) {
return fetch('/__api/auth_login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -361,11 +367,11 @@ async function boot() {
ensureWebSession.then(() => loadActiveInstance()).then(() => detectOpenclawStatus()).then(() => {
// 重新渲染侧边栏(检测完成后 isOpenclawReady 状态已更新)
renderSidebar(sidebar)
- if (!isOpenclawReady()) {
+ const wantsSetup = window.location.hash.startsWith('#/setup') || _forceSetup || (!isOpenclawReady() && !_skipSetup)
+ if (wantsSetup) {
setDefaultRoute('/setup')
navigate('/setup')
} else {
- if (window.location.hash === '#/setup') navigate('/dashboard')
setupGatewayBanner()
startGatewayPoll()
@@ -419,11 +425,8 @@ async function boot() {
await detectOpenclawStatus()
renderSidebar(sidebar)
// 如果安装完成后变为就绪,跳转到仪表盘
- if (isOpenclawReady() && window.location.hash === '#/setup') {
- navigate('/dashboard')
- }
// 如果卸载后变为未就绪,跳转到 setup
- if (!isOpenclawReady() && !isUpgrading()) {
+ if ((_forceSetup || (!isOpenclawReady() && !_skipSetup)) && !isUpgrading()) {
setDefaultRoute('/setup')
navigate('/setup')
}
@@ -730,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) {
diff --git a/src/pages/agents.js b/src/pages/agents.js
index eeee4fdc..223983e5 100644
--- a/src/pages/agents.js
+++ b/src/pages/agents.js
@@ -36,12 +36,12 @@ export async function render() {
function renderSkeleton(container) {
const item = () => `
-
+
`
@@ -61,7 +61,7 @@ async function loadAgents(page, state) {
state.eventsAttached = true
}
} catch (e) {
- container.innerHTML = '
加载失败: ' + e + '
'
+ container.innerHTML = '
加载失败: ' + e + '
'
toast('加载 Agent 列表失败: ' + e, 'error')
}
}
@@ -69,7 +69,7 @@ async function loadAgents(page, state) {
function renderAgents(page, state) {
const container = page.querySelector('#agents-list')
if (!state.agents.length) {
- container.innerHTML = '
暂无 Agent
'
+ container.innerHTML = '
暂无 Agent
'
return
}
@@ -100,7 +100,7 @@ function renderAgents(page, state) {
工作区:
- ${a.workspace || '未设置'}
+ ${a.workspace || '未设置'}
@@ -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-settings.js b/src/pages/assistant-settings.js
new file mode 100644
index 00000000..dfd4e411
--- /dev/null
+++ b/src/pages/assistant-settings.js
@@ -0,0 +1,388 @@
+export function renderAssistantSettingsModal({
+ config,
+ assistantName,
+ apiTypes,
+ providerPresets,
+ qtcool,
+ defaultName,
+ defaultPersonality,
+ normalizeApiType,
+ apiBasePlaceholder,
+ apiKeyPlaceholder,
+ apiHintText,
+ escHtml,
+ icon,
+}) {
+ const c = config
+ const soulIsOpenClaw = !!c.soulSource?.startsWith('openclaw:')
+ const assistantDisplayName = assistantName || defaultName
+ const toolCount = [c.tools?.terminal !== false, c.tools?.fileOps !== false, c.tools?.webSearch !== false].filter(Boolean).length
+ const knowledgeCount = Array.isArray(c.knowledgeFiles) ? c.knowledgeFiles.length : 0
+ const providerPresetOptions = providerPresets
+ .filter(p => !p.hidden)
+ .map(p => `
${i + 1}
${escHtml(item.text)}
@@ -1189,11 +1155,15 @@ function renderQueue() {
`).join('')
}
-function processQueue() {
- if (_isStreaming || _messageQueue.length === 0) return
- const next = _messageQueue.shift()
- renderQueue()
- sendMessageDirect(next.text)
+function processQueue(sessionId = _currentSessionId) {
+ if (!sessionId) return
+ if (getStreaming(sessionId)) return
+ const queue = getQueue(sessionId)
+ if (queue.length === 0) return
+ const next = queue.shift()
+ setQueue(sessionId, queue)
+ if (_currentSessionId === sessionId) renderQueue()
+ sendMessageDirect(next.text, { sessionId })
}
// ── 图片附件 ──
@@ -1224,12 +1194,12 @@ function addImageFromFile(file) {
ctx.drawImage(img, 0, 0, width, height)
// JPEG 压缩到合理大小
const dataUrl = canvas.toDataURL('image/jpeg', 0.85)
- _pendingImages.push({
- id: Date.now().toString() + Math.random().toString(36).slice(2, 6),
+ _pendingImages.push(createAssistantPendingImage({
dataUrl,
name: file.name || 'image.jpg',
- width, height,
- })
+ width,
+ height,
+ }))
renderImagePreview()
}
img.src = e.target.result
@@ -1243,7 +1213,7 @@ function addImageFromClipboard(item) {
}
function removeImage(id) {
- _pendingImages = _pendingImages.filter(img => img.id !== id)
+ _pendingImages = removeAssistantPendingImage(_pendingImages, id)
renderImagePreview()
}
@@ -1257,110 +1227,66 @@ function renderImagePreview() {
}
container.style.display = 'flex'
const delSvg = '
'
- container.innerHTML = _pendingImages.map(img => `
-
-

-
-
- `).join('')
+ container.innerHTML = buildAssistantImagePreviewHtml(_pendingImages, escHtml, delSvg)
}
function clearPendingImages() {
- _pendingImages = []
+ _pendingImages = clearAssistantPendingImages()
renderImagePreview()
}
// 构建多模态消息 content
function buildMessageContent(text, images) {
- if (!images || images.length === 0) return text
- const parts = []
- if (text) parts.push({ type: 'text', text })
- for (const img of images) {
- parts.push({
- type: 'image_url',
- image_url: { url: img.dataUrl, detail: 'auto' },
- })
- }
- return parts
+ return buildAssistantMessageContent(text, images)
}
// ── 会话状态管理 ──
function setSessionStatus(sessionId, status) {
- if (status === 'idle') {
- _sessionStatus.delete(sessionId)
- } else {
- _sessionStatus.set(sessionId, status)
- }
+ if (!sessionId) return
+ patchRequestState(sessionId, { status: status || 'idle' })
renderSessionList()
}
function getSessionStatus(sessionId) {
- return _sessionStatus.get(sessionId) || 'idle'
+ return getRequestState(sessionId)?.status || 'idle'
}
// ── 带重试的 fetch ──
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
- // 5xx 服务端错误,静默重试
- 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]))
- }
- }
+ return fetchAssistantWithRetry(url, options, retries)
}
// ── 配置读写 ──
function loadConfig() {
- try {
- const raw = localStorage.getItem(STORAGE_KEY)
- _config = raw ? JSON.parse(raw) : null
- } catch { _config = null }
- if (!_config) {
- _config = { baseUrl: '', apiKey: '', model: '', temperature: 0.7, tools: { terminal: false, fileOps: false, webSearch: false }, assistantName: DEFAULT_NAME, assistantPersonality: DEFAULT_PERSONALITY }
- }
- if (!_config.assistantName) _config.assistantName = DEFAULT_NAME
- if (!_config.assistantPersonality) _config.assistantPersonality = DEFAULT_PERSONALITY
- if (!_config.tools) _config.tools = { terminal: false, fileOps: false, webSearch: false }
- if (!_config.mode) _config.mode = DEFAULT_MODE
- _config.apiType = normalizeApiType(_config.apiType)
- if (_config.autoRounds === undefined) _config.autoRounds = 8
- if (!Array.isArray(_config.knowledgeFiles)) _config.knowledgeFiles = []
+ _config = loadAssistantConfig(localStorage, STORAGE_KEY, {
+ name: DEFAULT_NAME,
+ personality: DEFAULT_PERSONALITY,
+ mode: DEFAULT_MODE,
+ })
return _config
}
function saveConfig() {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(_config))
+ saveAssistantConfig(localStorage, STORAGE_KEY, _config)
}
// ── 会话管理 ──
function loadSessions() {
- try {
- const raw = localStorage.getItem(SESSIONS_KEY)
- _sessions = raw ? JSON.parse(raw) : []
- } catch { _sessions = [] }
+ _sessions = loadAssistantSessions(localStorage, SESSIONS_KEY)
+ const validIds = new Set(_sessions.map(s => s.id).filter(Boolean))
+ for (const id of [..._requestStateBySession.keys()]) {
+ if (!validIds.has(id)) _requestStateBySession.delete(id)
+ }
+ _sessions.forEach(session => ensureRequestState(session.id))
return _sessions
}
function saveSessions() {
- if (_sessions.length > MAX_SESSIONS) {
- _sessions = _sessions.slice(-MAX_SESSIONS)
- }
- // 保存时剥离图片 dataUrl(避免撑爆 localStorage)
- const serialized = JSON.stringify(_sessions, (key, value) => {
- if (key === 'dataUrl' && typeof value === 'string' && value.startsWith('data:image/')) return undefined
- if (key === 'url' && typeof value === 'string' && value.startsWith('data:image/')) return '[image]'
- return value
- })
+ const serializedState = serializeAssistantSessions(_sessions, MAX_SESSIONS)
+ _sessions = serializedState.sessions
try {
- localStorage.setItem(SESSIONS_KEY, serialized)
+ localStorage.setItem(SESSIONS_KEY, serializedState.serialized)
} catch (e) {
- // QuotaExceeded: 清理最旧的会话
if (e.name === 'QuotaExceededError' && _sessions.length > 1) {
_sessions.shift()
saveSessions()
@@ -1373,20 +1299,20 @@ function getCurrentSession() {
}
function createSession() {
- const session = {
- id: crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(36) + Math.random().toString(36).slice(2),
- title: '新会话',
- messages: [],
- createdAt: Date.now(),
- updatedAt: Date.now()
- }
+ const session = createAssistantSession(() => crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(36) + Math.random().toString(36).slice(2))
_sessions.push(session)
+ ensureRequestState(session.id)
_currentSessionId = session.id
saveSessions()
return session
}
function deleteSession(id) {
+ if (!id) return
+ const controller = getAbortController(id)
+ if (controller) controller.abort()
+ clearRequestState(id)
+ _requestStateBySession.delete(id)
_sessions = _sessions.filter(s => s.id !== id)
if (_currentSessionId === id) {
_currentSessionId = _sessions.length > 0 ? _sessions[_sessions.length - 1].id : null
@@ -1395,62 +1321,18 @@ function deleteSession(id) {
}
function autoTitle(session) {
- if (session.messages.length >= 1 && session.title === '新会话') {
- const firstUser = session.messages.find(m => m.role === 'user')
- if (firstUser) {
- const txt = firstUser._text || (typeof firstUser.content === 'string' ? firstUser.content : (firstUser.content?.find?.(p => p.type === 'text')?.text || '[图片消息]'))
- // 取第一行或前30字作为标题(跳过空行)
- const firstLine = txt.split('\n').find(l => l.trim()) || txt
- const title = firstLine.slice(0, 30) + (firstLine.length > 30 ? '...' : '')
- session.title = title
- }
- }
+ const title = getAutoSessionTitle(session)
+ if (title) session.title = title
}
// ── AI API 调用(自动兼容 Chat Completions + Responses API)──
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 || _config.apiType)
- if (type === 'anthropic-messages') {
- // Anthropic: https://api.anthropic.com/v1
- if (!base.endsWith('/v1')) base += '/v1'
- return base
- }
- if (type === 'google-gemini') {
- // Gemini: https://generativelanguage.googleapis.com/v1beta
- return base
- }
- if (/:(11434)$/i.test(base) && !base.endsWith('/v1')) return `${base}/v1`
- // 不再强制追加 /v1,尊重用户填写的 URL(火山引擎等第三方用 /v3 等路径)
- return base
+ return cleanAssistantBaseUrl(raw, apiType || _config.apiType)
}
function authHeaders(apiType, apiKey) {
- const type = normalizeApiType(apiType || _config.apiType)
- const key = apiKey || _config.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
+ return buildAssistantAuthHeaders(apiType || _config.apiType, apiKey || _config.apiKey || '')
}
// 超时常量
@@ -1458,372 +1340,83 @@ const TIMEOUT_TOTAL = 120_000 // 总超时 120 秒
const TIMEOUT_CHUNK = 30_000 // 流式 chunk 间隔超时 30 秒
const TIMEOUT_CONNECT = 30_000 // 连接超时 30 秒
-async function callAI(messages, onChunk) {
- const apiType = normalizeApiType(_config.apiType)
- if (!_config.baseUrl || !_config.model || (requiresApiKey(apiType) && !_config.apiKey)) {
- throw new Error('请先配置 AI 模型(点击右上角设置按钮)')
- }
-
- const base = cleanBaseUrl(_config.baseUrl, apiType)
- _abortController = new AbortController()
- const allMessages = [{ role: 'system', content: buildSystemPrompt() }, ...messages]
-
- // 总超时保护
- let _timedOut = false
- const totalTimer = setTimeout(() => {
- _timedOut = true
- if (_abortController) _abortController.abort()
- }, TIMEOUT_TOTAL)
-
- try {
- if (apiType === 'anthropic-messages') {
- await callAnthropicMessages(base, allMessages, onChunk)
- return
- }
-
- if (apiType === 'google-gemini') {
- await callGeminiGenerate(base, allMessages, onChunk)
- return
- }
-
- // OpenAI: 先尝试 Chat Completions API
- try {
- await callChatCompletions(base, allMessages, onChunk)
- return
- } catch (err) {
- // 超时触发的 abort → 转换为超时错误
- if (err.name === 'AbortError' && _timedOut) {
- throw new Error(`请求超时(${TIMEOUT_TOTAL / 1000} 秒),模型响应时间过长`)
- }
- // 如果是 "legacy protocol" 或 "use /v1/responses" 类错误,自动切换到 Responses API
- 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()
- await callResponsesAPI(base, allMessages, onChunk)
- return
- }
- throw err
- }
- } finally {
- clearTimeout(totalTimer)
- }
+async function callAI(sessionId, messages, onChunk, signal) {
+ const { text } = await callAICore({
+ config: _config,
+ messages,
+ adapters: { soulCache: _soulCache, knowledgeBase: OPENCLAW_KB },
+ mode: currentMode(),
+ signal,
+ })
+ if (typeof onChunk === 'function' && text) onChunk(text)
}
// ── 调试信息 ──
let _lastDebugInfo = null
// ── Chat Completions API(/v1/chat/completions)──
-async function callChatCompletions(base, messages, onChunk) {
- const url = base + '/chat/completions'
- const body = {
- model: _config.model,
+async function callChatCompletions(base, messages, onChunk, signal) {
+ return callAssistantChatCompletions({
+ base,
messages,
- stream: true,
- temperature: _config.temperature || 0.7,
- }
-
- const reqTime = Date.now()
- _lastDebugInfo = {
- url,
- method: 'POST',
- requestBody: { ...body, messages: body.messages.map(m => ({ role: m.role, content: typeof m.content === 'string' ? m.content.slice(0, 200) + (m.content.length > 200 ? '...' : '') : '[multimodal]' })) },
- requestTime: new Date(reqTime).toLocaleString('zh-CN'),
- }
-
- const resp = await fetchWithRetry(url, {
- method: 'POST',
- headers: authHeaders(),
- body: JSON.stringify(body),
- signal: _abortController.signal,
+ onChunk,
+ signal,
+ config: _config,
+ fetchWithRetry,
+ authHeaders,
+ setDebugInfo: (next) => {
+ _lastDebugInfo = typeof next === 'function' ? next(_lastDebugInfo) : next
+ },
})
-
- _lastDebugInfo.status = resp.status
- _lastDebugInfo.contentType = resp.headers.get('content-type') || ''
- _lastDebugInfo.responseTime = new Date().toLocaleString('zh-CN')
- _lastDebugInfo.latency = Date.now() - reqTime + 'ms'
-
- if (!resp.ok) {
- const errText = await resp.text().catch(() => '')
- _lastDebugInfo.errorBody = errText.slice(0, 500)
- let errMsg = `API 错误 ${resp.status}`
- try {
- const errJson = JSON.parse(errText)
- errMsg = errJson.error?.message || errJson.message || errMsg
- } catch {
- if (errText) errMsg += `: ${errText.slice(0, 200)}`
- }
- throw new Error(errMsg)
- }
-
- // 检测响应是否为 SSE 流式
- const ct = resp.headers.get('content-type') || ''
- if (ct.includes('text/event-stream') || ct.includes('text/plain')) {
- _lastDebugInfo.streaming = true
- let chunkCount = 0
- let contentChunks = 0
- let reasoningChunks = 0
- let reasoningBuf = ''
-
- await readSSEStream(resp, (json) => {
- chunkCount++
- const d = json.choices?.[0]?.delta
- if (!d) return
-
- // content 和 reasoning_content 分开处理
- if (d.content) {
- contentChunks++
- onChunk(d.content)
- } else if (d.reasoning_content) {
- reasoningChunks++
- reasoningBuf += d.reasoning_content
- }
- }, _abortController?.signal)
-
- _lastDebugInfo.chunks = { total: chunkCount, content: contentChunks, reasoning: reasoningChunks }
-
- // 如果没有 content 但有 reasoning,将推理内容作为回复(部分模型只返回 reasoning)
- if (contentChunks === 0 && reasoningBuf) {
- console.warn('[assistant] 无 content 块,使用 reasoning_content 作为回复')
- onChunk(reasoningBuf)
- _lastDebugInfo.fallbackToReasoning = true
- }
- } else {
- // 非流式响应:API 忽略了 stream:true,直接返回完整 JSON
- _lastDebugInfo.streaming = false
- const json = await resp.json()
- _lastDebugInfo.responseBody = { id: json.id, model: json.model, object: json.object, usage: json.usage }
- console.log('[assistant] 非流式响应:', json)
- const msg = json.choices?.[0]?.message
- const content = msg?.content || msg?.reasoning_content || ''
- if (content) onChunk(content)
- }
}
// ── Responses API(/v1/responses)──
-async function callResponsesAPI(base, messages, onChunk) {
- 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(),
- body: JSON.stringify(body),
- signal: _abortController.signal,
+async function callResponsesAPI(base, messages, onChunk, signal) {
+ return callAssistantResponsesAPI({
+ base,
+ messages,
+ onChunk,
+ signal,
+ config: _config,
+ fetchWithRetry,
+ authHeaders,
})
-
- if (!resp.ok) {
- const errText = await resp.text().catch(() => '')
- let errMsg = `API 错误 ${resp.status}`
- try {
- const errJson = JSON.parse(errText)
- errMsg = errJson.error?.message || errJson.message || errMsg
- } catch {
- if (errText) errMsg += `: ${errText.slice(0, 200)}`
- }
- throw new Error(errMsg)
- }
-
- await readSSEStream(resp, (json) => {
- // Responses API 的流式事件格式
- if (json.type === 'response.output_text.delta') {
- if (json.delta) onChunk(json.delta)
- }
- // 兼容:有些代理会转换为 choices 格式
- if (json.choices?.[0]?.delta?.content) {
- onChunk(json.choices[0].delta.content)
- }
- }, _abortController?.signal)
}
// ── Anthropic Messages API(/v1/messages)──
-async function callAnthropicMessages(base, messages, onChunk) {
- 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 reqTime = Date.now()
- _lastDebugInfo = {
- url, method: 'POST',
- requestBody: { ...body, messages: body.messages.map(m => ({ role: m.role, content: typeof m.content === 'string' ? m.content.slice(0, 200) + (m.content.length > 200 ? '...' : '') : '[multimodal]' })) },
- requestTime: new Date(reqTime).toLocaleString('zh-CN'),
- }
-
- const resp = await fetchWithRetry(url, {
- method: 'POST',
- headers: authHeaders(),
- body: JSON.stringify(body),
- signal: _abortController.signal,
+async function callAnthropicMessages(base, messages, onChunk, signal) {
+ return callAssistantAnthropicMessages({
+ base,
+ messages,
+ onChunk,
+ signal,
+ config: _config,
+ fetchWithRetry,
+ authHeaders,
+ setDebugInfo: (next) => {
+ _lastDebugInfo = typeof next === 'function' ? next(_lastDebugInfo) : next
+ },
})
-
- _lastDebugInfo.status = resp.status
- _lastDebugInfo.contentType = resp.headers.get('content-type') || ''
- _lastDebugInfo.responseTime = new Date().toLocaleString('zh-CN')
- _lastDebugInfo.latency = Date.now() - reqTime + 'ms'
-
- if (!resp.ok) {
- const errText = await resp.text().catch(() => '')
- _lastDebugInfo.errorBody = errText.slice(0, 500)
- let errMsg = `API 错误 ${resp.status}`
- try {
- const errJson = JSON.parse(errText)
- errMsg = errJson.error?.message || errJson.message || errMsg
- } catch {
- if (errText) errMsg += `: ${errText.slice(0, 200)}`
- }
- throw new Error(errMsg)
- }
-
- _lastDebugInfo.streaming = true
- let chunkCount = 0, contentChunks = 0, thinkingChunks = 0
- let thinkingBuf = ''
-
- await readSSEStream(resp, (json) => {
- chunkCount++
- if (json.type === 'content_block_delta') {
- const delta = json.delta
- if (delta?.type === 'text_delta' && delta.text) {
- contentChunks++
- onChunk(delta.text)
- } else if (delta?.type === 'thinking_delta' && delta.thinking) {
- thinkingChunks++
- thinkingBuf += delta.thinking
- }
- }
- }, _abortController?.signal)
-
- _lastDebugInfo.chunks = { total: chunkCount, content: contentChunks, thinking: thinkingChunks }
-
- if (contentChunks === 0 && thinkingBuf) {
- console.warn('[assistant] Anthropic: 无 text 块,使用 thinking 作为回复')
- onChunk(thinkingBuf)
- _lastDebugInfo.fallbackToThinking = true
- }
}
// ── Google Gemini API ──
-async function callGeminiGenerate(base, messages, onChunk) {
- const systemMsg = messages.find(m => m.role === 'system')?.content || ''
- const chatMessages = messages.filter(m => m.role !== 'system')
-
- // Gemini 格式转换
- 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 reqTime = Date.now()
- _lastDebugInfo = { url: url.replace(_config.apiKey, '***'), method: 'POST', requestTime: new Date(reqTime).toLocaleString('zh-CN') }
-
- const resp = await fetchWithRetry(url, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- signal: _abortController.signal,
+async function callGeminiGenerate(base, messages, onChunk, signal) {
+ return callAssistantGeminiGenerate({
+ base,
+ messages,
+ onChunk,
+ signal,
+ config: _config,
+ fetchWithRetry,
+ setDebugInfo: (next) => {
+ _lastDebugInfo = typeof next === 'function' ? next(_lastDebugInfo) : next
+ },
})
-
- _lastDebugInfo.status = resp.status
- _lastDebugInfo.latency = Date.now() - reqTime + 'ms'
-
- 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)
- }
-
- _lastDebugInfo.streaming = true
- let chunkCount = 0
-
- await readSSEStream(resp, (json) => {
- chunkCount++
- const text = json.candidates?.[0]?.content?.parts?.[0]?.text
- if (text) onChunk(text)
- }, _abortController?.signal)
-
- _lastDebugInfo.chunks = { total: chunkCount }
}
// ── 通用 SSE 流读取 ──
async function readSSEStream(resp, onEvent, signal) {
- const reader = resp.body.getReader()
- const decoder = new TextDecoder()
- let buffer = ''
-
- // 监听 abort 信号 → 取消 reader(关键:fetch abort 不会自动取消已建立的流)
- 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')
-
- // chunk 超时:如果 30 秒内没有收到任何数据,视为超时
- 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
-
- // 处理 SSE event: 行
- 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)
- }
+ return readAssistantSSEStream(resp, onEvent, signal, TIMEOUT_CHUNK)
}
// ── 工具执行 ──
@@ -1857,14 +1450,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':
@@ -1890,141 +1483,117 @@ async function executeTool(name, args) {
}
// ── ask_user 交互卡片 ──
-function showAskUserCard({ question, type, options, placeholder }) {
- const session = getCurrentSession()
- if (session) setSessionStatus(session.id, 'waiting')
+function showAskUserCard({ question, type, options, placeholder, sessionId = _currentSessionId }) {
+ const targetSessionId = sessionId || _currentSessionId
+ if (targetSessionId) setSessionStatus(targetSessionId, 'waiting')
return new Promise((resolve) => {
const cardId = 'ask-user-' + Date.now()
- const optionsHtml = (options || []).map((opt, i) => {
- 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 = `
-
${escHtml(question)}
- ${optionsHtml ? `
${optionsHtml}
` : ''}
- ${customHtml}
- ${textHtml}
-
-
-
-
- `
+ card.innerHTML = buildAssistantAskUserCardHtml({
+ cardId,
+ question,
+ type,
+ options,
+ placeholder,
+ escapeHtml: escHtml,
+ })
- // 插入到消息区域
_messagesEl.appendChild(card)
_messagesEl.scrollTop = _messagesEl.scrollHeight
- // 提交处理
card.querySelector('.ast-ask-submit').addEventListener('click', () => {
- let answer = ''
-
- if (type === 'text' || (!options?.length)) {
- answer = card.querySelector('.ast-ask-text')?.value?.trim() || ''
- } else 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)
- answer = checked.join('、') || '未选择'
- } else {
- // single
- const checked = card.querySelector('input[type="radio"]:checked')
- const custom = card.querySelector('.ast-ask-custom-input')?.value?.trim()
- answer = custom || checked?.value || '未选择'
- }
-
- // 替换卡片为已回答状态
- card.innerHTML = `
-
${escHtml(question)}
-
${icon('check', 14)} ${escHtml(answer)}
-
`
+ const answer = resolveAssistantAskUserAnswer(card, type, options)
+ card.innerHTML = buildAssistantAnsweredCardHtml({
+ question,
+ answer,
+ escapeHtml: escHtml,
+ iconHtml: icon('check', 14),
+ })
card.classList.add('answered')
- if (session) setSessionStatus(session.id, 'streaming')
+ if (targetSessionId && getStreaming(targetSessionId)) setSessionStatus(targetSessionId, 'streaming')
resolve(`用户回答: ${answer}`)
})
- // 跳过处理
card.querySelector('.ast-ask-skip').addEventListener('click', () => {
- card.innerHTML = `
-
${escHtml(question)}
-
— 已跳过
-
`
+ card.innerHTML = buildAssistantAnsweredCardHtml({
+ question,
+ answer: '',
+ skip: true,
+ escapeHtml: escHtml,
+ })
card.classList.add('answered')
- if (session) setSessionStatus(session.id, 'streaming')
+ if (targetSessionId && getStreaming(targetSessionId)) setSessionStatus(targetSessionId, 'streaming')
resolve('用户跳过了此问题')
})
})
}
// 危险工具确认弹窗
-async function confirmToolCall(tc, critical = false) {
- const name = tc.function.name
- let args
- try { args = JSON.parse(tc.function.arguments) } catch { args = {} }
-
- let desc = ''
- if (name === 'run_command') {
- desc = `执行命令:\n\n${args.command}${args.cwd ? '\n\n工作目录: ' + args.cwd : ''}`
- } else if (name === 'write_file') {
- const preview = (args.content || '').slice(0, 200)
- desc = `写入文件:\n${args.path}\n\n内容预览:\n${preview}${(args.content || '').length > 200 ? '\n...(已截断)' : ''}`
- }
-
- const prefix = critical
- ? '⛔ 安全围栏拦截 — 此命令被识别为极端危险操作!\n\n'
- : ''
-
- const session = getCurrentSession()
- if (session) setSessionStatus(session.id, 'waiting')
- const result = await showConfirm(`${prefix}AI 请求执行以下操作:\n\n${desc}\n\n是否允许?`)
- if (session) setSessionStatus(session.id, 'streaming')
+async function confirmToolCall(tc, critical = false, sessionId = _currentSessionId) {
+ if (sessionId) setSessionStatus(sessionId, 'waiting')
+ const result = await showConfirm(buildAssistantToolConfirmText(tc, critical))
+ if (sessionId && getStreaming(sessionId)) setSessionStatus(sessionId, 'streaming')
return result
}
// 将 OpenAI 格式工具定义转为 Anthropic 格式
function convertToolsForAnthropic(tools) {
- return tools.map(t => ({
- name: t.function.name,
- description: t.function.description || '',
- input_schema: t.function.parameters || { type: 'object', properties: {} },
- }))
+ return convertAssistantToolsForAnthropic(tools)
}
// 将 OpenAI 格式工具定义转为 Gemini 格式
function convertToolsForGemini(tools) {
- return [{ functionDeclarations: tools.map(t => ({
- name: t.function.name,
- description: t.function.description || '',
- parameters: t.function.parameters || { type: 'object', properties: {} },
- }))}]
+ return convertAssistantToolsForGemini(tools)
+}
+
+// 上下文裁剪:保留图片消息,避免多模态丢失
+function trimContextPreserveImages(messages, maxTokens) {
+ const trimmed = trimContextCore(messages, maxTokens)
+ 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]
+ pinnedList.forEach(msg => {
+ if (trimmedSet.has(msg)) return
+ merged.push(msg)
+ })
+ if (merged.length <= maxTokens) return merged
+ const compact = []
+ for (let i = merged.length - 1; i >= 0; i--) {
+ const msg = merged[i]
+ if (compact.length >= maxTokens && !pinnedSet.has(msg)) continue
+ compact.unshift(msg)
+ }
+ 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) {
+async function executeToolWithSafety(toolName, args, tcForConfirm, sessionId = _currentSessionId) {
let result = '', approved = true
const mode = MODES[currentMode()]
- const isCritical = toolName === 'run_command' && isCriticalCommand(args.command)
- if (isCritical) {
- approved = await confirmToolCall(tcForConfirm || { function: { name: toolName, arguments: JSON.stringify(args) } }, true)
- if (!approved) result = '用户拒绝了此危险操作'
- } else if (mode.confirmDanger && DANGEROUS_TOOLS.has(toolName)) {
- approved = await confirmToolCall(tcForConfirm || { function: { name: toolName, arguments: JSON.stringify(args) } })
- if (!approved) result = '用户拒绝了此操作'
+ const approval = resolveAssistantToolApproval(toolName, args, mode)
+ if (approval.needsConfirm) {
+ approved = await confirmToolCall(tcForConfirm || { function: { name: toolName, arguments: JSON.stringify(args) } }, approval.critical, sessionId)
+ if (!approved) result = approval.deniedText
}
if (approved) {
try { result = await executeTool(toolName, args) }
@@ -2034,215 +1603,40 @@ async function executeToolWithSafety(toolName, args, tcForConfirm) {
}
// 带工具调用的 AI 请求(非流式,用于 tool_calls 检测循环)
-async function callAIWithTools(messages, onStatus, onToolProgress) {
- const apiType = normalizeApiType(_config.apiType)
- if (!_config.baseUrl || !_config.model || (requiresApiKey(apiType) && !_config.apiKey)) {
- throw new Error('请先配置 AI 模型(点击右上角设置按钮)')
- }
-
- const base = cleanBaseUrl(_config.baseUrl, apiType)
- const tools = getEnabledTools()
- let currentMessages = [{ role: 'system', content: buildSystemPrompt() }, ...messages]
+async function callAIWithTools(sessionId, messages, onStatus, onToolProgress, signal) {
const toolHistory = []
-
- const autoRounds = _config.autoRounds ?? 8 // 0 = 无限制
- let nextPauseAt = autoRounds // 下一次暂停的轮次阈值
- for (let round = 0; ; round++) {
- // 检查是否已被用户中止
- if (!_isStreaming || _abortController?.signal?.aborted) {
- throw new DOMException('Aborted', 'AbortError')
- }
- if (autoRounds > 0 && round >= nextPauseAt) {
- const answer = await showAskUserCard({
- question: `AI 已连续调用工具 ${round} 轮,可能陷入循环。你希望怎么做?`,
- type: 'single',
- options: [`继续执行 ${autoRounds} 轮`, '不再中断,一直执行', '让 AI 换个思路', '停止并总结'],
- })
- if (answer.includes('停止')) {
- return { content: '用户要求停止工具调用,以下是目前的执行情况摘要。', toolHistory }
- } else if (answer.includes('换个思路')) {
- currentMessages.push({ role: 'user', content: '请换一种方法来解决这个问题,不要重复之前失败的操作。' })
- nextPauseAt = round + autoRounds
- } else if (answer.includes('不再中断')) {
- nextPauseAt = Infinity
- } else {
- nextPauseAt = round + autoRounds
- }
- }
-
- _abortController = new AbortController()
- onStatus(round === 0 ? 'AI 思考中...' : `AI 处理工具结果 (第${round + 1}轮)...`)
-
- // ── Anthropic 工具调用 ──
- if (apiType === 'anthropic-messages') {
- const systemMsg = currentMessages.find(m => m.role === 'system')?.content || ''
- const chatMsgs = currentMessages.filter(m => m.role !== 'system')
- const body = {
- model: _config.model,
- max_tokens: 8192,
- temperature: _config.temperature || 0.7,
- messages: chatMsgs,
- }
- if (systemMsg) body.system = systemMsg
- if (tools.length > 0) body.tools = convertToolsForAnthropic(tools)
-
- const resp = await fetchWithRetry(base + '/messages', {
- method: 'POST', headers: authHeaders(), body: JSON.stringify(body),
- signal: _abortController.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) {
- // 将 assistant 消息加入上下文
- currentMessages.push({ role: 'assistant', content: contentBlocks })
-
- const toolResults = []
- for (const tu of toolUses) {
- const args = tu.input || {}
- toolHistory.push({ name: tu.name, args, result: null, approved: true, pending: true })
- onToolProgress(toolHistory)
-
- const { result, approved } = await executeToolWithSafety(tu.name, args)
- const last = toolHistory[toolHistory.length - 1]
- last.result = result; last.approved = approved; last.pending = false
- onToolProgress(toolHistory)
-
- 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
- }
-
- return { content: textContent, toolHistory }
- }
-
- // ── Gemini 工具调用 ──
- 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: _config.temperature || 0.7 } }
- if (systemMsg) body.systemInstruction = { parts: [{ text: systemMsg }] }
- if (tools.length > 0) body.tools = convertToolsForGemini(tools)
-
- 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,
- })
- 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 || {}
- toolHistory.push({ name: fc.functionCall.name, args, result: null, approved: true, pending: true })
- onToolProgress(toolHistory)
-
- const { result, approved } = await executeToolWithSafety(fc.functionCall.name, args)
- const last = toolHistory[toolHistory.length - 1]
- last.result = result; last.approved = approved; last.pending = false
- onToolProgress(toolHistory)
-
- 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
- }
-
- return { content: textParts, toolHistory }
- }
-
- // ── OpenAI 工具调用 ──
- const body = {
- model: _config.model,
- messages: currentMessages,
- temperature: _config.temperature || 0.7,
- }
- if (tools.length > 0) body.tools = tools
-
- const resp = await fetchWithRetry(base + '/chat/completions', {
- method: 'POST',
- headers: authHeaders(),
- body: JSON.stringify(body),
- signal: _abortController.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
-
- toolHistory.push({ name: toolName, args, result: null, approved: true, pending: true })
- onToolProgress(toolHistory)
-
- const { result, approved } = await executeToolWithSafety(toolName, args, tc)
- const last = toolHistory[toolHistory.length - 1]
- last.result = result; last.approved = approved; last.pending = false
- onToolProgress(toolHistory)
-
- 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 || ''
- return { content, toolHistory }
+ const adapters = {
+ soulCache: _soulCache,
+ knowledgeBase: OPENCLAW_KB,
+ confirm: async (text) => withAssistantWaitingStatus(sessionId, {
+ setSessionStatus,
+ getStreaming,
+ }, () => showConfirm(text)),
+ askUser: async (args) => withAssistantWaitingStatus(sessionId, {
+ setSessionStatus,
+ getStreaming,
+ }, () => showAskUserCard({ ...(args || {}), sessionId })),
+ execTool: async ({ name, args }) => {
+ const entry = createAssistantToolHistoryEntry(name, args)
+ toolHistory.push(entry)
+ if (typeof onToolProgress === 'function') onToolProgress(toolHistory)
+ const execResult = await executeToolWithSafety(name, args, { function: { name, arguments: JSON.stringify(args || {}) } }, sessionId)
+ finalizeAssistantToolHistoryEntry(entry, execResult)
+ if (typeof onToolProgress === 'function') onToolProgress(toolHistory)
+ return execResult.result
+ },
}
+ if (typeof onStatus === 'function') onStatus('AI 思考中...')
+ const tools = getEnabledTools()
+ const result = await callAIWithToolsCore({
+ config: _config,
+ messages,
+ tools,
+ adapters,
+ mode: currentMode(),
+ signal,
+ })
+ return { content: result.text || '', toolHistory }
}
// ── 渲染 ──
@@ -2265,14 +1659,40 @@ function renderSessionList() {
}
function renderToolBlocks(toolHistory) {
- if (!toolHistory || toolHistory.length === 0) return ''
- return toolHistory.map(tc => {
- // ask_user 工具不显示在工具块中(它有自己的交互卡片)
- if (tc.name === 'ask_user') return ''
-
- const tcIcon = { run_command: icon('terminal', 14), write_file: icon('edit', 14), read_file: icon('file', 14), list_directory: icon('folder', 14), get_system_info: icon('monitor', 14), list_processes: icon('list', 14), check_port: icon('plug', 14), skills_list: icon('box', 14), skills_info: icon('box', 14), skills_check: icon('box', 14), skills_install_dep: icon('download', 14), skills_clawhub_search: icon('search', 14), skills_clawhub_install: icon('download', 14) }[tc.name] || icon('wrench', 14)
- const label = { run_command: '执行命令', read_file: '读取文件', write_file: '写入文件', list_directory: '列出目录', get_system_info: '系统信息', list_processes: '进程列表', check_port: '端口检测', skills_list: 'Skills 列表', skills_info: 'Skill 详情', skills_check: 'Skills 检查', skills_install_dep: '安装依赖', skills_clawhub_search: '搜索 ClawHub', skills_clawhub_install: '安装 Skill' }[tc.name] || tc.name
- const argsStr = tc.name === 'run_command' ? escHtml(tc.args.command || '')
+ return renderAssistantToolBlocksHtml(toolHistory, {
+ escapeHtml: escHtml,
+ defaultToolIcon: icon('wrench', 14),
+ toolIcons: {
+ run_command: icon('terminal', 14),
+ write_file: icon('edit', 14),
+ read_file: icon('file', 14),
+ list_directory: icon('folder', 14),
+ get_system_info: icon('monitor', 14),
+ list_processes: icon('list', 14),
+ check_port: icon('plug', 14),
+ skills_list: icon('box', 14),
+ skills_info: icon('box', 14),
+ skills_check: icon('box', 14),
+ skills_install_dep: icon('download', 14),
+ skills_clawhub_search: icon('search', 14),
+ skills_clawhub_install: icon('download', 14),
+ },
+ toolLabels: {
+ run_command: '执行命令',
+ read_file: '读取文件',
+ write_file: '写入文件',
+ list_directory: '列出目录',
+ get_system_info: '系统信息',
+ list_processes: '进程列表',
+ check_port: '端口检测',
+ skills_list: 'Skills 列表',
+ skills_info: 'Skill 详情',
+ skills_check: 'Skills 检查',
+ skills_install_dep: '安装依赖',
+ skills_clawhub_search: '搜索 ClawHub',
+ skills_clawhub_install: '安装 Skill',
+ },
+ getArgsPreview: (tc) => tc.name === 'run_command' ? escHtml(tc.args.command || '')
: tc.name === 'read_file' ? escHtml(tc.args.path || '')
: tc.name === 'write_file' ? escHtml(tc.args.path || '')
: tc.name === 'list_directory' ? escHtml(tc.args.path || '')
@@ -2284,22 +1704,8 @@ function renderToolBlocks(toolHistory) {
: tc.name === 'skills_clawhub_search' ? escHtml(tc.args.query || '')
: tc.name === 'skills_clawhub_install' ? escHtml(tc.args.slug || '')
: ['skills_list', 'skills_check'].includes(tc.name) ? ''
- : escHtml(JSON.stringify(tc.args))
-
- if (tc.pending) {
- return `
`
- }
-
- const statusClass = tc.approved === false ? 'denied' : 'ok'
- const statusLabel = tc.approved === false ? '已拒绝' : '已执行'
- const resultPreview = (tc.result || '').length > 500 ? tc.result.slice(0, 500) + '...' : (tc.result || '')
- return `
- ${tcIcon} ${label} ${argsStr} ${statusLabel}
- ${escHtml(resultPreview)}
- `
- }).join('')
+ : escHtml(JSON.stringify(tc.args)),
+ })
}
// ── 错误上下文 Banner ──
@@ -2504,208 +1910,21 @@ function showSettings() {
const c = _config
const overlay = document.createElement('div')
overlay.className = 'modal-overlay'
- overlay.innerHTML = `
-
-
${c.assistantName || DEFAULT_NAME} — 设置
-
-
-
-
-
-
-
-
-
-
-
-
- `
+ overlay.innerHTML = renderAssistantSettingsModal({
+ config: c,
+ assistantName: c.assistantName,
+ apiTypes: API_TYPES,
+ providerPresets: PROVIDER_PRESETS,
+ qtcool: QTCOOL,
+ defaultName: DEFAULT_NAME,
+ defaultPersonality: DEFAULT_PERSONALITY,
+ normalizeApiType,
+ apiBasePlaceholder,
+ apiKeyPlaceholder,
+ apiHintText,
+ escHtml,
+ icon,
+ })
document.body.appendChild(overlay)
// Tab 切换
@@ -2723,39 +1942,66 @@ function showSettings() {
const apiHintEl = overlay.querySelector('#ast-api-hint')
const baseUrlInput = overlay.querySelector('#ast-baseurl')
const apiKeyInput = overlay.querySelector('#ast-apikey')
- overlay.querySelectorAll('.ast-preset-btn').forEach(btn => {
- btn.onclick = () => {
- baseUrlInput.value = btn.dataset.url
- apiTypeSelect.value = btn.dataset.api
- apiTypeSelect.dispatchEvent(new Event('change'))
- // 切换服务商时清空模型和下拉列表,让用户重新选择或拉取
- const modelInput = overlay.querySelector('#ast-model')
- const modelDropdown = overlay.querySelector('#ast-model-dropdown')
- if (modelInput) modelInput.value = ''
- if (modelDropdown) { modelDropdown.innerHTML = ''; modelDropdown.style.display = 'none' }
- // 高亮选中
- overlay.querySelectorAll('.ast-preset-btn').forEach(b => b.style.opacity = '0.5')
- btn.style.opacity = '1'
- // 显示服务商详情
- const preset = PROVIDER_PRESETS.find(p => p.key === btn.dataset.key)
- const detailEl = overlay.querySelector('#ast-preset-detail')
- if (detailEl && preset && (preset.desc || preset.site)) {
- let html = preset.desc ? `
${preset.desc}
` : ''
- if (preset.site) html += `
→ 访问 ${preset.label}官网`
- detailEl.innerHTML = html
- detailEl.style.display = 'block'
- } else if (detailEl) {
- detailEl.style.display = 'none'
- }
+ const importBtn = overlay.querySelector('#ast-import-openclaw')
+ const presetSelect = overlay.querySelector('#ast-provider-presets')
+ presetSelect?.addEventListener('change', () => {
+ const preset = PROVIDER_PRESETS.find(p => p.key === presetSelect.value)
+ const detailEl = overlay.querySelector('#ast-preset-detail')
+ if (!preset) {
+ if (detailEl) detailEl.style.display = 'none'
+ return
+ }
+ baseUrlInput.value = preset.baseUrl || ''
+ apiTypeSelect.value = preset.api || apiTypeSelect.value
+ apiTypeSelect.dispatchEvent(new Event('change'))
+ const modelInput = overlay.querySelector('#ast-model')
+ const modelDropdown = overlay.querySelector('#ast-model-dropdown')
+ if (modelInput) modelInput.value = ''
+ if (modelDropdown) { modelDropdown.innerHTML = ''; modelDropdown.style.display = 'none' }
+ if (detailEl && (preset.desc || preset.site)) {
+ let html = preset.desc ? `
${preset.desc}
` : ''
+ if (preset.site) html += `
→ 访问 ${preset.label}官网`
+ detailEl.innerHTML = html
+ detailEl.style.display = 'block'
+ } else if (detailEl) {
+ detailEl.style.display = 'none'
}
})
+ if (importBtn) {
+ importBtn.addEventListener('click', async () => {
+ try {
+ importBtn.disabled = true
+ const result = await api.importOpenclawAiConfig()
+ if (!result || !result.model) {
+ toast('未找到可导入的 AI 配置', 'warning')
+ return
+ }
+ 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 = normalizeAssistantApiType(result.apiType)
+ 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)
- apiHintEl.textContent = apiHintText(v)
- baseUrlInput.placeholder = apiBasePlaceholder(v)
- apiKeyInput.placeholder = apiKeyPlaceholder(v)
+ const v = normalizeAssistantApiType(apiTypeSelect.value)
+ apiHintEl.textContent = getAssistantApiHintText(v)
+ baseUrlInput.placeholder = getAssistantApiBasePlaceholder(v)
+ apiKeyInput.placeholder = getAssistantApiKeyPlaceholder(v)
})
// 灵魂来源切换
@@ -2840,35 +2086,7 @@ function showSettings() {
let kbFiles = JSON.parse(JSON.stringify(_config.knowledgeFiles || []))
const renderKBList = () => {
- if (kbFiles.length === 0) {
- kbListEl.innerHTML = `
`
- kbHintEl.textContent = ''
- return
- }
- const totalSize = kbFiles.reduce((s, f) => s + (f.content?.length || 0), 0)
- const sizeStr = totalSize > 1024 ? (totalSize / 1024).toFixed(1) + ' KB' : totalSize + ' B'
- const enabledCount = kbFiles.filter(f => f.enabled !== false).length
- kbHintEl.textContent = `共 ${kbFiles.length} 个知识文件(${enabledCount} 个启用,${sizeStr}),保存后生效。`
- let html = '
'
- kbFiles.forEach((f, i) => {
- const fSize = f.content?.length > 1024 ? (f.content.length / 1024).toFixed(1) + ' KB' : (f.content?.length || 0) + ' B'
- const enabled = f.enabled !== false
- html += `
-
-
- ${escHtml(f.name)}
- ${f.content?.split('\n').length || 0} 行 · 点击编辑
-
-
${fSize}
-
-
`
- })
- html += '
'
- kbListEl.innerHTML = html
+ renderAssistantKnowledgeList({ kbFiles, kbListEl, kbHintEl, escHtml })
}
renderKBList()
@@ -2952,7 +2170,7 @@ function showSettings() {
}
qtcoolKeyInput.oninput = () => {
const key = qtcoolKeyInput.value.trim()
- qtcoolUsageLink.href = QTCOOL.usageUrl + (key || QTCOOL.defaultKey)
+ if (qtcoolUsageLink) qtcoolUsageLink.href = QTCOOL.usageUrl + (key || QTCOOL.defaultKey)
}
const qtcoolStatus = overlay.querySelector('#ast-qtcool-status')
@@ -3062,9 +2280,9 @@ function showSettings() {
const baseUrl = overlay.querySelector('#ast-baseurl').value.trim()
const apiKey = overlay.querySelector('#ast-apikey').value.trim()
const model = overlay.querySelector('#ast-model').value.trim()
- const selApiType = normalizeApiType(overlay.querySelector('#ast-apitype').value || 'openai-completions')
- if (!baseUrl || (requiresApiKey(selApiType) && !apiKey)) {
- resultEl.innerHTML = '
' + escHtml(requiresApiKey(selApiType) ? '请先填写 Base URL 和 API Key' : '请先填写 Base URL') + ''
+ const selApiType = normalizeAssistantApiType(overlay.querySelector('#ast-apitype').value || 'openai-completions')
+ if (!baseUrl || (requiresAssistantApiKey(selApiType) && !apiKey)) {
+ resultEl.innerHTML = '
' + escHtml(requiresAssistantApiKey(selApiType) ? '请先填写 Base URL 和 API Key' : '请先填写 Base URL') + ''
return
}
if (!model) {
@@ -3153,9 +2371,9 @@ function showSettings() {
const btn = e.target
const baseUrl = overlay.querySelector('#ast-baseurl').value.trim()
const apiKey = overlay.querySelector('#ast-apikey').value.trim()
- const selApiType = normalizeApiType(overlay.querySelector('#ast-apitype').value || 'openai-completions')
- if (!baseUrl || (requiresApiKey(selApiType) && !apiKey)) {
- resultEl.innerHTML = '
' + escHtml(requiresApiKey(selApiType) ? '请先填写 Base URL 和 API Key' : '请先填写 Base URL') + ''
+ const selApiType = normalizeAssistantApiType(overlay.querySelector('#ast-apitype').value || 'openai-completions')
+ if (!baseUrl || (requiresAssistantApiKey(selApiType) && !apiKey)) {
+ resultEl.innerHTML = '
' + escHtml(requiresAssistantApiKey(selApiType) ? '请先填写 Base URL 和 API Key' : '请先填写 Base URL') + ''
return
}
btn.disabled = true
@@ -3250,7 +2468,7 @@ function showSettings() {
name: pid,
baseUrl: p.baseUrl,
apiKey: p.apiKey || '',
- apiType: normalizeApiType(p.api),
+ apiType: normalizeAssistantApiType(p.api),
models: (p.models || []).map(m => m.id || m.name).filter(Boolean),
})
}
@@ -3270,7 +2488,7 @@ function showSettings() {
name: pid,
baseUrl: p.baseUrl,
apiKey: p.apiKey || '',
- apiType: normalizeApiType(p.api),
+ apiType: normalizeAssistantApiType(p.api),
models: (p.models || []).map(m => m.id || m.name).filter(Boolean),
})
}
@@ -3356,7 +2574,7 @@ function showSettings() {
_config.apiKey = overlay.querySelector('#ast-apikey').value.trim()
_config.model = overlay.querySelector('#ast-model').value.trim()
_config.temperature = parseFloat(overlay.querySelector('#ast-temp').value) || 0.7
- _config.apiType = normalizeApiType(overlay.querySelector('#ast-apitype').value || 'openai-completions')
+ _config.apiType = normalizeAssistantApiType(overlay.querySelector('#ast-apitype').value || 'openai-completions')
// 工具开关
_config.tools.terminal = overlay.querySelector('#ast-tool-terminal').checked
_config.tools.fileOps = overlay.querySelector('#ast-tool-fileops').checked
@@ -3376,20 +2594,7 @@ function showSettings() {
saveConfig()
overlay.remove()
// 更新 Header 标题和欢迎页
- const titleEl = _page.querySelector('.ast-title')
- if (titleEl) {
- // 灵魂移植模式下,尝试从 IDENTITY.md 提取名称
- let displayName = _config.assistantName
- if (_config.soulSource?.startsWith('openclaw:') && _soulCache?.identity) {
- const nameMatch = _soulCache.identity.match(/\*\*Name:\*\*\s*(.+)/i) || _soulCache.identity.match(/名[字称][::]\s*(.+)/i)
- const extracted = nameMatch?.[1]?.trim()
- // 跳过占位符文本(模板未填写时的默认值)
- if (extracted && !extracted.startsWith('_') && !extracted.startsWith('(') && extracted.length < 30) {
- displayName = extracted
- }
- }
- titleEl.textContent = displayName
- }
+ updateAssistantTitleFromSettings({ page: _page, config: _config, soulCache: _soulCache })
renderMessages()
toast('设置已保存', 'info')
updateModelBadge()
@@ -3419,7 +2624,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
@@ -3427,71 +2632,67 @@ function sendMessage(text) {
enqueueMessage(text.trim())
return
}
+ clearOptimizeSnapshot()
sendMessageDirect(text)
}
// 直接发送(内部使用,不经过队列)
-async function sendMessageDirect(text) {
- const hasContent = text.trim() || _pendingImages.length > 0
+async function sendMessageDirect(text, { sessionId = _currentSessionId } = {}) {
+ const targetSessionId = sessionId || _currentSessionId
+ const hasContent = text.trim() || (targetSessionId === _currentSessionId && _pendingImages.length > 0)
if (!hasContent) return
- if (_isStreaming) {
- if (_pendingImages.length > 0) { toast('请等待 AI 回复完成', 'info'); return }
- enqueueMessage(text.trim())
+ if (targetSessionId && getStreaming(targetSessionId)) {
+ if (targetSessionId === _currentSessionId && _pendingImages.length > 0) { toast('请等待 AI 回复完成', 'info'); return }
+ if (targetSessionId === _currentSessionId) enqueueMessage(text.trim())
return
}
- let session = getCurrentSession()
+ clearOptimizeSnapshot()
+
+ let session = targetSessionId
+ ? _sessions.find(s => s.id === targetSessionId) || null
+ : getCurrentSession()
if (!session) {
session = createSession()
renderSessionList()
}
- // 收集当前附件图片
- const images = [..._pendingImages]
- clearPendingImages()
+ // 收集当前附件图片(仅当前可见会话允许携带待发送图片)
+ const images = session.id === _currentSessionId ? [..._pendingImages] : []
+ if (session.id === _currentSessionId) clearPendingImages()
// 添加用户消息(多模态或纯文本)
- const textContent = text.trim()
- const msgContent = buildMessageContent(textContent, images)
- const userMsg = { role: 'user', content: msgContent, ts: Date.now() }
- if (images.length > 0) {
- // 为每张图片生成稳定 ID 并存入文件系统
- userMsg._images = images.map(i => {
- const dbId = 'img_' + i.id
- saveImageToFile(dbId, i.dataUrl) // 异步存储,不阻塞
- return { dbId, dataUrl: i.dataUrl, name: i.name, width: i.width, height: i.height }
- })
- }
- if (textContent) userMsg._text = textContent
+ const userMsg = createAssistantUserMessage({
+ text,
+ images,
+ buildMessageContent,
+ persistImage: saveImageToFile,
+ })
session.messages.push(userMsg)
autoTitle(session)
session.updatedAt = Date.now()
saveSessions()
- renderMessages()
+ if (_currentSessionId === session.id) renderMessages()
renderSessionList()
// 准备 AI 上下文(只保留 role + content,剔除内部字段)
// 过滤掉空的 AI 回复,避免污染上下文导致模型也返回空
- const contextMessages = session.messages
- .filter(m => {
- if (m.role === 'user') return true
- if (m.role === 'assistant') return m.content && m.content.length > 0
- return false
- })
- .slice(-MAX_CONTEXT_TOKENS)
- .map(m => ({ role: m.role, content: m.content }))
+ const contextMessages = buildContextMessages(session)
// 添加空 AI 消息占位
- const aiMsg = { role: 'assistant', content: '', ts: Date.now() }
+ const aiMsg = createAssistantAiPlaceholder()
session.messages.push(aiMsg)
- _isStreaming = true
- _sendBtn.innerHTML = stopIcon()
- setSessionStatus(session.id, 'streaming')
+ const { requestId, requestController } = createAssistantRequestContext(session.id, nextRequestId, patchRequestState)
+ if (_currentSessionId === session.id) {
+ _sendBtn.innerHTML = stopIcon()
+ startStreamRefresh(session.id)
+ }
+ renderSessionList()
// 渲染流式 typing 状态
- renderMessages()
- const aiBubbles = _messagesEl?.querySelectorAll('.ast-msg-bubble-ai')
+ if (_currentSessionId === session.id) renderMessages()
+ const aiBubbles = _currentSessionId === session.id ? _messagesEl?.querySelectorAll('.ast-msg-bubble-ai') : null
const lastBubble = aiBubbles?.[aiBubbles.length - 1]
if (lastBubble) lastBubble.innerHTML = '
思考中...'
@@ -3503,13 +2704,15 @@ 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 (!isActiveRequest(session.id, requestId)) return
if (lastBubble) lastBubble.innerHTML = `
${escHtml(status)}`
},
// onToolProgress
(history) => {
+ if (!isActiveRequest(session.id, requestId)) return
aiMsg.toolHistory = history
throttledSave() // 实时保存工具调用进度
if (!lastContainer) return
@@ -3517,17 +2720,20 @@ async function sendMessageDirect(text) {
const bubble = lastContainer.querySelector('.ast-msg-bubble-ai')
lastContainer.innerHTML = toolHtml + (bubble ? bubble.outerHTML : '')
if (_messagesEl) _messagesEl.scrollTop = _messagesEl.scrollHeight
- }
+ },
+ requestController.signal
)
+ if (!isActiveRequest(session.id, requestId)) return
aiMsg.content = result.content
if (result.toolHistory.length > 0) {
aiMsg.toolHistory = result.toolHistory
}
- renderMessages()
+ if (_currentSessionId === session.id) renderMessages()
} else {
// ── 普通流式模式 ──
- await callAI(contextMessages, (chunk) => {
+ await callAI(session.id, contextMessages, (chunk) => {
+ if (!isActiveRequest(session.id, requestId)) return
aiMsg.content += chunk
throttledSave() // 实时保存每个 chunk
if (lastBubble) {
@@ -3538,8 +2744,9 @@ async function sendMessageDirect(text) {
_lastRenderTime = now
}
}
- })
+ }, requestController.signal)
+ if (!isActiveRequest(session.id, requestId)) return
if (lastBubble) {
lastBubble.innerHTML = renderMarkdown(aiMsg.content)
}
@@ -3550,6 +2757,7 @@ async function sendMessageDirect(text) {
_lastDebugInfo = null
}
} catch (err) {
+ if (!isActiveRequest(session.id, requestId) && err?.name !== 'AbortError') return
if (err.name === 'AbortError') {
aiMsg.content += aiMsg.content ? '\n\n*[已停止]*' : '*[已停止]*'
} else {
@@ -3561,19 +2769,13 @@ async function sendMessageDirect(text) {
aiMsg.content += errInfo
aiMsg._canRetry = true
}
- renderMessages()
+ if (_currentSessionId === session.id) renderMessages()
// 错误后插入重试按钮
- if (aiMsg._canRetry && _messagesEl) {
+ if (aiMsg._canRetry && _messagesEl && _currentSessionId === session.id) {
const retryBar = document.createElement('div')
retryBar.className = 'ast-retry-bar'
- const retrySvg = '
'
- const continueSvg = '
'
- retryBar.innerHTML = `
-
-
-
请求失败(已自动重试 3 次)
- `
+ retryBar.innerHTML = buildAssistantRetryBarHtml()
_messagesEl.appendChild(retryBar)
_messagesEl.scrollTop = _messagesEl.scrollHeight
@@ -3592,83 +2794,73 @@ async function sendMessageDirect(text) {
})
}
} finally {
- _isStreaming = false
- _abortController = null
- stopStreamRefresh()
- if (_sendBtn) _sendBtn.innerHTML = sendIcon()
- if (_textarea) _textarea.focus()
+ clearRequestState(session.id, {
+ keepStatus: getSessionStatus(session.id) === 'error',
+ requestId,
+ })
+ if (_currentSessionId === session.id && _textarea) _textarea.focus()
session.updatedAt = Date.now()
flushSave()
- if (getSessionStatus(session.id) !== 'error') {
- setSessionStatus(session.id, 'idle')
- }
// 最终渲染(可能从后台回来,DOM 已重建)
- if (_messagesEl) {
+ if (isActiveRequest(session.id, requestId) && _messagesEl && _currentSessionId === session.id) {
renderMessages()
_messagesEl.scrollTop = _messagesEl.scrollHeight
}
- setTimeout(() => processQueue(), 100)
+ setTimeout(() => processQueue(session.id), 100)
}
}
// 重试 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')
- .slice(-MAX_CONTEXT_TOKENS)
+ const contextMessages = buildContextMessages(session)
- const aiMsg = { role: 'assistant', content: '', ts: Date.now() }
+ const aiMsg = createAssistantAiPlaceholder()
session.messages.push(aiMsg)
- _isStreaming = true
- if (_sendBtn) _sendBtn.innerHTML = stopIcon()
- setSessionStatus(session.id, 'streaming')
-
- renderMessages()
- const aiBubbles = _messagesEl?.querySelectorAll('.ast-msg-bubble-ai')
- const lastBubble = aiBubbles?.[aiBubbles.length - 1]
- if (lastBubble) lastBubble.innerHTML = '
重试中...'
-
- const toolsEnabled = getEnabledTools().length > 0
+ const { requestId, requestController } = createAssistantRequestContext(session.id, nextRequestId, patchRequestState)
+ const { lastBubble, toolsEnabled } = prepareAssistantRunContext({
+ session,
+ currentSessionId: _currentSessionId,
+ messagesEl: _messagesEl,
+ renderMessages,
+ renderSessionList,
+ sendBtn: _sendBtn,
+ stopIcon,
+ startStreamRefresh,
+ getEnabledTools,
+ typingText: '重试中...',
+ })
try {
- if (toolsEnabled) {
- const aiMsgContainers = _messagesEl?.querySelectorAll('.ast-msg-ai')
- const lastContainer = aiMsgContainers?.[aiMsgContainers.length - 1]
-
- const result = await callAIWithTools(contextMessages,
- (status) => { if (lastBubble) lastBubble.innerHTML = `
${escHtml(status)}` },
- (history) => {
- aiMsg.toolHistory = history
- throttledSave()
- if (!lastContainer) return
- const toolHtml = renderToolBlocks(history)
- const bubble = lastContainer.querySelector('.ast-msg-bubble-ai')
- lastContainer.innerHTML = toolHtml + (bubble ? bubble.outerHTML : '')
- if (_messagesEl) _messagesEl.scrollTop = _messagesEl.scrollHeight
- }
- )
- aiMsg.content = result.content
- if (result.toolHistory.length > 0) aiMsg.toolHistory = result.toolHistory
- renderMessages()
- } else {
- await callAI(contextMessages, (chunk) => {
- aiMsg.content += chunk
- throttledSave()
- if (lastBubble) {
- const now = Date.now()
- if (now - _lastRenderTime > 50) {
- lastBubble.innerHTML = renderMarkdown(aiMsg.content) + '
▊'
- if (_messagesEl) _messagesEl.scrollTop = _messagesEl.scrollHeight
- _lastRenderTime = now
- }
- }
- })
- if (lastBubble) lastBubble.innerHTML = renderMarkdown(aiMsg.content)
- }
+ await runAssistantResponse({
+ toolsEnabled,
+ session,
+ contextMessages,
+ requestId,
+ requestController,
+ aiMsg,
+ lastBubble,
+ messagesEl: _messagesEl,
+ currentSessionId: _currentSessionId,
+ callAIWithTools,
+ callAI,
+ isActiveRequest,
+ renderMessages,
+ renderToolBlocks,
+ renderMarkdown,
+ escHtml,
+ throttledSave,
+ updateAssistantToolProgress,
+ appendAssistantStreamChunk,
+ finalizeAssistantStreamBubble,
+ lastRenderTime: _lastRenderTime,
+ onLastRenderTime: (value) => { _lastRenderTime = value },
+ })
} catch (err) {
+ if (!isActiveRequest(session.id, requestId) && err?.name !== 'AbortError') return
if (err.name === 'AbortError') {
aiMsg.content += aiMsg.content ? '\n\n*[已停止]*' : '*[已停止]*'
} else {
@@ -3678,60 +2870,49 @@ async function retryAIResponse(session) {
: err.message
aiMsg._canRetry = true
}
- renderMessages()
-
- if (aiMsg._canRetry && _messagesEl) {
- const retryBar = document.createElement('div')
- retryBar.className = 'ast-retry-bar'
- const retrySvg = '
'
- const continueSvg = '
'
- retryBar.innerHTML = `
-
-
-
请求失败(已自动重试 3 次)
- `
- _messagesEl.appendChild(retryBar)
- _messagesEl.scrollTop = _messagesEl.scrollHeight
-
- retryBar.querySelector('.ast-btn-retry').addEventListener('click', () => {
- retryBar.remove()
- session.messages.pop()
- saveSessions()
- setSessionStatus(session.id, 'idle')
- retryAIResponse(session)
- })
- retryBar.querySelector('.ast-btn-continue').addEventListener('click', () => {
- retryBar.remove()
- setSessionStatus(session.id, 'idle')
- renderSessionList()
- _textarea?.focus()
+ if (_currentSessionId === session.id) renderMessages()
+
+ if (aiMsg._canRetry && _messagesEl && _currentSessionId === session.id) {
+ mountAssistantRetryBar({
+ messagesEl: _messagesEl,
+ buildRetryBarHtml: buildAssistantRetryBarHtml,
+ onRetry: (retryBar) => {
+ retryBar.remove()
+ session.messages.pop()
+ saveSessions()
+ setSessionStatus(session.id, 'idle')
+ retryAIResponse(session)
+ },
+ onContinue: (retryBar) => {
+ retryBar.remove()
+ setSessionStatus(session.id, 'idle')
+ renderSessionList()
+ _textarea?.focus()
+ },
})
}
} finally {
- _isStreaming = false
- _abortController = null
- stopStreamRefresh()
- if (_sendBtn) _sendBtn.innerHTML = sendIcon()
- if (_textarea) _textarea.focus()
- session.updatedAt = Date.now()
- flushSave()
- if (getSessionStatus(session.id) !== 'error') {
- setSessionStatus(session.id, 'idle')
- }
- if (_messagesEl) {
- renderMessages()
- _messagesEl.scrollTop = _messagesEl.scrollHeight
- }
- setTimeout(() => processQueue(), 100)
+ finalizeAssistantRequestLifecycle({
+ session,
+ requestId,
+ clearRequestState,
+ getSessionStatus,
+ currentSessionId: _currentSessionId,
+ messagesEl: _messagesEl,
+ renderMessages,
+ flushSave,
+ processQueue,
+ focusTextarea: () => _textarea?.focus(),
+ isActiveRequest,
+ })
}
}
-function stopStreaming() {
- _isStreaming = false
- if (_abortController) {
- _abortController.abort()
- _abortController = null
- }
+function stopStreaming(sessionId = _currentSessionId) {
+ if (!sessionId) return
+ const controller = getAbortController(sessionId)
+ if (controller) controller.abort()
+ clearRequestState(sessionId)
}
// ── 右键调试菜单 ──
@@ -3886,8 +3067,8 @@ export async function render() {
${Object.entries(MODES).map(([key, m]) => `
`).join('')}
-