1050
server-node/src/services/customWorldEntityGenerationService.ts
Normal file
1050
server-node/src/services/customWorldEntityGenerationService.ts
Normal file
File diff suppressed because it is too large
Load Diff
586
server-node/src/services/customWorldSceneNpcGenerationService.ts
Normal file
586
server-node/src/services/customWorldSceneNpcGenerationService.ts
Normal file
@@ -0,0 +1,586 @@
|
||||
import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js';
|
||||
import { badRequest } from '../errors.js';
|
||||
import type { UpstreamLlmClient } from './llmClient.js';
|
||||
|
||||
type SceneNpcGenerationInput = {
|
||||
profile: Record<string, unknown>;
|
||||
landmarkId: string;
|
||||
};
|
||||
|
||||
type ParsedStoryNpc = {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
role: string;
|
||||
description: string;
|
||||
personality: string;
|
||||
motivation: string;
|
||||
relationshipHooks: string[];
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
type ParsedLandmark = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
dangerLevel: string;
|
||||
sceneNpcIds: string[];
|
||||
};
|
||||
|
||||
type ParsedProfile = {
|
||||
name: string;
|
||||
settingText: string;
|
||||
storyNpcs: ParsedStoryNpc[];
|
||||
landmarks: ParsedLandmark[];
|
||||
};
|
||||
|
||||
type GeneratedNpcDraft = {
|
||||
name: string;
|
||||
title: string;
|
||||
role: string;
|
||||
description: string;
|
||||
backstory: string;
|
||||
personality: string;
|
||||
motivation: string;
|
||||
combatStyle: string;
|
||||
initialAffinity: number;
|
||||
relationshipHooks: string[];
|
||||
tags: string[];
|
||||
publicSummary: string;
|
||||
chapterTeasers: string[];
|
||||
chapterContents: string[];
|
||||
skills: Array<{
|
||||
name: string;
|
||||
summary: string;
|
||||
style: string;
|
||||
}>;
|
||||
initialItems: Array<{
|
||||
name: string;
|
||||
category: string;
|
||||
quantity: number;
|
||||
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
|
||||
description: string;
|
||||
tags: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
function toRecord(value: unknown) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function toText(value: unknown, fallback = '') {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown, maxCount = 12) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.map((item) => toText(item))
|
||||
.filter(Boolean)
|
||||
.slice(0, maxCount);
|
||||
}
|
||||
|
||||
function clampText(value: string, maxLength: number) {
|
||||
const normalized = value.replace(/\s+/gu, ' ').trim();
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`;
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/gu, '-')
|
||||
.replace(/^-+|-+$/gu, '');
|
||||
|
||||
return normalized || 'entry';
|
||||
}
|
||||
|
||||
function createStableId(prefix: string, label: string, seed: string) {
|
||||
return `${prefix}-${slugify(label || prefix)}-${seed}`;
|
||||
}
|
||||
|
||||
function dedupeStrings(values: string[], maxCount = 8) {
|
||||
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].slice(
|
||||
0,
|
||||
maxCount,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeStoryNpc(value: unknown): ParsedStoryNpc | null {
|
||||
const record = toRecord(value);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = toText(record.id);
|
||||
const name = toText(record.name);
|
||||
if (!id || !name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = toText(record.title);
|
||||
const role = toText(record.role, title || '场景角色');
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
title: title || role || '场景角色',
|
||||
role,
|
||||
description: toText(record.description),
|
||||
personality: toText(record.personality),
|
||||
motivation: toText(record.motivation),
|
||||
relationshipHooks: toStringArray(record.relationshipHooks, 6),
|
||||
tags: toStringArray(record.tags, 8),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLandmark(value: unknown): ParsedLandmark | null {
|
||||
const record = toRecord(value);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = toText(record.id);
|
||||
const name = toText(record.name);
|
||||
if (!id || !name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: toText(record.description),
|
||||
dangerLevel: toText(record.dangerLevel, '中'),
|
||||
sceneNpcIds: toStringArray(record.sceneNpcIds, 12),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProfile(value: unknown): ParsedProfile {
|
||||
const record = toRecord(value);
|
||||
if (!record) {
|
||||
throw badRequest('profile is required');
|
||||
}
|
||||
|
||||
const storyNpcs = Array.isArray(record.storyNpcs)
|
||||
? record.storyNpcs.map(normalizeStoryNpc).filter((item): item is ParsedStoryNpc => item !== null)
|
||||
: [];
|
||||
const landmarks = Array.isArray(record.landmarks)
|
||||
? record.landmarks.map(normalizeLandmark).filter((item): item is ParsedLandmark => item !== null)
|
||||
: [];
|
||||
|
||||
return {
|
||||
name: toText(record.name, '自定义世界'),
|
||||
settingText: toText(record.settingText),
|
||||
storyNpcs,
|
||||
landmarks,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureUniqueName(name: string, existingNames: string[]) {
|
||||
const normalizedName = name.trim() || '新场景角色';
|
||||
if (!existingNames.includes(normalizedName)) {
|
||||
return normalizedName;
|
||||
}
|
||||
|
||||
let index = 2;
|
||||
let nextName = `${normalizedName}${index}`;
|
||||
while (existingNames.includes(nextName)) {
|
||||
index += 1;
|
||||
nextName = `${normalizedName}${index}`;
|
||||
}
|
||||
return nextName;
|
||||
}
|
||||
|
||||
function buildFallbackDraft(
|
||||
profile: ParsedProfile,
|
||||
landmark: ParsedLandmark,
|
||||
sceneNpcs: ParsedStoryNpc[],
|
||||
): GeneratedNpcDraft {
|
||||
const tags = dedupeStrings([
|
||||
landmark.name,
|
||||
landmark.dangerLevel,
|
||||
...sceneNpcs.flatMap((npc) => npc.tags),
|
||||
], 4);
|
||||
|
||||
return {
|
||||
name: `${landmark.name}来客`,
|
||||
title: `${landmark.name}的观察者`,
|
||||
role: `${landmark.name}的观察者`,
|
||||
description: `长期活动于${landmark.name},熟悉这里的局势与暗线,能为玩家提供新的观察角度。`,
|
||||
backstory: `他在${landmark.name}扎根已久,对这片区域的危险节奏、人物流动与隐藏冲突有自己的判断。`,
|
||||
personality: '谨慎、敏锐,先观察再表态。',
|
||||
motivation: `希望借玩家之手改变${landmark.name}当前逐渐失衡的局面。`,
|
||||
combatStyle: '偏向控场与试探,不轻易暴露底牌。',
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: dedupeStrings([
|
||||
`与${landmark.name}局势深度绑定`,
|
||||
sceneNpcs[0] ? `对${sceneNpcs[0].name}保持长期观察` : '对玩家保持试探',
|
||||
'愿意交换情报,但保留关键秘密',
|
||||
], 3),
|
||||
tags,
|
||||
publicSummary: `一名活跃于${landmark.name}的关键观察者。`,
|
||||
chapterTeasers: [
|
||||
'他知道这片区域最近正在发生什么。',
|
||||
'他与此地某个旧事件有直接牵连。',
|
||||
'他真正想推动的局面并不只是自保。',
|
||||
'他手里握有改变关系网的最后筹码。',
|
||||
],
|
||||
chapterContents: [
|
||||
`他常年在${landmark.name}周边活动,对人和事的变化极为敏感。`,
|
||||
`多年前的一次变故把他和${landmark.name}牢牢绑在了一起。`,
|
||||
`他表面克制,实际上一直在寻找扭转局面的机会。`,
|
||||
'他保留着一张只会在局势逼近临界点时才动用的底牌。',
|
||||
],
|
||||
skills: [
|
||||
{
|
||||
name: '试探起手',
|
||||
summary: '以低风险方式摸清对手意图。',
|
||||
style: '试探压制',
|
||||
},
|
||||
{
|
||||
name: '地形借势',
|
||||
summary: `借助${landmark.name}环境制造主动权。`,
|
||||
style: '环境协同',
|
||||
},
|
||||
{
|
||||
name: '暗线反制',
|
||||
summary: '在关键回合揭示隐藏准备,打乱对方节奏。',
|
||||
style: '后手翻盘',
|
||||
},
|
||||
],
|
||||
initialItems: [
|
||||
{
|
||||
name: '随身兵装',
|
||||
category: '武器',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '常备的近身防护装备。',
|
||||
tags: ['自定义', landmark.name],
|
||||
},
|
||||
{
|
||||
name: '区域通行物',
|
||||
category: '道具',
|
||||
quantity: 1,
|
||||
rarity: 'uncommon',
|
||||
description: `能在${landmark.name}一带快速周转的私人物件。`,
|
||||
tags: ['自定义'],
|
||||
},
|
||||
{
|
||||
name: '情报残页',
|
||||
category: '专属物品',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '记录着部分隐藏线索与往事片段。',
|
||||
tags: ['线索'],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildPrompt(
|
||||
profile: ParsedProfile,
|
||||
landmark: ParsedLandmark,
|
||||
sceneNpcs: ParsedStoryNpc[],
|
||||
otherNpcs: ParsedStoryNpc[],
|
||||
) {
|
||||
const sceneNpcSummary = sceneNpcs.length
|
||||
? sceneNpcs
|
||||
.map(
|
||||
(npc, index) =>
|
||||
`${index + 1}. ${npc.name} / ${npc.title || npc.role} / ${npc.description || '无描述'} / 性格:${npc.personality || '未写'} / 动机:${npc.motivation || '未写'}`,
|
||||
)
|
||||
.join('\n')
|
||||
: '当前场景还没有已加入 NPC。';
|
||||
|
||||
const reserveNpcSummary = otherNpcs.length
|
||||
? otherNpcs
|
||||
.slice(0, 8)
|
||||
.map(
|
||||
(npc, index) =>
|
||||
`${index + 1}. ${npc.name} / ${npc.title || npc.role} / ${npc.description || '无描述'}`,
|
||||
)
|
||||
.join('\n')
|
||||
: '暂无其他场景角色参考。';
|
||||
|
||||
const landmarkSummary = profile.landmarks
|
||||
.slice(0, 10)
|
||||
.map(
|
||||
(entry, index) =>
|
||||
`${index + 1}. ${entry.name} / 危险度:${entry.dangerLevel || '中'} / ${entry.description || '无描述'}`,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return [
|
||||
`世界名:${profile.name}`,
|
||||
`世界设定:${profile.settingText || '未提供额外设定文本。'}`,
|
||||
`当前目标场景:${landmark.name}`,
|
||||
`场景描述:${landmark.description || '未填写'}`,
|
||||
`危险度:${landmark.dangerLevel || '中'}`,
|
||||
`当前场景已加入 NPC:\n${sceneNpcSummary}`,
|
||||
`其他可参考 NPC:\n${reserveNpcSummary}`,
|
||||
`世界内其他场景概览:\n${landmarkSummary}`,
|
||||
'请生成 1 名适合加入当前场景的新 NPC。',
|
||||
'要求:',
|
||||
'- 必须与当前场景气质、危险度、已有 NPC 分工互补,不要和已有 NPC 重复。',
|
||||
'- 角色要像真正可落地到游戏里的场景角色,不要写成抽象设定。',
|
||||
'- 关系钩子、技能、初始物品都要可直接进入编辑器。',
|
||||
'- 返回 JSON,不要额外解释。',
|
||||
'JSON 结构:',
|
||||
'{',
|
||||
' "npc": {',
|
||||
' "name": "角色名",',
|
||||
' "title": "头衔",',
|
||||
' "role": "身份",',
|
||||
' "description": "一句到两句角色描述",',
|
||||
' "backstory": "背景",',
|
||||
' "personality": "性格",',
|
||||
' "motivation": "动机",',
|
||||
' "combatStyle": "战斗风格",',
|
||||
' "initialAffinity": 6,',
|
||||
' "relationshipHooks": ["关系钩子1", "关系钩子2", "关系钩子3"],',
|
||||
' "tags": ["标签1", "标签2", "标签3"],',
|
||||
' "publicSummary": "公开背景摘要",',
|
||||
' "chapterTeasers": ["表层来意", "旧事裂痕", "隐藏执念", "最终底牌"],',
|
||||
' "chapterContents": ["对应正文1", "对应正文2", "对应正文3", "对应正文4"],',
|
||||
' "skills": [',
|
||||
' { "name": "技能1", "summary": "说明", "style": "风格" },',
|
||||
' { "name": "技能2", "summary": "说明", "style": "风格" },',
|
||||
' { "name": "技能3", "summary": "说明", "style": "风格" }',
|
||||
' ],',
|
||||
' "initialItems": [',
|
||||
' { "name": "物品1", "category": "分类", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },',
|
||||
' { "name": "物品2", "category": "分类", "quantity": 1, "rarity": "uncommon", "description": "说明", "tags": ["标签"] },',
|
||||
' { "name": "物品3", "category": "分类", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] }',
|
||||
' ]',
|
||||
' }',
|
||||
'}',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function sanitizeGeneratedNpc(
|
||||
rawValue: unknown,
|
||||
profile: ParsedProfile,
|
||||
landmark: ParsedLandmark,
|
||||
fallbackDraft: GeneratedNpcDraft,
|
||||
) {
|
||||
const record = toRecord(rawValue);
|
||||
const existingNames = profile.storyNpcs.map((npc) => npc.name);
|
||||
const seed = Date.now().toString(36);
|
||||
const chapterTitles = ['表层来意', '旧事裂痕', '隐藏执念', '最终底牌'];
|
||||
const chapterThresholds = [6, 12, 18, 24];
|
||||
const relationshipHooks = dedupeStrings(
|
||||
toStringArray(record?.relationshipHooks, 6).concat(
|
||||
fallbackDraft.relationshipHooks,
|
||||
),
|
||||
4,
|
||||
);
|
||||
const tags = dedupeStrings(
|
||||
toStringArray(record?.tags, 8).concat(fallbackDraft.tags, landmark.name),
|
||||
6,
|
||||
);
|
||||
const chapterTeasers = toStringArray(record?.chapterTeasers, 4);
|
||||
const chapterContents = toStringArray(record?.chapterContents, 4);
|
||||
const skillRecords = Array.isArray(record?.skills) ? record?.skills : [];
|
||||
const itemRecords = Array.isArray(record?.initialItems) ? record?.initialItems : [];
|
||||
|
||||
const draft: GeneratedNpcDraft = {
|
||||
name: ensureUniqueName(
|
||||
toText(record?.name, fallbackDraft.name),
|
||||
existingNames,
|
||||
),
|
||||
title: toText(record?.title, fallbackDraft.title),
|
||||
role: toText(record?.role, toText(record?.title, fallbackDraft.role)),
|
||||
description: clampText(
|
||||
toText(record?.description, fallbackDraft.description),
|
||||
120,
|
||||
),
|
||||
backstory: clampText(toText(record?.backstory, fallbackDraft.backstory), 260),
|
||||
personality: clampText(
|
||||
toText(record?.personality, fallbackDraft.personality),
|
||||
100,
|
||||
),
|
||||
motivation: clampText(
|
||||
toText(record?.motivation, fallbackDraft.motivation),
|
||||
120,
|
||||
),
|
||||
combatStyle: clampText(
|
||||
toText(record?.combatStyle, fallbackDraft.combatStyle),
|
||||
100,
|
||||
),
|
||||
initialAffinity:
|
||||
typeof record?.initialAffinity === 'number' &&
|
||||
Number.isFinite(record.initialAffinity)
|
||||
? Math.min(12, Math.max(1, Math.round(record.initialAffinity)))
|
||||
: fallbackDraft.initialAffinity,
|
||||
relationshipHooks,
|
||||
tags,
|
||||
publicSummary: clampText(
|
||||
toText(record?.publicSummary, fallbackDraft.publicSummary),
|
||||
120,
|
||||
),
|
||||
chapterTeasers:
|
||||
chapterTeasers.length === 4
|
||||
? chapterTeasers
|
||||
: fallbackDraft.chapterTeasers.slice(0, 4),
|
||||
chapterContents:
|
||||
chapterContents.length === 4
|
||||
? chapterContents
|
||||
: fallbackDraft.chapterContents.slice(0, 4),
|
||||
skills:
|
||||
skillRecords.length >= 3
|
||||
? skillRecords.slice(0, 3).map((skill, index) => {
|
||||
const skillRecord = toRecord(skill);
|
||||
const fallbackSkill =
|
||||
fallbackDraft.skills[index] ?? fallbackDraft.skills[0];
|
||||
return {
|
||||
name: clampText(
|
||||
toText(skillRecord?.name, fallbackSkill?.name || `技能${index + 1}`),
|
||||
20,
|
||||
),
|
||||
summary: clampText(
|
||||
toText(skillRecord?.summary, fallbackSkill?.summary || ''),
|
||||
60,
|
||||
),
|
||||
style: clampText(
|
||||
toText(skillRecord?.style, fallbackSkill?.style || ''),
|
||||
20,
|
||||
),
|
||||
};
|
||||
})
|
||||
: fallbackDraft.skills,
|
||||
initialItems:
|
||||
itemRecords.length >= 3
|
||||
? itemRecords.slice(0, 3).map((item, index) => {
|
||||
const itemRecord = toRecord(item);
|
||||
const fallbackItem =
|
||||
fallbackDraft.initialItems[index] ?? fallbackDraft.initialItems[0];
|
||||
const rarity = toText(itemRecord?.rarity, fallbackItem?.rarity || 'rare');
|
||||
return {
|
||||
name: clampText(
|
||||
toText(itemRecord?.name, fallbackItem?.name || `物品${index + 1}`),
|
||||
20,
|
||||
),
|
||||
category: clampText(
|
||||
toText(itemRecord?.category, fallbackItem?.category || '道具'),
|
||||
16,
|
||||
),
|
||||
quantity:
|
||||
typeof itemRecord?.quantity === 'number' &&
|
||||
Number.isFinite(itemRecord.quantity)
|
||||
? Math.min(9, Math.max(1, Math.round(itemRecord.quantity)))
|
||||
: fallbackItem?.quantity || 1,
|
||||
rarity:
|
||||
rarity === 'common' ||
|
||||
rarity === 'uncommon' ||
|
||||
rarity === 'rare' ||
|
||||
rarity === 'epic' ||
|
||||
rarity === 'legendary'
|
||||
? rarity
|
||||
: fallbackItem?.rarity || 'rare',
|
||||
description: clampText(
|
||||
toText(itemRecord?.description, fallbackItem?.description || ''),
|
||||
80,
|
||||
),
|
||||
tags: dedupeStrings(
|
||||
toStringArray(itemRecord?.tags, 4).concat(
|
||||
fallbackItem?.tags ?? [],
|
||||
),
|
||||
4,
|
||||
),
|
||||
};
|
||||
})
|
||||
: fallbackDraft.initialItems,
|
||||
};
|
||||
|
||||
return {
|
||||
id: createStableId('story-npc', draft.name, seed),
|
||||
name: draft.name,
|
||||
title: draft.title || draft.role,
|
||||
role: draft.role || draft.title,
|
||||
description: draft.description,
|
||||
backstory: draft.backstory,
|
||||
personality: draft.personality,
|
||||
motivation: draft.motivation,
|
||||
combatStyle: draft.combatStyle,
|
||||
initialAffinity: draft.initialAffinity,
|
||||
relationshipHooks: draft.relationshipHooks,
|
||||
relations: [],
|
||||
tags: draft.tags,
|
||||
backstoryReveal: {
|
||||
publicSummary: draft.publicSummary,
|
||||
chapters: chapterTitles.map((title, index) => ({
|
||||
id: ['surface', 'scar', 'hidden', 'final'][index],
|
||||
title,
|
||||
affinityRequired: chapterThresholds[index],
|
||||
teaser:
|
||||
draft.chapterTeasers[index] ?? fallbackDraft.chapterTeasers[index] ?? '',
|
||||
content:
|
||||
draft.chapterContents[index] ??
|
||||
fallbackDraft.chapterContents[index] ??
|
||||
'',
|
||||
contextSnippet: '',
|
||||
})),
|
||||
},
|
||||
skills: draft.skills.map((skill, index) => ({
|
||||
id: createStableId('skill', `${draft.name}-${skill.name}`, `${seed}-${index + 1}`),
|
||||
name: skill.name,
|
||||
summary: skill.summary,
|
||||
style: skill.style,
|
||||
})),
|
||||
initialItems: draft.initialItems.map((item, index) => ({
|
||||
id: createStableId('item', `${draft.name}-${item.name}`, `${seed}-${index + 1}`),
|
||||
name: item.name,
|
||||
category: item.category,
|
||||
quantity: item.quantity,
|
||||
rarity: item.rarity,
|
||||
description: item.description,
|
||||
tags: item.tags,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateSceneNpcForLandmark(
|
||||
llmClient: UpstreamLlmClient,
|
||||
input: SceneNpcGenerationInput,
|
||||
) {
|
||||
const profile = normalizeProfile(input.profile);
|
||||
const landmark = profile.landmarks.find((entry) => entry.id === input.landmarkId);
|
||||
if (!landmark) {
|
||||
throw badRequest('landmark not found');
|
||||
}
|
||||
|
||||
const storyNpcById = new Map(profile.storyNpcs.map((npc) => [npc.id, npc]));
|
||||
const sceneNpcs = landmark.sceneNpcIds
|
||||
.map((npcId) => storyNpcById.get(npcId))
|
||||
.filter((npc): npc is ParsedStoryNpc => Boolean(npc));
|
||||
const otherNpcs = profile.storyNpcs.filter(
|
||||
(npc) => !landmark.sceneNpcIds.includes(npc.id),
|
||||
);
|
||||
const fallbackDraft = buildFallbackDraft(profile, landmark, sceneNpcs);
|
||||
|
||||
try {
|
||||
const content = await llmClient.requestMessageContent({
|
||||
systemPrompt:
|
||||
'你是游戏世界编辑器的角色生成器。你必须只返回可解析 JSON,不要输出解释、前言或 markdown 代码块之外的额外内容。',
|
||||
userPrompt: buildPrompt(profile, landmark, sceneNpcs, otherNpcs),
|
||||
debugLabel: 'custom-world-scene-npc',
|
||||
});
|
||||
const parsed = parseJsonResponseText(content);
|
||||
const parsedRecord = toRecord(parsed);
|
||||
const npcRecord = parsedRecord?.npc ?? parsed;
|
||||
return sanitizeGeneratedNpc(npcRecord, profile, landmark, fallbackDraft);
|
||||
} catch {
|
||||
return sanitizeGeneratedNpc(fallbackDraft, profile, landmark, fallbackDraft);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { AppContext } from '../context.js';
|
||||
import { type AppConfig } from '../config.js';
|
||||
import type { AppContext } from '../context.js';
|
||||
import { generateSceneImage } from './sceneImageService.js';
|
||||
|
||||
const PNG_BUFFER = Buffer.from(
|
||||
@@ -24,7 +24,7 @@ function createTestConfig(
|
||||
dashScope: {
|
||||
baseUrl: dashScopeBaseUrl,
|
||||
apiKey: 'test-dashscope-key',
|
||||
imageModel: 'wan2.7-image',
|
||||
imageModel: 'wan2.2-t2i-flash',
|
||||
requestTimeoutMs: 5_000,
|
||||
},
|
||||
} as AppConfig;
|
||||
@@ -92,11 +92,8 @@ async function withHttpServer<T>(
|
||||
}
|
||||
}
|
||||
|
||||
test('generateSceneImage uploads a public reference image as a data url and saves the generated scene', async () => {
|
||||
test('generateSceneImage uses wan2.2-t2i-flash text-to-image payload and saves the generated scene', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-scene-image-'));
|
||||
const publicDir = path.join(tempRoot, 'public');
|
||||
fs.mkdirSync(path.join(publicDir, 'scene_bg'), { recursive: true });
|
||||
fs.writeFileSync(path.join(publicDir, 'scene_bg', 'reference-layout.png'), PNG_BUFFER);
|
||||
|
||||
const capturedRequests: Array<{
|
||||
pathname: string;
|
||||
@@ -164,7 +161,6 @@ test('generateSceneImage uploads a public reference image as a data url and save
|
||||
profileId: 'world-1',
|
||||
landmarkName: '旧港灯塔',
|
||||
landmarkId: 'landmark-1',
|
||||
referenceImageSrc: '/scene_bg/reference-layout.png',
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
@@ -177,6 +173,7 @@ test('generateSceneImage uploads a public reference image as a data url and save
|
||||
assert.ok(createRequest?.bodyText);
|
||||
|
||||
const createPayload = JSON.parse(createRequest.bodyText) as {
|
||||
model: string;
|
||||
input: {
|
||||
messages: Array<{
|
||||
content: Array<{ text?: string; image?: string }>;
|
||||
@@ -188,8 +185,9 @@ test('generateSceneImage uploads a public reference image as a data url and save
|
||||
};
|
||||
|
||||
const content = createPayload.input.messages[0]?.content ?? [];
|
||||
assert.equal(createPayload.model, 'wan2.2-t2i-flash');
|
||||
assert.equal(content[0]?.text, '海雾港口像素风场景');
|
||||
assert.match(content[1]?.image ?? '', /^data:image\/png;base64,/u);
|
||||
assert.equal(content.length, 1);
|
||||
assert.equal(createPayload.parameters.negative_prompt, '模糊');
|
||||
|
||||
const savedImagePath = path.join(tempRoot, 'public', result.imageSrc.slice(1));
|
||||
@@ -197,3 +195,105 @@ test('generateSceneImage uploads a public reference image as a data url and save
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('generateSceneImage uses qwen-image-2.0 edit flow when a reference image is provided', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-scene-image-'));
|
||||
const publicDir = path.join(tempRoot, 'public');
|
||||
fs.mkdirSync(path.join(publicDir, 'scene_bg'), { recursive: true });
|
||||
fs.writeFileSync(path.join(publicDir, 'scene_bg', 'reference-layout.png'), PNG_BUFFER);
|
||||
|
||||
const capturedRequests: Array<{
|
||||
pathname: string;
|
||||
bodyText?: string;
|
||||
}> = [];
|
||||
|
||||
await withHttpServer(
|
||||
(baseUrl) => async (req, res) => {
|
||||
const url = new URL(req.url || '/', baseUrl);
|
||||
const bodyText =
|
||||
req.method === 'POST' ? (await readRequestBody(req)).toString('utf8') : undefined;
|
||||
capturedRequests.push({
|
||||
pathname: url.pathname,
|
||||
bodyText,
|
||||
});
|
||||
|
||||
if (
|
||||
req.method === 'POST' &&
|
||||
url.pathname === '/api/v1/services/aigc/multimodal-generation/generation'
|
||||
) {
|
||||
sendJson(res, {
|
||||
output: {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
image: `${baseUrl}/downloads/reference-scene.png`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/downloads/reference-scene.png') {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.end(PNG_BUFFER);
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 404;
|
||||
res.end('not found');
|
||||
},
|
||||
async (dashScopeBaseUrl) => {
|
||||
const context = {
|
||||
config: createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`),
|
||||
} as AppContext;
|
||||
|
||||
const result = await generateSceneImage(context, {
|
||||
prompt: '废墟月台像素风场景',
|
||||
negativePrompt: '模糊',
|
||||
size: '1280*720',
|
||||
worldName: '碎轨边境',
|
||||
profileId: 'world-2',
|
||||
landmarkName: '裂轨月台',
|
||||
landmarkId: 'landmark-2',
|
||||
referenceImageSrc: '/scene_bg/reference-layout.png',
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.model, 'qwen-image-2.0');
|
||||
assert.match(result.taskId, /^scene-edit-/u);
|
||||
assert.equal(
|
||||
capturedRequests.some(
|
||||
(entry) => entry.pathname === '/api/v1/tasks/scene-task-1',
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
const createRequest = capturedRequests.find(
|
||||
(entry) =>
|
||||
entry.pathname === '/api/v1/services/aigc/multimodal-generation/generation',
|
||||
);
|
||||
assert.ok(createRequest?.bodyText);
|
||||
|
||||
const createPayload = JSON.parse(createRequest.bodyText) as {
|
||||
model: string;
|
||||
input: {
|
||||
messages: Array<{
|
||||
content: Array<{ text?: string; image?: string }>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
const content = createPayload.input.messages[0]?.content ?? [];
|
||||
assert.equal(createPayload.model, 'qwen-image-2.0');
|
||||
assert.match(content[0]?.image ?? '', /^data:image\/png;base64,/u);
|
||||
assert.equal(content[1]?.text, '废墟月台像素风场景');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -19,6 +19,8 @@ export const sceneImageSchema = z.object({
|
||||
landmarkId: z.string().trim().optional().default(''),
|
||||
referenceImageSrc: z.string().trim().optional().default(''),
|
||||
});
|
||||
const TEXT_TO_IMAGE_SCENE_MODEL = 'wan2.2-t2i-flash';
|
||||
const REFERENCE_IMAGE_SCENE_MODEL = 'qwen-image-2.0';
|
||||
|
||||
function parseImageDataUrl(source: string) {
|
||||
const matched = /^data:(image\/[^;]+);base64,(.+)$/u.exec(source);
|
||||
@@ -122,40 +124,72 @@ function extractImageUrls(payload: Record<string, unknown>) {
|
||||
return [...new Set(urls)];
|
||||
}
|
||||
|
||||
function ensurePayload(
|
||||
payload: z.infer<typeof sceneImageSchema>,
|
||||
defaultModel: string,
|
||||
) {
|
||||
if (!payload.landmarkName && !payload.landmarkId) {
|
||||
throw badRequest('landmarkName 或 landmarkId 至少要提供一个');
|
||||
async function createSceneImageTask(params: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
payload: z.infer<typeof sceneImageSchema>;
|
||||
}) {
|
||||
const { baseUrl, apiKey, payload } = params;
|
||||
const response = await fetch(`${baseUrl}/services/aigc/image-generation/generation`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-DashScope-Async': 'enable',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: payload.model,
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ text: payload.prompt }],
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
n: 1,
|
||||
size: payload.size,
|
||||
prompt_extend: true,
|
||||
watermark: false,
|
||||
...(payload.negativePrompt
|
||||
? { negative_prompt: payload.negativePrompt }
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false as const,
|
||||
errorMessage: extractApiErrorMessage(
|
||||
responseText,
|
||||
'创建场景图片生成任务失败',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...payload,
|
||||
model: payload.model || defaultModel,
|
||||
ok: true as const,
|
||||
payload: JSON.parse(responseText) as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateSceneImage(
|
||||
context: AppContext,
|
||||
input: z.infer<typeof sceneImageSchema>,
|
||||
) {
|
||||
const payload = ensurePayload(input, context.config.dashScope.imageModel);
|
||||
const baseUrl = context.config.dashScope.baseUrl.replace(/\/+$/u, '');
|
||||
const referenceImage = payload.referenceImageSrc
|
||||
? await resolveReferenceImageAsDataUrl(
|
||||
context.config.projectRoot,
|
||||
payload.referenceImageSrc,
|
||||
)
|
||||
: '';
|
||||
const createResponse = await fetch(
|
||||
`${baseUrl}/services/aigc/image-generation/generation`,
|
||||
async function createSceneImageFromReference(params: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
payload: z.infer<typeof sceneImageSchema>;
|
||||
referenceImage: string;
|
||||
}) {
|
||||
const { baseUrl, apiKey, payload, referenceImage } = params;
|
||||
const response = await fetch(
|
||||
`${baseUrl}/services/aigc/multimodal-generation/generation`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${context.config.dashScope.apiKey}`,
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-DashScope-Async': 'enable',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: payload.model,
|
||||
@@ -163,10 +197,7 @@ export async function generateSceneImage(
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ text: payload.prompt },
|
||||
...(referenceImage ? [{ image: referenceImage }] : []),
|
||||
],
|
||||
content: [{ image: referenceImage }, { text: payload.prompt }],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -182,56 +213,65 @@ export async function generateSceneImage(
|
||||
}),
|
||||
},
|
||||
);
|
||||
const createText = await createResponse.text();
|
||||
if (!createResponse.ok) {
|
||||
throw badRequest(
|
||||
extractApiErrorMessage(createText, '创建场景图片生成任务失败'),
|
||||
);
|
||||
}
|
||||
|
||||
const createPayload = JSON.parse(createText) as Record<string, unknown>;
|
||||
const taskId = extractTaskId(createPayload);
|
||||
if (!taskId) {
|
||||
throw badRequest('场景图片生成任务未返回 task_id');
|
||||
}
|
||||
|
||||
const deadline = Date.now() + context.config.dashScope.requestTimeoutMs;
|
||||
let imageUrl = '';
|
||||
let actualPrompt = '';
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const pollResponse = await fetch(`${baseUrl}/tasks/${taskId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${context.config.dashScope.apiKey}`,
|
||||
},
|
||||
});
|
||||
const pollText = await pollResponse.text();
|
||||
if (!pollResponse.ok) {
|
||||
throw badRequest(
|
||||
extractApiErrorMessage(pollText, '查询场景图片任务失败'),
|
||||
);
|
||||
}
|
||||
|
||||
const pollPayload = JSON.parse(pollText) as Record<string, unknown>;
|
||||
const status = findFirstStringByKey(pollPayload, 'task_status').trim();
|
||||
if (status === 'SUCCEEDED') {
|
||||
imageUrl = extractImageUrls(pollPayload)[0] ?? '';
|
||||
actualPrompt = findFirstStringByKey(pollPayload, 'actual_prompt').trim();
|
||||
break;
|
||||
}
|
||||
if (status === 'FAILED' || status === 'UNKNOWN') {
|
||||
throw badRequest(
|
||||
extractApiErrorMessage(pollText, '场景图片生成任务失败'),
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false as const,
|
||||
errorMessage: extractApiErrorMessage(
|
||||
responseText,
|
||||
'创建参考图场景编辑任务失败',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const responsePayload = JSON.parse(responseText) as Record<string, unknown>;
|
||||
const imageUrl = extractImageUrls(responsePayload)[0] ?? '';
|
||||
if (!imageUrl) {
|
||||
throw badRequest('场景图片生成超时或未返回图片地址');
|
||||
return {
|
||||
ok: false as const,
|
||||
errorMessage: '参考图场景编辑未返回图片地址',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
imageUrl,
|
||||
actualPrompt: findFirstStringByKey(responsePayload, 'actual_prompt').trim(),
|
||||
taskId: `scene-edit-${Date.now()}`,
|
||||
};
|
||||
}
|
||||
|
||||
function ensurePayload(
|
||||
payload: z.infer<typeof sceneImageSchema>,
|
||||
_defaultModel: string,
|
||||
) {
|
||||
if (!payload.landmarkName && !payload.landmarkId) {
|
||||
throw badRequest('landmarkName 或 landmarkId 至少要提供一个');
|
||||
}
|
||||
|
||||
const referenceImageSrc =
|
||||
typeof payload.referenceImageSrc === 'string'
|
||||
? payload.referenceImageSrc.trim()
|
||||
: '';
|
||||
|
||||
return {
|
||||
...payload,
|
||||
referenceImageSrc,
|
||||
model: referenceImageSrc
|
||||
? REFERENCE_IMAGE_SCENE_MODEL
|
||||
: TEXT_TO_IMAGE_SCENE_MODEL,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveSceneImageAsset(params: {
|
||||
context: AppContext;
|
||||
payload: z.infer<typeof sceneImageSchema>;
|
||||
imageUrl: string;
|
||||
taskId: string;
|
||||
actualPrompt: string;
|
||||
}) {
|
||||
const { context, payload, imageUrl, taskId, actualPrompt } = params;
|
||||
const imageResponse = await fetch(imageUrl);
|
||||
if (!imageResponse.ok) {
|
||||
throw badRequest('下载生成图片失败');
|
||||
@@ -295,3 +335,99 @@ export async function generateSceneImage(
|
||||
actualPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateSceneImage(
|
||||
context: AppContext,
|
||||
input: z.infer<typeof sceneImageSchema>,
|
||||
) {
|
||||
const payload = ensurePayload(input, context.config.dashScope.imageModel);
|
||||
const baseUrl = context.config.dashScope.baseUrl.replace(/\/+$/u, '');
|
||||
const referenceImage = payload.referenceImageSrc.trim()
|
||||
? await resolveReferenceImageAsDataUrl(
|
||||
context.config.projectRoot,
|
||||
payload.referenceImageSrc,
|
||||
)
|
||||
: '';
|
||||
|
||||
if (referenceImage) {
|
||||
const referenceResult = await createSceneImageFromReference({
|
||||
baseUrl,
|
||||
apiKey: context.config.dashScope.apiKey,
|
||||
payload,
|
||||
referenceImage,
|
||||
});
|
||||
|
||||
if (!referenceResult.ok) {
|
||||
throw badRequest(referenceResult.errorMessage);
|
||||
}
|
||||
|
||||
return saveSceneImageAsset({
|
||||
context,
|
||||
payload,
|
||||
imageUrl: referenceResult.imageUrl,
|
||||
taskId: referenceResult.taskId,
|
||||
actualPrompt: referenceResult.actualPrompt,
|
||||
});
|
||||
}
|
||||
|
||||
const createTaskResult = await createSceneImageTask({
|
||||
baseUrl,
|
||||
apiKey: context.config.dashScope.apiKey,
|
||||
payload,
|
||||
});
|
||||
|
||||
if (!createTaskResult.ok) {
|
||||
throw badRequest(createTaskResult.errorMessage);
|
||||
}
|
||||
|
||||
const createPayload = createTaskResult.payload;
|
||||
const taskId = extractTaskId(createPayload);
|
||||
if (!taskId) {
|
||||
throw badRequest('场景图片生成任务未返回 task_id');
|
||||
}
|
||||
|
||||
const deadline = Date.now() + context.config.dashScope.requestTimeoutMs;
|
||||
let imageUrl = '';
|
||||
let actualPrompt = '';
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const pollResponse = await fetch(`${baseUrl}/tasks/${taskId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${context.config.dashScope.apiKey}`,
|
||||
},
|
||||
});
|
||||
const pollText = await pollResponse.text();
|
||||
if (!pollResponse.ok) {
|
||||
throw badRequest(
|
||||
extractApiErrorMessage(pollText, '查询场景图片任务失败'),
|
||||
);
|
||||
}
|
||||
|
||||
const pollPayload = JSON.parse(pollText) as Record<string, unknown>;
|
||||
const status = findFirstStringByKey(pollPayload, 'task_status').trim();
|
||||
if (status === 'SUCCEEDED') {
|
||||
imageUrl = extractImageUrls(pollPayload)[0] ?? '';
|
||||
actualPrompt = findFirstStringByKey(pollPayload, 'actual_prompt').trim();
|
||||
break;
|
||||
}
|
||||
if (status === 'FAILED' || status === 'UNKNOWN') {
|
||||
throw badRequest(
|
||||
extractApiErrorMessage(pollText, '场景图片生成任务失败'),
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
if (!imageUrl) {
|
||||
throw badRequest('场景图片生成超时或未返回图片地址');
|
||||
}
|
||||
|
||||
return saveSceneImageAsset({
|
||||
context,
|
||||
payload,
|
||||
imageUrl,
|
||||
taskId,
|
||||
actualPrompt,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user