ai-chatbot/lib/agents/detail-replacement-batch.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

106 lines
3.2 KiB
TypeScript

import { createHash } from "node:crypto";
export type DetailReplacementBatchReference = {
id: string;
label: string;
imageUrl: string;
candidateId?: string | null;
passthrough?: boolean;
rerunReferenceImages?: Array<{ url: string }>;
};
export type DetailReplacementBatchRequest = {
taskKind: "agent-detail-replacement-batch";
projectId: string;
chatId: string;
submissionKey?: string;
model: string;
prompt: string;
userPrompt: string;
imageSize: string;
replacementStrategy: "product_only" | "scene_replace";
productImageUrls: string[];
modelImageUrl: string | null;
references: DetailReplacementBatchReference[];
};
export type DetailReplacementBatchCard = {
cardId: string;
status: "completed" | "fallback" | "passthrough";
imageUrl: string;
thumbnailUrl: string;
text: string;
error?: string;
};
export type DetailReplacementBatchResult = {
taskKind: "agent-detail-replacement-batch";
cards: DetailReplacementBatchCard[];
currentCardId: string | null;
};
export function isDetailReplacementBatchRequest(
value: unknown,
): value is DetailReplacementBatchRequest {
return Boolean(
value &&
typeof value === "object" &&
!Array.isArray(value) &&
(value as { taskKind?: unknown }).taskKind === "agent-detail-replacement-batch",
);
}
export function completedDetailReplacementCardIds(
result: DetailReplacementBatchResult | null | undefined,
) {
return new Set(
(result?.cards || [])
.filter((card) => card.status === "completed" || card.status === "passthrough")
.map((card) => card.cardId),
);
}
/**
* Identity of one user submission. Two clicks that ask for exactly the same
* work produce the same key, so the second one is answered with the running
* batch instead of submitting (and billing) the same images twice. A different
* card selection - for example re-running a single finished card while a batch
* is still going - produces a different key and is allowed to start.
*/
export function detailReplacementSubmissionKey(input: {
projectId: string;
model: string;
prompt: string;
userPrompt: string;
imageSize: string;
replacementStrategy: string;
productImageUrls: string[];
modelImageUrl: string | null;
references: DetailReplacementBatchReference[];
}) {
const payload = JSON.stringify({
projectId: input.projectId,
model: input.model,
prompt: input.prompt,
userPrompt: input.userPrompt,
imageSize: input.imageSize,
strategy: input.replacementStrategy,
products: [...input.productImageUrls].sort(),
modelImage: input.modelImageUrl || "",
cards: [...input.references]
.map((item) => [
item.id,
item.imageUrl,
item.passthrough ? 1 : 0,
(item.rerunReferenceImages || []).map((entry) => entry.url).join(","),
])
.sort((left, right) => String(left[0]).localeCompare(String(right[0]))),
});
return createHash("sha256").update(payload).digest("hex");
}
export function detailReplacementCardMessageId(taskId: string, cardId: string) {
const hex = createHash("sha256").update(`${taskId}:${cardId}`).digest("hex").slice(0, 32);
return [hex.slice(0, 8), hex.slice(8, 12), hex.slice(12, 16), hex.slice(16, 20), hex.slice(20, 32)].join("-");
}