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");