收口统一创作流程一期

This commit is contained in:
2026-05-31 14:46:32 +00:00
parent 724d8be405
commit 23dec91bd6
36 changed files with 919 additions and 469 deletions

View File

@@ -0,0 +1,786 @@
import { ArrowLeft } from 'lucide-react';
import {
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { PuzzleAgentActionRequest } from '../../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
CreatePuzzleAgentSessionRequest,
PuzzleAgentSessionSnapshot,
SendPuzzleAgentMessageRequest,
} from '../../../../packages/shared/src/contracts/puzzleAgentSession';
import { getPuzzleHistoryAssetReferenceLabel } from '../../../services/puzzle-works/puzzleHistoryAsset';
import {
cropPuzzleReferenceImageDataUrl,
isPuzzleReferenceImageSquare,
readPuzzleReferenceImageAsDataUrl,
readPuzzleReferenceImageForUpload,
} from '../../../services/puzzleReferenceImage';
import {
CreativeImageInputPanel,
type CreativeImageInputReferenceImage,
} from '../../common/CreativeImageInputPanel';
import {
buildCenteredSquareImageCropRect,
clampSquareImageCropRect,
SquareImageCropModal,
type SquareImageCropRect,
} from '../../common/SquareImageCropModal';
import PuzzleHistoryAssetPickerDialog from '../shared/PuzzleHistoryAssetPickerDialog';
import {
normalizePuzzleImageModel,
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
type PuzzleImageModelId,
} from '../shared/puzzleImageModelOptions';
import { PuzzleImageModelPicker } from '../shared/PuzzleImageModelPicker';
type PuzzleCreationWorkspaceProps = {
session: PuzzleAgentSessionSnapshot | null;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onSubmitMessage: (payload: SendPuzzleAgentMessageRequest) => void;
onExecuteAction: (payload: PuzzleAgentActionRequest) => void;
onCreateFromForm?: (payload: CreatePuzzleAgentSessionRequest) => void;
onAutoSaveForm?: (payload: CreatePuzzleAgentSessionRequest) => void;
initialFormPayload?: CreatePuzzleAgentSessionRequest | null;
showBackButton?: boolean;
title?: string | null;
unifiedChrome?: boolean;
};
type PuzzleFormState = {
pictureDescription: string;
referenceImageSrc: string;
referenceImageAssetObjectId: string;
referenceImageLabel: string;
referenceImageSrcs: CreativeImageInputReferenceImage[];
imageModel: PuzzleImageModelId;
aiRedraw: boolean;
};
const EMPTY_FORM_STATE: PuzzleFormState = {
pictureDescription: '',
referenceImageSrc: '',
referenceImageAssetObjectId: '',
referenceImageLabel: '',
referenceImageSrcs: [],
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
aiRedraw: true,
};
const PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT = 5;
type PuzzleImageCropState = {
source: string;
label: string;
fileName: string;
imageSize: { width: number; height: number };
cropRect: SquareImageCropRect;
error: string | null;
isSaving: boolean;
};
function resolveInitialFormState(
session: PuzzleAgentSessionSnapshot | null,
initialFormPayload: CreatePuzzleAgentSessionRequest | null = null,
): PuzzleFormState {
const shouldTreatEmptyPayloadAsFreshForm =
!session &&
Boolean(initialFormPayload) &&
Object.keys(initialFormPayload ?? {}).length === 0;
if (shouldTreatEmptyPayloadAsFreshForm) {
return EMPTY_FORM_STATE;
}
const formDraft = session?.draft?.formDraft;
if (formDraft) {
return {
pictureDescription: formDraft.pictureDescription ?? '',
referenceImageSrc: initialFormPayload?.referenceImageSrc ?? '',
referenceImageAssetObjectId:
initialFormPayload?.referenceImageAssetObjectId ?? '',
referenceImageLabel: initialFormPayload?.referenceImageSrc
? '已选择拼图图片'
: '',
referenceImageSrcs: createPuzzlePromptReferenceImagesFromSources(
initialFormPayload?.referenceImageSrcs,
initialFormPayload?.referenceImageAssetObjectIds,
),
imageModel: normalizePuzzleImageModel(initialFormPayload?.imageModel),
aiRedraw: initialFormPayload?.aiRedraw ?? true,
};
}
if (initialFormPayload) {
return {
pictureDescription:
initialFormPayload.pictureDescription ??
initialFormPayload.seedText ??
'',
referenceImageSrc: initialFormPayload.referenceImageSrc ?? '',
referenceImageAssetObjectId:
initialFormPayload.referenceImageAssetObjectId ?? '',
referenceImageLabel: initialFormPayload.referenceImageSrc
? '已选择拼图图片'
: '',
referenceImageSrcs: createPuzzlePromptReferenceImagesFromSources(
initialFormPayload.referenceImageSrcs,
initialFormPayload.referenceImageAssetObjectIds,
),
imageModel: normalizePuzzleImageModel(initialFormPayload.imageModel),
aiRedraw: initialFormPayload.aiRedraw ?? true,
};
}
if (!session) {
return EMPTY_FORM_STATE;
}
return {
pictureDescription:
session.draft?.formDraft?.pictureDescription ||
session.draft?.levels?.[0]?.pictureDescription ||
session.anchorPack.visualSubject.value ||
session.seedText ||
'',
referenceImageSrc: '',
referenceImageAssetObjectId: '',
referenceImageLabel: '',
referenceImageSrcs: [],
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
aiRedraw: true,
};
}
function normalizePuzzlePromptReferenceSources(
sources: readonly string[] | null | undefined,
) {
const normalizedSources: string[] = [];
for (const source of sources ?? []) {
const normalized = source.trim();
if (
normalized &&
!normalizedSources.some((current) => current === normalized)
) {
normalizedSources.push(normalized);
}
if (normalizedSources.length >= PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT) {
break;
}
}
return normalizedSources;
}
function createPuzzlePromptReferenceImagesFromSources(
sources: readonly string[] | null | undefined,
assetObjectIds: readonly string[] | null | undefined = [],
): CreativeImageInputReferenceImage[] {
const assetIds = normalizePuzzleAssetObjectIds(assetObjectIds);
const sourceImages = normalizePuzzlePromptReferenceSources(sources).map(
(imageSrc, index) => ({
id: `restored:${index}:${imageSrc}`,
label: `参考图 ${index + 1}`,
imageSrc,
assetObjectId: assetIds[index] ?? null,
}),
);
if (sourceImages.length > 0) {
return sourceImages;
}
return assetIds.map((assetObjectId, index) => ({
id: `restored-asset:${index}:${assetObjectId}`,
label: `参考图 ${index + 1}`,
imageSrc: '',
assetObjectId,
}));
}
function normalizePuzzleAssetObjectIds(
assetObjectIds: readonly (string | null | undefined)[] | null | undefined,
) {
const normalizedIds: string[] = [];
for (const assetObjectId of assetObjectIds ?? []) {
const normalized = assetObjectId?.trim() ?? '';
if (
normalized &&
!normalizedIds.some((current) => current === normalized)
) {
normalizedIds.push(normalized);
}
if (normalizedIds.length >= PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT) {
break;
}
}
return normalizedIds;
}
function addPuzzlePromptReferenceImage(
currentImages: CreativeImageInputReferenceImage[],
nextImage: CreativeImageInputReferenceImage,
) {
const deduped = currentImages.filter(
(image) => image.imageSrc !== nextImage.imageSrc,
);
return [...deduped, nextImage].slice(
0,
PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT,
);
}
/**
* 拼图创作入口已从 Agent 对话改为填表式。
* 统一创作目录内的拼图工作台实现。
*/
export function PuzzleCreationWorkspace({
session,
isBusy = false,
error = null,
onBack,
onExecuteAction,
onCreateFromForm,
onAutoSaveForm,
initialFormPayload = null,
showBackButton = true,
title = '想做个什么玩法?',
unifiedChrome = false,
}: PuzzleCreationWorkspaceProps) {
const [formState, setFormState] = useState<PuzzleFormState>(() =>
resolveInitialFormState(session, initialFormPayload),
);
const [referenceImageError, setReferenceImageError] = useState<string | null>(
null,
);
const [cropState, setCropState] = useState<PuzzleImageCropState | null>(null);
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
const [isPointCostConfirmOpen, setIsPointCostConfirmOpen] = useState(false);
const previousSessionIdRef = useRef<string | null>(
session?.sessionId ?? null,
);
const appliedInitialFormKeyRef = useRef<string | null>(null);
useEffect(() => {
const currentSessionId = session?.sessionId ?? null;
if (
currentSessionId &&
previousSessionIdRef.current === null &&
appliedInitialFormKeyRef.current ===
JSON.stringify(initialFormPayload ?? null)
) {
previousSessionIdRef.current = currentSessionId;
return;
}
previousSessionIdRef.current = currentSessionId;
const nextInitialFormKey =
currentSessionId ?? JSON.stringify(initialFormPayload ?? null);
if (appliedInitialFormKeyRef.current === nextInitialFormKey) {
return;
}
appliedInitialFormKeyRef.current = nextInitialFormKey;
setFormState(resolveInitialFormState(session, initialFormPayload));
setReferenceImageError(null);
setCropState(null);
setIsHistoryPickerOpen(false);
setIsPointCostConfirmOpen(false);
}, [initialFormPayload, session]);
const pictureDescription = formState.pictureDescription.trim();
const promptReferenceImageSrcs = useMemo(
() =>
normalizePuzzlePromptReferenceSources(
formState.referenceImageSrc
? []
: formState.referenceImageSrcs.map((image) => image.imageSrc),
),
[formState.referenceImageSrc, formState.referenceImageSrcs],
);
const promptReferenceAssetObjectIds = useMemo(
() =>
formState.referenceImageSrc
? []
: normalizePuzzleAssetObjectIds(
formState.referenceImageSrcs.map((image) => image.assetObjectId),
),
[formState.referenceImageSrc, formState.referenceImageSrcs],
);
const mainReferenceImageSrcForPayload =
formState.referenceImageAssetObjectId && formState.aiRedraw
? null
: formState.referenceImageSrc || null;
const promptReferenceImageSrcsForPayload =
promptReferenceAssetObjectIds.length > 0 ? [] : promptReferenceImageSrcs;
const canSubmit = formState.aiRedraw
? Boolean(pictureDescription) && !isBusy
: Boolean(formState.referenceImageSrc) && !isBusy;
const autosavePayload = useMemo(
() => ({
seedText: pictureDescription,
pictureDescription,
referenceImageSrc: mainReferenceImageSrcForPayload,
referenceImageSrcs: promptReferenceImageSrcsForPayload,
referenceImageAssetObjectId:
formState.referenceImageAssetObjectId || null,
referenceImageAssetObjectIds: promptReferenceAssetObjectIds,
imageModel: formState.imageModel,
aiRedraw: formState.aiRedraw,
}),
[
formState.aiRedraw,
formState.referenceImageAssetObjectId,
formState.imageModel,
mainReferenceImageSrcForPayload,
promptReferenceAssetObjectIds,
promptReferenceImageSrcsForPayload,
pictureDescription,
],
);
const autosaveSignature = JSON.stringify([
autosavePayload.pictureDescription,
autosavePayload.referenceImageSrc,
autosavePayload.referenceImageSrcs,
autosavePayload.referenceImageAssetObjectId,
autosavePayload.referenceImageAssetObjectIds,
autosavePayload.aiRedraw,
autosavePayload.imageModel,
]);
const lastAutosaveSignatureRef = useRef(autosaveSignature);
const autosaveSessionIdRef = useRef(session?.sessionId ?? null);
useEffect(() => {
const currentSessionId = session?.sessionId ?? null;
if (autosaveSessionIdRef.current === currentSessionId) {
return;
}
autosaveSessionIdRef.current = currentSessionId;
lastAutosaveSignatureRef.current = autosaveSignature;
}, [autosaveSignature, session]);
useEffect(() => {
if (
!session ||
session.stage !== 'collecting_anchors' ||
!session.draft?.formDraft ||
!onAutoSaveForm ||
lastAutosaveSignatureRef.current === autosaveSignature
) {
return;
}
const timer = window.setTimeout(() => {
lastAutosaveSignatureRef.current = autosaveSignature;
onAutoSaveForm(autosavePayload);
}, 700);
return () => window.clearTimeout(timer);
}, [
autosavePayload,
autosaveSignature,
onAutoSaveForm,
session?.draft?.formDraft,
session?.stage,
session,
]);
const handleReferenceImageFile = async (file: File) => {
try {
const uploadImage = await readPuzzleReferenceImageForUpload(file);
if (!isPuzzleReferenceImageSquare(uploadImage)) {
const imageSize = {
width: uploadImage.width,
height: uploadImage.height,
};
setCropState({
source: uploadImage.dataUrl,
label: file.name.trim() || '本地拼图图片',
fileName: file.name.trim() || 'puzzle-reference.jpg',
imageSize,
cropRect: buildCenteredSquareImageCropRect(imageSize),
error: null,
isSaving: false,
});
setReferenceImageError(null);
return;
}
setFormState((current) => ({
...current,
referenceImageSrc: uploadImage.dataUrl,
referenceImageAssetObjectId: '',
referenceImageLabel: file.name.trim() || '本地拼图图片',
}));
setReferenceImageError(null);
} catch (uploadError) {
setReferenceImageError(
uploadError instanceof Error
? uploadError.message
: '拼图图片读取失败,请重试。',
);
}
};
const handlePromptReferenceImageFiles = async (files: File[]) => {
if (files.length === 0) {
return;
}
const remainingSlots =
PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT -
formState.referenceImageSrcs.length;
if (remainingSlots <= 0) {
setReferenceImageError('参考图最多上传 5 张。');
return;
}
try {
const images = await Promise.all(
files.slice(0, remainingSlots).map(async (file, index) => ({
id: `prompt-upload:${Date.now()}:${index}:${file.name}`,
label: file.name.trim() || `参考图 ${index + 1}`,
imageSrc: await readPuzzleReferenceImageAsDataUrl(file),
assetObjectId: null,
})),
);
setFormState((current) => ({
...current,
referenceImageSrcs: images.reduce(
addPuzzlePromptReferenceImage,
current.referenceImageSrcs,
),
}));
setReferenceImageError(
files.length > remainingSlots ? '参考图最多上传 5 张。' : null,
);
} catch (uploadError) {
setReferenceImageError(
uploadError instanceof Error
? uploadError.message
: '参考图读取失败,请重试。',
);
}
};
const removePromptReferenceImage = (referenceId: string) => {
setFormState((current) => ({
...current,
referenceImageSrcs: current.referenceImageSrcs.filter(
(image) => image.id !== referenceId,
),
}));
setReferenceImageError(null);
};
const updateCropState = (nextCrop: { x: number; y: number; size: number }) => {
setCropState((current) => {
if (!current) {
return current;
}
const clamped = clampSquareImageCropRect(current.imageSize, nextCrop);
return {
...current,
cropRect: clamped,
};
});
};
const applyCropState = async () => {
const currentCropState = cropState;
if (!currentCropState) {
return;
}
setCropState({
...currentCropState,
isSaving: true,
error: null,
});
try {
const dataUrl = await cropPuzzleReferenceImageDataUrl({
source: currentCropState.source,
cropX: currentCropState.cropRect.x,
cropY: currentCropState.cropRect.y,
cropSize: currentCropState.cropRect.size,
});
setFormState((current) => ({
...current,
referenceImageSrc: dataUrl,
referenceImageAssetObjectId: '',
referenceImageLabel: currentCropState.label,
}));
setCropState(null);
setReferenceImageError(null);
} catch (cropError) {
setCropState({
...currentCropState,
isSaving: false,
error:
cropError instanceof Error
? cropError.message
: '拼图图片裁剪失败,请重试。',
});
}
};
const submitForm = () => {
if (!canSubmit) {
return;
}
if (formState.aiRedraw) {
setIsPointCostConfirmOpen(true);
return;
}
executeSubmitForm();
};
const executeSubmitForm = () => {
if (!canSubmit) {
return;
}
const payloadPictureDescription = formState.aiRedraw
? pictureDescription
: pictureDescription || formState.referenceImageLabel || '上传拼图图片';
const payload = {
seedText: payloadPictureDescription,
pictureDescription: payloadPictureDescription,
referenceImageSrc: mainReferenceImageSrcForPayload,
referenceImageSrcs: promptReferenceImageSrcsForPayload,
referenceImageAssetObjectId:
formState.referenceImageAssetObjectId || null,
referenceImageAssetObjectIds: promptReferenceAssetObjectIds,
imageModel: formState.imageModel,
aiRedraw: formState.aiRedraw,
};
if (!session && onCreateFromForm) {
setIsPointCostConfirmOpen(false);
onCreateFromForm(payload);
return;
}
setIsPointCostConfirmOpen(false);
onExecuteAction({
action: 'compile_puzzle_draft',
promptText: payloadPictureDescription,
pictureDescription: payloadPictureDescription,
referenceImageSrc: mainReferenceImageSrcForPayload,
referenceImageSrcs: promptReferenceImageSrcsForPayload,
referenceImageAssetObjectId:
formState.referenceImageAssetObjectId || null,
referenceImageAssetObjectIds: promptReferenceAssetObjectIds,
imageModel: formState.imageModel,
aiRedraw: formState.aiRedraw,
candidateCount: 1,
});
};
const removeReferenceImage = () => {
setFormState((current) => ({
...current,
referenceImageSrc: '',
referenceImageAssetObjectId: '',
referenceImageLabel: '',
aiRedraw: true,
}));
setReferenceImageError(null);
};
return (
<div
className={
unifiedChrome
? 'puzzle-agent-workspace mx-auto flex min-h-0 w-full max-w-none flex-col overflow-visible'
: 'puzzle-agent-workspace platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden'
}
data-unified-chrome={unifiedChrome ? 'true' : 'false'}
>
{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' : ''}`}
>
<span className="inline-flex items-center gap-1.5">
<ArrowLeft className="h-3.5 w-3.5" />
</span>
</button>
</div>
) : null}
{title && !unifiedChrome ? (
<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}
<CreativeImageInputPanel
className={unifiedChrome ? 'min-h-0 flex-none' : ''}
fillHeight={!unifiedChrome}
disabled={isBusy}
isSubmitting={isBusy}
uploadedImageSrc={formState.referenceImageSrc}
uploadedImageAlt="拼图图片"
mainImageInputId="puzzle-image-upload-input"
promptTextareaId="puzzle-picture-description-input"
prompt={formState.pictureDescription}
promptLabel={
formState.referenceImageSrc ? '画面AI重绘要求提示词' : '画面描述'
}
promptRows={2}
aiRedraw={formState.aiRedraw}
promptReferenceImages={formState.referenceImageSrcs}
promptReferenceLimit={PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT}
imageLimitHint="图片≤6MB"
imageModelPicker={
<PuzzleImageModelPicker
value={formState.imageModel}
disabled={isBusy}
onChange={(imageModel) =>
setFormState((current) => ({
...current,
imageModel,
}))
}
/>
}
inputError={referenceImageError}
error={error}
submitLabel="生成拼图游戏草稿"
submitCostLabel={formState.aiRedraw ? '消耗2泥点' : null}
submitDisabled={!canSubmit}
labels={{
imageField: '拼图画面',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
history: '选择历史图片',
}}
onMainImageFileSelect={handleReferenceImageFile}
onMainImageRemove={removeReferenceImage}
onAiRedrawChange={(enabled) => {
setFormState((current) => ({
...current,
aiRedraw: enabled,
}));
}}
onPromptChange={(value) => {
setFormState((current) => ({
...current,
pictureDescription: value,
}));
}}
onPromptReferenceFilesSelect={(files) => {
void handlePromptReferenceImageFiles(files);
}}
onPromptReferenceRemove={removePromptReferenceImage}
onHistoryClick={() => setIsHistoryPickerOpen(true)}
onSubmit={submitForm}
/>
{cropState ? (
<SquareImageCropModal
source={cropState.source}
imageSize={cropState.imageSize}
cropRect={cropState.cropRect}
titleId="puzzle-image-crop-title"
labels={{
title: '裁剪拼图图片',
close: '关闭拼图图片裁剪',
editor: '拼图图片裁剪操作区',
previewAlt: '拼图图片裁剪预览',
cancel: '取消',
submit: '应用',
saving: '裁剪中',
}}
error={cropState.error}
isSaving={cropState.isSaving}
onCropRectChange={updateCropState}
onClose={() => setCropState(null)}
onSubmit={() => {
void applyCropState();
}}
/>
) : null}
{isHistoryPickerOpen ? (
<PuzzleHistoryAssetPickerDialog
isBusy={isBusy}
onClose={() => setIsHistoryPickerOpen(false)}
onSelect={(asset) => {
setFormState((current) => ({
...current,
referenceImageSrc: asset.imageSrc,
referenceImageAssetObjectId: asset.assetObjectId,
referenceImageLabel: getPuzzleHistoryAssetReferenceLabel(
asset.imageSrc,
),
}));
setReferenceImageError(null);
setIsHistoryPickerOpen(false);
}}
/>
) : null}
{isPointCostConfirmOpen ? (
<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="puzzle-point-cost-confirm-title"
className="platform-modal-shell platform-remap-surface w-full max-w-xs rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
>
<div
id="puzzle-point-cost-confirm-title"
className="text-base font-black text-[var(--platform-text-strong)]"
>
</div>
<div className="mt-2 text-sm font-semibold leading-6 text-[var(--platform-text-base)]">
2
</div>
<div className="mt-5 grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setIsPointCostConfirmOpen(false)}
className="platform-button platform-button--secondary justify-center"
>
</button>
<button
type="button"
disabled={!canSubmit}
onClick={executeSubmitForm}
className={`platform-button platform-button--primary justify-center ${!canSubmit ? 'cursor-not-allowed opacity-55' : ''}`}
>
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
export default PuzzleCreationWorkspace;