用户自己加规则
网页版 ChatGPT 的规则写死在产品里:屏幕上显示的、存下来的、下一轮发给模型的是同一个字符串;想让程序记个数、每轮自动做点什么,都没有地方挂。酒馆把这几处都开了口子,用户和扩展作者自己往上挂规则。下面的正则、变量、事件三节用酒馆的原函数跑了效果,其余只贴模版。
正则:显示、存储、发给模型三层拆开
问题:卡要求模型每条回复末尾输出一段状态栏 <状态>好感:35</状态>。屏幕上想看渲染好的样子;旧的状态栏一直被回放,既占 token,又让模型拿旧数值当参考。
功能:正则脚本挂在几个固定的处理点上(用户输入、AI 输出、世界书、思考内容……),每个脚本有两个开关决定作用在哪一层:
- 都不勾:收到消息时就改,直接写进聊天文件,显示和 prompt 一致
- 只改显示(
markdownOnly):渲染时改,文件和 prompt 不动 - 只改 prompt(
promptOnly):拼 prompt 时改,屏幕和文件不动
再加一个深度范围,只作用于倒数第几条以后的消息:
// 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 时求值:
handler: ({ unnamedArgs: [name, value] }) => {
ctx.variables.local.set(name, value);
return '';
},
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 的脚本:
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 监听器,监听器拿到的是同一个对象,原地改了,发事件的一方读回来就生效:
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,把整个数组交出去:
const chat = chatCompletion.getChat();
const eventData = { chat, dryRun };
await eventSource.emit(event_types.CHAT_COMPLETION_PROMPT_READY, eventData);
效果:注册一个监听器,往数组倒数第二个位置塞一条 system「(扩展插入:外面下起了雨。)」,拼第二轮:
发给 DeepSeek 的最后三条:
assistant *塞拉菲娜在床边坐下,双手交叠放在膝上,神色变得严肃。* “这片森林里潜藏着许多危险。最常见的是影狼
user 你的魔法真厉害。我的伤要多久才能好?我还得赶去北边的镇子。⏎⏎(扩展插入:外面下起了雨。)
assistant *塞拉菲娜眉头微微蹙起,语气温和却带着一丝担忧。* “你的伤不轻,至少要静养两三天才能再赶路。” *
插进去的 system 到了 DeepSeek 那边被 semi 后处理改成 user,又和前一条 user 合并了。
生成拦截器:改发出的历史
问题:向量召回要在世界书扫描之前改动这一轮的历史,事件来得太晚。
功能:扩展在清单里声明一个拦截函数,生成前按加载顺序依次调用,拿到的是这一轮聊天的浅拷贝,可以挪动、删掉消息,也可以中止这次生成:
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 轮:
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|伤势:左腿未愈</状态>"
]
}