fix: dedupe recovered generation tasks

This commit is contained in:
FengLee
2026-06-04 11:37:49 +08:00
parent 9f41d2c87a
commit 4a00eb7ef5
8 changed files with 74 additions and 26 deletions

View File

@@ -48,6 +48,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
| Refreshing `/create` resets to the wrong creation tab | `src/app/create/page.tsx` | Active tab should persist in `miaojing:create-active-tab` and mirror to `/create?type=...`. Verify all creation tabs (`text2img`, `img2img`, `text2video`, `img2video`, `reversePrompt`) restore after refresh and query-param links still override storage. |
| Cannot submit a new generation job while another job is running, or active job cards overflow horizontally | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx`, `src/components/create/generation-task-list.tsx` | Create panels should keep the submit button enabled while models are available; active job cards should render inside the results column with wrapping vertical growth, not outside the result area. |
| Earlier completed image tasks disappear while later tasks are still running | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/generation-task-list.tsx` | The results column must not be a single `generating ? taskList : results` branch. Render active task cards and completed result cards together, and append each task's images as soon as that task succeeds instead of waiting for all submitted tasks to settle. |
| One submitted generation shows two running cards, or current results show the same media twice while refreshed history has one row | `src/components/create/use-generation-job-recovery.ts`, `src/components/create/generation-task-list.tsx`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx` | Check production `generation_jobs` first. If there is only one job and one result URL, the duplicate is frontend recovery state, not backend creation. Locally submitted tasks use temporary ids before the server job id is known; recovery must treat both `jobId` and `payload.clientRequestId` as the same task identity, and result appenders should filter duplicate URLs so a recovery poll cannot add the same completed media twice. |
| Job remains queued | `src/app/api/generation-jobs/route.ts`, `src/lib/generation-job-worker.ts`, `src/lib/generation-job-runner.ts` | `processNextGenerationJob()` invoked, stale job handling, DB locks/status, internal base URL. |
| Job remains running forever | `src/app/api/generation-jobs/[id]/route.ts`, `src/lib/generation-job-worker.ts`, `src/lib/generation-job-estimates.ts` | Stale timeout updates, `updated_at`, worker exceptions swallowed into error field. |
| Image generation returns upstream error | `src/app/api/generate/image/route.ts`, `src/lib/custom-api-fetch.ts`, `src/lib/custom-image-fallback.ts`, `src/lib/server-api-config.ts` | Resolved custom/system API credentials, endpoint URL, New API normalization, timeout, stream/progress parser, and system-default stream timeout fallback. Gateway 502/503/504 errors are retried once; system default model failures should return the last actionable upstream timeout/gateway message instead of hiding everything behind the generic busy message. |

View File

@@ -91,6 +91,31 @@ await runTest('active generation job recovery avoids anonymous polling and dedup
assert.match(source, /getActiveJobsRequestKey\(normalizedTypes, authToken\)/);
});
await runTest('active job recovery dedupes locally submitted tasks by client request id', () => {
const taskListSource = read('src/components/create/generation-task-list.tsx');
const recoverySource = read('src/components/create/use-generation-job-recovery.ts');
const textToImageSource = read('src/components/create/text-to-image.tsx');
assert.match(taskListSource, /clientRequestId\?: string;/);
assert.match(recoverySource, /payload\?\.clientRequestId/);
assert.match(recoverySource, /getJobIdentityIds/);
assert.match(recoverySource, /identityIds\.some\(id => activeJobIdsRef\.current\.has\(id\) \|\| knownJobIdsRef\.current\.has\(id\)\)/);
assert.match(textToImageSource, /clientRequestId: taskId/);
assert.match(textToImageSource, /task\.clientRequestId/);
});
await runTest('create panels do not prepend duplicate completed media urls', () => {
for (const relativePath of [
'src/components/create/text-to-image.tsx',
'src/components/create/image-to-image.tsx',
'src/components/create/text-to-video.tsx',
'src/components/create/image-to-video.tsx',
]) {
const source = read(relativePath);
assert.match(source, /filter\(url => !prev\.includes\(url\)\)/, `${relativePath} should filter duplicate result URLs before prepending`);
}
});
await runTest('generation job client builds auth headers from one parsed token per request', () => {
const source = read('src/lib/generation-job-client.ts');
assert.match(source, /function getAuthHeaders\(authToken = getAuthToken\(\)\)/);

View File

@@ -8,6 +8,7 @@ import { AlertTriangle, Loader2 } from 'lucide-react';
export type ActiveGenerationTask = {
id: string;
jobId?: string;
clientRequestId?: string;
title: string;
startedAt: number;
estimateSeconds: number;

View File

@@ -136,7 +136,7 @@ export function ImageToImagePanel() {
const syncConfirmationResolversRef = useRef(new Map<string, (confirmed: boolean) => void>());
const generating = activeTasks.length > 0;
const activeJobIds = useMemo(
() => activeTasks.map(task => task.jobId || task.id).filter((id): id is string => Boolean(id)),
() => activeTasks.flatMap(task => [task.jobId, task.clientRequestId, task.id]).filter((id): id is string => Boolean(id)),
[activeTasks],
);
@@ -492,7 +492,7 @@ export function ImageToImagePanel() {
types: ['image'],
knownJobIds: activeJobIds,
onTaskRecovered: task => {
setActiveTasks(prev => prev.some(item => item.id === task.id) ? prev : [...prev, task]);
setActiveTasks(prev => prev.some(item => item.id === task.id || item.jobId === task.jobId || (task.clientRequestId && item.clientRequestId === task.clientRequestId) || (task.clientRequestId && item.id === task.clientRequestId)) ? prev : [...prev, task]);
},
onTaskFinished: (taskId, job) => {
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
@@ -505,7 +505,7 @@ export function ImageToImagePanel() {
url,
result?.thumbnails?.[url] || result?.thumbnailUrls?.[imageIndex] || url,
]));
setResults(prev => [...images, ...prev]);
setResults(prev => [...images.filter(url => !prev.includes(url)), ...prev]);
setResultThumbnails(prev => ({ ...prev, ...thumbnails }));
if (result?.dimensions) setResultDimensions(prev => ({ ...prev, ...result.dimensions! }));
const creditsCost = Math.max(0, Number(result?.creditsCost || 0));
@@ -573,6 +573,7 @@ export function ImageToImagePanel() {
...prev,
{
id: taskId,
clientRequestId: taskId,
title: '正在生成图片',
startedAt: Date.now(),
estimateSeconds: 90,
@@ -626,7 +627,7 @@ export function ImageToImagePanel() {
);
let data: { images?: string[]; thumbnails?: Record<string, string>; thumbnailUrls?: string[]; dimensions?: Record<string, { width: number; height: number }>; error?: string; creditsCost?: number; creditsBalance?: number };
try {
data = await runJob({ ...requestBody, stream: true });
data = await runJob({ ...requestBody, clientRequestId: taskId, stream: true });
} catch (error) {
const confirmationMessage = parseStreamUnsupportedSyncMessage(error);
if (!confirmationMessage) throw error;
@@ -641,7 +642,7 @@ export function ImageToImagePanel() {
});
data = await runJob({
...requestBody,
clientRequestId: `img2img-sync-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
clientRequestId: taskId,
stream: false,
});
}
@@ -653,7 +654,7 @@ export function ImageToImagePanel() {
]));
const creditsCost = Math.max(0, Number(data.creditsCost || 0));
const creditsPerImage = creditsCost > 0 ? Math.ceil(creditsCost / Math.max(1, data.images.length)) : 0;
setResults(prev => [...data.images!, ...prev]);
setResults(prev => [...data.images!.filter(url => !prev.includes(url)), ...prev]);
setResultThumbnails(prev => ({ ...prev, ...thumbnails }));
if (data.dimensions) setResultDimensions(prev => ({ ...prev, ...data.dimensions }));
if (creditsPerImage > 0) {

View File

@@ -75,7 +75,7 @@ export function ImageToVideoPanel() {
const [referencePreviewSrc, setReferencePreviewSrc] = useState<string | null>(null);
const generating = activeTasks.length > 0;
const activeJobIds = useMemo(
() => activeTasks.map(task => task.jobId || task.id).filter((id): id is string => Boolean(id)),
() => activeTasks.flatMap(task => [task.jobId, task.clientRequestId, task.id]).filter((id): id is string => Boolean(id)),
[activeTasks],
);
@@ -369,14 +369,14 @@ export function ImageToVideoPanel() {
types: ['video'],
knownJobIds: activeJobIds,
onTaskRecovered: task => {
setActiveTasks(prev => prev.some(item => item.id === task.id) ? prev : [...prev, task]);
setActiveTasks(prev => prev.some(item => item.id === task.id || item.jobId === task.jobId || (task.clientRequestId && item.clientRequestId === task.clientRequestId) || (task.clientRequestId && item.id === task.clientRequestId)) ? prev : [...prev, task]);
},
onTaskFinished: (taskId, job) => {
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
const result = job.result as { videos?: string[]; creditsCost?: number; creditsBalance?: number } | undefined;
if (Array.isArray(result?.videos) && result.videos.length > 0) {
const primaryImage = refImages[0]?.dataUrl;
setResults(prev => [...result.videos!, ...prev]);
setResults(prev => [...result.videos!.filter(url => !prev.includes(url)), ...prev]);
setGenerationError(null);
const creditsCost = Math.max(0, Number(result?.creditsCost || 0));
const creditsPerVideo = creditsCost > 0 ? Math.ceil(creditsCost / Math.max(1, result.videos.length)) : 0;
@@ -417,6 +417,7 @@ export function ImageToVideoPanel() {
...prev,
{
id: taskId,
clientRequestId: taskId,
title: '正在生成视频',
startedAt: Date.now(),
estimateSeconds: 300,
@@ -433,7 +434,8 @@ export function ImageToVideoPanel() {
duration,
resolution,
fps: 30,
image: primaryImage,
clientRequestId: taskId,
image: primaryImage,
extraImages: refImages.length > 1 ? refImages.slice(1).map(img => img.dataUrl) : undefined,
images: refImages.length > 0 ? refImages.map(img => img.dataUrl) : undefined,
};
@@ -456,7 +458,7 @@ export function ImageToVideoPanel() {
);
await runGenerationFinalCountdown((seconds) => updateActiveTask(taskId, { finalCountdownSeconds: seconds }), 3);
if (data.videos && data.videos.length > 0) {
setResults(prev => [...data.videos!, ...prev]);
setResults(prev => [...data.videos!.filter(url => !prev.includes(url)), ...prev]);
setGenerationError(null);
const creditsCost = Math.max(0, Number(data.creditsCost || 0));
const creditsPerVideo = creditsCost > 0 ? Math.ceil(creditsCost / Math.max(1, data.videos.length)) : 0;

View File

@@ -131,7 +131,7 @@ export function TextToImagePanel() {
const syncConfirmationResolversRef = useRef(new Map<string, (confirmed: boolean) => void>());
const generating = activeTasks.length > 0;
const activeJobIds = useMemo(
() => activeTasks.map(task => task.jobId || task.id).filter((id): id is string => Boolean(id)),
() => activeTasks.flatMap(task => [task.jobId, task.clientRequestId, task.id]).filter((id): id is string => Boolean(id)),
[activeTasks],
);
@@ -395,7 +395,7 @@ export function TextToImagePanel() {
types: ['image'],
knownJobIds: activeJobIds,
onTaskRecovered: task => {
setActiveTasks(prev => prev.some(item => item.id === task.id) ? prev : [...prev, task]);
setActiveTasks(prev => prev.some(item => item.id === task.id || item.jobId === task.jobId || (task.clientRequestId && item.clientRequestId === task.clientRequestId) || (task.clientRequestId && item.id === task.clientRequestId)) ? prev : [...prev, task]);
},
onTaskFinished: (taskId, job) => {
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
@@ -413,7 +413,7 @@ export function TextToImagePanel() {
url,
result?.thumbnails?.[url] || result?.thumbnailUrls?.[imageIndex] || url,
]));
setResults(prev => [...images, ...prev]);
setResults(prev => [...images.filter(url => !prev.includes(url)), ...prev]);
setResultThumbnails(prev => ({ ...prev, ...thumbnails }));
if (result?.dimensions) setResultDimensions(prev => ({ ...prev, ...result.dimensions! }));
const creditsCost = Math.max(0, Number(result?.creditsCost || 0));
@@ -538,6 +538,7 @@ export function TextToImagePanel() {
...prev,
...taskIds.map(taskId => ({
id: taskId,
clientRequestId: taskId,
title: '正在生成图片',
startedAt: Date.now(),
estimateSeconds: 90,
@@ -555,7 +556,7 @@ export function TextToImagePanel() {
);
let data: { images?: string[]; thumbnails?: Record<string, string>; thumbnailUrls?: string[]; dimensions?: Record<string, { width: number; height: number }>; error?: string; creditsCost?: number; creditsBalance?: number };
try {
data = await runJob({ ...requestBodyBase, count: 1, clientRequestId: `${batchId}-${index + 1}`, stream: true });
data = await runJob({ ...requestBodyBase, count: 1, clientRequestId: taskId, stream: true });
} catch (error) {
const confirmationMessage = parseStreamUnsupportedSyncMessage(error);
if (!confirmationMessage) throw error;
@@ -571,7 +572,7 @@ export function TextToImagePanel() {
data = await runJob({
...requestBodyBase,
count: 1,
clientRequestId: `${batchId}-${index + 1}-sync-${Date.now()}`,
clientRequestId: taskId,
stream: false,
});
}
@@ -586,7 +587,7 @@ export function TextToImagePanel() {
]));
const creditsCost = Math.max(0, Number(data.creditsCost || 0));
const creditsPerImage = creditsCost > 0 ? Math.ceil(creditsCost / Math.max(1, taskImages.length)) : 0;
setResults(prev => [...taskImages, ...prev]);
setResults(prev => [...taskImages.filter(url => !prev.includes(url)), ...prev]);
setResultThumbnails(prev => ({ ...prev, ...thumbnails }));
if (data.dimensions) setResultDimensions(prev => ({ ...prev, ...data.dimensions }));
if (creditsPerImage > 0) {

View File

@@ -68,7 +68,7 @@ export function TextToVideoPanel() {
const [inspirationOpen, setInspirationOpen] = useState(false);
const generating = activeTasks.length > 0;
const activeJobIds = useMemo(
() => activeTasks.map(task => task.jobId || task.id).filter((id): id is string => Boolean(id)),
() => activeTasks.flatMap(task => [task.jobId, task.clientRequestId, task.id]).filter((id): id is string => Boolean(id)),
[activeTasks],
);
@@ -261,13 +261,13 @@ export function TextToVideoPanel() {
types: ['video'],
knownJobIds: activeJobIds,
onTaskRecovered: task => {
setActiveTasks(prev => prev.some(item => item.id === task.id) ? prev : [...prev, task]);
setActiveTasks(prev => prev.some(item => item.id === task.id || item.jobId === task.jobId || (task.clientRequestId && item.clientRequestId === task.clientRequestId) || (task.clientRequestId && item.id === task.clientRequestId)) ? prev : [...prev, task]);
},
onTaskFinished: (taskId, job) => {
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
const result = job.result as { videos?: string[]; creditsCost?: number; creditsBalance?: number } | undefined;
if (Array.isArray(result?.videos) && result.videos.length > 0) {
setResults(prev => [...result.videos!, ...prev]);
setResults(prev => [...result.videos!.filter(url => !prev.includes(url)), ...prev]);
setGenerationError(null);
const creditsCost = Math.max(0, Number(result?.creditsCost || 0));
const creditsPerVideo = creditsCost > 0 ? Math.ceil(creditsCost / Math.max(1, result.videos.length)) : 0;
@@ -306,6 +306,7 @@ export function TextToVideoPanel() {
...prev,
{
id: taskId,
clientRequestId: taskId,
title: '正在生成视频',
startedAt: Date.now(),
estimateSeconds: 300,
@@ -321,6 +322,7 @@ export function TextToVideoPanel() {
duration,
resolution,
fps: 30,
clientRequestId: taskId,
};
if (isCustomModel(selectedModel)) {
@@ -341,7 +343,7 @@ export function TextToVideoPanel() {
);
await runGenerationFinalCountdown((seconds) => updateActiveTask(taskId, { finalCountdownSeconds: seconds }), 3);
if (data.videos && data.videos.length > 0) {
setResults(prev => [...data.videos!, ...prev]);
setResults(prev => [...data.videos!.filter(url => !prev.includes(url)), ...prev]);
setGenerationError(null);
const creditsCost = Math.max(0, Number(data.creditsCost || 0));
const creditsPerVideo = creditsCost > 0 ? Math.ceil(creditsCost / Math.max(1, data.videos.length)) : 0;

View File

@@ -38,9 +38,11 @@ function normalizeJobTask(job: GenerationJobStatus): ActiveGenerationTask | null
const id = String(job.jobId || job.id || '');
if (!id) return null;
const type = job.type || 'image';
const clientRequestId = typeof job.payload?.clientRequestId === 'string' ? job.payload.clientRequestId : undefined;
return {
id,
jobId: id,
clientRequestId,
title: toJobTaskTitle(type),
startedAt: job.started_at ? new Date(job.started_at).getTime() : Date.now(),
estimateSeconds: toTaskEstimateSeconds(job),
@@ -53,6 +55,17 @@ function sleep(ms: number) {
return new Promise(resolve => window.setTimeout(resolve, ms));
}
function getJobIdentityIds(job: GenerationJobStatus, task: ActiveGenerationTask): string[] {
return Array.from(new Set([
task.id,
task.jobId,
task.clientRequestId,
typeof job.payload?.clientRequestId === 'string' ? job.payload.clientRequestId : undefined,
typeof job.jobId === 'string' ? job.jobId : undefined,
typeof job.id === 'string' ? job.id : undefined,
].filter((id): id is string => Boolean(id))));
}
export function useGenerationJobRecovery({
types,
knownJobIds = [],
@@ -101,25 +114,27 @@ export function useGenerationJobRecovery({
if (cancelled) return;
for (const job of jobs) {
const task = normalizeJobTask(job);
if (!task || activeJobIdsRef.current.has(task.id) || knownJobIdsRef.current.has(task.id)) continue;
activeJobIdsRef.current.add(task.id);
if (!task) continue;
const identityIds = getJobIdentityIds(job, task);
if (identityIds.some(id => activeJobIdsRef.current.has(id) || knownJobIdsRef.current.has(id))) continue;
for (const id of identityIds) activeJobIdsRef.current.add(id);
onTaskRecoveredRef.current(task, job);
void (async () => {
const timeoutMs = job.type === 'video' ? 600_000 : job.type === 'reverse-prompt' ? 300_000 : 900_000;
const onStatus = (status: GenerationJobStatus) => {
if (cancelled) return;
if (status.status === 'failed') {
activeJobIdsRef.current.delete(task.id);
for (const id of identityIds) activeJobIdsRef.current.delete(id);
onTaskFailedRef.current(task.id, status.error || '生成任务失败', status);
return;
}
if (status.status === 'succeeded') {
activeJobIdsRef.current.delete(task.id);
for (const id of identityIds) activeJobIdsRef.current.delete(id);
onTaskFinishedRef.current(task.id, status);
}
};
while (!cancelled && activeJobIdsRef.current.has(task.id)) {
while (!cancelled && identityIds.some(id => activeJobIdsRef.current.has(id))) {
try {
await continueGenerationJob(task.id, {
timeoutMs,