From 3fed9b94353156f2b6e4971cddba4cc07768de91 Mon Sep 17 00:00:00 2001 From: "Azmat@qq.com" Date: Mon, 14 Sep 2026 15:58:20 +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?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/backend/apps/ai/creation_agent.py | 371 ++++++++++-- core/backend/apps/ai/test_creation_agent.py | 184 +++++- .../apps/ai/test_creation_conversation.py | 54 ++ core/backend/apps/ai/views.py | 53 +- .../apps/projects/test_quick_create.py | 1 + core/frontend/src/api.ts | 3 +- core/frontend/src/omni-session-page.css | 526 +++++++++++++++++- core/frontend/src/routes/omni-session.tsx | 483 +++++++++++++--- 全能创作-契约-2026-09-02.md | 2 +- 9 files changed, 1538 insertions(+), 139 deletions(-) diff --git a/core/backend/apps/ai/creation_agent.py b/core/backend/apps/ai/creation_agent.py index 59ea1c7..59ab4a0 100644 --- a/core/backend/apps/ai/creation_agent.py +++ b/core/backend/apps/ai/creation_agent.py @@ -112,6 +112,74 @@ def append_step_confirm(conversation: CreationConversation, step: str) -> Creati ) +_GATE_CONTENT_KIND = { + "strategy": CreationMessage.Kind.STRATEGY, + "plan": CreationMessage.Kind.PLAN, + "prompt": CreationMessage.Kind.PROMPT_FILE, +} + + +def restore_gated_step_after_cancel(conversation: CreationConversation) -> bool: + """取消修订/推进中的规划时,恢复最近闸门的 step_confirm,供用户再点「按这个继续 / 我想改」。 + + 返回 True → 会话应回到 awaiting_user;False → 保持 idle(尚无闸门可恢复)。 + 幂等:已有未提交的 step_confirm 时只校正 stage。 + """ + gated = ("strategy", "plan", "prompt") + confirms = [] + for message in ( + conversation.messages.filter(kind=CreationMessage.Kind.ELICIT) + .order_by("-seq")[:30] + ): + payload = message.payload if isinstance(message.payload, dict) else {} + step = str(payload.get("step") or "").strip() + if payload.get("interaction") == "step_confirm" and step in gated: + confirms.append(message) + if not confirms: + return False + + latest = confirms[0] + payload = dict(latest.payload or {}) + step = str(payload.get("step") or "").strip() + if step not in gated: + return False + + if not payload.get("submitted"): + set_video_gate_stage(conversation, step) + return True + + content_kind = _GATE_CONTENT_KIND.get(step) + newer_content = False + if content_kind: + newer_content = conversation.messages.filter( + seq__gt=latest.seq, kind=content_kind + ).exists() + + if newer_content: + # 重写已落了新卡但还没出确认条 / 或已出过但本条仍是旧 submitted —— 补一条干净确认 + has_open = any( + (not (m.payload or {}).get("submitted")) + and str((m.payload or {}).get("step") or "") == step + for m in confirms + ) + if not has_open: + append_step_confirm(conversation, step) + else: + payload["submitted"] = False + payload["answers"] = {} + latest.payload = payload + latest.save(update_fields=["payload", "updated_at"]) + + set_video_gate_stage(conversation, step) + if step == "strategy": + memory = dict(conversation.memory or {}) + if "strategy_confirmed" in memory: + memory.pop("strategy_confirmed", None) + conversation.memory = memory + conversation.save(update_fields=["memory", "updated_at"]) + return True + + def emit_prompt_gate( conversation: CreationConversation, video_prompt: str | None = None ) -> list[CreationMessage]: @@ -220,32 +288,42 @@ IMAGE_MODEL_BY_LABEL = { # 「智能时长」= 交给我们定,取一个口播讲得完又不烧钱的中间值 SMART_DURATION = 15 -# 全能创作 video_prompt 写作规范:从专业创作 / 一键成片(口播脚本)抽硬规则, -# 适配「整段出片指令」而不是 ScriptDraft JSON。只要求口径接近,不改工具形态。 +# 全能创作 video_prompt 写作规范。目标是把 Prompt 写成导演、摄影、声音与出片模型都能 +# 直接执行的制作文件,而不是只有几行「0-3 秒做什么」的提纲。 _OMNI_VIDEO_PROMPT_RULES = """ -【出片脚本写法 · 对齐专业口播】 -表现形式默认**口播带货**,像真人在讲刚遇到的一件事,不要详情页朗读或主播念稿。 +【视频生成 Prompt · 制作级交付】 +video_prompt 是交给出片模型的完整制作文件,不是方案摘要、更不是几句分镜大纲。保留用户选定的 +预设、人物、商品和创意方向;下面规定的是文档完整度与写法,不能照搬任何其他项目的商品、人设或台词。 -video_prompt 必须能被出片模型直接执行,按时间轴写秒级分镜,建议结构: -- 片头写清:总时长、画幅、整体光线/色调、口播语气(口语、有停顿与立场) -- 然后按「0-3s / 3-8s / …」逐段写,每段同时写清这五项(不能省): - 1. 景别(大特写/特写/近景/中景/全景;一条片里至少切两次景别) - 2. 机位(平视/俯拍/仰拍/过肩/桌面视角) - 3. 运镜(手持跟拍/推近/拉远/横摇/环绕/固定 —— 每段至少一个运镜词) - 4. 动作(谁、哪只手、对什么、做什么;要连贯可拍,禁止「展示质感」等抽象词) - 5. 信息变化(这几秒画面上多了/变了什么) -- 人声原文单独写清,必须用「人声(仅音频,由人物口型与配音表达,不出现在画面上):…」,禁止写成「口播:…」(裸写口播会被出片模型烧成字幕);字数贴近会话时长:大约 5.0–5.7 字/秒, - 15 秒约 75–85 字;太短撑不满,太长会赶。 -- 钩子段画面不要「对着镜头说话」静态开场;前 15 个口播字禁止「大家好/今天分享/给你们推荐」。 -- 全片只围绕一个具体情境推进一个主卖点;卖点要有看得见的证据(质地/前后变化/用法结果)。 -- CTA 像跟朋友说话;禁止小黄车/立即购买/闭眼入等平台指令腔。 -- **不要写字幕/花字/标题贴片/弹幕/角标/水印/购物浮层**,也不要写「无字幕」 - (否定说法也容易把字画上屏)。口播只存在于声音;包装上原有印刷字除外。 -- 已 @ 的角色/商品/场景参考图会自动附上,不要在 prompt 里重描长相;以图锁定性别年龄服装外形。 -- 用户说「商品说话 / 商品自述 / 商品拟人」时,默认采用**无脸拟人**:商品声音是画外角色声, - 商品本体不做口型、不新增卡通五官;性格靠整体倾斜、转向、弹跳、进退、镜头和音效表达。 - 只有用户明确要求可见的卡通五官时才例外。 -- 同一场戏保持地点、光线、服装连续;要换环境就明确写下一时间段切换。 +请按以下顺序完整输出,每个标题都要有具体内容: +时长:X 秒以内,比例:X。任务:一句话说明要生成什么、引用哪些参考素材。 +标题:《本片标题》 +风格与视觉参考:说明成片质感、真实度、拍摄媒介感与 4–6 个风格关键词。 +镜头语言:说明主景别、人物/商品与镜头关系、机位、手持或稳定方式、镜头切换节奏。 +视觉与美术规则:说明场景陈设、主体外观连续性、商品外观连续性;有参考图时要说清「只取什么特征、忽略什么背景/构图」。 +色彩与材质系统:写出主色、材质、皮肤/产品/环境的可见质感。 +打光规则:光源方向、软硬、色温、人物和产品分别如何受光。 +剪辑节奏:列出时间段与 Hook → 证据/体验 → 转化收束的推进逻辑。 +声音方向:人声身份、语气、语速、环境声/拟音、背景音乐的进入和收束;人声原文必须分配到对应分镜。 +场景:逐一写清可见地点、前中后景、环境道具与景深。 +主体与参考素材:逐一说明角色、商品、场景的可见身份和一致性要求。已 @ 的素材按 @图片1、@图片2 … 标注其用途;仅引用实际提供的素材。 +各个分镜的具体内容:按时间顺序逐段展开。 + +分镜不能省略细节:每一段都必须严格用下面四行写完,不得只写一句画面描述: +「0–3 秒」 +拍法:景别 + 机位 + 运镜 + 节奏。 +画面内容:谁在何处、哪只手/身体如何动作、商品如何进入画面、前后发生什么可见变化。 +主体/产品露出:本段产品、人物或关键物件处在什么位置,哪些外观细节必须清晰稳定。 +声音:人声(仅音频,由人物口型与配音表达,不作为画面文字):{本段原话};再写清对应拟音和背景音乐状态。 + +硬性质量要求: +- 15 秒成片至少写 4 段连续分镜;更长时按约 3–5 秒一段拆开,至少切换两种景别与两种机位/运镜。 +- 15 秒 Prompt 通常不少于约 1200 个汉字;短片按时长同比例展开。每一段都要有可拍的连续动作、物理反馈或产品证据,不能用「高级感、展示质感、氛围拉满」代替动作。 +- 人声必须是自然口语,约 5.0–5.7 字/秒;钩子前 15 个字禁止「大家好 / 今天分享 / 给你们推荐」。CTA 像朋友提醒,禁止小黄车、立即购买、闭眼入等平台指令腔。 +- 全片围绕一个具体情境和一个主卖点推进,卖点必须有可见证据,例如质地、使用动作、前后变化或真实反应。 +- 画面中不出现新增字幕、花字、标题贴片、弹幕、角标、水印、购物浮层或说明性文字;口播只存在于声音,包装本身原有印刷字除外。 +- 结尾单列「全片一致性与禁用项」:重申角色、商品、场景、光线、服装/材质的连续性,以及本片最重要的画面禁用项。 +- 用户说「商品说话 / 商品自述 / 商品拟人」时,默认无脸拟人:商品声音是画外角色声,商品本体不做口型、不新增卡通五官;性格只通过整体倾斜、转向、弹跳、进退、镜头和音效表达。只有用户明确要求可见卡通五官时才例外。 """ @@ -362,6 +440,103 @@ def is_pure_chitchat(user_text: str) -> bool: return bool(_CHITCHAT_RE.match(text)) +# ---------------------------------------------------------------- 每轮引导(文字也要告诉用户下一步怎么回) + +_GUIDANCE_MARKER_RE = re.compile( + r"(" + r"请回复|请直接|直接说|直接丢|告诉我|可以回|下一步|" + r"想改|点「|回复:|回复:|发给我|选一个|怎么回|" + r"继续补充|想做什么" + r")" +) + + +def text_already_guides(text: str) -> bool: + """正文里是否已经写明用户下一步该怎么回。""" + return bool(_GUIDANCE_MARKER_RE.search((text or "").strip())) + + +def default_reply_hint( + conversation: CreationConversation | None = None, + *, + has_context: bool = False, + is_video: bool = True, +) -> str: + """纯文字收束时的默认「下一步可以这样回」提示。""" + stage = "" + if conversation is not None: + try: + stage = get_video_gate_stage(conversation) + except Exception: # noqa: BLE001 + stage = "" + if stage in ("strategy", "plan", "prompt"): + return "请点上方确认卡的「按这个继续」,或直接说想改哪里。" + if stage == "confirm": + return "请在确认卡上核对参数后点「开始生成」,或直接说要改的参数。" + if stage == "done": + return "想改哪一处直接说;要重新来就回复「重新来」。" + if has_context: + return "请直接说下一步:改哪里、换素材,或继续推进。" + kind = "短视频" if is_video else "商品图" + return f"请直接丢一句想法,例如「帮我做一条{kind}」。" + + +def apply_reply_hint(message: CreationMessage, hint: str) -> CreationMessage: + """给文字气泡挂上 reply_hint,供前端展示「下一步可以这样回」。""" + payload = dict(message.payload or {}) + if str(payload.get("reply_hint") or "").strip(): + return message + payload["reply_hint"] = hint + message.payload = payload + message.save(update_fields=["payload"]) + return message + + +def ensure_turn_guides( + conversation: CreationConversation, + *, + turn_has_gate: bool, + last_text_bubble: CreationMessage | None, + has_context: bool, + is_video: bool, +) -> list[dict]: + """回合收束校验:若本轮只有散文气泡、没有追问/确认闸门,补上回复引导。 + + 优先给已有文字气泡挂 reply_hint;若本轮连文字都没有,再落一条短引导。 + 不打断 5 步闸门(已有 elicit/confirm 时直接跳过)。 + """ + if turn_has_gate: + return [] + # 会话里若仍有未提交的追问/确认卡,用户本就能点卡推进,不必再注脚。 + open_gate = conversation.messages.filter( + role="assistant", + kind__in=(CreationMessage.Kind.ELICIT, CreationMessage.Kind.CONFIRM), + ).order_by("-seq").first() + if open_gate is not None and not bool((open_gate.payload or {}).get("submitted")): + return [] + + hint = default_reply_hint(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) + events.append({"type": "message", "message": _message_payload(updated)}) + return events + + guide = append_message( + conversation, + role="assistant", + text=hint, + payload={"reply_hint": hint}, + ) + events.append({"type": "message", "message": _message_payload(guide)}) + return events + + + + + # 明确要做内容才算出方案/出图。闲聊、吐槽、问功能都不算。 _CREATIVE_INTENT_RE = re.compile( r"(" @@ -370,7 +545,7 @@ _CREATIVE_INTENT_RE = re.compile( r"做[一两]?[条个张](?:视频|片|图|广告)?|拍[一两]?[条个](?:视频|片|广告)?|" r"出[一两]?[条个张](?:视频|片|图|广告)?|出片|出图|出方案|写方案|改方案|重写方案|重新写|" r"生成(?:一下|一张|几张|一条)?(?:视频|图|片|广告|脚本)?|做条|做个片|短视频|带货视频|短广告|广告片|带货片|" - r"换卖点|改卖点|换剧情|改剧情|重做|重新出|按这个出|确认出片|" + r"换卖点|改卖点|换剧情|改剧情|重做|重新出|重新来|再来一次|从头开始|重新做|按这个出|确认出片|" r"分镜|脚本|口播稿|storyboard|拟人|" r"(改|换|修改|更换).{0,8}(时长|秒数|比例|尺寸|画幅|分辨率|清晰度|模型)|" r"改成\s*\d+\s*秒|改成\s*\d+\s*[::]\s*\d+|改成.{0,8}(竖屏|横屏|比例|分辨率)" @@ -394,6 +569,8 @@ def has_creative_intent(user_text: str, refs: list | None = None) -> bool: return False if is_pure_chitchat(text): return False + if is_restart_intent(text): + return True if _CREATIVE_INTENT_RE.search(text): return True # 「把商品…创作一条…短广告」这类 brief:同时有创作动词和成片名词 @@ -412,6 +589,55 @@ def is_continue_intent(user_text: str) -> bool: return bool(_CONTINUE_INTENT_RE.match((user_text or "").strip())) +# 整轮重来:不是回答上一张追问卡,也不是局部「改策略/换模特」。 +_RESTART_INTENT_RE = re.compile( + r"^\s*(" + r"重新来|重来|从头开始|再来一次|重新做|" + r"重新开始|从头再来|重做一遍|再做一次|重来一遍|" + r"restart|start\s*over|start\s*again" + r")[吧啊呀呢]*[\s!!.。~~??…]*$", + re.IGNORECASE, +) + +_RESTART_CONTINUATION = ( + "用户明确要求重新来/从头开始。平台已重置闸门阶段并关闭未完成的追问/确认卡。" + "先用一句很短的话确认「好,我们重新来」,然后基于会话最初 brief 与已钉素材," + "从澄清或缺信息时 ask_user、信息够了就 write_strategy 重新开一整轮创作;" + "不要再打开上一轮的模特库/商品库追问,不要沿用旧策略/方案/Prompt,不要猜模特。" +) + + +def is_restart_intent(user_text: str) -> bool: + """用户明确要整轮重来(重新来/重来/从头开始…)。""" + return bool(_RESTART_INTENT_RE.match((user_text or "").strip())) + + +def apply_restart_intent(conversation: CreationConversation) -> int: + """重置视频闸门 memory,并关闭未提交的 elicit/confirm 卡。返回关闭张数。""" + memory = dict(conversation.memory or {}) + memory["stage"] = "clarify" + memory.pop("pending_video_prompt", None) + memory.pop("strategy_confirmed", None) + conversation.memory = memory + conversation.save(update_fields=["memory", "updated_at"]) + + closed = 0 + candidates = conversation.messages.filter( + kind__in=(CreationMessage.Kind.ELICIT, CreationMessage.Kind.CONFIRM), + ).order_by("-seq")[:40] + for message in candidates: + payload = dict(message.payload or {}) + if payload.get("submitted"): + continue + payload["submitted"] = True + payload["cancelled"] = True + payload["cancelled_reason"] = "restart" + message.payload = payload + message.save(update_fields=["payload", "updated_at"]) + closed += 1 + return closed + + def session_has_creative_context(conversation: CreationConversation) -> bool: """会话里是否已有可继续的创作进度(钉了素材 / 出过策略或方案)。 @@ -631,11 +857,13 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict "function": { "name": "ask_user", "description": ( - "缺少必要信息时向用户追问。" + "缺少必要信息、或需要用户做选择时向用户追问 —— 这是优先的引导方式。" "只在信息**确实缺失且无法合理推断**时用;能自己定的就自己定,别把用户当填表机器。" "用户要选/改/换商品、角色、模特、场景时**必须**调这个工具," "type 用 asset 并填 asset_types。系统会以类Agent自然对话轻量询问是否需要发送商品列表(同时支持直接输入名字或由你推荐),避免一上来粗暴弹出大卡片打断交流。" + "需要用户在几个明确选项里选时,用 type=single/multi 并给出 options。" "一次只问 1 项,禁止问「要不要继续」「要不要生成」「是否开始创作」这类流程问题。" + "若本轮只写说明文字、不做选择,也必须在文字里写清用户下一句该回什么。" ), "parameters": { "type": "object", @@ -728,7 +956,8 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict "调完只出方案卡并停下等人确认 —— 不要同轮出 Prompt 卡或积分确认卡。" "usp / points / timeline 必须写满具体文案;同时把完整 video_prompt 写好存档," "用户确认方案后由平台展示 Prompt。" - "video_prompt 按系统里的「出片脚本写法」写成口播秒级分镜,不要只写大纲。" + "video_prompt 按系统里的「制作级交付」写成完整 Prompt 文件:必须有整体规则、" + "声音/灯光/场景/参考素材锁定、逐镜四行细节和一致性收束,不要只写大纲。" "先有已确认的 write_strategy,再调它。" ), "parameters": { @@ -758,11 +987,10 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict "video_prompt": { "type": "string", "description": ( - "交给出片模型的完整口播带货指令(对齐专业创作口径)。" - "必须含:总时长与画幅、整体光线色调、按 0-Ns 分段的秒级分镜" - "(每段写清景别/机位/运镜/具体动作/信息变化)、人声(仅音频抬头)原文、一个主卖点与可见证据、口语 CTA。禁止「口播:」字样。" - "禁止字幕/花字/贴片及「无字幕」字样;禁止详情页腔与「大家好」开场。" - "已 @ 素材会自动作参考图,勿重描长相。" + "交给出片模型的制作级完整指令,不是大纲。按系统规定的标题顺序写:" + "时长任务、标题、风格、镜头语言、视觉美术、色彩材质、打光、剪辑、声音、场景、主体参考、逐镜脚本、一致性收束。" + "每一镜严格包含拍法/画面内容/主体或产品露出/声音四行;15 秒至少 4 镜,含人声原文、拟音和 BGM 节奏。" + "已 @ 素材标明用途与需锁定的特征;禁止把口播做成画面文字。" ), }, }, @@ -778,7 +1006,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict "写出片 Prompt 文件卡并请用户确认。" "仅当用户已确认方案、或明确要求改 Prompt 时调用;" "不要在 write_plan 同轮调用。调完停下等人确认,不要同轮出积分确认卡或直接出片。" - "video_prompt 必须是完整可执行的口播秒级分镜。" + "video_prompt 必须是系统规定的制作级完整文件,不能只把旧 Prompt 缩写成几行分镜。" ), "parameters": { "type": "object", @@ -786,8 +1014,8 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict "video_prompt": { "type": "string", "description": ( - "交给出片模型的完整口播带货指令。" - "必须含总时长与画幅、按秒分段的景别/机位/运镜/动作/信息变化、人声(仅音频抬头)。" + "交给出片模型的完整制作文件。必须含总体视觉/美术/色彩/打光/声音/场景/参考图锁定规则," + "以及按秒分段、每镜均含拍法/画面内容/主体或产品露出/声音的完整脚本与一致性收束。" ), }, }, @@ -1247,8 +1475,10 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c "- 缺少商品、模特、角色、场景等素材时:**绝不要直接弹出大块素材选择卡**打断对话。像懂创作、懂电商的专业伙伴一样先自然询问用户想推什么商品/用哪个角色,询问是否需要把商品列表发给他选,同时说明也可以直接输入商品名或由你推荐。", "- 只有当用户明确说要发列表(如「发给我」「发列表」「给我看看」「我来选」)时,才展示可视化卡片。", "- 用户直接打字输入商品名时,直接采纳该商品并继续推进创作方案,不要强迫用户去卡片里点选。", - "- 调性、受众、文案、时长等其他信息才用聊天追问,问题末尾顺手告诉用户下一句怎么回。", + "- 调性、受众、文案、时长等其他信息用 ask_user 或聊天追问;无论哪种,都必须让用户知道下一句怎么回。", "- 用户刚回答过你的问题时,直接沿着答案继续;不要复述答案,也不要额外回一句「收到」。", + "- 用户说「重新来」「重来」「从头开始」「再来一次」「重新做」:整轮重开创作。" + " 先短确认,再按最初 brief/已钉素材从澄清或 write_strategy 推进;禁止再打开上一轮模特/商品库追问。", "- 用户选择暂不提供某项素材时,把它当成明确授权:按已有信息和合理默认继续。除非任务客观上无法完成,否则不要再次追问同一素材。", "- 用户说「你来定」「你帮我选」「随便」「都行」时,就是授权你做专业判断;直接选合理方案继续,不要把选择题再抛回去。", "- 禁止问「要不要继续」「要不要生成」「是否开始创作」这类流程问题。缺信息用 ask_user;信息够了就写策略。" @@ -1259,6 +1489,14 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c "- 会话里**已有**商品或方向时:针对用户这句话本身自然回应;别用「要现在生成还是先调细节」把工作又抛回给用户。", "- 没有明确创作指令时不要自己写策略卡/方案卡,也不要主动追问流程确认;等用户给出具体想法或修改。", "", + "【每轮必须给引导】", + "- 每一轮回复结束时,用户必须知道下一步怎么做:要么调用 ask_user 弹出可选项," + "要么在文字里明确告诉用户该回复什么(例如「请回复:1… 2…」或「直接说想改的卖点」)。", + "- 禁止只丢一段解释/分析就结束、让用户不知道该回什么。", + "- 缺信息或要用户做选择时:优先 ask_user(带 options 的 single/multi,或 asset)。", + "- 纯说明/判断/闲聊回应:文末必须带一句可执行的回复指引。", + "- 视频闸门的 step_confirm / 积分确认卡本身已是引导,不要再口头追问流程。", + "", "【什么时候反问】", "- 只有信息**确实缺失且无法合理推断**时才调用 ask_user;能自己定的就自己定。", "- ask_user 一次只问一件事。type=asset 会以类Agent自然追问轻量询问是否发送列表或由你推荐;其他类型显示普通聊天问题,让用户直接输入。", @@ -1301,7 +1539,7 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c "plan": "当前阶段=等方案确认:不要 write_prompt 或出片。若用户在改方案,只重调 write_plan。", "prompt": "当前阶段=等 Prompt 确认:不要出积分确认/出片。若用户在改 Prompt,只重调 write_prompt。", "confirm": "当前阶段=等出片确认:不要再写策略/方案/Prompt;用户会在确认卡上点开始生成。", - "done": "当前阶段=已出片:等用户新的修改或新需求再行动。", + "done": "当前阶段=已出片:等用户新的修改或新需求再行动。用户说重新来/重来/从头开始时,当作新一轮创作,从澄清或 write_strategy 重开,不要再弹旧模特/商品追问。", }.get(stage, "") if stage_hint: lines.append(f"- {stage_hint}") @@ -1672,13 +1910,19 @@ def iter_creation_agent_events( # 单纯打招呼无论有没有参考素材、有没有既有上下文,都不值得等模型。 # 素材先收下,等用户说清用途再分析,避免「你好」卡住还冒出一大段建议。 if fast_greeting: + greet_text = ( + "你好,素材我收到了。想拿它做什么,直接说就行。" + if refs else "你好。想做什么,直接说就行。" + ) reply = append_message( conversation, role="assistant", - text=( - "你好,素材我收到了。想拿它做什么,直接说就行。" - if refs else "你好。想做什么,直接说就行。" - ), + text=greet_text, + payload={ + "reply_hint": default_reply_hint( + conversation, has_context=False, is_video=conversation.mode == CreationConversation.Mode.VIDEO + ), + }, ) yield {"type": "message", "message": _message_payload(reply)} yield {"type": "done"} @@ -1688,10 +1932,16 @@ def iter_creation_agent_events( # 针对用户实际说的话自然回应,但不给方案工具。 has_context = session_has_creative_context(conversation) if record_user_message and is_pure_chitchat(text) and not refs and not has_context: + chit_text = "我在。想做什么,直接丢一句想法给我就行。" reply = append_message( conversation, role="assistant", - text="我在。想做什么,直接丢一句想法给我就行。", + text=chit_text, + payload={ + "reply_hint": default_reply_hint( + conversation, has_context=False, is_video=conversation.mode == CreationConversation.Mode.VIDEO + ), + }, ) yield {"type": "message", "message": _message_payload(reply)} yield {"type": "done"} @@ -1728,6 +1978,8 @@ def iter_creation_agent_events( from .creation import is_agent_cancel_requested + turn_has_gate = False + last_text_bubble = None for _round in range(MAX_TOOL_ROUNDS): if is_agent_cancel_requested(conversation.id): # 用户终止:干净收束,不落 ERROR,已落库消息保留 @@ -1792,6 +2044,7 @@ def iter_creation_agent_events( 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) + last_text_bubble = bubble yield {"type": "message", "message": _message_payload(bubble)} if not calls: @@ -1801,6 +2054,13 @@ def iter_creation_agent_events( ) for event in result.get("_events", []): yield event + if event.get("type") == "message": + msg = event.get("message") or {} + if msg.get("kind") in ( + CreationMessage.Kind.ELICIT, + CreationMessage.Kind.CONFIRM, + ): + turn_has_gate = True break messages.append({ @@ -1835,6 +2095,13 @@ def iter_creation_agent_events( yield {"type": "tool", "id": name, "label": _TOOL_LABELS.get(name, name), "status": "done"} for event in result.get("_events", []): yield event + if event.get("type") == "message": + msg = event.get("message") or {} + if msg.get("kind") in ( + CreationMessage.Kind.ELICIT, + CreationMessage.Kind.CONFIRM, + ): + turn_has_gate = True messages.append({ "role": "tool", "tool_call_id": f"call_{index}", @@ -1847,6 +2114,16 @@ def iter_creation_agent_events( if stop: break + # 收束校验:本轮若只剩散文、没有追问/确认卡,补 reply_hint 或短引导。 + for event in ensure_turn_guides( + conversation, + turn_has_gate=turn_has_gate, + last_text_bubble=last_text_bubble, + has_context=has_context, + is_video=context.is_video, + ): + yield event + yield {"type": "done"} except Exception as exc: # noqa: BLE001 — SSE 里任何未捕获异常都会变成前端「白屏卡死」 logger.exception("creation agent stream failed: %s", exc) @@ -1966,10 +2243,16 @@ def run_creation_agent_turn( conversation.refresh_from_db(fields=["agent_status", "team_id"]) except Exception: # noqa: BLE001 pass - # 用户已终止时 API 侧多半已 idle+放锁;这里再收一次保证幂等,且不落入 awaiting_user + # 用户终止:恢复闸门确认条(若有),再 finish;避免卡在「已收到…正在重写」且 agent 变 idle + if user_cancelled: + try: + awaiting_user = restore_gated_step_after_cancel(conversation) + except Exception: # noqa: BLE001 + logger.exception("creation agent cancel: restore gated step failed") + awaiting_user = False finish_agent_planning( conversation, - awaiting_user=(awaiting_user and not user_cancelled), + awaiting_user=awaiting_user, ) return conversation_id diff --git a/core/backend/apps/ai/test_creation_agent.py b/core/backend/apps/ai/test_creation_agent.py index 5a035cc..1ae1cbc 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 SimpleTestCase, TestCase +from django.test import SimpleTestCase, TestCase, override_settings from rest_framework.test import APIClient from apps.accounts.models import Team, TeamMember, User @@ -14,6 +14,7 @@ from apps.assets.models import Asset, AssetFile from apps.products.models import Product, ProductImage from .creation import append_message +from .views import _typed_step_confirm_action from .creation_agent import ( AgentContext, COMPRESS_MIN_BATCH, @@ -23,16 +24,23 @@ from .creation_agent import ( _coerce_fields, _image_count, _merge_tool_call_deltas, + apply_restart_intent, + build_system_prompt, build_messages, + default_reply_hint, + ensure_turn_guides, get_creation_chat_model, + get_video_gate_stage, has_creative_intent, is_continue_intent, is_greeting, is_pure_chitchat, + is_restart_intent, session_has_creative_context, stream_creation_agent, submit_confirmed_image, submit_confirmed_video, + text_already_guides, tool_schemas, video_duration, video_model_name, @@ -447,6 +455,12 @@ 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_natural_step_confirmation_phrases_continue_instead_of_revising(self): + for text in ("这个还行", "这种还行", "可以", "就这样", "没问题", "按这个继续", "好,继续"): + with self.subTest(text=text): + self.assertEqual(_typed_step_confirm_action(text), "confirm") + self.assertEqual(_typed_step_confirm_action("可以,但镜头再快一点"), "revise") + 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="做一张轻松有趣的商品海报") @@ -1381,6 +1395,108 @@ class CreativeIntentTests(SimpleTestCase): self.assertFalse(is_continue_intent(text), text) +class RestartIntentTests(SimpleTestCase): + """「重新来」是整轮重开,不是追问卡答案,也不是局部改模特。""" + + def test_restart_phrases_match(self): + for text in ("重新来", "重来", "从头开始", "再来一次", "重新做", "重新来吧", "Restart"): + self.assertTrue(is_restart_intent(text), text) + self.assertTrue(has_creative_intent(text), text) + + def test_non_restart_phrases_do_not_match(self): + for text in ("重新选模特", "重新写方案", "改成重新来的感觉", "好的", "继续"): + self.assertFalse(is_restart_intent(text), text) + + +class RestartSendEndpointTests(TestCase): + """出片后打「重新来」应重置闸门,且不把未完成模特追问当成答案。""" + + def setUp(self): + self.user = User.objects.create_user(username="restart-owner", password="p") + self.team = Team.objects.create(name="Restart", owner=self.user) + TeamMember.objects.create(team=self.team, user=self.user, role="owner") + provider = ModelProvider.objects.create(name="fake-r", display_name="Fake", base_url="https://x") + self.model = ModelConfig.objects.create( + provider=provider, name="fake-text-r", display_name="Fake Text", + capability=ModelConfig.Capability.TEXT, endpoint="chat/completions", + ) + self.conversation = CreationConversation.objects.create( + team=self.team, created_by=self.user, mode="video", title="重来", + params={"model": "Seedance 2.5", "ratio": "9:16", "resolution": "720p", "duration": "15 秒"}, + memory={ + "stage": "done", + "strategy_confirmed": True, + "pending_video_prompt": "旧出片指令", + }, + ) + self.client = APIClient() + self.client.force_authenticate(self.user) + + def test_restart_text_closes_open_model_elicit_and_resets_stage(self): + gate = append_message( + self.conversation, + role="assistant", + kind=CreationMessage.Kind.ELICIT, + text="这条想用哪位模特?可以直接告诉我,或者需要我把模特库发给你选吗?", + payload={ + "interaction": "chat", + "phase": "gate", + "fields": [{"key": "_asset_gate", "label": "模特", "type": "text"}], + "pending_fields": [{ + "key": "model", + "label": "这条想用哪位模特?", + "type": "asset", + "asset_types": ["model"], + }], + "submitted": False, + "answers": {}, + }, + ) + confirm = append_message( + self.conversation, + role="assistant", + kind=CreationMessage.Kind.CONFIRM, + payload={"kind": "video", "submitted": False, "video_prompt": "旧片"}, + ) + + # 本机 .env 常开 INLINE;单测里改走 Celery 假入队,只断言 send 同步侧行为。 + with override_settings(CREATION_AGENT_INLINE=False, CREATION_AGENT_TASK_QUEUE="airshelf.local"), \ + patch("apps.ai.views.begin_agent_planning", return_value=True), \ + patch("apps.ai.views.run_creation_agent_turn_task") as task: + response = self.client.post( + f"/api/ai/creations/{self.conversation.id}/send/", + {"kind": "text", "text": "重新来"}, + format="json", + ) + self.assertEqual(response.status_code, 202, response.content) + self.assertTrue(task.apply_async.called) + turn_kwargs = task.apply_async.call_args.kwargs["kwargs"] + + gate.refresh_from_db() + confirm.refresh_from_db() + self.conversation.refresh_from_db() + self.assertTrue(gate.payload["submitted"]) + self.assertTrue(gate.payload.get("cancelled")) + self.assertEqual(gate.payload.get("cancelled_reason"), "restart") + # 绝不能把「重新来」当成模特答案写进追问卡 + self.assertEqual(gate.payload.get("answers") or {}, {}) + self.assertTrue(confirm.payload["submitted"]) + self.assertEqual(get_video_gate_stage(self.conversation), "clarify") + self.assertFalse((self.conversation.memory or {}).get("strategy_confirmed")) + self.assertNotIn("pending_video_prompt", self.conversation.memory or {}) + self.assertTrue(turn_kwargs["force_creative_turn"]) + self.assertIn("重新来", turn_kwargs["continuation_instruction"]) + self.assertIn("模特库", turn_kwargs["continuation_instruction"]) + self.assertEqual(turn_kwargs["text"], "重新来") + self.assertTrue(turn_kwargs["record_user_message"]) + + def test_apply_restart_intent_helper_resets_memory(self): + closed = apply_restart_intent(self.conversation) + self.conversation.refresh_from_db() + self.assertEqual(closed, 0) + self.assertEqual(get_video_gate_stage(self.conversation), "clarify") + + class ToolGateTests(CreationAgentBaseTests): def test_empty_conversation_greeting_is_human_and_actionable(self): events = _events(stream_creation_agent( @@ -1457,3 +1573,69 @@ class ToolGateTests(CreationAgentBaseTests): )) names = {tool["function"]["name"] for tool in fake.calls[0]["extra_body"]["tools"]} self.assertIn("generate_image", names) + + +class ReplyGuidanceTests(CreationAgentBaseTests): + """每轮回复必须给引导:系统提示有规则,散文收束会挂 reply_hint。""" + + def test_system_prompt_contains_turn_guidance_rule(self): + context = AgentContext(conversation=self.conversation, user=self.user, model_config=self.model) + prompt = build_system_prompt(context, allow_plan=True, has_context=False) + self.assertIn("【每轮必须给引导】", prompt) + self.assertIn("ask_user", prompt) + self.assertIn("该回复什么", prompt) + + def test_system_prompt_requires_production_grade_video_prompt_document(self): + self.conversation.mode = CreationConversation.Mode.VIDEO + self.conversation.save(update_fields=["mode"]) + context = AgentContext(conversation=self.conversation, user=self.user, model_config=self.model) + prompt = build_system_prompt(context, allow_plan=True, has_context=False) + self.assertIn("【视频生成 Prompt · 制作级交付】", prompt) + self.assertIn("视觉与美术规则", prompt) + self.assertIn("色彩与材质系统", prompt) + self.assertIn("主体/产品露出", prompt) + self.assertIn("全片一致性与禁用项", prompt) + + def test_plain_text_turn_gets_reply_hint(self): + # 闲聊路径会走到模型;模型只吐散文、不调工具时必须补引导。 + fake = FakeProvider([_text_chunks("这条可以偏清爽一点,突出成分感。")]) + with patch("apps.ai.creation_agent.build_provider", return_value=fake): + events = _events(stream_creation_agent( + conversation=self.conversation, user=self.user, + text="今天天气不错", model_config=self.model, + )) + texts = [ + e["message"] for e in events + if e.get("type") == "message" and e["message"]["role"] == "assistant" + and e["message"]["kind"] == "text" + ] + self.assertTrue(texts) + last = texts[-1] + self.assertTrue(str((last.get("payload") or {}).get("reply_hint") or "").strip()) + 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()) + + def test_ensure_turn_guides_skips_when_elicit_open(self): + bubble = append_message(self.conversation, role="assistant", text="先看看策略。") + append_message( + self.conversation, role="assistant", kind=CreationMessage.Kind.ELICIT, + text="确认后继续", + payload={"interaction": "step_confirm", "step": "strategy", "submitted": False}, + ) + events = ensure_turn_guides( + self.conversation, + turn_has_gate=False, + last_text_bubble=bubble, + has_context=True, + is_video=True, + ) + self.assertEqual(events, []) + bubble.refresh_from_db() + self.assertFalse((bubble.payload or {}).get("reply_hint")) + + def test_text_already_guides_detects_reply_instruction(self): + self.assertTrue(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/backend/apps/ai/test_creation_conversation.py b/core/backend/apps/ai/test_creation_conversation.py index 2f53dc4..c6ac1cf 100644 --- a/core/backend/apps/ai/test_creation_conversation.py +++ b/core/backend/apps/ai/test_creation_conversation.py @@ -16,6 +16,7 @@ from .creation import ( sync_generating_messages, team_agent_busy, ) +from .creation_agent import append_step_confirm, restore_gated_step_after_cancel from .models import AITask, CreationConversation, CreationMessage, ModelConfig, ModelProvider @@ -281,3 +282,56 @@ class CreationAgentCancelTests(TestCase): self.conversation.refresh_from_db() self.assertEqual(self.conversation.agent_status, CreationConversation.AgentStatus.IDLE) self.assertFalse(team_agent_busy(self.team)) + + def test_cancel_during_strategy_revise_reopens_step_confirm(self): + append_message( + self.conversation, + role="assistant", + kind=CreationMessage.Kind.STRATEGY, + payload={"target": "白领", "trust": "成分", "belief": "温和", "direction": "口播"}, + ) + confirm = append_step_confirm(self.conversation, "strategy") + confirm.payload = { + **confirm.payload, + "submitted": True, + "answers": {"step_action": "revise", "feedback": "更活泼一点"}, + } + confirm.save(update_fields=["payload", "updated_at"]) + self.conversation.memory = {"stage": "strategy", "strategy_confirmed": True} + self.conversation.save(update_fields=["memory", "updated_at"]) + + self.assertTrue(begin_agent_planning(self.conversation)) + ok = self.client.post(f"/api/ai/creations/{self.conversation.id}/cancel/", {}, format="json") + self.assertEqual(ok.status_code, 200, getattr(ok, "data", ok.content)) + body = ok.json() + self.assertEqual(body["agent_status"], "awaiting_user") + + confirm.refresh_from_db() + self.conversation.refresh_from_db() + self.assertFalse(confirm.payload.get("submitted")) + self.assertEqual(confirm.payload.get("answers") or {}, {}) + self.assertEqual(self.conversation.agent_status, CreationConversation.AgentStatus.AWAITING_USER) + self.assertEqual((self.conversation.memory or {}).get("stage"), "strategy") + self.assertNotIn("strategy_confirmed", self.conversation.memory or {}) + + def test_restore_gated_step_emits_fresh_confirm_when_rewrite_already_landed(self): + old = append_step_confirm(self.conversation, "plan") + old.payload = {**old.payload, "submitted": True, "answers": {"step_action": "revise"}} + old.save(update_fields=["payload", "updated_at"]) + append_message( + self.conversation, + role="assistant", + kind=CreationMessage.Kind.PLAN, + payload={"usp": "新品", "points": ["点1"], "timeline": [], "matrix": [], "voice_chars": [40, 50]}, + ) + + self.assertTrue(restore_gated_step_after_cancel(self.conversation)) + old.refresh_from_db() + self.assertTrue(old.payload.get("submitted")) # 历史卡保持已提交 + opens = [ + m for m in self.conversation.messages.filter(kind=CreationMessage.Kind.ELICIT).order_by("seq") + if (m.payload or {}).get("interaction") == "step_confirm" + and (m.payload or {}).get("step") == "plan" + and not (m.payload or {}).get("submitted") + ] + self.assertEqual(len(opens), 1) diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index ef1090f..1dff056 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -35,11 +35,15 @@ from .creation import ( ) from .creation_agent import ( _ASSET_CARD_LABELS, + _RESTART_CONTINUATION, apply_confirm_params, + apply_restart_intent, apply_session_params, emit_final_confirm_gate, emit_prompt_gate, is_greeting, + is_restart_intent, + restore_gated_step_after_cancel, set_video_gate_stage, submit_confirmed_image, submit_confirmed_video, @@ -134,6 +138,23 @@ _WANTS_CARD_RE = re.compile( re.IGNORECASE, ) +# 步骤确认卡下,用户往往不会去点「按这个继续」,而是自然地回一句 +# 「这个还行」「就这样」「没问题」。这些是确认,不应被误判成修改意见。 +_STEP_TEXT_CONFIRM_RE = re.compile( + r"^(?:" + r"好(?:的)?|行|可以(?:了)?|没问题|确认|继续(?:下一步)?|" + r"就这样(?:吧)?|按这个(?:继续|来|做)?|就按这个(?:继续|来|做)?|" + r"(?:这(?:个|样|版|种)?|方案|策略|prompt)(?:还行|可以|挺好|不错|没问题)(?:吧)?" + r")(?:继续(?:下一步)?)?$", + re.IGNORECASE, +) + + +def _typed_step_confirm_action(text: str) -> str: + """把对着步骤卡输入的自然语言收成「继续」或「修改」。""" + compact = re.sub(r"[\s,,。.!!??、~~…]+", "", str(text or "").lower()) + return "confirm" if _STEP_TEXT_CONFIRM_RE.fullmatch(compact) else "revise" + def _pending_chat_question(conversation: CreationConversation) -> CreationMessage | None: """找最近一条未回答的新式追问;卡片既可点选,也兼容直接输入。""" @@ -1625,7 +1646,9 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): def cancel(self, request, pk=None): """终止进行中的整理方案(agent planning)。 - 仅 `agent_status===planning` 可取消 → **200** `{conversation_id, agent_status: idle}`。 + 仅 `agent_status===planning` 可取消 → **200** `{conversation_id, agent_status}`。 + 若取消发生在策略/方案/Prompt 闸门的修订或推进中,恢复该步 `step_confirm` + (清除 submitted),`agent_status` 回到 `awaiting_user`;否则为 `idle`。 非 planning → **409**。打 Redis 取消标供 Celery 在工具轮间隙停跑,立刻释放 `omni:agent:{team_id}` 锁;已落库消息保留。不影响 confirm 出片/出图。 """ @@ -1640,10 +1663,21 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): {"detail": "当前没有正在整理的方案可终止"}, status=409, ) + restored = restore_gated_step_after_cancel(conversation) + if restored: + now = timezone.now() + CreationConversation.objects.filter(pk=conversation.pk).update( + agent_status=CreationConversation.AgentStatus.AWAITING_USER, + agent_started_at=None, + last_active_at=now, + updated_at=now, + ) + conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER + conversation.agent_started_at = None return JsonResponse( { "conversation_id": str(conversation.id), - "agent_status": CreationConversation.AgentStatus.IDLE, + "agent_status": conversation.agent_status, }, status=200, ) @@ -1671,17 +1705,26 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): if not isinstance(refs, list): return JsonResponse({"detail": "refs 必须是数组"}, status=400) + # 「重新来」优先于未完成追问卡:不能当成模特/商品答案继续旧闸门。 + if kind == "text" and text and is_restart_intent(text): + apply_restart_intent(conversation) + force_creative_turn = True + continuation_instruction = _RESTART_CONTINUATION # 打招呼不应被误当成上一条素材追问的答案;让 agent 立刻简短回应即可。 - if kind == "text" and text and not is_greeting(text): + elif kind == "text" and text and not is_greeting(text): pending = _pending_chat_question(conversation) if pending is not None: payload = dict(pending.payload or {}) if payload.get("interaction") == "step_confirm": - # 用户对着步骤确认卡直接打字 = 修改意见 + # 用户对着步骤确认卡直接打字:自然肯定句视为继续,其它才作为修改意见。 + typed_action = _typed_step_confirm_action(text) short, force_creative_turn, continuation_instruction = _handle_step_confirm_answer( conversation, card=pending, - answers={"step_action": "revise", "feedback": text}, + answers={ + "step_action": typed_action, + **({"feedback": text} if typed_action == "revise" else {}), + }, user=request.user, ) if short is not None: diff --git a/core/backend/apps/projects/test_quick_create.py b/core/backend/apps/projects/test_quick_create.py index ec55371..7a995cc 100644 --- a/core/backend/apps/projects/test_quick_create.py +++ b/core/backend/apps/projects/test_quick_create.py @@ -1372,3 +1372,4 @@ class QuickCreateCoordinatorTests(TestCase): + diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 6c9648b..3bab9df 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -628,7 +628,8 @@ export const api = { }); }, /** - * 终止整理方案(planning)。成功 **200** `{conversation_id, agent_status: idle}`; + * 终止整理方案(planning)。成功 **200** `{conversation_id, agent_status}`; + * 闸门修订/推进中取消时多为 `awaiting_user`(已恢复 step_confirm),否则 `idle`。 * 非 planning **409**。服务端放锁 + Redis 取消标,离开页面再进也生效。 */ cancelCreationAgent(id: string) { diff --git a/core/frontend/src/omni-session-page.css b/core/frontend/src/omni-session-page.css index 95ad7b1..2a6d2b6 100644 --- a/core/frontend/src/omni-session-page.css +++ b/core/frontend/src/omni-session-page.css @@ -33,6 +33,7 @@ } .omni-session-shell { + position: relative; width: min(980px, calc(100% - 42px)); min-height: calc(100vh - var(--topbar-height)); display: flex; @@ -162,6 +163,32 @@ white-space: pre-wrap; } + +.omni-reply-hint { + margin: 8px 0 0; + padding-top: 8px; + border-top: 1px dashed rgba(34, 42, 54, .12); + color: #667085; + font-size: 12px; + line-height: 1.55; +} + +.omni-reply-hint-label { + display: inline-block; + margin-right: 6px; + padding: 1px 6px; + border-radius: 999px; + color: #3b5bdb; + background: rgba(59, 91, 219, .08); + font-size: 11px; + font-weight: 600; +} + +.omni-chat-row.is-user .omni-reply-hint { + display: none; +} + + .omni-chat-row.is-user .omni-chat-bubble { align-items: flex-end; border-color: var(--black); @@ -296,6 +323,11 @@ .omni-video-plan-head span { color: #fff; background: var(--black); + font-size: 12px; +} + +.omni-video-plan-head strong { + font-size: 16px; } .omni-plan-section { @@ -310,7 +342,8 @@ .omni-plan-section > strong { display: block; margin-bottom: 10px; - font-size: 12px; + font-size: 14px; + line-height: 1.5; } .omni-plan-points { @@ -324,15 +357,16 @@ border-radius: 9px; color: #525965; background: #f6f8fa; - font-size: 12px; - line-height: 1.5; + font-size: 13px; + line-height: 1.65; } .omni-plan-points b { display: block; margin-bottom: 4px; color: #252b35; - font-size: 11px; + font-size: 12px; + line-height: 1.45; } .omni-plan-points i, @@ -364,15 +398,16 @@ border-radius: 10px; color: #5b626d; background: #fff; - font-size: 11px; - line-height: 1.5; + font-size: 13px; + line-height: 1.65; } .omni-plan-timeline b { display: block; margin-bottom: 5px; color: var(--klein); - font-size: 12px; + font-size: 13px; + line-height: 1.45; } .omni-plan-summary { @@ -383,14 +418,35 @@ padding: 13px 17px; color: #444b56; background: #f6f8fb; - font-size: 12px; - line-height: 1.5; + font-size: 13px; + line-height: 1.65; } .omni-plan-summary b { color: var(--klein); } +/* 「最终输入」是方案交给出片模型前的最后一层信息,不能和辅助说明一样小。 */ +.omni-plan-summary .omni-plan-final-input { + margin-left: auto; + color: var(--accent-black); + font-size: 13px; + font-weight: 500; +} + +.omni-plan-summary .omni-plan-final-input b { + font-size: 14px; + font-weight: 600; +} + +.omni-plan-summary .omni-plan-final-input .omni-plan-point-body { + display: inline; + margin-left: 2px; + color: var(--black-alpha-64); + font-size: 13px; + font-weight: 400; +} + .omni-prompt-file-card { display: grid; grid-template-columns: 42px minmax(0, 1fr) auto; @@ -1301,9 +1357,8 @@ box-sizing: border-box; width: min(760px, calc(100% - 44px)); display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; + flex-direction: column; + align-items: stretch; gap: 12px; margin: 0 0 24px 44px; padding: 14px 16px; @@ -1319,11 +1374,25 @@ opacity: 0.72; } +.omni-step-confirm-card.is-revising { + border-color: rgba(0, 47, 167, .22); + box-shadow: 0 12px 30px rgba(20, 27, 38, .065), 0 0 0 3px rgba(0, 47, 167, .08); +} + +.omni-step-confirm-top { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; +} + .omni-step-confirm-copy { display: flex; flex-direction: column; gap: 4px; min-width: 0; + flex: 1 1 180px; } .omni-step-confirm-copy strong { @@ -1342,6 +1411,7 @@ display: flex; flex-shrink: 0; gap: 8px; + justify-content: flex-end; } .omni-step-confirm-actions button { @@ -1365,6 +1435,46 @@ cursor: not-allowed; } +/* 「我想改」卡片内反馈区:与卡片同宽,主路径不再依赖底部 composer */ +.omni-step-confirm-revise { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; +} + +.omni-step-confirm-revise-input { + box-sizing: border-box; + width: 100%; + min-height: 76px; + padding: 10px 12px; + border: 1px solid rgba(34, 42, 54, 0.14); + border-radius: 10px; + background: #f7f8fa; + color: #222a36; + font-family: inherit; + font-size: 13px; + line-height: 1.55; + resize: vertical; + outline: 0; + transition: border-color .15s ease, box-shadow .15s ease, background .15s ease; +} + +.omni-step-confirm-revise-input::placeholder { + color: #8b919a; +} + +.omni-step-confirm-revise-input:focus { + border-color: rgba(0, 47, 167, .42); + background: #fff; + box-shadow: 0 0 0 3px rgba(0, 47, 167, .14); +} + +.omni-step-confirm-revise-input:disabled { + opacity: 0.6; + cursor: not-allowed; +} + /* ── 确认闸门(.omni-confirm-card)· 设计稿里是方案卡下面那条操作行,这里独立成卡。 积分数字挂在按钮里 —— 用户按下去之前就该知道要花多少。 */ .omni-confirm-card { @@ -1837,7 +1947,8 @@ .omni-prompt-drawer-bg { position: fixed; inset: 0; - z-index: 80; + /* Prompt 是当前查看的内容,必须盖过右侧「资源」工作区。 */ + z-index: calc(var(--z-overlay) + 10); border: 0; background: rgba(16, 18, 24, .28); cursor: pointer; @@ -1848,7 +1959,7 @@ top: 12px; right: 12px; bottom: 12px; - z-index: 81; + z-index: calc(var(--z-overlay) + 15); width: min(560px, calc(100vw - 24px)); display: flex; flex-direction: column; @@ -2119,3 +2230,390 @@ color: #5c6570; white-space: nowrap; } + + +/* ── 对话资源:固定入口 + 右侧工作区 ── */ + +.omni-assets-entry { + position: fixed; + top: 50%; + right: 28px; + z-index: 40; + display: inline-flex; + align-items: center; + gap: 7px; + height: 36px; + padding: 0 14px 0 12px; + border: 1px solid var(--border-faint); + border-radius: var(--r-md); + background: var(--surface); + color: var(--black-alpha-56); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transform: translateY(-50%); + transition: background var(--t-base), border-color var(--t-base), color var(--t-base), opacity var(--t-base); +} + +.omni-assets-entry svg { + width: 15px; + height: 15px; + flex: 0 0 auto; +} + +.omni-assets-entry:hover { + border-color: var(--border-loud); + background: var(--black-alpha-4); + color: var(--accent-black); +} + +.omni-assets-entry.is-open { + opacity: 0; + pointer-events: none; +} + +.omni-assets-drawer { + top: var(--topbar-height); + right: 0; + bottom: 0; + width: min(440px, 36vw); + max-width: none; + border-left: 1px solid var(--border-faint); + background: var(--surface); + box-shadow: none; + animation: none; +} + +.omni-assets-drawer-head { + min-height: 64px; + gap: 20px; + padding: 14px 20px; +} + +.omni-assets-drawer-head > div:first-child { + min-width: 0; +} + +.omni-assets-drawer-head strong, +.omni-assets-drawer-head small { + display: block; +} + +.omni-assets-drawer-head strong { + color: var(--accent-black); + font-size: 16px; + font-weight: 600; +} + +.omni-assets-drawer-head small { + margin-top: 2px; + color: var(--black-alpha-48); + font-size: 11px; +} + +.omni-assets-panel-tools { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} + +.omni-assets-panel-tools .view-toggle { + flex: 0 0 auto; +} + +.omni-assets-panel-tools .view-toggle button { + width: 28px; + height: 26px; +} + +.omni-assets-panel-tools .view-toggle svg { + width: 14px; + height: 14px; +} + +.omni-assets-search { + display: flex; + align-items: center; + width: 132px; + height: 32px; + gap: 6px; + padding: 0 9px; + border: 1px solid var(--border-faint); + border-radius: var(--r-md); + color: var(--black-alpha-48); + background: var(--background-lighter); +} + +.omni-assets-search:focus-within { + border-color: var(--black-alpha-24); + background: var(--surface); +} + +.omni-assets-search svg { + width: 14px; + height: 14px; + flex: 0 0 auto; +} + +.omni-assets-search input { + width: 100%; + min-width: 0; + padding: 0; + border: 0; + outline: 0; + color: var(--accent-black); + background: transparent; + font: inherit; + font-size: 12px; +} + +.omni-assets-search input::placeholder { + color: var(--black-alpha-40); +} + +.omni-assets-drawer-head .x { + flex: 0 0 auto; +} + +.omni-assets-drawer-body { + padding: 24px 20px 32px; + background: var(--surface); +} + +.omni-assets-empty { + min-height: 220px; + display: grid; + place-content: center; + justify-items: center; + gap: 10px; + color: var(--black-alpha-48); + text-align: center; +} + +.omni-assets-empty svg { + width: 24px; + height: 24px; +} + +.omni-assets-empty p { + margin: 0; + font-size: 13px; +} + +.omni-assets-group + .omni-assets-group { + margin-top: 28px; +} + +.omni-assets-group h3 { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 12px; + color: var(--accent-black); + font-size: 13px; + font-weight: 600; +} + +.omni-assets-group h3 em { + color: var(--black-alpha-48); + font-family: var(--font-mono); + font-size: 11px; + font-style: normal; + font-weight: 400; +} + +.omni-assets-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.omni-assets-grid.is-list { + grid-template-columns: 1fr; +} + +.omni-assets-card { + display: flex; + flex-direction: column; + gap: 8px; + padding: 0; + border: 1px solid var(--border-faint); + border-radius: var(--r-md); + background: var(--surface); + color: var(--accent-black); + text-align: left; + cursor: pointer; + overflow: hidden; + transition: background var(--t-base), border-color var(--t-base); +} + +.omni-assets-card:hover:not(:disabled) { + border-color: var(--border-loud); + background: var(--background-lighter); +} + +.omni-assets-card:disabled { + cursor: default; + opacity: .72; +} + +.omni-assets-card.is-doc, +.omni-assets-grid.is-list .omni-assets-card { + flex-direction: row; + align-items: center; + gap: 10px; + padding: 10px; +} + +.omni-assets-thumb { + position: relative; + display: block; + width: 100%; + aspect-ratio: 1; + background: var(--background-lighter); + overflow: hidden; +} + +.omni-assets-grid.is-list .omni-assets-thumb { + width: 52px; + height: 52px; + flex: 0 0 auto; + aspect-ratio: auto; + border-radius: 6px; +} + +.omni-assets-thumb.is-doc, +.omni-assets-card.is-doc .omni-assets-thumb { + width: 40px; + height: 40px; + flex: 0 0 auto; + aspect-ratio: auto; + display: grid; + place-items: center; + border-radius: 6px; + color: var(--heat); + background: var(--heat-12); +} + +.omni-assets-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.omni-assets-thumb > i { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: var(--black-alpha-48); +} + +.omni-assets-thumb > i svg, +.omni-assets-thumb.is-doc svg { + width: 18px; + height: 18px; +} + +.omni-assets-play { + position: absolute; + inset: 0; + display: grid; + place-items: center; + background: rgba(0, 0, 0, .24); + color: var(--surface); +} + +.omni-assets-play svg { + width: 20px; + height: 20px; + fill: currentColor; +} + +.omni-assets-meta { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + padding: 0 10px 10px; +} + +.omni-assets-card.is-doc .omni-assets-meta, +.omni-assets-grid.is-list .omni-assets-meta { + padding: 0; +} + +.omni-assets-meta strong, +.omni-assets-meta small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.omni-assets-meta strong { + color: var(--accent-black); + font-size: 12px; + font-weight: 500; +} + +.omni-assets-meta small { + color: var(--black-alpha-48); + font-family: var(--font-mono); + font-size: 11px; +} + +@media (min-width: 1101px) { + .omni-session-page.has-assets-panel { + box-sizing: border-box; + padding-right: min(440px, 36vw); + } + + .omni-session-page.has-assets-panel .omni-session-shell { + width: min(760px, calc(100% - 42px)); + } +} + +@media (max-width: 1100px) { + .omni-assets-drawer { + width: min(400px, calc(100vw - 16px)); + } +} + +@media (max-width: 720px) { + .omni-assets-entry { + right: 12px; + width: 36px; + padding: 0; + justify-content: center; + } + + .omni-assets-entry span { + display: none; + } + + .omni-assets-drawer { + width: calc(100vw - 8px); + } + + .omni-assets-drawer-head { + align-items: flex-start; + flex-wrap: wrap; + gap: 12px; + } + + .omni-assets-panel-tools { + width: 100%; + margin-left: 0; + } + + .omni-assets-search { + flex: 1 1 auto; + } + + .omni-assets-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/core/frontend/src/routes/omni-session.tsx b/core/frontend/src/routes/omni-session.tsx index 23e95bc..9ef758c 100644 --- a/core/frontend/src/routes/omni-session.tsx +++ b/core/frontend/src/routes/omni-session.tsx @@ -8,10 +8,13 @@ import { Download, FileText, FolderOpen, + Grid2X2, Image, + List, Play, Plus, Pencil, + Search, Sparkles, Square, Upload, @@ -296,6 +299,87 @@ const AGENT_POLL_INTERVAL_MS = 1500; // ────────────────────────────────────────────────────────── 卡片 +type SessionAssetKind = "image" | "video" | "document"; + +type SessionAsset = { + key: string; + kind: SessionAssetKind; + title: string; + thumb?: string; + src?: string; + promptTitle?: string; + promptBody?: string; +}; + +/** 从本会话已加载 messages(+pinned_refs / 会话上传)聚合聊天资产,不另打 BE。 */ +function collectSessionAssets( + messages: CreationMessage[], + pinnedRefs: CreationRef[] = [], + sessionUploads: CreationRef[] = [], +): SessionAsset[] { + const seen = new Set(); + const out: SessionAsset[] = []; + + const push = (item: SessionAsset) => { + if (!item.key || seen.has(item.key)) return; + seen.add(item.key); + out.push(item); + }; + + const addRef = (ref: CreationRef | null | undefined) => { + if (!ref?.id) return; + const cover = (ref.cover || "").trim(); + push({ + key: `ref:${ref.type}:${ref.id}`, + kind: "image", + title: shortRefName(ref.name) || REF_CHIP_LABEL[ref.type] || ref.name || "参考素材", + thumb: cover || undefined, + src: cover || undefined, + }); + }; + + for (const ref of pinnedRefs) addRef(ref); + for (const ref of sessionUploads) addRef(ref); + + for (const message of messages) { + for (const ref of message.refs || []) addRef(ref); + + if (message.kind === "result") { + const assets = (message.payload?.assets as Array> | undefined) || []; + assets.forEach((asset, index) => { + const url = (asset.url || asset.cover || "").trim(); + const cover = (asset.cover || asset.url || "").trim(); + if (!url && !cover) return; + const video = asset.type === "video"; + const id = asset.id || url || `${message.id}-${index}`; + push({ + key: `result:${id}`, + kind: video ? "video" : "image", + title: video + ? (assets.length > 1 ? `生成视频 ${index + 1}` : "生成视频") + : (assets.length > 1 ? `生成结果 ${index + 1}` : "生成图片"), + thumb: cover || undefined, + src: url || cover, + }); + }); + } + + if (message.kind === "prompt_file") { + const title = String(message.payload?.title || "视频生成Prompt.md"); + const body = String(message.payload?.body || ""); + push({ + key: `prompt:${message.id || title}`, + kind: "document", + title, + promptTitle: title, + promptBody: body || "暂无内容", + }); + } + } + + return out; +} + function strategyField(payload: Record, ...keys: string[]): string { for (const key of keys) { const value = payload[key]; @@ -435,7 +519,7 @@ function PlanCard({ payload }: { payload: Record }) { ,预计覆盖约 90% 时长 ) : null} - + 最终输入: Prompt.md + {String(payload.ref_count ?? 0)} 组参考素材 @@ -469,17 +553,18 @@ function PromptFileCard({ ); } -/** 视频 5 步闸门:策略/方案/Prompt 下方的「按这个继续 / 我想改」。 */ +/** 视频 5 步闸门:策略/方案/Prompt 下方的「按这个继续 / 我想改」。 + * 「我想改」在卡片内展开反馈输入,不再依赖底部 composer。 */ function StepConfirmCard({ message, disabled, onConfirm, - onRevise, + onReviseSubmit, }: { message: CreationMessage; disabled: boolean; onConfirm: () => void; - onRevise: () => void; + onReviseSubmit: (feedback: string) => void; }) { const submitted = Boolean(message.payload.submitted); const step = String(message.payload.step || ""); @@ -487,41 +572,113 @@ function StepConfirmCard({ const action = String(answers.step_action || ""); const stepLabel = step === "strategy" ? "创作策略" : step === "plan" ? "视频方案" : step === "prompt" ? "出片 Prompt" : "这一步"; + const placeholder = + step === "strategy" + ? "说说你想怎么改策略…" + : step === "plan" + ? "说说你想怎么改方案…" + : step === "prompt" + ? "说说你想怎么改 Prompt…" + : "说说你想怎么改…"; + const [revising, setRevising] = useState(false); + const [feedback, setFeedback] = useState(""); + const reviseRef = useRef(null); + + useEffect(() => { + if (!revising) return; + const el = reviseRef.current; + if (!el) return; + el.focus(); + const len = el.value.length; + el.setSelectionRange(len, len); + }, [revising]); + + const canSubmit = feedback.trim().length > 0; + return ( -
-
- {submitted ? (action === "revise" ? `已收到对${stepLabel}的修改意见` : `已确认${stepLabel}`) : `请确认${stepLabel}`} - {submitted ? (action === "revise" ? "正在按你的反馈重写" : "继续下一步") : (message.text || "确认后继续;要改可以直接说。")} -
- {submitted ? null : ( -
- - +
+
+
+ {submitted ? (action === "revise" ? `已收到对${stepLabel}的修改意见` : `已确认${stepLabel}`) : revising ? `修改${stepLabel}` : `请确认${stepLabel}`} + {submitted ? (action === "revise" ? "正在按你的反馈重写" : "继续下一步") : revising ? "写下你的想法,我们按这个重做这一步。" : (message.text || "确认后继续;要改可以直接说。")}
- )} + {submitted || revising ? null : ( +
+ + +
+ )} +
+ {!submitted && revising ? ( +
+