优化全能创作

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