From c5c49b52a9e0f0c3b12b0c2c6ac65372ca5e5016 Mon Sep 17 00:00:00 2001 From: 134 Date: Sat, 1 Aug 2026 15:59:32 +0800 Subject: [PATCH] fix(agent): sync detail replacement task status --- app/api/canvas/generate/image/route.ts | 14 + .../agents/AgentGenerationWorkspaceDarkV2.tsx | 263 +++++++++++++++--- lib/agents/pending-generation-guard.ts | 9 +- lib/agents/replacement-task-status.ts | 182 ++++++++++++ lib/ai/providers/fal-image.ts | 17 +- lib/canvas/generate-image.ts | 26 ++ lib/canvas/image-task-queue.ts | 110 +++++++- .../test-agent-generation-pending-guard.ts | 10 + scripts/test-agent-replacement-task-status.ts | 138 +++++++++ 9 files changed, 724 insertions(+), 45 deletions(-) create mode 100644 lib/agents/replacement-task-status.ts create mode 100644 scripts/test-agent-replacement-task-status.ts diff --git a/app/api/canvas/generate/image/route.ts b/app/api/canvas/generate/image/route.ts index df0b9ed20..1b48afe0d 100644 --- a/app/api/canvas/generate/image/route.ts +++ b/app/api/canvas/generate/image/route.ts @@ -4,6 +4,7 @@ import { createCanvasImageTask, enqueueCanvasImageTask, getCanvasImageTaskForUser, + getCanvasDetailReplacementTasksForUserContext, } from "@/lib/canvas/image-task-queue"; import { generateCanvasImage, @@ -87,6 +88,19 @@ export async function GET(request: Request) { const { searchParams } = new URL(request.url); const taskId = String(searchParams.get("taskId") || "").trim(); + const chatId = String(searchParams.get("chatId") || "").trim(); + const projectId = String(searchParams.get("projectId") || "").trim(); + if (!taskId && chatId) { + const tasks = await getCanvasDetailReplacementTasksForUserContext({ + userId: session.user.id, + chatId, + projectId: projectId || null, + }); + return NextResponse.json({ + success: true, + tasks, + }); + } if (!taskId) { return NextResponse.json( { success: false, error: "缺少 taskId 参数" }, diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index 4cce42441..b3f773550 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -118,6 +118,14 @@ import { isReplacementResultMode, shouldShowAgentGridResultSection, } from "@/lib/agents/generation-result-visibility"; +import { + buildLatestAgentReplacementTaskMap, + getAgentReplacementTaskResultImageUrl, + isAgentReplacementTaskInPendingRun, + resolveAgentReplacementCardStatus, + type AgentReplacementCardStatus, + type AgentReplacementTaskSnapshot, +} from "@/lib/agents/replacement-task-status"; import { defaultAgentWorkflowBusinessModeSettings, normalizeAgentWorkflowBusinessModeSettings, @@ -2401,14 +2409,12 @@ const IMAGE_OVERLAY_DANGER_TEXT_CLASS = const IMAGE_OVERLAY_DANGER_ICON_CLASS = "absolute right-3 top-3 flex h-9 w-9 items-center justify-center rounded-full border border-rose-200/55 bg-zinc-950/82 text-rose-50 shadow-[0_12px_34px_rgba(0,0,0,0.50)] backdrop-blur-md transition-colors hover:border-rose-100 hover:bg-rose-500/35"; -type ReplacementCardStatus = - | "waiting" - | "queued" - | "generating" - | "completed" - | "passthrough" - | "fallback" - | "missing"; +type ReplacementCardStatus = AgentReplacementCardStatus; + +interface AgentReplacementTasksResponse { + success?: boolean; + tasks?: AgentReplacementTaskSnapshot[]; +} type ReplacementModeType = Extract< AgentGenerationResultModeType, @@ -2482,7 +2488,9 @@ function areReplacementResultStatesEqual( ); } -function inferReplacementCardStatusFromText(text: string | null | undefined): ReplacementCardStatus | null { +function inferReplacementCardStatusFromText( + text: string | null | undefined +): "completed" | "passthrough" | "fallback" | null { const normalized = normalizeAssistantText(String(text || "")) .replace(/\s+/g, " ") .trim(); @@ -2511,30 +2519,19 @@ function getReplacementCardStatus(params: { generatedCount: number; isGenerating: boolean; activeRunIndex?: number | null; + taskStatus?: AgentReplacementTaskSnapshot["status"] | null; + isRequestedInPendingRun?: boolean; }): ReplacementCardStatus { - if ( - typeof params.activeRunIndex === "number" && - params.activeRunIndex >= 0 && - params.generatedCount <= params.activeRunIndex - ) { - if (params.isGenerating) { - return params.generatedCount === params.activeRunIndex ? "generating" : "queued"; - } - if (!params.resultUrl && params.generatedCount > 0) { - return "missing"; - } - } - - if (params.resultUrl) { - return inferReplacementCardStatusFromText(params.resultText) || "completed"; - } - if (params.isGenerating) { - return params.index === params.generatedCount ? "generating" : "queued"; - } - if (params.generatedCount > 0 && params.index >= params.generatedCount) { - return "missing"; - } - return "waiting"; + return resolveAgentReplacementCardStatus({ + index: params.index, + resultUrl: params.resultUrl, + persistedResultStatus: inferReplacementCardStatusFromText(params.resultText), + generatedCount: params.generatedCount, + isGenerating: params.isGenerating, + activeRunIndex: params.activeRunIndex, + taskStatus: params.taskStatus, + isRequestedInPendingRun: params.isRequestedInPendingRun === true, + }); } function getReplacementCardStatusMeta(status: ReplacementCardStatus) { @@ -2550,6 +2547,11 @@ function getReplacementCardStatusMeta(status: ReplacementCardStatus) { label: "失败已沿用", className: "border-amber-100/80 bg-amber-500/92 text-zinc-950", }; + case "failed": + return { + label: "生成失败", + className: "border-rose-100/80 bg-rose-600/92 text-white", + }; case "completed": return { label: "已生成", @@ -3472,6 +3474,54 @@ export function AgentGenerationWorkspaceDarkV2({ const isDetailAiGenerationMethod = currentResultModeType === "detail_generation"; const isReplacementPreset = currentResultModeType === "detail_replacement" || currentResultModeType === "sku_replacement"; + const replacementTasksUrl = + currentResultModeType === "detail_replacement" && rawChatId + ? `/api/canvas/generate/image?chatId=${encodeURIComponent( + rawChatId + )}${ + isValidChatUuid(projectId) + ? `&projectId=${encodeURIComponent(projectId)}` + : "" + }` + : null; + const { + data: replacementTasksData, + mutate: refreshReplacementTasks, + } = useSWR(replacementTasksUrl, fetcher, { + refreshInterval: (latestData) => + pendingGeneration || + latestData?.tasks?.some( + (task) => task.status === "pending" || task.status === "running" + ) + ? 2500 + : 0, + revalidateOnFocus: true, + }); + const replacementTaskSnapshots = useMemo( + () => + Array.isArray(replacementTasksData?.tasks) + ? replacementTasksData.tasks.filter( + (task) => task.modeType === "detail_replacement" + ) + : [], + [replacementTasksData?.tasks] + ); + const latestDetailTaskByCardId = useMemo( + () => + buildLatestAgentReplacementTaskMap({ + tasks: replacementTaskSnapshots, + modeType: "detail_replacement", + }), + [replacementTaskSnapshots] + ); + const hasActiveReplacementTasks = replacementTaskSnapshots.some( + (task) => task.status === "pending" || task.status === "running" + ); + const hasActiveDetailReplacementTasks = replacementTaskSnapshots.some( + (task) => + task.modeType === "detail_replacement" && + (task.status === "pending" || task.status === "running") + ); const isProductOnlyDetailReplacementMode = currentResultModeType === "detail_replacement" && detailReplacementStrategy === "product_only"; @@ -4703,6 +4753,74 @@ export function AgentGenerationWorkspaceDarkV2({ latestReplacementResults, skuReplacementReferenceCards, ]); + useEffect(() => { + const succeededTasks = replacementTaskSnapshots.filter( + (task) => + task.modeType === "detail_replacement" && + task.status === "succeeded" && + Boolean(getAgentReplacementTaskResultImageUrl(task.result)) + ); + if (succeededTasks.length === 0) { + return; + } + + const referenceCardById = new Map( + detailReplacementReferenceCards.map((card) => [card.id, card]) + ); + setReplacementResultState((current) => { + const nextDetailState = { ...current.detail_replacement }; + let changed = false; + + succeededTasks.forEach((task) => { + const referenceCard = referenceCardById.get(task.referenceId); + const resultUrl = getAgentReplacementTaskResultImageUrl(task.result); + if (!referenceCard || !resultUrl) { + return; + } + + const taskUpdatedAt = Date.parse( + task.completedAt || task.startedAt || task.createdAt + ); + const previousRecord = nextDetailState[task.referenceId]; + if ( + previousRecord?.url === resultUrl || + (Number.isFinite(taskUpdatedAt) && + Number(previousRecord?.updatedAt || 0) > taskUpdatedAt) + ) { + return; + } + + nextDetailState[task.referenceId] = { + url: resultUrl, + text: "已从后台详情替换任务恢复生图结果。", + status: "completed", + updatedAt: Number.isFinite(taskUpdatedAt) ? taskUpdatedAt : Date.now(), + compareSourceUrl: normalizeResultCompareSourceUrl( + resultUrl, + previousRecord?.url || + referenceCard.imageUrl || + referenceCard.previewUrl || + previousRecord?.compareSourceUrl || + null + ), + referenceImageUrl: + referenceCard.imageUrl || previousRecord?.referenceImageUrl || null, + referencePreviewUrl: + referenceCard.previewUrl || previousRecord?.referencePreviewUrl || null, + label: referenceCard.label || previousRecord?.label || null, + }; + changed = true; + }); + + if (!changed) { + return current; + } + return { + ...current, + detail_replacement: nextDetailState, + }; + }); + }, [detailReplacementReferenceCards, replacementTaskSnapshots]); const detailReplacementResultUrls = useMemo( () => { const urlsFromCards = detailReplacementReferenceCards @@ -4787,6 +4905,32 @@ export function AgentGenerationWorkspaceDarkV2({ return 0; } + if (pendingGeneration.modeType === "detail_replacement") { + const completedCardIds = new Set(); + latestReplacementResults.forEach((result, index) => { + const cardId = + result.replacementCardId || pendingGeneration.requestedCardIds?.[index] || null; + if (cardId) { + completedCardIds.add(cardId); + } + }); + (pendingGeneration.requestedCardIds || []).forEach((cardId) => { + const task = latestDetailTaskByCardId.get(cardId); + if ( + task && + (task.status === "succeeded" || task.status === "failed") && + isAgentReplacementTaskInPendingRun({ + task, + pendingStartedAt: pendingGeneration.timestamp, + requestMessageId: pendingGeneration.requestMessageId, + }) + ) { + completedCardIds.add(cardId); + } + }); + return completedCardIds.size; + } + if ( pendingGeneration.modeType !== "detail_generation" && userMessageCount <= pendingGeneration.userTurnCountAtStart @@ -4806,14 +4950,17 @@ export function AgentGenerationWorkspaceDarkV2({ }, [ currentSceneGeneratedUrls.length, detailGenerationRunResults.length, - latestReplacementResults.length, + latestDetailTaskByCardId, + latestReplacementResults, pendingGeneration, userMessageCount, ]); - const isAnyGenerationRunning = transportIsGenerating || Boolean(pendingGeneration); + const isAnyGenerationRunning = + transportIsGenerating || Boolean(pendingGeneration) || hasActiveReplacementTasks; const isDetailResultModeGenerating = pendingGeneration?.modeType === "detail_replacement" || - (currentResultModeType === "detail_replacement" && transportIsGenerating); + (currentResultModeType === "detail_replacement" && + (transportIsGenerating || hasActiveDetailReplacementTasks)); const isSkuResultModeGenerating = pendingGeneration?.modeType === "sku_replacement" || (currentResultModeType === "sku_replacement" && transportIsGenerating); @@ -4915,12 +5062,25 @@ export function AgentGenerationWorkspaceDarkV2({ Array.isArray(pendingGeneration.requestedCardIds) ? pendingGeneration.requestedCardIds.indexOf(card.id) : -1; + const latestTask = latestDetailTaskByCardId.get(card.id) || null; + const isRequestedInPendingRun = activeRunIndex >= 0; + const currentRunTask = + !pendingGeneration || + pendingGeneration.modeType !== "detail_replacement" || + isAgentReplacementTaskInPendingRun({ + task: latestTask, + pendingStartedAt: pendingGeneration.timestamp, + requestMessageId: pendingGeneration.requestMessageId, + }) + ? latestTask + : null; return { ...card, index, resultUrl, resultText, compareSourceUrl, + taskQueueStatus: currentRunTask?.queueStatus || null, queuePosition: activeRunIndex >= 0 && pendingGeneration?.modeType === "detail_replacement" ? activeRunIndex + 1 @@ -4939,14 +5099,18 @@ export function AgentGenerationWorkspaceDarkV2({ : 0, isGenerating: isDetailResultModeGenerating, activeRunIndex: activeRunIndex >= 0 ? activeRunIndex : null, + taskStatus: currentRunTask?.status || null, + isRequestedInPendingRun, }), }; }), [ detailReplacementReferenceCards, isDetailResultModeGenerating, + latestDetailTaskByCardId, latestReplacementResults, normalizedDeletedResultCollections.detail_replacement, + pendingGeneration, pendingGeneration?.modeType, pendingGeneration?.requestedCardIds, replacementResultState.detail_replacement, @@ -5436,6 +5600,9 @@ export function AgentGenerationWorkspaceDarkV2({ ) { return; } + if (transportIsGenerating) { + return; + } const requestAccepted = isAgentGenerationRequestAccepted({ pending: pendingGeneration, @@ -5483,7 +5650,13 @@ export function AgentGenerationWorkspaceDarkV2({ const timerId = window.setTimeout(clearUnacceptedGeneration, remainingMs); return () => window.clearTimeout(timerId); - }, [historyMessages, pendingGeneration, serverUserMessageCount, stop]); + }, [ + historyMessages, + pendingGeneration, + serverUserMessageCount, + stop, + transportIsGenerating, + ]); useEffect(() => { if (!pendingGeneration) { @@ -6452,6 +6625,11 @@ export function AgentGenerationWorkspaceDarkV2({ setPendingGeneration(null); toast.error(error instanceof Error ? error.message : "发送生成请求失败"); }); + if (replacementModeType === "detail_replacement") { + window.setTimeout(() => { + void refreshReplacementTasks(); + }, 500); + } if (selectedAgentPreset?.type !== "image") { setInput(""); } @@ -6484,6 +6662,7 @@ export function AgentGenerationWorkspaceDarkV2({ executableModelIdSet, lockedWorkflowModelId, requiredProductUploadCount, + refreshReplacementTasks, requiresPresetSelection, selectedAgentPreset, selectedAgentPresetId, @@ -10336,10 +10515,16 @@ export function AgentGenerationWorkspaceDarkV2({ {isPending ? card.status === "generating" ? "正在重做这一屏,新的结果回来后会直接覆盖当前展示。" + : Number(card.taskQueueStatus?.queuePosition || 0) > 0 + ? `已进入生图队列,前方还有 ${card.taskQueueStatus?.queuePosition} 个同模型任务。` : card.queuePosition && card.queueTotal - ? `当前批次位次 ${card.queuePosition}/${card.queueTotal},前面的屏完成后会继续处理这一屏。` - : "前面的屏生成完成后,会继续处理这一屏。" - : card.resultUrl + ? `当前批次位次 ${card.queuePosition}/${card.queueTotal},已进入生图队列。` + : "已进入生图队列,正在等待可用并发。" + : card.status === "failed" + ? card.resultUrl + ? "本次重新生成失败,已保留上一次结果,可再次重试。" + : "本次生成失败,可点击开始处理重新生成。" + : card.resultUrl ? card.status === "passthrough" ? "当前参考图未检测到可替换产品,本屏先沿用参考图展示;如需继续调整,仍可在当前页改字或重新生图。" : card.status === "fallback" diff --git a/lib/agents/pending-generation-guard.ts b/lib/agents/pending-generation-guard.ts index 0900fb058..25c0ca43a 100644 --- a/lib/agents/pending-generation-guard.ts +++ b/lib/agents/pending-generation-guard.ts @@ -18,11 +18,14 @@ export function isAgentGenerationRequestAccepted(params: { const requestMessageId = String( params.pending.requestMessageId || "" ).trim(); - if (requestMessageId) { - return params.serverMessages.some( + if ( + requestMessageId && + params.serverMessages.some( (message) => message.role === "user" && message.id === requestMessageId - ); + ) + ) { + return true; } const serverUserMessageCount = params.serverMessages.filter( diff --git a/lib/agents/replacement-task-status.ts b/lib/agents/replacement-task-status.ts new file mode 100644 index 000000000..aa3137fc9 --- /dev/null +++ b/lib/agents/replacement-task-status.ts @@ -0,0 +1,182 @@ +export type AgentReplacementTaskModeType = + | "detail_replacement" + | "sku_replacement"; + +export type AgentReplacementTaskStatus = + | "pending" + | "running" + | "succeeded" + | "failed"; + +export interface AgentReplacementTaskSnapshot { + id: string; + modeType: AgentReplacementTaskModeType; + referenceId: string; + messageId?: string | null; + status: AgentReplacementTaskStatus; + error: string | null; + result: unknown; + createdAt: string; + startedAt: string | null; + completedAt: string | null; + queueStatus?: { + queuePosition?: number | null; + sameModelPendingCount?: number | null; + sameModelRunningCount?: number | null; + concurrencyLimit?: number | null; + estimatedWaitMs?: number | null; + } | null; +} + +export type AgentReplacementCardStatus = + | "waiting" + | "queued" + | "generating" + | "completed" + | "passthrough" + | "fallback" + | "failed" + | "missing"; + +function toTimestamp(value: string | null | undefined) { + const timestamp = Date.parse(String(value || "")); + return Number.isFinite(timestamp) ? timestamp : 0; +} + +export function buildLatestAgentReplacementTaskMap(params: { + tasks: AgentReplacementTaskSnapshot[]; + modeType: AgentReplacementTaskModeType; +}) { + const result = new Map(); + const orderedTasks = [...params.tasks].sort( + (left, right) => toTimestamp(right.createdAt) - toTimestamp(left.createdAt) + ); + + for (const task of orderedTasks) { + if ( + task.modeType !== params.modeType || + !task.referenceId || + result.has(task.referenceId) + ) { + continue; + } + result.set(task.referenceId, task); + } + + return result; +} + +export function isAgentReplacementTaskInPendingRun(params: { + task: AgentReplacementTaskSnapshot | null | undefined; + pendingStartedAt?: number | null; + requestMessageId?: string | null; + clockSkewAllowanceMs?: number; +}) { + if (!params.task) { + return false; + } + const taskMessageId = String(params.task.messageId || "").trim(); + const requestMessageId = String(params.requestMessageId || "").trim(); + if (taskMessageId && requestMessageId) { + return taskMessageId === requestMessageId; + } + if (!Number.isFinite(Number(params.pendingStartedAt))) { + return true; + } + + const taskCreatedAt = toTimestamp(params.task.createdAt); + const pendingStartedAt = Number(params.pendingStartedAt); + const clockSkewAllowanceMs = Math.max(0, params.clockSkewAllowanceMs ?? 15_000); + return taskCreatedAt >= pendingStartedAt - clockSkewAllowanceMs; +} + +export function getAgentReplacementTaskResultImageUrl(result: unknown) { + if (!result || typeof result !== "object" || Array.isArray(result)) { + return null; + } + + const images = (result as { images?: unknown }).images; + if (!Array.isArray(images)) { + return null; + } + + for (const image of images) { + if (!image || typeof image !== "object" || Array.isArray(image)) { + continue; + } + const record = image as Record; + const originalUrl = String(record.originalUrl || "").trim(); + if (/^https?:\/\//i.test(originalUrl)) { + return originalUrl; + } + const thumbnailUrl = String(record.thumbnailUrl || "").trim(); + if (/^https?:\/\//i.test(thumbnailUrl)) { + return thumbnailUrl; + } + } + + return null; +} + +export function resolveAgentReplacementCardStatus(params: { + resultUrl: string | null; + persistedResultStatus?: "completed" | "passthrough" | "fallback" | null; + taskStatus?: AgentReplacementTaskStatus | null; + isRequestedInPendingRun: boolean; + isGenerating: boolean; + index: number; + generatedCount: number; + activeRunIndex?: number | null; +}): AgentReplacementCardStatus { + if (params.taskStatus === "pending") { + return "queued"; + } + if (params.taskStatus === "running") { + return "generating"; + } + + if (params.resultUrl) { + if ( + params.persistedResultStatus === "passthrough" || + params.persistedResultStatus === "fallback" + ) { + return params.persistedResultStatus; + } + if (params.taskStatus === "failed") { + return "failed"; + } + return "completed"; + } + + if (params.taskStatus === "failed") { + return "failed"; + } + if (params.taskStatus === "succeeded") { + return "missing"; + } + + if (params.isRequestedInPendingRun) { + return "queued"; + } + + if ( + typeof params.activeRunIndex === "number" && + params.activeRunIndex >= 0 && + params.generatedCount <= params.activeRunIndex + ) { + if (params.isGenerating) { + return params.generatedCount === params.activeRunIndex ? "generating" : "queued"; + } + if (params.generatedCount > 0) { + return "missing"; + } + } + + if (params.isGenerating) { + return params.index === params.generatedCount ? "generating" : "queued"; + } + if (params.generatedCount > 0 && params.index >= params.generatedCount) { + return "missing"; + } + return "waiting"; +} diff --git a/lib/ai/providers/fal-image.ts b/lib/ai/providers/fal-image.ts index 15ed1866c..015268d51 100644 --- a/lib/ai/providers/fal-image.ts +++ b/lib/ai/providers/fal-image.ts @@ -28,6 +28,7 @@ interface FalImageParams { systemPrompt?: string; thinkingLevel?: string; syncMode?: boolean; + requestTimeoutMs?: number; } interface UploadedImage { @@ -915,12 +916,24 @@ export function createFalImage({ apiKey, baseURL }: FalImageConfig) { const fal = await createFalClientFromApiKey(apiKey); + const requestTimeoutMs = Math.max( + 30_000, + Math.min( + DEFAULT_TIMEOUT_MS, + Number(options.imageParams?.requestTimeoutMs || DEFAULT_TIMEOUT_MS) + ) + ); + const abortController = new AbortController(); + const abortTimeoutId = setTimeout(() => { + abortController.abort(); + }, requestTimeoutMs); let result: any; try { result = await fal.subscribe(request.falEndpointId, { input: request.input, logs: true, - timeout: DEFAULT_TIMEOUT_MS, + timeout: requestTimeoutMs, + abortSignal: abortController.signal, onQueueUpdate: (update: any) => { if (update?.status === "IN_PROGRESS" && Array.isArray(update.logs)) { for (const log of update.logs) { @@ -934,6 +947,8 @@ export function createFalImage({ apiKey, baseURL }: FalImageConfig) { } catch (error) { console.error("[Fal Image] Request failed:", summarizeFalError(error)); throw createDetailedFalError(error); + } finally { + clearTimeout(abortTimeoutId); } const text = await normalizeFalResultToMarkdown({ diff --git a/lib/canvas/generate-image.ts b/lib/canvas/generate-image.ts index bd88e82d7..2be113087 100644 --- a/lib/canvas/generate-image.ts +++ b/lib/canvas/generate-image.ts @@ -219,6 +219,8 @@ const SCALE_ADJUST_RECTANGULAR_SEAM_STRONG_TRANSITION_RATIO = 0.26; const SCALE_ADJUST_RECTANGULAR_SEAM_VERY_STRONG_RUN_RATIO = 0.55; const SCALE_ADJUST_RECTANGULAR_SEAM_VERY_STRONG_TRANSITION_RATIO = 0.5; const CANVAS_DEFAULT_PROVIDER_BATCH_TIMEOUT_MS = 10 * 60 * 1000; +const CANVAS_DETAIL_REPLACEMENT_PROVIDER_BATCH_TIMEOUT_MS = 3 * 60 * 1000; +const CANVAS_DETAIL_REPLACEMENT_FAL_REQUEST_TIMEOUT_MS = 170 * 1000; const CANVAS_SCALE_ADJUST_PROVIDER_BATCH_TIMEOUT_MS = 3 * 60 * 1000; const CANVAS_SCALE_ADJUST_FINAL_PROVIDER_BATCH_TIMEOUT_MS = 5 * 60 * 1000; const CANVAS_SCALE_ADJUST_SNAPGEN_REQUEST_TIMEOUT_MS = 90 * 1000; @@ -260,6 +262,17 @@ function isCanvasScaleAdjustDebugRequest( ); } +function isCanvasDetailReplacementDebugRequest( + debugLabel: string, + debugMeta?: Record +) { + const operation = String(debugMeta?.operation || ""); + return ( + debugLabel === "agent-detail-replacement" || + operation === "agent-detail-replacement" + ); +} + function getCanvasProviderBatchTimeoutMs(params: { debugLabel: string; debugMeta?: Record; @@ -271,6 +284,10 @@ function getCanvasProviderBatchTimeoutMs(params: { : CANVAS_SCALE_ADJUST_PROVIDER_BATCH_TIMEOUT_MS; } + if (isCanvasDetailReplacementDebugRequest(params.debugLabel, params.debugMeta)) { + return CANVAS_DETAIL_REPLACEMENT_PROVIDER_BATCH_TIMEOUT_MS; + } + return CANVAS_DEFAULT_PROVIDER_BATCH_TIMEOUT_MS; } @@ -291,6 +308,15 @@ function getCanvasProviderRuntimeParams(params: { }; } + if ( + params.providerType === "fal-image" && + isCanvasDetailReplacementDebugRequest(params.debugLabel, params.debugMeta) + ) { + return { + requestTimeoutMs: CANVAS_DETAIL_REPLACEMENT_FAL_REQUEST_TIMEOUT_MS, + }; + } + return {}; } diff --git a/lib/canvas/image-task-queue.ts b/lib/canvas/image-task-queue.ts index 696ebcb03..2edec4d03 100644 --- a/lib/canvas/image-task-queue.ts +++ b/lib/canvas/image-task-queue.ts @@ -50,6 +50,22 @@ export type CanvasImageTaskQueueStatus = { source: "model_queue" | "batch_queue"; }; +export type CanvasDetailReplacementTaskContextSnapshot = { + id: string; + status: CanvasImageTaskStatus; + modeType: "detail_replacement"; + referenceId: string; + messageId: string | null; + error: string | null; + result: unknown; + attempts: number; + startedAt: Date | null; + completedAt: Date | null; + createdAt: Date; + updatedAt: Date; + queueStatus: CanvasImageTaskQueueStatus | null; +}; + interface PendingTaskEntry { taskId: string; modelKey: string; @@ -75,6 +91,7 @@ interface QueueState { } const DEFAULT_RUNNING_TIMEOUT_MS = 10 * 60 * 1000; +const DETAIL_REPLACEMENT_RUNNING_TIMEOUT_MS = 18 * 60 * 1000; const SCALE_ADJUST_RUNNING_TIMEOUT_MS = 15 * 60 * 1000; const LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS = 30 * 60 * 1000; const STALE_RUNNING_CLEANUP_INTERVAL_MS = 60 * 1000; @@ -133,6 +150,9 @@ function getCanvasImageRunningTimeoutMs(request: unknown) { const debugMeta = getRecord(record?.debugMeta); const debugLabel = String(record?.debugLabel || ""); const operation = String(debugMeta?.operation || ""); + const isDetailReplacement = + debugLabel === "agent-detail-replacement" || + operation === "agent-detail-replacement"; const isScaleAdjust = debugLabel === "canvas-scale-adjust" || operation === "scale-adjust" || @@ -140,8 +160,11 @@ function getCanvasImageRunningTimeoutMs(request: unknown) { Boolean(debugMeta?.scaleAdjustPatchForcedForShrink) || Boolean(record?.scaleAdjustPatchComposite); - return isScaleAdjust - ? SCALE_ADJUST_RUNNING_TIMEOUT_MS + if (isScaleAdjust) { + return SCALE_ADJUST_RUNNING_TIMEOUT_MS; + } + return isDetailReplacement + ? DETAIL_REPLACEMENT_RUNNING_TIMEOUT_MS : DEFAULT_RUNNING_TIMEOUT_MS; } @@ -889,3 +912,86 @@ export async function getCanvasImageTaskForUser(params: { }), }; } + +export async function getCanvasDetailReplacementTasksForUserContext(params: { + userId: string; + chatId: string; + projectId?: string | null; + limit?: number; +}): Promise { + const chatId = String(params.chatId || "").trim(); + const projectId = String(params.projectId || "").trim(); + if (!chatId) { + return []; + } + + const contextConditions = [ + eq(canvasImageTask.userId, params.userId), + sql`coalesce(${canvasImageTask.request}->'debugMeta'->>'chatId', '') = ${chatId}`, + sql`coalesce(${canvasImageTask.request}->'debugMeta'->>'operation', '') = 'agent-detail-replacement'`, + ]; + if (projectId) { + contextConditions.push( + sql`coalesce(${canvasImageTask.request}->'debugMeta'->>'projectId', '') = ${projectId}` + ); + } + + const rows = await db + .select({ + id: canvasImageTask.id, + status: canvasImageTask.status, + request: canvasImageTask.request, + result: canvasImageTask.result, + error: canvasImageTask.error, + attempts: canvasImageTask.attempts, + startedAt: canvasImageTask.startedAt, + completedAt: canvasImageTask.completedAt, + createdAt: canvasImageTask.createdAt, + updatedAt: canvasImageTask.updatedAt, + }) + .from(canvasImageTask) + .where(and(...contextConditions)) + .orderBy(desc(canvasImageTask.createdAt)) + .limit(Math.max(1, Math.min(128, Math.floor(params.limit || 128)))); + + const snapshots: Array = await Promise.all( + rows.map(async (row) => { + const request = getRecord(row.request); + const debugMeta = getRecord(request?.debugMeta); + const referenceId = String(debugMeta?.referenceId || "").trim(); + const messageId = String(debugMeta?.messageId || "").trim() || null; + if (!referenceId) { + return null; + } + + const activeTask = + row.status === "pending" || row.status === "running" + ? await getCanvasImageTaskForUser({ + taskId: row.id, + userId: params.userId, + }) + : null; + const status = (activeTask?.status || row.status) as CanvasImageTaskStatus; + + return { + id: row.id, + status, + modeType: "detail_replacement" as const, + referenceId, + messageId, + error: activeTask?.error || row.error || null, + result: activeTask?.result || row.result || null, + attempts: activeTask?.attempts || row.attempts, + startedAt: activeTask?.startedAt || row.startedAt, + completedAt: activeTask?.completedAt || row.completedAt, + createdAt: activeTask?.createdAt || row.createdAt, + updatedAt: activeTask?.updatedAt || row.updatedAt, + queueStatus: activeTask?.queueStatus || null, + } satisfies CanvasDetailReplacementTaskContextSnapshot; + }) + ); + + return snapshots.filter( + (task): task is CanvasDetailReplacementTaskContextSnapshot => task !== null + ); +} diff --git a/scripts/test-agent-generation-pending-guard.ts b/scripts/test-agent-generation-pending-guard.ts index 6b5409e9c..95cf88106 100644 --- a/scripts/test-agent-generation-pending-guard.ts +++ b/scripts/test-agent-generation-pending-guard.ts @@ -33,6 +33,16 @@ expect( }), "the exact persisted request message must acknowledge the request" ); +expect( + isAgentGenerationRequestAccepted({ + pending, + serverMessages: [ + { id: "request-1", role: "user" }, + { id: "server-replaced-request-id", role: "user" }, + ], + }), + "a newly persisted user turn must acknowledge the request even when its server id changes" +); expect( isAgentGenerationRequestAccepted({ pending: { diff --git a/scripts/test-agent-replacement-task-status.ts b/scripts/test-agent-replacement-task-status.ts new file mode 100644 index 000000000..0bd04e1a8 --- /dev/null +++ b/scripts/test-agent-replacement-task-status.ts @@ -0,0 +1,138 @@ +import { + buildLatestAgentReplacementTaskMap, + getAgentReplacementTaskResultImageUrl, + isAgentReplacementTaskInPendingRun, + resolveAgentReplacementCardStatus, + type AgentReplacementTaskSnapshot, +} from "@/lib/agents/replacement-task-status"; + +function expect(condition: boolean, message: string) { + if (!condition) { + throw new Error(message); + } +} + +function createTask( + overrides: Partial +): AgentReplacementTaskSnapshot { + return { + id: "task-1", + modeType: "detail_replacement", + referenceId: "detail-1", + messageId: "request-1", + status: "pending", + error: null, + result: null, + createdAt: "2026-08-01T08:00:00.000Z", + startedAt: null, + completedAt: null, + queueStatus: null, + ...overrides, + }; +} + +const pendingTask = createTask({ status: "pending" }); +const runningTask = createTask({ status: "running" }); +const failedTask = createTask({ status: "failed", error: "provider timeout" }); + +expect( + resolveAgentReplacementCardStatus({ + resultUrl: null, + taskStatus: pendingTask.status, + isRequestedInPendingRun: true, + isGenerating: true, + index: 0, + generatedCount: 0, + }) === "queued", + "a database pending task must display as queued" +); +expect( + resolveAgentReplacementCardStatus({ + resultUrl: null, + taskStatus: runningTask.status, + isRequestedInPendingRun: true, + isGenerating: true, + index: 0, + generatedCount: 0, + }) === "generating", + "a database running task must display as generating" +); +expect( + resolveAgentReplacementCardStatus({ + resultUrl: null, + taskStatus: failedTask.status, + isRequestedInPendingRun: false, + isGenerating: false, + index: 0, + generatedCount: 0, + }) === "failed", + "a failed database task must display as failed" +); +expect( + resolveAgentReplacementCardStatus({ + resultUrl: "https://cdn.example.com/previous-result.png", + taskStatus: failedTask.status, + isRequestedInPendingRun: false, + isGenerating: false, + index: 0, + generatedCount: 0, + }) === "failed", + "a failed rerun must retain the prior image while displaying the failed status" +); +expect( + resolveAgentReplacementCardStatus({ + resultUrl: null, + taskStatus: null, + isRequestedInPendingRun: true, + isGenerating: true, + index: 0, + generatedCount: 0, + }) === "queued", + "a submitted card must display as queued before its database task is visible" +); + +const newerTask = createTask({ + id: "task-2", + status: "succeeded", + createdAt: "2026-08-01T08:05:00.000Z", + completedAt: "2026-08-01T08:06:00.000Z", + result: { + images: [ + { + originalUrl: "https://cdn.example.com/detail-result.png", + thumbnailUrl: "https://cdn.example.com/detail-result-thumb.png", + }, + ], + }, +}); +const latestTaskMap = buildLatestAgentReplacementTaskMap({ + tasks: [pendingTask, newerTask], + modeType: "detail_replacement", +}); +expect( + latestTaskMap.get("detail-1")?.id === newerTask.id, + "the latest task for each detail card must win" +); +expect( + getAgentReplacementTaskResultImageUrl(newerTask.result) === + "https://cdn.example.com/detail-result.png", + "a succeeded task must expose its original result image" +); +expect( + !isAgentReplacementTaskInPendingRun({ + task: pendingTask, + pendingStartedAt: Date.parse("2026-08-01T08:02:00.000Z"), + requestMessageId: "request-2", + }), + "an old task must not control a newly submitted detail replacement run" +); +expect( + isAgentReplacementTaskInPendingRun({ + task: newerTask, + pendingStartedAt: Date.parse("2026-08-01T08:04:59.000Z"), + requestMessageId: "request-1", + }), + "the task created for the current run must control its detail card" +); + +console.log("agent replacement task status tests passed");