@riemannre3/dsh-roleplay 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +89 -0
- package/cordis.patch.yml +4 -0
- package/demo.png +0 -0
- package/lib/auxiliary-generation.js +68 -0
- package/lib/card-library.js +37 -0
- package/lib/card-runtime.js +363 -0
- package/lib/client.js +2327 -0
- package/lib/compatibility-call-runtime.js +34 -0
- package/lib/ejs-runtime.js +239 -0
- package/lib/ejs-worker.js +33 -0
- package/lib/frontend-runtime.js +352 -0
- package/lib/index.js +2998 -0
- package/lib/lifecycle.js +93 -0
- package/lib/mvu-session-control.js +70 -0
- package/lib/persona-runtime.js +60 -0
- package/lib/preset-runtime.js +222 -0
- package/lib/prompt-compiler.js +288 -0
- package/lib/rich-message.js +176 -0
- package/lib/session-runtime.js +324 -0
- package/lib/split-mvu.js +85 -0
- package/lib/variable-runtime.js +618 -0
- package/lib/worldbook.js +361 -0
- package/package.json +128 -0
- package/plugin-settings.png +0 -0
- package/runtime-assets/required/index.html +5 -0
- package/runtime-assets/required/weather-flags.json +7 -0
- package/runtime-assets/standalone/core.js +40 -0
- package/runtime-assets/standalone/index.html +15 -0
- package/runtime-assets/standalone/style.css +7 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const TAVERN_HELPER_COMPATIBILITY_VERSION = "4.9.3-dsh.1";
|
|
2
|
+
const catalog = [
|
|
3
|
+
{ surface: "TavernHelper", method: "getVariables", dshAction: "读取当前 Session 变量投影", effect: "只读" },
|
|
4
|
+
{ surface: "TavernHelper", method: "getAllVariables", dshAction: "读取当前 Session 的 stat_data 投影", effect: "只读" },
|
|
5
|
+
{ surface: "TavernHelper", method: "replaceVariables", dshAction: "原子替换当前 Session 变量状态", effect: "持久化写入" },
|
|
6
|
+
{ surface: "TavernHelper", method: "insertOrAssignVariables", dshAction: "合并后原子提交当前 Session 变量状态", effect: "持久化写入" },
|
|
7
|
+
{ surface: "TavernHelper", method: "getChatMessages", dshAction: "读取 DSH Session 消息与变量投影", effect: "只读" },
|
|
8
|
+
{ surface: "TavernHelper", method: "setChatMessages", dshAction: "将首条 assistant 消息的 swipe 映射到当前 Session 开场", effect: "持久化写入" },
|
|
9
|
+
{ surface: "TavernHelper", method: "generate", dshAction: "使用当前 Session 绑定生成辅助文本,不写入正式 Conversation", effect: "辅助模型生成" },
|
|
10
|
+
{ surface: "TavernHelper", method: "getWorldbook", dshAction: "读取当前卡绑定的世界书投影", effect: "只读" },
|
|
11
|
+
{ surface: "TavernHelper", method: "getWorldbookNames", dshAction: "读取当前卡世界书名称", effect: "只读" },
|
|
12
|
+
{ surface: "TavernHelper", method: "getCharWorldbookNames", dshAction: "读取当前卡世界书绑定", effect: "只读" },
|
|
13
|
+
{ surface: "TavernHelper", method: "updateWorldbookWith", dshAction: "提交当前 Session 的世界书启用覆盖", effect: "持久化写入" },
|
|
14
|
+
{ surface: "TavernHelper", method: "replaceWorldbook", dshAction: "提交当前 Session 的世界书启用覆盖", effect: "持久化写入" },
|
|
15
|
+
{ surface: "TavernHelper", method: "rebindCharWorldbooks", dshAction: "返回当前卡绑定投影,不改写卡片原件", effect: "本地执行" },
|
|
16
|
+
{ surface: "TavernHelper", method: "createWorldbookEntries", dshAction: "仅返回兼容结果;DSH 当前不创建卡片世界书条目", effect: "本地执行" },
|
|
17
|
+
{ surface: "TavernHelper", method: "eventOn", dshAction: "订阅当前隔离前端事件域", effect: "本地执行" },
|
|
18
|
+
{ surface: "TavernHelper", method: "eventEmit", dshAction: "发布当前隔离前端事件", effect: "本地执行" },
|
|
19
|
+
{ surface: "TavernHelper", method: "initializeGlobal", dshAction: "注册当前卡 companion 与消息 iframe 共享全局", effect: "本地执行" },
|
|
20
|
+
{ surface: "TavernHelper", method: "waitGlobalInitialized", dshAction: "等待当前卡隔离运行域共享全局", effect: "本地执行" },
|
|
21
|
+
{ surface: "TavernHelper", method: "triggerSlash", dshAction: "映射到 DSH 输入框草稿或正式消息提交", effect: "正式消息提交" },
|
|
22
|
+
{ surface: "MVU", method: "variables", dshAction: "读取当前 Session 变量投影", effect: "只读" },
|
|
23
|
+
{ surface: "MVU", method: "getMvuData", dshAction: "读取当前 Session 的 stat_data 投影", effect: "只读" },
|
|
24
|
+
{ surface: "MVU", method: "replaceMvuData", dshAction: "原子替换当前 Session 变量状态", effect: "持久化写入" },
|
|
25
|
+
{ surface: "SillyTavern", method: "getContext", dshAction: "读取当前 DSH Session 消息上下文投影", effect: "只读" },
|
|
26
|
+
{ surface: "SillyTavern", method: "sendMessage", dshAction: "向当前 DSH Session 提交正式玩家消息", effect: "正式消息提交" },
|
|
27
|
+
];
|
|
28
|
+
export function compatibilityCallCatalog() {
|
|
29
|
+
return catalog.map((item) => ({ ...item }));
|
|
30
|
+
}
|
|
31
|
+
export function describeCompatibilityCall(surface, method) {
|
|
32
|
+
const found = catalog.find((item) => item.surface === surface && item.method === method);
|
|
33
|
+
return found === undefined ? undefined : { ...found };
|
|
34
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
const nodeProcess = globalThis.process;
|
|
2
|
+
const WorkerConstructor = nodeProcess.getBuiltinModule("node:worker_threads").Worker;
|
|
3
|
+
const tag = /<%([_=#%-]?)([\s\S]*?)([_-]?)%>/gu;
|
|
4
|
+
const prohibitedCode = /\b(?:require|process|window|document|globalThis|eval|Function|fetch|XMLHttpRequest|WebSocket|import|localStorage|sessionStorage)\b/u;
|
|
5
|
+
const literalGetvar = /\bgetvar\s*\(\s*(["'])(.*?)\1/gu;
|
|
6
|
+
export function literalEjsVariableRoots(template) {
|
|
7
|
+
const roots = new Set();
|
|
8
|
+
for (const match of template.matchAll(new RegExp(literalGetvar.source, literalGetvar.flags))) {
|
|
9
|
+
const parts = String(match[2] ?? "")
|
|
10
|
+
.replace(/\[([^\]]+)\]/gu, ".$1")
|
|
11
|
+
.split(".")
|
|
12
|
+
.map((part) => part.replace(/^["']|["']$/gu, ""))
|
|
13
|
+
.filter(Boolean);
|
|
14
|
+
if (parts[0]?.toLocaleLowerCase() === "stat_data")
|
|
15
|
+
parts.shift();
|
|
16
|
+
const root = parts[0];
|
|
17
|
+
if (root !== undefined && !["__proto__", "prototype", "constructor"].includes(root))
|
|
18
|
+
roots.add(root);
|
|
19
|
+
}
|
|
20
|
+
return [...roots];
|
|
21
|
+
}
|
|
22
|
+
function runtimeError(code, message, cause) {
|
|
23
|
+
return Object.assign(new Error(message, cause === undefined ? undefined : { cause }), { code });
|
|
24
|
+
}
|
|
25
|
+
function compileTemplate(template) {
|
|
26
|
+
const parts = [
|
|
27
|
+
"(()=>{let __out='';const __append=value=>{if(value!==undefined&&value!==null)__out+=String(value)};",
|
|
28
|
+
];
|
|
29
|
+
let cursor = 0;
|
|
30
|
+
for (const match of template.matchAll(new RegExp(tag.source, tag.flags))) {
|
|
31
|
+
const index = match.index ?? cursor;
|
|
32
|
+
const open = match[1] ?? "";
|
|
33
|
+
const body = match[2] ?? "";
|
|
34
|
+
const close = match[3] ?? "";
|
|
35
|
+
let literal = template.slice(cursor, index);
|
|
36
|
+
if (open === "_")
|
|
37
|
+
literal = literal.replace(/\s+$/u, "");
|
|
38
|
+
if (literal.length > 0)
|
|
39
|
+
parts.push(`__append(${JSON.stringify(literal)});`);
|
|
40
|
+
if (open !== "#" && prohibitedCode.test(body))
|
|
41
|
+
throw runtimeError("ejs_invalid_template", "EJS 模板请求了未授权的宿主能力");
|
|
42
|
+
if (open === "=")
|
|
43
|
+
parts.push(`__append(__escape((${body})));`);
|
|
44
|
+
else if (open === "-")
|
|
45
|
+
parts.push(`__append((${body}));`);
|
|
46
|
+
else if (open === "#")
|
|
47
|
+
parts.push(";");
|
|
48
|
+
else if (open === "%")
|
|
49
|
+
parts.push(`__append(${JSON.stringify(`<%${body}${close}%>`)});`);
|
|
50
|
+
else
|
|
51
|
+
parts.push(`${body}\n`);
|
|
52
|
+
cursor = index + match[0].length;
|
|
53
|
+
if (close === "_") {
|
|
54
|
+
const whitespace = /^\s+/u.exec(template.slice(cursor));
|
|
55
|
+
cursor += whitespace?.[0].length ?? 0;
|
|
56
|
+
}
|
|
57
|
+
else if (close === "-") {
|
|
58
|
+
const newline = /^\r?\n/u.exec(template.slice(cursor));
|
|
59
|
+
cursor += newline?.[0].length ?? 0;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const tail = template.slice(cursor);
|
|
63
|
+
if (tail.length > 0)
|
|
64
|
+
parts.push(`__append(${JSON.stringify(tail)});`);
|
|
65
|
+
parts.push("return __out})()");
|
|
66
|
+
return parts.join("");
|
|
67
|
+
}
|
|
68
|
+
function batchProgram(templates, variables, context) {
|
|
69
|
+
const serializedVariables = JSON.stringify(variables);
|
|
70
|
+
if (serializedVariables === undefined)
|
|
71
|
+
throw runtimeError("ejs_invalid_template", "EJS 变量无法序列化");
|
|
72
|
+
const compiled = templates.map(compileTemplate).join(",\n");
|
|
73
|
+
return `(()=>{
|
|
74
|
+
const __state=JSON.parse(${JSON.stringify(serializedVariables)});
|
|
75
|
+
const __messageId=${Number.isFinite(context.messageId) ? Math.trunc(context.messageId) : -1};
|
|
76
|
+
const __seed=${JSON.stringify(context.seed ?? "dsh-re3-rp-ejs")};
|
|
77
|
+
const __missing=[];
|
|
78
|
+
const __parts=path=>String(path??'').replace(/\\[([^\\]]+)\\]/g,'.$1').split('.').map(part=>part.replace(/^['"]|['"]$/g,'')).filter(Boolean);
|
|
79
|
+
const __safe=parts=>parts.every(part=>part!=='__proto__'&&part!=='prototype'&&part!=='constructor');
|
|
80
|
+
const __escape=value=>String(value??'').replace(/[&<>"']/g,character=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character]));
|
|
81
|
+
let __randomState=2166136261;for(const character of __seed){__randomState^=character.codePointAt(0)??0;__randomState=Math.imul(__randomState,16777619)}
|
|
82
|
+
Math.random=()=>{__randomState+=0x6D2B79F5;let value=__randomState;value=Math.imul(value^(value>>>15),value|1);value^=value+Math.imul(value^(value>>>7),value|61);return((value^(value>>>14))>>>0)/4294967296};
|
|
83
|
+
function getvar(path,options={}){const original=String(path??'');const parts=__parts(path);if(parts[0]?.toLowerCase()==='stat_data')parts.shift();if(!__safe(parts)){__missing.push(original);return options?.defaults}let current=__state;for(const part of parts){if(current===null||current===undefined||!Object.prototype.hasOwnProperty.call(Object(current),part)){__missing.push(original);return options?.defaults}current=current[part]}return current}
|
|
84
|
+
function getLastMessageId(){return __messageId}
|
|
85
|
+
const __outputs=[${compiled}];return {outputs:__outputs,missingVariables:Array.from(new Set(__missing))}
|
|
86
|
+
})()`;
|
|
87
|
+
}
|
|
88
|
+
export async function createEjsRuntime(options = {}) {
|
|
89
|
+
const deadlineMs = Math.max(5, Math.min(5_000, options.deadlineMs ?? 50));
|
|
90
|
+
const memoryLimitBytes = Math.max(4 * 1024 * 1024, Math.min(256 * 1024 * 1024, options.memoryLimitBytes ?? 32 * 1024 * 1024));
|
|
91
|
+
const maxStackSizeBytes = Math.max(128 * 1024, Math.min(4 * 1024 * 1024, options.maxStackSizeBytes ?? 512 * 1024));
|
|
92
|
+
const maximumInputBytes = Math.max(64 * 1024, Math.min(64 * 1024 * 1024, options.maximumInputBytes ?? 256 * 1024));
|
|
93
|
+
const maximumOutputBytes = Math.max(64 * 1024, Math.min(64 * 1024 * 1024, options.maximumOutputBytes ?? 1024 * 1024));
|
|
94
|
+
const bytes = (value) => new TextEncoder().encode(value).byteLength;
|
|
95
|
+
let worker;
|
|
96
|
+
let nextRequestId = 0;
|
|
97
|
+
let queue = Promise.resolve();
|
|
98
|
+
let disposed = false;
|
|
99
|
+
const terminateWorker = async (target) => {
|
|
100
|
+
if (target === undefined)
|
|
101
|
+
return;
|
|
102
|
+
if (worker === target)
|
|
103
|
+
worker = undefined;
|
|
104
|
+
try {
|
|
105
|
+
await target.terminate();
|
|
106
|
+
}
|
|
107
|
+
catch { /* Worker may already have crashed. */ }
|
|
108
|
+
};
|
|
109
|
+
const ensureWorker = () => {
|
|
110
|
+
if (disposed)
|
|
111
|
+
throw runtimeError("ejs_render_failed", "EJS runtime 已关闭");
|
|
112
|
+
if (worker !== undefined)
|
|
113
|
+
return worker;
|
|
114
|
+
const created = new WorkerConstructor(new URL("./ejs-worker.js", import.meta.url), {
|
|
115
|
+
// `node --input-type=module -e` is useful for release probes, but the
|
|
116
|
+
// flag is invalid for a file-backed Worker and must not be inherited.
|
|
117
|
+
execArgv: [],
|
|
118
|
+
});
|
|
119
|
+
worker = created;
|
|
120
|
+
// Request listeners are intentionally short-lived. These lifecycle
|
|
121
|
+
// listeners remain attached so an idle crash is never an unhandled Worker
|
|
122
|
+
// error and the next render never reuses a dead cached Worker.
|
|
123
|
+
created.on("error", () => { if (worker === created)
|
|
124
|
+
worker = undefined; });
|
|
125
|
+
created.on("exit", () => { if (worker === created)
|
|
126
|
+
worker = undefined; });
|
|
127
|
+
// A forgotten plugin disposer must never keep the DSH process alive.
|
|
128
|
+
created.unref();
|
|
129
|
+
return created;
|
|
130
|
+
};
|
|
131
|
+
const runProgramNow = async (program) => {
|
|
132
|
+
const target = ensureWorker();
|
|
133
|
+
const id = ++nextRequestId;
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
let settled = false;
|
|
136
|
+
const settle = (handler) => {
|
|
137
|
+
if (settled)
|
|
138
|
+
return;
|
|
139
|
+
settled = true;
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
target.off("message", onMessage);
|
|
142
|
+
target.off("error", onError);
|
|
143
|
+
target.off("exit", onExit);
|
|
144
|
+
handler();
|
|
145
|
+
};
|
|
146
|
+
const onMessage = (response) => {
|
|
147
|
+
if (response?.id !== id)
|
|
148
|
+
return;
|
|
149
|
+
settle(() => {
|
|
150
|
+
if (response.ok === true)
|
|
151
|
+
resolve(response.result);
|
|
152
|
+
else
|
|
153
|
+
reject(runtimeError("ejs_render_failed", `EJS 渲染失败:${String(response.error?.message ?? "worker error")}`));
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
const onError = (cause) => settle(() => {
|
|
157
|
+
void terminateWorker(target);
|
|
158
|
+
reject(runtimeError("ejs_render_failed", `EJS Worker 崩溃:${cause.message}`, cause));
|
|
159
|
+
});
|
|
160
|
+
const onExit = (code) => settle(() => {
|
|
161
|
+
void terminateWorker(target);
|
|
162
|
+
reject(runtimeError("ejs_render_failed", `EJS Worker 异常退出(${code})`));
|
|
163
|
+
});
|
|
164
|
+
const timer = setTimeout(() => settle(() => {
|
|
165
|
+
void terminateWorker(target);
|
|
166
|
+
reject(runtimeError("ejs_timeout", "EJS 执行超过墙钟时间边界"));
|
|
167
|
+
}), Math.max(100, deadlineMs + 100));
|
|
168
|
+
target.on("message", onMessage);
|
|
169
|
+
target.once("error", onError);
|
|
170
|
+
target.once("exit", onExit);
|
|
171
|
+
target.postMessage({ id, program, deadlineMs, memoryLimitBytes, maxStackSizeBytes });
|
|
172
|
+
});
|
|
173
|
+
};
|
|
174
|
+
const runProgram = (program) => {
|
|
175
|
+
const scheduled = queue.then(() => runProgramNow(program), () => runProgramNow(program));
|
|
176
|
+
queue = scheduled.catch(() => undefined);
|
|
177
|
+
return scheduled;
|
|
178
|
+
};
|
|
179
|
+
const runtime = {
|
|
180
|
+
async render(templates, variables, context = {}) {
|
|
181
|
+
if (templates.some((template) => typeof template !== "string"))
|
|
182
|
+
throw runtimeError("ejs_invalid_template", "EJS 模板必须是字符串");
|
|
183
|
+
const inputBytes = templates.reduce((total, template) => total + bytes(template), 0);
|
|
184
|
+
if (inputBytes > maximumInputBytes)
|
|
185
|
+
throw runtimeError("ejs_invalid_template", `EJS 模板超过 ${maximumInputBytes} bytes 安全边界`);
|
|
186
|
+
// Each evalCode call inside the worker creates a fresh QuickJS
|
|
187
|
+
// runtime/context. Keeping the WASM host in a terminable Worker also
|
|
188
|
+
// contains implementation-level aborts instead of risking the DSH loop.
|
|
189
|
+
const rendered = [];
|
|
190
|
+
for (const [index, template] of templates.entries()) {
|
|
191
|
+
const program = batchProgram([template], variables, { ...context, seed: `${context.seed ?? "dsh-re3-rp-ejs"}:${index}` });
|
|
192
|
+
let result;
|
|
193
|
+
try {
|
|
194
|
+
result = await runProgram(program);
|
|
195
|
+
}
|
|
196
|
+
catch (cause) {
|
|
197
|
+
const message = cause instanceof Error
|
|
198
|
+
? cause.message
|
|
199
|
+
: typeof cause === "object" && cause !== null && typeof cause.message === "string"
|
|
200
|
+
? cause.message
|
|
201
|
+
: String(cause);
|
|
202
|
+
if (/interrupted/iu.test(message))
|
|
203
|
+
throw runtimeError("ejs_timeout", "EJS 执行超过时间边界", cause);
|
|
204
|
+
if (/memory|allocation|out of memory/iu.test(message))
|
|
205
|
+
throw runtimeError("ejs_memory_limit", "EJS 执行超过内存边界", cause);
|
|
206
|
+
throw runtimeError("ejs_render_failed", `EJS 渲染失败:${message}`, cause);
|
|
207
|
+
}
|
|
208
|
+
const resultObject = typeof result === "object" && result !== null ? result : undefined;
|
|
209
|
+
if (!Array.isArray(resultObject?.outputs) || resultObject.outputs.length !== 1 || typeof resultObject.outputs[0] !== "string") {
|
|
210
|
+
throw runtimeError("ejs_render_failed", "EJS 渲染结果形状无效");
|
|
211
|
+
}
|
|
212
|
+
if (Array.isArray(resultObject.missingVariables) && context.missingVariables !== undefined) {
|
|
213
|
+
context.missingVariables.push(...resultObject.missingVariables.filter((value) => typeof value === "string"));
|
|
214
|
+
}
|
|
215
|
+
rendered.push(resultObject.outputs[0]);
|
|
216
|
+
}
|
|
217
|
+
const outputBytes = rendered.reduce((total, value) => total + bytes(value), 0);
|
|
218
|
+
if (outputBytes > maximumOutputBytes)
|
|
219
|
+
throw runtimeError("ejs_memory_limit", `EJS 输出超过 ${maximumOutputBytes} bytes 安全边界`);
|
|
220
|
+
if (rendered.some((value) => /<%|%>/u.test(value)))
|
|
221
|
+
throw runtimeError("ejs_unresolved", "EJS 渲染后仍有未解析标签");
|
|
222
|
+
return rendered;
|
|
223
|
+
},
|
|
224
|
+
async dispose() {
|
|
225
|
+
disposed = true;
|
|
226
|
+
await terminateWorker(worker);
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
try {
|
|
230
|
+
const selfTest = await runtime.render(["<%= 6 * 7 %>"], {}, { seed: "dsh-re3-rp-ejs-self-test" });
|
|
231
|
+
if (selfTest[0] !== "42")
|
|
232
|
+
throw runtimeError("ejs_render_failed", "EJS runtime 自检结果错误");
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
await runtime.dispose();
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
return runtime;
|
|
239
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import variant from "@jitl/quickjs-wasmfile-release-sync";
|
|
2
|
+
import { newQuickJSWASMModuleFromVariant, shouldInterruptAfterDeadline } from "quickjs-emscripten-core";
|
|
3
|
+
const parentPort = globalThis.process.getBuiltinModule("node:worker_threads").parentPort;
|
|
4
|
+
if (parentPort === null)
|
|
5
|
+
throw new Error("EJS QuickJS worker 缺少 parentPort");
|
|
6
|
+
const quickJS = await newQuickJSWASMModuleFromVariant(variant);
|
|
7
|
+
parentPort.on("message", (request) => {
|
|
8
|
+
let response;
|
|
9
|
+
try {
|
|
10
|
+
response = {
|
|
11
|
+
id: request.id,
|
|
12
|
+
ok: true,
|
|
13
|
+
result: quickJS.evalCode(request.program, {
|
|
14
|
+
memoryLimitBytes: request.memoryLimitBytes,
|
|
15
|
+
maxStackSizeBytes: request.maxStackSizeBytes,
|
|
16
|
+
shouldInterrupt: shouldInterruptAfterDeadline(Date.now() + request.deadlineMs),
|
|
17
|
+
}),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
catch (cause) {
|
|
21
|
+
const message = cause instanceof Error
|
|
22
|
+
? cause.message
|
|
23
|
+
: typeof cause === "object" && cause !== null
|
|
24
|
+
? JSON.stringify(cause)
|
|
25
|
+
: String(cause);
|
|
26
|
+
response = {
|
|
27
|
+
id: request.id,
|
|
28
|
+
ok: false,
|
|
29
|
+
error: { message },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
parentPort.postMessage(response);
|
|
33
|
+
});
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
const CASE_CONTAINERS = {
|
|
2
|
+
"html-css-display": { runtimeClass: "message_html_css", container: "message-html" },
|
|
3
|
+
"message-action": { runtimeClass: "message_iframe", container: "message-iframe" },
|
|
4
|
+
"background-state-panel": { runtimeClass: "background_script_and_message_iframe", container: "message-iframe" },
|
|
5
|
+
"standalone-host-bridge": { runtimeClass: "standalone_app", container: "standalone" },
|
|
6
|
+
"required-remote-asset": { runtimeClass: "cross_origin_required_asset", container: "required-asset" },
|
|
7
|
+
};
|
|
8
|
+
const MIXED_MESSAGE_CASES = {
|
|
9
|
+
"opening-inline-action": { runtimeClass: "formal_message_mixed_projection", capabilities: ["submit_turn"] },
|
|
10
|
+
"generated-multi-fragment": { runtimeClass: "generated_multi_fragment_projection", capabilities: [] },
|
|
11
|
+
};
|
|
12
|
+
function object(value) {
|
|
13
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : undefined;
|
|
14
|
+
}
|
|
15
|
+
function stringArray(value) {
|
|
16
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
17
|
+
}
|
|
18
|
+
function regexDepth(value, minimum) {
|
|
19
|
+
return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= minimum ? value : null;
|
|
20
|
+
}
|
|
21
|
+
export function frontendDefinitionFromExtensions(value) {
|
|
22
|
+
const extensions = object(value);
|
|
23
|
+
const suiteId = typeof extensions?.suite_id === "string" ? extensions.suite_id : "";
|
|
24
|
+
const caseId = typeof extensions?.suite_case_id === "string" ? extensions.suite_case_id : "";
|
|
25
|
+
const cardId = typeof extensions?.card_id === "string" ? extensions.card_id : "";
|
|
26
|
+
const fixed = CASE_CONTAINERS[caseId];
|
|
27
|
+
const mixed = MIXED_MESSAGE_CASES[caseId];
|
|
28
|
+
if (cardId.length === 0)
|
|
29
|
+
return undefined;
|
|
30
|
+
const declaredRuntime = typeof extensions?.runtime_class === "string" ? extensions.runtime_class : "";
|
|
31
|
+
if (suiteId === "tavern-mixed-message" && mixed !== undefined && declaredRuntime === mixed.runtimeClass) {
|
|
32
|
+
return {
|
|
33
|
+
suiteId,
|
|
34
|
+
caseId,
|
|
35
|
+
cardId,
|
|
36
|
+
runtimeClass: mixed.runtimeClass,
|
|
37
|
+
container: "message-iframe",
|
|
38
|
+
requiredCapabilities: mixed.capabilities,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (suiteId !== "frontend-runtime" || fixed === undefined || declaredRuntime !== fixed.runtimeClass)
|
|
42
|
+
return undefined;
|
|
43
|
+
return {
|
|
44
|
+
suiteId,
|
|
45
|
+
caseId,
|
|
46
|
+
cardId,
|
|
47
|
+
runtimeClass: fixed.runtimeClass,
|
|
48
|
+
container: fixed.container,
|
|
49
|
+
requiredCapabilities: stringArray(extensions?.required_capabilities),
|
|
50
|
+
...(typeof extensions?.frontend_entry === "string" ? { frontendEntry: extensions.frontend_entry } : {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function bridgeCapabilities(definition) {
|
|
54
|
+
const common = ["connect", "projection.read", "events.read"];
|
|
55
|
+
if (definition.caseId === "message-action" || (definition.suiteId === "tavern-mixed-message" && definition.caseId === "opening-inline-action"))
|
|
56
|
+
return [...common, "turn.submit"];
|
|
57
|
+
if (definition.caseId === "background-state-panel")
|
|
58
|
+
return [...common, "state.submit", "state.subscribe"];
|
|
59
|
+
if (definition.caseId === "standalone-host-bridge")
|
|
60
|
+
return [...common, "turn.submit", "events.subscribe"];
|
|
61
|
+
if (definition.caseId === "required-remote-asset")
|
|
62
|
+
return [...common, "asset.resolve"];
|
|
63
|
+
return common;
|
|
64
|
+
}
|
|
65
|
+
function parseRegexLiteral(value) {
|
|
66
|
+
if (!value.startsWith("/"))
|
|
67
|
+
return value.length === 0 ? undefined : { pattern: value, flags: "g" };
|
|
68
|
+
let closing = -1;
|
|
69
|
+
for (let index = value.length - 1; index > 0; index -= 1) {
|
|
70
|
+
if (value[index] !== "/")
|
|
71
|
+
continue;
|
|
72
|
+
let escapes = 0;
|
|
73
|
+
for (let cursor = index - 1; cursor >= 0 && value[cursor] === "\\"; cursor -= 1)
|
|
74
|
+
escapes += 1;
|
|
75
|
+
if (escapes % 2 === 0) {
|
|
76
|
+
closing = index;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (closing < 1)
|
|
81
|
+
return undefined;
|
|
82
|
+
const flags = value.slice(closing + 1);
|
|
83
|
+
if (!/^[dgimsuvy]*$/u.test(flags))
|
|
84
|
+
return undefined;
|
|
85
|
+
return { pattern: value.slice(1, closing), flags };
|
|
86
|
+
}
|
|
87
|
+
export function messageRegexScriptsFromExtensions(value) {
|
|
88
|
+
const extensions = object(value);
|
|
89
|
+
const values = Array.isArray(extensions?.regex_scripts) ? extensions.regex_scripts : Array.isArray(extensions?.regex) ? extensions.regex : [];
|
|
90
|
+
return values.flatMap((entry, index) => {
|
|
91
|
+
const script = object(entry);
|
|
92
|
+
// SillyTavern runs a rule in the Markdown/display pass whenever
|
|
93
|
+
// markdownOnly is true, even if promptOnly is also true. Empty replacement
|
|
94
|
+
// strings are meaningful hide rules and must not be discarded.
|
|
95
|
+
if (script === undefined || script.disabled === true || script.markdownOnly !== true)
|
|
96
|
+
return [];
|
|
97
|
+
const placements = Array.isArray(script.placement) ? script.placement.filter((item) => Number.isInteger(item)) : [];
|
|
98
|
+
if (!placements.includes(2))
|
|
99
|
+
return [];
|
|
100
|
+
const source = typeof script.findRegex === "string" ? script.findRegex : typeof script.find_regex === "string" ? script.find_regex : "";
|
|
101
|
+
const parsed = parseRegexLiteral(source);
|
|
102
|
+
const replacement = typeof script.replaceString === "string" ? script.replaceString : typeof script.replace_string === "string" ? script.replace_string : "";
|
|
103
|
+
if (parsed === undefined)
|
|
104
|
+
return [];
|
|
105
|
+
try {
|
|
106
|
+
new RegExp(parsed.pattern, parsed.flags);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
return [{
|
|
112
|
+
id: typeof script.id === "string" ? script.id : `regex-${index}`,
|
|
113
|
+
name: typeof script.scriptName === "string" && script.scriptName.trim().length > 0 ? script.scriptName.trim() : `正则规则 ${index + 1}`,
|
|
114
|
+
pattern: parsed.pattern,
|
|
115
|
+
flags: parsed.flags,
|
|
116
|
+
replacement,
|
|
117
|
+
placements,
|
|
118
|
+
minDepth: regexDepth(script.minDepth, -1),
|
|
119
|
+
maxDepth: regexDepth(script.maxDepth, 0),
|
|
120
|
+
runOnEdit: script.runOnEdit !== false,
|
|
121
|
+
promptOnly: script.promptOnly === true,
|
|
122
|
+
}];
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
export function projectMessageRegex(text, scripts, macroValues, depth) {
|
|
126
|
+
const projected = scripts.reduce((value, script) => {
|
|
127
|
+
if (typeof depth === "number" && script.minDepth !== null && depth < script.minDepth)
|
|
128
|
+
return value;
|
|
129
|
+
if (typeof depth === "number" && script.maxDepth !== null && depth > script.maxDepth)
|
|
130
|
+
return value;
|
|
131
|
+
return value.replace(new RegExp(script.pattern, script.flags), (match, ...args) => {
|
|
132
|
+
const groups = typeof args.at(-1) === "object" ? args.at(-1) : undefined;
|
|
133
|
+
const captures = args.slice(0, groups === undefined ? -2 : -3);
|
|
134
|
+
// SillyTavern's Regex extension intentionally supports only $0/$1... and
|
|
135
|
+
// named $<group> references. Treating JavaScript's wider $&/$$/$`/$'
|
|
136
|
+
// replacement vocabulary as tokens corrupts bundled card scripts.
|
|
137
|
+
const replacement = script.replacement.replace(/\{\{match\}\}/giu, "$0");
|
|
138
|
+
const replaced = replacement.replace(/\$(\d+)|\$<([^>]+)>/gu, (_token, numeric, named) => {
|
|
139
|
+
const capture = numeric === undefined ? groups?.[named ?? ""] : Number(numeric) === 0 ? match : captures[Number(numeric) - 1];
|
|
140
|
+
return typeof capture === "string" ? capture : "";
|
|
141
|
+
});
|
|
142
|
+
if (replaced.length === 0)
|
|
143
|
+
return "";
|
|
144
|
+
const trimmed = replaced.trim();
|
|
145
|
+
const existingFence = /^```(?:html|text)?[ \t]*\r?\n([\s\S]*?)\r?\n```$/iu.exec(trimmed);
|
|
146
|
+
const candidate = existingFence?.[1] ?? replaced;
|
|
147
|
+
return /<(?:!doctype|html|head|body|style|script|div|section|details|button|[a-z][\w:-]*\b)/iu.test(candidate)
|
|
148
|
+
? `\n\n\`\`\`html\n${candidate}\n\`\`\`\n\n`
|
|
149
|
+
: replaced;
|
|
150
|
+
});
|
|
151
|
+
}, text);
|
|
152
|
+
return macroValues === undefined ? projected : substituteCardMacros(projected, macroValues);
|
|
153
|
+
}
|
|
154
|
+
export function stripInitvarForDisplay(text) {
|
|
155
|
+
return text
|
|
156
|
+
.replace(/<initvar\b[^>]*>[\s\S]*?<\/initvar>/giu, "")
|
|
157
|
+
.replace(/^(?:[ \t]*\r?\n)+/u, "")
|
|
158
|
+
.trimEnd();
|
|
159
|
+
}
|
|
160
|
+
// Card control protocols belong to the variable/runtime plane, not the
|
|
161
|
+
// player-visible conversation. Card Regex gets first refusal so an author can
|
|
162
|
+
// deliberately render a status panel; only residue that no card projection
|
|
163
|
+
// consumed is removed here.
|
|
164
|
+
export function stripAssistantControlForDisplay(text) {
|
|
165
|
+
const withoutReasoning = text.replace(/<(?:think|reasoning)\b[^>]*>[\s\S]*?<\/(?:think|reasoning)>/giu, "");
|
|
166
|
+
const withoutStandaloneAnalysis = withoutReasoning.replace(/<Analysis\b[^>]*>[\s\S]*?<\/Analysis>/giu, "");
|
|
167
|
+
const withoutUpdates = withoutStandaloneAnalysis.replace(/<UpdateVariable\b[^>]*>[\s\S]*?<\/UpdateVariable>/giu, "");
|
|
168
|
+
const opening = /^\s*\[开局\]([\s\S]*?)\[\/开局\]\s*$/u.exec(withoutUpdates);
|
|
169
|
+
const visible = opening === null ? withoutUpdates : opening[1] ?? "";
|
|
170
|
+
return visible.replace(/^(?:[ \t]*\r?\n)+/u, "").trimEnd();
|
|
171
|
+
}
|
|
172
|
+
export function stripHtmlFence(value) {
|
|
173
|
+
const trimmed = value.trim();
|
|
174
|
+
const fenced = /^```html\s*\n([\s\S]*?)\n```$/iu.exec(trimmed);
|
|
175
|
+
return fenced?.[1] ?? trimmed;
|
|
176
|
+
}
|
|
177
|
+
function bridgeBootstrap(sessionId) {
|
|
178
|
+
const encodedSession = JSON.stringify(sessionId).replace(/<\//gu, "<\\/");
|
|
179
|
+
return `<script>
|
|
180
|
+
(() => {
|
|
181
|
+
const sessionId = ${encodedSession};
|
|
182
|
+
const listeners = new Set();
|
|
183
|
+
const failure = (body, fallback) => Object.assign(new Error(body?.error?.message || body?.error || fallback), { code: body?.error?.code || fallback });
|
|
184
|
+
async function call(method, payload = {}) {
|
|
185
|
+
const response = await fetch('/dsh-re3-rp/bridge', {
|
|
186
|
+
method: 'POST',
|
|
187
|
+
headers: { 'content-type': 'application/json; charset=utf-8' },
|
|
188
|
+
body: JSON.stringify({ sessionId, method, payload, operationId: payload.operationId })
|
|
189
|
+
});
|
|
190
|
+
const body = await response.json().catch(() => ({}));
|
|
191
|
+
if (!response.ok || body.ok !== true) throw failure(body, 'bridge_unavailable');
|
|
192
|
+
return body.result;
|
|
193
|
+
}
|
|
194
|
+
window.__dshTavernSubmitHost = Object.freeze({
|
|
195
|
+
bridgeVersion: 'dsh-roleplay-v1',
|
|
196
|
+
submitTurn: payload => call('submitTurn', payload)
|
|
197
|
+
});
|
|
198
|
+
window.__dshTavernStateHost = Object.freeze({
|
|
199
|
+
bridgeVersion: 'dsh-roleplay-v1',
|
|
200
|
+
async getProjection() {
|
|
201
|
+
const projection = await call('getProjection');
|
|
202
|
+
return { ...projection.state, state_digest: projection.stateDigest };
|
|
203
|
+
},
|
|
204
|
+
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
|
|
205
|
+
async submitStateAction(payload) {
|
|
206
|
+
const result = await call('submitStateAction', payload);
|
|
207
|
+
const projection = { ...result.projection.state, state_digest: result.projection.stateDigest };
|
|
208
|
+
const event = { type: 'state_committed', operationId: payload.operationId, projection };
|
|
209
|
+
for (const listener of listeners) listener(event);
|
|
210
|
+
return event;
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
})();
|
|
214
|
+
</script>`;
|
|
215
|
+
}
|
|
216
|
+
export function adaptOpeningFrontendHtml(opening, sessionId, definition) {
|
|
217
|
+
let body = stripHtmlFence(opening);
|
|
218
|
+
if (definition.caseId === "message-action")
|
|
219
|
+
body = body.replaceAll("window.parent.frontendTestHost", "window.__dshTavernSubmitHost");
|
|
220
|
+
if (definition.caseId === "background-state-panel")
|
|
221
|
+
body = body.replaceAll("window.parent.frontendTestStateHost", "window.__dshTavernStateHost");
|
|
222
|
+
const bootstrap = definition.container === "message-iframe" ? bridgeBootstrap(sessionId) : "";
|
|
223
|
+
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body>${bootstrap}${body}</body></html>`;
|
|
224
|
+
}
|
|
225
|
+
function messageText(message) {
|
|
226
|
+
if (typeof message?.content === "string")
|
|
227
|
+
return message.content;
|
|
228
|
+
if (!Array.isArray(message?.content))
|
|
229
|
+
return "";
|
|
230
|
+
return message.content.filter((part) => part?.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n");
|
|
231
|
+
}
|
|
232
|
+
const CONVENTIONAL_STATUS_SLOT = "<StatusPlaceHolderImpl/>";
|
|
233
|
+
function isConventionalStatusFrontendScript(script) {
|
|
234
|
+
if (!script.pattern.includes("StatusPlaceHolderImpl"))
|
|
235
|
+
return false;
|
|
236
|
+
if (!/<(?:!doctype|html|head|body|style|script|div|section|details|button|[a-z][\w:-]*\b)/iu.test(script.replacement))
|
|
237
|
+
return false;
|
|
238
|
+
try {
|
|
239
|
+
return new RegExp(script.pattern, script.flags).test(CONVENTIONAL_STATUS_SLOT);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function hasConventionalStatusFrontend(scripts) {
|
|
246
|
+
return scripts.some(isConventionalStatusFrontendScript);
|
|
247
|
+
}
|
|
248
|
+
function assistantDisplayProjection(text, scripts, afterPlayerTurn) {
|
|
249
|
+
// TavernHelper cards use the same display slot for both inline MVU replies
|
|
250
|
+
// and split-step MVU replies whose secondary updater runs outside the prose
|
|
251
|
+
// response. For a DSH-synthesized slot, keep the rich status renderer while
|
|
252
|
+
// excluding validation/cleanup Regex that treat a missing inline block as an
|
|
253
|
+
// error. The formal Session reply remains byte-for-byte unchanged.
|
|
254
|
+
const hasVariableUpdate = /<UpdateVariable\b[^>]*>[\s\S]*?<\/UpdateVariable>/iu.test(text);
|
|
255
|
+
if (!afterPlayerTurn || text.includes(CONVENTIONAL_STATUS_SLOT) || !hasConventionalStatusFrontend(scripts)) {
|
|
256
|
+
return { source: text, scripts };
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
source: `${text}\n\n${CONVENTIONAL_STATUS_SLOT}`,
|
|
260
|
+
scripts: hasVariableUpdate
|
|
261
|
+
? scripts
|
|
262
|
+
: scripts.filter((script) => !script.pattern.includes("StatusPlaceHolderImpl") || isConventionalStatusFrontendScript(script)),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
export function projectFrontendMessages(session, regexScripts = [], macroValues) {
|
|
266
|
+
const nodes = Array.isArray(session?.surface?.nodes) ? session.surface.nodes : [];
|
|
267
|
+
const formalNodes = nodes.filter((seq) => {
|
|
268
|
+
const event = session?.events?.[seq];
|
|
269
|
+
if (event?.type !== "user/message" && event?.type !== "assistant/message")
|
|
270
|
+
return false;
|
|
271
|
+
const message = event.data?.message ?? event.data;
|
|
272
|
+
return message?.source?.kind !== "plugin" && messageText(message).length > 0;
|
|
273
|
+
});
|
|
274
|
+
return formalNodes.flatMap((seq, nodeIndex) => {
|
|
275
|
+
const event = session?.events?.[seq];
|
|
276
|
+
const message = event.data?.message ?? event.data;
|
|
277
|
+
const text = messageText(message);
|
|
278
|
+
const role = event.type === "user/message" ? "user" : "assistant";
|
|
279
|
+
const isCardOpening = (message?.source?.provider === "dsh-roleplay" || message?.source?.provider === "dsh-re3-rp") && message?.source?.model === "character-card-opening";
|
|
280
|
+
const afterPlayerTurn = role === "assistant" && !isCardOpening && formalNodes.slice(0, nodeIndex).some((candidateSeq) => session?.events?.[candidateSeq]?.type === "user/message");
|
|
281
|
+
const display = role === "assistant"
|
|
282
|
+
? assistantDisplayProjection(text, regexScripts, afterPlayerTurn)
|
|
283
|
+
: { source: text, scripts: regexScripts };
|
|
284
|
+
const depth = formalNodes.length - nodeIndex - 1;
|
|
285
|
+
const projected = role === "assistant"
|
|
286
|
+
? stripAssistantControlForDisplay(projectMessageRegex(stripInitvarForDisplay(display.source), display.scripts, macroValues, depth))
|
|
287
|
+
: text;
|
|
288
|
+
return [{ seq, role, text: projected, ...(projected === text ? {} : { rawText: text }) }];
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
export async function waitForCommittedFrontendTurn(options) {
|
|
292
|
+
const deadline = Date.now() + (options.timeoutMs ?? 10_000);
|
|
293
|
+
const pollIntervalMs = options.pollIntervalMs ?? 20;
|
|
294
|
+
do {
|
|
295
|
+
const messages = options.readMessages();
|
|
296
|
+
const user = messages.find((message) => message.seq > options.afterSeq && message.role === "user" && message.text === options.userText);
|
|
297
|
+
const assistant = messages.find((message) => message.seq > (user?.seq ?? Number.MAX_SAFE_INTEGER) && message.role === "assistant");
|
|
298
|
+
if (user !== undefined && assistant !== undefined) {
|
|
299
|
+
// Persist only after the complete turn is visible. Repeated flushes while
|
|
300
|
+
// rc.2 is still projecting the turn can contend with the agent's own
|
|
301
|
+
// session commit and delay the projection until this request returns.
|
|
302
|
+
await options.flush();
|
|
303
|
+
const durableMessages = options.readMessages();
|
|
304
|
+
const durableUser = durableMessages.find((message) => message.seq > options.afterSeq && message.role === "user" && message.text === options.userText);
|
|
305
|
+
const durableAssistant = durableMessages.find((message) => message.seq > (durableUser?.seq ?? Number.MAX_SAFE_INTEGER) && message.role === "assistant");
|
|
306
|
+
if (durableUser !== undefined && durableAssistant !== undefined)
|
|
307
|
+
return { user: durableUser, assistant: durableAssistant };
|
|
308
|
+
}
|
|
309
|
+
if (Date.now() >= deadline)
|
|
310
|
+
return undefined;
|
|
311
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
312
|
+
} while (true);
|
|
313
|
+
}
|
|
314
|
+
// DSH renders adjacent formal assistant messages as one assistant-step. Keep
|
|
315
|
+
// the formal Session messages separate everywhere else, but mirror that native
|
|
316
|
+
// grouping at the DOM projection seam so one verification/oracle message cannot
|
|
317
|
+
// disable rich rendering for the entire conversation.
|
|
318
|
+
export function groupFrontendMessagesForNativeFlow(messages) {
|
|
319
|
+
const grouped = [];
|
|
320
|
+
for (const message of messages) {
|
|
321
|
+
const previous = grouped.at(-1);
|
|
322
|
+
if (previous === undefined || previous.role !== message.role || message.role !== "assistant") {
|
|
323
|
+
grouped.push({ ...message });
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
const visible = [previous.text, message.text].filter((value) => value.length > 0).join("\n\n");
|
|
327
|
+
const requiresAdaptation = previous.rawText !== undefined || message.rawText !== undefined;
|
|
328
|
+
const raw = [previous.rawText ?? previous.text, message.rawText ?? message.text].filter((value) => value.length > 0).join("\n\n");
|
|
329
|
+
previous.text = visible;
|
|
330
|
+
if (requiresAdaptation)
|
|
331
|
+
previous.rawText = raw;
|
|
332
|
+
}
|
|
333
|
+
return grouped;
|
|
334
|
+
}
|
|
335
|
+
export function applyFrontendStateAction(caseId, state, payload) {
|
|
336
|
+
if (caseId !== "background-state-panel")
|
|
337
|
+
throw Object.assign(new Error("当前卡没有确定性状态动作权限"), { code: "capability_denied" });
|
|
338
|
+
if (payload.action !== "select_lamp_group" || (payload.value !== "main" && payload.value !== "backup")) {
|
|
339
|
+
throw Object.assign(new Error("只允许选择 main 或 backup 灯组"), { code: "capability_denied" });
|
|
340
|
+
}
|
|
341
|
+
return { ...state, lamp_group: payload.value };
|
|
342
|
+
}
|
|
343
|
+
export function frontendStateDigest(caseId, state) {
|
|
344
|
+
if (caseId === "background-state-panel")
|
|
345
|
+
return `lamp-group:${state.lamp_group ?? "main"}`;
|
|
346
|
+
return `frontend:${caseId}:empty`;
|
|
347
|
+
}
|
|
348
|
+
export function initialFrontendState(caseId) {
|
|
349
|
+
return caseId === "background-state-panel" ? { lamp_group: "main" } : {};
|
|
350
|
+
}
|
|
351
|
+
import { substituteCardMacros } from "./worldbook.js";
|
|
352
|
+
export { adaptRealCardFrontendHtml } from "./rich-message.js";
|