345 lines
9.5 KiB
TypeScript
345 lines
9.5 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
|
||
import { normalizeCustomWorldLandmarks } from '../../data/customWorldSceneGraph';
|
||
import { rpgCreationAssetClient } from '../../services/rpg-creation/rpgCreationAssetClient';
|
||
import type {
|
||
CustomWorldLandmark,
|
||
CustomWorldNpc,
|
||
CustomWorldPlayableNpc,
|
||
CustomWorldProfile,
|
||
} from '../../types';
|
||
import type { ResultTab } from '../CustomWorldEntityCatalog';
|
||
import type { RpgCreationEditorTarget } from '../rpg-creation-editor/RpgCreationEntityEditorModal';
|
||
|
||
export type EntityGenerationKind = 'playable' | 'story' | 'landmark';
|
||
|
||
export type PendingGeneratedEntity = {
|
||
id: string;
|
||
kind: EntityGenerationKind;
|
||
title: string;
|
||
progress: number;
|
||
phaseLabel: string;
|
||
};
|
||
|
||
export type RecentGeneratedIds = Record<EntityGenerationKind, string[]>;
|
||
|
||
export type AgentEntityGenerationResult = {
|
||
profile?: CustomWorldProfile | null;
|
||
};
|
||
|
||
function getCreateTargetByTab(
|
||
activeTab: ResultTab,
|
||
): RpgCreationEditorTarget | 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 getEntityCountByKind(
|
||
profile: CustomWorldProfile,
|
||
kind: EntityGenerationKind,
|
||
) {
|
||
if (kind === 'playable') return profile.playableNpcs.length;
|
||
if (kind === 'story') return profile.storyNpcs.length;
|
||
return profile.landmarks.length;
|
||
}
|
||
|
||
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 useRpgCreationResultActions(params: {
|
||
activeTab: ResultTab;
|
||
agentEntityGenerator?:
|
||
| ((kind: EntityGenerationKind) => Promise<AgentEntityGenerationResult | void>)
|
||
| undefined;
|
||
isGenerating: boolean;
|
||
onProfileChange: (profile: CustomWorldProfile) => void;
|
||
profile: CustomWorldProfile;
|
||
readOnly: boolean;
|
||
triggerRegenerate?: () => void;
|
||
}) {
|
||
const {
|
||
activeTab,
|
||
agentEntityGenerator,
|
||
isGenerating,
|
||
onProfileChange,
|
||
profile,
|
||
readOnly,
|
||
triggerRegenerate,
|
||
} = params;
|
||
const [editorTarget, setEditorTarget] =
|
||
useState<RpgCreationEditorTarget | null>(null);
|
||
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),
|
||
[activeTab],
|
||
);
|
||
const createLabel = useMemo(
|
||
() => 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 (agentEntityGenerator) {
|
||
const previousCount = getEntityCountByKind(profile, kind);
|
||
const generationResult = await agentEntityGenerator(kind);
|
||
const currentCount = generationResult?.profile
|
||
? getEntityCountByKind(generationResult.profile, kind)
|
||
: previousCount;
|
||
if (currentCount <= previousCount) {
|
||
throw new Error('生成请求已完成,但结果页未收到新增内容,请返回创作页重新打开草稿后重试。');
|
||
}
|
||
} else if (kind === 'playable') {
|
||
const nextNpc = await rpgCreationAssetClient.generatePlayableNpc({
|
||
profile,
|
||
});
|
||
onProfileChange(prependPlayableNpc(profile, nextNpc));
|
||
markGeneratedAsRecent('playable', nextNpc.id);
|
||
} else if (kind === 'story') {
|
||
const nextNpc = await rpgCreationAssetClient.generateStoryNpc({
|
||
profile,
|
||
});
|
||
onProfileChange(prependStoryNpc(profile, nextNpc));
|
||
markGeneratedAsRecent('story', nextNpc.id);
|
||
} else {
|
||
const nextLandmark = await rpgCreationAssetClient.generateLandmark({
|
||
profile,
|
||
});
|
||
onProfileChange(prependLandmark(profile, nextLandmark));
|
||
markGeneratedAsRecent('landmark', nextLandmark.id);
|
||
}
|
||
} catch (generationError) {
|
||
setLocalGenerationError(
|
||
generationError instanceof Error
|
||
? generationError.message
|
||
: '生成失败,请稍后重试。',
|
||
);
|
||
} finally {
|
||
finishPendingProgress();
|
||
}
|
||
};
|
||
|
||
const handleRegenerate = () => {
|
||
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));
|
||
};
|
||
|
||
return {
|
||
createLabel,
|
||
createTarget,
|
||
editorTarget,
|
||
handleDeleteLandmarks,
|
||
handleDeleteStoryNpcs,
|
||
handleGenerateEntity,
|
||
handleRegenerate,
|
||
localGenerationError,
|
||
pendingGeneratedEntity,
|
||
recentGeneratedIds,
|
||
setEditorTarget,
|
||
closeEditorTarget: () => setEditorTarget(null),
|
||
};
|
||
}
|