import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; import { normalizeCustomWorldLandmarks } from '../data/customWorldSceneGraph'; import { generateCustomWorldLandmark, generateCustomWorldPlayableNpc, generateCustomWorldStoryNpc, } from '../services/aiService'; import { Character, CustomWorldLandmark, CustomWorldNpc, CustomWorldPlayableNpc, CustomWorldProfile, } from '../types'; import { CustomWorldEntityCatalog, type ResultTab, } from './CustomWorldEntityCatalog'; import CustomWorldEntityEditorModal, { type CustomWorldEditorTarget, } from './CustomWorldEntityEditorModal'; interface CustomWorldResultViewProps { profile: CustomWorldProfile; previewCharacters: Character[]; isGenerating: boolean; progress: number; progressLabel: string; error: string | null; onBack: () => void; onEditSetting?: () => void; onRegenerate?: () => void; onContinueExpand?: () => void; onEnterWorld?: () => void; onProfileChange: (profile: CustomWorldProfile) => void; readOnly?: boolean; backLabel?: string; editActionLabel?: string; regenerateActionLabel?: string; enterWorldActionLabel?: string; autoSaveState?: 'idle' | 'saving' | 'saved' | 'error'; compactAgentResultMode?: boolean; } type EntityGenerationKind = 'playable' | 'story' | 'landmark'; type PendingGeneratedEntity = { id: string; kind: EntityGenerationKind; title: string; progress: number; phaseLabel: string; }; type RecentGeneratedIds = Record; type CustomWorldAssetDebugEntry = { id: string; label: string; imageSrc: string; kind: 'playable' | 'story' | 'landmark' | 'scene-act'; }; type AssetDebugLoadStatus = 'loading' | 'loaded' | 'error'; const CUSTOM_WORLD_ASSET_DEBUG_QUERY_KEY = 'debugCustomWorldAssets'; const CUSTOM_WORLD_ASSET_DEBUG_STORAGE_KEY = 'genarrative.debug.customWorldAssets'; function shouldEnableCustomWorldAssetDebugPanel() { if (!import.meta.env.DEV || typeof window === 'undefined') { return false; } const searchParams = new URLSearchParams(window.location.search); if (searchParams.get(CUSTOM_WORLD_ASSET_DEBUG_QUERY_KEY) === '1') { return true; } return ( window.localStorage.getItem(CUSTOM_WORLD_ASSET_DEBUG_STORAGE_KEY) === '1' ); } function collectCustomWorldAssetDebugEntries( profile: CustomWorldProfile, ): CustomWorldAssetDebugEntry[] { const playableEntries = profile.playableNpcs .map((role) => { const imageSrc = role.imageSrc?.trim() || ''; if (!imageSrc) { return null; } return { id: `playable:${role.id}`, label: `${role.name}主形象`, imageSrc, kind: 'playable' as const, }; }) .filter( (entry): entry is CustomWorldAssetDebugEntry => Boolean(entry), ); const storyEntries = profile.storyNpcs .map((role) => { const imageSrc = role.imageSrc?.trim() || ''; if (!imageSrc) { return null; } return { id: `story:${role.id}`, label: `${role.name}场景角色主图`, imageSrc, kind: 'story' as const, }; }) .filter( (entry): entry is CustomWorldAssetDebugEntry => Boolean(entry), ); const landmarkEntries = profile.landmarks .map((landmark) => { const imageSrc = landmark.imageSrc?.trim() || ''; if (!imageSrc) { return null; } return { id: `landmark:${landmark.id}`, label: `${landmark.name}场景主图`, imageSrc, kind: 'landmark' as const, }; }) .filter( (entry): entry is CustomWorldAssetDebugEntry => Boolean(entry), ); const sceneActEntries = profile.sceneChapterBlueprints?.flatMap((chapter) => chapter.acts .map((act) => { const imageSrc = act.backgroundImageSrc?.trim() || ''; if (!imageSrc) { return null; } return { id: `scene-act:${chapter.id}:${act.id}`, label: `${chapter.title || chapter.sceneId} / ${act.title}幕图`, imageSrc, kind: 'scene-act' as const, }; }) .filter( (entry): entry is CustomWorldAssetDebugEntry => Boolean(entry), ), ) ?? []; return [ ...playableEntries, ...storyEntries, ...landmarkEntries, ...sceneActEntries, ]; } function resolveAssetDebugStatusLabel(status: AssetDebugLoadStatus | undefined) { if (status === 'loaded') { return '已加载'; } if (status === 'error') { return '加载失败'; } return '检测中'; } function resolveAssetDebugSummary(profile: CustomWorldProfile) { return [ { label: '可扮演角色主图', value: `${profile.playableNpcs.filter((role) => Boolean(role.imageSrc?.trim())).length}/${profile.playableNpcs.length}`, }, { label: '场景角色主图', value: `${profile.storyNpcs.filter((role) => Boolean(role.imageSrc?.trim())).length}/${profile.storyNpcs.length}`, }, { label: '场景主图', value: `${profile.landmarks.filter((landmark) => Boolean(landmark.imageSrc?.trim())).length}/${profile.landmarks.length}`, }, { label: '分幕图', value: `${profile.sceneChapterBlueprints?.reduce( (sum, chapter) => sum + chapter.acts.filter((act) => Boolean(act.backgroundImageSrc?.trim())) .length, 0, ) ?? 0}/${ profile.sceneChapterBlueprints?.reduce( (sum, chapter) => sum + chapter.acts.length, 0, ) ?? 0 }`, }, ]; } function SmallButton({ onClick, children, tone = 'default', disabled = false, }: { onClick: () => void; children: ReactNode; tone?: 'default' | 'sky'; disabled?: boolean; }) { return ( ); } function getCreateTargetByTab( activeTab: ResultTab, ): CustomWorldEditorTarget | null { if (activeTab === 'playable') return { kind: 'playable', mode: 'create' }; if (activeTab === 'story') return { kind: 'story', mode: 'create' }; if (activeTab === 'landmarks') return { kind: 'landmark', mode: 'create' }; return null; } function getCreateLabelByTab(activeTab: ResultTab) { if (activeTab === 'playable') return '新增可扮演角色'; if (activeTab === 'story') return '新增场景角色'; if (activeTab === 'landmarks') return '新增场景'; return ''; } function createPendingGeneratedEntity( kind: EntityGenerationKind, ): PendingGeneratedEntity { return { id: `pending-${kind}-${Date.now()}`, kind, title: kind === 'playable' ? '新可扮演角色' : kind === 'story' ? '新场景角色' : '新场景', progress: 8, phaseLabel: '正在整理世界上下文', }; } function resolvePendingPhaseLabel( kind: EntityGenerationKind, progress: number, ) { if (progress < 28) { return '正在整理世界上下文'; } if (progress < 72) { return kind === 'landmark' ? '正在推理场景结构' : '正在推理角色结构'; } return '正在回写结果'; } function prependPlayableNpc( profile: CustomWorldProfile, npc: CustomWorldPlayableNpc, ) { return { ...profile, playableNpcs: [npc, ...profile.playableNpcs], } satisfies CustomWorldProfile; } function prependStoryNpc(profile: CustomWorldProfile, npc: CustomWorldNpc) { return { ...profile, storyNpcs: [npc, ...profile.storyNpcs], } satisfies CustomWorldProfile; } function prependLandmark( profile: CustomWorldProfile, landmark: CustomWorldLandmark, ) { return { ...profile, landmarks: normalizeCustomWorldLandmarks({ landmarks: [landmark, ...profile.landmarks], storyNpcs: profile.storyNpcs, }), } satisfies CustomWorldProfile; } function removeStoryNpcsFromProfile( profile: CustomWorldProfile, ids: string[], ) { const idSet = new Set(ids); const nextStoryNpcs = profile.storyNpcs.filter((npc) => !idSet.has(npc.id)); return { ...profile, storyNpcs: nextStoryNpcs, landmarks: normalizeCustomWorldLandmarks({ landmarks: profile.landmarks.map((landmark) => ({ ...landmark, sceneNpcIds: landmark.sceneNpcIds.filter((npcId) => !idSet.has(npcId)), })), storyNpcs: nextStoryNpcs, }), } satisfies CustomWorldProfile; } function removeLandmarksFromProfile( profile: CustomWorldProfile, ids: string[], ) { const idSet = new Set(ids); const nextLandmarks = profile.landmarks.filter( (landmark) => !idSet.has(landmark.id), ); return { ...profile, landmarks: normalizeCustomWorldLandmarks({ landmarks: nextLandmarks.map((landmark) => ({ ...landmark, connections: landmark.connections.filter( (connection) => !idSet.has(connection.targetLandmarkId), ), })), storyNpcs: profile.storyNpcs, }), } satisfies CustomWorldProfile; } export function CustomWorldResultView({ profile, previewCharacters, isGenerating, progress, progressLabel, error, onBack, onEditSetting, onRegenerate: triggerRegenerate, onContinueExpand, onEnterWorld, onProfileChange, readOnly = false, backLabel = '返回', editActionLabel = '修改设定', regenerateActionLabel = '重新生成', enterWorldActionLabel = '进入世界', autoSaveState = 'idle', compactAgentResultMode = false, }: CustomWorldResultViewProps) { const [editorTarget, setEditorTarget] = useState(null); const [activeTab, setActiveTab] = useState('world'); const [pendingGeneratedEntity, setPendingGeneratedEntity] = useState(null); const [recentGeneratedIds, setRecentGeneratedIds] = useState( { playable: [], story: [], landmark: [], }, ); const [localGenerationError, setLocalGenerationError] = useState( null, ); const pendingProgressTimerRef = useRef(null); const assetDebugEnabled = useMemo( () => shouldEnableCustomWorldAssetDebugPanel(), [], ); const assetDebugEntries = useMemo( () => assetDebugEnabled ? collectCustomWorldAssetDebugEntries(profile) : [], [assetDebugEnabled, profile], ); const assetDebugSummary = useMemo( () => (assetDebugEnabled ? resolveAssetDebugSummary(profile) : []), [assetDebugEnabled, profile], ); const [assetDebugStatusMap, setAssetDebugStatusMap] = useState< Record >({}); const createTarget = useMemo( () => getCreateTargetByTab(activeTab), [activeTab], ); const createLabel = useMemo( () => getCreateLabelByTab(activeTab), [activeTab], ); const stopPendingProgressTimer = () => { if (pendingProgressTimerRef.current !== null) { window.clearInterval(pendingProgressTimerRef.current); pendingProgressTimerRef.current = null; } }; useEffect(() => () => stopPendingProgressTimer(), []); useEffect(() => { if (!assetDebugEnabled) { setAssetDebugStatusMap({}); return; } if (assetDebugEntries.length === 0) { setAssetDebugStatusMap({}); return; } let cancelled = false; const cleanupList: Array<() => void> = []; setAssetDebugStatusMap( Object.fromEntries( assetDebugEntries.map((entry) => [entry.id, 'loading' as const]), ), ); assetDebugEntries.forEach((entry) => { const image = new Image(); const updateStatus = (status: AssetDebugLoadStatus) => { if (cancelled) { return; } setAssetDebugStatusMap((current) => { if (current[entry.id] === status) { return current; } return { ...current, [entry.id]: status, }; }); }; image.onload = () => updateStatus('loaded'); image.onerror = () => updateStatus('error'); image.src = entry.imageSrc; cleanupList.push(() => { image.onload = null; image.onerror = null; }); }); return () => { cancelled = true; cleanupList.forEach((cleanup) => cleanup()); }; }, [assetDebugEnabled, assetDebugEntries]); const startPendingProgress = (kind: EntityGenerationKind) => { stopPendingProgressTimer(); setPendingGeneratedEntity(createPendingGeneratedEntity(kind)); pendingProgressTimerRef.current = window.setInterval(() => { setPendingGeneratedEntity((current) => { if (!current || current.kind !== kind) { return current; } const nextProgress = Math.min( current.progress + (current.progress < 56 ? 11 : 5), 88, ); return { ...current, progress: nextProgress, phaseLabel: resolvePendingPhaseLabel(kind, nextProgress), }; }); }, 520); }; const finishPendingProgress = () => { stopPendingProgressTimer(); setPendingGeneratedEntity(null); }; const markGeneratedAsRecent = ( kind: EntityGenerationKind, generatedId: string, ) => { setRecentGeneratedIds((current) => ({ ...current, [kind]: [generatedId, ...current[kind].filter((id) => id !== generatedId)].slice( 0, 6, ), })); }; const handleGenerateEntity = async (kind: EntityGenerationKind) => { if (readOnly || isGenerating || pendingGeneratedEntity) { return; } setLocalGenerationError(null); startPendingProgress(kind); try { if (kind === 'playable') { const nextNpc = await generateCustomWorldPlayableNpc({ profile }); onProfileChange(prependPlayableNpc(profile, nextNpc)); markGeneratedAsRecent('playable', nextNpc.id); } else if (kind === 'story') { const nextNpc = await generateCustomWorldStoryNpc({ profile }); onProfileChange(prependStoryNpc(profile, nextNpc)); markGeneratedAsRecent('story', nextNpc.id); } else { const nextLandmark = await generateCustomWorldLandmark({ profile }); onProfileChange(prependLandmark(profile, nextLandmark)); markGeneratedAsRecent('landmark', nextLandmark.id); } } catch (generationError) { setLocalGenerationError( generationError instanceof Error ? generationError.message : '生成失败,请稍后重试。', ); } finally { finishPendingProgress(); } }; const onRegenerate = () => { if (isGenerating || !triggerRegenerate) return; const confirmed = window.confirm( `确认重新生成“${profile.name}”吗?\n\n重新生成会重新生成当前世界中的所有信息,包括你修改和新增的所有内容。`, ); if (!confirmed) return; triggerRegenerate(); }; const handleDeleteStoryNpcs = (ids: string[]) => { if (ids.length === 0) return; onProfileChange(removeStoryNpcsFromProfile(profile, ids)); }; const handleDeleteLandmarks = (ids: string[]) => { if (ids.length === 0) return; onProfileChange(removeLandmarksFromProfile(profile, ids)); }; const autoSaveBadge = autoSaveState === 'saved' ? (
已自动保存
) : autoSaveState === 'saving' ? (
保存中
) : autoSaveState === 'error' ? (
保存失败
) : null; return (
{autoSaveBadge}
{ if (activeTab === 'playable') { void handleGenerateEntity('playable'); return; } if (activeTab === 'story') { void handleGenerateEntity('story'); return; } if (activeTab === 'landmarks') { void handleGenerateEntity('landmark'); return; } setEditorTarget(createTarget); } } createActionDisabled={Boolean( isGenerating || pendingGeneratedEntity, )} pendingGeneratedEntity={pendingGeneratedEntity} recentGeneratedIds={recentGeneratedIds} readOnly={readOnly} />
{isGenerating && (
{progressLabel}
{Math.round(progress)}%
)} {error ? (
{error}
) : null} {!error && localGenerationError ? (
{localGenerationError}
) : null} {assetDebugEnabled ? (
资产诊断
仅开发模式显示,用来核对结果页当前拿到的图片字段和实际加载状态。
{assetDebugEntries.length}项
{assetDebugSummary.map((entry) => (
{entry.label}
{entry.value}
))}
{assetDebugEntries.length > 0 ? ( assetDebugEntries.map((entry) => (
{entry.label}
{entry.imageSrc}
{resolveAssetDebugStatusLabel( assetDebugStatusMap[entry.id], )}
)) ) : (
当前结果页 profile 里没有拿到任何可诊断的图片地址。
)}
) : null}
{profile.generationStatus === 'key_only' ? (
当前世界处于快速预览模式,只生成了关键对象。继续补全后,系统会生成长尾场景角色与完整场景网络。
) : null}
{onEditSetting ? ( {editActionLabel} ) : null} {triggerRegenerate ? ( {regenerateActionLabel} ) : null} {profile.generationStatus === 'key_only' && onContinueExpand ? ( 继续补全世界 ) : null} {onEnterWorld ? ( ) : null}
setEditorTarget(null)} onProfileChange={onProfileChange} />
); }