@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
package/lib/index.js
ADDED
|
@@ -0,0 +1,2998 @@
|
|
|
1
|
+
import z from "zod";
|
|
2
|
+
import Schema from "@deepseek-ai/schemastery";
|
|
3
|
+
import { settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
4
|
+
import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
|
|
5
|
+
import { NORMALIZED_CARD_INDEX_VERSION, parseCard, sha256 } from "./card-runtime.js";
|
|
6
|
+
import { activateWorldbookWithRenderer, normalizeWorldbookEntry, placeWorldbook, substituteCardMacros } from "./worldbook.js";
|
|
7
|
+
import { applyCompiledPromptToRequest, compileTavernPrompt, compiledTavernSystemPrompt, normalizeTavernChatMessages, renderTavernContextEnvelope } from "./prompt-compiler.js";
|
|
8
|
+
import { DEFAULT_TAVERN_PRESET, exportSillyTavernPreset, normalizeTavernPreset } from "./preset-runtime.js";
|
|
9
|
+
import { createTavernSessionSeed, currentOpeningSurfaceSeq, currentOpeningText, currentWorldbookSurfaceSeq, hasPlayerMessage, isTavernPluginId, isolateTavernAssembly, openingIdFromSetChatMessages, tavernSurfaceAudit, tavernSurfaceEventDetail, TAVERN_PLUGIN_ID, TAVERN_WORLD_CONTEXT_MARKER, upsertTavernAssemblyContext, worldbookContextRevision } from "./session-runtime.js";
|
|
10
|
+
import { applyVariableUpdate, CommittedReplyVariableGate, initializeVariableRuntime, mergeVariableScopes, variableStateDigest } from "./variable-runtime.js";
|
|
11
|
+
import { adaptOpeningFrontendHtml, applyFrontendStateAction, bridgeCapabilities, frontendStateDigest, groupFrontendMessagesForNativeFlow, initialFrontendState, projectFrontendMessages, waitForCommittedFrontendTurn } from "./frontend-runtime.js";
|
|
12
|
+
import { createEjsRuntime } from "./ejs-runtime.js";
|
|
13
|
+
import { applySplitMvuPatchCompatibility, hasSplitMvuContract, splitMvuActivationForPhase } from "./split-mvu.js";
|
|
14
|
+
import { compatibilityCallCatalog, describeCompatibilityCall } from "./compatibility-call-runtime.js";
|
|
15
|
+
import { defaultMvuSessionSettings, normalizeMvuSessionSettings, replayMvuReplies, resolveMvuExtraModel, supportsExtraModelParsing } from "./mvu-session-control.js";
|
|
16
|
+
import { personaBindingKey, personaBindingKeysToClearForSelection, renderPersonaPrompt, resolvePersona, validatePersonaDraft } from "./persona-runtime.js";
|
|
17
|
+
import { applyTavernHelperGenerateInjections, generateScanText, normalizeTavernHelperGenerateConfig } from "./auxiliary-generation.js";
|
|
18
|
+
import { hideCardFromLibrary, orderVisibleCards, preserveCardLibraryMetadata, reorderVisibleCards, restoreCardToLibrary } from "./card-library.js";
|
|
19
|
+
import { RolePlayLifecycle } from "./lifecycle.js";
|
|
20
|
+
export const name = "dsh-roleplay";
|
|
21
|
+
export const inject = ["webServer", "sessions", "sessionPersistence", "storageDomain", "settings", "agents", "agentDefaultModel", "llm"];
|
|
22
|
+
const rolePlaySettingsNamespace = settingsNamespace("dsh-roleplay");
|
|
23
|
+
const rolePlaySettingsSchema = Schema.object({});
|
|
24
|
+
const recordSchema = z.record(z.string(), z.unknown());
|
|
25
|
+
const domainSpec = defineDomain({
|
|
26
|
+
name: "dsh_re3_rp",
|
|
27
|
+
version: 1,
|
|
28
|
+
tables: {
|
|
29
|
+
cards: domainTable(recordSchema),
|
|
30
|
+
bindings: domainTable(recordSchema),
|
|
31
|
+
traces: domainTable(recordSchema),
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
const variableDomainSpec = defineDomain({
|
|
35
|
+
name: "dsh_re3_rp_variables",
|
|
36
|
+
version: 1,
|
|
37
|
+
tables: {
|
|
38
|
+
states: domainTable(recordSchema),
|
|
39
|
+
events: domainTable(recordSchema),
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
const frontendDomainSpec = defineDomain({
|
|
43
|
+
name: "dsh_re3_rp_frontend",
|
|
44
|
+
version: 1,
|
|
45
|
+
tables: {
|
|
46
|
+
states: domainTable(recordSchema),
|
|
47
|
+
events: domainTable(recordSchema),
|
|
48
|
+
receipts: domainTable(recordSchema),
|
|
49
|
+
assets: domainTable(recordSchema),
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
const personaDomainSpec = defineDomain({
|
|
53
|
+
name: "dsh_re3_rp_personas",
|
|
54
|
+
version: 1,
|
|
55
|
+
tables: {
|
|
56
|
+
personas: domainTable(recordSchema),
|
|
57
|
+
bindings: domainTable(recordSchema),
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
const presetDomainSpec = defineDomain({
|
|
61
|
+
name: "dsh_re3_rp_presets",
|
|
62
|
+
version: 1,
|
|
63
|
+
tables: {
|
|
64
|
+
presets: domainTable(recordSchema),
|
|
65
|
+
settings: domainTable(recordSchema),
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
function jsonBody(res, status, value) {
|
|
69
|
+
const body = JSON.stringify(value);
|
|
70
|
+
res.writeHead(status, {
|
|
71
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
72
|
+
"Cache-Control": "no-store",
|
|
73
|
+
"Content-Length": new TextEncoder().encode(body).byteLength,
|
|
74
|
+
});
|
|
75
|
+
res.end(body);
|
|
76
|
+
}
|
|
77
|
+
async function readBody(req, maximum = 512 * 1024 * 1024) {
|
|
78
|
+
const chunks = [];
|
|
79
|
+
let length = 0;
|
|
80
|
+
for await (const chunk of req) {
|
|
81
|
+
const bytes = typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk;
|
|
82
|
+
length += bytes.byteLength;
|
|
83
|
+
if (length > maximum)
|
|
84
|
+
throw new Error("单个原件超过 512 MiB 安全边界");
|
|
85
|
+
chunks.push(bytes);
|
|
86
|
+
}
|
|
87
|
+
const result = new Uint8Array(length);
|
|
88
|
+
let offset = 0;
|
|
89
|
+
for (const chunk of chunks) {
|
|
90
|
+
result.set(chunk, offset);
|
|
91
|
+
offset += chunk.byteLength;
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
async function readJson(req, maximum = 256 * 1024) {
|
|
96
|
+
const value = JSON.parse(new TextDecoder().decode(await readBody(req, maximum)));
|
|
97
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
98
|
+
throw new Error("请求 JSON 必须是对象");
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
function messageText(message) {
|
|
102
|
+
if (typeof message?.content === "string")
|
|
103
|
+
return message.content;
|
|
104
|
+
if (!Array.isArray(message?.content))
|
|
105
|
+
return "";
|
|
106
|
+
return message.content.filter((part) => part?.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n");
|
|
107
|
+
}
|
|
108
|
+
function sessionTexts(session) {
|
|
109
|
+
const events = session?.surface?.nodes?.map((seq) => session.events?.[seq]) ?? [];
|
|
110
|
+
return events.flatMap((event) => {
|
|
111
|
+
if (event?.type !== "user/message" && event?.type !== "assistant/message")
|
|
112
|
+
return [];
|
|
113
|
+
const message = event.data?.message ?? event.data;
|
|
114
|
+
if (message?.source?.kind === "plugin" && isTavernPluginId(message.source.plugin))
|
|
115
|
+
return [];
|
|
116
|
+
const value = messageText(message);
|
|
117
|
+
return value.length === 0 ? [] : [value];
|
|
118
|
+
}) ?? [];
|
|
119
|
+
}
|
|
120
|
+
function cardPrompt(card, userName, personaDescription = "") {
|
|
121
|
+
const values = { userName, characterName: card.title };
|
|
122
|
+
return [card.systemPrompt, `你正在扮演 ${card.title}。`, renderPersonaPrompt(userName, personaDescription), card.description, card.personality, card.scenario, card.messageExample, card.postHistoryInstructions]
|
|
123
|
+
.filter((part) => typeof part === "string" && part.trim().length > 0)
|
|
124
|
+
.map((part) => substituteCardMacros(part, values))
|
|
125
|
+
.join("\n\n");
|
|
126
|
+
}
|
|
127
|
+
function literalMacroVariables(card) {
|
|
128
|
+
const variables = new Map();
|
|
129
|
+
const texts = [card.systemPrompt, card.description, card.personality, card.scenario, card.messageExample, card.postHistoryInstructions, ...card.worldbook.map((entry) => entry.content)];
|
|
130
|
+
for (const text of texts) {
|
|
131
|
+
if (typeof text !== "string")
|
|
132
|
+
continue;
|
|
133
|
+
for (const match of text.matchAll(/\{\{[^{}]+\}\}/gu)) {
|
|
134
|
+
const token = match[0];
|
|
135
|
+
if (/^\{\{(?:user|char)\}\}$/iu.test(token) || variables.has(token))
|
|
136
|
+
continue;
|
|
137
|
+
variables.set(token, `dsh_re3_rp_literal_${variables.size}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return variables;
|
|
141
|
+
}
|
|
142
|
+
function protectLiteralMacros(text, variables) {
|
|
143
|
+
let protectedText = text;
|
|
144
|
+
for (const [token, variable] of variables)
|
|
145
|
+
protectedText = protectedText.split(token).join(`{{${variable}}}`);
|
|
146
|
+
return protectedText;
|
|
147
|
+
}
|
|
148
|
+
function publicCard(card, bindings, sessionLookup) {
|
|
149
|
+
const visibleMessageCount = (sessionId) => {
|
|
150
|
+
const session = sessionLookup?.(sessionId);
|
|
151
|
+
const events = session?.surface?.nodes?.map((seq) => session.events[seq]) ?? [];
|
|
152
|
+
return events.filter((event) => {
|
|
153
|
+
if (event?.type === "assistant/message")
|
|
154
|
+
return true;
|
|
155
|
+
if (event?.type !== "user/message")
|
|
156
|
+
return false;
|
|
157
|
+
const message = event.data?.message ?? event.data;
|
|
158
|
+
return message?.source?.kind !== "plugin";
|
|
159
|
+
}).length || 1;
|
|
160
|
+
};
|
|
161
|
+
return {
|
|
162
|
+
revisionId: card.revisionId,
|
|
163
|
+
sourceName: card.sourceName,
|
|
164
|
+
sourceFormat: card.sourceFormat,
|
|
165
|
+
title: card.title,
|
|
166
|
+
creator: card.creator,
|
|
167
|
+
summary: (card.description || card.scenario).replace(/\s+/gu, " ").trim().slice(0, 160),
|
|
168
|
+
playability: card.playability,
|
|
169
|
+
statusText: card.statusText,
|
|
170
|
+
statusDetail: card.statusDetail,
|
|
171
|
+
worldbookEntryCount: card.worldbook.length,
|
|
172
|
+
openings: card.openings.map((opening) => ({ id: opening.id, label: opening.label, preview: opening.message.trim().length === 0 ? "(原件中的空白开场)" : opening.message.replace(/\s+/gu, " ").trim().slice(0, 100) })),
|
|
173
|
+
compatibilityRows: card.compatibilityRows,
|
|
174
|
+
sessions: bindings.filter((binding) => binding.revisionId === card.revisionId).map((binding) => ({
|
|
175
|
+
id: binding.sessionId,
|
|
176
|
+
sessionId: binding.sessionId,
|
|
177
|
+
title: card.openings.find((opening) => opening.id === binding.openingId)?.label ?? "卡片会话",
|
|
178
|
+
messageCount: visibleMessageCount(binding.sessionId),
|
|
179
|
+
lastActive: binding.createdAt,
|
|
180
|
+
openingId: binding.openingId,
|
|
181
|
+
})),
|
|
182
|
+
originalUrl: `/dsh-re3-rp/original?revision=${card.revisionId}`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function publicCardDetail(card, bindings, worldbookEnabledOverrides = {}) {
|
|
186
|
+
return {
|
|
187
|
+
revisionId: card.revisionId,
|
|
188
|
+
sourceName: card.sourceName,
|
|
189
|
+
sourceFormat: card.sourceFormat,
|
|
190
|
+
importedAt: card.importedAt,
|
|
191
|
+
title: card.title,
|
|
192
|
+
creator: card.creator,
|
|
193
|
+
creatorNotes: card.creatorNotes ?? "",
|
|
194
|
+
tags: card.tags ?? [],
|
|
195
|
+
characterVersion: card.characterVersion ?? "",
|
|
196
|
+
description: card.description,
|
|
197
|
+
personality: card.personality,
|
|
198
|
+
scenario: card.scenario,
|
|
199
|
+
systemPrompt: card.systemPrompt,
|
|
200
|
+
postHistoryInstructions: card.postHistoryInstructions,
|
|
201
|
+
messageExample: card.messageExample,
|
|
202
|
+
openings: card.openings,
|
|
203
|
+
worldbook: card.worldbook.map((entry) => ({
|
|
204
|
+
...entry,
|
|
205
|
+
enabled: worldbookEnabledOverrides[entry.id] ?? entry.enabled,
|
|
206
|
+
sourceEnabled: entry.enabled,
|
|
207
|
+
})),
|
|
208
|
+
runtime: {
|
|
209
|
+
sessionCount: bindings.filter((binding) => binding.revisionId === card.revisionId).length,
|
|
210
|
+
scriptCount: card.tavernHelperScripts.length,
|
|
211
|
+
regexCount: card.messageRegexScripts.length,
|
|
212
|
+
hasFrontend: card.frontendDefinition !== undefined,
|
|
213
|
+
variableFormatCount: card.variableDefinition.initializationFormats.length + card.variableDefinition.updateFormats.length,
|
|
214
|
+
},
|
|
215
|
+
variableDefinition: {
|
|
216
|
+
initializationFormats: card.variableDefinition.initializationFormats,
|
|
217
|
+
updateFormats: card.variableDefinition.updateFormats,
|
|
218
|
+
worldbookInitvarEntryIds: card.variableDefinition.worldbookInitvarEntryIds,
|
|
219
|
+
openingInitvarIds: card.variableDefinition.openingInitvarIds,
|
|
220
|
+
},
|
|
221
|
+
messageRegexScripts: card.messageRegexScripts,
|
|
222
|
+
tavernHelperScripts: card.tavernHelperScripts,
|
|
223
|
+
frontendDefinition: card.frontendDefinition ?? null,
|
|
224
|
+
compatibilityRows: card.compatibilityRows,
|
|
225
|
+
unknownFields: card.unknownFields,
|
|
226
|
+
playability: card.playability,
|
|
227
|
+
statusText: card.statusText,
|
|
228
|
+
statusDetail: card.statusDetail,
|
|
229
|
+
originalUrl: `/dsh-re3-rp/original?revision=${card.revisionId}`,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function replaceOpening(session, text) {
|
|
233
|
+
const target = currentOpeningSurfaceSeq(session);
|
|
234
|
+
if (target === undefined)
|
|
235
|
+
throw new Error("当前会话缺少可切换的开场消息");
|
|
236
|
+
const message = {
|
|
237
|
+
id: crypto.randomUUID(),
|
|
238
|
+
role: "assistant",
|
|
239
|
+
content: [{ type: "text", text }],
|
|
240
|
+
source: { kind: "model", provider: TAVERN_PLUGIN_ID, model: "character-card-opening" },
|
|
241
|
+
};
|
|
242
|
+
session.append("assistant/message", { turn: 1, step: 1, message }, {
|
|
243
|
+
surfaceOp: { op: "replace", start: target, end: target },
|
|
244
|
+
sourceEventSeqs: [target],
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
async function applyRuntime(ctx) {
|
|
248
|
+
const verificationInstanceId = crypto.randomUUID();
|
|
249
|
+
const hostGlobal = globalThis;
|
|
250
|
+
const fs = hostGlobal.process.getBuiltinModule("node:fs");
|
|
251
|
+
const path = hostGlobal.process.getBuiltinModule("node:path");
|
|
252
|
+
const zlib = hostGlobal.process.getBuiltinModule("node:zlib");
|
|
253
|
+
// QuickJS is initialized before the plugin advertises its client surface.
|
|
254
|
+
// If the isolated renderer cannot load, the plugin fails closed instead of
|
|
255
|
+
// claiming EJS compatibility while passing templates through to the model.
|
|
256
|
+
const ejsRuntime = await createEjsRuntime();
|
|
257
|
+
const openedDomains = [];
|
|
258
|
+
const disposeInfrastructure = async () => {
|
|
259
|
+
const failures = [];
|
|
260
|
+
try {
|
|
261
|
+
await ejsRuntime.dispose();
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
failures.push(error);
|
|
265
|
+
}
|
|
266
|
+
while (openedDomains.length > 0) {
|
|
267
|
+
try {
|
|
268
|
+
await openedDomains.pop().close();
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
failures.push(error);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (failures.length === 1)
|
|
275
|
+
throw failures[0];
|
|
276
|
+
if (failures.length > 1)
|
|
277
|
+
throw new AggregateError(failures, "RolePlay 运行时资源卸载失败");
|
|
278
|
+
};
|
|
279
|
+
try {
|
|
280
|
+
const dshHome = hostGlobal.process.env.DSH_HOME;
|
|
281
|
+
if (typeof dshHome !== "string" || dshHome.length === 0)
|
|
282
|
+
throw new Error("dsh-roleplay 需要隔离的 DSH_HOME");
|
|
283
|
+
const blobRoot = path.join(dshHome, "dsh-re3-rp", "blobs");
|
|
284
|
+
const frontendAssetRoot = path.join(dshHome, "dsh-re3-rp", "frontend-assets");
|
|
285
|
+
fs.mkdirSync(blobRoot, { recursive: true });
|
|
286
|
+
fs.mkdirSync(frontendAssetRoot, { recursive: true });
|
|
287
|
+
let domain;
|
|
288
|
+
try {
|
|
289
|
+
domain = await ctx.storageDomain.open(domainSpec);
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
await ejsRuntime.dispose();
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
295
|
+
openedDomains.push(domain);
|
|
296
|
+
const variableDomain = await ctx.storageDomain.open(variableDomainSpec);
|
|
297
|
+
openedDomains.push(variableDomain);
|
|
298
|
+
const frontendDomain = await ctx.storageDomain.open(frontendDomainSpec);
|
|
299
|
+
openedDomains.push(frontendDomain);
|
|
300
|
+
const personaDomain = await ctx.storageDomain.open(personaDomainSpec);
|
|
301
|
+
openedDomains.push(personaDomain);
|
|
302
|
+
const presetDomain = await ctx.storageDomain.open(presetDomainSpec);
|
|
303
|
+
openedDomains.push(presetDomain);
|
|
304
|
+
const cards = domain.table("cards");
|
|
305
|
+
const bindings = domain.table("bindings");
|
|
306
|
+
const traces = domain.table("traces");
|
|
307
|
+
const variableStates = variableDomain.table("states");
|
|
308
|
+
const variableEvents = variableDomain.table("events");
|
|
309
|
+
const frontendStates = frontendDomain.table("states");
|
|
310
|
+
const frontendEvents = frontendDomain.table("events");
|
|
311
|
+
const frontendReceipts = frontendDomain.table("receipts");
|
|
312
|
+
const frontendAssets = frontendDomain.table("assets");
|
|
313
|
+
const personas = personaDomain.table("personas");
|
|
314
|
+
const personaBindings = personaDomain.table("bindings");
|
|
315
|
+
const storedPresets = presetDomain.table("presets");
|
|
316
|
+
const presetSettings = presetDomain.table("settings");
|
|
317
|
+
const presetFor = (presetId) => {
|
|
318
|
+
if (presetId === undefined || presetId.length === 0 || presetId === DEFAULT_TAVERN_PRESET.id)
|
|
319
|
+
return DEFAULT_TAVERN_PRESET;
|
|
320
|
+
const value = storedPresets.get(presetId);
|
|
321
|
+
if (value === undefined)
|
|
322
|
+
return DEFAULT_TAVERN_PRESET;
|
|
323
|
+
return normalizeTavernPreset(value, {
|
|
324
|
+
id: presetId,
|
|
325
|
+
source: value.source === "created" ? "created" : "imported",
|
|
326
|
+
now: typeof value.updatedAt === "string" ? value.updatedAt : undefined,
|
|
327
|
+
});
|
|
328
|
+
};
|
|
329
|
+
const allPresets = () => [
|
|
330
|
+
DEFAULT_TAVERN_PRESET,
|
|
331
|
+
...Array.from(storedPresets.entries()).map(([id, value]) => presetFor(id)).filter((preset) => preset.id !== DEFAULT_TAVERN_PRESET.id),
|
|
332
|
+
];
|
|
333
|
+
const activePresetId = () => {
|
|
334
|
+
const value = presetSettings.get("active");
|
|
335
|
+
const requested = typeof value?.presetId === "string" ? value.presetId : DEFAULT_TAVERN_PRESET.id;
|
|
336
|
+
return presetFor(requested).id;
|
|
337
|
+
};
|
|
338
|
+
const bindingPreset = (binding) => presetFor(binding.presetId ?? DEFAULT_TAVERN_PRESET.id);
|
|
339
|
+
const uniquePresetName = (requested, exceptId = "") => {
|
|
340
|
+
const base = requested.trim() || "未命名预设";
|
|
341
|
+
const occupied = new Set(allPresets().filter((preset) => preset.id !== exceptId).map((preset) => preset.name.toLocaleLowerCase("zh-CN")));
|
|
342
|
+
if (!occupied.has(base.toLocaleLowerCase("zh-CN")))
|
|
343
|
+
return base;
|
|
344
|
+
for (let suffix = 2; suffix < 10_000; suffix += 1) {
|
|
345
|
+
const candidate = `${base} ${suffix}`;
|
|
346
|
+
if (!occupied.has(candidate.toLocaleLowerCase("zh-CN")))
|
|
347
|
+
return candidate;
|
|
348
|
+
}
|
|
349
|
+
return `${base} ${crypto.randomUUID().slice(0, 8)}`;
|
|
350
|
+
};
|
|
351
|
+
const presetState = (sessionId = "") => {
|
|
352
|
+
const binding = sessionId.length === 0 ? undefined : bindings.get(sessionId);
|
|
353
|
+
const sessionPreset = binding === undefined ? undefined : bindingPreset(binding);
|
|
354
|
+
return {
|
|
355
|
+
ok: true,
|
|
356
|
+
activePresetId: activePresetId(),
|
|
357
|
+
sessionId: binding?.sessionId ?? null,
|
|
358
|
+
sessionPresetId: sessionPreset?.id ?? null,
|
|
359
|
+
presets: allPresets(),
|
|
360
|
+
runtimeSupport: {
|
|
361
|
+
promptOrder: "applied",
|
|
362
|
+
contextBudget: "dsh-native-compaction-or-imported-override",
|
|
363
|
+
maxReplyTokens: "adapter-default-or-imported-override",
|
|
364
|
+
temperature: "provider-request",
|
|
365
|
+
stream: "always-on",
|
|
366
|
+
topP: "round-trip-only",
|
|
367
|
+
frequencyPenalty: "round-trip-only",
|
|
368
|
+
presencePenalty: "round-trip-only",
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
};
|
|
372
|
+
// A card revision is immutable, but its derived executable index is not. Rebuild
|
|
373
|
+
// old indexes from the byte-identical original so parser fixes also apply after a
|
|
374
|
+
// real DSH restart; sessions continue to bind the same revision SHA-256.
|
|
375
|
+
for (const [revisionId, storedValue] of Array.from(cards.entries())) {
|
|
376
|
+
const storedCard = storedValue;
|
|
377
|
+
if (storedCard.normalizedIndexVersion === NORMALIZED_CARD_INDEX_VERSION)
|
|
378
|
+
continue;
|
|
379
|
+
const blobPath = path.join(blobRoot, revisionId);
|
|
380
|
+
if (!fs.existsSync(blobPath))
|
|
381
|
+
continue;
|
|
382
|
+
const reparsed = await parseCard(new Uint8Array(fs.readFileSync(blobPath)), storedCard.sourceName || `${revisionId}.png`, (input) => new Uint8Array(zlib.inflateSync(input)));
|
|
383
|
+
if (reparsed.revisionId !== revisionId)
|
|
384
|
+
throw new Error(`角色卡原件摘要不匹配:${revisionId}`);
|
|
385
|
+
await cards.put(revisionId, preserveCardLibraryMetadata(reparsed, storedValue));
|
|
386
|
+
}
|
|
387
|
+
const handles = new Map();
|
|
388
|
+
const latestActivations = new Map();
|
|
389
|
+
const latestAssemblies = new Map();
|
|
390
|
+
const pendingAssemblies = new Map();
|
|
391
|
+
const variableReplyGate = new CommittedReplyVariableGate();
|
|
392
|
+
const pendingBridgeOperations = new Map();
|
|
393
|
+
const pendingOpeningSelections = new Map();
|
|
394
|
+
const activeAuxiliaryGenerations = new Map();
|
|
395
|
+
const compiledRequestReentryGuards = new WeakSet();
|
|
396
|
+
const splitMvuRequestGuards = new WeakSet();
|
|
397
|
+
const disposers = [];
|
|
398
|
+
const allBindings = () => Array.from(bindings.entries(), ([, value]) => value);
|
|
399
|
+
const personaMap = () => new Map(Array.from(personas.entries(), ([key, value]) => [key, value]));
|
|
400
|
+
const personaBindingMap = () => new Map(Array.from(personaBindings.entries(), ([key, value]) => [key, value]));
|
|
401
|
+
const resolvedPersona = (context) => resolvePersona(personaMap(), personaBindingMap(), context);
|
|
402
|
+
const activePersona = (binding) => resolvedPersona({ revisionId: binding.revisionId, sessionId: binding.sessionId });
|
|
403
|
+
const effectiveUserName = (binding) => activePersona(binding)?.persona.displayName ?? binding.userName;
|
|
404
|
+
const cardFor = (revisionId) => {
|
|
405
|
+
const card = cards.get(revisionId);
|
|
406
|
+
if (card === undefined)
|
|
407
|
+
return undefined;
|
|
408
|
+
const worldbook = (card.worldbook ?? []).map((entry, index) => normalizeWorldbookEntry(entry, index));
|
|
409
|
+
const fallbackDefinition = {
|
|
410
|
+
character: {},
|
|
411
|
+
scripts: [],
|
|
412
|
+
worldbookInitvarEntryIds: worldbook.filter((entry) => /\[initvar\]/iu.test(entry.comment)).map((entry) => entry.id),
|
|
413
|
+
openingInitvarIds: card.openings.filter((opening) => /<initvar\b/iu.test(opening.message)).map((opening) => opening.id),
|
|
414
|
+
initializationFormats: [],
|
|
415
|
+
updateFormats: [],
|
|
416
|
+
unknownFormats: [],
|
|
417
|
+
};
|
|
418
|
+
return { ...card, worldbook, variableDefinition: card.variableDefinition ?? fallbackDefinition, tavernHelperScripts: card.tavernHelperScripts ?? [] };
|
|
419
|
+
};
|
|
420
|
+
const personaLibraryState = (context) => {
|
|
421
|
+
const records = Array.from(personaMap().values()).sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id));
|
|
422
|
+
const bindingRecords = personaBindingMap();
|
|
423
|
+
const directBinding = (scope, targetId = "") => bindingRecords.get(personaBindingKey(scope, targetId)) ?? null;
|
|
424
|
+
const effective = resolvePersona(new Map(records.map((persona) => [persona.id, persona])), bindingRecords, context);
|
|
425
|
+
return {
|
|
426
|
+
ok: true,
|
|
427
|
+
personas: records,
|
|
428
|
+
context,
|
|
429
|
+
bindings: {
|
|
430
|
+
global: directBinding("global"),
|
|
431
|
+
card: context.revisionId === undefined || context.revisionId.length === 0 ? null : directBinding("card", context.revisionId),
|
|
432
|
+
session: context.sessionId === undefined || context.sessionId.length === 0 ? null : directBinding("session", context.sessionId),
|
|
433
|
+
},
|
|
434
|
+
effective: effective === null ? null : {
|
|
435
|
+
personaId: effective.persona.id,
|
|
436
|
+
scope: effective.binding.scope,
|
|
437
|
+
targetId: effective.binding.targetId,
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
};
|
|
441
|
+
const runtimeText = (relativePath) => fs.readFileSync(new URL(`../runtime-assets/${relativePath}`, import.meta.url), "utf8").replace(/\r\n/gu, "\n");
|
|
442
|
+
const runtimeAsset = (relativePath) => new TextEncoder().encode(runtimeText(relativePath));
|
|
443
|
+
const standaloneCore = runtimeText("standalone/core.js");
|
|
444
|
+
const standaloneStyle = runtimeText("standalone/style.css");
|
|
445
|
+
const standaloneIndex = runtimeText("standalone/index.html");
|
|
446
|
+
const requiredAssetIndex = runtimeText("required/index.html");
|
|
447
|
+
const requiredWeatherAsset = runtimeAsset("required/weather-flags.json");
|
|
448
|
+
const initializeFrontend = async (sessionId, card) => {
|
|
449
|
+
const definition = card.frontendDefinition;
|
|
450
|
+
if (definition === undefined)
|
|
451
|
+
return undefined;
|
|
452
|
+
const state = initialFrontendState(definition.caseId);
|
|
453
|
+
const record = {
|
|
454
|
+
sessionId,
|
|
455
|
+
revisionId: card.revisionId,
|
|
456
|
+
cardId: definition.cardId,
|
|
457
|
+
contentDigest: card.revisionId,
|
|
458
|
+
caseId: definition.caseId,
|
|
459
|
+
state,
|
|
460
|
+
stateDigest: frontendStateDigest(definition.caseId, state),
|
|
461
|
+
eventSequence: 0,
|
|
462
|
+
updatedAt: new Date().toISOString(),
|
|
463
|
+
};
|
|
464
|
+
await frontendStates.put(sessionId, record);
|
|
465
|
+
return record;
|
|
466
|
+
};
|
|
467
|
+
const appendFrontendEvent = async (record, type, operationId, detail = {}) => {
|
|
468
|
+
const sequence = record.eventSequence;
|
|
469
|
+
record.eventSequence += 1;
|
|
470
|
+
record.updatedAt = new Date().toISOString();
|
|
471
|
+
const event = { sessionId: record.sessionId, revisionId: record.revisionId, sequence, type, operationId, capturedAt: record.updatedAt, ...detail };
|
|
472
|
+
await frontendEvents.put(`${record.sessionId}:${String(sequence).padStart(8, "0")}`, event);
|
|
473
|
+
await frontendStates.put(record.sessionId, record);
|
|
474
|
+
return event;
|
|
475
|
+
};
|
|
476
|
+
const frontendContext = (sessionId, requiredCapability) => {
|
|
477
|
+
const binding = bindings.get(sessionId);
|
|
478
|
+
const card = binding === undefined ? undefined : cardFor(binding.revisionId);
|
|
479
|
+
const definition = card?.frontendDefinition;
|
|
480
|
+
const record = frontendStates.get(sessionId);
|
|
481
|
+
const session = ctx.sessions.get(sessionId);
|
|
482
|
+
if (binding === undefined || card === undefined || definition === undefined || record === undefined || session === undefined) {
|
|
483
|
+
throw bridgeFailure("bridge_unavailable", "找不到已绑定的卡内前端 Session");
|
|
484
|
+
}
|
|
485
|
+
if (requiredCapability !== undefined && !bridgeCapabilities(definition).includes(requiredCapability)) {
|
|
486
|
+
throw bridgeFailure("capability_denied", `当前前端没有 ${requiredCapability} 权限`);
|
|
487
|
+
}
|
|
488
|
+
return { binding, card, definition, record, session };
|
|
489
|
+
};
|
|
490
|
+
const cardBridgeContext = (sessionId) => {
|
|
491
|
+
const binding = bindings.get(sessionId);
|
|
492
|
+
const card = binding === undefined ? undefined : cardFor(binding.revisionId);
|
|
493
|
+
const variables = variableStates.get(sessionId);
|
|
494
|
+
const session = ctx.sessions.get(sessionId);
|
|
495
|
+
if (binding === undefined || card === undefined || variables === undefined || session === undefined) {
|
|
496
|
+
throw bridgeFailure("bridge_unavailable", "找不到已绑定的真实酒馆卡 Session");
|
|
497
|
+
}
|
|
498
|
+
return { binding, card, variables, session };
|
|
499
|
+
};
|
|
500
|
+
const openingState = (binding, card, session) => {
|
|
501
|
+
const currentIndex = Math.max(0, card.openings.findIndex((opening) => opening.id === binding.openingId));
|
|
502
|
+
return {
|
|
503
|
+
ok: true,
|
|
504
|
+
sessionId: binding.sessionId,
|
|
505
|
+
revisionId: binding.revisionId,
|
|
506
|
+
openingId: binding.openingId,
|
|
507
|
+
currentIndex,
|
|
508
|
+
currentMessage: substituteCardMacros(card.openings[currentIndex]?.message ?? "", { userName: effectiveUserName(binding), characterName: card.title }),
|
|
509
|
+
locked: hasPlayerMessage(session.events),
|
|
510
|
+
openings: card.openings.map((opening, index) => ({
|
|
511
|
+
id: opening.id,
|
|
512
|
+
index,
|
|
513
|
+
label: opening.label,
|
|
514
|
+
preview: opening.message.replace(/\s+/gu, " ").trim().slice(0, 120) || "(空白开场)",
|
|
515
|
+
})),
|
|
516
|
+
};
|
|
517
|
+
};
|
|
518
|
+
const openingIntentKey = (sessionId) => `opening-intent:${sessionId}`;
|
|
519
|
+
const commitSessionOpening = async (sessionId, openingId) => {
|
|
520
|
+
const { binding, card, variables, session } = cardBridgeContext(sessionId);
|
|
521
|
+
if (hasPlayerMessage(session.events))
|
|
522
|
+
throw bridgeFailure("opening_locked", "第一句玩家消息已经发出,开场已锁定");
|
|
523
|
+
const opening = card.openings.find((candidate) => candidate.id === openingId);
|
|
524
|
+
if (opening === undefined)
|
|
525
|
+
throw bridgeFailure("invalid_action", "找不到这个备选开场");
|
|
526
|
+
const renderedOpening = substituteCardMacros(opening.message, { userName: effectiveUserName(binding), characterName: card.title });
|
|
527
|
+
if (currentOpeningText(session) !== renderedOpening)
|
|
528
|
+
replaceOpening(session, renderedOpening);
|
|
529
|
+
binding.openingId = opening.id;
|
|
530
|
+
binding.openingDigest = await sha256(new TextEncoder().encode(renderedOpening));
|
|
531
|
+
await selectOpeningVariables(variables, opening.id);
|
|
532
|
+
await bindings.put(sessionId, binding);
|
|
533
|
+
// Flush even if the live surface already matches: a prior attempt may have
|
|
534
|
+
// persisted binding/variables and then failed before Session persistence.
|
|
535
|
+
await ctx.sessions.flush(session);
|
|
536
|
+
return openingState(binding, card, session);
|
|
537
|
+
};
|
|
538
|
+
const putOpeningReceipt = async (sessionId, operationId, state) => {
|
|
539
|
+
if (typeof operationId !== "string")
|
|
540
|
+
return;
|
|
541
|
+
await frontendReceipts.put(`${sessionId}:${operationId}`, {
|
|
542
|
+
ok: true,
|
|
543
|
+
committed: true,
|
|
544
|
+
operationId,
|
|
545
|
+
openingId: state.openingId,
|
|
546
|
+
currentIndex: state.currentIndex,
|
|
547
|
+
});
|
|
548
|
+
};
|
|
549
|
+
const performSessionOpeningSelection = async (sessionId, openingId, operationId) => {
|
|
550
|
+
const { card, session } = cardBridgeContext(sessionId);
|
|
551
|
+
if (hasPlayerMessage(session.events))
|
|
552
|
+
throw bridgeFailure("opening_locked", "第一句玩家消息已经发出,开场已锁定");
|
|
553
|
+
if (!card.openings.some((opening) => opening.id === openingId))
|
|
554
|
+
throw bridgeFailure("invalid_action", "找不到这个备选开场");
|
|
555
|
+
const intentKey = openingIntentKey(sessionId);
|
|
556
|
+
await frontendReceipts.put(intentKey, { kind: "opening_selection_intent", sessionId, openingId, ...(operationId === undefined ? {} : { operationId }), createdAt: new Date().toISOString() });
|
|
557
|
+
try {
|
|
558
|
+
const state = await commitSessionOpening(sessionId, openingId);
|
|
559
|
+
await putOpeningReceipt(sessionId, operationId, state);
|
|
560
|
+
await frontendReceipts.delete(intentKey);
|
|
561
|
+
return state;
|
|
562
|
+
}
|
|
563
|
+
catch (error) {
|
|
564
|
+
const code = error.code;
|
|
565
|
+
if (code === "opening_locked" || code === "invalid_action")
|
|
566
|
+
await frontendReceipts.delete(intentKey);
|
|
567
|
+
throw error;
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
const selectSessionOpening = async (sessionId, openingId, operationId) => {
|
|
571
|
+
const previous = pendingOpeningSelections.get(sessionId) ?? Promise.resolve();
|
|
572
|
+
const operation = previous.catch(() => undefined).then(() => performSessionOpeningSelection(sessionId, openingId, operationId));
|
|
573
|
+
pendingOpeningSelections.set(sessionId, operation);
|
|
574
|
+
try {
|
|
575
|
+
return await operation;
|
|
576
|
+
}
|
|
577
|
+
finally {
|
|
578
|
+
if (pendingOpeningSelections.get(sessionId) === operation)
|
|
579
|
+
pendingOpeningSelections.delete(sessionId);
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
const compatibilityCallSequences = new Map();
|
|
583
|
+
const appendCompatibilityCall = async (sessionId, operationIdValue, payload) => {
|
|
584
|
+
const { binding } = cardBridgeContext(sessionId);
|
|
585
|
+
const operationId = requireOperationId(operationIdValue);
|
|
586
|
+
const descriptor = describeCompatibilityCall(String(payload.surface ?? ""), String(payload.method ?? ""));
|
|
587
|
+
if (descriptor === undefined)
|
|
588
|
+
throw bridgeFailure("capability_denied", "未声明的酒馆兼容调用");
|
|
589
|
+
const frontendRecord = frontendStates.get(sessionId);
|
|
590
|
+
if (frontendRecord !== undefined) {
|
|
591
|
+
return appendFrontendEvent(frontendRecord, "compatibility_call_observed", operationId, descriptor);
|
|
592
|
+
}
|
|
593
|
+
const current = compatibilityCallSequences.get(sessionId) ?? Array.from(frontendEvents.entries())
|
|
594
|
+
.flatMap(([, event]) => event.sessionId === sessionId && typeof event.sequence === "number" ? [event.sequence] : [])
|
|
595
|
+
.reduce((maximum, sequence) => Math.max(maximum, sequence + 1), 0);
|
|
596
|
+
compatibilityCallSequences.set(sessionId, current + 1);
|
|
597
|
+
const capturedAt = new Date().toISOString();
|
|
598
|
+
const event = { sessionId, revisionId: binding.revisionId, sequence: current, type: "compatibility_call_observed", operationId, capturedAt, ...descriptor };
|
|
599
|
+
await frontendEvents.put(`${sessionId}:${String(current).padStart(8, "0")}`, event);
|
|
600
|
+
return event;
|
|
601
|
+
};
|
|
602
|
+
const frontendProjection = (sessionId) => {
|
|
603
|
+
const { binding, card, record, session } = frontendContext(sessionId);
|
|
604
|
+
const variableState = variableStates.get(sessionId);
|
|
605
|
+
return {
|
|
606
|
+
sessionId,
|
|
607
|
+
messages: projectFrontendMessages(session, card.messageRegexScripts, { userName: effectiveUserName(binding), characterName: card.title, messageVariables: variableState?.state, macroSeed: sessionId }),
|
|
608
|
+
state: record.state,
|
|
609
|
+
stateDigest: record.stateDigest,
|
|
610
|
+
eventSequence: record.eventSequence,
|
|
611
|
+
};
|
|
612
|
+
};
|
|
613
|
+
const hostedFrontendEntry = (sessionId, definition) => {
|
|
614
|
+
if (definition.container === "standalone")
|
|
615
|
+
return `/dsh-re3-rp/frontend-standalone/index.html?sessionId=${encodeURIComponent(sessionId)}`;
|
|
616
|
+
if (definition.container === "required-asset")
|
|
617
|
+
return `/dsh-re3-rp/frontend-required/index.html?sessionId=${encodeURIComponent(sessionId)}`;
|
|
618
|
+
return undefined;
|
|
619
|
+
};
|
|
620
|
+
const requireOperationId = (value) => {
|
|
621
|
+
if (typeof value !== "string" || !/^[a-z0-9][a-z0-9._:-]{0,127}$/iu.test(value))
|
|
622
|
+
throw bridgeFailure("invalid_action", "operationId 必须是稳定的短标识");
|
|
623
|
+
return value;
|
|
624
|
+
};
|
|
625
|
+
const generateAuxiliaryText = async (sessionId, operationIdValue, payload, requestSignal) => {
|
|
626
|
+
const { binding, card, variables, session } = cardBridgeContext(sessionId);
|
|
627
|
+
const operationId = requireOperationId(operationIdValue);
|
|
628
|
+
const config = normalizeTavernHelperGenerateConfig(payload);
|
|
629
|
+
const traceId = `tavern-helper-generate:${sessionId}:${operationId}`;
|
|
630
|
+
const capturedAt = new Date().toISOString();
|
|
631
|
+
const preset = bindingPreset(binding);
|
|
632
|
+
const baseTrace = {
|
|
633
|
+
traceId,
|
|
634
|
+
kind: "tavern-helper-generation",
|
|
635
|
+
operationId,
|
|
636
|
+
sessionId,
|
|
637
|
+
revisionId: binding.revisionId,
|
|
638
|
+
capturedAt,
|
|
639
|
+
provider: binding.provider,
|
|
640
|
+
model: binding.model,
|
|
641
|
+
presetId: preset.id,
|
|
642
|
+
presetRevision: preset.revision,
|
|
643
|
+
requestedStreaming: config.shouldStream,
|
|
644
|
+
injectionCount: config.injects.length,
|
|
645
|
+
injectionRoles: config.injects.map((item) => item.role),
|
|
646
|
+
injectionLengths: config.injects.map((item) => item.content.length),
|
|
647
|
+
status: "running",
|
|
648
|
+
};
|
|
649
|
+
await traces.put(traceId, baseTrace);
|
|
650
|
+
const abortController = new AbortController();
|
|
651
|
+
const activeKey = `${sessionId}:${operationId}`;
|
|
652
|
+
if (activeAuxiliaryGenerations.has(activeKey))
|
|
653
|
+
throw bridgeFailure("invalid_action", "同一 operationId 的辅助生成仍在运行");
|
|
654
|
+
activeAuxiliaryGenerations.set(activeKey, abortController);
|
|
655
|
+
let timedOut = false;
|
|
656
|
+
const timer = setTimeout(() => { timedOut = true; abortController.abort(); }, config.timeoutMs);
|
|
657
|
+
const abortFromRequest = () => abortController.abort();
|
|
658
|
+
if (requestSignal?.aborted)
|
|
659
|
+
abortController.abort();
|
|
660
|
+
else
|
|
661
|
+
requestSignal?.addEventListener("abort", abortFromRequest, { once: true });
|
|
662
|
+
try {
|
|
663
|
+
const chat = normalizeTavernChatMessages(typeof session.deriveMessages === "function" ? session.deriveMessages() : []);
|
|
664
|
+
const currentPersona = activePersona(binding)?.persona;
|
|
665
|
+
const currentUserName = currentPersona?.displayName ?? binding.userName;
|
|
666
|
+
const personaDescription = currentPersona?.content ?? "";
|
|
667
|
+
const ejsVariables = variables.state ?? {};
|
|
668
|
+
const messageId = Math.max(-1, chat.length - 1);
|
|
669
|
+
const ejsDiagnostics = [];
|
|
670
|
+
let ejsSourceCount = 0;
|
|
671
|
+
let ejsInputBytes = 0;
|
|
672
|
+
let ejsOutputBytes = 0;
|
|
673
|
+
const encoder = new TextEncoder();
|
|
674
|
+
const renderEjsSource = async (source, sourceType, id) => {
|
|
675
|
+
if (!/<%|%>/u.test(source))
|
|
676
|
+
return source;
|
|
677
|
+
const sourceBytes = encoder.encode(source).byteLength;
|
|
678
|
+
if (ejsSourceCount >= 128 || ejsInputBytes + sourceBytes > 2 * 1024 * 1024) {
|
|
679
|
+
ejsDiagnostics.push({ source: sourceType, id, code: "ejs_round_limit", message: "辅助生成 EJS 输入超过安全边界" });
|
|
680
|
+
return undefined;
|
|
681
|
+
}
|
|
682
|
+
ejsSourceCount += 1;
|
|
683
|
+
ejsInputBytes += sourceBytes;
|
|
684
|
+
try {
|
|
685
|
+
const missingVariables = [];
|
|
686
|
+
const rendered = (await ejsRuntime.render([source], ejsVariables, {
|
|
687
|
+
messageId,
|
|
688
|
+
seed: `${card.revisionId}:${sessionId}:${operationId}:${sourceType}:${id}`,
|
|
689
|
+
missingVariables,
|
|
690
|
+
}))[0];
|
|
691
|
+
const outputBytes = encoder.encode(rendered).byteLength;
|
|
692
|
+
if (ejsOutputBytes + outputBytes > 4 * 1024 * 1024) {
|
|
693
|
+
ejsDiagnostics.push({ source: sourceType, id, code: "ejs_round_limit", message: "辅助生成 EJS 输出超过安全边界" });
|
|
694
|
+
return undefined;
|
|
695
|
+
}
|
|
696
|
+
ejsOutputBytes += outputBytes;
|
|
697
|
+
if (missingVariables.length > 0)
|
|
698
|
+
ejsDiagnostics.push({ source: sourceType, id, code: "ejs_variable_unavailable", message: `缺少变量:${[...new Set(missingVariables)].join("、")}` });
|
|
699
|
+
return rendered;
|
|
700
|
+
}
|
|
701
|
+
catch (error) {
|
|
702
|
+
ejsDiagnostics.push({ source: sourceType, id, code: typeof error?.code === "string" ? error.code : "ejs_render_failed", message: error instanceof Error ? error.message : String(error) });
|
|
703
|
+
return undefined;
|
|
704
|
+
}
|
|
705
|
+
};
|
|
706
|
+
const renderedCard = { ...card };
|
|
707
|
+
for (const key of ["description", "personality", "scenario", "messageExample", "systemPrompt", "postHistoryInstructions"]) {
|
|
708
|
+
const rendered = await renderEjsSource(card[key], "card", key);
|
|
709
|
+
if (rendered === undefined)
|
|
710
|
+
throw Object.assign(new Error(`辅助生成的 EJS 顶层角色字段 ${key} 渲染失败`), { code: "ejs_render_failed" });
|
|
711
|
+
renderedCard[key] = rendered;
|
|
712
|
+
}
|
|
713
|
+
const executableWorldbook = card.worldbook.map((entry) => binding.worldbookEnabledOverrides?.[entry.id] === undefined ? entry : { ...entry, enabled: binding.worldbookEnabledOverrides[entry.id] === true });
|
|
714
|
+
const resolved = await activateWorldbookWithRenderer(executableWorldbook, generateScanText(chat, config), sessionId, { messageCount: chat.length, runtimeState: binding.worldbookState, maxRecursionSteps: binding.worldInfoMaxRecursionSteps }, (entry) => /<%|%>/u.test(entry.content), async (entry) => renderEjsSource(entry.content, "worldbook", entry.id));
|
|
715
|
+
let compiled = compileTavernPrompt({ card: renderedCard, userName: currentUserName, personaDescription, chat, activation: resolved.activation, messageVariables: variables.state, macroSeed: `${sessionId}:${operationId}`, preset });
|
|
716
|
+
compiled = applyTavernHelperGenerateInjections(compiled, config);
|
|
717
|
+
const visibleText = compiled.messages.map((message) => message.content).join("\n");
|
|
718
|
+
if (/<%|%>/u.test(visibleText))
|
|
719
|
+
throw Object.assign(new Error("辅助生成请求仍含未解析 EJS,已阻止发送"), { code: "ejs_unresolved" });
|
|
720
|
+
binding.worldbookState = resolved.activation.runtimeState;
|
|
721
|
+
await bindings.put(sessionId, binding);
|
|
722
|
+
const options = { provider: binding.provider, model: binding.model, system: "", messages: [], tools: [], sessionId, purpose: "tavern-helper-generate", signal: abortController.signal };
|
|
723
|
+
applyCompiledPromptToRequest(options, compiled);
|
|
724
|
+
const requestText = JSON.stringify({ system: options.system, messages: options.messages });
|
|
725
|
+
let body = "";
|
|
726
|
+
let providerFailure = "";
|
|
727
|
+
for await (const chunk of ctx.llm.stream(options)) {
|
|
728
|
+
if (chunk?.type === "text-delta" && typeof chunk.text === "string")
|
|
729
|
+
body += chunk.text;
|
|
730
|
+
if (body.length > 4 * 1024 * 1024)
|
|
731
|
+
throw Object.assign(new Error("辅助生成结果超过 4 MiB"), { code: "provider_error" });
|
|
732
|
+
if (chunk?.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted"))
|
|
733
|
+
providerFailure = typeof chunk.reason.failure?.message === "string" ? chunk.reason.failure.message : `辅助生成${chunk.reason.kind}`;
|
|
734
|
+
}
|
|
735
|
+
if (providerFailure.length > 0)
|
|
736
|
+
throw Object.assign(new Error(providerFailure), { code: abortController.signal.aborted ? "generation_cancelled" : "provider_error" });
|
|
737
|
+
if (abortController.signal.aborted)
|
|
738
|
+
throw Object.assign(new Error(timedOut ? "辅助生成超时" : "辅助生成已取消"), { code: timedOut ? "generation_timeout" : "generation_cancelled" });
|
|
739
|
+
await traces.put(traceId, {
|
|
740
|
+
...baseTrace,
|
|
741
|
+
status: "completed",
|
|
742
|
+
completedAt: new Date().toISOString(),
|
|
743
|
+
requestDigest: await sha256(encoder.encode(requestText)),
|
|
744
|
+
messageRoles: (options.messages ?? []).map((message) => message.role),
|
|
745
|
+
messageLengths: (options.messages ?? []).map((message) => messageText(message).length),
|
|
746
|
+
responseDigest: await sha256(encoder.encode(body)),
|
|
747
|
+
responseLength: body.length,
|
|
748
|
+
ejsDiagnostics,
|
|
749
|
+
});
|
|
750
|
+
return { text: body, operationId, traceId, status: "completed", streamed: false };
|
|
751
|
+
}
|
|
752
|
+
catch (error) {
|
|
753
|
+
const requestedCode = typeof error?.code === "string" ? error.code : "provider_error";
|
|
754
|
+
const code = timedOut ? "generation_timeout" : abortController.signal.aborted && requestedCode === "provider_error" ? "generation_cancelled" : requestedCode;
|
|
755
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
756
|
+
await traces.put(traceId, { ...baseTrace, status: code === "generation_timeout" ? "timeout" : code === "generation_cancelled" ? "cancelled" : "failed", completedAt: new Date().toISOString(), error: { code, message } });
|
|
757
|
+
throw bridgeFailure(code, message);
|
|
758
|
+
}
|
|
759
|
+
finally {
|
|
760
|
+
clearTimeout(timer);
|
|
761
|
+
activeAuxiliaryGenerations.delete(activeKey);
|
|
762
|
+
requestSignal?.removeEventListener("abort", abortFromRequest);
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
const cancelAuxiliaryGeneration = (sessionId, operationIdValue) => {
|
|
766
|
+
cardBridgeContext(sessionId);
|
|
767
|
+
const operationId = requireOperationId(operationIdValue);
|
|
768
|
+
const controller = activeAuxiliaryGenerations.get(`${sessionId}:${operationId}`);
|
|
769
|
+
if (controller === undefined)
|
|
770
|
+
return { operationId, cancelled: false, status: "not_running" };
|
|
771
|
+
controller.abort();
|
|
772
|
+
return { operationId, cancelled: true, status: "cancelling" };
|
|
773
|
+
};
|
|
774
|
+
const missingRuntimeCriticalApis = (card) => (card.requiredCriticalTavernHelperApis ?? []).filter((api) => {
|
|
775
|
+
const match = /^TavernHelper\.(.+)$/u.exec(api);
|
|
776
|
+
if (match === null || describeCompatibilityCall("TavernHelper", match[1]) === undefined)
|
|
777
|
+
return true;
|
|
778
|
+
return match[1] === "generate" && typeof generateAuxiliaryText !== "function";
|
|
779
|
+
});
|
|
780
|
+
const storedReceipt = (sessionId, operationId) => frontendReceipts.get(`${sessionId}:${operationId}`);
|
|
781
|
+
const selectOpeningFromChatMessages = async (sessionId, operationIdValue, payload) => {
|
|
782
|
+
const operationId = requireOperationId(operationIdValue);
|
|
783
|
+
const key = `${sessionId}:${operationId}`;
|
|
784
|
+
const prior = storedReceipt(sessionId, operationId);
|
|
785
|
+
if (prior !== undefined)
|
|
786
|
+
return { ...prior, duplicate: true };
|
|
787
|
+
const active = pendingBridgeOperations.get(key);
|
|
788
|
+
if (active !== undefined)
|
|
789
|
+
return active;
|
|
790
|
+
const operation = (async () => {
|
|
791
|
+
const { card } = cardBridgeContext(sessionId);
|
|
792
|
+
const openingId = openingIdFromSetChatMessages(payload.messages, card.openings);
|
|
793
|
+
if (openingId === undefined)
|
|
794
|
+
throw bridgeFailure("invalid_action", "setChatMessages 只允许切换首条 assistant 消息的现有开场 swipe");
|
|
795
|
+
const state = await selectSessionOpening(sessionId, openingId, operationId);
|
|
796
|
+
const receipt = storedReceipt(sessionId, operationId);
|
|
797
|
+
if (receipt === undefined)
|
|
798
|
+
throw bridgeFailure("state_commit_failed", "开场状态已提交但 operation receipt 缺失");
|
|
799
|
+
return { ...receipt, state };
|
|
800
|
+
})().finally(() => pendingBridgeOperations.delete(key));
|
|
801
|
+
pendingBridgeOperations.set(key, operation);
|
|
802
|
+
return operation;
|
|
803
|
+
};
|
|
804
|
+
const submitFrontendTurn = async (sessionId, operationIdValue, payload) => {
|
|
805
|
+
const operationId = requireOperationId(operationIdValue);
|
|
806
|
+
const key = `${sessionId}:${operationId}`;
|
|
807
|
+
const prior = storedReceipt(sessionId, operationId);
|
|
808
|
+
if (prior !== undefined)
|
|
809
|
+
return { ...prior, duplicate: true, projection: frontendProjection(sessionId) };
|
|
810
|
+
const active = pendingBridgeOperations.get(key);
|
|
811
|
+
if (active !== undefined)
|
|
812
|
+
return active;
|
|
813
|
+
const operation = (async () => {
|
|
814
|
+
const { binding, card, record, session } = frontendContext(sessionId, "turn.submit");
|
|
815
|
+
const projectionMacros = { userName: effectiveUserName(binding), characterName: card.title, messageVariables: variableStates.get(sessionId)?.state, macroSeed: sessionId };
|
|
816
|
+
const text = typeof payload.text === "string" ? payload.text.trim() : "";
|
|
817
|
+
if (text.length === 0 || text.length > 20_000)
|
|
818
|
+
throw bridgeFailure("invalid_action", "正式玩家动作不能为空或超过 20000 字符");
|
|
819
|
+
const agent = ctx.agents.get(sessionId);
|
|
820
|
+
if (agent === undefined)
|
|
821
|
+
throw bridgeFailure("bridge_unavailable", "当前 DSH Session 尚未恢复 Host Agent");
|
|
822
|
+
const before = projectFrontendMessages(session, card.messageRegexScripts, projectionMacros);
|
|
823
|
+
agent.followup({ id: crypto.randomUUID(), role: "user", content: [{ type: "text", text }], source: { kind: "user" } });
|
|
824
|
+
await agent.whenIdle();
|
|
825
|
+
const committed = await waitForCommittedFrontendTurn({
|
|
826
|
+
afterSeq: before.at(-1)?.seq ?? -1,
|
|
827
|
+
userText: text,
|
|
828
|
+
flush: () => ctx.sessions.flush(session),
|
|
829
|
+
readMessages: () => projectFrontendMessages(session, card.messageRegexScripts, projectionMacros),
|
|
830
|
+
});
|
|
831
|
+
if (committed === undefined)
|
|
832
|
+
throw bridgeFailure("state_commit_failed", "Host 没有提交完整的正式玩家消息与模型回复");
|
|
833
|
+
const event = await appendFrontendEvent(record, "generation_committed", operationId, { committedUserSeq: committed.user.seq, committedSeq: committed.assistant.seq });
|
|
834
|
+
const receipt = { ok: true, committed: true, operationId, committedSeq: committed.assistant.seq, eventSequence: event.sequence };
|
|
835
|
+
await frontendReceipts.put(key, receipt);
|
|
836
|
+
return { ...receipt, projection: frontendProjection(sessionId) };
|
|
837
|
+
})().finally(() => pendingBridgeOperations.delete(key));
|
|
838
|
+
pendingBridgeOperations.set(key, operation);
|
|
839
|
+
return operation;
|
|
840
|
+
};
|
|
841
|
+
const submitCardTurn = async (sessionId, operationIdValue, payload) => {
|
|
842
|
+
const operationId = requireOperationId(operationIdValue);
|
|
843
|
+
const key = `${sessionId}:${operationId}`;
|
|
844
|
+
const prior = storedReceipt(sessionId, operationId);
|
|
845
|
+
if (prior !== undefined)
|
|
846
|
+
return { ...prior, duplicate: true };
|
|
847
|
+
const active = pendingBridgeOperations.get(key);
|
|
848
|
+
if (active !== undefined)
|
|
849
|
+
return active;
|
|
850
|
+
const operation = (async () => {
|
|
851
|
+
const { binding, card, session } = cardBridgeContext(sessionId);
|
|
852
|
+
const text = typeof payload.text === "string" ? payload.text.trim() : "";
|
|
853
|
+
if (text.length === 0 || text.length > 20_000)
|
|
854
|
+
throw bridgeFailure("invalid_action", "正式玩家动作不能为空或超过 20000 字符");
|
|
855
|
+
const agent = ctx.agents.get(sessionId);
|
|
856
|
+
if (agent === undefined)
|
|
857
|
+
throw bridgeFailure("bridge_unavailable", "当前 DSH Session 尚未恢复 Host Agent");
|
|
858
|
+
const macros = { userName: effectiveUserName(binding), characterName: card.title, messageVariables: cardBridgeContext(sessionId).variables.state, macroSeed: sessionId };
|
|
859
|
+
const before = projectFrontendMessages(session, card.messageRegexScripts, macros);
|
|
860
|
+
agent.followup({ id: crypto.randomUUID(), role: "user", content: [{ type: "text", text }], source: { kind: "user" } });
|
|
861
|
+
await agent.whenIdle();
|
|
862
|
+
const committed = await waitForCommittedFrontendTurn({
|
|
863
|
+
afterSeq: before.at(-1)?.seq ?? -1,
|
|
864
|
+
userText: text,
|
|
865
|
+
flush: () => ctx.sessions.flush(session),
|
|
866
|
+
readMessages: () => projectFrontendMessages(session, card.messageRegexScripts, macros),
|
|
867
|
+
});
|
|
868
|
+
if (committed === undefined)
|
|
869
|
+
throw bridgeFailure("state_commit_failed", "Host 没有提交完整的正式玩家消息与模型回复");
|
|
870
|
+
const receipt = { ok: true, committed: true, operationId, committedUserSeq: committed.user.seq, committedSeq: committed.assistant.seq };
|
|
871
|
+
await frontendReceipts.put(key, receipt);
|
|
872
|
+
return receipt;
|
|
873
|
+
})().finally(() => pendingBridgeOperations.delete(key));
|
|
874
|
+
pendingBridgeOperations.set(key, operation);
|
|
875
|
+
return operation;
|
|
876
|
+
};
|
|
877
|
+
const cardStateProjection = (sessionId) => {
|
|
878
|
+
const { variables } = cardBridgeContext(sessionId);
|
|
879
|
+
return { state: variables.state, stateDigest: variables.digest, eventSequence: variables.eventSequence };
|
|
880
|
+
};
|
|
881
|
+
const replaceCardState = async (sessionId, operationIdValue, payload) => {
|
|
882
|
+
const operationId = requireOperationId(operationIdValue);
|
|
883
|
+
const key = `${sessionId}:${operationId}`;
|
|
884
|
+
const prior = storedReceipt(sessionId, operationId);
|
|
885
|
+
if (prior !== undefined)
|
|
886
|
+
return { ...prior, duplicate: true, ...cardStateProjection(sessionId) };
|
|
887
|
+
if (typeof payload.state !== "object" || payload.state === null || Array.isArray(payload.state))
|
|
888
|
+
throw bridgeFailure("invalid_action", "卡内变量必须是 JSON 对象");
|
|
889
|
+
const { variables } = cardBridgeContext(sessionId);
|
|
890
|
+
const nextState = mergeVariableScopes({ character: payload.state });
|
|
891
|
+
const previousDigest = variables.digest;
|
|
892
|
+
variables.state = nextState;
|
|
893
|
+
variables.digest = await variableStateDigest(nextState);
|
|
894
|
+
variables.updatedAt = new Date().toISOString();
|
|
895
|
+
const sequence = variables.eventSequence;
|
|
896
|
+
variables.eventSequence += 1;
|
|
897
|
+
await variableEvents.put(`${sessionId}:${String(sequence).padStart(8, "0")}`, {
|
|
898
|
+
sessionId,
|
|
899
|
+
revisionId: variables.revisionId,
|
|
900
|
+
sequence,
|
|
901
|
+
capturedAt: variables.updatedAt,
|
|
902
|
+
type: "VARIABLE_FRONTEND_REPLACED",
|
|
903
|
+
phase: "card_frontend",
|
|
904
|
+
operationId,
|
|
905
|
+
stateDigestBefore: previousDigest,
|
|
906
|
+
stateDigestAfter: variables.digest,
|
|
907
|
+
});
|
|
908
|
+
await variableStates.put(sessionId, variables);
|
|
909
|
+
const receipt = { ok: true, committed: true, operationId, stateDigest: variables.digest, eventSequence: sequence };
|
|
910
|
+
await frontendReceipts.put(key, receipt);
|
|
911
|
+
return { ...receipt, state: variables.state };
|
|
912
|
+
};
|
|
913
|
+
const compatibleWorldbook = (sessionId) => {
|
|
914
|
+
const { binding, card } = cardBridgeContext(sessionId);
|
|
915
|
+
return card.worldbook.map((entry) => ({
|
|
916
|
+
id: entry.id,
|
|
917
|
+
uid: /^\d+$/u.test(entry.id) ? Number(entry.id) : entry.id,
|
|
918
|
+
name: entry.comment,
|
|
919
|
+
comment: entry.comment,
|
|
920
|
+
content: entry.content,
|
|
921
|
+
enabled: binding.worldbookEnabledOverrides?.[entry.id] ?? entry.enabled,
|
|
922
|
+
position: { order: entry.order, type: entry.position, depth: entry.depth, role: entry.role },
|
|
923
|
+
strategy: { keys: entry.keys, keys_secondary: entry.secondaryKeys },
|
|
924
|
+
constant: entry.constant,
|
|
925
|
+
selective: entry.selective,
|
|
926
|
+
}));
|
|
927
|
+
};
|
|
928
|
+
const updateCardWorldbook = async (sessionId, operationIdValue, payload) => {
|
|
929
|
+
const operationId = requireOperationId(operationIdValue);
|
|
930
|
+
const key = `${sessionId}:${operationId}`;
|
|
931
|
+
const prior = storedReceipt(sessionId, operationId);
|
|
932
|
+
if (prior !== undefined)
|
|
933
|
+
return { ...prior, duplicate: true };
|
|
934
|
+
const updates = Array.isArray(payload.updates) ? payload.updates : [];
|
|
935
|
+
if (updates.length > 4_000)
|
|
936
|
+
throw bridgeFailure("invalid_action", "单次世界书更新条目过多");
|
|
937
|
+
const { binding, card } = cardBridgeContext(sessionId);
|
|
938
|
+
const byId = new Map(card.worldbook.map((entry) => [entry.id, entry]));
|
|
939
|
+
const byName = new Map(card.worldbook.map((entry) => [entry.comment, entry]));
|
|
940
|
+
const overrides = { ...(binding.worldbookEnabledOverrides ?? {}) };
|
|
941
|
+
let changed = 0;
|
|
942
|
+
for (const value of updates) {
|
|
943
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
944
|
+
continue;
|
|
945
|
+
const update = value;
|
|
946
|
+
const entry = (typeof update.id === "string" ? byId.get(update.id) : undefined) ?? (typeof update.name === "string" ? byName.get(update.name) : undefined);
|
|
947
|
+
if (entry === undefined || typeof update.enabled !== "boolean")
|
|
948
|
+
continue;
|
|
949
|
+
if ((overrides[entry.id] ?? entry.enabled) !== update.enabled)
|
|
950
|
+
changed += 1;
|
|
951
|
+
overrides[entry.id] = update.enabled;
|
|
952
|
+
}
|
|
953
|
+
binding.worldbookEnabledOverrides = overrides;
|
|
954
|
+
await bindings.put(sessionId, binding);
|
|
955
|
+
const receipt = { ok: true, committed: true, operationId, changed };
|
|
956
|
+
await frontendReceipts.put(key, receipt);
|
|
957
|
+
return receipt;
|
|
958
|
+
};
|
|
959
|
+
const replaceCardStorage = async (sessionId, payload) => {
|
|
960
|
+
const { binding } = cardBridgeContext(sessionId);
|
|
961
|
+
if (typeof payload.entries !== "object" || payload.entries === null || Array.isArray(payload.entries))
|
|
962
|
+
throw bridgeFailure("invalid_action", "卡片存储必须是字符串键值对象");
|
|
963
|
+
const entries = {};
|
|
964
|
+
for (const [key, value] of Object.entries(payload.entries)) {
|
|
965
|
+
if (Object.keys(entries).length >= 512 || key.length > 256 || typeof value !== "string")
|
|
966
|
+
throw bridgeFailure("invalid_action", "卡片存储键值无效或数量过多");
|
|
967
|
+
entries[key] = value;
|
|
968
|
+
}
|
|
969
|
+
if (new TextEncoder().encode(JSON.stringify(entries)).byteLength > 1_048_576)
|
|
970
|
+
throw bridgeFailure("invalid_action", "卡片存储超过 1 MiB");
|
|
971
|
+
binding.frontendStorage = entries;
|
|
972
|
+
await bindings.put(sessionId, binding);
|
|
973
|
+
return { ok: true, committed: true, entries: binding.frontendStorage };
|
|
974
|
+
};
|
|
975
|
+
const submitFrontendStateAction = async (sessionId, operationIdValue, payload) => {
|
|
976
|
+
const operationId = requireOperationId(operationIdValue);
|
|
977
|
+
const key = `${sessionId}:${operationId}`;
|
|
978
|
+
const prior = storedReceipt(sessionId, operationId);
|
|
979
|
+
if (prior !== undefined)
|
|
980
|
+
return { ...prior, duplicate: true, projection: frontendProjection(sessionId) };
|
|
981
|
+
const { definition, record } = frontendContext(sessionId, "state.submit");
|
|
982
|
+
const nextState = applyFrontendStateAction(definition.caseId, record.state, payload);
|
|
983
|
+
record.state = nextState;
|
|
984
|
+
record.stateDigest = frontendStateDigest(definition.caseId, nextState);
|
|
985
|
+
const event = await appendFrontendEvent(record, "state_committed", operationId, { stateDigest: record.stateDigest, action: payload.action });
|
|
986
|
+
const receipt = { ok: true, committed: true, operationId, eventSequence: event.sequence, stateDigest: record.stateDigest };
|
|
987
|
+
await frontendReceipts.put(key, receipt);
|
|
988
|
+
return { ...receipt, projection: frontendProjection(sessionId) };
|
|
989
|
+
};
|
|
990
|
+
const resolveFrontendAsset = async (sessionId, payload) => {
|
|
991
|
+
const { definition, record } = frontendContext(sessionId, "asset.resolve");
|
|
992
|
+
if (definition.caseId !== "required-remote-asset" || payload.assetId !== "weather-flags")
|
|
993
|
+
throw bridgeFailure("capability_denied", "当前卡没有这个 required 资源");
|
|
994
|
+
const scenario = typeof payload.scenario === "string" ? payload.scenario : "normal";
|
|
995
|
+
if (scenario === "missing")
|
|
996
|
+
throw bridgeFailure("asset_unavailable", "required 资源不存在");
|
|
997
|
+
const expectedDigest = await sha256(requiredWeatherAsset);
|
|
998
|
+
const candidate = scenario === "digest-mismatch" ? new TextEncoder().encode(`${new TextDecoder().decode(requiredWeatherAsset)}\n`) : requiredWeatherAsset;
|
|
999
|
+
const actualDigest = await sha256(candidate);
|
|
1000
|
+
if (actualDigest !== expectedDigest)
|
|
1001
|
+
throw bridgeFailure("asset_digest_mismatch", `required 资源摘要不匹配:${actualDigest}`);
|
|
1002
|
+
const assetPath = path.join(frontendAssetRoot, expectedDigest);
|
|
1003
|
+
if (!fs.existsSync(assetPath)) {
|
|
1004
|
+
const temporary = `${assetPath}.${crypto.randomUUID()}.tmp`;
|
|
1005
|
+
fs.writeFileSync(temporary, candidate, { flag: "wx" });
|
|
1006
|
+
try {
|
|
1007
|
+
fs.renameSync(temporary, assetPath);
|
|
1008
|
+
}
|
|
1009
|
+
finally {
|
|
1010
|
+
if (fs.existsSync(temporary))
|
|
1011
|
+
fs.unlinkSync(temporary);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
const token = crypto.randomUUID();
|
|
1015
|
+
await frontendAssets.put(token, { token, sessionId, revisionId: record.revisionId, digest: expectedDigest, path: assetPath, contentType: "application/json; charset=utf-8" });
|
|
1016
|
+
await appendFrontendEvent(record, "asset_ready", `asset:${token}`, { assetId: "weather-flags", digest: expectedDigest });
|
|
1017
|
+
return { assetId: "weather-flags", digest: expectedDigest, url: `/dsh-re3-rp/asset?token=${encodeURIComponent(token)}` };
|
|
1018
|
+
};
|
|
1019
|
+
const frontendEventsAfter = (sessionId, after) => Array.from(frontendEvents.entries())
|
|
1020
|
+
.flatMap(([, event]) => event.sessionId === sessionId && typeof event.sequence === "number" && event.sequence > after ? [event] : [])
|
|
1021
|
+
.sort((left, right) => Number(left.sequence) - Number(right.sequence));
|
|
1022
|
+
const standaloneMain = (sessionId) => `import { mountStandaloneFrontend } from "./core.js";
|
|
1023
|
+
const sessionId = ${JSON.stringify(sessionId)};
|
|
1024
|
+
const listeners = new Set();
|
|
1025
|
+
async function call(method, payload = {}) {
|
|
1026
|
+
const response = await fetch('/dsh-re3-rp/bridge', { method: 'POST', headers: { 'content-type': 'application/json; charset=utf-8' }, body: JSON.stringify({ sessionId, method, payload, operationId: payload.operationId }) });
|
|
1027
|
+
const body = await response.json().catch(() => ({}));
|
|
1028
|
+
if (!response.ok || body.ok !== true) throw Object.assign(new Error(body?.error?.message || body?.error || 'Bridge request failed'), { code: body?.error?.code || 'bridge_unavailable' });
|
|
1029
|
+
return body.result;
|
|
1030
|
+
}
|
|
1031
|
+
const adapter = Object.freeze({
|
|
1032
|
+
version: 'dsh-re3-rp-v1',
|
|
1033
|
+
async getBinding() { const value = await call('connect'); return { chatId: value.sessionId, cardId: value.cardId }; },
|
|
1034
|
+
getProjection: () => call('getProjection'),
|
|
1035
|
+
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
|
|
1036
|
+
async submitTurn(action) { const result = await call('submitTurn', action); const event = { type: 'generation_committed', operationId: result.operationId, committedSeq: result.committedSeq, projection: result.projection }; for (const listener of listeners) listener(event); return result; }
|
|
1037
|
+
});
|
|
1038
|
+
await mountStandaloneFrontend({ adapter, document });
|
|
1039
|
+
`;
|
|
1040
|
+
const requiredAssetMain = (sessionId) => `const sessionId = ${JSON.stringify(sessionId)};
|
|
1041
|
+
const result = document.querySelector('#result');
|
|
1042
|
+
document.querySelector('#origin').textContent = 'DSH Host 固化资源';
|
|
1043
|
+
async function digest(bytes) { return [...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))].map(value => value.toString(16).padStart(2, '0')).join(''); }
|
|
1044
|
+
async function call(payload) {
|
|
1045
|
+
const response = await fetch('/dsh-re3-rp/bridge', { method: 'POST', headers: { 'content-type': 'application/json; charset=utf-8' }, body: JSON.stringify({ sessionId, method: 'resolveAsset', payload }) });
|
|
1046
|
+
const body = await response.json().catch(() => ({}));
|
|
1047
|
+
if (!response.ok || body.ok !== true) throw Object.assign(new Error(body?.error?.message || body?.error || 'resource failed'), { code: body?.error?.code || 'asset_unavailable' });
|
|
1048
|
+
return body.result;
|
|
1049
|
+
}
|
|
1050
|
+
async function probe(scenario) {
|
|
1051
|
+
delete result.dataset.error; result.dataset.status = 'checking'; result.textContent = '正在检查 ' + scenario + '…';
|
|
1052
|
+
const resolved = await call({ assetId: 'weather-flags', scenario });
|
|
1053
|
+
const response = await fetch(resolved.url, { cache: 'no-store' });
|
|
1054
|
+
if (!response.ok) throw Object.assign(new Error('HTTP ' + response.status), { code: 'asset_unavailable' });
|
|
1055
|
+
const actualDigest = await digest(await response.arrayBuffer());
|
|
1056
|
+
if (actualDigest !== resolved.digest) throw Object.assign(new Error(actualDigest + ' != ' + resolved.digest), { code: 'asset_digest_mismatch' });
|
|
1057
|
+
result.dataset.status = 'pass'; result.textContent = 'asset_ready sha256=' + actualDigest;
|
|
1058
|
+
}
|
|
1059
|
+
for (const button of document.querySelectorAll('[data-scenario]')) button.addEventListener('click', () => probe(button.dataset.scenario).catch(error => { result.dataset.status = 'blocked'; result.dataset.error = error.code || 'asset_unavailable'; result.textContent = result.dataset.error + ': ' + error.message; }));
|
|
1060
|
+
`;
|
|
1061
|
+
const scriptVariables = (card) => card.variableDefinition.scripts.reduce((state, script) => mergeVariableScopes({ global: state, character: script.variables }), {});
|
|
1062
|
+
const supportsExtraModel = (card) => supportsExtraModelParsing(card.variableDefinition.updateFormats);
|
|
1063
|
+
const mvuSettingsFor = (binding, card) => normalizeMvuSessionSettings(binding.mvuSettings, { provider: binding.provider, model: binding.model, supportsExtraModel: supportsExtraModel(card) });
|
|
1064
|
+
const assistantVariableReplies = (session) => {
|
|
1065
|
+
const events = session?.surface?.nodes?.map((seq) => session.events?.[seq]) ?? [];
|
|
1066
|
+
return events.flatMap((event) => {
|
|
1067
|
+
if (event?.type !== "assistant/message")
|
|
1068
|
+
return [];
|
|
1069
|
+
const message = event.data?.message ?? event.data;
|
|
1070
|
+
if (message?.source?.kind === "plugin")
|
|
1071
|
+
return [];
|
|
1072
|
+
if (isTavernPluginId(message?.source?.provider) && message?.source?.model === "character-card-opening")
|
|
1073
|
+
return [];
|
|
1074
|
+
const value = messageText(message);
|
|
1075
|
+
return value.length === 0 ? [] : [value];
|
|
1076
|
+
});
|
|
1077
|
+
};
|
|
1078
|
+
const initializationSources = (card, openingId) => {
|
|
1079
|
+
const worldbook = card.worldbook
|
|
1080
|
+
.filter((entry) => card.variableDefinition.worldbookInitvarEntryIds.includes(entry.id))
|
|
1081
|
+
.map((entry) => ({ id: `worldbook:${entry.id}`, content: entry.content, location: "worldbook" }));
|
|
1082
|
+
const opening = card.openings.find((candidate) => candidate.id === openingId);
|
|
1083
|
+
return opening !== undefined && /<initvar\b/iu.test(opening.message)
|
|
1084
|
+
? [...worldbook, { id: `opening:${opening.id}`, content: opening.message, location: "opening" }]
|
|
1085
|
+
: worldbook;
|
|
1086
|
+
};
|
|
1087
|
+
const appendVariableEvents = async (record, events, metadata = {}) => {
|
|
1088
|
+
for (const event of events) {
|
|
1089
|
+
const sequence = record.eventSequence;
|
|
1090
|
+
record.eventSequence += 1;
|
|
1091
|
+
await variableEvents.put(`${record.sessionId}:${String(sequence).padStart(8, "0")}`, {
|
|
1092
|
+
sessionId: record.sessionId,
|
|
1093
|
+
revisionId: record.revisionId,
|
|
1094
|
+
sequence,
|
|
1095
|
+
capturedAt: new Date().toISOString(),
|
|
1096
|
+
...metadata,
|
|
1097
|
+
...event,
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
};
|
|
1101
|
+
const initializeVariables = async (sessionId, card, selectedOpeningId) => {
|
|
1102
|
+
const baseScopes = {
|
|
1103
|
+
global: {},
|
|
1104
|
+
character: card.variableDefinition.character,
|
|
1105
|
+
script: scriptVariables(card),
|
|
1106
|
+
chat: {},
|
|
1107
|
+
messageSelectedVariant: {},
|
|
1108
|
+
};
|
|
1109
|
+
const initialSnapshots = {};
|
|
1110
|
+
const results = new Map();
|
|
1111
|
+
for (const opening of card.openings) {
|
|
1112
|
+
const result = initializeVariableRuntime(baseScopes, initializationSources(card, opening.id));
|
|
1113
|
+
results.set(opening.id, result);
|
|
1114
|
+
initialSnapshots[opening.id] = { state: result.state, digest: await variableStateDigest(result.state), status: result.status };
|
|
1115
|
+
}
|
|
1116
|
+
const selected = initialSnapshots[selectedOpeningId];
|
|
1117
|
+
if (selected === undefined)
|
|
1118
|
+
throw new Error("变量运行时找不到所选开场快照");
|
|
1119
|
+
const record = {
|
|
1120
|
+
sessionId,
|
|
1121
|
+
revisionId: card.revisionId,
|
|
1122
|
+
selectedOpeningId,
|
|
1123
|
+
state: selected.state,
|
|
1124
|
+
digest: selected.digest,
|
|
1125
|
+
initialSnapshots,
|
|
1126
|
+
eventSequence: 0,
|
|
1127
|
+
updatedAt: new Date().toISOString(),
|
|
1128
|
+
};
|
|
1129
|
+
for (const opening of card.openings)
|
|
1130
|
+
await appendVariableEvents(record, results.get(opening.id)?.events ?? [], { phase: "initialization", openingId: opening.id, stateDigest: initialSnapshots[opening.id].digest });
|
|
1131
|
+
await variableStates.put(sessionId, record);
|
|
1132
|
+
return record;
|
|
1133
|
+
};
|
|
1134
|
+
const selectOpeningVariables = async (record, openingId) => {
|
|
1135
|
+
const snapshot = record.initialSnapshots[openingId];
|
|
1136
|
+
if (snapshot === undefined)
|
|
1137
|
+
throw new Error("变量运行时找不到这个开场的独立初始快照");
|
|
1138
|
+
record.selectedOpeningId = openingId;
|
|
1139
|
+
record.state = snapshot.state;
|
|
1140
|
+
record.digest = snapshot.digest;
|
|
1141
|
+
record.updatedAt = new Date().toISOString();
|
|
1142
|
+
await variableStates.put(record.sessionId, record);
|
|
1143
|
+
};
|
|
1144
|
+
const updateVariablesFromReply = async (sessionId, body) => {
|
|
1145
|
+
const record = variableStates.get(sessionId);
|
|
1146
|
+
if (record === undefined)
|
|
1147
|
+
return;
|
|
1148
|
+
const result = applyVariableUpdate(record.state, body);
|
|
1149
|
+
if (result.status === "ignored")
|
|
1150
|
+
return result;
|
|
1151
|
+
const nextDigest = result.status === "committed" ? await variableStateDigest(result.state) : record.digest;
|
|
1152
|
+
await appendVariableEvents(record, result.events, { phase: "reply", stateDigestBefore: record.digest, stateDigestAfter: nextDigest, committed: result.status === "committed" });
|
|
1153
|
+
if (result.status === "committed") {
|
|
1154
|
+
record.lastCommittedStateBefore = structuredClone(record.state);
|
|
1155
|
+
record.lastCommittedReplyDigest = await sha256(new TextEncoder().encode(body));
|
|
1156
|
+
record.state = result.state;
|
|
1157
|
+
record.digest = nextDigest;
|
|
1158
|
+
}
|
|
1159
|
+
record.updatedAt = new Date().toISOString();
|
|
1160
|
+
await variableStates.put(sessionId, record);
|
|
1161
|
+
return result;
|
|
1162
|
+
};
|
|
1163
|
+
const replayVariablesFromInitial = async (record, replies, phase) => {
|
|
1164
|
+
const initial = record.initialSnapshots[record.selectedOpeningId];
|
|
1165
|
+
if (initial === undefined || initial.status !== "initialized")
|
|
1166
|
+
throw new Error("当前开场没有可用的初始变量快照");
|
|
1167
|
+
const replay = replayMvuReplies(initial.state, replies);
|
|
1168
|
+
if (replay.failedReplies > 0)
|
|
1169
|
+
throw new Error(`变量重放有 ${replay.failedReplies} 条回复未通过原子校验,原状态未改动`);
|
|
1170
|
+
const nextDigest = await variableStateDigest(replay.state);
|
|
1171
|
+
await appendVariableEvents(record, replay.events, { phase, stateDigestBefore: record.digest, stateDigestAfter: nextDigest });
|
|
1172
|
+
record.state = replay.state;
|
|
1173
|
+
record.digest = nextDigest;
|
|
1174
|
+
record.updatedAt = new Date().toISOString();
|
|
1175
|
+
await variableStates.put(record.sessionId, record);
|
|
1176
|
+
};
|
|
1177
|
+
const reprocessVariables = async (sessionId) => {
|
|
1178
|
+
const record = variableStates.get(sessionId);
|
|
1179
|
+
const session = ctx.sessions.get(sessionId);
|
|
1180
|
+
if (record === undefined || session === undefined)
|
|
1181
|
+
throw new Error("找不到酒馆 Session 的变量状态");
|
|
1182
|
+
const replies = assistantVariableReplies(session);
|
|
1183
|
+
if (record.lastCommittedStateBefore !== undefined && record.lastCommittedReplyDigest !== undefined) {
|
|
1184
|
+
let target = "";
|
|
1185
|
+
for (const reply of replies.slice().reverse()) {
|
|
1186
|
+
if (await sha256(new TextEncoder().encode(reply)) === record.lastCommittedReplyDigest) {
|
|
1187
|
+
target = reply;
|
|
1188
|
+
break;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
if (target.length > 0) {
|
|
1192
|
+
const result = applyVariableUpdate(record.lastCommittedStateBefore, target);
|
|
1193
|
+
if (result.status === "failed")
|
|
1194
|
+
throw new Error("最后一次变量更新重新处理失败,原状态未改动");
|
|
1195
|
+
if (result.status === "committed") {
|
|
1196
|
+
const nextDigest = await variableStateDigest(result.state);
|
|
1197
|
+
await appendVariableEvents(record, result.events, { phase: "repair-reprocess", stateDigestBefore: record.digest, stateDigestAfter: nextDigest });
|
|
1198
|
+
record.state = result.state;
|
|
1199
|
+
record.digest = nextDigest;
|
|
1200
|
+
record.updatedAt = new Date().toISOString();
|
|
1201
|
+
await variableStates.put(sessionId, record);
|
|
1202
|
+
return { digest: record.digest, replayedReplies: 1 };
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
await replayVariablesFromInitial(record, replies, "repair-reprocess");
|
|
1207
|
+
return { digest: record.digest, replayedReplies: replies.length };
|
|
1208
|
+
};
|
|
1209
|
+
const reloadInitialVariables = async (sessionId, card) => {
|
|
1210
|
+
const current = variableStates.get(sessionId);
|
|
1211
|
+
const session = ctx.sessions.get(sessionId);
|
|
1212
|
+
if (current === undefined || session === undefined)
|
|
1213
|
+
throw new Error("找不到酒馆 Session 的变量状态");
|
|
1214
|
+
const temporaryId = `${sessionId}:reload:${crypto.randomUUID()}`;
|
|
1215
|
+
try {
|
|
1216
|
+
const replacement = await initializeVariables(temporaryId, card, current.selectedOpeningId);
|
|
1217
|
+
replacement.sessionId = sessionId;
|
|
1218
|
+
replacement.eventSequence = current.eventSequence;
|
|
1219
|
+
const replies = assistantVariableReplies(session);
|
|
1220
|
+
const initial = replacement.initialSnapshots[replacement.selectedOpeningId];
|
|
1221
|
+
if (initial === undefined || initial.status !== "initialized")
|
|
1222
|
+
throw new Error("重新读取的初始变量未通过校验,原状态未改动");
|
|
1223
|
+
const replay = replayMvuReplies(initial.state, replies);
|
|
1224
|
+
if (replay.failedReplies > 0)
|
|
1225
|
+
throw new Error(`重新读取后有 ${replay.failedReplies} 条历史回复无法重放,原状态未改动`);
|
|
1226
|
+
replacement.state = replay.state;
|
|
1227
|
+
replacement.digest = await variableStateDigest(replay.state);
|
|
1228
|
+
replacement.updatedAt = new Date().toISOString();
|
|
1229
|
+
await appendVariableEvents(replacement, replay.events, { phase: "repair-reload-initial", stateDigestBefore: current.digest, stateDigestAfter: replacement.digest });
|
|
1230
|
+
await variableStates.put(sessionId, replacement);
|
|
1231
|
+
return { digest: replacement.digest, replayedReplies: replies.length };
|
|
1232
|
+
}
|
|
1233
|
+
finally {
|
|
1234
|
+
await variableStates.delete(temporaryId);
|
|
1235
|
+
for (const [key, event] of variableEvents.entries()) {
|
|
1236
|
+
if (event.sessionId === temporaryId)
|
|
1237
|
+
await variableEvents.delete(key);
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
};
|
|
1241
|
+
const runSplitMvuUpdate = async (sessionId, binding, card, compiled, narrative) => {
|
|
1242
|
+
const target = resolveMvuExtraModel(mvuSettingsFor(binding, card), binding);
|
|
1243
|
+
binding.splitMvu = {
|
|
1244
|
+
enabled: true,
|
|
1245
|
+
provider: target.provider,
|
|
1246
|
+
model: target.model,
|
|
1247
|
+
status: "running",
|
|
1248
|
+
updatedAt: new Date().toISOString(),
|
|
1249
|
+
};
|
|
1250
|
+
await bindings.put(sessionId, binding);
|
|
1251
|
+
let correction = "";
|
|
1252
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1253
|
+
const options = {
|
|
1254
|
+
provider: target.provider,
|
|
1255
|
+
model: target.model,
|
|
1256
|
+
system: "",
|
|
1257
|
+
messages: [],
|
|
1258
|
+
tools: [],
|
|
1259
|
+
maxTokens: target.maxTokens,
|
|
1260
|
+
sessionId,
|
|
1261
|
+
};
|
|
1262
|
+
if (typeof compiled === "string")
|
|
1263
|
+
options.system = compiled;
|
|
1264
|
+
else
|
|
1265
|
+
applyCompiledPromptToRequest(options, compiled);
|
|
1266
|
+
options.messages.push({
|
|
1267
|
+
id: crypto.randomUUID(),
|
|
1268
|
+
role: "assistant",
|
|
1269
|
+
content: [{ type: "text", text: narrative }],
|
|
1270
|
+
source: { kind: "model", provider: target.provider, model: target.model },
|
|
1271
|
+
}, {
|
|
1272
|
+
id: crypto.randomUUID(),
|
|
1273
|
+
role: "user",
|
|
1274
|
+
content: [{ type: "text", text: correction.length === 0
|
|
1275
|
+
? "根据刚刚完成的当前剧情,只执行分步 MVU 变量更新。严格遵守 [mvu_update] 指令,只输出完整的 <UpdateVariable> 块,不要续写剧情。"
|
|
1276
|
+
: `上一次变量更新被 Host 原子校验拒绝:${correction}\n请修正路径或操作类型;缺少的对象分支使用 insert 创建。重新输出一个完整的 <UpdateVariable> 块,不要续写剧情。` }],
|
|
1277
|
+
source: { kind: "plugin", plugin: TAVERN_PLUGIN_ID, form: attempt === 0 ? "split-mvu" : "split-mvu-repair" },
|
|
1278
|
+
});
|
|
1279
|
+
splitMvuRequestGuards.add(options);
|
|
1280
|
+
let body = "";
|
|
1281
|
+
let failure = "";
|
|
1282
|
+
for await (const chunk of ctx.llm.stream(options)) {
|
|
1283
|
+
if (chunk?.type === "text-delta" && typeof chunk.text === "string")
|
|
1284
|
+
body += chunk.text;
|
|
1285
|
+
if (chunk?.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
|
|
1286
|
+
failure = typeof chunk.reason.failure?.message === "string" ? chunk.reason.failure.message : `副模型调用${chunk.reason.kind}`;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
if (failure.length > 0)
|
|
1290
|
+
throw new Error(failure);
|
|
1291
|
+
if (!/<UpdateVariable\b[^>]*>[\s\S]*?<\/UpdateVariable>/iu.test(body)) {
|
|
1292
|
+
correction = "没有返回完整的 UpdateVariable 块";
|
|
1293
|
+
}
|
|
1294
|
+
else {
|
|
1295
|
+
const variableRecord = variableStates.get(sessionId);
|
|
1296
|
+
const compatibility = variableRecord === undefined
|
|
1297
|
+
? undefined
|
|
1298
|
+
: applySplitMvuPatchCompatibility(variableRecord.state, body);
|
|
1299
|
+
const result = await updateVariablesFromReply(sessionId, compatibility?.body ?? body);
|
|
1300
|
+
if (result?.status === "committed") {
|
|
1301
|
+
binding.splitMvu = { ...binding.splitMvu, status: "committed", updatedAt: new Date().toISOString() };
|
|
1302
|
+
await bindings.put(sessionId, binding);
|
|
1303
|
+
return result;
|
|
1304
|
+
}
|
|
1305
|
+
correction = result?.diagnostics.map((item) => item.message).join(";") || "变量更新未提交";
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
throw new Error(correction || "副模型变量更新未提交");
|
|
1309
|
+
};
|
|
1310
|
+
const setupAgent = (sessionId, card, binding) => (agentCtx) => {
|
|
1311
|
+
const literalMacros = literalMacroVariables(card);
|
|
1312
|
+
for (const [token, variable] of literalMacros)
|
|
1313
|
+
agentCtx.systemPrompt.variable(variable, () => token);
|
|
1314
|
+
const protect = (text) => protectLiteralMacros(text, literalMacros);
|
|
1315
|
+
const initialPersona = activePersona(binding)?.persona;
|
|
1316
|
+
agentCtx.systemPrompt.section({ name: "dsh-re3-rp:complete-character-prompt", order: 0, complete: true, text: protect(cardPrompt(card, initialPersona?.displayName ?? binding.userName, initialPersona?.content ?? "")) });
|
|
1317
|
+
agentCtx.on("system-prompt/assemble", async (_assembly, _context, next) => isolateTavernAssembly(await next()));
|
|
1318
|
+
agentCtx.on("agent/pre-step", async ({ agent, messages, signal }) => {
|
|
1319
|
+
if (signal?.aborted)
|
|
1320
|
+
return { kind: "reject" };
|
|
1321
|
+
const session = agent.session;
|
|
1322
|
+
const existing = normalizeTavernChatMessages(typeof session?.deriveMessages === "function" ? session.deriveMessages() : []);
|
|
1323
|
+
const incoming = normalizeTavernChatMessages(Array.isArray(messages) ? messages : []);
|
|
1324
|
+
const chat = [...existing, ...incoming];
|
|
1325
|
+
const variableState = variableStates.get(sessionId);
|
|
1326
|
+
const currentPersona = activePersona(binding)?.persona;
|
|
1327
|
+
const currentUserName = currentPersona?.displayName ?? binding.userName;
|
|
1328
|
+
const personaDescription = currentPersona?.content ?? "";
|
|
1329
|
+
const ejsVariables = variableState?.state ?? {};
|
|
1330
|
+
const messageId = Math.max(-1, chat.length - 1);
|
|
1331
|
+
const ejsDiagnostics = [];
|
|
1332
|
+
let ejsSourceCount = 0;
|
|
1333
|
+
let ejsInputBytes = 0;
|
|
1334
|
+
let ejsOutputBytes = 0;
|
|
1335
|
+
const ejsRoundEncoder = new TextEncoder();
|
|
1336
|
+
const renderEjsSource = async (source, sourceType, id) => {
|
|
1337
|
+
if (!/<%|%>/u.test(source))
|
|
1338
|
+
return source;
|
|
1339
|
+
const sourceBytes = ejsRoundEncoder.encode(source).byteLength;
|
|
1340
|
+
if (ejsSourceCount >= 128 || ejsInputBytes + sourceBytes > 2 * 1024 * 1024) {
|
|
1341
|
+
ejsDiagnostics.push({ source: sourceType, id, code: "ejs_round_limit", message: "本轮 EJS 输入超过条目数或总字节安全边界" });
|
|
1342
|
+
return undefined;
|
|
1343
|
+
}
|
|
1344
|
+
ejsSourceCount += 1;
|
|
1345
|
+
ejsInputBytes += sourceBytes;
|
|
1346
|
+
try {
|
|
1347
|
+
const missingVariables = [];
|
|
1348
|
+
const rendered = (await ejsRuntime.render([source], ejsVariables, {
|
|
1349
|
+
messageId,
|
|
1350
|
+
seed: `${card.revisionId}:${sessionId}:${messageId}:${sourceType}:${id}`,
|
|
1351
|
+
missingVariables,
|
|
1352
|
+
}))[0];
|
|
1353
|
+
const uniqueMissing = [...new Set(missingVariables)];
|
|
1354
|
+
if (uniqueMissing.length > 0) {
|
|
1355
|
+
ejsDiagnostics.push({
|
|
1356
|
+
source: sourceType,
|
|
1357
|
+
id,
|
|
1358
|
+
code: "ejs_variable_unavailable",
|
|
1359
|
+
message: `EJS getvar 本轮缺少变量:${uniqueMissing.join("、")};已按模板自己的 defaults / fallback 语义继续`,
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
const outputBytes = ejsRoundEncoder.encode(rendered).byteLength;
|
|
1363
|
+
if (ejsOutputBytes + outputBytes > 4 * 1024 * 1024) {
|
|
1364
|
+
ejsDiagnostics.push({ source: sourceType, id, code: "ejs_round_limit", message: "本轮 EJS 输出超过总字节安全边界" });
|
|
1365
|
+
return undefined;
|
|
1366
|
+
}
|
|
1367
|
+
ejsOutputBytes += outputBytes;
|
|
1368
|
+
return rendered;
|
|
1369
|
+
}
|
|
1370
|
+
catch (error) {
|
|
1371
|
+
ejsDiagnostics.push({
|
|
1372
|
+
source: sourceType,
|
|
1373
|
+
id,
|
|
1374
|
+
code: typeof error?.code === "string" ? error.code : "ejs_render_failed",
|
|
1375
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1376
|
+
});
|
|
1377
|
+
return undefined;
|
|
1378
|
+
}
|
|
1379
|
+
};
|
|
1380
|
+
const promptFieldKeys = ["description", "personality", "scenario", "messageExample", "systemPrompt", "postHistoryInstructions"];
|
|
1381
|
+
const renderedCard = { ...card };
|
|
1382
|
+
for (const key of promptFieldKeys) {
|
|
1383
|
+
const rendered = await renderEjsSource(card[key], "card", key);
|
|
1384
|
+
if (rendered === undefined) {
|
|
1385
|
+
const detail = [...ejsDiagnostics].reverse().find((item) => item.source === "card" && item.id === key);
|
|
1386
|
+
throw Object.assign(new Error(`EJS 顶层角色字段 ${key} 渲染失败,已阻止模型请求${detail === undefined ? "" : `:${detail.message}`}`), { code: detail?.code ?? "ejs_render_failed" });
|
|
1387
|
+
}
|
|
1388
|
+
renderedCard[key] = rendered;
|
|
1389
|
+
}
|
|
1390
|
+
const executableWorldbook = card.worldbook.map((entry) => binding.worldbookEnabledOverrides?.[entry.id] === undefined
|
|
1391
|
+
? entry
|
|
1392
|
+
: { ...entry, enabled: binding.worldbookEnabledOverrides[entry.id] === true });
|
|
1393
|
+
const resolvedWorldbook = await activateWorldbookWithRenderer(executableWorldbook, chat.map((message) => message.content), sessionId, {
|
|
1394
|
+
messageCount: chat.length,
|
|
1395
|
+
runtimeState: binding.worldbookState,
|
|
1396
|
+
maxRecursionSteps: binding.worldInfoMaxRecursionSteps,
|
|
1397
|
+
}, (entry) => /<%|%>/u.test(entry.content), async (entry) => renderEjsSource(entry.content, "worldbook", entry.id));
|
|
1398
|
+
const renderedActivation = resolvedWorldbook.activation;
|
|
1399
|
+
const mvuSettings = mvuSettingsFor(binding, card);
|
|
1400
|
+
binding.mvuSettings = mvuSettings;
|
|
1401
|
+
const extraModelRequested = mvuSettings.updateMethod === "额外模型解析" && supportsExtraModel(card);
|
|
1402
|
+
const splitMvu = extraModelRequested && hasSplitMvuContract(renderedActivation.active);
|
|
1403
|
+
const plotActivation = splitMvu ? splitMvuActivationForPhase(renderedActivation, "plot") : renderedActivation;
|
|
1404
|
+
const updateActivation = !extraModelRequested
|
|
1405
|
+
? undefined
|
|
1406
|
+
: splitMvu ? splitMvuActivationForPhase(renderedActivation, "update") : renderedActivation;
|
|
1407
|
+
const committedRuntimeState = renderedActivation.runtimeState;
|
|
1408
|
+
latestActivations.set(sessionId, {
|
|
1409
|
+
activation: plotActivation,
|
|
1410
|
+
placement: placeWorldbook(plotActivation.active, { userName: currentUserName, characterName: card.title }),
|
|
1411
|
+
});
|
|
1412
|
+
const preset = bindingPreset(binding);
|
|
1413
|
+
const compiled = compileTavernPrompt({ card: renderedCard, userName: currentUserName, personaDescription, chat, activation: plotActivation, messageVariables: variableState?.state, macroSeed: sessionId, preset });
|
|
1414
|
+
const updateCompiled = updateActivation === undefined
|
|
1415
|
+
? undefined
|
|
1416
|
+
: compileTavernPrompt({ card: renderedCard, userName: currentUserName, personaDescription, chat, activation: updateActivation, messageVariables: variableState?.state, macroSeed: `${sessionId}:mvu-update`, preset });
|
|
1417
|
+
const compiledModelText = compiled.messages.map((message) => message.content).join("\n");
|
|
1418
|
+
if (/<%|%>/u.test(compiledModelText))
|
|
1419
|
+
throw Object.assign(new Error("EJS 渲染后仍有未解析标签,已阻止模型请求"), { code: "ejs_unresolved" });
|
|
1420
|
+
binding.worldbookState = committedRuntimeState;
|
|
1421
|
+
const previous = latestAssemblies.get(sessionId);
|
|
1422
|
+
const activeEntryIds = compiled.activation.active.map((entry) => entry.id);
|
|
1423
|
+
const previousIds = new Set(previous?.activeEntryIds ?? binding.lastActiveEntryIds ?? []);
|
|
1424
|
+
const currentIds = new Set(activeEntryIds);
|
|
1425
|
+
const assemblyId = `${sessionId}:${Date.now()}:${crypto.randomUUID()}`;
|
|
1426
|
+
const summary = {
|
|
1427
|
+
assemblyId,
|
|
1428
|
+
presetName: compiled.preset.name,
|
|
1429
|
+
activeEntries: compiled.stats.activeWorldbookEntries,
|
|
1430
|
+
filteredEntries: compiled.stats.filteredWorldbookEntries,
|
|
1431
|
+
depthInjections: compiled.stats.depthInjections,
|
|
1432
|
+
messageCount: compiled.stats.messageCount,
|
|
1433
|
+
characterCount: compiled.stats.characterCount,
|
|
1434
|
+
addedEntryIds: activeEntryIds.filter((id) => !previousIds.has(id)),
|
|
1435
|
+
removedEntryIds: [...previousIds].filter((id) => !currentIds.has(id)),
|
|
1436
|
+
previousAssemblyId: previous?.assemblyId ?? binding.lastAssemblyId ?? null,
|
|
1437
|
+
stage: "request",
|
|
1438
|
+
};
|
|
1439
|
+
const assemblySeq = upsertTavernAssemblyContext(session, summary, renderTavernContextEnvelope(compiled));
|
|
1440
|
+
latestAssemblies.set(sessionId, { assemblyId, activeEntryIds, compiled });
|
|
1441
|
+
pendingAssemblies.set(sessionId, { summary, assemblySeq, compiled, activation: plotActivation, updateCompiled, ejsDiagnostics });
|
|
1442
|
+
if (updateCompiled !== undefined) {
|
|
1443
|
+
binding.splitMvu = {
|
|
1444
|
+
enabled: true,
|
|
1445
|
+
provider: binding.provider,
|
|
1446
|
+
model: binding.model,
|
|
1447
|
+
status: "ready",
|
|
1448
|
+
updatedAt: new Date().toISOString(),
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
binding.lastAssemblyId = assemblyId;
|
|
1452
|
+
binding.lastActiveEntryIds = activeEntryIds;
|
|
1453
|
+
await bindings.put(sessionId, binding);
|
|
1454
|
+
return {
|
|
1455
|
+
kind: "enter",
|
|
1456
|
+
messages: (Array.isArray(messages) ? messages : []).filter((message) => message?.source?.kind !== "plugin"),
|
|
1457
|
+
};
|
|
1458
|
+
});
|
|
1459
|
+
agentCtx.on("agent/request", async (_payload, next) => {
|
|
1460
|
+
const config = await next();
|
|
1461
|
+
const pending = pendingAssemblies.get(sessionId);
|
|
1462
|
+
if (pending === undefined)
|
|
1463
|
+
return { ...config, tools: [] };
|
|
1464
|
+
return {
|
|
1465
|
+
...config,
|
|
1466
|
+
system: compiledTavernSystemPrompt(pending.compiled) || `你正在扮演 ${card.title}。`,
|
|
1467
|
+
tools: [],
|
|
1468
|
+
temperature: pending.compiled.settings.temperature,
|
|
1469
|
+
...(pending.compiled.settings.maxReplyTokens === null ? {} : { maxTokens: pending.compiled.settings.maxReplyTokens }),
|
|
1470
|
+
};
|
|
1471
|
+
});
|
|
1472
|
+
agentCtx.on("agent/turn-stopping", async () => {
|
|
1473
|
+
await variableReplyGate.commit(sessionId, async (body) => {
|
|
1474
|
+
const inline = await updateVariablesFromReply(sessionId, body);
|
|
1475
|
+
const updateCompiled = pendingAssemblies.get(sessionId)?.updateCompiled;
|
|
1476
|
+
const settings = mvuSettingsFor(binding, card);
|
|
1477
|
+
if (inline?.status !== "ignored" || updateCompiled === undefined || !settings.automaticRequest)
|
|
1478
|
+
return;
|
|
1479
|
+
try {
|
|
1480
|
+
await runSplitMvuUpdate(sessionId, binding, card, updateCompiled, body);
|
|
1481
|
+
}
|
|
1482
|
+
catch (error) {
|
|
1483
|
+
binding.splitMvu = {
|
|
1484
|
+
enabled: true,
|
|
1485
|
+
provider: binding.provider,
|
|
1486
|
+
model: binding.model,
|
|
1487
|
+
status: "failed",
|
|
1488
|
+
updatedAt: new Date().toISOString(),
|
|
1489
|
+
error: error instanceof Error ? error.message : "副模型变量更新失败",
|
|
1490
|
+
};
|
|
1491
|
+
await bindings.put(sessionId, binding);
|
|
1492
|
+
}
|
|
1493
|
+
});
|
|
1494
|
+
});
|
|
1495
|
+
};
|
|
1496
|
+
const restore = async (binding) => {
|
|
1497
|
+
if (ctx.agents.get(binding.sessionId) !== undefined)
|
|
1498
|
+
return;
|
|
1499
|
+
const waitForConcurrentPublication = async () => {
|
|
1500
|
+
if (ctx.sessions.get(binding.sessionId) === undefined)
|
|
1501
|
+
return false;
|
|
1502
|
+
for (let attempt = 0; attempt < 200; attempt += 1) {
|
|
1503
|
+
if (ctx.agents.get(binding.sessionId) !== undefined)
|
|
1504
|
+
return true;
|
|
1505
|
+
if (ctx.sessions.get(binding.sessionId) === undefined)
|
|
1506
|
+
return false;
|
|
1507
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1508
|
+
}
|
|
1509
|
+
return ctx.agents.get(binding.sessionId) !== undefined;
|
|
1510
|
+
};
|
|
1511
|
+
// A reconnecting browser can recreate this binding while plugin startup is
|
|
1512
|
+
// restoring persisted bindings. Session publication precedes Agent registry
|
|
1513
|
+
// publication by a very small window, so wait for that concurrent owner
|
|
1514
|
+
// instead of attempting a second resume for the same durable Session.
|
|
1515
|
+
if (await waitForConcurrentPublication())
|
|
1516
|
+
return;
|
|
1517
|
+
const card = cardFor(binding.revisionId);
|
|
1518
|
+
if (card === undefined)
|
|
1519
|
+
throw new Error(`会话 ${binding.sessionId} 缺少卡片 revision ${binding.revisionId}`);
|
|
1520
|
+
if (variableStates.get(binding.sessionId) === undefined)
|
|
1521
|
+
await initializeVariables(binding.sessionId, card, binding.openingId);
|
|
1522
|
+
if (card.frontendDefinition !== undefined && frontendStates.get(binding.sessionId) === undefined)
|
|
1523
|
+
await initializeFrontend(binding.sessionId, card);
|
|
1524
|
+
let handle;
|
|
1525
|
+
try {
|
|
1526
|
+
handle = await ctx.agents.resume({
|
|
1527
|
+
resumeSessionId: binding.sessionId,
|
|
1528
|
+
agentOptions: { provider: binding.provider, model: binding.model },
|
|
1529
|
+
setup: setupAgent(binding.sessionId, card, binding),
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
catch (error) {
|
|
1533
|
+
if (await waitForConcurrentPublication())
|
|
1534
|
+
return;
|
|
1535
|
+
throw error;
|
|
1536
|
+
}
|
|
1537
|
+
handles.set(binding.sessionId, handle);
|
|
1538
|
+
};
|
|
1539
|
+
const reconcileOpeningIntent = async (binding) => {
|
|
1540
|
+
const intentKey = openingIntentKey(binding.sessionId);
|
|
1541
|
+
const intent = frontendReceipts.get(intentKey);
|
|
1542
|
+
if (intent?.kind !== "opening_selection_intent" || intent.sessionId !== binding.sessionId || typeof intent.openingId !== "string")
|
|
1543
|
+
return;
|
|
1544
|
+
const session = ctx.sessions.get(binding.sessionId);
|
|
1545
|
+
if (session !== undefined && hasPlayerMessage(session.events)) {
|
|
1546
|
+
const card = cardFor(binding.revisionId);
|
|
1547
|
+
const variables = variableStates.get(binding.sessionId);
|
|
1548
|
+
const opening = card?.openings.find((candidate) => candidate.id === intent.openingId);
|
|
1549
|
+
if (card === undefined || variables === undefined || opening === undefined)
|
|
1550
|
+
return;
|
|
1551
|
+
const renderedOpening = substituteCardMacros(opening.message, { userName: effectiveUserName(binding), characterName: card.title });
|
|
1552
|
+
const initial = variables.initialSnapshots[opening.id];
|
|
1553
|
+
if (initial === undefined || initial.status !== "initialized")
|
|
1554
|
+
throw new Error("恢复开场没有可用的初始变量快照");
|
|
1555
|
+
const replay = replayMvuReplies(initial.state, assistantVariableReplies(session));
|
|
1556
|
+
if (replay.failedReplies > 0)
|
|
1557
|
+
throw new Error(`恢复开场时有 ${replay.failedReplies} 条回复未通过原子校验,原状态未改动`);
|
|
1558
|
+
const nextVariables = structuredClone(variables);
|
|
1559
|
+
nextVariables.selectedOpeningId = opening.id;
|
|
1560
|
+
nextVariables.state = replay.state;
|
|
1561
|
+
nextVariables.digest = await variableStateDigest(replay.state);
|
|
1562
|
+
nextVariables.updatedAt = new Date().toISOString();
|
|
1563
|
+
if (currentOpeningText(session) !== renderedOpening)
|
|
1564
|
+
replaceOpening(session, renderedOpening);
|
|
1565
|
+
await variableStates.put(binding.sessionId, nextVariables);
|
|
1566
|
+
binding.openingId = opening.id;
|
|
1567
|
+
binding.openingDigest = await sha256(new TextEncoder().encode(renderedOpening));
|
|
1568
|
+
await bindings.put(binding.sessionId, binding);
|
|
1569
|
+
await ctx.sessions.flush(session);
|
|
1570
|
+
await putOpeningReceipt(binding.sessionId, intent.operationId, openingState(binding, card, session));
|
|
1571
|
+
await frontendReceipts.delete(intentKey);
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
const state = await commitSessionOpening(binding.sessionId, intent.openingId);
|
|
1575
|
+
await putOpeningReceipt(binding.sessionId, intent.operationId, state);
|
|
1576
|
+
await frontendReceipts.delete(intentKey);
|
|
1577
|
+
};
|
|
1578
|
+
for (const binding of allBindings()) {
|
|
1579
|
+
await restore(binding);
|
|
1580
|
+
await reconcileOpeningIntent(binding);
|
|
1581
|
+
}
|
|
1582
|
+
disposers.push(ctx.on("llm/stream", (options, next) => {
|
|
1583
|
+
if (splitMvuRequestGuards.has(options))
|
|
1584
|
+
return next();
|
|
1585
|
+
const sessionId = typeof options.sessionId === "string" ? options.sessionId : "";
|
|
1586
|
+
const binding = bindings.get(sessionId);
|
|
1587
|
+
if (binding === undefined || options.purpose !== undefined)
|
|
1588
|
+
return next();
|
|
1589
|
+
const card = cardFor(binding.revisionId);
|
|
1590
|
+
const session = ctx.sessions.get(sessionId);
|
|
1591
|
+
const pending = pendingAssemblies.get(sessionId);
|
|
1592
|
+
if (card === undefined || session === undefined || pending === undefined)
|
|
1593
|
+
return next();
|
|
1594
|
+
const { summary, assemblySeq, compiled, activation, ejsDiagnostics } = pending;
|
|
1595
|
+
if (!compiledRequestReentryGuards.has(options)) {
|
|
1596
|
+
const directOptions = { ...options, messages: [], tools: [] };
|
|
1597
|
+
applyCompiledPromptToRequest(directOptions, compiled);
|
|
1598
|
+
compiledRequestReentryGuards.add(directOptions);
|
|
1599
|
+
return ctx.llm.stream(directOptions);
|
|
1600
|
+
}
|
|
1601
|
+
const assemblyId = summary.assemblyId;
|
|
1602
|
+
const requestText = JSON.stringify({ system: options.system ?? "", messages: options.messages ?? [] });
|
|
1603
|
+
const modelVisibleText = `${options.system ?? ""}\n${(options.messages ?? []).map(messageText).join("\n")}`;
|
|
1604
|
+
if (/<%|%>/u.test(modelVisibleText))
|
|
1605
|
+
throw Object.assign(new Error("实际模型请求仍含 EJS 源码,已阻止发送"), { code: "ejs_unresolved" });
|
|
1606
|
+
const providerStream = () => {
|
|
1607
|
+
const stream = next();
|
|
1608
|
+
return stream;
|
|
1609
|
+
};
|
|
1610
|
+
const stream = hostGlobal.process.env.DSH_RE3_RP_VERIFY === "1"
|
|
1611
|
+
? (async function* () {
|
|
1612
|
+
const text = card.frontendDefinition?.caseId === "opening-inline-action"
|
|
1613
|
+
? "警铃穿过潮雾。<MixedWatchPanel phase=\"turn-1\"/>守望员登记了这次正式行动。"
|
|
1614
|
+
: card.frontendDefinition?.caseId === "generated-multi-fragment"
|
|
1615
|
+
? "潮汐哨兵回报:<HarborSignal>白砾号已确认</HarborSignal>,随后更新值守板。<TideStatusPanel phase=\"turn-1\"/>潮位记录已归档。"
|
|
1616
|
+
: "固定验收回复:正式玩家行动已经进入 DSH Session。";
|
|
1617
|
+
yield { type: "block-start", index: 0, blockType: "text" };
|
|
1618
|
+
yield { type: "text-delta", index: 0, text };
|
|
1619
|
+
yield { type: "block-end", index: 0, block: { type: "text", text } };
|
|
1620
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
1621
|
+
})()
|
|
1622
|
+
: providerStream();
|
|
1623
|
+
return (async function* () {
|
|
1624
|
+
{
|
|
1625
|
+
const worldbookSnapshotCount = (options.messages ?? []).filter((message) => messageText(message).startsWith(TAVERN_WORLD_CONTEXT_MARKER)).length;
|
|
1626
|
+
const activatedEntries = await Promise.all(activation.active.map(async (entry) => {
|
|
1627
|
+
const projected = compiled.placement.entries.find((candidate) => candidate.id === entry.id)?.content ?? entry.content;
|
|
1628
|
+
return {
|
|
1629
|
+
id: entry.id,
|
|
1630
|
+
order: entry.order,
|
|
1631
|
+
position: entry.position,
|
|
1632
|
+
contentDigest: await sha256(new TextEncoder().encode(entry.content)),
|
|
1633
|
+
projectedContentDigest: await sha256(new TextEncoder().encode(projected)),
|
|
1634
|
+
presentInActualRequest: modelVisibleText.includes(projected),
|
|
1635
|
+
};
|
|
1636
|
+
}));
|
|
1637
|
+
const unresolvedMacroKinds = [...new Set(Array.from(modelVisibleText.matchAll(/\{\{\s*([a-z_][a-z0-9_-]*)\b[^{}]*\}\}/giu), (match) => match[1].toLocaleLowerCase()))].sort();
|
|
1638
|
+
const variableState = variableStates.get(sessionId);
|
|
1639
|
+
await traces.put(assemblyId, {
|
|
1640
|
+
traceId: assemblyId,
|
|
1641
|
+
assemblyId,
|
|
1642
|
+
assemblySeq,
|
|
1643
|
+
sessionId,
|
|
1644
|
+
revisionId: binding.revisionId,
|
|
1645
|
+
openingId: binding.openingId,
|
|
1646
|
+
normalizedIndexVersion: binding.normalizedIndexVersion,
|
|
1647
|
+
capturedAt: new Date().toISOString(),
|
|
1648
|
+
provider: options.provider,
|
|
1649
|
+
model: options.model,
|
|
1650
|
+
requestDigest: await sha256(new TextEncoder().encode(requestText)),
|
|
1651
|
+
systemLength: typeof options.system === "string" ? options.system.length : 0,
|
|
1652
|
+
containsHarnessIdentity: /AI agent powered by DeepSeek Harness|DeepSeek Harness implementation checkout|DSH itself/iu.test(options.system ?? ""),
|
|
1653
|
+
toolCount: Array.isArray(options.tools) ? options.tools.length : 0,
|
|
1654
|
+
variableStateDigest: variableState?.digest ?? null,
|
|
1655
|
+
unresolvedMacroKinds,
|
|
1656
|
+
ejsDiagnostics,
|
|
1657
|
+
worldbookContextRevision: worldbookContextRevision(ctx.sessions.get(sessionId)),
|
|
1658
|
+
worldbookSurfaceSeq: currentWorldbookSurfaceSeq(ctx.sessions.get(sessionId)) ?? null,
|
|
1659
|
+
worldbookSnapshotCount,
|
|
1660
|
+
messageRoles: (options.messages ?? []).map((message) => message.role),
|
|
1661
|
+
messageLengths: (options.messages ?? []).map((message) => messageText(message).length),
|
|
1662
|
+
generation: {
|
|
1663
|
+
temperature: options.temperature ?? null,
|
|
1664
|
+
maxTokens: options.maxTokens ?? null,
|
|
1665
|
+
stream: true,
|
|
1666
|
+
contextTokens: compiled.settings.contextTokens,
|
|
1667
|
+
estimatedPromptTokens: compiled.stats.estimatedTokens,
|
|
1668
|
+
prunedChatMessages: compiled.stats.prunedChatMessages,
|
|
1669
|
+
prunedExampleMessages: compiled.stats.prunedExampleMessages,
|
|
1670
|
+
providerNeutralUnsupported: {
|
|
1671
|
+
topP: compiled.settings.topP,
|
|
1672
|
+
frequencyPenalty: compiled.settings.frequencyPenalty,
|
|
1673
|
+
presencePenalty: compiled.settings.presencePenalty,
|
|
1674
|
+
},
|
|
1675
|
+
},
|
|
1676
|
+
activatedEntries,
|
|
1677
|
+
activationPasses: activation.passes,
|
|
1678
|
+
assembly: {
|
|
1679
|
+
summary,
|
|
1680
|
+
preset: compiled.preset,
|
|
1681
|
+
stats: compiled.stats,
|
|
1682
|
+
blocks: compiled.blocks,
|
|
1683
|
+
placements: compiled.placement.entries.map(({ content: _content, ...entry }) => entry),
|
|
1684
|
+
activation: compiled.activation.trace,
|
|
1685
|
+
messages: compiled.messages,
|
|
1686
|
+
actualRequest: {
|
|
1687
|
+
system: typeof options.system === "string" ? options.system : "",
|
|
1688
|
+
messages: (options.messages ?? []).map((message) => ({ role: message.role, content: messageText(message) })),
|
|
1689
|
+
toolCount: Array.isArray(options.tools) ? options.tools.length : 0,
|
|
1690
|
+
generation: {
|
|
1691
|
+
temperature: options.temperature ?? null,
|
|
1692
|
+
maxTokens: options.maxTokens ?? null,
|
|
1693
|
+
stream: true,
|
|
1694
|
+
},
|
|
1695
|
+
},
|
|
1696
|
+
},
|
|
1697
|
+
});
|
|
1698
|
+
await bindings.put(sessionId, binding);
|
|
1699
|
+
await ctx.sessions.flush(session);
|
|
1700
|
+
}
|
|
1701
|
+
let assistantBody = "";
|
|
1702
|
+
for await (const chunk of stream) {
|
|
1703
|
+
if (chunk?.type === "text-delta" && typeof chunk.text === "string")
|
|
1704
|
+
assistantBody += chunk.text;
|
|
1705
|
+
yield chunk;
|
|
1706
|
+
}
|
|
1707
|
+
variableReplyGate.capture(sessionId, assistantBody);
|
|
1708
|
+
})();
|
|
1709
|
+
}, { global: true }));
|
|
1710
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/cards", handler: async (req, res) => {
|
|
1711
|
+
const cardRecords = () => Array.from(cards.entries(), ([, card]) => card);
|
|
1712
|
+
const value = () => ({ cards: orderVisibleCards(cardRecords()).map((card) => publicCard(card, allBindings(), (sessionId) => ctx.sessions.get(sessionId))) });
|
|
1713
|
+
if (req.method === "GET" || req.method === "HEAD") {
|
|
1714
|
+
if (req.method === "HEAD") {
|
|
1715
|
+
res.writeHead(200, { "Cache-Control": "no-store" });
|
|
1716
|
+
res.end();
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
jsonBody(res, 200, value());
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
try {
|
|
1723
|
+
if (req.method === "PATCH") {
|
|
1724
|
+
const input = await readJson(req);
|
|
1725
|
+
const revisionIds = Array.isArray(input.revisionIds) ? input.revisionIds.filter((item) => typeof item === "string") : [];
|
|
1726
|
+
const reordered = reorderVisibleCards(cardRecords(), revisionIds);
|
|
1727
|
+
for (const card of reordered)
|
|
1728
|
+
await cards.put(card.revisionId, card);
|
|
1729
|
+
jsonBody(res, 200, { ok: true, ...value() });
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1732
|
+
if (req.method === "DELETE") {
|
|
1733
|
+
const input = await readJson(req);
|
|
1734
|
+
const revisionId = typeof input.revisionId === "string" ? input.revisionId : "";
|
|
1735
|
+
const card = cards.get(revisionId);
|
|
1736
|
+
if (card === undefined || card.libraryHidden === true)
|
|
1737
|
+
throw new Error("找不到要从卡库删除的酒馆卡");
|
|
1738
|
+
await cards.put(revisionId, hideCardFromLibrary(card));
|
|
1739
|
+
jsonBody(res, 200, { ok: true, preservedSessions: allBindings().filter((binding) => binding.revisionId === revisionId).length, ...value() });
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
res.writeHead(405, { Allow: "GET, HEAD, PATCH, DELETE" });
|
|
1743
|
+
res.end();
|
|
1744
|
+
}
|
|
1745
|
+
catch (error) {
|
|
1746
|
+
jsonBody(res, 400, { ok: false, error: error instanceof Error ? error.message : "酒馆卡库操作失败" });
|
|
1747
|
+
}
|
|
1748
|
+
} }));
|
|
1749
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/import", handler: async (req, res) => {
|
|
1750
|
+
if (req.method !== "POST") {
|
|
1751
|
+
res.writeHead(405, { Allow: "POST" });
|
|
1752
|
+
res.end();
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
try {
|
|
1756
|
+
const encodedName = typeof req.headers["x-dsh-re3-rp-filename"] === "string" ? req.headers["x-dsh-re3-rp-filename"] : "card.json";
|
|
1757
|
+
const sourceName = decodeURIComponent(encodedName).replace(/[\\/]/gu, "_");
|
|
1758
|
+
const bytes = await readBody(req);
|
|
1759
|
+
const parsedCard = await parseCard(bytes, sourceName, (input) => new Uint8Array(zlib.inflateSync(input)));
|
|
1760
|
+
const storedCard = cardFor(parsedCard.revisionId);
|
|
1761
|
+
const card = storedCard?.normalizedIndexVersion === NORMALIZED_CARD_INDEX_VERSION ? storedCard : parsedCard;
|
|
1762
|
+
const blobPath = path.join(blobRoot, card.revisionId);
|
|
1763
|
+
if (!fs.existsSync(blobPath)) {
|
|
1764
|
+
const temporary = path.join(blobRoot, `.${card.revisionId}.${crypto.randomUUID()}.tmp`);
|
|
1765
|
+
fs.writeFileSync(temporary, bytes, { flag: "wx" });
|
|
1766
|
+
try {
|
|
1767
|
+
fs.renameSync(temporary, blobPath);
|
|
1768
|
+
}
|
|
1769
|
+
finally {
|
|
1770
|
+
if (fs.existsSync(temporary))
|
|
1771
|
+
fs.unlinkSync(temporary);
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
const restored = restoreCardToLibrary(card, storedCard);
|
|
1775
|
+
await cards.put(card.revisionId, restored);
|
|
1776
|
+
jsonBody(res, 201, { ok: true, card: publicCard(restored, allBindings()) });
|
|
1777
|
+
}
|
|
1778
|
+
catch (error) {
|
|
1779
|
+
jsonBody(res, 400, { ok: false, error: error instanceof Error ? error.message : "无法导入角色卡" });
|
|
1780
|
+
}
|
|
1781
|
+
} }));
|
|
1782
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/original", handler: (req, res) => {
|
|
1783
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1784
|
+
res.writeHead(405, { Allow: "GET, HEAD" });
|
|
1785
|
+
res.end();
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
const revisionId = new URL(req.url, "http://127.0.0.1").searchParams.get("revision") ?? "";
|
|
1789
|
+
const card = cardFor(revisionId);
|
|
1790
|
+
const blobPath = path.join(blobRoot, revisionId);
|
|
1791
|
+
if (card === undefined || !fs.existsSync(blobPath)) {
|
|
1792
|
+
jsonBody(res, 404, { ok: false, error: "原件不存在" });
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
const body = fs.readFileSync(blobPath);
|
|
1796
|
+
res.writeHead(200, { "Content-Type": card.sourceName.toLocaleLowerCase().endsWith(".png") ? "image/png" : "application/json", "Content-Length": body.byteLength, "Cache-Control": "private, max-age=60", "ETag": `\"${revisionId}\"` });
|
|
1797
|
+
res.end(req.method === "HEAD" ? undefined : body);
|
|
1798
|
+
} }));
|
|
1799
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/persona-avatar", handler: (req, res) => {
|
|
1800
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1801
|
+
res.writeHead(405, { Allow: "GET, HEAD" });
|
|
1802
|
+
res.end();
|
|
1803
|
+
return;
|
|
1804
|
+
}
|
|
1805
|
+
const avatar = new URL(req.url, "http://127.0.0.1").searchParams.get("avatar") ?? "";
|
|
1806
|
+
const avatars = {
|
|
1807
|
+
default: { background: "#475569", label: "默" },
|
|
1808
|
+
traveler: { background: "#0f766e", label: "旅" },
|
|
1809
|
+
"northern-ranger": { background: "#1d4ed8", label: "北" },
|
|
1810
|
+
"jianghu-wanderer": { background: "#9a3412", label: "侠" },
|
|
1811
|
+
};
|
|
1812
|
+
const selected = avatars[avatar];
|
|
1813
|
+
if (selected === undefined) {
|
|
1814
|
+
jsonBody(res, 404, { ok: false, error: "找不到这个 Persona 头像" });
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
const body = new TextEncoder().encode(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><rect width="96" height="96" rx="24" fill="${selected.background}"/><text x="48" y="61" text-anchor="middle" font-family="system-ui,sans-serif" font-size="42" fill="#fff">${selected.label}</text></svg>`);
|
|
1818
|
+
res.writeHead(200, { "Content-Type": "image/svg+xml; charset=utf-8", "Content-Length": body.byteLength, "Cache-Control": "public, max-age=31536000, immutable" });
|
|
1819
|
+
res.end(req.method === "HEAD" ? undefined : body);
|
|
1820
|
+
} }));
|
|
1821
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/personas", handler: async (req, res) => {
|
|
1822
|
+
if (req.method !== "GET" && req.method !== "POST") {
|
|
1823
|
+
res.writeHead(405, { Allow: "GET, POST" });
|
|
1824
|
+
res.end();
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1827
|
+
try {
|
|
1828
|
+
const input = req.method === "POST" ? await readJson(req) : {};
|
|
1829
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
1830
|
+
const sessionId = req.method === "POST"
|
|
1831
|
+
? (typeof input.sessionId === "string" ? input.sessionId : "")
|
|
1832
|
+
: url.searchParams.get("sessionId") ?? "";
|
|
1833
|
+
const sessionBinding = sessionId.length === 0 ? undefined : bindings.get(sessionId);
|
|
1834
|
+
if (sessionId.length > 0 && sessionBinding === undefined) {
|
|
1835
|
+
jsonBody(res, 404, { ok: false, error: "没有找到这个酒馆 Session" });
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
const requestedRevision = req.method === "POST"
|
|
1839
|
+
? (typeof input.revisionId === "string" ? input.revisionId : "")
|
|
1840
|
+
: url.searchParams.get("revision") ?? "";
|
|
1841
|
+
const revisionId = sessionBinding?.revisionId ?? requestedRevision;
|
|
1842
|
+
const context = { revisionId, sessionId };
|
|
1843
|
+
if (req.method === "GET") {
|
|
1844
|
+
jsonBody(res, 200, personaLibraryState(context));
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
const action = typeof input.action === "string" ? input.action : "";
|
|
1848
|
+
let createdPersonaId = null;
|
|
1849
|
+
if (action === "create") {
|
|
1850
|
+
const draft = validatePersonaDraft({
|
|
1851
|
+
displayName: typeof input.displayName === "string" ? input.displayName : "未命名 Persona",
|
|
1852
|
+
content: typeof input.content === "string" ? input.content : "",
|
|
1853
|
+
avatar: input.avatar,
|
|
1854
|
+
}, "default");
|
|
1855
|
+
const now = new Date().toISOString();
|
|
1856
|
+
createdPersonaId = crypto.randomUUID();
|
|
1857
|
+
const record = { id: createdPersonaId, ...draft, createdAt: now, updatedAt: now };
|
|
1858
|
+
await personas.put(record.id, record);
|
|
1859
|
+
}
|
|
1860
|
+
else if (action === "update") {
|
|
1861
|
+
const personaId = typeof input.personaId === "string" ? input.personaId : "";
|
|
1862
|
+
const existing = personas.get(personaId);
|
|
1863
|
+
if (existing === undefined)
|
|
1864
|
+
throw new Error("找不到这个 Persona");
|
|
1865
|
+
const draft = validatePersonaDraft(input, existing.avatar);
|
|
1866
|
+
await personas.put(personaId, { ...existing, ...draft, updatedAt: new Date().toISOString() });
|
|
1867
|
+
}
|
|
1868
|
+
else if (action === "bind") {
|
|
1869
|
+
const personaId = typeof input.personaId === "string" ? input.personaId : "";
|
|
1870
|
+
if (personas.get(personaId) === undefined)
|
|
1871
|
+
throw new Error("找不到要绑定的 Persona");
|
|
1872
|
+
const scope = input.scope;
|
|
1873
|
+
if (scope !== "global" && scope !== "card" && scope !== "session")
|
|
1874
|
+
throw new Error("Persona 绑定范围无效");
|
|
1875
|
+
if (scope === "card" && (revisionId.length === 0 || cardFor(revisionId) === undefined))
|
|
1876
|
+
throw new Error("当前没有可绑定的酒馆卡");
|
|
1877
|
+
if (scope === "session" && sessionBinding === undefined)
|
|
1878
|
+
throw new Error("当前没有可绑定的 Session");
|
|
1879
|
+
const targetId = scope === "global" ? "default" : scope === "card" ? revisionId : sessionId;
|
|
1880
|
+
const record = { scope, targetId, personaId, updatedAt: new Date().toISOString() };
|
|
1881
|
+
await personaBindings.put(personaBindingKey(scope, targetId), record);
|
|
1882
|
+
for (const bindingKey of personaBindingKeysToClearForSelection(scope, context))
|
|
1883
|
+
await personaBindings.delete(bindingKey);
|
|
1884
|
+
}
|
|
1885
|
+
else if (action === "clear") {
|
|
1886
|
+
const scope = input.scope;
|
|
1887
|
+
if (scope !== "global" && scope !== "card" && scope !== "session")
|
|
1888
|
+
throw new Error("Persona 绑定范围无效");
|
|
1889
|
+
const targetId = scope === "global" ? "default" : scope === "card" ? revisionId : sessionId;
|
|
1890
|
+
await personaBindings.delete(personaBindingKey(scope, targetId));
|
|
1891
|
+
}
|
|
1892
|
+
else
|
|
1893
|
+
throw new Error("不支持这个 Persona 操作");
|
|
1894
|
+
jsonBody(res, action === "create" ? 201 : 200, { ...personaLibraryState(context), createdPersonaId });
|
|
1895
|
+
}
|
|
1896
|
+
catch (error) {
|
|
1897
|
+
jsonBody(res, 400, { ok: false, error: error instanceof Error ? error.message : "Persona 操作失败" });
|
|
1898
|
+
}
|
|
1899
|
+
} }));
|
|
1900
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/opening", handler: async (req, res) => {
|
|
1901
|
+
if (req.method !== "GET" && req.method !== "POST") {
|
|
1902
|
+
res.writeHead(405, { Allow: "GET, POST" });
|
|
1903
|
+
res.end();
|
|
1904
|
+
return;
|
|
1905
|
+
}
|
|
1906
|
+
try {
|
|
1907
|
+
const input = req.method === "POST" ? await readJson(req) : {};
|
|
1908
|
+
const requestUrl = new URL(req.url, "http://127.0.0.1");
|
|
1909
|
+
const sessionId = req.method === "POST"
|
|
1910
|
+
? (typeof input.sessionId === "string" ? input.sessionId : "")
|
|
1911
|
+
: requestUrl.searchParams.get("sessionId") ?? "";
|
|
1912
|
+
const binding = bindings.get(sessionId);
|
|
1913
|
+
const session = ctx.sessions.get(sessionId);
|
|
1914
|
+
const card = binding === undefined ? undefined : cardFor(binding.revisionId);
|
|
1915
|
+
if (binding === undefined || session === undefined || card === undefined) {
|
|
1916
|
+
if (req.method === "GET" && requestUrl.searchParams.get("optional") === "1") {
|
|
1917
|
+
res.writeHead(204);
|
|
1918
|
+
res.end();
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1921
|
+
jsonBody(res, 404, { ok: false, error: "这不是可切换开场的酒馆会话" });
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
if (req.method === "GET") {
|
|
1925
|
+
jsonBody(res, 200, openingState(binding, card, session));
|
|
1926
|
+
return;
|
|
1927
|
+
}
|
|
1928
|
+
const openingId = typeof input.openingId === "string" ? input.openingId : "";
|
|
1929
|
+
jsonBody(res, 200, await selectSessionOpening(sessionId, openingId));
|
|
1930
|
+
}
|
|
1931
|
+
catch (error) {
|
|
1932
|
+
const failure = error;
|
|
1933
|
+
jsonBody(res, failure.code === "opening_locked" ? 409 : failure.code === "bridge_unavailable" ? 404 : 400, { ok: false, error: failure.message ?? "无法切换开场" });
|
|
1934
|
+
}
|
|
1935
|
+
} }));
|
|
1936
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/presets", handler: async (req, res) => {
|
|
1937
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
1938
|
+
if (req.method === "GET" && url.searchParams.has("download")) {
|
|
1939
|
+
const presetId = url.searchParams.get("download") ?? "";
|
|
1940
|
+
const preset = presetFor(presetId);
|
|
1941
|
+
if (preset.id !== presetId) {
|
|
1942
|
+
jsonBody(res, 404, { ok: false, error: "找不到这个预设" });
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
const body = JSON.stringify(exportSillyTavernPreset(preset), null, 2);
|
|
1946
|
+
const filename = `${preset.name.replace(/[\\/:*?"<>|]/gu, "_") || "preset"}.json`;
|
|
1947
|
+
res.writeHead(200, {
|
|
1948
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
1949
|
+
"Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
|
1950
|
+
"Cache-Control": "no-store",
|
|
1951
|
+
"Content-Length": new TextEncoder().encode(body).byteLength,
|
|
1952
|
+
});
|
|
1953
|
+
res.end(body);
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1956
|
+
if (req.method === "GET") {
|
|
1957
|
+
jsonBody(res, 200, presetState(url.searchParams.get("sessionId") ?? ""));
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
if (req.method !== "POST") {
|
|
1961
|
+
res.writeHead(405, { Allow: "GET, POST" });
|
|
1962
|
+
res.end();
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
try {
|
|
1966
|
+
const input = await readJson(req, 8 * 1024 * 1024);
|
|
1967
|
+
const action = typeof input.action === "string" ? input.action : "";
|
|
1968
|
+
const now = new Date().toISOString();
|
|
1969
|
+
if (action === "import") {
|
|
1970
|
+
if (typeof input.preset !== "object" || input.preset === null || Array.isArray(input.preset))
|
|
1971
|
+
throw new Error("导入内容不是酒馆 Chat Completion preset JSON");
|
|
1972
|
+
const requestedName = typeof input.name === "string" ? input.name.replace(/\.json$/iu, "") : "Imported preset";
|
|
1973
|
+
const id = crypto.randomUUID();
|
|
1974
|
+
const preset = normalizeTavernPreset(input.preset, { id, name: uniquePresetName(requestedName), source: "imported", now });
|
|
1975
|
+
await storedPresets.put(id, preset);
|
|
1976
|
+
await presetSettings.put("active", { presetId: id, updatedAt: now });
|
|
1977
|
+
const sessionId = typeof input.sessionId === "string" ? input.sessionId : "";
|
|
1978
|
+
const binding = bindings.get(sessionId);
|
|
1979
|
+
if (binding !== undefined) {
|
|
1980
|
+
binding.presetId = id;
|
|
1981
|
+
await bindings.put(sessionId, binding);
|
|
1982
|
+
}
|
|
1983
|
+
jsonBody(res, 201, { ...presetState(sessionId), importedPresetId: id });
|
|
1984
|
+
return;
|
|
1985
|
+
}
|
|
1986
|
+
if (action === "create") {
|
|
1987
|
+
const base = presetFor(typeof input.basePresetId === "string" ? input.basePresetId : activePresetId());
|
|
1988
|
+
const id = crypto.randomUUID();
|
|
1989
|
+
const requestedName = typeof input.name === "string" ? input.name : `${base.name} 副本`;
|
|
1990
|
+
const preset = normalizeTavernPreset({ ...base, revision: 1, createdAt: now, updatedAt: now }, { id, name: uniquePresetName(requestedName), source: "created", now });
|
|
1991
|
+
await storedPresets.put(id, preset);
|
|
1992
|
+
await presetSettings.put("active", { presetId: id, updatedAt: now });
|
|
1993
|
+
const sessionId = typeof input.sessionId === "string" ? input.sessionId : "";
|
|
1994
|
+
const binding = bindings.get(sessionId);
|
|
1995
|
+
if (binding !== undefined) {
|
|
1996
|
+
binding.presetId = id;
|
|
1997
|
+
await bindings.put(sessionId, binding);
|
|
1998
|
+
}
|
|
1999
|
+
jsonBody(res, 201, { ...presetState(sessionId), createdPresetId: id });
|
|
2000
|
+
return;
|
|
2001
|
+
}
|
|
2002
|
+
if (action === "save") {
|
|
2003
|
+
const requested = input.preset;
|
|
2004
|
+
if (typeof requested !== "object" || requested === null || Array.isArray(requested))
|
|
2005
|
+
throw new Error("缺少要保存的预设");
|
|
2006
|
+
let id = typeof requested.id === "string" ? requested.id : "";
|
|
2007
|
+
if (id.length === 0)
|
|
2008
|
+
throw new Error("缺少要保存的预设 id");
|
|
2009
|
+
if (id === DEFAULT_TAVERN_PRESET.id) {
|
|
2010
|
+
id = crypto.randomUUID();
|
|
2011
|
+
const requestedName = typeof requested.name === "string" ? requested.name : DEFAULT_TAVERN_PRESET.name;
|
|
2012
|
+
const preset = normalizeTavernPreset({ ...requested, revision: 1, createdAt: now, updatedAt: now }, { id, name: uniquePresetName(`${requestedName} 副本`), source: "created", now });
|
|
2013
|
+
await storedPresets.put(id, preset);
|
|
2014
|
+
await presetSettings.put("active", { presetId: id, updatedAt: now });
|
|
2015
|
+
const sessionId = typeof input.sessionId === "string" ? input.sessionId : "";
|
|
2016
|
+
const binding = bindings.get(sessionId);
|
|
2017
|
+
if (binding !== undefined) {
|
|
2018
|
+
binding.presetId = id;
|
|
2019
|
+
await bindings.put(sessionId, binding);
|
|
2020
|
+
}
|
|
2021
|
+
jsonBody(res, 201, { ...presetState(sessionId), savedPresetId: id });
|
|
2022
|
+
return;
|
|
2023
|
+
}
|
|
2024
|
+
const existingValue = storedPresets.get(id);
|
|
2025
|
+
if (existingValue === undefined)
|
|
2026
|
+
throw new Error("要保存的预设已经不存在");
|
|
2027
|
+
const existing = presetFor(id);
|
|
2028
|
+
const requestedName = typeof requested.name === "string" ? requested.name : existing.name;
|
|
2029
|
+
const preset = normalizeTavernPreset({ ...requested, revision: existing.revision + 1, createdAt: existing.createdAt, updatedAt: now }, { id, name: uniquePresetName(requestedName, id), source: existing.source, now });
|
|
2030
|
+
await storedPresets.put(id, preset);
|
|
2031
|
+
jsonBody(res, 200, { ...presetState(typeof input.sessionId === "string" ? input.sessionId : ""), savedPresetId: id });
|
|
2032
|
+
return;
|
|
2033
|
+
}
|
|
2034
|
+
if (action === "activate" || action === "bind") {
|
|
2035
|
+
const preset = presetFor(typeof input.presetId === "string" ? input.presetId : "");
|
|
2036
|
+
if (typeof input.presetId !== "string" || preset.id !== input.presetId)
|
|
2037
|
+
throw new Error("找不到要启用的预设");
|
|
2038
|
+
await presetSettings.put("active", { presetId: preset.id, updatedAt: now });
|
|
2039
|
+
if (action === "bind") {
|
|
2040
|
+
const sessionId = typeof input.sessionId === "string" ? input.sessionId : "";
|
|
2041
|
+
const binding = bindings.get(sessionId);
|
|
2042
|
+
if (binding === undefined)
|
|
2043
|
+
throw new Error("当前页面没有可绑定预设的酒馆 Session");
|
|
2044
|
+
binding.presetId = preset.id;
|
|
2045
|
+
await bindings.put(sessionId, binding);
|
|
2046
|
+
}
|
|
2047
|
+
jsonBody(res, 200, presetState(typeof input.sessionId === "string" ? input.sessionId : ""));
|
|
2048
|
+
return;
|
|
2049
|
+
}
|
|
2050
|
+
if (action === "delete") {
|
|
2051
|
+
const presetId = typeof input.presetId === "string" ? input.presetId : "";
|
|
2052
|
+
if (presetId === DEFAULT_TAVERN_PRESET.id)
|
|
2053
|
+
throw new Error("内置 Default 不可删除");
|
|
2054
|
+
if (storedPresets.get(presetId) === undefined)
|
|
2055
|
+
throw new Error("要删除的预设已经不存在");
|
|
2056
|
+
const usedBy = allBindings().filter((binding) => binding.presetId === presetId);
|
|
2057
|
+
if (usedBy.length > 0) {
|
|
2058
|
+
jsonBody(res, 409, { ok: false, error: `还有 ${usedBy.length} 个 Session 正在使用这个预设,请先切换这些 Session` });
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
const wasActive = activePresetId() === presetId;
|
|
2062
|
+
await storedPresets.delete(presetId);
|
|
2063
|
+
if (wasActive)
|
|
2064
|
+
await presetSettings.put("active", { presetId: DEFAULT_TAVERN_PRESET.id, updatedAt: now });
|
|
2065
|
+
jsonBody(res, 200, presetState(typeof input.sessionId === "string" ? input.sessionId : ""));
|
|
2066
|
+
return;
|
|
2067
|
+
}
|
|
2068
|
+
throw new Error("未知的预设操作");
|
|
2069
|
+
}
|
|
2070
|
+
catch (error) {
|
|
2071
|
+
jsonBody(res, 400, { ok: false, error: error instanceof Error ? error.message : "预设操作失败" });
|
|
2072
|
+
}
|
|
2073
|
+
} }));
|
|
2074
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/sessions", handler: async (req, res) => {
|
|
2075
|
+
if (req.method === "DELETE" && hostGlobal.process.env.DSH_RE3_RP_VERIFY === "1") {
|
|
2076
|
+
const input = await readJson(req);
|
|
2077
|
+
const sessionId = typeof input.sessionId === "string" ? input.sessionId : "";
|
|
2078
|
+
const handle = handles.get(sessionId);
|
|
2079
|
+
if (handle !== undefined) {
|
|
2080
|
+
handles.delete(sessionId);
|
|
2081
|
+
await handle.dispose();
|
|
2082
|
+
}
|
|
2083
|
+
await bindings.delete(sessionId);
|
|
2084
|
+
await personaBindings.delete(personaBindingKey("session", sessionId));
|
|
2085
|
+
variableReplyGate.discard(sessionId);
|
|
2086
|
+
await variableStates.delete(sessionId);
|
|
2087
|
+
for (const [key, value] of variableEvents.entries())
|
|
2088
|
+
if (value.sessionId === sessionId)
|
|
2089
|
+
await variableEvents.delete(key);
|
|
2090
|
+
await frontendStates.delete(sessionId);
|
|
2091
|
+
for (const [key, value] of frontendEvents.entries())
|
|
2092
|
+
if (value.sessionId === sessionId)
|
|
2093
|
+
await frontendEvents.delete(key);
|
|
2094
|
+
for (const [key, value] of frontendReceipts.entries())
|
|
2095
|
+
if (value.sessionId === sessionId)
|
|
2096
|
+
await frontendReceipts.delete(key);
|
|
2097
|
+
for (const [key, value] of frontendAssets.entries())
|
|
2098
|
+
if (value.sessionId === sessionId)
|
|
2099
|
+
await frontendAssets.delete(key);
|
|
2100
|
+
jsonBody(res, 200, { ok: true, sessionId });
|
|
2101
|
+
return;
|
|
2102
|
+
}
|
|
2103
|
+
if (req.method !== "POST") {
|
|
2104
|
+
res.writeHead(405, { Allow: "POST" });
|
|
2105
|
+
res.end();
|
|
2106
|
+
return;
|
|
2107
|
+
}
|
|
2108
|
+
let sessionId = "";
|
|
2109
|
+
try {
|
|
2110
|
+
const input = await readJson(req);
|
|
2111
|
+
const revisionId = typeof input.revisionId === "string" ? input.revisionId : "";
|
|
2112
|
+
const openingId = typeof input.openingId === "string" ? input.openingId : "";
|
|
2113
|
+
const userName = typeof input.userName === "string" ? input.userName.trim() : "";
|
|
2114
|
+
const card = cardFor(revisionId);
|
|
2115
|
+
const opening = card?.openings.find((candidate) => candidate.id === openingId);
|
|
2116
|
+
if (card === undefined || opening === undefined || userName.length === 0)
|
|
2117
|
+
throw new Error("缺少卡片 revision、开场选择或玩家名字");
|
|
2118
|
+
if (card.playability === "blocked")
|
|
2119
|
+
throw new Error(card.statusDetail);
|
|
2120
|
+
const runtimeMissing = missingRuntimeCriticalApis(card);
|
|
2121
|
+
if (runtimeMissing.length > 0)
|
|
2122
|
+
throw new Error(`当前 Host 运行时缺少卡内启动关键接口:${runtimeMissing.join("、")}`);
|
|
2123
|
+
sessionId = crypto.randomUUID();
|
|
2124
|
+
const selection = ctx.agentDefaultModel.currentSelection();
|
|
2125
|
+
const inheritedPersona = resolvedPersona({ revisionId })?.persona;
|
|
2126
|
+
const selectedPreset = presetFor(typeof input.presetId === "string" ? input.presetId : activePresetId());
|
|
2127
|
+
const renderedOpening = substituteCardMacros(opening.message, { userName: inheritedPersona?.displayName ?? userName, characterName: card.title });
|
|
2128
|
+
const binding = {
|
|
2129
|
+
sessionId, revisionId, openingId, userName,
|
|
2130
|
+
openingDigest: await sha256(new TextEncoder().encode(renderedOpening)),
|
|
2131
|
+
provider: selection.provider, model: selection.model, presetId: selectedPreset.id,
|
|
2132
|
+
createdAt: new Date().toISOString(), normalizedIndexVersion: NORMALIZED_CARD_INDEX_VERSION,
|
|
2133
|
+
lastAssemblyId: `${sessionId}:prepared`,
|
|
2134
|
+
lastActiveEntryIds: [],
|
|
2135
|
+
mvuSettings: defaultMvuSessionSettings({ provider: selection.provider, model: selection.model, supportsExtraModel: supportsExtraModel(card) }),
|
|
2136
|
+
};
|
|
2137
|
+
await bindings.put(sessionId, binding);
|
|
2138
|
+
await initializeVariables(sessionId, card, openingId);
|
|
2139
|
+
await initializeFrontend(sessionId, card);
|
|
2140
|
+
const handle = await ctx.agents.create({
|
|
2141
|
+
sessionId,
|
|
2142
|
+
seed: createTavernSessionSeed(renderedOpening, [], Date.now(), {
|
|
2143
|
+
assemblyId: `${sessionId}:prepared`,
|
|
2144
|
+
presetName: selectedPreset.name,
|
|
2145
|
+
activeEntries: 0,
|
|
2146
|
+
filteredEntries: card.worldbook.length,
|
|
2147
|
+
depthInjections: 0,
|
|
2148
|
+
messageCount: 0,
|
|
2149
|
+
characterCount: 0,
|
|
2150
|
+
addedEntryIds: [],
|
|
2151
|
+
removedEntryIds: [],
|
|
2152
|
+
previousAssemblyId: null,
|
|
2153
|
+
stage: "prepared",
|
|
2154
|
+
}),
|
|
2155
|
+
meta: { cwd: hostGlobal.process.cwd() },
|
|
2156
|
+
agentOptions: { provider: binding.provider, model: binding.model },
|
|
2157
|
+
setup: setupAgent(sessionId, card, binding),
|
|
2158
|
+
});
|
|
2159
|
+
handles.set(sessionId, handle);
|
|
2160
|
+
await ctx.sessions.flush(handle.agent.session);
|
|
2161
|
+
jsonBody(res, 201, { ok: true, sessionId, revisionId, openingId, openingDigest: binding.openingDigest });
|
|
2162
|
+
}
|
|
2163
|
+
catch (error) {
|
|
2164
|
+
if (sessionId.length > 0) {
|
|
2165
|
+
const handle = handles.get(sessionId);
|
|
2166
|
+
if (handle !== undefined) {
|
|
2167
|
+
handles.delete(sessionId);
|
|
2168
|
+
await handle.dispose();
|
|
2169
|
+
}
|
|
2170
|
+
await bindings.delete(sessionId);
|
|
2171
|
+
await personaBindings.delete(personaBindingKey("session", sessionId));
|
|
2172
|
+
variableReplyGate.discard(sessionId);
|
|
2173
|
+
await variableStates.delete(sessionId);
|
|
2174
|
+
for (const [key, value] of variableEvents.entries())
|
|
2175
|
+
if (value.sessionId === sessionId)
|
|
2176
|
+
await variableEvents.delete(key);
|
|
2177
|
+
await frontendStates.delete(sessionId);
|
|
2178
|
+
for (const [key, value] of frontendEvents.entries())
|
|
2179
|
+
if (value.sessionId === sessionId)
|
|
2180
|
+
await frontendEvents.delete(key);
|
|
2181
|
+
for (const [key, value] of frontendReceipts.entries())
|
|
2182
|
+
if (value.sessionId === sessionId)
|
|
2183
|
+
await frontendReceipts.delete(key);
|
|
2184
|
+
for (const [key, value] of frontendAssets.entries())
|
|
2185
|
+
if (value.sessionId === sessionId)
|
|
2186
|
+
await frontendAssets.delete(key);
|
|
2187
|
+
}
|
|
2188
|
+
jsonBody(res, 400, { ok: false, error: error instanceof Error ? error.message : "无法创建卡片会话" });
|
|
2189
|
+
}
|
|
2190
|
+
} }));
|
|
2191
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/session-proof", handler: async (req, res) => {
|
|
2192
|
+
if (req.method !== "GET") {
|
|
2193
|
+
res.writeHead(405, { Allow: "GET" });
|
|
2194
|
+
res.end();
|
|
2195
|
+
return;
|
|
2196
|
+
}
|
|
2197
|
+
const sessionId = new URL(req.url, "http://127.0.0.1").searchParams.get("sessionId") ?? "";
|
|
2198
|
+
const binding = bindings.get(sessionId);
|
|
2199
|
+
const session = ctx.sessions.get(sessionId);
|
|
2200
|
+
if (binding === undefined || session === undefined) {
|
|
2201
|
+
jsonBody(res, 404, { ok: false, error: "找不到卡片会话" });
|
|
2202
|
+
return;
|
|
2203
|
+
}
|
|
2204
|
+
const openingSeq = currentOpeningSurfaceSeq(session);
|
|
2205
|
+
const firstAssistant = openingSeq === undefined ? undefined : session.events[openingSeq];
|
|
2206
|
+
const firstText = messageText(firstAssistant?.data?.message ?? firstAssistant?.data);
|
|
2207
|
+
const actualOpeningDigest = await sha256(new TextEncoder().encode(firstText));
|
|
2208
|
+
const variableState = variableStates.get(sessionId);
|
|
2209
|
+
jsonBody(res, 200, {
|
|
2210
|
+
ok: true,
|
|
2211
|
+
sessionId,
|
|
2212
|
+
revisionId: binding.revisionId,
|
|
2213
|
+
openingId: binding.openingId,
|
|
2214
|
+
normalizedIndexVersion: binding.normalizedIndexVersion,
|
|
2215
|
+
firstAssistantEvent: firstAssistant?.type ?? null,
|
|
2216
|
+
firstAssistantLength: firstText.length,
|
|
2217
|
+
actualOpeningDigest,
|
|
2218
|
+
expectedOpeningDigest: binding.openingDigest,
|
|
2219
|
+
openingMatches: actualOpeningDigest === binding.openingDigest,
|
|
2220
|
+
variableStateDigest: variableState?.digest ?? null,
|
|
2221
|
+
variableOpeningId: variableState?.selectedOpeningId ?? null,
|
|
2222
|
+
});
|
|
2223
|
+
} }));
|
|
2224
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/mvu-control", handler: async (req, res) => {
|
|
2225
|
+
if (req.method !== "POST") {
|
|
2226
|
+
res.writeHead(405, { Allow: "POST" });
|
|
2227
|
+
res.end();
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2230
|
+
try {
|
|
2231
|
+
const input = await readJson(req);
|
|
2232
|
+
const sessionId = typeof input.sessionId === "string" ? input.sessionId : "";
|
|
2233
|
+
const action = typeof input.action === "string" ? input.action : "";
|
|
2234
|
+
const binding = bindings.get(sessionId);
|
|
2235
|
+
const card = binding === undefined ? undefined : cardFor(binding.revisionId);
|
|
2236
|
+
if (binding === undefined || card === undefined)
|
|
2237
|
+
throw new Error("找不到酒馆 Session");
|
|
2238
|
+
let result;
|
|
2239
|
+
if (action === "updateSettings") {
|
|
2240
|
+
binding.mvuSettings = normalizeMvuSessionSettings(input.settings, {
|
|
2241
|
+
provider: binding.provider,
|
|
2242
|
+
model: binding.model,
|
|
2243
|
+
supportsExtraModel: supportsExtraModel(card),
|
|
2244
|
+
});
|
|
2245
|
+
await bindings.put(sessionId, binding);
|
|
2246
|
+
result = binding.mvuSettings;
|
|
2247
|
+
}
|
|
2248
|
+
else if (action === "reprocessVariables") {
|
|
2249
|
+
result = await reprocessVariables(sessionId);
|
|
2250
|
+
}
|
|
2251
|
+
else if (action === "reloadInitialVariables") {
|
|
2252
|
+
result = await reloadInitialVariables(sessionId, card);
|
|
2253
|
+
}
|
|
2254
|
+
else if (action === "retryExtraModelParsing") {
|
|
2255
|
+
const settings = mvuSettingsFor(binding, card);
|
|
2256
|
+
if (settings.updateMethod !== "额外模型解析")
|
|
2257
|
+
throw new Error("变量更新方式不是“额外模型解析”");
|
|
2258
|
+
const narrative = assistantVariableReplies(ctx.sessions.get(sessionId)).at(-1);
|
|
2259
|
+
if (narrative === undefined)
|
|
2260
|
+
throw new Error("当前 Session 还没有可重试的剧情回复");
|
|
2261
|
+
const compiled = pendingAssemblies.get(sessionId)?.updateCompiled
|
|
2262
|
+
?? [
|
|
2263
|
+
"你只负责根据已经完成的剧情更新 MVU 变量,不得续写剧情。",
|
|
2264
|
+
...card.worldbook.filter((entry) => /\[mvu_update\]/iu.test(entry.comment)).map((entry) => entry.content),
|
|
2265
|
+
`当前变量状态:\n${JSON.stringify(variableStates.get(sessionId)?.state ?? {}, null, 2)}`,
|
|
2266
|
+
].join("\n\n");
|
|
2267
|
+
result = await runSplitMvuUpdate(sessionId, binding, card, compiled, narrative);
|
|
2268
|
+
}
|
|
2269
|
+
else {
|
|
2270
|
+
throw new Error("未知的 MVU 控制动作");
|
|
2271
|
+
}
|
|
2272
|
+
jsonBody(res, 200, { ok: true, action, result });
|
|
2273
|
+
}
|
|
2274
|
+
catch (error) {
|
|
2275
|
+
jsonBody(res, 400, { ok: false, error: error instanceof Error ? error.message : "MVU 操作失败" });
|
|
2276
|
+
}
|
|
2277
|
+
} }));
|
|
2278
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/capability-snapshot", handler: (req, res) => {
|
|
2279
|
+
if (req.method !== "GET") {
|
|
2280
|
+
res.writeHead(405, { Allow: "GET" });
|
|
2281
|
+
res.end();
|
|
2282
|
+
return;
|
|
2283
|
+
}
|
|
2284
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
2285
|
+
const requestedSessionId = url.searchParams.get("sessionId") ?? "";
|
|
2286
|
+
const requestedRevisionId = url.searchParams.get("revision") ?? "";
|
|
2287
|
+
const binding = requestedSessionId.length === 0 ? undefined : bindings.get(requestedSessionId);
|
|
2288
|
+
const revisionId = binding?.revisionId ?? requestedRevisionId;
|
|
2289
|
+
const card = cardFor(revisionId);
|
|
2290
|
+
if (card === undefined) {
|
|
2291
|
+
jsonBody(res, 404, { ok: false, error: "没有找到这张酒馆卡" });
|
|
2292
|
+
return;
|
|
2293
|
+
}
|
|
2294
|
+
if (requestedSessionId.length > 0 && binding === undefined) {
|
|
2295
|
+
jsonBody(res, 404, { ok: false, error: "没有找到这个酒馆 Session" });
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2298
|
+
const session = binding === undefined ? undefined : ctx.sessions.get(binding.sessionId);
|
|
2299
|
+
const trace = binding?.lastAssemblyId === undefined ? undefined : traces.get(binding.lastAssemblyId);
|
|
2300
|
+
const variableRecord = binding === undefined ? undefined : variableStates.get(binding.sessionId);
|
|
2301
|
+
const variableEventRows = binding === undefined ? [] : Array.from(variableEvents.entries())
|
|
2302
|
+
.flatMap(([, event]) => event.sessionId === binding.sessionId ? [event] : [])
|
|
2303
|
+
.sort((left, right) => Number(left.sequence) - Number(right.sequence));
|
|
2304
|
+
const frontendRecord = binding === undefined ? undefined : frontendStates.get(binding.sessionId);
|
|
2305
|
+
const frontendEventRows = binding === undefined ? [] : frontendEventsAfter(binding.sessionId, -1);
|
|
2306
|
+
const compatibilityCallRows = frontendEventRows.filter((event) => event.type === "compatibility_call_observed");
|
|
2307
|
+
const frontendReceiptCount = binding === undefined ? 0 : Array.from(frontendReceipts.entries())
|
|
2308
|
+
.filter(([key]) => key.startsWith(`${binding.sessionId}:`)).length;
|
|
2309
|
+
const macroValues = binding === undefined ? undefined : {
|
|
2310
|
+
userName: effectiveUserName(binding),
|
|
2311
|
+
characterName: card.title,
|
|
2312
|
+
messageVariables: variableRecord?.state,
|
|
2313
|
+
macroSeed: binding.sessionId,
|
|
2314
|
+
};
|
|
2315
|
+
const projectedMessages = binding === undefined || session === undefined
|
|
2316
|
+
? []
|
|
2317
|
+
: groupFrontendMessagesForNativeFlow(projectFrontendMessages(session, card.messageRegexScripts, macroValues));
|
|
2318
|
+
const regexMatches = projectedMessages.flatMap((message) => message.rawText === undefined ? [] : [{
|
|
2319
|
+
seq: message.seq,
|
|
2320
|
+
role: message.role,
|
|
2321
|
+
before: message.rawText,
|
|
2322
|
+
after: message.text,
|
|
2323
|
+
}]);
|
|
2324
|
+
const assembly = typeof trace?.assembly === "object" && trace.assembly !== null ? trace.assembly : null;
|
|
2325
|
+
const selectedPreset = binding === undefined ? DEFAULT_TAVERN_PRESET : bindingPreset(binding);
|
|
2326
|
+
const preset = { id: selectedPreset.id, name: selectedPreset.name, source: selectedPreset.source, revision: selectedPreset.revision };
|
|
2327
|
+
jsonBody(res, 200, {
|
|
2328
|
+
ok: true,
|
|
2329
|
+
card: publicCardDetail(card, allBindings(), binding?.worldbookEnabledOverrides),
|
|
2330
|
+
session: binding === undefined ? null : {
|
|
2331
|
+
id: binding.sessionId,
|
|
2332
|
+
revisionId: binding.revisionId,
|
|
2333
|
+
openingId: binding.openingId,
|
|
2334
|
+
userName: effectiveUserName(binding),
|
|
2335
|
+
provider: binding.provider,
|
|
2336
|
+
model: binding.model,
|
|
2337
|
+
createdAt: binding.createdAt,
|
|
2338
|
+
preset,
|
|
2339
|
+
splitMvu: binding.splitMvu ?? null,
|
|
2340
|
+
supportsExtraModel: supportsExtraModel(card),
|
|
2341
|
+
mvuSettings: mvuSettingsFor(binding, card),
|
|
2342
|
+
},
|
|
2343
|
+
context: binding === undefined || trace === undefined ? null : {
|
|
2344
|
+
assemblyId: binding.lastAssemblyId,
|
|
2345
|
+
capturedAt: trace.capturedAt ?? null,
|
|
2346
|
+
provider: trace.provider ?? binding.provider,
|
|
2347
|
+
model: trace.model ?? binding.model,
|
|
2348
|
+
requestDigest: trace.requestDigest ?? null,
|
|
2349
|
+
ejsDiagnostics: trace.ejsDiagnostics ?? [],
|
|
2350
|
+
assembly,
|
|
2351
|
+
},
|
|
2352
|
+
regex: {
|
|
2353
|
+
scripts: card.messageRegexScripts,
|
|
2354
|
+
matches: regexMatches,
|
|
2355
|
+
},
|
|
2356
|
+
frontend: {
|
|
2357
|
+
definition: card.frontendDefinition ?? null,
|
|
2358
|
+
companionScripts: card.tavernHelperScripts,
|
|
2359
|
+
state: frontendRecord?.state ?? null,
|
|
2360
|
+
stateDigest: frontendRecord?.stateDigest ?? null,
|
|
2361
|
+
updatedAt: frontendRecord?.updatedAt ?? null,
|
|
2362
|
+
events: frontendEventRows,
|
|
2363
|
+
receiptCount: frontendReceiptCount,
|
|
2364
|
+
capabilities: card.frontendDefinition === undefined ? [] : bridgeCapabilities(card.frontendDefinition),
|
|
2365
|
+
variables: variableRecord === undefined ? null : {
|
|
2366
|
+
selectedOpeningId: variableRecord.selectedOpeningId,
|
|
2367
|
+
state: variableRecord.state,
|
|
2368
|
+
digest: variableRecord.digest,
|
|
2369
|
+
updatedAt: variableRecord.updatedAt,
|
|
2370
|
+
initializationStatus: variableRecord.initialSnapshots[variableRecord.selectedOpeningId]?.status ?? "failed",
|
|
2371
|
+
events: variableEventRows,
|
|
2372
|
+
},
|
|
2373
|
+
compatibilityCatalog: compatibilityCallCatalog(),
|
|
2374
|
+
compatibilityCalls: compatibilityCallRows,
|
|
2375
|
+
},
|
|
2376
|
+
persona: {
|
|
2377
|
+
displayName: (binding === undefined ? resolvedPersona({ revisionId }) : activePersona(binding))?.persona.displayName ?? binding?.userName ?? "",
|
|
2378
|
+
content: (binding === undefined ? resolvedPersona({ revisionId }) : activePersona(binding))?.persona.content ?? null,
|
|
2379
|
+
avatar: (binding === undefined ? resolvedPersona({ revisionId }) : activePersona(binding))?.persona.avatar ?? null,
|
|
2380
|
+
lorebook: null,
|
|
2381
|
+
bindingScope: (binding === undefined ? resolvedPersona({ revisionId }) : activePersona(binding))?.binding.scope ?? null,
|
|
2382
|
+
backendAvailable: true,
|
|
2383
|
+
libraryCount: Array.from(personas.entries()).length,
|
|
2384
|
+
},
|
|
2385
|
+
});
|
|
2386
|
+
} }));
|
|
2387
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/variables", handler: (req, res) => {
|
|
2388
|
+
if (req.method !== "GET") {
|
|
2389
|
+
res.writeHead(405, { Allow: "GET" });
|
|
2390
|
+
res.end();
|
|
2391
|
+
return;
|
|
2392
|
+
}
|
|
2393
|
+
const sessionId = new URL(req.url, "http://127.0.0.1").searchParams.get("sessionId") ?? "";
|
|
2394
|
+
const record = variableStates.get(sessionId);
|
|
2395
|
+
if (record === undefined) {
|
|
2396
|
+
jsonBody(res, 404, { ok: false, error: "找不到酒馆变量状态" });
|
|
2397
|
+
return;
|
|
2398
|
+
}
|
|
2399
|
+
const events = Array.from(variableEvents.entries()).flatMap(([, event]) => event.sessionId === sessionId ? [event] : []);
|
|
2400
|
+
jsonBody(res, 200, { ok: true, sessionId, revisionId: record.revisionId, selectedOpeningId: record.selectedOpeningId, state: record.state, digest: record.digest, events });
|
|
2401
|
+
} }));
|
|
2402
|
+
// Expose a read-only message projection for the native DSH conversation.
|
|
2403
|
+
// The DSH Session remains authoritative.
|
|
2404
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/conversation-projection", handler: (req, res) => {
|
|
2405
|
+
if (req.method !== "GET") {
|
|
2406
|
+
res.writeHead(405, { Allow: "GET" });
|
|
2407
|
+
res.end();
|
|
2408
|
+
return;
|
|
2409
|
+
}
|
|
2410
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
2411
|
+
const sessionId = url.searchParams.get("sessionId") ?? "";
|
|
2412
|
+
const binding = bindings.get(sessionId);
|
|
2413
|
+
const session = ctx.sessions.get(sessionId);
|
|
2414
|
+
const card = binding === undefined ? undefined : cardFor(binding.revisionId);
|
|
2415
|
+
if (binding === undefined || session === undefined || card === undefined) {
|
|
2416
|
+
if (url.searchParams.get("optional") === "1") {
|
|
2417
|
+
res.writeHead(204);
|
|
2418
|
+
res.end();
|
|
2419
|
+
return;
|
|
2420
|
+
}
|
|
2421
|
+
jsonBody(res, 404, { ok: false, error: "找不到酒馆 Session" });
|
|
2422
|
+
return;
|
|
2423
|
+
}
|
|
2424
|
+
const frontend = frontendStates.get(sessionId);
|
|
2425
|
+
const variableState = variableStates.get(sessionId);
|
|
2426
|
+
jsonBody(res, 200, {
|
|
2427
|
+
ok: true,
|
|
2428
|
+
sessionId,
|
|
2429
|
+
title: card.title,
|
|
2430
|
+
revisionId: card.revisionId,
|
|
2431
|
+
messages: groupFrontendMessagesForNativeFlow(projectFrontendMessages(session, card.messageRegexScripts, { userName: effectiveUserName(binding), characterName: card.title, messageVariables: variableState?.state, macroSeed: sessionId })),
|
|
2432
|
+
variableState: variableState?.state ?? {},
|
|
2433
|
+
frontendStorage: binding.frontendStorage ?? {},
|
|
2434
|
+
companionScripts: card.tavernHelperScripts ?? [],
|
|
2435
|
+
frontend: frontend === undefined || card.frontendDefinition === undefined ? null : {
|
|
2436
|
+
cardId: frontend.cardId,
|
|
2437
|
+
caseId: frontend.caseId,
|
|
2438
|
+
container: card.frontendDefinition.container,
|
|
2439
|
+
state: frontend.state,
|
|
2440
|
+
stateDigest: frontend.stateDigest,
|
|
2441
|
+
...(hostedFrontendEntry(sessionId, card.frontendDefinition) === undefined ? {} : { entryUrl: hostedFrontendEntry(sessionId, card.frontendDefinition) }),
|
|
2442
|
+
},
|
|
2443
|
+
});
|
|
2444
|
+
} }));
|
|
2445
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/frontend", handler: (req, res) => {
|
|
2446
|
+
if (req.method !== "GET") {
|
|
2447
|
+
res.writeHead(405, { Allow: "GET" });
|
|
2448
|
+
res.end();
|
|
2449
|
+
return;
|
|
2450
|
+
}
|
|
2451
|
+
try {
|
|
2452
|
+
const sessionId = new URL(req.url, "http://127.0.0.1").searchParams.get("sessionId") ?? "";
|
|
2453
|
+
const { binding, card, definition, record } = frontendContext(sessionId);
|
|
2454
|
+
const opening = card.openings.find((candidate) => candidate.id === binding.openingId);
|
|
2455
|
+
if (opening === undefined)
|
|
2456
|
+
throw bridgeFailure("bridge_unavailable", "卡内前端找不到绑定开场");
|
|
2457
|
+
const renderedOpening = substituteCardMacros(opening.message, { userName: effectiveUserName(binding), characterName: card.title });
|
|
2458
|
+
jsonBody(res, 200, {
|
|
2459
|
+
ok: true,
|
|
2460
|
+
sessionId,
|
|
2461
|
+
cardId: definition.cardId,
|
|
2462
|
+
contentDigest: card.revisionId,
|
|
2463
|
+
caseId: definition.caseId,
|
|
2464
|
+
runtimeClass: definition.runtimeClass,
|
|
2465
|
+
container: definition.container,
|
|
2466
|
+
capabilities: bridgeCapabilities(definition),
|
|
2467
|
+
stateDigest: record.stateDigest,
|
|
2468
|
+
...(definition.container === "message-html" || definition.container === "message-iframe"
|
|
2469
|
+
? { srcDoc: adaptOpeningFrontendHtml(renderedOpening, sessionId, definition) }
|
|
2470
|
+
: { entryUrl: hostedFrontendEntry(sessionId, definition) }),
|
|
2471
|
+
});
|
|
2472
|
+
}
|
|
2473
|
+
catch (error) {
|
|
2474
|
+
const failure = error;
|
|
2475
|
+
jsonBody(res, failure.code === "bridge_unavailable" ? 404 : 400, { ok: false, error: { code: failure.code ?? "bridge_unavailable", message: failure.message ?? "无法读取卡内前端" } });
|
|
2476
|
+
}
|
|
2477
|
+
} }));
|
|
2478
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/bridge", handler: async (req, res) => {
|
|
2479
|
+
if (req.method !== "POST") {
|
|
2480
|
+
res.writeHead(405, { Allow: "POST" });
|
|
2481
|
+
res.end();
|
|
2482
|
+
return;
|
|
2483
|
+
}
|
|
2484
|
+
try {
|
|
2485
|
+
const input = await readJson(req);
|
|
2486
|
+
const sessionId = typeof input.sessionId === "string" ? input.sessionId : "";
|
|
2487
|
+
const method = typeof input.method === "string" ? input.method : "";
|
|
2488
|
+
const payload = typeof input.payload === "object" && input.payload !== null && !Array.isArray(input.payload) ? input.payload : {};
|
|
2489
|
+
const operationId = input.operationId ?? payload.operationId;
|
|
2490
|
+
let result;
|
|
2491
|
+
if (method === "reportCompatibilityCall")
|
|
2492
|
+
result = await appendCompatibilityCall(sessionId, operationId, payload);
|
|
2493
|
+
else if (method === "generate")
|
|
2494
|
+
result = await generateAuxiliaryText(sessionId, operationId, payload);
|
|
2495
|
+
else if (method === "cancelGenerate")
|
|
2496
|
+
result = cancelAuxiliaryGeneration(sessionId, operationId);
|
|
2497
|
+
else if (method === "getCardState")
|
|
2498
|
+
result = cardStateProjection(sessionId);
|
|
2499
|
+
else if (method === "replaceCardState")
|
|
2500
|
+
result = await replaceCardState(sessionId, operationId, payload);
|
|
2501
|
+
else if (method === "replaceCardStorage")
|
|
2502
|
+
result = await replaceCardStorage(sessionId, payload);
|
|
2503
|
+
else if (method === "getWorldbook")
|
|
2504
|
+
result = compatibleWorldbook(sessionId);
|
|
2505
|
+
else if (method === "updateWorldbook")
|
|
2506
|
+
result = await updateCardWorldbook(sessionId, operationId, payload);
|
|
2507
|
+
else if (method === "selectOpening")
|
|
2508
|
+
result = await selectOpeningFromChatMessages(sessionId, operationId, payload);
|
|
2509
|
+
else if (method === "submitTurn") {
|
|
2510
|
+
const binding = bindings.get(sessionId);
|
|
2511
|
+
const card = binding === undefined ? undefined : cardFor(binding.revisionId);
|
|
2512
|
+
result = card?.frontendDefinition === undefined
|
|
2513
|
+
? await submitCardTurn(sessionId, operationId, payload)
|
|
2514
|
+
: await submitFrontendTurn(sessionId, operationId, payload);
|
|
2515
|
+
}
|
|
2516
|
+
else {
|
|
2517
|
+
const { definition, record } = frontendContext(sessionId);
|
|
2518
|
+
if (method === "connect")
|
|
2519
|
+
result = { sessionId, cardId: record.cardId, contentDigest: record.contentDigest, protocolVersion: "dsh-re3-rp-v1", capabilities: bridgeCapabilities(definition) };
|
|
2520
|
+
else if (method === "getProjection")
|
|
2521
|
+
result = frontendProjection(sessionId);
|
|
2522
|
+
else if (method === "getEvents")
|
|
2523
|
+
result = { events: frontendEventsAfter(sessionId, typeof payload.after === "number" ? payload.after : -1) };
|
|
2524
|
+
else if (method === "submitStateAction")
|
|
2525
|
+
result = await submitFrontendStateAction(sessionId, operationId, payload);
|
|
2526
|
+
else if (method === "resolveAsset")
|
|
2527
|
+
result = await resolveFrontendAsset(sessionId, payload);
|
|
2528
|
+
else
|
|
2529
|
+
throw bridgeFailure("capability_denied", `Bridge 不支持 ${method || "空方法"}`);
|
|
2530
|
+
}
|
|
2531
|
+
jsonBody(res, 200, { ok: true, result });
|
|
2532
|
+
}
|
|
2533
|
+
catch (error) {
|
|
2534
|
+
const failure = error;
|
|
2535
|
+
const code = failure.code ?? "bridge_unavailable";
|
|
2536
|
+
const status = code === "capability_denied" ? 403 : code === "invalid_action" ? 400 : code === "asset_unavailable" ? 404 : code === "opening_locked" || code === "asset_digest_mismatch" || code === "state_commit_failed" ? 409 : 500;
|
|
2537
|
+
jsonBody(res, status, { ok: false, error: { code, message: failure.message ?? "Bridge 调用失败" } });
|
|
2538
|
+
}
|
|
2539
|
+
} }));
|
|
2540
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/asset", handler: (req, res) => {
|
|
2541
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
2542
|
+
res.writeHead(405, { Allow: "GET, HEAD" });
|
|
2543
|
+
res.end();
|
|
2544
|
+
return;
|
|
2545
|
+
}
|
|
2546
|
+
const token = new URL(req.url, "http://127.0.0.1").searchParams.get("token") ?? "";
|
|
2547
|
+
const asset = frontendAssets.get(token);
|
|
2548
|
+
if (asset === undefined || typeof asset.path !== "string" || !fs.existsSync(asset.path)) {
|
|
2549
|
+
jsonBody(res, 404, { ok: false, error: { code: "asset_unavailable", message: "固化资源不存在" } });
|
|
2550
|
+
return;
|
|
2551
|
+
}
|
|
2552
|
+
textBody(res, 200, typeof asset.contentType === "string" ? asset.contentType : "application/octet-stream", new Uint8Array(fs.readFileSync(asset.path)), req.method);
|
|
2553
|
+
} }));
|
|
2554
|
+
const appSessionId = (req) => new URL(req.url, "http://127.0.0.1").searchParams.get("sessionId") ?? "";
|
|
2555
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/frontend-standalone/index.html", handler: (req, res) => {
|
|
2556
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
2557
|
+
res.writeHead(405, { Allow: "GET, HEAD" });
|
|
2558
|
+
res.end();
|
|
2559
|
+
return;
|
|
2560
|
+
}
|
|
2561
|
+
const sessionId = appSessionId(req);
|
|
2562
|
+
textBody(res, 200, "text/html; charset=utf-8", standaloneIndex.replace('src="./main.js"', `src="./main.js?sessionId=${encodeURIComponent(sessionId)}"`), req.method);
|
|
2563
|
+
} }));
|
|
2564
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/frontend-standalone/core.js", handler: (req, res) => {
|
|
2565
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
2566
|
+
res.writeHead(405, { Allow: "GET, HEAD" });
|
|
2567
|
+
res.end();
|
|
2568
|
+
return;
|
|
2569
|
+
}
|
|
2570
|
+
textBody(res, 200, "text/javascript; charset=utf-8", standaloneCore, req.method);
|
|
2571
|
+
} }));
|
|
2572
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/frontend-standalone/style.css", handler: (req, res) => {
|
|
2573
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
2574
|
+
res.writeHead(405, { Allow: "GET, HEAD" });
|
|
2575
|
+
res.end();
|
|
2576
|
+
return;
|
|
2577
|
+
}
|
|
2578
|
+
textBody(res, 200, "text/css; charset=utf-8", standaloneStyle, req.method);
|
|
2579
|
+
} }));
|
|
2580
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/frontend-standalone/main.js", handler: (req, res) => {
|
|
2581
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
2582
|
+
res.writeHead(405, { Allow: "GET, HEAD" });
|
|
2583
|
+
res.end();
|
|
2584
|
+
return;
|
|
2585
|
+
}
|
|
2586
|
+
textBody(res, 200, "text/javascript; charset=utf-8", standaloneMain(appSessionId(req)), req.method);
|
|
2587
|
+
} }));
|
|
2588
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/frontend-required/index.html", handler: (req, res) => {
|
|
2589
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
2590
|
+
res.writeHead(405, { Allow: "GET, HEAD" });
|
|
2591
|
+
res.end();
|
|
2592
|
+
return;
|
|
2593
|
+
}
|
|
2594
|
+
const sessionId = appSessionId(req);
|
|
2595
|
+
textBody(res, 200, "text/html; charset=utf-8", requiredAssetIndex.replace('src="./main.js"', `src="./main.js?sessionId=${encodeURIComponent(sessionId)}"`), req.method);
|
|
2596
|
+
} }));
|
|
2597
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/frontend-required/main.js", handler: (req, res) => {
|
|
2598
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
2599
|
+
res.writeHead(405, { Allow: "GET, HEAD" });
|
|
2600
|
+
res.end();
|
|
2601
|
+
return;
|
|
2602
|
+
}
|
|
2603
|
+
textBody(res, 200, "text/javascript; charset=utf-8", requiredAssetMain(appSessionId(req)), req.method);
|
|
2604
|
+
} }));
|
|
2605
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/surface-audit", handler: (req, res) => {
|
|
2606
|
+
if (req.method !== "GET") {
|
|
2607
|
+
res.writeHead(405, { Allow: "GET" });
|
|
2608
|
+
res.end();
|
|
2609
|
+
return;
|
|
2610
|
+
}
|
|
2611
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
2612
|
+
const sessionId = url.searchParams.get("sessionId") ?? "";
|
|
2613
|
+
const binding = bindings.get(sessionId);
|
|
2614
|
+
const session = ctx.sessions.get(sessionId);
|
|
2615
|
+
if (binding === undefined || session === undefined) {
|
|
2616
|
+
if (url.searchParams.get("optional") === "1") {
|
|
2617
|
+
res.writeHead(204);
|
|
2618
|
+
res.end();
|
|
2619
|
+
return;
|
|
2620
|
+
}
|
|
2621
|
+
jsonBody(res, 404, { ok: false, error: "找不到卡片会话" });
|
|
2622
|
+
return;
|
|
2623
|
+
}
|
|
2624
|
+
jsonBody(res, 200, {
|
|
2625
|
+
ok: true,
|
|
2626
|
+
sessionId,
|
|
2627
|
+
currentSurface: Array.isArray(session.surface?.nodes) ? session.surface.nodes : [],
|
|
2628
|
+
operations: tavernSurfaceAudit(session),
|
|
2629
|
+
});
|
|
2630
|
+
} }));
|
|
2631
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/surface-event", handler: (req, res) => {
|
|
2632
|
+
if (req.method !== "GET") {
|
|
2633
|
+
res.writeHead(405, { Allow: "GET" });
|
|
2634
|
+
res.end();
|
|
2635
|
+
return;
|
|
2636
|
+
}
|
|
2637
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
2638
|
+
const sessionId = url.searchParams.get("sessionId") ?? "";
|
|
2639
|
+
const seq = Number(url.searchParams.get("seq"));
|
|
2640
|
+
const binding = bindings.get(sessionId);
|
|
2641
|
+
const session = ctx.sessions.get(sessionId);
|
|
2642
|
+
if (binding === undefined || session === undefined) {
|
|
2643
|
+
jsonBody(res, 404, { ok: false, error: "找不到卡片会话" });
|
|
2644
|
+
return;
|
|
2645
|
+
}
|
|
2646
|
+
const detail = Number.isInteger(seq) ? tavernSurfaceEventDetail(session, seq) : undefined;
|
|
2647
|
+
if (detail === undefined) {
|
|
2648
|
+
jsonBody(res, 404, { ok: false, error: "找不到这个 Surface 事件" });
|
|
2649
|
+
return;
|
|
2650
|
+
}
|
|
2651
|
+
const trace = detail.assembly?.assemblyId ? traces.get(detail.assembly.assemblyId) : undefined;
|
|
2652
|
+
jsonBody(res, 200, {
|
|
2653
|
+
ok: true,
|
|
2654
|
+
sessionId,
|
|
2655
|
+
event: detail,
|
|
2656
|
+
assembly: trace?.assembly ?? null,
|
|
2657
|
+
runtime: trace === undefined ? null : { provider: trace.provider ?? "", model: trace.model ?? "" },
|
|
2658
|
+
});
|
|
2659
|
+
} }));
|
|
2660
|
+
if (hostGlobal.process.env.DSH_RE3_RP_VERIFY === "1") {
|
|
2661
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/verify/runtime", handler: async (req, res) => {
|
|
2662
|
+
if (req.method !== "GET") {
|
|
2663
|
+
res.writeHead(405, { Allow: "GET" });
|
|
2664
|
+
res.end();
|
|
2665
|
+
return;
|
|
2666
|
+
}
|
|
2667
|
+
jsonBody(res, 200, { instanceId: verificationInstanceId });
|
|
2668
|
+
} }));
|
|
2669
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/verify/settings", handler: async (req, res) => {
|
|
2670
|
+
if (req.method !== "POST") {
|
|
2671
|
+
res.writeHead(405, { Allow: "POST" });
|
|
2672
|
+
res.end();
|
|
2673
|
+
return;
|
|
2674
|
+
}
|
|
2675
|
+
try {
|
|
2676
|
+
const input = await readJson(req);
|
|
2677
|
+
const sessionId = typeof input.sessionId === "string" ? input.sessionId : "";
|
|
2678
|
+
const binding = bindings.get(sessionId);
|
|
2679
|
+
const value = input.worldInfoMaxRecursionSteps;
|
|
2680
|
+
if (binding === undefined)
|
|
2681
|
+
throw new Error("找不到可配置的卡片会话");
|
|
2682
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0)
|
|
2683
|
+
throw new Error("worldInfoMaxRecursionSteps 必须是非负整数");
|
|
2684
|
+
binding.worldInfoMaxRecursionSteps = value;
|
|
2685
|
+
await bindings.put(sessionId, binding);
|
|
2686
|
+
jsonBody(res, 200, { ok: true, sessionId, worldInfoMaxRecursionSteps: value });
|
|
2687
|
+
}
|
|
2688
|
+
catch (error) {
|
|
2689
|
+
jsonBody(res, 400, { ok: false, error: error instanceof Error ? error.message : "无法设置验证参数" });
|
|
2690
|
+
}
|
|
2691
|
+
} }));
|
|
2692
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/verify/variables", handler: async (req, res) => {
|
|
2693
|
+
if (req.method !== "POST") {
|
|
2694
|
+
res.writeHead(405, { Allow: "POST" });
|
|
2695
|
+
res.end();
|
|
2696
|
+
return;
|
|
2697
|
+
}
|
|
2698
|
+
try {
|
|
2699
|
+
const input = await readJson(req);
|
|
2700
|
+
const sessionId = typeof input.sessionId === "string" ? input.sessionId : "";
|
|
2701
|
+
const body = typeof input.body === "string" ? input.body : "";
|
|
2702
|
+
if (variableStates.get(sessionId) === undefined || body.length === 0)
|
|
2703
|
+
throw new Error("缺少酒馆会话或固定回复正文");
|
|
2704
|
+
const result = await updateVariablesFromReply(sessionId, body);
|
|
2705
|
+
const record = variableStates.get(sessionId);
|
|
2706
|
+
jsonBody(res, 200, { ok: true, sessionId, status: result?.status ?? "ignored", diagnostics: result?.diagnostics ?? [], state: record.state, digest: record.digest });
|
|
2707
|
+
}
|
|
2708
|
+
catch (error) {
|
|
2709
|
+
jsonBody(res, 400, { ok: false, error: error instanceof Error ? error.message : "变量验证失败" });
|
|
2710
|
+
}
|
|
2711
|
+
} }));
|
|
2712
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/verify/assistant", handler: async (req, res) => {
|
|
2713
|
+
if (req.method !== "POST") {
|
|
2714
|
+
res.writeHead(405, { Allow: "POST" });
|
|
2715
|
+
res.end();
|
|
2716
|
+
return;
|
|
2717
|
+
}
|
|
2718
|
+
try {
|
|
2719
|
+
const input = await readJson(req);
|
|
2720
|
+
const sessionId = typeof input.sessionId === "string" ? input.sessionId : "";
|
|
2721
|
+
const body = typeof input.body === "string" ? input.body : "";
|
|
2722
|
+
const session = ctx.sessions.get(sessionId);
|
|
2723
|
+
if (session === undefined || variableStates.get(sessionId) === undefined || body.length === 0)
|
|
2724
|
+
throw new Error("缺少酒馆会话或固定 Oracle 回复正文");
|
|
2725
|
+
const message = {
|
|
2726
|
+
id: crypto.randomUUID(),
|
|
2727
|
+
role: "assistant",
|
|
2728
|
+
content: [{ type: "text", text: body }],
|
|
2729
|
+
source: { kind: "model", provider: "dsh-re3-rp-verification", model: "fixed-real-card-oracle" },
|
|
2730
|
+
};
|
|
2731
|
+
const turn = Math.max(1, Math.ceil((projectFrontendMessages(session).length + 1) / 2));
|
|
2732
|
+
session.append("assistant/message", { turn, step: 1, message }, { surfaceOp: "append" });
|
|
2733
|
+
const result = await updateVariablesFromReply(sessionId, body);
|
|
2734
|
+
await ctx.sessions.flush(session);
|
|
2735
|
+
const record = variableStates.get(sessionId);
|
|
2736
|
+
jsonBody(res, 200, { ok: true, sessionId, status: result?.status ?? "ignored", diagnostics: result?.diagnostics ?? [], digest: record.digest, messageId: message.id });
|
|
2737
|
+
}
|
|
2738
|
+
catch (error) {
|
|
2739
|
+
jsonBody(res, 400, { ok: false, error: error instanceof Error ? error.message : "固定 Oracle 回复验证失败" });
|
|
2740
|
+
}
|
|
2741
|
+
} }));
|
|
2742
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/verify/fork", handler: async (req, res) => {
|
|
2743
|
+
if (req.method !== "POST") {
|
|
2744
|
+
res.writeHead(405, { Allow: "POST" });
|
|
2745
|
+
res.end();
|
|
2746
|
+
return;
|
|
2747
|
+
}
|
|
2748
|
+
let childSessionId = "";
|
|
2749
|
+
let childHandle;
|
|
2750
|
+
try {
|
|
2751
|
+
const input = await readJson(req);
|
|
2752
|
+
const sourceSessionId = typeof input.sessionId === "string" ? input.sessionId : "";
|
|
2753
|
+
const sourceBinding = bindings.get(sourceSessionId);
|
|
2754
|
+
const source = ctx.sessions.get(sourceSessionId);
|
|
2755
|
+
if (sourceBinding === undefined || source === undefined)
|
|
2756
|
+
throw new Error("找不到可 Fork 的酒馆 Session");
|
|
2757
|
+
childSessionId = crypto.randomUUID();
|
|
2758
|
+
const childBinding = { ...sourceBinding, sessionId: childSessionId, createdAt: new Date().toISOString() };
|
|
2759
|
+
await bindings.put(childSessionId, childBinding);
|
|
2760
|
+
const sourcePersonaBinding = personaBindings.get(personaBindingKey("session", sourceSessionId));
|
|
2761
|
+
if (sourcePersonaBinding !== undefined) {
|
|
2762
|
+
const childPersonaBinding = { ...sourcePersonaBinding, targetId: childSessionId, updatedAt: new Date().toISOString() };
|
|
2763
|
+
await personaBindings.put(personaBindingKey("session", childSessionId), childPersonaBinding);
|
|
2764
|
+
}
|
|
2765
|
+
const sourceVariables = variableStates.get(sourceSessionId);
|
|
2766
|
+
if (sourceVariables !== undefined) {
|
|
2767
|
+
const childVariables = JSON.parse(JSON.stringify({ ...sourceVariables, sessionId: childSessionId, updatedAt: new Date().toISOString() }));
|
|
2768
|
+
await variableStates.put(childSessionId, childVariables);
|
|
2769
|
+
}
|
|
2770
|
+
const sourceFrontend = frontendStates.get(sourceSessionId);
|
|
2771
|
+
if (sourceFrontend !== undefined) {
|
|
2772
|
+
const childFrontend = JSON.parse(JSON.stringify({ ...sourceFrontend, sessionId: childSessionId, updatedAt: new Date().toISOString() }));
|
|
2773
|
+
await frontendStates.put(childSessionId, childFrontend);
|
|
2774
|
+
}
|
|
2775
|
+
childHandle = await ctx.agents.create({
|
|
2776
|
+
sessionId: childSessionId,
|
|
2777
|
+
seed: Array.from(source.events),
|
|
2778
|
+
meta: { cwd: hostGlobal.process.cwd(), parentSession: sourceSessionId, seedLength: source.events.length },
|
|
2779
|
+
agentOptions: { provider: childBinding.provider, model: childBinding.model },
|
|
2780
|
+
setup: setupAgent(childSessionId, cardFor(childBinding.revisionId), childBinding),
|
|
2781
|
+
});
|
|
2782
|
+
handles.set(childSessionId, childHandle);
|
|
2783
|
+
const child = childHandle.agent.session;
|
|
2784
|
+
await ctx.sessions.flush(child);
|
|
2785
|
+
jsonBody(res, 201, {
|
|
2786
|
+
ok: true,
|
|
2787
|
+
sourceSessionId,
|
|
2788
|
+
childSessionId,
|
|
2789
|
+
parentSession: child.header?.parentSession ?? null,
|
|
2790
|
+
seedLength: child.header?.seedLength ?? null,
|
|
2791
|
+
parentVariableDigest: sourceVariables?.digest ?? null,
|
|
2792
|
+
childVariableDigest: variableStates.get(childSessionId)?.digest ?? null,
|
|
2793
|
+
parentFrontendDigest: sourceFrontend?.stateDigest ?? null,
|
|
2794
|
+
childFrontendDigest: frontendStates.get(childSessionId)?.stateDigest ?? null,
|
|
2795
|
+
});
|
|
2796
|
+
}
|
|
2797
|
+
catch (error) {
|
|
2798
|
+
if (childSessionId.length > 0) {
|
|
2799
|
+
if (childHandle !== undefined)
|
|
2800
|
+
await childHandle.dispose();
|
|
2801
|
+
handles.delete(childSessionId);
|
|
2802
|
+
await bindings.delete(childSessionId);
|
|
2803
|
+
await personaBindings.delete(personaBindingKey("session", childSessionId));
|
|
2804
|
+
variableReplyGate.discard(childSessionId);
|
|
2805
|
+
await variableStates.delete(childSessionId);
|
|
2806
|
+
await frontendStates.delete(childSessionId);
|
|
2807
|
+
}
|
|
2808
|
+
jsonBody(res, 400, { ok: false, error: error instanceof Error ? error.message : "DSH Fork 验证失败" });
|
|
2809
|
+
}
|
|
2810
|
+
} }));
|
|
2811
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/verify/prompt", handler: async (req, res) => {
|
|
2812
|
+
if (req.method !== "POST") {
|
|
2813
|
+
res.writeHead(405, { Allow: "POST" });
|
|
2814
|
+
res.end();
|
|
2815
|
+
return;
|
|
2816
|
+
}
|
|
2817
|
+
try {
|
|
2818
|
+
const input = await readJson(req);
|
|
2819
|
+
const sessionId = typeof input.sessionId === "string" ? input.sessionId : "";
|
|
2820
|
+
const prompt = typeof input.prompt === "string" ? input.prompt : "";
|
|
2821
|
+
const agent = ctx.agents.get(sessionId);
|
|
2822
|
+
if (agent === undefined || prompt.length === 0)
|
|
2823
|
+
throw new Error("缺少可验证的卡片会话或玩家输入");
|
|
2824
|
+
agent.followup({ id: crypto.randomUUID(), role: "user", content: [{ type: "text", text: prompt }], source: { kind: "user" } });
|
|
2825
|
+
await agent.whenIdle();
|
|
2826
|
+
const traceCount = Array.from(traces.entries()).filter(([, trace]) => trace.sessionId === sessionId).length;
|
|
2827
|
+
jsonBody(res, 200, { ok: true, sessionId, agentStatus: agent.status, traceCount });
|
|
2828
|
+
}
|
|
2829
|
+
catch (error) {
|
|
2830
|
+
jsonBody(res, 400, { ok: false, error: error instanceof Error ? error.message : "验证输入失败" });
|
|
2831
|
+
}
|
|
2832
|
+
} }));
|
|
2833
|
+
}
|
|
2834
|
+
disposers.push(ctx.webServer.register({ kind: "exact", path: "/dsh-re3-rp/evidence", handler: (req, res) => {
|
|
2835
|
+
if (req.method !== "GET") {
|
|
2836
|
+
res.writeHead(405, { Allow: "GET" });
|
|
2837
|
+
res.end();
|
|
2838
|
+
return;
|
|
2839
|
+
}
|
|
2840
|
+
const sessionId = new URL(req.url, "http://127.0.0.1").searchParams.get("sessionId") ?? "";
|
|
2841
|
+
const binding = bindings.get(sessionId);
|
|
2842
|
+
if (binding === undefined) {
|
|
2843
|
+
jsonBody(res, 404, { ok: false, error: "找不到卡片会话" });
|
|
2844
|
+
return;
|
|
2845
|
+
}
|
|
2846
|
+
const session = ctx.sessions.get(sessionId);
|
|
2847
|
+
const sessionTraces = [];
|
|
2848
|
+
for (const [, trace] of traces.entries()) {
|
|
2849
|
+
if (trace.sessionId === sessionId)
|
|
2850
|
+
sessionTraces.push(trace);
|
|
2851
|
+
}
|
|
2852
|
+
jsonBody(res, 200, {
|
|
2853
|
+
worldbookContextRevision: worldbookContextRevision(session),
|
|
2854
|
+
worldbookSurfaceSeq: currentWorldbookSurfaceSeq(session) ?? null,
|
|
2855
|
+
binding: {
|
|
2856
|
+
sessionId: binding.sessionId,
|
|
2857
|
+
revisionId: binding.revisionId,
|
|
2858
|
+
openingId: binding.openingId,
|
|
2859
|
+
openingDigest: binding.openingDigest,
|
|
2860
|
+
normalizedIndexVersion: binding.normalizedIndexVersion,
|
|
2861
|
+
createdAt: binding.createdAt,
|
|
2862
|
+
splitMvu: binding.splitMvu ?? null,
|
|
2863
|
+
}, traces: sessionTraces
|
|
2864
|
+
});
|
|
2865
|
+
} }));
|
|
2866
|
+
return async () => {
|
|
2867
|
+
const failures = [];
|
|
2868
|
+
for (const dispose of disposers.reverse()) {
|
|
2869
|
+
try {
|
|
2870
|
+
dispose();
|
|
2871
|
+
}
|
|
2872
|
+
catch (error) {
|
|
2873
|
+
failures.push(error);
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
for (const binding of allBindings())
|
|
2877
|
+
variableReplyGate.discard(binding.sessionId);
|
|
2878
|
+
for (const handle of Array.from(handles.values()).reverse()) {
|
|
2879
|
+
try {
|
|
2880
|
+
await handle.dispose();
|
|
2881
|
+
}
|
|
2882
|
+
catch (error) {
|
|
2883
|
+
failures.push(error);
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
try {
|
|
2887
|
+
await disposeInfrastructure();
|
|
2888
|
+
}
|
|
2889
|
+
catch (error) {
|
|
2890
|
+
failures.push(error);
|
|
2891
|
+
}
|
|
2892
|
+
if (failures.length === 1)
|
|
2893
|
+
throw failures[0];
|
|
2894
|
+
if (failures.length > 1)
|
|
2895
|
+
throw new AggregateError(failures, "RolePlay 运行时卸载失败");
|
|
2896
|
+
};
|
|
2897
|
+
}
|
|
2898
|
+
catch (error) {
|
|
2899
|
+
try {
|
|
2900
|
+
await disposeInfrastructure();
|
|
2901
|
+
}
|
|
2902
|
+
catch (cleanupError) {
|
|
2903
|
+
throw new AggregateError([error, cleanupError], "RolePlay 启动失败且回滚不完整");
|
|
2904
|
+
}
|
|
2905
|
+
throw error;
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
export async function apply(ctx) {
|
|
2909
|
+
const hostGlobal = globalThis;
|
|
2910
|
+
const fs = hostGlobal.process.getBuiltinModule("node:fs");
|
|
2911
|
+
const path = hostGlobal.process.getBuiltinModule("node:path");
|
|
2912
|
+
const dshHome = hostGlobal.process.env.DSH_HOME;
|
|
2913
|
+
if (typeof dshHome !== "string" || dshHome.length === 0)
|
|
2914
|
+
throw new Error("dsh-roleplay 需要隔离的 DSH_HOME");
|
|
2915
|
+
const stateRoot = path.join(dshHome, "dsh-re3-rp");
|
|
2916
|
+
const statePath = path.join(stateRoot, "plugin-state.json");
|
|
2917
|
+
// Register a native settings namespace so the Web settings app can place the
|
|
2918
|
+
// plugin-owned lifecycle control in the existing "插件配置" collection.
|
|
2919
|
+
ctx.settings.register(rolePlaySettingsNamespace, rolePlaySettingsSchema, { base: {} });
|
|
2920
|
+
const lifecycle = new RolePlayLifecycle({
|
|
2921
|
+
loadEnabled: () => {
|
|
2922
|
+
if (!fs.existsSync(statePath))
|
|
2923
|
+
return true;
|
|
2924
|
+
const value = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
2925
|
+
return value?.enabled !== false;
|
|
2926
|
+
},
|
|
2927
|
+
saveEnabled: (enabled) => {
|
|
2928
|
+
fs.mkdirSync(stateRoot, { recursive: true });
|
|
2929
|
+
const temporaryPath = `${statePath}.${crypto.randomUUID()}.tmp`;
|
|
2930
|
+
try {
|
|
2931
|
+
fs.writeFileSync(temporaryPath, `${JSON.stringify({ enabled }, null, 2)}\n`, "utf8");
|
|
2932
|
+
fs.renameSync(temporaryPath, statePath);
|
|
2933
|
+
}
|
|
2934
|
+
finally {
|
|
2935
|
+
if (fs.existsSync(temporaryPath))
|
|
2936
|
+
fs.unlinkSync(temporaryPath);
|
|
2937
|
+
}
|
|
2938
|
+
},
|
|
2939
|
+
startRuntime: () => applyRuntime(ctx),
|
|
2940
|
+
});
|
|
2941
|
+
await lifecycle.initialize();
|
|
2942
|
+
const disposeControlRoute = ctx.webServer.register({
|
|
2943
|
+
kind: "exact",
|
|
2944
|
+
path: "/dsh-re3-rp/control",
|
|
2945
|
+
handler: async (req, res) => {
|
|
2946
|
+
if (req.method === "GET") {
|
|
2947
|
+
jsonBody(res, 200, { ok: true, ...lifecycle.snapshot(), preservesData: true });
|
|
2948
|
+
return;
|
|
2949
|
+
}
|
|
2950
|
+
if (req.method !== "POST") {
|
|
2951
|
+
jsonBody(res, 405, { ok: false, error: "仅支持 GET 或 POST" });
|
|
2952
|
+
return;
|
|
2953
|
+
}
|
|
2954
|
+
try {
|
|
2955
|
+
const payload = JSON.parse(new TextDecoder().decode(await readBody(req, 16 * 1024)));
|
|
2956
|
+
if (typeof payload?.enabled !== "boolean")
|
|
2957
|
+
throw new Error("enabled 必须是布尔值");
|
|
2958
|
+
const snapshot = await lifecycle.setEnabled(payload.enabled);
|
|
2959
|
+
jsonBody(res, 200, { ok: true, ...snapshot, preservesData: true });
|
|
2960
|
+
}
|
|
2961
|
+
catch (error) {
|
|
2962
|
+
jsonBody(res, 400, { ok: false, ...lifecycle.snapshot(), error: error instanceof Error ? error.message : String(error) });
|
|
2963
|
+
}
|
|
2964
|
+
},
|
|
2965
|
+
});
|
|
2966
|
+
return async () => {
|
|
2967
|
+
const failures = [];
|
|
2968
|
+
try {
|
|
2969
|
+
disposeControlRoute();
|
|
2970
|
+
}
|
|
2971
|
+
catch (error) {
|
|
2972
|
+
failures.push(error);
|
|
2973
|
+
}
|
|
2974
|
+
try {
|
|
2975
|
+
await lifecycle.dispose();
|
|
2976
|
+
}
|
|
2977
|
+
catch (error) {
|
|
2978
|
+
failures.push(error);
|
|
2979
|
+
}
|
|
2980
|
+
if (failures.length === 1)
|
|
2981
|
+
throw failures[0];
|
|
2982
|
+
if (failures.length > 1)
|
|
2983
|
+
throw new AggregateError(failures, "RolePlay 控制壳卸载失败");
|
|
2984
|
+
};
|
|
2985
|
+
}
|
|
2986
|
+
function textBody(res, status, contentType, value, method = "GET") {
|
|
2987
|
+
const bytes = typeof value === "string" ? new TextEncoder().encode(value) : value;
|
|
2988
|
+
res.writeHead(status, {
|
|
2989
|
+
"Content-Type": contentType,
|
|
2990
|
+
"Cache-Control": "no-store",
|
|
2991
|
+
"Content-Length": bytes.byteLength,
|
|
2992
|
+
"X-Content-Type-Options": "nosniff",
|
|
2993
|
+
});
|
|
2994
|
+
res.end(method === "HEAD" ? undefined : bytes);
|
|
2995
|
+
}
|
|
2996
|
+
function bridgeFailure(code, message) {
|
|
2997
|
+
return Object.assign(new Error(message), { code });
|
|
2998
|
+
}
|