Enforce Genarrative play-type SOP and update docs

Rewrite Genarrative play-type integration guidance across .codex and .hermes to define a platform-level SOP: default to form/image workbench, unify single-image asset slots (CreativeImageInputPanel), standardize series-material sheet->cut->transparent->OSS pipeline, and forbid copying legacy chat/agent workflows as the default. Add decision-log entry freezing the SOP and a pitfalls note warning against direct reuse of old play tools. Update CONTEXT.md and docs/README.md, add a new PRD file, and apply related small server-side changes (module-auth, spacetime-client mappers and runtime) to align back-end code with the new contracts and flows.
This commit is contained in:
2026-05-20 12:12:00 +08:00
parent f370539a6f
commit 3931442249
123 changed files with 15514 additions and 3419 deletions

View File

@@ -390,9 +390,6 @@ describe('PuzzleResultView', () => {
within(dialog).getByRole('button', { name: /生成画面/u }),
).toBeTruthy();
expect(within(dialog).getByText('消耗2泥点')).toBeTruthy();
expect(
within(dialog).getByText('等待时间可以制作更多关卡哦~'),
).toBeTruthy();
expect(within(dialog).getByText('画面图')).toBeTruthy();
expect(
within(dialog).queryByRole('button', { name: /关卡测试/u }),
@@ -1013,7 +1010,7 @@ describe('PuzzleResultView', () => {
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
target: { value: '新拼图UI背景提示词' },
});
expect(screen.getByRole('button', { name: /生成UI背景 · 2泥点/u })).toBeTruthy();
expect(screen.getByRole('button', { name: /生成UI背景.*2泥点/u })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
const confirmDialog = screen.getByRole('dialog', {
name: '确认消耗泥点',
@@ -1352,6 +1349,184 @@ describe('PuzzleResultView', () => {
);
});
test('level image editor exposes entrance image editing controls without sharing UI background state', () => {
const onExecuteAction = vi.fn();
const session = createSession({
draft: {
...createSession().draft!,
levels: [
{
...createSession().draft!.levels![0]!,
pictureReference: '/generated-puzzle-assets/history/saved-reference.png',
},
],
},
});
render(
<PuzzleResultView
session={session}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(within(dialog).getByText('画面图')).toBeTruthy();
expect(within(dialog).getByLabelText('上传参考图')).toBeTruthy();
expect(within(dialog).getByRole('switch', { name: 'AI重绘' })).toHaveProperty(
'checked',
true,
);
expect(
within(dialog).getByRole('button', { name: '选择历史图片' }),
).toBeTruthy();
fireEvent.change(within(dialog).getByLabelText('画面AI重绘要求提示词'), {
target: { value: '只重绘第一关猫街画面' },
});
fireEvent.click(within(dialog).getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith(
expect.objectContaining({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1',
promptText: '只重绘第一关猫街画面',
aiRedraw: true,
}),
);
expect(onExecuteAction).not.toHaveBeenCalledWith(
expect.objectContaining({
action: 'generate_puzzle_ui_background',
}),
);
});
test('level image editor keeps AI redraw switch scoped to the level image action', () => {
const onExecuteAction = vi.fn();
const session = createSession({
draft: {
...createSession().draft!,
levels: [
{
...createSession().draft!.levels![0]!,
pictureReference: '/generated-puzzle-assets/history/saved-reference.png',
},
],
},
});
render(
<PuzzleResultView
session={session}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
fireEvent.click(within(dialog).getByRole('switch', { name: 'AI重绘' }));
fireEvent.click(within(dialog).getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith(
expect.objectContaining({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1',
aiRedraw: false,
}),
);
expect(onExecuteAction).not.toHaveBeenCalledWith(
expect.objectContaining({
action: 'generate_puzzle_ui_background',
aiRedraw: false,
}),
);
});
test('level image editor hides AI redraw controls when only the formal image is shown', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(within(dialog).queryByRole('switch', { name: 'AI重绘' })).toBeNull();
expect(within(dialog).getByLabelText('画面描述')).toBeTruthy();
});
test('UI background generator reuses common image input UI without sharing level image fields', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
expect(screen.getByText('UI背景预览')).toBeTruthy();
expect(screen.getByLabelText('UI背景提示词')).toBeTruthy();
expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull();
expect(screen.queryByLabelText('上传拼图图片')).toBeNull();
fireEvent.change(screen.getByLabelText('UI背景提示词'), {
target: { value: '独立的草稿UI背景提示词' },
});
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: /确认消耗泥点/u })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith(
expect.objectContaining({
action: 'generate_puzzle_ui_background',
promptText: '独立的草稿UI背景提示词',
}),
);
const payload = onExecuteAction.mock.calls[0]![0];
expect(payload).not.toHaveProperty('referenceImageSrc');
expect(payload).not.toHaveProperty('aiRedraw');
expect(JSON.parse(payload.levelsJson ?? '[]')).toEqual([
expect.objectContaining({
levelId: 'puzzle-level-1',
pictureDescription: '屋檐下的猫与暖灯街角。',
uiBackgroundPrompt: '独立的草稿UI背景提示词',
}),
]);
});
test('shows creative agent draft edit bar and submits the current draft', () => {
const onSubmit = vi.fn();

View File

@@ -2,8 +2,6 @@ import {
ArrowLeft,
CheckCircle2,
Eye,
History,
ImagePlus,
LayoutTemplate,
Loader2,
MessageSquareText,
@@ -11,10 +9,9 @@ import {
Plus,
Sparkles,
Trash2,
Wand2,
X,
} from 'lucide-react';
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { CreativeDraftEditResult } from '../../../packages/shared/src/contracts/creativeAgent';
@@ -29,6 +26,7 @@ import { updatePuzzleWork } from '../../services/puzzle-works';
import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works/puzzleHistoryAsset';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { useAuthUi } from '../auth/AuthUiContext';
import { CreativeImageInputPanel } from '../common/CreativeImageInputPanel';
import PuzzleHistoryAssetPickerDialog from '../puzzle-agent/PuzzleHistoryAssetPickerDialog';
import {
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
@@ -659,6 +657,7 @@ function PuzzleLevelDetailDialog({
promptText?: string | null,
referenceImageSrc?: string | null,
imageModel?: PuzzleImageModelId | null,
aiRedraw?: boolean | null,
) => void;
onLevelChange: (nextLevel: PuzzleDraftLevel) => void;
onStartTestRun?: (level: PuzzleDraftLevel) => void;
@@ -674,6 +673,7 @@ function PuzzleLevelDetailDialog({
const [imageModel, setImageModel] = useState<PuzzleImageModelId>(
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
);
const [aiRedraw, setAiRedraw] = useState(true);
const formalImageSrc = resolveLevelFormalImageSrc(level);
const hasFormalImage = Boolean(formalImageSrc);
const effectiveReferenceImageSrc =
@@ -688,15 +688,7 @@ function PuzzleLevelDetailDialog({
generationNowMs,
);
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 dataUrl = await readPuzzleReferenceImageAsDataUrl(file);
setReferenceImageSrc(dataUrl);
@@ -722,6 +714,7 @@ function PuzzleLevelDetailDialog({
nextLevel.pictureDescription.trim() || undefined,
effectiveReferenceImageSrc || undefined,
imageModel,
aiRedraw,
);
};
@@ -776,134 +769,91 @@ function PuzzleLevelDetailDialog({
/>
</section>
<div className="grid gap-4 lg:grid-cols-[minmax(13rem,0.9fr)_minmax(0,1.1fr)]">
<section className="platform-subpanel rounded-[1.35rem] p-4">
<div className="mb-3 text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="relative aspect-square overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)]">
<input
id={`puzzle-level-reference-upload-${level.levelId}`}
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={isBusy}
aria-label="上传参考图"
onChange={(event) => {
void handleReferenceImageChange(event);
}}
className="sr-only"
/>
<label
htmlFor={`puzzle-level-reference-upload-${level.levelId}`}
className={`absolute inset-0 z-0 cursor-pointer ${isBusy ? 'cursor-not-allowed' : ''}`}
title={
effectiveReferenceImageSrc ? '更换参考图' : '上传参考图'
}
>
<span className="sr-only">
{effectiveReferenceImageSrc ? '更换参考图' : '上传参考图'}
</span>
</label>
{displayImageSrc ? (
<ResolvedAssetImage
src={displayImageSrc}
refreshKey={`${imageRefreshKey}:${level.levelId}`}
alt={displayImageAlt}
className="pointer-events-none 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.92),transparent_38%),linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,241,229,0.86))]">
<span className="flex h-16 w-16 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/92 text-[var(--platform-text-strong)] shadow-sm">
<ImagePlus className="h-7 w-7" />
</span>
</span>
)}
{generationProgress.isGenerating ? (
<div className="absolute inset-0 flex items-center justify-center bg-white/68 backdrop-blur-[2px]">
<div className="flex items-center gap-2 rounded-full border border-white/80 bg-white/94 px-4 py-2 text-sm font-black text-[var(--platform-text-strong)] shadow-sm">
<Loader2 className="h-4 w-4 animate-spin text-amber-600" />
<section className="platform-subpanel rounded-[1.35rem] p-4">
<CreativeImageInputPanel
disabled={isBusy || generationProgress.isGenerating}
isSubmitting={generationProgress.isGenerating}
uploadedImageSrc={displayImageSrc}
uploadedImageAlt={displayImageAlt}
uploadedImageRefreshKey={`${imageRefreshKey}:${level.levelId}`}
canRemoveMainImage={Boolean(effectiveReferenceImageSrc)}
canToggleAiRedraw={Boolean(effectiveReferenceImageSrc)}
mainImageMeta={
effectiveReferenceImageSrc ? (
<div className="flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
<div className="h-12 w-12 overflow-hidden rounded-[0.85rem] bg-[var(--platform-subpanel-fill)]">
<ResolvedAssetImage
src={effectiveReferenceImageSrc}
alt="拼图参考图"
className="h-full w-full object-cover"
/>
</div>
</div>
) : null}
<div className="absolute bottom-3 right-3 z-10">
<button
type="button"
disabled={isBusy}
onClick={() => setIsHistoryPickerOpen(true)}
className={`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>
</div>
</div>
{effectiveReferenceImageSrc ? (
<div className="mt-3 flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
<div className="h-12 w-12 overflow-hidden rounded-[0.85rem] bg-[var(--platform-subpanel-fill)]">
<ResolvedAssetImage
src={effectiveReferenceImageSrc}
alt="拼图参考图"
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--platform-text-strong)]">
<div className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{referenceImageLabel || '已选择参考图'}
</div>
</div>
<button
type="button"
disabled={isBusy}
onClick={() => {
setReferenceImageSrc('');
setReferenceImageLabel('');
setReferenceImageError(null);
onLevelChange({ ...level, pictureReference: null });
}}
className="platform-icon-button h-9 w-9"
aria-label="移除参考图"
title="移除参考图"
>
<X className="h-4 w-4" />
</button>
</div>
) : null}
{referenceImageError ? (
<div className="mt-2 text-xs leading-5 text-red-600">
{referenceImageError}
</div>
) : null}
</section>
<section className="platform-subpanel rounded-[1.35rem] p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="relative mt-3">
<textarea
value={level.pictureDescription}
disabled={isBusy}
rows={7}
onChange={(event) =>
onLevelChange({
...level,
pictureDescription: event.target.value,
})
}
className="h-[12rem] min-h-[12rem] w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-16 text-sm leading-6 text-[var(--platform-text-strong)] outline-none sm:h-[14rem] sm:min-h-[14rem] lg:h-full lg:min-h-[18rem]"
aria-label="画面描述"
/>
) : null
}
mainImageInputId={`puzzle-level-reference-upload-${level.levelId}`}
promptTextareaId={`puzzle-level-picture-description-${level.levelId}`}
prompt={level.pictureDescription}
promptLabel={
effectiveReferenceImageSrc
? '画面AI重绘要求提示词'
: '画面描述'
}
promptRows={7}
aiRedraw={aiRedraw}
promptReferenceImages={[]}
imageModelPicker={
<PuzzleImageModelPicker
value={imageModel}
disabled={isBusy}
disabled={isBusy || generationProgress.isGenerating}
onChange={setImageModel}
/>
</div>
</section>
</div>
}
inputError={referenceImageError}
submitLabel={hasFormalImage ? '重新生成画面' : '生成画面'}
submitCostLabel={`消耗${PUZZLE_IMAGE_GENERATION_POINT_COST}泥点`}
submitDisabled={
isBusy ||
generationProgress.isGenerating ||
(!level.pictureDescription.trim() && !effectiveReferenceImageSrc)
}
labels={{
imageField: '画面图',
uploadImage: '上传参考图',
replaceImage: '更换参考图',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除参考图',
removeImageConfirmTitle: '移除参考图?',
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
history: '选择历史图片',
}}
onMainImageFileSelect={(file) => {
void handleReferenceImageFile(file);
}}
onMainImageRemove={() => {
setReferenceImageSrc('');
setReferenceImageLabel('');
setReferenceImageError(null);
setAiRedraw(true);
onLevelChange({ ...level, pictureReference: null });
}}
onAiRedrawChange={setAiRedraw}
onPromptChange={(value) =>
onLevelChange({
...level,
pictureDescription: value,
})
}
onHistoryClick={() => setIsHistoryPickerOpen(true)}
onSubmit={() => setIsCostConfirmOpen(true)}
/>
</section>
</div>
</div>
@@ -940,25 +890,7 @@ function PuzzleLevelDetailDialog({
{generationProgress.secondsLeft}
</div>
</div>
) : (
<button
type="button"
disabled={isBusy}
onClick={() => setIsCostConfirmOpen(true)}
className="inline-flex w-full flex-col items-center justify-center gap-1 rounded-full bg-amber-600 px-4 py-3 text-sm font-bold text-white disabled:opacity-45"
>
<span className="inline-flex items-center justify-center gap-2">
<Sparkles className="h-4 w-4" />
<span>{hasFormalImage ? '重新生成画面' : '生成画面'}</span>
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
{PUZZLE_IMAGE_GENERATION_POINT_COST}
</span>
</span>
<span className="text-[11px] font-semibold leading-none text-white/78">
~
</span>
</button>
)}
) : null}
</div>
{isCostConfirmOpen ? (
@@ -1453,82 +1385,75 @@ function PuzzleUiAssetsTab({
return (
<div className="space-y-3">
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
<div className="grid gap-4 lg:grid-cols-[minmax(13rem,0.78fr)_minmax(0,1fr)]">
<button
type="button"
onClick={() => setIsPreviewOpen(true)}
className="mx-auto aspect-[9/16] max-h-[min(62dvh,34rem)] w-full max-w-[18rem] overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/62 text-left shadow-sm"
aria-label="打开拼图UI预览"
>
<ResolvedAssetImage
src={backgroundPreviewSrc}
refreshKey={`${imageRefreshKey}:ui-background`}
alt="拼图UI背景"
className="h-full w-full object-cover"
/>
</button>
<div className="flex min-h-0 flex-col">
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
UI背景提示词
</span>
<textarea
value={prompt}
disabled={isBusy || !firstLevel}
rows={8}
onChange={(event) => {
if (!firstLevel) {
return;
}
updateFirstLevel({
...firstLevel,
uiBackgroundPrompt: event.target.value,
});
}}
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="拼图UI背景提示词"
/>
</label>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
<button
type="button"
onClick={() => setIsPreviewOpen(true)}
className="platform-button platform-button--ghost min-h-11 justify-center gap-2 px-4 py-3"
>
<Eye className="h-4 w-4" />
UI
</button>
<button
type="button"
disabled={
!firstLevel ||
!normalizedPrompt ||
isBusy ||
isGeneratingUiBackground
}
onClick={() => {
if (!firstLevel || !normalizedPrompt) {
return;
}
setIsCostConfirmOpen(true);
}}
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 ${!firstLevel || !normalizedPrompt || isBusy || isGeneratingUiBackground ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isBusy || isGeneratingUiBackground ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Wand2 className="h-4 w-4" />
)}
{isGeneratingUiBackground
? '生成中'
: hasGeneratedUiBackground
? '重新生成'
: '生成UI背景'} · {PUZZLE_IMAGE_GENERATION_POINT_COST}
</button>
</div>
</div>
</div>
<CreativeImageInputPanel
mainImageMode="preview"
disabled={isBusy || !firstLevel || isGeneratingUiBackground}
isSubmitting={isGeneratingUiBackground}
uploadedImageSrc={backgroundPreviewSrc}
uploadedImageAlt="拼图UI背景图"
uploadedImageRefreshKey={`${imageRefreshKey}:ui-background`}
mainImageInputId="puzzle-ui-background-preview"
promptTextareaId="puzzle-ui-background-prompt-input"
prompt={prompt}
promptLabel="UI背景提示词"
promptAriaLabel="拼图UI背景提示词"
promptRows={8}
aiRedraw={false}
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel={
isGeneratingUiBackground
? '生成中'
: hasGeneratedUiBackground
? '重新生成'
: '生成UI背景'
}
submitCostLabel={`· ${PUZZLE_IMAGE_GENERATION_POINT_COST}泥点`}
submitDisabled={
!firstLevel ||
!normalizedPrompt ||
isBusy ||
isGeneratingUiBackground
}
labels={{
imageField: 'UI背景预览',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={(value) => {
if (!firstLevel) {
return;
}
updateFirstLevel({
...firstLevel,
uiBackgroundPrompt: value,
});
}}
onSubmit={() => {
if (!firstLevel || !normalizedPrompt) {
return;
}
setIsCostConfirmOpen(true);
}}
/>
<button
type="button"
onClick={() => setIsPreviewOpen(true)}
className="platform-button platform-button--ghost mt-3 min-h-11 w-full justify-center gap-2 px-4 py-3"
>
<Eye className="h-4 w-4" />
UI
</button>
</section>
{isPreviewOpen ? (
@@ -2301,7 +2226,13 @@ export function PuzzleResultView({
isBusy={isBusy}
level={activeLevel}
onClose={() => setActiveLevelId(null)}
onGenerate={(nextLevel, promptText, referenceImageSrc, imageModel) => {
onGenerate={(
nextLevel,
promptText,
referenceImageSrc,
imageModel,
aiRedraw,
) => {
updateLevel(nextLevel);
onExecuteAction({
action: 'generate_puzzle_images',
@@ -2309,7 +2240,7 @@ export function PuzzleResultView({
promptText,
referenceImageSrc,
imageModel: imageModel ?? PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
aiRedraw: true,
aiRedraw: aiRedraw ?? true,
candidateCount: 1,
shouldAutoNameLevel: !nextLevel.levelName.trim(),
workTitle: editState.workTitle.trim(),