AI聊天多分支对话的纯前端实现

发布于:2025-06-29 ⋅ 阅读:(14) ⋅ 点赞:(0)

AI聊天多分支对话的纯前端实现

背景与需求分析

多分支对话是现代AI聊天应用的核心功能之一,在ChatGPT、DeepSeek等主流平台中都有实现。这个功能允许用户:

  • 重新生成:对AI回答不满意时,基于相同上下文重新生成回复
  • 重新撰写:编辑历史消息内容,AI基于新内容重新回复
  • 分支切换:在同一问题的不同回答版本间自由切换
  • 上下文保持:切换分支时保持完整的对话历史

由于相关技术资料较少,本文记录了纯前端实现该功能的完整方案。

核心设计思路

为什么选择树形结构?

多分支对话本质上就是一个树形数据结构

  • 每条消息可能有多个回复(重新生成的不同版本)
  • 这些回复之间是平行的兄弟关系
  • 每个分支都可以独立发展,形成更深的对话
        欢迎消息 (根节点)
            │
       "推荐一本书" (用户)
            │
     ┌──────┼──────┐
推荐《三体》  推荐《1984》 推荐《百年孤独》 (AI的多个回答)
     │          │         │
  "不错"       "很好"     "还行" (用户的后续回复)
     │          │         │
   [继续]     [继续]     [继续]

功能映射到数据操作

  • 重新生成 = 为同一父节点创建新的子节点
  • 分支切换 = 在兄弟节点之间跳转
  • 版本指示 = 显示当前节点在兄弟中的位置
  • 历史保持 = 沿着树路径构建对话上下文

数据结构设计

树节点结构

export interface MessageNode {
  id: string
  content: string
  role: MessageRole
  timestamp: Date
  reasoning_content?: string  // DeepSeek R1模式的思考过程
  parentId: string | null
  children: MessageNode[]
  depth: number
}

扁平存储结构

为了优化深度树的查询性能,采用扁平Map存储:

export interface FlatMessage {
  id: string
  content: string
  role: MessageRole
  timestamp: Date
  reasoning_content?: string
  parentId: string | null  // 核心:通过parentId建立树形关系
}

完整对话树状态

export interface ConversationTree {
  flatMessages: Map<string, FlatMessage>  // O(1)查询性能
  rootNodes: MessageNode[]                // 构建的树形结构
  activePath: string[]                    // 当前激活路径 - 核心概念!
}

核心算法实现

1. 扁平结构转树形结构

export function buildTreeFromFlat(flatMessages: Map<string, FlatMessage>): MessageNode[] {
  const nodes = new Map<string, MessageNode>()
  const roots: MessageNode[] = []

  // 步骤1: 创建所有节点
  for (const [id, message] of flatMessages) {
    nodes.set(id, {
      ...message,
      children: [],
      depth: 0
    })
  }

  // 步骤2: 建立父子关系并计算深度
  for (const [id, node] of nodes) {
    if (node.parentId === null) {
      roots.push(node)
    } else {
      const parent = nodes.get(node.parentId)
      if (parent) {
        parent.children.push(node)
        node.depth = parent.depth + 1
      }
    }
  }

  // 步骤3: 按时间排序保证一致性
  const sortByTime = (a: MessageNode, b: MessageNode) => 
    a.timestamp.getTime() - b.timestamp.getTime()
  
  for (const node of nodes.values()) {
    node.children.sort(sortByTime)
  }

  return roots.sort(sortByTime)
}

2. 激活路径机制 - 解决线性API限制的关键

核心挑战:虽然前端维护树形结构,但AI API只能接收线性的对话历史。如何确保每个分支都有正确的上下文?

解决方案:引入activePath概念

// 示例:当前激活路径
activePath = ["welcome-id", "user-msg-001", "ai-reply-002", "user-msg-007"]

// 表示当前对话路径:欢迎消息 → 用户问题 → AI回复 → 用户追问

3. 构建线性对话历史

export function getConversationHistory(nodeId: string, flatMessages: Map<string, FlatMessage>): FlatMessage[] {
  const history: FlatMessage[] = []
  let currentId: string | null = nodeId

  // 从目标节点向根节点回溯
  while (currentId) {
    const message = flatMessages.get(currentId)
    if (!message) break
    
    history.unshift(message)  // 保持时间顺序
    currentId = message.parentId
  }

  return history
}

关键点:每次AI生成时,都会基于当前activePath构建线性的对话历史发送给API:

// 每次AI调用都执行
const conversationHistory = getConversationHistory(userMessage.id, newFlatMessages)
const finalMessage = await generateAIMessage(conversationHistory, ...)

核心功能实现

1. 发送新消息

async function sendMessage(content: string, parentNodeId?: string) {
  // 1. 确定父节点(默认为当前路径末尾)
  const actualParentId = parentNodeId || 
    (activePath.length > 0 ? activePath[activePath.length - 1] : null)

  // 2. 创建用户消息
  const userMessage = createFlatMessage(content, 'user', actualParentId)
  
  // 3. 更新树和激活路径
  const { newFlatMessages, newActivePath } = addMessageToTree(
    flatMessages, activePath, userMessage
  )
  
  // 4. 获取线性对话历史并调用AI
  const conversationHistory = getConversationHistory(userMessage.id, newFlatMessages)
  const aiReply = await callAI(conversationHistory)
  
  // 5. 添加AI回复到树中
  updateTree(newFlatMessages, newActivePath)
}

2. 分支切换导航

export function navigateBranch(
  nodeId: string, 
  direction: 'left' | 'right', 
  activePath: string[], 
  roots: MessageNode[]
): string[] | null {
  
  // 1. 找到目标兄弟节点
  const siblings = getSiblings(nodeId, roots)
  const currentIndex = siblings.findIndex(s => s.id === nodeId)
  const newIndex = direction === 'left' ? currentIndex - 1 : currentIndex + 1
  
  if (newIndex < 0 || newIndex >= siblings.length) return null
  
  const targetSibling = siblings[newIndex]
  
  // 2. 构建新的激活路径
  const nodeIndex = activePath.findIndex(id => id === nodeId)
  const pathBeforeNode = activePath.slice(0, nodeIndex)
  const newBasePath = [...pathBeforeNode, targetSibling.id]
  
  // 3. 在新分支中找到最深路径(显示完整对话)
  const deepestPath = findDeepestPath(targetSibling)
  
  return [...newBasePath, ...deepestPath]
}

3. 消息重新生成

async function regenerateMessage(nodeId: string) {
  const targetMessage = flatMessages.get(nodeId)
  if (!targetMessage) return

  if (targetMessage.role === 'assistant') {
    // AI消息重新生成:基于相同上下文重新生成
    const contextHistory = getConversationHistory(targetMessage.parentId!, flatMessages)
    const newReply = await callAI(contextHistory)
    
    // 作为兄弟节点添加
    addSiblingNode(newReply, targetMessage.parentId)
  } else {
    // 用户消息重新生成:基于用户消息生成新AI回复
    const contextHistory = getConversationHistory(nodeId, flatMessages)
    const newReply = await callAI(contextHistory)
    
    // 作为子节点添加
    addChildNode(newReply, nodeId)
  }
}

4. 消息编辑

async function editUserMessage(nodeId: string, newContent: string) {
  const originalMessage = flatMessages.get(nodeId)
  if (!originalMessage || originalMessage.role !== 'user') return

  // 1. 创建编辑后的新消息(作为兄弟节点)
  const editedMessage = createFlatMessage(newContent, 'user', originalMessage.parentId)
  
  // 2. 更新激活路径到编辑后的消息
  const targetIndex = activePath.indexOf(nodeId)
  const newActivePath = [...activePath.slice(0, targetIndex), editedMessage.id]
  
  // 3. 基于编辑后的消息重新生成AI回复
  updateTree(newFlatMessages, newActivePath)
  await generateAIReply(editedMessage)
}

5.完整关键代码如下

import type { 
  FlatMessage, 
  MessageNode, 
  ConversationTree, 
  BranchNavigation, 
  RegenerateContext 
} from './types'

// ===== 基础工具函数 =====

/**
 * 生成全局唯一的消息ID
 * 
 * 结合时间戳和随机字符串确保ID的唯一性
 * 时间戳确保按时间排序,随机部分避免冲突
 * 
 * @returns 唯一的消息ID字符串
 */
export function generateId(): string {
  return Date.now().toString() + Math.random().toString(36).substr(2, 9)
}

/**
 * 创建标准格式的扁平消息对象
 * 
 * 这是系统中所有消息创建的统一入口,确保数据格式的一致性
 * 
 * @param content - 消息文本内容
 * @param role - 消息发送者角色
 * @param parentId - 父消息ID,null表示根消息
 * @param reasoning_content - 可选的AI思考过程(仅R1模式)
 * @returns 完整的FlatMessage对象
 */
export function createFlatMessage(
  content: string, 
  role: FlatMessage['role'], 
  parentId: string | null,
  reasoning_content?: string
): FlatMessage {
  return {
    id: generateId(),
    content,
    role,
    timestamp: new Date(),
    parentId,
    reasoning_content
  }
}

// ===== 树构建与结构转换 =====

/**
 * 从扁平消息映射构建完整的树形结构
 * 
 * 这是系统中最重要的数据转换函数,将存储的扁平结构
 * 转换为UI渲染需要的树形结构
 * 
 * 算法步骤:
 * 1. 为每个扁平消息创建对应的树节点
 * 2. 根据parentId建立父子关系
 * 3. 计算每个节点的深度
 * 4. 对子节点按时间排序,确保分支的时间顺序
 * 
 * @param flatMessages - 扁平存储的消息映射
 * @returns 根节点数组,通常只包含一个欢迎消息
 */
export function buildTreeFromFlat(flatMessages: Map<string, FlatMessage>): MessageNode[] {
  const nodes = new Map<string, MessageNode>()
  const roots: MessageNode[] = []

  // 步骤1: 为每个扁平消息创建树节点
  for (const [id, message] of flatMessages) {
    nodes.set(id, {
      ...message,
      children: [],
      depth: 0  // 深度将在后续步骤中计算
    })
  }

  // 步骤2: 建立父子关系并计算深度
  for (const [id, node] of nodes) {
    if (node.parentId === null) {
      // 根节点:没有父节点
      roots.push(node)
    } else {
      // 子节点:添加到父节点的children数组
      const parent = nodes.get(node.parentId)
      if (parent) {
        parent.children.push(node)
        node.depth = parent.depth + 1  // 深度 = 父节点深度 + 1
      }
    }
  }

  // 步骤3: 排序 - 确保时间顺序的一致性
  const sortByTime = (a: MessageNode, b: MessageNode) => a.timestamp.getTime() - b.timestamp.getTime()
  
  // 对所有节点的子节点排序
  for (const node of nodes.values()) {
    node.children.sort(sortByTime)
  }

  // 对根节点排序
  return roots.sort(sortByTime)
}

/**
 * 从根节点构建节点ID到节点的映射
 * 
 * 为了避免在查找操作中重复遍历树,预先构建映射提供O(1)查找
 * 
 * @param roots - 树的根节点数组
 * @returns 节点ID到节点对象的映射
 */
export function buildNodeMap(roots: MessageNode[]): Map<string, MessageNode> {
  const nodeMap = new Map<string, MessageNode>()
  
  // 递归添加节点到映射
  function addToMap(node: MessageNode) {
    nodeMap.set(node.id, node)
    node.children.forEach(addToMap)
  }
  
  roots.forEach(addToMap)
  return nodeMap
}

/**
 * 在树中快速查找指定ID的节点
 * 
 * 使用预构建的节点映射实现O(1)查找性能
 * 
 * @param nodeId - 要查找的节点ID
 * @param roots - 树的根节点数组
 * @returns 找到的节点或null
 */
export function findNode(nodeId: string, roots: MessageNode[]): MessageNode | null {
  return buildNodeMap(roots).get(nodeId) || null
}

// ===== 路径管理和导航 =====

/**
 * 获取从根节点到指定节点的完整路径
 * 
 * 通过向上追溯parentId链构建路径,这种方法利用了扁平存储的优势
 * 避免了树形遍历,提供O(depth)的时间复杂度
 * 
 * @param nodeId - 目标节点ID
 * @param flatMessages - 扁平消息存储
 * @returns 从根到目标节点的ID路径数组
 */
export function getPathToNode(nodeId: string, flatMessages: Map<string, FlatMessage>): string[] {
  const path: string[] = []
  let currentId: string | null = nodeId

  // 从目标节点向上追溯到根节点
  while (currentId) {
    const message = flatMessages.get(currentId)
    if (!message) break
    
    path.unshift(currentId) // 插入到开头,构建正向路径(根 -> 叶子)
    currentId = message.parentId
  }

  return path
}

/**
 * 根据激活路径获取要在UI中渲染的节点序列
 * 
 * 激活路径定义了当前用户看到的对话线程
 * 这个函数将路径ID序列转换为实际的节点对象
 * 
 * @param activePath - 当前激活的消息ID路径
 * @param roots - 树的根节点数组
 * @returns 要渲染的节点对象数组
 */
export function getActiveNodesFromPath(activePath: string[], roots: MessageNode[]): MessageNode[] {
  if (activePath.length === 0) return []
  
  // 构建节点映射,避免重复搜索
  const nodeMap = buildNodeMap(roots)
  
  // 将ID路径转换为节点对象,过滤掉不存在的节点
  return activePath
    .map(nodeId => nodeMap.get(nodeId))
    .filter((node): node is MessageNode => node !== undefined)
}

// ===== 分支导航系统 =====

/**
 * 获取指定节点的分支导航信息
 * 
 * 分支导航允许用户在同级的不同回复之间切换
 * 这是实现对话分支功能的核心
 * 
 * @param nodeId - 目标节点ID
 * @param activePath - 当前激活路径(用于上下文)
 * @param roots - 树的根节点数组
 * @returns 分支导航信息对象
 */
export function getBranchNavigation(nodeId: string, activePath: string[], roots: MessageNode[]): BranchNavigation {
  const nodeMap = buildNodeMap(roots)
  const node = nodeMap.get(nodeId)
  
  // 节点不存在时返回默认值
  if (!node) {
    return { currentIndex: 0, totalBranches: 1, canNavigateLeft: false, canNavigateRight: false }
  }

  // 获取同级节点列表
  let siblings: MessageNode[]
  if (node.parentId === null) {
    // 根节点的同级节点就是所有根节点
    siblings = roots
  } else {
    // 非根节点的同级节点是父节点的所有子节点
    const parent = nodeMap.get(node.parentId)
    siblings = parent?.children || [node]
  }

  // 计算当前节点在同级中的位置
  const currentIndex = siblings.findIndex(sibling => sibling.id === nodeId)
  
  return {
    currentIndex,
    totalBranches: siblings.length,
    canNavigateLeft: currentIndex > 0,                     // 不是第一个
    canNavigateRight: currentIndex < siblings.length - 1   // 不是最后一个
  }
}

/**
 * 执行分支导航操作
 * 
 * 在同级分支之间切换,并智能地更新激活路径
 * 
 * 导航逻辑:
 * 1. 找到目标兄弟节点
 * 2. 更新激活路径到该分支
 * 3. 在新分支中找到最深的可用路径
 * 
 * @param nodeId - 当前节点ID
 * @param direction - 导航方向:'left'(上一个)或'right'(下一个)
 * @param activePath - 当前激活路径
 * @param roots - 树的根节点数组
 * @returns 新的激活路径,失败时返回null
 */
export function navigateBranch(
  nodeId: string, 
  direction: 'left' | 'right', 
  activePath: string[], 
  roots: MessageNode[]
): string[] | null {
  const nodeMap = buildNodeMap(roots)
  const node = nodeMap.get(nodeId)
  
  if (!node) return null

  // 检查是否可以进行导航
  const navigation = getBranchNavigation(nodeId, activePath, roots)
  
  let newIndex: number
  if (direction === 'left' && navigation.canNavigateLeft) {
    newIndex = navigation.currentIndex - 1
  } else if (direction === 'right' && navigation.canNavigateRight) {
    newIndex = navigation.currentIndex + 1
  } else {
    return null // 无法导航
  }

  // 获取目标兄弟节点
  let siblings: MessageNode[]
  if (node.parentId === null) {
    siblings = roots
  } else {
    const parent = nodeMap.get(node.parentId)
    if (!parent) return null
    siblings = parent.children
  }

  const targetSibling = siblings[newIndex]
  if (!targetSibling) return null

  // 构建新的激活路径
  const nodeIndex = activePath.findIndex(id => id === nodeId)
  if (nodeIndex === -1) return null

  // 分支切换逻辑:
  // 1. 保留到切换节点之前的路径
  // 2. 切换到目标兄弟节点
  // 3. 在新分支中找到最深的可用路径(显示完整的对话)
  const pathBeforeNode = activePath.slice(0, nodeIndex)
  const newBasePath = [...pathBeforeNode, targetSibling.id]
  
  // 在新分支中找到最深路径
  const deepestPath = findDeepestPath(targetSibling)
  
  return [...newBasePath, ...deepestPath]
}

/**
 * 在给定分支中找到最深的路径
 * 
 * 当切换到新分支时,我们希望显示该分支中最新最完整的对话
 * 这个函数沿着最新的子节点路径向下走到叶子节点
 * 
 * @param branchRoot - 分支的根节点
 * @returns 从该节点到最深叶子的路径(不包含根节点本身)
 */
function findDeepestPath(branchRoot: MessageNode): string[] {
  const deepPath: string[] = []
  let currentNode = branchRoot
  
  // 沿着每层最新的子节点向下走(子节点已按时间排序)
  while (currentNode.children.length > 0) {
    // 选择最新的子节点(时间戳最大的,在排序后是最后一个)
    const latestChild = currentNode.children[currentNode.children.length - 1]
    deepPath.push(latestChild.id)
    currentNode = latestChild
  }
  
  return deepPath
}

// ===== 对话历史构建 =====

/**
 * 获取到指定节点的完整对话历史
 * 
 * 这个函数构建发送给AI的上下文信息
 * 包含从根节点到目标节点的所有消息
 * 
 * @param nodeId - 目标节点ID
 * @param flatMessages - 扁平消息存储
 * @returns 有序的对话历史数组
 */
export function getConversationHistory(nodeId: string, flatMessages: Map<string, FlatMessage>): FlatMessage[] {
  const history: FlatMessage[] = []
  let currentId: string | null = nodeId

  // 从目标节点向上追溯到根节点
  while (currentId) {
    const message = flatMessages.get(currentId)
    if (!message) break
    
    history.unshift(message) // 插入到开头,保持时间顺序(早 -> 晚)
    currentId = message.parentId
  }

  return history
}

// ===== 消息编辑和重新生成支持 =====

/**
 * 获取重新生成消息所需的上下文信息
 * 
 * 为消息重新生成功能提供必要的上下文数据
 * 支持AI消息和用户消息的重新生成
 * 
 * @param nodeId - 要重新生成的节点ID
 * @param flatMessages - 扁平消息存储
 * @returns 重新生成上下文或null(如果节点不存在)
 */
export function getRegenerateContext(nodeId: string, flatMessages: Map<string, FlatMessage>): RegenerateContext | null {
  const targetMessage = flatMessages.get(nodeId)
  if (!targetMessage) return null

  // 对于AI消息,重新生成意味着要替换这条消息
  // 对于用户消息,重新生成意味着要基于这条消息生成新的AI回复
  const parentNodeId = targetMessage.role === 'assistant' 
    ? targetMessage.parentId || '' 
    : nodeId

  // 获取到父节点为止的对话历史
  const conversationHistory = getConversationHistory(parentNodeId, flatMessages)

  return {
    targetNodeId: nodeId,
    parentNodeId,
    conversationHistory
  }
}

/**
 * 向对话树添加新消息
 * 
 * 这是修改对话树的核心函数,确保数据一致性
 * 
 * @param flatMessages - 当前的扁平消息存储
 * @param activePath - 当前激活路径
 * @param newMessage - 要添加的新消息
 * @returns 更新后的存储和路径
 */
export function addMessageToTree(
  flatMessages: Map<string, FlatMessage>,
  activePath: string[],
  newMessage: FlatMessage
): { newFlatMessages: Map<string, FlatMessage>, newActivePath: string[] } {
  // 创建新的存储副本
  const newFlatMessages = new Map(flatMessages)
  newFlatMessages.set(newMessage.id, newMessage)

  // 更新激活路径:添加新消息到路径末尾
  const newActivePath = [...activePath, newMessage.id]

  return { newFlatMessages, newActivePath }
}

/**
 * 编辑用户消息并处理相关的树结构更新
 * 
 * 支持用户修改已发送的消息,创建新的对话分支
 * 
 * @param flatMessages - 当前的扁平消息存储
 * @param activePath - 当前激活路径
 * @param targetNodeId - 要编辑的消息节点ID
 * @param newContent - 新的消息内容
 * @returns 更新后的存储和路径,失败时返回null
 */
export function editUserMessage(
  flatMessages: Map<string, FlatMessage>,
  activePath: string[],
  targetNodeId: string,
  newContent: string
): { newFlatMessages: Map<string, FlatMessage>, newActivePath: string[] } | null {
  const originalMessage = flatMessages.get(targetNodeId)
  if (!originalMessage || originalMessage.role !== 'user') {
    return null // 只能编辑用户消息
  }

  // 创建编辑后的新消息
  const editedMessage = createFlatMessage(newContent, 'user', originalMessage.parentId)
  
  // 添加到对话树
  const newFlatMessages = new Map(flatMessages)
  newFlatMessages.set(editedMessage.id, editedMessage)

  // 更新激活路径:将编辑后的消息放在原消息的位置
  const targetIndex = activePath.indexOf(targetNodeId)
  const newActivePath = targetIndex >= 0 
    ? [...activePath.slice(0, targetIndex), editedMessage.id]
    : [...activePath, editedMessage.id]

  return { newFlatMessages, newActivePath }
}

/**
 * 创建初始的对话树状态
 * 
 * 初始化一个包含欢迎消息的空对话树
 * 
 * @param welcomeMessage - 可选的自定义欢迎消息
 * @returns 初始化的对话树
 */
export function createInitialConversationTree(welcomeMessage?: string): ConversationTree {
  const initialMessage = createFlatMessage(
    welcomeMessage || '你好!我是DeepSeek AI助手,有什么可以帮助您的吗?',
    'assistant',
    null  // 根消息没有父节点
  )

  const flatMessages = new Map<string, FlatMessage>()
  flatMessages.set(initialMessage.id, initialMessage)

  return {
    flatMessages,
    rootNodes: buildTreeFromFlat(flatMessages),
    activePath: [initialMessage.id]  // 初始激活路径只包含欢迎消息
  }
} 

网站公告

今日签到

点亮在社区的每一天
去签到