${shortcutsHtml}
@@ -444,12 +506,14 @@ async function openTaskDialog(job, page, state) {
const kind = modal.querySelector('select[name="taskKind"]').value
const showSession = kind === 'sessionMessage'
modal.querySelector('[data-field="sessionLabel"]').style.display = showSession ? 'block' : 'none'
- modal.querySelector('[data-field="message"]').style.display = showSession ? 'none' : 'block'
+ modal.querySelector('[data-field="triggerMode"]').style.display = showSession ? 'block' : 'none'
+ modal.querySelector('[data-field="schedule"]').style.display = (!showSession || modal.querySelector('select[name="triggerMode"]').value === 'cron') ? 'block' : 'none'
modal.querySelector('[data-field="agent"]').style.display = showSession ? 'none' : 'block'
modal.querySelector('[data-field="delivery"]').style.display = showSession ? 'none' : 'block'
}
toggleFields()
modal.querySelector('select[name="taskKind"]').onchange = toggleFields
+ modal.querySelector('select[name="triggerMode"]').onchange = toggleFields
// 保存
modal.querySelector('#btn-cron-save').onclick = async () => {
@@ -460,52 +524,69 @@ async function openTaskDialog(job, page, state) {
const agentId = modal.querySelector('select[name="agentId"]').value || undefined
const enabled = modal.querySelector('input[name="enabled"]').checked
const sessionLabel = modal.querySelector('select[name="sessionLabel"]').value
+ const triggerMode = modal.querySelector('select[name="triggerMode"]').value
if (!name) { toast('请输入任务名称', 'warning'); return }
- if (taskKind !== 'sessionMessage' && !message) { toast('请输入执行指令', 'warning'); return }
+ if (!message) { toast('请输入执行内容', 'warning'); return }
if (taskKind === 'sessionMessage' && !sessionLabel) { toast('请选择会话', 'warning'); return }
- if (!schedule) { toast('请设置执行周期', 'warning'); return }
+ if (taskKind === 'sessionMessage' && triggerMode === 'cron' && !schedule) { toast('请设置执行周期', 'warning'); return }
+ if (taskKind !== 'sessionMessage' && !schedule) { toast('请设置执行周期', 'warning'); return }
+ if (taskKind !== 'sessionMessage' && !isGatewayUp()) { toast('Gateway 未连接,无法保存非 sessionMessage 任务', 'warning'); return }
const saveBtn = modal.querySelector('#btn-cron-save')
saveBtn.disabled = true
saveBtn.textContent = '保存中...'
try {
- if (isEdit) {
- const patch = { name, enabled }
- patch.schedule = { kind: 'cron', expr: schedule }
- if (taskKind === 'sessionMessage') {
- patch.sessionTarget = 'main'
- patch.payload = { kind: 'sessionMessage', label: sessionLabel, message: SESSION_MESSAGE_TEXT, role: 'user', waitForIdle: true }
+ if (taskKind === 'sessionMessage') {
+ if (isEdit) {
+ await updateLocalSessionMessageJob(job.id, {
+ name,
+ enabled,
+ triggerMode,
+ schedule: { kind: 'cron', expr: schedule },
+ payload: { kind: 'sessionMessage', label: sessionLabel, message, role: 'user', waitForIdle: true },
+ })
+ toast('任务已更新', 'success')
} else {
+ await addLocalSessionMessageJob({
+ id: job?.id || uuid(),
+ name,
+ enabled,
+ triggerMode,
+ schedule: { kind: 'cron', expr: schedule },
+ payload: { kind: 'sessionMessage', label: sessionLabel, message, role: 'user', waitForIdle: true },
+ state: { lastRunStatus: null, lastRunAtMs: 0, lastError: null, lastIdleAtMs: 0 },
+ })
+ toast('任务已创建', 'success')
+ }
+ } else {
+ if (isEdit) {
+ const patch = { name, enabled }
+ patch.schedule = { kind: 'cron', expr: schedule }
patch.payload = { kind: 'agentTurn', message }
if (agentId) patch.agentId = agentId
const deliveryChannel = modal.querySelector('select[name="deliveryChannel"]')?.value
if (deliveryChannel) {
patch.delivery = { mode: 'push', to: deliveryChannel, channel: deliveryChannel }
}
- }
- await wsClient.request('cron.update', { jobId: job.id, patch })
- toast('任务已更新', 'success')
- } else {
- const params = {
- name,
- enabled,
- schedule: { kind: 'cron', expr: schedule },
- }
- if (taskKind === 'sessionMessage') {
- params.sessionTarget = 'main'
- params.payload = { kind: 'sessionMessage', label: sessionLabel, message: SESSION_MESSAGE_TEXT, role: 'user', waitForIdle: true }
+ await wsClient.request('cron.update', { jobId: job.id, patch })
+ toast('任务已更新', 'success')
} else {
- params.payload = { kind: 'agentTurn', message }
+ const params = {
+ name,
+ enabled,
+ schedule: { kind: 'cron', expr: schedule },
+ payload: { kind: 'agentTurn', message },
+ }
if (agentId) params.agentId = agentId
const deliveryChannel = modal.querySelector('select[name="deliveryChannel"]')?.value
if (deliveryChannel) {
params.delivery = { mode: 'push', to: deliveryChannel, channel: deliveryChannel }
}
+ await wsClient.request('cron.add', params)
+ toast('任务已创建', 'success')
}
- await wsClient.request('cron.add', params)
- toast('任务已创建', 'success')
}
modal.close?.() || modal.remove?.()
await fetchJobs(page, state)
@@ -519,6 +600,148 @@ async function openTaskDialog(job, page, state) {
// ── 工具函数 ──
+// ── sessionMessage 本地存储与调度 ──
+
+async function loadLocalSessionMessageJobs() {
+ const cfg = (await api.readPanelConfig()) || {}
+ const jobs = Array.isArray(cfg[LOCAL_SESSION_MESSAGE_KEY]) ? cfg[LOCAL_SESSION_MESSAGE_KEY] : []
+ return jobs
+}
+
+async function saveLocalSessionMessageJobs(jobs) {
+ const cfg = (await api.readPanelConfig()) || {}
+ cfg[LOCAL_SESSION_MESSAGE_KEY] = jobs
+ await api.writePanelConfig(cfg)
+}
+
+async function addLocalSessionMessageJob(job) {
+ const jobs = await loadLocalSessionMessageJobs()
+ jobs.push(job)
+ await saveLocalSessionMessageJobs(jobs)
+}
+
+async function updateLocalSessionMessageJob(id, patch) {
+ const jobs = await loadLocalSessionMessageJobs()
+ const idx = jobs.findIndex(j => j.id === id)
+ if (idx === -1) throw new Error('任务不存在')
+ jobs[idx] = { ...jobs[idx], ...patch }
+ await saveLocalSessionMessageJobs(jobs)
+}
+
+async function removeLocalSessionMessageJob(id) {
+ const jobs = await loadLocalSessionMessageJobs()
+ const next = jobs.filter(j => j.id !== id)
+ await saveLocalSessionMessageJobs(next)
+}
+
+function attachSessionMessageListeners() {
+ if (_unsubEvent) return
+ _unsubEvent = wsClient.onEvent((msg) => {
+ if (msg?.type !== 'event') return
+ const payload = msg.payload || {}
+ const event = msg.event
+ const sessionKey = payload.sessionKey || payload.session_key || null
+ if (!sessionKey) return
+ if (event === 'chat') {
+ if (payload.state === 'delta') {
+ _sessionLastActivity.set(sessionKey, Date.now())
+ _sessionActiveRuns.set(sessionKey, true)
+ }
+ if (payload.state === 'final') {
+ _sessionLastActivity.set(sessionKey, Date.now())
+ _sessionActiveRuns.set(sessionKey, false)
+ }
+ }
+ })
+}
+
+async function refreshSessionLabelMap() {
+ const now = Date.now()
+ if (now - _sessionLabelLastFetch < 30000 && _sessionLabelMap.size > 0) return
+ _sessionLabelLastFetch = now
+ _sessionLabelMap.clear()
+ if (!isGatewayUp()) {
+ if (wsClient.sessionKey) _sessionLabelMap.set('主会话', wsClient.sessionKey)
+ return
+ }
+ const res = await wsClient.sessionsList(200)
+ const list = res?.sessions || res || []
+ list.forEach(s => {
+ const key = s.sessionKey || s.key || ''
+ const label = parseSessionLabel(key)
+ if (label) _sessionLabelMap.set(label, key)
+ })
+ if (wsClient.sessionKey && !_sessionLabelMap.has('主会话')) {
+ _sessionLabelMap.set('主会话', wsClient.sessionKey)
+ }
+}
+
+function isSessionIdle(sessionKey) {
+ const last = _sessionLastActivity.get(sessionKey) || 0
+ const active = _sessionActiveRuns.get(sessionKey)
+ return !active && (Date.now() - last >= SESSION_IDLE_MS)
+}
+
+function stopSessionMessageTicker() {
+ if (_tickTimer) {
+ clearInterval(_tickTimer)
+ _tickTimer = null
+ }
+}
+
+function restartSessionMessageTicker(page, state) {
+ stopSessionMessageTicker()
+ _tickTimer = setInterval(() => tickSessionMessageJobs(page, state), SESSION_MESSAGE_TICK_MS)
+ tickSessionMessageJobs(page, state)
+}
+
+async function tickSessionMessageJobs(page, state) {
+ await refreshSessionLabelMap().catch(() => {})
+ const jobs = await loadLocalSessionMessageJobs()
+ const now = new Date()
+ for (const job of jobs) {
+ if (job.enabled === false) continue
+ const triggerMode = job.triggerMode || 'cron'
+ if (triggerMode === 'cron') {
+ const expr = extractCronExpr(job.schedule)
+ if (!expr) continue
+ if (!isCronDue(expr, now, job.state?.lastRunAtMs || 0)) continue
+ await runSessionMessageJob(job, false).catch(() => {})
+ } else if (triggerMode === 'onIdle') {
+ await runSessionMessageJob(job, false).catch(() => {})
+ }
+ }
+ await fetchJobs(page, state)
+}
+
+async function runSessionMessageJob(job, manual) {
+ await refreshSessionLabelMap().catch(() => {})
+ const label = job.payload?.label || '主会话'
+ const sessionKey = _sessionLabelMap.get(label) || wsClient.sessionKey
+ if (!sessionKey) {
+ await updateLocalSessionMessageJob(job.id, { state: { ...job.state, lastRunStatus: 'error', lastError: 'session not found', lastRunAtMs: Date.now() } })
+ throw new Error('session not found')
+ }
+ if (job.payload?.waitForIdle && !isSessionIdle(sessionKey)) {
+ if (manual) throw new Error('session busy')
+ return
+ }
+ const triggerMode = job.triggerMode || 'cron'
+ if (triggerMode === 'onIdle') {
+ const lastIdleAtMs = job.state?.lastIdleAtMs || 0
+ if (lastIdleAtMs && Date.now() - lastIdleAtMs < SESSION_IDLE_MS) return
+ }
+ try {
+ await wsClient.chatSend(sessionKey, job.payload?.message || '')
+ const nextState = { ...job.state, lastRunStatus: 'ok', lastError: null, lastRunAtMs: Date.now() }
+ if (triggerMode === 'onIdle') nextState.lastIdleAtMs = Date.now()
+ await updateLocalSessionMessageJob(job.id, { state: nextState })
+ } catch (e) {
+ await updateLocalSessionMessageJob(job.id, { state: { ...job.state, lastRunStatus: 'error', lastError: String(e), lastRunAtMs: Date.now() } })
+ throw e
+ }
+}
+
/** 从 Gateway 的 CronSchedule 对象或字符串中提取纯 cron 表达式 */
function extractCronExpr(schedule) {
if (!schedule) return null
From a2eedcd2948cb9e1bcd9023b43dba7057b42f84c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Tue, 17 Mar 2026 12:53:09 +0800
Subject: [PATCH 190/426] docs: add hosted agent design
---
docs/plans/2026-03-17-hosted-agent-design.md | 131 +++++++++++++++++++
1 file changed, 131 insertions(+)
create mode 100644 docs/plans/2026-03-17-hosted-agent-design.md
diff --git a/docs/plans/2026-03-17-hosted-agent-design.md b/docs/plans/2026-03-17-hosted-agent-design.md
new file mode 100644
index 00000000..262a63c1
--- /dev/null
+++ b/docs/plans/2026-03-17-hosted-agent-design.md
@@ -0,0 +1,131 @@
+# 托管 Agent(聊天页)详细设计
+
+> 结论:采用方案一(复用晴辰助手能力)。
+
+## 目标
+- 聊天页发送按钮右侧新增“托管 Agent”入口
+- 托管 Agent 通过 WSS 自动与当前会话 Agent 交互
+- 上下文仅包含:初始提示词 + 与对面 Agent 的交流
+- 继承当前会话工具权限
+- 全局默认 + 会话级启用,切换会话/页面仍生效
+- 输出直接插入当前聊天流并标记来源
+
+## 入口与 UI 交互
+### 入口按钮
+- 位置:`src/pages/chat.js` 内聊天输入区域,发送按钮右侧
+- 交互:点击打开托管 Agent 配置面板
+- 状态:idle / running / waiting_reply / paused / error
+
+### 配置面板
+- 初始提示词(必填)
+- 启用开关
+- 运行模式:对面 Agent 回复后自动继续
+- 停止策略:托管 Agent 自评停止
+- 高级选项:最大步数 / 步间隔 / 重试次数
+- 操作:保存并启用 / 暂停 / 立即停止
+
+### 输出展示
+- 直接插入当前聊天流
+- 格式示例:
+ - `[托管 Agent] 下一步指令: ...`
+- 样式区分:弱化颜色 + 标签
+
+## 运行循环与状态机
+### 状态
+- idle / running / waiting_reply / paused / error
+
+### 触发
+- 监听 `wsClient.onEvent`
+- event=chat,state=final,sessionKey=当前会话
+
+### 执行流程
+1. 对面 Agent final 回复到达
+2. 托管 Agent 生成下一步指令
+3. 使用 `wsClient.chatSend` 发送
+4. 进入 waiting_reply
+5. 满足 stopPolicy 或 maxSteps 停止
+
+## 上下文构建
+- 仅包含:初始提示词 + 与对面 Agent 对话
+- 截断策略:按 MAX_CONTEXT_TOKENS 或最近 N 条
+- 不引入其他会话内容
+
+## 数据结构与持久化
+### 全局默认(clawpanel.json)
+```json
+{
+ "hostedAgent": {
+ "default": {
+ "enabled": false,
+ "prompt": "",
+ "autoRunAfterTarget": true,
+ "stopPolicy": "self",
+ "maxSteps": 50,
+ "stepDelayMs": 1200,
+ "retryLimit": 2,
+ "toolPolicy": "inherit"
+ }
+ }
+}
+```
+
+### 会话级(localStorage)
+Key: `clawpanel-hosted-agent-sessions`
+```json
+{
+ "agent:main:main": {
+ "enabled": true,
+ "prompt": "任务目标",
+ "autoRunAfterTarget": true,
+ "stopPolicy": "self",
+ "maxSteps": 50,
+ "stepDelayMs": 1200,
+ "retryLimit": 2,
+ "toolPolicy": "inherit",
+ "state": {
+ "status": "running",
+ "stepCount": 12,
+ "lastRunAt": 1710000000000,
+ "lastError": ""
+ },
+ "history": [
+ { "role": "system", "content": "初始提示词" },
+ { "role": "assistant", "content": "托管 Agent 生成的指令" },
+ { "role": "target", "content": "对面 Agent 回复" }
+ ]
+ }
+}
+```
+
+## assistant-core 抽取清单
+新增:`src/lib/assistant-core.js`
+
+### 抽取项(从 assistant.js)
+- API 适配:OpenAI/Anthropic/Gemini
+- SSE 流解析与重试
+- 系统提示词构建
+- 工具声明、权限过滤、执行与安全检查
+- 上下文裁剪与会话数据工具
+
+### 适配器注入
+- `api.*` 工具桥接
+- `confirm / ask_user` UI 适配器
+- `storage` 适配器
+- 图片存储适配器
+
+### 保留在 assistant.js
+- DOM 渲染与 UI 交互
+- toast/modal
+- 视图与事件绑定
+
+## 风险与保护
+- Gateway 断开:自动暂停
+- 连续失败:触发 error 状态
+- 最大步数:强制停止
+- 避免重复触发:运行中忽略新触发
+
+## 测试要点
+- 启用后自动发送
+- 对面回复后自动继续
+- 切换会话/页面后仍生效
+- 停止策略与最大步数生效
From 49fab69b54b3116027f1268e1b1b541cc757ee9c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Tue, 17 Mar 2026 13:03:12 +0800
Subject: [PATCH 191/426] chore: checkpoint before pr94 summary docs
From 1b21aad0ccde5c667a13123d2dadbfbd0e28576c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Tue, 17 Mar 2026 13:05:04 +0800
Subject: [PATCH 192/426] docs: add PR #94 summary and review notes
---
CHANGELOG.md | 10 ++++++++++
README.md | 10 ++++++++++
docs/pr/PR-94-description.md | 19 ++++++++++++++++++
docs/pr/PR-94-review-notes.md | 22 +++++++++++++++++++++
docs/pr/PR-94-summary.md | 36 +++++++++++++++++++++++++++++++++++
5 files changed, 97 insertions(+)
create mode 100644 docs/pr/PR-94-description.md
create mode 100644 docs/pr/PR-94-review-notes.md
create mode 100644 docs/pr/PR-94-summary.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a6c17b10..746c13df 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,16 @@
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
+## PR #94 摘要
+
+- 里程碑:v0.1.0→v0.9.4 版本演进与功能聚合完成。
+- 核心功能:聊天体验重构、虚拟滚动与工具事件展示完善。
+- 安装/诊断:独立安装包、安装方式选择器、Gateway 补丁与自动检测链路完善。
+- 渠道/协作:多 Agent 渠道绑定与飞书/QQ 等渠道扩展。
+- 性能/分发:ARM 优化、R2 分发与通用包策略升级。
+- 质量/安全:CI/Clippy 清理、安全修复与稳定性增强。
+- 文档/官网:部署指南、官网展示与 README 双语完善。
+
## [0.9.4] - 2026-03-17
### 新功能 (Features)
diff --git a/README.md b/README.md
index 897eb1f9..732fd811 100644
--- a/README.md
+++ b/README.md
@@ -41,6 +41,16 @@ ClawPanel 是 [OpenClaw](https://github.com/1186258278/OpenClawChineseTranslatio
> **官网**: [claw.qt.cool](https://claw.qt.cool/) | **下载**: [GitHub Releases](https://github.com/qingchencloud/clawpanel/releases/latest)
+### 近期亮点
+
+- **v0.9.x**:聊天体验重构,消息渲染与交互稳定性显著提升。
+- **聊天虚拟滚动**:大聊天记录场景性能优化,滚动更顺、渲染负载降低。
+- **Markdown-it**:引入稳定的 Markdown 渲染与扩展能力,展示一致性提升。
+- **v0.8.0**:多 Agent 渠道能力完善,任务分发与协作更清晰。
+- **Gateway 补丁**:连接与转发链路修复,长连接更可靠。
+- **R2 通用包**:分发与复用成本降低,部署更灵活。
+- **v0.6.0**:基础能力完善与稳定性打底,为后续迭代奠基。
+
### ⚡ OpenClaw 独立安装包(零依赖,无需 Node.js/npm)
不想折腾 Node.js 环境?直接下载 [OpenClaw 独立安装包](https://github.com/qingchencloud/openclaw-standalone/releases/latest),**内置运行时,解压即用**:
diff --git a/docs/pr/PR-94-description.md b/docs/pr/PR-94-description.md
new file mode 100644
index 00000000..ef75c6ca
--- /dev/null
+++ b/docs/pr/PR-94-description.md
@@ -0,0 +1,19 @@
+# PR #94 Description
+
+## Summary
+-
+
+## Impact
+-
+
+## Risks
+-
+
+## Testing
+-
+
+## Migration
+-
+
+## Notes
+-
diff --git a/docs/pr/PR-94-review-notes.md b/docs/pr/PR-94-review-notes.md
new file mode 100644
index 00000000..d2627022
--- /dev/null
+++ b/docs/pr/PR-94-review-notes.md
@@ -0,0 +1,22 @@
+# PR #94 Review Notes
+
+## Reviewer Checklist
+- [ ] 变更范围与 PR 描述一致(不含未声明的功能变更)
+- [ ] 关键链路:chat / tools / ws / gateway / cron 无回归
+- [ ] 安装与升级流程(独立包/镜像源/诊断)无破坏性调整
+- [ ] 文档与 README 更新与实际变更一致
+- [ ] 风险项与回滚策略明确
+
+## 风险 / 影响
+- **WS 链路**:连接防护、ping 机制与事件合并逻辑若异常会影响聊天稳定性
+- **Gateway 修复链路**:补丁/自动检测若逻辑偏差可能导致误修复
+- **渲染与滚动**:虚拟滚动与自动滚动策略改动影响大聊天记录体验
+
+## 变更范围映射
+- **chat**:虚拟滚动、自动滚动、渲染稳定性、工具事件展示
+- **tools**:toolCall/toolResult 解析、合并与显示
+- **ws**:连接防护、单例与 ping 机制
+- **gateway**:补丁与自动检测链路
+- **cron**:sessionMessage 触发与参数修正
+- **docs**:方案/设计文档与变更说明
+- **website**:官网展示与下载引导
diff --git a/docs/pr/PR-94-summary.md b/docs/pr/PR-94-summary.md
new file mode 100644
index 00000000..970c8db4
--- /dev/null
+++ b/docs/pr/PR-94-summary.md
@@ -0,0 +1,36 @@
+# PR #94 变更总览
+
+## 1) 版本里程碑(v0.1.0 → v0.9.4)
+- **v0.1.0–v0.2.x**:项目骨架、主题系统、聊天与 Agent 管理、安装源与升级流程奠基。
+- **v0.3.0–v0.5.x**:官网与文档体系成型,Gateway 自愈与环境检测增强,内置 AI 助手与图片识别落地。
+- **v0.6.0**:知识库、公益接口、全局 AI 诊断、官网改版等功能爆发。
+- **v0.7.x–v0.8.x**:Docker 集群与消息渠道扩展、版本管理与 Web 模式覆盖、跨平台兼容推进。
+- **v0.9.0–v0.9.2**:Usage analytics、通信配置、品牌升级、多 Agent 渠道、SkillHub 双源等核心能力集成。
+- **v0.9.3**:性能优化与分发链路升级(R2 CDN / 通用包策略)。
+- **v0.9.4**:独立安装包与安装方式选择器落地,WS 与工具事件链路稳定化。
+
+## 2) 核心功能演进
+- **面板/设置**:配置编辑、环境诊断、版本管理、面板设置与代理配置逐步完善。
+- **聊天**:渲染链路稳定化、工具事件可视化、聊天虚拟滚动与滚动行为重构。
+- **Agent**:管理与展示优化,多 Agent 渠道绑定与能力扩展。
+- **渠道**:飞书/Lark、QQ 内置 Bot、多渠道绑定与引导完善。
+- **安装/部署**:独立安装包、镜像源切换、Docker/CLI 诊断与引导增强。
+- **诊断/修复**:Gateway 一键补丁、自动检测与修复路径完善。
+- **性能**:ARM 设备优化、缓存与轮询降频、R2 分发策略与通用包。
+
+## 3) 质量与安全
+- **CI/Clippy**:跨平台编译与 Clippy 告警清理,持续提升门禁稳定性。
+- **安全修复**:命令注入、路径遍历、allowedOrigins 收敛、审计日志补强。
+- **稳定性**:配对/握手/WS 连接链路修复,安装与配置容错增强。
+
+## 4) 文档与官网
+- 官网展示体系升级(视觉效果、演示素材、SEO)。
+- 部署与运维文档完善(Linux/Docker/Web/ARM 指引)。
+- README 中英双语与非商用协议说明完善。
+
+## 5) PR #94 建议修改点(审阅友好化)
+1. **合并 checkpoint 提交**:按主题聚合(docs/配置/功能),减少审阅噪音。
+2. **补充 PR 描述**:明确用户可见影响、潜在风险与回滚策略。
+3. **Changelog 与 README 对齐**:增加高层摘要与近期亮点,便于快速理解范围。
+4. **文档主题聚合**:设计/方案类文档按模块归档,降低跳转成本。
+5. **变更范围标注**:对 chat/tools/ws/gateway/cron 等影响模块做清单化标记。
From 98b1064f292f6590e7ff200f8de3e80f1a6c3cbd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Tue, 17 Mar 2026 13:06:18 +0800
Subject: [PATCH 193/426] docs: remove plan documents
---
docs/assistant-features-plan.md | 777 ------------------
docs/docker-multi-instance-plan.md | 494 -----------
docs/i18n-plan.md | 288 -------
...cloudflared-openclaw-integration-design.md | 59 --
...3-16-cron-sessionmessage-tool-ui-design.md | 55 --
...-03-16-gateway-patch-auto-detect-design.md | 25 -
...026-03-16-gateway-patch-oneclick-design.md | 37 -
.../2026-03-17-ai-config-import-design.md | 37 -
...-03-17-assistant-optimize-toggle-design.md | 27 -
...026-03-17-assistant-ux-and-shell-design.md | 43 -
.../2026-03-17-chat-autoscroll-design.md | 41 -
.../2026-03-17-chat-daylight-shadow-design.md | 37 -
...26-03-17-chat-history-tool-merge-design.md | 30 -
.../2026-03-17-chat-markdown-it-design.md | 25 -
.../2026-03-17-chat-tool-event-live-design.md | 34 -
.../2026-03-17-chat-virtual-scroll-design.md | 25 -
...at-virtual-scroll-implementation-design.md | 34 -
docs/plans/2026-03-17-force-setup-design.md | 31 -
docs/plans/2026-03-17-hosted-agent-design.md | 131 ---
.../2026-03-17-skillhub-env-fix-design.md | 65 --
...-17-toast-night-and-model-select-design.md | 36 -
docs/plans/2026-03-17-toast-shadow-design.md | 18 -
docs/plans/2026-03-17-toast-vercel-design.md | 27 -
.../plans/2026-03-17-tool-call-meta-design.md | 32 -
.../2026-03-17-ws-connect-bootstrap-design.md | 31 -
.../2026-03-17-ws-ping-multi-req-design.md | 27 -
.../2026-03-17-ws-ping-node-list-design.md | 18 -
.../2026-03-16-gateway-patch-auto-detect.md | 74 --
.../2026-03-16-gateway-patch-oneclick.md | 169 ----
.../plans/2026-03-17-ai-config-import.md | 82 --
.../2026-03-17-assistant-optimize-toggle.md | 52 --
.../2026-03-17-assistant-ux-and-shell.md | 118 ---
.../plans/2026-03-17-chat-autoscroll.md | 132 ---
.../plans/2026-03-17-chat-daylight-shadow.md | 46 --
.../2026-03-17-chat-history-tool-merge.md | 81 --
.../plans/2026-03-17-chat-markdown-it.md | 146 ----
.../plans/2026-03-17-chat-tool-event-live.md | 97 ---
...3-17-chat-virtual-scroll-implementation.md | 211 -----
.../plans/2026-03-17-cron-trigger-mode.md | 86 --
.../2026-03-17-cron-ws-sessionmessage.md | 199 -----
.../plans/2026-03-17-force-setup.md | 84 --
.../2026-03-17-skill-trigger-optimization.md | 165 ----
.../plans/2026-03-17-skillhub-env-fix.md | 102 ---
...2026-03-17-toast-night-and-model-select.md | 99 ---
.../plans/2026-03-17-toast-shadow.md | 54 --
.../plans/2026-03-17-toast-vercel.md | 54 --
.../plans/2026-03-17-tool-call-meta.md | 108 ---
.../plans/2026-03-17-ws-connect-bootstrap.md | 86 --
.../plans/2026-03-17-ws-ping-multi-req.md | 59 --
.../plans/2026-03-17-ws-ping-node-list.md | 56 --
50 files changed, 4844 deletions(-)
delete mode 100644 docs/assistant-features-plan.md
delete mode 100644 docs/docker-multi-instance-plan.md
delete mode 100644 docs/i18n-plan.md
delete mode 100644 docs/plans/2026-03-16-cloudflared-openclaw-integration-design.md
delete mode 100644 docs/plans/2026-03-16-cron-sessionmessage-tool-ui-design.md
delete mode 100644 docs/plans/2026-03-16-gateway-patch-auto-detect-design.md
delete mode 100644 docs/plans/2026-03-16-gateway-patch-oneclick-design.md
delete mode 100644 docs/plans/2026-03-17-ai-config-import-design.md
delete mode 100644 docs/plans/2026-03-17-assistant-optimize-toggle-design.md
delete mode 100644 docs/plans/2026-03-17-assistant-ux-and-shell-design.md
delete mode 100644 docs/plans/2026-03-17-chat-autoscroll-design.md
delete mode 100644 docs/plans/2026-03-17-chat-daylight-shadow-design.md
delete mode 100644 docs/plans/2026-03-17-chat-history-tool-merge-design.md
delete mode 100644 docs/plans/2026-03-17-chat-markdown-it-design.md
delete mode 100644 docs/plans/2026-03-17-chat-tool-event-live-design.md
delete mode 100644 docs/plans/2026-03-17-chat-virtual-scroll-design.md
delete mode 100644 docs/plans/2026-03-17-chat-virtual-scroll-implementation-design.md
delete mode 100644 docs/plans/2026-03-17-force-setup-design.md
delete mode 100644 docs/plans/2026-03-17-hosted-agent-design.md
delete mode 100644 docs/plans/2026-03-17-skillhub-env-fix-design.md
delete mode 100644 docs/plans/2026-03-17-toast-night-and-model-select-design.md
delete mode 100644 docs/plans/2026-03-17-toast-shadow-design.md
delete mode 100644 docs/plans/2026-03-17-toast-vercel-design.md
delete mode 100644 docs/plans/2026-03-17-tool-call-meta-design.md
delete mode 100644 docs/plans/2026-03-17-ws-connect-bootstrap-design.md
delete mode 100644 docs/plans/2026-03-17-ws-ping-multi-req-design.md
delete mode 100644 docs/plans/2026-03-17-ws-ping-node-list-design.md
delete mode 100644 docs/superpowers/plans/2026-03-16-gateway-patch-auto-detect.md
delete mode 100644 docs/superpowers/plans/2026-03-16-gateway-patch-oneclick.md
delete mode 100644 docs/superpowers/plans/2026-03-17-ai-config-import.md
delete mode 100644 docs/superpowers/plans/2026-03-17-assistant-optimize-toggle.md
delete mode 100644 docs/superpowers/plans/2026-03-17-assistant-ux-and-shell.md
delete mode 100644 docs/superpowers/plans/2026-03-17-chat-autoscroll.md
delete mode 100644 docs/superpowers/plans/2026-03-17-chat-daylight-shadow.md
delete mode 100644 docs/superpowers/plans/2026-03-17-chat-history-tool-merge.md
delete mode 100644 docs/superpowers/plans/2026-03-17-chat-markdown-it.md
delete mode 100644 docs/superpowers/plans/2026-03-17-chat-tool-event-live.md
delete mode 100644 docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md
delete mode 100644 docs/superpowers/plans/2026-03-17-cron-trigger-mode.md
delete mode 100644 docs/superpowers/plans/2026-03-17-cron-ws-sessionmessage.md
delete mode 100644 docs/superpowers/plans/2026-03-17-force-setup.md
delete mode 100644 docs/superpowers/plans/2026-03-17-skill-trigger-optimization.md
delete mode 100644 docs/superpowers/plans/2026-03-17-skillhub-env-fix.md
delete mode 100644 docs/superpowers/plans/2026-03-17-toast-night-and-model-select.md
delete mode 100644 docs/superpowers/plans/2026-03-17-toast-shadow.md
delete mode 100644 docs/superpowers/plans/2026-03-17-toast-vercel.md
delete mode 100644 docs/superpowers/plans/2026-03-17-tool-call-meta.md
delete mode 100644 docs/superpowers/plans/2026-03-17-ws-connect-bootstrap.md
delete mode 100644 docs/superpowers/plans/2026-03-17-ws-ping-multi-req.md
delete mode 100644 docs/superpowers/plans/2026-03-17-ws-ping-node-list.md
diff --git a/docs/assistant-features-plan.md b/docs/assistant-features-plan.md
deleted file mode 100644
index 9b2dd08a..00000000
--- a/docs/assistant-features-plan.md
+++ /dev/null
@@ -1,777 +0,0 @@
-# AI 助手功能扩展规划
-
-> 基于现有工具架构(TOOL_DEFS + executeTool + getEnabledTools),扩展 6 大能力模块。
-> 每个模块独立开关,遵循现有的 `_config.tools.xxx` + 设置面板 toggle 模式。
-
----
-
-## 当前架构概览
-
-```
-TOOL_DEFS = {
- system: [get_system_info] // 始终可用
- process: [list_processes, check_port] // 始终可用
- interaction: [ask_user] // 始终可用
- terminal: [run_command] // 开关控制
- fileOps: [read_file, write_file, list_directory] // 开关控制
-}
-
-_config.tools = { terminal: true/false, fileOps: true/false }
-```
-
-扩展后:
-
-```
-TOOL_DEFS = {
- ...existing,
- docker: [docker_list, docker_exec, docker_logs, wsl_exec] // 新增
- webSearch: [web_search, fetch_url] // 新增
- ssh: [ssh_exec, ssh_read_file, ssh_write_file] // 新增
- knowledge: [search_knowledge] // 新增
-}
-
-_config.tools = {
- ...existing,
- docker: false, // 默认关闭
- webSearch: false, // 默认关闭
- ssh: false, // 默认关闭
- knowledge: false, // 默认关闭
-}
-```
-
----
-
-## 模块一:Docker / WSL 管理工具
-
-### 场景
-- 用户的 OpenClaw 可能安装在 Docker 容器或 WSL 中
-- 本地检测不到时,帮用户在容器/WSL 内操作
-- 查看容器日志、进入容器执行命令、管理容器生命周期
-
-### 工具定义
-
-| 工具名 | 描述 | 参数 | 危险等级 |
-|--------|------|------|----------|
-| `docker_list` | 列出 Docker 容器 | `filter?`, `all?` | 安全 |
-| `docker_exec` | 在容器内执行命令 | `container`, `command` | ⚠️ 危险 |
-| `docker_logs` | 查看容器日志 | `container`, `lines?` | 安全 |
-| `docker_compose` | 执行 docker-compose 命令 | `action`, `file?`, `service?` | ⚠️ 危险 |
-| `wsl_exec` | 在 WSL 内执行命令 | `distro?`, `command` | ⚠️ 危险 |
-| `wsl_list` | 列出 WSL 发行版 | — | 安全 |
-
-### 后端实现
-
-```
-Tauri (Rust):
- - docker_list → Command::new("docker").args(["ps", ...])
- - docker_exec → Command::new("docker").args(["exec", container, ...])
- - wsl_exec → Command::new("wsl").args(["-d", distro, "-e", ...]) (Windows only)
-
-dev-api.js (Web):
- - execSync('docker ps --format json')
- - execSync(`docker exec ${container} ${command}`)
- - execSync(`wsl -d ${distro} -e ${command}`) (Windows only)
-```
-
-### 安全围栏
-- `docker_exec` / `wsl_exec` 归入 DANGEROUS_TOOLS
-- `docker rm`, `docker rmi`, `docker system prune` 归入 CRITICAL_PATTERNS
-
-### UI 扩展
-- 设置面板工具权限 tab 新增 toggle:
- ```
- Docker / WSL 工具 — 允许管理容器和 WSL 环境
- ```
-
-### 内置技能卡片
-```js
-{
- id: 'detect-docker-openclaw',
- icon: 'docker',
- name: '检测 Docker/WSL 中的 OpenClaw',
- desc: '扫描 Docker 容器和 WSL,查找 OpenClaw 安装',
- tools: ['docker'],
- prompt: `请帮我检查 Docker 和 WSL 中是否安装了 OpenClaw。
- 1. 调用 get_system_info 判断操作系统
- 2. 用 docker_list 列出所有容器,过滤包含 openclaw/gateway 的
- 3. 如果是 Windows,用 wsl_list 列出 WSL 发行版
- 4. 对每个 WSL 发行版,用 wsl_exec 执行 "which openclaw" 检测
- 5. 汇总发现的 OpenClaw 实例及其状态`
-}
-```
-
-### 优先级:高(解决用户最常见困惑)
-### 工时估算:1-2 天
-
----
-
-## 模块二:联网搜索工具
-
-### 场景
-- 用户遇到不常见的错误,AI 知识库可能没有
-- 搜索 GitHub Issues、文档、Stack Overflow 找到解决方案
-- 查找最新版本信息、API 文档等
-
-### 工具定义
-
-| 工具名 | 描述 | 参数 | 危险等级 |
-|--------|------|------|----------|
-| `web_search` | 联网搜索关键词 | `query`, `max_results?` | 安全 |
-| `fetch_url` | 抓取网页内容 | `url` | 安全 |
-
-### 后端实现方案(3 选 1)
-
-#### 方案 A:DuckDuckGo Instant Answer API(推荐,免费无 Key)
-```js
-// 搜索
-const resp = await fetch(`https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json`)
-// 但 Instant Answer 只返回摘要,不返回搜索结果列表
-
-// 实际搜索需要用 DuckDuckGo HTML 页面解析或第三方库
-```
-
-#### 方案 B:SearXNG 代理(自托管,最灵活)
-```js
-// 部署一个 SearXNG 实例,或者用公共实例
-const resp = await fetch(`https://searx.example.com/search?q=${query}&format=json`)
-```
-
-#### 方案 C:Jina Reader API(推荐搭配使用,免费)
-```js
-// 将任意 URL 转为纯文本/Markdown
-const resp = await fetch(`https://r.jina.ai/${targetUrl}`)
-const text = await resp.text()
-```
-
-### 推荐组合
-- **搜索**:使用 DuckDuckGo 的 `html.duckduckgo.com/html/?q=xxx` 页面解析结果
-- **内容抓取**:使用 Jina Reader `r.jina.ai/URL` 获取纯文本
-- 两者都 **免费无 Key**,无需用户配置
-
-### 系统提示词补充
-```
-## web_search 使用指南
-当你无法确定答案或需要最新信息时,可以使用 web_search 搜索互联网。
-搜索后,如果需要更多内容,可以用 fetch_url 抓取具体页面。
-搜索技巧:
-- 加 site:github.com 搜索 GitHub
-- 加 site:stackoverflow.com 搜索 StackOverflow
-- 搜索错误信息时,用引号包裹关键错误文本
-```
-
-### 安全围栏
-- 搜索和抓取不涉及破坏性操作,不归入 DANGEROUS_TOOLS
-- 但需要网络请求,添加超时保护(10 秒)
-- URL 抓取限制最大内容长度(100KB → 截断)
-
-### UI 扩展
-```
-联网搜索 — 允许搜索互联网和抓取网页内容(需联网)
-```
-
-### 优先级:高(大幅提升问题解决能力)
-### 工时估算:0.5-1 天
-
----
-
-## 模块三:SSH 远程管理工具
-
-### 场景
-- 用户的 OpenClaw 部署在远程服务器上
-- 帮用户远程安装、配置、排查 OpenClaw
-- 远程查看日志、重启服务、修改配置
-
-### 工具定义
-
-| 工具名 | 描述 | 参数 | 危险等级 |
-|--------|------|------|----------|
-| `ssh_exec` | 在远程服务器执行命令 | `connection_id`, `command` | ⚠️ 危险 |
-| `ssh_read_file` | 读取远程文件 | `connection_id`, `path` | 安全 |
-| `ssh_write_file` | 写入远程文件 | `connection_id`, `path`, `content` | ⚠️ 危险 |
-
-### 配置数据结构
-
-```js
-_config.sshConnections = [
- {
- id: 'my-server',
- name: '生产服务器',
- host: '192.168.1.100',
- port: 22,
- user: 'root',
- authType: 'key', // 'key' | 'password'
- keyPath: '~/.ssh/id_rsa',
- // password 不存储在 localStorage,每次询问或用 keytar 安全存储
- }
-]
-```
-
-### 后端实现
-
-```
-Tauri (Rust):
- - 使用 ssh2 crate 或调用系统 ssh CLI
- - ssh_exec → Command::new("ssh").args(["-p", port, "user@host", command])
- - ssh_read_file → ssh + cat
- - ssh_write_file → 通过 stdin pipe 写入
-
-dev-api.js (Web):
- - 使用 node-ssh 或 ssh2 npm 包
- - 或者直接调用 ssh CLI
-```
-
-### 设置 UI:新增 tab「远程连接」
-
-```
-┌─────────────────────────────────────────────┐
-│ 模型配置 │ 工具权限 │ 远程连接 │ 助手人设 │
-├─────────────────────────────────────────────┤
-│ │
-│ ┌─ 生产服务器 ──────────────── [编辑] [删] │
-│ │ root@192.168.1.100:22 (密钥认证) │
-│ └──────────────────────────────────────────│
-│ │
-│ ┌─ 测试服务器 ──────────────── [编辑] [删] │
-│ │ admin@10.0.0.5:22 (密码认证) │
-│ └──────────────────────────────────────────│
-│ │
-│ [+ 添加连接] │
-│ │
-│ 提示:推荐使用 SSH 密钥认证。 │
-│ 生成密钥:ssh-keygen -t ed25519 │
-│ 复制公钥:ssh-copy-id user@host │
-│ │
-└─────────────────────────────────────────────┘
-```
-
-### 安全围栏
-- `ssh_exec`, `ssh_write_file` 归入 DANGEROUS_TOOLS
-- 密码认证:每次执行时用 ask_user 确认,或使用系统密钥链
-- SSH 密钥路径验证:检查文件是否存在
-- 关键命令(rm -rf, reboot 等)在远程同样走 CRITICAL_PATTERNS
-
-### 系统提示词补充
-```
-## SSH 远程管理
-用户可能配置了远程服务器连接。当操作远程服务器时:
-- 先用 ask_user 确认要操作哪个连接
-- 远程命令比本地更谨慎,优先使用只读操作
-- 修改配置前先备份(cp xxx xxx.bak)
-```
-
-### 内置技能卡片
-```js
-{
- id: 'remote-manage',
- icon: 'network',
- name: '远程管理 OpenClaw',
- desc: '通过 SSH 连接远程服务器,管理 OpenClaw',
- tools: ['ssh', 'fileOps'],
- prompt: `请帮我管理远程服务器上的 OpenClaw。
- 1. 获取系统信息,列出已配置的 SSH 连接
- 2. 用 ask_user 让我选择要操作的服务器
- 3. 用 ssh_exec 检查远程 OpenClaw 状态
- 4. 检查 Gateway 进程和端口
- 5. 读取远程配置和日志
- 6. 汇总远程 OpenClaw 状态报告`
-}
-```
-
-### 优先级:中(用户量较少但价值极高)
-### 工时估算:2-3 天
-
----
-
-## 模块四:知识库 + 灵魂移植(借尸还魂)
-
-### 核心理念
-
-OpenClaw 的 Agent 有一套完整的**身份系统**,由工作区引导文件定义:
-
-```
-~/.openclaw/workspace/ ← Agent 的"灵魂"所在
- ├── AGENTS.md ← 操作指令、规则、记忆管理方式
- ├── SOUL.md ← 人设、边界、语气("Who You Are")
- ├── IDENTITY.md ← 名称、物种、风格、表情符号、头像
- ├── USER.md ← 用户档案(名字、称呼、时区、偏好)
- ├── TOOLS.md ← 工具本地笔记(SSH 配置、设备名等)
- ├── HEARTBEAT.md ← 心跳任务清单
- ├── MEMORY.md ← 精选长期记忆(仅主会话加载)
- └── memory/ ← 每日记忆日志
- ├── 2026-03-04-1609.md
- └── ...
-
-~/.openclaw/agents/
/agent/ ← Agent 的运行时状态
- ├── models.json ← 模型提供商配置(baseUrl + apiKey + models)
- ├── auth-profiles.json ← 认证配置文件
- └── auth.json
-```
-
-**"借尸还魂"不是复用知识库,而是完整接管 Agent 的灵魂**——
-ClawPanel 的 AI 助手直接读取这些文件,像 OpenClaw 一样把它们注入 system prompt,
-从而变成那个 Agent:有他的名字、他的性格、他的记忆、他认识的用户。
-
-### 4A:灵魂移植(Agent Identity Takeover)
-
-#### 工作流程
-
-1. **扫描** `~/.openclaw/workspace/` 和 `~/.openclaw/agents/` 目录
-2. **发现** 所有可用的 Agent 身份(main、test 等)
-3. **用户选择** 要附身的 Agent
-4. **读取** 该 Agent 的全部引导文件:
- - `SOUL.md` → 注入为人设(替换 ClawPanel 助手的默认人设)
- - `IDENTITY.md` → 提取名称/表情/风格(替换助手名称和性格描述)
- - `USER.md` → 注入用户上下文(知道用户叫什么、偏好什么)
- - `AGENTS.md` → 注入操作规则(Agent 的行为准则)
- - `TOOLS.md` → 注入工具笔记
- - `MEMORY.md` → 注入长期记忆
- - `memory/` → 注入最近的每日记忆(最近 3 天)
-5. **注入** 到 `buildSystemPrompt()` 中,完全替代默认人设
-
-#### 实现
-
-```js
-// 新增配置项
-_config.soulSource = null // null = 使用 ClawPanel 默认 | 'openclaw:main' | 'openclaw:test' | 'custom'
-_config.soulCache = null // 缓存读取的灵魂文件内容
-
-// buildSystemPrompt 改造
-function buildSystemPrompt() {
- if (_config.soulSource?.startsWith('openclaw:')) {
- // 借尸还魂模式:使用 OpenClaw Agent 的灵魂
- return buildOpenClawSoulPrompt()
- }
- // 默认模式:使用 ClawPanel 自带的系统提示词
- return buildDefaultPrompt()
-}
-
-function buildOpenClawSoulPrompt() {
- const soul = _config.soulCache
- if (!soul) return buildDefaultPrompt() // fallback
-
- let prompt = ''
-
- // 1. 身份注入
- if (soul.identity) {
- prompt += `# Identity\n${soul.identity}\n\n`
- }
-
- // 2. 灵魂注入(人设、边界、语气)
- if (soul.soul) {
- prompt += `# Soul\n${soul.soul}\n\n`
- }
-
- // 3. 用户上下文
- if (soul.user) {
- prompt += `# User\n${soul.user}\n\n`
- }
-
- // 4. 操作规则
- if (soul.agents) {
- prompt += `# Operating Instructions\n${soul.agents}\n\n`
- }
-
- // 5. 工具笔记
- if (soul.tools) {
- prompt += `# Tool Notes\n${soul.tools}\n\n`
- }
-
- // 6. 长期记忆
- if (soul.memory) {
- prompt += `# Long-term Memory\n${soul.memory}\n\n`
- }
-
- // 7. 最近的每日记忆
- if (soul.recentMemories?.length) {
- prompt += `# Recent Memory\n`
- for (const m of soul.recentMemories) {
- prompt += `## ${m.date}\n${m.content}\n\n`
- }
- }
-
- // 8. 追加 ClawPanel 特有的工具说明(保持工具能力)
- prompt += buildToolInstructions()
-
- return prompt
-}
-```
-
-#### 灵魂加载函数
-
-```js
-async function loadOpenClawSoul(agentId = 'main') {
- const home = await getHomeDir()
- const ws = `${home}/.openclaw/workspace` // 工作区是全局的,不按 agentId 分
-
- const readSafe = async (path) => {
- try { return await api.assistantReadFile(path) }
- catch { return null }
- }
-
- const soul = {
- identity: await readSafe(`${ws}/IDENTITY.md`),
- soul: await readSafe(`${ws}/SOUL.md`),
- user: await readSafe(`${ws}/USER.md`),
- agents: await readSafe(`${ws}/AGENTS.md`),
- tools: await readSafe(`${ws}/TOOLS.md`),
- memory: await readSafe(`${ws}/MEMORY.md`),
- recentMemories: [],
- }
-
- // 读取最近 3 天的每日记忆
- try {
- const memDir = await api.assistantListDir(`${ws}/memory`)
- const files = memDir.split('\n').filter(f => f.match(/\d{4}-\d{2}-\d{2}/))
- const recent = files.sort().slice(-3)
- for (const f of recent) {
- const content = await readSafe(`${ws}/memory/${f.trim()}`)
- if (content) soul.recentMemories.push({ date: f.trim(), content })
- }
- } catch {}
-
- return soul
-}
-```
-
-#### UI:设置面板「助手人设」Tab 改造
-
-```
-┌─────────────────────────────────────────────┐
-│ 模型配置 │ 工具权限 │ 知识库 │ 远程连接 │ 人设 │
-├─────────────────────────────────────────────┤
-│ │
-│ 身份来源 │
-│ ┌──────────────────────────────────────────│
-│ │ ● ClawPanel 默认人设 │ ← 当前默认
-│ │ ○ OpenClaw Agent 身份(借尸还魂) │ ← 新增
-│ │ ○ 自定义人设 │
-│ └──────────────────────────────────────────│
-│ │
-│ ─── 当选择「OpenClaw Agent」时显示 ──── │
-│ │
-│ 选择 Agent: [main ▼] │
-│ │
-│ 灵魂文件预览 │
-│ ┌──────────────────────────────────────────│
-│ │ SOUL.md ✓ 已加载 (1.6KB) │
-│ │ IDENTITY.md ✓ 已加载 (636B) │
-│ │ USER.md ✓ 已加载 (237B) │
-│ │ AGENTS.md ✓ 已加载 (7.8KB) │
-│ │ TOOLS.md ✓ 已加载 (860B) │
-│ │ MEMORY.md ✕ 未找到 │
-│ │ memory/ 2 个日志文件 │
-│ └──────────────────────────────────────────│
-│ │
-│ [附身] [刷新] │
-│ │
-│ 注意:附身后,助手将使用该 Agent 的人格、│
-│ 记忆和用户偏好。可随时切回默认。 │
-│ │
-│ ─── 当选择「ClawPanel 默认」时显示 ──── │
-│ │
-│ 助手名称: [晴辰助手 ] │
-│ 助手性格: [________________________] │
-│ │
-└─────────────────────────────────────────────┘
-```
-
-#### 附身后的效果
-
-| 维度 | 默认模式 | 附身模式 |
-|------|----------|----------|
-| 名称 | "晴辰助手" | IDENTITY.md 中的名称 |
-| 性格 | 简洁专业 | SOUL.md 定义的风格 |
-| 称呼用户 | "你" | USER.md 中的称呼(如"爸爸") |
-| 行为规则 | ClawPanel 内置 | AGENTS.md 的规则体系 |
-| 记忆 | 无 | MEMORY.md + 每日记忆 |
-| 工具知识 | ClawPanel 内置 | TOOLS.md 的本地笔记 |
-| 工具能力 | 保持不变 | 保持 ClawPanel 的工具 |
-
-**关键设计**:附身只替换"灵魂"(system prompt),**工具能力保持 ClawPanel 的**。
-因为 OpenClaw 的工具(exec/read/edit/write)和 ClawPanel 的工具本质相同,
-但 ClawPanel 有独有的 docker/ssh/搜索等扩展工具,这些要保留。
-
-### 4B:自定义知识库
-
-在灵魂移植之外,仍然支持用户上传额外的知识文档:
-
-#### 数据存储
-```
-~/.openclaw/clawpanel-kb/
- ├── index.json # 知识库索引
- ├── docs/
- │ ├── api-guide.md # 用户上传的文档
- │ ├── faq.md
- │ └── deploy-notes.txt
- └── chunks/ # 分块索引(可选,用于大文档)
- └── ...
-```
-
-#### 实现方案
-
-**V1(简单方案)**:
-- 小文档(<8KB)直接全文注入 system prompt 尾部
-- 大文档做关键词搜索(正则匹配 + 上下文窗口)
-- 总注入 token 上限:4000 tokens
-- 知识库和灵魂移植可叠加使用
-
-**V2(进阶方案)**:
-- embedding 语义搜索
-- `search_knowledge` 工具让 AI 按需检索
-
-### 优先级:� 高(灵魂移植是杀手级差异化功能)
-### 工时估算:灵魂移植 1-2 天,自定义知识库 V1 额外 1 天
-
----
-
-## 模块五:模型配置自动导入
-
-### 场景
-- 用户已安装 OpenClaw 并配置了模型
-- ClawPanel AI 助手需要单独配置模型(目前手动填写)
-- 一键从 OpenClaw 配置导入,省去重复配置
-
-### 实现
-
-#### 数据来源(两个层级)
-
-**层级 1:全局配置** `~/.openclaw/openclaw.json`
-```json
-{
- "models": {
- "providers": {
- "shengsuanyun": {
- "baseUrl": "http://127.0.0.1:8082/v1",
- "apiKey": "sk-xxx",
- "api": "openai-completions"
- }
- }
- }
-}
-```
-
-**层级 2:Agent 模型注册表** `~/.openclaw/agents//agent/models.json`
-```json
-{
- "providers": {
- "openai": {
- "baseUrl": "http://127.0.0.1:8082/v1",
- "apiKey": "sk-eB3ybVNFvqB4fGrTUp3F8Lq16QxF7tut",
- "api": "openai-completions",
- "models": [
- { "id": "gpt-5.4", "name": "gpt-5.4", "contextWindow": 200000, "maxTokens": 8192 },
- { "id": "gpt-5.2-codex", "name": "gpt-5.2-codex", ... }
- ]
- }
- }
-}
-```
-
-**推荐优先读取 Agent 的 models.json**——它有完整的 baseUrl + apiKey + models 列表,
-一键就能填充 ClawPanel 助手的配置。
-
-#### 读取逻辑
-```js
-async function discoverOpenClawModels() {
- const home = await getHomeDir()
- const results = []
-
- // 1. 扫描所有 Agent 的 models.json
- try {
- const agents = await api.assistantListDir(`${home}/.openclaw/agents`)
- for (const agentId of agents.split('\n').map(s => s.trim()).filter(Boolean)) {
- try {
- const raw = await api.assistantReadFile(`${home}/.openclaw/agents/${agentId}/agent/models.json`)
- const data = JSON.parse(raw)
- for (const [providerId, provider] of Object.entries(data.providers || {})) {
- results.push({
- source: `Agent: ${agentId}`,
- providerId,
- baseUrl: provider.baseUrl,
- apiKey: provider.apiKey,
- apiType: provider.api === 'openai-completions' ? 'openai' : provider.api,
- models: (provider.models || []).map(m => m.id || m.name),
- })
- }
- } catch {}
- }
- } catch {}
-
- // 2. 读取全局 openclaw.json 作为补充
- try {
- const raw = await api.assistantReadFile(`${home}/.openclaw/openclaw.json`)
- const config = JSON.parse(raw)
- for (const [providerId, provider] of Object.entries(config.models?.providers || {})) {
- // 去重:如果 Agent models.json 已有相同 providerId,跳过
- if (!results.find(r => r.providerId === providerId)) {
- results.push({
- source: '全局配置',
- providerId,
- baseUrl: provider.baseUrl,
- apiKey: provider.apiKey,
- apiType: 'openai',
- models: [], // 全局配置没有 models 列表
- })
- }
- }
- } catch {}
-
- return results
-}
-```
-
-#### UI:模型配置 tab 新增「导入」按钮
-
-```
-┌─────────────────────────────────────────────┐
-│ API Base URL API 类型 │
-│ [________________________] [OpenAI 兼容 ▼] │
-│ │
-│ API Key [测试] [拉取] [导入] │ ← 新增「导入」按钮
-│ [________________________] │
-│ │
-│ 模型 温度 │
-│ [________________________] [0.7] │
-│ │
-└─────────────────────────────────────────────┘
-```
-
-点击「导入」弹出选择面板:
-
-```
-┌─────────────────────────────────────────────┐
-│ 从 OpenClaw 导入模型配置 │
-│ │
-│ 检测到以下已配置的服务商: │
-│ │
-│ ○ OpenAI │
-│ https://api.openai.com/v1 │
-│ 模型: gpt-4o, gpt-4o-mini │
-│ │
-│ ○ DeepSeek │
-│ https://api.deepseek.com │
-│ 模型: deepseek-chat, deepseek-reasoner │
-│ │
-│ ○ 本地 Ollama │
-│ http://127.0.0.1:11434/v1 │
-│ 模型: qwen2.5:7b │
-│ │
-│ 选择一个服务商,自动填充配置。 │
-│ [取消] [导入] │
-└─────────────────────────────────────────────┘
-```
-
-### 后端
-
-```
-Tauri: 已有 read_openclaw_config 命令
-dev-api.js: 已有 read_config handler
-
-// 只需在前端加一个读取+解析+填充的逻辑
-```
-
-### 优先级:高(零成本,纯前端,极大提升体验)
-### 工时估算:0.5 天
-
----
-
-## 实施路线图
-
-### Phase 1:快速见效(1-2 天)
-| 序号 | 功能 | 工时 | 理由 |
-|------|------|------|------|
-| 1 | **模型配置自动导入** | 0.5d | 读 Agent models.json → 一键填充,纯前端零风险 |
-| 2 | **联网搜索工具** | 0.5-1d | DuckDuckGo + Jina,免费无 Key |
-| 3 | **灵魂移植(借尸还魂)** | 1-2d | 杀手级差异化——读 SOUL/IDENTITY/USER/AGENTS/MEMORY → 变身 |
-
-### Phase 2:核心扩展(2-3 天)
-| 序号 | 功能 | 工时 | 理由 |
-|------|------|------|------|
-| 4 | **Docker/WSL 工具** | 1-2d | 解决用户最常见的安装困惑 |
-| 5 | **自定义知识库 V1** | 1d | 用户上传 md/txt → 注入 prompt |
-
-### Phase 3:高级功能(3-5 天)
-| 序号 | 功能 | 工时 | 理由 |
-|------|------|------|------|
-| 6 | **SSH 远程管理** | 2-3d | 价值最高但复杂度也最高 |
-| 7 | **知识库 V2(语义搜索)** | 3-5d | 依赖 embedding API |
-
----
-
-## 设置面板 Tab 规划
-
-当前 3 个 Tab → 扩展为 5 个 Tab:
-
-```
-模型配置 │ 工具权限 │ 知识库 │ 远程连接 │ 助手人设
-```
-
-### 工具权限 Tab 最终形态
-
-```
-基础工具
- ☑ 终端工具 — 允许执行 Shell 命令
- ☑ 文件工具 — 允许读写文件和浏览目录
-
-扩展工具
- ☐ Docker/WSL — 允许管理容器和 WSL 环境
- ☐ 联网搜索 — 允许搜索互联网和抓取网页
- ☐ SSH 远程 — 允许连接远程服务器(需先配置连接)
- ☐ 知识库 — 允许检索知识库内容
-
-ℹ️ 进程列表、端口检测、系统信息工具始终可用(非聊天模式下)。
-```
-
----
-
-## 技术注意事项
-
-### 1. Token 预算管理
-灵魂移植 + 知识库注入会占用 context window,需要精细管理:
-
-| 组件 | 预算 | 说明 |
-|------|------|------|
-| ClawPanel 基础 prompt | ~2000 tokens | 产品介绍、工具指南、技能卡片 |
-| SOUL.md | ~500 tokens | 人设通常简短 |
-| IDENTITY.md | ~200 tokens | 名称/风格 |
-| USER.md | ~200 tokens | 用户档案 |
-| AGENTS.md | ~3000 tokens | 操作规则(最大,可截断) |
-| TOOLS.md | ~300 tokens | 工具笔记 |
-| MEMORY.md | ~2000 tokens | 长期记忆(截断保留最近部分) |
-| 每日记忆 (3天) | ~1500 tokens | 自动截断 |
-| 自定义知识库 | ~4000 tokens | 用户上传文档 |
-| 搜索结果 | ~2000 tokens | web_search 返回内容 |
-| **总计上限** | **~16000 tokens** | 留足空间给对话历史 |
-
-策略:
-- AGENTS.md 超过 3000 tokens 时截断尾部,保留前面的核心规则
-- MEMORY.md 超过 2000 tokens 时只保留最后 2000 tokens
-- 每日记忆超过 500 tokens/天时截断
-- 灵魂文件加载时计算总 token 并在 UI 中显示
-
-### 2. 跨平台兼容
-- Docker CLI 在 Windows/Mac/Linux 都可用
-- WSL 仅 Windows
-- SSH 密钥路径:Windows 用 `%USERPROFILE%\.ssh\`,Mac/Linux 用 `~/.ssh/`
-
-### 3. 安全存储
-- SSH 密码/API Key:
- - Tauri 模式:使用 keytar 或 tauri-plugin-store 加密存储
- - Web 模式:仅支持密钥认证(不存储密码)
-- 知识库文件:存储在 `~/.openclaw/` 下,与用户数据同目录
-
-### 4. 工具发现
-AI 模型需要知道哪些工具可用。当前已在 `buildSystemPrompt()` 中列出技能卡片。
-新增工具后,需要在系统提示词中补充使用指南(类似现有的 `ask_user` 指南)。
-
----
-
-## 文件变更预估
-
-| 文件 | 变更 |
-|------|------|
-| `src/pages/assistant.js` | TOOL_DEFS 新增 4 类 · executeTool 新增 case · getEnabledTools 新增分支 · 设置面板 UI · 模型导入弹窗 |
-| `src-tauri/src/commands/assistant.rs` | 新增 Rust 命令:docker_*, wsl_*, ssh_*, web_search, fetch_url |
-| `scripts/dev-api.js` | 新增 Web 模式 handler:同上 |
-| `src/style/assistant.css` | 知识库管理 UI · SSH 连接管理 UI · 导入弹窗样式 |
-| `src/pages/assistant.js` (prompt) | 系统提示词新增各工具使用指南 |
diff --git a/docs/docker-multi-instance-plan.md b/docs/docker-multi-instance-plan.md
deleted file mode 100644
index 0851aa03..00000000
--- a/docs/docker-multi-instance-plan.md
+++ /dev/null
@@ -1,494 +0,0 @@
-# ClawPanel Docker 多实例管理 — 技术规划
-
-> 版本: v1.0 | 日期: 2026-03-08
-
-## 1. 问题分析
-
-### 1.1 现状
-
-ClawPanel 当前架构是 **单实例管理**:
-
-```
-浏览器 → ClawPanel 前端
- │
- ├── /__api/* → dev-api.js → 读写本机 ~/.openclaw/ 文件
- ├── /ws → 代理到本机 Gateway:18789 (WebSocket)
- └── 静态文件 → dist/
-```
-
-**所有页面**(模型配置、Agent 管理、Gateway 设置、日志、聊天等)操作的都是:
-- 本机文件系统上的 `~/.openclaw/openclaw.json`
-- 本机运行的 Gateway 进程(端口 18789)
-
-### 1.2 Phase 1 已完成
-
-Docker 集群页面实现了 **容器生命周期管理**(通过 Docker Socket API):
-- 启动/停止/重启/删除容器
-- 部署新容器(端口映射、数据卷、环境变量)
-- 查看容器日志
-- 多节点管理(本机 + 远程 Docker 主机)
-
-### 1.3 缺口
-
-Docker 页面能管容器的"壳",但 **无法管理容器里的 OpenClaw**:
-- 无法配置某个容器内的模型
-- 无法查看某个容器内的 Gateway 日志
-- 无法管理某个容器内的 Agent
-- 聊天功能只连本机 Gateway
-
----
-
-## 2. 目标架构
-
-### 2.1 核心思路:API 代理 + 实例切换
-
-```
-┌──────────────────────────────────────────────────┐
-│ ClawPanel 前端 │
-│ ┌────────────────────────────────────────────┐ │
-│ │ 实例切换器: [ ● 本机 ▼ ] │ │
-│ │ [ ○ prod-server (Docker) ] │ │
-│ │ [ ○ dev-box (远程) ] │ │
-│ │ [ + 添加实例 ] │ │
-│ └────────────────────────────────────────────┘ │
-│ │
-│ 现有页面(模型/Agent/Gateway/日志/聊天...) │
-│ │ │
-│ │ api.readOpenclawConfig() │
-│ │ api.listAgents() │
-│ ▼ │
-│ tauri-api.js → webInvoke('read_openclaw_config') │
-│ │ │
-│ 自动附带 instanceId │
-└──────────────────┼───────────────────────────────┘
- ▼
- dev-api.js (本机后端)
- │
- ┌────────┼────────┐
- ▼ ▼ ▼
- 本机文件 代理转发 代理转发
- ~/.openclaw ↓ ↓
- 实例 A 实例 B
- http://host http://192.168.1.100
- :18790 :1420
- /__api/* /__api/*
-```
-
-**关键点:每个 Docker 容器运行 full 镜像,内含完整的 ClawPanel (serve.js) + Gateway。**
-因此每个容器已经有自己的 `/__api/*` 端点,我们只需要代理请求过去。
-
-### 2.2 WebSocket 连接
-
-```
-切换实例时:
- wsClient.disconnect() ← 断开旧连接
- wsClient.connect(newHost, newToken) ← 连接新实例的 Gateway
-```
-
-WebSocket 连接信息从目标实例的配置中读取(通过代理 API 获取 `read_openclaw_config`)。
-
-### 2.3 自动组网流程
-
-部署新容器时自动完成:
-
-```
-用户点击「部署容器」
- │
- ├─ 1. Docker API 创建容器(端口映射 hostPort→1420, hostPort→18789)
- ├─ 2. 启动容器,等待健康检查通过
- ├─ 3. 探测容器 Panel 端点:GET http://hostIP:hostPort/__api/check_installation
- ├─ 4. 自动写入实例注册表 ~/.openclaw/instances.json
- └─ 5. 前端自动刷新实例列表
-```
-
----
-
-## 3. 数据结构
-
-### 3.1 实例注册表
-
-文件位置:`~/.openclaw/instances.json`
-
-```json
-{
- "activeId": "local",
- "instances": [
- {
- "id": "local",
- "name": "本机",
- "type": "local",
- "endpoint": null,
- "gatewayPort": 18789,
- "addedAt": 1741420800,
- "note": ""
- },
- {
- "id": "docker-abc123",
- "name": "openclaw-prod",
- "type": "docker",
- "endpoint": "http://127.0.0.1:18790",
- "gatewayPort": 18789,
- "containerId": "abc123def456",
- "nodeId": "local",
- "addedAt": 1741420900,
- "note": "生产环境"
- },
- {
- "id": "remote-1",
- "name": "办公室服务器",
- "type": "remote",
- "endpoint": "http://192.168.1.100:1420",
- "gatewayPort": 18789,
- "addedAt": 1741421000,
- "note": ""
- }
- ]
-}
-```
-
-**三种实例类型:**
-
-| type | 说明 | 来源 |
-|------|------|------|
-| `local` | 本机 OpenClaw | 始终存在,不可删除 |
-| `docker` | Docker 容器内的 OpenClaw | 部署容器时自动注册 |
-| `remote` | 远程服务器上的 OpenClaw | 用户手动添加 |
-
-### 3.2 实例状态(运行时,不持久化)
-
-```js
-{
- id: 'docker-abc123',
- online: true, // 健康检查结果
- version: '2026.3.5', // OpenClaw 版本
- gatewayRunning: true, // Gateway 状态
- lastCheck: 1741420999, // 上次检查时间
-}
-```
-
----
-
-## 4. 改动清单
-
-### 4.1 后端 dev-api.js
-
-#### 4.1.1 实例注册表管理(新增)
-
-```
-新增 handlers:
- instance_list → 读取 instances.json
- instance_add → 添加实例(手动或自动)
- instance_remove → 删除实例
- instance_set_active → 切换活跃实例
- instance_health_check → 健康检查单个实例
- instance_health_all → 批量健康检查
-```
-
-#### 4.1.2 API 代理转发(核心改动)
-
-改造 `_apiMiddleware`:
-
-```js
-// 伪代码
-async function _apiMiddleware(req, res, next) {
- if (!req.url?.startsWith('/__api/')) return next()
-
- const cmd = extractCmd(req.url)
- const body = await readBody(req)
-
- // 实例管理命令 → 始终本机处理
- if (cmd.startsWith('instance_') || cmd.startsWith('docker_') || ALWAYS_LOCAL.has(cmd)) {
- return handleLocally(cmd, body, res)
- }
-
- // 获取当前活跃实例
- const active = getActiveInstance()
-
- if (active.type === 'local') {
- // 本机 → 直接处理(现有逻辑不变)
- return handleLocally(cmd, body, res)
- }
-
- // 远程/Docker 实例 → 代理转发
- return proxyToInstance(active, cmd, body, res)
-}
-```
-
-**始终在本机处理的命令(ALWAYS_LOCAL):**
-- `instance_*` — 实例管理本身
-- `docker_*` — Docker 容器管理
-- `auth_*` — 认证
-- `read_panel_config` / `write_panel_config` — 本地面板配置
-- `assistant_*` — AI 助手(操作本机文件系统)
-
-**通过代理转发的命令:**
-- `read_openclaw_config` / `write_openclaw_config` — 目标实例的配置
-- `get_services_status` / `start_service` / `stop_service` — 目标实例的服务
-- `list_agents` / `add_agent` / `delete_agent` — 目标实例的 Agent
-- `read_log_tail` / `search_log` — 目标实例的日志
-- `get_version_info` / `upgrade_openclaw` — 目标实例的版本
-- `list_memory_files` / `read_memory_file` — 目标实例的记忆文件
-- `read_mcp_config` / `write_mcp_config` — 目标实例的 MCP 配置
-- 等其他 OpenClaw 相关命令
-
-#### 4.1.3 代理转发实现
-
-```js
-async function proxyToInstance(instance, cmd, body, res) {
- const url = `${instance.endpoint}/__api/${cmd}`
- try {
- const resp = await fetch(url, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- })
- const data = await resp.text()
- res.writeHead(resp.status, { 'Content-Type': 'application/json' })
- res.end(data)
- } catch (e) {
- res.writeHead(502, { 'Content-Type': 'application/json' })
- res.end(JSON.stringify({ error: `实例 ${instance.name} 不可达: ${e.message}` }))
- }
-}
-```
-
-#### 4.1.4 Docker 部署自动注册
-
-修改 `docker_create_container` handler:
-- 容器创建并启动后,自动等待健康检查
-- 通过 `GET http://hostIP:panelPort/__api/check_installation` 验证
-- 健康检查通过后自动写入 `instances.json`
-- 返回结果包含 `instanceId`
-
-### 4.2 前端 tauri-api.js
-
-#### 4.2.1 新增实例管理 API
-
-```js
-// 实例管理
-instanceList: () => cachedInvoke('instance_list', {}, 10000),
-instanceAdd: (instance) => { invalidate('instance_list'); return invoke('instance_add', instance) },
-instanceRemove: (id) => { invalidate('instance_list'); return invoke('instance_remove', { id }) },
-instanceSetActive: (id) => { invalidate('instance_list'); _cache.clear(); return invoke('instance_set_active', { id }) },
-instanceHealthCheck: (id) => invoke('instance_health_check', { id }),
-instanceHealthAll: () => invoke('instance_health_all'),
-```
-
-**注意 `instanceSetActive` 清空全部缓存**,因为切换实例后所有缓存数据都过期了。
-
-#### 4.2.2 无需改动的部分
-
-现有的 `api.readOpenclawConfig()`、`api.listAgents()` 等方法 **完全不变**。
-代理逻辑在后端 `_apiMiddleware` 层透明处理。
-
-### 4.3 前端 app-state.js
-
-新增:
-
-```js
-let _activeInstance = { id: 'local', name: '本机', type: 'local' }
-let _instanceListeners = []
-
-export function getActiveInstance() { return _activeInstance }
-export function onInstanceChange(fn) { ... }
-
-export async function switchInstance(id) {
- // 1. 调后端切换
- await api.instanceSetActive(id)
- // 2. 更新本地状态
- _activeInstance = instances.find(i => i.id === id)
- // 3. 清缓存
- invalidate() // 清 API 缓存
- // 4. 断开旧 WebSocket
- wsClient.disconnect()
- // 5. 重新检测状态
- await detectOpenclawStatus()
- // 6. 连接新实例的 Gateway WebSocket
- connectToActiveGateway()
- // 7. 通知所有监听者(侧边栏、页面刷新)
- _instanceListeners.forEach(fn => fn(_activeInstance))
-}
-```
-
-### 4.4 前端 sidebar.js
-
-在侧边栏顶部 logo 下方添加实例切换器:
-
-```html
-
-
-
-
- 本机
-
-
- openclaw-prod
- Docker
-
-
-
- + 添加实例
-
-
-
-```
-
-### 4.5 前端 main.js
-
-`autoConnectWebSocket()` 改为读取当前活跃实例的 Gateway 端点:
-
-```js
-async function autoConnectWebSocket() {
- const instance = getActiveInstance()
- if (instance.type === 'local') {
- // 本机:读本地配置
- const config = await api.readOpenclawConfig()
- const port = config?.gateway?.port || 18789
- wsClient.connect(`127.0.0.1:${port}`, token)
- } else {
- // 远程/Docker:从实例 endpoint 推导 Gateway 地址
- const config = await api.readOpenclawConfig() // 已通过代理转发
- const gwPort = config?.gateway?.port || 18789
- const url = new URL(instance.endpoint)
- wsClient.connect(`${url.hostname}:${instance.gatewayPort || gwPort}`, token)
- }
-}
-```
-
-### 4.6 serve.js WebSocket 代理
-
-WebSocket 代理改为动态目标:
-
-```js
-server.on('upgrade', (req, socket, head) => {
- // 从 query 或 header 中获取目标实例
- const target = resolveWsTarget(req)
- const conn = net.createConnection(target.port, target.host, () => { ... })
-})
-```
-
-### 4.7 docker.js 集群页面
-
-部署对话框增加"自动注册"逻辑:
-- 容器创建成功后显示"正在等待实例就绪..."
-- 健康检查通过后自动出现在实例切换器中
-- 用户可直接切换到新实例进行管理
-
-### 4.8 现有页面适配
-
-| 页面 | 改动 | 说明 |
-|------|------|------|
-| dashboard.js | 极小 | 页头显示当前实例名称 |
-| models.js | 无 | API 透明代理 |
-| agents.js | 无 | API 透明代理 |
-| gateway.js | 极小 | 远程实例时隐藏部分本机功能 |
-| logs.js | 无 | API 透明代理 |
-| chat.js | 无 | WebSocket 已切换到目标实例 |
-| chat-debug.js | 无 | API 透明代理 |
-| memory.js | 无 | API 透明代理 |
-| services.js | 小 | 已有 Docker 适配,远程实例时隐藏 npm/CLI 相关 |
-| extensions.js | 小 | 远程实例时 cftunnel/clawapp 不可用 |
-| skills.js | 无 | API 透明代理 |
-| security.js | 小 | 远程实例的密码管理走代理 |
-| setup.js | 小 | 远程实例不需要 setup 流程 |
-| assistant.js | 特殊 | AI 助手始终操作本机(ALWAYS_LOCAL) |
-
----
-
-## 5. 实施步骤
-
-### Step 1: 实例注册表后端(dev-api.js)
-- `readInstances()` / `saveInstances()` 工具函数
-- 6 个 handler:`instance_list` / `add` / `remove` / `set_active` / `health_check` / `health_all`
-- 预计:~150 行
-
-### Step 2: API 代理转发(dev-api.js)
-- 改造 `_apiMiddleware` 添加代理逻辑
-- `proxyToInstance()` 函数
-- `ALWAYS_LOCAL` 命令集合
-- 预计:~80 行
-
-### Step 3: 前端实例管理 API(tauri-api.js)
-- 新增 `api.instance*` 方法 + mock 数据
-- 预计:~40 行
-
-### Step 4: 前端状态管理(app-state.js)
-- `_activeInstance` 状态 + `switchInstance()` 函数
-- 预计:~50 行
-
-### Step 5: 实例切换器 UI(sidebar.js)
-- 下拉选择器组件 + CSS
-- 预计:~100 行 JS + ~80 行 CSS
-
-### Step 6: WebSocket 动态连接(main.js + serve.js)
-- 切换实例时重新连接 WebSocket
-- serve.js WebSocket 代理动态化
-- 预计:~40 行
-
-### Step 7: Docker 部署自动注册(docker.js + dev-api.js)
-- `docker_create_container` 完成后自动注册
-- 健康检查 + 就绪等待
-- 预计:~60 行
-
-### Step 8: 页面微调
-- dashboard 显示实例名
-- 远程实例时隐藏本机独占功能
-- 预计:~30 行
-
-**总计新增代码:约 600 行**
-
----
-
-## 6. 安全考虑
-
-### 6.1 认证
-- 远程实例可能有不同的访问密码
-- 代理转发时需要携带目标实例的认证凭据
-- 首次连接时提示输入密码,存入 `instances.json`(加密存储待定)
-
-### 6.2 网络安全
-- Docker 容器默认只暴露在宿主机网络
-- 远程实例建议通过 SSH 隧道或 VPN 连接
-- 不建议在公网暴露 `/__api/` 端点而不加密码
-
-### 6.3 权限隔离
-- AI 助手(assistant_*)始终操作本机文件系统,不代理到远程
-- Docker 管理(docker_*)始终操作本机 Docker,不代理
-
----
-
-## 7. 边界与约束
-
-### 7.1 不做的事情
-- **不做** 统一聚合视图(如"查看所有实例的模型列表")
-- **不做** 跨实例数据同步(如"把本机模型配置复制到远程")— 后续可做
-- **不做** 实例间负载均衡
-- **不做** 复杂的权限角色系统
-
-### 7.2 前提条件
-- 远程实例必须运行 ClawPanel(serve.js),版本 >= 0.7.0
-- Docker 实例使用 full 镜像(含 Panel + Gateway)
-- 网络可达(ClawPanel 后端能访问远程实例的端口)
-
-### 7.3 兼容性
-- 现有单实例用户 **零影响**:默认 activeId 为 "local",行为完全不变
-- 实例切换器在只有本机时可以隐藏或最小化显示
-- 所有新功能向后兼容
-
----
-
-## 8. 测试计划
-
-| 场景 | 验证内容 |
-|------|---------|
-| 纯本机使用 | 现有功能不受影响,无回归 |
-| 部署 Docker 容器 | 自动注册为可管理实例 |
-| 切换到 Docker 实例 | 模型/Agent/日志等页面显示容器内数据 |
-| 切换实例后聊天 | WebSocket 连接到正确的 Gateway |
-| 远程实例离线 | 优雅报错,可切回本机 |
-| 删除 Docker 容器 | 实例列表自动移除 |
-| 多实例批量健康检查 | 侧边栏状态点实时更新 |
diff --git a/docs/i18n-plan.md b/docs/i18n-plan.md
deleted file mode 100644
index 2a7cfa4c..00000000
--- a/docs/i18n-plan.md
+++ /dev/null
@@ -1,288 +0,0 @@
-# ClawPanel i18n 国际化方案
-
-> 本文档是 ClawPanel 多语言国际化的完整技术方案和实施指南。
-> 任何后续会话开始 i18n 工作时,请先阅读本文档。
-
-## 一、现状评估
-
-- **硬编码中文行数**:3508 行(分布在 25+ 个 JS 文件中)
-- **预估翻译字符串数**:约 1500+ 个
-- **技术栈**:纯 Vanilla JS(无 React/Vue),Tauri v2 桌面应用
-- **当前语言**:仅中文
-
-### 文件中文行数分布(Top 15)
-
-| 行数 | 文件 | 模块 |
-|------|------|------|
-| 838 | assistant.js | AI 助手(含内嵌知识库) |
-| 312 | docker.js | Docker 集群管理 |
-| 243 | models.js | 模型配置 |
-| 183 | chat.js | 实时聊天 |
-| 156 | chat-debug.js | 系统诊断 |
-| 148 | openclaw-kb.js | 知识库文本 |
-| 142 | setup.js | 初始安装引导 |
-| 136 | channels.js | 消息渠道 |
-| 136 | main.js | 主入口/路由/横幅 |
-| 120 | services.js | 服务管理 |
-| 105 | about.js | 关于页面 |
-| 93 | cron.js | 定时任务 |
-| 88 | dashboard.js | 仪表盘 |
-| 72 | extensions.js | 扩展工具 |
-| 68 | gateway.js | 网关配置 |
-
-## 二、技术架构
-
-### 核心模块:`src/lib/i18n.js`
-
-```js
-// 使用方式
-import { t, setLocale, getLocale } from '../lib/i18n.js'
-
-// 简单翻译
-t('common.save') // → "保存" / "Save"
-t('common.cancel') // → "取消" / "Cancel"
-
-// 带参数插值
-t('chat.messageCount', { count: 5 }) // → "5 条消息" / "5 messages"
-
-// 嵌套 key
-t('dashboard.gateway.running') // → "运行中" / "Running"
-
-// 切换语言
-setLocale('en') // 存 localStorage,触发页面重渲染
-```
-
-### 语言检测优先级
-
-1. `localStorage` 中存储的用户选择 (`clawpanel-locale`)
-2. 浏览器 `navigator.language`(`zh-CN` → `zh-CN`,`en-US` → `en`)
-3. 默认值:`zh-CN`
-
-### 缺失翻译 fallback
-
-1. 查找当前语言包
-2. 查找 `zh-CN` 兜底(中文作为最完整的语言)
-3. 返回 key 本身(如 `common.save`)
-4. 开发模式下 console.warn 提示缺失翻译
-
-## 三、语言包结构
-
-```
-src/locales/
- zh-CN.json — 中文简体(默认,最完整)
- en.json — English
- zh-TW.json — 中文繁体(未来)
- ja.json — 日本語(未来)
- ko.json — 한국어(未来)
-```
-
-### JSON 格式规范
-
-按模块/页面分组,使用扁平化嵌套结构:
-
-```json
-{
- "common": {
- "save": "保存",
- "cancel": "取消",
- "delete": "删除",
- "confirm": "确定",
- "close": "关闭",
- "loading": "加载中...",
- "error": "错误",
- "success": "成功",
- "warning": "警告",
- "retry": "重试",
- "refresh": "刷新",
- "edit": "编辑",
- "create": "创建",
- "back": "返回",
- "next": "下一步",
- "search": "搜索",
- "copy": "复制",
- "download": "下载",
- "upload": "上传",
- "enable": "启用",
- "disable": "禁用",
- "start": "启动",
- "stop": "停止",
- "restart": "重启",
- "status": "状态",
- "running": "运行中",
- "stopped": "已停止",
- "unknown": "未知",
- "noData": "暂无数据",
- "operationFailed": "操作失败: {error}",
- "confirmDelete": "确定删除 {name}?",
- "savedSuccessfully": "已保存"
- },
- "sidebar": {
- "dashboard": "仪表盘",
- "assistant": "晴辰助手",
- "chat": "实时聊天",
- "services": "服务管理",
- "logs": "日志查看",
- "models": "模型配置",
- "agents": "Agent 管理",
- "memory": "记忆文件",
- "channels": "消息渠道",
- "gateway": "网关配置",
- "skills": "Skills 工具",
- "docker": "Docker 集群",
- "cron": "定时任务",
- "extensions": "扩展工具",
- "about": "关于",
- "setup": "初始设置",
- "chatDebug": "系统诊断"
- },
- "dashboard": { ... },
- "chat": { ... },
- "models": { ... },
- ...
-}
-```
-
-## 四、迁移步骤(每个页面)
-
-### Step 1: 提取中文字符串
-
-使用正则或手动扫描,将所有中文文本提取到对应的语言包 key 下。
-
-**需要翻译的内容**:
-- UI 文本(按钮文字、标题、描述、提示)
-- toast 消息
-- 错误消息
-- placeholder 文本
-- confirm 对话框文本
-- tooltip 文本
-
-**不需要翻译的内容**:
-- 代码注释(保持中文)
-- console.log 调试信息
-- 技术标识符(如 `Gateway`、`Agent`、`OpenClaw`)
-- API 错误消息(后端返回的)
-- 知识库内容 `openclaw-kb.js`(这个特殊处理,按语言版本分文件)
-
-### Step 2: 替换代码中的硬编码
-
-```js
-// Before
-toast('保存成功', 'success')
-
-// After
-toast(t('common.savedSuccessfully'), 'success')
-```
-
-```js
-// Before (HTML 模板)
-``
-
-// After
-``
-```
-
-### Step 3: 编写英文翻译
-
-逐 key 翻译到 `en.json`。
-
-### Step 4: 测试
-
-切换语言,检查每个页面的显示是否正常。
-
-## 五、迁移顺序
-
-### 第一批(基础 + 框架层,约 80 个字符串)
-1. `src/lib/i18n.js` — 创建核心模块
-2. `src/locales/zh-CN.json` — 初始化中文包
-3. `src/locales/en.json` — 初始化英文包
-4. `src/components/sidebar.js` — 导航菜单(~20 个)
-5. `src/components/modal.js` — 公共弹窗(~10 个)
-6. `src/components/toast.js` — 提示组件
-7. `src/pages/about.js` — 关于页面 + 语言切换 UI(~30 个)
-
-### 第二批(核心页面,约 250 个字符串)
-8. `src/pages/dashboard.js` — 仪表盘(~50 个)
-9. `src/pages/setup.js` — 初始设置(~80 个)
-10. `src/pages/chat.js` — 实时聊天(~100 个)
-11. `src/main.js` — 主入口/横幅(~20 个)
-
-### 第三批(配置页面,约 350 个字符串)
-12. `src/pages/models.js` — 模型配置(~120 个)
-13. `src/pages/channels.js` — 消息渠道(~80 个)
-14. `src/pages/services.js` — 服务管理(~70 个)
-15. `src/pages/gateway.js` — 网关配置(~40 个)
-16. `src/pages/agents.js` — Agent 管理(~40 个)
-
-### 第四批(功能页面,约 250 个字符串)
-17. `src/pages/cron.js` — 定时任务(~50 个)
-18. `src/pages/memory.js` — 记忆管理(~30 个)
-19. `src/pages/extensions.js` — 扩展工具(~40 个)
-20. `src/pages/logs.js` — 日志查看(~20 个)
-21. `src/pages/skills.js` — Skills 工具(~60 个)
-22. `src/pages/chat-debug.js` — 系统诊断(~50 个)
-
-### 第五批(大型页面 + 特殊处理,约 600 个字符串)
-23. `src/pages/docker.js` — Docker 管理(~150 个)
-24. `src/pages/assistant.js` — AI 助手(~400 个,含系统提示词)
-25. `src/lib/openclaw-kb.js` — 知识库(按语言分文件)
-26. `src/lib/error-diagnosis.js` — 错误诊断(~30 个)
-27. `src/components/engagement.js` — 推荐弹窗(~15 个)
-
-### 第六批(官网 + 文档)
-28. `docs/index.html` — 官网英文版
-29. `README.md` → `README_en.md`
-30. `CONTRIBUTING.md` → `CONTRIBUTING_en.md`
-
-## 六、语言切换 UI 设计
-
-### 位置
-1. **关于页面底部** — 语言选择下拉框
-2. **侧边栏底部** — 语言图标 + 当前语言缩写(如 `中` / `EN`)
-
-### 交互
-- 选择语言 → 存入 localStorage → 页面自动刷新
-- 首次访问自动检测浏览器语言
-
-## 七、注意事项
-
-### 技术品牌词不翻译
-以下词保持原样,不翻译:
-- `OpenClaw`
-- `ClawPanel`
-- `Gateway`
-- `Agent`(Agent 管理不翻译为"代理")
-- `MCP`
-- `Skills`
-- `Docker`
-- `Tauri`
-
-### 参数插值语法
-使用 `{param}` 语法:
-```json
-{
- "chat.sessions": "{count} sessions",
- "models.providers": "Based on {count} providers"
-}
-```
-
-### 复数形式
-英文需要处理复数,但 MVP 阶段可以用简单方式:
-```json
-{
- "chat.messageCount": "{count} message(s)"
-}
-```
-
-### Rust 后端
-后端错误消息暂不国际化(工作量大且用户较少直接看到),保持中文。
-
-## 八、验证清单
-
-每批迁移完成后检查:
-- [ ] 中文模式下所有功能正常
-- [ ] 英文模式下所有功能正常
-- [ ] 语言切换后页面正确刷新
-- [ ] 没有遗漏的硬编码中文
-- [ ] 参数插值正确显示
-- [ ] 长英文文本不溢出布局
-- [ ] toast/modal/confirm 文本正确
diff --git a/docs/plans/2026-03-16-cloudflared-openclaw-integration-design.md b/docs/plans/2026-03-16-cloudflared-openclaw-integration-design.md
deleted file mode 100644
index 3c8d590e..00000000
--- a/docs/plans/2026-03-16-cloudflared-openclaw-integration-design.md
+++ /dev/null
@@ -1,59 +0,0 @@
-# Cloudflared 公网访问与 OpenClaw 兼容导入设计
-
-日期:2026-03-16
-
-## 目标
-- 在 ClawPanel 设置页新增“公网访问(Cloudflared)”Tab
-- 支持快速隧道与命名隧道,进入页面后由用户选择
-- 一键认证登录(允许弹出浏览器完成 cloudflared login)
-- 默认暴露 OpenClaw Gateway(18789)
-- 读取用户已安装 OpenClaw 的 `C:\Users\\.openclaw\openclaw.json`,只读导入并做兼容或升级
-
-## 约束与偏好
-- 不要求用户输入隧道 Token
-- cloudflared.exe 优先 PATH 检测,其次 `~/.openclaw/bin`,不存在则复用 label-printer 的加速域名检测与下载逻辑
-- 不覆盖原始 openclaw.json,仅生成 ClawPanel 本地配置副本
-
-## 方案对比
-### 方案 A(推荐)
-- 内置 Cloudflared 管理器 + 一键登录 + 快速/命名双模式
-- 优点:体验一致,符合“一键认证登录”
-- 风险:需要完整接入本地进程管理与状态监控
-
-### 方案 B
-- 仅提供外部 cloudflared 路径配置
-- 优点:实现快
-- 缺点:不满足“一键认证登录”
-
-### 方案 C
-- A 为主,保留手动路径作为兜底
-- 优点:兼容性强
-- 代价:UI 复杂度增加
-
-推荐:方案 A,保留手动路径兜底入口
-
-## 设计 Section 1:架构与入口
-- 入口:设置页新增“公网访问(Cloudflared)”Tab
-- 核心模块:
- 1) Cloudflared 管理器(检测、下载、启动、停止、状态)
- 2) 隧道管理(快速隧道/命名隧道)
- 3) OpenClaw 配置导入(读取并兼容升级)
-- 数据流:UI → IPC → cloudflared → 状态回传 → UI
-
-## 设计 Section 2:获取与一键认证
-- 检测顺序:PATH → `~/.openclaw/bin/cloudflared.exe` → 下载
-- 下载策略:加速域名检测失败则回退官方下载
-- 一键认证:执行 `cloudflared tunnel login`,浏览器授权完成后保存凭据
-- 失败提示与重试
-
-## 设计 Section 3:隧道类型与运行流程
-- 快速隧道:临时访问
-- 命名隧道:固定域名与服务
-- 流程:选择类型 → 选择服务(默认 18789)→ 启动 → 展示地址/状态
-- 错误处理:启动失败、登录失效、端口占用
-
-## 设计 Section 4:OpenClaw 配置导入
-- 默认读取:`C:\Users\\.openclaw\openclaw.json`
-- 兼容升级:缺失字段补全、旧字段映射
-- 只读导入,不覆盖原配置
-- 失败回退为只读展示
diff --git a/docs/plans/2026-03-16-cron-sessionmessage-tool-ui-design.md b/docs/plans/2026-03-16-cron-sessionmessage-tool-ui-design.md
deleted file mode 100644
index fe98f9dd..00000000
--- a/docs/plans/2026-03-16-cron-sessionmessage-tool-ui-design.md
+++ /dev/null
@@ -1,55 +0,0 @@
-# Cron SessionMessage + Tool UI Design
-
-日期:2026-03-16
-
-## 目标
-- 在 ClawPanel 中新增 cron 任务类型:向指定 session 发送 user 消息
-- 支持等待对话结束(Gateway WebSocket 事件)再发送
-- Chat 页面展示工具调用(默认收起)
-- 适配 npm 全局安装 OpenClaw(补丁应用与更新重打)
-
-## 范围
-- 新增 payload.kind = sessionMessage(Gateway cron 执行分支)
-- ClawPanel cron UI 增加任务类型与字段
-- Chat UI 增加 tool call 展示
-- OpenClaw 版本更新时补丁自动重打
-
-## 方案概述
-### Cron SessionMessage
-- payload.kind: sessionMessage
-- 字段:label, message, role=user, waitForIdle=true
-- Gateway cron 执行:label -> sessionKey -> 等待 chat final -> 发送 user 消息
-
-### UI 改动
-- cron 表单新增任务类型选择
-- session label 下拉(来自 sessions.list)
-- message 文本输入
-- 列表与详情展示任务类型与目标
-
-### Chat 工具调用展示
-- 解析 message.content 中 tool / tool_result
-- 默认收起,仅显示工具名与状态
-- 点击展开显示参数与结果 JSON
-
-### 补丁与更新
-- 定位 npm 全局包路径(npm root -g + 包名)
-- 打补丁前备份原文件
-- 写入 clawpanel.json 记录补丁版本与 OpenClaw 版本
-- 更新后检测版本变化并重打补丁
-
-## 数据流
-- Cron UI -> Gateway cron.add -> payload sessionMessage
-- Gateway cron -> 监听 chat final -> chat.send (role=user)
-- Chat UI -> 渲染 tool call blocks
-
-## 错误处理
-- label 不存在:任务失败并记录错误
-- Gateway 未连接:cron UI 提示不可用
-- 补丁失败:自动回退并提示
-
-## 测试要点
-- cron 创建/编辑/删除
-- sessionMessage 执行成功
-- 等待对话结束后发送
-- tool call 展示与展开
-- 补丁重打与回退
diff --git a/docs/plans/2026-03-16-gateway-patch-auto-detect-design.md b/docs/plans/2026-03-16-gateway-patch-auto-detect-design.md
deleted file mode 100644
index 9dd17941..00000000
--- a/docs/plans/2026-03-16-gateway-patch-auto-detect-design.md
+++ /dev/null
@@ -1,25 +0,0 @@
-# Gateway 补丁自动检测与重打设计
-
-日期:2026-03-16
-
-## 目标
-- 启动与进入设置页自动检测全局 OpenClaw 版本
-- 版本变化时自动重打补丁,失败才提示
-- 过程静默、不中断使用
-
-## 触发策略
-- 应用启动时触发一次检测
-- 进入设置页时触发检测
-
-## 自动重打逻辑
-- 判断 installed_version 与 gatewayPatch.openclawVersion
-- 不一致时调用 gateway_patch_apply(force=true)
-- 使用内存节流与冷却(5 分钟)避免重复触发
-
-## 状态与提示
-- 失败写入 last_error,设置页展示错误
-- 不弹窗、不阻塞其他功能
-
-## 边界处理
-- 缺少 .bak 备份时,force 失败提示“缺少备份,建议先一键补丁”
-- 自动重打失败只提示,不回滚、不影响其他功能
diff --git a/docs/plans/2026-03-16-gateway-patch-oneclick-design.md b/docs/plans/2026-03-16-gateway-patch-oneclick-design.md
deleted file mode 100644
index e265deed..00000000
--- a/docs/plans/2026-03-16-gateway-patch-oneclick-design.md
+++ /dev/null
@@ -1,37 +0,0 @@
-# Gateway 一键补丁设计
-
-日期:2026-03-16
-
-## 目标
-- 在设置页提供一键补丁入口,自动对全局 npm 安装的 OpenClaw 打补丁
-- 支持检测版本、应用补丁、重打补丁、回滚
-
-## 入口与交互
-- 位置:设置页「公网访问」区域旁新增“Gateway 补丁”卡片
-- 按钮:一键补丁、重打补丁、回滚
-- 状态:展示检测到的 OpenClaw 版本、补丁状态、最近操作结果
-
-## 实现流程
-1. 定位全局 npm 根目录:`npm root -g`
-2. 在 `node_modules` 内查找 `openclaw` 包
-3. 自动识别目标文件(reply-*.js / gateway-cli-*.js)
-4. 备份文件(.bak)
-5. 应用补丁(sessionMessage 支持)
-6. 写入 `clawpanel.json` 记录补丁版本与 OpenClaw 版本
-7. 版本变更时提示并支持重打补丁
-8. 失败自动回滚
-
-## 数据与状态
-- `clawpanel.json` 新增:
- - `gatewayPatch`: { version, patchedAt, openclawVersion, files: [] }
-
-## 错误处理
-- 未找到 npm 根目录或包:提示错误
-- 文件名不匹配:提示错误并终止
-- 打补丁失败:回滚并记录错误
-
-## 测试要点
-- 正常补丁流程
-- 回滚流程
-- 版本变化后重打补丁
-- 错误路径处理
diff --git a/docs/plans/2026-03-17-ai-config-import-design.md b/docs/plans/2026-03-17-ai-config-import-design.md
deleted file mode 100644
index 1f4b2b0c..00000000
--- a/docs/plans/2026-03-17-ai-config-import-design.md
+++ /dev/null
@@ -1,37 +0,0 @@
-# AI 配置从 openclaw 导入设计
-
-日期: 2026-03-17
-
-## 目标
-在 AI 配置页提供“从 openclaw 导入”功能,导入模型参数 + API Key + Base URL。
-
-## 方案
-- 采用方案 A:手动按钮触发导入
-
-## 设计细节
-### 1) 导入入口
-- AI 配置页顶部操作区新增按钮:`从 openclaw 导入`
-
-### 2) 导入内容
-- 读取 `openclaw.json`
-- 提取字段:
- - model
- - temperature
- - top_p
- - api_key
- - base_url
-- 写回当前 AI 配置表单并持久化
-
-### 3) 反馈
-- 成功:toast 提示“已导入”
-- 失败:toast 提示读取失败或字段缺失
-
-## 影响范围
-- src/pages/models.js
-- src/lib/tauri-api.js
-- src-tauri/src/commands/config.rs
-
-## 测试要点
-- openclaw.json 有效 → 导入成功
-- 字段缺失 → 失败提示
-- 导入后配置可保存并生效
diff --git a/docs/plans/2026-03-17-assistant-optimize-toggle-design.md b/docs/plans/2026-03-17-assistant-optimize-toggle-design.md
deleted file mode 100644
index 4c65c7a5..00000000
--- a/docs/plans/2026-03-17-assistant-optimize-toggle-design.md
+++ /dev/null
@@ -1,27 +0,0 @@
-# 晴辰助手 优化/还原按钮切换设计
-
-日期: 2026-03-17
-
-## 目标
-优化与还原按钮不同时出现,默认显示“优化”,优化完成后显示“还原”,发送或还原后切回“优化”。
-
-## 方案
-- 采用方案 A:同位置切换显示
-
-## 设计细节
-### 1) UI 结构
-- 保留两个按钮节点
-- 通过状态控制显示
-
-### 2) 状态逻辑
-- `_optOriginalText` 为空:显示“优化”,隐藏“还原”
-- `_optOriginalText` 非空:隐藏“优化”,显示“还原”
-- `clearOptimizeSnapshot()` 或点击还原后切回“优化”
-
-## 影响范围
-- src/pages/assistant.js
-
-## 测试要点
-- 默认仅显示“优化”
-- 优化完成后仅显示“还原”
-- 发送或点击还原后仅显示“优化”
diff --git a/docs/plans/2026-03-17-assistant-ux-and-shell-design.md b/docs/plans/2026-03-17-assistant-ux-and-shell-design.md
deleted file mode 100644
index 9ddb66c3..00000000
--- a/docs/plans/2026-03-17-assistant-ux-and-shell-design.md
+++ /dev/null
@@ -1,43 +0,0 @@
-# Assistant UX + Windows Shell 优化设计
-
-日期: 2026-03-17
-
-## 目标
-1) Windows shell 优先 pwsh,其次 powershell,最后 cmd
-2) 复制按钮样式统一,右上角悬浮显示
-3) AI 助手输入区新增“优化/恢复原文”按钮,保留撤销栈
-
-## 方案
-- 采用方案 B
-
-## 设计细节
-### 1) assistant_exec shell 优先级
-- Windows: pwsh -> powershell -> cmd
-- 执行前检测可用 shell(where / Get-Command)
-- 继续使用 build_system_env() 注入完整系统环境
-
-### 2) 复制按钮错位修复
-- 不改 Markdown 解析
-- chat.css 与 assistant.css 的 pre / code-copy-btn 统一
-- copy 按钮右上角悬浮,hover 显示
-
-### 3) AI 优化按钮
-- 位置: AI 助手输入区,与发送按钮并列
-- 文本模板: “请在不改变原意和语言的前提下,重写为意思更清晰、更简洁的表达。”
-- 点击优化:调用同模型在线重写,替换输入框文本
-- 快照:保存原文快照 + 优化结果快照
-- 恢复原文:发送前始终可用
-- 发送后清空快照
-- 替换方式: setRangeText + input 事件,保留 Ctrl+Z
-
-## 影响范围
-- src-tauri/src/commands/assistant.rs
-- src/style/chat.css
-- src/style/assistant.css
-- src/pages/assistant.js
-- src/lib/tauri-api.js(如需复用 call)
-
-## 测试要点
-- Windows 下优先使用 pwsh
-- 复制按钮在 chat 与 assistant 页面一致
-- 优化/恢复流程与撤销栈可用
diff --git a/docs/plans/2026-03-17-chat-autoscroll-design.md b/docs/plans/2026-03-17-chat-autoscroll-design.md
deleted file mode 100644
index 88b4cf02..00000000
--- a/docs/plans/2026-03-17-chat-autoscroll-design.md
+++ /dev/null
@@ -1,41 +0,0 @@
-# 聊天页面自动滚动行为设计
-
-## 目标
-- 用户上滑查看历史时,不被强制拉回底部
-- 仅在“新消息出现”且用户处于底部时自动滚动
-
-## 现状问题
-当前聊天页存在持续自动滚动到底部的行为,导致用户无法查看上方历史消息,尤其在无新消息时仍被拉回底部。
-
-## 推荐方案(已确认)
-**方案 A:仅在新消息出现且用户在底部时自动滚动**
-- 维护“是否在底部”的状态
-- 当用户离开底部时,自动滚动关闭
-- 仅在新消息插入时、且自动滚动开启,才执行 `scrollToBottom()`
-
-## 关键设计点
-1. **状态管理**
- - `autoScrollEnabled`:用户在底部时为 true;上滑离开底部时为 false
- - 在滚动事件中更新该状态
-
-2. **滚动触发点**
- - 在“消息插入”函数内触发(如 `appendUserMessage` / `appendAiMessage` / `appendSystemMessage` / `createStreamBubble`)
- - 渲染过程中(如 `doRender` / `doVirtualRender`)不再强制滚动
-
-3. **流式输出处理**
- - 若用户不在底部,则流式输出不强制跟随
- - 若用户在底部,则跟随滚动
-
-4. **回到底部按钮**
- - 点击按钮后强制滚动到底部并恢复 `autoScrollEnabled = true`
-
-## 影响范围
-- `src/pages/chat.js`
-- 可能涉及 `chat.css`(若需要视觉提示或按钮行为调整)
-
-## 测试要点
-- 无新消息时,上滑查看历史不被拉回
-- 新消息出现时:
- - 若当前在底部,自动滚动
- - 若已上滑,保持当前位置
-- 点击“回到底部”按钮后恢复自动滚动
diff --git a/docs/plans/2026-03-17-chat-daylight-shadow-design.md b/docs/plans/2026-03-17-chat-daylight-shadow-design.md
deleted file mode 100644
index ce6c77cf..00000000
--- a/docs/plans/2026-03-17-chat-daylight-shadow-design.md
+++ /dev/null
@@ -1,37 +0,0 @@
-# Chat 日间模式助手气泡阴影设计
-
-## 目标
-- 解决日间模式下助手气泡不明显的问题
-- 不改变夜间模式表现
-- 不改变间距、圆角、字体,仅增加轻微阴影
-
-## 背景
-- 当前 `.msg-ai .msg-bubble` 使用半透明背景,在日间模式可能与底色融合,导致难以区分
-- 用户确认仅需补充日间模式阴影
-
-## 方案对比
-### 方案 A(推荐)
-- 仅在日间模式为 `.msg-ai .msg-bubble` 增加轻微阴影
-- 夜间模式保持不变
-- 风险最小,符合当前需求
-
-### 方案 B
-- 日间模式增加阴影 + 细边框
-- 对比更强,但视觉改动更明显
-
-### 方案 C
-- 调整日间模式背景色 + 阴影
-- 可读性提升,但偏离现有视觉体系
-
-## 采用方案
-- 采用方案 A
-
-## 设计细节
-- 选择器:`[data-theme="light"] .msg-ai .msg-bubble`
-- 阴影强度:轻微,不影响布局
-- 不改变背景色、圆角、内边距
-
-## 验收标准
-- 日间模式:助手消息气泡可清晰区分
-- 夜间模式:视觉无变化
-- 不引入新的布局抖动或遮挡问题
diff --git a/docs/plans/2026-03-17-chat-history-tool-merge-design.md b/docs/plans/2026-03-17-chat-history-tool-merge-design.md
deleted file mode 100644
index 5fb4b7f5..00000000
--- a/docs/plans/2026-03-17-chat-history-tool-merge-design.md
+++ /dev/null
@@ -1,30 +0,0 @@
-# 聊天历史工具卡片合并与排序设计
-
-## 目标
-- 历史记录解析后,工具卡片只显示 1 份(按 toolCallId 合并)
-- 工具卡片显示在消息气泡上方
-- 工具时间优先使用事件 ts,缺失回退 message.timestamp
-- 历史模式不再插入工具事件系统消息
-- 历史列表按时间排序,同时间工具在上文本在下
-
-## 现状问题
-- history 刷新后出现重复工具卡片(toolCall/toolResult 未合并)
-- 工具时间缺失导致排序异常
-- 工具事件系统消息与工具卡片同时出现,导致页面被工具卡片占满
-
-## 方案(合并式解析)
-1) 统一合并:工具块解析使用 upsertTool,按 toolCallId 合并
-2) 时间回退:工具时间 = eventTs || message.timestamp || null
-3) 历史禁入系统消息:history 解析不再插入工具事件系统消息
-4) 排序规则:按 time 升序;相同时间时工具卡片在上,文本在下
-
-## 设计细节
-- 新增工具时间解析函数:`resolveToolTime(toolId, messageTimestamp)`
-- 工具事件处理处增加 history 标记,历史模式不调用 `appendSystemMessage`
-- 渲染时将工具卡片与文本作为 entries 统一排序
-
-## 验收标准
-- 历史记录中工具卡片不重复
-- 刷新后工具时间可见
-- 工具卡片位于消息气泡上方
-- 列表不再被工具事件系统消息淹没
diff --git a/docs/plans/2026-03-17-chat-markdown-it-design.md b/docs/plans/2026-03-17-chat-markdown-it-design.md
deleted file mode 100644
index 0cd2dd34..00000000
--- a/docs/plans/2026-03-17-chat-markdown-it-design.md
+++ /dev/null
@@ -1,25 +0,0 @@
-# Chat Markdown-it 样式解析设计
-
-## 目标
-- clawpanel chat 页面支持完整 Markdown 样式:加粗、斜体、删除线、行内代码、代码块、引用、列表、链接、下划线、剧透、@提及
-- 解析行为与 GitHub 接近,安全渲染
-
-## 方案
-- 将 `src/lib/markdown.js` 切换为 `markdown-it` 作为渲染核心
-- 使用插件机制实现:
- - underline:`__text__` 输出 ``
- - spoiler:同时支持 `||spoiler||` 与 `>!spoiler!<`
- - mention:`@用户名` 输出 `@用户名`
-- 代码块高亮沿用现有 `highlightCode`
-- 链接白名单 `http/https/mailto`,否则降级为 `#`
-- 禁止任意 HTML 注入(html=false)
-
-## 样式
-- `.msg-mention` 高亮
-- `.msg-spoiler` 遮罩,点击显示(`revealed` 类切换)
-
-## 验收标准
-- chat 页面渲染包含 E 方案的全部样式
-- `|| ||` 与 `>! !<` 均可正确显示剧透
-- @提及高亮,非链接
-- 不引入 HTML 注入风险
diff --git a/docs/plans/2026-03-17-chat-tool-event-live-design.md b/docs/plans/2026-03-17-chat-tool-event-live-design.md
deleted file mode 100644
index 60ce4782..00000000
--- a/docs/plans/2026-03-17-chat-tool-event-live-design.md
+++ /dev/null
@@ -1,34 +0,0 @@
-# 实时聊天工具事件展示设计
-
-## 目标
-- 工具调用实时显示为独立系统消息
-- 使用 `payload.ts` 作为排序时间
-- 避免重复渲染
-
-## 现状问题
-- 工具事件未实时展示,需要刷新
-- 刷新后可能出现重复/顺序错乱
-
-## 方案
-- 监听 `event: agent` + `stream: tool` 事件
-- 使用 `payload.runId` 与 `toolCallId` 组成唯一键去重
-- 插入消息时按 `payload.ts` 排序
-
-## 设计细节
-### 时间与去重
-- 唯一键:`${payload.ts}:${toolCallId}`
-- 时间来源:`payload.ts`
-
-### 插入策略
-- 所有消息(用户/助手/系统/工具事件)都写入 `data-ts`
-- 插入时按时间从小到大找到位置
-
-### 工具事件消息内容
-- 标题:`工具调用开始/结果 · 工具名`
-- 结果:成功/失败(基于 isError)
-- 仅展示 start 与 result 两类
-
-## 验收标准
-- 工具事件实时出现,无需刷新
-- 不出现重复消息
-- 顺序按时间正确
diff --git a/docs/plans/2026-03-17-chat-virtual-scroll-design.md b/docs/plans/2026-03-17-chat-virtual-scroll-design.md
deleted file mode 100644
index 78425bfa..00000000
--- a/docs/plans/2026-03-17-chat-virtual-scroll-design.md
+++ /dev/null
@@ -1,25 +0,0 @@
-# Chat 虚拟滚动设计
-
-## 目标
-- 滚动流畅度提升
-- 控制渲染 DOM 数量,降低重绘
-
-## 现状
-- 所有消息一次性渲染,滚动卡顿
-
-## 方案
-- 使用简单虚拟列表策略:仅渲染可视区域 + 上下缓冲
-- 滚动时动态替换 DOM
-
-## 设计细节
-- 视窗计算:基于容器 scrollTop + clientHeight
-- 渲染范围:可视范围上下各缓冲 N 条
-- 需要维护消息高度缓存
-
-## 风险
-- DOM 搜索不可见
-- 需要高度缓存避免跳动
-
-## 验收标准
-- 滚动流畅度明显提升
-- 无明显跳动
diff --git a/docs/plans/2026-03-17-chat-virtual-scroll-implementation-design.md b/docs/plans/2026-03-17-chat-virtual-scroll-implementation-design.md
deleted file mode 100644
index eca40d2c..00000000
--- a/docs/plans/2026-03-17-chat-virtual-scroll-implementation-design.md
+++ /dev/null
@@ -1,34 +0,0 @@
-# Chat 虚拟列表实现设计
-
-## 目标
-- 提升首屏渲染速度(优先)
-- 提升滚动流畅度(次优)
-- 固定渲染窗口:40 条 + 上下缓冲 20 条
-- 滚动锚点:底部时自动滚动,否则保持位置
-- 高度策略:预估行高 + 运行时测量校准
-
-## 方案(固定窗口 + 占位高度)
-- 使用容器总高度占位
-- 仅渲染窗口范围消息
-- 运行时测量单条高度并缓存
-- 通过累计高度计算可视范围
-
-## 数据结构
-- items: [{ id, role, text, attachments, tools, timestamp }]
-- heights: Map
-- avgHeight: number (默认 64)
-- range: { start, end }
-
-## 渲染策略
-- 顶部、底部使用占位 div 控制滚动条
-- 每次滚动计算可视范围
-- 渲染 items[start..end]
-
-## 锚点策略
-- 若用户在底部,新增消息时自动滚动到底部
-- 否则保持当前滚动位置
-
-## 验收标准
-- 首屏渲染速度明显提升
-- 长列表滚动不卡顿
-- 新消息到来时遵循锚点策略
diff --git a/docs/plans/2026-03-17-force-setup-design.md b/docs/plans/2026-03-17-force-setup-design.md
deleted file mode 100644
index d747ce86..00000000
--- a/docs/plans/2026-03-17-force-setup-design.md
+++ /dev/null
@@ -1,31 +0,0 @@
-# 强制初始化(forceSetup)设计
-
-日期: 2026-03-17
-
-## 目标
-构建版首次启动可强制进入 /setup,不受已存在配置影响。
-
-## 方案
-- 采用方案 A:在 clawpanel.json 增加 forceSetup 字段
-
-## 设计细节
-### 1) 配置字段
-- `clawpanel.json` 新增:`forceSetup: true/false`
-
-### 2) 启动逻辑
-- 启动时读取 panel config
-- 若 `forceSetup === true`,即使 isOpenclawReady 为 true 也强制跳转 /setup
-
-### 3) 初始化完成后
-- setup 流程成功完成时写入 `forceSetup=false`
-
-## 影响范围
-- src/main.js
-- src/lib/tauri-api.js
-- src/pages/setup.js
-- src-tauri/src/commands/config.rs
-
-## 测试要点
-- forceSetup=true 时进入 /setup
-- 完成初始化后 forceSetup 自动清零
-- forceSetup=false 时恢复原有判断逻辑
diff --git a/docs/plans/2026-03-17-hosted-agent-design.md b/docs/plans/2026-03-17-hosted-agent-design.md
deleted file mode 100644
index 262a63c1..00000000
--- a/docs/plans/2026-03-17-hosted-agent-design.md
+++ /dev/null
@@ -1,131 +0,0 @@
-# 托管 Agent(聊天页)详细设计
-
-> 结论:采用方案一(复用晴辰助手能力)。
-
-## 目标
-- 聊天页发送按钮右侧新增“托管 Agent”入口
-- 托管 Agent 通过 WSS 自动与当前会话 Agent 交互
-- 上下文仅包含:初始提示词 + 与对面 Agent 的交流
-- 继承当前会话工具权限
-- 全局默认 + 会话级启用,切换会话/页面仍生效
-- 输出直接插入当前聊天流并标记来源
-
-## 入口与 UI 交互
-### 入口按钮
-- 位置:`src/pages/chat.js` 内聊天输入区域,发送按钮右侧
-- 交互:点击打开托管 Agent 配置面板
-- 状态:idle / running / waiting_reply / paused / error
-
-### 配置面板
-- 初始提示词(必填)
-- 启用开关
-- 运行模式:对面 Agent 回复后自动继续
-- 停止策略:托管 Agent 自评停止
-- 高级选项:最大步数 / 步间隔 / 重试次数
-- 操作:保存并启用 / 暂停 / 立即停止
-
-### 输出展示
-- 直接插入当前聊天流
-- 格式示例:
- - `[托管 Agent] 下一步指令: ...`
-- 样式区分:弱化颜色 + 标签
-
-## 运行循环与状态机
-### 状态
-- idle / running / waiting_reply / paused / error
-
-### 触发
-- 监听 `wsClient.onEvent`
-- event=chat,state=final,sessionKey=当前会话
-
-### 执行流程
-1. 对面 Agent final 回复到达
-2. 托管 Agent 生成下一步指令
-3. 使用 `wsClient.chatSend` 发送
-4. 进入 waiting_reply
-5. 满足 stopPolicy 或 maxSteps 停止
-
-## 上下文构建
-- 仅包含:初始提示词 + 与对面 Agent 对话
-- 截断策略:按 MAX_CONTEXT_TOKENS 或最近 N 条
-- 不引入其他会话内容
-
-## 数据结构与持久化
-### 全局默认(clawpanel.json)
-```json
-{
- "hostedAgent": {
- "default": {
- "enabled": false,
- "prompt": "",
- "autoRunAfterTarget": true,
- "stopPolicy": "self",
- "maxSteps": 50,
- "stepDelayMs": 1200,
- "retryLimit": 2,
- "toolPolicy": "inherit"
- }
- }
-}
-```
-
-### 会话级(localStorage)
-Key: `clawpanel-hosted-agent-sessions`
-```json
-{
- "agent:main:main": {
- "enabled": true,
- "prompt": "任务目标",
- "autoRunAfterTarget": true,
- "stopPolicy": "self",
- "maxSteps": 50,
- "stepDelayMs": 1200,
- "retryLimit": 2,
- "toolPolicy": "inherit",
- "state": {
- "status": "running",
- "stepCount": 12,
- "lastRunAt": 1710000000000,
- "lastError": ""
- },
- "history": [
- { "role": "system", "content": "初始提示词" },
- { "role": "assistant", "content": "托管 Agent 生成的指令" },
- { "role": "target", "content": "对面 Agent 回复" }
- ]
- }
-}
-```
-
-## assistant-core 抽取清单
-新增:`src/lib/assistant-core.js`
-
-### 抽取项(从 assistant.js)
-- API 适配:OpenAI/Anthropic/Gemini
-- SSE 流解析与重试
-- 系统提示词构建
-- 工具声明、权限过滤、执行与安全检查
-- 上下文裁剪与会话数据工具
-
-### 适配器注入
-- `api.*` 工具桥接
-- `confirm / ask_user` UI 适配器
-- `storage` 适配器
-- 图片存储适配器
-
-### 保留在 assistant.js
-- DOM 渲染与 UI 交互
-- toast/modal
-- 视图与事件绑定
-
-## 风险与保护
-- Gateway 断开:自动暂停
-- 连续失败:触发 error 状态
-- 最大步数:强制停止
-- 避免重复触发:运行中忽略新触发
-
-## 测试要点
-- 启用后自动发送
-- 对面回复后自动继续
-- 切换会话/页面后仍生效
-- 停止策略与最大步数生效
diff --git a/docs/plans/2026-03-17-skillhub-env-fix-design.md b/docs/plans/2026-03-17-skillhub-env-fix-design.md
deleted file mode 100644
index cbe492b3..00000000
--- a/docs/plans/2026-03-17-skillhub-env-fix-design.md
+++ /dev/null
@@ -1,65 +0,0 @@
-# SkillHub 动态探测与系统环境变量继承设计
-
-日期: 2026-03-17
-
-## 背景与目标
-- 现象: 页面提示已安装 SkillHub CLI,但检查仍显示未安装
-- 根因: ClawPanel 进程环境变量未刷新或 PATH 不完整,导致技能检测失败
-- 目标:
- 1) SkillHub CLI 检测支持动态探测路径并返回版本与命中路径
- 2) 所有 Tauri 命令执行时继承完整系统环境变量(用户 + 系统)
-
-## 范围
-- 仅修改 ClawPanel Tauri 后端命令执行环境与 SkillHub 检测逻辑
-- 前端仅增加路径展示(若返回 path)
-
-## 方案对比
-### 方案 A
-- 仅在 SkillHub 检测执行 `where skillhub` 进行路径探测
-- 其他命令仍使用当前进程环境
-- 缺点: 无法解决晴辰助手命令缺少系统环境变量的问题
-
-### 方案 B(推荐)
-- 增加统一系统环境构建函数,合并进程 env + Windows 用户/系统 env
-- 所有 Tauri 命令使用该环境执行
-- SkillHub 检测失败时 `where skillhub` 探测并返回路径
-- 优点: 满足所有需求,改动集中
-
-### 方案 C
-- 增加 envPolicy 配置项(inherit/system/whitelist)
-- 需要新 UI 与配置逻辑
-- 超出当前范围
-
-## 设计细节
-### 1) 系统环境变量合并
-- 新增 `build_system_env()`:
- - 读取当前进程 env
- - 读取注册表:
- - HKCU\Environment
- - HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment
- - 合并优先级: 进程 env 覆盖系统 env
- - PATH 合并去重(用户 + 系统 + 进程)
-
-### 2) SkillHub 动态探测
-- `skills_skillhub_check` 流程:
- 1) 直接尝试 `skillhub --version`
- 2) 失败则执行 `where skillhub`
- 3) 若命中路径,使用该路径执行 `--version`
- 4) 返回 `{ installed: true, version, path }`
-
-### 3) 统一命令执行环境
-- 所有 `tokio::process::Command` 调用统一使用 `cmd.envs(build_system_env())`
-- 现有 `enhanced_path()` 可改为调用 `build_system_env()` 或保留但不再使用
-
-### 4) 前端展示
-- Skills 页面展示安装路径(若后端返回 path)
-- 状态提示保持一致
-
-## 错误处理
-- 注册表读取失败时回退到当前进程 env
-- `where skillhub` 失败仍返回 `installed: false`
-
-## 测试要点
-- PATH 未刷新时: `skills_skillhub_check` 仍能识别已安装
-- 晴辰助手执行命令继承系统变量(如 PATH / HTTP_PROXY)
-- UI 能展示 path 与版本
diff --git a/docs/plans/2026-03-17-toast-night-and-model-select-design.md b/docs/plans/2026-03-17-toast-night-and-model-select-design.md
deleted file mode 100644
index da099118..00000000
--- a/docs/plans/2026-03-17-toast-night-and-model-select-design.md
+++ /dev/null
@@ -1,36 +0,0 @@
-# Toast 夜间样式与 Chat Model Select 设计
-
-## 目标
-- Toast 夜间模式可见性提升,风格参考 Vercel 简约风
-- Chat 模型选择器不截断,宽度自适应内容
-
-## Toast 夜间样式
-### 现状
-- Toast 使用统一背景与边框,夜间模式对比不足
-
-### 方案(Vercel 简约风)
-- 夜间模式下使用更深背景色与清晰边框
-- 保留状态色文字
-
-### 设计细节
-- 选择器:`[data-theme="dark"] .toast`
-- 背景:更深的卡片色(参考 Vercel 夜间卡片风格)
-- 边框:`1px solid var(--border)` 保持一致性
-- 阴影:沿用当前中等阴影
-
-## Chat Model Select
-### 现状
-- `chat-model-select` 文字被截断
-
-### 方案
-- 宽度随内容自适应,不做截断
-
-### 设计细节
-- 选择器:`.chat-model-select` 或其输入容器
-- 移除固定宽度/最大宽度
-- 允许内容自然撑开
-
-## 验收标准
-- 夜间模式下 toast 清晰可见,风格简约
-- chat-model-select 文字不再被截断
-- 不影响其他布局与交互
diff --git a/docs/plans/2026-03-17-toast-shadow-design.md b/docs/plans/2026-03-17-toast-shadow-design.md
deleted file mode 100644
index 2ca9e07f..00000000
--- a/docs/plans/2026-03-17-toast-shadow-design.md
+++ /dev/null
@@ -1,18 +0,0 @@
-# Toast 阴影强度设计
-
-## 目标
-- 为 toast 增加轻微阴影
-- 保持 Vercel 简约风格
-- 日夜主题自动适配
-
-## 方案
-- 采用中等强度阴影:`0 4px 12px rgba(0,0,0,0.12)`
-
-## 设计细节
-- 选择器:`.toast`
-- 在现有背景与边框基础上新增 `box-shadow`
-
-## 验收标准
-- 日间模式下 toast 清晰可见
-- 夜间模式不刺眼
-- 不影响布局与动画
diff --git a/docs/plans/2026-03-17-toast-vercel-design.md b/docs/plans/2026-03-17-toast-vercel-design.md
deleted file mode 100644
index a4baaf5a..00000000
--- a/docs/plans/2026-03-17-toast-vercel-design.md
+++ /dev/null
@@ -1,27 +0,0 @@
-# Toast Vercel 简约风格设计
-
-## 目标
-- 去掉液态玻璃/高斯模糊效果
-- 使用 Vercel 简约风格:纯色卡片 + 细边框
-- 日夜主题自动适配
-
-## 现状
-- `.toast` 使用 `backdrop-filter: blur(12px)`
-- 背景使用 `--success-muted / --error-muted / --info-muted / --warning-muted`
-
-## 方案
-- 移除 `backdrop-filter`
-- 背景统一使用 `var(--bg-primary)`
-- 边框统一使用 `1px solid var(--border)`
-- 文本颜色维持状态色(success/error/info/warning)
-
-## 设计细节
-- 选择器:`.toast`
-- 状态色:保留 `.toast.success/.error/.info/.warning` 的文字颜色
-- 背景色:`var(--bg-primary)`
-- 边框:`1px solid var(--border)`
-
-## 验收标准
-- 日夜模式下 toast 不卡壳、无毛玻璃效果
-- 视觉风格与 Vercel 简约风格一致
-- 状态色可辨识
diff --git a/docs/plans/2026-03-17-tool-call-meta-design.md b/docs/plans/2026-03-17-tool-call-meta-design.md
deleted file mode 100644
index ac32f148..00000000
--- a/docs/plans/2026-03-17-tool-call-meta-design.md
+++ /dev/null
@@ -1,32 +0,0 @@
-# 工具调用信息展示设计
-
-## 目标
-- 工具调用框内显示调用时间(使用工具自身时间戳)
-- 展开后始终显示参数与结果区,即使为空也提示
-
-## 现状问题
-- 工具调用框无时间信息
-- 展开后无数据时区域为空,用户无法确认是否无返回
-
-## 方案(已确认)
-- 方案 A:显示工具自身时间戳 + 参数/结果占位
-
-## 设计细节
-### 时间来源
-- 优先读取:`tool.end_time` / `tool.endTime` / `tool.timestamp` / `tool.time` / `tool.started_at` / `tool.startedAt`
-- 若均为空:使用工具事件流 `event: agent` 的 `payload.ts`(按 `toolCallId` 映射)
-- 仍为空时:显示 `时间未知`
-
-### 展开内容
-- `input` 为空时显示 `无参数`
-- `output` 为空时显示 `无结果`
-- 仍使用 `safeStringify` + `escapeHtml`
-
-### 结构
-- 标题行:`工具名 · 状态 · 时间`
-- 展开块:参数与结果两个区块
-
-## 验收标准
-- 工具调用框内显示时间
-- 展开后始终可见参数/结果区块
-- 无数据时显示占位文案
diff --git a/docs/plans/2026-03-17-ws-connect-bootstrap-design.md b/docs/plans/2026-03-17-ws-connect-bootstrap-design.md
deleted file mode 100644
index 9ed6eef4..00000000
--- a/docs/plans/2026-03-17-ws-connect-bootstrap-design.md
+++ /dev/null
@@ -1,31 +0,0 @@
-# WS 连接后启动请求设计
-
-## 目标
-- 连接成功后一次性发送官方同款 8 个请求
-- ping 间隔改为 5 秒
-- 重连后也执行一次
-
-## 官方请求清单
-- agent.identity.get (sessionKey)
-- agents.list
-- health
-- node.list
-- device.pair.list
-- chat.history (sessionKey, limit=200)
-- sessions.list (includeGlobal/includeUnknown)
-- models.list
-
-## 方案
-- 在 connect 成功处理函数中发送一组 req 帧
-- 每次重连也触发
-- ping 仍按 5 秒周期发送(保持已实现的多请求,启动批次会与首个 ping 重复一次)
-
-## 设计细节
-- 新增 `_sendBootstrapRequests()`
-- 使用 `uuid()` 生成 id
-- sessionKey 使用当前会话 `this._sessionKey`,缺省回退 `agent:full-stack-architect:main`
-
-## 验收标准
-- 每次连接成功后立即发送 8 个 req
-- ping 间隔为 5 秒
-- 控制台无异常
diff --git a/docs/plans/2026-03-17-ws-ping-multi-req-design.md b/docs/plans/2026-03-17-ws-ping-multi-req-design.md
deleted file mode 100644
index 230a7f26..00000000
--- a/docs/plans/2026-03-17-ws-ping-multi-req-design.md
+++ /dev/null
@@ -1,27 +0,0 @@
-# WS Ping 多请求设计
-
-## 目标
-- 每次心跳发送 4 个 req:node.list / models.list / sessions.list / chat.history
-- 保持连接存活并同步关键状态
-
-## 现状
-- 心跳仅发送 node.list
-
-## 方案
-- 在 `_startPing` 中按顺序发送 4 个 req
-- 复用现有 `uuid()` 生成 id
-- sessions.list 参数:`includeGlobal: true`, `includeUnknown: true`
-- chat.history 参数固定 `sessionKey: agent:full-stack-architect:main`, `limit: 200`
-
-## 设计细节
-- 帧格式统一:`{ type: "req", id, method, params }`
-- 在同一计时周期内连续发送 4 条 req
-
-## 风险
-- 请求频率增加,可能带来负载波动
-- 若 Gateway 限流,需要再降频
-
-## 验收标准
-- 每个周期触发 4 条 req
-- 控制台无异常
-- Gateway 正常返回
diff --git a/docs/plans/2026-03-17-ws-ping-node-list-design.md b/docs/plans/2026-03-17-ws-ping-node-list-design.md
deleted file mode 100644
index 593ca549..00000000
--- a/docs/plans/2026-03-17-ws-ping-node-list-design.md
+++ /dev/null
@@ -1,18 +0,0 @@
-# WS Ping 改为 node.list 设计
-
-## 目标
-- 将 WebSocket 心跳从 `{ "type":"ping" }` 改为 `req node.list`
-- 保持连接存活并提供实时节点状态
-
-## 方案
-- 在 `_startPing` 中改为发送 `req` 帧
-- 维持原有间隔
-
-## 设计细节
-- 帧格式:`{ type: "req", id: uuid(), method: "node.list", params: {} }`
-- 仅替换 ping 发送内容
-
-## 验收标准
-- 连接稳定
-- 控制台不报错
-- node.list 有返回
diff --git a/docs/superpowers/plans/2026-03-16-gateway-patch-auto-detect.md b/docs/superpowers/plans/2026-03-16-gateway-patch-auto-detect.md
deleted file mode 100644
index 2d3149fe..00000000
--- a/docs/superpowers/plans/2026-03-16-gateway-patch-auto-detect.md
+++ /dev/null
@@ -1,74 +0,0 @@
-# Gateway Patch Auto-Detect Implementation Plan
-
-> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Auto-detect global OpenClaw version changes at app start and settings entry, then reapply the gateway patch silently; show errors only in settings.
-
-**Architecture:** Add a small patch auto-detect runner in settings (client-side) and app bootstrap (Tauri setup) that calls gateway_patch_status and, on version mismatch, triggers gateway_patch_apply(force=true). Use in-memory throttle and 5-minute cooldown to avoid repeated runs.
-
-**Tech Stack:** Tauri (Rust), Vite/JS, ClawPanel config
-
----
-
-## Chunk 1: Backend status extensions
-
-### Task 1: Extend gateway_patch_status output
-
-**Files:**
-- Modify: `src-tauri/src/commands/gateway_patch.rs`
-
-- [ ] **Step 1: Add version mismatch helper**
-
-Add a helper to compute `needs_repatch` by comparing `installed_version` with stored `gatewayPatch.openclawVersion` and include it in status output if desired.
-
-- [ ] **Step 2: Ensure force apply uses backup detection**
-
-If force=true and backups missing, return a clear error string: "缺少备份,建议先一键补丁".
-
-- [ ] **Step 3: Commit**
-
-```
-git add src-tauri/src/commands/gateway_patch.rs
-
-git commit -m "feat: add patch auto-detect helpers"
-```
-
-## Chunk 2: Frontend auto-detect logic
-
-### Task 2: App startup auto-detect
-
-**Files:**
-- Modify: `src/main.js`
-
-- [ ] **Step 1: Add one-time auto-detect**
-
-Create a lightweight timer guard (module-scoped) and call `api.gatewayPatchStatus()` then `api.gatewayPatchApply(true)` when version mismatch is detected. Cooldown 5 minutes.
-
-- [ ] **Step 2: Commit**
-
-```
-git add src/main.js
-
-git commit -m "feat: auto-detect gateway patch at startup"
-```
-
-### Task 3: Settings page auto-detect
-
-**Files:**
-- Modify: `src/pages/settings.js`
-
-- [ ] **Step 1: Add auto-detect on loadAll**
-
-After `loadGatewayPatch`, run auto-detect handler with a shared cooldown guard; update UI based on result. Do not show toast on success; show error in the card only.
-
-- [ ] **Step 2: Commit**
-
-```
-git add src/pages/settings.js
-
-git commit -m "feat: auto-detect gateway patch in settings"
-```
-
----
-
-Plan complete and saved to `docs/superpowers/plans/2026-03-16-gateway-patch-auto-detect.md`. Ready to execute?
diff --git a/docs/superpowers/plans/2026-03-16-gateway-patch-oneclick.md b/docs/superpowers/plans/2026-03-16-gateway-patch-oneclick.md
deleted file mode 100644
index 7c31cfb4..00000000
--- a/docs/superpowers/plans/2026-03-16-gateway-patch-oneclick.md
+++ /dev/null
@@ -1,169 +0,0 @@
-# Gateway One-Click Patch Implementation Plan
-
-> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Add a one-click gateway patch flow in ClawPanel settings to apply/rollback sessionMessage patch for global npm OpenClaw installs.
-
-**Architecture:** Add a Tauri command that locates global npm OpenClaw, backs up dist files, applies patch, records status in clawpanel.json, and exposes status to the UI. Settings UI uses tauri-api bridge to render current patch status and provide apply/redo/rollback actions.
-
-**Tech Stack:** Tauri (Rust), Vite/JS, ClawPanel config (clawpanel.json)
-
----
-
-## Chunk 1: Backend patch command + config persistence
-
-### Task 1: Add gateway patch command
-
-**Files:**
-- Create: `src-tauri/src/commands/gateway_patch.rs`
-- Modify: `src-tauri/src/commands/mod.rs`
-- Modify: `src-tauri/src/lib.rs`
-
-- [ ] **Step 1: Create gateway_patch.rs skeleton**
-
-```rust
-use serde::{Deserialize, Serialize};
-use tauri::command;
-
-#[derive(Serialize, Deserialize)]
-pub struct GatewayPatchStatus {
- pub installed_version: Option,
- pub patched: bool,
- pub patched_version: Option,
- pub patched_at: Option,
- pub files: Vec,
- pub last_error: Option,
-}
-
-#[command]
-pub async fn gateway_patch_status() -> Result {
- Ok(GatewayPatchStatus {
- installed_version: None,
- patched: false,
- patched_version: None,
- patched_at: None,
- files: vec![],
- last_error: None,
- })
-}
-
-#[command]
-pub async fn gateway_patch_apply() -> Result {
- gateway_patch_status().await
-}
-
-#[command]
-pub async fn gateway_patch_rollback() -> Result {
- gateway_patch_status().await
-}
-```
-
-- [ ] **Step 2: Register commands**
-
-Update `src-tauri/src/commands/mod.rs` to export the new functions, and `src-tauri/src/lib.rs` to include them in `invoke_handler`.
-
-- [ ] **Step 3: Locate global npm root**
-
-Implement helper in `gateway_patch.rs`:
-- Run `npm root -g`
-- Join `openclaw/dist`
-- Detect `reply-*.js` and `gateway-cli-*.js` (choose latest by modified time)
-- Return errors if not found
-
-- [ ] **Step 4: Apply patch with backup**
-
-Implement:
-- Copy `reply-*.js` -> `.bak`
-- Copy `gateway-cli-*.js` -> `.bak`
-- Apply string-replace patches (same patterns used in manual patch)
-- Validate file content contains `sessionMessage` post patch
-
-- [ ] **Step 5: Persist status in clawpanel.json**
-
-Use existing panel config read/write to store:
-
-```json
-"gatewayPatch": {
- "version": "sessionMessage-v1",
- "patchedAt": "",
- "openclawVersion": "",
- "files": ["reply-*.js", "gateway-cli-*.js"],
- "lastError": null
-}
-```
-
-- [ ] **Step 6: Implement rollback**
-
-Restore from `.bak` files and update status.
-
-- [ ] **Step 7: Manual verification**
-
-Run:
-```
-openclaw gateway status
-```
-Expect: Gateway runs normally.
-
-- [ ] **Step 8: Commit**
-
-```
-git add src-tauri/src/commands/gateway_patch.rs src-tauri/src/commands/mod.rs src-tauri/src/lib.rs
-
-git commit -m "feat: add gateway patch commands"
-```
-
-## Chunk 2: UI + API bridge
-
-### Task 2: Add API bridge
-
-**Files:**
-- Modify: `src/lib/tauri-api.js`
-
-- [ ] **Step 1: Add methods**
-
-```js
-export const api = {
- // ...
- gatewayPatchStatus: () => invoke('gateway_patch_status'),
- gatewayPatchApply: () => invoke('gateway_patch_apply'),
- gatewayPatchRollback: () => invoke('gateway_patch_rollback')
-}
-```
-
-- [ ] **Step 2: Commit**
-
-```
-git add src/lib/tauri-api.js
-
-git commit -m "feat: add gateway patch api bridge"
-```
-
-### Task 3: Settings UI
-
-**Files:**
-- Modify: `src/pages/settings.js` (or `src/pages/settings-cloudflared.js` if the setting block lives there)
-- Modify: `src/style/settings.css` (if needed)
-
-- [ ] **Step 1: Add Gateway 补丁卡片**
-- Add status area: installed version, patched state, patched time
-- Buttons: 一键补丁 / 重打补丁 / 回滚
-
-- [ ] **Step 2: Wire buttons**
-- Call `api.gatewayPatchApply()` / `api.gatewayPatchRollback()`
-- Refresh status via `api.gatewayPatchStatus()`
-
-- [ ] **Step 3: Manual verification**
-- Open settings page, ensure card renders
-- Apply patch and verify status changes
-
-- [ ] **Step 4: Commit**
-
-```
-git add src/pages/settings.js src/style/settings.css
-
-git commit -m "feat: add gateway patch ui"
-```
-
----
-
-Plan complete and saved to `docs/superpowers/plans/2026-03-16-gateway-patch-oneclick.md`. Ready to execute?
diff --git a/docs/superpowers/plans/2026-03-17-ai-config-import.md b/docs/superpowers/plans/2026-03-17-ai-config-import.md
deleted file mode 100644
index f7bb8e3a..00000000
--- a/docs/superpowers/plans/2026-03-17-ai-config-import.md
+++ /dev/null
@@ -1,82 +0,0 @@
-# AI 配置从 openclaw 导入 Implementation Plan
-
-> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** 在 AI 配置页增加“从 openclaw 导入”按钮,导入 model/temperature/top_p/api_key/base_url。
-
-**Architecture:** 前端按钮触发调用 Tauri API 读取 openclaw.json 并写回表单配置,完成后持久化保存。
-
-**Tech Stack:** Vanilla JS, Tauri
-
----
-
-## Chunk 1: 后端读取 openclaw 配置
-
-### Task 1: 新增导入命令
-**Files:**
-- Modify: `src-tauri/src/commands/config.rs`
-
-- [ ] **Step 1: 新增 Tauri 命令**
-
-新增 `import_openclaw_ai_config`:
-- 读取 openclaw.json
-- 提取字段:model / temperature / top_p / api_key / base_url
-- 返回 JSON
-
-- [ ] **Step 2: 注册命令**
-
-在 `src-tauri/src/lib.rs` 注册命令。
-
-- [ ] **Step 3: 提交**
-```bash
-git add src-tauri/src/commands/config.rs src-tauri/src/lib.rs
-git commit -m "feat: add ai config import command"
-```
-
----
-
-## Chunk 2: 前端按钮与写回
-
-### Task 2: AI 配置页导入
-**Files:**
-- Modify: `src/lib/tauri-api.js`
-- Modify: `src/pages/models.js`
-
-- [ ] **Step 1: 添加 API 封装**
-
-tauri-api.js 增加 `importOpenclawAiConfig`.
-
-- [ ] **Step 2: UI 按钮与写回逻辑**
-
-models.js 增加按钮,点击后:
-- 调用 API
-- 写回表单
-- 保存当前配置
-
-- [ ] **Step 3: 提交**
-```bash
-git add src/lib/tauri-api.js src/pages/models.js
-git commit -m "feat: import ai config from openclaw"
-```
-
----
-
-## Chunk 3: 构建与验证
-
-### Task 3: 构建
-**Files:** 无
-
-- [ ] **Step 1: 构建**
-```bash
-npm run build
-```
-
-- [ ] **Step 2: 手工验证**
-- openclaw.json 存在 → 导入成功
-- 字段缺失 → 提示失败
-- 保存后配置生效
-
-- [ ] **Step 3: 推送**
-```bash
-git push
-```
diff --git a/docs/superpowers/plans/2026-03-17-assistant-optimize-toggle.md b/docs/superpowers/plans/2026-03-17-assistant-optimize-toggle.md
deleted file mode 100644
index d0d38ed0..00000000
--- a/docs/superpowers/plans/2026-03-17-assistant-optimize-toggle.md
+++ /dev/null
@@ -1,52 +0,0 @@
-# 优化/还原按钮切换 Implementation Plan
-
-> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** 优化与还原按钮互斥显示,默认显示“优化”,优化后显示“还原”,发送或还原后切回“优化”。
-
-**Architecture:** 根据 `_optOriginalText` 状态控制按钮显隐与禁用。
-
-**Tech Stack:** Vanilla JS
-
----
-
-## Chunk 1: 逻辑调整
-
-### Task 1: updateOptimizeState 互斥显示
-**Files:**
-- Modify: `src/pages/assistant.js`
-
-- [ ] **Step 1: 更新 updateOptimizeState**
-
-逻辑:
-- `_optOriginalText` 为 null → 显示优化按钮,隐藏还原按钮
-- `_optOriginalText` 非空 → 隐藏优化按钮,显示还原按钮
-
-- [ ] **Step 2: 按钮点击后切换**
-
-- 优化完成 → 切到还原
-- 点击还原 → 清空快照并切回优化
-- 发送 → 清空快照并切回优化
-
-- [ ] **Step 3: 提交**
-```bash
-git add src/pages/assistant.js
-git commit -m "fix: toggle optimize and restore buttons"
-```
-
----
-
-## Chunk 2: 构建与推送
-
-### Task 2: 构建
-**Files:** 无
-
-- [ ] **Step 1: 构建**
-```bash
-npm run build
-```
-
-- [ ] **Step 2: 推送**
-```bash
-git push
-```
diff --git a/docs/superpowers/plans/2026-03-17-assistant-ux-and-shell.md b/docs/superpowers/plans/2026-03-17-assistant-ux-and-shell.md
deleted file mode 100644
index 1946944a..00000000
--- a/docs/superpowers/plans/2026-03-17-assistant-ux-and-shell.md
+++ /dev/null
@@ -1,118 +0,0 @@
-# Assistant UX + Windows Shell 优化 Implementation Plan
-
-> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** 实现 Windows shell 优先级、复制按钮样式统一、AI 助手输入区新增优化/恢复功能并保留撤销栈。
-
-**Architecture:** 后端 assistant_exec 增加 shell 探测与降级;前端统一 code-copy-btn CSS;AI 助手输入区新增优化调用与快照管理。
-
-**Tech Stack:** Rust (Tauri), Vanilla JS, CSS
-
----
-
-## Chunk 1: Windows shell 优先级
-
-### Task 1: assistant_exec 使用 pwsh 优先级
-**Files:**
-- Modify: `src-tauri/src/commands/assistant.rs`
-
-- [ ] **Step 1: 增加 shell 探测函数**
-
-在 Windows 分支增加一个 `detect_windows_shell()`:
-- 依次检查 `pwsh`、`powershell`
-- 都不可用则返回 `cmd`
-
-实现方式:使用 `where` 探测,并使用 `build_system_env()` 注入完整环境。
-
-- [ ] **Step 2: 替换执行逻辑**
-
-`assistant_exec` 使用探测结果执行:
-- pwsh / powershell:`-NoProfile -Command `
-- cmd:`/c `
-
-- [ ] **Step 3: 提交**
-```bash
-git add src-tauri/src/commands/assistant.rs
-git commit -m "feat: prefer pwsh in assistant exec"
-```
-
----
-
-## Chunk 2: 复制按钮错位修复(CSS)
-
-### Task 2: 统一 chat.css 与 assistant.css
-**Files:**
-- Modify: `src/style/chat.css`
-- Modify: `src/style/assistant.css`
-
-- [ ] **Step 1: 统一 pre 与 copy 按钮样式**
-
-将 assistant.css 中 pre 样式改为与 chat.css 一致,确保:
-- `.code-copy-btn` 右上角悬浮
-- hover 才显示
-- 不改 Markdown 解析逻辑
-
-- [ ] **Step 2: 提交**
-```bash
-git add src/style/chat.css src/style/assistant.css
-git commit -m "fix: align code copy button styles"
-```
-
----
-
-## Chunk 3: AI 优化按钮
-
-### Task 3: 输入区新增优化/恢复按钮
-**Files:**
-- Modify: `src/pages/assistant.js`
-
-- [ ] **Step 1: 增加按钮 DOM**
-
-在输入区加入 “优化” 与 “恢复原文” 按钮。
-
-- [ ] **Step 2: 维护快照状态**
-
-新增变量:`_optOriginalText` / `_optOptimizedText`
-规则:
-- 点击优化:保存原文快照,写入优化结果快照
-- 点击恢复:恢复原文
-- 发送成功后清空快照
-
-- [ ] **Step 3: 调用同模型在线重写**
-
-复用现有模型调用逻辑:
-- 模板:`请在不改变原意和语言的前提下,重写为意思更清晰、更简洁的表达。`
-- 使用同模型
-- 结果直接替换输入框内容
-
-- [ ] **Step 4: setRangeText 触发 input 事件**
-
-使用 `textarea.setRangeText()` + 触发 `input` 事件,保证 Ctrl+Z 生效。
-
-- [ ] **Step 5: 提交**
-```bash
-git add src/pages/assistant.js
-git commit -m "feat: add optimize and restore buttons"
-```
-
----
-
-## Chunk 4: 构建与验证
-
-### Task 4: 构建
-**Files:** 无
-
-- [ ] **Step 1: 构建**
-```bash
-npm run build
-```
-
-- [ ] **Step 2: 手工验证**
-- Windows 下执行命令优先 pwsh
-- 复制按钮位置正确
-- 优化/恢复可用且 Ctrl+Z 正常
-
-- [ ] **Step 3: 推送**
-```bash
-git push
-```
diff --git a/docs/superpowers/plans/2026-03-17-chat-autoscroll.md b/docs/superpowers/plans/2026-03-17-chat-autoscroll.md
deleted file mode 100644
index 5a0a5399..00000000
--- a/docs/superpowers/plans/2026-03-17-chat-autoscroll.md
+++ /dev/null
@@ -1,132 +0,0 @@
-# Chat Auto-Scroll Gating Implementation Plan
-
-> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Only auto-scroll the chat view when new messages arrive and the user is at the bottom; never force-scroll while the user is reading history.
-
-**Architecture:** Add a single auto-scroll gate to `chat.js` that tracks whether the user is at the bottom. Trigger scrolling only on message insertion and stream updates when the gate is enabled. Avoid auto-scroll in render loops to prevent continuous snapping.
-
-**Tech Stack:** Vanilla JS, DOM APIs, existing chat page logic in `src/pages/chat.js`.
-
----
-
-## Chunk 1: Auto-scroll gating
-
-### Task 1: Add auto-scroll state and update on scroll
-
-**Files:**
-- Modify: `src/pages/chat.js`
-
-- [ ] **Step 1: Add state flags near other module-level state**
-
-Add:
-```js
-let _autoScrollEnabled = true
-```
-
-- [ ] **Step 2: Update auto-scroll flag on scroll**
-
-In the `_messagesEl.addEventListener('scroll', ...)` handler, update state based on `isAtBottom()`:
-```js
-_messagesEl.addEventListener('scroll', () => {
- const { scrollTop, scrollHeight, clientHeight } = _messagesEl
- _scrollBtn.style.display = (scrollHeight - scrollTop - clientHeight < 80) ? 'none' : 'flex'
- _autoScrollEnabled = isAtBottom()
-})
-```
-
-- [ ] **Step 3: Ensure scroll button restores auto-scroll**
-
-When the user clicks the scroll-to-bottom button, set `_autoScrollEnabled = true` after moving to bottom:
-```js
-_scrollBtn.addEventListener('click', () => {
- _autoScrollEnabled = true
- scrollToBottom(true)
-})
-```
-
-### Task 2: Gate auto-scroll to message insertion
-
-**Files:**
-- Modify: `src/pages/chat.js`
-
-- [ ] **Step 1: Update `scrollToBottom` to respect gating**
-
-Change function signature and behavior:
-```js
-function scrollToBottom(force = false) {
- if (!_messagesEl) return
- if (!force && !_autoScrollEnabled) return
- requestAnimationFrame(() => { _messagesEl.scrollTop = _messagesEl.scrollHeight })
-}
-```
-
-- [ ] **Step 2: Ensure scroll is invoked only on new message insertion**
-
-Keep `scrollToBottom()` calls only in:
-- `appendUserMessage`
-- `appendAiMessage`
-- `appendSystemMessage`
-- `createStreamBubble`
-- `showTyping(true)` (but it will now respect gate)
-
-Remove or avoid unconditional scrolling in render loops.
-
-- [ ] **Step 3: Stop continuous auto-scroll in render loops**
-
-In `doRender` remove the unconditional `scrollToBottom()` or guard it by auto-scroll:
-```js
-if (_currentAiBubble && _currentAiText) {
- _currentAiBubble.innerHTML = renderMarkdown(_currentAiText)
- scrollToBottom()
-}
-```
-(With gated `scrollToBottom`, it will only happen when user is at bottom.)
-
-- [ ] **Step 4: Guard virtual render bottom snapping**
-
-In `doVirtualRender`, only snap to bottom when `_autoScrollEnabled` is true:
-```js
-if (atBottom && _autoScrollEnabled) {
- scrollToBottom()
-}
-```
-
-### Task 3: Validate streaming behavior
-
-**Files:**
-- Modify: `src/pages/chat.js`
-
-- [ ] **Step 1: Ensure stream updates do not force-scroll while reading history**
-
-Verify `doRender` and `createStreamBubble` only scroll when `_autoScrollEnabled` is true.
-
-### Task 4: Manual verification and build
-
-**Files:**
-- None
-
-- [ ] **Step 1: Manual verification checklist**
-
-Checklist:
-- Open chat page with existing history
-- Scroll up; verify the view stays in place (no automatic snapping)
-- Send a new message while scrolled up; verify it does not force-scroll
-- Scroll to bottom and send/receive a message; verify it auto-scrolls
-- Click the scroll-to-bottom button; verify it jumps and re-enables auto-scroll
-
-- [ ] **Step 2: Build**
-
-Run:
-```powershell
-npm run build
-```
-Expected: build succeeds with no errors.
-
-- [ ] **Step 3: Commit**
-
-```powershell
-git add src\pages\chat.js
-
-git commit -m "fix: gate chat auto-scroll on new messages"
-```
diff --git a/docs/superpowers/plans/2026-03-17-chat-daylight-shadow.md b/docs/superpowers/plans/2026-03-17-chat-daylight-shadow.md
deleted file mode 100644
index 92195288..00000000
--- a/docs/superpowers/plans/2026-03-17-chat-daylight-shadow.md
+++ /dev/null
@@ -1,46 +0,0 @@
-# Chat Daylight Shadow Implementation Plan
-
-> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Add a light-mode shadow to assistant message bubbles so they remain visible against the page background.
-
-**Architecture:** Update chat CSS to apply a light-mode-only shadow on `.msg-ai .msg-bubble` without changing dark mode. No layout or markup changes.
-
-**Tech Stack:** CSS, Vite build
-
----
-
-## Chunk 1: Daylight shadow style
-
-### Task 1: Add light-mode shadow for assistant bubbles
-
-**Files:**
-- Modify: `src/style/chat.css` (near `.msg-ai .msg-bubble` rules)
-
-- [ ] **Step 1: Add light-mode CSS rule**
-
-Add a new rule scoped to light theme:
-
-```css
-[data-theme="light"] .msg-ai .msg-bubble {
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
-}
-```
-
-- [ ] **Step 2: Build**
-
-Run: `npm run build`
-Expected: Build succeeds without errors.
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add src/style/chat.css
-git commit -m "fix: add daylight shadow for ai bubble"
-```
-
-- [ ] **Step 4: Push**
-
-```bash
-git push
-```
diff --git a/docs/superpowers/plans/2026-03-17-chat-history-tool-merge.md b/docs/superpowers/plans/2026-03-17-chat-history-tool-merge.md
deleted file mode 100644
index ded067e3..00000000
--- a/docs/superpowers/plans/2026-03-17-chat-history-tool-merge.md
+++ /dev/null
@@ -1,81 +0,0 @@
-# Chat History Tool Merge Implementation Plan
-
-> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Fix chat history parsing so tool cards merge by toolCallId, show above text, and sort by time with correct fallback.
-
-**Architecture:** Merge tool blocks via upsertTool, resolve tool time from event ts or message timestamp, and suppress tool system messages during history parsing. Render tools and text as entries sorted by time (tools first when time ties).
-
-**Tech Stack:** React, JS
-
----
-
-## Chunk 1: Parsing + ordering adjustments
-
-### Task 1: Add time resolver + merge tool entries
-
-**Files:**
-- Modify: `src/pages/chat.js`
-
-- [ ] **Step 0: Checkpoint(PowerShell)**
-
-```powershell
-git status -sb
-git commit --allow-empty -m "chore: checkpoint before history tool merge"
-```
-
-- [ ] **Step 1: Add tool time resolver**
-
-Add helper near tool utilities:
-
-```js
-function resolveToolTime(toolId, messageTimestamp) {
- const eventTs = toolId ? _toolEventTimes.get(toolId) : null
- return eventTs || messageTimestamp || null
-}
-```
-
-- [ ] **Step 2: Use upsertTool in history parsing**
-
-In `extractChatContent` and `extractContent`, replace `tools.push` with `upsertTool` for toolCall/toolResult blocks so toolCallId merges.
-
-- [ ] **Step 3: Apply time fallback to tools**
-
-When building tool entries, set `time: resolveToolTime(id, message.timestamp)`.
-
-- [ ] **Step 4: Suppress tool system messages in history**
-
-When processing history responses, skip `appendSystemMessage` for tool events, only render tool cards.
-
-- [ ] **Step 5: Sort entries by time with tool-first tie**
-
-In rendering pipeline, build `entries` combining tools and text, then sort:
-
-```js
-entries.sort((a, b) => {
- const ta = a.time ?? 0
- const tb = b.time ?? 0
- if (ta !== tb) return ta - tb
- if (a.kind === 'tool' && b.kind !== 'tool') return -1
- if (a.kind !== 'tool' && b.kind === 'tool') return 1
- return 0
-})
-```
-
-- [ ] **Step 6: Build**
-
-Run: `npm run build`
-Expected: Build succeeds without errors.
-
-- [ ] **Step 7: Commit**
-
-```powershell
-git add src\pages\chat.js
-git commit -m "fix: history tool merge and ordering"
-```
-
-- [ ] **Step 8: Push**
-
-```powershell
-git push
-```
diff --git a/docs/superpowers/plans/2026-03-17-chat-markdown-it.md b/docs/superpowers/plans/2026-03-17-chat-markdown-it.md
deleted file mode 100644
index 2256a8de..00000000
--- a/docs/superpowers/plans/2026-03-17-chat-markdown-it.md
+++ /dev/null
@@ -1,146 +0,0 @@
-# Chat Markdown-it Implementation Plan
-
-> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Replace the chat Markdown renderer with markdown-it plus plugins to support underline, spoiler, and mention, matching GitHub-like behavior.
-
-**Architecture:** Use markdown-it with html disabled, link validation, custom renderer for code blocks using existing highlightCode. Add local plugins for spoiler (|| || + >! !<), mention (@user), and a custom underline rule that maps __text__ to .
-
-**Tech Stack:** JS, Vite
-
----
-
-## Chunk 1: Dependencies + renderer refactor
-
-### Task 1: Add markdown-it and plugin deps
-
-**Files:**
-- Modify: `package.json`
-
-- [ ] **Step 0: Checkpoint(PowerShell)**
-
-```powershell
-git status -sb
-git commit --allow-empty -m "chore: checkpoint before markdown-it"
-```
-
-- [ ] **Step 1: Add dependencies**
-
-Add to dependencies:
-- markdown-it
-
-- [ ] **Step 2: Install**
-
-```powershell
-npm install
-```
-
-- [ ] **Step 3: Commit deps**
-
-```powershell
-git add package.json package-lock.json
-git commit -m "chore: add markdown-it deps"
-```
-
-### Task 2: Replace renderer
-
-**Files:**
-- Modify: `src/lib/markdown.js`
-
-- [ ] **Step 1: Instantiate markdown-it**
-
-```js
-import MarkdownIt from 'markdown-it'
-```
-
-```js
-const md = new MarkdownIt({
- html: false,
- linkify: true,
- breaks: false,
- highlight: (code, lang) => {
- const highlighted = highlightCode(code, lang)
- const langLabel = lang ? `${escapeHtml(lang)}` : ''
- return `${langLabel}${highlighted}`
- },
-})
-```
-
-- [ ] **Step 1.1: Underline plugin (`__text__` -> ``)**
-
-Implement a custom inline rule to parse double-underscore into `` and keep `**` for bold.
-
-- [ ] **Step 2: Link whitelist**
-
-Override link renderer to allow only http/https/mailto, otherwise href="#".
-
-- [ ] **Step 3: Spoiler plugin**
-
-Implement custom plugin to parse:
-- `||spoiler||`
-- `>!spoiler!<`
-Output: `...`
-
-- [ ] **Step 4: Mention plugin**
-
-Parse `@username` into `@username`.
-
-- [ ] **Step 5: Update renderMarkdown**
-
-Replace manual parsing with `md.render(text)`.
-
-## Chunk 2: Styling
-
-### Task 3: Add styles
-
-**Files:**
-- Modify: `src/style/chat.css` or `src/style/components.css`
-
-- [ ] **Step 1: Add mention style**
-
-```css
-.msg-mention {
- color: var(--accent);
- font-weight: 600;
-}
-```
-
-- [ ] **Step 2: Add spoiler style**
-
-```css
-.msg-spoiler {
- background: currentColor;
- color: transparent;
- border-radius: 4px;
- padding: 0 4px;
- cursor: pointer;
-}
-.msg-spoiler.revealed {
- color: inherit;
- background: rgba(0,0,0,0.12);
-}
-```
-
-- [ ] **Step 3: Add click handler**
-
-In chat init or render, add event delegation to toggle `revealed` class on `.msg-spoiler`.
-
-## Chunk 3: Build + Commit
-
-- [ ] **Step 1: Build**
-
-Run: `npm run build`
-Expected: Build succeeds without errors.
-
-- [ ] **Step 2: Commit**
-
-```powershell
-git add src\lib\markdown.js src\style\chat.css src\style\components.css
-git commit -m "feat: markdown-it rendering"
-```
-
-- [ ] **Step 3: Push**
-
-```powershell
-git push
-```
diff --git a/docs/superpowers/plans/2026-03-17-chat-tool-event-live.md b/docs/superpowers/plans/2026-03-17-chat-tool-event-live.md
deleted file mode 100644
index 0127906e..00000000
--- a/docs/superpowers/plans/2026-03-17-chat-tool-event-live.md
+++ /dev/null
@@ -1,97 +0,0 @@
-# Chat Tool Event Live Implementation Plan
-
-> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Display tool events as live system messages ordered by payload.ts and de-duplicated by payload.runId + toolCallId.
-
-**Architecture:** Extend chat event handling to create tool-event system messages and insert into DOM by timestamp; add dedupe map keyed by ts+toolCallId.
-
-**Tech Stack:** Vanilla JS, CSS, Vite build
-
----
-
-## Chunk 1: Tool event live insertion
-
-### Task 1: Add dedupe and insert-by-time
-
-**Files:**
-- Modify: `src/pages/chat.js`
-
-- [ ] **Step 0: Checkpoint(PowerShell)**
-
-```powershell
-git status -sb
-git commit --allow-empty -m "chore: checkpoint before tool event live"
-```
-
-Note: This checkpoint is required by policy; final functional commit occurs after build.
-
-- [ ] **Step 1: Add maps for dedupe and event list**
-
-Add near top-level state:
-
-```js
-const _toolEventSeen = new Set()
-```
-
-- [ ] **Step 2: Insert helper for ordered messages**
-
-Add a helper to insert a message wrapper by timestamp:
-
-```js
-function insertMessageByTime(wrap, ts) {
- const tsValue = Number(ts || Date.now())
- wrap.dataset.ts = String(tsValue)
- const items = Array.from(_messagesEl.querySelectorAll('.msg'))
- for (const node of items) {
- const nodeTs = parseInt(node.dataset.ts || '0', 10)
- if (nodeTs > tsValue) {
- _messagesEl.insertBefore(wrap, node)
- return
- }
- }
- _messagesEl.insertBefore(wrap, _typingEl)
-}
-```
-
-- [ ] **Step 3: Add tool-event system message builder**
-
-```js
-function appendToolEventMessage(name, phase, ts, isError) {
- const wrap = document.createElement('div')
- wrap.className = 'msg msg-system'
- wrap.textContent = `${name} · ${phase}${isError ? ' · 失败' : ''}`
- insertMessageByTime(wrap, ts)
-}
-```
-
-- [ ] **Step 4: Handle tool events in handleEvent**
-
-```js
-if (event === 'agent' && payload?.stream === 'tool' && payload?.data?.toolCallId) {
- const key = `${payload.runId}:${payload.data.toolCallId}`
- if (_toolEventSeen.has(key)) return
- _toolEventSeen.add(key)
- const name = payload.data.name || '工具'
- const phase = payload.data.phase || 'unknown'
- appendToolEventMessage(name, phase, payload.ts, payload.data.isError)
-}
-```
-
-- [ ] **Step 5: Build**
-
-Run: `npm run build`
-Expected: Build succeeds without errors.
-
-- [ ] **Step 6: Commit(PowerShell)**
-
-```powershell
-git add src/pages/chat.js
-git commit -m "fix: show live tool events"
-```
-
-- [ ] **Step 7: Push(PowerShell)**
-
-```powershell
-git push
-```
diff --git a/docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md b/docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md
deleted file mode 100644
index 57c39dc0..00000000
--- a/docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md
+++ /dev/null
@@ -1,211 +0,0 @@
-# Chat Virtual Scroll Implementation Plan
-
-> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Implement virtual scrolling for chat messages with fixed window size (40 + overscan 20), fast first paint, and stable scroll anchoring.
-
-**Architecture:** Use a virtualized list with top/bottom spacers and total height based on cumulative measured heights (fallback to average). Range calculation uses cumulative heights (prefix sums + binary search) with a fixed window cap. Preserve anchor when not at bottom.
-
-**Tech Stack:** JS, Vite
-
----
-
-## File Map
-- Modify: `src/pages/chat.js:64-2000` (virtual state, scroll handler, render path)
-- Create: `src/lib/virtual-scroll.js` (range + prefix height helpers)
-- Create: `tests/virtual-scroll.test.js`
-- Modify: `package.json` (test script, devDependency)
-
----
-
-## Chunk 1: Test scaffolding + helpers (TDD)
-
-### Task 1: Add test tooling
-
-**Files:**
-- Modify: `package.json`
-
-- [ ] **Step 0: Checkpoint(PowerShell)**
-
-```powershell
-git status -sb
-git commit --allow-empty -m "chore: checkpoint before chat virtual scroll"
-```
-
-- [ ] **Step 1: Add dev dependency and script**
-
-Add:
-- `devDependencies.vitest`
-- `scripts.test = "vitest run"`
-
-- [ ] **Step 2: Install**
-
-```powershell
-npm install
-```
-
-### Task 2: Create helper module
-
-**Files:**
-- Create: `src/lib/virtual-scroll.js`
-
-- [ ] **Step 1: Implement helpers**
-
-```js
-export function getItemHeight(items, idx, heights, avgHeight) {
- const id = items[idx]?.id
- return heights.get(id) || avgHeight
-}
-
-export function buildPrefixHeights(items, heights, avgHeight) {
- const prefix = [0]
- for (let i = 0; i < items.length; i++) {
- prefix[i + 1] = prefix[i] + getItemHeight(items, i, heights, avgHeight)
- }
- return prefix
-}
-
-export function findStartIndex(prefix, scrollTop) {
- let lo = 0, hi = prefix.length - 1
- while (lo < hi) {
- const mid = Math.floor((lo + hi) / 2)
- if (prefix[mid] <= scrollTop) lo = mid + 1
- else hi = mid
- }
- return Math.max(0, lo - 1)
-}
-
-export function computeVirtualRange(items, scrollTop, viewportHeight, avgHeight, overscan, windowSize, heights) {
- const prefix = buildPrefixHeights(items, heights, avgHeight)
- const start = Math.max(0, findStartIndex(prefix, scrollTop) - overscan)
- let end = Math.min(items.length, start + windowSize + overscan * 2)
- // 固定窗口:严格限制 end-start 不超过 windowSize + overscan*2
- return { start, end, prefix }
-}
-
-export function getSpacerHeights(prefix, start, end) {
- const top = prefix[start]
- const total = prefix[prefix.length - 1]
- const bottom = Math.max(0, total - prefix[end])
- return { top, bottom, total }
-}
-```
-
-### Task 3: Add tests (TDD)
-
-**Files:**
-- Create: `tests/virtual-scroll.test.js`
-
-- [ ] **Step 1: Write failing tests**
-
-```js
-import { describe, it, expect } from 'vitest'
-import { buildPrefixHeights, computeVirtualRange, getSpacerHeights } from '../src/lib/virtual-scroll.js'
-
-describe('virtual scroll helpers', () => {
- it('builds prefix heights with avg fallback', () => {
- const items = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]
- const heights = new Map([['b', 80]])
- const prefix = buildPrefixHeights(items, heights, 50)
- expect(prefix).toEqual([0, 50, 130, 180])
- })
-
- it('computes range with window cap', () => {
- const items = Array.from({ length: 200 }, (_, i) => ({ id: String(i) }))
- const heights = new Map()
- const { start, end } = computeVirtualRange(items, 0, 600, 30, 20, 40, heights)
- expect(end - start).toBeLessThanOrEqual(80)
- })
-
- it('spacer heights sum to total', () => {
- const prefix = [0, 50, 100, 150]
- const { top, bottom, total } = getSpacerHeights(prefix, 1, 2)
- expect(top + bottom + (prefix[2] - prefix[1])).toBe(total)
- })
-})
-```
-
-- [ ] **Step 2: Run tests (expect FAIL)**
-
-```powershell
-npm run test
-```
-Expected: FAIL if helpers not implemented.
-
-- [ ] **Step 3: Implement helpers (Step 2) then re-run tests (expect PASS)**
-
-```powershell
-npm run test
-```
-
-- [ ] **Step 4: Commit helpers + tests**
-
-```powershell
-git add src\lib\virtual-scroll.js tests\virtual-scroll.test.js package.json package-lock.json
-git commit -m "test: add virtual scroll helpers"
-```
-
----
-
-## Chunk 2: Integrate virtual scroll into chat
-
-### Task 4: Add state + range calc
-
-**Files:**
-- Modify: `src/pages/chat.js:64-2000`
-
-- [ ] **Step 1: Add constants + state**
-
-```js
-const VIRTUAL_WINDOW = 40
-const VIRTUAL_OVERSCAN = 20
-let _virtualEnabled = true
-let _virtualHeights = new Map()
-let _virtualAvgHeight = 64
-let _virtualRange = { start: 0, end: 0, prefix: [0] }
-```
-
-- [ ] **Step 2: Import helpers**
-
-```js
-import { computeVirtualRange, getSpacerHeights } from '../lib/virtual-scroll.js'
-```
-
-- [ ] **Step 3: Scroll handler**
-
-On scroll, compute range using `computeVirtualRange(items, scrollTop, viewportHeight, _virtualAvgHeight, VIRTUAL_OVERSCAN, VIRTUAL_WINDOW, _virtualHeights)` and update `_virtualRange` when changed.
-
-### Task 5: Render with spacers + measurement
-
-**Files:**
-- Modify: `src/pages/chat.js:1380-1750`
-
-- [ ] **Step 1: Render spacers + window**
-
-Insert:
-- top spacer with height = `getSpacerHeights(prefix, start, end).top`
-- visible items = `items.slice(start, end)`
-- bottom spacer with height = `getSpacerHeights(prefix, start, end).bottom`
-
-- [ ] **Step 2: Measure heights**
-
-After render (requestAnimationFrame), measure visible `.msg` nodes using `getBoundingClientRect().height`, update `_virtualHeights`, and recompute `_virtualAvgHeight`.
-
-- [ ] **Step 3: Anchor strategy**
-
-If user is at bottom (within 80px), auto scroll to bottom after new message. Otherwise, preserve scroll position by capturing `scrollTop` before re-render and adjusting by delta in top spacer height.
-
-### Task 6: Build
-
-```powershell
-npm run build
-```
-Expected: Build succeeds without errors.
-
-### Task 7: Commit + Push
-
-```powershell
-git add src\pages\chat.js
-git commit -m "feat: chat virtual scroll"
-git push
-```
diff --git a/docs/superpowers/plans/2026-03-17-cron-trigger-mode.md b/docs/superpowers/plans/2026-03-17-cron-trigger-mode.md
deleted file mode 100644
index 3009d683..00000000
--- a/docs/superpowers/plans/2026-03-17-cron-trigger-mode.md
+++ /dev/null
@@ -1,86 +0,0 @@
-# Cron 触发模式扩展 Implementation Plan
-
-> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** 让 sessionMessage 任务支持两种触发模式:按 cron 执行或监听指定会话 agent 任务结束后发送。
-
-**Architecture:** 在 cron.js 中为 sessionMessage 任务增加触发模式字段(cron | onIdle),本地存储与渲染读取该字段;onIdle 模式基于 wsClient 事件跟踪目标会话的 run 状态,任务结束且空闲即发送;cron 模式保留现有定时器。非 sessionMessage 任务仍走 Gateway。
-
-**Tech Stack:** ClawPanel (Vite + JS), WebSocket client, Tauri panel config API
-
----
-
-## Chunk 1: 数据模型与 UI
-
-### Task 1: 增加触发模式字段
-
-**Files:**
-- Modify: `src/pages/cron.js`
-
-- [ ] **Step 1: 新增字段**
-
-为 sessionMessage 本地任务新增 `triggerMode` 字段:`cron` | `onIdle`。
-
-- [ ] **Step 2: UI 选择**
-
-在 sessionMessage 任务编辑弹窗加入触发模式选择:
-- 选项:`按 Cron` / `监听任务结束`
-- 选择 onIdle 时隐藏 cron 输入,显示说明:监听目标会话任务结束后发送。
-
-- [ ] **Step 3: 列表展示**
-
-列表中显示触发模式:
-- cron 显示 cron 文本
-- onIdle 显示 “任务结束后发送”
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add src/pages/cron.js
-git commit -m "feat: add sessionMessage trigger mode"
-```
-
-## Chunk 2: 触发逻辑
-
-### Task 2: cron 与 onIdle 双模式发送
-
-**Files:**
-- Modify: `src/pages/cron.js`
-
-- [ ] **Step 1: cron 触发**
-
-仅当 `triggerMode === 'cron'` 时参与 `tickSessionMessageJobs` 逻辑。
-
-- [ ] **Step 2: onIdle 触发**
-
-新增 `checkIdleTrigger(job)`:
-- 若 `triggerMode === 'onIdle'`,当目标会话从 active -> idle 且当前未发送过本轮,发送消息并记录 `lastRunAtMs`。
-
-- [ ] **Step 3: 去重**
-
-使用 `state.lastRunAtMs` 或 `state.lastIdleAtMs` 避免重复发送。
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add src/pages/cron.js
-git commit -m "feat: onIdle trigger for sessionMessage"
-```
-
-## Chunk 3: 验证
-
-### Task 3: Build 与手动验证
-
-- [ ] **Step 1: Build**
-
-```bash
-npm run build
-```
-
-- [ ] **Step 2: 验证**
-
-- 新建 sessionMessage 任务,选择 cron → 定时发送生效
-- 新建 sessionMessage 任务,选择 onIdle → 会话任务结束后发送
-- 非 sessionMessage 任务仍通过 Gateway 保存/触发
-
----
diff --git a/docs/superpowers/plans/2026-03-17-cron-ws-sessionmessage.md b/docs/superpowers/plans/2026-03-17-cron-ws-sessionmessage.md
deleted file mode 100644
index 008cbde8..00000000
--- a/docs/superpowers/plans/2026-03-17-cron-ws-sessionmessage.md
+++ /dev/null
@@ -1,199 +0,0 @@
-# Cron WS SessionMessage Replacement Implementation Plan
-
-> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Replace gateway patch–based cron sessionMessage with a client-side scheduler that sends user messages over the already-connected WSS only after the target session is idle/completed.
-
-**Architecture:** Store scheduled jobs in panel config (local), run a lightweight scheduler in ClawPanel (cron.js) that waits for gatewayReady and session idle state, then dispatch wsClient.chatSend. Track run state and last-run time in panel config to avoid duplicate sends.
-
-**Tech Stack:** Frontend JS (cron.js, ws-client.js), panel config (read/write via tauri-api), WebSocket RPC (sessions.list, sessions.get, chat events).
-
----
-
-## File Structure
-
-**Modify:**
-- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\src\pages\cron.js` (UI, local scheduler, local job storage)
-- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\src\lib\tauri-api.js` (panel config helpers)
-- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\src\lib\ws-client.js` (optional: expose session idle state helper)
-- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\src\pages\chat.js` (optional: emit idle status events)
-- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\src\main.js` (optional: start scheduler on boot)
-
-**Create:**
-- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\docs\superpowers\plans\2026-03-17-cron-ws-sessionmessage.md` (this plan)
-
----
-
-## Task 1: Define local cron job schema and storage
-
-**Files:**
-- Modify: `src/pages/cron.js`
-- Modify: `src/lib/tauri-api.js`
-
-- [ ] **Step 1: Add local job schema**
-
-Define a new `localCronJobs` array in panel config, each job:
-```
-{
- id: "uuid",
- name: "string",
- schedule: { kind: "cron", expr: "* * * * *" },
- enabled: true,
- payload: {
- kind: "sessionMessage",
- label: "sessionLabel",
- message: "text",
- waitForIdle: true
- },
- state: { lastRunAtMs: 0, lastStatus: "ok|error|skipped", lastError: "" }
-}
-```
-
-- [ ] **Step 2: Add API helpers for panel config**
-
-In `tauri-api.js`, add helpers:
-```
-readPanelConfig()
-writePanelConfig()
-```
-Ensure cron.js can read/write panel config locally without gateway.
-
-- [ ] **Step 3: Implement load/save local jobs**
-
-In cron.js:
-- On render, load panel config and initialize local jobs list if missing.
-- Use `localJobs` as a separate tab/section from gateway jobs.
-
----
-
-## Task 2: Update Cron UI for sessionMessage-only mode
-
-**Files:**
-- Modify: `src/pages/cron.js`
-
-- [ ] **Step 1: Replace task type selector**
-
-Remove gateway cron payload kind selector. Only show “发送 user 消息(WSS)”.
-
-- [ ] **Step 2: Show required fields**
-
-Show inputs:
-- name
-- schedule (cron)
-- sessionLabel (from sessions.list)
-- message (textarea)
-- enabled toggle
-- waitForIdle toggle
-
-- [ ] **Step 3: Save local job**
-
-On save, write to panel config localCronJobs, update lastRun fields to defaults.
-
-- [ ] **Step 4: Remove gateway cron create/update**
-
-Delete calls to `wsClient.request('cron.add'|'cron.update')` for local jobs.
-
----
-
-## Task 3: Implement WSS local scheduler
-
-**Files:**
-- Modify: `src/pages/cron.js`
-- Modify: `src/main.js` (optional startup hook)
-
-- [ ] **Step 1: Add scheduler loop**
-
-Create an interval (e.g., 10s) to:
-- Check wsClient.gatewayReady
-- For each enabled local job, check next due time from cron expression
-- If due and not run in current window, attempt send
-
-- [ ] **Step 2: Determine session idle state**
-
-Define idle as:
-- No active runs for target session
-- Or no “streaming” event in last N seconds
-
-Approach:
-- Use `sessions.list` or `sessions.get` via wsClient to read run state if available
-- If not available, fallback to client-side tracking of last chat event timestamps for that session
-
-- [ ] **Step 3: Send message only when idle**
-
-Use:
-```
-wsClient.chatSend(sessionKey, message)
-```
-Only when idle.
-
-- [ ] **Step 4: Update job state**
-
-On send success:
-- state.lastRunAtMs = Date.now()
-- state.lastStatus = "ok"
-On failure:
-- state.lastStatus = "error"
-- state.lastError = message
-
-Persist to panel config after each run.
-
----
-
-## Task 4: Session resolution by label
-
-**Files:**
-- Modify: `src/pages/cron.js`
-
-- [ ] **Step 1: Build label→sessionKey map**
-
-Use sessions.list to map label (parseSessionLabel) back to sessionKey.
-
-- [ ] **Step 2: Validate session exists**
-
-If session missing, mark lastStatus=error and lastError="session not found".
-
----
-
-## Task 5: Visual status and monitoring
-
-**Files:**
-- Modify: `src/pages/cron.js`
-
-- [ ] **Step 1: Show local cron jobs in UI**
-
-Include status badges:
-- last run time
-- last status
-- error message if any
-
-- [ ] **Step 2: Add manual run button**
-
-Trigger immediate send via scheduler path (same idle check).
-
----
-
-## Task 6: Verification
-
-**Files:**
-- Modify: `src/pages/cron.js`
-
-- [ ] **Step 1: Build**
-
-Run:
-```
-npm run build
-```
-Expected: Success.
-
-- [ ] **Step 2: Manual test**
-
-1) Create a local cron job to send to main session.
-2) Start a long-running agent task.
-3) Verify scheduler waits until idle, then sends message.
-
----
-
-## Notes
-- This plan intentionally bypasses gateway cron schema and patching.
-- Jobs are stored locally in panel config, so they only run while ClawPanel is open.
-- If headless scheduling is required later, a separate gateway-side implementation will be needed.
diff --git a/docs/superpowers/plans/2026-03-17-force-setup.md b/docs/superpowers/plans/2026-03-17-force-setup.md
deleted file mode 100644
index 93774207..00000000
--- a/docs/superpowers/plans/2026-03-17-force-setup.md
+++ /dev/null
@@ -1,84 +0,0 @@
-# forceSetup Implementation Plan
-
-> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** 添加 `forceSetup` 开关,使构建版可强制进入 /setup,完成初始化后自动关闭。
-
-**Architecture:** 在 panel config 中存储 forceSetup;启动时读取并强制跳转;setup 成功后清零。
-
-**Tech Stack:** Vanilla JS, Tauri
-
----
-
-## Chunk 1: 配置字段读写
-
-### Task 1: panel config 增加 forceSetup
-**Files:**
-- Modify: `src-tauri/src/commands/config.rs`
-- Modify: `src/lib/tauri-api.js`
-
-- [ ] **Step 1: 扩展 panel config 读写**
-
-在读写 panel config 时透传 `forceSetup`。
-
-- [ ] **Step 2: 提交**
-```bash
-git add src-tauri/src/commands/config.rs src/lib/tauri-api.js
-git commit -m "feat: add forceSetup to panel config"
-```
-
----
-
-## Chunk 2: 启动强制跳转
-
-### Task 2: main.js 强制跳 setup
-**Files:**
-- Modify: `src/main.js`
-
-- [ ] **Step 1: 启动时读取 panel config**
-
-在 `ensureWebSession` 前读取 panel config,若 `forceSetup===true` 强制跳转 `/setup`。
-
-- [ ] **Step 2: 提交**
-```bash
-git add src/main.js
-git commit -m "feat: force setup on startup"
-```
-
----
-
-## Chunk 3: setup 完成后清零
-
-### Task 3: setup.js 成功后清零
-**Files:**
-- Modify: `src/pages/setup.js`
-
-- [ ] **Step 1: setup 成功时写入 forceSetup=false**
-
-- [ ] **Step 2: 提交**
-```bash
-git add src/pages/setup.js
-git commit -m "feat: clear forceSetup after setup"
-```
-
----
-
-## Chunk 4: 构建与验证
-
-### Task 4: 构建
-**Files:** 无
-
-- [ ] **Step 1: 构建**
-```bash
-npm run build
-```
-
-- [ ] **Step 2: 手工验证**
-- forceSetup=true 时进入 /setup
-- setup 完成后不再强制跳转
-- forceSetup=false 时逻辑不变
-
-- [ ] **Step 3: 推送**
-```bash
-git push
-```
diff --git a/docs/superpowers/plans/2026-03-17-skill-trigger-optimization.md b/docs/superpowers/plans/2026-03-17-skill-trigger-optimization.md
deleted file mode 100644
index 38fc574d..00000000
--- a/docs/superpowers/plans/2026-03-17-skill-trigger-optimization.md
+++ /dev/null
@@ -1,165 +0,0 @@
-# Skill Trigger Optimization Implementation Plan
-
-> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Optimize trigger descriptions for all installed OpenClaw skills using the skill-creator workflow.
-
-**Architecture:** Inventory all skills under `~/.openclaw/skills/`, snapshot current SKILL.md frontmatter, then iterate through each skill to improve the description field with an automated loop when available. Ensure results are written back to each skill’s SKILL.md frontmatter and logged.
-
-**Tech Stack:** PowerShell, Python via `uv`, OpenClaw skill-creator assets (scripts/), markdown edits.
-
----
-
-### Task 1: Inventory and snapshot
-
-**Files:**
-- Read: `C:\Users\34438\.openclaw\skills\*\SKILL.md`
-- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\inventory.json`
-- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\snapshots\\SKILL.md`
-
-- [ ] **Step 1: List skill directories**
-
-Run:
-```
-Get-ChildItem "C:\Users\34438\.openclaw\skills" -Directory | Select-Object Name
-```
-Expected: list of skill folder names.
-
-- [ ] **Step 2: Snapshot current SKILL.md files**
-
-Run:
-```
-New-Item -ItemType Directory -Path "C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\snapshots" -Force | Out-Null
-```
-Then copy each `SKILL.md` into its snapshot folder.
-
-- [ ] **Step 3: Build inventory.json**
-
-Create a JSON array with entries:
-```
-{
- "name": "skill-folder",
- "path": "C:\\Users\\34438\\.openclaw\\skills\\skill-folder",
- "skill_md": "...\\SKILL.md",
- "description": ""
-}
-```
-
-- [ ] **Step 4: Commit checkpoint**
-
-Run:
-```
-git add docs/superpowers/plans/2026-03-17-skill-trigger-optimization.md
-```
-If in a repo, commit after build verification.
-
----
-
-### Task 2: Verify skill-creator tooling availability
-
-**Files:**
-- Read: `C:\Users\34438\.openclaw\skills\skill-creator\scripts\` (if exists)
-- Read: `C:\Users\34438\.openclaw\skills\skill-creator\references\` (if exists)
-
-- [ ] **Step 1: Confirm run_loop.py and eval scripts exist**
-
-Run:
-```
-Get-ChildItem "C:\Users\34438\.openclaw\skills\skill-creator\scripts" -Filter "*.py"
-```
-Expected: `run_loop.py`, `run_eval.py`, `aggregate_benchmark.py` or similar.
-
-- [ ] **Step 2: Decide path**
-
-If scripts exist: use automated loop per skill-creator instructions.
-If scripts are missing: use manual description optimization with heuristics (see Task 4).
-
----
-
-### Task 3: Automated description optimization loop (preferred)
-
-**Files:**
-- Modify: `C:\Users\34438\.openclaw\skills\\SKILL.md`
-- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\\trigger_eval.json`
-- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\\run_log.txt`
-
-- [ ] **Step 1: Generate trigger eval set**
-
-Create 16-20 queries (8-10 should-trigger, 8-10 should-not-trigger) per skill and save as JSON:
-```
-[
- {"query": "...", "should_trigger": true},
- {"query": "...", "should_trigger": false}
-]
-```
-
-- [ ] **Step 2: Run description optimization loop**
-
-Run (example):
-```
-uv run python -m scripts.run_loop --eval-set --skill-path --model openai-codex/gpt-5.2-codex --max-iterations 5 --verbose
-```
-Capture output to `run_log.txt`.
-
-- [ ] **Step 3: Apply best_description**
-
-Update the SKILL.md frontmatter `description` with `best_description`.
-
-- [ ] **Step 4: Record changes**
-
-Update `inventory.json` with new description and a `score` field if available.
-
----
-
-### Task 4: Manual description optimization (fallback)
-
-**Files:**
-- Modify: `C:\Users\34438\.openclaw\skills\\SKILL.md`
-- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\\manual_notes.md`
-
-- [ ] **Step 1: Draft improved description**
-
-Rewrite the description with explicit trigger phrases and contexts. Ensure it is pushy and includes common user phrasings.
-
-- [ ] **Step 2: Update SKILL.md frontmatter**
-
-Replace the `description` value while preserving name and formatting.
-
-- [ ] **Step 3: Log**
-
-Write before/after into `manual_notes.md` and update `inventory.json`.
-
----
-
-### Task 5: Validation and summary
-
-**Files:**
-- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\summary.md`
-
-- [ ] **Step 1: Validate frontmatter**
-
-Verify each SKILL.md starts with YAML frontmatter containing `name` and `description`.
-
-- [ ] **Step 2: Build summary**
-
-Write a summary with counts of skills updated and any failures.
-
-- [ ] **Step 3: Final build check (if required by repo policy)**
-
-Run:
-```
-npm run build
-```
-Expected: success.
-
-- [ ] **Step 4: Commit**
-
-Commit all changes after build success.
-
----
-
-## Notes
-- Use PowerShell only.
-- Use `uv run python` for Python scripts.
-- No emoji in outputs or docs.
-- Do not overwrite SKILL.md structure beyond frontmatter description.
diff --git a/docs/superpowers/plans/2026-03-17-skillhub-env-fix.md b/docs/superpowers/plans/2026-03-17-skillhub-env-fix.md
deleted file mode 100644
index f069849b..00000000
--- a/docs/superpowers/plans/2026-03-17-skillhub-env-fix.md
+++ /dev/null
@@ -1,102 +0,0 @@
-# SkillHub 动态探测与系统环境变量继承 Implementation Plan
-
-> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** 让 ClawPanel 通过动态探测识别 SkillHub CLI,并让所有 Tauri 命令继承完整系统环境变量(用户 + 系统)。
-
-**Architecture:** 在 Tauri 后端新增统一“系统环境构建函数”,所有命令执行时统一注入 envs;SkillHub 检测失败时走 where 探测并返回命中路径。
-
-**Tech Stack:** Rust (Tauri), Windows Registry, tokio::process::Command
-
----
-
-## Chunk 1: 环境变量合并工具函数
-
-### Task 1: 新增系统环境合并函数
-**Files:**
-- Modify: `src-tauri/src/utils.rs`
-- Modify: `src-tauri/src/commands/mod.rs`(如已有 enhanced_path 需更新)
-- Test: (无自动测试,手工验证)
-
-- [ ] **Step 1: 设计合并逻辑并落地函数**
-
-新增 `build_system_env()`,返回 `Vec<(String, String)>`,包含:
-- 当前进程 env
-- 用户 env(HKCU\Environment)
-- 系统 env(HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment)
-
-PATH 处理:系统 + 用户 + 进程,去重拼接。
-
-示例(伪代码):
-```rust
-pub fn build_system_env() -> Vec<(String, String)> {
- let mut env_map = HashMap::new();
- // 1) 读取系统 + 用户 env 并写入
- // 2) 读取进程 env 覆盖
- // 3) PATH 合并去重
- env_map.into_iter().collect()
-}
-```
-
-- [ ] **Step 2: 在 commands 统一使用 build_system_env**
-
-将 `enhanced_path()` 替换或改为使用 `build_system_env()`,确保所有命令执行时统一 `cmd.envs(build_system_env())`。
-
-- [ ] **Step 3: 提交**
-```bash
-git add src-tauri/src/utils.rs src-tauri/src/commands/mod.rs
-git commit -m "feat: inherit full system env for commands"
-```
-
----
-
-## Chunk 2: SkillHub 动态探测与返回路径
-
-### Task 2: SkillHub 检测增强
-**Files:**
-- Modify: `src-tauri/src/commands/skills.rs`
-- Modify: `src/lib/tauri-api.js`(若返回字段变化)
-- Modify: `src/pages/skills.js`
-
-- [ ] **Step 1: 更新 skills_skillhub_check**
-
-流程:
-1) `skillhub --version`
-2) 若失败,执行 `where skillhub`
-3) 取第一条路径,执行 ` --version`
-4) 返回 `{ installed: true, version, path }`
-
-- [ ] **Step 2: 更新前端展示**
-
-Skills 页面展示 path(若存在):
-- `#skillhub-status` 增加 “路径: xxx”
-- 仍显示版本号
-
-- [ ] **Step 3: 提交**
-```bash
-git add src-tauri/src/commands/skills.rs src/lib/tauri-api.js src/pages/skills.js
-git commit -m "feat: detect skillhub by path and show location"
-```
-
----
-
-## Chunk 3: 手工验证与构建
-
-### Task 3: 验证与构建
-**Files:**
-- 无
-
-- [ ] **Step 1: 前端构建**
-```bash
-npm run build
-```
-
-- [ ] **Step 2: 手工验证要点**
-- SkillHub CLI 安装后,未重启 ClawPanel 仍能识别 installed=true
-- UI 能显示 version + path
-- 晴辰助手执行命令时继承系统变量(如 PATH / HTTP_PROXY)
-
-- [ ] **Step 3: 代码汇总与推送**
-```bash
-git push
-```
diff --git a/docs/superpowers/plans/2026-03-17-toast-night-and-model-select.md b/docs/superpowers/plans/2026-03-17-toast-night-and-model-select.md
deleted file mode 100644
index 9c1c4519..00000000
--- a/docs/superpowers/plans/2026-03-17-toast-night-and-model-select.md
+++ /dev/null
@@ -1,99 +0,0 @@
-# Toast Night Style + Model Select Width Implementation Plan
-
-> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Improve toast visibility in dark mode and make chat-model-select auto-size without truncation.
-
-**Architecture:** Update CSS rules in `components.css` for toast dark mode and in `chat.css` for model select width.
-
-**Tech Stack:** CSS, Vite build
-
----
-
-## Chunk 1: Toast dark mode visibility
-
-### Task 1: Add dark theme toast background
-
-**Files:**
-- Modify: `src/style/components.css`
-
-- [ ] **Step 0: Checkpoint(PowerShell)**
-
-```powershell
-git status -sb
-git commit --allow-empty -m "chore: checkpoint before toast dark style"
-```
-
-Note: This checkpoint is mandatory by policy before any modification. The final functional commit still occurs after build to honor Build First, Commit Later for real changes.
-
-- [ ] **Step 1: Add dark theme override**
-
-```css
-[data-theme="dark"] .toast {
- background: var(--bg-secondary);
-}
-```
-
-- [ ] **Step 2: Build**
-
-Run: `npm run build`
-Expected: Build succeeds without errors.
-
-- [ ] **Step 3: Commit(PowerShell)**
-
-```powershell
-git add src/style/components.css
-git commit -m "fix: improve toast dark mode"
-```
-
-- [ ] **Step 4: Push(PowerShell)**
-
-```powershell
-git push
-```
-
-## Chunk 2: Model select auto width
-
-### Task 2: Remove truncation and allow auto width
-
-**Files:**
-- Modify: `src/style/chat.css`
-
-- [ ] **Step 0: Checkpoint(PowerShell)**
-
-```powershell
-git status -sb
-git commit --allow-empty -m "chore: checkpoint before model select width"
-```
-
-- [ ] **Step 1: Update model select styles**
-
-Set the model select to auto width and remove truncation. Example:
-
-```css
-.chat-model-select {
- width: auto;
- max-width: none;
- white-space: nowrap;
-}
-```
-
-Adjust if selectors differ in current file.
-
-- [ ] **Step 2: Build**
-
-Run: `npm run build`
-Expected: Build succeeds without errors.
-
-- [ ] **Step 3: Commit(PowerShell)**
-
-```powershell
-git add src/style/chat.css
-git commit -m "fix: auto width for chat model select"
-```
-
-- [ ] **Step 4: Push(PowerShell)**
-
-```powershell
-git push
-```
diff --git a/docs/superpowers/plans/2026-03-17-toast-shadow.md b/docs/superpowers/plans/2026-03-17-toast-shadow.md
deleted file mode 100644
index beb3bea8..00000000
--- a/docs/superpowers/plans/2026-03-17-toast-shadow.md
+++ /dev/null
@@ -1,54 +0,0 @@
-# Toast Shadow Implementation Plan
-
-> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Add a medium-strength shadow to toast cards while keeping the Vercel-style solid background.
-
-**Architecture:** Modify `.toast` rule in `components.css` to add `box-shadow` only.
-
-**Tech Stack:** CSS, Vite build
-
----
-
-## Chunk 1: Toast shadow
-
-### Task 1: Add toast shadow
-
-**Files:**
-- Modify: `src/style/components.css`
-
-- [ ] **Step 0: Checkpoint(PowerShell)**
-
-```powershell
-git status -sb
-git commit --allow-empty -m "chore: checkpoint before toast shadow"
-```
-
-Note: This checkpoint is required by policy to protect rollbacks. The final functional commit still happens after `npm run build`.
-
-- [ ] **Step 1: Add box-shadow**
-
-```css
-.toast {
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
-}
-```
-
-- [ ] **Step 2: Build**
-
-Run: `npm run build`
-Expected: Build succeeds without errors.
-Note: This is the frontend Vite build; no `wails build` required for this CSS-only change.
-
-- [ ] **Step 3: Commit(PowerShell)**
-
-```powershell
-git add src/style/components.css
-git commit -m "fix: add toast shadow"
-```
-
-- [ ] **Step 4: Push(PowerShell)**
-
-```powershell
-git push
-```
diff --git a/docs/superpowers/plans/2026-03-17-toast-vercel.md b/docs/superpowers/plans/2026-03-17-toast-vercel.md
deleted file mode 100644
index ed92e2c7..00000000
--- a/docs/superpowers/plans/2026-03-17-toast-vercel.md
+++ /dev/null
@@ -1,54 +0,0 @@
-# Toast Vercel Style Implementation Plan
-
-> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Replace glass/blur toast with solid Vercel-style card that adapts to light/dark themes.
-
-**Architecture:** Update toast styles in `components.css` to remove blur, use theme variables for background and border, keep status text colors.
-
-**Tech Stack:** CSS, Vite build
-
----
-
-## Chunk 1: Toast style update
-
-### Task 1: Update toast base style
-
-**Files:**
-- Modify: `src/style/components.css`
-
-- [ ] **Step 0: Checkpoint**
-
-```bash
-git status -sb
-git commit --allow-empty -m "chore: checkpoint before toast style update"
-```
-
-- [ ] **Step 1: Remove blur and set base card styles**
-
-Update `.toast` rule:
-- remove `backdrop-filter`
-- add `background: var(--bg-primary);`
-- add `border: 1px solid var(--border);`
-
-- [ ] **Step 2: Simplify status variants**
-
-Update `.toast.success/.error/.info/.warning` to only set `color`, removing background and border overrides.
-
-- [ ] **Step 3: Build**
-
-Run: `npm run build`
-Expected: Build succeeds without errors.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add src/style/components.css
-git commit -m "fix: vercel-style toast card"
-```
-
-- [ ] **Step 5: Push**
-
-```bash
-git push
-```
diff --git a/docs/superpowers/plans/2026-03-17-tool-call-meta.md b/docs/superpowers/plans/2026-03-17-tool-call-meta.md
deleted file mode 100644
index ac94c8db..00000000
--- a/docs/superpowers/plans/2026-03-17-tool-call-meta.md
+++ /dev/null
@@ -1,108 +0,0 @@
-# Tool Call Meta Implementation Plan
-
-> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Show tool call time in the tool card header and ensure expanded sections display input/output placeholders when data is empty.
-
-**Architecture:** Update tool rendering in `src/pages/chat.js` to compute display time and placeholders; optional minor CSS tweaks if needed.
-
-**Tech Stack:** Vanilla JS, CSS, Vite build
-
----
-
-## Chunk 1: Tool header time + placeholders
-
-### Task 1: Add tool time display
-
-**Files:**
-- Modify: `src/pages/chat.js`
-
-- [ ] **Step 0: Checkpoint(PowerShell)**
-
-```powershell
-git status -sb
-git commit --allow-empty -m "chore: checkpoint before tool meta"
-```
-
-Note: This checkpoint is required by policy; final functional commit occurs after build.
-
-- [ ] **Step 1: Add helper to get tool time**
-
-Add a function near other helpers in `src/pages/chat.js` (below `stripThinkingTags`):
-
-```js
-function getToolTime(tool) {
- const raw = tool?.end_time || tool?.endTime || tool?.timestamp || tool?.time || tool?.started_at || tool?.startedAt || null
- if (!raw) return null
- if (typeof raw === 'number' && raw < 1e12) return raw * 1000
- return raw
-}
-```
-
-Note: `formatTime` and `escapeHtml` already exist in `chat.js`.
-
-- [ ] **Step 1.5: Capture tool event timestamps**
-
-Add a map at top-level in `src/pages/chat.js`:
-
-```js
-const _toolEventTimes = new Map()
-```
-
-In `handleEvent`, before `handleChatEvent`, capture tool events:
-
-```js
-if (event === 'agent' && payload?.stream === 'tool' && payload?.data?.toolCallId) {
- const ts = payload.ts
- if (ts) _toolEventTimes.set(payload.data.toolCallId, ts)
-}
-```
-
-In `collectToolsFromMessage`, when constructing tool entries, set `time` when absent:
-
-```js
-const callId = call.id || call.tool_call_id
-const fallbackTime = callId ? _toolEventTimes.get(callId) : null
-... time: call.time || fallbackTime ...
-```
-
-- [ ] **Step 2: Render header with time**
-
-In `appendToolsToEl`, reuse the existing `summary` node created there and update header:
-
-```js
-const time = getToolTime(tool)
-const timeText = time ? formatTime(new Date(time)) : '时间未知'
-summary.innerHTML = `${escapeHtml(tool.name || '工具')} · ${status} · ${timeText}`
-```
-
-- [ ] **Step 3: Add placeholders**
-
-Use the exact block structure already used in tool body:
-
-```js
-const input = inputJson
- ? ``
- : ``
-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
deleted file mode 100644
index ff62269d..00000000
--- a/docs/superpowers/plans/2026-03-17-ws-connect-bootstrap.md
+++ /dev/null
@@ -1,86 +0,0 @@
-# WS Connect Bootstrap Implementation Plan
-
-> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** On each websocket connect success, send the official 8-request bootstrap batch; set ping interval to 5s (bootstrap may duplicate the first ping batch).
-
-**Architecture:** Add `_sendBootstrapRequests()` to `WsClient`, call it from `_handleConnectSuccess`, update `PING_INTERVAL` constant to 5000.
-
-**Tech Stack:** JS, Vite build
-
----
-
-## Chunk 1: Bootstrap batch + ping interval
-
-### Task 1: Implement bootstrap batch
-
-**Files:**
-- Modify: `src/lib/ws-client.js`
-
-- [ ] **Step 0: Checkpoint(PowerShell)**
-
-```powershell
-git status -sb
-git commit --allow-empty -m "chore: checkpoint before ws bootstrap"
-```
-
-Note: This checkpoint is mandatory by policy before modifications.
-
-- [ ] **Step 1: Add helper to send bootstrap batch**
-
-Add method inside `WsClient`:
-
-```js
-_sendBootstrapRequests() {
- if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return
- const sessionKey = this._sessionKey || 'agent:full-stack-architect:main'
- // Note: responses are fire-and-forget in this batch
- const frames = [
- { type: 'req', id: uuid(), method: 'agent.identity.get', params: { sessionKey } },
- { type: 'req', id: uuid(), method: 'agents.list', params: {} },
- { type: 'req', id: uuid(), method: 'health', params: {} },
- { type: 'req', id: uuid(), method: 'node.list', params: {} },
- { type: 'req', id: uuid(), method: 'device.pair.list', params: {} },
- { type: 'req', id: uuid(), method: 'chat.history', params: { sessionKey, limit: 200 } },
- { type: 'req', id: uuid(), method: 'sessions.list', params: { includeGlobal: true, includeUnknown: true } },
- { type: 'req', id: uuid(), method: 'models.list', params: {} },
- ]
- frames.forEach(frame => this._ws.send(JSON.stringify(frame)))
-}
-```
-
-- [ ] **Step 2: Call bootstrap on connect success**
-
-In `_handleConnectSuccess` add:
-
-```js
-this._sendBootstrapRequests()
-```
-
-- [ ] **Step 3: Set ping interval to 5s**
-
-Change constant:
-
-```js
-const PING_INTERVAL = 5000
-```
-
-Note: With 5s interval and multi-req pings, load increases. This matches the requested behavior.
-
-- [ ] **Step 4: Build**
-
-Run: `npm run build`
-Expected: Build succeeds without errors.
-
-- [ ] **Step 5: Commit(PowerShell)**
-
-```powershell
-git add src/lib/ws-client.js
-git commit -m "fix: ws bootstrap batch"
-```
-
-- [ ] **Step 6: Push(PowerShell)**
-
-```powershell
-git push
-```
diff --git a/docs/superpowers/plans/2026-03-17-ws-ping-multi-req.md b/docs/superpowers/plans/2026-03-17-ws-ping-multi-req.md
deleted file mode 100644
index 4b768851..00000000
--- a/docs/superpowers/plans/2026-03-17-ws-ping-multi-req.md
+++ /dev/null
@@ -1,59 +0,0 @@
-# WS Ping Multi-Req Implementation Plan
-
-> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Send node.list, models.list, sessions.list, and chat.history every ping interval.
-
-**Architecture:** Update `_startPing` in `src/lib/ws-client.js` to emit four req frames each interval.
-
-**Tech Stack:** JS, Vite build
-
----
-
-## Chunk 1: Ping multi-req
-
-### Task 1: Update ping sender
-
-**Files:**
-- Modify: `src/lib/ws-client.js`
-
-- [ ] **Step 0: Checkpoint(PowerShell)**
-
-```powershell
-git status -sb
-git commit --allow-empty -m "chore: checkpoint before ping multi req"
-```
-
-Note: This checkpoint is mandatory by policy before modifications.
-
-- [ ] **Step 1: Replace ping payload with 4 req frames**
-
-In `_startPing` interval:
-
-```js
-const frames = [
- { type: 'req', id: uuid(), method: 'node.list', params: {} },
- { type: 'req', id: uuid(), method: 'models.list', params: {} },
- { type: 'req', id: uuid(), method: 'sessions.list', params: { includeGlobal: true, includeUnknown: true } },
- { type: 'req', id: uuid(), method: 'chat.history', params: { sessionKey: 'agent:full-stack-architect:main', limit: 200 } },
-]
-frames.forEach(frame => this._ws.send(JSON.stringify(frame)))
-```
-
-- [ ] **Step 2: Build**
-
-Run: `npm run build`
-Expected: Build succeeds without errors.
-
-- [ ] **Step 3: Commit(PowerShell)**
-
-```powershell
-git add src/lib/ws-client.js
-git commit -m "fix: ping sends multi req"
-```
-
-- [ ] **Step 4: Push(PowerShell)**
-
-```powershell
-git push
-```
diff --git a/docs/superpowers/plans/2026-03-17-ws-ping-node-list.md b/docs/superpowers/plans/2026-03-17-ws-ping-node-list.md
deleted file mode 100644
index 7575460f..00000000
--- a/docs/superpowers/plans/2026-03-17-ws-ping-node-list.md
+++ /dev/null
@@ -1,56 +0,0 @@
-# WS Ping Node List Implementation Plan
-
-> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Replace websocket ping with a periodic node.list request.
-
-**Architecture:** Modify `_startPing` in `src/lib/ws-client.js` to send a req frame rather than a ping frame.
-
-**Tech Stack:** JS, Vite build
-
----
-
-## Chunk 1: Replace ping payload
-
-### Task 1: Update ping sender
-
-**Files:**
-- Modify: `src/lib/ws-client.js`
-
-- [ ] **Step 0: Checkpoint(PowerShell)**
-
-```powershell
-git status -sb
-git commit --allow-empty -m "chore: checkpoint before ping change"
-```
-
-Note: This checkpoint is mandatory by policy before modifications. The final functional commit still occurs after a successful build.
-
-- [ ] **Step 1: Replace ping payload**
-
-In `_startPing` interval:
-
-```js
-const frame = { type: 'req', id: uuid(), method: 'node.list', params: {} }
-this._ws.send(JSON.stringify(frame))
-```
-
-- [ ] **Step 2: Build**
-
-Run: `npm run build`
-Expected: Build succeeds without errors.
-
-- [ ] **Step 3: Commit(PowerShell)**
-
-```powershell
-git add src/lib/ws-client.js
-git commit -m "fix: ping uses node.list"
-```
-
-- [ ] **Step 4: Push(PowerShell)**
-
-Run only after successful build and commit.
-
-```powershell
-git push
-```
From ef86fdff384a3acee9e9fece0e6f1722635b91f9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Tue, 17 Mar 2026 13:16:15 +0800
Subject: [PATCH 194/426] Revert "docs: remove plan documents"
This reverts commit 98b1064f292f6590e7ff200f8de3e80f1a6c3cbd.
---
docs/assistant-features-plan.md | 777 ++++++++++++++++++
docs/docker-multi-instance-plan.md | 494 +++++++++++
docs/i18n-plan.md | 288 +++++++
...cloudflared-openclaw-integration-design.md | 59 ++
...3-16-cron-sessionmessage-tool-ui-design.md | 55 ++
...-03-16-gateway-patch-auto-detect-design.md | 25 +
...026-03-16-gateway-patch-oneclick-design.md | 37 +
.../2026-03-17-ai-config-import-design.md | 37 +
...-03-17-assistant-optimize-toggle-design.md | 27 +
...026-03-17-assistant-ux-and-shell-design.md | 43 +
.../2026-03-17-chat-autoscroll-design.md | 41 +
.../2026-03-17-chat-daylight-shadow-design.md | 37 +
...26-03-17-chat-history-tool-merge-design.md | 30 +
.../2026-03-17-chat-markdown-it-design.md | 25 +
.../2026-03-17-chat-tool-event-live-design.md | 34 +
.../2026-03-17-chat-virtual-scroll-design.md | 25 +
...at-virtual-scroll-implementation-design.md | 34 +
docs/plans/2026-03-17-force-setup-design.md | 31 +
docs/plans/2026-03-17-hosted-agent-design.md | 131 +++
.../2026-03-17-skillhub-env-fix-design.md | 65 ++
...-17-toast-night-and-model-select-design.md | 36 +
docs/plans/2026-03-17-toast-shadow-design.md | 18 +
docs/plans/2026-03-17-toast-vercel-design.md | 27 +
.../plans/2026-03-17-tool-call-meta-design.md | 32 +
.../2026-03-17-ws-connect-bootstrap-design.md | 31 +
.../2026-03-17-ws-ping-multi-req-design.md | 27 +
.../2026-03-17-ws-ping-node-list-design.md | 18 +
.../2026-03-16-gateway-patch-auto-detect.md | 74 ++
.../2026-03-16-gateway-patch-oneclick.md | 169 ++++
.../plans/2026-03-17-ai-config-import.md | 82 ++
.../2026-03-17-assistant-optimize-toggle.md | 52 ++
.../2026-03-17-assistant-ux-and-shell.md | 118 +++
.../plans/2026-03-17-chat-autoscroll.md | 132 +++
.../plans/2026-03-17-chat-daylight-shadow.md | 46 ++
.../2026-03-17-chat-history-tool-merge.md | 81 ++
.../plans/2026-03-17-chat-markdown-it.md | 146 ++++
.../plans/2026-03-17-chat-tool-event-live.md | 97 +++
...3-17-chat-virtual-scroll-implementation.md | 211 +++++
.../plans/2026-03-17-cron-trigger-mode.md | 86 ++
.../2026-03-17-cron-ws-sessionmessage.md | 199 +++++
.../plans/2026-03-17-force-setup.md | 84 ++
.../2026-03-17-skill-trigger-optimization.md | 165 ++++
.../plans/2026-03-17-skillhub-env-fix.md | 102 +++
...2026-03-17-toast-night-and-model-select.md | 99 +++
.../plans/2026-03-17-toast-shadow.md | 54 ++
.../plans/2026-03-17-toast-vercel.md | 54 ++
.../plans/2026-03-17-tool-call-meta.md | 108 +++
.../plans/2026-03-17-ws-connect-bootstrap.md | 86 ++
.../plans/2026-03-17-ws-ping-multi-req.md | 59 ++
.../plans/2026-03-17-ws-ping-node-list.md | 56 ++
50 files changed, 4844 insertions(+)
create mode 100644 docs/assistant-features-plan.md
create mode 100644 docs/docker-multi-instance-plan.md
create mode 100644 docs/i18n-plan.md
create mode 100644 docs/plans/2026-03-16-cloudflared-openclaw-integration-design.md
create mode 100644 docs/plans/2026-03-16-cron-sessionmessage-tool-ui-design.md
create mode 100644 docs/plans/2026-03-16-gateway-patch-auto-detect-design.md
create mode 100644 docs/plans/2026-03-16-gateway-patch-oneclick-design.md
create mode 100644 docs/plans/2026-03-17-ai-config-import-design.md
create mode 100644 docs/plans/2026-03-17-assistant-optimize-toggle-design.md
create mode 100644 docs/plans/2026-03-17-assistant-ux-and-shell-design.md
create mode 100644 docs/plans/2026-03-17-chat-autoscroll-design.md
create mode 100644 docs/plans/2026-03-17-chat-daylight-shadow-design.md
create mode 100644 docs/plans/2026-03-17-chat-history-tool-merge-design.md
create mode 100644 docs/plans/2026-03-17-chat-markdown-it-design.md
create mode 100644 docs/plans/2026-03-17-chat-tool-event-live-design.md
create mode 100644 docs/plans/2026-03-17-chat-virtual-scroll-design.md
create mode 100644 docs/plans/2026-03-17-chat-virtual-scroll-implementation-design.md
create mode 100644 docs/plans/2026-03-17-force-setup-design.md
create mode 100644 docs/plans/2026-03-17-hosted-agent-design.md
create mode 100644 docs/plans/2026-03-17-skillhub-env-fix-design.md
create mode 100644 docs/plans/2026-03-17-toast-night-and-model-select-design.md
create mode 100644 docs/plans/2026-03-17-toast-shadow-design.md
create mode 100644 docs/plans/2026-03-17-toast-vercel-design.md
create mode 100644 docs/plans/2026-03-17-tool-call-meta-design.md
create mode 100644 docs/plans/2026-03-17-ws-connect-bootstrap-design.md
create mode 100644 docs/plans/2026-03-17-ws-ping-multi-req-design.md
create mode 100644 docs/plans/2026-03-17-ws-ping-node-list-design.md
create mode 100644 docs/superpowers/plans/2026-03-16-gateway-patch-auto-detect.md
create mode 100644 docs/superpowers/plans/2026-03-16-gateway-patch-oneclick.md
create mode 100644 docs/superpowers/plans/2026-03-17-ai-config-import.md
create mode 100644 docs/superpowers/plans/2026-03-17-assistant-optimize-toggle.md
create mode 100644 docs/superpowers/plans/2026-03-17-assistant-ux-and-shell.md
create mode 100644 docs/superpowers/plans/2026-03-17-chat-autoscroll.md
create mode 100644 docs/superpowers/plans/2026-03-17-chat-daylight-shadow.md
create mode 100644 docs/superpowers/plans/2026-03-17-chat-history-tool-merge.md
create mode 100644 docs/superpowers/plans/2026-03-17-chat-markdown-it.md
create mode 100644 docs/superpowers/plans/2026-03-17-chat-tool-event-live.md
create mode 100644 docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md
create mode 100644 docs/superpowers/plans/2026-03-17-cron-trigger-mode.md
create mode 100644 docs/superpowers/plans/2026-03-17-cron-ws-sessionmessage.md
create mode 100644 docs/superpowers/plans/2026-03-17-force-setup.md
create mode 100644 docs/superpowers/plans/2026-03-17-skill-trigger-optimization.md
create mode 100644 docs/superpowers/plans/2026-03-17-skillhub-env-fix.md
create mode 100644 docs/superpowers/plans/2026-03-17-toast-night-and-model-select.md
create mode 100644 docs/superpowers/plans/2026-03-17-toast-shadow.md
create mode 100644 docs/superpowers/plans/2026-03-17-toast-vercel.md
create mode 100644 docs/superpowers/plans/2026-03-17-tool-call-meta.md
create mode 100644 docs/superpowers/plans/2026-03-17-ws-connect-bootstrap.md
create mode 100644 docs/superpowers/plans/2026-03-17-ws-ping-multi-req.md
create mode 100644 docs/superpowers/plans/2026-03-17-ws-ping-node-list.md
diff --git a/docs/assistant-features-plan.md b/docs/assistant-features-plan.md
new file mode 100644
index 00000000..9b2dd08a
--- /dev/null
+++ b/docs/assistant-features-plan.md
@@ -0,0 +1,777 @@
+# AI 助手功能扩展规划
+
+> 基于现有工具架构(TOOL_DEFS + executeTool + getEnabledTools),扩展 6 大能力模块。
+> 每个模块独立开关,遵循现有的 `_config.tools.xxx` + 设置面板 toggle 模式。
+
+---
+
+## 当前架构概览
+
+```
+TOOL_DEFS = {
+ system: [get_system_info] // 始终可用
+ process: [list_processes, check_port] // 始终可用
+ interaction: [ask_user] // 始终可用
+ terminal: [run_command] // 开关控制
+ fileOps: [read_file, write_file, list_directory] // 开关控制
+}
+
+_config.tools = { terminal: true/false, fileOps: true/false }
+```
+
+扩展后:
+
+```
+TOOL_DEFS = {
+ ...existing,
+ docker: [docker_list, docker_exec, docker_logs, wsl_exec] // 新增
+ webSearch: [web_search, fetch_url] // 新增
+ ssh: [ssh_exec, ssh_read_file, ssh_write_file] // 新增
+ knowledge: [search_knowledge] // 新增
+}
+
+_config.tools = {
+ ...existing,
+ docker: false, // 默认关闭
+ webSearch: false, // 默认关闭
+ ssh: false, // 默认关闭
+ knowledge: false, // 默认关闭
+}
+```
+
+---
+
+## 模块一:Docker / WSL 管理工具
+
+### 场景
+- 用户的 OpenClaw 可能安装在 Docker 容器或 WSL 中
+- 本地检测不到时,帮用户在容器/WSL 内操作
+- 查看容器日志、进入容器执行命令、管理容器生命周期
+
+### 工具定义
+
+| 工具名 | 描述 | 参数 | 危险等级 |
+|--------|------|------|----------|
+| `docker_list` | 列出 Docker 容器 | `filter?`, `all?` | 安全 |
+| `docker_exec` | 在容器内执行命令 | `container`, `command` | ⚠️ 危险 |
+| `docker_logs` | 查看容器日志 | `container`, `lines?` | 安全 |
+| `docker_compose` | 执行 docker-compose 命令 | `action`, `file?`, `service?` | ⚠️ 危险 |
+| `wsl_exec` | 在 WSL 内执行命令 | `distro?`, `command` | ⚠️ 危险 |
+| `wsl_list` | 列出 WSL 发行版 | — | 安全 |
+
+### 后端实现
+
+```
+Tauri (Rust):
+ - docker_list → Command::new("docker").args(["ps", ...])
+ - docker_exec → Command::new("docker").args(["exec", container, ...])
+ - wsl_exec → Command::new("wsl").args(["-d", distro, "-e", ...]) (Windows only)
+
+dev-api.js (Web):
+ - execSync('docker ps --format json')
+ - execSync(`docker exec ${container} ${command}`)
+ - execSync(`wsl -d ${distro} -e ${command}`) (Windows only)
+```
+
+### 安全围栏
+- `docker_exec` / `wsl_exec` 归入 DANGEROUS_TOOLS
+- `docker rm`, `docker rmi`, `docker system prune` 归入 CRITICAL_PATTERNS
+
+### UI 扩展
+- 设置面板工具权限 tab 新增 toggle:
+ ```
+ Docker / WSL 工具 — 允许管理容器和 WSL 环境
+ ```
+
+### 内置技能卡片
+```js
+{
+ id: 'detect-docker-openclaw',
+ icon: 'docker',
+ name: '检测 Docker/WSL 中的 OpenClaw',
+ desc: '扫描 Docker 容器和 WSL,查找 OpenClaw 安装',
+ tools: ['docker'],
+ prompt: `请帮我检查 Docker 和 WSL 中是否安装了 OpenClaw。
+ 1. 调用 get_system_info 判断操作系统
+ 2. 用 docker_list 列出所有容器,过滤包含 openclaw/gateway 的
+ 3. 如果是 Windows,用 wsl_list 列出 WSL 发行版
+ 4. 对每个 WSL 发行版,用 wsl_exec 执行 "which openclaw" 检测
+ 5. 汇总发现的 OpenClaw 实例及其状态`
+}
+```
+
+### 优先级:高(解决用户最常见困惑)
+### 工时估算:1-2 天
+
+---
+
+## 模块二:联网搜索工具
+
+### 场景
+- 用户遇到不常见的错误,AI 知识库可能没有
+- 搜索 GitHub Issues、文档、Stack Overflow 找到解决方案
+- 查找最新版本信息、API 文档等
+
+### 工具定义
+
+| 工具名 | 描述 | 参数 | 危险等级 |
+|--------|------|------|----------|
+| `web_search` | 联网搜索关键词 | `query`, `max_results?` | 安全 |
+| `fetch_url` | 抓取网页内容 | `url` | 安全 |
+
+### 后端实现方案(3 选 1)
+
+#### 方案 A:DuckDuckGo Instant Answer API(推荐,免费无 Key)
+```js
+// 搜索
+const resp = await fetch(`https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json`)
+// 但 Instant Answer 只返回摘要,不返回搜索结果列表
+
+// 实际搜索需要用 DuckDuckGo HTML 页面解析或第三方库
+```
+
+#### 方案 B:SearXNG 代理(自托管,最灵活)
+```js
+// 部署一个 SearXNG 实例,或者用公共实例
+const resp = await fetch(`https://searx.example.com/search?q=${query}&format=json`)
+```
+
+#### 方案 C:Jina Reader API(推荐搭配使用,免费)
+```js
+// 将任意 URL 转为纯文本/Markdown
+const resp = await fetch(`https://r.jina.ai/${targetUrl}`)
+const text = await resp.text()
+```
+
+### 推荐组合
+- **搜索**:使用 DuckDuckGo 的 `html.duckduckgo.com/html/?q=xxx` 页面解析结果
+- **内容抓取**:使用 Jina Reader `r.jina.ai/URL` 获取纯文本
+- 两者都 **免费无 Key**,无需用户配置
+
+### 系统提示词补充
+```
+## web_search 使用指南
+当你无法确定答案或需要最新信息时,可以使用 web_search 搜索互联网。
+搜索后,如果需要更多内容,可以用 fetch_url 抓取具体页面。
+搜索技巧:
+- 加 site:github.com 搜索 GitHub
+- 加 site:stackoverflow.com 搜索 StackOverflow
+- 搜索错误信息时,用引号包裹关键错误文本
+```
+
+### 安全围栏
+- 搜索和抓取不涉及破坏性操作,不归入 DANGEROUS_TOOLS
+- 但需要网络请求,添加超时保护(10 秒)
+- URL 抓取限制最大内容长度(100KB → 截断)
+
+### UI 扩展
+```
+联网搜索 — 允许搜索互联网和抓取网页内容(需联网)
+```
+
+### 优先级:高(大幅提升问题解决能力)
+### 工时估算:0.5-1 天
+
+---
+
+## 模块三:SSH 远程管理工具
+
+### 场景
+- 用户的 OpenClaw 部署在远程服务器上
+- 帮用户远程安装、配置、排查 OpenClaw
+- 远程查看日志、重启服务、修改配置
+
+### 工具定义
+
+| 工具名 | 描述 | 参数 | 危险等级 |
+|--------|------|------|----------|
+| `ssh_exec` | 在远程服务器执行命令 | `connection_id`, `command` | ⚠️ 危险 |
+| `ssh_read_file` | 读取远程文件 | `connection_id`, `path` | 安全 |
+| `ssh_write_file` | 写入远程文件 | `connection_id`, `path`, `content` | ⚠️ 危险 |
+
+### 配置数据结构
+
+```js
+_config.sshConnections = [
+ {
+ id: 'my-server',
+ name: '生产服务器',
+ host: '192.168.1.100',
+ port: 22,
+ user: 'root',
+ authType: 'key', // 'key' | 'password'
+ keyPath: '~/.ssh/id_rsa',
+ // password 不存储在 localStorage,每次询问或用 keytar 安全存储
+ }
+]
+```
+
+### 后端实现
+
+```
+Tauri (Rust):
+ - 使用 ssh2 crate 或调用系统 ssh CLI
+ - ssh_exec → Command::new("ssh").args(["-p", port, "user@host", command])
+ - ssh_read_file → ssh + cat
+ - ssh_write_file → 通过 stdin pipe 写入
+
+dev-api.js (Web):
+ - 使用 node-ssh 或 ssh2 npm 包
+ - 或者直接调用 ssh CLI
+```
+
+### 设置 UI:新增 tab「远程连接」
+
+```
+┌─────────────────────────────────────────────┐
+│ 模型配置 │ 工具权限 │ 远程连接 │ 助手人设 │
+├─────────────────────────────────────────────┤
+│ │
+│ ┌─ 生产服务器 ──────────────── [编辑] [删] │
+│ │ root@192.168.1.100:22 (密钥认证) │
+│ └──────────────────────────────────────────│
+│ │
+│ ┌─ 测试服务器 ──────────────── [编辑] [删] │
+│ │ admin@10.0.0.5:22 (密码认证) │
+│ └──────────────────────────────────────────│
+│ │
+│ [+ 添加连接] │
+│ │
+│ 提示:推荐使用 SSH 密钥认证。 │
+│ 生成密钥:ssh-keygen -t ed25519 │
+│ 复制公钥:ssh-copy-id user@host │
+│ │
+└─────────────────────────────────────────────┘
+```
+
+### 安全围栏
+- `ssh_exec`, `ssh_write_file` 归入 DANGEROUS_TOOLS
+- 密码认证:每次执行时用 ask_user 确认,或使用系统密钥链
+- SSH 密钥路径验证:检查文件是否存在
+- 关键命令(rm -rf, reboot 等)在远程同样走 CRITICAL_PATTERNS
+
+### 系统提示词补充
+```
+## SSH 远程管理
+用户可能配置了远程服务器连接。当操作远程服务器时:
+- 先用 ask_user 确认要操作哪个连接
+- 远程命令比本地更谨慎,优先使用只读操作
+- 修改配置前先备份(cp xxx xxx.bak)
+```
+
+### 内置技能卡片
+```js
+{
+ id: 'remote-manage',
+ icon: 'network',
+ name: '远程管理 OpenClaw',
+ desc: '通过 SSH 连接远程服务器,管理 OpenClaw',
+ tools: ['ssh', 'fileOps'],
+ prompt: `请帮我管理远程服务器上的 OpenClaw。
+ 1. 获取系统信息,列出已配置的 SSH 连接
+ 2. 用 ask_user 让我选择要操作的服务器
+ 3. 用 ssh_exec 检查远程 OpenClaw 状态
+ 4. 检查 Gateway 进程和端口
+ 5. 读取远程配置和日志
+ 6. 汇总远程 OpenClaw 状态报告`
+}
+```
+
+### 优先级:中(用户量较少但价值极高)
+### 工时估算:2-3 天
+
+---
+
+## 模块四:知识库 + 灵魂移植(借尸还魂)
+
+### 核心理念
+
+OpenClaw 的 Agent 有一套完整的**身份系统**,由工作区引导文件定义:
+
+```
+~/.openclaw/workspace/ ← Agent 的"灵魂"所在
+ ├── AGENTS.md ← 操作指令、规则、记忆管理方式
+ ├── SOUL.md ← 人设、边界、语气("Who You Are")
+ ├── IDENTITY.md ← 名称、物种、风格、表情符号、头像
+ ├── USER.md ← 用户档案(名字、称呼、时区、偏好)
+ ├── TOOLS.md ← 工具本地笔记(SSH 配置、设备名等)
+ ├── HEARTBEAT.md ← 心跳任务清单
+ ├── MEMORY.md ← 精选长期记忆(仅主会话加载)
+ └── memory/ ← 每日记忆日志
+ ├── 2026-03-04-1609.md
+ └── ...
+
+~/.openclaw/agents//agent/ ← Agent 的运行时状态
+ ├── models.json ← 模型提供商配置(baseUrl + apiKey + models)
+ ├── auth-profiles.json ← 认证配置文件
+ └── auth.json
+```
+
+**"借尸还魂"不是复用知识库,而是完整接管 Agent 的灵魂**——
+ClawPanel 的 AI 助手直接读取这些文件,像 OpenClaw 一样把它们注入 system prompt,
+从而变成那个 Agent:有他的名字、他的性格、他的记忆、他认识的用户。
+
+### 4A:灵魂移植(Agent Identity Takeover)
+
+#### 工作流程
+
+1. **扫描** `~/.openclaw/workspace/` 和 `~/.openclaw/agents/` 目录
+2. **发现** 所有可用的 Agent 身份(main、test 等)
+3. **用户选择** 要附身的 Agent
+4. **读取** 该 Agent 的全部引导文件:
+ - `SOUL.md` → 注入为人设(替换 ClawPanel 助手的默认人设)
+ - `IDENTITY.md` → 提取名称/表情/风格(替换助手名称和性格描述)
+ - `USER.md` → 注入用户上下文(知道用户叫什么、偏好什么)
+ - `AGENTS.md` → 注入操作规则(Agent 的行为准则)
+ - `TOOLS.md` → 注入工具笔记
+ - `MEMORY.md` → 注入长期记忆
+ - `memory/` → 注入最近的每日记忆(最近 3 天)
+5. **注入** 到 `buildSystemPrompt()` 中,完全替代默认人设
+
+#### 实现
+
+```js
+// 新增配置项
+_config.soulSource = null // null = 使用 ClawPanel 默认 | 'openclaw:main' | 'openclaw:test' | 'custom'
+_config.soulCache = null // 缓存读取的灵魂文件内容
+
+// buildSystemPrompt 改造
+function buildSystemPrompt() {
+ if (_config.soulSource?.startsWith('openclaw:')) {
+ // 借尸还魂模式:使用 OpenClaw Agent 的灵魂
+ return buildOpenClawSoulPrompt()
+ }
+ // 默认模式:使用 ClawPanel 自带的系统提示词
+ return buildDefaultPrompt()
+}
+
+function buildOpenClawSoulPrompt() {
+ const soul = _config.soulCache
+ if (!soul) return buildDefaultPrompt() // fallback
+
+ let prompt = ''
+
+ // 1. 身份注入
+ if (soul.identity) {
+ prompt += `# Identity\n${soul.identity}\n\n`
+ }
+
+ // 2. 灵魂注入(人设、边界、语气)
+ if (soul.soul) {
+ prompt += `# Soul\n${soul.soul}\n\n`
+ }
+
+ // 3. 用户上下文
+ if (soul.user) {
+ prompt += `# User\n${soul.user}\n\n`
+ }
+
+ // 4. 操作规则
+ if (soul.agents) {
+ prompt += `# Operating Instructions\n${soul.agents}\n\n`
+ }
+
+ // 5. 工具笔记
+ if (soul.tools) {
+ prompt += `# Tool Notes\n${soul.tools}\n\n`
+ }
+
+ // 6. 长期记忆
+ if (soul.memory) {
+ prompt += `# Long-term Memory\n${soul.memory}\n\n`
+ }
+
+ // 7. 最近的每日记忆
+ if (soul.recentMemories?.length) {
+ prompt += `# Recent Memory\n`
+ for (const m of soul.recentMemories) {
+ prompt += `## ${m.date}\n${m.content}\n\n`
+ }
+ }
+
+ // 8. 追加 ClawPanel 特有的工具说明(保持工具能力)
+ prompt += buildToolInstructions()
+
+ return prompt
+}
+```
+
+#### 灵魂加载函数
+
+```js
+async function loadOpenClawSoul(agentId = 'main') {
+ const home = await getHomeDir()
+ const ws = `${home}/.openclaw/workspace` // 工作区是全局的,不按 agentId 分
+
+ const readSafe = async (path) => {
+ try { return await api.assistantReadFile(path) }
+ catch { return null }
+ }
+
+ const soul = {
+ identity: await readSafe(`${ws}/IDENTITY.md`),
+ soul: await readSafe(`${ws}/SOUL.md`),
+ user: await readSafe(`${ws}/USER.md`),
+ agents: await readSafe(`${ws}/AGENTS.md`),
+ tools: await readSafe(`${ws}/TOOLS.md`),
+ memory: await readSafe(`${ws}/MEMORY.md`),
+ recentMemories: [],
+ }
+
+ // 读取最近 3 天的每日记忆
+ try {
+ const memDir = await api.assistantListDir(`${ws}/memory`)
+ const files = memDir.split('\n').filter(f => f.match(/\d{4}-\d{2}-\d{2}/))
+ const recent = files.sort().slice(-3)
+ for (const f of recent) {
+ const content = await readSafe(`${ws}/memory/${f.trim()}`)
+ if (content) soul.recentMemories.push({ date: f.trim(), content })
+ }
+ } catch {}
+
+ return soul
+}
+```
+
+#### UI:设置面板「助手人设」Tab 改造
+
+```
+┌─────────────────────────────────────────────┐
+│ 模型配置 │ 工具权限 │ 知识库 │ 远程连接 │ 人设 │
+├─────────────────────────────────────────────┤
+│ │
+│ 身份来源 │
+│ ┌──────────────────────────────────────────│
+│ │ ● ClawPanel 默认人设 │ ← 当前默认
+│ │ ○ OpenClaw Agent 身份(借尸还魂) │ ← 新增
+│ │ ○ 自定义人设 │
+│ └──────────────────────────────────────────│
+│ │
+│ ─── 当选择「OpenClaw Agent」时显示 ──── │
+│ │
+│ 选择 Agent: [main ▼] │
+│ │
+│ 灵魂文件预览 │
+│ ┌──────────────────────────────────────────│
+│ │ SOUL.md ✓ 已加载 (1.6KB) │
+│ │ IDENTITY.md ✓ 已加载 (636B) │
+│ │ USER.md ✓ 已加载 (237B) │
+│ │ AGENTS.md ✓ 已加载 (7.8KB) │
+│ │ TOOLS.md ✓ 已加载 (860B) │
+│ │ MEMORY.md ✕ 未找到 │
+│ │ memory/ 2 个日志文件 │
+│ └──────────────────────────────────────────│
+│ │
+│ [附身] [刷新] │
+│ │
+│ 注意:附身后,助手将使用该 Agent 的人格、│
+│ 记忆和用户偏好。可随时切回默认。 │
+│ │
+│ ─── 当选择「ClawPanel 默认」时显示 ──── │
+│ │
+│ 助手名称: [晴辰助手 ] │
+│ 助手性格: [________________________] │
+│ │
+└─────────────────────────────────────────────┘
+```
+
+#### 附身后的效果
+
+| 维度 | 默认模式 | 附身模式 |
+|------|----------|----------|
+| 名称 | "晴辰助手" | IDENTITY.md 中的名称 |
+| 性格 | 简洁专业 | SOUL.md 定义的风格 |
+| 称呼用户 | "你" | USER.md 中的称呼(如"爸爸") |
+| 行为规则 | ClawPanel 内置 | AGENTS.md 的规则体系 |
+| 记忆 | 无 | MEMORY.md + 每日记忆 |
+| 工具知识 | ClawPanel 内置 | TOOLS.md 的本地笔记 |
+| 工具能力 | 保持不变 | 保持 ClawPanel 的工具 |
+
+**关键设计**:附身只替换"灵魂"(system prompt),**工具能力保持 ClawPanel 的**。
+因为 OpenClaw 的工具(exec/read/edit/write)和 ClawPanel 的工具本质相同,
+但 ClawPanel 有独有的 docker/ssh/搜索等扩展工具,这些要保留。
+
+### 4B:自定义知识库
+
+在灵魂移植之外,仍然支持用户上传额外的知识文档:
+
+#### 数据存储
+```
+~/.openclaw/clawpanel-kb/
+ ├── index.json # 知识库索引
+ ├── docs/
+ │ ├── api-guide.md # 用户上传的文档
+ │ ├── faq.md
+ │ └── deploy-notes.txt
+ └── chunks/ # 分块索引(可选,用于大文档)
+ └── ...
+```
+
+#### 实现方案
+
+**V1(简单方案)**:
+- 小文档(<8KB)直接全文注入 system prompt 尾部
+- 大文档做关键词搜索(正则匹配 + 上下文窗口)
+- 总注入 token 上限:4000 tokens
+- 知识库和灵魂移植可叠加使用
+
+**V2(进阶方案)**:
+- embedding 语义搜索
+- `search_knowledge` 工具让 AI 按需检索
+
+### 优先级:� 高(灵魂移植是杀手级差异化功能)
+### 工时估算:灵魂移植 1-2 天,自定义知识库 V1 额外 1 天
+
+---
+
+## 模块五:模型配置自动导入
+
+### 场景
+- 用户已安装 OpenClaw 并配置了模型
+- ClawPanel AI 助手需要单独配置模型(目前手动填写)
+- 一键从 OpenClaw 配置导入,省去重复配置
+
+### 实现
+
+#### 数据来源(两个层级)
+
+**层级 1:全局配置** `~/.openclaw/openclaw.json`
+```json
+{
+ "models": {
+ "providers": {
+ "shengsuanyun": {
+ "baseUrl": "http://127.0.0.1:8082/v1",
+ "apiKey": "sk-xxx",
+ "api": "openai-completions"
+ }
+ }
+ }
+}
+```
+
+**层级 2:Agent 模型注册表** `~/.openclaw/agents//agent/models.json`
+```json
+{
+ "providers": {
+ "openai": {
+ "baseUrl": "http://127.0.0.1:8082/v1",
+ "apiKey": "sk-eB3ybVNFvqB4fGrTUp3F8Lq16QxF7tut",
+ "api": "openai-completions",
+ "models": [
+ { "id": "gpt-5.4", "name": "gpt-5.4", "contextWindow": 200000, "maxTokens": 8192 },
+ { "id": "gpt-5.2-codex", "name": "gpt-5.2-codex", ... }
+ ]
+ }
+ }
+}
+```
+
+**推荐优先读取 Agent 的 models.json**——它有完整的 baseUrl + apiKey + models 列表,
+一键就能填充 ClawPanel 助手的配置。
+
+#### 读取逻辑
+```js
+async function discoverOpenClawModels() {
+ const home = await getHomeDir()
+ const results = []
+
+ // 1. 扫描所有 Agent 的 models.json
+ try {
+ const agents = await api.assistantListDir(`${home}/.openclaw/agents`)
+ for (const agentId of agents.split('\n').map(s => s.trim()).filter(Boolean)) {
+ try {
+ const raw = await api.assistantReadFile(`${home}/.openclaw/agents/${agentId}/agent/models.json`)
+ const data = JSON.parse(raw)
+ for (const [providerId, provider] of Object.entries(data.providers || {})) {
+ results.push({
+ source: `Agent: ${agentId}`,
+ providerId,
+ baseUrl: provider.baseUrl,
+ apiKey: provider.apiKey,
+ apiType: provider.api === 'openai-completions' ? 'openai' : provider.api,
+ models: (provider.models || []).map(m => m.id || m.name),
+ })
+ }
+ } catch {}
+ }
+ } catch {}
+
+ // 2. 读取全局 openclaw.json 作为补充
+ try {
+ const raw = await api.assistantReadFile(`${home}/.openclaw/openclaw.json`)
+ const config = JSON.parse(raw)
+ for (const [providerId, provider] of Object.entries(config.models?.providers || {})) {
+ // 去重:如果 Agent models.json 已有相同 providerId,跳过
+ if (!results.find(r => r.providerId === providerId)) {
+ results.push({
+ source: '全局配置',
+ providerId,
+ baseUrl: provider.baseUrl,
+ apiKey: provider.apiKey,
+ apiType: 'openai',
+ models: [], // 全局配置没有 models 列表
+ })
+ }
+ }
+ } catch {}
+
+ return results
+}
+```
+
+#### UI:模型配置 tab 新增「导入」按钮
+
+```
+┌─────────────────────────────────────────────┐
+│ API Base URL API 类型 │
+│ [________________________] [OpenAI 兼容 ▼] │
+│ │
+│ API Key [测试] [拉取] [导入] │ ← 新增「导入」按钮
+│ [________________________] │
+│ │
+│ 模型 温度 │
+│ [________________________] [0.7] │
+│ │
+└─────────────────────────────────────────────┘
+```
+
+点击「导入」弹出选择面板:
+
+```
+┌─────────────────────────────────────────────┐
+│ 从 OpenClaw 导入模型配置 │
+│ │
+│ 检测到以下已配置的服务商: │
+│ │
+│ ○ OpenAI │
+│ https://api.openai.com/v1 │
+│ 模型: gpt-4o, gpt-4o-mini │
+│ │
+│ ○ DeepSeek │
+│ https://api.deepseek.com │
+│ 模型: deepseek-chat, deepseek-reasoner │
+│ │
+│ ○ 本地 Ollama │
+│ http://127.0.0.1:11434/v1 │
+│ 模型: qwen2.5:7b │
+│ │
+│ 选择一个服务商,自动填充配置。 │
+│ [取消] [导入] │
+└─────────────────────────────────────────────┘
+```
+
+### 后端
+
+```
+Tauri: 已有 read_openclaw_config 命令
+dev-api.js: 已有 read_config handler
+
+// 只需在前端加一个读取+解析+填充的逻辑
+```
+
+### 优先级:高(零成本,纯前端,极大提升体验)
+### 工时估算:0.5 天
+
+---
+
+## 实施路线图
+
+### Phase 1:快速见效(1-2 天)
+| 序号 | 功能 | 工时 | 理由 |
+|------|------|------|------|
+| 1 | **模型配置自动导入** | 0.5d | 读 Agent models.json → 一键填充,纯前端零风险 |
+| 2 | **联网搜索工具** | 0.5-1d | DuckDuckGo + Jina,免费无 Key |
+| 3 | **灵魂移植(借尸还魂)** | 1-2d | 杀手级差异化——读 SOUL/IDENTITY/USER/AGENTS/MEMORY → 变身 |
+
+### Phase 2:核心扩展(2-3 天)
+| 序号 | 功能 | 工时 | 理由 |
+|------|------|------|------|
+| 4 | **Docker/WSL 工具** | 1-2d | 解决用户最常见的安装困惑 |
+| 5 | **自定义知识库 V1** | 1d | 用户上传 md/txt → 注入 prompt |
+
+### Phase 3:高级功能(3-5 天)
+| 序号 | 功能 | 工时 | 理由 |
+|------|------|------|------|
+| 6 | **SSH 远程管理** | 2-3d | 价值最高但复杂度也最高 |
+| 7 | **知识库 V2(语义搜索)** | 3-5d | 依赖 embedding API |
+
+---
+
+## 设置面板 Tab 规划
+
+当前 3 个 Tab → 扩展为 5 个 Tab:
+
+```
+模型配置 │ 工具权限 │ 知识库 │ 远程连接 │ 助手人设
+```
+
+### 工具权限 Tab 最终形态
+
+```
+基础工具
+ ☑ 终端工具 — 允许执行 Shell 命令
+ ☑ 文件工具 — 允许读写文件和浏览目录
+
+扩展工具
+ ☐ Docker/WSL — 允许管理容器和 WSL 环境
+ ☐ 联网搜索 — 允许搜索互联网和抓取网页
+ ☐ SSH 远程 — 允许连接远程服务器(需先配置连接)
+ ☐ 知识库 — 允许检索知识库内容
+
+ℹ️ 进程列表、端口检测、系统信息工具始终可用(非聊天模式下)。
+```
+
+---
+
+## 技术注意事项
+
+### 1. Token 预算管理
+灵魂移植 + 知识库注入会占用 context window,需要精细管理:
+
+| 组件 | 预算 | 说明 |
+|------|------|------|
+| ClawPanel 基础 prompt | ~2000 tokens | 产品介绍、工具指南、技能卡片 |
+| SOUL.md | ~500 tokens | 人设通常简短 |
+| IDENTITY.md | ~200 tokens | 名称/风格 |
+| USER.md | ~200 tokens | 用户档案 |
+| AGENTS.md | ~3000 tokens | 操作规则(最大,可截断) |
+| TOOLS.md | ~300 tokens | 工具笔记 |
+| MEMORY.md | ~2000 tokens | 长期记忆(截断保留最近部分) |
+| 每日记忆 (3天) | ~1500 tokens | 自动截断 |
+| 自定义知识库 | ~4000 tokens | 用户上传文档 |
+| 搜索结果 | ~2000 tokens | web_search 返回内容 |
+| **总计上限** | **~16000 tokens** | 留足空间给对话历史 |
+
+策略:
+- AGENTS.md 超过 3000 tokens 时截断尾部,保留前面的核心规则
+- MEMORY.md 超过 2000 tokens 时只保留最后 2000 tokens
+- 每日记忆超过 500 tokens/天时截断
+- 灵魂文件加载时计算总 token 并在 UI 中显示
+
+### 2. 跨平台兼容
+- Docker CLI 在 Windows/Mac/Linux 都可用
+- WSL 仅 Windows
+- SSH 密钥路径:Windows 用 `%USERPROFILE%\.ssh\`,Mac/Linux 用 `~/.ssh/`
+
+### 3. 安全存储
+- SSH 密码/API Key:
+ - Tauri 模式:使用 keytar 或 tauri-plugin-store 加密存储
+ - Web 模式:仅支持密钥认证(不存储密码)
+- 知识库文件:存储在 `~/.openclaw/` 下,与用户数据同目录
+
+### 4. 工具发现
+AI 模型需要知道哪些工具可用。当前已在 `buildSystemPrompt()` 中列出技能卡片。
+新增工具后,需要在系统提示词中补充使用指南(类似现有的 `ask_user` 指南)。
+
+---
+
+## 文件变更预估
+
+| 文件 | 变更 |
+|------|------|
+| `src/pages/assistant.js` | TOOL_DEFS 新增 4 类 · executeTool 新增 case · getEnabledTools 新增分支 · 设置面板 UI · 模型导入弹窗 |
+| `src-tauri/src/commands/assistant.rs` | 新增 Rust 命令:docker_*, wsl_*, ssh_*, web_search, fetch_url |
+| `scripts/dev-api.js` | 新增 Web 模式 handler:同上 |
+| `src/style/assistant.css` | 知识库管理 UI · SSH 连接管理 UI · 导入弹窗样式 |
+| `src/pages/assistant.js` (prompt) | 系统提示词新增各工具使用指南 |
diff --git a/docs/docker-multi-instance-plan.md b/docs/docker-multi-instance-plan.md
new file mode 100644
index 00000000..0851aa03
--- /dev/null
+++ b/docs/docker-multi-instance-plan.md
@@ -0,0 +1,494 @@
+# ClawPanel Docker 多实例管理 — 技术规划
+
+> 版本: v1.0 | 日期: 2026-03-08
+
+## 1. 问题分析
+
+### 1.1 现状
+
+ClawPanel 当前架构是 **单实例管理**:
+
+```
+浏览器 → ClawPanel 前端
+ │
+ ├── /__api/* → dev-api.js → 读写本机 ~/.openclaw/ 文件
+ ├── /ws → 代理到本机 Gateway:18789 (WebSocket)
+ └── 静态文件 → dist/
+```
+
+**所有页面**(模型配置、Agent 管理、Gateway 设置、日志、聊天等)操作的都是:
+- 本机文件系统上的 `~/.openclaw/openclaw.json`
+- 本机运行的 Gateway 进程(端口 18789)
+
+### 1.2 Phase 1 已完成
+
+Docker 集群页面实现了 **容器生命周期管理**(通过 Docker Socket API):
+- 启动/停止/重启/删除容器
+- 部署新容器(端口映射、数据卷、环境变量)
+- 查看容器日志
+- 多节点管理(本机 + 远程 Docker 主机)
+
+### 1.3 缺口
+
+Docker 页面能管容器的"壳",但 **无法管理容器里的 OpenClaw**:
+- 无法配置某个容器内的模型
+- 无法查看某个容器内的 Gateway 日志
+- 无法管理某个容器内的 Agent
+- 聊天功能只连本机 Gateway
+
+---
+
+## 2. 目标架构
+
+### 2.1 核心思路:API 代理 + 实例切换
+
+```
+┌──────────────────────────────────────────────────┐
+│ ClawPanel 前端 │
+│ ┌────────────────────────────────────────────┐ │
+│ │ 实例切换器: [ ● 本机 ▼ ] │ │
+│ │ [ ○ prod-server (Docker) ] │ │
+│ │ [ ○ dev-box (远程) ] │ │
+│ │ [ + 添加实例 ] │ │
+│ └────────────────────────────────────────────┘ │
+│ │
+│ 现有页面(模型/Agent/Gateway/日志/聊天...) │
+│ │ │
+│ │ api.readOpenclawConfig() │
+│ │ api.listAgents() │
+│ ▼ │
+│ tauri-api.js → webInvoke('read_openclaw_config') │
+│ │ │
+│ 自动附带 instanceId │
+└──────────────────┼───────────────────────────────┘
+ ▼
+ dev-api.js (本机后端)
+ │
+ ┌────────┼────────┐
+ ▼ ▼ ▼
+ 本机文件 代理转发 代理转发
+ ~/.openclaw ↓ ↓
+ 实例 A 实例 B
+ http://host http://192.168.1.100
+ :18790 :1420
+ /__api/* /__api/*
+```
+
+**关键点:每个 Docker 容器运行 full 镜像,内含完整的 ClawPanel (serve.js) + Gateway。**
+因此每个容器已经有自己的 `/__api/*` 端点,我们只需要代理请求过去。
+
+### 2.2 WebSocket 连接
+
+```
+切换实例时:
+ wsClient.disconnect() ← 断开旧连接
+ wsClient.connect(newHost, newToken) ← 连接新实例的 Gateway
+```
+
+WebSocket 连接信息从目标实例的配置中读取(通过代理 API 获取 `read_openclaw_config`)。
+
+### 2.3 自动组网流程
+
+部署新容器时自动完成:
+
+```
+用户点击「部署容器」
+ │
+ ├─ 1. Docker API 创建容器(端口映射 hostPort→1420, hostPort→18789)
+ ├─ 2. 启动容器,等待健康检查通过
+ ├─ 3. 探测容器 Panel 端点:GET http://hostIP:hostPort/__api/check_installation
+ ├─ 4. 自动写入实例注册表 ~/.openclaw/instances.json
+ └─ 5. 前端自动刷新实例列表
+```
+
+---
+
+## 3. 数据结构
+
+### 3.1 实例注册表
+
+文件位置:`~/.openclaw/instances.json`
+
+```json
+{
+ "activeId": "local",
+ "instances": [
+ {
+ "id": "local",
+ "name": "本机",
+ "type": "local",
+ "endpoint": null,
+ "gatewayPort": 18789,
+ "addedAt": 1741420800,
+ "note": ""
+ },
+ {
+ "id": "docker-abc123",
+ "name": "openclaw-prod",
+ "type": "docker",
+ "endpoint": "http://127.0.0.1:18790",
+ "gatewayPort": 18789,
+ "containerId": "abc123def456",
+ "nodeId": "local",
+ "addedAt": 1741420900,
+ "note": "生产环境"
+ },
+ {
+ "id": "remote-1",
+ "name": "办公室服务器",
+ "type": "remote",
+ "endpoint": "http://192.168.1.100:1420",
+ "gatewayPort": 18789,
+ "addedAt": 1741421000,
+ "note": ""
+ }
+ ]
+}
+```
+
+**三种实例类型:**
+
+| type | 说明 | 来源 |
+|------|------|------|
+| `local` | 本机 OpenClaw | 始终存在,不可删除 |
+| `docker` | Docker 容器内的 OpenClaw | 部署容器时自动注册 |
+| `remote` | 远程服务器上的 OpenClaw | 用户手动添加 |
+
+### 3.2 实例状态(运行时,不持久化)
+
+```js
+{
+ id: 'docker-abc123',
+ online: true, // 健康检查结果
+ version: '2026.3.5', // OpenClaw 版本
+ gatewayRunning: true, // Gateway 状态
+ lastCheck: 1741420999, // 上次检查时间
+}
+```
+
+---
+
+## 4. 改动清单
+
+### 4.1 后端 dev-api.js
+
+#### 4.1.1 实例注册表管理(新增)
+
+```
+新增 handlers:
+ instance_list → 读取 instances.json
+ instance_add → 添加实例(手动或自动)
+ instance_remove → 删除实例
+ instance_set_active → 切换活跃实例
+ instance_health_check → 健康检查单个实例
+ instance_health_all → 批量健康检查
+```
+
+#### 4.1.2 API 代理转发(核心改动)
+
+改造 `_apiMiddleware`:
+
+```js
+// 伪代码
+async function _apiMiddleware(req, res, next) {
+ if (!req.url?.startsWith('/__api/')) return next()
+
+ const cmd = extractCmd(req.url)
+ const body = await readBody(req)
+
+ // 实例管理命令 → 始终本机处理
+ if (cmd.startsWith('instance_') || cmd.startsWith('docker_') || ALWAYS_LOCAL.has(cmd)) {
+ return handleLocally(cmd, body, res)
+ }
+
+ // 获取当前活跃实例
+ const active = getActiveInstance()
+
+ if (active.type === 'local') {
+ // 本机 → 直接处理(现有逻辑不变)
+ return handleLocally(cmd, body, res)
+ }
+
+ // 远程/Docker 实例 → 代理转发
+ return proxyToInstance(active, cmd, body, res)
+}
+```
+
+**始终在本机处理的命令(ALWAYS_LOCAL):**
+- `instance_*` — 实例管理本身
+- `docker_*` — Docker 容器管理
+- `auth_*` — 认证
+- `read_panel_config` / `write_panel_config` — 本地面板配置
+- `assistant_*` — AI 助手(操作本机文件系统)
+
+**通过代理转发的命令:**
+- `read_openclaw_config` / `write_openclaw_config` — 目标实例的配置
+- `get_services_status` / `start_service` / `stop_service` — 目标实例的服务
+- `list_agents` / `add_agent` / `delete_agent` — 目标实例的 Agent
+- `read_log_tail` / `search_log` — 目标实例的日志
+- `get_version_info` / `upgrade_openclaw` — 目标实例的版本
+- `list_memory_files` / `read_memory_file` — 目标实例的记忆文件
+- `read_mcp_config` / `write_mcp_config` — 目标实例的 MCP 配置
+- 等其他 OpenClaw 相关命令
+
+#### 4.1.3 代理转发实现
+
+```js
+async function proxyToInstance(instance, cmd, body, res) {
+ const url = `${instance.endpoint}/__api/${cmd}`
+ try {
+ const resp = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ const data = await resp.text()
+ res.writeHead(resp.status, { 'Content-Type': 'application/json' })
+ res.end(data)
+ } catch (e) {
+ res.writeHead(502, { 'Content-Type': 'application/json' })
+ res.end(JSON.stringify({ error: `实例 ${instance.name} 不可达: ${e.message}` }))
+ }
+}
+```
+
+#### 4.1.4 Docker 部署自动注册
+
+修改 `docker_create_container` handler:
+- 容器创建并启动后,自动等待健康检查
+- 通过 `GET http://hostIP:panelPort/__api/check_installation` 验证
+- 健康检查通过后自动写入 `instances.json`
+- 返回结果包含 `instanceId`
+
+### 4.2 前端 tauri-api.js
+
+#### 4.2.1 新增实例管理 API
+
+```js
+// 实例管理
+instanceList: () => cachedInvoke('instance_list', {}, 10000),
+instanceAdd: (instance) => { invalidate('instance_list'); return invoke('instance_add', instance) },
+instanceRemove: (id) => { invalidate('instance_list'); return invoke('instance_remove', { id }) },
+instanceSetActive: (id) => { invalidate('instance_list'); _cache.clear(); return invoke('instance_set_active', { id }) },
+instanceHealthCheck: (id) => invoke('instance_health_check', { id }),
+instanceHealthAll: () => invoke('instance_health_all'),
+```
+
+**注意 `instanceSetActive` 清空全部缓存**,因为切换实例后所有缓存数据都过期了。
+
+#### 4.2.2 无需改动的部分
+
+现有的 `api.readOpenclawConfig()`、`api.listAgents()` 等方法 **完全不变**。
+代理逻辑在后端 `_apiMiddleware` 层透明处理。
+
+### 4.3 前端 app-state.js
+
+新增:
+
+```js
+let _activeInstance = { id: 'local', name: '本机', type: 'local' }
+let _instanceListeners = []
+
+export function getActiveInstance() { return _activeInstance }
+export function onInstanceChange(fn) { ... }
+
+export async function switchInstance(id) {
+ // 1. 调后端切换
+ await api.instanceSetActive(id)
+ // 2. 更新本地状态
+ _activeInstance = instances.find(i => i.id === id)
+ // 3. 清缓存
+ invalidate() // 清 API 缓存
+ // 4. 断开旧 WebSocket
+ wsClient.disconnect()
+ // 5. 重新检测状态
+ await detectOpenclawStatus()
+ // 6. 连接新实例的 Gateway WebSocket
+ connectToActiveGateway()
+ // 7. 通知所有监听者(侧边栏、页面刷新)
+ _instanceListeners.forEach(fn => fn(_activeInstance))
+}
+```
+
+### 4.4 前端 sidebar.js
+
+在侧边栏顶部 logo 下方添加实例切换器:
+
+```html
+
+
+
+
+ 本机
+
+
+ openclaw-prod
+ Docker
+
+
+
+ + 添加实例
+
+
+
+```
+
+### 4.5 前端 main.js
+
+`autoConnectWebSocket()` 改为读取当前活跃实例的 Gateway 端点:
+
+```js
+async function autoConnectWebSocket() {
+ const instance = getActiveInstance()
+ if (instance.type === 'local') {
+ // 本机:读本地配置
+ const config = await api.readOpenclawConfig()
+ const port = config?.gateway?.port || 18789
+ wsClient.connect(`127.0.0.1:${port}`, token)
+ } else {
+ // 远程/Docker:从实例 endpoint 推导 Gateway 地址
+ const config = await api.readOpenclawConfig() // 已通过代理转发
+ const gwPort = config?.gateway?.port || 18789
+ const url = new URL(instance.endpoint)
+ wsClient.connect(`${url.hostname}:${instance.gatewayPort || gwPort}`, token)
+ }
+}
+```
+
+### 4.6 serve.js WebSocket 代理
+
+WebSocket 代理改为动态目标:
+
+```js
+server.on('upgrade', (req, socket, head) => {
+ // 从 query 或 header 中获取目标实例
+ const target = resolveWsTarget(req)
+ const conn = net.createConnection(target.port, target.host, () => { ... })
+})
+```
+
+### 4.7 docker.js 集群页面
+
+部署对话框增加"自动注册"逻辑:
+- 容器创建成功后显示"正在等待实例就绪..."
+- 健康检查通过后自动出现在实例切换器中
+- 用户可直接切换到新实例进行管理
+
+### 4.8 现有页面适配
+
+| 页面 | 改动 | 说明 |
+|------|------|------|
+| dashboard.js | 极小 | 页头显示当前实例名称 |
+| models.js | 无 | API 透明代理 |
+| agents.js | 无 | API 透明代理 |
+| gateway.js | 极小 | 远程实例时隐藏部分本机功能 |
+| logs.js | 无 | API 透明代理 |
+| chat.js | 无 | WebSocket 已切换到目标实例 |
+| chat-debug.js | 无 | API 透明代理 |
+| memory.js | 无 | API 透明代理 |
+| services.js | 小 | 已有 Docker 适配,远程实例时隐藏 npm/CLI 相关 |
+| extensions.js | 小 | 远程实例时 cftunnel/clawapp 不可用 |
+| skills.js | 无 | API 透明代理 |
+| security.js | 小 | 远程实例的密码管理走代理 |
+| setup.js | 小 | 远程实例不需要 setup 流程 |
+| assistant.js | 特殊 | AI 助手始终操作本机(ALWAYS_LOCAL) |
+
+---
+
+## 5. 实施步骤
+
+### Step 1: 实例注册表后端(dev-api.js)
+- `readInstances()` / `saveInstances()` 工具函数
+- 6 个 handler:`instance_list` / `add` / `remove` / `set_active` / `health_check` / `health_all`
+- 预计:~150 行
+
+### Step 2: API 代理转发(dev-api.js)
+- 改造 `_apiMiddleware` 添加代理逻辑
+- `proxyToInstance()` 函数
+- `ALWAYS_LOCAL` 命令集合
+- 预计:~80 行
+
+### Step 3: 前端实例管理 API(tauri-api.js)
+- 新增 `api.instance*` 方法 + mock 数据
+- 预计:~40 行
+
+### Step 4: 前端状态管理(app-state.js)
+- `_activeInstance` 状态 + `switchInstance()` 函数
+- 预计:~50 行
+
+### Step 5: 实例切换器 UI(sidebar.js)
+- 下拉选择器组件 + CSS
+- 预计:~100 行 JS + ~80 行 CSS
+
+### Step 6: WebSocket 动态连接(main.js + serve.js)
+- 切换实例时重新连接 WebSocket
+- serve.js WebSocket 代理动态化
+- 预计:~40 行
+
+### Step 7: Docker 部署自动注册(docker.js + dev-api.js)
+- `docker_create_container` 完成后自动注册
+- 健康检查 + 就绪等待
+- 预计:~60 行
+
+### Step 8: 页面微调
+- dashboard 显示实例名
+- 远程实例时隐藏本机独占功能
+- 预计:~30 行
+
+**总计新增代码:约 600 行**
+
+---
+
+## 6. 安全考虑
+
+### 6.1 认证
+- 远程实例可能有不同的访问密码
+- 代理转发时需要携带目标实例的认证凭据
+- 首次连接时提示输入密码,存入 `instances.json`(加密存储待定)
+
+### 6.2 网络安全
+- Docker 容器默认只暴露在宿主机网络
+- 远程实例建议通过 SSH 隧道或 VPN 连接
+- 不建议在公网暴露 `/__api/` 端点而不加密码
+
+### 6.3 权限隔离
+- AI 助手(assistant_*)始终操作本机文件系统,不代理到远程
+- Docker 管理(docker_*)始终操作本机 Docker,不代理
+
+---
+
+## 7. 边界与约束
+
+### 7.1 不做的事情
+- **不做** 统一聚合视图(如"查看所有实例的模型列表")
+- **不做** 跨实例数据同步(如"把本机模型配置复制到远程")— 后续可做
+- **不做** 实例间负载均衡
+- **不做** 复杂的权限角色系统
+
+### 7.2 前提条件
+- 远程实例必须运行 ClawPanel(serve.js),版本 >= 0.7.0
+- Docker 实例使用 full 镜像(含 Panel + Gateway)
+- 网络可达(ClawPanel 后端能访问远程实例的端口)
+
+### 7.3 兼容性
+- 现有单实例用户 **零影响**:默认 activeId 为 "local",行为完全不变
+- 实例切换器在只有本机时可以隐藏或最小化显示
+- 所有新功能向后兼容
+
+---
+
+## 8. 测试计划
+
+| 场景 | 验证内容 |
+|------|---------|
+| 纯本机使用 | 现有功能不受影响,无回归 |
+| 部署 Docker 容器 | 自动注册为可管理实例 |
+| 切换到 Docker 实例 | 模型/Agent/日志等页面显示容器内数据 |
+| 切换实例后聊天 | WebSocket 连接到正确的 Gateway |
+| 远程实例离线 | 优雅报错,可切回本机 |
+| 删除 Docker 容器 | 实例列表自动移除 |
+| 多实例批量健康检查 | 侧边栏状态点实时更新 |
diff --git a/docs/i18n-plan.md b/docs/i18n-plan.md
new file mode 100644
index 00000000..2a7cfa4c
--- /dev/null
+++ b/docs/i18n-plan.md
@@ -0,0 +1,288 @@
+# ClawPanel i18n 国际化方案
+
+> 本文档是 ClawPanel 多语言国际化的完整技术方案和实施指南。
+> 任何后续会话开始 i18n 工作时,请先阅读本文档。
+
+## 一、现状评估
+
+- **硬编码中文行数**:3508 行(分布在 25+ 个 JS 文件中)
+- **预估翻译字符串数**:约 1500+ 个
+- **技术栈**:纯 Vanilla JS(无 React/Vue),Tauri v2 桌面应用
+- **当前语言**:仅中文
+
+### 文件中文行数分布(Top 15)
+
+| 行数 | 文件 | 模块 |
+|------|------|------|
+| 838 | assistant.js | AI 助手(含内嵌知识库) |
+| 312 | docker.js | Docker 集群管理 |
+| 243 | models.js | 模型配置 |
+| 183 | chat.js | 实时聊天 |
+| 156 | chat-debug.js | 系统诊断 |
+| 148 | openclaw-kb.js | 知识库文本 |
+| 142 | setup.js | 初始安装引导 |
+| 136 | channels.js | 消息渠道 |
+| 136 | main.js | 主入口/路由/横幅 |
+| 120 | services.js | 服务管理 |
+| 105 | about.js | 关于页面 |
+| 93 | cron.js | 定时任务 |
+| 88 | dashboard.js | 仪表盘 |
+| 72 | extensions.js | 扩展工具 |
+| 68 | gateway.js | 网关配置 |
+
+## 二、技术架构
+
+### 核心模块:`src/lib/i18n.js`
+
+```js
+// 使用方式
+import { t, setLocale, getLocale } from '../lib/i18n.js'
+
+// 简单翻译
+t('common.save') // → "保存" / "Save"
+t('common.cancel') // → "取消" / "Cancel"
+
+// 带参数插值
+t('chat.messageCount', { count: 5 }) // → "5 条消息" / "5 messages"
+
+// 嵌套 key
+t('dashboard.gateway.running') // → "运行中" / "Running"
+
+// 切换语言
+setLocale('en') // 存 localStorage,触发页面重渲染
+```
+
+### 语言检测优先级
+
+1. `localStorage` 中存储的用户选择 (`clawpanel-locale`)
+2. 浏览器 `navigator.language`(`zh-CN` → `zh-CN`,`en-US` → `en`)
+3. 默认值:`zh-CN`
+
+### 缺失翻译 fallback
+
+1. 查找当前语言包
+2. 查找 `zh-CN` 兜底(中文作为最完整的语言)
+3. 返回 key 本身(如 `common.save`)
+4. 开发模式下 console.warn 提示缺失翻译
+
+## 三、语言包结构
+
+```
+src/locales/
+ zh-CN.json — 中文简体(默认,最完整)
+ en.json — English
+ zh-TW.json — 中文繁体(未来)
+ ja.json — 日本語(未来)
+ ko.json — 한국어(未来)
+```
+
+### JSON 格式规范
+
+按模块/页面分组,使用扁平化嵌套结构:
+
+```json
+{
+ "common": {
+ "save": "保存",
+ "cancel": "取消",
+ "delete": "删除",
+ "confirm": "确定",
+ "close": "关闭",
+ "loading": "加载中...",
+ "error": "错误",
+ "success": "成功",
+ "warning": "警告",
+ "retry": "重试",
+ "refresh": "刷新",
+ "edit": "编辑",
+ "create": "创建",
+ "back": "返回",
+ "next": "下一步",
+ "search": "搜索",
+ "copy": "复制",
+ "download": "下载",
+ "upload": "上传",
+ "enable": "启用",
+ "disable": "禁用",
+ "start": "启动",
+ "stop": "停止",
+ "restart": "重启",
+ "status": "状态",
+ "running": "运行中",
+ "stopped": "已停止",
+ "unknown": "未知",
+ "noData": "暂无数据",
+ "operationFailed": "操作失败: {error}",
+ "confirmDelete": "确定删除 {name}?",
+ "savedSuccessfully": "已保存"
+ },
+ "sidebar": {
+ "dashboard": "仪表盘",
+ "assistant": "晴辰助手",
+ "chat": "实时聊天",
+ "services": "服务管理",
+ "logs": "日志查看",
+ "models": "模型配置",
+ "agents": "Agent 管理",
+ "memory": "记忆文件",
+ "channels": "消息渠道",
+ "gateway": "网关配置",
+ "skills": "Skills 工具",
+ "docker": "Docker 集群",
+ "cron": "定时任务",
+ "extensions": "扩展工具",
+ "about": "关于",
+ "setup": "初始设置",
+ "chatDebug": "系统诊断"
+ },
+ "dashboard": { ... },
+ "chat": { ... },
+ "models": { ... },
+ ...
+}
+```
+
+## 四、迁移步骤(每个页面)
+
+### Step 1: 提取中文字符串
+
+使用正则或手动扫描,将所有中文文本提取到对应的语言包 key 下。
+
+**需要翻译的内容**:
+- UI 文本(按钮文字、标题、描述、提示)
+- toast 消息
+- 错误消息
+- placeholder 文本
+- confirm 对话框文本
+- tooltip 文本
+
+**不需要翻译的内容**:
+- 代码注释(保持中文)
+- console.log 调试信息
+- 技术标识符(如 `Gateway`、`Agent`、`OpenClaw`)
+- API 错误消息(后端返回的)
+- 知识库内容 `openclaw-kb.js`(这个特殊处理,按语言版本分文件)
+
+### Step 2: 替换代码中的硬编码
+
+```js
+// Before
+toast('保存成功', 'success')
+
+// After
+toast(t('common.savedSuccessfully'), 'success')
+```
+
+```js
+// Before (HTML 模板)
+``
+
+// After
+``
+```
+
+### Step 3: 编写英文翻译
+
+逐 key 翻译到 `en.json`。
+
+### Step 4: 测试
+
+切换语言,检查每个页面的显示是否正常。
+
+## 五、迁移顺序
+
+### 第一批(基础 + 框架层,约 80 个字符串)
+1. `src/lib/i18n.js` — 创建核心模块
+2. `src/locales/zh-CN.json` — 初始化中文包
+3. `src/locales/en.json` — 初始化英文包
+4. `src/components/sidebar.js` — 导航菜单(~20 个)
+5. `src/components/modal.js` — 公共弹窗(~10 个)
+6. `src/components/toast.js` — 提示组件
+7. `src/pages/about.js` — 关于页面 + 语言切换 UI(~30 个)
+
+### 第二批(核心页面,约 250 个字符串)
+8. `src/pages/dashboard.js` — 仪表盘(~50 个)
+9. `src/pages/setup.js` — 初始设置(~80 个)
+10. `src/pages/chat.js` — 实时聊天(~100 个)
+11. `src/main.js` — 主入口/横幅(~20 个)
+
+### 第三批(配置页面,约 350 个字符串)
+12. `src/pages/models.js` — 模型配置(~120 个)
+13. `src/pages/channels.js` — 消息渠道(~80 个)
+14. `src/pages/services.js` — 服务管理(~70 个)
+15. `src/pages/gateway.js` — 网关配置(~40 个)
+16. `src/pages/agents.js` — Agent 管理(~40 个)
+
+### 第四批(功能页面,约 250 个字符串)
+17. `src/pages/cron.js` — 定时任务(~50 个)
+18. `src/pages/memory.js` — 记忆管理(~30 个)
+19. `src/pages/extensions.js` — 扩展工具(~40 个)
+20. `src/pages/logs.js` — 日志查看(~20 个)
+21. `src/pages/skills.js` — Skills 工具(~60 个)
+22. `src/pages/chat-debug.js` — 系统诊断(~50 个)
+
+### 第五批(大型页面 + 特殊处理,约 600 个字符串)
+23. `src/pages/docker.js` — Docker 管理(~150 个)
+24. `src/pages/assistant.js` — AI 助手(~400 个,含系统提示词)
+25. `src/lib/openclaw-kb.js` — 知识库(按语言分文件)
+26. `src/lib/error-diagnosis.js` — 错误诊断(~30 个)
+27. `src/components/engagement.js` — 推荐弹窗(~15 个)
+
+### 第六批(官网 + 文档)
+28. `docs/index.html` — 官网英文版
+29. `README.md` → `README_en.md`
+30. `CONTRIBUTING.md` → `CONTRIBUTING_en.md`
+
+## 六、语言切换 UI 设计
+
+### 位置
+1. **关于页面底部** — 语言选择下拉框
+2. **侧边栏底部** — 语言图标 + 当前语言缩写(如 `中` / `EN`)
+
+### 交互
+- 选择语言 → 存入 localStorage → 页面自动刷新
+- 首次访问自动检测浏览器语言
+
+## 七、注意事项
+
+### 技术品牌词不翻译
+以下词保持原样,不翻译:
+- `OpenClaw`
+- `ClawPanel`
+- `Gateway`
+- `Agent`(Agent 管理不翻译为"代理")
+- `MCP`
+- `Skills`
+- `Docker`
+- `Tauri`
+
+### 参数插值语法
+使用 `{param}` 语法:
+```json
+{
+ "chat.sessions": "{count} sessions",
+ "models.providers": "Based on {count} providers"
+}
+```
+
+### 复数形式
+英文需要处理复数,但 MVP 阶段可以用简单方式:
+```json
+{
+ "chat.messageCount": "{count} message(s)"
+}
+```
+
+### Rust 后端
+后端错误消息暂不国际化(工作量大且用户较少直接看到),保持中文。
+
+## 八、验证清单
+
+每批迁移完成后检查:
+- [ ] 中文模式下所有功能正常
+- [ ] 英文模式下所有功能正常
+- [ ] 语言切换后页面正确刷新
+- [ ] 没有遗漏的硬编码中文
+- [ ] 参数插值正确显示
+- [ ] 长英文文本不溢出布局
+- [ ] toast/modal/confirm 文本正确
diff --git a/docs/plans/2026-03-16-cloudflared-openclaw-integration-design.md b/docs/plans/2026-03-16-cloudflared-openclaw-integration-design.md
new file mode 100644
index 00000000..3c8d590e
--- /dev/null
+++ b/docs/plans/2026-03-16-cloudflared-openclaw-integration-design.md
@@ -0,0 +1,59 @@
+# Cloudflared 公网访问与 OpenClaw 兼容导入设计
+
+日期:2026-03-16
+
+## 目标
+- 在 ClawPanel 设置页新增“公网访问(Cloudflared)”Tab
+- 支持快速隧道与命名隧道,进入页面后由用户选择
+- 一键认证登录(允许弹出浏览器完成 cloudflared login)
+- 默认暴露 OpenClaw Gateway(18789)
+- 读取用户已安装 OpenClaw 的 `C:\Users\\.openclaw\openclaw.json`,只读导入并做兼容或升级
+
+## 约束与偏好
+- 不要求用户输入隧道 Token
+- cloudflared.exe 优先 PATH 检测,其次 `~/.openclaw/bin`,不存在则复用 label-printer 的加速域名检测与下载逻辑
+- 不覆盖原始 openclaw.json,仅生成 ClawPanel 本地配置副本
+
+## 方案对比
+### 方案 A(推荐)
+- 内置 Cloudflared 管理器 + 一键登录 + 快速/命名双模式
+- 优点:体验一致,符合“一键认证登录”
+- 风险:需要完整接入本地进程管理与状态监控
+
+### 方案 B
+- 仅提供外部 cloudflared 路径配置
+- 优点:实现快
+- 缺点:不满足“一键认证登录”
+
+### 方案 C
+- A 为主,保留手动路径作为兜底
+- 优点:兼容性强
+- 代价:UI 复杂度增加
+
+推荐:方案 A,保留手动路径兜底入口
+
+## 设计 Section 1:架构与入口
+- 入口:设置页新增“公网访问(Cloudflared)”Tab
+- 核心模块:
+ 1) Cloudflared 管理器(检测、下载、启动、停止、状态)
+ 2) 隧道管理(快速隧道/命名隧道)
+ 3) OpenClaw 配置导入(读取并兼容升级)
+- 数据流:UI → IPC → cloudflared → 状态回传 → UI
+
+## 设计 Section 2:获取与一键认证
+- 检测顺序:PATH → `~/.openclaw/bin/cloudflared.exe` → 下载
+- 下载策略:加速域名检测失败则回退官方下载
+- 一键认证:执行 `cloudflared tunnel login`,浏览器授权完成后保存凭据
+- 失败提示与重试
+
+## 设计 Section 3:隧道类型与运行流程
+- 快速隧道:临时访问
+- 命名隧道:固定域名与服务
+- 流程:选择类型 → 选择服务(默认 18789)→ 启动 → 展示地址/状态
+- 错误处理:启动失败、登录失效、端口占用
+
+## 设计 Section 4:OpenClaw 配置导入
+- 默认读取:`C:\Users\\.openclaw\openclaw.json`
+- 兼容升级:缺失字段补全、旧字段映射
+- 只读导入,不覆盖原配置
+- 失败回退为只读展示
diff --git a/docs/plans/2026-03-16-cron-sessionmessage-tool-ui-design.md b/docs/plans/2026-03-16-cron-sessionmessage-tool-ui-design.md
new file mode 100644
index 00000000..fe98f9dd
--- /dev/null
+++ b/docs/plans/2026-03-16-cron-sessionmessage-tool-ui-design.md
@@ -0,0 +1,55 @@
+# Cron SessionMessage + Tool UI Design
+
+日期:2026-03-16
+
+## 目标
+- 在 ClawPanel 中新增 cron 任务类型:向指定 session 发送 user 消息
+- 支持等待对话结束(Gateway WebSocket 事件)再发送
+- Chat 页面展示工具调用(默认收起)
+- 适配 npm 全局安装 OpenClaw(补丁应用与更新重打)
+
+## 范围
+- 新增 payload.kind = sessionMessage(Gateway cron 执行分支)
+- ClawPanel cron UI 增加任务类型与字段
+- Chat UI 增加 tool call 展示
+- OpenClaw 版本更新时补丁自动重打
+
+## 方案概述
+### Cron SessionMessage
+- payload.kind: sessionMessage
+- 字段:label, message, role=user, waitForIdle=true
+- Gateway cron 执行:label -> sessionKey -> 等待 chat final -> 发送 user 消息
+
+### UI 改动
+- cron 表单新增任务类型选择
+- session label 下拉(来自 sessions.list)
+- message 文本输入
+- 列表与详情展示任务类型与目标
+
+### Chat 工具调用展示
+- 解析 message.content 中 tool / tool_result
+- 默认收起,仅显示工具名与状态
+- 点击展开显示参数与结果 JSON
+
+### 补丁与更新
+- 定位 npm 全局包路径(npm root -g + 包名)
+- 打补丁前备份原文件
+- 写入 clawpanel.json 记录补丁版本与 OpenClaw 版本
+- 更新后检测版本变化并重打补丁
+
+## 数据流
+- Cron UI -> Gateway cron.add -> payload sessionMessage
+- Gateway cron -> 监听 chat final -> chat.send (role=user)
+- Chat UI -> 渲染 tool call blocks
+
+## 错误处理
+- label 不存在:任务失败并记录错误
+- Gateway 未连接:cron UI 提示不可用
+- 补丁失败:自动回退并提示
+
+## 测试要点
+- cron 创建/编辑/删除
+- sessionMessage 执行成功
+- 等待对话结束后发送
+- tool call 展示与展开
+- 补丁重打与回退
diff --git a/docs/plans/2026-03-16-gateway-patch-auto-detect-design.md b/docs/plans/2026-03-16-gateway-patch-auto-detect-design.md
new file mode 100644
index 00000000..9dd17941
--- /dev/null
+++ b/docs/plans/2026-03-16-gateway-patch-auto-detect-design.md
@@ -0,0 +1,25 @@
+# Gateway 补丁自动检测与重打设计
+
+日期:2026-03-16
+
+## 目标
+- 启动与进入设置页自动检测全局 OpenClaw 版本
+- 版本变化时自动重打补丁,失败才提示
+- 过程静默、不中断使用
+
+## 触发策略
+- 应用启动时触发一次检测
+- 进入设置页时触发检测
+
+## 自动重打逻辑
+- 判断 installed_version 与 gatewayPatch.openclawVersion
+- 不一致时调用 gateway_patch_apply(force=true)
+- 使用内存节流与冷却(5 分钟)避免重复触发
+
+## 状态与提示
+- 失败写入 last_error,设置页展示错误
+- 不弹窗、不阻塞其他功能
+
+## 边界处理
+- 缺少 .bak 备份时,force 失败提示“缺少备份,建议先一键补丁”
+- 自动重打失败只提示,不回滚、不影响其他功能
diff --git a/docs/plans/2026-03-16-gateway-patch-oneclick-design.md b/docs/plans/2026-03-16-gateway-patch-oneclick-design.md
new file mode 100644
index 00000000..e265deed
--- /dev/null
+++ b/docs/plans/2026-03-16-gateway-patch-oneclick-design.md
@@ -0,0 +1,37 @@
+# Gateway 一键补丁设计
+
+日期:2026-03-16
+
+## 目标
+- 在设置页提供一键补丁入口,自动对全局 npm 安装的 OpenClaw 打补丁
+- 支持检测版本、应用补丁、重打补丁、回滚
+
+## 入口与交互
+- 位置:设置页「公网访问」区域旁新增“Gateway 补丁”卡片
+- 按钮:一键补丁、重打补丁、回滚
+- 状态:展示检测到的 OpenClaw 版本、补丁状态、最近操作结果
+
+## 实现流程
+1. 定位全局 npm 根目录:`npm root -g`
+2. 在 `node_modules` 内查找 `openclaw` 包
+3. 自动识别目标文件(reply-*.js / gateway-cli-*.js)
+4. 备份文件(.bak)
+5. 应用补丁(sessionMessage 支持)
+6. 写入 `clawpanel.json` 记录补丁版本与 OpenClaw 版本
+7. 版本变更时提示并支持重打补丁
+8. 失败自动回滚
+
+## 数据与状态
+- `clawpanel.json` 新增:
+ - `gatewayPatch`: { version, patchedAt, openclawVersion, files: [] }
+
+## 错误处理
+- 未找到 npm 根目录或包:提示错误
+- 文件名不匹配:提示错误并终止
+- 打补丁失败:回滚并记录错误
+
+## 测试要点
+- 正常补丁流程
+- 回滚流程
+- 版本变化后重打补丁
+- 错误路径处理
diff --git a/docs/plans/2026-03-17-ai-config-import-design.md b/docs/plans/2026-03-17-ai-config-import-design.md
new file mode 100644
index 00000000..1f4b2b0c
--- /dev/null
+++ b/docs/plans/2026-03-17-ai-config-import-design.md
@@ -0,0 +1,37 @@
+# AI 配置从 openclaw 导入设计
+
+日期: 2026-03-17
+
+## 目标
+在 AI 配置页提供“从 openclaw 导入”功能,导入模型参数 + API Key + Base URL。
+
+## 方案
+- 采用方案 A:手动按钮触发导入
+
+## 设计细节
+### 1) 导入入口
+- AI 配置页顶部操作区新增按钮:`从 openclaw 导入`
+
+### 2) 导入内容
+- 读取 `openclaw.json`
+- 提取字段:
+ - model
+ - temperature
+ - top_p
+ - api_key
+ - base_url
+- 写回当前 AI 配置表单并持久化
+
+### 3) 反馈
+- 成功:toast 提示“已导入”
+- 失败:toast 提示读取失败或字段缺失
+
+## 影响范围
+- src/pages/models.js
+- src/lib/tauri-api.js
+- src-tauri/src/commands/config.rs
+
+## 测试要点
+- openclaw.json 有效 → 导入成功
+- 字段缺失 → 失败提示
+- 导入后配置可保存并生效
diff --git a/docs/plans/2026-03-17-assistant-optimize-toggle-design.md b/docs/plans/2026-03-17-assistant-optimize-toggle-design.md
new file mode 100644
index 00000000..4c65c7a5
--- /dev/null
+++ b/docs/plans/2026-03-17-assistant-optimize-toggle-design.md
@@ -0,0 +1,27 @@
+# 晴辰助手 优化/还原按钮切换设计
+
+日期: 2026-03-17
+
+## 目标
+优化与还原按钮不同时出现,默认显示“优化”,优化完成后显示“还原”,发送或还原后切回“优化”。
+
+## 方案
+- 采用方案 A:同位置切换显示
+
+## 设计细节
+### 1) UI 结构
+- 保留两个按钮节点
+- 通过状态控制显示
+
+### 2) 状态逻辑
+- `_optOriginalText` 为空:显示“优化”,隐藏“还原”
+- `_optOriginalText` 非空:隐藏“优化”,显示“还原”
+- `clearOptimizeSnapshot()` 或点击还原后切回“优化”
+
+## 影响范围
+- src/pages/assistant.js
+
+## 测试要点
+- 默认仅显示“优化”
+- 优化完成后仅显示“还原”
+- 发送或点击还原后仅显示“优化”
diff --git a/docs/plans/2026-03-17-assistant-ux-and-shell-design.md b/docs/plans/2026-03-17-assistant-ux-and-shell-design.md
new file mode 100644
index 00000000..9ddb66c3
--- /dev/null
+++ b/docs/plans/2026-03-17-assistant-ux-and-shell-design.md
@@ -0,0 +1,43 @@
+# Assistant UX + Windows Shell 优化设计
+
+日期: 2026-03-17
+
+## 目标
+1) Windows shell 优先 pwsh,其次 powershell,最后 cmd
+2) 复制按钮样式统一,右上角悬浮显示
+3) AI 助手输入区新增“优化/恢复原文”按钮,保留撤销栈
+
+## 方案
+- 采用方案 B
+
+## 设计细节
+### 1) assistant_exec shell 优先级
+- Windows: pwsh -> powershell -> cmd
+- 执行前检测可用 shell(where / Get-Command)
+- 继续使用 build_system_env() 注入完整系统环境
+
+### 2) 复制按钮错位修复
+- 不改 Markdown 解析
+- chat.css 与 assistant.css 的 pre / code-copy-btn 统一
+- copy 按钮右上角悬浮,hover 显示
+
+### 3) AI 优化按钮
+- 位置: AI 助手输入区,与发送按钮并列
+- 文本模板: “请在不改变原意和语言的前提下,重写为意思更清晰、更简洁的表达。”
+- 点击优化:调用同模型在线重写,替换输入框文本
+- 快照:保存原文快照 + 优化结果快照
+- 恢复原文:发送前始终可用
+- 发送后清空快照
+- 替换方式: setRangeText + input 事件,保留 Ctrl+Z
+
+## 影响范围
+- src-tauri/src/commands/assistant.rs
+- src/style/chat.css
+- src/style/assistant.css
+- src/pages/assistant.js
+- src/lib/tauri-api.js(如需复用 call)
+
+## 测试要点
+- Windows 下优先使用 pwsh
+- 复制按钮在 chat 与 assistant 页面一致
+- 优化/恢复流程与撤销栈可用
diff --git a/docs/plans/2026-03-17-chat-autoscroll-design.md b/docs/plans/2026-03-17-chat-autoscroll-design.md
new file mode 100644
index 00000000..88b4cf02
--- /dev/null
+++ b/docs/plans/2026-03-17-chat-autoscroll-design.md
@@ -0,0 +1,41 @@
+# 聊天页面自动滚动行为设计
+
+## 目标
+- 用户上滑查看历史时,不被强制拉回底部
+- 仅在“新消息出现”且用户处于底部时自动滚动
+
+## 现状问题
+当前聊天页存在持续自动滚动到底部的行为,导致用户无法查看上方历史消息,尤其在无新消息时仍被拉回底部。
+
+## 推荐方案(已确认)
+**方案 A:仅在新消息出现且用户在底部时自动滚动**
+- 维护“是否在底部”的状态
+- 当用户离开底部时,自动滚动关闭
+- 仅在新消息插入时、且自动滚动开启,才执行 `scrollToBottom()`
+
+## 关键设计点
+1. **状态管理**
+ - `autoScrollEnabled`:用户在底部时为 true;上滑离开底部时为 false
+ - 在滚动事件中更新该状态
+
+2. **滚动触发点**
+ - 在“消息插入”函数内触发(如 `appendUserMessage` / `appendAiMessage` / `appendSystemMessage` / `createStreamBubble`)
+ - 渲染过程中(如 `doRender` / `doVirtualRender`)不再强制滚动
+
+3. **流式输出处理**
+ - 若用户不在底部,则流式输出不强制跟随
+ - 若用户在底部,则跟随滚动
+
+4. **回到底部按钮**
+ - 点击按钮后强制滚动到底部并恢复 `autoScrollEnabled = true`
+
+## 影响范围
+- `src/pages/chat.js`
+- 可能涉及 `chat.css`(若需要视觉提示或按钮行为调整)
+
+## 测试要点
+- 无新消息时,上滑查看历史不被拉回
+- 新消息出现时:
+ - 若当前在底部,自动滚动
+ - 若已上滑,保持当前位置
+- 点击“回到底部”按钮后恢复自动滚动
diff --git a/docs/plans/2026-03-17-chat-daylight-shadow-design.md b/docs/plans/2026-03-17-chat-daylight-shadow-design.md
new file mode 100644
index 00000000..ce6c77cf
--- /dev/null
+++ b/docs/plans/2026-03-17-chat-daylight-shadow-design.md
@@ -0,0 +1,37 @@
+# Chat 日间模式助手气泡阴影设计
+
+## 目标
+- 解决日间模式下助手气泡不明显的问题
+- 不改变夜间模式表现
+- 不改变间距、圆角、字体,仅增加轻微阴影
+
+## 背景
+- 当前 `.msg-ai .msg-bubble` 使用半透明背景,在日间模式可能与底色融合,导致难以区分
+- 用户确认仅需补充日间模式阴影
+
+## 方案对比
+### 方案 A(推荐)
+- 仅在日间模式为 `.msg-ai .msg-bubble` 增加轻微阴影
+- 夜间模式保持不变
+- 风险最小,符合当前需求
+
+### 方案 B
+- 日间模式增加阴影 + 细边框
+- 对比更强,但视觉改动更明显
+
+### 方案 C
+- 调整日间模式背景色 + 阴影
+- 可读性提升,但偏离现有视觉体系
+
+## 采用方案
+- 采用方案 A
+
+## 设计细节
+- 选择器:`[data-theme="light"] .msg-ai .msg-bubble`
+- 阴影强度:轻微,不影响布局
+- 不改变背景色、圆角、内边距
+
+## 验收标准
+- 日间模式:助手消息气泡可清晰区分
+- 夜间模式:视觉无变化
+- 不引入新的布局抖动或遮挡问题
diff --git a/docs/plans/2026-03-17-chat-history-tool-merge-design.md b/docs/plans/2026-03-17-chat-history-tool-merge-design.md
new file mode 100644
index 00000000..5fb4b7f5
--- /dev/null
+++ b/docs/plans/2026-03-17-chat-history-tool-merge-design.md
@@ -0,0 +1,30 @@
+# 聊天历史工具卡片合并与排序设计
+
+## 目标
+- 历史记录解析后,工具卡片只显示 1 份(按 toolCallId 合并)
+- 工具卡片显示在消息气泡上方
+- 工具时间优先使用事件 ts,缺失回退 message.timestamp
+- 历史模式不再插入工具事件系统消息
+- 历史列表按时间排序,同时间工具在上文本在下
+
+## 现状问题
+- history 刷新后出现重复工具卡片(toolCall/toolResult 未合并)
+- 工具时间缺失导致排序异常
+- 工具事件系统消息与工具卡片同时出现,导致页面被工具卡片占满
+
+## 方案(合并式解析)
+1) 统一合并:工具块解析使用 upsertTool,按 toolCallId 合并
+2) 时间回退:工具时间 = eventTs || message.timestamp || null
+3) 历史禁入系统消息:history 解析不再插入工具事件系统消息
+4) 排序规则:按 time 升序;相同时间时工具卡片在上,文本在下
+
+## 设计细节
+- 新增工具时间解析函数:`resolveToolTime(toolId, messageTimestamp)`
+- 工具事件处理处增加 history 标记,历史模式不调用 `appendSystemMessage`
+- 渲染时将工具卡片与文本作为 entries 统一排序
+
+## 验收标准
+- 历史记录中工具卡片不重复
+- 刷新后工具时间可见
+- 工具卡片位于消息气泡上方
+- 列表不再被工具事件系统消息淹没
diff --git a/docs/plans/2026-03-17-chat-markdown-it-design.md b/docs/plans/2026-03-17-chat-markdown-it-design.md
new file mode 100644
index 00000000..0cd2dd34
--- /dev/null
+++ b/docs/plans/2026-03-17-chat-markdown-it-design.md
@@ -0,0 +1,25 @@
+# Chat Markdown-it 样式解析设计
+
+## 目标
+- clawpanel chat 页面支持完整 Markdown 样式:加粗、斜体、删除线、行内代码、代码块、引用、列表、链接、下划线、剧透、@提及
+- 解析行为与 GitHub 接近,安全渲染
+
+## 方案
+- 将 `src/lib/markdown.js` 切换为 `markdown-it` 作为渲染核心
+- 使用插件机制实现:
+ - underline:`__text__` 输出 ``
+ - spoiler:同时支持 `||spoiler||` 与 `>!spoiler!<`
+ - mention:`@用户名` 输出 `@用户名`
+- 代码块高亮沿用现有 `highlightCode`
+- 链接白名单 `http/https/mailto`,否则降级为 `#`
+- 禁止任意 HTML 注入(html=false)
+
+## 样式
+- `.msg-mention` 高亮
+- `.msg-spoiler` 遮罩,点击显示(`revealed` 类切换)
+
+## 验收标准
+- chat 页面渲染包含 E 方案的全部样式
+- `|| ||` 与 `>! !<` 均可正确显示剧透
+- @提及高亮,非链接
+- 不引入 HTML 注入风险
diff --git a/docs/plans/2026-03-17-chat-tool-event-live-design.md b/docs/plans/2026-03-17-chat-tool-event-live-design.md
new file mode 100644
index 00000000..60ce4782
--- /dev/null
+++ b/docs/plans/2026-03-17-chat-tool-event-live-design.md
@@ -0,0 +1,34 @@
+# 实时聊天工具事件展示设计
+
+## 目标
+- 工具调用实时显示为独立系统消息
+- 使用 `payload.ts` 作为排序时间
+- 避免重复渲染
+
+## 现状问题
+- 工具事件未实时展示,需要刷新
+- 刷新后可能出现重复/顺序错乱
+
+## 方案
+- 监听 `event: agent` + `stream: tool` 事件
+- 使用 `payload.runId` 与 `toolCallId` 组成唯一键去重
+- 插入消息时按 `payload.ts` 排序
+
+## 设计细节
+### 时间与去重
+- 唯一键:`${payload.ts}:${toolCallId}`
+- 时间来源:`payload.ts`
+
+### 插入策略
+- 所有消息(用户/助手/系统/工具事件)都写入 `data-ts`
+- 插入时按时间从小到大找到位置
+
+### 工具事件消息内容
+- 标题:`工具调用开始/结果 · 工具名`
+- 结果:成功/失败(基于 isError)
+- 仅展示 start 与 result 两类
+
+## 验收标准
+- 工具事件实时出现,无需刷新
+- 不出现重复消息
+- 顺序按时间正确
diff --git a/docs/plans/2026-03-17-chat-virtual-scroll-design.md b/docs/plans/2026-03-17-chat-virtual-scroll-design.md
new file mode 100644
index 00000000..78425bfa
--- /dev/null
+++ b/docs/plans/2026-03-17-chat-virtual-scroll-design.md
@@ -0,0 +1,25 @@
+# Chat 虚拟滚动设计
+
+## 目标
+- 滚动流畅度提升
+- 控制渲染 DOM 数量,降低重绘
+
+## 现状
+- 所有消息一次性渲染,滚动卡顿
+
+## 方案
+- 使用简单虚拟列表策略:仅渲染可视区域 + 上下缓冲
+- 滚动时动态替换 DOM
+
+## 设计细节
+- 视窗计算:基于容器 scrollTop + clientHeight
+- 渲染范围:可视范围上下各缓冲 N 条
+- 需要维护消息高度缓存
+
+## 风险
+- DOM 搜索不可见
+- 需要高度缓存避免跳动
+
+## 验收标准
+- 滚动流畅度明显提升
+- 无明显跳动
diff --git a/docs/plans/2026-03-17-chat-virtual-scroll-implementation-design.md b/docs/plans/2026-03-17-chat-virtual-scroll-implementation-design.md
new file mode 100644
index 00000000..eca40d2c
--- /dev/null
+++ b/docs/plans/2026-03-17-chat-virtual-scroll-implementation-design.md
@@ -0,0 +1,34 @@
+# Chat 虚拟列表实现设计
+
+## 目标
+- 提升首屏渲染速度(优先)
+- 提升滚动流畅度(次优)
+- 固定渲染窗口:40 条 + 上下缓冲 20 条
+- 滚动锚点:底部时自动滚动,否则保持位置
+- 高度策略:预估行高 + 运行时测量校准
+
+## 方案(固定窗口 + 占位高度)
+- 使用容器总高度占位
+- 仅渲染窗口范围消息
+- 运行时测量单条高度并缓存
+- 通过累计高度计算可视范围
+
+## 数据结构
+- items: [{ id, role, text, attachments, tools, timestamp }]
+- heights: Map
+- avgHeight: number (默认 64)
+- range: { start, end }
+
+## 渲染策略
+- 顶部、底部使用占位 div 控制滚动条
+- 每次滚动计算可视范围
+- 渲染 items[start..end]
+
+## 锚点策略
+- 若用户在底部,新增消息时自动滚动到底部
+- 否则保持当前滚动位置
+
+## 验收标准
+- 首屏渲染速度明显提升
+- 长列表滚动不卡顿
+- 新消息到来时遵循锚点策略
diff --git a/docs/plans/2026-03-17-force-setup-design.md b/docs/plans/2026-03-17-force-setup-design.md
new file mode 100644
index 00000000..d747ce86
--- /dev/null
+++ b/docs/plans/2026-03-17-force-setup-design.md
@@ -0,0 +1,31 @@
+# 强制初始化(forceSetup)设计
+
+日期: 2026-03-17
+
+## 目标
+构建版首次启动可强制进入 /setup,不受已存在配置影响。
+
+## 方案
+- 采用方案 A:在 clawpanel.json 增加 forceSetup 字段
+
+## 设计细节
+### 1) 配置字段
+- `clawpanel.json` 新增:`forceSetup: true/false`
+
+### 2) 启动逻辑
+- 启动时读取 panel config
+- 若 `forceSetup === true`,即使 isOpenclawReady 为 true 也强制跳转 /setup
+
+### 3) 初始化完成后
+- setup 流程成功完成时写入 `forceSetup=false`
+
+## 影响范围
+- src/main.js
+- src/lib/tauri-api.js
+- src/pages/setup.js
+- src-tauri/src/commands/config.rs
+
+## 测试要点
+- forceSetup=true 时进入 /setup
+- 完成初始化后 forceSetup 自动清零
+- forceSetup=false 时恢复原有判断逻辑
diff --git a/docs/plans/2026-03-17-hosted-agent-design.md b/docs/plans/2026-03-17-hosted-agent-design.md
new file mode 100644
index 00000000..262a63c1
--- /dev/null
+++ b/docs/plans/2026-03-17-hosted-agent-design.md
@@ -0,0 +1,131 @@
+# 托管 Agent(聊天页)详细设计
+
+> 结论:采用方案一(复用晴辰助手能力)。
+
+## 目标
+- 聊天页发送按钮右侧新增“托管 Agent”入口
+- 托管 Agent 通过 WSS 自动与当前会话 Agent 交互
+- 上下文仅包含:初始提示词 + 与对面 Agent 的交流
+- 继承当前会话工具权限
+- 全局默认 + 会话级启用,切换会话/页面仍生效
+- 输出直接插入当前聊天流并标记来源
+
+## 入口与 UI 交互
+### 入口按钮
+- 位置:`src/pages/chat.js` 内聊天输入区域,发送按钮右侧
+- 交互:点击打开托管 Agent 配置面板
+- 状态:idle / running / waiting_reply / paused / error
+
+### 配置面板
+- 初始提示词(必填)
+- 启用开关
+- 运行模式:对面 Agent 回复后自动继续
+- 停止策略:托管 Agent 自评停止
+- 高级选项:最大步数 / 步间隔 / 重试次数
+- 操作:保存并启用 / 暂停 / 立即停止
+
+### 输出展示
+- 直接插入当前聊天流
+- 格式示例:
+ - `[托管 Agent] 下一步指令: ...`
+- 样式区分:弱化颜色 + 标签
+
+## 运行循环与状态机
+### 状态
+- idle / running / waiting_reply / paused / error
+
+### 触发
+- 监听 `wsClient.onEvent`
+- event=chat,state=final,sessionKey=当前会话
+
+### 执行流程
+1. 对面 Agent final 回复到达
+2. 托管 Agent 生成下一步指令
+3. 使用 `wsClient.chatSend` 发送
+4. 进入 waiting_reply
+5. 满足 stopPolicy 或 maxSteps 停止
+
+## 上下文构建
+- 仅包含:初始提示词 + 与对面 Agent 对话
+- 截断策略:按 MAX_CONTEXT_TOKENS 或最近 N 条
+- 不引入其他会话内容
+
+## 数据结构与持久化
+### 全局默认(clawpanel.json)
+```json
+{
+ "hostedAgent": {
+ "default": {
+ "enabled": false,
+ "prompt": "",
+ "autoRunAfterTarget": true,
+ "stopPolicy": "self",
+ "maxSteps": 50,
+ "stepDelayMs": 1200,
+ "retryLimit": 2,
+ "toolPolicy": "inherit"
+ }
+ }
+}
+```
+
+### 会话级(localStorage)
+Key: `clawpanel-hosted-agent-sessions`
+```json
+{
+ "agent:main:main": {
+ "enabled": true,
+ "prompt": "任务目标",
+ "autoRunAfterTarget": true,
+ "stopPolicy": "self",
+ "maxSteps": 50,
+ "stepDelayMs": 1200,
+ "retryLimit": 2,
+ "toolPolicy": "inherit",
+ "state": {
+ "status": "running",
+ "stepCount": 12,
+ "lastRunAt": 1710000000000,
+ "lastError": ""
+ },
+ "history": [
+ { "role": "system", "content": "初始提示词" },
+ { "role": "assistant", "content": "托管 Agent 生成的指令" },
+ { "role": "target", "content": "对面 Agent 回复" }
+ ]
+ }
+}
+```
+
+## assistant-core 抽取清单
+新增:`src/lib/assistant-core.js`
+
+### 抽取项(从 assistant.js)
+- API 适配:OpenAI/Anthropic/Gemini
+- SSE 流解析与重试
+- 系统提示词构建
+- 工具声明、权限过滤、执行与安全检查
+- 上下文裁剪与会话数据工具
+
+### 适配器注入
+- `api.*` 工具桥接
+- `confirm / ask_user` UI 适配器
+- `storage` 适配器
+- 图片存储适配器
+
+### 保留在 assistant.js
+- DOM 渲染与 UI 交互
+- toast/modal
+- 视图与事件绑定
+
+## 风险与保护
+- Gateway 断开:自动暂停
+- 连续失败:触发 error 状态
+- 最大步数:强制停止
+- 避免重复触发:运行中忽略新触发
+
+## 测试要点
+- 启用后自动发送
+- 对面回复后自动继续
+- 切换会话/页面后仍生效
+- 停止策略与最大步数生效
diff --git a/docs/plans/2026-03-17-skillhub-env-fix-design.md b/docs/plans/2026-03-17-skillhub-env-fix-design.md
new file mode 100644
index 00000000..cbe492b3
--- /dev/null
+++ b/docs/plans/2026-03-17-skillhub-env-fix-design.md
@@ -0,0 +1,65 @@
+# SkillHub 动态探测与系统环境变量继承设计
+
+日期: 2026-03-17
+
+## 背景与目标
+- 现象: 页面提示已安装 SkillHub CLI,但检查仍显示未安装
+- 根因: ClawPanel 进程环境变量未刷新或 PATH 不完整,导致技能检测失败
+- 目标:
+ 1) SkillHub CLI 检测支持动态探测路径并返回版本与命中路径
+ 2) 所有 Tauri 命令执行时继承完整系统环境变量(用户 + 系统)
+
+## 范围
+- 仅修改 ClawPanel Tauri 后端命令执行环境与 SkillHub 检测逻辑
+- 前端仅增加路径展示(若返回 path)
+
+## 方案对比
+### 方案 A
+- 仅在 SkillHub 检测执行 `where skillhub` 进行路径探测
+- 其他命令仍使用当前进程环境
+- 缺点: 无法解决晴辰助手命令缺少系统环境变量的问题
+
+### 方案 B(推荐)
+- 增加统一系统环境构建函数,合并进程 env + Windows 用户/系统 env
+- 所有 Tauri 命令使用该环境执行
+- SkillHub 检测失败时 `where skillhub` 探测并返回路径
+- 优点: 满足所有需求,改动集中
+
+### 方案 C
+- 增加 envPolicy 配置项(inherit/system/whitelist)
+- 需要新 UI 与配置逻辑
+- 超出当前范围
+
+## 设计细节
+### 1) 系统环境变量合并
+- 新增 `build_system_env()`:
+ - 读取当前进程 env
+ - 读取注册表:
+ - HKCU\Environment
+ - HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment
+ - 合并优先级: 进程 env 覆盖系统 env
+ - PATH 合并去重(用户 + 系统 + 进程)
+
+### 2) SkillHub 动态探测
+- `skills_skillhub_check` 流程:
+ 1) 直接尝试 `skillhub --version`
+ 2) 失败则执行 `where skillhub`
+ 3) 若命中路径,使用该路径执行 `--version`
+ 4) 返回 `{ installed: true, version, path }`
+
+### 3) 统一命令执行环境
+- 所有 `tokio::process::Command` 调用统一使用 `cmd.envs(build_system_env())`
+- 现有 `enhanced_path()` 可改为调用 `build_system_env()` 或保留但不再使用
+
+### 4) 前端展示
+- Skills 页面展示安装路径(若后端返回 path)
+- 状态提示保持一致
+
+## 错误处理
+- 注册表读取失败时回退到当前进程 env
+- `where skillhub` 失败仍返回 `installed: false`
+
+## 测试要点
+- PATH 未刷新时: `skills_skillhub_check` 仍能识别已安装
+- 晴辰助手执行命令继承系统变量(如 PATH / HTTP_PROXY)
+- UI 能展示 path 与版本
diff --git a/docs/plans/2026-03-17-toast-night-and-model-select-design.md b/docs/plans/2026-03-17-toast-night-and-model-select-design.md
new file mode 100644
index 00000000..da099118
--- /dev/null
+++ b/docs/plans/2026-03-17-toast-night-and-model-select-design.md
@@ -0,0 +1,36 @@
+# Toast 夜间样式与 Chat Model Select 设计
+
+## 目标
+- Toast 夜间模式可见性提升,风格参考 Vercel 简约风
+- Chat 模型选择器不截断,宽度自适应内容
+
+## Toast 夜间样式
+### 现状
+- Toast 使用统一背景与边框,夜间模式对比不足
+
+### 方案(Vercel 简约风)
+- 夜间模式下使用更深背景色与清晰边框
+- 保留状态色文字
+
+### 设计细节
+- 选择器:`[data-theme="dark"] .toast`
+- 背景:更深的卡片色(参考 Vercel 夜间卡片风格)
+- 边框:`1px solid var(--border)` 保持一致性
+- 阴影:沿用当前中等阴影
+
+## Chat Model Select
+### 现状
+- `chat-model-select` 文字被截断
+
+### 方案
+- 宽度随内容自适应,不做截断
+
+### 设计细节
+- 选择器:`.chat-model-select` 或其输入容器
+- 移除固定宽度/最大宽度
+- 允许内容自然撑开
+
+## 验收标准
+- 夜间模式下 toast 清晰可见,风格简约
+- chat-model-select 文字不再被截断
+- 不影响其他布局与交互
diff --git a/docs/plans/2026-03-17-toast-shadow-design.md b/docs/plans/2026-03-17-toast-shadow-design.md
new file mode 100644
index 00000000..2ca9e07f
--- /dev/null
+++ b/docs/plans/2026-03-17-toast-shadow-design.md
@@ -0,0 +1,18 @@
+# Toast 阴影强度设计
+
+## 目标
+- 为 toast 增加轻微阴影
+- 保持 Vercel 简约风格
+- 日夜主题自动适配
+
+## 方案
+- 采用中等强度阴影:`0 4px 12px rgba(0,0,0,0.12)`
+
+## 设计细节
+- 选择器:`.toast`
+- 在现有背景与边框基础上新增 `box-shadow`
+
+## 验收标准
+- 日间模式下 toast 清晰可见
+- 夜间模式不刺眼
+- 不影响布局与动画
diff --git a/docs/plans/2026-03-17-toast-vercel-design.md b/docs/plans/2026-03-17-toast-vercel-design.md
new file mode 100644
index 00000000..a4baaf5a
--- /dev/null
+++ b/docs/plans/2026-03-17-toast-vercel-design.md
@@ -0,0 +1,27 @@
+# Toast Vercel 简约风格设计
+
+## 目标
+- 去掉液态玻璃/高斯模糊效果
+- 使用 Vercel 简约风格:纯色卡片 + 细边框
+- 日夜主题自动适配
+
+## 现状
+- `.toast` 使用 `backdrop-filter: blur(12px)`
+- 背景使用 `--success-muted / --error-muted / --info-muted / --warning-muted`
+
+## 方案
+- 移除 `backdrop-filter`
+- 背景统一使用 `var(--bg-primary)`
+- 边框统一使用 `1px solid var(--border)`
+- 文本颜色维持状态色(success/error/info/warning)
+
+## 设计细节
+- 选择器:`.toast`
+- 状态色:保留 `.toast.success/.error/.info/.warning` 的文字颜色
+- 背景色:`var(--bg-primary)`
+- 边框:`1px solid var(--border)`
+
+## 验收标准
+- 日夜模式下 toast 不卡壳、无毛玻璃效果
+- 视觉风格与 Vercel 简约风格一致
+- 状态色可辨识
diff --git a/docs/plans/2026-03-17-tool-call-meta-design.md b/docs/plans/2026-03-17-tool-call-meta-design.md
new file mode 100644
index 00000000..ac32f148
--- /dev/null
+++ b/docs/plans/2026-03-17-tool-call-meta-design.md
@@ -0,0 +1,32 @@
+# 工具调用信息展示设计
+
+## 目标
+- 工具调用框内显示调用时间(使用工具自身时间戳)
+- 展开后始终显示参数与结果区,即使为空也提示
+
+## 现状问题
+- 工具调用框无时间信息
+- 展开后无数据时区域为空,用户无法确认是否无返回
+
+## 方案(已确认)
+- 方案 A:显示工具自身时间戳 + 参数/结果占位
+
+## 设计细节
+### 时间来源
+- 优先读取:`tool.end_time` / `tool.endTime` / `tool.timestamp` / `tool.time` / `tool.started_at` / `tool.startedAt`
+- 若均为空:使用工具事件流 `event: agent` 的 `payload.ts`(按 `toolCallId` 映射)
+- 仍为空时:显示 `时间未知`
+
+### 展开内容
+- `input` 为空时显示 `无参数`
+- `output` 为空时显示 `无结果`
+- 仍使用 `safeStringify` + `escapeHtml`
+
+### 结构
+- 标题行:`工具名 · 状态 · 时间`
+- 展开块:参数与结果两个区块
+
+## 验收标准
+- 工具调用框内显示时间
+- 展开后始终可见参数/结果区块
+- 无数据时显示占位文案
diff --git a/docs/plans/2026-03-17-ws-connect-bootstrap-design.md b/docs/plans/2026-03-17-ws-connect-bootstrap-design.md
new file mode 100644
index 00000000..9ed6eef4
--- /dev/null
+++ b/docs/plans/2026-03-17-ws-connect-bootstrap-design.md
@@ -0,0 +1,31 @@
+# WS 连接后启动请求设计
+
+## 目标
+- 连接成功后一次性发送官方同款 8 个请求
+- ping 间隔改为 5 秒
+- 重连后也执行一次
+
+## 官方请求清单
+- agent.identity.get (sessionKey)
+- agents.list
+- health
+- node.list
+- device.pair.list
+- chat.history (sessionKey, limit=200)
+- sessions.list (includeGlobal/includeUnknown)
+- models.list
+
+## 方案
+- 在 connect 成功处理函数中发送一组 req 帧
+- 每次重连也触发
+- ping 仍按 5 秒周期发送(保持已实现的多请求,启动批次会与首个 ping 重复一次)
+
+## 设计细节
+- 新增 `_sendBootstrapRequests()`
+- 使用 `uuid()` 生成 id
+- sessionKey 使用当前会话 `this._sessionKey`,缺省回退 `agent:full-stack-architect:main`
+
+## 验收标准
+- 每次连接成功后立即发送 8 个 req
+- ping 间隔为 5 秒
+- 控制台无异常
diff --git a/docs/plans/2026-03-17-ws-ping-multi-req-design.md b/docs/plans/2026-03-17-ws-ping-multi-req-design.md
new file mode 100644
index 00000000..230a7f26
--- /dev/null
+++ b/docs/plans/2026-03-17-ws-ping-multi-req-design.md
@@ -0,0 +1,27 @@
+# WS Ping 多请求设计
+
+## 目标
+- 每次心跳发送 4 个 req:node.list / models.list / sessions.list / chat.history
+- 保持连接存活并同步关键状态
+
+## 现状
+- 心跳仅发送 node.list
+
+## 方案
+- 在 `_startPing` 中按顺序发送 4 个 req
+- 复用现有 `uuid()` 生成 id
+- sessions.list 参数:`includeGlobal: true`, `includeUnknown: true`
+- chat.history 参数固定 `sessionKey: agent:full-stack-architect:main`, `limit: 200`
+
+## 设计细节
+- 帧格式统一:`{ type: "req", id, method, params }`
+- 在同一计时周期内连续发送 4 条 req
+
+## 风险
+- 请求频率增加,可能带来负载波动
+- 若 Gateway 限流,需要再降频
+
+## 验收标准
+- 每个周期触发 4 条 req
+- 控制台无异常
+- Gateway 正常返回
diff --git a/docs/plans/2026-03-17-ws-ping-node-list-design.md b/docs/plans/2026-03-17-ws-ping-node-list-design.md
new file mode 100644
index 00000000..593ca549
--- /dev/null
+++ b/docs/plans/2026-03-17-ws-ping-node-list-design.md
@@ -0,0 +1,18 @@
+# WS Ping 改为 node.list 设计
+
+## 目标
+- 将 WebSocket 心跳从 `{ "type":"ping" }` 改为 `req node.list`
+- 保持连接存活并提供实时节点状态
+
+## 方案
+- 在 `_startPing` 中改为发送 `req` 帧
+- 维持原有间隔
+
+## 设计细节
+- 帧格式:`{ type: "req", id: uuid(), method: "node.list", params: {} }`
+- 仅替换 ping 发送内容
+
+## 验收标准
+- 连接稳定
+- 控制台不报错
+- node.list 有返回
diff --git a/docs/superpowers/plans/2026-03-16-gateway-patch-auto-detect.md b/docs/superpowers/plans/2026-03-16-gateway-patch-auto-detect.md
new file mode 100644
index 00000000..2d3149fe
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-16-gateway-patch-auto-detect.md
@@ -0,0 +1,74 @@
+# Gateway Patch Auto-Detect Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Auto-detect global OpenClaw version changes at app start and settings entry, then reapply the gateway patch silently; show errors only in settings.
+
+**Architecture:** Add a small patch auto-detect runner in settings (client-side) and app bootstrap (Tauri setup) that calls gateway_patch_status and, on version mismatch, triggers gateway_patch_apply(force=true). Use in-memory throttle and 5-minute cooldown to avoid repeated runs.
+
+**Tech Stack:** Tauri (Rust), Vite/JS, ClawPanel config
+
+---
+
+## Chunk 1: Backend status extensions
+
+### Task 1: Extend gateway_patch_status output
+
+**Files:**
+- Modify: `src-tauri/src/commands/gateway_patch.rs`
+
+- [ ] **Step 1: Add version mismatch helper**
+
+Add a helper to compute `needs_repatch` by comparing `installed_version` with stored `gatewayPatch.openclawVersion` and include it in status output if desired.
+
+- [ ] **Step 2: Ensure force apply uses backup detection**
+
+If force=true and backups missing, return a clear error string: "缺少备份,建议先一键补丁".
+
+- [ ] **Step 3: Commit**
+
+```
+git add src-tauri/src/commands/gateway_patch.rs
+
+git commit -m "feat: add patch auto-detect helpers"
+```
+
+## Chunk 2: Frontend auto-detect logic
+
+### Task 2: App startup auto-detect
+
+**Files:**
+- Modify: `src/main.js`
+
+- [ ] **Step 1: Add one-time auto-detect**
+
+Create a lightweight timer guard (module-scoped) and call `api.gatewayPatchStatus()` then `api.gatewayPatchApply(true)` when version mismatch is detected. Cooldown 5 minutes.
+
+- [ ] **Step 2: Commit**
+
+```
+git add src/main.js
+
+git commit -m "feat: auto-detect gateway patch at startup"
+```
+
+### Task 3: Settings page auto-detect
+
+**Files:**
+- Modify: `src/pages/settings.js`
+
+- [ ] **Step 1: Add auto-detect on loadAll**
+
+After `loadGatewayPatch`, run auto-detect handler with a shared cooldown guard; update UI based on result. Do not show toast on success; show error in the card only.
+
+- [ ] **Step 2: Commit**
+
+```
+git add src/pages/settings.js
+
+git commit -m "feat: auto-detect gateway patch in settings"
+```
+
+---
+
+Plan complete and saved to `docs/superpowers/plans/2026-03-16-gateway-patch-auto-detect.md`. Ready to execute?
diff --git a/docs/superpowers/plans/2026-03-16-gateway-patch-oneclick.md b/docs/superpowers/plans/2026-03-16-gateway-patch-oneclick.md
new file mode 100644
index 00000000..7c31cfb4
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-16-gateway-patch-oneclick.md
@@ -0,0 +1,169 @@
+# Gateway One-Click Patch Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a one-click gateway patch flow in ClawPanel settings to apply/rollback sessionMessage patch for global npm OpenClaw installs.
+
+**Architecture:** Add a Tauri command that locates global npm OpenClaw, backs up dist files, applies patch, records status in clawpanel.json, and exposes status to the UI. Settings UI uses tauri-api bridge to render current patch status and provide apply/redo/rollback actions.
+
+**Tech Stack:** Tauri (Rust), Vite/JS, ClawPanel config (clawpanel.json)
+
+---
+
+## Chunk 1: Backend patch command + config persistence
+
+### Task 1: Add gateway patch command
+
+**Files:**
+- Create: `src-tauri/src/commands/gateway_patch.rs`
+- Modify: `src-tauri/src/commands/mod.rs`
+- Modify: `src-tauri/src/lib.rs`
+
+- [ ] **Step 1: Create gateway_patch.rs skeleton**
+
+```rust
+use serde::{Deserialize, Serialize};
+use tauri::command;
+
+#[derive(Serialize, Deserialize)]
+pub struct GatewayPatchStatus {
+ pub installed_version: Option,
+ pub patched: bool,
+ pub patched_version: Option,
+ pub patched_at: Option,
+ pub files: Vec,
+ pub last_error: Option,
+}
+
+#[command]
+pub async fn gateway_patch_status() -> Result {
+ Ok(GatewayPatchStatus {
+ installed_version: None,
+ patched: false,
+ patched_version: None,
+ patched_at: None,
+ files: vec![],
+ last_error: None,
+ })
+}
+
+#[command]
+pub async fn gateway_patch_apply() -> Result {
+ gateway_patch_status().await
+}
+
+#[command]
+pub async fn gateway_patch_rollback() -> Result {
+ gateway_patch_status().await
+}
+```
+
+- [ ] **Step 2: Register commands**
+
+Update `src-tauri/src/commands/mod.rs` to export the new functions, and `src-tauri/src/lib.rs` to include them in `invoke_handler`.
+
+- [ ] **Step 3: Locate global npm root**
+
+Implement helper in `gateway_patch.rs`:
+- Run `npm root -g`
+- Join `openclaw/dist`
+- Detect `reply-*.js` and `gateway-cli-*.js` (choose latest by modified time)
+- Return errors if not found
+
+- [ ] **Step 4: Apply patch with backup**
+
+Implement:
+- Copy `reply-*.js` -> `.bak`
+- Copy `gateway-cli-*.js` -> `.bak`
+- Apply string-replace patches (same patterns used in manual patch)
+- Validate file content contains `sessionMessage` post patch
+
+- [ ] **Step 5: Persist status in clawpanel.json**
+
+Use existing panel config read/write to store:
+
+```json
+"gatewayPatch": {
+ "version": "sessionMessage-v1",
+ "patchedAt": "",
+ "openclawVersion": "",
+ "files": ["reply-*.js", "gateway-cli-*.js"],
+ "lastError": null
+}
+```
+
+- [ ] **Step 6: Implement rollback**
+
+Restore from `.bak` files and update status.
+
+- [ ] **Step 7: Manual verification**
+
+Run:
+```
+openclaw gateway status
+```
+Expect: Gateway runs normally.
+
+- [ ] **Step 8: Commit**
+
+```
+git add src-tauri/src/commands/gateway_patch.rs src-tauri/src/commands/mod.rs src-tauri/src/lib.rs
+
+git commit -m "feat: add gateway patch commands"
+```
+
+## Chunk 2: UI + API bridge
+
+### Task 2: Add API bridge
+
+**Files:**
+- Modify: `src/lib/tauri-api.js`
+
+- [ ] **Step 1: Add methods**
+
+```js
+export const api = {
+ // ...
+ gatewayPatchStatus: () => invoke('gateway_patch_status'),
+ gatewayPatchApply: () => invoke('gateway_patch_apply'),
+ gatewayPatchRollback: () => invoke('gateway_patch_rollback')
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```
+git add src/lib/tauri-api.js
+
+git commit -m "feat: add gateway patch api bridge"
+```
+
+### Task 3: Settings UI
+
+**Files:**
+- Modify: `src/pages/settings.js` (or `src/pages/settings-cloudflared.js` if the setting block lives there)
+- Modify: `src/style/settings.css` (if needed)
+
+- [ ] **Step 1: Add Gateway 补丁卡片**
+- Add status area: installed version, patched state, patched time
+- Buttons: 一键补丁 / 重打补丁 / 回滚
+
+- [ ] **Step 2: Wire buttons**
+- Call `api.gatewayPatchApply()` / `api.gatewayPatchRollback()`
+- Refresh status via `api.gatewayPatchStatus()`
+
+- [ ] **Step 3: Manual verification**
+- Open settings page, ensure card renders
+- Apply patch and verify status changes
+
+- [ ] **Step 4: Commit**
+
+```
+git add src/pages/settings.js src/style/settings.css
+
+git commit -m "feat: add gateway patch ui"
+```
+
+---
+
+Plan complete and saved to `docs/superpowers/plans/2026-03-16-gateway-patch-oneclick.md`. Ready to execute?
diff --git a/docs/superpowers/plans/2026-03-17-ai-config-import.md b/docs/superpowers/plans/2026-03-17-ai-config-import.md
new file mode 100644
index 00000000..f7bb8e3a
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-17-ai-config-import.md
@@ -0,0 +1,82 @@
+# AI 配置从 openclaw 导入 Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 在 AI 配置页增加“从 openclaw 导入”按钮,导入 model/temperature/top_p/api_key/base_url。
+
+**Architecture:** 前端按钮触发调用 Tauri API 读取 openclaw.json 并写回表单配置,完成后持久化保存。
+
+**Tech Stack:** Vanilla JS, Tauri
+
+---
+
+## Chunk 1: 后端读取 openclaw 配置
+
+### Task 1: 新增导入命令
+**Files:**
+- Modify: `src-tauri/src/commands/config.rs`
+
+- [ ] **Step 1: 新增 Tauri 命令**
+
+新增 `import_openclaw_ai_config`:
+- 读取 openclaw.json
+- 提取字段:model / temperature / top_p / api_key / base_url
+- 返回 JSON
+
+- [ ] **Step 2: 注册命令**
+
+在 `src-tauri/src/lib.rs` 注册命令。
+
+- [ ] **Step 3: 提交**
+```bash
+git add src-tauri/src/commands/config.rs src-tauri/src/lib.rs
+git commit -m "feat: add ai config import command"
+```
+
+---
+
+## Chunk 2: 前端按钮与写回
+
+### Task 2: AI 配置页导入
+**Files:**
+- Modify: `src/lib/tauri-api.js`
+- Modify: `src/pages/models.js`
+
+- [ ] **Step 1: 添加 API 封装**
+
+tauri-api.js 增加 `importOpenclawAiConfig`.
+
+- [ ] **Step 2: UI 按钮与写回逻辑**
+
+models.js 增加按钮,点击后:
+- 调用 API
+- 写回表单
+- 保存当前配置
+
+- [ ] **Step 3: 提交**
+```bash
+git add src/lib/tauri-api.js src/pages/models.js
+git commit -m "feat: import ai config from openclaw"
+```
+
+---
+
+## Chunk 3: 构建与验证
+
+### Task 3: 构建
+**Files:** 无
+
+- [ ] **Step 1: 构建**
+```bash
+npm run build
+```
+
+- [ ] **Step 2: 手工验证**
+- openclaw.json 存在 → 导入成功
+- 字段缺失 → 提示失败
+- 保存后配置生效
+
+- [ ] **Step 3: 推送**
+```bash
+git push
+```
diff --git a/docs/superpowers/plans/2026-03-17-assistant-optimize-toggle.md b/docs/superpowers/plans/2026-03-17-assistant-optimize-toggle.md
new file mode 100644
index 00000000..d0d38ed0
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-17-assistant-optimize-toggle.md
@@ -0,0 +1,52 @@
+# 优化/还原按钮切换 Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 优化与还原按钮互斥显示,默认显示“优化”,优化后显示“还原”,发送或还原后切回“优化”。
+
+**Architecture:** 根据 `_optOriginalText` 状态控制按钮显隐与禁用。
+
+**Tech Stack:** Vanilla JS
+
+---
+
+## Chunk 1: 逻辑调整
+
+### Task 1: updateOptimizeState 互斥显示
+**Files:**
+- Modify: `src/pages/assistant.js`
+
+- [ ] **Step 1: 更新 updateOptimizeState**
+
+逻辑:
+- `_optOriginalText` 为 null → 显示优化按钮,隐藏还原按钮
+- `_optOriginalText` 非空 → 隐藏优化按钮,显示还原按钮
+
+- [ ] **Step 2: 按钮点击后切换**
+
+- 优化完成 → 切到还原
+- 点击还原 → 清空快照并切回优化
+- 发送 → 清空快照并切回优化
+
+- [ ] **Step 3: 提交**
+```bash
+git add src/pages/assistant.js
+git commit -m "fix: toggle optimize and restore buttons"
+```
+
+---
+
+## Chunk 2: 构建与推送
+
+### Task 2: 构建
+**Files:** 无
+
+- [ ] **Step 1: 构建**
+```bash
+npm run build
+```
+
+- [ ] **Step 2: 推送**
+```bash
+git push
+```
diff --git a/docs/superpowers/plans/2026-03-17-assistant-ux-and-shell.md b/docs/superpowers/plans/2026-03-17-assistant-ux-and-shell.md
new file mode 100644
index 00000000..1946944a
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-17-assistant-ux-and-shell.md
@@ -0,0 +1,118 @@
+# Assistant UX + Windows Shell 优化 Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 实现 Windows shell 优先级、复制按钮样式统一、AI 助手输入区新增优化/恢复功能并保留撤销栈。
+
+**Architecture:** 后端 assistant_exec 增加 shell 探测与降级;前端统一 code-copy-btn CSS;AI 助手输入区新增优化调用与快照管理。
+
+**Tech Stack:** Rust (Tauri), Vanilla JS, CSS
+
+---
+
+## Chunk 1: Windows shell 优先级
+
+### Task 1: assistant_exec 使用 pwsh 优先级
+**Files:**
+- Modify: `src-tauri/src/commands/assistant.rs`
+
+- [ ] **Step 1: 增加 shell 探测函数**
+
+在 Windows 分支增加一个 `detect_windows_shell()`:
+- 依次检查 `pwsh`、`powershell`
+- 都不可用则返回 `cmd`
+
+实现方式:使用 `where` 探测,并使用 `build_system_env()` 注入完整环境。
+
+- [ ] **Step 2: 替换执行逻辑**
+
+`assistant_exec` 使用探测结果执行:
+- pwsh / powershell:`-NoProfile -Command `
+- cmd:`/c `
+
+- [ ] **Step 3: 提交**
+```bash
+git add src-tauri/src/commands/assistant.rs
+git commit -m "feat: prefer pwsh in assistant exec"
+```
+
+---
+
+## Chunk 2: 复制按钮错位修复(CSS)
+
+### Task 2: 统一 chat.css 与 assistant.css
+**Files:**
+- Modify: `src/style/chat.css`
+- Modify: `src/style/assistant.css`
+
+- [ ] **Step 1: 统一 pre 与 copy 按钮样式**
+
+将 assistant.css 中 pre 样式改为与 chat.css 一致,确保:
+- `.code-copy-btn` 右上角悬浮
+- hover 才显示
+- 不改 Markdown 解析逻辑
+
+- [ ] **Step 2: 提交**
+```bash
+git add src/style/chat.css src/style/assistant.css
+git commit -m "fix: align code copy button styles"
+```
+
+---
+
+## Chunk 3: AI 优化按钮
+
+### Task 3: 输入区新增优化/恢复按钮
+**Files:**
+- Modify: `src/pages/assistant.js`
+
+- [ ] **Step 1: 增加按钮 DOM**
+
+在输入区加入 “优化” 与 “恢复原文” 按钮。
+
+- [ ] **Step 2: 维护快照状态**
+
+新增变量:`_optOriginalText` / `_optOptimizedText`
+规则:
+- 点击优化:保存原文快照,写入优化结果快照
+- 点击恢复:恢复原文
+- 发送成功后清空快照
+
+- [ ] **Step 3: 调用同模型在线重写**
+
+复用现有模型调用逻辑:
+- 模板:`请在不改变原意和语言的前提下,重写为意思更清晰、更简洁的表达。`
+- 使用同模型
+- 结果直接替换输入框内容
+
+- [ ] **Step 4: setRangeText 触发 input 事件**
+
+使用 `textarea.setRangeText()` + 触发 `input` 事件,保证 Ctrl+Z 生效。
+
+- [ ] **Step 5: 提交**
+```bash
+git add src/pages/assistant.js
+git commit -m "feat: add optimize and restore buttons"
+```
+
+---
+
+## Chunk 4: 构建与验证
+
+### Task 4: 构建
+**Files:** 无
+
+- [ ] **Step 1: 构建**
+```bash
+npm run build
+```
+
+- [ ] **Step 2: 手工验证**
+- Windows 下执行命令优先 pwsh
+- 复制按钮位置正确
+- 优化/恢复可用且 Ctrl+Z 正常
+
+- [ ] **Step 3: 推送**
+```bash
+git push
+```
diff --git a/docs/superpowers/plans/2026-03-17-chat-autoscroll.md b/docs/superpowers/plans/2026-03-17-chat-autoscroll.md
new file mode 100644
index 00000000..5a0a5399
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-17-chat-autoscroll.md
@@ -0,0 +1,132 @@
+# Chat Auto-Scroll Gating Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Only auto-scroll the chat view when new messages arrive and the user is at the bottom; never force-scroll while the user is reading history.
+
+**Architecture:** Add a single auto-scroll gate to `chat.js` that tracks whether the user is at the bottom. Trigger scrolling only on message insertion and stream updates when the gate is enabled. Avoid auto-scroll in render loops to prevent continuous snapping.
+
+**Tech Stack:** Vanilla JS, DOM APIs, existing chat page logic in `src/pages/chat.js`.
+
+---
+
+## Chunk 1: Auto-scroll gating
+
+### Task 1: Add auto-scroll state and update on scroll
+
+**Files:**
+- Modify: `src/pages/chat.js`
+
+- [ ] **Step 1: Add state flags near other module-level state**
+
+Add:
+```js
+let _autoScrollEnabled = true
+```
+
+- [ ] **Step 2: Update auto-scroll flag on scroll**
+
+In the `_messagesEl.addEventListener('scroll', ...)` handler, update state based on `isAtBottom()`:
+```js
+_messagesEl.addEventListener('scroll', () => {
+ const { scrollTop, scrollHeight, clientHeight } = _messagesEl
+ _scrollBtn.style.display = (scrollHeight - scrollTop - clientHeight < 80) ? 'none' : 'flex'
+ _autoScrollEnabled = isAtBottom()
+})
+```
+
+- [ ] **Step 3: Ensure scroll button restores auto-scroll**
+
+When the user clicks the scroll-to-bottom button, set `_autoScrollEnabled = true` after moving to bottom:
+```js
+_scrollBtn.addEventListener('click', () => {
+ _autoScrollEnabled = true
+ scrollToBottom(true)
+})
+```
+
+### Task 2: Gate auto-scroll to message insertion
+
+**Files:**
+- Modify: `src/pages/chat.js`
+
+- [ ] **Step 1: Update `scrollToBottom` to respect gating**
+
+Change function signature and behavior:
+```js
+function scrollToBottom(force = false) {
+ if (!_messagesEl) return
+ if (!force && !_autoScrollEnabled) return
+ requestAnimationFrame(() => { _messagesEl.scrollTop = _messagesEl.scrollHeight })
+}
+```
+
+- [ ] **Step 2: Ensure scroll is invoked only on new message insertion**
+
+Keep `scrollToBottom()` calls only in:
+- `appendUserMessage`
+- `appendAiMessage`
+- `appendSystemMessage`
+- `createStreamBubble`
+- `showTyping(true)` (but it will now respect gate)
+
+Remove or avoid unconditional scrolling in render loops.
+
+- [ ] **Step 3: Stop continuous auto-scroll in render loops**
+
+In `doRender` remove the unconditional `scrollToBottom()` or guard it by auto-scroll:
+```js
+if (_currentAiBubble && _currentAiText) {
+ _currentAiBubble.innerHTML = renderMarkdown(_currentAiText)
+ scrollToBottom()
+}
+```
+(With gated `scrollToBottom`, it will only happen when user is at bottom.)
+
+- [ ] **Step 4: Guard virtual render bottom snapping**
+
+In `doVirtualRender`, only snap to bottom when `_autoScrollEnabled` is true:
+```js
+if (atBottom && _autoScrollEnabled) {
+ scrollToBottom()
+}
+```
+
+### Task 3: Validate streaming behavior
+
+**Files:**
+- Modify: `src/pages/chat.js`
+
+- [ ] **Step 1: Ensure stream updates do not force-scroll while reading history**
+
+Verify `doRender` and `createStreamBubble` only scroll when `_autoScrollEnabled` is true.
+
+### Task 4: Manual verification and build
+
+**Files:**
+- None
+
+- [ ] **Step 1: Manual verification checklist**
+
+Checklist:
+- Open chat page with existing history
+- Scroll up; verify the view stays in place (no automatic snapping)
+- Send a new message while scrolled up; verify it does not force-scroll
+- Scroll to bottom and send/receive a message; verify it auto-scrolls
+- Click the scroll-to-bottom button; verify it jumps and re-enables auto-scroll
+
+- [ ] **Step 2: Build**
+
+Run:
+```powershell
+npm run build
+```
+Expected: build succeeds with no errors.
+
+- [ ] **Step 3: Commit**
+
+```powershell
+git add src\pages\chat.js
+
+git commit -m "fix: gate chat auto-scroll on new messages"
+```
diff --git a/docs/superpowers/plans/2026-03-17-chat-daylight-shadow.md b/docs/superpowers/plans/2026-03-17-chat-daylight-shadow.md
new file mode 100644
index 00000000..92195288
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-17-chat-daylight-shadow.md
@@ -0,0 +1,46 @@
+# Chat Daylight Shadow Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a light-mode shadow to assistant message bubbles so they remain visible against the page background.
+
+**Architecture:** Update chat CSS to apply a light-mode-only shadow on `.msg-ai .msg-bubble` without changing dark mode. No layout or markup changes.
+
+**Tech Stack:** CSS, Vite build
+
+---
+
+## Chunk 1: Daylight shadow style
+
+### Task 1: Add light-mode shadow for assistant bubbles
+
+**Files:**
+- Modify: `src/style/chat.css` (near `.msg-ai .msg-bubble` rules)
+
+- [ ] **Step 1: Add light-mode CSS rule**
+
+Add a new rule scoped to light theme:
+
+```css
+[data-theme="light"] .msg-ai .msg-bubble {
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+}
+```
+
+- [ ] **Step 2: Build**
+
+Run: `npm run build`
+Expected: Build succeeds without errors.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/style/chat.css
+git commit -m "fix: add daylight shadow for ai bubble"
+```
+
+- [ ] **Step 4: Push**
+
+```bash
+git push
+```
diff --git a/docs/superpowers/plans/2026-03-17-chat-history-tool-merge.md b/docs/superpowers/plans/2026-03-17-chat-history-tool-merge.md
new file mode 100644
index 00000000..ded067e3
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-17-chat-history-tool-merge.md
@@ -0,0 +1,81 @@
+# Chat History Tool Merge Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Fix chat history parsing so tool cards merge by toolCallId, show above text, and sort by time with correct fallback.
+
+**Architecture:** Merge tool blocks via upsertTool, resolve tool time from event ts or message timestamp, and suppress tool system messages during history parsing. Render tools and text as entries sorted by time (tools first when time ties).
+
+**Tech Stack:** React, JS
+
+---
+
+## Chunk 1: Parsing + ordering adjustments
+
+### Task 1: Add time resolver + merge tool entries
+
+**Files:**
+- Modify: `src/pages/chat.js`
+
+- [ ] **Step 0: Checkpoint(PowerShell)**
+
+```powershell
+git status -sb
+git commit --allow-empty -m "chore: checkpoint before history tool merge"
+```
+
+- [ ] **Step 1: Add tool time resolver**
+
+Add helper near tool utilities:
+
+```js
+function resolveToolTime(toolId, messageTimestamp) {
+ const eventTs = toolId ? _toolEventTimes.get(toolId) : null
+ return eventTs || messageTimestamp || null
+}
+```
+
+- [ ] **Step 2: Use upsertTool in history parsing**
+
+In `extractChatContent` and `extractContent`, replace `tools.push` with `upsertTool` for toolCall/toolResult blocks so toolCallId merges.
+
+- [ ] **Step 3: Apply time fallback to tools**
+
+When building tool entries, set `time: resolveToolTime(id, message.timestamp)`.
+
+- [ ] **Step 4: Suppress tool system messages in history**
+
+When processing history responses, skip `appendSystemMessage` for tool events, only render tool cards.
+
+- [ ] **Step 5: Sort entries by time with tool-first tie**
+
+In rendering pipeline, build `entries` combining tools and text, then sort:
+
+```js
+entries.sort((a, b) => {
+ const ta = a.time ?? 0
+ const tb = b.time ?? 0
+ if (ta !== tb) return ta - tb
+ if (a.kind === 'tool' && b.kind !== 'tool') return -1
+ if (a.kind !== 'tool' && b.kind === 'tool') return 1
+ return 0
+})
+```
+
+- [ ] **Step 6: Build**
+
+Run: `npm run build`
+Expected: Build succeeds without errors.
+
+- [ ] **Step 7: Commit**
+
+```powershell
+git add src\pages\chat.js
+git commit -m "fix: history tool merge and ordering"
+```
+
+- [ ] **Step 8: Push**
+
+```powershell
+git push
+```
diff --git a/docs/superpowers/plans/2026-03-17-chat-markdown-it.md b/docs/superpowers/plans/2026-03-17-chat-markdown-it.md
new file mode 100644
index 00000000..2256a8de
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-17-chat-markdown-it.md
@@ -0,0 +1,146 @@
+# Chat Markdown-it Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace the chat Markdown renderer with markdown-it plus plugins to support underline, spoiler, and mention, matching GitHub-like behavior.
+
+**Architecture:** Use markdown-it with html disabled, link validation, custom renderer for code blocks using existing highlightCode. Add local plugins for spoiler (|| || + >! !<), mention (@user), and a custom underline rule that maps __text__ to .
+
+**Tech Stack:** JS, Vite
+
+---
+
+## Chunk 1: Dependencies + renderer refactor
+
+### Task 1: Add markdown-it and plugin deps
+
+**Files:**
+- Modify: `package.json`
+
+- [ ] **Step 0: Checkpoint(PowerShell)**
+
+```powershell
+git status -sb
+git commit --allow-empty -m "chore: checkpoint before markdown-it"
+```
+
+- [ ] **Step 1: Add dependencies**
+
+Add to dependencies:
+- markdown-it
+
+- [ ] **Step 2: Install**
+
+```powershell
+npm install
+```
+
+- [ ] **Step 3: Commit deps**
+
+```powershell
+git add package.json package-lock.json
+git commit -m "chore: add markdown-it deps"
+```
+
+### Task 2: Replace renderer
+
+**Files:**
+- Modify: `src/lib/markdown.js`
+
+- [ ] **Step 1: Instantiate markdown-it**
+
+```js
+import MarkdownIt from 'markdown-it'
+```
+
+```js
+const md = new MarkdownIt({
+ html: false,
+ linkify: true,
+ breaks: false,
+ highlight: (code, lang) => {
+ const highlighted = highlightCode(code, lang)
+ const langLabel = lang ? `${escapeHtml(lang)}` : ''
+ return `${langLabel}${highlighted}`
+ },
+})
+```
+
+- [ ] **Step 1.1: Underline plugin (`__text__` -> ``)**
+
+Implement a custom inline rule to parse double-underscore into `` and keep `**` for bold.
+
+- [ ] **Step 2: Link whitelist**
+
+Override link renderer to allow only http/https/mailto, otherwise href="#".
+
+- [ ] **Step 3: Spoiler plugin**
+
+Implement custom plugin to parse:
+- `||spoiler||`
+- `>!spoiler!<`
+Output: `...`
+
+- [ ] **Step 4: Mention plugin**
+
+Parse `@username` into `@username`.
+
+- [ ] **Step 5: Update renderMarkdown**
+
+Replace manual parsing with `md.render(text)`.
+
+## Chunk 2: Styling
+
+### Task 3: Add styles
+
+**Files:**
+- Modify: `src/style/chat.css` or `src/style/components.css`
+
+- [ ] **Step 1: Add mention style**
+
+```css
+.msg-mention {
+ color: var(--accent);
+ font-weight: 600;
+}
+```
+
+- [ ] **Step 2: Add spoiler style**
+
+```css
+.msg-spoiler {
+ background: currentColor;
+ color: transparent;
+ border-radius: 4px;
+ padding: 0 4px;
+ cursor: pointer;
+}
+.msg-spoiler.revealed {
+ color: inherit;
+ background: rgba(0,0,0,0.12);
+}
+```
+
+- [ ] **Step 3: Add click handler**
+
+In chat init or render, add event delegation to toggle `revealed` class on `.msg-spoiler`.
+
+## Chunk 3: Build + Commit
+
+- [ ] **Step 1: Build**
+
+Run: `npm run build`
+Expected: Build succeeds without errors.
+
+- [ ] **Step 2: Commit**
+
+```powershell
+git add src\lib\markdown.js src\style\chat.css src\style\components.css
+git commit -m "feat: markdown-it rendering"
+```
+
+- [ ] **Step 3: Push**
+
+```powershell
+git push
+```
diff --git a/docs/superpowers/plans/2026-03-17-chat-tool-event-live.md b/docs/superpowers/plans/2026-03-17-chat-tool-event-live.md
new file mode 100644
index 00000000..0127906e
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-17-chat-tool-event-live.md
@@ -0,0 +1,97 @@
+# Chat Tool Event Live Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Display tool events as live system messages ordered by payload.ts and de-duplicated by payload.runId + toolCallId.
+
+**Architecture:** Extend chat event handling to create tool-event system messages and insert into DOM by timestamp; add dedupe map keyed by ts+toolCallId.
+
+**Tech Stack:** Vanilla JS, CSS, Vite build
+
+---
+
+## Chunk 1: Tool event live insertion
+
+### Task 1: Add dedupe and insert-by-time
+
+**Files:**
+- Modify: `src/pages/chat.js`
+
+- [ ] **Step 0: Checkpoint(PowerShell)**
+
+```powershell
+git status -sb
+git commit --allow-empty -m "chore: checkpoint before tool event live"
+```
+
+Note: This checkpoint is required by policy; final functional commit occurs after build.
+
+- [ ] **Step 1: Add maps for dedupe and event list**
+
+Add near top-level state:
+
+```js
+const _toolEventSeen = new Set()
+```
+
+- [ ] **Step 2: Insert helper for ordered messages**
+
+Add a helper to insert a message wrapper by timestamp:
+
+```js
+function insertMessageByTime(wrap, ts) {
+ const tsValue = Number(ts || Date.now())
+ wrap.dataset.ts = String(tsValue)
+ const items = Array.from(_messagesEl.querySelectorAll('.msg'))
+ for (const node of items) {
+ const nodeTs = parseInt(node.dataset.ts || '0', 10)
+ if (nodeTs > tsValue) {
+ _messagesEl.insertBefore(wrap, node)
+ return
+ }
+ }
+ _messagesEl.insertBefore(wrap, _typingEl)
+}
+```
+
+- [ ] **Step 3: Add tool-event system message builder**
+
+```js
+function appendToolEventMessage(name, phase, ts, isError) {
+ const wrap = document.createElement('div')
+ wrap.className = 'msg msg-system'
+ wrap.textContent = `${name} · ${phase}${isError ? ' · 失败' : ''}`
+ insertMessageByTime(wrap, ts)
+}
+```
+
+- [ ] **Step 4: Handle tool events in handleEvent**
+
+```js
+if (event === 'agent' && payload?.stream === 'tool' && payload?.data?.toolCallId) {
+ const key = `${payload.runId}:${payload.data.toolCallId}`
+ if (_toolEventSeen.has(key)) return
+ _toolEventSeen.add(key)
+ const name = payload.data.name || '工具'
+ const phase = payload.data.phase || 'unknown'
+ appendToolEventMessage(name, phase, payload.ts, payload.data.isError)
+}
+```
+
+- [ ] **Step 5: Build**
+
+Run: `npm run build`
+Expected: Build succeeds without errors.
+
+- [ ] **Step 6: Commit(PowerShell)**
+
+```powershell
+git add src/pages/chat.js
+git commit -m "fix: show live tool events"
+```
+
+- [ ] **Step 7: Push(PowerShell)**
+
+```powershell
+git push
+```
diff --git a/docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md b/docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md
new file mode 100644
index 00000000..57c39dc0
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-17-chat-virtual-scroll-implementation.md
@@ -0,0 +1,211 @@
+# Chat Virtual Scroll Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Implement virtual scrolling for chat messages with fixed window size (40 + overscan 20), fast first paint, and stable scroll anchoring.
+
+**Architecture:** Use a virtualized list with top/bottom spacers and total height based on cumulative measured heights (fallback to average). Range calculation uses cumulative heights (prefix sums + binary search) with a fixed window cap. Preserve anchor when not at bottom.
+
+**Tech Stack:** JS, Vite
+
+---
+
+## File Map
+- Modify: `src/pages/chat.js:64-2000` (virtual state, scroll handler, render path)
+- Create: `src/lib/virtual-scroll.js` (range + prefix height helpers)
+- Create: `tests/virtual-scroll.test.js`
+- Modify: `package.json` (test script, devDependency)
+
+---
+
+## Chunk 1: Test scaffolding + helpers (TDD)
+
+### Task 1: Add test tooling
+
+**Files:**
+- Modify: `package.json`
+
+- [ ] **Step 0: Checkpoint(PowerShell)**
+
+```powershell
+git status -sb
+git commit --allow-empty -m "chore: checkpoint before chat virtual scroll"
+```
+
+- [ ] **Step 1: Add dev dependency and script**
+
+Add:
+- `devDependencies.vitest`
+- `scripts.test = "vitest run"`
+
+- [ ] **Step 2: Install**
+
+```powershell
+npm install
+```
+
+### Task 2: Create helper module
+
+**Files:**
+- Create: `src/lib/virtual-scroll.js`
+
+- [ ] **Step 1: Implement helpers**
+
+```js
+export function getItemHeight(items, idx, heights, avgHeight) {
+ const id = items[idx]?.id
+ return heights.get(id) || avgHeight
+}
+
+export function buildPrefixHeights(items, heights, avgHeight) {
+ const prefix = [0]
+ for (let i = 0; i < items.length; i++) {
+ prefix[i + 1] = prefix[i] + getItemHeight(items, i, heights, avgHeight)
+ }
+ return prefix
+}
+
+export function findStartIndex(prefix, scrollTop) {
+ let lo = 0, hi = prefix.length - 1
+ while (lo < hi) {
+ const mid = Math.floor((lo + hi) / 2)
+ if (prefix[mid] <= scrollTop) lo = mid + 1
+ else hi = mid
+ }
+ return Math.max(0, lo - 1)
+}
+
+export function computeVirtualRange(items, scrollTop, viewportHeight, avgHeight, overscan, windowSize, heights) {
+ const prefix = buildPrefixHeights(items, heights, avgHeight)
+ const start = Math.max(0, findStartIndex(prefix, scrollTop) - overscan)
+ let end = Math.min(items.length, start + windowSize + overscan * 2)
+ // 固定窗口:严格限制 end-start 不超过 windowSize + overscan*2
+ return { start, end, prefix }
+}
+
+export function getSpacerHeights(prefix, start, end) {
+ const top = prefix[start]
+ const total = prefix[prefix.length - 1]
+ const bottom = Math.max(0, total - prefix[end])
+ return { top, bottom, total }
+}
+```
+
+### Task 3: Add tests (TDD)
+
+**Files:**
+- Create: `tests/virtual-scroll.test.js`
+
+- [ ] **Step 1: Write failing tests**
+
+```js
+import { describe, it, expect } from 'vitest'
+import { buildPrefixHeights, computeVirtualRange, getSpacerHeights } from '../src/lib/virtual-scroll.js'
+
+describe('virtual scroll helpers', () => {
+ it('builds prefix heights with avg fallback', () => {
+ const items = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]
+ const heights = new Map([['b', 80]])
+ const prefix = buildPrefixHeights(items, heights, 50)
+ expect(prefix).toEqual([0, 50, 130, 180])
+ })
+
+ it('computes range with window cap', () => {
+ const items = Array.from({ length: 200 }, (_, i) => ({ id: String(i) }))
+ const heights = new Map()
+ const { start, end } = computeVirtualRange(items, 0, 600, 30, 20, 40, heights)
+ expect(end - start).toBeLessThanOrEqual(80)
+ })
+
+ it('spacer heights sum to total', () => {
+ const prefix = [0, 50, 100, 150]
+ const { top, bottom, total } = getSpacerHeights(prefix, 1, 2)
+ expect(top + bottom + (prefix[2] - prefix[1])).toBe(total)
+ })
+})
+```
+
+- [ ] **Step 2: Run tests (expect FAIL)**
+
+```powershell
+npm run test
+```
+Expected: FAIL if helpers not implemented.
+
+- [ ] **Step 3: Implement helpers (Step 2) then re-run tests (expect PASS)**
+
+```powershell
+npm run test
+```
+
+- [ ] **Step 4: Commit helpers + tests**
+
+```powershell
+git add src\lib\virtual-scroll.js tests\virtual-scroll.test.js package.json package-lock.json
+git commit -m "test: add virtual scroll helpers"
+```
+
+---
+
+## Chunk 2: Integrate virtual scroll into chat
+
+### Task 4: Add state + range calc
+
+**Files:**
+- Modify: `src/pages/chat.js:64-2000`
+
+- [ ] **Step 1: Add constants + state**
+
+```js
+const VIRTUAL_WINDOW = 40
+const VIRTUAL_OVERSCAN = 20
+let _virtualEnabled = true
+let _virtualHeights = new Map()
+let _virtualAvgHeight = 64
+let _virtualRange = { start: 0, end: 0, prefix: [0] }
+```
+
+- [ ] **Step 2: Import helpers**
+
+```js
+import { computeVirtualRange, getSpacerHeights } from '../lib/virtual-scroll.js'
+```
+
+- [ ] **Step 3: Scroll handler**
+
+On scroll, compute range using `computeVirtualRange(items, scrollTop, viewportHeight, _virtualAvgHeight, VIRTUAL_OVERSCAN, VIRTUAL_WINDOW, _virtualHeights)` and update `_virtualRange` when changed.
+
+### Task 5: Render with spacers + measurement
+
+**Files:**
+- Modify: `src/pages/chat.js:1380-1750`
+
+- [ ] **Step 1: Render spacers + window**
+
+Insert:
+- top spacer with height = `getSpacerHeights(prefix, start, end).top`
+- visible items = `items.slice(start, end)`
+- bottom spacer with height = `getSpacerHeights(prefix, start, end).bottom`
+
+- [ ] **Step 2: Measure heights**
+
+After render (requestAnimationFrame), measure visible `.msg` nodes using `getBoundingClientRect().height`, update `_virtualHeights`, and recompute `_virtualAvgHeight`.
+
+- [ ] **Step 3: Anchor strategy**
+
+If user is at bottom (within 80px), auto scroll to bottom after new message. Otherwise, preserve scroll position by capturing `scrollTop` before re-render and adjusting by delta in top spacer height.
+
+### Task 6: Build
+
+```powershell
+npm run build
+```
+Expected: Build succeeds without errors.
+
+### Task 7: Commit + Push
+
+```powershell
+git add src\pages\chat.js
+git commit -m "feat: chat virtual scroll"
+git push
+```
diff --git a/docs/superpowers/plans/2026-03-17-cron-trigger-mode.md b/docs/superpowers/plans/2026-03-17-cron-trigger-mode.md
new file mode 100644
index 00000000..3009d683
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-17-cron-trigger-mode.md
@@ -0,0 +1,86 @@
+# Cron 触发模式扩展 Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 让 sessionMessage 任务支持两种触发模式:按 cron 执行或监听指定会话 agent 任务结束后发送。
+
+**Architecture:** 在 cron.js 中为 sessionMessage 任务增加触发模式字段(cron | onIdle),本地存储与渲染读取该字段;onIdle 模式基于 wsClient 事件跟踪目标会话的 run 状态,任务结束且空闲即发送;cron 模式保留现有定时器。非 sessionMessage 任务仍走 Gateway。
+
+**Tech Stack:** ClawPanel (Vite + JS), WebSocket client, Tauri panel config API
+
+---
+
+## Chunk 1: 数据模型与 UI
+
+### Task 1: 增加触发模式字段
+
+**Files:**
+- Modify: `src/pages/cron.js`
+
+- [ ] **Step 1: 新增字段**
+
+为 sessionMessage 本地任务新增 `triggerMode` 字段:`cron` | `onIdle`。
+
+- [ ] **Step 2: UI 选择**
+
+在 sessionMessage 任务编辑弹窗加入触发模式选择:
+- 选项:`按 Cron` / `监听任务结束`
+- 选择 onIdle 时隐藏 cron 输入,显示说明:监听目标会话任务结束后发送。
+
+- [ ] **Step 3: 列表展示**
+
+列表中显示触发模式:
+- cron 显示 cron 文本
+- onIdle 显示 “任务结束后发送”
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/pages/cron.js
+git commit -m "feat: add sessionMessage trigger mode"
+```
+
+## Chunk 2: 触发逻辑
+
+### Task 2: cron 与 onIdle 双模式发送
+
+**Files:**
+- Modify: `src/pages/cron.js`
+
+- [ ] **Step 1: cron 触发**
+
+仅当 `triggerMode === 'cron'` 时参与 `tickSessionMessageJobs` 逻辑。
+
+- [ ] **Step 2: onIdle 触发**
+
+新增 `checkIdleTrigger(job)`:
+- 若 `triggerMode === 'onIdle'`,当目标会话从 active -> idle 且当前未发送过本轮,发送消息并记录 `lastRunAtMs`。
+
+- [ ] **Step 3: 去重**
+
+使用 `state.lastRunAtMs` 或 `state.lastIdleAtMs` 避免重复发送。
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/pages/cron.js
+git commit -m "feat: onIdle trigger for sessionMessage"
+```
+
+## Chunk 3: 验证
+
+### Task 3: Build 与手动验证
+
+- [ ] **Step 1: Build**
+
+```bash
+npm run build
+```
+
+- [ ] **Step 2: 验证**
+
+- 新建 sessionMessage 任务,选择 cron → 定时发送生效
+- 新建 sessionMessage 任务,选择 onIdle → 会话任务结束后发送
+- 非 sessionMessage 任务仍通过 Gateway 保存/触发
+
+---
diff --git a/docs/superpowers/plans/2026-03-17-cron-ws-sessionmessage.md b/docs/superpowers/plans/2026-03-17-cron-ws-sessionmessage.md
new file mode 100644
index 00000000..008cbde8
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-17-cron-ws-sessionmessage.md
@@ -0,0 +1,199 @@
+# Cron WS SessionMessage Replacement Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace gateway patch–based cron sessionMessage with a client-side scheduler that sends user messages over the already-connected WSS only after the target session is idle/completed.
+
+**Architecture:** Store scheduled jobs in panel config (local), run a lightweight scheduler in ClawPanel (cron.js) that waits for gatewayReady and session idle state, then dispatch wsClient.chatSend. Track run state and last-run time in panel config to avoid duplicate sends.
+
+**Tech Stack:** Frontend JS (cron.js, ws-client.js), panel config (read/write via tauri-api), WebSocket RPC (sessions.list, sessions.get, chat events).
+
+---
+
+## File Structure
+
+**Modify:**
+- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\src\pages\cron.js` (UI, local scheduler, local job storage)
+- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\src\lib\tauri-api.js` (panel config helpers)
+- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\src\lib\ws-client.js` (optional: expose session idle state helper)
+- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\src\pages\chat.js` (optional: emit idle status events)
+- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\src\main.js` (optional: start scheduler on boot)
+
+**Create:**
+- `C:\Users\34438\.openclaw\workspace\tools\clawpanel\docs\superpowers\plans\2026-03-17-cron-ws-sessionmessage.md` (this plan)
+
+---
+
+## Task 1: Define local cron job schema and storage
+
+**Files:**
+- Modify: `src/pages/cron.js`
+- Modify: `src/lib/tauri-api.js`
+
+- [ ] **Step 1: Add local job schema**
+
+Define a new `localCronJobs` array in panel config, each job:
+```
+{
+ id: "uuid",
+ name: "string",
+ schedule: { kind: "cron", expr: "* * * * *" },
+ enabled: true,
+ payload: {
+ kind: "sessionMessage",
+ label: "sessionLabel",
+ message: "text",
+ waitForIdle: true
+ },
+ state: { lastRunAtMs: 0, lastStatus: "ok|error|skipped", lastError: "" }
+}
+```
+
+- [ ] **Step 2: Add API helpers for panel config**
+
+In `tauri-api.js`, add helpers:
+```
+readPanelConfig()
+writePanelConfig()
+```
+Ensure cron.js can read/write panel config locally without gateway.
+
+- [ ] **Step 3: Implement load/save local jobs**
+
+In cron.js:
+- On render, load panel config and initialize local jobs list if missing.
+- Use `localJobs` as a separate tab/section from gateway jobs.
+
+---
+
+## Task 2: Update Cron UI for sessionMessage-only mode
+
+**Files:**
+- Modify: `src/pages/cron.js`
+
+- [ ] **Step 1: Replace task type selector**
+
+Remove gateway cron payload kind selector. Only show “发送 user 消息(WSS)”.
+
+- [ ] **Step 2: Show required fields**
+
+Show inputs:
+- name
+- schedule (cron)
+- sessionLabel (from sessions.list)
+- message (textarea)
+- enabled toggle
+- waitForIdle toggle
+
+- [ ] **Step 3: Save local job**
+
+On save, write to panel config localCronJobs, update lastRun fields to defaults.
+
+- [ ] **Step 4: Remove gateway cron create/update**
+
+Delete calls to `wsClient.request('cron.add'|'cron.update')` for local jobs.
+
+---
+
+## Task 3: Implement WSS local scheduler
+
+**Files:**
+- Modify: `src/pages/cron.js`
+- Modify: `src/main.js` (optional startup hook)
+
+- [ ] **Step 1: Add scheduler loop**
+
+Create an interval (e.g., 10s) to:
+- Check wsClient.gatewayReady
+- For each enabled local job, check next due time from cron expression
+- If due and not run in current window, attempt send
+
+- [ ] **Step 2: Determine session idle state**
+
+Define idle as:
+- No active runs for target session
+- Or no “streaming” event in last N seconds
+
+Approach:
+- Use `sessions.list` or `sessions.get` via wsClient to read run state if available
+- If not available, fallback to client-side tracking of last chat event timestamps for that session
+
+- [ ] **Step 3: Send message only when idle**
+
+Use:
+```
+wsClient.chatSend(sessionKey, message)
+```
+Only when idle.
+
+- [ ] **Step 4: Update job state**
+
+On send success:
+- state.lastRunAtMs = Date.now()
+- state.lastStatus = "ok"
+On failure:
+- state.lastStatus = "error"
+- state.lastError = message
+
+Persist to panel config after each run.
+
+---
+
+## Task 4: Session resolution by label
+
+**Files:**
+- Modify: `src/pages/cron.js`
+
+- [ ] **Step 1: Build label→sessionKey map**
+
+Use sessions.list to map label (parseSessionLabel) back to sessionKey.
+
+- [ ] **Step 2: Validate session exists**
+
+If session missing, mark lastStatus=error and lastError="session not found".
+
+---
+
+## Task 5: Visual status and monitoring
+
+**Files:**
+- Modify: `src/pages/cron.js`
+
+- [ ] **Step 1: Show local cron jobs in UI**
+
+Include status badges:
+- last run time
+- last status
+- error message if any
+
+- [ ] **Step 2: Add manual run button**
+
+Trigger immediate send via scheduler path (same idle check).
+
+---
+
+## Task 6: Verification
+
+**Files:**
+- Modify: `src/pages/cron.js`
+
+- [ ] **Step 1: Build**
+
+Run:
+```
+npm run build
+```
+Expected: Success.
+
+- [ ] **Step 2: Manual test**
+
+1) Create a local cron job to send to main session.
+2) Start a long-running agent task.
+3) Verify scheduler waits until idle, then sends message.
+
+---
+
+## Notes
+- This plan intentionally bypasses gateway cron schema and patching.
+- Jobs are stored locally in panel config, so they only run while ClawPanel is open.
+- If headless scheduling is required later, a separate gateway-side implementation will be needed.
diff --git a/docs/superpowers/plans/2026-03-17-force-setup.md b/docs/superpowers/plans/2026-03-17-force-setup.md
new file mode 100644
index 00000000..93774207
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-17-force-setup.md
@@ -0,0 +1,84 @@
+# forceSetup Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 添加 `forceSetup` 开关,使构建版可强制进入 /setup,完成初始化后自动关闭。
+
+**Architecture:** 在 panel config 中存储 forceSetup;启动时读取并强制跳转;setup 成功后清零。
+
+**Tech Stack:** Vanilla JS, Tauri
+
+---
+
+## Chunk 1: 配置字段读写
+
+### Task 1: panel config 增加 forceSetup
+**Files:**
+- Modify: `src-tauri/src/commands/config.rs`
+- Modify: `src/lib/tauri-api.js`
+
+- [ ] **Step 1: 扩展 panel config 读写**
+
+在读写 panel config 时透传 `forceSetup`。
+
+- [ ] **Step 2: 提交**
+```bash
+git add src-tauri/src/commands/config.rs src/lib/tauri-api.js
+git commit -m "feat: add forceSetup to panel config"
+```
+
+---
+
+## Chunk 2: 启动强制跳转
+
+### Task 2: main.js 强制跳 setup
+**Files:**
+- Modify: `src/main.js`
+
+- [ ] **Step 1: 启动时读取 panel config**
+
+在 `ensureWebSession` 前读取 panel config,若 `forceSetup===true` 强制跳转 `/setup`。
+
+- [ ] **Step 2: 提交**
+```bash
+git add src/main.js
+git commit -m "feat: force setup on startup"
+```
+
+---
+
+## Chunk 3: setup 完成后清零
+
+### Task 3: setup.js 成功后清零
+**Files:**
+- Modify: `src/pages/setup.js`
+
+- [ ] **Step 1: setup 成功时写入 forceSetup=false**
+
+- [ ] **Step 2: 提交**
+```bash
+git add src/pages/setup.js
+git commit -m "feat: clear forceSetup after setup"
+```
+
+---
+
+## Chunk 4: 构建与验证
+
+### Task 4: 构建
+**Files:** 无
+
+- [ ] **Step 1: 构建**
+```bash
+npm run build
+```
+
+- [ ] **Step 2: 手工验证**
+- forceSetup=true 时进入 /setup
+- setup 完成后不再强制跳转
+- forceSetup=false 时逻辑不变
+
+- [ ] **Step 3: 推送**
+```bash
+git push
+```
diff --git a/docs/superpowers/plans/2026-03-17-skill-trigger-optimization.md b/docs/superpowers/plans/2026-03-17-skill-trigger-optimization.md
new file mode 100644
index 00000000..38fc574d
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-17-skill-trigger-optimization.md
@@ -0,0 +1,165 @@
+# Skill Trigger Optimization Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Optimize trigger descriptions for all installed OpenClaw skills using the skill-creator workflow.
+
+**Architecture:** Inventory all skills under `~/.openclaw/skills/`, snapshot current SKILL.md frontmatter, then iterate through each skill to improve the description field with an automated loop when available. Ensure results are written back to each skill’s SKILL.md frontmatter and logged.
+
+**Tech Stack:** PowerShell, Python via `uv`, OpenClaw skill-creator assets (scripts/), markdown edits.
+
+---
+
+### Task 1: Inventory and snapshot
+
+**Files:**
+- Read: `C:\Users\34438\.openclaw\skills\*\SKILL.md`
+- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\inventory.json`
+- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\snapshots\\SKILL.md`
+
+- [ ] **Step 1: List skill directories**
+
+Run:
+```
+Get-ChildItem "C:\Users\34438\.openclaw\skills" -Directory | Select-Object Name
+```
+Expected: list of skill folder names.
+
+- [ ] **Step 2: Snapshot current SKILL.md files**
+
+Run:
+```
+New-Item -ItemType Directory -Path "C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\snapshots" -Force | Out-Null
+```
+Then copy each `SKILL.md` into its snapshot folder.
+
+- [ ] **Step 3: Build inventory.json**
+
+Create a JSON array with entries:
+```
+{
+ "name": "skill-folder",
+ "path": "C:\\Users\\34438\\.openclaw\\skills\\skill-folder",
+ "skill_md": "...\\SKILL.md",
+ "description": ""
+}
+```
+
+- [ ] **Step 4: Commit checkpoint**
+
+Run:
+```
+git add docs/superpowers/plans/2026-03-17-skill-trigger-optimization.md
+```
+If in a repo, commit after build verification.
+
+---
+
+### Task 2: Verify skill-creator tooling availability
+
+**Files:**
+- Read: `C:\Users\34438\.openclaw\skills\skill-creator\scripts\` (if exists)
+- Read: `C:\Users\34438\.openclaw\skills\skill-creator\references\` (if exists)
+
+- [ ] **Step 1: Confirm run_loop.py and eval scripts exist**
+
+Run:
+```
+Get-ChildItem "C:\Users\34438\.openclaw\skills\skill-creator\scripts" -Filter "*.py"
+```
+Expected: `run_loop.py`, `run_eval.py`, `aggregate_benchmark.py` or similar.
+
+- [ ] **Step 2: Decide path**
+
+If scripts exist: use automated loop per skill-creator instructions.
+If scripts are missing: use manual description optimization with heuristics (see Task 4).
+
+---
+
+### Task 3: Automated description optimization loop (preferred)
+
+**Files:**
+- Modify: `C:\Users\34438\.openclaw\skills\\SKILL.md`
+- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\\trigger_eval.json`
+- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\\run_log.txt`
+
+- [ ] **Step 1: Generate trigger eval set**
+
+Create 16-20 queries (8-10 should-trigger, 8-10 should-not-trigger) per skill and save as JSON:
+```
+[
+ {"query": "...", "should_trigger": true},
+ {"query": "...", "should_trigger": false}
+]
+```
+
+- [ ] **Step 2: Run description optimization loop**
+
+Run (example):
+```
+uv run python -m scripts.run_loop --eval-set --skill-path --model openai-codex/gpt-5.2-codex --max-iterations 5 --verbose
+```
+Capture output to `run_log.txt`.
+
+- [ ] **Step 3: Apply best_description**
+
+Update the SKILL.md frontmatter `description` with `best_description`.
+
+- [ ] **Step 4: Record changes**
+
+Update `inventory.json` with new description and a `score` field if available.
+
+---
+
+### Task 4: Manual description optimization (fallback)
+
+**Files:**
+- Modify: `C:\Users\34438\.openclaw\skills\\SKILL.md`
+- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\\manual_notes.md`
+
+- [ ] **Step 1: Draft improved description**
+
+Rewrite the description with explicit trigger phrases and contexts. Ensure it is pushy and includes common user phrasings.
+
+- [ ] **Step 2: Update SKILL.md frontmatter**
+
+Replace the `description` value while preserving name and formatting.
+
+- [ ] **Step 3: Log**
+
+Write before/after into `manual_notes.md` and update `inventory.json`.
+
+---
+
+### Task 5: Validation and summary
+
+**Files:**
+- Create: `C:\Users\34438\.openclaw\workspace\skill-trigger-optimization\summary.md`
+
+- [ ] **Step 1: Validate frontmatter**
+
+Verify each SKILL.md starts with YAML frontmatter containing `name` and `description`.
+
+- [ ] **Step 2: Build summary**
+
+Write a summary with counts of skills updated and any failures.
+
+- [ ] **Step 3: Final build check (if required by repo policy)**
+
+Run:
+```
+npm run build
+```
+Expected: success.
+
+- [ ] **Step 4: Commit**
+
+Commit all changes after build success.
+
+---
+
+## Notes
+- Use PowerShell only.
+- Use `uv run python` for Python scripts.
+- No emoji in outputs or docs.
+- Do not overwrite SKILL.md structure beyond frontmatter description.
diff --git a/docs/superpowers/plans/2026-03-17-skillhub-env-fix.md b/docs/superpowers/plans/2026-03-17-skillhub-env-fix.md
new file mode 100644
index 00000000..f069849b
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-17-skillhub-env-fix.md
@@ -0,0 +1,102 @@
+# SkillHub 动态探测与系统环境变量继承 Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 让 ClawPanel 通过动态探测识别 SkillHub CLI,并让所有 Tauri 命令继承完整系统环境变量(用户 + 系统)。
+
+**Architecture:** 在 Tauri 后端新增统一“系统环境构建函数”,所有命令执行时统一注入 envs;SkillHub 检测失败时走 where 探测并返回命中路径。
+
+**Tech Stack:** Rust (Tauri), Windows Registry, tokio::process::Command
+
+---
+
+## Chunk 1: 环境变量合并工具函数
+
+### Task 1: 新增系统环境合并函数
+**Files:**
+- Modify: `src-tauri/src/utils.rs`
+- Modify: `src-tauri/src/commands/mod.rs`(如已有 enhanced_path 需更新)
+- Test: (无自动测试,手工验证)
+
+- [ ] **Step 1: 设计合并逻辑并落地函数**
+
+新增 `build_system_env()`,返回 `Vec<(String, String)>`,包含:
+- 当前进程 env
+- 用户 env(HKCU\Environment)
+- 系统 env(HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment)
+
+PATH 处理:系统 + 用户 + 进程,去重拼接。
+
+示例(伪代码):
+```rust
+pub fn build_system_env() -> Vec<(String, String)> {
+ let mut env_map = HashMap::new();
+ // 1) 读取系统 + 用户 env 并写入
+ // 2) 读取进程 env 覆盖
+ // 3) PATH 合并去重
+ env_map.into_iter().collect()
+}
+```
+
+- [ ] **Step 2: 在 commands 统一使用 build_system_env**
+
+将 `enhanced_path()` 替换或改为使用 `build_system_env()`,确保所有命令执行时统一 `cmd.envs(build_system_env())`。
+
+- [ ] **Step 3: 提交**
+```bash
+git add src-tauri/src/utils.rs src-tauri/src/commands/mod.rs
+git commit -m "feat: inherit full system env for commands"
+```
+
+---
+
+## Chunk 2: SkillHub 动态探测与返回路径
+
+### Task 2: SkillHub 检测增强
+**Files:**
+- Modify: `src-tauri/src/commands/skills.rs`
+- Modify: `src/lib/tauri-api.js`(若返回字段变化)
+- Modify: `src/pages/skills.js`
+
+- [ ] **Step 1: 更新 skills_skillhub_check**
+
+流程:
+1) `skillhub --version`
+2) 若失败,执行 `where skillhub`
+3) 取第一条路径,执行 ` --version`
+4) 返回 `{ installed: true, version, path }`
+
+- [ ] **Step 2: 更新前端展示**
+
+Skills 页面展示 path(若存在):
+- `#skillhub-status` 增加 “路径: xxx”
+- 仍显示版本号
+
+- [ ] **Step 3: 提交**
+```bash
+git add src-tauri/src/commands/skills.rs src/lib/tauri-api.js src/pages/skills.js
+git commit -m "feat: detect skillhub by path and show location"
+```
+
+---
+
+## Chunk 3: 手工验证与构建
+
+### Task 3: 验证与构建
+**Files:**
+- 无
+
+- [ ] **Step 1: 前端构建**
+```bash
+npm run build
+```
+
+- [ ] **Step 2: 手工验证要点**
+- SkillHub CLI 安装后,未重启 ClawPanel 仍能识别 installed=true
+- UI 能显示 version + path
+- 晴辰助手执行命令时继承系统变量(如 PATH / HTTP_PROXY)
+
+- [ ] **Step 3: 代码汇总与推送**
+```bash
+git push
+```
diff --git a/docs/superpowers/plans/2026-03-17-toast-night-and-model-select.md b/docs/superpowers/plans/2026-03-17-toast-night-and-model-select.md
new file mode 100644
index 00000000..9c1c4519
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-17-toast-night-and-model-select.md
@@ -0,0 +1,99 @@
+# Toast Night Style + Model Select Width Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Improve toast visibility in dark mode and make chat-model-select auto-size without truncation.
+
+**Architecture:** Update CSS rules in `components.css` for toast dark mode and in `chat.css` for model select width.
+
+**Tech Stack:** CSS, Vite build
+
+---
+
+## Chunk 1: Toast dark mode visibility
+
+### Task 1: Add dark theme toast background
+
+**Files:**
+- Modify: `src/style/components.css`
+
+- [ ] **Step 0: Checkpoint(PowerShell)**
+
+```powershell
+git status -sb
+git commit --allow-empty -m "chore: checkpoint before toast dark style"
+```
+
+Note: This checkpoint is mandatory by policy before any modification. The final functional commit still occurs after build to honor Build First, Commit Later for real changes.
+
+- [ ] **Step 1: Add dark theme override**
+
+```css
+[data-theme="dark"] .toast {
+ background: var(--bg-secondary);
+}
+```
+
+- [ ] **Step 2: Build**
+
+Run: `npm run build`
+Expected: Build succeeds without errors.
+
+- [ ] **Step 3: Commit(PowerShell)**
+
+```powershell
+git add src/style/components.css
+git commit -m "fix: improve toast dark mode"
+```
+
+- [ ] **Step 4: Push(PowerShell)**
+
+```powershell
+git push
+```
+
+## Chunk 2: Model select auto width
+
+### Task 2: Remove truncation and allow auto width
+
+**Files:**
+- Modify: `src/style/chat.css`
+
+- [ ] **Step 0: Checkpoint(PowerShell)**
+
+```powershell
+git status -sb
+git commit --allow-empty -m "chore: checkpoint before model select width"
+```
+
+- [ ] **Step 1: Update model select styles**
+
+Set the model select to auto width and remove truncation. Example:
+
+```css
+.chat-model-select {
+ width: auto;
+ max-width: none;
+ white-space: nowrap;
+}
+```
+
+Adjust if selectors differ in current file.
+
+- [ ] **Step 2: Build**
+
+Run: `npm run build`
+Expected: Build succeeds without errors.
+
+- [ ] **Step 3: Commit(PowerShell)**
+
+```powershell
+git add src/style/chat.css
+git commit -m "fix: auto width for chat model select"
+```
+
+- [ ] **Step 4: Push(PowerShell)**
+
+```powershell
+git push
+```
diff --git a/docs/superpowers/plans/2026-03-17-toast-shadow.md b/docs/superpowers/plans/2026-03-17-toast-shadow.md
new file mode 100644
index 00000000..beb3bea8
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-17-toast-shadow.md
@@ -0,0 +1,54 @@
+# Toast Shadow Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a medium-strength shadow to toast cards while keeping the Vercel-style solid background.
+
+**Architecture:** Modify `.toast` rule in `components.css` to add `box-shadow` only.
+
+**Tech Stack:** CSS, Vite build
+
+---
+
+## Chunk 1: Toast shadow
+
+### Task 1: Add toast shadow
+
+**Files:**
+- Modify: `src/style/components.css`
+
+- [ ] **Step 0: Checkpoint(PowerShell)**
+
+```powershell
+git status -sb
+git commit --allow-empty -m "chore: checkpoint before toast shadow"
+```
+
+Note: This checkpoint is required by policy to protect rollbacks. The final functional commit still happens after `npm run build`.
+
+- [ ] **Step 1: Add box-shadow**
+
+```css
+.toast {
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
+}
+```
+
+- [ ] **Step 2: Build**
+
+Run: `npm run build`
+Expected: Build succeeds without errors.
+Note: This is the frontend Vite build; no `wails build` required for this CSS-only change.
+
+- [ ] **Step 3: Commit(PowerShell)**
+
+```powershell
+git add src/style/components.css
+git commit -m "fix: add toast shadow"
+```
+
+- [ ] **Step 4: Push(PowerShell)**
+
+```powershell
+git push
+```
diff --git a/docs/superpowers/plans/2026-03-17-toast-vercel.md b/docs/superpowers/plans/2026-03-17-toast-vercel.md
new file mode 100644
index 00000000..ed92e2c7
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-17-toast-vercel.md
@@ -0,0 +1,54 @@
+# Toast Vercel Style Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace glass/blur toast with solid Vercel-style card that adapts to light/dark themes.
+
+**Architecture:** Update toast styles in `components.css` to remove blur, use theme variables for background and border, keep status text colors.
+
+**Tech Stack:** CSS, Vite build
+
+---
+
+## Chunk 1: Toast style update
+
+### Task 1: Update toast base style
+
+**Files:**
+- Modify: `src/style/components.css`
+
+- [ ] **Step 0: Checkpoint**
+
+```bash
+git status -sb
+git commit --allow-empty -m "chore: checkpoint before toast style update"
+```
+
+- [ ] **Step 1: Remove blur and set base card styles**
+
+Update `.toast` rule:
+- remove `backdrop-filter`
+- add `background: var(--bg-primary);`
+- add `border: 1px solid var(--border);`
+
+- [ ] **Step 2: Simplify status variants**
+
+Update `.toast.success/.error/.info/.warning` to only set `color`, removing background and border overrides.
+
+- [ ] **Step 3: Build**
+
+Run: `npm run build`
+Expected: Build succeeds without errors.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/style/components.css
+git commit -m "fix: vercel-style toast card"
+```
+
+- [ ] **Step 5: Push**
+
+```bash
+git push
+```
diff --git a/docs/superpowers/plans/2026-03-17-tool-call-meta.md b/docs/superpowers/plans/2026-03-17-tool-call-meta.md
new file mode 100644
index 00000000..ac94c8db
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-17-tool-call-meta.md
@@ -0,0 +1,108 @@
+# Tool Call Meta Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Show tool call time in the tool card header and ensure expanded sections display input/output placeholders when data is empty.
+
+**Architecture:** Update tool rendering in `src/pages/chat.js` to compute display time and placeholders; optional minor CSS tweaks if needed.
+
+**Tech Stack:** Vanilla JS, CSS, Vite build
+
+---
+
+## Chunk 1: Tool header time + placeholders
+
+### Task 1: Add tool time display
+
+**Files:**
+- Modify: `src/pages/chat.js`
+
+- [ ] **Step 0: Checkpoint(PowerShell)**
+
+```powershell
+git status -sb
+git commit --allow-empty -m "chore: checkpoint before tool meta"
+```
+
+Note: This checkpoint is required by policy; final functional commit occurs after build.
+
+- [ ] **Step 1: Add helper to get tool time**
+
+Add a function near other helpers in `src/pages/chat.js` (below `stripThinkingTags`):
+
+```js
+function getToolTime(tool) {
+ const raw = tool?.end_time || tool?.endTime || tool?.timestamp || tool?.time || tool?.started_at || tool?.startedAt || null
+ if (!raw) return null
+ if (typeof raw === 'number' && raw < 1e12) return raw * 1000
+ return raw
+}
+```
+
+Note: `formatTime` and `escapeHtml` already exist in `chat.js`.
+
+- [ ] **Step 1.5: Capture tool event timestamps**
+
+Add a map at top-level in `src/pages/chat.js`:
+
+```js
+const _toolEventTimes = new Map()
+```
+
+In `handleEvent`, before `handleChatEvent`, capture tool events:
+
+```js
+if (event === 'agent' && payload?.stream === 'tool' && payload?.data?.toolCallId) {
+ const ts = payload.ts
+ if (ts) _toolEventTimes.set(payload.data.toolCallId, ts)
+}
+```
+
+In `collectToolsFromMessage`, when constructing tool entries, set `time` when absent:
+
+```js
+const callId = call.id || call.tool_call_id
+const fallbackTime = callId ? _toolEventTimes.get(callId) : null
+... time: call.time || fallbackTime ...
+```
+
+- [ ] **Step 2: Render header with time**
+
+In `appendToolsToEl`, reuse the existing `summary` node created there and update header:
+
+```js
+const time = getToolTime(tool)
+const timeText = time ? formatTime(new Date(time)) : '时间未知'
+summary.innerHTML = `${escapeHtml(tool.name || '工具')} · ${status} · ${timeText}`
+```
+
+- [ ] **Step 3: Add placeholders**
+
+Use the exact block structure already used in tool body:
+
+```js
+const input = inputJson
+ ? ``
+ : ``
+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
+```
From 0f2da2f1f2da056c408e61cbf55d88dc3693f11f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Tue, 17 Mar 2026 13:18:11 +0800
Subject: [PATCH 195/426] chore: ignore plan/pr docs
---
.gitignore | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
index 26c5bb30..4b715ebc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -52,4 +52,9 @@ docs/promo-video.mp4
# Rust 开发工具
src-tauri/.cargo/
-.codex/
\ No newline at end of file
+.codex/docs/plans/
+docs/superpowers/plans/
+docs/*-plan.md
+docs/plan/
+docs/pr/
+docs/plans/
\ No newline at end of file
From 6098acb68c664a05ff52bb0c93c75e9b710d99a0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Tue, 17 Mar 2026 13:25:50 +0800
Subject: [PATCH 196/426] chore: checkpoint
From d359261d166a6421361f192ad7eb23888ce61bb2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Tue, 17 Mar 2026 13:26:25 +0800
Subject: [PATCH 197/426] chore: checkpoint before hosted agent chat
From 2ddb90bfec29953cb6ccd24c7da92c16350be4be Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Tue, 17 Mar 2026 13:26:51 +0800
Subject: [PATCH 198/426] chore: checkpoint assistant-core
From 32759575ec8b5d1ac35c4a8d38b4baf37b8e1dda Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Tue, 17 Mar 2026 13:36:36 +0800
Subject: [PATCH 199/426] feat: add assistant core module
---
src/lib/assistant-core.js | 1093 +++++++++++++++++++++++++++++++++++++
1 file changed, 1093 insertions(+)
create mode 100644 src/lib/assistant-core.js
diff --git a/src/lib/assistant-core.js b/src/lib/assistant-core.js
new file mode 100644
index 00000000..e719abe9
--- /dev/null
+++ b/src/lib/assistant-core.js
@@ -0,0 +1,1093 @@
+/**
+ * assistant-core.js
+ * 模型调用与工具执行核心逻辑(无 UI 依赖)
+ * 提供 buildSystemPrompt / getEnabledTools / callAIWithTools / callAI / trimContext
+ */
+
+// ── 常量 ──
+const DEFAULT_NAME = '晴辰助手'
+const DEFAULT_PERSONALITY = '专业、友善、简洁。善于分析问题,给出可操作的解决方案。'
+const DEFAULT_MODE = 'execute'
+
+const MODES = {
+ chat: { label: '聊天', desc: '纯对话,不调用任何工具', tools: false, readOnly: false, confirmDanger: true },
+ plan: { label: '规划', desc: '可调用工具分析,但不修改文件', tools: true, readOnly: true, confirmDanger: true },
+ execute: { label: '执行', desc: '完整工具权限,危险操作需确认', tools: true, readOnly: false, confirmDanger: true },
+ unlimited:{ label: '无限', desc: '最大权限,工具调用无需确认', tools: true, readOnly: false, confirmDanger: false },
+}
+
+// ── 工具定义(OpenAI function calling 格式)──
+const TOOL_DEFS = {
+ terminal: [
+ {
+ type: 'function',
+ function: {
+ name: 'run_command',
+ description: '在本机终端执行 shell 命令。用于系统管理、服务操作、文件查看等。注意:命令会直接在用户的机器上执行,请谨慎使用。',
+ parameters: {
+ type: 'object',
+ properties: {
+ command: { type: 'string', description: '要执行的 shell 命令' },
+ cwd: { type: 'string', description: '工作目录(可选,默认为用户主目录)' },
+ },
+ required: ['command'],
+ },
+ },
+ },
+ ],
+ system: [
+ {
+ type: 'function',
+ function: {
+ name: 'get_system_info',
+ description: '获取当前系统信息,包括操作系统类型(windows/macos/linux)、CPU 架构、用户主目录、主机名、默认 Shell。在执行任何命令前应先调用此工具来判断操作系统,以选择正确的命令语法。',
+ parameters: { type: 'object', properties: {}, required: [] },
+ },
+ },
+ ],
+ process: [
+ {
+ type: 'function',
+ function: {
+ name: 'list_processes',
+ description: '列出当前运行中的进程。可以按名称过滤,用于检查某个服务是否在运行(如 node、openclaw、gateway)。',
+ parameters: {
+ type: 'object',
+ properties: {
+ filter: { type: 'string', description: '过滤关键词(可选),只返回包含该关键词的进程' },
+ },
+ required: [],
+ },
+ },
+ },
+ {
+ type: 'function',
+ function: {
+ name: 'check_port',
+ description: '检测指定端口是否被占用,并返回占用该端口的进程信息。常用端口:Gateway 18789、WebSocket 18790。',
+ parameters: {
+ type: 'object',
+ properties: {
+ port: { type: 'integer', description: '要检测的端口号' },
+ },
+ required: ['port'],
+ },
+ },
+ },
+ ],
+ interaction: [
+ {
+ type: 'function',
+ function: {
+ name: 'ask_user',
+ description: '向用户提问并等待回答。支持单选、多选和自由输入。当你需要用户做决定、确认方案、选择选项时使用此工具。用户可以选择预设选项,也可以输入自定义内容。',
+ parameters: {
+ type: 'object',
+ properties: {
+ question: { type: 'string', description: '要问用户的问题' },
+ type: { type: 'string', enum: ['single', 'multiple', 'text'], description: '交互类型:single=单选, multiple=多选, text=自由输入' },
+ options: {
+ type: 'array',
+ items: { type: 'string' },
+ description: '预设选项列表(single/multiple 时必填,text 时可选作为建议)',
+ },
+ placeholder: { type: 'string', description: '自由输入时的占位提示文字(可选)' },
+ },
+ required: ['question', 'type'],
+ },
+ },
+ },
+ ],
+ webSearch: [
+ {
+ type: 'function',
+ function: {
+ name: 'web_search',
+ description: '联网搜索关键词,返回搜索结果列表(标题、链接、摘要)。用于查找错误解决方案、最新文档、GitHub Issues 等。',
+ parameters: {
+ type: 'object',
+ properties: {
+ query: { type: 'string', description: '搜索关键词' },
+ max_results: { type: 'integer', description: '最大结果数(默认 5)' },
+ },
+ required: ['query'],
+ },
+ },
+ },
+ {
+ type: 'function',
+ function: {
+ name: 'fetch_url',
+ description: '抓取指定 URL 的网页内容,返回纯文本/Markdown 格式。用于获取搜索结果中某个页面的详细内容。',
+ parameters: {
+ type: 'object',
+ properties: {
+ url: { type: 'string', description: '要抓取的网页 URL' },
+ },
+ required: ['url'],
+ },
+ },
+ },
+ ],
+ skills: [
+ {
+ type: 'function',
+ function: {
+ name: 'skills_list',
+ description: '列出所有 OpenClaw Skills 及其状态(可用/缺依赖/已禁用)。返回每个 Skill 的名称、描述、来源、依赖状态、缺少的依赖项、可用的安装选项等信息。',
+ parameters: { type: 'object', properties: {}, required: [] },
+ },
+ },
+ {
+ type: 'function',
+ function: {
+ name: 'skills_info',
+ description: '查看指定 Skill 的详细信息,包括描述、来源、依赖要求、缺少的依赖、安装选项等。',
+ parameters: {
+ type: 'object',
+ properties: {
+ name: { type: 'string', description: 'Skill 名称,如 github、weather、coding-agent' },
+ },
+ required: ['name'],
+ },
+ },
+ },
+ {
+ type: 'function',
+ function: {
+ name: 'skills_check',
+ description: '检查所有 Skills 的依赖状态,返回哪些可用、哪些缺少依赖、哪些已禁用的汇总信息。',
+ parameters: { type: 'object', properties: {}, required: [] },
+ },
+ },
+ {
+ type: 'function',
+ function: {
+ name: 'skills_install_dep',
+ description: '安装 Skill 缺少的依赖。根据 Skill 的 install spec 执行对应的包管理器命令(brew/npm/go/uv)。安装完成后会自动生效。',
+ parameters: {
+ type: 'object',
+ properties: {
+ kind: { type: 'string', enum: ['brew', 'node', 'go', 'uv'], description: '安装类型' },
+ spec: {
+ type: 'object',
+ description: '安装参数。brew 需要 formula,node 需要 package,go 需要 module,uv 需要 package。',
+ properties: {
+ formula: { type: 'string', description: 'Homebrew formula 名称' },
+ package: { type: 'string', description: 'npm 或 uv 包名' },
+ module: { type: 'string', description: 'Go module 路径' },
+ },
+ },
+ },
+ required: ['kind', 'spec'],
+ },
+ },
+ },
+ {
+ type: 'function',
+ function: {
+ name: 'skills_clawhub_search',
+ description: '在 ClawHub 社区市场中搜索 Skills。返回匹配的 Skill 列表(slug 和描述)。',
+ parameters: {
+ type: 'object',
+ properties: {
+ query: { type: 'string', description: '搜索关键词' },
+ },
+ required: ['query'],
+ },
+ },
+ },
+ {
+ type: 'function',
+ function: {
+ name: 'skills_clawhub_install',
+ description: '从 ClawHub 社区市场安装一个 Skill 到本地 ~/.openclaw/skills/ 目录。',
+ parameters: {
+ type: 'object',
+ properties: {
+ slug: { type: 'string', description: 'ClawHub 上的 Skill slug(名称标识)' },
+ },
+ required: ['slug'],
+ },
+ },
+ },
+ ],
+ fileOps: [
+ {
+ type: 'function',
+ function: {
+ name: 'read_file',
+ description: '读取指定路径的文件内容。用于查看配置文件、日志文件等。',
+ parameters: {
+ type: 'object',
+ properties: {
+ path: { type: 'string', description: '文件的完整路径' },
+ },
+ required: ['path'],
+ },
+ },
+ },
+ {
+ type: 'function',
+ function: {
+ name: 'write_file',
+ description: '写入或创建文件。会自动创建父目录。注意:会覆盖已有内容。',
+ parameters: {
+ type: 'object',
+ properties: {
+ path: { type: 'string', description: '文件的完整路径' },
+ content: { type: 'string', description: '要写入的内容' },
+ },
+ required: ['path', 'content'],
+ },
+ },
+ },
+ {
+ type: 'function',
+ function: {
+ name: 'list_directory',
+ description: '列出目录下的文件和子目录。',
+ parameters: {
+ type: 'object',
+ properties: {
+ path: { type: 'string', description: '目录路径' },
+ },
+ required: ['path'],
+ },
+ },
+ },
+ ],
+}
+
+const INTERACTIVE_TOOLS = new Set(['ask_user'])
+const DANGEROUS_TOOLS = new Set(['run_command', 'write_file', 'skills_install_dep', 'skills_clawhub_install'])
+const CRITICAL_PATTERNS = [
+ /rm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?[\/~]/i,
+ /rm\s+-[a-zA-Z]*r[a-zA-Z]*\s+\//i,
+ /format\s+[a-zA-Z]:/i,
+ /mkfs\./i,
+ /dd\s+.*of=\/dev\//i,
+ />\s*\/dev\/[sh]d/i,
+ /DROP\s+(DATABASE|TABLE|SCHEMA)/i,
+ /TRUNCATE\s+TABLE/i,
+ /DELETE\s+FROM\s+\w+\s*;?\s*$/i,
+ /:\(\){ :\|:& };/,
+ /shutdown|reboot|init\s+[06]/i,
+ /chmod\s+(-R\s+)?777\s+\//i,
+ /chown\s+(-R\s+)?.*\s+\//i,
+ /curl\s+.*\|\s*(sudo\s+)?bash/i,
+ /wget\s+.*\|\s*(sudo\s+)?bash/i,
+ /npm\s+publish/i,
+ /git\s+push\s+.*--force/i,
+]
+
+// 内置 Skills(用于系统提示词)
+const BUILTIN_SKILLS = [
+ { id: 'check-config', name: '检查 OpenClaw 配置', desc: '读取并分析 openclaw.json,检查配置是否正确' },
+ { id: 'diagnose-gateway', name: '诊断 Gateway', desc: '检查 Gateway 运行状态、端口、日志' },
+ { id: 'browse-dir', name: '浏览配置目录', desc: '查看 .openclaw 目录结构和文件' },
+ { id: 'check-env', name: '检查系统环境', desc: '检测 Node.js、npm 版本和系统信息' },
+]
+
+const STOP_PATTERNS = [/\<\s*stop\s*\>/ig]
+
+function normalizeApiType(raw) {
+ const type = (raw || '').trim()
+ if (type === 'anthropic' || type === 'anthropic-messages') return 'anthropic-messages'
+ if (type === 'google-gemini') return 'google-gemini'
+ if (type === 'openai' || type === 'openai-completions' || type === 'openai-responses') return 'openai-completions'
+ return 'openai-completions'
+}
+
+function requiresApiKey(apiType) {
+ const type = normalizeApiType(apiType)
+ return type === 'anthropic-messages' || type === 'google-gemini'
+}
+
+function apiHintText(apiType) {
+ return {
+ 'openai-completions': '自动兼容 Chat Completions 和 Responses API;Ollama 可留空 API Key',
+ 'anthropic-messages': '使用 Anthropic Messages API(/v1/messages)',
+ 'google-gemini': '使用 Gemini generateContent API',
+ }[normalizeApiType(apiType)] || '自动兼容 Chat Completions 和 Responses API;Ollama 可留空 API Key'
+}
+
+function apiBasePlaceholder(apiType) {
+ return {
+ 'openai-completions': 'https://api.openai.com/v1 或 http://127.0.0.1:11434',
+ 'anthropic-messages': 'https://api.anthropic.com',
+ 'google-gemini': 'https://generativelanguage.googleapis.com/v1beta',
+ }[normalizeApiType(apiType)] || 'https://api.openai.com/v1'
+}
+
+function apiKeyPlaceholder(apiType) {
+ return {
+ 'openai-completions': 'sk-...(Ollama 可留空)',
+ 'anthropic-messages': 'sk-ant-...',
+ 'google-gemini': 'AIza...',
+ }[normalizeApiType(apiType)] || 'sk-...'
+}
+
+function getSystemPromptBase(config) {
+ const name = config?.assistantName || DEFAULT_NAME
+ const personality = config?.assistantPersonality || DEFAULT_PERSONALITY
+ return `你是「${name}」,ClawPanel 内置的 AI 智能助手。
+
+## 你的性格
+${personality}
+
+## 你是谁
+- 你是 ClawPanel 内置的智能助手
+- 你帮助用户管理和排障 OpenClaw AI Agent 平台
+- 你精通 OpenClaw 的架构、配置、Gateway、Agent 管理等所有方面
+- 你善于分析日志、诊断错误、提供解决方案
+
+## 相关资源
+- ClawPanel 官网: https://claw.qt.cool
+- GitHub: https://github.com/qingchencloud
+- 开源项目:
+ - ClawPanel — OpenClaw 可视化管理面板(Tauri v2)
+ - OpenClaw 汉化版 — AI Agent 平台中文版,npm install -g @qingchencloud/openclaw-zh
+
+## ClawPanel 是什么
+- OpenClaw 的可视化管理面板,基于 Tauri v2 的跨平台桌面应用(Windows/macOS/Linux)
+- 支持仪表盘监控、模型配置、Agent 管理、实时聊天、记忆文件管理、AI 助手工具调用等
+- 官网: https://claw.qt.cool | GitHub: https://github.com/qingchencloud/clawpanel
+
+## OpenClaw 是什么
+- 开源的 AI Agent 平台,支持多模型、多 Agent、MCP 工具调用
+- 核心组件: Gateway(API 网关)、Agent(AI 代理)、Tools(工具系统)
+- 配置文件: ~/.openclaw/openclaw.json(全局配置)
+- 安装方式: npm install -g @qingchencloud/openclaw-zh(汉化版,推荐)或 npm install -g openclaw(官方英文版)`
+}
+
+function currentMode(config, modeOverride) {
+ const modeKey = modeOverride || config?.mode
+ return MODES[modeKey] ? modeKey : DEFAULT_MODE
+}
+
+function buildSystemPrompt({ config, soulCache, knowledgeBase }) {
+ const cfg = config || {}
+ const kbText = typeof knowledgeBase === 'string' ? knowledgeBase : ''
+ let prompt = ''
+
+ if (cfg?.soulSource?.startsWith('openclaw:') && soulCache) {
+ prompt += '# 你的身份\n'
+ if (soulCache.identity) prompt += soulCache.identity + '\n\n'
+ if (soulCache.soul) prompt += '# 灵魂\n' + soulCache.soul + '\n\n'
+ if (soulCache.user) prompt += '# 你的用户\n' + soulCache.user + '\n\n'
+ if (soulCache.agents) {
+ const agentsContent = soulCache.agents.length > 4000 ? soulCache.agents.slice(0, 4000) + '\n\n[...已截断]' : soulCache.agents
+ prompt += '# 操作规则\n' + agentsContent + '\n\n'
+ }
+ if (soulCache.tools) prompt += '# 工具笔记\n' + soulCache.tools + '\n\n'
+ if (soulCache.memory) {
+ const memContent = soulCache.memory.length > 3000 ? soulCache.memory.slice(-3000) : soulCache.memory
+ prompt += '# 长期记忆\n' + memContent + '\n\n'
+ }
+ if (soulCache.recentMemories?.length) {
+ prompt += '# 最近记忆\n'
+ for (const m of soulCache.recentMemories) {
+ const content = m.content.length > 800 ? m.content.slice(0, 800) + '...' : m.content
+ prompt += `## ${m.date}\n${content}\n\n`
+ }
+ }
+ prompt += '\n# ClawPanel 工具能力\n你同时是 ClawPanel 内置助手,拥有以下额外能力:\n'
+ prompt += '- 执行终端命令、读写文件、浏览目录\n'
+ prompt += '- 联网搜索和网页抓取\n'
+ prompt += '- 管理 OpenClaw 配置和服务\n'
+ prompt += '- 你精通 OpenClaw 的架构、配置、Gateway、Agent 管理\n'
+ } else {
+ prompt += getSystemPromptBase(cfg)
+ }
+
+ const modeKey = currentMode(cfg)
+ const mode = MODES[modeKey]
+
+ prompt += `\n\n## 当前模式:${mode.label}模式`
+
+ if (modeKey === 'chat') {
+ prompt += '\n你处于纯聊天模式,没有任何工具可用。请通过文字回答问题,给出具体的命令建议供用户手动执行。'
+ prompt += '\n如果用户需要你执行操作,建议用户切换到「执行」或「规划」模式。'
+ } else {
+ if (modeKey === 'plan') {
+ prompt += '\n你处于规划模式:可以调用工具读取信息、分析问题,但绝对不能修改任何文件(write_file 已禁用)。'
+ prompt += '\n你的任务是:分析问题 -> 制定方案 -> 输出详细步骤,让用户确认后再切换到执行模式操作。'
+ prompt += '\n即使使用 run_command,也只能执行只读命令(查看、检查、列出),不要执行任何修改操作。'
+ }
+ if (modeKey === 'unlimited') {
+ prompt += '\n你处于无限模式:所有工具调用无需用户确认,请高效完成任务。'
+ }
+
+ prompt += '\n\n### 可用工具'
+ prompt += '\n- 用户交互: ask_user — 向用户提问(单选/多选/文本),获取结构化回答。需要用户做决定时优先用此工具。'
+ prompt += '\n- 系统信息: get_system_info — 获取 OS 类型、架构、主目录等。在执行任何命令前必须先调用此工具。'
+ prompt += '\n- 进程/端口: list_processes(按名称过滤)、check_port(检测端口占用)'
+ prompt += '\n- 终端: run_command — 执行 shell 命令'
+ if (mode.readOnly) {
+ prompt += '\n- 文件: read_file、list_directory(只读,write_file 已禁用)'
+ } else {
+ prompt += '\n- 文件: read_file、write_file、list_directory'
+ }
+
+ prompt += '\n\n### 终端命令规范(重要)'
+ prompt += '\n- Windows: 终端是 PowerShell,必须使用 PowerShell 语法:'
+ prompt += '\n - 列目录: Get-ChildItem'
+ prompt += '\n - 看文件: Get-Content'
+ prompt += '\n - 查进程: Get-Process | Where-Object { $_.Name -like "*openclaw*" }'
+ prompt += '\n - 查端口: Get-NetTCPConnection -LocalPort 18789'
+ prompt += '\n - 文件尾: Get-Content file.log -Tail 50'
+ prompt += '\n - 搜内容: Select-String -Path file.log -Pattern "ERROR"'
+ prompt += '\n - 环境变量: $env:USERPROFILE'
+ prompt += '\n- macOS: zsh,标准 Unix 命令'
+ prompt += '\n- Linux: bash,标准 Unix 命令'
+ prompt += '\n- 绝对禁止 cmd.exe 语法(dir、type、findstr、netstat)'
+ prompt += '\n- 一次只执行一条命令,等结果出来再决定下一步'
+ prompt += '\n- 不要重复执行相同的命令'
+
+ prompt += '\n\n### 跨平台路径'
+ prompt += '\n- Windows: $env:USERPROFILE\\.openclaw\\'
+ prompt += '\n- macOS/Linux: ~/.openclaw/'
+
+ prompt += '\n\n### 工具使用原则'
+ prompt += '\n- 先 get_system_info,再根据 OS 执行正确命令'
+ prompt += '\n- 优先用 read_file / list_directory / list_processes / check_port 等专用工具,减少 run_command 使用'
+ prompt += '\n- 主动使用工具,不要只建议用户手动操作'
+ if (mode.confirmDanger) {
+ prompt += '\n- 执行破坏性操作前先告知用户'
+ }
+ }
+
+ prompt += '\n\n## 内置技能卡片'
+ prompt += '\n用户可以在欢迎页点击技能卡片快速触发操作。当用户遇到问题时,你也可以主动推荐合适的技能:'
+ for (const s of BUILTIN_SKILLS) {
+ prompt += `\n- ${s.name}(${s.desc})`
+ }
+ prompt += '\n\n当用户的需求匹配某个技能时,可以建议用户点击对应的技能卡片,或者你直接按技能的步骤操作。'
+
+ if (kbText) {
+ prompt += '\n\n' + kbText
+ }
+
+ const kbEnabled = (cfg.knowledgeFiles || []).filter(f => f.enabled !== false && f.content)
+ if (kbEnabled.length > 0) {
+ prompt += '\n\n## 用户自定义知识库'
+ prompt += '\n以下是用户提供的参考知识,回答问题时请优先参考这些内容:'
+ for (const kb of kbEnabled) {
+ const content = kb.content.length > 5000 ? kb.content.slice(0, 5000) + '\n\n[...内容已截断]' : kb.content
+ prompt += `\n\n### ${kb.name}\n${content}`
+ }
+ }
+
+ return prompt
+}
+
+function getEnabledTools({ config, mode } = {}) {
+ const cfg = config || {}
+ const modeKey = currentMode(cfg, mode)
+ const modeCfg = MODES[modeKey]
+ if (!modeCfg.tools) return []
+
+ const t = cfg.tools || {}
+ const tools = [...TOOL_DEFS.system, ...TOOL_DEFS.process, ...TOOL_DEFS.interaction]
+
+ if (t.terminal !== false) tools.push(...TOOL_DEFS.terminal)
+ if (t.webSearch !== false) tools.push(...TOOL_DEFS.webSearch)
+
+ if (t.fileOps !== false) {
+ if (modeCfg.readOnly) {
+ tools.push(...TOOL_DEFS.fileOps.filter(td => td.function.name !== 'write_file'))
+ } else {
+ tools.push(...TOOL_DEFS.fileOps)
+ }
+ }
+
+ if (modeCfg.readOnly) {
+ tools.push(...TOOL_DEFS.skills.filter(td => !['skills_install_dep', 'skills_clawhub_install'].includes(td.function.name)))
+ } else {
+ tools.push(...TOOL_DEFS.skills)
+ }
+
+ return tools
+}
+
+function trimContext(messages, maxTokens) {
+ if (!Array.isArray(messages)) return []
+ if (!maxTokens || maxTokens <= 0 || messages.length <= maxTokens) return messages
+
+ const first = messages[0]
+ if (first?.role === 'system' && maxTokens > 1) {
+ return [first, ...messages.slice(-(maxTokens - 1))]
+ }
+ return messages.slice(-maxTokens)
+}
+
+function cleanBaseUrl(raw, apiType) {
+ let base = (raw || '').replace(/\/+$/, '')
+ base = base.replace(/\/api\/chat\/?$/, '')
+ base = base.replace(/\/api\/generate\/?$/, '')
+ base = base.replace(/\/api\/tags\/?$/, '')
+ base = base.replace(/\/api\/?$/, '')
+ base = base.replace(/\/chat\/completions\/?$/, '')
+ base = base.replace(/\/completions\/?$/, '')
+ base = base.replace(/\/responses\/?$/, '')
+ base = base.replace(/\/messages\/?$/, '')
+ base = base.replace(/\/models\/?$/, '')
+ const type = normalizeApiType(apiType)
+ if (type === 'anthropic-messages') {
+ if (!base.endsWith('/v1')) base += '/v1'
+ return base
+ }
+ if (type === 'google-gemini') {
+ return base
+ }
+ if (/:(11434)$/i.test(base) && !base.endsWith('/v1')) return `${base}/v1`
+ return base
+}
+
+function authHeaders(apiType, apiKey) {
+ const type = normalizeApiType(apiType)
+ const key = apiKey || ''
+ if (type === 'anthropic-messages') {
+ const headers = {
+ 'Content-Type': 'application/json',
+ 'anthropic-version': '2023-06-01',
+ }
+ if (key) headers['x-api-key'] = key
+ return headers
+ }
+ const headers = { 'Content-Type': 'application/json' }
+ if (key) headers['Authorization'] = `Bearer ${key}`
+ return headers
+}
+
+const TIMEOUT_TOTAL = 120000
+const TIMEOUT_CHUNK = 30000
+
+async function fetchWithRetry(url, options, retries = 3) {
+ const delays = [1000, 3000, 8000]
+ for (let i = 0; i <= retries; i++) {
+ try {
+ const resp = await fetch(url, options)
+ if (resp.ok || resp.status < 500 || i >= retries) return resp
+ await new Promise(r => setTimeout(r, delays[i]))
+ } catch (err) {
+ if (err.name === 'AbortError') throw err
+ if (i >= retries) throw err
+ await new Promise(r => setTimeout(r, delays[i]))
+ }
+ }
+}
+
+async function readSSEStream(resp, onEvent, signal) {
+ const reader = resp.body.getReader()
+ const decoder = new TextDecoder()
+ let buffer = ''
+
+ const onAbort = () => { try { reader.cancel() } catch {} }
+ if (signal) {
+ if (signal.aborted) { reader.cancel(); throw new DOMException('Aborted', 'AbortError') }
+ signal.addEventListener('abort', onAbort, { once: true })
+ }
+
+ try {
+ while (true) {
+ if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
+ const readPromise = reader.read()
+ const timeoutPromise = new Promise((_, reject) =>
+ setTimeout(() => reject(new Error('流式响应超时:30 秒内未收到数据')), TIMEOUT_CHUNK)
+ )
+ const { done, value } = await Promise.race([readPromise, timeoutPromise])
+ if (done) {
+ if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
+ break
+ }
+
+ buffer += decoder.decode(value, { stream: true })
+ const lines = buffer.split('\n')
+ buffer = lines.pop() || ''
+
+ for (const line of lines) {
+ if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
+ const trimmed = line.trim()
+ if (!trimmed) continue
+ if (trimmed.startsWith('event:')) continue
+ if (!trimmed.startsWith('data:')) continue
+ const data = trimmed.slice(5).trim()
+ if (data === '[DONE]') return
+ try { onEvent(JSON.parse(data)) } catch {}
+ }
+ }
+ } finally {
+ signal?.removeEventListener('abort', onAbort)
+ }
+}
+
+function convertToolsForAnthropic(tools) {
+ return tools.map(t => ({
+ name: t.function.name,
+ description: t.function.description || '',
+ input_schema: t.function.parameters || { type: 'object', properties: {} },
+ }))
+}
+
+function convertToolsForGemini(tools) {
+ return [{ functionDeclarations: tools.map(t => ({
+ name: t.function.name,
+ description: t.function.description || '',
+ parameters: t.function.parameters || { type: 'object', properties: {} },
+ })) }]
+}
+
+function isCriticalCommand(command) {
+ if (!command) return false
+ return CRITICAL_PATTERNS.some(p => p.test(command))
+}
+
+function formatToolDescription(name, args) {
+ if (name === 'run_command') {
+ return `执行命令:\n\n${args.command}${args.cwd ? '\n\n工作目录: ' + args.cwd : ''}`
+ }
+ if (name === 'write_file') {
+ const preview = (args.content || '').slice(0, 200)
+ return `写入文件:\n${args.path}\n\n内容预览:\n${preview}${(args.content || '').length > 200 ? '\n...(已截断)' : ''}`
+ }
+ return `执行工具: ${name}`
+}
+
+async function confirmToolCall(toolName, args, critical, adapters) {
+ if (!adapters?.confirm) return !critical
+ const prefix = critical ? '安全围栏拦截:此命令被识别为极端危险操作。\n\n' : ''
+ const text = `${prefix}AI 请求执行以下操作:\n\n${formatToolDescription(toolName, args)}\n\n是否允许?`
+ return await adapters.confirm(text)
+}
+
+async function executeTool(name, args, adapters) {
+ if (name === 'ask_user' && adapters?.askUser) {
+ const answer = await adapters.askUser(args)
+ if (answer && typeof answer === 'object') return answer.message || ''
+ return answer || ''
+ }
+ if (!adapters?.execTool) return `未配置工具执行器: ${name}`
+ return await adapters.execTool({ name, args })
+}
+
+async function executeToolWithSafety(toolName, args, adapters, modeKey) {
+ let result = ''
+ let approved = true
+ const mode = MODES[modeKey] || MODES[DEFAULT_MODE]
+ const isCritical = toolName === 'run_command' && isCriticalCommand(args.command)
+
+ if (isCritical) {
+ approved = await confirmToolCall(toolName, args, true, adapters)
+ if (!approved) result = '用户拒绝了此危险操作'
+ } else if (mode.confirmDanger && DANGEROUS_TOOLS.has(toolName)) {
+ approved = await confirmToolCall(toolName, args, false, adapters)
+ if (!approved) result = '用户拒绝了此操作'
+ }
+
+ if (approved) {
+ try { result = await executeTool(toolName, args, adapters) }
+ catch (err) { result = `执行失败: ${typeof err === 'string' ? err : err.message || JSON.stringify(err)}` }
+ }
+ return { result, approved }
+}
+
+function applyStop(text) {
+ if (!text) return { text: '', stop: false }
+ let stop = false
+ let cleaned = text
+ for (const pattern of STOP_PATTERNS) {
+ if (pattern.test(cleaned)) {
+ stop = true
+ cleaned = cleaned.replace(pattern, '')
+ }
+ }
+ return { text: cleaned.trim(), stop }
+}
+
+async function callChatCompletions({ base, config, messages, onChunk, signal }) {
+ const url = base + '/chat/completions'
+ const body = {
+ model: config.model,
+ messages,
+ stream: true,
+ temperature: config.temperature || 0.7,
+ }
+
+ const resp = await fetchWithRetry(url, {
+ method: 'POST',
+ headers: authHeaders(config.apiType, config.apiKey),
+ body: JSON.stringify(body),
+ signal,
+ })
+
+ if (!resp.ok) {
+ const errText = await resp.text().catch(() => '')
+ let errMsg = `API 错误 ${resp.status}`
+ try { errMsg = JSON.parse(errText).error?.message || errMsg } catch {
+ if (errText) errMsg += `: ${errText.slice(0, 200)}`
+ }
+ throw new Error(errMsg)
+ }
+
+ const ct = resp.headers.get('content-type') || ''
+ if (ct.includes('text/event-stream') || ct.includes('text/plain')) {
+ await readSSEStream(resp, (json) => {
+ const d = json.choices?.[0]?.delta
+ if (d?.content) onChunk(d.content)
+ else if (d?.reasoning_content) onChunk(d.reasoning_content)
+ }, signal)
+ } else {
+ const json = await resp.json()
+ const msg = json.choices?.[0]?.message
+ const content = msg?.content || msg?.reasoning_content || ''
+ if (content) onChunk(content)
+ }
+}
+
+async function callResponsesAPI({ base, config, messages, onChunk, signal }) {
+ const url = base + '/responses'
+ const input = messages.filter(m => m.role !== 'system')
+ const instructions = messages.find(m => m.role === 'system')?.content || ''
+
+ const body = {
+ model: config.model,
+ input,
+ instructions,
+ stream: true,
+ temperature: config.temperature || 0.7,
+ }
+
+ const resp = await fetchWithRetry(url, {
+ method: 'POST',
+ headers: authHeaders(config.apiType, config.apiKey),
+ body: JSON.stringify(body),
+ signal,
+ })
+
+ if (!resp.ok) {
+ const errText = await resp.text().catch(() => '')
+ let errMsg = `API 错误 ${resp.status}`
+ try { errMsg = JSON.parse(errText).error?.message || errMsg } catch {
+ if (errText) errMsg += `: ${errText.slice(0, 200)}`
+ }
+ throw new Error(errMsg)
+ }
+
+ await readSSEStream(resp, (json) => {
+ if (json.type === 'response.output_text.delta' && json.delta) {
+ onChunk(json.delta)
+ }
+ if (json.choices?.[0]?.delta?.content) {
+ onChunk(json.choices[0].delta.content)
+ }
+ }, signal)
+}
+
+async function callAnthropicMessages({ base, config, messages, onChunk, signal }) {
+ const url = base + '/messages'
+ const systemMsg = messages.find(m => m.role === 'system')?.content || ''
+ const chatMessages = messages.filter(m => m.role !== 'system')
+
+ const body = {
+ model: config.model,
+ max_tokens: 8192,
+ stream: true,
+ temperature: config.temperature || 0.7,
+ }
+ if (systemMsg) body.system = systemMsg
+ body.messages = chatMessages
+
+ const resp = await fetchWithRetry(url, {
+ method: 'POST',
+ headers: authHeaders(config.apiType, config.apiKey),
+ body: JSON.stringify(body),
+ signal,
+ })
+
+ if (!resp.ok) {
+ const errText = await resp.text().catch(() => '')
+ let errMsg = `API 错误 ${resp.status}`
+ try { errMsg = JSON.parse(errText).error?.message || errMsg } catch {
+ if (errText) errMsg += `: ${errText.slice(0, 200)}`
+ }
+ throw new Error(errMsg)
+ }
+
+ await readSSEStream(resp, (json) => {
+ if (json.type === 'content_block_delta') {
+ const delta = json.delta
+ if (delta?.type === 'text_delta' && delta.text) onChunk(delta.text)
+ else if (delta?.type === 'thinking_delta' && delta.thinking) onChunk(delta.thinking)
+ }
+ }, signal)
+}
+
+async function callGeminiGenerate({ base, config, messages, onChunk, signal }) {
+ const systemMsg = messages.find(m => m.role === 'system')?.content || ''
+ const chatMessages = messages.filter(m => m.role !== 'system')
+
+ const contents = chatMessages.map(m => ({
+ role: m.role === 'assistant' ? 'model' : 'user',
+ parts: [{ text: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) }],
+ }))
+
+ const body = { contents, generationConfig: { temperature: config.temperature || 0.7 } }
+ if (systemMsg) body.systemInstruction = { parts: [{ text: systemMsg }] }
+
+ const url = `${base}/models/${config.model}:streamGenerateContent?alt=sse&key=${config.apiKey}`
+
+ const resp = await fetchWithRetry(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ signal,
+ })
+
+ if (!resp.ok) {
+ const errText = await resp.text().catch(() => '')
+ let errMsg = `API 错误 ${resp.status}`
+ try { errMsg = JSON.parse(errText).error?.message || errMsg } catch {}
+ throw new Error(errMsg)
+ }
+
+ await readSSEStream(resp, (json) => {
+ const text = json.candidates?.[0]?.content?.parts?.[0]?.text
+ if (text) onChunk(text)
+ }, signal)
+}
+
+async function callAI({ config, messages, adapters, mode }) {
+ const cfg = config || {}
+ const apiType = normalizeApiType(cfg.apiType)
+ if (!cfg.baseUrl || !cfg.model || (requiresApiKey(apiType) && !cfg.apiKey)) {
+ throw new Error('请先配置 AI 模型')
+ }
+
+ const base = cleanBaseUrl(cfg.baseUrl, apiType)
+ const allMessages = [{ role: 'system', content: buildSystemPrompt({ config: cfg, soulCache: adapters?.soulCache, knowledgeBase: adapters?.knowledgeBase }) }, ...messages]
+
+ const controller = new AbortController()
+ const totalTimer = setTimeout(() => controller.abort(), TIMEOUT_TOTAL)
+ let buffer = ''
+ const onChunk = (chunk) => { buffer += chunk }
+
+ try {
+ if (apiType === 'anthropic-messages') {
+ await callAnthropicMessages({ base, config: cfg, messages: allMessages, onChunk, signal: controller.signal })
+ } else if (apiType === 'google-gemini') {
+ await callGeminiGenerate({ base, config: cfg, messages: allMessages, onChunk, signal: controller.signal })
+ } else {
+ try {
+ await callChatCompletions({ base, config: cfg, messages: allMessages, onChunk, signal: controller.signal })
+ } catch (err) {
+ const msg = err.message || ''
+ if (msg.includes('legacy protocol') || msg.includes('/v1/responses') || msg.includes('not supported')) {
+ await callResponsesAPI({ base, config: cfg, messages: allMessages, onChunk, signal: controller.signal })
+ } else {
+ throw err
+ }
+ }
+ }
+ } finally {
+ clearTimeout(totalTimer)
+ }
+
+ const stopRes = applyStop(buffer)
+ return { text: stopRes.text, stop: stopRes.stop }
+}
+
+async function callAIWithTools({ config, messages, tools, adapters, mode }) {
+ const cfg = config || {}
+ const apiType = normalizeApiType(cfg.apiType)
+ if (!cfg.baseUrl || !cfg.model || (requiresApiKey(apiType) && !cfg.apiKey)) {
+ throw new Error('请先配置 AI 模型')
+ }
+
+ const base = cleanBaseUrl(cfg.baseUrl, apiType)
+ const modeKey = currentMode(cfg, mode)
+ const enabledTools = tools || getEnabledTools({ config: cfg, mode })
+ let currentMessages = [{ role: 'system', content: buildSystemPrompt({ config: cfg, soulCache: adapters?.soulCache, knowledgeBase: adapters?.knowledgeBase }) }, ...messages]
+
+ const maxRounds = cfg.autoRounds ?? 8
+ for (let round = 0; ; round++) {
+ if (maxRounds > 0 && round >= maxRounds) {
+ return { text: '工具调用达到上限,已停止。', stop: true }
+ }
+
+ const controller = new AbortController()
+ const totalTimer = setTimeout(() => controller.abort(), TIMEOUT_TOTAL)
+
+ try {
+ if (apiType === 'anthropic-messages') {
+ const systemMsg = currentMessages.find(m => m.role === 'system')?.content || ''
+ const chatMsgs = currentMessages.filter(m => m.role !== 'system')
+ const body = {
+ model: cfg.model,
+ max_tokens: 8192,
+ temperature: cfg.temperature || 0.7,
+ messages: chatMsgs,
+ }
+ if (systemMsg) body.system = systemMsg
+ if (enabledTools.length > 0) body.tools = convertToolsForAnthropic(enabledTools)
+
+ const resp = await fetchWithRetry(base + '/messages', {
+ method: 'POST',
+ headers: authHeaders(cfg.apiType, cfg.apiKey),
+ body: JSON.stringify(body),
+ signal: controller.signal,
+ })
+ if (!resp.ok) {
+ const errText = await resp.text().catch(() => '')
+ let errMsg = `API 错误 ${resp.status}`
+ try { errMsg = JSON.parse(errText).error?.message || errMsg } catch {}
+ throw new Error(errMsg)
+ }
+
+ const data = await resp.json()
+ const contentBlocks = data.content || []
+ const toolUses = contentBlocks.filter(b => b.type === 'tool_use')
+ const textContent = contentBlocks.filter(b => b.type === 'text').map(b => b.text).join('')
+
+ if (toolUses.length > 0) {
+ currentMessages.push({ role: 'assistant', content: contentBlocks })
+ const toolResults = []
+ for (const tu of toolUses) {
+ const args = tu.input || {}
+ const { result } = await executeToolWithSafety(tu.name, args, adapters, modeKey)
+ toolResults.push({
+ type: 'tool_result',
+ tool_use_id: tu.id,
+ content: typeof result === 'string' ? result : JSON.stringify(result),
+ })
+ }
+ currentMessages.push({ role: 'user', content: toolResults })
+ continue
+ }
+
+ const stopRes = applyStop(textContent)
+ return { text: stopRes.text, stop: stopRes.stop }
+ }
+
+ if (apiType === 'google-gemini') {
+ const systemMsg = currentMessages.find(m => m.role === 'system')?.content || ''
+ const chatMsgs = currentMessages.filter(m => m.role !== 'system')
+ const contents = chatMsgs.map(m => ({
+ role: m.role === 'assistant' ? 'model' : m.role === 'tool' ? 'function' : 'user',
+ parts: m.functionResponse
+ ? [{ functionResponse: m.functionResponse }]
+ : [{ text: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) }],
+ }))
+ const body = { contents, generationConfig: { temperature: cfg.temperature || 0.7 } }
+ if (systemMsg) body.systemInstruction = { parts: [{ text: systemMsg }] }
+ if (enabledTools.length > 0) body.tools = convertToolsForGemini(enabledTools)
+
+ const url = `${base}/models/${cfg.model}:generateContent?key=${cfg.apiKey}`
+ const resp = await fetchWithRetry(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ signal: controller.signal,
+ })
+ if (!resp.ok) {
+ const errText = await resp.text().catch(() => '')
+ let errMsg = `API 错误 ${resp.status}`
+ try { errMsg = JSON.parse(errText).error?.message || errMsg } catch {}
+ throw new Error(errMsg)
+ }
+
+ const data = await resp.json()
+ const parts = data.candidates?.[0]?.content?.parts || []
+ const funcCalls = parts.filter(p => p.functionCall)
+ const textParts = parts.filter(p => p.text).map(p => p.text).join('')
+
+ if (funcCalls.length > 0) {
+ currentMessages.push({ role: 'assistant', content: textParts, _geminiParts: parts })
+ for (const fc of funcCalls) {
+ const args = fc.functionCall.args || {}
+ const { result } = await executeToolWithSafety(fc.functionCall.name, args, adapters, modeKey)
+ currentMessages.push({
+ role: 'tool',
+ content: typeof result === 'string' ? result : JSON.stringify(result),
+ functionResponse: { name: fc.functionCall.name, response: { result: typeof result === 'string' ? result : JSON.stringify(result) } },
+ })
+ }
+ continue
+ }
+
+ const stopRes = applyStop(textParts)
+ return { text: stopRes.text, stop: stopRes.stop }
+ }
+
+ const body = {
+ model: cfg.model,
+ messages: currentMessages,
+ temperature: cfg.temperature || 0.7,
+ }
+ if (enabledTools.length > 0) body.tools = enabledTools
+
+ const resp = await fetchWithRetry(base + '/chat/completions', {
+ method: 'POST',
+ headers: authHeaders(cfg.apiType, cfg.apiKey),
+ body: JSON.stringify(body),
+ signal: controller.signal,
+ })
+
+ if (!resp.ok) {
+ const errText = await resp.text().catch(() => '')
+ let errMsg = `API 错误 ${resp.status}`
+ try { errMsg = JSON.parse(errText).error?.message || errMsg } catch {}
+ throw new Error(errMsg)
+ }
+
+ const data = await resp.json()
+ const choice = data.choices?.[0]
+ const assistantMsg = choice?.message
+
+ if (!assistantMsg) throw new Error('AI 未返回有效响应')
+
+ if (assistantMsg.tool_calls && assistantMsg.tool_calls.length > 0) {
+ currentMessages.push(assistantMsg)
+ for (const tc of assistantMsg.tool_calls) {
+ let args
+ try { args = JSON.parse(tc.function.arguments) } catch { args = {} }
+ const toolName = tc.function.name
+ const { result } = await executeToolWithSafety(toolName, args, adapters, modeKey)
+ currentMessages.push({
+ role: 'tool',
+ tool_call_id: tc.id,
+ content: typeof result === 'string' ? result : JSON.stringify(result),
+ })
+ }
+ continue
+ }
+
+ const content = assistantMsg.content || assistantMsg.reasoning_content || ''
+ const stopRes = applyStop(content)
+ return { text: stopRes.text, stop: stopRes.stop }
+ } finally {
+ clearTimeout(totalTimer)
+ }
+ }
+}
+
+// 适配器接口(由调用方提供)
+// const adapters = {
+// execTool: async (toolCall) => {},
+// confirm: async (text) => false,
+// askUser: async (prompt) => ({ ok: false, message: '' }),
+// storage: { getItem, setItem },
+// imageStore: { saveImage, loadImage, deleteImage },
+// soulCache,
+// knowledgeBase,
+// }
+
+export {
+ buildSystemPrompt,
+ getEnabledTools,
+ callAIWithTools,
+ callAI,
+ trimContext,
+}
+
From 0094c23b1a52c626bb8a7f7dd5fba8a45d34f880 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Tue, 17 Mar 2026 13:36:50 +0800
Subject: [PATCH 200/426] feat: add hosted agent to chat
---
src/pages/chat.js | 701 ++++++++++++++++++++++++++++++++++++++++++++-
src/style/chat.css | 176 ++++++++++++
2 files changed, 876 insertions(+), 1 deletion(-)
diff --git a/src/pages/chat.js b/src/pages/chat.js
index de101a8b..a923be7e 100644
--- a/src/pages/chat.js
+++ b/src/pages/chat.js
@@ -18,6 +18,40 @@ const STORAGE_MODEL_KEY = 'clawpanel-chat-selected-model'
const STORAGE_SIDEBAR_KEY = 'clawpanel-chat-sidebar-open'
const STORAGE_SESSION_NAMES_KEY = 'clawpanel-chat-session-names'
+const HOSTED_STATUS = {
+ IDLE: 'idle',
+ RUNNING: 'running',
+ WAITING: 'waiting_reply',
+ PAUSED: 'paused',
+ ERROR: 'error',
+}
+const HOSTED_SESSIONS_KEY = 'clawpanel-hosted-agent-sessions'
+const HOSTED_GLOBAL_KEY = 'hostedAgent.default'
+
+// { enabled, prompt, autoRunAfterTarget, stopPolicy, maxSteps, stepDelayMs, retryLimit, toolPolicy, state, history }
+// history roles: system | assistant | target
+const HOSTED_DEFAULTS = {
+ enabled: false,
+ prompt: '',
+ autoRunAfterTarget: true,
+ stopPolicy: 'self',
+ maxSteps: 50,
+ stepDelayMs: 1200,
+ retryLimit: 2,
+ toolPolicy: 'inherit',
+}
+
+const HOSTED_RUNTIME_DEFAULT = {
+ status: HOSTED_STATUS.IDLE,
+ stepCount: 0,
+ lastRunAt: 0,
+ lastError: '',
+ pending: false,
+ errorCount: 0,
+}
+
+const HOSTED_CONTEXT_MAX = 30
+
const COMMANDS = [
{ title: '会话', commands: [
{ cmd: '/new', desc: '新建会话', action: 'exec' },
@@ -60,6 +94,16 @@ let _sessionKey = null, _page = null, _messagesEl = null, _textarea = null
let _sendBtn = null, _statusDot = null, _typingEl = null, _scrollBtn = null
let _sessionListEl = null, _cmdPanelEl = null, _attachPreviewEl = null, _fileInputEl = null
let _modelSelectEl = null
+let _hostedBtn = null, _hostedPanelEl = null, _hostedBadgeEl = null
+let _hostedPromptEl = null, _hostedEnableEl = null, _hostedMaxStepsEl = null, _hostedStepDelayEl = null, _hostedRetryLimitEl = null
+let _hostedSaveBtn = null, _hostedPauseBtn = null, _hostedStopBtn = null, _hostedCloseBtn = null
+let _hostedGlobalSyncEl = null
+let _hostedDefaults = null
+let _hostedSessionConfig = null
+let _hostedRuntime = { ...HOSTED_RUNTIME_DEFAULT }
+let _hostedAutoTimer = null
+let _hostedLastTargetTs = 0
+let _hostedBusy = false
let _currentAiBubble = null, _currentAiText = '', _currentAiImages = [], _currentAiVideos = [], _currentAiAudios = [], _currentAiFiles = [], _currentAiTools = [], _currentRunId = null
let _isStreaming = false, _isSending = false, _messageQueue = [], _streamStartTime = 0
let _lastRenderTime = 0, _renderPending = false, _lastHistoryHash = ''
@@ -159,6 +203,65 @@ export async function render() {
+
+ 连接已断开,正在重连...
@@ -189,6 +292,19 @@ export async function render() {
_attachPreviewEl = page.querySelector('#chat-attachments-preview')
_fileInputEl = page.querySelector('#chat-file-input')
_modelSelectEl = page.querySelector('#chat-model-select')
+ _hostedBtn = page.querySelector('#chat-hosted-btn')
+ _hostedBadgeEl = page.querySelector('#chat-hosted-badge')
+ _hostedPanelEl = page.querySelector('#hosted-agent-panel')
+ _hostedPromptEl = page.querySelector('#hosted-agent-prompt')
+ _hostedEnableEl = page.querySelector('#hosted-agent-enabled')
+ _hostedMaxStepsEl = page.querySelector('#hosted-agent-max-steps')
+ _hostedStepDelayEl = page.querySelector('#hosted-agent-step-delay')
+ _hostedRetryLimitEl = page.querySelector('#hosted-agent-retry')
+ _hostedSaveBtn = page.querySelector('#hosted-agent-save')
+ _hostedPauseBtn = page.querySelector('#hosted-agent-pause')
+ _hostedStopBtn = page.querySelector('#hosted-agent-stop')
+ _hostedCloseBtn = page.querySelector('#hosted-agent-close')
+ _hostedGlobalSyncEl = page.querySelector('#hosted-agent-sync-global')
page.querySelector('#chat-sidebar')?.classList.toggle('open', getSidebarOpen())
bindEvents(page)
@@ -197,6 +313,12 @@ export async function render() {
// 首次使用引导提示
showPageGuide(_messagesEl)
+ loadHostedDefaults().then(() => {
+ loadHostedSessionConfig()
+ renderHostedPanel()
+ updateHostedBadge()
+ })
+
loadModelOptions()
// 非阻塞:先返回 DOM,后台连接 Gateway
connectGateway()
@@ -261,6 +383,25 @@ function bindEvents(page) {
else sendMessage()
})
+ if (_hostedBtn) {
+ _hostedBtn.addEventListener('click', (e) => {
+ e.stopPropagation()
+ toggleHostedPanel()
+ })
+ }
+ if (_hostedCloseBtn) {
+ _hostedCloseBtn.addEventListener('click', () => hideHostedPanel())
+ }
+ if (_hostedSaveBtn) {
+ _hostedSaveBtn.addEventListener('click', () => saveHostedConfig())
+ }
+ if (_hostedPauseBtn) {
+ _hostedPauseBtn.addEventListener('click', () => pauseHostedAgent())
+ }
+ if (_hostedStopBtn) {
+ _hostedStopBtn.addEventListener('click', () => stopHostedAgent())
+ }
+
const toggleSidebar = () => {
const sidebar = page.querySelector('#chat-sidebar')
if (!sidebar) return
@@ -302,7 +443,7 @@ function bindEvents(page) {
_autoScrollEnabled = true
scrollToBottom(true)
})
- _messagesEl.addEventListener('click', () => hideCmdPanel())
+ _messagesEl.addEventListener('click', () => { hideCmdPanel(); hideHostedPanel() })
_messagesEl.addEventListener('click', (e) => {
const target = e.target?.closest?.('.msg-spoiler')
if (!target) return
@@ -561,6 +702,11 @@ async function connectGateway() {
_hasEverConnected = true
if (bar) bar.style.display = 'none'
if (overlay) overlay.style.display = 'none'
+ if (_hostedRuntime.status === HOSTED_STATUS.PAUSED) {
+ _hostedRuntime.status = HOSTED_STATUS.IDLE
+ persistHostedRuntime()
+ updateHostedBadge()
+ }
} else if (status === 'error') {
// 连接错误:显示引导遮罩而非底部条
if (bar) bar.style.display = 'none'
@@ -568,6 +714,11 @@ async function connectGateway() {
overlay.style.display = 'flex'
if (desc) desc.textContent = errorMsg || '连接 Gateway 失败'
}
+ if (_hostedRuntime.status !== HOSTED_STATUS.PAUSED) {
+ _hostedRuntime.status = HOSTED_STATUS.PAUSED
+ persistHostedRuntime()
+ updateHostedBadge()
+ }
} else if (status === 'reconnecting' || status === 'disconnected') {
// 首次连接或多次重连失败时,显示引导遮罩而非底部小条
if (!_hasEverConnected) {
@@ -575,6 +726,11 @@ async function connectGateway() {
} else {
if (bar) { bar.textContent = '连接已断开,正在重连...'; bar.style.display = 'flex' }
}
+ if (_hostedRuntime.status !== HOSTED_STATUS.PAUSED) {
+ _hostedRuntime.status = HOSTED_STATUS.PAUSED
+ persistHostedRuntime()
+ updateHostedBadge()
+ }
} else {
if (bar) bar.style.display = 'none'
}
@@ -730,6 +886,9 @@ function switchSession(newKey) {
clearMessages()
loadHistory()
refreshSessionList()
+ loadHostedSessionConfig()
+ renderHostedPanel()
+ updateHostedBadge()
}
async function showNewSessionDialog() {
@@ -890,6 +1049,17 @@ function hideCmdPanel() {
if (_cmdPanelEl) _cmdPanelEl.style.display = 'none'
}
+function toggleHostedPanel() {
+ if (!_hostedPanelEl) return
+ const next = _hostedPanelEl.style.display !== 'block'
+ _hostedPanelEl.style.display = next ? 'block' : 'none'
+ if (next) renderHostedPanel()
+}
+
+function hideHostedPanel() {
+ if (_hostedPanelEl) _hostedPanelEl.style.display = 'none'
+}
+
function toggleCmdPanel() {
if (_cmdPanelEl?.style.display === 'block') hideCmdPanel()
else { _textarea.value = '/'; showCmdPanel(); _textarea.focus() }
@@ -980,6 +1150,14 @@ function handleEvent(msg) {
if (event === 'chat') handleChatEvent(payload)
+ if ((event === 'status' || event === 'gateway.status') && payload?.state === 'disconnected') {
+ if (_hostedRuntime.status !== HOSTED_STATUS.PAUSED) {
+ _hostedRuntime.status = HOSTED_STATUS.PAUSED
+ persistHostedRuntime()
+ updateHostedBadge()
+ }
+ }
+
// Compaction 状态指示:上游 2026.3.12 新增 status_reaction 事件
if (event === 'chat.status_reaction' || event === 'status_reaction') {
const reaction = payload.reaction || payload.emoji || ''
@@ -1055,6 +1233,14 @@ function handleChatEvent(payload) {
const ids = _toolRunIndex.get(runId) || []
finalTools = ids.map(id => mergeToolEventData({ id, name: '工具' })).filter(Boolean)
}
+
+ // 托管 Agent:记录对面回复并触发下一步
+ if (payload.sessionKey === _sessionKey || !_sessionKey) {
+ if (finalText && shouldCaptureHostedTarget(payload)) {
+ appendHostedTarget(finalText, payload.timestamp || Date.now())
+ maybeTriggerHostedRun()
+ }
+ }
if (finalImages.length) _currentAiImages = finalImages
if (finalVideos.length) _currentAiVideos = finalVideos
if (finalAudios.length) _currentAiAudios = finalAudios
@@ -2108,6 +2294,502 @@ function updateStatusDot(status) {
else _statusDot.classList.add('offline')
}
+async function loadHostedDefaults() {
+ _hostedDefaults = { ...HOSTED_DEFAULTS }
+ try {
+ const panel = await api.readPanelConfig()
+ const stored = panel?.hostedAgent?.default || null
+ if (stored) _hostedDefaults = { ..._hostedDefaults, ...stored }
+ } catch (e) {
+ console.warn('[chat][hosted] 读取 panel 配置失败:', e)
+ }
+}
+
+function getHostedSessionKey() {
+ return _sessionKey || localStorage.getItem(STORAGE_SESSION_KEY) || 'agent:main:main'
+}
+
+function loadHostedSessionConfig() {
+ let data = {}
+ try { data = JSON.parse(localStorage.getItem(HOSTED_SESSIONS_KEY) || '{}') } catch { data = {} }
+ const key = getHostedSessionKey()
+ const current = data[key] || {}
+ _hostedSessionConfig = { ...HOSTED_DEFAULTS, ..._hostedDefaults, ...current }
+ if (!_hostedSessionConfig.state) _hostedSessionConfig.state = { ...HOSTED_RUNTIME_DEFAULT }
+ if (!_hostedSessionConfig.history) _hostedSessionConfig.history = []
+ _hostedRuntime = { ...HOSTED_RUNTIME_DEFAULT, ..._hostedSessionConfig.state }
+ updateHostedBadge()
+}
+
+function saveHostedSessionConfig(nextConfig) {
+ let data = {}
+ try { data = JSON.parse(localStorage.getItem(HOSTED_SESSIONS_KEY) || '{}') } catch { data = {} }
+ data[getHostedSessionKey()] = nextConfig
+ localStorage.setItem(HOSTED_SESSIONS_KEY, JSON.stringify(data))
+}
+
+function persistHostedRuntime() {
+ if (!_hostedSessionConfig) return
+ _hostedSessionConfig.state = { ..._hostedRuntime }
+ saveHostedSessionConfig(_hostedSessionConfig)
+}
+
+function updateHostedBadge() {
+ if (!_hostedBadgeEl || !_hostedSessionConfig) return
+ const status = _hostedRuntime.status || HOSTED_STATUS.IDLE
+ const enabled = _hostedSessionConfig.enabled
+ let text = '未启用'
+ let cls = 'chat-hosted-badge'
+ if (!enabled) {
+ text = '未启用'
+ cls += ' idle'
+ } else if (status === HOSTED_STATUS.RUNNING) {
+ text = '运行中'
+ cls += ' running'
+ } else if (status === HOSTED_STATUS.WAITING) {
+ text = '等待回复'
+ cls += ' waiting'
+ } else if (status === HOSTED_STATUS.PAUSED) {
+ text = '已暂停'
+ cls += ' paused'
+ } else if (status === HOSTED_STATUS.ERROR) {
+ text = '异常'
+ cls += ' error'
+ } else {
+ text = '待命'
+ cls += ' idle'
+ }
+ _hostedBadgeEl.className = cls
+ _hostedBadgeEl.textContent = text
+}
+
+function renderHostedPanel() {
+ if (!_hostedPanelEl || !_hostedSessionConfig) return
+ if (_hostedPromptEl) _hostedPromptEl.value = _hostedSessionConfig.prompt || ''
+ if (_hostedEnableEl) _hostedEnableEl.checked = !!_hostedSessionConfig.enabled
+ if (_hostedMaxStepsEl) _hostedMaxStepsEl.value = _hostedSessionConfig.maxSteps || HOSTED_DEFAULTS.maxSteps
+ if (_hostedStepDelayEl) _hostedStepDelayEl.value = _hostedSessionConfig.stepDelayMs || HOSTED_DEFAULTS.stepDelayMs
+ if (_hostedRetryLimitEl) _hostedRetryLimitEl.value = _hostedSessionConfig.retryLimit ?? HOSTED_DEFAULTS.retryLimit
+ const statusEl = _hostedPanelEl.querySelector('#hosted-agent-status')
+ if (statusEl) {
+ const msg = _hostedRuntime.lastError ? `上次错误: ${_hostedRuntime.lastError}` : '状态正常'
+ statusEl.textContent = msg
+ }
+}
+
+async function saveHostedConfig() {
+ if (!_hostedSessionConfig) return
+ const prompt = (_hostedPromptEl?.value || '').trim()
+ if (!prompt) { toast('请输入初始提示词', 'warning'); return }
+ const enabled = !!_hostedEnableEl?.checked
+ const maxSteps = Math.max(1, parseInt(_hostedMaxStepsEl?.value || HOSTED_DEFAULTS.maxSteps, 10))
+ const stepDelayMs = Math.max(200, parseInt(_hostedStepDelayEl?.value || HOSTED_DEFAULTS.stepDelayMs, 10))
+ const retryLimit = Math.max(0, parseInt(_hostedRetryLimitEl?.value || HOSTED_DEFAULTS.retryLimit, 10))
+
+ _hostedSessionConfig = {
+ ..._hostedSessionConfig,
+ prompt,
+ enabled,
+ autoRunAfterTarget: true,
+ stopPolicy: 'self',
+ maxSteps,
+ stepDelayMs,
+ retryLimit,
+ }
+
+ if (!_hostedSessionConfig.history || !_hostedSessionConfig.history.length) {
+ _hostedSessionConfig.history = [{ role: 'system', content: prompt }]
+ } else if (_hostedSessionConfig.history[0]?.role !== 'system') {
+ _hostedSessionConfig.history.unshift({ role: 'system', content: prompt })
+ } else {
+ _hostedSessionConfig.history[0].content = prompt
+ }
+
+ if (!_hostedSessionConfig.state) _hostedSessionConfig.state = { ...HOSTED_RUNTIME_DEFAULT }
+ _hostedRuntime = { ...HOSTED_RUNTIME_DEFAULT, ..._hostedSessionConfig.state }
+ if (enabled && _hostedRuntime.status === HOSTED_STATUS.PAUSED) _hostedRuntime.status = HOSTED_STATUS.IDLE
+ persistHostedRuntime()
+ renderHostedPanel()
+ updateHostedBadge()
+
+ if (_hostedGlobalSyncEl?.checked) {
+ try {
+ const panel = await api.readPanelConfig()
+ const nextPanel = { ...(panel || {}) }
+ if (!nextPanel.hostedAgent) nextPanel.hostedAgent = {}
+ nextPanel.hostedAgent.default = {
+ ...HOSTED_DEFAULTS,
+ prompt,
+ enabled,
+ maxSteps,
+ stepDelayMs,
+ retryLimit,
+ }
+ await api.writePanelConfig(nextPanel)
+ toast('已同步为全局默认', 'success')
+ } catch (e) {
+ toast('同步全局默认失败: ' + (e.message || e), 'error')
+ }
+ }
+
+ if (enabled) toast('托管 Agent 已启用', 'success')
+ else toast('托管 Agent 已保存', 'info')
+}
+
+function pauseHostedAgent() {
+ if (!_hostedSessionConfig) return
+ _hostedRuntime.status = HOSTED_STATUS.PAUSED
+ _hostedRuntime.pending = false
+ persistHostedRuntime()
+ updateHostedBadge()
+ toast('托管 Agent 已暂停', 'info')
+}
+
+function stopHostedAgent() {
+ if (!_hostedSessionConfig) return
+ _hostedRuntime.status = HOSTED_STATUS.IDLE
+ _hostedRuntime.pending = false
+ _hostedRuntime.stepCount = 0
+ _hostedRuntime.lastError = ''
+ _hostedRuntime.errorCount = 0
+ persistHostedRuntime()
+ updateHostedBadge()
+ toast('托管 Agent 已停止', 'info')
+}
+
+function shouldCaptureHostedTarget(payload) {
+ if (!_hostedSessionConfig?.enabled) return false
+ if (_hostedRuntime.status === HOSTED_STATUS.PAUSED || _hostedRuntime.status === HOSTED_STATUS.ERROR) return false
+ if (payload?.message?.role && payload.message.role !== 'assistant') return false
+ const ts = payload?.timestamp || Date.now()
+ if (ts && ts === _hostedLastTargetTs) return false
+ _hostedLastTargetTs = ts
+ return true
+}
+
+function appendHostedTarget(text, ts) {
+ if (!_hostedSessionConfig) return
+ if (!_hostedSessionConfig.history) _hostedSessionConfig.history = []
+ _hostedSessionConfig.history.push({ role: 'target', content: text, ts: ts || Date.now() })
+ persistHostedRuntime()
+}
+
+function maybeTriggerHostedRun() {
+ if (!_hostedSessionConfig?.enabled) return
+ if (!_hostedSessionConfig.autoRunAfterTarget) return
+ if (_hostedRuntime.pending || _hostedRuntime.status === HOSTED_STATUS.RUNNING) return
+ if (_hostedRuntime.status === HOSTED_STATUS.PAUSED || _hostedRuntime.status === HOSTED_STATUS.ERROR) return
+ if (!wsClient.gatewayReady) {
+ _hostedRuntime.status = HOSTED_STATUS.PAUSED
+ persistHostedRuntime()
+ updateHostedBadge()
+ return
+ }
+ if (_hostedRuntime.status === HOSTED_STATUS.WAITING) {
+ _hostedRuntime.status = HOSTED_STATUS.IDLE
+ }
+ runHostedAgentStep()
+}
+
+function buildHostedMessages() {
+ const history = _hostedSessionConfig?.history || []
+ const trimmed = history.slice(-HOSTED_CONTEXT_MAX)
+ return trimmed.map(item => {
+ if (item.role === 'system') return { role: 'system', content: item.content }
+ if (item.role === 'assistant') return { role: 'assistant', content: item.content }
+ return { role: 'user', content: item.content }
+ })
+}
+
+function detectStopFromText(text) {
+ if (!text) return false
+ return /\b(完成|无需继续|结束|停止|done|stop|final)\b/i.test(text)
+}
+
+async function runHostedAgentStep() {
+ if (_hostedBusy || !_hostedSessionConfig?.enabled) return
+ const prompt = (_hostedSessionConfig.prompt || '').trim()
+ if (!prompt) return
+ if (_hostedRuntime.stepCount >= _hostedSessionConfig.maxSteps) {
+ _hostedRuntime.status = HOSTED_STATUS.IDLE
+ persistHostedRuntime()
+ updateHostedBadge()
+ return
+ }
+ _hostedBusy = true
+ _hostedRuntime.pending = true
+ _hostedRuntime.status = HOSTED_STATUS.RUNNING
+ _hostedRuntime.lastRunAt = Date.now()
+ persistHostedRuntime()
+ updateHostedBadge()
+
+ const delay = _hostedSessionConfig.stepDelayMs || HOSTED_DEFAULTS.stepDelayMs
+ if (delay > 0) {
+ await new Promise(r => setTimeout(r, delay))
+ }
+
+ try {
+ const messages = buildHostedMessages()
+ let resultText = ''
+ await callHostedAI(messages, (chunk) => {
+ resultText += chunk
+ })
+ const nextInstruction = resultText.trim()
+ if (!nextInstruction) throw new Error('托管 Agent 未生成指令')
+
+ _hostedRuntime.stepCount += 1
+ _hostedRuntime.errorCount = 0
+ _hostedRuntime.lastError = ''
+
+ _hostedSessionConfig.history.push({ role: 'assistant', content: nextInstruction, ts: Date.now() })
+ persistHostedRuntime()
+
+ appendHostedOutput(`[托管 Agent] 下一步指令: ${nextInstruction}`)
+ await wsClient.chatSend(_sessionKey, nextInstruction)
+
+ _hostedRuntime.status = HOSTED_STATUS.WAITING
+ _hostedRuntime.pending = false
+ persistHostedRuntime()
+ updateHostedBadge()
+
+ if (_hostedSessionConfig.stopPolicy === 'self' && detectStopFromText(nextInstruction)) {
+ _hostedRuntime.status = HOSTED_STATUS.IDLE
+ persistHostedRuntime()
+ updateHostedBadge()
+ }
+ } catch (e) {
+ _hostedRuntime.errorCount = (_hostedRuntime.errorCount || 0) + 1
+ _hostedRuntime.lastError = e.message || String(e)
+ _hostedRuntime.pending = false
+ if (_hostedRuntime.errorCount > _hostedSessionConfig.retryLimit) {
+ _hostedRuntime.status = HOSTED_STATUS.ERROR
+ updateHostedBadge()
+ persistHostedRuntime()
+ return
+ }
+ persistHostedRuntime()
+ updateHostedBadge()
+ const delay = _hostedSessionConfig.stepDelayMs || HOSTED_DEFAULTS.stepDelayMs
+ setTimeout(() => {
+ _hostedBusy = false
+ runHostedAgentStep()
+ }, delay)
+ return
+ } finally {
+ _hostedBusy = false
+ }
+}
+
+async function callHostedAI(messages, onChunk) {
+ const config = await loadHostedAssistantConfig()
+ const apiType = normalizeApiType(config.apiType)
+ if (!config.baseUrl || !config.model || (requiresApiKey(apiType) && !config.apiKey)) {
+ throw new Error('托管 Agent 未配置模型(请在 AI 助手页面配置)')
+ }
+ const base = cleanBaseUrl(config.baseUrl, apiType)
+ const systemPrompt = messages.find(m => m.role === 'system')?.content || ''
+ const chatMessages = messages.filter(m => m.role !== 'system')
+
+ if (apiType === 'anthropic-messages') {
+ await callAnthropicHosted(base, systemPrompt, chatMessages, config, onChunk)
+ return
+ }
+ if (apiType === 'google-gemini') {
+ await callGeminiHosted(base, systemPrompt, chatMessages, config, onChunk)
+ return
+ }
+ await callChatCompletionsHosted(base, systemPrompt, chatMessages, config, onChunk)
+}
+
+async function loadHostedAssistantConfig() {
+ try {
+ const raw = localStorage.getItem('clawpanel-assistant')
+ const stored = raw ? JSON.parse(raw) : {}
+ return {
+ baseUrl: stored.baseUrl || '',
+ apiKey: stored.apiKey || '',
+ model: stored.model || '',
+ temperature: stored.temperature || 0.7,
+ apiType: stored.apiType || 'openai-completions',
+ }
+ } catch {
+ return { baseUrl: '', apiKey: '', model: '', temperature: 0.7, apiType: 'openai-completions' }
+ }
+}
+
+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 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)
+ if (type === 'anthropic-messages') {
+ const headers = {
+ 'Content-Type': 'application/json',
+ 'anthropic-version': '2023-06-01',
+ }
+ if (apiKey) headers['x-api-key'] = apiKey
+ return headers
+ }
+ const headers = { 'Content-Type': 'application/json' }
+ if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
+ return headers
+}
+
+async function fetchWithRetry(url, options, retries = 2) {
+ const delays = [800, 1600, 3200]
+ 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 (i >= retries) throw err
+ await new Promise(r => setTimeout(r, delays[i]))
+ }
+ }
+}
+
+async function readSSEStream(resp, onEvent) {
+ const reader = resp.body.getReader()
+ const decoder = new TextDecoder()
+ let buffer = ''
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+ buffer += decoder.decode(value, { stream: true })
+ const lines = buffer.split('\n')
+ buffer = lines.pop() || ''
+ for (const line of lines) {
+ const trimmed = line.trim()
+ if (!trimmed) continue
+ if (!trimmed.startsWith('data:')) continue
+ const data = trimmed.slice(5).trim()
+ if (data === '[DONE]') return
+ try { onEvent(JSON.parse(data)) } catch {}
+ }
+ }
+}
+
+async function callChatCompletionsHosted(base, systemPrompt, messages, config, onChunk) {
+ const body = {
+ model: config.model,
+ messages: [systemPrompt ? { role: 'system', content: systemPrompt } : null, ...messages].filter(Boolean),
+ stream: true,
+ temperature: config.temperature || 0.7,
+ }
+ const resp = await fetchWithRetry(base + '/chat/completions', {
+ method: 'POST',
+ headers: authHeaders(config.apiType, config.apiKey),
+ body: JSON.stringify(body),
+ })
+ 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 delta = json.choices?.[0]?.delta
+ if (delta?.content) onChunk(delta.content)
+ })
+}
+
+async function callAnthropicHosted(base, systemPrompt, messages, config, onChunk) {
+ const body = {
+ model: config.model,
+ max_tokens: 4096,
+ stream: true,
+ temperature: config.temperature || 0.7,
+ messages,
+ }
+ if (systemPrompt) body.system = systemPrompt
+ const resp = await fetchWithRetry(base + '/messages', {
+ method: 'POST',
+ headers: authHeaders(config.apiType, config.apiKey),
+ body: JSON.stringify(body),
+ })
+ 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) => {
+ if (json.type === 'content_block_delta') {
+ const delta = json.delta
+ if (delta?.type === 'text_delta' && delta.text) onChunk(delta.text)
+ }
+ })
+}
+
+async function callGeminiHosted(base, systemPrompt, messages, config, onChunk) {
+ const contents = messages.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 (systemPrompt) body.systemInstruction = { parts: [{ text: systemPrompt }] }
+ 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),
+ })
+ 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)
+ })
+}
+
+function appendHostedOutput(text) {
+ if (!text) return
+ const wrap = document.createElement('div')
+ wrap.className = 'msg msg-system msg-hosted'
+ wrap.textContent = text
+ insertMessageByTime(wrap, Date.now())
+ scrollToBottom()
+}
+
// ── 页面离开清理 ──
export function cleanup() {
@@ -2139,4 +2821,21 @@ export function cleanup() {
_isSending = false
_messageQueue = []
_lastHistoryHash = ''
+ _hostedBtn = null
+ _hostedPanelEl = null
+ _hostedBadgeEl = null
+ _hostedPromptEl = null
+ _hostedEnableEl = null
+ _hostedMaxStepsEl = null
+ _hostedStepDelayEl = null
+ _hostedRetryLimitEl = null
+ _hostedSaveBtn = null
+ _hostedPauseBtn = null
+ _hostedStopBtn = null
+ _hostedCloseBtn = null
+ _hostedGlobalSyncEl = null
+ _hostedSessionConfig = null
+ _hostedDefaults = null
+ _hostedRuntime = { ...HOSTED_RUNTIME_DEFAULT }
+ _hostedBusy = false
}
diff --git a/src/style/chat.css b/src/style/chat.css
index 6579eb50..71529f4b 100644
--- a/src/style/chat.css
+++ b/src/style/chat.css
@@ -435,6 +435,182 @@
transition: opacity 0.15s, background 0.15s;
}
+/* 托管 Agent */
+.chat-hosted-btn {
+ height: 40px;
+ border-radius: var(--radius-md, 8px);
+ border: 1px solid var(--border);
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 0 10px;
+ font-size: 12px;
+ flex-shrink: 0;
+ transition: background 0.15s, border-color 0.15s;
+}
+
+.chat-hosted-btn:hover {
+ background: var(--bg-hover);
+ border-color: var(--border-primary);
+}
+
+.chat-hosted-label {
+ font-weight: 600;
+}
+
+.chat-hosted-badge {
+ font-size: 11px;
+ padding: 2px 6px;
+ border-radius: 10px;
+ background: var(--bg-tertiary);
+ color: var(--text-tertiary);
+}
+
+.chat-hosted-badge.running { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
+.chat-hosted-badge.waiting { background: rgba(245, 158, 11, 0.12); color: #f59e0b; }
+.chat-hosted-badge.paused { background: rgba(148, 163, 184, 0.2); color: #94a3b8; }
+.chat-hosted-badge.error { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
+.chat-hosted-badge.idle { background: rgba(100, 116, 139, 0.12); color: #94a3b8; }
+
+.hosted-agent-panel {
+ position: absolute;
+ right: 16px;
+ bottom: 90px;
+ width: 360px;
+ max-width: calc(100% - 32px);
+ background: var(--bg-primary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ box-shadow: 0 8px 24px rgba(0,0,0,0.2);
+ z-index: 30;
+ overflow: hidden;
+}
+
+.hosted-agent-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 12px;
+ border-bottom: 1px solid var(--border);
+ font-weight: 600;
+}
+
+.hosted-agent-close {
+ background: none;
+ border: none;
+ color: var(--text-tertiary);
+ font-size: 18px;
+ cursor: pointer;
+}
+
+.hosted-agent-body {
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.hosted-agent-prompt {
+ min-height: 88px;
+ resize: vertical;
+}
+
+.hosted-agent-switch {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ font-size: 13px;
+ color: var(--text-secondary);
+}
+
+.hosted-agent-switch input {
+ display: none;
+}
+
+.hosted-agent-track {
+ width: 34px;
+ height: 18px;
+ background: var(--bg-tertiary);
+ border-radius: 999px;
+ position: relative;
+ transition: background 0.2s;
+}
+
+.hosted-agent-track::after {
+ content: '';
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: #fff;
+ transition: transform 0.2s;
+}
+
+.hosted-agent-switch input:checked + .hosted-agent-track {
+ background: var(--accent);
+}
+
+.hosted-agent-switch input:checked + .hosted-agent-track::after {
+ transform: translateX(16px);
+}
+
+.hosted-agent-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 12px;
+ color: var(--text-secondary);
+ padding: 4px 0;
+}
+
+.hosted-agent-tag {
+ color: var(--text-tertiary);
+}
+
+.hosted-agent-advanced {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 8px;
+ background: var(--bg-secondary);
+}
+
+.hosted-agent-advanced-title {
+ font-size: 11px;
+ color: var(--text-tertiary);
+ margin-bottom: 6px;
+}
+
+.hosted-agent-grid {
+ display: grid;
+ gap: 8px;
+ grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
+}
+
+.hosted-agent-actions {
+ display: flex;
+ gap: 6px;
+ justify-content: flex-end;
+}
+
+.hosted-agent-footer {
+ padding: 8px 12px;
+ border-top: 1px solid var(--border);
+ font-size: 11px;
+ color: var(--text-tertiary);
+}
+
+.msg-hosted {
+ background: rgba(100, 116, 139, 0.08);
+ border: 1px dashed rgba(148, 163, 184, 0.4);
+ color: var(--text-secondary);
+}
+
.chat-send-btn:hover:not(:disabled) {
opacity: 0.85;
}
From 08e9916740c9b3c93f543db7bbebd99f268f95c4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Tue, 17 Mar 2026 13:37:39 +0800
Subject: [PATCH 201/426] chore: checkpoint before assistant refactor
From c377dcdb680f1e929ebab157c7ffc230cb640c0a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Tue, 17 Mar 2026 13:51:36 +0800
Subject: [PATCH 202/426] chore: checkpoint before assistant cleanup
From b73d8e78e561773cd71cd55320e51e60ad6ca735 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Tue, 17 Mar 2026 22:28:01 +0800
Subject: [PATCH 203/426] refactor: delegate assistant core helpers
---
src/pages/assistant.js | 326 +++++++++--------------------------------
1 file changed, 70 insertions(+), 256 deletions(-)
diff --git a/src/pages/assistant.js b/src/pages/assistant.js
index f1ff5266..702633c4 100644
--- a/src/pages/assistant.js
+++ b/src/pages/assistant.js
@@ -10,6 +10,7 @@ import { api } from '../lib/tauri-api.js'
import { OPENCLAW_KB } from '../lib/openclaw-kb.js'
import { icon, statusIcon } from '../lib/icons.js'
import { QTCOOL, PROVIDER_PRESETS, API_TYPES as SHARED_API_TYPES, fetchQtcoolModels } from '../lib/model-presets.js'
+import { buildSystemPrompt as buildSystemPromptCore, getEnabledTools as getEnabledToolsCore, callAIWithTools as callAIWithToolsCore, callAI as callAICore, trimContext as trimContextCore } from '../lib/assistant-core.js'
// ── 常量 ──
const STORAGE_KEY = 'clawpanel-assistant'
@@ -714,35 +715,7 @@ function currentMode() {
}
function getEnabledTools() {
- const mode = MODES[currentMode()]
- if (!mode.tools) return [] // 聊天模式:无工具
-
- const t = _config.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 (mode.readOnly) {
- tools.push(...TOOL_DEFS.fileOps.filter(td => td.function.name !== 'write_file'))
- } else {
- tools.push(...TOOL_DEFS.fileOps)
- }
- }
-
- // Skills 管理工具:始终启用(规划模式下排除安装操作)
- if (mode.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
+ return getEnabledToolsCore({ config: _config, mode: currentMode() })
}
function applyModeStyle(page, modeKey) {
@@ -837,6 +810,8 @@ function playModeTransition(page, modeKey) {
}
function buildSystemPrompt() {
+ return buildSystemPromptCore({ config: _config, soulCache: _soulCache, knowledgeBase: OPENCLAW_KB })
+
let prompt = ''
// 灵魂移植模式:用 OpenClaw Agent 的身份替代默认人设
@@ -1503,6 +1478,15 @@ const TIMEOUT_CHUNK = 30_000 // 流式 chunk 间隔超时 30 秒
const TIMEOUT_CONNECT = 30_000 // 连接超时 30 秒
async function callAI(sessionId, messages, onChunk) {
+ const { text } = await callAICore({
+ config: _config,
+ messages,
+ adapters: { soulCache: _soulCache, knowledgeBase: OPENCLAW_KB },
+ mode: currentMode(),
+ })
+ if (typeof onChunk === 'function' && text) onChunk(text)
+ return
+
const apiType = normalizeApiType(_config.apiType)
if (!_config.baseUrl || !_config.model || (requiresApiKey(apiType) && !_config.apiKey)) {
throw new Error('请先配置 AI 模型(点击右上角设置按钮)')
@@ -2087,227 +2071,54 @@ async function executeToolWithSafety(toolName, args, tcForConfirm) {
// 带工具调用的 AI 请求(非流式,用于 tool_calls 检测循环)
async function callAIWithTools(sessionId, messages, onStatus, onToolProgress) {
- const apiType = normalizeApiType(_config.apiType)
- if (!_config.baseUrl || !_config.model || (requiresApiKey(apiType) && !_config.apiKey)) {
- throw new Error('请先配置 AI 模型(点击右上角设置按钮)')
- }
-
- const base = cleanBaseUrl(_config.baseUrl, apiType)
- const tools = getEnabledTools()
- let currentMessages = [{ role: 'system', content: buildSystemPrompt() }, ...messages]
const toolHistory = []
- let controller = null
-
- const autoRounds = _config.autoRounds ?? 8 // 0 = 无限制
- let nextPauseAt = autoRounds // 下一次暂停的轮次阈值
- try {
- for (let round = 0; ; round++) {
- // 检查是否已被用户中止
- const active = sessionId ? getAbortController(sessionId) : null
- if (!sessionId || !getStreaming(sessionId) || active?.signal?.aborted) {
- throw new DOMException('Aborted', 'AbortError')
- }
- if (autoRounds > 0 && round >= nextPauseAt) {
- const answer = await showAskUserCard({
- question: `AI 已连续调用工具 ${round} 轮,可能陷入循环。你希望怎么做?`,
- 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
- }
- }
-
- controller = new AbortController()
- _abortController = controller
- if (sessionId) setAbortController(sessionId, controller)
- 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: controller.signal,
- })
- if (!resp.ok) {
- const errText = await resp.text().catch(() => '')
- let errMsg = `API 错误 ${resp.status}`
- try { errMsg = JSON.parse(errText).error?.message || errMsg } catch {}
- throw new Error(errMsg)
- }
-
- const data = await resp.json()
- const contentBlocks = data.content || []
- const toolUses = contentBlocks.filter(b => b.type === 'tool_use')
- const textContent = contentBlocks.filter(b => b.type === 'text').map(b => b.text).join('')
-
- if (toolUses.length > 0) {
- // 将 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: controller.signal,
- })
- if (!resp.ok) {
- const errText = await resp.text().catch(() => '')
- let errMsg = `API 错误 ${resp.status}`
- try { errMsg = JSON.parse(errText).error?.message || errMsg } catch {}
- throw new Error(errMsg)
+ const adapters = {
+ soulCache: _soulCache,
+ knowledgeBase: OPENCLAW_KB,
+ confirm: async (text) => {
+ const session = getCurrentSession()
+ if (session) setSessionStatus(session.id, 'waiting')
+ const result = await showConfirm(text)
+ if (session) setSessionStatus(session.id, 'streaming')
+ return result
+ },
+ askUser: async (args) => {
+ const session = getCurrentSession()
+ if (session) setSessionStatus(session.id, 'waiting')
+ const result = await showAskUserCard(args)
+ if (session) setSessionStatus(session.id, 'streaming')
+ return result
+ },
+ execTool: async ({ name, args }) => {
+ toolHistory.push({ name, args, result: null, approved: true, pending: true })
+ if (typeof onToolProgress === 'function') onToolProgress(toolHistory)
+ let result = ''
+ try {
+ result = await executeTool(name, args)
+ } catch (err) {
+ result = `执行失败: ${typeof err === 'string' ? err : err.message || JSON.stringify(err)}`
}
-
- const 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
+ const last = toolHistory[toolHistory.length - 1]
+ if (last) {
+ last.result = result
+ last.pending = false
}
-
- 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: controller.signal,
- })
-
- if (!resp.ok) {
- const errText = await resp.text().catch(() => '')
- let errMsg = `API 错误 ${resp.status}`
- try { errMsg = JSON.parse(errText).error?.message || errMsg } catch {}
- throw new Error(errMsg)
- }
-
- const data = await resp.json()
- const choice = data.choices?.[0]
- const assistantMsg = choice?.message
-
- if (!assistantMsg) throw new Error('AI 未返回有效响应')
-
- if (assistantMsg.tool_calls && assistantMsg.tool_calls.length > 0) {
- currentMessages.push(assistantMsg)
-
- for (const tc of assistantMsg.tool_calls) {
- let args
- try { args = JSON.parse(tc.function.arguments) } catch { args = {} }
- const toolName = tc.function.name
-
- 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 }
- }
- } finally {
- if (sessionId && controller && getAbortController(sessionId) === controller) {
- setAbortController(sessionId, null)
- }
+ if (typeof onToolProgress === 'function') onToolProgress(toolHistory)
+ return result
+ },
}
-}
+ if (typeof onStatus === 'function') onStatus('AI 思考中...')
+ const tools = getEnabledTools()
+ const result = await callAIWithToolsCore({
+ config: _config,
+ messages,
+ tools,
+ adapters,
+ mode: currentMode(),
+ })
+ return { content: result.text || '', toolHistory }
-// ── 渲染 ──
+ // ── 渲染 ──
function renderSessionList() {
if (!_sessionListEl) return
@@ -3569,14 +3380,16 @@ async function sendMessageDirect(text) {
// 准备 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 = trimContextCore(
+ 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
+ )
// 添加空 AI 消息占位
const aiMsg = { role: 'assistant', content: '', ts: Date.now() }
@@ -3719,9 +3532,10 @@ async function retryAIResponse(session) {
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 = trimContextCore(
+ session.messages.filter(m => m.role === 'user' || m.role === 'assistant'),
+ MAX_CONTEXT_TOKENS
+ )
const aiMsg = { role: 'assistant', content: '', ts: Date.now() }
session.messages.push(aiMsg)
From 869398c0bd765f18f2a1f87098f79e56226d47b4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Tue, 17 Mar 2026 22:29:29 +0800
Subject: [PATCH 204/426] refactor: use assistant core in assistant page
---
src/pages/assistant.js | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/pages/assistant.js b/src/pages/assistant.js
index 702633c4..72859180 100644
--- a/src/pages/assistant.js
+++ b/src/pages/assistant.js
@@ -2117,8 +2117,9 @@ async function callAIWithTools(sessionId, messages, onStatus, onToolProgress) {
mode: currentMode(),
})
return { content: result.text || '', toolHistory }
+}
- // ── 渲染 ──
+// ── 渲染 ──
function renderSessionList() {
if (!_sessionListEl) return
From 47eb00fbe54aa3e7db07655a76c525dcac463052 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Tue, 17 Mar 2026 22:55:47 +0800
Subject: [PATCH 205/426] chore: checkpoint before revert 604ea3d
From dfab03cc60c9433f7f7cfd5571b45f4fa80442bc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Tue, 17 Mar 2026 22:55:54 +0800
Subject: [PATCH 206/426] Revert "fix: cherry-pick PR#94 improvements +
dashboard loading fix"
This reverts commit 604ea3da965a8d233ec03411f58f463b6ffec994.
---
src/lib/ws-client.js | 16 +-
src/pages/chat.js | 390 ++++-------------------------------------
src/pages/dashboard.js | 21 +--
src/style/chat.css | 49 ------
4 files changed, 37 insertions(+), 439 deletions(-)
diff --git a/src/lib/ws-client.js b/src/lib/ws-client.js
index 88b68053..c5f7ad2a 100644
--- a/src/lib/ws-client.js
+++ b/src/lib/ws-client.js
@@ -38,7 +38,6 @@ export class WsClient {
this._connected = false
this._gatewayReady = false
this._handshaking = false
- this._connecting = false
this._intentionalClose = false
this._snapshot = null
this._hello = null
@@ -51,7 +50,6 @@ export class WsClient {
}
get connected() { return this._connected }
- get connecting() { return this._connecting }
get gatewayReady() { return this._gatewayReady }
get snapshot() { return this._snapshot }
get hello() { return this._hello }
@@ -74,12 +72,7 @@ export class WsClient {
this._token = token || ''
// 自动检测协议:如果页面通过 HTTPS 加载(反代场景),使用 wss://
const proto = opts.secure ?? (typeof location !== 'undefined' && location.protocol === 'https:') ? 'wss' : 'ws'
- const nextUrl = `${proto}://${host}/ws?token=${encodeURIComponent(this._token)}`
- if (this._connecting || this._handshaking || this._gatewayReady) {
- if (this._url === nextUrl) return
- }
- if (this._ws && (this._ws.readyState === WebSocket.OPEN || this._ws.readyState === WebSocket.CONNECTING)) return
- this._url = nextUrl
+ this._url = `${proto}://${host}/ws?token=${encodeURIComponent(this._token)}`
this._doConnect()
}
@@ -109,7 +102,6 @@ export class WsClient {
}
_doConnect() {
- this._connecting = true
this._closeWs()
this._gatewayReady = false
this._handshaking = false
@@ -121,7 +113,6 @@ export class WsClient {
ws.onopen = () => {
if (wsId !== this._wsId) return
- this._connecting = false
this._reconnectAttempts = 0
this._setConnected(true)
this._startPing()
@@ -144,7 +135,6 @@ export class WsClient {
ws.onclose = (e) => {
if (wsId !== this._wsId) return
this._ws = null
- this._connecting = false
this._clearChallengeTimer()
if (e.code === 4001 || e.code === 4003 || e.code === 4004) {
this._setConnected(false, 'auth_failed', e.reason || 'Token 认证失败')
@@ -421,6 +411,4 @@ export class WsClient {
}
}
-const _g = typeof window !== 'undefined' ? window : globalThis
-if (!_g.__clawpanelWsClient) _g.__clawpanelWsClient = new WsClient()
-export const wsClient = _g.__clawpanelWsClient
+export const wsClient = new WsClient()
diff --git a/src/pages/chat.js b/src/pages/chat.js
index 6c39aaf1..65bc9431 100644
--- a/src/pages/chat.js
+++ b/src/pages/chat.js
@@ -59,18 +59,12 @@ let _sessionKey = null, _page = null, _messagesEl = null, _textarea = null
let _sendBtn = null, _statusDot = null, _typingEl = null, _scrollBtn = null
let _sessionListEl = null, _cmdPanelEl = null, _attachPreviewEl = null, _fileInputEl = null
let _modelSelectEl = null
-let _currentAiBubble = null, _currentAiText = '', _currentAiImages = [], _currentAiVideos = [], _currentAiAudios = [], _currentAiFiles = [], _currentAiTools = [], _currentRunId = null
+let _currentAiBubble = null, _currentAiText = '', _currentAiImages = [], _currentAiVideos = [], _currentAiAudios = [], _currentAiFiles = [], _currentRunId = null
let _isStreaming = false, _isSending = false, _messageQueue = [], _streamStartTime = 0
let _lastRenderTime = 0, _renderPending = false, _lastHistoryHash = ''
-let _autoScrollEnabled = true, _lastScrollTop = 0, _touchStartY = 0
-let _isLoadingHistory = false
let _streamSafetyTimer = null, _unsubEvent = null, _unsubReady = null, _unsubStatus = null
let _seenRunIds = new Set()
let _pageActive = false
-const _toolEventTimes = new Map()
-const _toolEventData = new Map()
-const _toolRunIndex = new Map()
-const _toolEventSeen = new Set()
let _errorTimer = null, _lastErrorMsg = null
let _attachments = []
let _hasEverConnected = false
@@ -193,7 +187,6 @@ const GUIDE_KEY = 'clawpanel-guide-chat-dismissed'
function showPageGuide(container) {
if (localStorage.getItem(GUIDE_KEY)) return
- if (!container || container.querySelector('.chat-page-guide')) return
const guide = document.createElement('div')
guide.className = 'chat-page-guide'
guide.innerHTML = `
@@ -270,24 +263,8 @@ function bindEvents(page) {
_messagesEl.addEventListener('scroll', () => {
const { scrollTop, scrollHeight, clientHeight } = _messagesEl
_scrollBtn.style.display = (scrollHeight - scrollTop - clientHeight < 80) ? 'none' : 'flex'
- if (scrollTop < _lastScrollTop - 2) _autoScrollEnabled = false
- if (isAtBottom()) _autoScrollEnabled = true
- _lastScrollTop = scrollTop
- })
- _messagesEl.addEventListener('wheel', (e) => {
- if (e.deltaY < 0) _autoScrollEnabled = false
- }, { passive: true })
- _messagesEl.addEventListener('touchstart', (e) => {
- _touchStartY = e.touches?.[0]?.clientY || 0
- }, { passive: true })
- _messagesEl.addEventListener('touchmove', (e) => {
- const y = e.touches?.[0]?.clientY || 0
- if (y > _touchStartY + 2) _autoScrollEnabled = false
- }, { passive: true })
- _scrollBtn.addEventListener('click', () => {
- _autoScrollEnabled = true
- scrollToBottom(true)
})
+ _scrollBtn.addEventListener('click', () => scrollToBottom())
_messagesEl.addEventListener('click', () => hideCmdPanel())
}
@@ -495,7 +472,6 @@ function fileToBase64(file) {
}
function renderAttachments() {
- if (!_attachPreviewEl) return
if (!_attachments.length) {
_attachPreviewEl.style.display = 'none'
return
@@ -599,7 +575,7 @@ async function connectGateway() {
}
// 如果正在连接中(重连等),等待 onReady 回调即可
- if (wsClient.connected || wsClient.connecting || wsClient.gatewayReady) return
+ if (wsClient.connected) return
// 未连接,发起新连接
const config = await api.readOpenclawConfig()
@@ -877,10 +853,6 @@ function toggleCmdPanel() {
function sendMessage() {
const text = _textarea.value.trim()
if (!text && !_attachments.length) return
- if (!wsClient.gatewayReady || !_sessionKey) {
- toast('Gateway 未就绪,连接成功后再发送', 'warning')
- return
- }
hideCmdPanel()
_textarea.value = ''
_textarea.style.height = 'auto'
@@ -893,10 +865,6 @@ function sendMessage() {
}
async function doSend(text, attachments = []) {
- if (!wsClient.gatewayReady || !_sessionKey) {
- toast('Gateway 未就绪,连接成功后再发送', 'warning')
- return
- }
appendUserMessage(text, attachments)
saveMessage({
id: uuid(), sessionKey: _sessionKey, role: 'user', content: text, timestamp: Date.now(),
@@ -932,26 +900,6 @@ function handleEvent(msg) {
const { event, payload } = msg
if (!payload) return
- if (event === 'agent' && payload?.stream === 'tool' && payload?.data?.toolCallId) {
- const ts = payload.ts
- const toolCallId = payload.data.toolCallId
- const runKey = `${payload.runId}:${toolCallId}`
- if (_toolEventSeen.has(runKey)) return
- _toolEventSeen.add(runKey)
- if (ts) _toolEventTimes.set(toolCallId, ts)
- const current = _toolEventData.get(toolCallId) || {}
- if (payload.data?.args && current.input == null) current.input = payload.data.args
- if (payload.data?.meta && current.output == null) current.output = payload.data.meta
- if (typeof payload.data?.isError === 'boolean' && current.status == null) current.status = payload.data.isError ? 'error' : 'ok'
- if (current.time == null) current.time = ts || null
- _toolEventData.set(toolCallId, current)
- if (payload.runId) {
- const list = _toolRunIndex.get(payload.runId) || []
- if (!list.includes(toolCallId)) list.push(toolCallId)
- _toolRunIndex.set(payload.runId, list)
- }
- }
-
if (event === 'chat') handleChatEvent(payload)
// Compaction 状态指示:上游 2026.3.12 新增 status_reaction 事件
@@ -988,7 +936,6 @@ function handleChatEvent(payload) {
if (c?.videos?.length) _currentAiVideos = c.videos
if (c?.audios?.length) _currentAiAudios = c.audios
if (c?.files?.length) _currentAiFiles = c.files
- if (c?.tools?.length) _currentAiTools = c.tools
if (c?.text && c.text.length > _currentAiText.length) {
showTyping(false)
if (!_currentAiBubble) {
@@ -1024,17 +971,11 @@ function handleChatEvent(payload) {
const finalVideos = c?.videos || []
const finalAudios = c?.audios || []
const finalFiles = c?.files || []
- let finalTools = c?.tools || []
- if (!finalTools.length && runId) {
- const ids = _toolRunIndex.get(runId) || []
- finalTools = ids.map(id => mergeToolEventData({ id, name: '工具' })).filter(Boolean)
- }
if (finalImages.length) _currentAiImages = finalImages
if (finalVideos.length) _currentAiVideos = finalVideos
if (finalAudios.length) _currentAiAudios = finalAudios
if (finalFiles.length) _currentAiFiles = finalFiles
- if (finalTools.length) _currentAiTools = finalTools
- const hasContent = finalText || _currentAiImages.length || _currentAiVideos.length || _currentAiAudios.length || _currentAiFiles.length || _currentAiTools.length
+ const hasContent = finalText || _currentAiImages.length || _currentAiVideos.length || _currentAiAudios.length || _currentAiFiles.length
// 忽略空 final(Gateway 会为一条消息触发多个 run,部分是空 final)
if (!_currentAiBubble && !hasContent) return
// 标记 runId 为已处理,防止重复
@@ -1057,7 +998,6 @@ function handleChatEvent(payload) {
appendVideosToEl(_currentAiBubble, _currentAiVideos)
appendAudiosToEl(_currentAiBubble, _currentAiAudios)
appendFilesToEl(_currentAiBubble, _currentAiFiles)
- appendToolsToEl(_currentAiBubble, finalTools.length ? finalTools : _currentAiTools)
}
// 添加时间戳 + 耗时 + token 消耗
const wrapper = _currentAiBubble?.parentElement
@@ -1152,24 +1092,8 @@ function handleChatEvent(payload) {
/** 从 Gateway message 对象提取文本和所有媒体(参照 clawapp extractContent) */
function extractChatContent(message) {
if (!message || typeof message !== 'object') return null
- const tools = []
- collectToolsFromMessage(message, tools)
- if (message.role === 'tool' || message.role === 'toolResult') {
- const output = typeof message.content === 'string' ? message.content : null
- if (!tools.length) {
- tools.push({
- name: message.name || message.tool || message.tool_name || '工具',
- input: message.input || message.args || message.parameters || null,
- output: output || message.output || message.result || null,
- status: message.status || 'ok',
- })
- } else if (output && !tools[0].output) {
- tools[0].output = output
- }
- return { text: '', images: [], videos: [], audios: [], files: [], tools }
- }
const content = message.content
- if (typeof content === 'string') return { text: stripThinkingTags(content), images: [], videos: [], audios: [], files: [], tools }
+ if (typeof content === 'string') return { text: stripThinkingTags(content), images: [], videos: [], audios: [], files: [] }
if (Array.isArray(content)) {
const texts = [], images = [], videos = [], audios = [], files = []
for (const block of content) {
@@ -1191,34 +1115,6 @@ function extractChatContent(message) {
else if (block.type === 'file' || block.type === 'document') {
files.push({ url: block.url || '', name: block.fileName || block.name || '文件', mimeType: block.mimeType || '', size: block.size, data: block.data })
}
- else if (block.type === 'tool' || block.type === 'tool_use' || block.type === 'tool_call' || block.type === 'toolCall') {
- const callId = block.id || block.tool_call_id || block.toolCallId
- upsertTool(tools, {
- id: callId,
- name: block.name || block.tool || block.tool_name || block.toolName || '工具',
- input: block.input || block.args || block.parameters || block.arguments || null,
- output: null,
- status: block.status || 'ok',
- time: resolveToolTime(callId, message.timestamp),
- })
- }
- else if (block.type === 'tool_result' || block.type === 'toolResult') {
- const resId = block.id || block.tool_call_id || block.toolCallId
- upsertTool(tools, {
- id: resId,
- name: block.name || block.tool || block.tool_name || block.toolName || '工具',
- input: block.input || block.args || null,
- output: block.output || block.result || block.content || null,
- status: block.status || 'ok',
- time: resolveToolTime(resId, message.timestamp),
- })
- }
- }
- if (tools.length) {
- tools.forEach(t => {
- if (typeof t.input === 'string') t.input = stripAnsi(t.input)
- if (typeof t.output === 'string') t.output = stripAnsi(t.output)
- })
}
// 从 mediaUrl/mediaUrls 提取
const mediaUrls = message.mediaUrls || (message.mediaUrl ? [message.mediaUrl] : [])
@@ -1230,77 +1126,20 @@ function extractChatContent(message) {
else files.push({ url, name: url.split('/').pop().split('?')[0] || '文件', mimeType: '' })
}
const text = texts.length ? stripThinkingTags(texts.join('\n')) : ''
- return { text, images, videos, audios, files, tools }
+ return { text, images, videos, audios, files }
}
- if (typeof message.text === 'string') return { text: stripThinkingTags(message.text), images: [], videos: [], audios: [], files: [], tools: [] }
+ if (typeof message.text === 'string') return { text: stripThinkingTags(message.text), images: [], videos: [], audios: [], files: [] }
return null
}
-function stripAnsi(text) {
- if (!text) return ''
- return text.replace(/\u001b\[[0-9;]*[A-Za-z]/g, '')
-}
-
-function escapeHtml(text) {
- return (text || '')
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''')
-}
-
function stripThinkingTags(text) {
- const safe = stripAnsi(text)
- return safe
+ return text
.replace(/<\s*think(?:ing)?\s*>[\s\S]*?<\s*\/\s*think(?:ing)?\s*>/gi, '')
.replace(/Conversation info \(untrusted metadata\):\s*```json[\s\S]*?```\s*/gi, '')
.replace(/\[Queued messages while agent was busy\]\s*---\s*Queued #\d+\s*/gi, '')
.trim()
}
-function normalizeTime(raw) {
- if (!raw) return null
- if (raw instanceof Date) return raw.getTime()
- if (typeof raw === 'string') {
- const num = Number(raw)
- if (!Number.isNaN(num)) raw = num
- else {
- const parsed = Date.parse(raw)
- return Number.isNaN(parsed) ? null : parsed
- }
- }
- if (typeof raw === 'number' && raw < 1e12) return raw * 1000
- return raw
-}
-
-function resolveToolTime(toolId, messageTimestamp) {
- const eventTs = toolId ? _toolEventTimes.get(toolId) : null
- return normalizeTime(eventTs) || normalizeTime(messageTimestamp) || null
-}
-
-function getToolTime(tool) {
- const raw = tool?.end_time || tool?.endTime || tool?.timestamp || tool?.time || tool?.started_at || tool?.startedAt || null
- return normalizeTime(raw)
-}
-
-function safeStringify(value) {
- if (value == null) return ''
- const seen = new WeakSet()
- try {
- return JSON.stringify(value, (key, val) => {
- if (typeof val === 'bigint') return val.toString()
- if (typeof val === 'object' && val !== null) {
- if (seen.has(val)) return '[Circular]'
- seen.add(val)
- }
- return val
- }, 2)
- } catch {
- try { return String(value) } catch { return '' }
- }
-}
-
function formatTime(date) {
const now = new Date()
const h = date.getHours().toString().padStart(2, '0')
@@ -1321,7 +1160,6 @@ function formatFileSize(bytes) {
/** 创建流式 AI 气泡 */
function createStreamBubble() {
- if (!_messagesEl || !_typingEl) return null
showTyping(false)
const wrap = document.createElement('div')
wrap.className = 'msg msg-ai'
@@ -1359,13 +1197,12 @@ function doRender() {
function resetStreamState() {
clearTimeout(_streamSafetyTimer)
- if (_currentAiBubble && (_currentAiText || _currentAiImages.length || _currentAiVideos.length || _currentAiAudios.length || _currentAiFiles.length || _currentAiTools.length)) {
+ if (_currentAiBubble && (_currentAiText || _currentAiImages.length || _currentAiVideos.length || _currentAiAudios.length || _currentAiFiles.length)) {
_currentAiBubble.innerHTML = renderMarkdown(_currentAiText)
appendImagesToEl(_currentAiBubble, _currentAiImages)
appendVideosToEl(_currentAiBubble, _currentAiVideos)
appendAudiosToEl(_currentAiBubble, _currentAiAudios)
appendFilesToEl(_currentAiBubble, _currentAiFiles)
- appendToolsToEl(_currentAiBubble, _currentAiTools)
}
_renderPending = false
_lastRenderTime = 0
@@ -1375,7 +1212,6 @@ function resetStreamState() {
_currentAiVideos = []
_currentAiAudios = []
_currentAiFiles = []
- _currentAiTools = []
_currentRunId = null
_isStreaming = false
_streamStartTime = 0
@@ -1388,9 +1224,8 @@ function resetStreamState() {
// ── 历史消息加载 ──
async function loadHistory() {
- if (!_sessionKey || !_messagesEl) return
- _isLoadingHistory = true
- const hasExisting = _messagesEl.querySelector('.msg')
+ if (!_sessionKey) return
+ const hasExisting = _messagesEl?.querySelector('.msg')
if (!hasExisting && isStorageAvailable()) {
const local = await getLocalMessages(_sessionKey, 200)
if (local.length) {
@@ -1401,17 +1236,17 @@ async function loadHistory() {
if (msg.role === 'user') appendUserMessage(msg.content || '', msg.attachments || null, msgTime)
else if (msg.role === 'assistant') {
const images = (msg.attachments || []).filter(a => a.category === 'image').map(a => ({ mediaType: a.mimeType, data: a.content, url: a.url }))
- appendAiMessage(msg.content || '', msgTime, images, [], [], [], [])
+ appendAiMessage(msg.content || '', msgTime, images)
}
})
scrollToBottom()
}
}
- if (!wsClient.gatewayReady) { _isLoadingHistory = false; return }
+ if (!wsClient.gatewayReady) return
try {
const result = await wsClient.chatHistory(_sessionKey, 200)
if (!result?.messages?.length) {
- if (_messagesEl && !_messagesEl.querySelector('.msg')) appendSystemMessage('还没有消息,开始聊天吧')
+ if (!_messagesEl.querySelector('.msg')) appendSystemMessage('还没有消息,开始聊天吧')
return
}
const deduped = dedupeHistory(result.messages)
@@ -1423,17 +1258,15 @@ async function loadHistory() {
if (hasExisting && (_isSending || _isStreaming || _messageQueue.length > 0)) {
saveMessages(result.messages.map(m => {
const c = extractContent(m)
- const role = (m.role === 'tool' || m.role === 'toolResult') ? 'assistant' : m.role
- return { id: m.id || uuid(), sessionKey: _sessionKey, role, content: c?.text || '', timestamp: m.timestamp || Date.now() }
+ return { id: m.id || uuid(), sessionKey: _sessionKey, role: m.role, content: c?.text || '', timestamp: m.timestamp || Date.now() }
}))
- _isLoadingHistory = false
return
}
clearMessages()
let hasOmittedImages = false
deduped.forEach(msg => {
- if (!msg.text && !msg.images?.length && !msg.videos?.length && !msg.audios?.length && !msg.files?.length && !msg.tools?.length) return
+ if (!msg.text && !msg.images?.length && !msg.videos?.length && !msg.audios?.length && !msg.files?.length) return
const msgTime = msg.timestamp ? new Date(msg.timestamp) : new Date()
if (msg.role === 'user') {
const userAtts = msg.images?.length ? msg.images.map(i => ({
@@ -1444,7 +1277,7 @@ async function loadHistory() {
if (msg.images?.length && !userAtts.length) hasOmittedImages = true
appendUserMessage(msg.text, userAtts, msgTime)
} else if (msg.role === 'assistant') {
- appendAiMessage(msg.text, msgTime, msg.images, msg.videos, msg.audios, msg.files, msg.tools)
+ appendAiMessage(msg.text, msgTime, msg.images, msg.videos, msg.audios, msg.files)
}
})
if (hasOmittedImages) {
@@ -1452,33 +1285,25 @@ async function loadHistory() {
}
saveMessages(result.messages.map(m => {
const c = extractContent(m)
- const role = (m.role === 'tool' || m.role === 'toolResult') ? 'assistant' : m.role
- return { id: m.id || uuid(), sessionKey: _sessionKey, role, content: c?.text || '', timestamp: m.timestamp || Date.now() }
+ return { id: m.id || uuid(), sessionKey: _sessionKey, role: m.role, content: c?.text || '', timestamp: m.timestamp || Date.now() }
}))
scrollToBottom()
} catch (e) {
console.error('[chat] loadHistory error:', e)
- if (_messagesEl && !_messagesEl.querySelector('.msg')) appendSystemMessage('加载历史失败: ' + e.message)
- } finally {
- _isLoadingHistory = false
+ if (!_messagesEl.querySelector('.msg')) appendSystemMessage('加载历史失败: ' + e.message)
}
}
function dedupeHistory(messages) {
const deduped = []
for (const msg of messages) {
- const role = (msg.role === 'tool' || msg.role === 'toolResult') ? 'assistant' : msg.role
+ if (msg.role === 'toolResult') continue
const c = extractContent(msg)
- if (!c.text && !c.images.length && !c.videos.length && !c.audios.length && !c.files.length && !c.tools.length) continue
- const tools = (c.tools || []).map(t => {
- const id = t.id || t.tool_call_id
- const time = t.time || resolveToolTime(id, msg.timestamp)
- return { ...t, time, messageTimestamp: msg.timestamp }
- })
+ if (!c.text && !c.images.length && !c.videos.length && !c.audios.length && !c.files.length) continue
const last = deduped[deduped.length - 1]
- if (last && last.role === role) {
- if (role === 'user' && last.text === c.text) continue
- if (role === 'assistant') {
+ if (last && last.role === msg.role) {
+ if (msg.role === 'user' && last.text === c.text) continue
+ if (msg.role === 'assistant') {
// 同文本去重(Gateway 重试产生的重复回复)
if (c.text && last.text === c.text) continue
// 不同文本则合并
@@ -1487,34 +1312,15 @@ function dedupeHistory(messages) {
last.videos = [...(last.videos || []), ...c.videos]
last.audios = [...(last.audios || []), ...c.audios]
last.files = [...(last.files || []), ...c.files]
- tools.forEach(t => upsertTool(last.tools, t))
continue
}
}
- deduped.push({ role, text: c.text, images: c.images, videos: c.videos, audios: c.audios, files: c.files, tools, timestamp: msg.timestamp })
+ deduped.push({ role: msg.role, text: c.text, images: c.images, videos: c.videos, audios: c.audios, files: c.files, timestamp: msg.timestamp })
}
return deduped
}
function extractContent(msg) {
- const tools = []
- collectToolsFromMessage(msg, tools)
- if (msg.role === 'tool' || msg.role === 'toolResult') {
- const output = typeof msg.content === 'string' ? msg.content : null
- if (!tools.length) {
- upsertTool(tools, {
- id: msg.id || msg.tool_call_id || msg.toolCallId,
- name: msg.name || msg.tool || msg.tool_name || '工具',
- input: msg.input || msg.args || msg.parameters || null,
- output: output || msg.output || msg.result || null,
- status: msg.status || 'ok',
- time: resolveToolTime(msg.tool_call_id || msg.toolCallId || msg.id, msg.timestamp),
- })
- } else if (output && !tools[0].output) {
- tools[0].output = output
- }
- return { text: '', images: [], videos: [], audios: [], files: [], tools }
- }
if (Array.isArray(msg.content)) {
const texts = [], images = [], videos = [], audios = [], files = []
for (const block of msg.content) {
@@ -1536,34 +1342,6 @@ function extractContent(msg) {
else if (block.type === 'file' || block.type === 'document') {
files.push({ url: block.url || '', name: block.fileName || block.name || '文件', mimeType: block.mimeType || '', size: block.size, data: block.data })
}
- else if (block.type === 'tool' || block.type === 'tool_use' || block.type === 'tool_call' || block.type === 'toolCall') {
- const callId = block.id || block.tool_call_id || block.toolCallId
- upsertTool(tools, {
- id: callId,
- name: block.name || block.tool || block.tool_name || block.toolName || '工具',
- input: block.input || block.args || block.parameters || block.arguments || null,
- output: null,
- status: block.status || 'ok',
- time: resolveToolTime(callId, msg.timestamp),
- })
- }
- else if (block.type === 'tool_result' || block.type === 'toolResult') {
- const resId = block.id || block.tool_call_id || block.toolCallId
- upsertTool(tools, {
- id: resId,
- name: block.name || block.tool || block.tool_name || block.toolName || '工具',
- input: block.input || block.args || null,
- output: block.output || block.result || block.content || null,
- status: block.status || 'ok',
- time: resolveToolTime(resId, msg.timestamp),
- })
- }
- }
- if (tools.length) {
- tools.forEach(t => {
- if (typeof t.input === 'string') t.input = stripAnsi(t.input)
- if (typeof t.output === 'string') t.output = stripAnsi(t.output)
- })
}
const mediaUrls = msg.mediaUrls || (msg.mediaUrl ? [msg.mediaUrl] : [])
for (const url of mediaUrls) {
@@ -1573,10 +1351,10 @@ function extractContent(msg) {
else if (/\.(jpe?g|png|gif|webp|heic|svg)(\?|$)/i.test(url)) images.push({ url, mediaType: 'image/png' })
else files.push({ url, name: url.split('/').pop().split('?')[0] || '文件', mimeType: '' })
}
- return { text: stripThinkingTags(texts.join('\n')), images, videos, audios, files, tools }
+ return { text: stripThinkingTags(texts.join('\n')), images, videos, audios, files }
}
const text = typeof msg.text === 'string' ? msg.text : (typeof msg.content === 'string' ? msg.content : '')
- return { text: stripThinkingTags(text), images: [], videos: [], audios: [], files: [], tools }
+ return { text: stripThinkingTags(text), images: [], videos: [], audios: [], files: [] }
}
// ── DOM 操作 ──
@@ -1642,16 +1420,12 @@ function appendUserMessage(text, attachments = [], msgTime) {
scrollToBottom()
}
-function appendAiMessage(text, msgTime, images, videos, audios, files, tools) {
+function appendAiMessage(text, msgTime, images, videos, audios, files) {
const wrap = document.createElement('div')
wrap.className = 'msg msg-ai'
const bubble = document.createElement('div')
bubble.className = 'msg-bubble'
- appendToolsToEl(bubble, tools)
- const textEl = document.createElement('div')
- textEl.className = 'msg-text'
- textEl.innerHTML = renderMarkdown(text || '')
- bubble.appendChild(textEl)
+ bubble.innerHTML = renderMarkdown(text)
appendImagesToEl(bubble, images)
appendVideosToEl(bubble, videos)
appendAudiosToEl(bubble, audios)
@@ -1754,101 +1528,6 @@ function appendFilesToEl(el, files) {
})
}
-function mergeToolEventData(entry) {
- const id = entry?.id || entry?.tool_call_id
- if (!id) return entry
- const extra = _toolEventData.get(id)
- if (!extra) return entry
- if (entry.input == null && extra.input != null) entry.input = extra.input
- if (entry.output == null && extra.output != null) entry.output = extra.output
- if (entry.status == null && extra.status != null) entry.status = extra.status
- if (entry.time == null) entry.time = extra.time || _toolEventTimes.get(id) || null
- return entry
-}
-
-function upsertTool(tools, entry) {
- if (!entry) return
- const id = entry.id || entry.tool_call_id
- let target = null
- if (id) target = tools.find(t => t.id === id || t.tool_call_id === id)
- if (!target && entry.name) target = tools.find(t => t.name === entry.name && !t.output)
- if (target) {
- if (entry.input != null && target.input == null) target.input = entry.input
- if (entry.output != null && target.output == null) target.output = entry.output
- if (entry.status && target.status == null) target.status = entry.status
- if (entry.time && target.time == null) target.time = entry.time
- return
- }
- tools.push(mergeToolEventData(entry))
-}
-
-function collectToolsFromMessage(message, tools) {
- if (!message || !tools) return
- const toolCalls = message.tool_calls || message.toolCalls || message.tools
- if (Array.isArray(toolCalls)) {
- toolCalls.forEach(call => {
- const fn = call.function || null
- const name = call.name || call.tool || call.tool_name || fn?.name
- const input = call.input || call.args || call.parameters || call.arguments || fn?.arguments || null
- const callId = call.id || call.tool_call_id
- upsertTool(tools, {
- id: callId,
- name: name || '工具',
- input,
- output: null,
- status: call.status || 'ok',
- time: resolveToolTime(callId, message?.timestamp),
- })
- })
- }
- const toolResults = message.tool_results || message.toolResults
- if (Array.isArray(toolResults)) {
- toolResults.forEach(res => {
- const resId = res.id || res.tool_call_id
- upsertTool(tools, {
- id: resId,
- name: res.name || res.tool || res.tool_name || '工具',
- input: res.input || res.args || null,
- output: res.output || res.result || res.content || null,
- status: res.status || 'ok',
- time: resolveToolTime(resId, message?.timestamp),
- })
- })
- }
-}
-
-/** 渲染工具调用到消息气泡 */
-function appendToolsToEl(el, tools) {
- if (!el) return
- const existing = el.querySelector?.('.msg-tool')
- if (!tools?.length) {
- if (existing) existing.remove()
- return
- }
- const container = document.createElement('div')
- container.className = 'msg-tool'
- tools.forEach(tool => {
- const details = document.createElement('details')
- details.className = 'msg-tool-item'
- const summary = document.createElement('summary')
- const status = tool.status === 'error' ? '失败' : '成功'
- const timeValue = getToolTime(tool) || resolveToolTime(tool.id || tool.tool_call_id, tool.messageTimestamp)
- const timeText = timeValue ? formatTime(new Date(timeValue)) : ''
- summary.innerHTML = `${escapeHtml(tool.name || '工具')} · ${status}${timeText ? ' · ' + timeText : ''}`
- const body = document.createElement('div')
- body.className = 'msg-tool-body'
- const inputJson = stripAnsi(safeStringify(tool.input))
- const outputJson = stripAnsi(safeStringify(tool.output))
- body.innerHTML = `
`
- + `
`
- details.appendChild(summary)
- details.appendChild(body)
- container.appendChild(details)
- })
- if (existing) existing.remove()
- el.insertBefore(container, el.firstChild)
-}
-
/** 图片灯箱查看 */
function showLightbox(src) {
const existing = document.querySelector('.chat-lightbox')
@@ -1873,8 +1552,6 @@ function appendSystemMessage(text) {
function clearMessages() {
_messagesEl.querySelectorAll('.msg').forEach(m => m.remove())
- _autoScrollEnabled = true
- _lastScrollTop = 0
}
function showTyping(show) {
@@ -1896,17 +1573,11 @@ function showCompactionHint(show) {
}
}
-function scrollToBottom(force = false) {
+function scrollToBottom() {
if (!_messagesEl) return
- if (!force && !_autoScrollEnabled) return
requestAnimationFrame(() => { _messagesEl.scrollTop = _messagesEl.scrollHeight })
}
-function isAtBottom() {
- if (!_messagesEl) return true
- return _messagesEl.scrollHeight - _messagesEl.scrollTop - _messagesEl.clientHeight < 80
-}
-
function updateSendState() {
if (!_sendBtn || !_textarea) return
if (_isStreaming) {
@@ -1953,7 +1624,6 @@ export function cleanup() {
_currentAiVideos = []
_currentAiAudios = []
_currentAiFiles = []
- _currentAiTools = []
_currentRunId = null
_isStreaming = false
_isSending = false
diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js
index fcc7e13c..e716fc03 100644
--- a/src/pages/dashboard.js
+++ b/src/pages/dashboard.js
@@ -41,14 +41,7 @@ export async function render() {
bindActions(page)
// 异步加载数据
- loadDashboardData(page).catch(e => {
- console.error('[dashboard] loadDashboardData 异常:', e)
- const cardsEl = page.querySelector('#stat-cards')
- if (cardsEl && cardsEl.querySelector('.loading-placeholder')) {
- cardsEl.innerHTML = `
加载失败: ${escapeHtml(String(e?.message || e))}
`
- }
- })
- page.__retryLoad = () => loadDashboardData(page).catch(() => {})
+ loadDashboardData(page)
// 监听 Gateway 状态变化,自动刷新仪表盘
if (_unsubGw) _unsubGw()
@@ -68,23 +61,19 @@ let _dashboardInitialized = false
async function loadDashboardData(page, fullRefresh = false) {
// 分波加载:关键数据先渲染,次要数据后填充,减少白屏等待
// 轻量调用(读文件)每次都做;重量调用(spawn CLI/网络请求)只在首次或手动刷新时做
- const withTimeout = (promise, ms) => Promise.race([
- promise,
- new Promise((_, reject) => setTimeout(() => reject(new Error(`超时(${ms/1000}s)`)), ms))
- ])
- const coreP = withTimeout(Promise.allSettled([
+ const coreP = Promise.allSettled([
api.getServicesStatus(),
api.readOpenclawConfig(),
// 版本信息:首次加载或手动刷新时才查询(避免 ARM 设备上频繁查 npm registry)
(!_dashboardInitialized || fullRefresh) ? api.getVersionInfo() : Promise.resolve(null),
- ]), 15000)
- const secondaryP = withTimeout(Promise.allSettled([
+ ])
+ const secondaryP = Promise.allSettled([
api.listAgents(),
api.readMcpConfig(),
api.listBackups(),
// getStatusSummary 是最重的调用(spawn openclaw status --json),只在首次加载时调用
(!_dashboardInitialized || fullRefresh) ? api.getStatusSummary() : Promise.resolve(null),
- ]), 15000).catch(() => [{ status: 'rejected' }, { status: 'rejected' }, { status: 'rejected' }, { status: 'rejected' }])
+ ])
const logsP = api.readLogTail('gateway', 20).catch(() => '')
// 第一波:服务状态 + 配置 + 版本 → 立即渲染统计卡片
diff --git a/src/style/chat.css b/src/style/chat.css
index 63fbacad..7c91fe44 100644
--- a/src/style/chat.css
+++ b/src/style/chat.css
@@ -855,55 +855,6 @@
color: var(--text-tertiary);
}
-/* 工具调用 */
-.msg-tool {
- margin-bottom: 8px;
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-.msg-tool-item {
- border: 1px solid var(--border-primary, var(--border));
- background: var(--bg-tertiary, var(--bg-secondary));
- border-radius: var(--radius-md, 8px);
- padding: 8px 10px;
-}
-.msg-tool-item > summary {
- cursor: pointer;
- font-size: 12px;
- color: var(--text-secondary);
- list-style: none;
-}
-.msg-tool-item > summary::-webkit-details-marker {
- display: none;
-}
-.msg-tool-body {
- margin-top: 8px;
- display: none;
- gap: 8px;
-}
-.msg-tool-item[open] > .msg-tool-body {
- display: grid;
-}
-.msg-tool-block {
- background: var(--bg-primary, var(--bg));
- border: 1px solid var(--border-primary, var(--border));
- border-radius: var(--radius-sm, 4px);
- padding: 8px 10px;
-}
-.msg-tool-title {
- font-size: 11px;
- color: var(--text-tertiary);
- margin-bottom: 6px;
-}
-.msg-tool-block pre {
- margin: 0;
- white-space: pre-wrap;
- word-break: break-word;
- font-size: 11px;
- color: var(--text-primary);
-}
-
/* 首次引导提示 */
.chat-page-guide {
margin: 0 16px 8px;
From 60d2003c47045ad3fca4667e8c51fd6fefa84a98 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?=
Date: Tue, 17 Mar 2026 17:03:51 +0800
Subject: [PATCH 207/426] fix: cherry-pick PR#94 improvements + dashboard
loading fix
- ws-client: connection dedup (_connecting state), connect() guard, global singleton
- chat: 8 null guards (sendMessage/doSend/createStreamBubble/renderAttachments/showPageGuide/loadHistory)
- chat: auto-scroll control (wheel/touch/scrollBtn, disable on scroll-up)
- chat: tool call rendering (appendToolsToEl, collectToolsFromMessage, upsertTool, mergeToolEventData)
- chat: tool event tracking (agent tool events -> _toolEventData/_toolRunIndex)
- chat: extractChatContent/extractContent/dedupeHistory full tools support
- chat.css: .msg-tool collapsible card styles
- dashboard: .catch() on loadDashboardData fire-and-forget, error state + retry button
---
src/lib/ws-client.js | 16 +-
src/pages/chat.js | 390 +++++++++++++++++++++++++++++++++++++----
src/pages/dashboard.js | 21 ++-
src/style/chat.css | 49 ++++++
4 files changed, 439 insertions(+), 37 deletions(-)
diff --git a/src/lib/ws-client.js b/src/lib/ws-client.js
index c5f7ad2a..88b68053 100644
--- a/src/lib/ws-client.js
+++ b/src/lib/ws-client.js
@@ -38,6 +38,7 @@ export class WsClient {
this._connected = false
this._gatewayReady = false
this._handshaking = false
+ this._connecting = false
this._intentionalClose = false
this._snapshot = null
this._hello = null
@@ -50,6 +51,7 @@ export class WsClient {
}
get connected() { return this._connected }
+ get connecting() { return this._connecting }
get gatewayReady() { return this._gatewayReady }
get snapshot() { return this._snapshot }
get hello() { return this._hello }
@@ -72,7 +74,12 @@ export class WsClient {
this._token = token || ''
// 自动检测协议:如果页面通过 HTTPS 加载(反代场景),使用 wss://
const proto = opts.secure ?? (typeof location !== 'undefined' && location.protocol === 'https:') ? 'wss' : 'ws'
- this._url = `${proto}://${host}/ws?token=${encodeURIComponent(this._token)}`
+ const nextUrl = `${proto}://${host}/ws?token=${encodeURIComponent(this._token)}`
+ if (this._connecting || this._handshaking || this._gatewayReady) {
+ if (this._url === nextUrl) return
+ }
+ if (this._ws && (this._ws.readyState === WebSocket.OPEN || this._ws.readyState === WebSocket.CONNECTING)) return
+ this._url = nextUrl
this._doConnect()
}
@@ -102,6 +109,7 @@ export class WsClient {
}
_doConnect() {
+ this._connecting = true
this._closeWs()
this._gatewayReady = false
this._handshaking = false
@@ -113,6 +121,7 @@ export class WsClient {
ws.onopen = () => {
if (wsId !== this._wsId) return
+ this._connecting = false
this._reconnectAttempts = 0
this._setConnected(true)
this._startPing()
@@ -135,6 +144,7 @@ export class WsClient {
ws.onclose = (e) => {
if (wsId !== this._wsId) return
this._ws = null
+ this._connecting = false
this._clearChallengeTimer()
if (e.code === 4001 || e.code === 4003 || e.code === 4004) {
this._setConnected(false, 'auth_failed', e.reason || 'Token 认证失败')
@@ -411,4 +421,6 @@ export class WsClient {
}
}
-export const wsClient = new WsClient()
+const _g = typeof window !== 'undefined' ? window : globalThis
+if (!_g.__clawpanelWsClient) _g.__clawpanelWsClient = new WsClient()
+export const wsClient = _g.__clawpanelWsClient
diff --git a/src/pages/chat.js b/src/pages/chat.js
index 65bc9431..6c39aaf1 100644
--- a/src/pages/chat.js
+++ b/src/pages/chat.js
@@ -59,12 +59,18 @@ let _sessionKey = null, _page = null, _messagesEl = null, _textarea = null
let _sendBtn = null, _statusDot = null, _typingEl = null, _scrollBtn = null
let _sessionListEl = null, _cmdPanelEl = null, _attachPreviewEl = null, _fileInputEl = null
let _modelSelectEl = null
-let _currentAiBubble = null, _currentAiText = '', _currentAiImages = [], _currentAiVideos = [], _currentAiAudios = [], _currentAiFiles = [], _currentRunId = null
+let _currentAiBubble = null, _currentAiText = '', _currentAiImages = [], _currentAiVideos = [], _currentAiAudios = [], _currentAiFiles = [], _currentAiTools = [], _currentRunId = null
let _isStreaming = false, _isSending = false, _messageQueue = [], _streamStartTime = 0
let _lastRenderTime = 0, _renderPending = false, _lastHistoryHash = ''
+let _autoScrollEnabled = true, _lastScrollTop = 0, _touchStartY = 0
+let _isLoadingHistory = false
let _streamSafetyTimer = null, _unsubEvent = null, _unsubReady = null, _unsubStatus = null
let _seenRunIds = new Set()
let _pageActive = false
+const _toolEventTimes = new Map()
+const _toolEventData = new Map()
+const _toolRunIndex = new Map()
+const _toolEventSeen = new Set()
let _errorTimer = null, _lastErrorMsg = null
let _attachments = []
let _hasEverConnected = false
@@ -187,6 +193,7 @@ const GUIDE_KEY = 'clawpanel-guide-chat-dismissed'
function showPageGuide(container) {
if (localStorage.getItem(GUIDE_KEY)) return
+ if (!container || container.querySelector('.chat-page-guide')) return
const guide = document.createElement('div')
guide.className = 'chat-page-guide'
guide.innerHTML = `
@@ -263,8 +270,24 @@ function bindEvents(page) {
_messagesEl.addEventListener('scroll', () => {
const { scrollTop, scrollHeight, clientHeight } = _messagesEl
_scrollBtn.style.display = (scrollHeight - scrollTop - clientHeight < 80) ? 'none' : 'flex'
+ if (scrollTop < _lastScrollTop - 2) _autoScrollEnabled = false
+ if (isAtBottom()) _autoScrollEnabled = true
+ _lastScrollTop = scrollTop
+ })
+ _messagesEl.addEventListener('wheel', (e) => {
+ if (e.deltaY < 0) _autoScrollEnabled = false
+ }, { passive: true })
+ _messagesEl.addEventListener('touchstart', (e) => {
+ _touchStartY = e.touches?.[0]?.clientY || 0
+ }, { passive: true })
+ _messagesEl.addEventListener('touchmove', (e) => {
+ const y = e.touches?.[0]?.clientY || 0
+ if (y > _touchStartY + 2) _autoScrollEnabled = false
+ }, { passive: true })
+ _scrollBtn.addEventListener('click', () => {
+ _autoScrollEnabled = true
+ scrollToBottom(true)
})
- _scrollBtn.addEventListener('click', () => scrollToBottom())
_messagesEl.addEventListener('click', () => hideCmdPanel())
}
@@ -472,6 +495,7 @@ function fileToBase64(file) {
}
function renderAttachments() {
+ if (!_attachPreviewEl) return
if (!_attachments.length) {
_attachPreviewEl.style.display = 'none'
return
@@ -575,7 +599,7 @@ async function connectGateway() {
}
// 如果正在连接中(重连等),等待 onReady 回调即可
- if (wsClient.connected) return
+ if (wsClient.connected || wsClient.connecting || wsClient.gatewayReady) return
// 未连接,发起新连接
const config = await api.readOpenclawConfig()
@@ -853,6 +877,10 @@ function toggleCmdPanel() {
function sendMessage() {
const text = _textarea.value.trim()
if (!text && !_attachments.length) return
+ if (!wsClient.gatewayReady || !_sessionKey) {
+ toast('Gateway 未就绪,连接成功后再发送', 'warning')
+ return
+ }
hideCmdPanel()
_textarea.value = ''
_textarea.style.height = 'auto'
@@ -865,6 +893,10 @@ function sendMessage() {
}
async function doSend(text, attachments = []) {
+ if (!wsClient.gatewayReady || !_sessionKey) {
+ toast('Gateway 未就绪,连接成功后再发送', 'warning')
+ return
+ }
appendUserMessage(text, attachments)
saveMessage({
id: uuid(), sessionKey: _sessionKey, role: 'user', content: text, timestamp: Date.now(),
@@ -900,6 +932,26 @@ function handleEvent(msg) {
const { event, payload } = msg
if (!payload) return
+ if (event === 'agent' && payload?.stream === 'tool' && payload?.data?.toolCallId) {
+ const ts = payload.ts
+ const toolCallId = payload.data.toolCallId
+ const runKey = `${payload.runId}:${toolCallId}`
+ if (_toolEventSeen.has(runKey)) return
+ _toolEventSeen.add(runKey)
+ if (ts) _toolEventTimes.set(toolCallId, ts)
+ const current = _toolEventData.get(toolCallId) || {}
+ if (payload.data?.args && current.input == null) current.input = payload.data.args
+ if (payload.data?.meta && current.output == null) current.output = payload.data.meta
+ if (typeof payload.data?.isError === 'boolean' && current.status == null) current.status = payload.data.isError ? 'error' : 'ok'
+ if (current.time == null) current.time = ts || null
+ _toolEventData.set(toolCallId, current)
+ if (payload.runId) {
+ const list = _toolRunIndex.get(payload.runId) || []
+ if (!list.includes(toolCallId)) list.push(toolCallId)
+ _toolRunIndex.set(payload.runId, list)
+ }
+ }
+
if (event === 'chat') handleChatEvent(payload)
// Compaction 状态指示:上游 2026.3.12 新增 status_reaction 事件
@@ -936,6 +988,7 @@ function handleChatEvent(payload) {
if (c?.videos?.length) _currentAiVideos = c.videos
if (c?.audios?.length) _currentAiAudios = c.audios
if (c?.files?.length) _currentAiFiles = c.files
+ if (c?.tools?.length) _currentAiTools = c.tools
if (c?.text && c.text.length > _currentAiText.length) {
showTyping(false)
if (!_currentAiBubble) {
@@ -971,11 +1024,17 @@ function handleChatEvent(payload) {
const finalVideos = c?.videos || []
const finalAudios = c?.audios || []
const finalFiles = c?.files || []
+ let finalTools = c?.tools || []
+ if (!finalTools.length && runId) {
+ const ids = _toolRunIndex.get(runId) || []
+ finalTools = ids.map(id => mergeToolEventData({ id, name: '工具' })).filter(Boolean)
+ }
if (finalImages.length) _currentAiImages = finalImages
if (finalVideos.length) _currentAiVideos = finalVideos
if (finalAudios.length) _currentAiAudios = finalAudios
if (finalFiles.length) _currentAiFiles = finalFiles
- const hasContent = finalText || _currentAiImages.length || _currentAiVideos.length || _currentAiAudios.length || _currentAiFiles.length
+ if (finalTools.length) _currentAiTools = finalTools
+ const hasContent = finalText || _currentAiImages.length || _currentAiVideos.length || _currentAiAudios.length || _currentAiFiles.length || _currentAiTools.length
// 忽略空 final(Gateway 会为一条消息触发多个 run,部分是空 final)
if (!_currentAiBubble && !hasContent) return
// 标记 runId 为已处理,防止重复
@@ -998,6 +1057,7 @@ function handleChatEvent(payload) {
appendVideosToEl(_currentAiBubble, _currentAiVideos)
appendAudiosToEl(_currentAiBubble, _currentAiAudios)
appendFilesToEl(_currentAiBubble, _currentAiFiles)
+ appendToolsToEl(_currentAiBubble, finalTools.length ? finalTools : _currentAiTools)
}
// 添加时间戳 + 耗时 + token 消耗
const wrapper = _currentAiBubble?.parentElement
@@ -1092,8 +1152,24 @@ function handleChatEvent(payload) {
/** 从 Gateway message 对象提取文本和所有媒体(参照 clawapp extractContent) */
function extractChatContent(message) {
if (!message || typeof message !== 'object') return null
+ const tools = []
+ collectToolsFromMessage(message, tools)
+ if (message.role === 'tool' || message.role === 'toolResult') {
+ const output = typeof message.content === 'string' ? message.content : null
+ if (!tools.length) {
+ tools.push({
+ name: message.name || message.tool || message.tool_name || '工具',
+ input: message.input || message.args || message.parameters || null,
+ output: output || message.output || message.result || null,
+ status: message.status || 'ok',
+ })
+ } else if (output && !tools[0].output) {
+ tools[0].output = output
+ }
+ return { text: '', images: [], videos: [], audios: [], files: [], tools }
+ }
const content = message.content
- if (typeof content === 'string') return { text: stripThinkingTags(content), images: [], videos: [], audios: [], files: [] }
+ if (typeof content === 'string') return { text: stripThinkingTags(content), images: [], videos: [], audios: [], files: [], tools }
if (Array.isArray(content)) {
const texts = [], images = [], videos = [], audios = [], files = []
for (const block of content) {
@@ -1115,6 +1191,34 @@ function extractChatContent(message) {
else if (block.type === 'file' || block.type === 'document') {
files.push({ url: block.url || '', name: block.fileName || block.name || '文件', mimeType: block.mimeType || '', size: block.size, data: block.data })
}
+ else if (block.type === 'tool' || block.type === 'tool_use' || block.type === 'tool_call' || block.type === 'toolCall') {
+ const callId = block.id || block.tool_call_id || block.toolCallId
+ upsertTool(tools, {
+ id: callId,
+ name: block.name || block.tool || block.tool_name || block.toolName || '工具',
+ input: block.input || block.args || block.parameters || block.arguments || null,
+ output: null,
+ status: block.status || 'ok',
+ time: resolveToolTime(callId, message.timestamp),
+ })
+ }
+ else if (block.type === 'tool_result' || block.type === 'toolResult') {
+ const resId = block.id || block.tool_call_id || block.toolCallId
+ upsertTool(tools, {
+ id: resId,
+ name: block.name || block.tool || block.tool_name || block.toolName || '工具',
+ input: block.input || block.args || null,
+ output: block.output || block.result || block.content || null,
+ status: block.status || 'ok',
+ time: resolveToolTime(resId, message.timestamp),
+ })
+ }
+ }
+ if (tools.length) {
+ tools.forEach(t => {
+ if (typeof t.input === 'string') t.input = stripAnsi(t.input)
+ if (typeof t.output === 'string') t.output = stripAnsi(t.output)
+ })
}
// 从 mediaUrl/mediaUrls 提取
const mediaUrls = message.mediaUrls || (message.mediaUrl ? [message.mediaUrl] : [])
@@ -1126,20 +1230,77 @@ function extractChatContent(message) {
else files.push({ url, name: url.split('/').pop().split('?')[0] || '文件', mimeType: '' })
}
const text = texts.length ? stripThinkingTags(texts.join('\n')) : ''
- return { text, images, videos, audios, files }
+ return { text, images, videos, audios, files, tools }
}
- if (typeof message.text === 'string') return { text: stripThinkingTags(message.text), images: [], videos: [], audios: [], files: [] }
+ if (typeof message.text === 'string') return { text: stripThinkingTags(message.text), images: [], videos: [], audios: [], files: [], tools: [] }
return null
}
+function stripAnsi(text) {
+ if (!text) return ''
+ return text.replace(/\u001b\[[0-9;]*[A-Za-z]/g, '')
+}
+
+function escapeHtml(text) {
+ return (text || '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+}
+
function stripThinkingTags(text) {
- return text
+ const safe = stripAnsi(text)
+ return safe
.replace(/<\s*think(?:ing)?\s*>[\s\S]*?<\s*\/\s*think(?:ing)?\s*>/gi, '')
.replace(/Conversation info \(untrusted metadata\):\s*```json[\s\S]*?```\s*/gi, '')
.replace(/\[Queued messages while agent was busy\]\s*---\s*Queued #\d+\s*/gi, '')
.trim()
}
+function normalizeTime(raw) {
+ if (!raw) return null
+ if (raw instanceof Date) return raw.getTime()
+ if (typeof raw === 'string') {
+ const num = Number(raw)
+ if (!Number.isNaN(num)) raw = num
+ else {
+ const parsed = Date.parse(raw)
+ return Number.isNaN(parsed) ? null : parsed
+ }
+ }
+ if (typeof raw === 'number' && raw < 1e12) return raw * 1000
+ return raw
+}
+
+function resolveToolTime(toolId, messageTimestamp) {
+ const eventTs = toolId ? _toolEventTimes.get(toolId) : null
+ return normalizeTime(eventTs) || normalizeTime(messageTimestamp) || null
+}
+
+function getToolTime(tool) {
+ const raw = tool?.end_time || tool?.endTime || tool?.timestamp || tool?.time || tool?.started_at || tool?.startedAt || null
+ return normalizeTime(raw)
+}
+
+function safeStringify(value) {
+ if (value == null) return ''
+ const seen = new WeakSet()
+ try {
+ return JSON.stringify(value, (key, val) => {
+ if (typeof val === 'bigint') return val.toString()
+ if (typeof val === 'object' && val !== null) {
+ if (seen.has(val)) return '[Circular]'
+ seen.add(val)
+ }
+ return val
+ }, 2)
+ } catch {
+ try { return String(value) } catch { return '' }
+ }
+}
+
function formatTime(date) {
const now = new Date()
const h = date.getHours().toString().padStart(2, '0')
@@ -1160,6 +1321,7 @@ function formatFileSize(bytes) {
/** 创建流式 AI 气泡 */
function createStreamBubble() {
+ if (!_messagesEl || !_typingEl) return null
showTyping(false)
const wrap = document.createElement('div')
wrap.className = 'msg msg-ai'
@@ -1197,12 +1359,13 @@ function doRender() {
function resetStreamState() {
clearTimeout(_streamSafetyTimer)
- if (_currentAiBubble && (_currentAiText || _currentAiImages.length || _currentAiVideos.length || _currentAiAudios.length || _currentAiFiles.length)) {
+ if (_currentAiBubble && (_currentAiText || _currentAiImages.length || _currentAiVideos.length || _currentAiAudios.length || _currentAiFiles.length || _currentAiTools.length)) {
_currentAiBubble.innerHTML = renderMarkdown(_currentAiText)
appendImagesToEl(_currentAiBubble, _currentAiImages)
appendVideosToEl(_currentAiBubble, _currentAiVideos)
appendAudiosToEl(_currentAiBubble, _currentAiAudios)
appendFilesToEl(_currentAiBubble, _currentAiFiles)
+ appendToolsToEl(_currentAiBubble, _currentAiTools)
}
_renderPending = false
_lastRenderTime = 0
@@ -1212,6 +1375,7 @@ function resetStreamState() {
_currentAiVideos = []
_currentAiAudios = []
_currentAiFiles = []
+ _currentAiTools = []
_currentRunId = null
_isStreaming = false
_streamStartTime = 0
@@ -1224,8 +1388,9 @@ function resetStreamState() {
// ── 历史消息加载 ──
async function loadHistory() {
- if (!_sessionKey) return
- const hasExisting = _messagesEl?.querySelector('.msg')
+ if (!_sessionKey || !_messagesEl) return
+ _isLoadingHistory = true
+ const hasExisting = _messagesEl.querySelector('.msg')
if (!hasExisting && isStorageAvailable()) {
const local = await getLocalMessages(_sessionKey, 200)
if (local.length) {
@@ -1236,17 +1401,17 @@ async function loadHistory() {
if (msg.role === 'user') appendUserMessage(msg.content || '', msg.attachments || null, msgTime)
else if (msg.role === 'assistant') {
const images = (msg.attachments || []).filter(a => a.category === 'image').map(a => ({ mediaType: a.mimeType, data: a.content, url: a.url }))
- appendAiMessage(msg.content || '', msgTime, images)
+ appendAiMessage(msg.content || '', msgTime, images, [], [], [], [])
}
})
scrollToBottom()
}
}
- if (!wsClient.gatewayReady) return
+ if (!wsClient.gatewayReady) { _isLoadingHistory = false; return }
try {
const result = await wsClient.chatHistory(_sessionKey, 200)
if (!result?.messages?.length) {
- if (!_messagesEl.querySelector('.msg')) appendSystemMessage('还没有消息,开始聊天吧')
+ if (_messagesEl && !_messagesEl.querySelector('.msg')) appendSystemMessage('还没有消息,开始聊天吧')
return
}
const deduped = dedupeHistory(result.messages)
@@ -1258,15 +1423,17 @@ async function loadHistory() {
if (hasExisting && (_isSending || _isStreaming || _messageQueue.length > 0)) {
saveMessages(result.messages.map(m => {
const c = extractContent(m)
- return { id: m.id || uuid(), sessionKey: _sessionKey, role: m.role, content: c?.text || '', timestamp: m.timestamp || Date.now() }
+ const role = (m.role === 'tool' || m.role === 'toolResult') ? 'assistant' : m.role
+ return { id: m.id || uuid(), sessionKey: _sessionKey, role, content: c?.text || '', timestamp: m.timestamp || Date.now() }
}))
+ _isLoadingHistory = false
return
}
clearMessages()
let hasOmittedImages = false
deduped.forEach(msg => {
- if (!msg.text && !msg.images?.length && !msg.videos?.length && !msg.audios?.length && !msg.files?.length) return
+ if (!msg.text && !msg.images?.length && !msg.videos?.length && !msg.audios?.length && !msg.files?.length && !msg.tools?.length) return
const msgTime = msg.timestamp ? new Date(msg.timestamp) : new Date()
if (msg.role === 'user') {
const userAtts = msg.images?.length ? msg.images.map(i => ({
@@ -1277,7 +1444,7 @@ async function loadHistory() {
if (msg.images?.length && !userAtts.length) hasOmittedImages = true
appendUserMessage(msg.text, userAtts, msgTime)
} else if (msg.role === 'assistant') {
- appendAiMessage(msg.text, msgTime, msg.images, msg.videos, msg.audios, msg.files)
+ appendAiMessage(msg.text, msgTime, msg.images, msg.videos, msg.audios, msg.files, msg.tools)
}
})
if (hasOmittedImages) {
@@ -1285,25 +1452,33 @@ async function loadHistory() {
}
saveMessages(result.messages.map(m => {
const c = extractContent(m)
- return { id: m.id || uuid(), sessionKey: _sessionKey, role: m.role, content: c?.text || '', timestamp: m.timestamp || Date.now() }
+ const role = (m.role === 'tool' || m.role === 'toolResult') ? 'assistant' : m.role
+ return { id: m.id || uuid(), sessionKey: _sessionKey, role, content: c?.text || '', timestamp: m.timestamp || Date.now() }
}))
scrollToBottom()
} catch (e) {
console.error('[chat] loadHistory error:', e)
- if (!_messagesEl.querySelector('.msg')) appendSystemMessage('加载历史失败: ' + e.message)
+ if (_messagesEl && !_messagesEl.querySelector('.msg')) appendSystemMessage('加载历史失败: ' + e.message)
+ } finally {
+ _isLoadingHistory = false
}
}
function dedupeHistory(messages) {
const deduped = []
for (const msg of messages) {
- if (msg.role === 'toolResult') continue
+ const role = (msg.role === 'tool' || msg.role === 'toolResult') ? 'assistant' : msg.role
const c = extractContent(msg)
- if (!c.text && !c.images.length && !c.videos.length && !c.audios.length && !c.files.length) continue
+ if (!c.text && !c.images.length && !c.videos.length && !c.audios.length && !c.files.length && !c.tools.length) continue
+ const tools = (c.tools || []).map(t => {
+ const id = t.id || t.tool_call_id
+ const time = t.time || resolveToolTime(id, msg.timestamp)
+ return { ...t, time, messageTimestamp: msg.timestamp }
+ })
const last = deduped[deduped.length - 1]
- if (last && last.role === msg.role) {
- if (msg.role === 'user' && last.text === c.text) continue
- if (msg.role === 'assistant') {
+ if (last && last.role === role) {
+ if (role === 'user' && last.text === c.text) continue
+ if (role === 'assistant') {
// 同文本去重(Gateway 重试产生的重复回复)
if (c.text && last.text === c.text) continue
// 不同文本则合并
@@ -1312,15 +1487,34 @@ function dedupeHistory(messages) {
last.videos = [...(last.videos || []), ...c.videos]
last.audios = [...(last.audios || []), ...c.audios]
last.files = [...(last.files || []), ...c.files]
+ tools.forEach(t => upsertTool(last.tools, t))
continue
}
}
- deduped.push({ role: msg.role, text: c.text, images: c.images, videos: c.videos, audios: c.audios, files: c.files, timestamp: msg.timestamp })
+ deduped.push({ role, text: c.text, images: c.images, videos: c.videos, audios: c.audios, files: c.files, tools, timestamp: msg.timestamp })
}
return deduped
}
function extractContent(msg) {
+ const tools = []
+ collectToolsFromMessage(msg, tools)
+ if (msg.role === 'tool' || msg.role === 'toolResult') {
+ const output = typeof msg.content === 'string' ? msg.content : null
+ if (!tools.length) {
+ upsertTool(tools, {
+ id: msg.id || msg.tool_call_id || msg.toolCallId,
+ name: msg.name || msg.tool || msg.tool_name || '工具',
+ input: msg.input || msg.args || msg.parameters || null,
+ output: output || msg.output || msg.result || null,
+ status: msg.status || 'ok',
+ time: resolveToolTime(msg.tool_call_id || msg.toolCallId || msg.id, msg.timestamp),
+ })
+ } else if (output && !tools[0].output) {
+ tools[0].output = output
+ }
+ return { text: '', images: [], videos: [], audios: [], files: [], tools }
+ }
if (Array.isArray(msg.content)) {
const texts = [], images = [], videos = [], audios = [], files = []
for (const block of msg.content) {
@@ -1342,6 +1536,34 @@ function extractContent(msg) {
else if (block.type === 'file' || block.type === 'document') {
files.push({ url: block.url || '', name: block.fileName || block.name || '文件', mimeType: block.mimeType || '', size: block.size, data: block.data })
}
+ else if (block.type === 'tool' || block.type === 'tool_use' || block.type === 'tool_call' || block.type === 'toolCall') {
+ const callId = block.id || block.tool_call_id || block.toolCallId
+ upsertTool(tools, {
+ id: callId,
+ name: block.name || block.tool || block.tool_name || block.toolName || '工具',
+ input: block.input || block.args || block.parameters || block.arguments || null,
+ output: null,
+ status: block.status || 'ok',
+ time: resolveToolTime(callId, msg.timestamp),
+ })
+ }
+ else if (block.type === 'tool_result' || block.type === 'toolResult') {
+ const resId = block.id || block.tool_call_id || block.toolCallId
+ upsertTool(tools, {
+ id: resId,
+ name: block.name || block.tool || block.tool_name || block.toolName || '工具',
+ input: block.input || block.args || null,
+ output: block.output || block.result || block.content || null,
+ status: block.status || 'ok',
+ time: resolveToolTime(resId, msg.timestamp),
+ })
+ }
+ }
+ if (tools.length) {
+ tools.forEach(t => {
+ if (typeof t.input === 'string') t.input = stripAnsi(t.input)
+ if (typeof t.output === 'string') t.output = stripAnsi(t.output)
+ })
}
const mediaUrls = msg.mediaUrls || (msg.mediaUrl ? [msg.mediaUrl] : [])
for (const url of mediaUrls) {
@@ -1351,10 +1573,10 @@ function extractContent(msg) {
else if (/\.(jpe?g|png|gif|webp|heic|svg)(\?|$)/i.test(url)) images.push({ url, mediaType: 'image/png' })
else files.push({ url, name: url.split('/').pop().split('?')[0] || '文件', mimeType: '' })
}
- return { text: stripThinkingTags(texts.join('\n')), images, videos, audios, files }
+ return { text: stripThinkingTags(texts.join('\n')), images, videos, audios, files, tools }
}
const text = typeof msg.text === 'string' ? msg.text : (typeof msg.content === 'string' ? msg.content : '')
- return { text: stripThinkingTags(text), images: [], videos: [], audios: [], files: [] }
+ return { text: stripThinkingTags(text), images: [], videos: [], audios: [], files: [], tools }
}
// ── DOM 操作 ──
@@ -1420,12 +1642,16 @@ function appendUserMessage(text, attachments = [], msgTime) {
scrollToBottom()
}
-function appendAiMessage(text, msgTime, images, videos, audios, files) {
+function appendAiMessage(text, msgTime, images, videos, audios, files, tools) {
const wrap = document.createElement('div')
wrap.className = 'msg msg-ai'
const bubble = document.createElement('div')
bubble.className = 'msg-bubble'
- bubble.innerHTML = renderMarkdown(text)
+ appendToolsToEl(bubble, tools)
+ const textEl = document.createElement('div')
+ textEl.className = 'msg-text'
+ textEl.innerHTML = renderMarkdown(text || '')
+ bubble.appendChild(textEl)
appendImagesToEl(bubble, images)
appendVideosToEl(bubble, videos)
appendAudiosToEl(bubble, audios)
@@ -1528,6 +1754,101 @@ function appendFilesToEl(el, files) {
})
}
+function mergeToolEventData(entry) {
+ const id = entry?.id || entry?.tool_call_id
+ if (!id) return entry
+ const extra = _toolEventData.get(id)
+ if (!extra) return entry
+ if (entry.input == null && extra.input != null) entry.input = extra.input
+ if (entry.output == null && extra.output != null) entry.output = extra.output
+ if (entry.status == null && extra.status != null) entry.status = extra.status
+ if (entry.time == null) entry.time = extra.time || _toolEventTimes.get(id) || null
+ return entry
+}
+
+function upsertTool(tools, entry) {
+ if (!entry) return
+ const id = entry.id || entry.tool_call_id
+ let target = null
+ if (id) target = tools.find(t => t.id === id || t.tool_call_id === id)
+ if (!target && entry.name) target = tools.find(t => t.name === entry.name && !t.output)
+ if (target) {
+ if (entry.input != null && target.input == null) target.input = entry.input
+ if (entry.output != null && target.output == null) target.output = entry.output
+ if (entry.status && target.status == null) target.status = entry.status
+ if (entry.time && target.time == null) target.time = entry.time
+ return
+ }
+ tools.push(mergeToolEventData(entry))
+}
+
+function collectToolsFromMessage(message, tools) {
+ if (!message || !tools) return
+ const toolCalls = message.tool_calls || message.toolCalls || message.tools
+ if (Array.isArray(toolCalls)) {
+ toolCalls.forEach(call => {
+ const fn = call.function || null
+ const name = call.name || call.tool || call.tool_name || fn?.name
+ const input = call.input || call.args || call.parameters || call.arguments || fn?.arguments || null
+ const callId = call.id || call.tool_call_id
+ upsertTool(tools, {
+ id: callId,
+ name: name || '工具',
+ input,
+ output: null,
+ status: call.status || 'ok',
+ time: resolveToolTime(callId, message?.timestamp),
+ })
+ })
+ }
+ const toolResults = message.tool_results || message.toolResults
+ if (Array.isArray(toolResults)) {
+ toolResults.forEach(res => {
+ const resId = res.id || res.tool_call_id
+ upsertTool(tools, {
+ id: resId,
+ name: res.name || res.tool || res.tool_name || '工具',
+ input: res.input || res.args || null,
+ output: res.output || res.result || res.content || null,
+ status: res.status || 'ok',
+ time: resolveToolTime(resId, message?.timestamp),
+ })
+ })
+ }
+}
+
+/** 渲染工具调用到消息气泡 */
+function appendToolsToEl(el, tools) {
+ if (!el) return
+ const existing = el.querySelector?.('.msg-tool')
+ if (!tools?.length) {
+ if (existing) existing.remove()
+ return
+ }
+ const container = document.createElement('div')
+ container.className = 'msg-tool'
+ tools.forEach(tool => {
+ const details = document.createElement('details')
+ details.className = 'msg-tool-item'
+ const summary = document.createElement('summary')
+ const status = tool.status === 'error' ? '失败' : '成功'
+ const timeValue = getToolTime(tool) || resolveToolTime(tool.id || tool.tool_call_id, tool.messageTimestamp)
+ const timeText = timeValue ? formatTime(new Date(timeValue)) : ''
+ summary.innerHTML = `${escapeHtml(tool.name || '工具')} · ${status}${timeText ? ' · ' + timeText : ''}`
+ const body = document.createElement('div')
+ body.className = 'msg-tool-body'
+ const inputJson = stripAnsi(safeStringify(tool.input))
+ const outputJson = stripAnsi(safeStringify(tool.output))
+ body.innerHTML = ``
+ + ``
+ details.appendChild(summary)
+ details.appendChild(body)
+ container.appendChild(details)
+ })
+ if (existing) existing.remove()
+ el.insertBefore(container, el.firstChild)
+}
+
/** 图片灯箱查看 */
function showLightbox(src) {
const existing = document.querySelector('.chat-lightbox')
@@ -1552,6 +1873,8 @@ function appendSystemMessage(text) {
function clearMessages() {
_messagesEl.querySelectorAll('.msg').forEach(m => m.remove())
+ _autoScrollEnabled = true
+ _lastScrollTop = 0
}
function showTyping(show) {
@@ -1573,11 +1896,17 @@ function showCompactionHint(show) {
}
}
-function scrollToBottom() {
+function scrollToBottom(force = false) {
if (!_messagesEl) return
+ if (!force && !_autoScrollEnabled) return
requestAnimationFrame(() => { _messagesEl.scrollTop = _messagesEl.scrollHeight })
}
+function isAtBottom() {
+ if (!_messagesEl) return true
+ return _messagesEl.scrollHeight - _messagesEl.scrollTop - _messagesEl.clientHeight < 80
+}
+
function updateSendState() {
if (!_sendBtn || !_textarea) return
if (_isStreaming) {
@@ -1624,6 +1953,7 @@ export function cleanup() {
_currentAiVideos = []
_currentAiAudios = []
_currentAiFiles = []
+ _currentAiTools = []
_currentRunId = null
_isStreaming = false
_isSending = false
diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js
index e716fc03..fcc7e13c 100644
--- a/src/pages/dashboard.js
+++ b/src/pages/dashboard.js
@@ -41,7 +41,14 @@ export async function render() {
bindActions(page)
// 异步加载数据
- loadDashboardData(page)
+ loadDashboardData(page).catch(e => {
+ console.error('[dashboard] loadDashboardData 异常:', e)
+ const cardsEl = page.querySelector('#stat-cards')
+ if (cardsEl && cardsEl.querySelector('.loading-placeholder')) {
+ cardsEl.innerHTML = `加载失败: ${escapeHtml(String(e?.message || e))}
`
+ }
+ })
+ page.__retryLoad = () => loadDashboardData(page).catch(() => {})
// 监听 Gateway 状态变化,自动刷新仪表盘
if (_unsubGw) _unsubGw()
@@ -61,19 +68,23 @@ let _dashboardInitialized = false
async function loadDashboardData(page, fullRefresh = false) {
// 分波加载:关键数据先渲染,次要数据后填充,减少白屏等待
// 轻量调用(读文件)每次都做;重量调用(spawn CLI/网络请求)只在首次或手动刷新时做
- const coreP = Promise.allSettled([
+ const withTimeout = (promise, ms) => Promise.race([
+ promise,
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`超时(${ms/1000}s)`)), ms))
+ ])
+ const coreP = withTimeout(Promise.allSettled([
api.getServicesStatus(),
api.readOpenclawConfig(),
// 版本信息:首次加载或手动刷新时才查询(避免 ARM 设备上频繁查 npm registry)
(!_dashboardInitialized || fullRefresh) ? api.getVersionInfo() : Promise.resolve(null),
- ])
- const secondaryP = Promise.allSettled([
+ ]), 15000)
+ const secondaryP = withTimeout(Promise.allSettled([
api.listAgents(),
api.readMcpConfig(),
api.listBackups(),
// getStatusSummary 是最重的调用(spawn openclaw status --json),只在首次加载时调用
(!_dashboardInitialized || fullRefresh) ? api.getStatusSummary() : Promise.resolve(null),
- ])
+ ]), 15000).catch(() => [{ status: 'rejected' }, { status: 'rejected' }, { status: 'rejected' }, { status: 'rejected' }])
const logsP = api.readLogTail('gateway', 20).catch(() => '')
// 第一波:服务状态 + 配置 + 版本 → 立即渲染统计卡片
diff --git a/src/style/chat.css b/src/style/chat.css
index 7c91fe44..63fbacad 100644
--- a/src/style/chat.css
+++ b/src/style/chat.css
@@ -855,6 +855,55 @@
color: var(--text-tertiary);
}
+/* 工具调用 */
+.msg-tool {
+ margin-bottom: 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.msg-tool-item {
+ border: 1px solid var(--border-primary, var(--border));
+ background: var(--bg-tertiary, var(--bg-secondary));
+ border-radius: var(--radius-md, 8px);
+ padding: 8px 10px;
+}
+.msg-tool-item > summary {
+ cursor: pointer;
+ font-size: 12px;
+ color: var(--text-secondary);
+ list-style: none;
+}
+.msg-tool-item > summary::-webkit-details-marker {
+ display: none;
+}
+.msg-tool-body {
+ margin-top: 8px;
+ display: none;
+ gap: 8px;
+}
+.msg-tool-item[open] > .msg-tool-body {
+ display: grid;
+}
+.msg-tool-block {
+ background: var(--bg-primary, var(--bg));
+ border: 1px solid var(--border-primary, var(--border));
+ border-radius: var(--radius-sm, 4px);
+ padding: 8px 10px;
+}
+.msg-tool-title {
+ font-size: 11px;
+ color: var(--text-tertiary);
+ margin-bottom: 6px;
+}
+.msg-tool-block pre {
+ margin: 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-size: 11px;
+ color: var(--text-primary);
+}
+
/* 首次引导提示 */
.chat-page-guide {
margin: 0 16px 8px;
From c35eea5cf8dc4ec83adf043d4e8f7caad63197e6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Tue, 17 Mar 2026 13:36:50 +0800
Subject: [PATCH 208/426] feat: hosted agent chat enhancements
---
src/pages/chat.js | 714 ++++++++++++++++++++++++++++++++++++++++++++-
src/style/chat.css | 176 +++++++++++
2 files changed, 889 insertions(+), 1 deletion(-)
diff --git a/src/pages/chat.js b/src/pages/chat.js
index 6c39aaf1..2eb7a214 100644
--- a/src/pages/chat.js
+++ b/src/pages/chat.js
@@ -17,6 +17,40 @@ const STORAGE_MODEL_KEY = 'clawpanel-chat-selected-model'
const STORAGE_SIDEBAR_KEY = 'clawpanel-chat-sidebar-open'
const STORAGE_SESSION_NAMES_KEY = 'clawpanel-chat-session-names'
+const HOSTED_STATUS = {
+ IDLE: 'idle',
+ RUNNING: 'running',
+ WAITING: 'waiting_reply',
+ PAUSED: 'paused',
+ ERROR: 'error',
+}
+const HOSTED_SESSIONS_KEY = 'clawpanel-hosted-agent-sessions'
+const HOSTED_GLOBAL_KEY = 'hostedAgent.default'
+
+// { enabled, prompt, autoRunAfterTarget, stopPolicy, maxSteps, stepDelayMs, retryLimit, toolPolicy, state, history }
+// history roles: system | assistant | target
+const HOSTED_DEFAULTS = {
+ enabled: false,
+ prompt: '',
+ autoRunAfterTarget: true,
+ stopPolicy: 'self',
+ maxSteps: 50,
+ stepDelayMs: 1200,
+ retryLimit: 2,
+ toolPolicy: 'inherit',
+}
+
+const HOSTED_RUNTIME_DEFAULT = {
+ status: HOSTED_STATUS.IDLE,
+ stepCount: 0,
+ lastRunAt: 0,
+ lastError: '',
+ pending: false,
+ errorCount: 0,
+}
+
+const HOSTED_CONTEXT_MAX = 30
+
const COMMANDS = [
{ title: '会话', commands: [
{ cmd: '/new', desc: '新建会话', action: 'exec' },
@@ -59,6 +93,16 @@ let _sessionKey = null, _page = null, _messagesEl = null, _textarea = null
let _sendBtn = null, _statusDot = null, _typingEl = null, _scrollBtn = null
let _sessionListEl = null, _cmdPanelEl = null, _attachPreviewEl = null, _fileInputEl = null
let _modelSelectEl = null
+let _hostedBtn = null, _hostedPanelEl = null, _hostedBadgeEl = null
+let _hostedPromptEl = null, _hostedEnableEl = null, _hostedMaxStepsEl = null, _hostedStepDelayEl = null, _hostedRetryLimitEl = null
+let _hostedSaveBtn = null, _hostedPauseBtn = null, _hostedStopBtn = null, _hostedCloseBtn = null
+let _hostedGlobalSyncEl = null
+let _hostedDefaults = null
+let _hostedSessionConfig = null
+let _hostedRuntime = { ...HOSTED_RUNTIME_DEFAULT }
+let _hostedAutoTimer = null
+let _hostedLastTargetTs = 0
+let _hostedBusy = false
let _currentAiBubble = null, _currentAiText = '', _currentAiImages = [], _currentAiVideos = [], _currentAiAudios = [], _currentAiFiles = [], _currentAiTools = [], _currentRunId = null
let _isStreaming = false, _isSending = false, _messageQueue = [], _streamStartTime = 0
let _lastRenderTime = 0, _renderPending = false, _lastHistoryHash = ''
@@ -145,6 +189,65 @@ export async function render() {
+
+ 连接已断开,正在重连...
@@ -175,6 +278,19 @@ export async function render() {
_attachPreviewEl = page.querySelector('#chat-attachments-preview')
_fileInputEl = page.querySelector('#chat-file-input')
_modelSelectEl = page.querySelector('#chat-model-select')
+ _hostedBtn = page.querySelector('#chat-hosted-btn')
+ _hostedBadgeEl = page.querySelector('#chat-hosted-badge')
+ _hostedPanelEl = page.querySelector('#hosted-agent-panel')
+ _hostedPromptEl = page.querySelector('#hosted-agent-prompt')
+ _hostedEnableEl = page.querySelector('#hosted-agent-enabled')
+ _hostedMaxStepsEl = page.querySelector('#hosted-agent-max-steps')
+ _hostedStepDelayEl = page.querySelector('#hosted-agent-step-delay')
+ _hostedRetryLimitEl = page.querySelector('#hosted-agent-retry')
+ _hostedSaveBtn = page.querySelector('#hosted-agent-save')
+ _hostedPauseBtn = page.querySelector('#hosted-agent-pause')
+ _hostedStopBtn = page.querySelector('#hosted-agent-stop')
+ _hostedCloseBtn = page.querySelector('#hosted-agent-close')
+ _hostedGlobalSyncEl = page.querySelector('#hosted-agent-sync-global')
page.querySelector('#chat-sidebar')?.classList.toggle('open', getSidebarOpen())
bindEvents(page)
@@ -183,6 +299,12 @@ export async function render() {
// 首次使用引导提示
showPageGuide(_messagesEl)
+ loadHostedDefaults().then(() => {
+ loadHostedSessionConfig()
+ renderHostedPanel()
+ updateHostedBadge()
+ })
+
loadModelOptions()
// 非阻塞:先返回 DOM,后台连接 Gateway
connectGateway()
@@ -247,6 +369,25 @@ function bindEvents(page) {
else sendMessage()
})
+ if (_hostedBtn) {
+ _hostedBtn.addEventListener('click', (e) => {
+ e.stopPropagation()
+ toggleHostedPanel()
+ })
+ }
+ if (_hostedCloseBtn) {
+ _hostedCloseBtn.addEventListener('click', () => hideHostedPanel())
+ }
+ if (_hostedSaveBtn) {
+ _hostedSaveBtn.addEventListener('click', () => saveHostedConfig())
+ }
+ if (_hostedPauseBtn) {
+ _hostedPauseBtn.addEventListener('click', () => pauseHostedAgent())
+ }
+ if (_hostedStopBtn) {
+ _hostedStopBtn.addEventListener('click', () => stopHostedAgent())
+ }
+
const toggleSidebar = () => {
const sidebar = page.querySelector('#chat-sidebar')
if (!sidebar) return
@@ -288,7 +429,16 @@ function bindEvents(page) {
_autoScrollEnabled = true
scrollToBottom(true)
})
- _messagesEl.addEventListener('click', () => hideCmdPanel())
+ _messagesEl.addEventListener('click', () => { hideCmdPanel(); hideHostedPanel() })
+ _messagesEl.addEventListener('click', (e) => {
+ const target = e.target?.closest?.('.msg-spoiler')
+ if (!target) return
+ if (target.closest('code, pre')) return
+ target.classList.toggle('revealed')
+ })
+ _messagesEl.addEventListener('scroll', () => {
+ if (_virtualEnabled) requestVirtualRender()
+ })
}
async function loadModelOptions(showToast = false) {
@@ -538,6 +688,11 @@ async function connectGateway() {
_hasEverConnected = true
if (bar) bar.style.display = 'none'
if (overlay) overlay.style.display = 'none'
+ if (_hostedRuntime.status === HOSTED_STATUS.PAUSED) {
+ _hostedRuntime.status = HOSTED_STATUS.IDLE
+ persistHostedRuntime()
+ updateHostedBadge()
+ }
} else if (status === 'error') {
// 连接错误:显示引导遮罩而非底部条
if (bar) bar.style.display = 'none'
@@ -545,6 +700,11 @@ async function connectGateway() {
overlay.style.display = 'flex'
if (desc) desc.textContent = errorMsg || '连接 Gateway 失败'
}
+ if (_hostedRuntime.status !== HOSTED_STATUS.PAUSED) {
+ _hostedRuntime.status = HOSTED_STATUS.PAUSED
+ persistHostedRuntime()
+ updateHostedBadge()
+ }
} else if (status === 'reconnecting' || status === 'disconnected') {
// 首次连接或多次重连失败时,显示引导遮罩而非底部小条
if (!_hasEverConnected) {
@@ -552,6 +712,11 @@ async function connectGateway() {
} else {
if (bar) { bar.textContent = '连接已断开,正在重连...'; bar.style.display = 'flex' }
}
+ if (_hostedRuntime.status !== HOSTED_STATUS.PAUSED) {
+ _hostedRuntime.status = HOSTED_STATUS.PAUSED
+ persistHostedRuntime()
+ updateHostedBadge()
+ }
} else {
if (bar) bar.style.display = 'none'
}
@@ -707,6 +872,9 @@ function switchSession(newKey) {
clearMessages()
loadHistory()
refreshSessionList()
+ loadHostedSessionConfig()
+ renderHostedPanel()
+ updateHostedBadge()
}
async function showNewSessionDialog() {
@@ -867,6 +1035,17 @@ function hideCmdPanel() {
if (_cmdPanelEl) _cmdPanelEl.style.display = 'none'
}
+function toggleHostedPanel() {
+ if (!_hostedPanelEl) return
+ const next = _hostedPanelEl.style.display !== 'block'
+ _hostedPanelEl.style.display = next ? 'block' : 'none'
+ if (next) renderHostedPanel()
+}
+
+function hideHostedPanel() {
+ if (_hostedPanelEl) _hostedPanelEl.style.display = 'none'
+}
+
function toggleCmdPanel() {
if (_cmdPanelEl?.style.display === 'block') hideCmdPanel()
else { _textarea.value = '/'; showCmdPanel(); _textarea.focus() }
@@ -954,6 +1133,14 @@ function handleEvent(msg) {
if (event === 'chat') handleChatEvent(payload)
+ if ((event === 'status' || event === 'gateway.status') && payload?.state === 'disconnected') {
+ if (_hostedRuntime.status !== HOSTED_STATUS.PAUSED) {
+ _hostedRuntime.status = HOSTED_STATUS.PAUSED
+ persistHostedRuntime()
+ updateHostedBadge()
+ }
+ }
+
// Compaction 状态指示:上游 2026.3.12 新增 status_reaction 事件
if (event === 'chat.status_reaction' || event === 'status_reaction') {
const reaction = payload.reaction || payload.emoji || ''
@@ -1029,6 +1216,14 @@ function handleChatEvent(payload) {
const ids = _toolRunIndex.get(runId) || []
finalTools = ids.map(id => mergeToolEventData({ id, name: '工具' })).filter(Boolean)
}
+
+ // 托管 Agent:记录对面回复并触发下一步
+ if (payload.sessionKey === _sessionKey || !_sessionKey) {
+ if (finalText && shouldCaptureHostedTarget(payload)) {
+ appendHostedTarget(finalText, payload.timestamp || Date.now())
+ maybeTriggerHostedRun()
+ }
+ }
if (finalImages.length) _currentAiImages = finalImages
if (finalVideos.length) _currentAiVideos = finalVideos
if (finalAudios.length) _currentAiAudios = finalAudios
@@ -1928,6 +2123,506 @@ function updateStatusDot(status) {
else _statusDot.classList.add('offline')
}
+async function loadHostedDefaults() {
+ _hostedDefaults = { ...HOSTED_DEFAULTS }
+ try {
+ const panel = await api.readPanelConfig()
+ const stored = panel?.hostedAgent?.default || null
+ if (stored) _hostedDefaults = { ..._hostedDefaults, ...stored }
+ } catch (e) {
+ console.warn('[chat][hosted] 读取 panel 配置失败:', e)
+ }
+}
+
+function getHostedSessionKey() {
+ return _sessionKey || localStorage.getItem(STORAGE_SESSION_KEY) || 'agent:main:main'
+}
+
+function loadHostedSessionConfig() {
+ let data = {}
+ try { data = JSON.parse(localStorage.getItem(HOSTED_SESSIONS_KEY) || '{}') } catch { data = {} }
+ const key = getHostedSessionKey()
+ const current = data[key] || {}
+ _hostedSessionConfig = { ...HOSTED_DEFAULTS, ..._hostedDefaults, ...current }
+ if (!_hostedSessionConfig.state) _hostedSessionConfig.state = { ...HOSTED_RUNTIME_DEFAULT }
+ if (!_hostedSessionConfig.history) _hostedSessionConfig.history = []
+ _hostedRuntime = { ...HOSTED_RUNTIME_DEFAULT, ..._hostedSessionConfig.state }
+ updateHostedBadge()
+}
+
+function saveHostedSessionConfig(nextConfig) {
+ let data = {}
+ try { data = JSON.parse(localStorage.getItem(HOSTED_SESSIONS_KEY) || '{}') } catch { data = {} }
+ data[getHostedSessionKey()] = nextConfig
+ localStorage.setItem(HOSTED_SESSIONS_KEY, JSON.stringify(data))
+}
+
+function persistHostedRuntime() {
+ if (!_hostedSessionConfig) return
+ _hostedSessionConfig.state = { ..._hostedRuntime }
+ saveHostedSessionConfig(_hostedSessionConfig)
+}
+
+function updateHostedBadge() {
+ if (!_hostedBadgeEl || !_hostedSessionConfig) return
+ const status = _hostedRuntime.status || HOSTED_STATUS.IDLE
+ const enabled = _hostedSessionConfig.enabled
+ let text = '未启用'
+ let cls = 'chat-hosted-badge'
+ if (!enabled) {
+ text = '未启用'
+ cls += ' idle'
+ } else if (status === HOSTED_STATUS.RUNNING) {
+ text = '运行中'
+ cls += ' running'
+ } else if (status === HOSTED_STATUS.WAITING) {
+ text = '等待回复'
+ cls += ' waiting'
+ } else if (status === HOSTED_STATUS.PAUSED) {
+ text = '已暂停'
+ cls += ' paused'
+ } else if (status === HOSTED_STATUS.ERROR) {
+ text = '异常'
+ cls += ' error'
+ } else {
+ text = '待命'
+ cls += ' idle'
+ }
+ _hostedBadgeEl.className = cls
+ _hostedBadgeEl.textContent = text
+}
+
+function renderHostedPanel() {
+ if (!_hostedPanelEl || !_hostedSessionConfig) return
+ if (_hostedPromptEl) _hostedPromptEl.value = _hostedSessionConfig.prompt || ''
+ if (_hostedEnableEl) _hostedEnableEl.checked = !!_hostedSessionConfig.enabled
+ if (_hostedMaxStepsEl) _hostedMaxStepsEl.value = _hostedSessionConfig.maxSteps || HOSTED_DEFAULTS.maxSteps
+ if (_hostedStepDelayEl) _hostedStepDelayEl.value = _hostedSessionConfig.stepDelayMs || HOSTED_DEFAULTS.stepDelayMs
+ if (_hostedRetryLimitEl) _hostedRetryLimitEl.value = _hostedSessionConfig.retryLimit ?? HOSTED_DEFAULTS.retryLimit
+ const statusEl = _hostedPanelEl.querySelector('#hosted-agent-status')
+ if (statusEl) {
+ const msg = _hostedRuntime.lastError ? `上次错误: ${_hostedRuntime.lastError}` : '状态正常'
+ statusEl.textContent = msg
+ }
+}
+
+async function saveHostedConfig() {
+ if (!_hostedSessionConfig) return
+ const prompt = (_hostedPromptEl?.value || '').trim()
+ if (!prompt) { toast('请输入初始提示词', 'warning'); return }
+ const enabled = !!_hostedEnableEl?.checked
+ const maxSteps = Math.max(1, parseInt(_hostedMaxStepsEl?.value || HOSTED_DEFAULTS.maxSteps, 10))
+ const stepDelayMs = Math.max(200, parseInt(_hostedStepDelayEl?.value || HOSTED_DEFAULTS.stepDelayMs, 10))
+ const retryLimit = Math.max(0, parseInt(_hostedRetryLimitEl?.value || HOSTED_DEFAULTS.retryLimit, 10))
+
+ _hostedSessionConfig = {
+ ..._hostedSessionConfig,
+ prompt,
+ enabled,
+ autoRunAfterTarget: true,
+ stopPolicy: 'self',
+ maxSteps,
+ stepDelayMs,
+ retryLimit,
+ }
+
+ if (!_hostedSessionConfig.history || !_hostedSessionConfig.history.length) {
+ _hostedSessionConfig.history = [{ role: 'system', content: prompt }]
+ } else if (_hostedSessionConfig.history[0]?.role !== 'system') {
+ _hostedSessionConfig.history.unshift({ role: 'system', content: prompt })
+ } else {
+ _hostedSessionConfig.history[0].content = prompt
+ }
+
+ if (!_hostedSessionConfig.state) _hostedSessionConfig.state = { ...HOSTED_RUNTIME_DEFAULT }
+ _hostedRuntime = { ...HOSTED_RUNTIME_DEFAULT, ..._hostedSessionConfig.state }
+ if (enabled && _hostedRuntime.status === HOSTED_STATUS.PAUSED) _hostedRuntime.status = HOSTED_STATUS.IDLE
+ persistHostedRuntime()
+ renderHostedPanel()
+ updateHostedBadge()
+
+ if (enabled && _hostedRuntime.status === HOSTED_STATUS.IDLE) {
+ runHostedAgentStep()
+ }
+
+ if (_hostedGlobalSyncEl?.checked) {
+ try {
+ const panel = await api.readPanelConfig()
+ const nextPanel = { ...(panel || {}) }
+ if (!nextPanel.hostedAgent) nextPanel.hostedAgent = {}
+ nextPanel.hostedAgent.default = {
+ ...HOSTED_DEFAULTS,
+ prompt,
+ enabled,
+ maxSteps,
+ stepDelayMs,
+ retryLimit,
+ }
+ await api.writePanelConfig(nextPanel)
+ toast('已同步为全局默认', 'success')
+ } catch (e) {
+ toast('同步全局默认失败: ' + (e.message || e), 'error')
+ }
+ }
+
+ if (enabled) toast('托管 Agent 已启用', 'success')
+ else toast('托管 Agent 已保存', 'info')
+}
+
+function pauseHostedAgent() {
+ if (!_hostedSessionConfig) return
+ _hostedRuntime.status = HOSTED_STATUS.PAUSED
+ _hostedRuntime.pending = false
+ persistHostedRuntime()
+ updateHostedBadge()
+ toast('托管 Agent 已暂停', 'info')
+}
+
+function stopHostedAgent() {
+ if (!_hostedSessionConfig) return
+ _hostedRuntime.status = HOSTED_STATUS.IDLE
+ _hostedRuntime.pending = false
+ _hostedRuntime.stepCount = 0
+ _hostedRuntime.lastError = ''
+ _hostedRuntime.errorCount = 0
+ persistHostedRuntime()
+ updateHostedBadge()
+ toast('托管 Agent 已停止', 'info')
+}
+
+function shouldCaptureHostedTarget(payload) {
+ if (!_hostedSessionConfig?.enabled) return false
+ if (_hostedRuntime.status === HOSTED_STATUS.PAUSED || _hostedRuntime.status === HOSTED_STATUS.ERROR) return false
+ if (payload?.message?.role && payload.message.role !== 'assistant') return false
+ const ts = payload?.timestamp || Date.now()
+ if (ts && ts === _hostedLastTargetTs) return false
+ _hostedLastTargetTs = ts
+ return true
+}
+
+function appendHostedTarget(text, ts) {
+ if (!_hostedSessionConfig) return
+ if (!_hostedSessionConfig.history) _hostedSessionConfig.history = []
+ _hostedSessionConfig.history.push({ role: 'target', content: text, ts: ts || Date.now() })
+ persistHostedRuntime()
+}
+
+function maybeTriggerHostedRun() {
+ if (!_hostedSessionConfig?.enabled) return
+ if (!_hostedSessionConfig.autoRunAfterTarget) return
+ if (_hostedRuntime.pending || _hostedRuntime.status === HOSTED_STATUS.RUNNING) return
+ if (_hostedRuntime.status === HOSTED_STATUS.PAUSED || _hostedRuntime.status === HOSTED_STATUS.ERROR) return
+ if (!wsClient.gatewayReady) {
+ _hostedRuntime.status = HOSTED_STATUS.PAUSED
+ persistHostedRuntime()
+ updateHostedBadge()
+ return
+ }
+ if (_hostedRuntime.status === HOSTED_STATUS.WAITING) {
+ _hostedRuntime.status = HOSTED_STATUS.IDLE
+ }
+ runHostedAgentStep()
+}
+
+function buildHostedMessages() {
+ const history = _hostedSessionConfig?.history || []
+ const trimmed = history.slice(-HOSTED_CONTEXT_MAX)
+ return trimmed.map(item => {
+ if (item.role === 'system') return { role: 'system', content: item.content }
+ if (item.role === 'assistant') return { role: 'assistant', content: item.content }
+ return { role: 'user', content: item.content }
+ })
+}
+
+function detectStopFromText(text) {
+ if (!text) return false
+ return /\b(完成|无需继续|结束|停止|done|stop|final)\b/i.test(text)
+}
+
+async function runHostedAgentStep() {
+ if (_hostedBusy || !_hostedSessionConfig?.enabled) return
+ const prompt = (_hostedSessionConfig.prompt || '').trim()
+ if (!prompt) return
+ if (_hostedRuntime.stepCount >= _hostedSessionConfig.maxSteps) {
+ _hostedRuntime.status = HOSTED_STATUS.IDLE
+ persistHostedRuntime()
+ updateHostedBadge()
+ return
+ }
+ _hostedBusy = true
+ _hostedRuntime.pending = true
+ _hostedRuntime.status = HOSTED_STATUS.RUNNING
+ _hostedRuntime.lastRunAt = Date.now()
+ persistHostedRuntime()
+ updateHostedBadge()
+
+ const delay = _hostedSessionConfig.stepDelayMs || HOSTED_DEFAULTS.stepDelayMs
+ if (delay > 0) {
+ await new Promise(r => setTimeout(r, delay))
+ }
+
+ try {
+ const messages = buildHostedMessages()
+ let resultText = ''
+ await callHostedAI(messages, (chunk) => {
+ resultText += chunk
+ })
+ const nextInstruction = resultText.trim()
+ if (!nextInstruction) throw new Error('托管 Agent 未生成指令')
+
+ _hostedRuntime.stepCount += 1
+ _hostedRuntime.errorCount = 0
+ _hostedRuntime.lastError = ''
+
+ _hostedSessionConfig.history.push({ role: 'assistant', content: nextInstruction, ts: Date.now() })
+ persistHostedRuntime()
+
+ appendHostedOutput(`[托管 Agent] 下一步指令: ${nextInstruction}`)
+ await wsClient.chatSend(_sessionKey, nextInstruction)
+
+ _hostedRuntime.status = HOSTED_STATUS.WAITING
+ _hostedRuntime.pending = false
+ persistHostedRuntime()
+ updateHostedBadge()
+
+ if (_hostedSessionConfig.stopPolicy === 'self' && detectStopFromText(nextInstruction)) {
+ _hostedRuntime.status = HOSTED_STATUS.IDLE
+ persistHostedRuntime()
+ updateHostedBadge()
+ }
+ } catch (e) {
+ _hostedRuntime.errorCount = (_hostedRuntime.errorCount || 0) + 1
+ _hostedRuntime.lastError = e.message || String(e)
+ _hostedRuntime.pending = false
+ if (_hostedRuntime.errorCount > _hostedSessionConfig.retryLimit) {
+ _hostedRuntime.status = HOSTED_STATUS.ERROR
+ updateHostedBadge()
+ persistHostedRuntime()
+ return
+ }
+ persistHostedRuntime()
+ updateHostedBadge()
+ const delay = _hostedSessionConfig.stepDelayMs || HOSTED_DEFAULTS.stepDelayMs
+ setTimeout(() => {
+ _hostedBusy = false
+ runHostedAgentStep()
+ }, delay)
+ return
+ } finally {
+ _hostedBusy = false
+ }
+}
+
+async function callHostedAI(messages, onChunk) {
+ const config = await loadHostedAssistantConfig()
+ const apiType = normalizeApiType(config.apiType)
+ if (!config.baseUrl || !config.model || (requiresApiKey(apiType) && !config.apiKey)) {
+ throw new Error('托管 Agent 未配置模型(请在 AI 助手页面配置)')
+ }
+ const base = cleanBaseUrl(config.baseUrl, apiType)
+ const systemPrompt = messages.find(m => m.role === 'system')?.content || ''
+ const chatMessages = messages.filter(m => m.role !== 'system')
+
+ if (apiType === 'anthropic-messages') {
+ await callAnthropicHosted(base, systemPrompt, chatMessages, config, onChunk)
+ return
+ }
+ if (apiType === 'google-gemini') {
+ await callGeminiHosted(base, systemPrompt, chatMessages, config, onChunk)
+ return
+ }
+ await callChatCompletionsHosted(base, systemPrompt, chatMessages, config, onChunk)
+}
+
+async function loadHostedAssistantConfig() {
+ try {
+ const raw = localStorage.getItem('clawpanel-assistant')
+ const stored = raw ? JSON.parse(raw) : {}
+ return {
+ baseUrl: stored.baseUrl || '',
+ apiKey: stored.apiKey || '',
+ model: stored.model || '',
+ temperature: stored.temperature || 0.7,
+ apiType: stored.apiType || 'openai-completions',
+ }
+ } catch {
+ return { baseUrl: '', apiKey: '', model: '', temperature: 0.7, apiType: 'openai-completions' }
+ }
+}
+
+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 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)
+ if (type === 'anthropic-messages') {
+ const headers = {
+ 'Content-Type': 'application/json',
+ 'anthropic-version': '2023-06-01',
+ }
+ if (apiKey) headers['x-api-key'] = apiKey
+ return headers
+ }
+ const headers = { 'Content-Type': 'application/json' }
+ if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
+ return headers
+}
+
+async function fetchWithRetry(url, options, retries = 2) {
+ const delays = [800, 1600, 3200]
+ 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 (i >= retries) throw err
+ await new Promise(r => setTimeout(r, delays[i]))
+ }
+ }
+}
+
+async function readSSEStream(resp, onEvent) {
+ const reader = resp.body.getReader()
+ const decoder = new TextDecoder()
+ let buffer = ''
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+ buffer += decoder.decode(value, { stream: true })
+ const lines = buffer.split('\n')
+ buffer = lines.pop() || ''
+ for (const line of lines) {
+ const trimmed = line.trim()
+ if (!trimmed) continue
+ if (!trimmed.startsWith('data:')) continue
+ const data = trimmed.slice(5).trim()
+ if (data === '[DONE]') return
+ try { onEvent(JSON.parse(data)) } catch {}
+ }
+ }
+}
+
+async function callChatCompletionsHosted(base, systemPrompt, messages, config, onChunk) {
+ const body = {
+ model: config.model,
+ messages: [systemPrompt ? { role: 'system', content: systemPrompt } : null, ...messages].filter(Boolean),
+ stream: true,
+ temperature: config.temperature || 0.7,
+ }
+ const resp = await fetchWithRetry(base + '/chat/completions', {
+ method: 'POST',
+ headers: authHeaders(config.apiType, config.apiKey),
+ body: JSON.stringify(body),
+ })
+ 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 delta = json.choices?.[0]?.delta
+ if (delta?.content) onChunk(delta.content)
+ })
+}
+
+async function callAnthropicHosted(base, systemPrompt, messages, config, onChunk) {
+ const body = {
+ model: config.model,
+ max_tokens: 4096,
+ stream: true,
+ temperature: config.temperature || 0.7,
+ messages,
+ }
+ if (systemPrompt) body.system = systemPrompt
+ const resp = await fetchWithRetry(base + '/messages', {
+ method: 'POST',
+ headers: authHeaders(config.apiType, config.apiKey),
+ body: JSON.stringify(body),
+ })
+ 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) => {
+ if (json.type === 'content_block_delta') {
+ const delta = json.delta
+ if (delta?.type === 'text_delta' && delta.text) onChunk(delta.text)
+ }
+ })
+}
+
+async function callGeminiHosted(base, systemPrompt, messages, config, onChunk) {
+ const contents = messages.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 (systemPrompt) body.systemInstruction = { parts: [{ text: systemPrompt }] }
+ 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),
+ })
+ 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)
+ })
+}
+
+function appendHostedOutput(text) {
+ if (!text) return
+ const wrap = document.createElement('div')
+ wrap.className = 'msg msg-system msg-hosted'
+ wrap.textContent = text
+ insertMessageByTime(wrap, Date.now())
+ scrollToBottom()
+}
+
// ── 页面离开清理 ──
export function cleanup() {
@@ -1959,4 +2654,21 @@ export function cleanup() {
_isSending = false
_messageQueue = []
_lastHistoryHash = ''
+ _hostedBtn = null
+ _hostedPanelEl = null
+ _hostedBadgeEl = null
+ _hostedPromptEl = null
+ _hostedEnableEl = null
+ _hostedMaxStepsEl = null
+ _hostedStepDelayEl = null
+ _hostedRetryLimitEl = null
+ _hostedSaveBtn = null
+ _hostedPauseBtn = null
+ _hostedStopBtn = null
+ _hostedCloseBtn = null
+ _hostedGlobalSyncEl = null
+ _hostedSessionConfig = null
+ _hostedDefaults = null
+ _hostedRuntime = { ...HOSTED_RUNTIME_DEFAULT }
+ _hostedBusy = false
}
diff --git a/src/style/chat.css b/src/style/chat.css
index 63fbacad..323d8d90 100644
--- a/src/style/chat.css
+++ b/src/style/chat.css
@@ -404,6 +404,182 @@
transition: opacity 0.15s, background 0.15s;
}
+/* 托管 Agent */
+.chat-hosted-btn {
+ height: 40px;
+ border-radius: var(--radius-md, 8px);
+ border: 1px solid var(--border);
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 0 10px;
+ font-size: 12px;
+ flex-shrink: 0;
+ transition: background 0.15s, border-color 0.15s;
+}
+
+.chat-hosted-btn:hover {
+ background: var(--bg-hover);
+ border-color: var(--border-primary);
+}
+
+.chat-hosted-label {
+ font-weight: 600;
+}
+
+.chat-hosted-badge {
+ font-size: 11px;
+ padding: 2px 6px;
+ border-radius: 10px;
+ background: var(--bg-tertiary);
+ color: var(--text-tertiary);
+}
+
+.chat-hosted-badge.running { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
+.chat-hosted-badge.waiting { background: rgba(245, 158, 11, 0.12); color: #f59e0b; }
+.chat-hosted-badge.paused { background: rgba(148, 163, 184, 0.2); color: #94a3b8; }
+.chat-hosted-badge.error { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
+.chat-hosted-badge.idle { background: rgba(100, 116, 139, 0.12); color: #94a3b8; }
+
+.hosted-agent-panel {
+ position: absolute;
+ right: 16px;
+ bottom: 90px;
+ width: 360px;
+ max-width: calc(100% - 32px);
+ background: var(--bg-primary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ box-shadow: 0 8px 24px rgba(0,0,0,0.2);
+ z-index: 30;
+ overflow: hidden;
+}
+
+.hosted-agent-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 12px;
+ border-bottom: 1px solid var(--border);
+ font-weight: 600;
+}
+
+.hosted-agent-close {
+ background: none;
+ border: none;
+ color: var(--text-tertiary);
+ font-size: 18px;
+ cursor: pointer;
+}
+
+.hosted-agent-body {
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.hosted-agent-prompt {
+ min-height: 88px;
+ resize: vertical;
+}
+
+.hosted-agent-switch {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ font-size: 13px;
+ color: var(--text-secondary);
+}
+
+.hosted-agent-switch input {
+ display: none;
+}
+
+.hosted-agent-track {
+ width: 34px;
+ height: 18px;
+ background: var(--bg-tertiary);
+ border-radius: 999px;
+ position: relative;
+ transition: background 0.2s;
+}
+
+.hosted-agent-track::after {
+ content: '';
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: #fff;
+ transition: transform 0.2s;
+}
+
+.hosted-agent-switch input:checked + .hosted-agent-track {
+ background: var(--accent);
+}
+
+.hosted-agent-switch input:checked + .hosted-agent-track::after {
+ transform: translateX(16px);
+}
+
+.hosted-agent-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 12px;
+ color: var(--text-secondary);
+ padding: 4px 0;
+}
+
+.hosted-agent-tag {
+ color: var(--text-tertiary);
+}
+
+.hosted-agent-advanced {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 8px;
+ background: var(--bg-secondary);
+}
+
+.hosted-agent-advanced-title {
+ font-size: 11px;
+ color: var(--text-tertiary);
+ margin-bottom: 6px;
+}
+
+.hosted-agent-grid {
+ display: grid;
+ gap: 8px;
+ grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
+}
+
+.hosted-agent-actions {
+ display: flex;
+ gap: 6px;
+ justify-content: flex-end;
+}
+
+.hosted-agent-footer {
+ padding: 8px 12px;
+ border-top: 1px solid var(--border);
+ font-size: 11px;
+ color: var(--text-tertiary);
+}
+
+.msg-hosted {
+ background: rgba(100, 116, 139, 0.08);
+ border: 1px dashed rgba(148, 163, 184, 0.4);
+ color: var(--text-secondary);
+}
+
.chat-send-btn:hover:not(:disabled) {
opacity: 0.85;
}
From f3874fb5b73d895ac4b275d561d8bbdda86562d5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Tue, 17 Mar 2026 23:45:32 +0800
Subject: [PATCH 209/426] chore: checkpoint before stability fixes
From 79f76e8be71ce1c32a35344a0f55bda006c0070c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Tue, 17 Mar 2026 23:47:01 +0800
Subject: [PATCH 210/426] fix: harden hosted trigger and cleanup
---
src/lib/ws-client.js | 1 +
src/pages/chat.js | 10 +++++++++-
2 files changed, 10 insertions(+), 1 deletion(-)
diff --git a/src/lib/ws-client.js b/src/lib/ws-client.js
index 88b68053..2067f7a5 100644
--- a/src/lib/ws-client.js
+++ b/src/lib/ws-client.js
@@ -109,6 +109,7 @@ export class WsClient {
}
_doConnect() {
+ if (this._connecting) return
this._connecting = true
this._closeWs()
this._gatewayReady = false
diff --git a/src/pages/chat.js b/src/pages/chat.js
index 2eb7a214..6da6b85e 100644
--- a/src/pages/chat.js
+++ b/src/pages/chat.js
@@ -2242,7 +2242,11 @@ async function saveHostedConfig() {
updateHostedBadge()
if (enabled && _hostedRuntime.status === HOSTED_STATUS.IDLE) {
- runHostedAgentStep()
+ if (!wsClient.gatewayReady || !_sessionKey) {
+ toast('Gateway 未就绪,暂不启动', 'warning')
+ } else {
+ runHostedAgentStep()
+ }
}
if (_hostedGlobalSyncEl?.checked) {
@@ -2671,4 +2675,8 @@ export function cleanup() {
_hostedDefaults = null
_hostedRuntime = { ...HOSTED_RUNTIME_DEFAULT }
_hostedBusy = false
+ _toolEventTimes.clear()
+ _toolEventData.clear()
+ _toolRunIndex.clear()
+ _toolEventSeen.clear()
}
From f5edcc00a751d4663a1a39661fa8f5c561f80f6c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Tue, 17 Mar 2026 23:52:00 +0800
Subject: [PATCH 211/426] chore: checkpoint before fixing hosted errors
From 665883e56d72ed9f6575f1e54d944c6555f4dc32 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Tue, 17 Mar 2026 23:53:53 +0800
Subject: [PATCH 212/426] fix: restore missing chat helpers
---
src/pages/chat.js | 13 +++++++++++--
1 file changed, 11 insertions(+), 2 deletions(-)
diff --git a/src/pages/chat.js b/src/pages/chat.js
index 6da6b85e..be693dc4 100644
--- a/src/pages/chat.js
+++ b/src/pages/chat.js
@@ -108,6 +108,7 @@ let _isStreaming = false, _isSending = false, _messageQueue = [], _streamStartTi
let _lastRenderTime = 0, _renderPending = false, _lastHistoryHash = ''
let _autoScrollEnabled = true, _lastScrollTop = 0, _touchStartY = 0
let _isLoadingHistory = false
+let _virtualEnabled = false
let _streamSafetyTimer = null, _unsubEvent = null, _unsubReady = null, _unsubStatus = null
let _seenRunIds = new Set()
let _pageActive = false
@@ -2058,11 +2059,17 @@ function showLightbox(src) {
document.addEventListener('keydown', onKey)
}
+function insertMessageByTime(wrap, ts) {
+ const tsValue = Number(ts || Date.now())
+ wrap.dataset.ts = String(tsValue)
+ _messagesEl.insertBefore(wrap, _typingEl)
+}
+
function appendSystemMessage(text) {
const wrap = document.createElement('div')
wrap.className = 'msg msg-system'
wrap.textContent = text
- _messagesEl.insertBefore(wrap, _typingEl)
+ insertMessageByTime(wrap, Date.now())
scrollToBottom()
}
@@ -2072,6 +2079,8 @@ function clearMessages() {
_lastScrollTop = 0
}
+function requestVirtualRender() {}
+
function showTyping(show) {
if (_typingEl) _typingEl.style.display = show ? 'flex' : 'none'
if (show) scrollToBottom()
@@ -2083,7 +2092,7 @@ function showCompactionHint(show) {
hint = document.createElement('div')
hint.id = 'compaction-hint'
hint.className = 'msg msg-system compaction-hint'
- hint.innerHTML = '🗜️ 正在整理上下文(Compaction)…'
+ hint.innerHTML = '正在整理上下文(Compaction)…'
_messagesEl.insertBefore(hint, _typingEl)
scrollToBottom()
} else if (!show && hint) {
From 547d90a289d1ee6a77e943de9ee432b2513a2dab Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Tue, 17 Mar 2026 23:58:35 +0800
Subject: [PATCH 213/426] chore: checkpoint before restoring virtual render and
ping
From 2a93385bd6c886a6f7d0e22c9ee3f6572153ddd8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 00:03:29 +0800
Subject: [PATCH 214/426] chore: checkpoint before restore virtual scroll and
ping
From 9b2ebd705a6406c00fab6e3fc935c1e96973bb03 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 00:05:38 +0800
Subject: [PATCH 215/426] fix: restore virtual scroll and node list heartbeat
---
src/lib/ws-client.js | 9 ++-
src/pages/chat.js | 140 ++++++++++++++++++++++++++++++++++++++++---
2 files changed, 138 insertions(+), 11 deletions(-)
diff --git a/src/lib/ws-client.js b/src/lib/ws-client.js
index 2067f7a5..d7c9c56e 100644
--- a/src/lib/ws-client.js
+++ b/src/lib/ws-client.js
@@ -21,7 +21,7 @@ export function uuid() {
const REQUEST_TIMEOUT = 30000
const MAX_RECONNECT_DELAY = 30000
-const PING_INTERVAL = 25000
+const PING_INTERVAL = 5000
const CHALLENGE_TIMEOUT = 5000
export class WsClient {
@@ -350,8 +350,11 @@ export class WsClient {
_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: {} }))
+ } catch {}
}
}, PING_INTERVAL)
}
diff --git a/src/pages/chat.js b/src/pages/chat.js
index be693dc4..477ac79f 100644
--- a/src/pages/chat.js
+++ b/src/pages/chat.js
@@ -6,6 +6,7 @@ import { api, invalidate } from '../lib/tauri-api.js'
import { navigate } from '../router.js'
import { wsClient, uuid } from '../lib/ws-client.js'
import { renderMarkdown } from '../lib/markdown.js'
+import { computeVirtualRange, getSpacerHeights } from '../lib/virtual-scroll.js'
import { saveMessage, saveMessages, getLocalMessages, isStorageAvailable } from '../lib/message-db.js'
import { toast } from '../components/toast.js'
import { showModal, showConfirm } from '../components/modal.js'
@@ -106,9 +107,20 @@ let _hostedBusy = false
let _currentAiBubble = null, _currentAiText = '', _currentAiImages = [], _currentAiVideos = [], _currentAiAudios = [], _currentAiFiles = [], _currentAiTools = [], _currentRunId = null
let _isStreaming = false, _isSending = false, _messageQueue = [], _streamStartTime = 0
let _lastRenderTime = 0, _renderPending = false, _lastHistoryHash = ''
-let _autoScrollEnabled = true, _lastScrollTop = 0, _touchStartY = 0
let _isLoadingHistory = false
-let _virtualEnabled = false
+
+const VIRTUAL_WINDOW = 40
+const VIRTUAL_OVERSCAN = 20
+let _virtualEnabled = true
+let _virtualHeights = new Map()
+let _virtualAvgHeight = 64
+let _virtualRange = { start: 0, end: 0, prefix: [] }
+let _virtualItems = []
+let _virtualTopSpacer = null
+let _virtualBottomSpacer = null
+let _virtualRenderPending = false
+let _autoScrollEnabled = true, _lastScrollTop = 0, _touchStartY = 0
+
let _streamSafetyTimer = null, _unsubEvent = null, _unsubReady = null, _unsubStatus = null
let _seenRunIds = new Set()
let _pageActive = false
@@ -2062,25 +2074,51 @@ function showLightbox(src) {
function insertMessageByTime(wrap, ts) {
const tsValue = Number(ts || Date.now())
wrap.dataset.ts = String(tsValue)
- _messagesEl.insertBefore(wrap, _typingEl)
+
+ if (!_virtualEnabled) {
+ const items = Array.from(_messagesEl.querySelectorAll('.msg'))
+ for (const node of items) {
+ const nodeTs = parseInt(node.dataset.ts || '0', 10)
+ if (nodeTs > tsValue) {
+ _messagesEl.insertBefore(wrap, node)
+ return
+ }
+ }
+ _messagesEl.insertBefore(wrap, _typingEl)
+ return
+ }
+
+ if (!wrap.dataset.vid) wrap.dataset.vid = uuid()
+ const vid = wrap.dataset.vid
+ const existingIdx = _virtualItems.findIndex(item => item.id === vid)
+ const entry = { id: vid, ts: tsValue, node: wrap }
+ if (existingIdx >= 0) _virtualItems.splice(existingIdx, 1)
+ let insertIdx = _virtualItems.findIndex(item => item.ts > tsValue)
+ if (insertIdx < 0) insertIdx = _virtualItems.length
+ _virtualItems.splice(insertIdx, 0, entry)
+ requestVirtualRender(true)
}
-function appendSystemMessage(text) {
+function appendSystemMessage(text, ts) {
const wrap = document.createElement('div')
wrap.className = 'msg msg-system'
wrap.textContent = text
- insertMessageByTime(wrap, Date.now())
+ insertMessageByTime(wrap, ts)
scrollToBottom()
}
function clearMessages() {
_messagesEl.querySelectorAll('.msg').forEach(m => m.remove())
+ _virtualItems = []
+ _virtualHeights = new Map()
+ _virtualAvgHeight = 64
+ _virtualRange = { start: 0, end: 0, prefix: [] }
_autoScrollEnabled = true
_lastScrollTop = 0
+ if (_virtualTopSpacer) _virtualTopSpacer.style.height = '0px'
+ if (_virtualBottomSpacer) _virtualBottomSpacer.style.height = '0px'
}
-function requestVirtualRender() {}
-
function showTyping(show) {
if (_typingEl) _typingEl.style.display = show ? 'flex' : 'none'
if (show) scrollToBottom()
@@ -2108,7 +2146,93 @@ function scrollToBottom(force = false) {
function isAtBottom() {
if (!_messagesEl) return true
- return _messagesEl.scrollHeight - _messagesEl.scrollTop - _messagesEl.clientHeight < 80
+ const threshold = 80
+ return _messagesEl.scrollHeight - _messagesEl.scrollTop - _messagesEl.clientHeight < threshold
+}
+
+function ensureVirtualSpacers() {
+ if (!_messagesEl) return
+ if (!_virtualTopSpacer || _virtualTopSpacer.parentNode !== _messagesEl) {
+ _virtualTopSpacer = document.createElement('div')
+ _virtualTopSpacer.className = 'msg-virtual-spacer'
+ _messagesEl.insertBefore(_virtualTopSpacer, _messagesEl.firstChild)
+ }
+ if (!_virtualBottomSpacer || _virtualBottomSpacer.parentNode !== _messagesEl) {
+ _virtualBottomSpacer = document.createElement('div')
+ _virtualBottomSpacer.className = 'msg-virtual-spacer'
+ if (_typingEl && _typingEl.parentNode === _messagesEl) {
+ _messagesEl.insertBefore(_virtualBottomSpacer, _typingEl)
+ } else {
+ _messagesEl.appendChild(_virtualBottomSpacer)
+ }
+ }
+}
+
+function requestVirtualRender(force = false) {
+ if (!_virtualEnabled || !_messagesEl) return
+ if (_virtualRenderPending && !force) return
+ _virtualRenderPending = true
+ requestAnimationFrame(() => {
+ _virtualRenderPending = false
+ doVirtualRender()
+ })
+}
+
+function doVirtualRender() {
+ if (!_virtualEnabled || !_messagesEl) return
+ ensureVirtualSpacers()
+ const atBottom = isAtBottom()
+ const scrollTop = _messagesEl.scrollTop
+ const viewport = _messagesEl.clientHeight
+ const items = _virtualItems
+ const { start, end, prefix } = computeVirtualRange(items, scrollTop, viewport, _virtualAvgHeight, VIRTUAL_OVERSCAN, VIRTUAL_WINDOW, _virtualHeights)
+ _virtualRange = { start, end, prefix }
+ const { top, bottom } = getSpacerHeights(prefix, start, end)
+ _virtualTopSpacer.style.height = `${top}px`
+ _virtualBottomSpacer.style.height = `${bottom}px`
+
+ const visibleIds = new Set(items.slice(start, end).map(i => i.id))
+ _messagesEl.querySelectorAll('.msg').forEach(node => {
+ const vid = node.dataset.vid
+ if (!vid || !visibleIds.has(vid)) node.remove()
+ })
+
+ const anchor = _virtualTopSpacer.nextSibling
+ let refNode = anchor
+ for (let i = start; i < end; i++) {
+ const item = items[i]
+ if (!item?.node) continue
+ if (refNode && refNode.parentNode !== _messagesEl) refNode = _virtualBottomSpacer
+ if (_virtualBottomSpacer && _virtualBottomSpacer.parentNode !== _messagesEl) {
+ _messagesEl.appendChild(_virtualBottomSpacer)
+ }
+ if (item.node.parentNode !== _messagesEl) {
+ _messagesEl.insertBefore(item.node, refNode || _virtualBottomSpacer)
+ }
+ refNode = item.node.nextSibling
+ }
+
+ requestAnimationFrame(() => {
+ let total = 0
+ let count = 0
+ items.slice(start, end).forEach(item => {
+ const el = item.node
+ if (!el || !el.getBoundingClientRect) return
+ const h = Math.max(1, Math.ceil(el.getBoundingClientRect().height))
+ if (h) {
+ _virtualHeights.set(item.id, h)
+ total += h
+ count += 1
+ }
+ })
+ if (count) _virtualAvgHeight = Math.max(24, Math.round(total / count))
+
+ if (!atBottom || !_autoScrollEnabled) {
+ const newTop = _virtualTopSpacer.offsetHeight
+ const delta = newTop - top
+ if (delta !== 0) _messagesEl.scrollTop = scrollTop + delta
+ }
+ })
}
function updateSendState() {
From 88115eb320195a4f4dcf5d230cb80fd316d2b34d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 00:21:26 +0800
Subject: [PATCH 216/426] chore: checkpoint before rust warning fixes
From 3b57bb35051b5a0f06d7d8ce2a54f405f6e38500 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 00:22:53 +0800
Subject: [PATCH 217/426] fix: suppress unused config warnings
---
src-tauri/src/commands/config.rs | 1 +
1 file changed, 1 insertion(+)
diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs
index bc9468af..43fce79f 100644
--- a/src-tauri/src/commands/config.rs
+++ b/src-tauri/src/commands/config.rs
@@ -1146,6 +1146,7 @@ fn npm_global_modules_dir() -> Option
{
}
/// npm 全局 bin 目录
+#[allow(dead_code)]
fn npm_global_bin_dir() -> Option {
#[cfg(target_os = "windows")]
{
From d9a6df2efe5eb3b743a38c4ffde048437f7d2a7e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 00:40:46 +0800
Subject: [PATCH 218/426] chore: checkpoint before history render fix
From dc45a93d7b9c84c71920fa06e78999b56e4df06e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 00:42:22 +0800
Subject: [PATCH 219/426] fix: render system and object messages in history
---
src/pages/chat.js | 13 ++++++++++++-
1 file changed, 12 insertions(+), 1 deletion(-)
diff --git a/src/pages/chat.js b/src/pages/chat.js
index 477ac79f..c788b444 100644
--- a/src/pages/chat.js
+++ b/src/pages/chat.js
@@ -1610,6 +1610,10 @@ async function loadHistory() {
else if (msg.role === 'assistant') {
const images = (msg.attachments || []).filter(a => a.category === 'image').map(a => ({ mediaType: a.mimeType, data: a.content, url: a.url }))
appendAiMessage(msg.content || '', msgTime, images, [], [], [], [])
+ } else if (msg.role === 'system') {
+ appendSystemMessage(msg.content || '', msgTime?.getTime?.() || Date.now())
+ } else {
+ appendSystemMessage(msg.content || '', msgTime?.getTime?.() || Date.now())
}
})
scrollToBottom()
@@ -1653,6 +1657,10 @@ async function loadHistory() {
appendUserMessage(msg.text, userAtts, msgTime)
} else if (msg.role === 'assistant') {
appendAiMessage(msg.text, msgTime, msg.images, msg.videos, msg.audios, msg.files, msg.tools)
+ } else if (msg.role === 'system') {
+ appendSystemMessage(msg.text || '', msgTime?.getTime?.() || Date.now())
+ } else {
+ appendSystemMessage(msg.text || '', msgTime?.getTime?.() || Date.now())
}
})
if (hasOmittedImages) {
@@ -1783,7 +1791,10 @@ function extractContent(msg) {
}
return { text: stripThinkingTags(texts.join('\n')), images, videos, audios, files, tools }
}
- const text = typeof msg.text === 'string' ? msg.text : (typeof msg.content === 'string' ? msg.content : '')
+ let text = ''
+ if (typeof msg.text === 'string') text = msg.text
+ else if (typeof msg.content === 'string') text = msg.content
+ else if (msg.content && typeof msg.content === 'object') text = safeStringify(msg.content)
return { text: stripThinkingTags(text), images: [], videos: [], audios: [], files: [], tools }
}
From 959729316bc8d3b4847be312afc9fa703a158b9c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 00:48:49 +0800
Subject: [PATCH 220/426] chore: checkpoint before chat render fixes
From 0402d75b86e5e0477b3f5c3af1634b2b73fda8f6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 00:50:09 +0800
Subject: [PATCH 221/426] fix: protect virtual messages and clear tool cache
---
src/pages/chat.js | 11 ++++++++---
1 file changed, 8 insertions(+), 3 deletions(-)
diff --git a/src/pages/chat.js b/src/pages/chat.js
index c788b444..fd2d5d8f 100644
--- a/src/pages/chat.js
+++ b/src/pages/chat.js
@@ -1537,7 +1537,7 @@ function createStreamBubble() {
bubble.className = 'msg-bubble'
bubble.innerHTML = ''
wrap.appendChild(bubble)
- _messagesEl.insertBefore(wrap, _typingEl)
+ insertMessageByTime(wrap, Date.now())
scrollToBottom()
return bubble
}
@@ -2123,9 +2123,13 @@ function clearMessages() {
_virtualItems = []
_virtualHeights = new Map()
_virtualAvgHeight = 64
- _virtualRange = { start: 0, end: 0, prefix: [] }
+ _virtualRange = { start: 0, end: 0, prefix: [0] }
_autoScrollEnabled = true
_lastScrollTop = 0
+ _toolEventTimes.clear()
+ _toolEventData.clear()
+ _toolRunIndex.clear()
+ _toolEventSeen.clear()
if (_virtualTopSpacer) _virtualTopSpacer.style.height = '0px'
if (_virtualBottomSpacer) _virtualBottomSpacer.style.height = '0px'
}
@@ -2205,7 +2209,8 @@ function doVirtualRender() {
const visibleIds = new Set(items.slice(start, end).map(i => i.id))
_messagesEl.querySelectorAll('.msg').forEach(node => {
const vid = node.dataset.vid
- if (!vid || !visibleIds.has(vid)) node.remove()
+ if (!vid) return
+ if (!visibleIds.has(vid)) node.remove()
})
const anchor = _virtualTopSpacer.nextSibling
From f5c52c8bb022fba3e5790b53f3562b92eefd0e45 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 01:10:41 +0800
Subject: [PATCH 222/426] chore: checkpoint before batch optimizations
From f8ec8be4eb09405861786f008a3a5469366c4dec Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 01:11:57 +0800
Subject: [PATCH 223/426] chore: checkpoint before tool key and reaction fixes
From fb910e4ee9cf25d1a675f0c30fead8b6ea48a616 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 01:15:27 +0800
Subject: [PATCH 224/426] fix: scope tool events by run and remove emoji
reactions
---
src/pages/chat.js | 60 +++++++++++++++++++++++++++++------------------
1 file changed, 37 insertions(+), 23 deletions(-)
diff --git a/src/pages/chat.js b/src/pages/chat.js
index fd2d5d8f..00f88f63 100644
--- a/src/pages/chat.js
+++ b/src/pages/chat.js
@@ -1127,20 +1127,21 @@ function handleEvent(msg) {
if (event === 'agent' && payload?.stream === 'tool' && payload?.data?.toolCallId) {
const ts = payload.ts
const toolCallId = payload.data.toolCallId
- const runKey = `${payload.runId}:${toolCallId}`
+ const runId = payload.runId || ''
+ const runKey = runId ? `${runId}:${toolCallId}` : toolCallId
if (_toolEventSeen.has(runKey)) return
_toolEventSeen.add(runKey)
- if (ts) _toolEventTimes.set(toolCallId, ts)
- const current = _toolEventData.get(toolCallId) || {}
+ if (ts) _toolEventTimes.set(runKey, ts)
+ const current = _toolEventData.get(runKey) || {}
if (payload.data?.args && current.input == null) current.input = payload.data.args
if (payload.data?.meta && current.output == null) current.output = payload.data.meta
if (typeof payload.data?.isError === 'boolean' && current.status == null) current.status = payload.data.isError ? 'error' : 'ok'
if (current.time == null) current.time = ts || null
- _toolEventData.set(toolCallId, current)
- if (payload.runId) {
- const list = _toolRunIndex.get(payload.runId) || []
+ _toolEventData.set(runKey, current)
+ if (runId) {
+ const list = _toolRunIndex.get(runId) || []
if (!list.includes(toolCallId)) list.push(toolCallId)
- _toolRunIndex.set(payload.runId, list)
+ _toolRunIndex.set(runId, list)
}
}
@@ -1157,9 +1158,9 @@ function handleEvent(msg) {
// Compaction 状态指示:上游 2026.3.12 新增 status_reaction 事件
if (event === 'chat.status_reaction' || event === 'status_reaction') {
const reaction = payload.reaction || payload.emoji || ''
- if (reaction.includes('compact') || reaction === '🗜️' || reaction === '📦') {
+ if (reaction.includes('compact')) {
showCompactionHint(true)
- } else if (!reaction || reaction === 'thinking' || reaction === '💭') {
+ } else if (!reaction || reaction === 'thinking') {
showCompactionHint(false)
}
}
@@ -1407,7 +1408,7 @@ function extractChatContent(message) {
input: block.input || block.args || block.parameters || block.arguments || null,
output: null,
status: block.status || 'ok',
- time: resolveToolTime(callId, message.timestamp),
+ time: resolveToolTime(callId, message.timestamp, message.runId),
})
}
else if (block.type === 'tool_result' || block.type === 'toolResult') {
@@ -1418,7 +1419,7 @@ function extractChatContent(message) {
input: block.input || block.args || null,
output: block.output || block.result || block.content || null,
status: block.status || 'ok',
- time: resolveToolTime(resId, message.timestamp),
+ time: resolveToolTime(resId, message.timestamp, message.runId),
})
}
}
@@ -1482,8 +1483,14 @@ function normalizeTime(raw) {
return raw
}
-function resolveToolTime(toolId, messageTimestamp) {
- const eventTs = toolId ? _toolEventTimes.get(toolId) : null
+function resolveToolTime(toolId, messageTimestamp, runId) {
+ const key = runId ? `${runId}:${toolId}` : toolId
+ let eventTs = toolId ? _toolEventTimes.get(key) : null
+ if (!eventTs && runId) {
+ for (const [k, v] of _toolEventTimes.entries()) {
+ if (k.endsWith(`:${toolId}`)) { eventTs = v; break }
+ }
+ }
return normalizeTime(eventTs) || normalizeTime(messageTimestamp) || null
}
@@ -1688,8 +1695,8 @@ function dedupeHistory(messages) {
if (!c.text && !c.images.length && !c.videos.length && !c.audios.length && !c.files.length && !c.tools.length) continue
const tools = (c.tools || []).map(t => {
const id = t.id || t.tool_call_id
- const time = t.time || resolveToolTime(id, msg.timestamp)
- return { ...t, time, messageTimestamp: msg.timestamp }
+ const time = t.time || resolveToolTime(id, msg.timestamp, msg.runId)
+ return { ...t, time, messageTimestamp: msg.timestamp, runId: msg.runId }
})
const last = deduped[deduped.length - 1]
if (last && last.role === role) {
@@ -1724,7 +1731,7 @@ function extractContent(msg) {
input: msg.input || msg.args || msg.parameters || null,
output: output || msg.output || msg.result || null,
status: msg.status || 'ok',
- time: resolveToolTime(msg.tool_call_id || msg.toolCallId || msg.id, msg.timestamp),
+ time: resolveToolTime(msg.tool_call_id || msg.toolCallId || msg.id, msg.timestamp, msg.runId),
})
} else if (output && !tools[0].output) {
tools[0].output = output
@@ -1760,7 +1767,7 @@ function extractContent(msg) {
input: block.input || block.args || block.parameters || block.arguments || null,
output: null,
status: block.status || 'ok',
- time: resolveToolTime(callId, msg.timestamp),
+ time: resolveToolTime(callId, msg.timestamp, msg.runId),
})
}
else if (block.type === 'tool_result' || block.type === 'toolResult') {
@@ -1771,7 +1778,7 @@ function extractContent(msg) {
input: block.input || block.args || null,
output: block.output || block.result || block.content || null,
status: block.status || 'ok',
- time: resolveToolTime(resId, msg.timestamp),
+ time: resolveToolTime(resId, msg.timestamp, msg.runId),
})
}
}
@@ -1976,12 +1983,19 @@ function appendFilesToEl(el, files) {
function mergeToolEventData(entry) {
const id = entry?.id || entry?.tool_call_id
if (!id) return entry
- const extra = _toolEventData.get(id)
+ const runId = entry?.runId || entry?.run_id || entry?.run || ''
+ const key = runId ? `${runId}:${id}` : id
+ let extra = _toolEventData.get(key)
+ if (!extra && runId) {
+ for (const [k, v] of _toolEventData.entries()) {
+ if (k.endsWith(`:${id}`)) { extra = v; break }
+ }
+ }
if (!extra) return entry
if (entry.input == null && extra.input != null) entry.input = extra.input
if (entry.output == null && extra.output != null) entry.output = extra.output
if (entry.status == null && extra.status != null) entry.status = extra.status
- if (entry.time == null) entry.time = extra.time || _toolEventTimes.get(id) || null
+ if (entry.time == null) entry.time = extra.time || _toolEventTimes.get(key) || null
return entry
}
@@ -2016,7 +2030,7 @@ function collectToolsFromMessage(message, tools) {
input,
output: null,
status: call.status || 'ok',
- time: resolveToolTime(callId, message?.timestamp),
+ time: resolveToolTime(callId, message?.timestamp, message?.runId),
})
})
}
@@ -2030,7 +2044,7 @@ function collectToolsFromMessage(message, tools) {
input: res.input || res.args || null,
output: res.output || res.result || res.content || null,
status: res.status || 'ok',
- time: resolveToolTime(resId, message?.timestamp),
+ time: resolveToolTime(resId, message?.timestamp, message?.runId),
})
})
}
@@ -2051,7 +2065,7 @@ function appendToolsToEl(el, tools) {
details.className = 'msg-tool-item'
const summary = document.createElement('summary')
const status = tool.status === 'error' ? '失败' : '成功'
- const timeValue = getToolTime(tool) || resolveToolTime(tool.id || tool.tool_call_id, tool.messageTimestamp)
+ const timeValue = getToolTime(tool) || resolveToolTime(tool.id || tool.tool_call_id, tool.messageTimestamp, tool.runId)
const timeText = timeValue ? formatTime(new Date(timeValue)) : ''
summary.innerHTML = `${escapeHtml(tool.name || '工具')} · ${status}${timeText ? ' · ' + timeText : ''}`
const body = document.createElement('div')
From 3e5aae61b0e1ecab130d98250a17dec378fe1aaa Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 01:19:18 +0800
Subject: [PATCH 225/426] chore: checkpoint before stability batch
From 1a7ce5cc40f8ed5870455c9135faf652c348a01f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 01:26:20 +0800
Subject: [PATCH 226/426] fix: add aborts timeouts and ws send guards
---
src-tauri/src/commands/cloudflared.rs | 10 ++++
src/lib/tauri-api.js | 21 +++++--
src/lib/ws-client.js | 15 ++++-
src/pages/chat.js | 80 +++++++++++++++++----------
4 files changed, 92 insertions(+), 34 deletions(-)
diff --git a/src-tauri/src/commands/cloudflared.rs b/src-tauri/src/commands/cloudflared.rs
index 5528d24b..f50cae2f 100644
--- a/src-tauri/src/commands/cloudflared.rs
+++ b/src-tauri/src/commands/cloudflared.rs
@@ -403,6 +403,16 @@ pub async fn cloudflared_start(config: CloudflaredStartConfig) -> Result controller.abort(), 20000)
+ 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()
diff --git a/src/lib/ws-client.js b/src/lib/ws-client.js
index d7c9c56e..90b198b5 100644
--- a/src/lib/ws-client.js
+++ b/src/lib/ws-client.js
@@ -47,6 +47,7 @@ export class WsClient {
this._challengeTimer = null
this._wsId = 0
this._autoPairAttempts = 0
+ this._autoPairing = false
this._serverVersion = null
}
@@ -236,7 +237,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()
@@ -259,6 +264,8 @@ export class WsClient {
} catch (e) {
console.error('[ws] 自动配对失败:', e)
this._setConnected(false, 'error', `配对失败: ${e}`)
+ } finally {
+ this._autoPairing = false
}
}
@@ -383,7 +390,13 @@ 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 }))
+ 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/pages/chat.js b/src/pages/chat.js
index 00f88f63..13b12a4e 100644
--- a/src/pages/chat.js
+++ b/src/pages/chat.js
@@ -104,6 +104,7 @@ let _hostedRuntime = { ...HOSTED_RUNTIME_DEFAULT }
let _hostedAutoTimer = null
let _hostedLastTargetTs = 0
let _hostedBusy = false
+let _hostedAbort = null
let _currentAiBubble = null, _currentAiText = '', _currentAiImages = [], _currentAiVideos = [], _currentAiAudios = [], _currentAiFiles = [], _currentAiTools = [], _currentRunId = null
let _isStreaming = false, _isSending = false, _messageQueue = [], _streamStartTime = 0
let _lastRenderTime = 0, _renderPending = false, _lastHistoryHash = ''
@@ -2246,6 +2247,7 @@ function doVirtualRender() {
let total = 0
let count = 0
items.slice(start, end).forEach(item => {
+ if (_virtualHeights.has(item.id)) return
const el = item.node
if (!el || !el.getBoundingClientRect) return
const h = Math.max(1, Math.ceil(el.getBoundingClientRect().height))
@@ -2590,15 +2592,23 @@ async function callHostedAI(messages, onChunk) {
const systemPrompt = messages.find(m => m.role === 'system')?.content || ''
const chatMessages = messages.filter(m => m.role !== 'system')
- if (apiType === 'anthropic-messages') {
- await callAnthropicHosted(base, systemPrompt, chatMessages, config, onChunk)
- return
- }
- if (apiType === 'google-gemini') {
- await callGeminiHosted(base, systemPrompt, chatMessages, config, onChunk)
- return
+ if (_hostedAbort) { _hostedAbort.abort(); _hostedAbort = null }
+ _hostedAbort = new AbortController()
+ const signal = _hostedAbort.signal
+
+ try {
+ if (apiType === 'anthropic-messages') {
+ await callAnthropicHosted(base, systemPrompt, chatMessages, config, onChunk, signal)
+ return
+ }
+ if (apiType === 'google-gemini') {
+ await callGeminiHosted(base, systemPrompt, chatMessages, config, onChunk, signal)
+ return
+ }
+ await callChatCompletionsHosted(base, systemPrompt, chatMessages, config, onChunk, signal)
+ } finally {
+ _hostedAbort = null
}
- await callChatCompletionsHosted(base, systemPrompt, chatMessages, config, onChunk)
}
async function loadHostedAssistantConfig() {
@@ -2674,34 +2684,44 @@ async function fetchWithRetry(url, options, retries = 2) {
if (resp.ok || resp.status < 500 || i >= retries) return resp
await new Promise(r => setTimeout(r, delays[i]))
} catch (err) {
+ if (options?.signal?.aborted) throw err
if (i >= retries) throw err
await new Promise(r => setTimeout(r, delays[i]))
}
}
}
-async function readSSEStream(resp, onEvent) {
+async function readSSEStream(resp, onEvent, signal) {
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
- while (true) {
- const { done, value } = await reader.read()
- if (done) break
- buffer += decoder.decode(value, { stream: true })
- const lines = buffer.split('\n')
- buffer = lines.pop() || ''
- for (const line of lines) {
- const trimmed = line.trim()
- if (!trimmed) continue
- if (!trimmed.startsWith('data:')) continue
- const data = trimmed.slice(5).trim()
- if (data === '[DONE]') return
- try { onEvent(JSON.parse(data)) } catch {}
+ let aborted = false
+ const onAbort = () => { aborted = true }
+ if (signal) signal.addEventListener('abort', onAbort)
+ try {
+ while (true) {
+ if (aborted) break
+ const { done, value } = await reader.read()
+ if (done) break
+ buffer += decoder.decode(value, { stream: true })
+ const lines = buffer.split('\n')
+ buffer = lines.pop() || ''
+ for (const line of lines) {
+ const trimmed = line.trim()
+ if (!trimmed) continue
+ if (!trimmed.startsWith('data:')) continue
+ const data = trimmed.slice(5).trim()
+ if (data === '[DONE]') return
+ try { onEvent(JSON.parse(data)) } catch {}
+ }
}
+ } finally {
+ if (signal) signal.removeEventListener('abort', onAbort)
+ try { reader.cancel() } catch {}
}
}
-async function callChatCompletionsHosted(base, systemPrompt, messages, config, onChunk) {
+async function callChatCompletionsHosted(base, systemPrompt, messages, config, onChunk, signal) {
const body = {
model: config.model,
messages: [systemPrompt ? { role: 'system', content: systemPrompt } : null, ...messages].filter(Boolean),
@@ -2712,6 +2732,7 @@ async function callChatCompletionsHosted(base, systemPrompt, messages, config, o
method: 'POST',
headers: authHeaders(config.apiType, config.apiKey),
body: JSON.stringify(body),
+ signal,
})
if (!resp.ok) {
const errText = await resp.text().catch(() => '')
@@ -2722,10 +2743,10 @@ async function callChatCompletionsHosted(base, systemPrompt, messages, config, o
await readSSEStream(resp, (json) => {
const delta = json.choices?.[0]?.delta
if (delta?.content) onChunk(delta.content)
- })
+ }, signal)
}
-async function callAnthropicHosted(base, systemPrompt, messages, config, onChunk) {
+async function callAnthropicHosted(base, systemPrompt, messages, config, onChunk, signal) {
const body = {
model: config.model,
max_tokens: 4096,
@@ -2738,6 +2759,7 @@ async function callAnthropicHosted(base, systemPrompt, messages, config, onChunk
method: 'POST',
headers: authHeaders(config.apiType, config.apiKey),
body: JSON.stringify(body),
+ signal,
})
if (!resp.ok) {
const errText = await resp.text().catch(() => '')
@@ -2750,10 +2772,10 @@ async function callAnthropicHosted(base, systemPrompt, messages, config, onChunk
const delta = json.delta
if (delta?.type === 'text_delta' && delta.text) onChunk(delta.text)
}
- })
+ }, signal)
}
-async function callGeminiHosted(base, systemPrompt, messages, config, onChunk) {
+async function callGeminiHosted(base, systemPrompt, messages, config, onChunk, signal) {
const contents = messages.map(m => ({
role: m.role === 'assistant' ? 'model' : 'user',
parts: [{ text: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) }],
@@ -2768,6 +2790,7 @@ async function callGeminiHosted(base, systemPrompt, messages, config, onChunk) {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
+ signal,
})
if (!resp.ok) {
const errText = await resp.text().catch(() => '')
@@ -2778,7 +2801,7 @@ async function callGeminiHosted(base, systemPrompt, messages, config, onChunk) {
await readSSEStream(resp, (json) => {
const text = json.candidates?.[0]?.content?.parts?.[0]?.text
if (text) onChunk(text)
- })
+ }, signal)
}
function appendHostedOutput(text) {
@@ -2798,6 +2821,7 @@ export function cleanup() {
if (_unsubReady) { _unsubReady(); _unsubReady = null }
if (_unsubStatus) { _unsubStatus(); _unsubStatus = null }
clearTimeout(_streamSafetyTimer)
+ if (_hostedAbort) { _hostedAbort.abort(); _hostedAbort = null }
// 不断开 wsClient —— 它是全局单例,保持连接供下次进入复用
_sessionKey = null
_page = null
From e9579b7ebf4146ae8506d60ceeda84241151bba8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 01:35:22 +0800
Subject: [PATCH 227/426] chore: checkpoint before virtual prefix cache
From bc8828feb21d61d1749d35a04f66718e37e3e5eb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 01:38:01 +0800
Subject: [PATCH 228/426] perf: cache virtual prefix heights
---
src/pages/chat.js | 16 ++++++++++++++--
1 file changed, 14 insertions(+), 2 deletions(-)
diff --git a/src/pages/chat.js b/src/pages/chat.js
index 13b12a4e..7be8524c 100644
--- a/src/pages/chat.js
+++ b/src/pages/chat.js
@@ -6,7 +6,7 @@ import { api, invalidate } from '../lib/tauri-api.js'
import { navigate } from '../router.js'
import { wsClient, uuid } from '../lib/ws-client.js'
import { renderMarkdown } from '../lib/markdown.js'
-import { computeVirtualRange, getSpacerHeights } from '../lib/virtual-scroll.js'
+import { buildPrefixHeights, findStartIndex, getSpacerHeights } from '../lib/virtual-scroll.js'
import { saveMessage, saveMessages, getLocalMessages, isStorageAvailable } from '../lib/message-db.js'
import { toast } from '../components/toast.js'
import { showModal, showConfirm } from '../components/modal.js'
@@ -117,6 +117,8 @@ let _virtualHeights = new Map()
let _virtualAvgHeight = 64
let _virtualRange = { start: 0, end: 0, prefix: [] }
let _virtualItems = []
+let _virtualPrefix = [0]
+let _virtualPrefixDirty = true
let _virtualTopSpacer = null
let _virtualBottomSpacer = null
let _virtualRenderPending = false
@@ -2122,6 +2124,7 @@ function insertMessageByTime(wrap, ts) {
let insertIdx = _virtualItems.findIndex(item => item.ts > tsValue)
if (insertIdx < 0) insertIdx = _virtualItems.length
_virtualItems.splice(insertIdx, 0, entry)
+ _virtualPrefixDirty = true
requestVirtualRender(true)
}
@@ -2139,6 +2142,8 @@ function clearMessages() {
_virtualHeights = new Map()
_virtualAvgHeight = 64
_virtualRange = { start: 0, end: 0, prefix: [0] }
+ _virtualPrefix = [0]
+ _virtualPrefixDirty = true
_autoScrollEnabled = true
_lastScrollTop = 0
_toolEventTimes.clear()
@@ -2215,7 +2220,13 @@ function doVirtualRender() {
const scrollTop = _messagesEl.scrollTop
const viewport = _messagesEl.clientHeight
const items = _virtualItems
- const { start, end, prefix } = computeVirtualRange(items, scrollTop, viewport, _virtualAvgHeight, VIRTUAL_OVERSCAN, VIRTUAL_WINDOW, _virtualHeights)
+ if (_virtualPrefixDirty) {
+ _virtualPrefix = buildPrefixHeights(items, _virtualHeights, _virtualAvgHeight)
+ _virtualPrefixDirty = false
+ }
+ const prefix = _virtualPrefix
+ const start = Math.max(0, findStartIndex(prefix, scrollTop) - VIRTUAL_OVERSCAN)
+ const end = Math.min(items.length, start + VIRTUAL_WINDOW + VIRTUAL_OVERSCAN * 2)
_virtualRange = { start, end, prefix }
const { top, bottom } = getSpacerHeights(prefix, start, end)
_virtualTopSpacer.style.height = `${top}px`
@@ -2253,6 +2264,7 @@ function doVirtualRender() {
const h = Math.max(1, Math.ceil(el.getBoundingClientRect().height))
if (h) {
_virtualHeights.set(item.id, h)
+ _virtualPrefixDirty = true
total += h
count += 1
}
From 5cc110acda581e079fa0f0d82d34e2372864a2dc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 01:40:36 +0800
Subject: [PATCH 229/426] chore: checkpoint before resizeobserver cache
From b3f9f3b69de31be5d973bcafe6011d2c9acd22cf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 01:42:00 +0800
Subject: [PATCH 230/426] perf: add resizeobserver for virtual heights
---
src/pages/chat.js | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/src/pages/chat.js b/src/pages/chat.js
index 7be8524c..4ce2c4e6 100644
--- a/src/pages/chat.js
+++ b/src/pages/chat.js
@@ -122,6 +122,7 @@ let _virtualPrefixDirty = true
let _virtualTopSpacer = null
let _virtualBottomSpacer = null
let _virtualRenderPending = false
+let _virtualObserver = null
let _autoScrollEnabled = true, _lastScrollTop = 0, _touchStartY = 0
let _streamSafetyTimer = null, _unsubEvent = null, _unsubReady = null, _unsubStatus = null
@@ -2099,6 +2100,20 @@ function showLightbox(src) {
document.addEventListener('keydown', onKey)
}
+function ensureVirtualObserver() {
+ if (_virtualObserver || typeof ResizeObserver === 'undefined') return
+ _virtualObserver = new ResizeObserver(entries => {
+ for (const entry of entries) {
+ const el = entry.target
+ const id = el?.dataset?.vid
+ if (!id) continue
+ const h = Math.max(1, Math.ceil(entry.contentRect?.height || el.getBoundingClientRect().height))
+ _virtualHeights.set(id, h)
+ _virtualPrefixDirty = true
+ }
+ })
+}
+
function insertMessageByTime(wrap, ts) {
const tsValue = Number(ts || Date.now())
wrap.dataset.ts = String(tsValue)
@@ -2117,6 +2132,8 @@ function insertMessageByTime(wrap, ts) {
}
if (!wrap.dataset.vid) wrap.dataset.vid = uuid()
+ ensureVirtualObserver()
+ if (_virtualObserver) _virtualObserver.observe(wrap)
const vid = wrap.dataset.vid
const existingIdx = _virtualItems.findIndex(item => item.id === vid)
const entry = { id: vid, ts: tsValue, node: wrap }
@@ -2144,6 +2161,7 @@ function clearMessages() {
_virtualRange = { start: 0, end: 0, prefix: [0] }
_virtualPrefix = [0]
_virtualPrefixDirty = true
+ if (_virtualObserver) { _virtualObserver.disconnect(); _virtualObserver = null }
_autoScrollEnabled = true
_lastScrollTop = 0
_toolEventTimes.clear()
From 137c7eaa226d92d1a529cba174f90650b4207f3c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 01:45:42 +0800
Subject: [PATCH 231/426] fix: abort hosted stream after timeout
---
src/pages/chat.js | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/src/pages/chat.js b/src/pages/chat.js
index 4ce2c4e6..0dc2fb99 100644
--- a/src/pages/chat.js
+++ b/src/pages/chat.js
@@ -2625,6 +2625,9 @@ async function callHostedAI(messages, onChunk) {
if (_hostedAbort) { _hostedAbort.abort(); _hostedAbort = null }
_hostedAbort = new AbortController()
const signal = _hostedAbort.signal
+ const timeout = setTimeout(() => {
+ if (_hostedAbort) _hostedAbort.abort()
+ }, 120000)
try {
if (apiType === 'anthropic-messages') {
@@ -2637,6 +2640,7 @@ async function callHostedAI(messages, onChunk) {
}
await callChatCompletionsHosted(base, systemPrompt, chatMessages, config, onChunk, signal)
} finally {
+ clearTimeout(timeout)
_hostedAbort = null
}
}
@@ -2730,7 +2734,7 @@ async function readSSEStream(resp, onEvent, signal) {
if (signal) signal.addEventListener('abort', onAbort)
try {
while (true) {
- if (aborted) break
+ if (aborted) throw new Error('托管流式请求已中止')
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
From f21d9d8c8a81c3805de7883889744cff5e0c1bf3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 01:47:35 +0800
Subject: [PATCH 232/426] chore: checkpoint before ws state and timeouts
From fe6e27471745689326d8b84eeee82488a6fdc0ee Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 01:49:46 +0800
Subject: [PATCH 233/426] fix: add ws state and per-command timeouts
---
src/lib/tauri-api.js | 12 +++++++++++-
src/lib/ws-client.js | 3 +++
2 files changed, 14 insertions(+), 1 deletion(-)
diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js
index 0ddec374..ac88b1c4 100644
--- a/src/lib/tauri-api.js
+++ b/src/lib/tauri-api.js
@@ -101,9 +101,19 @@ 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 controller = new AbortController()
- const timeout = setTimeout(() => controller.abort(), 20000)
+ const timeoutMs = COMMAND_TIMEOUTS[cmd] || 20000
+ const timeout = setTimeout(() => controller.abort(), timeoutMs)
let resp
try {
resp = await fetch(`/__api/${cmd}`, {
diff --git a/src/lib/ws-client.js b/src/lib/ws-client.js
index 90b198b5..82380217 100644
--- a/src/lib/ws-client.js
+++ b/src/lib/ws-client.js
@@ -49,6 +49,7 @@ export class WsClient {
this._autoPairAttempts = 0
this._autoPairing = false
this._serverVersion = null
+ this._state = 'disconnected'
}
get connected() { return this._connected }
@@ -58,6 +59,7 @@ export class WsClient {
get hello() { return this._hello }
get sessionKey() { return this._sessionKey }
get serverVersion() { return this._serverVersion }
+ get state() { return this._state }
onStatusChange(fn) {
this._statusListeners.push(fn)
@@ -308,6 +310,7 @@ export class WsClient {
_setConnected(val, status, errorMsg) {
this._connected = val
const s = status || (val ? 'connected' : 'disconnected')
+ this._state = s
this._statusListeners.forEach(fn => {
try { fn(s, errorMsg) } catch (e) { console.error('[ws] status listener error:', e) }
})
From 475c8791f4780dbee8780a40a619a8ea0562a64b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 01:53:36 +0800
Subject: [PATCH 234/426] chore: checkpoint before ws state refactor
From 1e1bcf0915fbfd6f09d2da09528d0dbb0384f459 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 01:55:26 +0800
Subject: [PATCH 235/426] refactor: unify ws state constants
---
src/lib/ws-client.js | 24 +++++++++++++++++-------
1 file changed, 17 insertions(+), 7 deletions(-)
diff --git a/src/lib/ws-client.js b/src/lib/ws-client.js
index 82380217..d2642877 100644
--- a/src/lib/ws-client.js
+++ b/src/lib/ws-client.js
@@ -24,6 +24,16 @@ const MAX_RECONNECT_DELAY = 30000
const PING_INTERVAL = 5000
const CHALLENGE_TIMEOUT = 5000
+const WS_STATE = {
+ DISCONNECTED: 'disconnected',
+ CONNECTING: 'connecting',
+ CONNECTED: 'connected',
+ READY: 'ready',
+ RECONNECTING: 'reconnecting',
+ ERROR: 'error',
+ AUTH_FAILED: 'auth_failed',
+}
+
export class WsClient {
constructor() {
this._ws = null
@@ -117,7 +127,7 @@ export class WsClient {
this._closeWs()
this._gatewayReady = false
this._handshaking = false
- this._setConnected(false, 'connecting')
+ this._setConnected(false, WS_STATE.CONNECTING)
const wsId = ++this._wsId
let ws
try { ws = new WebSocket(this._url) } catch { this._scheduleReconnect(); return }
@@ -151,7 +161,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
@@ -159,12 +169,12 @@ 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)
@@ -207,7 +217,7 @@ 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 {}
})
@@ -299,7 +309,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)
@@ -353,7 +363,7 @@ 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)
}
From f92641ff221cd5caca60a3f55dd06697a2cc2ba8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 01:56:39 +0800
Subject: [PATCH 236/426] chore: checkpoint before ws state machine
From 00d27337e778818311ee4651b88f288aa518240b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 01:58:00 +0800
Subject: [PATCH 237/426] refactor: add ws handshaking state
---
src/lib/ws-client.js | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/src/lib/ws-client.js b/src/lib/ws-client.js
index d2642877..707fab3a 100644
--- a/src/lib/ws-client.js
+++ b/src/lib/ws-client.js
@@ -29,6 +29,7 @@ const WS_STATE = {
CONNECTING: 'connecting',
CONNECTED: 'connected',
READY: 'ready',
+ HANDSHAKING: 'handshaking',
RECONNECTING: 'reconnecting',
ERROR: 'error',
AUTH_FAILED: 'auth_failed',
@@ -103,7 +104,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
}
@@ -118,6 +119,7 @@ export class WsClient {
this._clearChallengeTimer()
this._flushPending()
this._closeWs()
+ this._setConnected(false, WS_STATE.CONNECTING)
this._doConnect()
}
@@ -283,6 +285,7 @@ export class WsClient {
async _sendConnectFrame(nonce) {
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) {
@@ -292,6 +295,7 @@ export class WsClient {
} catch (e) {
console.error('[ws] 生成 connect frame 失败:', e)
this._handshaking = false
+ this._setConnected(false, WS_STATE.ERROR, '生成握手失败')
}
}
From 465bfcb1f7852fb6b4eb00f30fb31824b31261de Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 02:00:01 +0800
Subject: [PATCH 238/426] chore: checkpoint before ws state cleanup
From b6ff42f7bbee9c8ab2dc63144e1edd8511d25c40 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 02:01:11 +0800
Subject: [PATCH 239/426] refactor: normalize ws state transitions
---
src/lib/ws-client.js | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/lib/ws-client.js b/src/lib/ws-client.js
index 707fab3a..1728ef36 100644
--- a/src/lib/ws-client.js
+++ b/src/lib/ws-client.js
@@ -139,7 +139,7 @@ export class WsClient {
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,超时则主动发
this._challengeTimer = setTimeout(() => {
@@ -179,7 +179,7 @@ export class WsClient {
this._setConnected(false, WS_STATE.ERROR, e.reason || 'origin not allowed,请点击「修复并重连」')
return
}
- this._setConnected(false)
+ this._setConnected(false, WS_STATE.DISCONNECTED)
this._gatewayReady = false
this._handshaking = false
this._stopPing()
@@ -277,7 +277,7 @@ 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
}
From 4d99a62126896d88ad47c6a3210e6e0e50c3a892 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 02:04:42 +0800
Subject: [PATCH 240/426] chore: checkpoint before tool event cleanup
From 07adc47c12dea55def5eb6de40ab5e2c72969c87 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 02:06:15 +0800
Subject: [PATCH 241/426] fix: clear tool caches after run
---
src/pages/chat.js | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/src/pages/chat.js b/src/pages/chat.js
index 0dc2fb99..01d45e56 100644
--- a/src/pages/chat.js
+++ b/src/pages/chat.js
@@ -1272,6 +1272,17 @@ function handleChatEvent(payload) {
appendFilesToEl(_currentAiBubble, _currentAiFiles)
appendToolsToEl(_currentAiBubble, finalTools.length ? finalTools : _currentAiTools)
}
+
+ if (runId) {
+ const ids = _toolRunIndex.get(runId) || []
+ ids.forEach(id => {
+ const key = `${runId}:${id}`
+ _toolEventTimes.delete(key)
+ _toolEventData.delete(key)
+ _toolEventSeen.delete(key)
+ })
+ _toolRunIndex.delete(runId)
+ }
// 添加时间戳 + 耗时 + token 消耗
const wrapper = _currentAiBubble?.parentElement
if (wrapper) {
From 114fa74430ceca6ffa9f48e99cf54c1c404c8b72 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 02:07:17 +0800
Subject: [PATCH 242/426] chore: checkpoint before tool merge safety
From 5740365f6e30c3898a5066f90588017ee3468401 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 02:10:35 +0800
Subject: [PATCH 243/426] fix: safer tool merge matching
---
src/pages/chat.js | 17 ++++++++++++++++-
1 file changed, 16 insertions(+), 1 deletion(-)
diff --git a/src/pages/chat.js b/src/pages/chat.js
index 01d45e56..25f3afd9 100644
--- a/src/pages/chat.js
+++ b/src/pages/chat.js
@@ -1424,6 +1424,8 @@ function extractChatContent(message) {
output: null,
status: block.status || 'ok',
time: resolveToolTime(callId, message.timestamp, message.runId),
+ runId: message.runId,
+ messageTimestamp: message.timestamp,
})
}
else if (block.type === 'tool_result' || block.type === 'toolResult') {
@@ -1435,6 +1437,8 @@ function extractChatContent(message) {
output: block.output || block.result || block.content || null,
status: block.status || 'ok',
time: resolveToolTime(resId, message.timestamp, message.runId),
+ runId: message.runId,
+ messageTimestamp: message.timestamp,
})
}
}
@@ -1747,6 +1751,8 @@ function extractContent(msg) {
output: output || msg.output || msg.result || null,
status: msg.status || 'ok',
time: resolveToolTime(msg.tool_call_id || msg.toolCallId || msg.id, msg.timestamp, msg.runId),
+ runId: msg.runId,
+ messageTimestamp: msg.timestamp,
})
} else if (output && !tools[0].output) {
tools[0].output = output
@@ -2019,7 +2025,12 @@ function upsertTool(tools, entry) {
const id = entry.id || entry.tool_call_id
let target = null
if (id) target = tools.find(t => t.id === id || t.tool_call_id === id)
- if (!target && entry.name) target = tools.find(t => t.name === entry.name && !t.output)
+ if (!target && entry.name && entry.runId) {
+ target = tools.find(t => t.name === entry.name && t.runId === entry.runId && !t.output)
+ }
+ if (!target && entry.name && entry.messageTimestamp) {
+ target = tools.find(t => t.name === entry.name && t.messageTimestamp === entry.messageTimestamp && !t.output)
+ }
if (target) {
if (entry.input != null && target.input == null) target.input = entry.input
if (entry.output != null && target.output == null) target.output = entry.output
@@ -2046,6 +2057,8 @@ function collectToolsFromMessage(message, tools) {
output: null,
status: call.status || 'ok',
time: resolveToolTime(callId, message?.timestamp, message?.runId),
+ runId: message?.runId,
+ messageTimestamp: message?.timestamp,
})
})
}
@@ -2060,6 +2073,8 @@ function collectToolsFromMessage(message, tools) {
output: res.output || res.result || res.content || null,
status: res.status || 'ok',
time: resolveToolTime(resId, message?.timestamp, message?.runId),
+ runId: message?.runId,
+ messageTimestamp: message?.timestamp,
})
})
}
From 73fda6204121044c8616282a65e499d4762e9df3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 02:15:14 +0800
Subject: [PATCH 244/426] chore: checkpoint before ws transition helper
From 590f3816476b8dc0fe8e36f68f69dbdb28169430 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 02:16:08 +0800
Subject: [PATCH 245/426] refactor: add ws transition helper
---
src/lib/ws-client.js | 14 +++++++++-----
1 file changed, 9 insertions(+), 5 deletions(-)
diff --git a/src/lib/ws-client.js b/src/lib/ws-client.js
index 1728ef36..2d996a84 100644
--- a/src/lib/ws-client.js
+++ b/src/lib/ws-client.js
@@ -321,15 +321,19 @@ export class WsClient {
})
}
- _setConnected(val, status, errorMsg) {
- this._connected = val
- const s = status || (val ? 'connected' : 'disconnected')
- this._state = s
+ _transition(status, errorMsg) {
+ this._state = status
+ this._connected = status === WS_STATE.CONNECTED || status === WS_STATE.READY
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) }
})
}
+ _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
From 2ce3bb12df7089cd32f02de8dd142ad8912f5324 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 02:19:45 +0800
Subject: [PATCH 246/426] chore: checkpoint before ws state logging
From 07f8df10fd30606df548cfa0fa2e85f775be988b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 02:20:44 +0800
Subject: [PATCH 247/426] chore: add ws debug state logging
---
src/lib/ws-client.js | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/src/lib/ws-client.js b/src/lib/ws-client.js
index 2d996a84..8e5733b7 100644
--- a/src/lib/ws-client.js
+++ b/src/lib/ws-client.js
@@ -23,6 +23,7 @@ const REQUEST_TIMEOUT = 30000
const MAX_RECONNECT_DELAY = 30000
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',
@@ -322,8 +323,12 @@ export class WsClient {
}
_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) {
+ console.log('[ws] state', prev, '->', status, errorMsg || '')
+ }
this._statusListeners.forEach(fn => {
try { fn(status, errorMsg) } catch (e) { console.error('[ws] status listener error:', e) }
})
From 663e49434a55c3104f4704f7363ff913a452aca1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 02:23:30 +0800
Subject: [PATCH 248/426] chore: checkpoint before ws transition guard
From d0e23bebbf7d49ce17599e31f6c1b5df59cc3e3e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 02:24:47 +0800
Subject: [PATCH 249/426] chore: warn on unexpected ws transitions
---
src/lib/ws-client.js | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/src/lib/ws-client.js b/src/lib/ws-client.js
index 8e5733b7..ad3d4544 100644
--- a/src/lib/ws-client.js
+++ b/src/lib/ws-client.js
@@ -327,6 +327,8 @@ export class WsClient {
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)
console.log('[ws] state', prev, '->', status, errorMsg || '')
}
this._statusListeners.forEach(fn => {
@@ -334,6 +336,22 @@ export class WsClient {
})
}
+ _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)
From b86fa35d334e96c660cb21789b9cb6b508186a85 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 02:29:35 +0800
Subject: [PATCH 250/426] chore: checkpoint before tool id compatibility
From 430ede161b7c9245c92798bfbf16d445e529f652 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 02:44:34 +0800
Subject: [PATCH 251/426] docs: add tool protocol compatibility design
---
.../2026-03-18-tool-protocol-compat-design.md | 43 +++++++++++++++++++
1 file changed, 43 insertions(+)
create mode 100644 docs/plans/2026-03-18-tool-protocol-compat-design.md
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 组件
+- 不更改既有渲染布局与样式
From 6b2284db558ae644416ba4da6bbafd2c530409fa Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 02:45:50 +0800
Subject: [PATCH 252/426] docs: add tool protocol compat plan
---
.../plans/2026-03-18-tool-protocol-compat.md | 34 +++++++++++++++++++
1 file changed, 34 insertions(+)
create mode 100644 docs/superpowers/plans/2026-03-18-tool-protocol-compat.md
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 可正确合并
+- 无异常报错
From dd5087d502ffad13de7a4a8c744edd6b2c636fdc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 02:46:10 +0800
Subject: [PATCH 253/426] chore: checkpoint before tool protocol compat
From 2061a07c7d93c70b38267254f3ce0a33b6d035c9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 02:47:45 +0800
Subject: [PATCH 254/426] fix: expand tool protocol compatibility
---
src/pages/chat.js | 30 +++++++++++++++---------------
1 file changed, 15 insertions(+), 15 deletions(-)
diff --git a/src/pages/chat.js b/src/pages/chat.js
index 25f3afd9..a22df8d5 100644
--- a/src/pages/chat.js
+++ b/src/pages/chat.js
@@ -1416,11 +1416,11 @@ function extractChatContent(message) {
files.push({ url: block.url || '', name: block.fileName || block.name || '文件', mimeType: block.mimeType || '', size: block.size, data: block.data })
}
else if (block.type === 'tool' || block.type === 'tool_use' || block.type === 'tool_call' || block.type === 'toolCall') {
- const callId = block.id || block.tool_call_id || block.toolCallId
+ const callId = block.id || block.tool_call_id || block.toolCallId || block.tool_use_id || block.toolUseId
upsertTool(tools, {
id: callId,
name: block.name || block.tool || block.tool_name || block.toolName || '工具',
- input: block.input || block.args || block.parameters || block.arguments || null,
+ input: block.input || block.args || block.parameters || block.arguments || block.meta?.input || null,
output: null,
status: block.status || 'ok',
time: resolveToolTime(callId, message.timestamp, message.runId),
@@ -1429,12 +1429,12 @@ function extractChatContent(message) {
})
}
else if (block.type === 'tool_result' || block.type === 'toolResult') {
- const resId = block.id || block.tool_call_id || block.toolCallId
+ const resId = block.id || block.tool_call_id || block.toolCallId || block.result_id || block.resultId
upsertTool(tools, {
id: resId,
name: block.name || block.tool || block.tool_name || block.toolName || '工具',
- input: block.input || block.args || null,
- output: block.output || block.result || block.content || null,
+ input: block.input || block.args || block.meta?.input || null,
+ output: block.output || block.result || block.content || block.meta?.output || null,
status: block.status || 'ok',
time: resolveToolTime(resId, message.timestamp, message.runId),
runId: message.runId,
@@ -1781,23 +1781,23 @@ function extractContent(msg) {
files.push({ url: block.url || '', name: block.fileName || block.name || '文件', mimeType: block.mimeType || '', size: block.size, data: block.data })
}
else if (block.type === 'tool' || block.type === 'tool_use' || block.type === 'tool_call' || block.type === 'toolCall') {
- const callId = block.id || block.tool_call_id || block.toolCallId
+ const callId = block.id || block.tool_call_id || block.toolCallId || block.tool_use_id || block.toolUseId
upsertTool(tools, {
id: callId,
name: block.name || block.tool || block.tool_name || block.toolName || '工具',
- input: block.input || block.args || block.parameters || block.arguments || null,
+ input: block.input || block.args || block.parameters || block.arguments || block.meta?.input || null,
output: null,
status: block.status || 'ok',
time: resolveToolTime(callId, msg.timestamp, msg.runId),
})
}
else if (block.type === 'tool_result' || block.type === 'toolResult') {
- const resId = block.id || block.tool_call_id || block.toolCallId
+ const resId = block.id || block.tool_call_id || block.toolCallId || block.result_id || block.resultId
upsertTool(tools, {
id: resId,
name: block.name || block.tool || block.tool_name || block.toolName || '工具',
- input: block.input || block.args || null,
- output: block.output || block.result || block.content || null,
+ input: block.input || block.args || block.meta?.input || null,
+ output: block.output || block.result || block.content || block.meta?.output || null,
status: block.status || 'ok',
time: resolveToolTime(resId, msg.timestamp, msg.runId),
})
@@ -2049,11 +2049,11 @@ function collectToolsFromMessage(message, tools) {
const fn = call.function || null
const name = call.name || call.tool || call.tool_name || fn?.name
const input = call.input || call.args || call.parameters || call.arguments || fn?.arguments || null
- const callId = call.id || call.tool_call_id
+ const callId = call.id || call.tool_call_id || call.tool_use_id || call.toolUseId
upsertTool(tools, {
id: callId,
name: name || '工具',
- input,
+ input: input || call.meta?.input || null,
output: null,
status: call.status || 'ok',
time: resolveToolTime(callId, message?.timestamp, message?.runId),
@@ -2065,12 +2065,12 @@ function collectToolsFromMessage(message, tools) {
const toolResults = message.tool_results || message.toolResults
if (Array.isArray(toolResults)) {
toolResults.forEach(res => {
- const resId = res.id || res.tool_call_id
+ const resId = res.id || res.tool_call_id || res.result_id || res.resultId
upsertTool(tools, {
id: resId,
name: res.name || res.tool || res.tool_name || '工具',
- input: res.input || res.args || null,
- output: res.output || res.result || res.content || null,
+ input: res.input || res.args || res.meta?.input || null,
+ output: res.output || res.result || res.content || res.meta?.output || null,
status: res.status || 'ok',
time: resolveToolTime(resId, message?.timestamp, message?.runId),
runId: message?.runId,
From dd162657040a954286924d013468d750b9516bc1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 02:54:21 +0800
Subject: [PATCH 255/426] chore: checkpoint before tool field variants and ws
state cleanup
From 3ac82ddfee463b832046a6a18c8c608698ae4a68 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 02:57:07 +0800
Subject: [PATCH 256/426] docs: add tool variants and ws state design
---
...026-03-18-tool-variants-ws-state-design.md | 38 +++++++++++++++++++
1 file changed, 38 insertions(+)
create mode 100644 docs/plans/2026-03-18-tool-variants-ws-state-design.md
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 结构与样式
+- 不引入新的组件或事件协议
From 349bb210e695eb1642be73ab4be3438b6054475b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 02:59:08 +0800
Subject: [PATCH 257/426] docs: add heartbeat chat history design
---
...2026-03-18-heartbeat-chat-history-design.md | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
create mode 100644 docs/plans/2026-03-18-heartbeat-chat-history-design.md
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
From 17835c8453f0a062f3fa3280b1ec4a031e006e5a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 02:59:31 +0800
Subject: [PATCH 258/426] chore: checkpoint before heartbeat chat history
From 7c6d5d84254671dcf6d04f2961d3324ace5376fa Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 03:00:08 +0800
Subject: [PATCH 259/426] chore: checkpoint before heartbeat chat history send
From 38fcf8a7079bf5a673537a65f0ebd96312c8b267 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 03:00:54 +0800
Subject: [PATCH 260/426] fix: send chat history on heartbeat
---
src/lib/ws-client.js | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/src/lib/ws-client.js b/src/lib/ws-client.js
index ad3d4544..b831f3b5 100644
--- a/src/lib/ws-client.js
+++ b/src/lib/ws-client.js
@@ -406,6 +406,16 @@ export class WsClient {
const id = uuid()
this._ws.send(JSON.stringify({ type: 'req', id, method: 'node.list', params: {} }))
} catch {}
+ try {
+ const sessionKey = this._sessionKey || `agent:main:main`
+ const id = uuid()
+ this._ws.send(JSON.stringify({
+ type: 'req',
+ id,
+ method: 'chat.history',
+ params: { sessionKey, limit: 50 },
+ }))
+ } catch {}
}
}, PING_INTERVAL)
}
From 81e9140629e0ace70fd83595c78384495c8118de Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 03:02:12 +0800
Subject: [PATCH 261/426] chore: checkpoint before tool variant fields
From a97e7f486caaa9b3636ed4c30e78e1781814e36f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 03:06:11 +0800
Subject: [PATCH 262/426] chore: checkpoint before heartbeat sessionKey sync
From 395bbc2b255984519ccae2e947795d7060f9d2ee Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 03:07:57 +0800
Subject: [PATCH 263/426] fix: sync ws sessionKey with chat
---
src/lib/ws-client.js | 4 ++++
src/pages/chat.js | 3 +++
2 files changed, 7 insertions(+)
diff --git a/src/lib/ws-client.js b/src/lib/ws-client.js
index b831f3b5..2f96e7c0 100644
--- a/src/lib/ws-client.js
+++ b/src/lib/ws-client.js
@@ -70,6 +70,10 @@ 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 }
diff --git a/src/pages/chat.js b/src/pages/chat.js
index a22df8d5..4fb2b730 100644
--- a/src/pages/chat.js
+++ b/src/pages/chat.js
@@ -756,6 +756,7 @@ async function connectGateway() {
if (!_sessionKey) {
const saved = localStorage.getItem(STORAGE_SESSION_KEY)
_sessionKey = saved || sessionKey
+ wsClient.setSessionKey(_sessionKey)
updateSessionTitle()
loadHistory()
}
@@ -772,6 +773,7 @@ async function connectGateway() {
if (wsClient.connected && wsClient.gatewayReady) {
const saved = localStorage.getItem(STORAGE_SESSION_KEY)
_sessionKey = saved || wsClient.sessionKey
+ wsClient.setSessionKey(_sessionKey)
updateStatusDot('ready')
showTyping(false) // 确保关闭加载动画
updateSessionTitle()
@@ -883,6 +885,7 @@ function switchSession(newKey) {
if (newKey === _sessionKey) return
_sessionKey = newKey
localStorage.setItem(STORAGE_SESSION_KEY, newKey)
+ wsClient.setSessionKey(newKey)
_lastHistoryHash = ''
resetStreamState()
updateSessionTitle()
From 1a16237c4ead571728f5f7b5b85a5373ba6a5e7e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 03:16:46 +0800
Subject: [PATCH 264/426] fix: apply heartbeat chat history updates
---
src/lib/ws-client.js | 26 +++++-----
src/pages/chat.js | 110 +++++++++++++++++++++++--------------------
2 files changed, 75 insertions(+), 61 deletions(-)
diff --git a/src/lib/ws-client.js b/src/lib/ws-client.js
index 2f96e7c0..5f3008db 100644
--- a/src/lib/ws-client.js
+++ b/src/lib/ws-client.js
@@ -241,8 +241,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
}
@@ -413,12 +422,7 @@ export class WsClient {
try {
const sessionKey = this._sessionKey || `agent:main:main`
const id = uuid()
- this._ws.send(JSON.stringify({
- type: 'req',
- id,
- method: 'chat.history',
- params: { sessionKey, limit: 50 },
- }))
+ this.request('chat.history', { sessionKey, limit: 50 }, { emitEvent: true }).catch(() => {})
} catch {}
}
}, PING_INTERVAL)
@@ -431,7 +435,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)) {
@@ -439,7 +443,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
}
@@ -447,7 +451,7 @@ 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._pending.set(id, { resolve, reject, timer, method, params, emitEvent: !!options.emitEvent })
try {
this._ws.send(JSON.stringify({ type: 'req', id, method, params }))
} catch (e) {
diff --git a/src/pages/chat.js b/src/pages/chat.js
index 4fb2b730..a601a860 100644
--- a/src/pages/chat.js
+++ b/src/pages/chat.js
@@ -1152,6 +1152,13 @@ function handleEvent(msg) {
}
}
+ if (event === 'chat.history') {
+ const reqKey = payload?._req?.sessionKey || ''
+ if (reqKey && _sessionKey && reqKey !== _sessionKey) return
+ const hasExisting = _messagesEl?.querySelector?.('.msg')
+ applyHistoryResult(payload, hasExisting)
+ }
+
if (event === 'chat') handleChatEvent(payload)
if ((event === 'status' || event === 'gateway.status') && payload?.state === 'disconnected') {
@@ -1624,6 +1631,58 @@ function resetStreamState() {
// ── 历史消息加载 ──
+function applyHistoryResult(result, hasExisting) {
+ if (!result?.messages?.length) {
+ if (_messagesEl && !_messagesEl.querySelector('.msg')) appendSystemMessage('还没有消息,开始聊天吧')
+ return
+ }
+ const deduped = dedupeHistory(result.messages)
+ const hash = deduped.map(m => `${m.role}:${(m.text || '').length}`).join('|')
+ if (hash === _lastHistoryHash && hasExisting) return
+ _lastHistoryHash = hash
+
+ // 正在发送/流式输出时不全量重绘,避免覆盖本地乐观渲染
+ if (hasExisting && (_isSending || _isStreaming || _messageQueue.length > 0)) {
+ saveMessages(result.messages.map(m => {
+ const c = extractContent(m)
+ const role = (m.role === 'tool' || m.role === 'toolResult') ? 'assistant' : m.role
+ return { id: m.id || uuid(), sessionKey: _sessionKey, role, content: c?.text || '', timestamp: m.timestamp || Date.now() }
+ }))
+ return
+ }
+
+ clearMessages()
+ let hasOmittedImages = false
+ deduped.forEach(msg => {
+ if (!msg.text && !msg.images?.length && !msg.videos?.length && !msg.audios?.length && !msg.files?.length && !msg.tools?.length) return
+ const msgTime = msg.timestamp ? new Date(msg.timestamp) : new Date()
+ if (msg.role === 'user') {
+ const userAtts = msg.images?.length ? msg.images.map(i => ({
+ mimeType: i.mediaType || i.media_type || 'image/png',
+ content: i.data || i.source?.data || '',
+ category: 'image',
+ })).filter(a => a.content) : []
+ if (msg.images?.length && !userAtts.length) hasOmittedImages = true
+ appendUserMessage(msg.text, userAtts, msgTime)
+ } else if (msg.role === 'assistant') {
+ appendAiMessage(msg.text, msgTime, msg.images, msg.videos, msg.audios, msg.files, msg.tools)
+ } else if (msg.role === 'system') {
+ appendSystemMessage(msg.text || '', msgTime?.getTime?.() || Date.now())
+ } else {
+ appendSystemMessage(msg.text || '', msgTime?.getTime?.() || Date.now())
+ }
+ })
+ if (hasOmittedImages) {
+ appendSystemMessage('部分历史图片无法显示(Gateway 不保留图片原始数据,仅当前会话内可见)')
+ }
+ saveMessages(result.messages.map(m => {
+ const c = extractContent(m)
+ const role = (m.role === 'tool' || m.role === 'toolResult') ? 'assistant' : m.role
+ return { id: m.id || uuid(), sessionKey: _sessionKey, role, content: c?.text || '', timestamp: m.timestamp || Date.now() }
+ }))
+ scrollToBottom()
+}
+
async function loadHistory() {
if (!_sessionKey || !_messagesEl) return
_isLoadingHistory = true
@@ -1651,56 +1710,7 @@ async function loadHistory() {
if (!wsClient.gatewayReady) { _isLoadingHistory = false; return }
try {
const result = await wsClient.chatHistory(_sessionKey, 200)
- if (!result?.messages?.length) {
- if (_messagesEl && !_messagesEl.querySelector('.msg')) appendSystemMessage('还没有消息,开始聊天吧')
- return
- }
- const deduped = dedupeHistory(result.messages)
- const hash = deduped.map(m => `${m.role}:${(m.text || '').length}`).join('|')
- if (hash === _lastHistoryHash && hasExisting) return
- _lastHistoryHash = hash
-
- // 正在发送/流式输出时不全量重绘,避免覆盖本地乐观渲染
- if (hasExisting && (_isSending || _isStreaming || _messageQueue.length > 0)) {
- saveMessages(result.messages.map(m => {
- const c = extractContent(m)
- const role = (m.role === 'tool' || m.role === 'toolResult') ? 'assistant' : m.role
- return { id: m.id || uuid(), sessionKey: _sessionKey, role, content: c?.text || '', timestamp: m.timestamp || Date.now() }
- }))
- _isLoadingHistory = false
- return
- }
-
- clearMessages()
- let hasOmittedImages = false
- deduped.forEach(msg => {
- if (!msg.text && !msg.images?.length && !msg.videos?.length && !msg.audios?.length && !msg.files?.length && !msg.tools?.length) return
- const msgTime = msg.timestamp ? new Date(msg.timestamp) : new Date()
- if (msg.role === 'user') {
- const userAtts = msg.images?.length ? msg.images.map(i => ({
- mimeType: i.mediaType || i.media_type || 'image/png',
- content: i.data || i.source?.data || '',
- category: 'image',
- })).filter(a => a.content) : []
- if (msg.images?.length && !userAtts.length) hasOmittedImages = true
- appendUserMessage(msg.text, userAtts, msgTime)
- } else if (msg.role === 'assistant') {
- appendAiMessage(msg.text, msgTime, msg.images, msg.videos, msg.audios, msg.files, msg.tools)
- } else if (msg.role === 'system') {
- appendSystemMessage(msg.text || '', msgTime?.getTime?.() || Date.now())
- } else {
- appendSystemMessage(msg.text || '', msgTime?.getTime?.() || Date.now())
- }
- })
- if (hasOmittedImages) {
- appendSystemMessage('部分历史图片无法显示(Gateway 不保留图片原始数据,仅当前会话内可见)')
- }
- saveMessages(result.messages.map(m => {
- const c = extractContent(m)
- const role = (m.role === 'tool' || m.role === 'toolResult') ? 'assistant' : m.role
- return { id: m.id || uuid(), sessionKey: _sessionKey, role, content: c?.text || '', timestamp: m.timestamp || Date.now() }
- }))
- scrollToBottom()
+ applyHistoryResult(result, hasExisting)
} catch (e) {
console.error('[chat] loadHistory error:', e)
if (_messagesEl && !_messagesEl.querySelector('.msg')) appendSystemMessage('加载历史失败: ' + e.message)
From 622595101cca6048fa5315a2a3703cc1ccbe51ba Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 03:21:48 +0800
Subject: [PATCH 265/426] fix: expand tool field variants
---
src/lib/ws-client.js | 2 +-
src/pages/chat.js | 36 ++++++++++++++++++------------------
2 files changed, 19 insertions(+), 19 deletions(-)
diff --git a/src/lib/ws-client.js b/src/lib/ws-client.js
index 5f3008db..dc9ea32a 100644
--- a/src/lib/ws-client.js
+++ b/src/lib/ws-client.js
@@ -61,7 +61,7 @@ export class WsClient {
this._autoPairAttempts = 0
this._autoPairing = false
this._serverVersion = null
- this._state = 'disconnected'
+ this._state = WS_STATE.DISCONNECTED
}
get connected() { return this._connected }
diff --git a/src/pages/chat.js b/src/pages/chat.js
index a601a860..175cb007 100644
--- a/src/pages/chat.js
+++ b/src/pages/chat.js
@@ -1392,9 +1392,9 @@ function extractChatContent(message) {
const output = typeof message.content === 'string' ? message.content : null
if (!tools.length) {
tools.push({
- name: message.name || message.tool || message.tool_name || '工具',
- input: message.input || message.args || message.parameters || null,
- output: output || message.output || message.result || null,
+ name: message.name || message.tool || message.tool_name || message.toolName || message.tool?.name || message.meta?.toolName || '工具',
+ input: message.input || message.args || message.parameters || message.arguments || message.tool_input || message.toolInput || message.meta?.input || message.meta?.args || null,
+ output: output || message.output || message.result || message.content || message.tool_output || message.output_text || message.result_text || message.meta?.output || null,
status: message.status || 'ok',
})
} else if (output && !tools[0].output) {
@@ -1429,8 +1429,8 @@ function extractChatContent(message) {
const callId = block.id || block.tool_call_id || block.toolCallId || block.tool_use_id || block.toolUseId
upsertTool(tools, {
id: callId,
- name: block.name || block.tool || block.tool_name || block.toolName || '工具',
- input: block.input || block.args || block.parameters || block.arguments || block.meta?.input || null,
+ name: block.name || block.tool || block.tool_name || block.toolName || block.tool?.name || block.meta?.toolName || '工具',
+ input: block.input || block.args || block.parameters || block.arguments || block.tool_input || block.toolInput || block.meta?.input || block.meta?.args || null,
output: null,
status: block.status || 'ok',
time: resolveToolTime(callId, message.timestamp, message.runId),
@@ -1442,9 +1442,9 @@ function extractChatContent(message) {
const resId = block.id || block.tool_call_id || block.toolCallId || block.result_id || block.resultId
upsertTool(tools, {
id: resId,
- name: block.name || block.tool || block.tool_name || block.toolName || '工具',
- input: block.input || block.args || block.meta?.input || null,
- output: block.output || block.result || block.content || block.meta?.output || null,
+ name: block.name || block.tool || block.tool_name || block.toolName || block.tool?.name || block.meta?.toolName || '工具',
+ input: block.input || block.args || block.tool_input || block.toolInput || block.meta?.input || block.meta?.args || null,
+ output: block.output || block.result || block.content || block.tool_output || block.output_text || block.result_text || block.meta?.output || null,
status: block.status || 'ok',
time: resolveToolTime(resId, message.timestamp, message.runId),
runId: message.runId,
@@ -1797,8 +1797,8 @@ function extractContent(msg) {
const callId = block.id || block.tool_call_id || block.toolCallId || block.tool_use_id || block.toolUseId
upsertTool(tools, {
id: callId,
- name: block.name || block.tool || block.tool_name || block.toolName || '工具',
- input: block.input || block.args || block.parameters || block.arguments || block.meta?.input || null,
+ name: block.name || block.tool || block.tool_name || block.toolName || block.tool?.name || block.meta?.toolName || '工具',
+ input: block.input || block.args || block.parameters || block.arguments || block.tool_input || block.toolInput || block.meta?.input || block.meta?.args || null,
output: null,
status: block.status || 'ok',
time: resolveToolTime(callId, msg.timestamp, msg.runId),
@@ -1808,9 +1808,9 @@ function extractContent(msg) {
const resId = block.id || block.tool_call_id || block.toolCallId || block.result_id || block.resultId
upsertTool(tools, {
id: resId,
- name: block.name || block.tool || block.tool_name || block.toolName || '工具',
- input: block.input || block.args || block.meta?.input || null,
- output: block.output || block.result || block.content || block.meta?.output || null,
+ name: block.name || block.tool || block.tool_name || block.toolName || block.tool?.name || block.meta?.toolName || '工具',
+ input: block.input || block.args || block.tool_input || block.toolInput || block.meta?.input || block.meta?.args || null,
+ output: block.output || block.result || block.content || block.tool_output || block.output_text || block.result_text || block.meta?.output || null,
status: block.status || 'ok',
time: resolveToolTime(resId, msg.timestamp, msg.runId),
})
@@ -2060,8 +2060,8 @@ function collectToolsFromMessage(message, tools) {
if (Array.isArray(toolCalls)) {
toolCalls.forEach(call => {
const fn = call.function || null
- const name = call.name || call.tool || call.tool_name || fn?.name
- const input = call.input || call.args || call.parameters || call.arguments || fn?.arguments || null
+ const name = call.name || call.tool || call.tool_name || call.toolName || call.tool?.name || call.meta?.toolName || fn?.name
+ const input = call.input || call.args || call.parameters || call.arguments || call.tool_input || call.toolInput || call.meta?.input || call.meta?.args || fn?.arguments || null
const callId = call.id || call.tool_call_id || call.tool_use_id || call.toolUseId
upsertTool(tools, {
id: callId,
@@ -2081,9 +2081,9 @@ function collectToolsFromMessage(message, tools) {
const resId = res.id || res.tool_call_id || res.result_id || res.resultId
upsertTool(tools, {
id: resId,
- name: res.name || res.tool || res.tool_name || '工具',
- input: res.input || res.args || res.meta?.input || null,
- output: res.output || res.result || res.content || res.meta?.output || null,
+ name: res.name || res.tool || res.tool_name || res.toolName || res.tool?.name || res.meta?.toolName || '工具',
+ input: res.input || res.args || res.tool_input || res.toolInput || res.meta?.input || res.meta?.args || null,
+ output: res.output || res.result || res.content || res.tool_output || res.output_text || res.result_text || res.meta?.output || null,
status: res.status || 'ok',
time: resolveToolTime(resId, message?.timestamp, message?.runId),
runId: message?.runId,
From 62dc465132fce7082e1bfeab75d0f4ab93ef2b46 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 03:26:24 +0800
Subject: [PATCH 266/426] chore: checkpoint before ws stats and tool variants
From ef902f1f3a80c9dbb4cce631db4f8f5daf877777 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 03:29:03 +0800
Subject: [PATCH 267/426] feat: add ws state stats and tool nesting
---
src/lib/ws-client.js | 4 +++-
src/pages/chat.js | 22 +++++++++++-----------
2 files changed, 14 insertions(+), 12 deletions(-)
diff --git a/src/lib/ws-client.js b/src/lib/ws-client.js
index dc9ea32a..2f0b455e 100644
--- a/src/lib/ws-client.js
+++ b/src/lib/ws-client.js
@@ -62,6 +62,7 @@ export class WsClient {
this._autoPairing = false
this._serverVersion = null
this._state = WS_STATE.DISCONNECTED
+ this._stateStats = {}
}
get connected() { return this._connected }
@@ -342,7 +343,8 @@ export class WsClient {
if (WS_DEBUG && prev !== status) {
const allowed = this._isAllowedTransition(prev, status)
if (!allowed) console.warn('[ws] unexpected state transition', prev, '->', status)
- console.log('[ws] state', prev, '->', status, errorMsg || '')
+ 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(status, errorMsg) } catch (e) { console.error('[ws] status listener error:', e) }
diff --git a/src/pages/chat.js b/src/pages/chat.js
index 175cb007..5aff37cc 100644
--- a/src/pages/chat.js
+++ b/src/pages/chat.js
@@ -1393,8 +1393,8 @@ function extractChatContent(message) {
if (!tools.length) {
tools.push({
name: message.name || message.tool || message.tool_name || message.toolName || message.tool?.name || message.meta?.toolName || '工具',
- input: message.input || message.args || message.parameters || message.arguments || message.tool_input || message.toolInput || message.meta?.input || message.meta?.args || null,
- output: output || message.output || message.result || message.content || message.tool_output || message.output_text || message.result_text || message.meta?.output || null,
+ input: message.input || message.args || message.parameters || message.arguments || message.tool_input || message.toolInput || message.tool?.input || message.tool?.args || message.meta?.input || message.meta?.args || null,
+ output: output || message.output || message.result || message.content || message.tool_output || message.output_text || message.result_text || message.tool?.output || message.meta?.output || null,
status: message.status || 'ok',
})
} else if (output && !tools[0].output) {
@@ -1430,7 +1430,7 @@ function extractChatContent(message) {
upsertTool(tools, {
id: callId,
name: block.name || block.tool || block.tool_name || block.toolName || block.tool?.name || block.meta?.toolName || '工具',
- input: block.input || block.args || block.parameters || block.arguments || block.tool_input || block.toolInput || block.meta?.input || block.meta?.args || null,
+ input: block.input || block.args || block.parameters || block.arguments || block.tool_input || block.toolInput || block.tool?.input || block.tool?.args || block.meta?.input || block.meta?.args || null,
output: null,
status: block.status || 'ok',
time: resolveToolTime(callId, message.timestamp, message.runId),
@@ -1443,8 +1443,8 @@ function extractChatContent(message) {
upsertTool(tools, {
id: resId,
name: block.name || block.tool || block.tool_name || block.toolName || block.tool?.name || block.meta?.toolName || '工具',
- input: block.input || block.args || block.tool_input || block.toolInput || block.meta?.input || block.meta?.args || null,
- output: block.output || block.result || block.content || block.tool_output || block.output_text || block.result_text || block.meta?.output || null,
+ input: block.input || block.args || block.tool_input || block.toolInput || block.tool?.input || block.tool?.args || block.meta?.input || block.meta?.args || null,
+ output: block.output || block.result || block.content || block.tool_output || block.output_text || block.result_text || block.tool?.output || block.meta?.output || null,
status: block.status || 'ok',
time: resolveToolTime(resId, message.timestamp, message.runId),
runId: message.runId,
@@ -1798,7 +1798,7 @@ function extractContent(msg) {
upsertTool(tools, {
id: callId,
name: block.name || block.tool || block.tool_name || block.toolName || block.tool?.name || block.meta?.toolName || '工具',
- input: block.input || block.args || block.parameters || block.arguments || block.tool_input || block.toolInput || block.meta?.input || block.meta?.args || null,
+ input: block.input || block.args || block.parameters || block.arguments || block.tool_input || block.toolInput || block.tool?.input || block.tool?.args || block.meta?.input || block.meta?.args || null,
output: null,
status: block.status || 'ok',
time: resolveToolTime(callId, msg.timestamp, msg.runId),
@@ -1809,8 +1809,8 @@ function extractContent(msg) {
upsertTool(tools, {
id: resId,
name: block.name || block.tool || block.tool_name || block.toolName || block.tool?.name || block.meta?.toolName || '工具',
- input: block.input || block.args || block.tool_input || block.toolInput || block.meta?.input || block.meta?.args || null,
- output: block.output || block.result || block.content || block.tool_output || block.output_text || block.result_text || block.meta?.output || null,
+ input: block.input || block.args || block.tool_input || block.toolInput || block.tool?.input || block.tool?.args || block.meta?.input || block.meta?.args || null,
+ output: block.output || block.result || block.content || block.tool_output || block.output_text || block.result_text || block.tool?.output || block.meta?.output || null,
status: block.status || 'ok',
time: resolveToolTime(resId, msg.timestamp, msg.runId),
})
@@ -2061,7 +2061,7 @@ function collectToolsFromMessage(message, tools) {
toolCalls.forEach(call => {
const fn = call.function || null
const name = call.name || call.tool || call.tool_name || call.toolName || call.tool?.name || call.meta?.toolName || fn?.name
- const input = call.input || call.args || call.parameters || call.arguments || call.tool_input || call.toolInput || call.meta?.input || call.meta?.args || fn?.arguments || null
+ const input = call.input || call.args || call.parameters || call.arguments || call.tool_input || call.toolInput || call.tool?.input || call.tool?.args || call.meta?.input || call.meta?.args || fn?.arguments || null
const callId = call.id || call.tool_call_id || call.tool_use_id || call.toolUseId
upsertTool(tools, {
id: callId,
@@ -2082,8 +2082,8 @@ function collectToolsFromMessage(message, tools) {
upsertTool(tools, {
id: resId,
name: res.name || res.tool || res.tool_name || res.toolName || res.tool?.name || res.meta?.toolName || '工具',
- input: res.input || res.args || res.tool_input || res.toolInput || res.meta?.input || res.meta?.args || null,
- output: res.output || res.result || res.content || res.tool_output || res.output_text || res.result_text || res.meta?.output || null,
+ input: res.input || res.args || res.tool_input || res.toolInput || res.tool?.input || res.tool?.args || res.meta?.input || res.meta?.args || null,
+ output: res.output || res.result || res.content || res.tool_output || res.output_text || res.result_text || res.tool?.output || res.meta?.output || null,
status: res.status || 'ok',
time: resolveToolTime(resId, message?.timestamp, message?.runId),
runId: message?.runId,
From a5138b16888294b68db76410b75e2821ed9f07e9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 03:39:58 +0800
Subject: [PATCH 268/426] chore: checkpoint before hosted agent output guard
From 7cd48c561285be03b907147f790ba4d7abb72a1a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 03:43:15 +0800
Subject: [PATCH 269/426] feat: harden hosted agent output
---
src/pages/chat.js | 69 +++++++++++++++++++++++++++++++++++++++++++----
1 file changed, 64 insertions(+), 5 deletions(-)
diff --git a/src/pages/chat.js b/src/pages/chat.js
index 5aff37cc..79bf8beb 100644
--- a/src/pages/chat.js
+++ b/src/pages/chat.js
@@ -45,6 +45,7 @@ const HOSTED_RUNTIME_DEFAULT = {
status: HOSTED_STATUS.IDLE,
stepCount: 0,
lastRunAt: 0,
+ lastRunId: '',
lastError: '',
pending: false,
errorCount: 0,
@@ -2581,6 +2582,21 @@ async function runHostedAgentStep() {
if (_hostedBusy || !_hostedSessionConfig?.enabled) return
const prompt = (_hostedSessionConfig.prompt || '').trim()
if (!prompt) return
+ if (!wsClient.gatewayReady || !_sessionKey) {
+ _hostedRuntime.status = HOSTED_STATUS.PAUSED
+ _hostedRuntime.lastError = 'Gateway 未就绪或 sessionKey 缺失'
+ persistHostedRuntime()
+ updateHostedBadge()
+ appendHostedOutput(`[托管 Agent] 需要人工介入: Gateway 未就绪或 sessionKey 缺失${formatHostedSummary()}`)
+ return
+ }
+ if (_hostedRuntime.errorCount > _hostedSessionConfig.retryLimit) {
+ _hostedRuntime.status = HOSTED_STATUS.ERROR
+ persistHostedRuntime()
+ updateHostedBadge()
+ appendHostedOutput(`[托管 Agent] 需要人工介入: 连续错误超过阈值${formatHostedSummary()}`)
+ return
+ }
if (_hostedRuntime.stepCount >= _hostedSessionConfig.maxSteps) {
_hostedRuntime.status = HOSTED_STATUS.IDLE
persistHostedRuntime()
@@ -2591,6 +2607,8 @@ async function runHostedAgentStep() {
_hostedRuntime.pending = true
_hostedRuntime.status = HOSTED_STATUS.RUNNING
_hostedRuntime.lastRunAt = Date.now()
+ _hostedRuntime.lastRunId = uuid()
+ _currentRunId = _hostedRuntime.lastRunId
persistHostedRuntime()
updateHostedBadge()
@@ -2605,18 +2623,18 @@ async function runHostedAgentStep() {
await callHostedAI(messages, (chunk) => {
resultText += chunk
})
- const nextInstruction = resultText.trim()
- if (!nextInstruction) throw new Error('托管 Agent 未生成指令')
+ const parsed = parseHostedTemplate(resultText)
+ if (!parsed) throw new Error('托管 Agent 输出未符合模板')
_hostedRuntime.stepCount += 1
_hostedRuntime.errorCount = 0
_hostedRuntime.lastError = ''
- _hostedSessionConfig.history.push({ role: 'assistant', content: nextInstruction, ts: Date.now() })
+ const rendered = renderHostedTemplate(parsed)
+ _hostedSessionConfig.history.push({ role: 'assistant', content: rendered, ts: Date.now() })
persistHostedRuntime()
- appendHostedOutput(`[托管 Agent] 下一步指令: ${nextInstruction}`)
- await wsClient.chatSend(_sessionKey, nextInstruction)
+ appendHostedOutput(`${rendered}${formatHostedSummary()}`)
_hostedRuntime.status = HOSTED_STATUS.WAITING
_hostedRuntime.pending = false
@@ -2636,6 +2654,7 @@ async function runHostedAgentStep() {
_hostedRuntime.status = HOSTED_STATUS.ERROR
updateHostedBadge()
persistHostedRuntime()
+ appendHostedOutput(`[托管 Agent] 需要人工介入: 连续错误超过阈值${formatHostedSummary()}`)
return
}
persistHostedRuntime()
@@ -2877,8 +2896,48 @@ async function callGeminiHosted(base, systemPrompt, messages, config, onChunk, s
}, signal)
}
+function formatHostedSummary(extra) {
+ const parts = []
+ if (_currentRunId) parts.push(`runId=${_currentRunId}`)
+ if (_hostedRuntime.stepCount != null) parts.push(`step=${_hostedRuntime.stepCount}`)
+ if (_hostedRuntime.lastError) parts.push(`error=${_hostedRuntime.lastError}`)
+ if (extra) parts.push(extra)
+ return parts.length ? ` | ${parts.join(' | ')}` : ''
+}
+
+function parseHostedTemplate(text) {
+ const raw = (text || '').trim()
+ if (!raw) return null
+ const lines = raw.split(/\r?\n/).map(s => s.trim()).filter(Boolean)
+ const goals = []
+ const suggestions = []
+ const risks = []
+ let section = ''
+ for (const line of lines) {
+ if (/^目标[::]/i.test(line)) { section = 'goal'; const v = line.replace(/^目标[::]\s*/i, ''); if (v) goals.push(v); continue }
+ if (/^建议[::]/i.test(line)) { section = 'suggest'; const v = line.replace(/^建议[::]\s*/i, ''); if (v) suggestions.push(v); continue }
+ if (/^风险[::]/i.test(line)) { section = 'risk'; const v = line.replace(/^风险[::]\s*/i, ''); if (v) risks.push(v); continue }
+ if (section === 'goal') goals.push(line)
+ else if (section === 'suggest') suggestions.push(line.replace(/^[\-*\d\.\s]+/, ''))
+ else if (section === 'risk') risks.push(line.replace(/^[\-*\d\.\s]+/, ''))
+ }
+ if (!goals.length || !suggestions.length) return null
+ return {
+ goal: goals.join(' '),
+ suggestions: suggestions.filter(Boolean),
+ risks: risks.filter(Boolean),
+ }
+}
+
+function renderHostedTemplate(parsed) {
+ const riskText = parsed.risks.length ? parsed.risks.map(r => `- ${r}`).join('\n') : '- 暂无'
+ const suggestText = parsed.suggestions.map(s => `- ${s}`).join('\n')
+ return `[托管 Agent] 目标: ${parsed.goal}\n建议:\n${suggestText}\n风险:\n${riskText}`
+}
+
function appendHostedOutput(text) {
if (!text) return
+ if (!text.startsWith('[托管 Agent]')) text = `[托管 Agent] ${text}`
const wrap = document.createElement('div')
wrap.className = 'msg msg-system msg-hosted'
wrap.textContent = text
From 9c5973c17441a91e02ccc89396b5db03f5a65a36 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 03:44:52 +0800
Subject: [PATCH 270/426] chore: checkpoint before hosted agent tweaks
From 33d08b5124157ded97ff5c8e01b0ed9e3118261e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 03:46:39 +0800
Subject: [PATCH 271/426] fix: adjust hosted agent template guard
---
src/pages/chat.js | 22 +++++++++++++++-------
1 file changed, 15 insertions(+), 7 deletions(-)
diff --git a/src/pages/chat.js b/src/pages/chat.js
index 79bf8beb..35d1d6be 100644
--- a/src/pages/chat.js
+++ b/src/pages/chat.js
@@ -2587,14 +2587,14 @@ async function runHostedAgentStep() {
_hostedRuntime.lastError = 'Gateway 未就绪或 sessionKey 缺失'
persistHostedRuntime()
updateHostedBadge()
- appendHostedOutput(`[托管 Agent] 需要人工介入: Gateway 未就绪或 sessionKey 缺失${formatHostedSummary()}`)
+ appendHostedOutput(`需要人工介入: Gateway 未就绪或 sessionKey 缺失${formatHostedSummary()}`)
return
}
- if (_hostedRuntime.errorCount > _hostedSessionConfig.retryLimit) {
+ if (_hostedRuntime.errorCount >= _hostedSessionConfig.retryLimit) {
_hostedRuntime.status = HOSTED_STATUS.ERROR
persistHostedRuntime()
updateHostedBadge()
- appendHostedOutput(`[托管 Agent] 需要人工介入: 连续错误超过阈值${formatHostedSummary()}`)
+ appendHostedOutput(`需要人工介入: 连续错误超过阈值${formatHostedSummary()}`)
return
}
if (_hostedRuntime.stepCount >= _hostedSessionConfig.maxSteps) {
@@ -2624,7 +2624,14 @@ async function runHostedAgentStep() {
resultText += chunk
})
const parsed = parseHostedTemplate(resultText)
- if (!parsed) throw new Error('托管 Agent 输出未符合模板')
+ if (!parsed) {
+ _hostedRuntime.errorCount = (_hostedRuntime.errorCount || 0) + 1
+ _hostedRuntime.lastError = '托管 Agent 输出未符合模板'
+ _hostedRuntime.pending = false
+ persistHostedRuntime()
+ updateHostedBadge()
+ return
+ }
_hostedRuntime.stepCount += 1
_hostedRuntime.errorCount = 0
@@ -2650,11 +2657,11 @@ async function runHostedAgentStep() {
_hostedRuntime.errorCount = (_hostedRuntime.errorCount || 0) + 1
_hostedRuntime.lastError = e.message || String(e)
_hostedRuntime.pending = false
- if (_hostedRuntime.errorCount > _hostedSessionConfig.retryLimit) {
+ if (_hostedRuntime.errorCount >= _hostedSessionConfig.retryLimit) {
_hostedRuntime.status = HOSTED_STATUS.ERROR
updateHostedBadge()
persistHostedRuntime()
- appendHostedOutput(`[托管 Agent] 需要人工介入: 连续错误超过阈值${formatHostedSummary()}`)
+ appendHostedOutput(`需要人工介入: 连续错误超过阈值${formatHostedSummary()}`)
return
}
persistHostedRuntime()
@@ -2921,6 +2928,7 @@ function parseHostedTemplate(text) {
else if (section === 'suggest') suggestions.push(line.replace(/^[\-*\d\.\s]+/, ''))
else if (section === 'risk') risks.push(line.replace(/^[\-*\d\.\s]+/, ''))
}
+ // risk 可为空,goal 与 suggestions 必须存在
if (!goals.length || !suggestions.length) return null
return {
goal: goals.join(' '),
@@ -2932,7 +2940,7 @@ function parseHostedTemplate(text) {
function renderHostedTemplate(parsed) {
const riskText = parsed.risks.length ? parsed.risks.map(r => `- ${r}`).join('\n') : '- 暂无'
const suggestText = parsed.suggestions.map(s => `- ${s}`).join('\n')
- return `[托管 Agent] 目标: ${parsed.goal}\n建议:\n${suggestText}\n风险:\n${riskText}`
+ return `目标: ${parsed.goal}\n建议:\n${suggestText}\n风险:\n${riskText}`
}
function appendHostedOutput(text) {
From 47cf65b038d95e3bb110af81c276dc9b0dd348b8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 03:52:16 +0800
Subject: [PATCH 272/426] docs: add hosted agent enhancements design
---
...-03-18-hosted-agent-enhancements-design.md | 43 +++++++++++++++++++
1 file changed, 43 insertions(+)
create mode 100644 docs/plans/2026-03-18-hosted-agent-enhancements-design.md
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 生效
+- 暂停后恢复:历史仍在
+- 停止后:历史清空
+- 长上下文自动裁剪
From 7cdddcf806a2e4974a971c1d28f6b4d7ef2922a7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 04:26:10 +0800
Subject: [PATCH 273/426] chore: checkpoint before hosted agent enhancements
From 25ff7aafd23460a97480103ea8b3d1873d15934b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 06:06:10 +0800
Subject: [PATCH 274/426] feat: enhance hosted agent runtime and prompts
---
src/pages/chat.js | 122 +++++++++++++++++++++++++++++++++++++++-------
1 file changed, 104 insertions(+), 18 deletions(-)
diff --git a/src/pages/chat.js b/src/pages/chat.js
index 35d1d6be..077e7991 100644
--- a/src/pages/chat.js
+++ b/src/pages/chat.js
@@ -33,6 +33,7 @@ const HOSTED_GLOBAL_KEY = 'hostedAgent.default'
const HOSTED_DEFAULTS = {
enabled: false,
prompt: '',
+ systemPrompt: '',
autoRunAfterTarget: true,
stopPolicy: 'self',
maxSteps: 50,
@@ -49,9 +50,13 @@ const HOSTED_RUNTIME_DEFAULT = {
lastError: '',
pending: false,
errorCount: 0,
+ contextTokens: 0,
+ lastTrimAt: 0,
+ lastAction: '',
}
const HOSTED_CONTEXT_MAX = 30
+const HOSTED_CONTEXT_TOKEN_LIMIT = 4000
const COMMANDS = [
{ title: '会话', commands: [
@@ -708,6 +713,7 @@ async function connectGateway() {
if (overlay) overlay.style.display = 'none'
if (_hostedRuntime.status === HOSTED_STATUS.PAUSED) {
_hostedRuntime.status = HOSTED_STATUS.IDLE
+ _hostedRuntime.lastAction = ''
persistHostedRuntime()
updateHostedBadge()
}
@@ -720,6 +726,7 @@ async function connectGateway() {
}
if (_hostedRuntime.status !== HOSTED_STATUS.PAUSED) {
_hostedRuntime.status = HOSTED_STATUS.PAUSED
+ _hostedRuntime.lastAction = 'paused'
persistHostedRuntime()
updateHostedBadge()
}
@@ -732,6 +739,7 @@ async function connectGateway() {
}
if (_hostedRuntime.status !== HOSTED_STATUS.PAUSED) {
_hostedRuntime.status = HOSTED_STATUS.PAUSED
+ _hostedRuntime.lastAction = 'paused'
persistHostedRuntime()
updateHostedBadge()
}
@@ -2358,12 +2366,49 @@ function updateStatusDot(status) {
else _statusDot.classList.add('offline')
}
+function resolveHostedSystemPrompt() {
+ return (_hostedSessionConfig?.systemPrompt || _hostedDefaults?.systemPrompt || '').trim()
+}
+
+function estimateTokens(text) {
+ return Math.max(1, Math.ceil((text || '').length / 4))
+}
+
+function trimHostedHistoryByTokens(limit = HOSTED_CONTEXT_TOKEN_LIMIT) {
+ if (!_hostedSessionConfig?.history) return
+ const systemPrompt = resolveHostedSystemPrompt()
+ const items = _hostedSessionConfig.history.filter(m => m.role !== 'system')
+ let tokens = systemPrompt ? estimateTokens(systemPrompt) : 0
+ for (const item of items) tokens += estimateTokens(item.content)
+
+ if (tokens <= limit) {
+ _hostedRuntime.contextTokens = tokens
+ return
+ }
+
+ let trimmed = [...items]
+ while (trimmed.length && tokens > limit) {
+ const removed = trimmed.shift()
+ tokens -= estimateTokens(removed?.content)
+ }
+
+ _hostedSessionConfig.history = trimmed
+ _hostedRuntime.contextTokens = tokens
+ _hostedRuntime.lastTrimAt = Date.now()
+}
+
async function loadHostedDefaults() {
_hostedDefaults = { ...HOSTED_DEFAULTS }
try {
const panel = await api.readPanelConfig()
const stored = panel?.hostedAgent?.default || null
if (stored) _hostedDefaults = { ..._hostedDefaults, ...stored }
+ if (_hostedDefaults.prompt && !_hostedDefaults.systemPrompt) {
+ _hostedDefaults.systemPrompt = _hostedDefaults.prompt
+ }
+ if (_hostedDefaults.systemPrompt && !_hostedDefaults.prompt) {
+ _hostedDefaults.prompt = _hostedDefaults.systemPrompt
+ }
} catch (e) {
console.warn('[chat][hosted] 读取 panel 配置失败:', e)
}
@@ -2379,9 +2424,17 @@ function loadHostedSessionConfig() {
const key = getHostedSessionKey()
const current = data[key] || {}
_hostedSessionConfig = { ...HOSTED_DEFAULTS, ..._hostedDefaults, ...current }
+ if (!_hostedSessionConfig.systemPrompt && _hostedSessionConfig.prompt) {
+ _hostedSessionConfig.systemPrompt = _hostedSessionConfig.prompt
+ }
+ if (!_hostedSessionConfig.prompt && _hostedSessionConfig.systemPrompt) {
+ _hostedSessionConfig.prompt = _hostedSessionConfig.systemPrompt
+ }
if (!_hostedSessionConfig.state) _hostedSessionConfig.state = { ...HOSTED_RUNTIME_DEFAULT }
if (!_hostedSessionConfig.history) _hostedSessionConfig.history = []
+ _hostedSessionConfig.history = _hostedSessionConfig.history.filter(m => m.role !== 'system')
_hostedRuntime = { ...HOSTED_RUNTIME_DEFAULT, ..._hostedSessionConfig.state }
+ trimHostedHistoryByTokens()
updateHostedBadge()
}
@@ -2436,7 +2489,11 @@ function renderHostedPanel() {
if (_hostedRetryLimitEl) _hostedRetryLimitEl.value = _hostedSessionConfig.retryLimit ?? HOSTED_DEFAULTS.retryLimit
const statusEl = _hostedPanelEl.querySelector('#hosted-agent-status')
if (statusEl) {
- const msg = _hostedRuntime.lastError ? `上次错误: ${_hostedRuntime.lastError}` : '状态正常'
+ let msg = '状态正常'
+ if (_hostedRuntime.status === HOSTED_STATUS.PAUSED) msg = '已暂停,历史保留'
+ if (_hostedRuntime.lastAction === 'stopped') msg = '已停止,历史已清空'
+ if (_hostedRuntime.status === HOSTED_STATUS.ERROR) msg = `异常: ${_hostedRuntime.lastError || '未知错误'}`
+ if (_hostedRuntime.lastError && _hostedRuntime.status !== HOSTED_STATUS.ERROR) msg = `上次错误: ${_hostedRuntime.lastError}`
statusEl.textContent = msg
}
}
@@ -2444,15 +2501,17 @@ function renderHostedPanel() {
async function saveHostedConfig() {
if (!_hostedSessionConfig) return
const prompt = (_hostedPromptEl?.value || '').trim()
- if (!prompt) { toast('请输入初始提示词', 'warning'); return }
const enabled = !!_hostedEnableEl?.checked
const maxSteps = Math.max(1, parseInt(_hostedMaxStepsEl?.value || HOSTED_DEFAULTS.maxSteps, 10))
const stepDelayMs = Math.max(200, parseInt(_hostedStepDelayEl?.value || HOSTED_DEFAULTS.stepDelayMs, 10))
const retryLimit = Math.max(0, parseInt(_hostedRetryLimitEl?.value || HOSTED_DEFAULTS.retryLimit, 10))
+ if (!prompt && enabled) { toast('请输入初始提示词', 'warning'); return }
+
_hostedSessionConfig = {
..._hostedSessionConfig,
prompt,
+ systemPrompt: prompt,
enabled,
autoRunAfterTarget: true,
stopPolicy: 'self',
@@ -2461,17 +2520,13 @@ async function saveHostedConfig() {
retryLimit,
}
- if (!_hostedSessionConfig.history || !_hostedSessionConfig.history.length) {
- _hostedSessionConfig.history = [{ role: 'system', content: prompt }]
- } else if (_hostedSessionConfig.history[0]?.role !== 'system') {
- _hostedSessionConfig.history.unshift({ role: 'system', content: prompt })
- } else {
- _hostedSessionConfig.history[0].content = prompt
- }
+ if (!_hostedSessionConfig.history) _hostedSessionConfig.history = []
+ _hostedSessionConfig.history = _hostedSessionConfig.history.filter(m => m.role !== 'system')
if (!_hostedSessionConfig.state) _hostedSessionConfig.state = { ...HOSTED_RUNTIME_DEFAULT }
_hostedRuntime = { ...HOSTED_RUNTIME_DEFAULT, ..._hostedSessionConfig.state }
if (enabled && _hostedRuntime.status === HOSTED_STATUS.PAUSED) _hostedRuntime.status = HOSTED_STATUS.IDLE
+ _hostedRuntime.lastAction = enabled ? '' : _hostedRuntime.lastAction
persistHostedRuntime()
renderHostedPanel()
updateHostedBadge()
@@ -2492,6 +2547,7 @@ async function saveHostedConfig() {
nextPanel.hostedAgent.default = {
...HOSTED_DEFAULTS,
prompt,
+ systemPrompt: prompt,
enabled,
maxSteps,
stepDelayMs,
@@ -2512,6 +2568,7 @@ function pauseHostedAgent() {
if (!_hostedSessionConfig) return
_hostedRuntime.status = HOSTED_STATUS.PAUSED
_hostedRuntime.pending = false
+ _hostedRuntime.lastAction = 'paused'
persistHostedRuntime()
updateHostedBadge()
toast('托管 Agent 已暂停', 'info')
@@ -2524,6 +2581,12 @@ function stopHostedAgent() {
_hostedRuntime.stepCount = 0
_hostedRuntime.lastError = ''
_hostedRuntime.errorCount = 0
+ _hostedRuntime.contextTokens = 0
+ _hostedRuntime.lastTrimAt = 0
+ _hostedRuntime.lastRunId = ''
+ _hostedRuntime.lastRunAt = 0
+ _hostedRuntime.lastAction = 'stopped'
+ _hostedSessionConfig.history = []
persistHostedRuntime()
updateHostedBadge()
toast('托管 Agent 已停止', 'info')
@@ -2543,6 +2606,7 @@ function appendHostedTarget(text, ts) {
if (!_hostedSessionConfig) return
if (!_hostedSessionConfig.history) _hostedSessionConfig.history = []
_hostedSessionConfig.history.push({ role: 'target', content: text, ts: ts || Date.now() })
+ trimHostedHistoryByTokens()
persistHostedRuntime()
}
@@ -2560,17 +2624,21 @@ function maybeTriggerHostedRun() {
if (_hostedRuntime.status === HOSTED_STATUS.WAITING) {
_hostedRuntime.status = HOSTED_STATUS.IDLE
}
+ _hostedRuntime.lastAction = ''
runHostedAgentStep()
}
function buildHostedMessages() {
+ trimHostedHistoryByTokens()
const history = _hostedSessionConfig?.history || []
const trimmed = history.slice(-HOSTED_CONTEXT_MAX)
- return trimmed.map(item => {
- if (item.role === 'system') return { role: 'system', content: item.content }
+ const systemPrompt = resolveHostedSystemPrompt()
+ const mapped = trimmed.map(item => {
if (item.role === 'assistant') return { role: 'assistant', content: item.content }
return { role: 'user', content: item.content }
})
+ if (systemPrompt) mapped.unshift({ role: 'system', content: systemPrompt })
+ return mapped
}
function detectStopFromText(text) {
@@ -2585,6 +2653,7 @@ async function runHostedAgentStep() {
if (!wsClient.gatewayReady || !_sessionKey) {
_hostedRuntime.status = HOSTED_STATUS.PAUSED
_hostedRuntime.lastError = 'Gateway 未就绪或 sessionKey 缺失'
+ _hostedRuntime.lastAction = 'paused'
persistHostedRuntime()
updateHostedBadge()
appendHostedOutput(`需要人工介入: Gateway 未就绪或 sessionKey 缺失${formatHostedSummary()}`)
@@ -2599,6 +2668,7 @@ async function runHostedAgentStep() {
}
if (_hostedRuntime.stepCount >= _hostedSessionConfig.maxSteps) {
_hostedRuntime.status = HOSTED_STATUS.IDLE
+ _hostedRuntime.lastAction = ''
persistHostedRuntime()
updateHostedBadge()
return
@@ -2608,6 +2678,7 @@ async function runHostedAgentStep() {
_hostedRuntime.status = HOSTED_STATUS.RUNNING
_hostedRuntime.lastRunAt = Date.now()
_hostedRuntime.lastRunId = uuid()
+ _hostedRuntime.lastAction = ''
_currentRunId = _hostedRuntime.lastRunId
persistHostedRuntime()
updateHostedBadge()
@@ -2639,6 +2710,7 @@ async function runHostedAgentStep() {
const rendered = renderHostedTemplate(parsed)
_hostedSessionConfig.history.push({ role: 'assistant', content: rendered, ts: Date.now() })
+ trimHostedHistoryByTokens()
persistHostedRuntime()
appendHostedOutput(`${rendered}${formatHostedSummary()}`)
@@ -2648,8 +2720,9 @@ async function runHostedAgentStep() {
persistHostedRuntime()
updateHostedBadge()
- if (_hostedSessionConfig.stopPolicy === 'self' && detectStopFromText(nextInstruction)) {
+ if (_hostedSessionConfig.stopPolicy === 'self' && detectStopFromText(rendered)) {
_hostedRuntime.status = HOSTED_STATUS.IDLE
+ _hostedRuntime.lastAction = 'stopped'
persistHostedRuntime()
updateHostedBadge()
}
@@ -2928,19 +3001,32 @@ function parseHostedTemplate(text) {
else if (section === 'suggest') suggestions.push(line.replace(/^[\-*\d\.\s]+/, ''))
else if (section === 'risk') risks.push(line.replace(/^[\-*\d\.\s]+/, ''))
}
- // risk 可为空,goal 与 suggestions 必须存在
- if (!goals.length || !suggestions.length) return null
+
+ if (!goals.length && !suggestions.length) {
+ return {
+ goal: '',
+ suggestions: [raw],
+ risks: [],
+ }
+ }
+
return {
goal: goals.join(' '),
- suggestions: suggestions.filter(Boolean),
+ suggestions: suggestions.filter(Boolean).length ? suggestions.filter(Boolean) : [raw],
risks: risks.filter(Boolean),
}
}
function renderHostedTemplate(parsed) {
- const riskText = parsed.risks.length ? parsed.risks.map(r => `- ${r}`).join('\n') : '- 暂无'
- const suggestText = parsed.suggestions.map(s => `- ${s}`).join('\n')
- return `目标: ${parsed.goal}\n建议:\n${suggestText}\n风险:\n${riskText}`
+ 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')
}
function appendHostedOutput(text) {
From e9137389d7073a8ad74b46b897cbb670c1b3f04d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 06:35:38 +0800
Subject: [PATCH 275/426] chore: checkpoint before hosted history seed
From 7418945edf652b864703b535b4a276967776dfaf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=AF=E8=BE=BE=E9=B8=AD?= <3443833799@qq.com>
Date: Wed, 18 Mar 2026 06:44:19 +0800
Subject: [PATCH 276/426] feat: seed hosted history and remove count limit
---
src/pages/chat.js | 54 +++++++++++++++++++++++++++++++++++++++--------
1 file changed, 45 insertions(+), 9 deletions(-)
diff --git a/src/pages/chat.js b/src/pages/chat.js
index 077e7991..28a63b0c 100644
--- a/src/pages/chat.js
+++ b/src/pages/chat.js
@@ -34,6 +34,7 @@ const HOSTED_DEFAULTS = {
enabled: false,
prompt: '',
systemPrompt: '',
+ contextTokenLimit: 200000,
autoRunAfterTarget: true,
stopPolicy: 'self',
maxSteps: 50,
@@ -55,8 +56,9 @@ const HOSTED_RUNTIME_DEFAULT = {
lastAction: '',
}
-const HOSTED_CONTEXT_MAX = 30
-const HOSTED_CONTEXT_TOKEN_LIMIT = 4000
+const HOSTED_CONTEXT_MAX = 0
+const HOSTED_CONTEXT_TOKEN_LIMIT = 200000
+let _hostedSeeded = false
const COMMANDS = [
{ title: '会话', commands: [
@@ -101,7 +103,7 @@ let _sendBtn = null, _statusDot = null, _typingEl = null, _scrollBtn = null
let _sessionListEl = null, _cmdPanelEl = null, _attachPreviewEl = null, _fileInputEl = null
let _modelSelectEl = null
let _hostedBtn = null, _hostedPanelEl = null, _hostedBadgeEl = null
-let _hostedPromptEl = null, _hostedEnableEl = null, _hostedMaxStepsEl = null, _hostedStepDelayEl = null, _hostedRetryLimitEl = null
+let _hostedPromptEl = null, _hostedEnableEl = null, _hostedMaxStepsEl = null, _hostedStepDelayEl = null, _hostedRetryLimitEl = null, _hostedContextLimitEl = null
let _hostedSaveBtn = null, _hostedPauseBtn = null, _hostedStopBtn = null, _hostedCloseBtn = null
let _hostedGlobalSyncEl = null
let _hostedDefaults = null
@@ -257,6 +259,10 @@ export async function render() {