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

183 lines
6.3 KiB
TypeScript

export type DetailReplacementGenerationInputRole =
| "product"
| "model"
| "supplement"
| "layout";
const MODEL_IMAGE_TEXT_HINT =
/自拍|合照|聚会|聚餐|围坐|用餐|试坐|承重|受力|拿手机|手持|人物场景|人物关系|真人演示|人物展示|人像展示|固定模特|用户模特|same\s+model|fixed\s+model|person|people|human/i;
export function shouldIncludeDetailReplacementModelImage(scenePlan: {
useModelImage?: boolean;
peoplePolicy?: string;
generationRoute?: string;
pageKind?: string;
modelImageReason?: string;
routeReason?: string;
sceneSummary?: string;
cameraSummary?: string;
placementSummary?: string;
preserveSummary?: string;
styleSummary?: string;
avoidSummary?: string;
generationPrompt?: string;
} | null | undefined) {
if (!scenePlan) return false;
if (scenePlan.useModelImage) return true;
if (scenePlan.peoplePolicy && scenePlan.peoplePolicy !== "none") return true;
if (scenePlan.generationRoute === "people_relation_locked") return true;
if (scenePlan.pageKind === "people_scene" || scenePlan.pageKind === "action_poster") return true;
return MODEL_IMAGE_TEXT_HINT.test([
scenePlan.modelImageReason,
scenePlan.routeReason,
scenePlan.sceneSummary,
scenePlan.cameraSummary,
scenePlan.placementSummary,
scenePlan.preserveSummary,
scenePlan.styleSummary,
scenePlan.avoidSummary,
scenePlan.generationPrompt,
].filter(Boolean).join(" "));
}
export function selectDetailReplacementGenerationInputs(params: {
productImageUrls: string[];
modelImageUrl?: string | null;
includeModelImage?: boolean;
templateReferenceImageUrls?: string[];
rerunReferenceImageUrls?: string[];
referenceImageUrl: string;
maxImages?: number;
}) {
const normalizeUrls = (values: Array<string | null | undefined>) =>
Array.from(
new Set(
values
.map((value) => String(value || "").trim())
.filter((value) => /^https?:\/\//i.test(value)),
),
);
const referenceImageUrl = String(params.referenceImageUrl || "").trim();
const productImageUrls = normalizeUrls(params.productImageUrls).filter(
(url) => url !== referenceImageUrl,
);
const modelImageUrl = params.includeModelImage
? normalizeUrls([params.modelImageUrl]).find((url) => url !== referenceImageUrl) || null
: null;
const rerunReferenceImageUrls = normalizeUrls(
params.rerunReferenceImageUrls || [],
).filter((url) => url !== referenceImageUrl);
const templateReferenceImageUrls = normalizeUrls(
params.templateReferenceImageUrls || [],
).filter((url) => url !== referenceImageUrl);
const maxImages = Math.max(2, Math.floor(params.maxImages || 3));
const contentCapacity = maxImages - 1;
const selectedProductImageUrls: string[] = [];
const selectedTemplateImageUrls: string[] = [];
const selectedRerunImageUrls: string[] = [];
let selectedModelImageUrl: string | null = null;
if (productImageUrls[0]) selectedProductImageUrls.push(productImageUrls[0]);
if (modelImageUrl && selectedProductImageUrls.length < contentCapacity) {
selectedModelImageUrl = modelImageUrl;
}
const usedCount = () =>
selectedProductImageUrls.length +
selectedTemplateImageUrls.length +
selectedRerunImageUrls.length +
(selectedModelImageUrl ? 1 : 0);
const addSupplement = (url: string | undefined, kind: "template" | "rerun") => {
if (!url || usedCount() >= contentCapacity) return;
const selectedSupplementImageUrls = [
...selectedTemplateImageUrls,
...selectedRerunImageUrls,
];
if (
selectedProductImageUrls.includes(url) ||
selectedSupplementImageUrls.includes(url) ||
selectedModelImageUrl === url
) {
return;
}
if (kind === "template") selectedTemplateImageUrls.push(url);
else selectedRerunImageUrls.push(url);
};
if (!selectedModelImageUrl) {
addSupplement(rerunReferenceImageUrls[0], "rerun");
if (usedCount() < contentCapacity && productImageUrls[1]) {
selectedProductImageUrls.push(productImageUrls[1]);
}
addSupplement(templateReferenceImageUrls[0], "template");
} else if (selectedProductImageUrls.length === 0) {
addSupplement(rerunReferenceImageUrls[0], "rerun");
addSupplement(templateReferenceImageUrls[0], "template");
}
while (usedCount() < contentCapacity) {
const nextProduct = productImageUrls.find(
(url) => !selectedProductImageUrls.includes(url),
);
if (nextProduct) {
selectedProductImageUrls.push(nextProduct);
continue;
}
const nextRerun = rerunReferenceImageUrls.find(
(url) =>
!selectedRerunImageUrls.includes(url) &&
!selectedTemplateImageUrls.includes(url) &&
!selectedProductImageUrls.includes(url) &&
selectedModelImageUrl !== url,
);
if (nextRerun) {
selectedRerunImageUrls.push(nextRerun);
continue;
}
const nextTemplate = templateReferenceImageUrls.find(
(url) =>
!selectedTemplateImageUrls.includes(url) &&
!selectedRerunImageUrls.includes(url) &&
!selectedProductImageUrls.includes(url) &&
selectedModelImageUrl !== url,
);
if (!nextTemplate) break;
selectedTemplateImageUrls.push(nextTemplate);
}
const entries: Array<{
url: string;
role: DetailReplacementGenerationInputRole;
}> = [
...selectedProductImageUrls.map((url) => ({ url, role: "product" as const })),
...(selectedModelImageUrl
? [{ url: selectedModelImageUrl, role: "model" as const }]
: []),
...selectedTemplateImageUrls.map((url) => ({
url,
role: "supplement" as const,
})),
...selectedRerunImageUrls.map((url) => ({
url,
role: "supplement" as const,
})),
{ url: referenceImageUrl, role: "layout" as const },
].filter((entry) => /^https?:\/\//i.test(entry.url));
return {
entries,
imageUrls: entries.map((entry) => entry.url),
inputImageRoleByUrl: Object.fromEntries(
entries.map((entry) => [entry.url, entry.role]),
) as Record<string, DetailReplacementGenerationInputRole>,
productImageUrls: selectedProductImageUrls,
modelImageUrl: selectedModelImageUrl,
supplementImageUrls: [
...selectedTemplateImageUrls,
...selectedRerunImageUrls,
],
templateImageUrls: selectedTemplateImageUrls,
rerunImageUrls: selectedRerunImageUrls,
};
}