Increase VectorEngine timeouts and add image UI
Add VectorEngine image generation config and raise request timeouts (env + scripts) from 180000 to 1000000ms. Introduce a reusable CreativeImageInputPanel component with tests and wire up mobile keyboard-focus helpers; update generation views and related tests (CustomWorldGenerationView, BarkBattle editor, Match3D, Puzzle flows). Improve API error handling / VectorEngine request guidance (packages/shared http.ts and docs), and apply multiple backend/frontend fixes for puzzle/match3d/prompt handling. Also include extensive docs and decision-log updates describing UI/UX decisions and verification steps.
This commit is contained in:
@@ -1,13 +1,5 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
History,
|
||||
ImagePlus,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
type ChangeEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
@@ -20,11 +12,17 @@ import type {
|
||||
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,
|
||||
@@ -57,6 +55,7 @@ type PuzzleFormState = {
|
||||
pictureDescription: string;
|
||||
referenceImageSrc: string;
|
||||
referenceImageLabel: string;
|
||||
referenceImageSrcs: CreativeImageInputReferenceImage[];
|
||||
imageModel: PuzzleImageModelId;
|
||||
aiRedraw: boolean;
|
||||
};
|
||||
@@ -65,10 +64,13 @@ const EMPTY_FORM_STATE: PuzzleFormState = {
|
||||
pictureDescription: '',
|
||||
referenceImageSrc: '',
|
||||
referenceImageLabel: '',
|
||||
referenceImageSrcs: [],
|
||||
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
aiRedraw: true,
|
||||
};
|
||||
|
||||
const PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT = 5;
|
||||
|
||||
type PuzzleImageCropState = {
|
||||
source: string;
|
||||
label: string;
|
||||
@@ -98,6 +100,9 @@ function resolveInitialFormState(
|
||||
referenceImageLabel: initialFormPayload?.referenceImageSrc
|
||||
? '已选择拼图图片'
|
||||
: '',
|
||||
referenceImageSrcs: createPuzzlePromptReferenceImagesFromSources(
|
||||
initialFormPayload?.referenceImageSrcs,
|
||||
),
|
||||
imageModel: normalizePuzzleImageModel(initialFormPayload?.imageModel),
|
||||
aiRedraw: initialFormPayload?.aiRedraw ?? true,
|
||||
};
|
||||
@@ -113,6 +118,9 @@ function resolveInitialFormState(
|
||||
referenceImageLabel: initialFormPayload.referenceImageSrc
|
||||
? '已选择拼图图片'
|
||||
: '',
|
||||
referenceImageSrcs: createPuzzlePromptReferenceImagesFromSources(
|
||||
initialFormPayload.referenceImageSrcs,
|
||||
),
|
||||
imageModel: normalizePuzzleImageModel(initialFormPayload.imageModel),
|
||||
aiRedraw: initialFormPayload.aiRedraw ?? true,
|
||||
};
|
||||
@@ -131,11 +139,56 @@ function resolveInitialFormState(
|
||||
'',
|
||||
referenceImageSrc: '',
|
||||
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,
|
||||
): CreativeImageInputReferenceImage[] {
|
||||
return normalizePuzzlePromptReferenceSources(sources).map(
|
||||
(imageSrc, index) => ({
|
||||
id: `restored:${index}:${imageSrc}`,
|
||||
label: `参考图 ${index + 1}`,
|
||||
imageSrc,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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 以兼容现有路由与草稿恢复入口。
|
||||
@@ -160,8 +213,6 @@ export function PuzzleAgentWorkspace({
|
||||
);
|
||||
const [cropState, setCropState] = useState<PuzzleImageCropState | null>(null);
|
||||
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
|
||||
const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] =
|
||||
useState(false);
|
||||
const [isPointCostConfirmOpen, setIsPointCostConfirmOpen] = useState(false);
|
||||
const previousSessionIdRef = useRef<string | null>(
|
||||
session?.sessionId ?? null,
|
||||
@@ -192,11 +243,19 @@ export function PuzzleAgentWorkspace({
|
||||
setReferenceImageError(null);
|
||||
setCropState(null);
|
||||
setIsHistoryPickerOpen(false);
|
||||
setIsRemoveImageConfirmOpen(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 canSubmit = formState.aiRedraw
|
||||
? Boolean(pictureDescription) && !isBusy
|
||||
: Boolean(formState.referenceImageSrc) && !isBusy;
|
||||
@@ -205,6 +264,7 @@ export function PuzzleAgentWorkspace({
|
||||
seedText: pictureDescription,
|
||||
pictureDescription,
|
||||
referenceImageSrc: formState.referenceImageSrc || null,
|
||||
referenceImageSrcs: promptReferenceImageSrcs,
|
||||
imageModel: formState.imageModel,
|
||||
aiRedraw: formState.aiRedraw,
|
||||
}),
|
||||
@@ -212,12 +272,14 @@ export function PuzzleAgentWorkspace({
|
||||
formState.aiRedraw,
|
||||
formState.referenceImageSrc,
|
||||
formState.imageModel,
|
||||
promptReferenceImageSrcs,
|
||||
pictureDescription,
|
||||
],
|
||||
);
|
||||
const autosaveSignature = JSON.stringify([
|
||||
autosavePayload.pictureDescription,
|
||||
autosavePayload.referenceImageSrc,
|
||||
autosavePayload.referenceImageSrcs,
|
||||
autosavePayload.aiRedraw,
|
||||
autosavePayload.imageModel,
|
||||
]);
|
||||
@@ -260,15 +322,7 @@ export function PuzzleAgentWorkspace({
|
||||
session,
|
||||
]);
|
||||
|
||||
const handleReferenceImageChange = async (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.currentTarget.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleReferenceImageFile = async (file: File) => {
|
||||
try {
|
||||
const uploadImage = await readPuzzleReferenceImageForUpload(file);
|
||||
if (!isPuzzleReferenceImageSquare(uploadImage)) {
|
||||
@@ -294,7 +348,6 @@ export function PuzzleAgentWorkspace({
|
||||
referenceImageLabel: file.name.trim() || '本地拼图图片',
|
||||
}));
|
||||
setReferenceImageError(null);
|
||||
setIsRemoveImageConfirmOpen(false);
|
||||
} catch (uploadError) {
|
||||
setReferenceImageError(
|
||||
uploadError instanceof Error
|
||||
@@ -304,6 +357,56 @@ export function PuzzleAgentWorkspace({
|
||||
}
|
||||
};
|
||||
|
||||
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),
|
||||
})),
|
||||
);
|
||||
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) {
|
||||
@@ -343,7 +446,6 @@ export function PuzzleAgentWorkspace({
|
||||
}));
|
||||
setCropState(null);
|
||||
setReferenceImageError(null);
|
||||
setIsRemoveImageConfirmOpen(false);
|
||||
} catch (cropError) {
|
||||
setCropState({
|
||||
...currentCropState,
|
||||
@@ -381,6 +483,7 @@ export function PuzzleAgentWorkspace({
|
||||
seedText: payloadPictureDescription,
|
||||
pictureDescription: payloadPictureDescription,
|
||||
referenceImageSrc: formState.referenceImageSrc || null,
|
||||
referenceImageSrcs: promptReferenceImageSrcs,
|
||||
imageModel: formState.imageModel,
|
||||
aiRedraw: formState.aiRedraw,
|
||||
};
|
||||
@@ -397,12 +500,13 @@ export function PuzzleAgentWorkspace({
|
||||
promptText: payloadPictureDescription,
|
||||
pictureDescription: payloadPictureDescription,
|
||||
referenceImageSrc: formState.referenceImageSrc || null,
|
||||
referenceImageSrcs: promptReferenceImageSrcs,
|
||||
imageModel: formState.imageModel,
|
||||
aiRedraw: formState.aiRedraw,
|
||||
candidateCount: 1,
|
||||
});
|
||||
};
|
||||
const confirmRemoveReferenceImage = () => {
|
||||
const removeReferenceImage = () => {
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
referenceImageSrc: '',
|
||||
@@ -410,11 +514,7 @@ export function PuzzleAgentWorkspace({
|
||||
aiRedraw: true,
|
||||
}));
|
||||
setReferenceImageError(null);
|
||||
setIsRemoveImageConfirmOpen(false);
|
||||
};
|
||||
const pictureDescriptionLabel = formState.referenceImageSrc
|
||||
? '画面AI重绘要求(提示词)'
|
||||
: '画面描述';
|
||||
|
||||
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">
|
||||
@@ -434,210 +534,85 @@ export function PuzzleAgentWorkspace({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="puzzle-creation-form-body flex min-h-0 flex-1 flex-col overflow-hidden pr-0 lg:overflow-y-auto lg:pr-1">
|
||||
{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>
|
||||
{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>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="puzzle-creation-form-section flex min-h-0 flex-1 flex-col overflow-hidden lg:overflow-visible">
|
||||
<div
|
||||
className={`puzzle-creation-form-grid min-h-0 flex-1 gap-2.5 sm:gap-4 ${
|
||||
formState.aiRedraw
|
||||
? 'flex flex-col lg:grid lg:grid-cols-[minmax(15rem,0.9fr)_minmax(0,1.15fr)]'
|
||||
: 'flex flex-col lg:grid lg:grid-cols-1'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`puzzle-image-field flex min-h-0 min-w-0 flex-1 flex-col ${isBusy ? 'opacity-55' : ''}`}
|
||||
>
|
||||
<div className="mb-2 shrink-0 text-sm font-black text-[var(--platform-text-strong)]">
|
||||
拼图画面
|
||||
</div>
|
||||
<div className="puzzle-image-card-frame flex min-h-0 flex-1 items-center justify-center">
|
||||
<div className="puzzle-image-upload-card relative aspect-square h-full max-h-full max-w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition lg:h-auto lg:w-full">
|
||||
<input
|
||||
id="puzzle-image-upload-input"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
disabled={isBusy}
|
||||
aria-label="上传拼图图片"
|
||||
onChange={(event) => {
|
||||
void handleReferenceImageChange(event);
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
<label
|
||||
htmlFor="puzzle-image-upload-input"
|
||||
className={`absolute inset-0 z-0 cursor-pointer ${isBusy ? 'cursor-not-allowed' : ''}`}
|
||||
title={
|
||||
formState.referenceImageSrc
|
||||
? '更换拼图图片'
|
||||
: '上传拼图图片'
|
||||
}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{formState.referenceImageSrc
|
||||
? '更换拼图图片'
|
||||
: '上传拼图图片'}
|
||||
</span>
|
||||
</label>
|
||||
{formState.referenceImageSrc ? (
|
||||
<img
|
||||
src={formState.referenceImageSrc}
|
||||
alt="拼图图片"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="pointer-events-none flex h-full items-center justify-center bg-[radial-gradient(circle_at_50%_28%,rgba(255,255,255,0.9),transparent_38%),linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,241,229,0.86))]">
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/92 text-[var(--platform-text-strong)] shadow-sm sm:h-20 sm:w-20">
|
||||
<ImagePlus className="h-6 w-6 sm:h-8 sm:w-8" />
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<div className="absolute inset-0 z-[1] bg-[linear-gradient(180deg,rgba(255,255,255,0.12)_0%,rgba(255,255,255,0.04)_42%,rgba(255,255,255,0.18)_100%)] pointer-events-none" />
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => setIsHistoryPickerOpen(true)}
|
||||
className={`absolute right-3 top-3 z-10 inline-flex items-center gap-1.5 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-[11px] font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
aria-label="选择历史图片"
|
||||
title="选择历史图片"
|
||||
>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
<span>历史</span>
|
||||
</button>
|
||||
{formState.referenceImageSrc ? (
|
||||
<label className="absolute bottom-3 left-3 z-10 inline-flex cursor-pointer items-center gap-2 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-xs font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur">
|
||||
<span>AI重绘</span>
|
||||
<input
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
checked={formState.aiRedraw}
|
||||
disabled={isBusy}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
aiRedraw: event.target.checked,
|
||||
}))
|
||||
}
|
||||
className="sr-only"
|
||||
aria-label="AI重绘"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`relative h-5 w-9 rounded-full transition ${
|
||||
formState.aiRedraw ? 'bg-[#ff4056]' : 'bg-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition ${
|
||||
formState.aiRedraw ? 'left-[1.125rem]' : 'left-0.5'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
{formState.referenceImageSrc ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => setIsRemoveImageConfirmOpen(true)}
|
||||
className="absolute left-3 top-3 z-10 inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/80 bg-white/94 text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] disabled:cursor-not-allowed disabled:opacity-55"
|
||||
aria-label="移除拼图图片"
|
||||
title="移除拼图图片"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<label
|
||||
htmlFor="puzzle-image-upload-input"
|
||||
className={`absolute bottom-9 left-1/2 z-10 -translate-x-1/2 whitespace-nowrap text-center text-sm font-black text-[var(--platform-text-strong)] drop-shadow-[0_1px_0_rgba(255,255,255,0.82)] transition hover:text-[#ff4056] sm:bottom-10 ${isBusy ? 'cursor-not-allowed opacity-55' : 'cursor-pointer'}`}
|
||||
>
|
||||
上传图片/填写画面描述
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formState.aiRedraw ? (
|
||||
<label className="block shrink-0 lg:min-h-0">
|
||||
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
|
||||
{pictureDescriptionLabel}
|
||||
</span>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={formState.pictureDescription}
|
||||
disabled={isBusy}
|
||||
rows={2}
|
||||
placeholder=""
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
pictureDescription: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="h-[6rem] min-h-[6rem] w-full resize-none rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-14 text-base leading-6 text-[var(--platform-text-strong)] outline-none placeholder:text-zinc-400 sm:h-[7.5rem] sm:min-h-[7.5rem] lg:h-[9.25rem] lg:min-h-[9.25rem]"
|
||||
aria-label={pictureDescriptionLabel}
|
||||
/>
|
||||
<PuzzleImageModelPicker
|
||||
value={formState.imageModel}
|
||||
disabled={isBusy}
|
||||
onChange={(imageModel) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
imageModel,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 shrink-0 space-y-3">
|
||||
{referenceImageError ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||||
{referenceImageError}
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSubmit}
|
||||
onClick={submitForm}
|
||||
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${!canSubmit ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
<span className="inline-flex flex-wrap items-center justify-center gap-1.5 sm:gap-2">
|
||||
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span>生成拼图游戏草稿</span>
|
||||
{formState.aiRedraw ? (
|
||||
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
|
||||
消耗2泥点
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<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}
|
||||
@@ -670,50 +645,15 @@ export function PuzzleAgentWorkspace({
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
referenceImageSrc: asset.imageSrc,
|
||||
referenceImageLabel: `历史素材 · ${asset.ownerLabel || '未记录账号'}`,
|
||||
referenceImageLabel: getPuzzleHistoryAssetReferenceLabel(
|
||||
asset.imageSrc,
|
||||
),
|
||||
}));
|
||||
setReferenceImageError(null);
|
||||
setIsHistoryPickerOpen(false);
|
||||
setIsRemoveImageConfirmOpen(false);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{isRemoveImageConfirmOpen ? (
|
||||
<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-image-remove-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-image-remove-confirm-title"
|
||||
className="text-base font-black text-[var(--platform-text-strong)]"
|
||||
>
|
||||
移除拼图图片?
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-base)]">
|
||||
移除后需要重新上传图片。
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsRemoveImageConfirmOpen(false)}
|
||||
className="platform-button platform-button--secondary justify-center"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirmRemoveReferenceImage}
|
||||
className="platform-button platform-button--primary justify-center"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{isPointCostConfirmOpen ? (
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user