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

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