Files
yingqing/core/backend/apps/ai/creation_agent.py
T

1213 lines
53 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""全能创作 · Agent 编排循环(契约 §3/§4)。
和 script_agent.py 的根本区别:那边是「单次结构化出稿」,模型只会写脚本;
这边是**真 function calling 循环** —— 模型自己决定这一轮该反问用户、该查素材,
还是该出图/出片。
铁律(踩过就回不来的三条):
1. **视频 510 分钟,绝不在 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
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": (
"交给出片模型的完整指令:秒级分镜(每镜画面/动作/机位/光线)、口播原文、"
"风格锚点、一致性要求。已 @ 的素材会自动作为参考图附上,"
"不要在这里重复描述它们的长相。**不要写字幕相关要求。**"
),
},
},
"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 video_model_name(params: dict) -> str:
"""会话参数里的模型 label → 火山模型名。认不出就回落 2.5(最长 30 秒那档)。"""
return VIDEO_MODEL_BY_LABEL.get(str(params.get("model") or ""), 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_BY_LABEL.get(str(params.get("model") or ""), params.get("model") or None),
reference_image_ids=reference_image_ids or None,
)
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 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 not context.is_video:
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
# 出图也走确认卡:用户先看当前模型/比例/张数,点了才提交。
confirm = append_message(
context.conversation, role="assistant",
kind=CreationMessage.Kind.CONFIRM,
payload={
"kind": "image",
"label": "开始生成",
"estimated_credits": 0,
"prompt": prompt,
"submitted": False,
"params": snapshot_session_params(context.conversation),
"param_options": confirm_param_options(False),
},
)
events = [{"type": "message", "message": _message_payload(confirm)}]
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