1
This commit is contained in:
340
server-node/src/modules/assets/characterAssetRoutes.test.ts
Normal file
340
server-node/src/modules/assets/characterAssetRoutes.test.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import express from 'express';
|
||||
|
||||
import type { AppConfig } from '../../config.js';
|
||||
import { createCharacterAssetRoutes } from './characterAssetRoutes.js';
|
||||
|
||||
const PNG_BUFFER = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
const MP4_BUFFER = Buffer.from('mock-video');
|
||||
|
||||
function createTestConfig(
|
||||
projectRoot: string,
|
||||
dashScopeBaseUrl: string,
|
||||
): AppConfig {
|
||||
return {
|
||||
projectRoot,
|
||||
assetsApiEnabled: true,
|
||||
rawEnv: {
|
||||
DASHSCOPE_BASE_URL: dashScopeBaseUrl,
|
||||
DASHSCOPE_API_KEY: 'test-dashscope-key',
|
||||
},
|
||||
} as AppConfig;
|
||||
}
|
||||
|
||||
function readRequestBody(req: IncomingMessage) {
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(res: ServerResponse, payload: unknown) {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
async function withHttpServer<T>(
|
||||
buildHandler: (baseUrl: string) => (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
) => void | Promise<void>,
|
||||
run: (baseUrl: string) => Promise<T>,
|
||||
) {
|
||||
let handler: (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
) => void | Promise<void> = () => undefined;
|
||||
const server = createServer((req, res) => {
|
||||
Promise.resolve(handler(req, res)).catch((error) => {
|
||||
res.statusCode = 500;
|
||||
res.end(error instanceof Error ? error.stack : String(error));
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('failed to resolve test server address');
|
||||
}
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
handler = buildHandler(baseUrl);
|
||||
|
||||
try {
|
||||
return await run(baseUrl);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function withAssetRouteServer<T>(
|
||||
config: AppConfig,
|
||||
run: (baseUrl: string) => Promise<T>,
|
||||
) {
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '25mb' }));
|
||||
app.use(createCharacterAssetRoutes(config));
|
||||
|
||||
const server = await new Promise<import('node:http').Server>((resolve) => {
|
||||
const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer));
|
||||
});
|
||||
|
||||
try {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('failed to resolve asset route server address');
|
||||
}
|
||||
|
||||
return await run(`http://127.0.0.1:${address.port}`);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('character visual generation converts public reference images into data urls before calling DashScope', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-visual-'));
|
||||
const publicDir = path.join(tempRoot, 'public');
|
||||
fs.mkdirSync(publicDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(publicDir, 'reference.png'), PNG_BUFFER);
|
||||
|
||||
let createPayloadText = '';
|
||||
|
||||
await withHttpServer(
|
||||
(dashScopeBaseUrl) => async (req, res) => {
|
||||
const url = new URL(req.url || '/', dashScopeBaseUrl);
|
||||
if (
|
||||
req.method === 'POST' &&
|
||||
url.pathname === '/api/v1/services/aigc/image-generation/generation'
|
||||
) {
|
||||
createPayloadText = (await readRequestBody(req)).toString('utf8');
|
||||
sendJson(res, {
|
||||
output: {
|
||||
task_id: 'visual-task-1',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/v1/tasks/visual-task-1') {
|
||||
sendJson(res, {
|
||||
output: {
|
||||
task_status: 'SUCCEEDED',
|
||||
results: [
|
||||
{
|
||||
url: `${dashScopeBaseUrl}/downloads/visual.png`,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/downloads/visual.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 config = createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`);
|
||||
await withAssetRouteServer(config, async (assetBaseUrl) => {
|
||||
const response = await fetch(
|
||||
`${assetBaseUrl}/api/assets/character-visual/generate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
characterId: 'harbor-guide',
|
||||
sourceMode: 'image-to-image',
|
||||
promptText: '潮雾港向导',
|
||||
characterBriefText: '旧港守望者',
|
||||
referenceImageDataUrls: ['/reference.png'],
|
||||
candidateCount: 1,
|
||||
imageModel: 'wan2.7-image-pro',
|
||||
size: '1024*1536',
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const payload = (await response.json()) as {
|
||||
drafts: Array<{ imageSrc: string }>;
|
||||
};
|
||||
assert.equal(payload.drafts.length, 1);
|
||||
|
||||
const createPayload = JSON.parse(createPayloadText) as {
|
||||
input: {
|
||||
messages: Array<{
|
||||
content: Array<{ text?: string; image?: string }>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const content = createPayload.input.messages[0]?.content ?? [];
|
||||
assert.match(content[1]?.image ?? '', /^data:image\/png;base64,/u);
|
||||
|
||||
const savedDraftPath = path.join(tempRoot, 'public', payload.drafts[0]!.imageSrc.slice(1));
|
||||
assert.equal(fs.existsSync(savedDraftPath), true);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('character animation image-to-video flow uploads a public visual source and submits the resolved oss url', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-video-'));
|
||||
const publicDir = path.join(tempRoot, 'public');
|
||||
fs.mkdirSync(publicDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(publicDir, 'visual.png'), PNG_BUFFER);
|
||||
|
||||
let uploadCalled = false;
|
||||
let videoSynthesisPayloadText = '';
|
||||
|
||||
await withHttpServer(
|
||||
(dashScopeBaseUrl) => async (req, res) => {
|
||||
const url = new URL(req.url || '/', dashScopeBaseUrl);
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/v1/uploads') {
|
||||
sendJson(res, {
|
||||
data: {
|
||||
upload_host: `${dashScopeBaseUrl}/upload`,
|
||||
upload_dir: 'uploads/test-dir',
|
||||
policy: 'policy',
|
||||
signature: 'signature',
|
||||
oss_access_key_id: 'oss-key',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/upload') {
|
||||
uploadCalled = true;
|
||||
await readRequestBody(req);
|
||||
res.statusCode = 200;
|
||||
res.end('ok');
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
req.method === 'POST' &&
|
||||
url.pathname === '/api/v1/services/aigc/video-generation/video-synthesis'
|
||||
) {
|
||||
videoSynthesisPayloadText = (await readRequestBody(req)).toString('utf8');
|
||||
sendJson(res, {
|
||||
output: {
|
||||
task_id: 'video-task-1',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/v1/tasks/video-task-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: 'idle',
|
||||
promptText: '站立观察海面',
|
||||
characterBriefText: '旧港守望者',
|
||||
visualSource: '/visual.png',
|
||||
referenceImageDataUrls: [],
|
||||
referenceVideoDataUrls: [],
|
||||
frameCount: 8,
|
||||
fps: 8,
|
||||
durationSeconds: 4,
|
||||
loop: true,
|
||||
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 payload = (await response.json()) as {
|
||||
previewVideoPath: string;
|
||||
};
|
||||
assert.equal(uploadCalled, true);
|
||||
|
||||
const videoPayload = JSON.parse(videoSynthesisPayloadText) as {
|
||||
input: {
|
||||
media: Array<{ type: string; url: string }>;
|
||||
};
|
||||
};
|
||||
assert.equal(videoPayload.input.media[0]?.type, 'first_frame');
|
||||
assert.match(videoPayload.input.media[0]?.url ?? '', /^oss:\/\/uploads\/test-dir\//u);
|
||||
|
||||
const savedVideoPath = path.join(tempRoot, 'public', payload.previewVideoPath.slice(1));
|
||||
assert.equal(fs.existsSync(savedVideoPath), true);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -867,35 +867,40 @@ async function handleGenerateCharacterVisuals(
|
||||
let activePrompt = '';
|
||||
try {
|
||||
const finalPrompt = buildNpcVisualPrompt(promptText, characterBriefText);
|
||||
const normalizedReferenceImages = await Promise.all(
|
||||
referenceImageDataUrls.map((image) =>
|
||||
resolveMediaSourceAsDataUrl(rootDir, image),
|
||||
),
|
||||
);
|
||||
activePrompt = finalPrompt;
|
||||
const content = [
|
||||
{ text: finalPrompt },
|
||||
...referenceImageDataUrls.map((image) => ({ image })),
|
||||
];
|
||||
const createTaskResponse = await proxyJsonRequest(
|
||||
`${baseUrl}/services/aigc/image-generation/generation`,
|
||||
apiKey,
|
||||
{
|
||||
model,
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content,
|
||||
},
|
||||
],
|
||||
...normalizedReferenceImages.map((image) => ({ image })),
|
||||
];
|
||||
const createTaskResponse = await proxyJsonRequest(
|
||||
`${baseUrl}/services/aigc/image-generation/generation`,
|
||||
apiKey,
|
||||
{
|
||||
model,
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content,
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
n: candidateCount,
|
||||
size,
|
||||
prompt_extend: true,
|
||||
watermark: false,
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
n: candidateCount,
|
||||
size,
|
||||
prompt_extend: true,
|
||||
watermark: false,
|
||||
{
|
||||
'X-DashScope-Async': 'enable',
|
||||
},
|
||||
},
|
||||
{
|
||||
'X-DashScope-Async': 'enable',
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
if (
|
||||
createTaskResponse.statusCode < 200 ||
|
||||
@@ -1178,6 +1183,15 @@ async function handleGenerateCharacterAnimation(
|
||||
frameCount,
|
||||
useChromaKey,
|
||||
);
|
||||
const normalizedVisualSource = await resolveMediaSourceAsDataUrl(
|
||||
rootDir,
|
||||
visualSource,
|
||||
);
|
||||
const normalizedReferenceImages = await Promise.all(
|
||||
referenceImageDataUrls.map((image) =>
|
||||
resolveMediaSourceAsDataUrl(rootDir, image),
|
||||
),
|
||||
);
|
||||
activePrompt = finalPrompt;
|
||||
activeModel = imageSequenceModel;
|
||||
const createTaskResponse = await proxyJsonRequest(
|
||||
@@ -1191,8 +1205,8 @@ async function handleGenerateCharacterAnimation(
|
||||
role: 'user',
|
||||
content: [
|
||||
{ text: finalPrompt },
|
||||
{ image: visualSource },
|
||||
...referenceImageDataUrls.map((image) => ({ image })),
|
||||
{ image: normalizedVisualSource },
|
||||
...normalizedReferenceImages.map((image) => ({ image })),
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,17 +37,18 @@ import {
|
||||
createEmptyCreatorIntentRecord,
|
||||
type CustomWorldCreatorIntentRecord,
|
||||
extractCreatorIntentPatch,
|
||||
hasMeaningfulCreatorIntentRecord,
|
||||
mergeCreatorIntentRecord,
|
||||
normalizeCreatorIntentRecord,
|
||||
} from './customWorldAgentIntentExtractionService.js';
|
||||
import {
|
||||
type CustomWorldAgentSessionRecord,
|
||||
CustomWorldAgentSessionStore,
|
||||
} from './customWorldAgentSessionStore.js';
|
||||
import {
|
||||
rebuildRoleAssetCoverage,
|
||||
resolveRoleAssetStatusLabel,
|
||||
} from './customWorldAgentRoleAssetStateService.js';
|
||||
import {
|
||||
type CustomWorldAgentSessionRecord,
|
||||
CustomWorldAgentSessionStore,
|
||||
} from './customWorldAgentSessionStore.js';
|
||||
import type { UpstreamLlmClient } from './llmClient.js';
|
||||
|
||||
const PHASE2_FORCE_FAIL_TOKEN = '__phase1_force_fail__';
|
||||
@@ -67,12 +68,14 @@ function sleep(ms: number) {
|
||||
});
|
||||
}
|
||||
|
||||
function buildSuggestedActions(params: {
|
||||
stage?: CustomWorldAgentSessionRecord['stage'];
|
||||
isReady?: boolean;
|
||||
draftProfile?: unknown;
|
||||
draftCards?: CustomWorldDraftCardSummary[];
|
||||
} = {}): CustomWorldSuggestedAction[] {
|
||||
function buildSuggestedActions(
|
||||
params: {
|
||||
stage?: CustomWorldAgentSessionRecord['stage'];
|
||||
isReady?: boolean;
|
||||
draftProfile?: unknown;
|
||||
draftCards?: CustomWorldDraftCardSummary[];
|
||||
} = {},
|
||||
): CustomWorldSuggestedAction[] {
|
||||
const profile = normalizeFoundationDraftProfile(params.draftProfile);
|
||||
const actions: CustomWorldSuggestedAction[] = [
|
||||
{
|
||||
@@ -95,7 +98,8 @@ function buildSuggestedActions(params: {
|
||||
}
|
||||
|
||||
if (
|
||||
(params.stage === 'object_refining' || params.stage === 'visual_refining') &&
|
||||
(params.stage === 'object_refining' ||
|
||||
params.stage === 'visual_refining') &&
|
||||
profile
|
||||
) {
|
||||
const worldCardId =
|
||||
@@ -177,13 +181,13 @@ function buildOperation(type: CustomWorldAgentOperationRecord['type']) {
|
||||
? '正在把这次设定改动写回草稿。'
|
||||
: type === 'generate_characters'
|
||||
? '正在围绕当前底稿补出新角色。'
|
||||
: type === 'generate_landmarks'
|
||||
? '正在围绕当前底稿补出新地点。'
|
||||
: type === 'generate_role_assets'
|
||||
? '正在准备角色资产工坊入口。'
|
||||
: type === 'sync_role_assets'
|
||||
? '正在把角色资产结果写回世界草稿。'
|
||||
: '正在整理这一轮新增的世界锚点。';
|
||||
: type === 'generate_landmarks'
|
||||
? '正在围绕当前底稿补出新地点。'
|
||||
: type === 'generate_role_assets'
|
||||
? '正在准备角色资产工坊入口。'
|
||||
: type === 'sync_role_assets'
|
||||
? '正在把角色资产结果写回世界草稿。'
|
||||
: '正在整理这一轮新增的世界锚点。';
|
||||
|
||||
return {
|
||||
operationId: `operation-${crypto.randomBytes(10).toString('hex')}`,
|
||||
@@ -287,9 +291,17 @@ function buildWelcomeMessage(params: {
|
||||
pendingClarifications: CustomWorldPendingClarification[];
|
||||
isReady: boolean;
|
||||
}) {
|
||||
const openingText = params.seedText
|
||||
? `收到:${truncateText(params.seedText, 88)}`
|
||||
: '想做一个什么样的世界?';
|
||||
let openingText: string;
|
||||
|
||||
if (params.seedText) {
|
||||
openingText = `收到:${truncateText(params.seedText, 88)}`;
|
||||
} else {
|
||||
// When user enters without saying anything, provide a welcoming introduction
|
||||
const hasAnyAnchors = hasMeaningfulCreatorIntentRecord(params.intent);
|
||||
openingText = hasAnyAnchors
|
||||
? '继续聊聊你的世界设定吧。'
|
||||
: '你好!我是你的世界设定助手,可以帮你一起构建游戏世界的核心设定。';
|
||||
}
|
||||
|
||||
return composeAssistantReply({
|
||||
openingText,
|
||||
@@ -321,7 +333,86 @@ function buildAssistantMessage(params: {
|
||||
} satisfies CustomWorldAgentMessage;
|
||||
}
|
||||
|
||||
function buildAgentLlmPrompt(params: {
|
||||
function buildAgentSystemPrompt(params: {
|
||||
isReady: boolean;
|
||||
hasAnyAnchors: boolean;
|
||||
}) {
|
||||
const baseInstructions = [
|
||||
'你是一个专业的RPG游戏剧情策划,通过对话帮助用户补全结构化世界锚点。',
|
||||
'',
|
||||
'# 核心原则',
|
||||
'- 像创作者搭档,不要写系统说明,不要列规则,不要提到 JSON 或后端',
|
||||
'- 用中文自然回复,语气专业但友好',
|
||||
'- 不要重复追问用户已经明确回答过的信息',
|
||||
'- 每次只聚焦一个关键问题,帮助用户高效推进',
|
||||
'',
|
||||
'# 输出格式',
|
||||
'必须输出严格的 JSON 格式:{“reply”:”...”,”recommendedReplies”:[“...”,”...”,”...”]}',
|
||||
'',
|
||||
];
|
||||
|
||||
if (params.isReady) {
|
||||
return [
|
||||
...baseInstructions,
|
||||
'# 当前阶段:设定已齐备',
|
||||
'',
|
||||
'## reply 字段要求',
|
||||
'- 第一段:明确回应并收住用户刚刚给出的具体设定',
|
||||
'- 第二段:明确告诉用户关键设定已经足够,可以生成第一版游戏草稿了',
|
||||
'- 最后:自然询问是否现在开始生成草稿',
|
||||
'- 整体要短,聚焦推进',
|
||||
'',
|
||||
'## recommendedReplies 字段要求',
|
||||
'- 必须正好 3 条',
|
||||
'- 每条都是用户下一句可以直接发送的话',
|
||||
'- 第 1 条:表达开始生成草稿(例如:”现在开始生成草稿”)',
|
||||
'- 第 2 条:让 Agent 总结当前设定(例如:”先总结一下当前设定”)',
|
||||
'- 第 3 条:继续补充设定内容(例如:”我还想再补充一点”)',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// When anchors are empty, use inspirational questioning strategy
|
||||
if (!params.hasAnyAnchors) {
|
||||
return [
|
||||
...baseInstructions,
|
||||
'# 当前阶段:初始启发',
|
||||
'',
|
||||
'## reply 字段要求',
|
||||
'- 第一段:如果用户刚进入对话还没说话,用欢迎语气开场(例如:”想创造一个什么样的世界?”)',
|
||||
'- 第一段:如果用户已经说了话,简短回应用户的输入',
|
||||
'- 第二段:提出一个开放性、启发性的问题,帮助用户构思世界的核心概念',
|
||||
'- 问题应该是高层次的,关于世界类型、主题、核心理念,而不是具体细节',
|
||||
'- 例如:世界的整体风格、故事的核心主题、想传达的感觉',
|
||||
'- 避免过早询问具体设定细节(如魔法系统、科技水平等)',
|
||||
'',
|
||||
'## recommendedReplies 字段要求',
|
||||
'- 必须正好 3 条',
|
||||
'- 3 条都是对当前问题的不同方向的回答',
|
||||
'- 每条回答应该代表一种不同的世界类型或主题方向',
|
||||
'- 回答要具体但不过于详细,给用户启发和选择空间',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
return [
|
||||
...baseInstructions,
|
||||
'# 当前阶段:收集设定中',
|
||||
'',
|
||||
'## reply 字段要求',
|
||||
'- 第一段:明确回应并收住用户上一次给出的具体落地设定(不能只说”收到”)',
|
||||
'- 第二段:固定只追问 1 个当前最关键、最能推进游戏设定的问题',
|
||||
'- 这个问题必须帮助你更快拿到作品最核心的设定信息',
|
||||
'- 必要时给一个很短的示例,帮助用户高效回答',
|
||||
'',
|
||||
'## recommendedReplies 字段要求',
|
||||
'- 必须正好 3 条',
|
||||
'- 3 条都必须是对当前这一个问题的直接回答',
|
||||
'- 不允许继续提问',
|
||||
'- 不允许写成”你先帮我””继续问我”这种让 Agent 行动的句子',
|
||||
'- 回答要尽量具体,优先提供能推进作品设定的核心信息',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildAgentUserPrompt(params: {
|
||||
session: CustomWorldAgentSessionRecord;
|
||||
latestUserText: string;
|
||||
intent: CustomWorldCreatorIntentRecord;
|
||||
@@ -338,52 +429,18 @@ function buildAgentLlmPrompt(params: {
|
||||
.join('\n');
|
||||
|
||||
return [
|
||||
'当前结构化世界锚点:',
|
||||
'# 当前结构化世界锚点',
|
||||
buildCreatorIntentDisplayText(params.intent) || '暂无',
|
||||
'',
|
||||
'注意:上面这些已确认设定和下面的历史对话都算有效上下文。不要重复追问用户已经明确回答过的信息。',
|
||||
`# 锚点是否齐备`,
|
||||
params.isReady ? '是' : '否',
|
||||
'',
|
||||
`锚点是否齐备:${params.isReady ? '是' : '否'}`,
|
||||
pendingQuestions ? `待确认问题:\n${pendingQuestions}` : '',
|
||||
'',
|
||||
'最近对话:',
|
||||
pendingQuestions ? `# 待确认问题\n${pendingQuestions}\n` : '',
|
||||
'# 最近对话',
|
||||
recentMessages || '暂无',
|
||||
'',
|
||||
`用户最新输入:${params.latestUserText}`,
|
||||
'',
|
||||
'请输出严格 JSON,格式如下:{"reply":"...","recommendedReplies":["...","...","..."]}',
|
||||
'',
|
||||
params.isReady
|
||||
? [
|
||||
'reply 字段要求:',
|
||||
'- 用中文自然回复。',
|
||||
'- 像创作者搭档,不要写系统说明,不要列规则,不要提到 JSON 或后端。',
|
||||
'- 第一段先明确回应并收住用户刚刚给出的具体设定。',
|
||||
'- 第二段明确告诉用户:关键设定已经足够,可以帮他生成第一版游戏草稿了。',
|
||||
'- 最后固定补一句自然问题,询问是否现在开始生成草稿。',
|
||||
'- 整体要短,聚焦推进。',
|
||||
'recommendedReplies 字段要求:',
|
||||
'- 必须正好 3 条。',
|
||||
'- 每条都是用户下一句可以直接发送的话。',
|
||||
'- 第 1 条必须表达开始生成草稿。',
|
||||
'- 第 2 条应是让 Agent 先总结一下当前设定。',
|
||||
'- 第 3 条应是用户还想再补充一点设定。',
|
||||
].join('\n')
|
||||
: [
|
||||
'reply 字段要求:',
|
||||
'- 用中文自然回复。',
|
||||
'- 像创作者搭档,不要写系统说明,不要列规则,不要提到 JSON 或后端。',
|
||||
'- 第一段必须明确回应并收住用户上一次给出的具体落地设定,不能只说“收到”。',
|
||||
'- 第二段开始固定只追问 1 个当前最关键、最能推进游戏设定的问题。',
|
||||
'- 这个问题必须帮助你更快拿到作品最核心的设定信息。',
|
||||
'- 必要时给一个很短的示例,帮助用户高效回答。',
|
||||
'recommendedReplies 字段要求:',
|
||||
'- 必须正好 3 条。',
|
||||
'- 3 条都必须是对当前这一个问题的直接回答。',
|
||||
'- 不允许继续提问。',
|
||||
'- 不允许写成“你先帮我”“继续问我”这种让 Agent 行动的句子。',
|
||||
'- 回答要尽量具体,优先提供能推进作品设定的核心信息。',
|
||||
].join('\n'),
|
||||
'# 用户最新输入',
|
||||
params.latestUserText,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
@@ -395,8 +452,7 @@ function parseAssistantTurnJson(text: string) {
|
||||
reply?: unknown;
|
||||
recommendedReplies?: unknown;
|
||||
};
|
||||
const reply =
|
||||
typeof parsed.reply === 'string' ? parsed.reply.trim() : '';
|
||||
const reply = typeof parsed.reply === 'string' ? parsed.reply.trim() : '';
|
||||
const recommendedReplies = Array.isArray(parsed.recommendedReplies)
|
||||
? parsed.recommendedReplies
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
@@ -553,7 +609,9 @@ export class CustomWorldAgentOrchestrator {
|
||||
private readonly sessionStore: CustomWorldAgentSessionStore,
|
||||
private readonly llmClient: UpstreamLlmClient | null = null,
|
||||
) {
|
||||
this.foundationDraftService = new CustomWorldAgentFoundationDraftService();
|
||||
this.foundationDraftService = new CustomWorldAgentFoundationDraftService(
|
||||
llmClient,
|
||||
);
|
||||
this.draftCompiler = new CustomWorldAgentDraftCompiler();
|
||||
this.entityGenerationService = new CustomWorldAgentEntityGenerationService(
|
||||
llmClient,
|
||||
@@ -727,7 +785,9 @@ export class CustomWorldAgentOrchestrator {
|
||||
session.draftCards.length > 0,
|
||||
);
|
||||
if (!hasDraftFoundation) {
|
||||
throw badRequest(`${payload.action} requires an existing draft foundation`);
|
||||
throw badRequest(
|
||||
`${payload.action} requires an existing draft foundation`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -793,7 +853,9 @@ export class CustomWorldAgentOrchestrator {
|
||||
|
||||
if (payload.action === 'generate_role_assets') {
|
||||
if (!Array.isArray(payload.roleIds) || payload.roleIds.length !== 1) {
|
||||
throw badRequest('generate_role_assets currently requires exactly one roleId');
|
||||
throw badRequest(
|
||||
'generate_role_assets currently requires exactly one roleId',
|
||||
);
|
||||
}
|
||||
|
||||
const operation = buildOperation('generate_role_assets');
|
||||
@@ -814,7 +876,10 @@ export class CustomWorldAgentOrchestrator {
|
||||
if (!payload.roleId.trim()) {
|
||||
throw badRequest('sync_role_assets requires roleId');
|
||||
}
|
||||
if (!payload.portraitPath.trim() || !payload.generatedVisualAssetId.trim()) {
|
||||
if (
|
||||
!payload.portraitPath.trim() ||
|
||||
!payload.generatedVisualAssetId.trim()
|
||||
) {
|
||||
throw badRequest(
|
||||
'sync_role_assets requires portraitPath and generatedVisualAssetId',
|
||||
);
|
||||
@@ -876,8 +941,11 @@ export class CustomWorldAgentOrchestrator {
|
||||
|
||||
try {
|
||||
const content = await this.llmClient.requestMessageContent({
|
||||
systemPrompt: '你只输出严格 JSON,不输出 Markdown。',
|
||||
userPrompt: buildAgentLlmPrompt({
|
||||
systemPrompt: buildAgentSystemPrompt({
|
||||
isReady: params.isReady,
|
||||
hasAnyAnchors: hasMeaningfulCreatorIntentRecord(params.intent),
|
||||
}),
|
||||
userPrompt: buildAgentUserPrompt({
|
||||
session: params.session,
|
||||
latestUserText: params.latestUserText,
|
||||
intent: params.intent,
|
||||
@@ -936,15 +1004,28 @@ export class CustomWorldAgentOrchestrator {
|
||||
throw new Error('session is not ready for draft_foundation');
|
||||
}
|
||||
|
||||
const draftProfile = this.foundationDraftService.generate({
|
||||
const draftProfile = await this.foundationDraftService.generate({
|
||||
creatorIntent: latestSession.creatorIntent,
|
||||
anchorPack: latestSession.anchorPack,
|
||||
onProgress: async (progress) => {
|
||||
await this.sessionStore.updateOperation(
|
||||
userId,
|
||||
sessionId,
|
||||
operationId,
|
||||
{
|
||||
status: 'running',
|
||||
phaseLabel: progress.phaseLabel,
|
||||
phaseDetail: progress.phaseDetail,
|
||||
progress: progress.progress,
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
await this.sessionStore.updateOperation(userId, sessionId, operationId, {
|
||||
phaseLabel: '编译草稿卡',
|
||||
phaseDetail: '正在把世界底稿整理成可浏览的卡片摘要和详情结构。',
|
||||
progress: 72,
|
||||
progress: 98,
|
||||
});
|
||||
|
||||
const draftCards = this.draftCompiler.compileDraftCards(draftProfile);
|
||||
@@ -992,9 +1073,7 @@ export class CustomWorldAgentOrchestrator {
|
||||
phaseDetail: '这一轮没有成功把锚点编成世界底稿。',
|
||||
progress: 100,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'draft foundation failed',
|
||||
error instanceof Error ? error.message : 'draft foundation failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1003,7 +1082,10 @@ export class CustomWorldAgentOrchestrator {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
operationId: string;
|
||||
payload: Extract<CustomWorldAgentActionRequest, { action: 'update_draft_card' }>;
|
||||
payload: Extract<
|
||||
CustomWorldAgentActionRequest,
|
||||
{ action: 'update_draft_card' }
|
||||
>;
|
||||
}) {
|
||||
const { userId, sessionId, operationId, payload } = params;
|
||||
|
||||
@@ -1024,7 +1106,10 @@ export class CustomWorldAgentOrchestrator {
|
||||
}
|
||||
|
||||
const nextDraftProfile = updateDraftCardSections({
|
||||
draftProfile: (latestSession.draftProfile ?? {}) as Record<string, unknown>,
|
||||
draftProfile: (latestSession.draftProfile ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
cardId: payload.cardId,
|
||||
sections: payload.sections,
|
||||
});
|
||||
@@ -1035,7 +1120,8 @@ export class CustomWorldAgentOrchestrator {
|
||||
progress: 72,
|
||||
});
|
||||
|
||||
const nextDraftCards = this.draftCompiler.compileDraftCards(nextDraftProfile);
|
||||
const nextDraftCards =
|
||||
this.draftCompiler.compileDraftCards(nextDraftProfile);
|
||||
const assetCoverage = rebuildRoleAssetCoverage(nextDraftProfile);
|
||||
const nextStage =
|
||||
latestSession.stage === 'visual_refining'
|
||||
@@ -1052,7 +1138,9 @@ export class CustomWorldAgentOrchestrator {
|
||||
payload.cardId,
|
||||
);
|
||||
const changedSectionIds = new Set(
|
||||
payload.sections.map((section) => section.sectionId.trim()).filter(Boolean),
|
||||
payload.sections
|
||||
.map((section) => section.sectionId.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
await this.sessionStore.replaceDerivedState(userId, sessionId, {
|
||||
@@ -1107,7 +1195,10 @@ export class CustomWorldAgentOrchestrator {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
operationId: string;
|
||||
payload: Extract<CustomWorldAgentActionRequest, { action: 'generate_characters' }>;
|
||||
payload: Extract<
|
||||
CustomWorldAgentActionRequest,
|
||||
{ action: 'generate_characters' }
|
||||
>;
|
||||
}) {
|
||||
const { userId, sessionId, operationId, payload } = params;
|
||||
|
||||
@@ -1131,7 +1222,10 @@ export class CustomWorldAgentOrchestrator {
|
||||
await this.entityGenerationService.generateAdditionalCharacters({
|
||||
creatorIntent: latestSession.creatorIntent,
|
||||
anchorPack: latestSession.anchorPack,
|
||||
draftProfile: (latestSession.draftProfile ?? {}) as Record<string, unknown>,
|
||||
draftProfile: (latestSession.draftProfile ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
count: payload.count,
|
||||
promptText: payload.promptText,
|
||||
anchorCardIds:
|
||||
@@ -1151,7 +1245,9 @@ export class CustomWorldAgentOrchestrator {
|
||||
const nextDraftCards = this.draftCompiler.compileDraftCards(
|
||||
generationResult.draftProfile,
|
||||
);
|
||||
const assetCoverage = rebuildRoleAssetCoverage(generationResult.draftProfile);
|
||||
const assetCoverage = rebuildRoleAssetCoverage(
|
||||
generationResult.draftProfile,
|
||||
);
|
||||
const nextStage =
|
||||
latestSession.stage === 'visual_refining'
|
||||
? ('visual_refining' as const)
|
||||
@@ -1183,7 +1279,9 @@ export class CustomWorldAgentOrchestrator {
|
||||
relatedOperationId: operationId,
|
||||
text: this.changeSummaryService.buildSummary({
|
||||
action: 'generate_characters',
|
||||
names: generationResult.generatedCharacters.map((entry) => entry.name),
|
||||
names: generationResult.generatedCharacters.map(
|
||||
(entry) => entry.name,
|
||||
),
|
||||
draftProfile: generationResult.draftProfile,
|
||||
}),
|
||||
}),
|
||||
@@ -1212,7 +1310,10 @@ export class CustomWorldAgentOrchestrator {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
operationId: string;
|
||||
payload: Extract<CustomWorldAgentActionRequest, { action: 'generate_landmarks' }>;
|
||||
payload: Extract<
|
||||
CustomWorldAgentActionRequest,
|
||||
{ action: 'generate_landmarks' }
|
||||
>;
|
||||
}) {
|
||||
const { userId, sessionId, operationId, payload } = params;
|
||||
|
||||
@@ -1236,7 +1337,10 @@ export class CustomWorldAgentOrchestrator {
|
||||
await this.entityGenerationService.generateAdditionalLandmarks({
|
||||
creatorIntent: latestSession.creatorIntent,
|
||||
anchorPack: latestSession.anchorPack,
|
||||
draftProfile: (latestSession.draftProfile ?? {}) as Record<string, unknown>,
|
||||
draftProfile: (latestSession.draftProfile ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
count: payload.count,
|
||||
promptText: payload.promptText,
|
||||
anchorCardIds:
|
||||
@@ -1256,7 +1360,9 @@ export class CustomWorldAgentOrchestrator {
|
||||
const nextDraftCards = this.draftCompiler.compileDraftCards(
|
||||
generationResult.draftProfile,
|
||||
);
|
||||
const assetCoverage = rebuildRoleAssetCoverage(generationResult.draftProfile);
|
||||
const assetCoverage = rebuildRoleAssetCoverage(
|
||||
generationResult.draftProfile,
|
||||
);
|
||||
const nextStage =
|
||||
latestSession.stage === 'visual_refining'
|
||||
? ('visual_refining' as const)
|
||||
@@ -1288,7 +1394,9 @@ export class CustomWorldAgentOrchestrator {
|
||||
relatedOperationId: operationId,
|
||||
text: this.changeSummaryService.buildSummary({
|
||||
action: 'generate_landmarks',
|
||||
names: generationResult.generatedLandmarks.map((entry) => entry.name),
|
||||
names: generationResult.generatedLandmarks.map(
|
||||
(entry) => entry.name,
|
||||
),
|
||||
draftProfile: generationResult.draftProfile,
|
||||
}),
|
||||
}),
|
||||
@@ -1317,7 +1425,10 @@ export class CustomWorldAgentOrchestrator {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
operationId: string;
|
||||
payload: Extract<CustomWorldAgentActionRequest, { action: 'generate_role_assets' }>;
|
||||
payload: Extract<
|
||||
CustomWorldAgentActionRequest,
|
||||
{ action: 'generate_role_assets' }
|
||||
>;
|
||||
}) {
|
||||
const { userId, sessionId, operationId, payload } = params;
|
||||
|
||||
@@ -1379,7 +1490,9 @@ export class CustomWorldAgentOrchestrator {
|
||||
phaseDetail: '这一轮没有成功进入角色资产工坊。',
|
||||
progress: 100,
|
||||
error:
|
||||
error instanceof Error ? error.message : 'generate role assets failed',
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'generate role assets failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1388,7 +1501,10 @@ export class CustomWorldAgentOrchestrator {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
operationId: string;
|
||||
payload: Extract<CustomWorldAgentActionRequest, { action: 'sync_role_assets' }>;
|
||||
payload: Extract<
|
||||
CustomWorldAgentActionRequest,
|
||||
{ action: 'sync_role_assets' }
|
||||
>;
|
||||
}) {
|
||||
const { userId, sessionId, operationId, payload } = params;
|
||||
|
||||
@@ -1542,9 +1658,7 @@ export class CustomWorldAgentOrchestrator {
|
||||
: derivedState.suggestedActions;
|
||||
|
||||
await this.sessionStore.replaceDerivedState(userId, sessionId, {
|
||||
stage: shouldPreserveDraftStage
|
||||
? preservedStage
|
||||
: derivedState.stage,
|
||||
stage: shouldPreserveDraftStage ? preservedStage : derivedState.stage,
|
||||
creatorIntent: nextIntent,
|
||||
creatorIntentReadiness: derivedState.readiness,
|
||||
anchorPack: derivedState.anchorPack,
|
||||
@@ -1607,7 +1721,7 @@ export class CustomWorldAgentOrchestrator {
|
||||
? '这轮补充已挂回当前底稿语境,现有草稿卡保持可继续浏览。'
|
||||
: derivedState.readiness.isReady
|
||||
? '最小锚点已齐备,可以进入下一阶段。'
|
||||
: '这一轮的创作锚点和澄清问题已经同步完成。',
|
||||
: '这一轮的创作锚点和澄清问题已经同步完成。',
|
||||
progress: 100,
|
||||
error: null,
|
||||
});
|
||||
|
||||
@@ -182,6 +182,19 @@ export class UpstreamLlmClient {
|
||||
? options.debugLabel.trim()
|
||||
: undefined;
|
||||
|
||||
const enableDebugLog = this.config.rawEnv.LLM_DEBUG_LOG === 'true';
|
||||
|
||||
if (enableDebugLog) {
|
||||
this.logger.info(
|
||||
{
|
||||
llm_model: model,
|
||||
llm_debug_label: debugLabel,
|
||||
llm_messages: body.messages,
|
||||
},
|
||||
'[LLM_DEBUG] Request prompt',
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
{
|
||||
llm_model: model,
|
||||
@@ -281,6 +294,18 @@ export class UpstreamLlmClient {
|
||||
throw upstreamError('LLM 返回内容为空');
|
||||
}
|
||||
|
||||
const enableDebugLog = this.config.rawEnv.LLM_DEBUG_LOG === 'true';
|
||||
if (enableDebugLog) {
|
||||
this.logger.info(
|
||||
{
|
||||
llm_debug_label: params.debugLabel,
|
||||
llm_response_content: content,
|
||||
llm_response_length: content.length,
|
||||
},
|
||||
'[LLM_DEBUG] Response content',
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
|
||||
199
server-node/src/services/sceneImageService.test.ts
Normal file
199
server-node/src/services/sceneImageService.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
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 { generateSceneImage } from './sceneImageService.js';
|
||||
|
||||
const PNG_BUFFER = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
|
||||
function createTestConfig(
|
||||
projectRoot: string,
|
||||
dashScopeBaseUrl: string,
|
||||
): AppConfig {
|
||||
return {
|
||||
projectRoot,
|
||||
publicDir: path.join(projectRoot, 'public'),
|
||||
dashScope: {
|
||||
baseUrl: dashScopeBaseUrl,
|
||||
apiKey: 'test-dashscope-key',
|
||||
imageModel: 'wan2.7-image',
|
||||
requestTimeoutMs: 5_000,
|
||||
},
|
||||
} as AppConfig;
|
||||
}
|
||||
|
||||
function readRequestBody(req: IncomingMessage) {
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(res: ServerResponse, payload: unknown) {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
async function withHttpServer<T>(
|
||||
buildHandler: (baseUrl: string) => (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
) => void | Promise<void>,
|
||||
run: (baseUrl: string) => Promise<T>,
|
||||
) {
|
||||
let handler: (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
) => void | Promise<void> = () => undefined;
|
||||
const server = createServer((req, res) => {
|
||||
Promise.resolve(handler(req, res)).catch((error) => {
|
||||
res.statusCode = 500;
|
||||
res.end(error instanceof Error ? error.stack : String(error));
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('failed to resolve test server address');
|
||||
}
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
handler = buildHandler(baseUrl);
|
||||
|
||||
try {
|
||||
return await run(baseUrl);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('generateSceneImage uploads a public reference image as a data url 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;
|
||||
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/image-generation/generation'
|
||||
) {
|
||||
sendJson(res, {
|
||||
output: {
|
||||
task_id: 'scene-task-1',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/v1/tasks/scene-task-1') {
|
||||
sendJson(res, {
|
||||
output: {
|
||||
task_status: 'SUCCEEDED',
|
||||
results: [
|
||||
{
|
||||
url: `${baseUrl}/downloads/scene.png`,
|
||||
actual_prompt: '整理后的场景提示词',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/downloads/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',
|
||||
model: 'wan2.7-image',
|
||||
worldName: '潮雾群岛',
|
||||
profileId: 'world-1',
|
||||
landmarkName: '旧港灯塔',
|
||||
landmarkId: 'landmark-1',
|
||||
referenceImageSrc: '/scene_bg/reference-layout.png',
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.match(result.imageSrc, /^\/generated-custom-world-scenes\//u);
|
||||
assert.equal(result.actualPrompt, '整理后的场景提示词');
|
||||
|
||||
const createRequest = capturedRequests.find(
|
||||
(entry) => entry.pathname === '/api/v1/services/aigc/image-generation/generation',
|
||||
);
|
||||
assert.ok(createRequest?.bodyText);
|
||||
|
||||
const createPayload = JSON.parse(createRequest.bodyText) as {
|
||||
input: {
|
||||
messages: Array<{
|
||||
content: Array<{ text?: string; image?: string }>;
|
||||
}>;
|
||||
};
|
||||
parameters: {
|
||||
negative_prompt?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const content = createPayload.input.messages[0]?.content ?? [];
|
||||
assert.equal(content[0]?.text, '海雾港口像素风场景');
|
||||
assert.match(content[1]?.image ?? '', /^data:image\/png;base64,/u);
|
||||
assert.equal(createPayload.parameters.negative_prompt, '模糊');
|
||||
|
||||
const savedImagePath = path.join(tempRoot, 'public', result.imageSrc.slice(1));
|
||||
assert.equal(fs.existsSync(savedImagePath), true);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { z } from 'zod';
|
||||
@@ -16,8 +17,111 @@ export const sceneImageSchema = z.object({
|
||||
profileId: z.string().trim().optional().default(''),
|
||||
landmarkName: z.string().trim().optional().default(''),
|
||||
landmarkId: z.string().trim().optional().default(''),
|
||||
referenceImageSrc: z.string().trim().optional().default(''),
|
||||
});
|
||||
|
||||
function parseImageDataUrl(source: string) {
|
||||
const matched = /^data:(image\/[^;]+);base64,(.+)$/u.exec(source);
|
||||
if (!matched) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
buffer: Buffer.from(matched[2], 'base64'),
|
||||
mimeType: matched[1],
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveReferenceImageAsDataUrl(rootDir: string, source: string) {
|
||||
const trimmedSource = source.trim();
|
||||
if (!trimmedSource) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parsedDataUrl = parseImageDataUrl(trimmedSource);
|
||||
if (parsedDataUrl) {
|
||||
return trimmedSource;
|
||||
}
|
||||
|
||||
if (!trimmedSource.startsWith('/')) {
|
||||
throw badRequest('参考图必须是 Data URL 或 public 目录下的 URL。');
|
||||
}
|
||||
|
||||
const normalizedSource = path.posix
|
||||
.normalize(trimmedSource)
|
||||
.replace(/^\/+/u, '');
|
||||
const absolutePath = path.resolve(
|
||||
rootDir,
|
||||
'public',
|
||||
...normalizedSource.split('/'),
|
||||
);
|
||||
const publicRoot = path.resolve(rootDir, 'public');
|
||||
if (!absolutePath.startsWith(publicRoot)) {
|
||||
throw badRequest('参考图路径越界。');
|
||||
}
|
||||
|
||||
const buffer = await readFile(absolutePath);
|
||||
const extension = path.extname(absolutePath).replace(/^\./u, '').toLowerCase();
|
||||
const mimeType = (() => {
|
||||
switch (extension) {
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
return 'image/jpeg';
|
||||
case 'webp':
|
||||
return 'image/webp';
|
||||
default:
|
||||
return 'image/png';
|
||||
}
|
||||
})();
|
||||
|
||||
return `data:${mimeType};base64,${buffer.toString('base64')}`;
|
||||
}
|
||||
|
||||
function collectStringsByKey(
|
||||
value: unknown,
|
||||
targetKey: string,
|
||||
results: string[],
|
||||
) {
|
||||
if (typeof value === 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((entry) => collectStringsByKey(entry, targetKey, results));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.entries(value).forEach(([key, nestedValue]) => {
|
||||
if (key === targetKey && typeof nestedValue === 'string' && nestedValue.trim()) {
|
||||
results.push(nestedValue.trim());
|
||||
return;
|
||||
}
|
||||
|
||||
collectStringsByKey(nestedValue, targetKey, results);
|
||||
});
|
||||
}
|
||||
|
||||
function findFirstStringByKey(value: unknown, targetKey: string) {
|
||||
const results: string[] = [];
|
||||
collectStringsByKey(value, targetKey, results);
|
||||
return results[0] ?? '';
|
||||
}
|
||||
|
||||
function extractTaskId(payload: Record<string, unknown>) {
|
||||
return findFirstStringByKey(payload, 'task_id');
|
||||
}
|
||||
|
||||
function extractImageUrls(payload: Record<string, unknown>) {
|
||||
const urls: string[] = [];
|
||||
collectStringsByKey(payload, 'image', urls);
|
||||
collectStringsByKey(payload, 'url', urls);
|
||||
return [...new Set(urls)];
|
||||
}
|
||||
|
||||
function ensurePayload(
|
||||
payload: z.infer<typeof sceneImageSchema>,
|
||||
defaultModel: string,
|
||||
@@ -38,8 +142,14 @@ export async function generateSceneImage(
|
||||
) {
|
||||
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/text2image/image-synthesis`,
|
||||
`${baseUrl}/services/aigc/image-generation/generation`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -50,16 +160,24 @@ export async function generateSceneImage(
|
||||
body: JSON.stringify({
|
||||
model: payload.model,
|
||||
input: {
|
||||
prompt: payload.prompt,
|
||||
...(payload.negativePrompt
|
||||
? { negative_prompt: payload.negativePrompt }
|
||||
: {}),
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ text: payload.prompt },
|
||||
...(referenceImage ? [{ image: referenceImage }] : []),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
n: 1,
|
||||
size: payload.size,
|
||||
prompt_extend: true,
|
||||
watermark: false,
|
||||
...(payload.negativePrompt
|
||||
? { negative_prompt: payload.negativePrompt }
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
@@ -71,12 +189,8 @@ export async function generateSceneImage(
|
||||
);
|
||||
}
|
||||
|
||||
const createPayload = JSON.parse(createText) as {
|
||||
output?: {
|
||||
task_id?: string;
|
||||
};
|
||||
};
|
||||
const taskId = createPayload.output?.task_id?.trim();
|
||||
const createPayload = JSON.parse(createText) as Record<string, unknown>;
|
||||
const taskId = extractTaskId(createPayload);
|
||||
if (!taskId) {
|
||||
throw badRequest('场景图片生成任务未返回 task_id');
|
||||
}
|
||||
@@ -98,21 +212,11 @@ export async function generateSceneImage(
|
||||
);
|
||||
}
|
||||
|
||||
const pollPayload = JSON.parse(pollText) as {
|
||||
output?: {
|
||||
task_status?: string;
|
||||
results?: Array<{
|
||||
url?: string;
|
||||
actual_prompt?: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const status = pollPayload.output?.task_status?.trim();
|
||||
const pollPayload = JSON.parse(pollText) as Record<string, unknown>;
|
||||
const status = findFirstStringByKey(pollPayload, 'task_status').trim();
|
||||
if (status === 'SUCCEEDED') {
|
||||
imageUrl =
|
||||
pollPayload.output?.results?.find((item) => item.url?.trim())?.url?.trim() || '';
|
||||
actualPrompt =
|
||||
pollPayload.output?.results?.find((item) => item.url?.trim())?.actual_prompt?.trim() || '';
|
||||
imageUrl = extractImageUrls(pollPayload)[0] ?? '';
|
||||
actualPrompt = findFirstStringByKey(pollPayload, 'actual_prompt').trim();
|
||||
break;
|
||||
}
|
||||
if (status === 'FAILED' || status === 'UNKNOWN') {
|
||||
|
||||
Reference in New Issue
Block a user