跳转至

重来、分叉、改历史

网页版 ChatGPT 的历史是一条线:不满意只能重新生成,旧回复就没了;模型说错的一句会被一直回放,错下去;想试另一个走向,得把后面的全删掉。酒馆把一条消息做成可以装多个候选的记录,把聊天存成一个首行是文件头的 JSONL 文件,分支就是复制前缀、带上文件头另存一份。下面的效果来自扮演主线的真实聊天;分支用的是 bookmarks.js 里原样的 createBranch。

swipe:同一位置留多个候选

问题:重新生成会覆盖掉上一版;几版各有可取之处,想留着挑。

功能:AI 的回复是一条带 swipes 数组的消息,swipe_id 指向当前选中的那个,mes 只是它的副本。往右滑到头,就在同一位置生成一个新候选:

模版 · public/script.js:6671-6676
    if (type === 'swipe') {
        oldMessage = lastMessage.mes;
        lastMessage.swipes.length++;
        if (lastMessage.swipe_id === lastMessage.swipes.length - 1) {
            lastMessage.title = title;
            lastMessage.mes = getMessage;

切换候选时,把选中那个的正文和附加数据复制回 mes:

模版 · public/script.js:7011-7015
    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,新候选是在去掉旧回复的上下文上生成的:

模版 · public/script.js:4496-4499
    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 的回复。改完同时写回当前候选,并给聊天打上「被动过」的标记:

模版 · public/script.js:8173-8190
    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):

模版 · public/scripts/chats.js:147-169
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 隐藏前五条,再拼下一轮:

效果 · /hide 0-4 前后(dry run)
隐藏前:聊天历史 11 条进 prompt
/hide 0-4 之后:6 条,第一条是「你一个人守在这里多久了?给我讲讲你是怎么成为森林守护者的吧。」

分支:复制前缀,带上文件头

问题:第 40 条做了关键选择,想试「当时如果选另一个」,又不想丢掉现在这条线。

功能:在某条消息上开分支:把这条及之前的消息复制出来,换上一个新的完整性标记、记下父聊天的名字,另存成一个新文件;父消息上记下分支名,两边用文件名互链。还可以指定某个候选,从当初没选的那个分出去:

模版 · public/scripts/bookmarks.js:198-244
    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;

另存走的是普通的保存函数,新文件头 = 当前文件头加上这两个字段:

模版 · public/script.js:7406,7421-7432
    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',
    };

服务端把数组逐条序列化,一行一条,整文件覆盖写:

模版 · src/endpoints/chats.js:532-543
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 份;名字里有中文的,在文件名后加一段哈希,免得几个中文名的聊天挤在同一个配额里:

模版 · src/endpoints/chats.js:42-78
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"}