优化全能创作

This commit is contained in:
Azmat@qq.com
2026-09-14 15:58:20 +08:00
parent 463613c0d7
commit 3fed9b9435
9 changed files with 1538 additions and 139 deletions
+327 -44
View File
@@ -112,6 +112,74 @@ def append_step_confirm(conversation: CreationConversation, step: str) -> Creati
)
_GATE_CONTENT_KIND = {
"strategy": CreationMessage.Kind.STRATEGY,
"plan": CreationMessage.Kind.PLAN,
"prompt": CreationMessage.Kind.PROMPT_FILE,
}
def restore_gated_step_after_cancel(conversation: CreationConversation) -> bool:
"""取消修订/推进中的规划时,恢复最近闸门的 step_confirm,供用户再点「按这个继续 / 我想改」。
返回 True → 会话应回到 awaiting_user;False → 保持 idle(尚无闸门可恢复)。
幂等:已有未提交的 step_confirm 时只校正 stage。
"""
gated = ("strategy", "plan", "prompt")
confirms = []
for message in (
conversation.messages.filter(kind=CreationMessage.Kind.ELICIT)
.order_by("-seq")[:30]
):
payload = message.payload if isinstance(message.payload, dict) else {}
step = str(payload.get("step") or "").strip()
if payload.get("interaction") == "step_confirm" and step in gated:
confirms.append(message)
if not confirms:
return False
latest = confirms[0]
payload = dict(latest.payload or {})
step = str(payload.get("step") or "").strip()
if step not in gated:
return False
if not payload.get("submitted"):
set_video_gate_stage(conversation, step)
return True
content_kind = _GATE_CONTENT_KIND.get(step)
newer_content = False
if content_kind:
newer_content = conversation.messages.filter(
seq__gt=latest.seq, kind=content_kind
).exists()
if newer_content:
# 重写已落了新卡但还没出确认条 / 或已出过但本条仍是旧 submitted —— 补一条干净确认
has_open = any(
(not (m.payload or {}).get("submitted"))
and str((m.payload or {}).get("step") or "") == step
for m in confirms
)
if not has_open:
append_step_confirm(conversation, step)
else:
payload["submitted"] = False
payload["answers"] = {}
latest.payload = payload
latest.save(update_fields=["payload", "updated_at"])
set_video_gate_stage(conversation, step)
if step == "strategy":
memory = dict(conversation.memory or {})
if "strategy_confirmed" in memory:
memory.pop("strategy_confirmed", None)
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
return True
def emit_prompt_gate(
conversation: CreationConversation, video_prompt: str | None = None
) -> list[CreationMessage]:
@@ -220,32 +288,42 @@ IMAGE_MODEL_BY_LABEL = {
# 「智能时长」= 交给我们定,取一个口播讲得完又不烧钱的中间值
SMART_DURATION = 15
# 全能创作 video_prompt 写作规范:从专业创作 / 一键成片(口播脚本)抽硬规则,
# 适配「整段出片指令」而不是 ScriptDraft JSON。只要求口径接近,不改工具形态
# 全能创作 video_prompt 写作规范。目标是把 Prompt 写成导演、摄影、声音与出片模型都能
# 直接执行的制作文件,而不是只有几行「0-3 秒做什么」的提纲
_OMNI_VIDEO_PROMPT_RULES = """
出片脚本写法 · 对齐专业口播
表现形式默认**口播带货**,像真人在讲刚遇到的一件事,不要详情页朗读或主播念稿。
视频生成 Prompt · 制作级交付
video_prompt 是交给出片模型的完整制作文件,不是方案摘要、更不是几句分镜大纲。保留用户选定的
预设、人物、商品和创意方向;下面规定的是文档完整度与写法,不能照搬任何其他项目的商品、人设或台词。
video_prompt 必须能被出片模型直接执行,按时间轴写秒级分镜,建议结构:
- 片头写清:总时长、画幅、整体光线/色调、口播语气(口语、有停顿与立场)
- 然后按「0-3s / 3-8s / …」逐段写,每段同时写清这五项(不能省):
1. 景别(大特写/特写/近景/中景/全景;一条片里至少切两次景别)
2. 机位(平视/俯拍/仰拍/过肩/桌面视角)
3. 运镜(手持跟拍/推近/拉远/横摇/环绕/固定 —— 每段至少一个运镜词)
4. 动作(谁、哪只手、对什么、做什么;要连贯可拍,禁止「展示质感」等抽象词)
5. 信息变化(这几秒画面上多了/变了什么)
- 人声原文单独写清,必须用「人声(仅音频,由人物口型与配音表达,不出现在画面上):…」,禁止写成「口播:…」(裸写口播会被出片模型烧成字幕);字数贴近会话时长:大约 5.0–5.7 字/秒,
15 秒约 75–85 字;太短撑不满,太长会赶
- 钩子段画面不要「对着镜头说话」静态开场;前 15 个口播字禁止「大家好/今天分享/给你们推荐」
- 全片只围绕一个具体情境推进一个主卖点;卖点要有看得见的证据(质地/前后变化/用法结果)
- CTA 像跟朋友说话;禁止小黄车/立即购买/闭眼入等平台指令腔
- **不要写字幕/花字/标题贴片/弹幕/角标/水印/购物浮层**,也不要写「无字幕」
(否定说法也容易把字画上屏)。口播只存在于声音;包装上原有印刷字除外。
- 已 @ 的角色/商品/场景参考图会自动附上,不要在 prompt 里重描长相;以图锁定性别年龄服装外形。
- 用户说「商品说话 / 商品自述 / 商品拟人」时,默认采用**无脸拟人**:商品声音是画外角色声,
商品本体不做口型、不新增卡通五官;性格靠整体倾斜、转向、弹跳、进退、镜头和音效表达
只有用户明确要求可见的卡通五官时才例外
- 同一场戏保持地点、光线、服装连续;要换环境就明确写下一时间段切换
请按以下顺序完整输出,每个标题都要有具体内容:
时长:X 秒以内,比例:X。任务:一句话说明要生成什么、引用哪些参考素材。
标题:《本片标题》
风格与视觉参考:说明成片质感、真实度、拍摄媒介感与 4–6 个风格关键词。
镜头语言:说明主景别、人物/商品与镜头关系、机位、手持或稳定方式、镜头切换节奏。
视觉与美术规则:说明场景陈设、主体外观连续性、商品外观连续性;有参考图时要说清「只取什么特征、忽略什么背景/构图」。
色彩与材质系统:写出主色、材质、皮肤/产品/环境的可见质感。
打光规则:光源方向、软硬、色温、人物和产品分别如何受光。
剪辑节奏:列出时间段与 Hook → 证据/体验 → 转化收束的推进逻辑。
声音方向:人声身份、语气、语速、环境声/拟音、背景音乐的进入和收束;人声原文必须分配到对应分镜
场景:逐一写清可见地点、前中后景、环境道具与景深
主体与参考素材:逐一说明角色、商品、场景的可见身份和一致性要求。已 @ 的素材按 @图片1、@图片2 … 标注其用途;仅引用实际提供的素材
各个分镜的具体内容:按时间顺序逐段展开
分镜不能省略细节:每一段都必须严格用下面四行写完,不得只写一句画面描述:
「03 秒」
拍法:景别 + 机位 + 运镜 + 节奏。
画面内容:谁在何处、哪只手/身体如何动作、商品如何进入画面、前后发生什么可见变化
主体/产品露出:本段产品、人物或关键物件处在什么位置,哪些外观细节必须清晰稳定
声音:人声(仅音频,由人物口型与配音表达,不作为画面文字):{本段原话};再写清对应拟音和背景音乐状态
硬性质量要求:
- 15 秒成片至少写 4 段连续分镜;更长时按约 3–5 秒一段拆开,至少切换两种景别与两种机位/运镜。
- 15 秒 Prompt 通常不少于约 1200 个汉字;短片按时长同比例展开。每一段都要有可拍的连续动作、物理反馈或产品证据,不能用「高级感、展示质感、氛围拉满」代替动作。
- 人声必须是自然口语,约 5.0–5.7 字/秒;钩子前 15 个字禁止「大家好 / 今天分享 / 给你们推荐」。CTA 像朋友提醒,禁止小黄车、立即购买、闭眼入等平台指令腔。
- 全片围绕一个具体情境和一个主卖点推进,卖点必须有可见证据,例如质地、使用动作、前后变化或真实反应。
- 画面中不出现新增字幕、花字、标题贴片、弹幕、角标、水印、购物浮层或说明性文字;口播只存在于声音,包装本身原有印刷字除外。
- 结尾单列「全片一致性与禁用项」:重申角色、商品、场景、光线、服装/材质的连续性,以及本片最重要的画面禁用项。
- 用户说「商品说话 / 商品自述 / 商品拟人」时,默认无脸拟人:商品声音是画外角色声,商品本体不做口型、不新增卡通五官;性格只通过整体倾斜、转向、弹跳、进退、镜头和音效表达。只有用户明确要求可见卡通五官时才例外。
"""
@@ -362,6 +440,103 @@ def is_pure_chitchat(user_text: str) -> bool:
return bool(_CHITCHAT_RE.match(text))
# ---------------------------------------------------------------- 每轮引导(文字也要告诉用户下一步怎么回)
_GUIDANCE_MARKER_RE = re.compile(
r"("
r"请回复|请直接|直接说|直接丢|告诉我|可以回|下一步|"
r"想改|点「|回复:|回复:|发给我|选一个|怎么回|"
r"继续补充|想做什么"
r")"
)
def text_already_guides(text: str) -> bool:
"""正文里是否已经写明用户下一步该怎么回。"""
return bool(_GUIDANCE_MARKER_RE.search((text or "").strip()))
def default_reply_hint(
conversation: CreationConversation | None = None,
*,
has_context: bool = False,
is_video: bool = True,
) -> str:
"""纯文字收束时的默认「下一步可以这样回」提示。"""
stage = ""
if conversation is not None:
try:
stage = get_video_gate_stage(conversation)
except Exception: # noqa: BLE001
stage = ""
if stage in ("strategy", "plan", "prompt"):
return "请点上方确认卡的「按这个继续」,或直接说想改哪里。"
if stage == "confirm":
return "请在确认卡上核对参数后点「开始生成」,或直接说要改的参数。"
if stage == "done":
return "想改哪一处直接说;要重新来就回复「重新来」。"
if has_context:
return "请直接说下一步:改哪里、换素材,或继续推进。"
kind = "短视频" if is_video else "商品图"
return f"请直接丢一句想法,例如「帮我做一条{kind}」。"
def apply_reply_hint(message: CreationMessage, hint: str) -> CreationMessage:
"""给文字气泡挂上 reply_hint,供前端展示「下一步可以这样回」。"""
payload = dict(message.payload or {})
if str(payload.get("reply_hint") or "").strip():
return message
payload["reply_hint"] = hint
message.payload = payload
message.save(update_fields=["payload"])
return message
def ensure_turn_guides(
conversation: CreationConversation,
*,
turn_has_gate: bool,
last_text_bubble: CreationMessage | None,
has_context: bool,
is_video: bool,
) -> list[dict]:
"""回合收束校验:若本轮只有散文气泡、没有追问/确认闸门,补上回复引导。
优先给已有文字气泡挂 reply_hint;若本轮连文字都没有,再落一条短引导。
不打断 5 步闸门(已有 elicit/confirm 时直接跳过)。
"""
if turn_has_gate:
return []
# 会话里若仍有未提交的追问/确认卡,用户本就能点卡推进,不必再注脚。
open_gate = conversation.messages.filter(
role="assistant",
kind__in=(CreationMessage.Kind.ELICIT, CreationMessage.Kind.CONFIRM),
).order_by("-seq").first()
if open_gate is not None and not bool((open_gate.payload or {}).get("submitted")):
return []
hint = default_reply_hint(conversation, has_context=has_context, is_video=is_video)
events: list[dict] = []
if last_text_bubble is not None:
if text_already_guides(last_text_bubble.text):
return []
updated = apply_reply_hint(last_text_bubble, hint)
events.append({"type": "message", "message": _message_payload(updated)})
return events
guide = append_message(
conversation,
role="assistant",
text=hint,
payload={"reply_hint": hint},
)
events.append({"type": "message", "message": _message_payload(guide)})
return events
# 明确要做内容才算出方案/出图。闲聊、吐槽、问功能都不算。
_CREATIVE_INTENT_RE = re.compile(
r"("
@@ -370,7 +545,7 @@ _CREATIVE_INTENT_RE = re.compile(
r"做[一两]?[条个张](?:视频|片|图|广告)?|拍[一两]?[条个](?:视频|片|广告)?|"
r"出[一两]?[条个张](?:视频|片|图|广告)?|出片|出图|出方案|写方案|改方案|重写方案|重新写|"
r"生成(?:一下|一张|几张|一条)?(?:视频|图|片|广告|脚本)?|做条|做个片|短视频|带货视频|短广告|广告片|带货片|"
r"换卖点|改卖点|换剧情|改剧情|重做|重新出|按这个出|确认出片|"
r"换卖点|改卖点|换剧情|改剧情|重做|重新出|重新来|再来一次|从头开始|重新做|按这个出|确认出片|"
r"分镜|脚本|口播稿|storyboard|拟人|"
r"(改|换|修改|更换).{0,8}(时长|秒数|比例|尺寸|画幅|分辨率|清晰度|模型)|"
r"改成\s*\d+\s*秒|改成\s*\d+\s*[:]\s*\d+|改成.{0,8}(竖屏|横屏|比例|分辨率)"
@@ -394,6 +569,8 @@ def has_creative_intent(user_text: str, refs: list | None = None) -> bool:
return False
if is_pure_chitchat(text):
return False
if is_restart_intent(text):
return True
if _CREATIVE_INTENT_RE.search(text):
return True
# 「把商品…创作一条…短广告」这类 brief:同时有创作动词和成片名词
@@ -412,6 +589,55 @@ def is_continue_intent(user_text: str) -> bool:
return bool(_CONTINUE_INTENT_RE.match((user_text or "").strip()))
# 整轮重来:不是回答上一张追问卡,也不是局部「改策略/换模特」。
_RESTART_INTENT_RE = re.compile(
r"^\s*("
r"重新来|重来|从头开始|再来一次|重新做|"
r"重新开始|从头再来|重做一遍|再做一次|重来一遍|"
r"restart|start\s*over|start\s*again"
r")[吧啊呀呢]*[\s!.。~~??…]*$",
re.IGNORECASE,
)
_RESTART_CONTINUATION = (
"用户明确要求重新来/从头开始。平台已重置闸门阶段并关闭未完成的追问/确认卡。"
"先用一句很短的话确认「好,我们重新来」,然后基于会话最初 brief 与已钉素材,"
"从澄清或缺信息时 ask_user、信息够了就 write_strategy 重新开一整轮创作;"
"不要再打开上一轮的模特库/商品库追问,不要沿用旧策略/方案/Prompt,不要猜模特。"
)
def is_restart_intent(user_text: str) -> bool:
"""用户明确要整轮重来(重新来/重来/从头开始…)。"""
return bool(_RESTART_INTENT_RE.match((user_text or "").strip()))
def apply_restart_intent(conversation: CreationConversation) -> int:
"""重置视频闸门 memory,并关闭未提交的 elicit/confirm 卡。返回关闭张数。"""
memory = dict(conversation.memory or {})
memory["stage"] = "clarify"
memory.pop("pending_video_prompt", None)
memory.pop("strategy_confirmed", None)
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
closed = 0
candidates = conversation.messages.filter(
kind__in=(CreationMessage.Kind.ELICIT, CreationMessage.Kind.CONFIRM),
).order_by("-seq")[:40]
for message in candidates:
payload = dict(message.payload or {})
if payload.get("submitted"):
continue
payload["submitted"] = True
payload["cancelled"] = True
payload["cancelled_reason"] = "restart"
message.payload = payload
message.save(update_fields=["payload", "updated_at"])
closed += 1
return closed
def session_has_creative_context(conversation: CreationConversation) -> bool:
"""会话里是否已有可继续的创作进度(钉了素材 / 出过策略或方案)。
@@ -631,11 +857,13 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
"function": {
"name": "ask_user",
"description": (
"缺少必要信息时向用户追问"
"缺少必要信息、或需要用户做选择时向用户追问 —— 这是优先的引导方式"
"只在信息**确实缺失且无法合理推断**时用;能自己定的就自己定,别把用户当填表机器。"
"用户要选/改/换商品、角色、模特、场景时**必须**调这个工具,"
"type 用 asset 并填 asset_types。系统会以类Agent自然对话轻量询问是否需要发送商品列表(同时支持直接输入名字或由你推荐),避免一上来粗暴弹出大卡片打断交流。"
"需要用户在几个明确选项里选时,用 type=single/multi 并给出 options。"
"一次只问 1 项,禁止问「要不要继续」「要不要生成」「是否开始创作」这类流程问题。"
"若本轮只写说明文字、不做选择,也必须在文字里写清用户下一句该回什么。"
),
"parameters": {
"type": "object",
@@ -728,7 +956,8 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
"调完只出方案卡并停下等人确认 —— 不要同轮出 Prompt 卡或积分确认卡。"
"usp / points / timeline 必须写满具体文案;同时把完整 video_prompt 写好存档,"
"用户确认方案后由平台展示 Prompt。"
"video_prompt 按系统里的「出片脚本写法」写成口播秒级分镜,不要只写大纲。"
"video_prompt 按系统里的「制作级交付」写成完整 Prompt 文件:必须有整体规则、"
"声音/灯光/场景/参考素材锁定、逐镜四行细节和一致性收束,不要只写大纲。"
"先有已确认的 write_strategy,再调它。"
),
"parameters": {
@@ -758,11 +987,10 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
"video_prompt": {
"type": "string",
"description": (
"交给出片模型的完整口播带货指令(对齐专业创作口径)。"
"必须含:总时长与画幅、整体光线色调、按 0-Ns 分段的秒级分镜"
"(每段写清景别/机位/运镜/具体动作/信息变化)、人声(仅音频抬头)原文、一个主卖点与可见证据、口语 CTA。禁止「口播:」字样"
"禁止字幕/花字/贴片及「无字幕」字样;禁止详情页腔与「大家好」开场"
"已 @ 素材会自动作参考图,勿重描长相。"
"交给出片模型的制作级完整指令,不是大纲。按系统规定的标题顺序写:"
"时长任务、标题、风格、镜头语言、视觉美术、色彩材质、打光、剪辑、声音、场景、主体参考、逐镜脚本、一致性收束。"
"每一镜严格包含拍法/画面内容/主体或产品露出/声音四行;15 秒至少 4 镜,含人声原文、拟音和 BGM 节奏"
"已 @ 素材标明用途与需锁定的特征;禁止把口播做成画面文字"
),
},
},
@@ -778,7 +1006,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
"写出片 Prompt 文件卡并请用户确认。"
"仅当用户已确认方案、或明确要求改 Prompt 时调用;"
"不要在 write_plan 同轮调用。调完停下等人确认,不要同轮出积分确认卡或直接出片。"
"video_prompt 必须是完整可执行的口播秒级分镜。"
"video_prompt 必须是系统规定的制作级完整文件,不能只把旧 Prompt 缩写成几行分镜。"
),
"parameters": {
"type": "object",
@@ -786,8 +1014,8 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
"video_prompt": {
"type": "string",
"description": (
"交给出片模型的完整口播带货指令。"
"必须含总时长与画幅、按秒分段的景别/机位/运镜/动作/信息变化、人声(仅音频抬头)"
"交给出片模型的完整制作文件。必须含总体视觉/美术/色彩/打光/声音/场景/参考图锁定规则,"
"以及按秒分段、每镜均含拍法/画面内容/主体或产品露出/声音的完整脚本与一致性收束"
),
},
},
@@ -1247,8 +1475,10 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
"- 缺少商品、模特、角色、场景等素材时:**绝不要直接弹出大块素材选择卡**打断对话。像懂创作、懂电商的专业伙伴一样先自然询问用户想推什么商品/用哪个角色,询问是否需要把商品列表发给他选,同时说明也可以直接输入商品名或由你推荐。",
"- 只有当用户明确说要发列表(如「发给我」「发列表」「给我看看」「我来选」)时,才展示可视化卡片。",
"- 用户直接打字输入商品名时,直接采纳该商品并继续推进创作方案,不要强迫用户去卡片里点选。",
"- 调性、受众、文案、时长等其他信息才用聊天追问,问题末尾顺手告诉用户下一句怎么回。",
"- 调性、受众、文案、时长等其他信息用 ask_user 或聊天追问;无论哪种,都必须让用户知道下一句怎么回。",
"- 用户刚回答过你的问题时,直接沿着答案继续;不要复述答案,也不要额外回一句「收到」。",
"- 用户说「重新来」「重来」「从头开始」「再来一次」「重新做」:整轮重开创作。"
" 先短确认,再按最初 brief/已钉素材从澄清或 write_strategy 推进;禁止再打开上一轮模特/商品库追问。",
"- 用户选择暂不提供某项素材时,把它当成明确授权:按已有信息和合理默认继续。除非任务客观上无法完成,否则不要再次追问同一素材。",
"- 用户说「你来定」「你帮我选」「随便」「都行」时,就是授权你做专业判断;直接选合理方案继续,不要把选择题再抛回去。",
"- 禁止问「要不要继续」「要不要生成」「是否开始创作」这类流程问题。缺信息用 ask_user;信息够了就写策略。"
@@ -1259,6 +1489,14 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
"- 会话里**已有**商品或方向时:针对用户这句话本身自然回应;别用「要现在生成还是先调细节」把工作又抛回给用户。",
"- 没有明确创作指令时不要自己写策略卡/方案卡,也不要主动追问流程确认;等用户给出具体想法或修改。",
"",
"【每轮必须给引导】",
"- 每一轮回复结束时,用户必须知道下一步怎么做:要么调用 ask_user 弹出可选项,"
"要么在文字里明确告诉用户该回复什么(例如「请回复:1… 2…」或「直接说想改的卖点」)。",
"- 禁止只丢一段解释/分析就结束、让用户不知道该回什么。",
"- 缺信息或要用户做选择时:优先 ask_user(带 options 的 single/multi,或 asset)。",
"- 纯说明/判断/闲聊回应:文末必须带一句可执行的回复指引。",
"- 视频闸门的 step_confirm / 积分确认卡本身已是引导,不要再口头追问流程。",
"",
"【什么时候反问】",
"- 只有信息**确实缺失且无法合理推断**时才调用 ask_user;能自己定的就自己定。",
"- ask_user 一次只问一件事。type=asset 会以类Agent自然追问轻量询问是否发送列表或由你推荐;其他类型显示普通聊天问题,让用户直接输入。",
@@ -1301,7 +1539,7 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
"plan": "当前阶段=等方案确认:不要 write_prompt 或出片。若用户在改方案,只重调 write_plan。",
"prompt": "当前阶段=等 Prompt 确认:不要出积分确认/出片。若用户在改 Prompt,只重调 write_prompt。",
"confirm": "当前阶段=等出片确认:不要再写策略/方案/Prompt;用户会在确认卡上点开始生成。",
"done": "当前阶段=已出片:等用户新的修改或新需求再行动。",
"done": "当前阶段=已出片:等用户新的修改或新需求再行动。用户说重新来/重来/从头开始时,当作新一轮创作,从澄清或 write_strategy 重开,不要再弹旧模特/商品追问。",
}.get(stage, "")
if stage_hint:
lines.append(f"- {stage_hint}")
@@ -1672,13 +1910,19 @@ def iter_creation_agent_events(
# 单纯打招呼无论有没有参考素材、有没有既有上下文,都不值得等模型。
# 素材先收下,等用户说清用途再分析,避免「你好」卡住还冒出一大段建议。
if fast_greeting:
greet_text = (
"你好,素材我收到了。想拿它做什么,直接说就行。"
if refs else "你好。想做什么,直接说就行。"
)
reply = append_message(
conversation,
role="assistant",
text=(
"你好,素材我收到了。想拿它做什么,直接说就行。"
if refs else "你好。想做什么,直接说就行。"
),
text=greet_text,
payload={
"reply_hint": default_reply_hint(
conversation, has_context=False, is_video=conversation.mode == CreationConversation.Mode.VIDEO
),
},
)
yield {"type": "message", "message": _message_payload(reply)}
yield {"type": "done"}
@@ -1688,10 +1932,16 @@ def iter_creation_agent_events(
# 针对用户实际说的话自然回应,但不给方案工具。
has_context = session_has_creative_context(conversation)
if record_user_message and is_pure_chitchat(text) and not refs and not has_context:
chit_text = "我在。想做什么,直接丢一句想法给我就行。"
reply = append_message(
conversation,
role="assistant",
text="我在。想做什么,直接丢一句想法给我就行。",
text=chit_text,
payload={
"reply_hint": default_reply_hint(
conversation, has_context=False, is_video=conversation.mode == CreationConversation.Mode.VIDEO
),
},
)
yield {"type": "message", "message": _message_payload(reply)}
yield {"type": "done"}
@@ -1728,6 +1978,8 @@ def iter_creation_agent_events(
from .creation import is_agent_cancel_requested
turn_has_gate = False
last_text_bubble = None
for _round in range(MAX_TOOL_ROUNDS):
if is_agent_cancel_requested(conversation.id):
# 用户终止:干净收束,不落 ERROR,已落库消息保留
@@ -1792,6 +2044,7 @@ def iter_creation_agent_events(
asks_user = bool(fallback_fields) or any(call.get("name") == "ask_user" for call in calls)
if said and not asks_user:
bubble = append_message(conversation, role="assistant", text=said)
last_text_bubble = bubble
yield {"type": "message", "message": _message_payload(bubble)}
if not calls:
@@ -1801,6 +2054,13 @@ def iter_creation_agent_events(
)
for event in result.get("_events", []):
yield event
if event.get("type") == "message":
msg = event.get("message") or {}
if msg.get("kind") in (
CreationMessage.Kind.ELICIT,
CreationMessage.Kind.CONFIRM,
):
turn_has_gate = True
break
messages.append({
@@ -1835,6 +2095,13 @@ def iter_creation_agent_events(
yield {"type": "tool", "id": name, "label": _TOOL_LABELS.get(name, name), "status": "done"}
for event in result.get("_events", []):
yield event
if event.get("type") == "message":
msg = event.get("message") or {}
if msg.get("kind") in (
CreationMessage.Kind.ELICIT,
CreationMessage.Kind.CONFIRM,
):
turn_has_gate = True
messages.append({
"role": "tool",
"tool_call_id": f"call_{index}",
@@ -1847,6 +2114,16 @@ def iter_creation_agent_events(
if stop:
break
# 收束校验:本轮若只剩散文、没有追问/确认卡,补 reply_hint 或短引导。
for event in ensure_turn_guides(
conversation,
turn_has_gate=turn_has_gate,
last_text_bubble=last_text_bubble,
has_context=has_context,
is_video=context.is_video,
):
yield event
yield {"type": "done"}
except Exception as exc: # noqa: BLE001 — SSE 里任何未捕获异常都会变成前端「白屏卡死」
logger.exception("creation agent stream failed: %s", exc)
@@ -1966,10 +2243,16 @@ def run_creation_agent_turn(
conversation.refresh_from_db(fields=["agent_status", "team_id"])
except Exception: # noqa: BLE001
pass
# 用户终止时 API 侧多半已 idle+放锁;这里再收一次保证幂等,且不落入 awaiting_user
# 用户终止:恢复闸门确认条(若有),再 finish;避免卡在「已收到…正在重写」且 agent 变 idle
if user_cancelled:
try:
awaiting_user = restore_gated_step_after_cancel(conversation)
except Exception: # noqa: BLE001
logger.exception("creation agent cancel: restore gated step failed")
awaiting_user = False
finish_agent_planning(
conversation,
awaiting_user=(awaiting_user and not user_cancelled),
awaiting_user=awaiting_user,
)
return conversation_id