修改全能创作

This commit is contained in:
Azmat@qq.com
2026-09-15 17:45:28 +08:00
parent 243a3aa54b
commit d4f282bfa9
8 changed files with 1142 additions and 212 deletions
+316 -17
View File
@@ -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
+181 -3
View File
@@ -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()
+146 -12
View File
@@ -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
+112 -7
View File
@@ -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":
+1 -109
View File
@@ -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;
+177 -18
View File
@@ -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;
+4 -13
View File
@@ -8,7 +8,6 @@ import {
LoaderCircle,
Lock,
LockKeyhole,
ScanLine,
ShieldCheck,
Sparkles,
Ticket,
@@ -559,18 +558,10 @@ export function AuthScreen({
</label>
<a href="#" onClick={(event) => { event.preventDefault(); showToast("重置密码", "请联系团队超管重置你的登录密码"); }}>?</a>
</div>
<div className="login-actions">
<button type="button" className="login-action-alt" onClick={() => showToast("微信扫码", "对接中 · 当前请用账号 + 密码登录")}>
<ScanLine /> <span></span>
</button>
<button className="login-submit" type="submit" disabled={busy}>
{busy ? (LOGIN_PROGRESS_COPY[loginProgress || "checking"] || "正在进入工作台") : "确认登录"}
{busy ? <LoaderCircle /> : <ArrowRight />}
</button>
<button type="button" className="login-action-alt" onClick={() => showToast("飞书 SSO", "对接中 · 当前请用账号 + 密码登录")}>
<ShieldCheck /> <span> SSO</span>
</button>
</div>
<button className="login-submit" type="submit" disabled={busy}>
{busy ? (LOGIN_PROGRESS_COPY[loginProgress || "checking"] || "正在进入工作台") : "确认登录"}
{busy ? <LoaderCircle /> : <ArrowRight />}
</button>
</form>
<p className="login-switch">
? <a href="/register" onClick={(event) => { event.preventDefault(); switchMode("register"); }}> </a>
+205 -33
View File
@@ -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<string, unknown>, isVideo: boolean): ReplyOption[] {
function stripNumericReplyInstruction(text: string): string {
return (text || "")
.replace(
/(?:\n)?(?:你|请)?\s*(?:直接)?(?:回复|选择|选)\s*(?:数字|编号)?\s*1\s*(?:[/、,,]\s*2)(?:\s*(?:[/、,,]\s*3))?[^。!?!\n]*(?:[。!?!]|$)/g,
"",
)
.trim();
}
function numberedReplyOptions(text: string): ReplyOption[] {
const matches = [...(text || "").matchAll(/(?:^|\n)\s*([1-3])[.、.]\s*(?:\*\*)?([^\n*]{1,80})/g)];
if (matches.length < 2) return [];
return matches.slice(0, 3).map((match) => ({
label: match[1],
text: `选择第${match[1]}个方向`,
}));
}
function replyOptionsForMessage(text: string, payload: Record<string, unknown>, isVideo: boolean): ReplyOption[] {
const numbered = numberedReplyOptions(text);
if (numbered.length) return numbered;
const stored = payload.reply_options;
if (Array.isArray(stored)) {
const options = stored
@@ -63,15 +83,55 @@ function replyOptionsForMessage(payload: Record<string, unknown>, isVideo: boole
{ label: "按这个方向出图", text: "按这个方向出图" },
{ label: "换个场景", text: "我想换个场景" },
{ label: "改人物状态", text: "我想改人物状态" },
];
];
}
function replyHintForMessage(payload: Record<string, unknown>, isVideo: boolean): string {
const stored = String(payload.reply_hint || "").trim();
if (stored) return stored;
return isVideo
? "可以回复「继续完善方案」,也可以选下方想调整的方向。"
: "可以回复「按这个方向出图」,也可以选下方想调整的方向。";
function ReplyActions({
options,
disabled,
onSubmit,
}: {
options: ReplyOption[];
disabled?: boolean;
onSubmit: (text: string) => void;
}) {
const [customIdea, setCustomIdea] = useState("");
const submitCustomIdea = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const text = customIdea.trim();
if (!text || disabled) return;
onSubmit(text);
setCustomIdea("");
};
return (
<div className="omni-reply-guide-options">
{options.map((option, index) => (
<button
type="button"
className={index === 0 && options.length < 3 ? "primary" : ""}
key={option.text}
disabled={disabled}
onClick={() => onSubmit(option.text)}
>
{option.label}
</button>
))}
<form className="omni-reply-custom-input" onSubmit={submitCustomIdea}>
<input
className="input"
value={customIdea}
disabled={disabled}
onChange={(event) => setCustomIdea(event.target.value)}
placeholder="输入自己的想法…"
aria-label="输入自己的想法"
/>
<button type="submit" aria-label="发送想法" disabled={disabled || !customIdea.trim()}>
<ArrowUp size={14} />
</button>
</form>
</div>
);
}
function parseMarkdownTable(lines: string[]): { headers: string[]; rows: string[][] } | null {
@@ -401,6 +461,25 @@ function collectSessionAssets(
}
// 视频确认卡保存的是最终用于出片的 Prompt。它不是聊天正文,而是本会话
// 的一份可复查资源;右侧资源栏始终只保留最新的一版,避免多次改稿堆出一串副本。
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
const payload = message.payload || {};
const body = String(
message.kind === "prompt_file" ? payload.body || "" : payload.video_prompt || "",
).trim();
if (!body) continue;
push({
key: `prompt:${message.id || index}`,
kind: "document",
title: String(payload.title || "视频生成Prompt.md"),
promptTitle: String(payload.title || "视频生成Prompt.md"),
promptBody: body,
});
break;
}
return out;
}
@@ -554,7 +633,15 @@ function PlanCard({ payload }: { payload: Record<string, unknown> }) {
);
}
function PromptFileCard({ payload }: { payload: Record<string, unknown> }) {
function PromptFileCard({
payload,
onView,
}: {
payload: Record<string, unknown>;
onView: (title: string, body: string) => void;
}) {
const title = String(payload.title || "视频生成Prompt.md");
const body = String(payload.body || "").trim();
return (
<section className="omni-prompt-file-card">
<span className="omni-prompt-file-icon">
@@ -564,6 +651,11 @@ function PromptFileCard({ payload }: { payload: Record<string, unknown> }) {
<strong></strong>
<small> {String(payload.ref_count ?? 0)} </small>
</div>
{body ? (
<button type="button" onClick={() => onView(title, body)}>
Prompt
</button>
) : null}
</section>
);
}
@@ -718,6 +810,7 @@ function ElicitCard({
const [answers, setAnswers] = useState<Record<string, string | string[]>>(saved);
const [assetOptions, setAssetOptions] = useState<Record<string, CreationRef[]>>({});
const [assetPicked, setAssetPicked] = useState<Record<string, CreationRef>>({});
const [customDirection, setCustomDirection] = useState("");
useEffect(() => {
if (submitted || interaction === "chat" || interaction === "step_confirm") return;
@@ -761,6 +854,76 @@ function ElicitCard({
);
}
if (interaction === "plot_twist_directions") {
const directions = Array.isArray(message.payload.directions)
? message.payload.directions
.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object")
.map((item) => ({
id: String(item.id || "").trim(),
title: String(item.title || "剧情方向").trim(),
conflict: String(item.conflict || "").trim(),
productRole: String(item.product_role || "").trim(),
reversal: String(item.reversal || "").trim(),
tone: String(item.tone || "").trim(),
}))
.filter((item) => item.id)
: [];
const selected = String(saved.story_direction || "");
const submitCustomDirection = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const idea = customDirection.trim();
if (!idea || disabled) return;
onSubmit({ story_direction: idea }, []);
setCustomDirection("");
};
return (
<div className="omni-elicit-slot omni-direction-slot">
<section className={`omni-direction-card${submitted ? " is-submitted" : ""}`}>
<header className="omni-direction-head">
<div>
<strong></strong>
<span></span>
</div>
</header>
<div className="omni-direction-list">
{directions.map((direction, index) => {
const isSelected = selected === direction.id || selected === direction.title;
return (
<button
type="button"
key={direction.id}
className={isSelected ? "is-selected" : ""}
disabled={disabled || submitted}
onClick={() => onSubmit({ story_direction: direction.id }, [])}
>
<span className="omni-direction-index"> {index + 1}</span>
<strong>{direction.title}</strong>
<p><b></b>{direction.conflict}</p>
<p><b></b>{direction.productRole}</p>
<p><b></b>{direction.reversal}</p>
{direction.tone ? <small>{direction.tone}</small> : null}
</button>
);
})}
</div>
{!submitted ? (
<form className="omni-direction-custom" onSubmit={submitCustomDirection}>
<input
className="input"
value={customDirection}
disabled={disabled}
onChange={(event) => setCustomDirection(event.target.value)}
placeholder="或者写下你自己的剧情想法…"
aria-label="输入自己的剧情想法"
/>
<button type="submit" disabled={disabled || !customDirection.trim()}>使</button>
</form>
) : null}
</section>
</div>
);
}
const toggleMulti = (key: string, value: string) => {
setAnswers((prev) => {
const current = Array.isArray(prev[key]) ? (prev[key] as string[]) : [];
@@ -831,8 +994,7 @@ function ElicitCard({
<p className="omni-chat-text">{question}</p>
</div>
{!submitted ? (
<div className="omni-reply-guide" aria-label="下一步引导">
<p><span></span></p>
<div className="omni-reply-guide" aria-label="回复操作">
<div className="omni-reply-guide-options">
<button type="button" className="primary" onClick={() => document.getElementById("omniSessionPrompt")?.focus()}>
@@ -991,7 +1153,7 @@ function ConfirmCard({
const raw = String(draft.duration || "");
const digits = raw.replace(/\D/g, "");
// 与后端 video_duration 一致:「智能时长」/解析不出 → 15 秒
const duration = digits ? Math.max(4, Math.min(Number(digits), 30)) : 15;
const duration = digits ? Math.max(4, Math.min(Number(digits), 60)) : 15;
const est = estimateCost(model, { ratio: draft.ratio || "9:16", resolution, duration, refs: [] });
if (est.listed && est.points > 0) return est.points;
// 挂牌缺失时仍展示后端下发的预估(若有)
@@ -2145,6 +2307,26 @@ export function OmniSessionPage({
|| (streaming && conversation?.agent_status !== "awaiting_user")
}
onSubmit={(answers, refs) => {
const selectedDuration = String(answers.duration || "").trim();
if (selectedDuration) {
setConversation((prev) =>
prev ? { ...prev, params: { ...prev.params, duration: selectedDuration } } : prev
);
}
// 剧情反转预设选择故事深度后,顶部参数同步显示实际时长;服务端仍是最终事实来源。
if (message.payload?.interaction === "plot_twist_story_depth") {
const durationByDepth: Record<string, string> = {
"15s": "15 秒",
"30s": "30 秒",
"60s": "60 秒",
};
const duration = durationByDepth[String(answers.story_depth || "")];
if (duration) {
setConversation((prev) =>
prev ? { ...prev, params: { ...prev.params, duration } } : prev
);
}
}
// 乐观标记已提交,避免短路径返回前按钮还能连点
setMessages((prev) =>
prev.map((item) =>
@@ -2173,6 +2355,7 @@ export function OmniSessionPage({
<PromptFileCard
key={message.clientKey || message.id}
payload={message.payload}
onView={(title, body) => setPromptView({ title, body })}
/>
);
case "confirm": {
@@ -2212,15 +2395,15 @@ export function OmniSessionPage({
);
default: {
const refs = message.refs || [];
const body = stripMentionText(message.text, refs);
const rawBody = stripMentionText(message.text, refs);
const body = message.role === "assistant" ? stripNumericReplyInstruction(rawBody) : rawBody;
const pending = message.id === pendingUserId;
const showReplyGuide =
message.role === "assistant"
&& message.kind === "text"
&& messages[messages.length - 1]?.id === message.id
&& !streaming;
const replyHint = showReplyGuide ? replyHintForMessage(message.payload, isVideo) : "";
const replyOptions = showReplyGuide ? replyOptionsForMessage(message.payload, isVideo) : [];
const replyOptions = showReplyGuide ? replyOptionsForMessage(body, message.payload, isVideo) : [];
return (
<div
className={`omni-chat-row ${message.role === "user" ? "is-user" : "agent"}${pending ? " is-pending" : ""}`}
@@ -2239,23 +2422,12 @@ export function OmniSessionPage({
</div>
) : null}
{showReplyGuide ? (
<div className="omni-reply-guide" aria-label="下一步引导">
<p>
<span></span>
{replyHint}
</p>
<div className="omni-reply-guide-options">
{replyOptions.map((option, index) => (
<button
type="button"
className={index === 0 ? "primary" : ""}
key={option.text}
onClick={() => void send({ kind: "text", text: option.text })}
>
{option.label}
</button>
))}
</div>
<div className="omni-reply-guide" aria-label="回复操作">
<ReplyActions
options={replyOptions}
disabled={streaming}
onSubmit={(text) => void send({ kind: "text", text })}
/>
</div>
) : null}
{message.role === "user" && !pending ? (