Puzzle: support history images & partial generation
Allow history-generated image paths to be submitted where Data URLs were previously required and avoid treating partial/result-page generations as blocking the whole draft. Backend: resolve history /generated-* references via resolve_puzzle_reference_image_as_data_url and convert to PuzzleDownloadedImage; add PuzzleDownloadedImage::from_resolved_reference_image; extend draft handling to apply generated level metadata (auto-naming) and normalize generation_status to treat levels with images as ready. API: add shouldAutoNameLevel to action contracts and use it to request/refine generated level names. Spacetime/module and mappers: normalize completed level statuses when saving/reading so result-page background or per-level generation doesn't mask completed drafts. Frontend: expose resolver helpers, only mark a work as generating when no usable cover or ready level exists, keep level controls enabled during UI-background regeneration, and add tests covering history-image submission, auto-naming, and UI-background/partial-generation behaviors.
This commit is contained in:
@@ -317,6 +317,7 @@ describe('PuzzleResultView', () => {
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
candidateCount: 1,
|
||||
shouldAutoNameLevel: false,
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
summary: '一套雨夜猫街主题拼图。',
|
||||
@@ -466,6 +467,7 @@ describe('PuzzleResultView', () => {
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
candidateCount: 1,
|
||||
shouldAutoNameLevel: true,
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
summary: '一套雨夜猫街主题拼图。',
|
||||
@@ -485,6 +487,42 @@ describe('PuzzleResultView', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('requests automatic level naming when generating an unnamed level image', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1_775_000_000_000);
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByRole('button', { name: /新增关卡/u }));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
fireEvent.change(within(dialog).getByLabelText('画面描述'), {
|
||||
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-1775000000000-2',
|
||||
promptText: '新关卡里有一座发光钟楼。',
|
||||
shouldAutoNameLevel: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps generation progress visible after closing and reopening level dialog', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
@@ -567,6 +605,90 @@ describe('PuzzleResultView', () => {
|
||||
).toHaveProperty('disabled', true);
|
||||
});
|
||||
|
||||
test('keeps level controls enabled while regenerating the UI background', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
isBusy={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
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背景',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: /生成中/u }),
|
||||
).toHaveProperty('disabled', true);
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
const addLevelButton = screen.getByRole('button', { name: /新增关卡/u });
|
||||
expect(addLevelButton).toHaveProperty('disabled', false);
|
||||
fireEvent.click(addLevelButton);
|
||||
expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('restores UI background generate button when background generation fails', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
const { rerender } = render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
isBusy={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
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(screen.getByRole('button', { name: /生成中/u })).toHaveProperty(
|
||||
'disabled',
|
||||
true,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
error="UI背景生成失败"
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
isBusy={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const generateButton = screen.getByRole('button', { name: /生成UI背景/u });
|
||||
expect(generateButton).toHaveProperty('disabled', false);
|
||||
expect(screen.queryByRole('button', { name: /生成中/u })).toBeNull();
|
||||
});
|
||||
|
||||
test('keeps the current level dialog open when another level generation completes', () => {
|
||||
const base = createSession();
|
||||
const firstLevel = base.draft!.levels![0]!;
|
||||
@@ -1143,6 +1265,7 @@ describe('PuzzleResultView', () => {
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
candidateCount: 1,
|
||||
shouldAutoNameLevel: false,
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
summary: '一套雨夜猫街主题拼图。',
|
||||
|
||||
@@ -93,6 +93,11 @@ type PuzzleLevelGenerationRuntime = {
|
||||
estimateSeconds: number;
|
||||
};
|
||||
|
||||
type PuzzleUiBackgroundGenerationState = {
|
||||
levelId: string;
|
||||
prompt: string;
|
||||
} | null;
|
||||
|
||||
function resolvePuzzleLevelGenerationProgress(
|
||||
level: PuzzleDraftLevel,
|
||||
runtime: PuzzleLevelGenerationRuntime | null,
|
||||
@@ -1409,16 +1414,22 @@ function PuzzleUiAssetsTab({
|
||||
editState,
|
||||
imageRefreshKey,
|
||||
isBusy,
|
||||
uiBackgroundGeneration,
|
||||
onChange,
|
||||
onGenerate,
|
||||
}: {
|
||||
editState: DraftEditState;
|
||||
imageRefreshKey: string;
|
||||
isBusy: boolean;
|
||||
uiBackgroundGeneration: PuzzleUiBackgroundGenerationState;
|
||||
onChange: (nextState: DraftEditState) => void;
|
||||
onGenerate: (prompt: string) => void;
|
||||
}) {
|
||||
const firstLevel = editState.levels[0] ?? null;
|
||||
const isGeneratingUiBackground = Boolean(
|
||||
firstLevel &&
|
||||
uiBackgroundGeneration?.levelId === firstLevel.levelId,
|
||||
);
|
||||
const formalImageSrc = firstLevel ? resolveLevelFormalImageSrc(firstLevel) : '';
|
||||
const defaultPrompt = buildDefaultPuzzleUiBackgroundPrompt(
|
||||
editState,
|
||||
@@ -1490,21 +1501,30 @@ function PuzzleUiAssetsTab({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!firstLevel || !normalizedPrompt || isBusy}
|
||||
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 ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
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 ? (
|
||||
{isBusy || isGeneratingUiBackground ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Wand2 className="h-4 w-4" />
|
||||
)}
|
||||
{hasGeneratedUiBackground ? '重新生成' : '生成UI背景'} · {PUZZLE_IMAGE_GENERATION_POINT_COST}泥点
|
||||
{isGeneratingUiBackground
|
||||
? '生成中'
|
||||
: hasGeneratedUiBackground
|
||||
? '重新生成'
|
||||
: '生成UI背景'} · {PUZZLE_IMAGE_GENERATION_POINT_COST}泥点
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1547,7 +1567,12 @@ function PuzzleUiAssetsTab({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!firstLevel || !normalizedPrompt || isBusy}
|
||||
disabled={
|
||||
!firstLevel ||
|
||||
!normalizedPrompt ||
|
||||
isBusy ||
|
||||
isGeneratingUiBackground
|
||||
}
|
||||
onClick={() => {
|
||||
if (!firstLevel || !normalizedPrompt) {
|
||||
return;
|
||||
@@ -1559,7 +1584,7 @@ function PuzzleUiAssetsTab({
|
||||
setIsCostConfirmOpen(false);
|
||||
onGenerate(normalizedPrompt);
|
||||
}}
|
||||
className={`platform-button platform-button--primary justify-center ${!firstLevel || !normalizedPrompt || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
className={`platform-button platform-button--primary justify-center ${!firstLevel || !normalizedPrompt || isBusy || isGeneratingUiBackground ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
@@ -1696,6 +1721,7 @@ function PuzzleAssetConfigTab({
|
||||
editState,
|
||||
imageRefreshKey,
|
||||
isBusy,
|
||||
uiBackgroundGeneration,
|
||||
onAssetConfigTabChange,
|
||||
onChange,
|
||||
onGenerateUiBackground,
|
||||
@@ -1704,6 +1730,7 @@ function PuzzleAssetConfigTab({
|
||||
editState: DraftEditState;
|
||||
imageRefreshKey: string;
|
||||
isBusy: boolean;
|
||||
uiBackgroundGeneration: PuzzleUiBackgroundGenerationState;
|
||||
onAssetConfigTabChange: (tab: PuzzleAssetConfigTabId) => void;
|
||||
onChange: (nextState: DraftEditState) => void;
|
||||
onGenerateUiBackground: (prompt: string) => void;
|
||||
@@ -1719,6 +1746,7 @@ function PuzzleAssetConfigTab({
|
||||
editState={editState}
|
||||
imageRefreshKey={imageRefreshKey}
|
||||
isBusy={isBusy}
|
||||
uiBackgroundGeneration={uiBackgroundGeneration}
|
||||
onChange={onChange}
|
||||
onGenerate={onGenerateUiBackground}
|
||||
/>
|
||||
@@ -1829,6 +1857,8 @@ export function PuzzleResultView({
|
||||
const [tagGenerationError, setTagGenerationError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [uiBackgroundGeneration, setUiBackgroundGeneration] =
|
||||
useState<PuzzleUiBackgroundGenerationState>(null);
|
||||
const [generationRuntimeByLevelId, setGenerationRuntimeByLevelId] = useState<
|
||||
Record<string, PuzzleLevelGenerationRuntime>
|
||||
>({});
|
||||
@@ -1844,11 +1874,18 @@ export function PuzzleResultView({
|
||||
latestEditStateRef.current = editState;
|
||||
}, [editState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setUiBackgroundGeneration(null);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft) {
|
||||
setEditState(null);
|
||||
latestEditStateRef.current = null;
|
||||
setActiveLevelId(null);
|
||||
setUiBackgroundGeneration(null);
|
||||
setAutoSaveState('idle');
|
||||
setAutoSaveError(null);
|
||||
setTagGenerationError(null);
|
||||
@@ -1884,6 +1921,19 @@ export function PuzzleResultView({
|
||||
setAutoSaveState('idle');
|
||||
setAutoSaveError(null);
|
||||
setTagGenerationError(null);
|
||||
setUiBackgroundGeneration((current) => {
|
||||
if (
|
||||
current &&
|
||||
mergedState.levels.some(
|
||||
(level) =>
|
||||
level.levelId === current.levelId &&
|
||||
resolvePuzzleUiBackgroundSource(level),
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return current;
|
||||
});
|
||||
}, [draft]);
|
||||
|
||||
const syncedDraft = useMemo(() => {
|
||||
@@ -2163,6 +2213,7 @@ export function PuzzleResultView({
|
||||
editState={editState}
|
||||
imageRefreshKey={imageRefreshKey}
|
||||
isBusy={isBusy}
|
||||
uiBackgroundGeneration={uiBackgroundGeneration}
|
||||
onAssetConfigTabChange={setActiveAssetConfigTab}
|
||||
onChange={setEditState}
|
||||
onGenerateUiBackground={(prompt) => {
|
||||
@@ -2170,6 +2221,10 @@ export function PuzzleResultView({
|
||||
if (!firstLevel) {
|
||||
return;
|
||||
}
|
||||
setUiBackgroundGeneration({
|
||||
levelId: firstLevel.levelId,
|
||||
prompt,
|
||||
});
|
||||
onExecuteAction({
|
||||
action: 'generate_puzzle_ui_background',
|
||||
levelId: firstLevel.levelId,
|
||||
@@ -2256,6 +2311,7 @@ export function PuzzleResultView({
|
||||
imageModel: imageModel ?? PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
aiRedraw: true,
|
||||
candidateCount: 1,
|
||||
shouldAutoNameLevel: !nextLevel.levelName.trim(),
|
||||
workTitle: editState.workTitle.trim(),
|
||||
workDescription: editState.workDescription.trim(),
|
||||
summary: editState.workDescription.trim(),
|
||||
|
||||
Reference in New Issue
Block a user