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:
2026-05-19 10:02:13 +08:00
parent 5e03b3d2f2
commit 7b37271f17
16 changed files with 653 additions and 73 deletions

View File

@@ -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: '一套雨夜猫街主题拼图。',

View File

@@ -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(),