-
修改设定
-
重新生成
+ {onEditSetting ? (
+
修改设定
+ ) : null}
+ {triggerRegenerate ? (
+
重新生成
+ ) : null}
{profile.generationStatus === 'key_only' && onContinueExpand ? (
继续补全世界
diff --git a/src/components/CustomWorldRoleAssetStudioModal.tsx b/src/components/CustomWorldRoleAssetStudioModal.tsx
index 86bed95b..03dfa223 100644
--- a/src/components/CustomWorldRoleAssetStudioModal.tsx
+++ b/src/components/CustomWorldRoleAssetStudioModal.tsx
@@ -4,13 +4,17 @@ import {
ImagePlus,
RefreshCcw,
} from 'lucide-react';
-import { type ChangeEvent, type ReactNode, useMemo, useState } from 'react';
+import {
+ type ChangeEvent,
+ type ReactNode,
+ useEffect,
+ useMemo,
+ useState,
+} from 'react';
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
import {
AnimationState,
- type CustomWorldNpc,
- type CustomWorldPlayableNpc,
} from '../types';
import { CharacterAnimator } from './CharacterAnimator';
import {
@@ -29,7 +33,23 @@ import {
publishCharacterVisualAsset,
} from './asset-studio/characterAssetWorkflowPersistence';
-type EditableCustomWorldRole = CustomWorldPlayableNpc | CustomWorldNpc;
+type EditableCustomWorldRole = {
+ id: string;
+ name: string;
+ title: string;
+ role: string;
+ description?: string;
+ backstory?: string;
+ personality?: string;
+ motivation?: string;
+ combatStyle?: string;
+ tags?: string[];
+ templateCharacterId?: string;
+ imageSrc?: string;
+ generatedVisualAssetId?: string;
+ generatedAnimationSetId?: string;
+ animationMap?: Record;
+};
type CustomWorldAiActionConfig = {
animation: AnimationState;
@@ -298,7 +318,7 @@ function buildRoleCharacterBrief(
role.personality ? `角色性格:${role.personality}` : '',
role.motivation ? `角色动机:${role.motivation}` : '',
role.combatStyle ? `战斗风格:${role.combatStyle}` : '',
- role.tags.length > 0 ? `角色标签:${role.tags.join('、')}` : '',
+ role.tags && role.tags.length > 0 ? `角色标签:${role.tags.join('、')}` : '',
templateLabel ? `参考模板:${templateLabel}` : '',
]
.filter(Boolean)
@@ -319,13 +339,35 @@ export function CustomWorldRoleAssetStudioModal({
role,
roleKind,
onApply,
+ onPublishSuccess,
onClose,
+ syncBusy = false,
+ visualPointCost = 20,
+ animationPointCost = 60,
+ priorityTier = roleKind === 'playable' ? 'hero' : 'featured',
}: {
role: EditableCustomWorldRole;
roleKind: 'playable' | 'story';
- onApply: (nextRole: EditableCustomWorldRole) => void;
+ onApply?: (nextRole: EditableCustomWorldRole) => void;
+ onPublishSuccess?: (
+ payload: {
+ roleId: string;
+ portraitPath: string;
+ generatedVisualAssetId: string;
+ generatedAnimationSetId?: string | null;
+ animationMap?: Record | null;
+ },
+ options?: {
+ closeAfterSync?: boolean;
+ },
+ ) => void;
onClose: () => void;
+ syncBusy?: boolean;
+ visualPointCost?: number;
+ animationPointCost?: number;
+ priorityTier?: 'hero' | 'featured' | 'supporting';
}) {
+ const [workingRole, setWorkingRole] = useState(role);
const [sourceMode, setSourceMode] =
useState>(
role.imageSrc ? 'image-to-image' : 'text-to-image',
@@ -351,42 +393,66 @@ export function CustomWorldRoleAssetStudioModal({
const [isGeneratingAnimations, setIsGeneratingAnimations] = useState(false);
const [isApplyingAnimations, setIsApplyingAnimations] = useState(false);
+ useEffect(() => {
+ setWorkingRole(role);
+ }, [role]);
+
const selectedTemplate =
- roleKind === 'playable' && 'templateCharacterId' in role && role.templateCharacterId
+ roleKind === 'playable' && workingRole.templateCharacterId
? ROLE_TEMPLATE_CHARACTERS.find(
- (character) => character.id === role.templateCharacterId,
+ (character) => character.id === workingRole.templateCharacterId,
) ?? null
: null;
const characterBriefText = useMemo(
() =>
buildRoleCharacterBrief(
- role,
+ workingRole,
selectedTemplate
? `${selectedTemplate.name} / ${selectedTemplate.title}`
: undefined,
),
- [role, selectedTemplate],
+ [workingRole, selectedTemplate],
);
const effectiveReferenceImages =
referenceImageDataUrls.length > 0
? referenceImageDataUrls
- : role.imageSrc
- ? [role.imageSrc]
+ : workingRole.imageSrc
+ ? [workingRole.imageSrc]
: [];
const selectedVisualDraft =
visualDrafts.find((draft) => draft.id === selectedVisualDraftId) ?? null;
const previewImageSrc =
selectedVisualDraft?.imageSrc ??
- role.imageSrc ??
+ workingRole.imageSrc ??
selectedTemplate?.portrait ??
'';
const selectedActionConfig =
CORE_ACTIONS.find((item) => item.animation === selectedAnimation) ??
CORE_ACTIONS[0];
const appliedActionCount = CORE_ACTIONS.filter(
- (item) => role.animationMap?.[item.animation]?.basePath,
+ (item) =>
+ Boolean(
+ (workingRole.animationMap as Record | null)
+ ?.[item.animation]?.basePath,
+ ),
).length;
+ const visualCandidateCount = priorityTier === 'supporting' ? 1 : 2;
+
+ const confirmPointSpend = (params: {
+ kindLabel: string;
+ points: number;
+ description: string;
+ }) => {
+ if (typeof window === 'undefined' || typeof window.confirm !== 'function') {
+ return true;
+ }
+
+ return window.confirm(
+ `${params.kindLabel}预计消耗 ${params.points} 积分。\n${params.description}`,
+ );
+ };
+
const handleReferenceImageUpload = async (
event: ChangeEvent,
) => {
@@ -406,6 +472,16 @@ export function CustomWorldRoleAssetStudioModal({
};
const handleGenerateVisuals = async () => {
+ if (
+ !confirmPointSpend({
+ kindLabel: '主图候选生成',
+ points: visualPointCost,
+ description: '这次是主图候选抽卡,不是最终发布。',
+ })
+ ) {
+ return;
+ }
+
setIsGeneratingVisuals(true);
setVisualStatus(null);
@@ -418,12 +494,12 @@ export function CustomWorldRoleAssetStudioModal({
}
const result = await generateCharacterVisualCandidates({
- characterId: role.id,
+ characterId: workingRole.id,
sourceMode,
promptText: visualPromptText,
characterBriefText,
referenceImageDataUrls: effectiveReferenceImages,
- candidateCount: 3,
+ candidateCount: visualCandidateCount,
imageModel: 'wan2.7-image-pro',
size: '1024*1536',
});
@@ -450,7 +526,7 @@ export function CustomWorldRoleAssetStudioModal({
try {
const result = await publishCharacterVisualAsset({
- characterId: role.id,
+ characterId: workingRole.id,
sourceMode,
promptText: visualPromptText,
selectedPreviewSource: selectedVisualDraft.imageSrc,
@@ -460,13 +536,25 @@ export function CustomWorldRoleAssetStudioModal({
updateCharacterOverride: false,
});
- onApply(
- mergeRole(role, {
- imageSrc: result.portraitPath,
+ const nextRole = mergeRole(workingRole, {
+ imageSrc: result.portraitPath,
+ generatedVisualAssetId: result.assetId,
+ generatedAnimationSetId: undefined,
+ animationMap: undefined,
+ });
+ setWorkingRole(nextRole);
+ onApply?.(nextRole);
+ onPublishSuccess?.(
+ {
+ roleId: workingRole.id,
+ portraitPath: result.portraitPath,
generatedVisualAssetId: result.assetId,
- generatedAnimationSetId: undefined,
- animationMap: undefined,
- }),
+ generatedAnimationSetId: null,
+ animationMap: null,
+ },
+ {
+ closeAfterSync: false,
+ },
);
setDraftAnimations({});
setAnimationStatus(null);
@@ -481,18 +569,18 @@ export function CustomWorldRoleAssetStudioModal({
};
const generateActionClip = async (config: CustomWorldAiActionConfig) => {
- if (!role.imageSrc || !role.generatedVisualAssetId) {
+ if (!workingRole.imageSrc || !workingRole.generatedVisualAssetId) {
throw new Error('请先应用主图,再生成动作。');
}
const result = await generateCharacterAnimationDraft({
- characterId: role.id,
+ characterId: workingRole.id,
strategy: 'image-to-video',
animation: config.animation,
promptText: animationPromptText,
characterBriefText,
actionTemplateId: config.templateId,
- visualSource: role.imageSrc,
+ visualSource: workingRole.imageSrc,
referenceImageDataUrls: [],
referenceVideoDataUrls: [],
frameCount: config.frameCount,
@@ -525,6 +613,16 @@ export function CustomWorldRoleAssetStudioModal({
return;
}
+ if (
+ !confirmPointSpend({
+ kindLabel: '动作草稿生成',
+ points: animationPointCost,
+ description: '这次是动作草稿试片,不是最终发布。',
+ })
+ ) {
+ return;
+ }
+
setIsGeneratingAnimations(true);
setAnimationStatus(null);
@@ -545,6 +643,16 @@ export function CustomWorldRoleAssetStudioModal({
};
const handleGenerateAllAnimations = async () => {
+ if (
+ !confirmPointSpend({
+ kindLabel: '核心动作生成',
+ points: animationPointCost,
+ description: '这次会生成核心动作草稿,发布前仍可继续调整。',
+ })
+ ) {
+ return;
+ }
+
setIsGeneratingAnimations(true);
setAnimationStatus(null);
@@ -570,7 +678,7 @@ export function CustomWorldRoleAssetStudioModal({
};
const handleApplyAnimations = async () => {
- if (!role.generatedVisualAssetId) {
+ if (!workingRole.generatedVisualAssetId) {
setAnimationStatus('请先应用主图,再应用动作。');
return;
}
@@ -601,22 +709,37 @@ export function CustomWorldRoleAssetStudioModal({
]),
);
const result = await publishCharacterAnimationAssets({
- characterId: role.id,
- visualAssetId: role.generatedVisualAssetId,
+ characterId: workingRole.id,
+ visualAssetId: workingRole.generatedVisualAssetId,
animations: payload,
updateCharacterOverride: false,
});
- onApply(
- mergeRole(role, {
+ const nextRole = mergeRole(workingRole, {
generatedAnimationSetId: result.animationSetId,
animationMap: {
- ...(role.animationMap ?? {}),
+ ...((workingRole.animationMap ?? {}) as Record),
...(result.animationMap as NonNullable<
EditableCustomWorldRole['animationMap']
>),
},
- }),
+ });
+ setWorkingRole(nextRole);
+ onApply?.(nextRole);
+ onPublishSuccess?.(
+ {
+ roleId: workingRole.id,
+ portraitPath: workingRole.imageSrc ?? previewImageSrc,
+ generatedVisualAssetId: workingRole.generatedVisualAssetId ?? '',
+ generatedAnimationSetId: result.animationSetId,
+ animationMap: (nextRole.animationMap ?? null) as Record<
+ string,
+ unknown
+ > | null,
+ },
+ {
+ closeAfterSync: true,
+ },
);
setAnimationStatus('核心动作已应用到当前角色。');
} catch (error) {
@@ -637,7 +760,8 @@ export function CustomWorldRoleAssetStudioModal({
isGeneratingVisuals ||
isApplyingVisual ||
isGeneratingAnimations ||
- isApplyingAnimations
+ isApplyingAnimations ||
+ syncBusy
}
>
@@ -695,18 +819,21 @@ export function CustomWorldRoleAssetStudioModal({
+
+ 本轮预计 {visualPointCost} 积分
+
}
label={isGeneratingVisuals ? '生成中...' : '生成主图候选'}
onClick={() => void handleGenerateVisuals()}
- disabled={isGeneratingVisuals}
+ disabled={isGeneratingVisuals || syncBusy}
tone="sky"
/>
}
label={isApplyingVisual ? '应用中...' : '应用主图'}
onClick={() => void handleApplyVisual()}
- disabled={isApplyingVisual || !selectedVisualDraft}
+ disabled={isApplyingVisual || !selectedVisualDraft || syncBusy}
tone="green"
/>
@@ -723,7 +850,7 @@ export function CustomWorldRoleAssetStudioModal({
{previewImageSrc ? (
) : selectedTemplate ? (
@@ -811,7 +938,9 @@ export function CustomWorldRoleAssetStudioModal({
: `生成${selectedActionConfig?.label ?? '当前'}动作`
}
onClick={() => void handleGenerateSingleAnimation()}
- disabled={isGeneratingAnimations || !role.imageSrc}
+ disabled={
+ isGeneratingAnimations || !workingRole.imageSrc || syncBusy
+ }
tone="sky"
/>
void handleGenerateAllAnimations()}
- disabled={isGeneratingAnimations || !role.imageSrc}
+ disabled={
+ isGeneratingAnimations || !workingRole.imageSrc || syncBusy
+ }
/>
}
label={isApplyingAnimations ? '应用中...' : '应用动作'}
onClick={() => void handleApplyAnimations()}
- disabled={isApplyingAnimations}
+ disabled={isApplyingAnimations || syncBusy}
tone="green"
/>
+
{animationStatus}
@@ -843,7 +977,7 @@ export function CustomWorldRoleAssetStudioModal({
已应用动作 {appliedActionCount}/{CORE_ACTIONS.length}
- {role.generatedVisualAssetId ? (
+ {workingRole.generatedVisualAssetId ? (
主图已应用
) : (
待应用主图
@@ -853,7 +987,12 @@ export function CustomWorldRoleAssetStudioModal({
{CORE_ACTIONS.map((item) => {
const hasDraft = Boolean(draftAnimations[item.animation]);
const isApplied = Boolean(
- role.animationMap?.[item.animation]?.basePath,
+ (
+ workingRole.animationMap as Record<
+ string,
+ { basePath?: string }
+ > | null
+ )?.[item.animation]?.basePath,
);
return (
),
}
: selectedTemplate.animationMap,
}}
@@ -916,7 +1060,7 @@ export function CustomWorldRoleAssetStudioModal({
) : previewImageSrc ? (

) : (
@@ -934,14 +1078,20 @@ export function CustomWorldRoleAssetStudioModal({
-
{role.name}
+
+ {workingRole.name}
+
- {role.title} / {role.role}
+ {workingRole.title} / {workingRole.role}
- {role.description ?
{role.description}
: null}
- {role.combatStyle ?
战斗风格:{role.combatStyle}
: null}
- {role.tags.length > 0 ?
标签:{role.tags.join('、')}
: null}
+ {workingRole.description ?
{workingRole.description}
: null}
+ {workingRole.combatStyle ? (
+
战斗风格:{workingRole.combatStyle}
+ ) : null}
+ {workingRole.tags && workingRole.tags.length > 0 ? (
+
标签:{workingRole.tags.join('、')}
+ ) : null}
@@ -959,7 +1109,7 @@ export function CustomWorldRoleAssetStudioModal({
主图状态
- {role.generatedVisualAssetId ? (
+ {workingRole.generatedVisualAssetId ? (
已应用
) : (
待生成
@@ -976,7 +1126,11 @@ export function CustomWorldRoleAssetStudioModal({
)}
- 当前面板只保留主图和图生视频抽帧这条生产链,不提供视频预览、抽帧编辑、修帧和导出步骤。
+ 角色优先级:{priorityTier === 'hero'
+ ? '主角级'
+ : priorityTier === 'featured'
+ ? '重点角色'
+ : '支撑角色'}
diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx
index 43c9767b..3c590d9f 100644
--- a/src/components/auth/AuthGate.tsx
+++ b/src/components/auth/AuthGate.tsx
@@ -47,7 +47,7 @@ type AuthStatus =
const allowDevGuestAutoAuth =
import.meta.env.DEV &&
- import.meta.env.VITE_AUTH_ALLOW_DEV_GUEST === 'true';
+ import.meta.env.VITE_AUTH_ALLOW_DEV_GUEST !== 'false';
export function AuthGate({ children }: AuthGateProps) {
const [status, setStatus] = useState
('checking');
diff --git a/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.test.tsx b/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.test.tsx
new file mode 100644
index 00000000..776516e2
--- /dev/null
+++ b/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.test.tsx
@@ -0,0 +1,46 @@
+import { renderToStaticMarkup } from 'react-dom/server';
+import { expect, test } from 'vitest';
+
+import { CustomWorldAgentClarificationPanel } from './CustomWorldAgentClarificationPanel';
+
+test('clarification panel shows pending questions and ready state', () => {
+ const pendingHtml = renderToStaticMarkup(
+ ,
+ );
+ const readyHtml = renderToStaticMarkup(
+ ,
+ );
+
+ expect(pendingHtml).toContain('待补充问题');
+ expect(pendingHtml).toContain('玩家是谁,故事开场时卡在什么处境里');
+ expect(readyHtml).toContain('最小锚点已齐备,可以进入下一阶段');
+});
diff --git a/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.tsx b/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.tsx
new file mode 100644
index 00000000..334a2412
--- /dev/null
+++ b/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.tsx
@@ -0,0 +1,64 @@
+import type {
+ CreatorIntentReadiness,
+ CustomWorldPendingClarification,
+} from '../../../packages/shared/src/contracts/customWorldAgent';
+
+type CustomWorldAgentClarificationPanelProps = {
+ pendingClarifications: CustomWorldPendingClarification[];
+ readiness: CreatorIntentReadiness;
+};
+
+export function CustomWorldAgentClarificationPanel({
+ pendingClarifications,
+ readiness,
+}: CustomWorldAgentClarificationPanelProps) {
+ if (readiness.isReady) {
+ return (
+
+
+ 下一阶段
+
+
+ 最小锚点已齐备,可以进入下一阶段
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ 待补充问题
+
+
+ 先补最关键的 1 到 3 项
+
+
+
+ {pendingClarifications.length}
+
+
+
+
+ {pendingClarifications.slice(0, 3).map((item, index) => (
+
+
+
+ {index + 1}. {item.label}
+
+
P{item.priority}
+
+
+ {item.question}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/custom-world-agent/CustomWorldAgentComposer.tsx b/src/components/custom-world-agent/CustomWorldAgentComposer.tsx
new file mode 100644
index 00000000..c1178424
--- /dev/null
+++ b/src/components/custom-world-agent/CustomWorldAgentComposer.tsx
@@ -0,0 +1,102 @@
+import type { RefObject } from 'react';
+import { useState } from 'react';
+
+import type { SendCustomWorldAgentMessageRequest } from '../../../packages/shared/src/contracts/customWorldAgent';
+
+type CustomWorldAgentComposerProps = {
+ disabled: boolean;
+ onSubmit: (payload: SendCustomWorldAgentMessageRequest) => void;
+ textareaRef?: RefObject;
+ onSummaryClick?: () => void;
+ onAutoCompleteClick?: () => void;
+ showAutoComplete?: boolean;
+};
+
+function createClientMessageId() {
+ if (
+ typeof crypto !== 'undefined' &&
+ typeof crypto.randomUUID === 'function'
+ ) {
+ return crypto.randomUUID();
+ }
+
+ return `client-message-${Date.now()}`;
+}
+
+export function CustomWorldAgentComposer({
+ disabled,
+ onSubmit,
+ textareaRef,
+ onSummaryClick,
+ onAutoCompleteClick,
+ showAutoComplete = true,
+}: CustomWorldAgentComposerProps) {
+ const [text, setText] = useState('');
+
+ const submit = () => {
+ const nextText = text.trim();
+ if (!nextText || disabled) {
+ return;
+ }
+
+ onSubmit({
+ clientMessageId: createClientMessageId(),
+ text: nextText,
+ focusCardId: null,
+ selectedCardIds: [],
+ });
+ setText('');
+ };
+
+ return (
+
+
+
+
+ {showAutoComplete ? (
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.interaction.test.tsx b/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.interaction.test.tsx
new file mode 100644
index 00000000..14a2f771
--- /dev/null
+++ b/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.interaction.test.tsx
@@ -0,0 +1,115 @@
+/* @vitest-environment jsdom */
+
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useState } from 'react';
+import { expect, test } from 'vitest';
+
+import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
+import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
+import { CustomWorldGenerateEntityModal } from './CustomWorldGenerateEntityModal';
+
+const CHARACTER_DETAIL: CustomWorldDraftCardDetail = {
+ id: 'character-1',
+ kind: 'character',
+ title: '沈砺',
+ sections: [
+ {
+ id: 'name',
+ label: '角色名',
+ value: '沈砺',
+ },
+ {
+ id: 'publicMask',
+ label: '外显身份',
+ value: '守灯会里最熟悉旧航道的人。',
+ },
+ {
+ id: 'summary',
+ label: '角色摘要',
+ value: '他像旧友,但也像一把始终没收回鞘的刀。',
+ },
+ ],
+ linkedIds: ['thread-1'],
+ locked: false,
+ editable: true,
+ editableSectionIds: ['name', 'publicMask', 'summary'],
+ warningMessages: [],
+ assetStatus: 'missing',
+ assetStatusLabel: '待生成主图',
+};
+
+function DetailInteractionHarness() {
+ const [editMode, setEditMode] = useState(false);
+ const [generateMode, setGenerateMode] = useState<'character' | 'landmark' | null>(
+ null,
+ );
+ const [savedPayload, setSavedPayload] = useState('');
+
+ return (
+ <>
+ {}}
+ onStartEdit={() => {
+ setEditMode(true);
+ }}
+ onCancelEdit={() => {
+ setEditMode(false);
+ }}
+ onSave={(sections) => {
+ setSavedPayload(JSON.stringify(sections));
+ setEditMode(false);
+ }}
+ onGenerateCharacter={() => {
+ setGenerateMode('character');
+ }}
+ onGenerateLandmark={() => {
+ setGenerateMode('landmark');
+ }}
+ onOpenRoleAssetStudio={() => {}}
+ />
+ {
+ setGenerateMode(null);
+ }}
+ onSubmit={() => {
+ setGenerateMode(null);
+ }}
+ />
+ {savedPayload}
+ >
+ );
+}
+
+test('draft detail panel supports edit save and opening generate modals', async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ await user.click(screen.getByRole('button', { name: '编辑设定' }));
+ const summaryInput = screen.getByLabelText('角色摘要');
+ await user.clear(summaryInput);
+ await user.type(summaryInput, '他像旧友,也像最早知道航道秘密的人。');
+ await user.click(screen.getByRole('button', { name: '保存' }));
+
+ expect(screen.getByTestId('saved-payload').textContent).toContain(
+ '他像旧友,也像最早知道航道秘密的人。',
+ );
+
+ await user.click(screen.getByRole('button', { name: '新增角色' }));
+ expect(screen.getByRole('button', { name: '生成角色' })).toBeTruthy();
+ expect(screen.getByText('当前参考卡')).toBeTruthy();
+ const closeButtons = screen.getAllByRole('button', { name: '关闭' });
+ await user.click(closeButtons[closeButtons.length - 1]!);
+
+ expect(screen.getByRole('button', { name: '角色资产' })).toBeTruthy();
+
+ await user.click(screen.getByRole('button', { name: '新增场景' }));
+ expect(screen.getByRole('button', { name: '生成场景' })).toBeTruthy();
+});
diff --git a/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.test.tsx b/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.test.tsx
new file mode 100644
index 00000000..f770ee26
--- /dev/null
+++ b/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.test.tsx
@@ -0,0 +1,45 @@
+import { renderToStaticMarkup } from 'react-dom/server';
+import { expect, test } from 'vitest';
+
+import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
+
+test('draft detail panel renders sections and warnings', () => {
+ const html = renderToStaticMarkup(
+ {}}
+ onStartEdit={() => {}}
+ onGenerateCharacter={() => {}}
+ onGenerateLandmark={() => {}}
+ />,
+ );
+
+ expect(html).toContain('谁掌握航道解释权');
+ expect(html).toContain('线程类型');
+ expect(html).toContain('守灯会与沉船商盟');
+ expect(html).toContain('继续精修');
+ expect(html).toContain('编辑设定');
+ expect(html).toContain('新增角色');
+});
diff --git a/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx b/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx
new file mode 100644
index 00000000..d22de26d
--- /dev/null
+++ b/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx
@@ -0,0 +1,204 @@
+import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
+import { CustomWorldDraftEditPanel } from './CustomWorldDraftEditPanel';
+
+type CustomWorldAgentDraftDetailPanelProps = {
+ detail: CustomWorldDraftCardDetail | null;
+ loading: boolean;
+ busy?: boolean;
+ editMode?: boolean;
+ onClose: () => void;
+ onStartEdit?: () => void;
+ onCancelEdit?: () => void;
+ onSave?: (
+ sections: Array<{
+ sectionId: string;
+ value: string;
+ }>,
+ ) => void;
+ onGenerateCharacter?: () => void;
+ onGenerateLandmark?: () => void;
+ onOpenRoleAssetStudio?: () => void;
+};
+
+function resolveKindLabel(kind: CustomWorldDraftCardDetail['kind']) {
+ if (kind === 'world') return '世界总卡';
+ if (kind === 'camp') return '营地';
+ if (kind === 'faction') return '势力';
+ if (kind === 'character') return '角色';
+ if (kind === 'landmark') return '地点';
+ if (kind === 'thread') return '线程';
+ if (kind === 'chapter') return '第一幕';
+ return '草稿卡';
+}
+
+function ActionButton(props: {
+ label: string;
+ onClick?: () => void;
+ disabled?: boolean;
+ tone?: 'default' | 'sky';
+}) {
+ const { label, onClick, disabled = false, tone = 'default' } = props;
+
+ if (!onClick) {
+ return null;
+ }
+
+ return (
+
+ );
+}
+
+export function CustomWorldAgentDraftDetailPanel({
+ detail,
+ loading,
+ busy = false,
+ editMode = false,
+ onClose,
+ onStartEdit,
+ onCancelEdit,
+ onSave,
+ onGenerateCharacter,
+ onGenerateLandmark,
+ onOpenRoleAssetStudio,
+}: CustomWorldAgentDraftDetailPanelProps) {
+ return (
+
+
+
+
+ 卡片详情
+
+
+ {loading ? '正在读取' : detail?.title || '选择一张草稿卡'}
+
+
+
+
+
+ {loading ? (
+
+ 正在整理这张卡的内容。
+
+ ) : detail ? (
+
+
+
+ {resolveKindLabel(detail.kind)}
+
+
+ 关联 {detail.linkedIds.length}
+
+ {detail.editable ? (
+
+ 可编辑
+
+ ) : null}
+ {detail.kind === 'character' && detail.assetStatusLabel ? (
+
+ {detail.assetStatusLabel}
+
+ ) : null}
+
+
+
+ {!editMode && detail.editable ? (
+
+ ) : null}
+ {!editMode && detail.kind === 'character' ? (
+
+ ) : null}
+ {!editMode ? (
+ <>
+
+
+ >
+ ) : null}
+
+
+ {editMode && onSave && onCancelEdit ? (
+
+ ) : (
+
+ {detail.sections.map((section) => (
+
+
+ {section.label}
+
+
+ {section.value}
+
+
+ ))}
+
+ )}
+
+ {detail.warningMessages.length > 0 ? (
+
+
+ 继续精修
+
+
+ {detail.warningMessages.map((message, index) => (
+
+ {message}
+
+ ))}
+
+
+ ) : null}
+
+ ) : (
+
+ 从草稿抽屉里点开一张卡,就能在这里看世界底稿的具体内容。
+
+ )}
+
+ );
+}
diff --git a/src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx b/src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx
new file mode 100644
index 00000000..972fa231
--- /dev/null
+++ b/src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx
@@ -0,0 +1,115 @@
+import type { CustomWorldDraftCardSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
+
+type CustomWorldAgentDraftDrawerProps = {
+ draftCards: CustomWorldDraftCardSummary[];
+ activeCardId?: string | null;
+ onSelectCard: (cardId: string) => void;
+};
+
+const DRAWER_KIND_ORDER: CustomWorldDraftCardSummary['kind'][] = [
+ 'world',
+ 'chapter',
+ 'thread',
+ 'faction',
+ 'character',
+ 'landmark',
+ 'camp',
+];
+
+function resolveGroupLabel(kind: CustomWorldDraftCardSummary['kind']) {
+ if (kind === 'world') return '世界总卡';
+ if (kind === 'chapter') return '第一幕';
+ if (kind === 'thread') return '世界线程';
+ if (kind === 'faction') return '势力';
+ if (kind === 'character') return '关键角色';
+ if (kind === 'landmark') return '关键地点';
+ if (kind === 'camp') return '营地';
+ return '草稿卡';
+}
+
+export function CustomWorldAgentDraftDrawer({
+ draftCards,
+ activeCardId,
+ onSelectCard,
+}: CustomWorldAgentDraftDrawerProps) {
+ const groupedCards = DRAWER_KIND_ORDER.map((kind) => ({
+ kind,
+ items: draftCards.filter((card) => card.kind === kind),
+ })).filter((group) => group.items.length > 0);
+
+ return (
+
+
+ 草稿抽屉
+
+ {groupedCards.length > 0 ? (
+
+ {groupedCards.map((group) => (
+
+
+
+ {resolveGroupLabel(group.kind)}
+
+
+ {group.items.length}
+
+
+
+ {group.items.map((card) => {
+ const isActive = activeCardId === card.id;
+
+ return (
+
+ );
+ })}
+
+
+ ))}
+
+ ) : (
+
+ 最小锚点齐备后,世界底稿会先从这里长出来。
+
+ )}
+
+ );
+}
diff --git a/src/components/custom-world-agent/CustomWorldAgentHeader.tsx b/src/components/custom-world-agent/CustomWorldAgentHeader.tsx
new file mode 100644
index 00000000..1c420107
--- /dev/null
+++ b/src/components/custom-world-agent/CustomWorldAgentHeader.tsx
@@ -0,0 +1,18 @@
+type CustomWorldAgentHeaderProps = {
+ onBack: () => void;
+};
+
+export function CustomWorldAgentHeader({ onBack }: CustomWorldAgentHeaderProps) {
+ return (
+
+ );
+}
diff --git a/src/components/custom-world-agent/CustomWorldAgentIntentSummaryPanel.test.tsx b/src/components/custom-world-agent/CustomWorldAgentIntentSummaryPanel.test.tsx
new file mode 100644
index 00000000..3874954d
--- /dev/null
+++ b/src/components/custom-world-agent/CustomWorldAgentIntentSummaryPanel.test.tsx
@@ -0,0 +1,40 @@
+import { renderToStaticMarkup } from 'react-dom/server';
+import { expect, test } from 'vitest';
+
+import { CustomWorldAgentIntentSummaryPanel } from './CustomWorldAgentIntentSummaryPanel';
+
+test('intent summary panel shows collected custom world anchors', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ expect(html).toContain('已收集锚点');
+ expect(html).toContain('世界一句话');
+ expect(html).toContain('一个被潮雾切开的列岛世界');
+ expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
+ expect(html).toContain('5/6');
+});
diff --git a/src/components/custom-world-agent/CustomWorldAgentIntentSummaryPanel.tsx b/src/components/custom-world-agent/CustomWorldAgentIntentSummaryPanel.tsx
new file mode 100644
index 00000000..31094d05
--- /dev/null
+++ b/src/components/custom-world-agent/CustomWorldAgentIntentSummaryPanel.tsx
@@ -0,0 +1,99 @@
+import type { CreatorIntentReadiness } from '../../../packages/shared/src/contracts/customWorldAgent';
+import {
+ evaluateCustomWorldCreatorIntentReadiness,
+ hasMeaningfulCustomWorldCreatorIntent,
+ normalizeCustomWorldCreatorIntent,
+} from '../../services/customWorldCreatorIntent';
+
+type CustomWorldAgentIntentSummaryPanelProps = {
+ creatorIntent: Record | null;
+ readiness: CreatorIntentReadiness;
+};
+
+export function CustomWorldAgentIntentSummaryPanel({
+ creatorIntent,
+ readiness,
+}: CustomWorldAgentIntentSummaryPanelProps) {
+ const intent = normalizeCustomWorldCreatorIntent(creatorIntent);
+ const resolvedReadiness =
+ readiness ?? evaluateCustomWorldCreatorIntentReadiness(intent);
+ const items = [
+ {
+ label: '世界一句话',
+ value: intent?.worldHook || '',
+ ready: resolvedReadiness.completedKeys.includes('world_hook'),
+ },
+ {
+ label: '玩家身份',
+ value: intent?.playerPremise || '',
+ ready: Boolean(intent?.playerPremise),
+ },
+ {
+ label: '开局处境',
+ value: intent?.openingSituation || '',
+ ready: Boolean(intent?.openingSituation),
+ },
+ {
+ label: '核心冲突',
+ value: intent?.coreConflicts.join('、') || '',
+ ready: resolvedReadiness.completedKeys.includes('core_conflict'),
+ },
+ {
+ label: '主题气质',
+ value:
+ [...(intent?.themeKeywords ?? []), ...(intent?.toneDirectives ?? [])]
+ .filter(Boolean)
+ .join('、') || '',
+ ready: resolvedReadiness.completedKeys.includes('theme_and_tone'),
+ },
+ {
+ label: '标志性要素',
+ value: intent?.iconicElements.join('、') || '',
+ ready: resolvedReadiness.completedKeys.includes('iconic_element'),
+ },
+ ];
+
+ return (
+
+
+
+
+ 已收集锚点
+
+
+ {resolvedReadiness.isReady ? '创作输入已齐备' : '继续收世界骨架'}
+
+
+
+ {resolvedReadiness.completedKeys.length}/6
+
+
+
+ {hasMeaningfulCustomWorldCreatorIntent(intent) ? (
+
+ {items.map((item) => (
+
+
+ {item.label}
+
+
+ {item.value || '待补充'}
+
+
+ ))}
+
+ ) : (
+
+ 还在收集你的世界锚点
+
+ )}
+
+ );
+}
diff --git a/src/components/custom-world-agent/CustomWorldAgentLauncherModal.tsx b/src/components/custom-world-agent/CustomWorldAgentLauncherModal.tsx
new file mode 100644
index 00000000..fcf80cf1
--- /dev/null
+++ b/src/components/custom-world-agent/CustomWorldAgentLauncherModal.tsx
@@ -0,0 +1,90 @@
+import { X } from 'lucide-react';
+
+type CustomWorldAgentLauncherModalProps = {
+ isOpen: boolean;
+ seedText: string;
+ isBusy: boolean;
+ error: string | null;
+ onClose: () => void;
+ onSeedTextChange: (value: string) => void;
+ onConfirm: () => void;
+};
+
+export function CustomWorldAgentLauncherModal({
+ isOpen,
+ seedText,
+ isBusy,
+ error,
+ onClose,
+ onSeedTextChange,
+ onConfirm,
+}: CustomWorldAgentLauncherModalProps) {
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ 开始和 Agent 共创
+
+
+ 输入一段种子灵感,先进入新的工作区。
+
+
+
+
+
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/custom-world-agent/CustomWorldAgentLockBar.tsx b/src/components/custom-world-agent/CustomWorldAgentLockBar.tsx
new file mode 100644
index 00000000..7ebf1e25
--- /dev/null
+++ b/src/components/custom-world-agent/CustomWorldAgentLockBar.tsx
@@ -0,0 +1,49 @@
+type CustomWorldAgentLockBarProps = {
+ lockState: Record | null;
+};
+
+function readLockedItems(lockState: Record | null) {
+ if (!lockState) {
+ return [];
+ }
+
+ return Object.entries(lockState)
+ .flatMap(([key, value]) =>
+ Array.isArray(value)
+ ? value.map((item) => `${key}:${String(item)}`)
+ : typeof value === 'string' && value.trim()
+ ? [`${key}:${value.trim()}`]
+ : [],
+ )
+ .slice(0, 8);
+}
+
+export function CustomWorldAgentLockBar({
+ lockState,
+}: CustomWorldAgentLockBarProps) {
+ const lockedItems = readLockedItems(lockState);
+
+ return (
+
+
+ 锁定内容
+
+ {lockedItems.length > 0 ? (
+
+ {lockedItems.map((item) => (
+
+ {item}
+
+ ))}
+
+ ) : (
+
+ 暂未锁定内容
+
+ )}
+
+ );
+}
diff --git a/src/components/custom-world-agent/CustomWorldAgentOperationBanner.tsx b/src/components/custom-world-agent/CustomWorldAgentOperationBanner.tsx
new file mode 100644
index 00000000..9f24ec90
--- /dev/null
+++ b/src/components/custom-world-agent/CustomWorldAgentOperationBanner.tsx
@@ -0,0 +1,74 @@
+import { useEffect, useState } from 'react';
+
+import type { CustomWorldAgentOperationRecord } from '../../../packages/shared/src/contracts/customWorldAgent';
+
+type CustomWorldAgentOperationBannerProps = {
+ operation: CustomWorldAgentOperationRecord | null;
+};
+
+export function CustomWorldAgentOperationBanner({
+ operation,
+}: CustomWorldAgentOperationBannerProps) {
+ const [visibleOperation, setVisibleOperation] =
+ useState(operation);
+
+ useEffect(() => {
+ setVisibleOperation(operation);
+
+ if (operation?.status !== 'completed') {
+ return;
+ }
+
+ const timeoutId = window.setTimeout(() => {
+ setVisibleOperation((current) =>
+ current?.operationId === operation.operationId ? null : current,
+ );
+ }, 1200);
+
+ return () => window.clearTimeout(timeoutId);
+ }, [operation]);
+
+ if (!visibleOperation) {
+ return null;
+ }
+
+ const isFailed = visibleOperation.status === 'failed';
+ const isRunning =
+ visibleOperation.status === 'running' || visibleOperation.status === 'queued';
+
+ return (
+
+
+
+ {visibleOperation.phaseLabel}
+
+
+ {Math.max(0, Math.min(100, Math.round(visibleOperation.progress)))}%
+
+
+ {visibleOperation.error ? (
+
+ {visibleOperation.error}
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/custom-world-agent/CustomWorldAgentQuickActions.tsx b/src/components/custom-world-agent/CustomWorldAgentQuickActions.tsx
new file mode 100644
index 00000000..b67c61a3
--- /dev/null
+++ b/src/components/custom-world-agent/CustomWorldAgentQuickActions.tsx
@@ -0,0 +1,128 @@
+import type { CustomWorldSuggestedAction } from '../../../packages/shared/src/contracts/customWorldAgent';
+
+type CustomWorldAgentQuickActionsProps = {
+ suggestedActions: CustomWorldSuggestedAction[];
+ disabled: boolean;
+ canDraftFoundation: boolean;
+ showEntityActions?: boolean;
+ onRequestSummary: () => void;
+ onDraftFoundation: () => void;
+ onGenerateCharacter?: () => void;
+ onGenerateLandmark?: () => void;
+ onGenerateRoleAssets?: () => void;
+ showRoleAssetAction?: boolean;
+ onFocusSuggestedAction: (action?: CustomWorldSuggestedAction) => void;
+};
+
+function QuickActionButton(props: {
+ label: string;
+ onClick: () => void;
+ disabled: boolean;
+ tone?: 'default' | 'sky' | 'amber';
+}) {
+ const { label, onClick, disabled, tone = 'default' } = props;
+
+ return (
+
+ );
+}
+
+export function CustomWorldAgentQuickActions({
+ suggestedActions,
+ disabled,
+ canDraftFoundation,
+ showEntityActions = false,
+ onRequestSummary,
+ onDraftFoundation,
+ onGenerateCharacter,
+ onGenerateLandmark,
+ onGenerateRoleAssets,
+ showRoleAssetAction = false,
+ onFocusSuggestedAction,
+}: CustomWorldAgentQuickActionsProps) {
+ const summaryAction = suggestedActions.find(
+ (action) => action.type === 'request_summary',
+ );
+ const draftAction = suggestedActions.find(
+ (action) => action.type === 'draft_foundation',
+ );
+ const refinementActions = suggestedActions.filter(
+ (action) =>
+ action.type !== 'request_summary' && action.type !== 'draft_foundation',
+ );
+
+ return (
+
+
+ 快捷动作
+
+
+
+ {draftAction && canDraftFoundation ? (
+
+ ) : null}
+ {showEntityActions && onGenerateCharacter ? (
+
+ ) : null}
+ {showEntityActions && onGenerateLandmark ? (
+
+ ) : null}
+ {showRoleAssetAction && onGenerateRoleAssets ? (
+
+ ) : null}
+ {refinementActions.length > 0 ? (
+ refinementActions.slice(0, 2).map((action) => (
+ onFocusSuggestedAction(action)}
+ disabled={disabled}
+ />
+ ))
+ ) : !draftAction || !canDraftFoundation ? (
+ onFocusSuggestedAction()}
+ disabled={disabled}
+ />
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/custom-world-agent/CustomWorldAgentSummaryPanel.tsx b/src/components/custom-world-agent/CustomWorldAgentSummaryPanel.tsx
new file mode 100644
index 00000000..14b3549c
--- /dev/null
+++ b/src/components/custom-world-agent/CustomWorldAgentSummaryPanel.tsx
@@ -0,0 +1,58 @@
+import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
+
+type CustomWorldAgentSummaryPanelProps = {
+ session: CustomWorldAgentSessionSnapshot;
+};
+
+function readSummaryText(
+ draftProfile: Record | null,
+ fallback: string,
+) {
+ const title =
+ typeof draftProfile?.title === 'string' ? draftProfile.title.trim() : '';
+ const summary =
+ typeof draftProfile?.summary === 'string'
+ ? draftProfile.summary.trim()
+ : '';
+
+ return {
+ title: title || '世界摘要待整理',
+ summary: summary || fallback,
+ };
+}
+
+export function CustomWorldAgentSummaryPanel({
+ session,
+}: CustomWorldAgentSummaryPanelProps) {
+ const pendingCount = session.pendingClarifications.length;
+ const { title, summary } = readSummaryText(
+ session.draftProfile,
+ '第一阶段先收住世界锚点,后续阶段再把这里整理成更完整的世界底稿摘要。',
+ );
+
+ return (
+
+
+
+
+ 顶部摘要
+
+
+ {title}
+
+
+
+
+ 消息 {session.messages.length}
+
+
+ 待澄清 {pendingCount}
+
+
+
+
+ {summary}
+
+
+ );
+}
diff --git a/src/components/custom-world-agent/CustomWorldAgentThread.tsx b/src/components/custom-world-agent/CustomWorldAgentThread.tsx
new file mode 100644
index 00000000..99d213ce
--- /dev/null
+++ b/src/components/custom-world-agent/CustomWorldAgentThread.tsx
@@ -0,0 +1,97 @@
+import { useEffect, useRef } from 'react';
+
+import type { CustomWorldAgentMessage } from '../../../packages/shared/src/contracts/customWorldAgent';
+
+type CustomWorldAgentThreadProps = {
+ messages: CustomWorldAgentMessage[];
+ recommendedReplies?: string[];
+ onRecommendedReply?: (text: string) => void;
+};
+
+function formatMessageTime(value: string) {
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return '';
+ }
+
+ return new Intl.DateTimeFormat('zh-CN', {
+ hour: '2-digit',
+ minute: '2-digit',
+ }).format(date);
+}
+
+export function CustomWorldAgentThread({
+ messages,
+ recommendedReplies = [],
+ onRecommendedReply,
+}: CustomWorldAgentThreadProps) {
+ const bottomRef = useRef(null);
+ const lastAssistantMessageId = [...messages]
+ .reverse()
+ .find((message) => message.role === 'assistant')?.id;
+
+ useEffect(() => {
+ bottomRef.current?.scrollIntoView({
+ behavior: 'smooth',
+ block: 'end',
+ });
+ }, [messages]);
+
+ return (
+
+ {messages.length === 0 ? (
+
+ 暂无消息
+
+ ) : (
+
+ {messages.map((message) => {
+ const isUser = message.role === 'user';
+ const isSystem = message.role === 'system';
+
+ return (
+
+
+
{message.text}
+
+ {formatMessageTime(message.createdAt)}
+
+ {!isUser &&
+ message.id === lastAssistantMessageId &&
+ recommendedReplies.length > 0 ? (
+
+ {recommendedReplies.slice(0, 3).map((reply) => (
+
+ ))}
+
+ ) : null}
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/custom-world-agent/CustomWorldAgentWorkspace.interaction.test.tsx b/src/components/custom-world-agent/CustomWorldAgentWorkspace.interaction.test.tsx
new file mode 100644
index 00000000..8967a6c4
--- /dev/null
+++ b/src/components/custom-world-agent/CustomWorldAgentWorkspace.interaction.test.tsx
@@ -0,0 +1,471 @@
+/* @vitest-environment jsdom */
+
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeEach, expect, test, vi } from 'vitest';
+
+import type {
+ CustomWorldAgentSessionSnapshot,
+ CustomWorldDraftCardDetail,
+} from '../../../packages/shared/src/contracts/customWorldAgent';
+import { getCustomWorldAgentCardDetail } from '../../services/aiService';
+import { CustomWorldAgentWorkspace } from './CustomWorldAgentWorkspace';
+
+vi.mock('../../services/aiService', () => ({
+ getCustomWorldAgentCardDetail: vi.fn(),
+}));
+
+vi.mock('../CustomWorldRoleAssetStudioModal', () => ({
+ CustomWorldRoleAssetStudioModal: ({
+ role,
+ onPublishSuccess,
+ }: {
+ role: { name: string };
+ onPublishSuccess?: (
+ payload: {
+ roleId: string;
+ portraitPath: string;
+ generatedVisualAssetId: string;
+ generatedAnimationSetId?: string | null;
+ animationMap?: Record | null;
+ },
+ options?: { closeAfterSync?: boolean },
+ ) => void;
+ }) => (
+
+
角色资产工坊:{role.name}
+
+
+ ),
+}));
+
+const detailById: Record = {
+ 'world-foundation': {
+ id: 'world-foundation',
+ kind: 'world',
+ title: '潮雾列岛',
+ sections: [
+ {
+ id: 'title',
+ label: '标题',
+ value: '潮雾列岛',
+ },
+ {
+ id: 'summary',
+ label: '摘要',
+ value: '这是第一版世界底稿。',
+ },
+ ],
+ linkedIds: ['thread-1', 'character-1'],
+ locked: false,
+ editable: true,
+ editableSectionIds: ['title', 'summary'],
+ warningMessages: [],
+ },
+ 'character-1': {
+ id: 'character-1',
+ kind: 'character',
+ title: '沈砺',
+ sections: [
+ {
+ id: 'name',
+ label: '角色名',
+ value: '沈砺',
+ },
+ {
+ id: 'summary',
+ label: '角色摘要',
+ value: '他像旧友,但也像一把始终没收回鞘的刀。',
+ },
+ ],
+ linkedIds: ['thread-1'],
+ locked: false,
+ editable: true,
+ editableSectionIds: ['name', 'summary'],
+ warningMessages: [],
+ assetStatus: 'missing',
+ assetStatusLabel: '待生成主图',
+ },
+ 'character-2': {
+ id: 'character-2',
+ kind: 'character',
+ title: '顾潮音',
+ sections: [
+ {
+ id: 'name',
+ label: '角色名',
+ value: '顾潮音',
+ },
+ {
+ id: 'summary',
+ label: '角色摘要',
+ value: '她总像比所有人更早知道海雾会往哪一侧压下来。',
+ },
+ ],
+ linkedIds: ['thread-1'],
+ locked: false,
+ editable: true,
+ editableSectionIds: ['name', 'summary'],
+ warningMessages: [],
+ assetStatus: 'missing',
+ assetStatusLabel: '待生成主图',
+ },
+};
+
+const baseSession: CustomWorldAgentSessionSnapshot = {
+ sessionId: 'custom-world-agent-session-1',
+ stage: 'object_refining',
+ focusCardId: 'world-foundation',
+ creatorIntent: {},
+ creatorIntentReadiness: {
+ isReady: true,
+ completedKeys: [
+ 'world_hook',
+ 'player_premise',
+ 'theme_and_tone',
+ 'core_conflict',
+ 'relationship_seed',
+ 'iconic_element',
+ ],
+ missingKeys: [],
+ },
+ anchorPack: {},
+ lockState: {},
+ draftProfile: {
+ name: '潮雾列岛',
+ storyNpcs: [
+ {
+ id: 'character-1',
+ name: '沈砺',
+ title: '守灯会旧友',
+ role: '航道向导',
+ publicMask: '守灯会里最熟悉旧航道的人。',
+ hiddenHook: '暗地里正在为沉船商盟引路。',
+ relationToPlayer: '旧友兼宿敌',
+ threadIds: ['thread-1'],
+ summary: '他像旧友,但也像一把始终没收回鞘的刀。',
+ },
+ ],
+ },
+ messages: [
+ {
+ id: 'message-1',
+ role: 'assistant',
+ kind: 'summary',
+ text: '当前底稿已经可以继续精修。',
+ createdAt: new Date().toISOString(),
+ relatedOperationId: null,
+ },
+ ],
+ draftCards: [
+ {
+ id: 'world-foundation',
+ kind: 'world',
+ title: '潮雾列岛',
+ subtitle: '旧灯塔与航道争夺',
+ summary: '世界总卡已经生成。',
+ status: 'warning',
+ linkedIds: ['thread-1', 'character-1'],
+ warningCount: 1,
+ },
+ {
+ id: 'character-1',
+ kind: 'character',
+ title: '沈砺',
+ subtitle: '守灯会旧友',
+ summary: '他最了解旧航道,也最可能先背叛。',
+ status: 'suggested',
+ linkedIds: ['thread-1'],
+ warningCount: 0,
+ },
+ ],
+ pendingClarifications: [],
+ suggestedActions: [
+ {
+ id: 'request-summary',
+ type: 'request_summary',
+ label: '总结当前世界底稿',
+ targetId: null,
+ },
+ ],
+ recommendedReplies: ['现在开始生成草稿', '先总结一下当前设定', '我还想再补充一点'],
+ qualityFindings: [],
+ assetCoverage: {
+ roleAssets: [
+ {
+ roleId: 'character-1',
+ roleName: '沈砺',
+ roleKind: 'story',
+ priorityTier: 'featured',
+ portraitPath: null,
+ generatedVisualAssetId: null,
+ generatedAnimationSetId: null,
+ status: 'missing',
+ missingAnimations: ['idle', 'run', 'attack', 'hurt', 'die'],
+ nextPointCost: 20,
+ },
+ ],
+ sceneAssets: [],
+ allRoleAssetsReady: false,
+ allSceneAssetsReady: false,
+ },
+ updatedAt: '2026-04-14T10:00:00.000Z',
+};
+
+beforeEach(() => {
+ vi.mocked(getCustomWorldAgentCardDetail).mockImplementation(
+ async (_sessionId, cardId): Promise =>
+ detailById[cardId] ?? detailById['world-foundation']!,
+ );
+ if (!Element.prototype.scrollIntoView) {
+ Element.prototype.scrollIntoView = () => {};
+ }
+});
+
+test('workspace loads detail, saves edits, opens generate actions, and reflects updated drawer cards', async () => {
+ const user = userEvent.setup();
+ const onExecuteAction = vi.fn();
+
+ const { rerender } = render(
+ {}}
+ onRefresh={() => {}}
+ onSubmitMessage={() => {}}
+ onExecuteAction={onExecuteAction}
+ />,
+ );
+
+ await waitFor(() => {
+ expect(getCustomWorldAgentCardDetail).toHaveBeenCalledWith(
+ baseSession.sessionId,
+ 'world-foundation',
+ );
+ });
+
+ await user.click(screen.getByRole('button', { name: '编辑设定' }));
+ const summaryInput = screen.getByLabelText('摘要');
+ await user.clear(summaryInput);
+ await user.type(summaryInput, '这是更新后的世界摘要。');
+ await user.click(screen.getByRole('button', { name: '保存' }));
+
+ expect(onExecuteAction).toHaveBeenCalledWith({
+ action: 'update_draft_card',
+ cardId: 'world-foundation',
+ sections: [
+ {
+ sectionId: 'title',
+ value: '潮雾列岛',
+ },
+ {
+ sectionId: 'summary',
+ value: '这是更新后的世界摘要。',
+ },
+ ],
+ });
+
+ await user.click(screen.getByRole('button', { name: /沈砺/u }));
+ await waitFor(() => {
+ expect(getCustomWorldAgentCardDetail).toHaveBeenLastCalledWith(
+ baseSession.sessionId,
+ 'character-1',
+ );
+ });
+
+ const [generateCharacterButton] = screen.getAllByRole('button', { name: '新增角色' });
+ await user.click(generateCharacterButton!);
+ expect(screen.getByRole('button', { name: '生成角色' })).toBeTruthy();
+ await user.click(screen.getByRole('button', { name: '生成角色' }));
+
+ expect(onExecuteAction).toHaveBeenCalledWith({
+ action: 'generate_characters',
+ count: 2,
+ promptText: null,
+ anchorCardIds: ['character-1'],
+ });
+
+ const [generateLandmarkButton] = screen.getAllByRole('button', { name: '新增场景' });
+ await user.click(generateLandmarkButton!);
+ expect(screen.getByRole('button', { name: '生成场景' })).toBeTruthy();
+ await user.click(screen.getByRole('button', { name: '生成场景' }));
+
+ expect(onExecuteAction).toHaveBeenCalledWith({
+ action: 'generate_landmarks',
+ count: 2,
+ promptText: null,
+ anchorCardIds: ['character-1'],
+ });
+
+ const [openRoleAssetsButton] = screen.getAllByRole('button', {
+ name: '角色资产',
+ });
+ await user.click(openRoleAssetsButton!);
+ expect(onExecuteAction).toHaveBeenCalledWith({
+ action: 'generate_role_assets',
+ roleIds: ['character-1'],
+ });
+
+ rerender(
+ {}}
+ onRefresh={() => {}}
+ onSubmitMessage={() => {}}
+ onExecuteAction={onExecuteAction}
+ />,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('顾潮音')).toBeTruthy();
+ });
+ expect(screen.getByText('角色资产工坊:沈砺')).toBeTruthy();
+
+ await user.click(screen.getByRole('button', { name: '模拟同步角色资产' }));
+ expect(onExecuteAction).toHaveBeenCalledWith({
+ action: 'sync_role_assets',
+ roleId: 'character-1',
+ portraitPath: '/generated/character-1.png',
+ generatedVisualAssetId: 'visual-character-1',
+ generatedAnimationSetId: 'animation-set-character-1',
+ animationMap: {
+ idle: { basePath: '/generated/character-1/idle' },
+ run: { basePath: '/generated/character-1/run' },
+ attack: { basePath: '/generated/character-1/attack' },
+ hurt: { basePath: '/generated/character-1/hurt' },
+ die: { basePath: '/generated/character-1/die' },
+ },
+ });
+
+ rerender(
+ {}}
+ onRefresh={() => {}}
+ onSubmitMessage={() => {}}
+ onExecuteAction={onExecuteAction}
+ />,
+ );
+
+ await waitFor(() => {
+ expect(screen.getAllByText('动作已就绪').length).toBeGreaterThan(0);
+ });
+});
diff --git a/src/components/custom-world-agent/CustomWorldAgentWorkspace.test.tsx b/src/components/custom-world-agent/CustomWorldAgentWorkspace.test.tsx
new file mode 100644
index 00000000..39119347
--- /dev/null
+++ b/src/components/custom-world-agent/CustomWorldAgentWorkspace.test.tsx
@@ -0,0 +1,96 @@
+import { renderToStaticMarkup } from 'react-dom/server';
+import { expect, test } from 'vitest';
+
+import { CustomWorldAgentWorkspace } from './CustomWorldAgentWorkspace';
+
+test('custom world agent workspace renders progress labels, action button and recommended replies', () => {
+ const html = renderToStaticMarkup(
+ {}}
+ onRefresh={() => {}}
+ onSubmitMessage={() => {}}
+ onExecuteAction={() => {}}
+ />,
+ );
+
+ expect(html).toContain('首轮草稿会先确认这 6 项信息');
+ expect(html).toContain('世界核心');
+ expect(html).toContain('玩家开局');
+ expect(html).toContain('现在开始生成草稿');
+ expect(html).toContain('开始生成草稿');
+ expect(html).toContain('欢迎。当前底稿已经可以继续精修。');
+});
diff --git a/src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx b/src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx
new file mode 100644
index 00000000..68ee83bd
--- /dev/null
+++ b/src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx
@@ -0,0 +1,702 @@
+import { useEffect, useState } from 'react';
+
+import type {
+ CustomWorldAgentActionRequest,
+ CustomWorldAgentOperationRecord,
+ CustomWorldAgentSessionSnapshot,
+ CustomWorldDraftCardDetail,
+ SendCustomWorldAgentMessageRequest,
+} from '../../../packages/shared/src/contracts/customWorldAgent';
+import { CustomWorldRoleAssetStudioModal } from '../CustomWorldRoleAssetStudioModal';
+import { getCustomWorldAgentCardDetail } from '../../services/aiService';
+import { CustomWorldAgentComposer } from './CustomWorldAgentComposer';
+import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
+import { CustomWorldAgentDraftDrawer } from './CustomWorldAgentDraftDrawer';
+import { CustomWorldAgentHeader } from './CustomWorldAgentHeader';
+import { CustomWorldAgentOperationBanner } from './CustomWorldAgentOperationBanner';
+import { CustomWorldAgentQuickActions } from './CustomWorldAgentQuickActions';
+import { CustomWorldAgentThread } from './CustomWorldAgentThread';
+import { CustomWorldDraftCardDetailModal } from './CustomWorldDraftCardDetailModal';
+import { CustomWorldGenerateEntityModal } from './CustomWorldGenerateEntityModal';
+
+type WorkspaceRoleAssetTarget = {
+ id: string;
+ name: string;
+ title: string;
+ role: string;
+ description?: string;
+ backstory?: string;
+ personality?: string;
+ motivation?: string;
+ combatStyle?: string;
+ tags?: string[];
+ imageSrc?: string;
+ generatedVisualAssetId?: string;
+ generatedAnimationSetId?: string;
+ animationMap?: Record;
+};
+
+type CustomWorldAgentWorkspaceProps = {
+ session: CustomWorldAgentSessionSnapshot | null;
+ activeOperation: CustomWorldAgentOperationRecord | null;
+ onBack: () => void;
+ onRefresh: () => void;
+ onSubmitMessage: (payload: SendCustomWorldAgentMessageRequest) => void;
+ onExecuteAction: (payload: CustomWorldAgentActionRequest) => void;
+};
+
+const TOTAL_READINESS_STEPS = 6;
+const READINESS_ITEMS = [
+ { key: 'world_hook', label: '世界核心' },
+ { key: 'player_premise', label: '玩家开局' },
+ { key: 'theme_and_tone', label: '主题气质' },
+ { key: 'core_conflict', label: '核心冲突' },
+ { key: 'relationship_seed', label: '关键关系' },
+ { key: 'iconic_element', label: '标志元素' },
+] as const;
+
+function createClientMessageId() {
+ if (
+ typeof crypto !== 'undefined' &&
+ typeof crypto.randomUUID === 'function'
+ ) {
+ return crypto.randomUUID();
+ }
+
+ return `client-message-${Date.now()}`;
+}
+
+function resolveInitialCardId(session: CustomWorldAgentSessionSnapshot | null) {
+ if (!session || session.draftCards.length === 0) {
+ return null;
+ }
+
+ return (
+ session.focusCardId ||
+ session.draftCards.find((card) => card.kind === 'world')?.id ||
+ session.draftCards[0]?.id ||
+ null
+ );
+}
+
+function buildRecommendedReplies(session: CustomWorldAgentSessionSnapshot) {
+ return session.recommendedReplies;
+}
+
+function toRecord(value: unknown) {
+ return value && typeof value === 'object' && !Array.isArray(value)
+ ? (value as Record)
+ : null;
+}
+
+function toRecordArray(value: unknown) {
+ return Array.isArray(value)
+ ? value.filter(
+ (item): item is Record =>
+ Boolean(item) && typeof item === 'object' && !Array.isArray(item),
+ )
+ : [];
+}
+
+function toText(value: unknown) {
+ return typeof value === 'string' ? value.trim() : '';
+}
+
+function resolveRoleAssetTarget(
+ session: CustomWorldAgentSessionSnapshot | null,
+ roleId: string | null,
+) {
+ if (!session || !roleId) {
+ return null;
+ }
+
+ const draftProfile = toRecord(session.draftProfile);
+ if (!draftProfile) {
+ return null;
+ }
+
+ const playableRole = toRecordArray(draftProfile.playableNpcs).find(
+ (item) => toText(item.id) === roleId,
+ );
+ const storyRole = toRecordArray(draftProfile.storyNpcs).find(
+ (item) => toText(item.id) === roleId,
+ );
+ const role = playableRole ?? storyRole;
+ if (!role) {
+ return null;
+ }
+
+ const assetSummary =
+ session.assetCoverage.roleAssets.find((entry) => entry.roleId === roleId) ??
+ null;
+
+ return {
+ role: {
+ id: roleId,
+ name: toText(role.name) || '未命名角色',
+ title: toText(role.title) || toText(role.role) || '关键角色',
+ role: toText(role.role) || toText(role.title) || '关键角色',
+ description: toText(role.summary),
+ backstory: toText(role.hiddenHook) || undefined,
+ personality: toText(role.publicMask) || undefined,
+ motivation: toText(role.relationToPlayer) || undefined,
+ combatStyle: toText(role.role) || undefined,
+ tags: Array.isArray(role.threadIds)
+ ? role.threadIds
+ .map((item) => toText(item))
+ .filter(Boolean)
+ .slice(0, 4)
+ : [],
+ imageSrc: toText(role.imageSrc) || undefined,
+ generatedVisualAssetId: toText(role.generatedVisualAssetId) || undefined,
+ generatedAnimationSetId: toText(role.generatedAnimationSetId) || undefined,
+ animationMap: toRecord(role.animationMap) ?? undefined,
+ } satisfies WorkspaceRoleAssetTarget,
+ roleKind: playableRole ? ('playable' as const) : ('story' as const),
+ assetSummary,
+ };
+}
+
+function CustomWorldAgentReadinessBar(props: {
+ completedKeys: string[];
+ isReady: boolean;
+ busy: boolean;
+ onStartDraft: () => void;
+}) {
+ const { completedKeys, isReady, busy, onStartDraft } = props;
+ const completedKeySet = new Set(completedKeys);
+ const completedCount = READINESS_ITEMS.filter((item) =>
+ completedKeySet.has(item.key),
+ ).length;
+
+ return (
+
+
+
+
+ {Math.min(completedCount, TOTAL_READINESS_STEPS)}/
+ {TOTAL_READINESS_STEPS}
+
+
+
+
+ {READINESS_ITEMS.map((item) => (
+
+ {item.label}
+
+ ))}
+
+
+ {isReady ? (
+
+ ) : null}
+
+
+
+ );
+}
+
+export function CustomWorldAgentWorkspace({
+ session,
+ activeOperation,
+ onBack,
+ onSubmitMessage,
+ onExecuteAction,
+}: CustomWorldAgentWorkspaceProps) {
+ const [selectedCardId, setSelectedCardId] = useState(() =>
+ resolveInitialCardId(session),
+ );
+ const [detail, setDetail] = useState(null);
+ const [detailLoading, setDetailLoading] = useState(false);
+ const [detailModalOpen, setDetailModalOpen] = useState(false);
+ const [editMode, setEditMode] = useState(false);
+ const [autoCompleteConfirmOpen, setAutoCompleteConfirmOpen] = useState(false);
+ const [generateEntityMode, setGenerateEntityMode] = useState<
+ 'character' | 'landmark' | null
+ >(null);
+ const [requestedRoleAssetTargetId, setRequestedRoleAssetTargetId] = useState<
+ string | null
+ >(null);
+ const [activeRoleAssetTargetId, setActiveRoleAssetTargetId] = useState<
+ string | null
+ >(null);
+ const [showRoleAssetStudio, setShowRoleAssetStudio] = useState(false);
+ const [closeRoleAssetStudioAfterSync, setCloseRoleAssetStudioAfterSync] =
+ useState(false);
+
+ useEffect(() => {
+ if (!session) {
+ setSelectedCardId(null);
+ return;
+ }
+
+ const availableCardIds = new Set(session.draftCards.map((card) => card.id));
+ if (session.focusCardId && availableCardIds.has(session.focusCardId)) {
+ setSelectedCardId(session.focusCardId);
+ return;
+ }
+
+ setSelectedCardId((current) => {
+ if (current && availableCardIds.has(current)) {
+ return current;
+ }
+
+ return resolveInitialCardId(session);
+ });
+ }, [session]);
+
+ useEffect(() => {
+ setEditMode(false);
+ }, [detail?.id]);
+
+ useEffect(() => {
+ if (!requestedRoleAssetTargetId || !activeOperation) {
+ return;
+ }
+
+ if (activeOperation.type !== 'generate_role_assets') {
+ return;
+ }
+
+ if (activeOperation.status === 'completed') {
+ setActiveRoleAssetTargetId(requestedRoleAssetTargetId);
+ setShowRoleAssetStudio(true);
+ setRequestedRoleAssetTargetId(null);
+ setDetailModalOpen(false);
+ return;
+ }
+
+ if (activeOperation.status === 'failed') {
+ setRequestedRoleAssetTargetId(null);
+ }
+ }, [activeOperation, requestedRoleAssetTargetId]);
+
+ useEffect(() => {
+ if (!activeOperation || activeOperation.type !== 'sync_role_assets') {
+ return;
+ }
+
+ if (activeOperation.status === 'completed') {
+ if (closeRoleAssetStudioAfterSync) {
+ setShowRoleAssetStudio(false);
+ }
+ setCloseRoleAssetStudioAfterSync(false);
+ return;
+ }
+
+ if (activeOperation.status === 'failed') {
+ setCloseRoleAssetStudioAfterSync(false);
+ }
+ }, [activeOperation, closeRoleAssetStudioAfterSync]);
+
+ useEffect(() => {
+ if (!session?.sessionId || !selectedCardId) {
+ setDetail(null);
+ setDetailLoading(false);
+ return;
+ }
+
+ let cancelled = false;
+ setDetailLoading(true);
+
+ void getCustomWorldAgentCardDetail(session.sessionId, selectedCardId)
+ .then((nextDetail) => {
+ if (cancelled) {
+ return;
+ }
+
+ setDetail(nextDetail);
+ })
+ .catch(() => {
+ if (cancelled) {
+ return;
+ }
+
+ setDetail(null);
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setDetailLoading(false);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [selectedCardId, session?.sessionId, session?.updatedAt]);
+
+ if (!session) {
+ return (
+
+ 正在恢复
+
+ );
+ }
+
+ const isBusy =
+ activeOperation?.status === 'queued' || activeOperation?.status === 'running';
+ const canStartDraft =
+ session.creatorIntentReadiness.isReady &&
+ session.stage === 'foundation_review';
+ const showAutoCompleteButton =
+ !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;
+ const recommendedReplies = buildRecommendedReplies(session);
+ const selectedRoleAssetContext = resolveRoleAssetTarget(
+ session,
+ activeRoleAssetTargetId,
+ );
+
+ const openRoleAssetStudio = (roleId: string | null) => {
+ if (!roleId) {
+ return;
+ }
+
+ setRequestedRoleAssetTargetId(roleId);
+ onExecuteAction({
+ action: 'generate_role_assets',
+ roleIds: [roleId],
+ });
+ };
+
+ const submitTextMessage = (text: string) => {
+ onSubmitMessage({
+ clientMessageId: createClientMessageId(),
+ text,
+ focusCardId: selectedCardId,
+ selectedCardIds: selectedCardId ? [selectedCardId] : [],
+ });
+ };
+
+ const submitSummaryRequest = () => {
+ submitTextMessage(
+ showDraftWorkspace
+ ? '帮我总结当前世界底稿,并指出下一步最值得精修的卡片。'
+ : '帮我总结当前设定,并指出下一步最值得补的世界锚点。',
+ );
+ };
+
+ const submitAutoCompleteRequest = () => {
+ submitTextMessage(
+ session.creatorIntentReadiness.isReady
+ ? '基于当前设定,帮我自动补强还可以更清晰的细节。'
+ : '请根据当前信息自动补全还缺的设定,并给我一版默认方案。',
+ );
+ setAutoCompleteConfirmOpen(false);
+ };
+
+ const handleRecommendedReply = (reply: string) => {
+ if (canStartDraft && reply.includes('生成草稿')) {
+ onExecuteAction({
+ action: 'draft_foundation',
+ });
+ return;
+ }
+
+ submitTextMessage(reply);
+ };
+
+ const openGenerateModal = (mode: 'character' | 'landmark') => {
+ setGenerateEntityMode(mode);
+ };
+
+ return (
+
+
+
{
+ onExecuteAction({
+ action: 'draft_foundation',
+ });
+ }}
+ />
+
+
+ {showDraftWorkspace ? (
+
+
+
{
+ onExecuteAction({
+ action: 'draft_foundation',
+ });
+ }}
+ onGenerateCharacter={() => {
+ openGenerateModal('character');
+ }}
+ onGenerateLandmark={() => {
+ openGenerateModal('landmark');
+ }}
+ onGenerateRoleAssets={() => {
+ openRoleAssetStudio(selectedCardId);
+ }}
+ onFocusSuggestedAction={(action) => {
+ if (action?.targetId) {
+ setSelectedCardId(action.targetId);
+ setDetailModalOpen(true);
+ return;
+ }
+
+ if (session.draftCards[0]) {
+ setSelectedCardId(session.draftCards[0].id);
+ setDetailModalOpen(true);
+ }
+ }}
+ />
+
+ {
+ setSelectedCardId(cardId);
+ setDetailModalOpen(true);
+ }}
+ />
+
+
+
+
+ {
+ setSelectedCardId(null);
+ setDetailModalOpen(false);
+ setEditMode(false);
+ }}
+ onStartEdit={() => {
+ setEditMode(true);
+ }}
+ onCancelEdit={() => {
+ setEditMode(false);
+ }}
+ onSave={(sections) => {
+ if (!detail) {
+ return;
+ }
+
+ setEditMode(false);
+ onExecuteAction({
+ action: 'update_draft_card',
+ cardId: detail.id,
+ sections,
+ });
+ }}
+ onGenerateCharacter={() => {
+ openGenerateModal('character');
+ }}
+ onGenerateLandmark={() => {
+ openGenerateModal('landmark');
+ }}
+ onOpenRoleAssetStudio={() => {
+ openRoleAssetStudio(detail?.id ?? selectedCardId);
+ }}
+ />
+
+
+
+
+
+
+
setAutoCompleteConfirmOpen(true)}
+ showAutoComplete={showAutoCompleteButton}
+ />
+
+
+ ) : (
+ <>
+
+ setAutoCompleteConfirmOpen(true)}
+ showAutoComplete={showAutoCompleteButton}
+ />
+ >
+ )}
+
+ {autoCompleteConfirmOpen ? (
+
+
+
+ 自动补全剩余设定
+
+
+ 自动补全会直接给缺失设定填入默认方案,可能降低作品质量。
+
+
+
+
+
+
+
+ ) : null}
+
+ {
+ setDetailModalOpen(false);
+ setEditMode(false);
+ }}
+ onStartEdit={() => {
+ setEditMode(true);
+ }}
+ onCancelEdit={() => {
+ setEditMode(false);
+ }}
+ onSave={(sections) => {
+ if (!detail) {
+ return;
+ }
+
+ setEditMode(false);
+ setDetailModalOpen(false);
+ onExecuteAction({
+ action: 'update_draft_card',
+ cardId: detail.id,
+ sections,
+ });
+ }}
+ onGenerateCharacter={() => {
+ setDetailModalOpen(false);
+ openGenerateModal('character');
+ }}
+ onGenerateLandmark={() => {
+ setDetailModalOpen(false);
+ openGenerateModal('landmark');
+ }}
+ onOpenRoleAssetStudio={() => {
+ setDetailModalOpen(false);
+ openRoleAssetStudio(detail?.id ?? selectedCardId);
+ }}
+ />
+
+ {
+ setGenerateEntityMode(null);
+ }}
+ onSubmit={({ count, promptText }) => {
+ if (!generateEntityMode) {
+ return;
+ }
+
+ onExecuteAction({
+ action:
+ generateEntityMode === 'character'
+ ? 'generate_characters'
+ : 'generate_landmarks',
+ count,
+ promptText: promptText || null,
+ anchorCardIds: selectedCardId ? [selectedCardId] : [],
+ });
+ setGenerateEntityMode(null);
+ }}
+ />
+
+ {showRoleAssetStudio && selectedRoleAssetContext ? (
+ {
+ setCloseRoleAssetStudioAfterSync(Boolean(options?.closeAfterSync));
+ onExecuteAction({
+ action: 'sync_role_assets',
+ ...payload,
+ });
+ }}
+ onClose={() => {
+ setShowRoleAssetStudio(false);
+ setCloseRoleAssetStudioAfterSync(false);
+ }}
+ />
+ ) : null}
+
+ );
+}
diff --git a/src/components/custom-world-agent/CustomWorldDraftCardDetailModal.tsx b/src/components/custom-world-agent/CustomWorldDraftCardDetailModal.tsx
new file mode 100644
index 00000000..7af0a7eb
--- /dev/null
+++ b/src/components/custom-world-agent/CustomWorldDraftCardDetailModal.tsx
@@ -0,0 +1,67 @@
+import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
+import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
+
+type CustomWorldDraftCardDetailModalProps = {
+ open: boolean;
+ detail: CustomWorldDraftCardDetail | null;
+ loading: boolean;
+ busy?: boolean;
+ editMode?: boolean;
+ onClose: () => void;
+ onStartEdit?: () => void;
+ onCancelEdit?: () => void;
+ onSave?: (
+ sections: Array<{
+ sectionId: string;
+ value: string;
+ }>,
+ ) => void;
+ onGenerateCharacter?: () => void;
+ onGenerateLandmark?: () => void;
+ onOpenRoleAssetStudio?: () => void;
+};
+
+export function CustomWorldDraftCardDetailModal({
+ open,
+ detail,
+ loading,
+ busy = false,
+ editMode = false,
+ onClose,
+ onStartEdit,
+ onCancelEdit,
+ onSave,
+ onGenerateCharacter,
+ onGenerateLandmark,
+ onOpenRoleAssetStudio,
+}: CustomWorldDraftCardDetailModalProps) {
+ if (!open) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/custom-world-agent/CustomWorldDraftEditPanel.test.tsx b/src/components/custom-world-agent/CustomWorldDraftEditPanel.test.tsx
new file mode 100644
index 00000000..be204dcf
--- /dev/null
+++ b/src/components/custom-world-agent/CustomWorldDraftEditPanel.test.tsx
@@ -0,0 +1,48 @@
+import { renderToStaticMarkup } from 'react-dom/server';
+import { expect, test } from 'vitest';
+
+import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
+
+test('draft detail panel renders editable form in edit mode', () => {
+ const html = renderToStaticMarkup(
+ {}}
+ onCancelEdit={() => {}}
+ onSave={() => {}}
+ />,
+ );
+
+ expect(html).toContain('保存');
+ expect(html).toContain('取消');
+ expect(html).toContain('角色名');
+ expect(html).toContain('textarea');
+});
diff --git a/src/components/custom-world-agent/CustomWorldDraftEditPanel.tsx b/src/components/custom-world-agent/CustomWorldDraftEditPanel.tsx
new file mode 100644
index 00000000..983fe464
--- /dev/null
+++ b/src/components/custom-world-agent/CustomWorldDraftEditPanel.tsx
@@ -0,0 +1,136 @@
+import { useEffect, useMemo, useState } from 'react';
+
+import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
+
+type CustomWorldDraftEditPanelProps = {
+ detail: CustomWorldDraftCardDetail;
+ disabled?: boolean;
+ onSave: (
+ sections: Array<{
+ sectionId: string;
+ value: string;
+ }>,
+ ) => void;
+ onCancel: () => void;
+};
+
+function shouldUseTextarea(sectionId: string, value: string) {
+ return (
+ value.length > 28 ||
+ value.includes('\n') ||
+ sectionId === 'summary' ||
+ sectionId === 'tone' ||
+ sectionId === 'coreConflicts' ||
+ sectionId === 'hiddenHook' ||
+ sectionId === 'secret' ||
+ sectionId === 'stakes' ||
+ sectionId === 'openingEvent' ||
+ sectionId === 'understandingShift' ||
+ sectionId === 'description'
+ );
+}
+
+export function CustomWorldDraftEditPanel({
+ detail,
+ disabled = false,
+ onSave,
+ onCancel,
+}: CustomWorldDraftEditPanelProps) {
+ const editableSections = useMemo(
+ () =>
+ detail.sections.filter((section) =>
+ detail.editableSectionIds.includes(section.id),
+ ),
+ [detail],
+ );
+ const [draftValues, setDraftValues] = useState>(() =>
+ Object.fromEntries(
+ editableSections.map((section) => [section.id, section.value]),
+ ),
+ );
+
+ useEffect(() => {
+ setDraftValues(
+ Object.fromEntries(editableSections.map((section) => [section.id, section.value])),
+ );
+ }, [editableSections]);
+
+ if (editableSections.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {editableSections.map((section) => {
+ const value = draftValues[section.id] ?? '';
+ const multiline = shouldUseTextarea(section.id, value);
+
+ return (
+
+ );
+ })}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/custom-world-agent/CustomWorldGenerateEntityModal.tsx b/src/components/custom-world-agent/CustomWorldGenerateEntityModal.tsx
new file mode 100644
index 00000000..bd76ef80
--- /dev/null
+++ b/src/components/custom-world-agent/CustomWorldGenerateEntityModal.tsx
@@ -0,0 +1,139 @@
+import { useEffect, useState } from 'react';
+
+type CustomWorldGenerateEntityModalProps = {
+ open: boolean;
+ mode: 'character' | 'landmark';
+ anchorCardTitle?: string | null;
+ disabled?: boolean;
+ onClose: () => void;
+ onSubmit: (payload: {
+ count: number;
+ promptText: string;
+ }) => void;
+};
+
+export function CustomWorldGenerateEntityModal({
+ open,
+ mode,
+ anchorCardTitle,
+ disabled = false,
+ onClose,
+ onSubmit,
+}: CustomWorldGenerateEntityModalProps) {
+ const [count, setCount] = useState(2);
+ const [promptText, setPromptText] = useState('');
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+
+ setCount(2);
+ setPromptText('');
+ }, [open, mode]);
+
+ if (!open) {
+ return null;
+ }
+
+ const title = mode === 'character' ? '新增角色' : '新增场景';
+ const submitLabel = mode === 'character' ? '生成角色' : '生成场景';
+
+ return (
+
+
+
+
+
+
+ {anchorCardTitle ? (
+
+
+ 当前参考卡
+
+
{anchorCardTitle}
+
+ ) : null}
+
+
+
数量
+
+ {[1, 2, 3].map((value) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx
new file mode 100644
index 00000000..8d0d0db7
--- /dev/null
+++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx
@@ -0,0 +1,74 @@
+/* @vitest-environment jsdom */
+
+import { render, screen } from '@testing-library/react';
+import { expect, test } from 'vitest';
+
+import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
+import { CustomWorldCreationHub } from './CustomWorldCreationHub';
+
+const baseDraftItem: CustomWorldWorkSummary = {
+ workId: 'draft:session-1',
+ sourceType: 'agent_session',
+ status: 'draft',
+ title: '潮雾列岛',
+ subtitle: '补齐关键锚点',
+ summary: '玩家是失职返乡的守灯人。',
+ coverImageSrc: null,
+ updatedAt: new Date('2026-04-14T10:00:00.000Z').toISOString(),
+ publishedAt: null,
+ stage: 'object_refining',
+ stageLabel: '精修对象',
+ playableNpcCount: 3,
+ landmarkCount: 4,
+ sessionId: 'session-1',
+ profileId: null,
+ canResume: true,
+ canEnterWorld: false,
+};
+
+test('creation hub reflects updated draft title summary and counts after rerender', () => {
+ const { rerender } = render(
+ {}}
+ onRetry={() => {}}
+ onCreateNew={() => {}}
+ onResumeDraft={() => {}}
+ onEnterPublished={() => {}}
+ />,
+ );
+
+ expect(screen.getByText('潮雾列岛')).toBeTruthy();
+ expect(screen.getByText('玩家是失职返乡的守灯人。')).toBeTruthy();
+ expect(screen.getByText('角色 3')).toBeTruthy();
+ expect(screen.getByText('地点 4')).toBeTruthy();
+
+ rerender(
+ {}}
+ onRetry={() => {}}
+ onCreateNew={() => {}}
+ onResumeDraft={() => {}}
+ onEnterPublished={() => {}}
+ />,
+ );
+
+ expect(screen.getByText('潮雾列岛·回潮版')).toBeTruthy();
+ expect(screen.getByText('世界总卡和角色网已经继续长出了新的支线。')).toBeTruthy();
+ expect(screen.getByText('角色 5')).toBeTruthy();
+ expect(screen.getByText('地点 6')).toBeTruthy();
+});
diff --git a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx
new file mode 100644
index 00000000..5e822639
--- /dev/null
+++ b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx
@@ -0,0 +1,44 @@
+import { renderToStaticMarkup } from 'react-dom/server';
+import { expect, test } from 'vitest';
+
+import { CustomWorldCreationHub } from './CustomWorldCreationHub';
+
+test('creation hub draft card renders compiled work summary fields', () => {
+ const html = renderToStaticMarkup(
+ {}}
+ onRetry={() => {}}
+ onCreateNew={() => {}}
+ onResumeDraft={() => {}}
+ onEnterPublished={() => {}}
+ />,
+ );
+
+ expect(html).toContain('一个被潮雾切开的列岛世界');
+ expect(html).toContain('玩家是失职返乡的守灯人');
+ expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
+});
diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx
new file mode 100644
index 00000000..2e239ada
--- /dev/null
+++ b/src/components/custom-world-home/CustomWorldCreationHub.tsx
@@ -0,0 +1,154 @@
+import { useMemo, useState } from 'react';
+
+import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
+import { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard';
+import { CustomWorldWorkCard } from './CustomWorldWorkCard';
+import {
+ type CustomWorldWorkFilter,
+ CustomWorldWorkTabs,
+} from './CustomWorldWorkTabs';
+
+type CustomWorldCreationHubProps = {
+ items: CustomWorldWorkSummary[];
+ loading: boolean;
+ error: string | null;
+ onBack: () => void;
+ onRetry: () => void;
+ onCreateNew: () => void;
+ onResumeDraft: (sessionId: string) => void;
+ onEnterPublished: (profileId: string) => void;
+};
+
+function EmptyState({ title }: { title: string }) {
+ return (
+
+ );
+}
+
+export function CustomWorldCreationHub({
+ items,
+ loading,
+ error,
+ onBack,
+ onRetry,
+ onCreateNew,
+ onResumeDraft,
+ onEnterPublished,
+}: CustomWorldCreationHubProps) {
+ const [activeFilter, setActiveFilter] =
+ useState('all');
+ const draftCount = items.filter((item) => item.status === 'draft').length;
+ const publishedCount = items.filter(
+ (item) => item.status === 'published',
+ ).length;
+ const filteredItems = useMemo(
+ () =>
+ items.filter((item) =>
+ activeFilter === 'all' ? true : item.status === activeFilter,
+ ),
+ [activeFilter, items],
+ );
+
+ return (
+
+
+
+
+
+
+ 草稿 {draftCount}
+
+
+ 已发布 {publishedCount}
+
+
+
+
+
+
+
+
+
+
+ {error ? (
+
+ ) : null}
+
+ {loading ? (
+
+ {Array.from({ length: 3 }).map((_, index) => (
+
+ ))}
+
+ ) : filteredItems.length > 0 ? (
+
+ {filteredItems.map((item) => (
+ {
+ if (item.status === 'draft' && item.sessionId) {
+ onResumeDraft(item.sessionId);
+ return;
+ }
+
+ if (item.status === 'published' && item.profileId) {
+ onEnterPublished(item.profileId);
+ }
+ }}
+ />
+ ))}
+
+ ) : items.length === 0 ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+export type { CustomWorldWorkFilter };
diff --git a/src/components/custom-world-home/CustomWorldCreationLauncherModal.tsx b/src/components/custom-world-home/CustomWorldCreationLauncherModal.tsx
new file mode 100644
index 00000000..ad18b781
--- /dev/null
+++ b/src/components/custom-world-home/CustomWorldCreationLauncherModal.tsx
@@ -0,0 +1,146 @@
+import { X } from 'lucide-react';
+
+import type { CustomWorldQuestion } from '../../../packages/shared/src/contracts/runtime';
+
+type CustomWorldCreationLauncherModalProps = {
+ isOpen: boolean;
+ mode: 'create' | 'resume';
+ seedText: string;
+ seedTextLocked: boolean;
+ questions: CustomWorldQuestion[];
+ answers: Record;
+ isBusy: boolean;
+ error: string | null;
+ lastError?: string | null;
+ primaryLabel: string;
+ onClose: () => void;
+ onSeedTextChange: (value: string) => void;
+ onAnswerChange: (questionId: string, value: string) => void;
+ onPrimaryAction: () => void;
+};
+
+export function CustomWorldCreationLauncherModal({
+ isOpen,
+ mode,
+ seedText,
+ seedTextLocked,
+ questions,
+ answers,
+ isBusy,
+ error,
+ lastError = null,
+ primaryLabel,
+ onClose,
+ onSeedTextChange,
+ onAnswerChange,
+ onPrimaryAction,
+}: CustomWorldCreationLauncherModalProps) {
+ if (!isOpen) {
+ return null;
+ }
+
+ const unansweredQuestions = questions.filter((question) => !question.answer?.trim());
+
+ return (
+
+
+
+
+
+ {mode === 'create' ? '新建作品' : '继续创作'}
+
+
+ 输入一点灵感,开始共创一个新世界。
+
+
+
+
+
+
+
+
+
+ {unansweredQuestions.length > 0 ? (
+
+
+ 先补齐几条关键锚点,再开始生成。
+
+ {unansweredQuestions.map((question) => (
+
+ ))}
+
+ ) : null}
+
+ {lastError ? (
+
+ 上次生成未完成:{lastError}
+
+ ) : null}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/custom-world-home/CustomWorldCreationStartCard.tsx b/src/components/custom-world-home/CustomWorldCreationStartCard.tsx
new file mode 100644
index 00000000..e87d5b5e
--- /dev/null
+++ b/src/components/custom-world-home/CustomWorldCreationStartCard.tsx
@@ -0,0 +1,43 @@
+import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
+
+type CustomWorldCreationStartCardProps = {
+ onCreateNew: () => void;
+};
+
+export function CustomWorldCreationStartCard({
+ onCreateNew,
+}: CustomWorldCreationStartCardProps) {
+ return (
+
+ );
+}
diff --git a/src/components/custom-world-home/CustomWorldWorkCard.tsx b/src/components/custom-world-home/CustomWorldWorkCard.tsx
new file mode 100644
index 00000000..c99cc726
--- /dev/null
+++ b/src/components/custom-world-home/CustomWorldWorkCard.tsx
@@ -0,0 +1,119 @@
+import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
+import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
+
+function formatUpdatedAt(value: string) {
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return '最近更新';
+ }
+
+ return new Intl.DateTimeFormat('zh-CN', {
+ month: 'numeric',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ }).format(date);
+}
+
+type CustomWorldWorkCardProps = {
+ item: CustomWorldWorkSummary;
+ onClick: () => void;
+};
+
+export function CustomWorldWorkCard({
+ item,
+ onClick,
+}: CustomWorldWorkCardProps) {
+ const isDraft = item.status === 'draft';
+ const hasFoundationDraft =
+ item.playableNpcCount > 0 || item.landmarkCount > 0;
+ const roleCountLabel = isDraft ? '角色' : '可扮演角色';
+
+ return (
+
+ {item.coverImageSrc ? (
+

+ ) : null}
+
+
+
+
+
+ {isDraft ? '草稿' : '已发布'}
+
+ {item.stageLabel ? (
+
+ {item.stageLabel}
+
+ ) : null}
+
+
+ {formatUpdatedAt(item.updatedAt)}
+
+
+
+
+
+ {item.title}
+
+
+ {item.subtitle}
+
+
+ {item.summary}
+
+
+
+
+
+
+ {roleCountLabel} {item.playableNpcCount}
+
+
+ 地点 {item.landmarkCount}
+
+ {item.roleVisualReadyCount ? (
+
+ 主图 {item.roleVisualReadyCount}
+
+ ) : null}
+ {item.roleAnimationReadyCount ? (
+
+ 动作 {item.roleAnimationReadyCount}
+
+ ) : null}
+ {item.roleAssetSummaryLabel ? (
+
+ {item.roleAssetSummaryLabel}
+
+ ) : null}
+
+
+
+
+
+ );
+}
diff --git a/src/components/custom-world-home/CustomWorldWorkTabs.tsx b/src/components/custom-world-home/CustomWorldWorkTabs.tsx
new file mode 100644
index 00000000..3653a22c
--- /dev/null
+++ b/src/components/custom-world-home/CustomWorldWorkTabs.tsx
@@ -0,0 +1,52 @@
+export type CustomWorldWorkFilter = 'all' | 'draft' | 'published';
+
+const FILTER_OPTIONS: Array<{
+ id: CustomWorldWorkFilter;
+ label: string;
+}> = [
+ { id: 'all', label: '全部' },
+ { id: 'draft', label: '草稿' },
+ { id: 'published', label: '已发布' },
+];
+
+type CustomWorldWorkTabsProps = {
+ activeFilter: CustomWorldWorkFilter;
+ draftCount: number;
+ publishedCount: number;
+ onChange: (filter: CustomWorldWorkFilter) => void;
+};
+
+export function CustomWorldWorkTabs({
+ activeFilter,
+ draftCount,
+ publishedCount,
+ onChange,
+}: CustomWorldWorkTabsProps) {
+ return (
+
+ {FILTER_OPTIONS.map((option) => {
+ const count =
+ option.id === 'draft'
+ ? draftCount
+ : option.id === 'published'
+ ? publishedCount
+ : draftCount + publishedCount;
+
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/game-canvas/GameCanvasEntityLayer.tsx b/src/components/game-canvas/GameCanvasEntityLayer.tsx
index 27c5344c..4422cb4d 100644
--- a/src/components/game-canvas/GameCanvasEntityLayer.tsx
+++ b/src/components/game-canvas/GameCanvasEntityLayer.tsx
@@ -111,6 +111,9 @@ export function GameCanvasEntityLayer({
monsterAnchorMeters,
playerX,
}: GameCanvasEntityLayerProps) {
+ const shouldRenderPeacefulEncounter =
+ Boolean(encounter) && (!inBattle || sceneCombatants.length === 0);
+
return (
<>
{companions.map(companion => {
@@ -327,8 +330,12 @@ export function GameCanvasEntityLayer({
);
})}
- {encounter &&
+ {shouldRenderPeacefulEncounter &&
(() => {
+ if (!encounter) {
+ return null;
+ }
+
const isCampCompanionEncounter =
encounter.specialBehavior === 'initial_companion'
|| encounter.specialBehavior === 'camp_companion';
diff --git a/src/components/game-shell/PreGameSelectionFlow.tsx b/src/components/game-shell/PreGameSelectionFlow.tsx
index d2370c7a..04046b21 100644
--- a/src/components/game-shell/PreGameSelectionFlow.tsx
+++ b/src/components/game-shell/PreGameSelectionFlow.tsx
@@ -1,55 +1,47 @@
import { AnimatePresence, motion } from 'motion/react';
-import { useEffect, useMemo, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import {
- buildCustomWorldPlayableCharacters,
-} from '../../data/characterPresets';
import type {
- CustomWorldGenerationProgress,
-} from '../../../packages/shared/src/contracts/runtime';
-import type { JsonObject } from '../../../packages/shared/src/contracts/common';
-import {
- readSavedCustomWorldProfiles,
- upsertSavedCustomWorldProfile,
-} from '../../data/customWorldLibrary';
+ CustomWorldAgentActionRequest,
+ CustomWorldAgentOperationRecord,
+ CustomWorldAgentSessionSnapshot,
+ CustomWorldWorkSummary,
+ SendCustomWorldAgentMessageRequest,
+} from '../../../packages/shared/src/contracts/customWorldAgent';
+import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
-import { getScenePreset } from '../../data/scenePresets';
import {
- generateCustomWorldProfile,
+ createCustomWorldAgentSession,
+ executeCustomWorldAgentAction,
+ getCustomWorldAgentOperation,
+ getCustomWorldAgentSession,
+ listCustomWorldWorks,
+ sendCustomWorldAgentMessage,
} from '../../services/aiService';
import {
- buildCustomWorldCreatorIntentDisplayText,
- buildCustomWorldCreatorIntentGenerationText,
- createEmptyCustomWorldCreatorIntent,
-} from '../../services/customWorldCreatorIntent';
+ clearCustomWorldAgentUiState,
+ readCustomWorldAgentUiState,
+ writeCustomWorldAgentUiState,
+} from '../../services/customWorldAgentUiState';
import { detectCustomWorldThemeMode } from '../../services/customWorldTheme';
+import { listCustomWorldLibrary } from '../../services/storageService';
+import { type CustomWorldProfile, type GameState } from '../../types';
import {
- listCustomWorldLibrary,
- upsertCustomWorldProfile,
-} from '../../services/storageService';
-import {
- type CustomWorldCreatorIntent,
- type CustomWorldGenerationMode,
- type CustomWorldProfile,
- type GameState,
-} from '../../types';
-import {
- CUSTOM_WORLD_THEME_ICONS,
CHROME_ICONS,
+ CUSTOM_WORLD_THEME_ICONS,
getNineSliceStyle,
UI_CHROME,
} from '../../uiAssets';
-import { CustomWorldGenerationView } from '../CustomWorldGenerationView';
-import { CustomWorldResultView } from '../CustomWorldResultView';
+import { CustomWorldAgentWorkspace } from '../custom-world-agent/CustomWorldAgentWorkspace';
+import { CustomWorldCreationHub } from '../custom-world-home/CustomWorldCreationHub';
import { DeveloperTeamModal } from '../DeveloperTeamModal';
import { PixelIcon } from '../PixelIcon';
-import { CustomWorldCreatorModal } from '../SelectionCustomizationModals';
export type SelectionStage =
| 'start'
| 'world'
- | 'custom-world-generating'
- | 'custom-world-result';
+ | 'custom-world-home'
+ | 'custom-world-agent';
type PreGameSelectionFlowProps = {
selectionStage: SelectionStage;
@@ -69,73 +61,20 @@ const START_SCREEN_CONTACTS = [
{ label: '微信', value: 'bzh253518756' },
] as const;
-function buildLockedSeedNameSets(profile: CustomWorldProfile) {
- const lockedCharacterNames = new Set(
- profile.creatorIntent?.keyCharacters
- .filter((entry) => entry.locked)
- .map((entry) => entry.name.trim())
- .filter(Boolean) ?? [],
- );
- const lockedLandmarkNames = new Set(
- profile.creatorIntent?.keyLandmarks
- .filter((entry) => entry.locked)
- .map((entry) => entry.name.trim())
- .filter(Boolean) ?? [],
- );
-
+function createOperationErrorBanner(
+ message: string,
+): CustomWorldAgentOperationRecord {
return {
- lockedCharacterNames,
- lockedLandmarkNames,
+ operationId: `operation-error-${Date.now()}`,
+ type: 'process_message',
+ status: 'failed',
+ phaseLabel: '处理失败',
+ phaseDetail: message,
+ progress: 100,
+ error: message,
};
}
-function mergeLockedProfileContent(
- currentProfile: CustomWorldProfile,
- nextProfile: CustomWorldProfile,
-) {
- const { lockedCharacterNames, lockedLandmarkNames } =
- buildLockedSeedNameSets(currentProfile);
-
- const nextPlayableNpcs = nextProfile.playableNpcs.map((npc) => {
- if (!lockedCharacterNames.has(npc.name.trim())) {
- return npc;
- }
- return (
- currentProfile.playableNpcs.find(
- (currentNpc) => currentNpc.name.trim() === npc.name.trim(),
- ) ?? npc
- );
- });
- const nextStoryNpcs = nextProfile.storyNpcs.map((npc) => {
- if (!lockedCharacterNames.has(npc.name.trim())) {
- return npc;
- }
- return (
- currentProfile.storyNpcs.find(
- (currentNpc) => currentNpc.name.trim() === npc.name.trim(),
- ) ?? npc
- );
- });
- const nextLandmarks = nextProfile.landmarks.map((landmark) => {
- if (!lockedLandmarkNames.has(landmark.name.trim())) {
- return landmark;
- }
- return (
- currentProfile.landmarks.find(
- (currentLandmark) =>
- currentLandmark.name.trim() === landmark.name.trim(),
- ) ?? landmark
- );
- });
-
- return {
- ...nextProfile,
- playableNpcs: nextPlayableNpcs,
- storyNpcs: nextStoryNpcs,
- landmarks: nextLandmarks,
- } satisfies CustomWorldProfile;
-}
-
export function PreGameSelectionFlow({
selectionStage,
setSelectionStage,
@@ -145,32 +84,31 @@ export function PreGameSelectionFlow({
handleStartNewGame,
handleCustomWorldSelect,
}: PreGameSelectionFlowProps) {
- const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] =
- useState(null);
const [savedCustomWorldProfiles, setSavedCustomWorldProfiles] = useState<
CustomWorldProfile[]
>([]);
+ const [customWorldWorks, setCustomWorldWorks] = useState<
+ CustomWorldWorkSummary[]
+ >([]);
+ const [isLoadingCustomWorldWorks, setIsLoadingCustomWorldWorks] =
+ useState(false);
+ const [customWorldWorksError, setCustomWorldWorksError] =
+ useState(null);
const [showDeveloperTeamModal, setShowDeveloperTeamModal] = useState(false);
- const [showCustomWorldModal, setShowCustomWorldModal] = useState(false);
- const [customWorldCreatorIntent, setCustomWorldCreatorIntent] =
- useState(() =>
- createEmptyCustomWorldCreatorIntent('freeform'),
- );
- const [customWorldGenerationMode, setCustomWorldGenerationMode] =
- useState('fast');
- const [customWorldError, setCustomWorldError] = useState(null);
- const [isGeneratingCustomWorld, setIsGeneratingCustomWorld] = useState(false);
- const [customWorldProgress, setCustomWorldProgress] =
- useState(null);
- const customWorldAbortControllerRef = useRef(null);
-
- const previewCustomWorldCharacters = useMemo(
- () =>
- generatedCustomWorldProfile
- ? buildCustomWorldPlayableCharacters(generatedCustomWorldProfile)
- : [],
- [generatedCustomWorldProfile],
- );
+ const [isCreatingCustomWorldWork, setIsCreatingCustomWorldWork] =
+ useState(false);
+ const [isRestoringAgentSession, setIsRestoringAgentSession] = useState(false);
+ const [activeCustomWorldAgentSessionId, setActiveCustomWorldAgentSessionId] =
+ useState(null);
+ const [
+ activeCustomWorldAgentOperationId,
+ setActiveCustomWorldAgentOperationId,
+ ] = useState(null);
+ const [activeCustomWorldAgentSession, setActiveCustomWorldAgentSession] =
+ useState(null);
+ const [activeCustomWorldAgentOperation, setActiveCustomWorldAgentOperation] =
+ useState(null);
+ const clearOperationTimeoutRef = useRef(null);
const savedCustomWorldCards = useMemo(
() =>
@@ -197,319 +135,348 @@ export function PreGameSelectionFlow({
[savedCustomWorldProfiles],
);
- const customWorldSettingPreview = useMemo(() => {
- if (customWorldCreatorIntent.sourceMode === 'freeform') {
- return customWorldCreatorIntent.rawSettingText.trim();
+ const refreshCustomWorldLibrary = useCallback(async () => {
+ try {
+ setSavedCustomWorldProfiles(await listCustomWorldLibrary());
+ } catch (error) {
+ console.warn(
+ '[PreGameSelectionFlow] failed to load custom world library',
+ error,
+ );
}
- const intentSummary = buildCustomWorldCreatorIntentDisplayText(
- customWorldCreatorIntent,
- ).trim();
- if (intentSummary) {
- return intentSummary;
- }
- return customWorldCreatorIntent.rawSettingText.trim();
- }, [customWorldCreatorIntent]);
+ }, []);
- useEffect(() => {
- if (
- selectionStage === 'custom-world-result' &&
- !generatedCustomWorldProfile
- ) {
- setSelectionStage('world');
+ const refreshCustomWorldWorks = useCallback(async () => {
+ setIsLoadingCustomWorldWorks(true);
+ try {
+ setCustomWorldWorks(await listCustomWorldWorks());
+ setCustomWorldWorksError(null);
+ } catch (error) {
+ setCustomWorldWorksError(
+ error instanceof Error ? error.message : '读取创作作品失败。',
+ );
+ } finally {
+ setIsLoadingCustomWorldWorks(false);
}
- }, [generatedCustomWorldProfile, selectionStage, setSelectionStage]);
+ }, []);
- useEffect(
- () => () => {
- customWorldAbortControllerRef.current?.abort();
+ const refreshCustomWorldHomeData = useCallback(async () => {
+ await Promise.all([refreshCustomWorldLibrary(), refreshCustomWorldWorks()]);
+ }, [refreshCustomWorldLibrary, refreshCustomWorldWorks]);
+
+ const syncCustomWorldAgentUiState = useCallback(
+ (sessionId: string | null, operationId: string | null) => {
+ setActiveCustomWorldAgentSessionId(sessionId);
+ setActiveCustomWorldAgentOperationId(operationId);
+ writeCustomWorldAgentUiState({
+ activeSessionId: sessionId,
+ activeOperationId: operationId,
+ });
},
[],
);
- useEffect(() => {
- let isActive = true;
- void listCustomWorldLibrary()
- .then((profiles) => {
- if (!isActive) return;
- setSavedCustomWorldProfiles(profiles);
- })
- .catch((error) => {
- console.warn(
- '[PreGameSelectionFlow] failed to load custom world library',
- error,
- );
+ const scheduleClearOperationBanner = useCallback(() => {
+ if (clearOperationTimeoutRef.current) {
+ window.clearTimeout(clearOperationTimeoutRef.current);
+ }
+
+ clearOperationTimeoutRef.current = window.setTimeout(() => {
+ setActiveCustomWorldAgentOperation(null);
+ setActiveCustomWorldAgentOperationId(null);
+ writeCustomWorldAgentUiState({
+ activeSessionId: activeCustomWorldAgentSessionId,
+ activeOperationId: null,
});
+ clearOperationTimeoutRef.current = null;
+ }, 1400);
+ }, [activeCustomWorldAgentSessionId]);
- return () => {
- isActive = false;
- };
- }, []);
+ const refreshActiveCustomWorldAgentSession = useCallback(
+ async (sessionId: string) => {
+ const session = await getCustomWorldAgentSession(sessionId);
+ setActiveCustomWorldAgentSession(session);
+ return session;
+ },
+ [],
+ );
- const leaveCustomWorldResult = () => {
- setGeneratedCustomWorldProfile(null);
- setCustomWorldError(null);
- setCustomWorldProgress(null);
- setSelectionStage('world');
- };
+ const loadCustomWorldAgentSession = useCallback(
+ async (sessionId: string, operationId?: string | null) => {
+ setIsRestoringAgentSession(true);
+ setActiveCustomWorldAgentSession(null);
+ try {
+ const session = await getCustomWorldAgentSession(sessionId);
+ setActiveCustomWorldAgentSession(session);
+ setActiveCustomWorldAgentOperation(null);
+ syncCustomWorldAgentUiState(sessionId, operationId ?? null);
+ setSelectionStage('custom-world-agent');
- const leaveCustomWorldGeneration = () => {
- if (isGeneratingCustomWorld) {
+ if (operationId) {
+ try {
+ const operation = await getCustomWorldAgentOperation(
+ sessionId,
+ operationId,
+ );
+ setActiveCustomWorldAgentOperation(operation);
+ } catch (error) {
+ setActiveCustomWorldAgentOperation(
+ createOperationErrorBanner(
+ error instanceof Error ? error.message : '读取操作状态失败。',
+ ),
+ );
+ }
+ }
+ } catch (error) {
+ clearCustomWorldAgentUiState();
+ syncCustomWorldAgentUiState(null, null);
+ setSelectionStage('custom-world-home');
+ setCustomWorldWorksError(
+ error instanceof Error ? error.message : '恢复共创会话失败。',
+ );
+ } finally {
+ setIsRestoringAgentSession(false);
+ }
+ },
+ [setSelectionStage, syncCustomWorldAgentUiState],
+ );
+
+ const leaveCustomWorldAgentWorkspace = useCallback(async () => {
+ clearCustomWorldAgentUiState();
+ syncCustomWorldAgentUiState(null, null);
+ setActiveCustomWorldAgentSession(null);
+ setActiveCustomWorldAgentOperation(null);
+ setSelectionStage('custom-world-home');
+ await refreshCustomWorldHomeData();
+ }, [
+ refreshCustomWorldHomeData,
+ setSelectionStage,
+ syncCustomWorldAgentUiState,
+ ]);
+
+ useEffect(() => {
+ void refreshCustomWorldHomeData();
+ const restoredState = readCustomWorldAgentUiState();
+
+ if (!gameState.worldType && restoredState.activeSessionId) {
+ void loadCustomWorldAgentSession(
+ restoredState.activeSessionId,
+ restoredState.activeOperationId ?? null,
+ );
+ }
+ }, [
+ gameState.worldType,
+ loadCustomWorldAgentSession,
+ refreshCustomWorldHomeData,
+ ]);
+
+ useEffect(() => {
+ if (!gameState.worldType && selectionStage === 'custom-world-home') {
+ void refreshCustomWorldHomeData();
+ }
+ }, [gameState.worldType, refreshCustomWorldHomeData, selectionStage]);
+
+ useEffect(() => {
+ if (
+ !activeCustomWorldAgentSessionId ||
+ !activeCustomWorldAgentOperationId ||
+ !activeCustomWorldAgentOperation ||
+ (activeCustomWorldAgentOperation.status !== 'queued' &&
+ activeCustomWorldAgentOperation.status !== 'running')
+ ) {
return;
}
- setCustomWorldError(null);
- setCustomWorldProgress(null);
- setSelectionStage('world');
- };
+ const timeoutId = window.setTimeout(async () => {
+ try {
+ const operation = await getCustomWorldAgentOperation(
+ activeCustomWorldAgentSessionId,
+ activeCustomWorldAgentOperationId,
+ );
+ setActiveCustomWorldAgentOperation(operation);
+
+ if (
+ operation.status === 'completed' ||
+ operation.status === 'failed'
+ ) {
+ await refreshActiveCustomWorldAgentSession(
+ activeCustomWorldAgentSessionId,
+ );
+ await refreshCustomWorldWorks();
+
+ if (operation.status === 'completed') {
+ scheduleClearOperationBanner();
+ } else {
+ setActiveCustomWorldAgentOperationId(null);
+ writeCustomWorldAgentUiState({
+ activeSessionId: activeCustomWorldAgentSessionId,
+ activeOperationId: null,
+ });
+ }
+ }
+ } catch (error) {
+ setActiveCustomWorldAgentOperation(
+ createOperationErrorBanner(
+ error instanceof Error ? error.message : '读取操作状态失败。',
+ ),
+ );
+ }
+ }, 500);
+
+ return () => window.clearTimeout(timeoutId);
+ }, [
+ activeCustomWorldAgentOperation,
+ activeCustomWorldAgentOperationId,
+ activeCustomWorldAgentSessionId,
+ refreshActiveCustomWorldAgentSession,
+ refreshCustomWorldWorks,
+ scheduleClearOperationBanner,
+ ]);
+
+ useEffect(
+ () => () => {
+ if (clearOperationTimeoutRef.current) {
+ window.clearTimeout(clearOperationTimeoutRef.current);
+ }
+ },
+ [],
+ );
const openCustomWorldCreator = () => {
- if (isGeneratingCustomWorld) {
- return;
- }
-
- setCustomWorldError(null);
- setCustomWorldProgress(null);
- setShowCustomWorldModal(true);
+ setSelectionStage('custom-world-home');
};
- const editCustomWorldSetting = () => {
- if (isGeneratingCustomWorld) {
- return;
- }
-
- if (generatedCustomWorldProfile) {
- setCustomWorldCreatorIntent(
- generatedCustomWorldProfile.creatorIntent ??
- ({
- ...createEmptyCustomWorldCreatorIntent('freeform'),
- rawSettingText: generatedCustomWorldProfile.settingText,
- } satisfies CustomWorldCreatorIntent),
- );
- setCustomWorldGenerationMode(
- generatedCustomWorldProfile.generationMode ?? 'full',
- );
- }
- setCustomWorldError(null);
- setCustomWorldProgress(null);
+ const startNewGame = () => {
+ handleStartNewGame();
+ clearCustomWorldAgentUiState();
+ syncCustomWorldAgentUiState(null, null);
+ setActiveCustomWorldAgentSession(null);
+ setActiveCustomWorldAgentOperation(null);
setSelectionStage('world');
- setShowCustomWorldModal(true);
};
- const saveGeneratedCustomWorld = async () => {
- if (!generatedCustomWorldProfile) {
+ const handleCreateNewWork = async () => {
+ if (isCreatingCustomWorldWork) {
return;
}
+ setIsCreatingCustomWorldWork(true);
+ setCustomWorldWorksError(null);
try {
- setSavedCustomWorldProfiles(
- await upsertCustomWorldProfile(generatedCustomWorldProfile),
- );
+ const response = await createCustomWorldAgentSession({});
+
+ setActiveCustomWorldAgentSession(response.session);
+ setActiveCustomWorldAgentOperation(null);
+ syncCustomWorldAgentUiState(response.session.sessionId, null);
+ setSelectionStage('custom-world-agent');
+ await refreshCustomWorldWorks();
} catch (error) {
- setCustomWorldError(
- error instanceof Error ? error.message : '保存自定义世界失败。',
+ setCustomWorldWorksError(
+ error instanceof Error ? error.message : '创建共创会话失败。',
);
+ } finally {
+ setIsCreatingCustomWorldWork(false);
+ }
+ };
+
+ const handleResumeDraft = async (sessionId: string) => {
+ await loadCustomWorldAgentSession(sessionId, null);
+ };
+
+ const handleEnterPublished = async (profileId: string) => {
+ let profile =
+ savedCustomWorldProfiles.find((item) => item.id === profileId) ?? null;
+
+ if (!profile) {
+ const profiles = await listCustomWorldLibrary();
+ setSavedCustomWorldProfiles(profiles);
+ profile = profiles.find((item) => item.id === profileId) ?? null;
+ }
+
+ if (!profile) {
+ setCustomWorldWorksError('读取已发布世界失败。');
return;
}
- handleCustomWorldSelect(generatedCustomWorldProfile);
- setGeneratedCustomWorldProfile(null);
- setCustomWorldError(null);
- setCustomWorldProgress(null);
- setSelectionStage('world');
+ handleCustomWorldSelect(profile);
};
- const openSavedCustomWorldEditor = (profile: CustomWorldProfile) => {
- if (isGeneratingCustomWorld) {
- return;
- }
-
- setGeneratedCustomWorldProfile(profile);
- setCustomWorldCreatorIntent(
- profile.creatorIntent ??
- ({
- ...createEmptyCustomWorldCreatorIntent('freeform'),
- rawSettingText: profile.settingText,
- } satisfies CustomWorldCreatorIntent),
- );
- setCustomWorldGenerationMode(profile.generationMode ?? 'full');
- setCustomWorldError(null);
- setCustomWorldProgress(null);
- setSelectionStage('custom-world-result');
- };
-
- const regenerateFromCurrentProfile = async (
- applyProfile: (
- currentProfile: CustomWorldProfile,
- regeneratedProfile: CustomWorldProfile,
- ) => CustomWorldProfile,
- options: {
- confirmMessage: string;
- generationMode?: CustomWorldGenerationMode;
- },
+ const handleSubmitCustomWorldAgentMessage = async (
+ payload: SendCustomWorldAgentMessageRequest,
) => {
- if (!generatedCustomWorldProfile || isGeneratingCustomWorld) {
+ if (!activeCustomWorldAgentSessionId) {
return;
}
- const confirmed = window.confirm(options.confirmMessage);
- if (!confirmed) {
- return;
- }
-
- const abortController = new AbortController();
- customWorldAbortControllerRef.current?.abort();
- customWorldAbortControllerRef.current = abortController;
- setIsGeneratingCustomWorld(true);
- setCustomWorldError(null);
-
try {
- const regeneratedProfile = await generateCustomWorldProfile(
- {
- settingText:
- generatedCustomWorldProfile.settingText.trim() ||
- customWorldSettingPreview,
- creatorIntent:
- (generatedCustomWorldProfile.creatorIntent as JsonObject | null) ??
- null,
- generationMode:
- options.generationMode ??
- generatedCustomWorldProfile.generationMode ??
- 'full',
- },
- {
- signal: abortController.signal,
- onProgress: setCustomWorldProgress,
- },
+ const response = await sendCustomWorldAgentMessage(
+ activeCustomWorldAgentSessionId,
+ payload,
);
-
- if (abortController.signal.aborted) {
- return;
- }
-
- const mergedProfile = applyProfile(
- generatedCustomWorldProfile,
- mergeLockedProfileContent(generatedCustomWorldProfile, regeneratedProfile),
+ setActiveCustomWorldAgentOperation(response.operation);
+ syncCustomWorldAgentUiState(
+ activeCustomWorldAgentSessionId,
+ response.operation.operationId,
);
- setGeneratedCustomWorldProfile(mergedProfile);
- setCustomWorldProgress(null);
- setCustomWorldError(null);
+ await refreshActiveCustomWorldAgentSession(activeCustomWorldAgentSessionId);
} catch (error) {
- if (abortController.signal.aborted) {
- setCustomWorldError('世界生成已中断。你可以重新尝试本次操作。');
- return;
- }
- setCustomWorldError(
- error instanceof Error ? error.message : '局部重生成失败。',
+ setActiveCustomWorldAgentOperation(
+ createOperationErrorBanner(
+ error instanceof Error ? error.message : '发送共创消息失败。',
+ ),
);
- } finally {
- if (customWorldAbortControllerRef.current === abortController) {
- customWorldAbortControllerRef.current = null;
- }
- setIsGeneratingCustomWorld(false);
}
};
- const continueExpandCustomWorld = async () => {
- await regenerateFromCurrentProfile(
- (_currentProfile, regeneratedProfile) => ({
- ...regeneratedProfile,
- generationMode: 'full',
- generationStatus: 'complete',
- }),
- {
- confirmMessage:
- '确认继续补全当前世界吗?系统会在保留已锁定锚点的前提下,继续生成长尾角色和场景网络。',
- generationMode: 'full',
- },
- );
- };
-
- const createCustomWorld = async () => {
- if (isGeneratingCustomWorld) {
+ const handleExecuteCustomWorldAgentAction = async (
+ payload: CustomWorldAgentActionRequest,
+ ) => {
+ if (!activeCustomWorldAgentSessionId) {
return;
}
- const generationText =
- buildCustomWorldCreatorIntentGenerationText(
- customWorldCreatorIntent,
- ).trim() || customWorldCreatorIntent.rawSettingText.trim();
- const settingText = customWorldSettingPreview.trim() || generationText;
-
- if (!generationText) {
- setCustomWorldError(
- customWorldCreatorIntent.sourceMode === 'card'
- ? '请至少填写一个世界锚点。'
- : '请先输入世界设置。',
- );
- return;
- }
-
- const abortController = new AbortController();
- customWorldAbortControllerRef.current?.abort();
- customWorldAbortControllerRef.current = abortController;
- setCustomWorldError(null);
- setGeneratedCustomWorldProfile(null);
- setCustomWorldProgress(null);
- setShowCustomWorldModal(false);
- setSelectionStage('custom-world-generating');
- setIsGeneratingCustomWorld(true);
-
try {
- const profile = await generateCustomWorldProfile(
- {
- settingText,
- creatorIntent: customWorldCreatorIntent as unknown as JsonObject,
- generationMode: customWorldGenerationMode,
- },
- {
- signal: abortController.signal,
- onProgress: setCustomWorldProgress,
- },
+ const response = await executeCustomWorldAgentAction(
+ activeCustomWorldAgentSessionId,
+ payload,
);
- if (abortController.signal.aborted) {
- return;
- }
- const persistedProfile = generatedCustomWorldProfile
- ? {
- ...profile,
- id: generatedCustomWorldProfile.id,
- }
- : profile;
- const savedProfiles = await upsertCustomWorldProfile(persistedProfile);
- setSavedCustomWorldProfiles(savedProfiles);
- setGeneratedCustomWorldProfile(null);
- setCustomWorldError(null);
- setCustomWorldProgress(null);
- setSelectionStage('world');
+ setActiveCustomWorldAgentOperation(response.operation);
+ syncCustomWorldAgentUiState(
+ activeCustomWorldAgentSessionId,
+ response.operation.operationId,
+ );
+ await refreshActiveCustomWorldAgentSession(activeCustomWorldAgentSessionId);
} catch (error) {
- if (abortController.signal.aborted) {
- setCustomWorldError('世界生成已中断。你可以返回修改设定,或重新开始。');
- return;
- }
- setCustomWorldError(
- error instanceof Error ? error.message : '生成自定义世界失败。',
+ setActiveCustomWorldAgentOperation(
+ createOperationErrorBanner(
+ error instanceof Error ? error.message : '执行共创动作失败。',
+ ),
);
- } finally {
- if (customWorldAbortControllerRef.current === abortController) {
- customWorldAbortControllerRef.current = null;
- }
- setIsGeneratingCustomWorld(false);
}
};
- const interruptCustomWorldGeneration = () => {
- if (!isGeneratingCustomWorld || !customWorldAbortControllerRef.current) {
+ const handleRefreshCustomWorldAgentSession = async () => {
+ if (!activeCustomWorldAgentSessionId) {
return;
}
- const confirmed = window.confirm(
- '确认中断当前世界生成吗?本轮未完成的内容不会保留。',
- );
- if (!confirmed) {
- return;
- }
+ try {
+ await refreshActiveCustomWorldAgentSession(activeCustomWorldAgentSessionId);
- customWorldAbortControllerRef.current.abort(new Error('世界生成已中断。'));
+ if (activeCustomWorldAgentOperationId) {
+ const operation = await getCustomWorldAgentOperation(
+ activeCustomWorldAgentSessionId,
+ activeCustomWorldAgentOperationId,
+ );
+ setActiveCustomWorldAgentOperation(operation);
+ }
+ } catch (error) {
+ setActiveCustomWorldAgentOperation(
+ createOperationErrorBanner(
+ error instanceof Error ? error.message : '刷新会话失败。',
+ ),
+ );
+ }
};
return (
@@ -526,7 +493,7 @@ export function PreGameSelectionFlow({
- {hasSavedGame && (
+ {hasSavedGame ? (
- )}
+ ) : null}
+
+
+
-
- {contact.label}
-
+ {contact.label}
{contact.value}
@@ -633,77 +590,15 @@ export function PreGameSelectionFlow({
-
+
- {savedCustomWorldCards.map((world) => (
-
-
-
-
- ))}
-
+
+ {savedCustomWorldCards.map((world) => (
+
+ ))}
)}
- {!gameState.worldType &&
- selectionStage === 'custom-world-generating' && (
-
- {
- void createCustomWorld();
- }}
- onInterrupt={interruptCustomWorldGeneration}
- />
-
- )}
+ {!gameState.worldType && selectionStage === 'custom-world-home' && (
+
+ setSelectionStage('world')}
+ onRetry={() => {
+ void refreshCustomWorldHomeData();
+ }}
+ onCreateNew={() => {
+ void handleCreateNewWork();
+ }}
+ onResumeDraft={(sessionId) => {
+ void handleResumeDraft(sessionId);
+ }}
+ onEnterPublished={(profileId) => {
+ void handleEnterPublished(profileId);
+ }}
+ />
+
+ )}
- {!gameState.worldType &&
- selectionStage === 'custom-world-result' &&
- generatedCustomWorldProfile && (
-
- {
- void createCustomWorld();
- }}
- onContinueExpand={() => {
- void continueExpandCustomWorld();
- }}
- onSave={() => {
- void saveGeneratedCustomWorld();
- }}
- />
-
- )}
+ {!gameState.worldType && selectionStage === 'custom-world-agent' && (
+
+ {
+ void leaveCustomWorldAgentWorkspace();
+ }}
+ onRefresh={() => {
+ void handleRefreshCustomWorldAgentSession();
+ }}
+ onSubmitMessage={(payload) => {
+ void handleSubmitCustomWorldAgentMessage(payload);
+ }}
+ onExecuteAction={(payload) => {
+ void handleExecuteCustomWorldAgentAction(payload);
+ }}
+ />
+
+ )}
-
{
- setCustomWorldCreatorIntent(value);
- if (customWorldError) setCustomWorldError(null);
- }}
- generationMode={customWorldGenerationMode}
- onGenerationModeChange={setCustomWorldGenerationMode}
- onClose={() => {
- if (isGeneratingCustomWorld) return;
- setShowCustomWorldModal(false);
- }}
- onSubmit={() => {
- void createCustomWorld();
- }}
- isGenerating={isGeneratingCustomWorld}
- progress={customWorldProgress?.overallProgress ?? 0}
- progressLabel={customWorldProgress?.phaseLabel ?? '正在准备生成'}
- error={customWorldError}
- />
-
= {}): Encounter {
+ return {
+ id: 'npc-trader',
+ kind: 'npc',
+ npcName: '梁伯',
+ npcDescription: '沿路摆摊的商人。',
+ npcAvatar: '梁',
+ context: '商贩',
+ ...overrides,
+ };
+}
+
+function createInventoryItem(
+ id: string,
+ name: string,
+ overrides: Partial = {},
+): InventoryItem {
+ return {
+ id,
+ name,
+ description: `${name} 的测试描述`,
+ quantity: 1,
+ category: 'misc',
+ rarity: 'common',
+ tags: [],
+ value: 1,
+ ...overrides,
+ };
+}
+
+function createModalState(overrides: Partial = {}): GameState {
+ return {
+ playerInventory: [
+ createInventoryItem('player-potion', '疗伤药'),
+ createInventoryItem('player-charm', '护符'),
+ ],
+ companions: [
+ {
+ npcId: 'npc-ally-1',
+ characterId: 'ally-1',
+ name: '阿青',
+ role: '同伴',
+ joinedAtAffinity: 12,
+ },
+ ],
+ ...overrides,
+ } as GameState;
+}
+
+describe('functionCatalog', () => {
+ it('keeps function documentation ids unique and source files resolvable', () => {
+ const documentationIds = ALL_FUNCTION_DOCUMENTATION.map((entry) => entry.id);
+
+ expect(new Set(documentationIds).size).toBe(documentationIds.length);
+ ALL_FUNCTION_DOCUMENTATION.forEach((entry) => {
+ expect(existsSync(entry.source), `${entry.id} -> ${entry.source}`).toBe(
+ true,
+ );
+ expect(getFunctionDocumentationById(entry.id)).toEqual(entry);
+ });
+ });
+
+ it('covers every server runtime function id with documentation metadata', () => {
+ SERVER_RUNTIME_FUNCTION_IDS.forEach((functionId) => {
+ expect(getFunctionDocumentationById(functionId)).not.toBeNull();
+ });
+ });
+
+ it('builds flow helper options with the expected function ids', () => {
+ const continueOption = buildContinueAdventureOption();
+ const campTravelOption = buildCampTravelHomeOption('竹林古道');
+
+ expect(continueOption.functionId).toBe(CONTINUE_ADVENTURE_FUNCTION.id);
+ expect(continueOption.priority).toBe(99);
+ expect(campTravelOption.functionId).toBe('camp_travel_home_scene');
+ expect(campTravelOption.actionText).toBe('前往 竹林古道');
+ expect(campTravelOption.detailText).toBe('离开营地,前往 竹林古道。');
+ });
+
+ it('builds npc preview talk options from the current encounter', () => {
+ const option = buildNpcPreviewTalkOption(createEncounter());
+
+ expect(option.functionId).toBe(NPC_PREVIEW_TALK_FUNCTION.id);
+ expect(option.actionText).toBe('与 梁伯 交谈');
+ expect(isNpcPreviewTalkOption(option)).toBe(true);
+ });
+
+ it('builds modal helper state for trade, gift and recruit flows', () => {
+ const state = createModalState();
+ const encounter = createEncounter();
+ const tradeModal = buildNpcTradeModalState(
+ state,
+ encounter,
+ '先看看货',
+ [
+ createInventoryItem('npc-herb', '止血草'),
+ createInventoryItem('npc-ore', '陨铁碎片'),
+ ],
+ );
+ const giftModal = buildNpcGiftModalState(
+ state,
+ encounter,
+ '送你一样东西',
+ 'player-charm',
+ );
+ const recruitModal = buildNpcRecruitModalState(
+ state,
+ encounter,
+ '谈谈同行的事',
+ );
+
+ expect(tradeModal.selectedNpcItemId).toBe('npc-herb');
+ expect(tradeModal.selectedPlayerItemId).toBe('player-potion');
+ expect(giftModal.selectedItemId).toBe('player-charm');
+ expect(recruitModal.selectedReleaseNpcId).toBe('npc-ally-1');
+ expect(shouldNpcRecruitOpenModal(2, 2)).toBe(true);
+ expect(shouldNpcRecruitOpenModal(1, 2)).toBe(false);
+ });
+
+ it('prefers the first tradable player item when zero-quantity items exist', () => {
+ const encounter = createEncounter();
+ const tradeModal = buildNpcTradeModalState(
+ createModalState({
+ playerInventory: [
+ createInventoryItem('empty-slot', '空槽位', { quantity: 0 }),
+ createInventoryItem('usable-item', '可售草药', { quantity: 2 }),
+ ],
+ }),
+ encounter,
+ '交易',
+ [createInventoryItem('npc-herb', '止血草')],
+ );
+
+ expect(tradeModal.selectedPlayerItemId).toBe('usable-item');
+ });
+});
diff --git a/src/data/functionCatalog/npc/npcTrade.ts b/src/data/functionCatalog/npc/npcTrade.ts
index 37921a60..8438ca8c 100644
--- a/src/data/functionCatalog/npc/npcTrade.ts
+++ b/src/data/functionCatalog/npc/npcTrade.ts
@@ -21,13 +21,18 @@ export function buildNpcTradeModalState(
actionText: string,
npcInventory: InventoryItem[],
): TradeModalState {
+ const selectedNpcItemId =
+ npcInventory.find((item) => item.quantity > 0)?.id ?? null;
+ const selectedPlayerItemId =
+ state.playerInventory.find((item) => item.quantity > 0)?.id ?? null;
+
return {
encounter,
actionText,
introText: buildNpcTradeModalIntroText(encounter),
mode: 'buy',
- selectedNpcItemId: npcInventory[0]?.id ?? null,
- selectedPlayerItemId: state.playerInventory[0]?.id ?? null,
+ selectedNpcItemId,
+ selectedPlayerItemId,
selectedQuantity: 1,
};
}
diff --git a/src/data/stateFunctions.test.ts b/src/data/stateFunctions.test.ts
new file mode 100644
index 00000000..3a4ea5f6
--- /dev/null
+++ b/src/data/stateFunctions.test.ts
@@ -0,0 +1,204 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ buildStateFunctionDefinitions,
+ getExecutableFunctions,
+ getFunctionById,
+ resolveFunctionOption,
+ sortStoryOptionsByPriority,
+} from './stateFunctions';
+import {
+ getScenePresetsByWorld,
+ getTravelScenePreset,
+ getWorldCampScenePreset,
+} from './scenePresets';
+import type { FunctionAvailabilityContext } from './stateFunctions';
+import type { SceneHostileNpc, StoryOption } from '../types';
+import { AnimationState, WorldType } from '../types';
+
+function createMonster(
+ overrides: Partial = {},
+): SceneHostileNpc {
+ return {
+ id: 'monster-wolf',
+ name: '山狼',
+ action: '朝你低吼逼近',
+ description: '一只逼近的山狼。',
+ animation: 'idle',
+ xMeters: 3.2,
+ yOffset: 0,
+ facing: 'left',
+ attackRange: 1.4,
+ speed: 1,
+ hp: 18,
+ maxHp: 18,
+ ...overrides,
+ };
+}
+
+function createContext(
+ overrides: Partial = {},
+): FunctionAvailabilityContext {
+ const defaultScene = getScenePresetsByWorld(WorldType.WUXIA)[0];
+ if (!defaultScene) {
+ throw new Error('Expected wuxia scene presets to exist');
+ }
+
+ return {
+ worldType: WorldType.WUXIA,
+ playerCharacter: null,
+ inBattle: false,
+ currentSceneId: defaultScene.id,
+ currentSceneName: defaultScene.name,
+ monsters: [],
+ playerHp: 80,
+ playerMaxHp: 100,
+ playerMana: 50,
+ playerMaxMana: 100,
+ ...overrides,
+ };
+}
+
+describe('stateFunctions', () => {
+ it('builds runtime state function definitions without inactive idle_follow_clue', () => {
+ const definitions = buildStateFunctionDefinitions();
+
+ expect(getFunctionById('idle_follow_clue', definitions)).toBeNull();
+ expect(getFunctionById('idle_explore_forward', definitions)?.text).toBe(
+ '继续向前探索',
+ );
+ });
+
+ it('prioritizes battle_recover_breath in high-pressure combat contexts', () => {
+ const executableFunctions = getExecutableFunctions(
+ createContext({
+ inBattle: true,
+ monsters: [createMonster()],
+ playerHp: 20,
+ playerMana: 10,
+ }),
+ );
+
+ expect(executableFunctions[0]?.id).toBe('battle_recover_breath');
+ });
+
+ it('hides idle_explore_forward in the world camp scene', () => {
+ const campScene = getWorldCampScenePreset(WorldType.WUXIA);
+ if (!campScene) {
+ throw new Error('Expected wuxia camp scene to exist');
+ }
+
+ const executableIds = getExecutableFunctions(
+ createContext({
+ currentSceneId: campScene.id,
+ currentSceneName: campScene.name,
+ }),
+ ).map((definition) => definition.id);
+
+ expect(executableIds).not.toContain('idle_explore_forward');
+ });
+
+ it('forces suggested action text for travel options and keeps custom battle copy', () => {
+ const idleContext = createContext();
+ const travelScene = getTravelScenePreset(
+ idleContext.worldType,
+ idleContext.currentSceneId,
+ );
+ const travelOption = resolveFunctionOption(
+ 'idle_travel_next_scene',
+ idleContext,
+ '这段自定义文案会被覆盖',
+ );
+ const battleOption = resolveFunctionOption(
+ 'battle_all_in_crush',
+ createContext({
+ inBattle: true,
+ monsters: [createMonster()],
+ }),
+ '顶上去狠狠干一轮',
+ );
+
+ expect(travelOption).not.toBeNull();
+ expect(travelOption?.actionText).toBe(
+ travelScene ? `前往${travelScene.name}` : '前往其他场景',
+ );
+ expect(battleOption?.actionText).toBe('顶上去狠狠干一轮');
+ });
+
+ it('sorts story options after preserving the first two model-locked entries', () => {
+ const options: StoryOption[] = [
+ {
+ functionId: 'npc_chat',
+ actionText: '继续交谈',
+ text: '继续交谈',
+ visuals: {
+ playerAnimation: AnimationState.IDLE,
+ playerMoveMeters: 0,
+ playerOffsetY: 0,
+ playerFacing: 'right',
+ scrollWorld: false,
+ monsterChanges: [],
+ },
+ },
+ {
+ functionId: 'battle_probe_pressure',
+ actionText: '稳住节奏',
+ text: '稳住节奏',
+ visuals: {
+ playerAnimation: AnimationState.IDLE,
+ playerMoveMeters: 0,
+ playerOffsetY: 0,
+ playerFacing: 'right',
+ scrollWorld: false,
+ monsterChanges: [],
+ },
+ },
+ {
+ functionId: 'npc_preview_talk',
+ actionText: '和对方说话',
+ text: '和对方说话',
+ visuals: {
+ playerAnimation: AnimationState.IDLE,
+ playerMoveMeters: 0,
+ playerOffsetY: 0,
+ playerFacing: 'right',
+ scrollWorld: false,
+ monsterChanges: [],
+ },
+ },
+ {
+ functionId: 'camp_travel_home_scene',
+ actionText: '离开营地',
+ text: '离开营地',
+ visuals: {
+ playerAnimation: AnimationState.IDLE,
+ playerMoveMeters: 0,
+ playerOffsetY: 0,
+ playerFacing: 'right',
+ scrollWorld: false,
+ monsterChanges: [],
+ },
+ },
+ ];
+
+ const sortedOptions = sortStoryOptionsByPriority(options);
+
+ expect(sortedOptions.map((option) => option.functionId)).toEqual([
+ 'npc_chat',
+ 'battle_probe_pressure',
+ 'camp_travel_home_scene',
+ 'npc_preview_talk',
+ ]);
+ });
+
+ it('removes battle_recover_breath when combat has no living monsters', () => {
+ const executableIds = getExecutableFunctions(
+ createContext({
+ inBattle: true,
+ monsters: [createMonster({ hp: 0 })],
+ }),
+ ).map((definition) => definition.id);
+
+ expect(executableIds).toEqual([]);
+ });
+});
diff --git a/src/data/stateFunctions.ts b/src/data/stateFunctions.ts
index 2e29cc12..780be036 100644
--- a/src/data/stateFunctions.ts
+++ b/src/data/stateFunctions.ts
@@ -390,7 +390,7 @@ function matchesCategory(
return !context.inBattle;
case 'recovery':
return definition.state === 'battle'
- ? context.inBattle
+ ? context.inBattle && hasAliveMonsters(context.monsters)
: !context.inBattle;
default:
return false;
diff --git a/src/hooks/story/choiceActions.test.ts b/src/hooks/story/choiceActions.test.ts
index 2c5ea6d6..b0217115 100644
--- a/src/hooks/story/choiceActions.test.ts
+++ b/src/hooks/story/choiceActions.test.ts
@@ -155,6 +155,106 @@ describe('createStoryChoiceActions', () => {
resolveServerRuntimeChoiceMock.mockReset();
});
+ it('reveals deferred adventure options when story_continue_adventure is selected', async () => {
+ const state = {
+ ...createBaseState(),
+ inBattle: false,
+ sceneHostileNpcs: [],
+ currentBattleNpcId: null,
+ currentNpcBattleMode: null,
+ };
+ const deferredOptions = [
+ {
+ functionId: 'idle_explore_forward',
+ actionText: '继续向前探索',
+ text: '继续向前探索',
+ visuals: {
+ playerAnimation: AnimationState.IDLE,
+ playerMoveMeters: 0,
+ playerOffsetY: 0,
+ playerFacing: 'right' as const,
+ scrollWorld: false,
+ monsterChanges: [],
+ },
+ },
+ ] satisfies StoryOption[];
+ const continueOption: StoryOption = {
+ functionId: 'story_continue_adventure',
+ actionText: '查看后续',
+ text: '查看后续',
+ visuals: {
+ playerAnimation: AnimationState.IDLE,
+ playerMoveMeters: 0,
+ playerOffsetY: 0,
+ playerFacing: 'right' as const,
+ scrollWorld: false,
+ monsterChanges: [],
+ },
+ };
+ const currentStory: StoryMoment = {
+ text: '对话已经完成',
+ options: [continueOption],
+ deferredOptions,
+ };
+ const setCurrentStory = vi.fn();
+ const generateStoryForState = vi.fn();
+ const handleNpcInteraction = vi.fn();
+
+ const { handleChoice } = createStoryChoiceActions({
+ gameState: state,
+ currentStory,
+ isLoading: false,
+ setGameState: vi.fn(),
+ setCurrentStory,
+ setAiError: vi.fn(),
+ setIsLoading: vi.fn(),
+ setBattleReward: vi.fn(),
+ buildResolvedChoiceState: vi.fn(),
+ playResolvedChoice: vi.fn(),
+ buildStoryContextFromState: vi.fn(),
+ buildStoryFromResponse: vi.fn((_, __, response) => response),
+ buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
+ generateStoryForState,
+ getAvailableOptionsForState: vi.fn(() => null),
+ getStoryGenerationHostileNpcs: vi.fn(() => []),
+ getResolvedSceneHostileNpcs: vi.fn(
+ (inputState: GameState) => inputState.sceneHostileNpcs,
+ ),
+ buildNpcStory: vi.fn(() => createFallbackStory()),
+ updateQuestLog: vi.fn((inputState: GameState) => inputState),
+ incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
+ getCampCompanionTravelScene: vi.fn(() => null),
+ startOpeningAdventure: vi.fn(),
+ enterNpcInteraction: vi.fn(() => false),
+ handleNpcInteraction,
+ handleTreasureInteraction: vi.fn(() => false),
+ commitGeneratedStateWithEncounterEntry: vi.fn(),
+ finalizeNpcBattleResult: vi.fn(() => null),
+ isContinueAdventureOption: vi.fn(
+ (option: StoryOption) =>
+ option.functionId === 'story_continue_adventure',
+ ),
+ isCampTravelHomeOption: vi.fn(() => false),
+ isInitialCompanionEncounter: neverNpcEncounter,
+ isRegularNpcEncounter: neverNpcEncounter,
+ isNpcEncounter: neverNpcEncounter,
+ npcPreviewTalkFunctionId: 'npc_preview_talk',
+ fallbackCompanionName: '同伴',
+ turnVisualMs: 820,
+ });
+
+ await handleChoice(continueOption);
+
+ expect(setCurrentStory).toHaveBeenCalledWith({
+ ...currentStory,
+ options: deferredOptions,
+ deferredOptions: undefined,
+ });
+ expect(generateStoryForState).not.toHaveBeenCalled();
+ expect(handleNpcInteraction).not.toHaveBeenCalled();
+ expect(resolveServerRuntimeChoiceMock).not.toHaveBeenCalled();
+ });
+
it('routes task5 story choices through the server runtime action endpoint', async () => {
const state = createBaseState();
const option = createBattleOption('npc_chat');
diff --git a/src/hooks/story/runtimeStoryCoordinator.test.ts b/src/hooks/story/runtimeStoryCoordinator.test.ts
index f55b5dfd..6cb43945 100644
--- a/src/hooks/story/runtimeStoryCoordinator.test.ts
+++ b/src/hooks/story/runtimeStoryCoordinator.test.ts
@@ -33,12 +33,13 @@ vi.mock('../../services/runtimeStoryService', async () => {
};
});
-import type { GameState, StoryMoment, StoryOption } from '../../types';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
+import type { GameState, StoryMoment, StoryOption } from '../../types';
+import { WorldType } from '../../types';
import {
loadServerRuntimeOptionCatalog,
- resumeServerRuntimeStory,
resolveServerRuntimeChoice,
+ resumeServerRuntimeStory,
} from './runtimeStoryCoordinator';
function createStory(text: string): StoryMoment {
@@ -55,6 +56,97 @@ function createGameState(): GameState {
} as GameState;
}
+function createRuntimeNpcBattleSnapshot(
+ overrides: Partial = {},
+) {
+ return {
+ version: 8,
+ savedAt: '2026-04-14T00:00:00.000Z',
+ bottomTab: 'adventure' as const,
+ currentStory: createStory('战斗中的服务端故事'),
+ gameState: {
+ worldType: WorldType.WUXIA,
+ customWorldProfile: null,
+ playerCharacter: {
+ id: 'hero',
+ },
+ runtimeActionVersion: 8,
+ runtimeSessionId: 'runtime-main',
+ currentScene: 'Story',
+ runtimeStats: {
+ playTimeMs: 0,
+ lastPlayTickAt: null,
+ hostileNpcsDefeated: 0,
+ questsAccepted: 0,
+ itemsUsed: 0,
+ scenesTraveled: 0,
+ },
+ storyHistory: [],
+ characterChats: {},
+ animationState: 'idle',
+ currentEncounter: {
+ kind: 'npc',
+ id: 'npc-bandit',
+ npcName: '断桥匪首',
+ npcDescription: '拦路的刀客',
+ context: '断桥口',
+ hostile: true,
+ },
+ npcInteractionActive: false,
+ currentScenePreset: null,
+ sceneHostileNpcs: [
+ {
+ id: 'npc-bandit',
+ name: '断桥匪首',
+ hp: 21,
+ maxHp: 32,
+ description: '拦路的刀客',
+ },
+ ],
+ playerX: 0,
+ playerOffsetY: 0,
+ playerFacing: 'right',
+ playerActionMode: 'idle',
+ scrollWorld: false,
+ inBattle: true,
+ playerHp: 42,
+ playerMaxHp: 50,
+ playerMana: 20,
+ playerMaxMana: 20,
+ playerSkillCooldowns: {},
+ activeCombatEffects: [],
+ playerCurrency: 0,
+ playerInventory: [],
+ playerEquipment: {
+ weapon: null,
+ armor: null,
+ relic: null,
+ },
+ npcStates: {
+ 'npc-bandit': {
+ affinity: -12,
+ chattedCount: 0,
+ helpUsed: false,
+ giftsGiven: 0,
+ inventory: [],
+ recruited: false,
+ },
+ },
+ quests: [],
+ roster: [],
+ companions: [],
+ currentBattleNpcId: 'npc-bandit',
+ currentNpcBattleMode: 'fight',
+ currentNpcBattleOutcome: null,
+ sparReturnEncounter: null,
+ sparPlayerHpBefore: null,
+ sparPlayerMaxHpBefore: null,
+ sparStoryHistoryBefore: null,
+ ...overrides,
+ } as unknown as GameState,
+ } as HydratedSavedGameSnapshot;
+}
+
describe('runtimeStoryCoordinator', () => {
beforeEach(() => {
putSaveSnapshotMock.mockReset();
@@ -363,4 +455,181 @@ describe('runtimeStoryCoordinator', () => {
expect(result.hydratedSnapshot).toBe(localHydratedSnapshot);
expect(result.nextStory).toBe(localHydratedSnapshot.currentStory);
});
+
+ it('rehydrates npc_fight server snapshots before returning runtime choices', async () => {
+ const gameState = createGameState();
+ const currentStory = createStory('当前故事');
+ const option = {
+ functionId: 'npc_fight',
+ actionText: '直接开战',
+ text: '直接开战',
+ interaction: {
+ kind: 'npc',
+ npcId: 'npc-bandit',
+ action: 'fight',
+ },
+ visuals: {
+ playerAnimation: 'idle',
+ playerMoveMeters: 0,
+ playerOffsetY: 0,
+ playerFacing: 'right',
+ scrollWorld: false,
+ monsterChanges: [],
+ },
+ } as StoryOption;
+ const rawBattleSnapshot = createRuntimeNpcBattleSnapshot();
+
+ resolveRuntimeStoryActionMock.mockResolvedValue({
+ sessionId: 'runtime-main',
+ serverVersion: 8,
+ viewModel: {
+ player: {
+ hp: 42,
+ maxHp: 50,
+ mana: 20,
+ maxMana: 20,
+ },
+ encounter: {
+ id: 'npc-bandit',
+ kind: 'npc',
+ npcName: '断桥匪首',
+ hostile: true,
+ affinity: -12,
+ recruited: false,
+ interactionActive: false,
+ battleMode: 'fight',
+ },
+ companions: [],
+ availableOptions: [
+ {
+ functionId: 'battle_probe_pressure',
+ actionText: '稳步试探',
+ scope: 'combat',
+ },
+ ],
+ status: {
+ inBattle: true,
+ npcInteractionActive: false,
+ currentNpcBattleMode: 'fight',
+ currentNpcBattleOutcome: null,
+ },
+ },
+ presentation: {
+ actionText: '直接开战',
+ resultText: '当前冲突正式转入战斗结算。',
+ storyText: '断桥匪首已经摆开架势。',
+ options: [],
+ },
+ patches: [],
+ snapshot: rawBattleSnapshot,
+ });
+
+ const result = await resolveServerRuntimeChoice({
+ gameState,
+ currentStory,
+ option,
+ });
+
+ expect(result.hydratedSnapshot.gameState.sceneHostileNpcs[0]).toEqual(
+ expect.objectContaining({
+ id: 'npc-bandit',
+ hp: 21,
+ maxHp: 32,
+ encounter: expect.objectContaining({
+ kind: 'npc',
+ id: 'npc-bandit',
+ npcName: '断桥匪首',
+ }),
+ }),
+ );
+ expect(result.nextStory.options[0]).toEqual(
+ expect.objectContaining({
+ functionId: 'battle_probe_pressure',
+ }),
+ );
+ });
+
+ it('rehydrates mid-battle snapshots when resuming a saved runtime story', async () => {
+ const localHydratedSnapshot = createRuntimeNpcBattleSnapshot({
+ runtimeActionVersion: 7,
+ });
+ const rawServerBattleSnapshot = createRuntimeNpcBattleSnapshot({
+ runtimeActionVersion: 8,
+ playerHp: 39,
+ sceneHostileNpcs: [
+ {
+ id: 'npc-bandit',
+ name: '断桥匪首',
+ hp: 14,
+ maxHp: 32,
+ description: '拦路的刀客',
+ },
+ ] as unknown as GameState['sceneHostileNpcs'],
+ });
+
+ getRuntimeStoryStateMock.mockResolvedValue({
+ sessionId: 'runtime-main',
+ serverVersion: 8,
+ viewModel: {
+ player: {
+ hp: 39,
+ maxHp: 50,
+ mana: 20,
+ maxMana: 20,
+ },
+ encounter: {
+ id: 'npc-bandit',
+ kind: 'npc',
+ npcName: '断桥匪首',
+ hostile: true,
+ affinity: -12,
+ recruited: false,
+ interactionActive: false,
+ battleMode: 'fight',
+ },
+ companions: [],
+ availableOptions: [
+ {
+ functionId: 'battle_guard_break',
+ actionText: '破架重击',
+ scope: 'combat',
+ },
+ ],
+ status: {
+ inBattle: true,
+ npcInteractionActive: false,
+ currentNpcBattleMode: 'fight',
+ currentNpcBattleOutcome: null,
+ },
+ },
+ presentation: {
+ actionText: '',
+ resultText: '',
+ storyText: '断桥匪首还在步步逼近。',
+ options: [],
+ },
+ patches: [],
+ snapshot: rawServerBattleSnapshot,
+ });
+
+ const result = await resumeServerRuntimeStory(localHydratedSnapshot);
+
+ expect(result.hydratedSnapshot.gameState.sceneHostileNpcs[0]).toEqual(
+ expect.objectContaining({
+ id: 'npc-bandit',
+ hp: 14,
+ maxHp: 32,
+ encounter: expect.objectContaining({
+ kind: 'npc',
+ id: 'npc-bandit',
+ }),
+ }),
+ );
+ expect(result.nextStory).not.toBeNull();
+ expect(result.nextStory?.options[0]).toEqual(
+ expect.objectContaining({
+ functionId: 'battle_guard_break',
+ }),
+ );
+ });
});
diff --git a/src/hooks/story/runtimeStoryCoordinator.ts b/src/hooks/story/runtimeStoryCoordinator.ts
index 0f55546e..94bd1af1 100644
--- a/src/hooks/story/runtimeStoryCoordinator.ts
+++ b/src/hooks/story/runtimeStoryCoordinator.ts
@@ -1,3 +1,4 @@
+import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import {
buildStoryMomentFromRuntimeOptions,
@@ -49,7 +50,7 @@ export async function loadServerRuntimeOptionCatalog(params: {
export async function resumeServerRuntimeStory(
snapshot: HydratedSavedGameSnapshot,
) {
- const hydratedSnapshot = snapshot;
+ const hydratedSnapshot = rehydrateSavedSnapshot(snapshot);
const shouldRefreshFromServer =
hydratedSnapshot.gameState.currentScene === 'Story' &&
Boolean(hydratedSnapshot.gameState.worldType) &&
@@ -65,7 +66,7 @@ export async function resumeServerRuntimeStory(
const response = await getRuntimeStoryState(
getRuntimeSessionId(hydratedSnapshot.gameState),
);
- const resumedSnapshot = response.snapshot;
+ const resumedSnapshot = rehydrateSavedSnapshot(response.snapshot);
const runtimeOptions = getRuntimeResponseOptions(response);
const nextStory =
response.presentation.storyText || runtimeOptions.length > 0
@@ -105,7 +106,7 @@ export async function resolveServerRuntimeChoice(params: {
: undefined,
payload: params.payload,
});
- const hydratedSnapshot = response.snapshot;
+ const hydratedSnapshot = rehydrateSavedSnapshot(response.snapshot);
return {
response,
diff --git a/src/hooks/story/storyGenerationState.test.ts b/src/hooks/story/storyGenerationState.test.ts
index 0cdc5d6b..7d9c7871 100644
--- a/src/hooks/story/storyGenerationState.test.ts
+++ b/src/hooks/story/storyGenerationState.test.ts
@@ -218,6 +218,26 @@ describe('storyGenerationState', () => {
expect(decision.modal.selectedQuantity).toBe(1);
});
+ it('skips zero-quantity player items when opening the trade modal', () => {
+ const decision = resolveNpcInteractionDecision(
+ {
+ ...createBaseState(),
+ playerInventory: [
+ createInventoryItem('empty-slot', 'Empty Slot', { quantity: 0 }),
+ createInventoryItem('player-herb', 'Herb'),
+ ],
+ },
+ createInteractionOption('trade'),
+ );
+
+ expect(decision.kind).toBe('trade_modal');
+ if (decision.kind !== 'trade_modal') {
+ throw new Error('Expected trade modal decision');
+ }
+
+ expect(decision.modal.selectedPlayerItemId).toBe('player-herb');
+ });
+
it('forces a recruit replacement modal when the active party is full', () => {
const state = {
...createBaseState(),
@@ -306,4 +326,3 @@ describe('storyGenerationState', () => {
expect(resolution.nextState.runtimeStats.scenesTraveled).toBe(1);
});
});
-
diff --git a/src/persistence/runtimeSnapshot.test.ts b/src/persistence/runtimeSnapshot.test.ts
index b88c8549..4d017449 100644
--- a/src/persistence/runtimeSnapshot.test.ts
+++ b/src/persistence/runtimeSnapshot.test.ts
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest';
import type { GameState, StoryMoment } from '../types';
+import { WorldType } from '../types';
import {
+ rehydrateSavedSnapshot,
resolveHydratedSnapshotState,
} from './runtimeSnapshot';
@@ -16,6 +18,97 @@ function createStory(
};
}
+function createHydratedBattleSnapshot(
+ gameStateOverrides: Partial = {},
+) {
+ return {
+ version: 3,
+ savedAt: '2026-04-14T00:00:00.000Z',
+ bottomTab: 'adventure' as const,
+ currentStory: createStory('战斗故事'),
+ gameState: {
+ worldType: WorldType.WUXIA,
+ customWorldProfile: null,
+ playerCharacter: {
+ id: 'hero',
+ },
+ runtimeActionVersion: 3,
+ runtimeSessionId: 'runtime-main',
+ currentScene: 'Story',
+ runtimeStats: {
+ playTimeMs: 0,
+ lastPlayTickAt: null,
+ hostileNpcsDefeated: 0,
+ questsAccepted: 0,
+ itemsUsed: 0,
+ scenesTraveled: 0,
+ },
+ storyHistory: [],
+ characterChats: {},
+ animationState: 'idle',
+ currentEncounter: {
+ kind: 'npc',
+ id: 'npc-fighter',
+ npcName: '断桥客',
+ npcDescription: '拦路的刀客',
+ context: '断桥对峙',
+ hostile: false,
+ },
+ npcInteractionActive: false,
+ currentScenePreset: null,
+ sceneHostileNpcs: [
+ {
+ id: 'npc-fighter',
+ name: '断桥客',
+ hp: 18,
+ maxHp: 32,
+ description: '拦路的刀客',
+ },
+ ],
+ playerX: 0,
+ playerOffsetY: 0,
+ playerFacing: 'right',
+ playerActionMode: 'idle',
+ scrollWorld: false,
+ inBattle: true,
+ playerHp: 40,
+ playerMaxHp: 40,
+ playerMana: 18,
+ playerMaxMana: 18,
+ playerSkillCooldowns: {},
+ activeCombatEffects: [],
+ playerCurrency: 0,
+ playerInventory: [],
+ playerEquipment: {
+ weapon: null,
+ armor: null,
+ relic: null,
+ },
+ npcStates: {
+ 'npc-fighter': {
+ affinity: -16,
+ chattedCount: 0,
+ helpUsed: false,
+ giftsGiven: 0,
+ inventory: [],
+ recruited: false,
+ },
+ },
+ quests: [],
+ roster: [],
+ companions: [],
+ currentBattleNpcId: 'npc-fighter',
+ currentNpcBattleMode: 'fight',
+ currentNpcBattleOutcome: null,
+ sparReturnEncounter: null,
+ sparPlayerHpBefore: null,
+ sparPlayerMaxHpBefore: null,
+ sparStoryHistoryBefore: null,
+ ...gameStateOverrides,
+ } as unknown as GameState,
+ };
+}
+
describe('runtimeSnapshot', () => {
it('keeps server-hydrated snapshots unchanged', () => {
const snapshot = {
@@ -72,4 +165,65 @@ describe('runtimeSnapshot', () => {
expect(hydrated.gameState.playerMaxMana).toBe(12);
expect(hydrated.gameState.playerMana).toBe(12);
});
+
+ it('rehydrates minimal npc battle snapshots into renderable combatants', () => {
+ const snapshot = createHydratedBattleSnapshot();
+
+ const hydrated = rehydrateSavedSnapshot(snapshot);
+ const hostileNpc = hydrated.gameState.sceneHostileNpcs[0];
+
+ expect(hydrated).not.toBe(snapshot);
+ expect(hostileNpc).toEqual(
+ expect.objectContaining({
+ id: 'npc-fighter',
+ name: '断桥客',
+ description: '拦路的刀客',
+ hp: 18,
+ maxHp: 32,
+ attackRange: expect.any(Number),
+ speed: expect.any(Number),
+ animation: 'idle',
+ renderKind: 'npc',
+ encounter: expect.objectContaining({
+ kind: 'npc',
+ id: 'npc-fighter',
+ npcName: '断桥客',
+ xMeters: expect.any(Number),
+ }),
+ }),
+ );
+ });
+
+ it('does not rewrite already renderable npc battle snapshots', () => {
+ const snapshot = createHydratedBattleSnapshot({
+ sceneHostileNpcs: [
+ {
+ id: 'npc-fighter',
+ name: '断桥客',
+ action: '摆开架势,随时准备出手',
+ description: '拦路的刀客',
+ animation: 'idle',
+ xMeters: 3.2,
+ yOffset: 0,
+ facing: 'left',
+ attackRange: 1.8,
+ speed: 7,
+ hp: 18,
+ maxHp: 32,
+ renderKind: 'npc',
+ encounter: {
+ kind: 'npc',
+ id: 'npc-fighter',
+ npcName: '断桥客',
+ npcDescription: '拦路的刀客',
+ npcAvatar: '',
+ context: '断桥对峙',
+ xMeters: 3.2,
+ },
+ },
+ ],
+ });
+
+ expect(rehydrateSavedSnapshot(snapshot)).toBe(snapshot);
+ });
});
diff --git a/src/persistence/runtimeSnapshot.ts b/src/persistence/runtimeSnapshot.ts
index 61174212..c42d469b 100644
--- a/src/persistence/runtimeSnapshot.ts
+++ b/src/persistence/runtimeSnapshot.ts
@@ -1,4 +1,15 @@
-import type { GameState, StoryMoment } from '../types';
+import {
+ buildInitialNpcState,
+ createNpcBattleMonster,
+} from '../data/npcInteractions';
+import type {
+ Encounter,
+ GameState,
+ NpcPersistentState,
+ SceneHostileNpc,
+ StoryMoment,
+} from '../types';
+import { WorldType } from '../types';
import type { BottomTab } from '../types/navigation';
import type {
HydratableGameState,
@@ -37,6 +48,199 @@ function createEmptyEquipmentLoadout() {
} satisfies GameState['playerEquipment'];
}
+function resolveHydrationWorldType(worldType: GameState['worldType']) {
+ const normalizedWorldType =
+ typeof worldType === 'string' ? worldType.toUpperCase() : worldType;
+
+ if (normalizedWorldType === WorldType.WUXIA) {
+ return WorldType.WUXIA;
+ }
+
+ if (normalizedWorldType === WorldType.XIANXIA) {
+ return WorldType.XIANXIA;
+ }
+
+ if (normalizedWorldType === WorldType.CUSTOM) {
+ return WorldType.CUSTOM;
+ }
+
+ return null;
+}
+
+function hasRenderableRuntimeNpcBattleFields(hostileNpc: SceneHostileNpc) {
+ const candidate = hostileNpc as Partial;
+
+ return Boolean(
+ candidate.encounter &&
+ typeof candidate.animation === 'string' &&
+ typeof candidate.xMeters === 'number' &&
+ typeof candidate.yOffset === 'number' &&
+ typeof candidate.facing === 'string' &&
+ typeof candidate.attackRange === 'number' &&
+ typeof candidate.speed === 'number',
+ );
+}
+
+function normalizeRuntimeBattleEncounter(
+ encounter: GameState['currentEncounter'],
+): Encounter | null {
+ if (!encounter || encounter.kind !== 'npc') {
+ return null;
+ }
+
+ const npcName =
+ typeof encounter.npcName === 'string' ? encounter.npcName.trim() : '';
+ if (!npcName) {
+ return null;
+ }
+
+ return {
+ ...encounter,
+ kind: 'npc',
+ npcName,
+ npcDescription:
+ typeof encounter.npcDescription === 'string'
+ ? encounter.npcDescription
+ : '',
+ npcAvatar:
+ typeof encounter.npcAvatar === 'string' ? encounter.npcAvatar : '',
+ context: typeof encounter.context === 'string' ? encounter.context : '',
+ hostile: true,
+ } satisfies Encounter;
+}
+
+function resolveRuntimeNpcBattleState(
+ gameState: Pick<
+ GameState,
+ | 'currentBattleNpcId'
+ | 'currentEncounter'
+ | 'customWorldProfile'
+ | 'npcStates'
+ | 'sceneHostileNpcs'
+ | 'worldType'
+ >,
+) {
+ const encounter = normalizeRuntimeBattleEncounter(gameState.currentEncounter);
+ if (!encounter || gameState.sceneHostileNpcs.length === 0) {
+ return null;
+ }
+
+ const npcStateKey =
+ gameState.currentBattleNpcId ??
+ encounter.id ??
+ encounter.npcName;
+ const npcState =
+ gameState.npcStates[npcStateKey] ??
+ buildInitialNpcState(
+ encounter,
+ resolveHydrationWorldType(gameState.worldType),
+ gameState as GameState,
+ );
+
+ return {
+ encounter,
+ npcState,
+ };
+}
+
+function hydrateRuntimeNpcBattleMonster(params: {
+ hostileNpc: SceneHostileNpc;
+ encounter: Encounter;
+ npcState: NpcPersistentState;
+ gameState: Pick;
+ battleMode: NonNullable;
+}) {
+ const template = createNpcBattleMonster(
+ params.encounter,
+ params.npcState,
+ params.battleMode,
+ {
+ worldType: resolveHydrationWorldType(params.gameState.worldType),
+ customWorldProfile: params.gameState.customWorldProfile,
+ },
+ );
+ const candidate = params.hostileNpc as Partial;
+ const xMeters =
+ typeof candidate.xMeters === 'number' ? candidate.xMeters : template.xMeters;
+ const yOffset =
+ typeof candidate.yOffset === 'number' ? candidate.yOffset : template.yOffset;
+
+ return {
+ ...template,
+ id:
+ typeof candidate.id === 'string' && candidate.id.trim()
+ ? candidate.id
+ : template.id,
+ name:
+ typeof candidate.name === 'string' && candidate.name.trim()
+ ? candidate.name
+ : template.name,
+ description:
+ typeof candidate.description === 'string'
+ ? candidate.description
+ : template.description,
+ hp: typeof candidate.hp === 'number' ? candidate.hp : template.hp,
+ maxHp:
+ typeof candidate.maxHp === 'number' ? candidate.maxHp : template.maxHp,
+ animation:
+ typeof candidate.animation === 'string'
+ ? candidate.animation
+ : template.animation,
+ xMeters,
+ yOffset,
+ facing:
+ candidate.facing === 'left' || candidate.facing === 'right'
+ ? candidate.facing
+ : template.facing,
+ attackRange:
+ typeof candidate.attackRange === 'number'
+ ? candidate.attackRange
+ : template.attackRange,
+ speed:
+ typeof candidate.speed === 'number' ? candidate.speed : template.speed,
+ encounter: {
+ ...template.encounter,
+ xMeters,
+ },
+ } satisfies SceneHostileNpc;
+}
+
+export function hydrateRuntimeNpcBattleGameState(
+ gameState: HydratedGameState,
+): HydratedGameState {
+ const battleMode = gameState.currentNpcBattleMode;
+
+ if (
+ gameState.inBattle !== true ||
+ (battleMode !== 'fight' && battleMode !== 'spar') ||
+ gameState.currentEncounter?.kind !== 'npc' ||
+ gameState.sceneHostileNpcs.length === 0 ||
+ gameState.sceneHostileNpcs.every(hasRenderableRuntimeNpcBattleFields)
+ ) {
+ return gameState;
+ }
+
+ const resolvedState = resolveRuntimeNpcBattleState(gameState);
+ if (!resolvedState) {
+ return gameState;
+ }
+
+ return {
+ ...gameState,
+ sceneHostileNpcs: gameState.sceneHostileNpcs.map((hostileNpc) =>
+ hasRenderableRuntimeNpcBattleFields(hostileNpc)
+ ? hostileNpc
+ : hydrateRuntimeNpcBattleMonster({
+ hostileNpc,
+ encounter: resolvedState.encounter,
+ npcState: resolvedState.npcState,
+ gameState,
+ battleMode,
+ }),
+ ),
+ };
+}
+
export function normalizeSavedStory(story: StoryMoment | null) {
if (!story) {
return null;
@@ -57,7 +261,7 @@ export function normalizeSavedGameState(gameState: GameState) {
const playerMaxHp = Math.max(1, hydratableState.playerMaxHp);
const playerMaxMana = Math.max(1, hydratableState.playerMaxMana);
- return {
+ return hydrateRuntimeNpcBattleGameState({
...hydratableState,
playerMaxHp,
playerHp: Math.min(hydratableState.playerHp, playerMaxHp),
@@ -72,7 +276,7 @@ export function normalizeSavedGameState(gameState: GameState) {
typeof hydratableState.runtimeSessionId === 'string'
? hydratableState.runtimeSessionId
: null,
- } satisfies HydratedGameState;
+ } satisfies HydratedGameState);
}
export function hydrateSnapshotState(snapshot: {
@@ -105,8 +309,23 @@ export function isHydratedSnapshotState(
);
}
+export function rehydrateSavedSnapshot(
+ snapshot: T,
+): T {
+ const hydratedGameState = hydrateRuntimeNpcBattleGameState(snapshot.gameState);
+
+ if (hydratedGameState === snapshot.gameState) {
+ return snapshot;
+ }
+
+ return {
+ ...snapshot,
+ gameState: hydratedGameState,
+ };
+}
+
export function resolveHydratedSnapshotState(snapshot: SnapshotState) {
return isHydratedSnapshotState(snapshot)
- ? snapshot
+ ? rehydrateSavedSnapshot(snapshot)
: hydrateSnapshotState(snapshot);
}
diff --git a/src/services/aiService.ts b/src/services/aiService.ts
index 90edf463..8364c0be 100644
--- a/src/services/aiService.ts
+++ b/src/services/aiService.ts
@@ -1,3 +1,14 @@
+import type {
+ CreateCustomWorldAgentSessionRequest,
+ CreateCustomWorldAgentSessionResponse,
+ CustomWorldAgentActionRequest,
+ CustomWorldAgentOperationRecord,
+ CustomWorldAgentSessionSnapshot,
+ CustomWorldDraftCardDetail,
+ GetCustomWorldAgentCardDetailResponse,
+ ListCustomWorldWorksResponse,
+ SendCustomWorldAgentMessageRequest,
+} from '../../packages/shared/src/contracts/customWorldAgent';
import type {
AnswerCustomWorldSessionQuestionRequest,
CreateCustomWorldSessionRequest,
@@ -365,10 +376,18 @@ export async function generateCustomWorldProfile(
});
}
+ return streamCustomWorldSessionGeneration(session.sessionId, options);
+}
+
+export async function streamCustomWorldSessionGeneration(
+ sessionId: string,
+ options: GenerateCustomWorldProfileOptions = {},
+): Promise {
const response = await fetchWithApiAuth(
- `${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(session.sessionId)}/generate/stream`,
+ `${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}/generate/stream`,
{
method: 'GET',
+ signal: options.signal,
},
);
if (!response.ok) {
@@ -487,6 +506,105 @@ export async function createCustomWorldSession(payload: {
);
}
+export async function listCustomWorldWorks() {
+ const response = await requestJson(
+ `${RUNTIME_API_BASE}/custom-world/works`,
+ {
+ method: 'GET',
+ },
+ '读取创作作品列表失败',
+ );
+
+ return Array.isArray(response?.items) ? response.items : [];
+}
+
+export async function createCustomWorldAgentSession(
+ payload: CreateCustomWorldAgentSessionRequest,
+) {
+ return requestJson(
+ `${RUNTIME_API_BASE}/custom-world/agent/sessions`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ },
+ '创建世界共创会话失败',
+ );
+}
+
+export async function getCustomWorldAgentSession(sessionId: string) {
+ return requestJson(
+ `${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}`,
+ {
+ method: 'GET',
+ },
+ '读取世界共创会话失败',
+ );
+}
+
+export async function sendCustomWorldAgentMessage(
+ sessionId: string,
+ payload: SendCustomWorldAgentMessageRequest,
+) {
+ return requestJson<{ operation: CustomWorldAgentOperationRecord }>(
+ `${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}/messages`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ },
+ '发送共创消息失败',
+ );
+}
+
+export async function executeCustomWorldAgentAction(
+ sessionId: string,
+ payload: CustomWorldAgentActionRequest,
+) {
+ return requestJson<{ operation: CustomWorldAgentOperationRecord }>(
+ `${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}/actions`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ },
+ '执行共创操作失败',
+ );
+}
+
+export async function getCustomWorldAgentOperation(
+ sessionId: string,
+ operationId: string,
+): Promise {
+ const response = await requestJson<{
+ operation?: CustomWorldAgentOperationRecord;
+ data?: CustomWorldAgentOperationRecord;
+ } & Partial>(
+ `${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}/operations/${encodeURIComponent(operationId)}`,
+ {
+ method: 'GET',
+ },
+ '读取共创操作状态失败',
+ );
+
+ return (response.operation ?? response.data ?? response) as CustomWorldAgentOperationRecord;
+}
+
+export async function getCustomWorldAgentCardDetail(
+ sessionId: string,
+ cardId: string,
+) {
+ const response = await requestJson(
+ `${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}/cards/${encodeURIComponent(cardId)}`,
+ {
+ method: 'GET',
+ },
+ '读取草稿卡详情失败',
+ );
+
+ return response.card as CustomWorldDraftCardDetail;
+}
+
export async function getCustomWorldSession(sessionId: string) {
return requestJson(
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}`,
diff --git a/src/services/authService.test.ts b/src/services/authService.test.ts
index 4ec88f83..3b617eb9 100644
--- a/src/services/authService.test.ts
+++ b/src/services/authService.test.ts
@@ -147,6 +147,30 @@ describe('authService auto auth', () => {
);
});
+ it('deduplicates concurrent auto auth requests', async () => {
+ requestJsonMock.mockResolvedValue({
+ token: 'jwt-auto',
+ user: {
+ id: 'user_auto',
+ username: 'guest_auto',
+ displayName: 'guest_auto',
+ phoneNumberMasked: null,
+ loginMethod: 'password',
+ bindingStatus: 'active',
+ wechatBound: false,
+ },
+ });
+
+ const [firstResult, secondResult] = await Promise.all([
+ ensureAutoAuthUser(),
+ ensureAutoAuthUser(),
+ ]);
+
+ expect(requestJsonMock).toHaveBeenCalledTimes(1);
+ expect(firstResult).toEqual(secondResult);
+ expect(getStoredAutoAuthCredentials()).toEqual(firstResult.credentials);
+ });
+
it('sends phone login code through the new auth endpoint', async () => {
requestJsonMock.mockResolvedValue({
ok: true,
diff --git a/src/services/authService.ts b/src/services/authService.ts
index 421bf754..ab8ca2cb 100644
--- a/src/services/authService.ts
+++ b/src/services/authService.ts
@@ -16,6 +16,7 @@ import type {
AuthRiskBlockSummary,
AuthSessionsResponse,
AuthSessionSummary,
+ AuthUser,
AuthWechatBindPhoneResponse,
AuthWechatStartResponse,
LogoutResponse,
@@ -53,6 +54,11 @@ export type ConsumedAuthCallback = {
error: string | null;
};
+let pendingAutoAuthUser: Promise<{
+ user: AuthUser;
+ credentials: AutoAuthCredentials;
+}> | null = null;
+
export function normalizePhoneInput(phoneInput: string) {
return phoneInput.replace(/[^\d+]/gu, '').trim();
}
@@ -248,14 +254,22 @@ export async function authEntryWithStoredCredentials(
}
export async function ensureAutoAuthUser() {
- const credentials =
- getStoredAutoAuthCredentials() ?? createAutoAuthCredentials();
- const user = await authEntryWithStoredCredentials(credentials);
+ pendingAutoAuthUser ??= (async () => {
+ const credentials =
+ getStoredAutoAuthCredentials() ?? createAutoAuthCredentials();
+ const user = await authEntryWithStoredCredentials(credentials);
- return {
- user,
- credentials,
- };
+ return {
+ user,
+ credentials,
+ };
+ })();
+
+ try {
+ return await pendingAutoAuthUser;
+ } finally {
+ pendingAutoAuthUser = null;
+ }
}
export function consumeAuthCallbackResult(): ConsumedAuthCallback | null {
diff --git a/src/services/customWorldAgentUiState.test.ts b/src/services/customWorldAgentUiState.test.ts
new file mode 100644
index 00000000..5b8e7134
--- /dev/null
+++ b/src/services/customWorldAgentUiState.test.ts
@@ -0,0 +1,61 @@
+import { expect, test } from 'vitest';
+
+import {
+ clearCustomWorldAgentUiState,
+ readCustomWorldAgentUiState,
+ writeCustomWorldAgentUiState,
+} from './customWorldAgentUiState';
+
+function createMemoryStorage() {
+ const store = new Map();
+
+ return {
+ getItem(key: string) {
+ return store.get(key) ?? null;
+ },
+ setItem(key: string, value: string) {
+ store.set(key, value);
+ },
+ removeItem(key: string) {
+ store.delete(key);
+ },
+ };
+}
+
+test('custom world agent ui state reads from query first and persists to session storage', () => {
+ const sessionStorage = createMemoryStorage();
+ let currentUrl = '/play';
+ const env = {
+ location: {
+ pathname: '/play',
+ get search() {
+ const [, search = ''] = currentUrl.split('?');
+ return search ? `?${search}` : '';
+ },
+ },
+ history: {
+ replaceState: (_data: unknown, _unused: string, nextUrl?: string | URL | null) => {
+ currentUrl = String(nextUrl ?? '/play');
+ },
+ },
+ sessionStorage,
+ };
+
+ writeCustomWorldAgentUiState(
+ {
+ activeSessionId: 'session-1',
+ activeOperationId: 'operation-1',
+ },
+ env,
+ );
+
+ expect(currentUrl).toContain('customWorldSessionId=session-1');
+ expect(currentUrl).toContain('customWorldOperationId=operation-1');
+ expect(readCustomWorldAgentUiState(env)).toEqual({
+ activeSessionId: 'session-1',
+ activeOperationId: 'operation-1',
+ });
+
+ clearCustomWorldAgentUiState(env);
+ expect(readCustomWorldAgentUiState(env)).toEqual({});
+});
diff --git a/src/services/customWorldAgentUiState.ts b/src/services/customWorldAgentUiState.ts
new file mode 100644
index 00000000..051c0868
--- /dev/null
+++ b/src/services/customWorldAgentUiState.ts
@@ -0,0 +1,139 @@
+import type { CustomWorldAgentUiState } from '../types';
+
+export const CUSTOM_WORLD_AGENT_SESSION_QUERY_KEY = 'customWorldSessionId';
+export const CUSTOM_WORLD_AGENT_OPERATION_QUERY_KEY = 'customWorldOperationId';
+export const CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY =
+ 'genarrative.custom-world-agent-ui.v1';
+
+type CustomWorldAgentUiEnvironment = {
+ location?: {
+ pathname: string;
+ search: string;
+ } | null;
+ history?: {
+ replaceState: (
+ data: unknown,
+ unused: string,
+ url?: string | URL | null,
+ ) => void;
+ } | null;
+ sessionStorage?: Pick | null;
+};
+
+function resolveEnvironment(
+ env?: CustomWorldAgentUiEnvironment,
+): Required {
+ if (env) {
+ return {
+ location: env.location ?? null,
+ history: env.history ?? null,
+ sessionStorage: env.sessionStorage ?? null,
+ };
+ }
+
+ if (typeof window === 'undefined') {
+ return {
+ location: null,
+ history: null,
+ sessionStorage: null,
+ };
+ }
+
+ return {
+ location: window.location,
+ history: window.history,
+ sessionStorage: window.sessionStorage,
+ };
+}
+
+function normalizeValue(value: unknown) {
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
+}
+
+export function readCustomWorldAgentUiState(
+ env?: CustomWorldAgentUiEnvironment,
+): CustomWorldAgentUiState {
+ const resolved = resolveEnvironment(env);
+ const params = new URLSearchParams(resolved.location?.search ?? '');
+ const stateFromQuery: CustomWorldAgentUiState = {
+ activeSessionId: normalizeValue(
+ params.get(CUSTOM_WORLD_AGENT_SESSION_QUERY_KEY),
+ ),
+ activeOperationId: normalizeValue(
+ params.get(CUSTOM_WORLD_AGENT_OPERATION_QUERY_KEY),
+ ),
+ };
+
+ if (stateFromQuery.activeSessionId || stateFromQuery.activeOperationId) {
+ return stateFromQuery;
+ }
+
+ const storedValue = resolved.sessionStorage?.getItem(
+ CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY,
+ );
+ if (!storedValue) {
+ return {};
+ }
+
+ try {
+ const parsed = JSON.parse(storedValue) as CustomWorldAgentUiState;
+ return {
+ activeSessionId: normalizeValue(parsed.activeSessionId),
+ activeOperationId: normalizeValue(parsed.activeOperationId),
+ };
+ } catch {
+ resolved.sessionStorage?.removeItem(CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY);
+ return {};
+ }
+}
+
+export function writeCustomWorldAgentUiState(
+ state: CustomWorldAgentUiState,
+ env?: CustomWorldAgentUiEnvironment,
+) {
+ const resolved = resolveEnvironment(env);
+ const activeSessionId = normalizeValue(state.activeSessionId);
+ const activeOperationId = normalizeValue(state.activeOperationId);
+ const nextState: CustomWorldAgentUiState = {
+ activeSessionId,
+ activeOperationId,
+ };
+
+ if (resolved.location && resolved.history?.replaceState) {
+ const params = new URLSearchParams(resolved.location.search);
+ if (activeSessionId) {
+ params.set(CUSTOM_WORLD_AGENT_SESSION_QUERY_KEY, activeSessionId);
+ } else {
+ params.delete(CUSTOM_WORLD_AGENT_SESSION_QUERY_KEY);
+ }
+
+ if (activeOperationId) {
+ params.set(CUSTOM_WORLD_AGENT_OPERATION_QUERY_KEY, activeOperationId);
+ } else {
+ params.delete(CUSTOM_WORLD_AGENT_OPERATION_QUERY_KEY);
+ }
+
+ const search = params.toString();
+ const nextUrl = search
+ ? `${resolved.location.pathname}?${search}`
+ : resolved.location.pathname;
+ resolved.history.replaceState(null, '', nextUrl);
+ }
+
+ if (resolved.sessionStorage) {
+ if (activeSessionId || activeOperationId) {
+ resolved.sessionStorage.setItem(
+ CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY,
+ JSON.stringify(nextState),
+ );
+ } else {
+ resolved.sessionStorage.removeItem(CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY);
+ }
+ }
+}
+
+export function clearCustomWorldAgentUiState(
+ env?: CustomWorldAgentUiEnvironment,
+) {
+ writeCustomWorldAgentUiState({}, env);
+}
diff --git a/src/services/customWorldCreatorIntent.test.ts b/src/services/customWorldCreatorIntent.test.ts
index 7135c280..8a6bf98d 100644
--- a/src/services/customWorldCreatorIntent.test.ts
+++ b/src/services/customWorldCreatorIntent.test.ts
@@ -2,8 +2,11 @@ import { describe, expect, it } from 'vitest';
import {
buildCustomWorldAnchorPackFromIntent,
+ buildPendingClarifications,
buildCustomWorldCreatorIntentDisplayText,
createEmptyCustomWorldCreatorIntent,
+ evaluateCustomWorldCreatorIntentReadiness,
+ mergeCustomWorldCreatorIntent,
normalizeCustomWorldCreatorIntent,
} from './customWorldCreatorIntent';
@@ -32,7 +35,9 @@ describe('customWorldCreatorIntent', () => {
const summary = buildCustomWorldCreatorIntentDisplayText(intent);
- expect(summary).toContain('世界一句话:一个会被灵潮反复改写地形的边境世界。');
+ expect(summary).toContain(
+ '世界一句话:一个会被灵潮反复改写地形的边境世界。',
+ );
expect(summary).toContain('主题关键词:边境、灵潮');
expect(summary).toContain('关键角色:沈砺 / 灰炬向导');
});
@@ -90,4 +95,72 @@ describe('customWorldCreatorIntent', () => {
expect(intent?.keyCharacters[0]?.name).toBe('梁砺');
expect(intent?.keyCharacters[0]?.id).toBeTruthy();
});
+
+ it('merges creator intent patches without dropping unrelated anchors', () => {
+ const baseIntent = {
+ ...createEmptyCustomWorldCreatorIntent('freeform'),
+ worldHook: '潮雾会改写地形的列岛世界。',
+ playerPremise: '玩家是失职返乡的守灯人。',
+ };
+
+ const merged = mergeCustomWorldCreatorIntent(baseIntent, {
+ coreConflicts: ['守灯会与沉船商盟争夺航道解释权'],
+ toneDirectives: ['冷峻'],
+ });
+ if (!merged) {
+ throw new Error('expected merged creator intent');
+ }
+
+ expect(merged.worldHook).toBe('潮雾会改写地形的列岛世界。');
+ expect(merged.playerPremise).toBe('玩家是失职返乡的守灯人。');
+ expect(merged.coreConflicts).toEqual(['守灯会与沉船商盟争夺航道解释权']);
+ expect(merged.toneDirectives).toEqual(['冷峻']);
+ });
+
+ it('replaces array anchors when a patch marks explicit rewrite fields', () => {
+ const merged = mergeCustomWorldCreatorIntent(
+ {
+ ...createEmptyCustomWorldCreatorIntent('freeform'),
+ themeKeywords: ['海岛', '旧案'],
+ coreConflicts: ['守灯会与沉船商盟争夺航道解释权'],
+ },
+ {
+ themeKeywords: ['宫廷', '悬疑'],
+ coreConflicts: ['王庭继承人与旧灯塔盟约对抗'],
+ replaceFields: ['themeKeywords', 'coreConflicts'],
+ },
+ );
+ if (!merged) {
+ throw new Error('expected merged creator intent');
+ }
+
+ expect(merged.themeKeywords).toEqual(['宫廷', '悬疑']);
+ expect(merged.coreConflicts).toEqual(['王庭继承人与旧灯塔盟约对抗']);
+ });
+
+ it('evaluates readiness and limits clarifications to top gaps', () => {
+ const readiness = evaluateCustomWorldCreatorIntentReadiness({
+ ...createEmptyCustomWorldCreatorIntent('freeform'),
+ worldHook: '一个被潮雾切开的列岛世界。',
+ themeKeywords: ['海岛'],
+ toneDirectives: ['冷峻'],
+ coreConflicts: ['旧灯塔正在被沉船商盟接管'],
+ });
+ const clarifications = buildPendingClarifications(
+ {
+ ...createEmptyCustomWorldCreatorIntent('freeform'),
+ worldHook: '一个被潮雾切开的列岛世界。',
+ themeKeywords: ['海岛'],
+ toneDirectives: ['冷峻'],
+ coreConflicts: ['旧灯塔正在被沉船商盟接管'],
+ },
+ readiness,
+ );
+
+ expect(readiness.isReady).toBe(false);
+ expect(readiness.completedKeys).toContain('world_hook');
+ expect(readiness.missingKeys).toContain('player_premise');
+ expect(clarifications).toHaveLength(3);
+ expect(clarifications[0]?.targetKey).toBe('player_premise');
+ });
});
diff --git a/src/services/customWorldCreatorIntent.ts b/src/services/customWorldCreatorIntent.ts
index 39b98c97..91e547a1 100644
--- a/src/services/customWorldCreatorIntent.ts
+++ b/src/services/customWorldCreatorIntent.ts
@@ -1,3 +1,7 @@
+import type {
+ CreatorIntentReadiness,
+ CustomWorldPendingClarification,
+} from '../../packages/shared/src/contracts/customWorldAgent';
import type {
ActorAnchor,
CreatorCharacterSeed,
@@ -10,6 +14,51 @@ import type {
LandmarkAnchor,
} from '../types';
+export type CustomWorldCreatorIntentPatch = Partial<
+ Pick<
+ CustomWorldCreatorIntent,
+ | 'rawSettingText'
+ | 'worldHook'
+ | 'themeKeywords'
+ | 'toneDirectives'
+ | 'playerPremise'
+ | 'openingSituation'
+ | 'coreConflicts'
+ | 'keyFactions'
+ | 'keyCharacters'
+ | 'keyLandmarks'
+ | 'iconicElements'
+ | 'forbiddenDirectives'
+ >
+>;
+
+export type CustomWorldCreatorIntentReplaceableField =
+ | 'rawSettingText'
+ | 'worldHook'
+ | 'themeKeywords'
+ | 'toneDirectives'
+ | 'playerPremise'
+ | 'openingSituation'
+ | 'coreConflicts'
+ | 'keyFactions'
+ | 'keyCharacters'
+ | 'keyLandmarks'
+ | 'iconicElements'
+ | 'forbiddenDirectives';
+
+export type CustomWorldCreatorIntentPatchInput =
+ CustomWorldCreatorIntentPatch & {
+ replaceFields?: CustomWorldCreatorIntentReplaceableField[];
+ };
+
+type CreatorIntentReadinessKey =
+ | 'world_hook'
+ | 'player_premise'
+ | 'theme_and_tone'
+ | 'core_conflict'
+ | 'relationship_seed'
+ | 'iconic_element';
+
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
@@ -19,13 +68,10 @@ function toStringArray(value: unknown, maxCount = 8) {
return [];
}
- return [
- ...new Set(
- value
- .map((item) => toText(item))
- .filter(Boolean),
- ),
- ].slice(0, maxCount);
+ return [...new Set(value.map((item) => toText(item)).filter(Boolean))].slice(
+ 0,
+ maxCount,
+ );
}
function slugify(value: string) {
@@ -72,7 +118,9 @@ function normalizeCreatorFactionSeed(
}
return {
- id: toText(item.id) || createSeedId('creator-faction', name || publicGoal, index),
+ id:
+ toText(item.id) ||
+ createSeedId('creator-faction', name || publicGoal, index),
name,
publicGoal,
tension,
@@ -167,6 +215,126 @@ function normalizeAnchorArray(
.slice(0, maxCount);
}
+function mergeStringArray(
+ base: string[],
+ patch: string[] | undefined,
+ maxCount: number,
+) {
+ if (!patch || patch.length === 0) {
+ return [...base];
+ }
+
+ return [
+ ...new Set([...base, ...patch.map((item) => toText(item)).filter(Boolean)]),
+ ].slice(0, maxCount);
+}
+
+function mergeNarrativeText(base: string, patch: string | undefined) {
+ const nextText = toText(patch);
+ if (!nextText) {
+ return base;
+ }
+ if (!base) {
+ return nextText;
+ }
+ if (base.includes(nextText)) {
+ return base;
+ }
+
+ return `${base}\n${nextText}`.trim();
+}
+
+function mergeSeedArray(
+ base: T[],
+ patch: T[] | undefined,
+ maxCount: number,
+ mergeEntry: (current: T, next: T) => T,
+) {
+ if (!patch || patch.length === 0) {
+ return [...base];
+ }
+
+ const nextItems = [...base];
+
+ patch.forEach((entry) => {
+ const normalizedName = toText(entry.name);
+ const existingIndex = nextItems.findIndex(
+ (item) =>
+ item.id === entry.id ||
+ (normalizedName &&
+ toText(item.name).toLowerCase() === normalizedName.toLowerCase()),
+ );
+
+ if (existingIndex >= 0) {
+ const currentItem = nextItems[existingIndex];
+ if (!currentItem) {
+ nextItems.push(entry);
+ return;
+ }
+
+ nextItems[existingIndex] = mergeEntry(currentItem, entry);
+ return;
+ }
+
+ nextItems.push(entry);
+ });
+
+ return nextItems.slice(0, maxCount);
+}
+
+function mergeCharacterSeed(
+ current: CreatorCharacterSeed,
+ next: CreatorCharacterSeed,
+): CreatorCharacterSeed {
+ return {
+ ...current,
+ ...next,
+ id: next.id || current.id,
+ name: toText(next.name) || current.name,
+ role: toText(next.role) || current.role,
+ publicMask: toText(next.publicMask) || current.publicMask,
+ hiddenHook: toText(next.hiddenHook) || current.hiddenHook,
+ relationToPlayer: toText(next.relationToPlayer) || current.relationToPlayer,
+ notes: toText(next.notes) || current.notes,
+ locked:
+ typeof next.locked === 'boolean' ? next.locked : Boolean(current.locked),
+ };
+}
+
+function mergeFactionSeed(
+ current: CreatorFactionSeed,
+ next: CreatorFactionSeed,
+): CreatorFactionSeed {
+ return {
+ ...current,
+ ...next,
+ id: next.id || current.id,
+ name: toText(next.name) || current.name,
+ publicGoal: toText(next.publicGoal) || current.publicGoal,
+ tension: toText(next.tension) || current.tension,
+ notes: toText(next.notes) || current.notes,
+ locked:
+ typeof next.locked === 'boolean' ? next.locked : Boolean(current.locked),
+ };
+}
+
+function mergeLandmarkSeed(
+ current: CreatorLandmarkSeed,
+ next: CreatorLandmarkSeed,
+): CreatorLandmarkSeed {
+ return {
+ ...current,
+ ...next,
+ id: next.id || current.id,
+ name: toText(next.name) || current.name,
+ purpose: toText(next.purpose) || current.purpose,
+ mood: toText(next.mood) || current.mood,
+ secret: toText(next.secret) || current.secret,
+ locked:
+ typeof next.locked === 'boolean' ? next.locked : Boolean(current.locked),
+ };
+}
+
export function createEmptyCustomWorldCreatorIntent(
sourceMode: CustomWorldCreatorInputMode = 'freeform',
): CustomWorldCreatorIntent {
@@ -259,6 +427,221 @@ export function normalizeCustomWorldCreatorIntent(
};
}
+export function mergeCustomWorldCreatorIntent(
+ current: CustomWorldCreatorIntent | null | undefined,
+ patch: CustomWorldCreatorIntentPatchInput | null | undefined,
+ fallbackMode: CustomWorldCreatorInputMode = 'freeform',
+) {
+ if (!patch) {
+ return current
+ ? normalizeCustomWorldCreatorIntent(current, fallbackMode)
+ : createEmptyCustomWorldCreatorIntent(fallbackMode);
+ }
+
+ const base =
+ normalizeCustomWorldCreatorIntent(current, fallbackMode) ??
+ createEmptyCustomWorldCreatorIntent(fallbackMode);
+ const replaceFields = new Set(patch.replaceFields ?? []);
+ const patchIntent =
+ normalizeCustomWorldCreatorIntent(
+ {
+ sourceMode: base.sourceMode,
+ ...patch,
+ },
+ base.sourceMode,
+ ) ?? createEmptyCustomWorldCreatorIntent(base.sourceMode);
+
+ return {
+ ...base,
+ rawSettingText: replaceFields.has('rawSettingText')
+ ? toText(patchIntent.rawSettingText) || base.rawSettingText
+ : mergeNarrativeText(base.rawSettingText, patchIntent.rawSettingText),
+ worldHook: toText(patchIntent.worldHook) || base.worldHook,
+ themeKeywords: replaceFields.has('themeKeywords')
+ ? [...patchIntent.themeKeywords]
+ : mergeStringArray(base.themeKeywords, patchIntent.themeKeywords, 8),
+ toneDirectives: replaceFields.has('toneDirectives')
+ ? [...patchIntent.toneDirectives]
+ : mergeStringArray(base.toneDirectives, patchIntent.toneDirectives, 8),
+ playerPremise: toText(patchIntent.playerPremise) || base.playerPremise,
+ openingSituation:
+ toText(patchIntent.openingSituation) || base.openingSituation,
+ coreConflicts: replaceFields.has('coreConflicts')
+ ? [...patchIntent.coreConflicts]
+ : mergeStringArray(base.coreConflicts, patchIntent.coreConflicts, 6),
+ keyFactions: replaceFields.has('keyFactions')
+ ? [...patchIntent.keyFactions]
+ : mergeSeedArray(
+ base.keyFactions,
+ patchIntent.keyFactions,
+ 6,
+ mergeFactionSeed,
+ ),
+ keyCharacters: replaceFields.has('keyCharacters')
+ ? [...patchIntent.keyCharacters]
+ : mergeSeedArray(
+ base.keyCharacters,
+ patchIntent.keyCharacters,
+ 8,
+ mergeCharacterSeed,
+ ),
+ keyLandmarks: replaceFields.has('keyLandmarks')
+ ? [...patchIntent.keyLandmarks]
+ : mergeSeedArray(
+ base.keyLandmarks,
+ patchIntent.keyLandmarks,
+ 8,
+ mergeLandmarkSeed,
+ ),
+ iconicElements: replaceFields.has('iconicElements')
+ ? [...patchIntent.iconicElements]
+ : mergeStringArray(base.iconicElements, patchIntent.iconicElements, 8),
+ forbiddenDirectives: replaceFields.has('forbiddenDirectives')
+ ? [...patchIntent.forbiddenDirectives]
+ : mergeStringArray(
+ base.forbiddenDirectives,
+ patchIntent.forbiddenDirectives,
+ 8,
+ ),
+ } satisfies CustomWorldCreatorIntent;
+}
+
+export function evaluateCustomWorldCreatorIntentReadiness(
+ intent: CustomWorldCreatorIntent | null | undefined,
+): CreatorIntentReadiness {
+ const normalized =
+ normalizeCustomWorldCreatorIntent(intent) ??
+ createEmptyCustomWorldCreatorIntent('freeform');
+ const completedKeys: CreatorIntentReadinessKey[] = [];
+ const missingKeys: CreatorIntentReadinessKey[] = [];
+ const relationshipReady = normalized.keyCharacters.some(
+ (entry) =>
+ Boolean(toText(entry.name)) &&
+ Boolean(toText(entry.relationToPlayer) || toText(entry.hiddenHook)),
+ );
+
+ const keyChecks: Array<{
+ key: CreatorIntentReadinessKey;
+ ready: boolean;
+ }> = [
+ {
+ key: 'world_hook',
+ ready:
+ normalized.worldHook.trim().length >= 8 ||
+ normalized.rawSettingText.trim().length >= 24,
+ },
+ {
+ key: 'player_premise',
+ ready: Boolean(
+ normalized.playerPremise.trim() && normalized.openingSituation.trim(),
+ ),
+ },
+ {
+ key: 'theme_and_tone',
+ ready:
+ normalized.themeKeywords.length >= 1 &&
+ normalized.toneDirectives.length >= 1,
+ },
+ {
+ key: 'core_conflict',
+ ready: normalized.coreConflicts.length >= 1,
+ },
+ {
+ key: 'relationship_seed',
+ ready: normalized.keyCharacters.length >= 1 && relationshipReady,
+ },
+ {
+ key: 'iconic_element',
+ ready: normalized.iconicElements.length >= 1,
+ },
+ ];
+
+ keyChecks.forEach((entry) => {
+ if (entry.ready) {
+ completedKeys.push(entry.key);
+ return;
+ }
+
+ missingKeys.push(entry.key);
+ });
+
+ return {
+ isReady: missingKeys.length === 0,
+ completedKeys,
+ missingKeys,
+ };
+}
+
+const CLARIFICATION_DEFINITIONS: Array<{
+ targetKey: CreatorIntentReadinessKey;
+ priority: number;
+ label: string;
+ question: string;
+}> = [
+ {
+ targetKey: 'world_hook',
+ priority: 1,
+ label: '世界一句话',
+ question:
+ '先用一句话说清,这个世界最独特的核心幻想是什么?可以直接给我一句钉住调性的描述。',
+ },
+ {
+ targetKey: 'player_premise',
+ priority: 2,
+ label: '玩家身份与开局',
+ question:
+ '玩家是谁,故事开场时正卡在什么局面里?你可以直接把身份和开局困境一起告诉我。',
+ },
+ {
+ targetKey: 'core_conflict',
+ priority: 3,
+ label: '核心冲突',
+ question:
+ '现在这个世界最主要的冲突是什么?最好是能立刻推动剧情的那种对抗或危机。',
+ },
+ {
+ targetKey: 'theme_and_tone',
+ priority: 4,
+ label: '主题气质',
+ question:
+ '你想要它整体更偏什么主题和气质?比如克制、压迫、浪漫、冷峻,或者明确不要什么。',
+ },
+ {
+ targetKey: 'relationship_seed',
+ priority: 5,
+ label: '关键关系钩子',
+ question:
+ '给我一个最值得写的关键人物种子就行,他和玩家是什么关系,或者身上藏着什么暗线?',
+ },
+ {
+ targetKey: 'iconic_element',
+ priority: 6,
+ label: '标志性要素',
+ question:
+ '这个世界有什么一眼就能认出来的标志性元素、意象或硬规则?先给 1 到 2 个就够。',
+ },
+];
+
+export function buildPendingClarifications(
+ intent: CustomWorldCreatorIntent | null | undefined,
+ readiness = evaluateCustomWorldCreatorIntentReadiness(intent),
+) {
+ return CLARIFICATION_DEFINITIONS.filter((entry) =>
+ readiness.missingKeys.includes(entry.targetKey),
+ )
+ .sort((left, right) => left.priority - right.priority)
+ .slice(0, 3)
+ .map(
+ (entry): CustomWorldPendingClarification => ({
+ id: entry.targetKey,
+ label: entry.label,
+ question: entry.question,
+ targetKey: entry.targetKey,
+ priority: entry.priority,
+ }),
+ );
+}
+
export function normalizeCustomWorldLockState(
value: unknown,
): CustomWorldLockState {
@@ -308,8 +691,7 @@ export function hasMeaningfulCustomWorldCreatorIntent(
) {
return Boolean(
intent &&
- (
- intent.rawSettingText ||
+ (intent.rawSettingText ||
intent.worldHook ||
intent.themeKeywords.length > 0 ||
intent.toneDirectives.length > 0 ||
@@ -320,8 +702,7 @@ export function hasMeaningfulCustomWorldCreatorIntent(
intent.keyCharacters.length > 0 ||
intent.keyLandmarks.length > 0 ||
intent.iconicElements.length > 0 ||
- intent.forbiddenDirectives.length > 0
- ),
+ intent.forbiddenDirectives.length > 0),
);
}
@@ -348,7 +729,9 @@ export function buildCustomWorldCreatorIntentDisplayText(
'关键势力',
intent?.keyFactions
.map((entry) =>
- [entry.name, entry.publicGoal, entry.tension].filter(Boolean).join(' / '),
+ [entry.name, entry.publicGoal, entry.tension]
+ .filter(Boolean)
+ .join(' / '),
)
.filter(Boolean)
.join(';') || '',
@@ -477,7 +860,9 @@ function buildCharacterAnchorSummary(entry: CreatorCharacterSeed): ActorAnchor {
};
}
-function buildLandmarkAnchorSummary(entry: CreatorLandmarkSeed): LandmarkAnchor {
+function buildLandmarkAnchorSummary(
+ entry: CreatorLandmarkSeed,
+): LandmarkAnchor {
const summary = clampText(
[entry.purpose, entry.mood, entry.secret ? `秘密 ${entry.secret}` : '']
.filter(Boolean)
@@ -500,9 +885,15 @@ export function buildCustomWorldAnchorPackFromIntent(
}
const lockedAnchorIds = [
- ...(intent?.keyCharacters.filter((entry) => entry.locked).map((entry) => entry.id) ?? []),
- ...(intent?.keyLandmarks.filter((entry) => entry.locked).map((entry) => entry.id) ?? []),
- ...(intent?.keyFactions.filter((entry) => entry.locked).map((entry) => entry.id) ?? []),
+ ...(intent?.keyCharacters
+ .filter((entry) => entry.locked)
+ .map((entry) => entry.id) ?? []),
+ ...(intent?.keyLandmarks
+ .filter((entry) => entry.locked)
+ .map((entry) => entry.id) ?? []),
+ ...(intent?.keyFactions
+ .filter((entry) => entry.locked)
+ .map((entry) => entry.id) ?? []),
];
return {
@@ -515,18 +906,24 @@ export function buildCustomWorldAnchorPackFromIntent(
240,
),
lockedAnchorIds,
- keyConflictSummaries: intent?.coreConflicts.map((entry) => clampText(entry, 48)) ?? [],
+ keyConflictSummaries:
+ intent?.coreConflicts.map((entry) => clampText(entry, 48)) ?? [],
keyFactionSummaries:
intent?.keyFactions.map((entry) =>
clampText(
- [entry.name, entry.publicGoal, entry.tension].filter(Boolean).join(';'),
+ [entry.name, entry.publicGoal, entry.tension]
+ .filter(Boolean)
+ .join(';'),
72,
),
) ?? [],
keyCharacterAnchors:
- intent?.keyCharacters.map((entry) => buildCharacterAnchorSummary(entry)) ?? [],
+ intent?.keyCharacters.map((entry) =>
+ buildCharacterAnchorSummary(entry),
+ ) ?? [],
keyLandmarkAnchors:
- intent?.keyLandmarks.map((entry) => buildLandmarkAnchorSummary(entry)) ?? [],
+ intent?.keyLandmarks.map((entry) => buildLandmarkAnchorSummary(entry)) ??
+ [],
motifDirectives: [
...(intent?.themeKeywords ?? []),
...(intent?.toneDirectives ?? []),
diff --git a/src/services/runtimeStoryService.ts b/src/services/runtimeStoryService.ts
index 9d082835..b1dd2f24 100644
--- a/src/services/runtimeStoryService.ts
+++ b/src/services/runtimeStoryService.ts
@@ -9,13 +9,14 @@ import {
SERVER_RUNTIME_FUNCTION_IDS,
TASK5_RUNTIME_FUNCTION_IDS,
} from '../../packages/shared/src/contracts/story';
+import { rehydrateSavedSnapshot } from '../persistence/runtimeSnapshot';
import type {
HydratedGameState,
HydratedSavedGameSnapshot,
} from '../persistence/runtimeSnapshotTypes';
import type { GameState, StoryMoment, StoryOption } from '../types';
import { AnimationState } from '../types';
-import { requestJson, type ApiRetryOptions } from './apiClient';
+import { type ApiRetryOptions,requestJson } from './apiClient';
const RUNTIME_STORY_API_BASE = '/api/runtime/story';
const DEFAULT_SESSION_ID = 'runtime-main';
@@ -171,12 +172,17 @@ export async function getRuntimeStoryState(
sessionId: string,
options: RuntimeStoryServiceOptions = {},
) {
- return requestRuntimeStoryJson(
+ const response = await requestRuntimeStoryJson(
`/state/${encodeURIComponent(sessionId || DEFAULT_SESSION_ID)}`,
{ method: 'GET' },
'读取运行时故事状态失败',
options,
);
+
+ return {
+ ...response,
+ snapshot: rehydrateSavedSnapshot(response.snapshot as HydratedSavedGameSnapshot),
+ } satisfies RuntimeStoryResponse;
}
export async function resolveRuntimeStoryAction(
@@ -189,7 +195,7 @@ export async function resolveRuntimeStoryAction(
},
options: RuntimeStoryServiceOptions = {},
) {
- return requestRuntimeStoryJson(
+ const response = await requestRuntimeStoryJson(
'/actions/resolve',
{
method: 'POST',
@@ -211,8 +217,15 @@ export async function resolveRuntimeStoryAction(
'执行运行时动作失败',
options,
);
+
+ return {
+ ...response,
+ snapshot: rehydrateSavedSnapshot(response.snapshot as HydratedSavedGameSnapshot),
+ } satisfies RuntimeStoryResponse;
}
export function getRuntimeActionSnapshot(response: RuntimeStoryResponse) {
- return response.snapshot as HydratedSavedGameSnapshot;
+ return rehydrateSavedSnapshot(
+ response.snapshot as HydratedSavedGameSnapshot,
+ );
}
diff --git a/src/services/storageService.ts b/src/services/storageService.ts
index 453af3bb..b843ea44 100644
--- a/src/services/storageService.ts
+++ b/src/services/storageService.ts
@@ -1,3 +1,6 @@
+import type {
+ ListCustomWorldWorksResponse,
+} from '../../packages/shared/src/contracts/customWorldAgent';
import type {
BasicOkResult,
CustomWorldLibraryResponse,
@@ -6,6 +9,7 @@ import type {
import type {
SavedGameSnapshotInput,
} from '../persistence/gameSaveStorage';
+import { rehydrateSavedSnapshot } from '../persistence/runtimeSnapshot';
import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes';
import type { CustomWorldProfile } from '../types';
import { type ApiRetryOptions,requestJson } from './apiClient';
@@ -51,19 +55,21 @@ function requestRuntimeJson(
}
export async function getSaveSnapshot(options: RuntimeRequestOptions = {}) {
- return requestRuntimeJson(
+ const snapshot = await requestRuntimeJson(
'/save/snapshot',
{ method: 'GET' },
'读取存档失败',
options,
);
+
+ return snapshot ? rehydrateSavedSnapshot(snapshot) : null;
}
export async function putSaveSnapshot(
snapshot: SavedGameSnapshotInput,
options: RuntimeRequestOptions = {},
) {
- return requestRuntimeJson(
+ const savedSnapshot = await requestRuntimeJson(
'/save/snapshot',
{
method: 'PUT',
@@ -73,6 +79,8 @@ export async function putSaveSnapshot(
'保存存档失败',
options,
);
+
+ return rehydrateSavedSnapshot(savedSnapshot);
}
export async function deleteSaveSnapshot(options: RuntimeRequestOptions = {}) {
@@ -120,6 +128,17 @@ export async function listCustomWorldLibrary(options: RuntimeRequestOptions = {}
return Array.isArray(response?.profiles) ? response.profiles : [];
}
+export async function listCustomWorldWorks(options: RuntimeRequestOptions = {}) {
+ const response = await requestRuntimeJson(
+ '/custom-world/works',
+ { method: 'GET' },
+ '读取创作作品列表失败',
+ options,
+ );
+
+ return Array.isArray(response?.items) ? response.items : [];
+}
+
export async function upsertCustomWorldProfile(
profile: CustomWorldProfile,
options: RuntimeRequestOptions = {},
@@ -161,6 +180,7 @@ export const runtimeStorageClient = {
getSettings,
putSettings,
listCustomWorldLibrary,
+ listCustomWorldWorks,
upsertCustomWorldProfile,
deleteCustomWorldProfile,
};
diff --git a/src/types/customWorld.ts b/src/types/customWorld.ts
index 6b4d9d6a..6649afa7 100644
--- a/src/types/customWorld.ts
+++ b/src/types/customWorld.ts
@@ -24,6 +24,10 @@ import type {
export type CustomWorldCreatorInputMode = 'freeform' | 'card';
export type CustomWorldGenerationMode = 'fast' | 'full';
export type CustomWorldGenerationStatus = 'key_only' | 'complete';
+export type CustomWorldAgentUiState = {
+ activeSessionId?: string | null;
+ activeOperationId?: string | null;
+};
export interface CreatorFactionSeed {
id: string;