fix: refine detail replacement workflow
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-03-22 21:25:58 +08:00
parent 29f94248f4
commit b98196b96e
3 changed files with 207 additions and 55 deletions

View File

@ -6135,6 +6135,19 @@ export async function POST(request: Request) {
const replacementSourceType = isDetailReplacementPreset const replacementSourceType = isDetailReplacementPreset
? "detail_replacement" ? "detail_replacement"
: "sku_replacement"; : "sku_replacement";
console.info("[Chat API] Replacement reference decision:", {
mode: replacementSourceType,
referenceIndex: referenceIndex + 1,
totalReferences: replacementReferences.length,
referenceLabel: reference.label,
referenceImageUrl: reference.imageUrl,
pairedProductIndex:
typeof pairedProductIndex === "number" ? pairedProductIndex + 1 : null,
shouldReplace: referenceDecision.shouldReplace,
reason: referenceDecision.reason,
detectionModelName: referenceDecision.detectionModelName,
detectionSource: referenceDecision.detectionSource,
});
const referenceSourceImageUrls = Array.from( const referenceSourceImageUrls = Array.from(
new Set([ new Set([
...sharedUploadedImageUrls, ...sharedUploadedImageUrls,
@ -6144,6 +6157,13 @@ export async function POST(request: Request) {
); );
if (!referenceDecision.shouldReplace) { if (!referenceDecision.shouldReplace) {
console.info("[Chat API] Replacement reference skipped and reused:", {
mode: replacementSourceType,
referenceIndex: referenceIndex + 1,
totalReferences: replacementReferences.length,
referenceLabel: reference.label,
reason: referenceDecision.reason,
});
await emitReplacementResultMessage({ await emitReplacementResultMessage({
reference, reference,
bodyText: referenceDecision.reason, bodyText: referenceDecision.reason,
@ -6185,6 +6205,15 @@ export async function POST(request: Request) {
hasModelImage: Boolean(modelInputImageUrl), hasModelImage: Boolean(modelInputImageUrl),
pairedProductIndex, pairedProductIndex,
}); });
console.info("[Chat API] Replacement image generation started:", {
mode: replacementSourceType,
referenceIndex: referenceIndex + 1,
totalReferences: replacementReferences.length,
referenceLabel: reference.label,
pairedProductIndex:
typeof pairedProductIndex === "number" ? pairedProductIndex + 1 : null,
sourceImageCount: sourceImageUrls.length,
});
try { try {
const result = await generateCanvasImage({ const result = await generateCanvasImage({
@ -6204,6 +6233,14 @@ export async function POST(request: Request) {
if (!firstImage) { if (!firstImage) {
throw new Error("未返回结果图片"); throw new Error("未返回结果图片");
} }
console.info("[Chat API] Replacement image generation completed:", {
mode: replacementSourceType,
referenceIndex: referenceIndex + 1,
totalReferences: replacementReferences.length,
referenceLabel: reference.label,
modelDisplayName: resolvedModelDisplayName,
resultImageUrl: firstImage.originalUrl,
});
await emitReplacementResultMessage({ await emitReplacementResultMessage({
reference, reference,
image: firstImage, image: firstImage,

View File

@ -130,11 +130,22 @@ function getCandidateTargetUrls(
return target === "detail" ? candidate.detailImageUrls : candidate.skuImageUrls; return target === "detail" ? candidate.detailImageUrls : candidate.skuImageUrls;
} }
function getRenderableCandidateTargetUrls(
candidate: AgentModeReferenceCandidate,
target: "main" | "detail" | "sku"
) {
const urls = getCandidateTargetUrls(candidate, target);
if (target === "detail" && urls.length > 1) {
return urls.slice(1);
}
return urls;
}
function buildReferenceItems(params: { function buildReferenceItems(params: {
candidate: AgentModeReferenceCandidate; candidate: AgentModeReferenceCandidate;
target: "main" | "detail" | "sku"; target: "main" | "detail" | "sku";
}): AgentDetailReplacementReferenceItem[] { }): AgentDetailReplacementReferenceItem[] {
const urls = getCandidateTargetUrls(params.candidate, params.target); const urls = getRenderableCandidateTargetUrls(params.candidate, params.target);
const targetLabel = const targetLabel =
params.target === "main" params.target === "main"
? "主图参考图" ? "主图参考图"
@ -252,7 +263,7 @@ export async function GET(request: NextRequest) {
(upstreamResult.data.candidates || []).map((item, index) => normalizeCandidate(item, index)) (upstreamResult.data.candidates || []).map((item, index) => normalizeCandidate(item, index))
) )
.filter((candidate) => isCandidateMatchedStore(candidate, requestedStoreName)) .filter((candidate) => isCandidateMatchedStore(candidate, requestedStoreName))
.filter((candidate) => getCandidateTargetUrls(candidate, parsed.target).length > 0); .filter((candidate) => getRenderableCandidateTargetUrls(candidate, parsed.target).length > 0);
if (normalizedCandidates.length === 0) { if (normalizedCandidates.length === 0) {
return NextResponse.json( return NextResponse.json(

View File

@ -249,12 +249,14 @@ function buildReferenceCardsFromCandidate(params: {
const candidate = params.candidate; const candidate = params.candidate;
if (!candidate) return [] as AgentModeWorkspaceState["detailReplacement"]["references"]; if (!candidate) return [] as AgentModeWorkspaceState["detailReplacement"]["references"];
const urls = const rawUrls =
params.target === "main" params.target === "main"
? candidate.mainImageUrls || [] ? candidate.mainImageUrls || []
: params.target === "detail" : params.target === "detail"
? candidate.detailImageUrls || [] ? candidate.detailImageUrls || []
: candidate.skuImageUrls || []; : candidate.skuImageUrls || [];
const urls =
params.target === "detail" && rawUrls.length > 1 ? rawUrls.slice(1) : rawUrls;
const targetLabel = `${getTaskDetailReferenceSourceLabel(params.target)}参考图`; const targetLabel = `${getTaskDetailReferenceSourceLabel(params.target)}参考图`;
const prefix = const prefix =
candidate.sceneName || candidate.taskNo || candidate.productModel || "候选整套"; candidate.sceneName || candidate.taskNo || candidate.productModel || "候选整套";
@ -273,12 +275,13 @@ function buildReferenceCardsFromCandidate(params: {
} }
function formatCandidateMeta(candidate: AgentModeReferenceCandidate, target: AgentTaskDetailReferenceSource) { function formatCandidateMeta(candidate: AgentModeReferenceCandidate, target: AgentTaskDetailReferenceSource) {
const imageCount = const rawImageCount =
target === "main" target === "main"
? candidate.mainImageUrls.length ? candidate.mainImageUrls.length
: target === "detail" : target === "detail"
? candidate.detailImageUrls.length ? candidate.detailImageUrls.length
: candidate.skuImageUrls.length; : candidate.skuImageUrls.length;
const imageCount = target === "detail" && rawImageCount > 1 ? rawImageCount - 1 : rawImageCount;
const uploadedAt = candidate.uploadedAt ? new Date(candidate.uploadedAt) : null; const uploadedAt = candidate.uploadedAt ? new Date(candidate.uploadedAt) : null;
const timeText = const timeText =
uploadedAt && !Number.isNaN(uploadedAt.getTime()) uploadedAt && !Number.isNaN(uploadedAt.getTime())
@ -303,12 +306,14 @@ function getCandidatePreviewUrl(
candidate: AgentModeReferenceCandidate, candidate: AgentModeReferenceCandidate,
target: AgentTaskDetailReferenceSource target: AgentTaskDetailReferenceSource
) { ) {
const targetUrls = const rawTargetUrls =
target === "main" target === "main"
? candidate.mainImageUrls || [] ? candidate.mainImageUrls || []
: target === "detail" : target === "detail"
? candidate.detailImageUrls || [] ? candidate.detailImageUrls || []
: candidate.skuImageUrls || []; : candidate.skuImageUrls || [];
const targetUrls =
target === "detail" && rawTargetUrls.length > 1 ? rawTargetUrls.slice(1) : rawTargetUrls;
return ( return (
targetUrls[0] || targetUrls[0] ||
candidate.mainImageUrls[0] || candidate.mainImageUrls[0] ||
@ -321,23 +326,56 @@ function getCandidateTargetCount(
candidate: AgentModeReferenceCandidate, candidate: AgentModeReferenceCandidate,
target: AgentTaskDetailReferenceSource target: AgentTaskDetailReferenceSource
) { ) {
return target === "main" const rawCount =
? candidate.mainImageUrls.length target === "main"
: target === "detail" ? candidate.mainImageUrls.length
? candidate.detailImageUrls.length : target === "detail"
: candidate.skuImageUrls.length; ? candidate.detailImageUrls.length
: candidate.skuImageUrls.length;
return target === "detail" && rawCount > 1 ? rawCount - 1 : rawCount;
} }
type ReplacementCardStatus = "waiting" | "queued" | "generating" | "completed" | "missing"; type ReplacementCardStatus =
| "waiting"
| "queued"
| "generating"
| "completed"
| "passthrough"
| "fallback"
| "missing";
function inferReplacementCardStatusFromText(text: string | null | undefined): ReplacementCardStatus | null {
const normalized = normalizeAssistantText(String(text || ""))
.replace(/\s+/g, " ")
.trim();
if (!normalized) {
return null;
}
if (normalized.includes("生成失败,当前先沿用参考图")) {
return "fallback";
}
if (
normalized.includes("沿用参考图") ||
normalized.includes("未检测到可替换的家具主体") ||
normalized.includes("视觉判断失败")
) {
return "passthrough";
}
return "completed";
}
function getReplacementCardStatus(params: { function getReplacementCardStatus(params: {
index: number; index: number;
resultUrl: string | null; resultUrl: string | null;
resultText?: string | null;
generatedCount: number; generatedCount: number;
isGenerating: boolean; isGenerating: boolean;
}) : ReplacementCardStatus { }) : ReplacementCardStatus {
if (params.resultUrl) { if (params.resultUrl) {
return "completed"; return inferReplacementCardStatusFromText(params.resultText) || "completed";
} }
if (params.isGenerating) { if (params.isGenerating) {
return params.index === params.generatedCount ? "generating" : "queued"; return params.index === params.generatedCount ? "generating" : "queued";
@ -350,6 +388,16 @@ function getReplacementCardStatus(params: {
function getReplacementCardStatusMeta(status: ReplacementCardStatus) { function getReplacementCardStatusMeta(status: ReplacementCardStatus) {
switch (status) { switch (status) {
case "passthrough":
return {
label: "沿用参考图",
className: "border-cyan-400/30 bg-cyan-500/[0.10] text-cyan-200",
};
case "fallback":
return {
label: "失败已沿用",
className: "border-amber-400/30 bg-amber-500/[0.10] text-amber-200",
};
case "completed": case "completed":
return { return {
label: "已生成", label: "已生成",
@ -619,7 +667,7 @@ export function AgentGenerationWorkspaceDarkV2({
const hasMaterialStep = Boolean(materialSelectionPanel); const hasMaterialStep = Boolean(materialSelectionPanel);
const hasWorkflowStyleStep = Boolean(workflowStylePanel) || hasBrandModelStep; const hasWorkflowStyleStep = Boolean(workflowStylePanel) || hasBrandModelStep;
const { messages, setMessages, sendMessage, status } = useChat<ChatMessage>({ const { messages, setMessages, sendMessage, status, stop } = useChat<ChatMessage>({
id: chatId, id: chatId,
messages: historyMessages, messages: historyMessages,
experimental_throttle: 100, experimental_throttle: 100,
@ -871,48 +919,78 @@ export function AgentGenerationWorkspaceDarkV2({
} }
return generatedGroups[generatedGroups.length - 1] || null; return generatedGroups[generatedGroups.length - 1] || null;
}, [generatedGroups, messages]); }, [generatedGroups, messages]);
const latestReplacementResults = useMemo(
() =>
replacementGeneratedGroupsAfterLastUser.map((group) => ({
url: group.imageUrls[0] || null,
text: group.text || "",
})),
[replacementGeneratedGroupsAfterLastUser]
);
const latestReplacementResultUrls = useMemo( const latestReplacementResultUrls = useMemo(
() => () =>
replacementGeneratedGroupsAfterLastUser.flatMap((group) => group.imageUrls), latestReplacementResults
[replacementGeneratedGroupsAfterLastUser] .map((item) => item.url)
.filter((url): url is string => Boolean(url)),
[latestReplacementResults]
); );
const displayedResultUrl = selectedResultUrl || latestGeneratedGroup?.imageUrls[0] || null; const displayedResultUrl = selectedResultUrl || latestGeneratedGroup?.imageUrls[0] || null;
const isGenerating = status === "streaming" || status === "submitted"; const isGenerating = status === "streaming" || status === "submitted";
const detailResultCards = useMemo( const detailResultCards = useMemo(
() => () =>
detailReplacementReferenceCards.map((card, index) => { detailReplacementReferenceCards.map((card, index) => {
const resultUrl = latestReplacementResultUrls[index] || null; const resultMeta = latestReplacementResults[index];
const resultUrl = resultMeta?.url || null;
return { return {
...card, ...card,
index, index,
resultUrl, resultUrl,
resultText: resultMeta?.text || "",
status: getReplacementCardStatus({ status: getReplacementCardStatus({
index, index,
resultUrl, resultUrl,
generatedCount: latestReplacementResultUrls.length, resultText: resultMeta?.text || "",
generatedCount: latestReplacementResults.length,
isGenerating, isGenerating,
}), }),
}; };
}), }),
[detailReplacementReferenceCards, isGenerating, latestReplacementResultUrls] [detailReplacementReferenceCards, isGenerating, latestReplacementResults]
); );
const skuResultCards = useMemo( const skuResultCards = useMemo(
() => () =>
skuReplacementReferenceCards.map((card, index) => { skuReplacementReferenceCards.map((card, index) => {
const resultUrl = latestReplacementResultUrls[index] || null; const resultMeta = latestReplacementResults[index];
const resultUrl = resultMeta?.url || null;
return { return {
...card, ...card,
index, index,
resultUrl, resultUrl,
resultText: resultMeta?.text || "",
status: getReplacementCardStatus({ status: getReplacementCardStatus({
index, index,
resultUrl, resultUrl,
generatedCount: latestReplacementResultUrls.length, resultText: resultMeta?.text || "",
generatedCount: latestReplacementResults.length,
isGenerating, isGenerating,
}), }),
}; };
}), }),
[isGenerating, latestReplacementResultUrls, skuReplacementReferenceCards] [isGenerating, latestReplacementResults, skuReplacementReferenceCards]
);
const detailProcessedCount = useMemo(
() =>
detailResultCards.filter((card) =>
["completed", "passthrough", "fallback"].includes(card.status)
).length,
[detailResultCards]
);
const skuProcessedCount = useMemo(
() =>
skuResultCards.filter((card) =>
["completed", "passthrough", "fallback"].includes(card.status)
).length,
[skuResultCards]
); );
const sceneResultCardCount = Math.max( const sceneResultCardCount = Math.max(
allGeneratedImages.length, allGeneratedImages.length,
@ -1376,6 +1454,11 @@ export function AgentGenerationWorkspaceDarkV2({
workflowContext?.promptText, workflowContext?.promptText,
workflowContext?.summaryText, workflowContext?.summaryText,
]); ]);
const handleStopGeneration = useCallback(() => {
stop();
setMessages((current) => current);
toast.info("已停止当前生成任务");
}, [setMessages, stop]);
const handleDownloadOriginalImage = useCallback(async (imageUrl: string) => { const handleDownloadOriginalImage = useCallback(async (imageUrl: string) => {
const raw = String(imageUrl || "").trim(); const raw = String(imageUrl || "").trim();
@ -2088,34 +2171,47 @@ export function AgentGenerationWorkspaceDarkV2({
imageGenConfig={currentModel?.imageGenConfig || undefined} imageGenConfig={currentModel?.imageGenConfig || undefined}
/> />
<Button <div className="flex gap-3">
onClick={handleSend} <Button
disabled={ onClick={handleSend}
isGenerating || disabled={
!selectedAgentPresetId || isGenerating ||
(hasAgentUploadSlots && !hasSatisfiedSlotRequirement) !selectedAgentPresetId ||
} (hasAgentUploadSlots && !hasSatisfiedSlotRequirement)
className={cn( }
"h-11 w-full gap-2 rounded-[24px]", className={cn(
"bg-gradient-to-r from-emerald-500 to-teal-500", "h-11 flex-1 gap-2 rounded-[24px]",
"text-white font-medium", "bg-gradient-to-r from-emerald-500 to-teal-500",
"hover:from-emerald-400 hover:to-teal-400", "text-white font-medium",
"shadow-[0_0_24px_rgba(16,185,129,0.24)]", "hover:from-emerald-400 hover:to-teal-400",
"disabled:opacity-30" "shadow-[0_0_24px_rgba(16,185,129,0.24)]",
)} "disabled:opacity-30"
> )}
>
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{isReplacementPreset ? "批量替换中..." : "生成中..."}
</>
) : (
<>
<ArrowUp className="h-4 w-4" />
{isReplacementPreset ? "开始批量替换" : "开始生成"}
</>
)}
</Button>
{isGenerating ? ( {isGenerating ? (
<> <Button
<Loader2 className="h-4 w-4 animate-spin" /> type="button"
{isReplacementPreset ? "批量替换中..." : "生成中..."} variant="outline"
</> onClick={handleStopGeneration}
) : ( className="h-11 shrink-0 rounded-[24px] border-white/12 bg-black/30 px-4 text-white hover:bg-white/[0.08]"
<> >
<ArrowUp className="h-4 w-4" /> <X className="mr-2 h-4 w-4" />
{isReplacementPreset ? "开始批量替换" : "开始生成"} 停止生成
</> </Button>
)} ) : null}
</Button> </div>
{slotRequirementMessage ? ( {slotRequirementMessage ? (
<div className="rounded-[18px] border border-amber-400/20 bg-amber-500/[0.08] px-4 py-3 text-xs leading-6 text-amber-100/90"> <div className="rounded-[18px] border border-amber-400/20 bg-amber-500/[0.08] px-4 py-3 text-xs leading-6 text-amber-100/90">
{slotRequirementMessage} {slotRequirementMessage}
@ -2213,7 +2309,7 @@ export function AgentGenerationWorkspaceDarkV2({
</div> </div>
{isDetailReplacementPreset ? ( {isDetailReplacementPreset ? (
<div className="grid flex-1 min-h-0 grid-cols-1 gap-0 xl:grid-cols-[320px_minmax(0,1fr)_360px]"> <div className="grid flex-1 min-h-0 grid-cols-1 gap-0 xl:grid-cols-[320px_repeat(2,minmax(0,1fr))]">
<div className="border-b border-white/10 p-4 xl:border-b-0 xl:border-r"> <div className="border-b border-white/10 p-4 xl:border-b-0 xl:border-r">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<div> <div>
@ -2422,7 +2518,7 @@ export function AgentGenerationWorkspaceDarkV2({
</div> </div>
) : null} ) : null}
<div className="agent-dark-scrollbar max-h-[620px] space-y-3 overflow-y-auto pr-1"> <div className="agent-dark-scrollbar grid max-h-[620px] grid-cols-1 gap-3 overflow-y-auto pr-1">
{detailReplacementReferenceCards.length > 0 ? ( {detailReplacementReferenceCards.length > 0 ? (
detailReplacementReferenceCards.map((card, index) => ( detailReplacementReferenceCards.map((card, index) => (
(() => { (() => {
@ -2496,11 +2592,11 @@ export function AgentGenerationWorkspaceDarkV2({
</div> </div>
</div> </div>
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-emerald-200/80"> <div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-emerald-200/80">
已接通 已处理 {detailProcessedCount}/{detailResultCards.length || 0}
</div> </div>
</div> </div>
<div className="agent-dark-scrollbar max-h-[720px] space-y-3 overflow-y-auto pr-1"> <div className="agent-dark-scrollbar grid max-h-[720px] grid-cols-1 gap-3 overflow-y-auto pr-1">
{detailResultCards.length > 0 ? ( {detailResultCards.length > 0 ? (
detailResultCards.map((card) => ( detailResultCards.map((card) => (
(() => { (() => {
@ -2577,7 +2673,11 @@ export function AgentGenerationWorkspaceDarkV2({
)} )}
<div className="border-t border-white/10 px-3 py-2 text-[11px] text-white/45"> <div className="border-t border-white/10 px-3 py-2 text-[11px] text-white/45">
{card.resultUrl {card.resultUrl
? "已生成,可继续逐屏调整文字或重新执行。" ? card.status === "passthrough"
? "当前参考图未检测到可替换产品,本屏直接沿用参考图展示。"
: card.status === "fallback"
? "当前参考图替换失败,本屏先沿用参考图展示。"
: "已生成,可继续逐屏调整文字或重新执行。"
: card.status === "generating" : card.status === "generating"
? "当前正在生成这一屏,请稍候。" ? "当前正在生成这一屏,请稍候。"
: card.status === "queued" : card.status === "queued"
@ -2948,7 +3048,7 @@ export function AgentGenerationWorkspaceDarkV2({
</div> </div>
</div> </div>
<div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-emerald-200/80"> <div className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-emerald-200/80">
已接通 已处理 {skuProcessedCount}/{skuResultCards.length || 0}
</div> </div>
</div> </div>
@ -3022,7 +3122,11 @@ export function AgentGenerationWorkspaceDarkV2({
)} )}
<div className="border-t border-white/10 px-3 py-2 text-[11px] text-white/45"> <div className="border-t border-white/10 px-3 py-2 text-[11px] text-white/45">
{card.resultUrl {card.resultUrl
? "已生成,可继续逐张检查并后续批量下载。" ? card.status === "passthrough"
? "当前参考图未检测到可替换产品,这一张直接沿用参考图展示。"
: card.status === "fallback"
? "当前参考图替换失败,这一张先沿用参考图展示。"
: "已生成,可继续逐张检查并后续批量下载。"
: card.status === "generating" : card.status === "generating"
? "当前正在生成这一张,请稍候。" ? "当前正在生成这一张,请稍候。"
: card.status === "queued" : card.status === "queued"