Files
Genarrative/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx
2026-05-21 18:55:25 +08:00

789 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;