From a154a28a4ad8c9d5da75b9823f76b239a83dbb88 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 3 Jun 2026 11:13:19 +0800 Subject: [PATCH] fix: harden detail replacement layout rules --- .../api/chat/_lib/detail-replacement.ts | 137 +++++++++++++--- app/api/agent-workflow/detail-stitch/route.ts | 146 ++++++++++++++---- .../AgentDetailReferenceStitchDialog.tsx | 32 +++- 3 files changed, 263 insertions(+), 52 deletions(-) diff --git a/app/(chat)/api/chat/_lib/detail-replacement.ts b/app/(chat)/api/chat/_lib/detail-replacement.ts index 660407778..a09968cf2 100644 --- a/app/(chat)/api/chat/_lib/detail-replacement.ts +++ b/app/(chat)/api/chat/_lib/detail-replacement.ts @@ -54,8 +54,11 @@ export type ReplacementReferenceVisualProfile = { height: number; nearWhitePixelRatio: number; lightLowSaturationPixelRatio: number; + topLightLowSaturationPixelRatio: number; + borderLightLowSaturationPixelRatio: number; darkPixelRatio: number; likelyWhiteLayout: boolean; + likelyWhiteScene: boolean; reason: string; }; @@ -131,9 +134,16 @@ export async function resolveReplacementReferenceVisualProfile( const pixelCount = Math.max(1, info.width * info.height); let nearWhiteCount = 0; let lightLowSaturationCount = 0; + let topLightLowSaturationCount = 0; + let topPixelCount = 0; + let borderLightLowSaturationCount = 0; + let borderPixelCount = 0; let darkCount = 0; for (let offset = 0; offset < data.length; offset += info.channels) { + const pixelIndex = Math.floor(offset / info.channels); + const x = pixelIndex % info.width; + const y = Math.floor(pixelIndex / info.width); const red = data[offset] || 0; const green = data[offset + 1] || 0; const blue = data[offset + 2] || 0; @@ -141,13 +151,32 @@ export async function resolveReplacementReferenceVisualProfile( const minChannel = Math.min(red, green, blue); const luminance = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255; const saturationSpan = maxChannel - minChannel; + const isLightLowSaturation = luminance >= 0.82 && saturationSpan <= 42; + const isTopBand = y <= Math.round(info.height * 0.32); + const isBorderBand = + x <= Math.round(info.width * 0.06) || + x >= Math.round(info.width * 0.94) || + y <= Math.round(info.height * 0.08) || + y >= Math.round(info.height * 0.92); if (red >= 238 && green >= 238 && blue >= 238) { nearWhiteCount += 1; } - if (luminance >= 0.82 && saturationSpan <= 42) { + if (isLightLowSaturation) { lightLowSaturationCount += 1; } + if (isTopBand) { + topPixelCount += 1; + if (isLightLowSaturation) { + topLightLowSaturationCount += 1; + } + } + if (isBorderBand) { + borderPixelCount += 1; + if (isLightLowSaturation) { + borderLightLowSaturationCount += 1; + } + } if (luminance <= 0.2) { darkCount += 1; } @@ -155,23 +184,40 @@ export async function resolveReplacementReferenceVisualProfile( const nearWhitePixelRatio = nearWhiteCount / pixelCount; const lightLowSaturationPixelRatio = lightLowSaturationCount / pixelCount; + const topLightLowSaturationPixelRatio = + topLightLowSaturationCount / Math.max(1, topPixelCount); + const borderLightLowSaturationPixelRatio = + borderLightLowSaturationCount / Math.max(1, borderPixelCount); const darkPixelRatio = darkCount / pixelCount; + const likelyWhiteScene = + (lightLowSaturationPixelRatio >= 0.5 && darkPixelRatio <= 0.28) || + (topLightLowSaturationPixelRatio >= 0.68 && + borderLightLowSaturationPixelRatio >= 0.52 && + darkPixelRatio <= 0.32); const likelyWhiteLayout = nearWhitePixelRatio >= 0.42 || (nearWhitePixelRatio >= 0.3 && lightLowSaturationPixelRatio >= 0.58 && - darkPixelRatio <= 0.18); + darkPixelRatio <= 0.18) || + (topLightLowSaturationPixelRatio >= 0.72 && + borderLightLowSaturationPixelRatio >= 0.6 && + darkPixelRatio <= 0.24); return { width: info.width, height: info.height, nearWhitePixelRatio, lightLowSaturationPixelRatio, + topLightLowSaturationPixelRatio, + borderLightLowSaturationPixelRatio, darkPixelRatio, likelyWhiteLayout, + likelyWhiteScene, reason: likelyWhiteLayout - ? "像素初筛显示大面积白底/浅色低饱和区域,按白底说明图保护" - : "像素初筛未达到白底说明图阈值", + ? "像素初筛显示大面积白底/浅色低饱和版式区域,按白底说明图保护" + : likelyWhiteScene + ? "像素初筛显示白/浅色低饱和场景,按白场景保护" + : "像素初筛未达到白底/白场景阈值", }; } catch (error) { chatApiLogger.warn("[Chat API] Failed to resolve replacement reference visual profile:", { @@ -717,11 +763,18 @@ function applyDetailReplacementScenePlanGuards( scenePlan.promptPackPolicy !== "forbid"; const hasWhiteLayoutHint = DETAIL_WHITE_LAYOUT_HINT_PATTERN.test(planText); const hasPeopleActionHint = DETAIL_PEOPLE_ACTION_HINT_PATTERN.test(planText); + const visualProfileSuggestsWhiteScene = + params.visualProfile?.likelyWhiteScene === true || + params.visualProfile?.likelyWhiteLayout === true; const shouldProtectWhiteLayout = !isProductOnly && (scenePlan.whiteBackgroundLikely || params.visualProfile?.likelyWhiteLayout === true || hasWhiteLayoutHint); + const shouldProtectWhiteScene = + !isProductOnly && + !shouldProtectWhiteLayout && + visualProfileSuggestsWhiteScene; const shouldProtectPeople = !isProductOnly && (params.containsPeople || @@ -737,7 +790,43 @@ function applyDetailReplacementScenePlanGuards( let next: ReplacementScenePlan = { ...scenePlan }; - if (shouldProtectPeople) { + if (shouldProtectPeople && (shouldProtectWhiteLayout || shouldProtectWhiteScene)) { + next = { + ...next, + generationRoute: shouldProtectWhiteLayout + ? "layout_preserve_replace" + : "people_relation_locked", + promptPackPolicy: "forbid", + textPolicy: next.textPolicy === "free" ? "preserve" : next.textPolicy, + peoplePolicy: shouldProtectPeople ? "preserve_relation" : next.peoplePolicy, + useModelImage: params.hasModelImage && shouldProtectPeople, + modelImageReason: params.hasModelImage + ? next.modelImageReason || + "当前参考页有人物/动作,但参考图为白底/浅色场景,模特只用于保持人物身份、动作和姿态,不用于重建复杂空间。" + : next.modelImageReason || + "当前参考页有人物/动作,但参考图为白底/浅色场景;应保留人物数量、动作和关系,不要生成复杂家居场景。", + routeReason: appendUniqueInstruction( + next.routeReason, + shouldProtectWhiteLayout + ? "命中白底排版 + 人物/动作语义,锁定白底版式和人物关系,禁止套用场景词包。" + : "命中白/浅色场景 + 人物/动作语义,锁定白场景和人物关系,禁止套用复杂空间词包。", + 220, + ), + generationPrompt: appendUniqueInstruction( + next.generationPrompt, + shouldProtectWhiteLayout + ? "本屏是白底/浅色详情排版页:必须保持白底或浅色底、标题区、标签、图片框、留白、人物数量/动作/关系和产品位置;不使用前置词包,不生成厨房、客厅、黑墙、吊灯或复杂软装场景。" + : "本屏是白/浅色低饱和场景页:最终结果也必须保持白色、米白、浅灰或浅木色的干净场景基调;人物数量、动作、朝向和产品关系保真,但不要生成厨房、客厅、黑墙、吊灯、复杂柜体或强软装背景。", + ), + avoidSummary: appendUniqueInstruction( + next.avoidSummary, + "不要把白底/白场景人物页改成复杂室内场景,不要让前置词包覆盖白色背景,不要把人物动作变成走动路人或无关模特。", + 280, + ), + whiteBackgroundLikely: true, + copyImageCoupled: next.copyImageCoupled || shouldProtectWhiteLayout, + }; + } else if (shouldProtectPeople) { next = { ...next, generationRoute: "people_relation_locked", @@ -796,7 +885,7 @@ function applyDetailReplacementScenePlanGuards( whiteBackgroundLikely: next.whiteBackgroundLikely || shouldProtectWhiteLayout, copyImageCoupled: true, }; - } else if (shouldProtectWhiteLayout) { + } else if (shouldProtectWhiteLayout || shouldProtectWhiteScene) { next = { ...next, generationRoute: "layout_preserve_replace", @@ -810,7 +899,9 @@ function applyDetailReplacementScenePlanGuards( ), generationPrompt: appendUniqueInstruction( next.generationPrompt, - "本屏是白底/浅色产品说明版式:不要套用空间、人群、灯光、机位等前置词包,只保留参考图白底排版并做必要局部替换。", + shouldProtectWhiteLayout + ? "本屏是白底/浅色产品说明版式:不要套用空间、人群、灯光、机位等前置词包,只保留参考图白底排版并做必要局部替换。" + : "本屏是白/浅色低饱和场景:最终结果也必须保持白场景、浅色背景、干净留白和低饱和氛围,不要改成厨房、客厅、黑墙、吊灯或复杂软装场景。", ), whiteBackgroundLikely: true, }; @@ -1009,6 +1100,7 @@ export function buildHeuristicReplacementScenePlan(params: { isDetailReplacement && !isProductOnly && (Boolean(params.visualProfile?.likelyWhiteLayout) || + Boolean(params.visualProfile?.likelyWhiteScene) || DETAIL_STRONG_COPY_HINT_PATTERN.test(fallbackText)); const generationRoute = params.shouldReplace ? isProductOnly @@ -1027,14 +1119,16 @@ export function buildHeuristicReplacementScenePlan(params: { ? "preserve" : "free", peoplePolicy: "none", - whiteBackgroundLikely: Boolean(params.visualProfile?.likelyWhiteLayout), + whiteBackgroundLikely: Boolean( + params.visualProfile?.likelyWhiteLayout || params.visualProfile?.likelyWhiteScene, + ), copyImageCoupled: false, routeReason: params.shouldReplace ? isProductOnly ? "兜底按仅替换产品执行,不使用词包。" : isDetailReplacement ? shouldProtectFallbackLayout - ? "兜底命中白底/说明/强文案特征,按详情版式保护执行,不使用词包。" + ? "兜底命中白底/白场景/说明/强文案特征,按详情版式或白场景保护执行,不使用词包。" : "兜底未命中白底、强文案或真人互动硬约束,按通用场景图执行词包场景替换。" : "兜底未检测到强约束,允许使用词包生成场景。" : params.reason, @@ -1050,7 +1144,7 @@ export function buildHeuristicReplacementScenePlan(params: { ? isProductOnly ? "严格保留参考图的场景、文案和版式,只把产品展示区中的旧产品替换成输入产品。" : shouldProtectFallbackLayout - ? "保留参考图版式骨架、构图、文字和主体位置,只做必要的产品或视觉区替换。" + ? "保留参考图白底/白场景、版式骨架、构图、文字和主体位置,只做必要的产品或视觉区替换。" : "保留参考图版式骨架、构图和主体位置,但把旧场景视觉内容按工作流目标风格重建。" : "当前参考图不满足替换条件。", cameraSummary: "保持参考图原有机位、构图和景别,不做整体重画。", @@ -1063,18 +1157,18 @@ export function buildHeuristicReplacementScenePlan(params: { styleSummary: isProductOnly ? "延续参考图原有风格、背景和氛围,不引入新的场景改造要求。" : shouldProtectFallbackLayout - ? "延续参考图原有版式和图文语义,不引入新的空间场景。" + ? "延续参考图原有白底/白场景、版式和图文语义,不引入新的空间场景。" : "除版式骨架外,墙面、地面、背景家具、软装、灯光、摆件和空间氛围都要按工作流风格重建。", avoidSummary: isProductOnly ? "不要改动背景、文案、按钮、图标、排版和装饰,不要重建整个场景,不要把多个产品角度同时塞进一张图。" : shouldProtectFallbackLayout - ? "不要把白底/说明/强文案页场景化,不要新增厨房/客厅/人物/软装,不要让文字和图片失配。" + ? "不要把白底/白场景/说明/强文案页场景化,不要新增厨房/客厅/黑墙/吊灯/复杂软装,不要让文字和图片失配。" : "不要只换产品不换场景,不要把参考图原有背景物件和空间氛围原样保留下来,不要把所有输入产品角度图同时强行出现在一张图中。", generationPrompt: params.shouldReplace ? isProductOnly ? `仅替换参考图中的产品主体,严格保留其余背景、文案、排版与光影关系。${params.reason}` : isDetailReplacement && shouldProtectFallbackLayout - ? `保留参考图的版式、文案、白底/留白和图文语义,不使用前置词包;只在产品、材质、板材、原材料或视觉图片区做必要替换。${params.reason}` + ? `保留参考图的版式、文案、白底/白场景/留白和图文语义,不使用前置词包;只在产品、材质、板材、原材料或视觉图片区做必要替换。${params.reason}` : isDetailReplacement ? `当前参考图按通用空间/场景详情页处理:必须使用前置工作流词包重建旧背景、空间、软装、灯光和产品场景,只保留参考图版式骨架、文字区、机位和信息层级。${params.reason}` : `以工作流目标风格重建当前这一屏的场景,只保留版式、机位、文字区与留白结构。${params.reason}` @@ -1565,10 +1659,13 @@ export async function analyzeReplacementExecutionPlan(params: { `当前要分析的参考图:${params.referenceLabel}`, "你现在只分析这一张参考图本身,不分析产品图,不分析模特图。", referenceVisualProfile - ? `像素初筛:nearWhite=${referenceVisualProfile.nearWhitePixelRatio.toFixed(2)}, lightLowSaturation=${referenceVisualProfile.lightLowSaturationPixelRatio.toFixed(2)}, dark=${referenceVisualProfile.darkPixelRatio.toFixed(2)}, likelyWhiteLayout=${referenceVisualProfile.likelyWhiteLayout ? "true" : "false"}。${referenceVisualProfile.reason}` + ? `像素初筛:nearWhite=${referenceVisualProfile.nearWhitePixelRatio.toFixed(2)}, lightLowSaturation=${referenceVisualProfile.lightLowSaturationPixelRatio.toFixed(2)}, topLight=${referenceVisualProfile.topLightLowSaturationPixelRatio.toFixed(2)}, borderLight=${referenceVisualProfile.borderLightLowSaturationPixelRatio.toFixed(2)}, dark=${referenceVisualProfile.darkPixelRatio.toFixed(2)}, likelyWhiteLayout=${referenceVisualProfile.likelyWhiteLayout ? "true" : "false"}, likelyWhiteScene=${referenceVisualProfile.likelyWhiteScene ? "true" : "false"}。${referenceVisualProfile.reason}` : "", referenceVisualProfile?.likelyWhiteLayout - ? "像素初筛已经命中白底/浅色说明页:如果画面是纯产品参数/说明/卖点排版且没有人物动作或真实使用展示,generationRoute 必须是 layout_preserve_replace 或 copy_locked_regenerate,promptPackPolicy 必须 forbid;如果画面包含人物、自拍、承重测试、使用产品、真实展示动作或明确生活场景,可以选择 people_relation_locked,并把 promptPackPolicy 设为 allow 或 soft。" + ? "像素初筛已经命中白底/浅色说明页:只要画面存在标题、标签、参数、尺寸、卖点、图标、白底图片区、圆角图片区或明显留白排版,generationRoute 必须是 layout_preserve_replace 或 copy_locked_regenerate,promptPackPolicy 必须 forbid;即使包含人物/动作,也只能保留人物关系与白底版式,不允许套用厨房、客厅、灯光、软装等场景词包。" + : "", + !referenceVisualProfile?.likelyWhiteLayout && referenceVisualProfile?.likelyWhiteScene + ? "像素初筛已经命中白/浅色低饱和场景:如果参考图主体是白底、米白、浅灰、浅木色、干净棚拍或轻生活场景,最终结果也必须保持白/浅色场景基调;不要把它改造成深色厨房、客厅、吊灯、黑墙、复杂柜体或强软装场景。" : "", "产品图和模特图会在后续生图阶段单独提供给生图模型使用,你这里不要尝试理解它们的具体外观,也不要要求必须使用所有输入图。", params.modelImageUrl && !isProductOnlyDetailReplacement @@ -1602,6 +1699,9 @@ export async function analyzeReplacementExecutionPlan(params: { isDetailReplacement ? "4. 判断 promptPackPolicy:纯白底参数/说明/卖点页、强图文绑定且没有真实展示动作的详情页必须 forbid;通用空间场景必须 allow;人物/自拍/聚会/承重测试/使用产品等动作展示页可以 people_relation_locked + allow/soft,含义是保留人物动作与文字层级,但允许前置词包重建背景、灯光、软装和产品场景。" : "", + isDetailReplacement + ? "4a. 白场景优先规则:如果参考图是白底/浅色说明页或白/浅色低饱和场景,promptPackPolicy 必须 forbid;人物动作可以保留,但不能使用前置词包把白场景改成厨房、客厅、黑墙、吊灯或复杂软装。" + : "", isDetailReplacement ? "5. 判断 textPolicy:画面中存在营销标题、卖点、编号、参数、标签且与图匹配时必须 lock 或 preserve,避免后续生图改字、混色或重排。" : "", @@ -1850,7 +1950,7 @@ export function buildAgentReplacementPrompt(params: { params.scenePlan.whiteBackgroundLikely ? shouldApplyPromptPack ? "白底/说明图判断:是,但本屏包含人物动作/真实展示语义,允许在保留版式和文字层级的前提下按词包改造背景/产品场景。" - : "白底/说明图判断:是,禁止强行场景化。" + : "白底/白场景判断:是,禁止强行场景化;最终结果必须保持白底、浅色底或干净低饱和场景基调。" : "", params.scenePlan.copyImageCoupled ? "图文绑定判断:是,文案与画面语义必须保持匹配。" @@ -1964,14 +2064,15 @@ export function buildAgentReplacementPrompt(params: { : params.scenePlan?.peoplePolicy === "preserve_relation" ? "本屏包含人物关系:必须保留人物数量、相对位置、互动关系、动作方向和出镜比例;如果参考图文字写了自拍/合照/聚会/用餐/拿手机等动作,必须让人物执行该动作,不要把多人图改成单人图、无人图或无关模特图。" : "如果参考图没有明确人物展示需求,不要凭空新增人物主体。", - params.scenePlan?.whiteBackgroundLikely && !shouldApplyPromptPack - ? "本屏疑似白底/说明图:禁止场景化、生活化或套空间词包,只允许保留白底/说明图语义并做必要的局部替换。" + params.scenePlan?.whiteBackgroundLikely && !shouldApplyPromptPack + ? "本屏疑似白底/白场景/浅色说明图:禁止场景化、生活化或套空间词包,只允许保留白底、浅色底、干净留白、原图片框和原说明图语义并做必要的局部替换;最终不能变成厨房、客厅、黑墙、吊灯或复杂软装场景。" : "", params.referenceGeometry ? `当前参考图画布尺寸为 ${params.referenceGeometry.width}x${params.referenceGeometry.height},输出必须保持这个页面画布比例。替换背景/商品时只能等比缩放后裁切或延展背景,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。` : "输出必须保持当前参考图页面画布比例。替换背景/商品时只能等比缩放后裁切或延展背景,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。", "文字安全区硬约束:所有标题、正文、编号、标签、按钮、品牌字样和图标必须完整落在画布内,左右至少保留 6% 内边距,上下至少保留 4% 内边距;不得贴边、出血、半截显示、跑出画面或被裁切。若参考图原文字靠边,也要在保持层级和相对位置的前提下向内收。", "文字排版硬约束:标题和标签必须保持参考图原来的相对坐标、行数、行距、字号比例和对齐方式;不要把标题从白底区移动到照片区,不要把横排标签移动到角落,不要把两行标题改成三行或压成一行。", + "白底/白场景硬约束:如果最后一张参考图的背景是白底、米白、浅灰、浅木色或大面积干净留白,最终结果也必须是同类白/浅色场景;只能在原照片框或主体区域内替换产品/材质/人物动作,不允许生成深色厨房、客厅、吊灯、黑墙、复杂柜体或强软装背景。", "单页画布硬约束:只生成当前这一张详情页,不允许把两个页面、两个方案或两张场景图上下拼接,不允许出现明显横向分割线、重复画面、瀑布流长图或截图拼接感。", "图片框硬约束:如果参考图下半部分是人物/产品照片框,人物、产品和新场景只能出现在这个照片框里;照片框之外的标题白底、标签、页边距和装饰必须保持干净,不要被场景纹理、墙面、灯具或人物侵入。", "镜头角度、透视关系、视平线、主体朝向和主体占比必须尽量跟随最后一张参考图,方便无缝替换当前版式,不要擅自改成完全不同的视角。", diff --git a/app/api/agent-workflow/detail-stitch/route.ts b/app/api/agent-workflow/detail-stitch/route.ts index 164ee9388..4aae6c2da 100644 --- a/app/api/agent-workflow/detail-stitch/route.ts +++ b/app/api/agent-workflow/detail-stitch/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; import { buildImageProxyUrl } from "@/lib/image-delivery"; +import { createLogger } from "@/lib/observability/logger"; import { uploadFile } from "@/lib/storage/service"; export const runtime = "nodejs"; @@ -28,14 +29,27 @@ type StitchSourceSegment = { }; const MAX_REFERENCE_COUNT = 120; -const FETCH_TIMEOUT_MS = 10000; -const FETCH_CONCURRENCY = 32; -const SLICE_UPLOAD_CONCURRENCY = 10; +const FETCH_TIMEOUT_MS = 20_000; +const FETCH_MAX_ATTEMPTS = 3; +const FETCH_RETRY_BASE_DELAY_MS = 350; +const FETCH_CONCURRENCY = 12; +const SLICE_UPLOAD_CONCURRENCY = 4; const PREVIEW_MAX_WIDTH = 720; const PREVIEW_MIN_WIDTH = 96; const PREVIEW_MAX_HEIGHT = 32000; const PREVIEW_MAX_PIXELS = 36_000_000; const SEGMENT_MATCH_TOLERANCE = 2; +const logger = createLogger("detail-stitch"); + +class ImageFetchError extends Error { + retryable: boolean; + + constructor(message: string, retryable = false) { + super(message); + this.name = "ImageFetchError"; + this.retryable = retryable; + } +} function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer { return buffer.buffer.slice( @@ -44,6 +58,38 @@ function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer { ) as ArrayBuffer; } +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error || ""); +} + +function isTimeoutLikeError(error: unknown) { + if (!(error instanceof Error)) return false; + return ( + error.name === "TimeoutError" || + error.name === "AbortError" || + /timeout|aborted|operation was aborted/i.test(error.message) + ); +} + +function isRetryableFetchError(error: unknown) { + if (error instanceof ImageFetchError) { + return error.retryable; + } + return isTimeoutLikeError(error); +} + +function toPublicErrorMessage(error: unknown) { + const message = getErrorMessage(error); + if (isTimeoutLikeError(error) || /timeout|aborted|operation was aborted/i.test(message)) { + return "切割图片下载超时,通常是源图过大、源站响应慢或网络抖动导致。请稍后重试,或减少本次切割图片数量后再应用。"; + } + return message || "拼合切割失败"; +} + async function runBoundedParallel( items: T[], workerCount: number, @@ -148,31 +194,54 @@ function buildReferenceFetchUrl(url: string): string { } async function fetchImageBuffer(url: string, requestOrigin: string, cookie: string): Promise { - const response = await fetch(url, { - cache: "no-store", - redirect: "follow", - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), - headers: buildFetchHeaders(url, requestOrigin, cookie), - }); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); + let lastError: unknown = null; + + for (let attempt = 1; attempt <= FETCH_MAX_ATTEMPTS; attempt += 1) { + try { + const response = await fetch(url, { + cache: "no-store", + redirect: "follow", + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + headers: buildFetchHeaders(url, requestOrigin, cookie), + }); + if (!response.ok) { + throw new ImageFetchError( + `图片下载失败:HTTP ${response.status}`, + response.status === 408 || response.status === 429 || response.status >= 500, + ); + } + const contentType = response.headers.get("content-type") || ""; + const normalizedContentType = contentType.toLowerCase(); + if ( + normalizedContentType && + !normalizedContentType.startsWith("image/") && + (normalizedContentType.startsWith("text/") || + normalizedContentType.includes("json") || + normalizedContentType.includes("xml")) + ) { + throw new ImageFetchError("返回内容不是图片"); + } + const buffer = Buffer.from(await response.arrayBuffer()); + if (buffer.length === 0) { + throw new ImageFetchError("图片内容为空"); + } + return buffer; + } catch (error) { + lastError = error; + if (attempt >= FETCH_MAX_ATTEMPTS || !isRetryableFetchError(error)) { + break; + } + logger.warn("image_fetch_retry", { + attempt, + maxAttempts: FETCH_MAX_ATTEMPTS, + timeoutMs: FETCH_TIMEOUT_MS, + reason: getErrorMessage(error), + }); + await sleep(FETCH_RETRY_BASE_DELAY_MS * attempt); + } } - const contentType = response.headers.get("content-type") || ""; - const normalizedContentType = contentType.toLowerCase(); - if ( - normalizedContentType && - !normalizedContentType.startsWith("image/") && - (normalizedContentType.startsWith("text/") || - normalizedContentType.includes("json") || - normalizedContentType.includes("xml")) - ) { - throw new Error("返回内容不是图片"); - } - const buffer = Buffer.from(await response.arrayBuffer()); - if (buffer.length === 0) { - throw new Error("图片内容为空"); - } - return buffer; + + throw new ImageFetchError(toPublicErrorMessage(lastError), false); } function resolvePreviewWidth(items: Array<{ width: number; height: number }>) { @@ -477,6 +546,8 @@ async function applyStitch(params: { } export async function POST(request: NextRequest) { + const startedAt = Date.now(); + let mode = "unknown"; try { const session = await auth(); if (!session?.user?.id) { @@ -484,7 +555,7 @@ export async function POST(request: NextRequest) { } const body = (await request.json().catch(() => ({}))) as Record; - const mode = String(body.mode || "").trim(); + mode = String(body.mode || "").trim(); const requestOrigin = getRequestOrigin(request); const cookie = request.headers.get("cookie") || ""; @@ -495,6 +566,12 @@ export async function POST(request: NextRequest) { } const data = await prepareStitch({ references, requestOrigin, cookie }); + logger.info("prepare_completed", { + referenceCount: references.length, + sourceSegmentCount: data.sourceSegments.length, + failedCount: data.failed.length, + durationMs: Date.now() - startedAt, + }); return NextResponse.json({ success: true, data }); } @@ -560,16 +637,25 @@ export async function POST(request: NextRequest) { requestOrigin, cookie, }); + logger.info("apply_completed", { + sourceSegmentCount: sourceSegments.length, + cutLineCount: normalizeCutLines(body.cutLines, height).length, + outputCount: data.cards.length, + durationMs: Date.now() - startedAt, + }); return NextResponse.json({ success: true, data }); } return NextResponse.json({ success: false, error: "未知操作" }, { status: 400 }); } catch (error) { - console.error("[DetailStitch API] Failed:", error); + logger.error("request_failed", error, { + mode, + durationMs: Date.now() - startedAt, + }); return NextResponse.json( { success: false, - error: error instanceof Error ? error.message : "拼合切割失败", + error: toPublicErrorMessage(error), }, { status: 500 }, ); diff --git a/components/agents/AgentDetailReferenceStitchDialog.tsx b/components/agents/AgentDetailReferenceStitchDialog.tsx index faa42c51b..b141f5812 100644 --- a/components/agents/AgentDetailReferenceStitchDialog.tsx +++ b/components/agents/AgentDetailReferenceStitchDialog.tsx @@ -21,12 +21,14 @@ import { } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; import { buildImageProxyUrl } from "@/lib/image-delivery"; +import { createClientLogger } from "@/lib/observability/client-logger"; import type { AgentDetailReplacementReferenceItem } from "@/types/agent"; const MIN_SLICE_HEIGHT = 120; const FAST_PREVIEW_WIDTH = 720; const ESTIMATED_REFERENCE_HEIGHT = 960; const SEGMENT_PRELOAD_CONCURRENCY = 4; +const logger = createClientLogger("agent-detail-reference-stitch"); type StitchSourceSegment = { card: AgentDetailReplacementReferenceItem; @@ -96,6 +98,18 @@ function getReferenceDimension(value: unknown) { return Number.isFinite(normalized) && normalized > 0 ? normalized : null; } +function normalizeDetailStitchErrorMessage(error: unknown, fallback: string) { + const message = + error instanceof Error ? error.message : String(error || "").trim(); + if (/timeout|aborted|operation was aborted/i.test(message)) { + return "切割图片下载超时,通常是源图过大、源站响应慢或网络抖动导致。请稍后重试,或减少本次切割图片数量后再应用。"; + } + if (/failed to fetch|networkerror|network error/i.test(message)) { + return "网络连接异常,部分参考图暂时无法下载,请稍后重试。"; + } + return message || fallback; +} + function resolveReferencePreviewHeight(card: AgentDetailReplacementReferenceItem) { const width = getReferenceDimension(card.width); const height = getReferenceDimension(card.height); @@ -389,9 +403,13 @@ export function AgentDetailReferenceStitchDialog({ ); } catch (error) { if (cancelled) return; - console.error("[AgentDetailReferenceStitchDialog] Failed to prepare merged image:", error); + const message = normalizeDetailStitchErrorMessage(error, "拼合参考图失败"); + logger.warn("prepare_failed", { + cardCount: setupCards.length, + message, + }); setWarningText( - `精准尺寸校准失败,已先使用快速预览:${error instanceof Error ? error.message : "拼合参考图失败"}` + `精准尺寸校准失败,已先使用快速预览:${message}` ); } finally { if (!cancelled) { @@ -692,8 +710,14 @@ export function AgentDetailReferenceStitchDialog({ onOpenChange(false); toast.success(`已生成 ${orderedCards.length} 张切片参考图`); } catch (error) { - console.error("[AgentDetailReferenceStitchDialog] Failed to apply slices:", error); - toast.error(error instanceof Error ? error.message : "切割参考图失败"); + const message = normalizeDetailStitchErrorMessage(error, "切割参考图失败"); + logger.error("apply_failed", { + cutLineCount: normalizedCutLines.length, + sliceCount: sliceSegments.length, + sourceSegmentCount: snapshot.sourceSegments.length, + message, + }); + toast.error(message); } finally { setIsApplying(false); setApplyProgressText("");