+
-
-
+ {content}
+
- {platformError ? (
-
- {platformError}
-
- ) : null}
-
-
-
- {isLoadingPlatform ? (
-
- ) : featuredShelf.length > 0 ? (
-
- {featuredShelf.map((entry) => (
- onOpenGalleryDetail(entry)}
- />
- ))}
-
- ) : (
-
- )}
-
-
-
-
- {isLoadingPlatform ? (
-
- ) : latestEntries.length > 0 ? (
-
- {latestEntries.map((entry) => (
- onOpenGalleryDetail(entry)}
- />
- ))}
-
- ) : (
-
- )}
-
-
-
-
-
-
-
- {myEntries.map((entry) => (
-
onOpenLibraryDetail(entry)}
- />
- ))}
-
- {!isLoadingPlatform && myEntries.length === 0 ? (
-
-
-
- ) : null}
-
+
+
+
onTabChange('home')}
+ />
+ onTabChange('create')}
+ />
+ onTabChange('profile')}
+ />
diff --git a/src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx b/src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx
new file mode 100644
index 00000000..04afb375
--- /dev/null
+++ b/src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx
@@ -0,0 +1,153 @@
+/* @vitest-environment jsdom */
+
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useState } from 'react';
+import { beforeEach, expect, test, vi } from 'vitest';
+
+import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
+import {
+ createCustomWorldAgentSession,
+ getCustomWorldAgentSession,
+} from '../../services/aiService';
+import {
+ listCustomWorldGallery,
+ listCustomWorldLibrary,
+} from '../../services/storageService';
+import type { GameState } from '../../types';
+import {
+ PreGameSelectionFlow,
+ type SelectionStage,
+} from './PreGameSelectionFlow';
+
+vi.mock('../../services/aiService', () => ({
+ createCustomWorldAgentSession: vi.fn(),
+ executeCustomWorldAgentAction: vi.fn(),
+ generateCustomWorldProfile: vi.fn(),
+ getCustomWorldAgentOperation: vi.fn(),
+ getCustomWorldAgentSession: vi.fn(),
+ sendCustomWorldAgentMessage: vi.fn(),
+}));
+
+vi.mock('../../services/storageService', () => ({
+ getCustomWorldGalleryDetail: vi.fn(),
+ listCustomWorldGallery: vi.fn(),
+ listCustomWorldLibrary: vi.fn(),
+ publishCustomWorldProfile: vi.fn(),
+ unpublishCustomWorldProfile: vi.fn(),
+ upsertCustomWorldProfile: vi.fn(),
+}));
+
+vi.mock('../custom-world-agent/CustomWorldAgentWorkspace', () => ({
+ CustomWorldAgentWorkspace: ({
+ session,
+ }: {
+ session: CustomWorldAgentSessionSnapshot | null;
+ }) => (
+
+ Agent工作区:{session?.sessionId ?? 'missing-session'}
+
+ ),
+}));
+
+const mockSession: CustomWorldAgentSessionSnapshot = {
+ sessionId: 'custom-world-agent-session-1',
+ stage: 'clarifying',
+ focusCardId: null,
+ creatorIntent: {},
+ creatorIntentReadiness: {
+ isReady: false,
+ completedKeys: ['world_hook'],
+ missingKeys: [
+ 'player_premise',
+ 'theme_and_tone',
+ 'core_conflict',
+ 'relationship_seed',
+ 'iconic_element',
+ ],
+ },
+ anchorPack: {},
+ lockState: {},
+ draftProfile: null,
+ messages: [
+ {
+ id: 'message-1',
+ role: 'assistant',
+ kind: 'summary',
+ text: '先告诉我你想做一个怎样的 RPG 世界。',
+ createdAt: '2026-04-14T12:00:00.000Z',
+ relatedOperationId: null,
+ },
+ ],
+ draftCards: [],
+ pendingClarifications: [],
+ suggestedActions: [],
+ recommendedReplies: [],
+ qualityFindings: [],
+ assetCoverage: {
+ roleAssets: [],
+ sceneAssets: [],
+ allRoleAssetsReady: false,
+ allSceneAssetsReady: false,
+ },
+ updatedAt: '2026-04-14T12:00:00.000Z',
+};
+
+function TestWrapper() {
+ const [selectionStage, setSelectionStage] =
+ useState
('platform');
+
+ return (
+ {}}
+ handleStartNewGame={() => {}}
+ handleCustomWorldSelect={() => {}}
+ />
+ );
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ window.history.replaceState(null, '', '/');
+ window.sessionStorage.clear();
+ vi.mocked(listCustomWorldLibrary).mockResolvedValue([]);
+ vi.mocked(listCustomWorldGallery).mockResolvedValue([]);
+ vi.mocked(createCustomWorldAgentSession).mockResolvedValue({
+ session: mockSession,
+ });
+ vi.mocked(getCustomWorldAgentSession).mockResolvedValue(mockSession);
+});
+
+test('create tab opens game type modal, keeps AIRP and visual novel locked, and enters agent workspace for RPG', async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ await user.click(screen.getByRole('button', { name: '创作' }));
+ await user.click(screen.getByRole('button', { name: /开启新的创作/u }));
+
+ expect(screen.getByText('选择创作类型')).toBeTruthy();
+
+ const airpButton = screen.getByRole('button', { name: /AIRP/u });
+ const visualNovelButton = screen.getByRole('button', {
+ name: /视觉小说/u,
+ });
+
+ expect((airpButton as HTMLButtonElement).disabled).toBe(true);
+ expect((visualNovelButton as HTMLButtonElement).disabled).toBe(true);
+
+ await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u }));
+
+ await waitFor(() => {
+ expect(createCustomWorldAgentSession).toHaveBeenCalledTimes(1);
+ });
+
+ expect(
+ await screen.findByText('Agent工作区:custom-world-agent-session-1'),
+ ).toBeTruthy();
+});
diff --git a/src/components/game-shell/PreGameSelectionFlow.tsx b/src/components/game-shell/PreGameSelectionFlow.tsx
index f7141b13..2c1676a5 100644
--- a/src/components/game-shell/PreGameSelectionFlow.tsx
+++ b/src/components/game-shell/PreGameSelectionFlow.tsx
@@ -10,6 +10,12 @@ import {
} from 'react';
import type { JsonObject } from '../../../packages/shared/src/contracts/common';
+import type {
+ CustomWorldAgentActionRequest,
+ CustomWorldAgentOperationRecord,
+ CustomWorldAgentSessionSnapshot,
+ SendCustomWorldAgentMessageRequest,
+} from '../../../packages/shared/src/contracts/customWorldAgent';
import type {
CustomWorldGalleryCard,
CustomWorldGenerationProgress,
@@ -17,7 +23,24 @@ import type {
} from '../../../packages/shared/src/contracts/runtime';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
-import { generateCustomWorldProfile } from '../../services/aiService';
+import {
+ createCustomWorldAgentSession,
+ executeCustomWorldAgentAction,
+ generateCustomWorldProfile,
+ getCustomWorldAgentOperation,
+ getCustomWorldAgentSession,
+ sendCustomWorldAgentMessage,
+} from '../../services/aiService';
+import {
+ readCustomWorldAgentUiState,
+ writeCustomWorldAgentUiState,
+} from '../../services/customWorldAgentUiState';
+import {
+ buildAgentDraftFoundationGenerationProgress,
+ buildAgentDraftFoundationSettingText,
+ isDraftFoundationOperation,
+ isDraftFoundationOperationRunning,
+} from '../../services/customWorldAgentGenerationProgress';
import {
buildCustomWorldCreatorIntentDisplayText,
buildCustomWorldCreatorIntentGenerationText,
@@ -37,7 +60,8 @@ import {
type CustomWorldProfile,
type GameState,
} from '../../types';
-import { PlatformHomeView } from './PlatformHomeView';
+import { PlatformCreationTypeModal } from './PlatformCreationTypeModal';
+import { type PlatformHomeTab,PlatformHomeView } from './PlatformHomeView';
import { PlatformWorldDetailView } from './PlatformWorldDetailView';
const CustomWorldGenerationView = lazy(async () => {
@@ -61,12 +85,27 @@ const CustomWorldCreatorModal = lazy(async () => {
};
});
+const CustomWorldAgentWorkspace = lazy(async () => {
+ const module = await import(
+ '../custom-world-agent/CustomWorldAgentWorkspace'
+ );
+ return {
+ default: module.CustomWorldAgentWorkspace,
+ };
+});
+
export type SelectionStage =
| 'platform'
| 'detail'
+ | 'agent-workspace'
| 'custom-world-generating'
| 'custom-world-result';
+type CustomWorldGenerationViewSource =
+ | 'classic'
+ | 'agent-draft-foundation'
+ | null;
+
type PreGameSelectionFlowProps = {
selectionStage: SelectionStage;
setSelectionStage: (stage: SelectionStage) => void;
@@ -151,6 +190,22 @@ function resolveErrorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
+function createFailedAgentOperation(params: {
+ type: CustomWorldAgentOperationRecord['type'];
+ phaseLabel: string;
+ error: string;
+}): CustomWorldAgentOperationRecord {
+ return {
+ operationId: `local-failed-${Date.now()}`,
+ type: params.type,
+ status: 'failed',
+ phaseLabel: params.phaseLabel,
+ phaseDetail: params.error,
+ progress: 100,
+ error: params.error,
+ };
+}
+
function LazyPanelFallback({ label }: { label: string }) {
return (
@@ -170,6 +225,8 @@ export function PreGameSelectionFlow({
handleStartNewGame,
handleCustomWorldSelect,
}: PreGameSelectionFlowProps) {
+ const initialAgentUiStateRef = useRef(readCustomWorldAgentUiState());
+ const hasAppliedInitialAgentWorkspaceRef = useRef(false);
const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] =
useState
(null);
const [savedCustomWorldEntries, setSavedCustomWorldEntries] = useState<
@@ -178,8 +235,25 @@ export function PreGameSelectionFlow({
const [publishedGalleryEntries, setPublishedGalleryEntries] = useState<
CustomWorldGalleryCard[]
>([]);
+ const [platformTab, setPlatformTab] = useState('home');
const [selectedDetailEntry, setSelectedDetailEntry] =
useState | null>(null);
+ const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
+ const [creationTypeError, setCreationTypeError] = useState(
+ null,
+ );
+ const [isCreatingAgentSession, setIsCreatingAgentSession] = useState(false);
+ const [activeAgentSessionId, setActiveAgentSessionId] = useState<
+ string | null
+ >(() => initialAgentUiStateRef.current.activeSessionId ?? null);
+ const [activeAgentOperationId, setActiveAgentOperationId] = useState<
+ string | null
+ >(() => initialAgentUiStateRef.current.activeOperationId ?? null);
+ const [agentSession, setAgentSession] =
+ useState(null);
+ const [agentOperation, setAgentOperation] =
+ useState(null);
+ const [isLoadingAgentSession, setIsLoadingAgentSession] = useState(false);
const [showCustomWorldModal, setShowCustomWorldModal] = useState(false);
const [customWorldCreatorIntent, setCustomWorldCreatorIntent] =
useState(() =>
@@ -196,6 +270,10 @@ export function PreGameSelectionFlow({
const [isMutatingDetail, setIsMutatingDetail] = useState(false);
const [customWorldProgress, setCustomWorldProgress] =
useState(null);
+ const [customWorldGenerationViewSource, setCustomWorldGenerationViewSource] =
+ useState(null);
+ const [agentDraftGenerationStartedAt, setAgentDraftGenerationStartedAt] =
+ useState(null);
const customWorldAbortControllerRef = useRef(null);
const previewCustomWorldCharacters = useMemo(
@@ -211,6 +289,24 @@ export function PreGameSelectionFlow({
[publishedGalleryEntries],
);
+ const persistAgentUiState = useCallback(
+ (nextSessionId: string | null, nextOperationId: string | null) => {
+ setActiveAgentSessionId(nextSessionId);
+ setActiveAgentOperationId(nextOperationId);
+ writeCustomWorldAgentUiState({
+ activeSessionId: nextSessionId,
+ activeOperationId: nextOperationId,
+ });
+ },
+ [],
+ );
+
+ const syncAgentSessionSnapshot = useCallback(async (sessionId: string) => {
+ const nextSession = await getCustomWorldAgentSession(sessionId);
+ setAgentSession(nextSession);
+ return nextSession;
+ }, []);
+
const refreshPlatformData = useCallback(async () => {
setIsLoadingPlatform(true);
setPlatformError(null);
@@ -239,6 +335,18 @@ export function PreGameSelectionFlow({
}
}, [selectedDetailEntry]);
+ useEffect(() => {
+ if (hasAppliedInitialAgentWorkspaceRef.current) {
+ return;
+ }
+
+ hasAppliedInitialAgentWorkspaceRef.current = true;
+ if (initialAgentUiStateRef.current.activeSessionId) {
+ setPlatformTab('create');
+ setSelectionStage('agent-workspace');
+ }
+ }, [setSelectionStage]);
+
useEffect(() => {
let isActive = true;
@@ -293,6 +401,117 @@ export function PreGameSelectionFlow({
[],
);
+ useEffect(() => {
+ if (!activeAgentSessionId) {
+ setAgentSession(null);
+ setIsLoadingAgentSession(false);
+ return;
+ }
+
+ let cancelled = false;
+ setIsLoadingAgentSession(true);
+
+ void syncAgentSessionSnapshot(activeAgentSessionId)
+ .then(() => {
+ if (!cancelled) {
+ setCreationTypeError(null);
+ }
+ })
+ .catch((error) => {
+ if (cancelled) {
+ return;
+ }
+
+ setCreationTypeError(
+ resolveErrorMessage(error, '读取 Agent 共创工作区失败。'),
+ );
+ setAgentSession(null);
+ setAgentOperation(null);
+ persistAgentUiState(null, null);
+ setPlatformTab('create');
+ setSelectionStage('platform');
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setIsLoadingAgentSession(false);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [
+ activeAgentSessionId,
+ persistAgentUiState,
+ setSelectionStage,
+ syncAgentSessionSnapshot,
+ ]);
+
+ useEffect(() => {
+ if (!activeAgentSessionId || !activeAgentOperationId) {
+ return;
+ }
+
+ let cancelled = false;
+
+ const pollOperation = async () => {
+ try {
+ const nextOperation = await getCustomWorldAgentOperation(
+ activeAgentSessionId,
+ activeAgentOperationId,
+ );
+
+ if (cancelled) {
+ return;
+ }
+
+ setAgentOperation(nextOperation);
+
+ if (
+ nextOperation.status === 'completed' ||
+ nextOperation.status === 'failed'
+ ) {
+ persistAgentUiState(activeAgentSessionId, null);
+ await syncAgentSessionSnapshot(activeAgentSessionId).catch(
+ () => null,
+ );
+ }
+ } catch (error) {
+ if (cancelled) {
+ return;
+ }
+
+ const errorMessage = resolveErrorMessage(
+ error,
+ '读取共创操作状态失败。',
+ );
+ setAgentOperation(
+ createFailedAgentOperation({
+ type: 'process_message',
+ phaseLabel: '读取操作状态失败',
+ error: errorMessage,
+ }),
+ );
+ persistAgentUiState(activeAgentSessionId, null);
+ }
+ };
+
+ void pollOperation();
+ const intervalId = window.setInterval(() => {
+ void pollOperation();
+ }, 1200);
+
+ return () => {
+ cancelled = true;
+ window.clearInterval(intervalId);
+ };
+ }, [
+ activeAgentOperationId,
+ activeAgentSessionId,
+ persistAgentUiState,
+ syncAgentSessionSnapshot,
+ ]);
+
const customWorldSettingPreview = useMemo(() => {
if (customWorldCreatorIntent.sourceMode === 'freeform') {
return customWorldCreatorIntent.rawSettingText.trim();
@@ -308,10 +527,40 @@ export function PreGameSelectionFlow({
return customWorldCreatorIntent.rawSettingText.trim();
}, [customWorldCreatorIntent]);
+ const agentDraftSettingPreview = useMemo(
+ () => buildAgentDraftFoundationSettingText(agentSession),
+ [agentSession],
+ );
+
+ const agentDraftGenerationProgress = useMemo(
+ () =>
+ buildAgentDraftFoundationGenerationProgress(
+ agentOperation,
+ agentDraftGenerationStartedAt,
+ ),
+ [agentDraftGenerationStartedAt, agentOperation],
+ );
+
+ const isAgentDraftGenerationView =
+ customWorldGenerationViewSource === 'agent-draft-foundation';
+ const activeGenerationProgress = isAgentDraftGenerationView
+ ? agentDraftGenerationProgress
+ : customWorldProgress;
+ const isActiveGenerationRunning = isAgentDraftGenerationView
+ ? isDraftFoundationOperationRunning(agentOperation)
+ : isGeneratingCustomWorld;
+ const activeGenerationError =
+ isAgentDraftGenerationView &&
+ isDraftFoundationOperation(agentOperation) &&
+ agentOperation.status === 'failed'
+ ? agentOperation.error || agentOperation.phaseDetail
+ : customWorldError;
+
const leaveCustomWorldResult = () => {
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldProgress(null);
+ setCustomWorldGenerationViewSource(null);
setSelectionStage(selectedDetailEntry ? 'detail' : 'platform');
};
@@ -322,6 +571,101 @@ export function PreGameSelectionFlow({
setCustomWorldError(null);
setCustomWorldProgress(null);
+ setCustomWorldGenerationViewSource(null);
+ setSelectionStage('platform');
+ };
+
+ const openCreationTypePicker = () => {
+ if (isCreatingAgentSession) {
+ return;
+ }
+
+ setCreationTypeError(null);
+ setShowCreationTypeModal(true);
+ };
+
+ const openRpgAgentWorkspace = async () => {
+ if (isCreatingAgentSession) {
+ return;
+ }
+
+ setIsCreatingAgentSession(true);
+ setCreationTypeError(null);
+
+ try {
+ const { session } = await createCustomWorldAgentSession({});
+ setAgentSession(session);
+ setAgentOperation(null);
+ persistAgentUiState(session.sessionId, null);
+ setShowCreationTypeModal(false);
+ setPlatformTab('create');
+ setSelectionStage('agent-workspace');
+ } catch (error) {
+ setCreationTypeError(resolveErrorMessage(error, '开启共创工作台失败。'));
+ } finally {
+ setIsCreatingAgentSession(false);
+ }
+ };
+
+ const submitAgentMessage = async (
+ payload: SendCustomWorldAgentMessageRequest,
+ ) => {
+ if (!activeAgentSessionId) {
+ return;
+ }
+
+ try {
+ const { operation } = await sendCustomWorldAgentMessage(
+ activeAgentSessionId,
+ payload,
+ );
+ setAgentOperation(operation);
+ persistAgentUiState(activeAgentSessionId, operation.operationId);
+ } catch (error) {
+ const errorMessage = resolveErrorMessage(error, '发送共创消息失败。');
+ setAgentOperation(
+ createFailedAgentOperation({
+ type: 'process_message',
+ phaseLabel: '发送消息失败',
+ error: errorMessage,
+ }),
+ );
+ persistAgentUiState(activeAgentSessionId, null);
+ }
+ };
+
+ const executeAgentAction = async (payload: CustomWorldAgentActionRequest) => {
+ if (!activeAgentSessionId) {
+ return;
+ }
+
+ try {
+ const { operation } = await executeCustomWorldAgentAction(
+ activeAgentSessionId,
+ payload,
+ );
+ setAgentOperation(operation);
+ persistAgentUiState(activeAgentSessionId, operation.operationId);
+ } catch (error) {
+ const errorMessage = resolveErrorMessage(error, '执行共创操作失败。');
+ setAgentOperation(
+ createFailedAgentOperation({
+ type:
+ payload.action === 'draft_foundation'
+ ? 'draft_foundation'
+ : payload.action,
+ phaseLabel: '执行操作失败',
+ error: errorMessage,
+ }),
+ );
+ persistAgentUiState(activeAgentSessionId, null);
+ }
+ };
+
+ const leaveAgentWorkspace = () => {
+ setPlatformTab('create');
+ setAgentOperation(null);
+ persistAgentUiState(activeAgentSessionId, null);
setSelectionStage('platform');
};
@@ -340,7 +684,9 @@ export function PreGameSelectionFlow({
setDetailError(null);
setCustomWorldError(null);
setCustomWorldProgress(null);
- setCustomWorldCreatorIntent(createEmptyCustomWorldCreatorIntent('freeform'));
+ setCustomWorldCreatorIntent(
+ createEmptyCustomWorldCreatorIntent('freeform'),
+ );
setCustomWorldGenerationMode('fast');
setShowCustomWorldModal(true);
};
@@ -400,7 +746,9 @@ export function PreGameSelectionFlow({
}
try {
- const mutation = await upsertCustomWorldProfile(generatedCustomWorldProfile);
+ const mutation = await upsertCustomWorldProfile(
+ generatedCustomWorldProfile,
+ );
setSavedCustomWorldEntries(mutation.entries);
setSelectedDetailEntry(mutation.entry);
await refreshPlatformData();
@@ -684,18 +1032,20 @@ export function PreGameSelectionFlow({
className="flex h-full min-h-0 flex-col"
>
{
- void refreshPlatformData();
- }}
onOpenCreateWorld={openCustomWorldCreator}
+ onOpenCreateTypePicker={openCreationTypePicker}
onOpenGalleryDetail={(entry) => {
void openGalleryDetail(entry);
}}
@@ -750,6 +1100,50 @@ export function PreGameSelectionFlow({
)}
+ {selectionStage === 'agent-workspace' && (
+
+
+ }
+ >
+ {agentSession ? (
+ {
+ if (!activeAgentSessionId) {
+ return;
+ }
+ void syncAgentSessionSnapshot(activeAgentSessionId);
+ }}
+ onSubmitMessage={(payload) => {
+ void submitAgentMessage(payload);
+ }}
+ onExecuteAction={(payload) => {
+ void executeAgentAction(payload);
+ }}
+ />
+ ) : (
+
+
+ {isLoadingAgentSession
+ ? '正在准备 Agent 共创工作区...'
+ : creationTypeError || '正在恢复创作工作区...'}
+
+
+ )}
+
+
+ )}
+
{selectionStage === 'custom-world-generating' && (
- }
+ fallback={}
>
+ {
+ if (isCreatingAgentSession) {
+ return;
+ }
+ setShowCreationTypeModal(false);
+ }}
+ onSelectRpg={() => {
+ void openRpgAgentWorkspace();
+ }}
+ />
+
{showCustomWorldModal ? (
diff --git a/src/services/customWorldAgentGenerationProgress.test.ts b/src/services/customWorldAgentGenerationProgress.test.ts
new file mode 100644
index 00000000..6ed1f591
--- /dev/null
+++ b/src/services/customWorldAgentGenerationProgress.test.ts
@@ -0,0 +1,131 @@
+import { expect, test } from 'vitest';
+
+import type {
+ CustomWorldAgentOperationRecord,
+ CustomWorldAgentSessionSnapshot,
+} from '../../packages/shared/src/contracts/customWorldAgent';
+import {
+ buildAgentDraftFoundationGenerationProgress,
+ buildAgentDraftFoundationSettingText,
+ isDraftFoundationOperationRunning,
+} from './customWorldAgentGenerationProgress';
+
+const baseOperation: CustomWorldAgentOperationRecord = {
+ operationId: 'operation-1',
+ type: 'draft_foundation',
+ status: 'running',
+ phaseLabel: '生成世界底稿',
+ phaseDetail: '正在根据已确认锚点编译第一版世界结构。',
+ progress: 38,
+ error: null,
+};
+
+const baseSession: CustomWorldAgentSessionSnapshot = {
+ sessionId: 'session-1',
+ stage: 'foundation_review',
+ focusCardId: null,
+ creatorIntent: {
+ sourceMode: 'card',
+ worldHook: '海雾、旧灯塔和失控航路交织的边缘群岛',
+ themeKeywords: ['海雾', '灯塔', '旧航路'],
+ toneDirectives: ['压抑', '悬疑'],
+ playerPremise: '玩家刚回到群岛,准备调查父亲沉船的真相。',
+ openingSituation: '首夜就有陌生船只在禁航区点灯。',
+ coreConflicts: ['航运公会与守灯会争夺航路控制权'],
+ keyFactions: [],
+ keyCharacters: [],
+ keyLandmarks: [],
+ iconicElements: ['会移动的海雾'],
+ forbiddenDirectives: [],
+ rawSettingText: '',
+ },
+ creatorIntentReadiness: {
+ isReady: true,
+ completedKeys: [],
+ missingKeys: [],
+ },
+ anchorPack: null,
+ lockState: null,
+ draftProfile: null,
+ messages: [
+ {
+ id: 'message-1',
+ role: 'user',
+ kind: 'chat',
+ text: '我想做一个被海雾吞没的旧航路世界。',
+ createdAt: '2026-04-14T10:00:00.000Z',
+ relatedOperationId: null,
+ },
+ ],
+ draftCards: [],
+ pendingClarifications: [],
+ suggestedActions: [],
+ recommendedReplies: [],
+ qualityFindings: [],
+ assetCoverage: {
+ roleAssets: [],
+ sceneAssets: [],
+ allRoleAssetsReady: false,
+ allSceneAssetsReady: false,
+ },
+ updatedAt: '2026-04-14T10:00:00.000Z',
+};
+
+test('maps running draft_foundation operation to legacy generation progress', () => {
+ const progress = buildAgentDraftFoundationGenerationProgress(
+ baseOperation,
+ 1_000,
+ 5_000,
+ );
+
+ expect(progress).not.toBeNull();
+ expect(progress?.phaseId).toBe('foundation');
+ expect(progress?.batchLabel).toBe('生成世界底稿');
+ expect(progress?.overallProgress).toBe(38);
+ expect(progress?.elapsedMs).toBe(4_000);
+ expect(progress?.estimatedRemainingMs).toBeGreaterThan(0);
+ expect(progress?.steps.map((step) => step.status)).toEqual([
+ 'completed',
+ 'active',
+ 'pending',
+ 'pending',
+ ]);
+ expect(isDraftFoundationOperationRunning(baseOperation)).toBe(true);
+});
+
+test('marks all legacy progress steps complete when draft foundation finishes', () => {
+ const progress = buildAgentDraftFoundationGenerationProgress(
+ {
+ ...baseOperation,
+ status: 'completed',
+ phaseLabel: '世界底稿已生成',
+ phaseDetail: '第一版世界底稿和 6 张草稿卡已经整理完成。',
+ progress: 100,
+ },
+ 1_000,
+ 5_000,
+ );
+
+ expect(progress?.phaseId).toBe('workspace');
+ expect(progress?.estimatedRemainingMs).toBe(0);
+ expect(progress?.steps.every((step) => step.status === 'completed')).toBe(
+ true,
+ );
+});
+
+test('builds readable draft setting text from creator intent first', () => {
+ const settingText = buildAgentDraftFoundationSettingText(baseSession);
+
+ expect(settingText).toContain('世界核心');
+ expect(settingText).toContain('玩家开局');
+ expect(settingText).toContain('标志元素');
+});
+
+test('falls back to latest user message when creator intent is unavailable', () => {
+ const settingText = buildAgentDraftFoundationSettingText({
+ ...baseSession,
+ creatorIntent: null,
+ });
+
+ expect(settingText).toBe('我想做一个被海雾吞没的旧航路世界。');
+});
diff --git a/src/services/customWorldAgentGenerationProgress.ts b/src/services/customWorldAgentGenerationProgress.ts
new file mode 100644
index 00000000..f36c4428
--- /dev/null
+++ b/src/services/customWorldAgentGenerationProgress.ts
@@ -0,0 +1,210 @@
+import type {
+ CustomWorldAgentOperationRecord,
+ CustomWorldAgentSessionSnapshot,
+} from '../../packages/shared/src/contracts/customWorldAgent';
+import type {
+ CustomWorldGenerationProgress,
+ CustomWorldGenerationStep,
+} from '../../packages/shared/src/contracts/runtime';
+import {
+ buildCustomWorldCreatorIntentDisplayText,
+ buildCustomWorldCreatorIntentGenerationText,
+ normalizeCustomWorldCreatorIntent,
+} from './customWorldCreatorIntent';
+
+const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
+ {
+ id: 'queue',
+ label: '接收生成请求',
+ detail: '正在锁定当前已确认的世界锚点与草稿范围。',
+ },
+ {
+ id: 'foundation',
+ label: '生成世界底稿',
+ detail: '正在根据世界核心、关系种子与冲突线编排第一版世界结构。',
+ },
+ {
+ id: 'cards',
+ label: '编译草稿卡',
+ detail: '正在整理世界卡、角色卡与地点卡的摘要和详情。',
+ },
+ {
+ id: 'workspace',
+ label: '准备精修工作区',
+ detail: '正在写回草稿数据,并切回可继续精修的工作区。',
+ },
+] as const satisfies ReadonlyArray<{
+ id: string;
+ label: string;
+ detail: string;
+}>;
+
+function clampProgress(progress: number | null | undefined) {
+ if (typeof progress !== 'number' || Number.isNaN(progress)) {
+ return 0;
+ }
+
+ return Math.max(0, Math.min(100, Math.round(progress)));
+}
+
+function resolveAgentDraftFoundationStepIndex(
+ operation: CustomWorldAgentOperationRecord,
+) {
+ const progress = clampProgress(operation.progress);
+ const phaseLabel = operation.phaseLabel.trim();
+
+ if (
+ operation.status === 'completed' ||
+ phaseLabel.includes('世界底稿已生成') ||
+ progress >= 90
+ ) {
+ return 3;
+ }
+
+ if (phaseLabel.includes('编译草稿卡') || progress >= 60) {
+ return 2;
+ }
+
+ if (phaseLabel.includes('生成世界底稿') || progress >= 25) {
+ return 1;
+ }
+
+ return 0;
+}
+
+function buildAgentDraftFoundationSteps(
+ operation: CustomWorldAgentOperationRecord,
+ activeStepIndex: number,
+) {
+ return AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.map((step, index) => {
+ const isCompleted =
+ operation.status === 'completed' || index < activeStepIndex;
+ const isActive = !isCompleted && index === activeStepIndex;
+
+ return {
+ id: step.id,
+ label: step.label,
+ detail: step.detail,
+ completed: isCompleted ? 1 : 0,
+ total: 1,
+ status: isCompleted
+ ? 'completed'
+ : isActive
+ ? 'active'
+ : 'pending',
+ } satisfies CustomWorldGenerationStep;
+ });
+}
+
+function resolveEstimatedRemainingMs(
+ progress: number,
+ startedAtMs: number | null,
+ nowMs: number,
+ status: CustomWorldAgentOperationRecord['status'],
+) {
+ if (status === 'completed') {
+ return 0;
+ }
+
+ if (!startedAtMs || progress <= 0 || progress >= 100) {
+ return null;
+ }
+
+ const elapsedMs = Math.max(0, nowMs - startedAtMs);
+ const progressFraction = progress / 100;
+
+ return Math.max(
+ 0,
+ Math.round(elapsedMs / progressFraction - elapsedMs),
+ );
+}
+
+export function isDraftFoundationOperation(
+ operation: CustomWorldAgentOperationRecord | null | undefined,
+): operation is CustomWorldAgentOperationRecord {
+ return Boolean(operation && operation.type === 'draft_foundation');
+}
+
+export function isDraftFoundationOperationRunning(
+ operation: CustomWorldAgentOperationRecord | null | undefined,
+) {
+ return (
+ isDraftFoundationOperation(operation) &&
+ (operation.status === 'queued' || operation.status === 'running')
+ );
+}
+
+export function buildAgentDraftFoundationGenerationProgress(
+ operation: CustomWorldAgentOperationRecord | null | undefined,
+ startedAtMs: number | null,
+ nowMs = Date.now(),
+): CustomWorldGenerationProgress | null {
+ if (!isDraftFoundationOperation(operation)) {
+ return null;
+ }
+
+ const overallProgress = clampProgress(operation.progress);
+ const activeStepIndex = resolveAgentDraftFoundationStepIndex(operation);
+ const elapsedMs = startedAtMs ? Math.max(0, nowMs - startedAtMs) : 0;
+ const estimatedRemainingMs = resolveEstimatedRemainingMs(
+ overallProgress,
+ startedAtMs,
+ nowMs,
+ operation.status,
+ );
+ const activeStep =
+ AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[activeStepIndex] ??
+ AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[0];
+
+ return {
+ phaseId: activeStep.id,
+ phaseLabel: operation.phaseLabel || activeStep.label,
+ phaseDetail: operation.phaseDetail || activeStep.detail,
+ batchLabel: activeStep.label,
+ overallProgress,
+ completedWeight: overallProgress,
+ totalWeight: 100,
+ elapsedMs,
+ estimatedRemainingMs,
+ activeStepIndex,
+ steps: buildAgentDraftFoundationSteps(operation, activeStepIndex),
+ };
+}
+
+export function buildAgentDraftFoundationSettingText(
+ session: CustomWorldAgentSessionSnapshot | null | undefined,
+) {
+ if (!session) {
+ return '';
+ }
+
+ const creatorIntent = normalizeCustomWorldCreatorIntent(
+ session.creatorIntent,
+ 'freeform',
+ );
+
+ if (creatorIntent) {
+ const displayText =
+ buildCustomWorldCreatorIntentDisplayText(creatorIntent).trim();
+ const generationText =
+ buildCustomWorldCreatorIntentGenerationText(creatorIntent).trim();
+
+ if (displayText) {
+ return displayText;
+ }
+
+ if (generationText) {
+ return generationText;
+ }
+
+ if (creatorIntent.rawSettingText.trim()) {
+ return creatorIntent.rawSettingText.trim();
+ }
+ }
+
+ const latestUserMessage = [...session.messages]
+ .reverse()
+ .find((message) => message.role === 'user' && message.text.trim());
+
+ return latestUserMessage?.text.trim() ?? '正在整理当前共创设定。';
+}