From 463613c0d7283d41d6bc4bcd8e7252938321a0d4 Mon Sep 17 00:00:00 2001 From: "Azmat@qq.com" Date: Mon, 14 Sep 2026 11:47:49 +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 | 304 +++++++++++++-- core/backend/apps/ai/test_creation_agent.py | 96 ++++- core/backend/apps/ai/views.py | 154 +++++++- .../apps/projects/test_quick_create.py | 2 + core/frontend/src/omni-session-page.css | 122 ++++-- core/frontend/src/routes/omni-session.tsx | 352 +++++++++++++----- 全能创作-契约-2026-09-02.md | 42 ++- 7 files changed, 875 insertions(+), 197 deletions(-) diff --git a/core/backend/apps/ai/creation_agent.py b/core/backend/apps/ai/creation_agent.py index bb5d34e..59ea1c7 100644 --- a/core/backend/apps/ai/creation_agent.py +++ b/core/backend/apps/ai/creation_agent.py @@ -7,8 +7,9 @@ 铁律(踩过就回不来的三条): 1. **视频 5–10 分钟,绝不在 SSE 里等。** 生成工具立刻返回 task_id,落一条 `generating` 消息,发 `task` 事件,收流。前端轮询完成后原地换成 `result`。 -2. **`ask_user` 一旦被调用就中断循环。** 反问的意义是等人回答,继续跑下去 - 等于自问自答。 +2. **闸门必须等人确认。** `ask_user` / `write_strategy` / `write_plan` / + `write_prompt` 一旦落卡就中断循环;视频 5 步(澄清→策略→方案→Prompt→出片确认) + 不可同轮连跳。 3. **一条用户消息最多计费生成一次。** 对话式会放大调用量,一句「多做几版」 能烧掉一堆积分。 @@ -39,6 +40,159 @@ MAX_TOOL_ROUNDS = 8 # 单条用户消息最多触发一次计费生成(契约 §4) MAX_BILLED_GENERATIONS = 1 +# 视频闸门阶段(落在 conversation.memory.stage;resume 靠它) +# clarify → strategy → plan → prompt → confirm → done +VIDEO_GATE_STAGES = ("clarify", "strategy", "plan", "prompt", "confirm", "done") +_STEP_CONFIRM_LABELS = { + "strategy": "创作策略已写好。确认后继续写方案;要改就点「我想改」或直接说改哪里。", + "plan": "视频方案已写好。确认后继续出 Prompt;要改就点「我想改」或直接说改哪里。", + "prompt": "出片 Prompt 已写好。确认后选择参数并生成;要改就点「我想改」或直接说改哪里。", +} + + +def get_video_gate_stage(conversation: CreationConversation) -> str: + memory = conversation.memory if isinstance(conversation.memory, dict) else {} + stage = str(memory.get("stage") or "clarify").strip() + return stage if stage in VIDEO_GATE_STAGES else "clarify" + + +def set_video_gate_stage( + conversation: CreationConversation, + stage: str, + *, + pending_video_prompt: str | None = None, + clear_pending_prompt: bool = False, +) -> None: + """持久化闸门阶段;可选缓存尚未展示的 video_prompt。""" + if stage not in VIDEO_GATE_STAGES: + stage = "clarify" + memory = dict(conversation.memory or {}) + memory["stage"] = stage + if pending_video_prompt is not None: + memory["pending_video_prompt"] = str(pending_video_prompt) + if clear_pending_prompt: + memory.pop("pending_video_prompt", None) + conversation.memory = memory + conversation.save(update_fields=["memory", "updated_at"]) + + +def get_pending_video_prompt(conversation: CreationConversation) -> str: + memory = conversation.memory if isinstance(conversation.memory, dict) else {} + return str(memory.get("pending_video_prompt") or "").strip() + + +def _step_confirm_payload(step: str) -> dict: + return { + "interaction": "step_confirm", + "step": step, + "fields": [ + { + "key": "step_action", + "label": "这一步可以继续吗?", + "type": "single", + "required": True, + "options": [ + {"value": "confirm", "label": "按这个继续"}, + {"value": "revise", "label": "我想改"}, + ], + } + ], + "submitted": False, + "answers": {}, + } + + +def append_step_confirm(conversation: CreationConversation, step: str) -> CreationMessage: + return append_message( + conversation, + role="assistant", + kind=CreationMessage.Kind.ELICIT, + text=_STEP_CONFIRM_LABELS.get(step, "请确认这一步后再继续。"), + payload=_step_confirm_payload(step), + ) + + +def emit_prompt_gate( + conversation: CreationConversation, video_prompt: str | None = None +) -> list[CreationMessage]: + """方案确认后:落 prompt_file + 步骤确认卡(确定性,不再跑模型)。""" + prompt = (video_prompt or get_pending_video_prompt(conversation) or "").strip() + if not prompt: + return [] + prompt = apply_product_voice_visual_guard(conversation, prompt) + messages = [] + prompt_file = append_message( + conversation, + role="assistant", + kind=CreationMessage.Kind.PROMPT_FILE, + payload={ + "title": "视频生成Prompt.md", + "body": prompt, + "ref_count": len(conversation.pinned_refs or []), + }, + ) + messages.append(prompt_file) + # 缓存供最终确认卡使用 + set_video_gate_stage(conversation, "prompt", pending_video_prompt=prompt) + messages.append(append_step_confirm(conversation, "prompt")) + return messages + + +def emit_final_confirm_gate( + conversation: CreationConversation, + *, + context: "AgentContext | None" = None, +) -> CreationMessage | None: + """Prompt 确认后:落最终积分确认卡(确定性)。""" + if conversation.mode != CreationConversation.Mode.VIDEO: + return None + prompt = get_pending_video_prompt(conversation) + if not prompt: + last = ( + conversation.messages.filter(kind=CreationMessage.Kind.PROMPT_FILE) + .order_by("-seq") + .first() + ) + prompt = str((last.payload or {}).get("body") or "").strip() if last else "" + if not prompt: + return None + prompt = apply_product_voice_visual_guard(conversation, prompt) + credits = 0 + if context is not None: + try: + credits = estimate_video_credits(context) + except Exception: # noqa: BLE001 + credits = 0 + else: + # 无 AgentContext 时用临时壳估分(model_config 可空) + try: + credits = estimate_video_credits( + AgentContext( + conversation=conversation, + user=conversation.created_by, + model_config=None, # type: ignore[arg-type] + ) + ) + except Exception: # noqa: BLE001 + credits = 0 + confirm = append_message( + conversation, + role="assistant", + kind=CreationMessage.Kind.CONFIRM, + payload={ + "kind": "video", + "label": "开始生成", + "estimated_credits": credits, + "video_prompt": prompt, + "submitted": False, + "params": snapshot_session_params(conversation), + "param_options": confirm_param_options(True), + }, + ) + set_video_gate_stage(conversation, "confirm", pending_video_prompt=prompt) + return confirm + + # 记忆压缩(契约 §5):超过这么多条消息就把最老的一批压成一段摘要, # 只保留最近 KEEP_RECENT_MESSAGES 条原文。 COMPRESS_AFTER_MESSAGES = 24 @@ -549,7 +703,8 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict "description": ( "写「创作策略理解」卡:说清这条片给谁看、他为什么会信、你想让他信什么、整体创作方向。" "四个字段都必须写具体非空文案,禁止空字符串。" - "仅当用户明确要做片/出方案时,在 write_plan 之前调一次;打招呼或闲聊不要调。" + "调完会停下来等用户确认或提出修改,不要同轮接着 write_plan。" + "仅当用户明确要做片/出方案时调用;打招呼或闲聊不要调。" ), "parameters": { "type": "object", @@ -568,11 +723,13 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict "function": { "name": "write_plan", "description": ( - "写「视频最终方案」卡并请用户确认。**仅当用户明确要做片/出方案/改方案时调用**;" - "打招呼或闲聊不要调。调完会等用户点确认,确认后平台直接按 video_prompt 出片。" - "usp / points / timeline 是给用户看的卡片正文,必须写满具体文案,禁止空着只交 video_prompt。" - "video_prompt 按系统里的「出片脚本写法」写成口播秒级分镜(专业创作同口径),不要只写大纲。" - "先调 write_strategy 再调它。" + "写「视频最终方案」卡(USP/卖点/时间轴)并请用户确认。" + "**仅当用户已确认策略、或明确要改方案时调用**;打招呼或闲聊不要调。" + "调完只出方案卡并停下等人确认 —— 不要同轮出 Prompt 卡或积分确认卡。" + "usp / points / timeline 必须写满具体文案;同时把完整 video_prompt 写好存档," + "用户确认方案后由平台展示 Prompt。" + "video_prompt 按系统里的「出片脚本写法」写成口播秒级分镜,不要只写大纲。" + "先有已确认的 write_strategy,再调它。" ), "parameters": { "type": "object", @@ -613,6 +770,31 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict }, }, }) + tools.append({ + "type": "function", + "function": { + "name": "write_prompt", + "description": ( + "写出片 Prompt 文件卡并请用户确认。" + "仅当用户已确认方案、或明确要求改 Prompt 时调用;" + "不要在 write_plan 同轮调用。调完停下等人确认,不要同轮出积分确认卡或直接出片。" + "video_prompt 必须是完整可执行的口播秒级分镜。" + ), + "parameters": { + "type": "object", + "properties": { + "video_prompt": { + "type": "string", + "description": ( + "交给出片模型的完整口播带货指令。" + "必须含总时长与画幅、按秒分段的景别/机位/运镜/动作/信息变化、人声(仅音频抬头)。" + ), + }, + }, + "required": ["video_prompt"], + }, + }, + }) else: tools.append({ "type": "function", @@ -914,6 +1096,7 @@ def submit_confirmed_video(*, conversation: CreationConversation, user, confirm_ payload={"task_id": str(task.id), "kind": "video", "prompt": prompt}, task=task, ) _remember_artifact(conversation, prompt, "video") + set_video_gate_stage(conversation, "done", clear_pending_prompt=True) return message, "" @@ -1068,7 +1251,8 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c "- 用户刚回答过你的问题时,直接沿着答案继续;不要复述答案,也不要额外回一句「收到」。", "- 用户选择暂不提供某项素材时,把它当成明确授权:按已有信息和合理默认继续。除非任务客观上无法完成,否则不要再次追问同一素材。", "- 用户说「你来定」「你帮我选」「随便」「都行」时,就是授权你做专业判断;直接选合理方案继续,不要把选择题再抛回去。", - "- 禁止问「要不要继续」「要不要生成」「是否开始创作」这类流程问题。用户已经提出创作需求且信息够了,就直接写策略/方案;真正扣积分前另有确认卡。", + "- 禁止问「要不要继续」「要不要生成」「是否开始创作」这类流程问题。缺信息用 ask_user;信息够了就写策略。" + " 视频每写完一步(策略/方案/Prompt)平台会出确认卡,由用户点「按这个继续」或提出修改;不要口头问流程。", "- 用户打招呼或闲聊(hi / 你好 / 在吗 / 你在干什么 / 嗯 / 好的 / ok):" " **禁止**调用 write_strategy、write_plan、generate_image,不要「整理方案」或直接开写脚本。", "- 会话里**还没有**商品/方向时:自然地告诉用户可以直接丢一句想法,别硬推销,也别用客服式结束语。", @@ -1094,7 +1278,33 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c lo = max(1, int(dur * 5.0)) hi = max(lo, min(85, int(dur * 5.7))) lines.append(f"- 当前按约 {dur} 秒出片,口播建议 {lo}–{hi} 字;write_plan 的 voice_chars 填这个区间。") - lines.append("- 只有用户明确要做片、出方案、改方案、换卖点/剧情时才调用 write_strategy / write_plan;闲聊与打招呼绝对不要。真要写方案时必须先 write_strategy 再 write_plan,video_prompt 按上面的秒级分镜规范写满,不要只给大纲,也禁止只回「好的,有需要再说」。") + lines.append( + "- 视频 5 步闸门(不可同轮连跳):①缺信息 ask_user 停 → ②write_strategy 停等确认 → " + "③用户确认后 write_plan 停等确认 → ④用户确认后 write_prompt(或平台代出 Prompt)停等确认 → " + "⑤用户确认后才出现积分确认卡出片。" + ) + lines.append( + "- 只有用户明确要做片、出方案、改方案、换卖点/剧情时才调用 write_strategy / write_plan / write_prompt;" + "闲聊与打招呼绝对不要。用户对某一步提出修改时,只重写那一步,不要跳到后面。" + "video_prompt 按上面的秒级分镜规范写满,不要只给大纲,也禁止只回「好的,有需要再说」。" + ) + stage = get_video_gate_stage(conversation) + memory = conversation.memory if isinstance(conversation.memory, dict) else {} + strategy_confirmed = bool(memory.get("strategy_confirmed")) + stage_hint = { + "clarify": "当前阶段=澄清:缺关键信息就 ask_user;信息够了只调 write_strategy。", + "strategy": ( + "当前阶段=策略已确认,请调用 write_plan 写方案;不要再写策略,不要 write_prompt。" + if strategy_confirmed else + "当前阶段=等策略确认:不要再 write_plan/write_prompt;用户确认后才会进入方案。若用户在改策略,只重调 write_strategy。" + ), + "plan": "当前阶段=等方案确认:不要 write_prompt 或出片。若用户在改方案,只重调 write_plan。", + "prompt": "当前阶段=等 Prompt 确认:不要出积分确认/出片。若用户在改 Prompt,只重调 write_prompt。", + "confirm": "当前阶段=等出片确认:不要再写策略/方案/Prompt;用户会在确认卡上点开始生成。", + "done": "当前阶段=已出片:等用户新的修改或新需求再行动。", + }.get(stage, "") + if stage_hint: + lines.append(f"- {stage_hint}") else: lines.extend([ "", @@ -1631,6 +1841,9 @@ def iter_creation_agent_events( "content": json.dumps(result.get("payload", {}), ensure_ascii=False), }) stop = stop or stop_after + # 闸门中断后不再执行同轮后续工具,防止策略+方案+Prompt 一锅端 + if stop_after: + break if stop: break @@ -1768,6 +1981,7 @@ _TOOL_LABELS = { "generate_image": "生成图片", "write_strategy": "梳理创作策略", "write_plan": "编排视频方案", + "write_prompt": "编写出片 Prompt", } @@ -1870,6 +2084,8 @@ def _dispatch_tool( payload=payload, ) # 反问一旦发出就必须停,等人回答。继续跑等于自问自答。 + if context.is_video: + set_video_gate_stage(context.conversation, "clarify") return { "payload": {"asked": True}, "_events": [{"type": "message", "message": _message_payload(message)}], @@ -1895,16 +2111,26 @@ def _dispatch_tool( kind=CreationMessage.Kind.STRATEGY, payload=strategy_payload, ) - # 策略卡只是「我理解对了吗」,不打断 —— 模型接着就该写方案 + confirm = append_step_confirm(context.conversation, "strategy") + set_video_gate_stage(context.conversation, "strategy") + memory = dict(context.conversation.memory or {}) + memory.pop("strategy_confirmed", None) + context.conversation.memory = memory + context.conversation.save(update_fields=["memory", "updated_at"]) + # 策略闸门:必须停下等人确认,禁止同轮连写方案 return { - "payload": {"written": True}, - "_events": [{"type": "message", "message": _message_payload(message)}], - }, False + "payload": {"written": True, "awaiting_step": "strategy"}, + "_events": [ + {"type": "message", "message": _message_payload(message)}, + {"type": "message", "message": _message_payload(confirm)}, + ], + }, True if name == "write_plan": video_prompt = str(args.get("video_prompt") or "").strip() if not video_prompt: return {"payload": {"error": "video_prompt 不能为空,请把完整出片指令写进去"}}, False + video_prompt = apply_product_voice_visual_guard(context.conversation, video_prompt) card = _coerce_plan_card_args(args if isinstance(args, dict) else {}) if not card["usp"] or not card["points"]: return { @@ -1936,34 +2162,32 @@ def _dispatch_tool( kind=CreationMessage.Kind.PLAN, payload=plan_payload, ) events.append({"type": "message", "message": _message_payload(plan)}) - - prompt_file = append_message( - context.conversation, role="assistant", - kind=CreationMessage.Kind.PROMPT_FILE, - payload={"title": "视频生成Prompt.md", "body": video_prompt, - "ref_count": plan_payload["ref_count"]}, + # video_prompt 先缓存:用户确认方案后由平台展示 Prompt 卡,本步不出 Prompt/积分卡 + set_video_gate_stage( + context.conversation, "plan", pending_video_prompt=video_prompt ) - events.append({"type": "message", "message": _message_payload(prompt_file)}) + step = append_step_confirm(context.conversation, "plan") + events.append({"type": "message", "message": _message_payload(step)}) + return { + "payload": {"awaiting_step": "plan", "stored_prompt": True}, + "_events": events, + }, True - credits = estimate_video_credits(context) - # video_prompt 存在确认卡里:用户点确认后直接照它出片,不再跑一轮模型 - confirm = append_message( - context.conversation, role="assistant", - kind=CreationMessage.Kind.CONFIRM, - payload={ - "kind": "video", - "label": "开始生成", - "estimated_credits": credits, - "video_prompt": video_prompt, - "submitted": False, - "params": snapshot_session_params(context.conversation), - "param_options": confirm_param_options(True), - }, - ) - events.append({"type": "message", "message": _message_payload(confirm)}) - events.append({"type": "credits", "estimated": credits}) - # 方案卡写着「仅需确认一次」—— 停在这里等人点,别自己往下出片 - return {"payload": {"awaiting_confirmation": True}, "_events": events}, True + if name == "write_prompt": + video_prompt = str(args.get("video_prompt") or "").strip() + if not video_prompt: + video_prompt = get_pending_video_prompt(context.conversation) + if not video_prompt: + return {"payload": {"error": "video_prompt 不能为空,请把完整出片指令写进去"}}, False + video_prompt = apply_product_voice_visual_guard(context.conversation, video_prompt) + emitted = emit_prompt_gate(context.conversation, video_prompt) + if not emitted: + return {"payload": {"error": "无法写出 Prompt 卡"}}, False + events = [{"type": "message", "message": _message_payload(m)} for m in emitted] + return { + "payload": {"awaiting_step": "prompt", "written": True}, + "_events": events, + }, True if name == "generate_image": if context.generations_used >= MAX_BILLED_GENERATIONS: diff --git a/core/backend/apps/ai/test_creation_agent.py b/core/backend/apps/ai/test_creation_agent.py index 2e0bada..5a035cc 100644 --- a/core/backend/apps/ai/test_creation_agent.py +++ b/core/backend/apps/ai/test_creation_agent.py @@ -836,23 +836,32 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): names = {t["function"]["name"] for t in fake.calls[0]["extra_body"]["tools"]} self.assertIn("write_strategy", names) self.assertIn("write_plan", names) + self.assertIn("write_prompt", names) self.assertNotIn("generate_image", names) - def test_strategy_card_does_not_stop_the_loop(self): + def test_strategy_card_stops_with_step_confirm(self): fake = FakeProvider([ _tool_chunks("write_strategy", {"target": "油皮通勤人群", "trust": "真实使用反馈", "belief": "值得一试", "direction": "达人 UGC 口播"}), - _text_chunks("方案我这就写"), + _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)) - strategy = [e for e in events if e.get("type") == "message" and e["message"]["kind"] == "strategy"] - self.assertEqual(strategy[0]["message"]["payload"]["target"], "油皮通勤人群") - # 策略卡只是「我理解对了吗」,不该停下来 - self.assertEqual(len(fake.calls), 2) + kinds = [e["message"]["kind"] for e in events if e.get("type") == "message"] + self.assertIn("strategy", kinds) + elicits = [ + e["message"] for e in events + if e.get("type") == "message" and e["message"]["kind"] == "elicit" + ] + self.assertTrue(elicits) + self.assertEqual(elicits[-1]["payload"].get("interaction"), "step_confirm") + self.assertEqual(elicits[-1]["payload"].get("step"), "strategy") + self.assertEqual((self.conversation.memory or {}).get("stage"), "strategy") + # 策略闸门必须停下等人确认,不能同轮连写方案 + self.assertEqual(len(fake.calls), 1) - def test_plan_emits_three_cards_and_stops_for_confirmation(self): + def test_plan_emits_plan_and_step_confirm_only(self): fake = FakeProvider([ _tool_chunks("write_plan", self._plan_args()), _text_chunks("不该跑到这一轮"), @@ -861,9 +870,76 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): events = _events(stream_creation_agent(conversation=self.conversation, user=self.user, text="做条视频", model_config=self.model)) kinds = [e["message"]["kind"] for e in events if e.get("type") == "message"] - self.assertEqual(kinds[-3:], ["plan", "prompt_file", "confirm"]) - self.assertTrue(any(e.get("type") == "credits" for e in events)) - # 「仅需确认一次」—— 必须停下等人点,不能自己往下烧钱出片 + self.assertIn("plan", kinds) + self.assertNotIn("prompt_file", kinds) + self.assertNotIn("confirm", kinds) + elicits = [ + e["message"] for e in events + if e.get("type") == "message" and e["message"]["kind"] == "elicit" + ] + self.assertTrue(elicits) + self.assertEqual(elicits[-1]["payload"].get("interaction"), "step_confirm") + self.assertEqual(elicits[-1]["payload"].get("step"), "plan") + self.conversation.refresh_from_db() + self.assertEqual((self.conversation.memory or {}).get("stage"), "plan") + self.assertEqual( + (self.conversation.memory or {}).get("pending_video_prompt"), + "0-3秒 近景手持商品…", + ) + # 方案闸门停下,不能同轮出 Prompt/积分卡 + self.assertEqual(len(fake.calls), 1) + + def test_write_prompt_emits_prompt_and_step_confirm(self): + fake = FakeProvider([ + _tool_chunks("write_prompt", {"video_prompt": "0-3秒 近景手持商品…"}), + _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)) + kinds = [e["message"]["kind"] for e in events if e.get("type") == "message"] + self.assertIn("prompt_file", kinds) + self.assertNotIn("confirm", kinds) + elicits = [ + e["message"] for e in events + if e.get("type") == "message" and e["message"]["kind"] == "elicit" + ] + self.assertEqual(elicits[-1]["payload"].get("step"), "prompt") + self.assertEqual(len(fake.calls), 1) + + def test_strategy_and_plan_same_round_stops_after_strategy(self): + """模型若同轮连调 write_strategy+write_plan,只落策略闸门。""" + from apps.ai.creation_agent import _parse_arguments # noqa: F401 + + # FakeProvider 一轮里多个 tool_call:模拟两个独立 rounds 不行,需单轮多 call。 + # 用手动合并:第一帧带两个 tool call deltas。 + def _multi_tool_chunks(*pairs): + chunks = [] + for index, (name, args) in enumerate(pairs): + import json as _json + chunks.append({ + "type": "tool_call", + "tool_calls": [{ + "index": index, + "function": {"name": name, "arguments": _json.dumps(args, ensure_ascii=False)}, + }], + }) + return chunks + + fake = FakeProvider([ + _multi_tool_chunks( + ("write_strategy", {"target": "油皮通勤人群", "trust": "真实使用反馈", + "belief": "值得一试", "direction": "达人 UGC 口播"}), + ("write_plan", self._plan_args()), + ), + _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)) + kinds = [e["message"]["kind"] for e in events if e.get("type") == "message"] + self.assertIn("strategy", kinds) + self.assertNotIn("plan", kinds) self.assertEqual(len(fake.calls), 1) def test_plan_without_video_prompt_is_rejected_without_emitting_cards(self): diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index f3ace6d..ef1090f 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -37,7 +37,10 @@ from .creation_agent import ( _ASSET_CARD_LABELS, apply_confirm_params, apply_session_params, + emit_final_confirm_gate, + emit_prompt_gate, is_greeting, + set_video_gate_stage, submit_confirmed_image, submit_confirmed_video, ) @@ -144,6 +147,119 @@ def _pending_chat_question(conversation: CreationConversation) -> CreationMessag return None +_STEP_CONTINUE_INSTRUCTIONS = { + "strategy": ( + "用户已确认创作策略。现在只调用 write_plan 写方案卡(含完整 video_prompt 存档);" + "不要再写策略,不要调用 write_prompt,不要出片。" + ), + "plan": ( + "用户已确认视频方案。若尚未写出 Prompt,调用 write_prompt 展示出片 Prompt;" + "不要重写策略/方案,不要出积分确认卡或直接出片。" + ), + "prompt": ( + "用户已确认出片 Prompt。不要再写策略/方案/Prompt;" + "平台会出积分确认卡,等用户点开始生成。" + ), +} + +_STEP_REVISE_INSTRUCTIONS = { + "strategy": ( + "用户要求修改创作策略。根据反馈只重新调用 write_strategy 写一版修订策略;" + "写完即停,不要同轮 write_plan / write_prompt。" + ), + "plan": ( + "用户要求修改视频方案。根据反馈只重新调用 write_plan 写一版修订方案" + "(含完整 video_prompt 存档);写完即停,不要同轮 write_prompt 或出片。" + ), + "prompt": ( + "用户要求修改出片 Prompt。根据反馈只重新调用 write_prompt 写一版修订 Prompt;" + "写完即停,不要出积分确认卡或直接出片。" + ), +} + + +def _handle_step_confirm_answer( + conversation: CreationConversation, + *, + card: CreationMessage, + answers: dict, + user, +) -> tuple[JsonResponse | None, bool, str]: + """处理步骤确认卡。返回 (短路径响应|None, force_creative, continuation_instruction)。""" + payload = dict(card.payload or {}) + step = str(payload.get("step") or "").strip() + action = str(answers.get("step_action") or answers.get("action") or "").strip().lower() + feedback = str(answers.get("feedback") or "").strip() + + # 兼容前端只传 confirm/revise 作为 answers 值 + if action not in {"confirm", "revise"}: + raw = " ".join(str(v) for v in answers.values()).lower() + if "revise" in raw or "改" in raw: + action = "revise" + else: + action = "confirm" + + payload["answers"] = { + "step_action": action, + **({"feedback": feedback} if feedback else {}), + } + payload["submitted"] = True + card.payload = payload + card.save(update_fields=["payload", "updated_at"]) + + if action == "revise": + set_video_gate_stage(conversation, step if step in {"strategy", "plan", "prompt"} else "clarify") + instruction = _STEP_REVISE_INSTRUCTIONS.get( + step, + "用户要求修改上一步产出。只重写被指出的那一步,不要跳到后续闸门。", + ) + if feedback: + instruction = f"{instruction} 用户反馈:{feedback}" + return None, True, instruction + + # confirm + if step == "strategy": + # 仍标 strategy,但打上已确认标记;write_plan 落库时会切到 plan + memory = dict(conversation.memory or {}) + memory["stage"] = "strategy" + memory["strategy_confirmed"] = True + conversation.memory = memory + conversation.save(update_fields=["memory", "updated_at"]) + return None, True, _STEP_CONTINUE_INSTRUCTIONS["strategy"] + + if step == "plan": + emitted = emit_prompt_gate(conversation) + if not emitted: + # 缓存丢了:让 agent 补 write_prompt + return None, True, _STEP_CONTINUE_INSTRUCTIONS["plan"] + conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER + conversation.save(update_fields=["agent_status", "updated_at"]) + return JsonResponse({ + "conversation_id": str(conversation.id), + "agent_status": conversation.agent_status, + "messages": [CreationMessageSerializer(m).data for m in emitted], + }, status=200), False, "" + + if step == "prompt": + confirm = emit_final_confirm_gate(conversation) + if confirm is None: + return None, True, _STEP_CONTINUE_INSTRUCTIONS["prompt"] + conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER + conversation.save(update_fields=["agent_status", "updated_at"]) + credits = int((confirm.payload or {}).get("estimated_credits") or 0) + body = { + "conversation_id": str(conversation.id), + "agent_status": conversation.agent_status, + "messages": [CreationMessageSerializer(confirm).data], + } + if credits: + body["estimated_credits"] = credits + return JsonResponse(body, status=200), False, "" + + # 未知 step:当普通继续 + return None, True, "用户已确认上一步。继续推进创作,不要复述确认。" + + class GenerateImageView(APIView): """独立生图(不绑项目)· 图片创作/模特图/平台套图共用 —— **异步**。 @@ -1560,7 +1676,18 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): pending = _pending_chat_question(conversation) if pending is not None: payload = dict(pending.payload or {}) - if payload.get("phase") == "gate": + if payload.get("interaction") == "step_confirm": + # 用户对着步骤确认卡直接打字 = 修改意见 + short, force_creative_turn, continuation_instruction = _handle_step_confirm_answer( + conversation, + card=pending, + answers={"step_action": "revise", "feedback": text}, + user=request.user, + ) + if short is not None: + return short + # 用户原文仍落库,方便回看;continuation 已带反馈 + elif payload.get("phase") == "gate": pending_fields = [item for item in (payload.get("pending_fields") or []) if isinstance(item, dict)] primary_field = pending_fields[0] if pending_fields else {} asset_types = [t for t in (primary_field.get("asset_types") or []) if t in TYPE_LABELS] or ["product"] @@ -1779,13 +1906,28 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): # 追问卡是一次性的:重复提交会让同一个问题在上下文里出现两次答案 return JsonResponse({"detail": "这个问题已经回答过了"}, status=409) payload = dict(card.payload or {}) - payload["answers"] = answers - payload["submitted"] = True - card.payload = payload - card.save(update_fields=["payload", "updated_at"]) + if payload.get("interaction") == "step_confirm": + short, force_creative_turn, continuation_instruction = _handle_step_confirm_answer( + conversation, + card=card, + answers=answers if isinstance(answers, dict) else {}, + user=request.user, + ) + if short is not None: + return short + text = "" + record_user_message = False + # force_creative / continuation 已设;跳过后面普通 elicit 逻辑 + else: + payload["answers"] = answers + payload["submitted"] = True + card.payload = payload + card.save(update_fields=["payload", "updated_at"]) # 素材选择闸门:用户愿意时再展开列表;跳过则直接沿原任务继续。 - if payload.get("phase") == "gate": + if payload.get("interaction") == "step_confirm": + pass # 已在上面处理 + elif payload.get("phase") == "gate": choice = str(answers.get("_asset_gate") or "").strip() if choice == "send": pending = payload.get("pending_fields") or [] diff --git a/core/backend/apps/projects/test_quick_create.py b/core/backend/apps/projects/test_quick_create.py index d2747df..ec55371 100644 --- a/core/backend/apps/projects/test_quick_create.py +++ b/core/backend/apps/projects/test_quick_create.py @@ -1370,3 +1370,5 @@ class QuickCreateCoordinatorTests(TestCase): + + diff --git a/core/frontend/src/omni-session-page.css b/core/frontend/src/omni-session-page.css index 0845e1e..95ad7b1 100644 --- a/core/frontend/src/omni-session-page.css +++ b/core/frontend/src/omni-session-page.css @@ -194,6 +194,11 @@ animation: omniMessageIn 220ms ease both; } +/* 追问/素材选择卡:关掉入场 opacity,poll 合并时即使短暂重挂也不会闪没 */ +.omni-elicit-card { + animation: none; +} + .omni-elicit-slot { width: min(760px, calc(100% - 44px)); margin: 0 0 24px 44px; @@ -370,37 +375,6 @@ font-size: 12px; } -.omni-plan-matrix { - width: 100%; - border-collapse: separate; - border-spacing: 5px; - table-layout: fixed; -} - -.omni-plan-matrix th, -.omni-plan-matrix td { - height: 28px; - border-radius: 7px; - color: #6d747e; - background: #f6f8fa; - font-size: 11px; - font-weight: 600; - text-align: center; -} - -.omni-plan-matrix th:first-child, -.omni-plan-matrix td:first-child { - width: 106px; - padding-left: 8px; - color: #323944; - text-align: left; -} - -.omni-plan-matrix td.hit { - color: #fff; - background: var(--klein); -} - .omni-plan-summary { display: flex; align-items: center; @@ -507,6 +481,13 @@ background: rgba(255, 255, 255, .96); box-shadow: 0 15px 34px rgba(20, 27, 38, .10); backdrop-filter: blur(14px); + transition: border-color .18s ease, box-shadow .18s ease; +} + +/* 「我想改」后点亮输入区,outline:0 的 textarea 本身看不出焦点 */ +.omni-session-composer.is-revise-hint { + border-color: rgba(0, 47, 167, .42); + box-shadow: 0 15px 34px rgba(20, 27, 38, .10), 0 0 0 3px rgba(0, 47, 167, .14); } #omniSessionPrompt { @@ -1157,7 +1138,8 @@ .omni-prompt-file-card, .omni-result-card, .omni-process-card, - .omni-elicit-slot { + .omni-elicit-slot, + .omni-step-confirm-card { width: 100%; margin-left: 0; } @@ -1313,6 +1295,76 @@ pointer-events: none; } +/* ── 步骤确认(.omni-step-confirm-card)· 策略/方案/Prompt 闸门 + 与 .omni-strategy-card 同宽同左缩进,避免策略卡下方出现一条瘦操作条 */ +.omni-step-confirm-card { + box-sizing: border-box; + width: min(760px, calc(100% - 44px)); + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 0 0 24px 44px; + padding: 14px 16px; + border: 1px solid rgba(34, 42, 54, 0.09); + border-radius: 16px; + background: #fff; + box-shadow: 0 12px 30px rgba(20, 27, 38, .065); + /* 不走入场 opacity 动画:poll 重渲染时避免「闪一下」 */ + animation: none; +} + +.omni-step-confirm-card.is-submitted { + opacity: 0.72; +} + +.omni-step-confirm-copy { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.omni-step-confirm-copy strong { + color: #222a36; + font-size: 14px; + font-weight: 600; +} + +.omni-step-confirm-copy span { + color: #575d66; + font-size: 12px; + line-height: 1.4; +} + +.omni-step-confirm-actions { + display: flex; + flex-shrink: 0; + gap: 8px; +} + +.omni-step-confirm-actions button { + padding: 8px 16px; + border: 0; + border-radius: 8px; + background: var(--klein); + color: #fff; + font-size: 13px; + cursor: pointer; +} + +.omni-step-confirm-actions button.is-secondary { + background: #fff; + border: 1px solid rgba(34, 42, 54, 0.14); + color: #222a36; +} + +.omni-step-confirm-actions button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + /* ── 确认闸门(.omni-confirm-card)· 设计稿里是方案卡下面那条操作行,这里独立成卡。 积分数字挂在按钮里 —— 用户按下去之前就该知道要花多少。 */ .omni-confirm-card { @@ -2008,6 +2060,12 @@ } +/* 闸门气泡所在行不入场闪:5 闸门按钮需在 awaiting_user 期间保持实心可见 */ +.omni-chat-row.agent:has(.omni-gate-actions), +.omni-chat-row.agent:has(.omni-gate-answered) { + animation: none; +} + .omni-gate-bubble { display: flex; flex-direction: column; diff --git a/core/frontend/src/routes/omni-session.tsx b/core/frontend/src/routes/omni-session.tsx index 06592ca..23e95bc 100644 --- a/core/frontend/src/routes/omni-session.tsx +++ b/core/frontend/src/routes/omni-session.tsx @@ -343,7 +343,6 @@ function StrategyCard({ payload }: { payload: Record }) { } type PlanTimelineItem = { start: number; end: number; stage: string; desc?: string }; -type PlanMatrix = { shots?: number; rows?: Array<{ point: string; hits?: number[] }> }; function coercePlanPoints(raw: unknown): string[] { if (Array.isArray(raw)) { @@ -387,9 +386,6 @@ function PlanCard({ payload }: { payload: Record }) { const points = coercePlanPoints(payload.points); const timeline = (payload.timeline as PlanTimelineItem[] | undefined) || []; const voice = coerceVoiceChars(payload.voice_chars); - const matrix = (payload.matrix as PlanMatrix | undefined) || undefined; - const matrixRows = Array.isArray(matrix?.rows) ? matrix!.rows! : []; - const shotCount = Math.max(4, Number(matrix?.shots) || 4); const format = (value: number) => (Number.isInteger(value) ? String(value) : value.toFixed(1)); const pointLabels = ["核心支撑 P0", "视觉支撑 P0", "转化支撑 P0"]; @@ -397,7 +393,7 @@ function PlanCard({ payload }: { payload: Record }) {
视频最终方案 - 仅需确认一次 + 请确认后再出 Prompt
卖点做减法 @@ -429,36 +425,6 @@ function PlanCard({ payload }: { payload: Record }) {
) : null} - {matrixRows.length > 0 ? ( -
- 卖点覆盖矩阵 - - - - - {Array.from({ length: shotCount }, (_, index) => ( - - ))} - - - - {matrixRows.map((row) => { - const hits = new Set((row.hits || []).map((n) => Number(n))); - return ( - - - {Array.from({ length: shotCount }, (_, index) => ( - - ))} - - ); - })} - -
卖点镜头 {index + 1}
{row.point} - {hits.has(index + 1) ? "✓" : ""} -
-
- ) : null}
{voice.length === 2 ? ( @@ -503,28 +469,89 @@ function PromptFileCard({ ); } +/** 视频 5 步闸门:策略/方案/Prompt 下方的「按这个继续 / 我想改」。 */ +function StepConfirmCard({ + message, + disabled, + onConfirm, + onRevise, +}: { + message: CreationMessage; + disabled: boolean; + onConfirm: () => void; + onRevise: () => void; +}) { + const submitted = Boolean(message.payload.submitted); + const step = String(message.payload.step || ""); + const answers = (message.payload.answers as Record | undefined) || {}; + const action = String(answers.step_action || ""); + const stepLabel = + step === "strategy" ? "创作策略" : step === "plan" ? "视频方案" : step === "prompt" ? "出片 Prompt" : "这一步"; + return ( +
+
+ {submitted ? (action === "revise" ? `已收到对${stepLabel}的修改意见` : `已确认${stepLabel}`) : `请确认${stepLabel}`} + {submitted ? (action === "revise" ? "正在按你的反馈重写" : "继续下一步") : (message.text || "确认后继续;要改可以直接说。")} +
+ {submitted ? null : ( +
+ + +
+ )} +
+ ); +} + /** 追问默认是普通聊天气泡;下面的卡片只为兼容旧会话数据。 */ function ElicitCard({ message, disabled, onSubmit, + onStepRevise, }: { message: CreationMessage; disabled: boolean; /** answers 给模型读(人话),refs 给后端取卖点和参考图。**选素材必须两样都回**, 只回名字的话同名素材会配错货。 */ onSubmit: (answers: Record, refs: CreationRef[]) => void; + /** 步骤确认「我想改」:打开输入框让用户写反馈 */ + onStepRevise?: () => void; }) { const fields = ((message.payload.fields as CreationField[] | undefined) || []).filter(Boolean); const submitted = Boolean(message.payload.submitted); const saved = (message.payload.answers as Record | undefined) || {}; const interaction = String(message.payload.interaction || ""); + // hooks 必须在任何 early return 之前:step_confirm / chat / gate 共用同一组件身份 const [answers, setAnswers] = useState>(saved); const [assetOptions, setAssetOptions] = useState>({}); const [assetPicked, setAssetPicked] = useState>({}); useEffect(() => { - if (submitted || interaction === "chat") return; + if (submitted || interaction === "chat" || interaction === "step_confirm") return; // 选素材字段要现拉候选:后端只给了 asset_types,具体有哪些素材是团队数据 const assetFields = fields.filter((f) => f.type === "asset"); if (assetFields.length === 0) return; @@ -554,6 +581,20 @@ function ElicitCard({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [message.id, submitted, interaction]); + if (interaction === "step_confirm") { + return ( + onSubmit({ step_action: "confirm" }, [])} + onRevise={() => { + if (onStepRevise) onStepRevise(); + else onSubmit({ step_action: "revise" }, []); + }} + /> + ); + } + if (interaction === "chat") { return (
@@ -1018,15 +1059,100 @@ function turnLooksSettled(detail: CreationConversationDetail) { return msgs.slice(userIdx + 1).some((m) => m.role !== "user"); } +/** payload 是否实质相同(忽略 key 顺序之外的引用身份) */ +function samePayload(a: Record, b: Record) { + return JSON.stringify(a || {}) === JSON.stringify(b || {}); +} + +/** 两条消息内容是否可原地复用(保持 React 组件身份,避免入场动画重放) */ +function sameMessageContent(prev: CreationMessage, next: CreationMessage) { + return ( + prev.id === next.id + && prev.kind === next.kind + && prev.role === next.role + && (prev.text || "") === (next.text || "") + && serverSeq(prev) === serverSeq(next) + && samePayload(prev.payload || {}, next.payload || {}) + ); +} + /** - * 合并服务端快照时保留尚未 ack 的乐观气泡。 - * 关键:只能把 local 对上「本轮新出现」的服务端用户消息(id 未见过或 seq 更大), - * 绝不能因为历史里有一条相同文案就把 pending local 吃掉。 + * 合并服务端消息时保留本地 UI 态: + * - 乐观 submitted / answers 尚未落库时不要被 poll 冲掉 + * - id/seq/payload 未变则复用原对象引用,避免 elicit / StepConfirm 卸载闪烁 + */ +function mergeMessagePreserveLocal(prevHit: CreationMessage | undefined, msg: CreationMessage): CreationMessage { + if (prevHit && sameMessageContent(prevHit, msg)) { + return prevHit; + } + const clientKey = prevHit?.clientKey || msg.clientKey || msg.id; + if (!prevHit) { + return msg.clientKey === clientKey ? msg : { ...msg, clientKey }; + } + // 开放中的追问/闸门:服务端尚未 submitted 时,保住本地已选选项与乐观提交 + if (prevHit.kind === "elicit" && msg.kind === "elicit") { + const prevPayload = prevHit.payload || {}; + const nextPayload = msg.payload || {}; + if (prevPayload.submitted && !nextPayload.submitted) { + return { + ...msg, + clientKey, + payload: { + ...nextPayload, + submitted: true, + answers: prevPayload.answers ?? nextPayload.answers, + }, + }; + } + // 字段定义未变时复用旧 payload 引用,避免选中态随 poll 重置 + if ( + !nextPayload.submitted + && !prevPayload.submitted + && samePayload( + { ...prevPayload, answers: undefined, submitted: undefined }, + { ...nextPayload, answers: undefined, submitted: undefined }, + ) + ) { + return { + ...prevHit, + text: msg.text || prevHit.text, + seq: msg.seq, + created_at: msg.created_at || prevHit.created_at, + clientKey, + payload: prevPayload, + }; + } + } + return { ...msg, clientKey }; +} + +/** + * 合并服务端快照时保留尚未 ack 的乐观气泡,并稳住未变消息的对象引用。 + * local 只对上「本轮新出现」的服务端用户消息,绝不按正文吞历史。 */ function keepUnackedLocals(prev: CreationMessage[], incoming: CreationMessage[]): CreationMessage[] { + const prevById = new Map(prev.map((m) => [m.id, m])); const locals = prev.filter((m) => isLocalUserId(m.id)); if (!locals.length) { - return incoming.map((msg) => (msg.clientKey ? msg : { ...msg, clientKey: msg.id })); + let changed = prev.length !== incoming.length; + const merged = incoming.map((msg, index) => { + const prevHit = prevById.get(msg.id) || (prev[index]?.id === msg.id ? prev[index] : undefined); + const next = mergeMessagePreserveLocal(prevHit, msg); + if (next !== prevHit) changed = true; + return next; + }); + if (!changed) { + // 长度与每条引用都未变 → 直接复用旧数组,阻断无意义重渲染 + let identical = true; + for (let i = 0; i < merged.length; i += 1) { + if (merged[i] !== prev[i]) { + identical = false; + break; + } + } + if (identical) return prev; + } + return merged; } const knownServerIds = new Set( prev.filter((m) => !isLocalUserId(m.id)).map((m) => m.id) @@ -1034,11 +1160,8 @@ function keepUnackedLocals(prev: CreationMessage[], incoming: CreationMessage[]) const knownSeq = lastKnownServerSeq(prev); const used = new Set(); const merged = incoming.map((msg) => { - const prevHit = prev.find((m) => m.id === msg.id); - const withKey = { - ...msg, - clientKey: prevHit?.clientKey || msg.clientKey || msg.id, - }; + const prevHit = prevById.get(msg.id); + let withKey = mergeMessagePreserveLocal(prevHit, msg); if (msg.role !== "user") return withKey; const isNewServerUser = !knownServerIds.has(msg.id) && serverSeq(msg) > knownSeq; @@ -1046,7 +1169,7 @@ function keepUnackedLocals(prev: CreationMessage[], incoming: CreationMessage[]) const local = locals.find((item) => !used.has(item.id) && sameUserBubble(item, msg)); if (!local) return withKey; used.add(local.id); - return { ...msg, clientKey: local.clientKey || local.id }; + return { ...withKey, clientKey: local.clientKey || local.id }; }); const leftover = locals.filter((m) => !used.has(m.id)); // leftover 追加在末尾;不要对历史做正文去重 @@ -1161,6 +1284,7 @@ export function OmniSessionPage({ return [makeLocalUserMessage(firstMessage?.trim() || "", firstRefs || [], id)]; }); const [prompt, setPrompt] = useState(""); + const [composerHint, setComposerHint] = useState(null); const [pendingRefs, setPendingRefs] = useState([]); const [sessionUploads, setSessionUploads] = useState(firstUploads || []); const [uploading, setUploading] = useState(false); @@ -1204,6 +1328,7 @@ export function OmniSessionPage({ const refs = message.refs || []; setPrompt(stripMentionText(message.text, refs)); setPendingRefs(refs); + setComposerHint(null); requestAnimationFrame(() => { composerRef.current?.focus(); composerRef.current?.setSelectionRange( @@ -1213,6 +1338,21 @@ export function OmniSessionPage({ }); }, []); + /** 「我想改」:露出底部输入框并聚焦,让用户写反馈(后端会把自由文本当 revise) */ + const beginStepRevise = useCallback(() => { + setComposerHint("说说要改哪里…"); + const focusComposer = () => { + const el = composerRef.current; + if (!el) return; + el.scrollIntoView({ behavior: "smooth", block: "nearest" }); + el.focus({ preventScroll: true }); + const len = el.value.length; + el.setSelectionRange(len, len); + }; + focusComposer(); + requestAnimationFrame(focusComposer); + }, []); + const feedRef = useRef(null); const firstSentRef = useRef(false); const pendingUserIdRef = useRef(bootLocalIdRef.current); @@ -1363,19 +1503,7 @@ export function OmniSessionPage({ .then((detail) => { if (cancelled) return; setConversation(detail); - setMessages((prev) => { - const before = new Map(prev.map((m) => [m.id, m])); - let changed = prev.length !== detail.messages.length; - const merged = detail.messages.map((next) => { - const old = before.get(next.id); - if (old && old.kind === next.kind && JSON.stringify(old.payload) === JSON.stringify(next.payload)) { - return old; - } - changed = true; - return next; - }); - return keepUnackedLocals(prev, changed ? merged : prev); - }); + setMessages((prev) => keepUnackedLocals(prev, detail.messages || [])); }) .catch(() => { /* 轮询失败静默重试,不打扰用户 */ @@ -1402,24 +1530,7 @@ export function OmniSessionPage({ } return detail; }); - setMessages((prev) => { - const before = new Map(prev.map((m) => [m.id, m])); - let changed = prev.length !== detail.messages.length; - const merged = detail.messages.map((next) => { - const oldMsg = before.get(next.id); - if ( - oldMsg - && oldMsg.kind === next.kind - && oldMsg.text === next.text - && JSON.stringify(oldMsg.payload) === JSON.stringify(next.payload) - ) { - return oldMsg; - } - changed = true; - return next; - }); - return keepUnackedLocals(prev, changed ? merged : prev); - }); + setMessages((prev) => keepUnackedLocals(prev, detail.messages || [])); if (localId && pendingUserIdRef.current === localId) { // 必须是「发送之后」新出现的用户消息,不能靠正文撞上历史气泡 const acked = detail.messages.some( @@ -1510,7 +1621,22 @@ export function OmniSessionPage({ clearThinking(); }, [conversationId, notify]); - const isPlanning = conversation?.agent_status === "planning" || streaming; + // awaiting_user 时不要被短暂 streaming 闪回拖进 planning 轮询,否则闸门按钮会跟着灰/闪 + const isPlanning = + conversation?.agent_status === "planning" + || (Boolean(streaming) && conversation?.agent_status !== "awaiting_user"); + + // 若状态已是等待用户而 streaming 仍残留,立刻收起,避免 send 被 streamingRef 卡住、按钮被灰掉 + useEffect(() => { + if (conversation?.agent_status !== "awaiting_user") return; + if (!streaming && !streamingRef.current) return; + holdPlanningUntilRef.current = 0; + streamingRef.current = false; + setStreaming(false); + setLiveText(""); + setLiveReasoning(""); + setActiveTool(""); + }, [conversation?.agent_status, streaming]); // 整理方案在 Celery:靠 poll 续进度。刷新/重进只要 agent_status=planning 就会接着转。 useEffect(() => { @@ -1537,7 +1663,8 @@ export function OmniSessionPage({ const send = useCallback( async (payload: Parameters[1]) => { - if (streamingRef.current) return; + // awaiting_user 下允许回答闸门;其它状态仍禁止并发发送 + if (streamingRef.current && conversation?.agent_status !== "awaiting_user") return; // 先占位用户气泡,再亮「正在整理方案」—— 顺序不能反 const isTextTurn = payload.kind !== "elicit_answer"; let localId: string | null = null; @@ -1648,7 +1775,7 @@ export function OmniSessionPage({ notify(status === 409 ? "info" : "error", (error as Error).message); } }, - [conversationId, mergeCreationDetail, notify] + [conversationId, conversation?.agent_status, mergeCreationDetail, notify] ); const handleStop = useCallback(async () => { @@ -1724,10 +1851,12 @@ export function OmniSessionPage({ const handleSend = () => { // 顺序要紧:先判 streaming 再清输入框。反过来的话,流式期间敲一次回车 // 会把已经打好的内容清空,消息却没发出去。 - if (streaming || uploading) return; + if (uploading) return; + if (streaming && conversation?.agent_status !== "awaiting_user") return; const text = prompt.trim(); if (!text && pendingRefs.length === 0) return; setPrompt(""); + setComposerHint(null); const refs = pendingRefs; setPendingRefs([]); void send({ kind: "text", text, refs }); @@ -1784,10 +1913,30 @@ export function OmniSessionPage({ - void send({ kind: "elicit_answer", reply_to: message.id, answers, refs }) + // 可见性只看 payload.submitted;awaiting_user 时即使 streaming 瞬间为 true 也不灰掉/误伤闸门按钮 + disabled={ + Boolean(message.payload?.submitted) + || (streaming && conversation?.agent_status !== "awaiting_user") } + onSubmit={(answers, refs) => { + // 乐观标记已提交,避免短路径返回前按钮还能连点 + setMessages((prev) => + prev.map((item) => + item.id === message.id + ? { + ...item, + payload: { + ...item.payload, + submitted: true, + answers: answers as Record, + }, + } + : item + ) + ); + void send({ kind: "elicit_answer", reply_to: message.id, answers, refs }); + }} + onStepRevise={beginStepRevise} /> ); case "strategy": @@ -1805,7 +1954,16 @@ export function OmniSessionPage({ })} /> ); - case "confirm": + case "confirm": { + // 视频:若仍有未确认的步骤闸门,禁止点「开始生成」(防旧会话/异常连跳) + const pendingStep = isVideo + ? messages.some( + (item) => + item.kind === "elicit" && + item.payload?.interaction === "step_confirm" && + !item.payload?.submitted + ) + : false; return ( void handleConfirm(message, nextParams)} /> ); + } case "generating": return ; case "result": @@ -1913,7 +2076,7 @@ export function OmniSessionPage({ {/* 流式中的临时气泡。挂 is-live 关掉入场动画 —— 它每来一个字符都会 重渲染,带动画的话整条会一直在闪;真消息落地时它被替换掉, 那一下也不该再演一次入场。 */} - {(streaming || conversation?.agent_status === "planning") && !hasGenerating ? ( + {(conversation?.agent_status !== "awaiting_user") && (streaming || conversation?.agent_status === "planning") && !hasGenerating ? ( /生成图片|生成视频/.test(activeTool) && !liveText ? ( ) : liveText ? ( @@ -1942,7 +2105,7 @@ export function OmniSessionPage({ ) : null} -