@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,288 @@
|
|
|
1
|
+
import { DEFAULT_TAVERN_PRESET } from "./preset-runtime.js";
|
|
2
|
+
import { placeWorldbook, substituteCardMacros } from "./worldbook.js";
|
|
3
|
+
import { renderPersonaPrompt } from "./persona-runtime.js";
|
|
4
|
+
export { DEFAULT_TAVERN_PRESET } from "./preset-runtime.js";
|
|
5
|
+
function clean(text) { return text.trim(); }
|
|
6
|
+
function formatWorldInfo(template, content) { return content.length === 0 ? "" : template.includes("{0}") ? template.replaceAll("{0}", content) : content; }
|
|
7
|
+
function resolveOutlets(text, outlets) { return text.replace(/\{\{outlet::([^{}]+)\}\}/gu, (_match, name) => outlets[name.trim()] ?? ""); }
|
|
8
|
+
function estimateMessageTokens(message) { return Math.ceil(message.content.length / 4) + 4; }
|
|
9
|
+
function sourceFor(definition, kind, extra = {}) {
|
|
10
|
+
return {
|
|
11
|
+
kind,
|
|
12
|
+
blockId: definition.marker ?? definition.identifier,
|
|
13
|
+
promptIdentifier: definition.identifier,
|
|
14
|
+
...(definition.marker === undefined ? {} : { marker: definition.marker }),
|
|
15
|
+
...extra,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function parseExamples(text, values, definition) {
|
|
19
|
+
const substituted = clean(substituteCardMacros(text, values));
|
|
20
|
+
if (substituted.length === 0)
|
|
21
|
+
return [];
|
|
22
|
+
const messages = [];
|
|
23
|
+
let current;
|
|
24
|
+
for (const rawLine of substituted.replaceAll("<START>", "").split(/\r?\n/gu)) {
|
|
25
|
+
const line = rawLine.trim();
|
|
26
|
+
if (line.length === 0)
|
|
27
|
+
continue;
|
|
28
|
+
const match = line.match(/^([^::]{1,80})[::]\s*(.*)$/u);
|
|
29
|
+
if (match !== null) {
|
|
30
|
+
const speaker = (match[1] ?? "").trim();
|
|
31
|
+
const resolvedRole = speaker === values.userName || /^user$/iu.test(speaker) ? "user" : speaker === values.characterName || /^char(?:acter)?$/iu.test(speaker) ? "assistant" : undefined;
|
|
32
|
+
if (resolvedRole !== undefined) {
|
|
33
|
+
current = { role: resolvedRole, content: match[2] ?? "", source: sourceFor(definition, "example") };
|
|
34
|
+
messages.push(current);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (current === undefined) {
|
|
39
|
+
current = { role: "system", content: line, source: sourceFor(definition, "example") };
|
|
40
|
+
messages.push(current);
|
|
41
|
+
}
|
|
42
|
+
else
|
|
43
|
+
current.content += `\n${line}`;
|
|
44
|
+
}
|
|
45
|
+
return messages;
|
|
46
|
+
}
|
|
47
|
+
function depthMessages(placement, chatDefinition) {
|
|
48
|
+
const grouped = new Map();
|
|
49
|
+
for (const entry of placement.entries.filter((candidate) => candidate.position === "at_depth")) {
|
|
50
|
+
const key = `${entry.depth}:${entry.role}`;
|
|
51
|
+
grouped.set(key, [...(grouped.get(key) ?? []), entry]);
|
|
52
|
+
}
|
|
53
|
+
return [...grouped.values()].map((entries) => ({
|
|
54
|
+
role: entries[0].role,
|
|
55
|
+
content: entries.map((entry) => entry.content).join("\n\n"),
|
|
56
|
+
source: sourceFor(chatDefinition, "worldbook", { entryIds: entries.map((entry) => entry.id), position: "at-depth", depth: entries[0].depth }),
|
|
57
|
+
})).sort((left, right) => (right.source.depth ?? 0) - (left.source.depth ?? 0) || ["system", "user", "assistant"].indexOf(left.role) - ["system", "user", "assistant"].indexOf(right.role));
|
|
58
|
+
}
|
|
59
|
+
function insertAtDepth(messages, injections) {
|
|
60
|
+
const roleOrder = { user: 0, assistant: 1, system: 2 };
|
|
61
|
+
const grouped = new Map();
|
|
62
|
+
for (const injection of injections) {
|
|
63
|
+
const index = Math.max(0, messages.length - Math.min(messages.length, injection.depth));
|
|
64
|
+
grouped.set(index, [...(grouped.get(index) ?? []), injection]);
|
|
65
|
+
}
|
|
66
|
+
for (const [index, rows] of [...grouped.entries()].sort((left, right) => right[0] - left[0])) {
|
|
67
|
+
rows.sort((left, right) => left.order - right.order || roleOrder[left.message.role] - roleOrder[right.message.role]);
|
|
68
|
+
messages.splice(index, 0, ...rows.map((row) => row.message));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function compileChatHistory(chat, placement, definition, inChatPrompts, values) {
|
|
72
|
+
const result = chat.map((message) => ({
|
|
73
|
+
role: message.role,
|
|
74
|
+
content: message.content,
|
|
75
|
+
source: sourceFor(definition, "chat", { sourceIndex: message.sourceIndex }),
|
|
76
|
+
}));
|
|
77
|
+
insertAtDepth(result, [
|
|
78
|
+
...depthMessages(placement, definition).map((message) => ({ message, depth: message.source.depth ?? 0, order: 100 })),
|
|
79
|
+
...inChatPrompts.flatMap((prompt) => {
|
|
80
|
+
const content = clean(resolveOutlets(substituteCardMacros(prompt.content, values), placement.outlets));
|
|
81
|
+
return content.length === 0 ? [] : [{ message: { role: prompt.role, content, source: sourceFor(prompt, "preset", { position: "in-chat", depth: prompt.injectionDepth }) }, depth: prompt.injectionDepth, order: prompt.injectionOrder }];
|
|
82
|
+
}),
|
|
83
|
+
]);
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
export function normalizeTavernChatMessages(messages) {
|
|
87
|
+
return messages.flatMap((message, sourceIndex) => {
|
|
88
|
+
if (message?.role !== "user" && message?.role !== "assistant")
|
|
89
|
+
return [];
|
|
90
|
+
if (message?.source?.kind === "plugin" && message.source.plugin !== "compact")
|
|
91
|
+
return [];
|
|
92
|
+
const content = typeof message.content === "string" ? message.content : Array.isArray(message.content) ? message.content.filter((part) => part?.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n") : "";
|
|
93
|
+
if (content.trim().length === 0 || /^\[DSH_RE3_RP_(?:WORLD_CONTEXT|ASSEMBLY)\]/u.test(content))
|
|
94
|
+
return [];
|
|
95
|
+
return [{ role: message.role, content, sourceIndex }];
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
function markerMessages(input) {
|
|
99
|
+
const { definition, card, placement, values } = input;
|
|
100
|
+
switch (definition.marker) {
|
|
101
|
+
case "main-prompt": {
|
|
102
|
+
const text = clean(substituteCardMacros(card.systemPrompt, values)) || clean(substituteCardMacros(definition.content, values)) || `你正在扮演 ${card.title}。请始终以角色身份自然地延续当前对话。`;
|
|
103
|
+
return [{ role: definition.role, content: text, source: sourceFor(definition, "preset") }];
|
|
104
|
+
}
|
|
105
|
+
case "world-info-before": {
|
|
106
|
+
const text = formatWorldInfo(input.worldInfoFormat, placement.beforeCharacter);
|
|
107
|
+
return text.length === 0 ? [] : [{ role: definition.role, content: text, source: sourceFor(definition, "worldbook", { entryIds: placement.entries.filter((entry) => entry.position === "before_char").map((entry) => entry.id), position: "before-char" }) }];
|
|
108
|
+
}
|
|
109
|
+
case "persona-description": {
|
|
110
|
+
const text = clean(substituteCardMacros(renderPersonaPrompt(values.userName, input.personaDescription ?? ""), values));
|
|
111
|
+
return text.length === 0 ? [] : [{ role: definition.role, content: text, source: sourceFor(definition, "preset") }];
|
|
112
|
+
}
|
|
113
|
+
case "character-description": {
|
|
114
|
+
const text = clean(substituteCardMacros(card.description, values));
|
|
115
|
+
return text.length === 0 ? [] : [{ role: definition.role, content: text, source: sourceFor(definition, "preset") }];
|
|
116
|
+
}
|
|
117
|
+
case "character-personality": {
|
|
118
|
+
const text = clean(substituteCardMacros(card.personality, values));
|
|
119
|
+
return text.length === 0 ? [] : [{ role: definition.role, content: text, source: sourceFor(definition, "preset") }];
|
|
120
|
+
}
|
|
121
|
+
case "scenario": {
|
|
122
|
+
const text = clean(substituteCardMacros(card.scenario, values));
|
|
123
|
+
return text.length === 0 ? [] : [{ role: definition.role, content: text, source: sourceFor(definition, "preset") }];
|
|
124
|
+
}
|
|
125
|
+
case "world-info-after": {
|
|
126
|
+
const text = formatWorldInfo(input.worldInfoFormat, placement.afterCharacter);
|
|
127
|
+
return text.length === 0 ? [] : [{ role: definition.role, content: text, source: sourceFor(definition, "worldbook", { entryIds: placement.entries.filter((entry) => entry.position === "after_char").map((entry) => entry.id), position: "after-char" }) }];
|
|
128
|
+
}
|
|
129
|
+
case "example-messages": {
|
|
130
|
+
const messages = [];
|
|
131
|
+
if (placement.beforeExamples.length > 0)
|
|
132
|
+
messages.push({ role: definition.role, content: placement.beforeExamples, source: sourceFor(definition, "worldbook", { entryIds: placement.entries.filter((entry) => entry.position === "before_examples").map((entry) => entry.id), position: "before-examples" }) });
|
|
133
|
+
messages.push(...parseExamples(card.messageExample, values, definition));
|
|
134
|
+
if (placement.afterExamples.length > 0)
|
|
135
|
+
messages.push({ role: definition.role, content: placement.afterExamples, source: sourceFor(definition, "worldbook", { entryIds: placement.entries.filter((entry) => entry.position === "after_examples").map((entry) => entry.id), position: "after-examples" }) });
|
|
136
|
+
return messages;
|
|
137
|
+
}
|
|
138
|
+
case "authors-note": {
|
|
139
|
+
const text = [placement.authorNoteTop, placement.authorNoteBottom].filter(Boolean).join("\n\n");
|
|
140
|
+
return text.length === 0 ? [] : [{ role: definition.role, content: text, source: sourceFor(definition, "worldbook", { entryIds: placement.entries.filter((entry) => entry.position === "an_top" || entry.position === "an_bottom").map((entry) => entry.id), position: "authors-note" }) }];
|
|
141
|
+
}
|
|
142
|
+
case "chat-history": return compileChatHistory(input.chat, placement, definition, input.inChatPrompts, values);
|
|
143
|
+
case "post-history-instructions": {
|
|
144
|
+
const text = clean(substituteCardMacros(card.postHistoryInstructions, values)) || clean(substituteCardMacros(definition.content, values));
|
|
145
|
+
return text.length === 0 ? [] : [{ role: definition.role, content: text, source: sourceFor(definition, "preset") }];
|
|
146
|
+
}
|
|
147
|
+
default: return [];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function trimToContext(messages, contextTokens) {
|
|
151
|
+
let current = messages;
|
|
152
|
+
let estimatedTokens = current.reduce((sum, message) => sum + estimateMessageTokens(message), 0);
|
|
153
|
+
let prunedChatMessages = 0;
|
|
154
|
+
let prunedExampleMessages = 0;
|
|
155
|
+
if (contextTokens === null)
|
|
156
|
+
return { messages: current, prunedChatMessages, prunedExampleMessages, estimatedTokens, contextExceeded: false };
|
|
157
|
+
if (estimatedTokens > contextTokens) {
|
|
158
|
+
const latestChatIndex = current.reduce((last, message, index) => message.source.kind === "chat" ? index : last, -1);
|
|
159
|
+
const removable = current.flatMap((message, index) => message.source.kind === "chat" && index !== latestChatIndex ? [index] : []);
|
|
160
|
+
const removed = new Set();
|
|
161
|
+
for (const index of removable) {
|
|
162
|
+
if (estimatedTokens <= contextTokens)
|
|
163
|
+
break;
|
|
164
|
+
estimatedTokens -= estimateMessageTokens(current[index]);
|
|
165
|
+
removed.add(index);
|
|
166
|
+
prunedChatMessages += 1;
|
|
167
|
+
}
|
|
168
|
+
current = current.filter((_message, index) => !removed.has(index));
|
|
169
|
+
}
|
|
170
|
+
if (estimatedTokens > contextTokens) {
|
|
171
|
+
const removed = new Set();
|
|
172
|
+
for (let index = 0; index < current.length; index += 1) {
|
|
173
|
+
if (estimatedTokens <= contextTokens)
|
|
174
|
+
break;
|
|
175
|
+
if (current[index].source.kind !== "example")
|
|
176
|
+
continue;
|
|
177
|
+
estimatedTokens -= estimateMessageTokens(current[index]);
|
|
178
|
+
removed.add(index);
|
|
179
|
+
prunedExampleMessages += 1;
|
|
180
|
+
}
|
|
181
|
+
current = current.filter((_message, index) => !removed.has(index));
|
|
182
|
+
}
|
|
183
|
+
return { messages: current, prunedChatMessages, prunedExampleMessages, estimatedTokens, contextExceeded: estimatedTokens > contextTokens };
|
|
184
|
+
}
|
|
185
|
+
export function compileTavernPrompt(input) {
|
|
186
|
+
const preset = input.preset ?? DEFAULT_TAVERN_PRESET;
|
|
187
|
+
const values = { userName: input.userName, characterName: input.card.title, messageVariables: input.messageVariables, localVariables: {}, macroSeed: input.macroSeed };
|
|
188
|
+
const placement = placeWorldbook(input.activation.active, values);
|
|
189
|
+
const definitions = new Map(preset.prompts.map((prompt) => [prompt.identifier, prompt]));
|
|
190
|
+
const enabled = new Map(preset.promptOrder.map((item) => [item.identifier, item.enabled]));
|
|
191
|
+
const inChatPrompts = preset.prompts.filter((prompt) => prompt.marker === undefined && prompt.injectionPosition === "in-chat" && enabled.get(prompt.identifier) === true);
|
|
192
|
+
const assembled = [];
|
|
193
|
+
for (const item of preset.promptOrder) {
|
|
194
|
+
if (!item.enabled)
|
|
195
|
+
continue;
|
|
196
|
+
const definition = definitions.get(item.identifier);
|
|
197
|
+
if (definition === undefined || definition.injectionPosition === "in-chat" && definition.marker === undefined)
|
|
198
|
+
continue;
|
|
199
|
+
if (definition.marker !== undefined)
|
|
200
|
+
assembled.push(...markerMessages({ definition, card: input.card, personaDescription: input.personaDescription, placement, chat: input.chat, inChatPrompts, values, worldInfoFormat: preset.worldInfoFormat }));
|
|
201
|
+
else {
|
|
202
|
+
const content = clean(resolveOutlets(substituteCardMacros(definition.content, values), placement.outlets));
|
|
203
|
+
if (content.length > 0)
|
|
204
|
+
assembled.push({ role: definition.role, content, source: sourceFor(definition, "preset") });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const trimmed = trimToContext(assembled, preset.settings.contextTokens);
|
|
208
|
+
const messages = trimmed.messages;
|
|
209
|
+
const blocks = preset.promptOrder.map((item) => {
|
|
210
|
+
const definition = definitions.get(item.identifier) ?? { identifier: item.identifier, name: item.identifier, role: "system", content: "", systemPrompt: false, injectionPosition: "relative", injectionDepth: 4, injectionOrder: 100, extra: {} };
|
|
211
|
+
const indexes = messages.flatMap((message, index) => message.source.promptIdentifier === item.identifier ? [index] : []);
|
|
212
|
+
const text = indexes.map((index) => messages[index].content).join("\n");
|
|
213
|
+
return {
|
|
214
|
+
id: definition.marker ?? definition.identifier,
|
|
215
|
+
promptIdentifier: definition.identifier,
|
|
216
|
+
...(definition.marker === undefined ? {} : { marker: definition.marker }),
|
|
217
|
+
label: definition.name,
|
|
218
|
+
enabled: item.enabled,
|
|
219
|
+
role: definition.role,
|
|
220
|
+
injectionPosition: definition.injectionPosition,
|
|
221
|
+
injectionDepth: definition.injectionDepth,
|
|
222
|
+
characterCount: text.length,
|
|
223
|
+
entryIds: [...new Set(indexes.flatMap((index) => messages[index].source.entryIds ?? []))],
|
|
224
|
+
messageIndexes: indexes,
|
|
225
|
+
preview: text.replace(/\s+/gu, " ").trim().slice(0, 180),
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
return {
|
|
229
|
+
preset: { id: preset.id, name: preset.name, source: preset.source, revision: preset.revision },
|
|
230
|
+
settings: { ...preset.settings },
|
|
231
|
+
messages,
|
|
232
|
+
blocks,
|
|
233
|
+
placement,
|
|
234
|
+
activation: input.activation,
|
|
235
|
+
stats: {
|
|
236
|
+
messageCount: messages.length,
|
|
237
|
+
characterCount: messages.reduce((sum, message) => sum + message.content.length, 0),
|
|
238
|
+
estimatedTokens: trimmed.estimatedTokens,
|
|
239
|
+
contextTokens: preset.settings.contextTokens,
|
|
240
|
+
prunedChatMessages: trimmed.prunedChatMessages,
|
|
241
|
+
prunedExampleMessages: trimmed.prunedExampleMessages,
|
|
242
|
+
contextExceeded: trimmed.contextExceeded,
|
|
243
|
+
activeWorldbookEntries: input.activation.active.length,
|
|
244
|
+
filteredWorldbookEntries: input.activation.trace.filter((row) => !row.activated).length,
|
|
245
|
+
depthInjections: placement.entries.filter((entry) => entry.position === "at_depth").length + inChatPrompts.length,
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
export function compiledTavernSystemPrompt(compiled) {
|
|
250
|
+
return compiled.messages.filter((message) => message.source.marker === "main-prompt").map((message) => message.content).join("\n\n");
|
|
251
|
+
}
|
|
252
|
+
export function renderTavernContextEnvelope(compiled) {
|
|
253
|
+
const labels = new Map(compiled.blocks.map((block) => [block.id, block.label]));
|
|
254
|
+
const sections = compiled.messages.flatMap((message) => {
|
|
255
|
+
if (message.source.marker === "main-prompt" || message.source.kind === "chat")
|
|
256
|
+
return [];
|
|
257
|
+
const label = message.source.position === "at-depth" ? `World Info · @Depth ${message.source.depth ?? 0}` : message.source.blockId === undefined ? "Prompt module" : labels.get(message.source.blockId) ?? "Prompt module";
|
|
258
|
+
const entrySuffix = (message.source.entryIds?.length ?? 0) > 0 ? ` · entries ${message.source.entryIds.join(", ")}` : "";
|
|
259
|
+
return [`[${label} · ${message.role}${entrySuffix}]\n${message.content}`];
|
|
260
|
+
});
|
|
261
|
+
return ["[DSH_RE3_RP_COMPILED_CONTEXT]", `Preset: ${compiled.preset.name}`, "The following sections are assembled roleplay context. Follow them without mentioning prompt modules, world info, presets, or this envelope.", ...sections].join("\n\n");
|
|
262
|
+
}
|
|
263
|
+
export function applyCompiledPromptToRequest(options, compiled) {
|
|
264
|
+
const messages = compiled.messages.map((message) => ({
|
|
265
|
+
id: crypto.randomUUID(),
|
|
266
|
+
role: message.role,
|
|
267
|
+
content: [{ type: "text", text: message.content }],
|
|
268
|
+
source: message.role === "assistant" ? { kind: "model", provider: "dsh-roleplay", model: "compiled-context" } : { kind: "plugin", plugin: "dsh-roleplay", form: "compiled", compiledSource: message.source },
|
|
269
|
+
}));
|
|
270
|
+
const leadingSystem = [];
|
|
271
|
+
while (messages[0]?.role === "system")
|
|
272
|
+
leadingSystem.push(messages.shift().content[0].text);
|
|
273
|
+
options.system = leadingSystem.join("\n\n");
|
|
274
|
+
options.messages = messages;
|
|
275
|
+
options.tools = [];
|
|
276
|
+
options.temperature = compiled.settings.temperature;
|
|
277
|
+
if (compiled.settings.maxReplyTokens !== null)
|
|
278
|
+
options.maxTokens = compiled.settings.maxReplyTokens;
|
|
279
|
+
options.tavernPresetRuntime = {
|
|
280
|
+
presetId: compiled.preset.id,
|
|
281
|
+
revision: compiled.preset.revision,
|
|
282
|
+
stream: compiled.settings.stream,
|
|
283
|
+
contextTokens: compiled.settings.contextTokens,
|
|
284
|
+
topP: compiled.settings.topP,
|
|
285
|
+
frequencyPenalty: compiled.settings.frequencyPenalty,
|
|
286
|
+
presencePenalty: compiled.settings.presencePenalty,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
const rawHtmlDocument = /^\s*<(?:!doctype|html|body|style|div|section|details)\b/iu;
|
|
2
|
+
const htmlFragment = /^\s*<(?:!doctype|[a-z][\w:-]*)(?:\s|>)[\s\S]*>\s*$/iu;
|
|
3
|
+
const fencedHtml = /```html\s*\r?\n([\s\S]*?)\r?\n```/giu;
|
|
4
|
+
const fencedUntypedHtmlDocument = /```[ \t]*\r?\n(\s*<(?:!doctype|html)\b[\s\S]*?)\r?\n```/giu;
|
|
5
|
+
const htmlTag = /<!--[\s\S]*?-->|<![^>]*>|<\/?([a-z][\w:-]*)\b(?:\s+(?:"[^"]*"|'[^']*'|[^'"<>])*)?\s*\/?>/giu;
|
|
6
|
+
const rawTextElement = /<(script|style|template)\b(?:\s+(?:"[^"]*"|'[^']*'|[^'"<>])*)?\s*>[\s\S]*?<\/\1\s*>/giu;
|
|
7
|
+
const voidHtmlTags = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
|
|
8
|
+
const crossBoundaryContainerTags = new Set(["article", "aside", "body", "details", "dialog", "div", "fieldset", "footer", "form", "header", "html", "main", "nav", "ol", "section", "table", "tbody", "tfoot", "thead", "ul"]);
|
|
9
|
+
function leavesOpenHtmlContainer(value) {
|
|
10
|
+
const stack = [];
|
|
11
|
+
const structuralHtml = value.replace(rawTextElement, "");
|
|
12
|
+
for (const match of structuralHtml.matchAll(new RegExp(htmlTag.source, htmlTag.flags))) {
|
|
13
|
+
const token = match[0];
|
|
14
|
+
const tag = match[1]?.toLowerCase();
|
|
15
|
+
if (tag === undefined || token.startsWith("<!--") || token.startsWith("<!"))
|
|
16
|
+
continue;
|
|
17
|
+
if (token.startsWith("</")) {
|
|
18
|
+
const matchingIndex = stack.lastIndexOf(tag);
|
|
19
|
+
if (matchingIndex >= 0)
|
|
20
|
+
stack.splice(matchingIndex);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (token.endsWith("/>") || voidHtmlTags.has(tag))
|
|
24
|
+
continue;
|
|
25
|
+
stack.push(tag);
|
|
26
|
+
}
|
|
27
|
+
return stack.some((tag) => crossBoundaryContainerTags.has(tag) || tag.includes("-"));
|
|
28
|
+
}
|
|
29
|
+
function escapeCrossBoundaryProse(value) {
|
|
30
|
+
// `<content>` is a common card control wrapper, not player prose. In the
|
|
31
|
+
// original shared message DOM the browser consumes it as markup; do the same
|
|
32
|
+
// without allowing arbitrary prose HTML to become executable in the iframe.
|
|
33
|
+
const visible = value
|
|
34
|
+
.replace(/(?:^|\r?\n)[ \t]*<\/?content\b[^>]*>[ \t]*(?=\r?\n|$)/giu, "\n")
|
|
35
|
+
.trim();
|
|
36
|
+
if (visible.length === 0)
|
|
37
|
+
return "";
|
|
38
|
+
const escaped = visible
|
|
39
|
+
.replaceAll("&", "&")
|
|
40
|
+
.replaceAll("<", "<")
|
|
41
|
+
.replaceAll(">", ">")
|
|
42
|
+
.replaceAll('"', """)
|
|
43
|
+
.replaceAll("'", "'");
|
|
44
|
+
return `<div data-dsh-cross-boundary-prose style="white-space: pre-wrap">${escaped}</div>`;
|
|
45
|
+
}
|
|
46
|
+
function stitchCrossBoundaryWrapper(blocks) {
|
|
47
|
+
const openingIndex = blocks.findIndex((block, index) => block.kind === "html"
|
|
48
|
+
&& index < blocks.length - 1
|
|
49
|
+
&& leavesOpenHtmlContainer(block.content));
|
|
50
|
+
if (openingIndex < 0)
|
|
51
|
+
return [...blocks];
|
|
52
|
+
const content = blocks.slice(openingIndex)
|
|
53
|
+
.map((block) => block.kind === "html" ? block.content : escapeCrossBoundaryProse(block.content))
|
|
54
|
+
.filter((value) => value.length > 0)
|
|
55
|
+
.join("\n");
|
|
56
|
+
return [...blocks.slice(0, openingIndex), { kind: "html", content }];
|
|
57
|
+
}
|
|
58
|
+
// Message iframes are expanded by the Host, so their previous viewport height
|
|
59
|
+
// must never participate in the next measurement. In particular,
|
|
60
|
+
// documentElement.scrollHeight is pinned to an already-tall iframe and cannot
|
|
61
|
+
// shrink after a card switches from a long page to a short page.
|
|
62
|
+
export function measureRichFrameContentHeight(metrics) {
|
|
63
|
+
const finite = (value) => Number.isFinite(value) ? value : 0;
|
|
64
|
+
const bodyTop = Math.min(0, finite(metrics.bodyRectTop));
|
|
65
|
+
const bodyBottom = Math.max(finite(metrics.bodyScrollHeight), finite(metrics.bodyOffsetHeight), finite(metrics.bodyRectBottom) - bodyTop);
|
|
66
|
+
// A viewport-relative card can keep body at exactly the current iframe
|
|
67
|
+
// height while moving the rest of its UI into overflow:auto descendants.
|
|
68
|
+
// Add that clipped extent so repeated resize receipts grow the viewport
|
|
69
|
+
// until the outer DSH conversation owns the vertical scroll surface.
|
|
70
|
+
const nestedOverflow = Math.max(0, finite(metrics.nestedScrollableOverflowHeight ?? 0));
|
|
71
|
+
return Math.max(72, Math.ceil(bodyBottom + nestedOverflow));
|
|
72
|
+
}
|
|
73
|
+
// Real cards can legitimately be many screens tall, so the Host owns one
|
|
74
|
+
// generous outer-scroll surface. Still cap a forged or broken resize receipt
|
|
75
|
+
// before it can create a billion-pixel layout in the conversation.
|
|
76
|
+
export function clampRichFrameHeight(value) {
|
|
77
|
+
if (!Number.isFinite(value))
|
|
78
|
+
return 72;
|
|
79
|
+
return Math.max(72, Math.min(40_000, Math.ceil(value)));
|
|
80
|
+
}
|
|
81
|
+
// The accepted SillyTavern reference profile exposes EjsTemplate as enabled.
|
|
82
|
+
// Real-card environment checks read this exact public context path. Keeping the
|
|
83
|
+
// compatibility value in one builder prevents the iframe bootstrap from
|
|
84
|
+
// drifting into an incomplete, card-specific mock.
|
|
85
|
+
export function buildSillyTavernCompatibilityContext(messageCount, currentMessageId) {
|
|
86
|
+
const safeCount = Number.isFinite(messageCount) ? Math.max(0, Math.trunc(messageCount)) : 0;
|
|
87
|
+
const safeMessageId = Number.isFinite(currentMessageId) ? Math.trunc(currentMessageId) : -1;
|
|
88
|
+
return {
|
|
89
|
+
chat: Array.from({ length: safeCount }, () => ({})),
|
|
90
|
+
chatId: safeMessageId,
|
|
91
|
+
extensionSettings: { EjsTemplate: { enabled: true } },
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
export function alignProjectedRoles(flowRoles, projectedRoles) {
|
|
95
|
+
const aligned = Array.from({ length: flowRoles.length }, () => null);
|
|
96
|
+
let projectedIndex = 0;
|
|
97
|
+
for (let flowIndex = 0; flowIndex < flowRoles.length && projectedIndex < projectedRoles.length; flowIndex += 1) {
|
|
98
|
+
if (flowRoles[flowIndex] !== projectedRoles[projectedIndex])
|
|
99
|
+
continue;
|
|
100
|
+
aligned[flowIndex] = projectedIndex;
|
|
101
|
+
projectedIndex += 1;
|
|
102
|
+
}
|
|
103
|
+
return projectedIndex === projectedRoles.length ? aligned : null;
|
|
104
|
+
}
|
|
105
|
+
export function splitRichMessage(text) {
|
|
106
|
+
if (rawHtmlDocument.test(text))
|
|
107
|
+
return [{ kind: "html", content: text.trim() }];
|
|
108
|
+
// Some real Tavern cards prepend a fenced <style> block to an opening whose
|
|
109
|
+
// document root is a custom element (for example <welcome>). Treat that
|
|
110
|
+
// combination as one frontend document so the style and markup share an
|
|
111
|
+
// iframe instead of leaking hundreds of HTML lines into native prose.
|
|
112
|
+
const fencedDocumentCount = [...text.matchAll(new RegExp(fencedHtml.source, fencedHtml.flags))].length
|
|
113
|
+
+ [...text.matchAll(new RegExp(fencedUntypedHtmlDocument.source, fencedUntypedHtmlDocument.flags))].length;
|
|
114
|
+
if (fencedDocumentCount <= 1) {
|
|
115
|
+
const stitched = text
|
|
116
|
+
.replace(fencedHtml, (_match, content) => content)
|
|
117
|
+
// SillyTavern cards in the wild also fence complete HTML documents without
|
|
118
|
+
// an `html` language label. Only unwrap documents whose first tag proves
|
|
119
|
+
// their intent; ordinary untyped code fences remain prose/code.
|
|
120
|
+
.replace(fencedUntypedHtmlDocument, (_match, content) => content)
|
|
121
|
+
.trim();
|
|
122
|
+
if (htmlFragment.test(stitched))
|
|
123
|
+
return [{ kind: "html", content: stitched }];
|
|
124
|
+
}
|
|
125
|
+
const blocks = [];
|
|
126
|
+
const expression = new RegExp(fencedHtml.source, fencedHtml.flags);
|
|
127
|
+
let cursor = 0;
|
|
128
|
+
for (const match of text.matchAll(expression)) {
|
|
129
|
+
const index = match.index ?? cursor;
|
|
130
|
+
const before = text.slice(cursor, index).trim();
|
|
131
|
+
if (before.length > 0)
|
|
132
|
+
blocks.push({ kind: "prose", content: before });
|
|
133
|
+
blocks.push({ kind: "html", content: match[1] ?? "" });
|
|
134
|
+
cursor = index + match[0].length;
|
|
135
|
+
}
|
|
136
|
+
const after = text.slice(cursor).trim();
|
|
137
|
+
if (after.length > 0)
|
|
138
|
+
blocks.push({ kind: "prose", content: after });
|
|
139
|
+
if (blocks.length === 0)
|
|
140
|
+
blocks.push({ kind: "prose", content: text });
|
|
141
|
+
// Some Tavern display Regex rules deliberately open a visual wrapper when a
|
|
142
|
+
// control tag starts and rely on the shared SillyTavern message DOM to close
|
|
143
|
+
// it at the end of the message. An iframe boundary would otherwise close the
|
|
144
|
+
// wrapper before the following prose. Compose only that structural case;
|
|
145
|
+
// balanced authored fragments keep the ordinary native prose/iframe flow.
|
|
146
|
+
return stitchCrossBoundaryWrapper(blocks);
|
|
147
|
+
}
|
|
148
|
+
// The authored documents run in a sandboxed data document with a unique
|
|
149
|
+
// origin. Rewrite only the well-known SillyTavern parent-window access points
|
|
150
|
+
// used by the accepted real cards; ordinary parent.postMessage remains
|
|
151
|
+
// untouched for resize notices.
|
|
152
|
+
export function adaptRealCardFrontendHtml(value) {
|
|
153
|
+
return value
|
|
154
|
+
.replaceAll("window.parent.document", "window.__dshCompatDocument")
|
|
155
|
+
.replaceAll("window.top.document", "window.__dshCompatDocument")
|
|
156
|
+
.replaceAll("parent.document", "window.__dshCompatDocument")
|
|
157
|
+
.replaceAll("top.document", "window.__dshCompatDocument")
|
|
158
|
+
.replaceAll("window.parent?.SillyTavern", "window.__dshSillyTavern")
|
|
159
|
+
.replaceAll("window.top?.SillyTavern", "window.__dshSillyTavern")
|
|
160
|
+
.replaceAll("window.parent.SillyTavern", "window.__dshSillyTavern")
|
|
161
|
+
.replaceAll("window.top.SillyTavern", "window.__dshSillyTavern")
|
|
162
|
+
.replaceAll("window.parent?.TavernHelper", "window.__dshTavernHelper")
|
|
163
|
+
.replaceAll("window.top?.TavernHelper", "window.__dshTavernHelper")
|
|
164
|
+
.replaceAll("window.parent.TavernHelper", "window.__dshTavernHelper")
|
|
165
|
+
.replaceAll("window.top.TavernHelper", "window.__dshTavernHelper")
|
|
166
|
+
.replaceAll("window.parent?.Mvu", "window.__dshMvu")
|
|
167
|
+
.replaceAll("window.top?.Mvu", "window.__dshMvu")
|
|
168
|
+
.replaceAll("window.parent.Mvu", "window.__dshMvu")
|
|
169
|
+
.replaceAll("window.top.Mvu", "window.__dshMvu")
|
|
170
|
+
// Bundled card code sometimes aliases the frame first (`const host =
|
|
171
|
+
// window.top`) and only reads SillyTavern APIs afterwards. Keep resize
|
|
172
|
+
// postMessages pointed at the host, then collapse any remaining top/parent
|
|
173
|
+
// aliases to the compatibility globals installed inside the data document.
|
|
174
|
+
.replaceAll("window.parent.postMessage", "parent.postMessage")
|
|
175
|
+
.replace(/window\.(?:parent|top)(?![\w$])/gu, "window");
|
|
176
|
+
}
|