优化只能创作里的图片创作

This commit is contained in:
Azmat@qq.com
2026-09-14 18:56:07 +08:00
parent 3fed9b9435
commit f15fe21b1a
16 changed files with 540 additions and 106 deletions
+96 -13
View File
@@ -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(
+40 -9
View File
@@ -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}"
)
+57 -4
View File
@@ -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))
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

+109 -7
View File
@@ -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;
}
+63 -1
View File
@@ -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;
+93 -59
View File
@@ -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<CreationRef[]>([]);
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({
</header>
<section className="omni-start-composer" aria-label="全能创作输入区">
<div className="omni-selected-case" hidden={!selectedCase}>
<span>
<WandSparkles />
<strong>{selectedCase?.name}</strong>
</span>
<button type="button" aria-label="取消预设" onClick={() => setSelectedCase(null)}>
<X />
</button>
</div>
<div className="omni-start-attachments" aria-live="polite">{pendingRefs.map((ref) => (
<span className="omni-attachment-chip" key={ref.id}>
{ref.cover ? <img src={ref.cover} alt="" /> : <ImageIcon />}
<span>{ref.name.split(" · ")[0]}</span>
<button
type="button"
className="omni-attachment-remove"
aria-label={`删除 ${ref.name}`}
onClick={() => setPendingRefs((prev) => prev.filter((item) => item.id !== ref.id))}
>
<div className="omni-start-context" hidden={!selectedCase && pendingRefs.length === 0}>
<div className="omni-selected-case" hidden={!selectedCase}>
<span>
<WandSparkles />
<strong>{selectedCase?.title}</strong>
</span>
<button type="button" aria-label="取消预设" onClick={() => setSelectedCase(null)}>
<X />
</button>
</span>
))}</div>
</div>
<div className="omni-start-attachments" aria-live="polite">{pendingRefs.map((ref) => (
<span className="omni-attachment-chip" key={ref.id}>
{ref.cover ? <img src={ref.cover} alt="" /> : <ImageIcon />}
<span>{ref.name.split(" · ")[0]}</span>
<button
type="button"
className="omni-attachment-remove"
aria-label={`删除 ${ref.name}`}
onClick={() => setPendingRefs((prev) => prev.filter((item) => item.id !== ref.id))}
>
<X />
</button>
</span>
))}</div>
</div>
<textarea
id="omniStartPrompt"
rows={3}
@@ -305,14 +314,16 @@ export function OmniCreatePage({
<div className="omni-upload-wrap">
<button
type="button"
className="omni-icon-tool"
aria-label="添加参考素材"
className={`omni-icon-tool${uploading ? " is-uploading" : ""}`}
aria-label={uploading ? "正在上传参考素材" : "添加参考素材"}
aria-busy={uploading}
disabled={uploading}
onClick={() => {
setUploadMenuOpen((open) => !open);
setMentionMenuOpen(false);
}}
>
<Plus />
{uploading ? <LoaderCircle /> : <Plus />}
</button>
<div className="omni-upload-menu" hidden={!uploadMenuOpen}>
<strong></strong>
@@ -332,6 +343,12 @@ export function OmniCreatePage({
</button>
</div>
</div>
{uploading ? (
<span className="omni-upload-status" role="status" aria-live="polite">
<LoaderCircle aria-hidden="true" />
{uploadProgress?.current || 1}/{uploadProgress?.total || 1}
</span>
) : null}
<input
ref={fileInputRef}
type="file"
@@ -438,22 +455,34 @@ export function OmniCreatePage({
</div>
</section>
<section className="omni-case-library" aria-labelledby="omniCaseTitle">
<div className="omni-case-head">
<strong className="omni-case-head-label" id="omniCaseTitle"></strong>
<div className="omni-case-filters" role="tablist" aria-label="预设筛选">
{filters.map((filter) => (
<button
type="button"
key={filter.key}
className={activeCategory === filter.key ? "active" : ""}
onClick={() => setActiveCategory(filter.key)}
>
{filter.label}
</button>
))}
<section
className={`omni-case-library${outputMode === "image" ? " is-image-presets" : ""}`}
aria-labelledby={outputMode === "video" ? "omniCaseTitle" : undefined}
aria-label={outputMode === "image" ? "图片风格预设" : undefined}
>
{outputMode === "video" ? (
<div className="omni-case-head">
<strong className="omni-case-head-label" id="omniCaseTitle"></strong>
<div className="omni-case-filters" role="tablist" aria-label="预设筛选">
{filters.map((filter) => (
<button
type="button"
key={filter.key}
className={activeCategory === filter.key ? "active" : ""}
onClick={() => setActiveCategory(filter.key)}
>
{filter.label}
</button>
))}
</div>
</div>
</div>
) : null}
{outputMode === "image" ? (
<div className="omni-image-preset-intro">
<strong></strong>
<span></span>
</div>
) : null}
<div className="omni-case-grid">
{presets.map((card) => (
<button
@@ -461,7 +490,10 @@ export function OmniCreatePage({
className={`omni-case-card${selectedCase?.name === card.name ? " active" : ""}`}
data-mode={card.mode}
key={card.name}
onClick={() => setPreviewCase(card)}
onClick={() => {
if (card.mode === "image") applyPreset(card);
else setPreviewCase(card);
}}
>
<span className="omni-case-visual">
<img src={card.cover} alt="" />
@@ -471,7 +503,7 @@ export function OmniCreatePage({
</span>
<span className="omni-case-copy">
<strong>{card.title}</strong>
<small>{card.desc}</small>
{card.desc ? <small>{card.desc}</small> : null}
</span>
<span
className="omni-card-use"
@@ -505,11 +537,13 @@ export function OmniCreatePage({
<div className="omni-preset-detail">
<span className="omni-preset-kind">{previewCase.mode === "video" ? "视频预设" : "图片预设"}</span>
<h2 id="omniPresetDialogTitle">{previewCase.title}</h2>
<p>{previewCase.desc}</p>
<div className="omni-preset-default">
<span></span>
<p>{previewCase.starter}</p>
</div>
{previewCase.desc ? <p>{previewCase.desc}</p> : null}
{previewCase.starter ? (
<div className="omni-preset-default">
<span></span>
<p>{previewCase.starter}</p>
</div>
) : null}
<div className="omni-preset-actions">
<button type="button" onClick={() => setPreviewCase(null)}></button>
<button type="button" className="primary" onClick={() => applyPreset(previewCase)}>使</button>
+82 -13
View File
@@ -41,6 +41,39 @@ type PromptBlock =
| { type: "shot"; heading: string; fields: Array<{ label: string; value: string }>; text: string }
| { type: "text"; heading: string; text: string };
type ReplyOption = { label: string; text: string };
function replyOptionsForMessage(payload: Record<string, unknown>, isVideo: boolean): ReplyOption[] {
const stored = payload.reply_options;
if (Array.isArray(stored)) {
const options = stored
.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object")
.map((item) => ({ label: String(item.label || "").trim(), text: String(item.text || "").trim() }))
.filter((item) => item.label && item.text)
.slice(0, 3);
if (options.length) return options;
}
return isVideo
? [
{ label: "继续完善方案", text: "继续完善方案" },
{ label: "换个场景", text: "我想换个场景" },
{ label: "改卖点", text: "我想改卖点" },
]
: [
{ label: "按这个方向出图", text: "按这个方向出图" },
{ label: "换个场景", text: "我想换个场景" },
{ label: "改人物状态", text: "我想改人物状态" },
];
}
function replyHintForMessage(payload: Record<string, unknown>, isVideo: boolean): string {
const stored = String(payload.reply_hint || "").trim();
if (stored) return stored;
return isVideo
? "可以回复「继续完善方案」,也可以选下方想调整的方向。"
: "可以回复「按这个方向出图」,也可以选下方想调整的方向。";
}
function parseMarkdownTable(lines: string[]): { headers: string[]; rows: string[][] } | null {
if (lines.length < 2) return null;
const splitRow = (line: string) =>
@@ -296,6 +329,8 @@ function MentionChips({
/** 生成中的消息靠轮询转成结果。视频 5–10 分钟,间隔别设太密。 */
const POLL_INTERVAL_MS = 4000;
const AGENT_POLL_INTERVAL_MS = 1500;
// 请求已发出、但 Celery 还没把会话切到 planning 时,不能让旧 idle 快照把 loading 收掉。
const AGENT_START_GRACE_MS = 20_000;
// ────────────────────────────────────────────────────────── 卡片
@@ -1540,6 +1575,13 @@ export function OmniSessionPage({
}
// 刷新/重进时若仍在整理方案,直接恢复轮询 UI —— 不要 abort 杀 Celery
const planning = detail.agent_status === "planning";
const waitingForFreshTurn =
!planning
&& streamingRef.current
&& Date.now() < holdPlanningUntilRef.current
&& !turnLooksSettled(detail);
// 刚发送时这里可能拿到请求之前的 idle 快照。保留 loading,等本轮真正落消息。
if (waitingForFreshTurn) return;
streamingRef.current = planning;
setStreaming(planning);
})
@@ -1869,6 +1911,11 @@ export function OmniSessionPage({
}
stalePlanningNotifiedRef.current = false;
userCancelledRef.current = false;
// 先占住 loading;服务端初始 202 / 旧 idle 快照回来时不能把它闪没。
holdPlanningUntilRef.current = Math.max(
holdPlanningUntilRef.current,
Date.now() + AGENT_START_GRACE_MS,
);
streamingRef.current = true;
setStreaming(true);
setLiveText("");
@@ -1912,16 +1959,17 @@ export function OmniSessionPage({
const planning = result.agent_status === "planning";
if (planning) {
// 发出后短时间忽略轮询里的陈旧 idle,避免 thinking 闪灭
holdPlanningUntilRef.current = Date.now() + 6000;
holdPlanningUntilRef.current = Math.max(
holdPlanningUntilRef.current,
Date.now() + AGENT_START_GRACE_MS,
);
streamingRef.current = true;
setStreaming(true);
} else {
holdPlanningUntilRef.current = 0;
streamingRef.current = false;
setStreaming(false);
setLiveText("");
setLiveReasoning("");
setActiveTool("");
// creationSend 可能先回 idle、Celery 随后才切 planning。此处不抢着收起,
// 紧接着的详情快照一旦落到助手消息/确认卡就会通过 mergeCreationDetail 正常收起。
streamingRef.current = true;
setStreaming(true);
}
api
.getCreation(conversationId)
@@ -2170,6 +2218,13 @@ export function OmniSessionPage({
const refs = message.refs || [];
const body = stripMentionText(message.text, refs);
const pending = message.id === pendingUserId;
const showReplyGuide =
message.role === "assistant"
&& message.kind === "text"
&& messages[messages.length - 1]?.id === message.id
&& !streaming;
const replyHint = showReplyGuide ? replyHintForMessage(message.payload, isVideo) : "";
const replyOptions = showReplyGuide ? replyOptionsForMessage(message.payload, isVideo) : [];
return (
<div
className={`omni-chat-row ${message.role === "user" ? "is-user" : "agent"}${pending ? " is-pending" : ""}`}
@@ -2185,12 +2240,26 @@ export function OmniSessionPage({
{(body || message.role !== "user") ? (
<div className="omni-chat-bubble" aria-busy={pending || undefined}>
{body ? <p className="omni-chat-text">{body}</p> : null}
{message.role === "assistant" && typeof message.payload?.reply_hint === "string" && message.payload.reply_hint.trim() ? (
<p className="omni-reply-hint">
<span className="omni-reply-hint-label"></span>
{String(message.payload.reply_hint)}
</p>
) : null}
</div>
) : null}
{showReplyGuide ? (
<div className="omni-reply-guide" aria-label="下一步引导">
<p>
<span></span>
{replyHint}
</p>
<div className="omni-reply-guide-options">
{replyOptions.map((option, index) => (
<button
type="button"
className={index === 0 ? "primary" : ""}
key={option.text}
onClick={() => void send({ kind: "text", text: option.text })}
>
{option.label}
</button>
))}
</div>
</div>
) : null}
{message.role === "user" && !pending ? (