Files
Genarrative/server-node/src/services/customWorldAgentTestHelpers.ts
2026-04-18 13:05:29 +08:00

322 lines
9.7 KiB
TypeScript

import type { EightAnchorContent } from '../../../packages/shared/src/contracts/customWorldAgent.js';
import type { UpstreamLlmClient } from './llmClient.js';
import {
extractCreatorIntentPatch,
mergeCreatorIntentRecord,
} from './customWorldAgentIntentExtractionService.js';
import {
buildCreatorIntentFromEightAnchorContent,
buildEightAnchorContentFromCreatorIntent,
createEmptyEightAnchorContent,
estimateProgressPercentFromAnchorContent,
normalizeEightAnchorContent,
} from './eightAnchorCompatibilityService.js';
type TestChatMessage = {
role: 'user' | 'assistant';
content: string;
};
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function shouldReplaceWorldPromise(params: {
latestUserText: string;
hasExistingWorldPromise: boolean;
}) {
if (!params.hasExistingWorldPromise) {
return true;
}
return /(|||||||||)/u.test(
params.latestUserText,
);
}
function buildAutoCompletePatch(intent: ReturnType<
typeof buildCreatorIntentFromEightAnchorContent
>) {
return {
worldHook:
intent.worldHook ||
intent.rawSettingText ||
'一个被未知异象改变秩序的边境世界。',
playerPremise: intent.playerPremise || '玩家是被卷入核心危机的返乡者。',
openingSituation:
intent.openingSituation || '开局时,玩家正抵达危机爆发的现场。',
themeKeywords:
intent.themeKeywords.length > 0 ? intent.themeKeywords : ['奇幻'],
toneDirectives:
intent.toneDirectives.length > 0 ? intent.toneDirectives : ['悬疑'],
coreConflicts:
intent.coreConflicts.length > 0
? intent.coreConflicts
: ['旧秩序与新威胁正在争夺世界的未来。'],
keyCharacters:
intent.keyCharacters.length > 0
? intent.keyCharacters
: [
{
id: 'auto-key-character-1',
name: '未命名关键人物',
role: '关键关系',
publicMask: '看似能帮助玩家的人。',
hiddenHook: '掌握一条会改变局势的暗线。',
relationToPlayer: '旧识',
notes: '自动补全,可继续修改。',
},
],
iconicElements:
intent.iconicElements.length > 0 ? intent.iconicElements : ['失落信标'],
};
}
function buildReplyText(params: {
nextAnchorContent: EightAnchorContent;
progressPercent: number;
quickFillRequested: boolean;
latestUserText: string;
}) {
if (params.quickFillRequested || params.progressPercent >= 100) {
return '这一版已经收住了,现在可以直接生成游戏设定草稿。';
}
if (/(|||)/u.test(params.latestUserText)) {
return '我已经按你刚刚修正后的方向重收了一版,现在这条主线会更稳。';
}
if (!params.nextAnchorContent.worldPromise?.hook) {
return '方向我先接住了一点。这个世界最抓人的那句核心设定,你想怎么钉住?';
}
if (!params.nextAnchorContent.playerFantasy?.playerRole) {
return '世界底色已经有了。你最想让玩家以什么身份卷进来?';
}
if (!params.nextAnchorContent.playerEntryPoint?.openingProblem) {
return '大方向先稳住了。故事开场时,玩家先撞上的麻烦是什么?';
}
if (!params.nextAnchorContent.coreConflict?.surfaceConflicts.length) {
return '现在气质和身份都更清楚了。接下来最值得钉住的,是这个世界正在爆开的主要冲突。';
}
return '这轮信息我已经收进当前版本里了,你可以继续往下补,也可以让我顺着这条线继续收束。';
}
function extractJsonBlock(text: string, marker: string) {
const markerIndex = text.indexOf(marker);
if (markerIndex < 0) {
return null;
}
let startIndex = markerIndex + marker.length;
while (startIndex < text.length && /\s/u.test(text[startIndex] ?? '')) {
startIndex += 1;
}
const firstCharacter = text[startIndex];
if (firstCharacter !== '{' && firstCharacter !== '[') {
return null;
}
const closingCharacter = firstCharacter === '{' ? '}' : ']';
let depth = 0;
let insideString = false;
let escaping = false;
for (let index = startIndex; index < text.length; index += 1) {
const character = text[index] ?? '';
if (insideString) {
if (escaping) {
escaping = false;
continue;
}
if (character === '\\') {
escaping = true;
continue;
}
if (character === '"') {
insideString = false;
}
continue;
}
if (character === '"') {
insideString = true;
continue;
}
if (character === firstCharacter) {
depth += 1;
continue;
}
if (character === closingCharacter) {
depth -= 1;
if (depth === 0) {
return text.slice(startIndex, index + 1);
}
}
}
return null;
}
function parsePromptInput(text: string) {
const anchorJson = extractJsonBlock(text, '当前完整设定结构:');
const chatJson = extractJsonBlock(text, '用户聊天记录:');
const currentAnchorContent = anchorJson
? normalizeEightAnchorContent(JSON.parse(anchorJson))
: createEmptyEightAnchorContent();
const chatHistory = chatJson
? (JSON.parse(chatJson) as TestChatMessage[])
: [];
const quickFillRequested =
text.includes('是否要求自动补全:是') ||
text.includes('conversationMode: force_complete') ||
text.includes('用户刚刚主动要求你自动补全剩余设定');
return {
currentAnchorContent,
chatHistory,
quickFillRequested,
};
}
export function buildTestEightAnchorTurn(params: {
currentAnchorContent: EightAnchorContent;
chatHistory: TestChatMessage[];
quickFillRequested: boolean;
}) {
const latestUserText =
[...params.chatHistory]
.reverse()
.find((entry) => entry.role === 'user' && entry.content.trim())?.content ??
'';
const currentIntent = buildCreatorIntentFromEightAnchorContent(
params.currentAnchorContent,
);
const intentPatch = extractCreatorIntentPatch({
currentIntent,
latestUserMessage: latestUserText,
recentMessages: params.chatHistory
.filter((entry) => entry.role === 'user')
.slice(-6, -1)
.map((entry) => entry.content),
});
const mergedIntent = mergeCreatorIntentRecord(
currentIntent,
params.quickFillRequested
? {
...intentPatch,
...buildAutoCompletePatch(currentIntent),
}
: intentPatch,
);
if (
!shouldReplaceWorldPromise({
latestUserText,
hasExistingWorldPromise: Boolean(currentIntent.worldHook),
})
) {
mergedIntent.worldHook = currentIntent.worldHook;
}
const nextAnchorContent = buildEightAnchorContentFromCreatorIntent(mergedIntent);
const progressPercent = params.quickFillRequested
? 100
: estimateProgressPercentFromAnchorContent(nextAnchorContent);
return {
replyText: buildReplyText({
nextAnchorContent,
progressPercent,
quickFillRequested: params.quickFillRequested,
latestUserText,
}),
progressPercent,
nextAnchorContent,
};
}
function buildStateInferenceFromPrompt(text: string) {
const { chatHistory, quickFillRequested } = parsePromptInput(text);
const latestUserText =
[...chatHistory]
.reverse()
.find((entry) => entry.role === 'user' && entry.content.trim())?.content ??
'';
const correction = /(|||||)/u.test(latestUserText);
const delegate = /(||||)/u.test(
latestUserText,
);
if (quickFillRequested) {
return {
userInputSignal: delegate ? 'delegate' : 'normal',
driftRisk: correction ? 'high' : 'medium',
conversationMode: 'force_complete',
judgementSummary: '用户希望系统直接补完,这一轮应优先补齐剩余设定并结束收集阶段。',
};
}
if (correction) {
return {
userInputSignal: 'correction',
driftRisk: 'high',
conversationMode: 'repair_direction',
judgementSummary: '用户正在修正旧方向,正式生成时要让修正后的版本直接接管当前语境。',
};
}
if (latestUserText.length < 20) {
return {
userInputSignal: delegate ? 'delegate' : 'sparse',
driftRisk: 'low',
conversationMode: 'bootstrap',
judgementSummary: '这轮新增信息较少,正式生成时应先低压力接住方向,再只推进一个最好回答的问题。',
};
}
return {
userInputSignal: latestUserText.length >= 40 ? 'rich' : 'normal',
driftRisk: 'low',
conversationMode: 'expand',
judgementSummary: '这轮是在顺着现有方向继续补充,正式生成时应吸收新增细节并往前推进一步。',
};
}
export function createTestCustomWorldAgentSingleTurnLlmClient() {
return {
requestMessageContent: async (params) => {
if (params.systemPrompt.includes('创作状态识别器')) {
return JSON.stringify(buildStateInferenceFromPrompt(params.userPrompt));
}
const promptInput = parsePromptInput(
[params.systemPrompt, params.userPrompt].join('\n\n'),
);
return JSON.stringify(buildTestEightAnchorTurn(promptInput));
},
streamMessageContent: async (params) => {
const promptInput = parsePromptInput(
[params.systemPrompt, params.userPrompt].join('\n\n'),
);
const output = buildTestEightAnchorTurn(promptInput);
const jsonText = JSON.stringify({
replyText: output.replyText,
progressPercent: output.progressPercent,
nextAnchorContent: output.nextAnchorContent,
});
params.onUpdate?.(jsonText);
return jsonText;
},
} as UpstreamLlmClient;
}