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

10 KiB
Raw Blame History

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 丢失,消息无法自动保存

useSessionListactiveSessionId 初始值为 null,且不会从 localStorage 恢复:

const [activeSessionId, setActiveSessionId] = useState<string | null>(null);

初始化逻辑(App.tsx L122-129仅在 sessions.length === 0 时调用 createSession()。对于回访用户sessions 不为空),activeSessionId 保持 null

自动保存的 useEffectL136-140需要 activeSessionId 非 null

if (activeSessionId && messages.length > 0) {
  persistSession(activeSessionId, messages);
}

结果:回访用户如果不点击侧边栏选择会话,所有新消息不会被持久化,刷新页面即丢失。

P1 — 重要级别

Bug 4切换会话时强制断开 WebSocket用户体验差

handleSelectSessionhandleNewSession 都调用 stopSession(),而 stopSession 会断开 WebSocket 连接。每次切换会话都需要重新建立连接TCP 握手 + JWT 认证 + VAD 初始化),增加约 1-3 秒延迟。

正确做法应该是在切换会话时保持 WebSocket 连接,仅在后端切换 sessionID通过发送 conversation_id 参数重连,或者在协议中增加切换会话的消息类型)。

Bug 5前端 historyRef 是无效的死代码

useVisionSession 中的 historyRefL48被维护但从未被实际使用

const historyRef = useRef<Array<{ role: string; content: string }>>([]);

它被 pushllm_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

useObservationModeonChange 回调L82-107添加了用户消息但未 push 到 historyRef。同 Bug 6。

P2 — 一般级别

Bug 8ChatPanel 使用数组 index 作为 React key

{messages.map((msg, index) => (
  <div key={index} ...>

当消息列表动态变化时(如 STT 结果更新替换了占位消息),使用 index 作为 key 可能导致 React 无法正确 diff出现闪烁或渲染异常。应使用稳定唯一的 IDtimestamp 或生成 UUID

Bug 9后端 AppendMessage 中 tokensUsed 始终为 0

MemoryManager.AppendMessage 异步写 PG 时硬编码 tokensUsed 为 0

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,但 ProcessQueryadapter.go内部并未使用这个参数。Eino Graph 的 History 节点(nodes_history.go)会自己重新调用 sessionMgr.GetHistory。传入的 history 参数被浪费了一次查询。

Bug 11后端 GetMessages 内存 fallback 的 beforeID 语义不一致

PostgreSQL 实现中 beforeID 是消息 ID 游标(WHERE id < $2),而内存 fallback 将其当作数组索引偏移量:

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 封装

// 新增对话 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

  • createSessionPOST /api/conversations
  • deleteSessionDELETE /api/conversations/:id
  • renameSessionPATCH /api/conversations/:id
  • selectSessionGET /api/conversations/:id/messages
  • 初始化时 → GET /api/conversations 加载列表
  • 移除 saveSessionMessages / loadSessionMessages 等 localStorage 操作
  • activeSessionId 持久化到 localStorage仅用于恢复选中状态

1.3 初始化逻辑修复

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

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。

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 是核心,完成后对话历史功能即可正常工作。