fix: 修复图片补图删除回填

This commit is contained in:
hh
2026-07-16 15:30:02 +08:00
parent 95cd4abd3c
commit ff5eaeee8a
8 changed files with 112 additions and 19 deletions
+1 -1
View File
@@ -601,7 +601,7 @@ 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[]; batch_id?: string; onSubmitted?: (taskIds: string[], batchId?: 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; retry_of_task_id?: string; onSubmitted?: (taskIds: string[], batchId?: string) => void }) {
// 异步生图:提交后立刻拿到任务列表,前端轮询直到出图。慢的 ARK 出图在 Celery worker 里跑——
// Web 层不被 ~30s 请求占住 → 健康探针不饿死 → 根治"几张图整站 502";且提交成功后浏览器关掉/断网,
// worker 仍会把图生成并落库(扣费/退费在 worker 内闭环),重开素材库即可见。
+1 -1
View File
@@ -719,7 +719,7 @@ export const api = {
// 异步生图:提交后秒级返回任务列表(慢出图在 worker 跑),再用 generateImageStatus 轮询取结果。
// 带 conversation_id 则归属该对话;不带则后端自动开一条新对话并回传其 id。
// 带 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 }) {
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; retry_of_task_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 —— 左栏会话列表 / 新对话 / 切换 / 重命名 / 删除
+78 -14
View File
@@ -605,6 +605,9 @@ type GenBatch = {
pendingIds?: string[];
/** 该批全部后端任务 id:删除/恢复批次时使用稳定锚点,终态批次也必须保留。 */
taskIds?: string[];
/** 补图/单图重跑任务:删掉其成图时不应减少原始批次的失败格数量。 */
rerunTaskIds?: string[];
failedTaskIds?: string[];
};
/**
@@ -643,7 +646,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[]; batch_id?: string; onSubmitted?: (taskIds: string[], batchId?: string) => void }) => Promise<{ assets: Asset[]; conversation_id?: string; batch_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; retry_of_task_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;
@@ -876,6 +879,11 @@ export function ImageWorkbenchPage({
const result: GenBatch[] = [];
for (const [key, list] of groups) {
// R109:成功但成图已全部删除(软删进垃圾桶)的任务不再回显 —— 生成记录与资产库数据绑定
const coveredFailureIds = new Set(
list.filter((t) => t.rerun && t.status === "succeeded" && t.retry_of_task_id).map((t) => t.retry_of_task_id!)
);
// 旧补图任务没有记录它替代的失败格;按一张补图抵一张失败格兼容,避免历史批次显示超过原始张数。
const legacyReplacementCount = list.filter((t) => t.rerun && t.status === "succeeded" && !t.retry_of_task_id).length;
const live = list.filter((t) => t.status !== "succeeded" || (t.assets || []).length > 0);
if (!live.length) continue;
const assets = live.flatMap((t) => t.assets || []);
@@ -885,14 +893,19 @@ export function ImageWorkbenchPage({
const status: GenBatch["status"] = hasRunning ? "generating" : assets.length > 0 ? "done" : "failed";
// 应出张数只数原始任务:重跑/补图任务(rerun)是替补,计入会把失败格越滚越多
// (原失败任务仍在批里,重跑一次多一个格)。成功补图后 results 增长,失败格自然收掉。
const failedTaskIds = list
.filter((t) => !t.rerun && ["failed", "cancelled"].includes(t.status) && !(t.assets || []).length && !coveredFailureIds.has(t.id))
.map((t) => t.id)
.slice(legacyReplacementCount);
const intended = live.filter((t) => !t.rerun).length;
const count = hasRunning ? Math.max(1, intended) : Math.max(1, assets.length + failedTaskIds.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: Math.max(1, intended),
count,
status,
results: assets,
ts: new Date(live[0]?.created_at || Date.now()).getTime(),
@@ -900,6 +913,8 @@ export function ImageWorkbenchPage({
// 真 batch_id 才能作重跑归属;老任务无 batch_id 时 key=任务 id,不能带给后端
backendBatchId: live[0]?.batch_id || undefined,
taskIds: live.map((t) => t.id),
rerunTaskIds: live.filter((t) => t.rerun).map((t) => t.id),
failedTaskIds,
});
}
// 旧批次在上、新批次在下(对话流自上而下时间序)
@@ -920,6 +935,11 @@ export function ImageWorkbenchPage({
const TERMINAL = new Set(["succeeded", "failed", "cancelled"]);
const result: GenBatch[] = [];
for (const [key, list] of groups) {
const coveredFailureIds = new Set(
list.filter((t) => t.rerun && t.status === "succeeded" && t.retry_of_task_id).map((t) => t.retry_of_task_id!)
);
// 同图片创作:历史补图没有目标格关联时,仍按一张补图替代一张失败格回放。
const legacyReplacementCount = list.filter((t) => t.rerun && t.status === "succeeded" && !t.retry_of_task_id).length;
const live = list.filter((t) => t.status !== "succeeded" || (t.assets || []).length > 0);
if (!live.length) continue;
const assets = live.flatMap((t) => t.assets || []);
@@ -928,12 +948,17 @@ export function ImageWorkbenchPage({
const first = live[0];
const platformKey = first.platform_id ? PLATFORM_KEY_MAP[first.platform_id] : "";
// 应出张数只数原始任务(重跑/补图任务是替补,不计入,与 batchesFromConvTasks 同理)
const failedTaskIds = list
.filter((t) => !t.rerun && ["failed", "cancelled"].includes(t.status) && !(t.assets || []).length && !coveredFailureIds.has(t.id))
.map((t) => t.id)
.slice(legacyReplacementCount);
const intended = live.filter((t) => !t.rerun).length;
const count = hasRunning ? Math.max(1, intended) : Math.max(1, assets.length + failedTaskIds.length);
result.push({
id: key,
prompt: first.prompt || "",
ratio: first.ratio || meta.ratio,
count: Math.max(1, intended),
count,
status,
results: assets,
ts: new Date(first.created_at || Date.now()).getTime(),
@@ -942,6 +967,8 @@ export function ImageWorkbenchPage({
platformIds: platformKey ? [platformKey] : undefined,
backendBatchId: first.batch_id || undefined,
taskIds: live.map((t) => t.id),
rerunTaskIds: live.filter((t) => t.rerun).map((t) => t.id),
failedTaskIds,
// 续轮询要带上整批任务 id(含已终态的):onResume 的结果会整体替换 results,
// 只传未完成 id 会把已出的好图从结果里丢掉(终态任务首轮轮询即回带成图,秒完成)。
pendingIds: hasRunning ? live.map((t) => t.id) : undefined,
@@ -1062,6 +1089,8 @@ export function ImageWorkbenchPage({
appendToBatchId?: string;
/** 重跑/补图:原批次的后端 batch_id,带给后端沿用 → 新任务归回原批次(刷新后不裂新记录) */
batchId?: string;
/** 单格重跑要替代的原失败任务。 */
retryOfTaskId?: string;
}) {
// 原地重跑/追加:沿用原批次 id,不再生成新 id、不再新增卡片
const batchId = opts.reuseBatchId || opts.appendToBatchId || `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
@@ -1115,8 +1144,16 @@ export function ImageWorkbenchPage({
// 带上当前对话 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, batch_id: opts.batchId,
onSubmitted: (taskIds, submittedBatchId) => setBatches((prev) => prev.map((b) => (b.id === batchId ? { ...b, pendingIds: taskIds, taskIds, backendBatchId: b.backendBatchId || submittedBatchId } : 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, retry_of_task_id: opts.retryOfTaskId,
onSubmitted: (taskIds, submittedBatchId) => {
setBatches((prev) => prev.map((b) => {
if (b.id !== batchId) return b;
const allTaskIds = opts.appendToBatchId ? [...new Set([...(b.taskIds || []), ...taskIds])] : taskIds;
const rerunTaskIds = opts.appendToBatchId ? [...new Set([...(b.rerunTaskIds || []), ...taskIds])] : b.rerunTaskIds;
return { ...b, pendingIds: taskIds, taskIds: allTaskIds, rerunTaskIds, backendBatchId: b.backendBatchId || submittedBatchId };
}));
}
});
// 首发新对话:把后端建的对话登记进左栏并设为 active;刷新列表拿到真标题/计数
const convId = result?.conversation_id;
if (convId && convId !== activeConvRef.current) {
@@ -1132,7 +1169,18 @@ export function ImageWorkbenchPage({
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, backendBatchId, status: merged.length ? ("done" as const) : ("failed" as const), results: merged, pendingIds: undefined };
const failedTaskIds = opts.retryOfTaskId && newAssets.length
? b.failedTaskIds?.filter((id) => id !== opts.retryOfTaskId)
: b.failedTaskIds;
return {
...b,
backendBatchId,
status: merged.length ? ("done" as const) : ("failed" as const),
results: merged,
failedTaskIds,
count: failedTaskIds ? Math.max(1, merged.length + failedTaskIds.length) : b.count,
pendingIds: undefined
};
}
return { ...b, backendBatchId, status: newAssets.length ? ("done" as const) : ("failed" as const), results: newAssets, pendingIds: undefined };
}));
@@ -1221,9 +1269,21 @@ export function ImageWorkbenchPage({
// 单张:从批次里摘掉;整批删空(且不在生成中)则整卡移除
setBatches((prev) => prev.flatMap((b) => {
if (b.id !== target.batchId) return [b];
const deletedAsset = b.results.find((a) => a.id === target.assetId);
const deletedTaskId = deletedAsset?.origin_task || undefined;
const results = b.results.filter((a) => a.id !== target.assetId);
if (!results.length && b.status === "done") return [];
return [{ ...b, results, count: Math.max(1, b.count - 1) }];
const taskIds = deletedTaskId ? b.taskIds?.filter((id) => id !== deletedTaskId) : b.taskIds;
const rerunTaskIds = deletedTaskId ? b.rerunTaskIds?.filter((id) => id !== deletedTaskId) : b.rerunTaskIds;
// 单图删除只移除该图对应的任务;同批仍有失败、成功或补图任务时,批次卡必须保留。
if (taskIds && taskIds.length === 0) return [];
return [{
...b,
results,
taskIds,
rerunTaskIds,
count: b.failedTaskIds ? Math.max(1, results.length + b.failedTaskIds.length) : Math.max(1, b.count - 1),
status: b.status === "generating" ? "generating" : results.length ? "done" : "failed"
}];
}));
return;
}
@@ -1252,7 +1312,7 @@ export function ImageWorkbenchPage({
/* 单图「再生成」/「重跑这张」(§4.18 悬浮① / YYX#5):
PMC#25——补一张图进原批次卡(不新开任务、不覆盖已有好图)。 */
async function regenSingleImage(src: GenBatch) {
async function regenSingleImage(src: GenBatch, retryOfTaskId?: string) {
await startBatch({
prompt: src.prompt,
ratio: src.ratio,
@@ -1266,7 +1326,8 @@ export function ImageWorkbenchPage({
// 原批次的参考图 + 后端批次 id 一并带回:补的这张仍参考原素材,且归回原批次不裂新记录
refs: src.refs,
batchId: src.backendBatchId,
appendToBatchId: src.id
appendToBatchId: src.id,
retryOfTaskId
});
}
@@ -1335,7 +1396,9 @@ export function ImageWorkbenchPage({
② 失败格占位 + 单张重跑。R109:成图自动入资产库,不再有「已入库」角标/入库切换。 */
function renderBatchGrid(batch: GenBatch) {
const generating = batch.status === "generating";
const n = batch.results.length || batch.count;
const n = generating
? Math.max(batch.results.length, batch.count)
: Math.max(1, batch.results.length + (batch.failedTaskIds?.length ?? Math.max(0, batch.count - batch.results.length)));
const cols = n >= 4 ? 4 : 2;
const ratioBatchVar = batch.ratio.replace(":", " / ");
return (
@@ -1345,11 +1408,12 @@ export function ImageWorkbenchPage({
>
{/* YYX#5:渲染满 count 个格子 —— 已出图的格用真图;非生成中又缺图的格 = 该张失败,
给失败占位符 + 单张重跑,不再因「部分失败」整批不显示失败格。 */}
{Array.from({ length: Math.max(batch.count, batch.results.length) }).map((_, index) => {
{Array.from({ length: n }).map((_, index) => {
const asset = batch.results[index];
const failedTaskId = batch.failedTaskIds?.[index - batch.results.length];
const url = asset?.files?.[0]?.preview_url;
const assetId = asset?.id || "";
const failed = !generating && !asset; // 非生成中且该格无图 → 这张失败了
const failed = !generating && !asset && (Boolean(failedTaskId) || !batch.failedTaskIds); // 非生成中且该格无图 → 这张失败了
const key = assetId || `ph-${index}`;
const cellKey = `${batch.id}:${assetId || key}`;
return (
@@ -1367,7 +1431,7 @@ export function ImageWorkbenchPage({
<span className="gen-failed">
<X size={18} />
<span className="gf-text"></span>
<button type="button" className="gf-retry" onClick={() => regenSingleImage(batch)}>
<button type="button" className="gf-retry" onClick={() => regenSingleImage(batch, failedTaskId)}>
<RefreshCw size={12} />
</button>
+2
View File
@@ -674,6 +674,7 @@ export type WorkbenchTask = {
platform_id: string;
/** 重跑/补图任务(复用原批次 batch_id 追加):不计入批次「应出张数」 */
rerun?: boolean;
retry_of_task_id?: string;
created_at: string;
assets: Asset[];
};
@@ -688,6 +689,7 @@ export type ImageConversationTask = {
ratio: string;
/** 重跑/补图任务(复用原批次 batch_id 追加):不计入批次「应出张数」 */
rerun?: boolean;
retry_of_task_id?: string;
/** id 供重跑时原样复用参考图(老部署/老数据可能没有) */
reference_images: { id?: string; name: string; url: string }[];
created_at: string;