From 0907aaff67ce245dfa4fc89a79030e99b4c6d94a Mon Sep 17 00:00:00 2001 From: 134 Date: Sat, 26 Sep 2026 02:39:11 +0800 Subject: [PATCH] fix(agent): make detail replacement results survive page and server restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 详情替换原来把整批生图挂在浏览器保持的对话流上,切换标签页、断网或连接中断后 结果就再也回不来,前端还会继续播放按时间推算的 96% 假进度。 现在改为服务器端后台批次:提交时先落任务,再按参考图逐张生成并把每张结果写进 项目,页面刷新、切页或服务重启后都能按任务状态恢复,不再依赖前端连接。 - 新增 /api/agents/detail-replacement/batches(提交/查询、幂等去重、重试上限) - 前端只在服务端确认批次后显示处理中,卡片显示排队/生成/完成/失败真实状态 - 去掉假进度动画;没有产品输入图时提交前直接拦截 - 服务重启后自动恢复未完成批次,长批次不再被通用 30 分钟清理误判 - 单张失败只落到该卡片并继续后续图片,已完成的图片不重复生成和扣费 - 详情替换默认模型由持续 404 的 gpt-image-2.5-flare 改回 gpt-image-2.5 --- app/(chat)/api/chat/route.ts | 40 +-- .../detail-replacement/batches/route.ts | 294 +++++++++++++++++ .../agents/AgentGenerationWorkspaceDarkV2.tsx | 247 +++++++++++---- instrumentation.ts | 13 + .../agent-project-result-persistence.ts | 82 ++++- lib/agents/detail-replacement-batch-runner.ts | 295 ++++++++++++++++++ lib/agents/detail-replacement-batch.ts | 105 +++++++ .../detail-replacement-image-model-config.ts | 2 +- lib/ai/detail-replacement-inputs.ts | 36 +++ lib/canvas/image-task-queue.ts | 121 ++++++- .../0101_fix_detail_replacement_model.sql | 5 + ...0102_dedupe_detail_replacement_batches.sql | 9 + lib/db/migrations/meta/_journal.json | 14 + playwright.helpers.config.ts | 1 + tests/routes/detail-replacement-batch.test.ts | 71 +++++ .../replacement-product-identity.test.ts | 17 +- 16 files changed, 1235 insertions(+), 117 deletions(-) create mode 100644 app/api/agents/detail-replacement/batches/route.ts create mode 100644 lib/agents/detail-replacement-batch-runner.ts create mode 100644 lib/agents/detail-replacement-batch.ts create mode 100644 lib/db/migrations/0101_fix_detail_replacement_model.sql create mode 100644 lib/db/migrations/0102_dedupe_detail_replacement_batches.sql create mode 100644 tests/routes/detail-replacement-batch.test.ts diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index f6ca7d59e..2764bd373 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -364,7 +364,10 @@ import { resolveReplacementReferenceGeometry, resolveReplacementScenePlanningRuntime, } from "./_lib/detail-replacement"; -import { selectDetailReplacementGenerationInputs } from "@/lib/ai/detail-replacement-inputs"; +import { + selectDetailReplacementGenerationInputs, + shouldIncludeDetailReplacementModelImage, +} from "@/lib/ai/detail-replacement-inputs"; import { normalizeAgentReportForDisplay, sanitizeAgentOutputSettingsSection, @@ -419,8 +422,6 @@ const chatApiLogger = createLogger("chat.route"); const INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN = /生成图片失败|图片生成失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片/i; -const DETAIL_REPLACEMENT_MODEL_TEXT_HINT_PATTERN = - /自拍|合照|聚会|聚餐|围坐|用餐|试坐|承重|受力|拿手机|手持|人物场景|人物关系|真人演示|人物展示|人像展示|固定模特|用户模特|same\s+model|fixed\s+model|person|people|human/i; function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: string) { const normalizedMarkdown = String(markdown || "").trim(); @@ -443,35 +444,6 @@ function getPublicImageGenerationErrorText(error: unknown) { return message; } -function replacementScenePlanShouldIncludeModelImage(scenePlan: any) { - if (!scenePlan) return false; - if (scenePlan.useModelImage === true) return true; - if ( - scenePlan.peoplePolicy && - scenePlan.peoplePolicy !== "none" - ) { - return true; - } - if (scenePlan.generationRoute === "people_relation_locked") return true; - if (scenePlan.pageKind === "people_scene" || scenePlan.pageKind === "action_poster") { - return true; - } - - const text = [ - scenePlan.modelImageReason, - scenePlan.routeReason, - scenePlan.sceneSummary, - scenePlan.cameraSummary, - scenePlan.placementSummary, - scenePlan.preserveSummary, - scenePlan.styleSummary, - scenePlan.avoidSummary, - scenePlan.generationPrompt, - ] - .filter(Boolean) - .join(" "); - return DETAIL_REPLACEMENT_MODEL_TEXT_HINT_PATTERN.test(text); -} const AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT = Math.max( 1, @@ -5940,7 +5912,7 @@ export async function POST(request: Request) { const shouldIncludeModelImage = detailReplacementStrategy !== "product_only" && Boolean(modelInputImageUrl) && - (replacementScenePlanShouldIncludeModelImage( + (shouldIncludeDetailReplacementModelImage( replacementExecutionPlan.scenePlan, ) || selectedProductContextImageUrls.length === 0); @@ -6833,7 +6805,7 @@ export async function POST(request: Request) { !(isDetailReplacementPreset && detailReplacementStrategy === "product_only") && Boolean(modelInputImageUrl) && - (replacementScenePlanShouldIncludeModelImage( + (shouldIncludeDetailReplacementModelImage( replacementExecutionPlan.scenePlan, ) || (isDetailReplacementPreset && diff --git a/app/api/agents/detail-replacement/batches/route.ts b/app/api/agents/detail-replacement/batches/route.ts new file mode 100644 index 000000000..1e562c463 --- /dev/null +++ b/app/api/agents/detail-replacement/batches/route.ts @@ -0,0 +1,294 @@ +import { and, desc, eq, inArray, sql } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { db } from "@/lib/db"; +import { canvasImageTask, canvasProject, message } from "@/lib/db/schema"; +import { extractAgentProjectStateFromCanvasData, isAgentProjectRow } from "@/lib/agents/agent-project-compat"; +import { + createCanvasImageTask, + enqueueCanvasImageTask, + ensureCanvasImageTaskActive, + getCanvasImageTaskForUser, +} from "@/lib/canvas/image-task-queue"; +import { + checkUserImageQuota, + getChatById, + getEnabledAiModelsWithProvider, + getPointsSystemConfig, + getUserById, + saveChat, +} from "@/lib/db/queries"; +import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing"; +import { resolveDirectReplacementModelRef } from "@/app/(chat)/api/chat/_lib/detail-replacement"; +import { + buildAgentSlotPrompt, + normalizeAgentSupplementPrompt, +} from "@/app/(chat)/api/chat/_lib/agent-scene-workflow"; +import { + detailReplacementSubmissionKey, + isDetailReplacementBatchRequest, + type DetailReplacementBatchReference, + type DetailReplacementBatchRequest, + type DetailReplacementBatchResult, +} from "@/lib/agents/detail-replacement-batch"; + +export const maxDuration = 1200; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function scopedChatId(projectId: string) { + const normalized = projectId.toLowerCase().replace(/-/g, ""); + const scoped = `${normalized.slice(0, 20)}a93e17a60001`; + return [scoped.slice(0, 8), scoped.slice(8, 12), scoped.slice(12, 16), scoped.slice(16, 20), scoped.slice(20, 32)].join("-"); +} + +function taskResponse(task: Awaited>) { + const result = task?.result as DetailReplacementBatchResult | null; + const request = task?.request as DetailReplacementBatchRequest | null; + return { + id: task?.id, + status: task?.status, + attempts: task?.attempts, + error: task?.error || null, + cards: Array.isArray(result?.cards) ? result.cards : [], + currentCardId: result?.currentCardId || null, + requestedCardIds: request?.references?.map((item) => item.id) || [], + createdAt: task?.createdAt || null, + queueStatus: task?.queueStatus || null, + }; +} + +async function findActiveBatchId(params: { + userId: string; + projectId: string; + submissionKey: string; +}) { + const [row] = await db.select({ id: canvasImageTask.id }) + .from(canvasImageTask) + .where(and( + eq(canvasImageTask.userId, params.userId), + inArray(canvasImageTask.status, ["pending", "running"]), + sql`${canvasImageTask.request}->>'taskKind' = 'agent-detail-replacement-batch'`, + sql`${canvasImageTask.request}->>'projectId' = ${params.projectId}`, + sql`${canvasImageTask.request}->>'submissionKey' = ${params.submissionKey}`, + )) + .orderBy(desc(canvasImageTask.createdAt)).limit(1); + return row?.id || null; +} + +function isUniqueViolation(error: unknown) { + for (let cursor: unknown = error; cursor && typeof cursor === "object"; ) { + if ((cursor as { code?: unknown }).code === "23505") return true; + cursor = (cursor as { cause?: unknown }).cause; + } + return false; +} + +export async function GET(request: Request) { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 }); + const params = new URL(request.url).searchParams; + const projectId = String(params.get("projectId") || "").trim(); + const taskId = String(params.get("taskId") || "").trim(); + if (!UUID_RE.test(projectId)) return NextResponse.json({ error: "项目编号无效" }, { status: 400 }); + const [project] = await db.select({ id: canvasProject.id }).from(canvasProject).where(and( + eq(canvasProject.id, projectId), eq(canvasProject.userId, session.user.id), + )).limit(1); + if (!project) return NextResponse.json({ error: "项目不存在" }, { status: 404 }); + + const [candidate] = await db.select({ id: canvasImageTask.id, request: canvasImageTask.request }) + .from(canvasImageTask) + .where(and( + eq(canvasImageTask.userId, session.user.id), + ...(taskId && UUID_RE.test(taskId) ? [eq(canvasImageTask.id, taskId)] : []), + sql`${canvasImageTask.request}->>'taskKind' = 'agent-detail-replacement-batch'`, + sql`${canvasImageTask.request}->>'projectId' = ${projectId}`, + )) + // A batch that is still working matters more than a newer finished one, so the + // workspace keeps showing live progress after a rerun of a single card. + .orderBy( + sql`case when ${canvasImageTask.status} in ('pending', 'running') then 0 else 1 end`, + desc(canvasImageTask.createdAt), + ).limit(1); + if (!candidate || !isDetailReplacementBatchRequest(candidate.request)) { + return NextResponse.json({ task: null }); + } + let task = await getCanvasImageTaskForUser({ taskId: candidate.id, userId: session.user.id }); + if (task && (task.status === "failed" || + (task.status === "running" && Date.now() - task.updatedAt.getTime() > 90_000))) { + await ensureCanvasImageTaskActive({ taskId: task.id, userId: session.user.id }); + task = await getCanvasImageTaskForUser({ taskId: candidate.id, userId: session.user.id }); + } + return NextResponse.json({ task: taskResponse(task) }); +} + +export async function POST(request: Request) { + try { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 }); + const body = await request.json(); + const projectId = String(body.projectId || "").trim(); + const taskId = String(body.requestId || "").trim(); + if (!UUID_RE.test(projectId) || !UUID_RE.test(taskId)) { + return NextResponse.json({ error: "任务编号或项目编号无效" }, { status: 400 }); + } + const [project] = await db.select().from(canvasProject).where(and( + eq(canvasProject.id, projectId), eq(canvasProject.userId, session.user.id), + )).limit(1); + if (!project || !isAgentProjectRow(project)) { + return NextResponse.json({ error: "风格化项目不存在" }, { status: 404 }); + } + + const state = extractAgentProjectStateFromCanvasData(project.canvasData); + const knownReferences = new Map( + (state.workflowState.agentModeState?.detailReplacement?.references || []) + .map((item) => [String(item.id), String(item.imageUrl)]), + ); + const requested = Array.isArray(body.references) ? body.references : []; + const references: DetailReplacementBatchReference[] = requested.map((raw: unknown) => { + const item = raw && typeof raw === "object" ? raw as Record : {}; + return { + id: String(item.id || "").slice(0, 128), + label: String(item.label || "详情参考图").slice(0, 255), + imageUrl: String(item.imageUrl || "").trim(), + candidateId: String(item.candidateId || "").slice(0, 128) || null, + passthrough: item.passthrough === true, + rerunReferenceImages: Array.isArray(item.rerunReferenceImages) + ? item.rerunReferenceImages.slice(0, 6).map((entry: { url?: string }) => ({ url: String(entry?.url || "") })) + : [], + }; + }); + if ( + references.length === 0 || references.length > 40 || + new Set(references.map((item) => item.id)).size !== references.length || + references.some((item) => !item.id || knownReferences.get(item.id) !== item.imageUrl) + ) { + return NextResponse.json({ error: "详情参考图尚未保存或已发生变化,请刷新后重试" }, { status: 400 }); + } + const productImageUrls = Array.isArray(body.productImageUrls) + ? Array.from(new Set(body.productImageUrls.map((url: unknown) => String(url || "").trim()) + .filter((url: string) => /^https?:\/\//i.test(url)))).slice(0, 16) as string[] + : []; + if (productImageUrls.length === 0 && !references.every((item) => item.passthrough)) { + return NextResponse.json({ error: "请先在输入图区选择至少一张产品图" }, { status: 400 }); + } + const strategy = body.replacementStrategy === "product_only" ? "product_only" : "scene_replace"; + const prompt = String(body.prompt || "").trim().slice(0, 24_000); + if (strategy !== "product_only" && !prompt) { + return NextResponse.json({ error: "请先配置风格化提示词" }, { status: 400 }); + } + const models = await getEnabledAiModelsWithProvider(); + const model = await resolveDirectReplacementModelRef({ + selectedChatModel: String(body.model || "auto"), + finalModelId: String(body.model || "auto"), + models, + }); + if (!model || model === "auto" || !models.some((entry) => entry.model.id === model)) { + return NextResponse.json({ error: "详情替换模型不可用,请检查模型配置" }, { status: 400 }); + } + const quota = await checkUserImageQuota(session.user.id); + if (!quota.allowed) { + return NextResponse.json({ error: "本月图片生成配额已用完" }, { status: 429 }); + } + const requestedImageCount = references.filter((item) => !item.passthrough).length; + if (quota.limit != null && quota.used + requestedImageCount > quota.limit) { + return NextResponse.json({ error: `本月剩余生图配额不足 ${requestedImageCount} 张` }, { status: 429 }); + } + const pointsConfig = await getPointsSystemConfig(); + if (pointsConfig.enabled && pointsConfig.blockOnInsufficient !== false) { + const selectedModel = models.find((entry) => entry.model.id === model)?.model; + const unitCost = getModelGenerationCreditCost( + selectedModel as Parameters[0], "image", { + imageSize: ["1K", "2K", "4K"].includes(body.imageSize) ? body.imageSize : "2K", + }); + const user = await getUserById({ id: session.user.id }); + const expectedCost = unitCost * requestedImageCount; + if (!user?.isHighVip && expectedCost > Number(user?.pointsBalance || 0)) { + return NextResponse.json({ error: `积分不足,本批次预计消耗 ${expectedCost} 积分` }, { status: 402 }); + } + } + const chatId = scopedChatId(projectId); + const existingChat = await getChatById({ id: chatId }); + if (existingChat && existingChat.userId !== session.user.id) { + return NextResponse.json({ error: "无权访问项目对话" }, { status: 403 }); + } + if (!existingChat) await saveChat({ + id: chatId, userId: session.user.id, title: project.title || "详情替换", visibility: "private", + }); + const taskRequest: DetailReplacementBatchRequest = { + taskKind: "agent-detail-replacement-batch", + projectId, + chatId, + model, + prompt: strategy === "product_only" + ? "仅替换参考详情图中的产品主体" + : buildAgentSlotPrompt({ + workflowPrompt: prompt, + slotPrompt: "", + userSupplementPrompt: normalizeAgentSupplementPrompt( + String(body.userPrompt || ""), "详情参考图替换", + ), + includeSlotTitle: false, + modeType: "detail_replacement", + }), + userPrompt: String(body.userPrompt || "").slice(0, 12_000), + imageSize: ["1K", "2K", "4K"].includes(body.imageSize) ? body.imageSize : "2K", + replacementStrategy: strategy, + productImageUrls, + modelImageUrl: /^https?:\/\//i.test(String(body.modelImageUrl || "")) + ? String(body.modelImageUrl) : null, + references, + }; + const submissionKey = detailReplacementSubmissionKey(taskRequest); + taskRequest.submissionKey = submissionKey; + + const duplicateId = await findActiveBatchId({ + userId: session.user.id, projectId, submissionKey, + }); + if (duplicateId) { + await ensureCanvasImageTaskActive({ taskId: duplicateId, userId: session.user.id }); + const duplicate = await getCanvasImageTaskForUser({ taskId: duplicateId, userId: session.user.id }); + return NextResponse.json({ task: taskResponse(duplicate) }); + } + + let task: Awaited>; + try { + task = await createCanvasImageTask({ + taskId, userId: session.user.id, request: taskRequest as unknown as Record, + }); + } catch (error) { + if (!isUniqueViolation(error)) throw error; + // Two identical submissions raced past the lookup above; the database + // unique index kept one of them, so answer with that batch. + const racedId = await findActiveBatchId({ + userId: session.user.id, projectId, submissionKey, + }); + if (!racedId) throw error; + const raced = await getCanvasImageTaskForUser({ taskId: racedId, userId: session.user.id }); + return NextResponse.json({ task: taskResponse(raced) }); + } + const existingTask = await getCanvasImageTaskForUser({ taskId: task.id, userId: session.user.id }); + const storedRequest = existingTask?.request as DetailReplacementBatchRequest | null; + if ( + !isDetailReplacementBatchRequest(storedRequest) || + storedRequest.projectId !== projectId || + (storedRequest.submissionKey && storedRequest.submissionKey !== submissionKey) + ) { + return NextResponse.json({ error: "任务编号已被其他请求使用" }, { status: 409 }); + } + await db.insert(message).values({ + id: taskId, + chatId, + role: "user", + parts: [{ type: "text", text: taskRequest.userPrompt || `批量替换 ${references.length} 张详情图` }], + attachments: [], + createdAt: new Date(), + }).onConflictDoNothing({ target: message.id }); + await enqueueCanvasImageTask(task.id); + const current = await getCanvasImageTaskForUser({ taskId, userId: session.user.id }); + return NextResponse.json({ task: taskResponse(current) }); + } catch (error) { + console.error("[Detail Replacement Batch] Failed to start", error); + return NextResponse.json({ error: error instanceof Error ? error.message : "提交详情替换任务失败" }, { status: 500 }); + } +} diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index e5b1ee61a..60e0e7517 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -2,6 +2,7 @@ import { useChat } from "@ai-sdk/react"; import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery"; +import type { DetailReplacementBatchCard } from "@/lib/agents/detail-replacement-batch"; import { DefaultChatTransport } from "ai"; import { Fragment, @@ -2564,10 +2565,23 @@ interface LocalGenerationPendingState { expectedCount: number; userTurnCountAtStart: number; requestMessageId?: string | null; + backendTaskId?: string | null; timestamp: number; requestedCardIds?: string[] | null; } +type DetailReplacementBatchTaskView = { + id: string; + status: "pending" | "running" | "succeeded" | "failed"; + error: string | null; + attempts: number; + cards: DetailReplacementBatchCard[]; + currentCardId: string | null; + requestedCardIds: string[]; + createdAt: string; + queueStatus?: { queuePosition?: number } | null; +}; + const AGENT_PENDING_GENERATION_STORAGE_KEY = "ai-chatbot-agent-pending-generations"; const AGENT_PENDING_GENERATION_EXPIRY_MS = 30 * 60 * 1000; const AGENT_SCENE_PENDING_FAILURE_GRACE_MS = 3 * 60 * 1000; @@ -2619,6 +2633,7 @@ function getStoredAgentPendingGenerations(): LocalGenerationPendingState[] { expectedCount: Number(record.expectedCount), userTurnCountAtStart: Number(record.userTurnCountAtStart), requestMessageId: typeof record.requestMessageId === "string" ? record.requestMessageId : null, + backendTaskId: typeof record.backendTaskId === "string" ? record.backendTaskId : null, timestamp: Number(record.timestamp), requestedCardIds: Array.isArray(record.requestedCardIds) ? record.requestedCardIds @@ -2651,16 +2666,6 @@ function saveStoredAgentPendingGenerations(items: LocalGenerationPendingState[]) } } -const DETAIL_RESULT_PROCESSING_STAGES = [ - "解码图像", - "定位选区", - "分离主体", - "重绘背景", - "融合边缘", - "检查细节", - "输出结果", -]; - function DetailResultProcessingScanOverlay({ imageUrl, label, @@ -2668,29 +2673,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 (