你用过 ChatGPT 或 Claude 的聊天界面吗?当你觉得 AI 的回答不够好,可以点"重新生成";当你觉得自己的问题没问对,可以编辑之前发过的消息;当你生成了好几个版本的回答,可以用小箭头在它们之间来回切换。
这篇教程会教你怎么从零实现这三个功能。不需要任何框架,原生 JavaScript + Node.js 就够了。
想象你在和 AI 聊天,对话像一条时间线:
你:今天天气怎么样?
AI:今天晴天,适合出门。
你:推荐个餐厅吧
AI:推荐xx火锅店...现在,三个功能分别对应三种"时间旅行":
你觉得 AI 最后那条回答不好,想让它重新答一次。就像让 AI"再想想"。但旧的回答不会消失,而是被保存起来,你随时可以切回去看。
类比:你让画师画了一幅肖像,不太满意,让他再画一张。两张都留着,你可以来回对比选喜欢的。
你觉得自己的问题没问好,想改一下重新问。比如把"推荐个餐厅"改成"推荐个日料餐厅"。改完之后,AI 会根据新问题重新回答,而旧的那条对话线(你的旧问题 + AI 的旧回答)会被保存成一个"分支"。
类比:你走到一个岔路口,第一次往左走了,现在你回到岔路口,改走右边。但左边的路并没有消失,你随时可以回去。
上面两个功能都会产生"多个版本"。分支导航就是让你在这些版本之间来回切换的 UI——一对小箭头 ‹ › 加一个计数器 2/3,告诉你现在看的是第几个版本、一共有几个。
一条普通消息长这样:
{
id: "a_1718350000000", // 唯一 ID,a_ 开头表示用户,c_ 开头表示 AI
role: "user", // "user" 或 "assistant"
content: "今天天气怎么样", // 消息内容
ts: 1718350000000 // 时间戳(毫秒)
}当 AI 的回答被重新生成过之后,这条消息上会多出两个字段:
{
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 里。
当用户编辑了消息之后,被编辑的那条消息上会多一个字段:
{
id: "a_1718350000000",
role: "user",
content: "推荐个日料餐厅", // 编辑后的内容
edited: true, // 标记:这条消息被编辑过
// ↓ 新增:编辑前的旧对话分支
edit_branches: [
{
id: "branch_1718350002000", // 分支的存储 ID
original_content: "推荐个餐厅", // 编辑前写的什么
tail_count: 3, // 旧分支里还有几条后续消息
ts: 1718350002000
}
]
}| 重新生成产生的分支 | 编辑消息产生的分支 | |
|---|---|---|
| 存在哪里 | 直接存在消息对象的 branches 数组里 | 存在后端数据库/KV 里,消息上只存引用 |
| 包含什么 | 只有 AI 那一条回答的不同版本 | 整段对话(编辑点之后的所有消息) |
| 切换方式 | 纯前端,不需要请求后端 | 需要调后端接口,因为要替换整段对话 |
| 为什么这样设计 | 只是一条消息的不同版本,数据量小,放内存就行 | 是整条对话线的分叉,数据量大,必须持久化 |
用户点击 ↻ 按钮
↓
前端发请求:POST /api/chat/send { retry: true }
↓
后端:把最后一条 AI 消息暂时移除 → 调用 AI 接口重新生成 → 把新旧回答合并成 branches
↓
前端:收到新回答,更新本地的 branches 数组,刷新页面只有最后一条 AI 消息才需要显示 ↻ 按钮,因为重新生成只对最新的回答有意义。
渲染消息的时候,判断一下:
// 找到最后一条 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>';
}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 里,否则原始回答就丢了。
// 收到 { 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 接口调用失败,旧消息会被恢复。用户永远不会因为一次失败的重新生成而丢失之前的回答。
这个功能比重新生成复杂一些,因为它涉及到"时间旅行"——回到过去的某个点,改变历史,然后让后面的事情重新发生。
用户点击 ✎ 按钮 → 消息变成可编辑的文本框
↓
用户修改内容,点"保存"
↓
前端发请求①:POST /api/chat/edit { msg_id, content }
后端:把旧消息 + 后续所有对话 保存成分支 → 截断对话到编辑点
↓
前端发请求②:POST /api/chat/send { edit_regen: true }
后端:基于编辑后的对话历史,调用 AI 生成新回答
↓
前端:显示新回答,完成在每条用户消息上放一个 ✎ 按钮。点击后,把消息内容替换成一个 <textarea>,加上"保存"和"取消"两个按钮。
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 根据修改后的对话重新回答。
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 条消息,构成了一个"旧分支"。这整段旧对话要被完整保存,以便将来你想切回去的时候能恢复。
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 页。信封里的旧页面随时可以拿出来看。
当一条 AI 消息有 branches 数组且长度大于 1 时,在消息下方显示导航:
‹ 2/3 › ↻渲染逻辑:
// 渲染消息时检查是否有分支
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>
`;
}切换逻辑非常简单,因为所有数据都已经在前端了:
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 数组里找。否则切换一次之后就找不到这条消息了。
编辑产生的分支比较重量级——它不只是一条消息的不同版本,而是整段对话的不同走向。切换的时候,编辑点之后的所有消息都要被替换,所以必须走后端。
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(); // 重新加载整个对话
}
}后端在切换时做了一件聪明的事:把当前的对话也存成分支,然后再恢复目标分支。这样切换永远是可逆的。
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 本,它还在书架上。
分支导航栏的样式需要低调、不抢眼,但交互要清晰:
/* 分支导航容器 */
.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;
}这是最重要的原则。不管是重新生成还是编辑,旧的内容永远会被保存:
branches 数组里chat:branch:{id} 里如果 AI 接口调用失败了,后端会把之前移除的旧消息恢复回去。用户不会看到一个空白的对话。
| 数据 | 存在哪里 | 保留多久 | 为什么 |
|---|---|---|---|
| 主对话 | chat:messages | 30 天 | 日常使用 |
| 对话分支 | chat:branch:{id} | 365 天 | 可能很久以后才想切回去看 |
| 功能 | 方法 | 路径 | 请求体 | 返回 |
|---|---|---|---|---|
| 重新生成 | 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: [...] } |