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
? "detail_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(
new Set([
...sharedUploadedImageUrls,
@ -6144,6 +6157,13 @@ export async function POST(request: Request) {
);
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({
reference,
bodyText: referenceDecision.reason,
@ -6185,6 +6205,15 @@ export async function POST(request: Request) {
hasModelImage: Boolean(modelInputImageUrl),
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 {
const result = await generateCanvasImage({
@ -6204,6 +6233,14 @@ export async function POST(request: Request) {
if (!firstImage) {
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({
reference,
image: firstImage,

View File

@ -130,11 +130,22 @@ function getCandidateTargetUrls(
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: {
candidate: AgentModeReferenceCandidate;
target: "main" | "detail" | "sku";
}): AgentDetailReplacementReferenceItem[] {
const urls = getCandidateTargetUrls(params.candidate, params.target);
const urls = getRenderableCandidateTargetUrls(params.candidate, params.target);
const targetLabel =
params.target === "main"
? "主图参考图"
@ -252,7 +263,7 @@ export async function GET(request: NextRequest) {
(upstreamResult.data.candidates || []).map((item, index) => normalizeCandidate(item, index))
)
.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) {
return NextResponse.json(

View File

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