优化全能创作部分优化脚本

This commit is contained in:
Azmat@qq.com
2026-09-10 18:59:08 +08:00
parent 0d56fdb299
commit 43f80a4d90
12 changed files with 1223 additions and 95 deletions
+374 -54
View File
@@ -29,7 +29,7 @@ from .creation import append_message, pin_refs
from .creation_presets import preset_guidance
from .mentions import TYPE_LABELS, infer_field_types, resolve_refs, search_mentions
from .models import CreationConversation, CreationMessage, ModelConfig
from .services import build_provider, get_default_model, resolve_text_model
from .services import build_provider, get_default_model, get_seed_text_model, resolve_text_model
logger = logging.getLogger(__name__)
@@ -80,7 +80,7 @@ video_prompt 必须能被出片模型直接执行,按时间轴写秒级分镜,
3. 运镜(手持跟拍/推近/拉远/横摇/环绕/固定 —— 每段至少一个运镜词)
4. 动作(谁、哪只手、对什么、做什么;要连贯可拍,禁止「展示质感」等抽象词)
5. 信息变化(这几秒画面上多了/变了什么)
- 口播原文单独写清(可用「口播:…」),字数贴近会话时长:大约 5.0–5.7 字/秒,
- 人声原文单独写清,必须用「人声(仅音频,由人物口型与配音表达,不出现在画面上):…」,禁止写成「口播:…」(裸写口播会被出片模型烧成字幕);字数贴近会话时长:大约 5.0–5.7 字/秒,
15 秒约 75–85 字;太短撑不满,太长会赶。
- 钩子段画面不要「对着镜头说话」静态开场;前 15 个口播字禁止「大家好/今天分享/给你们推荐」。
- 全片只围绕一个具体情境推进一个主卖点;卖点要有看得见的证据(质地/前后变化/用法结果)。
@@ -88,11 +88,53 @@ video_prompt 必须能被出片模型直接执行,按时间轴写秒级分镜,
- **不要写字幕/花字/标题贴片/弹幕/角标/水印/购物浮层**,也不要写「无字幕」
(否定说法也容易把字画上屏)。口播只存在于声音;包装上原有印刷字除外。
- 已 @ 的角色/商品/场景参考图会自动附上,不要在 prompt 里重描长相;以图锁定性别年龄服装外形。
- 用户说「商品说话 / 商品自述 / 商品拟人」时,默认采用**无脸拟人**:商品声音是画外角色声,
商品本体不做口型、不新增卡通五官;性格靠整体倾斜、转向、弹跳、进退、镜头和音效表达。
只有用户明确要求可见的卡通五官时才例外。
- 同一场戏保持地点、光线、服装连续;要换环境就明确写下一时间段切换。
"""
_PRODUCT_VOICE_HINT_RE = re.compile(
r"(商品|产品|包装|瓶|机身).{0,8}(说话|开口|自述|拟人)"
r"|拟人.{0,8}(商品|产品|包装|瓶|机身)"
)
_VISIBLE_PRODUCT_FACE_RE = re.compile(
r"(商品|产品|包装|瓶|机身).{0,12}(卡通)?(五官|眼睛|眼珠|嘴巴|嘴|口型)"
r"|(卡通)?(五官|眼睛|眼珠|嘴巴|嘴|口型).{0,12}(商品|产品|包装|瓶|机身)"
r"|(给|让).{0,12}(长出|加上|出现).{0,6}(卡通)?(五官|眼睛|眼珠|嘴巴|嘴|口型)"
)
PRODUCT_VOICE_VISUAL_GUARD = (
"【商品角色表现·最高优先级】商品始终是参考图里的真实物件。包装正面、瓶身、机身和全部可见表面"
"保持原有设计,只保留参考图本来就有的标签、图案与结构。拟人感完全通过整个商品轻微倾斜、"
"转向、弹跳、进退,配合镜头、光影、环境反应和音效表达。商品台词采用画外角色声,声源不在"
"画面内,商品保持完整物件形态;场景里的其他物件也保持真实原貌。整体采用精致实拍广告质感"
"与克制幽默。前文若有改造商品外观的动作描述,统一改成商品整体运动的对应表达。"
)
def apply_product_voice_visual_guard(conversation: CreationConversation, prompt: str) -> str:
"""商品拟人默认不长脸;用户明确要求可见卡通五官时尊重其创作选择。"""
base = str(prompt or "").strip()
recent_user_text = " ".join(
conversation.messages.filter(
role=CreationMessage.Role.USER,
kind=CreationMessage.Kind.TEXT,
).order_by("-seq").values_list("text", flat=True)[:12]
)
if _VISIBLE_PRODUCT_FACE_RE.search(recent_user_text):
return base
needs_guard = (
conversation.preset == "商品拟人广告"
or bool(_PRODUCT_VOICE_HINT_RE.search(recent_user_text))
or bool(_PRODUCT_VOICE_HINT_RE.search(base))
)
if not needs_guard or PRODUCT_VOICE_VISUAL_GUARD in base:
return base
return f"{base}\n{PRODUCT_VOICE_VISUAL_GUARD}".strip()
class AgentError(Exception):
"""Agent 循环里的业务错误,已经是可以直接给用户看的中文。"""
@@ -128,8 +170,105 @@ _ASSET_PICK_PATTERNS = (
)
_CHITCHAT_RE = re.compile(
r"^\s*("
r"hi|hello|hey|yo|hola|"
r"你好呀?|您好|嗨|哈喽|嘿|"
r"在吗|在不在|有人吗|"
r"早+|早安|早上好|午安|晚安|"
r"嗯+|哦+|噢+|额+|呃+|"
r"好的?|行|可以|ok(?:ay)?|thanks?|thank\s*you|谢谢了?|感谢|"
r"收到|知道了|明白了|了解"
r")[\s!.。~~??…]*$",
re.IGNORECASE,
)
def is_pure_chitchat(user_text: str) -> bool:
"""纯打招呼 / 应答,没有创作意图。这类消息绝不能触发 write_strategy / write_plan。"""
text = (user_text or "").strip()
if not text or len(text) > 24:
return False
return bool(_CHITCHAT_RE.match(text))
# 明确要做内容才算出方案/出图。闲聊、吐槽、问功能都不算。
_CREATIVE_INTENT_RE = re.compile(
r"("
r"帮我做|帮我拍|帮我出|帮我写|帮我改|帮我生成|"
r"创作[一两]?[条个张]?|创作(?:一条|个|短)?|"
r"做[一两]?[条个张](?:视频|片|图|广告)?|拍[一两]?[条个](?:视频|片|广告)?|"
r"出[一两]?[条个张](?:视频|片|图|广告)?|出片|出图|出方案|写方案|改方案|重写方案|重新写|"
r"生成(?:一下|一张|几张|一条)?(?:视频|图|片|广告|脚本)?|做条|做个片|短视频|带货视频|短广告|广告片|带货片|"
r"换卖点|改卖点|换剧情|改剧情|重做|重新出|按这个出|确认出片|"
r"分镜|脚本|口播稿|storyboard|拟人|"
r"(改|换|修改|更换).{0,8}(时长|秒数|比例|尺寸|画幅|分辨率|清晰度|模型)|"
r"改成\s*\d+\s*秒|改成\s*\d+\s*[:]\s*\d+|改成.{0,8}(竖屏|横屏|比例|分辨率)"
r")",
re.IGNORECASE,
)
# 创作 brief 常见搭配:动词 + 成片名词(「创作一条短广告」)
_CREATIVE_VERB_RE = re.compile(r"创作|制作|拍摄|生成|做|拍|出|写|弄|来一条|来个")
_CREATIVE_NOUN_RE = re.compile(r"视频|短片|短视频|广告|带货|出片|分镜|脚本|口播|主图|海报|成片")
def has_creative_intent(user_text: str, refs: list | None = None) -> bool:
"""用户本轮是否明确要做片/出图/改方案。
没意图时不把 write_strategy / write_plan / generate_image 塞进 tools,
避免模型把闲聊「整理」成方案卡。仅 @ 素材不算意图。
"""
text = (user_text or "").strip()
if not text:
return False
if is_pure_chitchat(text):
return False
if _CREATIVE_INTENT_RE.search(text):
return True
# 「把商品…创作一条…短广告」这类 brief:同时有创作动词和成片名词
if len(text) >= 8 and _CREATIVE_VERB_RE.search(text) and _CREATIVE_NOUN_RE.search(text):
return True
return False
_CONTINUE_INTENT_RE = re.compile(
r"^\s*(继续|继续做|接着|接着做|往下做|开始吧|开做吧|就这样|就按这个|按这个来|照这个做|直接做|直接来)[吧啊呀呢。.!!]*\s*$"
)
def is_continue_intent(user_text: str) -> bool:
"""已有创作上下文时,这些短句是在授权继续,不是闲聊。"""
return bool(_CONTINUE_INTENT_RE.match((user_text or "").strip()))
def session_has_creative_context(conversation: CreationConversation) -> bool:
"""会话里是否已有可继续的创作进度(钉了素材 / 出过策略或方案)。
这个信号只用于判断一句短回复是否承接创作,不拿来主动追问流程确认。
"""
if conversation.pinned_refs:
return True
if conversation.messages.filter(
kind__in=(
CreationMessage.Kind.STRATEGY,
CreationMessage.Kind.PLAN,
CreationMessage.Kind.PROMPT_FILE,
CreationMessage.Kind.CONFIRM,
)
).exists():
return True
# 追问发生在策略卡之前时,会话里可能还没有任何结构化产物。原始 brief 本身
# 就是创作上下文;不认它的话,刷新后一句「继续」会被当成空闲聊天。
recent_user_texts = conversation.messages.filter(
role=CreationMessage.Role.USER,
kind=CreationMessage.Kind.TEXT,
).order_by("-seq").values_list("text", flat=True)[:8]
return any(has_creative_intent(item) for item in recent_user_texts)
def wanted_asset_pick(user_text: str, refs: list | None) -> str | None:
"""用户说「改商品」却没 @ 时,应弹出素材卡,而不是让他自己填名字"""
"""用户说「改商品」却没点名时,直接发对应素材选择卡"""
if refs:
return None
text = user_text or ""
@@ -139,6 +278,34 @@ def wanted_asset_pick(user_text: str, refs: list | None) -> str | None:
return None
def requested_asset_card_from_context(
conversation: CreationConversation,
user_text: str,
) -> str | None:
"""「发一下卡片我选」没有说类型时,沿用最近一次素材追问的类型。"""
text = str(user_text or "")
wants_card = re.search(
r"(发|打开|展示|看看|看下|给我).{0,10}(卡片|列表|商品库|素材库)"
r"|(卡片|列表).{0,10}(选|选择|看看|看下)",
text,
)
if not wants_card:
return None
recent = conversation.messages.filter(
kind=CreationMessage.Kind.ELICIT
).order_by("-seq")[:8]
for message in recent:
payload = message.payload or {}
fields = payload.get("pending_fields") or payload.get("fields") or []
for field in fields:
if not isinstance(field, dict):
continue
inferred = infer_field_types(field)
if len(inferred) == 1 and inferred[0] in TYPE_LABELS:
return inferred[0]
return None
VIDEO_MODELS = ["Seedance 2.5", "Seedance 2.0", "Seedance 2.0 Fast", "Seedance 2.0 Mini"]
IMAGE_MODELS = ["Seedream5.0", "YQ image2"]
@@ -150,11 +317,11 @@ SESSION_PARAM_KEYS = ("duration", "ratio", "resolution", "video_model", "count")
_PARAM_TO_STORED = {"video_model": "model"}
_PARAM_PICK_LABEL = {
"duration": "改成多",
"ratio": "成什么比例?",
"resolution": "成什么分辨率?",
"video_model": "哪个模型?",
"count": "出几张?",
"duration": "改成多少秒",
"ratio": "想换成什么画幅?比如 9:16 竖屏或 16:9 横屏。",
"resolution": "想换成什么清晰度?直接说 480p、720p 或 1080p 就行。",
"video_model": "换哪个模型?直接告诉我模型名就行。",
"count": "这次想出几张?",
}
@@ -172,7 +339,8 @@ def _param_options(key: str, is_video: bool) -> list[str]:
def session_param_fields(keys: list[str], is_video: bool) -> list[dict]:
fields = []
for key in keys[:3]:
# 对话式追问一次只问一件事,避免又退化成参数问卷。
for key in keys[:1]:
if key not in _PARAM_PICK_LABEL:
continue
options = [{"value": item, "label": item} for item in _param_options(key, is_video)]
@@ -283,27 +451,29 @@ def apply_confirm_params(conversation, incoming: dict | None) -> tuple[dict, boo
# ---------------------------------------------------------------- 工具 schema
def tool_schemas(context: AgentContext) -> list[dict]:
def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict]:
"""给模型看的工具清单。图片会话不暴露 generate_video,反之亦然 ——
会话 mode 是定死的(契约 §0),把不该用的工具摆出来只会诱导模型走错路。"""
会话 mode 是定死的(契约 §0),把不该用的工具摆出来只会诱导模型走错路。
allow_plan=False 时隐藏 write_strategy / write_plan / generate_image,闲聊用不出来。"""
tools = [
{
"type": "function",
"function": {
"name": "ask_user",
"description": (
"缺少必要信息时反问用户。会在对话里渲染成可点选/可填写的卡片"
"缺少必要信息时向用户追问"
"只在信息**确实缺失且无法合理推断**时用;能自己定的就自己定,别把用户当填表机器。"
"用户要选/改/换商品、角色、模特、场景时**必须**调这个工具,"
"type 用 asset 并填 asset_types;禁止只在气泡里问「换成哪个」"
"一次最多问 3 项"
"type 用 asset 并填 asset_types,系统会直接显示可视化素材卡让用户点选"
"不要先问用户是否需要卡片或列表。其他类型才显示为普通聊天问题"
"一次只问 1 项,禁止问「要不要继续」「要不要生成」「是否开始创作」这类流程问题。"
),
"parameters": {
"type": "object",
"properties": {
"fields": {
"type": "array",
"maxItems": 3,
"maxItems": 1,
"items": {
"type": "object",
"properties": {
@@ -352,6 +522,10 @@ def tool_schemas(context: AgentContext) -> list[dict]:
},
},
]
if not allow_plan:
# 闲聊轮次不给 ask_user,避免模型追问「要不要出片」;
# 换商品/改参数仍由 wanted_asset_pick / wanted_param_keys 兜底追问。
return [t for t in tools if t.get("function", {}).get("name") == "search_library"]
if context.is_video:
tools.append({
"type": "function",
@@ -359,7 +533,7 @@ def tool_schemas(context: AgentContext) -> list[dict]:
"name": "write_strategy",
"description": (
"写「创作策略理解」卡:说清这条片给谁看、他为什么会信、你想让他信什么、整体创作方向。"
"在动手写方案之前调一次,让用户先确认你理解对了"
"仅当用户明确要做片/出方案时,在 write_plan 之前调一次;打招呼或闲聊不要调"
),
"parameters": {
"type": "object",
@@ -378,8 +552,8 @@ def tool_schemas(context: AgentContext) -> list[dict]:
"function": {
"name": "write_plan",
"description": (
"写「视频最终方案」卡并请用户确认。**这是出片前的最后一步**,调完会等用户点确认,"
"确认后平台直接按 video_prompt 出片,你不会再有插话机会"
"写「视频最终方案」卡并请用户确认。**仅当用户明确要做片/出方案/改方案时调用**;"
"打招呼或闲聊不要调。调完会等用户点确认,确认后平台直接按 video_prompt 出片。"
"video_prompt 按系统里的「出片脚本写法」写成口播秒级分镜(专业创作同口径),不要只写大纲。"
"先调 write_strategy 再调它。"
),
@@ -412,7 +586,7 @@ def tool_schemas(context: AgentContext) -> list[dict]:
"description": (
"交给出片模型的完整口播带货指令(对齐专业创作口径)。"
"必须含:总时长与画幅、整体光线色调、按 0-Ns 分段的秒级分镜"
"(每段写清景别/机位/运镜/具体动作/信息变化)、口播原文、一个主卖点与可见证据、口语 CTA。"
"(每段写清景别/机位/运镜/具体动作/信息变化)、人声(仅音频抬头)原文、一个主卖点与可见证据、口语 CTA。禁止「口播:」字样。"
"禁止字幕/花字/贴片及「无字幕」字样;禁止详情页腔与「大家好」开场。"
"已 @ 素材会自动作参考图,勿重描长相。"
),
@@ -451,7 +625,7 @@ def _coerce_fields(raw) -> list[dict]:
"""把模型给的 fields 规整成契约 §2 的 Field。脏数据丢弃而不是抛 ——
模型偶尔漏个 type 不该让整条对话崩掉。"""
fields: list[dict] = []
for item in (raw or [])[:3]:
for item in (raw or [])[:1]:
if not isinstance(item, dict):
continue
key = str(item.get("key") or "").strip()
@@ -635,6 +809,7 @@ def _video_submit_params(context: AgentContext, prompt: str) -> tuple[dict, list
它已经排好 角色 → 场景 → 商品 的顺序,那正是出片模型 @图N 的语义依据。"""
params = context.conversation.params or {}
resolved = resolve_refs(context.team, context.conversation.pinned_refs or [])
prompt = apply_product_voice_visual_guard(context.conversation, prompt)
submit = {
"prompt": prompt,
"feature": "omni_create",
@@ -759,8 +934,10 @@ def submit_confirmed_image(*, conversation: CreationConversation, user, confirm_
def get_creation_chat_model(requested: ModelConfig | None = None) -> ModelConfig | None:
"""全能创作对话模型:走平台文本解析,DeepSeek 一律换成 Seed 2.1 Pro"""
return resolve_text_model(requested)
"""全能创作编排固定优先 Seed 2.1 Pro;显式传入的可用模型仍尊重用户选择"""
if requested is not None:
return resolve_text_model(requested)
return get_seed_text_model() or resolve_text_model(None)
def _creation_model_sees_images(model_config: ModelConfig | None) -> bool:
@@ -853,7 +1030,7 @@ def _attach_ref_images(messages: list[dict], image_urls: list[str]) -> list[dict
return out
def build_system_prompt(context: AgentContext) -> str:
def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_context: bool = False) -> str:
conversation = context.conversation
params = conversation.params or {}
kind = "视频" if context.is_video else "图片"
@@ -862,16 +1039,30 @@ def build_system_prompt(context: AgentContext) -> str:
f"本次会话产出的是**{kind}**,这一点在整个会话里不会改变 —— 用户要另一种就请他新开一个创作。",
"",
"【怎么说话】",
"- 说人话,像个有经验的同事,不要写成客服话术或需求确认清单。",
"- 不要复述用户刚说过的话,不要说「好的,我明白了」这种空句",
"- 一次只推进一步。",
"- 说人话,像个懂创作、会一起把事做完的同事;自然、简短、有判断,不要写成客服话术或需求确认清单。",
"- 禁止用「好的」「收到」「明白了」「有需要再说」「我将为你」起手;这些句子没有信息量,也很像机器人",
"- 不要逐字复述用户刚说过的话。需要承接时,只说你的判断或下一步,例如「这个方向能做,我先把反转落在商品登场上。」",
"- 一次只推进一步,但不要把能直接做的事停在寒暄、确认或客套话上。",
"- 缺信息时一次只问一个真正影响结果的问题,不要把对话做成问卷。",
"- 商品、模特、角色、场景这类有库内素材可选的内容,直接发可视化选择卡让用户点;不要先问「要不要发列表」。",
"- 调性、受众、文案、时长等其他信息才用聊天追问,问题末尾顺手告诉用户下一句怎么回。",
"- 用户刚回答过你的问题时,直接沿着答案继续;不要复述答案,也不要额外回一句「收到」。",
"- 用户选择暂不提供某项素材时,把它当成明确授权:按已有信息和合理默认继续。除非任务客观上无法完成,否则不要再次追问同一素材。",
"- 用户说「你来定」「你帮我选」「随便」「都行」时,就是授权你做专业判断;直接选合理方案继续,不要把选择题再抛回去。",
"- 禁止问「要不要继续」「要不要生成」「是否开始创作」这类流程问题。用户已经提出创作需求且信息够了,就直接写策略/方案;真正扣积分前另有确认卡。",
"- 用户打招呼或闲聊(hi / 你好 / 在吗 / 你在干什么 / 嗯 / 好的 / ok):"
" **禁止**调用 write_strategy、write_plan、generate_image,不要「整理方案」或直接开写脚本。",
"- 会话里**还没有**商品/方向时:自然地告诉用户可以直接丢一句想法,别硬推销,也别用客服式结束语。",
"- 会话里**已有**商品或方向时:针对用户这句话本身自然回应;别用「要现在生成还是先调细节」把工作又抛回给用户。",
"- 没有明确创作指令时不要自己写策略卡/方案卡,也不要主动追问流程确认;等用户给出具体想法或修改。",
"",
"【什么时候反问】",
"- 只有信息**确实缺失且无法合理推断**时才调用 ask_user;能自己定的就自己定。",
"- ask_user 一次只问一件事。type=asset 会显示素材选择卡;其他类型显示普通聊天问题,让用户直接输入。",
"- 商品是谁、给谁看、什么调性 —— 这些缺了会直接影响成片,值得问。",
"- 让用户选商品/角色/模特/场景时,ask_user 必须用 type=asset 并填 asset_types。",
"- 用户说「改商品」「换角色」却没点名是哪个:立刻 ask_user 出点选卡,禁止只在气泡里问「换成哪个、填个名字」。用户不必 @",
"- 用户说改时长/模型/比例/分辨率:立刻 ask_user,type=single 给出选项;选完后旧方案作废,必须按新参数重新 write_plan。不要让用户去点底部菜单。",
"- 用户说「改商品」「换角色」却没点名是哪个:立刻 ask_user 发对应素材卡,不要再用文字追问名称",
"- 用户说改时长/模型/比例/分辨率但没给新值:立刻 ask_user,type=single 提供可识别的候选值;聊天里只显示问题,用户直接输入。回答后旧方案作废,必须按新参数重新 write_plan。",
"- 光线、构图、镜头这些专业判断是你的活,不要反过来问用户。",
]
if context.is_video:
@@ -884,7 +1075,7 @@ def build_system_prompt(context: AgentContext) -> str:
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_plan;video_prompt 按上面的秒级分镜规范写满,不要只给大纲。")
lines.append("- 只有用户明确要做片、出方案、改方案、换卖点/剧情时才调用 write_strategy / write_plan;闲聊与打招呼绝对不要。真要写方案时必须先 write_strategy 再 write_plan,video_prompt 按上面的秒级分镜规范写满,不要只给大纲,也禁止只回「好的,有需要再说」")
else:
lines.extend([
"",
@@ -892,10 +1083,26 @@ def build_system_prompt(context: AgentContext) -> str:
"- 决定出图时必须调用 generate_image,不要只口头说「我这就出图」。",
"- 一次用户消息只出一轮;张数用会话已定参数,不要自己加张。",
])
if not allow_plan:
lines.extend([
"",
"【本轮闸门】",
"- 用户本轮没有明确说「去做/出方案」。没有 write_strategy / write_plan / generate_image。",
"- **禁止**本轮直接写出策略卡或方案卡。",
])
if has_context:
lines.extend([
"- 会话已有素材或方向:针对用户本轮实际说的话自然回应;不要问要不要继续、要不要生成。",
"- 禁止只回「在呢」「有需要随时招呼」「收到」这种空话。",
])
else:
lines.extend([
"- 会话还没有创作进度:短回一句,告诉用户直接说想做什么即可;不要用客服式结束语,也不要硬推销出片。",
])
if params:
meta = "".join(f"{k}:{v}" for k, v in params.items() if v)
if meta:
lines.append(f"\n【会话已定参数】{meta}(出片按这套;用户要改就出选项卡,选完必须重写方案)")
lines.append(f"\n【会话已定参数】{meta}(出片按这套;用户改动后必须按新值重写方案)")
if conversation.preset:
# 只给名字模型只能靠猜;把这个预设的拍法约束一起给它
guidance = preset_guidance(conversation.preset)
@@ -929,14 +1136,25 @@ def build_system_prompt(context: AgentContext) -> str:
return "\n".join(lines)
def build_messages(context: AgentContext) -> list[dict]:
def _answer_label(field: dict, raw) -> str:
"""追问卡的内部值翻成人能读的标签,避免把 UUID / gate key 喂回模型。"""
values = raw if isinstance(raw, list) else [raw]
options = {
str(item.get("value")): str(item.get("label") or item.get("value") or "")
for item in (field.get("options") or [])
if isinstance(item, dict)
}
return "".join(options.get(str(value), str(value)) for value in values if value not in (None, ""))
def build_messages(context: AgentContext, *, allow_plan: bool = True, has_context: bool = False) -> list[dict]:
"""会话历史 → 模型消息。只喂对模型有意义的:文字、追问和它的答案、生成过什么。
策略卡/方案卡这类结构化产物压成一句话,原样塞 JSON 只会挤爆上下文。
长会话只喂最近 KEEP_RECENT_MESSAGES 条原文,更早的靠 system 里的【前情提要】
——摘要在 compress_memory() 里生成,不在这里现算。
"""
messages = [{"role": "system", "content": build_system_prompt(context)}]
messages = [{"role": "system", "content": build_system_prompt(context, allow_plan=allow_plan, has_context=has_context)}]
history = list(context.conversation.messages.all())
if len(history) > COMPRESS_AFTER_MESSAGES:
history = history[-KEEP_RECENT_MESSAGES:]
@@ -945,14 +1163,37 @@ def build_messages(context: AgentContext) -> list[dict]:
if message.text.strip():
messages.append({"role": message.role, "content": message.text})
elif message.kind == CreationMessage.Kind.ELICIT:
answers = (message.payload or {}).get("answers") or {}
labels = {f["key"]: f["label"] for f in (message.payload or {}).get("fields", [])}
payload = message.payload or {}
answers = payload.get("answers") or {}
fields = [item for item in (payload.get("fields") or []) if isinstance(item, dict)]
field_by_key = {str(item.get("key") or ""): item for item in fields}
if payload.get("interaction") == "chat" or (
payload.get("interaction") == "asset_picker"
and payload.get("answered_via") == "chat"
):
question = message.text.strip() or str((fields[0] if fields else {}).get("label") or "").strip()
if question:
messages.append({"role": "assistant", "content": question})
# 聊天式回答本身就是下一条 TEXT user 消息,这里不再合成一条伪造答案。
continue
if answers:
joined = ";".join(f"{labels.get(k, k)}{v}" for k, v in answers.items())
messages.append({"role": "assistant", "content": f"(我问了用户几个问题)"})
messages.append({"role": "user", "content": f"(用户回答){joined}"})
if payload.get("phase") == "gate":
choice = str(answers.get("_asset_gate") or "")
if choice == "skip":
answer_text = "(用户选择暂不添加这项素材,要求按现有信息继续原任务)"
else:
answer_text = "(用户希望打开素材列表继续选择)"
else:
joined = "".join(
f"{field_by_key.get(str(key), {}).get('label') or key}"
f"{_answer_label(field_by_key.get(str(key), {}), value)}"
for key, value in answers.items()
)
answer_text = f"(用户完成了刚才的选择:{joined})"
messages.append({"role": "assistant", "content": "(我请用户补充了一项会影响创作的信息)"})
messages.append({"role": "user", "content": answer_text})
else:
messages.append({"role": "assistant", "content": "(我向用户提了问题,还没收到回答)"})
messages.append({"role": "assistant", "content": "(我正在等用户回答刚才的问题)"})
elif message.kind in (CreationMessage.Kind.GENERATING, CreationMessage.Kind.RESULT):
prompt = (message.payload or {}).get("prompt") or ""
messages.append({"role": "assistant", "content": f"(我生成了一版,prompt:{prompt[:200]})"})
@@ -1005,6 +1246,9 @@ def stream_creation_agent(
text: str,
refs: list[dict] | None = None,
model_config: ModelConfig | None = None,
record_user_message: bool = True,
force_creative_turn: bool = False,
continuation_instruction: str = "",
) -> Iterator[str]:
"""一条用户消息 → SSE 流。生成器,由 StreamingHttpResponse 逐帧下发。"""
refs = refs or []
@@ -1015,10 +1259,26 @@ def stream_creation_agent(
context = AgentContext(conversation=conversation, user=user, model_config=model_config)
try:
user_message = None
with transaction.atomic():
pin_refs(conversation, refs)
user_message = append_message(conversation, role="user", text=text, refs=refs)
yield _sse({"type": "message", "message": _message_payload(user_message)})
if record_user_message:
user_message = append_message(conversation, role="user", text=text, refs=refs)
if user_message is not None:
yield _sse({"type": "message", "message": _message_payload(user_message)})
# 空会话里的纯打招呼:短回一句就够。已有商品/方向时走模型,
# 针对用户实际说的话自然回应,但不给方案工具。
has_context = session_has_creative_context(conversation)
if record_user_message and is_pure_chitchat(text) and not refs and not has_context:
reply = append_message(
conversation,
role="assistant",
text="我在。想做什么,直接丢一句想法给我就行。",
)
yield _sse({"type": "message", "message": _message_payload(reply)})
yield _sse({"type": "done"})
return
model_config = _prefer_vision_text_model(model_config, conversation.team, conversation.pinned_refs or [])
context.model_config = model_config
@@ -1035,9 +1295,18 @@ def stream_creation_agent(
# 用户消息已经先回显了,所以这一小段等待不会看起来像卡住。
compress_memory(context)
allow_plan = (
force_creative_turn
or has_creative_intent(text, refs)
or (has_context and is_continue_intent(text))
)
provider = build_provider(model_config)
messages = build_messages(context)
tools = tool_schemas(context)
messages = build_messages(context, allow_plan=allow_plan, has_context=has_context)
if continuation_instruction.strip():
# 卡片答案已经在历史里,这里仅给本轮一个不落库的执行指令。这样既不会多出
# 一条伪造的用户气泡,也不会让模型把「跳过素材」误判成闲聊后停住。
messages.append({"role": "user", "content": continuation_instruction.strip()})
tools = tool_schemas(context, allow_plan=allow_plan)
for _round in range(MAX_TOOL_ROUNDS):
text_buffer: list[str] = []
@@ -1060,17 +1329,15 @@ def stream_creation_agent(
said = "".join(text_buffer).strip()
calls = [tool_buffer[i] for i in sorted(tool_buffer) if tool_buffer[i].get("name")]
if said:
bubble = append_message(conversation, role="assistant", text=said)
yield _sse({"type": "message", "message": _message_payload(bubble)})
fallback_fields = None
if not calls:
pick = wanted_asset_pick(text, refs)
pick = (
wanted_asset_pick(text, refs)
or requested_asset_card_from_context(conversation, text)
)
param_keys = wanted_param_keys(text, is_video=context.is_video)
fields = None
if pick:
fields = [{
fallback_fields = [{
"key": pick,
"label": _ASSET_PICK_LABEL[pick],
"type": "asset",
@@ -1078,9 +1345,18 @@ def stream_creation_agent(
"asset_types": [pick],
}]
elif param_keys:
fields = session_param_fields(param_keys, context.is_video)
if fields:
result, _stop = _dispatch_tool(context, "ask_user", {"fields": fields})
fallback_fields = session_param_fields(param_keys, context.is_video)
# ask_user 自己会落一条可追踪的聊天问题。模型同时吐出的过渡文案不再
# 另存一条,否则界面会连续出现两遍几乎相同的问题。
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)
yield _sse({"type": "message", "message": _message_payload(bubble)})
if not calls:
if fallback_fields:
result, _stop = _dispatch_tool(context, "ask_user", {"fields": fallback_fields})
for event in result.get("_events", []):
yield _sse(event)
break
@@ -1138,6 +1414,47 @@ _TOOL_LABELS = {
}
_ASSET_CARD_LABELS = {
"product": "这条要展示哪款商品?",
"character": "这条想用哪个角色?",
"model": "这条想用哪位模特?",
"scene": "这条想放在哪个场景里?",
"asset": "这一步要使用哪项素材?",
}
def _guided_elicit_text(field: dict) -> str:
"""追问既要问清一件事,也要让用户知道下一句怎么回。"""
label = str(field.get("label") or "这项你想怎么定?").strip()
if field.get("type") == "asset":
return label
if str(field.get("key") or "") in SESSION_PARAM_KEYS:
return label
return f"{label} 直接用一句话告诉我就行,不用整理成完整需求。"
def _elicit_payload_for_fields(fields: list[dict]) -> dict:
"""素材问题直接出选择卡;其他问题继续走普通聊天。"""
field = dict(fields[0])
if field.get("type") == "asset":
asset_types = [item for item in (field.get("asset_types") or []) if item in _ASSET_CARD_LABELS]
field["label"] = _ASSET_CARD_LABELS[asset_types[0]] if len(asset_types) == 1 else _ASSET_CARD_LABELS["asset"]
return {
"interaction": "asset_picker",
"phase": "pick",
"fields": [field],
"submitted": False,
"answers": {},
}
return {
"interaction": "chat",
"fields": [field],
"submitted": False,
"answers": {},
}
def _dispatch_tool(context: AgentContext, name: str, args: dict) -> tuple[dict, bool]:
"""执行一个工具。返回 (结果, 是否中断循环)。
@@ -1147,10 +1464,13 @@ def _dispatch_tool(context: AgentContext, name: str, args: dict) -> tuple[dict,
fields = _coerce_fields(args.get("fields"))
if not fields:
return {"payload": {"error": "fields 不合法,请重新组织问题"}}, False
payload = _elicit_payload_for_fields(fields)
display_field = (payload.get("fields") or fields)[0]
message = append_message(
context.conversation, role="assistant",
kind=CreationMessage.Kind.ELICIT,
payload={"fields": fields, "submitted": False, "answers": {}},
text=_guided_elicit_text(display_field),
payload=payload,
)
# 反问一旦发出就必须停,等人回答。继续跑等于自问自答。
return {
+4 -2
View File
@@ -14,8 +14,10 @@ VIDEO_PRESETS: dict[str, str] = {
"商品不能在开头硬推,要等冲突立住了再自然介入。禁止把普通不便夸成严重后果。"
),
"商品拟人广告": (
"把商品拟人化成有性格的角色,用它的动作、表情和情绪推进。轻快有趣,不说教"
"拟人化不能牺牲商品真实外观 —— 材质、颜色、结构必须和参考图一致"
"把商品成有性格的角色,但默认采用无脸拟人:商品本体保持真实完整,不在瓶身、包装或机身上新增卡通五官"
"性格通过整个商品轻微倾斜、转向、弹跳、进退,配合镜头、光影、环境反应和音效来表达"
"商品需要说台词时使用画外角色声,商品本体不做口型。整体轻快有趣、精致克制,不是儿童贴纸玩具风。"
"只有用户明确说要可见的卡通五官时才采用有脸拟人。商品材质、颜色、结构和包装必须与参考图一致。"
),
"达人口播种草": (
"真人出镜口播,生活化语气,像朋友分享而不是念广告稿。开头一句话建立停留理由,"
+16 -3
View File
@@ -71,6 +71,9 @@ def model_resolutions(model_config) -> set[str]:
RATIOS = {"21:9", "16:9", "4:3", "1:1", "3:4", "9:16"}
RESOLUTIONS = {"480p", "720p", "1080p", "4k"}
MODES = {"universal", "keyframe"}
# 全能创作 / 分镜路用语义 type(character/product/…);对火山仍是参考图。
# 旧逻辑只认 image/video/audio,会把商品/角色图静默 skipped → 成片只靠文案「发明」包装。
IMAGE_LIKE_REF_TYPES = {"image", "character", "product", "scene", "model", "asset"}
IN_FLIGHT_STATUSES = (
AITask.Status.CREATED, # 视频复刻审核中也占并发,避免连点刷出一堆待审任务
AITask.Status.RESERVED,
@@ -300,7 +303,9 @@ def build_content_items(
# 三库引用(模块4 · 4.1):资产库 / 模特库 / 商品库 挑出来的东西最终都是一行 Asset,
# 所以后端只认一种 source=asset,三个库的差别全在前端的 picker 上。
if source == "asset" and ref.get("asset_id"):
# 全能创作 resolve_refs / 前端常带 type=character|product 且 source 缺省为 upload,
# 只要有 asset_id 就按 Asset 解析(含审核态 → asset://),避免直链分支把语义 type skipped。
if ref.get("asset_id") and source in {"asset", "upload", ""}:
asset = Asset.objects.filter(id=ref["asset_id"], team=team, is_deleted=False).first()
if asset is None:
raise ValueError(f"素材「{label or '未命名'}」不存在或已被删除")
@@ -326,8 +331,9 @@ def build_content_items(
# 直传素材(已上传 TOS 的直链)。resolved_url 可覆盖为 asset://(官方模特跨团队等)
push_url = str(ref.get("resolved_url") or url)
if ref_type == "image":
if ref_type in IMAGE_LIKE_REF_TYPES:
# 参考图模式下所有图 role 必须 reference_image;keyframe 用 first_frame/last_frame
# character/product/scene/model/asset 与 image 同等对待(全能创作语义 type)
effective_role = "reference_image" if mode == "universal" else (role or "first_frame")
asset_type = _push("image", push_url, effective_role)
elif ref_type == "video":
@@ -356,6 +362,13 @@ def build_content_items(
# @label 替换:按 label 长度降序,防子串吞噬
ordered = sorted(label_to_placeholder.items(), key=lambda kv: len(kv[0]), reverse=True)
api_prompt = _format_prompt_for_ark(prompt, ordered)
# 自由创作 / 全能创作是整段一次出片(没有「第 2 段」),开场先验强 ——
# 挂上与专业创作首镜相同的正面洁净指令;最终 enforce_no_embedded_captions 还会改写口播并挂禁令。
from .services import OPENING_SHOT_DIRECTIVE, rewrite_speech_as_audio_only
if OPENING_SHOT_DIRECTIVE not in api_prompt:
api_prompt = f"{OPENING_SHOT_DIRECTIVE}\n{api_prompt}"
api_prompt = rewrite_speech_as_audio_only(api_prompt)
return {
"content_items": content_items,
@@ -480,7 +493,7 @@ def submit_free_video(*, team, user, params: dict) -> AITask:
if mode == "keyframe":
roles = [str(r.get("role") or "") for r in references]
if any(str(r.get("type") or "image") != "image" for r in references):
if any(str(r.get("type") or "image") not in IMAGE_LIKE_REF_TYPES for r in references):
raise ValueError("首尾帧模式仅支持图片素材")
if "first_frame" not in roles:
raise ValueError("首尾帧模式需要提供首帧图片")
+2 -1
View File
@@ -277,6 +277,7 @@ def _asset_reference(asset, type_: str, label: str) -> dict | None:
"url": url,
"type": type_,
"label": label,
"source": "asset",
"asset_id": str(asset.id),
"review_status": asset.review_status,
"review_remote_id": asset.review_remote_id,
@@ -355,7 +356,7 @@ def _product_reference(product) -> dict | None:
cover = _product_cover_url(product)
return (
{"url": cover, "type": "product", "label": product.title,
"asset_id": "", "review_status": "", "review_remote_id": ""}
"source": "upload", "asset_id": "", "review_status": "", "review_remote_id": ""}
if cover else None
)
+34 -2
View File
@@ -948,6 +948,35 @@ _CAPTION_FIELD_INLINE_RE = re.compile(rf"[,;、]?\s*{_CAPTION_WORDS}\s*[
_CAPTION_CLAUSE_RE = re.compile(rf"[,;、]?\s*[^,;。\n]*{_CAPTION_WORDS}[^,;。\n]*[。]?")
_SPEECH_LINE_RE = re.compile(
r"^(\s*)(口播|旁白|台词|对白|解说)\s*[:]\s*(.+)$"
)
_SPEECH_AUDIO_ONLY_PREFIX = "人声(仅音频,由人物口型与配音表达,不出现在画面上):"
def rewrite_speech_as_audio_only(prompt: str) -> str:
"""把「口播:/旁白:/台词:」改写成「人声(仅音频…)」抬头。
全能创作 / 自由创作的 video_prompt 常按秒级分镜写「口播:…」。
裸摆口播原文时,出片模型极易把这段可见文本烧成画面字幕 —— 专业创作
已用同一抬头(_segment_script_text);这里对所有入口统一改写。
已带「仅音频」抬头的行不重复包一层。
"""
out: list[str] = []
for line in (prompt or "").split("\n"):
if "仅音频" in line and "人声" in line:
out.append(line)
continue
m = _SPEECH_LINE_RE.match(line)
if m:
indent, _kind, speech = m.group(1), m.group(2), m.group(3).strip()
if speech:
out.append(f"{indent}{_SPEECH_AUDIO_ONLY_PREFIX}{speech}")
continue
out.append(line)
return "\n".join(out)
def strip_caption_directives(prompt: str) -> str:
"""把正文里所有会让模型联想到「画面文字」的字样抹掉 —— 无论它是要求加字幕还是声明没有字幕。
模型不区分肯定否定,看到「字幕」这个词本身就更容易画出来,所以一律清掉,
@@ -966,15 +995,18 @@ def strip_caption_directives(prompt: str) -> str:
def enforce_no_embedded_captions(prompt: str) -> str:
"""所有视频入口(专业创作 / 一键成片 / 自由创作 / 视频复刻 / 提炼改写)最终汇入这里:
先洗掉正文里的字幕字段,再把最高优先级无字幕禁令置于提示词第一行
"""所有视频入口(专业创作 / 一键成片 / 自由创作 / 视频复刻 / 提炼改写 / 全能创作)最终汇入这里:
把「口播:」改写成仅音频抬头,再洗掉字幕字段,最后挂最高优先级无字幕禁令。
首镜最容易被模型自动加标题,所以不能再把禁令放在长提示词末尾。"""
# 顺序要紧:先摘掉可能已存在的规则本身,再洗正文,最后统一拼回去。
# 反过来的话,清洗器会把规则里「不要字幕…」那半句也当成字幕字样吃掉 → 摘不干净 → 规则拼两遍
# (重复 = 负面词频翻倍 = 更容易出字幕,正是本次要根治的毛病)。
base = (prompt or "").replace(NO_EMBEDDED_CAPTIONS_REQUIREMENT, "").replace(NO_EMBEDDED_CAPTIONS_TAIL, "")
base = base.replace(OPENING_SHOT_DIRECTIVE, "")
base = rewrite_speech_as_audio_only(base)
base = strip_caption_directives(base).strip()
# 首帧最容易被模型自动加标题页 → 规则放开头;末位权重高 → 结尾补一句中性的正面复述。
# 口播改写后再挂洁净规则,避免「口播:」原文被模型烧成字幕。
return f"{NO_EMBEDDED_CAPTIONS_REQUIREMENT}\n{base}\n{NO_EMBEDDED_CAPTIONS_TAIL}".strip()
+389 -8
View File
@@ -6,7 +6,7 @@
import json
from unittest.mock import patch
from django.test import TestCase
from django.test import SimpleTestCase, TestCase
from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
@@ -25,9 +25,14 @@ from .creation_agent import (
_merge_tool_call_deltas,
build_messages,
get_creation_chat_model,
has_creative_intent,
is_continue_intent,
is_pure_chitchat,
session_has_creative_context,
stream_creation_agent,
submit_confirmed_image,
submit_confirmed_video,
tool_schemas,
video_duration,
video_model_name,
wanted_asset_pick,
@@ -134,17 +139,19 @@ class FieldCoercionTests(TestCase):
self.assertEqual(fields, []) # 没选项的单选是废卡
def test_text_and_asset_fields_get_their_defaults(self):
fields = _coerce_fields([
text_fields = _coerce_fields([
{"key": "slogan", "label": "想突出哪句话?", "type": "text"},
])
asset_fields = _coerce_fields([
{"key": "who", "label": "用哪个模特?", "type": "asset"},
])
self.assertEqual(fields[0]["placeholder"], "")
self.assertIn("product", fields[1]["asset_types"])
self.assertEqual(text_fields[0]["placeholder"], "")
self.assertIn("product", asset_fields[0]["asset_types"])
def test_unknown_type_and_over_limit_are_trimmed(self):
raw = [{"key": f"k{i}", "label": "x", "type": "text"} for i in range(5)]
raw.append({"key": "bad", "label": "x", "type": "dropdown"})
self.assertEqual(len(_coerce_fields(raw)), 3) # 一次最多3
self.assertEqual(len(_coerce_fields(raw)), 1) # 聊天式追问一次只1
def test_product_text_or_single_is_coerced_to_asset_card(self):
fields = _coerce_fields([
@@ -185,6 +192,8 @@ class ParamPickIntentTests(TestCase):
class AskUserTests(CreationAgentBaseTests):
def test_saying_change_duration_injects_param_card(self):
self.conversation.mode = CreationConversation.Mode.VIDEO
self.conversation.save(update_fields=["mode"])
events, _ = self._run([_text_chunks("你想改成多长?")], text="我想改时长")
elicit = [e for e in events if e.get("type") == "message" and e["message"]["kind"] == "elicit"]
self.assertEqual(len(elicit), 1)
@@ -195,8 +204,42 @@ class AskUserTests(CreationAgentBaseTests):
events, _ = self._run([_text_chunks("换成哪个商品?直接选或填名字都行:")], text="我想修改商品")
elicit = [e for e in events if e.get("type") == "message" and e["message"]["kind"] == "elicit"]
self.assertEqual(len(elicit), 1)
self.assertEqual(elicit[0]["message"]["payload"]["fields"][0]["type"], "asset")
self.assertEqual(elicit[0]["message"]["payload"]["fields"][0]["asset_types"], ["product"])
payload = elicit[0]["message"]["payload"]
self.assertEqual(payload["interaction"], "asset_picker")
self.assertEqual(payload["phase"], "pick")
self.assertEqual(payload["fields"][0]["type"], "asset")
self.assertEqual(payload["fields"][0]["asset_types"], ["product"])
self.assertEqual(payload["fields"][0]["label"], "这条要展示哪款商品?")
self.assertEqual(elicit[0]["message"]["text"], "这条要展示哪款商品?")
def test_requesting_the_card_uses_the_previous_asset_context(self):
append_message(self.conversation, role="user", text="帮我做一条商品短片")
append_message(
self.conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text="这条要展示哪款商品?",
payload={
"interaction": "chat",
"fields": [{
"key": "product",
"label": "这条要展示哪款商品?",
"type": "asset",
"asset_types": ["product"],
}],
"submitted": True,
"answers": {"product": "你发一下卡片我选择"},
},
)
events, _ = self._run([_text_chunks("我把商品卡片发出来。")], text="你发一下卡片我选择")
cards = [
event["message"] for event in events
if event.get("type") == "message"
and event["message"]["kind"] == "elicit"
and event["message"]["payload"].get("interaction") == "asset_picker"
]
self.assertEqual(len(cards), 1)
self.assertEqual(cards[0]["payload"]["fields"][0]["asset_types"], ["product"])
def test_ask_user_emits_elicit_card_and_stops_the_loop(self):
events, fake = self._run([
@@ -209,6 +252,11 @@ class AskUserTests(CreationAgentBaseTests):
elicit = [e for e in events if e.get("type") == "message" and e["message"]["kind"] == "elicit"]
self.assertEqual(len(elicit), 1)
self.assertEqual(elicit[0]["message"]["payload"]["interaction"], "chat")
self.assertEqual(
elicit[0]["message"]["text"],
"想要什么调性? 直接用一句话告诉我就行,不用整理成完整需求。",
)
self.assertEqual(len(elicit[0]["message"]["payload"]["fields"][0]["options"]), 2)
# 反问必须中断循环,否则模型会自问自答
self.assertEqual(len(fake.calls), 1)
@@ -398,6 +446,136 @@ 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_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="做一张轻松有趣的商品海报")
question = append_message(
self.conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text="这次主推哪个商品?",
payload={
"interaction": "asset_picker",
"fields": [{
"key": "product",
"label": "这次主推哪个商品?",
"type": "asset",
"required": True,
"asset_types": ["product"],
}],
"submitted": False,
"answers": {},
},
)
fake = FakeProvider([_text_chunks("那就把反差感落在商品登场上。")])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
response = self.client.post(
f"/api/ai/creations/{self.conversation.id}/send/",
{"kind": "text", "text": f"就用{product.title}"},
format="json",
)
list(response.streaming_content)
question.refresh_from_db()
self.conversation.refresh_from_db()
self.assertTrue(question.payload["submitted"])
self.assertEqual(question.payload["answers"], {"product": product.title})
self.assertEqual(
self.conversation.messages.filter(role="user").order_by("-seq").first().text,
f"就用{product.title}",
)
self.assertTrue(any(
ref.get("type") == "product" and str(ref.get("id")) == str(product.id)
for ref in self.conversation.pinned_refs
))
tool_names = {tool["function"]["name"] for tool in fake.calls[0]["extra_body"]["tools"]}
self.assertIn("generate_image", tool_names)
def test_delegate_reply_picks_one_product_without_showing_a_list(self):
product = Product.objects.create(team=self.team, created_by=self.user, title="净颜精华")
question = append_message(
self.conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text="先定主商品:这条要展示哪款商品?",
payload={
"interaction": "asset_picker",
"fields": [{
"key": "product",
"label": "这条要展示哪款商品?",
"type": "asset",
"required": True,
"asset_types": ["product"],
}],
"submitted": False,
"answers": {},
},
)
fake = FakeProvider([_text_chunks("我用它来组织这条片。")])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
response = self.client.post(
f"/api/ai/creations/{self.conversation.id}/send/",
{"kind": "text", "text": "你来定"},
format="json",
)
list(response.streaming_content)
question.refresh_from_db()
self.conversation.refresh_from_db()
self.assertEqual(question.payload["answers"], {"product": product.title})
self.assertTrue(any(
ref.get("type") == "product" and str(ref.get("id")) == str(product.id)
for ref in self.conversation.pinned_refs
))
assistant_text = " ".join(
message.text for message in self.conversation.messages.filter(role="assistant")
if message.text
)
self.assertNotIn("可选商品", assistant_text)
def test_typed_param_reply_maps_to_option_value(self):
self.conversation.mode = CreationConversation.Mode.VIDEO
self.conversation.params = {"duration": "5 秒"}
self.conversation.save(update_fields=["mode", "params"])
question = append_message(
self.conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text="改成多长?",
payload={
"interaction": "chat",
"fields": [{
"key": "duration",
"label": "改成多长?",
"type": "single",
"required": True,
"options": [
{"value": "5 秒", "label": "5 秒"},
{"value": "10 秒", "label": "10 秒"},
{"value": "15 秒", "label": "15 秒"},
],
}],
"submitted": False,
"answers": {},
},
)
fake = FakeProvider([_text_chunks("我按十秒重新排镜头。")])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
response = self.client.post(
f"/api/ai/creations/{self.conversation.id}/send/",
{"kind": "text", "text": "改成 10 秒吧"},
format="json",
)
list(response.streaming_content)
question.refresh_from_db()
self.conversation.refresh_from_db()
self.assertEqual(question.payload["answers"], {"duration": "10 秒"})
self.assertEqual(self.conversation.params["duration"], "10 秒")
def test_answering_an_elicit_card_marks_it_submitted(self):
card = append_message(
self.conversation, role="assistant", kind=CreationMessage.Kind.ELICIT,
@@ -417,6 +595,8 @@ class SendEndpointTests(TestCase):
card.refresh_from_db()
self.assertTrue(card.payload["submitted"])
self.assertEqual(card.payload["answers"], {"tone": "warm"})
# 选择已经显示在卡片里,不能再复制成一条伪造的用户气泡。
self.assertFalse(self.conversation.messages.filter(role="user").exists())
def test_elicit_product_choice_pins_the_product(self):
"""对话里点选商品只回 answers 时,也必须钉进 pinned_refs,否则出片带不上商品图。"""
@@ -480,6 +660,61 @@ class SendEndpointTests(TestCase):
# 重复提交会让同一个问题在上下文里出现两次答案
self.assertEqual(response.status_code, 409)
def test_skipping_asset_picker_continues_without_fake_user_bubble(self):
"""点「先按当前想法做」应继续创作,不能把系统文案冒充成用户消息再停住。"""
append_message(self.conversation, role="user", text="帮我做一张有性格的商品海报")
card = append_message(
self.conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
payload={
"phase": "gate",
"fields": [{
"key": "_asset_gate",
"label": "选好商品能让画面更准确。要现在从素材库里挑一个吗?",
"type": "single",
"options": [
{"value": "send", "label": "选择商品"},
{"value": "skip", "label": "先按当前想法做"},
],
}],
"pending_fields": [{
"key": "product", "label": "选哪个商品?", "type": "asset",
"asset_types": ["product"],
}],
"submitted": False,
"answers": {},
},
)
fake = FakeProvider([_text_chunks("行,那我先按拟人海报方向往下做。")])
before_users = self.conversation.messages.filter(role="user").count()
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
response = self.client.post(
f"/api/ai/creations/{self.conversation.id}/send/",
{"kind": "elicit_answer", "reply_to": str(card.id),
"answers": {"_asset_gate": "skip"}},
format="json",
)
events = _events(
chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk
for chunk in response.streaming_content
)
self.assertEqual(response.status_code, 200)
self.assertEqual(self.conversation.messages.filter(role="user").count(), before_users)
self.assertFalse(any(
event.get("type") == "message" and event["message"]["role"] == "user"
for event in events
))
self.assertTrue(fake.calls)
tool_names = {tool["function"]["name"] for tool in fake.calls[0]["extra_body"]["tools"]}
self.assertIn("generate_image", tool_names)
self.assertNotIn("有需要再说", "".join(
event["message"].get("text", "")
for event in events if event.get("type") == "message"
))
class VideoPlanAndConfirmTests(CreationAgentBaseTests):
"""视频链路:策略卡 → 方案卡 → 确认闸门 → 出片(契约 §0)。"""
@@ -576,6 +811,51 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertEqual(params["aspect_ratio"], "9:16")
self.assertTrue(params["generate_audio"])
def test_personified_product_uses_offscreen_voice_without_changing_packaging(self):
self.conversation.preset = "商品拟人广告"
self.conversation.save(update_fields=["preset"])
card = append_message(
self.conversation, role="assistant", kind=CreationMessage.Kind.CONFIRM,
payload={"video_prompt": "精华瓶自述使用感受", "submitted": False},
)
task = AITask.objects.create(
team=self.team, created_by=self.user, task_type=AITask.Type.FREE_VIDEO,
model_config=self.model, idempotency_key="k-product-voice",
)
with patch("apps.ai.free_video.submit_free_video", return_value=task) as submit:
message, error = submit_confirmed_video(
conversation=self.conversation, user=self.user, confirm_message=card
)
prompt = submit.call_args.kwargs["params"]["prompt"]
self.assertEqual(error, "")
self.assertIsNotNone(message)
self.assertIn("商品台词采用画外角色声", prompt)
self.assertIn("全部可见表面保持原有设计", prompt)
def test_explicit_cartoon_face_request_can_override_the_default(self):
self.conversation.preset = "商品拟人广告"
self.conversation.save(update_fields=["preset"])
append_message(self.conversation, role="user", text="我就要精华瓶长出卡通眼睛和嘴巴")
original = "精华瓶眨眼开口说话"
card = append_message(
self.conversation, role="assistant", kind=CreationMessage.Kind.CONFIRM,
payload={"video_prompt": original, "submitted": False},
)
task = AITask.objects.create(
team=self.team, created_by=self.user, task_type=AITask.Type.FREE_VIDEO,
model_config=self.model, idempotency_key="k-product-face",
)
with patch("apps.ai.free_video.submit_free_video", return_value=task) as submit:
message, error = submit_confirmed_video(
conversation=self.conversation, user=self.user, confirm_message=card
)
prompt = submit.call_args.kwargs["params"]["prompt"]
self.assertEqual(error, "")
self.assertIsNotNone(message)
self.assertEqual(prompt, original)
def test_confirm_without_stored_prompt_reports_instead_of_submitting(self):
card = append_message(
self.conversation, role="assistant", kind=CreationMessage.Kind.CONFIRM,
@@ -794,6 +1074,18 @@ class PresetGuidanceTests(CreationAgentBaseTests):
# 光有名字模型只能靠猜,拍法约束必须一起给
self.assertIn("人物面部和身形必须全程一致", system)
def test_product_personification_defaults_to_faceless_expression(self):
conversation = CreationConversation.objects.create(
team=self.team, created_by=self.user, mode="video", preset="商品拟人广告", params={},
)
fake = FakeProvider([_text_chunks("开始设计")])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
list(stream_creation_agent(conversation=conversation, user=self.user,
text="开始", model_config=self.model))
system = fake.calls[0]["messages"][0]["content"]
self.assertIn("默认采用无脸拟人", system)
self.assertIn("商品需要说台词时使用画外角色声", system)
def test_unknown_preset_degrades_to_name_only(self):
conversation = CreationConversation.objects.create(
team=self.team, created_by=self.user, mode="video", preset="前端新加的卡", params={},
@@ -869,4 +1161,93 @@ class CreationChatModelTests(CreationAgentBaseTests):
is_default=True,
)
picked = get_creation_chat_model(None)
self.assertEqual(picked.id, seed.id)
# 测试迁移已经可能种过同名 Seed 2.1,重点是编排层选到该模型家族,
# 不是强行命中本测试后建的重复记录。
self.assertEqual(picked.name, seed.name)
class PureChitchatTests(SimpleTestCase):
"""打招呼不能触发整理方案 / write_plan。"""
def test_greetings_are_chitchat(self):
for text in ("hi", "Hi!", "你好", "在吗", "", "好的", "ok", "谢谢"):
self.assertTrue(is_pure_chitchat(text), text)
def test_creative_asks_are_not_chitchat(self):
for text in ("做条带货视频", "hi 帮我改个方案", "换个商品", "重新写方案"):
self.assertFalse(is_pure_chitchat(text), text)
class CreativeIntentTests(SimpleTestCase):
"""闲聊不出方案工具;明确要做片才放开。"""
def test_idle_chat_has_no_creative_intent(self):
for text in ("hi", "今天天气不错", "这个功能怎么用", "你们支持哪些模型", "随便聊聊"):
self.assertFalse(has_creative_intent(text), text)
def test_refs_alone_do_not_count(self):
self.assertFalse(has_creative_intent("看看", [{"type": "product", "id": "1"}]))
self.assertFalse(has_creative_intent("", [{"type": "product", "id": "1"}]))
def test_creative_asks_count(self):
for text in (
"做条带货视频",
"帮我出个方案",
"重新写方案",
"出一张主图",
"改成 9:16",
"把商品塑造为有性格的拟人角色,创作一条轻快、有趣的短广告。",
):
self.assertTrue(has_creative_intent(text), text)
def test_short_continue_phrases_are_explicit_continuations(self):
for text in ("继续", "接着做", "往下做吧", "就按这个", "直接来"):
self.assertTrue(is_continue_intent(text), text)
for text in ("好的", "", "先不用", "今天怎么样"):
self.assertFalse(is_continue_intent(text), text)
class ToolGateTests(CreationAgentBaseTests):
def test_empty_conversation_greeting_is_human_and_actionable(self):
events = _events(stream_creation_agent(
conversation=self.conversation,
user=self.user,
text="在吗",
model_config=self.model,
))
replies = [
event["message"]["text"]
for event in events
if event.get("type") == "message" and event["message"]["role"] == "assistant"
]
self.assertEqual(replies, ["我在。想做什么,直接丢一句想法给我就行。"])
self.assertNotIn("有需要再说", replies[0])
def test_idle_chat_hides_plan_tools(self):
fake = FakeProvider([_text_chunks("哈哈,有需要再说")])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
list(stream_creation_agent(
conversation=self.conversation, user=self.user,
text="今天天气不错", model_config=self.model,
))
self.assertTrue(fake.calls)
names = {t["function"]["name"] for t in fake.calls[0]["extra_body"]["tools"]}
self.assertNotIn("write_strategy", names)
self.assertNotIn("write_plan", names)
self.assertNotIn("ask_user", names) # 闲聊不给追问卡,免得推销出片
self.assertIn("search_library", names)
def test_continue_after_existing_brief_keeps_creation_tools(self):
append_message(self.conversation, role="user", text="帮我做一张商品主图")
self.assertTrue(session_has_creative_context(self.conversation))
fake = FakeProvider([_text_chunks("那就接着往下做。")])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
list(stream_creation_agent(
conversation=self.conversation,
user=self.user,
text="继续",
model_config=self.model,
))
names = {tool["function"]["name"] for tool in fake.calls[0]["extra_body"]["tools"]}
self.assertIn("generate_image", names)
+18
View File
@@ -110,7 +110,25 @@ class BuildContentItemsTests(TestCase):
self.assertEqual(roles, ["reference_image", "reference_image", "reference_video"])
self.assertEqual(built["video_duration_total"], 3.0)
def test_semantic_types_character_product_not_skipped(self):
"""全能创作 references 用 type=character/product,不能再静默 skipped。"""
refs = [
{"url": "http://x/person.png", "type": "character", "label": "男生", "source": "upload"},
{"url": "http://x/bottle.png", "type": "product", "label": "100nomur", "source": "upload"},
{"url": "http://x/tri.png", "type": "product", "label": "100nomur三视图", "source": "upload"},
]
built = build_content_items(team=self.team, prompt="@男生 拿着 @100nomur", mode="universal", references=refs)
self.assertEqual(built["image_n"], 3)
self.assertEqual(len(built["content_items"]), 3)
self.assertEqual(
[i.get("role") for i in built["content_items"]],
["reference_image", "reference_image", "reference_image"],
)
self.assertIn("图片1", built["api_prompt"])
self.assertIn("图片2", built["api_prompt"])
def test_keyframe_roles(self):
refs = [
{"url": "http://x/f.png", "type": "image", "role": "first_frame"},
{"url": "http://x/l.png", "type": "image", "role": "last_frame"},
@@ -5,6 +5,7 @@ from apps.ai.services import (
NO_EMBEDDED_CAPTIONS_TAIL,
OPENING_SHOT_DIRECTIVE,
enforce_no_embedded_captions,
rewrite_speech_as_audio_only,
strip_caption_directives,
)
@@ -90,6 +91,24 @@ class VideoCaptionPolicyTests(SimpleTestCase):
self.assertTrue(prompt.startswith(NO_EMBEDDED_CAPTIONS_REQUIREMENT))
def test_rewrites_koubo_as_audio_only(self):
"""全能创作「口播:」必须改成仅音频抬头,否则模型会把口播原文烧成字幕。"""
raw = "0-3s:近景\n口播:这开俩小时车,困得眼睛都快粘一块了。"
cleaned = rewrite_speech_as_audio_only(raw)
self.assertNotIn("口播:", cleaned)
self.assertIn("人声(仅音频,由人物口型与配音表达,不出现在画面上):这开俩小时车,困得眼睛都快粘一块了。", cleaned)
prompt = enforce_no_embedded_captions(raw)
self.assertNotIn("口播:", prompt)
self.assertIn("仅音频", prompt)
self.assertTrue(prompt.startswith(NO_EMBEDDED_CAPTIONS_REQUIREMENT))
def test_audio_only_prefix_not_double_wrapped(self):
line = "人声(仅音频,由人物口型与配音表达,不出现在画面上):已经包过"
self.assertEqual(rewrite_speech_as_audio_only(line), line)
class OpeningShotDirectiveTests(SimpleTestCase):
"""实测:一条片子出四段,只有开场那段带字幕,后三段干净 —— 模型在「电商口播开场」语境下
钩子 = 打一行大字的强先验开场段单独给一条正面指令把它掰回来"""
+179 -17
View File
@@ -1,4 +1,5 @@
import logging
import re
import uuid
from django.db import transaction
@@ -21,7 +22,7 @@ from apps.products.models import Product
from .generation_errors import classify_generation_error, public_error_for_task
from .creation import append_message, sync_generating_messages
from .creation_agent import apply_confirm_params, apply_session_params, stream_creation_agent, submit_confirmed_image, submit_confirmed_video
from .creation_agent import apply_confirm_params, apply_session_params, stream_creation_agent, submit_confirmed_image, submit_confirmed_video, _sse, _message_payload
from .mentions import TYPE_LABELS, VALID_TYPES, refs_from_elicit_answers, search_mentions
from .models import AITask, CreationConversation, CreationMessage, ImageConversation, ModelConfig
from .serializers import (
@@ -38,6 +39,84 @@ from .services import enqueue_standalone_images
logger = logging.getLogger(__name__)
def _normalise_chat_choice(value: str) -> str:
return re.sub(r"[^0-9a-zA-Z一-鿿]+", "", str(value or "")).lower()
def _chat_answer_for_field(field: dict, text: str, team=None):
"""把自然输入映射回字段值;认不出时保留原话,让 Agent 自己理解。"""
raw = str(text or "").strip()
if field.get("type") == "asset" and team is not None:
answer_norm = _normalise_chat_choice(raw)
types = [item for item in (field.get("asset_types") or []) if item in TYPE_LABELS] or None
hits = search_mentions(team, q="", types=types, limit=50)
if re.search(r"(你来定|你定|你帮我定|你帮我选|帮我挑|没想好|随便|都行)", raw):
# 用户把选择权交给 Agent 时静默选一个可用素材,不把整份库甩回聊天区。
if hits:
return str(hits[0].get("name") or raw)
contained = [
hit for hit in hits
if _normalise_chat_choice(hit.get("name") or "")
and _normalise_chat_choice(hit.get("name") or "") in answer_norm
]
if contained:
# 「就用净颜精华吧」也能绑定净颜精华;同名片段优先取名字更完整的。
contained.sort(key=lambda hit: len(_normalise_chat_choice(hit.get("name") or "")), reverse=True)
return str(contained[0].get("name") or raw)
if field.get("type") != "single":
if field.get("type") == "multi":
answer_norm = _normalise_chat_choice(raw)
matched = []
for option in field.get("options") or []:
value = str(option.get("value") or "")
label = str(option.get("label") or value)
token = _normalise_chat_choice(label)
if token and token in answer_norm:
matched.append(value)
return matched or [raw]
return raw
options = [item for item in (field.get("options") or []) if isinstance(item, dict)]
answer_norm = _normalise_chat_choice(raw)
for option in sorted(
options,
key=lambda item: len(_normalise_chat_choice(item.get("label") or item.get("value") or "")),
reverse=True,
):
value = str(option.get("value") or "")
label_norm = _normalise_chat_choice(option.get("label") or value)
value_norm = _normalise_chat_choice(value)
if answer_norm in {label_norm, value_norm} or (
label_norm and label_norm in answer_norm
) or (
value_norm and value_norm in answer_norm
):
return value
# 「10」也能命中「10 秒」。同一个数字对应多个模型名时不擅自猜。
answer_digits = re.findall(r"\d+(?:\.\d+)?", raw)
numeric_matches = []
if answer_digits:
for option in options:
label = str(option.get("label") or option.get("value") or "")
if re.findall(r"\d+(?:\.\d+)?", label) == answer_digits:
numeric_matches.append(str(option.get("value") or label))
if len(numeric_matches) == 1:
return numeric_matches[0]
return raw
def _pending_chat_question(conversation: CreationConversation) -> CreationMessage | None:
"""找最近一条未回答的新式追问;卡片既可点选,也兼容直接输入。"""
candidates = conversation.messages.filter(
kind=CreationMessage.Kind.ELICIT
).order_by("-seq")[:20]
for message in candidates:
payload = message.payload or {}
if payload.get("interaction") in {"chat", "asset_picker"} and not payload.get("submitted"):
return message
return None
class GenerateImageView(APIView):
"""独立生图(不绑项目)· 图片创作/模特图/平台套图共用 —— **异步**。
@@ -1380,9 +1459,48 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
kind = str(request.data.get("kind") or "text")
text = str(request.data.get("text") or "").strip()
refs = request.data.get("refs") or []
record_user_message = True
force_creative_turn = False
continuation_instruction = ""
if not isinstance(refs, list):
return JsonResponse({"detail": "refs 必须是数组"}, status=400)
if kind == "text" and text:
pending = _pending_chat_question(conversation)
if pending is not None:
payload = dict(pending.payload or {})
fields = [item for item in (payload.get("fields") or []) if isinstance(item, dict)]
if fields:
field = fields[0]
answers = {
str(field.get("key") or "answer"):
_chat_answer_for_field(field, text, conversation.team)
}
payload["answers"] = answers
payload["submitted"] = True
payload["answered_via"] = "chat"
pending.payload = payload
pending.save(update_fields=["payload", "updated_at"])
params_changed = apply_session_params(conversation, fields, answers)
existing = {
(item.get("type"), str(item.get("id")))
for item in refs if isinstance(item, dict)
}
for extra in refs_from_elicit_answers(conversation.team, fields, answers):
mark = (extra.get("type"), str(extra.get("id")))
if mark not in existing:
refs.append(extra)
existing.add(mark)
force_creative_turn = True
continuation_instruction = (
"用户刚用输入框回答了你上一句问题。直接基于这条真实回答继续原任务;"
"不要复述答案,不要回复收到,也不要问要不要继续或要不要生成。"
)
if params_changed:
continuation_instruction += " 会话参数已更新,旧方案作废,按新参数重新产出。"
if kind == "confirm":
reply_to = str(request.data.get("reply_to") or "").strip()
card = conversation.messages.filter(
@@ -1451,22 +1569,63 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
payload["submitted"] = True
card.payload = payload
card.save(update_fields=["payload", "updated_at"])
labels = {f["key"]: f["label"] for f in payload.get("fields", [])}
text = "".join(
f"{labels.get(k, k)}{''.join(v) if isinstance(v, list) else v}"
for k, v in answers.items()
)
if apply_session_params(conversation, payload.get("fields") or [], answers):
text = f"{text}。请按新的会话参数重新写方案,旧方案作废"
# 点选商品/角色必须钉成 Ref:模型常把选项做成单选文字,前端只回 answers。
refs = list(refs)
existing = {(item.get("type"), str(item.get("id"))) for item in refs if isinstance(item, dict)}
for extra in refs_from_elicit_answers(conversation.team, payload.get("fields") or [], answers):
mark = (extra.get("type"), str(extra.get("id")))
if mark in existing:
continue
refs.append(extra)
existing.add(mark)
# 素材选择闸门:用户愿意时再展开列表;跳过则直接沿原任务继续。
if payload.get("phase") == "gate":
choice = str(answers.get("_asset_gate") or "").strip()
if choice == "send":
pending = payload.get("pending_fields") or []
pick = append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
payload={
"phase": "pick",
"fields": pending,
"submitted": False,
"answers": {},
},
)
def _gate_send_stream():
yield _sse({"type": "message", "message": _message_payload(pick)})
yield _sse({"type": "done"})
response = StreamingHttpResponse(
_gate_send_stream(), content_type="text/event-stream"
)
response["Cache-Control"] = "no-cache"
response["X-Accel-Buffering"] = "no"
return response
# 卡片本身已经记录了用户的选择。不要再伪造一条黑色用户气泡;
# 继续原任务,并明确告诉模型不要再次追问同一项素材。
text = ""
record_user_message = False
force_creative_turn = True
continuation_instruction = (
"用户刚选择暂不添加这项素材。接受这个选择,按会话里已有的需求和合理默认继续原任务。"
"先用一句自然的话承接,随后直接推进创作;不要再次追问同一素材,不要只说收到或有需要再说。"
)
else:
params_changed = apply_session_params(conversation, payload.get("fields") or [], answers)
# 点选商品/角色必须钉成 Ref:模型常把选项做成单选文字,前端只回 answers。
refs = list(refs)
existing = {(item.get("type"), str(item.get("id"))) for item in refs if isinstance(item, dict)}
for extra in refs_from_elicit_answers(conversation.team, payload.get("fields") or [], answers):
mark = (extra.get("type"), str(extra.get("id")))
if mark in existing:
continue
refs.append(extra)
existing.add(mark)
# 答案已显示在追问卡里,不再复制成一条用户消息。让 Agent 直接往下做。
text = ""
record_user_message = False
force_creative_turn = True
continuation_instruction = (
"用户已经完成刚才的选择。直接基于卡片答案继续原任务,不要复述选项,不要只回复收到。"
)
if params_changed:
continuation_instruction += " 会话参数已经更新,旧方案作废,按新参数重新产出方案。"
elif not text and not refs:
return JsonResponse({"detail": "消息不能为空"}, status=400)
@@ -1485,6 +1644,9 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
text=text,
refs=refs,
model_config=model_config,
record_user_message=record_user_message,
force_creative_turn=force_creative_turn,
continuation_instruction=continuation_instruction,
)
response = StreamingHttpResponse(stream, content_type="text/event-stream")
response["Cache-Control"] = "no-cache"
@@ -1367,3 +1367,4 @@ class QuickCreateCoordinatorTests(TestCase):