优化全能创作
This commit is contained in:
@@ -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 … 标注其用途;仅引用实际提供的素材。
|
||||
各个分镜的具体内容:按时间顺序逐段展开。
|
||||
|
||||
分镜不能省略细节:每一段都必须严格用下面四行写完,不得只写一句画面描述:
|
||||
「0–3 秒」
|
||||
拍法:景别 + 机位 + 运镜 + 节奏。
|
||||
画面内容:谁在何处、哪只手/身体如何动作、商品如何进入画面、前后发生什么可见变化。
|
||||
主体/产品露出:本段产品、人物或关键物件处在什么位置,哪些外观细节必须清晰稳定。
|
||||
声音:人声(仅音频,由人物口型与配音表达,不作为画面文字):{本段原话};再写清对应拟音和背景音乐状态。
|
||||
|
||||
硬性质量要求:
|
||||
- 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
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
from django.test import SimpleTestCase, TestCase, override_settings
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.accounts.models import Team, TeamMember, User
|
||||
@@ -14,6 +14,7 @@ from apps.assets.models import Asset, AssetFile
|
||||
from apps.products.models import Product, ProductImage
|
||||
|
||||
from .creation import append_message
|
||||
from .views import _typed_step_confirm_action
|
||||
from .creation_agent import (
|
||||
AgentContext,
|
||||
COMPRESS_MIN_BATCH,
|
||||
@@ -23,16 +24,23 @@ from .creation_agent import (
|
||||
_coerce_fields,
|
||||
_image_count,
|
||||
_merge_tool_call_deltas,
|
||||
apply_restart_intent,
|
||||
build_system_prompt,
|
||||
build_messages,
|
||||
default_reply_hint,
|
||||
ensure_turn_guides,
|
||||
get_creation_chat_model,
|
||||
get_video_gate_stage,
|
||||
has_creative_intent,
|
||||
is_continue_intent,
|
||||
is_greeting,
|
||||
is_pure_chitchat,
|
||||
is_restart_intent,
|
||||
session_has_creative_context,
|
||||
stream_creation_agent,
|
||||
submit_confirmed_image,
|
||||
submit_confirmed_video,
|
||||
text_already_guides,
|
||||
tool_schemas,
|
||||
video_duration,
|
||||
video_model_name,
|
||||
@@ -447,6 +455,12 @@ class SendEndpointTests(TestCase):
|
||||
response = self.client.post(f"/api/ai/creations/{self.conversation.id}/send/", {}, format="json")
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_natural_step_confirmation_phrases_continue_instead_of_revising(self):
|
||||
for text in ("这个还行", "这种还行", "可以", "就这样", "没问题", "按这个继续", "好,继续"):
|
||||
with self.subTest(text=text):
|
||||
self.assertEqual(_typed_step_confirm_action(text), "confirm")
|
||||
self.assertEqual(_typed_step_confirm_action("可以,但镜头再快一点"), "revise")
|
||||
|
||||
def test_typed_reply_answers_chat_question_and_continues(self):
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="净颜精华")
|
||||
append_message(self.conversation, role="user", text="做一张轻松有趣的商品海报")
|
||||
@@ -1381,6 +1395,108 @@ class CreativeIntentTests(SimpleTestCase):
|
||||
self.assertFalse(is_continue_intent(text), text)
|
||||
|
||||
|
||||
class RestartIntentTests(SimpleTestCase):
|
||||
"""「重新来」是整轮重开,不是追问卡答案,也不是局部改模特。"""
|
||||
|
||||
def test_restart_phrases_match(self):
|
||||
for text in ("重新来", "重来", "从头开始", "再来一次", "重新做", "重新来吧", "Restart"):
|
||||
self.assertTrue(is_restart_intent(text), text)
|
||||
self.assertTrue(has_creative_intent(text), text)
|
||||
|
||||
def test_non_restart_phrases_do_not_match(self):
|
||||
for text in ("重新选模特", "重新写方案", "改成重新来的感觉", "好的", "继续"):
|
||||
self.assertFalse(is_restart_intent(text), text)
|
||||
|
||||
|
||||
class RestartSendEndpointTests(TestCase):
|
||||
"""出片后打「重新来」应重置闸门,且不把未完成模特追问当成答案。"""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="restart-owner", password="p")
|
||||
self.team = Team.objects.create(name="Restart", owner=self.user)
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role="owner")
|
||||
provider = ModelProvider.objects.create(name="fake-r", display_name="Fake", base_url="https://x")
|
||||
self.model = ModelConfig.objects.create(
|
||||
provider=provider, name="fake-text-r", display_name="Fake Text",
|
||||
capability=ModelConfig.Capability.TEXT, endpoint="chat/completions",
|
||||
)
|
||||
self.conversation = CreationConversation.objects.create(
|
||||
team=self.team, created_by=self.user, mode="video", title="重来",
|
||||
params={"model": "Seedance 2.5", "ratio": "9:16", "resolution": "720p", "duration": "15 秒"},
|
||||
memory={
|
||||
"stage": "done",
|
||||
"strategy_confirmed": True,
|
||||
"pending_video_prompt": "旧出片指令",
|
||||
},
|
||||
)
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.user)
|
||||
|
||||
def test_restart_text_closes_open_model_elicit_and_resets_stage(self):
|
||||
gate = append_message(
|
||||
self.conversation,
|
||||
role="assistant",
|
||||
kind=CreationMessage.Kind.ELICIT,
|
||||
text="这条想用哪位模特?可以直接告诉我,或者需要我把模特库发给你选吗?",
|
||||
payload={
|
||||
"interaction": "chat",
|
||||
"phase": "gate",
|
||||
"fields": [{"key": "_asset_gate", "label": "模特", "type": "text"}],
|
||||
"pending_fields": [{
|
||||
"key": "model",
|
||||
"label": "这条想用哪位模特?",
|
||||
"type": "asset",
|
||||
"asset_types": ["model"],
|
||||
}],
|
||||
"submitted": False,
|
||||
"answers": {},
|
||||
},
|
||||
)
|
||||
confirm = append_message(
|
||||
self.conversation,
|
||||
role="assistant",
|
||||
kind=CreationMessage.Kind.CONFIRM,
|
||||
payload={"kind": "video", "submitted": False, "video_prompt": "旧片"},
|
||||
)
|
||||
|
||||
# 本机 .env 常开 INLINE;单测里改走 Celery 假入队,只断言 send 同步侧行为。
|
||||
with override_settings(CREATION_AGENT_INLINE=False, CREATION_AGENT_TASK_QUEUE="airshelf.local"), \
|
||||
patch("apps.ai.views.begin_agent_planning", return_value=True), \
|
||||
patch("apps.ai.views.run_creation_agent_turn_task") as task:
|
||||
response = self.client.post(
|
||||
f"/api/ai/creations/{self.conversation.id}/send/",
|
||||
{"kind": "text", "text": "重新来"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 202, response.content)
|
||||
self.assertTrue(task.apply_async.called)
|
||||
turn_kwargs = task.apply_async.call_args.kwargs["kwargs"]
|
||||
|
||||
gate.refresh_from_db()
|
||||
confirm.refresh_from_db()
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertTrue(gate.payload["submitted"])
|
||||
self.assertTrue(gate.payload.get("cancelled"))
|
||||
self.assertEqual(gate.payload.get("cancelled_reason"), "restart")
|
||||
# 绝不能把「重新来」当成模特答案写进追问卡
|
||||
self.assertEqual(gate.payload.get("answers") or {}, {})
|
||||
self.assertTrue(confirm.payload["submitted"])
|
||||
self.assertEqual(get_video_gate_stage(self.conversation), "clarify")
|
||||
self.assertFalse((self.conversation.memory or {}).get("strategy_confirmed"))
|
||||
self.assertNotIn("pending_video_prompt", self.conversation.memory or {})
|
||||
self.assertTrue(turn_kwargs["force_creative_turn"])
|
||||
self.assertIn("重新来", turn_kwargs["continuation_instruction"])
|
||||
self.assertIn("模特库", turn_kwargs["continuation_instruction"])
|
||||
self.assertEqual(turn_kwargs["text"], "重新来")
|
||||
self.assertTrue(turn_kwargs["record_user_message"])
|
||||
|
||||
def test_apply_restart_intent_helper_resets_memory(self):
|
||||
closed = apply_restart_intent(self.conversation)
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertEqual(closed, 0)
|
||||
self.assertEqual(get_video_gate_stage(self.conversation), "clarify")
|
||||
|
||||
|
||||
class ToolGateTests(CreationAgentBaseTests):
|
||||
def test_empty_conversation_greeting_is_human_and_actionable(self):
|
||||
events = _events(stream_creation_agent(
|
||||
@@ -1457,3 +1573,69 @@ class ToolGateTests(CreationAgentBaseTests):
|
||||
))
|
||||
names = {tool["function"]["name"] for tool in fake.calls[0]["extra_body"]["tools"]}
|
||||
self.assertIn("generate_image", names)
|
||||
|
||||
|
||||
class ReplyGuidanceTests(CreationAgentBaseTests):
|
||||
"""每轮回复必须给引导:系统提示有规则,散文收束会挂 reply_hint。"""
|
||||
|
||||
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)
|
||||
|
||||
def test_system_prompt_requires_production_grade_video_prompt_document(self):
|
||||
self.conversation.mode = CreationConversation.Mode.VIDEO
|
||||
self.conversation.save(update_fields=["mode"])
|
||||
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 · 制作级交付】", prompt)
|
||||
self.assertIn("视觉与美术规则", prompt)
|
||||
self.assertIn("色彩与材质系统", prompt)
|
||||
self.assertIn("主体/产品露出", prompt)
|
||||
self.assertIn("全片一致性与禁用项", prompt)
|
||||
|
||||
def test_plain_text_turn_gets_reply_hint(self):
|
||||
# 闲聊路径会走到模型;模型只吐散文、不调工具时必须补引导。
|
||||
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,
|
||||
))
|
||||
texts = [
|
||||
e["message"] for e in events
|
||||
if e.get("type") == "message" and e["message"]["role"] == "assistant"
|
||||
and e["message"]["kind"] == "text"
|
||||
]
|
||||
self.assertTrue(texts)
|
||||
last = texts[-1]
|
||||
self.assertTrue(str((last.get("payload") or {}).get("reply_hint") or "").strip())
|
||||
self.conversation.refresh_from_db()
|
||||
stored = self.conversation.messages.filter(role="assistant", kind="text").order_by("-seq").first()
|
||||
self.assertIsNotNone(stored)
|
||||
self.assertTrue(str((stored.payload or {}).get("reply_hint") or "").strip())
|
||||
|
||||
def test_ensure_turn_guides_skips_when_elicit_open(self):
|
||||
bubble = append_message(self.conversation, role="assistant", text="先看看策略。")
|
||||
append_message(
|
||||
self.conversation, role="assistant", kind=CreationMessage.Kind.ELICIT,
|
||||
text="确认后继续",
|
||||
payload={"interaction": "step_confirm", "step": "strategy", "submitted": False},
|
||||
)
|
||||
events = ensure_turn_guides(
|
||||
self.conversation,
|
||||
turn_has_gate=False,
|
||||
last_text_bubble=bubble,
|
||||
has_context=True,
|
||||
is_video=True,
|
||||
)
|
||||
self.assertEqual(events, [])
|
||||
bubble.refresh_from_db()
|
||||
self.assertFalse((bubble.payload or {}).get("reply_hint"))
|
||||
|
||||
def test_text_already_guides_detects_reply_instruction(self):
|
||||
self.assertTrue(text_already_guides("这个方向能做。直接说想改的卖点。"))
|
||||
self.assertFalse(text_already_guides("这条可以偏清爽一点,突出成分感。"))
|
||||
self.assertIn("请直接", default_reply_hint(self.conversation, has_context=False, is_video=True))
|
||||
|
||||
@@ -16,6 +16,7 @@ from .creation import (
|
||||
sync_generating_messages,
|
||||
team_agent_busy,
|
||||
)
|
||||
from .creation_agent import append_step_confirm, restore_gated_step_after_cancel
|
||||
from .models import AITask, CreationConversation, CreationMessage, ModelConfig, ModelProvider
|
||||
|
||||
|
||||
@@ -281,3 +282,56 @@ class CreationAgentCancelTests(TestCase):
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertEqual(self.conversation.agent_status, CreationConversation.AgentStatus.IDLE)
|
||||
self.assertFalse(team_agent_busy(self.team))
|
||||
|
||||
def test_cancel_during_strategy_revise_reopens_step_confirm(self):
|
||||
append_message(
|
||||
self.conversation,
|
||||
role="assistant",
|
||||
kind=CreationMessage.Kind.STRATEGY,
|
||||
payload={"target": "白领", "trust": "成分", "belief": "温和", "direction": "口播"},
|
||||
)
|
||||
confirm = append_step_confirm(self.conversation, "strategy")
|
||||
confirm.payload = {
|
||||
**confirm.payload,
|
||||
"submitted": True,
|
||||
"answers": {"step_action": "revise", "feedback": "更活泼一点"},
|
||||
}
|
||||
confirm.save(update_fields=["payload", "updated_at"])
|
||||
self.conversation.memory = {"stage": "strategy", "strategy_confirmed": True}
|
||||
self.conversation.save(update_fields=["memory", "updated_at"])
|
||||
|
||||
self.assertTrue(begin_agent_planning(self.conversation))
|
||||
ok = self.client.post(f"/api/ai/creations/{self.conversation.id}/cancel/", {}, format="json")
|
||||
self.assertEqual(ok.status_code, 200, getattr(ok, "data", ok.content))
|
||||
body = ok.json()
|
||||
self.assertEqual(body["agent_status"], "awaiting_user")
|
||||
|
||||
confirm.refresh_from_db()
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertFalse(confirm.payload.get("submitted"))
|
||||
self.assertEqual(confirm.payload.get("answers") or {}, {})
|
||||
self.assertEqual(self.conversation.agent_status, CreationConversation.AgentStatus.AWAITING_USER)
|
||||
self.assertEqual((self.conversation.memory or {}).get("stage"), "strategy")
|
||||
self.assertNotIn("strategy_confirmed", self.conversation.memory or {})
|
||||
|
||||
def test_restore_gated_step_emits_fresh_confirm_when_rewrite_already_landed(self):
|
||||
old = append_step_confirm(self.conversation, "plan")
|
||||
old.payload = {**old.payload, "submitted": True, "answers": {"step_action": "revise"}}
|
||||
old.save(update_fields=["payload", "updated_at"])
|
||||
append_message(
|
||||
self.conversation,
|
||||
role="assistant",
|
||||
kind=CreationMessage.Kind.PLAN,
|
||||
payload={"usp": "新品", "points": ["点1"], "timeline": [], "matrix": [], "voice_chars": [40, 50]},
|
||||
)
|
||||
|
||||
self.assertTrue(restore_gated_step_after_cancel(self.conversation))
|
||||
old.refresh_from_db()
|
||||
self.assertTrue(old.payload.get("submitted")) # 历史卡保持已提交
|
||||
opens = [
|
||||
m for m in self.conversation.messages.filter(kind=CreationMessage.Kind.ELICIT).order_by("seq")
|
||||
if (m.payload or {}).get("interaction") == "step_confirm"
|
||||
and (m.payload or {}).get("step") == "plan"
|
||||
and not (m.payload or {}).get("submitted")
|
||||
]
|
||||
self.assertEqual(len(opens), 1)
|
||||
|
||||
@@ -35,11 +35,15 @@ from .creation import (
|
||||
)
|
||||
from .creation_agent import (
|
||||
_ASSET_CARD_LABELS,
|
||||
_RESTART_CONTINUATION,
|
||||
apply_confirm_params,
|
||||
apply_restart_intent,
|
||||
apply_session_params,
|
||||
emit_final_confirm_gate,
|
||||
emit_prompt_gate,
|
||||
is_greeting,
|
||||
is_restart_intent,
|
||||
restore_gated_step_after_cancel,
|
||||
set_video_gate_stage,
|
||||
submit_confirmed_image,
|
||||
submit_confirmed_video,
|
||||
@@ -134,6 +138,23 @@ _WANTS_CARD_RE = re.compile(
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# 步骤确认卡下,用户往往不会去点「按这个继续」,而是自然地回一句
|
||||
# 「这个还行」「就这样」「没问题」。这些是确认,不应被误判成修改意见。
|
||||
_STEP_TEXT_CONFIRM_RE = re.compile(
|
||||
r"^(?:"
|
||||
r"好(?:的)?|行|可以(?:了)?|没问题|确认|继续(?:下一步)?|"
|
||||
r"就这样(?:吧)?|按这个(?:继续|来|做)?|就按这个(?:继续|来|做)?|"
|
||||
r"(?:这(?:个|样|版|种)?|方案|策略|prompt)(?:还行|可以|挺好|不错|没问题)(?:吧)?"
|
||||
r")(?:继续(?:下一步)?)?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _typed_step_confirm_action(text: str) -> str:
|
||||
"""把对着步骤卡输入的自然语言收成「继续」或「修改」。"""
|
||||
compact = re.sub(r"[\s,,。.!!??、~~…]+", "", str(text or "").lower())
|
||||
return "confirm" if _STEP_TEXT_CONFIRM_RE.fullmatch(compact) else "revise"
|
||||
|
||||
|
||||
def _pending_chat_question(conversation: CreationConversation) -> CreationMessage | None:
|
||||
"""找最近一条未回答的新式追问;卡片既可点选,也兼容直接输入。"""
|
||||
@@ -1625,7 +1646,9 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
def cancel(self, request, pk=None):
|
||||
"""终止进行中的整理方案(agent planning)。
|
||||
|
||||
仅 `agent_status===planning` 可取消 → **200** `{conversation_id, agent_status: idle}`。
|
||||
仅 `agent_status===planning` 可取消 → **200** `{conversation_id, agent_status}`。
|
||||
若取消发生在策略/方案/Prompt 闸门的修订或推进中,恢复该步 `step_confirm`
|
||||
(清除 submitted),`agent_status` 回到 `awaiting_user`;否则为 `idle`。
|
||||
非 planning → **409**。打 Redis 取消标供 Celery 在工具轮间隙停跑,立刻释放
|
||||
`omni:agent:{team_id}` 锁;已落库消息保留。不影响 confirm 出片/出图。
|
||||
"""
|
||||
@@ -1640,10 +1663,21 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
{"detail": "当前没有正在整理的方案可终止"},
|
||||
status=409,
|
||||
)
|
||||
restored = restore_gated_step_after_cancel(conversation)
|
||||
if restored:
|
||||
now = timezone.now()
|
||||
CreationConversation.objects.filter(pk=conversation.pk).update(
|
||||
agent_status=CreationConversation.AgentStatus.AWAITING_USER,
|
||||
agent_started_at=None,
|
||||
last_active_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
|
||||
conversation.agent_started_at = None
|
||||
return JsonResponse(
|
||||
{
|
||||
"conversation_id": str(conversation.id),
|
||||
"agent_status": CreationConversation.AgentStatus.IDLE,
|
||||
"agent_status": conversation.agent_status,
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
@@ -1671,17 +1705,26 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
if not isinstance(refs, list):
|
||||
return JsonResponse({"detail": "refs 必须是数组"}, status=400)
|
||||
|
||||
# 「重新来」优先于未完成追问卡:不能当成模特/商品答案继续旧闸门。
|
||||
if kind == "text" and text and is_restart_intent(text):
|
||||
apply_restart_intent(conversation)
|
||||
force_creative_turn = True
|
||||
continuation_instruction = _RESTART_CONTINUATION
|
||||
# 打招呼不应被误当成上一条素材追问的答案;让 agent 立刻简短回应即可。
|
||||
if kind == "text" and text and not is_greeting(text):
|
||||
elif kind == "text" and text and not is_greeting(text):
|
||||
pending = _pending_chat_question(conversation)
|
||||
if pending is not None:
|
||||
payload = dict(pending.payload or {})
|
||||
if payload.get("interaction") == "step_confirm":
|
||||
# 用户对着步骤确认卡直接打字 = 修改意见
|
||||
# 用户对着步骤确认卡直接打字:自然肯定句视为继续,其它才作为修改意见。
|
||||
typed_action = _typed_step_confirm_action(text)
|
||||
short, force_creative_turn, continuation_instruction = _handle_step_confirm_answer(
|
||||
conversation,
|
||||
card=pending,
|
||||
answers={"step_action": "revise", "feedback": text},
|
||||
answers={
|
||||
"step_action": typed_action,
|
||||
**({"feedback": text} if typed_action == "revise" else {}),
|
||||
},
|
||||
user=request.user,
|
||||
)
|
||||
if short is not None:
|
||||
|
||||
@@ -1372,3 +1372,4 @@ class QuickCreateCoordinatorTests(TestCase):
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -628,7 +628,8 @@ export const api = {
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 终止整理方案(planning)。成功 **200** `{conversation_id, agent_status: idle}`;
|
||||
* 终止整理方案(planning)。成功 **200** `{conversation_id, agent_status}`;
|
||||
* 闸门修订/推进中取消时多为 `awaiting_user`(已恢复 step_confirm),否则 `idle`。
|
||||
* 非 planning **409**。服务端放锁 + Redis 取消标,离开页面再进也生效。
|
||||
*/
|
||||
cancelCreationAgent(id: string) {
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
}
|
||||
|
||||
.omni-session-shell {
|
||||
position: relative;
|
||||
width: min(980px, calc(100% - 42px));
|
||||
min-height: calc(100vh - var(--topbar-height));
|
||||
display: flex;
|
||||
@@ -162,6 +163,32 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
|
||||
.omni-reply-hint {
|
||||
margin: 8px 0 0;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed rgba(34, 42, 54, .12);
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.omni-reply-hint-label {
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
color: #3b5bdb;
|
||||
background: rgba(59, 91, 219, .08);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.omni-chat-row.is-user .omni-reply-hint {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.omni-chat-row.is-user .omni-chat-bubble {
|
||||
align-items: flex-end;
|
||||
border-color: var(--black);
|
||||
@@ -296,6 +323,11 @@
|
||||
.omni-video-plan-head span {
|
||||
color: #fff;
|
||||
background: var(--black);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.omni-video-plan-head strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.omni-plan-section {
|
||||
@@ -310,7 +342,8 @@
|
||||
.omni-plan-section > strong {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.omni-plan-points {
|
||||
@@ -324,15 +357,16 @@
|
||||
border-radius: 9px;
|
||||
color: #525965;
|
||||
background: #f6f8fa;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.omni-plan-points b {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: #252b35;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.omni-plan-points i,
|
||||
@@ -364,15 +398,16 @@
|
||||
border-radius: 10px;
|
||||
color: #5b626d;
|
||||
background: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.omni-plan-timeline b {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: var(--klein);
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.omni-plan-summary {
|
||||
@@ -383,14 +418,35 @@
|
||||
padding: 13px 17px;
|
||||
color: #444b56;
|
||||
background: #f6f8fb;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.omni-plan-summary b {
|
||||
color: var(--klein);
|
||||
}
|
||||
|
||||
/* 「最终输入」是方案交给出片模型前的最后一层信息,不能和辅助说明一样小。 */
|
||||
.omni-plan-summary .omni-plan-final-input {
|
||||
margin-left: auto;
|
||||
color: var(--accent-black);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.omni-plan-summary .omni-plan-final-input b {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.omni-plan-summary .omni-plan-final-input .omni-plan-point-body {
|
||||
display: inline;
|
||||
margin-left: 2px;
|
||||
color: var(--black-alpha-64);
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.omni-prompt-file-card {
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0, 1fr) auto;
|
||||
@@ -1301,9 +1357,8 @@
|
||||
box-sizing: border-box;
|
||||
width: min(760px, calc(100% - 44px));
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
margin: 0 0 24px 44px;
|
||||
padding: 14px 16px;
|
||||
@@ -1319,11 +1374,25 @@
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.omni-step-confirm-card.is-revising {
|
||||
border-color: rgba(0, 47, 167, .22);
|
||||
box-shadow: 0 12px 30px rgba(20, 27, 38, .065), 0 0 0 3px rgba(0, 47, 167, .08);
|
||||
}
|
||||
|
||||
.omni-step-confirm-top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.omni-step-confirm-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
flex: 1 1 180px;
|
||||
}
|
||||
|
||||
.omni-step-confirm-copy strong {
|
||||
@@ -1342,6 +1411,7 @@
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.omni-step-confirm-actions button {
|
||||
@@ -1365,6 +1435,46 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 「我想改」卡片内反馈区:与卡片同宽,主路径不再依赖底部 composer */
|
||||
.omni-step-confirm-revise {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.omni-step-confirm-revise-input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 76px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(34, 42, 54, 0.14);
|
||||
border-radius: 10px;
|
||||
background: #f7f8fa;
|
||||
color: #222a36;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
resize: vertical;
|
||||
outline: 0;
|
||||
transition: border-color .15s ease, box-shadow .15s ease, background .15s ease;
|
||||
}
|
||||
|
||||
.omni-step-confirm-revise-input::placeholder {
|
||||
color: #8b919a;
|
||||
}
|
||||
|
||||
.omni-step-confirm-revise-input:focus {
|
||||
border-color: rgba(0, 47, 167, .42);
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 3px rgba(0, 47, 167, .14);
|
||||
}
|
||||
|
||||
.omni-step-confirm-revise-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── 确认闸门(.omni-confirm-card)· 设计稿里是方案卡下面那条操作行,这里独立成卡。
|
||||
积分数字挂在按钮里 —— 用户按下去之前就该知道要花多少。 */
|
||||
.omni-confirm-card {
|
||||
@@ -1837,7 +1947,8 @@
|
||||
.omni-prompt-drawer-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
/* Prompt 是当前查看的内容,必须盖过右侧「资源」工作区。 */
|
||||
z-index: calc(var(--z-overlay) + 10);
|
||||
border: 0;
|
||||
background: rgba(16, 18, 24, .28);
|
||||
cursor: pointer;
|
||||
@@ -1848,7 +1959,7 @@
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
z-index: 81;
|
||||
z-index: calc(var(--z-overlay) + 15);
|
||||
width: min(560px, calc(100vw - 24px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2119,3 +2230,390 @@
|
||||
color: #5c6570;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
/* ── 对话资源:固定入口 + 右侧工作区 ── */
|
||||
|
||||
.omni-assets-entry {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
right: 28px;
|
||||
z-index: 40;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 36px;
|
||||
padding: 0 14px 0 12px;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--surface);
|
||||
color: var(--black-alpha-56);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transform: translateY(-50%);
|
||||
transition: background var(--t-base), border-color var(--t-base), color var(--t-base), opacity var(--t-base);
|
||||
}
|
||||
|
||||
.omni-assets-entry svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.omni-assets-entry:hover {
|
||||
border-color: var(--border-loud);
|
||||
background: var(--black-alpha-4);
|
||||
color: var(--accent-black);
|
||||
}
|
||||
|
||||
.omni-assets-entry.is-open {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.omni-assets-drawer {
|
||||
top: var(--topbar-height);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: min(440px, 36vw);
|
||||
max-width: none;
|
||||
border-left: 1px solid var(--border-faint);
|
||||
background: var(--surface);
|
||||
box-shadow: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.omni-assets-drawer-head {
|
||||
min-height: 64px;
|
||||
gap: 20px;
|
||||
padding: 14px 20px;
|
||||
}
|
||||
|
||||
.omni-assets-drawer-head > div:first-child {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.omni-assets-drawer-head strong,
|
||||
.omni-assets-drawer-head small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.omni-assets-drawer-head strong {
|
||||
color: var(--accent-black);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.omni-assets-drawer-head small {
|
||||
margin-top: 2px;
|
||||
color: var(--black-alpha-48);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.omni-assets-panel-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.omni-assets-panel-tools .view-toggle {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.omni-assets-panel-tools .view-toggle button {
|
||||
width: 28px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.omni-assets-panel-tools .view-toggle svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.omni-assets-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 132px;
|
||||
height: 32px;
|
||||
gap: 6px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
color: var(--black-alpha-48);
|
||||
background: var(--background-lighter);
|
||||
}
|
||||
|
||||
.omni-assets-search:focus-within {
|
||||
border-color: var(--black-alpha-24);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.omni-assets-search svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.omni-assets-search input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: var(--accent-black);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.omni-assets-search input::placeholder {
|
||||
color: var(--black-alpha-40);
|
||||
}
|
||||
|
||||
.omni-assets-drawer-head .x {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.omni-assets-drawer-body {
|
||||
padding: 24px 20px 32px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.omni-assets-empty {
|
||||
min-height: 220px;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 10px;
|
||||
color: var(--black-alpha-48);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.omni-assets-empty svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.omni-assets-empty p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.omni-assets-group + .omni-assets-group {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.omni-assets-group h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 12px;
|
||||
color: var(--accent-black);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.omni-assets-group h3 em {
|
||||
color: var(--black-alpha-48);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.omni-assets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.omni-assets-grid.is-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.omni-assets-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--surface);
|
||||
color: var(--accent-black);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: background var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
|
||||
.omni-assets-card:hover:not(:disabled) {
|
||||
border-color: var(--border-loud);
|
||||
background: var(--background-lighter);
|
||||
}
|
||||
|
||||
.omni-assets-card:disabled {
|
||||
cursor: default;
|
||||
opacity: .72;
|
||||
}
|
||||
|
||||
.omni-assets-card.is-doc,
|
||||
.omni-assets-grid.is-list .omni-assets-card {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.omni-assets-thumb {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
background: var(--background-lighter);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.omni-assets-grid.is-list .omni-assets-thumb {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
flex: 0 0 auto;
|
||||
aspect-ratio: auto;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.omni-assets-thumb.is-doc,
|
||||
.omni-assets-card.is-doc .omni-assets-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: 0 0 auto;
|
||||
aspect-ratio: auto;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 6px;
|
||||
color: var(--heat);
|
||||
background: var(--heat-12);
|
||||
}
|
||||
|
||||
.omni-assets-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.omni-assets-thumb > i {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--black-alpha-48);
|
||||
}
|
||||
|
||||
.omni-assets-thumb > i svg,
|
||||
.omni-assets-thumb.is-doc svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.omni-assets-play {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(0, 0, 0, .24);
|
||||
color: var(--surface);
|
||||
}
|
||||
|
||||
.omni-assets-play svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.omni-assets-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
padding: 0 10px 10px;
|
||||
}
|
||||
|
||||
.omni-assets-card.is-doc .omni-assets-meta,
|
||||
.omni-assets-grid.is-list .omni-assets-meta {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.omni-assets-meta strong,
|
||||
.omni-assets-meta small {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.omni-assets-meta strong {
|
||||
color: var(--accent-black);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.omni-assets-meta small {
|
||||
color: var(--black-alpha-48);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@media (min-width: 1101px) {
|
||||
.omni-session-page.has-assets-panel {
|
||||
box-sizing: border-box;
|
||||
padding-right: min(440px, 36vw);
|
||||
}
|
||||
|
||||
.omni-session-page.has-assets-panel .omni-session-shell {
|
||||
width: min(760px, calc(100% - 42px));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.omni-assets-drawer {
|
||||
width: min(400px, calc(100vw - 16px));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.omni-assets-entry {
|
||||
right: 12px;
|
||||
width: 36px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.omni-assets-entry span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.omni-assets-drawer {
|
||||
width: calc(100vw - 8px);
|
||||
}
|
||||
|
||||
.omni-assets-drawer-head {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.omni-assets-panel-tools {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.omni-assets-search {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.omni-assets-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,13 @@ import {
|
||||
Download,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Grid2X2,
|
||||
Image,
|
||||
List,
|
||||
Play,
|
||||
Plus,
|
||||
Pencil,
|
||||
Search,
|
||||
Sparkles,
|
||||
Square,
|
||||
Upload,
|
||||
@@ -296,6 +299,87 @@ const AGENT_POLL_INTERVAL_MS = 1500;
|
||||
|
||||
// ────────────────────────────────────────────────────────── 卡片
|
||||
|
||||
type SessionAssetKind = "image" | "video" | "document";
|
||||
|
||||
type SessionAsset = {
|
||||
key: string;
|
||||
kind: SessionAssetKind;
|
||||
title: string;
|
||||
thumb?: string;
|
||||
src?: string;
|
||||
promptTitle?: string;
|
||||
promptBody?: string;
|
||||
};
|
||||
|
||||
/** 从本会话已加载 messages(+pinned_refs / 会话上传)聚合聊天资产,不另打 BE。 */
|
||||
function collectSessionAssets(
|
||||
messages: CreationMessage[],
|
||||
pinnedRefs: CreationRef[] = [],
|
||||
sessionUploads: CreationRef[] = [],
|
||||
): SessionAsset[] {
|
||||
const seen = new Set<string>();
|
||||
const out: SessionAsset[] = [];
|
||||
|
||||
const push = (item: SessionAsset) => {
|
||||
if (!item.key || seen.has(item.key)) return;
|
||||
seen.add(item.key);
|
||||
out.push(item);
|
||||
};
|
||||
|
||||
const addRef = (ref: CreationRef | null | undefined) => {
|
||||
if (!ref?.id) return;
|
||||
const cover = (ref.cover || "").trim();
|
||||
push({
|
||||
key: `ref:${ref.type}:${ref.id}`,
|
||||
kind: "image",
|
||||
title: shortRefName(ref.name) || REF_CHIP_LABEL[ref.type] || ref.name || "参考素材",
|
||||
thumb: cover || undefined,
|
||||
src: cover || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
for (const ref of pinnedRefs) addRef(ref);
|
||||
for (const ref of sessionUploads) addRef(ref);
|
||||
|
||||
for (const message of messages) {
|
||||
for (const ref of message.refs || []) addRef(ref);
|
||||
|
||||
if (message.kind === "result") {
|
||||
const assets = (message.payload?.assets as Array<Record<string, string>> | undefined) || [];
|
||||
assets.forEach((asset, index) => {
|
||||
const url = (asset.url || asset.cover || "").trim();
|
||||
const cover = (asset.cover || asset.url || "").trim();
|
||||
if (!url && !cover) return;
|
||||
const video = asset.type === "video";
|
||||
const id = asset.id || url || `${message.id}-${index}`;
|
||||
push({
|
||||
key: `result:${id}`,
|
||||
kind: video ? "video" : "image",
|
||||
title: video
|
||||
? (assets.length > 1 ? `生成视频 ${index + 1}` : "生成视频")
|
||||
: (assets.length > 1 ? `生成结果 ${index + 1}` : "生成图片"),
|
||||
thumb: cover || undefined,
|
||||
src: url || cover,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (message.kind === "prompt_file") {
|
||||
const title = String(message.payload?.title || "视频生成Prompt.md");
|
||||
const body = String(message.payload?.body || "");
|
||||
push({
|
||||
key: `prompt:${message.id || title}`,
|
||||
kind: "document",
|
||||
title,
|
||||
promptTitle: title,
|
||||
promptBody: body || "暂无内容",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function strategyField(payload: Record<string, unknown>, ...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = payload[key];
|
||||
@@ -435,7 +519,7 @@ function PlanCard({ payload }: { payload: Record<string, unknown> }) {
|
||||
,预计覆盖约 90% 时长
|
||||
</span>
|
||||
) : null}
|
||||
<span>
|
||||
<span className="omni-plan-final-input">
|
||||
最终输入:
|
||||
<b>
|
||||
Prompt.md + <em className="omni-plan-point-body">{String(payload.ref_count ?? 0)}</em> 组参考素材
|
||||
@@ -469,17 +553,18 @@ function PromptFileCard({
|
||||
);
|
||||
}
|
||||
|
||||
/** 视频 5 步闸门:策略/方案/Prompt 下方的「按这个继续 / 我想改」。 */
|
||||
/** 视频 5 步闸门:策略/方案/Prompt 下方的「按这个继续 / 我想改」。
|
||||
* 「我想改」在卡片内展开反馈输入,不再依赖底部 composer。 */
|
||||
function StepConfirmCard({
|
||||
message,
|
||||
disabled,
|
||||
onConfirm,
|
||||
onRevise,
|
||||
onReviseSubmit,
|
||||
}: {
|
||||
message: CreationMessage;
|
||||
disabled: boolean;
|
||||
onConfirm: () => void;
|
||||
onRevise: () => void;
|
||||
onReviseSubmit: (feedback: string) => void;
|
||||
}) {
|
||||
const submitted = Boolean(message.payload.submitted);
|
||||
const step = String(message.payload.step || "");
|
||||
@@ -487,25 +572,47 @@ function StepConfirmCard({
|
||||
const action = String(answers.step_action || "");
|
||||
const stepLabel =
|
||||
step === "strategy" ? "创作策略" : step === "plan" ? "视频方案" : step === "prompt" ? "出片 Prompt" : "这一步";
|
||||
const placeholder =
|
||||
step === "strategy"
|
||||
? "说说你想怎么改策略…"
|
||||
: step === "plan"
|
||||
? "说说你想怎么改方案…"
|
||||
: step === "prompt"
|
||||
? "说说你想怎么改 Prompt…"
|
||||
: "说说你想怎么改…";
|
||||
const [revising, setRevising] = useState(false);
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const reviseRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!revising) return;
|
||||
const el = reviseRef.current;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
const len = el.value.length;
|
||||
el.setSelectionRange(len, len);
|
||||
}, [revising]);
|
||||
|
||||
const canSubmit = feedback.trim().length > 0;
|
||||
|
||||
return (
|
||||
<section className={`omni-step-confirm-card${submitted ? " is-submitted" : ""}`}>
|
||||
<section
|
||||
className={`omni-step-confirm-card${submitted ? " is-submitted" : ""}${revising && !submitted ? " is-revising" : ""}`}
|
||||
>
|
||||
<div className="omni-step-confirm-top">
|
||||
<div className="omni-step-confirm-copy">
|
||||
<strong>{submitted ? (action === "revise" ? `已收到对${stepLabel}的修改意见` : `已确认${stepLabel}`) : `请确认${stepLabel}`}</strong>
|
||||
<span>{submitted ? (action === "revise" ? "正在按你的反馈重写" : "继续下一步") : (message.text || "确认后继续;要改可以直接说。")}</span>
|
||||
<strong>{submitted ? (action === "revise" ? `已收到对${stepLabel}的修改意见` : `已确认${stepLabel}`) : revising ? `修改${stepLabel}` : `请确认${stepLabel}`}</strong>
|
||||
<span>{submitted ? (action === "revise" ? "正在按你的反馈重写" : "继续下一步") : revising ? "写下你的想法,我们按这个重做这一步。" : (message.text || "确认后继续;要改可以直接说。")}</span>
|
||||
</div>
|
||||
{submitted ? null : (
|
||||
{submitted || revising ? 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();
|
||||
setRevising(true);
|
||||
}}
|
||||
>
|
||||
我想改
|
||||
@@ -522,6 +629,56 @@ function StepConfirmCard({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!submitted && revising ? (
|
||||
<div className="omni-step-confirm-revise">
|
||||
<textarea
|
||||
ref={reviseRef}
|
||||
className="omni-step-confirm-revise-input"
|
||||
rows={3}
|
||||
value={feedback}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => setFeedback(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setRevising(false);
|
||||
setFeedback("");
|
||||
}
|
||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey) && canSubmit && !disabled) {
|
||||
event.preventDefault();
|
||||
onReviseSubmit(feedback.trim());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="omni-step-confirm-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="is-secondary"
|
||||
disabled={disabled}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setRevising(false);
|
||||
setFeedback("");
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || !canSubmit}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!canSubmit) return;
|
||||
onReviseSubmit(feedback.trim());
|
||||
}}
|
||||
>
|
||||
按这个重做
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -531,15 +688,12 @@ 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);
|
||||
@@ -587,10 +741,7 @@ function ElicitCard({
|
||||
message={message}
|
||||
disabled={disabled}
|
||||
onConfirm={() => onSubmit({ step_action: "confirm" }, [])}
|
||||
onRevise={() => {
|
||||
if (onStepRevise) onStepRevise();
|
||||
else onSubmit({ step_action: "revise" }, []);
|
||||
}}
|
||||
onReviseSubmit={(feedback) => onSubmit({ step_action: "revise", feedback }, [])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1089,11 +1240,16 @@ function mergeMessagePreserveLocal(prevHit: CreationMessage | undefined, msg: Cr
|
||||
if (!prevHit) {
|
||||
return msg.clientKey === clientKey ? msg : { ...msg, clientKey };
|
||||
}
|
||||
// 开放中的追问/闸门:服务端尚未 submitted 时,保住本地已选选项与乐观提交
|
||||
// 开放中的追问:服务端尚未 submitted 时,保住本地已选选项与乐观提交。
|
||||
// step_confirm 除外 —— 取消修订时服务端会主动撤回 submitted,必须以服务端为准,
|
||||
// 否则会卡在「已收到…正在按你的反馈重写」。
|
||||
if (prevHit.kind === "elicit" && msg.kind === "elicit") {
|
||||
const prevPayload = prevHit.payload || {};
|
||||
const nextPayload = msg.payload || {};
|
||||
if (prevPayload.submitted && !nextPayload.submitted) {
|
||||
const isStepConfirm =
|
||||
prevPayload.interaction === "step_confirm"
|
||||
|| nextPayload.interaction === "step_confirm";
|
||||
if (!isStepConfirm && prevPayload.submitted && !nextPayload.submitted) {
|
||||
return {
|
||||
...msg,
|
||||
clientKey,
|
||||
@@ -1304,6 +1460,10 @@ export function OmniSessionPage({
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [stopping, setStopping] = useState(false);
|
||||
const [promptView, setPromptView] = useState<{ title: string; body: string } | null>(null);
|
||||
const [assetsOpen, setAssetsOpen] = useState(false);
|
||||
const [assetView, setAssetView] = useState<"grid" | "list">("grid");
|
||||
const [assetQuery, setAssetQuery] = useState("");
|
||||
const [assetPreview, setAssetPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
|
||||
const [pendingUserId, setPendingUserId] = useState<string | null>(() => bootLocalIdRef.current);
|
||||
// App 传进来的 onNotify 是内联箭头,**每次 App 重渲染都是新身份**。
|
||||
// 直接放进 useEffect / useCallback 依赖会让整条会话被反复重拉、消息数组被反复替换
|
||||
@@ -1338,21 +1498,6 @@ 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);
|
||||
@@ -1490,6 +1635,32 @@ export function OmniSessionPage({
|
||||
[messages]
|
||||
);
|
||||
const visibleMessages = useMemo(() => withoutLegacyGateArtifacts(messages), [messages]);
|
||||
const sessionAssets = useMemo(
|
||||
() => collectSessionAssets(messages, conversation?.pinned_refs || [], sessionUploads),
|
||||
[messages, conversation?.pinned_refs, sessionUploads],
|
||||
);
|
||||
const sessionAssetGroups = useMemo(() => {
|
||||
const groups: Array<{ key: SessionAssetKind; label: string; items: SessionAsset[] }> = [
|
||||
{ key: "image", label: "图片", items: [] },
|
||||
{ key: "video", label: "视频", items: [] },
|
||||
{ key: "document", label: "文档", items: [] },
|
||||
];
|
||||
for (const asset of sessionAssets) {
|
||||
const group = groups.find((item) => item.key === asset.kind);
|
||||
if (group) group.items.push(asset);
|
||||
}
|
||||
return groups.filter((group) => group.items.length > 0);
|
||||
}, [sessionAssets]);
|
||||
const visibleSessionAssetGroups = useMemo(() => {
|
||||
const query = assetQuery.trim().toLocaleLowerCase();
|
||||
if (!query) return sessionAssetGroups;
|
||||
return sessionAssetGroups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => item.title.toLocaleLowerCase().includes(query)),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0);
|
||||
}, [assetQuery, sessionAssetGroups]);
|
||||
messagesRef.current = messages;
|
||||
|
||||
// 出片在 worker 里跑。刷新后 GENERATING 还在库里,进来立刻拉一次再轮询,
|
||||
@@ -1783,7 +1954,7 @@ export function OmniSessionPage({
|
||||
setStopping(true);
|
||||
userCancelledRef.current = true;
|
||||
holdPlanningUntilRef.current = 0;
|
||||
// 先本地收态,再打 API —— 离开/重进靠服务端 cancel 标 + idle
|
||||
// 先本地收态,再打 API —— 闸门修订中取消后服务端会恢复 step_confirm
|
||||
streamingRef.current = false;
|
||||
setStreaming(false);
|
||||
setLiveText("");
|
||||
@@ -1792,28 +1963,40 @@ export function OmniSessionPage({
|
||||
setConversation((prev) =>
|
||||
prev ? { ...prev, agent_status: "idle" } : prev
|
||||
);
|
||||
const refreshAfterCancel = async () => {
|
||||
try {
|
||||
await api.cancelCreationAgent(conversationId);
|
||||
const detail = await api.getCreation(conversationId);
|
||||
// 直接采用服务端快照,避免 merge 把已撤回的 step_confirm 又乐观粘回 submitted
|
||||
setConversation(detail);
|
||||
setMessages(detail.messages || []);
|
||||
} catch {
|
||||
/* 恢复失败则等 poll / 用户刷新 */
|
||||
}
|
||||
};
|
||||
try {
|
||||
const result = await api.cancelCreationAgent(conversationId);
|
||||
notify("success", "已终止");
|
||||
if (result.agent_status) {
|
||||
setConversation((prev) =>
|
||||
prev ? { ...prev, agent_status: result.agent_status } : prev
|
||||
);
|
||||
}
|
||||
await refreshAfterCancel();
|
||||
} catch (error) {
|
||||
const status = (error as { status?: number }).status;
|
||||
if (status === 409) {
|
||||
// 已结束或竞态完成 —— 保持 idle,不刷错误
|
||||
// 已结束或竞态完成 —— 仍拉一次详情,可能已落新闸门确认
|
||||
notify("info", "已终止");
|
||||
await refreshAfterCancel();
|
||||
} else {
|
||||
userCancelledRef.current = false;
|
||||
notify("error", (error as Error).message || "终止失败");
|
||||
try {
|
||||
const detail = await api.getCreation(conversationId);
|
||||
mergeCreationDetail(detail);
|
||||
} catch {
|
||||
/* 恢复失败则等用户刷新 */
|
||||
}
|
||||
await refreshAfterCancel();
|
||||
}
|
||||
} finally {
|
||||
setStopping(false);
|
||||
}
|
||||
}, [stopping, isPlanning, conversationId, notify, mergeCreationDetail]);
|
||||
}, [stopping, isPlanning, conversationId, notify]);
|
||||
|
||||
const handleConfirm = async (message: CreationMessage, nextParams: Record<string, string>) => {
|
||||
if (confirming) return;
|
||||
@@ -1936,7 +2119,6 @@ export function OmniSessionPage({
|
||||
);
|
||||
void send({ kind: "elicit_answer", reply_to: message.id, answers, refs });
|
||||
}}
|
||||
onStepRevise={beginStepRevise}
|
||||
/>
|
||||
);
|
||||
case "strategy":
|
||||
@@ -2003,6 +2185,12 @@ export function OmniSessionPage({
|
||||
{(body || message.role !== "user") ? (
|
||||
<div className="omni-chat-bubble" aria-busy={pending || undefined}>
|
||||
{body ? <p className="omni-chat-text">{body}</p> : null}
|
||||
{message.role === "assistant" && typeof message.payload?.reply_hint === "string" && message.payload.reply_hint.trim() ? (
|
||||
<p className="omni-reply-hint">
|
||||
<span className="omni-reply-hint-label">下一步可以这样回</span>
|
||||
{String(message.payload.reply_hint)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{message.role === "user" && !pending ? (
|
||||
@@ -2067,9 +2255,20 @@ export function OmniSessionPage({
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="page-view omni-session-page">
|
||||
<section className={`page-view omni-session-page${assetsOpen ? " has-assets-panel" : ""}`}>
|
||||
{topbarSlot ? createPortal(sessionHeader, topbarSlot) : sessionHeader}
|
||||
<div className="omni-session-shell">
|
||||
<button
|
||||
type="button"
|
||||
className={`omni-assets-entry${assetsOpen ? " is-open" : ""}`}
|
||||
aria-expanded={assetsOpen}
|
||||
aria-controls="omni-session-assets-drawer"
|
||||
title="查看对话资源"
|
||||
onClick={() => setAssetsOpen((open) => !open)}
|
||||
>
|
||||
<List />
|
||||
<span>查看对话资源</span>
|
||||
</button>
|
||||
|
||||
<main className="omni-session-feed" ref={feedRef} aria-live="polite">
|
||||
{visibleMessages.map(renderMessage)}
|
||||
@@ -2381,6 +2580,144 @@ export function OmniSessionPage({
|
||||
</aside>
|
||||
</>
|
||||
) : null}
|
||||
{assetsOpen ? (
|
||||
<aside
|
||||
id="omni-session-assets-drawer"
|
||||
className="drawer omni-assets-drawer show"
|
||||
role="dialog"
|
||||
aria-label="对话资源"
|
||||
>
|
||||
<header className="drawer-h omni-assets-drawer-head">
|
||||
<div>
|
||||
<strong>资源</strong>
|
||||
<small>{sessionAssets.length ? `本会话 · ${sessionAssets.length} 项` : "仅展示本会话素材"}</small>
|
||||
</div>
|
||||
<div className="omni-assets-panel-tools">
|
||||
<div className="view-toggle" aria-label="资源显示方式">
|
||||
<button
|
||||
type="button"
|
||||
className={assetView === "grid" ? "active" : ""}
|
||||
aria-label="网格显示"
|
||||
title="网格显示"
|
||||
onClick={() => setAssetView("grid")}
|
||||
>
|
||||
<Grid2X2 />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={assetView === "list" ? "active" : ""}
|
||||
aria-label="列表显示"
|
||||
title="列表显示"
|
||||
onClick={() => setAssetView("list")}
|
||||
>
|
||||
<List />
|
||||
</button>
|
||||
</div>
|
||||
<label className="omni-assets-search">
|
||||
<Search />
|
||||
<input
|
||||
value={assetQuery}
|
||||
onChange={(event) => setAssetQuery(event.target.value)}
|
||||
placeholder="搜索资源…"
|
||||
aria-label="搜索对话资源"
|
||||
/>
|
||||
</label>
|
||||
<button type="button" className="x" onClick={() => setAssetsOpen(false)} aria-label="关闭资源">
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="drawer-b omni-assets-drawer-body">
|
||||
{sessionAssets.length === 0 ? (
|
||||
<div className="omni-assets-empty" role="status">
|
||||
<FolderOpen />
|
||||
<p>本会话还没有素材</p>
|
||||
</div>
|
||||
) : visibleSessionAssetGroups.length === 0 ? (
|
||||
<div className="omni-assets-empty" role="status">
|
||||
<Search />
|
||||
<p>没有找到相关资源</p>
|
||||
</div>
|
||||
) : (
|
||||
visibleSessionAssetGroups.map((group) => (
|
||||
<section className="omni-assets-group" key={group.key}>
|
||||
<h3>
|
||||
{group.label}
|
||||
<em>{group.items.length}</em>
|
||||
</h3>
|
||||
<div className={`omni-assets-grid${group.key === "document" || assetView === "list" ? " is-list" : ""}`}>
|
||||
{group.items.map((asset) => {
|
||||
if (asset.kind === "document") {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="omni-assets-card is-doc"
|
||||
key={asset.key}
|
||||
onClick={() => {
|
||||
setPromptView({
|
||||
title: asset.promptTitle || asset.title,
|
||||
body: asset.promptBody || "暂无内容",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="omni-assets-thumb is-doc">
|
||||
<FileText />
|
||||
</span>
|
||||
<span className="omni-assets-meta">
|
||||
<strong>{asset.title}</strong>
|
||||
<small>文档 · 点击查看</small>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`omni-assets-card is-${asset.kind}`}
|
||||
key={asset.key}
|
||||
disabled={!asset.src}
|
||||
onClick={() => {
|
||||
if (!asset.src) return;
|
||||
setAssetPreview({
|
||||
src: asset.src,
|
||||
kind: asset.kind === "video" ? "video" : "image",
|
||||
name: asset.title,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="omni-assets-thumb">
|
||||
{asset.thumb ? (
|
||||
<img src={asset.thumb} alt="" />
|
||||
) : (
|
||||
<i>{asset.kind === "video" ? <Play /> : <Image />}</i>
|
||||
)}
|
||||
{asset.kind === "video" ? (
|
||||
<span className="omni-assets-play" aria-hidden="true">
|
||||
<Play />
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="omni-assets-meta">
|
||||
<strong>{asset.title}</strong>
|
||||
<small>{asset.kind === "video" ? "视频" : "图片"}</small>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
<MediaLightbox
|
||||
open={Boolean(assetPreview?.src)}
|
||||
src={assetPreview?.src || ""}
|
||||
kind={assetPreview?.kind}
|
||||
name={assetPreview?.name}
|
||||
close={() => setAssetPreview(null)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ created_at
|
||||
| GET/PATCH/DELETE | `/api/ai/creations/{id}/` | 详情(含全部消息) / 改名 / 软删 |
|
||||
| GET | `/api/ai/creations/{id}/messages/?after_seq=` | 增量拉消息(轮询时只补新的) |
|
||||
| POST | `/api/ai/creations/{id}/send/` | 发消息 → **202** `{conversation_id, agent_status}`(Celery 整理方案)+ poll;闸门短路径 **200**;`kind=confirm` → JSON 201;团队冲突 **409** |
|
||||
| POST | `/api/ai/creations/{id}/cancel/` | 终止整理方案 → **200** `{conversation_id, agent_status: idle}`(仅 `planning`);非 planning **409**。Redis 标 `omni:agent:cancel:{id}` + 立刻释放 `omni:agent:{team_id}`;Celery 在工具轮间隙停跑,已落库消息保留。不影响 confirm 出片 |
|
||||
| POST | `/api/ai/creations/{id}/cancel/` | 终止整理方案 → **200** `{conversation_id, agent_status}`(仅 `planning`);若取消时正处策略/方案/Prompt 闸门修订或推进,恢复该步 `step_confirm`(清 `submitted`)并返回 `awaiting_user`,否则 `idle`。非 planning **409**。Redis 标 `omni:agent:cancel:{id}` + 立刻释放 `omni:agent:{team_id}`;Celery 在工具轮间隙停跑,已落库消息保留。不影响 confirm 出片 |
|
||||
| GET | `/api/ai/creations/{id}/tasks/{task_id}/poll/` | 轮询生成任务(视频用) |
|
||||
|
||||
`POST send/` 请求体:
|
||||
|
||||
Reference in New Issue
Block a user