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:
2026-05-15 02:40:59 +08:00
parent 4642855fd0
commit 74fd9a33ac
87 changed files with 5508 additions and 1261 deletions

View File

@@ -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