1338 lines
58 KiB
Python
1338 lines
58 KiB
Python
"""全能创作 · Agent 编排循环(契约 §3/§4)。
|
||
|
||
和 script_agent.py 的根本区别:那边是「单次结构化出稿」,模型只会写脚本;
|
||
这边是**真 function calling 循环** —— 模型自己决定这一轮该反问用户、该查素材,
|
||
还是该出图/出片。
|
||
|
||
铁律(踩过就回不来的三条):
|
||
1. **视频 5–10 分钟,绝不在 SSE 里等。** 生成工具立刻返回 task_id,落一条
|
||
`generating` 消息,发 `task` 事件,收流。前端轮询完成后原地换成 `result`。
|
||
2. **`ask_user` 一旦被调用就中断循环。** 反问的意义是等人回答,继续跑下去
|
||
等于自问自答。
|
||
3. **一条用户消息最多计费生成一次。** 对话式会放大调用量,一句「多做几版」
|
||
能烧掉一堆积分。
|
||
|
||
SSE 事件见契约 §3。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import re
|
||
from collections.abc import Iterator
|
||
from dataclasses import dataclass
|
||
|
||
from django.core.serializers.json import DjangoJSONEncoder
|
||
from django.db import transaction
|
||
|
||
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
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 单条用户消息的循环上限。8 轮足够「查素材 → 反问 → 写方案 → 出图」,
|
||
# 再多基本是模型在原地打转。
|
||
MAX_TOOL_ROUNDS = 8
|
||
# 单条用户消息最多触发一次计费生成(契约 §4)
|
||
MAX_BILLED_GENERATIONS = 1
|
||
|
||
# 记忆压缩(契约 §5):超过这么多条消息就把最老的一批压成一段摘要,
|
||
# 只保留最近 KEEP_RECENT_MESSAGES 条原文。
|
||
COMPRESS_AFTER_MESSAGES = 24
|
||
KEEP_RECENT_MESSAGES = 12
|
||
# 攒够这么多条没压过的消息才重压一次。没有它的话,过了阈值以后**每一轮都要多花
|
||
# 一次模型调用**去重压那么两三句话 —— 长会话的成本会翻倍。
|
||
COMPRESS_MIN_BATCH = 8
|
||
|
||
FIELD_TYPES = ("single", "multi", "text", "asset")
|
||
|
||
# 顶栏下拉里的展示名 → 火山模型名。前端给的是人看的label,submit_free_video 只认真名。
|
||
VIDEO_MODEL_BY_LABEL = {
|
||
"Seedance 2.5": "doubao-seedance-2-5-260628",
|
||
"Seedance 2.0": "doubao-seedance-2-0-260128",
|
||
"Seedance 2.0 Fast": "doubao-seedance-2-0-fast-260128",
|
||
"Seedance 2.0 Mini": "doubao-seedance-2-0-mini-260615",
|
||
}
|
||
DEFAULT_VIDEO_MODEL = "doubao-seedance-2-5-260628"
|
||
IMAGE_MODEL_BY_LABEL = {
|
||
"Seedream5.0": "volcano",
|
||
"Seedream-5.0-pro": "volcano",
|
||
"YQ image2": "gpt-image",
|
||
"影擎-Image2": "gpt-image",
|
||
}
|
||
# 「智能时长」= 交给我们定,取一个口播讲得完又不烧钱的中间值
|
||
SMART_DURATION = 15
|
||
|
||
# 全能创作 video_prompt 写作规范:从专业创作 / 一键成片(口播脚本)抽硬规则,
|
||
# 适配「整段出片指令」而不是 ScriptDraft JSON。只要求口径接近,不改工具形态。
|
||
_OMNI_VIDEO_PROMPT_RULES = """
|
||
【出片脚本写法 · 对齐专业口播】
|
||
表现形式默认**口播带货**,像真人在讲刚遇到的一件事,不要详情页朗读或主播念稿。
|
||
|
||
video_prompt 必须能被出片模型直接执行,按时间轴写秒级分镜,建议结构:
|
||
- 片头写清:总时长、画幅、整体光线/色调、口播语气(口语、有停顿与立场)
|
||
- 然后按「0-3s / 3-8s / …」逐段写,每段同时写清这五项(不能省):
|
||
1. 景别(大特写/特写/近景/中景/全景;一条片里至少切两次景别)
|
||
2. 机位(平视/俯拍/仰拍/过肩/桌面视角)
|
||
3. 运镜(手持跟拍/推近/拉远/横摇/环绕/固定 —— 每段至少一个运镜词)
|
||
4. 动作(谁、哪只手、对什么、做什么;要连贯可拍,禁止「展示质感」等抽象词)
|
||
5. 信息变化(这几秒画面上多了/变了什么)
|
||
- 口播原文单独写清(可用「口播:…」),字数贴近会话时长:大约 5.0–5.7 字/秒,
|
||
15 秒约 75–85 字;太短撑不满,太长会赶。
|
||
- 钩子段画面不要「对着镜头说话」静态开场;前 15 个口播字禁止「大家好/今天分享/给你们推荐」。
|
||
- 全片只围绕一个具体情境推进一个主卖点;卖点要有看得见的证据(质地/前后变化/用法结果)。
|
||
- CTA 像跟朋友说话;禁止小黄车/立即购买/闭眼入等平台指令腔。
|
||
- **不要写字幕/花字/标题贴片/弹幕/角标/水印/购物浮层**,也不要写「无字幕」
|
||
(否定说法也容易把字画上屏)。口播只存在于声音;包装上原有印刷字除外。
|
||
- 已 @ 的角色/商品/场景参考图会自动附上,不要在 prompt 里重描长相;以图锁定性别年龄服装外形。
|
||
- 同一场戏保持地点、光线、服装连续;要换环境就明确写下一时间段切换。
|
||
"""
|
||
|
||
|
||
|
||
class AgentError(Exception):
|
||
"""Agent 循环里的业务错误,已经是可以直接给用户看的中文。"""
|
||
|
||
|
||
@dataclass
|
||
class AgentContext:
|
||
conversation: CreationConversation
|
||
user: object
|
||
model_config: ModelConfig
|
||
generations_used: int = 0
|
||
|
||
@property
|
||
def team(self):
|
||
return self.conversation.team
|
||
|
||
@property
|
||
def is_video(self) -> bool:
|
||
return self.conversation.mode == CreationConversation.Mode.VIDEO
|
||
|
||
|
||
|
||
_ASSET_PICK_LABEL = {
|
||
"product": "换成哪个商品?",
|
||
"character": "换成哪个角色?",
|
||
"model": "换成哪个模特?",
|
||
"scene": "换成哪个场景?",
|
||
}
|
||
_ASSET_PICK_PATTERNS = (
|
||
("product", re.compile(r"(改|换|修改|更换|重新选|选(一个|个)?|挑).{0,8}商品")),
|
||
("character", re.compile(r"(改|换|修改|更换|重新选|选(一个|个)?).{0,8}(角色|人物)")),
|
||
("model", re.compile(r"(改|换|修改|更换|重新选|选(一个|个)?).{0,8}模特")),
|
||
("scene", re.compile(r"(改|换|修改|更换|重新选|选(一个|个)?).{0,8}场景")),
|
||
)
|
||
|
||
|
||
def wanted_asset_pick(user_text: str, refs: list | None) -> str | None:
|
||
"""用户说「改商品」却没 @ 时,应弹出素材卡,而不是让他自己填名字。"""
|
||
if refs:
|
||
return None
|
||
text = user_text or ""
|
||
for type_, pattern in _ASSET_PICK_PATTERNS:
|
||
if pattern.search(text):
|
||
return type_
|
||
return None
|
||
|
||
|
||
|
||
VIDEO_MODELS = ["Seedance 2.5", "Seedance 2.0", "Seedance 2.0 Fast", "Seedance 2.0 Mini"]
|
||
IMAGE_MODELS = ["Seedream5.0", "YQ image2"]
|
||
RATIOS = ["16:9", "9:16", "4:3", "3:4", "1:1"]
|
||
RESOLUTIONS = ["480p", "720p", "1080p"]
|
||
VIDEO_DURATIONS = ["智能时长", "4 秒", "5 秒", "6 秒", "8 秒", "10 秒", "12 秒", "15 秒", "30 秒"]
|
||
IMAGE_COUNTS = ["1 张", "2 张", "4 张", "8 张"]
|
||
SESSION_PARAM_KEYS = ("duration", "ratio", "resolution", "video_model", "count")
|
||
_PARAM_TO_STORED = {"video_model": "model"}
|
||
|
||
_PARAM_PICK_LABEL = {
|
||
"duration": "改成多长?",
|
||
"ratio": "改成什么比例?",
|
||
"resolution": "改成什么分辨率?",
|
||
"video_model": "换成哪个模型?",
|
||
"count": "出几张?",
|
||
}
|
||
|
||
|
||
def _param_options(key: str, is_video: bool) -> list[str]:
|
||
if key == "duration":
|
||
return VIDEO_DURATIONS
|
||
if key == "ratio":
|
||
return RATIOS
|
||
if key == "resolution":
|
||
return RESOLUTIONS
|
||
if key == "count":
|
||
return IMAGE_COUNTS
|
||
return VIDEO_MODELS if is_video else IMAGE_MODELS
|
||
|
||
|
||
def session_param_fields(keys: list[str], is_video: bool) -> list[dict]:
|
||
fields = []
|
||
for key in keys[:3]:
|
||
if key not in _PARAM_PICK_LABEL:
|
||
continue
|
||
options = [{"value": item, "label": item} for item in _param_options(key, is_video)]
|
||
fields.append({
|
||
"key": key,
|
||
"label": _PARAM_PICK_LABEL[key],
|
||
"type": "single",
|
||
"required": True,
|
||
"options": options,
|
||
})
|
||
return fields
|
||
|
||
|
||
def wanted_param_keys(user_text: str, *, is_video: bool) -> list[str]:
|
||
"""用户说「改时长」「换模型」时弹出参数卡。不和「改模特」抢。"""
|
||
text = user_text or ""
|
||
keys: list[str] = []
|
||
if re.search(r"(改|换|修改|更换).{0,8}(时长|秒数)|改成\s*\d+\s*秒", text):
|
||
keys.append("duration" if is_video else "count")
|
||
if re.search(r"(改|换|修改|更换).{0,8}(比例|尺寸|画幅)", text):
|
||
keys.append("ratio")
|
||
if re.search(r"(改|换|修改|更换).{0,8}(分辨率|清晰度)", text):
|
||
keys.append("resolution")
|
||
if re.search(r"(改|换|修改|更换).{0,8}模型", text) and "模特" not in text:
|
||
keys.append("video_model")
|
||
if re.search(r"(改|换|修改|更换).{0,8}张数", text):
|
||
keys.append("count")
|
||
if not keys and re.search(r"(改|换|修改).{0,6}(参数|设置|规格)", text):
|
||
keys = ["duration", "video_model", "ratio"] if is_video else ["count", "video_model", "ratio"]
|
||
seen = set()
|
||
out = []
|
||
for key in keys:
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
out.append(key)
|
||
return out
|
||
|
||
|
||
def apply_session_params(conversation, fields, answers: dict) -> bool:
|
||
"""追问卡里选出的时长/模型等写回会话参数。返回是否有改动。"""
|
||
current = dict(conversation.params or {})
|
||
field_by_key = {str(item.get("key") or ""): item for item in (fields or []) if isinstance(item, dict)}
|
||
changed = False
|
||
for key, raw in (answers or {}).items():
|
||
field = field_by_key.get(str(key)) or {}
|
||
if field.get("type") == "asset":
|
||
continue
|
||
stored = _PARAM_TO_STORED.get(str(key), str(key))
|
||
if stored not in {"model", "ratio", "resolution", "duration", "count"}:
|
||
continue
|
||
value = "、".join(raw) if isinstance(raw, list) else str(raw or "").strip()
|
||
if not value or current.get(stored) == value:
|
||
continue
|
||
current[stored] = value
|
||
changed = True
|
||
if changed:
|
||
conversation.params = current
|
||
conversation.save(update_fields=["params", "updated_at"])
|
||
return changed
|
||
|
||
|
||
def snapshot_session_params(conversation) -> dict:
|
||
params = conversation.params or {}
|
||
return {
|
||
"model": str(params.get("model") or ""),
|
||
"resolution": str(params.get("resolution") or ""),
|
||
"ratio": str(params.get("ratio") or ""),
|
||
"duration": str(params.get("duration") or ""),
|
||
"count": str(params.get("count") or params.get("duration") or ""),
|
||
}
|
||
|
||
|
||
def confirm_param_options(is_video: bool) -> dict:
|
||
return {
|
||
"model": VIDEO_MODELS if is_video else IMAGE_MODELS,
|
||
"resolution": RESOLUTIONS if is_video else [],
|
||
"ratio": RATIOS,
|
||
"duration": VIDEO_DURATIONS if is_video else [],
|
||
"count": IMAGE_COUNTS if not is_video else [],
|
||
}
|
||
|
||
|
||
def apply_confirm_params(conversation, incoming: dict | None) -> tuple[dict, bool]:
|
||
"""确认卡上改的参数写回会话。返回 (最新 params, 视频时长是否变了)。"""
|
||
current = dict(conversation.params or {})
|
||
old_duration = str(current.get("duration") or "")
|
||
changed = False
|
||
for key, raw in (incoming or {}).items():
|
||
if key not in {"model", "ratio", "resolution", "duration", "count"}:
|
||
continue
|
||
value = str(raw or "").strip()
|
||
if not value or current.get(key) == value:
|
||
continue
|
||
current[key] = value
|
||
changed = True
|
||
duration_changed = (
|
||
conversation.mode == CreationConversation.Mode.VIDEO
|
||
and str(current.get("duration") or "") != old_duration
|
||
and bool(str(current.get("duration") or ""))
|
||
and bool(old_duration)
|
||
)
|
||
if changed:
|
||
conversation.params = current
|
||
conversation.save(update_fields=["params", "updated_at"])
|
||
return snapshot_session_params(conversation), duration_changed
|
||
|
||
|
||
# ---------------------------------------------------------------- 工具 schema
|
||
|
||
def tool_schemas(context: AgentContext) -> list[dict]:
|
||
"""给模型看的工具清单。图片会话不暴露 generate_video,反之亦然 ——
|
||
会话 mode 是定死的(契约 §0),把不该用的工具摆出来只会诱导模型走错路。"""
|
||
tools = [
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "ask_user",
|
||
"description": (
|
||
"缺少必要信息时反问用户。会在对话里渲染成可点选/可填写的卡片。"
|
||
"只在信息**确实缺失且无法合理推断**时用;能自己定的就自己定,别把用户当填表机器。"
|
||
"用户要选/改/换商品、角色、模特、场景时**必须**调这个工具,"
|
||
"type 用 asset 并填 asset_types;禁止只在气泡里问「换成哪个」。"
|
||
"一次最多问 3 项。"
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"fields": {
|
||
"type": "array",
|
||
"maxItems": 3,
|
||
"items": {
|
||
"type": "object",
|
||
"properties": {
|
||
"key": {"type": "string", "description": "英文标识,如 product / duration"},
|
||
"label": {"type": "string", "description": "问题原文,中文"},
|
||
"type": {"type": "string", "enum": list(FIELD_TYPES)},
|
||
"required": {"type": "boolean"},
|
||
"options": {
|
||
"type": "array",
|
||
"items": {
|
||
"type": "object",
|
||
"properties": {
|
||
"value": {"type": "string"},
|
||
"label": {"type": "string"},
|
||
},
|
||
"required": ["value", "label"],
|
||
},
|
||
},
|
||
"asset_types": {
|
||
"type": "array",
|
||
"items": {"type": "string", "enum": list(TYPE_LABELS)},
|
||
},
|
||
"placeholder": {"type": "string"},
|
||
},
|
||
"required": ["key", "label", "type"],
|
||
},
|
||
}
|
||
},
|
||
"required": ["fields"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "search_library",
|
||
"description": "在团队的商品库/模特库/角色/场景/资产库里找素材。用户说了名字但没 @ 时用它找回来。",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"query": {"type": "string"},
|
||
"types": {"type": "array", "items": {"type": "string", "enum": list(TYPE_LABELS)}},
|
||
},
|
||
"required": ["query"],
|
||
},
|
||
},
|
||
},
|
||
]
|
||
if context.is_video:
|
||
tools.append({
|
||
"type": "function",
|
||
"function": {
|
||
"name": "write_strategy",
|
||
"description": (
|
||
"写「创作策略理解」卡:说清这条片给谁看、他为什么会信、你想让他信什么、整体创作方向。"
|
||
"在动手写方案之前调它一次,让用户先确认你理解对了。"
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"target": {"type": "string", "description": "这条视频给谁看,要具体到人群特征"},
|
||
"trust": {"type": "string", "description": "用户为什么相信,靠什么建立可信度"},
|
||
"belief": {"type": "string", "description": "希望用户看完相信什么"},
|
||
"direction": {"type": "string", "description": "创作方向一句话,说清是什么类型的片"},
|
||
},
|
||
"required": ["target", "trust", "belief", "direction"],
|
||
},
|
||
},
|
||
})
|
||
tools.append({
|
||
"type": "function",
|
||
"function": {
|
||
"name": "write_plan",
|
||
"description": (
|
||
"写「视频最终方案」卡并请用户确认。**这是出片前的最后一步**,调完会等用户点确认,"
|
||
"确认后平台直接按 video_prompt 出片,你不会再有插话机会。"
|
||
"video_prompt 按系统里的「出片脚本写法」写成口播秒级分镜(专业创作同口径),不要只写大纲。"
|
||
"先调 write_strategy 再调它。"
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"usp": {"type": "string", "description": "主打卖点,全片只讲这一个核心价值"},
|
||
"points": {
|
||
"type": "array", "maxItems": 3, "items": {"type": "string"},
|
||
"description": "核心支撑卖点,最多 3 条",
|
||
},
|
||
"timeline": {
|
||
"type": "array",
|
||
"items": {
|
||
"type": "object",
|
||
"properties": {
|
||
"start": {"type": "number"}, "end": {"type": "number"},
|
||
"stage": {"type": "string", "description": "Hook / 过桥 / 正文 / CTA"},
|
||
"desc": {"type": "string"},
|
||
},
|
||
"required": ["start", "end", "stage"],
|
||
},
|
||
},
|
||
"voice_chars": {
|
||
"type": "array", "items": {"type": "integer"},
|
||
"description": "口播字数区间 [下限, 上限]",
|
||
},
|
||
"video_prompt": {
|
||
"type": "string",
|
||
"description": (
|
||
"交给出片模型的完整口播带货指令(对齐专业创作口径)。"
|
||
"必须含:总时长与画幅、整体光线色调、按 0-Ns 分段的秒级分镜"
|
||
"(每段写清景别/机位/运镜/具体动作/信息变化)、口播原文、一个主卖点与可见证据、口语 CTA。"
|
||
"禁止字幕/花字/贴片及「无字幕」字样;禁止详情页腔与「大家好」开场。"
|
||
"已 @ 素材会自动作参考图,勿重描长相。"
|
||
),
|
||
},
|
||
},
|
||
"required": ["usp", "video_prompt"],
|
||
},
|
||
},
|
||
})
|
||
else:
|
||
tools.append({
|
||
"type": "function",
|
||
"function": {
|
||
"name": "generate_image",
|
||
"description": (
|
||
"生成图片。prompt 必须是完整、可独立执行的画面描述(主体/动作/环境/光线/构图/风格),"
|
||
"不要写成对用户说的话。已 @ 引用的素材会自动作为参考图带上,不用在 prompt 里重复描述它们的外观。"
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"prompt": {"type": "string"},
|
||
"count": {"type": "integer", "minimum": 1, "maximum": 4},
|
||
},
|
||
"required": ["prompt"],
|
||
},
|
||
},
|
||
})
|
||
return tools
|
||
|
||
|
||
# ---------------------------------------------------------------- 工具执行
|
||
|
||
|
||
def _coerce_fields(raw) -> list[dict]:
|
||
"""把模型给的 fields 规整成契约 §2 的 Field。脏数据丢弃而不是抛 ——
|
||
模型偶尔漏个 type 不该让整条对话崩掉。"""
|
||
fields: list[dict] = []
|
||
for item in (raw or [])[:3]:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
key = str(item.get("key") or "").strip()
|
||
label = str(item.get("label") or "").strip()
|
||
type_ = str(item.get("type") or "").strip()
|
||
if not key or not label or type_ not in FIELD_TYPES:
|
||
continue
|
||
field = {
|
||
"key": key,
|
||
"label": label,
|
||
"type": type_,
|
||
"required": bool(item.get("required", True)),
|
||
}
|
||
options = [
|
||
{"value": str(o.get("value")), "label": str(o.get("label"))}
|
||
for o in (item.get("options") or [])
|
||
if isinstance(o, dict) and o.get("value") and o.get("label")
|
||
]
|
||
if type_ in ("single", "multi"):
|
||
if not options:
|
||
continue # 单选/多选没选项 = 废卡,丢掉
|
||
field["options"] = options
|
||
if type_ == "asset":
|
||
asset_types = [t for t in (item.get("asset_types") or []) if t in TYPE_LABELS]
|
||
field["asset_types"] = asset_types or list(TYPE_LABELS)
|
||
if type_ == "text":
|
||
field["placeholder"] = str(item.get("placeholder") or "")
|
||
inferred = infer_field_types(field)
|
||
# 商品/角色这类必须出素材卡,文字单选钉不上参考图
|
||
if key in SESSION_PARAM_KEYS:
|
||
fields.append(field)
|
||
continue
|
||
if len(inferred) == 1 and inferred[0] in TYPE_LABELS and type_ != "asset":
|
||
field["type"] = "asset"
|
||
field["asset_types"] = inferred
|
||
field.pop("options", None)
|
||
field.pop("placeholder", None)
|
||
fields.append(field)
|
||
return fields
|
||
|
||
|
||
|
||
|
||
def _normalize_model_label(value: str) -> str:
|
||
return "".join(ch for ch in str(value or "").lower() if ch.isalnum())
|
||
|
||
|
||
def image_model_name(params: dict) -> str | None:
|
||
"""出图模型 label → 供应商模型名;目录优先,认不出返回 None 让下游用默认。"""
|
||
from django.db.models import Q
|
||
|
||
from .models import ModelConfig
|
||
|
||
label = str(params.get("model") or "").strip()
|
||
if not label:
|
||
return None
|
||
mapped = IMAGE_MODEL_BY_LABEL.get(label)
|
||
if mapped:
|
||
return mapped
|
||
hit = (
|
||
ModelConfig.objects.filter(capability=ModelConfig.Capability.IMAGE, status=ModelConfig.Status.ACTIVE)
|
||
.filter(Q(display_name=label) | Q(name=label))
|
||
.order_by("created_at")
|
||
.first()
|
||
)
|
||
return hit.name if hit else None
|
||
|
||
|
||
def video_model_name(params: dict) -> str:
|
||
"""会话参数里的模型 label → 供应商模型名。
|
||
|
||
先认历史写死映射,再按 ModelConfig.display_name / name 查目录 —— 后台新加模型不用改代码。
|
||
"""
|
||
from django.db.models import Q
|
||
|
||
from .models import ModelConfig
|
||
|
||
label = str(params.get("model") or "").strip()
|
||
if not label:
|
||
return DEFAULT_VIDEO_MODEL
|
||
mapped = VIDEO_MODEL_BY_LABEL.get(label)
|
||
if not mapped:
|
||
norm = _normalize_model_label(label)
|
||
for k, v in VIDEO_MODEL_BY_LABEL.items():
|
||
if _normalize_model_label(k) == norm:
|
||
mapped = v
|
||
break
|
||
if mapped:
|
||
return mapped
|
||
hit = (
|
||
ModelConfig.objects.filter(capability=ModelConfig.Capability.VIDEO, status=ModelConfig.Status.ACTIVE)
|
||
.filter(Q(display_name=label) | Q(name=label))
|
||
.order_by("created_at")
|
||
.first()
|
||
)
|
||
if hit is None:
|
||
hit = (
|
||
ModelConfig.objects.filter(capability=ModelConfig.Capability.VIDEO, status=ModelConfig.Status.ACTIVE)
|
||
.filter(Q(display_name__icontains=label) | Q(name__icontains=label))
|
||
.order_by("created_at")
|
||
.first()
|
||
)
|
||
return hit.name if hit else DEFAULT_VIDEO_MODEL
|
||
|
||
|
||
def video_duration(params: dict) -> int:
|
||
"""「15 秒」→ 15;「智能时长」/ 解析不出 → SMART_DURATION。"""
|
||
raw = str(params.get("duration") or "")
|
||
digits = "".join(ch for ch in raw if ch.isdigit())
|
||
if not digits:
|
||
return SMART_DURATION
|
||
return max(4, min(int(digits), 30))
|
||
|
||
|
||
def _image_count(params: dict, raw) -> int:
|
||
"""出图张数:首页选过「N 张」就用它,否则用模型传的 count,默认 1,上限 8。"""
|
||
label = str((params or {}).get("count") or (params or {}).get("duration") or "")
|
||
if "张" in label:
|
||
digits = "".join(ch for ch in label if ch.isdigit())
|
||
if digits:
|
||
raw = digits
|
||
try:
|
||
count = int(raw or 1)
|
||
except (TypeError, ValueError):
|
||
count = 1
|
||
return max(1, min(count, 8))
|
||
|
||
|
||
def _run_search_library(context: AgentContext, args: dict) -> dict:
|
||
results = search_mentions(
|
||
context.team,
|
||
q=str(args.get("query") or "").strip(),
|
||
types=[t for t in (args.get("types") or []) if t in TYPE_LABELS] or None,
|
||
limit=5,
|
||
)
|
||
return {
|
||
"results": [
|
||
{"type": r["type"], "id": r["id"], "name": r["name"], "kind": TYPE_LABELS[r["type"]]}
|
||
for r in results
|
||
]
|
||
}
|
||
|
||
|
||
def _run_generate_image(context: AgentContext, args: dict) -> tuple[dict, list]:
|
||
"""提交出图。返回 (给模型看的结果, AITask 列表)。
|
||
|
||
出图也是异步的(worker 出图 ~30s),所以这里同样只提交不等待 —— 和视频一条路子,
|
||
前端拿 task_id 轮询 GET /api/ai/generate-image/?ids=…
|
||
"""
|
||
from .services import enqueue_standalone_images
|
||
|
||
prompt = str(args.get("prompt") or "").strip()
|
||
if not prompt:
|
||
raise AgentError("生成失败:模型没有给出画面描述")
|
||
|
||
params = context.conversation.params or {}
|
||
resolved = resolve_refs(context.team, context.conversation.pinned_refs or [])
|
||
reference_image_ids = [r["asset_id"] for r in resolved.references if r.get("asset_id")]
|
||
count = _image_count(params, args.get("count"))
|
||
|
||
tasks = enqueue_standalone_images(
|
||
team=context.team,
|
||
user=context.user,
|
||
prompt=prompt,
|
||
mode="image",
|
||
count=count,
|
||
ratio=params.get("ratio") or None,
|
||
image_model=image_model_name(params) or params.get("model") or None,
|
||
reference_image_ids=reference_image_ids or None,
|
||
feature="omni_create",
|
||
)
|
||
context.generations_used += 1
|
||
return (
|
||
{"submitted": True, "count": len(tasks), "note": "已提交生成,结果稍后回填,不要重复提交"},
|
||
list(tasks),
|
||
)
|
||
|
||
|
||
def _video_submit_params(context: AgentContext, prompt: str) -> tuple[dict, list]:
|
||
"""拼 submit_free_video 的入参。references 直接用 resolve_refs 的产物 ——
|
||
它已经排好 角色 → 场景 → 商品 的顺序,那正是出片模型 @图N 的语义依据。"""
|
||
params = context.conversation.params or {}
|
||
resolved = resolve_refs(context.team, context.conversation.pinned_refs or [])
|
||
submit = {
|
||
"prompt": prompt,
|
||
"feature": "omni_create",
|
||
"mode": "universal",
|
||
"model": video_model_name(params),
|
||
"aspect_ratio": params.get("ratio") or "9:16",
|
||
"resolution": params.get("resolution") or "720p",
|
||
"duration": video_duration(params),
|
||
"generate_audio": True,
|
||
"references": resolved.references,
|
||
}
|
||
return submit, resolved.references
|
||
|
||
|
||
def estimate_video_credits(context: AgentContext) -> int:
|
||
"""确认按钮旁的预计积分。算不出来返回 0,前端就不显示 ——
|
||
估价失败绝不能挡住出片(用户仍会在扣费环节看到真实数字)。"""
|
||
from apps.billing.pricing import quote_video_estimate
|
||
|
||
params = context.conversation.params or {}
|
||
submit, references = _video_submit_params(context, "")
|
||
model_config = ModelConfig.objects.filter(
|
||
name=submit["model"], capability=ModelConfig.Capability.VIDEO
|
||
).first()
|
||
if model_config is None:
|
||
return 0
|
||
try:
|
||
_tokens, quote = quote_video_estimate(
|
||
model_config,
|
||
aspect_ratio=submit["aspect_ratio"],
|
||
resolution=submit["resolution"],
|
||
duration=submit["duration"],
|
||
references=references,
|
||
team=context.team,
|
||
)
|
||
return int(quote.points)
|
||
except Exception: # noqa: BLE001 — 估价挂了不该挡住出片
|
||
logger.warning("omni create: video estimate failed", exc_info=True)
|
||
return 0
|
||
|
||
|
||
def estimate_image_credits(context: AgentContext) -> int:
|
||
"""出图确认卡预计积分:挂牌单价(含团队系数)逐张取整后再 × 张数,与 enqueue 逐任务预留同口径。"""
|
||
from apps.billing.pricing import quote_flat
|
||
from apps.ai.services import resolve_image_model, get_default_model
|
||
|
||
params = context.conversation.params or {}
|
||
model_name = image_model_name(params) or str(params.get("model") or "").strip() or None
|
||
model_config = resolve_image_model(model_name) if model_name else None
|
||
if model_config is None:
|
||
model_config = get_default_model(ModelConfig.Capability.IMAGE)
|
||
if model_config is None:
|
||
return 0
|
||
count = _image_count(params, None)
|
||
try:
|
||
per = quote_flat(model_config, units=1, team=context.team)
|
||
return int(per.points) * count
|
||
except Exception: # noqa: BLE001
|
||
logger.warning("omni create: image estimate failed", exc_info=True)
|
||
return 0
|
||
|
||
|
||
def submit_confirmed_video(*, conversation: CreationConversation, user, confirm_message: CreationMessage):
|
||
"""用户点了确认 → 直接按方案卡里存好的 video_prompt 出片。
|
||
|
||
**这里不再跑一轮模型**:方案已经确认过了,再让模型决定一次既费钱又可能它不调工具。
|
||
返回 (生成中消息, 错误文案),两者必有其一。
|
||
"""
|
||
from .free_video import submit_free_video
|
||
|
||
payload = confirm_message.payload or {}
|
||
prompt = str(payload.get("video_prompt") or "").strip()
|
||
if not prompt:
|
||
return None, "这条方案没有存下出片指令,请让我重新写一次方案。"
|
||
|
||
context = AgentContext(conversation=conversation, user=user, model_config=None)
|
||
submit, _references = _video_submit_params(context, prompt)
|
||
try:
|
||
task = submit_free_video(team=conversation.team, user=user, params=submit)
|
||
except ValueError as exc: # 校验类错误(时长/比例/额度),给用户看原文
|
||
return None, str(exc)
|
||
|
||
message = append_message(
|
||
conversation, role="assistant", kind=CreationMessage.Kind.GENERATING,
|
||
payload={"task_id": str(task.id), "kind": "video", "prompt": prompt}, task=task,
|
||
)
|
||
_remember_artifact(conversation, prompt, "video")
|
||
return message, ""
|
||
|
||
|
||
def submit_confirmed_image(*, conversation: CreationConversation, user, confirm_message: CreationMessage):
|
||
"""用户点了确认 → 按确认卡里存的画面描述出图。同样不跑一轮模型。"""
|
||
payload = confirm_message.payload or {}
|
||
prompt = str(payload.get("prompt") or payload.get("image_prompt") or "").strip()
|
||
if not prompt:
|
||
return None, "这条方案没有存下出图指令,请让我重新写一次。"
|
||
|
||
context = AgentContext(conversation=conversation, user=user, model_config=None)
|
||
try:
|
||
_result, tasks = _run_generate_image(context, {"prompt": prompt})
|
||
except AgentError as exc:
|
||
return None, str(exc)
|
||
except ValueError as exc:
|
||
return None, str(exc)
|
||
|
||
message = None
|
||
for task in tasks:
|
||
message = append_message(
|
||
conversation, role="assistant",
|
||
kind=CreationMessage.Kind.GENERATING,
|
||
payload={"task_id": str(task.id), "kind": "image", "prompt": prompt},
|
||
task=task,
|
||
)
|
||
if message is None:
|
||
return None, "出图没有提交成功,请再试一次。"
|
||
_remember_artifact(conversation, prompt, "image")
|
||
return message, ""
|
||
|
||
|
||
# ---------------------------------------------------------------- 提示词
|
||
|
||
|
||
|
||
def get_creation_chat_model(requested: ModelConfig | None = None) -> ModelConfig | None:
|
||
"""全能创作对话模型:走平台文本解析,DeepSeek 一律换成 Seed 2.1 Pro。"""
|
||
return resolve_text_model(requested)
|
||
|
||
|
||
def _creation_model_sees_images(model_config: ModelConfig | None) -> bool:
|
||
"""对话模型能不能收图。参考图只在能看图时才塞进 chat messages,避免纯文本模型整轮失败。"""
|
||
if model_config is None:
|
||
return False
|
||
if getattr(model_config, "capability", "") == ModelConfig.Capability.VISION:
|
||
return True
|
||
name = str(getattr(model_config, "name", "") or "").lower()
|
||
# 豆包 Seed 2.x / 1.6 文本档都支持图文;vl / vision 后缀同理。
|
||
if name.startswith("doubao-seed-") or "vision" in name or name.endswith("-vl") or "-vl-" in name:
|
||
return True
|
||
metadata = model_config.metadata if isinstance(getattr(model_config, "metadata", None), dict) else {}
|
||
capabilities = metadata.get("capabilities") if isinstance(metadata.get("capabilities"), dict) else {}
|
||
features = {str(item) for item in capabilities.get("features") or []}
|
||
return bool({"vision", "image_input", "multimodal"} & features)
|
||
|
||
|
||
def _prefer_vision_text_model(current: ModelConfig | None, team, refs: list | None) -> ModelConfig | None:
|
||
"""有参考图时,尽量换成能看图的文本模型(豆包 Seed 等),否则聊天侧完全看不见男女。"""
|
||
if current is not None and _creation_model_sees_images(current):
|
||
return current
|
||
if not _ref_image_urls(team, refs):
|
||
return current
|
||
qs = (
|
||
ModelConfig.objects.select_related("provider")
|
||
.filter(
|
||
capability=ModelConfig.Capability.TEXT,
|
||
status=ModelConfig.Status.ACTIVE,
|
||
provider__status="active",
|
||
)
|
||
.order_by("created_at")
|
||
)
|
||
for candidate in qs:
|
||
if _creation_model_sees_images(candidate):
|
||
return candidate
|
||
return get_default_model(ModelConfig.Capability.VISION) or current
|
||
|
||
|
||
def _ref_image_urls(team, refs: list | None) -> list[str]:
|
||
resolved = resolve_refs(team, refs or [])
|
||
urls: list[str] = []
|
||
seen: set[str] = set()
|
||
for item in resolved.references:
|
||
url = str(item.get("url") or "").strip()
|
||
if not url or url in seen:
|
||
continue
|
||
seen.add(url)
|
||
urls.append(url)
|
||
return urls[:6]
|
||
|
||
|
||
def _attach_ref_images(messages: list[dict], image_urls: list[str]) -> list[dict]:
|
||
"""把锁定素材图挂到最近一条 user 消息上(OpenAI image_url 格式)。"""
|
||
if not image_urls or not messages:
|
||
return messages
|
||
note = (
|
||
f"【参考图·请亲眼看】下面 {len(image_urls)} 张是用户锁定的素材。"
|
||
"人物的性别、年龄段、发型、服装必须以图为准;看不清再问用户,禁止凭文件名猜测性别。"
|
||
)
|
||
out = [dict(message) for message in messages]
|
||
index = next((i for i in range(len(out) - 1, -1, -1) if out[i].get("role") == "user"), None)
|
||
if index is None:
|
||
content = [{"type": "text", "text": note}]
|
||
content.extend({"type": "image_url", "image_url": {"url": url}} for url in image_urls)
|
||
out.append({"role": "user", "content": content})
|
||
return out
|
||
last = dict(out[index])
|
||
raw = last.get("content")
|
||
if isinstance(raw, list):
|
||
content = list(raw)
|
||
text_bits = [str(item.get("text") or "") for item in content if isinstance(item, dict) and item.get("type") == "text"]
|
||
if not any(note[:8] in bit for bit in text_bits):
|
||
content.append({"type": "text", "text": note})
|
||
existing = {
|
||
(item.get("image_url") or {}).get("url")
|
||
for item in content
|
||
if isinstance(item, dict) and item.get("type") == "image_url"
|
||
}
|
||
content.extend(
|
||
{"type": "image_url", "image_url": {"url": url}}
|
||
for url in image_urls
|
||
if url not in existing
|
||
)
|
||
else:
|
||
content = [{"type": "text", "text": f"{raw or ''}\n\n{note}".strip()}]
|
||
content.extend({"type": "image_url", "image_url": {"url": url}} for url in image_urls)
|
||
last["content"] = content
|
||
out[index] = last
|
||
return out
|
||
|
||
|
||
def build_system_prompt(context: AgentContext) -> str:
|
||
conversation = context.conversation
|
||
params = conversation.params or {}
|
||
kind = "视频" if context.is_video else "图片"
|
||
lines = [
|
||
"你是影擎「全能创作」的创作 agent,帮电商商家做短视频和商品图。",
|
||
f"本次会话产出的是**{kind}**,这一点在整个会话里不会改变 —— 用户要另一种就请他新开一个创作。",
|
||
"",
|
||
"【怎么说话】",
|
||
"- 说人话,像个有经验的同事,不要写成客服话术或需求确认清单。",
|
||
"- 不要复述用户刚说过的话,不要说「好的,我明白了」这种空句。",
|
||
"- 一次只推进一步。",
|
||
"",
|
||
"【什么时候反问】",
|
||
"- 只有信息**确实缺失且无法合理推断**时才调用 ask_user;能自己定的就自己定。",
|
||
"- 商品是谁、给谁看、什么调性 —— 这些缺了会直接影响成片,值得问。",
|
||
"- 让用户选商品/角色/模特/场景时,ask_user 必须用 type=asset 并填 asset_types。",
|
||
"- 用户说「改商品」「换角色」却没点名是哪个:立刻 ask_user 出点选卡,禁止只在气泡里问「换成哪个、填个名字」。用户不必 @。",
|
||
"- 用户说改时长/模型/比例/分辨率:立刻 ask_user,type=single 给出选项;选完后旧方案作废,必须按新参数重新 write_plan。不要让用户去点底部菜单。",
|
||
"- 光线、构图、镜头这些专业判断是你的活,不要反过来问用户。",
|
||
]
|
||
if context.is_video:
|
||
lines.extend(["", _OMNI_VIDEO_PROMPT_RULES.strip()])
|
||
# 按会话时长给出口播字数锚点(与专业创作 narration_limit 同口径)
|
||
try:
|
||
dur = video_duration(params)
|
||
except Exception: # noqa: BLE001
|
||
dur = SMART_DURATION
|
||
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 按上面的秒级分镜规范写满,不要只给大纲。")
|
||
else:
|
||
lines.extend([
|
||
"",
|
||
"【出图】",
|
||
"- 决定出图时必须调用 generate_image,不要只口头说「我这就出图」。",
|
||
"- 一次用户消息只出一轮;张数用会话已定参数,不要自己加张。",
|
||
])
|
||
if params:
|
||
meta = "、".join(f"{k}:{v}" for k, v in params.items() if v)
|
||
if meta:
|
||
lines.append(f"\n【会话已定参数】{meta}(出片按这套;用户要改就出选项卡,选完必须重写方案)")
|
||
if conversation.preset:
|
||
# 只给名字模型只能靠猜;把这个预设的拍法约束一起给它
|
||
guidance = preset_guidance(conversation.preset)
|
||
lines.append(f"\n【创作预设】{conversation.preset}")
|
||
if guidance:
|
||
lines.append(guidance)
|
||
lines.append("用户选了这个预设,就按它的拍法来;要偏离得先问过用户。")
|
||
|
||
resolved = resolve_refs(context.team, conversation.pinned_refs or [])
|
||
if resolved.facts:
|
||
lines.append("\n【本次会话已锁定的素材事实】")
|
||
lines.append(resolved.facts_text)
|
||
lines.append(
|
||
"以上素材的参考图会附给你看,出片时也会自动锁人锁物。"
|
||
"人物性别、年龄段、发型、服装、商品颜色外形必须以图为准;"
|
||
"图上看不清或没附图时,必须问用户,禁止凭文件名猜测男女。"
|
||
)
|
||
memory = conversation.memory or {}
|
||
if memory.get("summary"):
|
||
lines.append(f"\n【前情提要】{memory['summary']}")
|
||
artifacts = memory.get("artifacts") or []
|
||
if artifacts:
|
||
recent = artifacts[-3:]
|
||
lines.append("\n【本会话已生成过】")
|
||
for index, item in enumerate(recent, 1):
|
||
lines.append(f"{index}. {item.get('prompt', '')[:120]}")
|
||
lines.append(
|
||
"用户说「改成…」「换成…」时,是要在**最后一次生成**的基础上重新生成一版,"
|
||
"把改动合进完整 prompt 再调生成工具 —— 不要只写改动部分。"
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
|
||
def build_messages(context: AgentContext) -> list[dict]:
|
||
"""会话历史 → 模型消息。只喂对模型有意义的:文字、追问和它的答案、生成过什么。
|
||
策略卡/方案卡这类结构化产物压成一句话,原样塞 JSON 只会挤爆上下文。
|
||
|
||
长会话只喂最近 KEEP_RECENT_MESSAGES 条原文,更早的靠 system 里的【前情提要】
|
||
——摘要在 compress_memory() 里生成,不在这里现算。
|
||
"""
|
||
messages = [{"role": "system", "content": build_system_prompt(context)}]
|
||
history = list(context.conversation.messages.all())
|
||
if len(history) > COMPRESS_AFTER_MESSAGES:
|
||
history = history[-KEEP_RECENT_MESSAGES:]
|
||
for message in history:
|
||
if message.kind == CreationMessage.Kind.TEXT:
|
||
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", [])}
|
||
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}"})
|
||
else:
|
||
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]})"})
|
||
elif message.kind == CreationMessage.Kind.ERROR:
|
||
messages.append({"role": "assistant", "content": f"(上一次生成失败:{message.text})"})
|
||
if _creation_model_sees_images(context.model_config):
|
||
messages = _attach_ref_images(
|
||
messages,
|
||
_ref_image_urls(context.team, context.conversation.pinned_refs or []),
|
||
)
|
||
return messages
|
||
|
||
|
||
# ---------------------------------------------------------------- 流式循环
|
||
|
||
|
||
def _sse(event: dict) -> str:
|
||
"""一帧 SSE。**必须用 DjangoJSONEncoder** —— 消息里带 UUID(task 外键)和
|
||
datetime(created_at),标准 json.dumps 直接抛 TypeError,整条流当场断掉。"""
|
||
return f"data: {json.dumps(event, ensure_ascii=False, cls=DjangoJSONEncoder)}\n\n"
|
||
|
||
|
||
def _merge_tool_call_deltas(buffer: dict, deltas: list) -> None:
|
||
"""OpenAI 流式把一次 tool_call 的 arguments 拆成很多片,按 index 拼回来。
|
||
name 只在第一片出现,arguments 要逐片累加 —— 直接覆盖会只剩最后一个字符。"""
|
||
for delta in deltas or []:
|
||
if not isinstance(delta, dict):
|
||
continue
|
||
index = delta.get("index", 0)
|
||
slot = buffer.setdefault(index, {"name": "", "arguments": ""})
|
||
function = delta.get("function") or {}
|
||
if function.get("name"):
|
||
slot["name"] = function["name"]
|
||
if function.get("arguments"):
|
||
slot["arguments"] += function["arguments"]
|
||
|
||
|
||
def _parse_arguments(raw: str) -> dict:
|
||
try:
|
||
parsed = json.loads(raw or "{}")
|
||
except ValueError:
|
||
return {}
|
||
return parsed if isinstance(parsed, dict) else {}
|
||
|
||
|
||
def stream_creation_agent(
|
||
*,
|
||
conversation: CreationConversation,
|
||
user,
|
||
text: str,
|
||
refs: list[dict] | None = None,
|
||
model_config: ModelConfig | None = None,
|
||
) -> Iterator[str]:
|
||
"""一条用户消息 → SSE 流。生成器,由 StreamingHttpResponse 逐帧下发。"""
|
||
refs = refs or []
|
||
model_config = get_creation_chat_model(model_config)
|
||
if model_config is None:
|
||
yield _sse({"type": "error", "detail": "没有可用的文本模型,请先在模型库配置"})
|
||
return
|
||
|
||
context = AgentContext(conversation=conversation, user=user, model_config=model_config)
|
||
try:
|
||
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)})
|
||
model_config = _prefer_vision_text_model(model_config, conversation.team, conversation.pinned_refs or [])
|
||
context.model_config = model_config
|
||
|
||
resolved = resolve_refs(context.team, refs)
|
||
if resolved.missing:
|
||
names = "、".join(r.get("name") or "某个素材" for r in resolved.missing)
|
||
note = append_message(
|
||
conversation, role="assistant",
|
||
text=f"有几个引用的素材已经找不到了({names}),我先按其余信息继续。",
|
||
)
|
||
yield _sse({"type": "message", "message": _message_payload(note)})
|
||
|
||
# 压缩放在**建消息之前**:摘要要进这一轮的 system 提示词才有意义。
|
||
# 用户消息已经先回显了,所以这一小段等待不会看起来像卡住。
|
||
compress_memory(context)
|
||
|
||
provider = build_provider(model_config)
|
||
messages = build_messages(context)
|
||
tools = tool_schemas(context)
|
||
|
||
for _round in range(MAX_TOOL_ROUNDS):
|
||
text_buffer: list[str] = []
|
||
tool_buffer: dict = {}
|
||
for chunk in provider.chat_completion_stream(
|
||
model=model_config.name,
|
||
messages=messages,
|
||
endpoint=model_config.endpoint or "chat/completions",
|
||
extra_body={"tools": tools},
|
||
):
|
||
kind = chunk.get("type")
|
||
if kind == "reasoning":
|
||
yield _sse({"type": "reasoning", "text": chunk.get("text", "")})
|
||
elif kind == "delta":
|
||
piece = chunk.get("text", "")
|
||
text_buffer.append(piece)
|
||
yield _sse({"type": "delta", "text": piece})
|
||
elif kind == "tool_call":
|
||
_merge_tool_call_deltas(tool_buffer, chunk.get("tool_calls"))
|
||
|
||
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)})
|
||
|
||
if not calls:
|
||
pick = wanted_asset_pick(text, refs)
|
||
param_keys = wanted_param_keys(text, is_video=context.is_video)
|
||
fields = None
|
||
if pick:
|
||
fields = [{
|
||
"key": pick,
|
||
"label": _ASSET_PICK_LABEL[pick],
|
||
"type": "asset",
|
||
"required": True,
|
||
"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})
|
||
for event in result.get("_events", []):
|
||
yield _sse(event)
|
||
break
|
||
|
||
messages.append({
|
||
"role": "assistant",
|
||
"content": said or None,
|
||
"tool_calls": [
|
||
{"id": f"call_{i}", "type": "function",
|
||
"function": {"name": c["name"], "arguments": c["arguments"]}}
|
||
for i, c in enumerate(calls)
|
||
],
|
||
})
|
||
|
||
stop = False
|
||
for index, call in enumerate(calls):
|
||
name = call["name"]
|
||
args = _parse_arguments(call["arguments"])
|
||
yield _sse({"type": "tool", "id": name, "label": _TOOL_LABELS.get(name, name), "status": "running"})
|
||
try:
|
||
result, stop_after = _dispatch_tool(context, name, args)
|
||
except AgentError as exc:
|
||
yield _sse({"type": "tool", "id": name, "label": _TOOL_LABELS.get(name, name), "status": "error"})
|
||
failure = append_message(
|
||
conversation, role="assistant",
|
||
kind=CreationMessage.Kind.ERROR, text=str(exc),
|
||
)
|
||
yield _sse({"type": "message", "message": _message_payload(failure)})
|
||
yield _sse({"type": "done"})
|
||
return
|
||
yield _sse({"type": "tool", "id": name, "label": _TOOL_LABELS.get(name, name), "status": "done"})
|
||
for event in result.get("_events", []):
|
||
yield _sse(event)
|
||
messages.append({
|
||
"role": "tool",
|
||
"tool_call_id": f"call_{index}",
|
||
"content": json.dumps(result.get("payload", {}), ensure_ascii=False),
|
||
})
|
||
stop = stop or stop_after
|
||
if stop:
|
||
break
|
||
|
||
yield _sse({"type": "done"})
|
||
except Exception as exc: # noqa: BLE001 — SSE 里任何未捕获异常都会变成前端「白屏卡死」
|
||
logger.exception("creation agent stream failed: %s", exc)
|
||
yield _sse({"type": "error", "detail": "生成过程出错了,请再试一次"})
|
||
|
||
|
||
_TOOL_LABELS = {
|
||
"ask_user": "向你确认",
|
||
"search_library": "查找素材",
|
||
"generate_image": "生成图片",
|
||
"write_strategy": "梳理创作策略",
|
||
"write_plan": "编排视频方案",
|
||
}
|
||
|
||
|
||
def _dispatch_tool(context: AgentContext, name: str, args: dict) -> tuple[dict, bool]:
|
||
"""执行一个工具。返回 (结果, 是否中断循环)。
|
||
|
||
结果里的 `_events` 会原样转发给前端,`payload` 回喂给模型。
|
||
"""
|
||
if name == "ask_user":
|
||
fields = _coerce_fields(args.get("fields"))
|
||
if not fields:
|
||
return {"payload": {"error": "fields 不合法,请重新组织问题"}}, False
|
||
message = append_message(
|
||
context.conversation, role="assistant",
|
||
kind=CreationMessage.Kind.ELICIT,
|
||
payload={"fields": fields, "submitted": False, "answers": {}},
|
||
)
|
||
# 反问一旦发出就必须停,等人回答。继续跑等于自问自答。
|
||
return {
|
||
"payload": {"asked": True},
|
||
"_events": [{"type": "message", "message": _message_payload(message)}],
|
||
}, True
|
||
|
||
if name == "search_library":
|
||
return {"payload": _run_search_library(context, args)}, False
|
||
|
||
if name == "write_strategy":
|
||
message = append_message(
|
||
context.conversation, role="assistant",
|
||
kind=CreationMessage.Kind.STRATEGY,
|
||
payload={key: str(args.get(key) or "") for key in ("target", "trust", "belief", "direction")},
|
||
)
|
||
# 策略卡只是「我理解对了吗」,不打断 —— 模型接着就该写方案
|
||
return {
|
||
"payload": {"written": True},
|
||
"_events": [{"type": "message", "message": _message_payload(message)}],
|
||
}, False
|
||
|
||
if name == "write_plan":
|
||
video_prompt = str(args.get("video_prompt") or "").strip()
|
||
if not video_prompt:
|
||
return {"payload": {"error": "video_prompt 不能为空,请把完整出片指令写进去"}}, False
|
||
plan_payload = {
|
||
"usp": str(args.get("usp") or ""),
|
||
"points": [str(p) for p in (args.get("points") or [])][:3],
|
||
"timeline": args.get("timeline") or [],
|
||
"voice_chars": args.get("voice_chars") or [],
|
||
"ref_count": len(context.conversation.pinned_refs or []),
|
||
}
|
||
events = []
|
||
plan = append_message(
|
||
context.conversation, role="assistant",
|
||
kind=CreationMessage.Kind.PLAN, payload=plan_payload,
|
||
)
|
||
events.append({"type": "message", "message": _message_payload(plan)})
|
||
|
||
prompt_file = append_message(
|
||
context.conversation, role="assistant",
|
||
kind=CreationMessage.Kind.PROMPT_FILE,
|
||
payload={"title": "视频生成Prompt.md", "body": video_prompt,
|
||
"ref_count": plan_payload["ref_count"]},
|
||
)
|
||
events.append({"type": "message", "message": _message_payload(prompt_file)})
|
||
|
||
credits = estimate_video_credits(context)
|
||
# video_prompt 存在确认卡里:用户点确认后直接照它出片,不再跑一轮模型
|
||
confirm = append_message(
|
||
context.conversation, role="assistant",
|
||
kind=CreationMessage.Kind.CONFIRM,
|
||
payload={
|
||
"kind": "video",
|
||
"label": "开始生成",
|
||
"estimated_credits": credits,
|
||
"video_prompt": video_prompt,
|
||
"submitted": False,
|
||
"params": snapshot_session_params(context.conversation),
|
||
"param_options": confirm_param_options(True),
|
||
},
|
||
)
|
||
events.append({"type": "message", "message": _message_payload(confirm)})
|
||
events.append({"type": "credits", "estimated": credits})
|
||
# 方案卡写着「仅需确认一次」—— 停在这里等人点,别自己往下出片
|
||
return {"payload": {"awaiting_confirmation": True}, "_events": events}, True
|
||
|
||
if name == "generate_image":
|
||
if context.generations_used >= MAX_BILLED_GENERATIONS:
|
||
# 一条用户消息只计费一次。模型想连出好几版时在这里挡住。
|
||
return {"payload": {"error": "本轮已经生成过一次了,请让用户看过再决定要不要改"}}, True
|
||
prompt = str(args.get("prompt") or "").strip()
|
||
if not prompt:
|
||
return {"payload": {"error": "生成失败:模型没有给出画面描述"}}, False
|
||
# 出图也走确认卡:用户先看当前模型/比例/张数,点了才提交。
|
||
credits = estimate_image_credits(context)
|
||
confirm = append_message(
|
||
context.conversation, role="assistant",
|
||
kind=CreationMessage.Kind.CONFIRM,
|
||
payload={
|
||
"kind": "image",
|
||
"label": "开始生成",
|
||
"estimated_credits": credits,
|
||
"prompt": prompt,
|
||
"submitted": False,
|
||
"params": snapshot_session_params(context.conversation),
|
||
"param_options": confirm_param_options(False),
|
||
},
|
||
)
|
||
events = [
|
||
{"type": "message", "message": _message_payload(confirm)},
|
||
{"type": "credits", "estimated": credits},
|
||
]
|
||
return {"payload": {"awaiting_confirmation": True}, "_events": events}, True
|
||
|
||
return {"payload": {"error": f"未知工具 {name}"}}, False
|
||
|
||
|
||
def _summarize_source(messages: list[CreationMessage]) -> str:
|
||
"""要压缩的那批消息 → 喂给模型的纯文本。只取有信息量的部分。"""
|
||
lines = []
|
||
for message in messages:
|
||
if message.kind == CreationMessage.Kind.TEXT and message.text.strip():
|
||
who = "用户" if message.role == "user" else "我"
|
||
lines.append(f"{who}:{message.text.strip()}")
|
||
elif message.kind == CreationMessage.Kind.ELICIT:
|
||
answers = (message.payload or {}).get("answers") or {}
|
||
if answers:
|
||
lines.append("用户确认:" + ";".join(f"{k}={v}" for k, v in answers.items()))
|
||
elif message.kind in (CreationMessage.Kind.GENERATING, CreationMessage.Kind.RESULT):
|
||
prompt = (message.payload or {}).get("prompt") or ""
|
||
if prompt:
|
||
lines.append(f"我生成了一版:{prompt[:120]}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def compress_memory(context: AgentContext) -> None:
|
||
"""把早期消息压成一段摘要存进 conversation.memory.summary(契约 §5)。
|
||
|
||
只在消息数超过阈值时做;压缩失败**静默跳过** —— 摘要是锦上添花,
|
||
为它把整条对话打断不值得。压缩额外调一次模型,所以按 summarized_upto
|
||
记进度,同一批消息不重复压。
|
||
"""
|
||
conversation = context.conversation
|
||
history = list(conversation.messages.all())
|
||
if len(history) <= COMPRESS_AFTER_MESSAGES:
|
||
return
|
||
memory = dict(conversation.memory or {})
|
||
cutoff = len(history) - KEEP_RECENT_MESSAGES
|
||
if cutoff - int(memory.get("summarized_upto") or 0) < COMPRESS_MIN_BATCH:
|
||
return # 没压过的还不够一批,攒着 —— 每轮重压一次太贵
|
||
|
||
source = _summarize_source(history[:cutoff])
|
||
if not source.strip():
|
||
memory["summarized_upto"] = cutoff
|
||
conversation.memory = memory
|
||
conversation.save(update_fields=["memory", "updated_at"])
|
||
return
|
||
|
||
previous = memory.get("summary") or ""
|
||
instruction = (
|
||
"把下面这段创作对话压成一段中文摘要,150 字以内。"
|
||
"保留:用户明确提过的要求和否决过的方向、已确认的设定、生成过什么。"
|
||
"丢掉:寒暄、过程性的话。直接输出摘要正文,不要前言。\n\n"
|
||
+ (f"【已有摘要】{previous}\n\n" if previous else "")
|
||
+ f"【新增对话】\n{source}"
|
||
)
|
||
try:
|
||
provider = build_provider(context.model_config)
|
||
pieces = []
|
||
for chunk in provider.chat_completion_stream(
|
||
model=context.model_config.name,
|
||
messages=[{"role": "user", "content": instruction}],
|
||
endpoint=context.model_config.endpoint or "chat/completions",
|
||
):
|
||
if chunk.get("type") == "delta":
|
||
pieces.append(chunk.get("text", ""))
|
||
summary = "".join(pieces).strip()
|
||
except Exception: # noqa: BLE001 — 摘要失败不该打断对话
|
||
logger.warning("omni create: memory compression failed", exc_info=True)
|
||
return
|
||
if not summary:
|
||
return
|
||
memory["summary"] = summary[:400]
|
||
memory["summarized_upto"] = cutoff
|
||
conversation.memory = memory
|
||
conversation.save(update_fields=["memory", "updated_at"])
|
||
|
||
|
||
def _remember_artifact(conversation: CreationConversation, prompt: str, kind: str) -> None:
|
||
"""记进产物索引,让下一轮「把背景换成夜景」能定位到这一版(契约 §5)。"""
|
||
memory = dict(conversation.memory or {})
|
||
artifacts = list(memory.get("artifacts") or [])
|
||
artifacts.append({"prompt": prompt, "kind": kind})
|
||
memory["artifacts"] = artifacts[-10:]
|
||
conversation.memory = memory
|
||
conversation.save(update_fields=["memory", "updated_at"])
|
||
|
||
|
||
def _message_payload(message: CreationMessage) -> dict:
|
||
from .serializers import CreationMessageSerializer
|
||
|
||
return CreationMessageSerializer(message).data
|