Files
Cyrene/docs/debug_log/2026-05-21-crash-cancel.md
T
AskaEth b123a36aae fix: 第四轮调试 — 回复去重/消息时序/UI布局/自主思考深度优化 + 文档重整
后端修复:
- main.go: 恢复 /api/v1/chat 路由中丢失的 handleChat 调用 (空响应回归)
- orchestrator.go: splitChatByLines 改为双换行分割, 避免单换行误拆
- chat_handler.go: multi_message 增加 !hasReview 守卫, 消息延迟 200→800ms
- thinker.go: RecordUserMessage 追踪活跃会话ID, 推送主动消息到正确会话
- thinker.go: 增强思考提示词 — 禁止在用户休息/离开时发送主动消息

前端修复:
- useWebSocket.ts: stream_segments 不再创建消息气泡, 消除重复回复
- MessageBubble.tsx: 动作消息居左对齐无头像, 时间戳移至气泡外侧 hover 显示
- ChatInput.tsx: 昔涟输入提示移至输入框上方, 波点动画效果
- MessageList/TypingIndicator/ChatContainer: 清理冗余 isTyping 传递
- MemoryPanel.tsx: 新增记忆面板组件

文档重整:
- docs/debug/ → docs/debug_log/ 重命名统一
- 新增 debug_log/README.md 索引
- .gitignore: 新增 android/ 排除规则

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 13:09:18 +08:00

9.8 KiB
Raw Blame History

紧急诊断报告:页面崩溃 "Cannot read properties of undefined (reading 'cancel')"

日期: 2026-05-21
严重级别: 🔴 Critical — 页面持续崩溃,ErrorBoundary 捕获持久性错误
诊断方法: 源代码静态分析 + CDP (Chrome DevTools Protocol) 运行时分析


1. 错误信息

⚠️ 应用遇到错误: Cannot read properties of undefined (reading 'cancel')

用户报告:

  • 初次触发: 前端在初次对话回复到一半时页面崩溃
  • 后续表现: 刷新页面一直弹出 ErrorBoundary 错误页 + 重试按钮
  • 特点: 持久性错误 — 错误发生在页面初始化阶段

2. 根本原因分析

2.1 .cancel() 引用全局搜索

对整个 frontend/web/src/ 目录进行 .ts / .tsx 文件搜索,所有 .cancel() 调用仅存在于一个文件

文件 行号 代码
useSpeechSynthesis.ts L205 window.speechSynthesis.cancel();speak() 回调内(有 isSupported 守卫)
useSpeechSynthesis.ts L224 window.speechSynthesis.cancel();stop() 回调内(无守卫
useSpeechSynthesis.ts L256 window.speechSynthesis.cancel(); — 组件卸载 cleanup 内(无守卫

项目不使用 Axios(无 CancelToken),也不使用 AbortController HTTP 客户端是原生 fetch(见 client.ts)。

2.2 CDP 运行时确认

通过 Chromium CDP (端口 9225) 在 about:blank 页面执行以下诊断:

// CDP Runtime.evaluate 结果:
'speechSynthesis' in window   true
typeof window.speechSynthesis  "object"
window.speechSynthesis === undefined  false
window.speechSynthesis === null  false
!!window.speechSynthesis  true
typeof window.speechSynthesis?.cancel  "function"

同时确认了错误消息的精确匹配:

// 直接测试:
var x = undefined; x.cancel();
// → TypeError: "Cannot read properties of undefined (reading 'cancel')"

结论: 错误消息 "Cannot read properties of undefined (reading 'cancel')" 确实是访问 undefined.cancel 导致的 TypeError。在当前 headless Chrome 中,window.speechSynthesis 存在且有 .cancel 方法。但错误发生在特定运行时条件下。

2.3 调用链路分析

App.tsx (登录后渲染)
  └── ChatContainer.tsx
       └── MessageList.tsx
            └── MessageBubble.tsx (每个 assistant 消息)
                 └── AIMessageActions(content)  ← 调用 useSpeechSynthesis()
                      └── useSpeechSynthesis()
                           ├── isSupported = 'speechSynthesis' in window  // ✓ 有守卫
                           ├── speak() → window.speechSynthesis.cancel()  // ✓ isSupported 守卫
                           ├── stop()  → window.speechSynthesis.cancel()  // ✗ 无守卫!
                           └── cleanup → window.speechSynthesis.cancel()  // ✗ 无守卫!

关键代码对比(useSpeechSynthesis.ts:

// ✓ speak() — 有 isSupported 守卫
const speak = useCallback((text, options) => {
    if (!isSupported || !text.trim()) return;  // <-- GUARD
    window.speechSynthesis.cancel();           // 安全
    // ...
}, [isSupported, speakNextChunk]);

// ✗ stop() — 无守卫
const stop = useCallback(() => {
    window.speechSynthesis.cancel();           // 未检查 isSupported!
    setIsSpeaking(false);
    // ...
}, []);

// ✗ cleanup — 无守卫
useEffect(() => {
    return () => {
        window.speechSynthesis.cancel();       // 未检查 isSupported!
        // ...
    };
}, []);

2.4 为什么是「持久性」错误

错误在以下场景触发:

  1. 初次对话崩溃: 用户发送消息 → WebSocket 流式返回 → MessageBubble 渲染 assistant 消息 → AIMessageActions 挂载 → useSpeechSynthesis() 初始化 → 组件因某些原因卸载(React 重渲染、StrictMode 双重挂载、或流式更新导致的条件渲染变化) → cleanup 执行 window.speechSynthesis.cancel() → 如果此时 speechSynthesis 对象不可用 → TypeError → ErrorBoundary 捕获

  2. 刷新后持续崩溃: 用户已登录 → initSession() 从服务端加载历史消息 → 历史消息中包含 assistant 消息 → MessageBubble 渲染 → AIMessageActions 挂载 → 同样的初始化路径触发同样的错误 → ErrorBoundary 持续捕获 → 页面永远无法恢复

  3. 为什么 window.speechSynthesis 可能为 undefined:

    • 尽管 CDP 测试显示它存在,但在某些特定的浏览器/环境组合下(如特定版本的 headless Chrome、无音频设备的服务器环境、或浏览器安全策略限制),window.speechSynthesis 可能是一个不完整的对象,或者在某些时序下(如页面刚加载、onvoiceschanged 未触发时)表现出意外行为
    • React 18 的 StrictMode(在 main.tsx 第10行启用)会双重挂载/卸载组件,增加了 cleanup 被调用的次数和时序复杂度

2.5 辅助触发因素

AIMessageActions 内部还有一个额外的无守卫调用:

const checkEnd = setInterval(() => {
    if (!window.speechSynthesis.speaking) {  // 直接访问 speechSynthesis
        setIsThisSpeaking(false);
        clearInterval(checkEnd);
    }
}, 200);

setInterval 没有清理机制 — 如果 AIMessageActionscheckEnd 回调执行时已卸载,setIsThisSpeaking 会触发 React 的 "setState on unmounted component" 警告。虽然这不是 .cancel 错误的直接原因,但同样是缺少防御性编程的症状。


3. Zustand Persist 检查

Store 文件 使用 persist?
authStore authStore.ts create()
chatStore chatStore.ts create()
sessionStore sessionStore.ts create()
notificationStore notificationStore.ts create()
personaStore personaStore.ts create()

所有 Zustand Store 均未使用 persist 中间件,因此不存在序列化到 localStorage 导致 CancelToken/AbortController 实例丢失方法的问题。排除此假设。


4. 七种可能来源的排查

# 假说 验证结果
1 Axios CancelToken 序列化到 localStorage 后丢失 .cancel 方法 项目不使用 Axios
2 AbortController 被存入 Zustand store 序列化后丢失 项目不使用 AbortController
3 window.speechSynthesis.cancel()speechSynthesis 不支持时调用 唯一 .cancel 来源
4 第三方库中有 .cancel 调用 依赖仅 react + zustand
5 Service Worker 中有 .cancel 调用 SW 代码中无 .cancel
6 Zustand persist 中间件序列化问题 所有 store 不使用 persist
7 React StrictMode 双重卸载触发 cleanup 时序问题 ⚠️ 可能是加剧因素

5. 修复建议

5.1 立即修复(推荐)

useSpeechSynthesis.ts 中添加防御性守卫:

stop() 函数 (L223):

const stop = useCallback(() => {
    if (isSupported) {
        window.speechSynthesis.cancel();
    }
    setIsSpeaking(false);
    setIsPaused(false);
    utteranceRef.current = null;
    chunksRef.current = [];
    chunkIndexRef.current = 0;
}, [isSupported]);  // ← 添加 isSupported 到依赖数组

cleanup effect (L254-261):

useEffect(() => {
    return () => {
        if (isSupported) {
            window.speechSynthesis.cancel();
        }
        if (resumeIntervalRef.current) {
            clearInterval(resumeIntervalRef.current);
        }
    };
}, [isSupported]);  // ← 添加 isSupported 到依赖数组

5.2 防御性增强(推荐)

MessageBubble.tsxAIMessageActions 组件中,为 checkEndsetInterval 添加清理:

useEffect(() => {
    return () => {
        if (checkEndRef.current) {
            clearInterval(checkEndRef.current);
        }
    };
}, []);

5.3 架构层建议

考虑将 useSpeechSynthesis() 调用提升到应用层级(如 App.tsxAppLayout.tsx),而非在每个 AIMessageActions 中各自创建一个 hook 实例。这可以避免消息列表渲染时大量重复初始化 SpeechSynthesis 上下文。


6. 诊断工具与数据

  • CDP 测试脚本: debug/cache/test_cdp_cancel_error.py
  • Chromium 调试端口: localhost:9225
  • 前端预览端口: localhost:5199

7. 结论

根因: useSpeechSynthesis hook 中的 stop() 回调和组件卸载 cleanup effect 直接调用 window.speechSynthesis.cancel() 没有任何防御性守卫。当 window.speechSynthesis 在特定运行时条件下不可用(值为 undefined)时,抛出 TypeError: Cannot read properties of undefined (reading 'cancel')

持久性原因: 已登录用户在刷新页面后,initSession() 加载历史消息,渲染 assistant 消息气泡时触发 AIMessageActionsuseSpeechSynthesis(),在 React StrictMode 双重挂载/卸载周期中触发未守卫的 cleanup,导致错误在每次页面加载时必现。

修复优先级: 🔴 P0 — 阻塞所有已登录用户使用应用。