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:
@@ -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 请求里立刻反馈给前端(不会先建半套任务)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user