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

@@ -1,6 +1,10 @@
import { AnimatePresence, motion } from 'motion/react';
import { type CSSProperties, useEffect, useMemo, useState } from 'react';
import {
resolveRoleCombatStats,
type RoleCombatStats,
} from '../data/attributeCombat';
import {
formatAttributeList,
resolveAttributeSchema,
@@ -260,6 +264,10 @@ function formatAttributeMetricValue(value: number) {
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
}
function formatAttributePercentValue(value: number) {
return `${formatAttributeMetricValue(value * 100)}%`;
}
function getAttributeBonusPillClassName(bonus: number) {
if (bonus >= 0.05) {
return 'border-amber-400/25 bg-amber-500/12 text-amber-100';
@@ -270,6 +278,29 @@ function getAttributeBonusPillClassName(bonus: number) {
return 'border-white/10 bg-black/20 text-zinc-500';
}
function getAttributeEffectText(
slotId: string,
combatStats: RoleCombatStats,
resourceLabels: ReturnType<typeof getResourceLabelsForWorld>,
) {
switch (slotId) {
case 'axis_a':
return `攻击倍率 x${formatAttributeMetricValue(combatStats.attackPowerMultiplier)}`;
case 'axis_b':
return `${resourceLabels.maxHp} +${combatStats.maxHpBonus}`;
case 'axis_c':
return `${resourceLabels.hp}恢复 +${combatStats.storyRecovery}`;
case 'axis_d':
return `攻击速度 ${formatAttributeMetricValue(combatStats.turnSpeed)}`;
case 'axis_e':
return `暴击率 ${formatAttributePercentValue(combatStats.critChance)}`;
case 'axis_f':
return `暴击伤害 x${formatAttributeMetricValue(combatStats.critDamageMultiplier)}`;
default:
return '提升战斗表现';
}
}
function buildLeaderEquipmentRows(
playerCharacter: Character,
playerEquipment: EquipmentLoadout,
@@ -461,19 +492,26 @@ export function CharacterPanel({
? buildLeaderEquipmentRows(playerCharacter, playerEquipment)
: buildCompanionEquipmentRows(selectedMember.character, selectedMember.id)
: [];
const selectedAttributeRows = useMemo(
const selectedMemberAttributeProfile = useMemo(
() =>
selectedMember
? resolveCharacterAttributeProfile(
selectedMember.character,
worldType,
customWorldProfile,
)
: null,
[customWorldProfile, selectedMember, worldType],
);
const selectedAttributeRows = useMemo(
() =>
selectedMemberAttributeProfile
? formatAttributeList(
resolveCharacterAttributeProfile(
selectedMember.character,
worldType,
customWorldProfile,
),
selectedMemberAttributeProfile,
selectedAttributeSchema,
)
: [],
[customWorldProfile, selectedAttributeSchema, selectedMember, worldType],
[selectedAttributeSchema, selectedMemberAttributeProfile],
);
const selectedAttributeBonusBySlot = useMemo(
() =>
@@ -493,20 +531,67 @@ export function CharacterPanel({
) as Record<string, number>,
[selectedAttributeSchema, selectedBuildBreakdown],
);
const selectedBoostedAttributeProfile = useMemo(() => {
if (!selectedMemberAttributeProfile) {
return null;
}
return {
...selectedMemberAttributeProfile,
values: {
...(selectedMemberAttributeProfile.values ?? {}),
...Object.fromEntries(
selectedAttributeSchema.slots.map((slot) => {
const baseValue =
selectedMemberAttributeProfile.values?.[slot.slotId] ?? 0;
const totalBonus = selectedAttributeBonusBySlot[slot.slotId] ?? 0;
return [
slot.slotId,
Number((baseValue * (1 + totalBonus)).toFixed(4)),
];
}),
),
},
};
}, [
selectedAttributeBonusBySlot,
selectedAttributeSchema,
selectedMemberAttributeProfile,
]);
const selectedBoostedCombatStats = useMemo(
() =>
selectedMember
? resolveRoleCombatStats(selectedBoostedAttributeProfile)
: null,
[selectedBoostedAttributeProfile, selectedMember],
);
const selectedDisplayAttributeRows = useMemo(
() =>
selectedAttributeRows.map(({ slot, value }) => {
const totalBonus = selectedAttributeBonusBySlot[slot.slotId] ?? 0;
const boostedValue = value * (1 + totalBonus);
const boostedValue = Number((value * (1 + totalBonus)).toFixed(4));
return {
slot,
baseValue: value,
boostedValue,
totalBonus,
effectText: selectedBoostedCombatStats
? getAttributeEffectText(
slot.slotId,
selectedBoostedCombatStats,
resourceLabels,
)
: slot.combatUseText,
};
}),
[selectedAttributeBonusBySlot, selectedAttributeRows],
[
resourceLabels,
selectedAttributeBonusBySlot,
selectedAttributeRows,
selectedBoostedCombatStats,
],
);
const selectedContributionAttributes = selectedContributionRow
? getBuildContributionAttributeRows(
@@ -718,7 +803,6 @@ export function CharacterPanel({
</div>
</div>
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
@@ -877,7 +961,13 @@ export function CharacterPanel({
</div>
<div className="mt-4 grid grid-cols-2 gap-2 text-sm text-zinc-300">
{selectedDisplayAttributeRows.map(
({ slot, baseValue, boostedValue, totalBonus }) => (
({
slot,
baseValue,
boostedValue,
totalBonus,
effectText,
}) => (
<div
key={slot.slotId}
className="rounded-lg border border-white/5 bg-black/20 px-3 py-2"
@@ -886,22 +976,25 @@ export function CharacterPanel({
{slot.name}
</div>
<div className="mt-1 flex items-start justify-between gap-3">
<div>
<div className="min-w-0 flex-1">
<div className="text-2xl font-bold text-white">
{formatAttributeMetricValue(boostedValue)}
</div>
<div className="mt-1 text-[10px] text-zinc-500">
</div>
<div className="flex shrink-0 flex-col items-end gap-1 text-right">
<span
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium ${getAttributeBonusPillClassName(totalBonus)}`}
>
{' '}
{formatBuildContributionPercent(totalBonus)}
</span>
<div className="text-[10px] text-zinc-500">
{formatAttributeMetricValue(baseValue)}
</div>
</div>
<span
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium ${getAttributeBonusPillClassName(totalBonus)}`}
>
{formatBuildContributionPercent(totalBonus)}
</span>
</div>
<div className="mt-2 text-[10px] leading-relaxed text-zinc-500">
{slot.definition}
<div className="mt-2 text-[10px] leading-relaxed text-sky-200/85">
{effectText}
</div>
</div>
),

View File

@@ -31,6 +31,7 @@ import {
CustomWorldPlayableNpc,
CustomWorldProfile,
CustomWorldSceneConnection,
type ItemRarity,
} from '../types';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { CharacterAnimator } from './CharacterAnimator';
@@ -64,6 +65,14 @@ const [
BACKSTORY_UNLOCK_AFFINITY_CLOSE,
] = AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;
const ITEM_RARITY_OPTIONS: Array<{ value: ItemRarity; label: string }> = [
{ value: 'common', label: 'common' },
{ value: 'uncommon', label: 'uncommon' },
{ value: 'rare', label: 'rare' },
{ value: 'epic', label: 'epic' },
{ value: 'legendary', label: 'legendary' },
];
function slugify(value: string) {
const normalized = value
.trim()
@@ -101,6 +110,48 @@ function clampInitialAffinity(value: string, fallback: number) {
return Math.max(-40, Math.min(90, Math.round(parsed)));
}
function parseOptionalNumber(value: string) {
const trimmed = value.trim();
if (!trimmed) return undefined;
const parsed = Number.parseInt(trimmed, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
function createRoleSkillDraft(seedLabel: string, index: number) {
return {
id: createEntryId('skill', seedLabel, Date.now() + index),
name: `新技能${index + 1}`,
summary: '',
style: '起手压制',
};
}
function createRoleInitialItemDraft(seedLabel: string, index: number) {
return {
id: createEntryId('item', seedLabel, Date.now() + index),
name: `新物品${index + 1}`,
category: '材料',
quantity: 1,
rarity: 'rare' as ItemRarity,
description: '',
tags: [],
};
}
function createBackstoryChapterDraft(seedLabel: string, index: number) {
return {
id: createEntryId('backstory-chapter', seedLabel, Date.now() + index),
title: `背景片段${index + 1}`,
affinityRequired:
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS[
Math.min(index, AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS.length - 1)
] ?? BACKSTORY_UNLOCK_AFFINITY_CLOSE,
teaser: '',
content: '',
contextSnippet: '',
};
}
function syncLandmarksWithStoryNpcs(
landmarks: CustomWorldLandmark[],
storyNpcs: CustomWorldProfile['storyNpcs'],
@@ -700,7 +751,8 @@ function SaveBar({
onSave: () => void;
}) {
return (
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-end">
<div className="sticky bottom-0 z-10 -mx-4 border-t border-white/10 bg-[linear-gradient(180deg,rgba(8,10,17,0.2)_0%,rgba(8,10,17,0.96)_28%)] px-4 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)] pt-3 backdrop-blur sm:static sm:mx-0 sm:border-0 sm:bg-transparent sm:px-0 sm:pb-0 sm:pt-2 sm:backdrop-blur-0">
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button
type="button"
onClick={onClose}
@@ -716,16 +768,423 @@ function SaveBar({
paddingX: 16,
paddingY: 10,
})}
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white"></span>
<span className="text-white/60"></span>
</div>
</button>
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white"></span>
<span className="text-white/60"></span>
</div>
</button>
</div>
</div>
);
}
function SectionPanel({
title,
subtitle,
actions,
children,
}: {
title: string;
subtitle?: string;
actions?: ReactNode;
children: ReactNode;
}) {
return (
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 sm:px-4 sm:py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-300">
{title}
</div>
{subtitle ? (
<div className="mt-2 text-sm leading-6 text-zinc-400">
{subtitle}
</div>
) : null}
</div>
{actions}
</div>
<div className="mt-4 space-y-3">{children}</div>
</div>
);
}
function BackstoryRevealEditor({
value,
onChange,
}: {
value: CustomWorldPlayableNpc['backstoryReveal'];
onChange: (value: CustomWorldPlayableNpc['backstoryReveal']) => void;
}) {
const updateChapter = (
index: number,
updater: (
chapter: CustomWorldPlayableNpc['backstoryReveal']['chapters'][number],
) => CustomWorldPlayableNpc['backstoryReveal']['chapters'][number],
) => {
onChange({
...value,
chapters: value.chapters.map((chapter, chapterIndex) =>
chapterIndex === index ? updater(chapter) : chapter,
),
});
};
const addChapter = () => {
onChange({
...value,
chapters: [
...value.chapters,
createBackstoryChapterDraft('custom-role', value.chapters.length),
],
});
};
const removeChapter = (index: number) => {
if (value.chapters.length <= 1) {
window.alert('至少保留一个背景章节。');
return;
}
onChange({
...value,
chapters: value.chapters.filter(
(_chapter, chapterIndex) => chapterIndex !== index,
),
});
};
return (
<SectionPanel
title="背景公开与章节"
subtitle="这里直接决定结果页、关系推进和后续剧情提示词看到的背景摘要与章节线索。"
actions={
<ActionButton label="新增章节" onClick={addChapter} tone="sky" />
}
>
<Field label="公开背景摘要">
<TextArea
value={value.publicSummary}
onChange={(nextValue) =>
onChange({
...value,
publicSummary: nextValue,
})
}
rows={3}
/>
</Field>
{value.chapters.map((chapter, index) => (
<div
key={`${chapter.id}-${index}`}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
#{index + 1}
</div>
<ActionButton
label="删除章节"
onClick={() => removeChapter(index)}
/>
</div>
<Field label="章节标题">
<TextInput
value={chapter.title}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
title: nextValue,
}))
}
/>
</Field>
<Field label="解锁好感">
<TextInput
type="number"
value={chapter.affinityRequired}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
affinityRequired: clampInitialAffinity(
nextValue,
current.affinityRequired,
),
}))
}
/>
</Field>
<Field label="章节提示">
<TextArea
value={chapter.teaser}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
teaser: nextValue,
}))
}
rows={2}
/>
</Field>
<Field label="章节内容">
<TextArea
value={chapter.content}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
content: nextValue,
}))
}
rows={3}
/>
</Field>
<Field label="剧情引用摘要">
<TextArea
value={chapter.contextSnippet}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
contextSnippet: nextValue,
}))
}
rows={2}
/>
</Field>
</div>
))}
</SectionPanel>
);
}
function SkillListEditor({
value,
onChange,
labelSeed,
}: {
value: CustomWorldPlayableNpc['skills'];
onChange: (value: CustomWorldPlayableNpc['skills']) => void;
labelSeed: string;
}) {
const updateSkill = (
index: number,
updater: (
skill: CustomWorldPlayableNpc['skills'][number],
) => CustomWorldPlayableNpc['skills'][number],
) => {
onChange(
value.map((skill, skillIndex) =>
skillIndex === index ? updater(skill) : skill,
),
);
};
return (
<SectionPanel
title="技能"
subtitle="技能名、摘要和风格都会进入结果页与运行时 NPC 档案。"
actions={
<ActionButton
label="新增技能"
onClick={() =>
onChange([...value, createRoleSkillDraft(labelSeed, value.length)])
}
tone="sky"
/>
}
>
{value.map((skill, index) => (
<div
key={`${skill.id}-${index}`}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
#{index + 1}
</div>
<ActionButton
label="删除技能"
onClick={() =>
onChange(value.filter((_skill, skillIndex) => skillIndex !== index))
}
/>
</div>
<Field label="技能名称">
<TextInput
value={skill.name}
onChange={(nextValue) =>
updateSkill(index, (current) => ({
...current,
name: nextValue,
}))
}
/>
</Field>
<Field label="技能风格">
<TextInput
value={skill.style}
onChange={(nextValue) =>
updateSkill(index, (current) => ({
...current,
style: nextValue,
}))
}
/>
</Field>
<Field label="技能摘要">
<TextArea
value={skill.summary}
onChange={(nextValue) =>
updateSkill(index, (current) => ({
...current,
summary: nextValue,
}))
}
rows={3}
/>
</Field>
</div>
))}
</SectionPanel>
);
}
function InitialItemsEditor({
value,
onChange,
labelSeed,
}: {
value: CustomWorldPlayableNpc['initialItems'];
onChange: (value: CustomWorldPlayableNpc['initialItems']) => void;
labelSeed: string;
}) {
const updateItem = (
index: number,
updater: (
item: CustomWorldPlayableNpc['initialItems'][number],
) => CustomWorldPlayableNpc['initialItems'][number],
) => {
onChange(
value.map((item, itemIndex) =>
itemIndex === index ? updater(item) : item,
),
);
};
return (
<SectionPanel
title="初始物品"
subtitle="这里的内容会影响结果页展示,也会作为后续运行时参考档案。"
actions={
<ActionButton
label="新增物品"
onClick={() =>
onChange([
...value,
createRoleInitialItemDraft(labelSeed, value.length),
])
}
tone="sky"
/>
}
>
{value.map((item, index) => (
<div
key={`${item.id}-${index}`}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
#{index + 1}
</div>
<ActionButton
label="删除物品"
onClick={() =>
onChange(value.filter((_item, itemIndex) => itemIndex !== index))
}
/>
</div>
<Field label="名称">
<TextInput
value={item.name}
onChange={(nextValue) =>
updateItem(index, (current) => ({
...current,
name: nextValue,
}))
}
/>
</Field>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="分类">
<TextInput
value={item.category}
onChange={(nextValue) =>
updateItem(index, (current) => ({
...current,
category: nextValue,
}))
}
/>
</Field>
<Field label="稀有度">
<SelectField
value={item.rarity}
onChange={(nextValue) =>
updateItem(index, (current) => ({
...current,
rarity: nextValue as ItemRarity,
}))
}
options={ITEM_RARITY_OPTIONS}
/>
</Field>
</div>
<Field label="数量">
<TextInput
type="number"
value={item.quantity}
onChange={(nextValue) =>
updateItem(index, (current) => ({
...current,
quantity: Math.max(
1,
parseOptionalNumber(nextValue) ?? current.quantity,
),
}))
}
/>
</Field>
<Field label="描述">
<TextArea
value={item.description}
onChange={(nextValue) =>
updateItem(index, (current) => ({
...current,
description: nextValue,
}))
}
rows={3}
/>
</Field>
<Field label="标签">
<TextArea
value={commaText(item.tags)}
onChange={(nextValue) =>
updateItem(index, (current) => ({
...current,
tags: parseCommaText(nextValue),
}))
}
rows={2}
/>
</Field>
</div>
))}
</SectionPanel>
);
}
function StoryNpcVisualEditorModal({
npc,
visual,
@@ -873,7 +1332,7 @@ function PlayableNpcEditor({
return (
<ModalShell
title={mode === 'create' ? '新增可扮演角色' : `编辑角色:${npc.name}`}
subtitle="可为角色指定外观模板,结果页和正式选角都会同步使用。"
subtitle="这里可以直接修改可扮演角色的完整档案字段,结果页和正式选角都会同步使用。"
onClose={onClose}
>
<div className="space-y-4">
@@ -1026,6 +1485,35 @@ function PlayableNpcEditor({
rows={2}
/>
</Field>
<BackstoryRevealEditor
value={draft.backstoryReveal}
onChange={(backstoryReveal) =>
setDraft((current) => ({
...current,
backstoryReveal,
}))
}
/>
<SkillListEditor
value={draft.skills}
onChange={(skills) =>
setDraft((current) => ({
...current,
skills,
}))
}
labelSeed={draft.name || draft.id}
/>
<InitialItemsEditor
value={draft.initialItems}
onChange={(initialItems) =>
setDraft((current) => ({
...current,
initialItems,
}))
}
labelSeed={draft.name || draft.id}
/>
<SaveBar
onClose={onClose}
onSave={() => {
@@ -1059,7 +1547,7 @@ function StoryNpcEditor({
return (
<ModalShell
title={mode === 'create' ? '新增场景角色' : `编辑场景角色:${npc.name}`}
subtitle="场景角色形象编辑已拆分到独立面板,当前页面只保留档案信息与预览。"
subtitle="这里可以直接修改场景角色的完整档案字段,形象编辑仍保留在独立面板。"
onClose={onClose}
>
<div className="space-y-4">
@@ -1200,6 +1688,35 @@ function StoryNpcEditor({
rows={2}
/>
</Field>
<BackstoryRevealEditor
value={draft.backstoryReveal}
onChange={(backstoryReveal) =>
setDraft((current) => ({
...current,
backstoryReveal,
}))
}
/>
<SkillListEditor
value={draft.skills}
onChange={(skills) =>
setDraft((current) => ({
...current,
skills,
}))
}
labelSeed={draft.name || draft.id}
/>
<InitialItemsEditor
value={draft.initialItems}
onChange={(initialItems) =>
setDraft((current) => ({
...current,
initialItems,
}))
}
labelSeed={draft.name || draft.id}
/>
<SaveBar
onClose={onClose}
onSave={() => {

View File

@@ -0,0 +1,376 @@
import { motion } from 'motion/react';
import type {
CustomWorldGenerationProgress,
} from '../services/ai';
import { AnimationState, type Character } from '../types';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { CharacterAnimator } from './CharacterAnimator';
interface CustomWorldGenerationViewProps {
settingText: string;
actionPreviewCharacters: Character[];
progress: CustomWorldGenerationProgress | null;
isGenerating: boolean;
error: string | null;
onBack: () => void;
onEditSetting: () => void;
onRetry: () => void;
onInterrupt: () => void;
}
const ACTION_SHOWCASE: Array<{
label: string;
description: string;
state: AnimationState;
}> = [
{
label: '冲阵测试',
description: '检查角色前探、推进与开场压迫感。',
state: AnimationState.RUN,
},
{
label: '交战演示',
description: '预热战斗站姿与交锋节奏。',
state: AnimationState.ATTACK,
},
{
label: '驻场待命',
description: '确认角色在剧情停驻时的氛围姿态。',
state: AnimationState.IDLE,
},
] as const;
function formatDuration(ms: number) {
const safeMs = Math.max(0, Math.round(ms));
const totalSeconds = Math.ceil(safeMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes <= 0) {
return `${Math.max(1, seconds)}`;
}
if (seconds === 0) {
return `${minutes} 分钟`;
}
return `${minutes}${seconds}`;
}
function getProgressPercentage(progress: CustomWorldGenerationProgress | null) {
return Math.max(0, Math.min(100, progress?.overallProgress ?? 0));
}
export function CustomWorldGenerationView({
settingText,
actionPreviewCharacters,
progress,
isGenerating,
error,
onBack,
onEditSetting,
onRetry,
onInterrupt,
}: CustomWorldGenerationViewProps) {
const progressValue = getProgressPercentage(progress);
const steps = progress?.steps ?? [];
const estimatedWaitText =
progress?.estimatedRemainingMs != null
? `预计还需 ${formatDuration(progress.estimatedRemainingMs)}`
: '正在校准预计等待时间';
const elapsedText =
progress != null ? `已耗时 ${formatDuration(progress.elapsedMs)}` : '正在启动世界生成';
return (
<div
className="flex h-full min-h-0 flex-col overflow-y-auto overscroll-y-contain pr-1 pb-[max(1rem,env(safe-area-inset-bottom))]"
style={{ WebkitOverflowScrolling: 'touch' }}
>
<div className="sticky top-0 z-20 -mx-3 mb-4 flex items-center justify-between gap-3 bg-[linear-gradient(180deg,rgba(10,12,18,0.96),rgba(10,12,18,0.86),rgba(10,12,18,0))] px-3 pb-3 pt-1 sm:static sm:mx-0 sm:bg-none sm:px-0 sm:pb-0 sm:pt-0">
<button
type="button"
onClick={onBack}
disabled={isGenerating}
className={`rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'cursor-not-allowed opacity-45' : ''}`}
>
</button>
<div className="rounded-full border border-sky-300/16 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-sky-100">
{isGenerating ? '世界建设中' : error ? '生成已暂停' : '等待操作'}
</div>
</div>
<div className="grid flex-none gap-4 xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,1.1fr)_minmax(22rem,0.9fr)]">
<div className="flex flex-col gap-4 xl:min-h-0">
<section
className="pixel-nine-slice pixel-panel overflow-hidden"
style={getNineSliceStyle(UI_CHROME.storyPanel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="text-[11px] font-bold tracking-[0.2em] text-sky-100/85">
</div>
<div className="mt-1 text-sm text-zinc-400">
</div>
</div>
<button
type="button"
onClick={onEditSetting}
disabled={isGenerating}
className={`rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
>
</button>
</div>
<div className="rounded-2xl border border-white/8 bg-black/22 px-4 py-4 text-sm leading-7 text-zinc-200 md:max-h-[16rem] md:overflow-y-auto">
{settingText}
</div>
</section>
<section
className="pixel-nine-slice pixel-panel flex flex-col xl:min-h-0 xl:flex-1"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
</div>
<div className="mt-1 text-xl font-black leading-tight text-white sm:text-[2rem]">
{progress?.phaseLabel ?? '正在启动世界生成'}
</div>
<div className="mt-2 max-w-[36rem] text-sm leading-6 text-zinc-300">
{progress?.phaseDetail ?? '正在初始化世界生成链路与阶段监控。'}
</div>
</div>
<div className="shrink-0 sm:text-right">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 text-3xl font-black text-sky-100 sm:text-4xl">
{progressValue}%
</div>
</div>
</div>
<div className="mt-4 h-4 overflow-hidden rounded-full border border-white/10 bg-black/35">
<motion.div
className="h-full bg-[linear-gradient(90deg,#38bdf8_0%,#67e8f9_45%,#fde68a_100%)]"
animate={{ width: `${progressValue}%` }}
transition={{ duration: 0.35, ease: 'easeOut' }}
/>
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-3">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 text-sm font-semibold text-white">
{progress?.batchLabel ?? '准备中'}
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 text-sm font-semibold text-white">
{estimatedWaitText}
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 text-sm font-semibold text-white">
{elapsedText}
</div>
</div>
</div>
<div className="mt-4 space-y-2 xl:min-h-0 xl:flex-1 xl:overflow-y-auto xl:pr-1">
{steps.map((step) => (
<div
key={step.id}
className={`rounded-2xl border px-4 py-3 transition-colors ${
step.status === 'completed'
? 'border-emerald-400/16 bg-emerald-500/8'
: step.status === 'active'
? 'border-sky-300/22 bg-sky-500/10'
: 'border-white/8 bg-black/18'
}`}
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
{step.label}
</div>
<div className="text-xs text-zinc-300">
{step.completed}/{step.total}
</div>
</div>
<div className="mt-1 text-xs leading-6 text-zinc-400">
{step.detail}
</div>
</div>
))}
</div>
{error ? (
<div className="mt-4 rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{error}
</div>
) : null}
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:justify-end">
{!isGenerating ? (
<>
<button
type="button"
onClick={onEditSetting}
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-300 transition-colors hover:text-white"
>
</button>
<button
type="button"
onClick={onRetry}
className="pixel-nine-slice pixel-pressable w-full text-left sm:w-auto"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 16,
paddingY: 10,
})}
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white">
</span>
<span className="text-white/60"></span>
</div>
</button>
</>
) : (
<button
type="button"
onClick={onInterrupt}
className="rounded-full border border-rose-300/18 bg-rose-500/10 px-4 py-2 text-sm text-rose-100 transition-colors hover:text-white"
>
</button>
)}
</div>
</section>
</div>
<div className="flex flex-col gap-4 xl:min-h-0">
<section
className="pixel-nine-slice pixel-panel relative overflow-hidden"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
<motion.div
className="pointer-events-none absolute -left-8 top-0 h-36 w-36 rounded-full bg-sky-400/18 blur-3xl"
animate={{
opacity: [0.22, 0.48, 0.22],
scale: [0.92, 1.08, 0.92],
}}
transition={{ duration: 6.5, repeat: Infinity, ease: 'easeInOut' }}
/>
<motion.div
className="pointer-events-none absolute bottom-0 right-0 h-32 w-32 rounded-full bg-amber-200/12 blur-3xl"
animate={{ opacity: [0.18, 0.4, 0.18], scale: [1, 1.12, 1] }}
transition={{ duration: 7.2, repeat: Infinity, ease: 'easeInOut' }}
/>
<div className="relative z-10">
<div className="text-[11px] font-bold tracking-[0.2em] text-sky-100/85">
</div>
<div className="mt-2 text-xl font-black leading-tight text-white sm:text-2xl">
</div>
<div className="mt-3 max-w-[26rem] text-sm leading-6 text-zinc-300">
</div>
<div className="mt-5 grid grid-cols-2 gap-2 sm:grid-cols-3">
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-center text-xs text-zinc-200">
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-center text-xs text-zinc-200">
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-center text-xs text-zinc-200 col-span-2 sm:col-span-1">
</div>
</div>
</div>
</section>
<section
className="pixel-nine-slice pixel-panel xl:min-h-0 xl:flex-1"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="mb-3">
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
</div>
<div className="mt-1 text-sm leading-6 text-zinc-300">
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-3">
{ACTION_SHOWCASE.map((showcase, index) => {
const character =
actionPreviewCharacters[
index % Math.max(1, actionPreviewCharacters.length)
];
return (
<div
key={showcase.label}
className="rounded-[1.5rem] border border-white/8 bg-black/22 px-4 py-4"
>
<div className="flex h-28 items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(125,211,252,0.18),rgba(10,12,18,0.1)_38%,rgba(10,12,18,0.76)_100%)] sm:h-32">
{character ? (
<CharacterAnimator
state={showcase.state}
character={character}
className="h-full w-full"
imageClassName="object-bottom"
/>
) : null}
</div>
<div className="mt-3 text-sm font-semibold text-white">
{showcase.label}
</div>
<div className="mt-1 text-xs leading-6 text-zinc-400">
{showcase.description}
</div>
{character ? (
<div className="mt-3 rounded-full border border-sky-300/14 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.16em] text-sky-100">
{character.name}
</div>
) : null}
</div>
);
})}
</div>
</section>
</div>
</div>
</div>
);
}

View File

@@ -1,13 +1,19 @@
import { AnimatePresence, motion } from 'motion/react';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import {
buildCustomWorldPlayableCharacters,
PRESET_CHARACTERS,
} from '../../data/characterPresets';
import {
readSavedCustomWorldProfiles,
upsertSavedCustomWorldProfile,
} from '../../data/customWorldLibrary';
import { getScenePreset } from '../../data/scenePresets';
import { generateCustomWorldProfile } from '../../services/ai';
import {
type CustomWorldGenerationProgress,
generateCustomWorldProfile,
} from '../../services/ai';
import {
type CustomWorldProfile,
type GameState,
@@ -19,12 +25,17 @@ import {
UI_CHROME,
WORLD_SELECT_ICONS,
} from '../../uiAssets';
import { CustomWorldGenerationView } from '../CustomWorldGenerationView';
import { CustomWorldResultView } from '../CustomWorldResultView';
import { DeveloperTeamModal } from '../DeveloperTeamModal';
import { PixelIcon } from '../PixelIcon';
import { CustomWorldCreatorModal } from '../SelectionCustomizationModals';
export type SelectionStage = 'start' | 'world' | 'custom-world-result';
export type SelectionStage =
| 'start'
| 'world'
| 'custom-world-generating'
| 'custom-world-result';
type WorldOnlineCounts = Partial<Record<WorldType, number>>;
@@ -66,6 +77,8 @@ const WORLD_OPTIONS = [
},
] as const;
const GENERATION_PREVIEW_CHARACTERS = PRESET_CHARACTERS.slice(0, 3);
function generateWorldOnlineCounts(): WorldOnlineCounts {
const roll = (base: number) =>
Math.max(100, Math.min(200, base + Math.floor(Math.random() * 19) - 9));
@@ -75,22 +88,6 @@ function generateWorldOnlineCounts(): WorldOnlineCounts {
};
}
function getCustomWorldGenerationLabel(progress: number) {
if (progress >= 96) return '正在完成世界归档...';
if (progress >= 78) return '正在关联地标和关键物品...';
if (progress >= 52) return '正在生成核心角色...';
if (progress >= 28) return '正在生成可玩角色...';
return '正在解析世界设置...';
}
function getCustomWorldProgressLabel(progress: number) {
if (progress >= 96) return '正在完成世界归档...';
if (progress >= 78) return '正在组合场景和视觉效果...';
if (progress >= 52) return '正在生成核心角色...';
if (progress >= 28) return '正在生成可玩角色...';
return '正在解析世界设置...';
}
export function PreGameSelectionFlow({
selectionStage,
setSelectionStage,
@@ -113,7 +110,9 @@ export function PreGameSelectionFlow({
const [customWorldDraft, setCustomWorldDraft] = useState('');
const [customWorldError, setCustomWorldError] = useState<string | null>(null);
const [isGeneratingCustomWorld, setIsGeneratingCustomWorld] = useState(false);
const [customWorldProgress, setCustomWorldProgress] = useState(0);
const [customWorldProgress, setCustomWorldProgress] =
useState<CustomWorldGenerationProgress | null>(null);
const customWorldAbortControllerRef = useRef<AbortController | null>(null);
const previewCustomWorldCharacters = useMemo(
() =>
@@ -186,13 +185,51 @@ export function PreGameSelectionFlow({
}
}, [generatedCustomWorldProfile, selectionStage, setSelectionStage]);
useEffect(
() => () => {
customWorldAbortControllerRef.current?.abort();
},
[],
);
const leaveCustomWorldResult = () => {
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldProgress(0);
setCustomWorldProgress(null);
setSelectionStage('world');
};
const leaveCustomWorldGeneration = () => {
if (isGeneratingCustomWorld) {
return;
}
setCustomWorldError(null);
setCustomWorldProgress(null);
setSelectionStage('world');
};
const openCustomWorldCreator = () => {
if (isGeneratingCustomWorld) {
return;
}
setCustomWorldError(null);
setCustomWorldProgress(null);
setShowCustomWorldModal(true);
};
const editCustomWorldSetting = () => {
if (isGeneratingCustomWorld) {
return;
}
setCustomWorldError(null);
setCustomWorldProgress(null);
setSelectionStage('world');
setShowCustomWorldModal(true);
};
const saveGeneratedCustomWorld = () => {
if (!generatedCustomWorldProfile) {
return;
@@ -212,51 +249,73 @@ export function PreGameSelectionFlow({
handleWorldSelect(WorldType.CUSTOM, generatedCustomWorldProfile);
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldProgress(0);
setCustomWorldProgress(null);
setSelectionStage('world');
};
const createCustomWorld = async () => {
if (isGeneratingCustomWorld) {
return;
}
const settingText = customWorldDraft.trim();
if (!settingText) {
setCustomWorldError('请先输入世界设置。');
return;
}
const abortController = new AbortController();
customWorldAbortControllerRef.current?.abort();
customWorldAbortControllerRef.current = abortController;
setCustomWorldError(null);
setGeneratedCustomWorldProfile(null);
setCustomWorldProgress(null);
setShowCustomWorldModal(false);
setSelectionStage('custom-world-generating');
setIsGeneratingCustomWorld(true);
setCustomWorldProgress(8);
const progressTimer = window.setInterval(() => {
setCustomWorldProgress((current) => {
if (current >= 92) return current;
return Math.min(
92,
current + Math.max(3, Math.round((96 - current) / 5)),
);
});
}, 260);
try {
const profile = await generateCustomWorldProfile(settingText);
window.clearInterval(progressTimer);
setCustomWorldProgress(100);
await new Promise((resolve) => window.setTimeout(resolve, 180));
const profile = await generateCustomWorldProfile(settingText, {
signal: abortController.signal,
onProgress: setCustomWorldProgress,
});
if (abortController.signal.aborted) {
return;
}
setGeneratedCustomWorldProfile(profile);
setShowCustomWorldModal(false);
setCustomWorldError(null);
setSelectionStage('custom-world-result');
} catch (error) {
window.clearInterval(progressTimer);
setCustomWorldProgress(0);
if (abortController.signal.aborted) {
setCustomWorldError('世界生成已中断。你可以返回修改设定,或重新开始。');
return;
}
setCustomWorldError(
error instanceof Error ? error.message : '生成自定义世界失败。',
);
} finally {
if (customWorldAbortControllerRef.current === abortController) {
customWorldAbortControllerRef.current = null;
}
setIsGeneratingCustomWorld(false);
}
};
const interruptCustomWorldGeneration = () => {
if (!isGeneratingCustomWorld || !customWorldAbortControllerRef.current) {
return;
}
const confirmed = window.confirm(
'确认中断当前世界生成吗?本轮未完成的内容不会保留。',
);
if (!confirmed) {
return;
}
customWorldAbortControllerRef.current.abort(new Error('世界生成已中断。'));
};
return (
<>
<AnimatePresence mode="wait">
@@ -296,7 +355,7 @@ export function PreGameSelectionFlow({
setGeneratedCustomWorldProfile(null);
setCustomWorldDraft('');
setCustomWorldError(null);
setCustomWorldProgress(0);
setCustomWorldProgress(null);
setShowCustomWorldModal(false);
setSelectionStage('world');
}}
@@ -495,12 +554,7 @@ export function PreGameSelectionFlow({
<button
type="button"
onClick={() => {
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldProgress(0);
setShowCustomWorldModal(true);
}}
onClick={openCustomWorldCreator}
className="pixel-nine-slice pixel-pressable order-first relative flex min-h-[12.5rem] flex-col items-start justify-between overflow-hidden text-left"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 18,
@@ -533,6 +587,31 @@ export function PreGameSelectionFlow({
</motion.div>
)}
{!gameState.worldType &&
selectionStage === 'custom-world-generating' && (
<motion.div
key="custom-world-generating"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<CustomWorldGenerationView
settingText={customWorldDraft.trim()}
actionPreviewCharacters={GENERATION_PREVIEW_CHARACTERS}
progress={customWorldProgress}
isGenerating={isGeneratingCustomWorld}
error={customWorldError}
onBack={leaveCustomWorldGeneration}
onEditSetting={editCustomWorldSetting}
onRetry={() => {
void createCustomWorld();
}}
onInterrupt={interruptCustomWorldGeneration}
/>
</motion.div>
)}
{!gameState.worldType &&
selectionStage === 'custom-world-result' &&
generatedCustomWorldProfile && (
@@ -547,16 +626,12 @@ export function PreGameSelectionFlow({
profile={generatedCustomWorldProfile}
previewCharacters={previewCustomWorldCharacters}
isGenerating={isGeneratingCustomWorld}
progress={customWorldProgress}
progressLabel={getCustomWorldProgressLabel(customWorldProgress)}
progress={customWorldProgress?.overallProgress ?? 0}
progressLabel={customWorldProgress?.phaseLabel ?? ''}
error={customWorldError}
onProfileChange={setGeneratedCustomWorldProfile}
onBack={leaveCustomWorldResult}
onEditSetting={() => {
setCustomWorldError(null);
setCustomWorldProgress(0);
setShowCustomWorldModal(true);
}}
onEditSetting={editCustomWorldSetting}
onRegenerate={() => {
void createCustomWorld();
}}
@@ -581,8 +656,8 @@ export function PreGameSelectionFlow({
void createCustomWorld();
}}
isGenerating={isGeneratingCustomWorld}
progress={customWorldProgress}
progressLabel={getCustomWorldGenerationLabel(customWorldProgress)}
progress={customWorldProgress?.overallProgress ?? 0}
progressLabel={customWorldProgress?.phaseLabel ?? '正在准备生成'}
error={customWorldError}
/>

View File

@@ -1882,7 +1882,7 @@ export function buildCharacterBackstoryPromptContext(
...getUnlockedCharacterBackstoryChapters(character, affinity, worldType)
.map(chapter => chapter.contextSnippet.trim())
.filter(Boolean),
].filter(Boolean);
].filter((snippet): snippet is string => Boolean(snippet));
}
export function getCharacterHomeSceneId(worldType: WorldType, characterId: string) {

View File

@@ -0,0 +1,196 @@
import { afterEach, describe, expect, it } from 'vitest';
import { buildExpandedCustomWorldProfile } from '../services/customWorldBuilder';
import { WorldType } from '../types';
import { setRuntimeCustomWorldProfile } from './customWorldRuntime';
import { buildEncounterFromSceneNpc, getScenePresetsByWorld } from './scenePresets';
function createPlayableNpc(index: number) {
return {
name: `可扮演角色${index + 1}`,
title: `可扮演头衔${index + 1}`,
role: `可扮演身份${index + 1}`,
description: `可扮演角色描述${index + 1}`,
backstory: `可扮演角色背景${index + 1}`,
personality: `可扮演角色性格${index + 1}`,
motivation: `可扮演角色动机${index + 1}`,
combatStyle: `可扮演角色战斗风格${index + 1}`,
initialAffinity: 18,
relationshipHooks: [`切入口${index + 1}`],
tags: [`标签${index + 1}`],
backstoryReveal: {
publicSummary: `公开背景${index + 1}`,
chapters: [
{
id: `surface-${index + 1}`,
title: '表层来意',
affinityRequired: 10,
teaser: `提示${index + 1}-1`,
content: `内容${index + 1}-1`,
contextSnippet: `摘要${index + 1}-1`,
},
{
id: `scar-${index + 1}`,
title: '旧事裂痕',
affinityRequired: 30,
teaser: `提示${index + 1}-2`,
content: `内容${index + 1}-2`,
contextSnippet: `摘要${index + 1}-2`,
},
{
id: `hidden-${index + 1}`,
title: '隐藏执念',
affinityRequired: 55,
teaser: `提示${index + 1}-3`,
content: `内容${index + 1}-3`,
contextSnippet: `摘要${index + 1}-3`,
},
{
id: `final-${index + 1}`,
title: '最终底牌',
affinityRequired: 80,
teaser: `提示${index + 1}-4`,
content: `内容${index + 1}-4`,
contextSnippet: `摘要${index + 1}-4`,
},
],
},
skills: [
{ name: `技能${index + 1}-1`, summary: '技能摘要1', style: '起手压制' },
{ name: `技能${index + 1}-2`, summary: '技能摘要2', style: '机动周旋' },
{ name: `技能${index + 1}-3`, summary: '技能摘要3', style: '爆发终结' },
],
initialItems: [
{
name: `物品${index + 1}-1`,
category: '武器',
quantity: 1,
rarity: 'rare',
description: '物品描述1',
tags: ['物品标签1'],
},
{
name: `物品${index + 1}-2`,
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: '物品描述2',
tags: ['物品标签2'],
},
{
name: `物品${index + 1}-3`,
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '物品描述3',
tags: ['物品标签3'],
},
],
};
}
describe('scenePresets custom world npc mapping', () => {
afterEach(() => {
setRuntimeCustomWorldProfile(null);
});
it('preserves custom world npc dossier fields into scene npcs and encounters', () => {
const profile = buildExpandedCustomWorldProfile(
{
name: '雾潮世界',
subtitle: '潮雾未散',
summary: '一座围绕码头、断桥和旧潮路展开的自定义世界。',
tone: '克制、潮湿、危险',
playerGoal: '查清雾潮里失踪的人和桥下的旧案。',
templateWorldType: 'WUXIA',
playableNpcs: Array.from({ length: 5 }, (_, index) =>
createPlayableNpc(index),
),
storyNpcs: [
{
...createPlayableNpc(10),
name: '沈雾',
title: '潮路领航人',
role: '码头向导',
description: '熟悉潮路和暗栈的旧向导。',
backstory: '少年时曾在断桥坠潮夜里失去整队同伴。',
personality: '谨慎冷静,先观察再表态。',
motivation: '想把雾潮深处那条失踪航线重新找出来。',
combatStyle: '短刀试探后再借地形逼近。',
relationshipHooks: ['潮路', '断桥旧案'],
tags: ['码头', '旧潮路'],
imageSrc: '/custom/npcs/shenwu.png',
},
{
...createPlayableNpc(11),
name: '陆沉',
title: '断桥守更',
role: '守桥人',
description: '夜里守着断桥口的旧灯火。',
},
{
...createPlayableNpc(12),
name: '顾潮',
title: '潮册记录员',
role: '记录员',
description: '在潮账房里整理各路失踪名单。',
},
],
landmarks: [
{
name: '雾潮码头',
description: '旧船桩和潮雾把视线切成断续的几段。',
dangerLevel: 'high',
sceneNpcNames: ['沈雾', '陆沉', '顾潮'],
connections: [
{
targetLandmarkName: '断桥旧道',
relativePosition: 'north',
summary: '顺着潮路向北可抵断桥。',
},
],
},
{
name: '断桥旧道',
description: '半塌的桥面上还挂着旧索和残旗。',
dangerLevel: 'high',
sceneNpcNames: ['沈雾', '陆沉', '顾潮'],
connections: [
{
targetLandmarkName: '雾潮码头',
relativePosition: 'south',
summary: '沿旧潮路南返能回码头。',
},
],
},
],
},
'玩家想要一个围绕雾潮码头与断桥旧案展开的世界。',
);
setRuntimeCustomWorldProfile(profile);
const scene = getScenePresetsByWorld(WorldType.CUSTOM).find(
(entry) => entry.name === '雾潮码头',
);
const npc = scene?.npcs?.find((entry) => entry.name === '沈雾');
expect(scene).toBeTruthy();
expect(npc).toBeTruthy();
expect(npc?.title).toBe('潮路领航人');
expect(npc?.backstory).toContain('断桥坠潮夜');
expect(npc?.personality).toContain('谨慎冷静');
expect(npc?.motivation).toContain('失踪航线');
expect(npc?.skills).toHaveLength(3);
expect(npc?.initialItems).toHaveLength(3);
expect(npc?.avatar).toBe('/custom/npcs/shenwu.png');
const encounter = buildEncounterFromSceneNpc(npc!);
expect(encounter.title).toBe('潮路领航人');
expect(encounter.backstoryReveal?.publicSummary).toBe('公开背景11');
expect(encounter.skills?.[0]?.name).toBe('技能11-1');
expect(encounter.initialItems?.[0]?.name).toBe('物品11-1');
expect(encounter.imageSrc).toBe('/custom/npcs/shenwu.png');
});
});

View File

@@ -273,6 +273,18 @@ export function buildEncounterFromSceneNpc(
initialAffinity: npc.initialAffinity,
hostile: isHostileSceneNpc(npc),
attributeProfile: npc.attributeProfile,
title: npc.title,
backstory: npc.backstory,
personality: npc.personality,
motivation: npc.motivation,
combatStyle: npc.combatStyle,
relationshipHooks: npc.relationshipHooks,
tags: npc.tags,
backstoryReveal: npc.backstoryReveal,
skills: npc.skills,
initialItems: npc.initialItems,
imageSrc: npc.imageSrc,
visual: npc.visual,
};
}
@@ -307,8 +319,9 @@ function buildCustomSceneNpc(
return {
id: npc.id,
name: npc.name,
title: npc.title,
role: npc.role,
avatar: npc.name.slice(0, 1) || '?',
avatar: (npc.imageSrc ?? npc.name.slice(0, 1)) || '?',
description: [
npc.description,
npc.backstoryReveal.publicSummary
@@ -336,6 +349,20 @@ function buildCustomSceneNpc(
? ['fight']
: ['trade', 'fight', 'spar', 'help', 'chat', 'recruit', 'gift'],
attributeProfile,
backstory: npc.backstory,
personality: npc.personality,
motivation: npc.motivation,
combatStyle: npc.combatStyle,
relationshipHooks: [...npc.relationshipHooks],
tags: [...npc.tags],
backstoryReveal: npc.backstoryReveal,
skills: npc.skills.map((skill) => ({ ...skill })),
initialItems: npc.initialItems.map((item) => ({
...item,
tags: [...item.tags],
})),
imageSrc: npc.imageSrc,
visual: npc.visual,
};
}

View File

@@ -401,6 +401,23 @@ function buildStoryContextFromState(
encounterContext: state.currentEncounter?.context ?? null,
encounterCharacterId: state.currentEncounter?.characterId ?? null,
encounterGender: state.currentEncounter?.gender ?? null,
encounterCustomProfile: state.currentEncounter
? {
title: state.currentEncounter.title ?? '',
description: state.currentEncounter.npcDescription ?? '',
backstory: state.currentEncounter.backstory ?? '',
personality: state.currentEncounter.personality ?? '',
motivation: state.currentEncounter.motivation ?? '',
combatStyle: state.currentEncounter.combatStyle ?? '',
relationshipHooks: [...(state.currentEncounter.relationshipHooks ?? [])],
tags: [...(state.currentEncounter.tags ?? [])],
backstoryReveal: state.currentEncounter.backstoryReveal,
skills: [...(state.currentEncounter.skills ?? [])],
initialItems: [...(state.currentEncounter.initialItems ?? [])],
imageSrc: state.currentEncounter.imageSrc,
visual: state.currentEncounter.visual,
}
: null,
encounterAffinity: encounterDirective?.affinity ?? null,
encounterAffinityText,
encounterConversationStyle: encounterDirective?.style ?? null,

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}`) : []),

View File

@@ -3,8 +3,10 @@ import type {
RoleAttributeProfile,
RoleRelationState,
} from './attributes';
import type {Character} from './characters';
import type {CustomWorldSceneRelativePosition} from './customWorld';
import type {
Character,
CharacterBackstoryRevealConfig,
} from './characters';
import {
AnimationState,
type CharacterGender,
@@ -14,6 +16,12 @@ import {
type HostileNpcRenderAnimation,
type NpcFunctionType,
} from './core';
import type {
CustomWorldNpcVisual,
CustomWorldRoleInitialItem,
CustomWorldRoleSkill,
CustomWorldSceneRelativePosition,
} from './customWorld';
import type {InventoryItem} from './items';
export interface NpcPersistentState {
@@ -81,6 +89,18 @@ export interface Encounter {
initialAffinity?: number;
hostile?: boolean;
attributeProfile?: RoleAttributeProfile;
title?: string;
backstory?: string;
personality?: string;
motivation?: string;
combatStyle?: string;
relationshipHooks?: string[];
tags?: string[];
backstoryReveal?: CharacterBackstoryRevealConfig;
skills?: CustomWorldRoleSkill[];
initialItems?: CustomWorldRoleInitialItem[];
imageSrc?: string;
visual?: CustomWorldNpcVisual;
}
export interface SceneHostileNpc {
@@ -113,6 +133,7 @@ export interface SceneNpc {
description: string;
avatar: string;
role: string;
title?: string;
gender?: CharacterGender;
characterId?: string;
hostileNpcPresetId?: string;
@@ -122,6 +143,17 @@ export interface SceneNpc {
functions?: NpcFunctionType[];
recruitable?: boolean;
attributeProfile?: RoleAttributeProfile;
backstory?: string;
personality?: string;
motivation?: string;
combatStyle?: string;
relationshipHooks?: string[];
tags?: string[];
backstoryReveal?: CharacterBackstoryRevealConfig;
skills?: CustomWorldRoleSkill[];
initialItems?: CustomWorldRoleInitialItem[];
imageSrc?: string;
visual?: CustomWorldNpcVisual;
}
export type SceneEncounterKind = 'npc' | 'treasure' | 'none';