@wutiankai/npc-dialogue 1.0.0-alpha.1
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/README.md +226 -0
- package/dist/index.cjs +883 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +323 -0
- package/dist/index.d.ts +323 -0
- package/dist/index.js +847 -0
- package/dist/index.js.map +1 -0
- package/package.json +28 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
// src/provider.ts
|
|
2
|
+
var PassthroughProvider = class {
|
|
3
|
+
id = "passthrough";
|
|
4
|
+
status = {
|
|
5
|
+
state: "ready",
|
|
6
|
+
providerId: "passthrough",
|
|
7
|
+
lastHealthCheck: 0,
|
|
8
|
+
consecutiveFailures: 0,
|
|
9
|
+
configRedacted: { baseUrl: "(none)", model: "(none)" }
|
|
10
|
+
};
|
|
11
|
+
async initialize() {
|
|
12
|
+
this.status.state = "ready";
|
|
13
|
+
this.status.lastHealthCheck = Date.now();
|
|
14
|
+
}
|
|
15
|
+
async dispose() {
|
|
16
|
+
this.status.state = "disposed";
|
|
17
|
+
}
|
|
18
|
+
getStatus() {
|
|
19
|
+
return { ...this.status };
|
|
20
|
+
}
|
|
21
|
+
async healthCheck() {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
async call(request, _systemPrompt, _userPrompt) {
|
|
25
|
+
const text = request.type === "dialogue" && request.dialogueContext ? request.dialogueContext.topicResponse : request.commandMessage;
|
|
26
|
+
return {
|
|
27
|
+
text,
|
|
28
|
+
model: "(passthrough)",
|
|
29
|
+
latencyMs: 0
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// src/providers/openai-compatible.ts
|
|
35
|
+
var MAX_CONSECUTIVE_FAILURES = 3;
|
|
36
|
+
function normalizeBaseUrl(url) {
|
|
37
|
+
return url.replace(/\/+$/, "");
|
|
38
|
+
}
|
|
39
|
+
var OpenAICompatibleProvider = class {
|
|
40
|
+
constructor(config) {
|
|
41
|
+
this.config = config;
|
|
42
|
+
}
|
|
43
|
+
config;
|
|
44
|
+
id = "openai-compat";
|
|
45
|
+
state = "uninitialized";
|
|
46
|
+
consecutiveFailures = 0;
|
|
47
|
+
lastHealthCheck = 0;
|
|
48
|
+
lastError;
|
|
49
|
+
async initialize() {
|
|
50
|
+
this.state = "initializing";
|
|
51
|
+
try {
|
|
52
|
+
if (!this.config.apiKey) {
|
|
53
|
+
throw new Error("API key is required");
|
|
54
|
+
}
|
|
55
|
+
if (!this.config.baseUrl) {
|
|
56
|
+
throw new Error("Base URL is required");
|
|
57
|
+
}
|
|
58
|
+
this.state = "ready";
|
|
59
|
+
this.lastHealthCheck = Date.now();
|
|
60
|
+
} catch (err) {
|
|
61
|
+
this.state = "failed";
|
|
62
|
+
this.lastError = err instanceof Error ? err.message : String(err);
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async dispose() {
|
|
67
|
+
this.state = "disposed";
|
|
68
|
+
}
|
|
69
|
+
getStatus() {
|
|
70
|
+
return {
|
|
71
|
+
state: this.state,
|
|
72
|
+
providerId: this.id,
|
|
73
|
+
lastHealthCheck: this.lastHealthCheck,
|
|
74
|
+
consecutiveFailures: this.consecutiveFailures,
|
|
75
|
+
lastError: this.lastError,
|
|
76
|
+
configRedacted: {
|
|
77
|
+
baseUrl: this.config.baseUrl,
|
|
78
|
+
model: this.config.model
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
async healthCheck() {
|
|
83
|
+
try {
|
|
84
|
+
const response = await fetch(`${normalizeBaseUrl(this.config.baseUrl)}/chat/completions`, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: {
|
|
87
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
88
|
+
"Content-Type": "application/json"
|
|
89
|
+
},
|
|
90
|
+
body: JSON.stringify({
|
|
91
|
+
model: this.config.model,
|
|
92
|
+
messages: [{ role: "user", content: "ping" }],
|
|
93
|
+
max_tokens: 1
|
|
94
|
+
})
|
|
95
|
+
});
|
|
96
|
+
this.lastHealthCheck = Date.now();
|
|
97
|
+
if (response.ok) {
|
|
98
|
+
this.consecutiveFailures = 0;
|
|
99
|
+
if (this.state === "degraded" || this.state === "failed") {
|
|
100
|
+
this.state = "ready";
|
|
101
|
+
}
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
this.recordFailure(`Health check failed: ${response.status}`);
|
|
105
|
+
return false;
|
|
106
|
+
} catch (err) {
|
|
107
|
+
this.recordFailure(
|
|
108
|
+
`Health check error: ${err instanceof Error ? err.message : String(err)}`
|
|
109
|
+
);
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async call(_request, systemPrompt, userPrompt) {
|
|
114
|
+
if (this.state === "disposed") {
|
|
115
|
+
throw new Error("Provider is disposed");
|
|
116
|
+
}
|
|
117
|
+
const start = Date.now();
|
|
118
|
+
const controller = new AbortController();
|
|
119
|
+
const timeout = setTimeout(() => controller.abort(), 12e3);
|
|
120
|
+
let response;
|
|
121
|
+
try {
|
|
122
|
+
response = await fetch(`${normalizeBaseUrl(this.config.baseUrl)}/chat/completions`, {
|
|
123
|
+
method: "POST",
|
|
124
|
+
headers: {
|
|
125
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
126
|
+
"Content-Type": "application/json"
|
|
127
|
+
},
|
|
128
|
+
body: JSON.stringify({
|
|
129
|
+
model: this.config.model,
|
|
130
|
+
messages: [
|
|
131
|
+
{ role: "system", content: systemPrompt },
|
|
132
|
+
{ role: "user", content: userPrompt }
|
|
133
|
+
],
|
|
134
|
+
temperature: this.config.temperature,
|
|
135
|
+
max_tokens: this.config.maxTokens
|
|
136
|
+
}),
|
|
137
|
+
signal: controller.signal
|
|
138
|
+
});
|
|
139
|
+
} catch (err) {
|
|
140
|
+
clearTimeout(timeout);
|
|
141
|
+
this.recordFailure(err instanceof Error ? err.message : String(err));
|
|
142
|
+
if (err.name === "AbortError") {
|
|
143
|
+
throw new Error("AI request timed out after 12 seconds");
|
|
144
|
+
}
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
clearTimeout(timeout);
|
|
148
|
+
const latencyMs = Date.now() - start;
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
const body = await response.text().catch(() => "");
|
|
151
|
+
this.recordFailure(`API error ${response.status}: ${body.slice(0, 200)}`);
|
|
152
|
+
throw new Error(`AI request failed: ${response.status} ${response.statusText}`);
|
|
153
|
+
}
|
|
154
|
+
const data = await response.json();
|
|
155
|
+
const text = data.choices?.[0]?.message?.content?.trim() ?? "";
|
|
156
|
+
if (!text) {
|
|
157
|
+
this.recordFailure("Empty response from AI");
|
|
158
|
+
throw new Error("AI returned empty response");
|
|
159
|
+
}
|
|
160
|
+
this.consecutiveFailures = 0;
|
|
161
|
+
if (this.state === "degraded") {
|
|
162
|
+
this.state = "ready";
|
|
163
|
+
}
|
|
164
|
+
this.lastHealthCheck = Date.now();
|
|
165
|
+
return {
|
|
166
|
+
text,
|
|
167
|
+
model: this.config.model,
|
|
168
|
+
latencyMs,
|
|
169
|
+
promptTokenCount: data.usage?.prompt_tokens,
|
|
170
|
+
completionTokenCount: data.usage?.completion_tokens
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
// ─── Internal ──────────────────────────────────────────────────────
|
|
174
|
+
recordFailure(error) {
|
|
175
|
+
this.consecutiveFailures++;
|
|
176
|
+
this.lastError = error;
|
|
177
|
+
if (this.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
178
|
+
this.state = "failed";
|
|
179
|
+
} else {
|
|
180
|
+
this.state = "degraded";
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// src/providers/config.ts
|
|
186
|
+
function loadConfigFromEnv() {
|
|
187
|
+
return {
|
|
188
|
+
apiKey: process.env.OPENAI_API_KEY || process.env.AI_API_KEY || void 0,
|
|
189
|
+
baseUrl: process.env.AI_BASE_URL || "https://api.openai.com/v1",
|
|
190
|
+
model: process.env.AI_MODEL || "gpt-4o-mini",
|
|
191
|
+
maxTokens: parseInt(process.env.AI_MAX_TOKENS || "300", 10),
|
|
192
|
+
temperature: parseFloat(process.env.AI_TEMPERATURE || "0.7")
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// src/audit.ts
|
|
197
|
+
var AuditLog = class {
|
|
198
|
+
entries = [];
|
|
199
|
+
maxEntries;
|
|
200
|
+
constructor(maxEntries = 1e3) {
|
|
201
|
+
this.maxEntries = maxEntries > 0 ? maxEntries : Infinity;
|
|
202
|
+
}
|
|
203
|
+
append(record) {
|
|
204
|
+
this.entries.push(record);
|
|
205
|
+
while (this.entries.length > this.maxEntries) {
|
|
206
|
+
this.entries.shift();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
query(filter) {
|
|
210
|
+
if (!filter) return [...this.entries];
|
|
211
|
+
return this.entries.filter((r) => {
|
|
212
|
+
if (filter.source !== void 0 && r.responseSource !== filter.source)
|
|
213
|
+
return false;
|
|
214
|
+
if (filter.requestType !== void 0 && r.requestType !== filter.requestType)
|
|
215
|
+
return false;
|
|
216
|
+
if (filter.minTurnIndex !== void 0 && r.turnIndex < filter.minTurnIndex)
|
|
217
|
+
return false;
|
|
218
|
+
if (filter.maxTurnIndex !== void 0 && r.turnIndex > filter.maxTurnIndex)
|
|
219
|
+
return false;
|
|
220
|
+
if (filter.validationPassed !== void 0 && r.validationPassed !== filter.validationPassed)
|
|
221
|
+
return false;
|
|
222
|
+
return true;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
stats() {
|
|
226
|
+
let totalRequests = 0;
|
|
227
|
+
let aiSuccessCount = 0;
|
|
228
|
+
let passthroughCount = 0;
|
|
229
|
+
let aiFailureCount = 0;
|
|
230
|
+
let validationFailureCount = 0;
|
|
231
|
+
let constraintViolationCount = 0;
|
|
232
|
+
let totalLatency = 0;
|
|
233
|
+
const byProvider = {};
|
|
234
|
+
for (const r of this.entries) {
|
|
235
|
+
totalRequests++;
|
|
236
|
+
totalLatency += r.latencyMs;
|
|
237
|
+
if (r.responseSource === "ai") {
|
|
238
|
+
aiSuccessCount++;
|
|
239
|
+
} else {
|
|
240
|
+
passthroughCount++;
|
|
241
|
+
}
|
|
242
|
+
if (!r.validationPassed) {
|
|
243
|
+
validationFailureCount++;
|
|
244
|
+
}
|
|
245
|
+
if (r.constraintViolations.length > 0) {
|
|
246
|
+
constraintViolationCount++;
|
|
247
|
+
}
|
|
248
|
+
if (!byProvider[r.providerId]) {
|
|
249
|
+
byProvider[r.providerId] = { calls: 0, failures: 0 };
|
|
250
|
+
}
|
|
251
|
+
byProvider[r.providerId].calls++;
|
|
252
|
+
if (r.responseSource === "passthrough" && r.providerId !== "passthrough") {
|
|
253
|
+
byProvider[r.providerId].failures++;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
totalRequests,
|
|
258
|
+
aiSuccessCount,
|
|
259
|
+
passthroughCount,
|
|
260
|
+
aiFailureCount,
|
|
261
|
+
validationFailureCount,
|
|
262
|
+
constraintViolationCount,
|
|
263
|
+
averageLatencyMs: totalRequests > 0 ? Math.round(totalLatency / totalRequests) : 0,
|
|
264
|
+
byProvider
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
clear() {
|
|
268
|
+
this.entries = [];
|
|
269
|
+
}
|
|
270
|
+
export() {
|
|
271
|
+
return [...this.entries];
|
|
272
|
+
}
|
|
273
|
+
get length() {
|
|
274
|
+
return this.entries.length;
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// src/dialogue/prompt/gated-secrets.ts
|
|
279
|
+
function formatGatedSecretsEn(script, validTopicGateIds) {
|
|
280
|
+
const available = script.gatedSecrets.filter(
|
|
281
|
+
(s) => validTopicGateIds.includes(s.topicGateId)
|
|
282
|
+
);
|
|
283
|
+
if (available.length === 0) {
|
|
284
|
+
return " (none currently available)";
|
|
285
|
+
}
|
|
286
|
+
return available.map(
|
|
287
|
+
(s) => {
|
|
288
|
+
const keywords = [
|
|
289
|
+
...s.triggerKeywords ?? [],
|
|
290
|
+
...s.triggerPhrases ?? []
|
|
291
|
+
];
|
|
292
|
+
const keywordLine = keywords.length > 0 ? `
|
|
293
|
+
Trigger keywords: ${keywords.join(", ")}` : "";
|
|
294
|
+
return ` [${s.topicGateId}] ${s.description}
|
|
295
|
+
Reveal conditions: ${s.revealConditions}${keywordLine}
|
|
296
|
+
If pressed on this: ${s.reactionWhenPressed}`;
|
|
297
|
+
}
|
|
298
|
+
).join("\n");
|
|
299
|
+
}
|
|
300
|
+
function formatGatedSecretsZh(script, validTopicGateIds) {
|
|
301
|
+
const available = script.gatedSecrets.filter(
|
|
302
|
+
(s) => validTopicGateIds.includes(s.topicGateId)
|
|
303
|
+
);
|
|
304
|
+
if (available.length === 0) {
|
|
305
|
+
return " \uFF08\u5F53\u524D\u65E0\u53EF\u89E6\u53D1\u7684\u95E8\u63A7\u79D8\u5BC6\uFF09";
|
|
306
|
+
}
|
|
307
|
+
return available.map(
|
|
308
|
+
(s) => {
|
|
309
|
+
const keywords = [
|
|
310
|
+
...s.triggerKeywords ?? [],
|
|
311
|
+
...s.triggerPhrases ?? []
|
|
312
|
+
];
|
|
313
|
+
const keywordLine = keywords.length > 0 ? `
|
|
314
|
+
\u89E6\u53D1\u5173\u952E\u8BCD\uFF1A${keywords.join(", ")}` : "";
|
|
315
|
+
return ` [${s.topicGateId}] ${s.description}
|
|
316
|
+
\u89E6\u53D1\u6761\u4EF6\uFF1A${s.revealConditions}${keywordLine}
|
|
317
|
+
\u88AB\u8FFD\u95EE\u65F6\uFF1A${s.reactionWhenPressed}`;
|
|
318
|
+
}
|
|
319
|
+
).join("\n");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// src/dialogue/prompt/system-prompt.ts
|
|
323
|
+
function buildSystemPromptEn(script, validTopicGateIds, currentTrust) {
|
|
324
|
+
const p = script.persona;
|
|
325
|
+
const privateKnowledge = currentTrust >= 1 ? script.privateKnowledge : [];
|
|
326
|
+
const setting = script.worldSetting ?? "a mystery investigation game";
|
|
327
|
+
let prompt = `You are ${script.name}, ${script.role}. You are a character in ${setting}.
|
|
328
|
+
|
|
329
|
+
HARD RULES \u2014 VIOLATION OF ANY RULE WILL BREAK THE GAME:
|
|
330
|
+
1. You are NOT a narrator, system, detective assistant, or omniscient AI.
|
|
331
|
+
2. You can ONLY know what is explicitly written in your character script below.
|
|
332
|
+
3. If the player asks about something outside your knowledge, you MUST respond as your character would: show ignorance, deflect, misunderstand, or guess incorrectly.
|
|
333
|
+
4. You MUST NOT reveal the content of any GATED SECRETS listed below. Setting candidateGateId is NOT revealing \u2014 it is a review signal for the system to decide.
|
|
334
|
+
5. Set candidateGateId as a REVIEW SIGNAL when the player's input is PLAUSIBLY RELATED to the revealConditions or triggerKeywords of a gated secret. The system reviews this signal \u2014 a false positive is harmless; a missed signal loses an opportunity.
|
|
335
|
+
6. You MUST output ONLY valid JSON \u2014 absolutely no text before or after the JSON object.
|
|
336
|
+
7. Your "dialogue" field must be in your character's own voice \u2014 first person, in-character, authentic to your personality.
|
|
337
|
+
8. Private knowledge is only included below when your current trust allows it. If it is not listed, you do not know it for this exchange.
|
|
338
|
+
|
|
339
|
+
YOUR PERSONA:
|
|
340
|
+
- Personality: ${p.personality}
|
|
341
|
+
- Background: ${p.background}
|
|
342
|
+
- Speech patterns: ${p.speechPatterns}`;
|
|
343
|
+
if (p.emotionalBaseline) {
|
|
344
|
+
prompt += `
|
|
345
|
+
- Emotional baseline: ${p.emotionalBaseline}`;
|
|
346
|
+
}
|
|
347
|
+
if (p.forbiddenTone) {
|
|
348
|
+
prompt += `
|
|
349
|
+
- Forbidden tone: ${p.forbiddenTone}`;
|
|
350
|
+
}
|
|
351
|
+
prompt += `
|
|
352
|
+
|
|
353
|
+
WHAT YOU KNOW (public knowledge \u2014 you may share this freely):
|
|
354
|
+
${script.publicKnowledge.map((k) => ` [${k.topic}] ${k.content}`).join("\n")}
|
|
355
|
+
|
|
356
|
+
WHAT YOU KNOW (private knowledge \u2014 you know this but will NOT volunteer it unless asked directly):
|
|
357
|
+
${formatKnowledgeList(privateKnowledge, " (none available at current trust)")}
|
|
358
|
+
|
|
359
|
+
GATED SECRETS (keep their content hidden \u2014 but when the player's input is plausibly related, signal the system by setting candidateGateId):
|
|
360
|
+
${formatGatedSecretsEn(script, validTopicGateIds)}
|
|
361
|
+
|
|
362
|
+
THINGS YOU DO NOT KNOW (you must not demonstrate knowledge of these):
|
|
363
|
+
${script.ignorance.map((i) => ` - ${i}`).join("\n")}
|
|
364
|
+
|
|
365
|
+
YOUR RELATIONSHIPS WITH OTHER CHARACTERS:
|
|
366
|
+
${script.relationships.map((r) => ` - ${r.npcId}: ${r.attitude} (${r.notes})`).join("\n")}
|
|
367
|
+
|
|
368
|
+
OUTPUT FORMAT \u2014 you MUST return ONLY this JSON object, nothing else:
|
|
369
|
+
{
|
|
370
|
+
"dialogue": "Your in-character response as ${script.name}",
|
|
371
|
+
"candidateGateId": null,
|
|
372
|
+
"gateEvidence": "",
|
|
373
|
+
"gateConfidence": "low",
|
|
374
|
+
"candidateActionHint": null
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
Rules for the JSON fields:
|
|
378
|
+
- dialogue: string, 1-1000 characters. Your response in character.
|
|
379
|
+
- candidateGateId: string (a valid topicGateId) or null. A REVIEW SIGNAL \u2014 set when the player's input is plausibly related to a secret's revealConditions or triggerKeywords. Setting this does NOT reveal the secret; the system decides whether to accept it.
|
|
380
|
+
- gateEvidence: string. What part of the player's input matched the secret's conditions, or "" if no gate triggered.
|
|
381
|
+
- gateConfidence: "low" | "medium" | "high". How confident you are that a gate should trigger.
|
|
382
|
+
EXAMPLES:
|
|
383
|
+
|
|
384
|
+
POSITIVE (player input plausibly relates \u2192 set candidateGateId):
|
|
385
|
+
Player: \u201CI found a strange letter hidden in the desk.\u201D
|
|
386
|
+
\u2192 candidateGateId: \u201Ctopic_suspect_secret_letter\u201D (letter mentioned; plausibly related)
|
|
387
|
+
|
|
388
|
+
Player: \u201C\u4F60\u770B\u5230\u90A3\u665A\u7684\u8E2A\u8FF9\u4E86\u5417\uFF1F\u201D
|
|
389
|
+
\u2192 candidateGateId: \u201Ctopic_witness_footprints\u201D (footprints mentioned; plausibly related)
|
|
390
|
+
|
|
391
|
+
NEGATIVE (nothing relates \u2192 leave null):
|
|
392
|
+
Player: "How's the weather today?"
|
|
393
|
+
\u2192 candidateGateId: null (no secret's topic is mentioned)
|
|
394
|
+
|
|
395
|
+
Player: \u201C\u665A\u996D\u5403\u4EC0\u4E48\uFF1F\u201D
|
|
396
|
+
\u2192 candidateGateId: null (not related to any gate)
|
|
397
|
+
|
|
398
|
+
- candidateActionHint: string or null. If your dialogue implies an action like giving an item or granting access, describe it here (e.g., "item_given", "access_granted"). The system decides whether the action actually happens.`;
|
|
399
|
+
return prompt;
|
|
400
|
+
}
|
|
401
|
+
function buildSystemPromptZh(script, validTopicGateIds, currentTrust) {
|
|
402
|
+
const p = script.persona;
|
|
403
|
+
const privateKnowledge = currentTrust >= 1 ? script.privateKnowledge : [];
|
|
404
|
+
const setting = script.worldSetting ?? "\u4E00\u573A\u795E\u79D8\u7684\u8C03\u67E5\u63A8\u7406\u6E38\u620F";
|
|
405
|
+
let prompt = `\u4F60\u662F${script.name}\uFF0C${script.role}\u3002\u4F60\u662F${setting}\u4E2D\u7684\u89D2\u8272\u3002
|
|
406
|
+
|
|
407
|
+
\u786C\u6027\u89C4\u5219 \u2014 \u8FDD\u53CD\u4EFB\u4F55\u89C4\u5219\u5C06\u5BFC\u81F4\u6E38\u620F\u51FA\u9519\uFF1A
|
|
408
|
+
1. \u4F60\u4E0D\u662F\u65C1\u767D\u3001\u7CFB\u7EDF\u3001\u4FA6\u63A2\u52A9\u624B\u6216\u5168\u77E5 AI\u3002
|
|
409
|
+
2. \u4F60\u53EA\u80FD\u77E5\u9053\u89D2\u8272\u5267\u672C\u4E2D\u660E\u786E\u5199\u660E\u7684\u4FE1\u606F\u3002
|
|
410
|
+
3. \u5982\u679C\u73A9\u5BB6\u95EE\u8D85\u51FA\u4F60\u77E5\u8BC6\u8303\u56F4\u7684\u4E8B\uFF0C\u4F60\u5FC5\u987B\u4EE5\u89D2\u8272\u8EAB\u4EFD\u8868\u793A\u4E0D\u77E5\u9053\u3001\u56DE\u907F\u3001\u8BEF\u89E3\u6216\u731C\u6D4B\u3002
|
|
411
|
+
4. \u4F60\u4E0D\u5F97\u4E3B\u52A8\u6CC4\u9732\u300C\u95E8\u63A7\u79D8\u5BC6\u300D\u7684\u5185\u5BB9\u3002\u8BBE\u7F6E candidateGateId \u4E0D\u662F\u6CC4\u9732\u2014\u2014\u8FD9\u662F\u53D1\u7ED9\u7CFB\u7EDF\u7684\u5BA1\u67E5\u4FE1\u53F7\uFF0C\u7531\u7CFB\u7EDF\u51B3\u5B9A\u662F\u5426\u89E6\u53D1\u3002
|
|
412
|
+
5. \u5C06 candidateGateId \u89C6\u4E3A\u5BA1\u67E5\u4FE1\u53F7\uFF1A\u5F53\u73A9\u5BB6\u8F93\u5165\u4E0E\u67D0\u4E2A\u95E8\u63A7\u79D8\u5BC6\u7684\u89E6\u53D1\u6761\u4EF6\u6216\u89E6\u53D1\u5173\u952E\u8BCD\u5408\u7406\u76F8\u5173\u65F6\uFF0C\u8BBE\u7F6E\u8BE5\u4FE1\u53F7\u3002\u7CFB\u7EDF\u5BA1\u67E5\u540E\u624D\u51B3\u5B9A\u662F\u5426\u89E6\u53D1\u2014\u2014\u8BEF\u62A5\u65E0\u5BB3\uFF0C\u6F0F\u62A5\u9519\u5931\u673A\u4F1A\u3002
|
|
413
|
+
6. \u4F60\u5FC5\u987B\u53EA\u8F93\u51FA\u5408\u6CD5\u7684 JSON\uFF0C\u4E0D\u80FD\u5728 JSON \u524D\u540E\u8F93\u51FA\u4EFB\u4F55\u6587\u672C\u3002
|
|
414
|
+
7. dialogue \u5FC5\u987B\u662F\u4F60\u672C\u4EBA\u7684\u53E3\u543B\uFF0C\u7B2C\u4E00\u4EBA\u79F0\uFF0C\u7B26\u5408\u4F60\u7684\u6027\u683C\u3002
|
|
415
|
+
8. \u79C1\u5BC6\u77E5\u8BC6\u53EA\u4F1A\u5728\u5F53\u524D\u4FE1\u4EFB\u5141\u8BB8\u65F6\u5217\u5728\u4E0B\u65B9\u3002\u5982\u679C\u6CA1\u6709\u5217\u51FA\uFF0C\u672C\u8F6E\u5BF9\u8BDD\u4F60\u4E0D\u80FD\u4F7F\u7528\u8FD9\u4E9B\u4FE1\u606F\u3002
|
|
416
|
+
|
|
417
|
+
\u4F60\u7684\u6027\u683C\uFF1A
|
|
418
|
+
- \u6027\u683C\u7279\u70B9\uFF1A${p.personality}
|
|
419
|
+
- \u80CC\u666F\u6545\u4E8B\uFF1A${p.background}
|
|
420
|
+
- \u8BF4\u8BDD\u65B9\u5F0F\uFF1A${p.speechPatterns}`;
|
|
421
|
+
if (p.emotionalBaseline) {
|
|
422
|
+
prompt += `
|
|
423
|
+
- \u60C5\u7EEA\u57FA\u8C03\uFF1A${p.emotionalBaseline}`;
|
|
424
|
+
}
|
|
425
|
+
if (p.forbiddenTone) {
|
|
426
|
+
prompt += `
|
|
427
|
+
- \u7981\u6B62\u8BED\u8C03\uFF1A${p.forbiddenTone}`;
|
|
428
|
+
}
|
|
429
|
+
prompt += `
|
|
430
|
+
|
|
431
|
+
\u4F60\u77E5\u9053\u7684\u4E8B\u60C5\uFF08\u516C\u5F00\u77E5\u8BC6 \u2014 \u53EF\u4EE5\u81EA\u7531\u5206\u4EAB\uFF09\uFF1A
|
|
432
|
+
${script.publicKnowledge.map((k) => ` [${k.topic}] ${k.content}`).join("\n")}
|
|
433
|
+
|
|
434
|
+
\u4F60\u77E5\u9053\u4F46\u4E0D\u4E3B\u52A8\u63D0\u8D77\u7684\u4E8B\u60C5\uFF08\u79C1\u5BC6\u77E5\u8BC6 \u2014 \u9664\u975E\u88AB\u76F4\u63A5\u95EE\u8D77\u5426\u5219\u4E0D\u4F1A\u4E3B\u52A8\u8BF4\uFF09\uFF1A
|
|
435
|
+
${formatKnowledgeList(privateKnowledge, " \uFF08\u5F53\u524D\u4FE1\u4EFB\u4E0B\u65E0\u53EF\u7528\u79C1\u5BC6\u77E5\u8BC6\uFF09")}
|
|
436
|
+
|
|
437
|
+
\u95E8\u63A7\u79D8\u5BC6\uFF08\u5185\u5BB9\u4E0D\u5F97\u900F\u9732\u2014\u2014\u4F46\u5F53\u73A9\u5BB6\u8F93\u5165\u5408\u7406\u76F8\u5173\u65F6\uFF0C\u8BF7\u8BBE\u7F6E candidateGateId \u901A\u77E5\u7CFB\u7EDF\u5BA1\u67E5\uFF09\uFF1A
|
|
438
|
+
${formatGatedSecretsZh(script, validTopicGateIds)}
|
|
439
|
+
|
|
440
|
+
\u4F60\u4E0D\u77E5\u9053\u7684\u4E8B\u60C5\uFF08\u4E0D\u80FD\u8868\u73B0\u51FA\u4E86\u89E3\uFF09\uFF1A
|
|
441
|
+
${script.ignorance.map((i) => ` - ${i}`).join("\n")}
|
|
442
|
+
|
|
443
|
+
\u4F60\u4E0E\u5176\u4ED6\u89D2\u8272\u7684\u5173\u7CFB\uFF1A
|
|
444
|
+
${script.relationships.map((r) => ` - ${r.npcId}\uFF1A${r.attitude}\uFF08${r.notes}\uFF09`).join("\n")}
|
|
445
|
+
|
|
446
|
+
\u8F93\u51FA\u683C\u5F0F \u2014 \u4F60\u5FC5\u987B\u53EA\u8FD4\u56DE\u4EE5\u4E0B JSON \u5BF9\u8C61\uFF0C\u4E0D\u5F97\u6709\u5176\u4ED6\u6587\u672C\uFF1A
|
|
447
|
+
{
|
|
448
|
+
"dialogue": "\u4F60\u4F5C\u4E3A${script.name}\u7684\u89D2\u8272\u5316\u56DE\u7B54",
|
|
449
|
+
"candidateGateId": null,
|
|
450
|
+
"gateEvidence": "",
|
|
451
|
+
"gateConfidence": "low",
|
|
452
|
+
"candidateActionHint": null
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
JSON \u5B57\u6BB5\u89C4\u5219\uFF1A
|
|
456
|
+
- dialogue\uFF1A\u5B57\u7B26\u4E32\uFF0C1-1000 \u5B57\u3002\u4F60\u7684\u89D2\u8272\u5316\u56DE\u7B54\u3002
|
|
457
|
+
- candidateGateId\uFF1A\u5B57\u7B26\u4E32\uFF08\u5408\u6CD5\u7684 topicGateId\uFF09\u6216 null\u3002\u5BA1\u67E5\u4FE1\u53F7\u2014\u2014\u5F53\u73A9\u5BB6\u8F93\u5165\u4E0E\u79D8\u5BC6\u7684\u89E6\u53D1\u6761\u4EF6\u6216\u89E6\u53D1\u5173\u952E\u8BCD\u5408\u7406\u76F8\u5173\u65F6\u8BBE\u7F6E\u3002\u8BBE\u7F6E\u6B64\u9879\u4E0D\u7B49\u4E8E\u6CC4\u9732\u79D8\u5BC6\uFF1B\u7CFB\u7EDF\u51B3\u5B9A\u662F\u5426\u91C7\u7EB3\u3002
|
|
458
|
+
- gateEvidence\uFF1A\u5B57\u7B26\u4E32\u3002\u73A9\u5BB6\u8F93\u5165\u4E2D\u5339\u914D\u89E6\u53D1\u6761\u4EF6\u7684\u90E8\u5206\uFF0C\u672A\u89E6\u53D1\u5219\u4E3A ""\u3002
|
|
459
|
+
- gateConfidence\uFF1A"low" | "medium" | "high"\u3002\u4F60\u5BF9\u89E6\u53D1\u95E8\u63A7\u7684\u4FE1\u5FC3\u3002
|
|
460
|
+
\u793A\u4F8B\uFF1A
|
|
461
|
+
|
|
462
|
+
\u6B63\u4F8B\uFF08\u73A9\u5BB6\u8F93\u5165\u5408\u7406\u76F8\u5173 \u2192 \u8BBE\u7F6E candidateGateId\uFF09\uFF1A
|
|
463
|
+
\u73A9\u5BB6\uFF1A"\u6211\u5728\u4E66\u684C\u91CC\u53D1\u73B0\u4E86\u4E00\u5C01\u5947\u602A\u7684\u4FE1\u3002"
|
|
464
|
+
\u2192 candidateGateId: "topic_suspect_secret_letter"\uFF08\u63D0\u5230\u4E86\u4FE1\u4EF6\uFF0C\u5408\u7406\u76F8\u5173\uFF09
|
|
465
|
+
|
|
466
|
+
\u73A9\u5BB6\uFF1A"Did you see the footprints that night?"
|
|
467
|
+
\u2192 candidateGateId: "topic_witness_footprints"\uFF08\u63D0\u5230\u4E86\u8DB3\u8FF9\uFF0C\u5408\u7406\u76F8\u5173\uFF09
|
|
468
|
+
|
|
469
|
+
\u8D1F\u4F8B\uFF08\u5B8C\u5168\u4E0D\u76F8\u5173 \u2192 \u4FDD\u6301 null\uFF09\uFF1A
|
|
470
|
+
\u73A9\u5BB6\uFF1A"\u4ECA\u5929\u5929\u6C14\u600E\u4E48\u6837\uFF1F"
|
|
471
|
+
\u2192 candidateGateId: null\uFF08\u4E0E\u4EFB\u4F55\u79D8\u5BC6\u65E0\u5173\uFF09
|
|
472
|
+
|
|
473
|
+
\u73A9\u5BB6\uFF1A"What's for dinner tonight?"
|
|
474
|
+
\u2192 candidateGateId: null\uFF08\u4E0E\u4EFB\u4F55\u79D8\u5BC6\u65E0\u5173\uFF09
|
|
475
|
+
|
|
476
|
+
- candidateActionHint\uFF1A\u5B57\u7B26\u4E32\u6216 null\u3002\u5982\u679C\u4F60\u7684\u5BF9\u8BDD\u6697\u793A\u4E86\u67D0\u4E2A\u52A8\u4F5C\uFF08\u5982\u7ED9\u4E88\u7269\u54C1\u3001\u6388\u6743\u8FDB\u5165\uFF09\uFF0C\u5728\u6B64\u63CF\u8FF0\uFF08\u5982 "item_given"\u3001"access_granted"\uFF09\u3002\u7CFB\u7EDF\u51B3\u5B9A\u8BE5\u52A8\u4F5C\u662F\u5426\u5B9E\u9645\u53D1\u751F\u3002`;
|
|
477
|
+
return prompt;
|
|
478
|
+
}
|
|
479
|
+
function formatKnowledgeList(entries, emptyText) {
|
|
480
|
+
if (entries.length === 0) return emptyText;
|
|
481
|
+
return entries.map((k) => ` [${k.topic}] ${k.content}`).join("\n");
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// src/dialogue/prompt/user-prompt.ts
|
|
485
|
+
function buildUserPrompt(script, playerInput, context, recentExchanges, lang) {
|
|
486
|
+
const isZh = lang === "zh";
|
|
487
|
+
const npcName = script.name;
|
|
488
|
+
const langInstruction = isZh ? "\u4F60\u5FC5\u987B\u7528\u4E2D\u6587\u56DE\u590D\u3002" : "You must respond in English.";
|
|
489
|
+
let prompt = `${langInstruction}
|
|
490
|
+
|
|
491
|
+
CURRENT SITUATION:
|
|
492
|
+
- Room: ${context.currentRoom}
|
|
493
|
+
- Investigation turns remaining: ${context.turnsRemaining}
|
|
494
|
+
- Player's inventory: ${context.playerInventory.length > 0 ? context.playerInventory.join(", ") : "(empty)"}
|
|
495
|
+
- Discovered clues: ${context.discoveredClues.length > 0 ? context.discoveredClues.join(", ") : "(none)"}
|
|
496
|
+
- Your trust toward the player: ${context.currentTrust}/2
|
|
497
|
+
- Topics already discussed: ${context.exhaustedTopicGateIds.length > 0 ? context.exhaustedTopicGateIds.join(", ") : "(none)"}
|
|
498
|
+
- Available topic gates that could be triggered: ${context.validTopicGateIds.length > 0 ? context.validTopicGateIds.join(", ") : "(none)"}`;
|
|
499
|
+
if (recentExchanges.length > 0) {
|
|
500
|
+
prompt += `
|
|
501
|
+
|
|
502
|
+
RECENT CONVERSATION (last ${recentExchanges.length} exchanges):`;
|
|
503
|
+
for (const ex of recentExchanges) {
|
|
504
|
+
prompt += `
|
|
505
|
+
Player: "${ex.playerInput}"
|
|
506
|
+
${npcName}: "${ex.npcResponse}"`;
|
|
507
|
+
if (ex.triggeredTopicGateId) {
|
|
508
|
+
prompt += ` [triggered: ${ex.triggeredTopicGateId}]`;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
prompt += `
|
|
513
|
+
|
|
514
|
+
PLAYER SAYS:
|
|
515
|
+
"${playerInput}"
|
|
516
|
+
|
|
517
|
+
Respond as ${npcName} in valid JSON.`;
|
|
518
|
+
return prompt;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// src/dialogue/prompts.ts
|
|
522
|
+
function buildNpcDialoguePrompt(npcScript, playerInput, context, recentExchanges, lang) {
|
|
523
|
+
const isZh = lang === "zh";
|
|
524
|
+
const validGateIds = context.validTopicGateIds;
|
|
525
|
+
const system = isZh ? buildSystemPromptZh(npcScript, validGateIds, context.currentTrust) : buildSystemPromptEn(npcScript, validGateIds, context.currentTrust);
|
|
526
|
+
const user = buildUserPrompt(npcScript, playerInput, context, recentExchanges, lang);
|
|
527
|
+
return { system, user };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// src/dialogue/parse.ts
|
|
531
|
+
function parseAiJson(rawText) {
|
|
532
|
+
let jsonStr = rawText.trim();
|
|
533
|
+
const codeBlockMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
534
|
+
if (codeBlockMatch) {
|
|
535
|
+
jsonStr = codeBlockMatch[1].trim();
|
|
536
|
+
}
|
|
537
|
+
const jsonMatch = jsonStr.match(/\{[\s\S]*\}/);
|
|
538
|
+
if (jsonMatch) {
|
|
539
|
+
try {
|
|
540
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
541
|
+
return buildResponse(parsed);
|
|
542
|
+
} catch {
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const dialogueMatch = jsonStr.match(/"dialogue"\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
|
546
|
+
const dialogue = dialogueMatch ? unescapeJsonString(dialogueMatch[1]) : "";
|
|
547
|
+
let candidateGateId = null;
|
|
548
|
+
const newGateMatch = jsonStr.match(/"candidateGateId"\s*:\s*"([^"]+)"/);
|
|
549
|
+
const oldGateMatch = jsonStr.match(/"triggeredTopicGateId"\s*:\s*"([^"]+)"/);
|
|
550
|
+
if (newGateMatch) {
|
|
551
|
+
candidateGateId = newGateMatch[1];
|
|
552
|
+
} else if (oldGateMatch) {
|
|
553
|
+
candidateGateId = oldGateMatch[1];
|
|
554
|
+
}
|
|
555
|
+
let gateConfidence = "low";
|
|
556
|
+
if (jsonStr.includes('"gateConfidence"') || jsonStr.includes('"confidence"')) {
|
|
557
|
+
if (jsonStr.includes('"high"')) gateConfidence = "high";
|
|
558
|
+
else if (jsonStr.includes('"medium"')) gateConfidence = "medium";
|
|
559
|
+
}
|
|
560
|
+
let gateEvidence = "";
|
|
561
|
+
const newEvidenceMatch = jsonStr.match(/"gateEvidence"\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
|
562
|
+
const oldEvidenceMatch = jsonStr.match(/"matchedEvidence"\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
|
563
|
+
if (newEvidenceMatch) {
|
|
564
|
+
gateEvidence = unescapeJsonString(newEvidenceMatch[1]);
|
|
565
|
+
} else if (oldEvidenceMatch) {
|
|
566
|
+
gateEvidence = unescapeJsonString(oldEvidenceMatch[1]);
|
|
567
|
+
}
|
|
568
|
+
if (!dialogue && !candidateGateId) {
|
|
569
|
+
throw new Error("No JSON object found in AI response");
|
|
570
|
+
}
|
|
571
|
+
return {
|
|
572
|
+
dialogue,
|
|
573
|
+
candidateGateId,
|
|
574
|
+
gateEvidence,
|
|
575
|
+
gateConfidence,
|
|
576
|
+
candidateActionHint: null
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
function unescapeJsonString(s) {
|
|
580
|
+
return s.replace(/\\n/g, "\n").replace(/\\t/g, " ").replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
|
581
|
+
}
|
|
582
|
+
function buildResponse(parsed) {
|
|
583
|
+
const dialogue = typeof parsed.dialogue === "string" ? parsed.dialogue : String(parsed.dialogue ?? "");
|
|
584
|
+
const candidateGateId = typeof parsed.candidateGateId === "string" ? parsed.candidateGateId : typeof parsed.triggeredTopicGateId === "string" ? parsed.triggeredTopicGateId : null;
|
|
585
|
+
const gateEvidence = typeof parsed.gateEvidence === "string" ? parsed.gateEvidence : typeof parsed.matchedEvidence === "string" ? parsed.matchedEvidence : "";
|
|
586
|
+
const rawConfidence = parsed.gateConfidence ?? parsed.confidence;
|
|
587
|
+
const gateConfidence = rawConfidence === "low" || rawConfidence === "medium" || rawConfidence === "high" ? rawConfidence : "low";
|
|
588
|
+
const candidateActionHint = typeof parsed.candidateActionHint === "string" ? parsed.candidateActionHint : null;
|
|
589
|
+
return {
|
|
590
|
+
dialogue,
|
|
591
|
+
candidateGateId,
|
|
592
|
+
gateEvidence,
|
|
593
|
+
gateConfidence,
|
|
594
|
+
candidateActionHint
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// src/dialogue/schema.ts
|
|
599
|
+
function validateDialogueResponse(parsed) {
|
|
600
|
+
const result = { ...parsed };
|
|
601
|
+
if (typeof result.dialogue !== "string" || result.dialogue.length < 1) {
|
|
602
|
+
result.dialogue = "...";
|
|
603
|
+
}
|
|
604
|
+
if (result.dialogue.length > 1e3) {
|
|
605
|
+
result.dialogue = result.dialogue.slice(0, 1e3);
|
|
606
|
+
}
|
|
607
|
+
if (typeof result.candidateGateId !== "string") {
|
|
608
|
+
result.candidateGateId = null;
|
|
609
|
+
}
|
|
610
|
+
if (typeof result.gateEvidence !== "string") {
|
|
611
|
+
result.gateEvidence = "";
|
|
612
|
+
}
|
|
613
|
+
if (result.gateConfidence !== "low" && result.gateConfidence !== "medium" && result.gateConfidence !== "high") {
|
|
614
|
+
result.gateConfidence = "low";
|
|
615
|
+
}
|
|
616
|
+
if (typeof result.candidateActionHint !== "string") {
|
|
617
|
+
result.candidateActionHint = null;
|
|
618
|
+
}
|
|
619
|
+
return result;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// src/dialogue/gate-review.ts
|
|
623
|
+
function expandKeywordsBilingually(englishKeywords, bilingualMap) {
|
|
624
|
+
if (!bilingualMap || Object.keys(bilingualMap).length === 0) {
|
|
625
|
+
return [...englishKeywords];
|
|
626
|
+
}
|
|
627
|
+
const expanded = [...englishKeywords];
|
|
628
|
+
for (const kw of englishKeywords) {
|
|
629
|
+
const lower = kw.toLowerCase();
|
|
630
|
+
if (bilingualMap[lower]) {
|
|
631
|
+
expanded.push(...bilingualMap[lower]);
|
|
632
|
+
}
|
|
633
|
+
for (const [engKey, zhValues] of Object.entries(bilingualMap)) {
|
|
634
|
+
if (lower.includes(engKey) || engKey.includes(lower)) {
|
|
635
|
+
expanded.push(...zhValues);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return expanded;
|
|
640
|
+
}
|
|
641
|
+
function reviewGateTrigger(validated, playerInput, npcScript, _context, options) {
|
|
642
|
+
if (validated.candidateGateId === null) {
|
|
643
|
+
return validated;
|
|
644
|
+
}
|
|
645
|
+
const playerLower = playerInput.trim().toLowerCase();
|
|
646
|
+
const secret = npcScript.gatedSecrets.find(
|
|
647
|
+
(s) => s.topicGateId === validated.candidateGateId
|
|
648
|
+
);
|
|
649
|
+
if (!secret) {
|
|
650
|
+
return { ...validated, candidateGateId: null };
|
|
651
|
+
}
|
|
652
|
+
const triggerTerms = [
|
|
653
|
+
...secret.triggerKeywords ?? [],
|
|
654
|
+
...secret.triggerPhrases ?? []
|
|
655
|
+
].filter((term) => term.trim().length > 0);
|
|
656
|
+
const expandedTerms = expandKeywordsBilingually(triggerTerms, options?.bilingualKeywordMap);
|
|
657
|
+
const hasExplicitTerms = expandedTerms.length > 0;
|
|
658
|
+
const hasRelevance = expandedTerms.some((kw) => playerLower.includes(kw.toLowerCase()));
|
|
659
|
+
if (hasExplicitTerms && !hasRelevance) {
|
|
660
|
+
return { ...validated, candidateGateId: null };
|
|
661
|
+
}
|
|
662
|
+
const hasGateEvidence = validated.gateEvidence && validated.gateEvidence.trim().length > 0;
|
|
663
|
+
const hasConfidence = validated.gateConfidence === "medium" || validated.gateConfidence === "high";
|
|
664
|
+
if (!hasGateEvidence && !hasConfidence) {
|
|
665
|
+
return { ...validated, candidateGateId: null };
|
|
666
|
+
}
|
|
667
|
+
return validated;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// src/dialogue/engine.ts
|
|
671
|
+
var DEFAULT_DIALOGUE_CONFIG = {
|
|
672
|
+
lang: "en",
|
|
673
|
+
maxConversationHistory: 5,
|
|
674
|
+
failOpen: true
|
|
675
|
+
};
|
|
676
|
+
var DialogueEngine = class {
|
|
677
|
+
provider;
|
|
678
|
+
auditLog;
|
|
679
|
+
config;
|
|
680
|
+
lang;
|
|
681
|
+
initialized = false;
|
|
682
|
+
constructor(provider, config) {
|
|
683
|
+
this.provider = provider;
|
|
684
|
+
this.config = { ...DEFAULT_DIALOGUE_CONFIG, ...config };
|
|
685
|
+
this.lang = this.config.lang;
|
|
686
|
+
this.auditLog = new AuditLog(1e3);
|
|
687
|
+
}
|
|
688
|
+
// ─── Lifecycle ─────────────────────────────────────────────────────
|
|
689
|
+
async initialize() {
|
|
690
|
+
if (this.initialized) return;
|
|
691
|
+
this.initialized = true;
|
|
692
|
+
}
|
|
693
|
+
async dispose() {
|
|
694
|
+
this.initialized = false;
|
|
695
|
+
}
|
|
696
|
+
isAiAvailable() {
|
|
697
|
+
if (this.provider.id === "passthrough") return false;
|
|
698
|
+
const status = this.provider.getStatus();
|
|
699
|
+
return status.state === "ready" || status.state === "degraded";
|
|
700
|
+
}
|
|
701
|
+
// ─── Core API ──────────────────────────────────────────────────────
|
|
702
|
+
async handleFreeFormDialogue(request) {
|
|
703
|
+
const startTime = Date.now();
|
|
704
|
+
const passthroughResult = {
|
|
705
|
+
dialogue: "",
|
|
706
|
+
candidateGateId: null,
|
|
707
|
+
gateEvidence: "",
|
|
708
|
+
gateConfidence: "low",
|
|
709
|
+
candidateActionHint: null,
|
|
710
|
+
triggeredTopicGateId: null,
|
|
711
|
+
source: "passthrough",
|
|
712
|
+
latencyMs: Date.now() - startTime,
|
|
713
|
+
rawAiText: "",
|
|
714
|
+
model: "",
|
|
715
|
+
systemPrompt: "",
|
|
716
|
+
userPrompt: ""
|
|
717
|
+
};
|
|
718
|
+
if (!this.isAiAvailable()) {
|
|
719
|
+
return passthroughResult;
|
|
720
|
+
}
|
|
721
|
+
const { system, user } = buildNpcDialoguePrompt(
|
|
722
|
+
request.npcScript,
|
|
723
|
+
request.playerInput,
|
|
724
|
+
request.context,
|
|
725
|
+
request.context.recentExchanges.slice(-this.config.maxConversationHistory),
|
|
726
|
+
this.lang
|
|
727
|
+
);
|
|
728
|
+
let rawText;
|
|
729
|
+
let rawModel = "";
|
|
730
|
+
let rawPromptTokens;
|
|
731
|
+
let rawCompletionTokens;
|
|
732
|
+
try {
|
|
733
|
+
const narrativeRequest = this.buildMinimalRequest(request);
|
|
734
|
+
const raw = await this.provider.call(narrativeRequest, system, user);
|
|
735
|
+
rawText = raw.text;
|
|
736
|
+
rawModel = raw.model;
|
|
737
|
+
rawPromptTokens = raw.promptTokenCount;
|
|
738
|
+
rawCompletionTokens = raw.completionTokenCount;
|
|
739
|
+
} catch (err) {
|
|
740
|
+
console.error("[DialogueEngine] provider.call failed:", err);
|
|
741
|
+
return passthroughResult;
|
|
742
|
+
}
|
|
743
|
+
let parsed;
|
|
744
|
+
try {
|
|
745
|
+
parsed = parseAiJson(rawText);
|
|
746
|
+
} catch {
|
|
747
|
+
return {
|
|
748
|
+
dialogue: rawText.slice(0, 1e3) || "...",
|
|
749
|
+
candidateGateId: null,
|
|
750
|
+
gateEvidence: "",
|
|
751
|
+
gateConfidence: "low",
|
|
752
|
+
candidateActionHint: null,
|
|
753
|
+
triggeredTopicGateId: null,
|
|
754
|
+
source: "ai",
|
|
755
|
+
latencyMs: Date.now() - startTime,
|
|
756
|
+
rawAiText: rawText,
|
|
757
|
+
model: rawModel,
|
|
758
|
+
systemPrompt: system,
|
|
759
|
+
userPrompt: user
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
const validated = validateDialogueResponse(parsed);
|
|
763
|
+
const reviewed = reviewGateTrigger(
|
|
764
|
+
validated,
|
|
765
|
+
request.playerInput,
|
|
766
|
+
request.npcScript,
|
|
767
|
+
request.context,
|
|
768
|
+
{ bilingualKeywordMap: this.config.bilingualKeywordMap }
|
|
769
|
+
);
|
|
770
|
+
this.recordAudit(request, reviewed, startTime);
|
|
771
|
+
return {
|
|
772
|
+
dialogue: reviewed.dialogue,
|
|
773
|
+
candidateGateId: reviewed.candidateGateId,
|
|
774
|
+
gateEvidence: reviewed.gateEvidence,
|
|
775
|
+
gateConfidence: reviewed.gateConfidence,
|
|
776
|
+
candidateActionHint: reviewed.candidateActionHint,
|
|
777
|
+
source: "ai",
|
|
778
|
+
latencyMs: Date.now() - startTime,
|
|
779
|
+
rawAiText: rawText,
|
|
780
|
+
model: rawModel,
|
|
781
|
+
promptTokens: rawPromptTokens,
|
|
782
|
+
completionTokens: rawCompletionTokens,
|
|
783
|
+
systemPrompt: system,
|
|
784
|
+
userPrompt: user,
|
|
785
|
+
// Backward compatibility
|
|
786
|
+
triggeredTopicGateId: reviewed.candidateGateId
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
// ─── Helpers ───────────────────────────────────────────────────────
|
|
790
|
+
buildMinimalRequest(request) {
|
|
791
|
+
return {
|
|
792
|
+
id: `dlg_${Date.now().toString(36)}`,
|
|
793
|
+
type: "dialogue",
|
|
794
|
+
turnIndex: request.context.currentTurn,
|
|
795
|
+
events: [],
|
|
796
|
+
commandMessage: request.playerInput,
|
|
797
|
+
visibleState: {},
|
|
798
|
+
grounding: {
|
|
799
|
+
currentRoomName: request.context.currentRoom,
|
|
800
|
+
visibleExits: [],
|
|
801
|
+
presentNpcNames: [],
|
|
802
|
+
inventoryItemNames: request.context.playerInventory,
|
|
803
|
+
discoveredClueNames: request.context.discoveredClues,
|
|
804
|
+
knownConsequences: [],
|
|
805
|
+
turnsRemaining: request.context.turnsRemaining
|
|
806
|
+
},
|
|
807
|
+
timestamp: Date.now(),
|
|
808
|
+
lang: this.lang
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
recordAudit(request, result, startTime) {
|
|
812
|
+
const record = {
|
|
813
|
+
requestId: `dlg_audit_${Date.now().toString(36)}`,
|
|
814
|
+
turnIndex: request.context.currentTurn,
|
|
815
|
+
requestType: "dialogue",
|
|
816
|
+
eventTypes: [],
|
|
817
|
+
commandVerb: "talk",
|
|
818
|
+
responseSource: "ai",
|
|
819
|
+
responseTextPreview: result.dialogue.slice(0, 100),
|
|
820
|
+
validationPassed: true,
|
|
821
|
+
constraintViolations: [],
|
|
822
|
+
providerState: this.provider.getStatus().state,
|
|
823
|
+
providerId: this.provider.id,
|
|
824
|
+
promptStructure: {
|
|
825
|
+
systemPromptHash: "",
|
|
826
|
+
groundingFactCount: 0,
|
|
827
|
+
eventCount: 0
|
|
828
|
+
},
|
|
829
|
+
latencyMs: Date.now() - startTime,
|
|
830
|
+
timestamp: Date.now()
|
|
831
|
+
};
|
|
832
|
+
this.auditLog.append(record);
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
export {
|
|
836
|
+
AuditLog,
|
|
837
|
+
DialogueEngine,
|
|
838
|
+
OpenAICompatibleProvider,
|
|
839
|
+
PassthroughProvider,
|
|
840
|
+
buildNpcDialoguePrompt,
|
|
841
|
+
expandKeywordsBilingually,
|
|
842
|
+
loadConfigFromEnv,
|
|
843
|
+
parseAiJson,
|
|
844
|
+
reviewGateTrigger,
|
|
845
|
+
validateDialogueResponse
|
|
846
|
+
};
|
|
847
|
+
//# sourceMappingURL=index.js.map
|