[WebChat刷新导致内部系统/心跳条目泄露] - WebChat Refresh Leaks Internal System/Heartbeat Entries
刷新WebChat后,内部消息(系统消息、心跳响应、工具链产物)由于buildChatItems中历史记录过滤不一致,会泄露到可见对话中。
🔍 症状
视觉表现
WebChat 页面刷新后,用户在对话时间线中观察到以下伪影:
- 系统消息块 — 作为聊天项呈现的内部系统事件(例如"刷新过滤器复现")
- 心跳确认文本 — 最终用户可见的响应负载(如
HEARTBEAT_OK) - 内部工具/状态链项 — 对话视图中暴露的工具执行伪影和状态消息
CLI 复现命令
触发内部事件进行测试:
openclaw system event --mode now --text "Refresh filter repro"
预期与实际行为对比
| 状态 | 预期 | 实际(缺陷) |
|---|---|---|
| 刷新前 | 干净的用户/助手对话 | 干净的用户/助手对话 |
| 刷新后 | 干净的用户/助手对话 | 可见系统/心跳伪影 |
环境详情
- 版本: OpenClaw 2026.2.26
- 操作系统: macOS 26.1 (Darwin 25.1.0, arm64)
- 安装方式: pnpm 全局二进制文件 (
/Users/bell/Library/pnpm/openclaw) - 功能标志: WebChat 已启用,心跳已启用
🧠 根因分析
架构分析
WebChat 刷新泄漏源于聊天历史渲染管道中的两阶段架构不一致:
阶段 1:历史重新加载 (ui/src/ui/controllers/chat.ts)
当 WebChat 初始化或刷新时,聊天控制器加载完整的 chat.history 数组:
// ui/src/ui/controllers/chat.ts (line ~N)
const history = chat.history; // 加载包含内部条目的完整历史
这包括所有消息类型,无论其分类为内部消息还是面向用户的消息。
阶段 2:过滤不足 (ui/src/ui/views/chat.ts)
buildChatItems 函数应用的过滤逻辑范围不够广:
// ui/src/ui/views/chat.ts - buildChatItems 函数
function buildChatItems(history) {
return history.filter(item => {
// 当前过滤逻辑(不完整):
if (!item.thinking && item.type === 'toolresult') {
return false; // 仅在 thinking 关闭时抑制 toolresult
}
// 缺失:没有过滤 system/heartbeat/internal 消息类
return true;
});
}
故障序列
- 用户触发内部事件(心跳、系统命令)
- 内部消息以分类标志(如
role: 'system'、internal: true、messageClass: 'heartbeat')插入chat.history - 页面刷新发生
chat.ts重新加载包含内部条目的完整历史buildChatItems无法过滤这些条目,因为过滤逻辑不检查messageClass或internal标志- 内部伪影在可见的 WebChat 时间线中渲染
消息分类缺口
当前实现缺乏消息分类的系统性方法。内部消息应携带渲染层可用于过滤的元数据标志:
// 带有分类的预期消息结构
{
id: "msg_xxx",
role: "system",
content: "HEARTBEAT_OK",
messageClass: "heartbeat", // 过滤检查中缺失
internal: true, // 过滤检查中缺失
visibleInChat: false // 过滤检查中缺失
}
🛠️ 逐步修复
解决方案概述
实现一个 开发者模式开关,用于控制消息可见性:
- 开发者模式关闭(默认): 仅显示面向用户的对话项
- 开发者模式开启: 显示完整的内部信息用于调试
阶段 1:添加消息分类标志
文件: packages/core/src/types/chat.ts
// 添加到消息接口
export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'system' | 'tool';
content: string;
// ... 现有字段
// 新增:用于 UI 过滤的分类
internal?: boolean;
messageClass?: 'user' | 'assistant' | 'system' | 'heartbeat' | 'tool' | 'status';
visibleInChat?: boolean; // 显式覆盖
}
阶段 2:更新 buildChatItems 过滤
文件: ui/src/ui/views/chat.ts
// 之前(不完整的过滤)
function buildChatItems(history, devMode = false) {
return history.filter(item => {
if (!item.thinking && item.type === 'toolresult') {
return false;
}
return true;
});
}
// 之后(全面过滤)
function buildChatItems(history, devMode = false) {
return history.filter(item => {
// 显式可见性覆盖
if (item.visibleInChat === false && !devMode) {
return false;
}
// 生产模式下隐藏内部消息
if (item.internal && !devMode) {
return false;
}
// 基于消息类的过滤
const hiddenClasses = ['heartbeat', 'system', 'status'];
if (hiddenClasses.includes(item.messageClass) && !devMode) {
return false;
}
// 遗留 toolresult 处理(thinking 关闭时)
if (!item.thinking && item.type === 'toolresult') {
return false;
}
return true;
});
}
阶段 3:实现开发者模式开关
文件: ui/src/ui/components/DevModeToggle.tsx
import { useState, useEffect } from 'react';
import { loadSetting, saveSetting } from '../utils/settings';
const DEV_MODE_KEY = 'openclaw_dev_mode';
export function DevModeToggle() {
const [devMode, setDevMode] = useState(() =>
loadSetting(DEV_MODE_KEY, false)
);
useEffect(() => {
saveSetting(DEV_MODE_KEY, devMode);
}, [devMode]);
return (
<div className="dev-mode-toggle">
<label>
<input
type="checkbox"
checked={devMode}
onChange={(e) => setDevMode(e.target.checked)}
/>
开发者模式
</label>
<span className="dev-mode-indicator">
{devMode ? '🔧 调试中' : '🚀 生产环境'}
</span>
</div>
);
}
文件: ui/src/ui/utils/settings.ts
const SETTINGS_KEY = 'openclaw_ui_settings';
export function loadSetting(key: string, defaultValue: any): any {
try {
const settings = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}');
return settings[key] ?? defaultValue;
} catch {
return defaultValue;
}
}
export function saveSetting(key: string, value: any): void {
try {
const settings = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}');
settings[key] = value;
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
} catch (e) {
console.error('无法持久化设置:', key, e);
}
}
阶段 4:将开发者模式连接到聊天控制器
文件: ui/src/ui/controllers/chat.ts
// 之前
const history = chat.history;
// 之后
import { loadSetting } from '../utils/settings';
const DEV_MODE = loadSetting('openclaw_dev_mode', false);
const history = buildChatItems(chat.history, DEV_MODE);
阶段 5:在插入时标记内部消息
文件: packages/heartbeat/src/handler.ts(或相关心跳模块)
// 插入心跳响应时
chat.history.push({
id: generateId(),
role: 'system',
content: 'HEARTBEAT_OK',
messageClass: 'heartbeat',
internal: true,
visibleInChat: false, // 显式抑制
timestamp: Date.now()
});
🧪 验证
测试用例 1:验证刷新后视图干净(开发者模式关闭)
# 1. 启动启用心跳的 WebChat
openclaw start --webchat --heartbeat
# 2. 触发内部系统事件
openclaw system event --mode now --text "Refresh filter test"
# 3. 刷新 WebChat 页面(Ctrl+Shift+R / Cmd+Shift+R)
# 4. 验证对话中没有内部伪影
# 预期:仅可见用户消息和助手回复
# 检查是否不存在:HEARTBEAT_OK、系统消息块、工具链
预期输出: 对话仅包含用户/助手交换内容。
测试用例 2:验证开发者模式显示内部信息
# 1. 在 WebChat UI 中启用开发者模式开关
# 2. 刷新页面
# 3. 验证现在可见内部伪影
# 预期:系统消息、心跳 ACK、工具链可见
预期输出: 可见带有调试指示符的完整内部消息链。
测试用例 3:验证开发者模式跨会话持久化
# 1. 启用开发者模式
# 2. 关闭 WebChat 标签页
# 3. 重新打开 WebChat
# 4. 验证开发者模式状态已保留
# 检查 localStorage
window.localStorage.getItem('openclaw_ui_settings')
# 预期:{"openclaw_dev_mode":true}
测试用例 4:CLI 消息分类验证
# 验证消息具有正确的分类
openclaw chat history --format json | jq '.[] | select(.messageClass == "heartbeat")'
# 预期:返回心跳条目(确认分类已设置)
测试用例 5:工具结果过滤的回归测试
# 1. 禁用 thinking 模式
openclaw config set thinking false
# 2. 执行工具调用(如文件读取)
openclaw tool run read-file --path /tmp/test.txt
# 3. 刷新页面
# 4. 验证关闭模式下工具结果隐藏,开启模式下可见
预期退出码: 所有测试通过,退出码为 0。
⚠️ 常见陷阱
1. 历史加载时的竞态条件
问题: 聊天控制器可能在从 localStorage 读取开发者模式设置之前加载历史。
缓解措施: 在模块加载时同步初始化开发者模式,而不是延迟加载。
// 正确:同步初始化
const DEV_MODE = (() => {
try {
return JSON.parse(localStorage.getItem('openclaw_ui_settings') || '{}').openclaw_dev_mode ?? false;
} catch {
return false;
}
})();
// 错误:延迟初始化(导致竞态)
const getDevMode = async () => loadSetting(...); // 不要这样做
2. 消息分类不一致
问题: 某些消息生产者(心跳、系统事件、插件)可能未设置 messageClass 或 internal 标志。
缓解措施: 在开发模式下实现架构验证器:
// 添加到 buildChatItems
if (process.env.NODE_ENV === 'development') {
history.forEach(item => {
if (!item.messageClass && item.role === 'system') {
console.warn('[开发] 系统消息缺少 messageClass:', item.id);
}
});
}
3. Docker/容器环境中的 localStorage
问题: 在 Docker 容器内运行的 WebChat 可能具有隔离的 localStorage 行为。
变通方案: 除了 localStorage 外,还要确保通过后端 API 调用持久化开发者模式偏好:
// 回退到服务器端偏好
async function getDevMode() {
const local = loadSetting('openclaw_dev_mode', false);
const server = await fetch('/api/user/preferences/dev-mode').catch(() => null);
return server ? await server.json() : local;
}
4. 历史大小和内存
问题: 加载包含所有内部消息的完整历史可能导致长对话的内存问题。
缓解措施: 在保存时实现内部消息的历史修剪:
// 持久化聊天状态时
function pruneInternalMessages(chat) {
return {
...chat,
history: chat.history.filter(item =>
item.internal ? false : true // 不持久化内部消息
)
};
}
5. 多标签页同步
问题: 一个标签页中的开发者模式开关可能不会同步到其他打开的 WebChat 标签页。
变通方案: 监听存储事件:
window.addEventListener('storage', (e) => {
if (e.key === 'openclaw_ui_settings') {
// 使用更新后的设置重新渲染
forceUpdate();
}
});
6. 浏览器扩展冲突
问题: 修改 localStorage 或注入脚本的扩展可能破坏设置。
检测: 添加完整性检查:
function validateSettings() {
try {
const settings = JSON.parse(localStorage.getItem('openclaw_ui_settings'));
return typeof settings.openclaw_dev_mode === 'boolean';
} catch {
return false;
}
}
🔗 相关错误
逻辑关联的问题
- #26461 — 快速刷新周期后的历史状态损坏(相关:重新加载期间的状态管理)
- #21032 — 对话导出中出现系统消息(相关:导出边界处的历史过滤)
- #12186 — 工具链可见性未遵守 UI 偏好(已关闭/过时但类似的过滤范围)
类似的错误模式
| 错误代码 | 描述 | 关联性 |
|---|---|---|
E_HEARTBEAT_LEAK | 心跳响应在面向用户的视图中可见 | 此缺陷的直接症状 |
E_SYSTEM_MSG_LEAK | 系统消息在正常聊天模式中暴露 | 与心跳泄漏相同的根本原因 |
E_TOOLCHAIN_VISIBLE | 工具执行链在生产 UI 中可见 | buildChatItems 中的相关过滤问题 |
E_HISTORY_RELOAD_ARTIFACTS | 页面重新加载后出现陈旧的内部条目 | 历史重新加载时序问题 |
架构依赖
ui/src/ui/controllers/chat.ts— 历史加载逻辑ui/src/ui/views/chat.ts—buildChatItems过滤函数packages/heartbeat/src/handler.ts— 心跳消息插入packages/core/src/types/chat.ts— 消息类型定义