Add VectorEngine image generation config and raise request timeouts (env + scripts) from 180000 to 1000000ms. Introduce a reusable CreativeImageInputPanel component with tests and wire up mobile keyboard-focus helpers; update generation views and related tests (CustomWorldGenerationView, BarkBattle editor, Match3D, Puzzle flows). Improve API error handling / VectorEngine request guidance (packages/shared http.ts and docs), and apply multiple backend/frontend fixes for puzzle/match3d/prompt handling. Also include extensive docs and decision-log updates describing UI/UX decisions and verification steps.
427 lines
16 KiB
TypeScript
427 lines
16 KiB
TypeScript
import { motion } from 'motion/react';
|
|
import type { CSSProperties } from 'react';
|
|
import { useEffect, useState } from 'react';
|
|
|
|
import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime';
|
|
import type { CustomWorldStructuredAnchorEntry } from '../services/customWorldAgentGenerationProgress';
|
|
|
|
interface CustomWorldGenerationViewProps {
|
|
settingText: string;
|
|
anchorEntries?: CustomWorldStructuredAnchorEntry[];
|
|
progress: CustomWorldGenerationProgress | null;
|
|
isGenerating: boolean;
|
|
error: string | null;
|
|
onBack: () => void;
|
|
onEditSetting: () => void;
|
|
onRetry: () => void;
|
|
onInterrupt?: () => void;
|
|
backLabel?: string;
|
|
settingActionLabel?: string | null;
|
|
retryLabel?: string;
|
|
interruptLabel?: string;
|
|
settingTitle?: string;
|
|
settingDescription?: string | null;
|
|
progressTitle?: string;
|
|
activeBadgeLabel?: string;
|
|
pausedBadgeLabel?: string;
|
|
idleBadgeLabel?: string;
|
|
structuredEmptyText?: string;
|
|
hideBatchModule?: boolean;
|
|
}
|
|
|
|
function formatDuration(ms: number) {
|
|
const safeMs = Math.max(0, Math.round(ms));
|
|
const totalSeconds = Math.ceil(safeMs / 1000);
|
|
const minutes = Math.floor(totalSeconds / 60);
|
|
const seconds = totalSeconds % 60;
|
|
|
|
if (minutes <= 0) {
|
|
return `${Math.max(1, seconds)} 秒`;
|
|
}
|
|
|
|
if (seconds === 0) {
|
|
return `${minutes} 分钟`;
|
|
}
|
|
|
|
return `${minutes} 分 ${seconds} 秒`;
|
|
}
|
|
|
|
function getProgressPercentage(progress: CustomWorldGenerationProgress | null) {
|
|
return Math.max(0, Math.min(100, progress?.overallProgress ?? 0));
|
|
}
|
|
|
|
function getStepProgressPercentage(step: {
|
|
completed: number;
|
|
total: number;
|
|
status: string;
|
|
}) {
|
|
if (step.status === 'completed') {
|
|
return 100;
|
|
}
|
|
|
|
if (step.total <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
return Math.max(
|
|
0,
|
|
Math.min(100, Math.round((step.completed / step.total) * 100)),
|
|
);
|
|
}
|
|
|
|
function getStepStatusLabel(step: { status: string }) {
|
|
if (step.status === 'completed') {
|
|
return '完成';
|
|
}
|
|
|
|
if (step.status === 'active') {
|
|
return '进行中';
|
|
}
|
|
|
|
return '待处理';
|
|
}
|
|
|
|
function buildFallbackRenderKey(
|
|
value: string | null | undefined,
|
|
fallback: string,
|
|
) {
|
|
const normalizedValue = value?.trim();
|
|
return normalizedValue ? normalizedValue : fallback;
|
|
}
|
|
|
|
function useIsMobileGenerationLayout() {
|
|
const [isMobile, setIsMobile] = useState(() => {
|
|
if (
|
|
typeof window === 'undefined' ||
|
|
typeof window.matchMedia !== 'function'
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
return window.matchMedia('(max-width: 639px)').matches;
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (
|
|
typeof window === 'undefined' ||
|
|
typeof window.matchMedia !== 'function'
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
const mediaQuery = window.matchMedia('(max-width: 639px)');
|
|
const syncMobileLayout = () => {
|
|
setIsMobile(mediaQuery.matches);
|
|
};
|
|
|
|
syncMobileLayout();
|
|
|
|
if (typeof mediaQuery.addEventListener === 'function') {
|
|
mediaQuery.addEventListener('change', syncMobileLayout);
|
|
return () => {
|
|
mediaQuery.removeEventListener('change', syncMobileLayout);
|
|
};
|
|
}
|
|
|
|
mediaQuery.addListener(syncMobileLayout);
|
|
return () => {
|
|
mediaQuery.removeListener(syncMobileLayout);
|
|
};
|
|
}, []);
|
|
|
|
return isMobile;
|
|
}
|
|
|
|
export function CustomWorldGenerationView({
|
|
settingText,
|
|
anchorEntries = [],
|
|
progress,
|
|
isGenerating,
|
|
error,
|
|
onBack,
|
|
onEditSetting,
|
|
onRetry,
|
|
onInterrupt,
|
|
backLabel = '返回',
|
|
settingActionLabel = '修改设定',
|
|
retryLabel = '重新开始生成',
|
|
interruptLabel = '中断世界生成',
|
|
settingTitle = '玩家设定',
|
|
settingDescription = '这段文本会直接驱动本轮世界框架、角色与场景生成。',
|
|
progressTitle = '生成进度',
|
|
activeBadgeLabel = '世界建设中',
|
|
pausedBadgeLabel = '生成已暂停',
|
|
idleBadgeLabel = '等待操作',
|
|
structuredEmptyText = '正在整理当前设定结构,请稍后。',
|
|
hideBatchModule = false,
|
|
}: CustomWorldGenerationViewProps) {
|
|
const isMobileGenerationLayout = useIsMobileGenerationLayout();
|
|
const progressValue = getProgressPercentage(progress);
|
|
const steps = progress?.steps ?? [];
|
|
const hasStructuredAnchors = anchorEntries.length > 0;
|
|
// 允许不同生成场景按需隐藏第二模块的说明和次级返回动作。
|
|
const normalizedSettingActionLabel = settingActionLabel?.trim() ?? '';
|
|
const normalizedSettingDescription = settingDescription?.trim() ?? '';
|
|
const hasSettingActionLabel = normalizedSettingActionLabel.length > 0;
|
|
const hasSettingDescription = normalizedSettingDescription.length > 0;
|
|
const shouldHideBatchModule =
|
|
hideBatchModule ||
|
|
progressTitle === '拼图草稿生成进度' ||
|
|
progressTitle === '抓大鹅草稿生成进度';
|
|
const estimatedWaitText =
|
|
progress?.estimatedRemainingMs != null
|
|
? `预计还需 ${formatDuration(progress.estimatedRemainingMs)}`
|
|
: '正在校准预计等待时间';
|
|
const elapsedText =
|
|
progress != null
|
|
? `已耗时 ${formatDuration(progress.elapsedMs)}`
|
|
: '正在启动世界生成';
|
|
|
|
return (
|
|
<div
|
|
className="flex h-full min-h-0 flex-col overflow-y-auto overscroll-y-contain pr-1 pb-[max(1rem,env(safe-area-inset-bottom))]"
|
|
style={{ WebkitOverflowScrolling: 'touch' }}
|
|
>
|
|
<div className="platform-sticky-fade sticky top-0 z-20 -mx-3 mb-4 flex items-center justify-between gap-3 px-3 pb-3 pt-1 backdrop-blur-sm sm:static sm:mx-0 sm:bg-none sm:px-0 sm:pb-0 sm:pt-0 sm:backdrop-blur-none">
|
|
<button
|
|
type="button"
|
|
onClick={onBack}
|
|
className="platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px]"
|
|
>
|
|
{backLabel}
|
|
</button>
|
|
<div className="platform-pill platform-pill--cool px-3 py-1 text-[10px] tracking-[0.2em]">
|
|
{isGenerating
|
|
? activeBadgeLabel
|
|
: error
|
|
? pausedBadgeLabel
|
|
: idleBadgeLabel}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-none flex-col gap-4 xl:grid xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,1.35fr)_minmax(22rem,0.65fr)] xl:items-stretch">
|
|
<section className="platform-surface platform-surface--soft flex flex-col px-4 py-3.5 xl:min-h-0 xl:flex-1 xl:px-5 xl:py-4">
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between xl:gap-6">
|
|
<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-[var(--platform-text-strong)] sm:text-[2rem] xl:text-[2.4rem]">
|
|
{progress?.phaseLabel ?? '正在启动世界生成'}
|
|
</div>
|
|
<div className="mt-2 max-w-[36rem] text-sm leading-6 text-zinc-300">
|
|
{progress?.phaseDetail ?? '正在初始化世界生成链路与阶段监控。'}
|
|
</div>
|
|
</div>
|
|
<div className="shrink-0 sm:text-right">
|
|
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
|
|
总进度
|
|
</div>
|
|
<div className="mt-1 text-3xl font-black text-[var(--platform-cool-text)] sm:text-4xl">
|
|
{progressValue}%
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="platform-progress-track mt-4 h-4 overflow-hidden rounded-full xl:mt-5 xl:h-5">
|
|
<motion.div
|
|
className="h-full bg-[linear-gradient(90deg,#ff4f8b_0%,#ff8a73_52%,#ffd2a6_100%)]"
|
|
animate={{ width: `${progressValue}%` }}
|
|
transition={{ duration: 0.35, ease: 'easeOut' }}
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
className={`custom-world-generation-stats mt-4 grid gap-2 xl:gap-3 ${
|
|
shouldHideBatchModule
|
|
? 'custom-world-generation-stats--two-column grid-cols-2'
|
|
: 'sm:grid-cols-3'
|
|
}`}
|
|
style={
|
|
shouldHideBatchModule
|
|
? { gridTemplateColumns: 'repeat(2, minmax(0, 1fr))' }
|
|
: undefined
|
|
}
|
|
>
|
|
{shouldHideBatchModule ? null : (
|
|
<div className="platform-subpanel min-w-0 rounded-2xl px-4 py-3">
|
|
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
|
|
当前批次
|
|
</div>
|
|
<div className="mt-1 text-sm font-semibold text-white">
|
|
{progress?.batchLabel ?? '准备中'}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="platform-subpanel min-w-0 rounded-2xl px-3 py-3 sm:px-4">
|
|
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
|
|
预计等待
|
|
</div>
|
|
<div className="mt-1 break-keep text-xs font-semibold text-white sm:text-sm">
|
|
{estimatedWaitText}
|
|
</div>
|
|
</div>
|
|
<div className="platform-subpanel min-w-0 rounded-2xl px-3 py-3 sm:px-4">
|
|
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
|
|
计时
|
|
</div>
|
|
<div className="mt-1 break-keep text-xs font-semibold text-white sm:text-sm">
|
|
{elapsedText}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 space-y-2 xl:grid xl:min-h-0 xl:flex-1 xl:grid-cols-2 xl:content-start xl:gap-2 xl:space-y-0 xl:overflow-y-auto xl:pr-1">
|
|
{steps.map((step, index) => {
|
|
const stepProgress = getStepProgressPercentage(step);
|
|
|
|
return (
|
|
<motion.div
|
|
key={buildFallbackRenderKey(
|
|
step.id,
|
|
`progress-step-${index}`,
|
|
)}
|
|
className={`rounded-2xl border px-4 py-3 transition-colors ${
|
|
step.status === 'completed'
|
|
? 'border-emerald-400/16 bg-emerald-500/8'
|
|
: step.status === 'active'
|
|
? 'border-sky-300/22 bg-sky-500/10'
|
|
: 'platform-subpanel'
|
|
} custom-world-generation-step`}
|
|
initial={
|
|
isMobileGenerationLayout
|
|
? { opacity: 0, x: '-110vw' }
|
|
: false
|
|
}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{
|
|
duration: 0.34,
|
|
ease: 'easeOut',
|
|
delay: isMobileGenerationLayout ? index * 0.09 : 0,
|
|
}}
|
|
style={
|
|
{
|
|
'--generation-step-delay': `${index * 90}ms`,
|
|
} as CSSProperties
|
|
}
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="min-w-0 text-sm font-semibold text-white">
|
|
{step.label}
|
|
</div>
|
|
<div className="shrink-0 text-xs font-semibold text-zinc-300">
|
|
{getStepStatusLabel(step)} {stepProgress}%
|
|
</div>
|
|
</div>
|
|
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white/8">
|
|
<motion.div
|
|
className={`h-full rounded-full ${
|
|
step.status === 'completed'
|
|
? 'bg-emerald-300'
|
|
: step.status === 'active'
|
|
? 'bg-[linear-gradient(90deg,#7dd3fc_0%,#fcd34d_100%)]'
|
|
: 'bg-white/18'
|
|
}`}
|
|
animate={{ width: `${stepProgress}%` }}
|
|
transition={{ duration: 0.45, ease: 'easeOut' }}
|
|
/>
|
|
</div>
|
|
<div className="mt-2 text-xs leading-6 text-zinc-400">
|
|
{step.detail}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{error ? (
|
|
<div className="mt-4 rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
|
{error}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:justify-end">
|
|
{!isGenerating ? (
|
|
<>
|
|
{hasSettingActionLabel ? (
|
|
<button
|
|
type="button"
|
|
onClick={onEditSetting}
|
|
className="platform-button platform-button--ghost min-h-0 rounded-full px-4 py-2 text-sm"
|
|
>
|
|
{normalizedSettingActionLabel}
|
|
</button>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
onClick={onRetry}
|
|
className="platform-button platform-button--primary w-full sm:w-auto"
|
|
>
|
|
{retryLabel}
|
|
</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>
|
|
|
|
<section className="platform-surface platform-surface--soft overflow-hidden px-4 py-3.5 xl:flex xl:min-h-0 xl:flex-col xl:px-5 xl:py-4">
|
|
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between xl:flex-col xl:items-start xl:gap-2">
|
|
<div className="min-w-0">
|
|
<div className="text-[11px] font-bold tracking-[0.2em] text-[var(--platform-cool-text)]">
|
|
{settingTitle}
|
|
</div>
|
|
{hasSettingDescription ? (
|
|
<div className="mt-1 text-sm text-zinc-400">
|
|
{normalizedSettingDescription}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
{hasSettingActionLabel ? (
|
|
<button
|
|
type="button"
|
|
onClick={onEditSetting}
|
|
disabled={isGenerating}
|
|
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
|
|
>
|
|
{normalizedSettingActionLabel}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
{hasStructuredAnchors ? (
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:min-h-0 xl:flex-1 xl:grid-cols-1 xl:overflow-y-auto xl:pr-1">
|
|
{anchorEntries.map((entry, index) => (
|
|
<div
|
|
key={buildFallbackRenderKey(
|
|
entry.id,
|
|
`anchor-entry-${index}`,
|
|
)}
|
|
className="platform-subpanel rounded-2xl px-4 py-4 xl:py-3"
|
|
>
|
|
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
|
|
{entry.label}
|
|
</div>
|
|
<div className="mt-2 whitespace-pre-line text-sm leading-7 text-zinc-100">
|
|
{entry.value}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="platform-subpanel whitespace-pre-line rounded-2xl px-4 py-4 text-sm leading-7 text-zinc-200 md:max-h-[16rem] md:overflow-y-auto xl:max-h-none xl:min-h-0 xl:flex-1">
|
|
{settingText || structuredEmptyText}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|