Persist custom world asset configs in runtime snapshots

This commit is contained in:
2026-04-18 17:00:46 +08:00
parent 7ce61e9879
commit ac801fe05f
29 changed files with 3397 additions and 400 deletions

View File

@@ -3131,6 +3131,196 @@ test('runtime snapshot persistence accepts null currentStory payloads', async ()
});
});
test('runtime snapshot persistence syncs custom world asset configs into snapshot and profile storage', async () => {
await withTestServer(
'persistence-custom-world-assets',
async ({ baseUrl, context }) => {
const entry = await authEntry(
baseUrl,
'playercustomworldassets',
'secret123',
);
const saveResponse = await httpRequest(
`${baseUrl}/api/runtime/save/snapshot`,
withBearer(entry.token, {
method: 'PUT',
body: JSON.stringify({
gameState: {
currentScene: 'Story',
worldType: 'CUSTOM',
playerCharacter: {
id: 'playable-asset-role',
portrait:
'/generated-characters/playable-asset-role/visual/visual-1/master.png',
generatedVisualAssetId: 'visual-1',
generatedAnimationSetId: 'animation-set-1',
animationMap: {
idle: {
folder: 'idle',
prefix: 'Idle',
frames: 4,
basePath:
'/generated-animations/playable-asset-role/animation-set-1/idle',
},
},
},
currentScenePreset: {
id: 'custom-scene-landmark-1',
name: '潮声断桥',
description: '旧桥横在潮雾之上。',
imageSrc:
'/generated-custom-world-scenes/cw-profile-asset/landmark-1/scene.png',
},
customWorldProfile: {
id: 'cw-profile-asset',
name: '潮雾裂港',
subtitle: '退潮时响起旧讯号',
summary: '雾与潮共同切开港湾边境。',
tone: '冷潮压城,旧案未散',
playerGoal: '追出失落讯标的去向',
settingText: '一座被潮雾与旧讯号撕开的港湾世界。',
templateWorldType: 'WUXIA',
compatibilityTemplateWorldType: 'WUXIA',
majorFactions: ['潮关守备'],
coreConflicts: ['讯标争夺'],
playableNpcs: [
{
id: 'playable-asset-role',
name: '沈潮',
title: '归港行者',
role: '可扮演角色',
description: '总盯着退潮后的暗线。',
backstory: '他从失讯后的航路里活着回来。',
personality: '谨慎克制',
motivation: '找回失落讯标',
combatStyle: '借潮势游走压制',
initialAffinity: 18,
relationshipHooks: ['识得旧港规矩'],
tags: ['潮港', '追迹'],
backstoryReveal: {
publicSummary: '他像一直在等潮声回信。',
chapters: [],
},
skills: [],
initialItems: [],
},
],
storyNpcs: [],
items: [],
camp: {
name: '归潮居',
description: '退潮后还能落脚的旧屋。',
dangerLevel: 'low',
},
landmarks: [
{
id: 'landmark-1',
name: '潮声断桥',
description: '旧桥横在潮雾之上。',
dangerLevel: 'medium',
sceneNpcIds: [],
connections: [],
},
],
attributeSchema: {
slots: [],
},
},
},
bottomTab: 'adventure',
currentStory: {
text: '潮声还在桥下回荡。',
options: [],
},
}),
}),
);
const savePayload = (await saveResponse.json()) as {
gameState: {
customWorldProfile: {
playableNpcs: Array<{
imageSrc?: string;
generatedVisualAssetId?: string;
generatedAnimationSetId?: string;
animationMap?: Record<string, { basePath?: string }>;
}>;
landmarks: Array<{
imageSrc?: string;
}>;
} | null;
};
};
assert.equal(saveResponse.status, 200);
assert.equal(
savePayload.gameState.customWorldProfile?.playableNpcs[0]?.imageSrc,
'/generated-characters/playable-asset-role/visual/visual-1/master.png',
);
assert.equal(
savePayload.gameState.customWorldProfile?.playableNpcs[0]
?.generatedVisualAssetId,
'visual-1',
);
assert.equal(
savePayload.gameState.customWorldProfile?.playableNpcs[0]
?.generatedAnimationSetId,
'animation-set-1',
);
assert.equal(
savePayload.gameState.customWorldProfile?.playableNpcs[0]?.animationMap
?.idle?.basePath,
'/generated-animations/playable-asset-role/animation-set-1/idle',
);
assert.equal(
savePayload.gameState.customWorldProfile?.landmarks[0]?.imageSrc,
'/generated-custom-world-scenes/cw-profile-asset/landmark-1/scene.png',
);
const persistedRows = await context.db.query<{
payload: {
playableNpcs?: Array<{
imageSrc?: string;
generatedVisualAssetId?: string;
generatedAnimationSetId?: string;
animationMap?: Record<string, { basePath?: string }>;
}>;
landmarks?: Array<{
imageSrc?: string;
}>;
};
}>(
`SELECT payload_json AS payload
FROM custom_world_profiles
WHERE user_id = $1
AND profile_id = $2`,
[entry.user.id, 'cw-profile-asset'],
);
assert.equal(persistedRows.rows.length, 1);
assert.equal(
persistedRows.rows[0]?.payload?.playableNpcs?.[0]?.imageSrc,
'/generated-characters/playable-asset-role/visual/visual-1/master.png',
);
assert.equal(
persistedRows.rows[0]?.payload?.playableNpcs?.[0]
?.generatedAnimationSetId,
'animation-set-1',
);
assert.equal(
persistedRows.rows[0]?.payload?.playableNpcs?.[0]?.animationMap?.idle
?.basePath,
'/generated-animations/playable-asset-role/animation-set-1/idle',
);
assert.equal(
persistedRows.rows[0]?.payload?.landmarks?.[0]?.imageSrc,
'/generated-custom-world-scenes/cw-profile-asset/landmark-1/scene.png',
);
},
);
});
test('runtime snapshot persistence returns hydrated snapshots for frontend restore flows', async () => {
await withTestServer('persistence-hydrated-snapshot', async ({ baseUrl }) => {
const entry = await authEntry(

View File

@@ -238,7 +238,9 @@ test('character visual generation converts public reference images into data url
assert.match(content[0]?.text ?? '', //u);
assert.match(content[0]?.text ?? '', /绿绿/u);
assert.match(content[0]?.text ?? '', /1:1 |1:1 /u);
assert.match(content[0]?.text ?? '', /2 3 /u);
assert.match(content[0]?.text ?? '', /2 2\.5 |2 3 /u);
assert.match(content[0]?.text ?? '', //u);
assert.match(content[0]?.text ?? '', //u);
assert.match(content[0]?.text ?? '', //u);
assert.doesNotMatch(content[0]?.text ?? '', //u);
assert.match(content[1]?.image ?? '', /^data:image\/png;base64,/u);
@@ -287,7 +289,9 @@ test('character prompt bundle generation falls back to local defaults when llm c
assert.match(payload.visualPromptText, //u);
assert.match(payload.visualPromptText, //u);
assert.match(payload.visualPromptText, /绿绿/u);
assert.match(payload.visualPromptText, /2 3 /u);
assert.match(payload.visualPromptText, /2 2\.5 /u);
assert.match(payload.visualPromptText, //u);
assert.match(payload.visualPromptText, //u);
assert.match(payload.animationPromptText, //u);
assert.match(payload.scenePromptText, //u);
});
@@ -431,6 +435,104 @@ test('character workflow cache skips rewriting unchanged payloads', async () =>
);
});
test('character workflow cache stays isolated for different character ids', async () => {
const tempRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'genarrative-character-workflow-cache-isolated-'),
);
await withAssetRouteServer(
createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'),
async (assetBaseUrl) => {
const firstPayload = {
characterId: '巡海夜灯',
visualPromptText: '夜灯守望者',
animationPromptText: '短刀前压,动作克制',
visualDrafts: [
{
id: 'draft-1',
label: '候选 1',
imageSrc: '/generated-character-drafts/sea-lantern/draft-1.png',
width: 1024,
height: 1024,
},
],
selectedVisualDraftId: 'draft-1',
selectedAnimation: 'idle',
};
const secondPayload = {
characterId: '雾港引路人',
visualPromptText: '雾港引路者',
animationPromptText: '提灯侧身,站姿稳定',
visualDrafts: [
{
id: 'draft-2',
label: '候选 2',
imageSrc: '/generated-character-drafts/fog-guide/draft-2.png',
width: 1024,
height: 1024,
},
],
selectedVisualDraftId: 'draft-2',
selectedAnimation: 'run',
};
const firstSaveResponse = await fetch(
`${assetBaseUrl}/api/assets/character-workflow-cache`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(firstPayload),
},
);
assert.equal(firstSaveResponse.status, 200);
const secondSaveResponse = await fetch(
`${assetBaseUrl}/api/assets/character-workflow-cache`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(secondPayload),
},
);
assert.equal(secondSaveResponse.status, 200);
const firstReadResponse = await fetch(
`${assetBaseUrl}/api/assets/character-workflow-cache/${encodeURIComponent(firstPayload.characterId)}`,
);
assert.equal(firstReadResponse.status, 200);
const firstReadPayload = (await firstReadResponse.json()) as {
cache: {
characterId: string;
visualPromptText: string;
} | null;
};
const secondReadResponse = await fetch(
`${assetBaseUrl}/api/assets/character-workflow-cache/${encodeURIComponent(secondPayload.characterId)}`,
);
assert.equal(secondReadResponse.status, 200);
const secondReadPayload = (await secondReadResponse.json()) as {
cache: {
characterId: string;
visualPromptText: string;
} | null;
};
assert.equal(firstReadPayload.cache?.characterId, firstPayload.characterId);
assert.equal(firstReadPayload.cache?.visualPromptText, firstPayload.visualPromptText);
assert.equal(secondReadPayload.cache?.characterId, secondPayload.characterId);
assert.equal(
secondReadPayload.cache?.visualPromptText,
secondPayload.visualPromptText,
);
},
);
});
test('character animation publish returns frame dimensions in animation map', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-animation-publish-'));
@@ -755,6 +857,103 @@ test('character animation non-loop image-to-video uses first and last master fra
);
});
test('character animation die image-to-video does not send a last frame reference', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-kf2v-die-'));
const publicDir = path.join(tempRoot, 'public');
fs.mkdirSync(publicDir, { recursive: true });
fs.writeFileSync(path.join(publicDir, 'visual.png'), PNG_BUFFER);
let videoSynthesisPayloadText = '';
await withHttpServer(
(dashScopeBaseUrl) => async (req, res) => {
const url = new URL(req.url || '/', dashScopeBaseUrl);
if (
req.method === 'POST' &&
url.pathname === '/api/v1/services/aigc/image2video/video-synthesis'
) {
videoSynthesisPayloadText = (await readRequestBody(req)).toString('utf8');
sendJson(res, {
output: {
task_id: 'video-task-kf2v-die-1',
},
});
return;
}
if (req.method === 'GET' && url.pathname === '/api/v1/tasks/video-task-kf2v-die-1') {
sendJson(res, {
output: {
task_status: 'SUCCEEDED',
video_url: `${dashScopeBaseUrl}/downloads/preview.mp4`,
},
});
return;
}
if (req.method === 'GET' && url.pathname === '/downloads/preview.mp4') {
res.statusCode = 200;
res.setHeader('Content-Type', 'video/mp4');
res.end(MP4_BUFFER);
return;
}
res.statusCode = 404;
res.end('not found');
},
async (dashScopeBaseUrl) => {
const config = createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`);
await withAssetRouteServer(config, async (assetBaseUrl) => {
const response = await fetch(
`${assetBaseUrl}/api/assets/character-animation/generate`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
characterId: 'harbor-guide',
strategy: 'image-to-video',
animation: 'die',
promptText: '倒地后停在终止姿态',
characterBriefText: '旧港守望者',
visualSource: '/visual.png',
referenceImageDataUrls: [],
referenceVideoDataUrls: [],
frameCount: 8,
fps: 8,
durationSeconds: 4,
loop: false,
useChromaKey: true,
resolution: '720P',
imageSequenceModel: 'wan2.7-image-pro',
videoModel: 'wan2.7-i2v',
referenceVideoModel: 'wan2.7-r2v',
motionTransferModel: 'wan2.2-animate-move',
}),
},
);
assert.equal(response.status, 200);
const videoPayload = JSON.parse(videoSynthesisPayloadText) as {
model: string;
input: {
first_frame_url?: string;
last_frame_url?: string;
prompt?: string;
};
};
assert.equal(videoPayload.model, 'wan2.2-kf2v-flash');
assert.match(videoPayload.input.first_frame_url ?? '', /^data:image\/png;base64,/u);
assert.equal(videoPayload.input.last_frame_url, undefined);
assert.match(videoPayload.input.prompt ?? '', /姿/u);
});
},
);
});
test('character animation loop image-to-video uses wan2.6-i2v-flash with img_url only', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-i2v-loop-'));
const publicDir = path.join(tempRoot, 'public');

View File

@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import http, {
type IncomingMessage,
@@ -349,7 +350,7 @@ function buildFallbackCharacterPromptBundle(params: {
return {
visualPromptText: [
`${characterAnchor}${roleAnchor}`,
'单人全身2D 横版 RPG 角色标准设定图1:1 正方形画幅,头身比控制在 2 到 3 头身,右向斜侧身站立,身体整体朝右但保留少量正面信息,脚底完整可见,服装、发型、武器和轮廓稳定清楚。',
'单人全身2D 横版 RPG 角色标准设定图1:1 正方形画幅,头身比控制在 2 到 2.5 头身,偏大头身,靠头部、发型、服装、配饰表现角色记忆点,躯干与四肢短而紧凑,五官简化,深色粗轮廓配合清晰大色块,右向斜侧身站立,身体整体朝右但保留少量正面信息,服装、发型、轮廓稳定清楚。',
`外观气质围绕:${descriptionAnchor}`,
combatAnchor ? `战斗识别点:${combatAnchor}` : '',
tagAnchor,
@@ -359,7 +360,7 @@ function buildFallbackCharacterPromptBundle(params: {
.join(' '),
animationPromptText: [
`${characterAnchor}的核心动作试片。`,
'保持同一角色的服装、发型、武器和体型一致,镜头稳定,侧身朝右,动作连贯。',
'保持同一角色的服装、发型、体型一致,镜头稳定,侧身朝右,动作连贯。',
combatAnchor ? `动作气质参考:${combatAnchor}` : '',
params.personality ? `角色气质补充:${params.personality}` : '',
'发力起手明确,过程干净,收招利落,避免漂移和变形。',
@@ -560,11 +561,17 @@ function getJobRecordPath(
}
function getCharacterWorkflowCachePath(rootDir: string, characterId: string) {
const readableSegment = sanitizePathSegment(characterId);
const characterCacheKey = createHash('sha256')
.update(characterId, 'utf8')
.digest('hex')
.slice(0, 24);
return path.resolve(
rootDir,
'public',
'generated-character-drafts',
sanitizePathSegment(characterId),
`${readableSegment}-${characterCacheKey}`,
'workflow-cache.json',
);
}
@@ -1163,7 +1170,9 @@ function buildNpcAnimationPrompt(options: {
const actionDetailText = sanitizeAnimationPromptText(options.promptText, 140);
const loopRule = options.loop
? '这是循环动作,直接进入动作循环中段,不要开场静止站桩,不要把主参考图原样作为第一帧。'
: '这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。';
: options.animation === 'die'
? '这是死亡终结动作,首帧参考主图角色形象即可,尾帧停在死亡结束姿态,不要回到主图形象。'
: '这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。';
if (options.actionTemplateId) {
return [
@@ -1963,9 +1972,10 @@ async function handleGenerateCharacterAnimation(
`${characterId}-${animation}-visual`,
await resolveMediaSourcePayload(rootDir, visualSource),
);
const resolvedLastFrameSource = !loop
? lastFrameImageDataUrl || visualSource
: '';
const resolvedLastFrameSource =
!loop && animation !== 'die'
? lastFrameImageDataUrl || visualSource
: '';
const lastFrameRef = resolvedLastFrameSource
? isKf2vFlash
? await resolveMediaSourceAsDataUrl(rootDir, resolvedLastFrameSource)
@@ -2730,7 +2740,9 @@ async function handleGetCharacterWorkflowCache(
sendJson(res, 200, {
ok: true,
cache:
isRecordValue(cache) && typeof cache.characterId === 'string'
isRecordValue(cache) &&
typeof cache.characterId === 'string' &&
cache.characterId === characterId
? cache
: null,
});

View File

@@ -42,6 +42,7 @@ export function createStoryActionRoutes(context: AppContext) {
response,
await resolveRuntimeStoryAction({
runtimeRepository: context.runtimeRepository,
llmClient: context.llmClient,
userId: request.userId!,
request: payload,
}),

View File

@@ -2,12 +2,19 @@ import type {
RuntimeBattlePresentation,
RuntimeStoryActionRequest,
RuntimeStoryActionResponse,
RuntimeStoryOptionView,
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
import { conflict, invalidRequest } from '../../errors.js';
import type { RuntimeRepositoryPort } from '../../repositories/runtimeRepository.js';
import type { UpstreamLlmClient } from '../../services/llmClient.js';
import {
buildStrictNpcChatDialoguePrompt,
NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT,
} from '../ai/chatPromptBuilders.js';
import { generateNextStoryFromOrchestrator } from '../ai/storyOrchestrator.js';
import { resolveCombatAction } from '../combat/combatResolutionService.js';
import { resolveInventoryStoryAction, isSupportedInventoryStoryFunctionId } from '../inventory/inventoryStoryActionService.js';
import { isSupportedInventoryStoryFunctionId,resolveInventoryStoryAction } from '../inventory/inventoryStoryActionService.js';
import {
ensureNpcInventorySessionState,
isSupportedNpcInventoryStoryFunctionId,
@@ -21,12 +28,15 @@ import {
isSupportedQuestStoryFunctionId,
resolveQuestStoryAction,
} from '../quest/questStoryActionService.js';
import {
hydrateSavedSnapshot,
normalizeSavedSnapshotPayload,
} from '../runtime/runtimeSnapshotHydration.js';
import {
isSupportedTreasureStoryFunctionId,
resolveTreasureStoryAction,
} from '../runtime-item/treasureStoryActionService.js';
import {
TASK6_DEFERRED_FUNCTION_IDS,
appendStoryHistory,
buildAvailableOptions,
buildLegacyCurrentStory,
@@ -37,14 +47,11 @@ import {
isStoryFunctionId,
isTask5FunctionId,
loadRuntimeSession,
type RuntimeSession,
setEncounterNpcState,
syncRawGameState,
type RuntimeSession,
TASK6_DEFERRED_FUNCTION_IDS,
} from './runtimeSession.js';
import {
hydrateSavedSnapshot,
normalizeSavedSnapshotPayload,
} from '../runtime/runtimeSnapshotHydration.js';
type StoryResolution = {
actionText: string;
@@ -55,6 +62,36 @@ type StoryResolution = {
toast?: string | null;
};
type JsonRecord = Record<string, unknown>;
type StoryOptionLike = {
functionId: string;
actionText: string;
};
type GeneratedStoryPayload = {
storyText: string;
historyResultText: string;
presentationOptions: RuntimeStoryOptionView[];
savedCurrentStory: JsonRecord;
};
const CONTINUE_ADVENTURE_OPTION = {
functionId: 'story_continue_adventure',
actionText: '继续冒险',
detailText: '展开刚刚已经准备好的后续选项。',
scope: 'story',
} satisfies RuntimeStoryOptionView;
const DEFAULT_STORY_OPTION_VISUALS = {
playerAnimation: 'idle',
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
} as const;
function resolveActionText(defaultText: string, request: RuntimeStoryActionRequest) {
const payload = request.action.payload;
const optionText =
@@ -65,6 +102,354 @@ function resolveActionText(defaultText: string, request: RuntimeStoryActionReque
return optionText || defaultText;
}
function isObject(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
function readArray(value: unknown) {
return Array.isArray(value) ? value : [];
}
function buildStoryOptionInteraction(
session: RuntimeSession,
option: RuntimeStoryOptionView,
) {
const encounter = session.currentEncounter;
if (encounter?.kind === 'npc') {
const npcId = encounter.id || encounter.npcName;
const npcActionMap: Record<string, JsonRecord> = {
npc_chat: { kind: 'npc', npcId, action: 'chat' },
npc_help: { kind: 'npc', npcId, action: 'help' },
npc_fight: { kind: 'npc', npcId, action: 'fight' },
npc_leave: { kind: 'npc', npcId, action: 'leave' },
npc_recruit: { kind: 'npc', npcId, action: 'recruit' },
npc_spar: { kind: 'npc', npcId, action: 'spar' },
npc_trade: { kind: 'npc', npcId, action: 'trade' },
npc_gift: { kind: 'npc', npcId, action: 'gift' },
npc_quest_accept: { kind: 'npc', npcId, action: 'quest_accept' },
npc_quest_turn_in: { kind: 'npc', npcId, action: 'quest_turn_in' },
};
return npcActionMap[option.functionId];
}
if (encounter?.kind === 'treasure') {
const treasureActionMap: Record<string, JsonRecord> = {
treasure_secure: { kind: 'treasure', action: 'secure' },
treasure_inspect: { kind: 'treasure', action: 'inspect' },
treasure_leave: { kind: 'treasure', action: 'leave' },
};
return treasureActionMap[option.functionId];
}
return undefined;
}
function buildStoryOptionFromRuntimeOption(
session: RuntimeSession,
option: RuntimeStoryOptionView,
) {
const detailParts = [option.detailText, option.disabled ? option.reason : null]
.filter(Boolean)
.join(' ');
return {
functionId: option.functionId,
actionText: option.actionText,
text: option.actionText,
detailText: detailParts || undefined,
visuals: DEFAULT_STORY_OPTION_VISUALS,
interaction: buildStoryOptionInteraction(session, option),
} satisfies JsonRecord;
}
function buildStoryOptionsFromRuntimeOptions(
session: RuntimeSession,
options: RuntimeStoryOptionView[],
) {
return options
.filter((option) => !option.disabled)
.map((option) => buildStoryOptionFromRuntimeOption(session, option));
}
function escapeRegExp(value: string) {
return value.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&');
}
function normalizeDialogueSpeakerName(rawSpeakerName: string) {
return rawSpeakerName
.trim()
.replace(
/^[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+/u,
'',
)
.replace(
/[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+$/u,
'',
)
.replace(/^(?:\u540c\u4f34|\u961f\u53cb)\s*/u, '')
.trim();
}
function parseDialogueTurns(text: string, npcName: string) {
const turns: JsonRecord[] = [];
const dialogueColonPattern = '(?:\\uFF1A|:)';
const playerPrefixPattern = new RegExp(
'^(?:\\\\u4f60|\\\\u73a9\\\\u5bb6|\\\\u4e3b\\\\u89d2)\\\\s*' +
dialogueColonPattern +
'\\\\s*(.+)$',
'u',
);
const npcPrefixPattern = new RegExp(
'^' +
escapeRegExp(npcName) +
'\\\\s*' +
dialogueColonPattern +
'\\\\s*(.+)$',
'u',
);
const namedSpeakerPattern = new RegExp(
'^(.{1,24}?)\\\\s*' + dialogueColonPattern + '\\\\s*(.+)$',
'u',
);
const lines = text
.replace(/\r/g, '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
for (const line of lines) {
const playerMatch = line.match(playerPrefixPattern);
const playerText = playerMatch?.[1]?.trim();
if (playerText) {
turns.push({ speaker: 'player', text: playerText });
continue;
}
const npcMatch = line.match(npcPrefixPattern);
const npcText = npcMatch?.[1]?.trim();
if (npcText) {
turns.push({ speaker: 'npc', speakerName: npcName, text: npcText });
continue;
}
const namedSpeakerMatch = line.match(namedSpeakerPattern);
if (namedSpeakerMatch?.[1] && namedSpeakerMatch[2]) {
const speakerName = normalizeDialogueSpeakerName(namedSpeakerMatch[1]);
const speakerText = namedSpeakerMatch[2].trim();
if (speakerName && speakerText) {
turns.push({
speaker: speakerName === npcName ? 'npc' : 'companion',
speakerName,
text: speakerText,
});
continue;
}
}
if (line.startsWith('你:') || line.startsWith('你:')) {
turns.push({ speaker: 'player', text: line.slice(2).trim() });
continue;
}
if (line.startsWith(npcName + ':') || line.startsWith(npcName + '')) {
turns.push({
speaker: 'npc',
speakerName: npcName,
text: line.slice(npcName.length + 1).trim(),
});
continue;
}
const lastTurn = turns[turns.length - 1];
if (lastTurn && typeof lastTurn.text === 'string') {
lastTurn.text += line;
}
}
return turns.filter(
(turn) => typeof turn.text === 'string' && turn.text.length > 0,
);
}
function buildDialogueCurrentStory(params: {
session: RuntimeSession;
npcName: string;
text: string;
deferredOptions: RuntimeStoryOptionView[];
}) {
return {
text: params.text,
options: buildStoryOptionsFromRuntimeOptions(
params.session,
[CONTINUE_ADVENTURE_OPTION],
),
displayMode: 'dialogue',
dialogue: parseDialogueTurns(params.text, params.npcName),
streaming: false,
deferredOptions: buildStoryOptionsFromRuntimeOptions(
params.session,
params.deferredOptions,
),
} satisfies JsonRecord;
}
function buildStoryPromptContext(session: RuntimeSession, extras: JsonRecord = {}) {
const scenePreset = isObject(session.rawGameState.currentScenePreset)
? session.rawGameState.currentScenePreset
: null;
return {
sceneName:
readString(scenePreset?.name) ||
readString(session.rawGameState.currentScene) ||
'当前区域',
sceneDescription:
readString(scenePreset?.description) ||
readString(session.rawGameState.sceneDescription) ||
'周围气氛仍在继续变化。',
encounterName: session.currentEncounter?.npcName || null,
encounterId: session.currentEncounter?.id || null,
playerHp: session.playerHp,
playerMaxHp: session.playerMaxHp,
playerMana: session.playerMana,
playerMaxMana: session.playerMaxMana,
inBattle: session.inBattle,
pendingSceneEncounter: false,
...extras,
} satisfies JsonRecord;
}
function buildHistoryMoments(
session: RuntimeSession,
appendedEntries: Array<{ text: string; historyRole: 'action' | 'result' }>,
) {
return [
...session.storyHistory.map((entry) => ({
text: entry.text,
historyRole: entry.historyRole,
})),
...appendedEntries,
];
}
function buildPromptOptions(options: RuntimeStoryOptionView[]) {
return options
.filter((option) => !option.disabled)
.map((option) => ({
functionId: option.functionId,
actionText: option.actionText,
text: option.actionText,
}));
}
function mergeGeneratedRuntimeOptions(
baseOptions: RuntimeStoryOptionView[],
generatedOptions: StoryOptionLike[],
) {
if (generatedOptions.length === 0) {
return baseOptions;
}
const buckets = new Map<string, RuntimeStoryOptionView[]>();
baseOptions.forEach((option) => {
const bucket = buckets.get(option.functionId) ?? [];
bucket.push(option);
buckets.set(option.functionId, bucket);
});
const resolved: RuntimeStoryOptionView[] = [];
const consumed = new Set<RuntimeStoryOptionView>();
generatedOptions.forEach((option) => {
const bucket = buckets.get(option.functionId);
const matched = bucket?.shift();
if (!matched) {
return;
}
consumed.add(matched);
resolved.push({
...matched,
actionText: readString(option.actionText) || matched.actionText,
});
});
if (resolved.length === 0) {
return baseOptions;
}
const remaining = baseOptions.filter((option) => !consumed.has(option));
return [...resolved, ...remaining];
}
function buildOpeningCampChatContext(session: RuntimeSession) {
const encounter = session.currentEncounter;
if (!encounter || encounter.kind !== 'npc') {
return {};
}
const rawEncounter = isObject(session.rawGameState.currentEncounter)
? session.rawGameState.currentEncounter
: null;
if (readString(rawEncounter?.specialBehavior) !== 'camp_companion') {
return {};
}
const npcState = getEncounterNpcState(session);
if (!npcState || npcState.chattedCount > 2) {
return {};
}
const openingActionText = `在营地与 ${encounter.npcName} 交换开场判断`;
let openingDialogue = '';
for (let index = 0; index < session.storyHistory.length - 1; index += 1) {
const entry = session.storyHistory[index];
if (!entry || entry.historyRole !== 'action' || entry.text !== openingActionText) {
continue;
}
for (let nextIndex = index + 1; nextIndex < session.storyHistory.length; nextIndex += 1) {
const nextEntry = session.storyHistory[nextIndex];
if (!nextEntry) {
continue;
}
if (nextEntry.historyRole === 'action') {
break;
}
if (nextEntry.text.trim()) {
openingDialogue = nextEntry.text.trim();
break;
}
}
if (openingDialogue) {
break;
}
}
if (!openingDialogue) {
return {};
}
const playerCharacter = isObject(session.rawGameState.playerCharacter)
? session.rawGameState.playerCharacter
: null;
const playerName = readString(playerCharacter?.name) || '你';
return {
openingCampBackground: `${playerName} 在营地里和 ${encounter.npcName} 终于真正把注意力放回彼此身上,周围暂时没有新的打扰。`,
openingCampDialogue: openingDialogue,
};
}
function normalizeStatusPatch(session: RuntimeSession) {
return {
type: 'status_changed',
@@ -109,6 +494,128 @@ function buildFallbackStoryText(session: RuntimeSession) {
return '当前故事状态已经同步到后端,接下来可以继续推进这一轮运行时动作。';
}
async function generateNpcDialoguePayload(params: {
llmClient: UpstreamLlmClient;
session: RuntimeSession;
actionText: string;
resultText: string;
}): Promise<GeneratedStoryPayload | null> {
const encounter = params.session.currentEncounter;
const playerCharacter = isObject(params.session.rawGameState.playerCharacter)
? params.session.rawGameState.playerCharacter
: null;
if (!encounter || encounter.kind !== 'npc' || !playerCharacter || !params.session.worldType) {
return null;
}
const history = buildHistoryMoments(params.session, [
{ text: params.actionText, historyRole: 'action' },
{ text: params.resultText, historyRole: 'result' },
]);
const availableOptions = buildAvailableOptions(params.session);
const dialogueText = (
await params.llmClient.requestMessageContent({
systemPrompt: NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT,
userPrompt: buildStrictNpcChatDialoguePrompt({
worldType: params.session.worldType,
character: playerCharacter,
encounter: params.session.rawGameState.currentEncounter ?? {},
monsters: readArray(params.session.rawGameState.sceneHostileNpcs).filter(isObject),
history,
context: buildStoryPromptContext(params.session, {
...buildOpeningCampChatContext(params.session),
}),
topic: params.actionText,
resultSummary: params.resultText,
}),
debugLabel: 'runtime.npc_chat.dialogue',
})
).trim();
const finalDialogueText = dialogueText || params.resultText;
let deferredOptions = availableOptions;
try {
const nextStory = await generateNextStoryFromOrchestrator(
params.llmClient,
params.session.worldType,
playerCharacter,
readArray(params.session.rawGameState.sceneHostileNpcs).filter(isObject),
history,
params.actionText,
buildStoryPromptContext(params.session, {
lastFunctionId: 'npc_chat',
...buildOpeningCampChatContext(params.session),
}),
{
availableOptions: buildPromptOptions(availableOptions),
},
);
deferredOptions = mergeGeneratedRuntimeOptions(
availableOptions,
nextStory.options as StoryOptionLike[],
);
} catch {
deferredOptions = availableOptions;
}
return {
storyText: finalDialogueText,
historyResultText: finalDialogueText,
presentationOptions: [CONTINUE_ADVENTURE_OPTION],
savedCurrentStory: buildDialogueCurrentStory({
session: params.session,
npcName: encounter.npcName,
text: finalDialogueText,
deferredOptions,
}),
};
}
async function generateReasonedStoryPayload(params: {
llmClient: UpstreamLlmClient;
session: RuntimeSession;
actionText: string;
resultText: string;
}): Promise<GeneratedStoryPayload | null> {
const playerCharacter = isObject(params.session.rawGameState.playerCharacter)
? params.session.rawGameState.playerCharacter
: null;
if (!playerCharacter || !params.session.worldType) {
return null;
}
const availableOptions = buildAvailableOptions(params.session);
const history = buildHistoryMoments(params.session, [
{ text: params.actionText, historyRole: 'action' },
{ text: params.resultText, historyRole: 'result' },
]);
const nextStory = await generateNextStoryFromOrchestrator(
params.llmClient,
params.session.worldType,
playerCharacter,
readArray(params.session.rawGameState.sceneHostileNpcs).filter(isObject),
history,
params.actionText,
buildStoryPromptContext(params.session),
{
availableOptions: buildPromptOptions(availableOptions),
},
);
const resolvedOptions = mergeGeneratedRuntimeOptions(
availableOptions,
nextStory.options as StoryOptionLike[],
);
const storyText = readString(nextStory.storyText) || params.resultText;
return {
storyText,
historyResultText: storyText,
presentationOptions: resolvedOptions,
savedCurrentStory: buildLegacyCurrentStory(storyText, resolvedOptions),
};
}
function resolveStoryFlowAction(
session: RuntimeSession,
functionId: string,
@@ -212,6 +719,7 @@ function resolveStoryFlowAction(
export async function resolveRuntimeStoryAction(params: {
runtimeRepository: RuntimeRepositoryPort;
llmClient?: UpstreamLlmClient;
userId: string;
request: RuntimeStoryActionRequest;
}) {
@@ -295,16 +803,63 @@ export async function resolveRuntimeStoryAction(params: {
battle: resolution.battle ?? null,
});
const actionText = resolveActionText(resolution.actionText, params.request);
const storyText = resolution.storyText ?? resolution.resultText;
appendStoryHistory(session, actionText, resolution.resultText);
let actionText = resolveActionText(resolution.actionText, params.request);
if (
functionId === 'story_opening_camp_dialogue' &&
session.currentEncounter?.kind === 'npc'
) {
actionText = `在营地与 ${session.currentEncounter.npcName} 交换开场判断`;
}
let storyText = resolution.storyText ?? resolution.resultText;
let historyResultText = resolution.resultText;
session.runtimeVersion += 1;
session.sessionId = params.request.sessionId;
syncRawGameState(session);
ensureNpcInventorySessionState(session);
const options = buildAvailableOptions(session);
let options = buildAvailableOptions(session);
let savedCurrentStory: JsonRecord = buildLegacyCurrentStory(storyText, options);
if (
params.llmClient &&
(functionId === 'npc_chat' || functionId === 'story_opening_camp_dialogue')
) {
try {
const generatedPayload = await generateNpcDialoguePayload({
llmClient: params.llmClient,
session,
actionText,
resultText: resolution.resultText,
});
if (generatedPayload) {
storyText = generatedPayload.storyText;
historyResultText = generatedPayload.historyResultText;
options = generatedPayload.presentationOptions;
savedCurrentStory = generatedPayload.savedCurrentStory;
}
} catch {
savedCurrentStory = buildLegacyCurrentStory(storyText, options);
}
} else if (params.llmClient && isCombatFunctionId(functionId)) {
try {
const generatedPayload = await generateReasonedStoryPayload({
llmClient: params.llmClient,
session,
actionText,
resultText: resolution.resultText,
});
if (generatedPayload) {
storyText = generatedPayload.storyText;
historyResultText = generatedPayload.historyResultText;
options = generatedPayload.presentationOptions;
savedCurrentStory = generatedPayload.savedCurrentStory;
}
} catch {
savedCurrentStory = buildLegacyCurrentStory(storyText, options);
}
}
appendStoryHistory(session, actionText, historyResultText);
syncRawGameState(session);
const persistedSnapshot = await params.runtimeRepository.putSnapshot(
@@ -313,7 +868,7 @@ export async function resolveRuntimeStoryAction(params: {
savedAt: new Date().toISOString(),
bottomTab: session.snapshotBottomTab,
gameState: session.rawGameState,
currentStory: buildLegacyCurrentStory(storyText, options),
currentStory: savedCurrentStory,
}),
);
@@ -333,7 +888,7 @@ export async function resolveRuntimeStoryAction(params: {
{
type: 'story_history_append',
actionText,
resultText: resolution.resultText,
resultText: historyResultText,
},
...resolution.patches,
],

View File

@@ -340,6 +340,199 @@ function buildBuiltinWorldTitle(worldType: string) {
}
}
function looksLikeGeneratedAssetPath(value: string) {
return /^\/generated-/u.test(value);
}
function mergeSnapshotRoleAssets(
role: Record<string, unknown>,
assets: {
imageSrc?: string | null;
generatedVisualAssetId?: string | null;
generatedAnimationSetId?: string | null;
animationMap?: Record<string, unknown> | null;
},
) {
let changed = false;
const nextRole: Record<string, unknown> = { ...role };
const nextImageSrc = readString(assets.imageSrc);
const nextGeneratedVisualAssetId = readString(assets.generatedVisualAssetId);
const nextGeneratedAnimationSetId = readString(
assets.generatedAnimationSetId,
);
const nextAnimationMap = asRecord(assets.animationMap);
if (nextImageSrc && readString(role.imageSrc) !== nextImageSrc) {
nextRole.imageSrc = nextImageSrc;
changed = true;
}
if (
nextGeneratedVisualAssetId &&
readString(role.generatedVisualAssetId) !== nextGeneratedVisualAssetId
) {
nextRole.generatedVisualAssetId = nextGeneratedVisualAssetId;
changed = true;
}
if (
nextGeneratedAnimationSetId &&
readString(role.generatedAnimationSetId) !== nextGeneratedAnimationSetId
) {
nextRole.generatedAnimationSetId = nextGeneratedAnimationSetId;
changed = true;
}
if (nextAnimationMap && Object.keys(nextAnimationMap).length > 0) {
nextRole.animationMap = {
...(asRecord(role.animationMap) ?? {}),
...nextAnimationMap,
};
changed = true;
}
return changed ? nextRole : role;
}
function syncSnapshotRoleAssetsIntoProfile(
profile: Record<string, unknown>,
roleId: string,
assets: Parameters<typeof mergeSnapshotRoleAssets>[1],
) {
if (!roleId) {
return profile;
}
let changed = false;
const syncRoleArray = (value: unknown) => {
if (!Array.isArray(value)) {
return value;
}
return value.map((entry) => {
if (!asRecord(entry) || readString(entry.id) !== roleId) {
return entry;
}
const nextEntry = mergeSnapshotRoleAssets(entry, assets);
if (nextEntry !== entry) {
changed = true;
}
return nextEntry;
});
};
const nextPlayableNpcs = syncRoleArray(profile.playableNpcs);
const nextStoryNpcs = syncRoleArray(profile.storyNpcs);
return changed
? {
...profile,
playableNpcs: nextPlayableNpcs,
storyNpcs: nextStoryNpcs,
}
: profile;
}
function syncSnapshotSceneImageIntoProfile(
profile: Record<string, unknown>,
sceneId: string,
imageSrc: string,
) {
if (!sceneId || !imageSrc) {
return profile;
}
if (sceneId === 'custom-scene-camp') {
const currentCamp = asRecord(profile.camp) ?? {};
if (readString(currentCamp.imageSrc) === imageSrc) {
return profile;
}
return {
...profile,
camp: {
...currentCamp,
imageSrc,
},
};
}
const landmarkMatch = /^custom-scene-landmark-(\d+)$/u.exec(sceneId);
if (!landmarkMatch || !Array.isArray(profile.landmarks)) {
return profile;
}
const landmarkIndex = Number.parseInt(landmarkMatch[1] ?? '', 10) - 1;
if (
!Number.isInteger(landmarkIndex) ||
landmarkIndex < 0 ||
landmarkIndex >= profile.landmarks.length
) {
return profile;
}
const currentLandmark = asRecord(profile.landmarks[landmarkIndex]);
if (!currentLandmark || readString(currentLandmark.imageSrc) === imageSrc) {
return profile;
}
const nextLandmarks = [...profile.landmarks];
nextLandmarks[landmarkIndex] = {
...currentLandmark,
imageSrc,
};
return {
...profile,
landmarks: nextLandmarks,
};
}
function syncSnapshotCustomWorldProfile(gameState: unknown) {
const currentGameState = asRecord(gameState);
const currentProfile = asRecord(currentGameState?.customWorldProfile);
if (!currentGameState || !currentProfile) {
return gameState;
}
let nextProfile = currentProfile;
const playerCharacter = asRecord(currentGameState.playerCharacter);
const playerCharacterId = readString(playerCharacter?.id);
const playerPortrait = readString(playerCharacter?.portrait);
const playerAnimationMap = asRecord(playerCharacter?.animationMap);
const playerHasGeneratedAssets =
Boolean(readString(playerCharacter?.generatedVisualAssetId)) ||
Boolean(readString(playerCharacter?.generatedAnimationSetId)) ||
Boolean(playerAnimationMap && Object.keys(playerAnimationMap).length > 0) ||
looksLikeGeneratedAssetPath(playerPortrait);
nextProfile = syncSnapshotRoleAssetsIntoProfile(nextProfile, playerCharacterId, {
imageSrc: playerHasGeneratedAssets ? playerPortrait : null,
generatedVisualAssetId:
readString(playerCharacter?.generatedVisualAssetId) || null,
generatedAnimationSetId:
readString(playerCharacter?.generatedAnimationSetId) || null,
animationMap: playerAnimationMap,
});
const currentScenePreset = asRecord(currentGameState.currentScenePreset);
nextProfile = syncSnapshotSceneImageIntoProfile(
nextProfile,
readString(currentScenePreset?.id),
readString(currentScenePreset?.imageSrc),
);
if (nextProfile === currentProfile) {
return currentGameState;
}
return {
...currentGameState,
customWorldProfile: nextProfile,
};
}
function resolveProfileWorldSnapshotMeta(
snapshot: SavedSnapshot,
): ProfileWorldSnapshotMeta | null {
@@ -595,6 +788,67 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
});
}
private async syncCustomWorldProfileFromSnapshot(
userId: string,
snapshot: SavedSnapshot,
) {
const gameState = asRecord(snapshot.gameState);
const customWorldProfile = asRecord(gameState?.customWorldProfile);
const profileId = readString(customWorldProfile?.id);
if (!customWorldProfile || !profileId) {
return;
}
const payload = normalizeStoredProfile(profileId, customWorldProfile);
const metadata = extractCustomWorldLibraryMetadata(payload);
const syncedAt = snapshot.savedAt || new Date().toISOString();
await this.db.query(
`INSERT INTO custom_world_profiles (
user_id,
profile_id,
payload_json,
updated_at,
author_display_name,
world_name,
subtitle,
summary_text,
cover_image_src,
theme_mode,
playable_npc_count,
landmark_count,
deleted_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NULL)
ON CONFLICT (user_id, profile_id) DO UPDATE SET
payload_json = EXCLUDED.payload_json,
updated_at = EXCLUDED.updated_at,
deleted_at = NULL,
world_name = EXCLUDED.world_name,
subtitle = EXCLUDED.subtitle,
summary_text = EXCLUDED.summary_text,
cover_image_src = EXCLUDED.cover_image_src,
theme_mode = EXCLUDED.theme_mode,
playable_npc_count = EXCLUDED.playable_npc_count,
landmark_count = EXCLUDED.landmark_count`,
[
userId,
profileId,
payload,
syncedAt,
'玩家',
metadata.worldName,
metadata.subtitle,
metadata.summaryText,
metadata.coverImageSrc,
metadata.themeMode,
metadata.playableNpcCount,
metadata.landmarkCount,
],
);
}
async getSnapshot(userId: string) {
const result = await this.db.query<SnapshotRow>(
`SELECT version,
@@ -625,7 +879,7 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
const snapshot = {
version: SAVE_SNAPSHOT_VERSION,
savedAt: payload.savedAt,
gameState: payload.gameState,
gameState: syncSnapshotCustomWorldProfile(payload.gameState),
bottomTab: payload.bottomTab,
currentStory: payload.currentStory,
} satisfies SavedSnapshot;
@@ -668,6 +922,7 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
} satisfies SavedSnapshot;
await this.syncProfileDashboardFromSnapshot(userId, persistedSnapshot);
await this.syncCustomWorldProfileFromSnapshot(userId, persistedSnapshot);
return persistedSnapshot;
}

View File

@@ -451,24 +451,9 @@ function buildFallbackRoleDraft(
: `长期活跃于当前世界暗面,能补足场景视角的关键角色。`,
60,
),
visualDescription: clampText(
kind === 'playable'
? `他保留着适合长期同行的鲜明外形识别点,服装、装备和体态都能直接看出其职责、出身和会如何与玩家并肩行动。`
: `他身上带着与当前局势强绑定的外观痕迹,衣着、器具和整体气质会暴露其长期活动环境与所站的位置。`,
96,
),
actionDescription: clampText(
kind === 'playable'
? '动作表现偏向协作推进与稳定压制,起手克制,发力明确,收招干净。'
: '动作表现偏向试探、牵制与借势,节奏谨慎,但关键时刻会突然加重攻击或位移。',
72,
),
sceneVisualDescription: clampText(
profile.landmarks[0]?.description
? `他的主要活动空间与${profile.landmarks[0].name}相连,场景里能看到${profile.landmarks[0].description}`
: `他的主要活动空间与${profile.name}当前冲突线直接相关,环境里会留下势力痕迹、旧装置和仍在运转的局势线索。`,
96,
),
visualDescription: '',
actionDescription: '',
sceneVisualDescription: '',
backstory: clampText(
`他与${profile.name}当前正在扩张的冲突链紧密相连,知道一些还未公开的内情。`,
80,
@@ -567,10 +552,7 @@ function buildFallbackLandmarkDraft(profile: ParsedProfile) {
`承接${profile.name}当前主冲突的一处关键新场景,适合继续向外扩张世界关系网。`,
72,
),
visualDescription: clampText(
`这里延续${profile.name}当前主冲突的视觉气质,能看到明确的空间层次、可站立地面、核心建筑或地貌,以及仍在运转的局势痕迹。`,
88,
),
visualDescription: '',
dangerLevel: 'medium',
sceneNpcNames,
connections: targetLandmarkNames.map((targetLandmarkName, index) => ({

View File

@@ -112,7 +112,7 @@ test('generateSceneImage uses wan2.2-t2i-flash text-to-image payload and saves t
if (
req.method === 'POST' &&
url.pathname === '/api/v1/services/aigc/image-generation/generation'
url.pathname === '/api/v1/services/aigc/text2image/image-synthesis'
) {
sendJson(res, {
output: {
@@ -168,27 +168,23 @@ test('generateSceneImage uses wan2.2-t2i-flash text-to-image payload and saves t
assert.equal(result.actualPrompt, '整理后的场景提示词');
const createRequest = capturedRequests.find(
(entry) => entry.pathname === '/api/v1/services/aigc/image-generation/generation',
(entry) => entry.pathname === '/api/v1/services/aigc/text2image/image-synthesis',
);
assert.ok(createRequest?.bodyText);
const createPayload = JSON.parse(createRequest.bodyText) as {
model: string;
input: {
messages: Array<{
content: Array<{ text?: string; image?: string }>;
}>;
};
parameters: {
prompt: string;
negative_prompt?: string;
};
parameters: Record<string, unknown>;
};
const content = createPayload.input.messages[0]?.content ?? [];
assert.equal(createPayload.model, 'wan2.2-t2i-flash');
assert.equal(content[0]?.text, '海雾港口像素风场景');
assert.equal(content.length, 1);
assert.equal(createPayload.parameters.negative_prompt, '模糊');
assert.equal(createPayload.input.prompt, '海雾港口像素风场景');
assert.equal(createPayload.input.negative_prompt, '模糊');
assert.equal(createPayload.parameters.size, '1280*720');
const savedImagePath = path.join(tempRoot, 'public', result.imageSrc.slice(1));
assert.equal(fs.existsSync(savedImagePath), true);

View File

@@ -130,34 +130,32 @@ async function createSceneImageTask(params: {
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',
const response = await fetch(
`${baseUrl}/services/aigc/text2image/image-synthesis`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'X-DashScope-Async': 'enable',
},
body: JSON.stringify({
model: payload.model,
input: {
prompt: payload.prompt,
...(payload.negativePrompt
? { negative_prompt: payload.negativePrompt }
: {}),
},
parameters: {
n: 1,
size: payload.size,
prompt_extend: true,
watermark: false,
},
}),
},
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) {