feat: workerize external generation

This commit is contained in:
2026-06-05 17:29:08 +08:00
parent 5150925947
commit 8d54ea3374
60 changed files with 5285 additions and 700 deletions

View File

@@ -112,6 +112,7 @@ import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'
import {
buildPublicWorkStagePath,
pushAppHistoryPath,
resolvePathForSelectionStage,
} from '../../routing/appPageRoutes';
import { resolveWorkNotFoundRecoveryAction } from '../../routing/runtimeNotFoundRecovery';
import {
@@ -623,9 +624,143 @@ async function buildRecommendRuntimeAuthOptions(
return RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS;
}
const PUZZLE_DRAFT_GENERATION_POINT_COST = 2;
const PUZZLE_BACKGROUND_ACTION_POLL_INTERVAL_MS = 3000;
const PUZZLE_BACKGROUND_ACTION_MAX_POLL_ATTEMPTS = 160;
const MATCH3D_DRAFT_GENERATION_POINT_COST = 10;
const BARK_BATTLE_DRAFT_GENERATION_POINT_COST = 3;
function isPuzzleBackgroundAction(payload: PuzzleAgentActionRequest) {
return (
payload.action === 'generate_puzzle_images' ||
payload.action === 'generate_puzzle_ui_background'
);
}
function findPuzzleActionLevel(
session: PuzzleAgentSessionSnapshot | null | undefined,
payload: PuzzleAgentActionRequest,
) {
const levels = session?.draft?.levels ?? [];
const targetLevelId =
'levelId' in payload ? payload.levelId?.trim() : undefined;
if (targetLevelId) {
return levels.find((level) => level.levelId === targetLevelId) ?? null;
}
return levels[0] ?? null;
}
function buildPuzzleGeneratedImageSignature(
level: PuzzleDraftLevel | null | undefined,
) {
if (!level) {
return '';
}
return [
level.levelName,
level.selectedCandidateId ?? '',
level.coverImageSrc ?? '',
level.coverAssetId ?? '',
level.levelSceneImageSrc ?? '',
level.levelSceneImageObjectKey ?? '',
level.uiSpritesheetImageSrc ?? '',
level.uiSpritesheetImageObjectKey ?? '',
level.levelBackgroundImageSrc ?? '',
level.levelBackgroundImageObjectKey ?? '',
(level.candidates ?? [])
.map((candidate) =>
[
candidate.candidateId,
candidate.imageSrc,
candidate.assetId,
String(candidate.selected),
].join(':'),
)
.join('|'),
].join('::');
}
function buildPuzzleUiBackgroundSignature(
level: PuzzleDraftLevel | null | undefined,
) {
if (!level) {
return '';
}
return [
level.uiBackgroundPrompt ?? '',
level.uiBackgroundImageSrc ?? '',
level.uiBackgroundImageObjectKey ?? '',
].join('::');
}
function buildPuzzleBackgroundActionSignature(
payload: PuzzleAgentActionRequest,
level: PuzzleDraftLevel | null | undefined,
) {
if (payload.action === 'generate_puzzle_ui_background') {
return buildPuzzleUiBackgroundSignature(level);
}
return buildPuzzleGeneratedImageSignature(level);
}
function hasPuzzleBackgroundActionAsset(
payload: PuzzleAgentActionRequest,
level: PuzzleDraftLevel | null | undefined,
) {
if (!level) {
return false;
}
if (payload.action === 'generate_puzzle_ui_background') {
return Boolean(level.uiBackgroundImageSrc?.trim());
}
return Boolean(
level.coverImageSrc?.trim() ||
level.candidates?.some((candidate) => candidate.imageSrc.trim()),
);
}
function isPuzzleBackgroundActionSettled(
payload: PuzzleAgentActionRequest,
baselineSession: PuzzleAgentSessionSnapshot,
latestSession: PuzzleAgentSessionSnapshot,
) {
const latestLevel = findPuzzleActionLevel(latestSession, payload);
if (!latestLevel) {
return false;
}
if (latestLevel.generationStatus === 'failed') {
return true;
}
if (latestLevel.generationStatus === 'generating') {
return false;
}
const baselineSignature = buildPuzzleBackgroundActionSignature(
payload,
findPuzzleActionLevel(baselineSession, payload),
);
const latestSignature = buildPuzzleBackgroundActionSignature(
payload,
latestLevel,
);
return (
hasPuzzleBackgroundActionAsset(payload, latestLevel) &&
latestSignature !== baselineSignature
);
}
function waitForPuzzleBackgroundActionPollTick() {
return new Promise<void>((resolve) => {
window.setTimeout(resolve, PUZZLE_BACKGROUND_ACTION_POLL_INTERVAL_MS);
});
}
function getPlatformPublicGalleryEntryTime(entry: PlatformPublicGalleryCard) {
const rawTime = entry.publishedAt ?? entry.updatedAt;
const timestamp = new Date(rawTime).getTime();
@@ -2361,7 +2496,9 @@ function buildMatch3DFormPayloadFromSession(
seedText: themeText,
themeText,
referenceImageSrc:
session.config?.referenceImageSrc ?? session.draft?.referenceImageSrc ?? null,
session.config?.referenceImageSrc ??
session.draft?.referenceImageSrc ??
null,
clearCount:
session.config?.clearCount ??
session.draft?.clearCount ??
@@ -2579,6 +2716,22 @@ function hasRecoverableGeneratedPuzzleDraft(
);
}
function hasFailedPuzzleDraftGeneration(session: PuzzleAgentSessionSnapshot) {
const draft = session.draft;
if (!draft) {
return false;
}
return (
isPersistedDraftFailed(draft.generationStatus) ||
Boolean(
draft.levels?.some((level) =>
isPersistedDraftFailed(level.generationStatus),
),
)
);
}
function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem): string[] {
switch (item.source.kind) {
case 'rpg':
@@ -3071,7 +3224,8 @@ function buildPuzzleCompileActionFromFormPayload(
const pictureDescription =
payload?.pictureDescription?.trim() || payload?.seedText?.trim();
const workTitle = payload?.workTitle?.trim();
const workDescription = payload?.workDescription?.trim() || pictureDescription;
const workDescription =
payload?.workDescription?.trim() || pictureDescription;
return {
action: 'compile_puzzle_draft',
@@ -3736,6 +3890,12 @@ export function PlatformEntryFlowShellImpl({
useState<MiniGameDraftGenerationState | null>(null);
const [puzzleBackgroundCompileTasks, setPuzzleBackgroundCompileTasks] =
useState<Record<string, PuzzleBackgroundCompileTask>>({});
const puzzleGenerationViewSnapshotRef = useRef<{
payload: CreatePuzzleAgentSessionRequest | null;
generationState: MiniGameDraftGenerationState | null;
}>({ payload: null, generationState: null });
const puzzleRuntimeReturnSessionRef =
useRef<PuzzleAgentSessionSnapshot | null>(null);
const [miniGameGenerationProgressNowMs, setMiniGameGenerationProgressNowMs] =
useState(() => Date.now());
const [puzzleFormDraftPayload, setPuzzleFormDraftPayload] =
@@ -4109,6 +4269,11 @@ export function PlatformEntryFlowShellImpl({
viewedImmediately,
);
setProfileTaskRefreshKey((current) => current + 1);
if (viewedImmediately) {
setPendingPlatformTaskCompletionDialog(null);
return;
}
const completedAtMs = Date.now();
setPendingPlatformTaskCompletionDialog({
key: `${kind}:${collectDraftNoticeKeys(kind, ids).join('|')}:${completedAtMs}`,
@@ -4384,9 +4549,10 @@ export function PlatformEntryFlowShellImpl({
const ensureEnoughDraftGenerationPointsFromServer = useCallback(
async (pointsCost: number) => {
try {
const latestDashboard = await getPlatformProfileDashboardWithLocalWalletDelta(
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
);
const latestDashboard =
await getPlatformProfileDashboardWithLocalWalletDelta(
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
);
platformBootstrap.setProfileDashboard(latestDashboard);
const walletBalance = resolveProfileWalletBalance(latestDashboard);
if (walletBalance >= pointsCost) {
@@ -5916,7 +6082,11 @@ export function PlatformEntryFlowShellImpl({
markPendingDraftFailed('match3d', session.sessionId);
markDraftFailed(
'match3d',
[session.draft?.profileId, session.publishedProfileId, session.sessionId],
[
session.draft?.profileId,
session.publishedProfileId,
session.sessionId,
],
errorMessage,
);
try {
@@ -6198,7 +6368,11 @@ export function PlatformEntryFlowShellImpl({
markPendingDraftFailed('square-hole', session.sessionId);
markDraftFailed(
'square-hole',
[session.draft?.profileId, session.publishedProfileId, session.sessionId],
[
session.draft?.profileId,
session.publishedProfileId,
session.sessionId,
],
errorMessage,
);
void refreshSquareHoleShelf().catch(() => undefined);
@@ -6275,6 +6449,81 @@ export function PlatformEntryFlowShellImpl({
if (payload.action === 'compile_puzzle_draft') {
const openResult = selectionStageRef.current === 'puzzle-generating';
if (response.operation.status !== 'completed') {
const nextPayload =
formPayload ?? buildPuzzleFormPayloadFromSession(response.session);
activePuzzleGenerationSessionIdRef.current =
response.session.sessionId;
selectionStageRef.current = 'puzzle-generating';
setSelectionStage('puzzle-generating');
if (response.operation.status === 'failed') {
const errorMessage =
response.operation.error ?? '拼图草稿生成失败,请稍后再试。';
const failedGenerationState =
resolveFinishedMiniGameDraftGenerationState(
createPuzzleDraftGenerationStateFromPayload(
nextPayload,
response.session,
),
'failed',
{ error: errorMessage },
);
setPuzzleBackgroundCompileTasks((current) => ({
...current,
[response.session.sessionId]: {
session: response.session,
payload: nextPayload,
generationState: failedGenerationState,
error: errorMessage,
},
}));
setPuzzleGenerationState(failedGenerationState);
markPendingDraftFailed('puzzle', response.session.sessionId);
markDraftFailed(
'puzzle',
[
response.session.sessionId,
buildPuzzleResultWorkId(response.session.sessionId),
response.session.publishedProfileId,
buildPuzzleResultProfileId(response.session.sessionId),
],
errorMessage,
);
void refreshPuzzleShelf();
return { openResult: false };
}
const generatingState = mergePuzzleSessionProgressIntoGenerationState(
createPuzzleDraftGenerationStateFromPayload(
nextPayload,
response.session,
),
response.session,
);
setPuzzleGenerationState(generatingState);
setPuzzleBackgroundCompileTasks((current) => ({
...current,
[response.session.sessionId]: {
session: response.session,
payload: nextPayload,
generationState: generatingState,
error: null,
},
}));
markDraftGenerating('puzzle', [
response.session.sessionId,
buildPuzzleResultWorkId(response.session.sessionId),
response.session.publishedProfileId,
buildPuzzleResultProfileId(response.session.sessionId),
]);
markPendingDraftGenerating(
'puzzle',
response.session.sessionId,
buildPendingPuzzleDraftMetadata(nextPayload),
);
return { openResult: false };
}
setPuzzleGenerationState((current) =>
current
? resolveFinishedMiniGameDraftGenerationState(current, 'ready', {
@@ -6328,6 +6577,7 @@ export function PlatformEntryFlowShellImpl({
setPuzzleRun(run);
setPuzzleRuntimeAuthMode('default');
setPuzzleRuntimeReturnStage('puzzle-result');
puzzleRuntimeReturnSessionRef.current = response.session;
openPuzzleRuntimeStage(
setSelectionStage,
buildPuzzleDraftRuntimeUrlState(item, null),
@@ -6756,6 +7006,10 @@ export function PlatformEntryFlowShellImpl({
activePuzzleBackgroundCompileTask?.payload ?? puzzleFormDraftPayload;
const puzzleGenerationViewError =
activePuzzleBackgroundCompileTask?.error ?? puzzleError;
puzzleGenerationViewSnapshotRef.current = {
payload: puzzleGenerationViewPayload,
generationState: puzzleGenerationViewState,
};
const isPuzzleGenerationViewBusy =
isPuzzleBusy ||
isMiniGameDraftGenerating(
@@ -7170,6 +7424,60 @@ export function PlatformEntryFlowShellImpl({
}
setPuzzleSession(latestSession);
const snapshot = puzzleGenerationViewSnapshotRef.current;
const pollPayload =
snapshot.payload ?? buildPuzzleFormPayloadFromSession(latestSession);
const pollGenerationState =
snapshot.generationState ??
createPuzzleDraftGenerationStateFromPayload(
pollPayload,
latestSession,
);
if (hasRecoverableGeneratedPuzzleDraft(latestSession)) {
await recoverCompletedPuzzleDraftGeneration({
sessionId: activePuzzleGenerationSessionId,
payload: pollPayload,
generationState: pollGenerationState,
setSession: setPuzzleSession,
});
return;
}
if (hasFailedPuzzleDraftGeneration(latestSession)) {
const errorMessage =
latestSession.lastAssistantReply?.trim() ||
'拼图草稿生成失败,请稍后再试。';
const failedGenerationState =
resolveFinishedMiniGameDraftGenerationState(
pollGenerationState,
'failed',
{ error: errorMessage },
);
setPuzzleBackgroundCompileTasks((current) => ({
...current,
[activePuzzleGenerationSessionId]: {
session: latestSession,
payload: pollPayload,
generationState: failedGenerationState,
error: errorMessage,
},
}));
setPuzzleGenerationState(failedGenerationState);
markPendingDraftFailed('puzzle', activePuzzleGenerationSessionId);
markDraftFailed(
'puzzle',
[
latestSession.sessionId,
buildPuzzleResultWorkId(latestSession.sessionId),
latestSession.publishedProfileId,
buildPuzzleResultProfileId(latestSession.sessionId),
],
errorMessage,
);
puzzleErrorSetterRef.current(errorMessage);
void refreshPuzzleShelf();
refreshPlatformDashboardSilently();
return;
}
setPuzzleBackgroundCompileTasks((current) => {
const task = current[activePuzzleGenerationSessionId];
if (!task) {
@@ -7212,6 +7520,11 @@ export function PlatformEntryFlowShellImpl({
};
}, [
activePuzzleGenerationSessionId,
markDraftFailed,
markPendingDraftFailed,
recoverCompletedPuzzleDraftGeneration,
refreshPlatformDashboardSilently,
refreshPuzzleShelf,
shouldPollPuzzleGenerationSession,
setPuzzleSession,
]);
@@ -7561,7 +7874,79 @@ export function PlatformEntryFlowShellImpl({
actionPayload,
);
setPuzzleOperation(response.operation);
const openResult = isViewingPuzzleGeneration(nextSession.sessionId);
activePuzzleGenerationSessionIdRef.current = response.session.sessionId;
selectionStageRef.current = 'puzzle-generating';
const openResult = selectionStageRef.current === 'puzzle-generating';
if (response.operation.status !== 'completed') {
if (response.operation.status === 'failed') {
const errorMessage =
response.operation.error ?? '拼图草稿生成失败,请稍后再试。';
const failedGenerationState =
resolveFinishedMiniGameDraftGenerationState(
generationState,
'failed',
{ error: errorMessage },
);
setPuzzleBackgroundCompileTasks((current) => ({
...current,
[response.session.sessionId]: {
session: response.session,
payload,
generationState: failedGenerationState,
error: errorMessage,
},
}));
markPendingDraftFailed('puzzle', response.session.sessionId);
markDraftFailed(
'puzzle',
[
response.session.sessionId,
buildPuzzleResultWorkId(response.session.sessionId),
response.session.publishedProfileId,
buildPuzzleResultProfileId(response.session.sessionId),
],
errorMessage,
!openResult,
);
void refreshPuzzleShelf();
if (openResult) {
puzzleFlow.setSession(response.session);
setPuzzleError(errorMessage);
setPuzzleGenerationState(failedGenerationState);
}
return;
}
const generatingState = mergePuzzleSessionProgressIntoGenerationState(
generationState,
response.session,
);
setPuzzleBackgroundCompileTasks((current) => ({
...current,
[response.session.sessionId]: {
session: response.session,
payload,
generationState: generatingState,
error: null,
},
}));
markDraftGenerating('puzzle', [
response.session.sessionId,
buildPuzzleResultWorkId(response.session.sessionId),
response.session.publishedProfileId,
buildPuzzleResultProfileId(response.session.sessionId),
]);
markPendingDraftGenerating(
'puzzle',
response.session.sessionId,
buildPendingPuzzleDraftMetadata(payload),
);
if (openResult) {
puzzleFlow.setSession(response.session);
setPuzzleGenerationState(generatingState);
}
return;
}
const readyGenerationState =
resolveFinishedMiniGameDraftGenerationState(
generationState,
@@ -7580,8 +7965,8 @@ export function PlatformEntryFlowShellImpl({
error: null,
},
}));
puzzleFlow.setSession(response.session);
if (isViewingPuzzleGeneration(nextSession.sessionId)) {
puzzleFlow.setSession(response.session);
setPuzzleGenerationState(readyGenerationState);
}
@@ -7631,6 +8016,7 @@ export function PlatformEntryFlowShellImpl({
setPuzzleRun(run);
setPuzzleRuntimeAuthMode('default');
setPuzzleRuntimeReturnStage('puzzle-result');
puzzleRuntimeReturnSessionRef.current = response.session;
openPuzzleRuntimeStage(
setSelectionStage,
buildPuzzleDraftRuntimeUrlState(item, null),
@@ -10295,6 +10681,67 @@ export function PlatformEntryFlowShellImpl({
const executePuzzleAction = puzzleFlow.executeAction;
const pollPuzzleBackgroundActionUntilSettled = useCallback(
(
payload: PuzzleAgentActionRequest,
baselineSession: PuzzleAgentSessionSnapshot,
) => {
if (!isPuzzleBackgroundAction(payload)) {
return;
}
void (async () => {
for (
let attempt = 0;
attempt < PUZZLE_BACKGROUND_ACTION_MAX_POLL_ATTEMPTS;
attempt += 1
) {
await waitForPuzzleBackgroundActionPollTick();
try {
const response = await getPuzzleAgentSession(
baselineSession.sessionId,
);
puzzleFlow.setSession(response.session);
if (
isPuzzleBackgroundActionSettled(
payload,
baselineSession,
response.session,
)
) {
refreshPlatformDashboardSilently();
await Promise.allSettled([
refreshPuzzleShelf(),
refreshPuzzleGallery(),
]);
return;
}
} catch (pollError) {
if (attempt >= 2) {
setPuzzleError(
resolvePuzzleErrorMessage(
pollError,
'刷新拼图图片生成结果失败。',
),
);
return;
}
}
}
setPuzzleError('拼图图片仍在后台生成,请稍后刷新草稿查看结果。');
})();
},
[
puzzleFlow,
refreshPlatformDashboardSilently,
refreshPuzzleGallery,
refreshPuzzleShelf,
resolvePuzzleErrorMessage,
setPuzzleError,
],
);
const executePuzzleBackgroundAction = useCallback(
async (payload: PuzzleAgentActionRequest) => {
const targetSession = puzzleSession;
@@ -10315,6 +10762,13 @@ export function PlatformEntryFlowShellImpl({
);
setPuzzleOperation(response.operation);
puzzleFlow.setSession(response.session);
if (
isPuzzleBackgroundAction(payload) &&
(response.operation.status === 'queued' ||
response.operation.status === 'running')
) {
pollPuzzleBackgroundActionUntilSettled(payload, targetSession);
}
} catch (error) {
setPuzzleError(resolvePuzzleErrorMessage(error, '执行拼图操作失败。'));
} finally {
@@ -10329,6 +10783,7 @@ export function PlatformEntryFlowShellImpl({
[
puzzleFlow,
puzzleSession,
pollPuzzleBackgroundActionUntilSettled,
refreshPlatformDashboardSilently,
resolvePuzzleErrorMessage,
setPuzzleError,
@@ -11010,6 +11465,10 @@ export function PlatformEntryFlowShellImpl({
setPuzzleRun(run);
setPuzzleRuntimeAuthMode('default');
setPuzzleRuntimeReturnStage('puzzle-result');
puzzleRuntimeReturnSessionRef.current =
puzzleSession?.draft && !isPuzzleFormOnlyDraft(puzzleSession)
? puzzleSession
: null;
openPuzzleRuntimeStage(
setSelectionStage,
buildPuzzleDraftRuntimeUrlState(item, options.levelId ?? null),
@@ -11033,6 +11492,21 @@ export function PlatformEntryFlowShellImpl({
],
);
const returnFromPuzzleRuntime = useCallback(() => {
const targetStage = puzzleRuntimeReturnStage;
if (
targetStage === 'puzzle-result' &&
(!puzzleSession?.draft || isPuzzleFormOnlyDraft(puzzleSession)) &&
puzzleRuntimeReturnSessionRef.current
) {
puzzleFlow.setSession(puzzleRuntimeReturnSessionRef.current);
}
clearPuzzleRuntimeUrlState();
pushAppHistoryPath(resolvePathForSelectionStage(targetStage));
selectionStageRef.current = targetStage;
setSelectionStage(targetStage);
}, [puzzleFlow, puzzleRuntimeReturnStage, puzzleSession, setSelectionStage]);
const submitBigFishInput = useCallback(
async (payload: SubmitBigFishInputRequest) => {
if (
@@ -17971,7 +18445,9 @@ export function PlatformEntryFlowShellImpl({
<UnifiedCreationPage
spec={getUnifiedSpec('visual-novel')}
onBack={leaveVisualNovelFlow}
isBackDisabled={isVisualNovelBusy || isVisualNovelStreamingReply}
isBackDisabled={
isVisualNovelBusy || isVisualNovelStreamingReply
}
>
<VisualNovelAgentWorkspace
session={visualNovelSession}
@@ -18205,9 +18681,7 @@ export function PlatformEntryFlowShellImpl({
}
error={puzzleError}
hideBackButton={Boolean(puzzleOnboardingDraft)}
onBack={() => {
setSelectionStage(puzzleRuntimeReturnStage);
}}
onBack={returnFromPuzzleRuntime}
onRemodelWork={
selectedPuzzleDetail?.publicationStatus === 'published'
? remodelCurrentPuzzleRuntimeWork

View File

@@ -4769,6 +4769,60 @@ test('running puzzle form generation creates a new puzzle draft on same template
});
});
test('queued puzzle form generation stays on generation progress', async () => {
const user = userEvent.setup();
const queuedSession = buildMockPuzzleAgentSession({
sessionId: 'puzzle-queued-session-1',
progressPercent: 5,
lastAssistantReply: '拼图生成任务已进入后台队列。',
});
vi.mocked(createPuzzleAgentSession).mockResolvedValueOnce({
session: queuedSession,
});
vi.mocked(executePuzzleAgentAction).mockResolvedValueOnce({
operation: {
operationId: 'compile-puzzle-queued-1',
type: 'compile_puzzle_draft',
status: 'queued',
phaseLabel: '已进入后台队列',
phaseDetail: '拼图草稿生成已进入后台队列。',
progress: 5,
error: null,
},
session: queuedSession,
});
vi.mocked(getPuzzleAgentSession).mockResolvedValue({
session: {
...queuedSession,
progressPercent: 12,
},
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('拼图'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
const progressbar = await screen.findByRole('progressbar', {
name: '拼图图片生成进度',
});
expect(progressbar).toBeTruthy();
expect(updatePuzzleWork).not.toHaveBeenCalled();
expect(startLocalPuzzleRun).not.toHaveBeenCalled();
expect(screen.queryByText('拼图结果页')).toBeNull();
expect(window.location.pathname).not.toBe('/runtime/puzzle');
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
await waitFor(() => {
expect(
within(getPlatformTabPanel('saves')).getAllByRole('button', {
name: /继续创作《[^》]+》,生成中/u,
}).length,
).toBeGreaterThanOrEqual(1);
});
});
test('failed parallel puzzle generations stay as separate non-generating drafts', async () => {
const user = userEvent.setup();
const firstSession = buildMockPuzzleAgentSession({