diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 64f69b3..a4906fd 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -1166,8 +1166,11 @@ def build_platform_cover_prompt_refs( def build_free_reference_prompt(base_prompt: str, n_refs: int = 1, index: int = 0) -> str: """图片创作自由模式 · 带用户上传参考图时的提示词。 纯把用户原话丢给图生图,模型只会松散借个色调、不会真的保留参考图里的主体(背心/商品/人物), - 这正是「没参考我上传的素材」的根因。这里显式把参考图钉成「画面主体的唯一依据」,要求严格保留其 - 外形/款式/配色/材质/品牌文字/图案,再在此基础上按用户要求创作。""" + 这是「没参考我上传的素材」的根因 → 参考图仍钉成「画面主体的唯一依据」。 + 但一致性只锁「主体身份」(人物的五官/发型/体型,商品的品类/外形/Logo),不再无差别钉死款式/配色/材质: + 用户要求本身常常就是要改变某个属性(如「参考这个角色,生成现代服装的穿着」),旧模板的 + 「严格保留款式、不要换款」与之直接矛盾 → 同批图模型每张随机听一边,一半换装一半原封不动。 + 改为:用户明确要求改变的部分以用户要求为最高优先级;用户没提的部分才默认与参考图一致。""" base = (base_prompt or "").strip() if n_refs <= 1: ref_intro = "参考图是用户提供的素材,是本次画面主体(商品 / 人物 / 物体)的唯一依据。" @@ -1178,18 +1181,21 @@ def build_free_reference_prompt(base_prompt: str, n_refs: int = 1, index: int = ref_word = f"参考图{rng}" lines = [ ref_intro, - f"请严格保留{ref_word}中主体的外形、款式、配色、材质、纹样、品牌文字与 Logo," - "不要重新设计、不要换款、不要生成相似但不同的物体;主体须与参考图高度一致。", + f"画面主体必须取自{ref_word},主体身份须与参考图高度一致:人物须是同一个人(五官、发型、肤色、体型不变)," + "商品 / 物体须是同一件(品类、外形、品牌文字与 Logo 不变);不要换人、不要换成相似但不同的物体。", ] if base: - lines.append(f"在此基础上,按用户要求创作:{base}") + lines.append( + f"用户要求:{base}。用户要求是最高优先级:用户明确要求改变的部分(如服装、场景、动作、风格等)" + "必须按要求大胆改变、不要保留参考图原样;用户没有要求改变的部分,保持与参考图一致。" + ) else: - lines.append("在此基础上,生成干净、专业的电商视觉画面。") - # 同批多张要不同构图/视角,否则雷同(PMC#24);主体一致性已在上面钉死,这里只变表现。 + lines.append("用户未提出改变要求:请严格保留主体的外形、款式、配色、材质、纹样与图案,生成干净、专业的电商视觉画面。") + # 同批多张要不同构图/视角,否则雷同(PMC#24);主体身份一致性已在上面钉死,这里只变表现。 # 自由模式用 _FREE_VARIATIONS(只换视角/光线/机位),不能用套图 _COVER_VARIATIONS: # 后者含"卖点文案分区/留出标题价签区",会让带参考图的第2张起也凭空长出广告文案。 if index > 0: - lines.append(_FREE_VARIATIONS[index % len(_FREE_VARIATIONS)] + ",在保持主体一致的前提下换一种表现,不要添加任何文字、卖点、标题或价签。") + lines.append(_FREE_VARIATIONS[index % len(_FREE_VARIATIONS)] + ",在保持主体身份一致、遵循用户要求的前提下换一种表现,不要添加任何文字、卖点、标题或价签。") lines.append("画面真实、构图协调、细节清晰。") return " ".join(lines) @@ -2407,7 +2413,7 @@ def _reap_stale_standalone_image_tasks(*, team) -> None: 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) -> 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) -> list[AITask]: """独立生图(图片创作 / 模特上身图 / 平台套图)改为**异步**:本函数在 Web 请求里只做「建任务 + 预留额度」这种秒级的活,真正 ~30s 的 ARK 出图交给 Celery worker(generate_standalone_image_task)。 @@ -2432,13 +2438,28 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c if alt is not None: model_config = alt # 本次提交 = 一组(模特上身图组 / 平台套图组):同一 batch_id 串起这批图,前端可成组展示。 - batch_id = str(uuid.uuid4()) + # 重跑/补图会带原批次的 batch_id 进来 → 沿用它并打 batch_append 标记,后端记录归回原批次 + # 而不是裂成一条新批次(前端按 batch_id 归批,刷新/切换对话后重跑图仍在原卡里); + # batch_append 任务不计入批次「应出张数」,前端据此在补图成功后收掉对应的失败格。 + is_append = False + if batch_id: + try: + batch_id = str(uuid.UUID(str(batch_id))) + is_append = True + except (TypeError, ValueError): + batch_id = None + if not is_append: + batch_id = str(uuid.uuid4()) # 平台套图:规范化平台 id(前端 dy/tb… → canonical),用于注入平台版式块(优化版);非 cover 模式忽略。 platform_key = str(platform_id or "").strip() if mode == "cover" else "" platform_name = _PLATFORM_NAMES.get(platform_key, "") tasks: list[AITask] = [] for index in range(count): cost = estimate_cost(model_config) + request_payload = {"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode, "index": index, "product_id": str(product_id) if product_id else None, "reference_product": bool(reference_product), "model_id": str(model_id) if model_id else None, "model_entity_id": str(model_entity_id) if model_entity_id else None, "batch_id": batch_id, "ratio": str(ratio) if ratio else None, "reference_image_ids": ref_ids, "platform_id": platform_key or None, "platform_name": platform_name or None} + # 只在重跑/补图时落键(不落 False):workbench 用 KeyTextTransform 抽文本,"false" 字符串也是真值,会误判 + if is_append: + request_payload["batch_append"] = True task = AITask.objects.create( team=team, created_by=user, @@ -2448,7 +2469,7 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c status=AITask.Status.CREATED, model_config=model_config, idempotency_key=f"standalone-image:{team.id}:{uuid.uuid4()}", - request_payload={"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode, "index": index, "product_id": str(product_id) if product_id else None, "reference_product": bool(reference_product), "model_id": str(model_id) if model_id else None, "model_entity_id": str(model_entity_id) if model_entity_id else None, "batch_id": batch_id, "ratio": str(ratio) if ratio else None, "reference_image_ids": ref_ids, "platform_id": platform_key or None, "platform_name": platform_name or None}, + request_payload=request_payload, estimated_cost=cost, ) # 预留额度若余额不足会抛 ValueError,在同步的 Web 请求里立刻反馈给前端(不会先建半套任务) diff --git a/core/backend/apps/ai/tests.py b/core/backend/apps/ai/tests.py index 8863556..aecebfc 100644 --- a/core/backend/apps/ai/tests.py +++ b/core/backend/apps/ai/tests.py @@ -263,11 +263,12 @@ class StandaloneImageReferenceTests(TestCase): ) prov.image_edit.assert_called_once() self.assertEqual(prov.image_edit.call_args.kwargs["images"], ["http://x/ref.png"]) - # 提示词必须显式钉住「保留参考图主体」+ 带上用户原话(否则图生图不真的还原上传素材) + # 提示词必须显式钉住「参考图=主体唯一依据」+ 带上用户原话(否则图生图不真的还原上传素材); + # 但不能再无差别「严格保留款式」——用户要求(如换现代服装)必须是最高优先级,否则同批一半不换装 used_prompt = prov.image_edit.call_args.kwargs["prompt"] self.assertIn("按这件背心生成不同场景的穿搭", used_prompt) - self.assertIn("参考图", used_prompt) - self.assertIn("严格保留", used_prompt) + self.assertIn("唯一依据", used_prompt) + self.assertIn("用户要求是最高优先级", used_prompt) prov.image_generation.assert_not_called() def test_refs_force_image_edit_model_when_volcano_selected(self): @@ -374,9 +375,45 @@ class ImageConversationTests(TestCase): # 任务 payload 落了参考图 id task = AITask.objects.filter(conversation_id=conv_id).first() self.assertEqual(task.request_payload.get("reference_image_ids"), [str(ref.id)]) - # tasks 接口把参考图解析成 {name,url} 回显 + # tasks 接口把参考图解析成 {id,name,url} 回显 —— id 供前端重跑时原样复用(否则重跑丢参考图) data = self.client.get(f"/api/ai/image-conversations/{conv_id}/tasks/").json()["tasks"] - self.assertEqual(data[0]["reference_images"], [{"name": "背心参考", "url": "http://x/ref.png"}]) + self.assertEqual(data[0]["reference_images"], [{"id": str(ref.id), "name": "背心参考", "url": "http://x/ref.png"}]) + + def test_rerun_with_batch_id_reuses_batch_and_marks_append(self): + """重跑/补图带原批次 batch_id → 新任务沿用同一 batch_id 并打 batch_append 标记(tasks 接口回 + rerun=True)—— 守护「重跑不裂新批次卡 + 失败格计数不膨胀」;非法 batch_id 则铸新批次兜底。""" + CreditAccount.objects.create(team=self.team, balance="100.0000") + patch("apps.ai.tasks.generate_standalone_image_task.delay").start() + self.addCleanup(patch.stopall) + first = self.client.post( + "/api/ai/generate-image/", {"prompt": "一只猫", "mode": "image", "count": 2}, format="json" + ) + self.assertEqual(first.status_code, 202, first.content) + conv_id = first.json()["conversation_id"] + batch_id = first.json()["batch_id"] + self.assertTrue(batch_id) + # 重跑一张:带原对话 + 原 batch_id → 归回原批次 + second = self.client.post( + "/api/ai/generate-image/", + {"prompt": "一只猫", "mode": "image", "count": 1, "conversation_id": conv_id, "batch_id": batch_id}, + format="json", + ) + self.assertEqual(second.status_code, 202, second.content) + self.assertEqual(second.json()["batch_id"], batch_id) + tasks = self.client.get(f"/api/ai/image-conversations/{conv_id}/tasks/").json()["tasks"] + self.assertEqual(len(tasks), 3) + self.assertTrue(all(t["batch_id"] == batch_id for t in tasks)) + self.assertEqual([t["rerun"] for t in tasks], [False, False, True]) + # 非法 batch_id(不是 UUID):不沿用,铸新批次,不打 append 标记 + third = self.client.post( + "/api/ai/generate-image/", + {"prompt": "一只猫", "mode": "image", "count": 1, "conversation_id": conv_id, "batch_id": "not-a-uuid"}, + format="json", + ) + self.assertEqual(third.status_code, 202, third.content) + self.assertNotEqual(third.json()["batch_id"], batch_id) + newest = self.client.get(f"/api/ai/image-conversations/{conv_id}/tasks/").json()["tasks"][-1] + self.assertFalse(newest["rerun"]) class StandaloneCategoryTests(TestCase): diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index fdf740c..723f195 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -41,6 +41,8 @@ class GenerateImageView(APIView): # 平台套图:前端传规范化平台 id(taobao/douyin/…),用于后端注入平台版式块(优化版) platform_id = str(request.data.get("platform_id") or "").strip() or None conversation_id = str(request.data.get("conversation_id") or "").strip() or None + # 重跑/补图:前端带原批次 batch_id → enqueue 沿用(UUID 校验),记录归回原批次不裂新卡 + batch_id = str(request.data.get("batch_id") or "").strip() or None # 用户在图片创作里上传的参考图(已先传成 Asset),按 id 列表带入 → 生成时作多图参考(image_edit) raw_refs = request.data.get("reference_image_ids") or [] if isinstance(raw_refs, str): @@ -60,13 +62,18 @@ class GenerateImageView(APIView): title=(prompt[:24] or "默认创作"), ) 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) + 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) except ValueError as exc: # 无可用模型 / 余额不足等,立即反馈 return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) # 本次提交即刷新对话活跃时间,左栏「最近」据此置顶 ImageConversation.objects.filter(id=conversation.id).update(last_active_at=timezone.now()) + # batch_id 回传给前端存进批次卡:后续「重跑这张 / 重跑整批」带它回来即可归回原批次 return Response( - {"conversation_id": str(conversation.id), "tasks": [{"id": str(t.id), "status": t.status} for t in tasks]}, + { + "conversation_id": str(conversation.id), + "batch_id": (tasks[0].request_payload or {}).get("batch_id") if tasks else None, + "tasks": [{"id": str(t.id), "status": t.status} for t in tasks], + }, status=status.HTTP_202_ACCEPTED, ) @@ -177,6 +184,8 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet): rp_platform_id=KeyTextTransform("platform_id", "request_payload"), rp_model_id=KeyTextTransform("model_id", "request_payload"), rp_model_entity_id=KeyTextTransform("model_entity_id", "request_payload"), + # 只在重跑任务里落此键(值恒为 True);键不存在 → NULL → 假值,存在 → "true"/"1" → 真值 + rp_batch_append=KeyTextTransform("batch_append", "request_payload"), ) .filter(request_payload__mode=mode) # 路径查找,不能用 rp_mode 注解比较(MySQL 3141,见 _unread_base) ) @@ -197,6 +206,7 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet): "model_id": t.rp_model_id or "", "model_entity_id": t.rp_model_entity_id or "", "platform_id": t.rp_platform_id or "", + "rerun": bool(t.rp_batch_append), "created_at": t.created_at, # 软删的图不再出现在工作台记录里(R109:删除资产库图片 → 任务记录联动) "assets": AssetSerializer( @@ -271,7 +281,8 @@ class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): rid = str(rid) if rid not in ref_cache: a = Asset.objects.filter(id=rid).prefetch_related("files").first() - ref_cache[rid] = {"name": a.name, "url": _asset_preview(a)} if a else None + # 带上 id:前端重跑时凭它原样复用参考图(否则刷新恢复的批次只有 {name,url},重跑丢参考) + ref_cache[rid] = {"id": rid, "name": a.name, "url": _asset_preview(a)} if a else None if ref_cache[rid]: out.append(ref_cache[rid]) return out @@ -284,6 +295,8 @@ class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): "prompt": (t.request_payload or {}).get("prompt", ""), "batch_id": (t.request_payload or {}).get("batch_id", ""), "ratio": (t.request_payload or {}).get("ratio") or "", + # 重跑/补图任务:不计入批次「应出张数」,前端据此正确渲染失败格数量 + "rerun": bool((t.request_payload or {}).get("batch_append")), "reference_images": resolve_refs((t.request_payload or {}).get("reference_image_ids")), "created_at": t.created_at, "assets": AssetSerializer( diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 1ad6e89..49e6046 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -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) diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 8a2284e..c914228 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -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") { diff --git a/core/frontend/src/routes/ai-tools.tsx b/core/frontend/src/routes/ai-tools.tsx index 63c9f24..1ee651b 100644 --- a/core/frontend/src/routes/ai-tools.tsx +++ b/core/frontend/src/routes/ai-tools.tsx @@ -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 }); } diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index b32904f..a799dfb 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -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[]; };