重来、分叉、改历史
网页版 ChatGPT 的历史是一条线:不满意只能重新生成,旧回复就没了;模型说错的一句会被一直回放,错下去;想试另一个走向,得把后面的全删掉。酒馆把一条消息做成可以装多个候选的记录,把聊天存成一个首行是文件头的 JSONL 文件,分支就是复制前缀、带上文件头另存一份。下面的效果来自扮演主线的真实聊天;分支用的是 bookmarks.js 里原样的 createBranch。
swipe:同一位置留多个候选
问题:重新生成会覆盖掉上一版;几版各有可取之处,想留着挑。
功能:AI 的回复是一条带 swipes 数组的消息,swipe_id 指向当前选中的那个,mes 只是它的副本。往右滑到头,就在同一位置生成一个新候选:
if (type === 'swipe') {
oldMessage = lastMessage.mes;
lastMessage.swipes.length++;
if (lastMessage.swipe_id === lastMessage.swipes.length - 1) {
lastMessage.title = title;
lastMessage.mes = getMessage;
切换候选时,把选中那个的正文和附加数据复制回 mes:
targetMessage.mes = targetMessage.swipes[targetSwipeId];
targetMessage.send_date = targetSwipeInfo?.send_date;
targetMessage.gen_started = targetSwipeInfo?.gen_started;
targetMessage.gen_finished = targetSwipeInfo?.gen_finished;
targetMessage.extra = structuredClone(targetSwipeInfo?.extra) ?? {};
拼 prompt 只读 mes。生成新候选时,当前这条整个不进 prompt,新候选是在去掉旧回复的上下文上生成的:
let coreChat = chat.filter(x => !x.is_system || (canUseTools && Array.isArray(x.extra?.tool_invocations)));
if (type === 'swipe') {
coreChat.pop();
}
效果:主线第二轮的回复 swipe 了一次:
第 4 条消息:swipe_id = 1
swipes[0] = *塞拉菲娜眉头微微蹙起,语气温和却带着一丝担忧。* “你的伤不轻,至少要静养两三……
swipes[1] = *塞拉菲娜眉头一皱,握着你的手不觉紧了些。* “北边的镇子?” *她缓缓摇头,眼……
mes = *塞拉菲娜眉头一皱,握着你的手不觉紧了些。* “北边的镇子?” *她缓缓摇头,眼……
下一轮(r3)发出的 prompt 里:swipes[0] 不在,swipes[1] 在
两个候选都留在文件里,只有选中的进了下一轮。
改一句,就是改下一轮的输入
问题:模型把眼睛颜色写错了一次,这句被一直回放,之后就一直错。
功能:任意一条消息都能编辑,包括 AI 的回复。改完同时写回当前候选,并给聊天打上「被动过」的标记:
const bias = substituteParams(extractMessageBias(text));
text = substituteParams(text);
if (bias) {
text = removeMacros(text);
}
mes.mes = text;
if (mes.swipe_id !== undefined) {
ensureSwipes(mes);
mes.swipes[mes.swipe_id] = text;
}
if (mes?.is_system || mes?.is_user || mes.extra?.type === system_message_types.NARRATOR) {
mes.extra.bias = bias ?? null;
} else {
mes.extra.bias = null;
}
chat_metadata.tainted = true;
效果:把第二条(塞拉菲娜的第一句回复)里的一句改成「……最危险的怪物,怕光。」,再拼下一轮:
mes = *塞拉菲娜在床边坐下,双手交叠放在膝上,神色变得严肃。* “这片森林里潜藏着许多危险。最常见的是影狼——它们成群游荡,眼……
swipes[swipe_id] 同步改了:True
chat_metadata.tainted = True
下一轮 prompt 里有「怕光」:False
隐藏:人看得见,模型看不见
问题:baseline 只有「删」和「留」。OOC 闲聊想留着自己看,但不想让角色出戏;前面的剧情已经摘要过了,原文还占着上下文。
功能:/hide 把消息的 is_system 置为真,界面上照常显示,拼 prompt 时被过滤掉(就是上面 coreChat 那一行的 filter):
export async function hideChatMessageRange(start, end, unhide, nameFitler = null) {
if (isNaN(start)) return;
if (!end) end = start;
const hide = !unhide;
for (let messageId = start; messageId <= end; messageId++) {
const message = chat[messageId];
if (!message) continue;
if (nameFitler && message.name !== nameFitler) continue;
message.is_system = hide;
// Also toggle "hidden" state for all visible messages
const messageBlock = $(`.mes[mesid="${messageId}"]`);
if (!messageBlock.length) continue;
messageBlock.attr('is_system', String(hide));
}
// Reload swipes. Useful when a last message is hidden.
refreshSwipeButtons();
await saveChatConditional();
}
效果:主线摘要之后,/hide 0-4 隐藏前五条,再拼下一轮:
隐藏前:聊天历史 11 条进 prompt
/hide 0-4 之后:6 条,第一条是「你一个人守在这里多久了?给我讲讲你是怎么成为森林守护者的吧。」
分支:复制前缀,带上文件头
问题:第 40 条做了关键选择,想试「当时如果选另一个」,又不想丢掉现在这条线。
功能:在某条消息上开分支:把这条及之前的消息复制出来,换上一个新的完整性标记、记下父聊天的名字,另存成一个新文件;父消息上记下分支名,两边用文件名互链。还可以指定某个候选,从当初没选的那个分出去:
const lastMes = chat[mesId];
const mainChatName = (getCurrentChatDetails()).sessionName;
// Mint a fresh integrity slug so the branch is distinguishable from its parent (#5942)
const newMetadata = { main_chat: mainChatName, integrity: uuidv4() };
const selectedSwipeId = swipeId === null ? null : Number(swipeId);
if (selectedSwipeId !== null && (!Number.isInteger(selectedSwipeId) || selectedSwipeId < 0 || selectedSwipeId >= (lastMes?.swipes?.length ?? 0))) {
toastr.warning('Invalid swipe ID.', 'Branch creation failed');
return;
}
function buildBranchName(name, i) {
// Strip off existing suffixes, then build new name
let cleanName = name.replace(/ - Branch #\d+$/, '');
// Strip off legacy old name prefix too
cleanName = cleanName.replace(/^Branch #\d+ - /, '');
return `${cleanName} - Branch #${i}`;
}
const existingChats = await getExistingChatNames();
const name = getUniqueName(mainChatName, (x) => existingChats.includes(x), { nameBuilder: buildBranchName, startIndex: 1 });
if (!name) {
console.error('Could not generate a unique branch name.');
toastr.error('Could not generate a unique branch name.', 'Branch creation failed');
return;
}
const branchChatSnapshot = getBranchChatSnapshot(mesId, { swipeId: selectedSwipeId });
if (!branchChatSnapshot) {
toastr.warning('Could not prepare the selected swipe for branching.', 'Branch creation failed');
return;
}
if (selected_group) {
await saveGroupBookmarkChat(selected_group, name, newMetadata, mesId, branchChatSnapshot);
} else {
await saveChat({ chatName: name, withMetadata: newMetadata, mesId, chatData: branchChatSnapshot });
}
// append to branches list if it exists
// otherwise create it
if (typeof lastMes.extra !== 'object') {
lastMes.extra = {};
}
if (typeof lastMes.extra.branches !== 'object') {
lastMes.extra.branches = [];
}
lastMes.extra.branches.push(name);
return name;
另存走的是普通的保存函数,新文件头 = 当前文件头加上这两个字段:
const metadata = { ...chat_metadata, ...(withMetadata || {}) };
const trimmedChat = Array.isArray(chatData)
? chatData
: (mesId !== undefined && mesId >= 0 && mesId < chat.length)
? chat.slice(0, Number(mesId) + 1)
: chat.slice();
/** @type {ChatHeader} */
const chatHeader = {
chat_metadata: metadata,
user_name: 'unused',
character_name: 'unused',
};
服务端把数组逐条序列化,一行一条,整文件覆盖写:
export async function trySaveChat(chatData, filePath, skipIntegrityCheck = false, handle, cardName, backupDirectory) {
const jsonlData = chatData?.map(m => JSON.stringify(m)).join('\n');
const doIntegrityCheck = (checkIntegrity && !skipIntegrityCheck);
const chatIntegritySlug = doIntegrityCheck ? chatData?.[0]?.chat_metadata?.integrity : undefined;
if (chatIntegritySlug && !await checkChatIntegrity(filePath, chatIntegritySlug)) {
throw new IntegrityMismatchError(`Chat integrity check failed for "${filePath}". The expected integrity slug was "${chatIntegritySlug}".`);
}
tryWriteFileSync(filePath, jsonlData);
getBackupFunction(handle, cardName)(backupDirectory, cardName, jsonlData);
}
效果:主线最后的聊天先存盘,再从第 4 条开分支,指定它当初被换掉的候选 swipes[0]。文件头里另放了一个本地变量「好感 35」(构造:相当于先执行过 /setvar),看分支会不会带走:
目录里:塞拉菲娜 - 实跑 - Branch #1.jsonl、塞拉菲娜 - 实跑.jsonl
父聊天 12 行,分支 6 行(第一行是文件头)
分支的文件头:
{
"chat_metadata": {
"note_prompt": "",
"note_interval": 1,
"note_position": 1,
"note_depth": 4,
"note_role": 0,
"tainted": true,
"timedWorldInfo": {
"sticky": {},
"cooldown": {}
},
"integrity": "57d90292-993b-4dea-a805-cd36f1ab4fe1",
"variables": {
"好感": 35
},
"main_chat": "塞拉菲娜 - 实跑"
},
"user_name": "unused",
"character_name": "unused"
}
分支第 4 条:*塞拉菲娜眉头微微蹙起,语气温和却带着一丝担忧。* “你的伤不轻,至少要静养两三天才能再赶路。” *她停顿片刻,琥珀色的……
父聊天第 4 条:*塞拉菲娜眉头一皱,握着你的手不觉紧了些。* “北边的镇子?” *她缓缓摇头,眼里掠过一丝忧虑。* “旅人,北边的路已经……
父聊天第 4 条的 extra.branches = ["塞拉菲娜 - 实跑 - Branch #1"]
变量、作者注释的设置、世界书计时全在文件头里,分支原样继承,从这一刻起两条线各记各的。会话没有树结构,分支之间只靠文件名互链:父聊天改了名,链接就断了。
状态跟着重来走
问题:有了跨轮的状态以后,重来一次,上一版留下的状态还在不在?
功能:世界书的定时效果按聊天条数计时;swipe、regenerate 时聊天没有变长,计时就作废(见世界书 · 定时效果)。{{pick}} 的种子取主聊天的名字,从这局开出的分支抽到的值一样(见角色卡 · 宏)。
效果:
第 1 条之后:{"sticky": {"演示·定时.0": {"hash": 155117607671687, "start": 1, "end": 3, "protected": false}}, "cooldown": {"演示·定时.0": {"hash": 155117607671687, "start": 1, "end": 4, "protected": false}}}
同样长度再扫一次(swipe / regenerate,聊天没有前进):
[WI] Processing sticky entry 演示·定时.0
[WI] Removing sticky entry 演示·定时.0 from timedWorldInfo: chat not advanced
[WI] Processing cooldown entry 演示·定时.0
[WI] Removing cooldown entry 演示·定时.0 from timedWorldInfo: chat not advanced
[WI] Timed effect "delay" applied to entry
[WI] Entry 1 suppressed by delay
[WI] Entry 1 suppressed by delay
[WI] Adding sticky entry 演示·定时.0: start=1, end=3, protected=false
[WI] Adding cooldown entry 演示·定时.0: start=1, end=4, protected=false
备份
问题:整文件覆盖写,加上编辑、删除都能永久改写历史,一刀删错没法撤销。
功能:每次保存后,服务端按「用户 + 聊天」节流(10 秒一次)写一份带时间戳的备份,每个聊天留 50 份;名字里有中文的,在文件名后加一段哈希,免得几个中文名的聊天挤在同一个配额里:
export function getBackupKey(name) {
const sanitized = sanitize(name).replace(/[^a-z0-9]/gi, '_').toLowerCase();
if (/[^\x20-\x7E]/.test(name)) {
const hash = crypto.createHash('sha256').update(name).digest('hex').slice(0, 8);
return `${sanitized}_${hash}`;
}
return sanitized;
}
/**
* Saves a chat to the backups directory.
* @param {string} directory The user's backup directory.
* @param {string} name The name of the chat.
* @param {string} data The serialized chat to save.
* @param {string} backupPrefix The file prefix. Typically CHAT_BACKUPS_PREFIX.
* @returns
*/
function backupChat(directory, name, data, backupPrefix = CHAT_BACKUPS_PREFIX) {
try {
if (!isBackupEnabled) { return; }
if (!fs.existsSync(directory)) {
console.error(`The chat couldn't be backed up because no directory exists at ${directory}!`);
}
name = getBackupKey(name);
const backupFile = path.join(directory, `${backupPrefix}${name}_${generateTimestamp()}.jsonl`);
tryWriteFileSync(backupFile, data);
removeOldBackups(directory, `${backupPrefix}${name}_`);
if (isNaN(maxTotalChatBackups) || maxTotalChatBackups < 0) {
return;
}
removeOldBackups(directory, backupPrefix, maxTotalChatBackups);
} catch (err) {
console.error(`Could not backup chat for ${name}`, err);
}
}
这一节只贴模版,实跑没有起服务端。
谁管什么
- 程序:候选怎么存、哪个进 prompt、隐藏的过滤、分支的复制和文件头继承、重来时作废计时、备份
- 用户:滑哪个候选、改哪句、藏哪些、在哪开分支
- 模型:只看到选中、没隐藏、改过之后的那条线
实跑原件
分支文件(第一行是文件头)
{"chat_metadata":{"note_prompt":"","note_interval":1,"note_position":1,"note_depth":4,"note_role":0,"tainted":true,"timedWorldInfo":{"sticky":{},"cooldown":{}},"integrity":"57d90292-993b-4dea-a805-cd36f1ab4fe1","variables":{"好感":35},"main_chat":"塞拉菲娜 - 实跑"},"user_name":"unused","character_name":"unused"}
{"name":"塞拉菲娜","is_user":false,"is_system":false,"send_date":"2026-10-20T11:40:06.152Z","mes":"*你猛地惊醒,想起自己是怎么走进森林深处、又是怎么被野兽围攻的。随着眼睛适应了屋里柔和的光,那些记忆渐渐淡去。* “啊,你终于醒了。我好担心,发现你的时候你浑身是血,昏迷不醒。” *她走过来,双手握住你的手,掌心传来温暖和安慰,唇边浮起一个温柔关切的笑。* “我叫塞拉菲娜,是这片森林的守护者——我已经尽力用魔法治好了你的伤。你感觉怎么样?希望这杯茶能帮你恢复些力气。” *她琥珀色的眼睛注视着你,满是同情和对你身体的担忧。* “求你了,好好休息。你在这里很安全。我会照顾你,可你得休息。我的魔法能治的也只有这么多。”","extra":{}}
{"name":"旅人","is_user":true,"is_system":false,"send_date":"2026-10-20T11:40:06.155Z","mes":"*我撑起身子,接过茶杯* 谢谢你……这片森林里到底有什么怪物?","extra":{"isSmallSys":false,"reasoning":""}}
{"extra":{"api":"deepseek","model":"deepseek-flash","reasoning":"(旅人刚醒来就问起森林里的怪物,看来那些可怕的经历还在困扰着他。我得小心回答,既不能轻描淡写让他放松警惕,也不能说得太可怕吓到他。毕竟他才刚醒过来,身体还很虚弱。)\n(作为这片森林的守护者,我有责任让他了解真相,但也要让他感到安全。先从最危险的影牙说起吧,这些家伙是最需要提防的。不过得用温和的语气,不能让他觉得害怕。我可以描述它们的形态,但更要强调我的守护力量能保护他。)\n(说到其他生物时,语气可以稍微轻松些,毕竟不是所有森林生物都是危险的。最后要记得安抚他,让他知道在这里是安全的。嗯,这样应该能既满足他的好奇心,又不会让他过度担忧。)","reasoning_duration":null,"reasoning_signature":null},"name":"塞拉菲娜","is_user":false,"send_date":"2026-10-20T11:40:08.850Z","mes":"*塞拉菲娜在床边坐下,双手交叠放在膝上,神色变得严肃。* “这片森林里潜藏着许多危险。最常见的是影狼——它们成群游荡,眼睛像炭火一样发红。” *她压低声音,仿佛怕被它们听见。* “还有暗影蛛,会在树上织出漆黑的网。最危险的是……” *她顿了顿,眼神变得凝重。* “是那些被影牙腐化的生灵。它们原本是森林里的鹿、狐、熊,如今却成了没有理智的怪物。” *她伸手轻轻覆上你的手背。* “但你不必担心。我的结界它们破不了。只要","title":"","gen_started":"2026-10-20T11:40:05.927Z","gen_finished":"2026-10-20T11:40:08.850Z","swipe_info":[{"send_date":"2026-10-20T11:40:08.850Z","gen_started":"2026-10-20T11:40:05.927Z","gen_finished":"2026-10-20T11:40:08.850Z","extra":{"api":"deepseek","model":"deepseek-flash","reasoning":"(旅人刚醒来就问起森林里的怪物,看来那些可怕的经历还在困扰着他。我得小心回答,既不能轻描淡写让他放松警惕,也不能说得太可怕吓到他。毕竟他才刚醒过来,身体还很虚弱。)\n(作为这片森林的守护者,我有责任让他了解真相,但也要让他感到安全。先从最危险的影牙说起吧,这些家伙是最需要提防的。不过得用温和的语气,不能让他觉得害怕。我可以描述它们的形态,但更要强调我的守护力量能保护他。)\n(说到其他生物时,语气可以稍微轻松些,毕竟不是所有森林生物都是危险的。最后要记得安抚他,让他知道在这里是安全的。嗯,这样应该能既满足他的好奇心,又不会让他过度担忧。)","reasoning_duration":null,"reasoning_signature":null}}],"swipe_id":0,"swipes":["*塞拉菲娜在床边坐下,双手交叠放在膝上,神色变得严肃。* “这片森林里潜藏着许多危险。最常见的是影狼——它们成群游荡,眼睛像炭火一样发红。” *她压低声音,仿佛怕被它们听见。* “还有暗影蛛,会在树上织出漆黑的网。最危险的是……” *她顿了顿,眼神变得凝重。* “是那些被影牙腐化的生灵。它们原本是森林里的鹿、狐、熊,如今却成了没有理智的怪物。” *她伸手轻轻覆上你的手背。* “但你不必担心。我的结界它们破不了。只要"]}
{"name":"旅人","is_user":true,"is_system":false,"send_date":"2026-10-20T11:40:08.851Z","mes":"你的魔法真厉害。我的伤要多久才能好?我还得赶去北边的镇子。","extra":{"isSmallSys":false,"reasoning":""}}
{"extra":{"api":"deepseek","model":"deepseek-flash","reasoning":"(旅人提到要赶去北边的镇子,看来他伤还没好就急着办事。我得先告诉他伤势恢复需要多久,但更重要的是警告他北边的危险。作为森林守护者,我亲眼见过太多冒险者低估了影牙的威胁。)\n(我的语气要温和但坚定,既要让他明白伤势的严重性,又不能让恐惧压倒他。毕竟我是治愈者,得给他希望。但那个预感越来越强烈,北边森林确实不对劲,我必须把这份担忧说出来。)","reasoning_duration":null,"reasoning_signature":null},"name":"塞拉菲娜","is_user":false,"send_date":"2026-10-20T11:40:11.421Z","mes":"*塞拉菲娜眉头微微蹙起,语气温和却带着一丝担忧。* “你的伤不轻,至少要静养两三天才能再赶路。” *她停顿片刻,琥珀色的眼睛里掠过一丝犹豫,最终还是开口。* “不过……北边现在不太平。最近影牙的活动越来越频繁,越往北,黑暗的气息越浓。你要去镇子办的事,真的急到不能等吗?” *她轻轻捏了捏你的手。* “如果你非去不可,至少让我给你留些护身的东西。”","title":"","swipe_info":[{"send_date":"2026-10-20T11:40:11.421Z","gen_started":"2026-10-20T11:40:05.927Z","gen_finished":"2026-10-20T11:40:11.421Z","extra":{"api":"deepseek","model":"deepseek-flash","reasoning":"(旅人提到要赶去北边的镇子,看来他伤还没好就急着办事。我得先告诉他伤势恢复需要多久,但更重要的是警告他北边的危险。作为森林守护者,我亲眼见过太多冒险者低估了影牙的威胁。)\n(我的语气要温和但坚定,既要让他明白伤势的严重性,又不能让恐惧压倒他。毕竟我是治愈者,得给他希望。但那个预感越来越强烈,北边森林确实不对劲,我必须把这份担忧说出来。)","reasoning_duration":null,"reasoning_signature":null}},{"send_date":"2026-10-20T11:40:14.068Z","gen_started":"2026-10-20T11:40:05.927Z","gen_finished":"2026-10-20T11:40:14.068Z","extra":{"api":"deepseek","model":"deepseek-flash","reasoning":"(旅人刚醒来就问起伤愈时间和北上的行程,看来他急着赶路。提到北边的镇子时语气很自然,可能不知道那边现在有多危险。我得先让他明白去北边意味着什么,但也不能吓到他——毕竟他刚死里逃生。)\n(作为这片林地的守护者,我必须阻止他冒险。北边的路已经完全被黑暗侵蚀了,连我的结界都覆盖不到那么远的地方。得用温柔但坚定的方式告诉他实情,让他明白现在离开就是送死。不过看他这么着急,或许可以建议他多留几天,等伤好些再想别的办法。)","reasoning_duration":null,"reasoning_signature":null}}],"swipe_id":0,"swipes":["*塞拉菲娜眉头微微蹙起,语气温和却带着一丝担忧。* “你的伤不轻,至少要静养两三天才能再赶路。” *她停顿片刻,琥珀色的眼睛里掠过一丝犹豫,最终还是开口。* “不过……北边现在不太平。最近影牙的活动越来越频繁,越往北,黑暗的气息越浓。你要去镇子办的事,真的急到不能等吗?” *她轻轻捏了捏你的手。* “如果你非去不可,至少让我给你留些护身的东西。”","*塞拉菲娜眉头一皱,握着你的手不觉紧了些。* “北边的镇子?” *她缓缓摇头,眼里掠过一丝忧虑。* “旅人,北边的路已经被影牙的黑暗侵蚀了。那片地方现在比这里危险得多。” *她顿了顿,声音放轻。* “你的伤……按寻常,得七八天才能好透。以我的魔法,三四天或许能让你恢复行走的力气,可要是你急着赶路,伤只会更重。” *她凝视着你,琥珀色的眼睛里满是恳切。* “再多留几天,好吗?至少等你能站稳了再走。”"],"gen_started":"2026-10-20T11:40:05.927Z","gen_finished":"2026-10-20T11:40:11.421Z"}