Files
CamTalk/docs/conversation-history-bug-analysis.md
2026-06-20 19:57:36 +08:00

247 lines
10 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.
## CamTalk 对话历史功能 — Bug 分析与修复方案
### 一、整体架构现状
当前对话历史系统存在一个**根本性的架构缺陷**:前端和后端各自维护了一套完全独立的会话管理系统,两者之间从未同步。
**前端**`useSessionList` Hook + `localStorage` 管理会话列表和消息存储。会话 ID 由前端 `uuid` 生成,消息通过 `localStorage` 持久化。
**后端**`MemoryManager` (内存) + `PgSessionRepository` / `PgMessageRepository` (PostgreSQL) 管理会话和消息。会话 ID 由后端 `uuid.New()` 生成。
前端 `api.ts` 中没有任何对话相关的 REST API 调用,后端提供的 `/api/conversations` 全套接口List / Create / Get / Patch / Delete / GetMessages完全未被前端使用。
---
### 二、Bug 清单
#### P0 — 严重级别
**Bug 1前后端会话系统完全脱节**
前端创建会话(`useSessionList.createSession`)只在 localStorage 中写入一条 `SessionSummary`,后端完全不知道这个会话的存在。后端在 WebSocket 连接时创建的会话(`ws/handler.go` L148有独立的 ID前端也无法感知。两套 ID 体系互不关联,导致:
- 前端切换/删除会话无法影响后端
- 后端消息持久化到 PG 但前端无法读取
- 对话历史不能跨设备、跨浏览器同步
- 清除浏览器数据后所有历史丢失
**Bug 2活跃对话中创建/切换会话导致后端消息写入错误会话**
复现步骤:
1. 用户在会话 A 中正在对话WebSocket 已连接,后端 sessionID = A
2. 用户点击"新建对话"
3. 前端 `handleNewSession` 调用 `createSession()` 创建前端会话 B调用 `setMessages([])` 清空 UI
4. 由于 `connectionStatus === "connected"`,调用 `stopSession()` 断开 WebSocket
5. 用户在新 UI 中发送消息,前端显示在"新对话"下
6. 但 WebSocket 重连后,后端创建了一个**全新的**会话 C
结果:前端认为是会话 B后端实际是会话 C。如果 `stopSession` 未执行(连接状态判断时序问题),消息甚至会写入旧会话 A。
**Bug 3刷新页面后 activeSessionId 丢失,消息无法自动保存**
`useSessionList``activeSessionId` 初始值为 `null`,且不会从 localStorage 恢复:
```typescript
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
```
初始化逻辑(`App.tsx` L122-129仅在 `sessions.length === 0` 时调用 `createSession()`。对于回访用户sessions 不为空),`activeSessionId` 保持 `null`
自动保存的 `useEffect`L136-140需要 `activeSessionId` 非 null
```typescript
if (activeSessionId && messages.length > 0) {
persistSession(activeSessionId, messages);
}
```
结果:回访用户如果不点击侧边栏选择会话,所有新消息不会被持久化,刷新页面即丢失。
#### P1 — 重要级别
**Bug 4切换会话时强制断开 WebSocket用户体验差**
`handleSelectSession``handleNewSession` 都调用 `stopSession()`,而 `stopSession` 会断开 WebSocket 连接。每次切换会话都需要重新建立连接TCP 握手 + JWT 认证 + VAD 初始化),增加约 1-3 秒延迟。
正确做法应该是在切换会话时保持 WebSocket 连接,仅在后端切换 sessionID通过发送 `conversation_id` 参数重连,或者在协议中增加切换会话的消息类型)。
**Bug 5前端 historyRef 是无效的死代码**
`useVisionSession` 中的 `historyRef`L48被维护但从未被实际使用
```typescript
const historyRef = useRef<Array<{ role: string; content: string }>>([]);
```
它被 push`llm_done` 时 L258、`sendTextMessage` 时 L466、`interrupt` 时 L417但从未被读取或发送到后端。前端的 LLM 上下文完全由后端 `session.Manager.GetHistory` 独立管理。这段代码增加了维护负担却没有任何功能价值。
**Bug 6VAD 语音输入时用户消息未加入 historyRef**
`onSpeechEnd` 回调L186-228添加了用户消息到 `messages` state但从未 push 到 `historyRef`。同样,`stt_result` 处理器L236-249更新消息文本后也未同步到 `historyRef`
虽然 `historyRef` 本身是死代码Bug 5但如果未来要利用它这个遗漏会造成语音消息在前端历史中缺失。
**Bug 7观察模式消息未加入 historyRef**
`useObservationMode``onChange` 回调L82-107添加了用户消息但未 push 到 `historyRef`。同 Bug 6。
#### P2 — 一般级别
**Bug 8ChatPanel 使用数组 index 作为 React key**
```tsx
{messages.map((msg, index) => (
<div key={index} ...>
```
当消息列表动态变化时(如 STT 结果更新替换了占位消息),使用 index 作为 key 可能导致 React 无法正确 diff出现闪烁或渲染异常。应使用稳定唯一的 ID`timestamp` 或生成 UUID
**Bug 9后端 AppendMessage 中 tokensUsed 始终为 0**
`MemoryManager.AppendMessage` 异步写 PG 时硬编码 `tokensUsed` 为 0
```go
if err := m.msgRepo.SaveMessage(context.Background(), sessionID, msg, 0); err != nil {
```
`WsLLMDone` 中的 `tokens_used` 信息未被传递到持久化层,导致 PG 中所有消息的 token 统计均为 0。
**Bug 10后端 WS Handler 与 Eino 编排器重复获取历史**
`handler.go` L240 获取了 `history` 并传给 `ProcessQuery`,但 `ProcessQuery``adapter.go`内部并未使用这个参数。Eino Graph 的 History 节点(`nodes_history.go`)会自己重新调用 `sessionMgr.GetHistory`。传入的 `history` 参数被浪费了一次查询。
**Bug 11后端 GetMessages 内存 fallback 的 beforeID 语义不一致**
PostgreSQL 实现中 `beforeID` 是消息 ID 游标(`WHERE id < $2`),而内存 fallback 将其当作数组索引偏移量:
```go
if beforeID > 0 && int(beforeID) <= total {
allMessages = allMessages[:beforeID]
}
```
两种实现的语义完全不同,切换存储后端时分页行为会不一致。
---
### 三、修复方案
#### 方案核心思路
将前端会话管理从 localStorage 迁移到后端 API实现单一数据源。前端变为"薄客户端",会话 CRUD 和消息持久化全部走后端 `/api/conversations` 接口。
#### Phase 1前端对接后端 API解决 P0 Bug 1/2/3
**1.1 在 api.ts 中增加对话 API 封装**
```typescript
// 新增对话 API
export async function listConversations(token: string, page = 1, size = 20) { ... }
export async function createConversation(token: string, config?: SessionConfig) { ... }
export async function getConversationMessages(token: string, id: string) { ... }
export async function deleteConversation(token: string, id: string) { ... }
export async function renameConversation(token: string, id: string, title: string) { ... }
```
**1.2 重写 useSessionList Hook**
将所有 CRUD 操作从 localStorage 切换到后端 API
- `createSession``POST /api/conversations`
- `deleteSession``DELETE /api/conversations/:id`
- `renameSession``PATCH /api/conversations/:id`
- `selectSession``GET /api/conversations/:id/messages`
- 初始化时 → `GET /api/conversations` 加载列表
- 移除 `saveSessionMessages` / `loadSessionMessages` 等 localStorage 操作
-`activeSessionId` 持久化到 localStorage仅用于恢复选中状态
**1.3 初始化逻辑修复**
```typescript
useEffect(() => {
if (!initializedRef.current) {
initializedRef.current = true;
if (sessions.length === 0) {
createSession();
} else {
// 恢复上次选中的会话
const lastId = localStorage.getItem('camtalk:last_active_session');
if (lastId && sessions.find(s => s.id === lastId)) {
setActiveSessionId(lastId);
}
}
}
}, [sessions.length, createSession]);
```
#### Phase 2WebSocket 会话切换(解决 P0 Bug 2, P1 Bug 4
**2.1 WebSocket 连接增加 conversation_id 参数**
后端已支持 `conversation_id` 查询参数(`handler.go` L126-133前端需要在 `connect` 时传入当前会话 ID
```typescript
connect(token?: string, conversationId?: string): void {
const params = new URLSearchParams();
if (token) params.set('token', token);
if (conversationId) params.set('conversation_id', conversationId);
const url = `${WS_URL}?${params.toString()}`;
// ...
}
```
**2.2 切换会话时保持连接**
`handleSelectSession` 中,不再调用 `stopSession()`,而是:
1. 保存当前会话消息到后端(如果需要)
2. 断开当前 WebSocket
3. 用新会话 ID 重新连接
或者更优方案:在 WebSocket 协议中增加 `switch_session` 消息类型,允许在保持连接的情况下切换后端会话。
#### Phase 3清理前端冗余代码解决 P1 Bug 5/6/7, P2 Bug 8
**3.1 移除 historyRef**
删除 `useVisionSession` 中的 `historyRef` 及其所有 push 操作。前端不再维护独立的 LLM 上下文历史,完全依赖后端。
**3.2 消息列表使用稳定 key**
`ChatMessage` 类型增加 `id` 字段UUID在创建消息时生成用作文本 diff 和 React key。
```typescript
export interface ChatMessage {
id: string; // 新增
role: "user" | "assistant" | "system";
content: string;
// ...
}
```
#### Phase 4后端修复解决 P2 Bug 9/10/11
**4.1 传递 tokensUsed 到持久化层**
修改 `AppendMessage` 接口,增加 `tokensUsed` 参数;或在 `EinoOrchestrator.ProcessQuery` 中,在 `llm_done` 后单独调用一次 `UpdateMessageMeta` 更新 token 信息。
**4.2 移除 WS Handler 中多余的 GetHistory 调用**
删除 `handler.go` L240 的 `history` 获取,同时从 `ProcessQuery` 签名中移除 `history` 参数。
**4.3 统一 GetMessages beforeID 语义**
内存 fallback 中改为基于消息序号的偏移量,或直接移除内存 fallback生产环境始终使用 PG
---
### 四、实施优先级
| 优先级 | 修复项 | 预估工作量 |
|--------|--------|-----------|
| P0 | 前端对接后端 API + 初始化修复 | 2-3 天 |
| P0 | WebSocket 会话切换 | 1-2 天 |
| P1 | 清理 historyRef 死代码 | 0.5 天 |
| P2 | React key + tokensUsed + GetMessages | 1 天 |
总计约 5-7 天可完成全部修复。Phase 1 是核心,完成后对话历史功能即可正常工作。