11
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-16 21:47:20 +08:00
parent 2456c10c63
commit 09d4c0c31b
79 changed files with 11873 additions and 2341 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,586 @@
import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js';
import { badRequest } from '../errors.js';
import type { UpstreamLlmClient } from './llmClient.js';
type SceneNpcGenerationInput = {
profile: Record<string, unknown>;
landmarkId: string;
};
type ParsedStoryNpc = {
id: string;
name: string;
title: string;
role: string;
description: string;
personality: string;
motivation: string;
relationshipHooks: string[];
tags: string[];
};
type ParsedLandmark = {
id: string;
name: string;
description: string;
dangerLevel: string;
sceneNpcIds: string[];
};
type ParsedProfile = {
name: string;
settingText: string;
storyNpcs: ParsedStoryNpc[];
landmarks: ParsedLandmark[];
};
type GeneratedNpcDraft = {
name: string;
title: string;
role: string;
description: string;
backstory: string;
personality: string;
motivation: string;
combatStyle: string;
initialAffinity: number;
relationshipHooks: string[];
tags: string[];
publicSummary: string;
chapterTeasers: string[];
chapterContents: string[];
skills: Array<{
name: string;
summary: string;
style: string;
}>;
initialItems: Array<{
name: string;
category: string;
quantity: number;
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
description: string;
tags: string[];
}>;
};
function toRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function toText(value: unknown, fallback = '') {
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
function toStringArray(value: unknown, maxCount = 12) {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) => toText(item))
.filter(Boolean)
.slice(0, maxCount);
}
function clampText(value: string, maxLength: number) {
const normalized = value.replace(/\s+/gu, ' ').trim();
if (!normalized) {
return '';
}
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}`;
}
function slugify(value: string) {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/gu, '-')
.replace(/^-+|-+$/gu, '');
return normalized || 'entry';
}
function createStableId(prefix: string, label: string, seed: string) {
return `${prefix}-${slugify(label || prefix)}-${seed}`;
}
function dedupeStrings(values: string[], maxCount = 8) {
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].slice(
0,
maxCount,
);
}
function normalizeStoryNpc(value: unknown): ParsedStoryNpc | null {
const record = toRecord(value);
if (!record) {
return null;
}
const id = toText(record.id);
const name = toText(record.name);
if (!id || !name) {
return null;
}
const title = toText(record.title);
const role = toText(record.role, title || '场景角色');
return {
id,
name,
title: title || role || '场景角色',
role,
description: toText(record.description),
personality: toText(record.personality),
motivation: toText(record.motivation),
relationshipHooks: toStringArray(record.relationshipHooks, 6),
tags: toStringArray(record.tags, 8),
};
}
function normalizeLandmark(value: unknown): ParsedLandmark | null {
const record = toRecord(value);
if (!record) {
return null;
}
const id = toText(record.id);
const name = toText(record.name);
if (!id || !name) {
return null;
}
return {
id,
name,
description: toText(record.description),
dangerLevel: toText(record.dangerLevel, '中'),
sceneNpcIds: toStringArray(record.sceneNpcIds, 12),
};
}
function normalizeProfile(value: unknown): ParsedProfile {
const record = toRecord(value);
if (!record) {
throw badRequest('profile is required');
}
const storyNpcs = Array.isArray(record.storyNpcs)
? record.storyNpcs.map(normalizeStoryNpc).filter((item): item is ParsedStoryNpc => item !== null)
: [];
const landmarks = Array.isArray(record.landmarks)
? record.landmarks.map(normalizeLandmark).filter((item): item is ParsedLandmark => item !== null)
: [];
return {
name: toText(record.name, '自定义世界'),
settingText: toText(record.settingText),
storyNpcs,
landmarks,
};
}
function ensureUniqueName(name: string, existingNames: string[]) {
const normalizedName = name.trim() || '新场景角色';
if (!existingNames.includes(normalizedName)) {
return normalizedName;
}
let index = 2;
let nextName = `${normalizedName}${index}`;
while (existingNames.includes(nextName)) {
index += 1;
nextName = `${normalizedName}${index}`;
}
return nextName;
}
function buildFallbackDraft(
profile: ParsedProfile,
landmark: ParsedLandmark,
sceneNpcs: ParsedStoryNpc[],
): GeneratedNpcDraft {
const tags = dedupeStrings([
landmark.name,
landmark.dangerLevel,
...sceneNpcs.flatMap((npc) => npc.tags),
], 4);
return {
name: `${landmark.name}来客`,
title: `${landmark.name}的观察者`,
role: `${landmark.name}的观察者`,
description: `长期活动于${landmark.name},熟悉这里的局势与暗线,能为玩家提供新的观察角度。`,
backstory: `他在${landmark.name}扎根已久,对这片区域的危险节奏、人物流动与隐藏冲突有自己的判断。`,
personality: '谨慎、敏锐,先观察再表态。',
motivation: `希望借玩家之手改变${landmark.name}当前逐渐失衡的局面。`,
combatStyle: '偏向控场与试探,不轻易暴露底牌。',
initialAffinity: 6,
relationshipHooks: dedupeStrings([
`${landmark.name}局势深度绑定`,
sceneNpcs[0] ? `${sceneNpcs[0].name}保持长期观察` : '对玩家保持试探',
'愿意交换情报,但保留关键秘密',
], 3),
tags,
publicSummary: `一名活跃于${landmark.name}的关键观察者。`,
chapterTeasers: [
'他知道这片区域最近正在发生什么。',
'他与此地某个旧事件有直接牵连。',
'他真正想推动的局面并不只是自保。',
'他手里握有改变关系网的最后筹码。',
],
chapterContents: [
`他常年在${landmark.name}周边活动,对人和事的变化极为敏感。`,
`多年前的一次变故把他和${landmark.name}牢牢绑在了一起。`,
`他表面克制,实际上一直在寻找扭转局面的机会。`,
'他保留着一张只会在局势逼近临界点时才动用的底牌。',
],
skills: [
{
name: '试探起手',
summary: '以低风险方式摸清对手意图。',
style: '试探压制',
},
{
name: '地形借势',
summary: `借助${landmark.name}环境制造主动权。`,
style: '环境协同',
},
{
name: '暗线反制',
summary: '在关键回合揭示隐藏准备,打乱对方节奏。',
style: '后手翻盘',
},
],
initialItems: [
{
name: '随身兵装',
category: '武器',
quantity: 1,
rarity: 'rare',
description: '常备的近身防护装备。',
tags: ['自定义', landmark.name],
},
{
name: '区域通行物',
category: '道具',
quantity: 1,
rarity: 'uncommon',
description: `能在${landmark.name}一带快速周转的私人物件。`,
tags: ['自定义'],
},
{
name: '情报残页',
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '记录着部分隐藏线索与往事片段。',
tags: ['线索'],
},
],
};
}
function buildPrompt(
profile: ParsedProfile,
landmark: ParsedLandmark,
sceneNpcs: ParsedStoryNpc[],
otherNpcs: ParsedStoryNpc[],
) {
const sceneNpcSummary = sceneNpcs.length
? sceneNpcs
.map(
(npc, index) =>
`${index + 1}. ${npc.name} / ${npc.title || npc.role} / ${npc.description || '无描述'} / 性格:${npc.personality || '未写'} / 动机:${npc.motivation || '未写'}`,
)
.join('\n')
: '当前场景还没有已加入 NPC。';
const reserveNpcSummary = otherNpcs.length
? otherNpcs
.slice(0, 8)
.map(
(npc, index) =>
`${index + 1}. ${npc.name} / ${npc.title || npc.role} / ${npc.description || '无描述'}`,
)
.join('\n')
: '暂无其他场景角色参考。';
const landmarkSummary = profile.landmarks
.slice(0, 10)
.map(
(entry, index) =>
`${index + 1}. ${entry.name} / 危险度:${entry.dangerLevel || '中'} / ${entry.description || '无描述'}`,
)
.join('\n');
return [
`世界名:${profile.name}`,
`世界设定:${profile.settingText || '未提供额外设定文本。'}`,
`当前目标场景:${landmark.name}`,
`场景描述:${landmark.description || '未填写'}`,
`危险度:${landmark.dangerLevel || '中'}`,
`当前场景已加入 NPC\n${sceneNpcSummary}`,
`其他可参考 NPC\n${reserveNpcSummary}`,
`世界内其他场景概览:\n${landmarkSummary}`,
'请生成 1 名适合加入当前场景的新 NPC。',
'要求:',
'- 必须与当前场景气质、危险度、已有 NPC 分工互补,不要和已有 NPC 重复。',
'- 角色要像真正可落地到游戏里的场景角色,不要写成抽象设定。',
'- 关系钩子、技能、初始物品都要可直接进入编辑器。',
'- 返回 JSON不要额外解释。',
'JSON 结构:',
'{',
' "npc": {',
' "name": "角色名",',
' "title": "头衔",',
' "role": "身份",',
' "description": "一句到两句角色描述",',
' "backstory": "背景",',
' "personality": "性格",',
' "motivation": "动机",',
' "combatStyle": "战斗风格",',
' "initialAffinity": 6,',
' "relationshipHooks": ["关系钩子1", "关系钩子2", "关系钩子3"],',
' "tags": ["标签1", "标签2", "标签3"],',
' "publicSummary": "公开背景摘要",',
' "chapterTeasers": ["表层来意", "旧事裂痕", "隐藏执念", "最终底牌"],',
' "chapterContents": ["对应正文1", "对应正文2", "对应正文3", "对应正文4"],',
' "skills": [',
' { "name": "技能1", "summary": "说明", "style": "风格" },',
' { "name": "技能2", "summary": "说明", "style": "风格" },',
' { "name": "技能3", "summary": "说明", "style": "风格" }',
' ],',
' "initialItems": [',
' { "name": "物品1", "category": "分类", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },',
' { "name": "物品2", "category": "分类", "quantity": 1, "rarity": "uncommon", "description": "说明", "tags": ["标签"] },',
' { "name": "物品3", "category": "分类", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] }',
' ]',
' }',
'}',
].join('\n');
}
function sanitizeGeneratedNpc(
rawValue: unknown,
profile: ParsedProfile,
landmark: ParsedLandmark,
fallbackDraft: GeneratedNpcDraft,
) {
const record = toRecord(rawValue);
const existingNames = profile.storyNpcs.map((npc) => npc.name);
const seed = Date.now().toString(36);
const chapterTitles = ['表层来意', '旧事裂痕', '隐藏执念', '最终底牌'];
const chapterThresholds = [6, 12, 18, 24];
const relationshipHooks = dedupeStrings(
toStringArray(record?.relationshipHooks, 6).concat(
fallbackDraft.relationshipHooks,
),
4,
);
const tags = dedupeStrings(
toStringArray(record?.tags, 8).concat(fallbackDraft.tags, landmark.name),
6,
);
const chapterTeasers = toStringArray(record?.chapterTeasers, 4);
const chapterContents = toStringArray(record?.chapterContents, 4);
const skillRecords = Array.isArray(record?.skills) ? record?.skills : [];
const itemRecords = Array.isArray(record?.initialItems) ? record?.initialItems : [];
const draft: GeneratedNpcDraft = {
name: ensureUniqueName(
toText(record?.name, fallbackDraft.name),
existingNames,
),
title: toText(record?.title, fallbackDraft.title),
role: toText(record?.role, toText(record?.title, fallbackDraft.role)),
description: clampText(
toText(record?.description, fallbackDraft.description),
120,
),
backstory: clampText(toText(record?.backstory, fallbackDraft.backstory), 260),
personality: clampText(
toText(record?.personality, fallbackDraft.personality),
100,
),
motivation: clampText(
toText(record?.motivation, fallbackDraft.motivation),
120,
),
combatStyle: clampText(
toText(record?.combatStyle, fallbackDraft.combatStyle),
100,
),
initialAffinity:
typeof record?.initialAffinity === 'number' &&
Number.isFinite(record.initialAffinity)
? Math.min(12, Math.max(1, Math.round(record.initialAffinity)))
: fallbackDraft.initialAffinity,
relationshipHooks,
tags,
publicSummary: clampText(
toText(record?.publicSummary, fallbackDraft.publicSummary),
120,
),
chapterTeasers:
chapterTeasers.length === 4
? chapterTeasers
: fallbackDraft.chapterTeasers.slice(0, 4),
chapterContents:
chapterContents.length === 4
? chapterContents
: fallbackDraft.chapterContents.slice(0, 4),
skills:
skillRecords.length >= 3
? skillRecords.slice(0, 3).map((skill, index) => {
const skillRecord = toRecord(skill);
const fallbackSkill =
fallbackDraft.skills[index] ?? fallbackDraft.skills[0];
return {
name: clampText(
toText(skillRecord?.name, fallbackSkill?.name || `技能${index + 1}`),
20,
),
summary: clampText(
toText(skillRecord?.summary, fallbackSkill?.summary || ''),
60,
),
style: clampText(
toText(skillRecord?.style, fallbackSkill?.style || ''),
20,
),
};
})
: fallbackDraft.skills,
initialItems:
itemRecords.length >= 3
? itemRecords.slice(0, 3).map((item, index) => {
const itemRecord = toRecord(item);
const fallbackItem =
fallbackDraft.initialItems[index] ?? fallbackDraft.initialItems[0];
const rarity = toText(itemRecord?.rarity, fallbackItem?.rarity || 'rare');
return {
name: clampText(
toText(itemRecord?.name, fallbackItem?.name || `物品${index + 1}`),
20,
),
category: clampText(
toText(itemRecord?.category, fallbackItem?.category || '道具'),
16,
),
quantity:
typeof itemRecord?.quantity === 'number' &&
Number.isFinite(itemRecord.quantity)
? Math.min(9, Math.max(1, Math.round(itemRecord.quantity)))
: fallbackItem?.quantity || 1,
rarity:
rarity === 'common' ||
rarity === 'uncommon' ||
rarity === 'rare' ||
rarity === 'epic' ||
rarity === 'legendary'
? rarity
: fallbackItem?.rarity || 'rare',
description: clampText(
toText(itemRecord?.description, fallbackItem?.description || ''),
80,
),
tags: dedupeStrings(
toStringArray(itemRecord?.tags, 4).concat(
fallbackItem?.tags ?? [],
),
4,
),
};
})
: fallbackDraft.initialItems,
};
return {
id: createStableId('story-npc', draft.name, seed),
name: draft.name,
title: draft.title || draft.role,
role: draft.role || draft.title,
description: draft.description,
backstory: draft.backstory,
personality: draft.personality,
motivation: draft.motivation,
combatStyle: draft.combatStyle,
initialAffinity: draft.initialAffinity,
relationshipHooks: draft.relationshipHooks,
relations: [],
tags: draft.tags,
backstoryReveal: {
publicSummary: draft.publicSummary,
chapters: chapterTitles.map((title, index) => ({
id: ['surface', 'scar', 'hidden', 'final'][index],
title,
affinityRequired: chapterThresholds[index],
teaser:
draft.chapterTeasers[index] ?? fallbackDraft.chapterTeasers[index] ?? '',
content:
draft.chapterContents[index] ??
fallbackDraft.chapterContents[index] ??
'',
contextSnippet: '',
})),
},
skills: draft.skills.map((skill, index) => ({
id: createStableId('skill', `${draft.name}-${skill.name}`, `${seed}-${index + 1}`),
name: skill.name,
summary: skill.summary,
style: skill.style,
})),
initialItems: draft.initialItems.map((item, index) => ({
id: createStableId('item', `${draft.name}-${item.name}`, `${seed}-${index + 1}`),
name: item.name,
category: item.category,
quantity: item.quantity,
rarity: item.rarity,
description: item.description,
tags: item.tags,
})),
};
}
export async function generateSceneNpcForLandmark(
llmClient: UpstreamLlmClient,
input: SceneNpcGenerationInput,
) {
const profile = normalizeProfile(input.profile);
const landmark = profile.landmarks.find((entry) => entry.id === input.landmarkId);
if (!landmark) {
throw badRequest('landmark not found');
}
const storyNpcById = new Map(profile.storyNpcs.map((npc) => [npc.id, npc]));
const sceneNpcs = landmark.sceneNpcIds
.map((npcId) => storyNpcById.get(npcId))
.filter((npc): npc is ParsedStoryNpc => Boolean(npc));
const otherNpcs = profile.storyNpcs.filter(
(npc) => !landmark.sceneNpcIds.includes(npc.id),
);
const fallbackDraft = buildFallbackDraft(profile, landmark, sceneNpcs);
try {
const content = await llmClient.requestMessageContent({
systemPrompt:
'你是游戏世界编辑器的角色生成器。你必须只返回可解析 JSON不要输出解释、前言或 markdown 代码块之外的额外内容。',
userPrompt: buildPrompt(profile, landmark, sceneNpcs, otherNpcs),
debugLabel: 'custom-world-scene-npc',
});
const parsed = parseJsonResponseText(content);
const parsedRecord = toRecord(parsed);
const npcRecord = parsedRecord?.npc ?? parsed;
return sanitizeGeneratedNpc(npcRecord, profile, landmark, fallbackDraft);
} catch {
return sanitizeGeneratedNpc(fallbackDraft, profile, landmark, fallbackDraft);
}
}

View File

@@ -1,12 +1,12 @@
import assert from 'node:assert/strict';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import fs from 'node:fs';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import type { AppContext } from '../context.js';
import { type AppConfig } from '../config.js';
import type { AppContext } from '../context.js';
import { generateSceneImage } from './sceneImageService.js';
const PNG_BUFFER = Buffer.from(
@@ -24,7 +24,7 @@ function createTestConfig(
dashScope: {
baseUrl: dashScopeBaseUrl,
apiKey: 'test-dashscope-key',
imageModel: 'wan2.7-image',
imageModel: 'wan2.2-t2i-flash',
requestTimeoutMs: 5_000,
},
} as AppConfig;
@@ -92,11 +92,8 @@ async function withHttpServer<T>(
}
}
test('generateSceneImage uploads a public reference image as a data url and saves the generated scene', async () => {
test('generateSceneImage uses wan2.2-t2i-flash text-to-image payload and saves the generated scene', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-scene-image-'));
const publicDir = path.join(tempRoot, 'public');
fs.mkdirSync(path.join(publicDir, 'scene_bg'), { recursive: true });
fs.writeFileSync(path.join(publicDir, 'scene_bg', 'reference-layout.png'), PNG_BUFFER);
const capturedRequests: Array<{
pathname: string;
@@ -164,7 +161,6 @@ test('generateSceneImage uploads a public reference image as a data url and save
profileId: 'world-1',
landmarkName: '旧港灯塔',
landmarkId: 'landmark-1',
referenceImageSrc: '/scene_bg/reference-layout.png',
});
assert.equal(result.ok, true);
@@ -177,6 +173,7 @@ test('generateSceneImage uploads a public reference image as a data url and save
assert.ok(createRequest?.bodyText);
const createPayload = JSON.parse(createRequest.bodyText) as {
model: string;
input: {
messages: Array<{
content: Array<{ text?: string; image?: string }>;
@@ -188,8 +185,9 @@ test('generateSceneImage uploads a public reference image as a data url and save
};
const content = createPayload.input.messages[0]?.content ?? [];
assert.equal(createPayload.model, 'wan2.2-t2i-flash');
assert.equal(content[0]?.text, '海雾港口像素风场景');
assert.match(content[1]?.image ?? '', /^data:image\/png;base64,/u);
assert.equal(content.length, 1);
assert.equal(createPayload.parameters.negative_prompt, '模糊');
const savedImagePath = path.join(tempRoot, 'public', result.imageSrc.slice(1));
@@ -197,3 +195,105 @@ test('generateSceneImage uploads a public reference image as a data url and save
},
);
});
test('generateSceneImage uses qwen-image-2.0 edit flow when a reference image is provided', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-scene-image-'));
const publicDir = path.join(tempRoot, 'public');
fs.mkdirSync(path.join(publicDir, 'scene_bg'), { recursive: true });
fs.writeFileSync(path.join(publicDir, 'scene_bg', 'reference-layout.png'), PNG_BUFFER);
const capturedRequests: Array<{
pathname: string;
bodyText?: string;
}> = [];
await withHttpServer(
(baseUrl) => async (req, res) => {
const url = new URL(req.url || '/', baseUrl);
const bodyText =
req.method === 'POST' ? (await readRequestBody(req)).toString('utf8') : undefined;
capturedRequests.push({
pathname: url.pathname,
bodyText,
});
if (
req.method === 'POST' &&
url.pathname === '/api/v1/services/aigc/multimodal-generation/generation'
) {
sendJson(res, {
output: {
choices: [
{
message: {
content: [
{
image: `${baseUrl}/downloads/reference-scene.png`,
},
],
},
},
],
},
});
return;
}
if (req.method === 'GET' && url.pathname === '/downloads/reference-scene.png') {
res.statusCode = 200;
res.setHeader('Content-Type', 'image/png');
res.end(PNG_BUFFER);
return;
}
res.statusCode = 404;
res.end('not found');
},
async (dashScopeBaseUrl) => {
const context = {
config: createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`),
} as AppContext;
const result = await generateSceneImage(context, {
prompt: '废墟月台像素风场景',
negativePrompt: '模糊',
size: '1280*720',
worldName: '碎轨边境',
profileId: 'world-2',
landmarkName: '裂轨月台',
landmarkId: 'landmark-2',
referenceImageSrc: '/scene_bg/reference-layout.png',
});
assert.equal(result.ok, true);
assert.equal(result.model, 'qwen-image-2.0');
assert.match(result.taskId, /^scene-edit-/u);
assert.equal(
capturedRequests.some(
(entry) => entry.pathname === '/api/v1/tasks/scene-task-1',
),
false,
);
const createRequest = capturedRequests.find(
(entry) =>
entry.pathname === '/api/v1/services/aigc/multimodal-generation/generation',
);
assert.ok(createRequest?.bodyText);
const createPayload = JSON.parse(createRequest.bodyText) as {
model: string;
input: {
messages: Array<{
content: Array<{ text?: string; image?: string }>;
}>;
};
};
const content = createPayload.input.messages[0]?.content ?? [];
assert.equal(createPayload.model, 'qwen-image-2.0');
assert.match(content[0]?.image ?? '', /^data:image\/png;base64,/u);
assert.equal(content[1]?.text, '废墟月台像素风场景');
},
);
});

View File

@@ -19,6 +19,8 @@ export const sceneImageSchema = z.object({
landmarkId: z.string().trim().optional().default(''),
referenceImageSrc: z.string().trim().optional().default(''),
});
const TEXT_TO_IMAGE_SCENE_MODEL = 'wan2.2-t2i-flash';
const REFERENCE_IMAGE_SCENE_MODEL = 'qwen-image-2.0';
function parseImageDataUrl(source: string) {
const matched = /^data:(image\/[^;]+);base64,(.+)$/u.exec(source);
@@ -122,40 +124,72 @@ function extractImageUrls(payload: Record<string, unknown>) {
return [...new Set(urls)];
}
function ensurePayload(
payload: z.infer<typeof sceneImageSchema>,
defaultModel: string,
) {
if (!payload.landmarkName && !payload.landmarkId) {
throw badRequest('landmarkName 或 landmarkId 至少要提供一个');
async function createSceneImageTask(params: {
baseUrl: string;
apiKey: string;
payload: z.infer<typeof sceneImageSchema>;
}) {
const { baseUrl, apiKey, payload } = params;
const response = await fetch(`${baseUrl}/services/aigc/image-generation/generation`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'X-DashScope-Async': 'enable',
},
body: JSON.stringify({
model: payload.model,
input: {
messages: [
{
role: 'user',
content: [{ text: payload.prompt }],
},
],
},
parameters: {
n: 1,
size: payload.size,
prompt_extend: true,
watermark: false,
...(payload.negativePrompt
? { negative_prompt: payload.negativePrompt }
: {}),
},
}),
});
const responseText = await response.text();
if (!response.ok) {
return {
ok: false as const,
errorMessage: extractApiErrorMessage(
responseText,
'创建场景图片生成任务失败',
),
};
}
return {
...payload,
model: payload.model || defaultModel,
ok: true as const,
payload: JSON.parse(responseText) as Record<string, unknown>,
};
}
export async function generateSceneImage(
context: AppContext,
input: z.infer<typeof sceneImageSchema>,
) {
const payload = ensurePayload(input, context.config.dashScope.imageModel);
const baseUrl = context.config.dashScope.baseUrl.replace(/\/+$/u, '');
const referenceImage = payload.referenceImageSrc
? await resolveReferenceImageAsDataUrl(
context.config.projectRoot,
payload.referenceImageSrc,
)
: '';
const createResponse = await fetch(
`${baseUrl}/services/aigc/image-generation/generation`,
async function createSceneImageFromReference(params: {
baseUrl: string;
apiKey: string;
payload: z.infer<typeof sceneImageSchema>;
referenceImage: string;
}) {
const { baseUrl, apiKey, payload, referenceImage } = params;
const response = await fetch(
`${baseUrl}/services/aigc/multimodal-generation/generation`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${context.config.dashScope.apiKey}`,
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'X-DashScope-Async': 'enable',
},
body: JSON.stringify({
model: payload.model,
@@ -163,10 +197,7 @@ export async function generateSceneImage(
messages: [
{
role: 'user',
content: [
{ text: payload.prompt },
...(referenceImage ? [{ image: referenceImage }] : []),
],
content: [{ image: referenceImage }, { text: payload.prompt }],
},
],
},
@@ -182,56 +213,65 @@ export async function generateSceneImage(
}),
},
);
const createText = await createResponse.text();
if (!createResponse.ok) {
throw badRequest(
extractApiErrorMessage(createText, '创建场景图片生成任务失败'),
);
}
const createPayload = JSON.parse(createText) as Record<string, unknown>;
const taskId = extractTaskId(createPayload);
if (!taskId) {
throw badRequest('场景图片生成任务未返回 task_id');
}
const deadline = Date.now() + context.config.dashScope.requestTimeoutMs;
let imageUrl = '';
let actualPrompt = '';
while (Date.now() < deadline) {
const pollResponse = await fetch(`${baseUrl}/tasks/${taskId}`, {
headers: {
Authorization: `Bearer ${context.config.dashScope.apiKey}`,
},
});
const pollText = await pollResponse.text();
if (!pollResponse.ok) {
throw badRequest(
extractApiErrorMessage(pollText, '查询场景图片任务失败'),
);
}
const pollPayload = JSON.parse(pollText) as Record<string, unknown>;
const status = findFirstStringByKey(pollPayload, 'task_status').trim();
if (status === 'SUCCEEDED') {
imageUrl = extractImageUrls(pollPayload)[0] ?? '';
actualPrompt = findFirstStringByKey(pollPayload, 'actual_prompt').trim();
break;
}
if (status === 'FAILED' || status === 'UNKNOWN') {
throw badRequest(
extractApiErrorMessage(pollText, '场景图片生成任务失败'),
);
}
await new Promise((resolve) => setTimeout(resolve, 2000));
const responseText = await response.text();
if (!response.ok) {
return {
ok: false as const,
errorMessage: extractApiErrorMessage(
responseText,
'创建参考图场景编辑任务失败',
),
};
}
const responsePayload = JSON.parse(responseText) as Record<string, unknown>;
const imageUrl = extractImageUrls(responsePayload)[0] ?? '';
if (!imageUrl) {
throw badRequest('场景图片生成超时或未返回图片地址');
return {
ok: false as const,
errorMessage: '参考图场景编辑未返回图片地址',
};
}
return {
ok: true as const,
imageUrl,
actualPrompt: findFirstStringByKey(responsePayload, 'actual_prompt').trim(),
taskId: `scene-edit-${Date.now()}`,
};
}
function ensurePayload(
payload: z.infer<typeof sceneImageSchema>,
_defaultModel: string,
) {
if (!payload.landmarkName && !payload.landmarkId) {
throw badRequest('landmarkName 或 landmarkId 至少要提供一个');
}
const referenceImageSrc =
typeof payload.referenceImageSrc === 'string'
? payload.referenceImageSrc.trim()
: '';
return {
...payload,
referenceImageSrc,
model: referenceImageSrc
? REFERENCE_IMAGE_SCENE_MODEL
: TEXT_TO_IMAGE_SCENE_MODEL,
};
}
async function saveSceneImageAsset(params: {
context: AppContext;
payload: z.infer<typeof sceneImageSchema>;
imageUrl: string;
taskId: string;
actualPrompt: string;
}) {
const { context, payload, imageUrl, taskId, actualPrompt } = params;
const imageResponse = await fetch(imageUrl);
if (!imageResponse.ok) {
throw badRequest('下载生成图片失败');
@@ -295,3 +335,99 @@ export async function generateSceneImage(
actualPrompt,
};
}
export async function generateSceneImage(
context: AppContext,
input: z.infer<typeof sceneImageSchema>,
) {
const payload = ensurePayload(input, context.config.dashScope.imageModel);
const baseUrl = context.config.dashScope.baseUrl.replace(/\/+$/u, '');
const referenceImage = payload.referenceImageSrc.trim()
? await resolveReferenceImageAsDataUrl(
context.config.projectRoot,
payload.referenceImageSrc,
)
: '';
if (referenceImage) {
const referenceResult = await createSceneImageFromReference({
baseUrl,
apiKey: context.config.dashScope.apiKey,
payload,
referenceImage,
});
if (!referenceResult.ok) {
throw badRequest(referenceResult.errorMessage);
}
return saveSceneImageAsset({
context,
payload,
imageUrl: referenceResult.imageUrl,
taskId: referenceResult.taskId,
actualPrompt: referenceResult.actualPrompt,
});
}
const createTaskResult = await createSceneImageTask({
baseUrl,
apiKey: context.config.dashScope.apiKey,
payload,
});
if (!createTaskResult.ok) {
throw badRequest(createTaskResult.errorMessage);
}
const createPayload = createTaskResult.payload;
const taskId = extractTaskId(createPayload);
if (!taskId) {
throw badRequest('场景图片生成任务未返回 task_id');
}
const deadline = Date.now() + context.config.dashScope.requestTimeoutMs;
let imageUrl = '';
let actualPrompt = '';
while (Date.now() < deadline) {
const pollResponse = await fetch(`${baseUrl}/tasks/${taskId}`, {
headers: {
Authorization: `Bearer ${context.config.dashScope.apiKey}`,
},
});
const pollText = await pollResponse.text();
if (!pollResponse.ok) {
throw badRequest(
extractApiErrorMessage(pollText, '查询场景图片任务失败'),
);
}
const pollPayload = JSON.parse(pollText) as Record<string, unknown>;
const status = findFirstStringByKey(pollPayload, 'task_status').trim();
if (status === 'SUCCEEDED') {
imageUrl = extractImageUrls(pollPayload)[0] ?? '';
actualPrompt = findFirstStringByKey(pollPayload, 'actual_prompt').trim();
break;
}
if (status === 'FAILED' || status === 'UNKNOWN') {
throw badRequest(
extractApiErrorMessage(pollText, '场景图片生成任务失败'),
);
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
if (!imageUrl) {
throw badRequest('场景图片生成超时或未返回图片地址');
}
return saveSceneImageAsset({
context,
payload,
imageUrl,
taskId,
actualPrompt,
});
}