Fix DashScope env loading for scene image generation

This commit is contained in:
2026-04-06 15:01:15 +08:00
parent fcd8d727b0
commit d678929064
23 changed files with 4943 additions and 138 deletions

View File

@@ -539,6 +539,70 @@ describe('ai orchestration fallbacks', () => {
expect(debugLabels).toContain('custom-world-story-dossier-batch-1');
});
it('reports staged progress while generating a custom world', async () => {
requestPlainTextCompletionMock.mockResolvedValue(
JSON.stringify(createCustomWorldResponse()),
);
const onProgress = vi.fn();
await generateCustomWorldProfile('一个需要展示真实进度的世界', {
onProgress,
});
const phaseIds = onProgress.mock.calls.map(
(call) =>
(call[0] as { phaseId?: string; overallProgress?: number }).phaseId,
);
const lastProgress = onProgress.mock.calls.at(-1)?.[0] as
| { overallProgress?: number; estimatedRemainingMs?: number | null }
| undefined;
expect(phaseIds).toContain('framework');
expect(phaseIds).toContain('playable-outline');
expect(phaseIds).toContain('story-outline');
expect(phaseIds).toContain('landmark-seed');
expect(phaseIds).toContain('landmark-network');
expect(phaseIds).toContain('playable-narrative');
expect(phaseIds).toContain('playable-dossier');
expect(phaseIds).toContain('story-narrative');
expect(phaseIds).toContain('story-dossier');
expect(phaseIds).toContain('finalize');
expect(lastProgress?.overallProgress).toBe(100);
expect(lastProgress?.estimatedRemainingMs).toBe(0);
});
it('passes abort signals through custom world generation and rejects when interrupted', async () => {
requestPlainTextCompletionMock.mockImplementation(
(
_system: string,
_user: string,
options?: { signal?: AbortSignal },
) =>
new Promise((_resolve, reject) => {
options?.signal?.addEventListener(
'abort',
() => reject(options.signal?.reason ?? new Error('世界生成已中断。')),
{ once: true },
);
}),
);
const abortController = new AbortController();
const generation = generateCustomWorldProfile('一个会被中断的世界', {
signal: abortController.signal,
});
abortController.abort(new Error('手动中断生成'));
await expect(generation).rejects.toThrow('手动中断生成');
expect(requestPlainTextCompletionMock).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
expect.objectContaining({
signal: abortController.signal,
}),
);
});
it('retries custom world generation with a longer timeout after the first timeout attempt', async () => {
requestPlainTextCompletionMock
.mockRejectedValueOnce(timeoutError)

View File

@@ -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且自动修复仍未成功请稍后重试。',

View File

@@ -4,6 +4,7 @@ import type {
CharacterConversationStyle,
CharacterGender,
CompanionState,
CustomWorldNpc,
CustomWorldProfile,
EquipmentLoadout,
FacingDirection,
@@ -65,6 +66,24 @@ export interface StoryGenerationContext {
recentSharedEvent?: string | null;
talkPriority?: string | null;
encounterRelationshipSummary?: string | null;
encounterCustomProfile?: Partial<
Pick<
CustomWorldNpc,
| 'title'
| 'description'
| 'backstory'
| 'personality'
| 'motivation'
| 'combatStyle'
| 'relationshipHooks'
| 'tags'
| 'backstoryReveal'
| 'skills'
| 'initialItems'
| 'imageSrc'
| 'visual'
>
> | null;
partyRelationshipNotes?: string | null;
customWorldProfile?: CustomWorldProfile | null;
openingCampBackground?: string | null;

View File

@@ -14,6 +14,7 @@ import {
CharacterBackstoryRevealConfig,
CustomWorldItem,
CustomWorldLandmark,
CustomWorldNpc,
CustomWorldPlayableNpc,
CustomWorldProfile,
CustomWorldRoleInitialItem,
@@ -714,11 +715,18 @@ function normalizePlayableNpcList(value: unknown) {
function normalizeStoryNpcList(value: unknown) {
return toRecordArray(value)
.map((item, index) =>
normalizeRoleProfile(item, index, {
idPrefix: 'story-npc',
titleFallback: '未定称号',
defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY,
}),
({
...normalizeRoleProfile(item, index, {
idPrefix: 'story-npc',
titleFallback: '未定称号',
defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY,
}),
imageSrc: toText(item.imageSrc) || undefined,
visual:
item.visual && typeof item.visual === 'object'
? (item.visual as CustomWorldNpc['visual'])
: undefined,
}) satisfies CustomWorldNpc,
)
.filter((entry) => entry.name);
}

View File

@@ -9,6 +9,7 @@ const ENABLE_LLM_DEBUG_LOG = Boolean(ENV.DEV) || ENV.VITE_LLM_DEBUG_LOG === 'tru
export interface PlainTextCompletionOptions {
timeoutMs?: number;
debugLabel?: string;
signal?: AbortSignal;
}
export class LlmConnectivityError extends Error {
@@ -71,7 +72,9 @@ async function requestMessageContent(
) {
const timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS;
const debugLabel = options.debugLabel ?? 'chat';
const externalSignal = options.signal;
const controller = new AbortController();
const handleExternalAbort = () => controller.abort();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const startedAt = performance.now();
const requestBody = {
@@ -83,6 +86,16 @@ async function requestMessageContent(
};
const rawPromptText = `[System]\n${systemPrompt}\n\n[User]\n${userPrompt}`;
if (externalSignal) {
if (externalSignal.aborted) {
handleExternalAbort();
} else {
externalSignal.addEventListener('abort', handleExternalAbort, {
once: true,
});
}
}
try {
logLlmDebug(`[LLM:${debugLabel}] prompt text`, rawPromptText);
@@ -119,6 +132,11 @@ async function requestMessageContent(
return content.trim();
} catch (error) {
if (externalSignal?.aborted) {
throw externalSignal.reason instanceof Error
? externalSignal.reason
: new DOMException('The LLM request was aborted.', 'AbortError');
}
console.error(`[LLM:${debugLabel}] completion failed`, {
model: MODEL,
elapsedMs: Math.round(performance.now() - startedAt),
@@ -128,6 +146,7 @@ async function requestMessageContent(
return normalizeLlmError(error);
} finally {
clearTimeout(timeout);
externalSignal?.removeEventListener('abort', handleExternalAbort);
}
}

View File

@@ -412,6 +412,7 @@ function describeFrontEntity(
) {
const schema = resolveAttributeSchema(world, context.customWorldProfile);
if (context.encounterName) {
const encounterCustomProfile = context.encounterCustomProfile;
const encounterCharacter = context.encounterCharacterId
? getCharacterById(context.encounterCharacterId) ?? resolveEncounterRecruitCharacter({
characterId: context.encounterCharacterId,
@@ -427,11 +428,53 @@ function describeFrontEntity(
const attributeProfile = encounterCharacter
? resolveCharacterAttributeProfile(encounterCharacter, world, context.customWorldProfile)
: inferEncounterAttributeProfile(world, context, `encounter:${context.encounterName}`, [
inferEncounterPersonality(context.encounterContext, context.encounterDescription),
encounterCustomProfile?.personality ||
inferEncounterPersonality(
context.encounterContext,
context.encounterDescription,
),
encounterCustomProfile?.backstory ?? '',
encounterCustomProfile?.motivation ?? '',
encounterCustomProfile?.combatStyle ?? '',
...(encounterCustomProfile?.relationshipHooks ?? []),
...(encounterCustomProfile?.tags ?? []),
...(encounterCustomProfile?.backstoryReveal?.chapters ?? []).flatMap(
(chapter) => [
chapter.title,
chapter.teaser,
chapter.content,
chapter.contextSnippet,
],
),
...(encounterCustomProfile?.skills ?? []).flatMap((skill) => [
skill.name,
skill.summary,
skill.style,
]),
...(encounterCustomProfile?.initialItems ?? []).flatMap((item) => [
item.name,
item.category,
item.description,
...item.tags,
]),
]);
const title = encounterCharacter?.title ?? context.encounterContext ?? '此地生灵';
const description = encounterCharacter?.description ?? context.encounterDescription ?? '对方站在你面前,等待你进一步表态。';
const personality = encounterCharacter?.personality ?? inferEncounterPersonality(context.encounterContext, context.encounterDescription);
const title =
encounterCharacter?.title ??
encounterCustomProfile?.title ??
context.encounterContext ??
'此地生灵';
const description =
encounterCharacter?.description ??
encounterCustomProfile?.description ??
context.encounterDescription ??
'对方站在你面前,等待你进一步表态。';
const personality =
encounterCharacter?.personality ??
encounterCustomProfile?.personality ??
inferEncounterPersonality(
context.encounterContext,
context.encounterDescription,
);
const backstoryLines = encounterCharacter
? context.isFirstMeaningfulContact
? [getCharacterPublicBackstorySummary(encounterCharacter, world)]
@@ -440,7 +483,19 @@ function describeFrontEntity(
context.encounterAffinity ?? 0,
world,
)
: ['对方有自己的来路与立场,只是暂时没有完全表现出来。'];
: encounterCustomProfile
? [
encounterCustomProfile.backstoryReveal?.publicSummary ??
'对方有自己的来路与立场。',
encounterCustomProfile.backstory,
...(
encounterCustomProfile.backstoryReveal?.chapters.map(
(chapter) =>
chapter.contextSnippet || chapter.content || chapter.teaser,
) ?? []
),
].filter((line): line is string => Boolean(line))
: ['对方有自己的来路与立场,只是暂时没有完全表现出来。'];
const status = context.encounterKind === 'npc'
? context.isFirstMeaningfulContact
? '你们正在进行第一次真正接触,对方会先观察你的态度与来意。'
@@ -456,6 +511,31 @@ function describeFrontEntity(
`- 描述:${description}`,
...describeBackstoryContext('背景', backstoryLines).map(line => `- ${line}`),
`- 性格:${personality}`,
encounterCustomProfile?.motivation
? `- 当前动机:${encounterCustomProfile.motivation}`
: null,
encounterCustomProfile?.combatStyle
? `- 战斗风格:${encounterCustomProfile.combatStyle}`
: null,
encounterCustomProfile?.relationshipHooks?.length
? `- 关系切入口:${encounterCustomProfile.relationshipHooks.join('、')}`
: null,
encounterCustomProfile?.tags?.length
? `- 标签:${encounterCustomProfile.tags.join('、')}`
: null,
encounterCustomProfile?.skills?.length
? `- 自定义技能:${encounterCustomProfile.skills
.map((skill) => `${skill.name}(${skill.style})${skill.summary}`)
.join('')}`
: null,
encounterCustomProfile?.initialItems?.length
? `- 随身物:${encounterCustomProfile.initialItems
.map(
(item) =>
`${item.name}x${item.quantity}(${item.category}/${item.rarity})`,
)
.join('')}`
: null,
`- 世界属性框架:${buildSchemaSummary(schema).map(slot => `${slot.name}${slot.definition}`).join('、')}`,
...(encounterCharacter ? describeEncounterOpeningByStage(encounterCharacter, world, context).map(line => `- ${line}`) : []),