详情替换原来把整批生图挂在浏览器保持的对话流上,切换标签页、断网或连接中断后 结果就再也回不来,前端还会继续播放按时间推算的 96% 假进度。 现在改为服务器端后台批次:提交时先落任务,再按参考图逐张生成并把每张结果写进 项目,页面刷新、切页或服务重启后都能按任务状态恢复,不再依赖前端连接。 - 新增 /api/agents/detail-replacement/batches(提交/查询、幂等去重、重试上限) - 前端只在服务端确认批次后显示处理中,卡片显示排队/生成/完成/失败真实状态 - 去掉假进度动画;没有产品输入图时提交前直接拦截 - 服务重启后自动恢复未完成批次,长批次不再被通用 30 分钟清理误判 - 单张失败只落到该卡片并继续后续图片,已完成的图片不重复生成和扣费 - 详情替换默认模型由持续 404 的 gpt-image-2.5-flare 改回 gpt-image-2.5
106 lines
3.2 KiB
TypeScript
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("-");
|
|
}
|