1
This commit is contained in:
@@ -1,21 +1,28 @@
|
||||
import { type ReactNode, useDeferredValue, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
getCustomWorldSceneRelativePositionLabel,
|
||||
} from '../data/customWorldSceneGraph';
|
||||
type ReactNode,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph';
|
||||
import {
|
||||
resolveCustomWorldCampSceneImage,
|
||||
resolveCustomWorldLandmarkImageMap,
|
||||
} from '../data/customWorldVisuals';
|
||||
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
|
||||
import { buildCustomWorldCreatorIntentDisplayText } from '../services/customWorldCreatorIntent';
|
||||
import {
|
||||
buildCustomWorldCreatorIntentDisplayText,
|
||||
normalizeCustomWorldCreatorIntent,
|
||||
} from '../services/customWorldCreatorIntent';
|
||||
import { AnimationState, Character, CustomWorldProfile } from '../types';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import type { CustomWorldEditorTarget } from './CustomWorldEntityEditorModal';
|
||||
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
|
||||
|
||||
export type ResultTab = 'world' | 'anchors' | 'playable' | 'story' | 'landmarks';
|
||||
export type ResultTab = 'world' | 'playable' | 'story' | 'landmarks';
|
||||
|
||||
interface CustomWorldEntityCatalogProps {
|
||||
profile: CustomWorldProfile;
|
||||
@@ -28,11 +35,11 @@ interface CustomWorldEntityCatalogProps {
|
||||
onDeleteLandmarks?: (ids: string[]) => void;
|
||||
createActionLabel?: string;
|
||||
onCreateAction?: () => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const RESULT_TABS: Array<{ id: ResultTab; label: string }> = [
|
||||
{ id: 'world', label: '世界' },
|
||||
{ id: 'anchors', label: '锚点' },
|
||||
{ id: 'playable', label: '可扮演角色' },
|
||||
{ id: 'story', label: '场景角色' },
|
||||
{ id: 'landmarks', label: '场景' },
|
||||
@@ -50,11 +57,20 @@ function Section({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 14, paddingY: 12 })}>
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 14, paddingY: 12 })}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-bold tracking-[0.16em] text-white">{title}</div>
|
||||
{subtitle ? <div className="mt-1 text-xs leading-6 text-zinc-500">{subtitle}</div> : null}
|
||||
<div className="text-xs font-bold tracking-[0.16em] text-white">
|
||||
{title}
|
||||
</div>
|
||||
{subtitle ? (
|
||||
<div className="mt-1 text-xs leading-6 text-zinc-500">
|
||||
{subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
@@ -72,11 +88,12 @@ function SmallButton({
|
||||
children: ReactNode;
|
||||
tone?: 'default' | 'sky' | 'rose';
|
||||
}) {
|
||||
const toneClassName = tone === 'sky'
|
||||
? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white'
|
||||
: tone === 'rose'
|
||||
? 'border-rose-300/20 bg-rose-500/10 text-rose-100 hover:text-white'
|
||||
: 'border-white/10 bg-black/20 text-zinc-300 hover:text-white';
|
||||
const toneClassName =
|
||||
tone === 'sky'
|
||||
? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white'
|
||||
: tone === 'rose'
|
||||
? 'border-rose-300/20 bg-rose-500/10 text-rose-100 hover:text-white'
|
||||
: 'border-white/10 bg-black/20 text-zinc-300 hover:text-white';
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -102,7 +119,7 @@ function SearchBox({
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-3 py-2">
|
||||
<input
|
||||
value={value}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full bg-transparent text-sm text-zinc-100 outline-none placeholder:text-zinc-500"
|
||||
/>
|
||||
@@ -122,7 +139,9 @@ function ImageFrame({
|
||||
tone?: 'square' | 'landscape';
|
||||
}) {
|
||||
return (
|
||||
<div className={`overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] ${tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'}`}>
|
||||
<div
|
||||
className={`overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] ${tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'}`}
|
||||
>
|
||||
{src ? (
|
||||
<img src={src} alt={alt} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
@@ -149,6 +168,7 @@ function CatalogCard({
|
||||
isSelectionMode,
|
||||
isSelected,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -156,15 +176,19 @@ function CatalogCard({
|
||||
isSelectionMode: boolean;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`w-full rounded-[1.4rem] border p-3 text-left transition-colors ${
|
||||
isSelected
|
||||
? 'border-rose-300/35 bg-rose-500/10'
|
||||
: 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-black/28'
|
||||
: disabled
|
||||
? 'border-white/10 bg-black/20'
|
||||
: 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-black/28'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
@@ -172,7 +196,9 @@ function CatalogCard({
|
||||
{media}
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 text-base font-semibold text-white">{title}</div>
|
||||
<div className="min-w-0 text-base font-semibold text-white">
|
||||
{title}
|
||||
</div>
|
||||
{isSelectionMode ? (
|
||||
<div
|
||||
className={`shrink-0 rounded-full border px-2.5 py-1 text-[10px] ${
|
||||
@@ -204,6 +230,96 @@ function getSearchPlaceholder(tab: ResultTab) {
|
||||
return '搜索';
|
||||
}
|
||||
|
||||
function compactTextList(values: Array<string | null | undefined>) {
|
||||
return values.map((value) => value?.trim() ?? '').filter(Boolean);
|
||||
}
|
||||
|
||||
function buildOpeningSceneSearchText(
|
||||
profile: CustomWorldProfile,
|
||||
campScene: ReturnType<typeof resolveCustomWorldCampScene>,
|
||||
) {
|
||||
return [
|
||||
campScene.name,
|
||||
campScene.description,
|
||||
campScene.dangerLevel,
|
||||
profile.playerGoal,
|
||||
profile.summary,
|
||||
'开局场景',
|
||||
'开局归处',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function buildStructuredFoundationEntries(profile: CustomWorldProfile) {
|
||||
const creatorIntent = normalizeCustomWorldCreatorIntent(profile.creatorIntent);
|
||||
const relationshipSeed = creatorIntent?.keyCharacters[0];
|
||||
const relationshipText = relationshipSeed
|
||||
? compactTextList([
|
||||
relationshipSeed.name,
|
||||
relationshipSeed.role,
|
||||
relationshipSeed.relationToPlayer
|
||||
? `与玩家:${relationshipSeed.relationToPlayer}`
|
||||
: '',
|
||||
relationshipSeed.hiddenHook
|
||||
? `暗线:${relationshipSeed.hiddenHook}`
|
||||
: '',
|
||||
]).join(' · ')
|
||||
: '';
|
||||
const themeToneText = compactTextList([
|
||||
creatorIntent?.themeKeywords.join('、') || '',
|
||||
creatorIntent?.toneDirectives.join('、') || '',
|
||||
]).join(' / ');
|
||||
const playerOpeningText = compactTextList([
|
||||
creatorIntent?.playerPremise || '',
|
||||
creatorIntent?.openingSituation || '',
|
||||
]).join(';');
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'world-hook',
|
||||
label: '世界核心',
|
||||
value:
|
||||
creatorIntent?.worldHook ||
|
||||
profile.anchorPack?.worldSummary ||
|
||||
profile.summary,
|
||||
},
|
||||
{
|
||||
id: 'player-opening',
|
||||
label: '玩家开局',
|
||||
value: playerOpeningText || profile.playerGoal,
|
||||
},
|
||||
{
|
||||
id: 'theme-tone',
|
||||
label: '主题气质',
|
||||
value: themeToneText || profile.tone,
|
||||
},
|
||||
{
|
||||
id: 'core-conflict',
|
||||
label: '核心冲突',
|
||||
value:
|
||||
creatorIntent?.coreConflicts.join(';') ||
|
||||
profile.coreConflicts.join(';') ||
|
||||
profile.summary,
|
||||
},
|
||||
{
|
||||
id: 'relationship-seed',
|
||||
label: '关键关系',
|
||||
value:
|
||||
relationshipText ||
|
||||
profile.playableNpcs[0]?.relationshipHooks.join(';') ||
|
||||
profile.storyNpcs[0]?.relationshipHooks.join(';') ||
|
||||
'待补充',
|
||||
},
|
||||
{
|
||||
id: 'iconic-elements',
|
||||
label: '标志元素',
|
||||
value:
|
||||
creatorIntent?.iconicElements.join('、') ||
|
||||
profile.anchorPack?.motifDirectives.join('、') ||
|
||||
'待补充',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
type CatalogRole =
|
||||
| CustomWorldProfile['playableNpcs'][number]
|
||||
| CustomWorldProfile['storyNpcs'][number];
|
||||
@@ -268,9 +384,12 @@ export function CustomWorldEntityCatalog({
|
||||
onDeleteLandmarks,
|
||||
createActionLabel,
|
||||
onCreateAction,
|
||||
readOnly = false,
|
||||
}: CustomWorldEntityCatalogProps) {
|
||||
const [searchDraft, setSearchDraft] = useState('');
|
||||
const [bulkDeleteMode, setBulkDeleteMode] = useState<BulkDeleteTab | null>(null);
|
||||
const [bulkDeleteMode, setBulkDeleteMode] = useState<BulkDeleteTab | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedBulkIds, setSelectedBulkIds] = useState<string[]>([]);
|
||||
const deferredSearch = useDeferredValue(searchDraft.trim());
|
||||
|
||||
@@ -286,45 +405,101 @@ export function CustomWorldEntityCatalog({
|
||||
() => resolveCustomWorldLandmarkImageMap(profile),
|
||||
[profile],
|
||||
);
|
||||
const resolvedCampScene = useMemo(() => resolveCustomWorldCampScene(profile), [profile]);
|
||||
const resolvedCampScene = useMemo(
|
||||
() => resolveCustomWorldCampScene(profile),
|
||||
[profile],
|
||||
);
|
||||
const resolvedCampImageSrc = useMemo(
|
||||
() => resolveCustomWorldCampSceneImage(profile),
|
||||
[profile],
|
||||
);
|
||||
const previewCharacterById = useMemo(
|
||||
() => new Map(profile.playableNpcs.map((role, index) => [role.id, previewCharacters[index] ?? null])),
|
||||
() =>
|
||||
new Map(
|
||||
profile.playableNpcs.map((role, index) => [
|
||||
role.id,
|
||||
previewCharacters[index] ?? null,
|
||||
]),
|
||||
),
|
||||
[previewCharacters, profile.playableNpcs],
|
||||
);
|
||||
|
||||
const filteredPlayable = useMemo(
|
||||
() => profile.playableNpcs.filter(role =>
|
||||
!deferredSearch
|
||||
|| matchText(buildRoleSearchText(role), deferredSearch),
|
||||
),
|
||||
() =>
|
||||
profile.playableNpcs.filter(
|
||||
(role) =>
|
||||
!deferredSearch ||
|
||||
matchText(buildRoleSearchText(role), deferredSearch),
|
||||
),
|
||||
[deferredSearch, profile.playableNpcs],
|
||||
);
|
||||
|
||||
const filteredStory = useMemo(
|
||||
() => profile.storyNpcs.filter(npc =>
|
||||
!deferredSearch
|
||||
|| matchText(buildRoleSearchText(npc), deferredSearch),
|
||||
),
|
||||
() =>
|
||||
profile.storyNpcs.filter(
|
||||
(npc) =>
|
||||
!deferredSearch ||
|
||||
matchText(buildRoleSearchText(npc), deferredSearch),
|
||||
),
|
||||
[deferredSearch, profile.storyNpcs],
|
||||
);
|
||||
|
||||
const filteredLandmarks = useMemo(
|
||||
() => profile.landmarks.filter(landmark =>
|
||||
!deferredSearch
|
||||
|| matchText(
|
||||
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
|
||||
deferredSearch,
|
||||
() =>
|
||||
profile.landmarks.filter(
|
||||
(landmark) =>
|
||||
!deferredSearch ||
|
||||
matchText(
|
||||
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
|
||||
deferredSearch,
|
||||
),
|
||||
),
|
||||
),
|
||||
[deferredSearch, landmarkById, profile.landmarks, storyNpcById],
|
||||
);
|
||||
const structuredFoundationEntries = useMemo(
|
||||
() => buildStructuredFoundationEntries(profile),
|
||||
[profile],
|
||||
);
|
||||
const filteredSceneEntries = useMemo(() => {
|
||||
const openingSceneEntry = {
|
||||
id: 'custom-world-opening-scene',
|
||||
kind: 'camp' as const,
|
||||
name: resolvedCampScene.name,
|
||||
description: resolvedCampScene.description,
|
||||
imageSrc: resolvedCampImageSrc,
|
||||
searchText: buildOpeningSceneSearchText(profile, resolvedCampScene),
|
||||
};
|
||||
const landmarkEntries = filteredLandmarks.map((landmark) => ({
|
||||
id: landmark.id,
|
||||
kind: 'landmark' as const,
|
||||
name: landmark.name,
|
||||
description: landmark.description,
|
||||
imageSrc: landmarkImageById.get(landmark.id) ?? landmark.imageSrc,
|
||||
searchText: buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
|
||||
}));
|
||||
const allEntries = [openingSceneEntry, ...landmarkEntries];
|
||||
|
||||
if (!deferredSearch) {
|
||||
return allEntries;
|
||||
}
|
||||
|
||||
return allEntries.filter((entry) =>
|
||||
matchText(entry.searchText, deferredSearch),
|
||||
);
|
||||
}, [
|
||||
deferredSearch,
|
||||
filteredLandmarks,
|
||||
landmarkById,
|
||||
landmarkImageById,
|
||||
profile,
|
||||
resolvedCampImageSrc,
|
||||
resolvedCampScene,
|
||||
storyNpcById,
|
||||
]);
|
||||
|
||||
const creatorIntentSummary = useMemo(
|
||||
() => buildCustomWorldCreatorIntentDisplayText(profile.creatorIntent).trim(),
|
||||
() =>
|
||||
buildCustomWorldCreatorIntentDisplayText(profile.creatorIntent).trim(),
|
||||
[profile.creatorIntent],
|
||||
);
|
||||
const lockedCharacterNames = useMemo(
|
||||
@@ -340,15 +515,15 @@ export function CustomWorldEntityCatalog({
|
||||
|
||||
const counts = {
|
||||
world: 1,
|
||||
anchors: 1,
|
||||
playable: profile.playableNpcs.length,
|
||||
story: profile.storyNpcs.length,
|
||||
landmarks: profile.landmarks.length,
|
||||
landmarks: profile.landmarks.length + 1,
|
||||
} satisfies Record<ResultTab, number>;
|
||||
|
||||
const bulkDeleteTab: BulkDeleteTab | null =
|
||||
activeTab === 'story' || activeTab === 'landmarks' ? activeTab : null;
|
||||
const isBulkDeleteMode = bulkDeleteMode === bulkDeleteTab;
|
||||
const isBulkDeleteMode =
|
||||
bulkDeleteTab !== null && bulkDeleteMode === bulkDeleteTab;
|
||||
|
||||
useEffect(() => {
|
||||
if (bulkDeleteMode && bulkDeleteMode !== activeTab) {
|
||||
@@ -365,7 +540,7 @@ export function CustomWorldEntityCatalog({
|
||||
if (!window.confirm(`确认删除可扮演角色「${name}」吗?`)) return;
|
||||
onProfileChange({
|
||||
...profile,
|
||||
playableNpcs: profile.playableNpcs.filter(role => role.id !== id),
|
||||
playableNpcs: profile.playableNpcs.filter((role) => role.id !== id),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -411,14 +586,20 @@ export function CustomWorldEntityCatalog({
|
||||
return (
|
||||
<div className="h-full min-h-0 space-y-3 overflow-y-auto overscroll-contain pr-1 scrollbar-hide">
|
||||
<div className="px-1 pb-1 text-center">
|
||||
<div className="text-[11px] font-bold tracking-[0.28em] text-zinc-500">世界档案</div>
|
||||
<div className="mt-2 text-3xl font-black text-white sm:text-[2.2rem]">{profile.name}</div>
|
||||
<div className="mt-2 text-sm tracking-[0.18em] text-zinc-400">{profile.subtitle}</div>
|
||||
<div className="text-[11px] font-bold tracking-[0.28em] text-zinc-500">
|
||||
世界档案
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-black text-white sm:text-[2.2rem]">
|
||||
{profile.name}
|
||||
</div>
|
||||
<div className="mt-2 text-sm tracking-[0.18em] text-zinc-400">
|
||||
{profile.subtitle}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky top-0 z-10 -mx-1 space-y-3 bg-[linear-gradient(180deg,rgba(8,10,17,0.98)_0%,rgba(8,10,17,0.95)_75%,rgba(8,10,17,0)_100%)] px-1 pb-3 pt-1">
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{RESULT_TABS.map(tab => (
|
||||
{RESULT_TABS.map((tab) => (
|
||||
<div key={tab.id}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -426,16 +607,22 @@ export function CustomWorldEntityCatalog({
|
||||
className={`rounded-full border px-3 py-2 text-left text-sm transition-colors ${activeTab === tab.id ? 'border-sky-300/25 bg-sky-500/12 text-white' : 'border-white/10 bg-black/20 text-zinc-300 hover:text-white'}`}
|
||||
>
|
||||
<div className="font-semibold">{tab.label}</div>
|
||||
<div className="mt-1 text-[10px] tracking-[0.16em] text-white/55">{counts[tab.id]}</div>
|
||||
<div className="mt-1 text-[10px] tracking-[0.16em] text-white/55">
|
||||
{counts[tab.id]}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab !== 'world' && activeTab !== 'anchors' ? (
|
||||
{activeTab !== 'world' ? (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="min-w-0 flex-1">
|
||||
<SearchBox value={searchDraft} onChange={setSearchDraft} placeholder={getSearchPlaceholder(activeTab)} />
|
||||
<SearchBox
|
||||
value={searchDraft}
|
||||
onChange={setSearchDraft}
|
||||
placeholder={getSearchPlaceholder(activeTab)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{isBulkDeleteMode ? (
|
||||
@@ -444,20 +631,25 @@ export function CustomWorldEntityCatalog({
|
||||
已选 {selectedBulkIds.length}
|
||||
</div>
|
||||
<SmallButton onClick={cancelBulkDelete}>取消</SmallButton>
|
||||
<SmallButton
|
||||
onClick={confirmBulkDelete}
|
||||
tone="rose"
|
||||
>
|
||||
<SmallButton onClick={confirmBulkDelete} tone="rose">
|
||||
删除选中
|
||||
</SmallButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{createActionLabel && onCreateAction ? (
|
||||
<SmallButton onClick={onCreateAction} tone="sky">{createActionLabel}</SmallButton>
|
||||
{!readOnly && createActionLabel && onCreateAction ? (
|
||||
<SmallButton onClick={onCreateAction} tone="sky">
|
||||
{createActionLabel}
|
||||
</SmallButton>
|
||||
) : null}
|
||||
{bulkDeleteTab && ((bulkDeleteTab === 'story' && onDeleteStoryNpcs) || (bulkDeleteTab === 'landmarks' && onDeleteLandmarks)) ? (
|
||||
<SmallButton onClick={() => startBulkDelete(bulkDeleteTab)} tone="rose">
|
||||
{!readOnly &&
|
||||
bulkDeleteTab &&
|
||||
((bulkDeleteTab === 'story' && onDeleteStoryNpcs) ||
|
||||
(bulkDeleteTab === 'landmarks' && onDeleteLandmarks)) ? (
|
||||
<SmallButton
|
||||
onClick={() => startBulkDelete(bulkDeleteTab)}
|
||||
tone="rose"
|
||||
>
|
||||
批量删除
|
||||
</SmallButton>
|
||||
) : null}
|
||||
@@ -470,52 +662,91 @@ export function CustomWorldEntityCatalog({
|
||||
|
||||
{activeTab === 'world' ? (
|
||||
<>
|
||||
<Section title="世界概述" actions={<SmallButton onClick={() => onEditTarget({ kind: 'world' })} tone="sky">编辑</SmallButton>}>
|
||||
<Section
|
||||
title="世界概述"
|
||||
actions={
|
||||
readOnly ? (
|
||||
<SmallButton
|
||||
onClick={() => onEditTarget({ kind: 'world' })}
|
||||
tone="sky"
|
||||
>
|
||||
查看详情
|
||||
</SmallButton>
|
||||
) : (
|
||||
<SmallButton
|
||||
onClick={() => onEditTarget({ kind: 'world' })}
|
||||
tone="sky"
|
||||
>
|
||||
编辑
|
||||
</SmallButton>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="space-y-3 text-sm leading-7 text-zinc-300">
|
||||
<p>{profile.summary}</p>
|
||||
<div className="rounded-2xl border border-amber-300/12 bg-amber-500/8 px-3 py-3 text-amber-100">主线目标:{profile.playerGoal}</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">世界基调:{profile.tone}</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-zinc-400">原始设定:{profile.settingText}</div>
|
||||
<div className="rounded-2xl border border-amber-300/12 bg-amber-500/8 px-3 py-3 text-amber-100">
|
||||
主线目标:{profile.playerGoal}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
世界基调:{profile.tone}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{creatorIntentSummary ? (
|
||||
<Section title="创作锚点" subtitle="这部分来自创作者输入,AI 会围绕它继续展开世界。">
|
||||
<div className="whitespace-pre-line rounded-2xl border border-sky-300/14 bg-sky-500/8 px-4 py-4 text-sm leading-7 text-sky-50/95">
|
||||
{creatorIntentSummary}
|
||||
</div>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
<Section title="开局归处" subtitle="玩家进入自定义世界后的第一处落脚点,也会直接作为开场场景背景。">
|
||||
<Section
|
||||
title="原始设定"
|
||||
subtitle="把开局最关键的 6 个原始锚点拆开看,后续精修会更顺。"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<ImageFrame
|
||||
src={resolvedCampImageSrc}
|
||||
alt={resolvedCampScene.name}
|
||||
fallbackLabel={resolvedCampScene.name.slice(0, 4) || '归处'}
|
||||
tone="landscape"
|
||||
/>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-200">
|
||||
{resolvedCampScene.name}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-7 text-zinc-300">
|
||||
{resolvedCampScene.description}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{structuredFoundationEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4"
|
||||
>
|
||||
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
|
||||
{entry.label}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-7 text-zinc-100">
|
||||
{entry.value || '待补充'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{profile.settingText ? (
|
||||
<div className="whitespace-pre-line rounded-2xl border border-white/8 bg-black/20 px-4 py-4 text-sm leading-7 text-zinc-200">
|
||||
{profile.settingText}
|
||||
</div>
|
||||
) : null}
|
||||
{creatorIntentSummary && creatorIntentSummary !== profile.settingText ? (
|
||||
<div className="whitespace-pre-line rounded-2xl border border-sky-300/14 bg-sky-500/8 px-4 py-4 text-sm leading-7 text-sky-50/95">
|
||||
{creatorIntentSummary}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="档案规模" subtitle="结果页现在会同时维护场景角色归属和场景之间的相对位置连接关系。">
|
||||
<Section
|
||||
title="档案规模"
|
||||
subtitle="结果页现在会同时维护场景角色归属和场景之间的相对位置连接关系。"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-2 text-center text-[11px] text-zinc-300 sm:grid-cols-3">
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
|
||||
<div className="text-xl font-black text-white">{profile.playableNpcs.length}</div>
|
||||
<div className="text-xl font-black text-white">
|
||||
{profile.playableNpcs.length}
|
||||
</div>
|
||||
<div>可扮演角色</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
|
||||
<div className="text-xl font-black text-white">{profile.storyNpcs.length}</div>
|
||||
<div className="text-xl font-black text-white">
|
||||
{profile.storyNpcs.length}
|
||||
</div>
|
||||
<div>场景角色</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
|
||||
<div className="text-xl font-black text-white">{profile.landmarks.length}</div>
|
||||
<div className="text-xl font-black text-white">
|
||||
{profile.landmarks.length + 1}
|
||||
</div>
|
||||
<div>场景</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -526,128 +757,72 @@ export function CustomWorldEntityCatalog({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'anchors' ? (
|
||||
<div className="space-y-3">
|
||||
<Section
|
||||
title="创作者输入"
|
||||
subtitle="这些内容来自创作者工作台,会作为 AI 继续展开世界的锚点。"
|
||||
>
|
||||
<div className="whitespace-pre-line rounded-2xl border border-sky-300/14 bg-sky-500/8 px-4 py-4 text-sm leading-7 text-sky-50/95">
|
||||
{creatorIntentSummary || '当前还没有记录创作锚点。'}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="关键势力">
|
||||
<div className="space-y-2">
|
||||
{profile.creatorIntent?.keyFactions.length ? (
|
||||
profile.creatorIntent.keyFactions.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-semibold text-white">{entry.name || '未命名势力'}</div>
|
||||
{entry.locked ? (
|
||||
<span className="rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
|
||||
已锁定
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1">{entry.publicGoal || '暂无目标说明'}</div>
|
||||
{entry.tension ? <div className="mt-1 text-zinc-400">冲突:{entry.tension}</div> : null}
|
||||
{entry.notes ? <div className="mt-1 text-zinc-500">补充:{entry.notes}</div> : null}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<EmptyState title="当前没有关键势力锚点。" />
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="关键角色">
|
||||
<div className="space-y-2">
|
||||
{profile.creatorIntent?.keyCharacters.length ? (
|
||||
profile.creatorIntent.keyCharacters.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-semibold text-white">{entry.name || '未命名角色'}</div>
|
||||
{entry.locked ? (
|
||||
<span className="rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
|
||||
已锁定
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1">{entry.role || '未填写身份'}</div>
|
||||
{entry.publicMask ? <div className="mt-1 text-zinc-400">表面:{entry.publicMask}</div> : null}
|
||||
{entry.hiddenHook ? <div className="mt-1 text-zinc-400">暗线:{entry.hiddenHook}</div> : null}
|
||||
{entry.relationToPlayer ? <div className="mt-1 text-zinc-500">与玩家:{entry.relationToPlayer}</div> : null}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<EmptyState title="当前没有关键角色锚点。" />
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="关键地点">
|
||||
<div className="space-y-2">
|
||||
{profile.creatorIntent?.keyLandmarks.length ? (
|
||||
profile.creatorIntent.keyLandmarks.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-semibold text-white">{entry.name || '未命名地点'}</div>
|
||||
{entry.locked ? (
|
||||
<span className="rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
|
||||
已锁定
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1">{entry.purpose || '未填写作用'}</div>
|
||||
{entry.mood ? <div className="mt-1 text-zinc-400">氛围:{entry.mood}</div> : null}
|
||||
{entry.secret ? <div className="mt-1 text-zinc-500">秘密:{entry.secret}</div> : null}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<EmptyState title="当前没有关键地点锚点。" />
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'playable' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
可扮演角色支持新增、删除与更换外观模板。
|
||||
{readOnly
|
||||
? '当前是草稿结果预览,可先浏览角色结构,再回到工作区继续精修。'
|
||||
: '可扮演角色支持新增、删除与更换外观模板。'}
|
||||
</div>
|
||||
{filteredPlayable.length === 0 ? (
|
||||
<EmptyState title="当前没有符合搜索条件的可扮演角色。" />
|
||||
) : (
|
||||
filteredPlayable.map(role => {
|
||||
const previewCharacter = previewCharacterById.get(role.id) ?? null;
|
||||
filteredPlayable.map((role) => {
|
||||
const previewCharacter =
|
||||
previewCharacterById.get(role.id) ?? null;
|
||||
|
||||
return (
|
||||
<div key={role.id}>
|
||||
<Section
|
||||
title={role.name}
|
||||
subtitle={role.title}
|
||||
actions={(
|
||||
<div className="flex items-center gap-2">
|
||||
<SmallButton onClick={() => onEditTarget({ kind: 'playable', mode: 'edit', id: role.id })} tone="sky">编辑</SmallButton>
|
||||
<SmallButton onClick={() => removePlayable(role.id, role.name)} tone="rose">删除</SmallButton>
|
||||
</div>
|
||||
)}
|
||||
actions={
|
||||
readOnly ? (
|
||||
<SmallButton
|
||||
onClick={() =>
|
||||
onEditTarget({
|
||||
kind: 'playable',
|
||||
mode: 'edit',
|
||||
id: role.id,
|
||||
})
|
||||
}
|
||||
tone="sky"
|
||||
>
|
||||
查看详情
|
||||
</SmallButton>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<SmallButton
|
||||
onClick={() =>
|
||||
onEditTarget({
|
||||
kind: 'playable',
|
||||
mode: 'edit',
|
||||
id: role.id,
|
||||
})
|
||||
}
|
||||
tone="sky"
|
||||
>
|
||||
编辑
|
||||
</SmallButton>
|
||||
<SmallButton
|
||||
onClick={() => removePlayable(role.id, role.name)}
|
||||
tone="rose"
|
||||
>
|
||||
删除
|
||||
</SmallButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<div className="flex h-28 w-28 shrink-0 items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/35">
|
||||
{previewCharacter ? (
|
||||
<CharacterAnimator state={AnimationState.RUN} character={previewCharacter} className="h-full w-full" imageClassName="object-bottom" />
|
||||
<CharacterAnimator
|
||||
state={AnimationState.RUN}
|
||||
character={previewCharacter}
|
||||
className="h-full w-full"
|
||||
imageClassName="object-bottom"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -656,51 +831,86 @@ export function CustomWorldEntityCatalog({
|
||||
创作者锁定角色
|
||||
</div>
|
||||
) : null}
|
||||
<div className="text-sm leading-6 text-zinc-300">{role.description}</div>
|
||||
<div className="mt-2 text-xs leading-6 text-zinc-400">{role.backstory}</div>
|
||||
<div className="text-sm leading-6 text-zinc-300">
|
||||
{role.description}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-6 text-zinc-400">
|
||||
{role.backstory}
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-sky-300/12 bg-sky-500/8 px-3 py-2 text-xs leading-6 text-sky-50/95">
|
||||
公开背景:{role.backstoryReveal.publicSummary || '未填写'}
|
||||
公开背景:
|
||||
{role.backstoryReveal.publicSummary || '未填写'}
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">身份:{role.role}</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">初始好感:{role.initialAffinity}</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">性格:{role.personality}</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">战斗:{role.combatStyle}</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
身份:{role.role}
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
初始好感:{role.initialAffinity}
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
性格:{role.personality}
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
战斗:{role.combatStyle}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
动机:{role.motivation}
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">动机:{role.motivation}</div>
|
||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">好感背景章节</div>
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
|
||||
好感背景章节
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{role.backstoryReveal.chapters.map(chapter => (
|
||||
<div key={`${role.id}-${chapter.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
{chapter.affinityRequired} 好感 · {chapter.title}:{chapter.teaser}
|
||||
{role.backstoryReveal.chapters.map((chapter) => (
|
||||
<div
|
||||
key={`${role.id}-${chapter.id}`}
|
||||
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300"
|
||||
>
|
||||
{chapter.affinityRequired} 好感 ·{' '}
|
||||
{chapter.title}:{chapter.teaser}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">技能</div>
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
|
||||
技能
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{role.skills.map(skill => (
|
||||
<div key={`${role.id}-${skill.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
{role.skills.map((skill) => (
|
||||
<div
|
||||
key={`${role.id}-${skill.id}`}
|
||||
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300"
|
||||
>
|
||||
{skill.name} · {skill.style}:{skill.summary}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">初始物品</div>
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
|
||||
初始物品
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{role.initialItems.map(item => (
|
||||
<div key={`${role.id}-${item.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
{item.name} x{item.quantity} · {item.category} · {item.rarity}:{item.description}
|
||||
{role.initialItems.map((item) => (
|
||||
<div
|
||||
key={`${role.id}-${item.id}`}
|
||||
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300"
|
||||
>
|
||||
{item.name} x{item.quantity} · {item.category} ·{' '}
|
||||
{item.rarity}:{item.description}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{role.tags.map(tag => (
|
||||
<span key={`${role.id}-${tag}`} className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300">
|
||||
{role.tags.map((tag) => (
|
||||
<span
|
||||
key={`${role.id}-${tag}`}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
@@ -720,7 +930,7 @@ export function CustomWorldEntityCatalog({
|
||||
{filteredStory.length === 0 ? (
|
||||
<EmptyState title="当前没有符合搜索条件的场景角色。" />
|
||||
) : (
|
||||
filteredStory.map(npc => (
|
||||
filteredStory.map((npc) => (
|
||||
<div key={npc.id}>
|
||||
<CatalogCard
|
||||
title={npc.name}
|
||||
@@ -730,9 +940,19 @@ export function CustomWorldEntityCatalog({
|
||||
onClick={() =>
|
||||
isBulkDeleteMode
|
||||
? toggleBulkSelected(npc.id)
|
||||
: onEditTarget({ kind: 'story', mode: 'edit', id: npc.id })
|
||||
: readOnly
|
||||
? onEditTarget({
|
||||
kind: 'story',
|
||||
mode: 'edit',
|
||||
id: npc.id,
|
||||
})
|
||||
: onEditTarget({
|
||||
kind: 'story',
|
||||
mode: 'edit',
|
||||
id: npc.id,
|
||||
})
|
||||
}
|
||||
media={(
|
||||
media={
|
||||
<CustomWorldNpcPortrait
|
||||
npc={npc}
|
||||
profile={profile}
|
||||
@@ -741,7 +961,7 @@ export function CustomWorldEntityCatalog({
|
||||
scale={2.18}
|
||||
preferImageSrc
|
||||
/>
|
||||
)}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
@@ -751,29 +971,43 @@ export function CustomWorldEntityCatalog({
|
||||
|
||||
{activeTab === 'landmarks' ? (
|
||||
<div className="space-y-3">
|
||||
{filteredLandmarks.length === 0 ? (
|
||||
{filteredSceneEntries.length === 0 ? (
|
||||
<EmptyState title="当前没有符合搜索条件的场景。" />
|
||||
) : (
|
||||
filteredLandmarks.map(landmark => (
|
||||
<div key={landmark.id}>
|
||||
filteredSceneEntries.map((scene) => (
|
||||
<div key={scene.id}>
|
||||
<CatalogCard
|
||||
title={landmark.name}
|
||||
description={landmark.description}
|
||||
isSelectionMode={isBulkDeleteMode}
|
||||
isSelected={selectedBulkIds.includes(landmark.id)}
|
||||
onClick={() =>
|
||||
isBulkDeleteMode
|
||||
? toggleBulkSelected(landmark.id)
|
||||
: onEditTarget({ kind: 'landmark', mode: 'edit', id: landmark.id })
|
||||
title={scene.name}
|
||||
description={
|
||||
scene.kind === 'camp'
|
||||
? `开局场景 · ${scene.description}`
|
||||
: scene.description
|
||||
}
|
||||
media={(
|
||||
isSelectionMode={scene.kind === 'landmark' && isBulkDeleteMode}
|
||||
isSelected={
|
||||
scene.kind === 'landmark' &&
|
||||
selectedBulkIds.includes(scene.id)
|
||||
}
|
||||
onClick={() =>
|
||||
scene.kind === 'camp'
|
||||
? onEditTarget({ kind: 'world' })
|
||||
: isBulkDeleteMode
|
||||
? toggleBulkSelected(scene.id)
|
||||
: onEditTarget({
|
||||
kind: 'landmark',
|
||||
mode: 'edit',
|
||||
id: scene.id,
|
||||
})
|
||||
}
|
||||
media={
|
||||
<ImageFrame
|
||||
src={landmarkImageById.get(landmark.id) ?? landmark.imageSrc}
|
||||
alt={landmark.name}
|
||||
fallbackLabel={landmark.name.slice(0, 4) || '场景'}
|
||||
src={scene.imageSrc}
|
||||
alt={scene.name}
|
||||
fallbackLabel={scene.name.slice(0, 4) || '场景'}
|
||||
tone="landscape"
|
||||
/>
|
||||
)}
|
||||
}
|
||||
disabled={scene.kind === 'camp' && isBulkDeleteMode}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
import type {
|
||||
CustomWorldGenerationProgress,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
|
||||
interface CustomWorldGenerationViewProps {
|
||||
@@ -74,7 +72,9 @@ export function CustomWorldGenerationView({
|
||||
? `预计还需 ${formatDuration(progress.estimatedRemainingMs)}`
|
||||
: '正在校准预计等待时间';
|
||||
const elapsedText =
|
||||
progress != null ? `已耗时 ${formatDuration(progress.elapsedMs)}` : '正在启动世界生成';
|
||||
progress != null
|
||||
? `已耗时 ${formatDuration(progress.elapsedMs)}`
|
||||
: '正在启动世界生成';
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { type ReactNode,useMemo, useState } from 'react';
|
||||
import { type ReactNode, useMemo, useState } from 'react';
|
||||
|
||||
import { normalizeCustomWorldLandmarks } from '../data/customWorldSceneGraph';
|
||||
import { Character, CustomWorldProfile } from '../types';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { CustomWorldEntityCatalog, type ResultTab } from './CustomWorldEntityCatalog';
|
||||
import { type CustomWorldEditorTarget,CustomWorldEntityEditorModal } from './CustomWorldEntityEditorModal';
|
||||
import {
|
||||
CustomWorldEntityCatalog,
|
||||
type ResultTab,
|
||||
} from './CustomWorldEntityCatalog';
|
||||
import {
|
||||
type CustomWorldEditorTarget,
|
||||
CustomWorldEntityEditorModal,
|
||||
} from './CustomWorldEntityEditorModal';
|
||||
|
||||
interface CustomWorldResultViewProps {
|
||||
profile: CustomWorldProfile;
|
||||
@@ -17,8 +23,13 @@ interface CustomWorldResultViewProps {
|
||||
onEditSetting?: () => void;
|
||||
onRegenerate?: () => void;
|
||||
onContinueExpand?: () => void;
|
||||
onSave: () => void;
|
||||
onSave?: () => void;
|
||||
onProfileChange: (profile: CustomWorldProfile) => void;
|
||||
readOnly?: boolean;
|
||||
backLabel?: string;
|
||||
editActionLabel?: string;
|
||||
regenerateActionLabel?: string;
|
||||
saveActionLabel?: string;
|
||||
}
|
||||
|
||||
function SmallButton({
|
||||
@@ -48,7 +59,9 @@ function SmallButton({
|
||||
);
|
||||
}
|
||||
|
||||
function getCreateTargetByTab(activeTab: ResultTab): CustomWorldEditorTarget | null {
|
||||
function getCreateTargetByTab(
|
||||
activeTab: ResultTab,
|
||||
): CustomWorldEditorTarget | null {
|
||||
if (activeTab === 'playable') return { kind: 'playable', mode: 'create' };
|
||||
if (activeTab === 'story') return { kind: 'story', mode: 'create' };
|
||||
if (activeTab === 'landmarks') return { kind: 'landmark', mode: 'create' };
|
||||
@@ -82,7 +95,10 @@ function removeStoryNpcsFromProfile(
|
||||
} satisfies CustomWorldProfile;
|
||||
}
|
||||
|
||||
function removeLandmarksFromProfile(profile: CustomWorldProfile, ids: string[]) {
|
||||
function removeLandmarksFromProfile(
|
||||
profile: CustomWorldProfile,
|
||||
ids: string[],
|
||||
) {
|
||||
const idSet = new Set(ids);
|
||||
const nextLandmarks = profile.landmarks.filter(
|
||||
(landmark) => !idSet.has(landmark.id),
|
||||
@@ -115,12 +131,24 @@ export function CustomWorldResultView({
|
||||
onContinueExpand,
|
||||
onSave,
|
||||
onProfileChange,
|
||||
readOnly = false,
|
||||
backLabel = '返回',
|
||||
editActionLabel = '修改设定',
|
||||
regenerateActionLabel = '重新生成',
|
||||
saveActionLabel = '保存到我的作品',
|
||||
}: CustomWorldResultViewProps) {
|
||||
const [editorTarget, setEditorTarget] = useState<CustomWorldEditorTarget | null>(null);
|
||||
const [editorTarget, setEditorTarget] =
|
||||
useState<CustomWorldEditorTarget | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<ResultTab>('world');
|
||||
|
||||
const createTarget = useMemo(() => getCreateTargetByTab(activeTab), [activeTab]);
|
||||
const createLabel = useMemo(() => getCreateLabelByTab(activeTab), [activeTab]);
|
||||
const createTarget = useMemo(
|
||||
() => getCreateTargetByTab(activeTab),
|
||||
[activeTab],
|
||||
);
|
||||
const createLabel = useMemo(
|
||||
() => getCreateLabelByTab(activeTab),
|
||||
[activeTab],
|
||||
);
|
||||
const onRegenerate = () => {
|
||||
if (isGenerating || !triggerRegenerate) return;
|
||||
|
||||
@@ -151,7 +179,7 @@ export function CustomWorldResultView({
|
||||
disabled={isGenerating}
|
||||
className={`self-start 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 ? 'opacity-45' : ''}`}
|
||||
>
|
||||
返回
|
||||
{backLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -165,15 +193,22 @@ export function CustomWorldResultView({
|
||||
onProfileChange={onProfileChange}
|
||||
onDeleteStoryNpcs={handleDeleteStoryNpcs}
|
||||
onDeleteLandmarks={handleDeleteLandmarks}
|
||||
createActionLabel={createLabel}
|
||||
onCreateAction={createTarget ? () => setEditorTarget(createTarget) : undefined}
|
||||
createActionLabel={readOnly ? undefined : createLabel}
|
||||
onCreateAction={
|
||||
readOnly || !createTarget
|
||||
? undefined
|
||||
: () => setEditorTarget(createTarget)
|
||||
}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isGenerating && (
|
||||
<div className="mt-3 rounded-2xl border border-sky-400/18 bg-sky-500/10 px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-white">{progressLabel}</div>
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{progressLabel}
|
||||
</div>
|
||||
<div className="text-xs text-sky-100">{Math.round(progress)}%</div>
|
||||
</div>
|
||||
<div className="mt-3 h-3 overflow-hidden rounded-full border border-white/10 bg-black/35">
|
||||
@@ -199,28 +234,41 @@ export function CustomWorldResultView({
|
||||
) : null}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{onEditSetting ? (
|
||||
<SmallButton onClick={onEditSetting}>修改设定</SmallButton>
|
||||
<SmallButton onClick={onEditSetting}>{editActionLabel}</SmallButton>
|
||||
) : null}
|
||||
{triggerRegenerate ? (
|
||||
<SmallButton onClick={onRegenerate} tone="sky">重新生成</SmallButton>
|
||||
<SmallButton onClick={onRegenerate} tone="sky">
|
||||
{regenerateActionLabel}
|
||||
</SmallButton>
|
||||
) : null}
|
||||
{profile.generationStatus === 'key_only' && onContinueExpand ? (
|
||||
<SmallButton onClick={onContinueExpand} tone="sky" disabled={isGenerating}>
|
||||
<SmallButton
|
||||
onClick={onContinueExpand}
|
||||
tone="sky"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
继续补全世界
|
||||
</SmallButton>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={isGenerating}
|
||||
className={`pixel-nine-slice pixel-pressable text-left ${isGenerating ? 'opacity-55' : ''}`}
|
||||
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>
|
||||
{onSave ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={isGenerating}
|
||||
className={`pixel-nine-slice pixel-pressable text-left ${isGenerating ? 'opacity-55' : ''}`}
|
||||
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">
|
||||
{saveActionLabel}
|
||||
</span>
|
||||
<span className="text-white/60">→</span>
|
||||
</div>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -55,12 +55,12 @@ export function CustomWorldAgentDraftDrawer({
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{group.items.map((card) => {
|
||||
{group.items.map((card, index) => {
|
||||
const isActive = activeCardId === card.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={card.id}
|
||||
key={card.id || `${group.kind}-card-${index}`}
|
||||
type="button"
|
||||
onClick={() => onSelectCard(card.id)}
|
||||
className={`w-full rounded-[1.2rem] border px-3 py-3 text-left transition ${
|
||||
|
||||
@@ -7,15 +7,19 @@ function readLockedItems(lockState: Record<string, unknown> | null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(lockState)
|
||||
return [...new Set(
|
||||
Object.entries(lockState)
|
||||
.flatMap(([key, value]) =>
|
||||
Array.isArray(value)
|
||||
? value.map((item) => `${key}:${String(item)}`)
|
||||
? value
|
||||
.map((item) => String(item).trim())
|
||||
.filter(Boolean)
|
||||
.map((item) => `${key}:${item}`)
|
||||
: typeof value === 'string' && value.trim()
|
||||
? [`${key}:${value.trim()}`]
|
||||
: [],
|
||||
)
|
||||
.slice(0, 8);
|
||||
)].slice(0, 8);
|
||||
}
|
||||
|
||||
export function CustomWorldAgentLockBar({
|
||||
@@ -30,9 +34,9 @@ export function CustomWorldAgentLockBar({
|
||||
</div>
|
||||
{lockedItems.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{lockedItems.map((item) => (
|
||||
{lockedItems.map((item, index) => (
|
||||
<span
|
||||
key={item}
|
||||
key={`locked-item-${index}-${item}`}
|
||||
className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[11px] text-amber-100"
|
||||
>
|
||||
{item}
|
||||
|
||||
@@ -5,6 +5,7 @@ type CustomWorldAgentQuickActionsProps = {
|
||||
disabled: boolean;
|
||||
canDraftFoundation: boolean;
|
||||
showEntityActions?: boolean;
|
||||
showSummaryAction?: boolean;
|
||||
onRequestSummary: () => void;
|
||||
onDraftFoundation: () => void;
|
||||
onGenerateCharacter?: () => void;
|
||||
@@ -45,6 +46,7 @@ export function CustomWorldAgentQuickActions({
|
||||
disabled,
|
||||
canDraftFoundation,
|
||||
showEntityActions = false,
|
||||
showSummaryAction = true,
|
||||
onRequestSummary,
|
||||
onDraftFoundation,
|
||||
onGenerateCharacter,
|
||||
@@ -70,12 +72,14 @@ export function CustomWorldAgentQuickActions({
|
||||
快捷动作
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<QuickActionButton
|
||||
label={summaryAction?.label ?? '总结当前设定'}
|
||||
onClick={onRequestSummary}
|
||||
disabled={disabled}
|
||||
tone="sky"
|
||||
/>
|
||||
{showSummaryAction ? (
|
||||
<QuickActionButton
|
||||
label={summaryAction?.label ?? '总结当前设定'}
|
||||
onClick={onRequestSummary}
|
||||
disabled={disabled}
|
||||
tone="sky"
|
||||
/>
|
||||
) : null}
|
||||
{draftAction && canDraftFoundation ? (
|
||||
<QuickActionButton
|
||||
label={draftAction.label}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { CustomWorldAgentThread } from './CustomWorldAgentThread';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('filters empty recommended replies and avoids duplicate key warnings', () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
|
||||
render(
|
||||
<CustomWorldAgentThread
|
||||
messages={[
|
||||
{
|
||||
id: '',
|
||||
role: 'assistant',
|
||||
kind: 'summary',
|
||||
text: '先把世界骨架收出来。',
|
||||
createdAt: '2026-04-16T10:00:00.000Z',
|
||||
relatedOperationId: null,
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
role: 'user',
|
||||
kind: 'chat',
|
||||
text: '继续。',
|
||||
createdAt: '2026-04-16T10:01:00.000Z',
|
||||
relatedOperationId: null,
|
||||
},
|
||||
]}
|
||||
recommendedReplies={[
|
||||
'',
|
||||
'继续补充冲突',
|
||||
'继续补充冲突',
|
||||
' 先确定玩家身份 ',
|
||||
]}
|
||||
onRecommendedReply={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '继续补充冲突' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '先确定玩家身份' })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /^\s*$/u })).toBeNull();
|
||||
expect(screen.getAllByRole('button')).toHaveLength(2);
|
||||
|
||||
const duplicateKeyCalls = consoleErrorSpy.mock.calls.filter((call) =>
|
||||
call.some(
|
||||
(arg) =>
|
||||
typeof arg === 'string' &&
|
||||
arg.includes('Encountered two children with the same key'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(duplicateKeyCalls).toHaveLength(0);
|
||||
});
|
||||
@@ -26,9 +26,16 @@ export function CustomWorldAgentThread({
|
||||
onRecommendedReply,
|
||||
}: CustomWorldAgentThreadProps) {
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastAssistantMessageId = [...messages]
|
||||
.reverse()
|
||||
.find((message) => message.role === 'assistant')?.id;
|
||||
const visibleRecommendedReplies = [
|
||||
...new Set(
|
||||
recommendedReplies.map((reply) => reply.trim()).filter(Boolean),
|
||||
),
|
||||
].slice(0, 3);
|
||||
const lastAssistantMessageIndex = messages.reduce(
|
||||
(lastIndex, message, index) =>
|
||||
message.role === 'assistant' ? index : lastIndex,
|
||||
-1,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({
|
||||
@@ -45,13 +52,13 @@ export function CustomWorldAgentThread({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{messages.map((message) => {
|
||||
{messages.map((message, index) => {
|
||||
const isUser = message.role === 'user';
|
||||
const isSystem = message.role === 'system';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
key={message.id || `message-${index}`}
|
||||
className={`flex ${
|
||||
isUser ? 'justify-end' : 'justify-start'
|
||||
}`}
|
||||
@@ -70,12 +77,12 @@ export function CustomWorldAgentThread({
|
||||
{formatMessageTime(message.createdAt)}
|
||||
</div>
|
||||
{!isUser &&
|
||||
message.id === lastAssistantMessageId &&
|
||||
recommendedReplies.length > 0 ? (
|
||||
index === lastAssistantMessageIndex &&
|
||||
visibleRecommendedReplies.length > 0 ? (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{recommendedReplies.slice(0, 3).map((reply) => (
|
||||
{visibleRecommendedReplies.map((reply, replyIndex) => (
|
||||
<button
|
||||
key={reply}
|
||||
key={`recommended-reply-${replyIndex}-${reply}`}
|
||||
type="button"
|
||||
onClick={() => onRecommendedReply?.(reply)}
|
||||
className="rounded-2xl border border-white/10 bg-white/5 px-3 py-2 text-left text-xs leading-5 text-zinc-200 transition hover:border-emerald-300/25 hover:text-white"
|
||||
|
||||
@@ -212,7 +212,11 @@ const baseSession: CustomWorldAgentSessionSnapshot = {
|
||||
targetId: null,
|
||||
},
|
||||
],
|
||||
recommendedReplies: ['现在开始生成草稿', '先总结一下当前设定', '我还想再补充一点'],
|
||||
recommendedReplies: [
|
||||
'现在开始生成草稿',
|
||||
'先总结一下当前设定',
|
||||
'我还想再补充一点',
|
||||
],
|
||||
qualityFindings: [],
|
||||
assetCoverage: {
|
||||
roleAssets: [
|
||||
@@ -268,6 +272,10 @@ test('workspace loads detail, saves edits, opens generate actions, and reflects
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByText('卡片详情')).toBeTruthy();
|
||||
expect(screen.queryByPlaceholderText('输入消息')).toBeNull();
|
||||
expect(screen.queryByText('当前底稿已经可以继续精修。')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '编辑设定' }));
|
||||
const summaryInput = screen.getByLabelText('摘要');
|
||||
await user.clear(summaryInput);
|
||||
@@ -297,7 +305,9 @@ test('workspace loads detail, saves edits, opens generate actions, and reflects
|
||||
);
|
||||
});
|
||||
|
||||
const [generateCharacterButton] = screen.getAllByRole('button', { name: '新增角色' });
|
||||
const [generateCharacterButton] = screen.getAllByRole('button', {
|
||||
name: '新增角色',
|
||||
});
|
||||
await user.click(generateCharacterButton!);
|
||||
expect(screen.getByRole('button', { name: '生成角色' })).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '生成角色' }));
|
||||
@@ -309,7 +319,9 @@ test('workspace loads detail, saves edits, opens generate actions, and reflects
|
||||
anchorCardIds: ['character-1'],
|
||||
});
|
||||
|
||||
const [generateLandmarkButton] = screen.getAllByRole('button', { name: '新增场景' });
|
||||
const [generateLandmarkButton] = screen.getAllByRole('button', {
|
||||
name: '新增场景',
|
||||
});
|
||||
await user.click(generateLandmarkButton!);
|
||||
expect(screen.getByRole('button', { name: '生成场景' })).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '生成场景' }));
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect, test } from 'vitest';
|
||||
|
||||
import { CustomWorldAgentWorkspace } from './CustomWorldAgentWorkspace';
|
||||
|
||||
test('custom world agent workspace renders progress labels, action button and recommended replies', () => {
|
||||
test('custom world agent workspace renders draft workspace instead of chat after draft cards appear', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldAgentWorkspace
|
||||
session={{
|
||||
@@ -87,10 +87,11 @@ test('custom world agent workspace renders progress labels, action button and re
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('首轮草稿会先确认这 6 项信息');
|
||||
expect(html).toContain('世界核心');
|
||||
expect(html).toContain('玩家开局');
|
||||
expect(html).toContain('现在开始生成草稿');
|
||||
expect(html).toContain('开始生成草稿');
|
||||
expect(html).toContain('欢迎。当前底稿已经可以继续精修。');
|
||||
expect(html).toContain('卡片详情');
|
||||
expect(html).toContain('快捷动作');
|
||||
expect(html).toContain('草稿抽屉');
|
||||
expect(html).not.toContain('首轮草稿会先确认这 6 项信息');
|
||||
expect(html).not.toContain('现在开始生成草稿');
|
||||
expect(html).not.toContain('欢迎。当前底稿已经可以继续精修。');
|
||||
expect(html).not.toContain('输入消息');
|
||||
});
|
||||
|
||||
@@ -7,8 +7,8 @@ import type {
|
||||
CustomWorldDraftCardDetail,
|
||||
SendCustomWorldAgentMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { CustomWorldRoleAssetStudioModal } from '../CustomWorldRoleAssetStudioModal';
|
||||
import { getCustomWorldAgentCardDetail } from '../../services/aiService';
|
||||
import { CustomWorldRoleAssetStudioModal } from '../CustomWorldRoleAssetStudioModal';
|
||||
import { CustomWorldAgentComposer } from './CustomWorldAgentComposer';
|
||||
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
|
||||
import { CustomWorldAgentDraftDrawer } from './CustomWorldAgentDraftDrawer';
|
||||
@@ -149,7 +149,8 @@ function resolveRoleAssetTarget(
|
||||
: [],
|
||||
imageSrc: toText(role.imageSrc) || undefined,
|
||||
generatedVisualAssetId: toText(role.generatedVisualAssetId) || undefined,
|
||||
generatedAnimationSetId: toText(role.generatedAnimationSetId) || undefined,
|
||||
generatedAnimationSetId:
|
||||
toText(role.generatedAnimationSetId) || undefined,
|
||||
animationMap: toRecord(role.animationMap) ?? undefined,
|
||||
} satisfies WorkspaceRoleAssetTarget,
|
||||
roleKind: playableRole ? ('playable' as const) : ('story' as const),
|
||||
@@ -352,7 +353,8 @@ export function CustomWorldAgentWorkspace({
|
||||
}
|
||||
|
||||
const isBusy =
|
||||
activeOperation?.status === 'queued' || activeOperation?.status === 'running';
|
||||
activeOperation?.status === 'queued' ||
|
||||
activeOperation?.status === 'running';
|
||||
const canStartDraft =
|
||||
session.creatorIntentReadiness.isReady &&
|
||||
session.stage === 'foundation_review';
|
||||
@@ -360,9 +362,10 @@ export function CustomWorldAgentWorkspace({
|
||||
!session.creatorIntentReadiness.isReady &&
|
||||
session.creatorIntentReadiness.completedKeys.includes('world_hook');
|
||||
const showDraftWorkspace =
|
||||
(session.stage === 'object_refining' || session.stage === 'visual_refining') &&
|
||||
session.draftCards.length > 0;
|
||||
const selectedCard = session.draftCards.find((card) => card.id === selectedCardId) ?? null;
|
||||
session.stage !== 'foundation_review' && session.draftCards.length > 0;
|
||||
const showAgentConversation = !showDraftWorkspace;
|
||||
const selectedCard =
|
||||
session.draftCards.find((card) => card.id === selectedCardId) ?? null;
|
||||
const recommendedReplies = buildRecommendedReplies(session);
|
||||
const selectedRoleAssetContext = resolveRoleAssetTarget(
|
||||
session,
|
||||
@@ -424,27 +427,30 @@ export function CustomWorldAgentWorkspace({
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-[1500px] flex-col gap-3">
|
||||
<CustomWorldAgentHeader onBack={onBack} />
|
||||
<CustomWorldAgentReadinessBar
|
||||
completedKeys={session.creatorIntentReadiness.completedKeys}
|
||||
isReady={canStartDraft}
|
||||
busy={isBusy}
|
||||
onStartDraft={() => {
|
||||
onExecuteAction({
|
||||
action: 'draft_foundation',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{!showDraftWorkspace ? <CustomWorldAgentHeader onBack={onBack} /> : null}
|
||||
{!showDraftWorkspace ? (
|
||||
<CustomWorldAgentReadinessBar
|
||||
completedKeys={session.creatorIntentReadiness.completedKeys}
|
||||
isReady={canStartDraft}
|
||||
busy={isBusy}
|
||||
onStartDraft={() => {
|
||||
onExecuteAction({
|
||||
action: 'draft_foundation',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<CustomWorldAgentOperationBanner operation={activeOperation} />
|
||||
|
||||
{showDraftWorkspace ? (
|
||||
<div className="grid min-h-0 flex-1 gap-3 xl:grid-cols-[18rem_minmax(0,1fr)_24rem]">
|
||||
<div className="grid min-h-0 flex-1 gap-3 xl:grid-cols-[20rem_minmax(0,1fr)]">
|
||||
<div className="flex min-h-0 flex-col gap-3 xl:overflow-hidden">
|
||||
<CustomWorldAgentQuickActions
|
||||
suggestedActions={session.suggestedActions}
|
||||
disabled={isBusy}
|
||||
canDraftFoundation={canStartDraft}
|
||||
showEntityActions
|
||||
showSummaryAction={false}
|
||||
showRoleAssetAction={selectedCard?.kind === 'character'}
|
||||
onRequestSummary={submitSummaryRequest}
|
||||
onDraftFoundation={() => {
|
||||
@@ -464,13 +470,11 @@ export function CustomWorldAgentWorkspace({
|
||||
onFocusSuggestedAction={(action) => {
|
||||
if (action?.targetId) {
|
||||
setSelectedCardId(action.targetId);
|
||||
setDetailModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.draftCards[0]) {
|
||||
setSelectedCardId(session.draftCards[0].id);
|
||||
setDetailModalOpen(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -480,13 +484,12 @@ export function CustomWorldAgentWorkspace({
|
||||
activeCardId={selectedCardId}
|
||||
onSelectCard={(cardId) => {
|
||||
setSelectedCardId(cardId);
|
||||
setDetailModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden min-h-0 xl:block xl:overflow-y-auto">
|
||||
<div className="min-h-0 xl:overflow-y-auto">
|
||||
<CustomWorldAgentDraftDetailPanel
|
||||
detail={detail}
|
||||
loading={detailLoading}
|
||||
@@ -526,38 +529,25 @@ export function CustomWorldAgentWorkspace({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-col gap-3">
|
||||
<div className="h-[18rem] min-h-[18rem] xl:min-h-0 xl:flex-1">
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{showAgentConversation ? (
|
||||
<>
|
||||
<CustomWorldAgentThread
|
||||
messages={session.messages}
|
||||
recommendedReplies={recommendedReplies}
|
||||
onRecommendedReply={handleRecommendedReply}
|
||||
/>
|
||||
</div>
|
||||
<CustomWorldAgentComposer
|
||||
disabled={isBusy}
|
||||
onSubmit={onSubmitMessage}
|
||||
onSummaryClick={submitSummaryRequest}
|
||||
onAutoCompleteClick={() => setAutoCompleteConfirmOpen(true)}
|
||||
showAutoComplete={showAutoCompleteButton}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<CustomWorldAgentThread
|
||||
messages={session.messages}
|
||||
recommendedReplies={recommendedReplies}
|
||||
onRecommendedReply={handleRecommendedReply}
|
||||
/>
|
||||
<CustomWorldAgentComposer
|
||||
disabled={isBusy}
|
||||
onSubmit={onSubmitMessage}
|
||||
onSummaryClick={submitSummaryRequest}
|
||||
onAutoCompleteClick={() => setAutoCompleteConfirmOpen(true)}
|
||||
showAutoComplete={showAutoCompleteButton}
|
||||
/>
|
||||
<CustomWorldAgentComposer
|
||||
disabled={isBusy}
|
||||
onSubmit={onSubmitMessage}
|
||||
onSummaryClick={submitSummaryRequest}
|
||||
onAutoCompleteClick={() => setAutoCompleteConfirmOpen(true)}
|
||||
showAutoComplete={showAutoCompleteButton}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -671,13 +661,13 @@ export function CustomWorldAgentWorkspace({
|
||||
}
|
||||
visualPointCost={
|
||||
selectedRoleAssetContext.assetSummary?.status === 'missing'
|
||||
? selectedRoleAssetContext.assetSummary?.nextPointCost ?? 20
|
||||
? (selectedRoleAssetContext.assetSummary?.nextPointCost ?? 20)
|
||||
: 20
|
||||
}
|
||||
animationPointCost={
|
||||
selectedRoleAssetContext.assetSummary?.status === 'missing'
|
||||
? 60
|
||||
: selectedRoleAssetContext.assetSummary?.nextPointCost ?? 60
|
||||
: (selectedRoleAssetContext.assetSummary?.nextPointCost ?? 60)
|
||||
}
|
||||
syncBusy={
|
||||
activeOperation?.type === 'sync_role_assets' &&
|
||||
|
||||
@@ -70,7 +70,9 @@ function WorldCard({
|
||||
}) {
|
||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
|
||||
const tags = buildPlatformWorldTags(entry);
|
||||
const tags = [
|
||||
...new Set(buildPlatformWorldTags(entry).map((tag) => tag.trim()).filter(Boolean)),
|
||||
].slice(0, 3);
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -120,9 +122,9 @@ function WorldCard({
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{tags.length > 0 ? (
|
||||
tags.map((tag) => (
|
||||
tags.map((tag, index) => (
|
||||
<span
|
||||
key={tag}
|
||||
key={`world-tag-${index}-${tag || 'empty'}`}
|
||||
className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100"
|
||||
>
|
||||
{tag}
|
||||
|
||||
@@ -66,7 +66,9 @@ export function PlatformWorldDetailView({
|
||||
3,
|
||||
);
|
||||
const previewLandmarks = entry.profile.landmarks.slice(0, 3);
|
||||
const tags = buildPlatformWorldTags(entry);
|
||||
const tags = [
|
||||
...new Set(buildPlatformWorldTags(entry).map((tag) => tag.trim()).filter(Boolean)),
|
||||
].slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
@@ -133,9 +135,9 @@ export function PlatformWorldDetailView({
|
||||
{entry.summaryText || '等待补充世界摘要。'}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{tags.map((tag) => (
|
||||
{tags.map((tag, index) => (
|
||||
<span
|
||||
key={tag}
|
||||
key={`world-detail-tag-${index}-${tag || 'empty'}`}
|
||||
className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-100"
|
||||
>
|
||||
{tag}
|
||||
@@ -189,9 +191,9 @@ export function PlatformWorldDetailView({
|
||||
关键角色
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
{previewCharacters.map((character) => (
|
||||
{previewCharacters.map((character, index) => (
|
||||
<div
|
||||
key={character.id}
|
||||
key={character.id || `preview-character-${index}`}
|
||||
className="rounded-2xl border border-white/10 bg-black/20 px-3 py-3"
|
||||
>
|
||||
<div className="line-clamp-1 text-sm font-bold text-white">
|
||||
@@ -210,9 +212,9 @@ export function PlatformWorldDetailView({
|
||||
关键场景
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
{previewLandmarks.map((landmark) => (
|
||||
{previewLandmarks.map((landmark, index) => (
|
||||
<div
|
||||
key={landmark.id}
|
||||
key={landmark.id || `preview-landmark-${index}`}
|
||||
className="rounded-2xl border border-white/10 bg-black/20 px-3 py-3"
|
||||
>
|
||||
<div className="line-clamp-1 text-sm font-bold text-white">
|
||||
|
||||
@@ -8,11 +8,14 @@ import { beforeEach, expect, test, vi } from 'vitest';
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import {
|
||||
createCustomWorldAgentSession,
|
||||
executeCustomWorldAgentAction,
|
||||
getCustomWorldAgentOperation,
|
||||
getCustomWorldAgentSession,
|
||||
} from '../../services/aiService';
|
||||
import {
|
||||
listCustomWorldGallery,
|
||||
listCustomWorldLibrary,
|
||||
upsertCustomWorldProfile,
|
||||
} from '../../services/storageService';
|
||||
import type { GameState } from '../../types';
|
||||
import {
|
||||
@@ -41,11 +44,23 @@ vi.mock('../../services/storageService', () => ({
|
||||
vi.mock('../custom-world-agent/CustomWorldAgentWorkspace', () => ({
|
||||
CustomWorldAgentWorkspace: ({
|
||||
session,
|
||||
onExecuteAction,
|
||||
}: {
|
||||
session: CustomWorldAgentSessionSnapshot | null;
|
||||
onExecuteAction: (payload: { action: string }) => void;
|
||||
}) => (
|
||||
<div className="agent-workspace-mock">
|
||||
Agent工作区:{session?.sessionId ?? 'missing-session'}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onExecuteAction({
|
||||
action: 'draft_foundation',
|
||||
});
|
||||
}}
|
||||
>
|
||||
开始生成草稿
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
@@ -117,9 +132,51 @@ beforeEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
vi.mocked(listCustomWorldLibrary).mockResolvedValue([]);
|
||||
vi.mocked(listCustomWorldGallery).mockResolvedValue([]);
|
||||
vi.mocked(upsertCustomWorldProfile).mockResolvedValue({
|
||||
entry: {
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'agent-draft-custom-world-agent-session-1',
|
||||
profile: {
|
||||
id: 'agent-draft-custom-world-agent-session-1',
|
||||
name: '潮雾列岛',
|
||||
} as never,
|
||||
visibility: 'draft',
|
||||
publishedAt: null,
|
||||
updatedAt: '2026-04-14T12:00:00.000Z',
|
||||
authorDisplayName: '玩家',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '第一版世界底稿已经整理完成。',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'tide',
|
||||
playableNpcCount: 1,
|
||||
landmarkCount: 1,
|
||||
},
|
||||
entries: [],
|
||||
});
|
||||
vi.mocked(createCustomWorldAgentSession).mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
vi.mocked(executeCustomWorldAgentAction).mockResolvedValue({
|
||||
operation: {
|
||||
operationId: 'operation-draft-foundation-1',
|
||||
type: 'draft_foundation',
|
||||
status: 'queued',
|
||||
phaseLabel: '已接收请求',
|
||||
phaseDetail: '正在准备生成世界底稿。',
|
||||
progress: 10,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
vi.mocked(getCustomWorldAgentOperation).mockResolvedValue({
|
||||
operationId: 'operation-draft-foundation-1',
|
||||
type: 'draft_foundation',
|
||||
status: 'running',
|
||||
phaseLabel: '生成世界底稿',
|
||||
phaseDetail: '正在根据已确认锚点编译第一版世界结构。',
|
||||
progress: 38,
|
||||
error: null,
|
||||
});
|
||||
vi.mocked(getCustomWorldAgentSession).mockResolvedValue(mockSession);
|
||||
});
|
||||
|
||||
@@ -151,3 +208,168 @@ test('create tab opens game type modal, keeps AIRP and visual novel locked, and
|
||||
await screen.findByText('Agent工作区:custom-world-agent-session-1'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<TestWrapper />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '创作' }));
|
||||
await user.click(screen.getByRole('button', { name: /开启新的创作/u }));
|
||||
await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('Agent工作区:custom-world-agent-session-1'),
|
||||
).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '开始生成草稿' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(executeCustomWorldAgentAction).toHaveBeenCalledWith(
|
||||
'custom-world-agent-session-1',
|
||||
{
|
||||
action: 'draft_foundation',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
|
||||
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
|
||||
expect(screen.getAllByText('生成世界底稿').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('existing draft sessions enter the legacy result layout directly', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(getCustomWorldAgentOperation).mockResolvedValue({
|
||||
operationId: 'operation-draft-foundation-1',
|
||||
type: 'draft_foundation',
|
||||
status: 'completed',
|
||||
phaseLabel: '世界底稿已生成',
|
||||
phaseDetail: '第一版世界底稿和 4 张草稿卡已经整理完成。',
|
||||
progress: 100,
|
||||
error: null,
|
||||
});
|
||||
vi.mocked(getCustomWorldAgentSession).mockResolvedValue({
|
||||
...mockSession,
|
||||
stage: 'object_refining',
|
||||
creatorIntent: {
|
||||
sourceMode: 'card',
|
||||
worldHook: '被海雾吞没的旧航路群岛',
|
||||
playerPremise: '玩家回到群岛调查沉船真相。',
|
||||
themeKeywords: ['海雾', '旧航路'],
|
||||
toneDirectives: ['压抑', '悬疑'],
|
||||
openingSituation: '首夜就有陌生船只闯入禁航区。',
|
||||
coreConflicts: ['航运公会与守灯会争夺航路控制权'],
|
||||
keyFactions: [],
|
||||
keyCharacters: [],
|
||||
keyLandmarks: [],
|
||||
iconicElements: ['会移动的海雾'],
|
||||
forbiddenDirectives: [],
|
||||
rawSettingText: '',
|
||||
},
|
||||
draftProfile: {
|
||||
name: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summary: '第一版世界底稿已经整理完成。',
|
||||
tone: '压抑、潮湿、悬疑',
|
||||
playerGoal: '查清沉船与禁航区异动的真相。',
|
||||
majorFactions: ['守灯会', '航运公会'],
|
||||
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'playable-1',
|
||||
name: '沈砺',
|
||||
title: '旧航路引路人',
|
||||
role: '关键同行者',
|
||||
publicIdentity: '最熟悉旧航路的人。',
|
||||
publicMask: '看上去像可靠旧友。',
|
||||
currentPressure: '他必须在两股势力间站队。',
|
||||
hiddenHook: '暗中替沉船商盟引路。',
|
||||
relationToPlayer: '旧友兼潜在背叛者',
|
||||
threadIds: ['thread-1'],
|
||||
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'story-1',
|
||||
name: '顾潮音',
|
||||
title: '守灯会值夜人',
|
||||
role: '场景关键角色',
|
||||
publicIdentity: '负责夜间巡灯与封锁。',
|
||||
publicMask: '对外一直冷静克制。',
|
||||
currentPressure: '她知道更多禁航区真相。',
|
||||
hiddenHook: '曾亲眼见过失控海雾吞船。',
|
||||
relationToPlayer: '最早愿意交换线索的人',
|
||||
threadIds: ['thread-1'],
|
||||
summary: '她总像比所有人更早知道海雾会往哪一侧压下来。',
|
||||
},
|
||||
],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-1',
|
||||
name: '回潮旧灯塔',
|
||||
purpose: '观察雾潮与往来船只',
|
||||
mood: '潮湿、压抑、风声不止',
|
||||
importance: '开局核心场景',
|
||||
characterIds: ['story-1'],
|
||||
threadIds: ['thread-1'],
|
||||
summary: '旧灯塔是整片群岛最先看见异动的地方。',
|
||||
},
|
||||
],
|
||||
factions: [],
|
||||
threads: [],
|
||||
chapters: [],
|
||||
worldHook: '被海雾吞没的旧航路群岛',
|
||||
playerPremise: '玩家回到群岛调查沉船真相。',
|
||||
openingSituation: '首夜就有陌生船只闯入禁航区。',
|
||||
iconicElements: ['会移动的海雾'],
|
||||
sourceAnchorSummary: '海雾、旧灯塔、失控航路。',
|
||||
},
|
||||
draftCards: [
|
||||
{
|
||||
id: 'world-foundation',
|
||||
kind: 'world',
|
||||
title: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summary: '第一版世界底稿已经整理完成。',
|
||||
status: 'warning',
|
||||
linkedIds: ['playable-1', 'story-1', 'landmark-1'],
|
||||
warningCount: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<TestWrapper />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '创作' }));
|
||||
await user.click(screen.getByRole('button', { name: /开启新的创作/u }));
|
||||
await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u }));
|
||||
|
||||
await waitFor(
|
||||
async () => {
|
||||
expect(await screen.findByText('世界档案')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: /保存到我的作品|自动保存中|已保存到我的作品/u,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
},
|
||||
{ timeout: 2500 },
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /^锚点/u })).toBeNull();
|
||||
expect(screen.getByText(/原始设定/u)).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /场景角色/u }));
|
||||
await user.click(screen.getByRole('button', { name: /顾潮音/u }));
|
||||
|
||||
expect(await screen.findByText(/编辑场景角色:顾潮音/u)).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /AI生成形象与动作/u }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('技能')).toBeTruthy();
|
||||
|
||||
});
|
||||
|
||||
@@ -31,16 +31,17 @@ import {
|
||||
getCustomWorldAgentSession,
|
||||
sendCustomWorldAgentMessage,
|
||||
} from '../../services/aiService';
|
||||
import {
|
||||
readCustomWorldAgentUiState,
|
||||
writeCustomWorldAgentUiState,
|
||||
} from '../../services/customWorldAgentUiState';
|
||||
import { buildCustomWorldProfileFromAgentDraft } from '../../services/customWorldAgentDraftResult';
|
||||
import {
|
||||
buildAgentDraftFoundationGenerationProgress,
|
||||
buildAgentDraftFoundationSettingText,
|
||||
isDraftFoundationOperation,
|
||||
isDraftFoundationOperationRunning,
|
||||
} from '../../services/customWorldAgentGenerationProgress';
|
||||
import {
|
||||
readCustomWorldAgentUiState,
|
||||
writeCustomWorldAgentUiState,
|
||||
} from '../../services/customWorldAgentUiState';
|
||||
import {
|
||||
buildCustomWorldCreatorIntentDisplayText,
|
||||
buildCustomWorldCreatorIntentGenerationText,
|
||||
@@ -61,7 +62,7 @@ import {
|
||||
type GameState,
|
||||
} from '../../types';
|
||||
import { PlatformCreationTypeModal } from './PlatformCreationTypeModal';
|
||||
import { type PlatformHomeTab,PlatformHomeView } from './PlatformHomeView';
|
||||
import { type PlatformHomeTab, PlatformHomeView } from './PlatformHomeView';
|
||||
import { PlatformWorldDetailView } from './PlatformWorldDetailView';
|
||||
|
||||
const CustomWorldGenerationView = lazy(async () => {
|
||||
@@ -106,6 +107,9 @@ type CustomWorldGenerationViewSource =
|
||||
| 'agent-draft-foundation'
|
||||
| null;
|
||||
|
||||
type CustomWorldResultViewSource = 'classic' | 'agent-draft' | null;
|
||||
type CustomWorldAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
|
||||
type PreGameSelectionFlowProps = {
|
||||
selectionStage: SelectionStage;
|
||||
setSelectionStage: (stage: SelectionStage) => void;
|
||||
@@ -270,11 +274,21 @@ export function PreGameSelectionFlow({
|
||||
const [isMutatingDetail, setIsMutatingDetail] = useState(false);
|
||||
const [customWorldProgress, setCustomWorldProgress] =
|
||||
useState<CustomWorldGenerationProgress | null>(null);
|
||||
const [customWorldAutoSaveState, setCustomWorldAutoSaveState] =
|
||||
useState<CustomWorldAutoSaveState>('idle');
|
||||
const [customWorldAutoSaveError, setCustomWorldAutoSaveError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [customWorldGenerationViewSource, setCustomWorldGenerationViewSource] =
|
||||
useState<CustomWorldGenerationViewSource>(null);
|
||||
const [customWorldResultViewSource, setCustomWorldResultViewSource] =
|
||||
useState<CustomWorldResultViewSource>(null);
|
||||
const [agentDraftGenerationStartedAt, setAgentDraftGenerationStartedAt] =
|
||||
useState<number | null>(null);
|
||||
const customWorldAbortControllerRef = useRef<AbortController | null>(null);
|
||||
const customWorldAutoSaveTimeoutRef = useRef<number | null>(null);
|
||||
const lastAutoSavedProfileSignatureRef = useRef<string | null>(null);
|
||||
const latestAutoSaveRequestIdRef = useRef(0);
|
||||
|
||||
const previewCustomWorldCharacters = useMemo(
|
||||
() =>
|
||||
@@ -307,34 +321,6 @@ export function PreGameSelectionFlow({
|
||||
return nextSession;
|
||||
}, []);
|
||||
|
||||
const refreshPlatformData = useCallback(async () => {
|
||||
setIsLoadingPlatform(true);
|
||||
setPlatformError(null);
|
||||
|
||||
try {
|
||||
const [libraryEntries, galleryEntries] = await Promise.all([
|
||||
listCustomWorldLibrary(),
|
||||
listCustomWorldGallery(),
|
||||
]);
|
||||
setSavedCustomWorldEntries(libraryEntries);
|
||||
setPublishedGalleryEntries(galleryEntries);
|
||||
if (selectedDetailEntry) {
|
||||
const nextOwnedEntry = libraryEntries.find(
|
||||
(entry) =>
|
||||
entry.ownerUserId === selectedDetailEntry.ownerUserId &&
|
||||
entry.profileId === selectedDetailEntry.profileId,
|
||||
);
|
||||
if (nextOwnedEntry) {
|
||||
setSelectedDetailEntry(nextOwnedEntry);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setPlatformError(resolveErrorMessage(error, '读取平台数据失败。'));
|
||||
} finally {
|
||||
setIsLoadingPlatform(false);
|
||||
}
|
||||
}, [selectedDetailEntry]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasAppliedInitialAgentWorkspaceRef.current) {
|
||||
return;
|
||||
@@ -397,6 +383,9 @@ export function PreGameSelectionFlow({
|
||||
useEffect(
|
||||
() => () => {
|
||||
customWorldAbortControllerRef.current?.abort();
|
||||
if (customWorldAutoSaveTimeoutRef.current !== null) {
|
||||
window.clearTimeout(customWorldAutoSaveTimeoutRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -512,6 +501,72 @@ export function PreGameSelectionFlow({
|
||||
syncAgentSessionSnapshot,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isDraftFoundationOperationRunning(agentOperation) ||
|
||||
agentDraftGenerationStartedAt
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAgentDraftGenerationStartedAt(Date.now());
|
||||
}, [agentDraftGenerationStartedAt, agentOperation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectionStage !== 'custom-world-generating' ||
|
||||
customWorldGenerationViewSource !== 'agent-draft-foundation' ||
|
||||
!isDraftFoundationOperation(agentOperation) ||
|
||||
agentOperation.status !== 'completed'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void (async () => {
|
||||
const latestSession = activeAgentSessionId
|
||||
? await syncAgentSessionSnapshot(activeAgentSessionId).catch(
|
||||
() => null,
|
||||
)
|
||||
: agentSession;
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const draftResultProfile = buildCustomWorldProfileFromAgentDraft(
|
||||
latestSession ?? agentSession,
|
||||
);
|
||||
if (!draftResultProfile) {
|
||||
setAgentDraftGenerationStartedAt(null);
|
||||
setCustomWorldGenerationViewSource(null);
|
||||
setSelectionStage('agent-workspace');
|
||||
return;
|
||||
}
|
||||
|
||||
setGeneratedCustomWorldProfile(draftResultProfile);
|
||||
setAgentDraftGenerationStartedAt(null);
|
||||
setCustomWorldGenerationViewSource(null);
|
||||
setCustomWorldResultViewSource('agent-draft');
|
||||
setSelectionStage('custom-world-result');
|
||||
})();
|
||||
}, 900);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [
|
||||
activeAgentSessionId,
|
||||
agentOperation,
|
||||
customWorldGenerationViewSource,
|
||||
agentSession,
|
||||
selectionStage,
|
||||
setSelectionStage,
|
||||
syncAgentSessionSnapshot,
|
||||
]);
|
||||
|
||||
const customWorldSettingPreview = useMemo(() => {
|
||||
if (customWorldCreatorIntent.sourceMode === 'freeform') {
|
||||
return customWorldCreatorIntent.rawSettingText.trim();
|
||||
@@ -531,6 +586,51 @@ export function PreGameSelectionFlow({
|
||||
() => buildAgentDraftFoundationSettingText(agentSession),
|
||||
[agentSession],
|
||||
);
|
||||
const agentDraftResultProfile = useMemo(
|
||||
() => buildCustomWorldProfileFromAgentDraft(agentSession),
|
||||
[agentSession],
|
||||
);
|
||||
const shouldAutoOpenAgentDraftResult = useMemo(
|
||||
() =>
|
||||
Boolean(
|
||||
agentDraftResultProfile &&
|
||||
agentSession &&
|
||||
(agentSession.stage === 'object_refining' ||
|
||||
agentSession.stage === 'visual_refining' ||
|
||||
agentSession.stage === 'long_tail_review' ||
|
||||
agentSession.stage === 'ready_to_publish' ||
|
||||
agentSession.stage === 'published') &&
|
||||
agentSession.draftCards.length > 0,
|
||||
),
|
||||
[agentDraftResultProfile, agentSession],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldAutoOpenAgentDraftResult || !agentDraftResultProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectionStage === 'agent-workspace') {
|
||||
setGeneratedCustomWorldProfile(agentDraftResultProfile);
|
||||
setCustomWorldResultViewSource('agent-draft');
|
||||
setSelectionStage('custom-world-result');
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
selectionStage === 'custom-world-result' &&
|
||||
!generatedCustomWorldProfile
|
||||
) {
|
||||
setGeneratedCustomWorldProfile(agentDraftResultProfile);
|
||||
setCustomWorldResultViewSource('agent-draft');
|
||||
}
|
||||
}, [
|
||||
agentDraftResultProfile,
|
||||
generatedCustomWorldProfile,
|
||||
selectionStage,
|
||||
setSelectionStage,
|
||||
shouldAutoOpenAgentDraftResult,
|
||||
]);
|
||||
|
||||
const agentDraftGenerationProgress = useMemo(
|
||||
() =>
|
||||
@@ -543,25 +643,38 @@ export function PreGameSelectionFlow({
|
||||
|
||||
const isAgentDraftGenerationView =
|
||||
customWorldGenerationViewSource === 'agent-draft-foundation';
|
||||
const isAgentDraftResultView = customWorldResultViewSource === 'agent-draft';
|
||||
const activeGenerationSettingText = isAgentDraftGenerationView
|
||||
? agentDraftSettingPreview
|
||||
: customWorldSettingPreview;
|
||||
const activeGenerationProgress = isAgentDraftGenerationView
|
||||
? agentDraftGenerationProgress
|
||||
: customWorldProgress;
|
||||
const isActiveGenerationRunning = isAgentDraftGenerationView
|
||||
? isDraftFoundationOperationRunning(agentOperation)
|
||||
: isGeneratingCustomWorld;
|
||||
const activeGenerationError =
|
||||
isAgentDraftGenerationView &&
|
||||
isDraftFoundationOperation(agentOperation) &&
|
||||
agentOperation.status === 'failed'
|
||||
const activeGenerationError = isAgentDraftGenerationView
|
||||
? isDraftFoundationOperation(agentOperation) &&
|
||||
agentOperation.status === 'failed'
|
||||
? agentOperation.error || agentOperation.phaseDetail
|
||||
: customWorldError;
|
||||
: null
|
||||
: customWorldError;
|
||||
|
||||
const leaveCustomWorldResult = () => {
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldAutoSaveError(null);
|
||||
setCustomWorldAutoSaveState('idle');
|
||||
setCustomWorldProgress(null);
|
||||
setCustomWorldGenerationViewSource(null);
|
||||
setSelectionStage(selectedDetailEntry ? 'detail' : 'platform');
|
||||
setCustomWorldResultViewSource(null);
|
||||
setSelectionStage(
|
||||
isAgentDraftResultView
|
||||
? 'agent-workspace'
|
||||
: selectedDetailEntry
|
||||
? 'detail'
|
||||
: 'platform',
|
||||
);
|
||||
};
|
||||
|
||||
const leaveCustomWorldGeneration = () => {
|
||||
@@ -570,8 +683,11 @@ export function PreGameSelectionFlow({
|
||||
}
|
||||
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldAutoSaveError(null);
|
||||
setCustomWorldAutoSaveState('idle');
|
||||
setCustomWorldProgress(null);
|
||||
setCustomWorldGenerationViewSource(null);
|
||||
setCustomWorldResultViewSource(null);
|
||||
setSelectionStage('platform');
|
||||
};
|
||||
|
||||
@@ -596,6 +712,12 @@ export function PreGameSelectionFlow({
|
||||
const { session } = await createCustomWorldAgentSession({});
|
||||
setAgentSession(session);
|
||||
setAgentOperation(null);
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
setCustomWorldAutoSaveError(null);
|
||||
setCustomWorldAutoSaveState('idle');
|
||||
setAgentDraftGenerationStartedAt(null);
|
||||
setCustomWorldGenerationViewSource(null);
|
||||
setCustomWorldResultViewSource(null);
|
||||
persistAgentUiState(session.sessionId, null);
|
||||
setShowCreationTypeModal(false);
|
||||
setPlatformTab('create');
|
||||
@@ -639,6 +761,20 @@ export function PreGameSelectionFlow({
|
||||
return;
|
||||
}
|
||||
|
||||
const isDraftFoundationAction = payload.action === 'draft_foundation';
|
||||
|
||||
if (isDraftFoundationAction) {
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldAutoSaveError(null);
|
||||
setCustomWorldAutoSaveState('idle');
|
||||
setCustomWorldProgress(null);
|
||||
setCustomWorldGenerationViewSource('agent-draft-foundation');
|
||||
setCustomWorldResultViewSource(null);
|
||||
setAgentDraftGenerationStartedAt(Date.now());
|
||||
setSelectionStage('custom-world-generating');
|
||||
}
|
||||
|
||||
try {
|
||||
const { operation } = await executeCustomWorldAgentAction(
|
||||
activeAgentSessionId,
|
||||
@@ -665,10 +801,44 @@ export function PreGameSelectionFlow({
|
||||
const leaveAgentWorkspace = () => {
|
||||
setPlatformTab('create');
|
||||
setAgentOperation(null);
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
setCustomWorldAutoSaveError(null);
|
||||
setCustomWorldAutoSaveState('idle');
|
||||
setAgentDraftGenerationStartedAt(null);
|
||||
setCustomWorldGenerationViewSource(null);
|
||||
setCustomWorldResultViewSource(null);
|
||||
persistAgentUiState(activeAgentSessionId, null);
|
||||
setSelectionStage('platform');
|
||||
};
|
||||
|
||||
const leaveAgentDraftGeneration = () => {
|
||||
if (isDraftFoundationOperationRunning(agentOperation)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAgentDraftGenerationStartedAt(null);
|
||||
setCustomWorldGenerationViewSource(null);
|
||||
setSelectionStage('agent-workspace');
|
||||
};
|
||||
|
||||
const leaveAgentDraftResult = () => {
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldAutoSaveError(null);
|
||||
setCustomWorldAutoSaveState('idle');
|
||||
setCustomWorldProgress(null);
|
||||
setCustomWorldGenerationViewSource(null);
|
||||
setCustomWorldResultViewSource(null);
|
||||
setPlatformTab('create');
|
||||
setSelectionStage('platform');
|
||||
};
|
||||
|
||||
const retryAgentDraftGeneration = () => {
|
||||
void executeAgentAction({
|
||||
action: 'draft_foundation',
|
||||
});
|
||||
};
|
||||
|
||||
const openCustomWorldCreator = () => {
|
||||
if (isGeneratingCustomWorld) {
|
||||
return;
|
||||
@@ -683,7 +853,11 @@ export function PreGameSelectionFlow({
|
||||
setPlatformError(null);
|
||||
setDetailError(null);
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldAutoSaveError(null);
|
||||
setCustomWorldAutoSaveState('idle');
|
||||
setCustomWorldProgress(null);
|
||||
setCustomWorldGenerationViewSource(null);
|
||||
setCustomWorldResultViewSource(null);
|
||||
setCustomWorldCreatorIntent(
|
||||
createEmptyCustomWorldCreatorIntent('freeform'),
|
||||
);
|
||||
@@ -710,7 +884,11 @@ export function PreGameSelectionFlow({
|
||||
}
|
||||
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldAutoSaveError(null);
|
||||
setCustomWorldAutoSaveState('idle');
|
||||
setCustomWorldProgress(null);
|
||||
setCustomWorldGenerationViewSource(null);
|
||||
setCustomWorldResultViewSource(null);
|
||||
setShowCustomWorldModal(true);
|
||||
};
|
||||
|
||||
@@ -740,26 +918,98 @@ export function PreGameSelectionFlow({
|
||||
}
|
||||
};
|
||||
|
||||
const saveGeneratedCustomWorld = async () => {
|
||||
const saveGeneratedCustomWorld = useCallback(
|
||||
async (profile = generatedCustomWorldProfile) => {
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const profileSignature = JSON.stringify(profile);
|
||||
const requestId = latestAutoSaveRequestIdRef.current + 1;
|
||||
latestAutoSaveRequestIdRef.current = requestId;
|
||||
setCustomWorldAutoSaveState('saving');
|
||||
setCustomWorldAutoSaveError(null);
|
||||
|
||||
try {
|
||||
const mutation = await upsertCustomWorldProfile(profile);
|
||||
if (latestAutoSaveRequestIdRef.current !== requestId) {
|
||||
return mutation;
|
||||
}
|
||||
|
||||
lastAutoSavedProfileSignatureRef.current = profileSignature;
|
||||
setSavedCustomWorldEntries(mutation.entries);
|
||||
setSelectedDetailEntry((current) => {
|
||||
if (!current || current.profileId === mutation.entry.profileId) {
|
||||
return mutation.entry;
|
||||
}
|
||||
|
||||
return current;
|
||||
});
|
||||
setCustomWorldAutoSaveState('saved');
|
||||
setCustomWorldAutoSaveError(null);
|
||||
return mutation;
|
||||
} catch (error) {
|
||||
if (latestAutoSaveRequestIdRef.current !== requestId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setCustomWorldAutoSaveState('error');
|
||||
setCustomWorldAutoSaveError(
|
||||
resolveErrorMessage(error, '保存自定义世界失败。'),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[generatedCustomWorldProfile],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!generatedCustomWorldProfile) {
|
||||
setCustomWorldAutoSaveState('idle');
|
||||
setCustomWorldAutoSaveError(null);
|
||||
lastAutoSavedProfileSignatureRef.current = null;
|
||||
if (customWorldAutoSaveTimeoutRef.current !== null) {
|
||||
window.clearTimeout(customWorldAutoSaveTimeoutRef.current);
|
||||
customWorldAutoSaveTimeoutRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const mutation = await upsertCustomWorldProfile(
|
||||
generatedCustomWorldProfile,
|
||||
);
|
||||
setSavedCustomWorldEntries(mutation.entries);
|
||||
setSelectedDetailEntry(mutation.entry);
|
||||
await refreshPlatformData();
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldProgress(null);
|
||||
setSelectionStage('platform');
|
||||
} catch (error) {
|
||||
setCustomWorldError(resolveErrorMessage(error, '保存自定义世界失败。'));
|
||||
if (
|
||||
selectionStage !== 'custom-world-result' ||
|
||||
isGeneratingCustomWorld
|
||||
) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const nextSignature = JSON.stringify(generatedCustomWorldProfile);
|
||||
if (nextSignature === lastAutoSavedProfileSignatureRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCustomWorldAutoSaveState('saving');
|
||||
if (customWorldAutoSaveTimeoutRef.current !== null) {
|
||||
window.clearTimeout(customWorldAutoSaveTimeoutRef.current);
|
||||
}
|
||||
|
||||
const profileToSave = generatedCustomWorldProfile;
|
||||
customWorldAutoSaveTimeoutRef.current = window.setTimeout(() => {
|
||||
void saveGeneratedCustomWorld(profileToSave);
|
||||
customWorldAutoSaveTimeoutRef.current = null;
|
||||
}, 600);
|
||||
|
||||
return () => {
|
||||
if (customWorldAutoSaveTimeoutRef.current !== null) {
|
||||
window.clearTimeout(customWorldAutoSaveTimeoutRef.current);
|
||||
customWorldAutoSaveTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
generatedCustomWorldProfile,
|
||||
isGeneratingCustomWorld,
|
||||
saveGeneratedCustomWorld,
|
||||
selectionStage,
|
||||
]);
|
||||
|
||||
const openSavedCustomWorldEditor = (
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
||||
@@ -770,6 +1020,9 @@ export function PreGameSelectionFlow({
|
||||
|
||||
setSelectedDetailEntry(entry);
|
||||
setGeneratedCustomWorldProfile(entry.profile);
|
||||
lastAutoSavedProfileSignatureRef.current = JSON.stringify(entry.profile);
|
||||
setCustomWorldAutoSaveState('saved');
|
||||
setCustomWorldAutoSaveError(null);
|
||||
setCustomWorldCreatorIntent(
|
||||
entry.profile.creatorIntent ??
|
||||
({
|
||||
@@ -780,6 +1033,8 @@ export function PreGameSelectionFlow({
|
||||
setCustomWorldGenerationMode(entry.profile.generationMode ?? 'full');
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldProgress(null);
|
||||
setCustomWorldGenerationViewSource(null);
|
||||
setCustomWorldResultViewSource('classic');
|
||||
setSelectionStage('custom-world-result');
|
||||
};
|
||||
|
||||
@@ -844,6 +1099,9 @@ export function PreGameSelectionFlow({
|
||||
...mergedProfile,
|
||||
id: generatedCustomWorldProfile.id,
|
||||
});
|
||||
lastAutoSavedProfileSignatureRef.current = null;
|
||||
setCustomWorldAutoSaveState('idle');
|
||||
setCustomWorldAutoSaveError(null);
|
||||
setCustomWorldProgress(null);
|
||||
setCustomWorldError(null);
|
||||
} catch (error) {
|
||||
@@ -899,7 +1157,11 @@ export function PreGameSelectionFlow({
|
||||
customWorldAbortControllerRef.current?.abort();
|
||||
customWorldAbortControllerRef.current = abortController;
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldAutoSaveError(null);
|
||||
setCustomWorldAutoSaveState('idle');
|
||||
setCustomWorldProgress(null);
|
||||
setCustomWorldGenerationViewSource('classic');
|
||||
setCustomWorldResultViewSource(null);
|
||||
setShowCustomWorldModal(false);
|
||||
setSelectionStage('custom-world-generating');
|
||||
setIsGeneratingCustomWorld(true);
|
||||
@@ -929,8 +1191,13 @@ export function PreGameSelectionFlow({
|
||||
}
|
||||
: profile,
|
||||
);
|
||||
lastAutoSavedProfileSignatureRef.current = null;
|
||||
setCustomWorldAutoSaveState('idle');
|
||||
setCustomWorldAutoSaveError(null);
|
||||
setCustomWorldProgress(null);
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldGenerationViewSource(null);
|
||||
setCustomWorldResultViewSource('classic');
|
||||
setSelectionStage('custom-world-result');
|
||||
} catch (error) {
|
||||
if (abortController.signal.aborted) {
|
||||
@@ -1019,6 +1286,17 @@ export function PreGameSelectionFlow({
|
||||
entry.profileId === selectedDetailEntry.profileId,
|
||||
),
|
||||
);
|
||||
const resultViewSaveActionLabel =
|
||||
customWorldAutoSaveState === 'saving'
|
||||
? '自动保存中'
|
||||
: customWorldAutoSaveState === 'saved'
|
||||
? '已保存到我的作品'
|
||||
: customWorldAutoSaveState === 'error'
|
||||
? '重新保存到我的作品'
|
||||
: '保存到我的作品';
|
||||
const resultViewError =
|
||||
customWorldAutoSaveError ??
|
||||
(isAgentDraftResultView ? null : customWorldError);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -1156,16 +1434,61 @@ export function PreGameSelectionFlow({
|
||||
fallback={<LazyPanelFallback label="正在加载世界生成面板..." />}
|
||||
>
|
||||
<CustomWorldGenerationView
|
||||
settingText={customWorldSettingPreview}
|
||||
progress={customWorldProgress}
|
||||
isGenerating={isGeneratingCustomWorld}
|
||||
error={customWorldError}
|
||||
onBack={leaveCustomWorldGeneration}
|
||||
onEditSetting={editCustomWorldSetting}
|
||||
onRetry={() => {
|
||||
void createCustomWorld();
|
||||
}}
|
||||
onInterrupt={interruptCustomWorldGeneration}
|
||||
settingText={activeGenerationSettingText}
|
||||
progress={activeGenerationProgress}
|
||||
isGenerating={isActiveGenerationRunning}
|
||||
error={activeGenerationError}
|
||||
onBack={
|
||||
isAgentDraftGenerationView
|
||||
? leaveAgentDraftGeneration
|
||||
: leaveCustomWorldGeneration
|
||||
}
|
||||
onEditSetting={
|
||||
isAgentDraftGenerationView
|
||||
? leaveAgentDraftGeneration
|
||||
: editCustomWorldSetting
|
||||
}
|
||||
onRetry={
|
||||
isAgentDraftGenerationView
|
||||
? retryAgentDraftGeneration
|
||||
: () => {
|
||||
void createCustomWorld();
|
||||
}
|
||||
}
|
||||
onInterrupt={
|
||||
isAgentDraftGenerationView
|
||||
? undefined
|
||||
: interruptCustomWorldGeneration
|
||||
}
|
||||
backLabel={
|
||||
isAgentDraftGenerationView ? '返回工作区' : undefined
|
||||
}
|
||||
settingActionLabel={
|
||||
isAgentDraftGenerationView ? '回到工作区' : undefined
|
||||
}
|
||||
retryLabel={
|
||||
isAgentDraftGenerationView ? '重新生成草稿' : undefined
|
||||
}
|
||||
settingTitle={
|
||||
isAgentDraftGenerationView ? '当前共创设定' : undefined
|
||||
}
|
||||
settingDescription={
|
||||
isAgentDraftGenerationView
|
||||
? '这批锚点会被整理成第一版世界底稿与草稿卡。'
|
||||
: undefined
|
||||
}
|
||||
progressTitle={
|
||||
isAgentDraftGenerationView ? '世界草稿生成进度' : undefined
|
||||
}
|
||||
activeBadgeLabel={
|
||||
isAgentDraftGenerationView ? '草稿编译中' : undefined
|
||||
}
|
||||
pausedBadgeLabel={
|
||||
isAgentDraftGenerationView ? '草稿生成已暂停' : undefined
|
||||
}
|
||||
idleBadgeLabel={
|
||||
isAgentDraftGenerationView ? '等待返回工作区' : undefined
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
@@ -1186,22 +1509,52 @@ export function PreGameSelectionFlow({
|
||||
<CustomWorldResultView
|
||||
profile={generatedCustomWorldProfile}
|
||||
previewCharacters={previewCustomWorldCharacters}
|
||||
isGenerating={isGeneratingCustomWorld}
|
||||
progress={customWorldProgress?.overallProgress ?? 0}
|
||||
progressLabel={customWorldProgress?.phaseLabel ?? ''}
|
||||
error={customWorldError}
|
||||
isGenerating={
|
||||
isAgentDraftResultView ? false : isGeneratingCustomWorld
|
||||
}
|
||||
progress={
|
||||
isAgentDraftResultView
|
||||
? 0
|
||||
: (customWorldProgress?.overallProgress ?? 0)
|
||||
}
|
||||
progressLabel={
|
||||
isAgentDraftResultView
|
||||
? ''
|
||||
: (customWorldProgress?.phaseLabel ?? '')
|
||||
}
|
||||
error={resultViewError}
|
||||
onProfileChange={setGeneratedCustomWorldProfile}
|
||||
onBack={leaveCustomWorldResult}
|
||||
onEditSetting={editCustomWorldSetting}
|
||||
onRegenerate={() => {
|
||||
void createCustomWorld();
|
||||
}}
|
||||
onContinueExpand={() => {
|
||||
void continueExpandCustomWorld();
|
||||
}}
|
||||
onBack={
|
||||
isAgentDraftResultView
|
||||
? leaveAgentDraftResult
|
||||
: leaveCustomWorldResult
|
||||
}
|
||||
onEditSetting={
|
||||
isAgentDraftResultView ? undefined : editCustomWorldSetting
|
||||
}
|
||||
onRegenerate={
|
||||
isAgentDraftResultView
|
||||
? retryAgentDraftGeneration
|
||||
: () => {
|
||||
void createCustomWorld();
|
||||
}
|
||||
}
|
||||
onContinueExpand={
|
||||
isAgentDraftResultView
|
||||
? undefined
|
||||
: () => {
|
||||
void continueExpandCustomWorld();
|
||||
}
|
||||
}
|
||||
onSave={() => {
|
||||
void saveGeneratedCustomWorld();
|
||||
}}
|
||||
readOnly={false}
|
||||
backLabel={isAgentDraftResultView ? '返回创作' : undefined}
|
||||
regenerateActionLabel={
|
||||
isAgentDraftResultView ? '重新生成草稿' : undefined
|
||||
}
|
||||
saveActionLabel={resultViewSaveActionLabel}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
|
||||
Reference in New Issue
Block a user