Merge branch 'codex/dev' into codex/backend-rewrite-spacetimedb
# Conflicts: # docs/technical/README.md # server-node/src/modules/assets/qwenSpriteRoutes.ts # src/components/CustomWorldResultView.test.tsx # src/components/CustomWorldResultView.tsx # src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx # src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx # src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx # src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx # src/components/rpg-entry/RpgEntryCharacterSelectView.tsx # src/components/rpg-entry/RpgEntryHomeView.tsx # src/services/apiClient.ts # src/tools/QwenSpriteSheetTool.tsx
This commit is contained in:
25
src/components/rpg-entry/RpgEntryBrandLogo.tsx
Normal file
25
src/components/rpg-entry/RpgEntryBrandLogo.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
export interface RpgEntryBrandLogoProps {
|
||||
className?: string;
|
||||
decorative?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* RPG 入口品牌标识真实入口。
|
||||
* 第三批收口后,入口主链直接落在 `rpg-entry` 平台品牌组件。
|
||||
*/
|
||||
export function RpgEntryBrandLogo({
|
||||
className = '',
|
||||
decorative = false,
|
||||
}: RpgEntryBrandLogoProps) {
|
||||
return (
|
||||
<span
|
||||
className={`platform-brand-logo ${className}`.trim()}
|
||||
role={decorative ? undefined : 'img'}
|
||||
aria-hidden={decorative || undefined}
|
||||
aria-label={decorative ? undefined : '叙世 GENARRATIVE'}
|
||||
>
|
||||
<span className="platform-brand-logo__title">叙世</span>
|
||||
<span className="platform-brand-logo__subtitle">GENARRATIVE</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
228
src/components/rpg-entry/RpgEntryCharacterSelectView.test.tsx
Normal file
228
src/components/rpg-entry/RpgEntryCharacterSelectView.test.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
buildCustomWorldPlayableCharacters,
|
||||
} from '../../data/characterPresets';
|
||||
import {
|
||||
type Character,
|
||||
type CustomWorldProfile,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import { RpgEntryCharacterSelectView } from './RpgEntryCharacterSelectView';
|
||||
|
||||
vi.mock('../../data/characterPresets', () => ({
|
||||
ROLE_TEMPLATE_CHARACTERS: [],
|
||||
buildCustomWorldPlayableCharacters: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../CharacterAnimator', () => ({
|
||||
CharacterAnimator: ({ character }: { character: Character }) => (
|
||||
<div>{character.name}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../CharacterDetailModal', () => ({
|
||||
CharacterDetailModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../SelectionCustomizationModals', () => ({
|
||||
CharacterDraftModal: () => null,
|
||||
}));
|
||||
|
||||
function createCharacter(name: string, title: string): Character {
|
||||
return {
|
||||
id: '',
|
||||
name,
|
||||
title,
|
||||
description: `${name}的定位描述`,
|
||||
backstory: `${name}的背景故事`,
|
||||
personality: `${name} 冷静 果断`,
|
||||
gender: 'female',
|
||||
portrait: `/portraits/${name}.png`,
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 11,
|
||||
intelligence: 12,
|
||||
spirit: 13,
|
||||
},
|
||||
skills: [],
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('custom world character selection stays stable when character ids are empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleConfirm = vi.fn();
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
vi.mocked(buildCustomWorldPlayableCharacters).mockReturnValue([
|
||||
createCharacter('沈砺', '潮锋斥候'),
|
||||
createCharacter('闻潮', '雾海哨兵'),
|
||||
]);
|
||||
|
||||
HTMLElement.prototype.scrollTo = function scrollTo(
|
||||
this: HTMLElement,
|
||||
options?: ScrollToOptions | number,
|
||||
) {
|
||||
if (typeof options === 'object' && options) {
|
||||
if (typeof options.left === 'number') {
|
||||
this.scrollLeft = options.left;
|
||||
}
|
||||
if (typeof options.top === 'number') {
|
||||
this.scrollTop = options.top;
|
||||
}
|
||||
}
|
||||
this.dispatchEvent(new Event('scroll'));
|
||||
};
|
||||
|
||||
vi
|
||||
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.mockImplementation(function mockGetBoundingClientRect(this: HTMLElement) {
|
||||
if ((this as HTMLElement).dataset.carouselCard === 'true') {
|
||||
return {
|
||||
width: 240,
|
||||
height: 360,
|
||||
top: 0,
|
||||
right: 240,
|
||||
bottom: 360,
|
||||
left: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
});
|
||||
|
||||
render(
|
||||
<RpgEntryCharacterSelectView
|
||||
worldType={WorldType.CUSTOM}
|
||||
customWorldProfile={{
|
||||
attributeSchema: {
|
||||
id: 'schema:custom:test',
|
||||
worldId: 'custom:test',
|
||||
schemaVersion: 1,
|
||||
generatedFrom: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: '潮城',
|
||||
settingSummary: '潮水与迷雾交织的港城。',
|
||||
tone: '潮湿、危险、带着试探。',
|
||||
conflictCore: '在涨落之间抢先一步。',
|
||||
},
|
||||
slots: [
|
||||
{
|
||||
slotId: 'axis_a',
|
||||
name: '潮骨',
|
||||
definition: '扛住潮压与正面冲击的底子。',
|
||||
positiveSignals: [],
|
||||
negativeSignals: [],
|
||||
combatUseText: '顶住正面浪涌。',
|
||||
socialUseText: '给人能扛事的可靠感。',
|
||||
explorationUseText: '在风浪里稳住自己。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_b',
|
||||
name: '浪步',
|
||||
definition: '顺潮借势、换位穿行的能力。',
|
||||
positiveSignals: [],
|
||||
negativeSignals: [],
|
||||
combatUseText: '借势切线。',
|
||||
socialUseText: '谈吐灵活。',
|
||||
explorationUseText: '穿越复杂地形。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_c',
|
||||
name: '舟识',
|
||||
definition: '辨流向、识潮眼的能力。',
|
||||
positiveSignals: [],
|
||||
negativeSignals: [],
|
||||
combatUseText: '抓住变化时机。',
|
||||
socialUseText: '看懂局势留白。',
|
||||
explorationUseText: '辨认水路与遗痕。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_d',
|
||||
name: '潮魄',
|
||||
definition: '在剧烈变化中仍敢推进的胆气。',
|
||||
positiveSignals: [],
|
||||
negativeSignals: [],
|
||||
combatUseText: '顶着压力推进。',
|
||||
socialUseText: '在冲突里压住场子。',
|
||||
explorationUseText: '面对异变继续前探。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_e',
|
||||
name: '契汐',
|
||||
definition: '与人和约定形成牵引的能力。',
|
||||
positiveSignals: [],
|
||||
negativeSignals: [],
|
||||
combatUseText: '借协同形成连锁。',
|
||||
socialUseText: '结盟、安抚与交换。',
|
||||
explorationUseText: '从旧约中打开局面。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_f',
|
||||
name: '回澜',
|
||||
definition: '在漫长消耗中回稳状态的能力。',
|
||||
positiveSignals: [],
|
||||
negativeSignals: [],
|
||||
combatUseText: '久战不乱。',
|
||||
socialUseText: '遇事沉静。',
|
||||
explorationUseText: '在恶劣天气里保有余力。',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as CustomWorldProfile}
|
||||
onBack={() => {}}
|
||||
onConfirm={handleConfirm}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/潮骨:/u)).toBeTruthy();
|
||||
expect(screen.queryByText(/力量:/u)).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /闻潮/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /查看闻潮的详情/u })).toBeTruthy();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /进入营地/u }));
|
||||
|
||||
expect(handleConfirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: '闻潮',
|
||||
title: '雾海哨兵',
|
||||
}),
|
||||
);
|
||||
|
||||
const duplicateKeyCalls = consoleErrorSpy.mock.calls.filter((call) =>
|
||||
call.some(
|
||||
(arg) =>
|
||||
typeof arg === 'string'
|
||||
&& arg.includes('Encountered two children with the same key'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(duplicateKeyCalls).toHaveLength(0);
|
||||
});
|
||||
510
src/components/rpg-entry/RpgEntryCharacterSelectView.tsx
Normal file
510
src/components/rpg-entry/RpgEntryCharacterSelectView.tsx
Normal file
@@ -0,0 +1,510 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
buildCharacterAttributeProfile,
|
||||
} from '../../data/attributeProfileGenerator';
|
||||
import {
|
||||
resolveAttributeSchema,
|
||||
resolveCharacterAttributeProfile,
|
||||
} from '../../data/attributeResolver';
|
||||
import {
|
||||
buildCustomWorldPlayableCharacters,
|
||||
ROLE_TEMPLATE_CHARACTERS,
|
||||
} from '../../data/characterPresets';
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type CustomWorldProfile,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
import { CharacterAnimator } from '../CharacterAnimator';
|
||||
import { CharacterDetailModal } from '../CharacterDetailModal';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { CharacterDraftModal } from '../SelectionCustomizationModals';
|
||||
|
||||
type CharacterSelectionDraft = {
|
||||
name: string;
|
||||
backstory: string;
|
||||
};
|
||||
|
||||
type CarouselOrientation = 'horizontal' | 'vertical';
|
||||
|
||||
export type RpgEntryCharacterSelectViewProps = {
|
||||
worldType: WorldType;
|
||||
customWorldProfile: CustomWorldProfile | null;
|
||||
onBack: () => void;
|
||||
onConfirm: (character: Character) => void;
|
||||
};
|
||||
|
||||
const CHARACTER_DISPLAY: Record<
|
||||
string,
|
||||
{ name: string; title: string; role: string; tags: string[] }
|
||||
> = {
|
||||
'sword-princess': {
|
||||
name: '剑姬',
|
||||
title: '皇家之刃',
|
||||
role: '先锋',
|
||||
tags: ['剑术', '压制', '突进'],
|
||||
},
|
||||
'archer-hero': {
|
||||
name: '弓手英雄',
|
||||
title: '风之射手',
|
||||
role: '远程',
|
||||
tags: ['射程', '齐射', '风筝'],
|
||||
},
|
||||
'girl-hero': {
|
||||
name: '双刃刺客',
|
||||
title: '暗影之牙',
|
||||
role: '刺客',
|
||||
tags: ['连击', '冲锋', '机动'],
|
||||
},
|
||||
'punch-hero': {
|
||||
name: '战拳',
|
||||
title: '近战大师',
|
||||
role: '战士',
|
||||
tags: ['爆发', '格斗', '仇恨'],
|
||||
},
|
||||
'fighter-4': {
|
||||
name: '装甲长矛手',
|
||||
title: '重装先锋',
|
||||
role: '前线',
|
||||
tags: ['守护', '稳定', '突破'],
|
||||
},
|
||||
};
|
||||
|
||||
function getGenderLabel(gender: Character['gender']) {
|
||||
if (gender === 'female') return '女性';
|
||||
if (gender === 'male') return '男性';
|
||||
return '未知';
|
||||
}
|
||||
|
||||
function clampIndex(value: number, length: number) {
|
||||
if (length <= 0) return 0;
|
||||
return Math.max(0, Math.min(length - 1, value));
|
||||
}
|
||||
|
||||
function getCharacterMeta(
|
||||
character: Character,
|
||||
overrides: Partial<Pick<Character, 'name' | 'title'>> = {},
|
||||
) {
|
||||
const preset = CHARACTER_DISPLAY[character.id];
|
||||
return {
|
||||
name: overrides.name ?? character.name ?? preset?.name,
|
||||
title: overrides.title ?? character.title ?? preset?.title,
|
||||
role: preset?.role ?? '角色',
|
||||
tags: preset?.tags ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildSelectionCharacterKey(character: Character, index: number) {
|
||||
const normalizedId = character.id.trim();
|
||||
if (normalizedId) {
|
||||
return normalizedId;
|
||||
}
|
||||
|
||||
const fallbackSeed =
|
||||
character.name.trim()
|
||||
|| character.title.trim()
|
||||
|| character.description.trim()
|
||||
|| 'character';
|
||||
|
||||
return `selection-character-${index}-${fallbackSeed}`;
|
||||
}
|
||||
|
||||
function applyCharacterSelectionDraft(
|
||||
character: Character | null,
|
||||
draft?: CharacterSelectionDraft | null,
|
||||
) {
|
||||
if (!character || !draft) return character;
|
||||
return {
|
||||
...character,
|
||||
name: draft.name,
|
||||
backstory: draft.backstory,
|
||||
} satisfies Character;
|
||||
}
|
||||
|
||||
function getPersonalityTags(personality: string) {
|
||||
const tags = personality
|
||||
.split(/[,.!?/\\\s]+/u)
|
||||
.map(tag => tag.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return tags.length > 0 ? [...new Set(tags)] : [personality.trim()].filter(Boolean);
|
||||
}
|
||||
|
||||
function readCarouselProgress(container: HTMLDivElement, orientation: CarouselOrientation) {
|
||||
const firstCard = container.querySelector<HTMLElement>('[data-carousel-card="true"]');
|
||||
if (!firstCard) return 0;
|
||||
|
||||
const styles = window.getComputedStyle(container);
|
||||
const gap = parseFloat(
|
||||
orientation === 'vertical'
|
||||
? styles.rowGap || styles.gap || '0'
|
||||
: styles.columnGap || styles.gap || '0',
|
||||
);
|
||||
const stride = orientation === 'vertical'
|
||||
? firstCard.getBoundingClientRect().height + gap
|
||||
: firstCard.getBoundingClientRect().width + gap;
|
||||
|
||||
if (stride <= 0) return 0;
|
||||
return orientation === 'vertical' ? container.scrollTop / stride : container.scrollLeft / stride;
|
||||
}
|
||||
|
||||
function scrollCarouselToIndex(container: HTMLDivElement | null, index: number, orientation: CarouselOrientation) {
|
||||
if (!container) return;
|
||||
const firstCard = container.querySelector<HTMLElement>('[data-carousel-card="true"]');
|
||||
if (!firstCard) return;
|
||||
|
||||
const styles = window.getComputedStyle(container);
|
||||
const gap = parseFloat(
|
||||
orientation === 'vertical'
|
||||
? styles.rowGap || styles.gap || '0'
|
||||
: styles.columnGap || styles.gap || '0',
|
||||
);
|
||||
const stride = orientation === 'vertical'
|
||||
? firstCard.getBoundingClientRect().height + gap
|
||||
: firstCard.getBoundingClientRect().width + gap;
|
||||
|
||||
const behavior: ScrollBehavior = 'smooth';
|
||||
if (orientation === 'vertical') {
|
||||
container.scrollTo({top: stride * index, behavior});
|
||||
} else {
|
||||
container.scrollTo({left: stride * index, behavior});
|
||||
}
|
||||
}
|
||||
|
||||
function getCharacterCardStyle(index: number, progress: number) {
|
||||
const delta = index - progress;
|
||||
const distance = Math.min(Math.abs(delta), 2.4);
|
||||
|
||||
if (distance < 0.08) {
|
||||
return {
|
||||
opacity: 1,
|
||||
zIndex: 30,
|
||||
transform: 'none',
|
||||
filter: 'none',
|
||||
willChange: 'auto' as const,
|
||||
};
|
||||
}
|
||||
|
||||
const scale = 1 - distance * 0.12;
|
||||
const opacity = 1 - distance * 0.28;
|
||||
const rotate = delta * 8;
|
||||
const translateY = distance * 12;
|
||||
const translateX = delta * -12;
|
||||
|
||||
return {
|
||||
opacity,
|
||||
zIndex: 30 - Math.round(distance * 10),
|
||||
transform: `translate3d(${translateX}px, ${translateY}px, 0) scale(${scale}) rotate(${rotate}deg)`,
|
||||
filter: distance < 0.08 ? 'none' : `saturate(${1 - distance * 0.08})`,
|
||||
};
|
||||
}
|
||||
|
||||
export function RpgEntryCharacterSelectView({
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
onBack,
|
||||
onConfirm,
|
||||
}: RpgEntryCharacterSelectViewProps) {
|
||||
const selectionCharacters = useMemo(
|
||||
() => (customWorldProfile ? buildCustomWorldPlayableCharacters(customWorldProfile) : ROLE_TEMPLATE_CHARACTERS),
|
||||
[customWorldProfile],
|
||||
);
|
||||
const selectionEntries = useMemo(
|
||||
() =>
|
||||
selectionCharacters.map((character, index) => ({
|
||||
character,
|
||||
selectionKey: buildSelectionCharacterKey(character, index),
|
||||
})),
|
||||
[selectionCharacters],
|
||||
);
|
||||
const [selectedCharacterKey, setSelectedCharacterKey] = useState(selectionEntries[0]?.selectionKey ?? '');
|
||||
const [detailCharacter, setDetailCharacter] = useState<Character | null>(null);
|
||||
const characterCarouselRef = useRef<HTMLDivElement | null>(null);
|
||||
const [characterCarouselProgress, setCharacterCarouselProgress] = useState(0);
|
||||
const [showCharacterDraftModal, setShowCharacterDraftModal] = useState(false);
|
||||
const [characterDraftName, setCharacterDraftName] = useState('');
|
||||
const [characterDraftBackstory, setCharacterDraftBackstory] = useState('');
|
||||
const [characterDraftError, setCharacterDraftError] = useState<string | null>(null);
|
||||
const [characterSelectionDrafts, setCharacterSelectionDrafts] = useState<Record<string, CharacterSelectionDraft>>({});
|
||||
|
||||
const selectedCharacterEntry = useMemo(
|
||||
() => selectionEntries.find(entry => entry.selectionKey === selectedCharacterKey) ?? selectionEntries[0] ?? null,
|
||||
[selectedCharacterKey, selectionEntries],
|
||||
);
|
||||
const selectedCharacter = selectedCharacterEntry?.character ?? null;
|
||||
const selectedCharacterDraft = selectedCharacterEntry
|
||||
? characterSelectionDrafts[selectedCharacterEntry.selectionKey] ?? null
|
||||
: null;
|
||||
const selectedCharacterPreview = useMemo(
|
||||
() => applyCharacterSelectionDraft(selectedCharacter, selectedCharacterDraft),
|
||||
[selectedCharacter, selectedCharacterDraft],
|
||||
);
|
||||
const selectedCharacterMeta = selectedCharacter
|
||||
? getCharacterMeta(selectedCharacter, {name: selectedCharacterDraft?.name})
|
||||
: null;
|
||||
const attributeSchema = useMemo(
|
||||
() => resolveAttributeSchema(worldType, customWorldProfile),
|
||||
[customWorldProfile, worldType],
|
||||
);
|
||||
const selectedAttributeProfile = useMemo(
|
||||
() =>
|
||||
selectedCharacter
|
||||
? resolveCharacterAttributeProfile(
|
||||
selectedCharacter,
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
)
|
||||
?? buildCharacterAttributeProfile(selectedCharacter, attributeSchema)
|
||||
: null,
|
||||
[attributeSchema, customWorldProfile, selectedCharacter, worldType],
|
||||
);
|
||||
const selectedCharacterPersonalityTags = useMemo(
|
||||
() => (selectedCharacterPreview ? getPersonalityTags(selectedCharacterPreview.personality) : []),
|
||||
[selectedCharacterPreview],
|
||||
);
|
||||
const focusedCharacterIndex = clampIndex(Math.round(characterCarouselProgress), selectionCharacters.length);
|
||||
|
||||
const syncCharacterCarousel = useCallback(() => {
|
||||
if (!characterCarouselRef.current) return;
|
||||
setCharacterCarouselProgress(readCarouselProgress(characterCarouselRef.current, 'horizontal'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
syncCharacterCarousel();
|
||||
window.addEventListener('resize', syncCharacterCarousel);
|
||||
return () => window.removeEventListener('resize', syncCharacterCarousel);
|
||||
}, [syncCharacterCarousel]);
|
||||
|
||||
useEffect(() => {
|
||||
const focusedEntry = selectionEntries[focusedCharacterIndex];
|
||||
if (focusedEntry && focusedEntry.selectionKey !== selectedCharacterKey) {
|
||||
setSelectedCharacterKey(focusedEntry.selectionKey);
|
||||
}
|
||||
}, [focusedCharacterIndex, selectedCharacterKey, selectionEntries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectionEntries.length === 0) return;
|
||||
if (!selectionEntries.some(entry => entry.selectionKey === selectedCharacterKey)) {
|
||||
const firstEntry = selectionEntries[0];
|
||||
if (firstEntry) {
|
||||
setSelectedCharacterKey(firstEntry.selectionKey);
|
||||
}
|
||||
}
|
||||
}, [selectedCharacterKey, selectionEntries]);
|
||||
|
||||
const openCharacterDraftEditor = () => {
|
||||
if (!selectedCharacterPreview) return;
|
||||
setCharacterDraftName(selectedCharacterPreview.name);
|
||||
setCharacterDraftBackstory(selectedCharacterPreview.backstory);
|
||||
setCharacterDraftError(null);
|
||||
setShowCharacterDraftModal(true);
|
||||
};
|
||||
|
||||
const saveCharacterDraft = () => {
|
||||
if (!selectedCharacter || !selectedCharacterEntry) return;
|
||||
|
||||
const nextName = characterDraftName.trim();
|
||||
const nextBackstory = characterDraftBackstory.trim();
|
||||
if (!nextName) {
|
||||
setCharacterDraftError('请输入角色名称。');
|
||||
return;
|
||||
}
|
||||
if (!nextBackstory) {
|
||||
setCharacterDraftError('请输入角色背景故事。');
|
||||
return;
|
||||
}
|
||||
|
||||
setCharacterSelectionDrafts(current => ({
|
||||
...current,
|
||||
[selectedCharacterEntry.selectionKey]: {
|
||||
name: nextName,
|
||||
backstory: nextBackstory,
|
||||
},
|
||||
}));
|
||||
setCharacterDraftError(null);
|
||||
setShowCharacterDraftModal(false);
|
||||
};
|
||||
|
||||
if (!selectedCharacter || !selectedCharacterMeta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="mb-3 flex justify-start">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-4 text-center">
|
||||
<div className="text-2xl font-black text-white sm:text-[2rem]">选择你的角色</div>
|
||||
<div className="mt-1 text-[11px] tracking-[0.14em] text-zinc-500">左右滑动浏览角色</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={characterCarouselRef}
|
||||
onScroll={syncCharacterCarousel}
|
||||
className="character-carousel scrollbar-hide flex-[1_1_auto]"
|
||||
>
|
||||
{selectionEntries.map(({ character, selectionKey }, index) => {
|
||||
const characterDraft = characterSelectionDrafts[selectionKey];
|
||||
const meta = getCharacterMeta(character, {name: characterDraft?.name});
|
||||
const selected = selectionKey === selectedCharacterKey;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={selectionKey}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedCharacterKey(selectionKey);
|
||||
scrollCarouselToIndex(characterCarouselRef.current, index, 'horizontal');
|
||||
}}
|
||||
data-carousel-card="true"
|
||||
className={`character-carousel__card ${selected ? 'character-carousel__card--active' : ''}`}
|
||||
style={getCharacterCardStyle(index, characterCarouselProgress)}
|
||||
>
|
||||
<span className={`character-carousel__surface ${selected ? 'character-carousel__surface--active' : ''}`}>
|
||||
<span className="character-carousel__cover">
|
||||
{selected ? (
|
||||
<CharacterAnimator
|
||||
state={AnimationState.RUN}
|
||||
character={character}
|
||||
className="character-carousel__portrait character-carousel__portrait--animated"
|
||||
/>
|
||||
) : (
|
||||
<ResolvedAssetImage
|
||||
src={character.portrait}
|
||||
alt={meta.name}
|
||||
className="character-carousel__portrait"
|
||||
style={{imageRendering: 'pixelated'}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{selected ? (
|
||||
<>
|
||||
<span className="character-carousel__selected-name">{meta.name}</span>
|
||||
<span className="character-carousel__meta character-carousel__meta--selected">
|
||||
<span className="character-carousel__title character-carousel__title--selected">{meta.title}</span>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="character-carousel__meta">
|
||||
<span className="character-carousel__name">{meta.name}</span>
|
||||
<span className="character-carousel__title">{meta.title}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-[0.85fr_1.15fr]">
|
||||
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.statsPanel, {paddingX: 12, paddingY: 10})}>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="text-xs font-bold text-white">角色属性</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-zinc-500">
|
||||
<span>{selectedCharacterMeta.title}</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[9px] text-zinc-200">
|
||||
性别: {getGenderLabel(selectedCharacter.gender)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1 text-[11px] text-zinc-300 sm:grid-cols-3 sm:gap-1.5 sm:text-[13px]">
|
||||
{attributeSchema.slots.map((slot) => (
|
||||
<div key={slot.slotId} className="rounded-lg border border-white/6 bg-black/20 px-2 py-1.5 text-center sm:px-2.5">
|
||||
{slot.name}: {selectedAttributeProfile?.values?.[slot.slotId] ?? 0}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel character-backstory-panel flex flex-col"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {paddingX: 12, paddingY: 10})}
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-3">
|
||||
<div className="character-backstory-title text-xs font-bold text-white">背景故事</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCharacterDraftEditor}
|
||||
className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[10px] text-sky-100 transition-colors hover:text-white"
|
||||
>
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col text-[13px] leading-6 text-zinc-300">
|
||||
<div>{selectedCharacterPreview?.backstory ?? selectedCharacter.backstory}</div>
|
||||
<div className="mt-auto flex items-end justify-between gap-3 pt-3">
|
||||
<div className="min-w-0 flex flex-wrap gap-1.5">
|
||||
{selectedCharacterPersonalityTags.map(tag => (
|
||||
<span key={tag} className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[10px] text-zinc-300">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailCharacter(selectedCharacterPreview)}
|
||||
aria-label={`查看${selectedCharacterPreview?.name ?? selectedCharacter.name}的详情`}
|
||||
className="shrink-0 text-[11px] font-medium text-sky-200 transition-colors hover:text-white"
|
||||
>
|
||||
详情
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConfirm(selectedCharacterPreview ?? selectedCharacter)}
|
||||
className="pixel-nine-slice pixel-pressable mx-auto block w-full max-w-[16rem] text-left"
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {paddingX: 14, paddingY: 9})}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-semibold text-white">进入营地</span>
|
||||
<span className="text-white/60">开始</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CharacterDetailModal
|
||||
character={detailCharacter}
|
||||
worldType={worldType}
|
||||
customWorldProfile={customWorldProfile}
|
||||
subtitle="角色详情"
|
||||
onClose={() => setDetailCharacter(null)}
|
||||
/>
|
||||
<CharacterDraftModal
|
||||
isOpen={showCharacterDraftModal}
|
||||
characterLabel={selectedCharacterMeta ? `${selectedCharacterMeta.name} / ${selectedCharacterMeta.title}` : '当前角色'}
|
||||
draftName={characterDraftName}
|
||||
draftBackstory={characterDraftBackstory}
|
||||
onNameChange={value => {
|
||||
setCharacterDraftName(value);
|
||||
if (characterDraftError) setCharacterDraftError(null);
|
||||
}}
|
||||
onBackstoryChange={value => {
|
||||
setCharacterDraftBackstory(value);
|
||||
if (characterDraftError) setCharacterDraftError(null);
|
||||
}}
|
||||
onClose={() => setShowCharacterDraftModal(false)}
|
||||
onConfirm={saveCharacterDraft}
|
||||
error={characterDraftError}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const CharacterSelectionFlow = RpgEntryCharacterSelectView;
|
||||
156
src/components/rpg-entry/RpgEntryCreationTypeModal.tsx
Normal file
156
src/components/rpg-entry/RpgEntryCreationTypeModal.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { ArrowRight, X } from 'lucide-react';
|
||||
|
||||
export interface RpgEntryCreationTypeModalProps {
|
||||
isOpen: boolean;
|
||||
isBusy: boolean;
|
||||
error: string | null;
|
||||
onClose: () => void;
|
||||
onSelectRpg: () => void;
|
||||
}
|
||||
|
||||
type CreationGameTypeCard = {
|
||||
id: 'rpg' | 'airp' | 'visual-novel';
|
||||
title: string;
|
||||
subtitle: string;
|
||||
badge: string;
|
||||
locked: boolean;
|
||||
};
|
||||
|
||||
const CREATION_GAME_TYPES: CreationGameTypeCard[] = [
|
||||
{
|
||||
id: 'rpg',
|
||||
title: '角色扮演 RPG',
|
||||
subtitle: 'Agent 共创',
|
||||
badge: '可创建',
|
||||
locked: false,
|
||||
},
|
||||
{
|
||||
id: 'airp',
|
||||
title: 'AIRP',
|
||||
subtitle: '敬请期待',
|
||||
badge: '锁定',
|
||||
locked: true,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel',
|
||||
title: '视觉小说',
|
||||
subtitle: '敬请期待',
|
||||
badge: '锁定',
|
||||
locked: true,
|
||||
},
|
||||
];
|
||||
|
||||
function CreationTypeCard(props: {
|
||||
item: CreationGameTypeCard;
|
||||
busy: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { item, busy, onSelect } = props;
|
||||
const disabled = item.locked || busy;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onSelect}
|
||||
className={`platform-interactive-card relative overflow-hidden rounded-[1.65rem] border px-4 py-4 text-left ${
|
||||
item.locked
|
||||
? 'cursor-not-allowed border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] text-[var(--platform-text-soft)]'
|
||||
: 'border-[var(--platform-cool-border)] bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.24),transparent_34%),linear-gradient(135deg,rgba(255,79,139,0.96),rgba(255,145,110,0.9))] text-white'
|
||||
} ${busy && !item.locked ? 'opacity-70' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span
|
||||
className={`platform-pill px-3 ${
|
||||
item.locked
|
||||
? 'platform-pill--neutral text-[var(--platform-text-soft)]'
|
||||
: 'platform-pill--neutral border-white/30 bg-white/18 text-white'
|
||||
}`}
|
||||
>
|
||||
{item.locked ? item.badge : busy ? '正在开启' : item.badge}
|
||||
</span>
|
||||
{item.locked ? (
|
||||
<span className="text-lg leading-none text-white/45">·</span>
|
||||
) : (
|
||||
<ArrowRight className="h-4 w-4 text-white/80" />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-8 text-xl font-black leading-tight text-inherit">
|
||||
{item.title}
|
||||
</div>
|
||||
<div
|
||||
className={`mt-2 text-sm ${
|
||||
item.locked ? 'text-zinc-500' : 'text-zinc-200/82'
|
||||
}`}
|
||||
>
|
||||
{item.subtitle}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* RPG 入口创作类型弹层真实入口。
|
||||
* 第三批收口后入口主链不再直接依赖旧 `PlatformCreationTypeModal` 命名。
|
||||
*/
|
||||
export function RpgEntryCreationTypeModal({
|
||||
isOpen,
|
||||
isBusy,
|
||||
error,
|
||||
onClose,
|
||||
onSelectRpg,
|
||||
}: RpgEntryCreationTypeModalProps) {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="platform-overlay fixed inset-0 z-[90] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4">
|
||||
<div className="platform-modal-shell w-full max-w-3xl overflow-hidden rounded-[1.8rem]">
|
||||
<div className="bg-transparent">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
选择创作类型
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-[var(--platform-text-base)]">
|
||||
先选玩法类型,再进入对应创作工作台。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isBusy}
|
||||
className="platform-icon-button disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-4 sm:px-5 sm:py-5">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{CREATION_GAME_TYPES.map((item) => (
|
||||
<CreationTypeCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
busy={isBusy}
|
||||
onSelect={() => {
|
||||
if (item.id === 'rpg') {
|
||||
onSelectRpg();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-4 rounded-[1.25rem] text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
20
src/components/rpg-entry/RpgEntryFlowShell.tsx
Normal file
20
src/components/rpg-entry/RpgEntryFlowShell.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { RpgEntryFlowShellImpl } from './RpgEntryFlowShellImpl';
|
||||
import type { RpgEntryFlowShellProps } from './rpgEntryTypes';
|
||||
import type { SelectionStage } from './rpgEntryTypes';
|
||||
|
||||
export type { RpgEntryFlowShellProps, SelectionStage };
|
||||
|
||||
/**
|
||||
* RPG 入口域真实壳层入口。
|
||||
* 入口主链已收口到 `rpg-entry` 命名根,不再保留旧入口脚本。
|
||||
*/
|
||||
export function RpgEntryFlowShell(props: RpgEntryFlowShellProps) {
|
||||
return <RpgEntryFlowShellImpl {...props} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容创作链已经接入的旧组件命名,避免本轮迁移扩大影响面。
|
||||
*/
|
||||
export const RpgCreationShell = RpgEntryFlowShell;
|
||||
|
||||
export default RpgEntryFlowShell;
|
||||
718
src/components/rpg-entry/RpgEntryFlowShellImpl.tsx
Normal file
718
src/components/rpg-entry/RpgEntryFlowShellImpl.tsx
Normal file
@@ -0,0 +1,718 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||
import { readCustomWorldAgentUiState } from '../../services/customWorldAgentUiState';
|
||||
import { getRpgProfileDashboard } from '../../services/rpg-entry';
|
||||
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { CustomWorldCreationHub } from '../custom-world-home/CustomWorldCreationHub';
|
||||
import { RpgEntryCreationTypeModal } from './RpgEntryCreationTypeModal';
|
||||
import { RpgEntryHomeView } from './RpgEntryHomeView';
|
||||
import {
|
||||
buildCreationHubFallbackItems,
|
||||
normalizeAgentBackedProfile,
|
||||
resolveRpgCreationErrorMessage,
|
||||
} from './rpgEntryShared';
|
||||
import type { RpgEntryFlowShellProps } from './rpgEntryTypes';
|
||||
import { RpgEntryWorldDetailView } from './RpgEntryWorldDetailView';
|
||||
import { useRpgCreationAgentOperationPolling } from './useRpgCreationAgentOperationPolling';
|
||||
import { useRpgCreationEnterWorld } from './useRpgCreationEnterWorld';
|
||||
import { useRpgCreationResultAutosave } from './useRpgCreationResultAutosave';
|
||||
import { useRpgCreationSessionController } from './useRpgCreationSessionController';
|
||||
import { useRpgEntryBootstrap } from './useRpgEntryBootstrap';
|
||||
import { useRpgEntryLibraryDetail } from './useRpgEntryLibraryDetail';
|
||||
import { useRpgEntryNavigation } from './useRpgEntryNavigation';
|
||||
|
||||
const CustomWorldGenerationView = lazy(async () => {
|
||||
const module = await import('../CustomWorldGenerationView');
|
||||
return {
|
||||
default: module.CustomWorldGenerationView,
|
||||
};
|
||||
});
|
||||
|
||||
const RpgCreationResultView = lazy(async () => {
|
||||
const module = await import('../rpg-creation-result/RpgCreationResultView');
|
||||
return {
|
||||
default: module.RpgCreationResultView,
|
||||
};
|
||||
});
|
||||
|
||||
const CustomWorldAgentWorkspace = lazy(async () => {
|
||||
const module = await import(
|
||||
'../custom-world-agent/CustomWorldAgentWorkspace'
|
||||
);
|
||||
return {
|
||||
default: module.CustomWorldAgentWorkspace,
|
||||
};
|
||||
});
|
||||
|
||||
function LazyPanelFallback({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 items-center justify-center">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RpgEntryFlowShellImpl({
|
||||
selectionStage,
|
||||
setSelectionStage,
|
||||
hasSavedGame,
|
||||
savedSnapshot,
|
||||
handleContinueGame,
|
||||
handleStartNewGame,
|
||||
handleCustomWorldSelect,
|
||||
}: RpgEntryFlowShellProps) {
|
||||
const authUi = useAuthUi();
|
||||
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
|
||||
const [selectedDetailEntry, setSelectedDetailEntry] = useState<
|
||||
CustomWorldLibraryEntry<CustomWorldProfile> | null
|
||||
>(null);
|
||||
const hasInitialAgentSession = Boolean(
|
||||
readCustomWorldAgentUiState().activeSessionId,
|
||||
);
|
||||
|
||||
const platformBootstrap = useRpgEntryBootstrap({
|
||||
user: authUi?.user,
|
||||
getProfileDashboard: getRpgProfileDashboard,
|
||||
handleContinueGame,
|
||||
hasInitialAgentSession,
|
||||
});
|
||||
const entryNavigation = useRpgEntryNavigation({
|
||||
setSelectionStage,
|
||||
setSelectedDetailEntry,
|
||||
});
|
||||
|
||||
const enterCreateTab = useCallback(() => {
|
||||
platformBootstrap.setPlatformTab('create');
|
||||
}, [platformBootstrap]);
|
||||
|
||||
const sessionController = useRpgCreationSessionController({
|
||||
userId: authUi?.user?.id,
|
||||
openLoginModal: authUi?.openLoginModal,
|
||||
selectionStage,
|
||||
setSelectionStage,
|
||||
enterCreateTab,
|
||||
onSessionOpened: () => {
|
||||
setShowCreationTypeModal(false);
|
||||
},
|
||||
});
|
||||
|
||||
useRpgCreationAgentOperationPolling({
|
||||
activeAgentSessionId: sessionController.activeAgentSessionId,
|
||||
activeAgentOperationId: sessionController.activeAgentOperationId,
|
||||
userId: authUi?.user?.id,
|
||||
setAgentOperation: sessionController.setAgentOperation,
|
||||
persistAgentUiState: sessionController.persistAgentUiState,
|
||||
syncAgentSessionSnapshot: sessionController.syncAgentSessionSnapshot,
|
||||
});
|
||||
|
||||
const autosaveCoordinator = useRpgCreationResultAutosave({
|
||||
selectionStage,
|
||||
activeAgentSessionId: sessionController.activeAgentSessionId,
|
||||
agentSession: sessionController.agentSession,
|
||||
generatedCustomWorldProfile: sessionController.generatedCustomWorldProfile,
|
||||
isAgentDraftResultView: sessionController.isAgentDraftResultView,
|
||||
userId: authUi?.user?.id,
|
||||
setGeneratedCustomWorldProfile:
|
||||
sessionController.setGeneratedCustomWorldProfile,
|
||||
setAgentOperation: sessionController.setAgentOperation,
|
||||
setSavedCustomWorldEntries: platformBootstrap.setSavedCustomWorldEntries,
|
||||
setSelectedDetailEntry,
|
||||
refreshCustomWorldWorks: platformBootstrap.refreshCustomWorldWorks,
|
||||
persistAgentUiState: sessionController.persistAgentUiState,
|
||||
syncAgentSessionSnapshot: sessionController.syncAgentSessionSnapshot,
|
||||
buildDraftResultProfile: (session) =>
|
||||
rpgCreationPreviewAdapter.buildPreviewFromSession(session),
|
||||
});
|
||||
|
||||
const detailNavigation = useRpgEntryLibraryDetail({
|
||||
userId: authUi?.user?.id,
|
||||
selectedDetailEntry,
|
||||
setSelectedDetailEntry,
|
||||
savedCustomWorldEntries: platformBootstrap.savedCustomWorldEntries,
|
||||
setSavedCustomWorldEntries: platformBootstrap.setSavedCustomWorldEntries,
|
||||
setGeneratedCustomWorldProfile:
|
||||
sessionController.setGeneratedCustomWorldProfile,
|
||||
setCustomWorldError: sessionController.setCustomWorldError,
|
||||
setCustomWorldAutoSaveError: autosaveCoordinator.setCustomWorldAutoSaveError,
|
||||
setCustomWorldAutoSaveState: autosaveCoordinator.setCustomWorldAutoSaveState,
|
||||
setCustomWorldGenerationViewSource:
|
||||
sessionController.setCustomWorldGenerationViewSource,
|
||||
setCustomWorldResultViewSource:
|
||||
sessionController.setCustomWorldResultViewSource,
|
||||
setSelectionStage,
|
||||
setPlatformTabToCreate: enterCreateTab,
|
||||
setPlatformError: platformBootstrap.setPlatformError,
|
||||
appendBrowseHistoryEntry: platformBootstrap.appendBrowseHistoryEntry,
|
||||
refreshCustomWorldWorks: platformBootstrap.refreshCustomWorldWorks,
|
||||
refreshPublishedGallery: platformBootstrap.refreshPublishedGallery,
|
||||
persistAgentUiState: sessionController.persistAgentUiState,
|
||||
syncAgentSessionSnapshot: sessionController.syncAgentSessionSnapshot,
|
||||
buildDraftResultProfile: (session) =>
|
||||
rpgCreationPreviewAdapter.buildPreviewFromSession(session),
|
||||
suppressAgentDraftResultAutoOpen:
|
||||
sessionController.suppressAgentDraftResultAutoOpen,
|
||||
releaseAgentDraftResultAutoOpenSuppression:
|
||||
sessionController.releaseAgentDraftResultAutoOpenSuppression,
|
||||
resetAutoSaveTrackingToIdle: autosaveCoordinator.resetAutoSaveTrackingToIdle,
|
||||
markAutoSavedProfile: autosaveCoordinator.markAutoSavedProfile,
|
||||
});
|
||||
|
||||
const enterWorldCoordinator = useRpgCreationEnterWorld({
|
||||
isAgentDraftResultView: sessionController.isAgentDraftResultView,
|
||||
activeAgentSessionId: sessionController.activeAgentSessionId,
|
||||
generatedCustomWorldProfile: sessionController.generatedCustomWorldProfile,
|
||||
agentSessionProfile: sessionController.agentDraftResultProfile,
|
||||
agentSession: sessionController.agentSession,
|
||||
handleCustomWorldSelect,
|
||||
executePublishWorld: () =>
|
||||
autosaveCoordinator.executeAgentActionAndWait({
|
||||
action: 'publish_world',
|
||||
}),
|
||||
syncAgentDraftResultProfile: autosaveCoordinator.syncAgentDraftResultProfile,
|
||||
setGeneratedCustomWorldProfile:
|
||||
sessionController.setGeneratedCustomWorldProfile,
|
||||
});
|
||||
|
||||
const previewCustomWorldCharacters = useMemo(
|
||||
() =>
|
||||
sessionController.generatedCustomWorldProfile
|
||||
? buildCustomWorldPlayableCharacters(
|
||||
sessionController.generatedCustomWorldProfile,
|
||||
)
|
||||
: [],
|
||||
[sessionController.generatedCustomWorldProfile],
|
||||
);
|
||||
const agentResultPreview = sessionController.agentSession?.resultPreview ?? null;
|
||||
const agentResultPreviewBlockers = useMemo(
|
||||
() => agentResultPreview?.blockers?.map((entry) => entry.message) ?? [],
|
||||
[agentResultPreview],
|
||||
);
|
||||
const agentResultPreviewQualityFindings = useMemo(
|
||||
() => agentResultPreview?.qualityFindings ?? [],
|
||||
[agentResultPreview],
|
||||
);
|
||||
const agentResultPreviewSourceLabel = useMemo(() => {
|
||||
if (!agentResultPreview?.source) {
|
||||
return null;
|
||||
}
|
||||
if (agentResultPreview.source === 'published_profile') {
|
||||
return '已发布世界';
|
||||
}
|
||||
if (agentResultPreview.source === 'session_preview') {
|
||||
return '会话预览';
|
||||
}
|
||||
return '服务端预览';
|
||||
}, [agentResultPreview]);
|
||||
|
||||
const featuredGalleryEntries = useMemo(
|
||||
() => platformBootstrap.publishedGalleryEntries.slice(0, 6),
|
||||
[platformBootstrap.publishedGalleryEntries],
|
||||
);
|
||||
|
||||
const creationHubItems =
|
||||
platformBootstrap.customWorldWorkEntries.length > 0
|
||||
? platformBootstrap.customWorldWorkEntries
|
||||
: buildCreationHubFallbackItems(platformBootstrap.savedCustomWorldEntries);
|
||||
const resultViewError =
|
||||
autosaveCoordinator.customWorldAutoSaveError ??
|
||||
sessionController.customWorldError;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectionStage === 'custom-world-result' &&
|
||||
!sessionController.generatedCustomWorldProfile
|
||||
) {
|
||||
setSelectionStage(selectedDetailEntry ? 'detail' : 'platform');
|
||||
}
|
||||
}, [
|
||||
selectedDetailEntry,
|
||||
selectionStage,
|
||||
sessionController.generatedCustomWorldProfile,
|
||||
setSelectionStage,
|
||||
]);
|
||||
|
||||
const runProtectedAction = useCallback(
|
||||
(action: () => void) => {
|
||||
if (!authUi?.requireAuth) {
|
||||
action();
|
||||
return;
|
||||
}
|
||||
|
||||
authUi.requireAuth(action);
|
||||
},
|
||||
[authUi],
|
||||
);
|
||||
|
||||
const openCreationTypePicker = useCallback(() => {
|
||||
if (sessionController.isCreatingAgentSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasSavedGame) {
|
||||
handleStartNewGame();
|
||||
}
|
||||
|
||||
sessionController.setCreationTypeError(null);
|
||||
setShowCreationTypeModal(true);
|
||||
}, [
|
||||
handleStartNewGame,
|
||||
hasSavedGame,
|
||||
sessionController,
|
||||
]);
|
||||
|
||||
const leaveAgentWorkspace = useCallback(() => {
|
||||
enterCreateTab();
|
||||
sessionController.resetSessionViewState();
|
||||
sessionController.setGeneratedCustomWorldProfile(null);
|
||||
autosaveCoordinator.resetAutoSaveTrackingToIdle();
|
||||
sessionController.persistAgentUiState(
|
||||
sessionController.activeAgentSessionId,
|
||||
null,
|
||||
);
|
||||
setSelectionStage('platform');
|
||||
}, [
|
||||
autosaveCoordinator,
|
||||
enterCreateTab,
|
||||
sessionController,
|
||||
setSelectionStage,
|
||||
]);
|
||||
|
||||
const leaveAgentDraftGeneration = useCallback(() => {
|
||||
if (sessionController.isActiveGenerationRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
sessionController.setAgentDraftGenerationStartedAt(null);
|
||||
sessionController.setCustomWorldGenerationViewSource(null);
|
||||
setSelectionStage('agent-workspace');
|
||||
}, [sessionController, setSelectionStage]);
|
||||
|
||||
const leaveAgentDraftResult = useCallback(() => {
|
||||
sessionController.suppressAgentDraftResultAutoOpen();
|
||||
sessionController.setGeneratedCustomWorldProfile(null);
|
||||
sessionController.setCustomWorldError(null);
|
||||
autosaveCoordinator.resetAutoSaveTrackingToIdle();
|
||||
sessionController.setCustomWorldGenerationViewSource(null);
|
||||
sessionController.setCustomWorldResultViewSource(null);
|
||||
enterCreateTab();
|
||||
setSelectionStage('platform');
|
||||
}, [
|
||||
autosaveCoordinator,
|
||||
enterCreateTab,
|
||||
sessionController,
|
||||
setSelectionStage,
|
||||
]);
|
||||
|
||||
const leaveCustomWorldResult = useCallback(() => {
|
||||
sessionController.setGeneratedCustomWorldProfile(null);
|
||||
sessionController.setCustomWorldError(null);
|
||||
autosaveCoordinator.resetAutoSaveTrackingToIdle();
|
||||
sessionController.setCustomWorldGenerationViewSource(null);
|
||||
sessionController.setCustomWorldResultViewSource(null);
|
||||
setSelectionStage(selectedDetailEntry ? 'detail' : 'platform');
|
||||
}, [
|
||||
autosaveCoordinator,
|
||||
selectedDetailEntry,
|
||||
sessionController,
|
||||
setSelectionStage,
|
||||
]);
|
||||
|
||||
const handleStartSelectedWorld = useCallback(() => {
|
||||
if (!selectedDetailEntry) {
|
||||
return;
|
||||
}
|
||||
|
||||
runProtectedAction(() => {
|
||||
handleCustomWorldSelect(selectedDetailEntry.profile);
|
||||
});
|
||||
}, [handleCustomWorldSelect, runProtectedAction, selectedDetailEntry]);
|
||||
|
||||
const creationHubContent = (
|
||||
<CustomWorldCreationHub
|
||||
items={creationHubItems}
|
||||
loading={platformBootstrap.isLoadingPlatform}
|
||||
error={
|
||||
platformBootstrap.isLoadingPlatform
|
||||
? null
|
||||
: platformBootstrap.platformError ?? sessionController.creationTypeError
|
||||
}
|
||||
onBack={() => {
|
||||
platformBootstrap.setPlatformTab('home');
|
||||
}}
|
||||
onRetry={() => {
|
||||
platformBootstrap.setPlatformError(null);
|
||||
void platformBootstrap.refreshCustomWorldWorks().catch((error) => {
|
||||
platformBootstrap.setPlatformError(
|
||||
resolveRpgCreationErrorMessage(error, '读取创作作品列表失败。'),
|
||||
);
|
||||
});
|
||||
}}
|
||||
onCreateNew={openCreationTypePicker}
|
||||
onOpenDraft={(item) => {
|
||||
runProtectedAction(() => {
|
||||
void detailNavigation.handleOpenCreationWork(item);
|
||||
});
|
||||
}}
|
||||
onEnterPublished={(profileId) => {
|
||||
runProtectedAction(() => {
|
||||
const matchedWork = creationHubItems.find(
|
||||
(entry) => entry.profileId === profileId,
|
||||
);
|
||||
if (!matchedWork) {
|
||||
return;
|
||||
}
|
||||
void detailNavigation.handleOpenCreationWork(matchedWork);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence mode="wait">
|
||||
{selectionStage === 'platform' && (
|
||||
<motion.div
|
||||
key="platform-home"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<RpgEntryHomeView
|
||||
activeTab={platformBootstrap.platformTab}
|
||||
onTabChange={platformBootstrap.setPlatformTab}
|
||||
hasSavedGame={hasSavedGame}
|
||||
savedSnapshot={savedSnapshot}
|
||||
saveEntries={platformBootstrap.saveEntries}
|
||||
saveError={platformBootstrap.saveError}
|
||||
featuredEntries={featuredGalleryEntries}
|
||||
latestEntries={platformBootstrap.publishedGalleryEntries}
|
||||
myEntries={platformBootstrap.savedCustomWorldEntries}
|
||||
historyEntries={platformBootstrap.historyEntries}
|
||||
profileDashboard={platformBootstrap.profileDashboard}
|
||||
isLoadingPlatform={platformBootstrap.isLoadingPlatform}
|
||||
isLoadingDashboard={platformBootstrap.isLoadingDashboard}
|
||||
isResumingSaveWorldKey={platformBootstrap.isResumingSaveWorldKey}
|
||||
platformError={
|
||||
platformBootstrap.isLoadingPlatform
|
||||
? null
|
||||
: platformBootstrap.platformError ??
|
||||
sessionController.creationTypeError
|
||||
}
|
||||
dashboardError={
|
||||
platformBootstrap.isLoadingDashboard
|
||||
? null
|
||||
: platformBootstrap.dashboardError
|
||||
}
|
||||
createTabContent={creationHubContent}
|
||||
onContinueGame={handleContinueGame}
|
||||
onResumeSave={(entry) => {
|
||||
void platformBootstrap.handleResumeSaveEntry(entry);
|
||||
}}
|
||||
onOpenCreateWorld={openCreationTypePicker}
|
||||
onOpenCreateTypePicker={openCreationTypePicker}
|
||||
onOpenGalleryDetail={(entry) => {
|
||||
runProtectedAction(() => {
|
||||
void detailNavigation.openGalleryDetail(entry);
|
||||
});
|
||||
}}
|
||||
onOpenLibraryDetail={(entry) => {
|
||||
runProtectedAction(() => {
|
||||
detailNavigation.openLibraryDetail(entry);
|
||||
});
|
||||
}}
|
||||
onOpenProfileDashboardCard={() => {
|
||||
if (platformBootstrap.dashboardError) {
|
||||
void platformBootstrap.refreshProfileDashboard();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'detail' && (
|
||||
<motion.div
|
||||
key="platform-detail"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
{detailNavigation.isDetailLoading || !selectedDetailEntry ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
{detailNavigation.detailError || '正在读取作品详情...'}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<RpgEntryWorldDetailView
|
||||
entry={selectedDetailEntry}
|
||||
isMutating={detailNavigation.isMutatingDetail}
|
||||
error={detailNavigation.detailError}
|
||||
onBack={() => {
|
||||
detailNavigation.setDetailError(null);
|
||||
entryNavigation.backToPlatformHome();
|
||||
}}
|
||||
onStartGame={handleStartSelectedWorld}
|
||||
onContinueEdit={
|
||||
detailNavigation.isSelectedWorldOwned
|
||||
? () => {
|
||||
runProtectedAction(() => {
|
||||
detailNavigation.openSavedCustomWorldEditor(
|
||||
selectedDetailEntry,
|
||||
);
|
||||
});
|
||||
}
|
||||
: null
|
||||
}
|
||||
onPublish={
|
||||
selectedDetailEntry.visibility === 'draft' &&
|
||||
detailNavigation.isSelectedWorldOwned
|
||||
? () => {
|
||||
runProtectedAction(() => {
|
||||
void detailNavigation.handlePublishSelectedWorld();
|
||||
});
|
||||
}
|
||||
: null
|
||||
}
|
||||
onUnpublish={
|
||||
selectedDetailEntry.visibility === 'published' &&
|
||||
detailNavigation.isSelectedWorldOwned
|
||||
? () => {
|
||||
runProtectedAction(() => {
|
||||
void detailNavigation.handleUnpublishSelectedWorld();
|
||||
});
|
||||
}
|
||||
: null
|
||||
}
|
||||
onDelete={
|
||||
detailNavigation.isSelectedWorldOwned
|
||||
? () => {
|
||||
runProtectedAction(() => {
|
||||
void detailNavigation.handleDeleteSelectedWorld();
|
||||
});
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'agent-workspace' && (
|
||||
<motion.div
|
||||
key="agent-workspace"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<LazyPanelFallback label="正在加载 Agent 共创工作区..." />
|
||||
}
|
||||
>
|
||||
{sessionController.agentSession ? (
|
||||
<CustomWorldAgentWorkspace
|
||||
session={sessionController.agentSession}
|
||||
activeOperation={sessionController.agentOperation}
|
||||
streamingReplyText={sessionController.streamingAgentReplyText}
|
||||
isStreamingReply={sessionController.isStreamingAgentReply}
|
||||
onBack={leaveAgentWorkspace}
|
||||
onSubmitMessage={(payload) => {
|
||||
void sessionController.submitAgentMessage(payload);
|
||||
}}
|
||||
onExecuteAction={(payload) => {
|
||||
void sessionController.executeAgentAction(payload);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
{sessionController.isLoadingAgentSession
|
||||
? '正在准备 Agent 共创工作区...'
|
||||
: sessionController.creationTypeError || '正在恢复创作工作区...'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'custom-world-generating' && (
|
||||
<motion.div
|
||||
key="custom-world-generating"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载世界生成面板..." />}
|
||||
>
|
||||
<CustomWorldGenerationView
|
||||
settingText={sessionController.agentDraftSettingPreview}
|
||||
anchorEntries={sessionController.agentDraftAnchorPreviewEntries}
|
||||
progress={sessionController.agentDraftGenerationProgress}
|
||||
isGenerating={sessionController.isActiveGenerationRunning}
|
||||
error={sessionController.activeGenerationError}
|
||||
onBack={leaveAgentDraftGeneration}
|
||||
onEditSetting={leaveAgentDraftGeneration}
|
||||
onRetry={() => {
|
||||
void sessionController.executeAgentAction({
|
||||
action: 'draft_foundation',
|
||||
});
|
||||
}}
|
||||
onInterrupt={undefined}
|
||||
backLabel="返回工作区"
|
||||
settingActionLabel={null}
|
||||
retryLabel="重新生成草稿"
|
||||
settingTitle="当前世界信息"
|
||||
settingDescription={null}
|
||||
progressTitle="世界草稿生成进度"
|
||||
activeBadgeLabel="草稿编译中"
|
||||
pausedBadgeLabel="草稿生成已暂停"
|
||||
idleBadgeLabel="等待返回工作区"
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'custom-world-result' &&
|
||||
sessionController.generatedCustomWorldProfile && (
|
||||
<motion.div
|
||||
key="custom-world-result"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载世界编辑器..." />}
|
||||
>
|
||||
<RpgCreationResultView
|
||||
profile={sessionController.generatedCustomWorldProfile}
|
||||
previewCharacters={previewCustomWorldCharacters}
|
||||
isGenerating={false}
|
||||
progress={0}
|
||||
progressLabel=""
|
||||
error={resultViewError}
|
||||
onProfileChange={(profile) => {
|
||||
sessionController.setGeneratedCustomWorldProfile(
|
||||
normalizeAgentBackedProfile(profile),
|
||||
);
|
||||
}}
|
||||
onBack={
|
||||
sessionController.isAgentDraftResultView
|
||||
? () => {
|
||||
void (async () => {
|
||||
const currentProfile =
|
||||
sessionController.generatedCustomWorldProfile;
|
||||
if (!currentProfile) {
|
||||
leaveAgentDraftResult();
|
||||
return;
|
||||
}
|
||||
|
||||
await autosaveCoordinator.syncAgentDraftResultProfile(
|
||||
currentProfile,
|
||||
);
|
||||
leaveAgentDraftResult();
|
||||
})().catch((error) => {
|
||||
sessionController.setCustomWorldError(
|
||||
resolveRpgCreationErrorMessage(
|
||||
error,
|
||||
'返回创作前同步草稿失败。',
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
: leaveCustomWorldResult
|
||||
}
|
||||
onEditSetting={undefined}
|
||||
onRegenerate={undefined}
|
||||
onContinueExpand={undefined}
|
||||
onEnterWorld={() => {
|
||||
runProtectedAction(() => {
|
||||
void enterWorldCoordinator
|
||||
.enterWorldFromCurrentResult()
|
||||
.catch((error) => {
|
||||
sessionController.setCustomWorldError(
|
||||
resolveRpgCreationErrorMessage(
|
||||
error,
|
||||
'发布并进入世界失败。',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}}
|
||||
readOnly={false}
|
||||
compactAgentResultMode={sessionController.isAgentDraftResultView}
|
||||
backLabel={
|
||||
sessionController.isAgentDraftResultView
|
||||
? '返回创作'
|
||||
: undefined
|
||||
}
|
||||
editActionLabel="继续调整设定"
|
||||
enterWorldActionLabel={
|
||||
sessionController.isAgentDraftResultView &&
|
||||
sessionController.agentSession?.stage !== 'published'
|
||||
? '发布并进入世界'
|
||||
: '进入世界'
|
||||
}
|
||||
publishReady={
|
||||
sessionController.isAgentDraftResultView
|
||||
? Boolean(agentResultPreview?.publishReady)
|
||||
: true
|
||||
}
|
||||
publishBlockers={
|
||||
sessionController.isAgentDraftResultView
|
||||
? agentResultPreviewBlockers
|
||||
: []
|
||||
}
|
||||
qualityFindings={
|
||||
sessionController.isAgentDraftResultView
|
||||
? agentResultPreviewQualityFindings
|
||||
: []
|
||||
}
|
||||
previewSourceLabel={
|
||||
sessionController.isAgentDraftResultView
|
||||
? agentResultPreviewSourceLabel
|
||||
: null
|
||||
}
|
||||
autoSaveState={autosaveCoordinator.customWorldAutoSaveState}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<RpgEntryCreationTypeModal
|
||||
isOpen={showCreationTypeModal}
|
||||
isBusy={sessionController.isCreatingAgentSession}
|
||||
error={sessionController.creationTypeError}
|
||||
onClose={() => {
|
||||
if (sessionController.isCreatingAgentSession) {
|
||||
return;
|
||||
}
|
||||
setShowCreationTypeModal(false);
|
||||
}}
|
||||
onSelectRpg={() => {
|
||||
runProtectedAction(() => {
|
||||
void sessionController.openRpgAgentWorkspace();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const RpgCreationShellImpl = RpgEntryFlowShellImpl;
|
||||
|
||||
export default RpgEntryFlowShellImpl;
|
||||
1631
src/components/rpg-entry/RpgEntryHomeView.tsx
Normal file
1631
src/components/rpg-entry/RpgEntryHomeView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
295
src/components/rpg-entry/RpgEntryWorldDetailView.tsx
Normal file
295
src/components/rpg-entry/RpgEntryWorldDetailView.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import {
|
||||
buildPlatformWorldTags,
|
||||
describePlatformThemeLabel,
|
||||
formatPlatformWorldTime,
|
||||
resolvePlatformWorldCoverImage,
|
||||
resolvePlatformWorldLeadPortrait,
|
||||
} from './rpgEntryWorldPresentation';
|
||||
|
||||
export interface RpgEntryWorldDetailViewProps {
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>;
|
||||
isMutating: boolean;
|
||||
error: string | null;
|
||||
onBack: () => void;
|
||||
onStartGame: () => void;
|
||||
onContinueEdit?: (() => void) | null;
|
||||
onPublish?: (() => void) | null;
|
||||
onDelete?: (() => void) | null;
|
||||
onUnpublish?: (() => void) | null;
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
label,
|
||||
onClick,
|
||||
tone = 'default',
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
tone?: 'default' | 'primary' | 'danger';
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === 'primary'
|
||||
? 'platform-button platform-button--primary'
|
||||
: tone === 'danger'
|
||||
? 'platform-button platform-button--danger'
|
||||
: 'platform-button platform-button--secondary';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`${toneClass} ${disabled ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function RpgEntryWorldDetailView({
|
||||
entry,
|
||||
isMutating,
|
||||
error,
|
||||
onBack,
|
||||
onStartGame,
|
||||
onContinueEdit,
|
||||
onPublish,
|
||||
onDelete,
|
||||
onUnpublish,
|
||||
}: RpgEntryWorldDetailViewProps) {
|
||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
|
||||
const canStartGame = entry.visibility === 'published';
|
||||
const previewCharacters = buildCustomWorldPlayableCharacters(
|
||||
entry.profile,
|
||||
).slice(0, 3);
|
||||
const previewLandmarks = entry.profile.landmarks.slice(0, 3);
|
||||
const tags = [
|
||||
...new Set(
|
||||
buildPlatformWorldTags(entry)
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
].slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="platform-button platform-button--ghost px-3 py-1.5 text-[11px]"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回广场
|
||||
</button>
|
||||
<div className="platform-pill platform-pill--neutral px-3 py-1.5 text-[11px] tracking-[0.08em]">
|
||||
{entry.visibility === 'published' ? '已发布' : '草稿'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide">
|
||||
<div className="space-y-4 pb-2">
|
||||
<div className="platform-surface platform-surface--hero relative overflow-hidden px-[18px] py-4">
|
||||
{coverImage ? (
|
||||
<ResolvedAssetImage
|
||||
src={coverImage}
|
||||
alt={entry.worldName}
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-38"
|
||||
/>
|
||||
) : null}
|
||||
{leadPortrait ? (
|
||||
<ResolvedAssetImage
|
||||
src={leadPortrait}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute bottom-0 right-2 h-32 w-32 object-contain opacity-25"
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="platform-pill platform-pill--warm">
|
||||
{describePlatformThemeLabel(entry.themeMode)}
|
||||
</span>
|
||||
<span className="platform-pill platform-pill--neutral px-3">
|
||||
{entry.authorDisplayName}
|
||||
</span>
|
||||
<span className="platform-pill platform-pill--neutral px-3">
|
||||
{entry.visibility === 'published'
|
||||
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
|
||||
: '仅自己可见'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 text-3xl font-black text-white">
|
||||
{entry.worldName}
|
||||
</div>
|
||||
{entry.subtitle ? (
|
||||
<div className="mt-2 text-sm tracking-[0.18em] text-zinc-300/88">
|
||||
{entry.subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 max-w-[36rem] text-sm leading-7 text-zinc-200/88">
|
||||
{entry.summaryText || '等待补充世界摘要。'}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{tags.map((tag, index) => (
|
||||
<span
|
||||
key={`world-detail-tag-${index}-${tag || 'empty'}`}
|
||||
className="platform-pill platform-pill--neutral px-3"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<div className="platform-surface platform-surface--soft px-4 py-3.5">
|
||||
<div className="text-[10px] tracking-[0.22em] text-[var(--platform-text-soft)]">
|
||||
世界信息
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 text-sm text-[var(--platform-text-strong)] sm:grid-cols-4">
|
||||
<div className="platform-subpanel rounded-xl px-3 py-3">
|
||||
<div className="text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
可玩角色
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-bold">
|
||||
{entry.playableNpcCount}
|
||||
</div>
|
||||
</div>
|
||||
<div className="platform-subpanel rounded-xl px-3 py-3">
|
||||
<div className="text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
地标
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-bold">
|
||||
{entry.landmarkCount}
|
||||
</div>
|
||||
</div>
|
||||
<div className="platform-subpanel rounded-xl px-3 py-3">
|
||||
<div className="text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
阵营
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-bold">
|
||||
{entry.profile.majorFactions.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="platform-subpanel rounded-xl px-3 py-3">
|
||||
<div className="text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
冲突
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-bold">
|
||||
{entry.profile.coreConflicts.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="text-[10px] tracking-[0.22em] text-[var(--platform-text-soft)]">
|
||||
关键角色
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
{previewCharacters.map((character, index) => (
|
||||
<div
|
||||
key={character.id || `preview-character-${index}`}
|
||||
className="platform-subpanel rounded-2xl px-3 py-3"
|
||||
>
|
||||
<div className="line-clamp-1 text-sm font-bold text-[var(--platform-text-strong)]">
|
||||
{character.title}
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 text-xs leading-5 text-[var(--platform-text-base)]">
|
||||
{character.description}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="text-[10px] tracking-[0.22em] text-[var(--platform-text-soft)]">
|
||||
关键场景
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
{previewLandmarks.map((landmark, index) => (
|
||||
<div
|
||||
key={landmark.id || `preview-landmark-${index}`}
|
||||
className="platform-subpanel rounded-2xl px-3 py-3"
|
||||
>
|
||||
<div className="line-clamp-1 text-sm font-bold text-[var(--platform-text-strong)]">
|
||||
{landmark.name}
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 text-xs leading-5 text-[var(--platform-text-base)]">
|
||||
{landmark.description}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="platform-surface platform-surface--soft px-4 py-3.5">
|
||||
<div className="text-[10px] tracking-[0.22em] text-[var(--platform-text-soft)]">
|
||||
操作
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<ActionButton
|
||||
label={canStartGame ? '开始游戏' : '请先发布作品'}
|
||||
onClick={onStartGame}
|
||||
tone="primary"
|
||||
disabled={!canStartGame || isMutating}
|
||||
/>
|
||||
{onContinueEdit ? (
|
||||
<ActionButton
|
||||
label="继续创作"
|
||||
onClick={onContinueEdit}
|
||||
disabled={isMutating}
|
||||
/>
|
||||
) : null}
|
||||
{onPublish ? (
|
||||
<ActionButton
|
||||
label="发布到广场"
|
||||
onClick={onPublish}
|
||||
tone="primary"
|
||||
disabled={isMutating}
|
||||
/>
|
||||
) : null}
|
||||
{onUnpublish ? (
|
||||
<ActionButton
|
||||
label="下架作品"
|
||||
onClick={onUnpublish}
|
||||
tone="danger"
|
||||
disabled={isMutating}
|
||||
/>
|
||||
) : null}
|
||||
{onDelete ? (
|
||||
<ActionButton
|
||||
label="删除作品"
|
||||
onClick={onDelete}
|
||||
tone="danger"
|
||||
disabled={isMutating}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="mt-4 rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const PlatformWorldDetailView = RpgEntryWorldDetailView;
|
||||
27
src/components/rpg-entry/index.ts
Normal file
27
src/components/rpg-entry/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export {
|
||||
RpgEntryCharacterSelectView,
|
||||
type RpgEntryCharacterSelectViewProps,
|
||||
} from './RpgEntryCharacterSelectView';
|
||||
export {
|
||||
RpgEntryFlowShell,
|
||||
type RpgEntryFlowShellProps,
|
||||
type SelectionStage,
|
||||
} from './RpgEntryFlowShell';
|
||||
export {
|
||||
type PlatformHomeTab,
|
||||
RpgEntryHomeView,
|
||||
type RpgEntryHomeViewProps,
|
||||
} from './RpgEntryHomeView';
|
||||
export {
|
||||
RpgEntryWorldDetailView,
|
||||
type RpgEntryWorldDetailViewProps,
|
||||
} from './RpgEntryWorldDetailView';
|
||||
export { useRpgCreationAgentOperationPolling } from './useRpgCreationAgentOperationPolling';
|
||||
export { useRpgCreationEnterWorld } from './useRpgCreationEnterWorld';
|
||||
export { useRpgCreationResultAutosave } from './useRpgCreationResultAutosave';
|
||||
export { useRpgCreationSessionController } from './useRpgCreationSessionController';
|
||||
export { useRpgEntryBootstrap } from './useRpgEntryBootstrap';
|
||||
export { useRpgEntryCharacterSelect } from './useRpgEntryCharacterSelect';
|
||||
export { useRpgEntryLibraryDetail } from './useRpgEntryLibraryDetail';
|
||||
export { useRpgEntryNavigation } from './useRpgEntryNavigation';
|
||||
export { useRpgEntrySaveResume } from './useRpgEntrySaveResume';
|
||||
110
src/components/rpg-entry/rpgEntryShared.ts
Normal file
110
src/components/rpg-entry/rpgEntryShared.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type {
|
||||
CustomWorldAgentMessage,
|
||||
CustomWorldAgentOperationRecord,
|
||||
CustomWorldWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { buildCustomWorldCreatorIntentFoundationText } from '../../services/customWorldCreatorIntent';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export function resolveRpgEntryErrorMessage(
|
||||
error: unknown,
|
||||
fallback: string,
|
||||
) {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
export function createFailedRpgEntryAgentOperation(params: {
|
||||
type: CustomWorldAgentOperationRecord['type'];
|
||||
phaseLabel: string;
|
||||
error: string;
|
||||
}): CustomWorldAgentOperationRecord {
|
||||
return {
|
||||
operationId: `local-failed-${Date.now()}`,
|
||||
type: params.type,
|
||||
status: 'failed',
|
||||
phaseLabel: params.phaseLabel,
|
||||
phaseDetail: params.error,
|
||||
progress: 100,
|
||||
error: params.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOptimisticRpgEntryAgentMessage(
|
||||
payload: Pick<CustomWorldAgentMessage, 'id' | 'role' | 'kind' | 'text'>,
|
||||
): CustomWorldAgentMessage {
|
||||
return {
|
||||
...payload,
|
||||
createdAt: new Date().toISOString(),
|
||||
relatedOperationId: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeRpgEntryAgentBackedProfile(
|
||||
profile: CustomWorldProfile,
|
||||
) {
|
||||
const foundationText = buildCustomWorldCreatorIntentFoundationText(
|
||||
profile.creatorIntent,
|
||||
).trim();
|
||||
|
||||
if (!foundationText || foundationText === profile.settingText.trim()) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
return {
|
||||
...profile,
|
||||
settingText: foundationText,
|
||||
} satisfies CustomWorldProfile;
|
||||
}
|
||||
|
||||
export function stringifyRpgEntryAgentBackedProfile(
|
||||
profile: CustomWorldProfile,
|
||||
) {
|
||||
return JSON.stringify(normalizeRpgEntryAgentBackedProfile(profile));
|
||||
}
|
||||
|
||||
export function buildRpgEntryCreationHubFallbackItems(
|
||||
entries: CustomWorldLibraryEntry<CustomWorldProfile>[],
|
||||
): CustomWorldWorkSummary[] {
|
||||
return entries
|
||||
.filter((entry) => entry.visibility === 'published')
|
||||
.map((entry) => ({
|
||||
workId: `fallback:${entry.profileId}`,
|
||||
sourceType: 'published_profile',
|
||||
status: 'published',
|
||||
title: entry.worldName,
|
||||
subtitle: entry.subtitle || '已发布作品',
|
||||
summary: entry.summaryText || '继续补完这个世界的设定与游玩入口。',
|
||||
coverImageSrc: entry.coverImageSrc,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
updatedAt: entry.updatedAt,
|
||||
publishedAt: entry.publishedAt,
|
||||
stage: null,
|
||||
stageLabel: '已发布',
|
||||
playableNpcCount: entry.playableNpcCount,
|
||||
landmarkCount: entry.landmarkCount,
|
||||
roleVisualReadyCount: 0,
|
||||
roleAnimationReadyCount: 0,
|
||||
roleAssetSummaryLabel: null,
|
||||
sessionId: null,
|
||||
profileId: entry.profileId,
|
||||
canResume: false,
|
||||
canEnterWorld: true,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容创作链工作包已经接入的旧 helper 命名,避免本轮迁移波及其他并行改动。
|
||||
*/
|
||||
export const resolveRpgCreationErrorMessage = resolveRpgEntryErrorMessage;
|
||||
export const createFailedAgentOperation =
|
||||
createFailedRpgEntryAgentOperation;
|
||||
export const buildOptimisticAgentMessage =
|
||||
buildOptimisticRpgEntryAgentMessage;
|
||||
export const normalizeAgentBackedProfile =
|
||||
normalizeRpgEntryAgentBackedProfile;
|
||||
export const stringifyAgentBackedProfile =
|
||||
stringifyRpgEntryAgentBackedProfile;
|
||||
export const buildCreationHubFallbackItems =
|
||||
buildRpgEntryCreationHubFallbackItems;
|
||||
39
src/components/rpg-entry/rpgEntryTypes.ts
Normal file
39
src/components/rpg-entry/rpgEntryTypes.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type {
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { CustomWorldProfile, GameState } from '../../types';
|
||||
|
||||
export type SelectionStage =
|
||||
| 'platform'
|
||||
| 'detail'
|
||||
| 'agent-workspace'
|
||||
| 'custom-world-generating'
|
||||
| 'custom-world-result';
|
||||
|
||||
export type CustomWorldGenerationViewSource = 'agent-draft-foundation' | null;
|
||||
|
||||
export type CustomWorldResultViewSource = 'saved-profile' | 'agent-draft' | null;
|
||||
|
||||
export type CustomWorldAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
|
||||
export type SyncedAgentDraftResult = {
|
||||
session: CustomWorldAgentSessionSnapshot | null;
|
||||
profile: CustomWorldProfile | null;
|
||||
};
|
||||
|
||||
export type RpgEntryFlowShellProps = {
|
||||
selectionStage: SelectionStage;
|
||||
setSelectionStage: (stage: SelectionStage) => void;
|
||||
gameState: GameState;
|
||||
hasSavedGame: boolean;
|
||||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||||
handleStartNewGame: () => void;
|
||||
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 兼容旧创作链入口的 props 命名,避免并行工作包在迁移期间断开引用。
|
||||
*/
|
||||
export type RpgCreationShellProps = RpgEntryFlowShellProps;
|
||||
90
src/components/rpg-entry/rpgEntryWorldPresentation.ts
Normal file
90
src/components/rpg-entry/rpgEntryWorldPresentation.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export type PlatformWorldCardLike =
|
||||
| CustomWorldGalleryCard
|
||||
| CustomWorldLibraryEntry<CustomWorldProfile>;
|
||||
|
||||
export function isLibraryWorldEntry(
|
||||
entry: PlatformWorldCardLike,
|
||||
): entry is CustomWorldLibraryEntry<CustomWorldProfile> {
|
||||
return 'profile' in entry;
|
||||
}
|
||||
|
||||
export function resolvePlatformWorldCoverImage(entry: PlatformWorldCardLike) {
|
||||
if (entry.coverImageSrc) {
|
||||
return entry.coverImageSrc;
|
||||
}
|
||||
|
||||
if (isLibraryWorldEntry(entry)) {
|
||||
return resolveCustomWorldCampSceneImage(entry.profile) ?? '';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function resolvePlatformWorldLeadPortrait(entry: PlatformWorldCardLike) {
|
||||
if (!isLibraryWorldEntry(entry)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return buildCustomWorldPlayableCharacters(entry.profile)[0]?.portrait ?? '';
|
||||
}
|
||||
|
||||
export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
|
||||
if (!isLibraryWorldEntry(entry)) {
|
||||
return [
|
||||
describePlatformThemeLabel(entry.themeMode),
|
||||
`${entry.playableNpcCount} 角色`,
|
||||
`${entry.landmarkCount} 地标`,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
...entry.profile.majorFactions.slice(0, 2),
|
||||
...entry.profile.coreConflicts.slice(0, 1),
|
||||
]
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
export function formatPlatformWorldTime(value: string | null) {
|
||||
if (!value) {
|
||||
return '未发布';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function describePlatformThemeLabel(
|
||||
themeMode: PlatformWorldCardLike['themeMode'],
|
||||
) {
|
||||
switch (themeMode) {
|
||||
case 'martial':
|
||||
return '江湖';
|
||||
case 'arcane':
|
||||
return '灵脉';
|
||||
case 'machina':
|
||||
return '机巧';
|
||||
case 'tide':
|
||||
return '潮痕';
|
||||
case 'rift':
|
||||
return '裂界';
|
||||
default:
|
||||
return '回响';
|
||||
}
|
||||
}
|
||||
109
src/components/rpg-entry/useRpgCreationAgentOperationPolling.ts
Normal file
109
src/components/rpg-entry/useRpgCreationAgentOperationPolling.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import type {
|
||||
CustomWorldAgentOperationRecord,
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import {
|
||||
getRpgCreationOperation,
|
||||
} from '../../services/rpg-creation';
|
||||
import {
|
||||
createFailedAgentOperation,
|
||||
resolveRpgCreationErrorMessage,
|
||||
} from './rpgEntryShared';
|
||||
|
||||
type UseRpgCreationAgentOperationPollingParams = {
|
||||
activeAgentSessionId: string | null;
|
||||
activeAgentOperationId: string | null;
|
||||
userId: string | null | undefined;
|
||||
setAgentOperation: (
|
||||
operation: CustomWorldAgentOperationRecord | null,
|
||||
) => void;
|
||||
persistAgentUiState: (
|
||||
sessionId: string | null,
|
||||
operationId: string | null,
|
||||
) => void;
|
||||
syncAgentSessionSnapshot: (
|
||||
sessionId: string,
|
||||
) => Promise<CustomWorldAgentSessionSnapshot | null>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 只负责当前 Agent operation 的轮询与完成态刷新。
|
||||
* 壳层不再直接维护轮询 interval 与失败兜底细节。
|
||||
*/
|
||||
export function useRpgCreationAgentOperationPolling(
|
||||
params: UseRpgCreationAgentOperationPollingParams,
|
||||
) {
|
||||
const {
|
||||
activeAgentSessionId,
|
||||
activeAgentOperationId,
|
||||
userId,
|
||||
setAgentOperation,
|
||||
persistAgentUiState,
|
||||
syncAgentSessionSnapshot,
|
||||
} = params;
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeAgentSessionId || !activeAgentOperationId || !userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const pollOperation = async () => {
|
||||
try {
|
||||
const nextOperation = await getRpgCreationOperation(
|
||||
activeAgentSessionId,
|
||||
activeAgentOperationId,
|
||||
);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAgentOperation(nextOperation);
|
||||
|
||||
if (
|
||||
nextOperation.status === 'completed' ||
|
||||
nextOperation.status === 'failed'
|
||||
) {
|
||||
persistAgentUiState(activeAgentSessionId, null);
|
||||
await syncAgentSessionSnapshot(activeAgentSessionId).catch(
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAgentOperation(
|
||||
createFailedAgentOperation({
|
||||
type: 'process_message',
|
||||
phaseLabel: '读取操作状态失败',
|
||||
error: resolveRpgCreationErrorMessage(error, '读取共创操作状态失败。'),
|
||||
}),
|
||||
);
|
||||
persistAgentUiState(activeAgentSessionId, null);
|
||||
}
|
||||
};
|
||||
|
||||
void pollOperation();
|
||||
const intervalId = window.setInterval(() => {
|
||||
void pollOperation();
|
||||
}, 1200);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [
|
||||
activeAgentOperationId,
|
||||
activeAgentSessionId,
|
||||
persistAgentUiState,
|
||||
setAgentOperation,
|
||||
syncAgentSessionSnapshot,
|
||||
userId,
|
||||
]);
|
||||
}
|
||||
92
src/components/rpg-entry/useRpgCreationEnterWorld.ts
Normal file
92
src/components/rpg-entry/useRpgCreationEnterWorld.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
type UseRpgCreationEnterWorldParams = {
|
||||
isAgentDraftResultView: boolean;
|
||||
activeAgentSessionId: string | null;
|
||||
generatedCustomWorldProfile: CustomWorldProfile | null;
|
||||
agentSessionProfile: CustomWorldProfile | null;
|
||||
agentSession: CustomWorldAgentSessionSnapshot | null;
|
||||
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
|
||||
executePublishWorld: () => Promise<CustomWorldAgentSessionSnapshot | null>;
|
||||
syncAgentDraftResultProfile: (
|
||||
profile: CustomWorldProfile,
|
||||
) => Promise<{
|
||||
profile: CustomWorldProfile | null;
|
||||
session: CustomWorldAgentSessionSnapshot | null;
|
||||
}>;
|
||||
setGeneratedCustomWorldProfile: (profile: CustomWorldProfile | null) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 统一“进入世界”前的最终同步策略。
|
||||
* 非 Agent 草稿结果直接进入;Agent 草稿结果必须先把当前结果页并回 session。
|
||||
*/
|
||||
export function useRpgCreationEnterWorld(
|
||||
params: UseRpgCreationEnterWorldParams,
|
||||
) {
|
||||
const {
|
||||
isAgentDraftResultView,
|
||||
activeAgentSessionId,
|
||||
generatedCustomWorldProfile,
|
||||
agentSessionProfile,
|
||||
agentSession,
|
||||
handleCustomWorldSelect,
|
||||
executePublishWorld,
|
||||
syncAgentDraftResultProfile,
|
||||
setGeneratedCustomWorldProfile,
|
||||
} = params;
|
||||
|
||||
const enterWorldFromCurrentResult = useCallback(async () => {
|
||||
if (!generatedCustomWorldProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAgentDraftResultView || !activeAgentSessionId) {
|
||||
handleCustomWorldSelect(generatedCustomWorldProfile);
|
||||
return;
|
||||
}
|
||||
|
||||
const latestResult = await syncAgentDraftResultProfile(
|
||||
generatedCustomWorldProfile,
|
||||
);
|
||||
const latestProfile =
|
||||
latestResult.profile ?? agentSessionProfile ?? generatedCustomWorldProfile;
|
||||
setGeneratedCustomWorldProfile(latestProfile);
|
||||
|
||||
const latestSession = latestResult.session ?? agentSession;
|
||||
const canEnterPublishedWorld =
|
||||
latestSession?.stage === 'published' &&
|
||||
latestSession.resultPreview?.canEnterWorld;
|
||||
|
||||
if (canEnterPublishedWorld) {
|
||||
handleCustomWorldSelect(latestProfile);
|
||||
return;
|
||||
}
|
||||
|
||||
const publishedSession = await executePublishWorld();
|
||||
const publishedProfile =
|
||||
rpgCreationPreviewAdapter.buildPreviewFromSession(publishedSession) ??
|
||||
latestProfile;
|
||||
|
||||
setGeneratedCustomWorldProfile(publishedProfile);
|
||||
handleCustomWorldSelect(publishedProfile);
|
||||
}, [
|
||||
activeAgentSessionId,
|
||||
agentSession,
|
||||
agentSessionProfile,
|
||||
executePublishWorld,
|
||||
generatedCustomWorldProfile,
|
||||
handleCustomWorldSelect,
|
||||
isAgentDraftResultView,
|
||||
setGeneratedCustomWorldProfile,
|
||||
syncAgentDraftResultProfile,
|
||||
]);
|
||||
|
||||
return {
|
||||
enterWorldFromCurrentResult,
|
||||
};
|
||||
}
|
||||
392
src/components/rpg-entry/useRpgCreationResultAutosave.ts
Normal file
392
src/components/rpg-entry/useRpgCreationResultAutosave.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
CustomWorldAgentOperationRecord,
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type {
|
||||
CustomWorldLibraryEntry,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
executeRpgCreationAction,
|
||||
getRpgCreationOperation,
|
||||
upsertRpgWorldProfile,
|
||||
} from '../../services/rpg-creation';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import {
|
||||
normalizeAgentBackedProfile,
|
||||
resolveRpgCreationErrorMessage,
|
||||
stringifyAgentBackedProfile,
|
||||
} from './rpgEntryShared';
|
||||
import type {
|
||||
CustomWorldAutoSaveState,
|
||||
SelectionStage,
|
||||
SyncedAgentDraftResult,
|
||||
} from './rpgEntryTypes';
|
||||
|
||||
type UseRpgCreationResultAutosaveParams = {
|
||||
selectionStage: SelectionStage;
|
||||
activeAgentSessionId: string | null;
|
||||
agentSession: CustomWorldAgentSessionSnapshot | null;
|
||||
generatedCustomWorldProfile: CustomWorldProfile | null;
|
||||
isAgentDraftResultView: boolean;
|
||||
userId: string | null | undefined;
|
||||
setGeneratedCustomWorldProfile: (profile: CustomWorldProfile | null) => void;
|
||||
setAgentOperation: (
|
||||
operation: CustomWorldAgentOperationRecord | null,
|
||||
) => void;
|
||||
setSavedCustomWorldEntries: (
|
||||
entries: CustomWorldLibraryEntry<CustomWorldProfile>[],
|
||||
) => void;
|
||||
setSelectedDetailEntry: (
|
||||
updater:
|
||||
| CustomWorldLibraryEntry<CustomWorldProfile>
|
||||
| null
|
||||
| ((
|
||||
current: CustomWorldLibraryEntry<CustomWorldProfile> | null,
|
||||
) => CustomWorldLibraryEntry<CustomWorldProfile> | null),
|
||||
) => void;
|
||||
refreshCustomWorldWorks: () => Promise<unknown>;
|
||||
persistAgentUiState: (
|
||||
sessionId: string | null,
|
||||
operationId: string | null,
|
||||
) => void;
|
||||
syncAgentSessionSnapshot: (
|
||||
sessionId: string,
|
||||
) => Promise<CustomWorldAgentSessionSnapshot | null>;
|
||||
buildDraftResultProfile: (
|
||||
session: CustomWorldAgentSessionSnapshot | null,
|
||||
) => CustomWorldProfile | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 协调结果页自动保存与 Agent 草稿同步。
|
||||
* 这里统一维护去重签名、延时保存和“先同步 session 再落作品库”的顺序。
|
||||
*/
|
||||
export function useRpgCreationResultAutosave(
|
||||
params: UseRpgCreationResultAutosaveParams,
|
||||
) {
|
||||
const {
|
||||
selectionStage,
|
||||
activeAgentSessionId,
|
||||
agentSession,
|
||||
generatedCustomWorldProfile,
|
||||
isAgentDraftResultView,
|
||||
userId,
|
||||
setGeneratedCustomWorldProfile,
|
||||
setAgentOperation,
|
||||
setSavedCustomWorldEntries,
|
||||
setSelectedDetailEntry,
|
||||
refreshCustomWorldWorks,
|
||||
persistAgentUiState,
|
||||
syncAgentSessionSnapshot,
|
||||
buildDraftResultProfile,
|
||||
} = params;
|
||||
|
||||
const customWorldAutoSaveTimeoutRef = useRef<number | null>(null);
|
||||
const lastAutoSavedProfileSignatureRef = useRef<string | null>(null);
|
||||
const latestAutoSaveRequestIdRef = useRef(0);
|
||||
const latestAgentResultSyncSignatureRef = useRef<string | null>(null);
|
||||
const isCustomWorldAutoSaveBusyRef = useRef(false);
|
||||
|
||||
const [customWorldAutoSaveState, setCustomWorldAutoSaveState] =
|
||||
useState<CustomWorldAutoSaveState>('idle');
|
||||
const [customWorldAutoSaveError, setCustomWorldAutoSaveError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const resetAutoSaveTrackingToIdle = useCallback(() => {
|
||||
if (customWorldAutoSaveTimeoutRef.current !== null) {
|
||||
window.clearTimeout(customWorldAutoSaveTimeoutRef.current);
|
||||
customWorldAutoSaveTimeoutRef.current = null;
|
||||
}
|
||||
lastAutoSavedProfileSignatureRef.current = null;
|
||||
latestAgentResultSyncSignatureRef.current = null;
|
||||
setCustomWorldAutoSaveState('idle');
|
||||
setCustomWorldAutoSaveError(null);
|
||||
}, []);
|
||||
|
||||
const markAutoSavedProfile = useCallback((profile: CustomWorldProfile) => {
|
||||
lastAutoSavedProfileSignatureRef.current =
|
||||
stringifyAgentBackedProfile(profile);
|
||||
}, []);
|
||||
|
||||
const saveGeneratedCustomWorld = useCallback(
|
||||
async (profile = generatedCustomWorldProfile) => {
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedProfile = normalizeAgentBackedProfile(profile);
|
||||
const profileSignature = stringifyAgentBackedProfile(normalizedProfile);
|
||||
const requestId = latestAutoSaveRequestIdRef.current + 1;
|
||||
latestAutoSaveRequestIdRef.current = requestId;
|
||||
setCustomWorldAutoSaveState('saving');
|
||||
setCustomWorldAutoSaveError(null);
|
||||
|
||||
try {
|
||||
const mutation =
|
||||
await upsertRpgWorldProfile(normalizedProfile);
|
||||
if (latestAutoSaveRequestIdRef.current !== requestId) {
|
||||
return mutation;
|
||||
}
|
||||
|
||||
lastAutoSavedProfileSignatureRef.current = profileSignature;
|
||||
setSavedCustomWorldEntries(mutation.entries);
|
||||
if (userId) {
|
||||
void refreshCustomWorldWorks().catch(() => {});
|
||||
}
|
||||
setSelectedDetailEntry((current) => {
|
||||
if (!current || current.profileId === mutation.entry.profileId) {
|
||||
return mutation.entry;
|
||||
}
|
||||
|
||||
return current;
|
||||
});
|
||||
setCustomWorldAutoSaveState('saved');
|
||||
setCustomWorldAutoSaveError(null);
|
||||
return mutation;
|
||||
} catch (error) {
|
||||
if (latestAutoSaveRequestIdRef.current !== requestId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setCustomWorldAutoSaveState('error');
|
||||
setCustomWorldAutoSaveError(
|
||||
resolveRpgCreationErrorMessage(error, '保存自定义世界失败。'),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[
|
||||
generatedCustomWorldProfile,
|
||||
refreshCustomWorldWorks,
|
||||
setSavedCustomWorldEntries,
|
||||
setSelectedDetailEntry,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
const syncAgentDraftResultProfile = useCallback(
|
||||
async (profile: CustomWorldProfile) => {
|
||||
if (!activeAgentSessionId) {
|
||||
return {
|
||||
session: null,
|
||||
profile: null,
|
||||
} satisfies SyncedAgentDraftResult;
|
||||
}
|
||||
|
||||
const normalizedProfile = normalizeAgentBackedProfile(profile);
|
||||
const profileSignature = stringifyAgentBackedProfile(normalizedProfile);
|
||||
const latestSessionProfile = buildDraftResultProfile(agentSession);
|
||||
const latestSessionProfileSignature = latestSessionProfile
|
||||
? stringifyAgentBackedProfile(latestSessionProfile)
|
||||
: '';
|
||||
|
||||
if (latestSessionProfileSignature === profileSignature) {
|
||||
latestAgentResultSyncSignatureRef.current = profileSignature;
|
||||
return {
|
||||
session: agentSession,
|
||||
profile: normalizeAgentBackedProfile(latestSessionProfile ?? profile),
|
||||
} satisfies SyncedAgentDraftResult;
|
||||
}
|
||||
|
||||
if (latestAgentResultSyncSignatureRef.current === profileSignature) {
|
||||
return {
|
||||
session: agentSession,
|
||||
profile: normalizeAgentBackedProfile(latestSessionProfile ?? profile),
|
||||
} satisfies SyncedAgentDraftResult;
|
||||
}
|
||||
|
||||
const { operation } = await executeRpgCreationAction(
|
||||
activeAgentSessionId,
|
||||
{
|
||||
action: 'sync_result_profile',
|
||||
profile: normalizedProfile as unknown as Record<string, unknown>,
|
||||
},
|
||||
);
|
||||
setAgentOperation(operation);
|
||||
persistAgentUiState(activeAgentSessionId, operation.operationId);
|
||||
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
const latestOperation = await getRpgCreationOperation(
|
||||
activeAgentSessionId,
|
||||
operation.operationId,
|
||||
);
|
||||
setAgentOperation(latestOperation);
|
||||
|
||||
if (latestOperation.status === 'failed') {
|
||||
throw new Error(
|
||||
latestOperation.error ||
|
||||
latestOperation.phaseDetail ||
|
||||
'同步结果页世界快照失败。',
|
||||
);
|
||||
}
|
||||
|
||||
if (latestOperation.status === 'completed') {
|
||||
persistAgentUiState(activeAgentSessionId, null);
|
||||
const latestSession = await syncAgentSessionSnapshot(
|
||||
activeAgentSessionId,
|
||||
);
|
||||
const latestProfile = normalizeAgentBackedProfile(
|
||||
buildDraftResultProfile(latestSession) ?? profile,
|
||||
);
|
||||
if (latestProfile) {
|
||||
setGeneratedCustomWorldProfile(latestProfile);
|
||||
}
|
||||
latestAgentResultSyncSignatureRef.current = profileSignature;
|
||||
return {
|
||||
session: latestSession,
|
||||
profile: latestProfile,
|
||||
} satisfies SyncedAgentDraftResult;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 200));
|
||||
}
|
||||
|
||||
throw new Error('同步结果页世界快照超时。');
|
||||
},
|
||||
[
|
||||
activeAgentSessionId,
|
||||
agentSession,
|
||||
buildDraftResultProfile,
|
||||
persistAgentUiState,
|
||||
setAgentOperation,
|
||||
setGeneratedCustomWorldProfile,
|
||||
syncAgentSessionSnapshot,
|
||||
],
|
||||
);
|
||||
|
||||
const executeAgentActionAndWait = useCallback(
|
||||
async (action: Parameters<typeof executeRpgCreationAction>[1]) => {
|
||||
if (!activeAgentSessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { operation } = await executeRpgCreationAction(
|
||||
activeAgentSessionId,
|
||||
action,
|
||||
);
|
||||
setAgentOperation(operation);
|
||||
persistAgentUiState(activeAgentSessionId, operation.operationId);
|
||||
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
const latestOperation = await getRpgCreationOperation(
|
||||
activeAgentSessionId,
|
||||
operation.operationId,
|
||||
);
|
||||
setAgentOperation(latestOperation);
|
||||
|
||||
if (latestOperation.status === 'failed') {
|
||||
throw new Error(
|
||||
latestOperation.error ||
|
||||
latestOperation.phaseDetail ||
|
||||
'执行共创操作失败。',
|
||||
);
|
||||
}
|
||||
|
||||
if (latestOperation.status === 'completed') {
|
||||
persistAgentUiState(activeAgentSessionId, null);
|
||||
return syncAgentSessionSnapshot(activeAgentSessionId);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 200));
|
||||
}
|
||||
|
||||
throw new Error('执行共创操作超时。');
|
||||
},
|
||||
[
|
||||
activeAgentSessionId,
|
||||
persistAgentUiState,
|
||||
setAgentOperation,
|
||||
syncAgentSessionSnapshot,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (customWorldAutoSaveTimeoutRef.current !== null) {
|
||||
window.clearTimeout(customWorldAutoSaveTimeoutRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!generatedCustomWorldProfile) {
|
||||
resetAutoSaveTrackingToIdle();
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectionStage !== 'custom-world-result') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCustomWorldAutoSaveBusyRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSignature = stringifyAgentBackedProfile(generatedCustomWorldProfile);
|
||||
if (nextSignature === lastAutoSavedProfileSignatureRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCustomWorldAutoSaveState('saving');
|
||||
if (customWorldAutoSaveTimeoutRef.current !== null) {
|
||||
window.clearTimeout(customWorldAutoSaveTimeoutRef.current);
|
||||
}
|
||||
|
||||
const profileToSave = generatedCustomWorldProfile;
|
||||
customWorldAutoSaveTimeoutRef.current = window.setTimeout(() => {
|
||||
void (async () => {
|
||||
isCustomWorldAutoSaveBusyRef.current = true;
|
||||
try {
|
||||
let latestProfileToSave = normalizeAgentBackedProfile(profileToSave);
|
||||
if (isAgentDraftResultView) {
|
||||
const syncedResult =
|
||||
await syncAgentDraftResultProfile(profileToSave);
|
||||
// 作品库自动保存优先落同步后 session 重编译出的结果,避免继续保存旧的前端内存态。
|
||||
latestProfileToSave = normalizeAgentBackedProfile(
|
||||
syncedResult.profile ?? profileToSave,
|
||||
);
|
||||
}
|
||||
await saveGeneratedCustomWorld(latestProfileToSave);
|
||||
} catch (error) {
|
||||
setCustomWorldAutoSaveState('error');
|
||||
setCustomWorldAutoSaveError(
|
||||
resolveRpgCreationErrorMessage(error, '保存自定义世界失败。'),
|
||||
);
|
||||
} finally {
|
||||
isCustomWorldAutoSaveBusyRef.current = false;
|
||||
}
|
||||
})();
|
||||
customWorldAutoSaveTimeoutRef.current = null;
|
||||
}, 600);
|
||||
|
||||
return () => {
|
||||
if (customWorldAutoSaveTimeoutRef.current !== null) {
|
||||
window.clearTimeout(customWorldAutoSaveTimeoutRef.current);
|
||||
customWorldAutoSaveTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
generatedCustomWorldProfile,
|
||||
isAgentDraftResultView,
|
||||
resetAutoSaveTrackingToIdle,
|
||||
saveGeneratedCustomWorld,
|
||||
selectionStage,
|
||||
syncAgentDraftResultProfile,
|
||||
]);
|
||||
|
||||
return {
|
||||
customWorldAutoSaveState,
|
||||
setCustomWorldAutoSaveState,
|
||||
customWorldAutoSaveError,
|
||||
setCustomWorldAutoSaveError,
|
||||
resetAutoSaveTrackingToIdle,
|
||||
markAutoSavedProfile,
|
||||
saveGeneratedCustomWorld,
|
||||
syncAgentDraftResultProfile,
|
||||
executeAgentActionAndWait,
|
||||
};
|
||||
}
|
||||
570
src/components/rpg-entry/useRpgCreationSessionController.ts
Normal file
570
src/components/rpg-entry/useRpgCreationSessionController.ts
Normal file
@@ -0,0 +1,570 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
CustomWorldAgentActionRequest,
|
||||
CustomWorldAgentOperationRecord,
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
SendCustomWorldAgentMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import {
|
||||
buildAgentDraftFoundationAnchorEntries,
|
||||
buildAgentDraftFoundationGenerationProgress,
|
||||
buildAgentDraftFoundationSettingText,
|
||||
isDraftFoundationOperation,
|
||||
isDraftFoundationOperationRunning,
|
||||
} from '../../services/customWorldAgentGenerationProgress';
|
||||
import {
|
||||
readCustomWorldAgentUiState,
|
||||
writeCustomWorldAgentUiState,
|
||||
} from '../../services/customWorldAgentUiState';
|
||||
import {
|
||||
createRpgCreationSession,
|
||||
executeRpgCreationAction,
|
||||
getRpgCreationSession,
|
||||
streamRpgCreationMessage,
|
||||
} from '../../services/rpg-creation';
|
||||
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import {
|
||||
buildOptimisticAgentMessage,
|
||||
createFailedAgentOperation,
|
||||
normalizeAgentBackedProfile,
|
||||
resolveRpgCreationErrorMessage,
|
||||
} from './rpgEntryShared';
|
||||
import type {
|
||||
CustomWorldGenerationViewSource,
|
||||
CustomWorldResultViewSource,
|
||||
SelectionStage,
|
||||
} from './rpgEntryTypes';
|
||||
|
||||
type UseRpgCreationSessionControllerParams = {
|
||||
userId: string | null | undefined;
|
||||
openLoginModal?: ((postLoginAction?: (() => void) | null) => void) | undefined;
|
||||
selectionStage: SelectionStage;
|
||||
setSelectionStage: (stage: SelectionStage) => void;
|
||||
enterCreateTab?: (() => void) | undefined;
|
||||
onSessionOpened?: (() => void) | undefined;
|
||||
};
|
||||
|
||||
export function useRpgCreationSessionController(
|
||||
params: UseRpgCreationSessionControllerParams,
|
||||
) {
|
||||
const {
|
||||
userId,
|
||||
openLoginModal,
|
||||
selectionStage,
|
||||
setSelectionStage,
|
||||
enterCreateTab,
|
||||
onSessionOpened,
|
||||
} = params;
|
||||
const initialAgentUiStateRef = useRef(readCustomWorldAgentUiState());
|
||||
const hasAppliedInitialAgentWorkspaceRef = useRef(false);
|
||||
const hasRequestedInitialAgentWorkspaceAuthRef = useRef(false);
|
||||
const isAgentDraftResultAutoOpenSuppressedRef = useRef(false);
|
||||
|
||||
const [isCreatingAgentSession, setIsCreatingAgentSession] = useState(false);
|
||||
const [activeAgentSessionId, setActiveAgentSessionId] = useState<
|
||||
string | null
|
||||
>(() => initialAgentUiStateRef.current.activeSessionId ?? null);
|
||||
const [activeAgentOperationId, setActiveAgentOperationId] = useState<
|
||||
string | null
|
||||
>(() => initialAgentUiStateRef.current.activeOperationId ?? null);
|
||||
const [agentSession, setAgentSession] =
|
||||
useState<CustomWorldAgentSessionSnapshot | null>(null);
|
||||
const [agentOperation, setAgentOperation] =
|
||||
useState<CustomWorldAgentOperationRecord | null>(null);
|
||||
const [streamingAgentReplyText, setStreamingAgentReplyText] = useState('');
|
||||
const [isStreamingAgentReply, setIsStreamingAgentReply] = useState(false);
|
||||
const [isLoadingAgentSession, setIsLoadingAgentSession] = useState(false);
|
||||
const [creationTypeError, setCreationTypeError] = useState<string | null>(null);
|
||||
const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] =
|
||||
useState<CustomWorldProfile | null>(null);
|
||||
const [customWorldError, setCustomWorldError] = useState<string | null>(null);
|
||||
const [customWorldGenerationViewSource, setCustomWorldGenerationViewSource] =
|
||||
useState<CustomWorldGenerationViewSource>(null);
|
||||
const [customWorldResultViewSource, setCustomWorldResultViewSource] =
|
||||
useState<CustomWorldResultViewSource>(null);
|
||||
const [agentDraftGenerationStartedAt, setAgentDraftGenerationStartedAt] =
|
||||
useState<number | null>(null);
|
||||
|
||||
const persistAgentUiState = useCallback(
|
||||
(nextSessionId: string | null, nextOperationId: string | null) => {
|
||||
setActiveAgentSessionId(nextSessionId);
|
||||
setActiveAgentOperationId(nextOperationId);
|
||||
writeCustomWorldAgentUiState({
|
||||
activeSessionId: nextSessionId,
|
||||
activeOperationId: nextOperationId,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const syncAgentSessionSnapshot = useCallback(async (sessionId: string) => {
|
||||
const nextSession = await getRpgCreationSession(sessionId);
|
||||
setAgentSession(nextSession);
|
||||
return nextSession;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const initialAgentSessionId = initialAgentUiStateRef.current.activeSessionId;
|
||||
|
||||
if (!initialAgentSessionId || hasAppliedInitialAgentWorkspaceRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
enterCreateTab?.();
|
||||
|
||||
if (!userId) {
|
||||
if (!hasRequestedInitialAgentWorkspaceAuthRef.current) {
|
||||
hasRequestedInitialAgentWorkspaceAuthRef.current = true;
|
||||
openLoginModal?.(() => {
|
||||
setSelectionStage('agent-workspace');
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
hasAppliedInitialAgentWorkspaceRef.current = true;
|
||||
setSelectionStage('agent-workspace');
|
||||
}, [enterCreateTab, openLoginModal, setSelectionStage, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeAgentSessionId) {
|
||||
setAgentSession(null);
|
||||
setAgentOperation(null);
|
||||
setIsLoadingAgentSession(false);
|
||||
setStreamingAgentReplyText('');
|
||||
setIsStreamingAgentReply(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
setAgentSession(null);
|
||||
setAgentOperation(null);
|
||||
setIsLoadingAgentSession(false);
|
||||
setStreamingAgentReplyText('');
|
||||
setIsStreamingAgentReply(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingAgentSession(true);
|
||||
|
||||
void syncAgentSessionSnapshot(activeAgentSessionId)
|
||||
.then(() => {
|
||||
if (!cancelled) {
|
||||
setCreationTypeError(null);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCreationTypeError(
|
||||
resolveRpgCreationErrorMessage(error, '读取 Agent 共创工作区失败。'),
|
||||
);
|
||||
setAgentSession(null);
|
||||
setAgentOperation(null);
|
||||
setStreamingAgentReplyText('');
|
||||
setIsStreamingAgentReply(false);
|
||||
persistAgentUiState(null, null);
|
||||
enterCreateTab?.();
|
||||
setSelectionStage('platform');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoadingAgentSession(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
activeAgentSessionId,
|
||||
enterCreateTab,
|
||||
persistAgentUiState,
|
||||
setSelectionStage,
|
||||
syncAgentSessionSnapshot,
|
||||
userId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isDraftFoundationOperationRunning(agentOperation) ||
|
||||
agentDraftGenerationStartedAt
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAgentDraftGenerationStartedAt(Date.now());
|
||||
}, [agentDraftGenerationStartedAt, agentOperation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectionStage !== 'custom-world-generating' ||
|
||||
customWorldGenerationViewSource !== 'agent-draft-foundation' ||
|
||||
!isDraftFoundationOperation(agentOperation) ||
|
||||
agentOperation.status !== 'completed'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void (async () => {
|
||||
const latestSession = activeAgentSessionId
|
||||
? await syncAgentSessionSnapshot(activeAgentSessionId).catch(
|
||||
() => null,
|
||||
)
|
||||
: agentSession;
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const draftResultProfile =
|
||||
rpgCreationPreviewAdapter.buildPreviewFromSession(
|
||||
latestSession ?? agentSession,
|
||||
);
|
||||
if (!draftResultProfile) {
|
||||
setAgentDraftGenerationStartedAt(null);
|
||||
setCustomWorldGenerationViewSource(null);
|
||||
setSelectionStage('agent-workspace');
|
||||
return;
|
||||
}
|
||||
|
||||
setGeneratedCustomWorldProfile(
|
||||
normalizeAgentBackedProfile(draftResultProfile),
|
||||
);
|
||||
setAgentDraftGenerationStartedAt(null);
|
||||
setCustomWorldGenerationViewSource(null);
|
||||
setCustomWorldResultViewSource('agent-draft');
|
||||
setSelectionStage('custom-world-result');
|
||||
})();
|
||||
}, 900);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [
|
||||
activeAgentSessionId,
|
||||
agentOperation,
|
||||
agentSession,
|
||||
customWorldGenerationViewSource,
|
||||
selectionStage,
|
||||
setSelectionStage,
|
||||
syncAgentSessionSnapshot,
|
||||
]);
|
||||
|
||||
const agentDraftSettingPreview = useMemo(
|
||||
() => buildAgentDraftFoundationSettingText(agentSession),
|
||||
[agentSession],
|
||||
);
|
||||
const agentDraftAnchorPreviewEntries = useMemo(
|
||||
() => buildAgentDraftFoundationAnchorEntries(agentSession),
|
||||
[agentSession],
|
||||
);
|
||||
const agentDraftResultProfile = useMemo(
|
||||
() => rpgCreationPreviewAdapter.buildPreviewFromSession(agentSession),
|
||||
[agentSession],
|
||||
);
|
||||
const shouldAutoOpenAgentDraftResult = useMemo(
|
||||
() =>
|
||||
Boolean(
|
||||
agentDraftResultProfile &&
|
||||
agentSession &&
|
||||
(agentSession.stage === 'object_refining' ||
|
||||
agentSession.stage === 'visual_refining' ||
|
||||
agentSession.stage === 'long_tail_review' ||
|
||||
agentSession.stage === 'ready_to_publish' ||
|
||||
agentSession.stage === 'published') &&
|
||||
agentSession.draftCards.length > 0,
|
||||
),
|
||||
[agentDraftResultProfile, agentSession],
|
||||
);
|
||||
|
||||
const agentDraftGenerationProgress = useMemo(
|
||||
() =>
|
||||
buildAgentDraftFoundationGenerationProgress(
|
||||
agentOperation,
|
||||
agentDraftGenerationStartedAt,
|
||||
),
|
||||
[agentDraftGenerationStartedAt, agentOperation],
|
||||
);
|
||||
|
||||
const isAgentDraftGenerationView =
|
||||
customWorldGenerationViewSource === 'agent-draft-foundation';
|
||||
const isAgentDraftResultView = customWorldResultViewSource === 'agent-draft';
|
||||
const isActiveGenerationRunning =
|
||||
isDraftFoundationOperationRunning(agentOperation);
|
||||
const activeGenerationError =
|
||||
isDraftFoundationOperation(agentOperation) &&
|
||||
agentOperation.status === 'failed'
|
||||
? agentOperation.error || agentOperation.phaseDetail
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldAutoOpenAgentDraftResult || !agentDraftResultProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAgentDraftResultAutoOpenSuppressedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectionStage === 'agent-workspace') {
|
||||
setGeneratedCustomWorldProfile(agentDraftResultProfile);
|
||||
setCustomWorldResultViewSource('agent-draft');
|
||||
isAgentDraftResultAutoOpenSuppressedRef.current = false;
|
||||
setSelectionStage('custom-world-result');
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
selectionStage === 'custom-world-result' &&
|
||||
!generatedCustomWorldProfile
|
||||
) {
|
||||
setGeneratedCustomWorldProfile(agentDraftResultProfile);
|
||||
setCustomWorldResultViewSource('agent-draft');
|
||||
isAgentDraftResultAutoOpenSuppressedRef.current = false;
|
||||
}
|
||||
}, [
|
||||
agentDraftResultProfile,
|
||||
generatedCustomWorldProfile,
|
||||
selectionStage,
|
||||
setSelectionStage,
|
||||
shouldAutoOpenAgentDraftResult,
|
||||
]);
|
||||
|
||||
const openRpgAgentWorkspace = useCallback(
|
||||
async (seedText = '') => {
|
||||
if (isCreatingAgentSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreatingAgentSession(true);
|
||||
setCreationTypeError(null);
|
||||
isAgentDraftResultAutoOpenSuppressedRef.current = false;
|
||||
|
||||
try {
|
||||
const { session } = await createRpgCreationSession(
|
||||
seedText ? { seedText } : {},
|
||||
);
|
||||
setAgentSession(session);
|
||||
setAgentOperation(null);
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
setCustomWorldError(null);
|
||||
setAgentDraftGenerationStartedAt(null);
|
||||
setCustomWorldGenerationViewSource(null);
|
||||
setCustomWorldResultViewSource(null);
|
||||
enterCreateTab?.();
|
||||
onSessionOpened?.();
|
||||
persistAgentUiState(session.sessionId, null);
|
||||
setSelectionStage('agent-workspace');
|
||||
} catch (error) {
|
||||
setCreationTypeError(
|
||||
resolveRpgCreationErrorMessage(error, '开启共创工作台失败。'),
|
||||
);
|
||||
} finally {
|
||||
setIsCreatingAgentSession(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
enterCreateTab,
|
||||
isCreatingAgentSession,
|
||||
onSessionOpened,
|
||||
persistAgentUiState,
|
||||
setSelectionStage,
|
||||
],
|
||||
);
|
||||
|
||||
const submitAgentMessage = useCallback(
|
||||
async (payload: SendCustomWorldAgentMessageRequest) => {
|
||||
if (!activeAgentSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const optimisticUserMessage = buildOptimisticAgentMessage({
|
||||
id: payload.clientMessageId,
|
||||
role: 'user',
|
||||
kind: 'chat',
|
||||
text: payload.text.trim(),
|
||||
});
|
||||
|
||||
setAgentOperation(null);
|
||||
persistAgentUiState(activeAgentSessionId, null);
|
||||
setStreamingAgentReplyText('');
|
||||
setIsStreamingAgentReply(true);
|
||||
setAgentSession((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
messages: [...current.messages, optimisticUserMessage],
|
||||
updatedAt: optimisticUserMessage.createdAt,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
|
||||
try {
|
||||
const nextSession = await streamRpgCreationMessage(
|
||||
activeAgentSessionId,
|
||||
payload,
|
||||
{
|
||||
onUpdate: (text) => {
|
||||
setStreamingAgentReplyText(text);
|
||||
},
|
||||
},
|
||||
);
|
||||
setAgentSession(nextSession);
|
||||
setAgentOperation(null);
|
||||
setStreamingAgentReplyText('');
|
||||
} catch (error) {
|
||||
const errorMessage = resolveRpgCreationErrorMessage(
|
||||
error,
|
||||
'发送共创消息失败。',
|
||||
);
|
||||
setAgentSession((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
messages: [
|
||||
...current.messages,
|
||||
buildOptimisticAgentMessage({
|
||||
id: `message-error-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
kind: 'warning',
|
||||
text: errorMessage,
|
||||
}),
|
||||
],
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
setStreamingAgentReplyText('');
|
||||
persistAgentUiState(activeAgentSessionId, null);
|
||||
} finally {
|
||||
setIsStreamingAgentReply(false);
|
||||
}
|
||||
},
|
||||
[activeAgentSessionId, persistAgentUiState],
|
||||
);
|
||||
|
||||
const executeAgentAction = useCallback(
|
||||
async (payload: CustomWorldAgentActionRequest) => {
|
||||
if (!activeAgentSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isDraftFoundationAction = payload.action === 'draft_foundation';
|
||||
|
||||
if (isDraftFoundationAction) {
|
||||
isAgentDraftResultAutoOpenSuppressedRef.current = false;
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldGenerationViewSource('agent-draft-foundation');
|
||||
setCustomWorldResultViewSource(null);
|
||||
setAgentDraftGenerationStartedAt(Date.now());
|
||||
setSelectionStage('custom-world-generating');
|
||||
}
|
||||
|
||||
try {
|
||||
const { operation } = await executeRpgCreationAction(
|
||||
activeAgentSessionId,
|
||||
payload,
|
||||
);
|
||||
setAgentOperation(operation);
|
||||
persistAgentUiState(activeAgentSessionId, operation.operationId);
|
||||
} catch (error) {
|
||||
const errorMessage = resolveRpgCreationErrorMessage(
|
||||
error,
|
||||
'执行共创操作失败。',
|
||||
);
|
||||
setAgentOperation(
|
||||
createFailedAgentOperation({
|
||||
type:
|
||||
payload.action === 'draft_foundation'
|
||||
? 'draft_foundation'
|
||||
: payload.action,
|
||||
phaseLabel: '执行操作失败',
|
||||
error: errorMessage,
|
||||
}),
|
||||
);
|
||||
persistAgentUiState(activeAgentSessionId, null);
|
||||
}
|
||||
},
|
||||
[activeAgentSessionId, persistAgentUiState, setSelectionStage],
|
||||
);
|
||||
|
||||
const setNormalizedGeneratedCustomWorldProfile = useCallback(
|
||||
(profile: CustomWorldProfile | null) => {
|
||||
setGeneratedCustomWorldProfile(
|
||||
profile ? normalizeAgentBackedProfile(profile) : null,
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const resetSessionViewState = useCallback(() => {
|
||||
setAgentOperation(null);
|
||||
setStreamingAgentReplyText('');
|
||||
setIsStreamingAgentReply(false);
|
||||
setAgentDraftGenerationStartedAt(null);
|
||||
setCustomWorldGenerationViewSource(null);
|
||||
setCustomWorldResultViewSource(null);
|
||||
}, []);
|
||||
|
||||
const suppressAgentDraftResultAutoOpen = useCallback(() => {
|
||||
isAgentDraftResultAutoOpenSuppressedRef.current = true;
|
||||
}, []);
|
||||
|
||||
const releaseAgentDraftResultAutoOpenSuppression = useCallback(() => {
|
||||
isAgentDraftResultAutoOpenSuppressedRef.current = false;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
initialAgentSessionId: initialAgentUiStateRef.current.activeSessionId ?? null,
|
||||
isCreatingAgentSession,
|
||||
activeAgentSessionId,
|
||||
activeAgentOperationId,
|
||||
agentSession,
|
||||
setAgentSession,
|
||||
agentOperation,
|
||||
setAgentOperation,
|
||||
resetSessionViewState,
|
||||
streamingAgentReplyText,
|
||||
isStreamingAgentReply,
|
||||
isLoadingAgentSession,
|
||||
creationTypeError,
|
||||
setCreationTypeError,
|
||||
customWorldError,
|
||||
setCustomWorldError,
|
||||
generatedCustomWorldProfile,
|
||||
setGeneratedCustomWorldProfile: setNormalizedGeneratedCustomWorldProfile,
|
||||
rawSetGeneratedCustomWorldProfile: setGeneratedCustomWorldProfile,
|
||||
customWorldGenerationViewSource,
|
||||
setCustomWorldGenerationViewSource,
|
||||
customWorldResultViewSource,
|
||||
setCustomWorldResultViewSource,
|
||||
agentDraftGenerationStartedAt,
|
||||
setAgentDraftGenerationStartedAt,
|
||||
agentDraftSettingPreview,
|
||||
agentDraftAnchorPreviewEntries,
|
||||
agentDraftResultProfile,
|
||||
agentDraftGenerationProgress,
|
||||
isAgentDraftGenerationView,
|
||||
isAgentDraftResultView,
|
||||
isActiveGenerationRunning,
|
||||
activeGenerationError,
|
||||
isAgentDraftResultAutoOpenSuppressedRef,
|
||||
suppressAgentDraftResultAutoOpen,
|
||||
releaseAgentDraftResultAutoOpenSuppression,
|
||||
persistAgentUiState,
|
||||
syncAgentSessionSnapshot,
|
||||
openRpgAgentWorkspace,
|
||||
submitAgentMessage,
|
||||
executeAgentAction,
|
||||
};
|
||||
}
|
||||
346
src/components/rpg-entry/useRpgEntryBootstrap.ts
Normal file
346
src/components/rpg-entry/useRpgEntryBootstrap.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
CustomWorldWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
PlatformBrowseHistoryEntry,
|
||||
PlatformBrowseHistoryWriteEntry,
|
||||
ProfileDashboardSummary,
|
||||
ProfileSaveArchiveSummary,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import { listRpgCreationWorks } from '../../services/rpg-creation/index';
|
||||
import {
|
||||
listRpgEntryWorldGallery,
|
||||
listRpgEntryWorldLibrary,
|
||||
listRpgProfileBrowseHistory,
|
||||
listRpgProfileSaveArchives,
|
||||
resumeRpgProfileSaveArchive,
|
||||
upsertRpgProfileBrowseHistory,
|
||||
} from '../../services/rpg-entry';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import type { PlatformHomeTab } from './RpgEntryHomeView';
|
||||
import { resolveRpgEntryErrorMessage } from './rpgEntryShared';
|
||||
|
||||
type UseRpgEntryBootstrapParams = {
|
||||
user: AuthUser | null | undefined;
|
||||
getProfileDashboard: () => Promise<ProfileDashboardSummary | null>;
|
||||
handleContinueGame: (
|
||||
snapshot?: HydratedSavedGameSnapshot | null,
|
||||
) => void;
|
||||
hasInitialAgentSession: boolean;
|
||||
};
|
||||
|
||||
export function useRpgEntryBootstrap(
|
||||
params: UseRpgEntryBootstrapParams,
|
||||
) {
|
||||
const { user, getProfileDashboard, handleContinueGame, hasInitialAgentSession } =
|
||||
params;
|
||||
const isAuthenticated = Boolean(user);
|
||||
const platformTabBootstrapUserIdRef = useRef<string | null | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const [savedCustomWorldEntries, setSavedCustomWorldEntries] = useState<
|
||||
CustomWorldLibraryEntry<CustomWorldProfile>[]
|
||||
>([]);
|
||||
const [customWorldWorkEntries, setCustomWorldWorkEntries] = useState<
|
||||
CustomWorldWorkSummary[]
|
||||
>([]);
|
||||
const [publishedGalleryEntries, setPublishedGalleryEntries] = useState<
|
||||
CustomWorldGalleryCard[]
|
||||
>([]);
|
||||
const [historyEntries, setHistoryEntries] = useState<
|
||||
PlatformBrowseHistoryEntry[]
|
||||
>([]);
|
||||
const [saveEntries, setSaveEntries] = useState<ProfileSaveArchiveSummary[]>([]);
|
||||
const [platformTab, setPlatformTab] = useState<PlatformHomeTab>('home');
|
||||
const [platformError, setPlatformError] = useState<string | null>(null);
|
||||
const [dashboardError, setDashboardError] = useState<string | null>(null);
|
||||
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [isLoadingPlatform, setIsLoadingPlatform] = useState(false);
|
||||
const [isLoadingDashboard, setIsLoadingDashboard] = useState(false);
|
||||
const [isResumingSaveWorldKey, setIsResumingSaveWorldKey] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [profileDashboard, setProfileDashboard] =
|
||||
useState<ProfileDashboardSummary | null>(null);
|
||||
|
||||
const refreshProfileDashboard = useCallback(async () => {
|
||||
if (!user) {
|
||||
setProfileDashboard(null);
|
||||
setDashboardError(null);
|
||||
setIsLoadingDashboard(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingDashboard(true);
|
||||
setDashboardError(null);
|
||||
|
||||
try {
|
||||
setProfileDashboard(await getProfileDashboard());
|
||||
} catch (error) {
|
||||
setDashboardError(
|
||||
resolveRpgEntryErrorMessage(error, '读取个人数据看板失败。'),
|
||||
);
|
||||
} finally {
|
||||
setIsLoadingDashboard(false);
|
||||
}
|
||||
}, [getProfileDashboard, user]);
|
||||
|
||||
const refreshCustomWorldWorks = useCallback(async () => {
|
||||
if (!user) {
|
||||
setCustomWorldWorkEntries([]);
|
||||
return [];
|
||||
}
|
||||
|
||||
const nextItems = await listRpgCreationWorks();
|
||||
setCustomWorldWorkEntries(nextItems);
|
||||
return nextItems;
|
||||
}, [user]);
|
||||
|
||||
const refreshPublishedGallery = useCallback(async () => {
|
||||
const nextEntries = await listRpgEntryWorldGallery();
|
||||
setPublishedGalleryEntries(nextEntries);
|
||||
return nextEntries;
|
||||
}, []);
|
||||
|
||||
const refreshSavedCustomWorldLibrary = useCallback(async () => {
|
||||
if (!user) {
|
||||
setSavedCustomWorldEntries([]);
|
||||
return [];
|
||||
}
|
||||
|
||||
const nextEntries = await listRpgEntryWorldLibrary();
|
||||
setSavedCustomWorldEntries(nextEntries);
|
||||
return nextEntries;
|
||||
}, [user]);
|
||||
|
||||
const appendBrowseHistoryEntry = useCallback(
|
||||
async (entry: PlatformBrowseHistoryWriteEntry) => {
|
||||
setHistoryError(null);
|
||||
|
||||
try {
|
||||
const syncedEntries = await upsertRpgProfileBrowseHistory(entry);
|
||||
setHistoryEntries(syncedEntries);
|
||||
} catch (error) {
|
||||
setHistoryError(
|
||||
resolveRpgEntryErrorMessage(error, '写入浏览历史失败。'),
|
||||
);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleResumeSaveEntry = useCallback(
|
||||
async (entry: ProfileSaveArchiveSummary) => {
|
||||
if (!user || isResumingSaveWorldKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsResumingSaveWorldKey(entry.worldKey);
|
||||
setSaveError(null);
|
||||
|
||||
try {
|
||||
const resumedArchive = await resumeRpgProfileSaveArchive(entry.worldKey);
|
||||
setSaveEntries((currentEntries) =>
|
||||
currentEntries.map((currentEntry) =>
|
||||
currentEntry.worldKey === resumedArchive.entry.worldKey
|
||||
? resumedArchive.entry
|
||||
: currentEntry,
|
||||
),
|
||||
);
|
||||
handleContinueGame(resumedArchive.snapshot);
|
||||
} catch (error) {
|
||||
setSaveError(resolveRpgEntryErrorMessage(error, '恢复存档失败。'));
|
||||
} finally {
|
||||
setIsResumingSaveWorldKey(null);
|
||||
}
|
||||
},
|
||||
[handleContinueGame, isResumingSaveWorldKey, user],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
|
||||
void (async () => {
|
||||
setHistoryEntries([]);
|
||||
setHistoryError(null);
|
||||
setSaveError(null);
|
||||
setIsLoadingPlatform(true);
|
||||
setPlatformError(null);
|
||||
setIsLoadingDashboard(isAuthenticated);
|
||||
setDashboardError(null);
|
||||
if (!isAuthenticated) {
|
||||
setSavedCustomWorldEntries([]);
|
||||
setCustomWorldWorkEntries([]);
|
||||
setSaveEntries([]);
|
||||
setProfileDashboard(null);
|
||||
}
|
||||
|
||||
try {
|
||||
const [
|
||||
libraryEntriesResult,
|
||||
workEntriesResult,
|
||||
galleryEntriesResult,
|
||||
dashboardResult,
|
||||
historyResult,
|
||||
saveArchivesResult,
|
||||
] = await Promise.allSettled([
|
||||
isAuthenticated
|
||||
? listRpgEntryWorldLibrary()
|
||||
: Promise.resolve([]),
|
||||
isAuthenticated
|
||||
? listRpgCreationWorks()
|
||||
: Promise.resolve([]),
|
||||
listRpgEntryWorldGallery(),
|
||||
isAuthenticated ? getProfileDashboard() : Promise.resolve(null),
|
||||
isAuthenticated ? listRpgProfileBrowseHistory() : Promise.resolve([]),
|
||||
isAuthenticated ? listRpgProfileSaveArchives() : Promise.resolve([]),
|
||||
]);
|
||||
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (libraryEntriesResult.status === 'fulfilled') {
|
||||
setSavedCustomWorldEntries(libraryEntriesResult.value);
|
||||
} else {
|
||||
setSavedCustomWorldEntries([]);
|
||||
}
|
||||
|
||||
if (workEntriesResult.status === 'fulfilled') {
|
||||
setCustomWorldWorkEntries(workEntriesResult.value);
|
||||
} else {
|
||||
setCustomWorldWorkEntries([]);
|
||||
}
|
||||
|
||||
if (galleryEntriesResult.status === 'fulfilled') {
|
||||
setPublishedGalleryEntries(galleryEntriesResult.value);
|
||||
} else {
|
||||
setPublishedGalleryEntries([]);
|
||||
}
|
||||
|
||||
if (
|
||||
(isAuthenticated && libraryEntriesResult.status === 'rejected') ||
|
||||
(isAuthenticated && workEntriesResult.status === 'rejected') ||
|
||||
galleryEntriesResult.status === 'rejected'
|
||||
) {
|
||||
const platformFailure =
|
||||
libraryEntriesResult.status === 'rejected'
|
||||
? libraryEntriesResult.reason
|
||||
: workEntriesResult.status === 'rejected'
|
||||
? workEntriesResult.reason
|
||||
: galleryEntriesResult.status === 'rejected'
|
||||
? galleryEntriesResult.reason
|
||||
: null;
|
||||
setPlatformError(
|
||||
resolveRpgEntryErrorMessage(platformFailure, '读取平台数据失败。'),
|
||||
);
|
||||
}
|
||||
|
||||
if (dashboardResult.status === 'fulfilled') {
|
||||
setProfileDashboard(dashboardResult.value);
|
||||
} else if (isAuthenticated) {
|
||||
setProfileDashboard(null);
|
||||
setDashboardError(
|
||||
resolveRpgEntryErrorMessage(
|
||||
dashboardResult.reason,
|
||||
'读取个人数据看板失败。',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (historyResult.status === 'fulfilled') {
|
||||
setHistoryEntries(historyResult.value);
|
||||
} else if (isAuthenticated) {
|
||||
setHistoryError(
|
||||
resolveRpgEntryErrorMessage(historyResult.reason, '读取浏览历史失败。'),
|
||||
);
|
||||
}
|
||||
|
||||
if (saveArchivesResult.status === 'fulfilled') {
|
||||
setSaveEntries(saveArchivesResult.value);
|
||||
} else if (isAuthenticated) {
|
||||
setSaveEntries([]);
|
||||
setSaveError(
|
||||
resolveRpgEntryErrorMessage(
|
||||
saveArchivesResult.reason,
|
||||
'读取存档列表失败。',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const nextPlatformBootstrapUserId = user?.id ?? null;
|
||||
if (
|
||||
platformTabBootstrapUserIdRef.current !== nextPlatformBootstrapUserId
|
||||
) {
|
||||
platformTabBootstrapUserIdRef.current = nextPlatformBootstrapUserId;
|
||||
if (!hasInitialAgentSession) {
|
||||
setPlatformTab(
|
||||
isAuthenticated &&
|
||||
saveArchivesResult.status === 'fulfilled' &&
|
||||
saveArchivesResult.value.length > 0
|
||||
? 'saves'
|
||||
: 'home',
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (isActive) {
|
||||
setIsLoadingPlatform(false);
|
||||
setIsLoadingDashboard(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, [getProfileDashboard, hasInitialAgentSession, isAuthenticated, user]);
|
||||
|
||||
return {
|
||||
isAuthenticated,
|
||||
platformTab,
|
||||
setPlatformTab,
|
||||
savedCustomWorldEntries,
|
||||
setSavedCustomWorldEntries,
|
||||
customWorldWorkEntries,
|
||||
setCustomWorldWorkEntries,
|
||||
publishedGalleryEntries,
|
||||
setPublishedGalleryEntries,
|
||||
historyEntries,
|
||||
setHistoryEntries,
|
||||
saveEntries,
|
||||
setSaveEntries,
|
||||
platformError,
|
||||
setPlatformError,
|
||||
dashboardError,
|
||||
setDashboardError,
|
||||
historyError,
|
||||
setHistoryError,
|
||||
saveError,
|
||||
setSaveError,
|
||||
isLoadingPlatform,
|
||||
isLoadingDashboard,
|
||||
isResumingSaveWorldKey,
|
||||
profileDashboard,
|
||||
setProfileDashboard,
|
||||
refreshProfileDashboard,
|
||||
refreshCustomWorldWorks,
|
||||
refreshPublishedGallery,
|
||||
refreshSavedCustomWorldLibrary,
|
||||
appendBrowseHistoryEntry,
|
||||
handleResumeSaveEntry,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容旧创作链命名,避免并行工作包在本轮迁移后断开导入。
|
||||
*/
|
||||
export const useRpgCreationPlatformBootstrap = useRpgEntryBootstrap;
|
||||
46
src/components/rpg-entry/useRpgEntryCharacterSelect.ts
Normal file
46
src/components/rpg-entry/useRpgEntryCharacterSelect.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { Character, CustomWorldProfile, GameState } from '../../types';
|
||||
|
||||
type UseRpgEntryCharacterSelectParams = {
|
||||
gameState: GameState;
|
||||
handleBackToWorldSelect: () => void;
|
||||
setSelectionStage: (stage: 'platform') => void;
|
||||
handleCharacterSelect: (character: NonNullable<GameState['playerCharacter']>) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 统一角色选择页的返回与确认动作,保持主阶段路由器里只做装配。
|
||||
*/
|
||||
export function useRpgEntryCharacterSelect(
|
||||
params: UseRpgEntryCharacterSelectParams,
|
||||
) {
|
||||
const {
|
||||
gameState,
|
||||
handleBackToWorldSelect,
|
||||
setSelectionStage,
|
||||
handleCharacterSelect,
|
||||
} = params;
|
||||
|
||||
const customWorldProfile: CustomWorldProfile | null =
|
||||
gameState.customWorldProfile;
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
handleBackToWorldSelect();
|
||||
setSelectionStage('platform');
|
||||
}, [handleBackToWorldSelect, setSelectionStage]);
|
||||
|
||||
const handleConfirm = useCallback(
|
||||
(character: Character) => {
|
||||
handleCharacterSelect(character);
|
||||
},
|
||||
[handleCharacterSelect],
|
||||
);
|
||||
|
||||
return {
|
||||
worldType: gameState.worldType,
|
||||
customWorldProfile,
|
||||
handleBack,
|
||||
handleConfirm,
|
||||
};
|
||||
}
|
||||
476
src/components/rpg-entry/useRpgEntryLibraryDetail.ts
Normal file
476
src/components/rpg-entry/useRpgEntryLibraryDetail.ts
Normal file
@@ -0,0 +1,476 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import type {
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
CustomWorldWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
PlatformBrowseHistoryWriteEntry,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
deleteRpgEntryWorldProfile,
|
||||
getRpgEntryWorldGalleryDetail,
|
||||
listRpgEntryWorldLibrary,
|
||||
publishRpgEntryWorldProfile,
|
||||
unpublishRpgEntryWorldProfile,
|
||||
} from '../../services/rpg-entry';
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import {
|
||||
normalizeRpgEntryAgentBackedProfile,
|
||||
resolveRpgEntryErrorMessage,
|
||||
} from './rpgEntryShared';
|
||||
import type {
|
||||
CustomWorldAutoSaveState,
|
||||
CustomWorldGenerationViewSource,
|
||||
CustomWorldResultViewSource,
|
||||
SelectionStage,
|
||||
} from './rpgEntryTypes';
|
||||
|
||||
type UseRpgEntryLibraryDetailParams = {
|
||||
userId: string | null | undefined;
|
||||
selectedDetailEntry: CustomWorldLibraryEntry<CustomWorldProfile> | null;
|
||||
setSelectedDetailEntry: (
|
||||
entry:
|
||||
| CustomWorldLibraryEntry<CustomWorldProfile>
|
||||
| null
|
||||
| ((
|
||||
current: CustomWorldLibraryEntry<CustomWorldProfile> | null,
|
||||
) => CustomWorldLibraryEntry<CustomWorldProfile> | null),
|
||||
) => void;
|
||||
savedCustomWorldEntries: CustomWorldLibraryEntry<CustomWorldProfile>[];
|
||||
setSavedCustomWorldEntries: (
|
||||
entries: CustomWorldLibraryEntry<CustomWorldProfile>[],
|
||||
) => void;
|
||||
setGeneratedCustomWorldProfile: (
|
||||
profile: CustomWorldProfile | null,
|
||||
) => void;
|
||||
setCustomWorldError: (error: string | null) => void;
|
||||
setCustomWorldAutoSaveError: (error: string | null) => void;
|
||||
setCustomWorldAutoSaveState: (state: CustomWorldAutoSaveState) => void;
|
||||
setCustomWorldGenerationViewSource: (
|
||||
source: CustomWorldGenerationViewSource,
|
||||
) => void;
|
||||
setCustomWorldResultViewSource: (
|
||||
source: CustomWorldResultViewSource,
|
||||
) => void;
|
||||
setSelectionStage: (stage: SelectionStage) => void;
|
||||
setPlatformTabToCreate: () => void;
|
||||
setPlatformError: (error: string | null) => void;
|
||||
appendBrowseHistoryEntry: (
|
||||
entry: PlatformBrowseHistoryWriteEntry,
|
||||
) => Promise<void>;
|
||||
refreshCustomWorldWorks: () => Promise<unknown>;
|
||||
refreshPublishedGallery: () => Promise<unknown>;
|
||||
persistAgentUiState: (
|
||||
sessionId: string | null,
|
||||
operationId: string | null,
|
||||
) => void;
|
||||
syncAgentSessionSnapshot: (
|
||||
sessionId: string,
|
||||
) => Promise<CustomWorldAgentSessionSnapshot | null>;
|
||||
buildDraftResultProfile: (
|
||||
session: CustomWorldAgentSessionSnapshot | null,
|
||||
) => CustomWorldProfile | null;
|
||||
suppressAgentDraftResultAutoOpen: () => void;
|
||||
releaseAgentDraftResultAutoOpenSuppression: () => void;
|
||||
resetAutoSaveTrackingToIdle: () => void;
|
||||
markAutoSavedProfile: (profile: CustomWorldProfile) => void;
|
||||
};
|
||||
|
||||
function isMissingRpgEntryAgentSessionError(error: unknown) {
|
||||
return (
|
||||
error instanceof ApiClientError &&
|
||||
error.status === 404 &&
|
||||
error.code === 'NOT_FOUND'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 负责平台详情、创作作品入口和结果页打开路径。
|
||||
* 平台壳层只消费“打开哪个面板”的结果,不再自己拼接恢复流程细节。
|
||||
*/
|
||||
export function useRpgEntryLibraryDetail(
|
||||
params: UseRpgEntryLibraryDetailParams,
|
||||
) {
|
||||
const {
|
||||
userId,
|
||||
selectedDetailEntry,
|
||||
setSelectedDetailEntry,
|
||||
savedCustomWorldEntries,
|
||||
setSavedCustomWorldEntries,
|
||||
setGeneratedCustomWorldProfile,
|
||||
setCustomWorldError,
|
||||
setCustomWorldAutoSaveError,
|
||||
setCustomWorldAutoSaveState,
|
||||
setCustomWorldGenerationViewSource,
|
||||
setCustomWorldResultViewSource,
|
||||
setSelectionStage,
|
||||
setPlatformTabToCreate,
|
||||
setPlatformError,
|
||||
appendBrowseHistoryEntry,
|
||||
refreshCustomWorldWorks,
|
||||
refreshPublishedGallery,
|
||||
persistAgentUiState,
|
||||
syncAgentSessionSnapshot,
|
||||
buildDraftResultProfile,
|
||||
suppressAgentDraftResultAutoOpen,
|
||||
releaseAgentDraftResultAutoOpenSuppression,
|
||||
resetAutoSaveTrackingToIdle,
|
||||
markAutoSavedProfile,
|
||||
} = params;
|
||||
|
||||
const [detailError, setDetailError] = useState<string | null>(null);
|
||||
const [isDetailLoading, setIsDetailLoading] = useState(false);
|
||||
const [isMutatingDetail, setIsMutatingDetail] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedDetailEntry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextOwnedEntry = savedCustomWorldEntries.find(
|
||||
(entry) =>
|
||||
entry.ownerUserId === selectedDetailEntry.ownerUserId &&
|
||||
entry.profileId === selectedDetailEntry.profileId,
|
||||
);
|
||||
if (nextOwnedEntry && nextOwnedEntry !== selectedDetailEntry) {
|
||||
setSelectedDetailEntry(nextOwnedEntry);
|
||||
}
|
||||
}, [savedCustomWorldEntries, selectedDetailEntry, setSelectedDetailEntry]);
|
||||
|
||||
const openLibraryDetail = useCallback(
|
||||
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => {
|
||||
if (entry.visibility === 'published') {
|
||||
void appendBrowseHistoryEntry({
|
||||
ownerUserId: entry.ownerUserId,
|
||||
profileId: entry.profileId,
|
||||
worldName: entry.worldName,
|
||||
subtitle: entry.subtitle,
|
||||
summaryText: entry.summaryText,
|
||||
coverImageSrc: entry.coverImageSrc,
|
||||
themeMode: entry.themeMode,
|
||||
authorDisplayName: entry.authorDisplayName,
|
||||
});
|
||||
}
|
||||
setSelectedDetailEntry(entry);
|
||||
setDetailError(null);
|
||||
setSelectionStage('detail');
|
||||
},
|
||||
[appendBrowseHistoryEntry, setSelectedDetailEntry, setSelectionStage],
|
||||
);
|
||||
|
||||
const openGalleryDetail = useCallback(
|
||||
async (entry: CustomWorldGalleryCard) => {
|
||||
setSelectionStage('detail');
|
||||
setIsDetailLoading(true);
|
||||
setDetailError(null);
|
||||
|
||||
try {
|
||||
const detailEntry = await getRpgEntryWorldGalleryDetail(
|
||||
entry.ownerUserId,
|
||||
entry.profileId,
|
||||
);
|
||||
setSelectedDetailEntry(detailEntry);
|
||||
void appendBrowseHistoryEntry({
|
||||
ownerUserId: detailEntry.ownerUserId,
|
||||
profileId: detailEntry.profileId,
|
||||
worldName: detailEntry.worldName,
|
||||
subtitle: detailEntry.subtitle,
|
||||
summaryText: detailEntry.summaryText,
|
||||
coverImageSrc: detailEntry.coverImageSrc,
|
||||
themeMode: detailEntry.themeMode,
|
||||
authorDisplayName: detailEntry.authorDisplayName,
|
||||
});
|
||||
} catch (error) {
|
||||
setSelectedDetailEntry(null);
|
||||
setDetailError(
|
||||
resolveRpgEntryErrorMessage(error, '读取作品详情失败。'),
|
||||
);
|
||||
} finally {
|
||||
setIsDetailLoading(false);
|
||||
}
|
||||
},
|
||||
[appendBrowseHistoryEntry, setSelectedDetailEntry, setSelectionStage],
|
||||
);
|
||||
|
||||
const openSavedCustomWorldEditor = useCallback(
|
||||
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => {
|
||||
setSelectedDetailEntry(entry);
|
||||
const normalizedProfile = normalizeRpgEntryAgentBackedProfile(
|
||||
entry.profile,
|
||||
);
|
||||
setGeneratedCustomWorldProfile(normalizedProfile);
|
||||
markAutoSavedProfile(normalizedProfile);
|
||||
setCustomWorldAutoSaveState('saved');
|
||||
setCustomWorldAutoSaveError(null);
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldGenerationViewSource(null);
|
||||
setCustomWorldResultViewSource('saved-profile');
|
||||
setSelectionStage('custom-world-result');
|
||||
},
|
||||
[
|
||||
markAutoSavedProfile,
|
||||
setCustomWorldAutoSaveError,
|
||||
setCustomWorldAutoSaveState,
|
||||
setCustomWorldError,
|
||||
setCustomWorldGenerationViewSource,
|
||||
setCustomWorldResultViewSource,
|
||||
setGeneratedCustomWorldProfile,
|
||||
setSelectedDetailEntry,
|
||||
setSelectionStage,
|
||||
],
|
||||
);
|
||||
|
||||
const handleOpenCreationWork = useCallback(
|
||||
async (work: CustomWorldWorkSummary) => {
|
||||
if (work.status === 'draft' && work.sessionId) {
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldAutoSaveError(null);
|
||||
setCustomWorldAutoSaveState('idle');
|
||||
setCustomWorldGenerationViewSource(null);
|
||||
resetAutoSaveTrackingToIdle();
|
||||
|
||||
const shouldOpenAgentWorkspace =
|
||||
work.playableNpcCount <= 0 && work.landmarkCount <= 0;
|
||||
|
||||
try {
|
||||
if (shouldOpenAgentWorkspace) {
|
||||
// 仅八锚点未整理成底稿时才恢复 Agent 对话工作区。
|
||||
suppressAgentDraftResultAutoOpen();
|
||||
persistAgentUiState(work.sessionId, null);
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
setCustomWorldResultViewSource(null);
|
||||
setPlatformTabToCreate();
|
||||
setSelectionStage('agent-workspace');
|
||||
return;
|
||||
}
|
||||
|
||||
releaseAgentDraftResultAutoOpenSuppression();
|
||||
const latestSession = await syncAgentSessionSnapshot(work.sessionId);
|
||||
const nextProfile = buildDraftResultProfile(latestSession);
|
||||
if (!nextProfile) {
|
||||
persistAgentUiState(work.sessionId, null);
|
||||
setPlatformError('当前草稿还没有可编辑的结果页数据,请先继续补齐锚点。');
|
||||
setPlatformTabToCreate();
|
||||
setSelectionStage('agent-workspace');
|
||||
return;
|
||||
}
|
||||
|
||||
persistAgentUiState(work.sessionId, null);
|
||||
setGeneratedCustomWorldProfile(
|
||||
normalizeRpgEntryAgentBackedProfile(nextProfile),
|
||||
);
|
||||
setCustomWorldResultViewSource('agent-draft');
|
||||
setPlatformTabToCreate();
|
||||
setSelectionStage('custom-world-result');
|
||||
return;
|
||||
} catch (error) {
|
||||
if (isMissingRpgEntryAgentSessionError(error)) {
|
||||
// 失效会话不能继续保留在恢复状态里,否则刷新后会重复命中同一个坏 session。
|
||||
persistAgentUiState(null, null);
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
setCustomWorldResultViewSource(null);
|
||||
await refreshCustomWorldWorks().catch(() => []);
|
||||
setPlatformError(
|
||||
'这份共创草稿已失效,已为你返回创作中心,请重新开始创作。',
|
||||
);
|
||||
} else {
|
||||
setPlatformError(
|
||||
resolveRpgEntryErrorMessage(error, '读取创作草稿失败。'),
|
||||
);
|
||||
}
|
||||
|
||||
setPlatformTabToCreate();
|
||||
setSelectionStage('platform');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!work.profileId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let matchedEntry = savedCustomWorldEntries.find(
|
||||
(entry) => entry.profileId === work.profileId,
|
||||
);
|
||||
|
||||
if (!matchedEntry && userId) {
|
||||
const latestLibraryEntries = await listRpgEntryWorldLibrary();
|
||||
setSavedCustomWorldEntries(latestLibraryEntries);
|
||||
matchedEntry = latestLibraryEntries.find(
|
||||
(entry) => entry.profileId === work.profileId,
|
||||
);
|
||||
}
|
||||
|
||||
if (matchedEntry) {
|
||||
openLibraryDetail(matchedEntry);
|
||||
return;
|
||||
}
|
||||
|
||||
setPlatformError('未找到对应作品,请刷新后重试。');
|
||||
} catch (error) {
|
||||
setPlatformError(
|
||||
resolveRpgEntryErrorMessage(error, '读取作品详情失败。'),
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
buildDraftResultProfile,
|
||||
openLibraryDetail,
|
||||
persistAgentUiState,
|
||||
releaseAgentDraftResultAutoOpenSuppression,
|
||||
resetAutoSaveTrackingToIdle,
|
||||
savedCustomWorldEntries,
|
||||
setCustomWorldAutoSaveError,
|
||||
setCustomWorldAutoSaveState,
|
||||
setCustomWorldError,
|
||||
setCustomWorldGenerationViewSource,
|
||||
setCustomWorldResultViewSource,
|
||||
setGeneratedCustomWorldProfile,
|
||||
setPlatformError,
|
||||
setPlatformTabToCreate,
|
||||
setSavedCustomWorldEntries,
|
||||
setSelectionStage,
|
||||
suppressAgentDraftResultAutoOpen,
|
||||
syncAgentSessionSnapshot,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePublishSelectedWorld = useCallback(async () => {
|
||||
if (!selectedDetailEntry || isMutatingDetail) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsMutatingDetail(true);
|
||||
setDetailError(null);
|
||||
try {
|
||||
const mutation = await publishRpgEntryWorldProfile(
|
||||
selectedDetailEntry.profileId,
|
||||
);
|
||||
setSavedCustomWorldEntries(mutation.entries);
|
||||
await refreshCustomWorldWorks().catch(() => []);
|
||||
setSelectedDetailEntry(mutation.entry);
|
||||
await refreshPublishedGallery().catch(() => []);
|
||||
} catch (error) {
|
||||
setDetailError(
|
||||
resolveRpgEntryErrorMessage(error, '发布自定义世界失败。'),
|
||||
);
|
||||
} finally {
|
||||
setIsMutatingDetail(false);
|
||||
}
|
||||
}, [
|
||||
isMutatingDetail,
|
||||
refreshCustomWorldWorks,
|
||||
refreshPublishedGallery,
|
||||
selectedDetailEntry,
|
||||
setSavedCustomWorldEntries,
|
||||
setSelectedDetailEntry,
|
||||
]);
|
||||
|
||||
const handleUnpublishSelectedWorld = useCallback(async () => {
|
||||
if (!selectedDetailEntry || isMutatingDetail) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsMutatingDetail(true);
|
||||
setDetailError(null);
|
||||
try {
|
||||
const mutation = await unpublishRpgEntryWorldProfile(
|
||||
selectedDetailEntry.profileId,
|
||||
);
|
||||
setSavedCustomWorldEntries(mutation.entries);
|
||||
await refreshCustomWorldWorks().catch(() => []);
|
||||
setSelectedDetailEntry(mutation.entry);
|
||||
await refreshPublishedGallery().catch(() => []);
|
||||
} catch (error) {
|
||||
setDetailError(
|
||||
resolveRpgEntryErrorMessage(error, '下架自定义世界失败。'),
|
||||
);
|
||||
} finally {
|
||||
setIsMutatingDetail(false);
|
||||
}
|
||||
}, [
|
||||
isMutatingDetail,
|
||||
refreshCustomWorldWorks,
|
||||
refreshPublishedGallery,
|
||||
selectedDetailEntry,
|
||||
setSavedCustomWorldEntries,
|
||||
setSelectedDetailEntry,
|
||||
]);
|
||||
|
||||
const handleDeleteSelectedWorld = useCallback(async () => {
|
||||
if (!selectedDetailEntry || isMutatingDetail) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`确认删除作品《${selectedDetailEntry.worldName}》吗?删除后会从你的作品列表和公开广场中移除。`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsMutatingDetail(true);
|
||||
setDetailError(null);
|
||||
try {
|
||||
const entries = await deleteRpgEntryWorldProfile(
|
||||
selectedDetailEntry.profileId,
|
||||
);
|
||||
setSavedCustomWorldEntries(entries);
|
||||
await refreshCustomWorldWorks().catch(() => []);
|
||||
setSelectedDetailEntry(null);
|
||||
setPlatformTabToCreate();
|
||||
setSelectionStage('platform');
|
||||
await refreshPublishedGallery().catch(() => []);
|
||||
} catch (error) {
|
||||
setDetailError(
|
||||
resolveRpgEntryErrorMessage(error, '删除自定义世界失败。'),
|
||||
);
|
||||
} finally {
|
||||
setIsMutatingDetail(false);
|
||||
}
|
||||
}, [
|
||||
isMutatingDetail,
|
||||
refreshCustomWorldWorks,
|
||||
refreshPublishedGallery,
|
||||
selectedDetailEntry,
|
||||
setPlatformTabToCreate,
|
||||
setSavedCustomWorldEntries,
|
||||
setSelectedDetailEntry,
|
||||
setSelectionStage,
|
||||
]);
|
||||
|
||||
const isSelectedWorldOwned = Boolean(
|
||||
selectedDetailEntry &&
|
||||
savedCustomWorldEntries.some(
|
||||
(entry) =>
|
||||
entry.ownerUserId === selectedDetailEntry.ownerUserId &&
|
||||
entry.profileId === selectedDetailEntry.profileId,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
detailError,
|
||||
setDetailError,
|
||||
isDetailLoading,
|
||||
isMutatingDetail,
|
||||
isSelectedWorldOwned,
|
||||
openLibraryDetail,
|
||||
openGalleryDetail,
|
||||
openSavedCustomWorldEditor,
|
||||
handleOpenCreationWork,
|
||||
handlePublishSelectedWorld,
|
||||
handleUnpublishSelectedWorld,
|
||||
handleDeleteSelectedWorld,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容旧创作链命名,避免并行工作包在本轮迁移后断开导入。
|
||||
*/
|
||||
export const useRpgCreationDetailNavigation = useRpgEntryLibraryDetail;
|
||||
55
src/components/rpg-entry/useRpgEntryNavigation.ts
Normal file
55
src/components/rpg-entry/useRpgEntryNavigation.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import type { SelectionStage } from './rpgEntryTypes';
|
||||
|
||||
type UseRpgEntryNavigationParams = {
|
||||
setSelectionStage: (stage: SelectionStage) => void;
|
||||
setSelectedDetailEntry: (
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfile> | null,
|
||||
) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 收口 RPG 入口壳层的阶段跳转,避免页面内散落大量匿名 setState 回调。
|
||||
*/
|
||||
export function useRpgEntryNavigation(
|
||||
params: UseRpgEntryNavigationParams,
|
||||
) {
|
||||
const { setSelectionStage, setSelectedDetailEntry } = params;
|
||||
|
||||
const backToPlatformHome = useCallback(() => {
|
||||
setSelectionStage('platform');
|
||||
}, [setSelectionStage]);
|
||||
|
||||
const backToPlatformWithClearedDetail = useCallback(() => {
|
||||
setSelectedDetailEntry(null);
|
||||
setSelectionStage('platform');
|
||||
}, [setSelectedDetailEntry, setSelectionStage]);
|
||||
|
||||
const openDetailStage = useCallback(() => {
|
||||
setSelectionStage('detail');
|
||||
}, [setSelectionStage]);
|
||||
|
||||
const openAgentWorkspaceStage = useCallback(() => {
|
||||
setSelectionStage('agent-workspace');
|
||||
}, [setSelectionStage]);
|
||||
|
||||
const openCustomWorldGenerationStage = useCallback(() => {
|
||||
setSelectionStage('custom-world-generating');
|
||||
}, [setSelectionStage]);
|
||||
|
||||
const openCustomWorldResultStage = useCallback(() => {
|
||||
setSelectionStage('custom-world-result');
|
||||
}, [setSelectionStage]);
|
||||
|
||||
return {
|
||||
backToPlatformHome,
|
||||
backToPlatformWithClearedDetail,
|
||||
openDetailStage,
|
||||
openAgentWorkspaceStage,
|
||||
openCustomWorldGenerationStage,
|
||||
openCustomWorldResultStage,
|
||||
};
|
||||
}
|
||||
28
src/components/rpg-entry/useRpgEntrySaveResume.ts
Normal file
28
src/components/rpg-entry/useRpgEntrySaveResume.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { ProfileSaveArchiveSummary } from '../../../packages/shared/src/contracts/runtime';
|
||||
|
||||
type UseRpgEntrySaveResumeParams = {
|
||||
handleResumeSaveEntry: (entry: ProfileSaveArchiveSummary) => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* RPG 入口域里的“继续游戏”入口只负责转发恢复动作,
|
||||
* 让壳层组件不直接知道具体的存档恢复实现细节。
|
||||
*/
|
||||
export function useRpgEntrySaveResume(
|
||||
params: UseRpgEntrySaveResumeParams,
|
||||
) {
|
||||
const { handleResumeSaveEntry } = params;
|
||||
|
||||
const resumeSelectedSave = useCallback(
|
||||
async (entry: ProfileSaveArchiveSummary) => {
|
||||
await handleResumeSaveEntry(entry);
|
||||
},
|
||||
[handleResumeSaveEntry],
|
||||
);
|
||||
|
||||
return {
|
||||
resumeSelectedSave,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user