From 43f80a4d90f3d2c7025c46a2bc186de8e063afc3 Mon Sep 17 00:00:00 2001 From: "Azmat@qq.com" Date: Thu, 10 Sep 2026 18:59:08 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=85=A8=E8=83=BD=E5=88=9B?= =?UTF-8?q?=E4=BD=9C=E9=83=A8=E5=88=86=E4=BC=98=E5=8C=96=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/backend/apps/ai/creation_agent.py | 428 +++++++++++++++--- core/backend/apps/ai/creation_presets.py | 6 +- core/backend/apps/ai/free_video.py | 19 +- core/backend/apps/ai/mentions.py | 3 +- core/backend/apps/ai/services.py | 36 +- core/backend/apps/ai/test_creation_agent.py | 397 +++++++++++++++- core/backend/apps/ai/test_free_video.py | 18 + .../apps/ai/test_video_caption_policy.py | 19 + core/backend/apps/ai/views.py | 196 +++++++- .../apps/projects/test_quick_create.py | 1 + core/frontend/src/omni-session-page.css | 71 +++ core/frontend/src/routes/omni-session.tsx | 124 ++++- 12 files changed, 1223 insertions(+), 95 deletions(-) diff --git a/core/backend/apps/ai/creation_agent.py b/core/backend/apps/ai/creation_agent.py index 60799d3..299b323 100644 --- a/core/backend/apps/ai/creation_agent.py +++ b/core/backend/apps/ai/creation_agent.py @@ -29,7 +29,7 @@ from .creation import append_message, pin_refs from .creation_presets import preset_guidance from .mentions import TYPE_LABELS, infer_field_types, resolve_refs, search_mentions from .models import CreationConversation, CreationMessage, ModelConfig -from .services import build_provider, get_default_model, resolve_text_model +from .services import build_provider, get_default_model, get_seed_text_model, resolve_text_model logger = logging.getLogger(__name__) @@ -80,7 +80,7 @@ video_prompt 必须能被出片模型直接执行,按时间轴写秒级分镜, 3. 运镜(手持跟拍/推近/拉远/横摇/环绕/固定 —— 每段至少一个运镜词) 4. 动作(谁、哪只手、对什么、做什么;要连贯可拍,禁止「展示质感」等抽象词) 5. 信息变化(这几秒画面上多了/变了什么) -- 口播原文单独写清(可用「口播:…」),字数贴近会话时长:大约 5.0–5.7 字/秒, +- 人声原文单独写清,必须用「人声(仅音频,由人物口型与配音表达,不出现在画面上):…」,禁止写成「口播:…」(裸写口播会被出片模型烧成字幕);字数贴近会话时长:大约 5.0–5.7 字/秒, 15 秒约 75–85 字;太短撑不满,太长会赶。 - 钩子段画面不要「对着镜头说话」静态开场;前 15 个口播字禁止「大家好/今天分享/给你们推荐」。 - 全片只围绕一个具体情境推进一个主卖点;卖点要有看得见的证据(质地/前后变化/用法结果)。 @@ -88,11 +88,53 @@ video_prompt 必须能被出片模型直接执行,按时间轴写秒级分镜, - **不要写字幕/花字/标题贴片/弹幕/角标/水印/购物浮层**,也不要写「无字幕」 (否定说法也容易把字画上屏)。口播只存在于声音;包装上原有印刷字除外。 - 已 @ 的角色/商品/场景参考图会自动附上,不要在 prompt 里重描长相;以图锁定性别年龄服装外形。 +- 用户说「商品说话 / 商品自述 / 商品拟人」时,默认采用**无脸拟人**:商品声音是画外角色声, + 商品本体不做口型、不新增卡通五官;性格靠整体倾斜、转向、弹跳、进退、镜头和音效表达。 + 只有用户明确要求可见的卡通五官时才例外。 - 同一场戏保持地点、光线、服装连续;要换环境就明确写下一时间段切换。 """ +_PRODUCT_VOICE_HINT_RE = re.compile( + r"(商品|产品|包装|瓶|机身).{0,8}(说话|开口|自述|拟人)" + r"|拟人.{0,8}(商品|产品|包装|瓶|机身)" +) +_VISIBLE_PRODUCT_FACE_RE = re.compile( + r"(商品|产品|包装|瓶|机身).{0,12}(卡通)?(五官|眼睛|眼珠|嘴巴|嘴|口型)" + r"|(卡通)?(五官|眼睛|眼珠|嘴巴|嘴|口型).{0,12}(商品|产品|包装|瓶|机身)" + r"|(给|让).{0,12}(长出|加上|出现).{0,6}(卡通)?(五官|眼睛|眼珠|嘴巴|嘴|口型)" +) +PRODUCT_VOICE_VISUAL_GUARD = ( + "【商品角色表现·最高优先级】商品始终是参考图里的真实物件。包装正面、瓶身、机身和全部可见表面" + "保持原有设计,只保留参考图本来就有的标签、图案与结构。拟人感完全通过整个商品轻微倾斜、" + "转向、弹跳、进退,配合镜头、光影、环境反应和音效表达。商品台词采用画外角色声,声源不在" + "画面内,商品保持完整物件形态;场景里的其他物件也保持真实原貌。整体采用精致实拍广告质感" + "与克制幽默。前文若有改造商品外观的动作描述,统一改成商品整体运动的对应表达。" +) + + +def apply_product_voice_visual_guard(conversation: CreationConversation, prompt: str) -> str: + """商品拟人默认不长脸;用户明确要求可见卡通五官时尊重其创作选择。""" + base = str(prompt or "").strip() + recent_user_text = " ".join( + conversation.messages.filter( + role=CreationMessage.Role.USER, + kind=CreationMessage.Kind.TEXT, + ).order_by("-seq").values_list("text", flat=True)[:12] + ) + if _VISIBLE_PRODUCT_FACE_RE.search(recent_user_text): + return base + needs_guard = ( + conversation.preset == "商品拟人广告" + or bool(_PRODUCT_VOICE_HINT_RE.search(recent_user_text)) + or bool(_PRODUCT_VOICE_HINT_RE.search(base)) + ) + if not needs_guard or PRODUCT_VOICE_VISUAL_GUARD in base: + return base + return f"{base}\n{PRODUCT_VOICE_VISUAL_GUARD}".strip() + + class AgentError(Exception): """Agent 循环里的业务错误,已经是可以直接给用户看的中文。""" @@ -128,8 +170,105 @@ _ASSET_PICK_PATTERNS = ( ) +_CHITCHAT_RE = re.compile( + r"^\s*(" + r"hi|hello|hey|yo|hola|" + r"你好呀?|您好|嗨|哈喽|嘿|" + r"在吗|在不在|有人吗|" + r"早+|早安|早上好|午安|晚安|" + r"嗯+|哦+|噢+|额+|呃+|" + r"好的?|行|可以|ok(?:ay)?|thanks?|thank\s*you|谢谢了?|感谢|" + r"收到|知道了|明白了|了解" + r")[\s!!.。~~??…]*$", + re.IGNORECASE, +) + + +def is_pure_chitchat(user_text: str) -> bool: + """纯打招呼 / 应答,没有创作意图。这类消息绝不能触发 write_strategy / write_plan。""" + text = (user_text or "").strip() + if not text or len(text) > 24: + return False + return bool(_CHITCHAT_RE.match(text)) + + +# 明确要做内容才算出方案/出图。闲聊、吐槽、问功能都不算。 +_CREATIVE_INTENT_RE = re.compile( + r"(" + r"帮我做|帮我拍|帮我出|帮我写|帮我改|帮我生成|" + r"创作[一两]?[条个张]?|创作(?:一条|个|短)?|" + r"做[一两]?[条个张](?:视频|片|图|广告)?|拍[一两]?[条个](?:视频|片|广告)?|" + r"出[一两]?[条个张](?:视频|片|图|广告)?|出片|出图|出方案|写方案|改方案|重写方案|重新写|" + r"生成(?:一下|一张|几张|一条)?(?:视频|图|片|广告|脚本)?|做条|做个片|短视频|带货视频|短广告|广告片|带货片|" + r"换卖点|改卖点|换剧情|改剧情|重做|重新出|按这个出|确认出片|" + r"分镜|脚本|口播稿|storyboard|拟人|" + r"(改|换|修改|更换).{0,8}(时长|秒数|比例|尺寸|画幅|分辨率|清晰度|模型)|" + r"改成\s*\d+\s*秒|改成\s*\d+\s*[::]\s*\d+|改成.{0,8}(竖屏|横屏|比例|分辨率)" + r")", + re.IGNORECASE, +) + +# 创作 brief 常见搭配:动词 + 成片名词(「创作一条短广告」) +_CREATIVE_VERB_RE = re.compile(r"创作|制作|拍摄|生成|做|拍|出|写|弄|来一条|来个") +_CREATIVE_NOUN_RE = re.compile(r"视频|短片|短视频|广告|带货|出片|分镜|脚本|口播|主图|海报|成片") + + +def has_creative_intent(user_text: str, refs: list | None = None) -> bool: + """用户本轮是否明确要做片/出图/改方案。 + + 没意图时不把 write_strategy / write_plan / generate_image 塞进 tools, + 避免模型把闲聊「整理」成方案卡。仅 @ 素材不算意图。 + """ + text = (user_text or "").strip() + if not text: + return False + if is_pure_chitchat(text): + return False + if _CREATIVE_INTENT_RE.search(text): + return True + # 「把商品…创作一条…短广告」这类 brief:同时有创作动词和成片名词 + if len(text) >= 8 and _CREATIVE_VERB_RE.search(text) and _CREATIVE_NOUN_RE.search(text): + return True + return False + + +_CONTINUE_INTENT_RE = re.compile( + r"^\s*(继续|继续做|接着|接着做|往下做|开始吧|开做吧|就这样|就按这个|按这个来|照这个做|直接做|直接来)[吧啊呀呢。.!!]*\s*$" +) + + +def is_continue_intent(user_text: str) -> bool: + """已有创作上下文时,这些短句是在授权继续,不是闲聊。""" + return bool(_CONTINUE_INTENT_RE.match((user_text or "").strip())) + + +def session_has_creative_context(conversation: CreationConversation) -> bool: + """会话里是否已有可继续的创作进度(钉了素材 / 出过策略或方案)。 + + 这个信号只用于判断一句短回复是否承接创作,不拿来主动追问流程确认。 + """ + if conversation.pinned_refs: + return True + if conversation.messages.filter( + kind__in=( + CreationMessage.Kind.STRATEGY, + CreationMessage.Kind.PLAN, + CreationMessage.Kind.PROMPT_FILE, + CreationMessage.Kind.CONFIRM, + ) + ).exists(): + return True + # 追问发生在策略卡之前时,会话里可能还没有任何结构化产物。原始 brief 本身 + # 就是创作上下文;不认它的话,刷新后一句「继续」会被当成空闲聊天。 + recent_user_texts = conversation.messages.filter( + role=CreationMessage.Role.USER, + kind=CreationMessage.Kind.TEXT, + ).order_by("-seq").values_list("text", flat=True)[:8] + return any(has_creative_intent(item) for item in recent_user_texts) + + def wanted_asset_pick(user_text: str, refs: list | None) -> str | None: - """用户说「改商品」却没 @ 时,应弹出素材卡,而不是让他自己填名字。""" + """用户说「改商品」却没点名时,直接发对应素材选择卡。""" if refs: return None text = user_text or "" @@ -139,6 +278,34 @@ def wanted_asset_pick(user_text: str, refs: list | None) -> str | None: return None +def requested_asset_card_from_context( + conversation: CreationConversation, + user_text: str, +) -> str | None: + """「发一下卡片我选」没有说类型时,沿用最近一次素材追问的类型。""" + text = str(user_text or "") + wants_card = re.search( + r"(发|打开|展示|看看|看下|给我).{0,10}(卡片|列表|商品库|素材库)" + r"|(卡片|列表).{0,10}(选|选择|看看|看下)", + text, + ) + if not wants_card: + return None + recent = conversation.messages.filter( + kind=CreationMessage.Kind.ELICIT + ).order_by("-seq")[:8] + for message in recent: + payload = message.payload or {} + fields = payload.get("pending_fields") or payload.get("fields") or [] + for field in fields: + if not isinstance(field, dict): + continue + inferred = infer_field_types(field) + if len(inferred) == 1 and inferred[0] in TYPE_LABELS: + return inferred[0] + return None + + VIDEO_MODELS = ["Seedance 2.5", "Seedance 2.0", "Seedance 2.0 Fast", "Seedance 2.0 Mini"] IMAGE_MODELS = ["Seedream5.0", "YQ image2"] @@ -150,11 +317,11 @@ SESSION_PARAM_KEYS = ("duration", "ratio", "resolution", "video_model", "count") _PARAM_TO_STORED = {"video_model": "model"} _PARAM_PICK_LABEL = { - "duration": "改成多长?", - "ratio": "改成什么比例?", - "resolution": "改成什么分辨率?", - "video_model": "换成哪个模型?", - "count": "出几张?", + "duration": "想改成多少秒?", + "ratio": "想换成什么画幅?比如 9:16 竖屏或 16:9 横屏。", + "resolution": "想换成什么清晰度?直接说 480p、720p 或 1080p 就行。", + "video_model": "想换哪个模型?直接告诉我模型名就行。", + "count": "这次想出几张?", } @@ -172,7 +339,8 @@ def _param_options(key: str, is_video: bool) -> list[str]: def session_param_fields(keys: list[str], is_video: bool) -> list[dict]: fields = [] - for key in keys[:3]: + # 对话式追问一次只问一件事,避免又退化成参数问卷。 + for key in keys[:1]: if key not in _PARAM_PICK_LABEL: continue options = [{"value": item, "label": item} for item in _param_options(key, is_video)] @@ -283,27 +451,29 @@ def apply_confirm_params(conversation, incoming: dict | None) -> tuple[dict, boo # ---------------------------------------------------------------- 工具 schema -def tool_schemas(context: AgentContext) -> list[dict]: +def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict]: """给模型看的工具清单。图片会话不暴露 generate_video,反之亦然 —— - 会话 mode 是定死的(契约 §0),把不该用的工具摆出来只会诱导模型走错路。""" + 会话 mode 是定死的(契约 §0),把不该用的工具摆出来只会诱导模型走错路。 + allow_plan=False 时隐藏 write_strategy / write_plan / generate_image,闲聊用不出来。""" tools = [ { "type": "function", "function": { "name": "ask_user", "description": ( - "缺少必要信息时反问用户。会在对话里渲染成可点选/可填写的卡片。" + "缺少必要信息时向用户追问。" "只在信息**确实缺失且无法合理推断**时用;能自己定的就自己定,别把用户当填表机器。" "用户要选/改/换商品、角色、模特、场景时**必须**调这个工具," - "type 用 asset 并填 asset_types;禁止只在气泡里问「换成哪个」。" - "一次最多问 3 项。" + "type 用 asset 并填 asset_types,系统会直接显示可视化素材卡让用户点选。" + "不要先问用户是否需要卡片或列表。其他类型才显示为普通聊天问题。" + "一次只问 1 项,禁止问「要不要继续」「要不要生成」「是否开始创作」这类流程问题。" ), "parameters": { "type": "object", "properties": { "fields": { "type": "array", - "maxItems": 3, + "maxItems": 1, "items": { "type": "object", "properties": { @@ -352,6 +522,10 @@ def tool_schemas(context: AgentContext) -> list[dict]: }, }, ] + if not allow_plan: + # 闲聊轮次不给 ask_user,避免模型追问「要不要出片」; + # 换商品/改参数仍由 wanted_asset_pick / wanted_param_keys 兜底追问。 + return [t for t in tools if t.get("function", {}).get("name") == "search_library"] if context.is_video: tools.append({ "type": "function", @@ -359,7 +533,7 @@ def tool_schemas(context: AgentContext) -> list[dict]: "name": "write_strategy", "description": ( "写「创作策略理解」卡:说清这条片给谁看、他为什么会信、你想让他信什么、整体创作方向。" - "在动手写方案之前调它一次,让用户先确认你理解对了。" + "仅当用户明确要做片/出方案时,在 write_plan 之前调一次;打招呼或闲聊不要调。" ), "parameters": { "type": "object", @@ -378,8 +552,8 @@ def tool_schemas(context: AgentContext) -> list[dict]: "function": { "name": "write_plan", "description": ( - "写「视频最终方案」卡并请用户确认。**这是出片前的最后一步**,调完会等用户点确认," - "确认后平台直接按 video_prompt 出片,你不会再有插话机会。" + "写「视频最终方案」卡并请用户确认。**仅当用户明确要做片/出方案/改方案时调用**;" + "打招呼或闲聊不要调。调完会等用户点确认,确认后平台直接按 video_prompt 出片。" "video_prompt 按系统里的「出片脚本写法」写成口播秒级分镜(专业创作同口径),不要只写大纲。" "先调 write_strategy 再调它。" ), @@ -412,7 +586,7 @@ def tool_schemas(context: AgentContext) -> list[dict]: "description": ( "交给出片模型的完整口播带货指令(对齐专业创作口径)。" "必须含:总时长与画幅、整体光线色调、按 0-Ns 分段的秒级分镜" - "(每段写清景别/机位/运镜/具体动作/信息变化)、口播原文、一个主卖点与可见证据、口语 CTA。" + "(每段写清景别/机位/运镜/具体动作/信息变化)、人声(仅音频抬头)原文、一个主卖点与可见证据、口语 CTA。禁止「口播:」字样。" "禁止字幕/花字/贴片及「无字幕」字样;禁止详情页腔与「大家好」开场。" "已 @ 素材会自动作参考图,勿重描长相。" ), @@ -451,7 +625,7 @@ def _coerce_fields(raw) -> list[dict]: """把模型给的 fields 规整成契约 §2 的 Field。脏数据丢弃而不是抛 —— 模型偶尔漏个 type 不该让整条对话崩掉。""" fields: list[dict] = [] - for item in (raw or [])[:3]: + for item in (raw or [])[:1]: if not isinstance(item, dict): continue key = str(item.get("key") or "").strip() @@ -635,6 +809,7 @@ def _video_submit_params(context: AgentContext, prompt: str) -> tuple[dict, list 它已经排好 角色 → 场景 → 商品 的顺序,那正是出片模型 @图N 的语义依据。""" params = context.conversation.params or {} resolved = resolve_refs(context.team, context.conversation.pinned_refs or []) + prompt = apply_product_voice_visual_guard(context.conversation, prompt) submit = { "prompt": prompt, "feature": "omni_create", @@ -759,8 +934,10 @@ def submit_confirmed_image(*, conversation: CreationConversation, user, confirm_ def get_creation_chat_model(requested: ModelConfig | None = None) -> ModelConfig | None: - """全能创作对话模型:走平台文本解析,DeepSeek 一律换成 Seed 2.1 Pro。""" - return resolve_text_model(requested) + """全能创作编排固定优先 Seed 2.1 Pro;显式传入的可用模型仍尊重用户选择。""" + if requested is not None: + return resolve_text_model(requested) + return get_seed_text_model() or resolve_text_model(None) def _creation_model_sees_images(model_config: ModelConfig | None) -> bool: @@ -853,7 +1030,7 @@ def _attach_ref_images(messages: list[dict], image_urls: list[str]) -> list[dict return out -def build_system_prompt(context: AgentContext) -> str: +def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_context: bool = False) -> str: conversation = context.conversation params = conversation.params or {} kind = "视频" if context.is_video else "图片" @@ -862,16 +1039,30 @@ def build_system_prompt(context: AgentContext) -> str: f"本次会话产出的是**{kind}**,这一点在整个会话里不会改变 —— 用户要另一种就请他新开一个创作。", "", "【怎么说话】", - "- 说人话,像个有经验的同事,不要写成客服话术或需求确认清单。", - "- 不要复述用户刚说过的话,不要说「好的,我明白了」这种空句。", - "- 一次只推进一步。", + "- 说人话,像个懂创作、会一起把事做完的同事;自然、简短、有判断,不要写成客服话术或需求确认清单。", + "- 禁止用「好的」「收到」「明白了」「有需要再说」「我将为你」起手;这些句子没有信息量,也很像机器人。", + "- 不要逐字复述用户刚说过的话。需要承接时,只说你的判断或下一步,例如「这个方向能做,我先把反转落在商品登场上。」", + "- 一次只推进一步,但不要把能直接做的事停在寒暄、确认或客套话上。", + "- 缺信息时一次只问一个真正影响结果的问题,不要把对话做成问卷。", + "- 商品、模特、角色、场景这类有库内素材可选的内容,直接发可视化选择卡让用户点;不要先问「要不要发列表」。", + "- 调性、受众、文案、时长等其他信息才用聊天追问,问题末尾顺手告诉用户下一句怎么回。", + "- 用户刚回答过你的问题时,直接沿着答案继续;不要复述答案,也不要额外回一句「收到」。", + "- 用户选择暂不提供某项素材时,把它当成明确授权:按已有信息和合理默认继续。除非任务客观上无法完成,否则不要再次追问同一素材。", + "- 用户说「你来定」「你帮我选」「随便」「都行」时,就是授权你做专业判断;直接选合理方案继续,不要把选择题再抛回去。", + "- 禁止问「要不要继续」「要不要生成」「是否开始创作」这类流程问题。用户已经提出创作需求且信息够了,就直接写策略/方案;真正扣积分前另有确认卡。", + "- 用户打招呼或闲聊(hi / 你好 / 在吗 / 你在干什么 / 嗯 / 好的 / ok):" + " **禁止**调用 write_strategy、write_plan、generate_image,不要「整理方案」或直接开写脚本。", + "- 会话里**还没有**商品/方向时:自然地告诉用户可以直接丢一句想法,别硬推销,也别用客服式结束语。", + "- 会话里**已有**商品或方向时:针对用户这句话本身自然回应;别用「要现在生成还是先调细节」把工作又抛回给用户。", + "- 没有明确创作指令时不要自己写策略卡/方案卡,也不要主动追问流程确认;等用户给出具体想法或修改。", "", "【什么时候反问】", "- 只有信息**确实缺失且无法合理推断**时才调用 ask_user;能自己定的就自己定。", + "- ask_user 一次只问一件事。type=asset 会显示素材选择卡;其他类型显示普通聊天问题,让用户直接输入。", "- 商品是谁、给谁看、什么调性 —— 这些缺了会直接影响成片,值得问。", "- 让用户选商品/角色/模特/场景时,ask_user 必须用 type=asset 并填 asset_types。", - "- 用户说「改商品」「换角色」却没点名是哪个:立刻 ask_user 出点选卡,禁止只在气泡里问「换成哪个、填个名字」。用户不必 @。", - "- 用户说改时长/模型/比例/分辨率:立刻 ask_user,type=single 给出选项;选完后旧方案作废,必须按新参数重新 write_plan。不要让用户去点底部菜单。", + "- 用户说「改商品」「换角色」却没点名是哪个:立刻 ask_user 发对应素材卡,不要再用文字追问名称。", + "- 用户说改时长/模型/比例/分辨率但没给新值:立刻 ask_user,type=single 提供可识别的候选值;聊天里只显示问题,用户直接输入。回答后旧方案作废,必须按新参数重新 write_plan。", "- 光线、构图、镜头这些专业判断是你的活,不要反过来问用户。", ] if context.is_video: @@ -884,7 +1075,7 @@ def build_system_prompt(context: AgentContext) -> str: lo = max(1, int(dur * 5.0)) hi = max(lo, min(85, int(dur * 5.7))) lines.append(f"- 当前按约 {dur} 秒出片,口播建议 {lo}–{hi} 字;write_plan 的 voice_chars 填这个区间。") - lines.append("- 写方案时必须调用 write_plan;video_prompt 按上面的秒级分镜规范写满,不要只给大纲。") + lines.append("- 只有用户明确要做片、出方案、改方案、换卖点/剧情时才调用 write_strategy / write_plan;闲聊与打招呼绝对不要。真要写方案时必须先 write_strategy 再 write_plan,video_prompt 按上面的秒级分镜规范写满,不要只给大纲,也禁止只回「好的,有需要再说」。") else: lines.extend([ "", @@ -892,10 +1083,26 @@ def build_system_prompt(context: AgentContext) -> str: "- 决定出图时必须调用 generate_image,不要只口头说「我这就出图」。", "- 一次用户消息只出一轮;张数用会话已定参数,不要自己加张。", ]) + if not allow_plan: + lines.extend([ + "", + "【本轮闸门】", + "- 用户本轮没有明确说「去做/出方案」。没有 write_strategy / write_plan / generate_image。", + "- **禁止**本轮直接写出策略卡或方案卡。", + ]) + if has_context: + lines.extend([ + "- 会话已有素材或方向:针对用户本轮实际说的话自然回应;不要问要不要继续、要不要生成。", + "- 禁止只回「在呢」「有需要随时招呼」「收到」这种空话。", + ]) + else: + lines.extend([ + "- 会话还没有创作进度:短回一句,告诉用户直接说想做什么即可;不要用客服式结束语,也不要硬推销出片。", + ]) if params: meta = "、".join(f"{k}:{v}" for k, v in params.items() if v) if meta: - lines.append(f"\n【会话已定参数】{meta}(出片按这套;用户要改就出选项卡,选完必须重写方案)") + lines.append(f"\n【会话已定参数】{meta}(出片按这套;用户改动后必须按新值重写方案)") if conversation.preset: # 只给名字模型只能靠猜;把这个预设的拍法约束一起给它 guidance = preset_guidance(conversation.preset) @@ -929,14 +1136,25 @@ def build_system_prompt(context: AgentContext) -> str: return "\n".join(lines) -def build_messages(context: AgentContext) -> list[dict]: +def _answer_label(field: dict, raw) -> str: + """追问卡的内部值翻成人能读的标签,避免把 UUID / gate key 喂回模型。""" + values = raw if isinstance(raw, list) else [raw] + options = { + str(item.get("value")): str(item.get("label") or item.get("value") or "") + for item in (field.get("options") or []) + if isinstance(item, dict) + } + return "、".join(options.get(str(value), str(value)) for value in values if value not in (None, "")) + + +def build_messages(context: AgentContext, *, allow_plan: bool = True, has_context: bool = False) -> list[dict]: """会话历史 → 模型消息。只喂对模型有意义的:文字、追问和它的答案、生成过什么。 策略卡/方案卡这类结构化产物压成一句话,原样塞 JSON 只会挤爆上下文。 长会话只喂最近 KEEP_RECENT_MESSAGES 条原文,更早的靠 system 里的【前情提要】 ——摘要在 compress_memory() 里生成,不在这里现算。 """ - messages = [{"role": "system", "content": build_system_prompt(context)}] + messages = [{"role": "system", "content": build_system_prompt(context, allow_plan=allow_plan, has_context=has_context)}] history = list(context.conversation.messages.all()) if len(history) > COMPRESS_AFTER_MESSAGES: history = history[-KEEP_RECENT_MESSAGES:] @@ -945,14 +1163,37 @@ def build_messages(context: AgentContext) -> list[dict]: if message.text.strip(): messages.append({"role": message.role, "content": message.text}) elif message.kind == CreationMessage.Kind.ELICIT: - answers = (message.payload or {}).get("answers") or {} - labels = {f["key"]: f["label"] for f in (message.payload or {}).get("fields", [])} + payload = message.payload or {} + answers = payload.get("answers") or {} + fields = [item for item in (payload.get("fields") or []) if isinstance(item, dict)] + field_by_key = {str(item.get("key") or ""): item for item in fields} + if payload.get("interaction") == "chat" or ( + payload.get("interaction") == "asset_picker" + and payload.get("answered_via") == "chat" + ): + question = message.text.strip() or str((fields[0] if fields else {}).get("label") or "").strip() + if question: + messages.append({"role": "assistant", "content": question}) + # 聊天式回答本身就是下一条 TEXT user 消息,这里不再合成一条伪造答案。 + continue if answers: - joined = ";".join(f"{labels.get(k, k)} → {v}" for k, v in answers.items()) - messages.append({"role": "assistant", "content": f"(我问了用户几个问题)"}) - messages.append({"role": "user", "content": f"(用户回答){joined}"}) + if payload.get("phase") == "gate": + choice = str(answers.get("_asset_gate") or "") + if choice == "skip": + answer_text = "(用户选择暂不添加这项素材,要求按现有信息继续原任务)" + else: + answer_text = "(用户希望打开素材列表继续选择)" + else: + joined = ";".join( + f"{field_by_key.get(str(key), {}).get('label') or key}:" + f"{_answer_label(field_by_key.get(str(key), {}), value)}" + for key, value in answers.items() + ) + answer_text = f"(用户完成了刚才的选择:{joined})" + messages.append({"role": "assistant", "content": "(我请用户补充了一项会影响创作的信息)"}) + messages.append({"role": "user", "content": answer_text}) else: - messages.append({"role": "assistant", "content": "(我向用户提了问题,还没收到回答)"}) + messages.append({"role": "assistant", "content": "(我正在等用户回答刚才的问题)"}) elif message.kind in (CreationMessage.Kind.GENERATING, CreationMessage.Kind.RESULT): prompt = (message.payload or {}).get("prompt") or "" messages.append({"role": "assistant", "content": f"(我生成了一版,prompt:{prompt[:200]})"}) @@ -1005,6 +1246,9 @@ def stream_creation_agent( text: str, refs: list[dict] | None = None, model_config: ModelConfig | None = None, + record_user_message: bool = True, + force_creative_turn: bool = False, + continuation_instruction: str = "", ) -> Iterator[str]: """一条用户消息 → SSE 流。生成器,由 StreamingHttpResponse 逐帧下发。""" refs = refs or [] @@ -1015,10 +1259,26 @@ def stream_creation_agent( context = AgentContext(conversation=conversation, user=user, model_config=model_config) try: + user_message = None with transaction.atomic(): pin_refs(conversation, refs) - user_message = append_message(conversation, role="user", text=text, refs=refs) - yield _sse({"type": "message", "message": _message_payload(user_message)}) + if record_user_message: + user_message = append_message(conversation, role="user", text=text, refs=refs) + if user_message is not None: + yield _sse({"type": "message", "message": _message_payload(user_message)}) + # 空会话里的纯打招呼:短回一句就够。已有商品/方向时走模型, + # 针对用户实际说的话自然回应,但不给方案工具。 + has_context = session_has_creative_context(conversation) + if record_user_message and is_pure_chitchat(text) and not refs and not has_context: + reply = append_message( + conversation, + role="assistant", + text="我在。想做什么,直接丢一句想法给我就行。", + ) + yield _sse({"type": "message", "message": _message_payload(reply)}) + yield _sse({"type": "done"}) + return + model_config = _prefer_vision_text_model(model_config, conversation.team, conversation.pinned_refs or []) context.model_config = model_config @@ -1035,9 +1295,18 @@ def stream_creation_agent( # 用户消息已经先回显了,所以这一小段等待不会看起来像卡住。 compress_memory(context) + allow_plan = ( + force_creative_turn + or has_creative_intent(text, refs) + or (has_context and is_continue_intent(text)) + ) provider = build_provider(model_config) - messages = build_messages(context) - tools = tool_schemas(context) + messages = build_messages(context, allow_plan=allow_plan, has_context=has_context) + if continuation_instruction.strip(): + # 卡片答案已经在历史里,这里仅给本轮一个不落库的执行指令。这样既不会多出 + # 一条伪造的用户气泡,也不会让模型把「跳过素材」误判成闲聊后停住。 + messages.append({"role": "user", "content": continuation_instruction.strip()}) + tools = tool_schemas(context, allow_plan=allow_plan) for _round in range(MAX_TOOL_ROUNDS): text_buffer: list[str] = [] @@ -1060,17 +1329,15 @@ def stream_creation_agent( said = "".join(text_buffer).strip() calls = [tool_buffer[i] for i in sorted(tool_buffer) if tool_buffer[i].get("name")] - - if said: - bubble = append_message(conversation, role="assistant", text=said) - yield _sse({"type": "message", "message": _message_payload(bubble)}) - + fallback_fields = None if not calls: - pick = wanted_asset_pick(text, refs) + pick = ( + wanted_asset_pick(text, refs) + or requested_asset_card_from_context(conversation, text) + ) param_keys = wanted_param_keys(text, is_video=context.is_video) - fields = None if pick: - fields = [{ + fallback_fields = [{ "key": pick, "label": _ASSET_PICK_LABEL[pick], "type": "asset", @@ -1078,9 +1345,18 @@ def stream_creation_agent( "asset_types": [pick], }] elif param_keys: - fields = session_param_fields(param_keys, context.is_video) - if fields: - result, _stop = _dispatch_tool(context, "ask_user", {"fields": fields}) + fallback_fields = session_param_fields(param_keys, context.is_video) + + # ask_user 自己会落一条可追踪的聊天问题。模型同时吐出的过渡文案不再 + # 另存一条,否则界面会连续出现两遍几乎相同的问题。 + asks_user = bool(fallback_fields) or any(call.get("name") == "ask_user" for call in calls) + if said and not asks_user: + bubble = append_message(conversation, role="assistant", text=said) + yield _sse({"type": "message", "message": _message_payload(bubble)}) + + if not calls: + if fallback_fields: + result, _stop = _dispatch_tool(context, "ask_user", {"fields": fallback_fields}) for event in result.get("_events", []): yield _sse(event) break @@ -1138,6 +1414,47 @@ _TOOL_LABELS = { } +_ASSET_CARD_LABELS = { + "product": "这条要展示哪款商品?", + "character": "这条想用哪个角色?", + "model": "这条想用哪位模特?", + "scene": "这条想放在哪个场景里?", + "asset": "这一步要使用哪项素材?", +} + + +def _guided_elicit_text(field: dict) -> str: + """追问既要问清一件事,也要让用户知道下一句怎么回。""" + label = str(field.get("label") or "这项你想怎么定?").strip() + if field.get("type") == "asset": + return label + if str(field.get("key") or "") in SESSION_PARAM_KEYS: + return label + return f"{label} 直接用一句话告诉我就行,不用整理成完整需求。" + + + +def _elicit_payload_for_fields(fields: list[dict]) -> dict: + """素材问题直接出选择卡;其他问题继续走普通聊天。""" + field = dict(fields[0]) + if field.get("type") == "asset": + asset_types = [item for item in (field.get("asset_types") or []) if item in _ASSET_CARD_LABELS] + field["label"] = _ASSET_CARD_LABELS[asset_types[0]] if len(asset_types) == 1 else _ASSET_CARD_LABELS["asset"] + return { + "interaction": "asset_picker", + "phase": "pick", + "fields": [field], + "submitted": False, + "answers": {}, + } + return { + "interaction": "chat", + "fields": [field], + "submitted": False, + "answers": {}, + } + + def _dispatch_tool(context: AgentContext, name: str, args: dict) -> tuple[dict, bool]: """执行一个工具。返回 (结果, 是否中断循环)。 @@ -1147,10 +1464,13 @@ def _dispatch_tool(context: AgentContext, name: str, args: dict) -> tuple[dict, fields = _coerce_fields(args.get("fields")) if not fields: return {"payload": {"error": "fields 不合法,请重新组织问题"}}, False + payload = _elicit_payload_for_fields(fields) + display_field = (payload.get("fields") or fields)[0] message = append_message( context.conversation, role="assistant", kind=CreationMessage.Kind.ELICIT, - payload={"fields": fields, "submitted": False, "answers": {}}, + text=_guided_elicit_text(display_field), + payload=payload, ) # 反问一旦发出就必须停,等人回答。继续跑等于自问自答。 return { diff --git a/core/backend/apps/ai/creation_presets.py b/core/backend/apps/ai/creation_presets.py index 5fdaef2..16ff46c 100644 --- a/core/backend/apps/ai/creation_presets.py +++ b/core/backend/apps/ai/creation_presets.py @@ -14,8 +14,10 @@ VIDEO_PRESETS: dict[str, str] = { "商品不能在开头硬推,要等冲突立住了再自然介入。禁止把普通不便夸成严重后果。" ), "商品拟人广告": ( - "把商品拟人化成有性格的角色,用它的动作、表情和情绪推进。轻快有趣,不说教。" - "拟人化不能牺牲商品真实外观 —— 材质、颜色、结构必须和参考图一致。" + "把商品当成有性格的角色,但默认采用无脸拟人:商品本体保持真实完整,不在瓶身、包装或机身上新增卡通五官。" + "性格通过整个商品轻微倾斜、转向、弹跳、进退,配合镜头、光影、环境反应和音效来表达。" + "商品需要说台词时使用画外角色声,商品本体不做口型。整体轻快有趣、精致克制,不是儿童贴纸玩具风。" + "只有用户明确说要可见的卡通五官时才采用有脸拟人。商品材质、颜色、结构和包装必须与参考图一致。" ), "达人口播种草": ( "真人出镜口播,生活化语气,像朋友分享而不是念广告稿。开头一句话建立停留理由," diff --git a/core/backend/apps/ai/free_video.py b/core/backend/apps/ai/free_video.py index f1dfc06..c779281 100644 --- a/core/backend/apps/ai/free_video.py +++ b/core/backend/apps/ai/free_video.py @@ -71,6 +71,9 @@ def model_resolutions(model_config) -> set[str]: RATIOS = {"21:9", "16:9", "4:3", "1:1", "3:4", "9:16"} RESOLUTIONS = {"480p", "720p", "1080p", "4k"} MODES = {"universal", "keyframe"} +# 全能创作 / 分镜路用语义 type(character/product/…);对火山仍是参考图。 +# 旧逻辑只认 image/video/audio,会把商品/角色图静默 skipped → 成片只靠文案「发明」包装。 +IMAGE_LIKE_REF_TYPES = {"image", "character", "product", "scene", "model", "asset"} IN_FLIGHT_STATUSES = ( AITask.Status.CREATED, # 视频复刻审核中也占并发,避免连点刷出一堆待审任务 AITask.Status.RESERVED, @@ -300,7 +303,9 @@ def build_content_items( # 三库引用(模块4 · 4.1):资产库 / 模特库 / 商品库 挑出来的东西最终都是一行 Asset, # 所以后端只认一种 source=asset,三个库的差别全在前端的 picker 上。 - if source == "asset" and ref.get("asset_id"): + # 全能创作 resolve_refs / 前端常带 type=character|product 且 source 缺省为 upload, + # 只要有 asset_id 就按 Asset 解析(含审核态 → asset://),避免直链分支把语义 type skipped。 + if ref.get("asset_id") and source in {"asset", "upload", ""}: asset = Asset.objects.filter(id=ref["asset_id"], team=team, is_deleted=False).first() if asset is None: raise ValueError(f"素材「{label or '未命名'}」不存在或已被删除") @@ -326,8 +331,9 @@ def build_content_items( # 直传素材(已上传 TOS 的直链)。resolved_url 可覆盖为 asset://(官方模特跨团队等) push_url = str(ref.get("resolved_url") or url) - if ref_type == "image": + if ref_type in IMAGE_LIKE_REF_TYPES: # 参考图模式下所有图 role 必须 reference_image;keyframe 用 first_frame/last_frame + # character/product/scene/model/asset 与 image 同等对待(全能创作语义 type) effective_role = "reference_image" if mode == "universal" else (role or "first_frame") asset_type = _push("image", push_url, effective_role) elif ref_type == "video": @@ -356,6 +362,13 @@ def build_content_items( # @label 替换:按 label 长度降序,防子串吞噬 ordered = sorted(label_to_placeholder.items(), key=lambda kv: len(kv[0]), reverse=True) api_prompt = _format_prompt_for_ark(prompt, ordered) + # 自由创作 / 全能创作是整段一次出片(没有「第 2 段」),开场先验强 —— + # 挂上与专业创作首镜相同的正面洁净指令;最终 enforce_no_embedded_captions 还会改写口播并挂禁令。 + from .services import OPENING_SHOT_DIRECTIVE, rewrite_speech_as_audio_only + + if OPENING_SHOT_DIRECTIVE not in api_prompt: + api_prompt = f"{OPENING_SHOT_DIRECTIVE}\n{api_prompt}" + api_prompt = rewrite_speech_as_audio_only(api_prompt) return { "content_items": content_items, @@ -480,7 +493,7 @@ def submit_free_video(*, team, user, params: dict) -> AITask: if mode == "keyframe": roles = [str(r.get("role") or "") for r in references] - if any(str(r.get("type") or "image") != "image" for r in references): + if any(str(r.get("type") or "image") not in IMAGE_LIKE_REF_TYPES for r in references): raise ValueError("首尾帧模式仅支持图片素材") if "first_frame" not in roles: raise ValueError("首尾帧模式需要提供首帧图片") diff --git a/core/backend/apps/ai/mentions.py b/core/backend/apps/ai/mentions.py index 1c31e07..1ce49a6 100644 --- a/core/backend/apps/ai/mentions.py +++ b/core/backend/apps/ai/mentions.py @@ -277,6 +277,7 @@ def _asset_reference(asset, type_: str, label: str) -> dict | None: "url": url, "type": type_, "label": label, + "source": "asset", "asset_id": str(asset.id), "review_status": asset.review_status, "review_remote_id": asset.review_remote_id, @@ -355,7 +356,7 @@ def _product_reference(product) -> dict | None: cover = _product_cover_url(product) return ( {"url": cover, "type": "product", "label": product.title, - "asset_id": "", "review_status": "", "review_remote_id": ""} + "source": "upload", "asset_id": "", "review_status": "", "review_remote_id": ""} if cover else None ) diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index fd2a4f1..d63591b 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -948,6 +948,35 @@ _CAPTION_FIELD_INLINE_RE = re.compile(rf"[,,;;、]?\s*{_CAPTION_WORDS}\s*[ _CAPTION_CLAUSE_RE = re.compile(rf"[,,;;、]?\s*[^,,;;。\n]*{_CAPTION_WORDS}[^,,;;。\n]*[。]?") +_SPEECH_LINE_RE = re.compile( + r"^(\s*)(口播|旁白|台词|对白|解说)\s*[::]\s*(.+)$" +) +_SPEECH_AUDIO_ONLY_PREFIX = "人声(仅音频,由人物口型与配音表达,不出现在画面上):" + + +def rewrite_speech_as_audio_only(prompt: str) -> str: + """把「口播:/旁白:/台词:」改写成「人声(仅音频…)」抬头。 + + 全能创作 / 自由创作的 video_prompt 常按秒级分镜写「口播:…」。 + 裸摆口播原文时,出片模型极易把这段可见文本烧成画面字幕 —— 专业创作 + 已用同一抬头(_segment_script_text);这里对所有入口统一改写。 + 已带「仅音频」抬头的行不重复包一层。 + """ + out: list[str] = [] + for line in (prompt or "").split("\n"): + if "仅音频" in line and "人声" in line: + out.append(line) + continue + m = _SPEECH_LINE_RE.match(line) + if m: + indent, _kind, speech = m.group(1), m.group(2), m.group(3).strip() + if speech: + out.append(f"{indent}{_SPEECH_AUDIO_ONLY_PREFIX}{speech}") + continue + out.append(line) + return "\n".join(out) + + def strip_caption_directives(prompt: str) -> str: """把正文里所有会让模型联想到「画面文字」的字样抹掉 —— 无论它是要求加字幕还是声明没有字幕。 模型不区分肯定否定,看到「字幕」这个词本身就更容易画出来,所以一律清掉, @@ -966,15 +995,18 @@ def strip_caption_directives(prompt: str) -> str: def enforce_no_embedded_captions(prompt: str) -> str: - """所有视频入口(专业创作 / 一键成片 / 自由创作 / 视频复刻 / 提炼改写)最终汇入这里: - 先洗掉正文里的字幕字段,再把最高优先级的无字幕禁令置于提示词第一行。 + """所有视频入口(专业创作 / 一键成片 / 自由创作 / 视频复刻 / 提炼改写 / 全能创作)最终汇入这里: + 先把「口播:」改写成仅音频抬头,再洗掉字幕字段,最后挂最高优先级无字幕禁令。 首镜最容易被模型自动加标题,所以不能再把禁令放在长提示词末尾。""" # 顺序要紧:先摘掉可能已存在的规则本身,再洗正文,最后统一拼回去。 # 反过来的话,清洗器会把规则里「不要字幕…」那半句也当成字幕字样吃掉 → 摘不干净 → 规则拼两遍 # (重复 = 负面词频翻倍 = 更容易出字幕,正是本次要根治的毛病)。 base = (prompt or "").replace(NO_EMBEDDED_CAPTIONS_REQUIREMENT, "").replace(NO_EMBEDDED_CAPTIONS_TAIL, "") + base = base.replace(OPENING_SHOT_DIRECTIVE, "") + base = rewrite_speech_as_audio_only(base) base = strip_caption_directives(base).strip() # 首帧最容易被模型自动加标题页 → 规则放开头;末位权重高 → 结尾补一句中性的正面复述。 + # 口播改写后再挂洁净规则,避免「口播:」原文被模型烧成字幕。 return f"{NO_EMBEDDED_CAPTIONS_REQUIREMENT}\n{base}\n{NO_EMBEDDED_CAPTIONS_TAIL}".strip() diff --git a/core/backend/apps/ai/test_creation_agent.py b/core/backend/apps/ai/test_creation_agent.py index 31edd78..ddcb6da 100644 --- a/core/backend/apps/ai/test_creation_agent.py +++ b/core/backend/apps/ai/test_creation_agent.py @@ -6,7 +6,7 @@ import json from unittest.mock import patch -from django.test import TestCase +from django.test import SimpleTestCase, TestCase from rest_framework.test import APIClient from apps.accounts.models import Team, TeamMember, User @@ -25,9 +25,14 @@ from .creation_agent import ( _merge_tool_call_deltas, build_messages, get_creation_chat_model, + has_creative_intent, + is_continue_intent, + is_pure_chitchat, + session_has_creative_context, stream_creation_agent, submit_confirmed_image, submit_confirmed_video, + tool_schemas, video_duration, video_model_name, wanted_asset_pick, @@ -134,17 +139,19 @@ class FieldCoercionTests(TestCase): self.assertEqual(fields, []) # 没选项的单选是废卡 def test_text_and_asset_fields_get_their_defaults(self): - fields = _coerce_fields([ + text_fields = _coerce_fields([ {"key": "slogan", "label": "想突出哪句话?", "type": "text"}, + ]) + asset_fields = _coerce_fields([ {"key": "who", "label": "用哪个模特?", "type": "asset"}, ]) - self.assertEqual(fields[0]["placeholder"], "") - self.assertIn("product", fields[1]["asset_types"]) + self.assertEqual(text_fields[0]["placeholder"], "") + self.assertIn("product", asset_fields[0]["asset_types"]) def test_unknown_type_and_over_limit_are_trimmed(self): raw = [{"key": f"k{i}", "label": "x", "type": "text"} for i in range(5)] raw.append({"key": "bad", "label": "x", "type": "dropdown"}) - self.assertEqual(len(_coerce_fields(raw)), 3) # 一次最多问 3 项 + self.assertEqual(len(_coerce_fields(raw)), 1) # 聊天式追问一次只问 1 项 def test_product_text_or_single_is_coerced_to_asset_card(self): fields = _coerce_fields([ @@ -185,6 +192,8 @@ class ParamPickIntentTests(TestCase): class AskUserTests(CreationAgentBaseTests): def test_saying_change_duration_injects_param_card(self): + self.conversation.mode = CreationConversation.Mode.VIDEO + self.conversation.save(update_fields=["mode"]) events, _ = self._run([_text_chunks("你想改成多长?")], text="我想改时长") elicit = [e for e in events if e.get("type") == "message" and e["message"]["kind"] == "elicit"] self.assertEqual(len(elicit), 1) @@ -195,8 +204,42 @@ class AskUserTests(CreationAgentBaseTests): events, _ = self._run([_text_chunks("换成哪个商品?直接选或填名字都行:")], text="我想修改商品") elicit = [e for e in events if e.get("type") == "message" and e["message"]["kind"] == "elicit"] self.assertEqual(len(elicit), 1) - self.assertEqual(elicit[0]["message"]["payload"]["fields"][0]["type"], "asset") - self.assertEqual(elicit[0]["message"]["payload"]["fields"][0]["asset_types"], ["product"]) + payload = elicit[0]["message"]["payload"] + self.assertEqual(payload["interaction"], "asset_picker") + self.assertEqual(payload["phase"], "pick") + self.assertEqual(payload["fields"][0]["type"], "asset") + self.assertEqual(payload["fields"][0]["asset_types"], ["product"]) + self.assertEqual(payload["fields"][0]["label"], "这条要展示哪款商品?") + self.assertEqual(elicit[0]["message"]["text"], "这条要展示哪款商品?") + + def test_requesting_the_card_uses_the_previous_asset_context(self): + append_message(self.conversation, role="user", text="帮我做一条商品短片") + append_message( + self.conversation, + role="assistant", + kind=CreationMessage.Kind.ELICIT, + text="这条要展示哪款商品?", + payload={ + "interaction": "chat", + "fields": [{ + "key": "product", + "label": "这条要展示哪款商品?", + "type": "asset", + "asset_types": ["product"], + }], + "submitted": True, + "answers": {"product": "你发一下卡片我选择"}, + }, + ) + events, _ = self._run([_text_chunks("我把商品卡片发出来。")], text="你发一下卡片我选择") + cards = [ + event["message"] for event in events + if event.get("type") == "message" + and event["message"]["kind"] == "elicit" + and event["message"]["payload"].get("interaction") == "asset_picker" + ] + self.assertEqual(len(cards), 1) + self.assertEqual(cards[0]["payload"]["fields"][0]["asset_types"], ["product"]) def test_ask_user_emits_elicit_card_and_stops_the_loop(self): events, fake = self._run([ @@ -209,6 +252,11 @@ class AskUserTests(CreationAgentBaseTests): elicit = [e for e in events if e.get("type") == "message" and e["message"]["kind"] == "elicit"] self.assertEqual(len(elicit), 1) + self.assertEqual(elicit[0]["message"]["payload"]["interaction"], "chat") + self.assertEqual( + elicit[0]["message"]["text"], + "想要什么调性? 直接用一句话告诉我就行,不用整理成完整需求。", + ) self.assertEqual(len(elicit[0]["message"]["payload"]["fields"][0]["options"]), 2) # 反问必须中断循环,否则模型会自问自答 self.assertEqual(len(fake.calls), 1) @@ -398,6 +446,136 @@ class SendEndpointTests(TestCase): response = self.client.post(f"/api/ai/creations/{self.conversation.id}/send/", {}, format="json") self.assertEqual(response.status_code, 400) + def test_typed_reply_answers_chat_question_and_continues(self): + product = Product.objects.create(team=self.team, created_by=self.user, title="净颜精华") + append_message(self.conversation, role="user", text="做一张轻松有趣的商品海报") + question = append_message( + self.conversation, + role="assistant", + kind=CreationMessage.Kind.ELICIT, + text="这次主推哪个商品?", + payload={ + "interaction": "asset_picker", + "fields": [{ + "key": "product", + "label": "这次主推哪个商品?", + "type": "asset", + "required": True, + "asset_types": ["product"], + }], + "submitted": False, + "answers": {}, + }, + ) + fake = FakeProvider([_text_chunks("那就把反差感落在商品登场上。")]) + + with patch("apps.ai.creation_agent.build_provider", return_value=fake): + response = self.client.post( + f"/api/ai/creations/{self.conversation.id}/send/", + {"kind": "text", "text": f"就用{product.title}吧"}, + format="json", + ) + list(response.streaming_content) + + question.refresh_from_db() + self.conversation.refresh_from_db() + self.assertTrue(question.payload["submitted"]) + self.assertEqual(question.payload["answers"], {"product": product.title}) + self.assertEqual( + self.conversation.messages.filter(role="user").order_by("-seq").first().text, + f"就用{product.title}吧", + ) + self.assertTrue(any( + ref.get("type") == "product" and str(ref.get("id")) == str(product.id) + for ref in self.conversation.pinned_refs + )) + tool_names = {tool["function"]["name"] for tool in fake.calls[0]["extra_body"]["tools"]} + self.assertIn("generate_image", tool_names) + + def test_delegate_reply_picks_one_product_without_showing_a_list(self): + product = Product.objects.create(team=self.team, created_by=self.user, title="净颜精华") + question = append_message( + self.conversation, + role="assistant", + kind=CreationMessage.Kind.ELICIT, + text="先定主商品:这条要展示哪款商品?", + payload={ + "interaction": "asset_picker", + "fields": [{ + "key": "product", + "label": "这条要展示哪款商品?", + "type": "asset", + "required": True, + "asset_types": ["product"], + }], + "submitted": False, + "answers": {}, + }, + ) + fake = FakeProvider([_text_chunks("我用它来组织这条片。")]) + + with patch("apps.ai.creation_agent.build_provider", return_value=fake): + response = self.client.post( + f"/api/ai/creations/{self.conversation.id}/send/", + {"kind": "text", "text": "你来定"}, + format="json", + ) + list(response.streaming_content) + + question.refresh_from_db() + self.conversation.refresh_from_db() + self.assertEqual(question.payload["answers"], {"product": product.title}) + self.assertTrue(any( + ref.get("type") == "product" and str(ref.get("id")) == str(product.id) + for ref in self.conversation.pinned_refs + )) + assistant_text = " ".join( + message.text for message in self.conversation.messages.filter(role="assistant") + if message.text + ) + self.assertNotIn("可选商品", assistant_text) + + def test_typed_param_reply_maps_to_option_value(self): + self.conversation.mode = CreationConversation.Mode.VIDEO + self.conversation.params = {"duration": "5 秒"} + self.conversation.save(update_fields=["mode", "params"]) + question = append_message( + self.conversation, + role="assistant", + kind=CreationMessage.Kind.ELICIT, + text="改成多长?", + payload={ + "interaction": "chat", + "fields": [{ + "key": "duration", + "label": "改成多长?", + "type": "single", + "required": True, + "options": [ + {"value": "5 秒", "label": "5 秒"}, + {"value": "10 秒", "label": "10 秒"}, + {"value": "15 秒", "label": "15 秒"}, + ], + }], + "submitted": False, + "answers": {}, + }, + ) + fake = FakeProvider([_text_chunks("我按十秒重新排镜头。")]) + + with patch("apps.ai.creation_agent.build_provider", return_value=fake): + response = self.client.post( + f"/api/ai/creations/{self.conversation.id}/send/", + {"kind": "text", "text": "改成 10 秒吧"}, + format="json", + ) + list(response.streaming_content) + + question.refresh_from_db() + self.conversation.refresh_from_db() + self.assertEqual(question.payload["answers"], {"duration": "10 秒"}) + self.assertEqual(self.conversation.params["duration"], "10 秒") + def test_answering_an_elicit_card_marks_it_submitted(self): card = append_message( self.conversation, role="assistant", kind=CreationMessage.Kind.ELICIT, @@ -417,6 +595,8 @@ class SendEndpointTests(TestCase): card.refresh_from_db() self.assertTrue(card.payload["submitted"]) self.assertEqual(card.payload["answers"], {"tone": "warm"}) + # 选择已经显示在卡片里,不能再复制成一条伪造的用户气泡。 + self.assertFalse(self.conversation.messages.filter(role="user").exists()) def test_elicit_product_choice_pins_the_product(self): """对话里点选商品只回 answers 时,也必须钉进 pinned_refs,否则出片带不上商品图。""" @@ -480,6 +660,61 @@ class SendEndpointTests(TestCase): # 重复提交会让同一个问题在上下文里出现两次答案 self.assertEqual(response.status_code, 409) + def test_skipping_asset_picker_continues_without_fake_user_bubble(self): + """点「先按当前想法做」应继续创作,不能把系统文案冒充成用户消息再停住。""" + append_message(self.conversation, role="user", text="帮我做一张有性格的商品海报") + card = append_message( + self.conversation, + role="assistant", + kind=CreationMessage.Kind.ELICIT, + payload={ + "phase": "gate", + "fields": [{ + "key": "_asset_gate", + "label": "选好商品能让画面更准确。要现在从素材库里挑一个吗?", + "type": "single", + "options": [ + {"value": "send", "label": "选择商品"}, + {"value": "skip", "label": "先按当前想法做"}, + ], + }], + "pending_fields": [{ + "key": "product", "label": "选哪个商品?", "type": "asset", + "asset_types": ["product"], + }], + "submitted": False, + "answers": {}, + }, + ) + fake = FakeProvider([_text_chunks("行,那我先按拟人海报方向往下做。")]) + before_users = self.conversation.messages.filter(role="user").count() + + with patch("apps.ai.creation_agent.build_provider", return_value=fake): + response = self.client.post( + f"/api/ai/creations/{self.conversation.id}/send/", + {"kind": "elicit_answer", "reply_to": str(card.id), + "answers": {"_asset_gate": "skip"}}, + format="json", + ) + events = _events( + chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk + for chunk in response.streaming_content + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(self.conversation.messages.filter(role="user").count(), before_users) + self.assertFalse(any( + event.get("type") == "message" and event["message"]["role"] == "user" + for event in events + )) + self.assertTrue(fake.calls) + tool_names = {tool["function"]["name"] for tool in fake.calls[0]["extra_body"]["tools"]} + self.assertIn("generate_image", tool_names) + self.assertNotIn("有需要再说", "".join( + event["message"].get("text", "") + for event in events if event.get("type") == "message" + )) + class VideoPlanAndConfirmTests(CreationAgentBaseTests): """视频链路:策略卡 → 方案卡 → 确认闸门 → 出片(契约 §0)。""" @@ -576,6 +811,51 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): self.assertEqual(params["aspect_ratio"], "9:16") self.assertTrue(params["generate_audio"]) + def test_personified_product_uses_offscreen_voice_without_changing_packaging(self): + self.conversation.preset = "商品拟人广告" + self.conversation.save(update_fields=["preset"]) + card = append_message( + self.conversation, role="assistant", kind=CreationMessage.Kind.CONFIRM, + payload={"video_prompt": "精华瓶自述使用感受", "submitted": False}, + ) + task = AITask.objects.create( + team=self.team, created_by=self.user, task_type=AITask.Type.FREE_VIDEO, + model_config=self.model, idempotency_key="k-product-voice", + ) + with patch("apps.ai.free_video.submit_free_video", return_value=task) as submit: + message, error = submit_confirmed_video( + conversation=self.conversation, user=self.user, confirm_message=card + ) + prompt = submit.call_args.kwargs["params"]["prompt"] + + self.assertEqual(error, "") + self.assertIsNotNone(message) + self.assertIn("商品台词采用画外角色声", prompt) + self.assertIn("全部可见表面保持原有设计", prompt) + + def test_explicit_cartoon_face_request_can_override_the_default(self): + self.conversation.preset = "商品拟人广告" + self.conversation.save(update_fields=["preset"]) + append_message(self.conversation, role="user", text="我就要精华瓶长出卡通眼睛和嘴巴") + original = "精华瓶眨眼开口说话" + card = append_message( + self.conversation, role="assistant", kind=CreationMessage.Kind.CONFIRM, + payload={"video_prompt": original, "submitted": False}, + ) + task = AITask.objects.create( + team=self.team, created_by=self.user, task_type=AITask.Type.FREE_VIDEO, + model_config=self.model, idempotency_key="k-product-face", + ) + with patch("apps.ai.free_video.submit_free_video", return_value=task) as submit: + message, error = submit_confirmed_video( + conversation=self.conversation, user=self.user, confirm_message=card + ) + prompt = submit.call_args.kwargs["params"]["prompt"] + + self.assertEqual(error, "") + self.assertIsNotNone(message) + self.assertEqual(prompt, original) + def test_confirm_without_stored_prompt_reports_instead_of_submitting(self): card = append_message( self.conversation, role="assistant", kind=CreationMessage.Kind.CONFIRM, @@ -794,6 +1074,18 @@ class PresetGuidanceTests(CreationAgentBaseTests): # 光有名字模型只能靠猜,拍法约束必须一起给 self.assertIn("人物面部和身形必须全程一致", system) + def test_product_personification_defaults_to_faceless_expression(self): + conversation = CreationConversation.objects.create( + team=self.team, created_by=self.user, mode="video", preset="商品拟人广告", params={}, + ) + fake = FakeProvider([_text_chunks("开始设计")]) + with patch("apps.ai.creation_agent.build_provider", return_value=fake): + list(stream_creation_agent(conversation=conversation, user=self.user, + text="开始", model_config=self.model)) + system = fake.calls[0]["messages"][0]["content"] + self.assertIn("默认采用无脸拟人", system) + self.assertIn("商品需要说台词时使用画外角色声", system) + def test_unknown_preset_degrades_to_name_only(self): conversation = CreationConversation.objects.create( team=self.team, created_by=self.user, mode="video", preset="前端新加的卡", params={}, @@ -869,4 +1161,93 @@ class CreationChatModelTests(CreationAgentBaseTests): is_default=True, ) picked = get_creation_chat_model(None) - self.assertEqual(picked.id, seed.id) + # 测试迁移已经可能种过同名 Seed 2.1,重点是编排层选到该模型家族, + # 不是强行命中本测试后建的重复记录。 + self.assertEqual(picked.name, seed.name) + + +class PureChitchatTests(SimpleTestCase): + """打招呼不能触发整理方案 / write_plan。""" + + def test_greetings_are_chitchat(self): + for text in ("hi", "Hi!", "你好", "在吗", "嗯", "好的", "ok", "谢谢"): + self.assertTrue(is_pure_chitchat(text), text) + + def test_creative_asks_are_not_chitchat(self): + for text in ("做条带货视频", "hi 帮我改个方案", "换个商品", "重新写方案"): + self.assertFalse(is_pure_chitchat(text), text) + + +class CreativeIntentTests(SimpleTestCase): + """闲聊不出方案工具;明确要做片才放开。""" + + def test_idle_chat_has_no_creative_intent(self): + for text in ("hi", "今天天气不错", "这个功能怎么用", "你们支持哪些模型", "随便聊聊"): + self.assertFalse(has_creative_intent(text), text) + + def test_refs_alone_do_not_count(self): + self.assertFalse(has_creative_intent("看看", [{"type": "product", "id": "1"}])) + self.assertFalse(has_creative_intent("", [{"type": "product", "id": "1"}])) + + def test_creative_asks_count(self): + for text in ( + "做条带货视频", + "帮我出个方案", + "重新写方案", + "出一张主图", + "改成 9:16", + "把商品塑造为有性格的拟人角色,创作一条轻快、有趣的短广告。", + ): + self.assertTrue(has_creative_intent(text), text) + + def test_short_continue_phrases_are_explicit_continuations(self): + for text in ("继续", "接着做", "往下做吧", "就按这个", "直接来"): + self.assertTrue(is_continue_intent(text), text) + + for text in ("好的", "嗯", "先不用", "今天怎么样"): + self.assertFalse(is_continue_intent(text), text) + + +class ToolGateTests(CreationAgentBaseTests): + def test_empty_conversation_greeting_is_human_and_actionable(self): + events = _events(stream_creation_agent( + conversation=self.conversation, + user=self.user, + text="在吗", + model_config=self.model, + )) + replies = [ + event["message"]["text"] + for event in events + if event.get("type") == "message" and event["message"]["role"] == "assistant" + ] + self.assertEqual(replies, ["我在。想做什么,直接丢一句想法给我就行。"]) + self.assertNotIn("有需要再说", replies[0]) + + def test_idle_chat_hides_plan_tools(self): + fake = FakeProvider([_text_chunks("哈哈,有需要再说")]) + with patch("apps.ai.creation_agent.build_provider", return_value=fake): + list(stream_creation_agent( + conversation=self.conversation, user=self.user, + text="今天天气不错", model_config=self.model, + )) + self.assertTrue(fake.calls) + names = {t["function"]["name"] for t in fake.calls[0]["extra_body"]["tools"]} + self.assertNotIn("write_strategy", names) + self.assertNotIn("write_plan", names) + self.assertNotIn("ask_user", names) # 闲聊不给追问卡,免得推销出片 + self.assertIn("search_library", names) + + def test_continue_after_existing_brief_keeps_creation_tools(self): + append_message(self.conversation, role="user", text="帮我做一张商品主图") + self.assertTrue(session_has_creative_context(self.conversation)) + fake = FakeProvider([_text_chunks("那就接着往下做。")]) + with patch("apps.ai.creation_agent.build_provider", return_value=fake): + list(stream_creation_agent( + conversation=self.conversation, + user=self.user, + text="继续", + model_config=self.model, + )) + names = {tool["function"]["name"] for tool in fake.calls[0]["extra_body"]["tools"]} + self.assertIn("generate_image", names) diff --git a/core/backend/apps/ai/test_free_video.py b/core/backend/apps/ai/test_free_video.py index 4a64663..f457dee 100644 --- a/core/backend/apps/ai/test_free_video.py +++ b/core/backend/apps/ai/test_free_video.py @@ -110,7 +110,25 @@ class BuildContentItemsTests(TestCase): self.assertEqual(roles, ["reference_image", "reference_image", "reference_video"]) self.assertEqual(built["video_duration_total"], 3.0) + def test_semantic_types_character_product_not_skipped(self): + """全能创作 references 用 type=character/product,不能再静默 skipped。""" + refs = [ + {"url": "http://x/person.png", "type": "character", "label": "男生", "source": "upload"}, + {"url": "http://x/bottle.png", "type": "product", "label": "100nomur", "source": "upload"}, + {"url": "http://x/tri.png", "type": "product", "label": "100nomur三视图", "source": "upload"}, + ] + built = build_content_items(team=self.team, prompt="@男生 拿着 @100nomur", mode="universal", references=refs) + self.assertEqual(built["image_n"], 3) + self.assertEqual(len(built["content_items"]), 3) + self.assertEqual( + [i.get("role") for i in built["content_items"]], + ["reference_image", "reference_image", "reference_image"], + ) + self.assertIn("图片1", built["api_prompt"]) + self.assertIn("图片2", built["api_prompt"]) + def test_keyframe_roles(self): + refs = [ {"url": "http://x/f.png", "type": "image", "role": "first_frame"}, {"url": "http://x/l.png", "type": "image", "role": "last_frame"}, diff --git a/core/backend/apps/ai/test_video_caption_policy.py b/core/backend/apps/ai/test_video_caption_policy.py index 05855e5..607b1d1 100644 --- a/core/backend/apps/ai/test_video_caption_policy.py +++ b/core/backend/apps/ai/test_video_caption_policy.py @@ -5,6 +5,7 @@ from apps.ai.services import ( NO_EMBEDDED_CAPTIONS_TAIL, OPENING_SHOT_DIRECTIVE, enforce_no_embedded_captions, + rewrite_speech_as_audio_only, strip_caption_directives, ) @@ -90,6 +91,24 @@ class VideoCaptionPolicyTests(SimpleTestCase): self.assertTrue(prompt.startswith(NO_EMBEDDED_CAPTIONS_REQUIREMENT)) + + def test_rewrites_koubo_as_audio_only(self): + """全能创作「口播:」必须改成仅音频抬头,否则模型会把口播原文烧成字幕。""" + raw = "0-3s:近景\n口播:这开俩小时车,困得眼睛都快粘一块了。" + cleaned = rewrite_speech_as_audio_only(raw) + self.assertNotIn("口播:", cleaned) + self.assertIn("人声(仅音频,由人物口型与配音表达,不出现在画面上):这开俩小时车,困得眼睛都快粘一块了。", cleaned) + prompt = enforce_no_embedded_captions(raw) + self.assertNotIn("口播:", prompt) + self.assertIn("仅音频", prompt) + self.assertTrue(prompt.startswith(NO_EMBEDDED_CAPTIONS_REQUIREMENT)) + + def test_audio_only_prefix_not_double_wrapped(self): + line = "人声(仅音频,由人物口型与配音表达,不出现在画面上):已经包过" + self.assertEqual(rewrite_speech_as_audio_only(line), line) + + + class OpeningShotDirectiveTests(SimpleTestCase): """实测:一条片子出四段,只有开场那段带字幕,后三段干净 —— 模型在「电商口播开场」语境下 有「钩子 = 打一行大字」的强先验。开场段单独给一条正面指令把它掰回来。""" diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 1e0894c..aa09eeb 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -1,4 +1,5 @@ import logging +import re import uuid from django.db import transaction @@ -21,7 +22,7 @@ from apps.products.models import Product from .generation_errors import classify_generation_error, public_error_for_task from .creation import append_message, sync_generating_messages -from .creation_agent import apply_confirm_params, apply_session_params, stream_creation_agent, submit_confirmed_image, submit_confirmed_video +from .creation_agent import apply_confirm_params, apply_session_params, stream_creation_agent, submit_confirmed_image, submit_confirmed_video, _sse, _message_payload from .mentions import TYPE_LABELS, VALID_TYPES, refs_from_elicit_answers, search_mentions from .models import AITask, CreationConversation, CreationMessage, ImageConversation, ModelConfig from .serializers import ( @@ -38,6 +39,84 @@ from .services import enqueue_standalone_images logger = logging.getLogger(__name__) +def _normalise_chat_choice(value: str) -> str: + return re.sub(r"[^0-9a-zA-Z一-鿿]+", "", str(value or "")).lower() + + +def _chat_answer_for_field(field: dict, text: str, team=None): + """把自然输入映射回字段值;认不出时保留原话,让 Agent 自己理解。""" + raw = str(text or "").strip() + if field.get("type") == "asset" and team is not None: + answer_norm = _normalise_chat_choice(raw) + types = [item for item in (field.get("asset_types") or []) if item in TYPE_LABELS] or None + hits = search_mentions(team, q="", types=types, limit=50) + if re.search(r"(你来定|你定|你帮我定|你帮我选|帮我挑|没想好|随便|都行)", raw): + # 用户把选择权交给 Agent 时静默选一个可用素材,不把整份库甩回聊天区。 + if hits: + return str(hits[0].get("name") or raw) + contained = [ + hit for hit in hits + if _normalise_chat_choice(hit.get("name") or "") + and _normalise_chat_choice(hit.get("name") or "") in answer_norm + ] + if contained: + # 「就用净颜精华吧」也能绑定净颜精华;同名片段优先取名字更完整的。 + contained.sort(key=lambda hit: len(_normalise_chat_choice(hit.get("name") or "")), reverse=True) + return str(contained[0].get("name") or raw) + if field.get("type") != "single": + if field.get("type") == "multi": + answer_norm = _normalise_chat_choice(raw) + matched = [] + for option in field.get("options") or []: + value = str(option.get("value") or "") + label = str(option.get("label") or value) + token = _normalise_chat_choice(label) + if token and token in answer_norm: + matched.append(value) + return matched or [raw] + return raw + + options = [item for item in (field.get("options") or []) if isinstance(item, dict)] + answer_norm = _normalise_chat_choice(raw) + for option in sorted( + options, + key=lambda item: len(_normalise_chat_choice(item.get("label") or item.get("value") or "")), + reverse=True, + ): + value = str(option.get("value") or "") + label_norm = _normalise_chat_choice(option.get("label") or value) + value_norm = _normalise_chat_choice(value) + if answer_norm in {label_norm, value_norm} or ( + label_norm and label_norm in answer_norm + ) or ( + value_norm and value_norm in answer_norm + ): + return value + # 「10」也能命中「10 秒」。同一个数字对应多个模型名时不擅自猜。 + answer_digits = re.findall(r"\d+(?:\.\d+)?", raw) + numeric_matches = [] + if answer_digits: + for option in options: + label = str(option.get("label") or option.get("value") or "") + if re.findall(r"\d+(?:\.\d+)?", label) == answer_digits: + numeric_matches.append(str(option.get("value") or label)) + if len(numeric_matches) == 1: + return numeric_matches[0] + return raw + + +def _pending_chat_question(conversation: CreationConversation) -> CreationMessage | None: + """找最近一条未回答的新式追问;卡片既可点选,也兼容直接输入。""" + candidates = conversation.messages.filter( + kind=CreationMessage.Kind.ELICIT + ).order_by("-seq")[:20] + for message in candidates: + payload = message.payload or {} + if payload.get("interaction") in {"chat", "asset_picker"} and not payload.get("submitted"): + return message + return None + + class GenerateImageView(APIView): """独立生图(不绑项目)· 图片创作/模特图/平台套图共用 —— **异步**。 @@ -1380,9 +1459,48 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): kind = str(request.data.get("kind") or "text") text = str(request.data.get("text") or "").strip() refs = request.data.get("refs") or [] + record_user_message = True + force_creative_turn = False + continuation_instruction = "" if not isinstance(refs, list): return JsonResponse({"detail": "refs 必须是数组"}, status=400) + if kind == "text" and text: + pending = _pending_chat_question(conversation) + if pending is not None: + payload = dict(pending.payload or {}) + fields = [item for item in (payload.get("fields") or []) if isinstance(item, dict)] + if fields: + field = fields[0] + answers = { + str(field.get("key") or "answer"): + _chat_answer_for_field(field, text, conversation.team) + } + payload["answers"] = answers + payload["submitted"] = True + payload["answered_via"] = "chat" + pending.payload = payload + pending.save(update_fields=["payload", "updated_at"]) + + params_changed = apply_session_params(conversation, fields, answers) + existing = { + (item.get("type"), str(item.get("id"))) + for item in refs if isinstance(item, dict) + } + for extra in refs_from_elicit_answers(conversation.team, fields, answers): + mark = (extra.get("type"), str(extra.get("id"))) + if mark not in existing: + refs.append(extra) + existing.add(mark) + + force_creative_turn = True + continuation_instruction = ( + "用户刚用输入框回答了你上一句问题。直接基于这条真实回答继续原任务;" + "不要复述答案,不要回复收到,也不要问要不要继续或要不要生成。" + ) + if params_changed: + continuation_instruction += " 会话参数已更新,旧方案作废,按新参数重新产出。" + if kind == "confirm": reply_to = str(request.data.get("reply_to") or "").strip() card = conversation.messages.filter( @@ -1451,22 +1569,63 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): payload["submitted"] = True card.payload = payload card.save(update_fields=["payload", "updated_at"]) - labels = {f["key"]: f["label"] for f in payload.get("fields", [])} - text = ";".join( - f"{labels.get(k, k)}:{'、'.join(v) if isinstance(v, list) else v}" - for k, v in answers.items() - ) - if apply_session_params(conversation, payload.get("fields") or [], answers): - text = f"{text}。请按新的会话参数重新写方案,旧方案作废" - # 点选商品/角色必须钉成 Ref:模型常把选项做成单选文字,前端只回 answers。 - refs = list(refs) - existing = {(item.get("type"), str(item.get("id"))) for item in refs if isinstance(item, dict)} - for extra in refs_from_elicit_answers(conversation.team, payload.get("fields") or [], answers): - mark = (extra.get("type"), str(extra.get("id"))) - if mark in existing: - continue - refs.append(extra) - existing.add(mark) + + # 素材选择闸门:用户愿意时再展开列表;跳过则直接沿原任务继续。 + if payload.get("phase") == "gate": + choice = str(answers.get("_asset_gate") or "").strip() + if choice == "send": + pending = payload.get("pending_fields") or [] + pick = append_message( + conversation, + role="assistant", + kind=CreationMessage.Kind.ELICIT, + payload={ + "phase": "pick", + "fields": pending, + "submitted": False, + "answers": {}, + }, + ) + + def _gate_send_stream(): + yield _sse({"type": "message", "message": _message_payload(pick)}) + yield _sse({"type": "done"}) + + response = StreamingHttpResponse( + _gate_send_stream(), content_type="text/event-stream" + ) + response["Cache-Control"] = "no-cache" + response["X-Accel-Buffering"] = "no" + return response + # 卡片本身已经记录了用户的选择。不要再伪造一条黑色用户气泡; + # 继续原任务,并明确告诉模型不要再次追问同一项素材。 + 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。 + refs = list(refs) + existing = {(item.get("type"), str(item.get("id"))) for item in refs if isinstance(item, dict)} + for extra in refs_from_elicit_answers(conversation.team, payload.get("fields") or [], answers): + mark = (extra.get("type"), str(extra.get("id"))) + if mark in existing: + continue + refs.append(extra) + existing.add(mark) + # 答案已显示在追问卡里,不再复制成一条用户消息。让 Agent 直接往下做。 + text = "" + record_user_message = False + force_creative_turn = True + continuation_instruction = ( + "用户已经完成刚才的选择。直接基于卡片答案继续原任务,不要复述选项,不要只回复收到。" + ) + if params_changed: + continuation_instruction += " 会话参数已经更新,旧方案作废,按新参数重新产出方案。" elif not text and not refs: return JsonResponse({"detail": "消息不能为空"}, status=400) @@ -1485,6 +1644,9 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): text=text, refs=refs, model_config=model_config, + record_user_message=record_user_message, + force_creative_turn=force_creative_turn, + continuation_instruction=continuation_instruction, ) response = StreamingHttpResponse(stream, content_type="text/event-stream") response["Cache-Control"] = "no-cache" diff --git a/core/backend/apps/projects/test_quick_create.py b/core/backend/apps/projects/test_quick_create.py index e158ead..7b14731 100644 --- a/core/backend/apps/projects/test_quick_create.py +++ b/core/backend/apps/projects/test_quick_create.py @@ -1367,3 +1367,4 @@ class QuickCreateCoordinatorTests(TestCase): + diff --git a/core/frontend/src/omni-session-page.css b/core/frontend/src/omni-session-page.css index 492e661..4b32969 100644 --- a/core/frontend/src/omni-session-page.css +++ b/core/frontend/src/omni-session-page.css @@ -1528,6 +1528,35 @@ display: none; } +.omni-chat-bubble.is-reasoning { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 8px; + color: #5c6570; +} + +.omni-think-head { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: #5c6570; +} + +.omni-think-text { + margin: 0; + font-size: 12px; + line-height: 1.65; + color: #8a93a0; + white-space: pre-wrap; + word-break: break-word; + max-height: 168px; + overflow-y: auto; + border-left: 1px solid rgba(15, 23, 42, 0.08); + padding-left: 10px; +} + @keyframes omniShimmer { 0% { background-position: 100% 0; } 100% { background-position: -100% 0; } @@ -1672,3 +1701,45 @@ white-space: pre-wrap; word-break: break-word; } + + +.omni-gate-bubble { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; + max-width: min(520px, calc(100% - 44px)); +} + +.omni-gate-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.omni-gate-actions button { + padding: 8px 14px; + border: 1px solid rgba(34, 42, 54, .12); + border-radius: 999px; + background: #fff; + color: #575d66; + font-size: 13px; + cursor: pointer; +} + +.omni-gate-actions button.primary { + border-color: var(--klein); + background: var(--klein); + color: #fff; +} + +.omni-gate-actions button:disabled { + opacity: .55; + cursor: not-allowed; +} + +.omni-gate-answered { + margin: 0; + font-size: 12px; + color: #8a93a0; +} diff --git a/core/frontend/src/routes/omni-session.tsx b/core/frontend/src/routes/omni-session.tsx index d8ee1f6..503a467 100644 --- a/core/frontend/src/routes/omni-session.tsx +++ b/core/frontend/src/routes/omni-session.tsx @@ -239,10 +239,7 @@ function PromptFileCard({ ); } -/** - * 追问卡:后端 ask_user 工具的落点(设计稿里没有这张卡,新增)。 - * 提交后变只读 —— 重复提交后端会 409。 - */ +/** 追问默认是普通聊天气泡;下面的卡片只为兼容旧会话数据。 */ function ElicitCard({ message, disabled, @@ -257,12 +254,13 @@ function ElicitCard({ const fields = ((message.payload.fields as CreationField[] | undefined) || []).filter(Boolean); const submitted = Boolean(message.payload.submitted); const saved = (message.payload.answers as Record | undefined) || {}; + const interaction = String(message.payload.interaction || ""); const [answers, setAnswers] = useState>(saved); const [assetOptions, setAssetOptions] = useState>({}); const [assetPicked, setAssetPicked] = useState>({}); useEffect(() => { - if (submitted) return; + if (submitted || interaction === "chat") return; // 选素材字段要现拉候选:后端只给了 asset_types,具体有哪些素材是团队数据 const assetFields = fields.filter((f) => f.type === "asset"); if (assetFields.length === 0) return; @@ -290,7 +288,22 @@ function ElicitCard({ cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [message.id, submitted]); + }, [message.id, submitted, interaction]); + + if (interaction === "chat") { + return ( +
+ + + +
+

+ {message.text || fields[0]?.label || "这项你想怎么定?"} +

+
+
+ ); + } const toggleMulti = (key: string, value: string) => { setAnswers((prev) => { @@ -306,6 +319,48 @@ function ElicitCard({ return Array.isArray(value) ? value.length > 0 : Boolean(value); }); + const phase = String(message.payload.phase || "pick"); + const gateField = fields[0]; + const gateLabel = gateField?.label || "要我把列表发给你选吗?"; + + // 素材选择闸门:先轻量询问,用户愿意时再展开真正的选择卡 + if (phase === "gate") { + const gateAnswer = saved._asset_gate; + const selectedLabel = gateField?.options?.find((option) => option.value === gateAnswer)?.label; + return ( +
+ + + +
+

{gateLabel}

+ {!submitted ? ( +
+ {(gateField?.options || []).map((option) => ( + + ))} +
+ ) : ( +

+ {selectedLabel ? `已选择:${selectedLabel}` : "已完成选择"} +

+ )} +
+
+ ); + } + return (
@@ -615,6 +670,37 @@ function keepUnackedLocals(prev: CreationMessage[], incoming: CreationMessage[]) return collapseDupUser(leftover.length ? [...incoming, ...leftover] : incoming); } +function withoutLegacyGateArtifacts(list: CreationMessage[]): CreationMessage[] { + /** + * 旧版本点「先不用」时会落一条系统伪造的 user 消息,紧接着再回一句空话。 + * 数据先保留,这里只在能确认完整错误序列时隐藏,避免老会话刷新后还显示这组脏气泡。 + */ + const hidden = new Set(); + for (let index = 1; index < list.length; index += 1) { + const gate = list[index - 1]; + const echo = list[index]; + const gatePayload = gate?.payload || {}; + if ( + gate?.kind !== "elicit" + || gatePayload.phase !== "gate" + || (gatePayload.answers as Record | undefined)?._asset_gate !== "skip" + || echo?.role !== "user" + || echo.text !== "先不选素材也可以,有需要再说。" + ) continue; + + hidden.add(echo.id); + const deadEnd = list[index + 1]; + if ( + deadEnd?.role === "assistant" + && deadEnd.kind === "text" + && /^在的[,,]有需要再说(?:一声)?[。.]?$/.test(deadEnd.text.trim()) + ) { + hidden.add(deadEnd.id); + } + } + return hidden.size ? list.filter((message) => !hidden.has(message.id)) : list; +} + function ProcessCard({ payload }: { payload: Record }) { const isVideo = payload.kind === "video"; return ( @@ -684,6 +770,7 @@ export function OmniSessionPage({ const fileInputRef = useRef(null); const [streaming, setStreaming] = useState(false); const [liveText, setLiveText] = useState(""); + const [liveReasoning, setLiveReasoning] = useState(""); const [activeTool, setActiveTool] = useState(""); const [mentionMenuOpen, setMentionMenuOpen] = useState(false); const [mentionTab, setMentionTab] = useState("asset"); @@ -805,6 +892,7 @@ export function OmniSessionPage({ () => messages.some((message) => message.kind === "generating"), [messages] ); + const visibleMessages = useMemo(() => withoutLegacyGateArtifacts(messages), [messages]); // 出片在 worker 里跑。刷新后 GENERATING 还在库里,进来立刻拉一次再轮询, // 不要等流式对话结束,也不要空等一个间隔才看见进度。 @@ -847,6 +935,7 @@ export function OmniSessionPage({ if (event.type === "message") { const message = event.message as CreationMessage; setLiveText(""); + setLiveReasoning(""); setMessages((prev) => collapseDupUser(replaceLocalUser(prev, message))); if (message.role === "user" && pendingUserIdRef.current) { pendingUserIdRef.current = null; @@ -854,6 +943,10 @@ export function OmniSessionPage({ } return; } + if (event.type === "reasoning") { + setLiveReasoning((prev) => prev + String(event.text || "")); + return; + } if (event.type === "delta") { setLiveText((prev) => prev + String(event.text || "")); return; @@ -864,6 +957,7 @@ export function OmniSessionPage({ } if (event.type === "error") { setLiveText(""); + setLiveReasoning(""); } }, []); @@ -873,6 +967,7 @@ export function OmniSessionPage({ streamingRef.current = true; setStreaming(true); setLiveText(""); + setLiveReasoning(""); setActiveTool(""); const isTextTurn = payload.kind !== "elicit_answer"; let localId: string | null = null; @@ -920,6 +1015,7 @@ export function OmniSessionPage({ streamingRef.current = false; setStreaming(false); setLiveText(""); + setLiveReasoning(""); setActiveTool(""); abortRef.current = null; // 流结束再拉一次:worker 可能已经把 GENERATING 改成 RESULT, @@ -1145,7 +1241,7 @@ export function OmniSessionPage({
- {messages.map(renderMessage)} + {visibleMessages.map(renderMessage)} {/* 流式中的临时气泡。挂 is-live 关掉入场动画 —— 它每来一个字符都会 重渲染,带动画的话整条会一直在闪;真消息落地时它被替换掉, 那一下也不该再演一次入场。 */} @@ -1157,9 +1253,21 @@ export function OmniSessionPage({ -
+
{liveText ? (

{liveText}

+ ) : liveReasoning ? ( + <> +
+ + {activeTool ? `${activeTool}…` : "思考中"} +
+

{liveReasoning}

+ ) : ( <>