后端修复: - 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>
9.8 KiB
紧急诊断报告:页面崩溃 "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 为什么是「持久性」错误
错误在以下场景触发:
-
初次对话崩溃: 用户发送消息 → WebSocket 流式返回 →
MessageBubble渲染 assistant 消息 →AIMessageActions挂载 →useSpeechSynthesis()初始化 → 组件因某些原因卸载(React 重渲染、StrictMode 双重挂载、或流式更新导致的条件渲染变化) → cleanup 执行window.speechSynthesis.cancel()→ 如果此时speechSynthesis对象不可用 → TypeError → ErrorBoundary 捕获 -
刷新后持续崩溃: 用户已登录 →
initSession()从服务端加载历史消息 → 历史消息中包含 assistant 消息 →MessageBubble渲染 →AIMessageActions挂载 → 同样的初始化路径触发同样的错误 → ErrorBoundary 持续捕获 → 页面永远无法恢复 -
为什么
window.speechSynthesis可能为undefined:- 尽管 CDP 测试显示它存在,但在某些特定的浏览器/环境组合下(如特定版本的 headless Chrome、无音频设备的服务器环境、或浏览器安全策略限制),
window.speechSynthesis可能是一个不完整的对象,或者在某些时序下(如页面刚加载、onvoiceschanged未触发时)表现出意外行为 - React 18 的
StrictMode(在main.tsx第10行启用)会双重挂载/卸载组件,增加了 cleanup 被调用的次数和时序复杂度
- 尽管 CDP 测试显示它存在,但在某些特定的浏览器/环境组合下(如特定版本的 headless Chrome、无音频设备的服务器环境、或浏览器安全策略限制),
2.5 辅助触发因素
AIMessageActions 内部还有一个额外的无守卫调用:
const checkEnd = setInterval(() => {
if (!window.speechSynthesis.speaking) { // 直接访问 speechSynthesis
setIsThisSpeaking(false);
clearInterval(checkEnd);
}
}, 200);
此 setInterval 没有清理机制 — 如果 AIMessageActions 在 checkEnd 回调执行时已卸载,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.tsx 的 AIMessageActions 组件中,为 checkEnd 的 setInterval 添加清理:
useEffect(() => {
return () => {
if (checkEndRef.current) {
clearInterval(checkEndRef.current);
}
};
}, []);
5.3 架构层建议
考虑将 useSpeechSynthesis() 调用提升到应用层级(如 App.tsx 或 AppLayout.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 消息气泡时触发 AIMessageActions → useSpeechSynthesis(),在 React StrictMode 双重挂载/卸载周期中触发未守卫的 cleanup,导致错误在每次页面加载时必现。
修复优先级: 🔴 P0 — 阻塞所有已登录用户使用应用。