Integrate role asset studio into custom world agent flow

This commit is contained in:
2026-04-14 20:16:41 +08:00
parent 0981d6ee1b
commit bc2999ffb9
118 changed files with 31211 additions and 1232 deletions

View File

@@ -1,462 +1,490 @@
import type {
CustomWorldGenerationMode,
CustomWorldGenerationProgress,
GenerateCustomWorldProfileInput,
} from '../../../../packages/shared/src/contracts/runtime.js';
import { parseJsonResponseText } from '../../../../packages/shared/src/llm/parsers.js';
import {
MIN_CUSTOM_WORLD_LANDMARK_COUNT,
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
validateGeneratedCustomWorldProfile,
} from '../../../../src/services/customWorld.js';
import { buildExpandedCustomWorldProfile } from '../../../../src/services/customWorldBuilder.js';
import {
buildCustomWorldAnchorPackFromIntent,
buildCustomWorldCreatorIntentGenerationText,
deriveCustomWorldLockStateFromIntent,
hasMeaningfulCustomWorldCreatorIntent,
normalizeCustomWorldCreatorIntent,
} from '../../../../src/services/customWorldCreatorIntent.js';
import type {
CustomWorldCreatorIntent,
CustomWorldProfile,
} from '../../../../src/types.js';
import type { UpstreamLlmClient } from '../../services/llmClient.js';
type GeneratedProfile = Record<string, unknown>;
const PLAYABLE_ROLE_TEMPLATES = [
{ title: '断桥行者', role: '游历剑客', style: '快剑追击', tags: ['快剑', '突进', '追击'] },
{ title: '听风客', role: '远行弓手', style: '远射游击', tags: ['远射', '游击', '风行'] },
{ title: '守夜人', role: '前列护卫', style: '守御护体', tags: ['守御', '护体', '先锋'] },
{ title: '观火者', role: '术式使', style: '法修过载', tags: ['法修', '过载', '法力'] },
{ title: '逐潮者', role: '浪客拳师', style: '重击压制', tags: ['重击', '爆发', '压制'] },
const CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT = `你是严格的自定义世界 JSON 生成器。
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。`;
const CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。
你会收到一段本应为单个 JSON 对象的文本。
你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。
不要输出 Markdown、代码块、解释、注释或额外文字。
尽量保留原始语义,只修复格式问题;必要时可以补齐缺失的引号、逗号、括号、数组闭合或缺失字段。`;
const CUSTOM_WORLD_REQUEST_TIMEOUT_MS = 180000;
const FAST_CUSTOM_WORLD_PLAYABLE_COUNT = 3;
const FAST_CUSTOM_WORLD_STORY_COUNT = 8;
const FAST_CUSTOM_WORLD_LANDMARK_COUNT = 4;
const CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS = [
{
id: 'prepare',
label: '整理设定',
detail: '整理创作者输入,准备模型推理上下文。',
total: 1,
weight: 1,
},
{
id: 'llm-profile',
label: '大模型推理',
detail: '正在请求模型生成世界档案、角色群像与场景网络。',
total: 1,
weight: 8,
},
{
id: 'normalize',
label: '系统编译',
detail: '正在把模型结果归一成运行时可用结构。',
total: 1,
weight: 2,
},
{
id: 'finalize',
label: '归档世界',
detail: '整理最终世界档案并做完整性校验。',
total: 1,
weight: 1,
},
] as const;
const STORY_ROLE_TEMPLATES = [
{ role: '沿街商贩', danger: 'low', tags: ['交易', '情报'] },
{ role: '巡路探子', danger: 'medium', tags: ['巡守', '警觉'] },
{ role: '旧案见证人', danger: 'medium', tags: ['旧案', '隐情'] },
{ role: '守桥武人', danger: 'high', tags: ['守御', '敌意'] },
{ role: '异变潜伏者', danger: 'high', tags: ['异变', '威胁'] },
] as const;
type CustomWorldGenerationStageId =
(typeof CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS)[number]['id'];
const LANDMARK_TEMPLATES = [
'断桥口',
'旧市桥廊',
'潮痕渡口',
'灰塔前庭',
'沉钟小巷',
'碑下荒庭',
'雾潮栈道',
'封灯码头',
'裂潮前哨',
'残照高台',
] as const;
class CustomWorldGenerationAbortedError extends Error {
constructor(message = '世界生成已中断。') {
super(message);
this.name = 'CustomWorldGenerationAbortedError';
}
}
function nowMs() {
return Date.now();
}
function inferWorldType(settingText: string) {
return /||||||/u.test(settingText)
? 'XIANXIA'
: 'WUXIA';
function throwIfCustomWorldGenerationAborted(signal?: AbortSignal) {
if (!signal?.aborted) {
return;
}
throw signal.reason instanceof Error
? signal.reason
: new CustomWorldGenerationAbortedError();
}
function seedText(input: GenerateCustomWorldProfileInput) {
return input.settingText.trim().replace(/\s+/g, ' ');
function isCustomWorldGenerationAbortLikeError(error: unknown) {
return (
error instanceof CustomWorldGenerationAbortedError ||
(typeof DOMException !== 'undefined' &&
error instanceof DOMException &&
error.name === 'AbortError') ||
(error instanceof Error && error.name === 'AbortError')
);
}
function slugify(value: string) {
const normalized = value
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
.replace(/^-+|-+$/g, '');
function sanitizeJsonLikeText(text: string) {
const trimmed = text.trim();
if (!trimmed) {
return '';
}
return normalized || 'entry';
const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/iu);
const unfenced = fencedMatch?.[1]?.trim() || trimmed;
const firstBrace = unfenced.indexOf('{');
const lastBrace = unfenced.lastIndexOf('}');
const extracted =
firstBrace >= 0 && lastBrace > firstBrace
? unfenced.slice(firstBrace, lastBrace + 1)
: unfenced;
return extracted
.replace(/^\uFEFF/u, '')
.replace(/[\u201C\u201D]/gu, '"')
.replace(/[\u2018\u2019]/gu, "'")
.replace(/\u00A0/gu, ' ')
.replace(/,\s*([}\]])/gu, '$1')
.trim();
}
function buildAttributeSchema(worldType: 'WUXIA' | 'XIANXIA') {
function resolveCustomWorldGenerationInput(
input: GenerateCustomWorldProfileInput,
): {
settingText: string;
generationSeedText: string;
creatorIntent: CustomWorldCreatorIntent | null;
generationMode: CustomWorldGenerationMode;
} {
const settingText = input.settingText.trim();
const creatorIntent = normalizeCustomWorldCreatorIntent(input.creatorIntent);
const generationSeedText =
creatorIntent && hasMeaningfulCustomWorldCreatorIntent(creatorIntent)
? buildCustomWorldCreatorIntentGenerationText(creatorIntent)
: settingText;
return {
id: `schema:${worldType.toLowerCase()}:default`,
worldId: `world:${worldType.toLowerCase()}`,
schemaVersion: 1,
generatedFrom: {
worldType,
worldName: worldType === 'XIANXIA' ? '云海异境' : '裂潮边城',
settingSummary: worldType === 'XIANXIA' ? '灵潮翻涌的高空异境' : '旧桥与边城交错的裂潮地界',
tone: worldType === 'XIANXIA' ? '高危、空灵、失衡' : '冷峻、紧绷、边境余震',
conflictCore: '旧秩序与新威胁正在同时逼近',
},
schemaName: worldType === 'XIANXIA' ? '灵潮六轴' : '边城六轴',
slots: [
{
slotId: 'axis_a',
name: '锋势',
definition: '临战时的主动压迫与破面能力',
positiveSignals: ['先手', '破势'],
negativeSignals: ['迟疑', '退缩'],
combatUseText: '决定压制与追击能力',
socialUseText: '决定发起对峙的胆气',
explorationUseText: '决定冒险前推的强度',
},
{
slotId: 'axis_b',
name: '守意',
definition: '承压、稳住阵脚与保全同伴的能力',
positiveSignals: ['护持', '稳守'],
negativeSignals: ['失衡', '溃散'],
combatUseText: '决定承伤与稳场',
socialUseText: '决定是否可靠',
explorationUseText: '决定穿越危险区的稳定性',
},
{
slotId: 'axis_c',
name: '灵运',
definition: '资源调度、法力回转与术式适配能力',
positiveSignals: ['回转', '灵感'],
negativeSignals: ['枯竭', '滞涩'],
combatUseText: '决定灵力和术式运转',
socialUseText: '决定理解复杂信息的能力',
explorationUseText: '决定破解机关与异象',
},
{
slotId: 'axis_d',
name: '机变',
definition: '借势应变、换位与局势判断能力',
positiveSignals: ['借势', '换位'],
negativeSignals: ['僵硬', '迟钝'],
combatUseText: '决定机动与变招',
socialUseText: '决定读懂弦外之音',
explorationUseText: '决定追踪与绕险',
},
{
slotId: 'axis_e',
name: '因缘',
definition: '人与人之间的牵连、信任与旧债张力',
positiveSignals: ['信任', '牵连'],
negativeSignals: ['隔阂', '背离'],
combatUseText: '决定协同与互援',
socialUseText: '决定关系推进',
explorationUseText: '决定是否能得到帮助',
},
{
slotId: 'axis_f',
name: '秘痕',
definition: '旧案、禁忌与隐秘线索的承载程度',
positiveSignals: ['旧痕', '秘线'],
negativeSignals: ['空白', '浅表'],
combatUseText: '决定异象与特殊效果',
socialUseText: '决定话题深度',
explorationUseText: '决定发现隐藏真相的能力',
},
],
settingText,
generationSeedText: generationSeedText.trim() || settingText,
creatorIntent,
generationMode: input.generationMode === 'fast' ? 'fast' : 'full',
};
}
function buildBackstoryReveal(name: string) {
function getCustomWorldGenerationTargets(
generationMode: CustomWorldGenerationMode,
) {
if (generationMode === 'fast') {
return {
playableCount: FAST_CUSTOM_WORLD_PLAYABLE_COUNT,
storyCount: FAST_CUSTOM_WORLD_STORY_COUNT,
landmarkCount: FAST_CUSTOM_WORLD_LANDMARK_COUNT,
generationStatus: 'key_only' as const,
};
}
return {
publicSummary: `${name}在表面上只露出一层足以自保的说辞。`,
privateChatUnlockAffinity: 60,
chapters: [
{
id: `${slugify(name)}-surface`,
title: '表层来意',
affinityRequired: 15,
teaser: `${name}对你仍留着一层试探。`,
content: `${name}先承认自己并非偶然出现在这里,而是被同一场异动推到了前线。`,
contextSnippet: `${name}的真正来意还没有完全摊开。`,
},
{
id: `${slugify(name)}-scar`,
title: '旧事裂痕',
affinityRequired: 30,
teaser: `${name}提到过一次不愿重说的旧伤。`,
content: `${name}曾在上一轮风暴里失去过重要的人,因此对类似局势格外警觉。`,
contextSnippet: `${name}和旧案之间存在未平的裂痕。`,
},
{
id: `${slugify(name)}-hidden`,
title: '隐藏执念',
affinityRequired: 60,
teaser: `${name}其实一直在盯着更深一层的线索。`,
content: `${name}真正想追索的不是眼前纷乱本身,而是它背后那只一直没露面的手。`,
contextSnippet: `${name}的行动始终绕着一条更深的暗线。`,
},
{
id: `${slugify(name)}-final`,
title: '最终底牌',
affinityRequired: 90,
teaser: `${name}手里一直留着最后一道底牌。`,
content: `${name}早就为最坏结局准备了最后的应对,但不到绝境绝不会轻易亮出。`,
contextSnippet: `${name}仍保留着能改写局面的最后筹码。`,
},
],
playableCount: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
storyCount: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
landmarkCount: MIN_CUSTOM_WORLD_LANDMARK_COUNT,
generationStatus: 'complete' as const,
};
}
function buildSkills(name: string) {
return [
{
id: `${slugify(name)}-skill-1`,
name: `${name}起手`,
summary: '先用短促动作压住眼前节奏。',
style: '起手压制',
},
{
id: `${slugify(name)}-skill-2`,
name: `${name}变招`,
summary: '在试探后迅速换位改势。',
style: '机动周旋',
},
{
id: `${slugify(name)}-skill-3`,
name: `${name}底牌`,
summary: '在局势逼紧时打出保留手段。',
style: '爆发终结',
},
];
}
function buildInitialItems(name: string) {
return [
{
id: `${slugify(name)}-item-1`,
name: `${name}常备武具`,
category: '武器',
quantity: 1,
rarity: 'rare',
description: '随身不离手的主战物件。',
tags: ['战斗', '随身'],
},
{
id: `${slugify(name)}-item-2`,
name: `${name}补给包`,
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: '为了久战和撤离准备的基础补给。',
tags: ['补给', '行动'],
},
{
id: `${slugify(name)}-item-3`,
name: `${name}私人物件`,
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '不愿轻易交出的旧信物。',
tags: ['信物', '线索'],
},
];
}
function buildPlayableNpcs(seed: string) {
return PLAYABLE_ROLE_TEMPLATES.map((template, index) => {
const name = `${seed.slice(0, 2) || '裂潮'}${['岚', '砺', '遥', '烛', '澜'][index]}`;
return {
id: `playable-npc-${index + 1}`,
name,
title: template.title,
role: template.role,
description: `${name}习惯先观察再出手,对局势变化反应极快。`,
backstory: `${name}长期在风暴边缘活动,对眼前这场失衡局势并不陌生。`,
personality: '谨慎、沉稳、保留余地',
motivation: '想先查清是谁把局势推到这一步。',
combatStyle: template.style,
initialAffinity: 18 + index * 4,
relationshipHooks: ['共同求生', '交换情报'],
tags: [...template.tags],
backstoryReveal: buildBackstoryReveal(name),
skills: buildSkills(name),
initialItems: buildInitialItems(name),
templateCharacterId: ['sword-princess', 'archer-hero', 'girl-hero', 'punch-hero', 'fighter-4'][index],
};
});
}
function buildStoryNpcs(seed: string) {
return Array.from({ length: 25 }, (_, index) => {
const template = STORY_ROLE_TEMPLATES[index % STORY_ROLE_TEMPLATES.length]!;
const name = `${seed.slice(0, 2) || '裂潮'}${['青', '玄', '沉', '洛', '霁'][index % 5]}${index + 1}`;
return {
id: `story-npc-${index + 1}`,
name,
title: `${index + 1}位见证者`,
role: template.role,
description: `${name}始终在观察这场异动会把谁先逼到台前。`,
backstory: `${name}和这片地界的旧事牵连很深,只是还没有把来历说透。`,
personality: '警觉、克制、善于藏话',
motivation: '想确认这轮动荡背后真正的引线。',
combatStyle: template.danger === 'high' ? '先压后断' : '先试后动',
initialAffinity: template.danger === 'high' ? -12 : 6 + (index % 3) * 6,
relationshipHooks: ['旧案牵连', '局势试探'],
tags: [...template.tags],
backstoryReveal: buildBackstoryReveal(name),
skills: buildSkills(name),
initialItems: buildInitialItems(name),
};
});
}
function buildLandmarks(seed: string, storyNpcIds: string[]) {
return LANDMARK_TEMPLATES.map((baseName, index, all) => {
const name = `${seed.slice(0, 2) || '裂潮'}${baseName}`;
return {
id: `landmark-${index + 1}`,
name,
description: `${name}附近同时压着旧痕、异动与尚未收束的危险。`,
dangerLevel: index < 3 ? 'medium' : index < 7 ? 'high' : 'extreme',
sceneNpcIds: [
storyNpcIds[index % storyNpcIds.length],
storyNpcIds[(index + 7) % storyNpcIds.length],
storyNpcIds[(index + 13) % storyNpcIds.length],
],
connections: [
{
targetLandmarkId: `landmark-${((index + 1) % all.length) + 1}`,
relativePosition: 'forward',
summary: '沿着当前道路继续前推就能抵达。',
},
{
targetLandmarkId: `landmark-${((index + all.length - 1) % all.length) + 1}`,
relativePosition: 'back',
summary: '沿原路回撤可以折返到上一处节点。',
},
],
};
});
}
function buildProgress(
phaseId: string,
phaseLabel: string,
phaseDetail: string,
overallProgress: number,
activeStepIndex: number,
startedAt: number,
): CustomWorldGenerationProgress {
const steps = [
{ id: 'framework', label: '世界框架', detail: '整理世界基础骨架。', status: overallProgress >= 0.25 ? 'completed' : phaseId === 'framework' ? 'active' : 'pending', completed: overallProgress >= 0.25 ? 1 : 0, total: 1 },
{ id: 'roles', label: '角色群像', detail: '生成可玩与场景角色。', status: overallProgress >= 0.6 ? 'completed' : phaseId === 'roles' ? 'active' : 'pending', completed: overallProgress >= 0.6 ? 1 : 0, total: 1 },
{ id: 'landmarks', label: '场景网络', detail: '生成地标与连接关系。', status: overallProgress >= 0.85 ? 'completed' : phaseId === 'landmarks' ? 'active' : 'pending', completed: overallProgress >= 0.85 ? 1 : 0, total: 1 },
{ id: 'finalize', label: '最终归档', detail: '整理最终世界资料。', status: overallProgress >= 1 ? 'completed' : phaseId === 'finalize' ? 'active' : 'pending', completed: overallProgress >= 1 ? 1 : 0, total: 1 },
] as CustomWorldGenerationProgress['steps'];
return {
phaseId,
phaseLabel,
phaseDetail,
overallProgress,
completedWeight: Math.round(overallProgress * 100),
totalWeight: 100,
elapsedMs: nowMs() - startedAt,
estimatedRemainingMs: overallProgress >= 1 ? 0 : Math.max(1000, Math.round((1 - overallProgress) * 4000)),
activeStepIndex,
steps,
};
}
function inferMajorFactions(seed: string) {
return [
`${seed.slice(0, 2) || '裂潮'}守桥司`,
`${seed.slice(0, 2) || '裂潮'}旧案会`,
`${seed.slice(0, 2) || '裂潮'}商旅盟`,
];
}
function inferCoreConflicts(seedText: string) {
const core = seedText.slice(0, 24) || '旧秩序与新威胁的失衡';
return [
`围绕“${core}”的旧秩序正在松动。`,
'各方都在争夺谁来解释眼前的异变。',
'真正推动局势的人始终没有完全现身。',
];
}
function buildDeterministicProfile(input: GenerateCustomWorldProfileInput) {
const setting = seedText(input);
const worldType = inferWorldType(setting);
const seed = setting.replace(/\s+/g, '').slice(0, 6) || (worldType === 'XIANXIA' ? '云潮' : '裂潮');
const playableNpcs = buildPlayableNpcs(seed);
const storyNpcs = buildStoryNpcs(seed);
const landmarks = buildLandmarks(
seed,
storyNpcs.map((npc) => npc.id),
function createCustomWorldGenerationReporter(
onProgress?: (progress: CustomWorldGenerationProgress) => void,
) {
const startedAt = nowMs();
const completedByStage = Object.fromEntries(
CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.map((stage) => [stage.id, 0]),
) as Record<CustomWorldGenerationStageId, number>;
const totalWeight = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.reduce(
(sum, stage) => sum + stage.weight,
0,
);
const emit = (
stageId: CustomWorldGenerationStageId,
options: Partial<{
completed: number;
phaseDetail: string;
}> = {},
) => {
const stage = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.find(
(item) => item.id === stageId,
);
if (!stage) {
return;
}
if (typeof options.completed === 'number') {
completedByStage[stageId] = Math.max(
0,
Math.min(stage.total, options.completed),
);
}
const steps = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.map((item) => {
const completed = Math.max(
0,
Math.min(item.total, completedByStage[item.id]),
);
return {
id: item.id,
label: item.label,
detail: item.detail,
completed,
total: item.total,
status:
completed >= item.total
? 'completed'
: item.id === stageId
? 'active'
: 'pending',
} satisfies CustomWorldGenerationProgress['steps'][number];
});
const completedWeight = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.reduce(
(sum, item) =>
sum + (completedByStage[item.id] / item.total || 0) * item.weight,
0,
);
const progressFraction = totalWeight > 0 ? completedWeight / totalWeight : 0;
const elapsedMs = Math.max(0, nowMs() - startedAt);
const estimatedRemainingMs =
progressFraction > 0 && progressFraction < 1
? Math.max(0, Math.round(elapsedMs / progressFraction - elapsedMs))
: progressFraction >= 1
? 0
: null;
onProgress?.({
phaseId: stage.id,
phaseLabel: stage.label,
phaseDetail: options.phaseDetail ?? stage.detail,
overallProgress: Math.max(
0,
Math.min(100, Math.round(progressFraction * 100)),
),
completedWeight,
totalWeight,
elapsedMs,
estimatedRemainingMs,
activeStepIndex: CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.findIndex(
(item) => item.id === stage.id,
),
steps,
});
};
return {
id: `custom-world-${Date.now().toString(36)}-${slugify(seed)}`,
settingText: setting,
name: worldType === 'XIANXIA' ? `${seed}灵境` : `${seed}边城`,
subtitle: '前路未明',
summary: `这个世界围绕“${setting.slice(0, 28)}”展开,旧秩序与新威胁正在同时逼近。`,
tone: worldType === 'XIANXIA' ? '空灵、危险、失衡' : '冷峻、紧绷、边境余震',
playerGoal: '查清眼前局势的关键矛盾,并守住仍值得相信的人与事',
templateWorldType: worldType,
compatibilityTemplateWorldType: worldType,
majorFactions: inferMajorFactions(seed),
coreConflicts: inferCoreConflicts(setting),
attributeSchema: buildAttributeSchema(worldType),
playableNpcs,
storyNpcs,
items: [],
camp: {
name: worldType === 'XIANXIA' ? `${seed}归云舍` : `${seed}归桥居`,
description: '这是玩家开局时暂时安身、整理情报与调整队伍的位置。',
dangerLevel: 'low',
begin(stageId: CustomWorldGenerationStageId, phaseDetail?: string) {
emit(stageId, {
completed: completedByStage[stageId],
phaseDetail,
});
},
landmarks,
themePack: null,
storyGraph: null,
knowledgeFacts: [],
threadContracts: [],
creatorIntent: input.creatorIntent ?? null,
anchorPack: null,
lockState: null,
ownedSettingLayers: null,
generationMode: input.generationMode ?? 'full',
generationStatus: input.generationMode === 'fast' ? 'key_only' : 'complete',
scenarioPackId: null,
campaignPackId: null,
} satisfies GeneratedProfile;
complete(stageId: CustomWorldGenerationStageId, phaseDetail?: string) {
const stage = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.find(
(item) => item.id === stageId,
);
if (!stage) {
return;
}
emit(stageId, {
completed: stage.total,
phaseDetail,
});
},
};
}
function buildCustomWorldProfilePrompt(params: {
settingText: string;
generationSeedText: string;
creatorIntent: CustomWorldCreatorIntent | null;
generationMode: CustomWorldGenerationMode;
}) {
const targets = getCustomWorldGenerationTargets(params.generationMode);
const creatorIntentText =
params.creatorIntent && hasMeaningfulCustomWorldCreatorIntent(params.creatorIntent)
? buildCustomWorldCreatorIntentGenerationText(params.creatorIntent)
: '';
return [
'请根据创作者输入,生成一个可直接进入游戏的自定义世界档案 JSON。',
'必须严格输出单个 JSON 对象,不要 Markdown不要解释。',
'',
`生成模式:${params.generationMode}`,
`可扮演角色数量:${targets.playableCount}`,
`场景角色数量:${targets.storyCount}`,
`关键场景数量:${targets.landmarkCount}`,
'',
'创作者输入:',
params.generationSeedText,
creatorIntentText ? `\n结构化创作锚点\n${creatorIntentText}` : '',
'',
'输出 JSON 字段要求:',
'- name, subtitle, summary, tone, playerGoal, templateWorldType',
'- majorFactions: string[]coreConflicts: string[]',
'- camp: { name, description, dangerLevel }',
'- playableNpcs: 每项包含 name,title,role,description,backstory,personality,motivation,combatStyle,initialAffinity,relationshipHooks,tags',
'- storyNpcs: 每项包含 name,title,role,description,backstory,personality,motivation,combatStyle,initialAffinity,relationshipHooks,tags',
'- landmarks: 每项包含 name,description,dangerLevel,sceneNpcNames,connections',
'- connections 每项包含 targetLandmarkName, relativePosition, summarytargetLandmarkName 必须指向本次输出的其他场景名',
'',
'约束:',
'- 所有世界观、角色、场景必须贴合创作者输入,不要套用通用武侠模板。',
'- 角色名字、势力名、场景名必须互相区分,避免重复。',
'- sceneNpcNames 只能引用 storyNpcs 中已经输出的 name。',
'- templateWorldType 只能是 WUXIA 或 XIANXIA。',
'- dangerLevel 使用 low、medium、high、extreme 之一。',
'- relativePosition 使用 forward、back、left、right、up、down、inside、outside 之一。',
'- 不要预生成物品档案items 如需输出,必须为空数组。',
].filter(Boolean).join('\n');
}
function buildCustomWorldProfileRepairPrompt(responseText: string) {
return [
'请修复下面的自定义世界 JSON。',
'只输出能被 JSON.parse 直接解析的单个 JSON 对象,不要解释。',
responseText,
].join('\n\n');
}
async function parseCustomWorldJsonStage(params: {
llmClient: UpstreamLlmClient;
responseText: string;
signal?: AbortSignal;
}) {
throwIfCustomWorldGenerationAborted(params.signal);
try {
return parseJsonResponseText(params.responseText);
} catch {
const sanitized = sanitizeJsonLikeText(params.responseText);
if (sanitized && sanitized !== params.responseText.trim()) {
try {
return parseJsonResponseText(sanitized);
} catch {
// Fall through to model-assisted repair.
}
}
const repairedText = await params.llmClient.requestMessageContent({
systemPrompt: CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT,
userPrompt: buildCustomWorldProfileRepairPrompt(params.responseText),
signal: params.signal,
timeoutMs: 90000,
debugLabel: 'custom-world-profile-json-repair',
});
throwIfCustomWorldGenerationAborted(params.signal);
return parseJsonResponseText(sanitizeJsonLikeText(repairedText) || repairedText);
}
}
async function requestCustomWorldProfileJson(params: {
llmClient: UpstreamLlmClient;
userPrompt: string;
signal?: AbortSignal;
}) {
const responseText = await params.llmClient.requestMessageContent({
systemPrompt: CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT,
userPrompt: params.userPrompt,
signal: params.signal,
timeoutMs: CUSTOM_WORLD_REQUEST_TIMEOUT_MS,
debugLabel: 'custom-world-profile',
});
if (!responseText.trim()) {
throw new Error('自定义世界生成失败:模型没有返回有效内容。');
}
return parseCustomWorldJsonStage({
llmClient: params.llmClient,
responseText,
signal: params.signal,
});
}
function attachRuntimeGenerationMetadata(params: {
profile: CustomWorldProfile;
settingText: string;
creatorIntent: CustomWorldCreatorIntent | null;
generationMode: CustomWorldGenerationMode;
}) {
const targets = getCustomWorldGenerationTargets(params.generationMode);
return {
...params.profile,
settingText: params.settingText || params.profile.settingText,
creatorIntent: params.creatorIntent,
anchorPack:
params.profile.anchorPack ??
buildCustomWorldAnchorPackFromIntent(params.creatorIntent),
lockState:
params.profile.lockState ??
deriveCustomWorldLockStateFromIntent(params.creatorIntent),
generationMode: params.generationMode,
generationStatus: targets.generationStatus,
items: [],
} satisfies CustomWorldProfile;
}
export async function generateCustomWorldProfileFromOrchestrator(
llmClient: UpstreamLlmClient,
input: GenerateCustomWorldProfileInput,
options: {
onProgress?: (progress: CustomWorldGenerationProgress) => void;
signal?: AbortSignal;
} = {},
) {
if (options.signal?.aborted) {
throw new Error('世界生成已中断。');
const {
settingText,
generationSeedText,
creatorIntent,
generationMode,
} = resolveCustomWorldGenerationInput(input);
const reporter = createCustomWorldGenerationReporter(options.onProgress);
try {
throwIfCustomWorldGenerationAborted(options.signal);
reporter.begin('prepare', '正在整理创作者输入与结构化锚点。');
const userPrompt = buildCustomWorldProfilePrompt({
settingText,
generationSeedText,
creatorIntent,
generationMode,
});
reporter.complete('prepare', '设定上下文已整理,开始请求大模型推理。');
reporter.begin('llm-profile', '正在请求模型生成世界档案、角色群像与场景网络。');
const rawProfile = await requestCustomWorldProfileJson({
llmClient,
userPrompt,
signal: options.signal,
});
reporter.complete('llm-profile', '模型已返回世界档案,开始系统归一与运行时编译。');
reporter.begin('normalize', '正在把模型 JSON 归一为可运行的自定义世界结构。');
const expandedProfile = buildExpandedCustomWorldProfile(
{
...(rawProfile as GeneratedProfile),
settingText,
creatorIntent,
generationMode,
generationStatus: getCustomWorldGenerationTargets(generationMode)
.generationStatus,
},
generationSeedText,
);
const profile = attachRuntimeGenerationMetadata({
profile: expandedProfile,
settingText,
creatorIntent,
generationMode,
});
reporter.complete('normalize', '模型结果已完成运行时结构编译。');
reporter.begin('finalize', '正在做最终完整性校验。');
if (generationMode === 'full') {
validateGeneratedCustomWorldProfile(profile);
}
reporter.complete('finalize', `世界“${profile.name}”已完成归档。`);
return profile as unknown as GeneratedProfile;
} catch (error) {
if (isCustomWorldGenerationAbortLikeError(error) || options.signal?.aborted) {
throw error instanceof Error
? error
: new CustomWorldGenerationAbortedError();
}
if (error instanceof SyntaxError) {
throw new Error(
'自定义世界生成失败:模型返回了非严格 JSON且自动修复仍未成功请稍后重试。',
);
}
throw error;
}
const startedAt = nowMs();
options.onProgress?.(
buildProgress(
'framework',
'世界框架',
'正在整理世界基础设定与主矛盾。',
0.2,
0,
startedAt,
),
);
options.onProgress?.(
buildProgress(
'roles',
'角色群像',
'正在生成可扮演角色与场景角色骨架。',
0.55,
1,
startedAt,
),
);
options.onProgress?.(
buildProgress(
'landmarks',
'场景网络',
'正在生成地标与场景连接关系。',
0.82,
2,
startedAt,
),
);
const profile = buildDeterministicProfile(input);
options.onProgress?.(
buildProgress(
'finalize',
'最终归档',
`世界“${String(profile.name)}”已完成归档。`,
1,
3,
startedAt,
),
);
return profile;
}

View File

@@ -5,12 +5,15 @@ import type {
CharacterChatSuggestionsRequest,
} from '../../../../packages/shared/src/contracts/story.js';
import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.js';
import { CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT } from './chatPromptBuilders.js';
import { SYSTEM_PROMPT } from './storyPromptBuilders.js';
import {
generateCharacterChatSuggestionsFromOrchestrator,
} from './chatOrchestrator.js';
import { CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT } from './chatPromptBuilders.js';
import {
generateCustomWorldProfileFromOrchestrator,
} from './customWorldOrchestrator.js';
import { generateInitialStoryFromOrchestrator } from './storyOrchestrator.js';
import { SYSTEM_PROMPT } from './storyPromptBuilders.js';
type TestStoryContext = Parameters<typeof generateInitialStoryFromOrchestrator>[4];
type TestStoryOption = Awaited<
@@ -191,3 +194,105 @@ test('chat orchestrator builds character suggestion prompts on the server side',
assert.match(capturedPrompts[0]?.userPrompt ?? '', //u);
assert.match(capturedPrompts[0]?.userPrompt ?? '', new RegExp(payload.targetCharacter.name, 'u'));
});
test('custom world orchestrator requests LLM content before compiling the profile', async () => {
const capturedPrompts: Array<{ systemPrompt: string; userPrompt: string }> = [];
const storyNpcNames = Array.from(
{ length: 8 },
(_, index) => `潮灯见证者${index + 1}`,
);
const llmClient = {
requestMessageContent: async ({
systemPrompt,
userPrompt,
}: {
systemPrompt: string;
userPrompt: string;
}) => {
capturedPrompts.push({ systemPrompt, userPrompt });
return JSON.stringify({
name: '潮灯列岛',
subtitle: '雾潮之下',
summary: '旧灯塔、潮雾与沉船盟约纠缠出的列岛冒险。',
tone: '潮湿、悬疑、克制',
playerGoal: '查明潮雾为何吞掉守灯人的名字',
templateWorldType: 'WUXIA',
majorFactions: ['守灯会', '沉船商盟', '潮雾祭司'],
coreConflicts: ['守灯会与沉船商盟争夺航道解释权'],
camp: {
name: '旧灯塔下层',
description: '潮水退去时才露出的临时据点。',
dangerLevel: 'low',
},
playableNpcs: Array.from({ length: 3 }, (_, index) => ({
name: `守灯旅人${index + 1}`,
title: `${index + 1}盏灯`,
role: '守灯同行者',
description: '在潮雾边缘辨认灯火与人声。',
backstory: '曾经守过一座被除名的灯塔。',
personality: '谨慎、沉静、记仇',
motivation: '找回被潮雾吞掉的名字。',
combatStyle: '短刃牵制后借灯火逼退敌人。',
initialAffinity: 18,
relationshipHooks: ['守灯', '旧名'],
tags: ['潮雾', '灯塔'],
})),
storyNpcs: storyNpcNames.map((name, index) => ({
name,
title: `${index + 1}位见证者`,
role: '潮雾见证者',
description: '知道一段被潮水洗掉的航线传闻。',
backstory: '在沉船夜里听见过不该出现的钟声。',
personality: '警觉、克制',
motivation: '确认下一次潮雾会带走谁。',
combatStyle: '先试探再撤入雾中。',
initialAffinity: 6,
relationshipHooks: ['沉船夜', '钟声'],
tags: ['潮雾', '线索'],
})),
landmarks: Array.from({ length: 4 }, (_, index) => ({
name: `潮灯地标${index + 1}`,
description: '潮雾会在这里折回,留下盐痕和旧灯影。',
dangerLevel: index === 0 ? 'medium' : 'high',
sceneNpcNames: storyNpcNames.slice(index, index + 3),
connections: [
{
targetLandmarkName: `潮灯地标${(index + 1) % 4 + 1}`,
relativePosition: 'forward',
summary: '沿潮痕继续前行即可抵达下一处灯影。',
},
],
})),
items: [],
});
},
} as const;
const progressEvents: Array<{ phaseId: string; overallProgress: number }> = [];
const profile = await generateCustomWorldProfileFromOrchestrator(
llmClient as never,
{
settingText: '一个被潮雾与失落列岛切碎的边境世界。',
generationMode: 'fast',
},
{
onProgress: (progress) => {
progressEvents.push({
phaseId: progress.phaseId,
overallProgress: progress.overallProgress,
});
},
},
);
assert.equal(capturedPrompts.length, 1);
assert.match(capturedPrompts[0]?.systemPrompt ?? '', /JSON /u);
assert.match(capturedPrompts[0]?.userPrompt ?? '', /fast/u);
assert.match(capturedPrompts[0]?.userPrompt ?? '', //u);
assert.equal(profile.name, '潮灯列岛');
assert.equal(profile.generationMode, 'fast');
assert.equal(profile.generationStatus, 'key_only');
assert.equal((profile.playableNpcs as unknown[]).length, 3);
assert.ok(progressEvents.some((event) => event.phaseId === 'llm-profile'));
assert.equal(progressEvents.at(-1)?.overallProgress, 100);
});