This commit is contained in:
2026-04-16 15:45:00 +08:00
parent 6363267bca
commit 91b63675eb
43 changed files with 5652 additions and 853 deletions

View File

@@ -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}

View File

@@ -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">

View File

@@ -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();
});

View File

@@ -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>