fix: 隔离图片创作商品会话
This commit is contained in:
@@ -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}。
|
||||
|
||||
@@ -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 ""),
|
||||
|
||||
@@ -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 <FreeCreatePage modelConfigs={modelConfigs} onNotify={(type, text) => setNotice({ type, text })} onTaskSettled={refreshFreeCreateShell} />;
|
||||
case "imageOptimize":
|
||||
return <ImageWorkbenchPage mode="image" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onProductChange={setActiveProductId} unreadByProduct={aiUnreadByProduct} onProductViewed={markAiRead} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />;
|
||||
return <ImageWorkbenchPage mode="image" products={products} modelConfigs={modelConfigs} imageProductId={route.productId} initialProductId={activeProductId} onProductChange={setActiveProductId} unreadByProduct={aiUnreadByProduct} onProductViewed={markAiRead} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />;
|
||||
case "modelPhoto":
|
||||
return <ImageWorkbenchPage mode="model" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onProductChange={setActiveProductId} unreadByProduct={aiUnreadByProduct} onProductViewed={markAiRead} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />;
|
||||
case "platformCover":
|
||||
|
||||
@@ -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<Paginated<ImageConversation>>(`/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<Paginated<ImageConversation>>(`/api/ai/image-conversations/?${params.toString()}`);
|
||||
},
|
||||
conversationsTrash(mode: "image" | "model" | "cover" = "image") {
|
||||
return request<Paginated<ImageConversation>>(`/api/ai/image-conversations/trash/?mode=${mode}&page_size=100`);
|
||||
|
||||
@@ -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<string>("");
|
||||
const activeConvRef = useRef<string>("");
|
||||
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({
|
||||
新对话
|
||||
</button>
|
||||
{convError && <div className="ic-conv-error">{convError}</div>}
|
||||
<div className="ic-side-sec">最近</div>
|
||||
<div className="ic-side-sec">{conversationScopeLabel}</div>
|
||||
<div className="ic-conv-list">
|
||||
{conversations.length === 0 ? (
|
||||
<div className="ic-conv-empty">
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user