@@ -19,11 +19,14 @@ import {
|
||||
buildCustomWorldLandmarkNetworkBatchPrompt,
|
||||
buildCustomWorldLandmarkSeedBatchJsonRepairPrompt,
|
||||
buildCustomWorldLandmarkSeedBatchPrompt,
|
||||
buildCustomWorldRawProfileFromFramework,
|
||||
buildCustomWorldRoleBatchJsonRepairPrompt,
|
||||
buildCustomWorldRoleBatchPrompt,
|
||||
buildCustomWorldRoleOutlineBatchJsonRepairPrompt,
|
||||
buildCustomWorldRoleOutlineBatchPrompt,
|
||||
} from '../prompts/customWorldPrompts.js';
|
||||
import {
|
||||
buildCompiledCustomWorldProfile,
|
||||
buildCustomWorldRawProfileFromFramework,
|
||||
type CustomWorldGenerationFramework,
|
||||
type CustomWorldGenerationLandmarkOutline,
|
||||
type CustomWorldGenerationRoleBatchStage,
|
||||
@@ -32,9 +35,8 @@ import {
|
||||
normalizeCustomWorldGenerationFramework,
|
||||
normalizeCustomWorldGenerationLandmarkOutlineBatch,
|
||||
normalizeCustomWorldGenerationRoleOutlineBatch,
|
||||
} from '../../../src/services/customWorld.js';
|
||||
import { buildExpandedCustomWorldProfile } from '../../../src/services/customWorldBuilder.js';
|
||||
import type { CustomWorldProfile } from '../../../src/types.js';
|
||||
} from '../modules/custom-world/runtimeProfile.js';
|
||||
import type { CustomWorldProfile } from '../modules/custom-world/runtimeTypes.js';
|
||||
import {
|
||||
buildDraftSummaryFromIntent,
|
||||
type CreatorCharacterSeedRecord,
|
||||
@@ -792,7 +794,7 @@ type DraftProgressCallback = (
|
||||
payload: DraftProgressPayload,
|
||||
) => void | Promise<void>;
|
||||
|
||||
type MergeableNamedRecord = Record<string, unknown> & {
|
||||
type MergeableNamedRecord = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
@@ -1366,7 +1368,9 @@ function buildDraftFactionsFromRuntimeProfile(profile: CustomWorldProfile) {
|
||||
});
|
||||
}
|
||||
|
||||
function buildDraftThreadsFromRuntimeProfile(profile: CustomWorldProfile) {
|
||||
function buildDraftThreadsFromRuntimeProfile(
|
||||
profile: CustomWorldProfile,
|
||||
): CustomWorldFoundationDraftThread[] {
|
||||
const graphThreads = [
|
||||
...(profile.storyGraph?.visibleThreads ?? []).slice(0, 2),
|
||||
...(profile.storyGraph?.hiddenThreads ?? []).slice(0, 2),
|
||||
@@ -1558,8 +1562,12 @@ function convertRuntimeProfileToFoundationDraft(params: {
|
||||
summary: clampText(params.profile.camp.description, 88),
|
||||
} satisfies CustomWorldFoundationDraftCamp)
|
||||
: null,
|
||||
themePack: params.profile.themePack ?? null,
|
||||
storyGraph: params.profile.storyGraph ?? null,
|
||||
themePack:
|
||||
(params.profile.themePack as unknown as Record<string, unknown> | null) ??
|
||||
null,
|
||||
storyGraph:
|
||||
(params.profile.storyGraph as unknown as Record<string, unknown> | null) ??
|
||||
null,
|
||||
factions,
|
||||
threads,
|
||||
chapters: [chapter],
|
||||
@@ -1718,7 +1726,7 @@ async function buildFoundationDraftProfileWithLlm(params: {
|
||||
rawProfile.storyNpcs = storyDetailed;
|
||||
rawProfile.landmarks = framework.landmarks;
|
||||
|
||||
const runtimeProfile = buildExpandedCustomWorldProfile(
|
||||
const runtimeProfile = buildCompiledCustomWorldProfile(
|
||||
rawProfile,
|
||||
settingText,
|
||||
);
|
||||
|
||||
@@ -74,6 +74,12 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
|
||||
profilesByUser.set(userId, current);
|
||||
return current;
|
||||
},
|
||||
async listProfileSaveArchives() {
|
||||
return [];
|
||||
},
|
||||
async resumeProfileSaveArchive() {
|
||||
return null;
|
||||
},
|
||||
async listCustomWorldSessions(userId) {
|
||||
return [...getSessionBucket(userId).values()];
|
||||
},
|
||||
|
||||
@@ -66,6 +66,12 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
|
||||
profilesByUser.set(userId, current);
|
||||
return current;
|
||||
},
|
||||
async listProfileSaveArchives() {
|
||||
return [];
|
||||
},
|
||||
async resumeProfileSaveArchive() {
|
||||
return null;
|
||||
},
|
||||
async listCustomWorldSessions(userId) {
|
||||
return [...getSessionBucket(userId).values()];
|
||||
},
|
||||
|
||||
@@ -67,6 +67,12 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
|
||||
profilesByUser.set(userId, current);
|
||||
return current;
|
||||
},
|
||||
async listProfileSaveArchives() {
|
||||
return [];
|
||||
},
|
||||
async resumeProfileSaveArchive() {
|
||||
return null;
|
||||
},
|
||||
async listCustomWorldSessions(userId) {
|
||||
return [...getSessionBucket(userId).values()];
|
||||
},
|
||||
|
||||
@@ -66,6 +66,12 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
|
||||
profilesByUser.set(userId, current);
|
||||
return current;
|
||||
},
|
||||
async listProfileSaveArchives() {
|
||||
return [];
|
||||
},
|
||||
async resumeProfileSaveArchive() {
|
||||
return null;
|
||||
},
|
||||
async listCustomWorldSessions(userId) {
|
||||
return [...getSessionBucket(userId).values()];
|
||||
},
|
||||
|
||||
566
server-node/src/services/customWorldCoverAssetService.ts
Normal file
566
server-node/src/services/customWorldCoverAssetService.ts
Normal file
@@ -0,0 +1,566 @@
|
||||
import fs from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { AppContext } from '../context.js';
|
||||
import { badRequest } from '../errors.js';
|
||||
import { extractApiErrorMessage } from '../http.js';
|
||||
|
||||
const TEXT_TO_IMAGE_COVER_MODEL = 'wan2.2-t2i-flash';
|
||||
const REFERENCE_IMAGE_COVER_MODEL = 'qwen-image-2.0';
|
||||
|
||||
const coverRoleSchema = z.object({
|
||||
id: z.string().trim().optional().default(''),
|
||||
name: z.string().trim().optional().default(''),
|
||||
title: z.string().trim().optional().default(''),
|
||||
role: z.string().trim().optional().default(''),
|
||||
description: z.string().trim().optional().default(''),
|
||||
imageSrc: z.string().trim().optional().default(''),
|
||||
});
|
||||
|
||||
const coverCampSchema = z.object({
|
||||
name: z.string().trim().optional().default(''),
|
||||
description: z.string().trim().optional().default(''),
|
||||
imageSrc: z.string().trim().optional().default(''),
|
||||
});
|
||||
|
||||
const coverLandmarkSchema = z.object({
|
||||
id: z.string().trim().optional().default(''),
|
||||
name: z.string().trim().optional().default(''),
|
||||
description: z.string().trim().optional().default(''),
|
||||
imageSrc: z.string().trim().optional().default(''),
|
||||
});
|
||||
|
||||
const coverProfileSchema = z.object({
|
||||
id: z.string().trim().optional().default(''),
|
||||
name: z.string().trim().optional().default(''),
|
||||
subtitle: z.string().trim().optional().default(''),
|
||||
summary: z.string().trim().optional().default(''),
|
||||
tone: z.string().trim().optional().default(''),
|
||||
playerGoal: z.string().trim().optional().default(''),
|
||||
settingText: z.string().trim().optional().default(''),
|
||||
camp: coverCampSchema.nullable().optional(),
|
||||
landmarks: z.array(coverLandmarkSchema).optional().default([]),
|
||||
playableNpcs: z.array(coverRoleSchema).optional().default([]),
|
||||
});
|
||||
|
||||
export const customWorldCoverImageSchema = z.object({
|
||||
profile: coverProfileSchema,
|
||||
userPrompt: z.string().trim().optional().default(''),
|
||||
referenceImageSrc: z.string().trim().optional().default(''),
|
||||
characterRoleIds: z.array(z.string().trim()).max(3).optional().default([]),
|
||||
size: z.string().trim().optional().default('1600*900'),
|
||||
});
|
||||
|
||||
export const customWorldCoverUploadSchema = z.object({
|
||||
profileId: z.string().trim().optional().default(''),
|
||||
worldName: z.string().trim().optional().default(''),
|
||||
imageDataUrl: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
type CoverProfile = z.infer<typeof coverProfileSchema>;
|
||||
|
||||
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 sanitizeSegment(value: string, fallback: string) {
|
||||
const normalized = value
|
||||
.replace(/[^\w\u4e00-\u9fa5-]+/gu, '-')
|
||||
.replace(/-+/gu, '-')
|
||||
.replace(/^-+|-+$/gu, '');
|
||||
|
||||
return (normalized || fallback).slice(0, 48);
|
||||
}
|
||||
|
||||
function resolveSelectedRoles(
|
||||
profile: CoverProfile,
|
||||
requestedRoleIds: string[],
|
||||
) {
|
||||
const roleById = new Map(
|
||||
profile.playableNpcs.map((role) => [role.id.trim(), role] as const),
|
||||
);
|
||||
const selectedRoles = [...new Set(requestedRoleIds.map((roleId) => roleId.trim()))]
|
||||
.map((roleId) => roleById.get(roleId))
|
||||
.filter((role): role is z.infer<typeof coverRoleSchema> => Boolean(role));
|
||||
|
||||
if (selectedRoles.length > 0) {
|
||||
return selectedRoles.slice(0, 3);
|
||||
}
|
||||
|
||||
return profile.playableNpcs.slice(0, 3);
|
||||
}
|
||||
|
||||
function buildCustomWorldCoverImagePrompt(
|
||||
profile: CoverProfile,
|
||||
requestedRoleIds: string[],
|
||||
userPrompt: string,
|
||||
options: {
|
||||
hasReferenceImage?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const openingScene = profile.camp ?? profile.landmarks[0] ?? null;
|
||||
const selectedRoles = resolveSelectedRoles(profile, requestedRoleIds);
|
||||
const roleSummary = selectedRoles
|
||||
.map((role) =>
|
||||
[role.name, role.title || role.role, role.description]
|
||||
.filter(Boolean)
|
||||
.join(' / '),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(';');
|
||||
|
||||
return [
|
||||
'为 16:9 横版 RPG 作品生成一张高完成度封面图,用于创作列表与作品详情头图。',
|
||||
'画面重点是“开局场景 + 2 到 3 个主要角色”的主视觉,不是纯背景图,也不是 UI 截图。',
|
||||
'构图需要有明显前中后景层次,前景角色清晰、主体集中、适合移动端缩略显示。',
|
||||
'不要出现任何标题文字、UI、按钮、水印、logo、边框或排版装饰。',
|
||||
options.hasReferenceImage
|
||||
? '已提供一张参考图,请尽量沿用其构图、镜头或色彩气质。'
|
||||
: '',
|
||||
profile.name ? `作品名:${profile.name}。` : '',
|
||||
profile.subtitle ? `副标题:${profile.subtitle}。` : '',
|
||||
profile.settingText ? `玩家设定:${profile.settingText}。` : '',
|
||||
profile.summary ? `世界概述:${profile.summary}。` : '',
|
||||
profile.tone ? `整体基调:${profile.tone}。` : '',
|
||||
profile.playerGoal ? `主线目标:${profile.playerGoal}。` : '',
|
||||
openingScene?.name ? `开局场景:${openingScene.name}。` : '',
|
||||
openingScene?.description ? `场景描述:${openingScene.description}。` : '',
|
||||
roleSummary ? `需要出现的角色主形象:${roleSummary}。` : '',
|
||||
userPrompt ? `额外要求:${userPrompt}。` : '',
|
||||
'整体观感要像一张正式作品封面,主体明确,氛围饱满,人物与场景统一。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
async function createCoverImageTask(params: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
prompt: string;
|
||||
size: string;
|
||||
}) {
|
||||
const response = await fetch(
|
||||
`${params.baseUrl}/services/aigc/text2image/image-synthesis`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-DashScope-Async': 'enable',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: TEXT_TO_IMAGE_COVER_MODEL,
|
||||
input: {
|
||||
prompt: params.prompt,
|
||||
},
|
||||
parameters: {
|
||||
n: 1,
|
||||
size: params.size,
|
||||
prompt_extend: true,
|
||||
watermark: false,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw badRequest(
|
||||
extractApiErrorMessage(responseText, '创建作品封面生成任务失败'),
|
||||
);
|
||||
}
|
||||
|
||||
return JSON.parse(responseText) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function createCoverImageFromReference(params: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
prompt: string;
|
||||
size: string;
|
||||
referenceImage: string;
|
||||
}) {
|
||||
const response = await fetch(
|
||||
`${params.baseUrl}/services/aigc/multimodal-generation/generation`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: REFERENCE_IMAGE_COVER_MODEL,
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ image: params.referenceImage },
|
||||
{ text: params.prompt },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
n: 1,
|
||||
size: params.size,
|
||||
prompt_extend: true,
|
||||
watermark: false,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw badRequest(
|
||||
extractApiErrorMessage(responseText, '创建参考图封面任务失败'),
|
||||
);
|
||||
}
|
||||
|
||||
const responsePayload = JSON.parse(responseText) as Record<string, unknown>;
|
||||
const imageUrl = extractImageUrls(responsePayload)[0] ?? '';
|
||||
if (!imageUrl) {
|
||||
throw badRequest('封面生成未返回图片地址');
|
||||
}
|
||||
|
||||
return {
|
||||
imageUrl,
|
||||
actualPrompt: findFirstStringByKey(responsePayload, 'actual_prompt').trim(),
|
||||
taskId: `cover-edit-${Date.now()}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveGeneratedCoverAsset(params: {
|
||||
context: AppContext;
|
||||
profile: CoverProfile;
|
||||
imageUrl: string;
|
||||
taskId: string;
|
||||
prompt: string;
|
||||
actualPrompt: string;
|
||||
size: string;
|
||||
model: string;
|
||||
}) {
|
||||
const imageResponse = await fetch(params.imageUrl);
|
||||
if (!imageResponse.ok) {
|
||||
throw badRequest('下载作品封面失败');
|
||||
}
|
||||
|
||||
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
|
||||
const contentType = imageResponse.headers.get('content-type') || '';
|
||||
const extension = contentType.includes('png')
|
||||
? 'png'
|
||||
: contentType.includes('webp')
|
||||
? 'webp'
|
||||
: 'jpg';
|
||||
const assetId = `custom-cover-${Date.now()}`;
|
||||
const worldSegment = sanitizeSegment(
|
||||
params.profile.id || params.profile.name,
|
||||
'world',
|
||||
);
|
||||
const relativeDir = path.join(
|
||||
'generated-custom-world-covers',
|
||||
worldSegment,
|
||||
assetId,
|
||||
);
|
||||
const outputDir = path.join(params.context.config.publicDir, relativeDir);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const fileName = `cover.${extension}`;
|
||||
fs.writeFileSync(path.join(outputDir, fileName), imageBuffer);
|
||||
|
||||
const imageSrc = `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`;
|
||||
fs.writeFileSync(
|
||||
path.join(outputDir, 'manifest.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
assetId,
|
||||
sourceType: 'generated',
|
||||
taskId: params.taskId,
|
||||
model: params.model,
|
||||
size: params.size,
|
||||
prompt: params.prompt,
|
||||
actualPrompt: params.actualPrompt,
|
||||
imageSrc,
|
||||
worldName: params.profile.name,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
return {
|
||||
imageSrc,
|
||||
assetId,
|
||||
sourceType: 'generated' as const,
|
||||
model: params.model,
|
||||
size: params.size,
|
||||
taskId: params.taskId,
|
||||
prompt: params.prompt,
|
||||
actualPrompt: params.actualPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function uploadCustomWorldCoverImage(
|
||||
context: AppContext,
|
||||
input: z.infer<typeof customWorldCoverUploadSchema>,
|
||||
) {
|
||||
const payload = customWorldCoverUploadSchema.parse(input);
|
||||
const parsedDataUrl = parseImageDataUrl(payload.imageDataUrl);
|
||||
if (!parsedDataUrl) {
|
||||
throw badRequest('上传封面必须是有效图片 Data URL。');
|
||||
}
|
||||
|
||||
const extension = parsedDataUrl.mimeType.includes('png')
|
||||
? 'png'
|
||||
: parsedDataUrl.mimeType.includes('webp')
|
||||
? 'webp'
|
||||
: 'jpg';
|
||||
const assetId = `custom-cover-upload-${Date.now()}`;
|
||||
const worldSegment = sanitizeSegment(
|
||||
payload.profileId || payload.worldName,
|
||||
'world',
|
||||
);
|
||||
const relativeDir = path.join(
|
||||
'generated-custom-world-covers',
|
||||
worldSegment,
|
||||
assetId,
|
||||
);
|
||||
const outputDir = path.join(context.config.publicDir, relativeDir);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const fileName = `cover.${extension}`;
|
||||
fs.writeFileSync(path.join(outputDir, fileName), parsedDataUrl.buffer);
|
||||
|
||||
const imageSrc = `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`;
|
||||
fs.writeFileSync(
|
||||
path.join(outputDir, 'manifest.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
assetId,
|
||||
sourceType: 'uploaded',
|
||||
imageSrc,
|
||||
worldName: payload.worldName,
|
||||
profileId: payload.profileId,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
return {
|
||||
imageSrc,
|
||||
assetId,
|
||||
sourceType: 'uploaded' as const,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateCustomWorldCoverImage(
|
||||
context: AppContext,
|
||||
input: z.infer<typeof customWorldCoverImageSchema>,
|
||||
) {
|
||||
const payload = customWorldCoverImageSchema.parse(input);
|
||||
const prompt = buildCustomWorldCoverImagePrompt(
|
||||
payload.profile,
|
||||
payload.characterRoleIds,
|
||||
payload.userPrompt,
|
||||
{
|
||||
hasReferenceImage: Boolean(payload.referenceImageSrc.trim()),
|
||||
},
|
||||
);
|
||||
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 createCoverImageFromReference({
|
||||
baseUrl,
|
||||
apiKey: context.config.dashScope.apiKey,
|
||||
prompt,
|
||||
size: payload.size,
|
||||
referenceImage,
|
||||
});
|
||||
|
||||
return saveGeneratedCoverAsset({
|
||||
context,
|
||||
profile: payload.profile,
|
||||
imageUrl: referenceResult.imageUrl,
|
||||
taskId: referenceResult.taskId,
|
||||
prompt,
|
||||
actualPrompt: referenceResult.actualPrompt,
|
||||
size: payload.size,
|
||||
model: REFERENCE_IMAGE_COVER_MODEL,
|
||||
});
|
||||
}
|
||||
|
||||
const createPayload = await createCoverImageTask({
|
||||
baseUrl,
|
||||
apiKey: context.config.dashScope.apiKey,
|
||||
prompt,
|
||||
size: payload.size,
|
||||
});
|
||||
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 saveGeneratedCoverAsset({
|
||||
context,
|
||||
profile: payload.profile,
|
||||
imageUrl,
|
||||
taskId,
|
||||
prompt,
|
||||
actualPrompt,
|
||||
size: payload.size,
|
||||
model: TEXT_TO_IMAGE_COVER_MODEL,
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
CustomWorldProfileRecord,
|
||||
} from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
|
||||
import { resolveCustomWorldCoverPresentation } from '../repositories/customWorldLibraryMetadata.js';
|
||||
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
|
||||
import {
|
||||
buildDraftSummaryFromIntent,
|
||||
@@ -140,12 +141,19 @@ function resolveDraftRoleAssetProgress(session: CustomWorldAgentSessionRecord) {
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePublishedCover(profile: Record<string, unknown>) {
|
||||
const camp = toRecord(profile.camp);
|
||||
const playableNpcs = toRecordArray(profile.playableNpcs);
|
||||
const leadNpc = toRecord(playableNpcs[0]);
|
||||
function resolveDraftCover(session: CustomWorldAgentSessionRecord) {
|
||||
const draftProfile = toRecord(session.draftProfile);
|
||||
if (!draftProfile) {
|
||||
return {
|
||||
imageSrc: null,
|
||||
renderMode: 'image' as const,
|
||||
characterImageSrcs: [],
|
||||
};
|
||||
}
|
||||
|
||||
return toText(camp?.imageSrc) || toText(leadNpc?.imageSrc) || null;
|
||||
return resolveCustomWorldCoverPresentation(
|
||||
draftProfile as CustomWorldProfileRecord,
|
||||
);
|
||||
}
|
||||
|
||||
function isLibraryEntry(
|
||||
@@ -175,6 +183,7 @@ export async function listCustomWorldWorkSummaries(
|
||||
const draftItems: CustomWorldWorkSummary[] = sessions.map((session) => {
|
||||
const counts = resolveDraftCounts(session);
|
||||
const roleAssetProgress = resolveDraftRoleAssetProgress(session);
|
||||
const coverPresentation = resolveDraftCover(session);
|
||||
|
||||
return {
|
||||
workId: `draft:${session.sessionId}`,
|
||||
@@ -185,7 +194,9 @@ export async function listCustomWorldWorkSummaries(
|
||||
normalizeFoundationDraftProfile(session.draftProfile)?.subtitle ||
|
||||
formatDraftStageLabel(session.stage),
|
||||
summary: resolveDraftSummary(session),
|
||||
coverImageSrc: null,
|
||||
coverImageSrc: coverPresentation.imageSrc,
|
||||
coverRenderMode: coverPresentation.renderMode,
|
||||
coverCharacterImageSrcs: coverPresentation.characterImageSrcs,
|
||||
updatedAt: session.updatedAt,
|
||||
publishedAt: null,
|
||||
stage: session.stage,
|
||||
@@ -213,6 +224,7 @@ export async function listCustomWorldWorkSummaries(
|
||||
(libraryEntry ? toText(libraryEntry.updatedAt) : '') ||
|
||||
toText(profileRecord.updatedAt) ||
|
||||
new Date().toISOString();
|
||||
const coverPresentation = resolveCustomWorldCoverPresentation(profileRecord);
|
||||
const roleVisualReadyCount = playableNpcs.filter(
|
||||
(entry) =>
|
||||
Boolean(toText(entry.imageSrc)) &&
|
||||
@@ -240,7 +252,9 @@ export async function listCustomWorldWorkSummaries(
|
||||
'这个世界已经可以直接进入体验。',
|
||||
coverImageSrc:
|
||||
(libraryEntry ? libraryEntry.coverImageSrc : null) ||
|
||||
resolvePublishedCover(profileRecord),
|
||||
coverPresentation.imageSrc,
|
||||
coverRenderMode: coverPresentation.renderMode,
|
||||
coverCharacterImageSrcs: coverPresentation.characterImageSrcs,
|
||||
updatedAt,
|
||||
publishedAt:
|
||||
(libraryEntry ? toText(libraryEntry.publishedAt) : '') ||
|
||||
|
||||
@@ -1,784 +1 @@
|
||||
import type {
|
||||
EightAnchorContent,
|
||||
HiddenLineValue,
|
||||
IconicElementValue,
|
||||
KeyRelationshipValue,
|
||||
ThemeBoundaryValue,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import {
|
||||
createEmptyEightAnchorContent,
|
||||
normalizeEightAnchorContent,
|
||||
} from './eightAnchorCompatibilityService.js';
|
||||
|
||||
export type PromptUserInputSignal =
|
||||
| 'rich'
|
||||
| 'normal'
|
||||
| 'sparse'
|
||||
| 'correction'
|
||||
| 'delegate';
|
||||
|
||||
export type PromptDriftRisk = 'low' | 'medium' | 'high';
|
||||
|
||||
export type PromptConversationMode =
|
||||
| 'bootstrap'
|
||||
| 'expand'
|
||||
| 'compress'
|
||||
| 'repair_direction'
|
||||
| 'force_complete'
|
||||
| 'closing';
|
||||
|
||||
export type PromptDynamicState = {
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
userInputSignal: PromptUserInputSignal;
|
||||
driftRisk: PromptDriftRisk;
|
||||
quickFillRequested: boolean;
|
||||
conversationMode: PromptConversationMode;
|
||||
judgementSummary: string;
|
||||
};
|
||||
|
||||
export type PromptDynamicStateInference = {
|
||||
userInputSignal?: unknown;
|
||||
driftRisk?: unknown;
|
||||
conversationMode?: unknown;
|
||||
judgementSummary?: unknown;
|
||||
};
|
||||
|
||||
const BASE_SYSTEM_PROMPT = `你是一个负责共创游戏世界设定的专业策划。
|
||||
|
||||
你正在和用户一起共创一个游戏世界。每一轮你都必须读取:
|
||||
1. 当前完整设定结构
|
||||
2. 用户聊天记录
|
||||
|
||||
然后输出:
|
||||
1. 一版新的完整设定结构
|
||||
2. 当前 progress 百分比
|
||||
3. 一段直接回复用户的话
|
||||
|
||||
你必须把“新的完整设定结构”视为下一轮的唯一有效版本。
|
||||
你的输出会直接覆盖上一版设定结构。
|
||||
|
||||
你不是在做局部 patch。
|
||||
你不是在做解释报告。
|
||||
你不是在给开发者写分析。
|
||||
你是在同时完成:
|
||||
1. 世界设定更新
|
||||
2. 当前推进程度判断
|
||||
3. 对用户的共创回复`;
|
||||
|
||||
const GLOBAL_HARD_RULES = `全局硬约束:
|
||||
|
||||
1. 必须输出完整的设定结构,而不是只输出变化部分。
|
||||
2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。
|
||||
3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。
|
||||
4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。
|
||||
5. progressPercent 最低为 0,不允许为负数。
|
||||
6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。
|
||||
7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。
|
||||
8. replyText 不要写成长篇策划文,不要展开大段世界观百科。
|
||||
9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。
|
||||
10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。
|
||||
11. 你输出的 JSON 必须可以被直接解析。
|
||||
12. 输出字段顺序必须固定为:replyText、progressPercent、nextAnchorContent。`;
|
||||
|
||||
const MODE_RULES: Record<PromptConversationMode, string> = {
|
||||
bootstrap: `当前模式:bootstrap
|
||||
|
||||
目标:
|
||||
1. 先把世界的基本方向抓住
|
||||
2. 不要一次塞太多新设定
|
||||
3. 回复要降低用户开口压力
|
||||
|
||||
本轮行为要求:
|
||||
1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索
|
||||
2. 如果用户信息很少,不要强行把整套结构一次补满
|
||||
3. replyText 要像共创搭档,而不是像审问
|
||||
4. 默认只推进一个最关键的问题方向
|
||||
5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步
|
||||
6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题
|
||||
7. 不要把问题问得像表单采集,不要一口气追问多个维度
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户觉得“现在很容易继续往下说”
|
||||
2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉
|
||||
3. replyText 最好短、稳、可接话
|
||||
4. 如果用户信息很少,也不要显得冷淡或机械`,
|
||||
expand: `当前模式:expand
|
||||
|
||||
目标:
|
||||
1. 在保持现有方向的前提下,把设定结构逐步补全
|
||||
2. 尽量让一轮输入覆盖多个关键维度
|
||||
|
||||
本轮行为要求:
|
||||
1. 继续保留上一版里仍成立的设定
|
||||
2. 优先把用户本轮输入映射进多个关键维度,而不是只更新一个字段
|
||||
3. replyText 要明确体现“你已经理解了哪些内容”
|
||||
4. 不要突然大幅改写已经成形的世界
|
||||
5. 如果用户这一轮给了多条有效信息,replyText 应先把这些信息自然串起来,再决定下一步
|
||||
6. 可以适度替用户整理,但不要把回复写成总结报告
|
||||
7. 默认继续往前推一步,不要在还没必要时突然收束或突然跳到成稿感
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感到“我刚说的内容都被接住了”
|
||||
2. 回复里可以带一点顺势整理感,但不要太像会议纪要
|
||||
3. 不要无视用户刚提供的高价值细节
|
||||
4. 不要让用户觉得系统在自顾自重写世界`,
|
||||
compress: `当前模式:compress
|
||||
|
||||
目标:
|
||||
1. 开始收束当前设定
|
||||
2. 减少无效发散
|
||||
3. 让 progress 更接近可进入下一阶段
|
||||
|
||||
本轮行为要求:
|
||||
1. 新的设定结构优先保留稳定内容,不要无端重写
|
||||
2. 对用户本轮输入做高密度吸收
|
||||
3. replyText 要更聚焦,不要绕圈
|
||||
4. 默认只推进当前最影响 completion 的一步
|
||||
5. 如果用户还在补细节,优先把细节挂回现有骨架,而不是继续开新分支
|
||||
6. 可以适度提醒“还差哪类关键空位”,但不要把回复写成 checklist
|
||||
7. 如果已有信息足够,replyText 可以更像“确认并收束”,少一点继续发散式追问
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感觉世界正在变得更稳,而不是越来越散
|
||||
2. 让推进感更明确,但不要显得催促
|
||||
3. 回复语气应更笃定一些,减少反复横跳
|
||||
4. 不要把用户刚补进来的细节又冲淡掉`,
|
||||
repair_direction: `当前模式:repair_direction
|
||||
|
||||
目标:
|
||||
1. 处理用户对既有设定的修正
|
||||
2. 避免世界方向飘散或自相矛盾
|
||||
|
||||
本轮行为要求:
|
||||
1. 如果用户明确改口,新的设定结构必须体现修正后的方向
|
||||
2. 对已经不再成立的旧设定,不要机械保留
|
||||
3. progressPercent 可以停滞,也可以小幅回落,但不能为负
|
||||
4. replyText 要承认用户的修正,并顺着修正后的方向继续聊
|
||||
5. 先处理“改掉什么”,再决定“往哪里继续推”
|
||||
6. 不要一边口头承认用户修正,一边在设定结构里偷偷留住旧方向
|
||||
7. 如果修正幅度很大,replyText 可以帮助用户确认新方向已经接管当前语境
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感到“我刚刚的纠偏真的生效了”
|
||||
2. 不要和用户辩论旧方案为什么也行
|
||||
3. 不要表现出对修正的不情愿
|
||||
4. 回复要体现重心已经切到新方向,而不是停留在旧世界观惯性里`,
|
||||
force_complete: `当前模式:force_complete
|
||||
|
||||
目标:
|
||||
1. 基于当前方向直接补齐剩余设定
|
||||
2. 生成一版尽量完整、可进入下一阶段的设定结构
|
||||
3. 结束当前收集阶段
|
||||
|
||||
本轮行为要求:
|
||||
1. 尽量保留已经形成的世界方向
|
||||
2. 对明显缺失的关键维度进行合理补全
|
||||
3. 不要继续拉长聊天,不要再追问用户
|
||||
4. progressPercent 直接输出为 100
|
||||
5. replyText 要自然引导用户点击“生成游戏设定草稿”
|
||||
6. 补全时要优先做“顺着已有方向补齐”,而不是突然换题材、换气质、换主冲突
|
||||
7. 可以让结果更完整,但不要补得过满、过死、过像定稿圣经
|
||||
8. replyText 更像阶段完成提示,不再像继续采集信息的对话
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感到“系统已经帮我把能补的补好了”
|
||||
2. 不要在这一步突然冒出很多陌生设定把用户吓出戏
|
||||
3. 回复要有完成感,但不要太官话
|
||||
4. 清楚告诉用户下一步可以做什么`,
|
||||
closing: `当前模式:closing
|
||||
|
||||
目标:
|
||||
1. 尽量形成一版可用的设定底子
|
||||
2. 不再继续发散新世界观
|
||||
|
||||
本轮行为要求:
|
||||
1. 优先收束,而不是扩写
|
||||
2. 不要大改已经成形的核心设定
|
||||
3. progressPercent 接近完成时,replyText 要更像确认与推进
|
||||
4. 如果用户没有大改方向,尽量让下一版内容更稳定
|
||||
5. 可以轻微补足缺口,但不要再大开新支线
|
||||
6. replyText 应减少探索式措辞,增加“已经基本成形”的稳定感
|
||||
7. 如果只差少量空位,优先把这些空位自然补平,而不是重新打开大话题
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感觉作品已经快成了,而不是还在无穷试探
|
||||
2. 回复可以更像确认和轻推,不要继续像前期那样频繁试探
|
||||
3. 保持留白感,不要把所有东西都一次说死
|
||||
4. 让用户自然过渡到下一阶段,而不是突然被切断对话`,
|
||||
};
|
||||
|
||||
const USER_SIGNAL_RULES: Record<PromptUserInputSignal, string> = {
|
||||
rich: `本轮用户输入信息密度高。
|
||||
请尽量从这一轮里提取多个锚点,不要只更新单一方向。
|
||||
如果一条输入同时影响世界方向、冲突和关系,请在新的完整设定结构中一起体现。`,
|
||||
normal: `本轮用户输入为正常补充。
|
||||
请优先顺着当前方向稳定更新,不要主动扩写太多新设定。`,
|
||||
sparse: `本轮用户输入较少或较虚。
|
||||
请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。
|
||||
replyText 要让用户容易继续往下说。`,
|
||||
correction: `本轮用户在修正或推翻旧设定。
|
||||
请优先吸收修正,不要机械复读旧版本。
|
||||
新的完整设定结构必须以修正后的方向为准。`,
|
||||
delegate: `本轮用户把部分决定权交给你。
|
||||
你可以在 replyText 中给出有限度的建议,但不要突然补满整套设定。
|
||||
新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。`,
|
||||
};
|
||||
|
||||
const QUICK_FILL_EXTRA_RULES = `用户刚刚主动要求你自动补全剩余设定。
|
||||
|
||||
这表示用户接受你基于当前方向自动补完剩余设定。
|
||||
|
||||
本轮要求:
|
||||
1. 不要再继续提问
|
||||
2. 直接输出一版尽量完整的设定结构
|
||||
3. progressPercent 直接输出为 100
|
||||
4. replyText 要告诉用户现在可以进入“生成游戏设定草稿”`;
|
||||
|
||||
const STATE_INFERENCE_SYSTEM_PROMPT = `你是正式生成世界设定前的一步“创作状态识别器”。
|
||||
你的职责不是直接生成新设定,而是先判断:下一轮正式生成应该用什么推进策略,尤其要判断 replyText 应该更偏确认、吸收、收束、纠偏,还是启发式提问。
|
||||
|
||||
你必须综合以下信息判断:
|
||||
1. 当前轮次 currentTurn
|
||||
2. 当前完成度 progressPercent
|
||||
3. 用户是否要求自动补全 quickFillRequested
|
||||
4. 当前完整设定结构
|
||||
5. 最近聊天记录,尤其是最近 1 到 3 轮用户消息
|
||||
|
||||
你需要输出 4 个字段:
|
||||
1. userInputSignal:只能是 rich / normal / sparse / correction / delegate
|
||||
2. driftRisk:只能是 low / medium / high
|
||||
3. conversationMode:只能是 bootstrap / expand / compress / repair_direction / force_complete / closing
|
||||
4. judgementSummary:1 到 2 句中文,概括你为什么这样判断,以及正式生成时最该注意什么
|
||||
|
||||
请按下面的语义判断。
|
||||
|
||||
一、userInputSignal 定义
|
||||
1. rich
|
||||
- 用户这一轮给了多条可直接落地的有效信息
|
||||
- 这些信息可能同时覆盖世界方向、玩家处境、开局事件、冲突、关系、标志元素中的多个
|
||||
- 正式生成时应优先高密度吸收,不要只更新一个点
|
||||
|
||||
2. normal
|
||||
- 用户在顺着当前方向做正常补充
|
||||
- 信息量中等,有明确新增内容,但没有明显推翻旧方向,也没有把决定权交给系统
|
||||
- 正式生成时应稳定推进并自然接住用户内容
|
||||
|
||||
3. sparse
|
||||
- 用户输入很短、很虚、很笼统,或几乎没有新增有效事实
|
||||
- 例如只有一个题材词、一个气质词、一句很概括的话、一个很短的倾向表达
|
||||
- 这种情况下,正式生成阶段的 replyText 应优先采用启发式提问
|
||||
- 启发式提问的要求是:只问一个最容易回答、最能推动落地设计的问题
|
||||
|
||||
4. correction
|
||||
- 用户这轮核心动作是在修正、替换、推翻、重定向旧设定
|
||||
- 即使文字不长,只要主意图是“之前那个不对,现在改成这个”,也应优先判为 correction
|
||||
- correction 的优先级高于 rich 和 normal
|
||||
|
||||
5. delegate
|
||||
- 用户把部分决定权交给系统
|
||||
- 例如“你来定”“你帮我补”“按你觉得合理的来”“先给我一个默认方案”
|
||||
- delegate 关注的是授权关系,不只是信息多寡
|
||||
|
||||
二、driftRisk 定义
|
||||
1. low
|
||||
- 当前轮输入与已有方向基本一致
|
||||
- 没有明显改口或冲突
|
||||
|
||||
2. medium
|
||||
- 当前轮带来一定方向变化或扩张
|
||||
- 还没有明显推翻旧方向,但如果处理不好,容易让设定开始发散
|
||||
|
||||
3. high
|
||||
- 用户明确纠偏、改口、替换方向,或最近多轮反复修正
|
||||
- 这时最重要的是防止旧方向重新回流到正式生成结果里
|
||||
|
||||
三、conversationMode 选择原则
|
||||
1. bootstrap
|
||||
- 适用于前期、信息少、核心方向未稳定
|
||||
- replyText 更适合低压力确认和单点启发
|
||||
|
||||
2. expand
|
||||
- 适用于方向已成形,正在顺着现有路线继续补充
|
||||
- replyText 更适合总结已接住的内容并往前推一步
|
||||
|
||||
3. compress
|
||||
- 适用于中后段,已有骨架,需要开始收束
|
||||
- replyText 更适合聚焦最关键缺口,而不是继续开支线
|
||||
|
||||
4. repair_direction
|
||||
- 适用于用户正在纠偏
|
||||
- replyText 更适合先承认修正,再沿修正后的方向继续推进
|
||||
|
||||
5. force_complete
|
||||
- 适用于用户明确要求自动补全
|
||||
- replyText 不再提问,而应给出完成感和下一步引导
|
||||
|
||||
6. closing
|
||||
- 适用于接近完成但并非强制一键补全
|
||||
- replyText 更像确认与收束,而不是前期式探索
|
||||
|
||||
四、优先级规则
|
||||
1. 如果 quickFillRequested 为 true,conversationMode 必须优先判为 force_complete
|
||||
2. 如果用户核心意图是修正旧方向,userInputSignal 优先判为 correction,conversationMode 通常优先考虑 repair_direction
|
||||
3. 如果用户核心意图是授权系统替他补完,userInputSignal 优先判为 delegate
|
||||
4. 只有在没有明显纠偏、也没有明确自动补全要求时,才主要依据 currentTurn、progressPercent 和信息密度,在 bootstrap / expand / compress / closing 之间选择
|
||||
|
||||
五、关于 replyText 风格的专门判断要求
|
||||
1. 如果用户输入较少、较虚或不够落地,正式生成阶段的 replyText 应采用启发式提问
|
||||
2. 启发式提问一次最多只能提 1 个问题,不能连问两个或更多
|
||||
3. 启发式提问必须问“最能推动当前设计落地”的那个问题,而不是泛泛而谈
|
||||
4. 如果用户输入已经足够 rich,就不要再机械提问,优先吸收和推进
|
||||
5. 如果用户在 correction 或 delegate 状态下,replyText 是否提问要服从更高目标:纠偏生效或代为补全,不要机械套 sparse 的问法
|
||||
|
||||
六、关于 replyText 用语的硬约束
|
||||
1. replyText 禁止提及内部结构名、锚点名、字段名、schema 名、框架词
|
||||
2. 禁止出现这类内部表达:世界承诺、玩家幻想、主题边界、玩家入口、核心冲突、关键关系、隐藏线、标志元素、字段、结构、模块、八锚点
|
||||
3. replyText 只能用通俗、直接、面向创作沟通的语言回应用户
|
||||
4. replyText 应该围绕用户正在讨论的具体内容来落地,比如身份、开场处境、冲突、人物关系、地点、规则、气质,而不是抽象谈结构
|
||||
5. judgementSummary 可以简洁提到“这轮更适合启发式提问”或“这轮应优先吸收修正”,但也不要堆内部术语
|
||||
|
||||
七、关于 judgementSummary 的写法
|
||||
1. 必须简洁,不要写成长篇分析
|
||||
2. 必须直接服务于下一轮正式生成
|
||||
3. 最好同时包含两层信息:
|
||||
- 为什么这么判断
|
||||
- 正式生成时最该优先做什么,或最该避免什么
|
||||
|
||||
八、硬性约束
|
||||
1. 只能输出 JSON,不能输出解释、代码块或额外说明
|
||||
2. 不能发明上下文里不存在的设定事实
|
||||
3. 你的任务是“判断生成策略”,不是“代替正式生成直接写新设定”
|
||||
4. 即使信息不完全,也必须在给定枚举里选出最合理的一组状态
|
||||
5. judgementSummary 必须是中文
|
||||
6. 输出值必须严格落在给定枚举中`;
|
||||
|
||||
const STATE_INFERENCE_OUTPUT_CONTRACT = `请严格按以下 JSON 结构输出,不要输出其他文字:
|
||||
{
|
||||
"userInputSignal": "normal",
|
||||
"driftRisk": "low",
|
||||
"conversationMode": "expand",
|
||||
"judgementSummary": ""
|
||||
}`;
|
||||
|
||||
const OUTPUT_CONTRACT_REMINDER = `请严格按以下 JSON 结构输出,不要输出其他文字:
|
||||
{
|
||||
"replyText": "",
|
||||
"progressPercent": 0,
|
||||
"nextAnchorContent": {
|
||||
"worldPromise": {
|
||||
"hook": "",
|
||||
"differentiator": "",
|
||||
"desiredExperience": ""
|
||||
},
|
||||
"playerFantasy": {
|
||||
"playerRole": "",
|
||||
"corePursuit": "",
|
||||
"fearOfLoss": ""
|
||||
},
|
||||
"themeBoundary": {
|
||||
"toneKeywords": [],
|
||||
"aestheticDirectives": [],
|
||||
"forbiddenDirectives": []
|
||||
},
|
||||
"playerEntryPoint": {
|
||||
"openingIdentity": "",
|
||||
"openingProblem": "",
|
||||
"entryMotivation": ""
|
||||
},
|
||||
"coreConflict": {
|
||||
"surfaceConflicts": [],
|
||||
"hiddenCrisis": "",
|
||||
"firstTouchedConflict": ""
|
||||
},
|
||||
"keyRelationships": [
|
||||
{
|
||||
"pairs": "",
|
||||
"relationshipType": "",
|
||||
"secretOrCost": ""
|
||||
}
|
||||
],
|
||||
"hiddenLines": {
|
||||
"hiddenTruths": [],
|
||||
"misdirectionHints": [],
|
||||
"revealPacing": ""
|
||||
},
|
||||
"iconicElements": {
|
||||
"iconicMotifs": [],
|
||||
"institutionsOrArtifacts": [],
|
||||
"hardRules": []
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
function toJson(value: unknown) {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function getLatestUserText(
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
) {
|
||||
return (
|
||||
[...chatHistory]
|
||||
.reverse()
|
||||
.find((entry) => entry.role === 'user' && entry.content.trim())?.content ??
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function includesAny(text: string, patterns: RegExp[]) {
|
||||
return patterns.some((pattern) => pattern.test(text));
|
||||
}
|
||||
|
||||
function isPromptUserInputSignal(
|
||||
value: unknown,
|
||||
): value is PromptUserInputSignal {
|
||||
return (
|
||||
value === 'rich' ||
|
||||
value === 'normal' ||
|
||||
value === 'sparse' ||
|
||||
value === 'correction' ||
|
||||
value === 'delegate'
|
||||
);
|
||||
}
|
||||
|
||||
function isPromptDriftRisk(value: unknown): value is PromptDriftRisk {
|
||||
return value === 'low' || value === 'medium' || value === 'high';
|
||||
}
|
||||
|
||||
function isPromptConversationMode(
|
||||
value: unknown,
|
||||
): value is PromptConversationMode {
|
||||
return (
|
||||
value === 'bootstrap' ||
|
||||
value === 'expand' ||
|
||||
value === 'compress' ||
|
||||
value === 'repair_direction' ||
|
||||
value === 'force_complete' ||
|
||||
value === 'closing'
|
||||
);
|
||||
}
|
||||
|
||||
export function detectUserInputSignal(
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
): PromptUserInputSignal {
|
||||
const latestUserText = getLatestUserText(chatHistory).trim();
|
||||
|
||||
if (!latestUserText) {
|
||||
return 'sparse';
|
||||
}
|
||||
|
||||
if (includesAny(latestUserText, [/(不是|改成|改为|换成|重来|推翻|修正)/u])) {
|
||||
return 'correction';
|
||||
}
|
||||
|
||||
if (includesAny(latestUserText, [/(你帮我想|你来定|你决定|你补完)/u])) {
|
||||
return 'delegate';
|
||||
}
|
||||
|
||||
const segments = latestUserText
|
||||
.split(/[。!?;\n]/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (latestUserText.length <= 10 || segments.length <= 1) {
|
||||
return 'sparse';
|
||||
}
|
||||
|
||||
if (segments.length >= 3 || latestUserText.length >= 60) {
|
||||
return 'rich';
|
||||
}
|
||||
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
function summarizeDynamicState(
|
||||
state: Pick<
|
||||
PromptDynamicState,
|
||||
'userInputSignal' | 'driftRisk' | 'conversationMode'
|
||||
>,
|
||||
) {
|
||||
return `输入信号=${state.userInputSignal},漂移风险=${state.driftRisk},本轮模式=${state.conversationMode}。正式生成时按这组状态执行。`;
|
||||
}
|
||||
|
||||
function isThemeBoundaryFilled(value: ThemeBoundaryValue | null) {
|
||||
return Boolean(
|
||||
value &&
|
||||
(value.toneKeywords.length > 0 ||
|
||||
value.aestheticDirectives.length > 0 ||
|
||||
value.forbiddenDirectives.length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
function isRelationshipsFilled(value: KeyRelationshipValue[]) {
|
||||
return value.length > 0;
|
||||
}
|
||||
|
||||
function isHiddenLinesFilled(value: HiddenLineValue | null) {
|
||||
return Boolean(
|
||||
value &&
|
||||
(value.hiddenTruths.length > 0 ||
|
||||
value.misdirectionHints.length > 0 ||
|
||||
value.revealPacing),
|
||||
);
|
||||
}
|
||||
|
||||
function isIconicElementsFilled(value: IconicElementValue | null) {
|
||||
return Boolean(
|
||||
value &&
|
||||
(value.iconicMotifs.length > 0 ||
|
||||
value.institutionsOrArtifacts.length > 0 ||
|
||||
value.hardRules.length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
export function detectDriftRisk(params: {
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
anchorContent: EightAnchorContent;
|
||||
progressPercent: number;
|
||||
}) {
|
||||
const latestUserText = getLatestUserText(params.chatHistory).trim();
|
||||
const recentUserMessages = params.chatHistory
|
||||
.filter((entry) => entry.role === 'user')
|
||||
.slice(-3)
|
||||
.map((entry) => entry.content.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const correctionCount = recentUserMessages.filter((entry) =>
|
||||
/(不是|改成|改为|换成|推翻|重来|修正)/u.test(entry),
|
||||
).length;
|
||||
|
||||
if (
|
||||
correctionCount >= 2 ||
|
||||
(params.progressPercent >= 65 &&
|
||||
/(不是|改成|改为|换成|重来|推翻)/u.test(latestUserText))
|
||||
) {
|
||||
return 'high' as const;
|
||||
}
|
||||
|
||||
const normalizedContent = normalizeEightAnchorContent(params.anchorContent);
|
||||
const filledCount = [
|
||||
Boolean(normalizedContent.worldPromise),
|
||||
Boolean(normalizedContent.playerFantasy),
|
||||
isThemeBoundaryFilled(normalizedContent.themeBoundary),
|
||||
Boolean(normalizedContent.playerEntryPoint),
|
||||
Boolean(normalizedContent.coreConflict),
|
||||
isRelationshipsFilled(normalizedContent.keyRelationships),
|
||||
isHiddenLinesFilled(normalizedContent.hiddenLines),
|
||||
isIconicElementsFilled(normalizedContent.iconicElements),
|
||||
].filter(Boolean).length;
|
||||
|
||||
if (filledCount >= 3 && latestUserText.length >= 40) {
|
||||
return 'medium' as const;
|
||||
}
|
||||
|
||||
return 'low' as const;
|
||||
}
|
||||
|
||||
export function pickConversationMode(params: {
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
userInputSignal: PromptUserInputSignal;
|
||||
driftRisk: PromptDriftRisk;
|
||||
quickFillRequested: boolean;
|
||||
}) {
|
||||
if (params.quickFillRequested) {
|
||||
return 'force_complete' as const;
|
||||
}
|
||||
|
||||
if (
|
||||
params.userInputSignal === 'correction' ||
|
||||
params.driftRisk === 'high'
|
||||
) {
|
||||
return 'repair_direction' as const;
|
||||
}
|
||||
|
||||
if (params.progressPercent >= 85 || params.currentTurn >= 15) {
|
||||
return 'closing' as const;
|
||||
}
|
||||
|
||||
if (params.currentTurn > 10 || params.progressPercent >= 65) {
|
||||
return 'compress' as const;
|
||||
}
|
||||
|
||||
if (params.currentTurn <= 10 && params.progressPercent < 65) {
|
||||
return 'expand' as const;
|
||||
}
|
||||
|
||||
return 'bootstrap' as const;
|
||||
}
|
||||
|
||||
function buildRuleBasedPromptDynamicState(input: {
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
quickFillRequested: boolean;
|
||||
currentAnchorContent: EightAnchorContent;
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
}): PromptDynamicState {
|
||||
const userInputSignal = detectUserInputSignal(input.chatHistory);
|
||||
const driftRisk = detectDriftRisk({
|
||||
chatHistory: input.chatHistory,
|
||||
anchorContent: input.currentAnchorContent,
|
||||
progressPercent: input.progressPercent,
|
||||
});
|
||||
|
||||
const conversationMode = pickConversationMode({
|
||||
currentTurn: input.currentTurn,
|
||||
progressPercent: input.progressPercent,
|
||||
userInputSignal,
|
||||
driftRisk,
|
||||
quickFillRequested: input.quickFillRequested,
|
||||
});
|
||||
|
||||
return {
|
||||
currentTurn: input.currentTurn,
|
||||
progressPercent: input.progressPercent,
|
||||
userInputSignal,
|
||||
driftRisk,
|
||||
quickFillRequested: input.quickFillRequested,
|
||||
conversationMode,
|
||||
judgementSummary: summarizeDynamicState({
|
||||
userInputSignal,
|
||||
driftRisk,
|
||||
conversationMode,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPromptDynamicState(input: {
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
quickFillRequested: boolean;
|
||||
currentAnchorContent: EightAnchorContent;
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
}, inference?: PromptDynamicStateInference | null): PromptDynamicState {
|
||||
const fallbackState = buildRuleBasedPromptDynamicState(input);
|
||||
|
||||
if (!inference) {
|
||||
return fallbackState;
|
||||
}
|
||||
|
||||
const userInputSignal = isPromptUserInputSignal(inference.userInputSignal)
|
||||
? inference.userInputSignal
|
||||
: fallbackState.userInputSignal;
|
||||
const driftRisk = isPromptDriftRisk(inference.driftRisk)
|
||||
? inference.driftRisk
|
||||
: fallbackState.driftRisk;
|
||||
const conversationMode = isPromptConversationMode(inference.conversationMode)
|
||||
? inference.conversationMode
|
||||
: fallbackState.conversationMode;
|
||||
const judgementSummary =
|
||||
toText(inference.judgementSummary) ||
|
||||
summarizeDynamicState({
|
||||
userInputSignal,
|
||||
driftRisk,
|
||||
conversationMode,
|
||||
});
|
||||
|
||||
return {
|
||||
currentTurn: input.currentTurn,
|
||||
progressPercent: input.progressPercent,
|
||||
userInputSignal,
|
||||
driftRisk,
|
||||
quickFillRequested: input.quickFillRequested,
|
||||
conversationMode,
|
||||
judgementSummary,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPromptDynamicStateInferencePrompt(input: {
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
quickFillRequested: boolean;
|
||||
currentAnchorContent: EightAnchorContent;
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
}) {
|
||||
const currentAnchorContent =
|
||||
normalizeEightAnchorContent(input.currentAnchorContent) ??
|
||||
createEmptyEightAnchorContent();
|
||||
|
||||
return {
|
||||
systemPrompt: [
|
||||
STATE_INFERENCE_SYSTEM_PROMPT,
|
||||
STATE_INFERENCE_OUTPUT_CONTRACT,
|
||||
].join('\n\n'),
|
||||
userPrompt: [
|
||||
`当前轮次:${input.currentTurn}`,
|
||||
`当前完成度:${input.progressPercent}`,
|
||||
`是否要求自动补全:${input.quickFillRequested ? '是' : '否'}`,
|
||||
renderCurrentAnchorContext(currentAnchorContent),
|
||||
renderChatHistoryContext(input.chatHistory),
|
||||
].join('\n\n'),
|
||||
};
|
||||
}
|
||||
|
||||
function renderDynamicStateContext(dynamicState: PromptDynamicState) {
|
||||
return `上一轮预判得到的创作状态如下。
|
||||
正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。
|
||||
|
||||
创作状态:
|
||||
- userInputSignal: ${dynamicState.userInputSignal}
|
||||
- driftRisk: ${dynamicState.driftRisk}
|
||||
- conversationMode: ${dynamicState.conversationMode}
|
||||
- judgementSummary: ${dynamicState.judgementSummary}`;
|
||||
}
|
||||
|
||||
function renderCurrentAnchorContext(anchorContent: EightAnchorContent) {
|
||||
return `当前完整设定结构如下。
|
||||
你必须把它视为上一版有效世界底子。
|
||||
|
||||
如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。
|
||||
如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。
|
||||
|
||||
当前完整设定结构:
|
||||
${toJson(normalizeEightAnchorContent(anchorContent))}`;
|
||||
}
|
||||
|
||||
function renderChatHistoryContext(
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
) {
|
||||
return `以下是用户聊天记录。
|
||||
请重点理解最近几轮里用户新增、修正、强调的设定信息。
|
||||
不要把早期已经被用户否定的内容继续当成最终结论。
|
||||
|
||||
用户聊天记录:
|
||||
${toJson(chatHistory)}`;
|
||||
}
|
||||
|
||||
export function buildEightAnchorSingleTurnPrompt(input: {
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
quickFillRequested: boolean;
|
||||
currentAnchorContent: EightAnchorContent;
|
||||
chatHistory: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
dynamicState?: PromptDynamicStateInference | PromptDynamicState | null;
|
||||
}) {
|
||||
const currentAnchorContent =
|
||||
normalizeEightAnchorContent(input.currentAnchorContent) ??
|
||||
createEmptyEightAnchorContent();
|
||||
const dynamicState = buildPromptDynamicState({
|
||||
...input,
|
||||
currentAnchorContent,
|
||||
}, input.dynamicState);
|
||||
|
||||
return {
|
||||
prompt: [
|
||||
BASE_SYSTEM_PROMPT,
|
||||
GLOBAL_HARD_RULES,
|
||||
MODE_RULES[dynamicState.conversationMode],
|
||||
USER_SIGNAL_RULES[dynamicState.userInputSignal],
|
||||
dynamicState.quickFillRequested ? QUICK_FILL_EXTRA_RULES : null,
|
||||
renderDynamicStateContext(dynamicState),
|
||||
renderCurrentAnchorContext(currentAnchorContent),
|
||||
renderChatHistoryContext(input.chatHistory),
|
||||
OUTPUT_CONTRACT_REMINDER,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n'),
|
||||
dynamicState,
|
||||
};
|
||||
}
|
||||
export * from '../prompts/eightAnchorPrompts.js';
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import {
|
||||
createServer,
|
||||
type IncomingMessage,
|
||||
type ServerResponse,
|
||||
} from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
@@ -48,10 +52,9 @@ function sendJson(res: ServerResponse, payload: unknown) {
|
||||
}
|
||||
|
||||
async function withHttpServer<T>(
|
||||
buildHandler: (baseUrl: string) => (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
) => void | Promise<void>,
|
||||
buildHandler: (
|
||||
baseUrl: string,
|
||||
) => (req: IncomingMessage, res: ServerResponse) => void | Promise<void>,
|
||||
run: (baseUrl: string) => Promise<T>,
|
||||
) {
|
||||
let handler: (
|
||||
@@ -93,7 +96,9 @@ async function withHttpServer<T>(
|
||||
}
|
||||
|
||||
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 tempRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'genarrative-scene-image-'),
|
||||
);
|
||||
|
||||
const capturedRequests: Array<{
|
||||
pathname: string;
|
||||
@@ -104,7 +109,9 @@ test('generateSceneImage uses wan2.2-t2i-flash text-to-image payload and saves t
|
||||
(baseUrl) => async (req, res) => {
|
||||
const url = new URL(req.url || '/', baseUrl);
|
||||
const bodyText =
|
||||
req.method === 'POST' ? (await readRequestBody(req)).toString('utf8') : undefined;
|
||||
req.method === 'POST'
|
||||
? (await readRequestBody(req)).toString('utf8')
|
||||
: undefined;
|
||||
capturedRequests.push({
|
||||
pathname: url.pathname,
|
||||
bodyText,
|
||||
@@ -122,7 +129,10 @@ test('generateSceneImage uses wan2.2-t2i-flash text-to-image payload and saves t
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/v1/tasks/scene-task-1') {
|
||||
if (
|
||||
req.method === 'GET' &&
|
||||
url.pathname === '/api/v1/tasks/scene-task-1'
|
||||
) {
|
||||
sendJson(res, {
|
||||
output: {
|
||||
task_status: 'SUCCEEDED',
|
||||
@@ -168,7 +178,8 @@ 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/text2image/image-synthesis',
|
||||
(entry) =>
|
||||
entry.pathname === '/api/v1/services/aigc/text2image/image-synthesis',
|
||||
);
|
||||
assert.ok(createRequest?.bodyText);
|
||||
|
||||
@@ -186,17 +197,20 @@ test('generateSceneImage uses wan2.2-t2i-flash text-to-image payload and saves t
|
||||
assert.equal(createPayload.input.negative_prompt, '模糊');
|
||||
assert.equal(createPayload.parameters.size, '1280*720');
|
||||
|
||||
const savedImagePath = path.join(tempRoot, 'public', result.imageSrc.slice(1));
|
||||
const savedImagePath = path.join(
|
||||
tempRoot,
|
||||
'public',
|
||||
result.imageSrc.slice(1),
|
||||
);
|
||||
assert.equal(fs.existsSync(savedImagePath), true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
test('generateSceneImage builds the scene prompt on the server when the client only submits world and landmark context', async () => {
|
||||
const tempRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'genarrative-scene-image-'),
|
||||
);
|
||||
|
||||
const capturedRequests: Array<{
|
||||
pathname: string;
|
||||
@@ -207,7 +221,9 @@ test('generateSceneImage uses qwen-image-2.0 edit flow when a reference image is
|
||||
(baseUrl) => async (req, res) => {
|
||||
const url = new URL(req.url || '/', baseUrl);
|
||||
const bodyText =
|
||||
req.method === 'POST' ? (await readRequestBody(req)).toString('utf8') : undefined;
|
||||
req.method === 'POST'
|
||||
? (await readRequestBody(req)).toString('utf8')
|
||||
: undefined;
|
||||
capturedRequests.push({
|
||||
pathname: url.pathname,
|
||||
bodyText,
|
||||
@@ -215,7 +231,134 @@ test('generateSceneImage uses qwen-image-2.0 edit flow when a reference image is
|
||||
|
||||
if (
|
||||
req.method === 'POST' &&
|
||||
url.pathname === '/api/v1/services/aigc/multimodal-generation/generation'
|
||||
url.pathname === '/api/v1/services/aigc/text2image/image-synthesis'
|
||||
) {
|
||||
sendJson(res, {
|
||||
output: {
|
||||
task_id: 'scene-task-2',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
req.method === 'GET' &&
|
||||
url.pathname === '/api/v1/tasks/scene-task-2'
|
||||
) {
|
||||
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, {
|
||||
worldName: '',
|
||||
profileId: '',
|
||||
landmarkName: '',
|
||||
landmarkId: '',
|
||||
userPrompt: '想让灯塔更偏暴风夜',
|
||||
profile: {
|
||||
id: 'world-3',
|
||||
name: '潮雾群岛',
|
||||
subtitle: '迷雾海界',
|
||||
summary: '岛链被旧航道和风暴一起缠住。',
|
||||
tone: '潮湿、压迫、带着未知回声',
|
||||
playerGoal: '先找到断线的引路火',
|
||||
settingText: '玩家在海雾和旧航道之间寻找可以靠岸的线索。',
|
||||
},
|
||||
landmark: {
|
||||
id: 'landmark-3',
|
||||
name: '旧港灯塔',
|
||||
description: '灯塔外墙被海盐侵蚀,塔下平台还能勉强落脚。',
|
||||
dangerLevel: 'high',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
|
||||
const createRequest = capturedRequests.find(
|
||||
(entry) =>
|
||||
entry.pathname === '/api/v1/services/aigc/text2image/image-synthesis',
|
||||
);
|
||||
assert.ok(createRequest?.bodyText);
|
||||
|
||||
const createPayload = JSON.parse(createRequest.bodyText) as {
|
||||
input: {
|
||||
prompt: string;
|
||||
negative_prompt?: string;
|
||||
};
|
||||
};
|
||||
|
||||
assert.match(createPayload.input.prompt, /世界:潮雾群岛,迷雾海界。/u);
|
||||
assert.match(createPayload.input.prompt, /场景名称:旧港灯塔。/u);
|
||||
assert.match(
|
||||
createPayload.input.prompt,
|
||||
/本次想要生成的画面内容:想让灯塔更偏暴风夜。/u,
|
||||
);
|
||||
assert.match(createPayload.input.prompt, /危险感强烈/u);
|
||||
assert.equal(
|
||||
createPayload.input.negative_prompt,
|
||||
'文字,水印,logo,UI界面,对话框,边框,人物近景特写,多人合照,模糊,低清晰度,畸形建筑,现代车辆,监控摄像头',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
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: {
|
||||
@@ -235,7 +378,10 @@ test('generateSceneImage uses qwen-image-2.0 edit flow when a reference image is
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/downloads/reference-scene.png') {
|
||||
if (
|
||||
req.method === 'GET' &&
|
||||
url.pathname === '/downloads/reference-scene.png'
|
||||
) {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.end(PNG_BUFFER);
|
||||
@@ -273,7 +419,8 @@ test('generateSceneImage uses qwen-image-2.0 edit flow when a reference image is
|
||||
|
||||
const createRequest = capturedRequests.find(
|
||||
(entry) =>
|
||||
entry.pathname === '/api/v1/services/aigc/multimodal-generation/generation',
|
||||
entry.pathname ===
|
||||
'/api/v1/services/aigc/multimodal-generation/generation',
|
||||
);
|
||||
assert.ok(createRequest?.bodyText);
|
||||
|
||||
|
||||
@@ -4,12 +4,33 @@ import path from 'node:path';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
buildCustomWorldSceneImagePrompt,
|
||||
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,
|
||||
} from '../prompts/customWorldPrompts.js';
|
||||
import type { AppContext } from '../context.js';
|
||||
import { badRequest } from '../errors.js';
|
||||
import { extractApiErrorMessage } from '../http.js';
|
||||
|
||||
const sceneImageProfileSchema = z.object({
|
||||
id: z.string().trim().optional().default(''),
|
||||
name: z.string().trim().optional().default(''),
|
||||
subtitle: z.string().trim().optional().default(''),
|
||||
summary: z.string().trim().optional().default(''),
|
||||
tone: z.string().trim().optional().default(''),
|
||||
playerGoal: z.string().trim().optional().default(''),
|
||||
settingText: z.string().trim().optional().default(''),
|
||||
});
|
||||
|
||||
const sceneImageLandmarkSchema = z.object({
|
||||
id: z.string().trim().optional().default(''),
|
||||
name: z.string().trim().optional().default(''),
|
||||
description: z.string().trim().optional().default(''),
|
||||
dangerLevel: z.string().trim().optional().default(''),
|
||||
});
|
||||
|
||||
export const sceneImageSchema = z.object({
|
||||
prompt: z.string().trim().min(1),
|
||||
prompt: z.string().trim().optional().default(''),
|
||||
negativePrompt: z.string().trim().optional().default(''),
|
||||
size: z.string().trim().optional().default('1280*720'),
|
||||
model: z.string().trim().optional().default(''),
|
||||
@@ -18,6 +39,9 @@ export const sceneImageSchema = z.object({
|
||||
landmarkName: z.string().trim().optional().default(''),
|
||||
landmarkId: z.string().trim().optional().default(''),
|
||||
referenceImageSrc: z.string().trim().optional().default(''),
|
||||
userPrompt: z.string().trim().optional().default(''),
|
||||
profile: sceneImageProfileSchema.optional(),
|
||||
landmark: sceneImageLandmarkSchema.optional(),
|
||||
});
|
||||
const TEXT_TO_IMAGE_SCENE_MODEL = 'wan2.2-t2i-flash';
|
||||
const REFERENCE_IMAGE_SCENE_MODEL = 'qwen-image-2.0';
|
||||
@@ -63,7 +87,10 @@ async function resolveReferenceImageAsDataUrl(rootDir: string, source: string) {
|
||||
}
|
||||
|
||||
const buffer = await readFile(absolutePath);
|
||||
const extension = path.extname(absolutePath).replace(/^\./u, '').toLowerCase();
|
||||
const extension = path
|
||||
.extname(absolutePath)
|
||||
.replace(/^\./u, '')
|
||||
.toLowerCase();
|
||||
const mimeType = (() => {
|
||||
switch (extension) {
|
||||
case 'jpg':
|
||||
@@ -98,7 +125,11 @@ function collectStringsByKey(
|
||||
}
|
||||
|
||||
Object.entries(value).forEach(([key, nestedValue]) => {
|
||||
if (key === targetKey && typeof nestedValue === 'string' && nestedValue.trim()) {
|
||||
if (
|
||||
key === targetKey &&
|
||||
typeof nestedValue === 'string' &&
|
||||
nestedValue.trim()
|
||||
) {
|
||||
results.push(nestedValue.trim());
|
||||
return;
|
||||
}
|
||||
@@ -244,17 +275,53 @@ 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()
|
||||
: '';
|
||||
const profile = payload.profile ?? sceneImageProfileSchema.parse({});
|
||||
const landmark = payload.landmark ?? sceneImageLandmarkSchema.parse({});
|
||||
const profileId = payload.profileId.trim() || profile.id;
|
||||
const worldName = payload.worldName.trim() || profile.name;
|
||||
const landmarkId = payload.landmarkId.trim() || landmark.id;
|
||||
const landmarkName = payload.landmarkName.trim() || landmark.name;
|
||||
|
||||
if (!landmarkName && !landmarkId) {
|
||||
throw badRequest('landmarkName 或 landmarkId 至少要提供一个');
|
||||
}
|
||||
const prompt =
|
||||
payload.prompt.trim() ||
|
||||
buildCustomWorldSceneImagePrompt(
|
||||
{
|
||||
...profile,
|
||||
id: profileId,
|
||||
name: worldName,
|
||||
},
|
||||
{
|
||||
...landmark,
|
||||
id: landmarkId,
|
||||
name: landmarkName,
|
||||
},
|
||||
payload.userPrompt,
|
||||
{
|
||||
hasReferenceImage: Boolean(referenceImageSrc),
|
||||
},
|
||||
);
|
||||
if (!prompt) {
|
||||
throw badRequest('prompt 不能为空');
|
||||
}
|
||||
const negativePrompt =
|
||||
payload.negativePrompt.trim() ||
|
||||
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT;
|
||||
|
||||
return {
|
||||
...payload,
|
||||
prompt,
|
||||
negativePrompt,
|
||||
worldName,
|
||||
profileId,
|
||||
landmarkName,
|
||||
landmarkId,
|
||||
referenceImageSrc,
|
||||
model: referenceImageSrc
|
||||
? REFERENCE_IMAGE_SCENE_MODEL
|
||||
@@ -286,7 +353,11 @@ async function saveSceneImageAsset(params: {
|
||||
const worldSegment = (payload.profileId || payload.worldName || 'world')
|
||||
.replace(/[^\w\u4e00-\u9fa5-]+/gu, '-')
|
||||
.slice(0, 48);
|
||||
const landmarkSegment = (payload.landmarkId || payload.landmarkName || 'landmark')
|
||||
const landmarkSegment = (
|
||||
payload.landmarkId ||
|
||||
payload.landmarkName ||
|
||||
'landmark'
|
||||
)
|
||||
.replace(/[^\w\u4e00-\u9fa5-]+/gu, '-')
|
||||
.slice(0, 48);
|
||||
const relativeDir = path.join(
|
||||
@@ -338,7 +409,10 @@ export async function generateSceneImage(
|
||||
context: AppContext,
|
||||
input: z.infer<typeof sceneImageSchema>,
|
||||
) {
|
||||
const payload = ensurePayload(input, context.config.dashScope.imageModel);
|
||||
const payload = ensurePayload(
|
||||
sceneImageSchema.parse(input),
|
||||
context.config.dashScope.imageModel,
|
||||
);
|
||||
const baseUrl = context.config.dashScope.baseUrl.replace(/\/+$/u, '');
|
||||
const referenceImage = payload.referenceImageSrc.trim()
|
||||
? await resolveReferenceImageAsDataUrl(
|
||||
|
||||
Reference in New Issue
Block a user