fix: improve detail stitch and model recovery
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-14 16:35:16 +08:00
parent 359b964033
commit 769862cee9
7 changed files with 272 additions and 151 deletions

View File

@ -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<string, Promise<Buffer>>();
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 });
}

View File

@ -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"}`;

View File

@ -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<string, number>;
@ -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,

View File

@ -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<string | null>(null);
const [warningText, setWarningText] = useState<string | null>(null);
const [previewImageLoadFailed, setPreviewImageLoadFailed] = useState(false);
const [passthroughSegmentIndexes, setPassthroughSegmentIndexes] = useState<number[]>([]);
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<HTMLImageElement, Event>) => {
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<HTMLImageElement, Event>) => {
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({
<div className="text-sm text-white/75">
{loadingProgressText || "正在拼合参考图..."}
</div>
<div className="text-xs text-white/42">会按当前参考图顺序从上到下拼成一张长图</div>
<div className="text-xs text-white/42">会按当前参考图顺序生成虚拟长图,不再上传超长拼合图</div>
</div>
) : errorText ? (
<div className="flex min-h-[420px] flex-col items-center justify-center rounded-[24px] border border-dashed border-red-400/20 bg-red-500/[0.05] px-6 text-center">
@ -570,15 +626,49 @@ export function AgentDetailReferenceStitchDialog({
onPointerLeave={handlePreviewPointerLeave}
onClick={handlePreviewClick}
>
<img
src={snapshot.previewUrl}
alt="拼合长图预览"
className="block h-auto w-full select-none"
width={snapshot.width}
height={snapshot.height}
draggable={false}
decoding="async"
/>
{useVirtualPreview ? (
<div className="w-full bg-white">
{snapshot.sourceSegments.map((segment, index) => {
const segmentImageUrl = String(segment.card.imageUrl || "").trim();
return (
<div
key={`${segment.card.id || "segment"}-${index}-${segment.top}`}
className="relative overflow-hidden bg-white"
style={{
aspectRatio: `${snapshot.width} / ${Math.max(segment.height, 1)}`,
}}
>
<div className="absolute inset-0 flex items-center justify-center px-4 text-center text-xs text-neutral-400">
参考图 {index + 1} 加载失败
</div>
{segmentImageUrl ? (
<img
src={buildStitchPreviewImageUrl(segmentImageUrl, snapshot.width)}
alt={`拼合分段预览 ${index + 1}`}
className="relative z-10 block h-full w-full select-none object-fill"
draggable={false}
decoding="async"
loading={index > 2 ? "lazy" : "eager"}
onError={handleSegmentPreviewImageError(segmentImageUrl)}
/>
) : null}
</div>
);
})}
</div>
) : (
<img
key={mergedPreviewDisplayUrl || snapshot.previewUrl}
src={mergedPreviewDisplayUrl || snapshot.previewUrl}
alt="拼合长图预览"
className="block h-auto w-full select-none"
width={snapshot.width}
height={snapshot.height}
draggable={false}
decoding="async"
onError={handleMergedPreviewImageError}
/>
)}
<div className="pointer-events-none absolute inset-0">
{isAddingCutLine && previewCutLineY !== null ? (

View File

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

View File

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

View File

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