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