This commit is contained in:
2026-05-10 22:20:54 +08:00
parent d6219f1a0c
commit 192accd796
92 changed files with 7045 additions and 1559 deletions

View File

@@ -0,0 +1,170 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
import { Match3DAgentWorkspace } from './Match3DAgentWorkspace';
const baseSession: Match3DAgentSessionSnapshot = {
sessionId: 'match3d-session-1',
currentTurn: 0,
progressPercent: 0,
stage: 'collecting_config',
anchorPack: {
theme: {
key: 'theme',
label: '题材主题',
value: '水果摊',
status: 'confirmed',
},
clearCount: {
key: 'clearCount',
label: '需要消除次数',
value: '8',
status: 'confirmed',
},
difficulty: {
key: 'difficulty',
label: '难度',
value: '3',
status: 'confirmed',
},
},
config: {
themeText: '水果摊',
referenceImageSrc: null,
clearCount: 8,
difficulty: 3,
assetStyleId: 'low-poly',
assetStyleLabel: '低多边形',
assetStylePrompt:
'块面清晰、轮廓简洁、颜色分区明确的低多边形 3D 素材风格。',
},
draft: null,
messages: [
{
id: 'message-1',
role: 'assistant',
kind: 'chat',
text: '旧会话固定追问不再作为主入口。',
createdAt: '2026-05-10T10:00:00.000Z',
},
],
lastAssistantReply: '旧会话固定追问不再作为主入口。',
publishedProfileId: null,
updatedAt: '2026-05-10T10:00:00.000Z',
};
test('match3d workspace submits derived entry form payload instead of agent chat', () => {
const onCreateFromForm = vi.fn();
const onExecuteAction = vi.fn();
render(
<Match3DAgentWorkspace
session={null}
onBack={() => {}}
onExecuteAction={onExecuteAction}
onCreateFromForm={onCreateFromForm}
/>,
);
expect(screen.getByText('想做个什么玩法?')).toBeTruthy();
expect(screen.getByLabelText('想做一个什么题材的抓大鹅?')).toBeTruthy();
expect(screen.getByText('3D素材风格')).toBeTruthy();
expect(screen.getByRole('button', { name: '黏土手作' })).toBeTruthy();
expect(screen.getByRole('button', { name: '自定义' })).toBeTruthy();
expect(screen.getByText('消耗20光点')).toBeTruthy();
expect(screen.queryByText('参考图')).toBeNull();
expect(screen.queryByLabelText('上传抓大鹅参考图')).toBeNull();
expect(screen.queryByLabelText('需要消除次数')).toBeNull();
expect(screen.queryByLabelText('难度数值')).toBeNull();
expect(screen.queryByText('物品')).toBeNull();
expect(screen.queryByText('旧会话固定追问不再作为主入口。')).toBeNull();
fireEvent.change(screen.getByLabelText('想做一个什么题材的抓大鹅?'), {
target: { value: '赛博水果摊' },
});
fireEvent.click(screen.getByRole('button', { name: '进阶' }));
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '赛博水果摊题材消除16次难度6',
themeText: '赛博水果摊',
referenceImageSrc: null,
clearCount: 16,
difficulty: 6,
assetStyleId: 'clay-toy',
assetStyleLabel: '黏土手作',
assetStylePrompt: '圆润、哑光、带轻微手捏痕迹的黏土手作 3D 素材风格。',
});
expect(onExecuteAction).not.toHaveBeenCalled();
});
test('match3d workspace supports custom 3d asset style prompt', () => {
const onCreateFromForm = vi.fn();
render(
<Match3DAgentWorkspace
session={null}
onBack={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
fireEvent.change(screen.getByLabelText('想做一个什么题材的抓大鹅?'), {
target: { value: '海底甜品店' },
});
fireEvent.click(screen.getByRole('button', { name: '自定义' }));
expect(screen.getByRole('dialog', { name: '自定义风格' })).toBeTruthy();
fireEvent.change(screen.getByLabelText('自定义3D素材风格描述'), {
target: { value: '透明果冻材质,边缘有柔和蓝色荧光' },
});
fireEvent.click(screen.getByRole('button', { name: '应用' }));
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onCreateFromForm).toHaveBeenCalledWith(
expect.objectContaining({
seedText: '海底甜品店题材消除12次难度4',
themeText: '海底甜品店',
clearCount: 12,
difficulty: 4,
assetStyleId: 'custom',
assetStyleLabel: '自定义风格',
assetStylePrompt: '透明果冻材质,边缘有柔和蓝色荧光',
}),
);
});
test('match3d workspace falls back to compile action when restored from the legacy route', () => {
const onExecuteAction = vi.fn();
render(
<Match3DAgentWorkspace
session={baseSession}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
expect(
(screen.getByLabelText('想做一个什么题材的抓大鹅?') as HTMLTextAreaElement)
.value,
).toBe('水果摊');
expect(
screen.getByRole('button', { name: '轻松' }).getAttribute('aria-pressed'),
).toBe('true');
expect(
screen.getByRole('button', { name: '低多边形' }).getAttribute(
'aria-pressed',
),
).toBe('true');
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'match3d_compile_draft',
});
});

View File

@@ -1,214 +1,531 @@
import { useState } from 'react';
import { Loader2, Sparkles, WandSparkles, X } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type {
CreateMatch3DSessionRequest,
ExecuteMatch3DActionRequest,
Match3DAgentSessionSnapshot,
Match3DAnchorItemResponse,
SendMatch3DMessageRequest,
} from '../../../packages/shared/src/contracts/match3dAgent';
import {
buildCreationAgentChatMessage,
createCreationAgentChatQuickActions,
createCreationAgentClientMessageId,
resolveCreationAgentQuickActionMessage,
} from '../../services/creation-agent';
import {
type CreationAgentAnchorView,
type CreationAgentSessionView,
type CreationAgentTheme,
CreationAgentWorkspace,
} from '../creation-agent';
type Match3DAgentWorkspaceProps = {
session: Match3DAgentSessionSnapshot | null;
streamingReplyText?: string;
isStreamingReply?: boolean;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onSubmitMessage: (payload: SendMatch3DMessageRequest) => void;
onSubmitMessage?: (payload: SendMatch3DMessageRequest) => void;
onExecuteAction: (payload: ExecuteMatch3DActionRequest) => void;
onCreateFromForm?: (payload: CreateMatch3DSessionRequest) => void;
initialFormPayload?: CreateMatch3DSessionRequest | null;
showBackButton?: boolean;
title?: string | null;
};
type Match3DReferenceImageState = {
src: string;
label: string;
type Match3DFormState = {
themeText: string;
difficultyOptionId: Match3DDifficultyOptionId;
assetStyleId: Match3DAssetStyleOptionId;
customAssetStylePrompt: string;
};
const MATCH3D_AGENT_THEME: CreationAgentTheme = {
accentTextClass: 'text-lime-100/86',
accentBgClass: 'bg-lime-200',
accentButtonClass: 'bg-lime-200 shadow-emerald-950/20',
userBubbleClass: 'bg-emerald-600 text-white',
heroClass:
'border border-lime-100/18 bg-[radial-gradient(circle_at_top_left,rgba(190,242,100,0.24),transparent_34%),radial-gradient(circle_at_bottom_right,rgba(251,146,60,0.2),transparent_32%),linear-gradient(135deg,rgba(20,83,45,0.96),rgba(39,39,42,0.96))]',
anchorGridClass: 'grid gap-2 sm:grid-cols-3',
const EMPTY_FORM_STATE: Match3DFormState = {
themeText: '',
difficultyOptionId: 'standard',
assetStyleId: 'clay-toy',
customAssetStylePrompt: '',
};
const MATCH3D_QUICK_ACTIONS = [
...createCreationAgentChatQuickActions(),
// 中文注释:入口页只暴露难度选项,消除次数和难度数值由选项稳定派生给后端。
const MATCH3D_DIFFICULTY_OPTIONS = [
{ id: 'easy', label: '轻松', clearCount: 8, difficulty: 2 },
{ id: 'standard', label: '标准', clearCount: 12, difficulty: 4 },
{ id: 'advanced', label: '进阶', clearCount: 16, difficulty: 6 },
{ id: 'hardcore', label: '硬核', clearCount: 20, difficulty: 8 },
] as const;
type Match3DDifficultyOptionId =
(typeof MATCH3D_DIFFICULTY_OPTIONS)[number]['id'];
const MATCH3D_ASSET_STYLE_OPTIONS = [
{
key: 'match3d-auto-config',
label: '自动配置',
id: 'clay-toy',
label: '黏土手作',
imageSrc: '/match3d-style-references/clay-toy.png',
prompt: '圆润、哑光、带轻微手捏痕迹的黏土手作 3D 素材风格。',
},
];
{
id: 'low-poly',
label: '低多边形',
imageSrc: '/match3d-style-references/low-poly.png',
prompt: '块面清晰、轮廓简洁、颜色分区明确的低多边形 3D 素材风格。',
},
{
id: 'toy-plastic',
label: '玩具塑料',
imageSrc: '/match3d-style-references/toy-plastic.png',
prompt: '亮面、光滑、有柔和高光的玩具塑料 3D 素材风格。',
},
{
id: 'wood-carved',
label: '木质雕刻',
imageSrc: '/match3d-style-references/wood-carved.png',
prompt: '保留木纹和手工雕刻感的温润木质 3D 素材风格。',
},
{
id: 'voxel-block',
label: '体素积木',
imageSrc: '/match3d-style-references/voxel-block.png',
prompt: '由小方块构成、边缘清晰、带游戏感的体素积木 3D 素材风格。',
},
{
id: 'metal-mecha',
label: '金属机甲',
imageSrc: '/match3d-style-references/metal-mecha.png',
prompt: '带金属拉丝、柔和高光和轻科幻感的金属机甲 3D 素材风格。',
},
{
id: 'custom',
label: '自定义',
imageSrc: null,
prompt: '',
},
] as const;
function readMatch3DReferenceImageAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
if (!file.type.startsWith('image/')) {
reject(new Error('请选择图片文件。'));
return;
}
type Match3DAssetStyleOptionId =
(typeof MATCH3D_ASSET_STYLE_OPTIONS)[number]['id'];
const reader = new FileReader();
reader.onerror = () => reject(new Error('参考图读取失败,请重试。'));
reader.onload = () => resolve(String(reader.result || ''));
reader.readAsDataURL(file);
});
function normalizeDifficulty(value: number) {
return Math.max(1, Math.min(10, Math.round(value)));
}
function mapMatch3DAnchor(
anchor: Match3DAnchorItemResponse,
): CreationAgentAnchorView {
return {
key: anchor.key,
label: anchor.label,
value: anchor.value,
status: anchor.status,
};
}
function mapMatch3DSession(
session: Match3DAgentSessionSnapshot,
): CreationAgentSessionView {
// 中文注释:抓大鹅 F1 只展示聊天与配置锚点,草稿结果交给后续结果页承接。
const chatMessages = session.messages.filter(
(message) =>
message.kind === 'chat' ||
message.kind === 'summary' ||
message.kind === 'warning',
function resolveDifficultyOptionId(
difficulty: number | null | undefined,
clearCount: number | null | undefined,
): Match3DDifficultyOptionId {
const clearCountMatchedOption = MATCH3D_DIFFICULTY_OPTIONS.find(
(option) => option.clearCount === clearCount,
);
if (clearCountMatchedOption) {
return clearCountMatchedOption.id;
}
if (typeof difficulty !== 'number' || !Number.isFinite(difficulty)) {
return 'standard';
}
const normalizedDifficulty = normalizeDifficulty(Number(difficulty));
return MATCH3D_DIFFICULTY_OPTIONS.reduce(
(nearestOption, option) =>
Math.abs(option.difficulty - normalizedDifficulty) <
Math.abs(nearestOption.difficulty - normalizedDifficulty)
? option
: nearestOption,
MATCH3D_DIFFICULTY_OPTIONS[1],
).id;
}
function getDifficultyOption(optionId: Match3DDifficultyOptionId) {
return (
MATCH3D_DIFFICULTY_OPTIONS.find((option) => option.id === optionId) ??
MATCH3D_DIFFICULTY_OPTIONS[1]
);
}
function getAssetStyleOption(optionId: Match3DAssetStyleOptionId) {
return (
MATCH3D_ASSET_STYLE_OPTIONS.find((option) => option.id === optionId) ??
MATCH3D_ASSET_STYLE_OPTIONS[0]
);
}
function resolveAssetStyleOptionId(
assetStyleId: string | null | undefined,
assetStylePrompt: string | null | undefined,
): Match3DAssetStyleOptionId {
const matchedOption = MATCH3D_ASSET_STYLE_OPTIONS.find(
(option) => option.id === assetStyleId,
);
if (matchedOption) {
return matchedOption.id;
}
return assetStylePrompt?.trim() ? 'custom' : 'clay-toy';
}
function resolveInitialFormState(
session: Match3DAgentSessionSnapshot | null,
initialFormPayload: CreateMatch3DSessionRequest | null = null,
): Match3DFormState {
const config = session?.config;
const themeText =
initialFormPayload?.themeText?.trim() ||
config?.themeText?.trim() ||
session?.anchorPack.theme.value?.trim() ||
initialFormPayload?.seedText?.trim() ||
'';
const clearCount =
initialFormPayload?.clearCount ??
config?.clearCount ??
null;
const difficulty =
initialFormPayload?.difficulty ??
config?.difficulty ??
null;
const assetStyleId =
initialFormPayload?.assetStyleId ??
config?.assetStyleId ??
null;
const assetStylePrompt =
initialFormPayload?.assetStylePrompt ??
config?.assetStylePrompt ??
'';
return {
sessionId: session.sessionId,
title: null,
assistantSummary: null,
currentTurn: session.currentTurn,
progressPercent: session.progressPercent,
anchors: [
session.anchorPack.theme,
session.anchorPack.clearCount,
session.anchorPack.difficulty,
].map(mapMatch3DAnchor),
messages: chatMessages,
recommendedReplies: [],
...EMPTY_FORM_STATE,
themeText,
difficultyOptionId: resolveDifficultyOptionId(difficulty, clearCount),
assetStyleId: resolveAssetStyleOptionId(assetStyleId, assetStylePrompt),
customAssetStylePrompt: assetStylePrompt,
};
}
function buildMatch3DChatPayload({
text,
quickFillRequested = false,
referenceImageSrc,
}: {
text: string;
quickFillRequested?: boolean;
referenceImageSrc?: string | null;
}) {
return buildCreationAgentChatMessage<{
referenceImageSrc?: string | null;
}>({
clientMessageId: createCreationAgentClientMessageId('match3d'),
text,
quickFillRequested,
extraPayload: {
referenceImageSrc: referenceImageSrc || null,
},
});
}
/**
* 抓大鹅创作入口已从固定 Agent 追问改成表单式。
* 组件名保留为 Match3DAgentWorkspace兼容现有路由、草稿恢复和父层分流。
*/
export function Match3DAgentWorkspace({
session,
streamingReplyText = '',
isStreamingReply = false,
isBusy = false,
error = null,
onBack,
onSubmitMessage,
onExecuteAction,
onCreateFromForm,
initialFormPayload = null,
showBackButton = true,
title = '想做个什么玩法?',
}: Match3DAgentWorkspaceProps) {
const [referenceImage, setReferenceImage] =
useState<Match3DReferenceImageState | null>(null);
const [referenceImageError, setReferenceImageError] = useState<string | null>(
null,
const [formState, setFormState] = useState<Match3DFormState>(() =>
resolveInitialFormState(session, initialFormPayload),
);
const [isCustomStylePanelOpen, setIsCustomStylePanelOpen] = useState(false);
const [draftCustomStylePrompt, setDraftCustomStylePrompt] = useState('');
const appliedInitialFormKeyRef = useRef<string | null>(null);
useEffect(() => {
const nextInitialFormKey =
session?.sessionId ?? JSON.stringify(initialFormPayload ?? null);
if (appliedInitialFormKeyRef.current === nextInitialFormKey) {
return;
}
appliedInitialFormKeyRef.current = nextInitialFormKey;
setFormState(resolveInitialFormState(session, initialFormPayload));
setIsCustomStylePanelOpen(false);
setDraftCustomStylePrompt('');
}, [initialFormPayload, session]);
const themeText = formState.themeText.trim();
const selectedDifficultyOption = getDifficultyOption(
formState.difficultyOptionId,
);
const selectedAssetStyleOption = getAssetStyleOption(formState.assetStyleId);
const assetStylePrompt =
formState.assetStyleId === 'custom'
? formState.customAssetStylePrompt.trim()
: selectedAssetStyleOption.prompt;
const assetStyleLabel =
formState.assetStyleId === 'custom'
? '自定义风格'
: selectedAssetStyleOption.label;
const canSubmit = Boolean(
themeText &&
!isBusy &&
(formState.assetStyleId !== 'custom' ||
formState.customAssetStylePrompt.trim()),
);
const formPayload = useMemo<CreateMatch3DSessionRequest>(
() => ({
seedText: themeText
? `${themeText}题材,消除${selectedDifficultyOption.clearCount}次,难度${selectedDifficultyOption.difficulty}`
: themeText,
themeText,
referenceImageSrc: null,
clearCount: selectedDifficultyOption.clearCount,
difficulty: selectedDifficultyOption.difficulty,
assetStyleId: formState.assetStyleId,
assetStyleLabel,
assetStylePrompt,
}),
[
assetStyleLabel,
assetStylePrompt,
formState.assetStyleId,
selectedDifficultyOption,
themeText,
],
);
return (
<CreationAgentWorkspace
session={session ? mapMatch3DSession(session) : null}
theme={MATCH3D_AGENT_THEME}
loadingText="正在准备抓大鹅共创工作区..."
composerPlaceholder="题材、消除次数、难度..."
primaryActionLabel="生成结果页"
streamingReplyText={streamingReplyText}
isStreamingReply={isStreamingReply}
isBusy={isBusy}
error={error}
quickActions={MATCH3D_QUICK_ACTIONS}
referenceImagePreviewSrc={referenceImage?.src ?? null}
referenceImageLabel={referenceImage?.label ?? null}
referenceImageError={referenceImageError}
onBack={onBack}
onSubmitText={(text) => {
onSubmitMessage(
buildMatch3DChatPayload({
text,
referenceImageSrc: referenceImage?.src ?? null,
}),
);
}}
onPrimaryAction={() => {
onExecuteAction({ action: 'match3d_compile_draft' });
}}
onQuickAction={(action) => {
const quickActionMessage =
action.key === 'match3d-auto-config'
? {
text: '自动配置',
quickFillRequested: true,
}
: resolveCreationAgentQuickActionMessage(
action.key,
'请总结一下当前抓大鹅设定。',
);
const openCustomStylePanel = () => {
setDraftCustomStylePrompt(formState.customAssetStylePrompt);
setIsCustomStylePanelOpen(true);
};
onSubmitMessage(
buildMatch3DChatPayload({
...quickActionMessage,
referenceImageSrc: referenceImage?.src ?? null,
}),
);
}}
onReferenceImageChange={async (file) => {
try {
const dataUrl = await readMatch3DReferenceImageAsDataUrl(file);
setReferenceImage({
src: dataUrl,
label: file.name.trim() || '本地参考图',
});
setReferenceImageError(null);
} catch (caughtError) {
setReferenceImageError(
caughtError instanceof Error
? caughtError.message
: '参考图读取失败,请重试。',
);
}
}}
onClearReferenceImage={() => {
setReferenceImage(null);
setReferenceImageError(null);
}}
/>
const applyCustomStylePrompt = () => {
setFormState((current) => ({
...current,
assetStyleId: 'custom',
customAssetStylePrompt: draftCustomStylePrompt.trim(),
}));
setIsCustomStylePanelOpen(false);
};
const submitForm = () => {
if (!canSubmit) {
return;
}
if (onCreateFromForm) {
onCreateFromForm(formPayload);
return;
}
if (session) {
onExecuteAction({ action: 'match3d_compile_draft' });
}
};
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden">
{showBackButton ? (
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
>
</button>
</div>
) : null}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden pr-0">
{title ? (
<div className="mb-3 shrink-0 sm:mb-5">
<div className="flex flex-wrap items-center gap-2">
<h1 className="m-0 text-3xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
{title}
</h1>
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
BETA
</span>
</div>
</div>
) : null}
<section className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div
className={`grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] gap-2 sm:gap-3 lg:grid-cols-[minmax(0,1.1fr)_minmax(16rem,0.9fr)] lg:grid-rows-1 ${isBusy ? 'opacity-55' : ''}`}
>
<label className="block min-h-0">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<textarea
value={formState.themeText}
disabled={isBusy}
rows={5}
placeholder=""
onChange={(event) =>
setFormState((current) => ({
...current,
themeText: event.target.value,
}))
}
className="h-full min-h-[7.75rem] w-full resize-none rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none sm:min-h-[9rem] lg:min-h-[14rem]"
aria-label="想做一个什么题材的抓大鹅?"
/>
</label>
<div className="flex min-h-0 flex-col gap-2 overflow-hidden">
<div className="min-h-0">
<div className="mb-1.5 text-sm font-black text-[var(--platform-text-strong)]">
3D素材风格
</div>
<div
className="flex snap-x gap-2 overflow-x-auto overscroll-x-contain pb-1 scrollbar-hide touch-pan-x [-webkit-overflow-scrolling:touch]"
aria-label="3D素材风格"
>
{MATCH3D_ASSET_STYLE_OPTIONS.map((option) => {
const selected = formState.assetStyleId === option.id;
const isCustom = option.id === 'custom';
return (
<button
key={option.id}
type="button"
disabled={isBusy}
onClick={() => {
if (isCustom) {
openCustomStylePanel();
return;
}
setFormState((current) => ({
...current,
assetStyleId: option.id,
}));
}}
className={`relative h-[4.45rem] w-[5.2rem] shrink-0 snap-start overflow-hidden rounded-[0.9rem] border p-0 text-left transition sm:h-[5.2rem] sm:w-[6.1rem] ${
selected
? 'border-[#ff4056] ring-1 ring-inset ring-[#ff4056]'
: 'border-[var(--platform-subpanel-border)]'
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-pressed={selected}
aria-label={option.label}
>
{option.imageSrc ? (
<img
src={option.imageSrc}
alt=""
className="absolute inset-0 h-full w-full object-cover"
loading="lazy"
/>
) : (
<span className="absolute inset-0 bg-[linear-gradient(135deg,rgba(255,255,255,0.98),rgba(255,240,244,0.9))]" />
)}
<span className="absolute inset-0 bg-[linear-gradient(180deg,rgba(3,7,18,0.02)_0%,rgba(3,7,18,0.1)_42%,rgba(3,7,18,0.82)_100%)]" />
{isCustom ? (
<span className="absolute inset-0 flex items-center justify-center text-2xl font-black text-[var(--platform-text-strong)]">
+
</span>
) : null}
<span className="absolute inset-x-2 bottom-1.5 truncate rounded-full bg-black/26 px-1.5 py-0.5 text-center text-[11px] font-black text-white [text-shadow:0_1px_6px_rgba(0,0,0,0.9)]">
{option.label}
</span>
</button>
);
})}
</div>
</div>
<div className="shrink-0">
<div className="mb-1.5 text-sm font-black text-[var(--platform-text-strong)]">
</div>
<div className="grid grid-cols-4 gap-1.5 sm:gap-2 lg:grid-cols-2">
{MATCH3D_DIFFICULTY_OPTIONS.map((option) => {
const selected =
formState.difficultyOptionId === option.id;
return (
<button
key={option.id}
type="button"
disabled={isBusy}
onClick={() =>
setFormState((current) => ({
...current,
difficultyOptionId: option.id,
}))
}
className={`min-h-10 rounded-[0.85rem] border px-2 text-sm font-black transition sm:min-h-11 ${
selected
? 'border-[#ff4056] bg-[#ff4056] text-white shadow-[0_8px_18px_rgba(255,64,86,0.18)]'
: 'border-[var(--platform-subpanel-border)] bg-white/88 text-[var(--platform-text-strong)]'
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-pressed={selected}
>
{option.label}
</button>
);
})}
</div>
</div>
</div>
</div>
<div className="mt-2 shrink-0 space-y-3">
{error ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{error}
</div>
) : null}
</div>
</section>
</div>
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
<button
type="button"
disabled={!canSubmit}
onClick={submitForm}
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${!canSubmit ? 'cursor-not-allowed opacity-55' : ''}`}
>
<span className="inline-flex flex-wrap items-center justify-center gap-1.5 sm:gap-2">
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{session ? (
<Sparkles className="h-4 w-4" />
) : (
<WandSparkles className="h-4 w-4" />
)}
<span>稿</span>
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
20
</span>
</span>
</button>
</div>
{isCustomStylePanelOpen ? (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div
role="dialog"
aria-modal="true"
aria-labelledby="match3d-custom-style-title"
className="platform-modal-shell platform-remap-surface w-full max-w-sm rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
>
<div className="flex items-center justify-between gap-3">
<div
id="match3d-custom-style-title"
className="text-base font-black text-[var(--platform-text-strong)]"
>
</div>
<button
type="button"
aria-label="关闭自定义风格"
onClick={() => setIsCustomStylePanelOpen(false)}
className="platform-profile-icon-button flex h-8 w-8 items-center justify-center rounded-full"
>
<X className="h-4 w-4" />
</button>
</div>
<textarea
value={draftCustomStylePrompt}
onChange={(event) => setDraftCustomStylePrompt(event.target.value)}
rows={4}
className="mt-4 h-[7.5rem] w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="自定义3D素材风格描述"
/>
<div className="mt-5 grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setIsCustomStylePanelOpen(false)}
className="platform-button platform-button--secondary justify-center"
>
</button>
<button
type="button"
disabled={!draftCustomStylePrompt.trim()}
onClick={applyCustomStylePrompt}
className={`platform-button platform-button--primary justify-center ${!draftCustomStylePrompt.trim() ? 'cursor-not-allowed opacity-55' : ''}`}
>
</button>
</div>
</div>
</div>
) : null}
</div>
);
}

View File

@@ -4,6 +4,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { Match3DWorkProfile } from '../../../packages/shared/src/contracts/match3dWorks';
import * as assetReadUrlService from '../../services/assetReadUrlService';
import * as hyper3dService from '../../services/hyper3dModelGenerationService';
import * as match3dWorksService from '../../services/match3d-works';
import { Match3DResultView } from './Match3DResultView';
@@ -19,13 +21,44 @@ vi.mock('../ResolvedAssetImage', () => ({
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: (src?: string | null) => ({
resolvedUrl: src?.startsWith('/generated-')
? `https://signed.example.com${src}`
: (src ?? ''),
isResolving: false,
shouldResolve: Boolean(src?.startsWith('/generated-')),
}),
}));
vi.mock('../../services/assetReadUrlService', () => ({
readAssetBytes: vi.fn(() =>
Promise.resolve(
new Response(new Uint8Array([104, 101, 108, 108, 111]), {
status: 200,
headers: {
'Content-Type': 'image/png',
},
}),
),
),
}));
vi.mock('../../services/match3d-works', () => ({
publishMatch3DWork: vi.fn(),
updateMatch3DWork: vi.fn(),
}));
vi.mock('../../services/hyper3dModelGenerationService', () => ({
getHyper3dDownloads: vi.fn(),
getHyper3dTaskStatus: vi.fn(),
submitHyper3dImageToModel: vi.fn(),
submitHyper3dTextToModel: vi.fn(),
}));
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
function createProfile(
@@ -100,4 +133,265 @@ describe('Match3DResultView', () => {
fireEvent.click(publishButton);
expect(match3dWorksService.publishMatch3DWork).not.toHaveBeenCalled();
});
test('结果页提供多 Tab并能进入 Rodin 3D 素材详情', () => {
render(
<Match3DResultView
profile={createProfile({ themeText: '水果' })}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
expect(screen.getByRole('button', { name: '作品信息' })).toBeTruthy();
expect(screen.getByRole('button', { name: '玩法配置' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(screen.getByText('素材名称')).toBeTruthy();
expect(screen.getByRole('button', { name: '文生模型' })).toBeTruthy();
expect(screen.getByRole('button', { name: '图生模型' })).toBeTruthy();
});
test('Rodin 文生模型提交使用 Hyper3D 代理', async () => {
vi.mocked(hyper3dService.submitHyper3dTextToModel).mockResolvedValue({
ok: true,
provider: 'hyper3d-rodin',
mode: 'text-to-model',
taskUuid: 'task-1',
subscriptionKey: 'sub-1',
jobUuids: ['job-1'],
message: 'submitted',
tier: 'Gen-2',
});
render(
<Match3DResultView
profile={createProfile({ themeText: '水果' })}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(hyper3dService.submitHyper3dTextToModel).toHaveBeenCalledWith(
expect.objectContaining({
geometryFileFormat: 'glb',
material: 'PBR',
meshMode: 'Quad',
prompt: expect.stringContaining('水果核心物件'),
}),
);
});
await waitFor(() => {
expect(screen.getAllByText('排队中').length).toBeGreaterThan(0);
});
});
test('Rodin 图生模型没有参考图时阻止提交', async () => {
render(
<Match3DResultView
profile={createProfile({ themeText: '水果' })}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.click(screen.getByRole('button', { name: '图生模型' }));
const generateButton = screen.getByRole('button', { name: '生成' });
expect(generateButton).toHaveProperty('disabled', true);
expect(hyper3dService.submitHyper3dImageToModel).not.toHaveBeenCalled();
});
test('结果页优先预览生成出来的物品图片和模型文件', () => {
render(
<Match3DResultView
profile={createProfile({
clearCount: 3,
generatedItemAssets: [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: '/generated-match3d-assets/session/profile/items/strawberry/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/strawberry/image.png',
modelSrc: '/generated-match3d-assets/session/profile/items/strawberry/model/model.glb',
modelObjectKey:
'generated-match3d-assets/session/profile/items/strawberry/model/model.glb',
modelFileName: 'strawberry.glb',
taskUuid: 'task-strawberry',
subscriptionKey: 'sub-strawberry',
status: 'model_ready',
error: null,
},
],
})}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(screen.getByDisplayValue('草莓')).toBeTruthy();
expect(screen.getAllByText('已完成').length).toBeGreaterThan(0);
const modelLink = screen.getByRole('link', { name: /strawberry\.glb/u });
expect(modelLink.getAttribute('href')).toBe(
'https://signed.example.com/generated-match3d-assets/session/profile/items/strawberry/model/model.glb',
);
});
test('草稿阶段仅有切割图片时展示图片已就绪,不要求模型文件', () => {
render(
<Match3DResultView
profile={createProfile({
clearCount: 3,
generatedItemAssets: [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: '/generated-match3d-assets/session/profile/items/strawberry/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/strawberry/image.png',
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
},
],
})}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(screen.getByDisplayValue('草莓')).toBeTruthy();
expect(screen.getAllByText('图片已就绪').length).toBeGreaterThan(0);
expect(screen.getByText('0 文件')).toBeTruthy();
expect(screen.queryByRole('link', { name: /\.glb/u })).toBeNull();
});
test('重进草稿页时从持久化 profile 素材恢复 3D 素材列表', () => {
render(
<Match3DResultView
profile={createProfile({
clearCount: 3,
generatedItemAssets: [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc:
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
},
{
itemId: 'match3d-item-2',
itemName: '苹果',
imageSrc:
'/generated-match3d-assets/session/profile/items/match3d-item-2-item/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-2-item/image.png',
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
},
],
})}
draft={null}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
});
test('Rodin 图生模型提交前会把 generated 参考图转成 data URL', async () => {
vi.mocked(hyper3dService.submitHyper3dImageToModel).mockResolvedValue({
ok: true,
provider: 'hyper3d-rodin',
mode: 'image-to-model',
taskUuid: 'task-image',
subscriptionKey: 'sub-image',
jobUuids: ['job-image'],
message: 'submitted',
tier: 'Gen-2',
});
vi.stubGlobal('fetch', vi.fn());
render(
<Match3DResultView
profile={createProfile({
clearCount: 3,
generatedItemAssets: [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: '/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
},
],
})}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(assetReadUrlService.readAssetBytes).toHaveBeenCalledWith(
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
expect.objectContaining({ expireSeconds: 300 }),
);
expect(globalThis.fetch).not.toHaveBeenCalled();
expect(hyper3dService.submitHyper3dImageToModel).toHaveBeenCalledWith(
expect.objectContaining({
imageDataUrls: ['data:image/png;base64,aGVsbG8='],
prompt: expect.stringContaining('草莓'),
}),
);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1219,6 +1219,17 @@ function disposeTrayPreview(runtime: TrayPreviewRuntime | null) {
runtime.renderer.domElement.remove();
}
export function applyMatch3DRendererCanvasLayout(
canvas: HTMLCanvasElement,
) {
// 中文注释WebGL 绘图缓冲区会乘设备 DPRCSS 尺寸必须单独锁住,否则手机端画布会放大溢出。
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.inset = '0';
canvas.style.position = 'absolute';
canvas.style.width = '100%';
}
function positionTrayPreviewObject(
runtime: TrayPreviewRuntime,
object: ThreeObject3D,
@@ -1280,11 +1291,7 @@ export function Match3DTrayPreviewBoard({
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8));
renderer.outputColorSpace = three.SRGBColorSpace;
renderer.domElement.style.display = 'block';
renderer.domElement.style.height = '100%';
renderer.domElement.style.inset = '0';
renderer.domElement.style.position = 'absolute';
renderer.domElement.style.width = '100%';
applyMatch3DRendererCanvasLayout(renderer.domElement);
container.appendChild(renderer.domElement);
const handleContextLost = (event: Event) => {
event.preventDefault();
@@ -1529,6 +1536,7 @@ export function Match3DPhysicsBoard({
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8));
renderer.shadowMap.enabled = true;
renderer.outputColorSpace = three.SRGBColorSpace;
applyMatch3DRendererCanvasLayout(renderer.domElement);
container.appendChild(renderer.domElement);
const handleContextLost = (event: Event) => {
event.preventDefault();

View File

@@ -20,6 +20,7 @@ import {
MATCH3D_EXTRUDED_READABLE_SHAPES,
MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE,
MATCH3D_TRAY_MODEL_TARGET_SIZE,
applyMatch3DRendererCanvasLayout,
buildMatch3DPhysicsEntrySignature,
createMatch3DCannonShape,
createMatch3DThreeGeometry,
@@ -190,6 +191,18 @@ test('3D 模式下备选栏使用共享模型预览层,避免挤占中心棋
expect(screen.getByTestId('match3d-tray-model-board')).toBeTruthy();
});
test('3D WebGL 画布锁定 CSS 尺寸,避免高 DPR 手机上溢出中心棋盘', () => {
const canvas = document.createElement('canvas');
applyMatch3DRendererCanvasLayout(canvas);
expect(canvas.style.position).toBe('absolute');
expect(canvas.style.inset).toBe('0');
expect(canvas.style.width).toBe('100%');
expect(canvas.style.height).toBe('100%');
expect(canvas.style.display).toBe('block');
});
test('3D 物理条目签名随 run 和视觉资源变化,避免旧模型复用到新局', () => {
const run = startLocalMatch3DRun(10);
const item = run.items[0]!;

View File

@@ -154,6 +154,7 @@ import {
} from '../../services/match3d-works';
import {
buildBigFishGenerationAnchorEntries,
buildMatch3DGenerationAnchorEntries,
buildMiniGameDraftGenerationProgress,
buildPuzzleGenerationAnchorEntries,
buildSquareHoleGenerationAnchorEntries,
@@ -284,9 +285,13 @@ import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreation
import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld';
import { useRpgCreationResultAutosave } from '../rpg-entry/useRpgCreationResultAutosave';
import { useRpgCreationSessionController } from '../rpg-entry/useRpgCreationSessionController';
import {
buildVisualNovelEntryGenerationAnchorEntries,
buildVisualNovelEntryGenerationProgress,
type VisualNovelEntryFormPayload,
} from '../visual-novel-creation/VisualNovelAgentWorkspace';
import { createMockVisualNovelRunFromDraft } from '../visual-novel-runtime/visualNovelMockData';
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
import { PlatformFeedbackView } from './PlatformFeedbackView';
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
import {
getVisiblePlatformCreationTypes,
@@ -302,6 +307,7 @@ import {
} from './platformEntryShared';
import type { PlatformEntryFlowShellProps } from './platformEntryTypes';
import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView';
import { PlatformFeedbackView } from './PlatformFeedbackView';
import { PlatformWorkDetailView } from './PlatformWorkDetailView';
import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController';
import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap';
@@ -349,6 +355,7 @@ type VisualNovelRuntimeReturnStage =
| 'visual-novel-gallery-detail'
| 'work-detail'
| 'platform';
type VisualNovelEntryGenerationPhase = 'generating' | 'ready' | 'failed';
type PuzzleSaveArchiveState = {
runtimeKind?: unknown;
@@ -591,6 +598,7 @@ function buildMatch3DProfileFromSession(
updatedAt: now,
publishedAt: null,
publishReady: Boolean(draft.publishReady),
generatedItemAssets: draft.generatedItemAssets,
};
}
@@ -1513,6 +1521,10 @@ export function PlatformEntryFlowShellImpl({
const [match3dRuntimeReturnStage, setMatch3DRuntimeReturnStage] = useState<
'match3d-result' | 'work-detail'
>('match3d-result');
const [match3dFormDraftPayload, setMatch3DFormDraftPayload] =
useState<CreateMatch3DSessionRequest | null>(null);
const [match3dGenerationState, setMatch3DGenerationState] =
useState<MiniGameDraftGenerationState | null>(null);
const [isMatch3DLoadingLibrary, setIsMatch3DLoadingLibrary] = useState(false);
const [squareHoleWorks, setSquareHoleWorks] = useState<
SquareHoleWorkSummary[]
@@ -1597,10 +1609,12 @@ export function PlatformEntryFlowShellImpl({
);
const [puzzleGenerationState, setPuzzleGenerationState] =
useState<MiniGameDraftGenerationState | null>(null);
const [puzzleGenerationProgressNowMs, setPuzzleGenerationProgressNowMs] =
const [miniGameGenerationProgressNowMs, setMiniGameGenerationProgressNowMs] =
useState(() => Date.now());
const [puzzleFormDraftPayload, setPuzzleFormDraftPayload] =
useState<CreatePuzzleAgentSessionRequest | null>(null);
const [activeCreationFormType, setActiveCreationFormType] =
useState<PlatformCreationTypeId>('puzzle');
const [puzzleOnboardingPrompt, setPuzzleOnboardingPrompt] = useState('');
const [puzzleOnboardingPhase, setPuzzleOnboardingPhase] =
useState<PuzzleOnboardingPhase>('input');
@@ -1640,6 +1654,12 @@ export function PlatformEntryFlowShellImpl({
useState<VisualNovelRunSnapshot | null>(null);
const [visualNovelRuntimeReturnStage, setVisualNovelRuntimeReturnStage] =
useState<VisualNovelRuntimeReturnStage>('visual-novel-result');
const [visualNovelFormDraftPayload, setVisualNovelFormDraftPayload] =
useState<VisualNovelEntryFormPayload | null>(null);
const [visualNovelGenerationStartedAtMs, setVisualNovelGenerationStartedAtMs] =
useState<number | null>(null);
const [visualNovelGenerationPhase, setVisualNovelGenerationPhase] =
useState<VisualNovelEntryGenerationPhase>('generating');
const [isVisualNovelLoadingLibrary, setIsVisualNovelLoadingLibrary] =
useState(false);
const [isPuzzleNextLevelGenerating, setIsPuzzleNextLevelGenerating] =
@@ -2191,23 +2211,38 @@ export function PlatformEntryFlowShellImpl({
]);
useEffect(() => {
const shouldTickPuzzleProgress =
selectionStage === 'puzzle-generating' &&
puzzleGenerationState != null &&
puzzleGenerationState.phase !== 'ready' &&
puzzleGenerationState.phase !== 'failed';
const activeGenerationState =
selectionStage === 'puzzle-generating'
? puzzleGenerationState
: selectionStage === 'match3d-generating'
? match3dGenerationState
: null;
const shouldTickProgress =
selectionStage === 'visual-novel-generating'
? visualNovelGenerationStartedAtMs != null &&
visualNovelGenerationPhase !== 'ready' &&
visualNovelGenerationPhase !== 'failed'
: activeGenerationState != null &&
activeGenerationState.phase !== 'ready' &&
activeGenerationState.phase !== 'failed';
if (!shouldTickPuzzleProgress) {
if (!shouldTickProgress) {
return undefined;
}
setPuzzleGenerationProgressNowMs(Date.now());
setMiniGameGenerationProgressNowMs(Date.now());
const timerId = window.setInterval(() => {
setPuzzleGenerationProgressNowMs(Date.now());
setMiniGameGenerationProgressNowMs(Date.now());
}, 500);
return () => window.clearInterval(timerId);
}, [puzzleGenerationState, selectionStage]);
}, [
match3dGenerationState,
puzzleGenerationState,
selectionStage,
visualNovelGenerationPhase,
visualNovelGenerationStartedAtMs,
]);
const runProtectedAction = useCallback(
(action: () => void) => {
@@ -2514,6 +2549,7 @@ export function PlatformEntryFlowShellImpl({
enterCreateTab,
setSelectionStage,
onSessionOpened: () => {
setActiveCreationFormType('match3d');
setShowCreationTypeModal(false);
},
onActionComplete: async ({ payload, response, setSession }) => {
@@ -2521,6 +2557,18 @@ export function PlatformEntryFlowShellImpl({
if (payload.action !== 'match3d_compile_draft') {
return;
}
setMatch3DGenerationState((current) =>
current
? {
...current,
phase: 'ready',
completedAssetCount:
response.session.draft?.generatedItemAssets?.length ?? 3,
totalAssetCount:
response.session.draft?.generatedItemAssets?.length ?? 3,
}
: current,
);
const profileId = response.session.draft?.profileId;
if (!profileId) {
@@ -2530,12 +2578,38 @@ export function PlatformEntryFlowShellImpl({
try {
const { item } = await getMatch3DWorkDetail(profileId);
setMatch3DProfile(item);
setMatch3DProfile({
...item,
generatedItemAssets:
response.session.draft?.generatedItemAssets ??
item.generatedItemAssets,
});
await refreshMatch3DShelf().catch(() => undefined);
} catch {
setMatch3DProfile(buildMatch3DProfileFromSession(response.session));
}
},
beforeExecuteAction: ({ payload }) => {
if (payload.action !== 'match3d_compile_draft') {
return;
}
setSelectionStage('match3d-generating');
setMatch3DGenerationState(createMiniGameDraftGenerationState('match3d'));
},
onActionError: ({ payload, errorMessage }) => {
if (payload.action !== 'match3d_compile_draft') {
return;
}
setMatch3DGenerationState((current) =>
current
? {
...current,
phase: 'failed',
error: errorMessage,
}
: current,
);
},
});
const squareHoleFlow = usePlatformCreationAgentFlowController<
@@ -2723,6 +2797,7 @@ export function PlatformEntryFlowShellImpl({
enterCreateTab,
setSelectionStage,
onSessionOpened: () => {
setActiveCreationFormType('puzzle');
sessionController.setCreationTypeError(null);
setPuzzleCreationError(null);
setShowCreationTypeModal(false);
@@ -2873,7 +2948,6 @@ export function PlatformEntryFlowShellImpl({
const setMatch3DError = match3dFlow.setError;
match3DErrorSetterRef.current = setMatch3DError;
const isMatch3DBusy = match3dFlow.isBusy;
const streamingMatch3DReplyText = match3dFlow.streamingReplyText;
const setStreamingMatch3DReplyText = match3dFlow.setStreamingReplyText;
const isStreamingMatch3DReply = match3dFlow.isStreamingReply;
const setIsStreamingMatch3DReply = match3dFlow.setIsStreamingReply;
@@ -2903,7 +2977,6 @@ export function PlatformEntryFlowShellImpl({
visualNovelErrorSetterRef.current = setVisualNovelError;
const isVisualNovelBusy = visualNovelFlow.isBusy;
const setIsVisualNovelBusy = visualNovelFlow.setIsBusy;
const visualNovelStreamingReplyText = visualNovelFlow.streamingReplyText;
const isVisualNovelStreamingReply = visualNovelFlow.isStreamingReply;
const resetRpgSessionViewState = sessionController.resetSessionViewState;
const setRpgGeneratedCustomWorldProfile =
@@ -2922,24 +2995,6 @@ export function PlatformEntryFlowShellImpl({
await bigFishFlow.openWorkspace();
}, [bigFishFlow]);
const openMatch3DAgentWorkspace = useCallback(async () => {
setMatch3DSession(null);
setMatch3DProfile(null);
setMatch3DRun(null);
setMatch3DError(null);
setStreamingMatch3DReplyText('');
setIsStreamingMatch3DReply(false);
await match3dFlow.openWorkspace();
}, [
match3dFlow,
setIsStreamingMatch3DReply,
setMatch3DError,
setMatch3DProfile,
setMatch3DRun,
setMatch3DSession,
setStreamingMatch3DReplyText,
]);
const openSquareHoleAgentWorkspace = useCallback(async () => {
setSquareHoleSession(null);
setSquareHoleProfile(null);
@@ -2960,30 +3015,6 @@ export function PlatformEntryFlowShellImpl({
squareHoleFlow,
]);
const openPuzzleAgentWorkspace = useCallback(async () => {
setPuzzleRun(null);
setPuzzleRuntimeAuthMode('default');
setPuzzleOperation(null);
setPuzzleGenerationState(null);
setPuzzleFormDraftPayload(null);
sessionController.setCreationTypeError(null);
setPuzzleCreationError(null);
const nextSession = await puzzleFlow.openWorkspace({});
if (nextSession) {
void refreshPuzzleShelf();
}
}, [puzzleFlow, refreshPuzzleShelf, sessionController]);
const openVisualNovelAgentWorkspace = useCallback(() => {
setVisualNovelWork(null);
setVisualNovelRun(null);
setVisualNovelRuntimeReturnStage('visual-novel-result');
visualNovelFlow.resetTransientState();
enterCreateTab();
setShowCreationTypeModal(false);
setSelectionStage('visual-novel-agent-workspace');
}, [enterCreateTab, setSelectionStage, visualNovelFlow]);
const leaveCreativeAgentWorkspace = useCallback(() => {
const sessionId = creativeAgentSession?.sessionId?.trim();
if (sessionId && creativeAgentSession?.stage !== 'target_ready') {
@@ -3058,6 +3089,85 @@ export function PlatformEntryFlowShellImpl({
[puzzleFlow],
);
const createMatch3DDraftFromForm = useCallback(
async (payload: CreateMatch3DSessionRequest) => {
setMatch3DFormDraftPayload(payload);
setMatch3DGenerationState(null);
setMatch3DSession(null);
setMatch3DProfile(null);
setMatch3DRun(null);
setMatch3DError(null);
setStreamingMatch3DReplyText('');
setIsStreamingMatch3DReply(false);
const nextSession = await match3dFlow.openWorkspace(payload);
if (!nextSession) {
return;
}
await match3dFlow.executeAction(
{ action: 'match3d_compile_draft' },
nextSession,
);
},
[
match3dFlow,
setIsStreamingMatch3DReply,
setMatch3DError,
setMatch3DProfile,
setMatch3DRun,
setMatch3DSession,
setStreamingMatch3DReplyText,
],
);
const createVisualNovelDraftFromForm = useCallback(
async (payload: VisualNovelEntryFormPayload) => {
setVisualNovelFormDraftPayload(payload);
setVisualNovelGenerationStartedAtMs(Date.now());
setVisualNovelGenerationPhase('generating');
setVisualNovelWork(null);
setVisualNovelRun(null);
setVisualNovelRuntimeReturnStage('visual-novel-result');
setVisualNovelError(null);
setIsVisualNovelBusy(true);
setSelectionStage('visual-novel-generating');
try {
const createResponse = await createVisualNovelSession({
sourceMode: payload.sourceMode,
seedText: payload.seedText,
sourceAssetIds: payload.sourceAssetIds,
});
setVisualNovelSession(createResponse.session);
const nextSession = await streamVisualNovelMessage(
createResponse.session.sessionId,
{
clientMessageId: `visual-novel-entry-${Date.now().toString(36)}`,
text: payload.seedText,
},
);
setVisualNovelSession(nextSession);
setVisualNovelGenerationPhase('ready');
setSelectionStage('visual-novel-result');
} catch (error) {
setVisualNovelGenerationPhase('failed');
setVisualNovelError(
resolvePuzzleErrorMessage(error, '生成视觉小说草稿失败。'),
);
} finally {
setIsVisualNovelBusy(false);
}
},
[
resolvePuzzleErrorMessage,
setIsVisualNovelBusy,
setSelectionStage,
setVisualNovelError,
setVisualNovelSession,
],
);
const savePuzzleFormDraft = useCallback(
async (payload: CreatePuzzleAgentSessionRequest) => {
const session = puzzleFlow.session;
@@ -3118,6 +3228,8 @@ export function PlatformEntryFlowShellImpl({
setBigFishError(null);
setMatch3DSession(null);
setMatch3DProfile(null);
setMatch3DFormDraftPayload(null);
setActiveCreationFormType('puzzle');
setMatch3DWorks([]);
setMatch3DGalleryEntries([]);
setMatch3DRun(null);
@@ -3160,6 +3272,9 @@ export function PlatformEntryFlowShellImpl({
setVisualNovelGalleryEntries([]);
setVisualNovelRun(null);
setVisualNovelRuntimeReturnStage('visual-novel-result');
setVisualNovelFormDraftPayload(null);
setVisualNovelGenerationStartedAtMs(null);
setVisualNovelGenerationPhase('generating');
setVisualNovelError(null);
setDeletingCreationWorkId(null);
setClaimingPuzzlePointIncentiveProfileId(null);
@@ -3189,6 +3304,7 @@ export function PlatformEntryFlowShellImpl({
resetAutoSaveTrackingToIdle,
resetRpgSessionViewState,
selectionStage,
setActiveCreationFormType,
setBigFishError,
setIsStreamingMatch3DReply,
setIsStreamingSquareHoleReply,
@@ -3231,9 +3347,10 @@ export function PlatformEntryFlowShellImpl({
}
if (type === 'match3d') {
runProtectedAction(() => {
void openMatch3DAgentWorkspace();
});
enterCreateTab();
setShowCreationTypeModal(false);
setActiveCreationFormType('match3d');
setMatch3DError(null);
return;
}
@@ -3245,28 +3362,34 @@ export function PlatformEntryFlowShellImpl({
}
if (type === 'puzzle') {
runProtectedAction(() => {
void openPuzzleAgentWorkspace();
});
enterCreateTab();
setShowCreationTypeModal(false);
setActiveCreationFormType('puzzle');
setPuzzleCreationError(null);
setPuzzleError(null);
return;
}
if (type === 'visual-novel') {
runProtectedAction(() => {
openVisualNovelAgentWorkspace();
});
enterCreateTab();
setShowCreationTypeModal(false);
setActiveCreationFormType('visual-novel');
setVisualNovelError(null);
return;
}
},
[
openBigFishAgentWorkspace,
openMatch3DAgentWorkspace,
openPuzzleAgentWorkspace,
openSquareHoleAgentWorkspace,
openVisualNovelAgentWorkspace,
enterCreateTab,
openSquareHoleAgentWorkspace,
prepareCreationLaunch,
runProtectedAction,
sessionController,
setActiveCreationFormType,
setMatch3DError,
setPuzzleCreationError,
setPuzzleError,
setVisualNovelError,
],
);
@@ -3281,9 +3404,11 @@ export function PlatformEntryFlowShellImpl({
const leaveMatch3DFlow = useCallback(() => {
setMatch3DRun(null);
setMatch3DFormDraftPayload(null);
setMatch3DGenerationState(null);
setMatch3DRuntimeReturnStage('match3d-result');
match3dFlow.leaveFlow();
}, [match3dFlow]);
}, [match3dFlow, setMatch3DFormDraftPayload]);
const leaveSquareHoleFlow = useCallback(() => {
setSquareHoleRun(null);
@@ -3307,20 +3432,12 @@ export function PlatformEntryFlowShellImpl({
setVisualNovelWork(null);
setVisualNovelRun(null);
setVisualNovelRuntimeReturnStage('visual-novel-result');
setVisualNovelFormDraftPayload(null);
setVisualNovelGenerationStartedAtMs(null);
setVisualNovelGenerationPhase('generating');
visualNovelFlow.leaveFlow();
}, [visualNovelFlow]);
const openVisualNovelResult = useCallback(
(session: VisualNovelAgentSessionSnapshot) => {
setVisualNovelSession(session);
setVisualNovelWork(null);
setVisualNovelRun(null);
setVisualNovelRuntimeReturnStage('visual-novel-result');
setSelectionStage('visual-novel-result');
},
[setSelectionStage, setVisualNovelSession],
);
const saveVisualNovelDraft = useCallback(
async (draft: VisualNovelResultDraft) => {
const currentSession = visualNovelSession;
@@ -3716,8 +3833,6 @@ export function PlatformEntryFlowShellImpl({
const submitBigFishMessage = bigFishFlow.submitMessage;
const submitMatch3DMessage = match3dFlow.submitMessage;
const submitSquareHoleMessage = squareHoleFlow.submitMessage;
const submitPuzzleMessage = puzzleFlow.submitMessage;
@@ -3728,6 +3843,21 @@ export function PlatformEntryFlowShellImpl({
const executeSquareHoleAction = squareHoleFlow.executeAction;
const retryMatch3DDraftGeneration = useCallback(() => {
if (match3dFormDraftPayload) {
void createMatch3DDraftFromForm(match3dFormDraftPayload);
return;
}
void executeMatch3DAction({
action: 'match3d_compile_draft',
});
}, [
createMatch3DDraftFromForm,
executeMatch3DAction,
match3dFormDraftPayload,
]);
const retrySquareHoleAssetGeneration = useCallback(() => {
const session = squareHoleSession;
if (!session?.draft?.profileId) {
@@ -3744,6 +3874,35 @@ export function PlatformEntryFlowShellImpl({
const executePuzzleAction = puzzleFlow.executeAction;
const executePuzzleBackgroundAction = useCallback(
async (payload: PuzzleAgentActionRequest) => {
const targetSession = puzzleFlow.session;
if (!targetSession) {
return;
}
const formPayload = buildPuzzleFormPayloadFromAction(payload);
if (formPayload) {
setPuzzleFormDraftPayload(formPayload);
}
setPuzzleError(null);
try {
const response = await executePuzzleAgentAction(
targetSession.sessionId,
payload,
);
setPuzzleOperation(response.operation);
puzzleFlow.setSession(response.session);
} catch (error) {
setPuzzleError(
resolvePuzzleErrorMessage(error, '执行拼图操作失败。'),
);
}
},
[puzzleFlow, resolvePuzzleErrorMessage, setPuzzleError],
);
const retryPuzzleDraftGeneration = useCallback(() => {
if (puzzleFormDraftPayload) {
void createPuzzleDraftFromForm(puzzleFormDraftPayload);
@@ -3755,6 +3914,19 @@ export function PlatformEntryFlowShellImpl({
);
}, [createPuzzleDraftFromForm, executePuzzleAction, puzzleFormDraftPayload]);
const retryVisualNovelDraftGeneration = useCallback(() => {
if (!visualNovelFormDraftPayload) {
setSelectionStage('visual-novel-agent-workspace');
return;
}
void createVisualNovelDraftFromForm(visualNovelFormDraftPayload);
}, [
createVisualNovelDraftFromForm,
setSelectionStage,
visualNovelFormDraftPayload,
]);
const executePuzzleWorkspaceAction = useCallback(
(payload: PuzzleAgentActionRequest) => {
if (
@@ -3768,9 +3940,19 @@ export function PlatformEntryFlowShellImpl({
}
}
if (payload.action === 'generate_puzzle_images') {
void executePuzzleBackgroundAction(payload);
return;
}
void executePuzzleAction(payload);
},
[createPuzzleDraftFromForm, executePuzzleAction, puzzleFlow.session],
[
createPuzzleDraftFromForm,
executePuzzleAction,
executePuzzleBackgroundAction,
puzzleFlow.session,
],
);
const openCreativeAgentTarget = useCallback(async () => {
@@ -5098,6 +5280,7 @@ export function PlatformEntryFlowShellImpl({
: '删除后会从你的作品列表中移除。',
run: () => {
setDeletingCreationWorkId(work.workId);
setMatch3DFormDraftPayload(null);
setMatch3DError(null);
void deleteMatch3DWork(work.profileId)
@@ -5121,6 +5304,7 @@ export function PlatformEntryFlowShellImpl({
refreshMatch3DGallery,
requestDeleteCreationWork,
resolveMatch3DErrorMessage,
setMatch3DFormDraftPayload,
setMatch3DError,
],
);
@@ -5805,6 +5989,8 @@ export function PlatformEntryFlowShellImpl({
return;
}
setMatch3DFormDraftPayload(null);
try {
const { item: profile } = await getMatch3DWorkDetail(item.profileId);
setMatch3DProfile(profile);
@@ -5820,6 +6006,7 @@ export function PlatformEntryFlowShellImpl({
openPublicWorkDetail,
refreshMatch3DShelf,
resolveMatch3DErrorMessage,
setMatch3DFormDraftPayload,
setMatch3DError,
],
);
@@ -7424,7 +7611,7 @@ export function PlatformEntryFlowShellImpl({
aria-label="选择模板"
>
{getVisiblePlatformCreationTypes().map((item) => {
const selected = item.id === 'puzzle';
const selected = item.id === activeCreationFormType;
const disabled =
item.locked ||
sessionController.isCreatingAgentSession ||
@@ -7446,7 +7633,7 @@ export function PlatformEntryFlowShellImpl({
aria-selected={selected}
disabled={disabled}
onClick={() => {
if (item.id === 'puzzle') {
if (item.id === activeCreationFormType) {
return;
}
handleCreationHubCreateType(item.id);
@@ -7496,31 +7683,76 @@ export function PlatformEntryFlowShellImpl({
</div>
<div className="mt-3 min-h-0 flex-1 overflow-hidden">
<Suspense fallback={<LazyPanelFallback label="正在加载拼图创作..." />}>
<PuzzleAgentWorkspace
session={puzzleSession}
isBusy={isPuzzleBusy || isStreamingPuzzleReply}
error={puzzleError}
onBack={leavePuzzleFlow}
onSubmitMessage={(payload) => {
void submitPuzzleMessage(payload);
}}
onExecuteAction={(payload) => {
executePuzzleWorkspaceAction(payload);
}}
initialFormPayload={puzzleFormDraftPayload}
onCreateFromForm={(payload) => {
runProtectedAction(() => {
void createPuzzleDraftFromForm(payload);
});
}}
onAutoSaveForm={(payload) => {
void savePuzzleFormDraft(payload);
}}
showBackButton={false}
title={null}
/>
</Suspense>
{activeCreationFormType === 'match3d' ? (
<Suspense
fallback={
<LazyPanelFallback label="正在加载抓大鹅创作..." />
}
>
<Match3DAgentWorkspace
session={match3dSession}
isBusy={isMatch3DBusy || isStreamingMatch3DReply}
error={match3dError}
onBack={leaveMatch3DFlow}
onExecuteAction={(payload) => {
void executeMatch3DAction(payload);
}}
initialFormPayload={match3dFormDraftPayload}
onCreateFromForm={(payload) => {
runProtectedAction(() => {
void createMatch3DDraftFromForm(payload);
});
}}
showBackButton={false}
title={null}
/>
</Suspense>
) : activeCreationFormType === 'visual-novel' ? (
<Suspense
fallback={<LazyPanelFallback label="正在加载视觉小说创作..." />}
>
<VisualNovelAgentWorkspace
session={null}
isBusy={isVisualNovelBusy || isVisualNovelStreamingReply}
error={visualNovelError}
onBack={leaveVisualNovelFlow}
initialFormPayload={visualNovelFormDraftPayload}
onCreateFromForm={(payload) => {
runProtectedAction(() => {
void createVisualNovelDraftFromForm(payload);
});
}}
showBackButton={false}
title={null}
/>
</Suspense>
) : (
<Suspense fallback={<LazyPanelFallback label="正在加载拼图创作..." />}>
<PuzzleAgentWorkspace
session={puzzleSession}
isBusy={isPuzzleBusy || isStreamingPuzzleReply}
error={puzzleError}
onBack={leavePuzzleFlow}
onSubmitMessage={(payload) => {
void submitPuzzleMessage(payload);
}}
onExecuteAction={(payload) => {
executePuzzleWorkspaceAction(payload);
}}
initialFormPayload={puzzleFormDraftPayload}
onCreateFromForm={(payload) => {
runProtectedAction(() => {
void createPuzzleDraftFromForm(payload);
});
}}
onAutoSaveForm={(payload) => {
void savePuzzleFormDraft(payload);
}}
showBackButton={false}
title={null}
/>
</Suspense>
)}
</div>
</div>
</div>
@@ -7601,6 +7833,12 @@ export function PlatformEntryFlowShellImpl({
onSelectPreviousRecommendEntry={() =>
selectAdjacentRecommendRuntimeEntry(-1)
}
onLikeRecommendEntry={(entry) => {
likePublicWork(entry);
}}
onRemixRecommendEntry={(entry) => {
remixPublicWork(entry);
}}
onOpenLibraryDetail={(entry) => {
runProtectedAction(() => {
void detailNavigation.openLibraryDetail(entry);
@@ -7984,7 +8222,7 @@ export function PlatformEntryFlowShellImpl({
</motion.div>
)}
{selectionStage === 'match3d-agent-workspace' && (
{selectionStage === 'match3d-agent-workspace' && match3dSession && (
<motion.div
key="match3d-agent-workspace"
initial={{ opacity: 0, y: 12 }}
@@ -7999,14 +8237,9 @@ export function PlatformEntryFlowShellImpl({
>
<Match3DAgentWorkspace
session={match3dSession}
streamingReplyText={streamingMatch3DReplyText}
isStreamingReply={isStreamingMatch3DReply}
isBusy={isMatch3DBusy || isStreamingMatch3DReply}
error={match3dError}
onBack={leaveMatch3DFlow}
onSubmitMessage={(payload) => {
void submitMatch3DMessage(payload);
}}
onExecuteAction={(payload) => {
void executeMatch3DAction(payload);
}}
@@ -8015,6 +8248,52 @@ export function PlatformEntryFlowShellImpl({
</motion.div>
)}
{selectionStage === 'match3d-generating' && (
<motion.div
key="match3d-generating"
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="正在加载抓大鹅生成面板..." />}
>
<CustomWorldGenerationView
settingText={
match3dSession?.lastAssistantReply ??
'正在生成本局抓大鹅物品素材。'
}
anchorEntries={buildMatch3DGenerationAnchorEntries(
match3dSession,
match3dFormDraftPayload,
)}
progress={buildMiniGameDraftGenerationProgress(
match3dGenerationState,
miniGameGenerationProgressNowMs,
)}
isGenerating={isMatch3DBusy}
error={match3dError}
onBack={leaveMatch3DFlow}
onEditSetting={() => {
setSelectionStage('match3d-agent-workspace');
}}
onRetry={retryMatch3DDraftGeneration}
onInterrupt={undefined}
backLabel="返回创作中心"
settingActionLabel={null}
retryLabel="重新生成草稿"
settingTitle="当前抓大鹅信息"
settingDescription={null}
progressTitle="抓大鹅草稿生成进度"
activeBadgeLabel="素材生成中"
pausedBadgeLabel="素材生成已暂停"
idleBadgeLabel="等待返回工作区"
/>
</Suspense>
</motion.div>
)}
{selectionStage === 'match3d-result' && match3dSession?.draft && (
<motion.div
key="match3d-result"
@@ -8467,7 +8746,7 @@ export function PlatformEntryFlowShellImpl({
)}
progress={buildMiniGameDraftGenerationProgress(
puzzleGenerationState,
puzzleGenerationProgressNowMs,
miniGameGenerationProgressNowMs,
)}
isGenerating={isPuzzleBusy}
error={puzzleError}
@@ -8514,7 +8793,7 @@ export function PlatformEntryFlowShellImpl({
error={puzzleError}
onBack={leavePuzzleFlow}
onExecuteAction={(payload) => {
void executePuzzleAction(payload);
executePuzzleWorkspaceAction(payload);
}}
onStartTestRun={startPuzzleTestRunFromDraft}
creativeDraftEdit={
@@ -8546,25 +8825,67 @@ export function PlatformEntryFlowShellImpl({
session={visualNovelSession}
isBusy={isVisualNovelBusy || isVisualNovelStreamingReply}
error={visualNovelError}
streamingReplyText={visualNovelStreamingReplyText}
onBack={leaveVisualNovelFlow}
onCreateSession={(payload) => {
void visualNovelFlow.openWorkspace(payload);
initialFormPayload={visualNovelFormDraftPayload}
onCreateFromForm={(payload) => {
void createVisualNovelDraftFromForm(payload);
}}
onSubmitMessage={(payload) => {
void visualNovelFlow.submitMessage(payload);
/>
</Suspense>
</motion.div>
)}
{selectionStage === 'visual-novel-generating' && (
<motion.div
key="visual-novel-generating"
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="正在加载视觉小说生成面板..." />}
>
<CustomWorldGenerationView
settingText={
visualNovelFormDraftPayload?.seedText ??
visualNovelSession?.messages.find(
(message) => message.role === 'user',
)?.text ??
'正在整理当前视觉小说草稿。'
}
anchorEntries={buildVisualNovelEntryGenerationAnchorEntries(
visualNovelFormDraftPayload,
)}
progress={buildVisualNovelEntryGenerationProgress(
visualNovelGenerationStartedAtMs,
visualNovelGenerationPhase,
miniGameGenerationProgressNowMs,
)}
isGenerating={isVisualNovelBusy || isVisualNovelStreamingReply}
error={visualNovelError}
onBack={leaveVisualNovelFlow}
onEditSetting={() => {
setSelectionStage('visual-novel-agent-workspace');
}}
onExecuteAction={(payload) => {
void visualNovelFlow.executeAction(payload);
}}
onOpenResult={openVisualNovelResult}
onRetry={retryVisualNovelDraftGeneration}
onInterrupt={undefined}
backLabel="返回创作中心"
settingActionLabel={null}
retryLabel="重新生成草稿"
settingTitle="当前视觉小说信息"
settingDescription={null}
progressTitle="视觉小说草稿生成进度"
activeBadgeLabel="草稿生成中"
pausedBadgeLabel="草稿生成已暂停"
idleBadgeLabel="等待返回工作区"
/>
</Suspense>
</motion.div>
)}
{selectionStage === 'visual-novel-result' &&
visualNovelSession?.draft && (
(visualNovelSession?.draft || visualNovelWork?.draft) && (
<motion.div
key="visual-novel-result"
initial={{ opacity: 0, y: 12 }}
@@ -8576,7 +8897,7 @@ export function PlatformEntryFlowShellImpl({
fallback={<LazyPanelFallback label="正在加载视觉小说结果..." />}
>
<VisualNovelResultView
draft={visualNovelWork?.draft ?? visualNovelSession.draft}
draft={visualNovelWork?.draft ?? visualNovelSession?.draft}
isBusy={isVisualNovelBusy}
error={visualNovelError}
onBack={() => {
@@ -9049,9 +9370,7 @@ export function PlatformEntryFlowShellImpl({
});
}}
onSelectMatch3D={() => {
runProtectedAction(() => {
void openMatch3DAgentWorkspace();
});
handleCreationHubCreateType('match3d');
}}
onSelectSquareHole={() => {
runProtectedAction(() => {
@@ -9059,9 +9378,7 @@ export function PlatformEntryFlowShellImpl({
});
}}
onSelectPuzzle={() => {
runProtectedAction(() => {
void openPuzzleAgentWorkspace();
});
handleCreationHubCreateType('puzzle');
}}
onSelectCreativeAgent={() => {
runProtectedAction(() => {
@@ -9069,9 +9386,7 @@ export function PlatformEntryFlowShellImpl({
});
}}
onSelectVisualNovel={() => {
runProtectedAction(() => {
openVisualNovelAgentWorkspace();
});
handleCreationHubCreateType('visual-novel');
}}
/>
<PublishShareModal

View File

@@ -24,6 +24,7 @@ export type SelectionStage =
| 'big-fish-result'
| 'big-fish-runtime'
| 'match3d-agent-workspace'
| 'match3d-generating'
| 'match3d-result'
| 'match3d-runtime'
| 'square-hole-agent-workspace'
@@ -32,6 +33,7 @@ export type SelectionStage =
| 'square-hole-runtime'
| 'creative-agent-workspace'
| 'visual-novel-agent-workspace'
| 'visual-novel-generating'
| 'visual-novel-result'
| 'visual-novel-gallery-detail'
| 'visual-novel-runtime'

View File

@@ -1,11 +1,37 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient';
import { PuzzleAgentWorkspace } from './PuzzleAgentWorkspace';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({
puzzleAssetClient: {
listHistoryAssets: vi.fn(),
},
}));
const baseSession: PuzzleAgentSessionSnapshot = {
sessionId: 'puzzle-session-1',
currentTurn: 3,
@@ -70,6 +96,7 @@ afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
vi.clearAllMocks();
});
function stubReferenceImageUpload(dataUrl: string, width = 512, height = 512) {
@@ -221,6 +248,66 @@ test('puzzle workspace keeps the reference image upload as a primary panel', ()
);
});
test('puzzle workspace selects a history image from the upload card', async () => {
const onCreateFromForm = vi.fn();
vi.mocked(puzzleAssetClient.listHistoryAssets).mockResolvedValue([
{
assetObjectId: 'asset-history-1',
assetKind: 'puzzle_cover_image',
imageSrc: '/generated-puzzle-assets/history/image.png',
ownerUserId: 'user-1',
ownerLabel: '账号 user-1',
profileId: null,
entityId: 'puzzle-session-1',
createdAt: '2026-04-27T10:00:00.000Z',
updatedAt: '2026-04-27T10:00:00.000Z',
},
]);
render(
<PuzzleAgentWorkspace
session={null}
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
const historyButton = screen.getByRole('button', { name: '选择历史图片' });
expect(historyButton.closest('.puzzle-image-upload-card')).toBeTruthy();
expect(screen.getByText('历史').closest('.puzzle-image-upload-card')).toBeTruthy();
fireEvent.click(historyButton);
const picker = await screen.findByRole('dialog', {
name: '选择历史图片',
});
fireEvent.click(
await within(picker).findByRole('button', { name: / user-1/u }),
);
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '选择历史图片' })).toBeNull();
});
expect(screen.getByAltText('拼图图片')).toHaveProperty(
'src',
expect.stringContaining('/generated-puzzle-assets/history/image.png'),
);
fireEvent.change(screen.getByLabelText('画面AI重绘要求提示词'), {
target: { value: '保留历史图里的主体,改成晴天花园。' },
});
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '保留历史图里的主体,改成晴天花园。',
pictureDescription: '保留历史图里的主体,改成晴天花园。',
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
imageModel: 'gpt-image-2',
aiRedraw: true,
});
});
test('puzzle upload card stays light in light theme', () => {
const onCreateFromForm = vi.fn();
const { container } = render(
@@ -237,6 +324,9 @@ test('puzzle upload card stays light in light theme', () => {
const uploadLabel = screen.getByText('点击上传拼图图片');
expect(uploadLabel).toBeTruthy();
expect(uploadLabel.closest('.puzzle-image-upload-card')).toBeTruthy();
expect(uploadLabel.className).not.toContain('rounded-full');
expect(uploadLabel.className).not.toContain('bg-white/94');
expect(uploadLabel.className).not.toContain('border');
expect(screen.queryByText('AI重绘')).toBeNull();
expect(container.querySelector('.puzzle-image-upload-card')?.className).toContain(
'bg-white/90',

View File

@@ -1,4 +1,11 @@
import { ArrowLeft, ImagePlus, Loader2, Sparkles, Trash2 } from 'lucide-react';
import {
ArrowLeft,
History,
ImagePlus,
Loader2,
Sparkles,
Trash2,
} from 'lucide-react';
import {
type ChangeEvent,
type CSSProperties,
@@ -20,6 +27,7 @@ import {
isPuzzleReferenceImageSquare,
readPuzzleReferenceImageForUpload,
} from '../../services/puzzleReferenceImage';
import PuzzleHistoryAssetPickerDialog from './PuzzleHistoryAssetPickerDialog';
import {
normalizePuzzleImageModel,
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
@@ -535,6 +543,7 @@ export function PuzzleAgentWorkspace({
null,
);
const [cropState, setCropState] = useState<PuzzleImageCropState | null>(null);
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] =
useState(false);
const previousSessionIdRef = useRef<string | null>(
@@ -565,6 +574,7 @@ export function PuzzleAgentWorkspace({
setFormState(resolveInitialFormState(session, initialFormPayload));
setReferenceImageError(null);
setCropState(null);
setIsHistoryPickerOpen(false);
setIsRemoveImageConfirmOpen(false);
}, [initialFormPayload, session]);
@@ -865,6 +875,17 @@ export function PuzzleAgentWorkspace({
</span>
)}
<div className="absolute inset-0 z-[1] bg-[linear-gradient(180deg,rgba(255,255,255,0.12)_0%,rgba(255,255,255,0.04)_42%,rgba(255,255,255,0.18)_100%)] pointer-events-none" />
<button
type="button"
disabled={isBusy}
onClick={() => setIsHistoryPickerOpen(true)}
className={`absolute bottom-3 right-3 z-10 inline-flex items-center gap-1.5 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-[11px] font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-label="选择历史图片"
title="选择历史图片"
>
<History className="h-3.5 w-3.5" />
<span></span>
</button>
{formState.referenceImageSrc ? (
<label className="absolute bottom-3 left-3 z-10 inline-flex cursor-pointer items-center gap-2 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-xs font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur">
<span>AI重绘</span>
@@ -910,7 +931,7 @@ export function PuzzleAgentWorkspace({
) : (
<label
htmlFor="puzzle-image-upload-input"
className={`absolute bottom-3 left-1/2 z-10 inline-flex min-h-10 -translate-x-1/2 items-center justify-center whitespace-nowrap rounded-full border border-white/80 bg-white/94 px-4 text-sm font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] ${isBusy ? 'cursor-not-allowed' : 'cursor-pointer'}`}
className={`absolute bottom-9 left-1/2 z-10 -translate-x-1/2 whitespace-nowrap text-center text-sm font-black text-[var(--platform-text-strong)] drop-shadow-[0_1px_0_rgba(255,255,255,0.82)] transition hover:text-[#ff4056] sm:bottom-10 ${isBusy ? 'cursor-not-allowed opacity-55' : 'cursor-pointer'}`}
>
</label>
@@ -1003,6 +1024,22 @@ export function PuzzleAgentWorkspace({
}}
/>
) : null}
{isHistoryPickerOpen ? (
<PuzzleHistoryAssetPickerDialog
isBusy={isBusy}
onClose={() => setIsHistoryPickerOpen(false)}
onSelect={(asset) => {
setFormState((current) => ({
...current,
referenceImageSrc: asset.imageSrc,
referenceImageLabel: `历史素材 · ${asset.ownerLabel || '未记录账号'}`,
}));
setReferenceImageError(null);
setIsHistoryPickerOpen(false);
setIsRemoveImageConfirmOpen(false);
}}
/>
) : null}
{isRemoveImageConfirmOpen ? (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div

View File

@@ -0,0 +1,164 @@
import { X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import {
puzzleAssetClient,
type PuzzleHistoryAsset,
} from '../../services/puzzle-works/puzzleAssetClient';
import { useAuthUi } from '../auth/AuthUiContext';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type PuzzleHistoryAssetPickerDialogProps = {
isBusy: boolean;
onClose: () => void;
onSelect: (asset: PuzzleHistoryAsset) => void;
};
function formatHistoryAssetDate(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '未知时间';
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date);
}
export function PuzzleHistoryAssetPickerDialog({
isBusy,
onClose,
onSelect,
}: PuzzleHistoryAssetPickerDialogProps) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
const [assets, setAssets] = useState<PuzzleHistoryAsset[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setIsLoading(true);
setError(null);
puzzleAssetClient
.listHistoryAssets({ limit: 120 })
.then((nextAssets) => {
if (!cancelled) {
setAssets(nextAssets);
}
})
.catch((loadError) => {
if (!cancelled) {
setError(
loadError instanceof Error
? loadError.message
: '历史拼图素材读取失败。',
);
}
})
.finally(() => {
if (!cancelled) {
setIsLoading(false);
}
});
return () => {
cancelled = true;
};
}, []);
if (typeof document === 'undefined') {
return null;
}
return createPortal(
<div
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[145] 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(92vh,46rem)] w-full max-w-5xl 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 className="text-base font-semibold text-[var(--platform-text-strong)]">
</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">
{error ? (
<div className="platform-banner platform-banner--danger text-sm leading-6">
{error}
</div>
) : null}
{isLoading ? (
<div className="flex min-h-[14rem] items-center justify-center rounded-[1.35rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/52 px-6 text-center text-sm text-[var(--platform-text-base)]">
...
</div>
) : null}
{!isLoading && !error && assets.length <= 0 ? (
<div className="flex min-h-[14rem] items-center justify-center rounded-[1.35rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/52 px-6 text-center text-sm text-[var(--platform-text-base)]">
</div>
) : null}
{!isLoading && assets.length > 0 ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{assets.map((asset) => (
<button
key={asset.assetObjectId}
type="button"
disabled={isBusy}
onClick={() => onSelect(asset)}
className={`overflow-hidden rounded-[1.25rem] border bg-white/82 text-left transition hover:border-amber-300/70 hover:bg-white ${isBusy ? 'cursor-not-allowed opacity-55' : 'border-[var(--platform-subpanel-border)]'}`}
>
<div className="aspect-square overflow-hidden bg-[var(--platform-subpanel-fill)]">
<ResolvedAssetImage
src={asset.imageSrc}
alt={asset.ownerLabel || '历史拼图素材'}
className="h-full w-full object-cover"
/>
</div>
<div className="space-y-1 px-4 py-4">
<div className="truncate text-sm font-black text-[var(--platform-text-strong)]">
{asset.ownerLabel || '未记录账号'}
</div>
<div className="text-xs leading-5 text-[var(--platform-text-base)]">
{formatHistoryAssetDate(asset.createdAt)}
</div>
</div>
</button>
))}
</div>
) : null}
</div>
</div>
</div>,
document.body,
);
}
export default PuzzleHistoryAssetPickerDialog;

View File

@@ -249,6 +249,7 @@ describe('PuzzleResultView', () => {
promptText: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: undefined,
imageModel: 'gpt-image-2',
aiRedraw: true,
candidateCount: 1,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
@@ -263,8 +264,15 @@ describe('PuzzleResultView', () => {
levelId: 'puzzle-level-1',
levelName: '暖灯猫街',
pictureDescription: '一只猫在雨夜灯牌下回头。',
generationStatus: 'generating',
}),
]);
expect(within(dialog).getByText('预计剩余 90 秒')).toBeTruthy();
expect(
within(dialog).queryByPlaceholderText('参考图链接或资产ID'),
).toBeNull();
const levelList = screen.getByLabelText('拼图关卡列表');
expect(within(levelList).getAllByText('生成中').length).toBeGreaterThan(0);
const levelNameInput = within(dialog).getByLabelText('关卡名称');
const formalImageTitle = within(dialog).getByText('画面图');
@@ -314,7 +322,10 @@ describe('PuzzleResultView', () => {
within(dialog).getByRole('button', { name: //u }),
).toBeTruthy();
expect(within(dialog).getByText('消耗2光点')).toBeTruthy();
expect(within(dialog).queryByText('画面图')).toBeNull();
expect(
within(dialog).getByText('等待时间可以制作更多关卡哦~'),
).toBeTruthy();
expect(within(dialog).getByText('画面图')).toBeTruthy();
expect(
within(dialog).queryByRole('button', { name: //u }),
).toBeNull();
@@ -385,6 +396,7 @@ describe('PuzzleResultView', () => {
promptText: '新关卡里有一座发光钟楼。',
referenceImageSrc: undefined,
imageModel: 'gpt-image-2',
aiRedraw: true,
candidateCount: 1,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
@@ -400,10 +412,90 @@ describe('PuzzleResultView', () => {
levelId: 'puzzle-level-1775000000000-2',
levelName: '',
pictureDescription: '新关卡里有一座发光钟楼。',
generationStatus: 'generating',
}),
]);
});
test('keeps generation progress visible after closing and reopening level dialog', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByText('雨夜猫街'));
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗光点' })).getByRole(
'button',
{ name: '确定' },
),
);
fireEvent.click(screen.getByLabelText('关闭'));
expect(
within(screen.getByLabelText('拼图关卡列表')).getAllByText('生成中')
.length,
).toBeGreaterThan(0);
fireEvent.click(screen.getByText('雨夜猫街'));
const reopenedDialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(
within(reopenedDialog).getByRole('progressbar', { name: '画面生成进度' }),
).toBeTruthy();
expect(within(reopenedDialog).getByText('预计剩余 90 秒')).toBeTruthy();
});
test('allows parallel draft editing while a level image is generating but blocks publish', () => {
const onExecuteAction = vi.fn();
const onStartTestRun = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.click(screen.getByText('雨夜猫街'));
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗光点' })).getByRole(
'button',
{ name: '确定' },
),
);
fireEvent.change(screen.getByLabelText('关卡名称'), {
target: { value: '继续编辑的猫街' },
});
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
levelName: '继续编辑的猫街',
}),
);
fireEvent.click(screen.getByLabelText('关闭'));
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy();
fireEvent.click(screen.getByLabelText('关闭'));
fireEvent.click(screen.getByRole('button', { name: //u }));
const publishDialog = screen.getByRole('dialog', { name: '发布拼图作品' });
expect(within(publishDialog).getByText('还有关卡画面正在生成。')).toBeTruthy();
expect(
within(publishDialog).getByRole('button', { name: '发布到广场' }),
).toHaveProperty('disabled', true);
});
test('publishes with work info and serialized levels', () => {
const onExecuteAction = vi.fn();
@@ -552,19 +644,26 @@ describe('PuzzleResultView', () => {
);
fireEvent.click(screen.getByText('雨夜猫街'));
fireEvent.click(screen.getByLabelText('从历史拼图素材库选择'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
const uploadInput = within(dialog).getByLabelText('上传参考图', {
selector: 'input',
});
expect(uploadInput.closest('.platform-subpanel')).toBeTruthy();
const historyButton = within(dialog).getByRole('button', {
name: '选择历史图片',
});
expect(within(historyButton).getByText('历史')).toBeTruthy();
fireEvent.click(historyButton);
const picker = await screen.findByRole('dialog', {
name: '选择历史拼图素材',
name: '选择历史图片',
});
fireEvent.click(
await within(picker).findByRole('button', { name: / user-1/u }),
);
await waitFor(() => {
expect(
screen.queryByRole('dialog', { name: '选择历史拼图素材' }),
).toBeNull();
expect(screen.queryByRole('dialog', { name: '选择历史图片' })).toBeNull();
});
fireEvent.click(screen.getByRole('button', { name: //u }));
@@ -580,6 +679,7 @@ describe('PuzzleResultView', () => {
promptText: '屋檐下的猫与暖灯街角。',
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
imageModel: 'gpt-image-2',
aiRedraw: true,
candidateCount: 1,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
@@ -624,8 +724,10 @@ describe('PuzzleResultView', () => {
expect.objectContaining({
action: 'generate_puzzle_images',
referenceImageSrc: '/generated-puzzle-assets/history/saved-reference.png',
aiRedraw: true,
}),
);
expect(screen.queryByPlaceholderText('参考图链接或资产ID')).toBeNull();
});
test('passes the selected image model when regenerating a level image', () => {

View File

@@ -1,8 +1,8 @@
import {
ArrowLeft,
CheckCircle2,
History,
ImagePlus,
Images,
Loader2,
MessageSquareText,
Play,
@@ -22,12 +22,9 @@ import type {
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { updatePuzzleWork } from '../../services/puzzle-works';
import {
puzzleAssetClient,
type PuzzleHistoryAsset,
} from '../../services/puzzle-works/puzzleAssetClient';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { useAuthUi } from '../auth/AuthUiContext';
import PuzzleHistoryAssetPickerDialog from '../puzzle-agent/PuzzleHistoryAssetPickerDialog';
import {
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
type PuzzleImageModelId,
@@ -67,7 +64,46 @@ const PUZZLE_MIN_THEME_TAG_COUNT = 3;
const PUZZLE_MAX_THEME_TAG_COUNT = 6;
const PUZZLE_AUTOSAVE_DEBOUNCE_MS = 600;
const PUZZLE_IMAGE_GENERATION_POINT_COST = 2;
const PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS = 30;
const PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS = 90;
type PuzzleLevelGenerationRuntime = {
startedAtMs: number;
estimateSeconds: number;
};
function resolvePuzzleLevelGenerationProgress(
level: PuzzleDraftLevel,
runtime: PuzzleLevelGenerationRuntime | null,
nowMs: number,
) {
if (level.generationStatus !== 'generating') {
return {
isGenerating: false,
progressPercent: 0,
secondsLeft: 0,
};
}
const estimateSeconds =
runtime?.estimateSeconds ?? PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS;
const elapsedSeconds = runtime
? Math.max(0, Math.floor((nowMs - runtime.startedAtMs) / 1000))
: 0;
const secondsLeft = Math.max(0, estimateSeconds - elapsedSeconds);
const progressPercent = Math.min(
96,
Math.max(
6,
Math.round(((estimateSeconds - secondsLeft) / estimateSeconds) * 100),
),
);
return {
isGenerating: true,
progressPercent,
secondsLeft,
};
}
function normalizeThemeTagInput(value: string) {
return [
@@ -163,6 +199,61 @@ function createDraftEditState(draft: PuzzleResultDraft): DraftEditState {
};
}
function mergeDraftEditStateWithIncomingState(
currentState: DraftEditState | null,
incomingState: DraftEditState,
): DraftEditState {
if (!currentState) {
return incomingState;
}
const incomingLevelsById = new Map(
incomingState.levels.map((level) => [level.levelId, level]),
);
const shouldPreserveLocalEdits = currentState.levels.some((level) => {
const incomingLevel = incomingLevelsById.get(level.levelId);
return (
level.generationStatus === 'generating' &&
Boolean(incomingLevel) &&
incomingLevel?.generationStatus !== 'generating'
);
});
if (!shouldPreserveLocalEdits) {
return incomingState;
}
const mergedLevels = currentState.levels.map((level) => {
const incomingLevel = incomingLevelsById.get(level.levelId);
if (
!incomingLevel ||
level.generationStatus !== 'generating' ||
incomingLevel.generationStatus === 'generating'
) {
return level;
}
return {
...level,
candidates: incomingLevel.candidates,
selectedCandidateId: incomingLevel.selectedCandidateId,
coverImageSrc: incomingLevel.coverImageSrc,
coverAssetId: incomingLevel.coverAssetId,
pictureReference: incomingLevel.pictureReference ?? level.pictureReference,
generationStatus: incomingLevel.generationStatus || 'ready',
};
});
const mergedLevelIds = new Set(mergedLevels.map((level) => level.levelId));
const appendedIncomingLevels = incomingState.levels.filter(
(level) => !mergedLevelIds.has(level.levelId),
);
return {
...currentState,
levels: [...mergedLevels, ...appendedIncomingLevels],
};
}
function createBlankPuzzleLevel(
existingLevels: PuzzleDraftLevel[],
): PuzzleDraftLevel {
@@ -180,19 +271,6 @@ function createBlankPuzzleLevel(
};
}
function formatHistoryAssetDate(value: string) {
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 buildPublishReady(
session: PuzzleAgentSessionSnapshot,
editState: DraftEditState,
@@ -209,6 +287,9 @@ function buildPublishReady(
)
.map((entry) => entry.message) ?? [];
const levels = editState.levels;
const hasGeneratingLevel = levels.some(
(level) => level.generationStatus === 'generating',
);
const blockers = [
...(session.resultPreview ? [] : ['等待结果页草稿完成后再发布。']),
...preservedBlockers,
@@ -226,7 +307,11 @@ function buildPublishReady(
...(resolveLevelFormalImageSrc(level)
? []
: [`${index + 1}关缺少正式图。`]),
...(level.generationStatus === 'generating'
? [`${index + 1}关画面正在生成。`]
: []),
]),
...(hasGeneratingLevel ? ['还有关卡画面正在生成。'] : []),
];
return {
@@ -462,142 +547,10 @@ function PuzzleThemeTagEditor({
);
}
function PuzzleHistoryAssetPickerDialog({
isBusy,
onClose,
onSelect,
}: {
isBusy: boolean;
onClose: () => void;
onSelect: (asset: PuzzleHistoryAsset) => void;
}) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
const [assets, setAssets] = useState<PuzzleHistoryAsset[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setIsLoading(true);
setError(null);
puzzleAssetClient
.listHistoryAssets({ limit: 120 })
.then((nextAssets) => {
if (!cancelled) {
setAssets(nextAssets);
}
})
.catch((loadError) => {
if (!cancelled) {
setError(
loadError instanceof Error
? loadError.message
: '历史拼图素材读取失败。',
);
}
})
.finally(() => {
if (!cancelled) {
setIsLoading(false);
}
});
return () => {
cancelled = true;
};
}, []);
if (typeof document === 'undefined') {
return null;
}
return createPortal(
<div
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[145] 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(92vh,46rem)] w-full max-w-5xl 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 className="text-base font-semibold text-[var(--platform-text-strong)]">
</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">
{error ? (
<div className="platform-banner platform-banner--danger text-sm leading-6">
{error}
</div>
) : null}
{isLoading ? (
<div className="flex min-h-[14rem] items-center justify-center rounded-[1.35rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/52 px-6 text-center text-sm text-[var(--platform-text-base)]">
...
</div>
) : null}
{!isLoading && !error && assets.length <= 0 ? (
<div className="flex min-h-[14rem] items-center justify-center rounded-[1.35rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/52 px-6 text-center text-sm text-[var(--platform-text-base)]">
</div>
) : null}
{!isLoading && assets.length > 0 ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{assets.map((asset) => (
<button
key={asset.assetObjectId}
type="button"
disabled={isBusy}
onClick={() => onSelect(asset)}
className={`overflow-hidden rounded-[1.25rem] border bg-white/82 text-left transition hover:border-amber-300/70 hover:bg-white ${isBusy ? 'cursor-not-allowed opacity-55' : 'border-[var(--platform-subpanel-border)]'}`}
>
<div className="aspect-square overflow-hidden bg-[var(--platform-subpanel-fill)]">
<ResolvedAssetImage
src={asset.imageSrc}
alt={asset.ownerLabel || '历史拼图素材'}
className="h-full w-full object-cover"
/>
</div>
<div className="space-y-1 px-4 py-4">
<div className="truncate text-sm font-black text-[var(--platform-text-strong)]">
{asset.ownerLabel || '未记录账号'}
</div>
<div className="text-xs leading-5 text-[var(--platform-text-base)]">
{formatHistoryAssetDate(asset.createdAt)}
</div>
</div>
</button>
))}
</div>
) : null}
</div>
</div>
</div>,
document.body,
);
}
function PuzzleLevelDetailDialog({
draft,
generationNowMs,
generationRuntime,
imageRefreshKey,
isBusy,
level,
@@ -607,12 +560,14 @@ function PuzzleLevelDetailDialog({
onStartTestRun,
}: {
draft: PuzzleResultDraft;
generationNowMs: number;
generationRuntime: PuzzleLevelGenerationRuntime | null;
imageRefreshKey: string;
isBusy: boolean;
level: PuzzleDraftLevel;
onClose: () => void;
onGenerate: (
levelId: string,
level: PuzzleDraftLevel,
promptText?: string | null,
referenceImageSrc?: string | null,
imageModel?: PuzzleImageModelId | null,
@@ -628,10 +583,6 @@ function PuzzleLevelDetailDialog({
);
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false);
const [isGenerationProgressActive, setIsGenerationProgressActive] =
useState(false);
const [generationCountdown, setGenerationCountdown] = useState(0);
const generationBusySeenRef = useRef(false);
const [imageModel, setImageModel] = useState<PuzzleImageModelId>(
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
);
@@ -639,18 +590,14 @@ function PuzzleLevelDetailDialog({
const hasFormalImage = Boolean(formalImageSrc);
const effectiveReferenceImageSrc =
referenceImageSrc.trim() || level.pictureReference?.trim() || '';
const isGenerationProgressVisible = isGenerationProgressActive;
const generationSecondsLeft = isBusy
? Math.max(generationCountdown, 1)
: generationCountdown;
const generationProgressPercent = Math.max(
6,
Math.round(
((PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS -
Math.max(generationSecondsLeft, 0)) /
PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS) *
100,
),
const displayImageSrc = formalImageSrc || effectiveReferenceImageSrc;
const displayImageAlt = formalImageSrc
? level.levelName || draft.workTitle || '拼图关卡'
: '拼图参考图';
const generationProgress = resolvePuzzleLevelGenerationProgress(
level,
generationRuntime,
generationNowMs,
);
const handleReferenceImageChange = async (
@@ -676,54 +623,15 @@ function PuzzleLevelDetailDialog({
}
};
useEffect(() => {
if (!isGenerationProgressActive) {
return;
}
if (generationCountdown <= 0) {
if (!isBusy) {
setIsGenerationProgressActive(false);
}
return;
}
const timer = window.setTimeout(() => {
setGenerationCountdown((current) => Math.max(0, current - 1));
}, 1000);
return () => window.clearTimeout(timer);
}, [generationCountdown, isBusy, isGenerationProgressActive]);
useEffect(() => {
if (isGenerationProgressActive && isBusy) {
generationBusySeenRef.current = true;
return;
}
if (
isGenerationProgressActive &&
!isBusy &&
generationBusySeenRef.current
) {
generationBusySeenRef.current = false;
setIsGenerationProgressActive(false);
setGenerationCountdown(0);
}
if (!isBusy) {
setIsCostConfirmOpen(false);
}
}, [isBusy, isGenerationProgressActive]);
const executeGeneration = () => {
const nextLevel = {
...level,
generationStatus: 'generating' as const,
};
setIsCostConfirmOpen(false);
setIsGenerationProgressActive(true);
generationBusySeenRef.current = false;
setGenerationCountdown(PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS);
onGenerate(
level.levelId,
level.pictureDescription.trim() || undefined,
nextLevel,
nextLevel.pictureDescription.trim() || undefined,
effectiveReferenceImageSrc || undefined,
imageModel,
);
@@ -780,127 +688,134 @@ function PuzzleLevelDetailDialog({
/>
</section>
{hasFormalImage ? (
<div className="grid gap-4 lg:grid-cols-[minmax(13rem,0.9fr)_minmax(0,1.1fr)]">
<section className="platform-subpanel rounded-[1.35rem] p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
<div className="mb-3 text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="relative mt-3 aspect-square overflow-hidden rounded-[1.15rem] bg-[var(--platform-subpanel-fill)]">
<ResolvedAssetImage
src={formalImageSrc}
refreshKey={`${imageRefreshKey}:${level.levelId}`}
alt={level.levelName || draft.workTitle || '拼图关卡'}
className="h-full w-full object-cover"
/>
<button
type="button"
disabled={isBusy}
onClick={() => setIsHistoryPickerOpen(true)}
className={`absolute bottom-3 right-3 inline-flex h-10 w-10 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/96 text-[var(--platform-text-strong)] shadow-sm transition hover:bg-[var(--platform-subpanel-fill)] ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-label="从历史拼图素材库选择"
title="从历史拼图素材库选择"
>
<Images className="h-4 w-4" />
</button>
</div>
</section>
) : null}
<section className="platform-subpanel rounded-[1.35rem] p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="relative mt-3">
<textarea
value={level.pictureDescription}
disabled={isBusy}
rows={9}
onChange={(event) =>
onLevelChange({
...level,
pictureDescription: event.target.value,
})
}
className="w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-16 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="画面描述"
/>
<PuzzleImageModelPicker
value={imageModel}
disabled={isBusy}
onChange={setImageModel}
/>
<label
className={`absolute bottom-3 right-3 inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-amber-300/70 bg-white/96 text-amber-700 shadow-sm transition hover:bg-amber-50 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
title={effectiveReferenceImageSrc ? '更换参考图' : '添加参考图'}
>
<ImagePlus className="h-4 w-4" />
<span className="sr-only">
{effectiveReferenceImageSrc ? '更换参考图' : '添加参考图'}
</span>
<div className="relative aspect-square overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)]">
<input
id={`puzzle-level-reference-upload-${level.levelId}`}
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={isBusy}
aria-label="上传参考图"
onChange={(event) => {
void handleReferenceImageChange(event);
}}
className="hidden"
className="sr-only"
/>
</label>
</div>
<input
value={level.pictureReference ?? ''}
disabled={isBusy}
onChange={(event) =>
onLevelChange({
...level,
pictureReference: event.target.value,
})
}
className="mt-3 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm text-[var(--platform-text-strong)] outline-none"
placeholder="参考图链接或资产ID"
aria-label="图面参考"
/>
{effectiveReferenceImageSrc ? (
<div className="mt-3 flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
<div className="h-14 w-14 overflow-hidden rounded-[0.9rem] bg-[var(--platform-subpanel-fill)]">
<ResolvedAssetImage
src={effectiveReferenceImageSrc}
alt="拼图参考图"
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{referenceImageLabel || '已选择参考图'}
</div>
</div>
<button
type="button"
disabled={isBusy}
onClick={() => {
setReferenceImageSrc('');
setReferenceImageLabel('');
setReferenceImageError(null);
onLevelChange({ ...level, pictureReference: null });
}}
className="platform-icon-button h-9 w-9"
aria-label="移除参考图"
title="移除参考图"
<label
htmlFor={`puzzle-level-reference-upload-${level.levelId}`}
className={`absolute inset-0 z-0 cursor-pointer ${isBusy ? 'cursor-not-allowed' : ''}`}
title={
effectiveReferenceImageSrc ? '更换参考图' : '上传参考图'
}
>
<X className="h-4 w-4" />
</button>
<span className="sr-only">
{effectiveReferenceImageSrc ? '更换参考图' : '上传参考图'}
</span>
</label>
{displayImageSrc ? (
<ResolvedAssetImage
src={displayImageSrc}
refreshKey={`${imageRefreshKey}:${level.levelId}`}
alt={displayImageAlt}
className="pointer-events-none h-full w-full object-cover"
/>
) : (
<span className="pointer-events-none flex h-full items-center justify-center bg-[radial-gradient(circle_at_50%_28%,rgba(255,255,255,0.92),transparent_38%),linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,241,229,0.86))]">
<span className="flex h-16 w-16 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/92 text-[var(--platform-text-strong)] shadow-sm">
<ImagePlus className="h-7 w-7" />
</span>
</span>
)}
{generationProgress.isGenerating ? (
<div className="absolute inset-0 flex items-center justify-center bg-white/68 backdrop-blur-[2px]">
<div className="flex items-center gap-2 rounded-full border border-white/80 bg-white/94 px-4 py-2 text-sm font-black text-[var(--platform-text-strong)] shadow-sm">
<Loader2 className="h-4 w-4 animate-spin text-amber-600" />
</div>
</div>
) : null}
<div className="absolute bottom-3 right-3 z-10">
<button
type="button"
disabled={isBusy}
onClick={() => setIsHistoryPickerOpen(true)}
className={`inline-flex items-center gap-1.5 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-[11px] font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-label="选择历史图片"
title="选择历史图片"
>
<History className="h-3.5 w-3.5" />
<span></span>
</button>
</div>
</div>
) : null}
{effectiveReferenceImageSrc ? (
<div className="mt-3 flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
<div className="h-12 w-12 overflow-hidden rounded-[0.85rem] bg-[var(--platform-subpanel-fill)]">
<ResolvedAssetImage
src={effectiveReferenceImageSrc}
alt="拼图参考图"
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{referenceImageLabel || '已选择参考图'}
</div>
</div>
<button
type="button"
disabled={isBusy}
onClick={() => {
setReferenceImageSrc('');
setReferenceImageLabel('');
setReferenceImageError(null);
onLevelChange({ ...level, pictureReference: null });
}}
className="platform-icon-button h-9 w-9"
aria-label="移除参考图"
title="移除参考图"
>
<X className="h-4 w-4" />
</button>
</div>
) : null}
{referenceImageError ? (
<div className="mt-2 text-xs leading-5 text-red-600">
{referenceImageError}
</div>
) : null}
</section>
{referenceImageError ? (
<div className="mt-2 text-xs leading-5 text-red-600">
{referenceImageError}
<section className="platform-subpanel rounded-[1.35rem] p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
) : null}
</section>
<div className="relative mt-3">
<textarea
value={level.pictureDescription}
disabled={isBusy}
rows={7}
onChange={(event) =>
onLevelChange({
...level,
pictureDescription: event.target.value,
})
}
className="h-[12rem] min-h-[12rem] w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-16 text-sm leading-6 text-[var(--platform-text-strong)] outline-none sm:h-[14rem] sm:min-h-[14rem] lg:h-full lg:min-h-[18rem]"
aria-label="画面描述"
/>
<PuzzleImageModelPicker
value={imageModel}
disabled={isBusy}
onChange={setImageModel}
/>
</div>
</section>
</div>
</div>
</div>
@@ -919,22 +834,22 @@ function PuzzleLevelDetailDialog({
</button>
) : null}
{isGenerationProgressVisible ? (
{generationProgress.isGenerating ? (
<div
role="progressbar"
aria-label="画面生成进度"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={generationProgressPercent}
aria-valuenow={generationProgress.progressPercent}
className="platform-progress-track relative h-12 overflow-hidden rounded-full"
>
<div
className="h-full rounded-full bg-amber-600 transition-[width] duration-300"
style={{ width: `${generationProgressPercent}%` }}
style={{ width: `${generationProgress.progressPercent}%` }}
/>
<div className="absolute inset-0 flex items-center justify-center gap-2 px-4 text-sm font-bold text-white">
<Loader2 className="h-4 w-4 animate-spin" />
{generationSecondsLeft}
{generationProgress.secondsLeft}
</div>
</div>
) : (
@@ -942,12 +857,17 @@ function PuzzleLevelDetailDialog({
type="button"
disabled={isBusy}
onClick={() => setIsCostConfirmOpen(true)}
className="inline-flex w-full items-center justify-center gap-2 rounded-full bg-amber-600 px-4 py-3 text-sm font-bold text-white disabled:opacity-45"
className="inline-flex w-full flex-col items-center justify-center gap-1 rounded-full bg-amber-600 px-4 py-3 text-sm font-bold text-white disabled:opacity-45"
>
<Sparkles className="h-4 w-4" />
<span>{hasFormalImage ? '重新生成画面' : '生成画面'}</span>
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
{PUZZLE_IMAGE_GENERATION_POINT_COST}
<span className="inline-flex items-center justify-center gap-2">
<Sparkles className="h-4 w-4" />
<span>{hasFormalImage ? '重新生成画面' : '生成画面'}</span>
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
{PUZZLE_IMAGE_GENERATION_POINT_COST}
</span>
</span>
<span className="text-[11px] font-semibold leading-none text-white/78">
~
</span>
</button>
)}
@@ -986,8 +906,9 @@ function PuzzleLevelDetailDialog({
</button>
<button
type="button"
disabled={isBusy || generationProgress.isGenerating}
onClick={executeGeneration}
className="platform-button platform-button--primary min-h-10 px-5 py-2 text-sm"
className={`platform-button platform-button--primary min-h-10 px-5 py-2 text-sm ${isBusy || generationProgress.isGenerating ? 'opacity-55' : ''}`}
>
</button>
@@ -1221,6 +1142,8 @@ function PuzzleCreativeDraftEditBar({
function PuzzleLevelListTab({
editState,
generationNowMs,
generationRuntimeByLevelId,
imageRefreshKey,
isBusy,
onAddLevel,
@@ -1228,6 +1151,8 @@ function PuzzleLevelListTab({
onOpenLevel,
}: {
editState: DraftEditState;
generationNowMs: number;
generationRuntimeByLevelId: Record<string, PuzzleLevelGenerationRuntime>;
imageRefreshKey: string;
isBusy: boolean;
onAddLevel: () => void;
@@ -1236,10 +1161,18 @@ function PuzzleLevelListTab({
}) {
return (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
<div
aria-label="拼图关卡列表"
className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3"
>
{editState.levels.map((level, index) => {
const imageSrc = resolveLevelFormalImageSrc(level);
const displayLevelName = level.levelName || `${index + 1}`;
const generationProgress = resolvePuzzleLevelGenerationProgress(
level,
generationRuntimeByLevelId[level.levelId] ?? null,
generationNowMs,
);
return (
<div
key={level.levelId}
@@ -1250,7 +1183,7 @@ function PuzzleLevelListTab({
onClick={() => onOpenLevel(level.levelId)}
className="block w-full text-left"
>
<div className="aspect-[4/3] overflow-hidden bg-[var(--platform-subpanel-fill)]">
<div className="relative aspect-[4/3] overflow-hidden bg-[var(--platform-subpanel-fill)]">
{imageSrc ? (
<ResolvedAssetImage
src={imageSrc}
@@ -1263,10 +1196,23 @@ function PuzzleLevelListTab({
</div>
)}
{generationProgress.isGenerating ? (
<div className="absolute inset-0 flex items-center justify-center bg-white/68 backdrop-blur-[2px]">
<div className="flex items-center gap-2 rounded-full border border-white/80 bg-white/94 px-3 py-1.5 text-xs font-black text-[var(--platform-text-strong)] shadow-sm">
<Loader2 className="h-3.5 w-3.5 animate-spin text-amber-600" />
</div>
</div>
) : null}
</div>
<div className="space-y-1 px-4 py-4">
<div className="text-[11px] font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
{index + 1}
<div className="flex items-center justify-between gap-2 text-[11px] font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
<span>{index + 1}</span>
{generationProgress.isGenerating ? (
<span className="rounded-full bg-amber-100 px-2 py-0.5 tracking-normal text-amber-700">
</span>
) : null}
</div>
</div>
</button>
@@ -1452,6 +1398,10 @@ export function PuzzleResultView({
const [tagGenerationError, setTagGenerationError] = useState<string | null>(
null,
);
const [generationRuntimeByLevelId, setGenerationRuntimeByLevelId] = useState<
Record<string, PuzzleLevelGenerationRuntime>
>({});
const [generationNowMs, setGenerationNowMs] = useState(() => Date.now());
const savedEditStateRef = useRef<DraftEditState | null>(
draft ? createDraftEditState(draft) : null,
);
@@ -1466,8 +1416,27 @@ export function PuzzleResultView({
return;
}
const nextState = createDraftEditState(draft);
savedEditStateRef.current = nextState;
setEditState(nextState);
setEditState((currentState) => {
const mergedState = mergeDraftEditStateWithIncomingState(
currentState,
nextState,
);
savedEditStateRef.current = nextState;
return mergedState;
});
setGenerationRuntimeByLevelId((current) => {
const nextRuntimes: Record<string, PuzzleLevelGenerationRuntime> = {};
nextState.levels.forEach((level) => {
if (level.generationStatus === 'generating') {
nextRuntimes[level.levelId] =
current[level.levelId] ?? {
startedAtMs: Date.now(),
estimateSeconds: PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS,
};
}
});
return nextRuntimes;
});
setActiveLevelId((currentLevelId) =>
currentLevelId &&
nextState.levels.some((level) => level.levelId === currentLevelId)
@@ -1492,6 +1461,45 @@ export function PuzzleResultView({
const imageRefreshKey = `${session.updatedAt}:${primaryImageSrc}:${editState?.levels.length ?? 0}`;
const activeLevel =
editState?.levels.find((level) => level.levelId === activeLevelId) ?? null;
const hasGeneratingLevel = Boolean(
editState?.levels.some((level) => level.generationStatus === 'generating'),
);
useEffect(() => {
if (!hasGeneratingLevel) {
return;
}
const timer = window.setInterval(() => {
setGenerationNowMs(Date.now());
}, 1000);
return () => window.clearInterval(timer);
}, [hasGeneratingLevel]);
useEffect(() => {
if (!editState) {
return;
}
const activeGeneratingLevelIds = new Set(
editState.levels
.filter((level) => level.generationStatus === 'generating')
.map((level) => level.levelId),
);
setGenerationRuntimeByLevelId((current) => {
let changed = false;
const nextRuntime: Record<string, PuzzleLevelGenerationRuntime> = {};
Object.entries(current).forEach(([levelId, runtime]) => {
if (!activeGeneratingLevelIds.has(levelId)) {
changed = true;
return;
}
nextRuntime[levelId] = runtime;
});
return changed ? nextRuntime : current;
});
}, [editState]);
useEffect(() => {
if (!draft || !editState || !profileId) {
@@ -1507,6 +1515,8 @@ export function PuzzleResultView({
...level,
levelName: level.levelName.trim(),
pictureDescription: level.pictureDescription.trim(),
pictureReference: level.pictureReference?.trim() || null,
generationStatus: level.generationStatus || 'idle',
})),
};
const originalState =
@@ -1580,6 +1590,26 @@ export function PuzzleResultView({
}
const updateLevel = (nextLevel: PuzzleDraftLevel) => {
setGenerationRuntimeByLevelId((current) => {
if (nextLevel.generationStatus === 'generating') {
return {
...current,
[nextLevel.levelId]:
current[nextLevel.levelId] ?? {
startedAtMs: Date.now(),
estimateSeconds: PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS,
},
};
}
if (!current[nextLevel.levelId]) {
return current;
}
const nextRuntime = { ...current };
delete nextRuntime[nextLevel.levelId];
return nextRuntime;
});
setEditState((currentState) =>
currentState
? {
@@ -1627,6 +1657,8 @@ export function PuzzleResultView({
{activeTab === 'levels' ? (
<PuzzleLevelListTab
editState={editState}
generationNowMs={generationNowMs}
generationRuntimeByLevelId={generationRuntimeByLevelId}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
onAddLevel={() => {
@@ -1721,23 +1753,33 @@ export function PuzzleResultView({
{activeLevel ? (
<PuzzleLevelDetailDialog
draft={syncedDraft}
generationNowMs={generationNowMs}
generationRuntime={
generationRuntimeByLevelId[activeLevel.levelId] ?? null
}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
level={activeLevel}
onClose={() => setActiveLevelId(null)}
onGenerate={(levelId, promptText, referenceImageSrc, imageModel) => {
onGenerate={(nextLevel, promptText, referenceImageSrc, imageModel) => {
updateLevel(nextLevel);
onExecuteAction({
action: 'generate_puzzle_images',
levelId,
levelId: nextLevel.levelId,
promptText,
referenceImageSrc,
imageModel: imageModel ?? PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
aiRedraw: true,
candidateCount: 1,
workTitle: editState.workTitle.trim(),
workDescription: editState.workDescription.trim(),
summary: editState.workDescription.trim(),
themeTags: editState.themeTags,
levelsJson: JSON.stringify(editState.levels),
levelsJson: JSON.stringify(
editState.levels.map((level) =>
level.levelId === nextLevel.levelId ? nextLevel : level,
),
),
});
}}
onLevelChange={updateLevel}

View File

@@ -207,7 +207,8 @@ test('拼图界面在 mocap open_palm 时显示体感光标', () => {
const cursor = screen.getByTestId('puzzle-mocap-cursor');
expect(cursor).toBeTruthy();
expect(cursor).toHaveStyle({left: '42%', top: '58%'});
expect(cursor.style.left).toBe('42%');
expect(cursor.style.top).toBe('58%');
mocapMock.state = 'grab';
});
@@ -813,6 +814,7 @@ test('基础单块使用圆角裁剪图片', () => {
) as HTMLElement | null;
expect(basePiece?.className).toContain('overflow-hidden');
expect(basePiece?.className).toContain('rounded-[0.85rem]');
expect(basePiece?.querySelector('.puzzle-runtime-piece-overlay')).toBeNull();
});
test('移动端点击拼图片时立即触发一次震动反馈', () => {

View File

@@ -1347,7 +1347,6 @@ export function PuzzleRuntimeShell({
) : (
<div className="absolute inset-0 bg-[linear-gradient(145deg,rgba(251,191,36,0.4),rgba(76,29,19,0.72))]" />
)}
<div className="puzzle-runtime-piece-overlay absolute inset-0" />
</div>
) : (
''
@@ -2000,7 +1999,7 @@ export function PuzzleRuntimeShell({
</div>
<div className="puzzle-runtime-dialog__line overflow-hidden rounded-[1rem] border">
<div className="puzzle-runtime-leaderboard-head grid grid-cols-[3.5rem_minmax(0,1fr)_6rem] px-3 py-2 text-[11px] font-bold">
<div className="puzzle-runtime-leaderboard-head grid grid-cols-[3rem_minmax(0,1fr)_5.75rem] px-3 py-2 text-[11px] font-bold">
<span></span>
<span></span>
<span className="text-right"></span>
@@ -2010,7 +2009,7 @@ export function PuzzleRuntimeShell({
leaderboardEntries.map((entry) => (
<div
key={`${entry.rank}:${entry.nickname}:${entry.elapsedMs}`}
className={`grid grid-cols-[3.5rem_minmax(0,1fr)_6rem] items-center px-3 py-2.5 text-sm ${
className={`grid min-h-[3.25rem] grid-cols-[3rem_minmax(0,1fr)_5.75rem] items-center gap-x-2 px-3 py-2.5 text-sm ${
entry.isCurrentPlayer
? 'puzzle-runtime-leaderboard-row--active'
: 'puzzle-runtime-leaderboard-row border-t'
@@ -2019,8 +2018,22 @@ export function PuzzleRuntimeShell({
<span className="font-mono font-black">
#{entry.rank}
</span>
<span className="truncate font-semibold">
{entry.nickname}
<span className="min-w-0">
<span className="block truncate font-semibold leading-tight">
{entry.nickname}
</span>
{entry.visibleTags?.length ? (
<span className="puzzle-runtime-leaderboard-tags">
{entry.visibleTags.map((tag) => (
<span
className="puzzle-runtime-leaderboard-tag"
key={tag}
>
{tag}
</span>
))}
</span>
) : null}
</span>
<span className="text-right font-mono text-xs font-bold">
{formatElapsedMs(entry.elapsedMs)}
@@ -2112,7 +2125,7 @@ function PuzzleNextWorkCard({
) : (
<div className="h-full w-full bg-[linear-gradient(145deg,rgba(20,184,166,0.34),rgba(15,23,42,0.88))]" />
)}
<div className="puzzle-runtime-piece-overlay absolute inset-0 transition group-hover:opacity-0" />
<div className="puzzle-runtime-next-card-overlay absolute inset-0 transition group-hover:opacity-0" />
</div>
<div className="min-w-0 px-3 py-2.5">
<div className="truncate text-sm font-black">

View File

@@ -67,6 +67,7 @@ import {
} from '../../services/match3d-works';
import {
createPuzzleAgentSession,
executePuzzleAgentAction,
getPuzzleAgentSession,
} from '../../services/puzzle-agent';
import {
@@ -463,9 +464,17 @@ vi.mock('../puzzle-agent/PuzzleAgentWorkspace', () => ({
vi.mock('../puzzle-result/PuzzleResultView', () => ({
PuzzleResultView: ({
isBusy,
onExecuteAction,
session,
onBack,
}: {
isBusy?: boolean;
onExecuteAction: (payload: {
action: string;
levelId?: string;
promptText?: string;
}) => void;
session: { draft?: { levelName: string } | null };
onBack: () => void;
}) => (
@@ -475,6 +484,21 @@ vi.mock('../puzzle-result/PuzzleResultView', () => ({
<input readOnly value={session.draft?.levelName ?? ''} />
</label>
<button
type="button"
onClick={() => {
onExecuteAction({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1',
promptText: '重新生成猫街',
});
}}
>
</button>
<button type="button" disabled={isBusy}>
</button>
<button type="button" onClick={onBack}>
</button>
@@ -550,14 +574,36 @@ vi.mock('../big-fish-result/BigFishResultView', () => ({
vi.mock('../match3d-creation/Match3DAgentWorkspace', () => ({
Match3DAgentWorkspace: ({
session,
onCreateFromForm,
}: {
session: { sessionId: string; messages: Array<{ text: string }> } | null;
onCreateFromForm?: (payload: {
seedText: string;
themeText: string;
referenceImageSrc: string | null;
clearCount: number;
difficulty: number;
}) => void;
}) => (
<div className="match3d-agent-workspace-mock">
<div>{session?.sessionId ?? 'missing-session'}</div>
{session?.messages.map((message) => (
<div key={`${session.sessionId}-${message.text}`}>{message.text}</div>
))}
<button
type="button"
onClick={() => {
onCreateFromForm?.({
seedText: '赛博水果摊题材消除9次难度6',
themeText: '赛博水果摊',
referenceImageSrc: null,
clearCount: 9,
difficulty: 6,
});
}}
>
稿
</button>
</div>
),
}));
@@ -2355,6 +2401,9 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
expect(
screen.getByRole('tab', { name: 'AIRP' }).querySelector('img')?.src,
).toContain('/creation-type-references/airp.webp');
expect(
screen.getByRole('tab', { name: '抓大鹅' }).querySelector('img')?.src,
).toContain('/creation-type-references/match3d.webp');
expect(
screen.getByRole('tab', { name: '拼图' }).querySelector('.text-white'),
).toBeTruthy();
@@ -2364,12 +2413,29 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByPlaceholderText('问一问百梦')).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('tab', { name: //u })).toBeNull();
expect(createRpgCreationSession).not.toHaveBeenCalled();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
});
test('create tab switches match3d into the embedded entry form', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
expect(
screen.getByRole('tab', { name: '抓大鹅' }).getAttribute('aria-selected'),
).toBe('true');
expect(
await screen.findByText('抓大鹅工作区missing-session'),
).toBeTruthy();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
});
test('embedded puzzle form routes through requireAuth while logged out', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
@@ -2826,6 +2892,159 @@ test('owned public puzzle detail edits original draft instead of remixing', asyn
expect(getPuzzleAgentSession).toHaveBeenCalledWith('puzzle-session-1');
expect(remixPuzzleGalleryWork).not.toHaveBeenCalled();
expect(await screen.findByText('拼图结果页')).toBeTruthy();
vi.mocked(executePuzzleAgentAction).mockResolvedValueOnce({
operation: {
operationId: 'puzzle-image-generation-1',
type: 'generate_puzzle_images',
status: 'running',
phaseLabel: '生成中',
phaseDetail: '正在生成拼图画面',
progress: 0.3,
},
session: {
sessionId: 'puzzle-session-1',
currentTurn: 3,
progressPercent: 88,
stage: 'ready_to_publish',
anchorPack: {
themePromise: {
key: 'theme_promise',
label: '主题承诺',
value: '雨夜猫街',
status: 'confirmed',
},
visualSubject: {
key: 'visual_subject',
label: '视觉主体',
value: '屋檐下的猫',
status: 'confirmed',
},
visualMood: {
key: 'visual_mood',
label: '视觉气质',
value: '温暖',
status: 'confirmed',
},
compositionHooks: {
key: 'composition_hooks',
label: '构图钩子',
value: '雨滴与灯牌',
status: 'confirmed',
},
tagsAndForbidden: {
key: 'tags_and_forbidden',
label: '标签与禁区',
value: '猫咪、雨夜',
status: 'confirmed',
},
},
draft: {
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
levelName: '雨夜猫街',
summary: '屋檐下的猫与暖灯街角。',
themeTags: ['猫咪', '雨夜', '暖灯'],
forbiddenDirectives: [],
creatorIntent: null,
anchorPack: {
themePromise: {
key: 'theme_promise',
label: '主题承诺',
value: '雨夜猫街',
status: 'confirmed',
},
visualSubject: {
key: 'visual_subject',
label: '视觉主体',
value: '屋檐下的猫',
status: 'confirmed',
},
visualMood: {
key: 'visual_mood',
label: '视觉气质',
value: '温暖',
status: 'confirmed',
},
compositionHooks: {
key: 'composition_hooks',
label: '构图钩子',
value: '雨滴与灯牌',
status: 'confirmed',
},
tagsAndForbidden: {
key: 'tags_and_forbidden',
label: '标签与禁区',
value: '猫咪、雨夜',
status: 'confirmed',
},
},
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/puzzle/candidate-1.png',
assetId: 'asset-1',
prompt: '雨夜猫咪',
actualPrompt: null,
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-1',
coverImageSrc: '/puzzle/candidate-1.png',
coverAssetId: 'asset-1',
generationStatus: 'generating',
levels: [
{
levelId: 'puzzle-level-1',
levelName: '雨夜猫街',
pictureDescription: '屋檐下的猫与暖灯街角。',
pictureReference: null,
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/puzzle/candidate-1.png',
assetId: 'asset-1',
prompt: '雨夜猫咪',
actualPrompt: null,
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-1',
coverImageSrc: '/puzzle/candidate-1.png',
coverAssetId: 'asset-1',
generationStatus: 'generating',
},
],
metadata: null,
},
messages: [],
lastAssistantReply: '大鱼结果页草稿已经生成,可以补正式资产。',
publishedProfileId: null,
suggestedActions: [],
resultPreview: {
draft: null,
publishReady: false,
blockers: [],
qualityFindings: [],
},
updatedAt: '2026-04-26T10:10:00.000Z',
},
});
await user.click(screen.getByRole('button', { name: '重新生成画面' }));
expect(executePuzzleAgentAction).toHaveBeenCalledWith(
'puzzle-session-1',
expect.objectContaining({
action: 'generate_puzzle_images',
}),
);
expect(screen.getByRole('button', { name: '新增关卡' })).toHaveProperty(
'disabled',
false,
);
});
test('logged out public detail gates big fish start before local runtime', async () => {
@@ -3027,7 +3246,6 @@ test('published puzzle works appear on home and mobile game category channel', a
});
test('home recommendation starts embedded puzzle without global auth reset on local failure', async () => {
const user = userEvent.setup();
const publishedPuzzleWork = {
workId: 'puzzle-work-public-1',
profileId: 'puzzle-profile-public-1',
@@ -3396,7 +3614,7 @@ test('embedded puzzle form timeout exits busy state and shows a readable error',
expect(streamCreativeAgentMessage).not.toHaveBeenCalled();
});
test('hidden match3d creation card stays closed even when public galleries fail', async () => {
test('match3d creation tab stays usable even when public galleries fail', async () => {
const user = userEvent.setup();
vi.mocked(listRpgEntryWorldGallery).mockRejectedValueOnce(
@@ -3411,9 +3629,7 @@ test('hidden match3d creation card stays closed even when public galleries fail'
await openCreateTemplateHub(user);
expect(screen.queryByText('读取作品广场失败')).toBeNull();
expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull();
expect(
screen.queryByRole('tab', { name: /.*/u }),
).toBeNull();
expect(screen.getByRole('tab', { name: '抓大鹅' })).toBeTruthy();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
});

View File

@@ -1312,6 +1312,8 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
vi.useFakeTimers();
const onSelectNextRecommendEntry = vi.fn();
const onSelectPreviousRecommendEntry = vi.fn();
const onLikeRecommendEntry = vi.fn();
const onRemixRecommendEntry = vi.fn();
const firstEntry = {
...puzzlePublicEntry,
workId: 'puzzle-work-feed-1',
@@ -1397,6 +1399,8 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
activeRecommendEntryKey="puzzle:user-feed-1:puzzle-profile-feed-1"
onSelectNextRecommendEntry={onSelectNextRecommendEntry}
onSelectPreviousRecommendEntry={onSelectPreviousRecommendEntry}
onLikeRecommendEntry={onLikeRecommendEntry}
onRemixRecommendEntry={onRemixRecommendEntry}
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={vi.fn()}
/>
@@ -1412,8 +1416,38 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
).toHaveLength(3);
expect(screen.getAllByText('下一拼图').length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText('上一拼图').length).toBeGreaterThanOrEqual(2);
expect(screen.queryByText('评论')).toBeNull();
expect(screen.queryByLabelText(//u)).toBeNull();
const clipboardWriteText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: clipboardWriteText },
});
const meta = screen.getByLabelText('当前拼图 作品信息') as HTMLElement;
const activeRecommendCard = within(meta);
const likeButton = activeRecommendCard.getByRole('button', {
name: '点赞 12',
});
expect(likeButton).toBeTruthy();
expect(activeRecommendCard.getByLabelText('12 个赞')).toBeTruthy();
const shareButton = activeRecommendCard.getByRole('button', { name: '分享' });
const remixButton = activeRecommendCard.getByRole('button', {
name: '改造 5',
});
expect(shareButton).toBeTruthy();
expect(remixButton).toBeTruthy();
fireEvent.click(likeButton);
fireEvent.click(shareButton);
fireEvent.click(remixButton);
expect(onLikeRecommendEntry).toHaveBeenCalledWith(firstEntry);
expect(onRemixRecommendEntry).toHaveBeenCalledWith(firstEntry);
expect(clipboardWriteText).toHaveBeenCalledWith(
expect.stringContaining('作品号PZ-FEED1'),
);
act(() => {
dispatchPointerEvent(meta, 'pointerdown', { pointerId: 1, clientY: 300 });
dispatchPointerEvent(meta, 'pointermove', { pointerId: 1, clientY: 210 });

View File

@@ -9,6 +9,7 @@ import {
Coins,
Compass,
Copy,
GitFork,
Gamepad2,
Heart,
LogIn,
@@ -16,10 +17,12 @@ import {
Pencil,
Plus,
Search,
Share2,
Settings,
SlidersHorizontal,
Sparkles,
Star,
ThumbsUp,
Ticket,
UserPlus,
UserRound,
@@ -54,6 +57,7 @@ import type {
RedeemProfileRewardCodeResponse,
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
import type { AuthUser } from '../../services/authService';
import {
getPublicAuthUserByCode,
@@ -86,6 +90,7 @@ import {
isVisualNovelGalleryEntry,
type PlatformPublicGalleryCard,
type PlatformWorldCardLike,
resolvePlatformPublicWorkCode,
resolvePlatformWorldCoverImage,
resolvePlatformWorldCoverSlides,
resolvePlatformWorldLeadPortrait,
@@ -126,6 +131,8 @@ export interface RpgEntryHomeViewProps {
recommendRuntimeError?: string | null;
onSelectNextRecommendEntry?: () => void;
onSelectPreviousRecommendEntry?: () => void;
onLikeRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
onRemixRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
onOpenLibraryDetail: (
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
) => void;
@@ -772,19 +779,27 @@ function RecommendSwipeCard({
authorAvatarUrl,
isActive,
visual,
shareState,
onDragPointerDown,
onDragPointerMove,
onDragPointerUp,
onDragPointerCancel,
onLike,
onShare,
onRemix,
}: {
entry: PlatformPublicGalleryCard;
authorAvatarUrl?: string | null;
isActive: boolean;
visual: ReactNode;
shareState?: 'idle' | 'copied' | 'failed';
onDragPointerDown?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerCancel?: (event: PointerEvent<HTMLElement>) => void;
onLike?: () => void;
onShare?: () => void;
onRemix?: () => void;
}) {
return (
<div
@@ -797,10 +812,14 @@ function RecommendSwipeCard({
entry={entry}
authorAvatarUrl={authorAvatarUrl}
isActive={isActive}
shareState={shareState}
onDragPointerDown={onDragPointerDown}
onDragPointerMove={onDragPointerMove}
onDragPointerUp={onDragPointerUp}
onDragPointerCancel={onDragPointerCancel}
onLike={onLike}
onShare={onShare}
onRemix={onRemix}
/>
</div>
</div>
@@ -814,6 +833,10 @@ function RecommendRuntimeMeta({
onDragPointerMove,
onDragPointerUp,
onDragPointerCancel,
shareState = 'idle',
onLike,
onShare,
onRemix,
isActive = true,
}: {
entry: PlatformPublicGalleryCard;
@@ -822,20 +845,21 @@ function RecommendRuntimeMeta({
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerCancel?: (event: PointerEvent<HTMLElement>) => void;
shareState?: 'idle' | 'copied' | 'failed';
onLike?: () => void;
onShare?: () => void;
onRemix?: () => void;
isActive?: boolean;
}) {
const playCount = getPlatformWorldPlayCount(entry);
const remixCount = getPlatformWorldRemixCount(entry);
const likeCount = getPlatformWorldLikeCount(entry);
const remixCount = getPlatformWorldRemixCount(entry);
const authorName = entry.authorDisplayName.trim() || '玩家';
const authorAvatarLabel = getPublicAuthorAvatarLabel(authorName);
const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? '';
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const statItems = [
{ label: '游玩', value: playCount, icon: Gamepad2 },
{ label: '点赞', value: likeCount, icon: Heart },
{ label: '改造', value: remixCount, icon: MessageCircle },
];
const stopActionPointer = (event: PointerEvent<HTMLButtonElement>) => {
event.stopPropagation();
};
return (
<section
@@ -854,19 +878,6 @@ function RecommendRuntimeMeta({
onPointerUp={onDragPointerUp}
onPointerCancel={onDragPointerCancel}
>
<div className="platform-recommend-work-meta__stats">
{statItems.map(({ label, value, icon: Icon }) => (
<span
key={label}
className="platform-recommend-work-meta__stat"
aria-label={`${label} ${formatCompactCount(value)}`}
>
<Icon className="h-4 w-4" aria-hidden="true" />
<span>{formatCompactCount(value)}</span>
</span>
))}
</div>
<div className="platform-recommend-work-meta__row">
<div
className="platform-recommend-work-meta__identity"
@@ -894,6 +905,62 @@ function RecommendRuntimeMeta({
</span>
</span>
</div>
<div className="platform-recommend-work-meta__actions">
<button
type="button"
className="platform-recommend-work-meta__action platform-recommend-work-meta__action--icon platform-recommend-work-meta__action--like"
onPointerDown={stopActionPointer}
onClick={(event) => {
event.stopPropagation();
onLike?.();
}}
disabled={!isActive || !onLike}
aria-label={`点赞 ${formatCompactCount(likeCount)}`}
title="点赞"
>
<ThumbsUp className="h-5 w-5" aria-hidden="true" />
</button>
<span
className="platform-recommend-work-meta__like-count"
aria-label={`${formatCompactCount(likeCount)} 个赞`}
>
{formatCompactCount(likeCount)}
</span>
<button
type="button"
className="platform-recommend-work-meta__action platform-recommend-work-meta__action--icon"
onPointerDown={stopActionPointer}
onClick={(event) => {
event.stopPropagation();
onShare?.();
}}
disabled={!isActive || !onShare}
aria-label={
shareState === 'copied'
? '分享内容已复制'
: shareState === 'failed'
? '分享内容复制失败'
: '分享'
}
title="分享"
>
<Share2 className="h-5 w-5" aria-hidden="true" />
</button>
<button
type="button"
className="platform-recommend-work-meta__action platform-recommend-work-meta__action--icon platform-recommend-work-meta__action--remix"
onPointerDown={stopActionPointer}
onClick={(event) => {
event.stopPropagation();
onRemix?.();
}}
disabled={!isActive || !onRemix}
aria-label={`改造 ${formatCompactCount(remixCount)}`}
title="改造"
>
<GitFork className="h-5 w-5" aria-hidden="true" />
</button>
</div>
</div>
</section>
);
@@ -2977,6 +3044,8 @@ export function RpgEntryHomeView({
recommendRuntimeError = null,
onSelectNextRecommendEntry,
onSelectPreviousRecommendEntry,
onLikeRecommendEntry,
onRemixRecommendEntry,
onOpenLibraryDetail,
onDeleteLibraryEntry,
deletingLibraryEntryId = null,
@@ -3863,6 +3932,10 @@ export function RpgEntryHomeView({
const [recommendDragOffsetY, setRecommendDragOffsetY] = useState(0);
const [recommendDragCommitDirection, setRecommendDragCommitDirection] =
useState<1 | -1 | null>(null);
const [recommendShareState, setRecommendShareState] = useState<
'idle' | 'copied' | 'failed'
>('idle');
const recommendShareResetTimerRef = useRef<number | null>(null);
const recommendCardStageRef = useRef<HTMLDivElement | null>(null);
const recommendDragStartRef = useRef<{
pointerId: number;
@@ -4005,6 +4078,36 @@ export function RpgEntryHomeView({
onSelectNextRecommendEntry,
recommendedFeedEntries.length,
]);
useEffect(
() => () => {
if (recommendShareResetTimerRef.current !== null) {
window.clearTimeout(recommendShareResetTimerRef.current);
}
},
[],
);
useEffect(() => {
setRecommendShareState('idle');
}, [activeRecommendEntryKey]);
const shareRecommendEntry = useCallback((entry: PlatformPublicGalleryCard) => {
const publicWorkCode = resolvePlatformPublicWorkCode(entry)?.trim();
if (!publicWorkCode) {
setRecommendShareState('failed');
return;
}
const shareText = `邀请你来玩《${entry.worldName}\n作品号${publicWorkCode}\n${buildPublicWorkDetailUrl(publicWorkCode)}`;
void copyTextToClipboard(shareText).then((copied) => {
setRecommendShareState(copied ? 'copied' : 'failed');
if (recommendShareResetTimerRef.current !== null) {
window.clearTimeout(recommendShareResetTimerRef.current);
}
recommendShareResetTimerRef.current = window.setTimeout(() => {
recommendShareResetTimerRef.current = null;
setRecommendShareState('idle');
}, 1400);
});
}, []);
const openActiveRecommendEntry = useCallback(() => {
if (!activeRecommendEntry) {
return;
@@ -4168,6 +4271,10 @@ export function RpgEntryHomeView({
onDragPointerMove={moveRecommendDrag}
onDragPointerUp={endRecommendDrag}
onDragPointerCancel={cancelRecommendDrag}
shareState={recommendShareState}
onLike={() => onLikeRecommendEntry?.(activeRecommendEntry)}
onShare={() => shareRecommendEntry(activeRecommendEntry)}
onRemix={() => onRemixRecommendEntry?.(activeRecommendEntry)}
/>
</div>

View File

@@ -1,123 +1,101 @@
/* @vitest-environment jsdom */
import type { ComponentProps } from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { buildVisualNovelForbiddenCopyPattern } from '../visual-novel-runtime/visualNovelForbiddenCopy';
import { mockVisualNovelSession } from '../visual-novel-runtime/visualNovelMockData';
import { VisualNovelAgentWorkspace } from './VisualNovelAgentWorkspace';
import { AuthUiContext } from '../auth/AuthUiContext';
import {
buildVisualNovelEntryGenerationAnchorEntries,
buildVisualNovelEntryGenerationProgress,
VisualNovelAgentWorkspace,
} from './VisualNovelAgentWorkspace';
vi.mock('../../services/creation-agent/creationAgentDocumentInput', () => ({
parseCreationAgentDocumentInput: vi.fn(),
}));
vi.mock('../../services/visual-novel-creation', () => ({
uploadVisualNovelAsset: vi.fn(),
}));
const mockedParseCreationAgentDocumentInput = vi.mocked(
await import('../../services/creation-agent/creationAgentDocumentInput'),
);
const mockedVisualNovelAssetClient = vi.mocked(
await import('../../services/visual-novel-creation'),
);
function renderWorkspace(ui?: Partial<ComponentProps<typeof VisualNovelAgentWorkspace>>) {
function renderWorkspace(
ui?: Partial<ComponentProps<typeof VisualNovelAgentWorkspace>>,
) {
return render(
<AuthUiContext.Provider
value={
{
user: { id: 'user-1' },
platformTheme: 'light',
} as never
}
>
<VisualNovelAgentWorkspace
session={mockVisualNovelSession}
onBack={() => {}}
{...ui}
/>
</AuthUiContext.Provider>,
<VisualNovelAgentWorkspace onBack={() => {}} session={null} {...ui} />,
);
}
test('visual novel workspace renders mock creation shell without forbidden entry', async () => {
const user = userEvent.setup();
const onOpenResult = vi.fn();
test('visual novel workspace only exposes one-line input and visual style entry', () => {
const onCreateFromForm = vi.fn();
renderWorkspace({ onOpenResult });
renderWorkspace({ onCreateFromForm });
expect(screen.getByRole('heading', { name: '视觉小说' })).toBeTruthy();
expect(screen.getByRole('button', { name: '一句话' })).toBeTruthy();
expect(screen.getByLabelText('一句话创作')).toBeTruthy();
expect(screen.getByText('视觉画风')).toBeTruthy();
expect(screen.getByRole('button', { name: '映画动画' })).toBeTruthy();
expect(screen.getByRole('button', { name: '水彩绘本' })).toBeTruthy();
expect(screen.getByText('消耗20光点')).toBeTruthy();
expect(screen.queryByText(buildVisualNovelForbiddenCopyPattern())).toBeNull();
await user.click(screen.getByRole('button', { name: '进入结果页' }));
expect(onOpenResult).toHaveBeenCalledWith(mockVisualNovelSession);
expect(screen.queryByRole('button', { name: '文档' })).toBeNull();
expect(screen.queryByRole('button', { name: '空白' })).toBeNull();
expect(screen.queryByLabelText('上传文档')).toBeNull();
expect(screen.queryByText('进入结果页')).toBeNull();
expect(screen.queryByText('Agent')).toBeNull();
});
test('visual novel workspace opens editable blank draft from blank source', async () => {
const user = userEvent.setup();
const onOpenResult = vi.fn();
test('visual novel workspace submits idea and selected visual style as seed text', () => {
const onCreateFromForm = vi.fn();
renderWorkspace({ session: null, onOpenResult });
renderWorkspace({ onCreateFromForm });
await user.click(screen.getByRole('button', { name: '空白' }));
const openResultButtons = screen.getAllByRole('button', {
name: '进入结果页',
fireEvent.change(screen.getByLabelText('一句话创作'), {
target: { value: '失忆画师在雨夜剧场寻找旧胶片。' },
});
await user.click(openResultButtons[0]!);
fireEvent.click(screen.getByRole('button', { name: '像素霓虹' }));
fireEvent.click(
screen.getByRole('button', { name: /稿/u }),
);
expect(onOpenResult).toHaveBeenCalledTimes(1);
const session = onOpenResult.mock.calls[0]?.[0];
expect(session?.sourceMode).toBe('blank');
expect(session?.draft?.sourceMode).toBe('blank');
expect(session?.draft?.runtimeConfig.textModeEnabled).toBe(true);
expect(onCreateFromForm).toHaveBeenCalledWith({
sourceMode: 'idea',
sourceAssetIds: [],
ideaText: '失忆画师在雨夜剧场寻找旧胶片。',
visualStyleId: 'pixel-noir',
visualStyleLabel: '像素霓虹',
visualStylePrompt:
'高可读像素视觉小说画风,霓虹反差、硬朗轮廓和复古界面气质。',
seedText:
'失忆画师在雨夜剧场寻找旧胶片。\n视觉画风像素霓虹\n画风要求高可读像素视觉小说画风霓虹反差、硬朗轮廓和复古界面气质。',
});
});
test('visual novel workspace uploads document asset and passes asset id to session', async () => {
const user = userEvent.setup();
const onCreateSession = vi.fn();
const parseMock = mockedParseCreationAgentDocumentInput.parseCreationAgentDocumentInput;
const uploadMock = mockedVisualNovelAssetClient.uploadVisualNovelAsset;
test('visual novel workspace restores idea text from existing session', () => {
renderWorkspace({ session: mockVisualNovelSession });
parseMock.mockResolvedValue({
document: {
fileName: '世界设定.docx',
contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
sizeBytes: 128,
text: '第一章\n雨夜书店\n第二章\n失踪乘客',
sourceAssetId: 'asset-doc-source',
},
});
uploadMock.mockResolvedValue({
assetObjectId: 'asset-doc-1',
assetKind: 'visual_novel_document',
objectKey: 'generated-character-drafts/visual-novel/draft/document/1.docx',
imageSrc: '/generated-character-drafts/visual-novel/draft/document/1.docx',
});
renderWorkspace({ session: null, onCreateSession });
await user.click(screen.getByRole('button', { name: '文档' }));
const fileInput = screen.getByLabelText('上传文档') as HTMLInputElement;
const file = new File(['first chapter'], '世界设定.docx', {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
});
await user.upload(fileInput, file);
await user.click(screen.getByRole('button', { name: '生成底稿' }));
expect(parseMock).toHaveBeenCalledTimes(1);
expect(uploadMock).toHaveBeenCalledTimes(1);
expect(onCreateSession).toHaveBeenCalledWith(
expect.objectContaining({
sourceMode: 'document',
sourceAssetIds: ['asset-doc-1'],
seedText: expect.stringContaining('第一章'),
}),
expect((screen.getByLabelText('一句话创作') as HTMLTextAreaElement).value).toBe(
'想做一个雪夜列车和旧电台有关的悬疑视觉小说。',
);
});
test('visual novel generation helpers build process page data', () => {
const payload = {
sourceMode: 'idea' as const,
seedText:
'雨夜书店\n视觉画风水彩绘本\n画风要求透明水彩与绘本质感。',
sourceAssetIds: [],
ideaText: '雨夜书店',
visualStyleId: 'watercolor' as const,
visualStyleLabel: '水彩绘本',
visualStylePrompt: '透明水彩与绘本质感。',
};
expect(buildVisualNovelEntryGenerationAnchorEntries(payload)).toEqual([
{ id: 'visual-novel-idea', label: '一句话', value: '雨夜书店' },
{ id: 'visual-novel-style', label: '视觉画风', value: '水彩绘本' },
]);
const progress = buildVisualNovelEntryGenerationProgress(
1_000,
'generating',
8_000,
);
expect(progress.phaseId).toBe('generating');
expect(progress.overallProgress).toBeGreaterThan(0);
expect(progress.steps.some((step) => step.status === 'active')).toBe(true);
});

View File

@@ -1,70 +1,146 @@
import {
ArrowLeft,
FileText,
Loader2,
PenLine,
Upload,
Send,
Sparkles,
} from 'lucide-react';
import { type ChangeEvent, useMemo, useRef, useState } from 'react';
import { ArrowLeft, Loader2, Sparkles, WandSparkles } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { CustomWorldGenerationProgress } from '../../../packages/shared/src/contracts/runtime';
import type {
CreateVisualNovelSessionRequest,
ExecuteVisualNovelAgentActionRequest,
SendVisualNovelMessageRequest,
VisualNovelAgentSessionSnapshot,
VisualNovelSourceMode,
} from '../../../packages/shared/src/contracts/visualNovel';
import { useAuthUi } from '../auth/AuthUiContext';
import { parseCreationAgentDocumentInput } from '../../services/creation-agent/creationAgentDocumentInput';
import { uploadVisualNovelAsset } from '../../services/visual-novel-creation';
import {
createBlankVisualNovelDraft,
createMockVisualNovelSessionFromDraft,
mockVisualNovelSession,
} from '../visual-novel-runtime/visualNovelMockData';
import type { CustomWorldStructuredAnchorEntry } from '../../services/customWorldAgentGenerationProgress';
type VisualNovelAgentWorkspaceProps = {
session?: VisualNovelAgentSessionSnapshot | null;
isBusy?: boolean;
error?: string | null;
streamingReplyText?: string;
onBack: () => void;
onCreateSession?: (payload: CreateVisualNovelSessionRequest) => void;
onSubmitMessage?: (payload: SendVisualNovelMessageRequest) => void;
onExecuteAction?: (payload: ExecuteVisualNovelAgentActionRequest) => void;
onOpenResult?: (session: VisualNovelAgentSessionSnapshot) => void;
onCreateFromForm?: (payload: VisualNovelEntryFormPayload) => void;
initialFormPayload?: VisualNovelEntryFormPayload | null;
showBackButton?: boolean;
title?: string | null;
};
const SOURCE_OPTIONS: Array<{
id: VisualNovelSourceMode;
label: string;
icon: typeof PenLine;
}> = [
{ id: 'idea', label: '一句话', icon: Sparkles },
{ id: 'document', label: '文档', icon: FileText },
{ id: 'blank', label: '空白', icon: PenLine },
];
export type VisualNovelEntryFormPayload = Omit<
CreateVisualNovelSessionRequest,
'seedText' | 'sourceMode' | 'sourceAssetIds'
> & {
sourceMode: 'idea';
seedText: string;
sourceAssetIds: string[];
ideaText: string;
visualStyleId: VisualNovelStyleOptionId;
visualStyleLabel: string;
visualStylePrompt: string;
};
function buildClientMessageId() {
return `vn-message-${Date.now()}-${Math.round(Math.random() * 1_000_000)}`;
type VisualNovelFormState = {
ideaText: string;
visualStyleId: VisualNovelStyleOptionId;
};
const VISUAL_NOVEL_STYLE_OPTIONS = [
{
id: 'cinematic-anime',
label: '映画动画',
prompt: '电影感动画视觉小说画风,光影层次清晰,角色立绘精致,背景有景深。',
},
{
id: 'watercolor',
label: '水彩绘本',
prompt: '透明水彩与绘本质感,色彩柔和,边缘带手绘晕染,适合温柔叙事。',
},
{
id: 'pixel-noir',
label: '像素霓虹',
prompt: '高可读像素视觉小说画风,霓虹反差、硬朗轮廓和复古界面气质。',
},
{
id: 'ink-fantasy',
label: '水墨幻想',
prompt: '东方水墨幻想画风,留白、墨色层次和淡彩点染并重。',
},
{
id: 'soft-pastel',
label: '柔彩校园',
prompt: '柔和粉彩校园画风,干净明亮,角色表情细腻,日常氛围轻盈。',
},
{
id: 'dark-gothic',
label: '暗色哥特',
prompt: '暗色哥特视觉小说画风,深色场景、烛光高光和华丽服装细节。',
},
] as const;
type VisualNovelStyleOptionId =
(typeof VISUAL_NOVEL_STYLE_OPTIONS)[number]['id'];
const EMPTY_FORM_STATE: VisualNovelFormState = {
ideaText: '',
visualStyleId: 'cinematic-anime',
};
function getVisualNovelStyleOption(optionId: VisualNovelStyleOptionId) {
return (
VISUAL_NOVEL_STYLE_OPTIONS.find((option) => option.id === optionId) ??
VISUAL_NOVEL_STYLE_OPTIONS[0]
);
}
function clampDocumentSeedText(value: string) {
return value.trim().replace(/\s+/gu, ' ').slice(0, 4000);
function resolveStyleOptionId(
value: string | null | undefined,
): VisualNovelStyleOptionId {
return (
VISUAL_NOVEL_STYLE_OPTIONS.find((option) => option.id === value)?.id ??
'cinematic-anime'
);
}
function VisualNovelSourceButton({
function resolveIdeaTextFromSession(
session: VisualNovelAgentSessionSnapshot | null | undefined,
) {
const userText =
session?.messages.find((message) => message.role === 'user')?.text ?? '';
return userText
.replace(/[:][^\n]*(\n|$)/u, '')
.replace(/[:][^\n]*(\n|$)/u, '')
.trim();
}
function resolveInitialFormState(
session: VisualNovelAgentSessionSnapshot | null | undefined,
initialFormPayload: VisualNovelEntryFormPayload | null = null,
): VisualNovelFormState {
return {
...EMPTY_FORM_STATE,
ideaText:
initialFormPayload?.ideaText?.trim() ||
resolveIdeaTextFromSession(session) ||
'',
visualStyleId: resolveStyleOptionId(initialFormPayload?.visualStyleId),
};
}
function buildVisualNovelSeedText(
ideaText: string,
visualStyleLabel: string,
visualStylePrompt: string,
) {
return [
ideaText.trim(),
`视觉画风:${visualStyleLabel}`,
`画风要求:${visualStylePrompt}`,
]
.filter(Boolean)
.join('\n');
}
function VisualNovelStyleButton({
active,
disabled,
icon: Icon,
label,
onClick,
}: {
active: boolean;
disabled: boolean;
icon: typeof PenLine;
label: string;
onClick: () => void;
}) {
@@ -73,353 +149,349 @@ function VisualNovelSourceButton({
type="button"
disabled={disabled}
aria-pressed={active}
aria-label={label}
onClick={onClick}
className={`flex min-h-16 min-w-0 items-center gap-3 rounded-[1.1rem] border px-3 text-left transition ${
className={`relative h-[4.45rem] w-[5.2rem] shrink-0 snap-start overflow-hidden rounded-[0.9rem] border p-0 text-left transition sm:h-[5.2rem] sm:w-[6.1rem] ${
active
? 'border-[var(--platform-button-primary-border)] bg-[var(--platform-nav-active-fill)] text-[var(--platform-text-strong)]'
: 'border-[var(--platform-subpanel-border)] bg-white/72 text-[var(--platform-text-base)] hover:bg-white'
? 'border-[#ff4056] ring-1 ring-inset ring-[#ff4056]'
: 'border-[var(--platform-subpanel-border)]'
} ${disabled ? 'cursor-not-allowed opacity-55' : ''}`}
>
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-white/80">
<Icon className="h-4 w-4" />
<span className="absolute inset-0 bg-[linear-gradient(135deg,rgba(255,255,255,0.98),rgba(244,247,255,0.9))]" />
<span className="absolute inset-0 bg-[radial-gradient(circle_at_30%_20%,rgba(255,255,255,0.95),transparent_28%),linear-gradient(135deg,rgba(255,64,86,0.18),rgba(56,189,248,0.18))]" />
<span className="absolute inset-0 bg-[linear-gradient(180deg,rgba(3,7,18,0.02)_0%,rgba(3,7,18,0.1)_42%,rgba(3,7,18,0.82)_100%)]" />
<span className="absolute inset-x-2 bottom-1.5 truncate rounded-full bg-black/26 px-1.5 py-0.5 text-center text-[11px] font-black text-white [text-shadow:0_1px_6px_rgba(0,0,0,0.9)]">
{label}
</span>
<span className="min-w-0 truncate text-sm font-black">{label}</span>
</button>
);
}
export function VisualNovelAgentWorkspace({
session = mockVisualNovelSession,
session = null,
isBusy = false,
error = null,
streamingReplyText = '',
onBack,
onCreateSession,
onSubmitMessage,
onExecuteAction,
onOpenResult,
onCreateFromForm,
initialFormPayload = null,
showBackButton = true,
title = null,
}: VisualNovelAgentWorkspaceProps) {
const [sourceMode, setSourceMode] = useState<VisualNovelSourceMode>(
session?.sourceMode ?? 'idea',
const [formState, setFormState] = useState<VisualNovelFormState>(() =>
resolveInitialFormState(session, initialFormPayload),
);
const [seedText, setSeedText] = useState(
session?.messages.find((message) => message.role === 'user')?.text ?? '',
const appliedInitialFormKeyRef = useRef<string | null>(null);
useEffect(() => {
const nextInitialFormKey =
session?.sessionId ?? JSON.stringify(initialFormPayload ?? null);
if (appliedInitialFormKeyRef.current === nextInitialFormKey) {
return;
}
appliedInitialFormKeyRef.current = nextInitialFormKey;
setFormState(resolveInitialFormState(session, initialFormPayload));
}, [initialFormPayload, session]);
const ideaText = formState.ideaText.trim();
const selectedStyleOption = getVisualNovelStyleOption(
formState.visualStyleId,
);
const [documentAssetId, setDocumentAssetId] = useState(
session?.draft?.sourceAssetIds[0] ?? '',
);
const [documentAssetLabel, setDocumentAssetLabel] = useState('');
const [documentUploadError, setDocumentUploadError] = useState<string | null>(
null,
);
const [isDocumentUploading, setIsDocumentUploading] = useState(false);
const [messageText, setMessageText] = useState('');
const displaySession = session ?? mockVisualNovelSession;
const draft = displaySession.draft;
const authUi = useAuthUi();
const documentFileInputRef = useRef<HTMLInputElement | null>(null);
const canStart =
!isBusy &&
((sourceMode === 'blank') ||
(sourceMode === 'idea' && Boolean(seedText.trim())) ||
(sourceMode === 'document' && Boolean(documentAssetId.trim())));
const canSend = !isBusy && messageText.trim();
const pendingAction = displaySession.pendingAction;
const progressItems = useMemo(
() => [
{ label: '世界观', value: draft?.world.title || '-' },
{ label: '角色', value: `${draft?.characters.length ?? 0}` },
{ label: '场景', value: `${draft?.scenes.length ?? 0}` },
{ label: '阶段', value: `${draft?.storyPhases.length ?? 0}` },
],
[draft],
const formPayload = useMemo<VisualNovelEntryFormPayload>(
() => ({
sourceMode: 'idea',
seedText: buildVisualNovelSeedText(
ideaText,
selectedStyleOption.label,
selectedStyleOption.prompt,
),
sourceAssetIds: [],
ideaText,
visualStyleId: selectedStyleOption.id,
visualStyleLabel: selectedStyleOption.label,
visualStylePrompt: selectedStyleOption.prompt,
}),
[ideaText, selectedStyleOption],
);
const canSubmit = Boolean(ideaText && !isBusy);
const startDraft = () => {
if (!canStart) {
if (!canSubmit) {
return;
}
if (sourceMode === 'blank') {
const blankSession = createMockVisualNovelSessionFromDraft(
createBlankVisualNovelDraft('blank'),
);
onOpenResult?.(blankSession);
return;
}
onCreateSession?.({
sourceMode,
seedText: seedText.trim() || null,
sourceAssetIds:
sourceMode === 'document' && documentAssetId.trim()
? [documentAssetId.trim()]
: [],
});
};
const handleDocumentFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.currentTarget.value = '';
if (!file) {
return;
}
setIsDocumentUploading(true);
setDocumentUploadError(null);
try {
const parsed = await parseCreationAgentDocumentInput(file);
const uploadedAsset = await uploadVisualNovelAsset({
kind: 'document',
file,
ownerUserId: authUi?.user?.id ?? null,
});
setDocumentAssetId(uploadedAsset.assetObjectId);
setDocumentAssetLabel(file.name.trim() || parsed.document.fileName);
setSeedText(clampDocumentSeedText(parsed.document.text));
} catch (uploadError) {
setDocumentUploadError(
uploadError instanceof Error
? uploadError.message
: '文档上传失败,请稍后重试。',
);
} finally {
setIsDocumentUploading(false);
}
};
const submitMessage = () => {
const text = messageText.trim();
if (!text || isBusy) {
return;
}
onSubmitMessage?.({
clientMessageId: buildClientMessageId(),
text,
});
setMessageText('');
onCreateFromForm?.(formPayload);
};
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col">
<div className="mb-4 flex items-center justify-between gap-3">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className="platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px]"
>
<ArrowLeft className="h-3.5 w-3.5" />
</button>
</div>
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden">
{showBackButton ? (
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
>
<ArrowLeft className="h-3.5 w-3.5" />
</button>
</div>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(20rem,0.55fr)]">
<section className="platform-subpanel rounded-[1.45rem] p-4 sm:p-5">
<div className="mb-4">
<h1 className="m-0 text-3xl font-black leading-tight text-[var(--platform-text-strong)] sm:text-5xl">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden pr-0">
{title ? (
<div className="mb-3 shrink-0 sm:mb-5">
<div className="flex flex-wrap items-center gap-2">
<h1 className="m-0 text-3xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
{title}
</h1>
<span className="rounded-full border border-rose-200 bg-rose-50 px-3 py-1 text-[11px] font-black text-rose-700">
BETA
</span>
</div>
</div>
) : null}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{SOURCE_OPTIONS.map((option) => (
<VisualNovelSourceButton
key={option.id}
active={sourceMode === option.id}
disabled={isBusy}
icon={option.icon}
label={option.label}
onClick={() => setSourceMode(option.id)}
/>
))}
</div>
{sourceMode === 'document' ? (
<div className="mt-3 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
disabled={isBusy || isDocumentUploading}
onClick={() => documentFileInputRef.current?.click()}
className="platform-button platform-button--secondary min-h-10 px-4 py-2 text-sm"
>
{isDocumentUploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{documentAssetId ? '重新上传文档' : '上传平台文档'}
</button>
{documentAssetId ? (
<span className="rounded-full border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-2 text-xs font-semibold text-[var(--platform-text-base)]">
{documentAssetLabel || '已绑定平台文档'}
</span>
) : null}
<input
ref={documentFileInputRef}
type="file"
accept=".txt,.md,.markdown,.docx,.csv,.json"
disabled={isBusy || isDocumentUploading}
onChange={(event) => {
void handleDocumentFileChange(event);
}}
className="hidden"
aria-label="上传文档"
/>
</div>
{documentAssetId ? (
<div className="truncate rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-4 py-2 text-xs font-semibold text-[var(--platform-text-soft)]">
ID{documentAssetId}
</div>
) : null}
{documentUploadError ? (
<div className="rounded-[1rem] border border-rose-200/50 bg-rose-500/10 px-4 py-2 text-sm leading-6 text-rose-700">
{documentUploadError}
</div>
) : null}
</div>
) : null}
<label className="mt-4 block">
<span className="sr-only"></span>
<section className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div
className={`grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] gap-2 sm:gap-3 lg:grid-cols-[minmax(0,1.1fr)_minmax(16rem,0.9fr)] lg:grid-rows-1 ${isBusy ? 'opacity-55' : ''}`}
>
<label className="block min-h-0">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<textarea
value={seedText}
disabled={isBusy || sourceMode === 'blank'}
rows={8}
onChange={(event) => setSeedText(event.target.value)}
placeholder={
sourceMode === 'document'
? '粘贴文档摘要或选择平台文档资产'
: sourceMode === 'blank'
? '空白创建将直接进入结果页'
: '雪夜列车、旧电台、失踪乘客'
value={formState.ideaText}
disabled={isBusy}
rows={5}
placeholder=""
onChange={(event) =>
setFormState((current) => ({
...current,
ideaText: event.target.value,
}))
}
className="w-full resize-none rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/88 px-4 py-4 text-base leading-7 text-[var(--platform-text-strong)] outline-none placeholder:text-zinc-400 disabled:opacity-60"
aria-label="创作想法"
className="h-full min-h-[7.75rem] w-full resize-none rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none sm:min-h-[9rem] lg:min-h-[14rem]"
aria-label="一句话创作"
/>
</label>
<div className="flex min-h-0 flex-col gap-2 overflow-hidden">
<div className="min-h-0">
<div className="mb-1.5 text-sm font-black text-[var(--platform-text-strong)]">
</div>
<div
className="flex snap-x gap-2 overflow-x-auto overscroll-x-contain pb-1 scrollbar-hide touch-pan-x [-webkit-overflow-scrolling:touch]"
aria-label="视觉画风"
>
{VISUAL_NOVEL_STYLE_OPTIONS.map((option) => (
<VisualNovelStyleButton
key={option.id}
active={formState.visualStyleId === option.id}
disabled={isBusy}
label={option.label}
onClick={() =>
setFormState((current) => ({
...current,
visualStyleId: option.id,
}))
}
/>
))}
</div>
</div>
</div>
</div>
<div className="mt-2 shrink-0 space-y-3">
{error ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{error}
</div>
) : null}
<div className="mt-4 flex justify-end">
<button
type="button"
disabled={!canStart}
onClick={startDraft}
className="platform-button platform-button--primary min-h-11 px-5 py-3"
>
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<Sparkles className="h-4 w-4" />
{sourceMode === 'blank' ? '进入结果页' : '生成底稿'}
</button>
</div>
</section>
<aside className="platform-subpanel flex min-h-[22rem] flex-col rounded-[1.45rem] p-4 sm:p-5">
<div className="grid grid-cols-4 gap-2">
{progressItems.map((item) => (
<div
key={item.label}
className="min-w-0 rounded-[0.9rem] bg-white/72 px-2 py-2 text-center"
>
<div className="truncate text-[11px] font-bold text-[var(--platform-text-soft)]">
{item.label}
</div>
<div className="mt-1 truncate text-sm font-black text-[var(--platform-text-strong)]">
{item.value}
</div>
</div>
))}
</div>
<div className="mt-4 min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
{displaySession.messages.map((message) => (
<div
key={message.id}
className={`max-w-[88%] rounded-[1.1rem] px-3 py-2 text-sm leading-6 ${
message.role === 'user'
? 'ml-auto bg-[var(--platform-button-primary-fill)] text-[var(--platform-button-primary-text)]'
: 'bg-white/78 text-[var(--platform-text-strong)]'
}`}
>
{message.text}
</div>
))}
{streamingReplyText ? (
<div className="max-w-[88%] rounded-[1.1rem] bg-white/78 px-3 py-2 text-sm leading-6 text-[var(--platform-text-strong)]">
{streamingReplyText}
</div>
) : null}
</div>
{pendingAction ? (
<button
type="button"
disabled={isBusy}
onClick={() =>
onExecuteAction?.({
actionId: pendingAction.actionId,
kind: pendingAction.kind,
targetId: pendingAction.targetId ?? null,
payload: pendingAction.payload,
})
}
className="platform-button platform-button--secondary mt-4 min-h-11 justify-center px-4 py-3"
>
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<Sparkles className="h-4 w-4" />
{pendingAction.label || '执行操作'}
</button>
) : null}
<div className="mt-4 flex gap-2">
<input
value={messageText}
disabled={isBusy}
onChange={(event) => setMessageText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
submitMessage();
}
}}
className="min-h-11 min-w-0 flex-1 rounded-full border border-[var(--platform-subpanel-border)] bg-white/88 px-4 text-sm text-[var(--platform-text-strong)] outline-none"
placeholder="补充设定"
aria-label="补充设定"
/>
<button
type="button"
disabled={!canSend}
onClick={submitMessage}
className="platform-icon-button h-11 w-11"
aria-label="发送"
title="发送"
>
<Send className="h-4 w-4" />
</button>
</div>
</aside>
</div>
</div>
</section>
</div>
<div className="mt-4 flex justify-end pb-[max(0.25rem,env(safe-area-inset-bottom))]">
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
<button
type="button"
disabled={isBusy || !displaySession.draft}
onClick={() => onOpenResult?.(displaySession)}
className="platform-button platform-button--primary min-h-11 px-5 py-3"
disabled={!canSubmit}
onClick={startDraft}
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${!canSubmit ? 'cursor-not-allowed opacity-55' : ''}`}
>
<span className="inline-flex flex-wrap items-center justify-center gap-1.5 sm:gap-2">
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{session ? (
<Sparkles className="h-4 w-4" />
) : (
<WandSparkles className="h-4 w-4" />
)}
<span>稿</span>
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
20
</span>
</span>
</button>
</div>
</div>
);
}
export function buildVisualNovelEntryGenerationAnchorEntries(
payload: VisualNovelEntryFormPayload | null | undefined,
): CustomWorldStructuredAnchorEntry[] {
if (!payload) {
return [];
}
return [
{
id: 'visual-novel-idea',
label: '一句话',
value: payload.ideaText,
},
{
id: 'visual-novel-style',
label: '视觉画风',
value: payload.visualStyleLabel,
},
].filter((entry) => entry.value.trim());
}
export function buildVisualNovelEntryGenerationProgress(
startedAtMs: number | null,
phase: 'generating' | 'ready' | 'failed',
nowMs = Date.now(),
): CustomWorldGenerationProgress {
const elapsedMs = startedAtMs ? Math.max(0, nowMs - startedAtMs) : 0;
const timeline: [
{
id: string;
label: string;
detail: string;
weight: number;
durationMs: number;
},
{
id: string;
label: string;
detail: string;
weight: number;
durationMs: number;
},
{
id: string;
label: string;
detail: string;
weight: number;
durationMs: number;
},
] = [
{
id: 'visual-novel-session',
label: '创建创作会话',
detail: '写入一句话与视觉画风,准备生成视觉小说底稿。',
weight: 24,
durationMs: 5_000,
},
{
id: 'visual-novel-draft',
label: '生成故事底稿',
detail: '整理世界观、角色、场景和剧情阶段。',
weight: 56,
durationMs: 22_000,
},
{
id: 'visual-novel-ready',
label: '准备草稿页',
detail: '校验可编辑字段并进入草稿页。',
weight: 20,
durationMs: 4_000,
},
];
let elapsedBeforeStep = 0;
const activeStepIndex =
phase === 'ready'
? timeline.length - 1
: timeline.findIndex((step) => {
const elapsedInStep = elapsedMs - elapsedBeforeStep;
const isActive = elapsedInStep < step.durationMs;
if (!isActive) {
elapsedBeforeStep += step.durationMs;
}
return isActive;
});
const normalizedActiveStepIndex =
activeStepIndex >= 0 ? activeStepIndex : timeline.length - 1;
const activeStep = timeline[normalizedActiveStepIndex] ?? timeline[0];
const activeElapsed =
elapsedMs -
timeline
.slice(0, normalizedActiveStepIndex)
.reduce((sum, step) => sum + step.durationMs, 0);
const activeRatio =
phase === 'ready'
? 1
: phase === 'failed'
? 0
: Math.max(0, Math.min(1, activeElapsed / activeStep.durationMs));
const completedWeight = timeline
.slice(0, phase === 'ready' ? timeline.length : normalizedActiveStepIndex)
.reduce((sum, step) => sum + step.weight, 0);
const overallProgress =
phase === 'ready'
? 100
: phase === 'failed'
? Math.max(1, completedWeight)
: Math.min(98, completedWeight + activeStep.weight * activeRatio);
return {
phaseId: phase,
phaseLabel:
phase === 'ready'
? '生成完成'
: phase === 'failed'
? '生成失败'
: activeStep.label,
phaseDetail:
phase === 'ready'
? '视觉小说草稿已准备完成。'
: phase === 'failed'
? '草稿生成失败,请返回入口页调整后重试。'
: activeStep.detail,
batchLabel: activeStep.label,
overallProgress: Math.max(0, Math.min(100, Math.round(overallProgress))),
completedWeight: Math.max(0, Math.min(100, Math.round(overallProgress))),
totalWeight: 100,
elapsedMs,
estimatedRemainingMs:
phase === 'ready' ? 0 : Math.max(0, 31_000 - elapsedMs),
activeStepIndex: normalizedActiveStepIndex,
steps: timeline.map((step, index) => {
const isCompleted =
phase === 'ready' || index < normalizedActiveStepIndex;
const isActive =
phase !== 'failed' &&
!isCompleted &&
index === normalizedActiveStepIndex;
const status: 'completed' | 'active' | 'pending' = isCompleted
? 'completed'
: isActive
? 'active'
: 'pending';
return {
id: step.id,
label: step.label,
detail: step.detail,
completed: isCompleted ? 1 : isActive ? activeRatio : 0,
total: 1,
status,
};
}),
};
}
export default VisualNovelAgentWorkspace;