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, puzzleReferenceImageDataUrlToFile, readPuzzleReferenceImageAsDataUrl, readPuzzleReferenceImageForUpload, } from '../../services/puzzleReferenceImage'; import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient'; import { CreativeImageInputPanel, type CreativeImageInputReferenceImage, } from '../common/CreativeImageInputPanel'; import { buildCenteredSquareImageCropRect, clampSquareImageCropRect, SquareImageCropModal, type SquareImageCropRect, } from '../common/SquareImageCropModal'; import PuzzleHistoryAssetPickerDialog from './PuzzleHistoryAssetPickerDialog'; import { normalizePuzzleImageModel, PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, type PuzzleImageModelId, } from './puzzleImageModelOptions'; import { PuzzleImageModelPicker } from './PuzzleImageModelPicker'; type PuzzleAgentWorkspaceProps = { 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; }; 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 对话改为填表式。 * 组件名保留为 PuzzleAgentWorkspace 以兼容现有路由与草稿恢复入口。 */ export function PuzzleAgentWorkspace({ session, isBusy = false, error = null, onBack, onExecuteAction, onCreateFromForm, onAutoSaveForm, initialFormPayload = null, showBackButton = true, title = '想做个什么玩法?', }: PuzzleAgentWorkspaceProps) { const [formState, setFormState] = useState(() => resolveInitialFormState(session, initialFormPayload), ); const [referenceImageError, setReferenceImageError] = useState( null, ); const [cropState, setCropState] = useState(null); const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false); const [isPointCostConfirmOpen, setIsPointCostConfirmOpen] = useState(false); const previousSessionIdRef = useRef( session?.sessionId ?? null, ); const appliedInitialFormKeyRef = useRef(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; } const asset = await puzzleAssetClient.uploadReferenceImage({ file }); setFormState((current) => ({ ...current, referenceImageSrc: asset.imageSrc || uploadImage.dataUrl, referenceImageAssetObjectId: asset.assetObjectId, 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) => { const [imageSrc, asset] = await Promise.all([ readPuzzleReferenceImageAsDataUrl(file), puzzleAssetClient.uploadReferenceImage({ file }), ]); return { id: `prompt-upload:${Date.now()}:${index}:${file.name}`, label: file.name.trim() || `参考图 ${index + 1}`, imageSrc: asset.imageSrc || imageSrc, assetObjectId: asset.assetObjectId, }; }), ); 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, }); const file = puzzleReferenceImageDataUrlToFile( dataUrl, currentCropState.fileName, ); const asset = await puzzleAssetClient.uploadReferenceImage({ file }); setFormState((current) => ({ ...current, referenceImageSrc: asset.imageSrc || dataUrl, referenceImageAssetObjectId: asset.assetObjectId, 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 (
{showBackButton ? (
) : null} {title ? (

{title}

BETA
) : null} 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 ? ( setCropState(null)} onSubmit={() => { void applyCropState(); }} /> ) : null} {isHistoryPickerOpen ? ( setIsHistoryPickerOpen(false)} onSelect={(asset) => { setFormState((current) => ({ ...current, referenceImageSrc: asset.imageSrc, referenceImageAssetObjectId: asset.assetObjectId, referenceImageLabel: getPuzzleHistoryAssetReferenceLabel( asset.imageSrc, ), })); setReferenceImageError(null); setIsHistoryPickerOpen(false); }} /> ) : null} {isPointCostConfirmOpen ? (
确认消耗泥点
消耗 2 泥点
) : null}
); } export default PuzzleAgentWorkspace;