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
+15 -1
View File
@@ -2775,7 +2775,7 @@ def _reap_stale_standalone_image_tasks(*, team) -> None:
continue continue
def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1, product_id: str | None = None, reference_product: bool = False, model_id: str | None = None, model_entity_id: str | None = None, ratio: str | None = None, image_model: str | None = None, conversation=None, reference_image_ids: list[str] | None = None, platform_id: str | None = None, batch_id: str | None = None, tryon_prompt_v2_override: bool = False, tryon_ab: dict | None = None, dispatch: bool = True) -> list[AITask]: def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1, product_id: str | None = None, reference_product: bool = False, model_id: str | None = None, model_entity_id: str | None = None, ratio: str | None = None, image_model: str | None = None, conversation=None, reference_image_ids: list[str] | None = None, platform_id: str | None = None, batch_id: str | None = None, retry_of_task_id: str | None = None, tryon_prompt_v2_override: bool = False, tryon_ab: dict | None = None, dispatch: bool = True) -> list[AITask]:
"""独立生图(图片创作 / 模特上身图 / 平台套图)改为**异步**:本函数在 Web 请求里只做「建任务 + """独立生图(图片创作 / 模特上身图 / 平台套图)改为**异步**:本函数在 Web 请求里只做「建任务 +
预留额度」这种秒级的活,真正 ~30s 的 ARK 出图交给 Celery worker(generate_standalone_image_task)。 预留额度」这种秒级的活,真正 ~30s 的 ARK 出图交给 Celery worker(generate_standalone_image_task)。
@@ -2812,6 +2812,18 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
batch_id = None batch_id = None
if not is_append: if not is_append:
batch_id = str(uuid.uuid4()) batch_id = str(uuid.uuid4())
retry_of_task_id = None
elif retry_of_task_id:
retry_of_task = AITask.objects.filter(
id=retry_of_task_id,
team=team,
project__isnull=True,
is_deleted=False,
purged_at__isnull=True,
request_payload__batch_id=batch_id,
status__in=(AITask.Status.FAILED, AITask.Status.CANCELLED),
).first()
retry_of_task_id = str(retry_of_task.id) if retry_of_task else None
# 平台套图:规范化平台 id(前端 dy/tb… → canonical),用于注入平台版式块(优化版);非 cover 模式忽略。 # 平台套图:规范化平台 id(前端 dy/tb… → canonical),用于注入平台版式块(优化版);非 cover 模式忽略。
platform_key = str(platform_id or "").strip() if mode == "cover" else "" platform_key = str(platform_id or "").strip() if mode == "cover" else ""
platform_name = _PLATFORM_NAMES.get(platform_key, "") platform_name = _PLATFORM_NAMES.get(platform_key, "")
@@ -2884,6 +2896,8 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
# 只在重跑/补图时落键(不落 False):workbench 用 KeyTextTransform 抽文本,"false" 字符串也是真值,会误判 # 只在重跑/补图时落键(不落 False):workbench 用 KeyTextTransform 抽文本,"false" 字符串也是真值,会误判
if is_append: if is_append:
request_payload["batch_append"] = True request_payload["batch_append"] = True
if retry_of_task_id:
request_payload["retry_of_task_id"] = retry_of_task_id
if quote.meta.get("rate"): if quote.meta.get("rate"):
request_payload["points_per_yuan_snapshot"] = quote.meta["rate"] request_payload["points_per_yuan_snapshot"] = quote.meta["rate"]
task = AITask.objects.create( task = AITask.objects.create(
+5 -1
View File
@@ -1215,10 +1215,13 @@ class ImageConversationTests(TestCase):
conv_id = first.json()["conversation_id"] conv_id = first.json()["conversation_id"]
batch_id = first.json()["batch_id"] batch_id = first.json()["batch_id"]
self.assertTrue(batch_id) self.assertTrue(batch_id)
failed_task = AITask.objects.filter(conversation_id=conv_id).order_by("created_at").first()
failed_task.status = AITask.Status.FAILED
failed_task.save(update_fields=["status"])
# 重跑一张:带原对话 + 原 batch_id → 归回原批次 # 重跑一张:带原对话 + 原 batch_id → 归回原批次
second = self.client.post( second = self.client.post(
"/api/ai/generate-image/", "/api/ai/generate-image/",
{"prompt": "一只猫", "mode": "image", "count": 1, "conversation_id": conv_id, "batch_id": batch_id}, {"prompt": "一只猫", "mode": "image", "count": 1, "conversation_id": conv_id, "batch_id": batch_id, "retry_of_task_id": str(failed_task.id)},
format="json", format="json",
) )
self.assertEqual(second.status_code, 202, second.content) self.assertEqual(second.status_code, 202, second.content)
@@ -1227,6 +1230,7 @@ class ImageConversationTests(TestCase):
self.assertEqual(len(tasks), 3) self.assertEqual(len(tasks), 3)
self.assertTrue(all(t["batch_id"] == batch_id for t in tasks)) self.assertTrue(all(t["batch_id"] == batch_id for t in tasks))
self.assertEqual([t["rerun"] for t in tasks], [False, False, True]) self.assertEqual([t["rerun"] for t in tasks], [False, False, True])
self.assertEqual(tasks[-1]["retry_of_task_id"], str(failed_task.id))
# 非法 batch_id(不是 UUID):不沿用,铸新批次,不打 append 标记 # 非法 batch_id(不是 UUID):不沿用,铸新批次,不打 append 标记
third = self.client.post( third = self.client.post(
"/api/ai/generate-image/", "/api/ai/generate-image/",
+4 -1
View File
@@ -48,6 +48,7 @@ class GenerateImageView(APIView):
conversation_id = str(request.data.get("conversation_id") or "").strip() or None conversation_id = str(request.data.get("conversation_id") or "").strip() or None
# 重跑/补图:前端带原批次 batch_id → enqueue 沿用(UUID 校验),记录归回原批次不裂新卡 # 重跑/补图:前端带原批次 batch_id → enqueue 沿用(UUID 校验),记录归回原批次不裂新卡
batch_id = str(request.data.get("batch_id") or "").strip() or None batch_id = str(request.data.get("batch_id") or "").strip() or None
retry_of_task_id = str(request.data.get("retry_of_task_id") or "").strip() or None
# 用户在图片创作里上传的参考图(已先传成 Asset),按 id 列表带入 → 生成时作多图参考(image_edit) # 用户在图片创作里上传的参考图(已先传成 Asset),按 id 列表带入 → 生成时作多图参考(image_edit)
raw_refs = request.data.get("reference_image_ids") or [] raw_refs = request.data.get("reference_image_ids") or []
if isinstance(raw_refs, str): if isinstance(raw_refs, str):
@@ -69,7 +70,7 @@ class GenerateImageView(APIView):
title=(prompt[:24] or "默认创作"), title=(prompt[:24] or "默认创作"),
) )
try: try:
tasks = enqueue_standalone_images(team=team, user=request.user, prompt=prompt, mode=mode, count=count, product_id=product_id, reference_product=reference_product, model_id=model_id, model_entity_id=model_entity_id, ratio=ratio, image_model=image_model, conversation=conversation, reference_image_ids=reference_image_ids, platform_id=platform_id, batch_id=batch_id) tasks = enqueue_standalone_images(team=team, user=request.user, prompt=prompt, mode=mode, count=count, product_id=product_id, reference_product=reference_product, model_id=model_id, model_entity_id=model_entity_id, ratio=ratio, image_model=image_model, conversation=conversation, reference_image_ids=reference_image_ids, platform_id=platform_id, batch_id=batch_id, retry_of_task_id=retry_of_task_id)
except ValueError as exc: # 无可用模型 / 余额不足等,立即反馈 except ValueError as exc: # 无可用模型 / 余额不足等,立即反馈
internal_kind = "user_credit_insufficient" if str(exc).strip().lower() == "insufficient credit" else "" internal_kind = "user_credit_insufficient" if str(exc).strip().lower() == "insufficient credit" else ""
public_error = classify_generation_error( public_error = classify_generation_error(
@@ -365,6 +366,7 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
"model_entity_id": t.rp_model_entity_id or "", "model_entity_id": t.rp_model_entity_id or "",
"platform_id": t.rp_platform_id or "", "platform_id": t.rp_platform_id or "",
"rerun": bool(t.rp_batch_append), "rerun": bool(t.rp_batch_append),
"retry_of_task_id": str((t.request_payload or {}).get("retry_of_task_id") or ""),
"created_at": t.created_at, "created_at": t.created_at,
# 软删的图不再出现在工作台记录里(R109:删除资产库图片 → 任务记录联动) # 软删的图不再出现在工作台记录里(R109:删除资产库图片 → 任务记录联动)
"assets": AssetSerializer( "assets": AssetSerializer(
@@ -499,6 +501,7 @@ class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
"ratio": (t.request_payload or {}).get("ratio") or "", "ratio": (t.request_payload or {}).get("ratio") or "",
# 重跑/补图任务:不计入批次「应出张数」,前端据此正确渲染失败格数量 # 重跑/补图任务:不计入批次「应出张数」,前端据此正确渲染失败格数量
"rerun": bool((t.request_payload or {}).get("batch_append")), "rerun": bool((t.request_payload or {}).get("batch_append")),
"retry_of_task_id": str((t.request_payload or {}).get("retry_of_task_id") or ""),
"reference_images": resolve_refs((t.request_payload or {}).get("reference_image_ids")), "reference_images": resolve_refs((t.request_payload or {}).get("reference_image_ids")),
"created_at": t.created_at, "created_at": t.created_at,
"assets": AssetSerializer( "assets": AssetSerializer(
+1 -1
View File
@@ -601,7 +601,7 @@ export function App() {
if (res) setUser(res); 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 里跑—— // 异步生图:提交后立刻拿到任务列表,前端轮询直到出图。慢的 ARK 出图在 Celery worker 里跑——
// Web 层不被 ~30s 请求占住 → 健康探针不饿死 → 根治"几张图整站 502";且提交成功后浏览器关掉/断网, // Web 层不被 ~30s 请求占住 → 健康探针不饿死 → 根治"几张图整站 502";且提交成功后浏览器关掉/断网,
// worker 仍会把图生成并落库(扣费/退费在 worker 内闭环),重开素材库即可见。 // worker 仍会把图生成并落库(扣费/退费在 worker 内闭环),重开素材库即可见。
+1 -1
View File
@@ -719,7 +719,7 @@ export const api = {
// 异步生图:提交后秒级返回任务列表(慢出图在 worker 跑),再用 generateImageStatus 轮询取结果。 // 异步生图:提交后秒级返回任务列表(慢出图在 worker 跑),再用 generateImageStatus 轮询取结果。
// 带 conversation_id 则归属该对话;不带则后端自动开一条新对话并回传其 id。 // 带 conversation_id 则归属该对话;不带则后端自动开一条新对话并回传其 id。
// 带 batch_id(重跑/补图)则任务归回原批次;响应回传本批 batch_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) }); return request<{ conversation_id: string; batch_id?: string; tasks: { id: string; status: string }[] }>("/api/ai/generate-image/", { method: "POST", body: JSON.stringify(payload) });
}, },
// 图片创作对话 CRUD —— 左栏会话列表 / 新对话 / 切换 / 重命名 / 删除 // 图片创作对话 CRUD —— 左栏会话列表 / 新对话 / 切换 / 重命名 / 删除
+78 -14
View File
@@ -605,6 +605,9 @@ type GenBatch = {
pendingIds?: string[]; pendingIds?: string[];
/** 该批全部后端任务 id:删除/恢复批次时使用稳定锚点,终态批次也必须保留。 */ /** 该批全部后端任务 id:删除/恢复批次时使用稳定锚点,终态批次也必须保留。 */
taskIds?: string[]; taskIds?: string[];
/** 补图/单图重跑任务:删掉其成图时不应减少原始批次的失败格数量。 */
rerunTaskIds?: string[];
failedTaskIds?: string[];
}; };
/** /**
@@ -643,7 +646,7 @@ export function ImageWorkbenchPage({
modelConfigs: ModelConfig[]; modelConfigs: ModelConfig[];
onBack: () => void; onBack: () => void;
navigate?: (page: Page) => 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>; onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>;
/** 行19:从商品页带入的初始商品 id(可选);未传则回退到第一个商品 */ /** 行19:从商品页带入的初始商品 id(可选);未传则回退到第一个商品 */
initialProductId?: string; initialProductId?: string;
@@ -876,6 +879,11 @@ export function ImageWorkbenchPage({
const result: GenBatch[] = []; const result: GenBatch[] = [];
for (const [key, list] of groups) { for (const [key, list] of groups) {
// R109:成功但成图已全部删除(软删进垃圾桶)的任务不再回显 —— 生成记录与资产库数据绑定 // 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); const live = list.filter((t) => t.status !== "succeeded" || (t.assets || []).length > 0);
if (!live.length) continue; if (!live.length) continue;
const assets = live.flatMap((t) => t.assets || []); 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"; const status: GenBatch["status"] = hasRunning ? "generating" : assets.length > 0 ? "done" : "failed";
// 应出张数只数原始任务:重跑/补图任务(rerun)是替补,计入会把失败格越滚越多 // 应出张数只数原始任务:重跑/补图任务(rerun)是替补,计入会把失败格越滚越多
// (原失败任务仍在批里,重跑一次多一个格)。成功补图后 results 增长,失败格自然收掉。 // (原失败任务仍在批里,重跑一次多一个格)。成功补图后 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 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 原样复用 // 该批用过的参考图(后端解析回 {id,name,url}):批次头回显 + 重跑凭 assetId 原样复用
const refSrc = live.find((t) => (t.reference_images || []).length)?.reference_images || []; const refSrc = live.find((t) => (t.reference_images || []).length)?.reference_images || [];
result.push({ result.push({
id: key, id: key,
prompt: live[0]?.prompt || "", prompt: live[0]?.prompt || "",
ratio: live[0]?.ratio || meta.ratio, ratio: live[0]?.ratio || meta.ratio,
count: Math.max(1, intended), count,
status, status,
results: assets, results: assets,
ts: new Date(live[0]?.created_at || Date.now()).getTime(), ts: new Date(live[0]?.created_at || Date.now()).getTime(),
@@ -900,6 +913,8 @@ export function ImageWorkbenchPage({
// 真 batch_id 才能作重跑归属;老任务无 batch_id 时 key=任务 id,不能带给后端 // 真 batch_id 才能作重跑归属;老任务无 batch_id 时 key=任务 id,不能带给后端
backendBatchId: live[0]?.batch_id || undefined, backendBatchId: live[0]?.batch_id || undefined,
taskIds: live.map((t) => t.id), 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 TERMINAL = new Set(["succeeded", "failed", "cancelled"]);
const result: GenBatch[] = []; const result: GenBatch[] = [];
for (const [key, list] of groups) { 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); const live = list.filter((t) => t.status !== "succeeded" || (t.assets || []).length > 0);
if (!live.length) continue; if (!live.length) continue;
const assets = live.flatMap((t) => t.assets || []); const assets = live.flatMap((t) => t.assets || []);
@@ -928,12 +948,17 @@ export function ImageWorkbenchPage({
const first = live[0]; const first = live[0];
const platformKey = first.platform_id ? PLATFORM_KEY_MAP[first.platform_id] : ""; const platformKey = first.platform_id ? PLATFORM_KEY_MAP[first.platform_id] : "";
// 应出张数只数原始任务(重跑/补图任务是替补,不计入,与 batchesFromConvTasks 同理) // 应出张数只数原始任务(重跑/补图任务是替补,不计入,与 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 intended = live.filter((t) => !t.rerun).length;
const count = hasRunning ? Math.max(1, intended) : Math.max(1, assets.length + failedTaskIds.length);
result.push({ result.push({
id: key, id: key,
prompt: first.prompt || "", prompt: first.prompt || "",
ratio: first.ratio || meta.ratio, ratio: first.ratio || meta.ratio,
count: Math.max(1, intended), count,
status, status,
results: assets, results: assets,
ts: new Date(first.created_at || Date.now()).getTime(), ts: new Date(first.created_at || Date.now()).getTime(),
@@ -942,6 +967,8 @@ export function ImageWorkbenchPage({
platformIds: platformKey ? [platformKey] : undefined, platformIds: platformKey ? [platformKey] : undefined,
backendBatchId: first.batch_id || undefined, backendBatchId: first.batch_id || undefined,
taskIds: live.map((t) => t.id), taskIds: live.map((t) => t.id),
rerunTaskIds: live.filter((t) => t.rerun).map((t) => t.id),
failedTaskIds,
// 续轮询要带上整批任务 id(含已终态的):onResume 的结果会整体替换 results, // 续轮询要带上整批任务 id(含已终态的):onResume 的结果会整体替换 results,
// 只传未完成 id 会把已出的好图从结果里丢掉(终态任务首轮轮询即回带成图,秒完成)。 // 只传未完成 id 会把已出的好图从结果里丢掉(终态任务首轮轮询即回带成图,秒完成)。
pendingIds: hasRunning ? live.map((t) => t.id) : undefined, pendingIds: hasRunning ? live.map((t) => t.id) : undefined,
@@ -1062,6 +1089,8 @@ export function ImageWorkbenchPage({
appendToBatchId?: string; appendToBatchId?: string;
/** 重跑/补图:原批次的后端 batch_id,带给后端沿用 → 新任务归回原批次(刷新后不裂新记录) */ /** 重跑/补图:原批次的后端 batch_id,带给后端沿用 → 新任务归回原批次(刷新后不裂新记录) */
batchId?: string; batchId?: string;
/** 单格重跑要替代的原失败任务。 */
retryOfTaskId?: string;
}) { }) {
// 原地重跑/追加:沿用原批次 id,不再生成新 id、不再新增卡片 // 原地重跑/追加:沿用原批次 id,不再生成新 id、不再新增卡片
const batchId = opts.reuseBatchId || opts.appendToBatchId || `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; 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 取最新值,避免闭包旧值 // 带上当前对话 id(空则后端自动开一条并回传);conversation_id 用 ref 取最新值,避免闭包旧值
// batch_id:重跑/补图带原批次 id → 后端沿用,记录归回原批次(刷新后不裂新聊天记录) // batch_id:重跑/补图带原批次 id → 后端沿用,记录归回原批次(刷新后不裂新聊天记录)
// onSubmitted:提交成功拿到任务 id 记进本批 pendingIds → 切走再回来由后端记录接续轮询(R100) // 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, 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) => (b.id === batchId ? { ...b, pendingIds: taskIds, taskIds, backendBatchId: b.backendBatchId || submittedBatchId } : b))) }); 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;刷新列表拿到真标题/计数 // 首发新对话:把后端建的对话登记进左栏并设为 active;刷新列表拿到真标题/计数
const convId = result?.conversation_id; const convId = result?.conversation_id;
if (convId && convId !== activeConvRef.current) { if (convId && convId !== activeConvRef.current) {
@@ -1132,7 +1169,18 @@ export function ImageWorkbenchPage({
if (opts.appendToBatchId) { if (opts.appendToBatchId) {
const seen = new Set(b.results.map((a) => a.id)); const seen = new Set(b.results.map((a) => a.id));
const merged = [...b.results, ...newAssets.filter((a) => !a.id || !seen.has(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 }; 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) => { setBatches((prev) => prev.flatMap((b) => {
if (b.id !== target.batchId) return [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); const results = b.results.filter((a) => a.id !== target.assetId);
if (!results.length && b.status === "done") return []; const taskIds = deletedTaskId ? b.taskIds?.filter((id) => id !== deletedTaskId) : b.taskIds;
return [{ ...b, results, count: Math.max(1, b.count - 1) }]; 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; return;
} }
@@ -1252,7 +1312,7 @@ export function ImageWorkbenchPage({
/* 单图「再生成」/(§4.18 / YYX#5): /* 单图「再生成」/(§4.18 / YYX#5):
PMC#25() */ PMC#25() */
async function regenSingleImage(src: GenBatch) { async function regenSingleImage(src: GenBatch, retryOfTaskId?: string) {
await startBatch({ await startBatch({
prompt: src.prompt, prompt: src.prompt,
ratio: src.ratio, ratio: src.ratio,
@@ -1266,7 +1326,8 @@ export function ImageWorkbenchPage({
// 原批次的参考图 + 后端批次 id 一并带回:补的这张仍参考原素材,且归回原批次不裂新记录 // 原批次的参考图 + 后端批次 id 一并带回:补的这张仍参考原素材,且归回原批次不裂新记录
refs: src.refs, refs: src.refs,
batchId: src.backendBatchId, batchId: src.backendBatchId,
appendToBatchId: src.id appendToBatchId: src.id,
retryOfTaskId
}); });
} }
@@ -1335,7 +1396,9 @@ export function ImageWorkbenchPage({
+ R109:成图自动入资产库,/ */ + R109:成图自动入资产库,/ */
function renderBatchGrid(batch: GenBatch) { function renderBatchGrid(batch: GenBatch) {
const generating = batch.status === "generating"; 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 cols = n >= 4 ? 4 : 2;
const ratioBatchVar = batch.ratio.replace(":", " / "); const ratioBatchVar = batch.ratio.replace(":", " / ");
return ( return (
@@ -1345,11 +1408,12 @@ export function ImageWorkbenchPage({
> >
{/* YYX#5: count ; = , {/* 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 asset = batch.results[index];
const failedTaskId = batch.failedTaskIds?.[index - batch.results.length];
const url = asset?.files?.[0]?.preview_url; const url = asset?.files?.[0]?.preview_url;
const assetId = asset?.id || ""; const assetId = asset?.id || "";
const failed = !generating && !asset; // 非生成中且该格无图 → 这张失败了 const failed = !generating && !asset && (Boolean(failedTaskId) || !batch.failedTaskIds); // 非生成中且该格无图 → 这张失败了
const key = assetId || `ph-${index}`; const key = assetId || `ph-${index}`;
const cellKey = `${batch.id}:${assetId || key}`; const cellKey = `${batch.id}:${assetId || key}`;
return ( return (
@@ -1367,7 +1431,7 @@ export function ImageWorkbenchPage({
<span className="gen-failed"> <span className="gen-failed">
<X size={18} /> <X size={18} />
<span className="gf-text"></span> <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} /> <RefreshCw size={12} />
</button> </button>
+2
View File
@@ -674,6 +674,7 @@ export type WorkbenchTask = {
platform_id: string; platform_id: string;
/** 重跑/补图任务(复用原批次 batch_id 追加):不计入批次「应出张数」 */ /** 重跑/补图任务(复用原批次 batch_id 追加):不计入批次「应出张数」 */
rerun?: boolean; rerun?: boolean;
retry_of_task_id?: string;
created_at: string; created_at: string;
assets: Asset[]; assets: Asset[];
}; };
@@ -688,6 +689,7 @@ export type ImageConversationTask = {
ratio: string; ratio: string;
/** 重跑/补图任务(复用原批次 batch_id 追加):不计入批次「应出张数」 */ /** 重跑/补图任务(复用原批次 batch_id 追加):不计入批次「应出张数」 */
rerun?: boolean; rerun?: boolean;
retry_of_task_id?: string;
/** id 供重跑时原样复用参考图(老部署/老数据可能没有) */ /** id 供重跑时原样复用参考图(老部署/老数据可能没有) */
reference_images: { id?: string; name: string; url: string }[]; reference_images: { id?: string; name: string; url: string }[];
created_at: string; created_at: string;
@@ -1,5 +1,11 @@
# 图片生成无法删除失败图片 BUG TODO # 图片生成无法删除失败图片 BUG TODO
> 最终状态:已完成(2026-07-16)
>
> 补充修复:三种图片生成模式的单图删除、失败格重跑与页面回填已统一。重跑图会绑定其替代的失败格;删除该补图后,对应失败格不会在刷新或切换页面后复活。历史补图记录也按原始批次张数回显,不会出现“最多 4 张却显示 5 张”。
>
> 验证:后端重跑关联回归测试通过;前端 TypeScript 与生产构建通过;单图仍进入原有资产垃圾桶,整批删除与“图片异常批次”流程保持不变。
> 状态:Step 2 方案已确认,待进入实施 > 状态:Step 2 方案已确认,待进入实施
> 页面:图片生成入口 `/asset-factory` 所进入的模特上身图、平台套图、图片创作 > 页面:图片生成入口 `/asset-factory` 所进入的模特上身图、平台套图、图片创作
> 协作规则:一次只执行一个 Step;在确认问题表现前,不排查、不修改代码。 > 协作规则:一次只执行一个 Step;在确认问题表现前,不排查、不修改代码。