跳转至

用户自己加规则

网页版 ChatGPT 的规则写死在产品里:屏幕上显示的、存下来的、下一轮发给模型的是同一个字符串;想让程序记个数、每轮自动做点什么,都没有地方挂。酒馆把这几处都开了口子,用户和扩展作者自己往上挂规则。下面的正则、变量、事件三节用酒馆的原函数跑了效果,其余只贴模版。

正则:显示、存储、发给模型三层拆开

问题:卡要求模型每条回复末尾输出一段状态栏 <状态>好感:35</状态>。屏幕上想看渲染好的样子;旧的状态栏一直被回放,既占 token,又让模型拿旧数值当参考。

功能:正则脚本挂在几个固定的处理点上(用户输入、AI 输出、世界书、思考内容……),每个脚本有两个开关决定作用在哪一层:

  • 都不勾:收到消息时就改,直接写进聊天文件,显示和 prompt 一致
  • 只改显示(markdownOnly):渲染时改,文件和 prompt 不动
  • 只改 prompt(promptOnly):拼 prompt 时改,屏幕和文件不动

再加一个深度范围,只作用于倒数第几条以后的消息:

模版 · public/scripts/extensions/regex/engine.js:349-376
            // Script applies to Markdown and input is Markdown
            (script.markdownOnly && isMarkdown) ||
            // Script applies to Generate and input is Generate
            (script.promptOnly && isPrompt) ||
            // Script applies to all cases when neither "only"s are true, but there's no need to do it when `isMarkdown`, the as source (chat history) should already be changed beforehand
            (!script.markdownOnly && !script.promptOnly && !isMarkdown && !isPrompt)
        ) {
            if (isEdit && !script.runOnEdit) {
                console.debug(`getRegexedString: Skipping script ${script.scriptName} because it does not run on edit`);
                return;
            }

            // Check if the depth is within the min/max depth
            if (typeof depth === 'number') {
                if (!isNaN(script.minDepth) && script.minDepth !== null && script.minDepth >= -1 && depth < script.minDepth) {
                    console.debug(`getRegexedString: Skipping script ${script.scriptName} because depth ${depth} is less than minDepth ${script.minDepth}`);
                    return;
                }

                if (!isNaN(script.maxDepth) && script.maxDepth !== null && script.maxDepth >= 0 && depth > script.maxDepth) {
                    console.debug(`getRegexedString: Skipping script ${script.scriptName} because depth ${depth} is greater than maxDepth ${script.maxDepth}`);
                    return;
                }
            }

            if (script.placement.includes(placement)) {
                finalString = runRegexScript(script, finalString, { characterOverride });
            }

效果:前两轮的三条 AI 消息末尾各挂一个状态栏(构造的输入),两个脚本:一个只改显示,把状态栏渲染成方框;一个只改 prompt、最小深度 1,旧的状态栏不发给模型:

效果 · 同一条消息的三层
脚本:
  「状态栏渲染」 /<状态>([\s\S]*?)<\/状态>/g → "<div class=\"status\">$1</div>"  markdownOnly=True promptOnly=False minDepth=None
  「旧状态栏不发给模型」 /\n?<状态>[\s\S]*?<\/状态>/g → ""  markdownOnly=False promptOnly=True minDepth=1

存在聊天文件里(三条都没动):
  第 0 条 ……只有这么多。”⏎<状态>好感:20|伤势:昏迷刚醒</状态>
  第 2 条 ……们破不了。只要⏎<状态>好感:30|伤势:左腿未愈</状态>
  第 4 条 ……护身的东西。”⏎<状态>好感:35|伤势:左腿未愈</状态>

屏幕上显示:
  第 0 条(深度 4)……⏎<div class="status">好感:20|伤势:昏迷刚醒</div>
  第 2 条(深度 2)……⏎<div class="status">好感:30|伤势:左腿未愈</div>
  第 4 条(深度 0)……⏎<div class="status">好感:35|伤势:左腿未愈</div>

发给模型(三条 assistant 消息的结尾):
  ……好好休息。你在这里很安全。我会照顾你,可你得休息。我的魔法能治的也只有这么多。”
  ……怪物。” *她伸手轻轻覆上你的手背。* “但你不必担心。我的结界它们破不了。只要
  ……可,至少让我给你留些护身的东西。”⏎<状态>好感:35|伤势:左腿未愈</状态>

文件里三条都完整保留,屏幕上三条都渲染了,发给模型时只剩最新一条带着状态栏。

变量:状态放在模型外面

问题:好感度、回合数只能靠模型从历史里「记」,越聊越漂。

功能:变量存在聊天文件头的 chat_metadata.variables,用宏读写,宏每轮拼 prompt 时求值:

模版 · public/scripts/macros/definitions/variable-macros.js:28-31
        handler: ({ unnamedArgs: [name, value] }) => {
            ctx.variables.local.set(name, value);
            return '';
        },
模版 · public/scripts/variables.js:48-55
export function setLocalVariable(name, value, args = {}) {
    if (!name) {
        throw new Error('Variable name cannot be empty or undefined.');
    }

    if (!chat_metadata.variables) {
        chat_metadata.variables = {};
    }

效果:

效果 · 变量宏实算
{{setvar::好感::35}}好感是 {{getvar::好感}}  →  好感是 35
{{addvar::好感::5}}现在是 {{getvar::好感}}  →  现在是 40
chat_metadata.variables = {"好感": 40}

变量在文件头里,开分支时跟着复制(见分支)。另有全局变量,存在用户设置里,所有聊天共用。

Quick Reply 和 STscript:把操作写成脚本,挂到事件上

问题:「每条回复后好感 +1」「每 20 轮摘要一次」这类事,baseline 只能靠人记得去做。

功能:输入框里以 / 开头的不发给模型,交给 STscript 执行:有管道、变量、条件、循环,也有调模型的命令(带上下文的后台生成、不带上下文的裸生成)。Quick Reply 把一段脚本做成按钮,还可以挂到事件上自动执行:启动、用户发言、AI 回复、切换聊天、生成前,以及世界书条目激活——条目上填一个自动化 ID,激活时执行同 ID 的脚本:

模版 · public/scripts/extensions/quick-reply/src/AutoExecuteHandler.js:85-103
    async handleWIActivation(entries) {
        if (!this.checkExecute() || !Array.isArray(entries) || entries.length === 0) return;
        const automationIds = entries.map(entry => entry.automationId).filter(Boolean);
        if (automationIds.length === 0) return;

        const getFromConfig = (config) => {
            return config?.setList
                ?.map(link => link.set ? link.set.qrList.filter(qr => qr.automationId && automationIds.includes(qr.automationId)) : [])
                ?.flat() ?? [];
        };

        const qrList = [
            ...getFromConfig(this.settings.config),
            ...getFromConfig(this.settings.chatConfig),
            ...getFromConfig(this.settings.charConfig),
        ];

        await this.performAutoExecute(qrList);
    }

这一节只贴模版:STscript 的执行器要整个前端,实跑没有接。

事件总线:串行等待,能原地改

问题:扩展想在 prompt 发出去之前改一笔,得有一个时机,还得保证改了算数。

功能:事件按注册顺序逐个 await 监听器,监听器拿到的是同一个对象,原地改了,发事件的一方读回来就生效:

模版 · public/lib/eventemitter.js:140-153
    if (typeof this.events[event] === 'object') {
        listeners = this.events[event].slice();
        length = listeners.length;

        for (i = 0; i < length; i++) {
            try {
                await listeners[i].apply(this, args);
            }
            catch (err) {
                console.error(err);
                console.trace('Error in event listener');
            }
        }
    }

消息数组拼完、发出之前发一次 CHAT_COMPLETION_PROMPT_READY,把整个数组交出去:

模版 · public/scripts/openai.js:1616-1619
    const chat = chatCompletion.getChat();

    const eventData = { chat, dryRun };
    await eventSource.emit(event_types.CHAT_COMPLETION_PROMPT_READY, eventData);

效果:注册一个监听器,往数组倒数第二个位置塞一条 system「(扩展插入:外面下起了雨。)」,拼第二轮:

效果 · 监听器改过之后发给 DeepSeek 的结尾
发给 DeepSeek 的最后三条:
  assistant *塞拉菲娜在床边坐下,双手交叠放在膝上,神色变得严肃。* “这片森林里潜藏着许多危险。最常见的是影狼
  user      你的魔法真厉害。我的伤要多久才能好?我还得赶去北边的镇子。⏎⏎(扩展插入:外面下起了雨。)
  assistant *塞拉菲娜眉头微微蹙起,语气温和却带着一丝担忧。* “你的伤不轻,至少要静养两三天才能再赶路。” *

插进去的 system 到了 DeepSeek 那边被 semi 后处理改成 user,又和前一条 user 合并了。

生成拦截器:改发出的历史

问题:向量召回要在世界书扫描之前改动这一轮的历史,事件来得太晚。

功能:扩展在清单里声明一个拦截函数,生成前按加载顺序依次调用,拿到的是这一轮聊天的浅拷贝,可以挪动、删掉消息,也可以中止这次生成:

模版 · public/scripts/extensions.js:2024-2046
export async function runGenerationInterceptors(chat, contextSize, type) {
    let aborted = false;
    let exitImmediately = false;

    const abort = (/** @type {boolean} */ immediately) => {
        aborted = true;
        exitImmediately = immediately;
    };

    for (const manifest of Object.values(manifests).filter(x => x.generate_interceptor).sort((a, b) => sortManifestsByOrder(a, b))) {
        const interceptorKey = manifest.generate_interceptor;
        if (typeof globalThis[interceptorKey] === 'function') {
            try {
                await globalThis[interceptorKey](chat, contextSize, abort, type);
            } catch (e) {
                console.error(`Failed running interceptor for ${manifest.display_name}`, e);
            }
        }

        if (exitImmediately) {
            break;
        }
    }

向量召回就挂在这里(见聊久了);出图扩展用它拦下「给我画一张」这类请求,转去出图。这一节只贴模版。扩展往 prompt 里加内容,走的是另一条路:登记进注入表。

工具调用:递归生成,最多 5 轮

问题:baseline 一轮只生成一条,模型没法掷个骰子、查个天气再接着讲。

功能:出厂关着。打开后,回复里有工具调用就执行,结果存成一条隐藏消息,再以 depth + 1 重新调一次生成,最多 5 轮:

模版 · public/script.js:5541-5558
        if (canPerformToolCalls) {
            const hasToolCalls = ToolManager.hasToolCalls(data);
            const shouldDeleteMessage = type !== 'swipe' && ['', '...'].includes(getMessage) && !reasoning;
            hasToolCalls && shouldDeleteMessage && await deleteLastMessage();
            const invocationResult = await ToolManager.invokeFunctionTools(data, { reasoningText: reasoning });
            const shouldStopGeneration = (!invocationResult.invocations.length && shouldDeleteMessage) || invocationResult.stealthCalls.length;
            if (hasToolCalls) {
                if (shouldStopGeneration) {
                    if (Array.isArray(invocationResult.errors) && invocationResult.errors.length) {
                        ToolManager.showToolCallError(invocationResult.errors);
                    }
                    unblockGeneration(type);
                    return;
                }

                depth = depth + 1;
                await ToolManager.saveFunctionToolInvocations(invocationResult.invocations);
                return Generate('normal', { automatic_trigger, force_name2, quiet_prompt, quietToLoud, skipWIAN, force_chid, signal, quietImage, quietName, depth }, dryRun);

工具由扩展或 STscript 注册。这一节只贴模版,实跑保持出厂的关闭状态。

谁管什么

  • 程序:正则的作用层和深度判断、变量的存放、事件的串行执行、拦截器的调用顺序、工具调用的递归上限
  • 用户、卡作者、预设作者:写正则、写脚本、挂自动执行、注册工具;卡和预设都能自带正则脚本,导入时先问用户同不同意
  • 扩展作者:监听事件、注册拦截器、往注入表里写内容
  • 模型:只看到这些规则作用之后的 prompt;打开工具调用后,可以决定调哪个工具

实跑原件

正则、变量、事件三组实验的原始输出
{
 "scripts": [
  {
   "scriptName": "状态栏渲染",
   "findRegex": "/<状态>([\\s\\S]*?)<\\/状态>/g",
   "replaceString": "<div class=\"status\">$1</div>",
   "markdownOnly": true,
   "promptOnly": false,
   "minDepth": null
  },
  {
   "scriptName": "旧状态栏不发给模型",
   "findRegex": "/\\n?<状态>[\\s\\S]*?<\\/状态>/g",
   "replaceString": "",
   "markdownOnly": false,
   "promptOnly": true,
   "minDepth": 1
  }
 ],
 "stored": [
  {
   "index": 0,
   "stored": "只有这么多。”\n<状态>好感:20|伤势:昏迷刚醒</状态>"
  },
  {
   "index": 2,
   "stored": "们破不了。只要\n<状态>好感:30|伤势:左腿未愈</状态>"
  },
  {
   "index": 4,
   "stored": "护身的东西。”\n<状态>好感:35|伤势:左腿未愈</状态>"
  }
 ],
 "display": [
  {
   "index": 0,
   "depth": 4,
   "shown": "\n<div class=\"status\">好感:20|伤势:昏迷刚醒</div>"
  },
  {
   "index": 2,
   "depth": 2,
   "shown": "\n<div class=\"status\">好感:30|伤势:左腿未愈</div>"
  },
  {
   "index": 4,
   "depth": 0,
   "shown": "\n<div class=\"status\">好感:35|伤势:左腿未愈</div>"
  }
 ],
 "sentToModel": [
  "好好休息。你在这里很安全。我会照顾你,可你得休息。我的魔法能治的也只有这么多。”",
  "怪物。” *她伸手轻轻覆上你的手背。* “但你不必担心。我的结界它们破不了。只要",
  "可,至少让我给你留些护身的东西。”\n<状态>好感:35|伤势:左腿未愈</状态>"
 ]
}
{
 "tail": [
  {
   "role": "assistant",
   "content": "*塞拉菲娜在床边坐下,双手交叠放在膝上,神色变得严肃。* “这片森林里潜藏着许多危险。最常见的是影狼"
  },
  {
   "role": "user",
   "content": "你的魔法真厉害。我的伤要多久才能好?我还得赶去北边的镇子。\n\n(扩展插入:外面下起了雨。)"
  },
  {
   "role": "assistant",
   "content": "*塞拉菲娜眉头微微蹙起,语气温和却带着一丝担忧。* “你的伤不轻,至少要静养两三天才能再赶路。” *"
  }
 ]
}