789 lines
25 KiB
TypeScript
789 lines
25 KiB
TypeScript
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<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;
|
||
}
|
||
|
||
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 (
|
||
<div className="platform-remap-surface puzzle-agent-workspace 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' : ''}`}
|
||
>
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<ArrowLeft className="h-3.5 w-3.5" />
|
||
返回
|
||
</span>
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
|
||
{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}
|
||
|
||
<CreativeImageInputPanel
|
||
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}
|
||
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 PuzzleAgentWorkspace;
|