fix: stream agent replacement results incrementally

This commit is contained in:
Your Name 2026-03-22 20:43:50 +08:00
parent 5fc5a5e47c
commit 29f94248f4
3 changed files with 207 additions and 147 deletions

View File

@ -5948,7 +5948,6 @@ export async function POST(request: Request) {
(isDetailReplacementPreset || isSkuReplacementPreset)
) {
const replacementSlot = orderedSlots[0];
const replacementAssistantMessageId = generateUUID();
const replacementTemplateReferenceImageUrls = Array.from(
new Set(
(replacementSlot?.referenceAssets || [])
@ -6000,12 +5999,6 @@ export async function POST(request: Request) {
typeof mergedImageParams.imageSize === "string"
? mergedImageParams.imageSize
: effectiveImageParams?.imageSize || defaultImageQualityParams.imageSize;
const replacementSections: string[] = [];
const replacementResultImages: Array<{
originalUrl: string;
thumbnailUrl?: string | null;
}> = [];
let generatedReplacementCount = 0;
let resolvedModelDisplayName =
selectedAgentPreset.defaultModel?.displayName || selectedAgentPreset.name;
const replacementVisionModels = await getEnabledAiModelsWithProvider().catch(
@ -6022,6 +6015,103 @@ export async function POST(request: Request) {
"agent-preset:replacement",
12_000,
);
const emitReplacementResultMessage = async (params: {
reference: (typeof replacementReferences)[number];
bodyText?: string | null;
image: {
originalUrl: string;
thumbnailUrl?: string | null;
};
modelDisplayName?: string | null;
sourceImageUrls: string[];
sourceType: "detail_replacement" | "sku_replacement";
streamLabel: string;
}) => {
const assistantText = prependImageModelBadge(
[
`### ${params.reference.label}`,
String(params.bodyText || "").trim(),
buildAgentToolImagesMarkdown([
{
originalUrl: params.image.originalUrl,
thumbnailUrl: params.image.thumbnailUrl,
imageSize: resolvedImageSize,
},
]),
]
.filter(Boolean)
.join("\n\n"),
params.modelDisplayName || null,
);
const assistantMessageId = generateUUID();
await saveMessages({
messages: [
{
id: assistantMessageId,
chatId: id,
role: "assistant",
parts: [{ type: "text", text: assistantText }],
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
});
await saveGeneratedImages(
assistantText,
session.user.id,
id,
assistantMessageId,
baseReplacementPrompt || selectedAgentPreset.name,
resolvedModelRef,
{
sourceAssetIds,
sourceImageUrls: params.sourceImageUrls,
relationType: "edited_from",
sourceType: params.sourceType,
projectId: contextPayload?.projectId || null,
userPrompt: requestTextContent,
optimizedPrompt: baseReplacementPrompt || undefined,
requestedImageCount: 1,
aspectRatio: resolvedAspectRatio,
imageSize: resolvedImageSize,
},
);
writeAssistantTextChunk(
writer,
assistantText,
params.streamLabel,
assistantMessageId,
);
if (contextPayload?.source === "canvas") {
writeCanvasMediaChunk(
writer,
{
messageId: assistantMessageId,
imageUrls: [params.image.originalUrl],
prompt: baseReplacementPrompt || selectedAgentPreset.name,
sourceImageUrls: params.sourceImageUrls,
agentPresetId: selectedAgentPreset.id,
agentPresetName: selectedAgentPreset.name,
agentCategoryId:
selectedAgentPreset.category?.id ||
selectedAgentPreset.categoryId ||
null,
agentCategoryName: selectedAgentPreset.category?.name || null,
agentExecutionSnapshotId: presetSnapshot.id,
},
`${params.streamLabel}:canvas-media`,
);
}
};
try {
for (const [referenceIndex, reference] of replacementReferences.entries()) {
@ -6042,23 +6132,32 @@ export async function POST(request: Request) {
imageUrl: reference.imageUrl,
models: replacementVisionModels,
});
const replacementSourceType = isDetailReplacementPreset
? "detail_replacement"
: "sku_replacement";
const referenceSourceImageUrls = Array.from(
new Set([
...sharedUploadedImageUrls,
...replacementTemplateReferenceImageUrls,
reference.imageUrl,
]),
);
if (!referenceDecision.shouldReplace) {
replacementResultImages.push({
originalUrl: reference.imageUrl,
thumbnailUrl: reference.imageUrl,
await emitReplacementResultMessage({
reference,
bodyText: referenceDecision.reason,
image: {
originalUrl: reference.imageUrl,
thumbnailUrl: reference.imageUrl,
},
modelDisplayName: null,
sourceImageUrls: referenceSourceImageUrls,
sourceType: replacementSourceType,
streamLabel: isDetailReplacementPreset
? "agent-preset:image:detail-replacement:item"
: "agent-preset:image:sku-replacement:item",
});
replacementSections.push(
`### ${reference.label}\n\n${referenceDecision.reason}\n\n${buildAgentToolImagesMarkdown(
[
{
originalUrl: reference.imageUrl,
thumbnailUrl: reference.imageUrl,
imageSize: resolvedImageSize,
},
],
)}`,
);
successCount += 1;
continue;
}
@ -6105,132 +6204,53 @@ export async function POST(request: Request) {
if (!firstImage) {
throw new Error("未返回结果图片");
}
replacementResultImages.push(firstImage);
replacementSections.push(
`### ${reference.label}\n\n${buildAgentToolImagesMarkdown([
{
originalUrl: firstImage.originalUrl,
thumbnailUrl: firstImage.thumbnailUrl,
imageSize: resolvedImageSize,
},
])}`,
);
await emitReplacementResultMessage({
reference,
image: firstImage,
modelDisplayName: resolvedModelDisplayName,
sourceImageUrls: Array.from(
new Set([
...sharedUploadedImageUrls,
...replacementTemplateReferenceImageUrls,
...replacementReferences.map((item) => item.imageUrl),
]),
),
sourceType: replacementSourceType,
streamLabel: isDetailReplacementPreset
? "agent-preset:image:detail-replacement:item"
: "agent-preset:image:sku-replacement:item",
});
successCount += 1;
generatedReplacementCount += 1;
} catch (replacementItemError) {
console.error("[Chat API] Agent replacement item execution failed:", {
referenceIndex,
referenceLabel: reference.label,
error: replacementItemError,
});
replacementSections.push(
`### ${reference.label}\n\n执行失败:${
await emitReplacementResultMessage({
reference,
bodyText: `生成失败,当前先沿用参考图:${
replacementItemError instanceof Error
? replacementItemError.message
: "请稍后重试"
}`,
);
image: {
originalUrl: reference.imageUrl,
thumbnailUrl: reference.imageUrl,
},
modelDisplayName: null,
sourceImageUrls: referenceSourceImageUrls,
sourceType: replacementSourceType,
streamLabel: isDetailReplacementPreset
? "agent-preset:image:detail-replacement:item-fallback"
: "agent-preset:image:sku-replacement:item-fallback",
});
successCount += 1;
}
}
} finally {
stopKeepalive();
}
const assistantText = prependImageModelBadge(
[
`### ${selectedAgentPreset.name}`,
...replacementSections,
]
.filter(Boolean)
.join("\n\n"),
generatedReplacementCount > 0 ? resolvedModelDisplayName : null,
);
await saveMessages({
messages: [
{
id: replacementAssistantMessageId,
chatId: id,
role: "assistant",
parts: [{ type: "text", text: assistantText }],
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
});
if (replacementResultImages.length > 0) {
await saveGeneratedImages(
assistantText,
session.user.id,
id,
replacementAssistantMessageId,
baseReplacementPrompt || selectedAgentPreset.name,
resolvedModelRef,
{
sourceAssetIds,
sourceImageUrls: Array.from(
new Set([
...sharedUploadedImageUrls,
...replacementTemplateReferenceImageUrls,
...replacementReferences.map((item) => item.imageUrl),
]),
),
relationType: "edited_from",
sourceType: isDetailReplacementPreset
? "detail_replacement"
: "sku_replacement",
projectId: contextPayload?.projectId || null,
userPrompt: requestTextContent,
optimizedPrompt: baseReplacementPrompt || undefined,
requestedImageCount: replacementResultImages.length,
aspectRatio: resolvedAspectRatio,
imageSize: resolvedImageSize,
},
);
}
writeAssistantTextChunk(
writer,
assistantText,
isDetailReplacementPreset
? "agent-preset:image:detail-replacement"
: "agent-preset:image:sku-replacement",
replacementAssistantMessageId,
);
if (contextPayload?.source === "canvas" && replacementResultImages.length > 0) {
writeCanvasMediaChunk(
writer,
{
messageId: replacementAssistantMessageId,
imageUrls: replacementResultImages.map((image) => image.originalUrl),
prompt: baseReplacementPrompt || selectedAgentPreset.name,
sourceImageUrls: Array.from(
new Set([
...sharedUploadedImageUrls,
...replacementTemplateReferenceImageUrls,
...replacementReferences.map((item) => item.imageUrl),
]),
),
agentPresetId: selectedAgentPreset.id,
agentPresetName: selectedAgentPreset.name,
agentCategoryId:
selectedAgentPreset.category?.id ||
selectedAgentPreset.categoryId ||
null,
agentCategoryName: selectedAgentPreset.category?.name || null,
agentExecutionSnapshotId: presetSnapshot.id,
},
isDetailReplacementPreset
? "agent-preset:image:detail-replacement:canvas-media"
: "agent-preset:image:sku-replacement:canvas-media",
);
}
} else if (selectedAgentPreset.type === "image" && isSceneGenerationPreset) {
const sceneSlot = orderedSlots[0];
const sceneAssistantMessageId = generateUUID();

View File

@ -834,6 +834,20 @@ export function AgentGenerationWorkspaceDarkV2({
() => Array.from(new Set(generatedGroups.flatMap((group) => group.imageUrls))),
[generatedGroups]
);
const replacementGeneratedGroupsAfterLastUser = useMemo(() => {
const lastUserIndex = [...messages]
.map((message) => message.role)
.lastIndexOf("user");
if (lastUserIndex < 0) {
return [] as GeneratedImageGroup[];
}
return messages
.slice(lastUserIndex + 1)
.filter((message) => message.role === "assistant")
.map((message) => generatedGroups.find((group) => group.messageId === message.id) || null)
.filter((group): group is GeneratedImageGroup => Boolean(group));
}, [generatedGroups, messages]);
const latestGeneratedGroup = useMemo(() => {
const lastUserIndex = [...messages]
.map((message) => message.role)
@ -857,7 +871,11 @@ export function AgentGenerationWorkspaceDarkV2({
}
return generatedGroups[generatedGroups.length - 1] || null;
}, [generatedGroups, messages]);
const latestReplacementResultUrls = latestGeneratedGroup?.imageUrls || [];
const latestReplacementResultUrls = useMemo(
() =>
replacementGeneratedGroupsAfterLastUser.flatMap((group) => group.imageUrls),
[replacementGeneratedGroupsAfterLastUser]
);
const displayedResultUrl = selectedResultUrl || latestGeneratedGroup?.imageUrls[0] || null;
const isGenerating = status === "streaming" || status === "submitted";
const detailResultCards = useMemo(
@ -2240,10 +2258,21 @@ export function AgentGenerationWorkspaceDarkV2({
)}
{selectedModelAttachment ? (
<div className="rounded-[22px] border border-emerald-400/20 bg-emerald-500/[0.06] p-3">
<div className="text-xs font-medium text-emerald-200">模特图</div>
<div className="mt-1 truncate text-[11px] text-emerald-100/70">
{selectedModelAttachment.name || "已选择模特图"}
<div className="overflow-hidden rounded-[22px] border border-emerald-400/20 bg-emerald-500/[0.06]">
<div className="relative aspect-[5/4] overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.12),transparent_58%),linear-gradient(180deg,#08110d_0%,#101916_100%)]">
<img
src={buildPreviewImageUrl(selectedModelAttachment.url, 720)}
alt={selectedModelAttachment.name || "模特图"}
className="h-full w-full object-cover"
/>
<div className="absolute left-3 top-3 rounded-full border border-emerald-300/25 bg-black/45 px-2.5 py-1 text-[11px] text-emerald-100">
模特图
</div>
</div>
<div className="border-t border-emerald-400/15 px-3 py-2">
<div className="truncate text-xs font-medium text-emerald-100">
{selectedModelAttachment.name || "已选择模特图"}
</div>
</div>
</div>
) : null}
@ -2402,7 +2431,7 @@ export function AgentGenerationWorkspaceDarkV2({
<div
key={card.id}
className={cn(
"cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
isActive
? "border-emerald-400/40 shadow-[0_0_0_1px_rgba(52,211,153,0.15)]"
: "border-white/10"
@ -2481,7 +2510,7 @@ export function AgentGenerationWorkspaceDarkV2({
<div
key={`${card.id}-result`}
className={cn(
"cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
isActive
? "border-emerald-400/40 shadow-[0_0_0_1px_rgba(52,211,153,0.15)]"
: "border-white/10"
@ -2618,10 +2647,21 @@ export function AgentGenerationWorkspaceDarkV2({
)}
{selectedModelAttachment ? (
<div className="rounded-[22px] border border-emerald-400/20 bg-emerald-500/[0.06] p-3">
<div className="text-xs font-medium text-emerald-200">模特图</div>
<div className="mt-1 truncate text-[11px] text-emerald-100/70">
{selectedModelAttachment.name || "已选择模特图"}
<div className="overflow-hidden rounded-[22px] border border-emerald-400/20 bg-emerald-500/[0.06]">
<div className="relative aspect-[5/4] overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.12),transparent_58%),linear-gradient(180deg,#08110d_0%,#101916_100%)]">
<img
src={buildPreviewImageUrl(selectedModelAttachment.url, 720)}
alt={selectedModelAttachment.name || "模特图"}
className="h-full w-full object-cover"
/>
<div className="absolute left-3 top-3 rounded-full border border-emerald-300/25 bg-black/45 px-2.5 py-1 text-[11px] text-emerald-100">
模特图
</div>
</div>
<div className="border-t border-emerald-400/15 px-3 py-2">
<div className="truncate text-xs font-medium text-emerald-100">
{selectedModelAttachment.name || "已选择模特图"}
</div>
</div>
</div>
) : null}
@ -2799,7 +2839,7 @@ export function AgentGenerationWorkspaceDarkV2({
<div
key={card.id}
className={cn(
"cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
activeSkuReferenceCardId === card.id
? "border-emerald-400/40 shadow-[0_0_0_1px_rgba(52,211,153,0.15)]"
: "border-white/10"
@ -2920,7 +2960,7 @@ export function AgentGenerationWorkspaceDarkV2({
<div
key={`${card.id}-result`}
className={cn(
"cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
"w-full cursor-pointer overflow-hidden rounded-[22px] border bg-black/20 transition-colors",
activeSkuReferenceCardId === card.id
? "border-emerald-400/40 shadow-[0_0_0_1px_rgba(52,211,153,0.15)]"
: "border-white/10"

View File

@ -180,8 +180,8 @@ export function AgentPresetCardDark({
) : null}
</div>
<div className="flex items-center gap-2.5">
<div className="relative h-[64px] w-[64px] shrink-0 overflow-hidden rounded-[18px] border border-white/10 bg-black/30">
<div className="flex items-stretch gap-3">
<div className="relative w-[88px] min-h-[112px] shrink-0 overflow-hidden rounded-[20px] border border-white/10 bg-black/30">
{previewUrl ? (
<img
src={previewUrl}
@ -199,7 +199,7 @@ export function AgentPresetCardDark({
</div>
</div>
<div className="min-w-0 flex-1 pr-7">
<div className="flex min-h-[112px] min-w-0 flex-1 flex-col justify-between pr-7">
<div className="line-clamp-2 text-[13px] font-semibold leading-5 text-white">
{preset.name}
</div>