Content is user-generated and unverified.

从零实现 AI 聊天的消息编辑与对话分支

你用过 ChatGPT 或 Claude 的聊天界面吗?当你觉得 AI 的回答不够好,可以点"重新生成";当你觉得自己的问题没问对,可以编辑之前发过的消息;当你生成了好几个版本的回答,可以用小箭头在它们之间来回切换。

这篇教程会教你怎么从零实现这三个功能。不需要任何框架,原生 JavaScript + Node.js 就够了。


目录

  1. 先理解这三个功能在做什么
  2. 数据怎么存:消息的结构设计
  3. 功能一:重新生成 AI 回复
  4. 功能二:编辑用户消息并重新发送
  5. 功能三:在多个版本之间切换
  6. 样式参考
  7. 设计原则总结

1. 先理解这三个功能在做什么

想象你在和 AI 聊天,对话像一条时间线:

你:今天天气怎么样?
AI:今天晴天,适合出门。
你:推荐个餐厅吧
AI:推荐xx火锅店...

现在,三个功能分别对应三种"时间旅行":

重新生成(Regenerate)

你觉得 AI 最后那条回答不好,想让它重新答一次。就像让 AI"再想想"。但旧的回答不会消失,而是被保存起来,你随时可以切回去看。

类比:你让画师画了一幅肖像,不太满意,让他再画一张。两张都留着,你可以来回对比选喜欢的。

编辑消息(Edit & Resend)

你觉得自己的问题没问好,想改一下重新问。比如把"推荐个餐厅"改成"推荐个日料餐厅"。改完之后,AI 会根据新问题重新回答,而旧的那条对话线(你的旧问题 + AI 的旧回答)会被保存成一个"分支"。

类比:你走到一个岔路口,第一次往左走了,现在你回到岔路口,改走右边。但左边的路并没有消失,你随时可以回去。

对话分支导航(Branch Navigation)

上面两个功能都会产生"多个版本"。分支导航就是让你在这些版本之间来回切换的 UI——一对小箭头 ‹ › 加一个计数器 2/3,告诉你现在看的是第几个版本、一共有几个。


2. 数据怎么存:消息的结构设计

一条普通消息长这样:

javascript
{
  id: "a_1718350000000",    // 唯一 ID,a_ 开头表示用户,c_ 开头表示 AI
  role: "user",             // "user" 或 "assistant"
  content: "今天天气怎么样",  // 消息内容
  ts: 1718350000000          // 时间戳(毫秒)
}

当 AI 的回答被重新生成过之后,这条消息上会多出两个字段:

javascript
{
  id: "c_1718350001000",
  role: "assistant",
  content: "第二次生成的回答",    // 当前展示的内容
  ts: 1718350001000,

  // ↓ 新增:所有版本的回答
  branches: [
    { content: "第一次生成的回答", id: "c_1718350000500", ts: 1718350000500 },
    { content: "第二次生成的回答", id: "c_1718350001000", ts: 1718350001000 }
  ],
  branch_idx: 1    // 当前展示的是第几个版本(从 0 开始)
}

核心思路content 字段永远是"当前正在展示的版本",branches 数组保存所有历史版本,branch_idx 记住你正在看哪一个。切换版本的时候,只需要改 branch_idx,然后把对应的内容复制到 content 里。

当用户编辑了消息之后,被编辑的那条消息上会多一个字段:

javascript
{
  id: "a_1718350000000",
  role: "user",
  content: "推荐个日料餐厅",    // 编辑后的内容
  edited: true,                  // 标记:这条消息被编辑过

  // ↓ 新增:编辑前的旧对话分支
  edit_branches: [
    {
      id: "branch_1718350002000",        // 分支的存储 ID
      original_content: "推荐个餐厅",    // 编辑前写的什么
      tail_count: 3,                      // 旧分支里还有几条后续消息
      ts: 1718350002000
    }
  ]
}

两种分支的区别

重新生成产生的分支编辑消息产生的分支
存在哪里直接存在消息对象的 branches 数组里存在后端数据库/KV 里,消息上只存引用
包含什么只有 AI 那一条回答的不同版本整段对话(编辑点之后的所有消息)
切换方式纯前端,不需要请求后端需要调后端接口,因为要替换整段对话
为什么这样设计只是一条消息的不同版本,数据量小,放内存就行是整条对话线的分叉,数据量大,必须持久化

3. 功能一:重新生成 AI 回复

整体流程

用户点击 ↻ 按钮
    ↓
前端发请求:POST /api/chat/send { retry: true }
    ↓
后端:把最后一条 AI 消息暂时移除 → 调用 AI 接口重新生成 → 把新旧回答合并成 branches
    ↓
前端:收到新回答,更新本地的 branches 数组,刷新页面

第一步:在 AI 消息上显示重新生成按钮

只有最后一条 AI 消息才需要显示 ↻ 按钮,因为重新生成只对最新的回答有意义。

渲染消息的时候,判断一下:

javascript
// 找到最后一条 AI 消息
function findLastAI() {
  for (let i = history.length - 1; i >= 0; i--) {
    if (history[i].role === 'assistant') return history[i];
  }
  return null;
}

// 渲染时,如果这条消息是最后一条 AI 消息,就加上 ↻ 按钮
const isLastAI = (msg === findLastAI());
if (isLastAI) {
  footer.innerHTML += '<span class="regen-btn" onclick="doRetry()">↻</span>';
}

第二步:前端发起重新生成请求

javascript
async function doRetry() {
  if (sending) return;  // 防止重复点击
  sending = true;

  // 先显示一个"正在思考"的提示
  showTypingIndicator('重新生成中...');

  try {
    const res = await fetch('/api/chat/send', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ retry: true })
    });
    const data = await res.json();

    if (data.reply) {
      const lastAI = findLastAI();

      // 关键逻辑:第一次重新生成时,要把原来的回答也存进 branches
      if (!lastAI.branches) {
        lastAI.branches = [{
          content: lastAI.content,
          id: lastAI.id,
          ts: lastAI.ts
        }];
      }

      // 把新的回答追加到 branches
      lastAI.branches.push({
        content: data.reply,
        id: data.reply_id,
        ts: Date.now()
      });

      // 切换到最新版本
      lastAI.branch_idx = lastAI.branches.length - 1;
      lastAI.content = data.reply;
    }
  } catch (e) {
    console.error('重新生成失败:', e);
  }

  sending = false;
  renderMessages();  // 重新渲染页面
}

为什么第一次要特殊处理? 因为一条从未被重新生成过的消息是没有 branches 数组的。第一次点击重新生成时,需要把"当前这个回答"也放进 branches 里,否则原始回答就丢了。

第三步:后端处理重新生成

javascript
// 收到 { retry: true } 的请求
if (body.retry) {
  let messages = await loadMessages();

  // 1. 暂时保存并移除最后一条 AI 消息
  let oldAIMsg = null;
  if (messages.at(-1).role === 'assistant') {
    oldAIMsg = structuredClone(messages.at(-1));  // 深拷贝,防止引用问题
    messages.pop();
    await saveMessages(messages);
  }

  // 2. 用剩余的对话历史调用 AI 接口
  const aiResult = await callYourAI(messages);

  // 3. 把新旧回答合并成 branches,存回去
  if (aiResult.reply && oldAIMsg) {
    if (!oldAIMsg.branches) {
      oldAIMsg.branches = [{ content: oldAIMsg.content, id: oldAIMsg.id, ts: oldAIMsg.ts }];
    }
    oldAIMsg.branches.push({ content: aiResult.reply, id: 'c_' + Date.now(), ts: Date.now() });
    oldAIMsg.branch_idx = oldAIMsg.branches.length - 1;
    oldAIMsg.content = aiResult.reply;
    await appendMessage(oldAIMsg);  // 存回消息列表

    return res.json({ reply: aiResult.reply, reply_id: oldAIMsg.branches.at(-1).id });
  }

  // 4. 如果 AI 接口失败了,把旧消息恢复回去,不能让用户丢消息
  if (oldAIMsg) await appendMessage(oldAIMsg);
  return res.json({ reply: '' });
}

安全设计:如果 AI 接口调用失败,旧消息会被恢复。用户永远不会因为一次失败的重新生成而丢失之前的回答。


4. 功能二:编辑用户消息并重新发送

这个功能比重新生成复杂一些,因为它涉及到"时间旅行"——回到过去的某个点,改变历史,然后让后面的事情重新发生。

整体流程

用户点击 ✎ 按钮 → 消息变成可编辑的文本框
    ↓
用户修改内容,点"保存"
    ↓
前端发请求①:POST /api/chat/edit { msg_id, content }
  后端:把旧消息 + 后续所有对话 保存成分支 → 截断对话到编辑点
    ↓
前端发请求②:POST /api/chat/send { edit_regen: true }
  后端:基于编辑后的对话历史,调用 AI 生成新回答
    ↓
前端:显示新回答,完成

第一步:把消息变成可编辑状态

在每条用户消息上放一个 ✎ 按钮。点击后,把消息内容替换成一个 <textarea>,加上"保存"和"取消"两个按钮。

javascript
function editMessage(id) {
  const msg = history.find(m => m.id === id);
  const el = document.querySelector(`[data-mid="${id}"]`);

  // 用 textarea 替换原来的文字
  const textarea = document.createElement('textarea');
  textarea.value = msg.content;

  const saveBtn = document.createElement('button');
  saveBtn.textContent = '保存';
  saveBtn.onclick = () => saveEdit(id);

  const cancelBtn = document.createElement('button');
  cancelBtn.textContent = '取消';
  cancelBtn.onclick = () => renderMessages();  // 取消就重新渲染,恢复原样

  el.textContent = '';
  el.append(textarea, saveBtn, cancelBtn);
  textarea.focus();
}

第二步:保存编辑并请求新回答

保存编辑分两步走:先告诉后端"我改了这条消息",再请求 AI 根据修改后的对话重新回答。

javascript
async function saveEdit(id) {
  const textarea = document.querySelector(`[data-mid="${id}"] textarea`);
  const newContent = textarea.value.trim();
  if (!newContent) return;

  // ① 发送编辑请求(后端会保存旧分支 + 截断对话)
  const editRes = await fetch('/api/chat/edit', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ msg_id: id, content: newContent })
  });
  const editData = await editRes.json();
  if (!editData.ok) return;

  // 更新本地状态:修改消息内容,删掉后面所有消息
  const idx = history.findIndex(m => m.id === id);
  history[idx].content = newContent;
  history[idx].edited = true;
  history.splice(idx + 1);  // 删掉编辑点之后的所有消息

  // 显示"正在思考"
  showTypingIndicator();

  // ② 请求新回答
  const sendRes = await fetch('/api/chat/send', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ edit_regen: true })
  });
  const sendData = await sendRes.json();

  // 把新回答加到对话里
  removeTypingIndicator();
  if (sendData.reply) {
    history.push({
      role: 'assistant',
      content: sendData.reply,
      id: sendData.reply_id,
      ts: Date.now()
    });
  }
  renderMessages();
}

第三步:后端处理编辑请求

后端收到编辑请求后,要做一件很重要的事:在修改之前,先把旧的对话保存下来

想象一下:你的对话有 10 条消息,你编辑了第 3 条。那么第 3 条(旧版本)+ 第 4~10 条消息,构成了一个"旧分支"。这整段旧对话要被完整保存,以便将来你想切回去的时候能恢复。

javascript
app.post('/api/chat/edit', async (req, res) => {
  const { msg_id, content } = req.body;

  let messages = await loadMessages();
  const idx = messages.findIndex(m => m.id === msg_id);
  if (idx < 0) return res.status(404).json({ error: '消息不存在' });

  // ── 保存旧分支 ──
  // 从编辑点开始的所有消息(包括被编辑的这条)都要保存
  const oldContent = messages[idx].content;
  const tail = messages.slice(idx);

  const branchId = 'branch_' + Date.now();
  await database.save('chat:branch:' + branchId, JSON.stringify(tail));

  // 在消息上记录这个分支的引用
  if (!messages[idx].edit_branches) messages[idx].edit_branches = [];
  messages[idx].edit_branches.push({
    id: branchId,
    original_content: oldContent,        // 记住编辑前写的什么,方便展示
    tail_count: tail.length - 1,          // 后续有几条消息
    ts: Date.now()
  });

  // ── 执行编辑 ──
  messages[idx].content = content;
  messages[idx].edited = true;

  // 截断:只保留到编辑点,后面的消息全部移除(已经存到分支里了)
  const truncated = messages.slice(0, idx + 1);
  await saveMessages(truncated);

  res.json({ ok: true });
});

然后后端收到 { edit_regen: true } 的请求时,就基于截断后的对话历史调用 AI 接口,把新回答追加上去。

类比:你在日记本上写到第 5 页,觉得第 3 页写错了。你把第 3~5 页撕下来夹在信封里保存好(这就是分支),然后在第 3 页上重新写,继续往后写新的第 4、5 页。信封里的旧页面随时可以拿出来看。


5. 功能三:在多个版本之间切换

重新生成产生的版本切换(纯前端)

当一条 AI 消息有 branches 数组且长度大于 1 时,在消息下方显示导航:

  ‹  2/3  ›  ↻

渲染逻辑:

javascript
// 渲染消息时检查是否有分支
const hasBranches = msg.branches && msg.branches.length > 1;

if (hasBranches) {
  const total = msg.branches.length;
  const current = (msg.branch_idx ?? 0) + 1;  // 展示给用户的从 1 开始

  branchHTML = `
    <span class="branch-nav" onclick="switchBranch('${msg.id}', -1)">‹</span>
    <span class="branch-count">${current}/${total}</span>
    <span class="branch-nav" onclick="switchBranch('${msg.id}', 1)">›</span>
  `;
}

切换逻辑非常简单,因为所有数据都已经在前端了:

javascript
function switchBranch(messageId, direction) {
  // 找到这条消息(注意:切换过版本后 id 会变,所以也要在 branches 里找)
  const msg = history.find(m =>
    m.id === messageId ||
    (m.branches && m.branches.some(b => b.id === messageId))
  );

  if (!msg || !msg.branches || msg.branches.length < 2) return;

  // 计算新的索引
  const newIdx = (msg.branch_idx || 0) + direction;
  if (newIdx < 0 || newIdx >= msg.branches.length) return;  // 到边界了就不动

  // 把选中版本的内容复制到消息上
  msg.branch_idx = newIdx;
  msg.content = msg.branches[newIdx].content;
  msg.id = msg.branches[newIdx].id;
  msg.ts = msg.branches[newIdx].ts;

  renderMessages();
}

这里有个细节:切换版本的时候,msg.id 也会变。所以查找消息的时候,不能只用 msg.id 去匹配,还要在 branches 数组里找。否则切换一次之后就找不到这条消息了。

编辑产生的分支切换(需要后端)

编辑产生的分支比较重量级——它不只是一条消息的不同版本,而是整段对话的不同走向。切换的时候,编辑点之后的所有消息都要被替换,所以必须走后端。

javascript
async function switchEditBranch(forkMessageId, branchId) {
  const res = await fetch('/api/chat/branch/switch', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ fork_id: forkMessageId, branch_id: branchId })
  });
  const data = await res.json();
  if (data.ok) {
    await loadMessages();  // 重新加载整个对话
  }
}

后端在切换时做了一件聪明的事:把当前的对话也存成分支,然后再恢复目标分支。这样切换永远是可逆的。

javascript
app.post('/api/chat/branch/switch', async (req, res) => {
  const { fork_id, branch_id } = req.body;

  let messages = await loadMessages();
  const forkIdx = messages.findIndex(m => m.id === fork_id);

  // 加载目标分支
  const targetTail = JSON.parse(await database.get('chat:branch:' + branch_id));

  // 先把当前对话存成新分支(这样可以切回来)
  const currentTail = messages.slice(forkIdx);
  const savedId = 'branch_' + Date.now();
  await database.save('chat:branch:' + savedId, JSON.stringify(currentTail));

  // 用目标分支替换当前对话
  const restored = messages.slice(0, forkIdx).concat(targetTail);

  // 更新分支引用列表:移除刚恢复的,加入刚保存的
  const forkMsg = restored[forkIdx];
  forkMsg.edit_branches = (messages[forkIdx].edit_branches || [])
    .filter(b => b.id !== branch_id)  // 这个分支已经恢复了,从列表里去掉
    .concat([{                         // 把刚才的当前对话加进去
      id: savedId,
      original_content: currentTail[0]?.content?.slice(0, 80),
      tail_count: currentTail.length - 1,
      ts: Date.now()
    }]);

  await saveMessages(restored);
  res.json({ ok: true });
});

类比:你有两本日记,A 本和 B 本,从第 3 页开始内容不一样。你现在在看 A 本,想切换到 B 本。系统会:把 A 本放回书架(存成分支),把 B 本拿下来打开到第 3 页(恢复分支)。下次你想切回 A 本,它还在书架上。


6. 样式参考

分支导航栏的样式需要低调、不抢眼,但交互要清晰:

css
/* 分支导航容器 */
.branch-bar {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  user-select: none;     /* 防止误选中文字 */
}

/* ‹ › 箭头按钮 */
.branch-nav {
  cursor: pointer;
  padding: 2px 6px;
  font-size: 14px;
  opacity: 0.4;          /* 默认很淡,不抢注意力 */
  border-radius: 4px;
}

.branch-nav:hover {
  opacity: 0.7;
}

.branch-nav:active {
  opacity: 0.9;
  background: rgba(0, 0, 0, 0.06);
}

/* 2/3 计数器 */
.branch-count {
  font-family: monospace;
  font-size: 10px;
  opacity: 0.4;
  min-width: 22px;
  text-align: center;
}

/* ↻ 重新生成按钮 */
.branch-regen {
  cursor: pointer;
  padding: 4px 8px;
  font-size: 18px;
  opacity: 0.4;
  border-radius: 6px;
}

.branch-regen:hover {
  opacity: 0.7;
}

/* 编辑状态 */
.edit-area {
  width: 100%;
  min-height: 60px;
  max-height: 200px;
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 8px;
  font-size: 14px;
  resize: vertical;
}

.edit-btns {
  display: flex;
  gap: 8px;
  margin-top: 6px;
}

.edit-btns button {
  padding: 4px 12px;
  border-radius: 6px;
  border: 1px solid #ddd;
  cursor: pointer;
}

7. 设计原则总结

永远不丢数据

这是最重要的原则。不管是重新生成还是编辑,旧的内容永远会被保存:

  • 重新生成:旧回答存在 branches 数组里
  • 编辑消息:旧对话存在数据库的 chat:branch:{id}
  • 切换分支:当前对话在被替换之前,也会先存成新分支

失败时恢复原状

如果 AI 接口调用失败了,后端会把之前移除的旧消息恢复回去。用户不会看到一个空白的对话。

轻重分离

  • "一条消息的不同版本"(重新生成)→ 数据量小 → 直接存在消息对象上,前端切换
  • "一整段对话的分叉"(编辑消息)→ 数据量大 → 存在后端数据库里,后端切换

存储策略

数据存在哪里保留多久为什么
主对话chat:messages30 天日常使用
对话分支chat:branch:{id}365 天可能很久以后才想切回去看

API 速查表

功能方法路径请求体返回
重新生成POST/api/chat/send{ retry: true }{ reply, reply_id }
编辑消息POST/api/chat/edit{ msg_id, content }{ ok: true }
编辑后重新生成POST/api/chat/send{ edit_regen: true }{ reply, reply_id }
切换编辑分支POST/api/chat/branch/switch{ fork_id, branch_id }{ ok: true }
查看某个分支GET/api/chat/branch/:id-{ messages: [...] }
Content is user-generated and unverified.
    AI Chat Message Editing & Branching: Complete Implementation Guide | Claude