init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

View File

@@ -0,0 +1,314 @@
import { useEffect, useMemo, useState } from 'react';
import type { CustomWorldProfile } from '../../types';
import { resolveAssetReadUrl } from '../../services/assetReadUrlService';
type RpgCreationAssetDebugEntry = {
id: string;
kind: 'playable' | 'story' | 'landmark' | 'scene-act';
label: string;
imageSrc: string;
readUrl?: string;
};
type AssetDebugLoadStatus = 'loading' | 'loaded' | 'error';
const RPG_CREATION_ASSET_DEBUG_QUERY_KEY = 'debugCustomWorldAssets';
const RPG_CREATION_ASSET_DEBUG_STORAGE_KEY =
'genarrative.debug.customWorldAssets';
function isPresent<T>(value: T | null): value is T {
return value !== null;
}
export function shouldEnableRpgCreationAssetDebugPanel() {
if (!import.meta.env.DEV || typeof window === 'undefined') {
return false;
}
const searchParams = new URLSearchParams(window.location.search);
if (searchParams.get(RPG_CREATION_ASSET_DEBUG_QUERY_KEY) === '1') {
return true;
}
return (
window.localStorage.getItem(RPG_CREATION_ASSET_DEBUG_STORAGE_KEY) === '1'
);
}
function collectRpgCreationAssetDebugEntries(
profile: CustomWorldProfile,
): RpgCreationAssetDebugEntry[] {
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(isPresent);
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(isPresent);
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(isPresent);
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(isPresent),
) ?? [];
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
}`,
},
];
}
export function RpgCreationAssetDebugPanel({
profile,
}: {
profile: CustomWorldProfile;
}) {
const assetDebugEntries = useMemo(
() => collectRpgCreationAssetDebugEntries(profile),
[profile],
);
const assetDebugSummary = useMemo(
() => resolveAssetDebugSummary(profile),
[profile],
);
const [assetDebugStatusMap, setAssetDebugStatusMap] = useState<
Record<string, AssetDebugLoadStatus>
>({});
const [assetDebugReadUrlMap, setAssetDebugReadUrlMap] = useState<
Record<string, string>
>({});
useEffect(() => {
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');
void resolveAssetReadUrl(entry.imageSrc)
.then((readUrl) => {
if (cancelled) {
return;
}
setAssetDebugReadUrlMap((current) => ({
...current,
[entry.id]: readUrl,
}));
image.src = readUrl;
})
.catch(() => {
image.src = entry.imageSrc;
});
cleanupList.push(() => {
image.onload = null;
image.onerror = null;
});
});
return () => {
cancelled = true;
cleanupList.forEach((cleanup) => cleanup());
};
}, [assetDebugEntries]);
return (
<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) => {
const readUrl = assetDebugReadUrlMap[entry.id] || entry.imageSrc;
return (
<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={readUrl}
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>
);
}
export default RpgCreationAssetDebugPanel;

View File

@@ -0,0 +1,306 @@
import { X } from 'lucide-react';
import { type ReactNode, useState } from 'react';
import { createPortal } from 'react-dom';
import { resolveCustomWorldCoverPresentation } from '../../services/customWorldCover';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
function SmallButton({
children,
disabled = false,
onClick,
tone = 'default',
}: {
children: ReactNode;
disabled?: boolean;
onClick: () => void;
tone?: 'default' | 'sky';
}) {
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 PublishPanelDialog({
blockers,
profile,
publishReady,
isPublishing,
onClose,
onEditCover,
onPublish,
}: {
blockers: string[];
profile: CustomWorldProfile;
publishReady: boolean;
isPublishing: boolean;
onClose: () => void;
onEditCover: () => void;
onPublish: () => void;
}) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
const coverPresentation = resolveCustomWorldCoverPresentation(profile);
if (typeof document === 'undefined') {
return null;
}
return createPortal(
<div
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[140] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4`}
onClick={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<div
role="dialog"
aria-modal="true"
aria-label="发布作品"
className="platform-modal-shell platform-remap-surface flex max-h-[min(90vh,46rem)] w-full max-w-4xl flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm leading-6 text-[var(--platform-text-soft)]">
</div>
</div>
<button
type="button"
onClick={onClose}
aria-label="关闭"
className="platform-icon-button"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(18rem,0.78fr)]">
<div className="space-y-3">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
{blockers.length > 0 ? (
<div className="space-y-2">
{blockers.map((blocker, index) => (
<div
key={`publish-blocker-${index}-${blocker}`}
className="platform-banner platform-banner--warning text-sm leading-6"
>
<div className="text-xs font-semibold tracking-[0.14em] text-[var(--platform-warm-text)] opacity-80">
{index + 1}
</div>
<div className="mt-1 text-[var(--platform-text-strong)]">
{blocker}
</div>
</div>
))}
</div>
) : (
<div className="platform-banner platform-banner--success text-sm leading-6">
</div>
)}
</div>
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
{coverPresentation.sourceType === 'uploaded'
? '上传封面'
: coverPresentation.sourceType === 'generated'
? 'AI封面'
: '默认封面'}
</span>
</div>
<div className="platform-subpanel rounded-[1.25rem] p-2">
<CustomWorldCoverArtwork
imageSrc={coverPresentation.imageSrc}
title={profile.name}
fallbackLabel={profile.name.slice(0, 4) || '封面'}
renderMode={coverPresentation.renderMode}
characterImageSrcs={coverPresentation.characterImageSrcs}
className="aspect-[16/9] max-h-[15rem] rounded-[1rem]"
/>
</div>
<SmallButton onClick={onEditCover} tone="sky">
</SmallButton>
</div>
</div>
</div>
<div className="flex flex-col-reverse gap-3 border-t border-[var(--platform-subpanel-border)] px-5 py-4 sm:flex-row sm:justify-end">
<SmallButton onClick={onClose}></SmallButton>
<button
type="button"
onClick={onPublish}
disabled={!publishReady || isPublishing}
className={`platform-button platform-button--primary ${!publishReady || isPublishing ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isPublishing ? '发布中...' : '发布到广场'}
</button>
</div>
</div>
</div>,
document.body,
);
}
interface RpgCreationResultActionBarProps {
editActionLabel: string;
enterWorldActionLabel: string;
isGenerating: boolean;
onContinueExpand?: () => void;
onEditSetting?: () => void;
onEnterWorld?: () => void;
onOpenCoverEditor?: () => void;
onPublishWorld?: () => Promise<void> | void;
onTestWorld?: () => void;
onRegenerate?: () => void;
profile: CustomWorldProfile;
regenerateActionLabel: string;
publishReady: boolean;
publishBlockers: string[];
}
export function RpgCreationResultActionBar({
editActionLabel,
enterWorldActionLabel,
isGenerating,
onContinueExpand,
onEditSetting,
onEnterWorld,
onOpenCoverEditor,
onPublishWorld,
onTestWorld,
onRegenerate,
profile,
regenerateActionLabel,
publishReady,
publishBlockers,
}: RpgCreationResultActionBarProps) {
const [showPublishBlockersDialog, setShowPublishBlockersDialog] =
useState(false);
const [isPublishing, setIsPublishing] = useState(false);
// 结果页只在用户点击发布动作时展示阻断项,不做吸底常驻提示。
const handleEnterWorld = () => {
if (!publishReady) {
setShowPublishBlockersDialog(true);
return;
}
onEnterWorld?.();
};
const handlePublish = async () => {
if (!publishReady || isPublishing || !onPublishWorld) {
return;
}
setIsPublishing(true);
try {
await onPublishWorld();
setShowPublishBlockersDialog(false);
} finally {
setIsPublishing(false);
}
};
return (
<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}
{onRegenerate ? (
<SmallButton onClick={onRegenerate} tone="sky">
{regenerateActionLabel}
</SmallButton>
) : null}
{profile.generationStatus === 'key_only' && onContinueExpand ? (
<SmallButton
onClick={onContinueExpand}
tone="sky"
disabled={isGenerating}
>
</SmallButton>
) : null}
{onTestWorld ? (
<button
type="button"
onClick={onTestWorld}
disabled={isGenerating}
className={`platform-button platform-button--secondary ${isGenerating ? 'opacity-55' : ''}`}
>
</button>
) : null}
{onPublishWorld ? (
<button
type="button"
onClick={() => setShowPublishBlockersDialog(true)}
disabled={isGenerating}
className={`platform-button platform-button--primary ${isGenerating ? 'opacity-55' : ''}`}
>
</button>
) : onEnterWorld ? (
<button
type="button"
onClick={handleEnterWorld}
disabled={isGenerating}
className={`platform-button platform-button--primary ${isGenerating ? 'opacity-55' : ''}`}
>
{enterWorldActionLabel}
</button>
) : null}
</div>
{showPublishBlockersDialog ? (
<PublishPanelDialog
blockers={publishBlockers}
profile={profile}
publishReady={publishReady}
isPublishing={isPublishing}
onClose={() => setShowPublishBlockersDialog(false)}
onEditCover={() => {
setShowPublishBlockersDialog(false);
onOpenCoverEditor?.();
}}
onPublish={() => {
void handlePublish();
}}
/>
) : null}
</div>
);
}
export default RpgCreationResultActionBar;

View File

@@ -0,0 +1,59 @@
interface RpgCreationResultHeaderProps {
autoSaveState: 'idle' | 'saving' | 'saved' | 'error';
backLabel: string;
isGenerating: boolean;
onBack: () => void;
}
function renderAutoSaveBadge(
autoSaveState: RpgCreationResultHeaderProps['autoSaveState'],
) {
if (autoSaveState === 'saved') {
return (
<div className="platform-pill platform-pill--success px-3 py-1 text-[11px]">
</div>
);
}
if (autoSaveState === 'saving') {
return (
<div className="platform-pill platform-pill--warm px-3 py-1 text-[11px]">
</div>
);
}
if (autoSaveState === 'error') {
return (
<div className="platform-pill platform-pill--rose px-3 py-1 text-[11px]">
</div>
);
}
return null;
}
export function RpgCreationResultHeader({
autoSaveState,
backLabel,
isGenerating,
onBack,
}: RpgCreationResultHeaderProps) {
return (
<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>
{renderAutoSaveBadge(autoSaveState)}
</div>
);
}
export default RpgCreationResultHeader;

View File

@@ -0,0 +1,17 @@
import type { ComponentProps } from 'react';
import { RpgCreationResultView as RpgCreationResultViewImpl } from './RpgCreationResultViewImpl';
/**
* 工作包 C 完成后,结果页入口统一桥接 RPG 创作目录下的真实实现。
* 旧 `CustomWorldResultView.tsx` 兼容入口已经删除,后续结果页细化继续在该目录内部推进。
*/
export type RpgCreationResultViewProps = ComponentProps<
typeof RpgCreationResultViewImpl
>;
export function RpgCreationResultView(props: RpgCreationResultViewProps) {
return <RpgCreationResultViewImpl {...props} />;
}
export default RpgCreationResultView;

View File

@@ -0,0 +1,246 @@
import { useMemo, useState } from 'react';
import type { Character, CustomWorldProfile } from '../../types';
import {
CustomWorldEntityCatalog,
type ResultTab,
} from '../CustomWorldEntityCatalog';
import RpgCreationEntityEditorModal from '../rpg-creation-editor/RpgCreationEntityEditorModal';
import RpgCreationAssetDebugPanel, {
shouldEnableRpgCreationAssetDebugPanel,
} from './RpgCreationAssetDebugPanel';
import RpgCreationResultActionBar from './RpgCreationResultActionBar';
import RpgCreationResultHeader from './RpgCreationResultHeader';
import { useRpgCreationResultActions } from './useRpgCreationResultActions';
import type { EntityGenerationKind } from './useRpgCreationResultActions';
export interface RpgCreationResultViewProps {
profile: CustomWorldProfile;
previewCharacters: Character[];
isGenerating: boolean;
progress: number;
progressLabel: string;
error: string | null;
onBack: () => void;
onEditSetting?: () => void;
onRegenerate?: () => void;
onContinueExpand?: () => void;
onEnterWorld?: () => void;
onOpenCoverEditor?: () => void;
onPublishWorld?: () => Promise<void> | void;
onTestWorld?: () => void;
onDeleteEntities?: (kind: 'story' | 'landmark', ids: string[]) => Promise<void> | void;
onGenerateEntity?:
| ((kind: EntityGenerationKind) => Promise<{ profile?: CustomWorldProfile | null } | void> | { profile?: CustomWorldProfile | null } | void)
| undefined;
onProfileChange: (profile: CustomWorldProfile) => void;
readOnly?: boolean;
backLabel?: string;
editActionLabel?: string;
regenerateActionLabel?: string;
enterWorldActionLabel?: string;
autoSaveState?: 'idle' | 'saving' | 'saved' | 'error';
compactAgentResultMode?: boolean;
publishReady?: boolean;
publishBlockers?: string[];
qualityFindings?: Array<{
id: string;
severity: 'info' | 'warning' | 'blocker';
code: string;
targetId?: string | null;
message: string;
}>;
previewSourceLabel?: string | null;
}
export function RpgCreationResultView({
profile,
previewCharacters,
isGenerating,
progress,
progressLabel,
error,
onBack,
onEditSetting,
onRegenerate: triggerRegenerate,
onContinueExpand,
onOpenCoverEditor,
onPublishWorld,
onTestWorld,
onDeleteEntities,
onEnterWorld,
onGenerateEntity,
onProfileChange,
readOnly = false,
backLabel = '返回',
editActionLabel = '修改设定',
regenerateActionLabel = '重新生成',
enterWorldActionLabel = '进入世界',
autoSaveState = 'idle',
compactAgentResultMode = false,
publishReady = true,
publishBlockers = [],
qualityFindings = [],
}: RpgCreationResultViewProps) {
const [activeTab, setActiveTab] = useState<ResultTab>('world');
const assetDebugEnabled = useMemo(
() => shouldEnableRpgCreationAssetDebugPanel(),
[],
);
const {
closeEditorTarget,
createLabel,
createTarget,
editorTarget,
handleDeleteLandmarks,
handleDeleteStoryNpcs,
handleGenerateEntity,
handleRegenerate,
localGenerationError,
pendingGeneratedEntity,
recentGeneratedIds,
setEditorTarget,
} = useRpgCreationResultActions({
activeTab,
agentEntityGenerator: onGenerateEntity
? async (kind) => {
return onGenerateEntity(kind);
}
: undefined,
isGenerating,
onProfileChange,
profile,
readOnly,
triggerRegenerate,
});
const deleteStoryNpcs = onDeleteEntities
? (ids: string[]) => {
void onDeleteEntities('story', ids);
}
: handleDeleteStoryNpcs;
const deleteLandmarks = onDeleteEntities
? (ids: string[]) => {
void onDeleteEntities('landmark', ids);
}
: handleDeleteLandmarks;
return (
<div className="platform-remap-surface flex h-full min-h-0 flex-col">
<RpgCreationResultHeader
autoSaveState={autoSaveState}
backLabel={backLabel}
isGenerating={isGenerating}
onBack={onBack}
/>
<div className="min-h-0 flex-1 overflow-hidden">
<CustomWorldEntityCatalog
profile={profile}
previewCharacters={previewCharacters}
activeTab={activeTab}
onActiveTabChange={setActiveTab}
onEditTarget={setEditorTarget}
onProfileChange={onProfileChange}
onDeleteStoryNpcs={deleteStoryNpcs}
onDeleteLandmarks={deleteLandmarks}
createActionLabel={
readOnly || (compactAgentResultMode && !onGenerateEntity)
? undefined
: createLabel
}
onCreateAction={
readOnly || (compactAgentResultMode && !onGenerateEntity) || !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 &&
compactAgentResultMode &&
publishBlockers.length <= 0 &&
qualityFindings.some((entry) => entry.severity === 'warning') ? (
<div className="platform-banner platform-banner--info mt-3 rounded-2xl text-sm leading-6">
{qualityFindings.filter((entry) => entry.severity === 'warning').length} warning
</div>
) : null}
{!error && localGenerationError ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{localGenerationError}
</div>
) : null}
{assetDebugEnabled ? <RpgCreationAssetDebugPanel profile={profile} /> : null}
<RpgCreationResultActionBar
editActionLabel={editActionLabel}
enterWorldActionLabel={enterWorldActionLabel}
isGenerating={isGenerating}
onContinueExpand={onContinueExpand}
onEditSetting={onEditSetting}
onEnterWorld={onEnterWorld}
onOpenCoverEditor={
onOpenCoverEditor ?? (() => setEditorTarget({ kind: 'cover' }))
}
onPublishWorld={onPublishWorld}
onTestWorld={onTestWorld}
onRegenerate={triggerRegenerate ? handleRegenerate : undefined}
profile={profile}
regenerateActionLabel={regenerateActionLabel}
publishReady={publishReady}
publishBlockers={publishBlockers}
/>
<RpgCreationEntityEditorModal
profile={profile}
target={editorTarget}
onClose={closeEditorTarget}
onProfileChange={onProfileChange}
/>
</div>
);
}

View File

@@ -0,0 +1,344 @@
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),
};
}