From f2e8a5f3c14cd166da1538f7f33f6abd8016e6c1 Mon Sep 17 00:00:00 2001 From: "Azmat@qq.com" Date: Sun, 20 Sep 2026 09:28:31 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=A8=E8=83=BD=E5=88=9B=E4=BD=9C=EF=BC=9A?= =?UTF-8?q?=E6=8D=A2=E6=AC=BE=E5=8F=8C=E5=BD=A2=E6=80=81=E4=B8=8E=E7=B4=A0?= =?UTF-8?q?=E6=9D=90=E9=97=B8=E9=97=A8=E9=98=B2=E8=B7=B3=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本地上传优先于商品库推荐;点击换款支持只出手/角色日常;角色未锁定禁止进核心卖点,「你来推荐」空库不再假完成。 --- core/backend/apps/ai/creation_agent.py | 234 ++++++++++++++- core/backend/apps/ai/creation_presets.py | 54 +++- core/backend/apps/ai/test_creation_agent.py | 57 +++- core/backend/apps/ai/views.py | 307 +++++++++++++++++--- core/frontend/src/routes/omni-session.tsx | 53 +++- 5 files changed, 638 insertions(+), 67 deletions(-) diff --git a/core/backend/apps/ai/creation_agent.py b/core/backend/apps/ai/creation_agent.py index 6c78b4b..e7da0d7 100644 --- a/core/backend/apps/ai/creation_agent.py +++ b/core/backend/apps/ai/creation_agent.py @@ -376,6 +376,22 @@ def has_locked_person_reference(conversation: CreationConversation) -> bool: ) +def person_identity_ready(conversation: CreationConversation) -> bool: + """角色图是否已真正可用:已钉 Ref、正在生成、或明确只要手指(点击换款)。 + + 仅有 person_source_ready / 「你来推荐」但库里没命中、也没钉住人,不算完成。 + """ + if has_locked_person_reference(conversation): + return True + memory = conversation.memory if isinstance(conversation.memory, dict) else {} + if memory.get("person_source_pending"): + return True + if str(memory.get("person_source") or "") == "finger_only": + return True + return False + + + def locked_person_references(conversation: CreationConversation) -> list[dict]: """返回去重后的已锁定人物,顺序与用户添加顺序一致。""" people: list[dict] = [] @@ -539,11 +555,15 @@ def append_multi_character_relation_gate(conversation: CreationConversation) -> def video_needs_person_source(conversation: CreationConversation, user_text: str = "") -> bool: """需要真人/角色的视频在写策略前必须先锁定人物来源。""" - if conversation.mode != CreationConversation.Mode.VIDEO or has_locked_person_reference(conversation): + if conversation.mode != CreationConversation.Mode.VIDEO: + return False + # 已钉角色图 / 正在生成 / 只出手:才算人物步骤完成。禁止仅凭「你来推荐」空跑跳过。 + if person_identity_ready(conversation): return False memory = conversation.memory if isinstance(conversation.memory, dict) else {} - if memory.get("person_source_ready") or memory.get("person_source_pending"): - return False + # 点击换款:只有「角色日常换款」才要人物;「只出手」跳过,且不被开场文案里的「口播/剧情」否定词误触发。 + if is_click_swap_preset(conversation.preset): + return click_swap_mode(conversation) == "character" if conversation.preset in _PERSON_SOURCE_PRESETS or is_pet_preset(conversation.preset): return True recent = list( @@ -731,11 +751,18 @@ def click_swap_needs_sequence(conversation: CreationConversation) -> bool: def append_click_swap_sequence_gate(conversation: CreationConversation) -> CreationMessage: + mode = click_swap_mode(conversation) + if mode == "character": + hint = "先确认要切换的款式和展示顺序。角色会在日常场景中按这个顺序变色换款。" + placeholder = "例如:曜石黑 → 象牙白 → 樱花粉" + else: + hint = "先确认要切换的款式和展示顺序。后续每次手指点击都会严格按这个顺序原位换款。" + placeholder = "例如:黑色 → 白色 → 樱花粉" return append_message( conversation, role="assistant", kind=CreationMessage.Kind.ELICIT, - text="先确认要切换的款式和展示顺序。后续每次手指点击都会严格按这个顺序原位换款。", + text=hint, payload={ "interaction": "click_swap_sku_gate", "fields": [{ @@ -743,7 +770,7 @@ def append_click_swap_sequence_gate(conversation: CreationConversation) -> Creat "label": "款式与切换顺序", "type": "text", "required": True, - "placeholder": "例如:黑色 → 白色 → 樱花粉", + "placeholder": placeholder, }], "submitted": False, "answers": {}, @@ -751,6 +778,73 @@ def append_click_swap_sequence_gate(conversation: CreationConversation) -> Creat ) +def click_swap_mode(conversation: CreationConversation) -> str: + memory = conversation.memory if isinstance(conversation.memory, dict) else {} + return str(memory.get("click_swap_mode") or "").strip() + + +def click_swap_needs_mode(conversation: CreationConversation) -> bool: + """点击换款必须先选形态:只出手 / 角色日常换款。""" + if conversation.mode != CreationConversation.Mode.VIDEO: + return False + if not is_click_swap_preset(conversation.preset): + return False + return click_swap_mode(conversation) not in {"finger", "character"} + + +def append_click_swap_mode_gate(conversation: CreationConversation) -> CreationMessage: + return append_message( + conversation, + role="assistant", + kind=CreationMessage.Kind.ELICIT, + text="这条点击换款想用哪种形态?", + payload={ + "interaction": "click_swap_mode_gate", + "fields": [{ + "key": "click_swap_mode", + "label": "选择换款形态", + "type": "single", + "required": True, + "options": [ + {"value": "finger", "label": "只出手指出镜(不用角色图)"}, + {"value": "character", "label": "角色在日常场景中换款"}, + ], + }], + "submitted": False, + "answers": {}, + }, + ) + + +def apply_click_swap_mode(conversation: CreationConversation, mode: str) -> str: + """写入换款形态;只出手时直接跳过人物闸门。""" + value = str(mode or "").strip() + if value not in {"finger", "character"}: + return "" + memory = dict(conversation.memory or {}) + memory["click_swap_mode"] = value + if value == "finger": + memory["person_source"] = "finger_only" + memory["person_source_ready"] = True + memory["person_source_pending"] = False + else: + memory.pop("person_source_ready", None) + memory.pop("person_source_pending", None) + if memory.get("person_source") == "finger_only": + memory.pop("person_source", None) + conversation.memory = memory + conversation.save(update_fields=["memory", "updated_at"]) + if value == "finger": + return ( + "商家已选择【只出手指出镜】。不要生成角色定妆图,不要追问出镜人物;" + "后续只围绕固定机位 + 手指点击 + 商品原位换款推进。" + ) + return ( + "商家已选择【角色在日常场景中换款】。下一步先锁定出镜角色来源;" + "脚本必须是同一角色在日常使用场景中按序变色换款,不要改成只出手指出镜。" + ) + + def submit_generated_person_reference( *, conversation: CreationConversation, @@ -1622,6 +1716,7 @@ def apply_restart_intent(conversation: CreationConversation) -> int: memory.pop("pain_point_direction", None) memory.pop("click_swap_ready", None) memory.pop("click_swap_sequence", None) + memory.pop("click_swap_mode", None) conversation.memory = memory conversation.save(update_fields=["memory", "updated_at"]) @@ -2521,7 +2616,7 @@ def _video_submit_params(context: AgentContext, prompt: str) -> tuple[dict, list """拼 submit_free_video 的入参。references 直接用 resolve_refs 的产物 —— 它已经排好 角色 → 场景 → 商品 的顺序,那正是出片模型 @图N 的语义依据。""" resolved = resolve_refs(context.team, context.conversation.pinned_refs or []) - prompt = apply_video_preset_prompt(context.conversation.preset, prompt) + prompt = apply_video_preset_prompt(context.conversation.preset, prompt, click_swap_mode=click_swap_mode(context.conversation)) prompt = apply_product_voice_visual_guard(context.conversation, prompt) prompt = apply_product_reality_guard(prompt) prompt = apply_video_platform_safety_guard(prompt) @@ -2997,7 +3092,7 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c workflow_guidance = preset_workflow_guidance(conversation.preset) if context.is_video else "" if workflow_guidance: lines.append(f"【当前预设的工作重点】{workflow_guidance}") - delivery_contract = video_preset_delivery_contract(conversation.preset) if context.is_video else "" + delivery_contract = video_preset_delivery_contract(conversation.preset, click_swap_mode=click_swap_mode(conversation)) if context.is_video else "" if delivery_contract: lines.append(f"【当前预设必须贯穿脚本与出片】{delivery_contract}") @@ -3389,6 +3484,51 @@ def apply_click_swap_plan_card( third_end = max(second_end + 1, round(total * 0.75)) third_end = min(third_end, total - 1) second_end = min(second_end, third_end - 1) + if click_swap_mode(conversation) == "character": + return { + **card, + "usp": f"同一角色在日常场景中按「{sequence}」变色换款", + "points": [ + "锁定同一角色、同一生活场景与光线", + "角色自然使用或展示商品,按确认顺序换款", + f"严格按「{sequence}」逐款展示,结尾给出全款式收束", + ], + "timeline": [ + { + "start": 0, + "end": first_end, + "stage": "日常开场", + "desc": "同一角色在日常使用场景中亮相,商品作为手头物件自然入画。", + }, + { + "start": first_end, + "end": second_end, + "stage": "首次换款", + "desc": "角色保持同一身份与场景,商品切换到下一款,颜色/款式变化清楚。", + }, + { + "start": second_end, + "end": third_end, + "stage": "按序换款", + "desc": f"按「{sequence}」继续换款;人物五官、发型、身形、基础服装与场景不变。", + }, + { + "start": third_end, + "end": total, + "stage": "全款式收束", + "desc": "同一角色与场景下给出全款式收束,不改成口播带货腔或复杂剧情。", + }, + ], + "matrix": { + "shots": 4, + "rows": [ + {"point": "角色一致", "hits": [1, 2, 3, 4]}, + {"point": "日常场景", "hits": [1, 2, 3, 4]}, + {"point": "款式顺序", "hits": [1, 2, 3, 4]}, + ], + }, + "voice_chars": [0, 0], + } return { **card, "usp": f"手指逐次点击,商品按「{sequence}」在原位连续换款", @@ -4094,6 +4234,65 @@ def _elicit_payload_for_fields( } +def _asset_ask_already_resolved(conversation: CreationConversation, fields: list[dict]) -> str | None: + """素材类 ask_user 若本轮/会话已确认过,返回跳过原因;避免「你来推荐」后同款闸门再弹一次。""" + if not fields: + return None + field = fields[0] if isinstance(fields[0], dict) else {} + types = [t for t in (field.get("asset_types") or infer_field_types(field) or []) if t in TYPE_LABELS] + key = str(field.get("key") or "").strip().lower() + if not types: + if key in {"character", "person", "model", "product", "scene", "asset"}: + types = [key if key != "person" else "character"] + elif field.get("type") != "asset": + return None + else: + types = ["product"] + memory = conversation.memory if isinstance(conversation.memory, dict) else {} + if any(t in {"character", "model", "person"} for t in types): + if person_identity_ready(conversation): + return ( + "出镜角色图已锁定或正在生成。不要再 ask_user 追问角色;直接继续创作。" + ) + if "product" in types: + if memory.get("product_source_resolved") or has_locked_product_reference(conversation): + return ( + "商品来源已确认(含本地上传或「你来推荐」)。不要再 ask_user 追问商品;直接继续创作。" + ) + # 最近一条同类型闸门若已 submitted,也视为已答(防同轮或紧邻轮重复弹)。 + recent = conversation.messages.filter(kind=CreationMessage.Kind.ELICIT).order_by("-seq")[:6] + for message in recent: + payload = message.payload or {} + if not payload.get("submitted"): + continue + if payload.get("phase") not in {"gate", "pick"} and payload.get("interaction") not in { + "chat", "asset_picker", "person_source_gate" + }: + continue + pending = payload.get("pending_fields") or payload.get("fields") or [] + for item in pending: + if not isinstance(item, dict): + continue + item_types = [t for t in (item.get("asset_types") or infer_field_types(item) or []) if t] + if item.get("key") == "_asset_gate": + # pending_fields 才是真实素材类型 + continue + if set(item_types) & set(types): + return ( + f"刚刚已确认过「{'/'.join(types)}」素材选择。不要重复弹出同一问题;直接继续创作。" + ) + # gate 的 pending_fields + for item in (payload.get("pending_fields") or []): + if not isinstance(item, dict): + continue + item_types = [t for t in (item.get("asset_types") or infer_field_types(item) or []) if t] + if set(item_types) & set(types) and payload.get("answers"): + return ( + f"刚刚已确认过「{'/'.join(types)}」素材选择。不要重复弹出同一问题;直接继续创作。" + ) + return None + + def _dispatch_tool( context: AgentContext, name: str, args: dict, *, allow_pick: bool = False ) -> tuple[dict, bool]: @@ -4105,6 +4304,9 @@ def _dispatch_tool( fields = _coerce_fields(args.get("fields")) if not fields: return {"payload": {"error": "fields 不合法,请重新组织问题"}}, False + skip_reason = _asset_ask_already_resolved(context.conversation, fields) + if skip_reason: + return {"payload": {"skipped": True, "reason": "already_resolved", "note": skip_reason}}, False payload = _elicit_payload_for_fields(fields, allow_pick=allow_pick, is_video=context.is_video) display_field = (payload.get("fields") or fields)[0] message = append_message( @@ -4142,6 +4344,13 @@ def _dispatch_tool( return {"payload": _run_search_library(context, args)}, False if name == "write_strategy": + if context.is_video and click_swap_needs_mode(context.conversation): + gate = append_click_swap_mode_gate(context.conversation) + set_video_gate_stage(context.conversation, "clarify") + return { + "payload": {"asked": True, "field": "click_swap_mode"}, + "_events": [{"type": "message", "message": _message_payload(gate)}], + }, True if context.is_video and click_swap_needs_sequence(context.conversation): gate = append_click_swap_sequence_gate(context.conversation) set_video_gate_stage(context.conversation, "clarify") @@ -4209,6 +4418,13 @@ def _dispatch_tool( }, True if name == "write_plan": + if context.is_video and click_swap_needs_mode(context.conversation): + gate = append_click_swap_mode_gate(context.conversation) + set_video_gate_stage(context.conversation, "clarify") + return { + "payload": {"asked": True, "field": "click_swap_mode"}, + "_events": [{"type": "message", "message": _message_payload(gate)}], + }, True video_prompt = str(args.get("video_prompt") or "").strip() if not video_prompt: return {"payload": {"error": "video_prompt 不能为空,请把完整出片指令写进去"}}, False @@ -4226,7 +4442,7 @@ def _dispatch_tool( "payload": {"asked": True, "field": "person_source"}, "_events": [{"type": "message", "message": _message_payload(gate)}], }, True - video_prompt = apply_video_preset_prompt(context.conversation.preset, video_prompt) + video_prompt = apply_video_preset_prompt(context.conversation.preset, video_prompt, click_swap_mode=click_swap_mode(context.conversation)) if is_click_swap_preset(context.conversation.preset): video_prompt = ( f"{video_prompt}\n\n【商家确认的换款顺序】{click_swap_sequence(context.conversation)}\n" @@ -4314,7 +4530,7 @@ def _dispatch_tool( "payload": {"asked": True, "field": "sku_sequence"}, "_events": [{"type": "message", "message": _message_payload(gate)}], }, True - video_prompt = apply_video_preset_prompt(context.conversation.preset, video_prompt) + video_prompt = apply_video_preset_prompt(context.conversation.preset, video_prompt, click_swap_mode=click_swap_mode(context.conversation)) video_prompt = apply_product_voice_visual_guard(context.conversation, video_prompt) video_prompt = apply_product_reality_guard(video_prompt) video_prompt = apply_video_platform_safety_guard(video_prompt) diff --git a/core/backend/apps/ai/creation_presets.py b/core/backend/apps/ai/creation_presets.py index bd3855e..3af97a3 100644 --- a/core/backend/apps/ai/creation_presets.py +++ b/core/backend/apps/ai/creation_presets.py @@ -9,12 +9,18 @@ key 必须是同一个中文名(会话建的时候原样存进 CreationConversat from __future__ import annotations CLICK_SWAP_PRESETS = frozenset({"点击换款", "点触换款", "多色商品换款"}) -_CLICK_SWAP_GUIDANCE = ( - "固定机位的商品点击换款短片。必须先确认要展示的颜色/款式/SKU 及顺序," +_CLICK_SWAP_FINGER_GUIDANCE = ( + "形态:只出手指出镜。固定机位的商品点击换款短片。必须先确认要展示的颜色/款式/SKU 及顺序," "画面只围绕同一件商品的逐款切换:商品始终居中、大小与角度不变,背景、灯光、机位不变。" "每次画面中的手指点击/轻触商品后,下一款在原位立即完成干净的 match cut 切换。" - "禁止改成口播、剧情、使用教程、多场景展示、换人或商品飞舞变形;最后才用同一构图做全款式收束。" + "禁止完整人物出镜、禁止生成角色定妆图、禁止改成口播/剧情/使用教程;最后才用同一构图做全款式收束。" ) +_CLICK_SWAP_CHARACTER_GUIDANCE = ( + "形态:角色在日常使用场景中变色换款。锁定同一位角色与同一生活场景,角色自然使用或展示商品;" + "商品颜色/款式必须按用户确认顺序切换,人物五官、发型、身形、基础服装与场景保持一致。" + "允许自然动作与生活感,但不要改成口播带货腔、复杂剧情反转或频繁换景;结尾给出全款式收束。" +) +_CLICK_SWAP_GUIDANCE = _CLICK_SWAP_FINGER_GUIDANCE # 未选形态前的默认提示 VIDEO_PRESETS: dict[str, str] = { "痛点解决演示": ( @@ -308,35 +314,63 @@ def preset_workflow_guidance(name: str) -> str: return VIDEO_PRESET_WORKFLOWS.get((name or "").strip(), "") -def video_preset_delivery_contract(name: str) -> str: +_CLICK_SWAP_FINGER_CONTRACT = ( + "【预设执行层·点击换款·只出手】这不是口播片、剧情片或普通商品展示片。" + "使用单一固定机位和同一背景,商品全程处于完全相同的中心位置、尺寸、角度与透视。" + "每个切换节点都必须清楚拍到一根手指轻触/点击商品,接触瞬间通过原位 match cut 换成下一款;" + "不改场景、不改机位、不运镜、不说话、不出现完整人物、不演剧情、不演示使用步骤、不生成角色定妆图。" + "各款必须按用户确认的顺序逐一出现,不自行发明颜色或款式;结尾停在干净的全款式总览。" +) +_CLICK_SWAP_CHARACTER_CONTRACT = ( + "【预设执行层·点击换款·角色日常换款】锁定同一位角色与同一日常使用场景。" + "角色在自然生活动作中展示/使用商品,商品颜色或款式按用户确认顺序切换;" + "人物五官、发型、年龄感、身形与基础服装全程一致,场景与光线保持连贯。" + "允许生活感动作,禁止口播带货腔、复杂剧情反转、频繁换景或随机换人;结尾给出全款式收束。" +) + + +def video_preset_delivery_contract(name: str, *, click_swap_mode: str = "") -> str: """预设名 → 最终视频生成阶段必须执行的结构与镜头约束。""" - return VIDEO_PRESET_DELIVERY_CONTRACTS.get((name or "").strip(), "") + preset = (name or "").strip() + if preset in CLICK_SWAP_PRESETS: + if (click_swap_mode or "").strip() == "character": + return _CLICK_SWAP_CHARACTER_CONTRACT + return _CLICK_SWAP_FINGER_CONTRACT + return VIDEO_PRESET_DELIVERY_CONTRACTS.get(preset, "") def is_click_swap_preset(name: str) -> bool: return (name or "").strip() in CLICK_SWAP_PRESETS -def apply_video_preset_prompt(name: str, prompt: str) -> str: +def apply_video_preset_prompt(name: str, prompt: str, *, click_swap_mode: str = "") -> str: """把视频预设确定性并入最终出片指令,避免只依赖对话模型主动复述。""" base = str(prompt or "").strip() preset = (name or "").strip() - contract = video_preset_delivery_contract(preset) + mode = (click_swap_mode or "").strip() + contract = video_preset_delivery_contract(preset, click_swap_mode=mode) if not base or not contract: return base marker = f"【视频预设】{preset}" if marker in base: return base if is_click_swap_preset(preset): - # 换款是镜头结构,不是可有可无的风格词。把强制执行层放在前面, - # 原始 Agent Prompt 只作为商品/SKU 事实来源;其中若有口播、剧情、运镜或换场景等拍法,不得执行。 + if mode == "character": + return ( + f"{marker}\n{contract}\n\n" + "【原始商品与 SKU 信息】仅提取下文中的商品外观、颜色、款式、顺序与角色/场景事实;" + "下文任何与「同一角色、同一日常场景、按序换款」冲突的拍法一律忽略。\n" + f"{base}\n\n" + "【最终执行检查】全片锁定同一位角色与同一生活场景;" + "商品颜色/款式严格按确认顺序切换,禁止随机换人、口播带货腔或复杂剧情反转。" + ) return ( f"{marker}\n{contract}\n\n" "【原始商品与 SKU 信息】仅提取下文中的商品外观、颜色、款式和顺序事实;" "下文任何与「固定机位、手指点击、商品原位换款」冲突的拍法一律忽略。\n" f"{base}\n\n" "【最终执行检查】每一次换款都必须由画面内手指的一次清晰点击触发," - "切换前后商品中心点、尺寸、角度、背景、光线和机位不变。" + "切换前后商品中心点、尺寸、角度、背景、光线和机位不变;不出现完整人物、不生成角色图。" ) return f"{base}\n\n{marker}\n{contract}" diff --git a/core/backend/apps/ai/test_creation_agent.py b/core/backend/apps/ai/test_creation_agent.py index 07aef70..dbe3855 100644 --- a/core/backend/apps/ai/test_creation_agent.py +++ b/core/backend/apps/ai/test_creation_agent.py @@ -14,7 +14,7 @@ from apps.assets.models import Asset, AssetFile, Model as AssetModel from apps.products.models import Product, ProductImage from .creation import append_message, finish_generating_message -from .views import _typed_step_confirm_action +from .views import _auto_pick_product_continuation, _typed_step_confirm_action from .creation_agent import ( AgentContext, COMPRESS_MIN_BATCH, @@ -209,6 +209,61 @@ class ParamPickIntentTests(TestCase): self.assertEqual(wanted_param_keys("改模特", is_video=True), []) + +class AutoPickProductPrefersUploadTests(CreationAgentBaseTests): + """「你来推荐」必须优先用会话本地上传图,不能盲取商品库最新一条。""" + + def _make_upload_asset(self, name="耳机实物图.png"): + cats = list(Asset.Category.values) + category = "product" if "product" in cats else ("other" if "other" in cats else cats[0]) + return Asset.objects.create( + team=self.team, + created_by=self.user, + name=name, + category=category, + in_library=False, + ) + + def test_auto_pick_uses_uploaded_asset_instead_of_latest_library_product(self): + self.conversation.mode = CreationConversation.Mode.VIDEO + self.conversation.preset = "点击换款" + self.conversation.save(update_fields=["mode", "preset", "updated_at"]) + + decoy = Product.objects.create(team=self.team, created_by=self.user, title="芳华精华液") + uploaded = self._make_upload_asset() + upload_ref = { + "type": "asset", + "id": str(uploaded.id), + "name": uploaded.name, + "cover": "https://example.com/headphone.png", + } + append_message( + self.conversation, + role="user", + text="我上传了几张耳机实物图", + refs=[upload_ref], + ) + + preferred, instruction = _auto_pick_product_continuation(self.conversation, [upload_ref]) + self.assertEqual(len(preferred), 1) + self.assertEqual(str(preferred[0]["id"]), str(uploaded.id)) + self.assertEqual(preferred[0]["type"], "asset") + self.assertIn("上传", instruction) + self.assertIn("禁止改用商品库", instruction) + self.conversation.refresh_from_db() + pinned_ids = {str(item.get("id")) for item in (self.conversation.pinned_refs or [])} + self.assertIn(str(uploaded.id), pinned_ids) + self.assertNotIn(str(decoy.id), pinned_ids) + + def test_auto_pick_falls_back_to_library_when_no_upload(self): + Product.objects.create(team=self.team, created_by=self.user, title="芳华精华液") + preferred, instruction = _auto_pick_product_continuation(self.conversation, []) + self.assertEqual(len(preferred), 1) + self.assertEqual(preferred[0]["type"], "product") + self.assertEqual(preferred[0]["name"], "芳华精华液") + self.assertIn("商品库", instruction) + + class AskUserTests(CreationAgentBaseTests): def test_saying_change_duration_injects_param_card(self): self.conversation.mode = CreationConversation.Mode.VIDEO diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 4f9cb76..8a50629 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -39,12 +39,15 @@ from .creation_agent import ( _ASSET_CARD_LABELS, _RESTART_CONTINUATION, apply_cast_relation_choice, + apply_click_swap_mode, + append_person_source_gate, apply_pain_point_direction, apply_confirm_params, apply_restart_intent, apply_session_params, emit_prompt_gate, emit_final_confirm_gate, + locked_product_references, set_plot_twist_story_depth, is_greeting, is_pain_point_conversation, @@ -289,6 +292,7 @@ def _store_click_swap_sequence(conversation: CreationConversation, value: str) - ) + def _mark_product_source_resolved(conversation: CreationConversation) -> None: """用户选择跳过、自动推荐或直接描述商品后,不重复弹同一个商品闸门。""" memory = dict(conversation.memory or {}) @@ -297,6 +301,83 @@ def _mark_product_source_resolved(conversation: CreationConversation) -> None: conversation.save(update_fields=["memory", "updated_at"]) +def _is_product_like_ref(ref: dict) -> bool: + """商品库商品,或本地上传的商品图(排除人物角色/模特)。""" + if not isinstance(ref, dict): + return False + type_ = str(ref.get("type") or "").strip() + ref_id = str(ref.get("id") or "").strip() + if not ref_id: + return False + if type_ == "product": + return True + if type_ != "asset": + return False + category = str(ref.get("category") or "").strip().lower() + return category not in {"character", "person", "model"} + + +def _dedupe_refs(refs: list[dict]) -> list[dict]: + out: list[dict] = [] + seen: set[tuple[str, str]] = set() + for ref in refs or []: + if not _is_product_like_ref(ref): + continue + mark = (str(ref.get("type")), str(ref.get("id"))) + if mark in seen: + continue + seen.add(mark) + out.append(ref) + return out + + +def _session_product_refs(conversation: CreationConversation, request_refs=None) -> list[dict]: + """优先收集本会话已上传/已锁定的商品图,避免「你来推荐」盲取商品库最新一条。""" + buckets: list[list] = [list(request_refs or []), list(locked_product_references(conversation))] + recent = ( + conversation.messages.filter(role=CreationMessage.Role.USER) + .order_by("-seq")[:12] + ) + for message in recent: + buckets.append(list(message.refs or [])) + merged: list[dict] = [] + for bucket in buckets: + merged.extend(bucket) + return _dedupe_refs(merged) + + +def _ref_display_name(ref: dict) -> str: + name = str(ref.get("name") or "").split(" · ")[0].strip() + if name and not name.lower().endswith((".png", ".jpg", ".jpeg", ".webp", ".gif")): + return name + return "已上传商品图" + + +def _auto_pick_product_continuation(conversation: CreationConversation, request_refs=None): + """「你来推荐」:有本地上传/已锁定商品图时用它们;否则才回落到商品库检索。""" + preferred = _session_product_refs(conversation, request_refs) + if preferred: + pin_refs(conversation, preferred) + names = "、".join(_ref_display_name(item) for item in preferred[:6]) + return preferred, ( + f"用户已提供商品参考图({names})。必须基于这些上传图推进创作," + "禁止改用商品库里的其他商品(例如库里最新一条)。" + "若品牌或具体品名仍不明确,先确认品牌与品名,再继续方案。" + ) + + hits = search_mentions(conversation.team, q="", types=["product"], limit=1) + if hits: + pin_refs(conversation, hits) + return hits, ( + f"用户希望你帮忙挑选素材,会话里尚无本地上传商品图,已从商品库选定【{hits[0]['name']}】。" + "直接基于该素材推进创作方案,不要复述选项,不要重复追问。" + ) + return [], ( + "用户希望你帮忙定素材,目前会话无本地上传商品图,商品库也暂无可用商品。" + "请结合所选预设与合理电商默认设想一款匹配的商品继续推进创作方案,不要重复追问。" + ) + + _STEP_CONTINUE_INSTRUCTIONS = { "strategy": ( "用户已确认创作策略。现在只调用 write_plan 写方案卡(含完整 video_prompt 存档);" @@ -1921,6 +2002,24 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): continuation_instruction = _plot_twist_direction_continuation( conversation, payload, choice ) + elif payload.get("interaction") == "click_swap_mode_gate": + raw = text.strip() + mode = "" + if re.search(r"只出手|手指|不用角色|不要角色|无角色", raw): + mode = "finger" + elif re.search(r"角色|日常|拟人|出镜人物|达人", raw): + mode = "character" + instruction = apply_click_swap_mode(conversation, mode) if mode else "" + if instruction: + payload["answers"] = {"click_swap_mode": mode} + payload["submitted"] = True + payload["answered_via"] = "chat" + pending.payload = payload + pending.save(update_fields=["payload", "updated_at"]) + text = "" + record_user_message = False + force_creative_turn = True + continuation_instruction = instruction elif payload.get("interaction") == "click_swap_sku_gate": sequence = text.strip() if sequence: @@ -1977,40 +2076,83 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): ], }, status=200) - # 2. 用户说「你帮我选/你定/随便」 - elif re.search(r"(你来定|你定|你帮我定|你帮我选|帮我挑|没想好|随便|都行|你挑)", text): - hits = search_mentions(conversation.team, q="", types=asset_types, limit=1) - chosen_name = hits[0]["name"] if hits else "推荐商品" - if hits: - mark = (hits[0].get("type"), str(hits[0].get("id"))) - existing = {(item.get("type"), str(item.get("id"))) for item in refs if isinstance(item, dict)} - if mark not in existing: - refs.append(hits[0]) - payload["answers"] = {"_asset_gate": "auto", str(primary_field.get("key") or "product"): chosen_name} - payload["submitted"] = True - payload["answered_via"] = "chat" - pending.payload = payload - pending.save(update_fields=["payload", "updated_at"]) + # 2. 用户说「你帮我选/你定/随便/你来推荐」 + elif re.search(r"(你来定|你定|你帮我定|你帮我选|帮我挑|没想好|随便|都行|你挑|你来推荐|推荐一款|推荐一个)", text): if is_product_gate: + preferred, continuation_instruction = _auto_pick_product_continuation( + conversation, refs + ) + chosen_name = ( + _ref_display_name(preferred[0]) if preferred else "推荐商品" + ) + for item in preferred: + mark = (item.get("type"), str(item.get("id"))) + existing = { + (r.get("type"), str(r.get("id"))) + for r in refs if isinstance(r, dict) + } + if mark not in existing: + refs.append(item) + payload["answers"] = { + "_asset_gate": "auto", + str(primary_field.get("key") or "product"): chosen_name, + } + payload["submitted"] = True + payload["answered_via"] = "chat" + pending.payload = payload + pending.save(update_fields=["payload", "updated_at"]) _mark_product_source_resolved(conversation) - is_character_gate = any(t in ("character", "model") for t in asset_types) - if is_character_gate: - memory = dict(conversation.memory or {}) - memory["person_source"] = "auto" - memory["person_source_ready"] = True - memory["person_source_pending"] = False - conversation.memory = memory - conversation.save(update_fields=["memory", "updated_at"]) - - force_creative_turn = True - if hits: - continuation_instruction = ( - f"用户让你帮忙挑选,系统已为你选定【{hits[0]['name']}】。直接基于该素材推进创作方案,不要复述选项,不要重复追问。" - ) + force_creative_turn = True else: - continuation_instruction = ( - "用户让你帮忙定素材,目前素材库暂无已上传素材。请结合所选预设与合理电商默认设想一款匹配的商品继续推进创作方案,不要重复追问。" - ) + hits = search_mentions(conversation.team, q="", types=asset_types, limit=1) + chosen_name = hits[0]["name"] if hits else "推荐素材" + if hits: + mark = (hits[0].get("type"), str(hits[0].get("id"))) + existing = {(item.get("type"), str(item.get("id"))) for item in refs if isinstance(item, dict)} + if mark not in existing: + refs.append(hits[0]) + pin_refs(conversation, [hits[0]]) + payload["answers"] = {"_asset_gate": "auto", str(primary_field.get("key") or "product"): chosen_name} + payload["submitted"] = True + payload["answered_via"] = "chat" + pending.payload = payload + pending.save(update_fields=["payload", "updated_at"]) + is_character_gate = any(t in ("character", "model") for t in asset_types) + if is_character_gate and hits: + memory = dict(conversation.memory or {}) + memory["person_source"] = "auto" + memory["person_source_ready"] = True + memory["person_source_pending"] = False + conversation.memory = memory + conversation.save(update_fields=["memory", "updated_at"]) + + if hits: + force_creative_turn = True + continuation_instruction = ( + f"用户让你帮忙挑选,系统已为你选定【{hits[0]['name']}】。" + "直接基于该素材推进创作方案,不要复述选项,不要再次 ask_user 追问同一类素材。" + ) + elif is_character_gate: + memory = dict(conversation.memory or {}) + memory.pop("person_source_ready", None) + memory.pop("person_source_pending", None) + memory["person_source"] = "" + conversation.memory = memory + conversation.save(update_fields=["memory", "updated_at"]) + gate = append_person_source_gate(conversation) + conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER + conversation.save(update_fields=["agent_status", "updated_at"]) + return JsonResponse({ + "conversation_id": str(conversation.id), + "agent_status": conversation.agent_status, + "messages": [CreationMessageSerializer(gate).data], + }, status=200) + else: + force_creative_turn = True + continuation_instruction = ( + "用户让你帮忙定素材,目前素材库暂无现成项。" + "按用户刚说的外观要求继续创作;禁止再次弹出同类「发列表 / 你来推荐」追问。" + ) # 3. 用户说「先不用/不选了/跳过」 elif re.search(r"(先不用|不用|不选|先不选|跳过|暂不|没有商品|不需要)", text): @@ -2274,6 +2416,10 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): ) if not valid: return JsonResponse({"detail": "请先从模特库选择一位人物"}, status=400) + if payload.get("interaction") == "click_swap_mode_gate": + mode = str(answers.get("click_swap_mode") or "").strip() + if mode not in {"finger", "character"}: + return JsonResponse({"detail": "请选择只出手指出镜,或角色日常换款"}, status=400) if payload.get("interaction") == "click_swap_sku_gate": sequence = str(answers.get("sku_sequence") or "").strip() if not sequence: @@ -2349,6 +2495,15 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): else "商家选择系统推荐卖点。现在只调用 write_strategy 写创作策略;" "从商品资料和现有素材中挑一个最容易被画面证明的真实核心卖点,不要虚构功效、价格或规格。" ) + elif payload.get("interaction") == "click_swap_mode_gate": + mode = str(answers.get("click_swap_mode") or "").strip() + instruction = apply_click_swap_mode(conversation, mode) + if not instruction: + return JsonResponse({"detail": "请选择只出手指出镜,或角色日常换款"}, status=400) + text = "" + record_user_message = False + force_creative_turn = True + continuation_instruction = instruction elif payload.get("interaction") == "click_swap_sku_gate": sequence = str(answers.get("sku_sequence") or "").strip() text = "" @@ -2477,30 +2632,94 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): "messages": [CreationMessageSerializer(pick).data], }, status=200) elif choice == "auto": - hits = search_mentions(conversation.team, q="", types=gate_types, limit=1) - if hits: + if is_product_gate: + preferred, continuation_instruction = _auto_pick_product_continuation( + conversation, refs + ) refs = list(refs) - refs.append(hits[0]) + existing = { + (item.get("type"), str(item.get("id"))) + for item in refs if isinstance(item, dict) + } + for item in preferred: + mark = (item.get("type"), str(item.get("id"))) + if mark not in existing: + refs.append(item) + existing.add(mark) + _mark_product_source_resolved(conversation) + else: + hits = search_mentions(conversation.team, q="", types=gate_types, limit=1) + is_character_gate = any(t in ("character", "model") for t in gate_types) + if hits: + refs = list(refs) + refs.append(hits[0]) + pin_refs(conversation, [hits[0]]) + continuation_instruction = ( + f"用户希望你帮忙挑选素材,已为你选定【{hits[0]['name']}】。" + "直接基于该素材推进创作方案,不要复述选项,不要再次 ask_user 追问同一类素材。" + ) + else: + if is_character_gate: + # 库里没有角色却点了「你来推荐」:不能假完成,否则会跳到核心卖点。 + # 改走人物来源闸门(上传 / 模特库 / 平台生成)。 + memory = dict(conversation.memory or {}) + memory.pop("person_source_ready", None) + memory.pop("person_source_pending", None) + memory["person_source"] = "" + conversation.memory = memory + conversation.save(update_fields=["memory", "updated_at"]) + gate = append_person_source_gate(conversation) + conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER + conversation.save(update_fields=["agent_status", "updated_at"]) + return JsonResponse({ + "conversation_id": str(conversation.id), + "agent_status": conversation.agent_status, + "messages": [CreationMessageSerializer(gate).data], + }, status=200) + continuation_instruction = ( + "用户希望你帮忙定素材,目前素材库暂无现成项。" + "按用户刚说的外观要求继续创作;不要再弹同一类「发列表 / 你来推荐」。" + ) + if is_character_gate and hits: + memory = dict(conversation.memory or {}) + memory["person_source"] = "auto" + memory["person_source_ready"] = True + memory["person_source_pending"] = False + conversation.memory = memory + conversation.save(update_fields=["memory", "updated_at"]) + text = "" + record_user_message = False + force_creative_turn = True + elif choice == "upload": + # 闸门「上传商品图」:用本轮 refs / 会话已上传图,禁止再去商品库抽奖。 + preferred = _session_product_refs(conversation, refs) + if preferred: + pin_refs(conversation, preferred) + refs = list(refs) + existing = { + (item.get("type"), str(item.get("id"))) + for item in refs if isinstance(item, dict) + } + for item in preferred: + mark = (item.get("type"), str(item.get("id"))) + if mark not in existing: + refs.append(item) + existing.add(mark) + names = "、".join(_ref_display_name(item) for item in preferred[:6]) continuation_instruction = ( - f"用户希望你帮忙挑选素材,已为你选定【{hits[0]['name']}】。直接基于该素材推进创作方案,不要复述选项,不要重复追问。" + f"用户刚上传了商品参考图({names})。必须基于这些上传图推进创作," + "禁止改用商品库里的其他商品。若品牌或具体品名不明确,先确认品牌与品名。" ) else: continuation_instruction = ( - "用户希望你帮忙定素材,目前素材库暂无已上传素材。请结合所选预设与合理电商默认设想一款匹配的商品继续推进创作方案,不要重复追问。" + "用户选择上传商品图,但本轮尚未收到可用图片。" + "请提醒用户再传一张清晰的商品实物图,不要改从商品库挑选。" ) text = "" record_user_message = False force_creative_turn = True if is_product_gate: _mark_product_source_resolved(conversation) - is_character_gate = any(t in ("character", "model") for t in gate_types) - if is_character_gate: - memory = dict(conversation.memory or {}) - memory["person_source"] = "auto" - memory["person_source_ready"] = True - memory["person_source_pending"] = False - conversation.memory = memory - conversation.save(update_fields=["memory", "updated_at"]) else: # 卡片本身已经记录了用户的选择。不要再伪造一条黑色用户气泡; # 继续原任务,并明确告诉模型不要再次追问同一项素材。 diff --git a/core/frontend/src/routes/omni-session.tsx b/core/frontend/src/routes/omni-session.tsx index cfc7145..2cbb0c1 100644 --- a/core/frontend/src/routes/omni-session.tsx +++ b/core/frontend/src/routes/omni-session.tsx @@ -1153,6 +1153,7 @@ function ElicitCard({ onChatAnswer, onPersonSourceAction, onUpload, + sessionProductRefs = [], }: { message: CreationMessage; disabled: boolean; @@ -1167,6 +1168,8 @@ function ElicitCard({ personPrompt?: string, ) => void; onUpload?: () => void; + /** 会话里已上传/已选的商品图。点「你来推荐」时一并回传,避免后端盲取库里最新商品。 */ + sessionProductRefs?: CreationRef[]; }) { const fields = ((message.payload.fields as CreationField[] | undefined) || []).filter(Boolean); const submitted = Boolean(message.payload.submitted); @@ -1504,7 +1507,12 @@ function ElicitCard({ return; } if (!gateField?.key) return; - onSubmit({ [gateField.key]: option.value }, []); + // 「你来推荐」必须带上本会话已上传的商品图,否则后端会盲取商品库最新一条。 + const attachRefs = + option.value === "auto" || /推荐/.test(option.label) + ? sessionProductRefs + : []; + onSubmit({ [gateField.key]: option.value }, attachRefs); }} > {option.label} @@ -2358,6 +2366,8 @@ export function OmniSessionPage({ messageId: string; source: "local_upload" | "model_library"; } | null>(null); + /** 商品闸门点「上传商品图」后,上传完成要回填 elicit_answer,而不是只塞进输入框。 */ + const productGateMessageIdRef = useRef(null); const composerRef = useRef(null); const [streaming, setStreaming] = useState(false); const [liveText, setLiveText] = useState(""); @@ -2582,6 +2592,25 @@ export function OmniSessionPage({ () => collectSessionAssets(messages, conversation?.pinned_refs || [], sessionUploads), [messages, conversation?.pinned_refs, sessionUploads], ); + /** 商品闸门回传用的结构化 Ref(不是资源栏 SessionAsset)。 */ + const sessionProductRefs = useMemo(() => { + const out: CreationRef[] = []; + const seen = new Set(); + const add = (ref?: CreationRef | null) => { + if (!ref?.id) return; + if (ref.type !== "product" && ref.type !== "asset") return; + const key = `${ref.type}:${ref.id}`; + if (seen.has(key)) return; + seen.add(key); + out.push(ref); + }; + for (const ref of conversation?.pinned_refs || []) add(ref); + for (const ref of sessionUploads) add(ref); + for (const message of messages) { + for (const ref of message.refs || []) add(ref); + } + return out; + }, [messages, conversation?.pinned_refs, sessionUploads]); const sessionAssetGroups = useMemo(() => { const groups: Array<{ key: SessionAssetKind; label: string; items: SessionAsset[] }> = [ { key: "image", label: "图片", items: [] }, @@ -3178,7 +3207,14 @@ export function OmniSessionPage({ } void submitPersonSourceChoice(message.id, source, [], personPrompt); }} - onUpload={() => fileInputRef.current?.click()} + sessionProductRefs={sessionProductRefs} + onUpload={() => { + productGateMessageIdRef.current = message.id; + quickUploadReplyRef.current = null; + setMentionMenuOpen(false); + setUploadMenuOpen(false); + fileInputRef.current?.click(); + }} /> ); case "strategy": @@ -3520,13 +3556,24 @@ export function OmniSessionPage({ } } notify("success", "素材已上传并通过审核"); - if (quickReply && uploadedRefs.length) { + const gateMessageId = productGateMessageIdRef.current; + if (gateMessageId && uploadedRefs.length) { + productGateMessageIdRef.current = null; + quickUploadReplyRef.current = null; + void send({ + kind: "elicit_answer", + reply_to: gateMessageId, + answers: { _asset_gate: "upload" }, + refs: uploadedRefs, + }); + } else if (quickReply && uploadedRefs.length) { quickUploadReplyRef.current = null; void send({ kind: "text", text: uploadReplyText(quickReply), refs: uploadedRefs }); } } catch (error) { notify("error", (error as Error).message); quickUploadReplyRef.current = null; + productGateMessageIdRef.current = null; } finally { setUploading(false); }