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

226 lines
9.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 紧急诊断报告:页面崩溃 "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`](frontend/web/src/hooks/useSpeechSynthesis.ts:205) | L205 | `window.speechSynthesis.cancel();``speak()` 回调内(有 `isSupported` 守卫) |
| [`useSpeechSynthesis.ts`](frontend/web/src/hooks/useSpeechSynthesis.ts:224) | L224 | `window.speechSynthesis.cancel();``stop()` 回调内(**无守卫** |
| [`useSpeechSynthesis.ts`](frontend/web/src/hooks/useSpeechSynthesis.ts:256) | L256 | `window.speechSynthesis.cancel();` — 组件卸载 cleanup 内(**无守卫** |
**项目不使用 Axios(无 `CancelToken`),也不使用 `AbortController`。** HTTP 客户端是原生 `fetch`(见 [`client.ts`](frontend/web/src/api/client.ts))。
### 2.2 CDP 运行时确认
通过 Chromium CDP (端口 9225) 在 `about:blank` 页面执行以下诊断:
```javascript
// 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"
```
同时确认了错误消息的精确匹配:
```javascript
// 直接测试:
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`](frontend/web/src/hooks/useSpeechSynthesis.ts):
```typescript
// ✓ 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` 内部还有一个额外的[无守卫调用](frontend/web/src/components/chat/MessageBubble.tsx:104-105):
```typescript
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`](frontend/web/src/store/authStore.ts) | ❌ 纯 `create()` |
| `chatStore` | [`chatStore.ts`](frontend/web/src/store/chatStore.ts) | ❌ 纯 `create()` |
| `sessionStore` | [`sessionStore.ts`](frontend/web/src/store/sessionStore.ts) | ❌ 纯 `create()` |
| `notificationStore` | [`notificationStore.ts`](frontend/web/src/store/notificationStore.ts) | ❌ 纯 `create()` |
| `personaStore` | [`personaStore.ts`](frontend/web/src/store/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`](frontend/web/src/hooks/useSpeechSynthesis.ts) 中添加防御性守卫:
**`stop()` 函数 (L223)**:
```typescript
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)**:
```typescript
useEffect(() => {
return () => {
if (isSupported) {
window.speechSynthesis.cancel();
}
if (resumeIntervalRef.current) {
clearInterval(resumeIntervalRef.current);
}
};
}, [isSupported]); // ← 添加 isSupported 到依赖数组
```
### 5.2 防御性增强(推荐)
在 [`MessageBubble.tsx`](frontend/web/src/components/chat/MessageBubble.tsx) 的 `AIMessageActions` 组件中,为 `checkEnd``setInterval` 添加清理:
```typescript
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 — 阻塞所有已登录用户使用应用。