Fix DashScope env loading for scene image generation
This commit is contained in:
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
376
src/components/CustomWorldGenerationView.tsx
Normal file
376
src/components/CustomWorldGenerationView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
196
src/data/scenePresets.test.ts
Normal file
196
src/data/scenePresets.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,且自动修复仍未成功,请稍后重试。',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`) : []),
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user