@@ -13,7 +13,17 @@ interface CustomWorldGenerationViewProps {
|
||||
onBack: () => void;
|
||||
onEditSetting: () => void;
|
||||
onRetry: () => void;
|
||||
onInterrupt: () => void;
|
||||
onInterrupt?: () => void;
|
||||
backLabel?: string;
|
||||
settingActionLabel?: string;
|
||||
retryLabel?: string;
|
||||
interruptLabel?: string;
|
||||
settingTitle?: string;
|
||||
settingDescription?: string;
|
||||
progressTitle?: string;
|
||||
activeBadgeLabel?: string;
|
||||
pausedBadgeLabel?: string;
|
||||
idleBadgeLabel?: string;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number) {
|
||||
@@ -46,6 +56,16 @@ export function CustomWorldGenerationView({
|
||||
onEditSetting,
|
||||
onRetry,
|
||||
onInterrupt,
|
||||
backLabel = '返回',
|
||||
settingActionLabel = '修改设定',
|
||||
retryLabel = '重新开始生成',
|
||||
interruptLabel = '中断世界生成',
|
||||
settingTitle = '玩家设定',
|
||||
settingDescription = '这段文本会直接驱动本轮世界框架、角色与场景生成。',
|
||||
progressTitle = '生成进度',
|
||||
activeBadgeLabel = '世界建设中',
|
||||
pausedBadgeLabel = '生成已暂停',
|
||||
idleBadgeLabel = '等待操作',
|
||||
}: CustomWorldGenerationViewProps) {
|
||||
const progressValue = getProgressPercentage(progress);
|
||||
const steps = progress?.steps ?? [];
|
||||
@@ -68,10 +88,14 @@ export function CustomWorldGenerationView({
|
||||
disabled={isGenerating}
|
||||
className={`rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'cursor-not-allowed opacity-45' : ''}`}
|
||||
>
|
||||
返回
|
||||
{backLabel}
|
||||
</button>
|
||||
<div className="rounded-full border border-sky-300/16 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-sky-100">
|
||||
{isGenerating ? '世界建设中' : error ? '生成已暂停' : '等待操作'}
|
||||
{isGenerating
|
||||
? activeBadgeLabel
|
||||
: error
|
||||
? pausedBadgeLabel
|
||||
: idleBadgeLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -86,10 +110,10 @@ export function CustomWorldGenerationView({
|
||||
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-sky-100/85">
|
||||
玩家设定
|
||||
{settingTitle}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-zinc-400">
|
||||
这段文本会直接驱动本轮世界框架、角色与场景生成。
|
||||
{settingDescription}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -98,7 +122,7 @@ export function CustomWorldGenerationView({
|
||||
disabled={isGenerating}
|
||||
className={`rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
|
||||
>
|
||||
修改设定
|
||||
{settingActionLabel}
|
||||
</button>
|
||||
</div>
|
||||
<div className="whitespace-pre-line rounded-2xl border border-white/8 bg-black/22 px-4 py-4 text-sm leading-7 text-zinc-200 md:max-h-[16rem] md:overflow-y-auto">
|
||||
@@ -116,7 +140,7 @@ export function CustomWorldGenerationView({
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
生成进度
|
||||
{progressTitle}
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-black leading-tight text-white sm:text-[2rem]">
|
||||
{progress?.phaseLabel ?? '正在启动世界生成'}
|
||||
@@ -211,7 +235,7 @@ export function CustomWorldGenerationView({
|
||||
onClick={onEditSetting}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-300 transition-colors hover:text-white"
|
||||
>
|
||||
返回修改
|
||||
{settingActionLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -224,21 +248,21 @@ export function CustomWorldGenerationView({
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-semibold text-white">
|
||||
重新开始生成
|
||||
{retryLabel}
|
||||
</span>
|
||||
<span className="text-white/60">→</span>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
) : onInterrupt ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onInterrupt}
|
||||
className="rounded-full border border-rose-300/18 bg-rose-500/10 px-4 py-2 text-sm text-rose-100 transition-colors hover:text-white"
|
||||
>
|
||||
中断世界生成
|
||||
{interruptLabel}
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -273,9 +273,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
||||
gameState.currentScene === 'Selection' &&
|
||||
Boolean(gameState.worldType) &&
|
||||
!gameState.playerCharacter;
|
||||
const hideSelectionHero =
|
||||
gameState.currentScene === 'Selection' &&
|
||||
selectionStage !== 'platform';
|
||||
const collapseTopStage = gameState.currentScene === 'Selection';
|
||||
const shouldHideStoryOptions = sceneTransitionPhase !== 'idle';
|
||||
|
||||
const dialogueIndicator = useMemo(() => {
|
||||
@@ -375,15 +373,8 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
||||
backgroundRepeat: 'repeat',
|
||||
}}
|
||||
>
|
||||
<div className={`relative ${hideSelectionHero ? 'h-0 border-b-0' : 'h-[36%] border-b border-white/5'}`}>
|
||||
{gameState.currentScene === 'Selection' && !hideSelectionHero ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.14),transparent_40%),linear-gradient(180deg,rgba(8,10,14,0.2),rgba(8,10,14,0.82))]">
|
||||
<div className="text-center">
|
||||
<div className="text-5xl font-black tracking-[0.14em] text-white sm:text-6xl">叙世</div>
|
||||
<div className="mt-3 text-sm tracking-[0.44em] text-zinc-300 sm:text-base">视觉叙事 RPG</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`relative ${collapseTopStage ? 'h-0 border-b-0' : 'h-[36%] border-b border-white/5'}`}>
|
||||
{collapseTopStage ? null : (
|
||||
<GameCanvas
|
||||
scrollWorld={visibleGameState.scrollWorld}
|
||||
animationState={visibleGameState.animationState}
|
||||
|
||||
@@ -31,14 +31,7 @@ export function GameShellCanvasStage({
|
||||
}) {
|
||||
return (
|
||||
<div className={`relative ${hideSelectionHero ? 'h-0 border-b-0' : 'h-[36%] border-b border-white/5'}`}>
|
||||
{gameState.currentScene === 'Selection' && !hideSelectionHero ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.14),transparent_40%),linear-gradient(180deg,rgba(8,10,14,0.2),rgba(8,10,14,0.82))]">
|
||||
<div className="selection-hero-brand px-6 text-center">
|
||||
<div className="selection-hero-brand__title">叙世</div>
|
||||
<div className="selection-hero-brand__subtitle">视觉叙事 RPG</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
{hideSelectionHero ? null : (
|
||||
<GameCanvas
|
||||
scrollWorld={visibleGameState.scrollWorld}
|
||||
animationState={visibleGameState.animationState}
|
||||
|
||||
158
src/components/game-shell/PlatformCreationTypeModal.tsx
Normal file
158
src/components/game-shell/PlatformCreationTypeModal.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
|
||||
type PlatformCreationTypeModalProps = {
|
||||
isOpen: boolean;
|
||||
isBusy: boolean;
|
||||
error: string | null;
|
||||
onClose: () => void;
|
||||
onSelectRpg: () => void;
|
||||
};
|
||||
|
||||
type CreationGameTypeCard = {
|
||||
id: 'rpg' | 'airp' | 'visual-novel';
|
||||
title: string;
|
||||
subtitle: string;
|
||||
badge: string;
|
||||
locked: boolean;
|
||||
};
|
||||
|
||||
const CREATION_GAME_TYPES: CreationGameTypeCard[] = [
|
||||
{
|
||||
id: 'rpg',
|
||||
title: '角色扮演 RPG',
|
||||
subtitle: 'Agent 共创',
|
||||
badge: '可创建',
|
||||
locked: false,
|
||||
},
|
||||
{
|
||||
id: 'airp',
|
||||
title: 'AIRP',
|
||||
subtitle: '敬请期待',
|
||||
badge: '锁定',
|
||||
locked: true,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel',
|
||||
title: '视觉小说',
|
||||
subtitle: '敬请期待',
|
||||
badge: '锁定',
|
||||
locked: true,
|
||||
},
|
||||
];
|
||||
|
||||
function CreationTypeCard(props: {
|
||||
item: CreationGameTypeCard;
|
||||
busy: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { item, busy, onSelect } = props;
|
||||
const disabled = item.locked || busy;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onSelect}
|
||||
className={`relative overflow-hidden rounded-[1.65rem] border px-4 py-4 text-left transition ${
|
||||
item.locked
|
||||
? 'cursor-not-allowed border-white/8 bg-white/5 text-zinc-500'
|
||||
: 'border-emerald-300/18 bg-[radial-gradient(circle_at_top_left,rgba(110,231,183,0.16),transparent_36%),linear-gradient(180deg,rgba(255,255,255,0.03),rgba(255,255,255,0.02))] text-white hover:border-emerald-300/35'
|
||||
} ${busy && !item.locked ? 'opacity-70' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-[10px] tracking-[0.18em] ${
|
||||
item.locked
|
||||
? 'border border-white/8 bg-black/18 text-zinc-400'
|
||||
: 'border border-emerald-300/20 bg-emerald-500/10 text-emerald-100'
|
||||
}`}
|
||||
>
|
||||
{item.locked ? item.badge : busy ? '正在开启' : item.badge}
|
||||
</span>
|
||||
<span className="text-lg leading-none text-white/45">
|
||||
{item.locked ? '·' : '→'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-8 text-xl font-black leading-tight text-inherit">
|
||||
{item.title}
|
||||
</div>
|
||||
<div
|
||||
className={`mt-2 text-sm ${
|
||||
item.locked ? 'text-zinc-500' : 'text-zinc-200/82'
|
||||
}`}
|
||||
>
|
||||
{item.subtitle}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlatformCreationTypeModal({
|
||||
isOpen,
|
||||
isBusy,
|
||||
error,
|
||||
onClose,
|
||||
onSelectRpg,
|
||||
}: PlatformCreationTypeModalProps) {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[90] flex items-end justify-center bg-black/72 p-3 backdrop-blur-sm sm:items-center sm:p-4">
|
||||
<div
|
||||
className="pixel-nine-slice w-full max-w-3xl"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel, {
|
||||
paddingX: 18,
|
||||
paddingY: 18,
|
||||
})}
|
||||
>
|
||||
<div className="rounded-[1.8rem] bg-[linear-gradient(180deg,rgba(11,16,22,0.98),rgba(8,10,14,0.98))]">
|
||||
<div className="flex items-start justify-between gap-3 border-b border-white/8 px-4 py-4 sm:px-5">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-white">
|
||||
选择创作类型
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
先选玩法类型,再进入对应创作工作台。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isBusy}
|
||||
className="rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-4 sm:px-5 sm:py-5">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{CREATION_GAME_TYPES.map((item) => (
|
||||
<CreationTypeCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
busy={isBusy}
|
||||
onSelect={() => {
|
||||
if (item.id === 'rpg') {
|
||||
onSelectRpg();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-4 rounded-[1.25rem] border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,27 @@
|
||||
import { type ComponentType, useMemo } from 'react';
|
||||
import {
|
||||
BookOpen,
|
||||
Camera,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
Coins,
|
||||
Copy,
|
||||
Crown,
|
||||
MessageCircle,
|
||||
Pencil,
|
||||
Settings,
|
||||
Ticket,
|
||||
UserPlus,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import {
|
||||
CHROME_ICONS,
|
||||
getNineSliceStyle,
|
||||
UI_CHROME,
|
||||
} from '../../uiAssets';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
import {
|
||||
@@ -20,43 +33,20 @@ import {
|
||||
resolvePlatformWorldLeadPortrait,
|
||||
} from './platformWorldPresentation';
|
||||
|
||||
function SectionHeader({
|
||||
title,
|
||||
detail,
|
||||
actionLabel,
|
||||
onAction,
|
||||
}: {
|
||||
title: string;
|
||||
detail: string;
|
||||
actionLabel?: string;
|
||||
onAction?: (() => void) | null;
|
||||
}) {
|
||||
export type PlatformHomeTab = 'home' | 'create' | 'profile';
|
||||
|
||||
function SectionHeader({ title, detail }: { title: string; detail: string }) {
|
||||
return (
|
||||
<div className="mb-3 flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
|
||||
{detail}
|
||||
</div>
|
||||
<div className="mt-1 text-base font-bold text-white">{title}</div>
|
||||
<div className="mb-3">
|
||||
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
|
||||
{detail}
|
||||
</div>
|
||||
{actionLabel && onAction ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAction}
|
||||
className="rounded-full border border-white/10 bg-black/25 px-3 py-1.5 text-[11px] text-zinc-200 transition hover:border-white/20 hover:text-white"
|
||||
>
|
||||
{actionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
<div className="mt-1 text-base font-bold text-white">{title}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyShelf({
|
||||
text,
|
||||
}: {
|
||||
text: string;
|
||||
}) {
|
||||
function EmptyShelf({ text }: { text: string }) {
|
||||
return (
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel rounded-[1.35rem] text-sm leading-6 text-zinc-300"
|
||||
@@ -150,7 +140,173 @@ function WorldCard({
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformTabButton({
|
||||
active,
|
||||
label,
|
||||
iconSrc,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
iconSrc: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex h-full w-full items-center justify-center rounded-[1rem] px-2 py-1.5 transition ${
|
||||
active
|
||||
? 'border border-white/15 bg-white/8 text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.08)]'
|
||||
: 'text-zinc-400 hover:bg-white/5 hover:text-zinc-100'
|
||||
}`}
|
||||
>
|
||||
<span className="flex flex-col items-center justify-center gap-1">
|
||||
<span
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-full ${
|
||||
active
|
||||
? 'bg-white/10 shadow-[0_0_14px_rgba(255,255,255,0.14)]'
|
||||
: 'bg-black/10'
|
||||
}`}
|
||||
>
|
||||
<PixelIcon
|
||||
src={iconSrc}
|
||||
className={`h-[1.125rem] w-[1.125rem] ${
|
||||
active ? 'opacity-100' : 'opacity-65 grayscale'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={`text-[11px] font-semibold tracking-[0.18em] ${
|
||||
active ? 'text-white' : 'text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function formatSnapshotTime(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '刚刚保存';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function describeLoginMethod(loginMethod: AuthUser['loginMethod']) {
|
||||
switch (loginMethod) {
|
||||
case 'phone':
|
||||
return '手机号';
|
||||
case 'wechat':
|
||||
return '微信';
|
||||
default:
|
||||
return '账号密码';
|
||||
}
|
||||
}
|
||||
|
||||
function describeBindingStatus(bindingStatus: AuthUser['bindingStatus']) {
|
||||
return bindingStatus === 'pending_bind_phone' ? '待绑定手机号' : '正常';
|
||||
}
|
||||
|
||||
function formatPlayTime(playTimeMs: number) {
|
||||
const totalSeconds = Math.max(0, Math.floor(playTimeMs / 1000));
|
||||
const days = Math.floor(totalSeconds / 86400);
|
||||
const hours = Math.floor((totalSeconds % 86400) / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}天 ${hours}小时`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours}小时 ${minutes}分`;
|
||||
}
|
||||
return `${minutes}分`;
|
||||
}
|
||||
|
||||
function buildPublicUserCode(user: AuthUser | null | undefined) {
|
||||
const raw =
|
||||
user?.id.replace(/[^a-zA-Z0-9]/gu, '').toUpperCase() ||
|
||||
user?.username.replace(/[^a-zA-Z0-9]/gu, '').toUpperCase() ||
|
||||
'00000000';
|
||||
|
||||
return `SY-${raw.slice(-8).padStart(8, '0')}`;
|
||||
}
|
||||
|
||||
function getUserAvatarLabel(user: AuthUser | null | undefined) {
|
||||
return (user?.displayName || user?.username || '叙').slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
function copyText(value: string) {
|
||||
if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) {
|
||||
return;
|
||||
}
|
||||
|
||||
void navigator.clipboard.writeText(value);
|
||||
}
|
||||
|
||||
function ProfileStatCard({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
}) {
|
||||
const Icon = icon;
|
||||
|
||||
return (
|
||||
<div className="rounded-[1.35rem] border border-white/10 bg-black/18 px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-zinc-400">
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="text-[11px] tracking-[0.16em]">{label}</span>
|
||||
</div>
|
||||
<div className="mt-3 text-lg font-black text-white">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileShortcutButton({
|
||||
label,
|
||||
icon,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
onClick?: (() => void) | null;
|
||||
}) {
|
||||
const Icon = icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick ?? undefined}
|
||||
className="flex min-h-[5.25rem] flex-col items-center justify-center gap-2 rounded-[1.2rem] border border-white/10 bg-black/16 px-3 py-3 text-center transition hover:border-white/20 hover:bg-white/6"
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/8 text-zinc-100">
|
||||
<Icon className="h-[1.125rem] w-[1.125rem]" />
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-white">{label}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlatformHomeView({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
hasSavedGame,
|
||||
savedSnapshot,
|
||||
featuredEntries,
|
||||
@@ -159,11 +315,13 @@ export function PlatformHomeView({
|
||||
isLoadingPlatform,
|
||||
platformError,
|
||||
onContinueGame,
|
||||
onRefresh,
|
||||
onOpenCreateWorld,
|
||||
onOpenCreateTypePicker,
|
||||
onOpenGalleryDetail,
|
||||
onOpenLibraryDetail,
|
||||
}: {
|
||||
activeTab: PlatformHomeTab;
|
||||
onTabChange: (tab: PlatformHomeTab) => void;
|
||||
hasSavedGame: boolean;
|
||||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||
featuredEntries: CustomWorldGalleryCard[];
|
||||
@@ -172,12 +330,18 @@ export function PlatformHomeView({
|
||||
isLoadingPlatform: boolean;
|
||||
platformError: string | null;
|
||||
onContinueGame: () => void;
|
||||
onRefresh: () => void;
|
||||
onOpenCreateWorld: () => void;
|
||||
onOpenCreateTypePicker: () => void;
|
||||
onOpenGalleryDetail: (entry: CustomWorldGalleryCard) => void;
|
||||
onOpenLibraryDetail: (entry: CustomWorldLibraryEntry<CustomWorldProfile>) => void;
|
||||
onOpenLibraryDetail: (
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
||||
) => void;
|
||||
}) {
|
||||
const authUi = useAuthUi();
|
||||
const featuredShelf = useMemo(
|
||||
() => featuredEntries.slice(0, 6),
|
||||
[featuredEntries],
|
||||
);
|
||||
const snapshotWorldName =
|
||||
savedSnapshot?.gameState.customWorldProfile?.name ??
|
||||
savedSnapshot?.gameState.currentScenePreset?.name ??
|
||||
@@ -186,169 +350,401 @@ export function PlatformHomeView({
|
||||
savedSnapshot?.gameState.playerCharacter?.title ??
|
||||
savedSnapshot?.gameState.playerCharacter?.name ??
|
||||
'旅人';
|
||||
const featuredShelf = featuredEntries.slice(0, 6);
|
||||
const snapshotDigest =
|
||||
savedSnapshot?.gameState.storyEngineMemory?.continueGameDigest ??
|
||||
savedSnapshot?.currentStory?.text ??
|
||||
savedSnapshot?.gameState.customWorldProfile?.summary ??
|
||||
'上一次冒险已经保存,可以从这里继续推进故事。';
|
||||
const publicUserCode = buildPublicUserCode(authUi?.user);
|
||||
const avatarLabel = getUserAvatarLabel(authUi?.user);
|
||||
const remainingNarrativeCoins = savedSnapshot?.gameState.playerCurrency ?? 0;
|
||||
const totalPlayTime = formatPlayTime(
|
||||
savedSnapshot?.gameState.runtimeStats.playTimeMs ?? 0,
|
||||
);
|
||||
const playedWorkCount = hasSavedGame ? 1 : 0;
|
||||
const tabIcons = {
|
||||
home: "/Icons/Admurin's Pixel Items/Admurin's Pixel Items/Miscellaneous/Singles/192_RustyTrinket_House.png",
|
||||
create: '/Icons/01_Scroll.png',
|
||||
profile: '/UI/Icon_Eq_Head.png',
|
||||
} as const;
|
||||
const recentPlayItems = savedSnapshot
|
||||
? [
|
||||
{
|
||||
id: 'latest-save',
|
||||
title: snapshotWorldName,
|
||||
subtitle: snapshotCharacterName,
|
||||
summary: snapshotDigest,
|
||||
updatedAt: savedSnapshot.savedAt,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-amber-300/20 bg-amber-500/10">
|
||||
<PixelIcon src={CHROME_ICONS.refreshOptions} className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
|
||||
GENARRATIVE PLATFORM
|
||||
let content = (
|
||||
<div className="space-y-4 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={hasSavedGame ? onContinueGame : onOpenCreateWorld}
|
||||
className="pixel-nine-slice pixel-pressable relative block w-full overflow-hidden text-left"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 18,
|
||||
paddingY: 16,
|
||||
})}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(250,204,21,0.16),transparent_36%),linear-gradient(135deg,rgba(15,23,42,0.78),rgba(8,10,14,0.95))]" />
|
||||
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-amber-100">
|
||||
{hasSavedGame ? 'CONTINUE' : 'CREATE'}
|
||||
</span>
|
||||
<div className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[11px] text-zinc-100">
|
||||
{hasSavedGame ? '继续冒险' : '创建世界'}
|
||||
</div>
|
||||
<div className="truncate text-lg font-black text-white">
|
||||
自定义世界广场
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-black text-white">
|
||||
{hasSavedGame ? snapshotWorldName : '写下一个能被游玩的世界'}
|
||||
</div>
|
||||
<div className="mt-2 max-w-[28rem] text-sm leading-6 text-zinc-200/88">
|
||||
{hasSavedGame
|
||||
? `${snapshotCharacterName} 的进度已保存,点这里回到上一次停下来的故事节点。`
|
||||
: '从设定、角色到场景网络,先生成一版可玩的世界底稿,再继续精修和发布。'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
className="rounded-full border border-white/10 bg-black/25 px-3 py-2 text-[11px] text-zinc-200 transition hover:border-white/20 hover:text-white"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
{authUi?.user ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => authUi.openAccountModal()}
|
||||
className="rounded-full border border-white/10 bg-black/25 px-3 py-2 text-[11px] text-zinc-100 transition hover:border-white/20 hover:text-white"
|
||||
</button>
|
||||
|
||||
{platformError ? (
|
||||
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{platformError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section>
|
||||
<SectionHeader title="精选推荐" detail="为你挑选" />
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取精选作品..." />
|
||||
) : featuredShelf.length > 0 ? (
|
||||
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{featuredShelf.map((entry: CustomWorldGalleryCard) => (
|
||||
<WorldCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:featured`}
|
||||
entry={entry}
|
||||
badge="推荐"
|
||||
metaLabel={describePlatformThemeLabel(entry.themeMode)}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="还没有公开作品,先创建你的第一个世界吧。" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader title="最新发布" detail="玩家广场" />
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取最新发布..." />
|
||||
) : latestEntries.length > 0 ? (
|
||||
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{latestEntries.map((entry: CustomWorldGalleryCard) => (
|
||||
<WorldCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:latest`}
|
||||
entry={entry}
|
||||
badge={formatPlatformWorldTime(entry.publishedAt)}
|
||||
metaLabel={entry.authorDisplayName}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="公开广场暂时还没有新作品。" />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (activeTab === 'create') {
|
||||
content = (
|
||||
<div className="space-y-4 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenCreateTypePicker}
|
||||
className="pixel-nine-slice pixel-pressable relative block w-full overflow-hidden text-left"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 18,
|
||||
paddingY: 16,
|
||||
})}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.18),transparent_38%),linear-gradient(180deg,rgba(8,10,14,0.18),rgba(8,10,14,0.92))]" />
|
||||
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
|
||||
<span className="w-fit rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-sky-100">
|
||||
CREATE
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-3xl font-black text-white">开启新的创作</div>
|
||||
<div className="mt-2 max-w-[28rem] text-sm leading-6 text-zinc-200/88">
|
||||
先选择游戏类型,再进入对应的创作工作台继续推进。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<section>
|
||||
<SectionHeader title="我的创作" detail="草稿与已发布" />
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取你的作品..." />
|
||||
) : myEntries.length > 0 ? (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{myEntries.map(
|
||||
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => (
|
||||
<WorldCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:mine`}
|
||||
entry={entry}
|
||||
badge={entry.visibility === 'published' ? '已发布' : '草稿'}
|
||||
metaLabel={
|
||||
entry.visibility === 'published'
|
||||
? formatPlatformWorldTime(entry.publishedAt)
|
||||
: '仅自己可见'
|
||||
}
|
||||
onClick={() => onOpenLibraryDetail(entry)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="你还没有保存任何自定义世界,先创建一个草稿开始吧。" />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab === 'profile') {
|
||||
content = (
|
||||
<div className="space-y-4 pb-2">
|
||||
{authUi?.user ? (
|
||||
<>
|
||||
<section className="overflow-hidden rounded-[1.8rem] border border-white/10 bg-[linear-gradient(180deg,rgba(248,244,236,0.96),rgba(232,225,214,0.92))] p-4 text-slate-900 shadow-[0_18px_50px_rgba(0,0,0,0.18)]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => authUi.openAccountModal()}
|
||||
className="relative h-16 w-16 shrink-0 rounded-[1.4rem] bg-[linear-gradient(135deg,#2a3141,#66718a)] text-white shadow-[0_12px_24px_rgba(15,23,42,0.22)]"
|
||||
>
|
||||
<span className="flex h-full w-full items-center justify-center text-2xl font-black">
|
||||
{avatarLabel}
|
||||
</span>
|
||||
<span className="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full border border-white/30 bg-white/85 text-slate-700">
|
||||
<Camera className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="truncate text-xl font-black text-slate-900">
|
||||
{authUi.user.displayName}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => authUi.openAccountModal()}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full bg-slate-900/6 text-slate-700 transition hover:bg-slate-900/10"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||
<span>叙世号 {publicUserCode}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(publicUserCode)}
|
||||
className="flex items-center gap-1 rounded-full bg-slate-900/6 px-2 py-1 text-slate-700 transition hover:bg-slate-900/10"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="rounded-full bg-slate-900/6 px-2.5 py-1 text-slate-700">
|
||||
{describeLoginMethod(authUi.user.loginMethod)}
|
||||
</span>
|
||||
<span className="rounded-full bg-slate-900/6 px-2.5 py-1 text-slate-700">
|
||||
{describeBindingStatus(authUi.user.bindingStatus)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex shrink-0 items-center gap-2 rounded-[1.1rem] bg-[linear-gradient(135deg,#5d79ff,#8ba2ff)] px-3 py-2 text-left text-white shadow-[0_12px_24px_rgba(93,121,255,0.28)]"
|
||||
>
|
||||
<Crown className="h-4 w-4" />
|
||||
<div>
|
||||
<div className="text-xs font-bold">会员充值</div>
|
||||
<div className="text-[10px] text-white/80">普通用户</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 opacity-80" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="pixel-nine-slice"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 16,
|
||||
paddingY: 14,
|
||||
})}
|
||||
>
|
||||
{authUi.user.displayName}
|
||||
</button>
|
||||
) : null}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<ProfileStatCard
|
||||
label="剩余叙世币"
|
||||
value={`${remainingNarrativeCoins}`}
|
||||
icon={Coins}
|
||||
/>
|
||||
<ProfileStatCard
|
||||
label="总游戏时长"
|
||||
value={totalPlayTime}
|
||||
icon={Clock3}
|
||||
/>
|
||||
<ProfileStatCard
|
||||
label="玩过作品"
|
||||
value={`${playedWorkCount}`}
|
||||
icon={BookOpen}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="pixel-nine-slice"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 16,
|
||||
paddingY: 14,
|
||||
})}
|
||||
>
|
||||
<SectionHeader title="最近游玩" detail="继续上次进度" />
|
||||
{recentPlayItems.length > 0 ? (
|
||||
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{recentPlayItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={onContinueGame}
|
||||
className="relative flex h-[10.5rem] w-[17rem] shrink-0 overflow-hidden rounded-[1.4rem] border border-white/10 bg-[linear-gradient(135deg,rgba(25,32,46,0.95),rgba(9,12,18,0.96))] p-4 text-left"
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(96,165,250,0.18),transparent_35%),radial-gradient(circle_at_bottom_left,rgba(250,204,21,0.12),transparent_28%)]" />
|
||||
<div className="relative z-10 flex h-full flex-col">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-sky-100">
|
||||
RECENT PLAY
|
||||
</span>
|
||||
<span className="text-[11px] text-zinc-400">
|
||||
{formatSnapshotTime(item.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<div className="line-clamp-1 text-xl font-black text-white">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-zinc-300">
|
||||
当前角色:{item.subtitle}
|
||||
</div>
|
||||
<div className="mt-2 line-clamp-3 text-xs leading-5 text-zinc-400">
|
||||
{item.summary}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="还没有最近游玩的存档,去首页挑一个世界开始冒险吧。" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="pixel-nine-slice"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 16,
|
||||
paddingY: 14,
|
||||
})}
|
||||
>
|
||||
<SectionHeader title="常用功能" detail="快捷入口" />
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<ProfileShortcutButton label="邀请好友" icon={UserPlus} />
|
||||
<ProfileShortcutButton label="填邀请码" icon={Ticket} />
|
||||
<ProfileShortcutButton label="玩家社区" icon={MessageCircle} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="pixel-nine-slice"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 16,
|
||||
paddingY: 14,
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => authUi.openAccountModal()}
|
||||
className="flex w-full items-center justify-between gap-3 rounded-[1.25rem] border border-white/10 bg-black/16 px-4 py-4 text-left transition hover:border-white/20 hover:bg-white/6"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/8 text-zinc-100">
|
||||
<Settings className="h-[1.125rem] w-[1.125rem]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold text-white">设置</div>
|
||||
<div className="text-xs text-zinc-400">账号与安全</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-zinc-500" />
|
||||
</button>
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<EmptyShelf text="当前还没有读取到账户信息。" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="mb-4">
|
||||
<div className="text-lg font-black text-white">叙世</div>
|
||||
<div className="mt-1 text-[10px] tracking-[0.28em] text-zinc-500">
|
||||
GENARRATIVE
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide">
|
||||
<div className="space-y-4 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={hasSavedGame ? onContinueGame : onOpenCreateWorld}
|
||||
className="pixel-nine-slice pixel-pressable relative block w-full overflow-hidden text-left"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 18, paddingY: 16 })}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(250,204,21,0.16),transparent_36%),linear-gradient(135deg,rgba(15,23,42,0.78),rgba(8,10,14,0.95))]" />
|
||||
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-amber-100">
|
||||
{hasSavedGame ? 'CONTINUE' : 'CREATE'}
|
||||
</span>
|
||||
<div className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[11px] text-zinc-100">
|
||||
{hasSavedGame ? '继续冒险' : '创建世界'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-black text-white">
|
||||
{hasSavedGame ? snapshotWorldName : '把第一页变成你的作品页'}
|
||||
</div>
|
||||
<div className="mt-2 max-w-[28rem] text-sm leading-6 text-zinc-200/88">
|
||||
{hasSavedGame
|
||||
? `${snapshotCharacterName} 的上一次冒险已保存在云端,点这里直接回到故事现场。`
|
||||
: '从设定、角色到场景网络,一次生成一部可游玩的自定义 RPG,再决定是否发布到广场。'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{content}
|
||||
</div>
|
||||
|
||||
{platformError ? (
|
||||
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{platformError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="精选推荐"
|
||||
detail="为你挑选"
|
||||
actionLabel="看看最新"
|
||||
onAction={onRefresh}
|
||||
/>
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取精选作品..." />
|
||||
) : featuredShelf.length > 0 ? (
|
||||
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{featuredShelf.map((entry) => (
|
||||
<WorldCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:featured`}
|
||||
entry={entry}
|
||||
badge="推荐"
|
||||
metaLabel={describePlatformThemeLabel(entry.themeMode)}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="还没有公开作品,先创建你的第一个世界吧。" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader title="最新发布" detail="玩家广场" />
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取最新发布..." />
|
||||
) : latestEntries.length > 0 ? (
|
||||
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{latestEntries.map((entry) => (
|
||||
<WorldCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:latest`}
|
||||
entry={entry}
|
||||
badge={formatPlatformWorldTime(entry.publishedAt)}
|
||||
metaLabel={entry.authorDisplayName}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="公开广场暂时还没有新作品。" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader title="我的作品" detail="草稿与已发布" />
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenCreateWorld}
|
||||
className="pixel-nine-slice pixel-pressable relative min-h-[13rem] overflow-hidden text-left"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 18, paddingY: 16 })}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_36%),linear-gradient(180deg,rgba(8,10,14,0.2),rgba(8,10,14,0.92))]" />
|
||||
<div className="relative z-10 flex h-full flex-col">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-sky-300/20 bg-sky-500/10">
|
||||
<PixelIcon src={CHROME_ICONS.refreshOptions} className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<div className="text-2xl font-black text-white">
|
||||
创建新世界
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-300">
|
||||
新建一个只属于你的世界设定,生成后先进入草稿库,再决定要不要发布。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{myEntries.map((entry) => (
|
||||
<WorldCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:mine`}
|
||||
entry={entry}
|
||||
badge={entry.visibility === 'published' ? '已发布' : '草稿'}
|
||||
metaLabel={entry.visibility === 'published' ? formatPlatformWorldTime(entry.publishedAt) : '仅自己可见'}
|
||||
onClick={() => onOpenLibraryDetail(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!isLoadingPlatform && myEntries.length === 0 ? (
|
||||
<div className="mt-3">
|
||||
<EmptyShelf text="你还没有保存任何自定义世界,先创建一个草稿开始吧。" />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
<div
|
||||
className="mt-4 border-t border-white/5 pt-3"
|
||||
style={{ paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)' }}
|
||||
>
|
||||
<div className="grid h-14 grid-cols-3 gap-1 rounded-[1.2rem] bg-black/18 px-1 py-1">
|
||||
<PlatformTabButton
|
||||
active={activeTab === 'home'}
|
||||
label="首页"
|
||||
iconSrc={tabIcons.home}
|
||||
onClick={() => onTabChange('home')}
|
||||
/>
|
||||
<PlatformTabButton
|
||||
active={activeTab === 'create'}
|
||||
label="创作"
|
||||
iconSrc={tabIcons.create}
|
||||
onClick={() => onTabChange('create')}
|
||||
/>
|
||||
<PlatformTabButton
|
||||
active={activeTab === 'profile'}
|
||||
label="我的"
|
||||
iconSrc={tabIcons.profile}
|
||||
onClick={() => onTabChange('profile')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import {
|
||||
createCustomWorldAgentSession,
|
||||
getCustomWorldAgentSession,
|
||||
} from '../../services/aiService';
|
||||
import {
|
||||
listCustomWorldGallery,
|
||||
listCustomWorldLibrary,
|
||||
} from '../../services/storageService';
|
||||
import type { GameState } from '../../types';
|
||||
import {
|
||||
PreGameSelectionFlow,
|
||||
type SelectionStage,
|
||||
} from './PreGameSelectionFlow';
|
||||
|
||||
vi.mock('../../services/aiService', () => ({
|
||||
createCustomWorldAgentSession: vi.fn(),
|
||||
executeCustomWorldAgentAction: vi.fn(),
|
||||
generateCustomWorldProfile: vi.fn(),
|
||||
getCustomWorldAgentOperation: vi.fn(),
|
||||
getCustomWorldAgentSession: vi.fn(),
|
||||
sendCustomWorldAgentMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/storageService', () => ({
|
||||
getCustomWorldGalleryDetail: vi.fn(),
|
||||
listCustomWorldGallery: vi.fn(),
|
||||
listCustomWorldLibrary: vi.fn(),
|
||||
publishCustomWorldProfile: vi.fn(),
|
||||
unpublishCustomWorldProfile: vi.fn(),
|
||||
upsertCustomWorldProfile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../custom-world-agent/CustomWorldAgentWorkspace', () => ({
|
||||
CustomWorldAgentWorkspace: ({
|
||||
session,
|
||||
}: {
|
||||
session: CustomWorldAgentSessionSnapshot | null;
|
||||
}) => (
|
||||
<div className="agent-workspace-mock">
|
||||
Agent工作区:{session?.sessionId ?? 'missing-session'}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockSession: CustomWorldAgentSessionSnapshot = {
|
||||
sessionId: 'custom-world-agent-session-1',
|
||||
stage: 'clarifying',
|
||||
focusCardId: null,
|
||||
creatorIntent: {},
|
||||
creatorIntentReadiness: {
|
||||
isReady: false,
|
||||
completedKeys: ['world_hook'],
|
||||
missingKeys: [
|
||||
'player_premise',
|
||||
'theme_and_tone',
|
||||
'core_conflict',
|
||||
'relationship_seed',
|
||||
'iconic_element',
|
||||
],
|
||||
},
|
||||
anchorPack: {},
|
||||
lockState: {},
|
||||
draftProfile: null,
|
||||
messages: [
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'assistant',
|
||||
kind: 'summary',
|
||||
text: '先告诉我你想做一个怎样的 RPG 世界。',
|
||||
createdAt: '2026-04-14T12:00:00.000Z',
|
||||
relatedOperationId: null,
|
||||
},
|
||||
],
|
||||
draftCards: [],
|
||||
pendingClarifications: [],
|
||||
suggestedActions: [],
|
||||
recommendedReplies: [],
|
||||
qualityFindings: [],
|
||||
assetCoverage: {
|
||||
roleAssets: [],
|
||||
sceneAssets: [],
|
||||
allRoleAssetsReady: false,
|
||||
allSceneAssetsReady: false,
|
||||
},
|
||||
updatedAt: '2026-04-14T12:00:00.000Z',
|
||||
};
|
||||
|
||||
function TestWrapper() {
|
||||
const [selectionStage, setSelectionStage] =
|
||||
useState<SelectionStage>('platform');
|
||||
|
||||
return (
|
||||
<PreGameSelectionFlow
|
||||
selectionStage={selectionStage}
|
||||
setSelectionStage={setSelectionStage}
|
||||
gameState={{} as GameState}
|
||||
hasSavedGame={false}
|
||||
savedSnapshot={null}
|
||||
handleContinueGame={() => {}}
|
||||
handleStartNewGame={() => {}}
|
||||
handleCustomWorldSelect={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.history.replaceState(null, '', '/');
|
||||
window.sessionStorage.clear();
|
||||
vi.mocked(listCustomWorldLibrary).mockResolvedValue([]);
|
||||
vi.mocked(listCustomWorldGallery).mockResolvedValue([]);
|
||||
vi.mocked(createCustomWorldAgentSession).mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
vi.mocked(getCustomWorldAgentSession).mockResolvedValue(mockSession);
|
||||
});
|
||||
|
||||
test('create tab opens game type modal, keeps AIRP and visual novel locked, and enters agent workspace for RPG', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<TestWrapper />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '创作' }));
|
||||
await user.click(screen.getByRole('button', { name: /开启新的创作/u }));
|
||||
|
||||
expect(screen.getByText('选择创作类型')).toBeTruthy();
|
||||
|
||||
const airpButton = screen.getByRole('button', { name: /AIRP/u });
|
||||
const visualNovelButton = screen.getByRole('button', {
|
||||
name: /视觉小说/u,
|
||||
});
|
||||
|
||||
expect((airpButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((visualNovelButton as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createCustomWorldAgentSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText('Agent工作区:custom-world-agent-session-1'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
@@ -10,6 +10,12 @@ import {
|
||||
} from 'react';
|
||||
|
||||
import type { JsonObject } from '../../../packages/shared/src/contracts/common';
|
||||
import type {
|
||||
CustomWorldAgentActionRequest,
|
||||
CustomWorldAgentOperationRecord,
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
SendCustomWorldAgentMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldGenerationProgress,
|
||||
@@ -17,7 +23,24 @@ import type {
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { generateCustomWorldProfile } from '../../services/aiService';
|
||||
import {
|
||||
createCustomWorldAgentSession,
|
||||
executeCustomWorldAgentAction,
|
||||
generateCustomWorldProfile,
|
||||
getCustomWorldAgentOperation,
|
||||
getCustomWorldAgentSession,
|
||||
sendCustomWorldAgentMessage,
|
||||
} from '../../services/aiService';
|
||||
import {
|
||||
readCustomWorldAgentUiState,
|
||||
writeCustomWorldAgentUiState,
|
||||
} from '../../services/customWorldAgentUiState';
|
||||
import {
|
||||
buildAgentDraftFoundationGenerationProgress,
|
||||
buildAgentDraftFoundationSettingText,
|
||||
isDraftFoundationOperation,
|
||||
isDraftFoundationOperationRunning,
|
||||
} from '../../services/customWorldAgentGenerationProgress';
|
||||
import {
|
||||
buildCustomWorldCreatorIntentDisplayText,
|
||||
buildCustomWorldCreatorIntentGenerationText,
|
||||
@@ -37,7 +60,8 @@ import {
|
||||
type CustomWorldProfile,
|
||||
type GameState,
|
||||
} from '../../types';
|
||||
import { PlatformHomeView } from './PlatformHomeView';
|
||||
import { PlatformCreationTypeModal } from './PlatformCreationTypeModal';
|
||||
import { type PlatformHomeTab,PlatformHomeView } from './PlatformHomeView';
|
||||
import { PlatformWorldDetailView } from './PlatformWorldDetailView';
|
||||
|
||||
const CustomWorldGenerationView = lazy(async () => {
|
||||
@@ -61,12 +85,27 @@ const CustomWorldCreatorModal = lazy(async () => {
|
||||
};
|
||||
});
|
||||
|
||||
const CustomWorldAgentWorkspace = lazy(async () => {
|
||||
const module = await import(
|
||||
'../custom-world-agent/CustomWorldAgentWorkspace'
|
||||
);
|
||||
return {
|
||||
default: module.CustomWorldAgentWorkspace,
|
||||
};
|
||||
});
|
||||
|
||||
export type SelectionStage =
|
||||
| 'platform'
|
||||
| 'detail'
|
||||
| 'agent-workspace'
|
||||
| 'custom-world-generating'
|
||||
| 'custom-world-result';
|
||||
|
||||
type CustomWorldGenerationViewSource =
|
||||
| 'classic'
|
||||
| 'agent-draft-foundation'
|
||||
| null;
|
||||
|
||||
type PreGameSelectionFlowProps = {
|
||||
selectionStage: SelectionStage;
|
||||
setSelectionStage: (stage: SelectionStage) => void;
|
||||
@@ -151,6 +190,22 @@ function resolveErrorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
function createFailedAgentOperation(params: {
|
||||
type: CustomWorldAgentOperationRecord['type'];
|
||||
phaseLabel: string;
|
||||
error: string;
|
||||
}): CustomWorldAgentOperationRecord {
|
||||
return {
|
||||
operationId: `local-failed-${Date.now()}`,
|
||||
type: params.type,
|
||||
status: 'failed',
|
||||
phaseLabel: params.phaseLabel,
|
||||
phaseDetail: params.error,
|
||||
progress: 100,
|
||||
error: params.error,
|
||||
};
|
||||
}
|
||||
|
||||
function LazyPanelFallback({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 items-center justify-center">
|
||||
@@ -170,6 +225,8 @@ export function PreGameSelectionFlow({
|
||||
handleStartNewGame,
|
||||
handleCustomWorldSelect,
|
||||
}: PreGameSelectionFlowProps) {
|
||||
const initialAgentUiStateRef = useRef(readCustomWorldAgentUiState());
|
||||
const hasAppliedInitialAgentWorkspaceRef = useRef(false);
|
||||
const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] =
|
||||
useState<CustomWorldProfile | null>(null);
|
||||
const [savedCustomWorldEntries, setSavedCustomWorldEntries] = useState<
|
||||
@@ -178,8 +235,25 @@ export function PreGameSelectionFlow({
|
||||
const [publishedGalleryEntries, setPublishedGalleryEntries] = useState<
|
||||
CustomWorldGalleryCard[]
|
||||
>([]);
|
||||
const [platformTab, setPlatformTab] = useState<PlatformHomeTab>('home');
|
||||
const [selectedDetailEntry, setSelectedDetailEntry] =
|
||||
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
|
||||
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
|
||||
const [creationTypeError, setCreationTypeError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isCreatingAgentSession, setIsCreatingAgentSession] = useState(false);
|
||||
const [activeAgentSessionId, setActiveAgentSessionId] = useState<
|
||||
string | null
|
||||
>(() => initialAgentUiStateRef.current.activeSessionId ?? null);
|
||||
const [activeAgentOperationId, setActiveAgentOperationId] = useState<
|
||||
string | null
|
||||
>(() => initialAgentUiStateRef.current.activeOperationId ?? null);
|
||||
const [agentSession, setAgentSession] =
|
||||
useState<CustomWorldAgentSessionSnapshot | null>(null);
|
||||
const [agentOperation, setAgentOperation] =
|
||||
useState<CustomWorldAgentOperationRecord | null>(null);
|
||||
const [isLoadingAgentSession, setIsLoadingAgentSession] = useState(false);
|
||||
const [showCustomWorldModal, setShowCustomWorldModal] = useState(false);
|
||||
const [customWorldCreatorIntent, setCustomWorldCreatorIntent] =
|
||||
useState<CustomWorldCreatorIntent>(() =>
|
||||
@@ -196,6 +270,10 @@ export function PreGameSelectionFlow({
|
||||
const [isMutatingDetail, setIsMutatingDetail] = useState(false);
|
||||
const [customWorldProgress, setCustomWorldProgress] =
|
||||
useState<CustomWorldGenerationProgress | null>(null);
|
||||
const [customWorldGenerationViewSource, setCustomWorldGenerationViewSource] =
|
||||
useState<CustomWorldGenerationViewSource>(null);
|
||||
const [agentDraftGenerationStartedAt, setAgentDraftGenerationStartedAt] =
|
||||
useState<number | null>(null);
|
||||
const customWorldAbortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const previewCustomWorldCharacters = useMemo(
|
||||
@@ -211,6 +289,24 @@ export function PreGameSelectionFlow({
|
||||
[publishedGalleryEntries],
|
||||
);
|
||||
|
||||
const persistAgentUiState = useCallback(
|
||||
(nextSessionId: string | null, nextOperationId: string | null) => {
|
||||
setActiveAgentSessionId(nextSessionId);
|
||||
setActiveAgentOperationId(nextOperationId);
|
||||
writeCustomWorldAgentUiState({
|
||||
activeSessionId: nextSessionId,
|
||||
activeOperationId: nextOperationId,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const syncAgentSessionSnapshot = useCallback(async (sessionId: string) => {
|
||||
const nextSession = await getCustomWorldAgentSession(sessionId);
|
||||
setAgentSession(nextSession);
|
||||
return nextSession;
|
||||
}, []);
|
||||
|
||||
const refreshPlatformData = useCallback(async () => {
|
||||
setIsLoadingPlatform(true);
|
||||
setPlatformError(null);
|
||||
@@ -239,6 +335,18 @@ export function PreGameSelectionFlow({
|
||||
}
|
||||
}, [selectedDetailEntry]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasAppliedInitialAgentWorkspaceRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasAppliedInitialAgentWorkspaceRef.current = true;
|
||||
if (initialAgentUiStateRef.current.activeSessionId) {
|
||||
setPlatformTab('create');
|
||||
setSelectionStage('agent-workspace');
|
||||
}
|
||||
}, [setSelectionStage]);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
|
||||
@@ -293,6 +401,117 @@ export function PreGameSelectionFlow({
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeAgentSessionId) {
|
||||
setAgentSession(null);
|
||||
setIsLoadingAgentSession(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingAgentSession(true);
|
||||
|
||||
void syncAgentSessionSnapshot(activeAgentSessionId)
|
||||
.then(() => {
|
||||
if (!cancelled) {
|
||||
setCreationTypeError(null);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCreationTypeError(
|
||||
resolveErrorMessage(error, '读取 Agent 共创工作区失败。'),
|
||||
);
|
||||
setAgentSession(null);
|
||||
setAgentOperation(null);
|
||||
persistAgentUiState(null, null);
|
||||
setPlatformTab('create');
|
||||
setSelectionStage('platform');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoadingAgentSession(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
activeAgentSessionId,
|
||||
persistAgentUiState,
|
||||
setSelectionStage,
|
||||
syncAgentSessionSnapshot,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeAgentSessionId || !activeAgentOperationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const pollOperation = async () => {
|
||||
try {
|
||||
const nextOperation = await getCustomWorldAgentOperation(
|
||||
activeAgentSessionId,
|
||||
activeAgentOperationId,
|
||||
);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAgentOperation(nextOperation);
|
||||
|
||||
if (
|
||||
nextOperation.status === 'completed' ||
|
||||
nextOperation.status === 'failed'
|
||||
) {
|
||||
persistAgentUiState(activeAgentSessionId, null);
|
||||
await syncAgentSessionSnapshot(activeAgentSessionId).catch(
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage = resolveErrorMessage(
|
||||
error,
|
||||
'读取共创操作状态失败。',
|
||||
);
|
||||
setAgentOperation(
|
||||
createFailedAgentOperation({
|
||||
type: 'process_message',
|
||||
phaseLabel: '读取操作状态失败',
|
||||
error: errorMessage,
|
||||
}),
|
||||
);
|
||||
persistAgentUiState(activeAgentSessionId, null);
|
||||
}
|
||||
};
|
||||
|
||||
void pollOperation();
|
||||
const intervalId = window.setInterval(() => {
|
||||
void pollOperation();
|
||||
}, 1200);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [
|
||||
activeAgentOperationId,
|
||||
activeAgentSessionId,
|
||||
persistAgentUiState,
|
||||
syncAgentSessionSnapshot,
|
||||
]);
|
||||
|
||||
const customWorldSettingPreview = useMemo(() => {
|
||||
if (customWorldCreatorIntent.sourceMode === 'freeform') {
|
||||
return customWorldCreatorIntent.rawSettingText.trim();
|
||||
@@ -308,10 +527,40 @@ export function PreGameSelectionFlow({
|
||||
return customWorldCreatorIntent.rawSettingText.trim();
|
||||
}, [customWorldCreatorIntent]);
|
||||
|
||||
const agentDraftSettingPreview = useMemo(
|
||||
() => buildAgentDraftFoundationSettingText(agentSession),
|
||||
[agentSession],
|
||||
);
|
||||
|
||||
const agentDraftGenerationProgress = useMemo(
|
||||
() =>
|
||||
buildAgentDraftFoundationGenerationProgress(
|
||||
agentOperation,
|
||||
agentDraftGenerationStartedAt,
|
||||
),
|
||||
[agentDraftGenerationStartedAt, agentOperation],
|
||||
);
|
||||
|
||||
const isAgentDraftGenerationView =
|
||||
customWorldGenerationViewSource === 'agent-draft-foundation';
|
||||
const activeGenerationProgress = isAgentDraftGenerationView
|
||||
? agentDraftGenerationProgress
|
||||
: customWorldProgress;
|
||||
const isActiveGenerationRunning = isAgentDraftGenerationView
|
||||
? isDraftFoundationOperationRunning(agentOperation)
|
||||
: isGeneratingCustomWorld;
|
||||
const activeGenerationError =
|
||||
isAgentDraftGenerationView &&
|
||||
isDraftFoundationOperation(agentOperation) &&
|
||||
agentOperation.status === 'failed'
|
||||
? agentOperation.error || agentOperation.phaseDetail
|
||||
: customWorldError;
|
||||
|
||||
const leaveCustomWorldResult = () => {
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldProgress(null);
|
||||
setCustomWorldGenerationViewSource(null);
|
||||
setSelectionStage(selectedDetailEntry ? 'detail' : 'platform');
|
||||
};
|
||||
|
||||
@@ -322,6 +571,101 @@ export function PreGameSelectionFlow({
|
||||
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldProgress(null);
|
||||
setCustomWorldGenerationViewSource(null);
|
||||
setSelectionStage('platform');
|
||||
};
|
||||
|
||||
const openCreationTypePicker = () => {
|
||||
if (isCreatingAgentSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCreationTypeError(null);
|
||||
setShowCreationTypeModal(true);
|
||||
};
|
||||
|
||||
const openRpgAgentWorkspace = async () => {
|
||||
if (isCreatingAgentSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreatingAgentSession(true);
|
||||
setCreationTypeError(null);
|
||||
|
||||
try {
|
||||
const { session } = await createCustomWorldAgentSession({});
|
||||
setAgentSession(session);
|
||||
setAgentOperation(null);
|
||||
persistAgentUiState(session.sessionId, null);
|
||||
setShowCreationTypeModal(false);
|
||||
setPlatformTab('create');
|
||||
setSelectionStage('agent-workspace');
|
||||
} catch (error) {
|
||||
setCreationTypeError(resolveErrorMessage(error, '开启共创工作台失败。'));
|
||||
} finally {
|
||||
setIsCreatingAgentSession(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitAgentMessage = async (
|
||||
payload: SendCustomWorldAgentMessageRequest,
|
||||
) => {
|
||||
if (!activeAgentSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { operation } = await sendCustomWorldAgentMessage(
|
||||
activeAgentSessionId,
|
||||
payload,
|
||||
);
|
||||
setAgentOperation(operation);
|
||||
persistAgentUiState(activeAgentSessionId, operation.operationId);
|
||||
} catch (error) {
|
||||
const errorMessage = resolveErrorMessage(error, '发送共创消息失败。');
|
||||
setAgentOperation(
|
||||
createFailedAgentOperation({
|
||||
type: 'process_message',
|
||||
phaseLabel: '发送消息失败',
|
||||
error: errorMessage,
|
||||
}),
|
||||
);
|
||||
persistAgentUiState(activeAgentSessionId, null);
|
||||
}
|
||||
};
|
||||
|
||||
const executeAgentAction = async (payload: CustomWorldAgentActionRequest) => {
|
||||
if (!activeAgentSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { operation } = await executeCustomWorldAgentAction(
|
||||
activeAgentSessionId,
|
||||
payload,
|
||||
);
|
||||
setAgentOperation(operation);
|
||||
persistAgentUiState(activeAgentSessionId, operation.operationId);
|
||||
} catch (error) {
|
||||
const errorMessage = resolveErrorMessage(error, '执行共创操作失败。');
|
||||
setAgentOperation(
|
||||
createFailedAgentOperation({
|
||||
type:
|
||||
payload.action === 'draft_foundation'
|
||||
? 'draft_foundation'
|
||||
: payload.action,
|
||||
phaseLabel: '执行操作失败',
|
||||
error: errorMessage,
|
||||
}),
|
||||
);
|
||||
persistAgentUiState(activeAgentSessionId, null);
|
||||
}
|
||||
};
|
||||
|
||||
const leaveAgentWorkspace = () => {
|
||||
setPlatformTab('create');
|
||||
setAgentOperation(null);
|
||||
persistAgentUiState(activeAgentSessionId, null);
|
||||
setSelectionStage('platform');
|
||||
};
|
||||
|
||||
@@ -340,7 +684,9 @@ export function PreGameSelectionFlow({
|
||||
setDetailError(null);
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldProgress(null);
|
||||
setCustomWorldCreatorIntent(createEmptyCustomWorldCreatorIntent('freeform'));
|
||||
setCustomWorldCreatorIntent(
|
||||
createEmptyCustomWorldCreatorIntent('freeform'),
|
||||
);
|
||||
setCustomWorldGenerationMode('fast');
|
||||
setShowCustomWorldModal(true);
|
||||
};
|
||||
@@ -400,7 +746,9 @@ export function PreGameSelectionFlow({
|
||||
}
|
||||
|
||||
try {
|
||||
const mutation = await upsertCustomWorldProfile(generatedCustomWorldProfile);
|
||||
const mutation = await upsertCustomWorldProfile(
|
||||
generatedCustomWorldProfile,
|
||||
);
|
||||
setSavedCustomWorldEntries(mutation.entries);
|
||||
setSelectedDetailEntry(mutation.entry);
|
||||
await refreshPlatformData();
|
||||
@@ -684,18 +1032,20 @@ export function PreGameSelectionFlow({
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<PlatformHomeView
|
||||
activeTab={platformTab}
|
||||
onTabChange={setPlatformTab}
|
||||
hasSavedGame={hasSavedGame}
|
||||
savedSnapshot={savedSnapshot}
|
||||
featuredEntries={featuredGalleryEntries}
|
||||
latestEntries={publishedGalleryEntries}
|
||||
myEntries={savedCustomWorldEntries}
|
||||
isLoadingPlatform={isLoadingPlatform}
|
||||
platformError={isLoadingPlatform ? null : platformError}
|
||||
platformError={
|
||||
isLoadingPlatform ? null : (platformError ?? creationTypeError)
|
||||
}
|
||||
onContinueGame={handleContinueGame}
|
||||
onRefresh={() => {
|
||||
void refreshPlatformData();
|
||||
}}
|
||||
onOpenCreateWorld={openCustomWorldCreator}
|
||||
onOpenCreateTypePicker={openCreationTypePicker}
|
||||
onOpenGalleryDetail={(entry) => {
|
||||
void openGalleryDetail(entry);
|
||||
}}
|
||||
@@ -750,6 +1100,50 @@ export function PreGameSelectionFlow({
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'agent-workspace' && (
|
||||
<motion.div
|
||||
key="agent-workspace"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<LazyPanelFallback label="正在加载 Agent 共创工作区..." />
|
||||
}
|
||||
>
|
||||
{agentSession ? (
|
||||
<CustomWorldAgentWorkspace
|
||||
session={agentSession}
|
||||
activeOperation={agentOperation}
|
||||
onBack={leaveAgentWorkspace}
|
||||
onRefresh={() => {
|
||||
if (!activeAgentSessionId) {
|
||||
return;
|
||||
}
|
||||
void syncAgentSessionSnapshot(activeAgentSessionId);
|
||||
}}
|
||||
onSubmitMessage={(payload) => {
|
||||
void submitAgentMessage(payload);
|
||||
}}
|
||||
onExecuteAction={(payload) => {
|
||||
void executeAgentAction(payload);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/30 px-5 py-4 text-sm text-zinc-300">
|
||||
{isLoadingAgentSession
|
||||
? '正在准备 Agent 共创工作区...'
|
||||
: creationTypeError || '正在恢复创作工作区...'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'custom-world-generating' && (
|
||||
<motion.div
|
||||
key="custom-world-generating"
|
||||
@@ -759,9 +1153,7 @@ export function PreGameSelectionFlow({
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<LazyPanelFallback label="正在加载世界生成面板..." />
|
||||
}
|
||||
fallback={<LazyPanelFallback label="正在加载世界生成面板..." />}
|
||||
>
|
||||
<CustomWorldGenerationView
|
||||
settingText={customWorldSettingPreview}
|
||||
@@ -816,6 +1208,21 @@ export function PreGameSelectionFlow({
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<PlatformCreationTypeModal
|
||||
isOpen={showCreationTypeModal}
|
||||
isBusy={isCreatingAgentSession}
|
||||
error={creationTypeError}
|
||||
onClose={() => {
|
||||
if (isCreatingAgentSession) {
|
||||
return;
|
||||
}
|
||||
setShowCreationTypeModal(false);
|
||||
}}
|
||||
onSelectRpg={() => {
|
||||
void openRpgAgentWorkspace();
|
||||
}}
|
||||
/>
|
||||
|
||||
{showCustomWorldModal ? (
|
||||
<Suspense fallback={null}>
|
||||
<CustomWorldCreatorModal
|
||||
|
||||
@@ -151,9 +151,7 @@ export function useGameShellRuntimeViewModel(params: Pick<
|
||||
Boolean(gameState.worldType) &&
|
||||
!gameState.playerCharacter;
|
||||
const shouldHideStoryOptions = sceneTransitionPhase !== 'idle';
|
||||
const hideSelectionHero =
|
||||
gameState.currentScene === 'Selection' &&
|
||||
shellViewModel.selectionStage !== 'platform';
|
||||
const hideSelectionHero = gameState.currentScene === 'Selection';
|
||||
|
||||
const dialogueIndicator = useMemo(
|
||||
() =>
|
||||
|
||||
131
src/services/customWorldAgentGenerationProgress.test.ts
Normal file
131
src/services/customWorldAgentGenerationProgress.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type {
|
||||
CustomWorldAgentOperationRecord,
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
} from '../../packages/shared/src/contracts/customWorldAgent';
|
||||
import {
|
||||
buildAgentDraftFoundationGenerationProgress,
|
||||
buildAgentDraftFoundationSettingText,
|
||||
isDraftFoundationOperationRunning,
|
||||
} from './customWorldAgentGenerationProgress';
|
||||
|
||||
const baseOperation: CustomWorldAgentOperationRecord = {
|
||||
operationId: 'operation-1',
|
||||
type: 'draft_foundation',
|
||||
status: 'running',
|
||||
phaseLabel: '生成世界底稿',
|
||||
phaseDetail: '正在根据已确认锚点编译第一版世界结构。',
|
||||
progress: 38,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const baseSession: CustomWorldAgentSessionSnapshot = {
|
||||
sessionId: 'session-1',
|
||||
stage: 'foundation_review',
|
||||
focusCardId: null,
|
||||
creatorIntent: {
|
||||
sourceMode: 'card',
|
||||
worldHook: '海雾、旧灯塔和失控航路交织的边缘群岛',
|
||||
themeKeywords: ['海雾', '灯塔', '旧航路'],
|
||||
toneDirectives: ['压抑', '悬疑'],
|
||||
playerPremise: '玩家刚回到群岛,准备调查父亲沉船的真相。',
|
||||
openingSituation: '首夜就有陌生船只在禁航区点灯。',
|
||||
coreConflicts: ['航运公会与守灯会争夺航路控制权'],
|
||||
keyFactions: [],
|
||||
keyCharacters: [],
|
||||
keyLandmarks: [],
|
||||
iconicElements: ['会移动的海雾'],
|
||||
forbiddenDirectives: [],
|
||||
rawSettingText: '',
|
||||
},
|
||||
creatorIntentReadiness: {
|
||||
isReady: true,
|
||||
completedKeys: [],
|
||||
missingKeys: [],
|
||||
},
|
||||
anchorPack: null,
|
||||
lockState: null,
|
||||
draftProfile: null,
|
||||
messages: [
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'user',
|
||||
kind: 'chat',
|
||||
text: '我想做一个被海雾吞没的旧航路世界。',
|
||||
createdAt: '2026-04-14T10:00:00.000Z',
|
||||
relatedOperationId: null,
|
||||
},
|
||||
],
|
||||
draftCards: [],
|
||||
pendingClarifications: [],
|
||||
suggestedActions: [],
|
||||
recommendedReplies: [],
|
||||
qualityFindings: [],
|
||||
assetCoverage: {
|
||||
roleAssets: [],
|
||||
sceneAssets: [],
|
||||
allRoleAssetsReady: false,
|
||||
allSceneAssetsReady: false,
|
||||
},
|
||||
updatedAt: '2026-04-14T10:00:00.000Z',
|
||||
};
|
||||
|
||||
test('maps running draft_foundation operation to legacy generation progress', () => {
|
||||
const progress = buildAgentDraftFoundationGenerationProgress(
|
||||
baseOperation,
|
||||
1_000,
|
||||
5_000,
|
||||
);
|
||||
|
||||
expect(progress).not.toBeNull();
|
||||
expect(progress?.phaseId).toBe('foundation');
|
||||
expect(progress?.batchLabel).toBe('生成世界底稿');
|
||||
expect(progress?.overallProgress).toBe(38);
|
||||
expect(progress?.elapsedMs).toBe(4_000);
|
||||
expect(progress?.estimatedRemainingMs).toBeGreaterThan(0);
|
||||
expect(progress?.steps.map((step) => step.status)).toEqual([
|
||||
'completed',
|
||||
'active',
|
||||
'pending',
|
||||
'pending',
|
||||
]);
|
||||
expect(isDraftFoundationOperationRunning(baseOperation)).toBe(true);
|
||||
});
|
||||
|
||||
test('marks all legacy progress steps complete when draft foundation finishes', () => {
|
||||
const progress = buildAgentDraftFoundationGenerationProgress(
|
||||
{
|
||||
...baseOperation,
|
||||
status: 'completed',
|
||||
phaseLabel: '世界底稿已生成',
|
||||
phaseDetail: '第一版世界底稿和 6 张草稿卡已经整理完成。',
|
||||
progress: 100,
|
||||
},
|
||||
1_000,
|
||||
5_000,
|
||||
);
|
||||
|
||||
expect(progress?.phaseId).toBe('workspace');
|
||||
expect(progress?.estimatedRemainingMs).toBe(0);
|
||||
expect(progress?.steps.every((step) => step.status === 'completed')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('builds readable draft setting text from creator intent first', () => {
|
||||
const settingText = buildAgentDraftFoundationSettingText(baseSession);
|
||||
|
||||
expect(settingText).toContain('世界核心');
|
||||
expect(settingText).toContain('玩家开局');
|
||||
expect(settingText).toContain('标志元素');
|
||||
});
|
||||
|
||||
test('falls back to latest user message when creator intent is unavailable', () => {
|
||||
const settingText = buildAgentDraftFoundationSettingText({
|
||||
...baseSession,
|
||||
creatorIntent: null,
|
||||
});
|
||||
|
||||
expect(settingText).toBe('我想做一个被海雾吞没的旧航路世界。');
|
||||
});
|
||||
210
src/services/customWorldAgentGenerationProgress.ts
Normal file
210
src/services/customWorldAgentGenerationProgress.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import type {
|
||||
CustomWorldAgentOperationRecord,
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
} from '../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type {
|
||||
CustomWorldGenerationProgress,
|
||||
CustomWorldGenerationStep,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
buildCustomWorldCreatorIntentDisplayText,
|
||||
buildCustomWorldCreatorIntentGenerationText,
|
||||
normalizeCustomWorldCreatorIntent,
|
||||
} from './customWorldCreatorIntent';
|
||||
|
||||
const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
|
||||
{
|
||||
id: 'queue',
|
||||
label: '接收生成请求',
|
||||
detail: '正在锁定当前已确认的世界锚点与草稿范围。',
|
||||
},
|
||||
{
|
||||
id: 'foundation',
|
||||
label: '生成世界底稿',
|
||||
detail: '正在根据世界核心、关系种子与冲突线编排第一版世界结构。',
|
||||
},
|
||||
{
|
||||
id: 'cards',
|
||||
label: '编译草稿卡',
|
||||
detail: '正在整理世界卡、角色卡与地点卡的摘要和详情。',
|
||||
},
|
||||
{
|
||||
id: 'workspace',
|
||||
label: '准备精修工作区',
|
||||
detail: '正在写回草稿数据,并切回可继续精修的工作区。',
|
||||
},
|
||||
] as const satisfies ReadonlyArray<{
|
||||
id: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
}>;
|
||||
|
||||
function clampProgress(progress: number | null | undefined) {
|
||||
if (typeof progress !== 'number' || Number.isNaN(progress)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(100, Math.round(progress)));
|
||||
}
|
||||
|
||||
function resolveAgentDraftFoundationStepIndex(
|
||||
operation: CustomWorldAgentOperationRecord,
|
||||
) {
|
||||
const progress = clampProgress(operation.progress);
|
||||
const phaseLabel = operation.phaseLabel.trim();
|
||||
|
||||
if (
|
||||
operation.status === 'completed' ||
|
||||
phaseLabel.includes('世界底稿已生成') ||
|
||||
progress >= 90
|
||||
) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
if (phaseLabel.includes('编译草稿卡') || progress >= 60) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (phaseLabel.includes('生成世界底稿') || progress >= 25) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function buildAgentDraftFoundationSteps(
|
||||
operation: CustomWorldAgentOperationRecord,
|
||||
activeStepIndex: number,
|
||||
) {
|
||||
return AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.map((step, index) => {
|
||||
const isCompleted =
|
||||
operation.status === 'completed' || index < activeStepIndex;
|
||||
const isActive = !isCompleted && index === activeStepIndex;
|
||||
|
||||
return {
|
||||
id: step.id,
|
||||
label: step.label,
|
||||
detail: step.detail,
|
||||
completed: isCompleted ? 1 : 0,
|
||||
total: 1,
|
||||
status: isCompleted
|
||||
? 'completed'
|
||||
: isActive
|
||||
? 'active'
|
||||
: 'pending',
|
||||
} satisfies CustomWorldGenerationStep;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveEstimatedRemainingMs(
|
||||
progress: number,
|
||||
startedAtMs: number | null,
|
||||
nowMs: number,
|
||||
status: CustomWorldAgentOperationRecord['status'],
|
||||
) {
|
||||
if (status === 'completed') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!startedAtMs || progress <= 0 || progress >= 100) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const elapsedMs = Math.max(0, nowMs - startedAtMs);
|
||||
const progressFraction = progress / 100;
|
||||
|
||||
return Math.max(
|
||||
0,
|
||||
Math.round(elapsedMs / progressFraction - elapsedMs),
|
||||
);
|
||||
}
|
||||
|
||||
export function isDraftFoundationOperation(
|
||||
operation: CustomWorldAgentOperationRecord | null | undefined,
|
||||
): operation is CustomWorldAgentOperationRecord {
|
||||
return Boolean(operation && operation.type === 'draft_foundation');
|
||||
}
|
||||
|
||||
export function isDraftFoundationOperationRunning(
|
||||
operation: CustomWorldAgentOperationRecord | null | undefined,
|
||||
) {
|
||||
return (
|
||||
isDraftFoundationOperation(operation) &&
|
||||
(operation.status === 'queued' || operation.status === 'running')
|
||||
);
|
||||
}
|
||||
|
||||
export function buildAgentDraftFoundationGenerationProgress(
|
||||
operation: CustomWorldAgentOperationRecord | null | undefined,
|
||||
startedAtMs: number | null,
|
||||
nowMs = Date.now(),
|
||||
): CustomWorldGenerationProgress | null {
|
||||
if (!isDraftFoundationOperation(operation)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const overallProgress = clampProgress(operation.progress);
|
||||
const activeStepIndex = resolveAgentDraftFoundationStepIndex(operation);
|
||||
const elapsedMs = startedAtMs ? Math.max(0, nowMs - startedAtMs) : 0;
|
||||
const estimatedRemainingMs = resolveEstimatedRemainingMs(
|
||||
overallProgress,
|
||||
startedAtMs,
|
||||
nowMs,
|
||||
operation.status,
|
||||
);
|
||||
const activeStep =
|
||||
AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[activeStepIndex] ??
|
||||
AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[0];
|
||||
|
||||
return {
|
||||
phaseId: activeStep.id,
|
||||
phaseLabel: operation.phaseLabel || activeStep.label,
|
||||
phaseDetail: operation.phaseDetail || activeStep.detail,
|
||||
batchLabel: activeStep.label,
|
||||
overallProgress,
|
||||
completedWeight: overallProgress,
|
||||
totalWeight: 100,
|
||||
elapsedMs,
|
||||
estimatedRemainingMs,
|
||||
activeStepIndex,
|
||||
steps: buildAgentDraftFoundationSteps(operation, activeStepIndex),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAgentDraftFoundationSettingText(
|
||||
session: CustomWorldAgentSessionSnapshot | null | undefined,
|
||||
) {
|
||||
if (!session) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const creatorIntent = normalizeCustomWorldCreatorIntent(
|
||||
session.creatorIntent,
|
||||
'freeform',
|
||||
);
|
||||
|
||||
if (creatorIntent) {
|
||||
const displayText =
|
||||
buildCustomWorldCreatorIntentDisplayText(creatorIntent).trim();
|
||||
const generationText =
|
||||
buildCustomWorldCreatorIntentGenerationText(creatorIntent).trim();
|
||||
|
||||
if (displayText) {
|
||||
return displayText;
|
||||
}
|
||||
|
||||
if (generationText) {
|
||||
return generationText;
|
||||
}
|
||||
|
||||
if (creatorIntent.rawSettingText.trim()) {
|
||||
return creatorIntent.rawSettingText.trim();
|
||||
}
|
||||
}
|
||||
|
||||
const latestUserMessage = [...session.messages]
|
||||
.reverse()
|
||||
.find((message) => message.role === 'user' && message.text.trim());
|
||||
|
||||
return latestUserMessage?.text.trim() ?? '正在整理当前共创设定。';
|
||||
}
|
||||
Reference in New Issue
Block a user