diff --git a/core/backend/apps/ai/creation_agent.py b/core/backend/apps/ai/creation_agent.py index 44694aa..51bcfa0 100644 --- a/core/backend/apps/ai/creation_agent.py +++ b/core/backend/apps/ai/creation_agent.py @@ -27,7 +27,18 @@ from django.core.serializers.json import DjangoJSONEncoder from django.db import transaction from .creation import append_message, pin_refs -from .creation_presets import apply_image_preset_prompt, preset_guidance, preset_workflow_guidance +from .creation_presets import ( + PLOT_TWIST_PRESET, + PLOT_TWIST_STORY_DEPTH_OPTIONS, + apply_image_preset_prompt, + apply_plot_twist_story_contract, + apply_video_preset_prompt, + plot_twist_story_depth, + plot_twist_story_contract, + preset_guidance, + preset_workflow_guidance, + video_preset_delivery_contract, +) from .mentions import TYPE_LABELS, infer_field_types, resolve_refs, search_mentions from .models import CreationConversation, CreationMessage, ModelConfig from .services import build_provider, get_default_model, get_seed_text_model, resolve_text_model @@ -41,8 +52,7 @@ MAX_TOOL_ROUNDS = 8 MAX_BILLED_GENERATIONS = 1 # 视频闸门阶段(落在 conversation.memory.stage;resume 靠它) -# prompt 仅兼容历史会话。新视频链路在方案确认后直接转入出片确认,Prompt 始终由平台在后台维护。 -# clarify → strategy → plan → confirm → done +# clarify → strategy → plan → prompt → confirm → done VIDEO_GATE_STAGES = ("clarify", "strategy", "plan", "prompt", "confirm", "done") _STEP_CONFIRM_LABELS = { "strategy": "创作策略已写好。确认后继续写方案;要改就点「我想改」或直接说改哪里。", @@ -51,6 +61,146 @@ _STEP_CONFIRM_LABELS = { } +def is_plot_twist_conversation(conversation: CreationConversation) -> bool: + return conversation.mode == CreationConversation.Mode.VIDEO and conversation.preset == PLOT_TWIST_PRESET + + +def active_plot_twist_story_depth(conversation: CreationConversation) -> str: + """时长是最终事实来源;用户中途改时长后,故事结构必须随之切换。""" + from_duration = plot_twist_story_depth(str((conversation.params or {}).get("duration") or "")) + if from_duration and from_duration["value"] != "smart": + return str(from_duration["value"]) + memory = conversation.memory if isinstance(conversation.memory, dict) else {} + return str(memory.get("plot_twist_story_depth") or "").strip() + + +def set_plot_twist_story_depth(conversation: CreationConversation, value: str) -> dict | None: + """卡片或自然语言选时长后,同步会话顶部参数与最终 Prompt 的故事结构。""" + if not is_plot_twist_conversation(conversation): + return None + depth = plot_twist_story_depth(value) + if depth is None: + return None + memory = dict(conversation.memory or {}) + params = dict(conversation.params or {}) + memory["plot_twist_story_depth"] = depth["value"] + if depth["duration"]: + params["duration"] = depth["duration"] + conversation.memory = memory + conversation.params = params + conversation.save(update_fields=["memory", "params", "updated_at"]) + return depth + + +def append_plot_twist_story_depth_question(conversation: CreationConversation) -> CreationMessage: + return append_message( + conversation, + role="assistant", + kind=CreationMessage.Kind.ELICIT, + text="你希望这支剧情带货视频做到什么程度?", + payload={ + "interaction": "plot_twist_story_depth", + "fields": [ + { + "key": "story_depth", + "label": "故事深度选择", + "type": "single", + "required": True, + "options": [ + {"value": item["value"], "label": f"{item['label']}|{item['summary']}"} + for item in PLOT_TWIST_STORY_DEPTH_OPTIONS + ], + } + ], + "submitted": False, + "answers": {}, + }, + ) + + +def _plot_twist_direction_fallback(conversation: CreationConversation) -> list[dict]: + """模型遗漏方向卡工具时的平台兜底,不能只留下「我准备了三个方向」的空话。""" + refs = [item for item in (conversation.pinned_refs or []) if isinstance(item, dict)] + product = next((str(item.get("name") or "").strip() for item in refs if item.get("type") == "product"), "这款商品") + return [ + { + "id": "misunderstanding", + "title": "误会翻盘", + "conflict": "主角把眼前的麻烦误判成无解,情绪不断升级。", + "product_role": f"{product} 作为关键证据或解决工具,在最需要时自然出现。", + "reversal": "原来问题的答案一直在眼前,误会被当场化解。", + "tone": "轻喜剧、反差感强", + }, + { + "id": "last_chance", + "title": "最后一次机会", + "conflict": "主角已经试过常规办法仍然失败,只剩最后一次选择。", + "product_role": f"{product} 承担最后一次可验证的尝试,完整展示正常使用过程。", + "reversal": "看似失败的局面被扭转,结果恰好印证核心卖点。", + "tone": "紧张后释然、转化更强", + }, + { + "id": "foreshadowing", + "title": "伏笔回收", + "conflict": "开头埋下一个不起眼的细节,人物关系或目标因此受阻。", + "product_role": f"{product} 先作为日常细节出现,后半段成为推动结局的关键线索。", + "reversal": "前面的细节被回收,观众才发现结局早有铺垫。", + "tone": "温暖小反转、故事感更强", + }, + ] + + +def _coerce_plot_twist_directions(raw) -> list[dict]: + """把模型给的三条方向收成可直接渲染的卡片字段。""" + if not isinstance(raw, list): + return [] + directions: list[dict] = [] + for index, item in enumerate(raw[:3], start=1): + if not isinstance(item, dict): + continue + title = str(item.get("title") or item.get("name") or "").strip() + conflict = str(item.get("conflict") or item.get("setup") or "").strip() + product_role = str(item.get("product_role") or item.get("product") or "").strip() + reversal = str(item.get("reversal") or item.get("twist") or "").strip() + tone = str(item.get("tone") or item.get("feel") or "").strip() + if not all((title, conflict, product_role, reversal)): + continue + directions.append({ + "id": str(item.get("id") or f"direction_{index}").strip() or f"direction_{index}", + "title": title, + "conflict": conflict, + "product_role": product_role, + "reversal": reversal, + "tone": tone or "剧情带货", + }) + return directions if len(directions) == 3 else [] + + +def _append_plot_twist_direction_question(context: "AgentContext", directions: list[dict]) -> CreationMessage: + return append_message( + context.conversation, + role="assistant", + kind=CreationMessage.Kind.ELICIT, + text="选一个剧情方向,我会按它写成完整方案。", + payload={ + "interaction": "plot_twist_directions", + "directions": directions, + "fields": [{ + "key": "story_direction", + "label": "三个剧情反转方向", + "type": "single", + "required": True, + "options": [ + {"value": item["id"], "label": item["title"]} + for item in directions + ], + }], + "submitted": False, + "answers": {}, + }, + ) + + 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() @@ -478,6 +628,17 @@ def text_already_guides(text: str) -> bool: return bool(_GUIDANCE_MARKER_RE.search((text or "").strip())) +_NUMERIC_REPLY_INSTRUCTION_RE = re.compile( + r"(?:你|请)?\s*(?:直接)?(?:回复|选择|选)\s*(?:数字|编号)?\s*" + r"1\s*(?:[/、,,]\s*2)(?:\s*(?:[/、,,]\s*3))?[^。!?!\n]*(?:[。!?!]|$)" +) + + +def strip_numeric_reply_instruction(text: str) -> str: + """选择器会把 1/2/3 直接做成按钮,正文不再要求用户手输数字。""" + return _NUMERIC_REPLY_INSTRUCTION_RE.sub("", text or "").strip() + + def default_reply_hint( conversation: CreationConversation | None = None, *, @@ -776,7 +937,7 @@ VIDEO_MODELS = ["Seedance 2.5", "Seedance 2.0", "Seedance 2.0 Fast", "Seedance 2 IMAGE_MODELS = ["Seedream5.0", "YQ image2"] RATIOS = ["16:9", "9:16", "4:3", "3:4", "1:1"] RESOLUTIONS = ["480p", "720p", "1080p"] -VIDEO_DURATIONS = ["智能时长", "4 秒", "5 秒", "6 秒", "8 秒", "10 秒", "12 秒", "15 秒", "30 秒"] +VIDEO_DURATIONS = ["智能时长", "4 秒", "5 秒", "6 秒", "8 秒", "10 秒", "12 秒", "15 秒", "30 秒", "60 秒"] IMAGE_COUNTS = ["1 张", "2 张", "4 张", "8 张"] SESSION_PARAM_KEYS = ("duration", "ratio", "resolution", "video_model", "count") _PARAM_TO_STORED = {"video_model": "model"} @@ -865,6 +1026,9 @@ def apply_session_params(conversation, fields, answers: dict) -> bool: if changed: conversation.params = current conversation.save(update_fields=["params", "updated_at"]) + if is_plot_twist_conversation(conversation): + # 用户在参数卡里改了时长,剧情结构也必须立即跟着切换。 + set_plot_twist_story_depth(conversation, str(current.get("duration") or "")) return changed @@ -911,6 +1075,9 @@ def apply_confirm_params(conversation, incoming: dict | None) -> tuple[dict, boo if changed: conversation.params = current conversation.save(update_fields=["params", "updated_at"]) + if is_plot_twist_conversation(conversation): + # 在确认卡改时长也要切换故事契约;随后视图会要求重写旧方案。 + set_plot_twist_story_depth(conversation, str(current.get("duration") or "")) return snapshot_session_params(conversation), duration_changed @@ -993,6 +1160,41 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict # 换商品/改参数仍由 wanted_asset_pick / wanted_param_keys 兜底追问。 return [t for t in tools if t.get("function", {}).get("name") == "search_library"] if context.is_video: + if is_plot_twist_conversation(context.conversation) and active_plot_twist_story_depth(context.conversation): + tools.append({ + "type": "function", + "function": { + "name": "present_story_directions", + "description": ( + "剧情反转带货预设在用户选定故事深度后,先调用此工具展示 3 个可点击的剧情方向。" + "每条都必须有不同的冲突、商品承担的实际作用、反转和情绪;不能只说‘我准备了三个方向’。" + "用户点击其一后才可 write_strategy;本工具调用后必须停下来等待选择。" + ), + "parameters": { + "type": "object", + "properties": { + "directions": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "title": {"type": "string", "description": "短标题,不超过 10 个字"}, + "conflict": {"type": "string", "description": "人物处境与开场冲突"}, + "product_role": {"type": "string", "description": "商品如何自然推进剧情"}, + "reversal": {"type": "string", "description": "最终反转如何回收"}, + "tone": {"type": "string", "description": "情绪与转化倾向"}, + }, + "required": ["title", "conflict", "product_role", "reversal", "tone"], + }, + }, + }, + "required": ["directions"], + }, + }, + }) tools.append({ "type": "function", "function": { @@ -1233,7 +1435,7 @@ def video_duration(params: dict) -> int: digits = "".join(ch for ch in raw if ch.isdigit()) if not digits: return SMART_DURATION - return max(4, min(int(digits), 30)) + return max(4, min(int(digits), 60)) def _image_count(params: dict, raw) -> int: @@ -1306,8 +1508,14 @@ def _video_submit_params(context: AgentContext, prompt: str) -> tuple[dict, list 它已经排好 角色 → 场景 → 商品 的顺序,那正是出片模型 @图N 的语义依据。""" params = context.conversation.params or {} resolved = resolve_refs(context.team, context.conversation.pinned_refs or []) + prompt = apply_video_preset_prompt(context.conversation.preset, prompt) prompt = apply_product_voice_visual_guard(context.conversation, prompt) prompt = apply_product_reality_guard(prompt) + prompt = apply_plot_twist_story_contract( + context.conversation.preset, + active_plot_twist_story_depth(context.conversation), + prompt, + ) submit = { "prompt": prompt, "feature": "omni_create", @@ -1562,7 +1770,9 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c "", "【每轮必须给引导】", "- 每一轮回复结束时,用户必须知道下一步怎么做:要么调用 ask_user 弹出可选项," - "要么在文字里明确告诉用户该回复什么(例如「请回复:1… 2…」或「直接说想改的卖点」)。", + "要么在文字里明确告诉用户该回复什么(例如「直接说想改的卖点」)。", + "- 当你给出 2–3 个方向时,必须调用 ask_user 生成可点击选项;正文只解释各方向," + "绝不要求用户回复数字、编号或 1/2/3。", "- 禁止只丢一段解释/分析就结束、让用户不知道该回什么。", "- 用户已经给出可直接执行的图片需求(主体、场景或氛围已足够)时,直接调用 generate_image 进入确认卡;" "不要只描述你准备怎么拍,再让用户继续补一句。", @@ -1601,8 +1811,8 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c hi = max(lo, min(85, int(dur * 5.7))) lines.append(f"- 当前按约 {dur} 秒出片,口播建议 {lo}–{hi} 字;write_plan 的 voice_chars 填这个区间。") lines.append( - "- 视频 4 步闸门(不可同轮连跳):①缺信息 ask_user 停 → ②write_strategy 停等确认 → " - "③用户确认后 write_plan 停等确认 → ④方案确认后平台在后台整理完整出片指令,并直接出现积分确认卡。" + "- 视频 5 步闸门(不可同轮连跳):①缺信息 ask_user 停 → ②write_strategy 停等确认 → " + "③用户确认后 write_plan 停等确认 → ④方案确认后展示完整出片指令并停等确认 → ⑤再显示积分确认卡。" ) lines.append( "- 只有用户明确要做片、出方案、改方案、换卖点/剧情时才调用 write_strategy / write_plan;" @@ -1657,12 +1867,36 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c # 只给名字模型只能靠猜;把这个预设的拍法约束一起给它 guidance = preset_guidance(conversation.preset) lines.append(f"\n【创作预设】{conversation.preset}") + if context.is_video: + lines.append( + "预设必须贯穿本次对话:据此判断该补问哪些必要事实和素材、给什么创意选择、" + "如何写脚本/分镜、商品如何出现、生成前检查什么;不能把预设当成最后才附加的一行风格词。" + ) if guidance: lines.append(guidance) lines.append("用户选了这个预设,就按它的拍法来;要偏离得先问过用户。") + if is_plot_twist_conversation(conversation): + depth = active_plot_twist_story_depth(conversation) + if not depth: + lines.append("剧情反转带货尚未选择故事深度:必须先调用 ask_user 让用户选 15 秒、30 秒、60 秒或智能推荐;此时禁止给剧情方向、策略或方案。") + else: + lines.append(plot_twist_story_contract(depth)) + memory = conversation.memory if isinstance(conversation.memory, dict) else {} + selected_direction = str(memory.get("plot_twist_story_direction") or "").strip() + if selected_direction: + lines.append(f"用户已选剧情方向:{selected_direction}。直接围绕此方向写策略,不要重发方向卡。") + else: + lines.append( + "【强制下一步】现在必须调用 present_story_directions,直接展示 3 张完整剧情方向卡。" + "每张卡写清冲突、商品如何推进剧情、反转和情绪;不得只在文字中说‘我准备了三个方向’," + "不得先 write_strategy / write_plan,也不要让用户输入编号。" + ) workflow_guidance = preset_workflow_guidance(conversation.preset) if context.is_video else "" if workflow_guidance: lines.append(f"【当前预设的工作重点】{workflow_guidance}") + delivery_contract = video_preset_delivery_contract(conversation.preset) if context.is_video else "" + if delivery_contract: + lines.append(f"【当前预设必须贯穿脚本与出片】{delivery_contract}") resolved = resolve_refs(context.team, conversation.pinned_refs or []) if resolved.facts: @@ -2039,6 +2273,20 @@ def iter_creation_agent_events( yield {"type": "done"} return + # 剧情反转预设必须先选故事深度。平台直接落选择卡,不能把这一步交给模型猜, + # 否则默认 15 秒会吞掉 30/60 秒该有的人物关系和冲突发展。 + if is_plot_twist_conversation(conversation) and not active_plot_twist_story_depth(conversation): + explicit_depth = plot_twist_story_depth(text) + if explicit_depth is not None and explicit_depth["value"] != "smart": + set_plot_twist_story_depth(conversation, explicit_depth["value"]) + else: + question = append_plot_twist_story_depth_question(conversation) + conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER + conversation.save(update_fields=["agent_status", "updated_at"]) + yield {"type": "message", "message": _message_payload(question)} + yield {"type": "done"} + return + model_config = _prefer_vision_text_model(model_config, conversation.team, conversation.pinned_refs or []) context.model_config = model_config @@ -2095,7 +2343,7 @@ def iter_creation_agent_events( elif kind == "tool_call": _merge_tool_call_deltas(tool_buffer, chunk.get("tool_calls")) - said = "".join(text_buffer).strip() + said = strip_numeric_reply_instruction("".join(text_buffer)) calls = [tool_buffer[i] for i in sorted(tool_buffer) if tool_buffer[i].get("name")] fallback_fields = None allow_pick = False @@ -2131,6 +2379,28 @@ def iter_creation_agent_events( "asset_types": ["product"], }] + # 方向卡是剧情反转预设的固定入口。模型偶尔只会说「我准备了三个方向」而忘了调工具, + # 此处直接补上可点击卡,不能让用户面对一段空话再自己追问。 + memory = conversation.memory if isinstance(conversation.memory, dict) else {} + needs_plot_twist_directions = ( + not calls + and allow_plan + and is_plot_twist_conversation(conversation) + and bool(active_plot_twist_story_depth(conversation)) + and not str(memory.get("plot_twist_story_direction") or "").strip() + ) + if needs_plot_twist_directions: + result, _stop = _dispatch_tool( + context, + "present_story_directions", + {"directions": _plot_twist_direction_fallback(conversation)}, + ) + for event in result.get("_events", []): + yield event + if event.get("type") == "message": + turn_has_gate = True + break + # ask_user 自己会落一条可追踪的聊天问题。模型同时吐出的过渡文案不再 # 另存一条,否则界面会连续出现两遍几乎相同的问题。 asks_user = bool(fallback_fields) or any(call.get("name") == "ask_user" for call in calls) @@ -2352,6 +2622,7 @@ def run_creation_agent_turn( _TOOL_LABELS = { "ask_user": "向你确认", + "present_story_directions": "整理剧情方向", "search_library": "查找素材", "generate_image": "生成图片", "write_strategy": "梳理创作策略", @@ -2476,6 +2747,23 @@ def _dispatch_tool( "_events": [{"type": "message", "message": _message_payload(message)}], }, True + if name == "present_story_directions": + if not is_plot_twist_conversation(context.conversation): + return {"payload": {"error": "当前会话不是剧情反转带货预设"}}, False + directions = _coerce_plot_twist_directions(args.get("directions")) + if not directions: + return { + "payload": { + "error": "必须提供恰好 3 个完整剧情方向,每个都要有标题、冲突、商品作用、反转和情绪。" + } + }, False + message = _append_plot_twist_direction_question(context, directions) + set_video_gate_stage(context.conversation, "clarify") + return { + "payload": {"presented": True, "count": 3}, + "_events": [{"type": "message", "message": _message_payload(message)}], + }, True + if name == "search_library": return {"payload": _run_search_library(context, args)}, False @@ -2515,8 +2803,14 @@ def _dispatch_tool( video_prompt = str(args.get("video_prompt") or "").strip() if not video_prompt: return {"payload": {"error": "video_prompt 不能为空,请把完整出片指令写进去"}}, False + video_prompt = apply_video_preset_prompt(context.conversation.preset, video_prompt) video_prompt = apply_product_voice_visual_guard(context.conversation, video_prompt) video_prompt = apply_product_reality_guard(video_prompt) + video_prompt = apply_plot_twist_story_contract( + context.conversation.preset, + active_plot_twist_story_depth(context.conversation), + video_prompt, + ) card = _coerce_plan_card_args(args if isinstance(args, dict) else {}) if not card["usp"] or not card["points"]: return { @@ -2548,7 +2842,7 @@ def _dispatch_tool( kind=CreationMessage.Kind.PLAN, payload=plan_payload, ) events.append({"type": "message", "message": _message_payload(plan)}) - # video_prompt 只存后台。用户确认方案后直接进入积分确认,不展示内部 Prompt。 + # video_prompt 先作为方案产物存档;用户确认方案后会展示指令文件供确认。 set_video_gate_stage( context.conversation, "plan", pending_video_prompt=video_prompt ) @@ -2565,18 +2859,23 @@ def _dispatch_tool( video_prompt = get_pending_video_prompt(context.conversation) if not video_prompt: return {"payload": {"error": "video_prompt 不能为空,请把完整出片指令写进去"}}, False + video_prompt = apply_video_preset_prompt(context.conversation.preset, video_prompt) video_prompt = apply_product_voice_visual_guard(context.conversation, video_prompt) video_prompt = apply_product_reality_guard(video_prompt) - set_video_gate_stage(context.conversation, "prompt", pending_video_prompt=video_prompt) - confirm = emit_final_confirm_gate(context.conversation, context=context) - if confirm is None: - return {"payload": {"error": "无法准备出片确认"}}, False + video_prompt = apply_plot_twist_story_contract( + context.conversation.preset, + active_plot_twist_story_depth(context.conversation), + video_prompt, + ) + prompt_messages = emit_prompt_gate(context.conversation, video_prompt) + if not prompt_messages: + return {"payload": {"error": "无法准备出片指令"}}, False events = [ - {"type": "message", "message": _message_payload(confirm)}, - {"type": "credits", "estimated": int((confirm.payload or {}).get("estimated_credits") or 0)}, + {"type": "message", "message": _message_payload(message)} + for message in prompt_messages ] return { - "payload": {"awaiting_confirmation": True, "prepared": True}, + "payload": {"awaiting_step": "prompt", "prepared": True}, "_events": events, }, True diff --git a/core/backend/apps/ai/creation_presets.py b/core/backend/apps/ai/creation_presets.py index 21547ef..4d352ac 100644 --- a/core/backend/apps/ai/creation_presets.py +++ b/core/backend/apps/ai/creation_presets.py @@ -23,8 +23,11 @@ VIDEO_PRESETS: dict[str, str] = { "用户没给关键功能或使用边界时,只问一次缺失事实;其余镜头用可观察的正常动作补齐。" ), "剧情反转带货": ( - "轻剧情短片。必须有一个具体的困境场景 → 意外转折 → 商品成为解决问题的关键道具。" - "商品不能在开头硬推,要等冲突立住了再自然介入。禁止把普通不便夸成严重后果。" + "剧情反转带货不是给商品套一个故事壳。商品必须作为解决困境、解除误会、证明事实、完成翻盘或回收伏笔的关键物," + "并在最终 Prompt、镜头、动作、对白和反转结果中反复可见地承担这个作用。" + "不得在故事结束后突然停下来介绍商品,也不能只在最后两秒放商品图。" + "必须先确定故事深度,再按所选 15 秒、30 秒或 60 秒的结构写人物关系、冲突铺垫、商品介入时机、反转和情绪落点;" + "禁止把同一条 15 秒故事机械加长或缩短。" ), "商品拟人广告": ( "把商品当成有性格的角色,但默认采用无脸拟人:商品本体保持真实完整,不在瓶身、包装或机身上新增卡通五官。" @@ -75,12 +78,105 @@ VIDEO_PRESETS: dict[str, str] = { ), } + +PLOT_TWIST_PRESET = "剧情反转带货" + +# 值同时是会话记忆中的稳定标识;label 则是用户在对话里看到的短说明。 +PLOT_TWIST_STORY_DEPTH_OPTIONS = ( + { + "value": "15s", + "label": "15秒|快节奏反转", + "duration": "15 秒", + "summary": "强冲突快速抓人,商品尽早介入并完成反转。", + }, + { + "value": "30s", + "label": "30秒|轻剧情带货", + "duration": "30 秒", + "summary": "补足人物处境和矛盾铺垫,让商品在转折点自然发挥作用。", + }, + { + "value": "60s", + "label": "60秒|完整短剧带货", + "duration": "60 秒", + "summary": "建立人物关系与持续矛盾,商品作为伏笔或关键线索完成翻盘。", + }, + { + "value": "smart", + "label": "智能推荐", + "duration": "", + "summary": "结合商品卖点、素材和剧情空间推荐时长,仍由你最终决定。", + }, +) + +_PLOT_TWIST_DEPTH_BY_VALUE = {item["value"]: item for item in PLOT_TWIST_STORY_DEPTH_OPTIONS} + + +def plot_twist_story_depth(value: str) -> dict | None: + """兼容卡片 value、展示文案和自然语言里带的秒数。""" + raw = str(value or "").strip() + if raw in _PLOT_TWIST_DEPTH_BY_VALUE: + return _PLOT_TWIST_DEPTH_BY_VALUE[raw] + if "60" in raw: + return _PLOT_TWIST_DEPTH_BY_VALUE["60s"] + if "30" in raw: + return _PLOT_TWIST_DEPTH_BY_VALUE["30s"] + if "15" in raw: + return _PLOT_TWIST_DEPTH_BY_VALUE["15s"] + if "智能" in raw or "推荐" in raw: + return _PLOT_TWIST_DEPTH_BY_VALUE["smart"] + return None + + +def plot_twist_story_contract(value: str) -> str: + """真实写入出片 Prompt 的硬约束,确保预设不会退化成一行风格词。""" + depth = plot_twist_story_depth(value) + if depth is None: + return "" + if depth["value"] == "15s": + return ( + "【剧情反转带货·15秒快节奏反转·强制执行】只保留一条主冲突,主角 1 人、最多 1 名辅助人物。" + "0-3 秒强冲突或意外,3-6 秒问题升级,6-11 秒商品以正常使用动作介入并证明一个核心卖点," + "11-15 秒完成结果反转与自然行动引导。商品必须在中段前出现;对白短、直接、有记忆点;" + "禁止复杂背景、支线、重复卖点和与商品无关的镜头。" + ) + if depth["value"] == "30s": + return ( + "【剧情反转带货·30秒轻剧情带货·强制执行】必须交代人物处境,可设置两人互动。" + "0-4 秒抛出结果预告或冲突钩子,4-10 秒建立处境,10-17 秒让矛盾升级或一次错误尝试," + "17-24 秒商品在转折点以完整正常使用过程介入,24-28 秒回收反转与人物反应,28-30 秒自然收束。" + "商品承担改变局面的实际作用;反转既服务剧情也证明卖点,禁止为了凑时长重复同一卖点。" + ) + if depth["value"] == "60s": + return ( + "【剧情反转带货·60秒完整短剧带货·强制执行】必须有人物关系、持续发展的矛盾、一次铺垫和一次回收。" + "0-5 秒高强度钩子,5-15 秒交代关系和处境,15-28 秒矛盾逐步升级,28-38 秒主角面临选择或第一次失败," + "38-48 秒商品作为关键线索、工具、证据或关系转折点推进剧情,48-56 秒完成主要反转和情绪释放," + "56-60 秒回扣商品价值并自然转化。商品可前置为伏笔但必须在后半段真正改变结局;" + "禁止用重复对白、无意义空镜或硬插卖点填满时长。" + ) + return ( + "【剧情反转带货·智能推荐】先根据商品卖点、已有素材和剧情空间推荐 15、30 或 60 秒之一并说明理由;" + "随后必须让用户选择实际时长,未确认前不得写剧情方向、策略、方案或出片指令。" + ) + + +def apply_plot_twist_story_contract(name: str, story_depth: str, prompt: str) -> str: + """在最终出片指令里确定性追加所选故事深度,避免模型漏写关键结构。""" + base = str(prompt or "").strip() + if (name or "").strip() != PLOT_TWIST_PRESET or not base: + return base + contract = plot_twist_story_contract(story_depth) + if not contract or contract in base: + return base + return f"{base}\n\n{contract}" + # 预设除了决定最终 Prompt,也决定 Agent 在素材整理、追问与生成前检查时的工作重点。 # 这段进入系统提示词,避免把预设退化成一句风格修饰词。 VIDEO_PRESET_WORKFLOWS: dict[str, str] = { "痛点解决演示": "先确认商品真实解决的具体问题与正常用法;生成前核对痛点、过程和结果都有可见证据。", "真实使用演示": "优先核对商品真实用途、关键步骤和可证实卖点;不为氛围而增加不合理测试。", - "剧情反转带货": "优先确认人物关系或冲突;缺剧情时给 2–3 个可选方向,商品必须成为解决问题或反转的关键。", + "剧情反转带货": "先让用户选故事深度(15秒快节奏反转、30秒轻剧情带货、60秒完整短剧带货或智能推荐),再给三个与时长匹配的剧情方向;商品必须成为解决问题、解除误会、证明事实、回收伏笔或完成翻盘的关键。", "商品拟人广告": "先确认商品外观和性格表达方式;默认无脸拟人,商品保持真实完整,台词用画外声。", "达人口播种草": "优先确认达人/人物、真实体验和主卖点;生成前核对口播字数能在时长内说完,每个卖点都有画面证明。", "商品图一键成片": "优先从商品参考图锁定外观;自动补场景和动作,但不替换或改变用户商品图里的结构、颜色和包装。", @@ -94,6 +190,70 @@ VIDEO_PRESET_WORKFLOWS: dict[str, str] = { "人物与音色替换": "先确认原视频、替换对象和替换范围;未经要求不得改变原视频的构图、动作、商品、场景或节奏。", } +# 这一层不是给聊天看的装饰文字,而是实际写入 video_prompt 的制作约束。 +# 预设示例片的效果主要来自结构、镜头和素材处理方式,不能只追加一句风格名。 +VIDEO_PRESET_DELIVERY_CONTRACTS: dict[str, str] = { + "痛点解决演示": ( + "【预设执行层·痛点解决演示】全片严格按‘真实困扰 → 商品进入 → 正常使用过程 → 可见结果 → 自然收束’推进。" + "开头先让目标用户看懂具体困扰;中段完整拍清产品的真实操作和一个可观察证据;" + "结尾只回扣已经被画面证明的价值。不得虚构压力测试、绝对功效、价格或促销。" + ), + "真实使用演示": ( + "【预设执行层·真实使用演示】像一镜到底可复核的日常实拍:先交代真实场景,再按正确用途完成步骤," + "用近景展示材质、动作或结果。商品全程保持正常完整;不以破损、漏液、渗水、超载或超出用途的测试制造戏剧性。" + ), + "剧情反转带货": ( + "【预设执行层·剧情反转带货】人物关系、冲突、情绪变化和反转必须在每个关键镜头连续推进。" + "商品不是摆拍道具:必须充当解决问题的工具、解除误会的证据、关系变化的礼物或回收伏笔的关键物;" + "商品出现、使用和结果必须直接改变剧情走向,禁止剧情结束后再硬插卖点。" + ), + "商品拟人广告": ( + "【预设执行层·商品拟人广告】商品本体保持真实包装、颜色、材质和结构;默认不长五官、不做口型。" + "性格由整体运动、镜头、环境反应和画外角色声表达。每次拟人动作都要符合物理状态,趣味服务于一个真实卖点。" + ), + "达人口播种草": ( + "【预设执行层·达人口播种草】固定一位可信的真人,在真实生活场景中自然口播。" + "结构为:前 2–3 秒具体痛点或体验钩子 → 真实使用/质地/细节证明 → 一句个人感受 → 克制收束。" + "口播像朋友分享,所有卖点必须被动作或特写佐证;禁止万能主播腔、堆砌卖点、画面字幕和夸张承诺。" + ), + "商品图一键成片": ( + "【预设执行层·商品图一键成片】以用户商品图作为外观唯一事实源:先用主图确立商品,再补与真实用途匹配的场景、手部动作和细节特写," + "最后以干净的产品收束。包装文字、颜色、比例和结构全程稳定,不能用 AI 生成的相似商品替代参考图。" + ), + "鱼眼换装": ( + "【预设执行层·鱼眼换装】使用近距离鱼眼/广角透视和稳定的同一机位;人物脸、身形、发型、场景、光线连续一致。" + "每一套服装由一个清晰的身体动作触发切换,按用户提供顺序完整展示;镜头的变化来自动作和节奏,不额外编复杂营销剧情。" + ), + "多色商品换款": ( + "【预设执行层·多色商品换款】先建立统一构图、比例、机位和光线,再以点击、触碰、转动或同一连续动作逐款切换。" + "每个颜色/SKU 都要清晰出现且顺序可辨,商品尺寸和位置不得漂移;最后给出全部款式同框总览。" + ), + "点触换款": ( + "【预设执行层·点触换款】先建立统一构图、比例、机位和光线,再以点击、触碰、转动或同一连续动作逐款切换。" + "每个颜色/SKU 都要清晰出现且顺序可辨,商品尺寸和位置不得漂移;最后给出全部款式同框总览。" + ), + "探店漫游": ( + "【预设执行层·探店漫游】用一条连续空间动线讲清入口、环境、关键细节和主推服务/商品。" + "镜头移动要有明确去向,人物或旁白承担导览;环境、服务和商品在同一体验中自然出现,不做碎片化硬切或虚假热闹。" + ), + "品牌质感大片": ( + "【预设执行层·品牌质感大片】少信息、高控制:围绕一个商品主角,以材质、光线、留白和镜头节奏建立品牌气质。" + "用真实可见的表面、边缘光、环境反射和干净构图表达高级感;禁止无意义的漂浮特效、过度 CG 和堆叠卖点。" + ), + "前后对比实测": ( + "【预设执行层·前后对比实测】同一对象、同一条件、同一机位依次呈现使用前、正常使用过程和使用后。" + "对比必须由用户素材或可见事实支持,画面明确说明比较对象;禁止伪造夸张效果、偷换条件或把未验证功效拍成结论。" + ), + "AI 宠物拟人": ( + "【预设执行层·AI 宠物拟人】宠物外观、体型、毛色和性格前后一致;趣味来自拟人动作与真实反应。" + "宠物和商品的互动必须符合商品真实结构与正常用法,商品不变成玩具或获得不存在的能力;剧情里至少有一个可见卖点证据。" + ), + "人物与音色替换": ( + "【预设执行层·人物与音色替换】严格保留原视频的构图、镜头顺序、动作、节奏、商品、场景和剪辑;" + "只替换用户明确指定的人物、声音或两者。替换人物的脸、身形、服装和新声音要全片一致,未授权内容一律不改。" + ), +} + IMAGE_PRESETS: dict[str, str] = { "电影感": "电影级摄影质感,宽容度高,强烈但自然的明暗层次,深邃阴影,柔和高光,冷暖色彩对比,轻微青橙调,真实环境光,局部轮廓光,适度暗角,细腻胶片颗粒,低饱和高级色调,电影调色,富有叙事感,真实摄影质感,避免过度HDR。", "日式清新": "日系清新摄影,高明度,低对比,柔和自然光,干净通透的空气感,白色与浅色占比高,低饱和淡彩,轻微偏冷色温,柔和肤色与高光,阴影浅淡,画面轻盈,曝光稍高但保留细节,清透自然,简洁治愈。", @@ -128,6 +288,24 @@ def preset_workflow_guidance(name: str) -> str: return VIDEO_PRESET_WORKFLOWS.get((name or "").strip(), "") +def video_preset_delivery_contract(name: str) -> str: + """预设名 → 最终视频生成阶段必须执行的结构与镜头约束。""" + return VIDEO_PRESET_DELIVERY_CONTRACTS.get((name or "").strip(), "") + + +def apply_video_preset_prompt(name: str, prompt: str) -> str: + """把视频预设确定性并入最终出片指令,避免只依赖对话模型主动复述。""" + base = str(prompt or "").strip() + preset = (name or "").strip() + contract = video_preset_delivery_contract(preset) + if not base or not contract: + return base + marker = f"【视频预设】{preset}" + if marker in base: + return base + return f"{base}\n\n{marker}\n{contract}" + + def apply_image_preset_prompt(name: str, prompt: str) -> str: """将已选图片预设的风格字段确定性并入实际出图指令,不依赖 Agent 自行复述。""" base = str(prompt or "").strip() diff --git a/core/backend/apps/ai/test_creation_agent.py b/core/backend/apps/ai/test_creation_agent.py index 6b62509..a2fd150 100644 --- a/core/backend/apps/ai/test_creation_agent.py +++ b/core/backend/apps/ai/test_creation_agent.py @@ -25,6 +25,7 @@ from .creation_agent import ( _image_count, _merge_tool_call_deltas, apply_restart_intent, + active_plot_twist_story_depth, build_system_prompt, build_messages, default_reply_hint, @@ -38,6 +39,8 @@ from .creation_agent import ( is_restart_intent, session_has_creative_context, stream_creation_agent, + set_plot_twist_story_depth, + strip_numeric_reply_instruction, submit_confirmed_image, submit_confirmed_video, text_already_guides, @@ -846,6 +849,87 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): args.update(overrides) return args + def test_plot_twist_preset_requires_story_depth_before_creative_output(self): + self.conversation.preset = "剧情反转带货" + self.conversation.params = {**self.conversation.params, "duration": "智能时长"} + self.conversation.memory = {} + self.conversation.save(update_fields=["preset", "params", "memory", "updated_at"]) + 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, + )) + + question = next( + event["message"] for event in events + if event.get("type") == "message" + and event["message"]["kind"] == "elicit" + ) + self.assertEqual(question["payload"].get("interaction"), "plot_twist_story_depth") + self.assertEqual( + [option["value"] for option in question["payload"]["fields"][0]["options"]], + ["15s", "30s", "60s", "smart"], + ) + self.conversation.refresh_from_db() + self.assertEqual(self.conversation.agent_status, "awaiting_user") + self.assertEqual(fake.calls, []) + + def test_plot_twist_depth_syncs_duration_and_is_written_into_final_prompt(self): + self.conversation.preset = "剧情反转带货" + self.conversation.save(update_fields=["preset", "updated_at"]) + depth = set_plot_twist_story_depth(self.conversation, "30s") + self.assertEqual(depth["duration"], "30 秒") + self.conversation.refresh_from_db() + self.assertEqual(active_plot_twist_story_depth(self.conversation), "30s") + + card = append_message( + self.conversation, role="assistant", kind=CreationMessage.Kind.CONFIRM, + payload={"video_prompt": "女主发现早餐来不及准备", "submitted": False}, + ) + task = AITask.objects.create( + team=self.team, created_by=self.user, task_type=AITask.Type.FREE_VIDEO, + model_config=self.model, idempotency_key="k-plot-twist-depth", + ) + with patch("apps.ai.free_video.submit_free_video", return_value=task) as submit: + message, error = submit_confirmed_video( + conversation=self.conversation, user=self.user, confirm_message=card + ) + prompt = submit.call_args.kwargs["params"]["prompt"] + + self.assertEqual(error, "") + self.assertIsNotNone(message) + self.assertIn("30秒轻剧情带货", prompt) + self.assertIn("17-24 秒商品在转折点", prompt) + + def test_plot_twist_always_displays_three_direction_cards(self): + self.conversation.preset = "剧情反转带货" + self.conversation.params = {**self.conversation.params, "duration": "30 秒"} + self.conversation.save(update_fields=["preset", "params", "updated_at"]) + # 即使模型只输出一句空话,平台也必须补上三个可点击方向,而不是让用户继续追问。 + 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, + )) + + card = next( + event["message"] for event in events + if event.get("type") == "message" + and event["message"]["kind"] == "elicit" + ) + self.assertEqual(card["payload"].get("interaction"), "plot_twist_directions") + directions = card["payload"].get("directions") or [] + self.assertEqual(len(directions), 3) + self.assertTrue(all(item.get("conflict") and item.get("product_role") and item.get("reversal") for item in directions)) + def test_image_tool_is_hidden_and_video_tools_offered(self): fake = FakeProvider([_text_chunks("先聊聊")]) with patch("apps.ai.creation_agent.build_provider", return_value=fake): @@ -900,14 +984,13 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): 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秒 近景手持商品…", - ) + pending_prompt = (self.conversation.memory or {}).get("pending_video_prompt") or "" + self.assertTrue(pending_prompt.startswith("0-3秒 近景手持商品…")) + self.assertIn("不出现破损、漏液、渗水", pending_prompt) # 方案闸门停下,不能同轮出 Prompt/积分卡 self.assertEqual(len(fake.calls), 1) - def test_write_prompt_keeps_internal_prompt_and_emits_confirm(self): + def test_write_prompt_emits_document_and_requires_prompt_confirmation(self): fake = FakeProvider([ _tool_chunks("write_prompt", {"video_prompt": "0-3秒 近景手持商品…"}), _text_chunks("不该跑到这一轮"), @@ -916,10 +999,15 @@ 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.assertNotIn("prompt_file", kinds) - self.assertIn("confirm", kinds) + self.assertIn("prompt_file", kinds) + self.assertNotIn("confirm", kinds) + elicit = next( + e["message"] for e in events + if e.get("type") == "message" and e["message"]["kind"] == "elicit" + ) + self.assertEqual(elicit["payload"].get("step"), "prompt") self.conversation.refresh_from_db() - self.assertEqual((self.conversation.memory or {}).get("stage"), "confirm") + self.assertEqual((self.conversation.memory or {}).get("stage"), "prompt") self.assertEqual(len(fake.calls), 1) def test_strategy_and_plan_same_round_stops_after_strategy(self): @@ -986,13 +1074,37 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): self.assertEqual(error, "") self.assertEqual(message.kind, CreationMessage.Kind.GENERATING) - self.assertEqual(params["prompt"], "0-3秒 近景手持商品…") + self.assertTrue(params["prompt"].startswith("0-3秒 近景手持商品…")) + self.assertIn("不出现破损、漏液、渗水", params["prompt"]) # 顶栏参数直接用,label 要翻成火山真名 self.assertEqual(params["model"], "doubao-seedance-2-5-260628") self.assertEqual(params["duration"], 15) self.assertEqual(params["aspect_ratio"], "9:16") self.assertTrue(params["generate_audio"]) + def test_confirmed_video_keeps_selected_preset_contract(self): + self.conversation.preset = "多色商品换款" + self.conversation.save(update_fields=["preset", "updated_at"]) + card = append_message( + self.conversation, role="assistant", kind=CreationMessage.Kind.CONFIRM, + payload={"video_prompt": "手指点击后展示三种颜色的同款耳机", "submitted": False}, + ) + task = AITask.objects.create( + team=self.team, created_by=self.user, task_type=AITask.Type.FREE_VIDEO, + model_config=self.model, idempotency_key="k-multi-sku-contract", + ) + with patch("apps.ai.free_video.submit_free_video", return_value=task) as submit: + message, error = submit_confirmed_video( + conversation=self.conversation, user=self.user, confirm_message=card + ) + prompt = submit.call_args.kwargs["params"]["prompt"] + + self.assertEqual(error, "") + self.assertIsNotNone(message) + self.assertIn("【视频预设】多色商品换款", prompt) + self.assertIn("每个颜色/SKU 都要清晰出现", prompt) + self.assertIn("最后给出全部款式同框总览", prompt) + def test_personified_product_uses_offscreen_voice_without_changing_packaging(self): self.conversation.preset = "商品拟人广告" self.conversation.save(update_fields=["preset"]) @@ -1036,7 +1148,8 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): self.assertEqual(error, "") self.assertIsNotNone(message) - self.assertEqual(prompt, original) + self.assertTrue(prompt.startswith(original)) + self.assertIn("不出现破损、漏液、渗水", prompt) def test_confirm_without_stored_prompt_reports_instead_of_submitting(self): card = append_message( @@ -1057,8 +1170,8 @@ class VideoParamParsingTests(TestCase): self.assertEqual(video_duration({"duration": "15 秒"}), 15) self.assertEqual(video_duration({"duration": "智能时长"}), SMART_DURATION) self.assertEqual(video_duration({}), SMART_DURATION) - # 火山单次最长 30 秒,超了要夹住而不是让 submit 报错 - self.assertEqual(video_duration({"duration": "99 秒"}), 30) + # 当前视频工作流支持完整短剧,超出预设上限时仍需夹住。 + self.assertEqual(video_duration({"duration": "99 秒"}), 60) def test_model_label_maps_to_volcano_name(self): self.assertEqual(video_model_name({"model": "Seedance 2.0 Fast"}), "doubao-seedance-2-0-fast-260128") @@ -1304,6 +1417,22 @@ class PresetGuidanceTests(CreationAgentBaseTests): self.assertIn("所有 SKU 都已进入时间轴", system) self.assertIn("比例、位置和机位保持一致", system) + def test_video_preset_is_injected_into_the_actual_generation_prompt(self): + from .creation_presets import apply_video_preset_prompt + + prompt = apply_video_preset_prompt("鱼眼换装", "人物抬手完成连续换装") + self.assertIn("【视频预设】鱼眼换装", prompt) + self.assertIn("近距离鱼眼/广角透视", prompt) + self.assertIn("人物脸、身形、发型、场景、光线连续一致", prompt) + # 出片确认、重试等多次经过此处,规则只写一次。 + self.assertEqual(apply_video_preset_prompt("鱼眼换装", prompt), prompt) + + def test_every_video_preset_has_a_delivery_contract(self): + from .creation_presets import VIDEO_PRESETS, VIDEO_PRESET_DELIVERY_CONTRACTS + + self.assertEqual(set(VIDEO_PRESETS), set(VIDEO_PRESET_DELIVERY_CONTRACTS)) + self.assertTrue(all(text.strip() for text in VIDEO_PRESET_DELIVERY_CONTRACTS.values())) + def test_product_reality_guard_is_added_to_final_prompt(self): from .creation_agent import apply_product_reality_guard @@ -1631,12 +1760,17 @@ class ToolGateTests(CreationAgentBaseTests): class ReplyGuidanceTests(CreationAgentBaseTests): """每轮回复必须给引导:系统提示有规则,散文收束会挂 reply_hint。""" + def test_numbered_reply_instruction_is_removed_from_text(self): + text = "三个方向都能做。\n你直接回复数字1/2/3选就行,想改细节也可以直接说。" + self.assertEqual(strip_numeric_reply_instruction(text), "三个方向都能做。") + 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) + self.assertIn("绝不要求用户回复数字", prompt) def test_system_prompt_requires_production_grade_video_prompt_document(self): self.conversation.mode = CreationConversation.Mode.VIDEO diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index e19ccdb..c433594 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -39,7 +39,9 @@ from .creation_agent import ( apply_confirm_params, apply_restart_intent, apply_session_params, + emit_prompt_gate, emit_final_confirm_gate, + set_plot_twist_story_depth, is_greeting, is_restart_intent, restore_gated_step_after_cancel, @@ -167,6 +169,56 @@ def _pending_chat_question(conversation: CreationConversation) -> CreationMessag return None +def _plot_twist_depth_continuation(depth: dict) -> str: + """让故事深度卡的回答直接进入对应的创作分支。""" + if depth.get("value") == "smart": + return ( + "用户选择智能推荐。先根据商品卖点、已有素材和剧情空间推荐 15、30 或 60 秒其中一个," + "说明一句推荐理由,再调用 ask_user 让用户最终选择实际时长;未确认实际时长前不要给剧情方向、策略或方案。" + ) + return ( + f"用户已选择:{depth['label']}。立刻按这个故事深度给出 3 个明显不同的剧情方向," + "每个方向必须写人物关系、开场冲突、商品如何进入剧情、商品承担的作用、最终反转、情绪和偏故事/偏转化;" + "然后调用 ask_user 让用户点击选择或输入自己的想法。不得按默认 15 秒偷换结构。" + ) + + +def _plot_twist_direction_continuation( + conversation: CreationConversation, + payload: dict, + choice: str, +) -> str: + """保存方向卡的真实内容,再让模型进入策略步骤。""" + direction = next( + ( + item for item in (payload.get("directions") or []) + if isinstance(item, dict) + and (str(item.get("id") or "") == choice or str(item.get("title") or "") == choice) + ), + None, + ) + if isinstance(direction, dict): + title = str(direction.get("title") or choice).strip() + detail = ";".join( + str(direction.get(key) or "").strip() + for key in ("conflict", "product_role", "reversal", "tone") + if str(direction.get(key) or "").strip() + ) + else: + title = choice.strip() or "用户自定义方向" + detail = title + memory = dict(conversation.memory or {}) + memory["plot_twist_story_direction"] = title + memory["plot_twist_story_direction_detail"] = detail + conversation.memory = memory + conversation.save(update_fields=["memory", "updated_at"]) + return ( + f"用户已选择剧情方向【{title}】。方向细节:{detail}。" + "现在只调用 write_strategy 写创作策略卡,必须沿用该冲突、商品作用与反转;" + "不要再展示方向卡、不要复述选择、不要直接写方案或出片。" + ) + + _STEP_CONTINUE_INSTRUCTIONS = { "strategy": ( "用户已确认创作策略。现在只调用 write_plan 写方案卡(含完整 video_prompt 存档);" @@ -248,20 +300,17 @@ def _handle_step_confirm_answer( return None, True, _STEP_CONTINUE_INSTRUCTIONS["strategy"] if step == "plan": - confirm = emit_final_confirm_gate(conversation) - if confirm is None: - # 兼容旧会话或缓存丢失:让 agent 补齐后台出片指令,再直接给出积分确认。 + prompt_messages = emit_prompt_gate(conversation) + if not prompt_messages: + # 兼容旧会话或缓存丢失:让 agent 补齐出片指令,再交给用户确认。 return None, True, _STEP_CONTINUE_INSTRUCTIONS["plan"] 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], + "messages": [CreationMessageSerializer(message).data for message in prompt_messages], } - if credits: - body["estimated_credits"] = credits return JsonResponse(body, status=200), False, "" if step == "prompt": @@ -1733,6 +1782,41 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): if short is not None: return short # 用户原文仍落库,方便回看;continuation 已带反馈 + elif payload.get("interaction") == "plot_twist_story_depth": + # 故事深度卡既可点选,也允许用户直接输入「30 秒」之类的自然回答。 + depth = set_plot_twist_story_depth(conversation, text) + if depth is not None: + payload["answers"] = {"story_depth": depth["value"]} + payload["submitted"] = True + payload["answered_via"] = "chat" + pending.payload = payload + pending.save(update_fields=["payload", "updated_at"]) + text = "" + record_user_message = False + force_creative_turn = True + continuation_instruction = _plot_twist_depth_continuation(depth) + elif payload.get("interaction") == "plot_twist_directions": + options = [item for item in (payload.get("directions") or []) if isinstance(item, dict)] + chosen = next( + ( + item for item in options + if str(item.get("title") or "") in text or str(item.get("id") or "") == text + ), + None, + ) + choice = str((chosen or {}).get("id") or text).strip() + if choice: + payload["answers"] = {"story_direction": choice} + payload["submitted"] = True + payload["answered_via"] = "chat" + pending.payload = payload + pending.save(update_fields=["payload", "updated_at"]) + text = "" + record_user_message = False + force_creative_turn = True + continuation_instruction = _plot_twist_direction_continuation( + conversation, payload, choice + ) 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 {} @@ -1973,6 +2057,27 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): # 素材选择闸门:用户愿意时再展开列表;跳过则直接沿原任务继续。 if payload.get("interaction") == "step_confirm": pass # 已在上面处理 + elif payload.get("interaction") == "plot_twist_story_depth": + depth = set_plot_twist_story_depth( + conversation, + str(answers.get("story_depth") or ""), + ) + if depth is None: + return JsonResponse({"detail": "请选择一个有效的故事深度"}, status=400) + text = "" + record_user_message = False + force_creative_turn = True + continuation_instruction = _plot_twist_depth_continuation(depth) + elif payload.get("interaction") == "plot_twist_directions": + choice = str(answers.get("story_direction") or "").strip() + if not choice: + return JsonResponse({"detail": "请选择一个剧情方向"}, status=400) + text = "" + record_user_message = False + force_creative_turn = True + continuation_instruction = _plot_twist_direction_continuation( + conversation, payload, choice + ) elif payload.get("phase") == "gate": choice = str(answers.get("_asset_gate") or "").strip() if choice == "send": diff --git a/core/frontend/src/login-page.css b/core/frontend/src/login-page.css index 4c7fc86..1c997d9 100644 --- a/core/frontend/src/login-page.css +++ b/core/frontend/src/login-page.css @@ -461,6 +461,7 @@ align-items: center; justify-content: center; gap: 10px; + margin-top: 2px; border: 0; border-radius: 11px; color: #fff; @@ -515,60 +516,6 @@ .login-page .login-row a:hover, .login-page .login-switch a:hover { text-decoration: underline; } -/* 三入口操作条:同宽收进卡片,小屏改纵向避免撑破 */ -.login-page .login-actions { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1fr); - align-items: stretch; - gap: 0; - min-width: 0; - width: 100%; - max-width: 100%; - overflow: hidden; - border-radius: 11px; - background: linear-gradient(135deg, #002d9f, #001d70 76%); - box-shadow: 0 14px 30px rgba(0, 25, 102, 0.32); -} -.login-page .login-actions .login-submit { - margin-top: 0; - border-radius: 0; - background: transparent; - box-shadow: none; -} -.login-page .login-actions .login-submit:hover:not(:disabled) { - transform: none; - filter: brightness(1.08); - box-shadow: none; -} -.login-page .login-action-alt { - min-width: 0; - height: 54px; - padding: 0 8px; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - border: 0; - border-radius: 0; - color: rgba(255, 255, 255, 0.88); - background: transparent; - font: inherit; - font-size: 12px; - line-height: 1.2; - cursor: pointer; -} -.login-page .login-action-alt + .login-submit, -.login-page .login-submit + .login-action-alt { - box-shadow: inset 1px 0 0 rgba(255, 255, 255, 0.16); -} -.login-page .login-action-alt span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.login-page .login-action-alt:hover { background: rgba(255, 255, 255, 0.08); } -.login-page .login-action-alt svg { width: 14px; height: 14px; flex: 0 0 auto; } - .login-page .login-switch { margin: 18px 0 0; text-align: center; @@ -598,30 +545,6 @@ font-size: 12px; transform: none; } -.login-page .login-sso { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; - min-width: 0; -} -.login-page .login-sso button { - min-width: 0; - height: 36px; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 7px; - border: 1px solid rgba(255, 255, 255, 0.14); - border-radius: 10px; - color: rgba(255, 255, 255, 0.72); - background: rgba(255, 255, 255, 0.045); - font: inherit; - font-size: 12px; - cursor: pointer; -} -.login-page .login-sso button:hover { background: rgba(255, 255, 255, 0.08); } -.login-page .login-sso svg { width: 14px; height: 14px; flex: 0 0 auto; } - .login-page .login-note { margin: 0; color: rgba(255, 255, 255, 0.52); @@ -749,7 +672,6 @@ .login-page .login-form { gap: 14px; } } -/* 窄屏:主登录在上,两侧入口两列在下 */ @media (max-width: 560px) { .login-page .login-form-panel { padding: 24px 14px 20px; @@ -758,36 +680,6 @@ width: min(100%, 480px); padding: 28px 16px 22px; } - .login-page .login-actions { - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; - background: transparent; - box-shadow: none; - overflow: visible; - } - .login-page .login-actions .login-submit { - grid-column: 1 / -1; - order: -1; - border-radius: 11px; - background: linear-gradient(135deg, #002d9f, #001d70 76%); - box-shadow: 0 14px 30px rgba(0, 25, 102, 0.32); - } - .login-page .login-actions .login-submit:hover:not(:disabled) { - transform: translateY(-2px); - box-shadow: 0 18px 36px rgba(0, 23, 94, 0.4); - filter: brightness(1.06); - } - .login-page .login-action-alt, - .login-page .login-action-alt + .login-submit, - .login-page .login-submit + .login-action-alt { - box-shadow: none; - } - .login-page .login-action-alt { - height: 44px; - border: 1px solid rgba(255, 255, 255, 0.14); - border-radius: 10px; - background: rgba(255, 255, 255, 0.045); - } .login-page .login-row { flex-wrap: wrap; gap: 8px 12px; diff --git a/core/frontend/src/omni-session-page.css b/core/frontend/src/omni-session-page.css index 13be063..a71e6c4 100644 --- a/core/frontend/src/omni-session-page.css +++ b/core/frontend/src/omni-session-page.css @@ -190,28 +190,12 @@ .omni-reply-guide { margin-top: 8px; - padding-top: 8px; - border-top: 1px dashed var(--border-faint); -} - -.omni-reply-guide p { - margin: 0 0 8px; - color: var(--black-alpha-56); - font-size: 12px; - line-height: 1.55; -} - -.omni-reply-guide p > span { - margin-right: 6px; - color: var(--accent-black); - font-family: var(--font-mono); - font-size: 11px; - letter-spacing: .04em; } .omni-reply-guide-options { display: flex; flex-wrap: wrap; + align-items: center; gap: 8px; } @@ -247,6 +231,29 @@ cursor: not-allowed; } +.omni-reply-custom-input { + display: flex; + flex: 1 1 220px; + gap: 6px; + max-width: 300px; +} + +.omni-reply-custom-input .input { + min-width: 0; + height: 30px; + padding: 0 10px; + font-size: 12px; +} + +.omni-reply-custom-input button { + flex: 0 0 30px; + width: 30px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} + .omni-chat-row.is-user .omni-chat-bubble { align-items: flex-end; @@ -1261,10 +1268,24 @@ .omni-strategy-grid, .omni-plan-points, - .omni-plan-timeline { + .omni-plan-timeline, + .omni-direction-list { grid-template-columns: 1fr; } + .omni-direction-card { + width: 100%; + margin-left: 0; + } + + .omni-direction-custom { + flex-direction: column; + } + + .omni-direction-custom button { + width: 100%; + } + .omni-plan-summary { align-items: flex-start; flex-direction: column; @@ -1290,6 +1311,144 @@ padding: 14px 18px 16px; } +/* 剧情反转预设的方向选择:三个完整方向直接平铺,避免只留一句「我准备了三个方向」。 */ +.omni-direction-card { + box-sizing: border-box; + width: min(760px, calc(100% - 44px)); + margin: 0 0 24px 44px; + overflow: hidden; + border-radius: 8px; + background: var(--surface); + box-shadow: inset 0 0 0 1px var(--border-faint); +} + +.omni-direction-head { + display: flex; + align-items: center; + min-height: 62px; + padding: 12px 16px; + box-shadow: inset 0 -1px 0 var(--border-faint); +} + +.omni-direction-head > div { + display: flex; + flex-direction: column; + gap: 3px; +} + +.omni-direction-head strong { + color: var(--accent-black); + font-size: 14px; + font-weight: 600; +} + +.omni-direction-head span { + color: var(--black-alpha-56); + font-size: 12px; +} + +.omni-direction-list { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + padding: 14px 16px; +} + +.omni-direction-list > button { + min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 7px; + padding: 12px; + border: 0; + border-radius: 8px; + background: var(--background-base); + box-shadow: inset 0 0 0 1px var(--border-faint); + color: var(--accent-black); + text-align: left; + cursor: pointer; + transition: background 200ms ease, box-shadow 200ms ease, transform 100ms ease; +} + +.omni-direction-list > button:hover:not(:disabled) { + background: var(--background-lighter); + box-shadow: inset 0 0 0 1px var(--black-alpha-24); + transform: translateY(-1px); +} + +.omni-direction-list > button:focus-visible { + outline: none; + box-shadow: inset 0 0 0 1px var(--heat), 0 0 0 2px var(--heat-40); +} + +.omni-direction-list > button.is-selected { + background: var(--heat-12); + box-shadow: inset 0 0 0 1px var(--heat-40); +} + +.omni-direction-list > button:disabled { + cursor: default; +} + +.omni-direction-index, +.omni-direction-list small { + color: var(--black-alpha-56); + font-size: 11px; + line-height: 1.4; +} + +.omni-direction-list > button > strong { + font-size: 13px; + font-weight: 600; + line-height: 1.4; +} + +.omni-direction-list p { + margin: 0; + color: var(--black-alpha-64); + font-size: 12px; + line-height: 1.55; +} + +.omni-direction-list p b { + display: block; + margin-bottom: 2px; + color: var(--accent-black); + font-size: 11px; + font-weight: 600; +} + +.omni-direction-custom { + display: flex; + gap: 8px; + padding: 0 16px 16px; +} + +.omni-direction-custom .input { + min-width: 0; + flex: 1; +} + +.omni-direction-custom button { + height: 36px; + flex: 0 0 auto; + padding: 0 14px; + border: 0; + border-radius: 8px; + background: var(--accent-black); + color: var(--surface); + font-size: 12px; + font-weight: 500; + cursor: pointer; +} + +.omni-direction-custom button:disabled { + background: var(--black-alpha-12); + color: var(--black-alpha-48); + cursor: not-allowed; +} + .omni-elicit-field > label { display: block; margin-bottom: 9px; diff --git a/core/frontend/src/routes/auth-screen.tsx b/core/frontend/src/routes/auth-screen.tsx index bd7c43c..82bdb9a 100644 --- a/core/frontend/src/routes/auth-screen.tsx +++ b/core/frontend/src/routes/auth-screen.tsx @@ -8,7 +8,6 @@ import { LoaderCircle, Lock, LockKeyhole, - ScanLine, ShieldCheck, Sparkles, Ticket, @@ -559,18 +558,10 @@ export function AuthScreen({ { event.preventDefault(); showToast("重置密码", "请联系团队超管重置你的登录密码"); }}>忘记密码? -
还没账号? { event.preventDefault(); switchMode("register"); }}>注册团队 →
diff --git a/core/frontend/src/routes/omni-session.tsx b/core/frontend/src/routes/omni-session.tsx
index 7041cbd..bfadf98 100644
--- a/core/frontend/src/routes/omni-session.tsx
+++ b/core/frontend/src/routes/omni-session.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+import { type FormEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
ArrowLeft,
@@ -43,7 +43,27 @@ type PromptBlock =
type ReplyOption = { label: string; text: string };
-function replyOptionsForMessage(payload: Record {question} 下一步直接在下方输入你的想法,回答这一项就行。