792 lines
23 KiB
TypeScript
792 lines
23 KiB
TypeScript
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<EntityGenerationKind, string[]>;
|
||
|
||
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 (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
className={`${
|
||
tone === 'sky'
|
||
? 'platform-button platform-button--primary'
|
||
: 'platform-button platform-button--ghost'
|
||
} min-h-0 rounded-full px-3 py-2 text-sm ${disabled ? 'cursor-not-allowed opacity-45' : ''}`}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
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<CustomWorldEditorTarget | null>(null);
|
||
const [activeTab, setActiveTab] = useState<ResultTab>('world');
|
||
const [pendingGeneratedEntity, setPendingGeneratedEntity] =
|
||
useState<PendingGeneratedEntity | null>(null);
|
||
const [recentGeneratedIds, setRecentGeneratedIds] = useState<RecentGeneratedIds>(
|
||
{
|
||
playable: [],
|
||
story: [],
|
||
landmark: [],
|
||
},
|
||
);
|
||
const [localGenerationError, setLocalGenerationError] = useState<string | null>(
|
||
null,
|
||
);
|
||
const pendingProgressTimerRef = useRef<number | null>(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<string, AssetDebugLoadStatus>
|
||
>({});
|
||
|
||
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' ? (
|
||
<div className="platform-pill platform-pill--success px-3 py-1 text-[11px]">
|
||
已自动保存
|
||
</div>
|
||
) : autoSaveState === 'saving' ? (
|
||
<div className="platform-pill platform-pill--warm px-3 py-1 text-[11px]">
|
||
保存中
|
||
</div>
|
||
) : autoSaveState === 'error' ? (
|
||
<div className="platform-pill platform-pill--rose px-3 py-1 text-[11px]">
|
||
保存失败
|
||
</div>
|
||
) : null;
|
||
|
||
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}
|
||
disabled={isGenerating}
|
||
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isGenerating ? 'opacity-45' : ''}`}
|
||
>
|
||
{backLabel}
|
||
</button>
|
||
{autoSaveBadge}
|
||
</div>
|
||
|
||
<div className="min-h-0 flex-1 overflow-hidden">
|
||
<CustomWorldEntityCatalog
|
||
profile={profile}
|
||
previewCharacters={previewCharacters}
|
||
activeTab={activeTab}
|
||
onActiveTabChange={setActiveTab}
|
||
onEditTarget={setEditorTarget}
|
||
onProfileChange={onProfileChange}
|
||
onDeleteStoryNpcs={handleDeleteStoryNpcs}
|
||
onDeleteLandmarks={handleDeleteLandmarks}
|
||
createActionLabel={
|
||
readOnly || compactAgentResultMode ? undefined : createLabel
|
||
}
|
||
onCreateAction={
|
||
readOnly || compactAgentResultMode || !createTarget
|
||
? undefined
|
||
: () => {
|
||
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}
|
||
/>
|
||
</div>
|
||
|
||
{isGenerating && (
|
||
<div className="platform-banner platform-banner--info mt-3 rounded-2xl px-4 py-4">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||
{progressLabel}
|
||
</div>
|
||
<div className="text-xs text-[var(--platform-text-base)]">
|
||
{Math.round(progress)}%
|
||
</div>
|
||
</div>
|
||
<div className="platform-progress-track mt-3 h-3 overflow-hidden rounded-full">
|
||
<div
|
||
className="h-full bg-[linear-gradient(90deg,#ff4f8b_0%,#ff8a73_48%,#ffd2a6_100%)] transition-[width] duration-300"
|
||
style={{ width: `${Math.max(0, Math.min(100, progress))}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{error ? (
|
||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||
{error}
|
||
</div>
|
||
) : null}
|
||
{!error && localGenerationError ? (
|
||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||
{localGenerationError}
|
||
</div>
|
||
) : null}
|
||
{assetDebugEnabled ? (
|
||
<div className="platform-surface platform-surface--soft mt-3 px-3.5 py-3">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<div className="text-xs font-bold tracking-[0.16em] text-white">
|
||
资产诊断
|
||
</div>
|
||
<div className="mt-1 text-xs leading-6 text-zinc-500">
|
||
仅开发模式显示,用来核对结果页当前拿到的图片字段和实际加载状态。
|
||
</div>
|
||
</div>
|
||
<div className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
|
||
{assetDebugEntries.length}项
|
||
</div>
|
||
</div>
|
||
<div className="mt-3 grid grid-cols-2 gap-2 xl:grid-cols-4">
|
||
{assetDebugSummary.map((entry) => (
|
||
<div
|
||
key={entry.label}
|
||
className="platform-subpanel rounded-2xl px-3 py-2"
|
||
>
|
||
<div className="text-[11px] text-zinc-500">{entry.label}</div>
|
||
<div className="mt-1 text-sm font-semibold text-white">
|
||
{entry.value}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="mt-3 space-y-2">
|
||
{assetDebugEntries.length > 0 ? (
|
||
assetDebugEntries.map((entry) => (
|
||
<div
|
||
key={entry.id}
|
||
className="platform-subpanel rounded-2xl px-3 py-2"
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="text-sm font-semibold text-white">
|
||
{entry.label}
|
||
</div>
|
||
<div className="mt-1 break-all text-[11px] leading-5 text-zinc-400">
|
||
{entry.imageSrc}
|
||
</div>
|
||
</div>
|
||
<div className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
|
||
{resolveAssetDebugStatusLabel(
|
||
assetDebugStatusMap[entry.id],
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="mt-2">
|
||
<a
|
||
href={entry.imageSrc}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
aria-label={`打开 ${entry.label}`}
|
||
className="text-xs font-semibold text-amber-200 underline decoration-white/20 underline-offset-2"
|
||
>
|
||
打开原图
|
||
</a>
|
||
</div>
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="platform-subpanel rounded-2xl px-3 py-3 text-sm text-zinc-400">
|
||
当前结果页 profile 里没有拿到任何可诊断的图片地址。
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="mt-4 flex flex-col gap-3">
|
||
{profile.generationStatus === 'key_only' ? (
|
||
<div className="platform-banner platform-banner--warning rounded-2xl text-sm leading-6">
|
||
当前世界处于快速预览模式,只生成了关键对象。继续补全后,系统会生成长尾场景角色与完整场景网络。
|
||
</div>
|
||
) : null}
|
||
<div className="flex items-center justify-end gap-3">
|
||
{onEditSetting ? (
|
||
<SmallButton onClick={onEditSetting}>{editActionLabel}</SmallButton>
|
||
) : null}
|
||
{triggerRegenerate ? (
|
||
<SmallButton onClick={onRegenerate} tone="sky">
|
||
{regenerateActionLabel}
|
||
</SmallButton>
|
||
) : null}
|
||
{profile.generationStatus === 'key_only' && onContinueExpand ? (
|
||
<SmallButton
|
||
onClick={onContinueExpand}
|
||
tone="sky"
|
||
disabled={isGenerating}
|
||
>
|
||
继续补全世界
|
||
</SmallButton>
|
||
) : null}
|
||
{onEnterWorld ? (
|
||
<button
|
||
type="button"
|
||
onClick={onEnterWorld}
|
||
disabled={isGenerating}
|
||
className={`platform-button platform-button--primary ${isGenerating ? 'opacity-55' : ''}`}
|
||
>
|
||
{enterWorldActionLabel}
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
<CustomWorldEntityEditorModal
|
||
profile={profile}
|
||
target={editorTarget}
|
||
onClose={() => setEditorTarget(null)}
|
||
onProfileChange={onProfileChange}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|