AI聊天多分支对话的纯前端实现
AI聊天多分支对话的纯前端实现
背景与需求分析
多分支对话是现代AI聊天应用的核心功能之一,在ChatGPT、DeepSeek等主流平台中都有实现。这个功能允许用户:
- 重新生成:对AI回答不满意时,基于相同上下文重新生成回复
- 重新撰写:编辑历史消息内容,AI基于新内容重新回复
- 分支切换:在同一问题的不同回答版本间自由切换
- 上下文保持:切换分支时保持完整的对话历史
由于相关技术资料较少,本文记录了纯前端实现该功能的完整方案。
核心设计思路
为什么选择树形结构?
多分支对话本质上就是一个树形数据结构:
- 每条消息可能有多个回复(重新生成的不同版本)
- 这些回复之间是平行的兄弟关系
- 每个分支都可以独立发展,形成更深的对话
欢迎消息 (根节点)│"推荐一本书" (用户)│┌──────┼──────┐
推荐《三体》 推荐《1984》 推荐《百年孤独》 (AI的多个回答)│ │ │"不错" "很好" "还行" (用户的后续回复)│ │ │[继续] [继续] [继续]
功能映射到数据操作
- 重新生成 = 为同一父节点创建新的子节点
- 分支切换 = 在兄弟节点之间跳转
- 版本指示 = 显示当前节点在兄弟中的位置
- 历史保持 = 沿着树路径构建对话上下文
数据结构设计
树节点结构
export interface MessageNode {id: stringcontent: stringrole: MessageRoletimestamp: Datereasoning_content?: string // DeepSeek R1模式的思考过程parentId: string | nullchildren: MessageNode[]depth: number
}
扁平存储结构
为了优化深度树的查询性能,采用扁平Map存储:
export interface FlatMessage {id: stringcontent: stringrole: MessageRoletimestamp: Datereasoning_content?: stringparentId: 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) breakhistory.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. 获取线性对话历史并调用AIconst 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 + 1if (newIndex < 0 || newIndex >= siblings.length) return nullconst 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) returnif (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) breakpath.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: numberif (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 nullsiblings = 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) breakhistory.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] // 初始激活路径只包含欢迎消息}
}