fix(agent): persist detail replacement tasks

This commit is contained in:
134 2026-08-01 13:48:42 +08:00
parent 9093b0370b
commit 87bbcf0ccd
5 changed files with 378 additions and 88 deletions

View File

@ -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) {

View File

@ -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 (
<div className="absolute inset-0 z-30 overflow-hidden rounded-[inherit] bg-[#07090f]">
<style>{`
@ -2718,6 +2701,11 @@ function DetailResultProcessingScanOverlay({
70% { box-shadow: 0 0 0 8px rgba(125, 211, 252, 0); }
100% { box-shadow: 0 0 0 0 rgba(125, 211, 252, 0); }
}
@keyframes detailResultIndeterminate {
0% { transform: translateX(-100%); }
55% { transform: translateX(80%); }
100% { transform: translateX(260%); }
}
`}</style>
{isHttpImageUrl(imageUrl) ? (
<img
@ -2790,24 +2778,13 @@ function DetailResultProcessingScanOverlay({
style={{ animation: "detailResultScanPulseRing 1.8s ease-out infinite" }}
/>
<span>{label}</span>
<span className="min-w-[34px] text-right tabular-nums text-slate-300">{progress}%</span>
</div>
<div
className="absolute bottom-3 left-3 z-[70] isolate inline-flex items-center gap-2 rounded-full border border-white/14 bg-[#080b12]/95 px-2.5 py-1.5 text-[11px] font-semibold leading-none text-slate-100 shadow-[0_12px_28px_rgba(0,0,0,0.32)]"
style={{
WebkitFontSmoothing: "antialiased",
textShadow: "0 1px 1px rgba(0,0,0,0.72)",
}}
>
<span className="tabular-nums text-sky-300">{String(stageIndex + 1).padStart(2, "0")}</span>
<span>{stageName}</span>
</div>
<div className="absolute inset-x-0 bottom-0 z-[60] h-0.5 bg-white/5">
<div
className="h-full rounded-r-full shadow-[0_0_8px_rgba(125,211,252,0.6)] transition-[width] duration-300 ease-linear"
className="h-full w-1/3 rounded-full shadow-[0_0_8px_rgba(125,211,252,0.6)]"
style={{
animation: "detailResultIndeterminate 1.8s ease-in-out infinite",
background: "linear-gradient(90deg, #7dd3fc, #a78bfa)",
width: `${progress}%`,
}}
/>
</div>
@ -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,

View File

@ -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));
}

View File

@ -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<CanvasGenerateImageResult>;
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<void>;
}): Promise<CanvasGenerateImageResult> {
const {
onQueueStatusChange,
ownerKey: _requestOwnerKey,
...serializableRequest
} = params.request;
const task = await createCanvasImageTask({
userId: params.userId,
request: serializableRequest as unknown as Record<string, unknown>,
});
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;

View File

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