+158
-32
@@ -40,6 +40,7 @@ from .creation_agent import (
|
||||
_RESTART_CONTINUATION,
|
||||
apply_cast_relation_choice,
|
||||
apply_click_swap_mode,
|
||||
append_multi_character_relation_gate,
|
||||
append_person_source_gate,
|
||||
apply_pain_point_direction,
|
||||
apply_confirm_params,
|
||||
@@ -47,22 +48,27 @@ from .creation_agent import (
|
||||
apply_session_params,
|
||||
emit_prompt_gate,
|
||||
emit_final_confirm_gate,
|
||||
insufficient_cast_refs_message,
|
||||
locked_product_references,
|
||||
set_plot_twist_story_depth,
|
||||
is_greeting,
|
||||
is_pain_point_conversation,
|
||||
is_pain_point_direction_payload,
|
||||
is_restart_intent,
|
||||
multi_character_relation_needs_clarification,
|
||||
restore_gated_step_after_cancel,
|
||||
get_video_gate_stage,
|
||||
set_video_gate_stage,
|
||||
sync_prompt_after_cast,
|
||||
submit_confirmed_image,
|
||||
submit_confirmed_video,
|
||||
submit_generated_person_reference,
|
||||
video_needs_person_source,
|
||||
is_incomplete_product_brand_answer,
|
||||
PRODUCT_BRAND_EMPTY_TEMPLATE,
|
||||
)
|
||||
from .tasks import run_creation_agent_turn_task
|
||||
from .mentions import TYPE_LABELS, VALID_TYPES, refs_from_elicit_answers, search_mentions
|
||||
from .mentions import TYPE_LABELS, VALID_TYPES, refs_from_elicit_answers, resolve_refs, search_mentions
|
||||
from .models import AITask, CreationConversation, CreationMessage, ImageConversation, ModelConfig
|
||||
from .serializers import (
|
||||
AITaskSerializer,
|
||||
@@ -73,7 +79,7 @@ from .serializers import (
|
||||
ImageConversationTrashSerializer,
|
||||
ModelConfigSerializer,
|
||||
)
|
||||
from .services import enqueue_standalone_images
|
||||
from .services import enqueue_standalone_images, normalize_cover_product_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -433,6 +439,44 @@ _STEP_REVISE_INSTRUCTIONS = {
|
||||
}
|
||||
|
||||
|
||||
def _advance_after_video_prompt(conversation: CreationConversation) -> list[CreationMessage]:
|
||||
"""Prompt 确认后按固定顺序补角色,再展示可调参数的最终生成确认。
|
||||
|
||||
视频架构与 Prompt 已经定稿,角色图只在这里锁定,保证预设的必经选择和
|
||||
架构撰写不会被人物来源卡片抢断。多角色时继续在同一阶段确认出镜关系。
|
||||
"""
|
||||
from .creation_agent import video_needs_person_source
|
||||
|
||||
if video_needs_person_source(conversation):
|
||||
set_video_gate_stage(conversation, "cast")
|
||||
return [append_person_source_gate(conversation)]
|
||||
if multi_character_relation_needs_clarification(conversation):
|
||||
set_video_gate_stage(conversation, "cast")
|
||||
return [append_multi_character_relation_gate(conversation)]
|
||||
synced = sync_prompt_after_cast(conversation)
|
||||
confirm = emit_final_confirm_gate(conversation)
|
||||
return [item for item in (synced, confirm) if item is not None]
|
||||
|
||||
|
||||
def _video_gate_response(
|
||||
conversation: CreationConversation,
|
||||
messages: list[CreationMessage],
|
||||
) -> JsonResponse:
|
||||
"""返回角色或最终确认卡;只有最终确认卡携带预计积分。"""
|
||||
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 messages],
|
||||
}
|
||||
if len(messages) == 1 and messages[0].kind == CreationMessage.Kind.CONFIRM:
|
||||
credits = int((messages[0].payload or {}).get("estimated_credits") or 0)
|
||||
if credits:
|
||||
body["estimated_credits"] = credits
|
||||
return JsonResponse(body, status=200)
|
||||
|
||||
|
||||
def _handle_step_confirm_answer(
|
||||
conversation: CreationConversation,
|
||||
*,
|
||||
@@ -497,20 +541,10 @@ def _handle_step_confirm_answer(
|
||||
return JsonResponse(body, status=200), False, ""
|
||||
|
||||
if step == "prompt":
|
||||
confirm = emit_final_confirm_gate(conversation)
|
||||
if confirm is None:
|
||||
messages = _advance_after_video_prompt(conversation)
|
||||
if not messages:
|
||||
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, ""
|
||||
return _video_gate_response(conversation, messages), False, ""
|
||||
|
||||
# 未知 step:当普通继续
|
||||
return None, True, "用户已确认上一步。继续推进创作,不要复述确认。"
|
||||
@@ -539,8 +573,12 @@ class GenerateImageView(APIView):
|
||||
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/…),用于后端注入平台版式块(优化版)
|
||||
# 平台套图:平台 id 已改为可选(页面不再选平台,默认通用电商主图规范);旧调用方传 canonical id
|
||||
# (taobao/douyin/…)仍注入平台版式块,向后兼容。
|
||||
platform_id = str(request.data.get("platform_id") or "").strip() or None
|
||||
# 平台套图:用户填写的商品信息(selling_points/effect/audience/specs/notes),只对 cover 生效,
|
||||
# 由 enqueue_standalone_images 规范化后写入 request_payload.product_info;不传 = 旧行为。
|
||||
product_info = request.data.get("product_info") if mode == "cover" else 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
|
||||
@@ -551,6 +589,25 @@ class GenerateImageView(APIView):
|
||||
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)
|
||||
# 图片创作输入框的 @ 引用和全能创作使用同一份解析契约:
|
||||
# 不信任前端传来的图片地址,只拿 type/id 回库取事实与真正的参考 Asset。
|
||||
raw_mentions = request.data.get("mention_refs") or []
|
||||
if not isinstance(raw_mentions, list):
|
||||
raw_mentions = []
|
||||
mention_refs = [
|
||||
{"type": str(item.get("type") or ""), "id": str(item.get("id") or "")}
|
||||
for item in raw_mentions
|
||||
if isinstance(item, dict)
|
||||
and str(item.get("type") or "") in VALID_TYPES
|
||||
and str(item.get("id") or "").strip()
|
||||
]
|
||||
resolved_mentions = resolve_refs(team, mention_refs)
|
||||
# @引用的素材与手动上传参考图都进同一条 image_edit 链路;保持解析器的
|
||||
# 角色 → 场景 → 商品顺序,再追加用户临时上传图并去重。
|
||||
reference_image_ids = list(dict.fromkeys(
|
||||
[str(item["asset_id"]) for item in resolved_mentions.references if item.get("asset_id")]
|
||||
+ reference_image_ids
|
||||
))
|
||||
if product_id:
|
||||
try:
|
||||
normalized_product_id = str(uuid.UUID(product_id))
|
||||
@@ -604,7 +661,7 @@ class GenerateImageView(APIView):
|
||||
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)
|
||||
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, reference_context=resolved_mentions.facts_text, platform_id=platform_id, product_info=product_info, 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(
|
||||
@@ -901,6 +958,8 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
|
||||
rp_prompt=KeyTextTransform("prompt", "request_payload"),
|
||||
rp_ratio=KeyTextTransform("ratio", "request_payload"),
|
||||
rp_platform_id=KeyTextTransform("platform_id", "request_payload"),
|
||||
# 平台套图商品信息(JSON 对象的文本形态),前端恢复批次后重跑沿用;旧记录无此键 → NULL
|
||||
rp_product_info=KeyTextTransform("product_info", "request_payload"),
|
||||
rp_model_id=KeyTextTransform("model_id", "request_payload"),
|
||||
rp_model_entity_id=KeyTextTransform("model_entity_id", "request_payload"),
|
||||
# 只在重跑任务里落此键(值恒为 True);键不存在 → NULL → 假值,存在 → "true"/"1" → 真值
|
||||
@@ -928,6 +987,7 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
|
||||
"model_id": t.rp_model_id or "",
|
||||
"model_entity_id": t.rp_model_entity_id or "",
|
||||
"platform_id": t.rp_platform_id or "",
|
||||
"product_info": normalize_cover_product_info(t.rp_product_info) or None,
|
||||
"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,
|
||||
@@ -2030,6 +2090,26 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
record_user_message = False
|
||||
force_creative_turn = True
|
||||
continuation_instruction = _plot_twist_depth_continuation(depth)
|
||||
elif payload.get("interaction") == "product_brief_review":
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["product_brief_reviewed"] = True
|
||||
if text:
|
||||
memory["product_brief_note"] = text
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
payload["answers"] = {
|
||||
"review_action": "supplement" if text else "continue",
|
||||
"product_brief_note": text,
|
||||
}
|
||||
payload["submitted"] = True
|
||||
payload["answered_via"] = "chat"
|
||||
pending.payload = payload
|
||||
pending.save(update_fields=["payload", "updated_at"])
|
||||
force_creative_turn = True
|
||||
continuation_instruction = (
|
||||
"用户已核对商品信息。把补充内容当作真实商品事实;未确认的品牌、价格、优惠或功效不得编造。"
|
||||
"继续完成视频架构。"
|
||||
)
|
||||
elif payload.get("interaction") == "plot_twist_directions":
|
||||
options = [item for item in (payload.get("directions") or []) if isinstance(item, dict)]
|
||||
chosen = next(
|
||||
@@ -2379,6 +2459,13 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
memory_now["person_confirm_pending"] = False
|
||||
conversation.memory = memory_now
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
# 角色图属于 Prompt 之后的固定闸门。用户确认平台定妆后,直接进入
|
||||
# 多角色安排或最终参数确认,不再重新跑一轮架构 / Prompt Agent。
|
||||
if get_video_gate_stage(conversation) == "cast":
|
||||
append_message(conversation, role="user", text=clean_text)
|
||||
messages = _advance_after_video_prompt(conversation)
|
||||
if messages:
|
||||
return _video_gate_response(conversation, messages)
|
||||
force_creative_turn = True
|
||||
continuation_instruction = (
|
||||
"用户已确认使用当前生成的角色出镜。"
|
||||
@@ -2455,7 +2542,7 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
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(
|
||||
latest_params, needs_rebuild = apply_confirm_params(
|
||||
conversation, incoming if isinstance(incoming, dict) else None
|
||||
)
|
||||
card.payload = {
|
||||
@@ -2464,14 +2551,34 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"params": latest_params,
|
||||
}
|
||||
card.save(update_fields=["payload", "updated_at"])
|
||||
# 改时长会让旧脚本对不上(5 秒方案不能直接出 10 秒)。确认卡作废,前端再发一轮让模型重写。
|
||||
if duration_changed:
|
||||
# 改时长或视频模型会影响能力与 Prompt。保留事实/素材,只重写受影响步骤。
|
||||
if needs_rebuild:
|
||||
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
|
||||
if not is_image:
|
||||
prompt = str((card.payload or {}).get("video_prompt") or "").strip()
|
||||
cast_gap = insufficient_cast_refs_message(conversation, prompt)
|
||||
if cast_gap:
|
||||
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
|
||||
if memory.get("person_source_pending"):
|
||||
# 历史确认卡可能仍可见;角色正在生成时不落 ERROR,也不重复创建角色闸门。
|
||||
card.payload = {**(card.payload or {}), "submitted": False}
|
||||
card.save(update_fields=["payload", "updated_at"])
|
||||
return JsonResponse({
|
||||
"detail": "角色图正在生成,完成后会自动继续到参数确认。",
|
||||
"message": None,
|
||||
}, status=409)
|
||||
# 兼容更新前已经生成的确认卡:把缺角色错误转换成明确的角色补全步骤。
|
||||
if video_needs_person_source(conversation, prompt):
|
||||
set_video_gate_stage(conversation, "cast")
|
||||
return _video_gate_response(
|
||||
conversation,
|
||||
[append_person_source_gate(conversation, prompt)],
|
||||
)
|
||||
submitter = submit_confirmed_image if is_image else submit_confirmed_video
|
||||
message, error = submitter(
|
||||
conversation=conversation, user=request.user, confirm_message=card
|
||||
@@ -2607,6 +2714,26 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
record_user_message = False
|
||||
force_creative_turn = True
|
||||
continuation_instruction = _plot_twist_depth_continuation(depth)
|
||||
elif payload.get("interaction") == "product_brief_review":
|
||||
action = str(answers.get("review_action") or "").strip()
|
||||
if action not in {"continue", "supplement"}:
|
||||
return JsonResponse({"detail": "请确认按现有信息继续,或补充真实商品信息"}, status=400)
|
||||
note = str(answers.get("product_brief_note") or "").strip()
|
||||
if action == "supplement" and not note:
|
||||
return JsonResponse({"detail": "请填写要补充的真实商品信息"}, status=400)
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["product_brief_reviewed"] = True
|
||||
if note:
|
||||
memory["product_brief_note"] = note
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
text = ""
|
||||
record_user_message = False
|
||||
force_creative_turn = True
|
||||
continuation_instruction = (
|
||||
"用户已核对商品信息。把卡片中的补充内容当作真实商品事实;"
|
||||
"任何未确认的品牌、价格、优惠或功效都不得编造。继续完成视频架构。"
|
||||
)
|
||||
elif payload.get("interaction") == "plot_twist_directions":
|
||||
choice = str(answers.get("story_direction") or "").strip()
|
||||
if not choice:
|
||||
@@ -2670,6 +2797,10 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
)
|
||||
if not relation:
|
||||
return JsonResponse({"detail": "这个角色选项已经失效,请重新选择"}, status=400)
|
||||
if get_video_gate_stage(conversation) == "cast":
|
||||
messages = _advance_after_video_prompt(conversation)
|
||||
if messages:
|
||||
return _video_gate_response(conversation, messages)
|
||||
text = ""
|
||||
record_user_message = False
|
||||
force_creative_turn = True
|
||||
@@ -2717,6 +2848,11 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
conversation.memory = memory
|
||||
conversation.status = CreationConversation.Status.RUNNING
|
||||
conversation.save(update_fields=["memory", "status", "updated_at"])
|
||||
# Prompt 已确认后才开始选角色;选择完成后直接开放可调视频参数。
|
||||
if get_video_gate_stage(conversation) == "cast":
|
||||
messages = _advance_after_video_prompt(conversation)
|
||||
if messages:
|
||||
return _video_gate_response(conversation, messages)
|
||||
text = ""
|
||||
record_user_message = False
|
||||
force_creative_turn = True
|
||||
@@ -2965,23 +3101,13 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
):
|
||||
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,
|
||||
# 全能创作不接受前端覆盖语言模型;worker 内只解析固定的 GPT-6 Luna。
|
||||
"model_config_id": None,
|
||||
"record_user_message": record_user_message,
|
||||
"force_creative_turn": force_creative_turn,
|
||||
"continuation_instruction": continuation_instruction,
|
||||
|
||||
Reference in New Issue
Block a user