Skip to content

角色扮演变量与消息规则生成指南(AI Skill)

这份文档面向 AI 使用,如果你是人类,你可以直接将本文链接丢给AI让其阅读后根据你的需求编写变量和消息规则,将AI输出的JSON复制后通过界面"变量字段编辑器"和"消息规则库 JSON 编辑器"导入即可。

当用户提出"帮我做一套好感度系统 / 做选项菜单 / 加阶段推进 / 处理括号独白"之类的角色扮演功能需求时,按本文档的格式与约束输出 JSON。

阅读顺序建议:先看 §1 交付物 → §3 原理速览 → §4 字段约束 → §5 雷区 → §6 主题示例。生成前对照 §7 自检清单。


1. 你要交付什么

最多两份独立的 JSON 块:

交付物用途用户导入位置
变量字段 JSON定义"角色 / 群聊"里能用的自定义状态字段(如 affectionphase人格设置 → 变量字段编辑器;或群聊设置 → 变量字段编辑器
消息规则库 JSON定义按正则匹配并改写消息内容、注入提示词、写入变量的规则消息规则库管理 → 新建库 → JSON 编辑器

输出格式约定:

  • 每份 JSON 单独放一个 ```json 代码块,前面用一行中文说明导入位置。
  • 如果不需要新变量,就只输出消息规则库 JSON,不要写空的变量字段块。
  • 如果只是给变量做定义(少见),就只输出变量字段 JSON。
  • 在两份 JSON 之间用一段简短中文说明"这条规则做什么 / 为什么这样写"。
  • 不要混合在同一个 JSON 里。

2. 顶层结构

2.1 变量字段 JSON

json
{
  "version": 1,
  "fields": [
    {
      "id": "affection",
      "displayName": "好感度",
      "type": "number",
      "description": "可选,字段说明",
      "minValue": 0,
      "maxValue": 100,
      "defaultValue": 50
    }
  ]
}
字段类型必填说明
versionnumber固定为 1
fieldsarray字段定义数组
fields[].idstring见 §4.1,全小写、字母开头、不与保留字冲突
fields[].displayNamestring用户可见名(可中文)
fields[].typestringtext / number / boolean / enum(注意:是 enum 不是 enumeration
fields[].descriptionstring字段说明
fields[].enumOptionsstring[]enum 时建议必填枚举选项
fields[].minValue / maxValuenumbernumber 生效
fields[].defaultValueany类型须与 type 匹配,enum 必须命中 enumOptionsnumbermin/max 之间

2.2 消息规则库 JSON

json
{
  "version": 1,
  "name": "好感度系统",
  "entries": [
    {
      "title": "好感度上升标记",
      "search": "【好感度上升】",
      "replace": "$0{{set affection=affection+5}}",
      "sourceType": "character",
      "targetChannels": ["user"],
      "enabled": true,
      "caseSensitive": false,
      "global": true
    }
  ]
}
字段类型必填默认值说明
versionnumber固定为 1
namestring规则库名称
entriesarray规则条目数组
entries[].titlestring条目名称
entries[].searchstring正则表达式(Dart RegExp 兼容)
entries[].replacestring替换模板(支持 $N / {{...}} / {{set}} / {{command ...}}
entries[].sourceTypestringcharacteruser / character / system 三选一
entries[].targetChannelsstring[]["user","character"]元素从 user / character / system / speech 选;非法值会被丢弃,全空时回退默认值
entries[].escapeNewlineOnlyboolfalse开启后,replace 中的实际换行只用于编辑排版,不参与最终输出;只有显式 \\n 才会生成换行
entries[].enabledbooltrue
entries[].caseSensitiveboolfalse正则是否区分大小写
entries[].globalbooltruetrue 处理所有命中,false 只处理第一个

3. 原理速览

3.1 三种角色 / 通道概念

每条消息都有三个独立维度:

  • 来源(消息规则 sourceType:消息是谁发的

    • user = 用户身份发的
    • character = AI 角色发的
    • system = 群聊导演 system AI 发的(含用户替 system 发言)
  • 通道(消息规则 targetChannels:消息给谁看时走这条规则

    • user = 显示给用户看的(界面气泡)
    • character = 发送给 AI 角色的(影响 LLM 上下文)
    • system = 发送给群聊 system AI 选人时的
    • speech = TTS 语音生成时

一条规则只在 sourceType 与当前消息来源一致时被触发。触发后会在 targetChannels 列出的通道分别运行替换。

3.2 模板语法(共 6 类节点)

语法说明
{{变量名}}变量插值。变量名大小写不敏感
{{expr 表达式}}直接输出表达式结果;null 会输出空字符串
{{if 条件}}A{{elseif 条件}}B{{else}}C{{end}}条件判断(多分支)
{{when 条件}}...{{end}}条件触发(区别见 §3.7)
{{set 字段名=表达式}}仅写入期执行的赋值,不输出文本
{{command stop}} / {{command delete}}仅写入期执行的控制命令,不输出文本

注意

  • {{expr}} 只是把结果输出成文本;如果想把结果写入状态,仍然要用 {{set}}
  • {{set}}{{command}} 是单语句,不需要 {{end}}
  • {{if}}{{when}} 必须有 {{end}} 闭合。
  • {{set}}{{command}} 只在消息规则 replace 中用;提示词模板里不要{{set}}{{command}}

表达式支持范围(if / elseif / when / expr / set 共用):

!  &&  ||  ==  !=  >  >=  <  <=  +  -  *  /  ()
true / false / null
"字符串" 或 '字符串'
数字

内置函数:

  • floor(x):向下取整。示例:{{expr floor(Random*10)}}
  • ceil(x):向上取整。示例:{{expr ceil(score/10)}}
  • round(x):四舍五入。示例:{{expr round(score/10)}}
  • RandomInt(min, max):基于当前消息的稳定 Random 生成闭区间整数。示例:{{expr RandomInt(1, 6)}}

补充:

  • 函数名大小写不敏感:floor / Floor / FLOOR 等价。
  • RandomInt(min, max)min / max 必须是整数,且 min <= max
  • RandomInt(1, 6) 的结果范围是 1 到 6,不是 1 到 5。
  • RandomInt 依赖消息规则里的 Random 变量,所以只应用在消息规则里;人格提示词、群聊提示词、system AI 的额外提示词里当前保存时会直接报错。
  • {{expr ...}} 能读取的变量,取决于它所在的位置:在提示词里读提示词可用变量,在消息规则里读消息内置变量 / 捕获组 / 自定义状态快照。

3.3 大小写规则(重要)

  • 变量名 / 关键字 / true/false/null 大小写不敏感:{{User}}{{user}} 完全等价。
  • 字符串字面量比较仍区分大小写phase == "warming" 不等于 phase == "WARMING"
  • 因此条件里写中文枚举(如 phase == "序章")时,必须和 defaultValue / enumOptions 里写的完全一致。

3.4 变量分类

提示词中可用消息规则 replace 中可用
提示词内置变量 {{User}} {{Target}} {{DateTime}} {{Language}} {{HasKnowledge}} {{KnowledgeBaseCount}} {{KnowledgeEntryTitles}} {{HasLongTermMemory}}
消息内置变量 {{MessageDateTime}} {{MessageDate}} {{MessageTime}} {{SenderName}} {{ReaderName}} {{Random}}
匹配变量 {{Match0}}{{Match1}}... 与 $0 $1...是(仅消息规则)
自定义状态字段 {{affection}}是(取消息时刻快照)

补充:

  • Random 是 0~1 的稳定伪随机值,基于消息 ID 生成,刷新/重启后值不变。
  • 需要整数随机时,可以写 {{expr RandomInt(1, 6)}},或在 set 里写 {{set dice=RandomInt(1, 6)}}
  • {{expr ...}} 可以直接把计算结果输出到文本里,例如 {{expr floor(Random*10)}}{{expr affection+5}}
  • floor / ceil / round 是通用表达式函数;RandomInt 则依赖消息规则里的 Random
  • {{Match0}} 等同于 $0(整段命中),但 {{MatchN}} 适合放进条件里比较,$N 适合直接做替换文本。
  • $N 在编译期就是独立节点,所以 $1{{affection}}$2 不会被错读成 $150 + $2(即变量值的尾随数字不会被吞)。
  • $$ 表示字面 $$N 中 N 超出当前正则捕获组数量时按字面 $N 输出。

3.5 消息规则可用的特殊语义:ReaderName

ReaderName 表示"当前是谁在读这个通道":

  • user 通道 → 当前用户身份名
  • character 通道 → 当前正在读这条消息的角色 displayName
  • system 通道 → 字符串 "system"
  • speech 通道 → 字符串 "Speech"
  • 长期记忆总结读取 character 通道时 → 字符串 "longTermMemory"(驼峰)

ReaderName 让同一条消息对不同读者呈现不同结果。例如让"内心独白"只有发送者自己能看见。

3.6 写入期操作语句(set / command

setcommand 都属于写入期操作语句,核心共性是:

  • 都只在消息写入时执行一次
  • 都固定基于 user 通道当前规则链上的文本与变量上下文
  • 历史消息重新解释时,都不会再次执行
  • 如果规则的 targetChannels 不包含 user,它们都不会执行
  • 如果模板里同时出现 ReaderName,真实触发判定也只看写入期那一次 user 通道上下文

其中:

  • {{set 字段=表达式}}:写入状态,不输出文本
  • {{command stop}} / {{command delete}}:控制当前消息和后续追加流程,不输出文本

后果:

  • {{set affection=affection+1}} 不会因为反复打开历史而重复加
  • command 在测试预览 / 历史渲染 / 调试读取时,不会删除消息,也不会中断流程
  • 即使规则的 targetChannels 包含 user / character / system / speech 多个通道,它们也都只触发一次
  • 设计规则时若同条模板既有 set 又有变量读取,应把文本理解为依赖最终状态,不要按“逐步观察中间态”去设计

command 的结果区别:

  • {{command stop}}:当前消息保留,但停止本轮后续消息追加
  • {{command delete}}:当前消息不入库、不显示
  • 同时命中 stop + delete:当前消息删除,且本轮后续消息也停止追加

3.7 whenif 的区别

ifwhen
何时显示其内容只看条件是否为真条件为真 表达式依赖的字段在当前 whenFields 集合中
是否消耗状态命中时会消费这些字段(从 whenFields 移除)
单次写入后的触发次数每次读取都会重新判断字段刚被写入后的下一次渲染触发一次,触发后被消费
字段被反复写入时每次都按当前值判断每一次写入都会把该字段重新放回 whenFields,下次又会触发

whenFields 的入栈:

  • 用户手动改变量 → 该字段进入 whenFields
  • 群聊 system AI 改变量 → 该字段进入 whenFields
  • {{set field=...}} 真实写入了与旧值不同的新值 → 该字段进入 whenFields(写入相同值时不入栈,详见 §5.13)

重要语义澄清{{when affection >= 80}}...{{end}} 不是"affection 第一次到 80 时触发一次、之后永不再触发"。它的真实行为是"affection 每次被改写之后,只要条件仍成立就再触发一次"。要做"真·一次性事件"必须用独立标志位,详见 §5.12 与示例 9。

when 适合写"刚发生了状态变化时给一段提示词"。if 适合写"展示当前值"。

when 仅在以下场景生效

  • 人格提示词
  • 消息规则的 replace

不支持 when 的位置:

  • 群聊提示词(ChatGroup.prompt
  • 群聊 system AI 的额外提示词(ChatGroup.groupSpeakerSmartPrompt

在这两处写 {{when}},当前保存时会直接报错;即使历史数据里残留了 {{when}},运行时也不会展开或消费 whenFields。

3.8 私聊 vs 群聊

  • 变量字段定义来源:私聊取自人格的 stateFieldDefinitions,群聊取自群聊的 stateFieldDefinitions。两边都支持变量模式。
  • 消息规则库挂载:私聊读人格挂载的规则库;群聊读群聊公共挂载的,不叠加发言者人格自己挂载的。
  • system AI 改变量:仅群聊(且打开"允许 system AI 改变量"开关)。私聊永远没有 systemStatePatch 来源。
  • sourceType: system:私聊几乎用不到。如果用户在私聊场景给规则,避免sourceType 设为 system
  • system AI 提示词变量差异HasKnowledge / KnowledgeBaseCount / KnowledgeEntryTitles 这三个变量名在 system AI 的额外提示词里仍然可写,但当前值固定视为 false / 0 / ""
  • system AI 提示词来源:群聊导演 system AI 当前只读取 ChatGroup.groupSpeakerSmartPrompt 这份额外提示词,不会拼接 ChatGroup.prompt

3.9 空通道规则

  • 三个逻辑通道某一个被替换为空 → 该通道当前不可见
  • 私聊用户消息后若当前角色读到的 character 通道为空 → 不会触发角色回复。所以不要轻易在 targetChannels: ["character"] 上写"全部清空"的规则。
  • 即使三个逻辑通道都为空,消息默认也仍会保留;只有显式命中 {{command delete}} 才会让消息不入库。

3.10 缓存友好原则:动态内容用消息规则,不要进提示词

人格提示词、群聊提示词、system AI 的额外提示词在每次 LLM 请求时都会作为 system prompt 的一部分发给 LLM。主流 LLM 提供商通常对完全一致的前缀做缓存命中,命中价格通常是非缓存输入的 1/4 ~ 1/10。

如果在人格提示词里写经常变化的变量,例如 {{DateTime}}、自定义状态变量 {{affection}} / {{phase}}、或 {{if affection > 80}}...{{end}} 这种依赖变量的条件块——每次会话发起时提示词都会因变量变化而改变,缓存前缀失效,每次都得从头重新计算整段 system prompt。短期看是延迟上升,长期看是 token 成本累计上升。

应当默认遵循的设计原则

✅ 推荐:动态内容放在消息规则里,通过改写"用户消息" / "角色消息"的 character 通道把动态值注入 LLM 上下文。

需求用消息规则的写法(参考示例)
让 AI 知道现实时间在用户消息开头注入 [{{MessageDateTime}}](示例 7)
让 AI 看到当前好感度在用户消息末尾追加 *当前好感度:{{affection}}*(示例 1 第 4 条)
阶段相关的条件提示词用消息规则 {{if phase=="发展"}}...{{end}} 注入 character 通道(示例 3)
一次性事件用消息规则 + {{when}} + 标志位(示例 9)

❌ 不推荐(除非用户明确要求):

text
(人格提示词中)
当前时间:{{DateTime}}
当前好感度:{{affection}}
{{if affection > 80}}你已经很喜欢用户了{{end}}

放在消息规则里还有一个隐藏好处:每条消息携带"该消息时刻"的状态值,时序上下文与具体场景挂钩——AI 看到"用户在好感度 65 时说了 X,在好感度 90 时又说了 Y",比看到"系统提示词里写当前好感度 90"能更好地理解情绪曲线。

当用户明确要求把动态变量塞进人格提示词时:按需求做,但在交付时附一句简短提醒:

这种写法会让人格提示词在每次请求时都变化,导致 LLM 提示词缓存失效,可能显著增加调用成本与首字延迟。如果只是想让 AI 看到当前数值,更推荐改用消息规则在用户/角色消息上注入。

不在此约束范围内的变量(写进提示词不会破坏缓存或本就稳定):

  • {{User}} / {{Target}} / {{Language}} —— 在同一会话里通常稳定
  • {{HasKnowledge}} / {{KnowledgeBaseCount}} / {{KnowledgeEntryTitles}} —— 知识库挂载变化时才变,可放
  • {{HasLongTermMemory}} —— 整个会话生命周期内最多翻一次
  • 依赖稳定变量的纯静态 {{if ...}} 不存在(条件总要依赖变量;只有"条件依赖的是稳定变量"才安全)

4. 字段约束

4.1 自定义变量字段 id

  • 必须全小写字母、数字、下划线
  • 必须字母开头
  • 不能与保留字冲突(见 §4.3)
  • 同一份字段定义里不能重复

合法:affectionphaseaffinity_soyois_angryevent_a1 非法:Affection好感度1phaseis-angryaffinity.soyo

4.2 type 取值(关键)

只允许这四个字符串字面量

"text"   "number"   "boolean"   "enum"

⚠️ 不要写 "enumeration""string""enumeration" 在解析时无法匹配,会被静默回退到 text,导致字段类型被悄悄改成文本,下游 set 失败。

4.3 保留字列表

id 不能命中以下任何一项(大小写不敏感):

模板关键字:if elseif expr when set command else end 条件字面量:true false null 提示词内置:user target datetime hasknowledge knowledgebasecount knowledgeentrytitles haslongtermmemory language 消息内置:messagedatetime messagedate messagetime sendername readername random 匹配变量:match0 match1 match2 ...(即 match 后跟数字) 表达式函数:randomint floor ceil round

4.4 默认值与归一化

  • text:会 trim(),空字符串视为无 defaultValue
  • number:必须可解析为数字,且会被 min/max 截断
  • boolean:接受 true/false、数字(0=false,非0=true)、字符串("true"/"false"/"1"/"0"
  • enum:必须精确命中 enumOptions 之一(区分大小写);不命中则 defaultValue 被丢弃

4.5 消息规则字段约束

  • search 必须是有效正则(Dart RegExp 兼容)。空 search → 该条目验证失败但仍能导入。
  • replace 编译失败(如 {{if ... 没有 {{end}}}}) → 整条规则不参与运行(在调试面板里会以红色"编译失败"显示)。
  • replace{{set field=...}}
    • field 必须已经在变量字段定义里,否则 trace 显示 errored,本次 set 整体回滚(文本替换照常)。
    • 表达式求值失败(如类型不能比较、除以 0、RandomInt(1.5, 6)RandomInt(6, 1))也会回滚 set,不写 messageRuleStatePatch

5. 雷区(最容易写错的地方)

5.1 JSON 转义

  • 正则中的反斜杠在 JSON 中要 \\\d{4} 写成 "\\d{4}"\( 写成 "\\("
  • 双引号要写 \"
  • 一个常见错:"search": "\d+" ← 这是 JSON 解析失败的常见原因,应写 "search": "\\d+"

5.2 多行替换

replace 模板里的 \n \r \t \\ 在导入后会被解码为真正的换行/回车/制表符/反斜杠。其它 \x 按字面保留。

例如要让规则插入"换行 + 一段提示词":

json
"replace": "$0\n\nsystem: 当前用户处于愤怒状态"

如果希望模板源码里的实际换行只用于排版,不参与最终输出,需要加:

json
"escapeNewlineOnly": true

此时:

  • 模板里的真实换行会被忽略
  • 只有显式 \\n 才会生成真实换行

5.3 set 的字段必须先在 fields 里定义

如果消息规则里写了 {{set affection=...}},那么变量字段 JSON 里必须有 id: "affection" 的字段。否则 set 会失败但文本替换照样进行,用户无法察觉。

生成时务必前后呼应:每个 set 引用的字段都列在 fields 里。

5.4 ReaderName 与写入期操作语句(set / command

下面这些写法当前都允许保存

text
{{if ReaderName == "白露"}}{{set affection=affection+1}}{{end}}$0
{{if ReaderName == "白露"}}{{command delete}}{{end}}

但它们的含义都不是“按不同读取者分别触发不同副作用”。真实原因是:写入期操作语句只执行一次,而 ReaderName 在历史读取时是多视角变量,两者组合后必须固定为一次性判定。

真实语义:

  • set / command 是否触发,只看写入期 user 通道那一次的 ReaderName
  • 历史展示 / 切换读取者时,不会重新决定这条消息是否改值、被删掉或 stop
  • 如果规则不包含 user 通道,写入期操作语句都不会执行

所以:

  • 如果用户要做"只对某个读取者隐藏",应用普通 if + ReaderName
  • 如果要做一次性改值 / 删除 / 截断,可以写 ReaderName + set/command,但要清楚它们都只按写入期 user 通道判定一次

5.5 command stopcommand delete 的区别

  • stop:当前消息可保留,但停止后续消息追加
  • delete:当前消息不入库
  • stop + delete:相当于截断本轮输出

5.6 when 必须依赖某个 whenFields 字段

text
{{when affection >= 80}}...{{end}}

只有在 affection 刚被改(被 set / manualOverride / systemStatePatch 写过且尚未消费)时才会展开。如果想让 affection >= 80每次都注入提示词,应该用 {{if}},不是 {{when}}

典型错误:把 when 写在游戏开局总是注入的提示词里 → 永远不会触发。

5.7 群聊提示词与 system AI 的额外提示词不支持 when

如果用户的需求要在群聊提示词里做"达成条件触发一次",改用 if + 自定义布尔字段实现:

text
{{if event_a1_done == false && affection >= 80}}...{{set event_a1_done=true}}...{{end}}

但注意,这种写法只能放在人格提示词消息规则中。群聊提示词里不要写 {{set}}

补充:

  • 群聊提示词与 system AI 的额外提示词里,当前保存时会直接拒绝 {{when}}{{set}}{{command ...}}
  • 这两类提示词里也不要写 RandomInt(...),当前保存时同样会直接报错

5.8 Random/RandomInt、取整函数与 expr 的作用域

  • 要显示表达式计算的结果必须使用 {{expr ...}}表达式
  • {{expr ...}} 可以在提示词和消息规则里使用,但它只负责输出文本,不会写状态。
  • floor / ceil / round 是通用表达式函数:凡是能写表达式的地方(if / when / expr / set)都能用。
  • Random消息规则专属内置变量。
  • RandomInt(min, max) 本质依赖 Random,因此只应放在消息规则里;若写进人格提示词、群聊提示词、system AI 的额外提示词,当前保存会直接报错。
  • RandomInt 的参数必须是整数,且 min <= max
  • RandomInt(1, 6) 是闭区间 1~6;同一条消息里它的结果和Random一样是稳定的,不会因为刷新历史重新抽样。

典型写法:

text
{{expr RandomInt(1, 6)}}
{{expr floor(Random*10)}}
{{set dice=RandomInt(1, 6)}}
{{if RandomInt(1, 100) <= 30}}触发事件 A{{else}}触发事件 B{{end}}

5.9 关键字与变量名的歧义

{{if affection > 80}}affection 是变量名;{{if "warming"}}"warming" 是字符串字面量。永远给字符串加引号,否则会被当成变量名(值为 null)。

5.10 targetChannels 的语义边界

  • 只想影响显示(不让 LLM 看见):["user"]
  • 只想影响LLM 看到的内容(不影响显示):["character"]
  • 大多数"提示词注入 + 变量更新"场景:["character"](注入只给 LLM)或 ["user","character"](同时显示和发给 LLM)
  • TTS 处理(去除括号独白、纠正读音):["speech"]

⚠️ 不要随意加 system。它只对群聊导演 system AI 有效,私聊场景设了等于无效配置。

5.11 同条模板里 set 后再读变量的真实语义

当前消息规则写入期 user 通道采用“两阶段求值”:

  1. 先执行同条模板里的 set / command
  2. 再基于推进后的最终临时状态渲染文本

因此,同条模板里既写 {{set affection=...}} 又写 {{affection}}当前是允许的;但应把它理解为“读取最终状态”,而不是“逐步观察中间态”。

例如:

text
{{set affection=1}}{{if affection==1}}A{{end}}{{set affection=2}}

最终文本按 affection=2 来理解,因此结果为空字符串。

text
{{set affection=3}}{{if affection==3}}{{set affection=2}}A{{affection}}{{end}}{{if affection==2}}B{{affection}}{{end}}

最终文本按 affection=2 来理解,因此结果为 B2

什么时候仍建议拆成两条规则

  • 想让规则结构更直观,避免后来维护者误把它理解成“左到右逐步显示中间态”
  • 想把“状态推进”和“展示格式”分离,便于调试与 trace 查看
  • 同一变量会在多条规则之间复用,拆开后更容易控制执行顺序

✅ 一条规则就够的场景:

  • 你本来就想在同条模板里先推进状态,再按最终值决定文本
  • 例如:{{set affection=2}}B{{affection}}

✅ 拆成两条更清晰的场景:

  • AI 输出 【好感+5=55】,你想先解析增量再统一校正显示值
  • 这时仍推荐“第一条只负责 set,第二条只负责显示覆盖”的写法

5.12 {{when}} 不是"上升沿触发器"

{{when affection >= 80}}...{{end}} 不会"在 affection 第一次到 80 时触发一次永远不再触发"。

实际语义:

  1. 任何一次对 affection 的修改(set / 手动 / system AI)都会把 affection 重新放回 whenFields
  2. 下一次模板渲染时,when 检查到 affection 在 whenFields 里且条件为真 → 展开内容并把 affection 从 whenFields 中消费。
  3. 之后只要 affection 又被任何形式修改,步骤 1~2 重复 —— when 会再次触发。

也就是说:在剧情里 affection 会因为正负事件被反复 set,只要每次最终值仍然 ≥ 80,这段 when 都会反复触发,不是只触发一次。

真·一次性事件必须用独立标志变量(参见示例 9):

  1. 加一个布尔字段,例如 affection_event_highdefaultValue: false

  2. 用一条规则在条件首次满足时把它从 false 翻成 true之后永不再 set

    {{if affection >= 80 && affection_event_high == false}}{{set affection_event_high=true}}{{end}}
  3. {{when affection_event_high == true}}...{{end}} 注入一次性提示词。

由于 affection_event_high 一旦变成 true 就再也不会被 set(条件 affection_event_high == false 已不成立),它不会再次进入 whenFieldswhen 也就只会触发一次。

核心模式when 真正的"只触发一次"靠的是让依赖字段不再被写入,而不是 when 本身能去重。

5.13 set 写入"相同值"不会进入 whenFields

{{set affection=affection}}{{set phase="序章"}}(当前 phase 已经是"序章")这类值未变化的赋值:

  • 写入 messageRuleStatePatch runtime event
  • 把字段加入 whenFields
  • 等于无操作

利用这一点:

  • 想避免触发某个 when,写一个"重复赋当前值"的 set 是没用的(不会消耗它)
  • 反过来,如果你确实想让 when 触发(哪怕值没变),也做不到——必须 set 一个真正不同的值

5.14 规则间的执行顺序与 isGlobal

多条规则按 entries 数组顺序串行执行:第 N 条规则的输入文本 = 第 N-1 条规则改写后的输出文本。这是示例 2(好感度自动校正)能成立的前提。

写规则集时要按依赖关系排顺序:

  • 先做正则捕获 + set 的规则
  • 再做读取 {{当前变量值}} 的规则
  • 最后做"格式化输出"或"清理符号"的规则

isGlobal 字段对 set 累加效果的影响

  • "global": true(默认):正则的所有命中都执行替换。如果模板里有 set每个命中都会触发一次 set。比如一条消息里出现 3 次 【好感度上升】affection 会被 +5 三次。
  • "global": false:只处理第一个命中,set 最多触发一次。

如果不希望同一条消息内多次累加(比如 AI 偶尔在一条消息中重复输出标记),可以把 "global" 设为 false,或在正则上加更严格的边界。


6. 完整主题示例

下面每个示例都给出完整可导入的两份 JSON。如果某主题不需要变量字段,则省略变量字段块。

示例 1:好感度系统(基础版)

需求:让 AI 用 【好感度上升】 / 【好感度下降】 标记好感变化,自动累加;每次用户发送消息后,发给 AI 的上下文里附加当前好感度。

变量字段 JSON(导入到人格设置 → 变量字段编辑器):

json
{
  "version": 1,
  "fields": [
    {
      "id": "affection",
      "displayName": "好感度",
      "type": "number",
      "description": "角色对用户的好感度(0~100)",
      "minValue": 0,
      "maxValue": 100,
      "defaultValue": 50
    }
  ]
}

消息规则库 JSON(导入为消息规则库,挂载到该人格):

json
{
  "version": 1,
  "name": "好感度系统",
  "entries": [
    {
      "title": "标记上升",
      "search": "【好感度上升】",
      "replace": "$0{{set affection=affection+5}}",
      "sourceType": "character",
      "targetChannels": ["user"]
    },
    {
      "title": "标记下降",
      "search": "【好感度下降】",
      "replace": "$0{{set affection=affection-5}}",
      "sourceType": "character",
      "targetChannels": ["user"]
    },
    {
      "title": "AI 直接给数值",
      "search": "【好感度:(\\d+)】",
      "replace": "$0{{set affection=Match1}}",
      "sourceType": "character",
      "targetChannels": ["user"]
    },
    {
      "title": "用户消息后附加当前好感度",
      "search": "$",
      "replace": "\n\n*当前好感度:{{affection}}*",
      "sourceType": "user",
      "targetChannels": ["character"]
    }
  ]
}

工作原理:AI 在文本中输出 【好感度上升】 之类标记 → 在用户通道保留显示,但 set 会更新数值。每次用户发送消息时,规则会在角色通道末尾追加当前好感度,让 LLM 始终知道情感状态。

示例 2:好感度系统(AI 自报增量 + 自动校正显示值)

需求:让 AI 用 【好感+5=55】("增量=结果"格式)的语法报告好感变化。增量由 AI 给出,结果数值由规则用真实当前值覆盖——这样即使 AI 算错,UI 上看到的也永远是真实状态。

用法:在人格提示词里要求 AI 输出格式 【好感±数字=数字】,比如:

【输出格式】
当好感度发生变化时,必须在消息末尾输出 `【好感+N=变化后的值】` 或 `【好感-N=变化后的值】`,N 为变化量。

复用示例 1 的变量字段 JSON。消息规则必须拆成两条

json
{
  "version": 1,
  "name": "好感度系统(自报增量)",
  "entries": [
    {
      "title": "解析增量并更新好感度",
      "search": "【好感\\+(\\d+)=(\\d+)】",
      "replace": "$0{{set affection=affection+Match1}}",
      "sourceType": "character",
      "targetChannels": ["user"]
    },
    {
      "title": "解析减量并更新好感度",
      "search": "【好感-(\\d+)=(\\d+)】",
      "replace": "$0{{set affection=affection-Match1}}",
      "sourceType": "character",
      "targetChannels": ["user"]
    },
    {
      "title": "把显示数字校正为当前实际值",
      "search": "(【好感[+-]\\d+=)\\d+(】)",
      "replace": "$1{{affection}}$2",
      "sourceType": "character",
      "targetChannels": ["user", "character"]
    }
  ]
}

为什么这里仍然推荐拆成两步:一条规则专门负责 set,另一条规则专门负责显示校正,结构更清晰,也更方便调试 trace。虽然当前同条模板里 set 后再读变量已经允许,但把“状态推进”和“展示修正”分开,后续维护成本更低。详见 §5.11。

拆成两条规则后,第一条只负责 set 不读自己写的变量,第二条只负责显示——无论写入期还是历史期,{{affection}} 都读到一致的新值。

示例 3:阶段推进 + 阶段相关随机事件

需求:用 phase 表示故事阶段;进入第 2 阶段后,每次用户发送消息有 30% 概率触发事件 A,否则触发事件 B。

json
{
  "version": 1,
  "fields": [
    {
      "id": "phase",
      "displayName": "故事阶段",
      "type": "enum",
      "description": "当前剧情进展",
      "enumOptions": ["序章", "发展", "高潮", "尾声"],
      "defaultValue": "序章"
    }
  ]
}
json
{
  "version": 1,
  "name": "阶段事件",
  "entries": [
    {
      "title": "AI 切换阶段",
      "search": "【进入(序章|发展|高潮|尾声)阶段】",
      "replace": "{{set phase=Match1}}",
      "sourceType": "character",
      "targetChannels": ["user", "character"]
    },
    {
      "title": "发展阶段随机事件",
      "search": "$",
      "replace": "{{if phase == \"发展\"}}\n\n{{if Random < 0.3}}system: 此刻她突然想起了过去的回忆,请描写她神情的瞬间变化{{else}}system: 此刻一切风平浪静,请按当前自然推进{{end}}{{end}}",
      "sourceType": "user",
      "targetChannels": ["character"]
    }
  ]
}

注意:

  • phaseenum,所以条件里写 phase == "发展" 必须和 enumOptions 中的字符串完全一致(区分大小写)。
  • 同一条消息中 Random/RandomInt 值稳定,所以这里的 30%/70% 分流不会因刷新而抖动。
  • 如果更想用离散整数概率,也可以把条件改成 {{if RandomInt(1, 100) <= 30}}...{{else}}...{{end}}

示例 4:骰子检定 + expr 输出整数随机

需求:每次用户发送消息时,在 UI 上显示一个 1~6 的骰点,同时给 AI 注入一个 1~100 的隐藏检定值;并把这次骰点写入状态,供后续规则使用。

变量字段 JSON(导入到人格设置 → 变量字段编辑器):

json
{
  "version": 1,
  "fields": [
    {
      "id": "last_roll",
      "displayName": "上次骰点",
      "type": "number",
      "minValue": 1,
      "maxValue": 6
    }
  ]
}

消息规则库 JSON(导入为消息规则库,挂载到该人格):

json
{
  "version": 1,
  "name": "骰子检定",
  "entries": [
    {
      "title": "在用户通道显示本次骰点并写入状态",
      "search": "$",
      "replace": "\n\n*本次骰点:{{expr RandomInt(1, 6)}}*{{set last_roll=RandomInt(1, 6)}}",
      "sourceType": "user",
      "targetChannels": ["user"]
    },
    {
      "title": "给 AI 注入 1 到 100 的隐藏检定值",
      "search": "$",
      "replace": "\n\nsystem: 本轮隐藏检定值为 {{expr RandomInt(1, 100)}}。80~100 视为大成功,1~20 视为严重受挫,其余按中间状态自然描写。",
      "sourceType": "user",
      "targetChannels": ["character"]
    }
  ]
}

工作原理:

  • {{expr ...}} 用来把表达式结果直接渲染成文本。
  • RandomInt(1, 6)RandomInt(1, 100) 都是闭区间整数
  • 同一条消息里的 Random 稳定,所以同一条模板里重复写 RandomInt(1, 6) 得到的点数会一致;这里显示出来的骰点与 last_roll 写入值会保持一致。
  • 这条规则没有在 {{set last_roll=...}} 后面再读 {{last_roll}},结构上更直观,也更方便排查状态写入与文本渲染。

示例 5:内心独白只对发送者本人可见

需求:群聊里中文括号 (...) 表示内心独白,应该只让说话者本人看到,其他角色看不到(用于猜疑推理类玩法)。

仅消息规则,不需要新变量。

json
{
  "version": 1,
  "name": "内心独白隔离",
  "entries": [
    {
      "title": "括号内独白",
      "search": "([\\s\\S]*?)",
      "replace": "{{if SenderName == ReaderName}}$0{{end}}",
      "sourceType": "character",
      "targetChannels": ["character"]
    }
  ]
}

工作原理:发到 LLM 的角色通道时,按当前正在读这条消息的角色 displayName 与发送者比较;不是同一人就把整段独白替换为空字符串。用户通道不处理,所以用户仍然能看到所有角色的独白(导演视角)。

示例 6:选项菜单

需求:让角色每次发言末尾给 1~4 个选项,渲染成可点击链接,用户点击后直接发送对应消息。

仅消息规则,不需要新变量。但必须在人格提示词里指示 AI 输出列表项格式

【选项】
在每次输出最后**必须**额外增加一条选项消息,格式为:
- 选项内容1
- 选项内容2
- 选项内容3
json
{
  "version": 1,
  "name": "选项菜单",
  "entries": [
    {
      "title": "列表项转可点击链接",
      "search": "- (.+)",
      "replace": "- [$1](rengeguan://chat/send?text=$1)",
      "sourceType": "character",
      "targetChannels": ["user"]
    }
  ]
}

链接 rengeguan://chat/send?text=... 是软件内置协议,点击后会直接发送对应文本作为用户消息。

示例 7:现实时间注入与每条消息显示当前好感度

需求:每条用户消息发送给 LLM 时附带现实时间,且在用户通道也显示当前好感度(结合示例 1)。

复用示例 1 的变量字段 JSON。消息规则可以叠加:

json
{
  "version": 1,
  "name": "现实时间与状态",
  "entries": [
    {
      "title": "在每条消息开头注入时间",
      "search": "^",
      "replace": "[{{MessageDateTime}}] ",
      "sourceType": "user",
      "targetChannels": ["character"]
    },
    {
      "title": "用户消息底部显示好感度",
      "search": "$",
      "replace": "\n\n*当前好感度:{{affection}}*",
      "sourceType": "user",
      "targetChannels": ["user"]
    }
  ]
}

示例 8:中文括号自动转斜体

需求:将 AI 输出的中文括号 (...) 自动转为 Markdown 斜体 *...*,并且让模型也能在下次会话里看到斜体格式(学到这个习惯)。

仅消息规则。

json
{
  "version": 1,
  "name": "括号转斜体",
  "entries": [
    {
      "title": "中文括号转斜体",
      "search": "(?<!\\*)(.*?)(?!\\*)",
      "replace": " *$0* ",
      "sourceType": "character",
      "targetChannels": ["user", "character"]
    }
  ]
}

(?<!\*)(?!\*) 是断言,避免在已经是斜体的内容外围再加一层。两边的空格用于避免和相邻文字粘连导致 Markdown 斜体失效。

示例 9:好感度阈值一次性事件(when 用法)

需求:当 affection >= 80 时,只触发一次特殊剧情提示词,之后即使好感继续超过 80 也不再注入。

json
{
  "version": 1,
  "fields": [
    {
      "id": "affection",
      "displayName": "好感度",
      "type": "number",
      "minValue": 0,
      "maxValue": 100,
      "defaultValue": 50
    },
    {
      "id": "affection_event_high",
      "displayName": "已触发高好感事件",
      "type": "boolean",
      "defaultValue": false
    }
  ]
}
json
{
  "version": 1,
  "name": "好感度阈值事件",
  "entries": [
    {
      "title": "好感达到 80 标记事件",
      "search": "$",
      "replace": "{{if affection >= 80 && affection_event_high == false}}{{set affection_event_high=true}}{{end}}",
      "sourceType": "character",
      "targetChannels": ["user"]
    },
    {
      "title": "事件触发时注入提示词(仅一次)",
      "search": "$",
      "replace": "{{when affection_event_high == true}}\n\nsystem: 此刻她对用户的感情终于翻越某个临界点,请描写一段她突然主动靠近的细节剧情{{end}}",
      "sourceType": "user",
      "targetChannels": ["character"]
    }
  ]
}

工作原理与关键点

  • 第一条规则的 if 限制:affection >= 80 && affection_event_high == false。这一句的意义是让 set 永远只发生一次——当 affection_event_high 已经为 true 时条件不再成立,set 不会再次执行。
  • 因为 affection_event_high 永远只被 set 为 true 一次(之后再也不会被写),它只会进入 whenFields 一次,第二条规则的 when 也就只会触发一次
  • 这就是 §5.12 提到的核心模式:when 真正"只触发一次"靠的是依赖字段不再被写入。如果改写成 {{when affection >= 80}}...{{end}} 直接依赖 affectionaffection 会被反复 set,when 就会反复触发——失去"一次性事件"语义。

示例 10:自定义指令 /save

需求:用户发送 /save 时,转换为一段要求 AI 总结当前会话的提示词。

仅消息规则。

json
{
  "version": 1,
  "name": "自定义指令",
  "entries": [
    {
      "title": "总结指令",
      "search": "^/save([\\s\\S]*)$",
      "replace": "$1\n\nsystem: 现在请直接输出当前会话的所有内容的完整详细总结",
      "sourceType": "user",
      "targetChannels": ["character"]
    }
  ]
}

^/save([\s\S]*)$ 抓取从 /save 到消息结尾的所有内容(包括换行);$1 保留用户后续可能附加的文本,再在末尾追加 system 指令。

示例 11:语音通道处理

需求:在 TTS 朗读时去除括号独白、去除 Markdown 斜体、纠正几个常见词的读音。

仅消息规则。

json
{
  "version": 1,
  "name": "语音通道净化",
  "entries": [
    {
      "title": "去除括号独白",
      "search": "[((].*?[))]",
      "replace": "",
      "sourceType": "character",
      "targetChannels": ["speech"]
    },
    {
      "title": "去除斜体独白",
      "search": "\\*.*?\\*",
      "replace": "",
      "sourceType": "character",
      "targetChannels": ["speech"]
    },
    {
      "title": "纠正 MyGO 读音",
      "search": "MyGO",
      "replace": "my go",
      "sourceType": "character",
      "targetChannels": ["speech"],
      "caseSensitive": false
    },
    {
      "title": "纠正 Tomorin 读音",
      "search": "Tomorin",
      "replace": "偷摸零",
      "sourceType": "character",
      "targetChannels": ["speech"]
    }
  ]
}

这些规则的 targetChannels 都只有 speech,所以不影响 UI 显示和发给 LLM 的上下文。

示例 12:条件截断当前回复

需求:当 AI 输出特殊结束标记 【终止】 时,当前消息与之后的本轮回复都丢弃。

json
{
  "version": 1,
  "name": "条件截断",
  "entries": [
    {
      "title": "命中终止标记后截断本轮输出",
      "search": "^【终止】$",
      "replace": "$0{{command stop}}{{command delete}}",
      "sourceType": "character",
      "targetChannels": ["user"],
      "caseSensitive": true,
      "global": false
    }
  ]
}

这条规则的含义是:当前消息不入库,并且停止本轮后续消息追加。

示例 13:只允许转义换行

需求:想把复杂条件模板写成多行排版,但只在显式 \n 处生成换行。

json
{
  "version": 1,
  "name": "只允许转义换行",
  "entries": [
    {
      "title": "多行排版模板",
      "search": "^状态$",
      "replace": "{{if phase == \"序章\"}}\n序章\\n继续推进\n{{else}}\n其它阶段\\n继续推进\n{{end}}",
      "sourceType": "user",
      "targetChannels": ["character"],
      "escapeNewlineOnly": true,
      "caseSensitive": true,
      "global": false
    }
  ]
}

开启后,模板中的真实换行\n只用于编辑排版;最终只有 \\n 会生效。


7. 生成自检清单

完成生成后,在交付前对照以下清单逐条检查:

7.1 字段定义层

  • [ ] 每个 id 都是小写字母 + 数字 + 下划线 + 字母开头
  • [ ] 没有 id 命中保留字(特别检查:expr user target random language match0 等)
  • [ ] 同一 fields 数组内 id 不重复
  • [ ] type 取值是 text / number / boolean / enum 之一(不是 enumeration
  • [ ] enum 类型有 enumOptionsdefaultValue 在选项列表中
  • [ ] number 类型如果有 minValue/maxValuedefaultValue 在范围内
  • [ ] displayName 都不为空
  • [ ] 没有把 randomint / floor / ceil / round 这类函数名拿来当字段 id

7.2 消息规则层

  • [ ] 每条规则的 search 是有效正则;JSON 内反斜杠都写成 \\
  • [ ] replace 中所有 {{if}} {{when}} 都有对应 {{end}}
  • [ ] {{expr}} 都写成 {{expr 表达式}},没有空表达式
  • [ ] {{set}}{{command}} 不要{{end}}
  • [ ] {{set field=...}}field 都已经在 fields 中定义
  • [ ] sourceType 取值是 user/character/system 之一
  • [ ] targetChannels 元素都是 user/character/system/speech
  • [ ] 字符串字面量都加了引号(如 phase == "序章"不是 phase == 序章
  • [ ] 中文枚举/字符串和 enumOptions 完全一致(区分大小写)
  • [ ] 若使用 RandomInt(min, max),确认 min/max 是整数且 min <= max

7.3 语义层

  • [ ] 用 {{when}} 的地方,依赖的字段确实会被 set/手动改写而触发,不是常态条件
  • [ ] 用 {{if}} 的地方是希望"每次读取都重新判断"的场景
  • [ ] 多通道规则里的 set 不会因为通道而重复(确认理解:set 只在 user 通道执行一次)
  • [ ] 若使用 {{command ...}},已经确认它只会按写入期 user 通道执行一次;如果不包含 user 通道,它不会生效
  • [ ] 若使用 {{command ...}}{{ReaderName}} 共存,已经确认这只是一次性的写入期 user 通道判定,不是“按不同读取者分别删除/截断”
  • [ ] 若想只在显式 \\n 处换行,已设置 "escapeNewlineOnly": true
  • [ ] 用 {{expr}} 的地方只是为了输出结果;如果需求是持久化数值,已额外配 {{set}}
  • [ ] 若同条模板同时包含 {{set X=...}}{{X}},已确认这里的文本语义应按最终状态理解,而不是依赖中间态;若想提高可读性,已评估是否拆成两条规则——见 §5.11
  • [ ] 真·一次性事件用了独立标志位:当用户需求是"达到某条件触发一次永不再触发"时,依赖 {{when}} 的字段必须是一个只会被 set 一次的标志位(如 xxx_event_done),而不是会反复变化的字段如 affection——见 §5.12
  • [ ] 群聊提示词或 system AI 提示词里没有{{when}} / {{set}} / {{command ...}}
  • [ ] 若使用 Random / RandomInt,位置确实在消息规则里,不是提示词模板(否则当前保存会直接报错)
  • [ ] 私聊场景不要用 sourceType: system
  • [ ] 私聊不要把 character 通道完全清空(会阻断角色回复)
  • [ ] 多次出现可能导致累加问题的规则(比如 【好感度上升】),评估是否需要把 "global": false 防止单条消息内多次累加
  • [ ] 没有把动态变量塞进人格提示词{{DateTime}} / {{affection}} / 依赖动态变量的 {{if}} 应通过消息规则注入到 character 通道,而不是写在人格提示词里——见 §3.10。如果用户明确要求写在提示词里,已附上缓存失效成本的提醒

7.4 交付物

  • [ ] 输出至少一份 JSON,每份独立放在 ```json 代码块中
  • [ ] 在 JSON 块前用一行中文说明导入位置
  • [ ] 在多份 JSON 之间用简短中文说明它们是配套的
  • [ ] 用户提到的所有需求都有对应规则覆盖,没有遗漏

附录:术语对照

UI 术语内部数据库字段 / 代码
变量字段Persona.stateFieldDefinitions / ChatGroup.stateFieldDefinitions
变量模式sessions.stateJson + whenFieldsJson + session_runtime_events
消息规则RegexEntry(数据库表里仍叫 regex
消息规则库RegexLibrary
群聊提示词ChatGroup.prompt
system AI 的额外提示词ChatGroup.groupSpeakerSmartPrompt
允许 system AI 改变量群聊专属开关(私聊永远关闭)