fix(ai): 图片创作重跑三连修——丢参考图/裂新批次/提示词矛盾

① 重跑丢参考图:「重跑这张/重跑整批」原来不带 refs → 后端收不到
reference_image_ids 落到纯文生图,出图与上传素材无关。现 GenBatch.refs
存 assetId(上传后回写;恢复批次由 tasks 接口新带的 id 还原),重跑原样带回。

② 重跑裂新聊天记录:enqueue 每次新铸 batch_id,后端没有「补进原批次」
概念,刷新/切对话后重跑图裂成新卡、原卡失败格还挂着。现生成接口收/回
batch_id(UUID 校验,非法兜底新铸),重跑沿用原批次并打 batch_append 标记;
前端批次卡存 backendBatchId(提交回调即落,整批全失败也不丢),重跑带回。
恢复分组时 rerun 任务不计入「应出张数」→ 补图成功后失败格自然收掉;
批次状态改按「有任务在跑」判定,已有好图+补图在途也能接续轮询。

③ 同批换装一半不换:带参考图模板「严格保留款式/不要换款」与用户要求
(如"生成现代服装的穿着")直接矛盾,模型每张随机听一边。改为只锁主体身份
(人物五官/发型/体型,商品品类/外形/Logo),用户明确要求改变的以用户要求
为最高优先级,未提及的才默认与参考图一致。

测试:apps 全量 214 过(需 --settings=airshelf.settings.test);
新增 test_rerun_with_batch_id_reuses_batch_and_marks_append。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-07-02 13:40:52 +08:00
co-authored by Claude Fable 5
parent dcc5d7fe50
commit a048c32527
7 changed files with 148 additions and 43 deletions
+7 -6
View File
@@ -538,24 +538,25 @@ export function App() {
if (res) setUser(res);
}
function generateImages(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; ratio?: string; image_model?: string; platform_id?: string; conversation_id?: string; reference_image_ids?: string[]; onSubmitted?: (taskIds: string[]) => void }) {
function generateImages(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; ratio?: string; image_model?: string; platform_id?: string; conversation_id?: string; reference_image_ids?: string[]; batch_id?: string; onSubmitted?: (taskIds: string[], batchId?: string) => void }) {
// 异步生图:提交后立刻拿到任务列表,前端轮询直到出图。慢的 ARK 出图在 Celery worker 里跑——
// Web 层不被 ~30s 请求占住 → 健康探针不饿死 → 根治"几张图整站 502";且提交成功后浏览器关掉/断网,
// worker 仍会把图生成并落库(扣费/退费在 worker 内闭环),重开素材库即可见。
const { onSubmitted, ...apiPayload } = payload; // onSubmitted 是本地回调,不发后端
return action(async () => {
const { tasks, conversation_id } = await api.submitGenerateImage(apiPayload);
const { tasks, conversation_id, batch_id } = await api.submitGenerateImage(apiPayload);
const ids = tasks.map((t) => t.id);
if (ids.length === 0) throw new Error("未能提交生成任务");
// 把刚提交的任务 id 回传给工作台,让它按「批」各自落盘 pending → 切走再回来每一批都能恢复并续轮询(PMC#5/#10)
onSubmitted?.(ids);
// 把刚提交的任务 id + 后端 batch_id 回传给工作台:pending 按批落盘续轮询(PMC#5/#10);
// batch_id 必须在提交时就落(不能等出图)——整批全失败时轮询会抛错,等不到结果,重跑就丢了归属
onSubmitted?.(ids, batch_id);
// 提交成功即落盘:刷新页面也能恢复"生成中"并继续轮询(任务在 worker 里跑,关掉浏览器也不丢)
// 记下本批所属商品(id+名),恢复在途批次时用它当导航头,而不是用「当前选中商品」(切走再回会显示错名)
const batchProduct = products.find((p) => p.id === payload.product_id);
saveImgwb(payload.mode, { pending: ids, results: [], count: payload.count, productId: payload.product_id, productTitle: batchProduct?.title });
const res = await pollImageTasks(payload.mode, ids);
// 回传后端归属/新建的对话 id,供工作室把它登记进左栏会话列表并设为 active
return { ...res, conversation_id };
// 回传后端归属/新建的对话 id + 本批 batch_id(重跑时带回原批次用),供工作台登记
return { ...res, conversation_id, batch_id };
// successText 留空:工作台的批次卡已就地显示出图结果(生成中→已完成),全局右下角「图片已生成」toast 多余,
// 且会在「生成中删掉该批次」后才弹出来,让人以为删了又生成(PMC#23)。靠批次卡反馈即可。
// concurrent:生图支持多批并行,不占全局单飞锁(否则并发重跑被拒成 null→标失败,PMC#8)
+3 -2
View File
@@ -634,8 +634,9 @@ export const api = {
},
// 异步生图:提交后秒级返回任务列表(慢出图在 worker 跑),再用 generateImageStatus 轮询取结果。
// 带 conversation_id 则归属该对话;不带则后端自动开一条新对话并回传其 id。
submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; model_entity_id?: string; ratio?: string; image_model?: string; platform_id?: string; conversation_id?: string; reference_image_ids?: string[] }) {
return request<{ conversation_id: string; tasks: { id: string; status: string }[] }>("/api/ai/generate-image/", { method: "POST", body: JSON.stringify(payload) });
// 带 batch_id(重跑/补图)则任务归回原批次;响应回传本批 batch_id 供前端存进批次卡。
submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; model_entity_id?: string; ratio?: string; image_model?: string; platform_id?: string; conversation_id?: string; reference_image_ids?: string[]; batch_id?: string }) {
return request<{ conversation_id: string; batch_id?: string; tasks: { id: string; status: string }[] }>("/api/ai/generate-image/", { method: "POST", body: JSON.stringify(payload) });
},
// 图片创作对话 CRUD —— 左栏会话列表 / 新对话 / 切换 / 重命名 / 删除
listConversations(mode: "image" | "model" | "cover" = "image") {
+42 -15
View File
@@ -608,8 +608,10 @@ type GenBatch = {
modelName?: string;
/** 该批次选中的平台 id 列表(平台套图:多选 → 各平台分组,P0③) */
platformIds?: string[];
/** 该批次提交的参考图(图片创作:用户上传作生成参考),用于批次头回显「参考了哪些图」 */
refs?: { name: string; url: string }[];
/** 该批次提交的参考图(图片创作:用户上传作生成参考):批次头回显 + 重跑时凭 assetId 原样复用 */
refs?: { name: string; url: string; assetId?: string }[];
/** 后端批次 id:重跑/补图带它回去,新任务归回原批次(否则后端裂成新批次,刷新后多出一条记录) */
backendBatchId?: string;
/** 该批次已提交、尚未终态的生图任务 id:切走再回来可据此对每一批各自续轮询(PMC#5/#10) */
pendingIds?: string[];
};
@@ -637,7 +639,7 @@ export function ImageWorkbenchPage({
modelConfigs: ModelConfig[];
onBack: () => void;
navigate?: (page: Page) => void;
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string; image_model?: string; platform_id?: string; conversation_id?: string; reference_image_ids?: string[]; onSubmitted?: (taskIds: string[]) => void }) => Promise<{ assets: Asset[]; conversation_id?: string } | null>;
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string; image_model?: string; platform_id?: string; conversation_id?: string; reference_image_ids?: string[]; batch_id?: string; onSubmitted?: (taskIds: string[], batchId?: string) => void }) => Promise<{ assets: Asset[]; conversation_id?: string; batch_id?: string } | null>;
onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>;
/** 行19:从商品页带入的初始商品 id(可选);未传则回退到第一个商品 */
initialProductId?: string;
@@ -844,18 +846,26 @@ export function ImageWorkbenchPage({
const live = list.filter((t) => t.status !== "succeeded" || (t.assets || []).length > 0);
if (!live.length) continue;
const assets = live.flatMap((t) => t.assets || []);
const allTerminal = live.every((t) => TERMINAL.has(t.status));
const status: GenBatch["status"] = assets.length > 0 ? "done" : allTerminal ? "failed" : "generating";
// 有任务还在跑(含重跑补图)就是 generating,不能只看「有没有图」——
// 否则「已有部分好图 + 重跑在途」被判 done,轮询不接续,补的图要手动刷新才出现
const hasRunning = live.some((t) => !TERMINAL.has(t.status));
const status: GenBatch["status"] = hasRunning ? "generating" : assets.length > 0 ? "done" : "failed";
// 应出张数只数原始任务:重跑/补图任务(rerun)是替补,计入会把失败格越滚越多
// (原失败任务仍在批里,重跑一次多一个格)。成功补图后 results 增长,失败格自然收掉。
const intended = live.filter((t) => !t.rerun).length;
// 该批用过的参考图(后端解析回 {id,name,url}):批次头回显 + 重跑凭 assetId 原样复用
const refSrc = live.find((t) => (t.reference_images || []).length)?.reference_images || [];
result.push({
id: key,
prompt: live[0]?.prompt || "",
ratio: live[0]?.ratio || meta.ratio,
count: live.length,
count: Math.max(1, intended),
status,
results: assets,
ts: new Date(live[0]?.created_at || Date.now()).getTime(),
// 该批用过的参考图(后端按 reference_image_ids 解析回 {name,url}),切换/刷新后仍可见
refs: (live[0]?.reference_images || []).length ? live[0].reference_images : undefined,
refs: refSrc.length ? refSrc.map((r) => ({ name: r.name, url: r.url, assetId: r.id })) : undefined,
// 真 batch_id 才能作重跑归属;老任务无 batch_id 时 key=任务 id,不能带给后端
backendBatchId: live[0]?.batch_id || undefined,
});
}
// 旧批次在上、新批次在下(对话流自上而下时间序)
@@ -882,17 +892,20 @@ export function ImageWorkbenchPage({
const status: GenBatch["status"] = hasRunning ? "generating" : assets.length ? "done" : "failed";
const first = live[0];
const platformKey = first.platform_id ? PLATFORM_KEY_MAP[first.platform_id] : "";
// 应出张数只数原始任务(重跑/补图任务是替补,不计入,与 batchesFromConvTasks 同理)
const intended = live.filter((t) => !t.rerun).length;
result.push({
id: key,
prompt: first.prompt || "",
ratio: first.ratio || meta.ratio,
count: live.length,
count: Math.max(1, intended),
status,
results: assets,
ts: new Date(first.created_at || Date.now()).getTime(),
productId: first.product_id || undefined,
modelId: first.model_id || undefined,
platformIds: platformKey ? [platformKey] : undefined,
backendBatchId: first.batch_id || undefined,
// 续轮询要带上整批任务 id(含已终态的):onResume 的结果会整体替换 results,
// 只传未完成 id 会把已出的好图从结果里丢掉(终态任务首轮轮询即回带成图,秒完成)。
pendingIds: hasRunning ? live.map((t) => t.id) : undefined,
@@ -1011,6 +1024,8 @@ export function ImageWorkbenchPage({
reuseBatchId?: string;
/** PMC#25:单图重跑——把新图追加进这张已有批次卡(不清旧好图、不新开卡) */
appendToBatchId?: string;
/** 重跑/补图:原批次的后端 batch_id,带给后端沿用 → 新任务归回原批次(刷新后不裂新记录) */
batchId?: string;
}) {
// 原地重跑/追加:沿用原批次 id,不再生成新 id、不再新增卡片
const batchId = opts.reuseBatchId || opts.appendToBatchId || `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
@@ -1034,8 +1049,8 @@ export function ImageWorkbenchPage({
modelId: opts.modelId,
modelName: opts.modelName,
platformIds: opts.platformIds,
// 批次头回显「参考了哪些图」(存名+预览,不存 file)
refs: opts.refs?.map((r) => ({ name: r.name, url: r.url }))
// 批次头回显「参考了哪些图」(存名+预览+已知 assetId,不存 file;上传成功后统一回写 assetId)
refs: opts.refs?.map((r) => ({ name: r.name, url: r.url, assetId: r.assetId }))
};
setBatches((prev) => [...prev, newBatch]);
}
@@ -1057,11 +1072,15 @@ export function ImageWorkbenchPage({
})
);
referenceImageIds = ids.filter((x): x is string => !!x);
// 上传得到的 assetId 回写进批次 refs:重跑时凭它原样复用参考图(不再重新上传、不丢参考)
const refsWithIds = opts.refs.map((r, i) => ({ name: r.name, url: r.url, assetId: r.assetId || ids[i] || undefined }));
setBatches((prev) => prev.map((b) => (b.id === batchId ? { ...b, refs: refsWithIds } : b)));
}
// 带上当前对话 id(空则后端自动开一条并回传);conversation_id 用 ref 取最新值,避免闭包旧值
// batch_id:重跑/补图带原批次 id → 后端沿用,记录归回原批次(刷新后不裂新聊天记录)
// onSubmitted:提交成功拿到任务 id 记进本批 pendingIds → 切走再回来由后端记录接续轮询(R100)
const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio, image_model: genModel, platform_id: opts.platformId, conversation_id: activeConvRef.current || undefined, reference_image_ids: referenceImageIds,
onSubmitted: (taskIds) => setBatches((prev) => prev.map((b) => (b.id === batchId ? { ...b, pendingIds: taskIds } : b))) });
const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio, image_model: genModel, platform_id: opts.platformId, conversation_id: activeConvRef.current || undefined, reference_image_ids: referenceImageIds, batch_id: opts.batchId,
onSubmitted: (taskIds, submittedBatchId) => setBatches((prev) => prev.map((b) => (b.id === batchId ? { ...b, pendingIds: taskIds, backendBatchId: b.backendBatchId || submittedBatchId } : b))) });
// 首发新对话:把后端建的对话登记进左栏并设为 active;刷新列表拿到真标题/计数
const convId = result?.conversation_id;
if (convId && convId !== activeConvRef.current) {
@@ -1071,13 +1090,15 @@ export function ImageWorkbenchPage({
setBatches((prev) => prev.map((b) => {
if (b.id !== batchId) return b;
const newAssets = result?.assets || [];
// 后端批次 id 落进批次卡:之后重跑这张/重跑整批都带它回原批次
const backendBatchId = b.backendBatchId || result?.batch_id;
// 单图追加重跑:把新图并进原有好图(按 id 去重),不覆盖;其余路径直接用本批结果
if (opts.appendToBatchId) {
const seen = new Set(b.results.map((a) => a.id));
const merged = [...b.results, ...newAssets.filter((a) => !a.id || !seen.has(a.id))];
return { ...b, status: merged.length ? ("done" as const) : ("failed" as const), results: merged, pendingIds: undefined };
return { ...b, backendBatchId, status: merged.length ? ("done" as const) : ("failed" as const), results: merged, pendingIds: undefined };
}
return { ...b, status: newAssets.length ? ("done" as const) : ("failed" as const), results: newAssets, pendingIds: undefined };
return { ...b, backendBatchId, status: newAssets.length ? ("done" as const) : ("failed" as const), results: newAssets, pendingIds: undefined };
}));
} catch {
// 追加重跑失败:保留原有好图,只把状态落回(有图=done,无图=failed)
@@ -1141,6 +1162,9 @@ export function ImageWorkbenchPage({
modelName: src.modelName,
platformIds: src.platformIds,
platformId: src.platformIds?.[0] ? PLATFORM_ID_MAP[src.platformIds[0]] : undefined,
// 原批次的参考图 + 后端批次 id 一并带回:重跑仍参考原素材,且任务归回原批次不裂新记录
refs: src.refs,
batchId: src.backendBatchId,
reuseBatchId: src.id
});
}
@@ -1197,6 +1221,9 @@ export function ImageWorkbenchPage({
modelName: src.modelName,
platformIds: src.platformIds,
platformId: src.platformIds?.[0] ? PLATFORM_ID_MAP[src.platformIds[0]] : undefined,
// 原批次的参考图 + 后端批次 id 一并带回:补的这张仍参考原素材,且归回原批次不裂新记录
refs: src.refs,
batchId: src.backendBatchId,
appendToBatchId: src.id
});
}
+6 -1
View File
@@ -575,6 +575,8 @@ export type WorkbenchTask = {
model_id: string;
model_entity_id: string;
platform_id: string;
/** 重跑/补图任务(复用原批次 batch_id 追加):不计入批次「应出张数」 */
rerun?: boolean;
created_at: string;
assets: Asset[];
};
@@ -587,7 +589,10 @@ export type ImageConversationTask = {
prompt: string;
batch_id: string;
ratio: string;
reference_images: { name: string; url: string }[];
/** 重跑/补图任务(复用原批次 batch_id 追加):不计入批次「应出张数」 */
rerun?: boolean;
/** id 供重跑时原样复用参考图(老部署/老数据可能没有) */
reference_images: { id?: string; name: string; url: string }[];
created_at: string;
assets: Asset[];
};