From 9f15b71f196a763256b1f6de7c67f9c4ab0f80f3 Mon Sep 17 00:00:00 2001 From: "Azmat@qq.com" Date: Mon, 21 Sep 2026 16:47:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=A8=E8=83=BD=E5=88=9B=E4=BD=9C=E4=B8=8E?= =?UTF-8?q?=E6=A8=A1=E7=89=B9=E5=BA=93=E6=94=B6=E5=8F=A3=EF=BC=9A=E5=93=81?= =?UTF-8?q?=E7=89=8C=E5=93=81=E5=90=8D=E8=BE=93=E5=85=A5=E3=80=81=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E8=A7=92=E8=89=B2=E5=BE=AA=E7=8E=AF=E3=80=81=E6=8D=A2?= =?UTF-8?q?=E6=AC=BE=E8=90=BD=E7=82=B9=E4=B8=8E=E9=B1=BC=E7=9C=BC=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充品牌品名改为预填输入框且空骨架不跳步;痛点确认角色后「继续完善方案」可继续推进;点击换款要求每次落点不同;鱼眼换装改用小云雀连环换装 prompt 模板;模特详情标签自动换行。 --- core/backend/apps/ai/creation_agent.py | 133 +++++++++++++++-- core/backend/apps/ai/creation_presets.py | 155 +++++++++++++++++--- core/backend/apps/ai/test_creation_agent.py | 31 +++- core/backend/apps/ai/views.py | 87 ++++++++--- core/frontend/src/models-page.css | 8 + core/frontend/src/routes/omni-session.tsx | 43 +++++- 6 files changed, 395 insertions(+), 62 deletions(-) diff --git a/core/backend/apps/ai/creation_agent.py b/core/backend/apps/ai/creation_agent.py index 7fe2752..4725acc 100644 --- a/core/backend/apps/ai/creation_agent.py +++ b/core/backend/apps/ai/creation_agent.py @@ -37,11 +37,14 @@ from .creation_presets import ( format_plot_twist_direction_contract, apply_video_preset_prompt, is_click_swap_preset, + is_fish_eye_outfit_preset, + fish_eye_outfit_prompt_template, plot_twist_story_depth, plot_twist_story_contract, preset_guidance, preset_workflow_guidance, video_preset_delivery_contract, + scrub_click_swap_same_position_wording, ) from .mentions import TYPE_LABELS, infer_field_types, resolve_refs, search_mentions from .models import CreationConversation, CreationMessage, ModelConfig @@ -769,12 +772,65 @@ def has_locked_product_reference(conversation: CreationConversation) -> bool: return len(locked_product_references(conversation)) > 0 + +PRODUCT_BRAND_EMPTY_TEMPLATE = "品牌是:,商品名是:" + + +def is_incomplete_product_brand_answer(text: str) -> bool: + """空模板或只有「品牌是:/商品名是:」骨架、没有真实内容时视为未填完。""" + raw = (text or "").strip() + if not raw: + return True + skeletons = { + PRODUCT_BRAND_EMPTY_TEMPLATE, + "品牌是:,商品名是:", + "品牌是:,品名是:", + "品牌是:,品名是:", + } + if raw in skeletons: + return True + cleaned = raw + for sk in skeletons: + cleaned = cleaned.replace(sk, "") + cleaned = ( + cleaned.replace("品牌是:", "") + .replace("品牌是:", "") + .replace("商品名是:", "") + .replace("商品名是:", "") + .replace("品名是:", "") + .replace("品名是:", "") + .replace(",", "") + .replace(",", "") + .strip() + ) + if not cleaned: + return True + bm = re.search(r"品牌[::是为]\s*([^\n,,。!!]+)", raw) + nm = re.search(r"(?:品名|商品名|产品名)[::是为]\s*([^\n,,。!!]+)", raw) + if bm or nm: + brand = (bm.group(1).strip() if bm else "") + name = (nm.group(1).strip() if nm else "") + token = re.compile(r"[\u4e00-\u9fffA-Za-z0-9]{2,}") + if not token.search(brand) and not token.search(name) and not token.search(cleaned): + return True + return False + + 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"): + 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() + token = re.compile(r"[\u4e00-\u9fffA-Za-z0-9]{2,}") + 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: return False # 如果已锁定的首个商品是正式商品库商品(已自带品牌和品名),无需重复询问 first_prod = next( @@ -827,7 +883,11 @@ def append_product_info_gate(conversation: CreationConversation) -> CreationMess "reply_hint": "输入品牌和品名,例如「品牌XX,品名YY」…", "reply_options": [ {"label": "按图片内容创作", "text": "直接根据图片内容设计品牌与品名,不用再问"}, - {"label": "我来补充品牌品名", "text": "品牌是:,商品名是:"}, + { + "label": "我来补充品牌品名", + "text": PRODUCT_BRAND_EMPTY_TEMPLATE, + "action": "compose", + }, ], }, ) @@ -923,7 +983,7 @@ def append_click_swap_sequence_gate(conversation: CreationConversation) -> Creat hint = "先确认要切换的款式和展示顺序。角色会在日常场景中按这个顺序变色换款。" placeholder = "例如:曜石黑 → 象牙白 → 樱花粉" else: - hint = "先确认要切换的款式和展示顺序。后续按这个顺序用手指点击触发换款即可,不必强调每次点同一位置。" + hint = "先确认要切换的款式和展示顺序。后续按这个顺序用手指点击触发换款;每一次点击落点都要与上一次不同。" placeholder = "例如:黑色 → 白色 → 樱花粉" return append_message( conversation, @@ -1004,7 +1064,7 @@ def apply_click_swap_mode(conversation: CreationConversation, mode: str) -> str: if value == "finger": return ( "商家已选择【只出手指出镜】。不要生成角色定妆图,不要追问出镜人物;" - "后续只围绕固定机位 + 手指点击触发换款推进;分镜不要反复写「每次点击同一位置」。" + "后续只围绕固定机位 + 手指点击触发换款推进;每一次换款点击落点必须与上一次不同,禁止写「每次点击同一位置」。" ) return ( "商家已选择【角色在日常场景中换款】。下一步先锁定出镜角色来源;" @@ -1927,8 +1987,11 @@ def default_reply_options( {"label": "从素材里判断", "text": "先根据我上传的素材判断可表达的卖点"}, {"label": "先只突出一个点", "text": "先围绕一个最核心的卖点创作"}, ] - # 没有足够语境时只留自由输入,不伪造“继续/改卖点”这种人机按钮。 - return [] + # 已有创作上下文但未命中更具体的追问时,给一个可识别的「继续完善方案」按钮, + # 避免只剩文案引导、用户手输后又不算创作意图而卡住。 + return [ + {"label": "继续完善方案", "text": "继续完善方案"}, + ] kind = "短视频" if is_video else "商品图" return [ {"label": f"帮我做一条{kind}" if is_video else "帮我做一张商品图", "text": f"帮我做一条{kind}" if is_video else "帮我做一张商品图"}, @@ -2010,7 +2073,7 @@ _CREATIVE_INTENT_RE = re.compile( r"帮我做|帮我拍|帮我出|帮我写|帮我改|帮我生成|" r"创作[一两]?[条个张]?|创作(?:一条|个|短)?|" r"做[一两]?[条个张](?:视频|片|图|广告)?|拍[一两]?[条个张](?:视频|片|图|广告)?|" - r"出[一两]?[条个张](?:视频|片|图|广告)?|出片|出图|出方案|写方案|改方案|重写方案|重新写|" + r"出[一两]?[条个张](?:视频|片|图|广告)?|出片|出图|出方案|写方案|改方案|完善方案|继续完善|继续创作|重写方案|重新写|" r"生成(?:一下|一张|几张|一条)?(?:视频|图|片|广告|脚本)?|做条|做个片|短视频|带货视频|短广告|广告片|带货片|" r"换卖点|改卖点|换剧情|改剧情|重做|重新出|重新来|再来一次|从头开始|重新做|按这个出|确认出片|" r"分镜|脚本|口播稿|storyboard|拟人|" @@ -2047,12 +2110,20 @@ def has_creative_intent(user_text: str, refs: list | None = None) -> bool: _CONTINUE_INTENT_RE = re.compile( - r"^\s*(继续|继续做|接着|接着做|往下做|开始吧|开做吧|就这样|就按这个|按这个来|照这个做|直接做|直接来)[吧啊呀呢。.!!]*\s*$" + r"^\s*(" + r"继续|继续做|接着|接着做|往下做|开始吧|开做吧|就这样|就按这个|按这个来|照这个做|直接做|直接来|" + r"继续完善方案|完善方案|继续创作|继续推进|往下推进|" + r"按当前描述继续|按这个继续|没有其他调整[,,]?按当前描述继续|" + r"使用这[个位只]角色继续创作|使用这[个位只]宠物角色继续创作" + r")[吧啊呀呢。.!!]*\s*$" ) def is_continue_intent(user_text: str) -> bool: - """已有创作上下文时,这些短句是在授权继续,不是闲聊。""" + """已有创作上下文时,这些短句是在授权继续,不是闲聊。 + + 含系统引导里的「继续完善方案」——若不识别,用户一点就又回到同一句引导,死循环。 + """ return bool(_CONTINUE_INTENT_RE.match((user_text or "").strip())) @@ -3669,10 +3740,19 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c ) if context.is_video and is_click_swap_preset(conversation.preset) and click_swap_mode(conversation) != "character": lines.append( - "【点击换款分镜硬性要求】换款由手指点击触发即可;撰写 video_prompt / 方案分镜时," - "禁止反复写「每次点击同一位置 / 点同一点 / 手指点在完全相同的位置」;" - "各镜重点写款式变化与节奏,点击动作点到为止。" + "【点击换款分镜硬性要求】换款由手指点击触发;撰写 video_prompt / 方案分镜时," + "每一次换款的点击落点必须与上一次不同(点商品的不同区域/角点);" + "禁止写或暗示「每次点击同一位置 / 点同一点 / 手指始终点同一处」;" + "各镜要写明本次点击位置不同于上次,并重点写款式变化与节奏。" ) + if context.is_video and is_fish_eye_outfit_preset(conversation.preset): + lines.append( + "【鱼眼换装 Prompt 硬性要求】write_plan / write_prompt 的 video_prompt 必须按小云雀「鱼眼连环换装」模板撰写:" + "时长按套数估算、9:16、超广角鱼眼风格、全片单一机位节拍换装、转场瞬间切装、主体只取五官发型妆容姿态、" + "服装状态机 S0→SN、无对白、转场配快门/whoosh;禁止穿衣过程与口播。" + "不要套用通用「制作级分镜四行+人声」长文模板。" + ) + lines.append(fish_eye_outfit_prompt_template()) if context.is_video and is_pet_preset(conversation.preset): lines.append( "【宠物拟人台词硬性要求】策略、方案和 video_prompt 里的人声必须是宠物第一人称开口," @@ -4125,7 +4205,7 @@ def apply_click_swap_plan_card( "usp": f"手指点击触发,商品按「{sequence}」连续换款", "points": [ "固定机位、背景与光线,商品构图稳定", - "换款由手指点击触发,分镜不反复写同一点击位置", + "换款由手指点击触发,每一次点击落点必须与上一次不同", f"严格按「{sequence}」逐款展示,结尾给出全款式总览", ], "timeline": [ @@ -4139,13 +4219,13 @@ def apply_click_swap_plan_card( "start": first_end, "end": second_end, "stage": "首次点击换款", - "desc": "手指点击商品触发换款,接触后以干净 match cut 切到下一款;不写点击同一位置。", + "desc": "手指点击商品某一区域触发换款,接触后以干净 match cut 切到下一款;本次落点作为基准,后续不得复用同一落点。", }, { "start": second_end, "end": third_end, "stage": "按序连续换款", - "desc": f"按「{sequence}」继续点击触发换款;机位、构图与光线保持稳定,不反复描写点击位置。", + "desc": f"按「{sequence}」继续点击触发换款;每一次点击都换到商品的不同区域/角点,机位、构图与光线保持稳定。", }, { "start": third_end, @@ -4676,6 +4756,27 @@ def iter_creation_agent_events( if stop: break + # 强制创作回合若模型既没落闸门也没写正文,补一条可点的「继续完善方案」引导, + # 且该文案已被 is_continue_intent 识别,避免用户再点又回到空引导死循环。 + if force_creative_turn and not turn_has_gate and last_text_bubble is None: + nudge = append_message( + conversation, + role="assistant", + text=( + "角色已锁定。接下来直接推进创作方案;" + "也可以回复「继续完善方案」让我继续写策略。" + ), + payload={ + "reply_hint": "可以回复「继续完善方案」,也可以直接说卖点或痛点方向。", + "reply_options": [ + {"label": "继续完善方案", "text": "继续完善方案"}, + {"label": "先定痛点方向", "text": "先帮我定痛点方向"}, + ], + }, + ) + last_text_bubble = nudge + yield {"type": "message", "message": _message_payload(nudge)} + # 收束校验:本轮若只剩散文、没有追问/确认卡,补 reply_hint 或短引导。 for event in ensure_turn_guides( conversation, @@ -5169,6 +5270,8 @@ def _dispatch_tool( f"{video_prompt}\n\n【商家确认的换款顺序】{click_swap_sequence(context.conversation)}\n" "只允许按这个顺序逐款切换;不得跳序、漏款或自行增加颜色和款式。" ) + if click_swap_mode(context.conversation) != "character": + video_prompt = scrub_click_swap_same_position_wording(video_prompt) video_prompt = apply_product_voice_visual_guard(context.conversation, video_prompt) video_prompt = apply_product_reality_guard(video_prompt) video_prompt = apply_clothing_video_guard(context.conversation, video_prompt) diff --git a/core/backend/apps/ai/creation_presets.py b/core/backend/apps/ai/creation_presets.py index 89d1c01..ca9fe33 100644 --- a/core/backend/apps/ai/creation_presets.py +++ b/core/backend/apps/ai/creation_presets.py @@ -8,12 +8,14 @@ key 必须是同一个中文名(会话建的时候原样存进 CreationConversat """ from __future__ import annotations +import re + CLICK_SWAP_PRESETS = frozenset({"点击换款", "点触换款", "多色商品换款"}) _CLICK_SWAP_FINGER_GUIDANCE = ( "形态:只出手指出镜。固定机位的商品点击换款短片。必须先确认要展示的颜色/款式/SKU 及顺序," "画面只围绕同一件商品的逐款切换:机位、背景与灯光稳定,商品构图稳定。" "用手指点击/轻触触发换款,接触后以干净 match cut 切到下一款。" - "撰写分镜时不要反复写「每次点击同一位置 / 点同一点」;点击动作点到为止,重点写款式变化与节奏。" + "每一次换款的手指点击落点必须与上一次不同(点商品的不同区域/角点),禁止写或暗示「每次点击同一位置 / 点同一点 / 手指始终点同一处」;分镜与 video_prompt 要体现「本次点击位置不同于上次」,不要复读同一落点。" "禁止完整人物出镜、禁止生成角色定妆图、禁止改成口播/剧情/使用教程;最后才用同一构图做全款式收束。" ) _CLICK_SWAP_CHARACTER_GUIDANCE = ( @@ -61,10 +63,12 @@ VIDEO_PRESETS: dict[str, str] = { "节奏清晰,转场干净。商品外观严格以参考图为准。" ), "鱼眼换装": ( - "鱼眼/广角近距离透视,连续换装节奏。**人物面部和身形必须全程一致**," - "只有服装在变。人物从第一镜起就已经穿着当前套装;换装用甩头、抬手、转身等触发瞬间切换到已穿好的下一套," - "禁止拍穿衣服、套袖、拉拉链等穿戴过程。" - ), + "超广角鱼眼/近距离透视的连环换装短片。" + "全片单一机位、同一构图与背景锁定;画面中心人物清晰、四边缘畸变拉伸,高饱和时尚质感,逐拍卡点剪辑。" + "人物从第一镜起就已经穿着当前套装;换装发生在遮挡/甩头/甩镜等动态瞬间,瞬间切到已穿好的下一套。" + "禁止拍穿衣服、套袖、拉拉链等穿戴过程。video_prompt 必须按「鱼眼连环换装」制作模板撰写" + "(时长比例、标题、风格、单机位节拍、转场规则、主体、服装状态机、无对白卡点音效)。" +), "点击换款": _CLICK_SWAP_GUIDANCE, "多色商品换款": _CLICK_SWAP_GUIDANCE, # 旧会话仍会保存「点触换款」,保留同一拍法约束以支持历史继续创作。 @@ -270,10 +274,10 @@ VIDEO_PRESET_WORKFLOWS: dict[str, str] = { "商品拟人广告": "先确认商品外观和性格表达方式;默认无脸拟人,商品保持真实完整,台词用画外声。", "达人口播种草": "优先确认人物、多人出镜关系、真实体验和主卖点;生成前核对口播字数能在时长内说完,每个卖点都有画面证明。", "商品图一键成片": "优先从商品参考图锁定外观;自动补场景和动作,但不替换或改变用户商品图里的结构、颜色和包装。", - "鱼眼换装": "优先确认人物参考、服装套数和展示顺序;生成前核对脸、身形、场景稳定,只有服装瞬间切换;人物始终已穿好当前套,不写穿衣过程。", - "点击换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位 + 手指点击触发换款;分镜不要反复写「每次点击同一位置」,禁止转成剧情或口播。", - "多色商品换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位 + 手指点击触发换款;分镜不要反复写「每次点击同一位置」,禁止转成剧情或口播。", - "点触换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位 + 手指点击触发换款;分镜不要反复写「每次点击同一位置」,禁止转成剧情或口播。", + "鱼眼换装": "优先确认人物参考、服装套数N与展示顺序、本条机位(低机位仰拍/高机位俯拍/荷兰角/贴地滑行/手持环绕等择一全片不变)、场景与转场方式;时长按 N×1.5–2.5 秒估算(约 4–15 秒);write_prompt 严格用「鱼眼连环换装」模板:节拍换装 + 服装状态机,无对白,禁止穿衣过程。", + "点击换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位 + 手指点击触发换款;每次换款点击位置必须不同(换商品不同区域),禁止「每次点击同一位置 / 点同一点」。禁止转成剧情或口播。", + "多色商品换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位 + 手指点击触发换款;每次换款点击位置必须不同(换商品不同区域),禁止「每次点击同一位置 / 点同一点」。禁止转成剧情或口播。", + "点触换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位 + 手指点击触发换款;每次换款点击位置必须不同(换商品不同区域),禁止「每次点击同一位置 / 点同一点」。禁止转成剧情或口播。", "探店漫游": "优先确认门店动线与主推项目;用连续移动串联入口、环境、细节和服务,不做碎片化硬切。", "品牌质感大片": "优先确认品牌气质、材质和商品主卖点;由 Agent 决定光线和镜头,不把专业选择反复抛给用户。", "前后对比实测": "优先确认同一对象的前、中、后素材或可比较条件;生成前核对对比不夸大且没有缺失关键阶段。", @@ -313,15 +317,23 @@ VIDEO_PRESET_DELIVERY_CONTRACTS: dict[str, str] = { "最后以干净的产品收束。包装文字、颜色、比例和结构全程稳定,不能用 AI 生成的相似商品替代参考图。" ), "鱼眼换装": ( - "【预设执行层·鱼眼换装】使用近距离鱼眼/广角透视和稳定的同一机位;人物脸、身形、发型、场景、光线连续一致。" - "人物始终已穿着当前套装;每一套由甩头、抬手、转身等动作触发瞬间切换到已穿好的下一套,按用户提供顺序完整展示。" - "禁止穿衣、套袖、扣扣、拉拉链等穿戴过程镜头;镜头变化来自触发动作与节奏,不额外编复杂营销剧情。" - ), + "【预设执行层·鱼眼换装·小云雀模板】生成时尚换装短视频。" + "比例默认 9:16;时长按服装套数 N 估算(每套约 1.5–2.5 秒,通常 4–15 秒)。" + "风格:超广角鱼眼,中心清晰、四边缘明显畸变拉伸,高饱和,时尚质感,逐拍卡点剪辑。" + "镜头:全片单一机位(本条自选一种并整条不变:低机位仰拍 / 高机位俯拍 / 荷兰角倾斜 / 贴地滑行 / 手持环绕);" + "0–1 秒该机位对准主体、第 1 套造型定格 pose;之后逐拍换装,机位始终不变,仅服装与动作逐拍更新;最后一拍同机位定格收尾。" + "转场:每次换装发生在遮挡/甩镜动态瞬间(抬手遮挡镜头 / 甩头转身划过画面 / 镜头快速甩动出虚影);" + "转场瞬间服装切换,机位保持原位,下一拍回到同一构图。" + "主体:只采用参考图五官、发型、妆容、姿态,不采用参考图服装与背景。" + "服装状态机:S0 造型1 → 转场动作 → S1 造型2 → … → SN 定格收尾;" + "全过程人物身份/体型/发型一致,机位/构图/背景锁定,仅服装逐拍变化;人物始终已穿好当前套,禁止穿戴过程镜头。" + "声音:无对白,预留卡点 BGM,每次转场配快门/whoosh 音效。" +), "点击换款": ( "【预设执行层·点击换款·强制】这不是口播片、剧情片或普通商品展示片。" "使用单一固定机位和同一背景,商品构图、尺寸与角度保持稳定。" "换款由手指轻触/点击触发,接触后以干净 match cut 切到下一款;" - "分镜不要反复写「每次点击同一位置 / 点同一点」,点击动作点到为止,重点写款式变化。" + "每一次换款的手指点击落点必须与上一次不同(点商品的不同区域/角点),禁止写或暗示「每次点击同一位置 / 点同一点 / 手指始终点同一处」;分镜与 video_prompt 要体现「本次点击位置不同于上次」,不要复读同一落点。" "不改场景、不改机位、不运镜、不说话、不出现完整人物、不演剧情、不演示使用步骤。" "各款必须按用户确认的顺序逐一出现,不自行发明颜色或款式;结尾停在干净的全款式总览。" ), @@ -329,7 +341,7 @@ VIDEO_PRESET_DELIVERY_CONTRACTS: dict[str, str] = { "【预设执行层·点击换款·强制】这不是口播片、剧情片或普通商品展示片。" "使用单一固定机位和同一背景,商品构图、尺寸与角度保持稳定。" "换款由手指轻触/点击触发,接触后以干净 match cut 切到下一款;" - "分镜不要反复写「每次点击同一位置 / 点同一点」,点击动作点到为止,重点写款式变化。" + "每一次换款的手指点击落点必须与上一次不同(点商品的不同区域/角点),禁止写或暗示「每次点击同一位置 / 点同一点 / 手指始终点同一处」;分镜与 video_prompt 要体现「本次点击位置不同于上次」,不要复读同一落点。" "不改场景、不改机位、不运镜、不说话、不出现完整人物、不演剧情、不演示使用步骤。" "各款必须按用户确认的顺序逐一出现,不自行发明颜色或款式;结尾停在干净的全款式总览。" ), @@ -337,7 +349,7 @@ VIDEO_PRESET_DELIVERY_CONTRACTS: dict[str, str] = { "【预设执行层·点击换款·强制】这不是口播片、剧情片或普通商品展示片。" "使用单一固定机位和同一背景,商品构图、尺寸与角度保持稳定。" "换款由手指轻触/点击触发,接触后以干净 match cut 切到下一款;" - "分镜不要反复写「每次点击同一位置 / 点同一点」,点击动作点到为止,重点写款式变化。" + "每一次换款的手指点击落点必须与上一次不同(点商品的不同区域/角点),禁止写或暗示「每次点击同一位置 / 点同一点 / 手指始终点同一处」;分镜与 video_prompt 要体现「本次点击位置不同于上次」,不要复读同一落点。" "不改场景、不改机位、不运镜、不说话、不出现完整人物、不演剧情、不演示使用步骤。" "各款必须按用户确认的顺序逐一出现,不自行发明颜色或款式;结尾停在干净的全款式总览。" ), @@ -403,7 +415,9 @@ _CLICK_SWAP_FINGER_CONTRACT = ( "【预设执行层·点击换款·只出手】这不是口播片、剧情片或普通商品展示片。" "使用单一固定机位和同一背景,商品构图、尺寸与角度保持稳定。" "换款由手指轻触/点击触发,接触后以干净 match cut 切到下一款;" - "分镜不要反复写「每次点击同一位置 / 点同一点」,点击动作点到为止,重点写款式变化。" + "每一次换款的手指点击落点必须与上一次不同(点商品的不同区域/角点)," + "禁止写或暗示「每次点击同一位置 / 点同一点 / 手指始终点同一处」;" + "分镜与 video_prompt 要体现「本次点击位置不同于上次」。" "不改场景、不改机位、不运镜、不说话、不出现完整人物、不演剧情、不演示使用步骤、不生成角色定妆图。" "各款必须按用户确认的顺序逐一出现,不自行发明颜色或款式;结尾停在干净的全款式总览。" ) @@ -425,10 +439,102 @@ def video_preset_delivery_contract(name: str, *, click_swap_mode: str = "") -> s return VIDEO_PRESET_DELIVERY_CONTRACTS.get(preset, "") + +FISH_EYE_OUTFIT_PRESET = "鱼眼换装" + +_FISH_EYE_OUTFIT_PROMPT_TEMPLATE = """ +【鱼眼连环换装 · video_prompt 强制模板(来自小云雀写法)】 +当预设为「鱼眼换装」时,write_plan / write_prompt 的 video_prompt 必须按下面骨架填写, +用会话里真实的人物、服装套数、机位、场景与顺序替换【】占位;不要改回通用「制作级分镜四行」长文,也不要编口播。 + +时长:【4-15秒,按N推算,每套服装约1.5-2.5秒】秒 +比例:9:16 +任务:生成时尚换装短视频。 + +标题:《鱼眼连环换装·【机位名】版》 + +风格与视觉参考: +超广角鱼眼镜头风格,画面中心人物清晰、四边缘明显畸变拉伸, +高饱和色彩,时尚质感,逐拍卡点剪辑。 + +镜头语言: +全片单一机位:【本条视频机位描述,如:低机位仰拍 / 高机位俯拍 / +荷兰角倾斜 / 贴地滑行 / 手持环绕——每条视频自选一种,整条不变】 +0-1秒:该机位对准【主体】,第1套造型定格pose +之后逐拍换装,机位始终不变,仅服装与动作逐拍更新: +节拍1:造型1,随节拍做动作 +节拍2:造型2,随节拍做动作 +节拍3:造型3,随节拍做动作 +……按N套延伸 +最后一拍:同机位稳定构图,定格收尾pose + +转场规则:每次换装发生在遮挡/甩镜的动态瞬间—— +【转场方式:抬手遮挡镜头 / 甩头转身划过画面 / 镜头快速甩动出虚影, +转场瞬间服装切换,机位保持原位,下一拍回到同一构图】 + +主体: +主体1:【人物描述】,对应@图片1 +(只采用五官、发型、妆容、姿态,不采用服装与背景) + +服装状态机: +S0 造型1:【服装1描述】 + → 动作:【转场方式】 + → S1 造型2:【服装2描述】 + → 动作:【转场方式】 + → ……按N套延伸 + → SN 造型N:【服装N描述】定格收尾 +全过程不变量:人物身份、体型、发型全程一致; +机位、构图、背景全程锁定不变,仅服装逐拍变化; +人物始终已穿好当前套,禁止穿衣/套袖/拉拉链等穿戴过程镜头。 + +声音:无对白,预留卡点BGM位,每次转场配快门/whoosh音效 + +全片保持主体1身份一致、鱼眼畸变风格统一、 +机位与构图全片同一,背景【场景描述】不变。 +""" + + +def is_fish_eye_outfit_preset(name: str) -> bool: + return (name or "").strip() == FISH_EYE_OUTFIT_PRESET + + +def fish_eye_outfit_prompt_template() -> str: + return _FISH_EYE_OUTFIT_PROMPT_TEMPLATE.strip() + def is_click_swap_preset(name: str) -> bool: return (name or "").strip() in CLICK_SWAP_PRESETS + +_SAME_CLICK_POS_RE = re.compile( + r"(?:每次(?:都)?)?(?:点击|点触|轻触|手指(?:点击|点)?)(?:在|到)?(?:完全)?(?:相?同的?)?(?:一个位置|一(?:个)?位置|一点|同一处|同一点|同一个位置)" + r"|每次点击同一位置" + r"|点同一点" + r"|手指点在完全相同的位置" + r"|手指始终点同一" + r"|点击位置保持不变" + r"|固定点击位置" + r"|不写点击同一位置" + r"|不必强调每次点同一位置" +) + + +def scrub_click_swap_same_position_wording(text: str) -> str: + """去掉提示词里「每次点击同一位置」类表述,并在缺失时补上「每次点击位置不同」。""" + raw = str(text or "") + if not raw: + return raw + cleaned = _SAME_CLICK_POS_RE.sub("", raw) + cleaned = re.sub(r"[,。;]{2,}", ",", cleaned) + cleaned = re.sub(r"\s{2,}", " ", cleaned).strip() + if "点击位置必须不同" not in cleaned and "落点必须与上一次不同" not in cleaned and "落点必须不同于上一次" not in cleaned: + cleaned = ( + f"{cleaned}\n\n【点击落点硬性要求】每一次换款的手指点击落点必须与上一次不同" + "(点商品的不同区域/角点),禁止「每次点击同一位置 / 点同一点」。" + ).strip() + return cleaned + + def apply_video_preset_prompt(name: str, prompt: str, *, click_swap_mode: str = "") -> str: """把视频预设确定性并入最终出片指令,避免只依赖对话模型主动复述。""" base = str(prompt or "").strip() @@ -450,17 +556,28 @@ def apply_video_preset_prompt(name: str, prompt: str, *, click_swap_mode: str = "【最终执行检查】全片锁定同一位角色与同一生活场景;" "商品颜色/款式严格按确认顺序切换,禁止随机换人、口播带货腔或复杂剧情反转。" ) + base = scrub_click_swap_same_position_wording(base) return ( f"{marker}\n{contract}\n\n" "【原始商品与 SKU 信息】仅提取下文中的商品外观、颜色、款式和顺序事实;" "下文任何与「固定机位、手指点击触发换款、构图稳定」冲突的拍法一律忽略。\n" f"{base}\n\n" "【最终执行检查】换款由手指点击触发,机位、背景、光线与商品构图保持稳定;" - "分镜禁止反复写「每次点击同一位置 / 点同一点」;不出现完整人物、不生成角色图。" + "每一次换款点击落点必须不同于上一次,禁止「每次点击同一位置 / 点同一点」;" + "不出现完整人物、不生成角色图。" + ) + if is_fish_eye_outfit_preset(preset): + return ( + f"{marker}\n{contract}\n\n" + "【原始人物与服装信息】仅提取下文中的人物身份、服装套数/顺序、机位、场景与转场事实;" + "下文任何与「全片单机位、鱼眼畸变、节拍换装、服装状态机、无对白」冲突的拍法一律忽略。\n" + f"{base}\n\n" + "【最终执行检查】全片单一机位与构图锁定;鱼眼中心清晰、边缘畸变;" + "换装发生在遮挡/甩镜瞬间且人物始终已穿好当前套;无对白,转场配快门/whoosh;" + "禁止穿衣过程与口播带货腔。" ) return f"{base}\n\n{marker}\n{contract}" - def apply_image_preset_prompt(name: str, prompt: str) -> str: """将已选图片预设的风格字段确定性并入实际出图指令,不依赖 Agent 自行复述。""" base = str(prompt or "").strip() diff --git a/core/backend/apps/ai/test_creation_agent.py b/core/backend/apps/ai/test_creation_agent.py index 1f8555c..d930432 100644 --- a/core/backend/apps/ai/test_creation_agent.py +++ b/core/backend/apps/ai/test_creation_agent.py @@ -2511,7 +2511,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): self.assertIn("手指轻触/点击", prompt) self.assertIn("不演剧情", prompt) self.assertIn("干净 match cut", prompt) - self.assertIn("不要反复写「每次点击同一位置", prompt) + self.assertIn("每一次换款的手指点击落点必须与上一次不同", prompt) def test_personified_product_uses_offscreen_voice_without_changing_packaging(self): self.conversation.preset = "商品拟人广告" @@ -2891,8 +2891,10 @@ class PresetGuidanceTests(CreationAgentBaseTests): text="开始", model_config=self.model)) system = fake.calls[0]["messages"][0]["content"] self.assertIn("鱼眼换装", system) - # 光有名字模型只能靠猜,拍法约束必须一起给 - self.assertIn("人物面部和身形必须全程一致", system) + # 光有名字模型只能靠猜,拍法约束必须一起给(小云雀连环换装模板) + self.assertIn("小云雀", system) + self.assertIn("服装状态机", system) + self.assertIn("超广角鱼眼", system) def test_product_personification_defaults_to_faceless_expression(self): product = Product.objects.create(team=self.team, created_by=self.user, title="拟人测试商品") @@ -2943,7 +2945,7 @@ class PresetGuidanceTests(CreationAgentBaseTests): self.assertIn("当前预设的工作重点", system) self.assertIn("确认颜色/款式/SKU 和切换顺序", system) self.assertIn("固定机位 + 手指点击触发换款", system) - self.assertIn("不要反复写「每次点击同一位置」", system) + self.assertIn("每一次换款的点击落点必须与上一次不同", system) self.assertIn("禁止转成剧情或口播", system) def test_video_preset_is_injected_into_the_actual_generation_prompt(self): @@ -2951,8 +2953,10 @@ class PresetGuidanceTests(CreationAgentBaseTests): prompt = apply_video_preset_prompt("鱼眼换装", "人物抬手完成连续换装") self.assertIn("【视频预设】鱼眼换装", prompt) - self.assertIn("近距离鱼眼/广角透视", prompt) - self.assertIn("人物脸、身形、发型、场景、光线连续一致", prompt) + self.assertIn("小云雀模板", prompt) + self.assertIn("服装状态机", prompt) + self.assertIn("超广角鱼眼", prompt) + self.assertIn("无对白", prompt) # 出片确认、重试等多次经过此处,规则只写一次。 self.assertEqual(apply_video_preset_prompt("鱼眼换装", prompt), prompt) @@ -2970,7 +2974,7 @@ class PresetGuidanceTests(CreationAgentBaseTests): self.assertIn("原始商品与 SKU 信息", prompt) self.assertIn("冲突的拍法一律忽略", prompt) self.assertIn("换款由手指点击触发", prompt) - self.assertIn("分镜禁止反复写「每次点击同一位置", prompt) + self.assertIn("每一次换款点击落点必须不同于上一次", prompt) self.assertEqual(apply_video_preset_prompt("点击换款", prompt), prompt) def test_every_video_preset_has_a_delivery_contract(self): @@ -3267,6 +3271,7 @@ class CreativeIntentTests(SimpleTestCase): for text in ( "做条带货视频", "帮我出个方案", + "继续完善方案", "重新写方案", "出一张主图", "拍一张自然、有温度的生活方式照片", @@ -3276,7 +3281,17 @@ class CreativeIntentTests(SimpleTestCase): self.assertTrue(has_creative_intent(text), text) def test_short_continue_phrases_are_explicit_continuations(self): - for text in ("继续", "接着做", "往下做吧", "就按这个", "直接来"): + for text in ( + "继续", + "接着做", + "往下做吧", + "就按这个", + "直接来", + "继续完善方案", + "完善方案", + "继续创作", + "使用这个角色继续创作", + ): self.assertTrue(is_continue_intent(text), text) for text in ("好的", "嗯", "先不用", "今天怎么样"): diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index a98e229..506cb8f 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -58,6 +58,8 @@ from .creation_agent import ( submit_confirmed_image, submit_confirmed_video, submit_generated_person_reference, + is_incomplete_product_brand_answer, + PRODUCT_BRAND_EMPTY_TEMPLATE, ) from .tasks import run_creation_agent_turn_task from .mentions import TYPE_LABELS, VALID_TYPES, refs_from_elicit_answers, search_mentions @@ -2237,6 +2239,20 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): fields = [item for item in (payload.get("fields") or []) if isinstance(item, dict)] if fields: field = fields[0] + if payload.get("topic") == "product_info": + clean_probe = text.strip() + design_from_image = any( + kw in clean_probe for kw in ("按图片", "设计品牌", "不用再问") + ) + if (not design_from_image) and is_incomplete_product_brand_answer(clean_probe): + 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, + "detail": "请填写品牌和品名后再发送,例如「品牌浅蓝小熊,品名婴儿柔湿巾」。", + "messages": [CreationMessageSerializer(pending).data], + }, status=200) answers = { str(field.get("key") or "answer"): _chat_answer_for_field(field, text, conversation.team) @@ -2270,8 +2286,20 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): 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 ("按图片", "设计品牌", "不用再问")): + design_from_image = any(kw in clean_ans for kw in ("按图片", "设计品牌", "不用再问")) + if design_from_image: + memory["product_info_resolved"] = True + continuation_instruction = ( + "用户要求直接根据图片内容设计品牌与品名推进创作。" + "直接基于图片外观特征推进方案,不要重复追问品牌。" + ) + elif is_incomplete_product_brand_answer(clean_ans): + memory.pop("product_info_resolved", None) + continuation_instruction = ( + "用户尚未填写完整品牌与品名。请继续请他补充,不要进入下一步。" + ) + else: + memory["product_info_resolved"] = True memory["product_brand_and_name"] = clean_ans bm = re.search(r"品牌[::是为]?\s*([^\n,,。!!]+)", clean_ans) nm = re.search(r"(?:品名|商品名|产品名)[::是为]?\s*([^\n,,。!!]+)", clean_ans) @@ -2284,9 +2312,10 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): r["name"] = clean_ans break conversation.pinned_refs = conversation.pinned_refs - continuation_instruction = f"用户已提供商品品牌与品名:【{clean_ans}】。直接围绕该商品推进方案,不要重复追问。" - else: - continuation_instruction = "用户要求直接根据图片内容设计品牌与品名推进创作。直接基于图片外观特征推进方案,不要重复追问品牌。" + continuation_instruction = ( + f"用户已提供商品品牌与品名:【{clean_ans}】。" + "直接围绕该商品推进方案,不要重复追问。" + ) conversation.memory = memory conversation.save(update_fields=["memory", "pinned_refs", "updated_at"]) # 保留用户原文气泡,便于回看刚填的品牌/品名;不要清空 text, @@ -2331,8 +2360,11 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): conversation.save(update_fields=["memory", "updated_at"]) force_creative_turn = True continuation_instruction = ( - "用户已确认使用当前生成的角色出镜。直接基于该角色继续推进创作方案" - "(若尚未选商品则确定商品,已选好商品则开始写创作策略和方案),不要再次追问角色来源。" + "用户已确认使用当前生成的角色出镜。" + "本轮必须实质推进创作:若还缺商品则 ask_user 选商品;" + "痛点解决演示若还缺痛点方向则 ask_user(pain_point_direction);" + "信息够了就立刻 write_strategy。" + "禁止只回一句「可以回复继续完善方案」之类的空引导,也不要再次追问角色来源。" ) elif is_platform_person and (regenerate_button or (person_confirm_pending and clean_text and not upload_person)): prev_prompt = str(memory_now.get("person_prompt") or "").strip() @@ -2662,24 +2694,45 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): 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) + design_from_image = any(kw in ans for kw in ("按图片", "设计品牌", "不用再问")) + clean_ans = ans.replace(PRODUCT_BRAND_EMPTY_TEMPLATE, "").strip() + if (not design_from_image) and is_incomplete_product_brand_answer(ans): + payload["submitted"] = False + payload["answers"] = {} + pending.payload = payload + pending.save(update_fields=["payload", "updated_at"]) + 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, + "detail": "请填写品牌和品名后再发送,例如「品牌浅蓝小熊,品名婴儿柔湿巾」。", + "messages": [CreationMessageSerializer(pending).data], + }, status=200) + if design_from_image: + memory["product_info_resolved"] = True + continuation_instruction = ( + "用户要求直接根据图片内容设计品牌与品名推进创作。" + "直接基于图片外观特征推进方案,不要重复追问品牌。" + ) + else: + memory["product_info_resolved"] = True + memory["product_brand_and_name"] = clean_ans or ans + bm = re.search(r"品牌[::是为]?\s*([^\n,,。!!]+)", clean_ans or ans) + nm = re.search(r"(?:品名|商品名|产品名)[::是为]?\s*([^\n,,。!!]+)", clean_ans or 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 + r["name"] = clean_ans or ans break conversation.pinned_refs = conversation.pinned_refs - continuation_instruction = f"用户已提供商品品牌与品名:【{clean_ans}】。直接围绕该商品推进方案,不要重复追问。" - else: - continuation_instruction = "用户要求直接根据图片内容设计品牌与品名推进创作。直接基于图片外观特征推进方案,不要重复追问品牌。" + continuation_instruction = ( + f"用户已提供商品品牌与品名:【{clean_ans or ans}】。" + "直接围绕该商品推进方案,不要重复追问。" + ) conversation.memory = memory conversation.save(update_fields=["memory", "pinned_refs", "updated_at"]) text = "" diff --git a/core/frontend/src/models-page.css b/core/frontend/src/models-page.css index 0ba5874..aad6820 100644 --- a/core/frontend/src/models-page.css +++ b/core/frontend/src/models-page.css @@ -318,9 +318,17 @@ .model-detail-portrait[role="button"] { cursor: zoom-in; } .model-detail-tags { display: flex; + flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px; + min-width: 0; + max-width: 100%; +} +.model-detail-tags .pill { + flex: 0 1 auto; + max-width: 100%; + overflow-wrap: anywhere; } .model-detail-triview { width: 100%; diff --git a/core/frontend/src/routes/omni-session.tsx b/core/frontend/src/routes/omni-session.tsx index 6957056..53407f7 100644 --- a/core/frontend/src/routes/omni-session.tsx +++ b/core/frontend/src/routes/omni-session.tsx @@ -48,7 +48,7 @@ type PromptBlock = | { type: "shot"; heading: string; fields: Array<{ label: string; value: string }>; text: string } | { type: "text"; heading: string; text: string }; -type ReplyOption = { label: string; text: string; action?: "upload" }; +type ReplyOption = { label: string; text: string; action?: "upload" | "compose" }; function renderInlineMarkdown(text: string): ReactNode[] { return text.split(/(\*\*[^*]+\*\*|`[^`]+`|\*[^*]+\*)/g).filter(Boolean).map((part, index) => { @@ -293,11 +293,18 @@ function replyOptionsForMessage(text: string, payload: Record): .filter((item): item is Record => Boolean(item) && typeof item === "object") .map((item) => { const label = String(item.label || "").trim(); + const rawAction = String(item.action || "").trim(); + const compose = rawAction === "compose" || /我来补充/.test(label); return { label, text: String(item.text || "").trim(), // 任何引导里的“上传图片/实物图/素材图”都走本地文件选择器,避免同一套对话里有的能传、有的只是文字回复。 - action: /上传.*(?:图|图片|素材)|(?:图|图片|素材).*上传/.test(label) ? "upload" as const : undefined, + // compose:只预填输入框,不直接提交空骨架。 + action: /上传.*(?:图|图片|素材)|(?:图|图片|素材).*上传/.test(label) + ? "upload" as const + : compose + ? "compose" as const + : undefined, }; }) .filter((item) => item.label && item.text) @@ -1676,6 +1683,7 @@ function ElicitCard({ options: replyOptions.map((item, index) => ({ value: String(item.text || item.value || item.label || `option_${index + 1}`), label: String(item.label || item.text || item.value || `选项 ${index + 1}`), + action: String((item as { action?: string }).action || "").trim() || (/我来补充/.test(String(item.label || "")) ? "compose" : ""), })), } : null; @@ -1722,6 +1730,17 @@ function ElicitCard({ event.preventDefault(); const text = chatAnswer.trim(); if (!text || disabled) return; + // 品牌品名空骨架未填完:留在输入框,不提交。 + if ( + String(message.payload.topic || "") === "product_info" + && (/^品牌是[::]\s*,\s*(?:商品名|品名)是[::]\s*$/.test(text) || text === "品牌是:,商品名是:") + ) { + requestAnimationFrame(() => { + const input = document.querySelector(".omni-chat-question-input input") as HTMLInputElement | null; + input?.focus(); + }); + return; + } onChatAnswer(text); setChatAnswer(""); }; @@ -1753,6 +1772,20 @@ function ElicitCard({ onClick={() => { // reply_options 是快捷整句回答,走聊天正文;字段 options 仍走 elicit answers。 if (replyChoiceField && !fieldChoice) { + const action = typeof option === "string" ? "" : String(option?.action || ""); + // 「我来补充…」:预填输入框,等用户填完再发,禁止空骨架直接提交。 + if (action === "compose" || /我来补充/.test(label) || /^品牌是[::]\s*,?\s*(?:商品名|品名)是[::]\s*$/.test(value)) { + setChatAnswer(value.includes("品牌是") ? value : "品牌是:,商品名是:"); + requestAnimationFrame(() => { + const input = document.querySelector(".omni-chat-question-input input") as HTMLInputElement | null; + input?.focus(); + if (input && input.value.includes(":,")) { + const pos = input.value.indexOf(":") + 1; + input.setSelectionRange(pos, pos); + } + }); + return; + } onChatAnswer(value); return; } @@ -1772,7 +1805,11 @@ function ElicitCard({ value={chatAnswer} disabled={disabled} onChange={(event) => setChatAnswer(event.target.value)} - placeholder="输入你的想法…" + placeholder={ + String(message.payload.topic || "") === "product_info" + ? "例如:品牌浅蓝小熊,品名婴儿柔湿巾" + : "输入你的想法…" + } aria-label="回答这个问题" />