Files
yingqing/core/backend/apps/ai/views.py
T
Azmat@qq.com 0bd1db6bf9 模特库标签分页与全能创作收口:藏长视频、选择器分页
角色库导入打标签并支持筛选;模特库与全能创作角色/商品选择改为每页 20 条分页。临时限制成片 ≤60 秒,过滤对话里的超长时长选项,并收拢本地全能创作与后台用户相关修复。
2026-09-21 16:24:37 +08:00

2976 lines
152 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.
import logging
import threading
import re
import uuid
from django.conf import settings
from django.db import transaction
from django.http import JsonResponse
from django.db.models import Count, Exists, OuterRef, Q
from django.utils import timezone
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
from apps.assets.models import Asset, Model as AssetModel
from apps.assets.serializers import AssetFileSerializer, AssetSerializer
from apps.common.api import TeamScopedViewSetMixin, get_current_team
from apps.common.celery_health import require_worker, require_worker_task
from apps.products.models import Product
from .generation_errors import classify_generation_error, public_error_for_task
from .creation import (
AGENT_BUSY_DETAIL,
append_message,
begin_agent_planning,
cleanup_stale_agent_planning,
finish_agent_planning,
pin_refs,
request_agent_cancel,
start_segmented_video_merge,
sync_generating_messages,
team_agent_busy,
)
from .creation_agent import (
_ASSET_CARD_LABELS,
_RESTART_CONTINUATION,
apply_cast_relation_choice,
apply_click_swap_mode,
append_person_source_gate,
apply_pain_point_direction,
apply_confirm_params,
apply_restart_intent,
apply_session_params,
emit_prompt_gate,
emit_final_confirm_gate,
locked_product_references,
set_plot_twist_story_depth,
is_greeting,
is_pain_point_conversation,
is_pain_point_direction_payload,
is_restart_intent,
restore_gated_step_after_cancel,
set_video_gate_stage,
submit_confirmed_image,
submit_confirmed_video,
submit_generated_person_reference,
)
from .tasks import run_creation_agent_turn_task
from .mentions import TYPE_LABELS, VALID_TYPES, refs_from_elicit_answers, search_mentions
from .models import AITask, CreationConversation, CreationMessage, ImageConversation, ModelConfig
from .serializers import (
AITaskSerializer,
CreationConversationDetailSerializer,
CreationConversationSerializer,
CreationMessageSerializer,
ImageConversationSerializer,
ImageConversationTrashSerializer,
ModelConfigSerializer,
)
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
_WANTS_CARD_RE = re.compile(
r"(发|打开|展示|看看|看下|给我|发我|发来).{0,10}(卡片|列表|商品库|素材库|库|选项)?"
r"|(卡片|列表).{0,10}(选|选择|看看|看下)?"
r"|^(需要|要|需要的|要的|可以|行|好|好的|发|发我|发来|发给我|发出来|打开|打开列表|看下|看下列表|发列表|选商品|选素材|你发吧|好的发吧|发吧|发给我选)[吧呀呢啊!!.。 ]*$",
re.IGNORECASE,
)
_WANTS_PRODUCT_PICKER_RE = re.compile(
r"(?:商品|产品).{0,8}(?:列表|库)|(?:列表|库).{0,8}(?:商品|产品)",
re.IGNORECASE,
)
# 步骤确认卡下,用户往往不会去点「按这个继续」,而是自然地回一句
# 「这个还行」「就这样」「没问题」。这些是确认,不应被误判成修改意见。
_STEP_TEXT_CONFIRM_RE = re.compile(
r"^(?:"
r"好(?:的)?|行|可以(?:了)?|没问题|确认|继续(?:下一步)?|"
r"就这样(?:吧)?|按这个(?:继续|来|做)?|就按这个(?:继续|来|做)?|"
r"(?:这(?:个|样|版|种)?|方案|策略|prompt)(?:还行|可以|挺好|不错|没问题)(?:吧)?"
r")(?:继续(?:下一步)?)?$",
re.IGNORECASE,
)
def _typed_step_confirm_action(text: str) -> str:
"""把对着步骤卡输入的自然语言收成「继续」或「修改」。"""
compact = re.sub(r"[\s,,。.!??、~~…]+", "", str(text or "").lower())
return "confirm" if _STEP_TEXT_CONFIRM_RE.fullmatch(compact) else "revise"
def _pending_chat_question(conversation: CreationConversation) -> CreationMessage | None:
"""找最近一条未回答的新式追问;卡片既可点选,也兼容直接输入。"""
candidates = conversation.messages.filter(
kind=CreationMessage.Kind.ELICIT
).order_by("-seq")[:20]
for message in candidates:
payload = message.payload or {}
if not payload.get("submitted"):
return message
return None
def _open_product_picker(
conversation: CreationConversation,
user_text: str,
pending: CreationMessage | None = None,
):
"""用户要求商品列表时,直接返回可点击的商品卡。"""
if pending is not None and not bool((pending.payload or {}).get("submitted")):
payload = dict(pending.payload or {})
payload.update({"submitted": True, "superseded_by": "product_picker"})
pending.payload = payload
pending.save(update_fields=["payload", "updated_at"])
user_message = append_message(conversation, role="user", text=user_text)
field = {
"key": "product",
"label": _ASSET_CARD_LABELS.get("product", "选择要推广的商品"),
"type": "asset",
"required": True,
"asset_types": ["product"],
}
picker = append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text=field["label"],
payload={
"interaction": "asset_picker",
"phase": "pick",
"fields": [field],
"submitted": False,
"answers": {},
},
)
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
return JsonResponse({
"conversation_id": str(conversation.id),
"agent_status": conversation.agent_status,
"messages": [
CreationMessageSerializer(user_message).data,
CreationMessageSerializer(picker).data,
],
}, status=200)
def _plot_twist_depth_continuation(depth: dict) -> str:
"""让故事深度卡的回答直接进入对应的创作分支。"""
if depth.get("value") == "smart":
return (
"用户选择智能推荐。先根据商品卖点、已有素材和剧情空间推荐 15、30 或 60 秒其中一个,"
"说明一句推荐理由,再调用 ask_user 让用户最终选择实际时长;未确认实际时长前不要给剧情方向、策略或方案。"
)
return (
f"用户已选择:{depth['label']}。立刻按这个故事深度给出 3 个明显不同的剧情方向,"
"每个方向必须写人物关系、开场冲突、商品如何进入剧情、商品承担的作用、最终反转、情绪和偏故事/偏转化;"
"然后调用 ask_user 让用户点击选择或输入自己的想法。不得按默认 15 秒偷换结构。"
)
def _plot_twist_direction_continuation(
conversation: CreationConversation,
payload: dict,
choice: str,
) -> str:
"""保存方向卡的真实内容,再让模型进入策略步骤。"""
direction = next(
(
item for item in (payload.get("directions") or [])
if isinstance(item, dict)
and (str(item.get("id") or "") == choice or str(item.get("title") or "") == choice)
),
None,
)
if isinstance(direction, dict):
title = str(direction.get("title") or choice).strip()
conflict = str(direction.get("conflict") or "").strip()
product_role = str(direction.get("product_role") or "").strip()
reversal = str(direction.get("reversal") or "").strip()
tone = str(direction.get("tone") or "").strip()
detail = "".join(
part for part in (conflict, product_role, reversal, tone) if part
)
payload_dir = {
"id": str(direction.get("id") or "").strip(),
"title": title,
"conflict": conflict,
"product_role": product_role,
"reversal": reversal,
"tone": tone,
}
else:
title = choice.strip() or "用户自定义方向"
conflict = product_role = reversal = tone = ""
detail = title
payload_dir = {
"id": "",
"title": title,
"conflict": "",
"product_role": "",
"reversal": "",
"tone": "",
}
memory = dict(conversation.memory or {})
memory["plot_twist_story_direction"] = title
memory["plot_twist_story_direction_detail"] = detail
memory["plot_twist_story_direction_payload"] = payload_dir
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
return (
f"用户已选择剧情方向【{title}】。方向细节:{detail}。"
"现在只调用 write_strategy 写创作策略卡,策略的创作方向与后续方案/分镜必须严格沿用该冲突、商品作用与反转,"
"不得改写成另一套常见带货故事;不要再展示方向卡、不要复述选择、不要直接写方案或出片。"
)
def _store_click_swap_sequence(conversation: CreationConversation, value: str) -> str:
sequence = str(value or "").strip()
memory = dict(conversation.memory or {})
memory["click_swap_ready"] = True
memory["click_swap_sequence"] = sequence
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
return (
f"商家已确认点击换款顺序:【{sequence}】。"
"严格使用固定机位、同一背景和同一商品中心位置,每次由一根手指清晰点击后原位切换到下一款。"
"现在只调用 write_strategy 写创作策略;禁止改成口播、剧情、换场景或普通使用演示。"
)
def _mark_product_source_resolved(conversation: CreationConversation) -> None:
"""用户选择跳过、自动推荐或直接描述商品后,不重复弹同一个商品闸门。"""
memory = dict(conversation.memory or {})
memory["product_source_resolved"] = True
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
def _is_product_like_ref(ref: dict) -> bool:
"""商品库商品,或本地上传的商品图(排除人物角色/模特)。"""
if not isinstance(ref, dict):
return False
type_ = str(ref.get("type") or "").strip()
ref_id = str(ref.get("id") or "").strip()
if not ref_id:
return False
if type_ == "product":
return True
if type_ != "asset":
return False
category = str(ref.get("category") or "").strip().lower()
return category not in {"character", "person", "model"}
def _dedupe_refs(refs: list[dict]) -> list[dict]:
out: list[dict] = []
seen: set[tuple[str, str]] = set()
for ref in refs or []:
if not _is_product_like_ref(ref):
continue
mark = (str(ref.get("type")), str(ref.get("id")))
if mark in seen:
continue
seen.add(mark)
out.append(ref)
return out
def _session_product_refs(conversation: CreationConversation, request_refs=None) -> list[dict]:
"""优先收集本会话已上传/已锁定的商品图,避免「你来推荐」盲取商品库最新一条。"""
buckets: list[list] = [list(request_refs or []), list(locked_product_references(conversation))]
recent = (
conversation.messages.filter(role=CreationMessage.Role.USER)
.order_by("-seq")[:12]
)
for message in recent:
buckets.append(list(message.refs or []))
merged: list[dict] = []
for bucket in buckets:
merged.extend(bucket)
return _dedupe_refs(merged)
def _ref_display_name(ref: dict) -> str:
name = str(ref.get("name") or "").split(" · ")[0].strip()
if name and not name.lower().endswith((".png", ".jpg", ".jpeg", ".webp", ".gif")):
return name
return "已上传商品图"
def _auto_pick_product_continuation(conversation: CreationConversation, request_refs=None):
"""「你来推荐」:有本地上传/已锁定商品图时用它们;否则才回落到商品库检索。"""
preferred = _session_product_refs(conversation, request_refs)
if preferred:
pin_refs(conversation, preferred)
names = "、".join(_ref_display_name(item) for item in preferred[:6])
return preferred, (
f"用户已提供商品参考图({names})。必须基于这些上传图推进创作,"
"禁止改用商品库里的其他商品(例如库里最新一条)。"
"若品牌或具体品名仍不明确,先确认品牌与品名,再继续方案。"
)
hits = search_mentions(conversation.team, q="", types=["product"], limit=1)
if hits:
pin_refs(conversation, hits)
return hits, (
f"用户希望你帮忙挑选素材,会话里尚无本地上传商品图,已从商品库选定【{hits[0]['name']}】。"
"直接基于该素材推进创作方案,不要复述选项,不要重复追问。"
)
return [], (
"用户希望你帮忙定素材,目前会话无本地上传商品图,商品库也暂无可用商品。"
"请结合所选预设与合理电商默认设想一款匹配的商品继续推进创作方案,不要重复追问。"
)
_STEP_CONTINUE_INSTRUCTIONS = {
"strategy": (
"用户已确认创作策略。现在只调用 write_plan 写方案卡并存档 video_prompt;"
"超过 60 秒时只写紧凑章节稿(章节结构 + 每个 30 秒段关键镜头 + 交接状态),不要写制作级长文。"
"不要再写策略,不要调用 write_prompt,不要出片。"
),
"plan": (
"用户已确认视频方案。调用 write_prompt 整理后台出片指令并给出积分确认卡;"
"超过 60 秒时长优先沿用方案里已存的 video_prompt,只补简短全片规则,不要重写成短片密度的制作长文。"
"不要重写策略/方案,也不要直接出片。"
),
"prompt": (
"用户已确认后台出片指令。不要再写策略/方案;"
"平台会出积分确认卡,等用户点开始生成。"
),
}
_STEP_REVISE_INSTRUCTIONS = {
"strategy": (
"用户要求修改创作策略。根据反馈只重新调用 write_strategy 写一版修订策略;"
"写完即停,不要同轮 write_plan / write_prompt。"
),
"plan": (
"用户要求修改视频方案。根据反馈只重新调用 write_plan 写一版修订方案并更新 video_prompt;"
"超过 60 秒时仍写紧凑章节稿,不要写制作级长文。写完即停,不要同轮 write_prompt 或出片。"
),
"prompt": (
"用户要求修改出片细节。根据反馈只重新调用 write_prompt 整理一版修订指令;"
"长视频不要把逐镜再扩写成短片密度。完成后给出积分确认卡,不要直接出片。"
),
}
def _handle_step_confirm_answer(
conversation: CreationConversation,
*,
card: CreationMessage,
answers: dict,
user,
) -> tuple[JsonResponse | None, bool, str]:
"""处理步骤确认卡。返回 (短路径响应|None, force_creative, continuation_instruction)。"""
payload = dict(card.payload or {})
step = str(payload.get("step") or "").strip()
action = str(answers.get("step_action") or answers.get("action") or "").strip().lower()
feedback = str(answers.get("feedback") or "").strip()
# 兼容前端只传 confirm/revise 作为 answers 值
if action not in {"confirm", "revise"}:
raw = " ".join(str(v) for v in answers.values()).lower()
if "revise" in raw or "改" in raw:
action = "revise"
else:
action = "confirm"
payload["answers"] = {
"step_action": action,
**({"feedback": feedback} if feedback else {}),
}
payload["submitted"] = True
card.payload = payload
card.save(update_fields=["payload", "updated_at"])
if action == "revise":
set_video_gate_stage(conversation, step if step in {"strategy", "plan", "prompt"} else "clarify")
instruction = _STEP_REVISE_INSTRUCTIONS.get(
step,
"用户要求修改上一步产出。只重写被指出的那一步,不要跳到后续闸门。",
)
if feedback:
instruction = f"{instruction} 用户反馈:{feedback}"
return None, True, instruction
# confirm
if step == "strategy":
# 仍标 strategy,但打上已确认标记;write_plan 落库时会切到 plan
memory = dict(conversation.memory or {})
memory["stage"] = "strategy"
memory["strategy_confirmed"] = True
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
return None, True, _STEP_CONTINUE_INSTRUCTIONS["strategy"]
if step == "plan":
prompt_messages = emit_prompt_gate(conversation)
if not prompt_messages:
# 兼容旧会话或缓存丢失:让 agent 补齐出片指令,再交给用户确认。
return None, True, _STEP_CONTINUE_INSTRUCTIONS["plan"]
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
body = {
"conversation_id": str(conversation.id),
"agent_status": conversation.agent_status,
"messages": [CreationMessageSerializer(message).data for message in prompt_messages],
}
return JsonResponse(body, status=200), False, ""
if step == "prompt":
confirm = emit_final_confirm_gate(conversation)
if confirm is None:
return None, True, _STEP_CONTINUE_INSTRUCTIONS["prompt"]
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
credits = int((confirm.payload or {}).get("estimated_credits") or 0)
body = {
"conversation_id": str(conversation.id),
"agent_status": conversation.agent_status,
"messages": [CreationMessageSerializer(confirm).data],
}
if credits:
body["estimated_credits"] = credits
return JsonResponse(body, status=200), False, ""
# 未知 step:当普通继续
return None, True, "用户已确认上一步。继续推进创作,不要复述确认。"
class GenerateImageView(APIView):
"""独立生图(不绑项目)· 图片创作/模特图/平台套图共用 —— **异步**。
POST /api/ai/generate-image/ 提交生成,秒级返回 RESERVED 任务列表(慢出图交给 worker)。
GET /api/ai/generate-image/?ids=… 轮询这些任务的状态;成功的任务带回成图 asset。
"""
def post(self, request):
require_worker() # 异步出图依赖 worker 兜底执行,没 worker 直接拒绝(否则任务永远 RESERVED)
prompt = str(request.data.get("prompt") or "").strip()
if not prompt:
return Response({"detail": "prompt 不能为空"}, status=status.HTTP_400_BAD_REQUEST)
mode = str(request.data.get("mode") or "image")
try:
count = int(request.data.get("count") or 1)
except (TypeError, ValueError):
count = 1
product_id = str(request.data.get("product_id") or "").strip() or None
reference_product = bool(request.data.get("reference_product"))
model_id = str(request.data.get("model_id") or "").strip() or None
model_entity_id = str(request.data.get("model_entity_id") or "").strip() or None
ratio = str(request.data.get("ratio") or "").strip() or None
image_model = str(request.data.get("image_model") or "").strip() or None
# 平台套图:前端传规范化平台 id(taobao/douyin/…),用于后端注入平台版式块(优化版)
platform_id = str(request.data.get("platform_id") or "").strip() or None
conversation_id = str(request.data.get("conversation_id") or "").strip() or None
# 重跑/补图:前端带原批次 batch_id → enqueue 沿用(UUID 校验),记录归回原批次不裂新卡
batch_id = str(request.data.get("batch_id") or "").strip() or None
retry_of_task_id = str(request.data.get("retry_of_task_id") or "").strip() or None
# 用户在图片创作里上传的参考图(已先传成 Asset),按 id 列表带入 → 生成时作多图参考(image_edit)
raw_refs = request.data.get("reference_image_ids") or []
if isinstance(raw_refs, str):
raw_refs = [s for s in raw_refs.split(",") if s.strip()]
reference_image_ids = [str(r).strip() for r in raw_refs if str(r).strip()]
team = get_current_team(request.user)
if product_id:
try:
normalized_product_id = str(uuid.UUID(product_id))
except (TypeError, ValueError, AttributeError):
return Response({"detail": "商品 ID 无效"}, status=status.HTTP_400_BAD_REQUEST)
if not Product.objects.filter(team=team, id=normalized_product_id).exists():
return Response({"detail": "商品不存在或不属于当前团队"}, status=status.HTTP_400_BAD_REQUEST)
product_id = normalized_product_id
# 对话归属:传了 id 就用现成对话(限本团队);没传则自动开一条新对话,标题取 prompt 前 24 字。
conversation = None
if conversation_id:
conversation = ImageConversation.objects.filter(
team=team, id=conversation_id, is_deleted=False, purged_at__isnull=True
).first()
# 合法重跑/补图必须能在当前对话里找到原批次;归属强制读取原批次,不信任当前路由或客户端值。
original_batch_task = None
if conversation is not None and batch_id:
candidates = AITask.objects.filter(
team=team,
conversation=conversation,
is_deleted=False,
purged_at__isnull=True,
request_payload__batch_id=batch_id,
).order_by("created_at")
original_batch_task = next(
(task for task in candidates if not (task.request_payload or {}).get("batch_append")),
None,
)
if original_batch_task is not None:
product_id = str((original_batch_task.request_payload or {}).get("product_id") or "").strip() or None
else:
# 任意 UUID 不能伪装成可追加批次;降级为普通新批次。
batch_id = None
retry_of_task_id = None
elif batch_id:
batch_id = None
retry_of_task_id = None
# 普通新批次不能写入其他商品/通用范围的旧会话;竞态或旧客户端出现错配时自动开正确范围会话。
if conversation is not None:
same_mode = conversation.mode == mode
same_product = str(conversation.product_id or "") == str(product_id or "")
if not same_mode or (mode == ImageConversation.Mode.IMAGE and original_batch_task is None and not same_product):
conversation = None
if conversation is None:
conversation = ImageConversation.objects.create(
team=team,
created_by=request.user,
mode=mode if mode in dict(ImageConversation.Mode.choices) else ImageConversation.Mode.IMAGE,
product_id=product_id,
title=(prompt[:24] or "默认创作"),
)
try:
tasks = enqueue_standalone_images(team=team, user=request.user, prompt=prompt, mode=mode, count=count, product_id=product_id, reference_product=reference_product, model_id=model_id, model_entity_id=model_entity_id, ratio=ratio, image_model=image_model, conversation=conversation, reference_image_ids=reference_image_ids, platform_id=platform_id, batch_id=batch_id, retry_of_task_id=retry_of_task_id)
except ValueError as exc: # 无可用模型 / 余额不足等,立即反馈
internal_kind = "user_credit_insufficient" if str(exc).strip().lower() == "insufficient credit" else ""
public_error = classify_generation_error(
exc, operation="image_generate", internal_kind=internal_kind
)
return Response(
{"detail": public_error.fallback_message, "error": public_error.as_dict()},
status=status.HTTP_400_BAD_REQUEST,
)
# 本次提交即刷新对话活跃时间,左栏「最近」据此置顶
ImageConversation.objects.filter(id=conversation.id).update(last_active_at=timezone.now())
# batch_id 回传给前端存进批次卡:后续「重跑这张 / 重跑整批」带它回来即可归回原批次
return Response(
{
"conversation_id": str(conversation.id),
"batch_id": (tasks[0].request_payload or {}).get("batch_id") if tasks else None,
"tasks": [{"id": str(t.id), "status": t.status} for t in tasks],
},
status=status.HTTP_202_ACCEPTED,
)
def get(self, request):
team = get_current_team(request.user)
ids = [s for s in str(request.query_params.get("ids") or "").split(",") if s]
if not ids:
return Response({"tasks": []})
tasks = AITask.objects.filter(team=team, id__in=ids).prefetch_related(
"generated_assets", "generated_assets__files"
)
data = []
for task in tasks:
public_error = public_error_for_task(task)
data.append({
"id": str(task.id),
"status": task.status,
"error": public_error.as_dict() if public_error else None,
"error_message": public_error.fallback_message if public_error else "",
"assets": AssetSerializer(
[a for a in task.generated_assets.all() if not a.is_deleted and a.purged_at is None], many=True
).data,
})
return Response({"tasks": data})
class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
# 序列化器不含 request_payload/response_payload(单条可达 3MB+ base64 图),defer 掉:
# 否则只为序列化 14 个小字段也会把几十 MB blob 从库里拉回(远程库实测 40 条要 30s+)。
# 默认按创建时间倒序:任务中心 = 历史流水,最新的(多为成功)排最前。
# 缺省排序时 MySQL 按主键(UUID)乱序返回,会把一批旧失败记录顶到首页,
# 前端只取首页 → 误判「全部失败」。order_by 保证稳定且新任务优先。
queryset = AITask.objects.select_related("team", "project", "model_config", "model_config__provider").defer("request_payload", "response_payload").order_by("-created_at")
serializer_class = AITaskSerializer
search_fields = ["idempotency_key", "provider_task_id", "project__name"]
ordering_fields = ["created_at", "updated_at", "completed_at"]
def get_queryset(self):
# 可选 ?task_type=a,b,c 过滤:生图工作室的任务中心只想看生图任务(模特上身图/平台套图/
# 图片创作 = person_image / product_image),不掺脚本/实体抽取/故事板等流水线内部任务。
queryset = super().get_queryset().filter(is_deleted=False, purged_at__isnull=True)
# 从 request_payload(已 defer)里只抽 batch_id / mode 两个 JSON 标量供前端「按批分组 + 标签」用:
# KeyTextTransform 在 SQL 层 JSON_EXTRACT,不会把几 MB 的 payload 整列拉回(避开 payload 性能坑)。
from django.db.models.fields.json import KeyTextTransform
queryset = queryset.annotate(
rp_batch_id=KeyTextTransform("batch_id", "request_payload"),
rp_mode=KeyTextTransform("mode", "request_payload"),
rp_product_id=KeyTextTransform("product_id", "request_payload"),
)
raw = self.request.query_params.get("task_type", "").strip()
if raw:
types = [t.strip() for t in raw.split(",") if t.strip()]
if types:
queryset = queryset.filter(task_type__in=types)
return queryset
# YYX#row22:只有「工作台图片生成」(mode∈model/cover/image)才计入未读;
# 脚本/实体抽取/故事板等流水线内部任务不进这块未读统计。
_GEN_MODES = ("model", "cover", "image")
_WORKBENCH_IMAGE_TYPES = (AITask.Type.PERSON_IMAGE, AITask.Type.PRODUCT_IMAGE)
_DELETABLE_IMAGE_BATCH_STATUSES = (AITask.Status.SUCCEEDED, AITask.Status.FAILED, AITask.Status.CANCELLED)
def _image_batch_tasks(self, pk, *, deleted: bool):
"""Resolve one standalone image batch from a user-visible task anchor.
The browser only supplies an anchor task id. Team, mode and batch membership are always
derived server-side so one team cannot delete or restore another team's task group.
"""
base = AITask.objects.filter(
team=self.get_team(),
project__isnull=True,
task_type__in=self._WORKBENCH_IMAGE_TYPES,
is_deleted=deleted,
purged_at__isnull=True,
)
anchor = base.filter(pk=pk).first()
if anchor is None:
return None, []
payload = anchor.request_payload or {}
mode = str(payload.get("mode") or "").strip()
if mode not in self._GEN_MODES:
return None, []
batch_id = str(payload.get("batch_id") or "").strip()
batch_qs = base.filter(request_payload__mode=mode)
if batch_id:
batch_qs = batch_qs.filter(request_payload__batch_id=batch_id)
else:
batch_qs = batch_qs.filter(pk=anchor.pk)
return anchor, list(batch_qs.order_by("created_at").select_for_update())
@staticmethod
def _is_exception_batch(tasks):
return any(task.status in (AITask.Status.FAILED, AITask.Status.CANCELLED) for task in tasks)
@action(detail=True, methods=["delete"], url_path="workbench-batch")
def delete_workbench_batch(self, request, pk=None):
"""Delete a completed image batch.
Fully successful batches preserve the existing asset-trash behavior. A batch containing
a failed/cancelled task is soft-deleted as one recoverable exception batch instead.
"""
with transaction.atomic():
anchor, tasks = self._image_batch_tasks(pk, deleted=False)
if anchor is None or not tasks:
return Response({"detail": "图片批次不存在或不可删除"}, status=status.HTTP_404_NOT_FOUND)
if any(task.status not in self._DELETABLE_IMAGE_BATCH_STATUSES for task in tasks):
return Response({"detail": "批次仍在处理中,暂不能删除"}, status=status.HTTP_409_CONFLICT)
now = timezone.now()
task_ids = [task.id for task in tasks]
Asset.objects.filter(origin_task_id__in=task_ids, purged_at__isnull=True).update(is_deleted=True, updated_at=now)
if self._is_exception_batch(tasks):
AITask.objects.filter(id__in=task_ids).update(is_deleted=True, updated_at=now)
return Response({"storage": "exception_batch", "deleted_task_count": len(task_ids)})
return Response({"storage": "asset", "deleted_task_count": 0})
@action(detail=True, methods=["post"], url_path="restore-workbench-batch")
def restore_workbench_batch(self, request, pk=None):
"""Restore one previously deleted exception batch, including its generated images."""
with transaction.atomic():
anchor, tasks = self._image_batch_tasks(pk, deleted=True)
if anchor is None or not tasks:
return Response({"detail": "图片异常批次不存在或无法恢复"}, status=status.HTTP_404_NOT_FOUND)
now = timezone.now()
task_ids = [task.id for task in tasks]
AITask.objects.filter(id__in=task_ids).update(is_deleted=False, purged_at=None, updated_at=now)
Asset.objects.filter(origin_task_id__in=task_ids, purged_at__isnull=True).update(is_deleted=False, updated_at=now)
return Response({"restored_task_count": len(task_ids)})
@action(detail=True, methods=["delete"], url_path="purge-workbench-batch")
def purge_workbench_batch(self, request, pk=None):
"""Permanently hide one deleted exception batch and all of its generated images."""
with transaction.atomic():
anchor, tasks = self._image_batch_tasks(pk, deleted=True)
if anchor is None or not tasks:
return Response({"detail": "图片异常批次不存在或已彻底删除"}, status=status.HTTP_404_NOT_FOUND)
now = timezone.now()
task_ids = [task.id for task in tasks]
AITask.objects.filter(id__in=task_ids).update(is_deleted=True, purged_at=now, updated_at=now)
Asset.objects.filter(origin_task_id__in=task_ids, purged_at__isnull=True).update(is_deleted=True, purged_at=now, updated_at=now)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=False, methods=["get"], url_path="workbench-exception-batches-trash")
def workbench_exception_batches_trash(self, request):
"""List recoverable failed/cancelled image batches for the existing global trash page."""
tasks = list(
AITask.objects.filter(
team=self.get_team(),
project__isnull=True,
task_type__in=self._WORKBENCH_IMAGE_TYPES,
is_deleted=True,
purged_at__isnull=True,
request_payload__mode__in=self._GEN_MODES,
).filter(Q(conversation__isnull=True) | Q(conversation__is_deleted=False)).order_by("created_at")
)
groups = {}
product_ids = set()
for task in tasks:
payload = task.request_payload or {}
key = str(payload.get("batch_id") or task.id)
groups.setdefault(key, []).append(task)
product_id = str(payload.get("product_id") or "").strip()
if product_id:
product_ids.add(product_id)
# 异常批次缩略图只来自该批任务自己的成功成图。批次删除时这些资产会一起软删,
# 这里批量取回并按任务映射,避免逐批查询;商品封面不再参与缩略图回退。
preview_by_task_id = {}
task_ids = [task.id for task in tasks]
generated_assets = (
Asset.objects.filter(
team=self.get_team(),
origin_task_id__in=task_ids,
asset_type=Asset.Type.IMAGE,
is_deleted=True,
purged_at__isnull=True,
)
.prefetch_related("files")
.order_by("created_at", "id")
)
for asset in generated_assets:
files = sorted(
asset.files.all(),
key=lambda item: (not item.is_primary, item.created_at, str(item.id)),
)
for file in files:
preview_url = AssetFileSerializer(file).data.get("preview_url", "")
if preview_url:
preview_by_task_id.setdefault(asset.origin_task_id, preview_url)
break
# 商品仍只用于异常批次标题文字回退,不再关联或预取商品封面资源。
products = Product.objects.filter(
team=self.get_team(), id__in=product_ids, status=Product.Status.ACTIVE, purged_at__isnull=True
).only("id", "title")
product_by_id = {str(product.id): product for product in products}
rows = []
for key, group in groups.items():
if not self._is_exception_batch(group):
continue
first = group[0]
payload = first.request_payload or {}
product = product_by_id.get(str(payload.get("product_id") or ""))
cover = next(
(
preview_by_task_id[task.id]
for task in group
if task.status == AITask.Status.SUCCEEDED and task.id in preview_by_task_id
),
"",
)
intended = sum(1 for task in group if not (task.request_payload or {}).get("batch_append"))
rows.append({
"id": str(first.id),
"batch_id": str(payload.get("batch_id") or ""),
"mode": str(payload.get("mode") or "image"),
"prompt": str(payload.get("prompt") or ""),
"count": max(1, intended),
"product_id": str(payload.get("product_id") or ""),
"product_title": product.title if product else "",
"cover_preview_url": cover,
"updated_at": max(task.updated_at for task in group),
})
rows.sort(key=lambda row: row["updated_at"], reverse=True)
return Response({"results": rows})
def _unread_base(self):
"""本团队、属于图片生成、且未读(read_at is null)的任务集合(已 annotate rp_mode/rp_product_id)。
过滤必须走 request_payload__mode 路径查找而非 rp_mode 注解:KeyTextTransform 注解上的
exact/in 查找在 MySQL 会把裸字符串塞进 JSON_EXTRACT 当文档解析 → 3141 全查询炸
(sqlite 编译路径不同,单测测不出来);路径查找 RHS 会被正确 JSON 编码。"""
return self.get_queryset().filter(request_payload__mode__in=self._GEN_MODES, read_at__isnull=True)
@action(detail=False, methods=["get"], url_path="unread")
def unread(self, request):
"""未读生成任务汇总:总数(导航胶囊) + 按商品分组(商品预览角标)。"""
rows = self._unread_base().values_list("rp_product_id", flat=True)
total = 0
by_product: dict[str, int] = {}
for pid in rows:
total += 1
if pid:
by_product[str(pid)] = by_product.get(str(pid), 0) + 1
return Response({"total": total, "by_product": by_product})
@action(detail=False, methods=["get"], url_path="workbench")
def workbench(self, request):
"""R100:工作台(模特上身图/平台套图/图片创作)的生成记录改从后端持久数据拉取 ——
原先只存前端 localStorage(1 小时过期、换浏览器/清缓存即空),任务中心有记录而工作台丢。
与任务中心同源 = 同一批 AITask;按 request_payload.mode(白名单)+ product 过滤,
附带每个任务的存活成图资产,前端按 batch_id 归批还原批次流。
性能:仍只用 KeyTextTransform 在 SQL 层抽 payload 标量、defer 两个巨型 payload 列,不整列拖出。"""
from django.db.models.fields.json import KeyTextTransform
mode = str(request.query_params.get("mode") or "").strip()
if mode not in self._GEN_MODES:
return Response({"detail": "mode 仅支持 model / cover / image"}, status=status.HTTP_400_BAD_REQUEST)
try:
limit = min(500, max(1, int(request.query_params.get("limit") or 200)))
except (TypeError, ValueError):
limit = 200
qs = (
AITask.objects.filter(
team=self.get_team(),
project__isnull=True, # 工作台独立生图不挂项目;排除流水线内部任务
task_type__in=[AITask.Type.PERSON_IMAGE, AITask.Type.PRODUCT_IMAGE],
is_deleted=False,
purged_at__isnull=True,
)
.defer("request_payload", "response_payload")
.annotate(
rp_mode=KeyTextTransform("mode", "request_payload"),
rp_batch_id=KeyTextTransform("batch_id", "request_payload"),
rp_product_id=KeyTextTransform("product_id", "request_payload"),
rp_prompt=KeyTextTransform("prompt", "request_payload"),
rp_ratio=KeyTextTransform("ratio", "request_payload"),
rp_platform_id=KeyTextTransform("platform_id", "request_payload"),
rp_model_id=KeyTextTransform("model_id", "request_payload"),
rp_model_entity_id=KeyTextTransform("model_entity_id", "request_payload"),
# 只在重跑任务里落此键(值恒为 True);键不存在 → NULL → 假值,存在 → "true"/"1" → 真值
rp_batch_append=KeyTextTransform("batch_append", "request_payload"),
)
.filter(request_payload__mode=mode) # 路径查找,不能用 rp_mode 注解比较(MySQL 3141,见 _unread_base)
)
product_id = str(request.query_params.get("product") or "").strip()
if product_id:
qs = qs.filter(request_payload__product_id=product_id)
tasks = list(qs.order_by("-created_at").prefetch_related("generated_assets", "generated_assets__files")[:limit])
tasks.reverse() # 旧 → 新,与对话流/工作台批次流的时间序一致
data = []
for t in tasks:
public_error = public_error_for_task(t)
data.append({
"id": str(t.id),
"status": t.status,
"error": public_error.as_dict() if public_error else None,
"error_message": public_error.fallback_message if public_error else "",
"prompt": t.rp_prompt or "",
"batch_id": t.rp_batch_id or "",
"ratio": t.rp_ratio or "",
"product_id": t.rp_product_id or "",
"model_id": t.rp_model_id or "",
"model_entity_id": t.rp_model_entity_id or "",
"platform_id": t.rp_platform_id or "",
"rerun": bool(t.rp_batch_append),
"retry_of_task_id": str((t.request_payload or {}).get("retry_of_task_id") or ""),
"created_at": t.created_at,
# 软删的图不再出现在工作台记录里(R109:删除资产库图片 → 任务记录联动)
"assets": AssetSerializer(
[a for a in t.generated_assets.all() if not a.is_deleted and a.purged_at is None], many=True
).data,
})
return Response({"tasks": data})
@action(detail=False, methods=["post"], url_path="mark-read")
def mark_read(self, request):
"""标记已读 → 清零未读胶囊。
body 可选:product_id(只清该商品) / batch_id(只清该批) / ids(指定任务);
都不传 = 把当前团队所有未读生成任务标记已读(进任务中心时调用)。"""
qs = self._unread_base()
product_id = str(request.data.get("product_id") or "").strip()
batch_id = str(request.data.get("batch_id") or "").strip()
ids = request.data.get("ids") or []
if isinstance(ids, str):
ids = [s for s in ids.split(",") if s.strip()]
if product_id:
qs = qs.filter(request_payload__product_id=product_id)
if batch_id:
qs = qs.filter(request_payload__batch_id=batch_id)
if ids:
qs = qs.filter(id__in=[str(i).strip() for i in ids if str(i).strip()])
updated = qs.update(read_at=timezone.now())
return Response({"updated": updated})
class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
"""图片创作工作室的「对话」CRUD。
list 按 ?mode= 过滤、排除软删、按 last_active_at 倒序(左栏「最近」);
create 开新对话;partial_update 重命名;destroy 软删(不连带删图)。
detail action `tasks` 返回该对话下的生图任务 + 成图 asset,供切换对话时回填批次流。
"""
serializer_class = ImageConversationSerializer
queryset = ImageConversation.objects.select_related("product").order_by("-last_active_at")
def get_serializer_class(self):
if self.action == "trash":
return ImageConversationTrashSerializer
return super().get_serializer_class()
def get_queryset(self):
queryset = super().get_queryset().annotate(_task_count=Count("tasks"))
if self.action in ("trash", "restore", "purge"):
queryset = queryset.filter(is_deleted=True, purged_at__isnull=True)
else:
queryset = queryset.filter(is_deleted=False, purged_at__isnull=True)
mode = self.request.query_params.get("mode", "").strip()
if mode:
queryset = queryset.filter(mode=mode)
if self.action == "list":
scope = self.request.query_params.get("scope", "").strip()
product_id = self.request.query_params.get("product_id", "").strip()
if scope and product_id:
raise ValidationError({"detail": "scope 与 product_id 不能同时传入"})
if scope:
if scope != "unbound":
raise ValidationError({"detail": "scope 仅支持 unbound"})
queryset = queryset.filter(product__isnull=True)
elif product_id:
try:
normalized_product_id = str(uuid.UUID(product_id))
except (TypeError, ValueError, AttributeError) as exc:
raise ValidationError({"detail": "商品 ID 无效"}) from exc
if not Product.objects.filter(team=self.get_team(), id=normalized_product_id).exists():
raise ValidationError({"detail": "商品不存在或不属于当前团队"})
queryset = queryset.filter(product_id=normalized_product_id)
return queryset
def perform_create(self, serializer):
product = serializer.validated_data.get("product")
if product is not None and product.team_id != self.get_team().id:
raise ValidationError({"product": "商品不存在或不属于当前团队"})
super().perform_create(serializer)
def perform_destroy(self, instance):
# 只联动该会话生成任务产出的 Asset,不碰用户上传的参考素材。
with transaction.atomic():
Asset.objects.filter(team=instance.team, origin_task__conversation=instance, purged_at__isnull=True).update(is_deleted=True)
AITask.objects.filter(team=instance.team, conversation=instance, purged_at__isnull=True).update(is_deleted=True)
instance.is_deleted = True
instance.save(update_fields=["is_deleted", "updated_at"])
@action(detail=False, methods=["get"], url_path="trash")
def trash(self, request):
"""垃圾桶:列出本团队已软删且未彻底隐藏的自由创作图片会话。"""
qs = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(qs)
if page is not None:
self._attach_trash_cover_preview_urls(page)
return self.get_paginated_response(self.get_serializer(page, many=True).data)
conversations = list(qs)
self._attach_trash_cover_preview_urls(conversations)
return Response(self.get_serializer(conversations, many=True).data)
def _attach_trash_cover_preview_urls(self, conversations):
"""给当前页对话挂一张展示封面;全部资产仍由对话统一恢复/彻底删除。"""
conversation_ids = [conversation.id for conversation in conversations]
if not conversation_ids:
return
covers = {}
assets = (
Asset.objects.filter(
team=self.get_team(),
origin_task__conversation_id__in=conversation_ids,
origin_task__status=AITask.Status.SUCCEEDED,
origin_task__is_deleted=True,
origin_task__purged_at__isnull=True,
asset_type=Asset.Type.IMAGE,
is_deleted=True,
purged_at__isnull=True,
)
.select_related("origin_task")
.prefetch_related("files")
.order_by("origin_task__conversation_id", "-origin_task__created_at", "created_at", "id")
)
for asset in assets:
conversation_id = asset.origin_task.conversation_id
if conversation_id in covers:
continue
files = sorted(
asset.files.all(),
key=lambda item: (not item.is_primary, item.created_at, str(item.id)),
)
for file in files:
preview_url = AssetFileSerializer(file).data.get("preview_url", "")
if preview_url:
covers[conversation_id] = preview_url
break
for conversation in conversations:
conversation._trash_cover_preview_url = covers.get(conversation.id, "")
@action(detail=True, methods=["post"], url_path="restore")
def restore(self, request, pk=None):
"""从垃圾桶恢复自由创作图片会话。"""
conversation = self.get_object()
with transaction.atomic():
conversation.is_deleted = False
conversation.purged_at = None
conversation.save(update_fields=["is_deleted", "purged_at", "updated_at"])
AITask.objects.filter(team=conversation.team, conversation=conversation, purged_at__isnull=True).update(is_deleted=False)
Asset.objects.filter(team=conversation.team, origin_task__conversation=conversation, purged_at__isnull=True).update(is_deleted=False)
return Response(self.get_serializer(conversation).data, status=status.HTTP_200_OK)
@action(detail=True, methods=["delete"], url_path="purge")
def purge(self, request, pk=None):
"""彻底删除 = 二级软删除:从垃圾桶隐藏,DB 记录保留。"""
conversation = self.get_object()
now = timezone.now()
with transaction.atomic():
conversation.purged_at = now
conversation.save(update_fields=["purged_at", "updated_at"])
AITask.objects.filter(team=conversation.team, conversation=conversation, purged_at__isnull=True).update(
is_deleted=True, purged_at=now
)
Asset.objects.filter(team=conversation.team, origin_task__conversation=conversation, purged_at__isnull=True).update(
is_deleted=True, purged_at=now
)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=["get"])
def tasks(self, request, pk=None):
from apps.assets.models import Asset
from apps.assets.serializers import _asset_preview
conversation = self.get_object()
tasks = (
AITask.objects.filter(conversation=conversation, is_deleted=False, purged_at__isnull=True)
.prefetch_related("generated_assets", "generated_assets__files")
.order_by("created_at")
)
# 参考图 id → {name,url}:跨任务可能重复,缓存一次解析,供切换/刷新后批次头回显「参考了哪些图」
ref_cache: dict[str, dict] = {}
def resolve_refs(ids):
out = []
for rid in ids or []:
rid = str(rid)
if rid not in ref_cache:
a = Asset.objects.filter(id=rid, is_deleted=False, purged_at__isnull=True).prefetch_related("files").first()
# 带上 id:前端重跑时凭它原样复用参考图(否则刷新恢复的批次只有 {name,url},重跑丢参考)
ref_cache[rid] = {"id": rid, "name": a.name, "url": _asset_preview(a)} if a else None
if ref_cache[rid]:
out.append(ref_cache[rid])
return out
data = []
for t in tasks:
public_error = public_error_for_task(t)
data.append({
"id": str(t.id),
"status": t.status,
"error": public_error.as_dict() if public_error else None,
"error_message": public_error.fallback_message if public_error else "",
"prompt": (t.request_payload or {}).get("prompt", ""),
"batch_id": (t.request_payload or {}).get("batch_id", ""),
"ratio": (t.request_payload or {}).get("ratio") or "",
"product_id": str((t.request_payload or {}).get("product_id") or ""),
# 重跑/补图任务:不计入批次「应出张数」,前端据此正确渲染失败格数量
"rerun": bool((t.request_payload or {}).get("batch_append")),
"retry_of_task_id": str((t.request_payload or {}).get("retry_of_task_id") or ""),
"reference_images": resolve_refs((t.request_payload or {}).get("reference_image_ids")),
"created_at": t.created_at,
"assets": AssetSerializer(
[a for a in t.generated_assets.all() if not a.is_deleted and a.purged_at is None], many=True
).data,
})
return Response({"conversation_id": str(conversation.id), "tasks": data})
def _free_video_task_queryset(team):
return (
AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, is_deleted=False, purged_at__isnull=True)
.select_related("model_config")
.prefetch_related("generated_assets", "generated_assets__files")
)
def _not_omni_create_q() -> Q:
"""「不是全能创作」。同样不能写成 exclude(request_payload__feature="omni_create")——
JSON 键缺失时比较结果是 NULLexclude 会把没写 feature 的历史任务一起筛没。"""
return Q(request_payload__feature__isnull=True) | ~Q(request_payload__feature="omni_create")
def _free_video_list_queryset(team, *, include_replace=False):
"""正常任务流隐藏已从资产库删除的成品,但保留生成中/失败及无落库资产的历史任务。"""
from .video_replace import not_video_replace_q, video_replace_q
video_assets = Asset.objects.filter(origin_task_id=OuterRef("pk"), asset_type=Asset.Type.VIDEO)
active_video_assets = video_assets.filter(is_deleted=False, purged_at__isnull=True)
qs = (
_free_video_task_queryset(team)
.annotate(
_has_video_asset=Exists(video_assets),
_has_active_video_asset=Exists(active_video_assets),
)
.exclude(
status=AITask.Status.SUCCEEDED,
_has_video_asset=True,
_has_active_video_asset=False,
)
)
if include_replace:
return qs.filter(video_replace_q())
# 全能创作也走 FREE_VIDEO 任务类型,但不能出现在自由生成任务流里。
return qs.filter(not_video_replace_q()).filter(_not_omni_create_q())
def _free_video_trash_queryset(team):
return (
AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, is_deleted=True, purged_at__isnull=True)
.filter(_not_omni_create_q())
.select_related("model_config")
.prefetch_related("generated_assets", "generated_assets__files")
)
def _set_free_video_generated_assets_deleted(task, deleted):
Asset.objects.filter(team=task.team, origin_task=task, purged_at__isnull=True).update(is_deleted=deleted)
class VideoDigestView(APIView):
"""视频提炼 · 上传参考视频提炼分镜稿(不绑项目)。
POST /api/ai/video-digest/ multipart file → 秒回任务,worker 抽帧 + Gemini。失败退还。
GET /api/ai/video-digest/ 本团队已完成的提炼历史(新→旧)。
"""
parser_classes = [MultiPartParser, FormParser, JSONParser]
def get(self, request):
from .video_digest import expire_stale_team_digests, get_inflight_team_digest, list_team_digest_history
team = get_current_team(request.user)
expire_stale_team_digests(team=team)
results = list_team_digest_history(team=team)
return Response({
"results": results,
"total": len(results),
"inflight": get_inflight_team_digest(team=team),
})
def post(self, request):
upload = request.FILES.get("file") or request.data.get("file")
reuse_task_id = request.data.get("reuse_task_id") or None
if upload is None and not reuse_task_id:
return Response({"detail": "请先上传参考视频"}, status=status.HTTP_400_BAD_REQUEST)
require_worker_task("apps.ai.tasks.run_video_digest_task")
from .video_digest import VideoDigestError, VideoDigestInProgress, submit_team_digest
team = get_current_team(request.user)
try:
result = submit_team_digest(
team=team,
user=request.user,
upload=upload,
reuse_task_id=reuse_task_id,
model_config_id=request.data.get("model_config_id") or None,
)
except VideoDigestInProgress as exc:
# 409 + 在跑的那条:前端据此直接切到进行中状态,而不是弹个错就完了
return Response({"detail": str(exc), "inflight": exc.job}, status=status.HTTP_409_CONFLICT)
except VideoDigestError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except Exception as exc: # noqa: BLE001 — 模型/网络失败:走统一安全文案
logger.exception("video digest failed")
public_error = classify_generation_error(exc, operation="video_digest")
return Response(
{"detail": public_error.fallback_message, "error": public_error.as_dict()},
status=status.HTTP_502_BAD_GATEWAY,
)
return Response(
{"name": getattr(upload, "name", "") or result.get("file_name") or "参考视频", **result},
status=status.HTTP_202_ACCEPTED,
)
class VideoDigestDetailView(APIView):
"""GET /api/ai/video-digest/<id>/ 轮询提炼任务。
PATCH /api/ai/video-digest/<id>/ 保存编辑后的提示词到这条历史。
DELETE /api/ai/video-digest/<id>/ 取消进行中的提炼并退预留积分。
"""
parser_classes = [JSONParser, FormParser]
def get(self, request, task_id):
from .video_digest import get_team_digest_job
team = get_current_team(request.user)
item = get_team_digest_job(team=team, task_id=task_id)
if item is None:
return Response({"detail": "记录不存在"}, status=status.HTTP_404_NOT_FOUND)
return Response(item)
def patch(self, request, task_id):
from .video_digest import VideoDigestError, save_digest_prompt
team = get_current_team(request.user)
try:
item = save_digest_prompt(
team=team,
task_id=task_id,
prompt=str(request.data.get("prompt") or ""),
)
except VideoDigestError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
if item is None:
return Response({"detail": "记录不存在"}, status=status.HTTP_404_NOT_FOUND)
return Response(item)
def delete(self, request, task_id):
from .video_digest import cancel_team_digest
team = get_current_team(request.user)
item = cancel_team_digest(team=team, task_id=task_id)
if item is None:
return Response({"detail": "记录不存在"}, status=status.HTTP_404_NOT_FOUND)
return Response(item)
class FreeVideoView(APIView):
"""自由创作·视频生成(不绑项目,universal 全能参考 / keyframe 首尾帧)。
POST /api/ai/free-video/ 提交任务,秒回(火山 create 同步调、轮询交给 worker 兜底 + 前端主动 poll)
GET /api/ai/free-video/ 任务流分页(offset/page_size,新→旧)
"""
def post(self, request):
require_worker() # 生成闸:无 worker 时任务提交火山后无人兜底轮询(额度冻结、结果丢失)
from .free_video import serialize_free_video_task, submit_free_video
team = get_current_team(request.user)
try:
task = submit_free_video(team=team, user=request.user, params=request.data or {})
except ValueError as exc:
message = str(exc)
internal_kind = (
"user_credit_insufficient" if "余额不足" in message
else "model_unavailable" if "模型未配置" in message
else "provider_rate_limited" if "任务进行中" in message
else "invalid_input"
)
public_error = classify_generation_error(
exc, operation="video_generate", internal_kind=internal_kind
)
return Response(
{"detail": public_error.fallback_message, "error": public_error.as_dict()},
status=status.HTTP_400_BAD_REQUEST,
)
# 重取带 prefetch 的实例,序列化统一走同一条路
task = _free_video_task_queryset(team).get(id=task.id)
return Response({"task": serialize_free_video_task(task)}, status=status.HTTP_202_ACCEPTED)
def get(self, request):
from .free_video import serialize_free_video_task
team = get_current_team(request.user)
try:
offset = max(0, int(request.query_params.get("offset") or 0))
except (TypeError, ValueError):
offset = 0
try:
page_size = min(50, max(1, int(request.query_params.get("page_size") or 20)))
except (TypeError, ValueError):
page_size = 20
qs = _free_video_list_queryset(team).order_by("-created_at")
total = qs.count()
tasks = list(qs[offset : offset + page_size])
return Response(
{
"results": [serialize_free_video_task(t) for t in tasks],
"total": total,
"has_more": offset + page_size < total,
}
)
class VideoReplaceView(APIView):
"""视频复刻:参考视频 + 商品图/人物图,走 Seedance 换商品或换角色。
POST /api/ai/video-replace/ 提交(提示词后端写死)
GET /api/ai/video-replace/ 本页历史(不含自由创作)
"""
def post(self, request):
require_worker()
# 复刻的拆解在 worker 里跑;worker 还是旧镜像的话消息会被静默丢弃 → 永久「提炼中」。
require_worker_task("apps.ai.tasks.run_video_replace_digest_task")
from .video_replace import (
VideoReplaceInProgress,
serialize_video_replace_task,
submit_video_replace,
)
team = get_current_team(request.user)
try:
task = submit_video_replace(team=team, user=request.user, params=request.data or {})
except VideoReplaceInProgress as exc:
# 409 + 在跑的那条:前端据此直接切到进行中状态,而不是弹个错就完了
return Response(
{"detail": str(exc), "inflight": serialize_video_replace_task(exc.task)},
status=status.HTTP_409_CONFLICT,
)
except ValueError as exc:
message = str(exc)
internal_kind = (
"user_credit_insufficient" if "余额不足" in message
else "model_unavailable" if "模型未配置" in message
else "provider_rate_limited" if "任务进行中" in message
else "provider_unavailable" if "审核服务" in message
else "content_rejected" if "合规审核" in message
else "invalid_input"
)
public_error = classify_generation_error(
exc, operation="video_generate", internal_kind=internal_kind
)
# detail 用校验本身的原话:submit_video_replace 抛的 ValueError 全都是写给用户看的
# 中文("这个商品还没有可用图片""参考视频不能超过 30 秒"…)。套成 invalid_input 那句
# 万能文案("请检查描述、参数或素材格式后重试")等于把原因丢了,用户和排查都没法下手。
# error 里仍带结构化 code/action,前端要按类型渲染照旧可用。
payload = public_error.as_dict()
payload["fallback_message"] = message
return Response(
{"detail": message, "error": payload},
status=status.HTTP_400_BAD_REQUEST,
)
task = _free_video_task_queryset(team).get(id=task.id)
return Response({"task": serialize_video_replace_task(task)}, status=status.HTTP_202_ACCEPTED)
def get(self, request):
from .video_replace import serialize_video_replace_task
team = get_current_team(request.user)
try:
offset = max(0, int(request.query_params.get("offset") or 0))
except (TypeError, ValueError):
offset = 0
try:
page_size = min(50, max(1, int(request.query_params.get("page_size") or 20)))
except (TypeError, ValueError):
page_size = 20
qs = _free_video_list_queryset(team, include_replace=True).order_by("-created_at")
total = qs.count()
tasks = list(qs[offset : offset + page_size])
from .video_replace import get_inflight_video_replace
running = get_inflight_video_replace(team) if offset == 0 else None
return Response(
{
"results": [serialize_video_replace_task(t) for t in tasks],
"total": total,
"has_more": offset + page_size < total,
# 首页才带:前端刷新后据此恢复「进行中」,不必自己在列表里翻状态
"inflight": serialize_video_replace_task(running) if running is not None else None,
}
)
class VideoReplacePollView(APIView):
"""POST /api/ai/video-replace/<id>/poll/ —— 审核中推进送审,生成中走 finalize。"""
def post(self, request, task_id):
from .video_replace import advance_video_replace, is_video_replace_task, serialize_video_replace_task
team = get_current_team(request.user)
task = _free_video_task_queryset(team).filter(id=task_id).first()
if task is None or not is_video_replace_task(task):
return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND)
if task.status in (AITask.Status.CREATED, AITask.Status.SUBMITTED, AITask.Status.POLLING):
try:
task = advance_video_replace(task)
except Exception: # noqa: BLE001 — 单次轮询失败不终结任务
logger.warning("video replace poll failed for %s", task_id, exc_info=True)
task = _free_video_task_queryset(team).get(id=task.id)
return Response({"task": serialize_video_replace_task(task)})
class FreeVideoPollView(APIView):
"""POST /api/ai/free-video/<id>/poll/ —— web 进程内单次轮询+终态化(幂等)。
前端渐进轮询打这里;本地无 worker 也能全程收尾(与 pipeline poll-video-segment 同模式)。"""
def post(self, request, task_id):
from .free_video import finalize_free_video, serialize_free_video_task
team = get_current_team(request.user)
task = _free_video_task_queryset(team).filter(id=task_id).first()
if task is None:
return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND)
if task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING):
try:
task = finalize_free_video(task=task)
except Exception: # noqa: BLE001 — 单次轮询失败(网络抖动)不终结任务,返回现状继续轮
import logging
logging.getLogger(__name__).warning("free video poll failed for %s", task_id, exc_info=True)
# 终态后重取(finalize 里可能新建了资产)
task = _free_video_task_queryset(team).get(id=task.id)
return Response({"task": serialize_free_video_task(task)})
class FreeVideoFavoriteView(APIView):
"""POST /api/ai/free-video/<id>/favorite/ —— 收藏开关。"""
def post(self, request, task_id):
team = get_current_team(request.user)
task = AITask.objects.filter(
team=team, task_type=AITask.Type.FREE_VIDEO, id=task_id, is_deleted=False, purged_at__isnull=True
).first()
if task is None:
return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND)
task.is_favorited = not task.is_favorited
task.save(update_fields=["is_favorited", "updated_at"])
return Response({"is_favorited": task.is_favorited})
class FreeVideoDetailView(APIView):
"""DELETE /api/ai/free-video/<id>/ —— 软删(在途任务拒删,等终态)。"""
def delete(self, request, task_id):
team = get_current_team(request.user)
task = AITask.objects.filter(
team=team, task_type=AITask.Type.FREE_VIDEO, id=task_id, is_deleted=False, purged_at__isnull=True
).first()
if task is None:
return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND)
if task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING, AITask.Status.POSTPROCESSING):
return Response({"detail": "任务生成中,请等待完成后再删除"}, status=status.HTTP_400_BAD_REQUEST)
with transaction.atomic():
_set_free_video_generated_assets_deleted(task, True)
task.is_deleted = True
task.save(update_fields=["is_deleted", "updated_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
class FreeVideoTrashView(APIView):
"""GET /api/ai/free-video/trash/ —— 自由创作视频垃圾桶。"""
def get(self, request):
from .free_video import serialize_free_video_task
team = get_current_team(request.user)
try:
offset = max(0, int(request.query_params.get("offset") or 0))
except (TypeError, ValueError):
offset = 0
try:
page_size = min(50, max(1, int(request.query_params.get("page_size") or 20)))
except (TypeError, ValueError):
page_size = 20
qs = _free_video_trash_queryset(team).order_by("-updated_at")
total = qs.count()
tasks = list(qs[offset : offset + page_size])
return Response(
{
"results": [serialize_free_video_task(t, include_deleted_assets=True) for t in tasks],
"total": total,
"has_more": offset + page_size < total,
}
)
class FreeVideoRestoreView(APIView):
"""POST /api/ai/free-video/<id>/restore/ —— 从垃圾桶恢复自由创作视频。"""
def post(self, request, task_id):
from .free_video import serialize_free_video_task
team = get_current_team(request.user)
task = _free_video_trash_queryset(team).filter(id=task_id).first()
if task is None:
return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND)
with transaction.atomic():
task.is_deleted = False
task.purged_at = None
task.save(update_fields=["is_deleted", "purged_at", "updated_at"])
_set_free_video_generated_assets_deleted(task, False)
task = _free_video_task_queryset(team).get(id=task.id)
return Response({"task": serialize_free_video_task(task)}, status=status.HTTP_200_OK)
class FreeVideoPurgeView(APIView):
"""DELETE /api/ai/free-video/<id>/purge/ —— 二级软删除,从垃圾桶隐藏。"""
def delete(self, request, task_id):
team = get_current_team(request.user)
task = _free_video_trash_queryset(team).filter(id=task_id).first()
if task is None:
return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND)
now = timezone.now()
with transaction.atomic():
task.purged_at = now
task.save(update_fields=["purged_at", "updated_at"])
Asset.objects.filter(team=task.team, origin_task=task, purged_at__isnull=True).update(is_deleted=True, purged_at=now)
return Response(status=status.HTTP_204_NO_CONTENT)
# 上传参考素材的格式/尺寸限制(与 jimeng inputBar 校验对齐;后端兜底,前端也拦)
_FREE_REF_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp"}
_FREE_REF_VIDEO_TYPES = {"video/mp4", "video/quicktime"}
_FREE_REF_AUDIO_TYPES = {"audio/mpeg", "audio/wav", "audio/x-wav", "audio/wave"}
_FREE_REF_IMAGE_MAX = 30 * 1024 * 1024
_FREE_REF_VIDEO_MAX = 50 * 1024 * 1024
# 视频复刻上传:文件可以到 200MB,时长按 Seedance 2.5 的 30 秒收口。原片只用来提炼。
_REPLACE_SOURCE_PURPOSE = "video_replace_product"
_REPLACE_SOURCE_VIDEO_MAX = 200 * 1024 * 1024
_REPLACE_SOURCE_DURATION_MAX = 30.5
_FREE_REF_AUDIO_MAX = 15 * 1024 * 1024
class FreeVideoUploadView(APIView):
"""POST /api/ai/free-video/upload/ —— 参考素材上传(图/视频/音频)。
校验(图 300-6000px、比例(0.4,2.5)、≤30MB;视频 mp4/mov ≤50MB、2-15s;音频 mp3/wav ≤15MB、2-15s)
→ TOS → Asset(source=UPLOAD, in_library=False) → {asset_id,url,type,duration,thumb_url}。
视频顺带 ffmpeg 抽首帧缩略图。"""
parser_classes = [MultiPartParser, FormParser]
def post(self, request):
import tempfile
import uuid as _uuid
from io import BytesIO
from pathlib import Path
from apps.assets.models import Asset, AssetFile
from apps.assets.storage import TosStorage
from .media_probe import duration_in_ref_range, extract_video_poster, probe_duration
upload = request.FILES.get("file")
if upload is None:
return Response({"detail": "缺少文件"}, status=status.HTTP_400_BAD_REQUEST)
team = get_current_team(request.user)
content_type = (upload.content_type or "").lower()
size = upload.size or 0
long_source = str(request.data.get("purpose") or "").strip() == _REPLACE_SOURCE_PURPOSE
if content_type in _FREE_REF_IMAGE_TYPES:
kind, asset_type, suffix = "image", Asset.Type.IMAGE, {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}[content_type]
if size > _FREE_REF_IMAGE_MAX:
return Response({"detail": "图片大小不能超过 30MB"}, status=status.HTTP_400_BAD_REQUEST)
elif content_type in _FREE_REF_VIDEO_TYPES:
kind, asset_type, suffix = "video", Asset.Type.VIDEO, ".mp4" if content_type == "video/mp4" else ".mov"
video_max = _REPLACE_SOURCE_VIDEO_MAX if long_source else _FREE_REF_VIDEO_MAX
if size > video_max:
return Response(
{"detail": f"视频大小不能超过 {video_max // 1024 // 1024}MB"},
status=status.HTTP_400_BAD_REQUEST,
)
elif content_type in _FREE_REF_AUDIO_TYPES:
kind, asset_type, suffix = "audio", Asset.Type.AUDIO, ".mp3" if content_type == "audio/mpeg" else ".wav"
if size > _FREE_REF_AUDIO_MAX:
return Response({"detail": "音频大小不能超过 15MB"}, status=status.HTTP_400_BAD_REQUEST)
else:
return Response(
{"detail": "不支持的文件格式(图片 JPG/PNG/WebP,视频 MP4/MOV,音频 MP3/WAV"},
status=status.HTTP_400_BAD_REQUEST,
)
raw = upload.read()
width = height = None
duration = None
poster_bytes = None
if kind == "image":
try:
from PIL import Image
with Image.open(BytesIO(raw)) as im:
width, height = im.size
except Exception: # noqa: BLE001
return Response({"detail": "图片解析失败,请更换文件"}, status=status.HTTP_400_BAD_REQUEST)
if not (300 <= width <= 6000 and 300 <= height <= 6000):
return Response({"detail": "图片边长需在 300-6000 像素之间"}, status=status.HTTP_400_BAD_REQUEST)
ratio = width / height
if not (0.4 <= ratio <= 2.5):
return Response({"detail": "图片宽高比需在 0.4-2.5 之间"}, status=status.HTTP_400_BAD_REQUEST)
else:
with tempfile.TemporaryDirectory(prefix="airshelf-fc-upload-") as tmp:
tmp_path = Path(tmp) / f"in{suffix}"
tmp_path.write_bytes(raw)
duration = probe_duration(str(tmp_path))
if duration is None:
return Response({"detail": "媒体文件解析失败,请更换文件"}, status=status.HTTP_400_BAD_REQUEST)
from .media_probe import REF_DURATION_MIN
if long_source and kind == "video":
in_range = REF_DURATION_MIN <= duration <= _REPLACE_SOURCE_DURATION_MAX
range_hint = "视频时长需在 2-30 秒之间"
else:
in_range = duration_in_ref_range(duration)
range_hint = f"{'视频' if kind == 'video' else '音频'}时长需在 2-15 秒之间"
if not in_range:
return Response({"detail": range_hint}, status=status.HTTP_400_BAD_REQUEST)
if kind == "video":
poster_bytes = extract_video_poster(str(tmp_path))
asset_id = _uuid.uuid4()
storage = TosStorage()
object_key = f"teams/{team.id}/free-create/uploads/{asset_id}{suffix}"
stored = storage.upload_fileobj(fileobj=BytesIO(raw), object_key=object_key, content_type=content_type)
name = (upload.name or f"素材{suffix}")[:255]
asset = Asset.objects.create(
id=asset_id,
team=team,
created_by=request.user,
name=name,
asset_type=asset_type,
source=Asset.Source.UPLOAD,
category=Asset.Category.UPLOAD,
in_library=False, # 仅作生成参考,不进资产库列表
metadata={"feature": "free_video_reference"},
)
AssetFile.objects.create(
asset=asset,
object_key=stored.object_key,
bucket=stored.bucket,
content_type=stored.content_type,
size_bytes=stored.size_bytes,
width=width,
height=height,
duration_ms=int(duration * 1000) if duration else None,
is_primary=True,
)
url = storage.public_url(object_key=stored.object_key)
thumb_url = ""
if poster_bytes:
poster_key = f"teams/{team.id}/free-create/uploads/{asset_id}-poster.jpg"
poster_stored = storage.upload_fileobj(
fileobj=BytesIO(poster_bytes), object_key=poster_key, content_type="image/jpeg"
)
AssetFile.objects.create(
asset=asset,
object_key=poster_stored.object_key,
bucket=poster_stored.bucket,
content_type=poster_stored.content_type,
size_bytes=poster_stored.size_bytes,
is_primary=False,
)
thumb_url = storage.public_url(object_key=poster_key)
# 上传即送审:通过后再入库并返回链接,避免出片时才把「已提交审核」塞进对话。
from apps.assets.review import wait_upload_review
review_status = wait_upload_review(asset)
asset.refresh_from_db()
if review_status == "failed":
detail = (asset.review_error or "素材未通过审核,请换一张图再试").strip()
return Response({"detail": detail, "review_status": "failed", "asset_id": str(asset.id)}, status=status.HTTP_400_BAD_REQUEST)
if review_status == "processing":
return Response(
{"detail": "素材还在审核中,请稍后再试", "review_status": "processing", "asset_id": str(asset.id)},
status=status.HTTP_408_REQUEST_TIMEOUT,
)
# active / allowed:进库,可供引用
if not asset.in_library:
asset.in_library = True
asset.save(update_fields=["in_library", "updated_at"])
return Response(
{
"asset_id": str(asset.id),
"url": url,
"type": kind,
"name": name,
"duration": duration,
"width": width,
"height": height,
"thumb_url": thumb_url or (url if kind == "image" else ""),
"review_status": "active" if review_status in ("active", "allowed") else review_status,
"in_library": True,
},
status=status.HTTP_201_CREATED,
)
class ModelConfigViewSet(ReadOnlyModelViewSet):
# 按创建序固定排序:最早创建的 active 模型排第一 = 前端选择器默认项,与 get_default_model 口径一致。
# DeepSeek 已停用,下拉里不再出现。
# 列表短且高频(创作页下拉),整页结果缓存;后台改模型走 invalidate_model_catalog_cache。
queryset = (
ModelConfig.objects.select_related("provider")
.filter(status=ModelConfig.Status.ACTIVE)
.exclude(name__icontains="deepseek")
.exclude(display_name__icontains="deepseek")
.order_by("created_at")
)
serializer_class = ModelConfigSerializer
search_fields = ["name", "display_name", "capability"]
ordering_fields = ["created_at", "display_name"]
def list(self, request, *args, **kwargs):
from django.core.cache import cache
from apps.ai.model_catalog import MODEL_CATALOG_CACHE_KEY, MODEL_CATALOG_CACHE_TTL
# 缓存「无 search/ordering/capability、首页」目录。page_size=200 是创作页常规用法。
qp = request.query_params
page = str(qp.get("page") or "1")
page_size = str(qp.get("page_size") or "")
is_plain = (
not any(qp.get(k) for k in ("search", "ordering", "capability"))
and page in {"1", ""}
and page_size in {"", "200"}
)
cache_key = MODEL_CATALOG_CACHE_KEY if page_size in {"", "200"} else f"{MODEL_CATALOG_CACHE_KEY}:{page_size}"
if is_plain:
cached = cache.get(cache_key)
if cached is not None:
return Response(cached)
response = super().list(request, *args, **kwargs)
if is_plain and response.status_code == 200:
cache.set(cache_key, response.data, MODEL_CATALOG_CACHE_TTL)
return response
def _agent_busy_response():
return JsonResponse({"detail": AGENT_BUSY_DETAIL}, status=409)
class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
"""全能创作会话 CRUD(契约 §3)。
list 创作历史页,按 ?status=running|completed 过滤,-last_active_at 倒序
retrieve 进对话页,一次性带回全部消息
create 从首页「开始创作」发起,带 mode/preset/params;首条用户消息由 messages 接口发
partial_update 只允许改 title(重命名)
destroy 软删(不连带删已生成的资产 —— 图还在资产库里)
发消息走 POST {id}/messages/(SSE),不在这里。
"""
serializer_class = CreationConversationSerializer
queryset = CreationConversation.objects.order_by("-last_active_at")
def get_serializer_class(self):
if self.action == "retrieve":
return CreationConversationDetailSerializer
return super().get_serializer_class()
def create(self, request, *args, **kwargs):
# 离开「整理方案」页再新建会话也不行 —— 团队同时只能有一个 agent turn
team = self.get_team()
if team_agent_busy(team):
return _agent_busy_response()
return super().create(request, *args, **kwargs)
def get_queryset(self):
queryset = super().get_queryset().filter(is_deleted=False, purged_at__isnull=True)
if self.action == "retrieve":
queryset = queryset.prefetch_related("messages")
else:
queryset = queryset.annotate(_message_count=Count("messages"))
mode = self.request.query_params.get("mode", "").strip()
if mode:
if mode not in CreationConversation.Mode.values:
raise ValidationError({"detail": "mode 仅支持 video / image"})
queryset = queryset.filter(mode=mode)
conv_status = self.request.query_params.get("status", "").strip()
if conv_status:
if conv_status not in CreationConversation.Status.values:
raise ValidationError({"detail": "status 仅支持 running / completed / failed"})
queryset = queryset.filter(status=conv_status)
return queryset
def perform_destroy(self, instance):
# 只软删会话本身。已生成的图/视频留在资产库 —— 用户删对话不等于要删素材。
instance.is_deleted = True
instance.save(update_fields=["is_deleted", "updated_at"])
def _synced_conversation(self):
"""拉会话前先把已结束的 GENERATING 回填成 RESULT/ERROR。
出图在 worker 里跑,agent 只提交。前端轮询 GET 本接口拿结果。
prefetch 缓存里是回填前的旧对象,改过必须丢掉再读。
"""
conversation = self.get_object()
if sync_generating_messages(conversation):
conversation.refresh_from_db()
cache = getattr(conversation, "_prefetched_objects_cache", None)
if cache is not None:
cache.pop("messages", None)
return conversation
def retrieve(self, request, *args, **kwargs):
conversation = self._synced_conversation()
# poll 也会打 retrieve;顺手清超时 planning,避免 worker 挂掉后前端无限「整理方案」
team = getattr(conversation, "team", None)
if team is not None:
cleared = cleanup_stale_agent_planning(team)
if cleared:
conversation.refresh_from_db()
serializer = self.get_serializer(conversation)
return Response(serializer.data)
@action(detail=True, methods=["get"], url_path="messages")
def messages(self, request, pk=None):
"""按 ?after_seq= 增量拉消息。轮询视频结果时前端只补新的,不重拉整条会话。"""
conversation = self._synced_conversation()
queryset = conversation.messages.all()
after_seq = request.query_params.get("after_seq", "").strip()
if after_seq:
try:
queryset = queryset.filter(seq__gt=int(after_seq))
except (TypeError, ValueError) as exc:
raise ValidationError({"detail": "after_seq 必须是整数"}) from exc
return Response(CreationMessageSerializer(queryset, many=True).data)
@action(detail=True, methods=["post"], url_path="merge-video-segments")
def merge_video_segments(self, request, pk=None):
"""合并多段成片(兼容旧前端手动触发;正常流程在分段全成功后由平台自动合并)。"""
conversation = self.get_object()
message_id = str(request.data.get("message_id") or "").strip()
message = conversation.messages.filter(id=message_id).first() if message_id else None
if message is None:
return JsonResponse({"detail": "找不到要合并的视频片段"}, status=404)
try:
merge_task, generating = start_segmented_video_merge(
conversation=conversation, message=message, user=request.user
)
except ValueError as exc:
return JsonResponse({"detail": str(exc)}, status=400)
try:
from .tasks import merge_omni_video_segments_task
merge_omni_video_segments_task.apply_async(args=[str(merge_task.id)])
except Exception: # noqa: BLE001 - 没有 worker 时前端轮询仍可看到任务,避免重复执行 ffmpeg
logger.warning("omni video merge enqueue failed for %s", merge_task.id, exc_info=True)
return JsonResponse({"message": CreationMessageSerializer(generating).data}, status=202)
@action(detail=True, methods=["post"], url_path="cancel")
def cancel(self, request, pk=None):
"""终止进行中的整理方案(agent planning)。
仅 `agent_status===planning` 可取消 → **200** `{conversation_id, agent_status}`。
若取消发生在策略/方案/Prompt 闸门的修订或推进中,恢复该步 `step_confirm`
(清除 submitted),`agent_status` 回到 `awaiting_user`;否则为 `idle`。
非 planning → **409**。打 Redis 取消标供 Celery 在工具轮间隙停跑,立刻释放
`omni:agent:{team_id}` 锁;已落库消息保留。不影响 confirm 出片/出图。
"""
conversation = self.get_object()
if conversation.agent_status != CreationConversation.AgentStatus.PLANNING:
return JsonResponse(
{"detail": "当前没有正在整理的方案可终止"},
status=409,
)
if not request_agent_cancel(conversation):
return JsonResponse(
{"detail": "当前没有正在整理的方案可终止"},
status=409,
)
restored = restore_gated_step_after_cancel(conversation)
if restored:
now = timezone.now()
CreationConversation.objects.filter(pk=conversation.pk).update(
agent_status=CreationConversation.AgentStatus.AWAITING_USER,
agent_started_at=None,
last_active_at=now,
updated_at=now,
)
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.agent_started_at = None
return JsonResponse(
{
"conversation_id": str(conversation.id),
"agent_status": conversation.agent_status,
},
status=200,
)
@action(detail=True, methods=["post"], url_path="send")
def send(self, request, pk=None):
"""发一条消息(契约 §3)。
kind=text / elicit_answer → 入队 Celery 跑 agent,立刻 **202**
`{conversation_id, agent_status}`;前端靠 poll 拉消息。
kind=confirm → 同步 JSON 201(出片/出图),与 agent 编排无关。
闸门「发列表」等短路径 → 同步 JSON 200(已落库消息),不占 agent 锁。
团队同时只允许一个 planning;冲突 **409** + 中文 detail。
"""
conversation = self.get_object()
# 模型/比例/分辨率/时长在新建会话时锁定,发送和确认出片都按当时那套,
# 否则 5 秒方案被改成 10 秒再出片会对不上。
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 and is_restart_intent(text):
apply_restart_intent(conversation)
force_creative_turn = True
continuation_instruction = _RESTART_CONTINUATION
# 打招呼不应被误当成上一条素材追问的答案;让 agent 立刻简短回应即可。
elif kind == "text" and text and not is_greeting(text):
pending = _pending_chat_question(conversation)
# 无论旧会话遗留了什么追问,只要用户要商品列表,就直接出可点击卡片。
# 不能再交给模型 search_library 后用文字念出商品名。
if _WANTS_PRODUCT_PICKER_RE.search(text):
return _open_product_picker(conversation, text, pending)
if pending is not None:
payload = dict(pending.payload or {})
if payload.get("interaction") == "step_confirm":
# 用户对着步骤确认卡直接打字:自然肯定句视为继续,其它才作为修改意见。
typed_action = _typed_step_confirm_action(text)
short, force_creative_turn, continuation_instruction = _handle_step_confirm_answer(
conversation,
card=pending,
answers={
"step_action": typed_action,
**({"feedback": text} if typed_action == "revise" else {}),
},
user=request.user,
)
if short is not None:
return short
# 用户原文仍落库,方便回看;continuation 已带反馈
elif payload.get("interaction") == "plot_twist_story_depth":
# 故事深度卡既可点选,也允许用户直接输入「30 秒」之类的自然回答。
depth = set_plot_twist_story_depth(conversation, text)
if depth is not None:
payload["answers"] = {"story_depth": depth["value"]}
payload["submitted"] = True
payload["answered_via"] = "chat"
pending.payload = payload
pending.save(update_fields=["payload", "updated_at"])
text = ""
record_user_message = False
force_creative_turn = True
continuation_instruction = _plot_twist_depth_continuation(depth)
elif payload.get("interaction") == "plot_twist_directions":
options = [item for item in (payload.get("directions") or []) if isinstance(item, dict)]
chosen = next(
(
item for item in options
if str(item.get("title") or "") in text or str(item.get("id") or "") == text
),
None,
)
choice = str((chosen or {}).get("id") or text).strip()
if choice:
payload["answers"] = {"story_direction": choice}
payload["submitted"] = True
payload["answered_via"] = "chat"
pending.payload = payload
pending.save(update_fields=["payload", "updated_at"])
text = ""
record_user_message = False
force_creative_turn = True
continuation_instruction = _plot_twist_direction_continuation(
conversation, payload, choice
)
elif payload.get("interaction") == "click_swap_mode_gate":
raw = text.strip()
mode = ""
if re.search(r"只出手|手指|不用角色|不要角色|无角色", raw):
mode = "finger"
elif re.search(r"角色|日常|拟人|出镜人物|达人", raw):
mode = "character"
instruction = apply_click_swap_mode(conversation, mode) if mode else ""
if instruction:
payload["answers"] = {"click_swap_mode": mode}
payload["submitted"] = True
payload["answered_via"] = "chat"
pending.payload = payload
pending.save(update_fields=["payload", "updated_at"])
text = ""
record_user_message = False
force_creative_turn = True
continuation_instruction = instruction
elif payload.get("interaction") == "click_swap_sku_gate":
sequence = text.strip()
if sequence:
payload["answers"] = {"sku_sequence": sequence}
payload["submitted"] = True
payload["answered_via"] = "chat"
pending.payload = payload
pending.save(update_fields=["payload", "updated_at"])
text = ""
record_user_message = False
force_creative_turn = True
continuation_instruction = _store_click_swap_sequence(conversation, sequence)
elif payload.get("phase") == "gate":
pending_fields = [item for item in (payload.get("pending_fields") or []) if isinstance(item, dict)]
primary_field = pending_fields[0] if pending_fields else {}
asset_types = [t for t in (primary_field.get("asset_types") or []) if t in TYPE_LABELS] or ["product"]
is_product_gate = "product" in asset_types
# 1. 用户明确在打字说「发列表/发给我/发卡片/打开列表」
if _WANTS_CARD_RE.search(text):
payload["answers"] = {"_asset_gate": "send"}
payload["submitted"] = True
payload["answered_via"] = "chat"
pending.payload = payload
pending.save(update_fields=["payload", "updated_at"])
user_msg = append_message(conversation, role="user", text=text)
pick_field = dict(primary_field)
pick_field["label"] = _ASSET_CARD_LABELS.get(
asset_types[0], _ASSET_CARD_LABELS.get("asset", "请选择素材")
)
pick = append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text=pick_field["label"],
payload={
"interaction": "asset_picker",
"phase": "pick",
"fields": [pick_field],
"submitted": False,
"answers": {},
},
)
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
return JsonResponse({
"conversation_id": str(conversation.id),
"agent_status": conversation.agent_status,
"messages": [
CreationMessageSerializer(user_msg).data,
CreationMessageSerializer(pick).data,
],
}, status=200)
# 2. 用户说「你帮我选/你定/随便/你来推荐」
elif re.search(r"(你来定|你定|你帮我定|你帮我选|帮我挑|没想好|随便|都行|你挑|你来推荐|推荐一款|推荐一个)", text):
if is_product_gate:
preferred, continuation_instruction = _auto_pick_product_continuation(
conversation, refs
)
chosen_name = (
_ref_display_name(preferred[0]) if preferred else "推荐商品"
)
for item in preferred:
mark = (item.get("type"), str(item.get("id")))
existing = {
(r.get("type"), str(r.get("id")))
for r in refs if isinstance(r, dict)
}
if mark not in existing:
refs.append(item)
payload["answers"] = {
"_asset_gate": "auto",
str(primary_field.get("key") or "product"): chosen_name,
}
payload["submitted"] = True
payload["answered_via"] = "chat"
pending.payload = payload
pending.save(update_fields=["payload", "updated_at"])
_mark_product_source_resolved(conversation)
force_creative_turn = True
else:
hits = search_mentions(conversation.team, q="", types=asset_types, limit=1)
chosen_name = hits[0]["name"] if hits else "推荐素材"
if hits:
mark = (hits[0].get("type"), str(hits[0].get("id")))
existing = {(item.get("type"), str(item.get("id"))) for item in refs if isinstance(item, dict)}
if mark not in existing:
refs.append(hits[0])
pin_refs(conversation, [hits[0]])
payload["answers"] = {"_asset_gate": "auto", str(primary_field.get("key") or "product"): chosen_name}
payload["submitted"] = True
payload["answered_via"] = "chat"
pending.payload = payload
pending.save(update_fields=["payload", "updated_at"])
is_character_gate = any(t in ("character", "model") for t in asset_types)
if is_character_gate and hits:
memory = dict(conversation.memory or {})
memory["person_source"] = "auto"
memory["person_source_ready"] = True
memory["person_source_pending"] = False
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
if hits:
force_creative_turn = True
continuation_instruction = (
f"用户让你帮忙挑选,系统已为你选定【{hits[0]['name']}】。"
"直接基于该素材推进创作方案,不要复述选项,不要再次 ask_user 追问同一类素材。"
)
elif is_character_gate:
memory = dict(conversation.memory or {})
memory.pop("person_source_ready", None)
memory.pop("person_source_pending", None)
memory["person_source"] = ""
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
gate = append_person_source_gate(conversation)
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
return JsonResponse({
"conversation_id": str(conversation.id),
"agent_status": conversation.agent_status,
"messages": [CreationMessageSerializer(gate).data],
}, status=200)
else:
force_creative_turn = True
continuation_instruction = (
"用户让你帮忙定素材,目前素材库暂无现成项。"
"按用户刚说的外观要求继续创作;禁止再次弹出同类「发列表 / 你来推荐」追问。"
)
# 3. 用户说「先不用/不选了/跳过」
elif re.search(r"(先不用|不用|不选|先不选|跳过|暂不|没有商品|不需要)", text):
payload["answers"] = {"_asset_gate": "skip"}
payload["submitted"] = True
payload["answered_via"] = "chat"
pending.payload = payload
pending.save(update_fields=["payload", "updated_at"])
if is_product_gate:
_mark_product_source_resolved(conversation)
force_creative_turn = True
continuation_instruction = (
"用户刚选择暂不添加这项素材。接受这个选择,按会话里已有的需求和合理默认继续原任务。"
"先用一句自然的话承接,随后直接推进创作;不要再次追问同一素材,不要只说收到或有需要再说。"
)
# 4. 用户直接输入了商品名或其它内容
else:
hits = search_mentions(conversation.team, q="", types=asset_types, limit=50)
text_norm = _normalise_chat_choice(text)
matched_hit = None
for hit in hits:
hname = _normalise_chat_choice(hit.get("name") or "")
if hname and (hname in text_norm or text_norm in hname):
matched_hit = hit
break
if matched_hit:
mark = (matched_hit.get("type"), str(matched_hit.get("id")))
existing = {(item.get("type"), str(item.get("id"))) for item in refs if isinstance(item, dict)}
if mark not in existing:
refs.append(matched_hit)
chosen_name = matched_hit["name"]
continuation_instruction = (
f"用户已指定使用素材【{chosen_name}】。直接基于该素材推进创作方案,不要复述选项,不要重复追问。"
)
else:
chosen_name = text
continuation_instruction = (
f"用户已指定推广内容为「{text}」。直接围绕该内容推进创作方案,不要重复追问。"
)
payload["answers"] = {"_asset_gate": chosen_name, str(primary_field.get("key") or "product"): chosen_name}
payload["submitted"] = True
payload["answered_via"] = "chat"
pending.payload = payload
pending.save(update_fields=["payload", "updated_at"])
if is_product_gate:
_mark_product_source_resolved(conversation)
force_creative_turn = True
else:
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
if is_pain_point_conversation(conversation) and is_pain_point_direction_payload(payload):
field_key = str(field.get("key") or "pain_point_direction")
choice = str(answers.get(field_key) or "").strip()
text = ""
record_user_message = False
continuation_instruction = apply_pain_point_direction(
conversation, payload, choice
)
elif payload.get("topic") == "product_info":
clean_ans = text.strip()
memory = dict(conversation.memory or {})
memory["product_info_resolved"] = True
if clean_ans and not any(kw in clean_ans for kw in ("按图片", "设计品牌", "不用再问")):
memory["product_brand_and_name"] = clean_ans
bm = re.search(r"品牌[:是为]?\s*([^\n,。!!]+)", clean_ans)
nm = re.search(r"(?:品名|商品名|产品名)[:是为]?\s*([^\n,。!!]+)", clean_ans)
if bm:
memory["product_brand"] = bm.group(1).strip()
if nm:
memory["product_name"] = nm.group(1).strip()
for r in (conversation.pinned_refs or []):
if isinstance(r, dict) and r.get("type") in ("asset", "product"):
r["name"] = clean_ans
break
conversation.pinned_refs = conversation.pinned_refs
continuation_instruction = f"用户已提供商品品牌与品名:【{clean_ans}】。直接围绕该商品推进方案,不要重复追问。"
else:
continuation_instruction = "用户要求直接根据图片内容设计品牌与品名推进创作。直接基于图片外观特征推进方案,不要重复追问品牌。"
conversation.memory = memory
conversation.save(update_fields=["memory", "pinned_refs", "updated_at"])
# 保留用户原文气泡,便于回看刚填的品牌/品名;不要清空 text,
# 否则会落到「消息不能为空」且对话里看不到这次回答。
force_creative_turn = True
else:
continuation_instruction = (
"用户刚用输入框回答了你上一句问题。直接基于这条真实回答继续原任务;"
"不要复述答案,不要回复收到,也不要问要不要继续或要不要生成。"
)
if params_changed:
continuation_instruction += " 会话参数已更新,旧方案作废,按新参数重新产出。"
# 用户对刚生成的角色给出确认/重刷/外观修改反馈
clean_text = text.strip()
memory_now = dict(conversation.memory or {})
person_confirm_pending = bool(memory_now.get("person_confirm_pending"))
is_platform_person = memory_now.get("person_source") == "platform_generate"
confirmed_person = (
bool(re.search(
r"^(使用这[个位只]角色|就用这[个位只]角色|就用[她他它]|满意|确认使用|合适|可以)[继续创作。!! ]*$",
clean_text,
))
or clean_text in (
"使用这个角色继续创作",
"使用这个宠物角色继续创作",
)
or (person_confirm_pending and clean_text in ("继续", "继续创作"))
)
regenerate_button = bool(re.search(
r"^(重新生成|再生成|重刷|换一个|换位|不满意).{0,6}(角色|人物|模特|宠物)?[。!! ]*$",
clean_text,
))
upload_person = bool(re.search(
r"(上传|发一张|发个).{0,8}(人物|角色|模特|宠物)|我上传",
clean_text,
))
if confirmed_person:
if person_confirm_pending:
memory_now["person_confirm_pending"] = False
conversation.memory = memory_now
conversation.save(update_fields=["memory", "updated_at"])
force_creative_turn = True
continuation_instruction = (
"用户已确认使用当前生成的角色出镜。直接基于该角色继续推进创作方案"
"(若尚未选商品则确定商品,已选好商品则开始写创作策略和方案),不要再次追问角色来源。"
)
elif is_platform_person and (regenerate_button or (person_confirm_pending and clean_text and not upload_person)):
prev_prompt = str(memory_now.get("person_prompt") or "").strip()
if regenerate_button:
appearance_prompt = prev_prompt
elif prev_prompt:
appearance_prompt = f"{prev_prompt};按用户最新要求调整:{clean_text}"
else:
appearance_prompt = clean_text
# 卸掉上一张平台定妆锁定,避免新旧角色叠在 pinned_refs 里
old_model_id = str(memory_now.get("person_model_id") or "").strip()
if old_model_id:
conversation.pinned_refs = [
ref for ref in (conversation.pinned_refs or [])
if not (
isinstance(ref, dict)
and ref.get("type") in {"model", "character"}
and str(ref.get("id") or "") == old_model_id
)
]
memory_now["person_confirm_pending"] = False
memory_now.pop("person_source_ready", None)
memory_now.pop("person_model_id", None)
conversation.memory = memory_now
conversation.save(update_fields=["memory", "pinned_refs", "updated_at"])
try:
user_msg = append_message(conversation, role="user", text=clean_text)
generating = submit_generated_person_reference(
conversation=conversation,
user=request.user,
appearance_prompt=appearance_prompt,
)
return JsonResponse({
"conversation_id": str(conversation.id),
"agent_status": conversation.agent_status,
"messages": [
CreationMessageSerializer(user_msg).data,
CreationMessageSerializer(generating).data,
],
}, status=202)
except Exception:
pass
if kind == "confirm":
reply_to = str(request.data.get("reply_to") or "").strip()
card = conversation.messages.filter(
id=reply_to, kind=CreationMessage.Kind.CONFIRM
).first() if reply_to else None
if card is None:
return JsonResponse({"detail": "确认卡不存在"}, status=404)
if (card.payload or {}).get("submitted"):
# 确认闸门是一次性的:连点两下会出两条片、扣两次积分
return JsonResponse({"detail": "这条方案已经确认过了"}, status=409)
incoming = request.data.get("params")
if incoming is not None and not isinstance(incoming, dict):
return JsonResponse({"detail": "params 必须是对象"}, status=400)
latest_params, duration_changed = apply_confirm_params(
conversation, incoming if isinstance(incoming, dict) else None
)
card.payload = {
**(card.payload or {}),
"submitted": True,
"params": latest_params,
}
card.save(update_fields=["payload", "updated_at"])
# 改时长会让旧脚本对不上(5 秒方案不能直接出 10 秒)。确认卡作废,前端再发一轮让模型重写。
if duration_changed:
return JsonResponse({
"regenerate": True,
"params": latest_params,
"message": None,
}, status=200)
is_image = (card.payload or {}).get("kind") == "image" or conversation.mode == CreationConversation.Mode.IMAGE
submitter = submit_confirmed_image if is_image else submit_confirmed_video
message, error = submitter(
conversation=conversation, user=request.user, confirm_message=card
)
if error:
# 出片没提交成功 → 把闸门放回去,用户可以改完再确认
card.payload = {**(card.payload or {}), "submitted": False}
card.save(update_fields=["payload", "updated_at"])
# 审核类提示只走 toast,不要落成对话气泡(上传时应已审完)
reviewish = any(
key in error
for key in ("审核", "已提交审核", "通过后即可引用", "上传素材")
)
if reviewish:
return JsonResponse({"detail": error, "message": None}, status=400)
failure = append_message(
conversation, role="assistant",
kind=CreationMessage.Kind.ERROR, text=error,
)
return JsonResponse(
{"detail": error, "message": CreationMessageSerializer(failure).data}, status=400
)
# 纯 Django 响应:这个 action 只挂了 SSE renderer,走 DRF Response 会渲染失败
return JsonResponse(
{"message": CreationMessageSerializer(message).data}, status=201
)
if kind == "elicit_answer":
reply_to = str(request.data.get("reply_to") or "").strip()
answers = request.data.get("answers")
if not reply_to or not isinstance(answers, dict):
return JsonResponse({"detail": "回答追问需要 reply_to 与 answers"}, status=400)
card = conversation.messages.filter(
id=reply_to, kind=CreationMessage.Kind.ELICIT
).first()
if card is None:
return JsonResponse({"detail": "追问卡不存在"}, status=404)
if (card.payload or {}).get("submitted"):
# 追问卡是一次性的:重复提交会让同一个问题在上下文里出现两次答案
return JsonResponse({"detail": "这个问题已经回答过了"}, status=409)
payload = dict(card.payload or {})
if payload.get("interaction") == "step_confirm":
short, force_creative_turn, continuation_instruction = _handle_step_confirm_answer(
conversation,
card=card,
answers=answers if isinstance(answers, dict) else {},
user=request.user,
)
if short is not None:
return short
text = ""
record_user_message = False
# force_creative / continuation 已设;跳过后面普通 elicit 逻辑
else:
if payload.get("interaction") == "selling_point_gate":
mode = str(answers.get("selling_point_mode") or "").strip().lower()
selling_point = str(answers.get("selling_point") or "").strip()
if mode not in {"manual", "auto"}:
return JsonResponse({"detail": "请选择自己填写卖点或系统推荐"}, status=400)
if mode == "manual" and not selling_point:
return JsonResponse({"detail": "请先填写一个真实卖点,或选择系统推荐"}, status=400)
if payload.get("interaction") == "person_source_gate":
source = str(answers.get("person_source") or "").strip()
if source not in {"local_upload", "model_library", "platform_generate"}:
return JsonResponse({"detail": "请选择本地上传、模特库或平台生成"}, status=400)
if source == "local_upload":
candidates = [
ref for ref in refs
if isinstance(ref, dict) and ref.get("type") == "character" and ref.get("id")
]
valid = any(
Asset.objects.filter(
team=conversation.team,
id=ref.get("id"),
is_deleted=False,
purged_at__isnull=True,
).exists()
for ref in candidates
)
if not valid:
return JsonResponse({"detail": "请先上传一张人物参考图"}, status=400)
elif source == "model_library":
candidates = [
ref for ref in refs
if isinstance(ref, dict) and ref.get("type") == "model" and ref.get("id")
]
valid = any(
AssetModel.objects.filter(
Q(team=conversation.team) | Q(is_official=True),
id=ref.get("id"),
is_deleted=False,
purged_at__isnull=True,
).exists()
for ref in candidates
)
if not valid:
return JsonResponse({"detail": "请先从模特库选择一位人物"}, status=400)
if payload.get("interaction") == "click_swap_mode_gate":
mode = str(answers.get("click_swap_mode") or "").strip()
if mode not in {"finger", "character"}:
return JsonResponse({"detail": "请选择只出手指出镜,或角色日常换款"}, status=400)
if payload.get("interaction") == "click_swap_sku_gate":
sequence = str(answers.get("sku_sequence") or "").strip()
if not sequence:
return JsonResponse({"detail": "请填写要展示的款式和切换顺序"}, status=400)
if payload.get("topic") == "cast_relation":
choice = str(answers.get("cast_relation") or "").strip()
valid_choices = {
str(option.get("value") or "")
for field in (payload.get("fields") or [])
if isinstance(field, dict) and field.get("key") == "cast_relation"
for option in (field.get("options") or [])
if isinstance(option, dict)
}
if not choice or choice not in valid_choices:
return JsonResponse({"detail": "请选择共同出镜或一位主讲角色"}, status=400)
payload["answers"] = answers
payload["submitted"] = True
card.payload = payload
card.save(update_fields=["payload", "updated_at"])
# 素材选择闸门:用户愿意时再展开列表;跳过则直接沿原任务继续。
if payload.get("interaction") == "step_confirm":
pass # 已在上面处理
elif payload.get("interaction") == "plot_twist_story_depth":
depth = set_plot_twist_story_depth(
conversation,
str(answers.get("story_depth") or ""),
)
if depth is None:
return JsonResponse({"detail": "请选择一个有效的故事深度"}, status=400)
text = ""
record_user_message = False
force_creative_turn = True
continuation_instruction = _plot_twist_depth_continuation(depth)
elif payload.get("interaction") == "plot_twist_directions":
choice = str(answers.get("story_direction") or "").strip()
if not choice:
return JsonResponse({"detail": "请选择一个剧情方向"}, status=400)
text = ""
record_user_message = False
force_creative_turn = True
continuation_instruction = _plot_twist_direction_continuation(
conversation, payload, choice
)
elif is_pain_point_conversation(conversation) and is_pain_point_direction_payload(payload):
fields = [item for item in (payload.get("fields") or []) if isinstance(item, dict)]
field_key = str((fields[0] if fields else {}).get("key") or "pain_point_direction")
choice = str(answers.get(field_key) or "").strip()
if not choice:
return JsonResponse({"detail": "请选择一个痛点方向"}, status=400)
text = ""
record_user_message = False
force_creative_turn = True
continuation_instruction = apply_pain_point_direction(
conversation, payload, choice
)
elif payload.get("interaction") == "selling_point_gate":
mode = str(answers.get("selling_point_mode") or "").strip().lower()
selling_point = str(answers.get("selling_point") or "").strip()
memory = dict(conversation.memory or {})
memory["selling_point_ready"] = True
memory["selling_point_mode"] = mode
memory["selling_point"] = selling_point if mode == "manual" else ""
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
text = ""
record_user_message = False
force_creative_turn = True
continuation_instruction = (
f"商家已确认核心卖点:【{selling_point}】。现在只调用 write_strategy 写创作策略,"
"再等待商家确认;策略、后续方案和视频都必须围绕这个真实卖点,不要替换或夸大。"
if mode == "manual"
else "商家选择系统推荐卖点。现在只调用 write_strategy 写创作策略;"
"从商品资料和现有素材中挑一个最容易被画面证明的真实核心卖点,不要虚构功效、价格或规格。"
)
elif payload.get("interaction") == "click_swap_mode_gate":
mode = str(answers.get("click_swap_mode") or "").strip()
instruction = apply_click_swap_mode(conversation, mode)
if not instruction:
return JsonResponse({"detail": "请选择只出手指出镜,或角色日常换款"}, status=400)
text = ""
record_user_message = False
force_creative_turn = True
continuation_instruction = instruction
elif payload.get("interaction") == "click_swap_sku_gate":
sequence = str(answers.get("sku_sequence") or "").strip()
text = ""
record_user_message = False
force_creative_turn = True
continuation_instruction = _store_click_swap_sequence(conversation, sequence)
elif payload.get("topic") == "cast_relation":
relation = apply_cast_relation_choice(
conversation,
str(answers.get("cast_relation") or ""),
)
if not relation:
return JsonResponse({"detail": "这个角色选项已经失效,请重新选择"}, status=400)
text = ""
record_user_message = False
force_creative_turn = True
continuation_instruction = (
f"商家已确认出镜安排:【{relation}】。保留全部已锁定角色,直接继续原任务;"
"后续策略、脚本、分镜和视频分段都严格保持该人物关系,不要再次追问。"
)
elif payload.get("interaction") == "person_source_gate":
source = str(answers.get("person_source") or "").strip()
if source == "platform_generate":
person_prompt = str(answers.get("person_prompt") or "").strip()[:500]
try:
generating = submit_generated_person_reference(
conversation=conversation,
user=request.user,
appearance_prompt=person_prompt,
)
except ValueError as exc:
# 生图没提交成功,把闸门放回去供用户换方式或重试。
payload["submitted"] = False
payload["answers"] = {}
card.payload = payload
card.save(update_fields=["payload", "updated_at"])
return JsonResponse({"detail": str(exc)}, status=400)
return JsonResponse({
"conversation_id": str(conversation.id),
"agent_status": conversation.agent_status,
"messages": [CreationMessageSerializer(generating).data],
}, status=202)
# 上传/模特库的人物立即进入实体锁定,不等 Celery turn 开始才保存。
# 这样即使用户刷新,后续 60s 两段也仍能取到同一张人物图。
person_refs = [
ref for ref in refs
if isinstance(ref, dict) and ref.get("type") in {"character", "model"} and ref.get("id")
]
pin_refs(conversation, person_refs)
memory = dict(conversation.memory or {})
memory["person_source"] = source
memory["person_source_ready"] = True
memory["person_source_pending"] = False
conversation.memory = memory
conversation.status = CreationConversation.Status.RUNNING
conversation.save(update_fields=["memory", "status", "updated_at"])
text = ""
record_user_message = False
force_creative_turn = True
is_pet = "宠物" in str(conversation.preset or "")
continuation_instruction = (
"用户已选定出镜宠物角色,该宠物形象已作为整条视频的固定身份参考。"
"直接继续创作;所有镜头和分段保持同一宠物,不要再追问角色来源。"
if is_pet else
"用户已选定出镜人物,该人物已作为整条视频的固定身份参考。"
"直接继续创作;所有镜头和分段保持同一人,不要再追问人物来源。"
)
elif payload.get("topic") == "product_info":
ans = str(answers.get("product_brand_and_name") or "").strip()
memory = dict(conversation.memory or {})
memory["product_info_resolved"] = True
clean_ans = ans.replace("品牌是:,商品名是:", "").strip()
if clean_ans and not any(kw in clean_ans for kw in ("按图片", "设计品牌", "不用再问")):
memory["product_brand_and_name"] = clean_ans
bm = re.search(r"品牌[:是为]?\s*([^\n,。!!]+)", clean_ans)
nm = re.search(r"(?:品名|商品名|产品名)[:是为]?\s*([^\n,。!!]+)", clean_ans)
if bm:
memory["product_brand"] = bm.group(1).strip()
if nm:
memory["product_name"] = nm.group(1).strip()
for r in (conversation.pinned_refs or []):
if isinstance(r, dict) and r.get("type") in ("asset", "product"):
r["name"] = clean_ans
break
conversation.pinned_refs = conversation.pinned_refs
continuation_instruction = f"用户已提供商品品牌与品名:【{clean_ans}】。直接围绕该商品推进方案,不要重复追问。"
else:
continuation_instruction = "用户要求直接根据图片内容设计品牌与品名推进创作。直接基于图片外观特征推进方案,不要重复追问品牌。"
conversation.memory = memory
conversation.save(update_fields=["memory", "pinned_refs", "updated_at"])
text = ""
record_user_message = False
force_creative_turn = True
elif payload.get("phase") == "gate":
choice = str(answers.get("_asset_gate") or "").strip()
pending = payload.get("pending_fields") or []
primary_field = pending[0] if pending else {}
gate_types = [item for item in (primary_field.get("asset_types") or []) if item in TYPE_LABELS] or ["product"]
is_product_gate = "product" in gate_types
if choice == "send":
types = gate_types
pick_field = dict(primary_field)
pick_field["label"] = _ASSET_CARD_LABELS.get(
types[0], _ASSET_CARD_LABELS.get("asset", "请选择素材")
)
pick = append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text=pick_field["label"],
payload={
"interaction": "asset_picker",
"phase": "pick",
"fields": [pick_field],
"submitted": False,
"answers": {},
},
)
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
return JsonResponse({
"conversation_id": str(conversation.id),
"agent_status": conversation.agent_status,
"messages": [CreationMessageSerializer(pick).data],
}, status=200)
elif choice == "auto":
if is_product_gate:
preferred, continuation_instruction = _auto_pick_product_continuation(
conversation, refs
)
refs = list(refs)
existing = {
(item.get("type"), str(item.get("id")))
for item in refs if isinstance(item, dict)
}
for item in preferred:
mark = (item.get("type"), str(item.get("id")))
if mark not in existing:
refs.append(item)
existing.add(mark)
_mark_product_source_resolved(conversation)
else:
hits = search_mentions(conversation.team, q="", types=gate_types, limit=1)
is_character_gate = any(t in ("character", "model") for t in gate_types)
if hits:
refs = list(refs)
refs.append(hits[0])
pin_refs(conversation, [hits[0]])
continuation_instruction = (
f"用户希望你帮忙挑选素材,已为你选定【{hits[0]['name']}】。"
"直接基于该素材推进创作方案,不要复述选项,不要再次 ask_user 追问同一类素材。"
)
else:
if is_character_gate:
# 库里没有角色却点了「你来推荐」:不能假完成,否则会跳到核心卖点。
# 改走人物来源闸门(上传 / 模特库 / 平台生成)。
memory = dict(conversation.memory or {})
memory.pop("person_source_ready", None)
memory.pop("person_source_pending", None)
memory["person_source"] = ""
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
gate = append_person_source_gate(conversation)
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
return JsonResponse({
"conversation_id": str(conversation.id),
"agent_status": conversation.agent_status,
"messages": [CreationMessageSerializer(gate).data],
}, status=200)
continuation_instruction = (
"用户希望你帮忙定素材,目前素材库暂无现成项。"
"按用户刚说的外观要求继续创作;不要再弹同一类「发列表 / 你来推荐」。"
)
if is_character_gate and hits:
memory = dict(conversation.memory or {})
memory["person_source"] = "auto"
memory["person_source_ready"] = True
memory["person_source_pending"] = False
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
text = ""
record_user_message = False
force_creative_turn = True
elif choice == "upload":
# 闸门「上传商品图」:用本轮 refs / 会话已上传图,禁止再去商品库抽奖。
preferred = _session_product_refs(conversation, refs)
if preferred:
pin_refs(conversation, preferred)
refs = list(refs)
existing = {
(item.get("type"), str(item.get("id")))
for item in refs if isinstance(item, dict)
}
for item in preferred:
mark = (item.get("type"), str(item.get("id")))
if mark not in existing:
refs.append(item)
existing.add(mark)
names = "、".join(_ref_display_name(item) for item in preferred[:6])
continuation_instruction = (
f"用户刚上传了商品参考图({names})。必须基于这些上传图推进创作,"
"禁止改用商品库里的其他商品。若品牌或具体品名不明确,先确认品牌与品名。"
)
else:
continuation_instruction = (
"用户选择上传商品图,但本轮尚未收到可用图片。"
"请提醒用户再传一张清晰的商品实物图,不要改从商品库挑选。"
)
text = ""
record_user_message = False
force_creative_turn = True
if is_product_gate:
_mark_product_source_resolved(conversation)
else:
# 卡片本身已经记录了用户的选择。不要再伪造一条黑色用户气泡;
# 继续原任务,并明确告诉模型不要再次追问同一项素材。
text = ""
record_user_message = False
force_creative_turn = True
if is_product_gate:
_mark_product_source_resolved(conversation)
continuation_instruction = (
"用户刚选择暂不添加这项素材。接受这个选择,按会话里已有的需求和合理默认继续原任务。"
"先用一句自然的话承接,随后直接推进创作;不要再次追问同一素材,不要只说收到或有需要再说。"
)
elif payload.get("phase") == "pick" and (
answers.get("_action") == "cancel"
or any(str(v).lower() in ("cancel", "取消", "skip", "跳过") for v in answers.values())
):
pick_fields = payload.get("fields") or []
primary = pick_fields[0] if pick_fields else {}
types = primary.get("asset_types") or []
key = str(primary.get("key") or "").strip()
if "product" in types or key == "product":
_mark_product_source_resolved(conversation)
if any(t in types for t in ("character", "model")) or key in ("character", "model", "person"):
memory = dict(conversation.memory or {})
memory["person_source_ready"] = True
memory["person_source_pending"] = False
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
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 and not (force_creative_turn or continuation_instruction):
# 追问卡(品牌品名/点选闸门等)会把正文清空、改走 continuation
# 这时没有 text/refs 也是合法推进,不能误报「消息不能为空」。
return JsonResponse({"detail": "消息不能为空"}, status=400)
# 同一账号同时只允许一个整理方案 turn(含本会话已在 planning 的二次发送)
if (
conversation.agent_status == CreationConversation.AgentStatus.PLANNING
or team_agent_busy(conversation.team)
):
return _agent_busy_response()
model_config_id = None
requested = request.data.get("model_config_id")
if requested:
model_config = (
ModelConfig.objects.select_related("provider")
.filter(id=requested, capability=ModelConfig.Capability.TEXT, status=ModelConfig.Status.ACTIVE)
.first()
)
if model_config is not None:
model_config_id = str(model_config.id)
turn_kwargs = {
"conversation_id": str(conversation.id),
"user_id": str(request.user.id),
"text": text,
"refs": refs,
"model_config_id": model_config_id,
"record_user_message": record_user_message,
"force_creative_turn": force_creative_turn,
"continuation_instruction": continuation_instruction,
}
inline = bool(getattr(settings, "CREATION_AGENT_INLINE", False))
queue = (getattr(settings, "CREATION_AGENT_TASK_QUEUE", "celery") or "celery").strip() or "celery"
if not inline:
# 共用远程 broker 时 broadcast inspect 常只回 K8s 节点(未注册本任务) → 误 503。
# 本机专用队列 airshelf.local 不依赖「远程已注册」闸;仍靠本机 worker -Q 保证消费。
if queue in {"celery", "airshelf.quick"}:
require_worker_task("apps.ai.tasks.run_creation_agent_turn_task")
if not begin_agent_planning(conversation):
return _agent_busy_response()
try:
if inline:
# 本机 inline:不入共享 broker,避免远程 K8s worker 偷走未注册任务
def _run_inline(kwargs=turn_kwargs):
try:
from apps.ai.creation_agent import run_creation_agent_turn
run_creation_agent_turn(**kwargs)
except Exception: # noqa: BLE001 — 线程内必须自收,否则永久 planning
logger.exception("creation agent inline turn failed")
try:
from apps.ai.models import CreationConversation
from apps.ai.creation import finish_agent_planning as _finish
conv = CreationConversation.objects.filter(
id=kwargs["conversation_id"]
).first()
if conv is not None:
_finish(conv, awaiting_user=False)
except Exception: # noqa: BLE001
logger.exception("creation agent inline: failed to finish planning")
transaction.on_commit(
lambda: threading.Thread(
target=_run_inline, name="creation-agent-inline", daemon=True
).start()
)
else:
run_creation_agent_turn_task.apply_async(kwargs=turn_kwargs, queue=queue)
except Exception:
# 入队失败要把锁和 planning 态收回,否则整团队卡死
finish_agent_planning(conversation, awaiting_user=False)
raise
return JsonResponse(
{
"conversation_id": str(conversation.id),
"agent_status": CreationConversation.AgentStatus.PLANNING,
},
status=202,
)
class MentionSearchView(APIView):
"""@ 引用检索(契约 §3)。
GET /api/ai/mentions/?q=净颜&types=product,character&limit=8
返回 [Ref] —— 前端把它按 type 分组渲染成 @ 菜单(设计稿 .omni-mention-group)。
**前端拿到后必须整条 Ref 存进消息的 refs 字段**,不能只把 name 拼进文本,
否则后端取不到卖点和参考图(契约 §1)。
"""
def get(self, request):
team = get_current_team(request.user)
q = str(request.query_params.get("q") or "").strip()
raw_types = str(request.query_params.get("types") or "").strip()
types = [t.strip() for t in raw_types.split(",") if t.strip()] if raw_types else None
if types:
unknown = [t for t in types if t not in VALID_TYPES]
if unknown:
raise ValidationError({"detail": f"未知引用类型:{'、'.join(unknown)}"})
try:
limit = int(request.query_params.get("limit") or 8)
except (TypeError, ValueError) as exc:
raise ValidationError({"detail": "limit 必须是整数"}) from exc
limit = max(1, min(limit, 20))
results = search_mentions(team, q=q, types=types, limit=limit)
return Response({"results": results, "type_labels": TYPE_LABELS})