@@ -1,7 +1,18 @@
|
||||
import { type ReactNode, useMemo, useState } from 'react';
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { normalizeCustomWorldLandmarks } from '../data/customWorldSceneGraph';
|
||||
import { Character, CustomWorldProfile } from '../types';
|
||||
import {
|
||||
generateCustomWorldLandmark,
|
||||
generateCustomWorldPlayableNpc,
|
||||
generateCustomWorldStoryNpc,
|
||||
} from '../services/aiService';
|
||||
import {
|
||||
Character,
|
||||
CustomWorldLandmark,
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
} from '../types';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import {
|
||||
CustomWorldEntityCatalog,
|
||||
@@ -23,15 +34,28 @@ interface CustomWorldResultViewProps {
|
||||
onEditSetting?: () => void;
|
||||
onRegenerate?: () => void;
|
||||
onContinueExpand?: () => void;
|
||||
onSave?: () => void;
|
||||
onEnterWorld?: () => void;
|
||||
onProfileChange: (profile: CustomWorldProfile) => void;
|
||||
readOnly?: boolean;
|
||||
backLabel?: string;
|
||||
editActionLabel?: string;
|
||||
regenerateActionLabel?: string;
|
||||
saveActionLabel?: string;
|
||||
enterWorldActionLabel?: string;
|
||||
autoSaveState?: 'idle' | 'saving' | 'saved' | 'error';
|
||||
}
|
||||
|
||||
type EntityGenerationKind = 'playable' | 'story' | 'landmark';
|
||||
|
||||
type PendingGeneratedEntity = {
|
||||
id: string;
|
||||
kind: EntityGenerationKind;
|
||||
title: string;
|
||||
progress: number;
|
||||
phaseLabel: string;
|
||||
};
|
||||
|
||||
type RecentGeneratedIds = Record<EntityGenerationKind, string[]>;
|
||||
|
||||
function SmallButton({
|
||||
onClick,
|
||||
children,
|
||||
@@ -75,6 +99,66 @@ function getCreateLabelByTab(activeTab: ResultTab) {
|
||||
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[],
|
||||
@@ -129,17 +213,31 @@ export function CustomWorldResultView({
|
||||
onEditSetting,
|
||||
onRegenerate: triggerRegenerate,
|
||||
onContinueExpand,
|
||||
onSave,
|
||||
onEnterWorld,
|
||||
onProfileChange,
|
||||
readOnly = false,
|
||||
backLabel = '返回',
|
||||
editActionLabel = '修改设定',
|
||||
regenerateActionLabel = '重新生成',
|
||||
saveActionLabel = '保存到我的作品',
|
||||
enterWorldActionLabel = '进入世界',
|
||||
autoSaveState = 'idle',
|
||||
}: 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 createTarget = useMemo(
|
||||
() => getCreateTargetByTab(activeTab),
|
||||
@@ -149,6 +247,89 @@ export function CustomWorldResultView({
|
||||
() => getCreateLabelByTab(activeTab),
|
||||
[activeTab],
|
||||
);
|
||||
const stopPendingProgressTimer = () => {
|
||||
if (pendingProgressTimerRef.current !== null) {
|
||||
window.clearInterval(pendingProgressTimerRef.current);
|
||||
pendingProgressTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => () => stopPendingProgressTimer(), []);
|
||||
|
||||
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;
|
||||
|
||||
@@ -169,10 +350,24 @@ export function CustomWorldResultView({
|
||||
if (ids.length === 0) return;
|
||||
onProfileChange(removeLandmarksFromProfile(profile, ids));
|
||||
};
|
||||
const autoSaveBadge =
|
||||
autoSaveState === 'saved' ? (
|
||||
<div className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1 text-[11px] text-emerald-100">
|
||||
已自动保存
|
||||
</div>
|
||||
) : autoSaveState === 'saving' ? (
|
||||
<div className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[11px] text-amber-100">
|
||||
保存中
|
||||
</div>
|
||||
) : autoSaveState === 'error' ? (
|
||||
<div className="rounded-full border border-rose-300/20 bg-rose-500/10 px-3 py-1 text-[11px] text-rose-100">
|
||||
保存失败
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="mb-4 flex justify-start">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
@@ -181,6 +376,7 @@ export function CustomWorldResultView({
|
||||
>
|
||||
{backLabel}
|
||||
</button>
|
||||
{autoSaveBadge}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
@@ -197,8 +393,27 @@ export function CustomWorldResultView({
|
||||
onCreateAction={
|
||||
readOnly || !createTarget
|
||||
? undefined
|
||||
: () => setEditorTarget(createTarget)
|
||||
: () => {
|
||||
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>
|
||||
@@ -225,6 +440,11 @@ export function CustomWorldResultView({
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{!error && localGenerationError ? (
|
||||
<div className="mt-3 rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{localGenerationError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
{profile.generationStatus === 'key_only' ? (
|
||||
@@ -250,10 +470,10 @@ export function CustomWorldResultView({
|
||||
继续补全世界
|
||||
</SmallButton>
|
||||
) : null}
|
||||
{onSave ? (
|
||||
{onEnterWorld ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
onClick={onEnterWorld}
|
||||
disabled={isGenerating}
|
||||
className={`pixel-nine-slice pixel-pressable text-left ${isGenerating ? 'opacity-55' : ''}`}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
@@ -263,7 +483,7 @@ export function CustomWorldResultView({
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{saveActionLabel}
|
||||
{enterWorldActionLabel}
|
||||
</span>
|
||||
<span className="text-white/60">→</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user