April 21, 2026 • 版本: 2026.2.26

[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;
  });
}

故障序列

  1. 用户触发内部事件(心跳、系统命令)
  2. 内部消息以分类标志(如 role: 'system'internal: truemessageClass: 'heartbeat')插入 chat.history
  3. 页面刷新发生
  4. chat.ts 重新加载包含内部条目的完整历史
  5. buildChatItems 无法过滤这些条目,因为过滤逻辑不检查 messageClassinternal 标志
  6. 内部伪影在可见的 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. 消息分类不一致

问题: 某些消息生产者(心跳、系统事件、插件)可能未设置 messageClassinternal 标志。

缓解措施: 在开发模式下实现架构验证器:

// 添加到 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.tsbuildChatItems 过滤函数
  • packages/heartbeat/src/handler.ts — 心跳消息插入
  • packages/core/src/types/chat.ts — 消息类型定义

依据与来源

本故障排除指南由 FixClaw 智能管线从社区讨论中自动合成。