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] // 初始激活路径只包含欢迎消息
}
}