diff --git a/app/api/agent-workflow/detail-stitch/route.ts b/app/api/agent-workflow/detail-stitch/route.ts index e73f863fe..9910f464f 100644 --- a/app/api/agent-workflow/detail-stitch/route.ts +++ b/app/api/agent-workflow/detail-stitch/route.ts @@ -20,12 +20,13 @@ type StitchSourceSegment = { card: StitchReferenceInput; top: number; height: number; + sourceWidth?: number; + sourceHeight?: number; }; const MAX_REFERENCE_COUNT = 120; const FETCH_TIMEOUT_MS = 10000; const FETCH_CONCURRENCY = 32; -const TRANSFORM_CONCURRENCY = 10; const SLICE_UPLOAD_CONCURRENCY = 10; const PREVIEW_MAX_WIDTH = 720; const PREVIEW_MIN_WIDTH = 96; @@ -250,73 +251,26 @@ async function prepareStitch(params: { const sourceSegments: StitchSourceSegment[] = []; const defaultCutLines: number[] = []; - const resizedItems = await runBoundedParallel(loaded, TRANSFORM_CONCURRENCY, async (item, loadedIndex) => { + loaded.forEach((item, index) => { const height = Math.max(1, Math.round((previewWidth / Math.max(item.width, 1)) * item.height)); - const resized = await sharp(item.buffer, { failOn: "none" }) - .rotate() - .resize(previewWidth, height, { - fit: "fill", - withoutEnlargement: false, - }) - .jpeg({ - quality: 76, - mozjpeg: true, - progressive: true, - }) - .toBuffer(); - - return { - input: resized, - top: 0, - left: 0, - height, - loadedIndex, - card: item.card, - }; - }); - - const compositeItems = resizedItems.map((item, index) => { const currentTop = top; - top += item.height; - if (index < resizedItems.length - 1) { + top += height; + if (index < loaded.length - 1) { defaultCutLines.push(top); } sourceSegments.push({ card: item.card, top: currentTop, - height: item.height, + height, + sourceWidth: item.width, + sourceHeight: item.height, }); - return { - input: item.input, - top: currentTop, - left: 0, - }; }); const previewHeight = Math.max(1, top); - const previewBuffer = await sharp({ - create: { - width: previewWidth, - height: previewHeight, - channels: 3, - background: "#ffffff", - }, - }) - .composite(compositeItems) - .jpeg({ - quality: 76, - mozjpeg: true, - progressive: true, - }) - .toBuffer(); - - const upload = await uploadFile( - `detail-stitch-preview-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.jpg`, - bufferToArrayBuffer(previewBuffer), - ); return { - previewUrl: upload.url, + previewUrl: "", width: previewWidth, height: previewHeight, defaultCutLines, @@ -349,10 +303,36 @@ async function applyStitch(params: { })); const passthroughSet = new Set(params.passthroughSegmentIndexes); const segmentMatches = segments.map((segment) => findMatchingSourceSegment(segment, params.sourceSegments)); - const needsCropping = segmentMatches.some((match) => !match); - const previewBuffer = needsCropping - ? await fetchImageBuffer(params.previewUrl, params.requestOrigin, params.cookie) - : null; + const preparedSourceCache = new Map>(); + + const getPreparedSourceBuffer = (source: StitchSourceSegment) => { + const sourceUrl = source.card.imageUrl; + const key = `${sourceUrl}|${params.width}|${source.height}`; + const cached = preparedSourceCache.get(key); + if (cached) return cached; + + const prepared = (async () => { + const buffer = await fetchImageBuffer( + buildReferenceFetchUrl(sourceUrl), + params.requestOrigin, + params.cookie, + ); + return sharp(buffer, { failOn: "none" }) + .rotate() + .resize(params.width, source.height, { + fit: "fill", + withoutEnlargement: false, + }) + .jpeg({ + quality: 88, + mozjpeg: true, + progressive: true, + }) + .toBuffer(); + })(); + preparedSourceCache.set(key, prepared); + return prepared; + }; const cards = await runBoundedParallel(segments, SLICE_UPLOAD_CONCURRENCY, async (segment) => { const matchedSource = segmentMatches[segment.index] || null; @@ -367,21 +347,65 @@ async function applyStitch(params: { }; } - const safeTop = Math.max(0, Math.min(params.height - 1, Math.round(segment.top))); - const safeHeight = Math.max( + const segmentTop = Math.max(0, Math.round(segment.top)); + const segmentHeight = Math.max( 1, - Math.min(params.height - safeTop, Math.round(segment.height)), + Math.min(params.height - segmentTop, Math.round(segment.height)), ); - if (!previewBuffer) { - throw new Error("拼合预览图不可用"); + const segmentBottom = segmentTop + segmentHeight; + const overlappingSources = params.sourceSegments.filter((source) => { + const sourceTop = Math.round(source.top); + const sourceBottom = sourceTop + Math.round(source.height); + return sourceTop < segmentBottom && sourceBottom > segmentTop; + }); + + if (overlappingSources.length === 0) { + throw new Error("当前切片没有匹配到原始参考图"); } - const sliceBuffer = await sharp(previewBuffer, { failOn: "none" }) - .extract({ - left: 0, - top: safeTop, + + const compositeItems = await Promise.all( + overlappingSources.map(async (source) => { + const sourceTop = Math.round(source.top); + const sourceHeight = Math.max(1, Math.round(source.height)); + const overlapTop = Math.max(segmentTop, sourceTop); + const overlapBottom = Math.min(segmentBottom, sourceTop + sourceHeight); + const cropTop = Math.max(0, Math.min(sourceHeight - 1, overlapTop - sourceTop)); + const cropHeight = Math.max( + 1, + Math.min(sourceHeight - cropTop, Math.round(overlapBottom - overlapTop)), + ); + const sourceBuffer = await getPreparedSourceBuffer(source); + const input = await sharp(sourceBuffer, { failOn: "none" }) + .extract({ + left: 0, + top: cropTop, + width: params.width, + height: cropHeight, + }) + .jpeg({ + quality: 88, + mozjpeg: true, + progressive: true, + }) + .toBuffer(); + + return { + input, + top: Math.max(0, Math.round(overlapTop - segmentTop)), + left: 0, + }; + }), + ); + + const sliceBuffer = await sharp({ + create: { width: params.width, - height: safeHeight, - }) + height: segmentHeight, + channels: 3, + background: "#ffffff", + }, + }) + .composite(compositeItems) .jpeg({ quality: 88, mozjpeg: true, @@ -445,12 +469,16 @@ export async function POST(request: NextRequest) { const imageUrl = resolveHttpUrl(rawCard.imageUrl, requestOrigin); const top = Math.round(Number(raw.top)); const segmentHeight = Math.round(Number(raw.height)); + const sourceWidth = Math.round(Number(raw.sourceWidth)); + const sourceHeight = Math.round(Number(raw.sourceHeight)); if (!imageUrl || !Number.isFinite(top) || !Number.isFinite(segmentHeight)) { return null; } return { top, height: Math.max(1, segmentHeight), + sourceWidth: Number.isFinite(sourceWidth) && sourceWidth > 0 ? sourceWidth : undefined, + sourceHeight: Number.isFinite(sourceHeight) && sourceHeight > 0 ? sourceHeight : undefined, card: { id: String(rawCard.id || "").trim() || `source-${top}`, label: String(rawCard.label || "").trim() || "参考图", @@ -466,7 +494,7 @@ export async function POST(request: NextRequest) { }) .filter((item): item is StitchSourceSegment => Boolean(item)); - if (!previewUrl || !width || !height || sourceSegments.length === 0) { + if (!width || !height || sourceSegments.length === 0) { return NextResponse.json({ success: false, error: "缺少切割参数" }, { status: 400 }); } diff --git a/app/api/image-proxy/route.ts b/app/api/image-proxy/route.ts index faf9ac887..087c06fb4 100644 --- a/app/api/image-proxy/route.ts +++ b/app/api/image-proxy/route.ts @@ -192,16 +192,19 @@ export async function GET(request: NextRequest) { const quality = requestedQuality ? clamp(requestedQuality, MIN_QUALITY, MAX_QUALITY) : DEFAULT_QUALITY; const directQuality = requestedQuality ? clamp(requestedQuality, MIN_QUALITY, MAX_QUALITY) : null; const requestedFormat = searchParams.get("f"); + const forceProxy = searchParams.get("proxy") === "1"; - const directImageTarget = resolveDirectImageTarget( - request, - target, - width, - directQuality, - requestedFormat - ); - if (directImageTarget) { - return redirectToDirectImage(directImageTarget); + if (!forceProxy) { + const directImageTarget = resolveDirectImageTarget( + request, + target, + width, + directQuality, + requestedFormat + ); + if (directImageTarget) { + return redirectToDirectImage(directImageTarget); + } } const cacheKey = `${target.toString()}|${width || "auto"}|${quality}|${requestedFormat || "auto"}`; diff --git a/app/api/scene-fusion/route.ts b/app/api/scene-fusion/route.ts index 270d53fdf..f1bfe9bda 100644 --- a/app/api/scene-fusion/route.ts +++ b/app/api/scene-fusion/route.ts @@ -32,10 +32,6 @@ import { type SceneFusionImageGenConfig, } from "@/lib/scene-fusion/output-capabilities"; import { acquireCanvasImageModelSlot } from "@/lib/canvas/image-concurrency"; -import { - recordAiModelHealthFailure, - recordAiModelHealthSuccess, -} from "@/lib/ai/runtime/model-health"; // 请求体 Schema const sceneFusionRequestSchema = z.object({ @@ -55,7 +51,7 @@ const sceneFusionRequestSchema = z.object({ type SceneFusionMode = "scene-fission" | "multi-product-scene" | "random-bg"; // 默认提示词(当数据库未配置时使用)- 使用"参考"的表述方式 -const FALLBACK_PROMPT = "请参考第一张图片中的产品,将其自然地融合到第二张图片的场景中。要求:1)保持产品的整体外观、颜色、形状和设计风格与第一张图一致;2)调整产品的大小和角度以适应场景;3)注意光影和透视关系使融合自然。"; +const FALLBACK_PROMPT = "请参考第一张图片中的产品,将其自然地融合到第二张图片的场景中。要求:1)必须保持产品的核心结构、轮廓比例、部件数量、材质颜色和设计细节与第一张图一致;2)允许为了匹配参考场景调整产品角度、朝向、大小和透视,不要把角度变化当成结构改变;3)注意光影和透视关系使融合自然。"; const SCENE_FUSION_MODEL_COOLDOWN_MS = 2 * 60 * 1000; const SCENE_FUSION_MAX_ITEMS = 10; @@ -68,9 +64,9 @@ const SCENE_FUSION_ECHO_HASH_TIMEOUT_MS = 20_000; const SCENE_FUSION_ECHO_HASH_THRESHOLD = 8; const SCENE_FUSION_REVIEW_TIMEOUT_MS = 25_000; const SCENE_FUSION_STRICT_PROMPT_TAIL = - "硬性要求:严禁直接返回第一张参考图(产品图)原图,必须保留第二张图的场景与构图,仅替换目标主体;结果中不得同时完整保留原目标主体与新产品;若识别不确定,请优先保持第二张图背景不变并完成主体替换。"; + "硬性要求:严禁直接返回第一张参考图(产品图)原图,必须保留第二张图的场景与构图,仅替换目标主体;结果中不得同时完整保留原目标主体与新产品;产品可因场景透视调整角度、朝向和大小,但核心结构、轮廓比例、部件关系、材质颜色不得被改造;若识别不确定,请优先保持第二张图背景不变并完成主体替换。"; const SCENE_FUSION_RANDOM_BG_PROMPT_TAIL = - "硬性要求:背景必须与原图明显不同(颜色、结构、元素至少两项显著变化),禁止保留原背景纹理或直接回传原图;仅保留产品主体并保证主体边缘完整自然。"; + "硬性要求:背景必须与原图明显不同(颜色、结构、元素至少两项显著变化),禁止保留原背景纹理或直接回传原图;仅保留产品主体并保证主体结构、轮廓比例、部件关系和边缘完整自然,允许为新背景调整朝向、角度和透视。"; let sceneFusionRoundRobinCursor = 0; type SceneFusionModelCooldown = Map; @@ -533,12 +529,14 @@ async function reviewSceneFusionResult(params: { locationHints ? `定位提示:${locationHints}` : "", "请严格判断图3是否同时满足以下条件:", `1. 图3里已经出现了与图1同主体的${productLabel}`, - `2. 图2原本需要被替换的${targetLabel}没有被完整保留下来`, - "3. 图2的大部分场景构图、机位和背景仍被保留,只做主体替换", - "4. 图3不是图1产品原图、裁切图或仅做轻微背景变化的回传图", + `2. 图3中的${productLabel}核心结构、轮廓比例、部件数量、材质颜色和主要设计细节与图1一致`, + `3. 图2原本需要被替换的${targetLabel}没有被完整保留下来`, + "4. 图2的大部分场景构图、机位和背景仍被保留,只做主体替换", + "5. 图3不是图1产品原图、裁切图或仅做轻微背景变化的回传图", + "注意:为了匹配图2的机位和参考图角度,产品朝向、旋转、透视、大小变化是允许的;不要仅因为角度不同判失败。", "若任一条件不满足,就判定为 failed。", "仅返回 JSON:", - '{"passed":true,"score":0.0,"productMatched":true,"targetRemoved":true,"scenePreserved":true,"suspectedEcho":false,"reason":"一句话说明","confidence":0.0}', + '{"passed":true,"score":0.0,"productMatched":true,"productStructurePreserved":true,"angleAcceptable":true,"targetRemoved":true,"scenePreserved":true,"suspectedEcho":false,"reason":"一句话说明","confidence":0.0}', ] .filter(Boolean) .join("\n"); @@ -562,6 +560,10 @@ async function reviewSceneFusionResult(params: { const score = Number.isFinite(scoreRaw) ? Math.max(0, Math.min(1, scoreRaw)) : 0.5; const passedFlag = normalizeSceneFusionBoolean(parsed.passed); const productMatched = normalizeSceneFusionBoolean(parsed.productMatched); + const productStructurePreserved = normalizeSceneFusionBoolean( + parsed.productStructurePreserved ?? parsed.productMatched + ); + const angleAcceptable = normalizeSceneFusionBoolean(parsed.angleAcceptable); const targetRemoved = normalizeSceneFusionBoolean(parsed.targetRemoved); const scenePreserved = normalizeSceneFusionBoolean(parsed.scenePreserved); const suspectedEcho = normalizeSceneFusionBoolean(parsed.suspectedEcho); @@ -569,6 +571,8 @@ async function reviewSceneFusionResult(params: { normalizeSceneFusionHint(parsed.reason, 80) || [ productMatched === false ? "未识别出参考产品主体" : "", + productStructurePreserved === false ? "产品核心结构被改变" : "", + angleAcceptable === false ? "产品角度透视不适合场景" : "", targetRemoved === false ? "原目标主体仍明显保留" : "", scenePreserved === false ? "原场景结构改动过大" : "", suspectedEcho === true ? "结果疑似回传产品图" : "", @@ -580,10 +584,12 @@ async function reviewSceneFusionResult(params: { const passed = passedFlag === true && productMatched !== false && + productStructurePreserved !== false && + angleAcceptable !== false && targetRemoved !== false && scenePreserved !== false && suspectedEcho !== true && - score >= 0.55; + score >= 0.5; console.log(`[SceneFusion][${params.requestId}] Review result:`, { sceneIndex: params.sceneIndex + 1, @@ -591,6 +597,8 @@ async function reviewSceneFusionResult(params: { passed, score, reason, + productStructurePreserved, + angleAcceptable, reviewModel: model, }); @@ -1385,15 +1393,15 @@ function buildPrompt( if (sceneMode === "random-bg") { const subject = productName || "产品"; - prompt = `请以第一张图片中的${subject}为唯一主体,对第二张图片进行背景重绘。要求:1)${subject}外观、材质、颜色与细节保持一致;2)允许轻微调整主体位置与角度以匹配新背景;3)背景需要完整重建并与原图明显不同;4)光影透视自然,输出商业摄影质感。`; + prompt = `请以第一张图片中的${subject}为唯一主体,对第二张图片进行背景重绘。要求:1)必须保持${subject}核心结构、轮廓比例、部件数量、材质颜色和主要设计细节与第一张图一致,不要改造成另一款产品;2)允许调整主体位置、大小、角度、朝向和透视以匹配新背景,不要把角度变化当成结构改变;3)背景需要完整重建并与原图明显不同;4)光影透视自然,输出商业摄影质感。`; } else { const subject = productName || "产品"; const targetSubject = targetName || "场景主主体"; const locationHints = [positionHint, replaceCue].filter(Boolean).join(","); if (targetName) { - prompt = `请参考第一张图片中的${subject},仅替换第二张图片中的${targetSubject},不要改成全新场景。要求:1)保持${subject}的整体外观、材质、颜色与结构细节和第一张图一致;2)先移除第二张图中原有的${targetSubject},再将${subject}放入其原位置;3)调整${subject}的大小、角度、光影和透视,使其与第二张图自然融合;4)结果中不得同时完整保留原${targetSubject}和新的${subject}。`; + prompt = `请参考第一张图片中的${subject},仅替换第二张图片中的${targetSubject},不要改成全新场景。要求:1)必须保持${subject}核心结构、轮廓比例、部件数量、材质颜色和主要设计细节与第一张图一致,不要增删关键部件或改造成另一款产品;2)先移除第二张图中原有的${targetSubject},再将${subject}放入其原位置;3)允许调整${subject}的大小、角度、朝向、光影和透视,使其与第二张图自然融合,角度变化本身是允许的;4)结果中不得同时完整保留原${targetSubject}和新的${subject}。`; } else { - prompt = `请参考第一张图片中的${subject},将其自然地融合到第二张图片的场景主主体位置。要求:1)保持${subject}的整体外观、颜色、形状和设计风格与第一张图一致;2)优先替换第二张图中最主要的同类主体,不要只做风格微调;3)注意光影和透视关系使融合自然。`; + prompt = `请参考第一张图片中的${subject},将其自然地融合到第二张图片的场景主主体位置。要求:1)必须保持${subject}核心结构、轮廓比例、部件数量、材质颜色和主要设计细节与第一张图一致,不要改造成另一款产品;2)优先替换第二张图中最主要的同类主体,不要只做风格微调;3)允许为了匹配场景调整${subject}角度、朝向、大小和透视,角度变化不代表结构变化;4)注意光影和透视关系使融合自然。`; } if (locationHints) { prompt = `${prompt} 待替换目标定位:${locationHints}。`; @@ -1870,7 +1878,6 @@ async function processSceneWithDiversity( for (let attemptIndex = 0; attemptIndex < attemptCandidates.length; attemptIndex += 1) { const candidate = attemptCandidates[attemptIndex]!; - const attemptStartedAt = Date.now(); try { const normalizedOutputParams = normalizeSceneFusionOutputParamsForCandidate(candidate, { aspectRatio: params.aspectRatio, @@ -1954,18 +1961,6 @@ async function processSceneWithDiversity( } markSceneFusionCandidateHealthy(candidate, params.modelCooldown); - await recordAiModelHealthSuccess({ - model: { - id: candidate.model.id, - healthStatus: "healthy", - }, - }).catch((healthError) => { - console.warn("[SceneFusion] Record model health success failed:", { - requestId: params.requestId, - model: candidate.model.modelId, - error: healthError instanceof Error ? healthError.message : String(healthError), - }); - }); return { result, candidate, @@ -1975,34 +1970,8 @@ async function processSceneWithDiversity( const errorMessage = error instanceof Error ? error.message : "未知错误"; failureReasons.push(`${candidate.model.displayName}: ${errorMessage}`); markSceneFusionCandidateFailed(candidate, params.modelCooldown); - await recordAiModelHealthFailure({ - model: { - id: candidate.model.id, - modelId: candidate.model.modelId, - displayName: candidate.model.displayName, - excludeFromFailover: Boolean(candidate.model.excludeFromFailover), - }, - provider: { - id: candidate.provider.id, - name: candidate.provider.name, - type: candidate.provider.type, - }, - error, - context: "scene-fusion", - elapsedMs: Date.now() - attemptStartedAt, - requestId: params.requestId, - metadata: { - sceneIndex: params.sceneIndex, - attempt: attemptIndex + 1, - sceneMode: params.sceneMode, - }, - }).catch((healthError) => { - console.warn("[SceneFusion] Record model health failure failed:", { - requestId: params.requestId, - model: candidate.model.modelId, - error: healthError instanceof Error ? healthError.message : String(healthError), - }); - }); + // Batch processing can fail because of per-image inputs, review gates, or echo checks. + // Keep these failures local to this request so they do not auto-disable shared models. console.error("[SceneFusion] Candidate failed, try next:", { requestId: params.requestId, sceneIndex: params.sceneIndex, diff --git a/components/agents/AgentDetailReferenceStitchDialog.tsx b/components/agents/AgentDetailReferenceStitchDialog.tsx index 0d46772a8..1d09a1bfa 100644 --- a/components/agents/AgentDetailReferenceStitchDialog.tsx +++ b/components/agents/AgentDetailReferenceStitchDialog.tsx @@ -7,6 +7,7 @@ import { useRef, useState, type PointerEvent as ReactPointerEvent, + type SyntheticEvent, } from "react"; import { Loader2, Plus, RotateCcw, Scissors, Trash2 } from "lucide-react"; import { toast } from "sonner"; @@ -19,6 +20,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; +import { buildImageProxyUrl } from "@/lib/image-delivery"; import type { AgentDetailReplacementReferenceItem } from "@/types/agent"; const MIN_SLICE_HEIGHT = 120; @@ -27,6 +29,8 @@ type StitchSourceSegment = { card: AgentDetailReplacementReferenceItem; top: number; height: number; + sourceWidth?: number; + sourceHeight?: number; }; type MergedSnapshot = { @@ -62,6 +66,17 @@ function clamp(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)); } +function buildStitchPreviewImageUrl(url: string, width: number) { + const normalizedUrl = String(url || "").trim(); + if (!normalizedUrl) return ""; + return buildImageProxyUrl(normalizedUrl, { + width: Math.min(Math.max(Math.round(width || 720), 96), 1200), + quality: 84, + format: "jpeg", + delivery: "proxy", + }); +} + interface AgentDetailReferenceStitchDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -89,6 +104,7 @@ export function AgentDetailReferenceStitchDialog({ const [loadingProgressText, setLoadingProgressText] = useState(""); const [errorText, setErrorText] = useState(null); const [warningText, setWarningText] = useState(null); + const [previewImageLoadFailed, setPreviewImageLoadFailed] = useState(false); const [passthroughSegmentIndexes, setPassthroughSegmentIndexes] = useState([]); const validCards = useMemo( @@ -135,6 +151,14 @@ export function AgentDetailReferenceStitchDialog({ () => new Set(passthroughSegmentIndexes), [passthroughSegmentIndexes] ); + const mergedPreviewDisplayUrl = useMemo( + () => + snapshot + ? buildStitchPreviewImageUrl(snapshot.previewUrl, snapshot.width) + : "", + [snapshot] + ); + const useVirtualPreview = previewImageLoadFailed || !mergedPreviewDisplayUrl; useEffect(() => { if (!open) { @@ -143,6 +167,7 @@ export function AgentDetailReferenceStitchDialog({ setPreviewCutLineY(null); setLoadingProgressText(""); setWarningText(null); + setPreviewImageLoadFailed(false); setPassthroughSegmentIndexes([]); return; } @@ -161,7 +186,8 @@ export function AgentDetailReferenceStitchDialog({ setIsLoading(true); setErrorText(null); setWarningText(null); - setLoadingProgressText(`正在服务端并行拼合 ${setupCards.length} 张参考图`); + setPreviewImageLoadFailed(false); + setLoadingProgressText(`正在读取 ${setupCards.length} 张参考图尺寸`); const response = await fetch("/api/agent-workflow/detail-stitch", { method: "POST", @@ -199,7 +225,7 @@ export function AgentDetailReferenceStitchDialog({ .filter((value) => Number.isFinite(value) && value > 0 && value < height) : []; - if (!previewUrl || !width || !height || sourceSegments.length === 0) { + if (!width || !height || sourceSegments.length === 0) { throw new Error("拼合结果无效,请稍后重试"); } @@ -210,6 +236,7 @@ export function AgentDetailReferenceStitchDialog({ defaultCutLines, sourceSegments, }); + setPreviewImageLoadFailed(!previewUrl); setCutLines(defaultCutLines); setPassthroughSegmentIndexes([]); setWarningText( @@ -390,6 +417,35 @@ export function AgentDetailReferenceStitchDialog({ [] ); + const handleMergedPreviewImageError = useCallback( + (event: SyntheticEvent) => { + const image = event.currentTarget; + const fallbackUrl = String(snapshot?.previewUrl || "").trim(); + if (fallbackUrl && image.dataset.originalFallbackApplied !== "1") { + image.dataset.originalFallbackApplied = "1"; + image.src = fallbackUrl; + return; + } + setPreviewImageLoadFailed(true); + setWarningText((current) => current || "拼合预览图加载失败,已切换为分段预览。"); + }, + [snapshot?.previewUrl] + ); + + const handleSegmentPreviewImageError = useCallback( + (originalUrl: string) => (event: SyntheticEvent) => { + const image = event.currentTarget; + const fallbackUrl = String(originalUrl || "").trim(); + if (fallbackUrl && image.dataset.originalFallbackApplied !== "1") { + image.dataset.originalFallbackApplied = "1"; + image.src = fallbackUrl; + return; + } + image.style.opacity = "0"; + }, + [] + ); + const handleApply = useCallback(async () => { if (!snapshot) { toast.error("当前还没有可用的拼合图"); @@ -546,7 +602,7 @@ export function AgentDetailReferenceStitchDialog({
{loadingProgressText || "正在拼合参考图..."}
-
会按当前参考图顺序从上到下拼成一张长图
+
会按当前参考图顺序生成虚拟长图,不再上传超长拼合图
) : errorText ? (
@@ -570,15 +626,49 @@ export function AgentDetailReferenceStitchDialog({ onPointerLeave={handlePreviewPointerLeave} onClick={handlePreviewClick} > - 拼合长图预览 + {useVirtualPreview ? ( +
+ {snapshot.sourceSegments.map((segment, index) => { + const segmentImageUrl = String(segment.card.imageUrl || "").trim(); + return ( +
+
+ 参考图 {index + 1} 加载失败 +
+ {segmentImageUrl ? ( + {`拼合分段预览 2 ? "lazy" : "eager"} + onError={handleSegmentPreviewImageError(segmentImageUrl)} + /> + ) : null} +
+ ); + })} +
+ ) : ( + 拼合长图预览 + )}
{isAddingCutLine && previewCutLineY !== null ? ( diff --git a/lib/ai/runtime/model-health.ts b/lib/ai/runtime/model-health.ts index 72c0e9f32..9722e48f8 100644 --- a/lib/ai/runtime/model-health.ts +++ b/lib/ai/runtime/model-health.ts @@ -1,6 +1,6 @@ import "server-only"; -import { and, desc, eq, inArray, lte, sql } from "drizzle-orm"; +import { and, desc, eq, inArray, sql } from "drizzle-orm"; import { db } from "@/lib/db"; import { aiModel, @@ -188,10 +188,25 @@ export async function reviveAiModelsFromCooldown(params?: { modelId?: string }) const now = new Date(); const cooldownCutoff = getAutoDisableCooldownCutoff(now); const conditions = [ - eq(aiModel.healthStatus, "auto_disabled"), eq(aiModel.isEnabled, false), eq(aiModel.excludeFromFailover, false), - lte(aiModel.healthAutoDisabledAt, cooldownCutoff), + sql`COALESCE(${aiModel.healthAutoDisabledAt}, ${aiModel.healthLastFailureAt}, ${aiModel.healthUpdatedAt}) <= ${cooldownCutoff}`, + sql`( + ${aiModel.healthStatus} = 'auto_disabled' + OR ( + ${aiModel.healthStatus} = 'degraded' + AND ${aiModel.healthLastFailureAt} IS NOT NULL + AND ${aiModel.healthConsecutiveFailures} > 0 + AND ${aiModel.healthLastEventType} IN ( + 'timeout', + 'quota', + 'unavailable', + 'invalid_model', + 'download_error', + 'error' + ) + ) + )`, ]; if (params?.modelId) { @@ -203,6 +218,9 @@ export async function reviveAiModelsFromCooldown(params?: { modelId?: string }) id: aiModel.id, modelId: aiModel.modelId, displayName: aiModel.displayName, + healthStatus: aiModel.healthStatus, + healthAutoDisabledAt: aiModel.healthAutoDisabledAt, + healthLastFailureAt: aiModel.healthLastFailureAt, }) .from(aiModel) .where(and(...conditions)); @@ -237,6 +255,7 @@ export async function reviveAiModelsFromCooldown(params?: { modelId?: string }) id: item.id, modelId: item.modelId, displayName: item.displayName, + previousStatus: item.healthStatus, })), }); } diff --git a/lib/db/queries.ts b/lib/db/queries.ts index 7cb30e85b..ce4c3979a 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -4204,6 +4204,17 @@ export async function updateAiModel({ updateData.healthRecoverySuccessStreak = 0; updateData.healthObservationStartedAt = null; updateData.healthUpdatedAt = new Date(); + } else { + updateData.healthStatus = "healthy"; + updateData.healthConsecutiveFailures = 0; + updateData.healthLastEventType = null; + updateData.healthLastError = null; + updateData.healthLastFailureAt = null; + updateData.healthAutoDisabledAt = null; + updateData.healthAutoDisabledReason = null; + updateData.healthRecoverySuccessStreak = 0; + updateData.healthObservationStartedAt = null; + updateData.healthUpdatedAt = new Date(); } } if (isHidden !== undefined) updateData.isHidden = isHidden; diff --git a/lib/image-delivery.ts b/lib/image-delivery.ts index 99bb21543..e9d56ef35 100644 --- a/lib/image-delivery.ts +++ b/lib/image-delivery.ts @@ -108,6 +108,7 @@ export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {}) const params = new URLSearchParams(); params.set("url", source); + if (options.delivery === "proxy") params.set("proxy", "1"); if (width) params.set("w", String(width)); if (quality) params.set("q", String(quality)); if (format) params.set("f", format);