diff --git a/core/backend/apps/ai/creation_agent.py b/core/backend/apps/ai/creation_agent.py index 59ab4a0..6d166c4 100644 --- a/core/backend/apps/ai/creation_agent.py +++ b/core/backend/apps/ai/creation_agent.py @@ -27,7 +27,7 @@ from django.core.serializers.json import DjangoJSONEncoder from django.db import transaction from .creation import append_message, pin_refs -from .creation_presets import preset_guidance +from .creation_presets import apply_image_preset_prompt, 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, get_seed_text_model, resolve_text_model @@ -188,6 +188,7 @@ def emit_prompt_gate( if not prompt: return [] prompt = apply_product_voice_visual_guard(conversation, prompt) + prompt = apply_product_reality_guard(prompt) messages = [] prompt_file = append_message( conversation, @@ -225,6 +226,7 @@ def emit_final_confirm_gate( if not prompt: return None prompt = apply_product_voice_visual_guard(conversation, prompt) + prompt = apply_product_reality_guard(prompt) credits = 0 if context is not None: try: @@ -321,6 +323,9 @@ video_prompt 是交给出片模型的完整制作文件,不是方案摘要、 - 15 秒 Prompt 通常不少于约 1200 个汉字;短片按时长同比例展开。每一段都要有可拍的连续动作、物理反馈或产品证据,不能用「高级感、展示质感、氛围拉满」代替动作。 - 人声必须是自然口语,约 5.0–5.7 字/秒;钩子前 15 个字禁止「大家好 / 今天分享 / 给你们推荐」。CTA 像朋友提醒,禁止小黄车、立即购买、闭眼入等平台指令腔。 - 全片围绕一个具体情境和一个主卖点推进,卖点必须有可见证据,例如质地、使用动作、前后变化或真实反应。 +- 商品演示先过“真实用途与状态”检查:只展示商品在其已知用途和已提供事实范围内的正常、完整状态; + 禁止无依据出现破损、漏液、渗水、失效、异常变形、脏污,或把商品拿去做超出用途的压力测试。 + 不确定防水、防漏、承重、耐热、容量、材质等关键能力时,不编造测试和结果;要么问用户,要么改用可观察的正常使用动作。 - 画面中不出现新增字幕、花字、标题贴片、弹幕、角标、水印、购物浮层或说明性文字;口播只存在于声音,包装本身原有印刷字除外。 - 结尾单列「全片一致性与禁用项」:重申角色、商品、场景、光线、服装/材质的连续性,以及本片最重要的画面禁用项。 - 用户说「商品说话 / 商品自述 / 商品拟人」时,默认无脸拟人:商品声音是画外角色声,商品本体不做口型、不新增卡通五官;性格只通过整体倾斜、转向、弹跳、进退、镜头和音效表达。只有用户明确要求可见卡通五官时才例外。 @@ -345,6 +350,15 @@ PRODUCT_VOICE_VISUAL_GUARD = ( "与克制幽默。前文若有改造商品外观的动作描述,统一改成商品整体运动的对应表达。" ) +# 这段在最终出片 Prompt 上再加一道确定性约束,避免模型虽然在方案里写了“真实使用”, +# 但生成时又把商品拍进不合理的失效状态。它不替用户补商品性能,只限制无依据的错误演示。 +PRODUCT_REALITY_GUARD = ( + "【商品真实使用约束·最高优先级】商品只按已知真实用途和用户提供的事实展示," + "全程保持正常、完整、可用的状态。没有用户明确提供的性能依据时,不出现破损、漏液、渗水、" + "失效、异常变形、脏污、超载或超出用途的测试;也不把不确定的防水、防漏、承重、耐热、容量、" + "材质等能力拍成已被验证的结果。若关键信息不足,使用可观察的正常操作替代夸张测试。" +) + def apply_product_voice_visual_guard(conversation: CreationConversation, prompt: str) -> str: """商品拟人默认不长脸;用户明确要求可见卡通五官时尊重其创作选择。""" @@ -367,6 +381,14 @@ def apply_product_voice_visual_guard(conversation: CreationConversation, prompt: return f"{base}\n{PRODUCT_VOICE_VISUAL_GUARD}".strip() +def apply_product_reality_guard(prompt: str) -> str: + """所有商品视频在出片前补上物理与用途边界,避免最终模型误演“产品坏了”。""" + base = str(prompt or "").strip() + if not base or PRODUCT_REALITY_GUARD in base: + return base + return f"{base}\n{PRODUCT_REALITY_GUARD}".strip() + + class AgentError(Exception): """Agent 循环里的业务错误,已经是可以直接给用户看的中文。""" @@ -444,9 +466,8 @@ def is_pure_chitchat(user_text: str) -> bool: _GUIDANCE_MARKER_RE = re.compile( r"(" - r"请回复|请直接|直接说|直接丢|告诉我|可以回|下一步|" - r"想改|点「|回复:|回复:|发给我|选一个|怎么回|" - r"继续补充|想做什么" + r"请回复|回复[::]|请直接|请告诉我|请选|点「|直接输入|" + r"选择一个|发「|例如「" r")" ) @@ -474,19 +495,65 @@ def default_reply_hint( if stage == "confirm": return "请在确认卡上核对参数后点「开始生成」,或直接说要改的参数。" if stage == "done": - return "想改哪一处直接说;要重新来就回复「重新来」。" + return "可以回复「再出一版」,也可以选下方想调整的部分。" if has_context: - return "请直接说下一步:改哪里、换素材,或继续推进。" + if is_video: + return "可以回复「继续完善方案」,也可以选下方想调整的方向。" + return "可以回复「按这个方向出图」,也可以选下方想调整的方向。" kind = "短视频" if is_video else "商品图" return f"请直接丢一句想法,例如「帮我做一条{kind}」。" -def apply_reply_hint(message: CreationMessage, hint: str) -> CreationMessage: - """给文字气泡挂上 reply_hint,供前端展示「下一步可以这样回」。""" +def default_reply_options( + conversation: CreationConversation | None = None, + *, + has_context: bool = False, + is_video: bool = True, +) -> list[dict[str, str]]: + """纯文字收束时给前端的可点下一步,避免用户面对一段说明不知道怎么继续。""" + stage = "" + if conversation is not None: + try: + stage = get_video_gate_stage(conversation) + except Exception: # noqa: BLE001 + stage = "" + if stage == "done": + return [ + {"label": "再出一版", "text": "按当前方向再出一版"}, + {"label": "调整画面", "text": "我想调整画面"}, + {"label": "重新来", "text": "重新来"}, + ] + if has_context: + if is_video: + return [ + {"label": "继续完善方案", "text": "继续完善方案"}, + {"label": "换个场景", "text": "我想换个场景"}, + {"label": "改卖点", "text": "我想改卖点"}, + ] + return [ + {"label": "按这个方向出图", "text": "按这个方向出图"}, + {"label": "换个场景", "text": "我想换个场景"}, + {"label": "改人物状态", "text": "我想改人物状态"}, + ] + kind = "短视频" if is_video else "商品图" + return [ + {"label": f"帮我做一条{kind}" if is_video else "帮我做一张商品图", "text": f"帮我做一条{kind}" if is_video else "帮我做一张商品图"}, + {"label": "我先说想法", "text": "我想做一个新的创作"}, + ] + + +def apply_reply_hint(message: CreationMessage, hint: str, options: list[dict[str, str]]) -> CreationMessage: + """给文字气泡挂上回复示例和可点选项,供前端把下一步直接交到用户手里。""" payload = dict(message.payload or {}) - if str(payload.get("reply_hint") or "").strip(): + changed = False + if not str(payload.get("reply_hint") or "").strip(): + payload["reply_hint"] = hint + changed = True + if not isinstance(payload.get("reply_options"), list) or not payload.get("reply_options"): + payload["reply_options"] = options + changed = True + if not changed: return message - payload["reply_hint"] = hint message.payload = payload message.save(update_fields=["payload"]) return message @@ -516,11 +583,12 @@ def ensure_turn_guides( return [] hint = default_reply_hint(conversation, has_context=has_context, is_video=is_video) + options = default_reply_options(conversation, has_context=has_context, is_video=is_video) events: list[dict] = [] if last_text_bubble is not None: if text_already_guides(last_text_bubble.text): return [] - updated = apply_reply_hint(last_text_bubble, hint) + updated = apply_reply_hint(last_text_bubble, hint, options) events.append({"type": "message", "message": _message_payload(updated)}) return events @@ -528,7 +596,7 @@ def ensure_turn_guides( conversation, role="assistant", text=hint, - payload={"reply_hint": hint}, + payload={"reply_hint": hint, "reply_options": options}, ) events.append({"type": "message", "message": _message_payload(guide)}) return events @@ -542,7 +610,7 @@ _CREATIVE_INTENT_RE = re.compile( r"(" r"帮我做|帮我拍|帮我出|帮我写|帮我改|帮我生成|" r"创作[一两]?[条个张]?|创作(?:一条|个|短)?|" - r"做[一两]?[条个张](?:视频|片|图|广告)?|拍[一两]?[条个](?:视频|片|广告)?|" + r"做[一两]?[条个张](?:视频|片|图|广告)?|拍[一两]?[条个张](?:视频|片|图|广告)?|" r"出[一两]?[条个张](?:视频|片|图|广告)?|出片|出图|出方案|写方案|改方案|重写方案|重新写|" r"生成(?:一下|一张|几张|一条)?(?:视频|图|片|广告|脚本)?|做条|做个片|短视频|带货视频|短广告|广告片|带货片|" r"换卖点|改卖点|换剧情|改剧情|重做|重新出|重新来|再来一次|从头开始|重新做|按这个出|确认出片|" @@ -1207,6 +1275,7 @@ def _run_generate_image(context: AgentContext, args: dict) -> tuple[dict, list]: prompt = str(args.get("prompt") or "").strip() if not prompt: raise AgentError("生成失败:模型没有给出画面描述") + prompt = apply_image_preset_prompt(context.conversation.preset, prompt) params = context.conversation.params or {} resolved = resolve_refs(context.team, context.conversation.pinned_refs or []) @@ -1237,6 +1306,7 @@ def _video_submit_params(context: AgentContext, prompt: str) -> tuple[dict, list params = context.conversation.params or {} resolved = resolve_refs(context.team, context.conversation.pinned_refs or []) prompt = apply_product_voice_visual_guard(context.conversation, prompt) + prompt = apply_product_reality_guard(prompt) submit = { "prompt": prompt, "feature": "omni_create", @@ -1493,6 +1563,10 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c "- 每一轮回复结束时,用户必须知道下一步怎么做:要么调用 ask_user 弹出可选项," "要么在文字里明确告诉用户该回复什么(例如「请回复:1… 2…」或「直接说想改的卖点」)。", "- 禁止只丢一段解释/分析就结束、让用户不知道该回什么。", + "- 用户已经给出可直接执行的图片需求(主体、场景或氛围已足够)时,直接调用 generate_image 进入确认卡;" + "不要只描述你准备怎么拍,再让用户继续补一句。", + "- 禁止用「有想调整可以直接说」这类泛泛收尾代替引导;要么 ask_user 给出可选项," + "要么给出一句用户可以直接点击或复制回复的话。", "- 缺信息或要用户做选择时:优先 ask_user(带 options 的 single/multi,或 asset)。", "- 纯说明/判断/闲聊回应:文末必须带一句可执行的回复指引。", "- 视频闸门的 step_confirm / 积分确认卡本身已是引导,不要再口头追问流程。", @@ -1922,6 +1996,9 @@ def iter_creation_agent_events( "reply_hint": default_reply_hint( conversation, has_context=False, is_video=conversation.mode == CreationConversation.Mode.VIDEO ), + "reply_options": default_reply_options( + conversation, has_context=False, is_video=conversation.mode == CreationConversation.Mode.VIDEO + ), }, ) yield {"type": "message", "message": _message_payload(reply)} @@ -1941,6 +2018,9 @@ def iter_creation_agent_events( "reply_hint": default_reply_hint( conversation, has_context=False, is_video=conversation.mode == CreationConversation.Mode.VIDEO ), + "reply_options": default_reply_options( + conversation, has_context=False, is_video=conversation.mode == CreationConversation.Mode.VIDEO + ), }, ) yield {"type": "message", "message": _message_payload(reply)} @@ -2414,6 +2494,7 @@ def _dispatch_tool( if not video_prompt: return {"payload": {"error": "video_prompt 不能为空,请把完整出片指令写进去"}}, False video_prompt = apply_product_voice_visual_guard(context.conversation, video_prompt) + video_prompt = apply_product_reality_guard(video_prompt) card = _coerce_plan_card_args(args if isinstance(args, dict) else {}) if not card["usp"] or not card["points"]: return { @@ -2463,6 +2544,7 @@ def _dispatch_tool( if not video_prompt: return {"payload": {"error": "video_prompt 不能为空,请把完整出片指令写进去"}}, False video_prompt = apply_product_voice_visual_guard(context.conversation, video_prompt) + video_prompt = apply_product_reality_guard(video_prompt) emitted = emit_prompt_gate(context.conversation, video_prompt) if not emitted: return {"payload": {"error": "无法写出 Prompt 卡"}}, False @@ -2479,6 +2561,7 @@ def _dispatch_tool( prompt = str(args.get("prompt") or "").strip() if not prompt: return {"payload": {"error": "生成失败:模型没有给出画面描述"}}, False + prompt = apply_image_preset_prompt(context.conversation.preset, prompt) # 出图也走确认卡:用户先看当前模型/比例/张数,点了才提交。 credits = estimate_image_credits(context) confirm = append_message( diff --git a/core/backend/apps/ai/creation_presets.py b/core/backend/apps/ai/creation_presets.py index 16ff46c..855598c 100644 --- a/core/backend/apps/ai/creation_presets.py +++ b/core/backend/apps/ai/creation_presets.py @@ -9,6 +9,15 @@ key 必须是同一个中文名(会话建的时候原样存进 CreationConversat from __future__ import annotations VIDEO_PRESETS: dict[str, str] = { + "真实使用演示": ( + "可信实拍型商品短片。先按商品真实用途、可见结构和已有卖点安排演示,再谈氛围和转化;" + "每一个卖点都必须由看得见的使用步骤、材质细节或结果承接,不能凭空加性能。" + "商品始终保持正常、完好的使用状态:禁止无依据拍成破损、漏液、渗水、变形、脏污或失效," + "也禁止拿商品做其用途和已知能力之外的压力测试。" + "例如垃圾袋默认呈现铺入垃圾桶、投放干燥日常垃圾、提起与扎口收纳;" + "没有明确的防漏/承重/容量事实时,不用装水、尖锐物或超载来证明性能。" + "用户没给关键功能或使用边界时,只问一次缺失事实;其余镜头用可观察的正常动作补齐。" + ), "剧情反转带货": ( "轻剧情短片。必须有一个具体的困境场景 → 意外转折 → 商品成为解决问题的关键道具。" "商品不能在开头硬推,要等冲突立住了再自然介入。禁止把普通不便夸成严重后果。" @@ -46,17 +55,22 @@ VIDEO_PRESETS: dict[str, str] = { } IMAGE_PRESETS: dict[str, str] = { - "商品场景套图": ( - "一组风格统一的电商图:主图(干净突出商品)、场景图(真实使用环境)、细节图(材质/工艺特写)。" - "三张的光线、色调、质感必须是同一套。" - ), - "极简棚拍": "干净背景、柔和投影、主体明确居中。大量留白,不加多余道具。适合主图和详情页头图。", - "清透自然光人像": "自然光,保留真实肤质和毛孔,不磨皮不过曝。氛围清透,人物状态放松自然。", - "生活方式场景": "把商品放进真实生活空间和使用动作里,强调自然可信的生活气息,不要摆拍感。", - "高级奢华质感": "深色环境 + 局部高光 + 材质细节特写,强化品牌高级感。对比强但不失细节。", - "复古胶片风格": "低饱和、细腻颗粒、柔和对比、偏暖或偏青的胶片色调。怀旧情绪但不脏。", + "电影感": "电影级摄影质感,宽容度高,强烈但自然的明暗层次,深邃阴影,柔和高光,冷暖色彩对比,轻微青橙调,真实环境光,局部轮廓光,适度暗角,细腻胶片颗粒,低饱和高级色调,电影调色,富有叙事感,真实摄影质感,避免过度HDR。", + "日式清新": "日系清新摄影,高明度,低对比,柔和自然光,干净通透的空气感,白色与浅色占比高,低饱和淡彩,轻微偏冷色温,柔和肤色与高光,阴影浅淡,画面轻盈,曝光稍高但保留细节,清透自然,简洁治愈。", + "复古胶片": "复古胶片摄影,模拟35mm胶片质感,暖棕与米黄色调,轻微褪色,黑位微微抬起,低对比度,柔和高光,细腻胶片颗粒,轻微色偏,适度暗角,少量灰尘与划痕,真实模拟胶片扫描质感,怀旧而自然,避免强烈漏光与大面积炫光。", + "暖调生活感": "温暖生活方式摄影,自然暖阳,柔和金黄色调,真实环境光,低至中等对比,温暖肤色,柔和阴影,轻微逆光,舒适自然的曝光,细腻质感,生活气息浓厚,真实、不刻意、治愈温馨,轻微暖色电影调色。", + "高质感杂志": "高级时尚杂志摄影,编辑大片质感,中低饱和,高级灰色调,精准曝光,硬朗且具有雕塑感的光影,深邃阴影,清晰轮廓,高级商业摄影质感,细节锐利但不过度锐化,极简色彩控制,克制、高级、时尚、冷静。", + "梦幻柔光": "梦幻柔光摄影,低对比度,柔焦效果,柔和高光扩散,轻微Bloom光晕,浅色粉彩色调,柔和暖白曝光,低饱和,空气感与雾化感,细腻柔软的光线,背景高光形成柔美散景,浪漫、轻盈、唯美,保留主体细节。", + "黑金高级": "黑金奢华商业摄影,深黑背景,金色高光,低调曝光,高对比度,强烈明暗塑形,精准轮廓光,金属反射质感,高级镜面反射,深邃黑位,暖金色点缀,低饱和、精致、克制、奢华,高端广告摄影质感。", + "冷峻科技": "现代科技商业摄影,冷灰蓝色调,低饱和,高级冷色温,干净硬朗的光线,精准边缘光,金属与玻璃质感清晰,适度高对比,深灰阴影,简洁现代,锐利清晰但不过度数字化,真实产品摄影质感,理性、专业、克制,避免科幻全息、机器人和过度AI视觉元素。", + "赛博朋克": "赛博朋克摄影风格,蓝紫与洋红霓虹色调,高饱和霓虹光,强烈冷暖色彩碰撞,高对比,深黑阴影,彩色轮廓光,霓虹反射,局部RGB光效,未来都市视觉语言,强烈视觉冲击,暗部层次丰富,真实摄影质感,科技感与潮流感结合,避免雨天效果。", } +# 来源于「图片创作预设.docx」的统一生成收束。用户界面只展示预设标题; +# 风格文字和负面约束只在真正提交出图时拼进模型 Prompt。 +IMAGE_PRESET_QUALITY_SUFFIX = "photorealistic, professional photography, realistic lighting, natural texture, high detail" +IMAGE_PRESET_NEGATIVE_CONSTRAINTS = "避免过度HDR、过度锐化、塑料质感、廉价AI感、异常高饱和、过曝、死黑阴影、过强光晕、明显CG感、过度磨皮。" + ALL_PRESETS: dict[str, str] = {**VIDEO_PRESETS, **IMAGE_PRESETS} @@ -64,3 +78,20 @@ def preset_guidance(name: str) -> str: """预设名 → 拍法约束。认不出的名字返回 "" —— 前端加了新卡但这里还没写时, 退回「只有名字」的行为,不要报错。""" return ALL_PRESETS.get((name or "").strip(), "") + + +def apply_image_preset_prompt(name: str, prompt: str) -> str: + """将已选图片预设的风格字段确定性并入实际出图指令,不依赖 Agent 自行复述。""" + base = str(prompt or "").strip() + preset = (name or "").strip() + style = IMAGE_PRESETS.get(preset) + if not base or not style: + return base + marker = f"【图片风格预设】{preset}" + if marker in base: + return base + return ( + f"{base}\n\n{marker}\n{style}\n" + f"画质基线:{IMAGE_PRESET_QUALITY_SUFFIX}\n" + f"负面约束:{IMAGE_PRESET_NEGATIVE_CONSTRAINTS}" + ) diff --git a/core/backend/apps/ai/test_creation_agent.py b/core/backend/apps/ai/test_creation_agent.py index 1ae1cbc..68cace6 100644 --- a/core/backend/apps/ai/test_creation_agent.py +++ b/core/backend/apps/ai/test_creation_agent.py @@ -1279,12 +1279,43 @@ class PresetGuidanceTests(CreationAgentBaseTests): # 前端加了新卡但后端还没写拍法时,退回「只有名字」而不是报错 self.assertIn("前端新加的卡", system) + def test_real_use_preset_reaches_the_system_prompt(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_product_reality_guard_is_added_to_final_prompt(self): + from .creation_agent import apply_product_reality_guard + + prompt = apply_product_reality_guard("拍一条垃圾袋的日常使用演示") + self.assertIn("商品真实使用约束", prompt) + self.assertIn("不出现破损、漏液、渗水", prompt) + # 重复走闸门不会把同一条约束堆进 Prompt 两遍。 + self.assertEqual(apply_product_reality_guard(prompt), prompt) + + def test_image_preset_style_is_added_only_to_the_actual_image_prompt(self): + from .creation_presets import apply_image_preset_prompt + + prompt = apply_image_preset_prompt("电影感", "一只白色香水瓶放在窗边") + self.assertIn("【图片风格预设】电影感", prompt) + self.assertIn("电影级摄影质感", prompt) + self.assertIn("photorealistic, professional photography", prompt) + self.assertIn("避免过度HDR", prompt) + self.assertEqual(apply_image_preset_prompt("电影感", prompt), prompt) + def test_every_frontend_preset_has_guidance(self): - """前端 8 个视频 + 6 个图片预设都要有拍法,漏一个就等于那张卡是摆设。""" + """前端 9 个视频 + 9 个图片预设都要有拍法,漏一个就等于那张卡是摆设。""" from .creation_presets import IMAGE_PRESETS, VIDEO_PRESETS - self.assertEqual(len(VIDEO_PRESETS), 8) - self.assertEqual(len(IMAGE_PRESETS), 6) + self.assertEqual(len(VIDEO_PRESETS), 9) + self.assertEqual(len(IMAGE_PRESETS), 9) self.assertTrue(all(text.strip() for text in {**VIDEO_PRESETS, **IMAGE_PRESETS}.values())) @@ -1382,6 +1413,7 @@ class CreativeIntentTests(SimpleTestCase): "帮我出个方案", "重新写方案", "出一张主图", + "拍一张自然、有温度的生活方式照片", "改成 9:16", "把商品塑造为有性格的拟人角色,创作一条轻快、有趣的短广告。", ): @@ -1612,10 +1644,12 @@ class ReplyGuidanceTests(CreationAgentBaseTests): self.assertTrue(texts) last = texts[-1] self.assertTrue(str((last.get("payload") or {}).get("reply_hint") or "").strip()) + self.assertTrue((last.get("payload") or {}).get("reply_options")) self.conversation.refresh_from_db() stored = self.conversation.messages.filter(role="assistant", kind="text").order_by("-seq").first() self.assertIsNotNone(stored) self.assertTrue(str((stored.payload or {}).get("reply_hint") or "").strip()) + self.assertTrue((stored.payload or {}).get("reply_options")) def test_ensure_turn_guides_skips_when_elicit_open(self): bubble = append_message(self.conversation, role="assistant", text="先看看策略。") @@ -1635,7 +1669,26 @@ class ReplyGuidanceTests(CreationAgentBaseTests): bubble.refresh_from_db() self.assertFalse((bubble.payload or {}).get("reply_hint")) + def test_vague_chat_closing_still_gets_clickable_guidance(self): + bubble = append_message( + self.conversation, + role="assistant", + text="场景和人物状态想调整的话,可以直接说。", + ) + events = ensure_turn_guides( + self.conversation, + turn_has_gate=False, + last_text_bubble=bubble, + has_context=True, + is_video=False, + ) + self.assertTrue(events) + bubble.refresh_from_db() + self.assertIn("按这个方向出图", str((bubble.payload or {}).get("reply_hint") or "")) + self.assertTrue((bubble.payload or {}).get("reply_options")) + def test_text_already_guides_detects_reply_instruction(self): - self.assertTrue(text_already_guides("这个方向能做。直接说想改的卖点。")) + self.assertTrue(text_already_guides("这个方向能做。请回复:按这个方向继续。")) + self.assertFalse(text_already_guides("这个方向能做。有想调整可以直接说。")) self.assertFalse(text_already_guides("这条可以偏清爽一点,突出成分感。")) self.assertIn("请直接", default_reply_hint(self.conversation, has_context=False, is_video=True)) diff --git a/core/frontend/public/assets/image-presets/black-gold.png b/core/frontend/public/assets/image-presets/black-gold.png new file mode 100644 index 0000000..ddcf43c Binary files /dev/null and b/core/frontend/public/assets/image-presets/black-gold.png differ diff --git a/core/frontend/public/assets/image-presets/cinematic.png b/core/frontend/public/assets/image-presets/cinematic.png new file mode 100644 index 0000000..0a8aaba Binary files /dev/null and b/core/frontend/public/assets/image-presets/cinematic.png differ diff --git a/core/frontend/public/assets/image-presets/cyberpunk.png b/core/frontend/public/assets/image-presets/cyberpunk.png new file mode 100644 index 0000000..ba665e9 Binary files /dev/null and b/core/frontend/public/assets/image-presets/cyberpunk.png differ diff --git a/core/frontend/public/assets/image-presets/dreamy-soft.png b/core/frontend/public/assets/image-presets/dreamy-soft.png new file mode 100644 index 0000000..19513d7 Binary files /dev/null and b/core/frontend/public/assets/image-presets/dreamy-soft.png differ diff --git a/core/frontend/public/assets/image-presets/editorial.png b/core/frontend/public/assets/image-presets/editorial.png new file mode 100644 index 0000000..9939ad4 Binary files /dev/null and b/core/frontend/public/assets/image-presets/editorial.png differ diff --git a/core/frontend/public/assets/image-presets/film-vintage.png b/core/frontend/public/assets/image-presets/film-vintage.png new file mode 100644 index 0000000..ae14856 Binary files /dev/null and b/core/frontend/public/assets/image-presets/film-vintage.png differ diff --git a/core/frontend/public/assets/image-presets/japanese-fresh.png b/core/frontend/public/assets/image-presets/japanese-fresh.png new file mode 100644 index 0000000..6f47282 Binary files /dev/null and b/core/frontend/public/assets/image-presets/japanese-fresh.png differ diff --git a/core/frontend/public/assets/image-presets/tech-cool.png b/core/frontend/public/assets/image-presets/tech-cool.png new file mode 100644 index 0000000..5352026 Binary files /dev/null and b/core/frontend/public/assets/image-presets/tech-cool.png differ diff --git a/core/frontend/public/assets/image-presets/warm-lifestyle.png b/core/frontend/public/assets/image-presets/warm-lifestyle.png new file mode 100644 index 0000000..a8d5ea4 Binary files /dev/null and b/core/frontend/public/assets/image-presets/warm-lifestyle.png differ diff --git a/core/frontend/src/omni-create-page.css b/core/frontend/src/omni-create-page.css index bebac32..e6b0e65 100644 --- a/core/frontend/src/omni-create-page.css +++ b/core/frontend/src/omni-create-page.css @@ -137,17 +137,30 @@ box-shadow: 0 24px 58px rgba(21, 29, 42, .14), 0 0 0 4px rgba(0, 47, 167, .055); } +.omni-start-context { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 7px; + margin-bottom: 10px; +} + +.omni-start-context[hidden] { + display: none; +} + .omni-selected-case { width: fit-content; max-width: 100%; + height: 36px; display: flex; align-items: center; justify-content: space-between; - gap: 12px; - margin-bottom: 6px; - padding: 8px 9px 8px 11px; + gap: 6px; + margin: 0; + padding: 0 5px 0 11px; border: 1px solid rgba(0, 47, 167, .13); - border-radius: 10px; + border-radius: 8px; background: #edf5ff; } @@ -170,8 +183,8 @@ } .omni-selected-case button { - width: 27px; - height: 27px; + width: 23px; + height: 23px; display: grid; place-items: center; border-radius: 8px; @@ -189,7 +202,7 @@ display: flex; flex-wrap: wrap; gap: 7px; - margin-top: 7px; + margin: 0; } .omni-start-attachments:empty { @@ -315,6 +328,39 @@ height: 15px; } +.omni-icon-tool.is-uploading { + color: var(--black-alpha-56); + background: var(--background-lighter); + border-color: var(--border-faint); + cursor: wait; +} + +.omni-icon-tool.is-uploading svg, +.omni-upload-status svg { + animation: omni-upload-spin .8s linear infinite; +} + +.omni-upload-status { + height: 34px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 4px; + color: var(--black-alpha-56); + font-size: 12px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.omni-upload-status svg { + width: 14px; + height: 14px; +} + +@keyframes omni-upload-spin { + to { transform: rotate(360deg); } +} + .omni-mention-wrap { position: relative; } @@ -788,6 +834,36 @@ gap: 14px; } +/* 图片预设不需要分类筛选,直接用更舒展的画幅呈现风格本身。 */ +.omni-case-library.is-image-presets .omni-case-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; +} + +.omni-image-preset-intro { + margin: 0 0 14px; + text-align: center; +} + +.omni-image-preset-intro strong, +.omni-image-preset-intro span { + display: block; +} + +.omni-image-preset-intro strong { + color: var(--accent-black); + font-size: 16px; + font-weight: 500; + line-height: 1.4; +} + +.omni-image-preset-intro span { + margin-top: 4px; + color: var(--black-alpha-56); + font-size: 13px; + line-height: 1.8; +} + .omni-case-card { position: relative; min-width: 0; @@ -865,6 +941,11 @@ display: none; } +.omni-case-card[data-mode="image"] .omni-case-visual { + height: auto; + aspect-ratio: 4 / 3; +} + .omni-card-use { position: absolute; right: 10px; @@ -1105,6 +1186,19 @@ -webkit-line-clamp: 2; } +/* 图片预设只展示风格标题,去掉说明后同步收紧卡片底部留白。 */ +.omni-case-card[data-mode="image"] .omni-case-copy { + min-height: 52px; + display: flex; + align-items: center; + padding-top: 10px; + padding-bottom: 10px; +} + +.omni-case-card[data-mode="image"] .omni-case-copy strong { + margin-bottom: 0; +} + .omni-history-page { width: min(1320px, 100%); margin: 0 auto; @@ -1527,6 +1621,10 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } + .omni-case-library.is-image-presets .omni-case-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .omni-start-toolbar { align-items: flex-end; } @@ -1537,6 +1635,10 @@ } @media (max-width: 720px) { + .omni-case-library.is-image-presets .omni-case-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .omni-preset-modal { padding: 12px; } diff --git a/core/frontend/src/omni-session-page.css b/core/frontend/src/omni-session-page.css index 2a6d2b6..6794121 100644 --- a/core/frontend/src/omni-session-page.css +++ b/core/frontend/src/omni-session-page.css @@ -188,6 +188,65 @@ display: none; } +.omni-reply-guide { + margin-top: 8px; + padding-top: 8px; + border-top: 1px dashed var(--border-faint); +} + +.omni-reply-guide p { + margin: 0 0 8px; + color: var(--black-alpha-56); + font-size: 12px; + line-height: 1.55; +} + +.omni-reply-guide p > span { + margin-right: 6px; + color: var(--accent-black); + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: .04em; +} + +.omni-reply-guide-options { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.omni-reply-guide-options button { + min-height: 30px; + padding: 0 12px; + border: 1px solid var(--border-faint); + border-radius: var(--r-md); + color: var(--accent-black); + background: var(--surface); + font: inherit; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease; +} + +.omni-reply-guide-options button:hover:not(:disabled) { + border-color: var(--heat-40); + color: var(--heat); + background: var(--heat-12); +} + +.omni-reply-guide-options button.primary { + border-color: var(--heat-20); + color: var(--heat); + background: var(--heat-12); +} + +.omni-reply-guide-options button:disabled { + color: var(--black-alpha-48); + background: var(--black-alpha-4); + cursor: not-allowed; +} + .omni-chat-row.is-user .omni-chat-bubble { align-items: flex-end; @@ -1481,7 +1540,10 @@ position: relative; /* 不要压过 sticky composer(z-index:3);下拉菜单自身另有更高 z-index */ z-index: 1; - width: min(760px, 100%); + /* 和其余助手卡片一样预留左侧头像对齐位。 + 资源面板打开时会话栏会收窄,不能再用 100% 后额外叠加 44px 左边距。 */ + box-sizing: border-box; + width: min(760px, calc(100% - 44px)); display: flex; flex-direction: column; align-items: stretch; diff --git a/core/frontend/src/routes/omni-create.tsx b/core/frontend/src/routes/omni-create.tsx index dc803bf..76d1972 100644 --- a/core/frontend/src/routes/omni-create.tsx +++ b/core/frontend/src/routes/omni-create.tsx @@ -6,6 +6,7 @@ import { FolderOpen, History, Image as ImageIcon, + LoaderCircle, Play, Plus, Sparkles, @@ -31,12 +32,13 @@ type PresetItem = { category: string; mode: OutputMode; title: string; - desc: string; - starter: string; + desc?: string; + starter?: string; cover: string; }; const VIDEO_PRESETS: PresetItem[] = [ + { name: "真实使用演示", category: "commerce", mode: "video", title: "真实使用演示", desc: "从商品真实用途出发,拍清使用过程与卖点证据,避免不合理的夸张演示。", starter: "基于商品的真实用途创作一条可信的使用演示:拍清使用步骤、产品状态和核心卖点,不夸张、不出现不合理的使用场景。", cover: "/assets/prototype/video-oneclick-film-v3.png" }, { name: "剧情反转带货", category: "story", mode: "video", title: "剧情反转带货", desc: "用人物冲突与意外反转建立记忆点,商品承担剧情中的关键作用。", starter: "创作一条有前后反转的剧情带货视频,让商品自然成为解决问题的关键。", cover: "/assets/prototype/photo-1529139574466-a303027c1d8b.jpg" }, { name: "商品拟人广告", category: "commerce", mode: "video", title: "商品拟人广告", desc: "让商品成为故事主角,通过个性、动作和情绪完成趣味表达。", starter: "把商品塑造成有性格的拟人角色,创作一条轻快、有趣的短广告。", cover: "/assets/prototype/photo-1608248543803-ba4f8c70ae0b.jpg" }, { name: "达人口播种草", category: "speaker", mode: "video", title: "达人口播种草", desc: "用真实体验与生活化表达建立信任,适合电商和本地生活内容。", starter: "创作一条真实自然的达人口播种草视频,重点讲清使用场景和核心卖点。", cover: "/assets/prototype/photo-1494790108377-be9c29b29330.jpg" }, @@ -48,12 +50,15 @@ const VIDEO_PRESETS: PresetItem[] = [ ]; const IMAGE_PRESETS: PresetItem[] = [ - { name: "商品场景套图", category: "product", mode: "image", title: "商品场景套图", desc: "一次生成多张统一视觉的电商图片,覆盖主图、场景和细节表达。", starter: "根据商品参考图生成一组统一风格的主图、场景图和细节图。", cover: "/assets/prototype/photo-1556229010-6c3f2c9ca5f8.jpg" }, - { name: "极简棚拍", category: "product", mode: "image", title: "极简棚拍", desc: "干净背景、柔和投影与明确主体,适合商品主图和详情页头图。", starter: "生成一组留白克制、光线干净的极简棚拍商品图。", cover: "/assets/prototype/photo-1523275335684-37898b6baf30.jpg" }, - { name: "清透自然光人像", category: "portrait", mode: "image", title: "清透自然光人像", desc: "保留真实肤质与自然光影,适合人物种草和生活方式内容。", starter: "生成一组清透自然光、肤色真实的人物氛围图。", cover: "/assets/prototype/photo-1524504388940-b1c1722653e1.jpg" }, - { name: "生活方式场景", category: "scene", mode: "image", title: "生活方式场景", desc: "将商品融入真实空间和使用动作,强调自然、可信的生活气息。", starter: "把商品放入真实、舒适的生活方式场景中,生成自然使用感的图片。", cover: "/assets/prototype/photo-1600210492486-724fe5c67fb0.jpg" }, - { name: "高级奢华质感", category: "style", mode: "image", title: "高级奢华质感", desc: "通过深色环境、局部高光和材质细节强化品牌高级感。", starter: "生成一组深色光影、精致材质与高级氛围的品牌视觉图片。", cover: "/assets/prototype/video-quick-cinematic-v2.png" }, - { name: "复古胶片风格", category: "style", mode: "image", title: "复古胶片风格", desc: "使用低饱和色彩、胶片颗粒和柔和对比形成怀旧情绪。", starter: "生成一组低饱和、细腻颗粒与复古色调的胶片感图片。", cover: "/assets/prototype/photo-1485846234645-a62644f84728.jpg" }, + { name: "电影感", category: "style", mode: "image", title: "电影感", cover: "/assets/image-presets/cinematic.png" }, + { name: "日式清新", category: "style", mode: "image", title: "日式清新", cover: "/assets/image-presets/japanese-fresh.png" }, + { name: "复古胶片", category: "style", mode: "image", title: "复古胶片", cover: "/assets/image-presets/film-vintage.png" }, + { name: "暖调生活感", category: "style", mode: "image", title: "暖调生活感", cover: "/assets/image-presets/warm-lifestyle.png" }, + { name: "高质感杂志", category: "style", mode: "image", title: "高质感杂志", cover: "/assets/image-presets/editorial.png" }, + { name: "梦幻柔光", category: "style", mode: "image", title: "梦幻柔光", cover: "/assets/image-presets/dreamy-soft.png" }, + { name: "黑金高级", category: "style", mode: "image", title: "黑金高级", cover: "/assets/image-presets/black-gold.png" }, + { name: "冷峻科技", category: "style", mode: "image", title: "冷峻科技", cover: "/assets/image-presets/tech-cool.png" }, + { name: "赛博朋克", category: "style", mode: "image", title: "赛博朋克", cover: "/assets/image-presets/cyberpunk.png" }, ]; const VIDEO_FILTERS = [ @@ -66,9 +71,6 @@ const VIDEO_FILTERS = [ const IMAGE_FILTERS = [ { key: "all", label: "全部" }, - { key: "product", label: "商品" }, - { key: "portrait", label: "人像" }, - { key: "scene", label: "场景" }, { key: "style", label: "风格" }, ]; @@ -116,6 +118,7 @@ export function OmniCreatePage({ const [prompt, setPrompt] = useState(""); const [sessionUploads, setSessionUploads] = useState([]); const [uploading, setUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState<{ current: number; total: number } | null>(null); const [model, setModel] = useState("Seedance 2.5"); const [resolution, setResolution] = useState("1080p"); const [ratio, setRatio] = useState("9:16"); @@ -173,7 +176,8 @@ export function OmniCreatePage({ const applyPreset = (preset: PresetItem) => { setSelectedCase(preset); - setPrompt(preset.starter); + // 图片预设只作为隐藏的生成风格,用户输入区保留自己的创作描述,不把内部 Prompt 展示出来。 + if (preset.mode !== "image" && preset.starter) setPrompt(preset.starter); setPreviewCase(null); onNotify?.("info", `已选择预设:${preset.name}`); }; @@ -221,8 +225,10 @@ export function OmniCreatePage({ } if (!images.length) return; setUploading(true); + setUploadProgress({ current: 1, total: images.length }); try { - for (const file of images) { + for (const [index, file] of images.entries()) { + setUploadProgress({ current: index + 1, total: images.length }); const form = new FormData(); form.append("file", file); const data = await api.uploadFreeVideoRef(form); @@ -242,6 +248,7 @@ export function OmniCreatePage({ onNotify?.("error", (error as Error).message); } finally { setUploading(false); + setUploadProgress(null); } }; @@ -265,29 +272,31 @@ export function OmniCreatePage({
- -
{pendingRefs.map((ref) => ( - - {ref.cover ? : } - {ref.name.split(" · ")[0]} - - - ))}
+ +
{pendingRefs.map((ref) => ( + + {ref.cover ? : } + {ref.name.split(" · ")[0]} + + + ))}
+