## 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(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>([]); ``` 它被 push(`llm_done` 时 L258、`sendTextMessage` 时 L466、`interrupt` 时 L417),但从未被读取或发送到后端。前端的 LLM 上下文完全由后端 `session.Manager.GetHistory` 独立管理。这段代码增加了维护负担却没有任何功能价值。 **Bug 6:VAD 语音输入时用户消息未加入 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 8:ChatPanel 使用数组 index 作为 React key** ```tsx {messages.map((msg, 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 2:WebSocket 会话切换(解决 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 是核心,完成后对话历史功能即可正常工作。