收口统一创作流程一期
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user