From 2745760d33c7674661d20ab4c3ca54c6dbe0a7ef Mon Sep 17 00:00:00 2001 From: "Azmat@qq.com" Date: Tue, 22 Sep 2026 10:02:47 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=A8=E8=83=BD=E5=88=9B=E4=BD=9C=E9=97=B8?= =?UTF-8?q?=E9=97=A8=E4=B8=8E=E5=A4=9A=E4=BA=BA=E7=89=A9=E5=AE=9A=E5=A6=86?= =?UTF-8?q?=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主页已填卖点跳过卖点确认;按图片内容创作不再重复问品牌品名;方案卡支撑改为 P0/P1/P2;多人物视频一次生成多张定妆图并在出片前校验数量;超管登录直进后台。 --- core/backend/apps/ai/creation.py | 97 +++- core/backend/apps/ai/creation_agent.py | 463 ++++++++++++++++---- core/backend/apps/ai/test_creation_agent.py | 119 ++++- core/backend/apps/ai/views.py | 38 +- core/frontend/src/App.tsx | 31 +- core/frontend/src/routes/omni-session.tsx | 6 +- 6 files changed, 640 insertions(+), 114 deletions(-) diff --git a/core/backend/apps/ai/creation.py b/core/backend/apps/ai/creation.py index 54ad5a4..64c2105 100644 --- a/core/backend/apps/ai/creation.py +++ b/core/backend/apps/ai/creation.py @@ -591,23 +591,45 @@ def finish_generating_message(message: CreationMessage, *, assets: list[dict], m ).first() if asset is None: return fail_generating_message(message, "人物参考已生成,但未找到可锁定的图片资产") + cast_index = int(original_payload.get("cast_index") or 1) + cast_total = int(original_payload.get("cast_total") or 1) + cast_model_name = str(original_payload.get("cast_model_name") or "").strip() + is_pet = "宠物" in str(conversation.preset or "") + if not cast_model_name: + if is_pet: + cast_model_name = "平台生成宠物角色" + elif cast_total > 1: + cast_model_name = f"平台生成出镜人物{cast_index}" + else: + cast_model_name = "平台生成出镜人物" model = Model.objects.filter( team=conversation.team, portrait_asset=asset, 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="平台生成宠物角色" if is_pet else "平台生成出镜人物", + name=cast_model_name, source=Model.Source.AI, portrait_asset=asset, - description="由全能创作生成并锁定的宠物角色。" if is_pet else "由全能创作生成并锁定的视频出镜人物。", - metadata={"feature": "omni_create", "conversation_id": str(conversation.id)}, + description=( + "由全能创作生成并锁定的宠物角色。" + if is_pet else + "由全能创作生成并锁定的视频出镜人物。" + ), + metadata={ + "feature": "omni_create", + "conversation_id": str(conversation.id), + "cast_index": cast_index, + "cast_total": cast_total, + }, ) + elif model.name != cast_model_name and cast_total > 1: + model.name = cast_model_name + model.save(update_fields=["name", "updated_at"]) pin_refs(conversation, [{ "type": "model", "id": str(model.id), @@ -616,10 +638,37 @@ def finish_generating_message(message: CreationMessage, *, assets: list[dict], m }]) memory = dict(conversation.memory or {}) memory["person_source"] = "platform_generate" + model_ids = [ + str(item) + for item in (memory.get("person_cast_model_ids") or []) + if str(item).strip() + ] + model_id = str(model.id) + if model_id not in model_ids: + model_ids.append(model_id) + memory["person_cast_model_ids"] = model_ids + memory["person_model_id"] = model_id + pending = int(memory.get("person_cast_pending") or cast_total or 1) + pending = max(0, pending - 1) + memory["person_cast_pending"] = pending + memory["person_cast_total"] = int(memory.get("person_cast_total") or cast_total or 1) + if pending > 0: + # 还有其它角色定妆图在生成:继续等待,不打断用户确认 + memory["person_source_pending"] = True + memory.pop("person_source_ready", None) + memory.pop("person_confirm_pending", None) + conversation.memory = memory + conversation.status = CreationConversation.Status.RUNNING + conversation.agent_status = CreationConversation.AgentStatus.IDLE + conversation.last_active_at = timezone.now() + conversation.save(update_fields=[ + "memory", "status", "agent_status", "last_active_at", "updated_at", + ]) + return message + memory["person_source_pending"] = False memory["person_source_ready"] = True memory["person_confirm_pending"] = True - memory["person_model_id"] = str(model.id) conversation.memory = memory conversation.status = CreationConversation.Status.RUNNING conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER @@ -627,24 +676,38 @@ def finish_generating_message(message: CreationMessage, *, assets: list[dict], m conversation.save(update_fields=[ "memory", "status", "agent_status", "last_active_at", "updated_at", ]) + total = int(memory.get("person_cast_total") or 1) + if is_pet: + confirm_text = "宠物角色已生成。你看这只宠物合适吗?是否使用这个宠物角色继续创作?" + reply_hint = "回复「使用这个角色」继续,或说明想要的宠物品种与外观…" + regen = "重新生成一个宠物角色" + upload = "我上传宠物参考图" + upload_label = "上传其他宠物" + elif total > 1: + confirm_text = ( + f"已生成 {total} 位出镜角色定妆图。长视频会按这些图分别锁脸," + "避免前后形象漂移。你看这组角色合适吗?是否使用这些角色继续创作?" + ) + reply_hint = "回复「使用这个角色」继续,或说明想调整哪一位…" + regen = "重新生成一个角色" + upload = "我上传人物参考图" + upload_label = "上传其他人物" + else: + confirm_text = "人物参考已生成。你看这位角色合适吗?是否使用这个角色继续创作?" + reply_hint = "回复「使用这个角色」继续,或说明想调整的地方…" + regen = "重新生成一个角色" + upload = "我上传人物参考图" + upload_label = "上传其他人物" append_message( conversation, role="assistant", - text=( - "宠物角色已生成。你看这只宠物合适吗?是否使用这个宠物角色继续创作?" - if is_pet else - "人物参考已生成。你看这位角色合适吗?是否使用这个角色继续创作?" - ), + text=confirm_text, payload={ - "reply_hint": ( - "回复「使用这个角色」继续,或说明想要的宠物品种与外观…" - if is_pet else - "回复「使用这个角色」继续,或说明想调整的地方…" - ), + "reply_hint": reply_hint, "reply_options": [ {"label": "使用这个角色", "text": "使用这个角色继续创作"}, - {"label": "重新生成一个", "text": "重新生成一个宠物角色" if is_pet else "重新生成一个角色"}, - {"label": "上传其他宠物" if is_pet else "上传其他人物", "text": "我上传宠物参考图" if is_pet else "我上传人物参考图"}, + {"label": "重新生成一个", "text": regen}, + {"label": upload_label, "text": upload}, ], }, ) diff --git a/core/backend/apps/ai/creation_agent.py b/core/backend/apps/ai/creation_agent.py index 4725acc..a938ccf 100644 --- a/core/backend/apps/ai/creation_agent.py +++ b/core/backend/apps/ai/creation_agent.py @@ -506,12 +506,126 @@ def get_pending_video_prompt(conversation: CreationConversation) -> str: return str(memory.get("pending_video_prompt") or "").strip() +_SELLING_POINT_DECLARE_RE = re.compile( + r"(?:核心)?卖点\s*(?:是|为|:|:)\s*(.+)", + re.IGNORECASE | re.DOTALL, +) +_SELLING_POINT_FOCUS_RE = re.compile( + # 「重点讲」必须带「是/为/:」,避免预设文案「重点讲清使用场景…」被当成已填卖点。 + r"(?:我想?突出(?:的)?|重点突出|主要讲|主打)\s*(?:的是|是|为|:|:)\s*(.+)" + r"|重点讲\s*(?:的是|是|为|:|:)\s*(.+)", + re.IGNORECASE | re.DOTALL, +) +_SELLING_POINT_BRIEF_CREATIVE_RE = re.compile( + r"(?:创作|做[一条]?|视频|帮我|生成|脚本|出片|参考|预设|方向)", +) + + +def extract_declared_selling_point(text: str) -> str: + """从用户话术里抽出已声明的核心卖点。 + + 主页开场描述常写成「核心卖点是…」或一串短卖点列表;这些都应视为已确认, + 避免 write_strategy 前再弹一次卖点闸门。 + """ + raw = (text or "").strip() + if not raw: + return "" + for pattern in (_SELLING_POINT_DECLARE_RE, _SELLING_POINT_FOCUS_RE): + match = pattern.search(raw) + if not match: + continue + value = next((group for group in match.groups() if group), "").strip() + value = re.split(r"[\n\r]", value)[0].strip() + value = value.strip("。;;!!??,,、 ") + if len(value) >= 2: + return value[:200] + # 整段就是短卖点列表(如「补水保湿,便于携带,买三送一」),且不像创作指令 + if len(raw) <= 80 and not _SELLING_POINT_BRIEF_CREATIVE_RE.search(raw): + parts = [part.strip() for part in re.split(r"[,,、;;]", raw) if part.strip()] + if len(parts) >= 2 and all(2 <= len(part) <= 24 for part in parts): + return raw[:200] + return "" + + +def product_selling_points_summary(conversation: CreationConversation) -> str: + """取已钉商品库商品上的卖点标题,供闸门预填或作为已填卖点。""" + from apps.products.models import Product + + for ref in locked_product_references(conversation): + if str(ref.get("type") or "") != "product": + continue + product = ( + Product.objects.filter(id=ref.get("id"), team_id=conversation.team_id) + .prefetch_related("selling_points") + .first() + ) + if product is None: + continue + titles: list[str] = [] + for point in product.selling_points.order_by("sort_order", "created_at"): + title = str(point.title or "").strip() + if title and title not in titles: + titles.append(title) + if titles: + return ",".join(titles)[:200] + return "" + + +def lock_selling_point( + conversation: CreationConversation, + selling_point: str, + *, + mode: str = "manual", +) -> None: + memory = dict(conversation.memory or {}) + memory["selling_point_ready"] = True + memory["selling_point_mode"] = mode + memory["selling_point"] = selling_point if mode == "manual" else "" + conversation.memory = memory + conversation.save(update_fields=["memory", "updated_at"]) + + +def ensure_selling_point_ready_from_context( + conversation: CreationConversation, + user_text: str = "", +) -> bool: + """主页/聊天已声明卖点,或商品库已填卖点时,直接锁定,避免重复询问。""" + if conversation.mode != CreationConversation.Mode.VIDEO: + return False + # 痛点解决演示用「方向三选一」把方向写成核心卖点;这里抢先 ready 会跳过那道闸门。 + if is_pain_point_conversation(conversation): + return False + memory = conversation.memory if isinstance(conversation.memory, dict) else {} + if memory.get("selling_point_ready"): + return True + + candidates: list[str] = [] + declared = extract_declared_selling_point(user_text) + if declared: + candidates.append(declared) + for text in conversation.messages.filter(role=CreationMessage.Role.USER).order_by("seq").values_list("text", flat=True): + found = extract_declared_selling_point(text) + if found: + candidates.append(found) + if candidates: + # 以最近一次声明为准(主页首条或后续补充) + lock_selling_point(conversation, candidates[-1], mode="manual") + return True + + summary = product_selling_points_summary(conversation) + if summary: + lock_selling_point(conversation, summary, mode="manual") + return True + return False + + def append_selling_point_gate(conversation: CreationConversation) -> CreationMessage: """在视频方案生成前确认卖点来源。 商家可以直接给真实卖点;若交给系统,则后续只从商品资料和参考素材中选择可证实的表达, 不把“系统推荐”误做成无依据的夸大文案。 """ + prefill = product_selling_points_summary(conversation) return append_message( conversation, role="assistant", @@ -529,7 +643,7 @@ def append_selling_point_gate(conversation: CreationConversation) -> CreationMes } ], "submitted": False, - "answers": {}, + "answers": {"selling_point": prefill} if prefill else {}, }, ) @@ -821,6 +935,12 @@ def product_info_needs_confirmation(conversation: CreationConversation, user_tex if conversation.preset not in _PRODUCT_REQUIRED_PRESETS: return False memory = conversation.memory if isinstance(conversation.memory, dict) else {} + # 「按图片内容创作」只标记 resolved、不写真实品牌;也必须视为已确认,否则会反复弹闸门。 + if memory.get("product_info_resolved") or memory.get("product_info_from_image"): + return False + probe = str(user_text or "") + if any(kw in probe for kw in ("按图片", "设计品牌", "不用再问")): + return False brand = str(memory.get("product_brand") or "").strip() name = str(memory.get("product_name") or "").strip() combined = str(memory.get("product_brand_and_name") or "").strip() @@ -828,9 +948,7 @@ def product_info_needs_confirmation(conversation: CreationConversation, user_tex has_real_brand = bool(token.search(brand)) has_real_name = bool(token.search(name)) has_real_combined = bool(combined) and not is_incomplete_product_brand_answer(combined) - if memory.get("product_info_resolved") and (has_real_brand or has_real_name or has_real_combined): - return False - if has_real_brand or has_real_name: + if has_real_brand or has_real_name or has_real_combined: return False # 如果已锁定的首个商品是正式商品库商品(已自带品牌和品名),无需重复询问 first_prod = next( @@ -850,13 +968,14 @@ def product_info_needs_confirmation(conversation: CreationConversation, user_tex else [] ) all_text = " ".join(str(m.text or "") for m in user_messages) + " " + (user_text or "") + if any(kw in all_text for kw in ("按图片", "设计品牌", "不用再问")): + return False 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 = ( @@ -929,11 +1048,21 @@ def append_person_source_gate(conversation: CreationConversation) -> CreationMes field_label = "选择宠物来源" library_label = "从角色库选择" else: - prompt_text = ( - f"商品已选定【{product_name}】。这条视频想由哪位角色/达人出镜?选定后,所有镜头和分段都会锁定同一位人物。" - if product_name else - "先确定这条视频的出镜人物。选定后,所有镜头和分段都会锁定同一位人物。" - ) + cast_needed = infer_needed_cast_count(conversation) + if cast_needed > 1: + prompt_text = ( + f"商品已选定【{product_name}】。这条视频需要 {cast_needed} 位出镜人物;" + "请上传/选择/生成对应数量的角色定妆图,所有镜头和分段都会按这些图锁脸。" + if product_name else + f"这条视频需要 {cast_needed} 位出镜人物。请上传/选择/生成对应数量的角色定妆图," + "所有镜头和分段都会按这些图锁脸,避免长视频前后形象漂移。" + ) + else: + prompt_text = ( + f"商品已选定【{product_name}】。这条视频想由哪位角色/达人出镜?选定后,所有镜头和分段都会锁定同一位人物。" + if product_name else + "先确定这条视频的出镜人物。选定后,所有镜头和分段都会锁定同一位人物。" + ) field_label = "选择人物来源" library_label = "从模特库选择" @@ -1087,16 +1216,188 @@ def _character_only_appearance_text(raw: str) -> str: return text[:500] +def _conversation_cast_text_blob(conversation: CreationConversation, extra_text: str = "") -> str: + """汇总可用来判断「几位出镜人物」的文本。""" + chunks: list[str] = [str(extra_text or "").strip()] + memory = conversation.memory if isinstance(conversation.memory, dict) else {} + for key in ("person_prompt", "cast_relation", "pending_video_prompt", "pain_point_direction"): + value = str(memory.get(key) or "").strip() + if value: + chunks.append(value) + if getattr(conversation, "created_at", None): + for text in conversation.messages.filter(role=CreationMessage.Role.USER).order_by("-seq").values_list("text", flat=True)[:8]: + if text and str(text).strip(): + chunks.append(str(text).strip()) + for payload in conversation.messages.filter(kind__in=[ + CreationMessage.Kind.STRATEGY, + CreationMessage.Kind.PLAN, + ]).order_by("-seq").values_list("payload", flat=True)[:4]: + if not isinstance(payload, dict): + continue + for key in ("belief", "direction", "usp", "video_prompt", "target", "trust"): + value = str(payload.get(key) or "").strip() + if value: + chunks.append(value) + points = payload.get("points") + if isinstance(points, list): + chunks.extend(str(item).strip() for item in points if str(item).strip()) + return "\n".join(chunks) + + +def infer_needed_cast_count(conversation: CreationConversation, extra_text: str = "") -> int: + """从主页描述/策略/方案推断需要几张角色定妆图。 + + 多人物长视频若只锁一张脸,分段后很容易前后形象漂移;这里至少按剧情人数生成。 + 宠物预设默认 1;人手上限 4,避免一次刷太多出图。 + """ + if is_pet_preset(conversation.preset): + return 1 + blob = _conversation_cast_text_blob(conversation, extra_text) + if not blob.strip(): + return 1 + + explicit = [ + (5, r"(?:五位|五个|5\s*人|5\s*位|五人)"), + (4, r"(?:四位|四个|4\s*人|4\s*位|四人)"), + (3, r"(?:三位|三个|3\s*人|3\s*位|三人)"), + (2, r"(?:两位|两个|2\s*人|2\s*位|两人|双人|一对|情侣|夫妻|母女|父女|母子|父子|姐妹|兄弟|闺蜜|搭档|CP|男女主|男主.*?女主|女主.*?男主)"), + ] + needed = 1 + for count, pattern in explicit: + if re.search(pattern, blob, flags=re.IGNORECASE | re.DOTALL): + needed = max(needed, count) + + # 「角色1 / 角色2」或分行描述多个角色 + role_labels = re.findall(r"(?:角色|人物|出演)\s*([1-4一二三四])", blob) + if len(set(role_labels)) >= 2: + mapping = {"1": 1, "2": 2, "3": 3, "4": 4, "一": 1, "二": 2, "三": 3, "四": 4} + needed = max(needed, max(mapping.get(item, 1) for item in role_labels)) + + split_roles = [ + part.strip() + for part in re.split(r"(?:^|\n)\s*(?:角色|人物)\s*[1-4一二三四][::、..\s]", blob) + if part.strip() + ] + if len(split_roles) >= 2: + needed = max(needed, min(4, len(split_roles))) + + return max(1, min(4, needed)) + + +def build_cast_role_briefs(count: int, appearance_prompt: str = "") -> list[str]: + """把用户外观描述拆成 N 条;不够时补「与其他角色外貌明显区分」的配角 brief。""" + raw = (appearance_prompt or "").strip() + briefs: list[str] = [] + if raw: + labeled = re.findall( + r"(?:角色|人物)\s*[1-4一二三四][::、..\s]+([^\n;;]+)", + raw, + ) + labeled = [part.strip() for part in labeled if part.strip()] + if len(labeled) >= 2: + briefs = labeled + else: + numbered = re.split( + r"(?:^|[\n;;])\s*(?:角色|人物)\s*[1-4一二三四][::、..\s]+", + raw, + ) + numbered = [part.strip() for part in numbered if part.strip()] + if len(numbered) >= 2: + briefs = numbered + else: + lined = [line.strip(" -•\t") for line in raw.splitlines() if line.strip()] + if len(lined) >= 2: + briefs = lined + else: + parts = [part.strip() for part in re.split(r"[;;//|]", raw) if part.strip()] + briefs = parts if len(parts) >= 2 else [raw] + + while len(briefs) < count: + index = len(briefs) + 1 + if index == 1: + briefs.append("") + else: + briefs.append( + f"第{index}位出镜角色,成年,五官发型服装气质与其余角色明显不同," + "可作为配角/对手戏人物独立辨认" + ) + return [_character_only_appearance_text(item)[:500] for item in briefs[:count]] + + + +def _build_single_person_reference_prompt( + *, + conversation: CreationConversation, + appearance_only: str, + context_brief: str, + cast_index: int, + cast_total: int, +) -> tuple[str, str, str]: + """返回 (prompt, generating_label, model_name)。""" + is_pet = is_pet_preset(conversation.preset) + no_product = ( + "画面中只允许出现角色本身与简洁背景,禁止出现任何商品、包装、瓶罐、袋装、纸盒、" + "电子产品、食品零食、广告道具或手持卖品;不要商品特写,不要把商品画进画面。" + ) + if is_pet: + context_text = f"{appearance_only} {context_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"画面中只出现这一只可爱的宠物动物主角,绝对不要出现真人人类!不要文字、水印、拼图或变形。" + f"{no_product}" + ) + if appearance_only: + prompt += f" 用户指定的宠物外观与品种:{appearance_only}。严格保留这些宠物外观要求。" + label = "正在生成宠物角色参考" + model_name = "平台生成宠物角色" + else: + role_tag = f"第{cast_index}位/共{cast_total}位" if cast_total > 1 else "唯一" + prompt = ( + f"为短视频生成一张可反复用于锁定身份的真人模特定妆参考图({role_tag}出镜角色)。" + "只出现一位成年人物,正面或轻微三分之四角度,中近景,表情自然," + "五官、发型、肤色、身形和服装细节清晰,简洁中性背景,写实摄影," + "不要文字、水印、拼图、多人、遮挡脸部或夸张滤镜。" + f"{no_product}" + ) + if appearance_only: + prompt += f" 用户指定的人物外观:{appearance_only}。严格保留这些外观要求。" + if cast_total > 1: + prompt += ( + f" 这是多人物视频中的角色{cast_index},必须与其他角色在性别或发型或年龄段或服装气质上" + "有清晰可辨的差异,便于整片锁脸。" + ) + label = f"正在生成人物参考({cast_index}/{cast_total})" + model_name = f"平台生成出镜人物{cast_index}" + else: + label = "正在生成人物参考" + model_name = "平台生成出镜人物" + return prompt, label, model_name + + def submit_generated_person_reference( *, conversation: CreationConversation, user, appearance_prompt: str = "", -) -> CreationMessage: - """先生成独立人物/宠物角色定妆参考,完成后再由 creation.py 自动建模特并锁定。 + cast_count: int | None = None, +) -> list: + """生成人物/宠物角色定妆参考;多人物视频会一次生成多张,完成后再建模特并锁定。 定妆图 prompt 只写角色外观,不写入商品名/卖点/用户创作简述,也不带商品参考图, 否则出图模型常会把商品画进角色照。 + 返回 GENERATING 消息列表(单人时长度为 1)。 """ from .services import enqueue_standalone_images @@ -1111,78 +1412,79 @@ def submit_generated_person_reference( # 仅用于推断宠物品种;绝不拼进最终定妆 prompt。 context_brief = "\n".join(reversed([item.strip() for item in recent_user if item and item.strip()]))[:700] raw_appearance = (appearance_prompt or "").strip()[:500] - appearance_only = _character_only_appearance_text(raw_appearance) - is_pet = is_pet_preset(conversation.preset) - no_product = ( - "画面中只允许出现角色本身与简洁背景,禁止出现任何商品、包装、瓶罐、袋装、纸盒、" - "电子产品、食品零食、广告道具或手持卖品;不要商品特写,不要把商品画进画面。" - ) - if is_pet: - # 智能推断宠物类别:用户指定 > 商品及需求上下文 > 默认萌宠(只影响品种提示,不写入商品名) - context_text = f"{raw_appearance} {context_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 = "一只可爱灵动的萌宠(如呆萌小狗或可爱猫咪)" + total = cast_count if cast_count is not None else infer_needed_cast_count(conversation, raw_appearance) + total = max(1, min(4, int(total or 1))) + if is_pet_preset(conversation.preset): + total = 1 + role_briefs = build_cast_role_briefs(total, raw_appearance) - prompt = ( - f"为AI宠物拟人短视频生成一张可反复用于锁定角色身份的萌宠主角定妆参考图。" - f"主角必须是{pet_type_hint},展现拟人化的生动表情与灵动神态," - f"正面或微侧三分之四角度,中近景特写,眼神清澈,毛发蓬松有光泽且根根分明," - f"可搭配精致简约的拟人小配饰(如可爱小领结、小方巾或背带,契合萌宠调性)," - f"写实摄影风格,电影级柔和摄影棚光影,简洁纯净背景," - f"画面中只出现这一只可爱的宠物动物主角,绝对不要出现真人人类!不要文字、水印、拼图或变形。" - f"{no_product}" - ) - if appearance_only: - prompt += f" 用户指定的宠物外观与品种:{appearance_only}。严格保留这些宠物外观要求。" - label = "正在生成宠物角色参考" - else: - prompt = ( - "为短视频生成一张可反复用于锁定身份的真人模特定妆参考图。" - "只出现一位成年人物,正面或轻微三分之四角度,中近景,表情自然," - "五官、发型、肤色、身形和服装细节清晰,简洁中性背景,写实摄影," - "不要文字、水印、拼图、多人、遮挡脸部或夸张滤镜。" - f"{no_product}" - ) - if appearance_only: - prompt += f" 用户指定的人物外观:{appearance_only}。严格保留这些外观要求。" - label = "正在生成人物参考" - - tasks = enqueue_standalone_images( - team=conversation.team, - user=user, - prompt=prompt, - mode="model", - count=1, - ratio="portrait", - feature="omni_create", - ) - task = tasks[0] memory = dict(conversation.memory or {}) memory["person_source"] = "platform_generate" memory["person_source_pending"] = True memory["person_prompt"] = appearance_prompt + memory["person_cast_total"] = total + memory["person_cast_pending"] = total + memory["person_cast_model_ids"] = [] + memory.pop("person_confirm_pending", None) + memory.pop("person_source_ready", None) + memory.pop("person_model_id", None) conversation.memory = memory conversation.status = CreationConversation.Status.RUNNING conversation.agent_status = CreationConversation.AgentStatus.IDLE conversation.save(update_fields=["memory", "status", "agent_status", "updated_at"]) - return append_message( - conversation, - role="assistant", - kind=CreationMessage.Kind.GENERATING, - payload={ - "task_id": str(task.id), - "kind": "person_reference", - "prompt": prompt, - "label": label, - }, - task=task, + + messages = [] + for index, brief in enumerate(role_briefs, start=1): + prompt, label, model_name = _build_single_person_reference_prompt( + conversation=conversation, + appearance_only=brief, + context_brief=context_brief, + cast_index=index, + cast_total=total, + ) + tasks = enqueue_standalone_images( + team=conversation.team, + user=user, + prompt=prompt, + mode="model", + count=1, + ratio="portrait", + feature="omni_create", + ) + task = tasks[0] + messages.append( + append_message( + conversation, + role="assistant", + kind=CreationMessage.Kind.GENERATING, + payload={ + "task_id": str(task.id), + "kind": "person_reference", + "prompt": prompt, + "label": label, + "cast_index": index, + "cast_total": total, + "cast_model_name": model_name, + }, + task=task, + ) + ) + return messages + + +def insufficient_cast_refs_message(conversation: CreationConversation, prompt: str = "") -> str: + """出片前:多人物但角色图不够时给出阻断文案。""" + memory = conversation.memory if isinstance(conversation.memory, dict) else {} + if str(memory.get("person_source") or "") == "finger_only": + return "" + needed = infer_needed_cast_count(conversation, prompt) + have = len(locked_person_references(conversation)) + if needed <= have: + return "" + return ( + f"这是多人物视频,需要 {needed} 张角色定妆图锁定前后形象,当前只有 {have} 张。" + "请先用「平台帮忙生成」一次生成多位角色,或从模特库/本地补齐后再出片。" ) @@ -3319,6 +3621,10 @@ def submit_confirmed_video(*, conversation: CreationConversation, user, confirm_ if not prompt: return None, "这条方案没有存下出片指令,请让我重新写一次方案。" + cast_gap = insufficient_cast_refs_message(conversation, prompt) + if cast_gap: + return None, cast_gap + context = AgentContext(conversation=conversation, user=user, model_config=None) submit, _references = _video_submit_params(context, prompt) total_duration = int(submit["duration"]) @@ -4027,7 +4333,7 @@ def _coerce_timeline(raw) -> list[dict]: def _default_plan_matrix(usp: str, points: list[str]) -> dict: """设计稿里的「卖点覆盖矩阵」:有 USP/支撑点就自动铺一版,避免方案卡干瘪。""" rows = [{"point": "主打卖点 USP", "hits": [1, 3]}] - labels = ["体验卖点 P0", "视觉卖点 P0", "转化卖点 P0"] + labels = ["核心支撑 P0", "视觉支撑 P1", "转化支撑 P2"] for index, point in enumerate(points[:3]): label = labels[index] if index < len(labels) else f"支撑卖点 P{index}" # 点名用文案前缀,hits 错落分布到 4 镜 @@ -4276,6 +4582,9 @@ def iter_creation_agent_events( user_message = append_message(conversation, role="user", text=text, refs=refs) if user_message is not None: yield {"type": "message", "message": _message_payload(user_message)} + # 主页/聊天已写卖点时尽早锁定,避免模型再追问或 write_strategy 弹闸门 + if conversation.mode == CreationConversation.Mode.VIDEO: + ensure_selling_point_ready_from_context(conversation, text) set_public_agent_progress(conversation, "starting") # brief 里直接写「做一条 120 秒视频」就是明确参数,不能仍沿用顶部的智能时长。 # 要在任何创作闸门之前落库,后面的策略、脚本和出片分段才会统一使用该时长。 @@ -5191,6 +5500,10 @@ def _dispatch_tool( ) } }, False + if context.is_video and not memory.get("selling_point_ready"): + # 主页开场描述 / 聊天声明 / 商品库已填卖点 → 直接锁定,不再弹闸门 + ensure_selling_point_ready_from_context(context.conversation) + memory = context.conversation.memory if isinstance(context.conversation.memory, dict) else {} if context.is_video and is_pain_point_conversation(context.conversation) and not memory.get("selling_point_ready"): return { "payload": { diff --git a/core/backend/apps/ai/test_creation_agent.py b/core/backend/apps/ai/test_creation_agent.py index d930432..196c526 100644 --- a/core/backend/apps/ai/test_creation_agent.py +++ b/core/backend/apps/ai/test_creation_agent.py @@ -28,6 +28,8 @@ from .creation_agent import ( _merge_tool_call_deltas, apply_explicit_video_duration_from_text, apply_pain_point_direction, + ensure_selling_point_ready_from_context, + extract_declared_selling_point, apply_restart_intent, apply_person_identity_guard, apply_product_reference_guard, @@ -642,12 +644,14 @@ 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( + messages = submit_generated_person_reference( conversation=self.conversation, user=self.user, appearance_prompt="25 岁左右女性,利落短发,干练通勤风", ) + self.assertEqual(len(messages), 1) + message = messages[0] self.assertEqual(message.kind, CreationMessage.Kind.GENERATING) self.assertEqual(message.payload["kind"], "person_reference") self.assertEqual(enqueue.call_args.kwargs["mode"], "model") @@ -695,6 +699,40 @@ class PersonReferenceCompletionTests(CreationAgentBaseTests): self.assertIsNone(enqueue.call_args.kwargs.get("product_id")) self.assertFalse(enqueue.call_args.kwargs.get("reference_product")) + def test_multi_person_brief_enqueues_multiple_character_refs(self): + """多人物开场描述时,平台生成应一次提交多张定妆图。""" + from .creation import append_message + + append_message( + self.conversation, + role="user", + kind=CreationMessage.Kind.TEXT, + text="做一条两位空乘的剧情带货,母女互动,60秒", + ) + tasks = [ + AITask.objects.create( + team=self.team, + created_by=self.user, + task_type=AITask.Type.PERSON_IMAGE, + model_config=self.model, + idempotency_key=f"k-multi-person-{index}", + ) + for index in (1, 2) + ] + with patch("apps.ai.services.enqueue_standalone_images", side_effect=[[tasks[0]], [tasks[1]]]) as enqueue: + messages = submit_generated_person_reference( + conversation=self.conversation, + user=self.user, + appearance_prompt="角色1:25岁短发空乘;角色2:45岁长发妈妈", + ) + self.assertEqual(len(messages), 2) + self.assertEqual(enqueue.call_count, 2) + self.assertEqual(messages[0].payload.get("cast_total"), 2) + self.assertEqual(messages[1].payload.get("cast_index"), 2) + self.conversation.refresh_from_db() + self.assertEqual(self.conversation.memory.get("person_cast_total"), 2) + self.assertEqual(self.conversation.memory.get("person_cast_pending"), 2) + class SendEndpointTests(TestCase): def setUp(self): @@ -760,7 +798,7 @@ class SendEndpointTests(TestCase): payload={"kind": "person_reference"}, ) with patch("apps.ai.views.submit_generated_person_reference") as submit: - submit.return_value = generating + submit.return_value = [generating] response = self.client.post( f"/api/ai/creations/{self.conversation.id}/send/", {"kind": "text", "text": "短发,御姐"}, @@ -1789,6 +1827,83 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): self.assertEqual(cards[-1]["payload"].get("interaction"), "selling_point_gate") self.assertFalse(any(event.get("message", {}).get("kind") == "strategy" for event in events)) + def test_home_brief_selling_point_skips_gate(self): + """主页/聊天已写「核心卖点是…」时,不再弹卖点确认卡。""" + self._pin_person() + self._pin_product("补水喷雾") + fake = FakeProvider([ + _tool_chunks("write_strategy", {"target": "通勤党", "trust": "真实使用", + "belief": "补水保湿", "direction": "口播证明"}), + _text_chunks("策略已写好"), + ]) + with patch("apps.ai.creation_agent.build_provider", return_value=fake): + events = _events(stream_creation_agent( + conversation=self.conversation, + user=self.user, + text="核心卖点是补水保湿,便于携带,买三送一", + model_config=self.model, + )) + elicit_interactions = [ + event["message"]["payload"].get("interaction") + for event in events + if event.get("type") == "message" + and event["message"]["kind"] == "elicit" + ] + self.assertNotIn("selling_point_gate", elicit_interactions) + self.assertTrue(any(event.get("message", {}).get("kind") == "strategy" for event in events)) + self.conversation.refresh_from_db() + memory = self.conversation.memory or {} + self.assertTrue(memory.get("selling_point_ready")) + self.assertEqual(memory.get("selling_point"), "补水保湿,便于携带,买三送一") + + def test_product_library_selling_points_skip_gate(self): + """商品库已填卖点时,视为主页已提供,跳过卖点闸门。""" + from apps.products.models import ProductSellingPoint + + self._pin_person() + product = self._pin_product("便携补水仪") + ProductSellingPoint.objects.create(product=product, title="补水保湿", detail="补水保湿", sort_order=0) + ProductSellingPoint.objects.create(product=product, title="便于携带", detail="便于携带", sort_order=1) + fake = FakeProvider([ + _tool_chunks("write_strategy", {"target": "差旅党", "trust": "真实使用", + "belief": "补水保湿", "direction": "场景口播"}), + _text_chunks("策略已写好"), + ]) + with patch("apps.ai.creation_agent.build_provider", return_value=fake): + events = _events(stream_creation_agent( + conversation=self.conversation, + user=self.user, + text="做条视频", + model_config=self.model, + )) + elicit_interactions = [ + event["message"]["payload"].get("interaction") + for event in events + if event.get("type") == "message" + and event["message"]["kind"] == "elicit" + ] + self.assertNotIn("selling_point_gate", elicit_interactions) + self.conversation.refresh_from_db() + memory = self.conversation.memory or {} + self.assertTrue(memory.get("selling_point_ready")) + self.assertIn("补水保湿", memory.get("selling_point") or "") + + def test_extract_declared_selling_point_helpers(self): + self.assertEqual( + extract_declared_selling_point("核心卖点是补水保湿,便于携带,买三送一"), + "补水保湿,便于携带,买三送一", + ) + self.assertEqual( + extract_declared_selling_point("补水保湿,便于携带,买三送一"), + "补水保湿,便于携带,买三送一", + ) + self.assertEqual(extract_declared_selling_point("做条真实自然的口播视频"), "") + self.assertEqual( + extract_declared_selling_point("创作一条真实自然的达人口播种草视频,重点讲清使用场景和核心卖点。"), + "", + ) + self.assertEqual(extract_declared_selling_point("我想突出的是补水保湿,便于携带"), "补水保湿,便于携带") + def test_plan_emits_plan_and_step_confirm_only(self): fake = FakeProvider([ _tool_chunks("write_plan", self._plan_args()), diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 506cb8f..3df4d89 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -2289,9 +2289,10 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): design_from_image = any(kw in clean_ans for kw in ("按图片", "设计品牌", "不用再问")) if design_from_image: memory["product_info_resolved"] = True + memory["product_info_from_image"] = True continuation_instruction = ( "用户要求直接根据图片内容设计品牌与品名推进创作。" - "直接基于图片外观特征推进方案,不要重复追问品牌。" + "直接基于图片外观特征推进方案,不要重复追问品牌品名,也不要再弹出品牌确认卡。" ) elif is_incomplete_product_brand_answer(clean_ans): memory.pop("product_info_resolved", None) @@ -2374,25 +2375,35 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): appearance_prompt = f"{prev_prompt};按用户最新要求调整:{clean_text}" else: appearance_prompt = clean_text - # 卸掉上一张平台定妆锁定,避免新旧角色叠在 pinned_refs 里 - old_model_id = str(memory_now.get("person_model_id") or "").strip() - if old_model_id: + # 卸掉上一批平台定妆锁定,避免新旧角色叠在 pinned_refs 里 + old_model_ids = { + str(item).strip() + for item in (memory_now.get("person_cast_model_ids") or []) + if str(item).strip() + } + legacy_id = str(memory_now.get("person_model_id") or "").strip() + if legacy_id: + old_model_ids.add(legacy_id) + if old_model_ids: conversation.pinned_refs = [ ref for ref in (conversation.pinned_refs or []) if not ( isinstance(ref, dict) and ref.get("type") in {"model", "character"} - and str(ref.get("id") or "") == old_model_id + and str(ref.get("id") or "") in old_model_ids ) ] memory_now["person_confirm_pending"] = False memory_now.pop("person_source_ready", None) memory_now.pop("person_model_id", None) + memory_now.pop("person_cast_model_ids", None) + memory_now.pop("person_cast_pending", None) + memory_now.pop("person_cast_total", None) conversation.memory = memory_now conversation.save(update_fields=["memory", "pinned_refs", "updated_at"]) try: user_msg = append_message(conversation, role="user", text=clean_text) - generating = submit_generated_person_reference( + generating_messages = submit_generated_person_reference( conversation=conversation, user=request.user, appearance_prompt=appearance_prompt, @@ -2402,7 +2413,10 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): "agent_status": conversation.agent_status, "messages": [ CreationMessageSerializer(user_msg).data, - CreationMessageSerializer(generating).data, + *[ + CreationMessageSerializer(item).data + for item in generating_messages + ], ], }, status=202) except Exception: @@ -2648,7 +2662,7 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): if source == "platform_generate": person_prompt = str(answers.get("person_prompt") or "").strip()[:500] try: - generating = submit_generated_person_reference( + generating_messages = submit_generated_person_reference( conversation=conversation, user=request.user, appearance_prompt=person_prompt, @@ -2663,7 +2677,10 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): return JsonResponse({ "conversation_id": str(conversation.id), "agent_status": conversation.agent_status, - "messages": [CreationMessageSerializer(generating).data], + "messages": [ + CreationMessageSerializer(item).data + for item in generating_messages + ], }, status=202) # 上传/模特库的人物立即进入实体锁定,不等 Celery turn 开始才保存。 @@ -2711,9 +2728,10 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): }, status=200) if design_from_image: memory["product_info_resolved"] = True + memory["product_info_from_image"] = True continuation_instruction = ( "用户要求直接根据图片内容设计品牌与品名推进创作。" - "直接基于图片外观特征推进方案,不要重复追问品牌。" + "直接基于图片外观特征推进方案,不要重复追问品牌品名,也不要再弹出品牌确认卡。" ) else: memory["product_info_resolved"] = True diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 951b3ed..c991ccf 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -358,13 +358,23 @@ export function App() { }, [page, route.projectId, route.productId]); // 平台后台 gating(身份就绪后): - // - 平台超管且无团队:任何非 admin 路由都送进 /admin(否则普通外壳因 !team 永久卡 loading) + // - 平台超管打开站点根路径 / 登录页:直接进 /admin,不先落全能创作再点侧栏 + // - 无团队的超管:任何非 admin 路由都送进 /admin(否则普通外壳因 !team 永久卡 loading) // - 非超管访问 /admin/*:纠回工作台 + // 已有团队的超管从后台点「返回工作台」会落到 /omni-create,这里不拦。 + useLayoutEffect(() => { + if (booting || !user?.is_platform_admin || route.admin !== undefined) return; + const path = window.location.pathname.replace(/\/+$/, "") || "/"; + const defaultEntry = path === "/" || path === "/login" || path === "/dashboard"; + if (!team || defaultEntry) { + navigateAdmin("", { replace: true }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [booting, user, team, route.admin]); + useEffect(() => { if (booting || !user) return; - if (user.is_platform_admin && !team && route.admin === undefined) { - navigateAdmin("", { replace: true }); - } else if (!user.is_platform_admin && route.admin !== undefined) { + if (!user.is_platform_admin && route.admin !== undefined) { navigate("omniCreate", { replace: true }); } // navigate/navigateAdmin 为组件内函数,故意不入依赖避免每次渲染重跑 @@ -386,9 +396,12 @@ export function App() { useLayoutEffect(() => { if (booting || !user || page !== "dashboard") return; if (route.admin !== undefined) return; + const path = window.location.pathname.replace(/\/+$/, "") || "/"; + // 同一次渲染里超管入口已经改去 /admin,这里不能再改写成全能创作。 + if (user.is_platform_admin && (path === "/" || path === "/login" || path === "/dashboard" || !team)) return; navigate("omniCreate", { replace: true }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [booting, user, page, route.admin]); + }, [booting, user, team, page, route.admin]); // Load preferences + sessions when entering settings. // 进创作相关页时重拉模型目录,避免后台刚改能力/积分,前端还捧着首屏缓存 @@ -888,10 +901,14 @@ export function App() { setTeam(payload.team); setRole(payload.role || ""); setBooting(false); - // 平台超管且无团队:直落后台,不拉团队级数据(否则 products/projects 等接口因无团队报错) - if (payload.user.is_platform_admin && !payload.team) { + // 平台超管登录后直接进后台。有团队时后台补拉工作台数据,供「返回工作台」使用; + // 无团队则不拉团队级接口(products/projects 会因无团队报错)。 + if (payload.user.is_platform_admin) { setAuthed(true); navigateAdmin("", { replace: true }); + if (payload.team) { + loadDataWithRetry().catch((error) => console.error("[login] data hydrate failed:", error)); + } return; } navigate("omniCreate", { replace: true }); diff --git a/core/frontend/src/routes/omni-session.tsx b/core/frontend/src/routes/omni-session.tsx index 53407f7..58a3379 100644 --- a/core/frontend/src/routes/omni-session.tsx +++ b/core/frontend/src/routes/omni-session.tsx @@ -1044,7 +1044,7 @@ function PlanCard({ payload }: { payload: Record }) { const timeline = (payload.timeline as PlanTimelineItem[] | undefined) || []; const voice = coerceVoiceChars(payload.voice_chars); const format = (value: number) => (Number.isInteger(value) ? String(value) : value.toFixed(1)); - const pointLabels = ["核心支撑 P0", "视觉支撑 P0", "转化支撑 P0"]; + const pointLabels = ["核心支撑 P0", "视觉支撑 P1", "转化支撑 P2"]; return (
@@ -1417,7 +1417,7 @@ function ElicitCard({ placeholder={ isPet ? "例如:呆萌可爱的金毛幼犬,毛发蓬松干净,系着小红领巾,眼神灵动" - : "例如:25岁左右女性,短发,干练通勤风,表情自然" + : "单人例如:25岁女性,短发通勤风。多人物可写:角色1:年轻空乘短发;角色2:中年旅客长发" } onChange={(event) => setPersonPrompt(event.target.value)} /> @@ -1425,7 +1425,7 @@ function ElicitCard({ {isPet ? "不填写也可以,平台会结合当前商品和拟人主题自动设计萌宠角色。" - : "不填写也可以,平台会结合当前商品和创作主题自动设计角色。"} + : "不填写也可以。若脚本是多人物,平台会按人数一次生成多张定妆图,避免长视频前后形象漂移。"}