From b5d970a4b54b391f851266cbf64bbd58fd6d8d4d Mon Sep 17 00:00:00 2001 From: "Azmat@qq.com" Date: Fri, 18 Sep 2026 14:51:57 +0800 Subject: [PATCH] =?UTF-8?q?=20=E4=BC=98=E5=8C=96=E5=B7=B2=E5=8F=91?= =?UTF-8?q?=E7=8E=B0=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/backend/apps/ai/creation.py | 23 +- core/backend/apps/ai/creation_agent.py | 287 +++++++-- core/backend/apps/ai/mentions.py | 22 +- core/backend/apps/ai/test_creation_agent.py | 68 ++- core/backend/apps/ai/views.py | 115 +++- .../apps/projects/test_quick_create.py | 2 + core/frontend/src/omni-session-page.css | 227 ++++++- core/frontend/src/routes/omni-session.tsx | 572 ++++++++++++------ 8 files changed, 1080 insertions(+), 236 deletions(-) diff --git a/core/backend/apps/ai/creation.py b/core/backend/apps/ai/creation.py index 9d60a3e..1a1c9ca 100644 --- a/core/backend/apps/ai/creation.py +++ b/core/backend/apps/ai/creation.py @@ -453,14 +453,15 @@ def finish_generating_message(message: CreationMessage, *, assets: list[dict], m is_deleted=False, purged_at__isnull=True, ).first() + is_pet = "宠物" in str(conversation.preset or "") if model is None: model = Model.objects.create( team=conversation.team, created_by=conversation.created_by, - name="平台生成出镜人物", + name="平台生成宠物角色" if is_pet else "平台生成出镜人物", source=Model.Source.AI, portrait_asset=asset, - description="由全能创作生成并锁定的视频出镜人物。", + description="由全能创作生成并锁定的宠物角色。" if is_pet else "由全能创作生成并锁定的视频出镜人物。", metadata={"feature": "omni_create", "conversation_id": str(conversation.id)}, ) pin_refs(conversation, [{ @@ -484,10 +485,22 @@ def finish_generating_message(message: CreationMessage, *, assets: list[dict], m append_message( conversation, role="assistant", - text="人物参考已生成并锁定。后续所有镜头和长视频分段都会使用这位人物。", + text=( + "宠物角色已生成。你看这只宠物合适吗?是否使用这个宠物角色继续创作?" + if is_pet else + "人物参考已生成。你看这位角色合适吗?是否使用这个角色继续创作?" + ), payload={ - "reply_hint": "继续创作…", - "reply_options": [{"label": "继续创作", "text": "继续创作"}], + "reply_hint": ( + "回复「使用这个角色」继续,或说明想要的宠物品种与外观…" + if is_pet else + "回复「使用这个角色」继续,或说明想调整的地方…" + ), + "reply_options": [ + {"label": "使用这个角色", "text": "使用这个角色继续创作"}, + {"label": "重新生成一个", "text": "重新生成一个宠物角色" if is_pet else "重新生成一个角色"}, + {"label": "上传其他宠物" if is_pet else "上传其他人物", "text": "我上传宠物参考图" if is_pet else "我上传人物参考图"}, + ], }, ) return message diff --git a/core/backend/apps/ai/creation_agent.py b/core/backend/apps/ai/creation_agent.py index a49aca2..6c78b4b 100644 --- a/core/backend/apps/ai/creation_agent.py +++ b/core/backend/apps/ai/creation_agent.py @@ -72,9 +72,16 @@ _STEP_CONFIRM_LABELS = { _PERSON_SOURCE_PRESETS = { "痛点解决演示", PLOT_TWIST_PRESET, + "短剧反转带货", "达人口播种草", "鱼眼换装", + "AI 宠物拟人", } + + +def is_pet_preset(preset: str | None) -> bool: + name = str(preset or "").strip() + return "宠物" in name _PERSON_VISUAL_RE = re.compile( r"(人物|角色|模特|主角|达人|主播|出镜|口播|女生|女性|男生|男性|" r"女主|男主|年轻人|手模|手部|真人|换装|穿搭|剧情|短剧)" @@ -537,7 +544,7 @@ def video_needs_person_source(conversation: CreationConversation, user_text: str memory = conversation.memory if isinstance(conversation.memory, dict) else {} if memory.get("person_source_ready") or memory.get("person_source_pending"): return False - if conversation.preset in _PERSON_SOURCE_PRESETS: + if conversation.preset in _PERSON_SOURCE_PRESETS or is_pet_preset(conversation.preset): return True recent = list( conversation.messages.order_by("-seq").values_list("text", flat=True)[:12] @@ -546,10 +553,96 @@ def video_needs_person_source(conversation: CreationConversation, user_text: str return bool(_PERSON_VISUAL_RE.search("\n".join([user_text, pending_prompt, *recent]))) +def locked_product_references(conversation: CreationConversation) -> list[dict]: + """返回会话里所有已锁定的商品/产品素材实体(包括商品库商品和本地上传的商品图片)。""" + products: list[dict] = [] + seen: set[tuple[str, str]] = set() + person_ids = { + str(ref.get("id")) + for ref in locked_person_references(conversation) + if isinstance(ref, dict) and ref.get("id") + } + for ref in (conversation.pinned_refs or []): + if not isinstance(ref, dict): + continue + type_ = str(ref.get("type") or "").strip() + ref_id = str(ref.get("id") or "").strip() + if not ref_id or ref_id in person_ids: + continue + # 显式商品,或上传的素材图片(排除人物角色/模特) + if type_ == "product" or (type_ == "asset" and ref.get("category") != "character"): + mark = (type_, ref_id) + if mark not in seen: + products.append(ref) + seen.add(mark) + return products + + def has_locked_product_reference(conversation: CreationConversation) -> bool: - return any( - isinstance(ref, dict) and ref.get("type") == "product" and ref.get("id") - for ref in (conversation.pinned_refs or []) + return len(locked_product_references(conversation)) > 0 + + +def product_info_needs_confirmation(conversation: CreationConversation, user_text: str = "") -> bool: + """针对本地上传的商品素材,在对话开始或方案前必须确认品牌与具体品名。""" + if conversation.preset not in _PRODUCT_REQUIRED_PRESETS: + return False + memory = conversation.memory if isinstance(conversation.memory, dict) else {} + if memory.get("product_info_resolved") or memory.get("product_brand") or memory.get("product_name"): + return False + # 如果已锁定的首个商品是正式商品库商品(已自带品牌和品名),无需重复询问 + first_prod = next( + (ref for ref in (conversation.pinned_refs or []) if isinstance(ref, dict) and ref.get("type") == "product"), + None, + ) + if first_prod is not None: + return False + # 检查是否包含本地上传的商品素材 + uploaded_prods = [ref for ref in locked_product_references(conversation) if ref.get("type") == "asset"] + if not uploaded_prods: + return False + # 检查用户输入的文字中是否已经明确提供了品牌/品名 + user_messages = ( + conversation.messages.filter(role="user").order_by("seq") + if getattr(conversation, "created_at", None) + else [] + ) + all_text = " ".join(str(m.text or "") for m in user_messages) + " " + (user_text or "") + has_explicit_brand = bool(re.search(r"(?:品牌|牌子)[::是为]\s*([^\n,,。!!]{2,30})", all_text)) + has_explicit_name = bool(re.search(r"(?:品名|商品名|产品名)[::是为]\s*([^\n,,。!!]{2,30})", all_text)) + if has_explicit_brand or has_explicit_name: + return False + return True + + +def append_product_info_gate(conversation: CreationConversation) -> CreationMessage: + """针对已上传商品图但尚未说明品牌/品名的情况,主动询问商品品牌与品名。""" + question = ( + "已收到你上传的商品图。请问这款商品的**品牌**和**具体品名**是什么?" + "有想要重点突出的核心卖点也可以一起告诉我,以便在后续脚本中精准植入。" + ) + return append_message( + conversation, + role="assistant", + kind=CreationMessage.Kind.ELICIT, + text=question, + payload={ + "interaction": "chat", + "topic": "product_info", + "fields": [{ + "key": "product_brand_and_name", + "label": "请提供商品品牌与具体品名", + "type": "text", + "required": True, + "placeholder": "例如:品牌「浅蓝小熊」,品名「婴儿柔湿巾」", + }], + "submitted": False, + "answers": {}, + "reply_hint": "输入品牌和品名,例如「品牌XX,品名YY」…", + "reply_options": [ + {"label": "按图片内容创作", "text": "直接根据图片内容设计品牌与品名,不用再问"}, + {"label": "我来补充品牌品名", "text": "品牌是:,商品名是:"}, + ], + }, ) @@ -568,17 +661,35 @@ def creation_needs_product_source(conversation: CreationConversation, user_text: def append_person_source_gate(conversation: CreationConversation) -> CreationMessage: - """可视化的人物来源闸门;三个选项分别进文件、模特库和生图流程。""" + """可视化的人物/角色来源闸门;三个选项分别进文件、模特库和生图流程。""" product_name = "" - for ref in (conversation.pinned_refs or []): - if isinstance(ref, dict) and ref.get("type") == "product": - product_name = str(ref.get("name") or "").split(" · ")[0].strip() + for ref in locked_product_references(conversation): + name = str(ref.get("name") or "").split(" · ")[0].strip() + if name and not name.lower().endswith((".png", ".jpg", ".jpeg", ".webp")): + product_name = name break - prompt_text = ( - f"商品已选定【{product_name}】。这条视频想由哪位角色/达人出镜?选定后,所有镜头和分段都会锁定同一位人物。" - if product_name else - "先确定这条视频的出镜人物。选定后,所有镜头和分段都会锁定同一位人物。" - ) + if not product_name: + memory = conversation.memory if isinstance(conversation.memory, dict) else {} + product_name = str(memory.get("product_name") or memory.get("product_brand_and_name") or "").strip() + + is_pet = is_pet_preset(conversation.preset) + if is_pet: + prompt_text = ( + f"商品已选定【{product_name}】。这条视频想由哪只宠物角色出镜?选定后,所有镜头和分段都会锁定同一只宠物形象。" + if product_name else + "先确定这条视频出镜的宠物角色。选定后,所有镜头和分段都会锁定同一只宠物形象。" + ) + field_label = "选择宠物来源" + library_label = "从角色库选择" + else: + prompt_text = ( + f"商品已选定【{product_name}】。这条视频想由哪位角色/达人出镜?选定后,所有镜头和分段都会锁定同一位人物。" + if product_name else + "先确定这条视频的出镜人物。选定后,所有镜头和分段都会锁定同一位人物。" + ) + field_label = "选择人物来源" + library_label = "从模特库选择" + return append_message( conversation, role="assistant", @@ -586,14 +697,15 @@ def append_person_source_gate(conversation: CreationConversation) -> CreationMes text=prompt_text, payload={ "interaction": "person_source_gate", + "is_pet": is_pet, "fields": [{ "key": "person_source", - "label": "选择人物来源", + "label": field_label, "type": "single", "required": True, "options": [ {"value": "local_upload", "label": "本地上传"}, - {"value": "model_library", "label": "从模特库选择"}, + {"value": "model_library", "label": library_label}, {"value": "platform_generate", "label": "平台帮忙生成"}, ], }], @@ -639,25 +751,67 @@ def append_click_swap_sequence_gate(conversation: CreationConversation) -> Creat ) -def submit_generated_person_reference(*, conversation: CreationConversation, user) -> CreationMessage: - """先生成独立人物定妆参考,完成后再由 creation.py 自动建模特并锁定。""" +def submit_generated_person_reference( + *, + conversation: CreationConversation, + user, + appearance_prompt: str = "", +) -> CreationMessage: + """先生成独立人物/宠物角色定妆参考,完成后再由 creation.py 自动建模特并锁定。""" from .services import enqueue_standalone_images - recent_user = list( - conversation.messages.filter(role=CreationMessage.Role.USER) - .order_by("-seq").values_list("text", flat=True)[:6] + recent_user = ( + list( + conversation.messages.filter(role=CreationMessage.Role.USER) + .order_by("-seq").values_list("text", flat=True)[:6] + ) + if getattr(conversation, "created_at", None) + else [] ) brief = "\n".join(reversed([item.strip() for item in recent_user if item and item.strip()]))[:700] - prompt = ( - "为短视频生成一张可反复用于锁定身份的真人模特定妆参考图。" - "只出现一位成年人物,正面或轻微三分之四角度,中近景,表情自然," - "五官、发型、肤色、身形和服装细节清晰,简洁中性背景,写实摄影," - "不要文字、水印、拼图、多人、遮挡脸部或夸张滤镜。" - ) - if conversation.preset: - prompt += f" 适配视频预设:{conversation.preset}。" - if brief: - prompt += f" 参考用户需求:{brief}" + appearance_prompt = (appearance_prompt or "").strip()[:500] + + is_pet = is_pet_preset(conversation.preset) + if is_pet: + # 智能推断宠物类别:用户指定 > 商品及需求上下文 > 默认萌宠 + context_text = f"{appearance_prompt} {brief} {conversation.preset}" + for ref in locked_product_references(conversation): + context_text += " " + str(ref.get("name") or "") + if any(w in context_text for w in ("狗", "犬", "咬胶", "磨牙", "骨头", "汪", "狗粮", "犬粮", "幼犬", "成犬", "中大型犬", "小型犬")): + pet_type_hint = "一只呆萌可爱、神采奕奕的小狗(如金毛幼犬、柯基或柴犬等萌犬)" + elif any(w in context_text for w in ("猫", "喵", "猫砂", "猫条", "猫粮", "冻干", "猫草", "逗猫", "幼猫")): + pet_type_hint = "一只大眼睛、圆脸可爱的萌宠猫咪(如英短、美短或布偶等萌猫)" + else: + pet_type_hint = "一只可爱灵动的萌宠(如呆萌小狗或可爱猫咪)" + + prompt = ( + f"为AI宠物拟人短视频生成一张可反复用于锁定角色身份的萌宠主角定妆参考图。" + f"主角必须是{pet_type_hint},展现拟人化的生动表情与灵动神态," + f"正面或微侧三分之四角度,中近景特写,眼神清澈,毛发蓬松有光泽且根根分明," + f"可搭配精致简约的拟人小配饰(如可爱小领结、小方巾或背带,契合萌宠调性)," + f"写实摄影风格,电影级柔和摄影棚光影,简洁纯净背景," + f"画面中只出现这一只可爱的宠物动物主角,绝对不要出现真人人类!不要文字、水印、拼图或变形。" + ) + if appearance_prompt: + prompt += f" 用户指定的宠物外观与品种:{appearance_prompt}。严格保留这些宠物外观要求。" + if brief: + prompt += f" 参考用户需求:{brief}" + label = "正在生成宠物角色参考" + else: + prompt = ( + "为短视频生成一张可反复用于锁定身份的真人模特定妆参考图。" + "只出现一位成年人物,正面或轻微三分之四角度,中近景,表情自然," + "五官、发型、肤色、身形和服装细节清晰,简洁中性背景,写实摄影," + "不要文字、水印、拼图、多人、遮挡脸部或夸张滤镜。" + ) + if appearance_prompt: + prompt += f" 用户指定的人物外观:{appearance_prompt}。严格保留这些外观要求。" + if conversation.preset: + prompt += f" 适配视频预设:{conversation.preset}。" + if brief: + prompt += f" 参考用户需求:{brief}" + label = "正在生成人物参考" + tasks = enqueue_standalone_images( team=conversation.team, user=user, @@ -671,6 +825,7 @@ def submit_generated_person_reference(*, conversation: CreationConversation, use memory = dict(conversation.memory or {}) memory["person_source"] = "platform_generate" memory["person_source_pending"] = True + memory["person_prompt"] = appearance_prompt conversation.memory = memory conversation.status = CreationConversation.Status.RUNNING conversation.agent_status = CreationConversation.AgentStatus.IDLE @@ -683,7 +838,7 @@ def submit_generated_person_reference(*, conversation: CreationConversation, use "task_id": str(task.id), "kind": "person_reference", "prompt": prompt, - "label": "正在生成人物参考", + "label": label, }, task=task, ) @@ -1258,6 +1413,16 @@ def default_reply_options( {"label": "我直接说商品名", "text": "我直接告诉你商品名"}, {"label": "你来推荐", "text": "你根据当前需求推荐一款"}, ] + if re.search( + r"是否使用这[个位]角色|角色.{0,8}(?:已生成|合适吗)|你看这[个位]角色|人物参考已生成", + guidance, + re.IGNORECASE, + ): + return [ + {"label": "使用这个角色", "text": "使用这个角色继续创作"}, + {"label": "重新生成一个", "text": "重新生成一个角色"}, + {"label": "上传其他人物", "text": "我上传人物参考图"}, + ] if re.search( r"(?:哪位|哪个|什么|选择|选|换|更换|调整|修改|改).{0,12}(?:人物|角色|模特|出镜)|" r"(?:人物|角色|模特|出镜).{0,12}(?:哪位|哪个|选择|选|换|更换|调整|修改|改)", @@ -1962,7 +2127,31 @@ def _coerce_fields(raw) -> list[dict]: continue key = str(item.get("key") or "").strip() label = str(item.get("label") or "").strip() + raw_options = item.get("options") or [] + if isinstance(raw_options, dict): + raw_options = [{"value": str(k), "label": str(v)} for k, v in raw_options.items()] + options = [] + for idx, o in enumerate(raw_options, start=1): + if isinstance(o, str): + s = o.strip() + if s: + options.append({"value": f"option_{idx}", "label": s}) + elif isinstance(o, dict): + val = o.get("value") + lbl = o.get("label") or o.get("text") or o.get("title") or o.get("name") + if val is None or str(val).strip() == "": + val = lbl if lbl is not None else f"option_{idx}" + if lbl is None or str(lbl).strip() == "": + lbl = val + val_str = str(val).strip() + lbl_str = str(lbl).strip() + if lbl_str: + options.append({"value": val_str, "label": lbl_str}) type_ = str(item.get("type") or "").strip() + if not type_: + type_ = "single" if options else "text" + elif type_ in ("choice", "select", "radio"): + type_ = "single" if not key or not label or type_ not in FIELD_TYPES: continue field = { @@ -1971,15 +2160,13 @@ def _coerce_fields(raw) -> list[dict]: "type": type_, "required": bool(item.get("required", True)), } - options = [ - {"value": str(o.get("value")), "label": str(o.get("label"))} - for o in (item.get("options") or []) - if isinstance(o, dict) and o.get("value") and o.get("label") - ] if type_ in ("single", "multi"): if not options: continue # 单选/多选没选项 = 废卡,丢掉 field["options"] = options + # 明确提供了具体 options 的选择题(如痛点方向三选一),保留 options,绝不降级转为 asset + fields.append(field) + continue if type_ == "asset": asset_types = [t for t in (item.get("asset_types") or []) if t in TYPE_LABELS] field["asset_types"] = asset_types or list(TYPE_LABELS) @@ -3324,6 +3511,16 @@ def iter_creation_agent_events( yield {"type": "done"} return + # 0. 本地上传商品图优先确认品牌与具体品名 + if product_info_needs_confirmation(conversation, text): + question = append_product_info_gate(conversation) + set_video_gate_stage(conversation, "clarify") + conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER + conversation.save(update_fields=["agent_status", "updated_at"]) + yield {"type": "message", "message": _message_payload(question)} + yield {"type": "done"} + return + # 剧情反转预设必须先选故事深度。平台直接落选择卡,不能把这一步交给模型猜, # 否则默认 15 秒会吞掉 30/60 秒该有的人物关系和冲突发展。 if is_plot_twist_conversation(conversation) and not active_plot_twist_story_depth(conversation): @@ -3826,7 +4023,8 @@ def _guided_elicit_text(field: dict) -> str: return label if str(field.get("key") or "") in SESSION_PARAM_KEYS: return label - if field.get("type") == "single" and field.get("options"): + options = field.get("options") + if field.get("type") in ("single", "multi") and isinstance(options, list) and len(options) > 0: return f"{label} 直接点击一个选项,也可以输入自己的想法。" return f"{label} 直接用一句话告诉我就行,不用整理成完整需求。" @@ -3852,9 +4050,9 @@ def _elicit_payload_for_fields( if primary_type == "product": gate_label = ( - "这条视频想推哪款商品?可以直接告诉我商品名,或者需要我把商品库列表发给你选吗?" + "这条视频想推哪款商品?可以直接告诉我商品的品牌与品名,也可以上传一张商品实物图,或需要我把商品库列表发给你选吗?" if is_video else - "这次想做哪款商品?可以直接告诉我商品名,或者需要我把商品库列表发给你选吗?" + "这次想做哪款商品?可以直接告诉我商品的品牌与品名,也可以上传一张商品实物图,或需要我把商品库列表发给你选吗?" ) else: gate_label = _GATE_LABELS.get(primary_type, _GATE_LABELS["asset"]) @@ -3865,15 +4063,18 @@ def _elicit_payload_for_fields( "model": "模特", "scene": "场景", }.get(primary_type, "素材") + gate_options = [ + {"value": "send", "label": f"发{asset_label}列表"}, + {"value": "auto", "label": "你来推荐"}, + ] + if primary_type == "product": + gate_options.insert(0, {"value": "upload", "label": "上传商品图"}) gate_field = { "key": "_asset_gate", "label": gate_label, "type": "text", "required": False, - "options": [ - {"value": "send", "label": f"发{asset_label}列表"}, - {"value": "auto", "label": "你来推荐"}, - ], + "options": gate_options, } return { # 这是 Agent 的一句自然追问,不是让用户点选流程的卡片。 diff --git a/core/backend/apps/ai/mentions.py b/core/backend/apps/ai/mentions.py index eea23b4..a478713 100644 --- a/core/backend/apps/ai/mentions.py +++ b/core/backend/apps/ai/mentions.py @@ -13,6 +13,8 @@ from __future__ import annotations import uuid from dataclasses import dataclass, field +from django.db.models import Q + from apps.assets.models import Asset, Model from apps.products.models import Product @@ -72,11 +74,15 @@ def _search_products(team, q: str, limit: int) -> list[dict]: def _search_models(team, q: str, limit: int) -> list[dict]: - queryset = Model.objects.filter(team=team, is_deleted=False, purged_at__isnull=True) + queryset = Model.objects.filter( + Q(team=team) | Q(is_official=True), + is_deleted=False, + purged_at__isnull=True, + ) if q: queryset = queryset.filter(name__icontains=q) out = [] - for model in queryset.select_related("portrait_asset").order_by("-created_at")[:limit]: + for model in queryset.select_related("portrait_asset").order_by("-is_official", "-created_at")[:limit]: out.append(_ref("model", model.id, model.name, _asset_preview_url(model.portrait_asset))) return out @@ -173,7 +179,12 @@ def lookup_mention(team, value: str, types: list[str] | None = None) -> dict | N return _ref("product", product.id, product.title, _product_cover_url(product)) if "model" in wanted: model = ( - Model.objects.filter(team=team, id=uid, is_deleted=False, purged_at__isnull=True) + Model.objects.filter( + Q(team=team) | Q(is_official=True), + id=uid, + is_deleted=False, + purged_at__isnull=True, + ) .select_related("portrait_asset") .first() ) @@ -384,7 +395,10 @@ def resolve_refs(team, refs: list[dict]) -> ResolvedRefs: continue if type_ == "model": model = Model.objects.filter( - team=team, id=ref_id, is_deleted=False, purged_at__isnull=True + Q(team=team) | Q(is_official=True), + id=ref_id, + is_deleted=False, + purged_at__isnull=True, ).select_related("triview_asset", "portrait_asset").first() if model is None: resolved.missing.append(ref) diff --git a/core/backend/apps/ai/test_creation_agent.py b/core/backend/apps/ai/test_creation_agent.py index d8e43a0..07aef70 100644 --- a/core/backend/apps/ai/test_creation_agent.py +++ b/core/backend/apps/ai/test_creation_agent.py @@ -507,7 +507,7 @@ class PersonReferenceCompletionTests(CreationAgentBaseTests): self.assertTrue(AssetModel.objects.filter(id=model_ref["id"], portrait_asset=asset).exists()) self.assertTrue(self.conversation.memory["person_source_ready"]) self.assertTrue( - self.conversation.messages.filter(text__contains="人物参考已生成并锁定").exists() + self.conversation.messages.filter(text__contains="是否使用这个角色").exists() ) def test_platform_person_submission_uses_model_mode(self): @@ -519,13 +519,23 @@ class PersonReferenceCompletionTests(CreationAgentBaseTests): idempotency_key="k-person-submit", ) with patch("apps.ai.services.enqueue_standalone_images", return_value=[task]) as enqueue: - message = submit_generated_person_reference(conversation=self.conversation, user=self.user) + message = submit_generated_person_reference( + conversation=self.conversation, + user=self.user, + appearance_prompt="25 岁左右女性,利落短发,干练通勤风", + ) self.assertEqual(message.kind, CreationMessage.Kind.GENERATING) self.assertEqual(message.payload["kind"], "person_reference") self.assertEqual(enqueue.call_args.kwargs["mode"], "model") self.assertEqual(enqueue.call_args.kwargs["count"], 1) self.assertEqual(enqueue.call_args.kwargs["feature"], "omni_create") + self.assertIn("利落短发", enqueue.call_args.kwargs["prompt"]) + self.conversation.refresh_from_db() + self.assertEqual( + self.conversation.memory["person_prompt"], + "25 岁左右女性,利落短发,干练通勤风", + ) class SendEndpointTests(TestCase): @@ -600,6 +610,60 @@ class SendEndpointTests(TestCase): for ref in self.conversation.pinned_refs )) + def test_official_model_can_be_selected_from_person_source_gate(self): + official_owner = User.objects.create_user(username="official-model-owner", password="p") + official_team = Team.objects.create(name="Official Models", owner=official_owner) + TeamMember.objects.create(team=official_team, user=official_owner, role="owner") + portrait = Asset.objects.create( + team=official_team, + created_by=official_owner, + name="官方出镜人物", + asset_type=Asset.Type.IMAGE, + source=Asset.Source.AI_GENERATED, + category=Asset.Category.MODEL_PORTRAIT, + ) + model = AssetModel.objects.create( + team=official_team, + created_by=official_owner, + name="官方出镜人物", + portrait_asset=portrait, + is_official=True, + ) + self.conversation.mode = CreationConversation.Mode.VIDEO + self.conversation.save(update_fields=["mode", "updated_at"]) + card = append_message( + self.conversation, + role="assistant", + kind=CreationMessage.Kind.ELICIT, + text="先确定出镜人物", + payload={ + "interaction": "person_source_gate", + "fields": [{"key": "person_source", "type": "single"}], + "submitted": False, + "answers": {}, + }, + ) + + response = self.client.post( + f"/api/ai/creations/{self.conversation.id}/send/", + { + "kind": "elicit_answer", + "reply_to": str(card.id), + "answers": {"person_source": "model_library"}, + "refs": [{"type": "model", "id": str(model.id), "name": model.name}], + }, + format="json", + ) + + self.assertEqual(response.status_code, 202) + card.refresh_from_db() + self.conversation.refresh_from_db() + self.assertTrue(card.payload["submitted"]) + self.assertTrue(any( + ref.get("type") == "model" and str(ref.get("id")) == str(model.id) + for ref in self.conversation.pinned_refs + )) + @override_settings(CREATION_AGENT_INLINE=False, CREATION_AGENT_TASK_QUEUE="airshelf.local") def test_cast_relation_label_click_saves_lead_and_continues_without_text_input(self): product = Product.objects.create(team=self.team, created_by=self.user, title="蓝牙耳机") diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index ae40aea..4f9cb76 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -1993,6 +1993,14 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): pending.save(update_fields=["payload", "updated_at"]) if is_product_gate: _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: @@ -2089,6 +2097,24 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): continuation_instruction = apply_pain_point_direction( conversation, payload, choice ) + elif payload.get("topic") == "product_info": + clean_ans = text.strip() + memory = dict(conversation.memory or {}) + memory["product_info_resolved"] = True + if clean_ans and not any(kw in clean_ans for kw in ("按图片", "设计品牌", "不用再问")): + memory["product_brand_and_name"] = clean_ans + for r in (conversation.pinned_refs or []): + if isinstance(r, dict) and r.get("type") in ("asset", "product"): + r["name"] = clean_ans + break + conversation.pinned_refs = conversation.pinned_refs + continuation_instruction = f"用户已提供商品品牌与品名:【{clean_ans}】。直接围绕该商品推进方案,不要重复追问。" + else: + continuation_instruction = "用户要求直接根据图片内容设计品牌与品名推进创作。直接基于图片外观特征推进方案,不要重复追问品牌。" + conversation.memory = memory + conversation.save(update_fields=["memory", "pinned_refs", "updated_at"]) + text = "" + record_user_message = False else: continuation_instruction = ( "用户刚用输入框回答了你上一句问题。直接基于这条真实回答继续原任务;" @@ -2097,6 +2123,30 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): if params_changed: continuation_instruction += " 会话参数已更新,旧方案作废,按新参数重新产出。" + # 用户对刚生成的角色给出确认/重刷反馈 + clean_text = text.strip() + if re.search(r"^(使用这[个位只]角色|就用这[个位只]角色|就用[她他它]|满意|确认使用|合适|可以)[继续创作。!! ]*$", clean_text) or clean_text in ("使用这个角色继续创作", "使用这个宠物角色继续创作"): + force_creative_turn = True + continuation_instruction = ( + "用户已确认使用当前生成的角色出镜。直接基于该角色继续推进创作方案" + "(若尚未选商品则确定商品,已选好商品则开始写创作策略和方案),不要再次追问角色来源。" + ) + elif re.search(r"^(重新生成|再生成|重刷|换一个|换位|不满意).{0,6}(角色|人物|模特|宠物)?[。!! ]*$", clean_text) and (conversation.memory or {}).get("person_source") == "platform_generate": + person_prompt = str((conversation.memory or {}).get("person_prompt") or "").strip() + try: + generating = submit_generated_person_reference( + conversation=conversation, + user=request.user, + appearance_prompt=person_prompt, + ) + return JsonResponse({ + "conversation_id": str(conversation.id), + "agent_status": conversation.agent_status, + "messages": [CreationMessageSerializer(generating).data], + }, status=202) + except Exception: + pass + if kind == "confirm": reply_to = str(request.data.get("reply_to") or "").strip() card = conversation.messages.filter( @@ -2215,7 +2265,7 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): ] valid = any( AssetModel.objects.filter( - team=conversation.team, + Q(team=conversation.team) | Q(is_official=True), id=ref.get("id"), is_deleted=False, purged_at__isnull=True, @@ -2322,10 +2372,12 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): elif payload.get("interaction") == "person_source_gate": source = str(answers.get("person_source") or "").strip() if source == "platform_generate": + person_prompt = str(answers.get("person_prompt") or "").strip()[:500] try: generating = submit_generated_person_reference( conversation=conversation, user=request.user, + appearance_prompt=person_prompt, ) except ValueError as exc: # 生图没提交成功,把闸门放回去供用户换方式或重试。 @@ -2357,10 +2409,40 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): text = "" record_user_message = False force_creative_turn = True + is_pet = "宠物" in str(conversation.preset or "") continuation_instruction = ( + "用户已选定出镜宠物角色,该宠物形象已作为整条视频的固定身份参考。" + "直接继续创作;所有镜头和分段保持同一宠物,不要再追问角色来源。" + if is_pet else "用户已选定出镜人物,该人物已作为整条视频的固定身份参考。" "直接继续创作;所有镜头和分段保持同一人,不要再追问人物来源。" ) + elif payload.get("topic") == "product_info": + ans = str(answers.get("product_brand_and_name") or "").strip() + memory = dict(conversation.memory or {}) + memory["product_info_resolved"] = True + clean_ans = ans.replace("品牌是:,商品名是:", "").strip() + if clean_ans and not any(kw in clean_ans for kw in ("按图片", "设计品牌", "不用再问")): + memory["product_brand_and_name"] = clean_ans + bm = re.search(r"品牌[::是为]?\s*([^\n,,。!!]+)", clean_ans) + nm = re.search(r"(?:品名|商品名|产品名)[::是为]?\s*([^\n,,。!!]+)", clean_ans) + if bm: + memory["product_brand"] = bm.group(1).strip() + if nm: + memory["product_name"] = nm.group(1).strip() + for r in (conversation.pinned_refs or []): + if isinstance(r, dict) and r.get("type") in ("asset", "product"): + r["name"] = clean_ans + break + conversation.pinned_refs = conversation.pinned_refs + continuation_instruction = f"用户已提供商品品牌与品名:【{clean_ans}】。直接围绕该商品推进方案,不要重复追问。" + else: + continuation_instruction = "用户要求直接根据图片内容设计品牌与品名推进创作。直接基于图片外观特征推进方案,不要重复追问品牌。" + conversation.memory = memory + conversation.save(update_fields=["memory", "pinned_refs", "updated_at"]) + text = "" + record_user_message = False + force_creative_turn = True elif payload.get("phase") == "gate": choice = str(answers.get("_asset_gate") or "").strip() pending = payload.get("pending_fields") or [] @@ -2411,6 +2493,14 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): 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: # 卡片本身已经记录了用户的选择。不要再伪造一条黑色用户气泡; # 继续原任务,并明确告诉模型不要再次追问同一项素材。 @@ -2423,6 +2513,29 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): "用户刚选择暂不添加这项素材。接受这个选择,按会话里已有的需求和合理默认继续原任务。" "先用一句自然的话承接,随后直接推进创作;不要再次追问同一素材,不要只说收到或有需要再说。" ) + elif payload.get("phase") == "pick" and ( + answers.get("_action") == "cancel" + or any(str(v).lower() in ("cancel", "取消", "skip", "跳过") for v in answers.values()) + ): + pick_fields = payload.get("fields") or [] + primary = pick_fields[0] if pick_fields else {} + types = primary.get("asset_types") or [] + key = str(primary.get("key") or "").strip() + if "product" in types or key == "product": + _mark_product_source_resolved(conversation) + if any(t in types for t in ("character", "model")) or key in ("character", "model", "person"): + memory = dict(conversation.memory or {}) + 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 + continuation_instruction = ( + "用户取消了从列表选择素材(素材库暂无合适素材或用户放弃选择)。" + "接受这个选择,根据会话里已有的需求和合理默认继续推进原任务,不要重复追问同一项素材。" + ) else: params_changed = apply_session_params(conversation, payload.get("fields") or [], answers) # 点选商品/角色必须钉成 Ref:模型常把选项做成单选文字,前端只回 answers。 diff --git a/core/backend/apps/projects/test_quick_create.py b/core/backend/apps/projects/test_quick_create.py index f5be1e2..1dd61d0 100644 --- a/core/backend/apps/projects/test_quick_create.py +++ b/core/backend/apps/projects/test_quick_create.py @@ -1375,6 +1375,8 @@ class QuickCreateCoordinatorTests(TestCase): + + diff --git a/core/frontend/src/omni-session-page.css b/core/frontend/src/omni-session-page.css index cb7dacc..53778a6 100644 --- a/core/frontend/src/omni-session-page.css +++ b/core/frontend/src/omni-session-page.css @@ -360,13 +360,34 @@ .omni-result-card, .omni-process-card { - width: min(760px, calc(100% - 44px)); + box-sizing: border-box; + width: min(320px, calc(100% - 44px)); + max-width: 320px; margin: 0 0 24px 44px; border: 1px solid rgba(34, 42, 54, .09); border-radius: 15px; background: #fff; box-shadow: 0 10px 26px rgba(20, 27, 38, .06); animation: omniMessageIn 220ms ease both; + overflow: hidden; +} + +.omni-result-card.has-multiple, +.omni-process-card.has-multiple { + width: min(520px, calc(100% - 44px)); + max-width: 520px; +} + +.omni-result-card.has-single.is-ratio-16-9, +.omni-process-card.has-single.is-ratio-16-9 { + width: min(480px, calc(100% - 44px)); + max-width: 480px; +} + +.omni-result-card.has-single.is-ratio-1-1, +.omni-process-card.has-single.is-ratio-1-1 { + width: min(340px, calc(100% - 44px)); + max-width: 340px; } .omni-strategy-card, @@ -1167,7 +1188,10 @@ } .omni-process-frame { - height: 310px; + width: 100%; + height: 100%; + aspect-ratio: 9 / 16; + min-height: 200px; display: flex; flex-direction: column; align-items: center; @@ -1178,6 +1202,14 @@ animation: omniShimmer 1.2s ease infinite; } +.is-ratio-16-9 .omni-process-frame { + aspect-ratio: 16 / 9; +} + +.is-ratio-1-1 .omni-process-frame { + aspect-ratio: 1 / 1; +} + .omni-process-frame strong { color: #5c6570; font-size: 14px; @@ -1211,6 +1243,8 @@ .omni-result-media.is-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + padding: 10px; } .omni-result-tile { @@ -1220,11 +1254,22 @@ border-radius: var(--r-md, 12px); background: #edf0f4; box-shadow: 0 8px 20px rgba(20, 27, 38, .08); + aspect-ratio: 9 / 16; + width: 100%; +} + +.is-ratio-16-9 .omni-result-tile { + aspect-ratio: 16 / 9; +} + +.is-ratio-1-1 .omni-result-tile { + aspect-ratio: 1 / 1; } .omni-result-preview { display: block; width: 100%; + height: 100%; padding: 0; border: 0; background: transparent; @@ -1233,18 +1278,19 @@ .omni-result-tile img { width: 100%; - height: 310px; + height: 100%; + aspect-ratio: inherit; display: block; object-fit: cover; background: #edf0f4; } .omni-result-media.is-grid .omni-result-tile img { - height: 220px; + height: 100%; } .omni-result-media.is-grid .omni-process-frame { - height: 220px; + height: 100%; } .omni-result-play { @@ -1731,14 +1777,36 @@ background: var(--klein); color: #fff; font-size: 13px; + font-weight: 500; cursor: pointer; + transition: all 160ms ease; +} + +.omni-elicit-actions button.cancel-btn { + background: var(--surface, #fff); + color: var(--text-secondary, #606a78); + border: 1px solid var(--border-faint, #e2e8f0); +} + +.omni-elicit-actions button.cancel-btn:hover:not(:disabled) { + background: var(--background-hover, #f8fafc); + color: var(--accent-black, #1a202c); + border-color: var(--border-subtle, #cbd5e1); } .omni-elicit-actions button:disabled { background: rgba(34, 42, 54, .18); + color: rgba(255, 255, 255, .7); cursor: not-allowed; } +.omni-elicit-actions button.cancel-btn:disabled { + background: var(--surface, #fff); + color: var(--text-tertiary, #94a3b8); + border-color: var(--border-faint, #e2e8f0); + opacity: 0.6; +} + /* 已提交的追问卡变成只读回顾,不能再改 —— 重复提交后端会 409 */ .omni-elicit-card.is-submitted .omni-elicit-options button, .omni-elicit-card.is-submitted .omni-elicit-assets button { @@ -2703,6 +2771,47 @@ cursor: not-allowed; } +.omni-person-generate-panel { + display: grid; + width: 100%; + gap: 8px; + margin-top: 4px; + padding: 12px; + border-radius: var(--r-md); + background: var(--background-lighter); + box-shadow: inset 0 0 0 1px var(--border-faint); +} + +.omni-person-generate-panel label { + color: var(--accent-black); + font-size: 12px; + font-weight: 600; +} + +.omni-person-generate-panel .textarea { + width: 100%; + min-height: 88px; + resize: vertical; +} + +.omni-person-generate-panel > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.omni-person-generate-panel > div > span { + flex: 1 1 auto; + color: var(--black-alpha-48); + font-size: 11px; + line-height: 1.6; +} + +.omni-person-generate-panel > div > button { + flex: 0 0 auto; +} + /* 单选追问直接点选即提交;长方向文案纵向铺开,避免挤成难扫读的小胶囊。 */ .omni-chat-choice-actions { width: 100%; @@ -3079,6 +3188,105 @@ font-size: 11px; } +.omni-model-picker-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.omni-model-picker-card { + display: flex; + min-width: 0; + flex-direction: column; + gap: 8px; + padding: 8px; + border: 0; + border-radius: var(--r-md); + background: var(--surface); + color: var(--accent-black); + box-shadow: inset 0 0 0 1px var(--border-faint); + text-align: left; + cursor: pointer; + transition: background var(--t-base), box-shadow var(--t-base); +} + +.omni-model-picker-card:hover, +.omni-model-picker-card.is-selected { + background: var(--background-lighter); + box-shadow: inset 0 0 0 1px var(--heat-40); +} + +.omni-model-picker-card.is-selected { + color: var(--heat); +} + +.omni-model-picker-media { + display: grid; + width: 100%; + aspect-ratio: 3 / 4; + place-items: center; + overflow: hidden; + border-radius: 6px; + background: var(--background-base); + color: var(--black-alpha-48); +} + +.omni-model-picker-media img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.omni-model-picker-media svg { + width: 24px; + height: 24px; +} + +.omni-model-picker-copy { + display: block; + min-width: 0; + padding: 0 2px 2px; +} + +.omni-model-picker-copy strong, +.omni-model-picker-copy small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.omni-model-picker-copy strong { + font-size: 13px; + font-weight: 600; +} + +.omni-model-picker-copy small { + margin-top: 2px; + color: var(--black-alpha-48); + font-family: var(--font-mono); + font-size: 10px; +} + +.omni-model-picker-footer { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 20px; +} + +.omni-model-picker-selection { + min-width: 0; + flex: 1 1 auto; + overflow: hidden; + color: var(--black-alpha-48); + font-family: var(--font-mono); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + @media (min-width: 1101px) { .omni-session-page.has-assets-panel { box-sizing: border-box; @@ -3130,4 +3338,13 @@ .omni-assets-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .omni-person-generate-panel > div { + align-items: stretch; + flex-direction: column; + } + + .omni-person-generate-panel > div > button { + width: 100%; + } } diff --git a/core/frontend/src/routes/omni-session.tsx b/core/frontend/src/routes/omni-session.tsx index 449aec6..cfc7145 100644 --- a/core/frontend/src/routes/omni-session.tsx +++ b/core/frontend/src/routes/omni-session.tsx @@ -38,6 +38,7 @@ import type { CreationMessage, CreationRef, ModelConfig, + ModelEntity, } from "../types"; import type { NavigateFn } from "./route-config"; @@ -219,6 +220,14 @@ function contextualReplyOptions(text: string): ReplyOption[] { { label: "你来推荐", text: "你根据当前需求推荐一款" }, ]; } + // 角色/人物图已生成时,询问用户是否使用该角色,不再提供「由你设定角色/上传人物图/不需要人物」 + if (/是否使用这[个位]角色|角色.{0,8}(?:已生成|合适吗)|你看这[个位]角色|人物参考已生成/i.test(guidance)) { + return [ + { label: "使用这个角色", text: "使用这个角色继续创作" }, + { label: "重新生成一个", text: "重新生成一个角色" }, + { label: "上传其他人物", text: "我上传人物参考图", action: "upload" }, + ]; + } if (/(?:哪位|哪个|什么|选择|选|换|更换|调整|修改|改).{0,12}(?:人物|角色|模特|出镜)|(?:人物|角色|模特|出镜).{0,12}(?:哪位|哪个|选择|选|换|更换|调整|修改|改)/i.test(guidance)) { return [ { label: "上传人物图", text: "我上传人物参考图", action: "upload" }, @@ -1143,6 +1152,7 @@ function ElicitCard({ onSubmit, onChatAnswer, onPersonSourceAction, + onUpload, }: { message: CreationMessage; disabled: boolean; @@ -1152,7 +1162,11 @@ function ElicitCard({ /** 普通追问直接在气泡下回答,仍走 text 链路以保留用户消息与上下文。 */ onChatAnswer: (text: string) => void; /** 人物来源三选一要分别打开系统文件、模特库和平台生成流程。 */ - onPersonSourceAction: (source: "local_upload" | "model_library" | "platform_generate") => void; + onPersonSourceAction: ( + source: "local_upload" | "model_library" | "platform_generate", + personPrompt?: string, + ) => void; + onUpload?: () => void; }) { const fields = ((message.payload.fields as CreationField[] | undefined) || []).filter(Boolean); const submitted = Boolean(message.payload.submitted); @@ -1164,6 +1178,8 @@ function ElicitCard({ const [assetPicked, setAssetPicked] = useState>({}); const [customDirection, setCustomDirection] = useState(""); const [chatAnswer, setChatAnswer] = useState(""); + const [personPromptOpen, setPersonPromptOpen] = useState(false); + const [personPrompt, setPersonPrompt] = useState(""); useEffect(() => { if (submitted || interaction === "chat" || interaction === "step_confirm") return; @@ -1210,15 +1226,21 @@ function ElicitCard({ if (interaction === "person_source_gate") { const selected = String(saved.person_source || ""); const selectedLabel = fields[0]?.options?.find((option) => option.value === selected)?.label; + const isPet = Boolean( + message.payload?.is_pet || + message.text?.includes("宠物") + ); + const libraryOption = fields[0]?.options?.find((option) => option.value === "model_library"); + const libraryLabel = libraryOption?.label || (isPet ? "从角色库选择" : "从模特库选择"); return (
- + {!submitted ? ( -
+
+ {personPromptOpen ? ( +
+ +