11
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-16 21:47:20 +08:00
parent 2456c10c63
commit 09d4c0c31b
79 changed files with 11873 additions and 2341 deletions

View File

@@ -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>