diff --git a/core/backend/apps/ai/tests.py b/core/backend/apps/ai/tests.py index 28b308e..0ad7e5a 100644 --- a/core/backend/apps/ai/tests.py +++ b/core/backend/apps/ai/tests.py @@ -1012,6 +1012,42 @@ class ImageConversationTests(TestCase): self.assertEqual(self.client.get("/api/ai/image-conversations/?mode=image").json()["results"], []) self.assertTrue(ImageConversation.objects.get(id=conv_id).is_deleted) + def test_listing_filters_product_and_unbound_scopes(self): + product = Product.objects.create(team=self.team, created_by=self.user, title="范围商品") + unbound = ImageConversation.objects.create(team=self.team, created_by=self.user, title="通用会话") + bound = ImageConversation.objects.create(team=self.team, created_by=self.user, title="商品会话", product=product) + + unbound_rows = self.client.get( + "/api/ai/image-conversations/?mode=image&scope=unbound" + ).json()["results"] + product_rows = self.client.get( + f"/api/ai/image-conversations/?mode=image&product_id={product.id}" + ).json()["results"] + + self.assertEqual([row["id"] for row in unbound_rows], [str(unbound.id)]) + self.assertEqual([row["id"] for row in product_rows], [str(bound.id)]) + conflict = self.client.get( + f"/api/ai/image-conversations/?mode=image&scope=unbound&product_id={product.id}" + ) + self.assertEqual(conflict.status_code, 400) + + def test_conversation_scope_rejects_cross_team_product(self): + other = User.objects.create_user(username="conv-other", password="pass") + other_team = Team.objects.create(name="Conv Other", owner=other) + other_product = Product.objects.create(team=other_team, created_by=other, title="其他团队商品") + + listed = self.client.get( + f"/api/ai/image-conversations/?mode=image&product_id={other_product.id}" + ) + created = self.client.post( + "/api/ai/image-conversations/", + {"mode": "image", "title": "越权会话", "product": str(other_product.id)}, + format="json", + ) + + self.assertEqual(listed.status_code, 400) + self.assertEqual(created.status_code, 400) + def test_trash_restore_and_purge_conversation_keeps_record(self): conv = ImageConversation.objects.create(team=self.team, created_by=self.user, title="待删", mode=ImageConversation.Mode.IMAGE) provider = ModelProvider.objects.create(name="conv-trash-provider", display_name="Conv Trash Provider") @@ -1162,13 +1198,79 @@ class ImageConversationTests(TestCase): self.assertTrue(all(t.conversation_id == conv.id for t in tasks)) self.assertEqual(conv.tasks.count(), 2) + def test_image_generate_without_product_keeps_conversation_and_task_unbound(self): + """图片生成首页进入的图片创作不应静默补入当前/首个商品。""" + CreditAccount.objects.create(team=self.team, balance="100.0000") + Product.objects.create(team=self.team, created_by=self.user, title="不应自动关联的商品") + with patch("apps.ai.tasks.generate_standalone_image_task.delay"): + response = self.client.post( + "/api/ai/generate-image/", + {"prompt": "一只宇航猫", "mode": "image", "count": 1}, + format="json", + ) + + self.assertEqual(response.status_code, 202, response.content) + conversation = ImageConversation.objects.get(id=response.json()["conversation_id"]) + task = AITask.objects.get(conversation=conversation) + self.assertIsNone(conversation.product_id) + self.assertIsNone(task.request_payload.get("product_id")) + + def test_image_generate_with_explicit_product_keeps_product_binding(self): + """商品详情显式进入图片创作时,仍保留商品归属。""" + CreditAccount.objects.create(team=self.team, balance="100.0000") + product = Product.objects.create(team=self.team, created_by=self.user, title="显式关联商品") + with patch("apps.ai.tasks.generate_standalone_image_task.delay"): + response = self.client.post( + "/api/ai/generate-image/", + {"prompt": "商品海报", "mode": "image", "count": 1, "product_id": str(product.id)}, + format="json", + ) + + self.assertEqual(response.status_code, 202, response.content) + conversation = ImageConversation.objects.get(id=response.json()["conversation_id"]) + task = AITask.objects.get(conversation=conversation) + self.assertEqual(conversation.product_id, product.id) + self.assertEqual(task.request_payload.get("product_id"), str(product.id)) + + def test_new_generation_with_mismatched_conversation_creates_correct_scope(self): + CreditAccount.objects.create(team=self.team, balance="100.0000") + original_product = Product.objects.create(team=self.team, created_by=self.user, title="原商品") + target_product = Product.objects.create(team=self.team, created_by=self.user, title="目标商品") + original_conversation = ImageConversation.objects.create( + team=self.team, + created_by=self.user, + title="原商品会话", + product=original_product, + ) + + with patch("apps.ai.tasks.generate_standalone_image_task.delay"): + response = self.client.post( + "/api/ai/generate-image/", + { + "prompt": "目标商品海报", + "mode": "image", + "count": 1, + "product_id": str(target_product.id), + "conversation_id": str(original_conversation.id), + }, + format="json", + ) + + self.assertEqual(response.status_code, 202, response.content) + self.assertNotEqual(response.json()["conversation_id"], str(original_conversation.id)) + created_conversation = ImageConversation.objects.get(id=response.json()["conversation_id"]) + self.assertEqual(created_conversation.product_id, target_product.id) + self.assertEqual(original_conversation.tasks.count(), 0) + self.assertEqual(created_conversation.tasks.get().request_payload.get("product_id"), str(target_product.id)) + def test_tasks_endpoint_returns_grouped_history(self): - conv = ImageConversation.objects.create(team=self.team, created_by=self.user, title="历史") + product = Product.objects.create(team=self.team, created_by=self.user, title="历史商品") + conv = ImageConversation.objects.create(team=self.team, created_by=self.user, title="历史", product=product) mc = ModelConfig.objects.filter(capability=ModelConfig.Capability.IMAGE).first() AITask.objects.create( team=self.team, created_by=self.user, conversation=conv, task_type=AITask.Type.PRODUCT_IMAGE, status=AITask.Status.SUCCEEDED, model_config=mc, idempotency_key="conv-test-1", - request_payload={"prompt": "猫", "batch_id": "bx", "ratio": "1:1"}, + request_payload={"prompt": "猫", "batch_id": "bx", "ratio": "1:1", "product_id": str(product.id)}, ) r = self.client.get(f"/api/ai/image-conversations/{conv.id}/tasks/") self.assertEqual(r.status_code, 200, r.content) @@ -1176,6 +1278,40 @@ class ImageConversationTests(TestCase): self.assertEqual(len(data), 1) self.assertEqual(data[0]["prompt"], "猫") self.assertEqual(data[0]["batch_id"], "bx") + self.assertEqual(data[0]["product_id"], str(product.id)) + + def test_rerun_uses_original_batch_product_instead_of_request_product(self): + CreditAccount.objects.create(team=self.team, balance="100.0000") + original_product = Product.objects.create(team=self.team, created_by=self.user, title="原批次商品") + other_product = Product.objects.create(team=self.team, created_by=self.user, title="当前路由商品") + with patch("apps.ai.tasks.generate_standalone_image_task.delay"): + first = self.client.post( + "/api/ai/generate-image/", + {"prompt": "原商品海报", "mode": "image", "count": 1, "product_id": str(original_product.id)}, + format="json", + ) + self.assertEqual(first.status_code, 202, first.content) + original_task = AITask.objects.get(conversation_id=first.json()["conversation_id"]) + original_task.status = AITask.Status.FAILED + original_task.save(update_fields=["status"]) + rerun = self.client.post( + "/api/ai/generate-image/", + { + "prompt": "原商品海报", + "mode": "image", + "count": 1, + "product_id": str(other_product.id), + "conversation_id": first.json()["conversation_id"], + "batch_id": first.json()["batch_id"], + "retry_of_task_id": str(original_task.id), + }, + format="json", + ) + + self.assertEqual(rerun.status_code, 202, rerun.content) + self.assertEqual(rerun.json()["conversation_id"], first.json()["conversation_id"]) + newest = AITask.objects.filter(conversation_id=first.json()["conversation_id"]).order_by("created_at").last() + self.assertEqual(newest.request_payload.get("product_id"), str(original_product.id)) def test_generate_with_refs_persists_and_tasks_endpoint_returns_them(self): """HTTP 全链路:带 reference_image_ids 提交 → 任务 payload 落 ids → tasks 接口把参考图解析回 {name,url}。 diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index abda627..3ac2d07 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -1,8 +1,11 @@ +import uuid + from django.db import transaction from django.db.models import Count, Exists, OuterRef, Q from django.utils import timezone from rest_framework import status from rest_framework.decorators import action +from rest_framework.exceptions import ValidationError from rest_framework.parsers import FormParser, MultiPartParser from rest_framework.response import Response from rest_framework.views import APIView @@ -55,12 +58,50 @@ class GenerateImageView(APIView): raw_refs = [s for s in raw_refs.split(",") if s.strip()] reference_image_ids = [str(r).strip() for r in raw_refs if str(r).strip()] team = get_current_team(request.user) + if product_id: + try: + normalized_product_id = str(uuid.UUID(product_id)) + except (TypeError, ValueError, AttributeError): + return Response({"detail": "商品 ID 无效"}, status=status.HTTP_400_BAD_REQUEST) + if not Product.objects.filter(team=team, id=normalized_product_id).exists(): + return Response({"detail": "商品不存在或不属于当前团队"}, status=status.HTTP_400_BAD_REQUEST) + product_id = normalized_product_id # 对话归属:传了 id 就用现成对话(限本团队);没传则自动开一条新对话,标题取 prompt 前 24 字。 conversation = None if conversation_id: conversation = ImageConversation.objects.filter( team=team, id=conversation_id, is_deleted=False, purged_at__isnull=True ).first() + # 合法重跑/补图必须能在当前对话里找到原批次;归属强制读取原批次,不信任当前路由或客户端值。 + original_batch_task = None + if conversation is not None and batch_id: + candidates = AITask.objects.filter( + team=team, + conversation=conversation, + is_deleted=False, + purged_at__isnull=True, + request_payload__batch_id=batch_id, + ).order_by("created_at") + original_batch_task = next( + (task for task in candidates if not (task.request_payload or {}).get("batch_append")), + None, + ) + if original_batch_task is not None: + product_id = str((original_batch_task.request_payload or {}).get("product_id") or "").strip() or None + else: + # 任意 UUID 不能伪装成可追加批次;降级为普通新批次。 + batch_id = None + retry_of_task_id = None + elif batch_id: + batch_id = None + retry_of_task_id = None + + # 普通新批次不能写入其他商品/通用范围的旧会话;竞态或旧客户端出现错配时自动开正确范围会话。 + if conversation is not None: + same_mode = conversation.mode == mode + same_product = str(conversation.product_id or "") == str(product_id or "") + if not same_mode or (mode == ImageConversation.Mode.IMAGE and original_batch_task is None and not same_product): + conversation = None if conversation is None: conversation = ImageConversation.objects.create( team=team, @@ -415,8 +456,31 @@ class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): mode = self.request.query_params.get("mode", "").strip() if mode: queryset = queryset.filter(mode=mode) + if self.action == "list": + scope = self.request.query_params.get("scope", "").strip() + product_id = self.request.query_params.get("product_id", "").strip() + if scope and product_id: + raise ValidationError({"detail": "scope 与 product_id 不能同时传入"}) + if scope: + if scope != "unbound": + raise ValidationError({"detail": "scope 仅支持 unbound"}) + queryset = queryset.filter(product__isnull=True) + elif product_id: + try: + normalized_product_id = str(uuid.UUID(product_id)) + except (TypeError, ValueError, AttributeError) as exc: + raise ValidationError({"detail": "商品 ID 无效"}) from exc + if not Product.objects.filter(team=self.get_team(), id=normalized_product_id).exists(): + raise ValidationError({"detail": "商品不存在或不属于当前团队"}) + queryset = queryset.filter(product_id=normalized_product_id) return queryset + def perform_create(self, serializer): + product = serializer.validated_data.get("product") + if product is not None and product.team_id != self.get_team().id: + raise ValidationError({"product": "商品不存在或不属于当前团队"}) + super().perform_create(serializer) + def perform_destroy(self, instance): # 只联动该会话生成任务产出的 Asset,不碰用户上传的参考素材。 with transaction.atomic(): @@ -499,6 +563,7 @@ 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 "", + "product_id": str((t.request_payload or {}).get("product_id") or ""), # 重跑/补图任务:不计入批次「应出张数」,前端据此正确渲染失败格数量 "rerun": bool((t.request_payload or {}).get("batch_append")), "retry_of_task_id": str((t.request_payload or {}).get("retry_of_task_id") or ""), diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 05042d8..23715ce 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -460,7 +460,8 @@ export function App() { setNotice({ type: "info", text: "当前账号暂无访问权限" }); return; } - const productId = options.productId ?? activeProductId; + // 图片创作只有显式入口才携带商品;从图片生成首页进入时不能继承全局当前商品。 + const productId = next === "imageOptimize" ? options.productId : (options.productId ?? activeProductId); const projectId = options.projectId ?? activeProjectId; if (options.productId !== undefined) setActiveProductId(options.productId); if (options.projectId !== undefined) setActiveProjectId(options.projectId); @@ -955,7 +956,7 @@ export function App() { case "freeCreate": return setNotice({ type, text })} onTaskSettled={refreshFreeCreateShell} />; case "imageOptimize": - return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />; + return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />; case "modelPhoto": return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />; case "platformCover": diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index ade8a5b..9539c8d 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -723,8 +723,11 @@ export const api = { 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") { - return request>(`/api/ai/image-conversations/?mode=${mode}&page_size=100`); + listConversations(mode: "image" | "model" | "cover" = "image", productId?: string) { + const params = new URLSearchParams({ mode, page_size: "100" }); + if (productId) params.set("product_id", productId); + else params.set("scope", "unbound"); + return request>(`/api/ai/image-conversations/?${params.toString()}`); }, conversationsTrash(mode: "image" | "model" | "cover" = "image") { return request>(`/api/ai/image-conversations/trash/?mode=${mode}&page_size=100`); diff --git a/core/frontend/src/routes/ai-tools.tsx b/core/frontend/src/routes/ai-tools.tsx index c000ca6..030e160 100644 --- a/core/frontend/src/routes/ai-tools.tsx +++ b/core/frontend/src/routes/ai-tools.tsx @@ -635,6 +635,7 @@ export function ImageWorkbenchPage({ navigate, onGenerate, onResume, + imageProductId, initialProductId, onProductChange, unreadByProduct, @@ -648,6 +649,8 @@ export function ImageWorkbenchPage({ 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; 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>; + /** 图片创作仅接受路由显式带入的商品;不使用工作台全局当前商品。 */ + imageProductId?: string; /** 行19:从商品页带入的初始商品 id(可选);未传则回退到第一个商品 */ initialProductId?: string; /** 选中商品上抛给 App,持久化进 activeProductId;否则切走再回来选择会被重置回默认第一个商品 */ @@ -662,10 +665,15 @@ export function ImageWorkbenchPage({ const meta = MODE_META[mode]; const [productId, setProductId] = useState(initialProductId || products[0]?.id || ""); // 选中商品同步到 App(activeProductId),保证切换栏目再回来时初始商品仍是上次选的那个 - useEffect(() => { if (productId) onProductChange?.(productId); }, [productId, onProductChange]); + useEffect(() => { if (mode !== "image" && productId) onProductChange?.(productId); }, [mode, productId, onProductChange]); // YYX#row22:选中(查看)某商品即把它的未读生成任务标记已读 → 角标清零 - useEffect(() => { if (productId && (unreadByProduct?.[productId] ?? 0) > 0) onProductViewed?.(productId); }, [productId, unreadByProduct, onProductViewed]); + useEffect(() => { if (mode !== "image" && productId && (unreadByProduct?.[productId] ?? 0) > 0) onProductViewed?.(productId); }, [mode, productId, unreadByProduct, onProductViewed]); const product = products.find((item) => item.id === productId) || products[0]; + const imageProduct = products.find((item) => item.id === imageProductId); + const conversationScopeKey = imageProductId ? `product:${imageProductId}` : "unbound"; + const conversationScopeLabel = imageProductId ? `当前商品 · ${imageProduct?.title || "加载中"}` : "通用创作"; + const conversationScopeKeyRef = useRef(conversationScopeKey); + conversationScopeKeyRef.current = conversationScopeKey; // 图片创作(image)默认留空,只靠 placeholder 引导;模特/平台仍预填模板省一步 const [prompt, setPrompt] = useState(mode === "image" ? "" : meta.promptTemplate(products[0]?.title || "商品")); const [ratio, setRatio] = useState(meta.ratio); @@ -722,6 +730,8 @@ export function ImageWorkbenchPage({ const [activeConvId, setActiveConvId] = useState(""); const activeConvRef = useRef(""); useEffect(() => { activeConvRef.current = activeConvId; }, [activeConvId]); + const conversationLoadSeqRef = useRef(0); + const conversationBatchLoadSeqRef = useRef(0); const [convLoading, setConvLoading] = useState(false); // 对话操作失败的可见提示(替代原来的静默吞错) const [convError, setConvError] = useState(""); @@ -909,6 +919,8 @@ export function ImageWorkbenchPage({ status, results: assets, ts: new Date(live[0]?.created_at || Date.now()).getTime(), + productId: live[0]?.product_id || undefined, + productTitle: products.find((item) => item.id === live[0]?.product_id)?.title, 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, @@ -979,8 +991,10 @@ export function ImageWorkbenchPage({ // 拉某对话的历史批次并回显;非终态批次继续轮询补齐 const loadConvBatches = useCallback(async (convId: string) => { + const batchLoadSeq = ++conversationBatchLoadSeqRef.current; try { const res = await api.conversationTasks(convId); + if (batchLoadSeq !== conversationBatchLoadSeqRef.current) return; const next = batchesFromConvTasks(res.tasks); setBatches(next); // 仍在跑的批次(刷新时 worker 还没出完)继续轮询补齐 @@ -989,13 +1003,14 @@ export function ImageWorkbenchPage({ const ids = res.tasks.filter((t) => (t.batch_id || t.id) === b.id).map((t) => t.id); if (!ids.length) continue; onResume(mode, ids).then((r) => { + if (batchLoadSeq !== conversationBatchLoadSeqRef.current) return; if (!r?.assets) return; setBatches((prev) => prev.map((x) => (x.id === b.id ? { ...x, status: "done", results: r.assets } : x))); }); } } } catch { - setBatches([]); + if (batchLoadSeq === conversationBatchLoadSeqRef.current) setBatches([]); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [mode, onResume]); @@ -1003,6 +1018,7 @@ export function ImageWorkbenchPage({ // 切换对话:置为 active 并回填它的批次 const selectConversation = useCallback((convId: string) => { if (convId === activeConvRef.current) return; + activeConvRef.current = convId; setActiveConvId(convId); setRenamingId(""); void loadConvBatches(convId); @@ -1011,9 +1027,10 @@ export function ImageWorkbenchPage({ // 新对话:后端建一条 → 置顶列表 → 设为 active → 清空批次流 async function handleNewConversation() { try { - const conv = await api.createConversation({ mode, product: product?.id || null }); + const conv = await api.createConversation({ mode, product: imageProductId || null }); setConvError(""); setConversations((prev) => [conv, ...prev]); + activeConvRef.current = conv.id; setActiveConvId(conv.id); setBatches([]); setPrompt(mode === "image" ? "" : meta.promptTemplate(product?.title || "商品")); @@ -1039,6 +1056,7 @@ export function ImageWorkbenchPage({ setConversations(remaining); if (convId === activeConvId) { const nextActive = remaining[0]?.id || ""; + activeConvRef.current = nextActive; setActiveConvId(nextActive); if (nextActive) void loadConvBatches(nextActive); else setBatches([]); @@ -1050,24 +1068,40 @@ export function ImageWorkbenchPage({ // autoSelect=true(首次进入):自动选最近一条并回填历史;false(首发后只刷新列表):不动当前对话/批次。 const loadConversations = useCallback(async (autoSelect = true) => { if (mode !== "image") return; + const loadSeq = ++conversationLoadSeqRef.current; setConvLoading(true); try { - const res = await api.listConversations(mode); + const res = await api.listConversations(mode, imageProductId); + if (loadSeq !== conversationLoadSeqRef.current) return; setConversations(res.results); if (autoSelect && res.results.length && !activeConvRef.current) { + activeConvRef.current = res.results[0].id; setActiveConvId(res.results[0].id); void loadConvBatches(res.results[0].id); } } catch { + if (loadSeq !== conversationLoadSeqRef.current) return; setConversations([]); } finally { - setConvLoading(false); + if (loadSeq === conversationLoadSeqRef.current) setConvLoading(false); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mode, loadConvBatches]); + }, [mode, imageProductId, loadConvBatches]); - // 只在挂载 / 切 mode 时加载一次对话列表(不依赖 callback 身份,否则 onResume 每次渲染变更会触发反复重拉) - useEffect(() => { void loadConversations(); }, [mode]); // eslint-disable-line react-hooks/exhaustive-deps + // 图片创作范围变化时先同步清掉旧活动会话,再加载当前商品/通用范围;序号防止旧请求晚到覆盖新列表。 + useEffect(() => { + if (mode !== "image") return; + conversationLoadSeqRef.current += 1; + conversationBatchLoadSeqRef.current += 1; + activeConvRef.current = ""; + setActiveConvId(""); + setConversations([]); + setBatches([]); + setRenamingId(""); + setConvError(""); + void loadConversations(); + return () => { conversationLoadSeqRef.current += 1; }; + }, [mode, conversationScopeKey]); // eslint-disable-line react-hooks/exhaustive-deps /* 单批次执行:追加占位 → onGenerate → 回填结果/失败。所有提交路径(立即生成 / 重跑 / 单图再生成 / 平台分组)共用。 */ async function startBatch(opts: { @@ -1092,6 +1126,7 @@ export function ImageWorkbenchPage({ /** 单格重跑要替代的原失败任务。 */ retryOfTaskId?: string; }) { + const submittedScopeKey = conversationScopeKey; // 原地重跑/追加:沿用原批次 id,不再生成新 id、不再新增卡片 const batchId = opts.reuseBatchId || opts.appendToBatchId || `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; if (opts.reuseBatchId) { @@ -1146,6 +1181,7 @@ export function ImageWorkbenchPage({ // 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, retry_of_task_id: opts.retryOfTaskId, onSubmitted: (taskIds, submittedBatchId) => { + if (mode === "image" && submittedScopeKey !== conversationScopeKeyRef.current) return; setBatches((prev) => prev.map((b) => { if (b.id !== batchId) return b; const allTaskIds = opts.appendToBatchId ? [...new Set([...(b.taskIds || []), ...taskIds])] : taskIds; @@ -1154,9 +1190,11 @@ export function ImageWorkbenchPage({ })); } }); + if (mode === "image" && submittedScopeKey !== conversationScopeKeyRef.current) return; // 首发新对话:把后端建的对话登记进左栏并设为 active;刷新列表拿到真标题/计数 const convId = result?.conversation_id; if (convId && convId !== activeConvRef.current) { + activeConvRef.current = convId; setActiveConvId(convId); void loadConversations(false); } @@ -1185,6 +1223,7 @@ export function ImageWorkbenchPage({ return { ...b, backendBatchId, status: newAssets.length ? ("done" as const) : ("failed" as const), results: newAssets, pendingIds: undefined }; })); } catch { + if (mode === "image" && submittedScopeKey !== conversationScopeKeyRef.current) return; // 追加重跑失败:保留原有好图,只把状态落回(有图=done,无图=failed) setBatches((prev) => prev.map((b) => (b.id === batchId ? { ...b, status: ((opts.appendToBatchId && b.results.length ? "done" : "failed") as GenBatch["status"]) } : b))); } @@ -1192,14 +1231,17 @@ export function ImageWorkbenchPage({ async function runGenerate() { if (!canGenerate) return; + const requestedScopeKey = conversationScopeKey; // 图片创作:生成前先把对话「坐实」到侧栏(若还没有),这样这条长达 ~60s 的生成在进行中也能切走/切回—— // 否则对话要等生成返回后才登记进列表,生成中根本看不到这条会话 → 切走就找不回 → loading 状态像丢了。 if (mode === "image" && !activeConvRef.current) { try { - const conv = await api.createConversation({ mode, title: prompt.trim().slice(0, 24) || undefined }); - activeConvRef.current = conv.id; // ref 立即生效,startBatch 同步读得到 - setActiveConvId(conv.id); - setConversations((prev) => [conv, ...prev]); + const conv = await api.createConversation({ mode, product: imageProductId || null, title: prompt.trim().slice(0, 24) || undefined }); + if (requestedScopeKey === conversationScopeKeyRef.current) { + activeConvRef.current = conv.id; // ref 立即生效,startBatch 同步读得到 + setActiveConvId(conv.id); + setConversations((prev) => [conv, ...prev]); + } } catch { /* 建会话失败:回退到后端自动建(仍能生成,只是生成中暂不可切回) */ } @@ -1208,8 +1250,8 @@ export function ImageWorkbenchPage({ prompt: prompt.trim(), ratio, count: candidateCount, - productId: product?.id, - productTitle: product?.title + productId: mode === "image" ? imageProductId : product?.id, + productTitle: mode === "image" ? imageProduct?.title : product?.title }; if (mode === "cover") { // P0③:每个选中平台各起一批 → 右侧自然形成多平台分组 section; @@ -1240,8 +1282,8 @@ export function ImageWorkbenchPage({ ratio: src.ratio, count: src.count, // 重跑归属原批次的商品,而非当前选中商品 - productId: src.productId ?? product?.id, - productTitle: src.productTitle ?? product?.title, + productId: mode === "image" ? src.productId : (src.productId ?? product?.id), + productTitle: mode === "image" ? src.productTitle : (src.productTitle ?? product?.title), modelId: src.modelId, modelName: src.modelName, platformIds: src.platformIds, @@ -1317,8 +1359,8 @@ export function ImageWorkbenchPage({ prompt: src.prompt, ratio: src.ratio, count: 1, - productId: src.productId ?? product?.id, - productTitle: src.productTitle ?? product?.title, + productId: mode === "image" ? src.productId : (src.productId ?? product?.id), + productTitle: mode === "image" ? src.productTitle : (src.productTitle ?? product?.title), modelId: src.modelId, modelName: src.modelName, platformIds: src.platformIds, @@ -1591,7 +1633,7 @@ export function ImageWorkbenchPage({ 新对话 {convError &&
{convError}
} -
最近
+
{conversationScopeLabel}
{conversations.length === 0 ? (
diff --git a/core/frontend/src/routes/route-config.ts b/core/frontend/src/routes/route-config.ts index 279e2fe..98b3181 100644 --- a/core/frontend/src/routes/route-config.ts +++ b/core/frontend/src/routes/route-config.ts @@ -161,7 +161,9 @@ export function resolveRoute(): ResolvedRoute { if (path === "/messages") return { page: "messages", authMode: "login", hash }; if (path === "/asset-factory") return { page: "assetFactory", authMode: "login", hash }; if (path === "/free-create") return { page: "freeCreate", authMode: "login", hash }; - if (path === "/image-optimize") return { page: "imageOptimize", authMode: "login", hash }; + if (path === "/image-optimize") { + return { page: "imageOptimize", authMode: "login", productId: search.get("product_id") || undefined, hash }; + } if (path === "/model-photo") return { page: "modelPhoto", authMode: "login", hash }; if (path === "/model-photo/demo-a") return { page: "modelPhotoDemoA", authMode: "login", hash }; if (path === "/model-photo/demo-b") return { page: "modelPhotoDemoB", authMode: "login", hash }; @@ -203,7 +205,7 @@ export function pathForPage(page: Page, options: NavigateOptions = {}) { case "freeCreate": return "/free-create"; case "imageOptimize": - return "/image-optimize"; + return options.productId ? `/image-optimize?product_id=${encodeURIComponent(options.productId)}` : "/image-optimize"; case "modelPhoto": return "/model-photo"; case "modelPhotoDemoA": diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index 974b18c..04d7129 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -687,6 +687,7 @@ export type ImageConversationTask = { prompt: string; batch_id: string; ratio: string; + product_id: string; /** 重跑/补图任务(复用原批次 batch_id 追加):不计入批次「应出张数」 */ rerun?: boolean; retry_of_task_id?: string;