Fix DashScope env loading for scene image generation
This commit is contained in:
@@ -58,8 +58,10 @@ import {
|
||||
buildCustomWorldRoleOutlineBatchPrompt,
|
||||
buildCustomWorldSceneImagePrompt,
|
||||
type CustomWorldGenerationFramework,
|
||||
type CustomWorldGenerationLandmarkOutline,
|
||||
type CustomWorldGenerationRoleBatchStage,
|
||||
type CustomWorldGenerationRoleBatchType,
|
||||
type CustomWorldGenerationRoleOutline,
|
||||
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,
|
||||
MIN_CUSTOM_WORLD_LANDMARK_COUNT,
|
||||
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||||
@@ -106,7 +108,7 @@ type RawOptionItem = {
|
||||
|
||||
type MergeableCustomWorldRoleEntry = {
|
||||
name: string;
|
||||
} & Record<string, unknown>;
|
||||
};
|
||||
|
||||
const CUSTOM_WORLD_SCENE_IMAGE_API_BASE_URL =
|
||||
import.meta.env.VITE_SCENE_IMAGE_PROXY_BASE_URL ||
|
||||
@@ -159,6 +161,157 @@ export interface CustomWorldSceneImageResult {
|
||||
actualPrompt?: string;
|
||||
}
|
||||
|
||||
const CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS = [
|
||||
{
|
||||
id: 'framework',
|
||||
label: '世界框架',
|
||||
detail: '解析设定文本,确定世界主题、主目标与基础模板。',
|
||||
total: 1,
|
||||
weight: 1,
|
||||
},
|
||||
{
|
||||
id: 'playable-outline',
|
||||
label: '可扮演角色骨架',
|
||||
detail: '先生成可扮演角色名单与核心定位。',
|
||||
total: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||||
weight: Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT /
|
||||
CUSTOM_WORLD_FRAMEWORK_PLAYABLE_OUTLINE_BATCH_SIZE,
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'story-outline',
|
||||
label: '场景角色骨架',
|
||||
detail: '补齐世界里的关键角色与势力关系。',
|
||||
total: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
|
||||
weight: Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
MIN_CUSTOM_WORLD_STORY_NPC_COUNT /
|
||||
CUSTOM_WORLD_FRAMEWORK_STORY_OUTLINE_BATCH_SIZE,
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'landmark-seed',
|
||||
label: '场景骨架',
|
||||
detail: '生成地标、区域描述与危险等级。',
|
||||
total: MIN_CUSTOM_WORLD_LANDMARK_COUNT,
|
||||
weight: Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
MIN_CUSTOM_WORLD_LANDMARK_COUNT /
|
||||
CUSTOM_WORLD_FRAMEWORK_LANDMARK_SEED_BATCH_SIZE,
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'landmark-network',
|
||||
label: '场景连接',
|
||||
detail: '建立场景连接关系与场景内角色分布。',
|
||||
total: MIN_CUSTOM_WORLD_LANDMARK_COUNT,
|
||||
weight: Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
MIN_CUSTOM_WORLD_LANDMARK_COUNT /
|
||||
CUSTOM_WORLD_FRAMEWORK_LANDMARK_NETWORK_BATCH_SIZE,
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'playable-narrative',
|
||||
label: '可扮演角色叙事',
|
||||
detail: '为可扮演角色补充公开背景、动机与风格。',
|
||||
total: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||||
weight: Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT / CUSTOM_WORLD_PLAYABLE_BATCH_SIZE,
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'playable-dossier',
|
||||
label: '可扮演角色档案',
|
||||
detail: '补齐技能、好感章节与初始携带信息。',
|
||||
total: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||||
weight: Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT / CUSTOM_WORLD_PLAYABLE_BATCH_SIZE,
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'story-narrative',
|
||||
label: '场景角色叙事',
|
||||
detail: '扩写场景角色的关系钩子与叙事位置。',
|
||||
total: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
|
||||
weight: Math.max(
|
||||
1,
|
||||
Math.ceil(MIN_CUSTOM_WORLD_STORY_NPC_COUNT / CUSTOM_WORLD_STORY_BATCH_SIZE),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'story-dossier',
|
||||
label: '场景角色档案',
|
||||
detail: '补齐场景角色档案与互动素材。',
|
||||
total: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
|
||||
weight: Math.max(
|
||||
1,
|
||||
Math.ceil(MIN_CUSTOM_WORLD_STORY_NPC_COUNT / CUSTOM_WORLD_STORY_BATCH_SIZE),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'finalize',
|
||||
label: '归档世界',
|
||||
detail: '整理最终世界档案并做完整性校验。',
|
||||
total: 1,
|
||||
weight: 1,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type CustomWorldGenerationStageId =
|
||||
(typeof CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS)[number]['id'];
|
||||
|
||||
export interface CustomWorldGenerationStep {
|
||||
id: CustomWorldGenerationStageId;
|
||||
label: string;
|
||||
detail: string;
|
||||
completed: number;
|
||||
total: number;
|
||||
status: 'pending' | 'active' | 'completed';
|
||||
}
|
||||
|
||||
export interface CustomWorldGenerationProgress {
|
||||
phaseId: CustomWorldGenerationStageId;
|
||||
phaseLabel: string;
|
||||
phaseDetail: string;
|
||||
batchLabel?: string;
|
||||
overallProgress: number;
|
||||
completedWeight: number;
|
||||
totalWeight: number;
|
||||
elapsedMs: number;
|
||||
estimatedRemainingMs: number | null;
|
||||
activeStepIndex: number;
|
||||
steps: CustomWorldGenerationStep[];
|
||||
}
|
||||
|
||||
export interface GenerateCustomWorldProfileOptions {
|
||||
onProgress?: (progress: CustomWorldGenerationProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
class CustomWorldGenerationAbortedError extends Error {
|
||||
constructor(message = '世界生成已中断。') {
|
||||
super(message);
|
||||
this.name = 'CustomWorldGenerationAbortedError';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeApiErrorMessage(
|
||||
responseText: string,
|
||||
fallbackMessage: string,
|
||||
@@ -312,14 +465,212 @@ function appendUniqueNamedEntries<T extends MergeableCustomWorldRoleEntry>(
|
||||
return merged;
|
||||
}
|
||||
|
||||
const CUSTOM_WORLD_GENERATION_STAGE_MAP = new Map(
|
||||
CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.map((stage) => [stage.id, stage]),
|
||||
);
|
||||
const CUSTOM_WORLD_GENERATION_TOTAL_WEIGHT =
|
||||
CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.reduce(
|
||||
(sum, stage) => sum + stage.weight,
|
||||
0,
|
||||
);
|
||||
|
||||
function getCustomWorldGenerationStageIdForRoleOutline(
|
||||
roleType: CustomWorldGenerationRoleBatchType,
|
||||
): CustomWorldGenerationStageId {
|
||||
return roleType === 'playable' ? 'playable-outline' : 'story-outline';
|
||||
}
|
||||
|
||||
function getCustomWorldGenerationStageIdForRoleExpansion(
|
||||
roleType: CustomWorldGenerationRoleBatchType,
|
||||
stage: CustomWorldGenerationRoleBatchStage,
|
||||
): CustomWorldGenerationStageId {
|
||||
if (roleType === 'playable') {
|
||||
return stage === 'narrative'
|
||||
? 'playable-narrative'
|
||||
: 'playable-dossier';
|
||||
}
|
||||
|
||||
return stage === 'narrative' ? 'story-narrative' : 'story-dossier';
|
||||
}
|
||||
|
||||
function throwIfCustomWorldGenerationAborted(signal?: AbortSignal) {
|
||||
if (!signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: new CustomWorldGenerationAbortedError();
|
||||
}
|
||||
|
||||
function isCustomWorldGenerationAbortLikeError(error: unknown) {
|
||||
return (
|
||||
error instanceof CustomWorldGenerationAbortedError ||
|
||||
(typeof DOMException !== 'undefined' &&
|
||||
error instanceof DOMException &&
|
||||
error.name === 'AbortError')
|
||||
);
|
||||
}
|
||||
|
||||
function createCustomWorldGenerationReporter(
|
||||
onProgress?: GenerateCustomWorldProfileOptions['onProgress'],
|
||||
) {
|
||||
const startedAt = performance.now();
|
||||
const completedByStage = Object.fromEntries(
|
||||
CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.map((stage) => [stage.id, 0]),
|
||||
) as Record<CustomWorldGenerationStageId, number>;
|
||||
|
||||
const emit = (
|
||||
stageId: CustomWorldGenerationStageId,
|
||||
options: Partial<{
|
||||
completed: number;
|
||||
phaseDetail: string;
|
||||
batchLabel: string;
|
||||
}> = {},
|
||||
) => {
|
||||
const stage = CUSTOM_WORLD_GENERATION_STAGE_MAP.get(stageId);
|
||||
if (!stage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof options.completed === 'number') {
|
||||
completedByStage[stageId] = Math.max(
|
||||
0,
|
||||
Math.min(stage.total, options.completed),
|
||||
);
|
||||
}
|
||||
|
||||
const steps = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.map((item) => {
|
||||
const completed = Math.max(
|
||||
0,
|
||||
Math.min(item.total, completedByStage[item.id]),
|
||||
);
|
||||
return {
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
detail: item.detail,
|
||||
completed,
|
||||
total: item.total,
|
||||
status:
|
||||
completed >= item.total
|
||||
? 'completed'
|
||||
: item.id === stageId
|
||||
? 'active'
|
||||
: 'pending',
|
||||
} satisfies CustomWorldGenerationStep;
|
||||
});
|
||||
|
||||
const completedWeight = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.reduce(
|
||||
(sum, item) =>
|
||||
sum +
|
||||
(completedByStage[item.id] / item.total || 0) * item.weight,
|
||||
0,
|
||||
);
|
||||
const progressFraction =
|
||||
CUSTOM_WORLD_GENERATION_TOTAL_WEIGHT > 0
|
||||
? completedWeight / CUSTOM_WORLD_GENERATION_TOTAL_WEIGHT
|
||||
: 0;
|
||||
const elapsedMs = Math.max(0, performance.now() - startedAt);
|
||||
const estimatedRemainingMs =
|
||||
progressFraction > 0 && progressFraction < 1
|
||||
? Math.max(
|
||||
0,
|
||||
Math.round(elapsedMs / progressFraction - elapsedMs),
|
||||
)
|
||||
: progressFraction >= 1
|
||||
? 0
|
||||
: null;
|
||||
|
||||
onProgress?.({
|
||||
phaseId: stage.id,
|
||||
phaseLabel: stage.label,
|
||||
phaseDetail: options.phaseDetail ?? stage.detail,
|
||||
batchLabel: options.batchLabel,
|
||||
overallProgress: Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round(progressFraction * 100)),
|
||||
),
|
||||
completedWeight,
|
||||
totalWeight: CUSTOM_WORLD_GENERATION_TOTAL_WEIGHT,
|
||||
elapsedMs: Math.round(elapsedMs),
|
||||
estimatedRemainingMs,
|
||||
activeStepIndex: CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.findIndex(
|
||||
(item) => item.id === stage.id,
|
||||
),
|
||||
steps,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
begin(
|
||||
stageId: CustomWorldGenerationStageId,
|
||||
options: Partial<{
|
||||
phaseDetail: string;
|
||||
batchLabel: string;
|
||||
}> = {},
|
||||
) {
|
||||
emit(stageId, {
|
||||
completed: completedByStage[stageId],
|
||||
...options,
|
||||
});
|
||||
},
|
||||
update(
|
||||
stageId: CustomWorldGenerationStageId,
|
||||
completed: number,
|
||||
options: Partial<{
|
||||
phaseDetail: string;
|
||||
batchLabel: string;
|
||||
}> = {},
|
||||
) {
|
||||
emit(stageId, {
|
||||
completed,
|
||||
...options,
|
||||
});
|
||||
},
|
||||
complete(
|
||||
stageId: CustomWorldGenerationStageId,
|
||||
options: Partial<{
|
||||
phaseDetail: string;
|
||||
batchLabel: string;
|
||||
}> = {},
|
||||
) {
|
||||
const stage = CUSTOM_WORLD_GENERATION_STAGE_MAP.get(stageId);
|
||||
if (!stage) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit(stageId, {
|
||||
completed: stage.total,
|
||||
...options,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type CustomWorldGenerationReporter = ReturnType<
|
||||
typeof createCustomWorldGenerationReporter
|
||||
>;
|
||||
|
||||
async function generateCustomWorldRoleOutlineEntries(params: {
|
||||
framework: CustomWorldGenerationFramework;
|
||||
roleType: CustomWorldGenerationRoleBatchType;
|
||||
totalCount: number;
|
||||
batchSize: number;
|
||||
reporter?: CustomWorldGenerationReporter;
|
||||
signal?: AbortSignal;
|
||||
}) {
|
||||
const { framework, roleType, totalCount, batchSize } = params;
|
||||
let mergedEntries: MergeableCustomWorldRoleEntry[] = [];
|
||||
const {
|
||||
framework,
|
||||
roleType,
|
||||
totalCount,
|
||||
batchSize,
|
||||
reporter = createCustomWorldGenerationReporter(),
|
||||
signal,
|
||||
} = params;
|
||||
const stageId = getCustomWorldGenerationStageIdForRoleOutline(roleType);
|
||||
const plannedBatchCount = Math.max(1, Math.ceil(totalCount / batchSize));
|
||||
const roleLabel = roleType === 'playable' ? '可扮演角色' : '场景角色';
|
||||
let mergedEntries: CustomWorldGenerationRoleOutline[] = [];
|
||||
const maxBatchAttempts = Math.max(2, Math.ceil(totalCount / batchSize) + 2);
|
||||
|
||||
for (
|
||||
@@ -327,7 +678,12 @@ async function generateCustomWorldRoleOutlineEntries(params: {
|
||||
batchIndex < maxBatchAttempts && mergedEntries.length < totalCount;
|
||||
batchIndex += 1
|
||||
) {
|
||||
throwIfCustomWorldGenerationAborted(signal);
|
||||
const batchCount = Math.min(batchSize, totalCount - mergedEntries.length);
|
||||
reporter.update(stageId, mergedEntries.length, {
|
||||
phaseDetail: `正在生成${roleLabel},已完成 ${mergedEntries.length}/${totalCount}。`,
|
||||
batchLabel: `第 ${Math.min(batchIndex + 1, plannedBatchCount)} / ${plannedBatchCount} 批`,
|
||||
});
|
||||
const batchRaw = await requestCustomWorldJsonStage({
|
||||
userPrompt: buildCustomWorldRoleOutlineBatchPrompt({
|
||||
framework,
|
||||
@@ -345,6 +701,7 @@ async function generateCustomWorldRoleOutlineEntries(params: {
|
||||
}),
|
||||
repairDebugLabel: `custom-world-${roleType}-outline-batch-${batchIndex + 1}-json-repair`,
|
||||
emptyResponseMessage: `自定义世界${roleType === 'playable' ? '可扮演角色' : '场景角色'}名单批次 ${batchIndex + 1} 生成失败:模型没有返回有效内容。`,
|
||||
signal,
|
||||
});
|
||||
|
||||
mergedEntries = appendUniqueNamedEntries(
|
||||
@@ -352,6 +709,10 @@ async function generateCustomWorldRoleOutlineEntries(params: {
|
||||
normalizeCustomWorldGenerationRoleOutlineBatch(batchRaw, roleType),
|
||||
totalCount,
|
||||
);
|
||||
reporter.update(stageId, mergedEntries.length, {
|
||||
phaseDetail: `正在生成${roleLabel},已完成 ${mergedEntries.length}/${totalCount}。`,
|
||||
batchLabel: `第 ${Math.min(batchIndex + 1, plannedBatchCount)} / ${plannedBatchCount} 批`,
|
||||
});
|
||||
|
||||
if (batchCount <= 0) {
|
||||
break;
|
||||
@@ -365,9 +726,18 @@ async function generateCustomWorldLandmarkSeedEntries(params: {
|
||||
framework: CustomWorldGenerationFramework;
|
||||
totalCount: number;
|
||||
batchSize: number;
|
||||
reporter?: CustomWorldGenerationReporter;
|
||||
signal?: AbortSignal;
|
||||
}) {
|
||||
const { framework, totalCount, batchSize } = params;
|
||||
let mergedEntries: MergeableCustomWorldRoleEntry[] = [];
|
||||
const {
|
||||
framework,
|
||||
totalCount,
|
||||
batchSize,
|
||||
reporter = createCustomWorldGenerationReporter(),
|
||||
signal,
|
||||
} = params;
|
||||
const plannedBatchCount = Math.max(1, Math.ceil(totalCount / batchSize));
|
||||
let mergedEntries: CustomWorldGenerationLandmarkOutline[] = [];
|
||||
const maxBatchAttempts = Math.max(2, Math.ceil(totalCount / batchSize) + 2);
|
||||
|
||||
for (
|
||||
@@ -375,7 +745,12 @@ async function generateCustomWorldLandmarkSeedEntries(params: {
|
||||
batchIndex < maxBatchAttempts && mergedEntries.length < totalCount;
|
||||
batchIndex += 1
|
||||
) {
|
||||
throwIfCustomWorldGenerationAborted(signal);
|
||||
const batchCount = Math.min(batchSize, totalCount - mergedEntries.length);
|
||||
reporter.update('landmark-seed', mergedEntries.length, {
|
||||
phaseDetail: `正在生成场景骨架,已完成 ${mergedEntries.length}/${totalCount}。`,
|
||||
batchLabel: `第 ${Math.min(batchIndex + 1, plannedBatchCount)} / ${plannedBatchCount} 批`,
|
||||
});
|
||||
const batchRaw = await requestCustomWorldJsonStage({
|
||||
userPrompt: buildCustomWorldLandmarkSeedBatchPrompt({
|
||||
framework,
|
||||
@@ -391,6 +766,7 @@ async function generateCustomWorldLandmarkSeedEntries(params: {
|
||||
}),
|
||||
repairDebugLabel: `custom-world-landmark-seed-batch-${batchIndex + 1}-json-repair`,
|
||||
emptyResponseMessage: `自定义世界场景骨架批次 ${batchIndex + 1} 生成失败:模型没有返回有效内容。`,
|
||||
signal,
|
||||
});
|
||||
|
||||
mergedEntries = appendUniqueNamedEntries(
|
||||
@@ -398,6 +774,10 @@ async function generateCustomWorldLandmarkSeedEntries(params: {
|
||||
normalizeCustomWorldGenerationLandmarkOutlineBatch(batchRaw),
|
||||
totalCount,
|
||||
);
|
||||
reporter.update('landmark-seed', mergedEntries.length, {
|
||||
phaseDetail: `正在生成场景骨架,已完成 ${mergedEntries.length}/${totalCount}。`,
|
||||
batchLabel: `第 ${Math.min(batchIndex + 1, plannedBatchCount)} / ${plannedBatchCount} 批`,
|
||||
});
|
||||
|
||||
if (batchCount <= 0) {
|
||||
break;
|
||||
@@ -410,16 +790,35 @@ async function generateCustomWorldLandmarkSeedEntries(params: {
|
||||
async function expandCustomWorldLandmarkNetworkEntries(params: {
|
||||
framework: CustomWorldGenerationFramework;
|
||||
storyNpcs: CustomWorldGenerationFramework['storyNpcs'];
|
||||
baseEntries: MergeableCustomWorldRoleEntry[];
|
||||
baseEntries: CustomWorldGenerationLandmarkOutline[];
|
||||
batchSize: number;
|
||||
reporter?: CustomWorldGenerationReporter;
|
||||
signal?: AbortSignal;
|
||||
}) {
|
||||
const { framework, storyNpcs, baseEntries, batchSize } = params;
|
||||
const {
|
||||
framework,
|
||||
storyNpcs,
|
||||
baseEntries,
|
||||
batchSize,
|
||||
reporter = createCustomWorldGenerationReporter(),
|
||||
signal,
|
||||
} = params;
|
||||
const plannedBatchCount = Math.max(
|
||||
1,
|
||||
Math.ceil(framework.landmarks.length / batchSize),
|
||||
);
|
||||
let mergedEntries = baseEntries.map((entry) => ({ ...entry }));
|
||||
let processedCount = 0;
|
||||
|
||||
for (const [batchIndex, landmarkBatch] of chunkArray(
|
||||
framework.landmarks,
|
||||
batchSize,
|
||||
).entries()) {
|
||||
throwIfCustomWorldGenerationAborted(signal);
|
||||
reporter.update('landmark-network', processedCount, {
|
||||
phaseDetail: `正在建立场景连接,已完成 ${processedCount}/${framework.landmarks.length}。`,
|
||||
batchLabel: `第 ${batchIndex + 1} / ${plannedBatchCount} 批`,
|
||||
});
|
||||
const batchRaw = await requestCustomWorldJsonStage({
|
||||
userPrompt: buildCustomWorldLandmarkNetworkBatchPrompt({
|
||||
framework,
|
||||
@@ -434,6 +833,7 @@ async function expandCustomWorldLandmarkNetworkEntries(params: {
|
||||
}),
|
||||
repairDebugLabel: `custom-world-landmark-network-batch-${batchIndex + 1}-json-repair`,
|
||||
emptyResponseMessage: `自定义世界场景连接批次 ${batchIndex + 1} 生成失败:模型没有返回有效内容。`,
|
||||
signal,
|
||||
});
|
||||
|
||||
mergedEntries = mergeRoleBatchDetails(
|
||||
@@ -442,6 +842,14 @@ async function expandCustomWorldLandmarkNetworkEntries(params: {
|
||||
(entry) => ({ ...entry }),
|
||||
),
|
||||
);
|
||||
processedCount = Math.min(
|
||||
framework.landmarks.length,
|
||||
processedCount + landmarkBatch.length,
|
||||
);
|
||||
reporter.update('landmark-network', processedCount, {
|
||||
phaseDetail: `正在建立场景连接,已完成 ${processedCount}/${framework.landmarks.length}。`,
|
||||
batchLabel: `第 ${batchIndex + 1} / ${plannedBatchCount} 批`,
|
||||
});
|
||||
}
|
||||
|
||||
return mergedEntries;
|
||||
@@ -454,19 +862,45 @@ async function expandCustomWorldRoleEntries<
|
||||
roleType: CustomWorldGenerationRoleBatchType;
|
||||
baseEntries: T[];
|
||||
batchSize: number;
|
||||
reporter?: CustomWorldGenerationReporter;
|
||||
signal?: AbortSignal;
|
||||
}) {
|
||||
const { framework, roleType, baseEntries, batchSize } = params;
|
||||
const {
|
||||
framework,
|
||||
roleType,
|
||||
baseEntries,
|
||||
batchSize,
|
||||
reporter = createCustomWorldGenerationReporter(),
|
||||
signal,
|
||||
} = params;
|
||||
const roleBatchSource =
|
||||
roleType === 'playable' ? framework.playableNpcs : framework.storyNpcs;
|
||||
const roleLabel = roleType === 'playable' ? '可扮演角色' : '场景角色';
|
||||
let mergedEntries = baseEntries.map((entry) => ({ ...entry })) as T[];
|
||||
const plannedBatchCount = Math.max(
|
||||
1,
|
||||
Math.ceil(roleBatchSource.length / batchSize),
|
||||
);
|
||||
const processedByStage: Record<CustomWorldGenerationRoleBatchStage, number> = {
|
||||
narrative: 0,
|
||||
dossier: 0,
|
||||
};
|
||||
|
||||
const requestBatchStage = async (
|
||||
roleBatch: typeof roleBatchSource,
|
||||
batchIndex: number,
|
||||
stage: CustomWorldGenerationRoleBatchStage,
|
||||
) => {
|
||||
throwIfCustomWorldGenerationAborted(signal);
|
||||
const stageLabel = stage === 'narrative' ? '叙事设定' : '档案补全';
|
||||
const stageId = getCustomWorldGenerationStageIdForRoleExpansion(
|
||||
roleType,
|
||||
stage,
|
||||
);
|
||||
reporter.update(stageId, processedByStage[stage], {
|
||||
phaseDetail: `正在补充${roleLabel}${stageLabel},已完成 ${processedByStage[stage]}/${roleBatchSource.length}。`,
|
||||
batchLabel: `第 ${batchIndex + 1} / ${plannedBatchCount} 批`,
|
||||
});
|
||||
const stageRaw = await requestCustomWorldJsonStage({
|
||||
userPrompt: buildCustomWorldRoleBatchPrompt({
|
||||
framework,
|
||||
@@ -484,6 +918,7 @@ async function expandCustomWorldRoleEntries<
|
||||
}),
|
||||
repairDebugLabel: `custom-world-${roleType}-${stage}-batch-${batchIndex + 1}-json-repair`,
|
||||
emptyResponseMessage: `自定义世界${roleLabel}批次 ${batchIndex + 1} 的${stageLabel}生成失败:模型没有返回有效内容。`,
|
||||
signal,
|
||||
});
|
||||
|
||||
mergedEntries = mergeRoleBatchDetails(
|
||||
@@ -493,9 +928,17 @@ async function expandCustomWorldRoleEntries<
|
||||
? (stageRaw as Record<string, unknown>)[
|
||||
roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'
|
||||
]
|
||||
: [],
|
||||
: []
|
||||
),
|
||||
);
|
||||
processedByStage[stage] = Math.min(
|
||||
roleBatchSource.length,
|
||||
processedByStage[stage] + roleBatch.length,
|
||||
);
|
||||
reporter.update(stageId, processedByStage[stage], {
|
||||
phaseDetail: `正在补充${roleLabel}${stageLabel},已完成 ${processedByStage[stage]}/${roleBatchSource.length}。`,
|
||||
batchLabel: `第 ${batchIndex + 1} / ${plannedBatchCount} 批`,
|
||||
});
|
||||
};
|
||||
|
||||
for (const [batchIndex, roleBatch] of chunkArray(
|
||||
@@ -513,8 +956,10 @@ async function parseCustomWorldStageResponseJson(params: {
|
||||
responseText: string;
|
||||
repairPrompt: string;
|
||||
repairDebugLabel: string;
|
||||
signal?: AbortSignal;
|
||||
}) {
|
||||
const { responseText, repairPrompt, repairDebugLabel } = params;
|
||||
const { responseText, repairPrompt, repairDebugLabel, signal } = params;
|
||||
throwIfCustomWorldGenerationAborted(signal);
|
||||
try {
|
||||
return parseJsonResponseTextFromParser(responseText);
|
||||
} catch {
|
||||
@@ -536,9 +981,11 @@ async function parseCustomWorldStageResponseJson(params: {
|
||||
Math.min(90000, Math.round(CLIENT_CUSTOM_WORLD_REQUEST_TIMEOUT_MS / 2)),
|
||||
),
|
||||
debugLabel: repairDebugLabel,
|
||||
signal,
|
||||
},
|
||||
);
|
||||
|
||||
throwIfCustomWorldGenerationAborted(signal);
|
||||
return parseJsonResponseTextFromParser(
|
||||
sanitizeJsonLikeText(repairedText) || repairedText,
|
||||
);
|
||||
@@ -551,6 +998,7 @@ async function requestCustomWorldJsonStage(params: {
|
||||
repairPromptBuilder: (responseText: string) => string;
|
||||
repairDebugLabel: string;
|
||||
emptyResponseMessage: string;
|
||||
signal?: AbortSignal;
|
||||
}) {
|
||||
const {
|
||||
userPrompt,
|
||||
@@ -558,6 +1006,7 @@ async function requestCustomWorldJsonStage(params: {
|
||||
repairPromptBuilder,
|
||||
repairDebugLabel,
|
||||
emptyResponseMessage,
|
||||
signal,
|
||||
} = params;
|
||||
const timeoutPlan = [
|
||||
CLIENT_CUSTOM_WORLD_REQUEST_TIMEOUT_MS,
|
||||
@@ -569,6 +1018,7 @@ async function requestCustomWorldJsonStage(params: {
|
||||
|
||||
for (const [attemptIndex, timeoutMs] of timeoutPlan.entries()) {
|
||||
try {
|
||||
throwIfCustomWorldGenerationAborted(signal);
|
||||
const responseText = await requestPlainTextCompletionFromClient(
|
||||
CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
@@ -578,6 +1028,7 @@ async function requestCustomWorldJsonStage(params: {
|
||||
attemptIndex === 0
|
||||
? debugLabel
|
||||
: `${debugLabel}-retry-${attemptIndex + 1}`,
|
||||
signal,
|
||||
},
|
||||
);
|
||||
text = typeof responseText === 'string' ? responseText : '';
|
||||
@@ -602,6 +1053,7 @@ async function requestCustomWorldJsonStage(params: {
|
||||
responseText: text,
|
||||
repairPrompt: repairPromptBuilder(text),
|
||||
repairDebugLabel,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1133,16 +1585,24 @@ export async function generateCustomWorldSceneImage({
|
||||
|
||||
export async function generateCustomWorldProfile(
|
||||
settingText: string,
|
||||
options: GenerateCustomWorldProfileOptions = {},
|
||||
): Promise<CustomWorldProfile> {
|
||||
const normalizedSettingText = settingText.trim();
|
||||
const reporter = createCustomWorldGenerationReporter(options.onProgress);
|
||||
const signal = options.signal;
|
||||
|
||||
try {
|
||||
throwIfCustomWorldGenerationAborted(signal);
|
||||
reporter.begin('framework', {
|
||||
phaseDetail: '正在解析你的设定文本,准备搭建世界框架。',
|
||||
});
|
||||
const frameworkRaw = await requestCustomWorldJsonStage({
|
||||
userPrompt: buildCustomWorldFrameworkPrompt(normalizedSettingText),
|
||||
debugLabel: 'custom-world-framework',
|
||||
repairPromptBuilder: buildCustomWorldFrameworkJsonRepairPrompt,
|
||||
repairDebugLabel: 'custom-world-framework-json-repair',
|
||||
emptyResponseMessage: '自定义世界框架生成失败:模型没有返回有效内容。',
|
||||
signal,
|
||||
});
|
||||
const frameworkBase = {
|
||||
...normalizeCustomWorldGenerationFramework(
|
||||
@@ -1153,49 +1613,84 @@ export async function generateCustomWorldProfile(
|
||||
storyNpcs: [],
|
||||
landmarks: [],
|
||||
} satisfies CustomWorldGenerationFramework;
|
||||
reporter.complete('framework', {
|
||||
phaseDetail: `世界框架已确定,基础模板锚定为${frameworkBase.templateWorldType === WorldType.WUXIA ? '武侠' : '仙侠'}。`,
|
||||
});
|
||||
|
||||
reporter.begin('playable-outline', {
|
||||
phaseDetail: '正在生成可扮演角色骨架。',
|
||||
});
|
||||
const playableNpcs =
|
||||
(await generateCustomWorldRoleOutlineEntries({
|
||||
framework: frameworkBase,
|
||||
roleType: 'playable',
|
||||
totalCount: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||||
batchSize: CUSTOM_WORLD_FRAMEWORK_PLAYABLE_OUTLINE_BATCH_SIZE,
|
||||
reporter,
|
||||
signal,
|
||||
})) as CustomWorldGenerationFramework['playableNpcs'];
|
||||
reporter.complete('playable-outline', {
|
||||
phaseDetail: `可扮演角色骨架已完成,共 ${playableNpcs.length} 名。`,
|
||||
});
|
||||
const frameworkWithPlayable = {
|
||||
...frameworkBase,
|
||||
playableNpcs,
|
||||
} satisfies CustomWorldGenerationFramework;
|
||||
|
||||
reporter.begin('story-outline', {
|
||||
phaseDetail: '正在生成场景角色骨架。',
|
||||
});
|
||||
const storyNpcs =
|
||||
(await generateCustomWorldRoleOutlineEntries({
|
||||
framework: frameworkWithPlayable,
|
||||
roleType: 'story',
|
||||
totalCount: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
|
||||
batchSize: CUSTOM_WORLD_FRAMEWORK_STORY_OUTLINE_BATCH_SIZE,
|
||||
reporter,
|
||||
signal,
|
||||
})) as CustomWorldGenerationFramework['storyNpcs'];
|
||||
reporter.complete('story-outline', {
|
||||
phaseDetail: `场景角色骨架已完成,共 ${storyNpcs.length} 名。`,
|
||||
});
|
||||
const frameworkWithStory = {
|
||||
...frameworkWithPlayable,
|
||||
storyNpcs,
|
||||
} satisfies CustomWorldGenerationFramework;
|
||||
|
||||
reporter.begin('landmark-seed', {
|
||||
phaseDetail: '正在生成场景骨架。',
|
||||
});
|
||||
const landmarkSeeds =
|
||||
(await generateCustomWorldLandmarkSeedEntries({
|
||||
framework: frameworkWithStory,
|
||||
totalCount: MIN_CUSTOM_WORLD_LANDMARK_COUNT,
|
||||
batchSize: CUSTOM_WORLD_FRAMEWORK_LANDMARK_SEED_BATCH_SIZE,
|
||||
reporter,
|
||||
signal,
|
||||
})) as CustomWorldGenerationFramework['landmarks'];
|
||||
reporter.complete('landmark-seed', {
|
||||
phaseDetail: `场景骨架已完成,共 ${landmarkSeeds.length} 个地标。`,
|
||||
});
|
||||
const frameworkWithLandmarkSeeds = {
|
||||
...frameworkWithStory,
|
||||
landmarks: landmarkSeeds,
|
||||
} satisfies CustomWorldGenerationFramework;
|
||||
|
||||
reporter.begin('landmark-network', {
|
||||
phaseDetail: '正在建立场景连接与场景角色分布。',
|
||||
});
|
||||
const landmarks =
|
||||
(await expandCustomWorldLandmarkNetworkEntries({
|
||||
framework: frameworkWithLandmarkSeeds,
|
||||
storyNpcs,
|
||||
baseEntries: landmarkSeeds,
|
||||
batchSize: CUSTOM_WORLD_FRAMEWORK_LANDMARK_NETWORK_BATCH_SIZE,
|
||||
reporter,
|
||||
signal,
|
||||
})) as CustomWorldGenerationFramework['landmarks'];
|
||||
reporter.complete('landmark-network', {
|
||||
phaseDetail: `场景连接已完成,共整理 ${landmarks.length} 个地标网络。`,
|
||||
});
|
||||
|
||||
const framework = {
|
||||
...frameworkWithStory,
|
||||
@@ -1204,19 +1699,34 @@ export async function generateCustomWorldProfile(
|
||||
validateCustomWorldGenerationFramework(framework);
|
||||
|
||||
const baseRawProfile = buildCustomWorldRawProfileFromFramework(framework);
|
||||
reporter.begin('playable-narrative', {
|
||||
phaseDetail: '正在补充可扮演角色的叙事设定。',
|
||||
});
|
||||
const mergedPlayableNpcs = await expandCustomWorldRoleEntries({
|
||||
framework,
|
||||
roleType: 'playable',
|
||||
baseEntries: baseRawProfile.playableNpcs.map((npc) => ({ ...npc })),
|
||||
batchSize: CUSTOM_WORLD_PLAYABLE_BATCH_SIZE,
|
||||
reporter,
|
||||
signal,
|
||||
});
|
||||
|
||||
reporter.begin('story-narrative', {
|
||||
phaseDetail: '正在补充场景角色的叙事设定。',
|
||||
});
|
||||
const mergedStoryNpcs = await expandCustomWorldRoleEntries({
|
||||
framework,
|
||||
roleType: 'story',
|
||||
baseEntries: baseRawProfile.storyNpcs.map((npc) => ({ ...npc })),
|
||||
batchSize: CUSTOM_WORLD_STORY_BATCH_SIZE,
|
||||
reporter,
|
||||
signal,
|
||||
});
|
||||
|
||||
reporter.begin('finalize', {
|
||||
phaseDetail: '正在归档世界并做完整性校验。',
|
||||
});
|
||||
throwIfCustomWorldGenerationAborted(signal);
|
||||
const profile = buildExpandedCustomWorldProfile(
|
||||
{
|
||||
...baseRawProfile,
|
||||
@@ -1226,11 +1736,19 @@ export async function generateCustomWorldProfile(
|
||||
normalizedSettingText,
|
||||
);
|
||||
validateGeneratedCustomWorldProfile(profile);
|
||||
reporter.complete('finalize', {
|
||||
phaseDetail: `世界“${profile.name}”已完成归档。`,
|
||||
});
|
||||
return {
|
||||
...profile,
|
||||
items: [],
|
||||
};
|
||||
} catch (error) {
|
||||
if (isCustomWorldGenerationAbortLikeError(error) || signal?.aborted) {
|
||||
throw error instanceof Error
|
||||
? error
|
||||
: new CustomWorldGenerationAbortedError();
|
||||
}
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error(
|
||||
'自定义世界生成失败:模型返回了非严格 JSON,且自动修复仍未成功,请稍后重试。',
|
||||
|
||||
Reference in New Issue
Block a user