ai-chatbot/lib/agents/detail-replacement-batch-runner.ts
134 0907aaff67 fix(agent): make detail replacement results survive page and server restarts
详情替换原来把整批生图挂在浏览器保持的对话流上,切换标签页、断网或连接中断后
结果就再也回不来,前端还会继续播放按时间推算的 96% 假进度。

现在改为服务器端后台批次:提交时先落任务,再按参考图逐张生成并把每张结果写进
项目,页面刷新、切页或服务重启后都能按任务状态恢复,不再依赖前端连接。

- 新增 /api/agents/detail-replacement/batches(提交/查询、幂等去重、重试上限)
- 前端只在服务端确认批次后显示处理中,卡片显示排队/生成/完成/失败真实状态
- 去掉假进度动画;没有产品输入图时提交前直接拦截
- 服务重启后自动恢复未完成批次,长批次不再被通用 30 分钟清理误判
- 单张失败只落到该卡片并继续后续图片,已完成的图片不重复生成和扣费
- 详情替换默认模型由持续 404 的 gpt-image-2.5-flare 改回 gpt-image-2.5
2026-09-26 02:39:11 +08:00

296 lines
12 KiB
TypeScript

import "server-only";
import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { canvasImageTask, canvasProject, generatedImage, message } from "@/lib/db/schema";
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
import { generateCanvasImage } from "@/lib/canvas/generate-image";
import { saveGeneratedImages } from "@/lib/ai/runtime/generated-image-persistence";
import {
buildAgentToolImagesMarkdown,
prependImageModelBadge,
} from "@/lib/ai/runtime/agent-media-markdown";
import {
selectDetailReplacementGenerationInputs,
shouldIncludeDetailReplacementModelImage,
} from "@/lib/ai/detail-replacement-inputs";
import { isRemoteImageAccessible } from "@/app/(chat)/api/chat/_lib/image-media";
import {
analyzeReplacementExecutionPlan,
buildAgentReplacementPrompt,
isMarketplaceProductDetailReferenceId,
normalizeReplacementResultImage,
resolveReplacementReferenceGeometry,
resolveReplacementScenePlanningRuntime,
} from "@/app/(chat)/api/chat/_lib/detail-replacement";
import { persistAgentDetailReplacementCard } from "@/lib/agents/agent-project-result-persistence";
import { extractAgentProjectStateFromCanvasData } from "@/lib/agents/agent-project-compat";
import {
completedDetailReplacementCardIds,
detailReplacementCardMessageId,
type DetailReplacementBatchCard,
type DetailReplacementBatchRequest,
type DetailReplacementBatchResult,
} from "@/lib/agents/detail-replacement-batch";
export async function executeDetailReplacementBatch(params: {
taskId: string;
userId: string;
request: DetailReplacementBatchRequest;
}): Promise<DetailReplacementBatchResult> {
const { taskId, userId, request } = params;
const [storedTask] = await db.select({ result: canvasImageTask.result })
.from(canvasImageTask)
.where(and(eq(canvasImageTask.id, taskId), eq(canvasImageTask.userId, userId)))
.limit(1);
const storedResult = storedTask?.result as DetailReplacementBatchResult | null;
const cards = Array.isArray(storedResult?.cards) ? [...storedResult.cards] : [];
const completedIds = completedDetailReplacementCardIds(storedResult);
const models = await getEnabledAiModelsWithProvider();
const planningRuntime = await resolveReplacementScenePlanningRuntime({ models });
const publish = async (currentCardId: string | null) => {
const result: DetailReplacementBatchResult = {
taskKind: "agent-detail-replacement-batch",
cards: [...cards],
currentCardId,
};
await db.update(canvasImageTask).set({ result, updatedAt: new Date() })
.where(and(eq(canvasImageTask.id, taskId), eq(canvasImageTask.status, "running")));
return result;
};
const persistAssistantAndAlbum = async (params: {
card: DetailReplacementBatchCard;
text: string;
prompt: string;
sourceImageUrls: string[];
}) => {
const messageId = detailReplacementCardMessageId(taskId, params.card.cardId);
await db.insert(message).values({
id: messageId,
chatId: request.chatId,
role: "assistant",
parts: [{ type: "text", text: params.text }],
attachments: [],
createdAt: new Date(),
}).onConflictDoUpdate({
target: message.id,
set: { parts: [{ type: "text", text: params.text }], createdAt: new Date() },
});
if (params.card.status !== "completed") return;
const [existingImage] = await db.select({ id: generatedImage.id })
.from(generatedImage)
.where(and(eq(generatedImage.messageId, messageId), eq(generatedImage.userId, userId)))
.limit(1);
if (existingImage) return;
await saveGeneratedImages(
params.text, userId, request.chatId, messageId, params.prompt, request.model,
{
sourceImageUrls: params.sourceImageUrls,
relationType: "edited_from",
sourceType: "detail_replacement",
projectId: request.projectId,
userPrompt: request.userPrompt,
optimizedPrompt: params.prompt,
requestedImageCount: 1,
aspectRatio: "auto",
imageSize: request.imageSize,
},
).catch((error) => {
console.error("[Detail Replacement Batch] Album persistence failed", {
taskId, cardId: params.card.cardId, error,
});
});
};
for (const [index, reference] of request.references.entries()) {
if (completedIds.has(reference.id)) continue;
await publish(reference.id);
const startedAt = Date.now();
let card: DetailReplacementBatchCard;
let sourceImageUrls = [...request.productImageUrls, reference.imageUrl];
let prompt = request.prompt;
const [storedProject] = await db.select({ canvasData: canvasProject.canvasData })
.from(canvasProject)
.where(and(eq(canvasProject.id, request.projectId), eq(canvasProject.userId, userId)))
.limit(1);
const recoveredRecord = extractAgentProjectStateFromCanvasData(storedProject?.canvasData)
.replacementResultState?.detail_replacement?.[reference.id];
if (
recoveredRecord?.url &&
recoveredRecord.status !== "fallback" &&
recoveredRecord.text.includes(`<!-- AGENT_REPLACEMENT_RUN_ID:${taskId} -->`)
) {
card = {
cardId: reference.id,
status: recoveredRecord.status,
imageUrl: recoveredRecord.url,
thumbnailUrl: recoveredRecord.url,
text: recoveredRecord.text,
};
await persistAssistantAndAlbum({
card, text: recoveredRecord.text, prompt, sourceImageUrls,
});
const previousIndex = cards.findIndex((item) => item.cardId === card.cardId);
if (previousIndex >= 0) cards.splice(previousIndex, 1);
cards.push(card);
completedIds.add(reference.id);
await publish(null);
continue;
}
try {
if (reference.passthrough) {
card = {
cardId: reference.id,
status: "passthrough",
imageUrl: reference.imageUrl,
thumbnailUrl: reference.imageUrl,
text: "已按人工标记沿用参考图,直接应用当前切片,不参与继续生成。",
};
} else {
const plan = await analyzeReplacementExecutionPlan({
modeType: "detail_replacement",
replacementStrategy: request.replacementStrategy,
referenceImageUrl: reference.imageUrl,
referenceLabel: reference.label,
productImageUrls: request.productImageUrls,
modelImageUrl: request.modelImageUrl,
workflowPrompt: request.replacementStrategy === "product_only" ? null : request.prompt,
planningRuntime,
models,
});
const geometry = await resolveReplacementReferenceGeometry(reference.imageUrl);
const selectedIndexes = plan.scenePlan.selectedProductImageIndexes
.map((value) => value - 1)
.filter((value) => value >= 0 && value < request.productImageUrls.length);
const selectedProducts = selectedIndexes.length > 0
? selectedIndexes.map((value) => request.productImageUrls[value]!)
: request.productImageUrls.slice(0, 2);
const inputs = selectDetailReplacementGenerationInputs({
productImageUrls: selectedProducts,
modelImageUrl: request.modelImageUrl,
includeModelImage: Boolean(
request.replacementStrategy !== "product_only" &&
request.modelImageUrl &&
(shouldIncludeDetailReplacementModelImage(plan.scenePlan) ||
selectedProducts.length === 0),
),
rerunReferenceImageUrls: reference.rerunReferenceImages?.map((image) => image.url),
referenceImageUrl: reference.imageUrl,
});
sourceImageUrls = inputs.imageUrls;
prompt = buildAgentReplacementPrompt({
basePrompt: request.prompt,
modeType: "detail_replacement",
replacementStrategy: request.replacementStrategy,
marketplaceProductDetailReference:
isMarketplaceProductDetailReferenceId(reference.id) ||
isMarketplaceProductDetailReferenceId(reference.candidateId),
referenceLabel: reference.label,
referenceIndex: index,
totalReferences: request.references.length,
hasModelImage: Boolean(inputs.modelImageUrl),
productImageCount: inputs.productImageUrls.length,
originalProductImageCount: request.productImageUrls.length,
rerunReferenceCount: inputs.supplementImageUrls.length,
templateReferenceCount: 0,
scenePlan: plan.scenePlan,
referenceGeometry: geometry,
});
const generated = await generateCanvasImage({
prompt,
model: request.model,
ownerKey: userId,
aspectRatio: geometry?.aspectRatio || "auto",
imageSize: request.imageSize,
imageCount: 1,
referenceImages: inputs.imageUrls,
promptExtend: false,
providerImageParams: { inputImageRoleByUrl: inputs.inputImageRoleByUrl },
debugLabel: "agent-detail-replacement-batch",
debugMeta: { projectId: request.projectId, cardId: reference.id },
});
const image = generated.images[0];
if (!image?.originalUrl) throw new Error("模型未返回结果图片");
let finalImage = image;
try {
finalImage = await normalizeReplacementResultImage({
generatedImageUrl: image.originalUrl,
generatedThumbnailUrl: image.thumbnailUrl,
referenceImageUrl: reference.imageUrl,
referenceGeometry: geometry,
scenePlan: plan.scenePlan,
preserveReferenceOutsideSubject: request.replacementStrategy === "product_only",
preserveReferenceText: true,
uploadPrefix: "agent-detail-replacement",
});
} catch (error) {
console.warn("[Detail Replacement Batch] Normalize failed, retaining generated image", {
taskId, cardId: reference.id, error,
});
if (!await isRemoteImageAccessible(image.originalUrl).catch(() => false)) {
throw error;
}
}
card = {
cardId: reference.id,
status: "completed",
imageUrl: finalImage.originalUrl,
thumbnailUrl: finalImage.thumbnailUrl || finalImage.originalUrl,
text: "",
};
}
} catch (error) {
const message = error instanceof Error ? error.message : "生成失败";
console.error("[Detail Replacement Batch] Card failed", {
taskId, cardId: reference.id, elapsedMs: Date.now() - startedAt, error,
});
card = {
cardId: reference.id,
status: "fallback",
imageUrl: reference.imageUrl,
thumbnailUrl: reference.imageUrl,
text: `生成失败,当前先沿用参考图:${message}`,
error: message,
};
}
const assistantText = prependImageModelBadge(
[
`<!-- AGENT_REPLACEMENT_CARD_ID:${reference.id} -->`,
`<!-- AGENT_REPLACEMENT_RUN_ID:${taskId} -->`,
`<!-- AGENT_REPLACEMENT_STATUS:${card.status === "completed" ? "completed" : "failed"} -->`,
`### ${reference.label}`,
card.text,
buildAgentToolImagesMarkdown([{
originalUrl: card.imageUrl,
thumbnailUrl: card.thumbnailUrl,
imageSize: request.imageSize,
}]),
].filter(Boolean).join("\n\n"),
card.status === "completed" ? request.model : null,
);
const persisted = await persistAgentDetailReplacementCard({
projectId: request.projectId,
userId,
cardId: reference.id,
imageUrl: card.imageUrl,
referenceImageUrl: reference.imageUrl,
label: reference.label,
status: card.status,
text: assistantText,
});
if (!persisted) throw new Error(`无法保存详情替换结果:${reference.id}`);
await persistAssistantAndAlbum({ card, text: assistantText, prompt, sourceImageUrls });
const previousIndex = cards.findIndex((item) => item.cardId === card.cardId);
if (previousIndex >= 0) cards.splice(previousIndex, 1);
cards.push(card);
completedIds.add(reference.id);
await publish(null);
}
return publish(null);
}