From 87bbcf0ccd1e3d8cb942685552dcce74fcd63b56 Mon Sep 17 00:00:00 2001 From: 134 Date: Sat, 1 Aug 2026 13:48:42 +0800 Subject: [PATCH] fix(agent): persist detail replacement tasks --- app/(chat)/api/chat/route.ts | 108 ++++++++------ .../agents/AgentGenerationWorkspaceDarkV2.tsx | 138 ++++++++++++------ lib/agents/pending-generation-guard.ts | 48 ++++++ lib/canvas/image-task-queue.ts | 106 ++++++++++++++ .../test-agent-generation-pending-guard.ts | 66 +++++++++ 5 files changed, 378 insertions(+), 88 deletions(-) create mode 100644 lib/agents/pending-generation-guard.ts create mode 100644 scripts/test-agent-generation-pending-guard.ts diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index dd80c823e..14b0ecb61 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -138,7 +138,11 @@ import { generatedImage, message as messageTable, } from "@/lib/db/schema"; -import { generateCanvasImage } from "@/lib/canvas/generate-image"; +import { + generateCanvasImage, + type CanvasGenerateImageRequest, +} from "@/lib/canvas/generate-image"; +import { generateCanvasImageWithPersistentQueue } from "@/lib/canvas/image-task-queue"; import { detectRequestedStoryboardGrid, isGridConfirmationReply, @@ -5992,44 +5996,55 @@ export async function POST(request: Request) { let normalizeElapsedMs: number | null = null; try { generationStartedAt = Date.now(); - const result = await generateCanvasImage({ - prompt: replacementPrompt, - negativePrompt: effectiveImageParams?.negativePrompt, - model: resolvedModelRef, - ownerKey: session.user.id, - aspectRatio: generationAspectRatio, - imageSize: resolvedImageSize, - imageCount: 1, - referenceImages: sourceImageUrls, - debugLabel: "agent-detail-replacement", - debugMeta: { - referenceLabel: reference.label, - referenceWidth: referenceGeometry?.width, - referenceHeight: referenceGeometry?.height, - referenceAspectRatio: referenceGeometry?.aspectRatio, + const result = await generateCanvasImageWithPersistentQueue({ + userId: session.user.id, + request: { + prompt: replacementPrompt, + negativePrompt: effectiveImageParams?.negativePrompt, + model: resolvedModelRef, + ownerKey: session.user.id, + aspectRatio: generationAspectRatio, + imageSize: resolvedImageSize, + imageCount: 1, + referenceImages: sourceImageUrls, + debugLabel: "agent-detail-replacement", + debugMeta: { + operation: "agent-detail-replacement", + chatId: id, + messageId: message.id, + projectId: contextPayload?.projectId || null, + referenceId: reference.id, + referenceLabel: reference.label, + referenceWidth: referenceGeometry?.width, + referenceHeight: referenceGeometry?.height, + referenceAspectRatio: referenceGeometry?.aspectRatio, + }, + onQueueStatusChange: + contextPayload?.source === "canvas" + ? async (queueStatus) => { + writeImageGenerationStatusChunk( + writer, + { + phase: + queueStatus.status === "running" + ? "processing" + : "queued", + modelName: + queueStatus.modelName || + resolvedModelDisplayName || + resolvedModelRef, + imageCount: 1, + queuePosition: queueStatus.queuePosition, + sameModelRunningCount: + queueStatus.sameModelRunningCount, + concurrencyLimit: queueStatus.concurrencyLimit, + estimatedWaitMs: queueStatus.estimatedWaitMs, + }, + `agent-mode:detail-replacement:queue:${reference.id}`, + ); + } + : undefined, }, - onQueueStatusChange: - contextPayload?.source === "canvas" - ? async (queueStatus) => { - writeImageGenerationStatusChunk( - writer, - { - phase: - queueStatus.status === "running" - ? "processing" - : "queued", - modelName: - queueStatus.modelName || resolvedModelDisplayName || resolvedModelRef, - imageCount: 1, - queuePosition: queueStatus.queuePosition, - sameModelRunningCount: queueStatus.sameModelRunningCount, - concurrencyLimit: queueStatus.concurrencyLimit, - estimatedWaitMs: queueStatus.estimatedWaitMs, - }, - `agent-mode:detail-replacement:queue:${reference.id}`, - ); - } - : undefined, }); generationElapsedMs = Date.now() - generationStartedAt; resolvedModelDisplayName = result.model || resolvedModelDisplayName; @@ -6879,7 +6894,7 @@ export async function POST(request: Request) { }); try { - const result = await generateCanvasImage({ + const generationRequest: CanvasGenerateImageRequest = { prompt: replacementPrompt, negativePrompt: typeof mergedImageParams.negativePrompt === "string" @@ -6893,6 +6908,11 @@ export async function POST(request: Request) { referenceImages: sourceImageUrls, debugLabel: `agent-${replacementSourceType}`, debugMeta: { + operation: `agent-${replacementSourceType}`, + chatId: id, + messageId: message.id, + projectId: contextPayload?.projectId || null, + referenceId: reference.id, referenceLabel: reference.label, referenceWidth: referenceGeometry?.width, referenceHeight: referenceGeometry?.height, @@ -6921,8 +6941,14 @@ export async function POST(request: Request) { `agent-preset:replacement:queue:${reference.id}`, ); } - : undefined, - }); + : undefined, + }; + const result = isDetailReplacementPreset + ? await generateCanvasImageWithPersistentQueue({ + userId: session.user.id, + request: generationRequest, + }) + : await generateCanvasImage(generationRequest); resolvedModelDisplayName = result.model || resolvedModelDisplayName; const firstImage = result.images[0]; if (!firstImage) { diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index a9cfc0d58..9e54def76 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -109,6 +109,11 @@ import { import { normalizeAgentPresetModeConfig, } from "@/lib/agents/preset-mode-config"; +import { + AGENT_PENDING_SERVER_ACCEPTANCE_TIMEOUT_MS, + getAgentGenerationAcceptanceRemainingMs, + isAgentGenerationRequestAccepted, +} from "@/lib/agents/pending-generation-guard"; import { defaultAgentWorkflowBusinessModeSettings, normalizeAgentWorkflowBusinessModeSettings, @@ -2574,6 +2579,8 @@ interface LocalGenerationPendingState { modeType: AgentGenerationResultModeType; expectedCount: number; userTurnCountAtStart: number; + serverUserTurnCountAtStart?: number; + requestMessageId?: string | null; timestamp: number; requestedCardIds?: string[] | null; } @@ -2628,6 +2635,15 @@ function getStoredAgentPendingGenerations(): LocalGenerationPendingState[] { modeType: String(record.modeType || "").trim() as AgentGenerationResultModeType, expectedCount: Number(record.expectedCount), userTurnCountAtStart: Number(record.userTurnCountAtStart), + serverUserTurnCountAtStart: Number.isFinite( + Number(record.serverUserTurnCountAtStart) + ) + ? Number(record.serverUserTurnCountAtStart) + : undefined, + requestMessageId: + typeof record.requestMessageId === "string" + ? record.requestMessageId.trim() || null + : null, timestamp: Number(record.timestamp), requestedCardIds: Array.isArray(record.requestedCardIds) ? record.requestedCardIds @@ -2660,16 +2676,6 @@ function saveStoredAgentPendingGenerations(items: LocalGenerationPendingState[]) } } -const DETAIL_RESULT_PROCESSING_STAGES = [ - "解码图像", - "定位选区", - "分离主体", - "重绘背景", - "融合边缘", - "检查细节", - "输出结果", -]; - function DetailResultProcessingScanOverlay({ imageUrl, label, @@ -2677,29 +2683,6 @@ function DetailResultProcessingScanOverlay({ imageUrl?: string; label: string; }) { - const [progress, setProgress] = useState(6); - - useEffect(() => { - const startedAt = Date.now(); - const durationMs = 240_000; - const timer = window.setInterval(() => { - const elapsed = Date.now() - startedAt; - const linear = Math.min(1, elapsed / durationMs); - const eased = 1 - Math.pow(1 - linear, 1.8); - const drift = Math.sin(elapsed / 3200) * 1.2; - const next = Math.max(6, Math.min(96, Math.round(eased * 96 + drift))); - setProgress((current) => Math.max(current, next)); - }, 720); - - return () => window.clearInterval(timer); - }, [imageUrl, label]); - - const stageIndex = Math.min( - DETAIL_RESULT_PROCESSING_STAGES.length - 1, - Math.floor((progress / 100) * DETAIL_RESULT_PROCESSING_STAGES.length) - ); - const stageName = DETAIL_RESULT_PROCESSING_STAGES[stageIndex] || "处理中"; - return (
{isHttpImageUrl(imageUrl) ? ( {label} - {progress}% -
-
- {String(stageIndex + 1).padStart(2, "0")} - {stageName}
@@ -3419,6 +3396,10 @@ export function AgentGenerationWorkspaceDarkV2({ ); const historyMessages = useMemo(() => historyData?.messages || [], [historyData?.messages]); + const serverUserMessageCount = useMemo( + () => historyMessages.filter((message) => message.role === "user").length, + [historyMessages] + ); const referenceImages = workflowContext?.referenceImages || []; const resolvedAgentModeState = agentModeState || localAgentModeState; const emitAgentModeState = onAgentModeStateChange || setLocalAgentModeState; @@ -5422,6 +5403,63 @@ export function AgentGenerationWorkspaceDarkV2({ clearError(); }, [chatError, clearError, pendingGeneration]); + useEffect(() => { + if ( + !pendingGeneration || + (pendingGeneration.modeType !== "detail_replacement" && + pendingGeneration.modeType !== "sku_replacement") + ) { + return; + } + + const requestAccepted = isAgentGenerationRequestAccepted({ + pending: pendingGeneration, + serverMessages: historyMessages, + }); + if (requestAccepted) { + return; + } + + const remainingMs = getAgentGenerationAcceptanceRemainingMs({ + startedAt: pendingGeneration.timestamp, + timeoutMs: AGENT_PENDING_SERVER_ACCEPTANCE_TIMEOUT_MS, + }); + const clearUnacceptedGeneration = () => { + generationRequestSnapshotRef.current = { + hiddenPrompt: null, + replacementExecutionCardIds: null, + replacementRerunReferenceImages: null, + }; + try { + stop(); + } catch (error) { + console.warn( + "[AgentGenerationWorkspaceDarkV2] Failed to stop unaccepted generation request:", + error + ); + } + setPendingGeneration((current) => { + if ( + !current || + current.chatId !== pendingGeneration.chatId || + current.timestamp !== pendingGeneration.timestamp + ) { + return current; + } + return null; + }); + toast.error("生成请求未被服务器接收,已结束处理中状态,请重新提交。"); + }; + + if (remainingMs <= 0) { + clearUnacceptedGeneration(); + return; + } + + const timerId = window.setTimeout(clearUnacceptedGeneration, remainingMs); + return () => window.clearTimeout(timerId); + }, [historyMessages, pendingGeneration, serverUserMessageCount, stop]); + useEffect(() => { if (!pendingGeneration) { return; @@ -6353,6 +6391,7 @@ export function AgentGenerationWorkspaceDarkV2({ ); setActiveResultModeType(currentResultModeType); setSelectedResultUrl(null); + const requestMessageId = generateUUID(); setPendingGeneration({ chatId, modeType: currentResultModeType, @@ -6365,6 +6404,8 @@ export function AgentGenerationWorkspaceDarkV2({ ? detailReplacementReferenceCards.length : skuReplacementReferenceCards.length, userTurnCountAtStart: userMessageCount, + serverUserTurnCountAtStart: serverUserMessageCount, + requestMessageId, timestamp: Date.now(), requestedCardIds: replacementModeType && replacementExecutionCards.length > 0 @@ -6375,7 +6416,9 @@ export function AgentGenerationWorkspaceDarkV2({ toast.success(`继续处理剩余 ${replacementExecutionCards.length} 张结果图`); } try { - void Promise.resolve(sendMessage({ role: "user", parts })).catch((error) => { + void Promise.resolve( + sendMessage({ id: requestMessageId, role: "user", parts }) + ).catch((error) => { generationRequestSnapshotRef.current = { hiddenPrompt: null, replacementExecutionCardIds: null, @@ -6421,6 +6464,7 @@ export function AgentGenerationWorkspaceDarkV2({ selectedAgentPresetId, selectedModelId, sendMessage, + serverUserMessageCount, setPendingGeneration, skuReplacementReferenceCards.length, skuResultCards, diff --git a/lib/agents/pending-generation-guard.ts b/lib/agents/pending-generation-guard.ts new file mode 100644 index 000000000..0900fb058 --- /dev/null +++ b/lib/agents/pending-generation-guard.ts @@ -0,0 +1,48 @@ +export const AGENT_PENDING_SERVER_ACCEPTANCE_TIMEOUT_MS = 60 * 1000; + +interface PendingGenerationAcceptanceState { + requestMessageId?: string | null; + serverUserTurnCountAtStart?: number; + userTurnCountAtStart: number; +} + +interface ServerMessageIdentity { + id: string; + role: string; +} + +export function isAgentGenerationRequestAccepted(params: { + pending: PendingGenerationAcceptanceState; + serverMessages: ServerMessageIdentity[]; +}) { + const requestMessageId = String( + params.pending.requestMessageId || "" + ).trim(); + if (requestMessageId) { + return params.serverMessages.some( + (message) => + message.role === "user" && message.id === requestMessageId + ); + } + + const serverUserMessageCount = params.serverMessages.filter( + (message) => message.role === "user" + ).length; + const baseline = + params.pending.serverUserTurnCountAtStart ?? + params.pending.userTurnCountAtStart; + return serverUserMessageCount > baseline; +} + +export function getAgentGenerationAcceptanceRemainingMs(params: { + startedAt: number; + now?: number; + timeoutMs?: number; +}) { + const now = params.now ?? Date.now(); + const timeoutMs = Math.max( + 0, + params.timeoutMs ?? AGENT_PENDING_SERVER_ACCEPTANCE_TIMEOUT_MS + ); + return Math.max(0, timeoutMs - Math.max(0, now - params.startedAt)); +} diff --git a/lib/canvas/image-task-queue.ts b/lib/canvas/image-task-queue.ts index 185583fbb..696ebcb03 100644 --- a/lib/canvas/image-task-queue.ts +++ b/lib/canvas/image-task-queue.ts @@ -33,6 +33,13 @@ type CanvasImageTaskRequest = | CanvasLayoutReplaceTaskRequest; type CanvasImageTaskResult = CanvasGenerateImageResult | StyleProTaskResult; +export type CanvasGenerateImageTaskUpdate = { + taskId: string; + status: CanvasImageTaskStatus; + queueStatus: CanvasImageTaskQueueStatus; + error?: string | null; +}; + export type CanvasImageTaskQueueStatus = { status: CanvasImageTaskStatus; queuePosition: number; @@ -578,6 +585,105 @@ export async function createCanvasImageTask(params: { return task; } +function isCanvasGenerateImageResult( + value: unknown +): value is CanvasGenerateImageResult { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const result = value as Partial; + return ( + Array.isArray(result.images) && + result.images.length > 0 && + result.images.every( + (image) => + Boolean(image) && + typeof image.originalUrl === "string" && + typeof image.thumbnailUrl === "string" + ) + ); +} + +export async function generateCanvasImageWithPersistentQueue(params: { + userId: string; + request: CanvasGenerateImageRequest; + timeoutMs?: number; + onTaskUpdate?: ( + update: CanvasGenerateImageTaskUpdate + ) => void | Promise; +}): Promise { + const { + onQueueStatusChange, + ownerKey: _requestOwnerKey, + ...serializableRequest + } = params.request; + const task = await createCanvasImageTask({ + userId: params.userId, + request: serializableRequest as unknown as Record, + }); + await enqueueCanvasImageTask(task.id); + + const timeoutMs = Math.max(60_000, params.timeoutMs ?? 30 * 60 * 1000); + const startedAt = Date.now(); + let lastStatusSignature = ""; + + while (Date.now() - startedAt < timeoutMs) { + const currentTask = await getCanvasImageTaskForUser({ + taskId: task.id, + userId: params.userId, + }); + if (!currentTask) { + throw new Error(`图片生成任务不存在(${task.id})`); + } + + const status = currentTask.status as CanvasImageTaskStatus; + const queueStatus = currentTask.queueStatus; + const statusSignature = [ + status, + queueStatus.queuePosition, + queueStatus.sameModelPendingCount, + queueStatus.sameModelRunningCount, + queueStatus.concurrencyLimit, + ].join(":"); + if (statusSignature !== lastStatusSignature) { + lastStatusSignature = statusSignature; + await params.onTaskUpdate?.({ + taskId: task.id, + status, + queueStatus, + error: currentTask.error, + }); + if (status === "pending" || status === "running") { + await onQueueStatusChange?.({ + ...queueStatus, + modelName: serializableRequest.model || null, + modelRef: serializableRequest.model || null, + }); + } + } + + if (status === "succeeded") { + if (!isCanvasGenerateImageResult(currentTask.result)) { + throw new Error(`图片生成任务已完成但结果无效(${task.id})`); + } + return currentTask.result; + } + if (status === "failed") { + throw new Error(currentTask.error || "图片生成任务失败"); + } + + const estimatedWaitMs = Number(queueStatus.estimatedWaitMs || 0); + const pollAfterMs = + status === "pending" && estimatedWaitMs > 0 + ? Math.min(4000, Math.max(1000, estimatedWaitMs)) + : 1000; + await new Promise((resolve) => setTimeout(resolve, pollAfterMs)); + } + + throw new Error(`图片生成任务等待超时(${task.id})`); +} + async function estimateModelQueueWaitMs(params: { modelKey: string; sameModelRunningCount: number; diff --git a/scripts/test-agent-generation-pending-guard.ts b/scripts/test-agent-generation-pending-guard.ts new file mode 100644 index 000000000..6b5409e9c --- /dev/null +++ b/scripts/test-agent-generation-pending-guard.ts @@ -0,0 +1,66 @@ +import { + AGENT_PENDING_SERVER_ACCEPTANCE_TIMEOUT_MS, + getAgentGenerationAcceptanceRemainingMs, + isAgentGenerationRequestAccepted, +} from "@/lib/agents/pending-generation-guard"; + +function expect(condition: boolean, message: string) { + if (!condition) { + throw new Error(message); + } +} + +const pending = { + requestMessageId: "request-2", + serverUserTurnCountAtStart: 1, + userTurnCountAtStart: 1, +}; + +expect( + !isAgentGenerationRequestAccepted({ + pending, + serverMessages: [{ id: "request-1", role: "user" }], + }), + "an unrelated persisted user message must not acknowledge the request" +); +expect( + isAgentGenerationRequestAccepted({ + pending, + serverMessages: [ + { id: "request-1", role: "user" }, + { id: "request-2", role: "user" }, + ], + }), + "the exact persisted request message must acknowledge the request" +); +expect( + isAgentGenerationRequestAccepted({ + pending: { + requestMessageId: null, + serverUserTurnCountAtStart: 1, + userTurnCountAtStart: 1, + }, + serverMessages: [ + { id: "request-1", role: "user" }, + { id: "legacy-request", role: "user" }, + ], + }), + "legacy pending state must recover by persisted user message count" +); +expect( + getAgentGenerationAcceptanceRemainingMs({ + startedAt: 1_000, + now: 31_000, + }) === + AGENT_PENDING_SERVER_ACCEPTANCE_TIMEOUT_MS - 30_000, + "acceptance timeout must retain the expected remaining duration" +); +expect( + getAgentGenerationAcceptanceRemainingMs({ + startedAt: 1_000, + now: 91_000, + }) === 0, + "expired acceptance timeout must clamp to zero" +); + +console.log("agent generation pending guard tests passed");