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

@@ -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>
);
}