解决已发现问题

This commit is contained in:
Azmat@qq.com
2026-09-17 15:00:15 +08:00
parent f3c49b36be
commit 5df038c635
24 changed files with 1913 additions and 94 deletions
+6 -1
View File
@@ -116,6 +116,8 @@ class AdminUserSerializer(serializers.ModelSerializer):
class AdminTaskSerializer(serializers.ModelSerializer): class AdminTaskSerializer(serializers.ModelSerializer):
team_name = serializers.CharField(source="team.name", read_only=True, default=None) team_name = serializers.CharField(source="team.name", read_only=True, default=None)
model_name = serializers.CharField(source="model_config.name", read_only=True, default=None) model_name = serializers.CharField(source="model_config.name", read_only=True, default=None)
# 不改变 AITask.task_type 的调度语义;后台展示/筛选使用来源分类。
task_category = serializers.SerializerMethodField()
cost_anomaly = serializers.SerializerMethodField() cost_anomaly = serializers.SerializerMethodField()
# 单任务毛利(¥):actual_cost(积分)÷汇率 base_cost。base_cost=0(成本未知)时 None,报表侧过滤 # 单任务毛利(¥):actual_cost(积分)÷汇率 base_cost。base_cost=0(成本未知)时 None,报表侧过滤
margin_yuan = serializers.SerializerMethodField() margin_yuan = serializers.SerializerMethodField()
@@ -125,11 +127,14 @@ class AdminTaskSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = AITask model = AITask
fields = [ fields = [
"id", "task_type", "status", "team", "team_name", "model_name", "id", "task_type", "task_category", "status", "team", "team_name", "model_name",
"estimated_cost", "actual_cost", "base_cost", "margin_yuan", "cost_anomaly", "error_code", "reapable", "created_at", "estimated_cost", "actual_cost", "base_cost", "margin_yuan", "cost_anomaly", "error_code", "reapable", "created_at",
] ]
read_only_fields = fields read_only_fields = fields
def get_task_category(self, obj) -> str:
return str(getattr(obj, "task_category", "standard") or "standard")
def get_cost_anomaly(self, obj) -> bool: def get_cost_anomaly(self, obj) -> bool:
return is_cost_anomaly(obj.estimated_cost, obj.actual_cost) return is_cost_anomaly(obj.estimated_cost, obj.actual_cost)
+22 -1
View File
@@ -403,9 +403,10 @@ class AdminTaskMonitorTests(TestCase):
] ]
self.assertTrue(task_selects) self.assertTrue(task_selects)
for sql in task_selects: for sql in task_selects:
self.assertNotIn("request_payload", sql) # 仅允许从 JSON 中提取 feature 做「全能创作」分类,不能把整份 Prompt/响应/报错读进列表页。
self.assertNotIn("response_payload", sql) self.assertNotIn("response_payload", sql)
self.assertNotIn("error_message", sql) self.assertNotIn("error_message", sql)
self.assertNotIn("x" * 100_000, str(response.data))
detail = self.ac.get(f"/api/admin/tasks/{self.t_ok.id}/") detail = self.ac.get(f"/api/admin/tasks/{self.t_ok.id}/")
self.assertEqual(detail.status_code, 200) self.assertEqual(detail.status_code, 200)
@@ -420,6 +421,26 @@ class AdminTaskMonitorTests(TestCase):
self.assertIn(str(self.t_anom.id), ids) self.assertIn(str(self.t_anom.id), ids)
self.assertNotIn(str(self.t_ok.id), ids) self.assertNotIn(str(self.t_ok.id), ids)
def test_omni_create_tasks_have_separate_category_and_filter(self):
task = self.AITask.objects.create(
team=self.team,
model_config=self.mc,
task_type=self.AITask.Type.FREE_VIDEO,
status=self.AITask.Status.SUCCEEDED,
estimated_cost="1.0",
actual_cost="1.0",
idempotency_key="k-omni-create",
request_payload={"feature": "omni_create", "prompt": "完整出片指令"},
)
all_rows = self.ac.get("/api/admin/tasks/").data["results"]
row = next(item for item in all_rows if item["id"] == str(task.id))
self.assertEqual(row["task_type"], "free_video")
self.assertEqual(row["task_category"], "omni_create")
category_rows = self.ac.get("/api/admin/tasks/?category=omni_create").data["results"]
self.assertEqual([item["id"] for item in category_rows], [str(task.id)])
def test_cost_anomaly_flag(self): def test_cost_anomaly_flag(self):
self.assertTrue(self.ac.get(f"/api/admin/tasks/{self.t_anom.id}/").data["cost_anomaly"]) self.assertTrue(self.ac.get(f"/api/admin/tasks/{self.t_anom.id}/").data["cost_anomaly"])
self.assertFalse(self.ac.get(f"/api/admin/tasks/{self.t_ok.id}/").data["cost_anomaly"]) self.assertFalse(self.ac.get(f"/api/admin/tasks/{self.t_ok.id}/").data["cost_anomaly"])
+12 -1
View File
@@ -3,7 +3,7 @@
import logging import logging
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
from django.db.models import Count, F, Q from django.db.models import Case, CharField, Count, F, Q, Value, When
from rest_framework import status from rest_framework import status
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from rest_framework.decorators import api_view, permission_classes from rest_framework.decorators import api_view, permission_classes
@@ -469,6 +469,14 @@ def admin_tasks(request):
qs = ( qs = (
AITask.objects.select_related("team", "model_config") AITask.objects.select_related("team", "model_config")
.defer("request_payload", "response_payload", "error_message") .defer("request_payload", "response_payload", "error_message")
# task_type 仍用于调度;用请求来源区分「全能创作」和自由视频/图片,不读取整份 Prompt。
.annotate(
task_category=Case(
When(request_payload__feature="omni_create", then=Value("omni_create")),
default=Value("standard"),
output_field=CharField(),
)
)
.order_by("-created_at") .order_by("-created_at")
) )
st = request.query_params.get("status") st = request.query_params.get("status")
@@ -479,6 +487,9 @@ def admin_tasks(request):
tt = request.query_params.get("task_type") tt = request.query_params.get("task_type")
if tt in dict(AITask.Type.choices): if tt in dict(AITask.Type.choices):
qs = qs.filter(task_type=tt) qs = qs.filter(task_type=tt)
category = request.query_params.get("category")
if category == "omni_create":
qs = qs.filter(request_payload__feature="omni_create")
team_id = request.query_params.get("team") team_id = request.query_params.get("team")
if team_id: if team_id:
qs = qs.filter(team_id=team_id) qs = qs.filter(team_id=team_id)
+93 -3
View File
@@ -103,10 +103,32 @@ def _message_task(message: CreationMessage):
@transaction.atomic @transaction.atomic
def fail_generating_message(message: CreationMessage, error: str) -> CreationMessage: def fail_generating_message(message: CreationMessage, error: str) -> CreationMessage:
"""生成失败:GENERATING 原地改成 ERROR,不另开一条,避免中间态刷屏。""" """生成失败:GENERATING 原地改成 ERROR,不另开一条,避免中间态刷屏。"""
is_person_reference = (message.payload or {}).get("kind") == "person_reference"
message.kind = CreationMessage.Kind.ERROR message.kind = CreationMessage.Kind.ERROR
message.text = (error or "生成失败")[:500] message.text = (error or "生成失败")[:500]
message.save(update_fields=["kind", "text", "updated_at"]) message.save(update_fields=["kind", "text", "updated_at"])
conversation = message.conversation conversation = message.conversation
if is_person_reference:
memory = dict(conversation.memory or {})
memory["person_source_pending"] = False
memory.pop("person_source_ready", None)
conversation.memory = memory
conversation.status = CreationConversation.Status.RUNNING
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.last_active_at = timezone.now()
conversation.save(update_fields=[
"memory", "status", "agent_status", "last_active_at", "updated_at",
])
append_message(
conversation,
role="assistant",
text="这次人物参考没生成成功。可以重新选择人物来源。",
payload={
"reply_hint": "选择一种方式继续…",
"reply_options": [{"label": "重新选择人物", "text": "重新选择人物来源"}],
},
)
return message
conversation.status = CreationConversation.Status.FAILED conversation.status = CreationConversation.Status.FAILED
conversation.last_active_at = timezone.now() conversation.last_active_at = timezone.now()
conversation.save(update_fields=["status", "last_active_at", "updated_at"]) conversation.save(update_fields=["status", "last_active_at", "updated_at"])
@@ -141,7 +163,13 @@ def sync_generating_message(message: CreationMessage) -> bool:
finish_generating_message(message, assets=assets, meta=_meta_from_task(task, message)) finish_generating_message(message, assets=assets, meta=_meta_from_task(task, message))
return True return True
if task.status in (AITask.Status.FAILED, AITask.Status.CANCELLED): if task.status in (AITask.Status.FAILED, AITask.Status.CANCELLED):
fail_generating_message(message, task.error_message or "生成失败") from .generation_errors import public_error_for_task
public_error = public_error_for_task(task)
fail_generating_message(
message,
public_error.fallback_message if public_error else (task.error_message or "生成失败"),
)
return True return True
return False return False
@@ -187,8 +215,12 @@ def _sync_segmented_video_message(message: CreationMessage) -> bool:
None, None,
) )
if failed is not None: if failed is not None:
from .generation_errors import public_error_for_task
number = next((index + 1 for index, task in enumerate(refreshed) if task.id == failed.id), 1) number = next((index + 1 for index, task in enumerate(refreshed) if task.id == failed.id), 1)
fail_generating_message(message, f"{number} 段生成失败:{failed.error_message or '请重试'}") public_error = public_error_for_task(failed, operation="video_generate")
detail = public_error.fallback_message if public_error else (failed.error_message or "请重试")
fail_generating_message(message, f"{number} 段生成失败:{detail}")
return True return True
assets: list[dict] = [] assets: list[dict] = []
completed_segments = 0 completed_segments = 0
@@ -397,10 +429,68 @@ def finish_generating_message(message: CreationMessage, *, assets: list[dict], m
重生成是**新开一条** GENERATING → RESULT,所以对话流仍然是往下叠加; 重生成是**新开一条** GENERATING → RESULT,所以对话流仍然是往下叠加;
这里改的只是同一次生成自己的中间态。 这里改的只是同一次生成自己的中间态。
""" """
original_payload = dict(message.payload or {})
is_person_reference = original_payload.get("kind") == "person_reference"
message.kind = CreationMessage.Kind.RESULT message.kind = CreationMessage.Kind.RESULT
message.payload = {**(message.payload or {}), **meta, "assets": assets} message.payload = {**original_payload, **meta, "assets": assets}
message.save(update_fields=["kind", "payload", "updated_at"]) message.save(update_fields=["kind", "payload", "updated_at"])
conversation = message.conversation conversation = message.conversation
if is_person_reference:
from apps.assets.models import Asset, Model
asset_id = str((assets[0] if assets else {}).get("id") or "")
asset = Asset.objects.filter(
id=asset_id,
team=conversation.team,
is_deleted=False,
purged_at__isnull=True,
).first()
if asset is None:
return fail_generating_message(message, "人物参考已生成,但未找到可锁定的图片资产")
model = Model.objects.filter(
team=conversation.team,
portrait_asset=asset,
is_deleted=False,
purged_at__isnull=True,
).first()
if model is None:
model = Model.objects.create(
team=conversation.team,
created_by=conversation.created_by,
name="平台生成出镜人物",
source=Model.Source.AI,
portrait_asset=asset,
description="由全能创作生成并锁定的视频出镜人物。",
metadata={"feature": "omni_create", "conversation_id": str(conversation.id)},
)
pin_refs(conversation, [{
"type": "model",
"id": str(model.id),
"name": model.name,
"cover": (assets[0] if assets else {}).get("cover") or (assets[0] if assets else {}).get("url") or "",
}])
memory = dict(conversation.memory or {})
memory["person_source"] = "platform_generate"
memory["person_source_pending"] = False
memory["person_source_ready"] = True
memory["person_model_id"] = str(model.id)
conversation.memory = memory
conversation.status = CreationConversation.Status.RUNNING
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.last_active_at = timezone.now()
conversation.save(update_fields=[
"memory", "status", "agent_status", "last_active_at", "updated_at",
])
append_message(
conversation,
role="assistant",
text="人物参考已生成并锁定。后续所有镜头和长视频分段都会使用这位人物。",
payload={
"reply_hint": "继续创作…",
"reply_options": [{"label": "继续创作", "text": "继续创作"}],
},
)
return message
conversation.status = CreationConversation.Status.COMPLETED conversation.status = CreationConversation.Status.COMPLETED
conversation.last_active_at = timezone.now() conversation.last_active_at = timezone.now()
conversation.save(update_fields=["status", "last_active_at", "updated_at"]) conversation.save(update_fields=["status", "last_active_at", "updated_at"])
+616 -21
View File
@@ -33,6 +33,7 @@ from .creation_presets import (
apply_image_preset_prompt, apply_image_preset_prompt,
apply_plot_twist_story_contract, apply_plot_twist_story_contract,
apply_video_preset_prompt, apply_video_preset_prompt,
is_click_swap_preset,
plot_twist_story_depth, plot_twist_story_depth,
plot_twist_story_contract, plot_twist_story_contract,
preset_guidance, preset_guidance,
@@ -41,7 +42,13 @@ from .creation_presets import (
) )
from .mentions import TYPE_LABELS, infer_field_types, resolve_refs, search_mentions from .mentions import TYPE_LABELS, infer_field_types, resolve_refs, search_mentions
from .models import CreationConversation, CreationMessage, ModelConfig from .models import CreationConversation, CreationMessage, ModelConfig
from .services import build_provider, get_default_model, get_seed_text_model, resolve_text_model from .services import (
build_provider,
enforce_no_embedded_captions,
get_default_model,
get_seed_text_model,
resolve_text_model,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -54,17 +61,89 @@ MAX_BILLED_GENERATIONS = 1
# 视频闸门阶段(落在 conversation.memory.stage;resume 靠它) # 视频闸门阶段(落在 conversation.memory.stage;resume 靠它)
# clarify → strategy → plan → prompt → confirm → done # clarify → strategy → plan → prompt → confirm → done
VIDEO_GATE_STAGES = ("clarify", "strategy", "plan", "prompt", "confirm", "done") VIDEO_GATE_STAGES = ("clarify", "strategy", "plan", "prompt", "confirm", "done")
PAIN_POINT_PRESET = "痛点解决演示"
PAIN_POINT_DIRECTION_KEY = "pain_point_direction"
_STEP_CONFIRM_LABELS = { _STEP_CONFIRM_LABELS = {
"strategy": "创作策略已写好。确认后继续写方案;要改就点「我想改」或直接说改哪里。", "strategy": "创作策略已写好。确认后继续写方案;要改就点「我想改」或直接说改哪里。",
"plan": "视频方案已写好。确认后我会整理出片细节,并带你确认生成参数;要改就点「我想改」或直接说改哪里。", "plan": "视频方案已写好。确认后我会整理出片细节,并带你确认生成参数;要改就点「我想改」或直接说改哪里。",
"prompt": "出片指令已整理好。确认后核对参数并生成;要改就点「我想改」或直接说改哪里。", "prompt": "出片指令已整理好。确认后核对参数并生成;要改就点「我想改」或直接说改哪里。",
} }
_PERSON_SOURCE_PRESETS = {
"痛点解决演示",
PLOT_TWIST_PRESET,
"达人口播种草",
"鱼眼换装",
}
_PERSON_VISUAL_RE = re.compile(
r"(人物|角色|模特|主角|达人|主播|出镜|口播|女生|女性|男生|男性|"
r"女主|男主|年轻人|手模|手部|真人|换装|穿搭|剧情|短剧)"
)
def is_plot_twist_conversation(conversation: CreationConversation) -> bool: def is_plot_twist_conversation(conversation: CreationConversation) -> bool:
return conversation.mode == CreationConversation.Mode.VIDEO and conversation.preset == PLOT_TWIST_PRESET return conversation.mode == CreationConversation.Mode.VIDEO and conversation.preset == PLOT_TWIST_PRESET
def is_pain_point_conversation(conversation: CreationConversation) -> bool:
return conversation.mode == CreationConversation.Mode.VIDEO and conversation.preset == PAIN_POINT_PRESET
def is_pain_point_direction_payload(payload: dict) -> bool:
fields = [item for item in (payload.get("fields") or []) if isinstance(item, dict)]
return bool(fields and str(fields[0].get("key") or "") == PAIN_POINT_DIRECTION_KEY)
def apply_pain_point_direction(
conversation: CreationConversation,
payload: dict,
choice: str,
) -> str:
"""把已选方向直接作为本轮核心痛点/卖点,后续不再重复追问核心卖点。"""
fields = [item for item in (payload.get("fields") or []) if isinstance(item, dict)]
options = (fields[0].get("options") or []) if fields else []
selected = next(
(
item for item in options
if isinstance(item, dict)
and str(item.get("value") or "") == str(choice or "")
),
None,
)
direction = str((selected or {}).get("label") or choice or "").strip()
memory = dict(conversation.memory or {})
memory["pain_point_direction_ready"] = True
memory["pain_point_direction"] = direction
memory["selling_point_ready"] = True
memory["selling_point_mode"] = "manual"
memory["selling_point"] = direction
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
return (
f"商家已选择痛点方向:【{direction}】。这个选择同时就是本轮要突出的核心痛点与核心卖点。"
"现在只调用 write_strategy 写创作策略,并让痛点、正常使用过程和可见结果都围绕它展开;"
"不要再询问核心卖点,不要复述选择,不要直接写方案或出片。"
)
def pain_point_direction_options_from_text(text: str) -> list[dict[str, str]]:
"""模型偶尔只输出三条列表而忘记 ask_user;把列表确定性转成可点击选项。"""
items: list[str] = []
for line in str(text or "").splitlines():
match = re.match(r"^\s*(?:[-*+•]|[1-3][.、.)])\s*(.+?)\s*$", line)
if not match:
continue
label = re.sub(r"\*\*", "", match.group(1)).strip()
if label and label not in items:
items.append(label)
if len(items) != 3:
return []
return [
{"value": f"direction_{index}", "label": label}
for index, label in enumerate(items, start=1)
]
def active_plot_twist_story_depth(conversation: CreationConversation) -> str: def active_plot_twist_story_depth(conversation: CreationConversation) -> str:
"""时长是最终事实来源;用户中途改时长后,故事结构必须随之切换。""" """时长是最终事实来源;用户中途改时长后,故事结构必须随之切换。"""
from_duration = plot_twist_story_depth(str((conversation.params or {}).get("duration") or "")) from_duration = plot_twist_story_depth(str((conversation.params or {}).get("duration") or ""))
@@ -260,6 +339,146 @@ def append_selling_point_gate(conversation: CreationConversation) -> CreationMes
) )
def has_locked_person_reference(conversation: CreationConversation) -> bool:
"""人物一致性的前提是会话里有可解析的人物 Ref。
本地上传人物图由前端标记为 character,模特库则是 model;普通 asset
不能被默认当人,否则商品图/场景图会误跳过这个闸门。
"""
return any(
isinstance(ref, dict) and ref.get("type") in {"model", "character"} and ref.get("id")
for ref in (conversation.pinned_refs or [])
)
def video_needs_person_source(conversation: CreationConversation, user_text: str = "") -> bool:
"""需要真人/角色的视频在写策略前必须先锁定人物来源。"""
if conversation.mode != CreationConversation.Mode.VIDEO or has_locked_person_reference(conversation):
return False
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
if memory.get("person_source_ready") or memory.get("person_source_pending"):
return False
if conversation.preset in _PERSON_SOURCE_PRESETS:
return True
recent = list(
conversation.messages.order_by("-seq").values_list("text", flat=True)[:12]
)
pending_prompt = str(memory.get("pending_video_prompt") or "")
return bool(_PERSON_VISUAL_RE.search("\n".join([user_text, pending_prompt, *recent])))
def append_person_source_gate(conversation: CreationConversation) -> CreationMessage:
"""可视化的人物来源闸门;三个选项分别进文件、模特库和生图流程。"""
return append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text="先确定这条视频的出镜人物。选定后,所有镜头和分段都会锁定同一位人物。",
payload={
"interaction": "person_source_gate",
"fields": [{
"key": "person_source",
"label": "选择人物来源",
"type": "single",
"required": True,
"options": [
{"value": "local_upload", "label": "本地上传"},
{"value": "model_library", "label": "从模特库选择"},
{"value": "platform_generate", "label": "平台帮忙生成"},
],
}],
"submitted": False,
"answers": {},
},
)
def click_swap_sequence(conversation: CreationConversation) -> str:
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
return str(memory.get("click_swap_sequence") or "").strip()
def click_swap_needs_sequence(conversation: CreationConversation) -> bool:
"""点击换款只有在商家明确款式和顺序后才能写脚本。"""
if conversation.mode != CreationConversation.Mode.VIDEO:
return False
if not is_click_swap_preset(conversation.preset):
return False
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
return not bool(memory.get("click_swap_ready") and click_swap_sequence(conversation))
def append_click_swap_sequence_gate(conversation: CreationConversation) -> CreationMessage:
return append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text="先确认要切换的款式和展示顺序。后续每次手指点击都会严格按这个顺序原位换款。",
payload={
"interaction": "click_swap_sku_gate",
"fields": [{
"key": "sku_sequence",
"label": "款式与切换顺序",
"type": "text",
"required": True,
"placeholder": "例如:黑色 → 白色 → 樱花粉",
}],
"submitted": False,
"answers": {},
},
)
def submit_generated_person_reference(*, conversation: CreationConversation, user) -> CreationMessage:
"""先生成独立人物定妆参考,完成后再由 creation.py 自动建模特并锁定。"""
from .services import enqueue_standalone_images
recent_user = list(
conversation.messages.filter(role=CreationMessage.Role.USER)
.order_by("-seq").values_list("text", flat=True)[:6]
)
brief = "\n".join(reversed([item.strip() for item in recent_user if item and item.strip()]))[:700]
prompt = (
"为短视频生成一张可反复用于锁定身份的真人模特定妆参考图。"
"只出现一位成年人物,正面或轻微三分之四角度,中近景,表情自然,"
"五官、发型、肤色、身形和服装细节清晰,简洁中性背景,写实摄影,"
"不要文字、水印、拼图、多人、遮挡脸部或夸张滤镜。"
)
if conversation.preset:
prompt += f" 适配视频预设:{conversation.preset}"
if brief:
prompt += f" 参考用户需求:{brief}"
tasks = enqueue_standalone_images(
team=conversation.team,
user=user,
prompt=prompt,
mode="model",
count=1,
ratio="portrait",
feature="omni_create",
)
task = tasks[0]
memory = dict(conversation.memory or {})
memory["person_source"] = "platform_generate"
memory["person_source_pending"] = True
conversation.memory = memory
conversation.status = CreationConversation.Status.RUNNING
conversation.agent_status = CreationConversation.AgentStatus.IDLE
conversation.save(update_fields=["memory", "status", "agent_status", "updated_at"])
return append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.GENERATING,
payload={
"task_id": str(task.id),
"kind": "person_reference",
"prompt": prompt,
"label": "正在生成人物参考",
},
task=task,
)
def _step_confirm_payload(step: str) -> dict: def _step_confirm_payload(step: str) -> dict:
return { return {
"interaction": "step_confirm", "interaction": "step_confirm",
@@ -472,7 +691,9 @@ IMAGE_MODEL_BY_LABEL = {
# 不能把每条片都悄悄压成 15 秒。 # 不能把每条片都悄悄压成 15 秒。
SMART_DURATION = 15 SMART_DURATION = 15
_SMART_DURATION_RE = re.compile( _SMART_DURATION_RE = re.compile(
r"(?<!\d)(\d{1,2}(?:\.\d+)?)\s*(?:-|—||~|至|到)\s*(\d{1,2}(?:\.\d+)?)\s*秒?" # 结尾必须明确带「秒/s」。原来的「秒?」会把“18–22岁”误当成 22 秒。
r"(?<!\d)(\d{1,2}(?:\.\d+)?)\s*(?:-|—||~|至|到)\s*(\d{1,2}(?:\.\d+)?)\s*(?:秒|s)",
re.IGNORECASE,
) )
_TOTAL_DURATION_RE = re.compile( _TOTAL_DURATION_RE = re.compile(
r"(?:总时长|成片时长|时长)\s*[:]?\s*(\d{1,2}(?:\.\d+)?)\s*秒", r"(?:总时长|成片时长|时长)\s*[:]?\s*(\d{1,2}(?:\.\d+)?)\s*秒",
@@ -516,9 +737,11 @@ video_prompt 是交给出片模型的完整制作文件,不是方案摘要、
禁止无依据出现破损、漏液、渗水、失效、异常变形、脏污,或把商品拿去做超出用途的压力测试。 禁止无依据出现破损、漏液、渗水、失效、异常变形、脏污,或把商品拿去做超出用途的压力测试。
不确定防水、防漏、承重、耐热、容量、材质等关键能力时,不编造测试和结果;要么问用户,要么改用可观察的正常使用动作。 不确定防水、防漏、承重、耐热、容量、材质等关键能力时,不编造测试和结果;要么问用户,要么改用可观察的正常使用动作。
- 画面中不出现新增字幕、花字、标题贴片、弹幕、角标、水印、购物浮层或说明性文字;口播只存在于声音,包装本身原有印刷字除外。 - 画面中不出现新增字幕、花字、标题贴片、弹幕、角标、水印、购物浮层或说明性文字;口播只存在于声音,包装本身原有印刷字除外。
- 结尾单列「全片一致性与禁用项」:重申角色、商品、场景、光线、服装/材质的连续性,以及本片最重要的画面禁用项 - 结尾单列「全片一致性与约束」:用正向、可执行的句子重申角色、商品、场景、光线、服装/材质的连续性。
- 用户说「商品说话 / 商品自述 / 商品拟人」时,默认无脸拟人:商品声音是画外角色声,商品本体不做口型、不新增卡通五官;性格只通过整体倾斜、转向、弹跳、进退、镜头和音效表达。只有用户明确要求可见卡通五官时才例外。 - 用户说「商品说话 / 商品自述 / 商品拟人」时,默认无脸拟人:商品声音是画外角色声,商品本体不做口型、不新增卡通五官;性格只通过整体倾斜、转向、弹跳、进退、镜头和音效表达。只有用户明确要求可见卡通五官时才例外。
- 平台安全优先于戏剧冲突:不要撰写或照抄暴力伤害、血腥、裸体或性暗示、违法犯罪、危险挑战、政治敏感、仇恨歧视、真实名人/IP 模仿等内容。若用户原意涉及上述内容,保留原来的情绪转折和商品卖点,改成成年人之间安全、非暴力、无违法的日常误会、压力或竞争,并采用原创人物与场景。儿童只能出现在安全的日常家庭场景,绝不参与危险、成人或营销煽动情节 - 审核安全必须在第一次生成时完成,不能依赖提交前清洗。没有锁定人物素材时,人物只写「成年女性 / 成年男性 / 成年人」,不要自创精确年龄区间,不使用带幼态联想的称呼、音色或人设。服装写日常得体,默认平视、自然俯拍或尊重主体的正面构图,不强调身体局部
- 最终 video_prompt 只使用正向安全描述。不要把平台风险类别、禁用词或用户原始高风险措辞逐项写进 Prompt,否定句、免责声明和「禁止出现某词」也不能照抄;先在内部把冲突改写成成年人之间积极、友善的日常互动,再输出改写后的可拍内容。
- 平台安全优先于戏剧冲突:若原意不适合直接出片,只保留情绪转折和真实商品卖点,改成成年人之间的日常误会、压力或良性竞争,并采用原创人物与场景;不要在输出中复述被替换掉的原情节。
- 不写医疗诊断、治愈/根治、绝对效果、虚构权威背书或夸大功效;把卖点改为真实可见的使用动作、材质细节和日常体验。最终 video_prompt 必须是可直接提交审核和出片的安全版本。 - 不写医疗诊断、治愈/根治、绝对效果、虚构权威背书或夸大功效;把卖点改为真实可见的使用动作、材质细节和日常体验。最终 video_prompt 必须是可直接提交审核和出片的安全版本。
""" """
@@ -553,12 +776,10 @@ PRODUCT_REALITY_GUARD = (
# 这一层不是替代平台审核,而是在脚本落成最终 video_prompt 前先把最容易被视频模型 # 这一层不是替代平台审核,而是在脚本落成最终 video_prompt 前先把最容易被视频模型
# 拦截的明确高风险表述改成等价的安全叙事。模型仍会收到下方完整约束,避免只靠关键词替换。 # 拦截的明确高风险表述改成等价的安全叙事。模型仍会收到下方完整约束,避免只靠关键词替换。
VIDEO_PLATFORM_SAFETY_GUARD = ( VIDEO_PLATFORM_SAFETY_GUARD = (
"【平台安全出片约束·最高优先级】全片仅呈现安全、合法适合公开传播的原创商业内容" "【平台安全出片约束·最高优先级】全片采用健康、友善、合法适合公开传播的原创商业表达"
"不出现或暗示暴力伤害、血腥、自残、裸体、性内容、违法犯罪、赌博、毒品、武器、危险挑战、" "出镜人物均明确为二十二岁以上成年人,着装与镜头语言自然得体,构图保持尊重。"
"政治敏感、仇恨歧视、真实名人或受保护 IP 模仿。若原剧情有冲突,只用成年人之间安全、" "所有情节通过日常互动、积极沟通和轻松表达推进,并以正向结果收束。"
"非暴力的日常误会、压力或竞争来表达,并以沟通或轻松反转收束。儿童仅可出现在安全的日常家庭场景," "商品只呈现已知事实、正常用途和可观察的使用体验,所有描述保持客观克制。"
"不得参与危险、成人或煽动性情节。商品功效只写已知事实和可见使用体验,不作医疗、治愈、"
"绝对化或虚构权威承诺。"
) )
_VIDEO_PLATFORM_SAFETY_REWRITES: tuple[tuple[re.Pattern[str], str], ...] = ( _VIDEO_PLATFORM_SAFETY_REWRITES: tuple[tuple[re.Pattern[str], str], ...] = (
( (
@@ -566,7 +787,7 @@ _VIDEO_PLATFORM_SAFETY_REWRITES: tuple[tuple[re.Pattern[str], str], ...] = (
r"暴打|殴打|群殴|互殴|动手伤人|打伤|捅伤|刺伤|砍伤|见血|鲜血(?:直流)?|血泊|" r"暴打|殴打|群殴|互殴|动手伤人|打伤|捅伤|刺伤|砍伤|见血|鲜血(?:直流)?|血泊|"
r"杀人|杀死|自杀|自残|割腕|跳楼|爆炸|绑架|虐待" r"杀人|杀死|自杀|自残|割腕|跳楼|爆炸|绑架|虐待"
), ),
"强烈情绪冲突以安全、非暴力方式化解", "强烈情绪冲突通过沟通与日常误会化解",
), ),
( (
re.compile(r"裸体|裸露|色情|性爱|性行为|性暗示|挑逗|床戏"), re.compile(r"裸体|裸露|色情|性爱|性行为|性暗示|挑逗|床戏"),
@@ -574,7 +795,7 @@ _VIDEO_PLATFORM_SAFETY_REWRITES: tuple[tuple[re.Pattern[str], str], ...] = (
), ),
( (
re.compile(r"吸毒|毒品交易|贩毒|赌博|赌钱|诈骗|抢劫|偷窃|枪战|持枪|开枪"), re.compile(r"吸毒|毒品交易|贩毒|赌博|赌钱|诈骗|抢劫|偷窃|枪战|持枪|开枪"),
"不涉及违法或危险行为的日常情节", "合规、稳妥的日常情节",
), ),
( (
re.compile(r"治愈|根治|药到病除|包治|抗癌|无副作用|百分之百(?:有效|治愈)|永久(?:有效|瘦)"), re.compile(r"治愈|根治|药到病除|包治|抗癌|无副作用|百分之百(?:有效|治愈)|永久(?:有效|瘦)"),
@@ -584,6 +805,18 @@ _VIDEO_PLATFORM_SAFETY_REWRITES: tuple[tuple[re.Pattern[str], str], ...] = (
re.compile(r"(?:模仿|复刻|扮演|像).{0,16}(?:明星|艺人|名人|网红|演员)"), re.compile(r"(?:模仿|复刻|扮演|像).{0,16}(?:明星|艺人|名人|网红|演员)"),
"采用原创人物设定与表演", "采用原创人物设定与表演",
), ),
(
re.compile(r"(?<!\d)18\s*(?:-|—||~|至|到)\s*22\s*岁\s*(?:软甜)?少女音"),
"二十二岁以上成年女性的清甜自然声线",
),
(
re.compile(r"(?<!\d)18\s*(?:-|—||~|至|到)\s*22\s*岁"),
"二十二岁以上",
),
(re.compile(r"少女"), "年轻成年女性"),
(re.compile(r"甜妹"), "甜美风成年女性"),
(re.compile(r"低角度轻微仰拍"), "略低于视线的正面拍摄"),
(re.compile(r"低角度鱼眼机位"), "正面鱼眼机位"),
) )
@@ -774,31 +1007,75 @@ def default_reply_options(
] ]
if has_context: if has_context:
text = str(assistant_text or "") text = str(assistant_text or "")
if re.search(r"颜色|色号|色彩|SKU|款式|几种", text, re.IGNORECASE): # 创作描述常同时出现商品、人物、场景、颜色。快捷回复只看末尾真正交给用户
# 决定的部分,避免正文里的普通名词抢走最后一句的意图。
paragraphs = [part.strip() for part in re.split(r"\n+", text) if part.strip()]
source = paragraphs[-1] if paragraphs else text
sentences = [part.strip() for part in re.split(r"(?<=[。!?!?])", source) if part.strip()]
guidance = "".join(sentences[-2:])[-240:] if sentences else source[-240:]
asks_for_detail = bool(re.search(
r"(?:额外|另外|其他|重点).{0,12}(?:突出|强调).{0,12}(?:细节|重点|卖点)|"
r"(?:细节|重点|卖点).{0,12}(?:突出|强调)",
guidance,
re.IGNORECASE,
))
asks_for_color_order = bool(re.search(
r"配色.{0,12}(?:顺序|排序|调换|调整)|(?:调换|调整).{0,12}配色",
guidance,
re.IGNORECASE,
))
if asks_for_detail and asks_for_color_order:
return [
{"label": "补充突出细节", "text": "我想补充需要额外突出的细节"},
{"label": "调整配色顺序", "text": "我想调整配色的展示顺序"},
{"label": "按当前描述继续", "text": "没有其他调整,按当前描述继续"},
]
if re.search(r"颜色|色号|色彩|配色|SKU|款式|几种|展示顺序", guidance, re.IGNORECASE):
return [ return [
{"label": "补充颜色和顺序", "text": "我来补充每个颜色和展示顺序"}, {"label": "补充颜色和顺序", "text": "我来补充每个颜色和展示顺序"},
{"label": "上传各款实物图", "text": "我补充各颜色/款式的实物图"}, {"label": "上传各款实物图", "text": "我补充各颜色/款式的实物图"},
{"label": "先按当前主款做", "text": "先按当前主款做,其他颜色后面再补"}, {"label": "先按当前主款做", "text": "先按当前主款做,其他颜色后面再补"},
] ]
if re.search(r"商品|产品|主推|哪款", text, re.IGNORECASE): if re.search(
r"(?:哪款|哪个|什么).{0,8}(?:商品|产品)|"
r"(?:商品|产品).{0,12}(?:选择|选|换|更换|主推|想推|要推)|"
r"(?:选择|选|换|更换|主推|想推|要推).{0,12}(?:商品|产品)",
guidance,
re.IGNORECASE,
):
return [ return [
{"label": "发商品列表", "text": "把商品列表发给我选"}, {"label": "发商品列表", "text": "把商品列表发给我选"},
{"label": "我直接说商品名", "text": "我直接告诉你商品名"}, {"label": "我直接说商品名", "text": "我直接告诉你商品名"},
{"label": "你来推荐", "text": "你根据当前需求推荐一款"}, {"label": "你来推荐", "text": "你根据当前需求推荐一款"},
] ]
if re.search(r"人物|角色|模特|出镜", text, re.IGNORECASE): if re.search(
r"(?:哪位|哪个|什么|选择|选|换|更换|调整|修改|改).{0,12}(?:人物|角色|模特|出镜)|"
r"(?:人物|角色|模特|出镜).{0,12}(?:哪位|哪个|选择|选|换|更换|调整|修改|改)",
guidance,
re.IGNORECASE,
):
return [ return [
{"label": "上传人物图", "text": "我上传人物参考图"}, {"label": "上传人物图", "text": "我上传人物参考图"},
{"label": "由你设定角色", "text": "你先帮我设定一个合适的角色"}, {"label": "由你设定角色", "text": "你先帮我设定一个合适的角色"},
{"label": "不需要人物", "text": "这条先不需要人物出镜"}, {"label": "不需要人物", "text": "这条先不需要人物出镜"},
] ]
if re.search(r"场景|地点|背景|在哪", text, re.IGNORECASE): if re.search(
r"(?:哪里|哪儿|什么|选择|选|换|更换|调整|修改|改).{0,12}(?:场景|地点|背景)|"
r"(?:场景|地点|背景).{0,12}(?:哪里|哪儿|选择|选|换|更换|调整|修改|改)",
guidance,
re.IGNORECASE,
):
return [ return [
{"label": "上传场景图", "text": "我上传场景参考图"}, {"label": "上传场景图", "text": "我上传场景参考图"},
{"label": "你来推荐场景", "text": "你按商品和预设推荐场景"}, {"label": "你来推荐场景", "text": "你按商品和预设推荐场景"},
{"label": "用干净日常场景", "text": "先用干净自然的日常场景"}, {"label": "用干净日常场景", "text": "先用干净自然的日常场景"},
] ]
if re.search(r"卖点|功能|效果|优惠|价格", text, re.IGNORECASE): if re.search(
r"(?:补充|选择|选|换|更换|调整|修改|改|突出|强调).{0,12}(?:卖点|功能|效果|优惠|价格)|"
r"(?:卖点|功能|效果|优惠|价格).{0,12}(?:补充|选择|选|换|更换|调整|修改|改|突出|强调)",
guidance,
re.IGNORECASE,
):
return [ return [
{"label": "补充真实卖点", "text": "我来补充商品真实卖点"}, {"label": "补充真实卖点", "text": "我来补充商品真实卖点"},
{"label": "从素材里判断", "text": "先根据我上传的素材判断可表达的卖点"}, {"label": "从素材里判断", "text": "先根据我上传的素材判断可表达的卖点"},
@@ -965,6 +1242,10 @@ def apply_restart_intent(conversation: CreationConversation) -> int:
memory.pop("selling_point_ready", None) memory.pop("selling_point_ready", None)
memory.pop("selling_point_mode", None) memory.pop("selling_point_mode", None)
memory.pop("selling_point", None) memory.pop("selling_point", None)
memory.pop("pain_point_direction_ready", None)
memory.pop("pain_point_direction", None)
memory.pop("click_swap_ready", None)
memory.pop("click_swap_sequence", None)
conversation.memory = memory conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"]) conversation.save(update_fields=["memory", "updated_at"])
@@ -1181,6 +1462,15 @@ def confirm_param_options(is_video: bool) -> dict:
} }
def _normalize_confirm_duration(value) -> tuple[str, int | str]:
"""确认卡只按实际秒数判断时长变化,兼容「8秒 / 8 秒」等历史格式。"""
raw = str(value or "").strip()
match = re.search(r"\d+(?:\.\d+)?", raw)
if match:
return "seconds", int(float(match.group()))
return "label", re.sub(r"\s+", "", raw).lower()
def apply_confirm_params(conversation, incoming: dict | None) -> tuple[dict, bool]: def apply_confirm_params(conversation, incoming: dict | None) -> tuple[dict, bool]:
"""确认卡上改的参数写回会话。返回 (最新 params, 视频时长是否变了)。""" """确认卡上改的参数写回会话。返回 (最新 params, 视频时长是否变了)。"""
current = dict(conversation.params or {}) current = dict(conversation.params or {})
@@ -1192,11 +1482,13 @@ def apply_confirm_params(conversation, incoming: dict | None) -> tuple[dict, boo
value = str(raw or "").strip() value = str(raw or "").strip()
if not value or current.get(key) == value: if not value or current.get(key) == value:
continue continue
if key == "duration" and _normalize_confirm_duration(current.get(key)) == _normalize_confirm_duration(value):
continue
current[key] = value current[key] = value
changed = True changed = True
duration_changed = ( duration_changed = (
conversation.mode == CreationConversation.Mode.VIDEO conversation.mode == CreationConversation.Mode.VIDEO
and str(current.get("duration") or "") != old_duration and _normalize_confirm_duration(current.get("duration")) != _normalize_confirm_duration(old_duration)
and bool(str(current.get("duration") or "")) and bool(str(current.get("duration") or ""))
and bool(old_duration) and bool(old_duration)
) )
@@ -1330,6 +1622,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
"description": ( "description": (
"写「创作策略理解」卡:说清这条片给谁看、他为什么会信、你想让他信什么、整体创作方向。" "写「创作策略理解」卡:说清这条片给谁看、他为什么会信、你想让他信什么、整体创作方向。"
"四个字段都必须写具体非空文案,禁止空字符串。" "四个字段都必须写具体非空文案,禁止空字符串。"
"策略从第一稿就使用健康、正向、明确成年的人物与情节表达,不要复述需要规避的原始措辞。"
"调完会停下来等用户确认或提出修改,不要同轮接着 write_plan。" "调完会停下来等用户确认或提出修改,不要同轮接着 write_plan。"
"仅当用户明确要做片/出方案时调用;打招呼或闲聊不要调。" "仅当用户明确要做片/出方案时调用;打招呼或闲聊不要调。"
), ),
@@ -1357,6 +1650,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
"用户确认方案后由平台展示 Prompt。" "用户确认方案后由平台展示 Prompt。"
"video_prompt 按系统里的「制作级交付」写成完整 Prompt 文件:必须有整体规则、" "video_prompt 按系统里的「制作级交付」写成完整 Prompt 文件:必须有整体规则、"
"声音/灯光/场景/参考素材锁定、逐镜四行细节和一致性收束,不要只写大纲。" "声音/灯光/场景/参考素材锁定、逐镜四行细节和一致性收束,不要只写大纲。"
"第一稿必须已经可直接过平台审核:只写正向安全描述,不要输出风险词清单或否定式免责声明。"
"先有已确认的 write_strategy,再调它。" "先有已确认的 write_strategy,再调它。"
), ),
"parameters": { "parameters": {
@@ -1390,6 +1684,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
"时长任务、标题、风格、镜头语言、视觉美术、色彩材质、打光、剪辑、声音、场景、主体参考、逐镜脚本、一致性收束。" "时长任务、标题、风格、镜头语言、视觉美术、色彩材质、打光、剪辑、声音、场景、主体参考、逐镜脚本、一致性收束。"
"每一镜严格包含拍法/画面内容/主体或产品露出/声音四行;15 秒至少 4 镜,含人声原文、拟音和 BGM 节奏。" "每一镜严格包含拍法/画面内容/主体或产品露出/声音四行;15 秒至少 4 镜,含人声原文、拟音和 BGM 节奏。"
"已 @ 素材标明用途与需锁定的特征;禁止把口播做成画面文字。" "已 @ 素材标明用途与需锁定的特征;禁止把口播做成画面文字。"
"人物必须明确为成年人且构图得体;只写正向可拍内容,不列风险词或禁用词。"
), ),
}, },
}, },
@@ -1655,11 +1950,34 @@ def segment_video_prompt(prompt: str, segment: dict, total_duration: int) -> str
return ( return (
f"{prompt.strip()}\n\n" f"{prompt.strip()}\n\n"
f"【分段出片约束】这是整支 {total_duration} 秒视频的第 {index} 段,只生成 {start}{end} 秒的内容。" f"【分段出片约束】这是整支 {total_duration} 秒视频的第 {index} 段,只生成 {start}{end} 秒的内容。"
"仅呈现这一时间段对应的情节与镜头,承接上一段的角色、服装、商品、场景和光线" "仅呈现这一时间段对应的情节与镜头。必须继续使用参考图锁定的同一位人物"
"不得在本段重新设计、随机替换或改变其五官、发型、年龄、身形和服装。"
"承接上一段的动作、商品、场景和光线,"
"为下一段留出自然动作衔接;不要重演完整故事,不要添加字幕、文字、角标或水印。" "为下一段留出自然动作衔接;不要重演完整故事,不要添加字幕、文字、角标或水印。"
) )
def apply_person_identity_guard(prompt: str, references: list[dict]) -> str:
"""把解析后的人物参考编号写进最终出片 Prompt。
resolve_refs 会把人物排在最前,但仍按真实位置计算 @图N,避免混入
场景/商品后编号指错。
"""
indexes = [
index for index, item in enumerate(references or [], start=1)
if isinstance(item, dict) and item.get("type") in {"model", "character"}
]
if not indexes:
return prompt
labels = "".join(f"参考图{index}" for index in indexes)
return (
f"{prompt.strip()}\n\n【人物一致性硬约束】{labels}定义本片的固定出镜人物。"
"整片及所有分段、远景、近景、转场都必须保持同一人;五官比例、脸型、发型、"
"肤色、年龄、身形、手部特征和基础服装不得漂移。不得换人、随机造人、合并成新面孔,"
"不得因镜头或光线变化而改变身份。"
)
def video_duration(params: dict, *, prompt: str = "", timeline: list[dict] | None = None) -> int: def video_duration(params: dict, *, prompt: str = "", timeline: list[dict] | None = None) -> int:
"""显式时长优先;智能时长从方案/Prompt 推导,完全缺失才回退 15 秒。""" """显式时长优先;智能时长从方案/Prompt 推导,完全缺失才回退 15 秒。"""
raw = str(params.get("duration") or "") raw = str(params.get("duration") or "")
@@ -1804,6 +2122,7 @@ def _video_submit_params(context: AgentContext, prompt: str) -> tuple[dict, list
active_plot_twist_story_depth(context.conversation), active_plot_twist_story_depth(context.conversation),
prompt, prompt,
) )
prompt = apply_person_identity_guard(prompt, resolved.references)
duration = resolve_smart_video_duration(context.conversation, prompt=prompt) duration = resolve_smart_video_duration(context.conversation, prompt=prompt)
# resolve_smart_video_duration 可能为了完整脚本切到支持长时长的模型,必须重新取参数。 # resolve_smart_video_duration 可能为了完整脚本切到支持长时长的模型,必须重新取参数。
params = context.conversation.params or {} params = context.conversation.params or {}
@@ -1818,6 +2137,9 @@ def _video_submit_params(context: AgentContext, prompt: str) -> tuple[dict, list
"generate_audio": True, "generate_audio": True,
"references": resolved.references, "references": resolved.references,
} }
if any(item.get("type") in {"model", "character"} for item in resolved.references):
# 长视频的多个任务共用同一个确定性 seed,减少两段各自随机采样导致的脸部/服装漂移。
submit["seed"] = int(str(context.conversation.id).replace("-", "")[:8], 16)
return submit, resolved.references return submit, resolved.references
@@ -1901,10 +2223,19 @@ def submit_confirmed_video(*, conversation: CreationConversation, user, confirm_
tasks = [] tasks = []
try: try:
for segment in segments: for segment in segments:
segment_prompt = (
segment_video_prompt(submit["prompt"], segment, total_duration)
if len(segments) > 1
else submit["prompt"]
)
segment_submit = { segment_submit = {
**submit, **submit,
"duration": int(segment["duration"]), "duration": int(segment["duration"]),
"prompt": segment_video_prompt(prompt, segment, total_duration) if len(segments) > 1 else submit["prompt"], # 长视频也必须从完整的最终 Prompt 分段。以前这里误用原始 prompt,
# 会丢掉预设约束、商品安全约束和人物锁定,是 60s 前后换人的直接原因。
# 分段之后再执行一次统一清洗:移除模型/用户写进正文的任何屏显指令,
# 并让每个独立视频任务的首尾都带最高优先级画面洁净约束。
"prompt": enforce_no_embedded_captions(segment_prompt),
"extra_payload": { "extra_payload": {
"omni_segment": { "omni_segment": {
"index": segment["index"], "index": segment["index"],
@@ -2135,6 +2466,7 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
"- 可以自己决定转场、灯光、普通镜头细节;商品功能、规格、价格、活动、功效和关键使用边界不能猜,缺失时一次只问一项。", "- 可以自己决定转场、灯光、普通镜头细节;商品功能、规格、价格、活动、功效和关键使用边界不能猜,缺失时一次只问一项。",
"- 用户上传的人物、商品、服装和场景优先作为参考;如确实需要额外生成角色、场景或道具,先说明用途和预计积分,等用户确认。", "- 用户上传的人物、商品、服装和场景优先作为参考;如确实需要额外生成角色、场景或道具,先说明用途和预计积分,等用户确认。",
"- 出片前检查:主卖点都有画面或台词证据、口播能在时长内说完、脚本中每位人物/商品/服装/场景都有对应素材、商品正常使用、全片一致、所有 SKU 都已安排。内容超出时长时建议删减、延长或拆分,而不是硬塞。", "- 出片前检查:主卖点都有画面或台词证据、口播能在时长内说完、脚本中每位人物/商品/服装/场景都有对应素材、商品正常使用、全片一致、所有 SKU 都已安排。内容超出时长时建议删减、延长或拆分,而不是硬塞。",
"- 安全检查前置到创作第一稿:人物默认明确为成年人,服装与构图得体;策略、方案和 video_prompt 只写改写后的正向可拍内容,不复述风险情节,不罗列平台禁用词,也不写否定式免责声明。",
]) ])
# 按会话时长给出口播字数锚点(与专业创作 narration_limit 同口径) # 按会话时长给出口播字数锚点(与专业创作 narration_limit 同口径)
try: try:
@@ -2237,6 +2569,21 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
"每张卡写清冲突、商品如何推进剧情、反转和情绪;不得只在文字中说‘我准备了三个方向’," "每张卡写清冲突、商品如何推进剧情、反转和情绪;不得只在文字中说‘我准备了三个方向’,"
"不得先 write_strategy / write_plan,也不要让用户输入编号。" "不得先 write_strategy / write_plan,也不要让用户输入编号。"
) )
if is_pain_point_conversation(conversation):
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
selected_direction = str(memory.get("pain_point_direction") or "").strip()
if selected_direction:
lines.append(
f"用户已选择痛点方向:【{selected_direction}】。这个方向就是已确认的核心卖点;"
"直接围绕它写策略,不得再询问核心卖点。"
)
else:
lines.append(
"【强制下一步·痛点方向三选一】先根据商品事实与可见素材整理恰好 3 个明显不同、可被画面证明的痛点方向。"
"必须调用 ask_userfield key 固定为 pain_point_directiontype=singleoptions 恰好 3 项;"
"每个 label 直接写完整的‘具体困扰 + 商品正常使用后可见结果’,让用户点击即选中。"
"不得只在正文里列三条,不得要求用户回复编号,不得先 write_strategy,也不得另问核心卖点。"
)
workflow_guidance = preset_workflow_guidance(conversation.preset) if context.is_video else "" workflow_guidance = preset_workflow_guidance(conversation.preset) if context.is_video else ""
if workflow_guidance: if workflow_guidance:
lines.append(f"【当前预设的工作重点】{workflow_guidance}") lines.append(f"【当前预设的工作重点】{workflow_guidance}")
@@ -2516,6 +2863,64 @@ def _coerce_strategy_args(args: dict) -> dict:
} }
_STRATEGY_TEXT_SECTION_ALIASES = {
"目标受众": "target",
"目标人群": "target",
"这条视频给谁看": "target",
"给谁看": "target",
"用户为什么相信": "trust",
"为什么相信": "trust",
"内容逻辑": "trust",
"可信依据": "trust",
"信任依据": "trust",
"希望用户相信什么": "belief",
"希望相信": "belief",
"核心卖点": "belief",
"核心主张": "belief",
"创作方向": "direction",
"视觉调性": "direction",
"视觉风格": "direction",
"表达方向": "direction",
}
def strategy_args_from_text(text: str) -> dict:
"""模型漏调 write_strategy 时,把带明确栏目名的策略正文救回结构化卡片。
只接受四个栏目都能识别的高置信文本,普通聊天不会被误转成策略卡。
"""
sections = {"target": [], "trust": [], "belief": [], "direction": []}
current = ""
for raw_line in str(text or "").splitlines():
line = re.sub(r"^\s*(?:[-*#>]+\s*)?", "", raw_line).replace("**", "").strip()
if not line:
continue
match = re.match(r"^([^:\n]{2,36})\s*[:]\s*(.*)$", line)
if match:
heading = re.sub(r"[(].*$", "", match.group(1)).strip().replace(" ", "")
key = _STRATEGY_TEXT_SECTION_ALIASES.get(heading)
if key:
current = key
content = match.group(2).strip()
if content:
sections[key].append(content)
continue
if heading in {"创作策略", "策略理解", "创作策略理解"}:
current = ""
continue
if not current:
continue
if re.match(r"^(?:你看|请确认|如果你|是否需要|可以再)", line):
continue
sections[current].append(line)
payload = {
key: "\n".join(parts).strip()
for key, parts in sections.items()
}
return payload if all(payload.values()) else {}
def _coerce_plan_card_args(args: dict) -> dict: def _coerce_plan_card_args(args: dict) -> dict:
usp = _pick_str(args, "usp", "主打卖点", "卖点", "core_usp", "main_point") usp = _pick_str(args, "usp", "主打卖点", "卖点", "core_usp", "main_point")
points = _coerce_points( points = _coerce_points(
@@ -2543,6 +2948,69 @@ def _coerce_plan_card_args(args: dict) -> dict:
} }
def apply_click_swap_plan_card(
conversation: CreationConversation,
card: dict,
duration: int,
) -> dict:
"""把模型可能写偏的方案卡收束成可核对的点击换款时间轴。"""
if not is_click_swap_preset(conversation.preset):
return card
sequence = click_swap_sequence(conversation)
if not sequence:
return card
total = max(4, min(int(duration or 0), 60))
first_end = max(1, round(total * 0.2))
second_end = max(first_end + 1, round(total * 0.45))
third_end = max(second_end + 1, round(total * 0.75))
third_end = min(third_end, total - 1)
second_end = min(second_end, third_end - 1)
return {
**card,
"usp": f"手指逐次点击,商品按「{sequence}」在原位连续换款",
"points": [
"固定机位、背景、光线与商品中心位置",
"每次换款都由一次清楚的手指点击触发",
f"严格按「{sequence}」逐款展示,结尾给出全款式总览",
],
"timeline": [
{
"start": 0,
"end": first_end,
"stage": "首款定帧",
"desc": "固定机位建立首款,商品位置、尺寸、角度和背景作为后续唯一基准。",
},
{
"start": first_end,
"end": second_end,
"stage": "首次点击换款",
"desc": "手指清晰点击商品,接触瞬间在原位 match cut 为下一款。",
},
{
"start": second_end,
"end": third_end,
"stage": "按序连续换款",
"desc": f"按「{sequence}」继续一触一换;机位、构图、商品比例和光线完全不变。",
},
{
"start": third_end,
"end": total,
"stage": "全款式收束",
"desc": "保持同一构图完成全款式总览,不加入口播、剧情、换景或字幕。",
},
],
"matrix": {
"shots": 4,
"rows": [
{"point": "固定构图", "hits": [1, 2, 3, 4]},
{"point": "点击触发", "hits": [2, 3]},
{"point": "款式顺序", "hits": [1, 2, 3, 4]},
],
},
"voice_chars": [0, 0],
}
def iter_creation_agent_events( def iter_creation_agent_events(
*, *,
conversation: CreationConversation, conversation: CreationConversation,
@@ -2633,6 +3101,27 @@ def iter_creation_agent_events(
yield {"type": "done"} yield {"type": "done"}
return return
# 点击换款的款式清单和顺序是脚本事实,不能让模型自行猜色号或把预设改成普通展示片。
if click_swap_needs_sequence(conversation):
question = append_click_swap_sequence_gate(conversation)
set_video_gate_stage(conversation, "clarify")
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
yield {"type": "message", "message": _message_payload(question)}
yield {"type": "done"}
return
# 需要真人/角色的视频必须先选定人物来源。这是平台闸门,
# 不交给模型自由发挥,否则它会在脚本里随机造人,到 60s 分段时必然漂移。
if video_needs_person_source(conversation, text):
question = append_person_source_gate(conversation)
set_video_gate_stage(conversation, "clarify")
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
yield {"type": "message", "message": _message_payload(question)}
yield {"type": "done"}
return
# 明确要商品/角色/场景列表时,直接生成真实选择卡,绝不先让模型念出素材名称。 # 明确要商品/角色/场景列表时,直接生成真实选择卡,绝不先让模型念出素材名称。
requested_card = requested_asset_card_from_context(conversation, text) requested_card = requested_asset_card_from_context(conversation, text)
if requested_card: if requested_card:
@@ -2748,6 +3237,26 @@ def iter_creation_agent_events(
"asset_types": ["product"], "asset_types": ["product"],
}] }]
# 痛点解决预设若模型只写了三条列表却漏调 ask_user,平台直接把这三条
# 转成单选按钮;不允许用户再手抄一遍,也不进入重复的核心卖点闸门。
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
if (
not calls
and fallback_fields is None
and allow_plan
and is_pain_point_conversation(conversation)
and not memory.get("selling_point_ready")
):
pain_options = pain_point_direction_options_from_text(said)
if pain_options:
fallback_fields = [{
"key": PAIN_POINT_DIRECTION_KEY,
"label": "选择这条视频要重点解决的痛点",
"type": "single",
"required": True,
"options": pain_options,
}]
# 方向卡是剧情反转预设的固定入口。模型偶尔只会说「我准备了三个方向」而忘了调工具, # 方向卡是剧情反转预设的固定入口。模型偶尔只会说「我准备了三个方向」而忘了调工具,
# 此处直接补上可点击卡,不能让用户面对一段空话再自己追问。 # 此处直接补上可点击卡,不能让用户面对一段空话再自己追问。
memory = conversation.memory if isinstance(conversation.memory, dict) else {} memory = conversation.memory if isinstance(conversation.memory, dict) else {}
@@ -2770,6 +3279,32 @@ def iter_creation_agent_events(
turn_has_gate = True turn_has_gate = True
break break
# 有些模型会把完整策略按「核心卖点/目标受众/内容逻辑/视觉调性」写成散文,
# 却漏掉 write_strategy 工具调用。高置信识别后直接走同一条结构化策略闸门,
# 避免前端退化成一整块普通聊天气泡,也避免缺失「按这个继续」。
current_stage = get_video_gate_stage(conversation)
may_write_strategy = (
current_stage == "clarify"
or (current_stage == "strategy" and not bool(memory.get("strategy_confirmed")))
)
prose_strategy = (
strategy_args_from_text(said)
if not calls and context.is_video and may_write_strategy
else {}
)
if prose_strategy:
result, _stop = _dispatch_tool(context, "write_strategy", prose_strategy)
for event in result.get("_events", []):
yield event
if event.get("type") == "message":
msg = event.get("message") or {}
if msg.get("kind") in (
CreationMessage.Kind.ELICIT,
CreationMessage.Kind.CONFIRM,
):
turn_has_gate = True
break
# ask_user 自己会落一条可追踪的聊天问题。模型同时吐出的过渡文案不再 # ask_user 自己会落一条可追踪的聊天问题。模型同时吐出的过渡文案不再
# 另存一条,否则界面会连续出现两遍几乎相同的问题。 # 另存一条,否则界面会连续出现两遍几乎相同的问题。
asks_user = bool(fallback_fields) or any(call.get("name") == "ask_user" for call in calls) asks_user = bool(fallback_fields) or any(call.get("name") == "ask_user" for call in calls)
@@ -3024,6 +3559,8 @@ def _guided_elicit_text(field: dict) -> str:
return label return label
if str(field.get("key") or "") in SESSION_PARAM_KEYS: if str(field.get("key") or "") in SESSION_PARAM_KEYS:
return label return label
if field.get("type") == "single" and field.get("options"):
return f"{label} 直接点击一个选项,也可以输入自己的想法。"
return f"{label} 直接用一句话告诉我就行,不用整理成完整需求。" return f"{label} 直接用一句话告诉我就行,不用整理成完整需求。"
@@ -3137,7 +3674,34 @@ def _dispatch_tool(
return {"payload": _run_search_library(context, args)}, False return {"payload": _run_search_library(context, args)}, False
if name == "write_strategy": if name == "write_strategy":
if context.is_video and click_swap_needs_sequence(context.conversation):
gate = append_click_swap_sequence_gate(context.conversation)
set_video_gate_stage(context.conversation, "clarify")
return {
"payload": {"asked": True, "field": "sku_sequence"},
"_events": [{"type": "message", "message": _message_payload(gate)}],
}, True
if context.is_video and video_needs_person_source(
context.conversation,
json.dumps(args if isinstance(args, dict) else {}, ensure_ascii=False),
):
gate = append_person_source_gate(context.conversation)
set_video_gate_stage(context.conversation, "clarify")
return {
"payload": {"asked": True, "field": "person_source"},
"_events": [{"type": "message", "message": _message_payload(gate)}],
}, True
memory = context.conversation.memory if isinstance(context.conversation.memory, dict) else {} memory = context.conversation.memory if isinstance(context.conversation.memory, dict) else {}
if context.is_video and is_pain_point_conversation(context.conversation) and not memory.get("selling_point_ready"):
return {
"payload": {
"error": (
"痛点解决演示必须先调用 ask_user 展示痛点方向三选一:"
"key=pain_point_direction、type=single、恰好 3 个 options。"
"用户点击后该方向会直接成为核心卖点,不要另开卖点确认卡。"
)
}
}, False
if context.is_video and not memory.get("selling_point_ready"): if context.is_video and not memory.get("selling_point_ready"):
gate = append_selling_point_gate(context.conversation) gate = append_selling_point_gate(context.conversation)
set_video_gate_stage(context.conversation, "clarify") set_video_gate_stage(context.conversation, "clarify")
@@ -3180,7 +3744,26 @@ def _dispatch_tool(
video_prompt = str(args.get("video_prompt") or "").strip() video_prompt = str(args.get("video_prompt") or "").strip()
if not video_prompt: if not video_prompt:
return {"payload": {"error": "video_prompt 不能为空,请把完整出片指令写进去"}}, False return {"payload": {"error": "video_prompt 不能为空,请把完整出片指令写进去"}}, False
if context.is_video and click_swap_needs_sequence(context.conversation):
gate = append_click_swap_sequence_gate(context.conversation)
set_video_gate_stage(context.conversation, "clarify")
return {
"payload": {"asked": True, "field": "sku_sequence"},
"_events": [{"type": "message", "message": _message_payload(gate)}],
}, True
if context.is_video and video_needs_person_source(context.conversation, video_prompt):
gate = append_person_source_gate(context.conversation)
set_video_gate_stage(context.conversation, "clarify")
return {
"payload": {"asked": True, "field": "person_source"},
"_events": [{"type": "message", "message": _message_payload(gate)}],
}, True
video_prompt = apply_video_preset_prompt(context.conversation.preset, video_prompt) video_prompt = apply_video_preset_prompt(context.conversation.preset, video_prompt)
if is_click_swap_preset(context.conversation.preset):
video_prompt = (
f"{video_prompt}\n\n【商家确认的换款顺序】{click_swap_sequence(context.conversation)}\n"
"只允许按这个顺序逐款切换;不得跳序、漏款或自行增加颜色和款式。"
)
video_prompt = apply_product_voice_visual_guard(context.conversation, video_prompt) video_prompt = apply_product_voice_visual_guard(context.conversation, video_prompt)
video_prompt = apply_product_reality_guard(video_prompt) video_prompt = apply_product_reality_guard(video_prompt)
video_prompt = apply_video_platform_safety_guard(video_prompt) video_prompt = apply_video_platform_safety_guard(video_prompt)
@@ -3218,6 +3801,7 @@ def _dispatch_tool(
prompt=video_prompt, prompt=video_prompt,
timeline=card["timeline"], timeline=card["timeline"],
) )
card = apply_click_swap_plan_card(context.conversation, card, duration)
lo = max(20, round(duration * 3.4)) lo = max(20, round(duration * 3.4))
hi = max(lo + 1, round(duration * 4)) hi = max(lo + 1, round(duration * 4))
plan_payload = { plan_payload = {
@@ -3225,7 +3809,11 @@ def _dispatch_tool(
"points": card["points"], "points": card["points"],
"timeline": card["timeline"], "timeline": card["timeline"],
"matrix": card["matrix"], "matrix": card["matrix"],
"voice_chars": _coerce_voice_chars(card["voice_chars"], [lo, hi]), "voice_chars": (
[0, 0]
if is_click_swap_preset(context.conversation.preset)
else _coerce_voice_chars(card["voice_chars"], [lo, hi])
),
"ref_count": len(context.conversation.pinned_refs or []), "ref_count": len(context.conversation.pinned_refs or []),
} }
events = [] events = []
@@ -3251,6 +3839,13 @@ def _dispatch_tool(
video_prompt = get_pending_video_prompt(context.conversation) video_prompt = get_pending_video_prompt(context.conversation)
if not video_prompt: if not video_prompt:
return {"payload": {"error": "video_prompt 不能为空,请把完整出片指令写进去"}}, False return {"payload": {"error": "video_prompt 不能为空,请把完整出片指令写进去"}}, False
if context.is_video and click_swap_needs_sequence(context.conversation):
gate = append_click_swap_sequence_gate(context.conversation)
set_video_gate_stage(context.conversation, "clarify")
return {
"payload": {"asked": True, "field": "sku_sequence"},
"_events": [{"type": "message", "message": _message_payload(gate)}],
}, True
video_prompt = apply_video_preset_prompt(context.conversation.preset, video_prompt) video_prompt = apply_video_preset_prompt(context.conversation.preset, video_prompt)
video_prompt = apply_product_voice_visual_guard(context.conversation, video_prompt) video_prompt = apply_product_voice_visual_guard(context.conversation, video_prompt)
video_prompt = apply_product_reality_guard(video_prompt) video_prompt = apply_product_reality_guard(video_prompt)
+46 -14
View File
@@ -8,6 +8,14 @@ key 必须是同一个中文名(会话建的时候原样存进 CreationConversat
""" """
from __future__ import annotations from __future__ import annotations
CLICK_SWAP_PRESETS = frozenset({"点击换款", "点触换款", "多色商品换款"})
_CLICK_SWAP_GUIDANCE = (
"固定机位的商品点击换款短片。必须先确认要展示的颜色/款式/SKU 及顺序,"
"画面只围绕同一件商品的逐款切换:商品始终居中、大小与角度不变,背景、灯光、机位不变。"
"每次画面中的手指点击/轻触商品后,下一款在原位立即完成干净的 match cut 切换。"
"禁止改成口播、剧情、使用教程、多场景展示、换人或商品飞舞变形;最后才用同一构图做全款式收束。"
)
VIDEO_PRESETS: dict[str, str] = { VIDEO_PRESETS: dict[str, str] = {
"痛点解决演示": ( "痛点解决演示": (
"真实问题解决型短片。按「具体痛点 → 商品自然登场 → 正常使用步骤 → 可见结果 → 轻行动引导」推进。" "真实问题解决型短片。按「具体痛点 → 商品自然登场 → 正常使用步骤 → 可见结果 → 轻行动引导」推进。"
@@ -47,15 +55,10 @@ VIDEO_PRESETS: dict[str, str] = {
"鱼眼/广角近距离透视,连续换装节奏。**人物面部和身形必须全程一致**," "鱼眼/广角近距离透视,连续换装节奏。**人物面部和身形必须全程一致**,"
"只有服装在变。每次换装用一个明确动作触发。" "只有服装在变。每次换装用一个明确动作触发。"
), ),
"多色商品换款": ( "点击换款": _CLICK_SWAP_GUIDANCE,
"先识别并整理不同颜色、款式或 SKU,确保每款的比例、位置、外观与顺序清楚。" "多色商品换款": _CLICK_SWAP_GUIDANCE,
"统一构图和机位,用点击/触碰或一个明确动作触发换款;背景、光线、机位全程稳定,最后用全家福镜头覆盖全部款式。"
),
# 旧会话仍会保存「点触换款」,保留同一拍法约束以支持历史继续创作。 # 旧会话仍会保存「点触换款」,保留同一拍法约束以支持历史继续创作。
"点触换款": ( "点触换款": _CLICK_SWAP_GUIDANCE,
"先识别并整理不同颜色、款式或 SKU,确保每款的比例、位置、外观与顺序清楚。"
"统一构图和机位,用点击/触碰或一个明确动作触发换款;背景、光线、机位全程稳定,最后用全家福镜头覆盖全部款式。"
),
"探店漫游": ( "探店漫游": (
"以空间动线串联:入口 → 环境 → 关键细节 → 服务/主推项目。" "以空间动线串联:入口 → 环境 → 关键细节 → 服务/主推项目。"
"镜头连续移动有路线感,不要碎切。" "镜头连续移动有路线感,不要碎切。"
@@ -181,8 +184,9 @@ VIDEO_PRESET_WORKFLOWS: dict[str, str] = {
"达人口播种草": "优先确认达人/人物、真实体验和主卖点;生成前核对口播字数能在时长内说完,每个卖点都有画面证明。", "达人口播种草": "优先确认达人/人物、真实体验和主卖点;生成前核对口播字数能在时长内说完,每个卖点都有画面证明。",
"商品图一键成片": "优先从商品参考图锁定外观;自动补场景和动作,但不替换或改变用户商品图里的结构、颜色和包装。", "商品图一键成片": "优先从商品参考图锁定外观;自动补场景和动作,但不替换或改变用户商品图里的结构、颜色和包装。",
"鱼眼换装": "优先确认人物参考、服装套数和展示顺序;生成前核对脸、身形、场景稳定,只有服装随动作切换。", "鱼眼换装": "优先确认人物参考、服装套数和展示顺序;生成前核对脸、身形、场景稳定,只有服装随动作切换。",
"多色商品换款": "优先识别每个颜色款式及其顺序;生成前核对所有 SKU 都已进入时间轴,比例、位置和机位保持一致", "点击换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位下手指逐次点击、商品原位换款,禁止转成剧情或口播",
"点触换款": "优先识别每个颜色款式及其顺序;生成前核对所有 SKU 都已进入时间轴,比例、位置和机位保持一致", "多色商品换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位下手指逐次点击、商品原位换款,禁止转成剧情或口播",
"点触换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位下手指逐次点击、商品原位换款,禁止转成剧情或口播。",
"探店漫游": "优先确认门店动线与主推项目;用连续移动串联入口、环境、细节和服务,不做碎片化硬切。", "探店漫游": "优先确认门店动线与主推项目;用连续移动串联入口、环境、细节和服务,不做碎片化硬切。",
"品牌质感大片": "优先确认品牌气质、材质和商品主卖点;由 Agent 决定光线和镜头,不把专业选择反复抛给用户。", "品牌质感大片": "优先确认品牌气质、材质和商品主卖点;由 Agent 决定光线和镜头,不把专业选择反复抛给用户。",
"前后对比实测": "优先确认同一对象的前、中、后素材或可比较条件;生成前核对对比不夸大且没有缺失关键阶段。", "前后对比实测": "优先确认同一对象的前、中、后素材或可比较条件;生成前核对对比不夸大且没有缺失关键阶段。",
@@ -224,13 +228,26 @@ VIDEO_PRESET_DELIVERY_CONTRACTS: dict[str, str] = {
"【预设执行层·鱼眼换装】使用近距离鱼眼/广角透视和稳定的同一机位;人物脸、身形、发型、场景、光线连续一致。" "【预设执行层·鱼眼换装】使用近距离鱼眼/广角透视和稳定的同一机位;人物脸、身形、发型、场景、光线连续一致。"
"每一套服装由一个清晰的身体动作触发切换,按用户提供顺序完整展示;镜头的变化来自动作和节奏,不额外编复杂营销剧情。" "每一套服装由一个清晰的身体动作触发切换,按用户提供顺序完整展示;镜头的变化来自动作和节奏,不额外编复杂营销剧情。"
), ),
"点击换款": (
"【预设执行层·点击换款·强制】这不是口播片、剧情片或普通商品展示片。"
"使用单一固定机位和同一背景,商品全程处于完全相同的中心位置、尺寸、角度与透视。"
"每个切换节点都必须清楚拍到一根手指轻触/点击商品,接触瞬间通过原位 match cut 换成下一款;"
"不改场景、不改机位、不运镜、不说话、不出现完整人物、不演剧情、不演示使用步骤。"
"各款必须按用户确认的顺序逐一出现,不自行发明颜色或款式;结尾停在干净的全款式总览。"
),
"多色商品换款": ( "多色商品换款": (
"【预设执行层·多色商品换款】先建立统一构图、比例、机位和光线,再以点击、触碰、转动或同一连续动作逐款切换" "【预设执行层·点击换款·强制】这不是口播片、剧情片或普通商品展示片"
"每个颜色/SKU 都要清晰出现且顺序可辨,商品尺寸和位置不得漂移;最后给出全部款式同框总览" "使用单一固定机位和同一背景,商品全程处于完全相同的中心位置、尺寸、角度与透视"
"每个切换节点都必须清楚拍到一根手指轻触/点击商品,接触瞬间通过原位 match cut 换成下一款;"
"不改场景、不改机位、不运镜、不说话、不出现完整人物、不演剧情、不演示使用步骤。"
"各款必须按用户确认的顺序逐一出现,不自行发明颜色或款式;结尾停在干净的全款式总览。"
), ),
"点触换款": ( "点触换款": (
"【预设执行层·点换款】先建立统一构图、比例、机位和光线,再以点击、触碰、转动或同一连续动作逐款切换" "【预设执行层·点换款·强制】这不是口播片、剧情片或普通商品展示片"
"每个颜色/SKU 都要清晰出现且顺序可辨,商品尺寸和位置不得漂移;最后给出全部款式同框总览" "使用单一固定机位和同一背景,商品全程处于完全相同的中心位置、尺寸、角度与透视"
"每个切换节点都必须清楚拍到一根手指轻触/点击商品,接触瞬间通过原位 match cut 换成下一款;"
"不改场景、不改机位、不运镜、不说话、不出现完整人物、不演剧情、不演示使用步骤。"
"各款必须按用户确认的顺序逐一出现,不自行发明颜色或款式;结尾停在干净的全款式总览。"
), ),
"探店漫游": ( "探店漫游": (
"【预设执行层·探店漫游】用一条连续空间动线讲清入口、环境、关键细节和主推服务/商品。" "【预设执行层·探店漫游】用一条连续空间动线讲清入口、环境、关键细节和主推服务/商品。"
@@ -293,6 +310,10 @@ def video_preset_delivery_contract(name: str) -> str:
return VIDEO_PRESET_DELIVERY_CONTRACTS.get((name or "").strip(), "") return VIDEO_PRESET_DELIVERY_CONTRACTS.get((name or "").strip(), "")
def is_click_swap_preset(name: str) -> bool:
return (name or "").strip() in CLICK_SWAP_PRESETS
def apply_video_preset_prompt(name: str, prompt: str) -> str: def apply_video_preset_prompt(name: str, prompt: str) -> str:
"""把视频预设确定性并入最终出片指令,避免只依赖对话模型主动复述。""" """把视频预设确定性并入最终出片指令,避免只依赖对话模型主动复述。"""
base = str(prompt or "").strip() base = str(prompt or "").strip()
@@ -303,6 +324,17 @@ def apply_video_preset_prompt(name: str, prompt: str) -> str:
marker = f"【视频预设】{preset}" marker = f"【视频预设】{preset}"
if marker in base: if marker in base:
return base return base
if is_click_swap_preset(preset):
# 换款是镜头结构,不是可有可无的风格词。把强制执行层放在前面,
# 原始 Agent Prompt 只作为商品/SKU 事实来源;其中若有口播、剧情、运镜或换场景等拍法,不得执行。
return (
f"{marker}\n{contract}\n\n"
"【原始商品与 SKU 信息】仅提取下文中的商品外观、颜色、款式和顺序事实;"
"下文任何与「固定机位、手指点击、商品原位换款」冲突的拍法一律忽略。\n"
f"{base}\n\n"
"【最终执行检查】每一次换款都必须由画面内手指的一次清晰点击触发,"
"切换前后商品中心点、尺寸、角度、背景、光线和机位不变。"
)
return f"{base}\n\n{marker}\n{contract}" return f"{base}\n\n{marker}\n{contract}"
+5 -6
View File
@@ -367,13 +367,12 @@ def build_content_items(
# @label 替换:按 label 长度降序,防子串吞噬 # @label 替换:按 label 长度降序,防子串吞噬
ordered = sorted(label_to_placeholder.items(), key=lambda kv: len(kv[0]), reverse=True) ordered = sorted(label_to_placeholder.items(), key=lambda kv: len(kv[0]), reverse=True)
api_prompt = _format_prompt_for_ark(prompt, ordered) api_prompt = _format_prompt_for_ark(prompt, ordered)
# 自由创作 / 全能创作是整段一次出片(没有「第 2 段」),开场先验强 —— # 在素材编号替换完成后就做最终字幕清洗,不等 provider 层才处理。
# 挂上与专业创作首镜相同的正面洁净指令;最终 enforce_no_embedded_captions 还会改写口播并挂禁令。 # 这样全能创作的每个 60s 分段、审核重提交和路由备选模型都共用同一份
from .services import OPENING_SHOT_DIRECTIVE, rewrite_speech_as_audio_only # 「纯实拍 + 开场洁净 + 口播仅音频」Prompt;provider 层仍会幂等再校验一次。
from .services import enforce_no_embedded_captions
if OPENING_SHOT_DIRECTIVE not in api_prompt: api_prompt = enforce_no_embedded_captions(api_prompt)
api_prompt = f"{OPENING_SHOT_DIRECTIVE}\n{api_prompt}"
api_prompt = rewrite_speech_as_audio_only(api_prompt)
return { return {
"content_items": content_items, "content_items": content_items,
+9 -1
View File
@@ -921,6 +921,7 @@ NO_EMBEDDED_CAPTIONS_REQUIREMENT = (
"画面里能看到的文字只有一种 —— 参考图中商品包装、标签上本来就印着的那些," "画面里能看到的文字只有一种 —— 参考图中商品包装、标签上本来就印着的那些,"
"保持与参考图一致,不放大、不重排、不新增。" "保持与参考图一致,不放大、不重排、不新增。"
"人物说的话通过口型和声音表达,不写到画面上;不要字幕,也不要任何贴片、浮层或界面元素。" "人物说的话通过口型和声音表达,不写到画面上;不要字幕,也不要任何贴片、浮层或界面元素。"
"每一帧必须保持屏幕洁净;若出现商品原包装以外的任何可读字符,本次输出即为失败。"
) )
# 结尾再补一句极短的正面复述:末位权重高,但只用「叠加」这类中性词,不再重复负面名词。 # 结尾再补一句极短的正面复述:末位权重高,但只用「叠加」这类中性词,不再重复负面名词。
@@ -1007,7 +1008,14 @@ def enforce_no_embedded_captions(prompt: str) -> str:
base = strip_caption_directives(base).strip() base = strip_caption_directives(base).strip()
# 首帧最容易被模型自动加标题页 → 规则放开头;末位权重高 → 结尾补一句中性的正面复述。 # 首帧最容易被模型自动加标题页 → 规则放开头;末位权重高 → 结尾补一句中性的正面复述。
# 口播改写后再挂洁净规则,避免「口播:」原文被模型烧成字幕。 # 口播改写后再挂洁净规则,避免「口播:」原文被模型烧成字幕。
return f"{NO_EMBEDDED_CAPTIONS_REQUIREMENT}\n{base}\n{NO_EMBEDDED_CAPTIONS_TAIL}".strip() # 上一版会在清理时移除 OPENING_SHOT_DIRECTIVE,却没有把它拼回最终 Prompt,
# 相当于丢了专门压制「电商口播开场自动打大字」的关键约束。
return (
f"{NO_EMBEDDED_CAPTIONS_REQUIREMENT}\n"
f"{OPENING_SHOT_DIRECTIVE}\n"
f"{base}\n"
f"{NO_EMBEDDED_CAPTIONS_TAIL}"
).strip()
def execute_routed_video_submit( def execute_routed_video_submit(
+563 -14
View File
@@ -10,10 +10,10 @@ from django.test import SimpleTestCase, TestCase, override_settings
from rest_framework.test import APIClient from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User from apps.accounts.models import Team, TeamMember, User
from apps.assets.models import Asset, AssetFile from apps.assets.models import Asset, AssetFile, Model as AssetModel
from apps.products.models import Product, ProductImage from apps.products.models import Product, ProductImage
from .creation import append_message from .creation import append_message, finish_generating_message
from .views import _typed_step_confirm_action from .views import _typed_step_confirm_action
from .creation_agent import ( from .creation_agent import (
AgentContext, AgentContext,
@@ -24,7 +24,9 @@ from .creation_agent import (
_coerce_fields, _coerce_fields,
_image_count, _image_count,
_merge_tool_call_deltas, _merge_tool_call_deltas,
apply_pain_point_direction,
apply_restart_intent, apply_restart_intent,
apply_person_identity_guard,
active_plot_twist_story_depth, active_plot_twist_story_depth,
build_system_prompt, build_system_prompt,
build_messages, build_messages,
@@ -46,6 +48,7 @@ from .creation_agent import (
strip_numeric_reply_instruction, strip_numeric_reply_instruction,
submit_confirmed_image, submit_confirmed_image,
submit_confirmed_video, submit_confirmed_video,
submit_generated_person_reference,
text_already_guides, text_already_guides,
tool_schemas, tool_schemas,
video_duration, video_duration,
@@ -274,7 +277,7 @@ class AskUserTests(CreationAgentBaseTests):
self.assertEqual(elicit[0]["message"]["payload"]["interaction"], "chat") self.assertEqual(elicit[0]["message"]["payload"]["interaction"], "chat")
self.assertEqual( self.assertEqual(
elicit[0]["message"]["text"], elicit[0]["message"]["text"],
"想要什么调性? 直接用一句话告诉我就行,不用整理成完整需求", "想要什么调性? 直接点击一个选项,也可以输入自己的想法",
) )
self.assertEqual(len(elicit[0]["message"]["payload"]["fields"][0]["options"]), 2) self.assertEqual(len(elicit[0]["message"]["payload"]["fields"][0]["options"]), 2)
# 反问必须中断循环,否则模型会自问自答 # 反问必须中断循环,否则模型会自问自答
@@ -450,6 +453,80 @@ class SseFramingTests(CreationAgentBaseTests):
self.assertIn(str(task.id), encoded) self.assertIn(str(task.id), encoded)
class PersonReferenceCompletionTests(CreationAgentBaseTests):
def test_platform_person_generation_becomes_a_locked_model_reference(self):
task = AITask.objects.create(
team=self.team,
created_by=self.user,
task_type=AITask.Type.PERSON_IMAGE,
model_config=self.model,
idempotency_key="k-person-reference",
)
generating = append_message(
self.conversation,
role="assistant",
kind=CreationMessage.Kind.GENERATING,
payload={"kind": "person_reference", "task_id": str(task.id)},
task=task,
)
asset = Asset.objects.create(
team=self.team,
created_by=self.user,
name="AI 出镜人物",
asset_type=Asset.Type.IMAGE,
source=Asset.Source.AI_GENERATED,
category=Asset.Category.MODEL_PORTRAIT,
origin_task=task,
)
AssetFile.objects.create(
asset=asset,
object_key="person-generated.jpg",
bucket="test",
preview_url="https://cdn.example/person-generated.jpg",
is_primary=True,
)
finish_generating_message(
generating,
assets=[{
"id": str(asset.id),
"url": "https://cdn.example/person-generated.jpg",
"cover": "https://cdn.example/person-generated.jpg",
"type": "image",
}],
meta={"prompt": "person"},
)
self.conversation.refresh_from_db()
generating.refresh_from_db()
self.assertEqual(generating.kind, CreationMessage.Kind.RESULT)
self.assertEqual(self.conversation.status, CreationConversation.Status.RUNNING)
self.assertEqual(self.conversation.agent_status, CreationConversation.AgentStatus.AWAITING_USER)
model_ref = next(ref for ref in self.conversation.pinned_refs if ref.get("type") == "model")
self.assertTrue(AssetModel.objects.filter(id=model_ref["id"], portrait_asset=asset).exists())
self.assertTrue(self.conversation.memory["person_source_ready"])
self.assertTrue(
self.conversation.messages.filter(text__contains="人物参考已生成并锁定").exists()
)
def test_platform_person_submission_uses_model_mode(self):
task = AITask.objects.create(
team=self.team,
created_by=self.user,
task_type=AITask.Type.PERSON_IMAGE,
model_config=self.model,
idempotency_key="k-person-submit",
)
with patch("apps.ai.services.enqueue_standalone_images", return_value=[task]) as enqueue:
message = submit_generated_person_reference(conversation=self.conversation, user=self.user)
self.assertEqual(message.kind, CreationMessage.Kind.GENERATING)
self.assertEqual(message.payload["kind"], "person_reference")
self.assertEqual(enqueue.call_args.kwargs["mode"], "model")
self.assertEqual(enqueue.call_args.kwargs["count"], 1)
self.assertEqual(enqueue.call_args.kwargs["feature"], "omni_create")
class SendEndpointTests(TestCase): class SendEndpointTests(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create_user(username="send-owner", password="p") self.user = User.objects.create_user(username="send-owner", password="p")
@@ -465,6 +542,99 @@ class SendEndpointTests(TestCase):
response = self.client.post(f"/api/ai/creations/{self.conversation.id}/send/", {}, format="json") response = self.client.post(f"/api/ai/creations/{self.conversation.id}/send/", {}, format="json")
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
def test_person_source_upload_is_pinned_before_agent_continues(self):
self.conversation.mode = CreationConversation.Mode.VIDEO
self.conversation.save(update_fields=["mode", "updated_at"])
portrait = Asset.objects.create(
team=self.team,
created_by=self.user,
name="本地上传人物",
asset_type=Asset.Type.IMAGE,
source=Asset.Source.UPLOAD,
category=Asset.Category.UPLOAD,
)
AssetFile.objects.create(
asset=portrait,
object_key="uploaded-person.jpg",
bucket="test",
preview_url="https://cdn.example/uploaded-person.jpg",
is_primary=True,
)
card = append_message(
self.conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text="先确定出镜人物",
payload={
"interaction": "person_source_gate",
"fields": [{"key": "person_source", "type": "single"}],
"submitted": False,
"answers": {},
},
)
response = self.client.post(
f"/api/ai/creations/{self.conversation.id}/send/",
{
"kind": "elicit_answer",
"reply_to": str(card.id),
"answers": {"person_source": "local_upload"},
"refs": [{
"type": "character",
"id": str(portrait.id),
"name": portrait.name,
"cover": "https://cdn.example/uploaded-person.jpg",
}],
},
format="json",
)
self.assertEqual(response.status_code, 202)
card.refresh_from_db()
self.conversation.refresh_from_db()
self.assertTrue(card.payload["submitted"])
self.assertTrue(self.conversation.memory["person_source_ready"])
self.assertTrue(any(
ref.get("type") == "character" and str(ref.get("id")) == str(portrait.id)
for ref in self.conversation.pinned_refs
))
def test_click_swap_sequence_answer_is_saved_before_agent_continues(self):
self.conversation.mode = CreationConversation.Mode.VIDEO
self.conversation.preset = "点击换款"
self.conversation.save(update_fields=["mode", "preset", "updated_at"])
card = append_message(
self.conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text="确认款式顺序",
payload={
"interaction": "click_swap_sku_gate",
"fields": [{"key": "sku_sequence", "type": "text"}],
"submitted": False,
"answers": {},
},
)
fake = FakeProvider([_text_chunks("开始整理点击换款策略")])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
response = self.client.post(
f"/api/ai/creations/{self.conversation.id}/send/",
{
"kind": "elicit_answer",
"reply_to": str(card.id),
"answers": {"sku_sequence": "黑色 → 白色 → 樱花粉"},
},
format="json",
)
self.assertEqual(response.status_code, 202)
card.refresh_from_db()
self.conversation.refresh_from_db()
self.assertTrue(card.payload["submitted"])
self.assertTrue(self.conversation.memory["click_swap_ready"])
self.assertEqual(self.conversation.memory["click_swap_sequence"], "黑色 → 白色 → 樱花粉")
def test_natural_step_confirmation_phrases_continue_instead_of_revising(self): def test_natural_step_confirmation_phrases_continue_instead_of_revising(self):
for text in ("这个还行", "这种还行", "可以", "就这样", "没问题", "按这个继续", "好,继续"): for text in ("这个还行", "这种还行", "可以", "就这样", "没问题", "按这个继续", "好,继续"):
with self.subTest(text=text): with self.subTest(text=text):
@@ -911,7 +1081,8 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
def test_plot_twist_always_displays_three_direction_cards(self): def test_plot_twist_always_displays_three_direction_cards(self):
self.conversation.preset = "剧情反转带货" self.conversation.preset = "剧情反转带货"
self.conversation.params = {**self.conversation.params, "duration": "30 秒"} self.conversation.params = {**self.conversation.params, "duration": "30 秒"}
self.conversation.save(update_fields=["preset", "params", "updated_at"]) self.conversation.memory = {"person_source_ready": True}
self.conversation.save(update_fields=["preset", "params", "memory", "updated_at"])
# 即使模型只输出一句空话,平台也必须补上三个可点击方向,而不是让用户继续追问。 # 即使模型只输出一句空话,平台也必须补上三个可点击方向,而不是让用户继续追问。
fake = FakeProvider([_text_chunks("我准备了三个剧情反转方向。")]) fake = FakeProvider([_text_chunks("我准备了三个剧情反转方向。")])
@@ -933,6 +1104,51 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertEqual(len(directions), 3) self.assertEqual(len(directions), 3)
self.assertTrue(all(item.get("conflict") and item.get("product_role") and item.get("reversal") for item in directions)) self.assertTrue(all(item.get("conflict") and item.get("product_role") and item.get("reversal") for item in directions))
def test_pain_point_list_becomes_clickable_and_choice_is_the_selling_point(self):
product = Product.objects.create(team=self.team, created_by=self.user, title="舒缓面霜")
self.conversation.mode = CreationConversation.Mode.VIDEO
self.conversation.preset = "痛点解决演示"
self.conversation.memory = {"person_source_ready": True}
self.conversation.pinned_refs = [{"type": "product", "id": str(product.id), "name": product.title}]
self.conversation.save(update_fields=["mode", "preset", "memory", "pinned_refs", "updated_at"])
fake = FakeProvider([_text_chunks(
"可以从下面三个方向选一个:\n"
"- 换季干燥紧绷,正常涂抹后保持舒适不拔干\n"
"- 空调房久坐脸颊不适,薄涂后肤感更柔润\n"
"- 妆前容易卡粉,按正常用量涂开后底妆更服帖"
)])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
events = _events(stream_creation_agent(
conversation=self.conversation,
user=self.user,
text="用这个商品做一条痛点解决演示",
model_config=self.model,
))
card = next(
event["message"] for event in events
if event.get("type") == "message" and event["message"]["kind"] == "elicit"
)
field = card["payload"]["fields"][0]
self.assertEqual(field["key"], "pain_point_direction")
self.assertEqual(len(field["options"]), 3)
self.assertIn("直接点击一个选项", card["text"])
self.assertIn("pain_point_direction", fake.calls[0]["messages"][0]["content"])
stored = CreationMessage.objects.get(id=card["id"])
selected = field["options"][0]
continuation = apply_pain_point_direction(
self.conversation,
stored.payload,
selected["value"],
)
self.conversation.refresh_from_db()
memory = self.conversation.memory or {}
self.assertTrue(memory.get("selling_point_ready"))
self.assertEqual(memory.get("selling_point"), selected["label"])
self.assertIn("不要再询问核心卖点", continuation)
def test_image_tool_is_hidden_and_video_tools_offered(self): def test_image_tool_is_hidden_and_video_tools_offered(self):
fake = FakeProvider([_text_chunks("先聊聊")]) fake = FakeProvider([_text_chunks("先聊聊")])
with patch("apps.ai.creation_agent.build_provider", return_value=fake): with patch("apps.ai.creation_agent.build_provider", return_value=fake):
@@ -945,7 +1161,11 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertNotIn("generate_image", names) self.assertNotIn("generate_image", names)
def test_strategy_card_stops_with_step_confirm(self): def test_strategy_card_stops_with_step_confirm(self):
self.conversation.memory = {"selling_point_ready": True, "selling_point_mode": "auto"} self.conversation.memory = {
"selling_point_ready": True,
"selling_point_mode": "auto",
"person_source_ready": True,
}
self.conversation.save(update_fields=["memory", "updated_at"]) self.conversation.save(update_fields=["memory", "updated_at"])
fake = FakeProvider([ fake = FakeProvider([
_tool_chunks("write_strategy", {"target": "油皮通勤人群", "trust": "真实使用反馈", _tool_chunks("write_strategy", {"target": "油皮通勤人群", "trust": "真实使用反馈",
@@ -968,7 +1188,61 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
# 策略闸门必须停下等人确认,不能同轮连写方案 # 策略闸门必须停下等人确认,不能同轮连写方案
self.assertEqual(len(fake.calls), 1) self.assertEqual(len(fake.calls), 1)
def test_plain_strategy_prose_is_recovered_as_strategy_card(self):
self.conversation.preset = "痛点解决演示"
self.conversation.memory = {
"selling_point_ready": True,
"selling_point_mode": "manual",
"selling_point": "温和不刺激",
"pain_point_direction_ready": True,
"pain_point_direction": "温和不刺激",
"person_source_ready": True,
}
self.conversation.save(update_fields=["preset", "memory", "updated_at"])
fake = FakeProvider([_text_chunks(
"创作策略:小熊婴儿湿巾痛点短片\n"
"核心卖点:温和清洁,不会泛红干涩。\n"
"目标受众:家有 0-3 岁宝宝的年轻宝妈。\n"
"内容逻辑(严格遵循痛点解决路径):\n"
"1. 痛点钩子:普通湿巾摩擦后皮肤泛红。\n"
"2. 商品登场:展示棉花般柔软质地。\n"
"3. 使用实证:擦拭过程轻柔服帖。\n"
"视觉调性:明亮柔和的居家治愈风。\n"
"你看这个方向是否合适?"
)])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
events = _events(stream_creation_agent(
conversation=self.conversation,
user=self.user,
text="按选好的痛点方向继续",
model_config=self.model,
))
cards = [
event["message"] for event in events
if event.get("type") == "message" and event["message"]["kind"] == "strategy"
]
self.assertEqual(len(cards), 1)
self.assertEqual(cards[0]["payload"]["target"], "家有 0-3 岁宝宝的年轻宝妈。")
self.assertIn("使用实证", cards[0]["payload"]["trust"])
self.assertEqual(cards[0]["payload"]["belief"], "温和清洁,不会泛红干涩。")
self.assertEqual(cards[0]["payload"]["direction"], "明亮柔和的居家治愈风。")
self.assertFalse(any(
event.get("type") == "message"
and event["message"]["role"] == "assistant"
and event["message"]["kind"] == "text"
for event in events
))
confirm = next(
event["message"] for event in events
if event.get("type") == "message"
and event["message"]["kind"] == "elicit"
)
self.assertEqual(confirm["payload"].get("step"), "strategy")
def test_selling_point_is_confirmed_before_strategy(self): def test_selling_point_is_confirmed_before_strategy(self):
self.conversation.memory = {"person_source_ready": True}
self.conversation.save(update_fields=["memory", "updated_at"])
fake = FakeProvider([ fake = FakeProvider([
_tool_chunks("write_strategy", {"target": "油皮通勤人群", "trust": "真实使用反馈", _tool_chunks("write_strategy", {"target": "油皮通勤人群", "trust": "真实使用反馈",
"belief": "值得一试", "direction": "达人 UGC 口播"}), "belief": "值得一试", "direction": "达人 UGC 口播"}),
@@ -1010,6 +1284,67 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
# 方案闸门停下,不能同轮出 Prompt/积分卡 # 方案闸门停下,不能同轮出 Prompt/积分卡
self.assertEqual(len(fake.calls), 1) self.assertEqual(len(fake.calls), 1)
def test_click_swap_requires_sequence_before_calling_model(self):
self.conversation.preset = "点击换款"
self.conversation.memory = {}
self.conversation.save(update_fields=["preset", "memory", "updated_at"])
fake = FakeProvider([_text_chunks("这一轮不应调用模型")])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
events = _events(stream_creation_agent(
conversation=self.conversation,
user=self.user,
text="做一条手指点击后换颜色的视频",
model_config=self.model,
))
gate = next(
event["message"] for event in events
if event.get("type") == "message" and event["message"]["kind"] == "elicit"
)
self.assertEqual(gate["payload"].get("interaction"), "click_swap_sku_gate")
self.assertEqual(gate["payload"]["fields"][0]["key"], "sku_sequence")
self.assertEqual(fake.calls, [])
def test_click_swap_plan_overrides_conflicting_generic_script(self):
self.conversation.preset = "点击换款"
self.conversation.memory = {
"click_swap_ready": True,
"click_swap_sequence": "黑色 → 白色 → 樱花粉",
"person_source_ready": True,
}
self.conversation.save(update_fields=["preset", "memory", "updated_at"])
fake = FakeProvider([_tool_chunks("write_plan", self._plan_args(
usp="多场景故事感展示",
points=["人物口播", "移动运镜"],
timeline=[{"start": 0, "end": 15, "stage": "剧情口播"}],
video_prompt="达人走进三个不同场景,一边口播一边展示商品",
))])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
events = _events(stream_creation_agent(
conversation=self.conversation,
user=self.user,
text="继续做方案",
model_config=self.model,
))
plan = next(
event["message"] for event in events
if event.get("type") == "message" and event["message"]["kind"] == "plan"
)
self.assertIn("黑色 → 白色 → 樱花粉", plan["payload"]["usp"])
self.assertEqual(plan["payload"]["voice_chars"], [0, 0])
self.assertEqual(
[item["stage"] for item in plan["payload"]["timeline"]],
["首款定帧", "首次点击换款", "按序连续换款", "全款式收束"],
)
self.conversation.refresh_from_db()
stored = self.conversation.memory["pending_video_prompt"]
self.assertTrue(stored.startswith("【视频预设】点击换款"))
self.assertIn("【商家确认的换款顺序】黑色 → 白色 → 樱花粉", stored)
self.assertIn("口播片、剧情片", stored)
def test_manual_selling_point_is_locked_as_plan_usp(self): def test_manual_selling_point_is_locked_as_plan_usp(self):
self.conversation.memory = { self.conversation.memory = {
"selling_point_ready": True, "selling_point_ready": True,
@@ -1051,7 +1386,11 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
"""模型若同轮连调 write_strategy+write_plan,只落策略闸门。""" """模型若同轮连调 write_strategy+write_plan,只落策略闸门。"""
from apps.ai.creation_agent import _parse_arguments # noqa: F401 from apps.ai.creation_agent import _parse_arguments # noqa: F401
self.conversation.memory = {"selling_point_ready": True, "selling_point_mode": "auto"} self.conversation.memory = {
"selling_point_ready": True,
"selling_point_mode": "auto",
"person_source_ready": True,
}
self.conversation.save(update_fields=["memory", "updated_at"]) self.conversation.save(update_fields=["memory", "updated_at"])
# FakeProvider 一轮里多个 tool_call:模拟两个独立 rounds 不行,需单轮多 call。 # FakeProvider 一轮里多个 tool_call:模拟两个独立 rounds 不行,需单轮多 call。
@@ -1085,6 +1424,103 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertNotIn("plan", kinds) self.assertNotIn("plan", kinds)
self.assertEqual(len(fake.calls), 1) self.assertEqual(len(fake.calls), 1)
def test_person_video_requires_a_source_before_the_provider_runs(self):
self.conversation.preset = "达人口播种草"
self.conversation.save(update_fields=["preset", "updated_at"])
fake = FakeProvider([_text_chunks("这一轮不应调用模型")])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
events = _events(stream_creation_agent(
conversation=self.conversation,
user=self.user,
text="做一条真实自然的口播视频",
model_config=self.model,
))
card = next(
event["message"] for event in events
if event.get("type") == "message" and event["message"]["kind"] == "elicit"
)
self.assertEqual(card["payload"]["interaction"], "person_source_gate")
self.assertEqual(
[option["value"] for option in card["payload"]["fields"][0]["options"]],
["local_upload", "model_library", "platform_generate"],
)
self.assertEqual(fake.calls, [])
def test_sixty_second_segments_reuse_full_prompt_and_same_person_reference(self):
portrait = Asset.objects.create(
team=self.team,
created_by=self.user,
name="锁定女主",
asset_type=Asset.Type.IMAGE,
source=Asset.Source.UPLOAD,
category=Asset.Category.MODEL_PORTRAIT,
review_status="active",
review_remote_id="person-ref-1",
)
AssetFile.objects.create(
asset=portrait,
object_key="person.jpg",
bucket="test",
preview_url="https://cdn.example/person.jpg",
is_primary=True,
)
model = AssetModel.objects.create(
team=self.team,
created_by=self.user,
name="锁定女主",
portrait_asset=portrait,
)
self.conversation.preset = "达人口播种草"
self.conversation.params = {**self.conversation.params, "duration": "60 秒"}
self.conversation.pinned_refs = [{"type": "model", "id": str(model.id), "name": model.name}]
self.conversation.save(update_fields=["preset", "params", "pinned_refs", "updated_at"])
card = append_message(
self.conversation,
role="assistant",
kind=CreationMessage.Kind.CONFIRM,
payload={
"video_prompt": "女主在日常场景分享真实使用体验\n字幕:限时8折",
"submitted": False,
},
)
tasks = [
AITask.objects.create(
team=self.team,
created_by=self.user,
task_type=AITask.Type.FREE_VIDEO,
model_config=self.model,
idempotency_key=f"k-person-60-{index}",
status=AITask.Status.SUCCEEDED,
)
for index in (1, 2)
]
with patch("apps.ai.free_video.submit_free_video", side_effect=tasks) as submit:
message, error = submit_confirmed_video(
conversation=self.conversation,
user=self.user,
confirm_message=card,
)
self.assertEqual(error, "")
self.assertEqual(message.payload["kind"], "video_segments")
self.assertEqual(submit.call_count, 2)
first = submit.call_args_list[0].kwargs["params"]
second = submit.call_args_list[1].kwargs["params"]
self.assertEqual(first["references"], second["references"])
self.assertEqual(first["seed"], second["seed"])
self.assertEqual(first["references"][0]["asset_id"], str(portrait.id))
for params in (first, second):
self.assertTrue(params["prompt"].startswith("【画面洁净 · 最高优先级】"))
self.assertIn("【开场】这是全片的第一段", params["prompt"])
self.assertNotIn("限时8折", params["prompt"])
self.assertEqual(params["prompt"].count("字幕"), 1)
self.assertIn("【人物一致性硬约束】", params["prompt"])
self.assertIn("【预设执行层·达人口播种草】", params["prompt"])
self.assertIn("必须继续使用参考图锁定的同一位人物", params["prompt"])
def test_plan_without_video_prompt_is_rejected_without_emitting_cards(self): def test_plan_without_video_prompt_is_rejected_without_emitting_cards(self):
fake = FakeProvider([ fake = FakeProvider([
_tool_chunks("write_plan", self._plan_args(video_prompt="")), _tool_chunks("write_plan", self._plan_args(video_prompt="")),
@@ -1114,7 +1550,8 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertEqual(error, "") self.assertEqual(error, "")
self.assertEqual(message.kind, CreationMessage.Kind.GENERATING) self.assertEqual(message.kind, CreationMessage.Kind.GENERATING)
self.assertTrue(params["prompt"].startswith("0-3秒 近景手持商品…")) self.assertTrue(params["prompt"].startswith("【画面洁净 · 最高优先级】"))
self.assertIn("0-3秒 近景手持商品…", params["prompt"])
self.assertIn("不出现破损、漏液、渗水", params["prompt"]) self.assertIn("不出现破损、漏液、渗水", params["prompt"])
# 顶栏参数直接用,label 要翻成火山真名 # 顶栏参数直接用,label 要翻成火山真名
self.assertEqual(params["model"], "doubao-seedance-2-5-260628") self.assertEqual(params["model"], "doubao-seedance-2-5-260628")
@@ -1142,8 +1579,10 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertEqual(error, "") self.assertEqual(error, "")
self.assertIsNotNone(message) self.assertIsNotNone(message)
self.assertIn("【视频预设】多色商品换款", prompt) self.assertIn("【视频预设】多色商品换款", prompt)
self.assertIn("每个颜色/SKU 都要清晰出现", prompt) self.assertIn("【预设执行层·点击换款·强制】", prompt)
self.assertIn("最后给出全部款式同框总览", prompt) self.assertIn("手指轻触/点击商品", prompt)
self.assertIn("不演剧情", prompt)
self.assertIn("原位 match cut", prompt)
def test_personified_product_uses_offscreen_voice_without_changing_packaging(self): def test_personified_product_uses_offscreen_voice_without_changing_packaging(self):
self.conversation.preset = "商品拟人广告" self.conversation.preset = "商品拟人广告"
@@ -1188,7 +1627,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertEqual(error, "") self.assertEqual(error, "")
self.assertIsNotNone(message) self.assertIsNotNone(message)
self.assertTrue(prompt.startswith(original)) self.assertIn(original, prompt)
self.assertIn("不出现破损、漏液、渗水", prompt) self.assertIn("不出现破损、漏液、渗水", prompt)
def test_confirm_without_stored_prompt_reports_instead_of_submitting(self): def test_confirm_without_stored_prompt_reports_instead_of_submitting(self):
@@ -1223,6 +1662,15 @@ class VideoParamParsingTests(TestCase):
self.assertEqual(infer_script_duration(timeline=timeline), 20) self.assertEqual(infer_script_duration(timeline=timeline), 20)
self.assertEqual(video_duration({"duration": "智能时长"}, timeline=timeline), 20) self.assertEqual(video_duration({"duration": "智能时长"}, timeline=timeline), 20)
def test_age_range_is_not_mistaken_for_video_duration(self):
prompt = (
"人声是18-22岁软甜少女音,语气自然。\n"
"「0-3 秒」开场展示第一套穿搭。\n"
"「12-15 秒」人物定格收束。"
)
self.assertEqual(infer_script_duration(prompt=prompt), 15)
self.assertEqual(video_duration({"duration": "智能时长"}, prompt=prompt), 15)
def test_long_video_is_split_into_seedance_sized_segments(self): def test_long_video_is_split_into_seedance_sized_segments(self):
self.assertEqual( self.assertEqual(
plan_video_segments(45, [{"end": 14}, {"end": 25}, {"end": 45}]), plan_video_segments(45, [{"end": 14}, {"end": 25}, {"end": 45}]),
@@ -1234,6 +1682,15 @@ class VideoParamParsingTests(TestCase):
self.assertEqual(plan_video_segments(60)[0]["duration"], 30) self.assertEqual(plan_video_segments(60)[0]["duration"], 30)
self.assertEqual(plan_video_segments(60)[1]["duration"], 30) self.assertEqual(plan_video_segments(60)[1]["duration"], 30)
def test_person_identity_guard_uses_real_reference_indexes(self):
prompt = apply_person_identity_guard("base", [
{"type": "model", "url": "person-a"},
{"type": "model", "url": "person-b"},
{"type": "product", "url": "product"},
])
self.assertIn("参考图1、参考图2", prompt)
self.assertNotIn("参考图3定义本片的固定出镜人物", prompt)
def test_model_label_maps_to_volcano_name(self): def test_model_label_maps_to_volcano_name(self):
self.assertEqual(video_model_name({"model": "Seedance 2.0 Fast"}), "doubao-seedance-2-0-fast-260128") self.assertEqual(video_model_name({"model": "Seedance 2.0 Fast"}), "doubao-seedance-2-0-fast-260128")
self.assertEqual(video_model_name({"model": "没见过的模型"}), DEFAULT_VIDEO_MODEL) self.assertEqual(video_model_name({"model": "没见过的模型"}), DEFAULT_VIDEO_MODEL)
@@ -1334,6 +1791,33 @@ class ConfirmEndpointTests(TestCase):
self.assertEqual(submit.call_args.kwargs["params"]["resolution"], "720p") self.assertEqual(submit.call_args.kwargs["params"]["resolution"], "720p")
self.assertEqual(submit.call_args.kwargs["params"]["duration"], 8) self.assertEqual(submit.call_args.kwargs["params"]["duration"], 8)
def test_confirm_ignores_duration_spacing_when_model_changes(self):
self.conversation.params = {
"model": "Seedance 2.0 Fast", "resolution": "480p",
"ratio": "9:16", "duration": "8秒",
}
self.conversation.save(update_fields=["params"])
provider = ModelProvider.objects.create(name="fk-duration-format", display_name="F", base_url="https://x")
model = ModelConfig.objects.create(
provider=provider, name="fk-video-duration-format", display_name="V",
capability=ModelConfig.Capability.VIDEO,
)
task = AITask.objects.create(
team=self.team, created_by=self.user, task_type=AITask.Type.FREE_VIDEO,
model_config=model, idempotency_key="k-duration-format",
)
with patch("apps.ai.free_video.submit_free_video", return_value=task) as submit:
response = self.client.post(
f"/api/ai/creations/{self.conversation.id}/send/",
{"kind": "confirm", "reply_to": str(self.card.id),
"params": {"duration": "8 秒", "model": "Seedance 2.5",
"resolution": "720p", "ratio": "9:16"}},
format="json",
)
self.assertEqual(response.status_code, 201)
self.assertFalse(response.json().get("regenerate"))
submit.assert_called_once()
class MemoryCompressionTests(CreationAgentBaseTests): class MemoryCompressionTests(CreationAgentBaseTests):
"""长会话记忆压缩(契约 §5)。""" """长会话记忆压缩(契约 §5)。"""
@@ -1420,6 +1904,7 @@ class PresetGuidanceTests(CreationAgentBaseTests):
def test_preset_guidance_reaches_the_system_prompt(self): def test_preset_guidance_reaches_the_system_prompt(self):
conversation = CreationConversation.objects.create( conversation = CreationConversation.objects.create(
team=self.team, created_by=self.user, mode="video", preset="鱼眼换装", params={}, team=self.team, created_by=self.user, mode="video", preset="鱼眼换装", params={},
memory={"person_source_ready": True},
) )
fake = FakeProvider([_text_chunks("")]) fake = FakeProvider([_text_chunks("")])
with patch("apps.ai.creation_agent.build_provider", return_value=fake): with patch("apps.ai.creation_agent.build_provider", return_value=fake):
@@ -1475,8 +1960,9 @@ class PresetGuidanceTests(CreationAgentBaseTests):
system = build_system_prompt(context) system = build_system_prompt(context)
self.assertIn("当前预设的工作重点", system) self.assertIn("当前预设的工作重点", system)
self.assertIn("所有 SKU 都已进入时间轴", system) self.assertIn("确认颜色/款式/SKU 和切换顺序", system)
self.assertIn("比例、位置和机位保持一致", system) self.assertIn("固定机位下手指逐次点击", system)
self.assertIn("禁止转成剧情或口播", system)
def test_video_preset_is_injected_into_the_actual_generation_prompt(self): def test_video_preset_is_injected_into_the_actual_generation_prompt(self):
from .creation_presets import apply_video_preset_prompt from .creation_presets import apply_video_preset_prompt
@@ -1488,6 +1974,21 @@ class PresetGuidanceTests(CreationAgentBaseTests):
# 出片确认、重试等多次经过此处,规则只写一次。 # 出片确认、重试等多次经过此处,规则只写一次。
self.assertEqual(apply_video_preset_prompt("鱼眼换装", prompt), prompt) self.assertEqual(apply_video_preset_prompt("鱼眼换装", prompt), prompt)
def test_click_swap_preset_overrides_conflicting_shooting_method(self):
from .creation_presets import apply_video_preset_prompt
prompt = apply_video_preset_prompt(
"点击换款",
"三款耳机是黑色、白色、樱花粉;达人边走边口播,在多个场景切换展示。",
)
self.assertTrue(prompt.startswith("【视频预设】点击换款"))
self.assertIn("【预设执行层·点击换款·强制】", prompt)
self.assertIn("原始商品与 SKU 信息", prompt)
self.assertIn("冲突的拍法一律忽略", prompt)
self.assertIn("每一次换款都必须由画面内手指的一次清晰点击触发", prompt)
self.assertEqual(apply_video_preset_prompt("点击换款", prompt), prompt)
def test_every_video_preset_has_a_delivery_contract(self): def test_every_video_preset_has_a_delivery_contract(self):
from .creation_presets import VIDEO_PRESETS, VIDEO_PRESET_DELIVERY_CONTRACTS from .creation_presets import VIDEO_PRESETS, VIDEO_PRESET_DELIVERY_CONTRACTS
@@ -1514,11 +2015,31 @@ class PresetGuidanceTests(CreationAgentBaseTests):
self.assertNotIn("暴打", prompt) self.assertNotIn("暴打", prompt)
self.assertNotIn("鲜血直流", prompt) self.assertNotIn("鲜血直流", prompt)
self.assertNotIn("药到病除", prompt) self.assertNotIn("药到病除", prompt)
self.assertIn("安全、非暴力方式化解", prompt) self.assertNotIn("血腥", prompt)
self.assertNotIn("自残", prompt)
self.assertNotIn("裸体", prompt)
self.assertNotIn("毒品", prompt)
self.assertNotIn("武器", prompt)
self.assertNotIn("政治敏感", prompt)
self.assertIn("通过沟通与日常误会化解", prompt)
self.assertIn("真实、可观察的日常使用体验", prompt) self.assertIn("真实、可观察的日常使用体验", prompt)
# 方案卡、Prompt 文件、最终提交会多次经过这层,规则只能追加一次。 # 方案卡、Prompt 文件、最终提交会多次经过这层,规则只能追加一次。
self.assertEqual(apply_video_platform_safety_guard(prompt), prompt) self.assertEqual(apply_video_platform_safety_guard(prompt), prompt)
def test_video_platform_safety_guard_normalizes_ambiguous_young_person_copy(self):
from .creation_agent import apply_video_platform_safety_guard
prompt = apply_video_platform_safety_guard(
"18-22岁软甜少女音,甜妹穿短裙站在固定低角度鱼眼机位前。"
)
self.assertNotIn("18-22岁", prompt)
self.assertNotIn("少女", prompt)
self.assertNotIn("甜妹", prompt)
self.assertNotIn("低角度鱼眼机位", prompt)
self.assertIn("成年女性", prompt)
self.assertIn("正面鱼眼机位", prompt)
def test_video_system_prompt_requires_audit_safe_script(self): def test_video_system_prompt_requires_audit_safe_script(self):
conversation = CreationConversation.objects.create( conversation = CreationConversation.objects.create(
team=self.team, created_by=self.user, mode="video", params={}, team=self.team, created_by=self.user, mode="video", params={},
@@ -1527,6 +2048,10 @@ class PresetGuidanceTests(CreationAgentBaseTests):
system = build_system_prompt(AgentContext(conversation=conversation, user=self.user, model_config=self.model)) system = build_system_prompt(AgentContext(conversation=conversation, user=self.user, model_config=self.model))
self.assertIn("平台安全优先于戏剧冲突", system) self.assertIn("平台安全优先于戏剧冲突", system)
self.assertIn("审核安全必须在第一次生成时完成", system)
self.assertIn("人物只写「成年女性 / 成年男性 / 成年人」", system)
self.assertIn("不要把平台风险类别、禁用词", system)
self.assertIn("否定式免责声明", system)
self.assertIn("最终 video_prompt 必须是可直接提交审核和出片的安全版本", system) self.assertIn("最终 video_prompt 必须是可直接提交审核和出片的安全版本", system)
def test_image_preset_style_is_added_only_to_the_actual_image_prompt(self): def test_image_preset_style_is_added_only_to_the_actual_image_prompt(self):
@@ -1551,7 +2076,8 @@ class PresetGuidanceTests(CreationAgentBaseTests):
"""视频和图片预设都要有拍法,漏一个就等于那张卡是摆设。""" """视频和图片预设都要有拍法,漏一个就等于那张卡是摆设。"""
from .creation_presets import IMAGE_PRESETS, VIDEO_PRESETS from .creation_presets import IMAGE_PRESETS, VIDEO_PRESETS
self.assertEqual(len(VIDEO_PRESETS), 14) # 「点击换款」是当前前端名称;同时保留「多色商品换款 / 点触换款」兼容旧会话。
self.assertEqual(len(VIDEO_PRESETS), 15)
self.assertEqual(len(IMAGE_PRESETS), 12) self.assertEqual(len(IMAGE_PRESETS), 12)
self.assertTrue(all(text.strip() for text in {**VIDEO_PRESETS, **IMAGE_PRESETS}.values())) self.assertTrue(all(text.strip() for text in {**VIDEO_PRESETS, **IMAGE_PRESETS}.values()))
@@ -1940,6 +2466,29 @@ class ReplyGuidanceTests(CreationAgentBaseTests):
self.assertEqual(labels, ["补充颜色和顺序", "上传各款实物图", "先按当前主款做"]) self.assertEqual(labels, ["补充颜色和顺序", "上传各款实物图", "先按当前主款做"])
self.assertNotIn("改卖点", labels) self.assertNotIn("改卖点", labels)
def test_reply_options_follow_final_detail_and_color_prompt(self):
options = default_reply_options(
self.conversation,
has_context=True,
is_video=True,
assistant_text=(
"这个方向会用年轻女生手部展示商品,不会抢商品风头。\n\n"
"你要是有想额外突出的细节,或者想调换配色顺序,现在直接告诉我就行。"
),
)
labels = [option["label"] for option in options]
self.assertEqual(labels, ["补充突出细节", "调整配色顺序", "按当前描述继续"])
self.assertNotIn("发商品列表", labels)
def test_incidental_product_mention_does_not_create_product_picker_actions(self):
options = default_reply_options(
self.conversation,
has_context=True,
is_video=True,
assistant_text="人物只做手部展示,商品保持真实结构。按这个方向继续就行。",
)
self.assertEqual(options, [])
def test_unknown_context_question_does_not_get_fixed_actions(self): def test_unknown_context_question_does_not_get_fixed_actions(self):
options = default_reply_options( options = default_reply_options(
self.conversation, self.conversation,
@@ -147,7 +147,27 @@ class GenerationBackfillTests(TestCase):
self.assertEqual(sync_generating_messages(self.conversation), 1) self.assertEqual(sync_generating_messages(self.conversation), 1)
message.refresh_from_db() message.refresh_from_db()
self.assertEqual(message.kind, CreationMessage.Kind.ERROR) self.assertEqual(message.kind, CreationMessage.Kind.ERROR)
self.assertIn("额度不足", message.text) self.assertIn("积分不足", message.text)
def test_sensitive_text_failure_is_shown_as_safe_chinese_message(self):
task = self._task(AITask.Status.FAILED, key="k-sensitive-text")
task.task_type = AITask.Type.FREE_VIDEO
task.error_code = "InputTextSensitiveContentDetected"
task.error_message = (
"The request failed because the input text 'content[0]' may contain sensitive information. "
"Request id: provider-secret-reference"
)
task.save(update_fields=["task_type", "error_code", "error_message"])
message = append_message(
self.conversation, role="assistant", kind=CreationMessage.Kind.GENERATING,
payload={"task_id": str(task.id), "kind": "video"}, task=task,
)
self.assertEqual(sync_generating_messages(self.conversation), 1)
message.refresh_from_db()
self.assertEqual(message.kind, CreationMessage.Kind.ERROR)
self.assertIn("内容未通过生成审核", message.text)
self.assertNotIn("Request id", message.text)
def test_running_task_stays_generating(self): def test_running_task_stays_generating(self):
task = self._task(AITask.Status.RESERVED, key="k-run") task = self._task(AITask.Status.RESERVED, key="k-run")
+16 -2
View File
@@ -87,6 +87,18 @@ class BuildContentItemsTests(TestCase):
self.user = User.objects.create_user(username="fvowner", password="p") self.user = User.objects.create_user(username="fvowner", password="p")
self.team = Team.objects.create(name="FV", owner=self.user) self.team = Team.objects.create(name="FV", owner=self.user)
def test_final_api_prompt_strips_requested_caption_copy(self):
built = build_content_items(
team=self.team,
prompt="人物对镜头说话\n字幕:今天限时8折",
mode="universal",
references=[],
)
self.assertNotIn("今天限时8折", built["api_prompt"])
self.assertTrue(built["api_prompt"].startswith("【画面洁净 · 最高优先级】"))
self.assertEqual(built["api_prompt"].count("字幕"), 1)
def test_label_replacement_length_desc_no_substring_swallow(self): def test_label_replacement_length_desc_no_substring_swallow(self):
refs = [ refs = [
{"url": "http://x/a.png", "type": "image", "label": ""}, {"url": "http://x/a.png", "type": "image", "label": ""},
@@ -94,7 +106,8 @@ class BuildContentItemsTests(TestCase):
] ]
built = build_content_items(team=self.team, prompt="@碧碧 拥抱 @碧", mode="universal", references=refs) built = build_content_items(team=self.team, prompt="@碧碧 拥抱 @碧", mode="universal", references=refs)
# 「碧碧」(图片2)必须先于「碧」(图片1)替换,否则被吞成「图片1碧」 # 「碧碧」(图片2)必须先于「碧」(图片1)替换,否则被吞成「图片1碧」
self.assertEqual(built["api_prompt"], "图片2 拥抱 图片1") self.assertIn("图片2 拥抱 图片1", built["api_prompt"])
self.assertTrue(built["api_prompt"].startswith("【画面洁净 · 最高优先级】"))
def test_counters_match_content_items(self): def test_counters_match_content_items(self):
refs = [ refs = [
@@ -105,7 +118,8 @@ class BuildContentItemsTests(TestCase):
built = build_content_items(team=self.team, prompt="@图a @图b @视a", mode="universal", references=refs) built = build_content_items(team=self.team, prompt="@图a @图b @视a", mode="universal", references=refs)
self.assertEqual(built["image_n"], 2) self.assertEqual(built["image_n"], 2)
self.assertEqual(built["video_n"], 1) self.assertEqual(built["video_n"], 1)
self.assertEqual(built["api_prompt"], "图片1 图片2 视频1") self.assertIn("图片1 图片2 视频1", built["api_prompt"])
self.assertTrue(built["api_prompt"].startswith("【画面洁净 · 最高优先级】"))
roles = [i.get("role") for i in built["content_items"]] roles = [i.get("role") for i in built["content_items"]]
self.assertEqual(roles, ["reference_image", "reference_image", "reference_video"]) self.assertEqual(roles, ["reference_image", "reference_image", "reference_video"])
self.assertEqual(built["video_duration_total"], 3.0) self.assertEqual(built["video_duration_total"], 3.0)
@@ -99,7 +99,8 @@ class AssetReferenceBuildTests(TestCase):
built = self._build(asset) built = self._build(asset)
self.assertEqual(built["image_n"], 1) self.assertEqual(built["image_n"], 1)
self.assertEqual(built["content_items"][0]["image_url"]["url"], "http://tos/1.png") self.assertEqual(built["content_items"][0]["image_url"]["url"], "http://tos/1.png")
self.assertEqual(built["api_prompt"], "图片1 走过来") self.assertIn("图片1 走过来", built["api_prompt"])
self.assertTrue(built["api_prompt"].startswith("【画面洁净 · 最高优先级】"))
def test_registered_asset_uses_volcano_asset_scheme(self): def test_registered_asset_uses_volcano_asset_scheme(self):
"""已登记火山素材库的走 asset://,写实人脸传直链会被拒。""" """已登记火山素材库的走 asset://,写实人脸传直链会被拒。"""
@@ -19,6 +19,7 @@ class VideoCaptionPolicyTests(SimpleTestCase):
self.assertTrue(prompt.startswith(NO_EMBEDDED_CAPTIONS_REQUIREMENT)) # 首帧最容易被加标题页 self.assertTrue(prompt.startswith(NO_EMBEDDED_CAPTIONS_REQUIREMENT)) # 首帧最容易被加标题页
self.assertTrue(prompt.endswith(NO_EMBEDDED_CAPTIONS_TAIL)) # 末位权重高 self.assertTrue(prompt.endswith(NO_EMBEDDED_CAPTIONS_TAIL)) # 末位权重高
self.assertEqual(prompt.count(OPENING_SHOT_DIRECTIVE), 1)
self.assertIn("一位用户在厨房使用商品", prompt) self.assertIn("一位用户在厨房使用商品", prompt)
def test_rule_is_never_duplicated(self): def test_rule_is_never_duplicated(self):
@@ -43,6 +44,7 @@ class VideoCaptionPolicyTests(SimpleTestCase):
"""裸摆一段台词时模型会把它当成要渲染的画面文本 —— 必须讲明只出声。""" """裸摆一段台词时模型会把它当成要渲染的画面文本 —— 必须讲明只出声。"""
self.assertIn("口型", NO_EMBEDDED_CAPTIONS_REQUIREMENT) self.assertIn("口型", NO_EMBEDDED_CAPTIONS_REQUIREMENT)
self.assertIn("不写到画面上", NO_EMBEDDED_CAPTIONS_REQUIREMENT) self.assertIn("不写到画面上", NO_EMBEDDED_CAPTIONS_REQUIREMENT)
self.assertIn("本次输出即为失败", NO_EMBEDDED_CAPTIONS_REQUIREMENT)
def test_strips_caption_field_lines(self): def test_strips_caption_field_lines(self):
cleaned = strip_caption_directives("字幕:限时8折\n0-3s:近景;平视;女主抬眼") cleaned = strip_caption_directives("字幕:限时8折\n0-3s:近景;平视;女主抬眼")
+145 -5
View File
@@ -16,7 +16,7 @@ from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
from apps.assets.models import Asset from apps.assets.models import Asset, Model as AssetModel
from apps.assets.serializers import AssetFileSerializer, AssetSerializer from apps.assets.serializers import AssetFileSerializer, AssetSerializer
from apps.common.api import TeamScopedViewSetMixin, get_current_team from apps.common.api import TeamScopedViewSetMixin, get_current_team
from apps.common.celery_health import require_worker, require_worker_task from apps.common.celery_health import require_worker, require_worker_task
@@ -29,6 +29,7 @@ from .creation import (
begin_agent_planning, begin_agent_planning,
cleanup_stale_agent_planning, cleanup_stale_agent_planning,
finish_agent_planning, finish_agent_planning,
pin_refs,
request_agent_cancel, request_agent_cancel,
start_segmented_video_merge, start_segmented_video_merge,
sync_generating_messages, sync_generating_messages,
@@ -37,6 +38,7 @@ from .creation import (
from .creation_agent import ( from .creation_agent import (
_ASSET_CARD_LABELS, _ASSET_CARD_LABELS,
_RESTART_CONTINUATION, _RESTART_CONTINUATION,
apply_pain_point_direction,
apply_confirm_params, apply_confirm_params,
apply_restart_intent, apply_restart_intent,
apply_session_params, apply_session_params,
@@ -44,11 +46,14 @@ from .creation_agent import (
emit_final_confirm_gate, emit_final_confirm_gate,
set_plot_twist_story_depth, set_plot_twist_story_depth,
is_greeting, is_greeting,
is_pain_point_conversation,
is_pain_point_direction_payload,
is_restart_intent, is_restart_intent,
restore_gated_step_after_cancel, restore_gated_step_after_cancel,
set_video_gate_stage, set_video_gate_stage,
submit_confirmed_image, submit_confirmed_image,
submit_confirmed_video, submit_confirmed_video,
submit_generated_person_reference,
) )
from .tasks import run_creation_agent_turn_task 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, search_mentions
@@ -269,6 +274,20 @@ def _plot_twist_direction_continuation(
) )
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 写创作策略;禁止改成口播、剧情、换场景或普通使用演示。"
)
_STEP_CONTINUE_INSTRUCTIONS = { _STEP_CONTINUE_INSTRUCTIONS = {
"strategy": ( "strategy": (
"用户已确认创作策略。现在只调用 write_plan 写方案卡(含完整 video_prompt 存档);" "用户已确认创作策略。现在只调用 write_plan 写方案卡(含完整 video_prompt 存档);"
@@ -1893,6 +1912,18 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
continuation_instruction = _plot_twist_direction_continuation( continuation_instruction = _plot_twist_direction_continuation(
conversation, payload, choice conversation, payload, choice
) )
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": elif payload.get("phase") == "gate":
pending_fields = [item for item in (payload.get("pending_fields") or []) if isinstance(item, dict)] pending_fields = [item for item in (payload.get("pending_fields") or []) if isinstance(item, dict)]
primary_field = pending_fields[0] if pending_fields else {} primary_field = pending_fields[0] if pending_fields else {}
@@ -2034,10 +2065,19 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
existing.add(mark) existing.add(mark)
force_creative_turn = True force_creative_turn = True
continuation_instruction = ( 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
)
else:
continuation_instruction = (
"用户刚用输入框回答了你上一句问题。直接基于这条真实回答继续原任务;"
"不要复述答案,不要回复收到,也不要问要不要继续或要不要生成。"
)
if params_changed: if params_changed:
continuation_instruction += " 会话参数已更新,旧方案作废,按新参数重新产出。" continuation_instruction += " 会话参数已更新,旧方案作废,按新参数重新产出。"
@@ -2132,6 +2172,46 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
return JsonResponse({"detail": "请选择自己填写卖点或系统推荐"}, status=400) return JsonResponse({"detail": "请选择自己填写卖点或系统推荐"}, status=400)
if mode == "manual" and not selling_point: if mode == "manual" and not selling_point:
return JsonResponse({"detail": "请先填写一个真实卖点,或选择系统推荐"}, status=400) 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(
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)
if payload.get("interaction") == "click_swap_sku_gate":
sequence = str(answers.get("sku_sequence") or "").strip()
if not sequence:
return JsonResponse({"detail": "请填写要展示的款式和切换顺序"}, status=400)
payload["answers"] = answers payload["answers"] = answers
payload["submitted"] = True payload["submitted"] = True
card.payload = payload card.payload = payload
@@ -2161,6 +2241,18 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
continuation_instruction = _plot_twist_direction_continuation( continuation_instruction = _plot_twist_direction_continuation(
conversation, payload, choice 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": elif payload.get("interaction") == "selling_point_gate":
mode = str(answers.get("selling_point_mode") or "").strip().lower() mode = str(answers.get("selling_point_mode") or "").strip().lower()
selling_point = str(answers.get("selling_point") or "").strip() selling_point = str(answers.get("selling_point") or "").strip()
@@ -2180,6 +2272,54 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
else "商家选择系统推荐卖点。现在只调用 write_strategy 写创作策略;" else "商家选择系统推荐卖点。现在只调用 write_strategy 写创作策略;"
"从商品资料和现有素材中挑一个最容易被画面证明的真实核心卖点,不要虚构功效、价格或规格。" "从商品资料和现有素材中挑一个最容易被画面证明的真实核心卖点,不要虚构功效、价格或规格。"
) )
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("interaction") == "person_source_gate":
source = str(answers.get("person_source") or "").strip()
if source == "platform_generate":
try:
generating = submit_generated_person_reference(
conversation=conversation,
user=request.user,
)
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
continuation_instruction = (
"用户已选定出镜人物,该人物已作为整条视频的固定身份参考。"
"直接继续创作;所有镜头和分段保持同一人,不要再追问人物来源。"
)
elif payload.get("phase") == "gate": elif payload.get("phase") == "gate":
choice = str(answers.get("_asset_gate") or "").strip() choice = str(answers.get("_asset_gate") or "").strip()
if choice == "send": if choice == "send":
@@ -110,6 +110,8 @@ description: >
- **未成年人绝不生成角色资产或画面主体**`type:"character"` 只能是明确的成年人,禁止婴儿、宝宝、幼儿、儿童、小朋友、未成年人及任何 `017 岁` 人物。 - **未成年人绝不生成角色资产或画面主体**`type:"character"` 只能是明确的成年人,禁止婴儿、宝宝、幼儿、儿童、小朋友、未成年人及任何 `017 岁` 人物。
婴幼儿/儿童用品也一样:用商品平铺、包装、材质、尺寸、功能细节、成人手部或成年照护者的局部演示表达;不得写儿童出镜、试穿、坐卧、拿着商品或作为镜头主体。 婴幼儿/儿童用品也一样:用商品平铺、包装、材质、尺寸、功能细节、成人手部或成年照护者的局部演示表达;不得写儿童出镜、试穿、坐卧、拿着商品或作为镜头主体。
旁白可以说明适用年龄与使用场景,但不能把儿童变成要生成的角色/画面。 旁白可以说明适用年龄与使用场景,但不能把儿童变成要生成的角色/画面。
- **审核安全从第一稿完成**:没有用户锁定的人物参考时,角色统一写明确的成年女性、成年男性或成年人,不自创年轻年龄区间,不使用容易产生幼态联想的人设、称呼或音色。服装保持日常得体,机位优先平视、自然俯拍或尊重主体的正面构图,不强调身体局部。
- **输出只写正向可拍内容**:如果输入含有不适合直接出片的情节,先在内部保留其情绪功能和真实商品卖点,改成成年人之间积极、友善的日常互动。不要在 JSON、`visual_prompt``visual`、旁白或对白中复述原措辞,也不要罗列平台风险类别、禁用词、否定式免责声明或审核说明。
- **`visual` 固定使用导演层级**(四栏,逐栏写满,缺栏视为不合格): - **`visual` 固定使用导演层级**(四栏,逐栏写满,缺栏视为不合格):
`【本镜任务】` 这一段要完成的情绪 / 信息变化 → `【本镜任务】` 这一段要完成的情绪 / 信息变化 →
`【光线氛围】` 光源方向与性质(窗光 / 顶光 / 台灯 / 逆光)、色温冷暖、明暗对比、整体色调 → `【光线氛围】` 光源方向与性质(窗光 / 顶光 / 台灯 / 逆光)、色温冷暖、明暗对比、整体色调 →
@@ -47,6 +47,8 @@
- [ ] 同一角色/场景/商品全程复用同一 entity(没有给同一对象写出两份 visual_prompt)。 - [ ] 同一角色/场景/商品全程复用同一 entity(没有给同一对象写出两份 visual_prompt)。
- [ ] 每个 `visual_prompt` 信息足够喂图模型(角色/场景/商品的外观特征写清)。 - [ ] 每个 `visual_prompt` 信息足够喂图模型(角色/场景/商品的外观特征写清)。
- [ ] 每镜 `product_exposure` 自然、与 role 匹配(参考方法论露出表)。 - [ ] 每镜 `product_exposure` 自然、与 role 匹配(参考方法论露出表)。
- [ ] **人物审核安全**:所有画面主体均明确为成年人;没有自创年轻年龄区间、幼态化称呼或音色;服装和构图日常得体,不强调身体局部。
- [ ] **正向描述检查**:产物只写改写后的可拍内容,没有复述原始风险情节,没有罗列平台风险类别、禁用词、否定式免责声明或审核说明。
## C. 旁白红线扫描(逐镜) ## C. 旁白红线扫描(逐镜)
@@ -72,6 +72,7 @@
只在视频镜头里与人物同框,避免人物图把真实包装改掉。 只在视频镜头里与人物同框,避免人物图把真实包装改掉。
- **儿童用品不生成儿童角色**:任何未成年人(婴儿、宝宝、幼儿、儿童、小朋友、0–17 岁)都不能作为 `character` 或视频的画面主体。 - **儿童用品不生成儿童角色**:任何未成年人(婴儿、宝宝、幼儿、儿童、小朋友、0–17 岁)都不能作为 `character` 或视频的画面主体。
这类商品改用商品平铺、材质/包装/尺寸细节和成人手部或成年照护者局部演示;适用年龄只写在旁白和商品信息中,不把儿童写成要生图的人物。 这类商品改用商品平铺、材质/包装/尺寸细节和成人手部或成年照护者局部演示;适用年龄只写在旁白和商品信息中,不把儿童写成要生图的人物。
- **人物描述默认审核友好**:没有锁定参考人物时,只写明确成年人,不自创年轻年龄区间或幼态化称呼;服装日常得体,镜头以平视、自然俯拍和尊重主体的正面构图为主。遇到不适合直接出片的输入,先在内部改成积极、友善的日常互动,产物只保留改写后的可拍内容,不复述原措辞,也不写风险词清单或否定式免责声明。
- `type` 三选一:`character`(人) / `scene`(环境) / `product`(商品)。一份脚本通常至少 1 个 `product`、**至少 1 个 `scene`**。 - `type` 三选一:`character`(人) / `scene`(环境) / `product`(商品)。一份脚本通常至少 1 个 `product`、**至少 1 个 `scene`**。
- **场景与镜头一一对应(可复用)****每个 segment 的 `entity_refs` 必须恰好引用一个 `scene`**——它就是这一镜画面所处的环境。多镜同环境就复用同一个 scene id(如全程宿舍 → 只一个「宿舍书桌」scene,4 镜都引用它);真正换了地点才新建另一个 scene。纯产品特写镜也要绑它所处环境的 scene(无明确环境则复用主场景)。这样下游「场景」基础资产能成图、且同环境背景一致。 - **场景与镜头一一对应(可复用)****每个 segment 的 `entity_refs` 必须恰好引用一个 `scene`**——它就是这一镜画面所处的环境。多镜同环境就复用同一个 scene id(如全程宿舍 → 只一个「宿舍书桌」scene,4 镜都引用它);真正换了地点才新建另一个 scene。纯产品特写镜也要绑它所处环境的 scene(无明确环境则复用主场景)。这样下游「场景」基础资产能成图、且同环境背景一致。
- `ref_index` 是该 entity 在图集里的参考序号(从 1 递增,供下游三视图/参考图对齐)。 - `ref_index` 是该 entity 在图集里的参考序号(从 1 递增,供下游三视图/参考图对齐)。
+2 -1
View File
@@ -1262,10 +1262,11 @@ export const adminApi = {
{ method: "POST", body: JSON.stringify(assetIds ? { asset_ids: assetIds } : {}) } { method: "POST", body: JSON.stringify(assetIds ? { asset_ids: assetIds } : {}) }
); );
}, },
tasks(params?: { status?: string; task_type?: string; team?: string; anomaly?: string; page?: number; page_size?: number }) { tasks(params?: { status?: string; task_type?: string; category?: "omni_create"; team?: string; anomaly?: string; page?: number; page_size?: number }) {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (params?.status) qs.set("status", params.status); if (params?.status) qs.set("status", params.status);
if (params?.task_type) qs.set("task_type", params.task_type); if (params?.task_type) qs.set("task_type", params.task_type);
if (params?.category) qs.set("category", params.category);
if (params?.team) qs.set("team", params.team); if (params?.team) qs.set("team", params.team);
if (params?.anomaly) qs.set("anomaly", params.anomaly); if (params?.anomaly) qs.set("anomaly", params.anomaly);
if (params?.page) qs.set("page", String(params.page)); if (params?.page) qs.set("page", String(params.page));
@@ -24,6 +24,19 @@ function withCurrent(values: string[], current: string) {
return current && !values.includes(current) ? [current, ...values] : values; return current && !values.includes(current) ? [current, ...values] : values;
} }
/** 同一秒数允许「8秒 / 8 秒」等历史格式共存,不因展示格式触发参数变更。 */
export function normalizeDurationValue(value: string): string {
const raw = String(value || "").trim();
const seconds = raw.match(/\d+(?:\.\d+)?/)?.[0];
if (seconds) return `seconds:${Number(seconds)}`;
return `label:${raw.replace(/\s+/g, "").toLowerCase()}`;
}
function includesDuration(values: string[], current: string): boolean {
const normalized = normalizeDurationValue(current);
return values.some((value) => normalizeDurationValue(value) === normalized);
}
function modelOptionLabel(config: ModelConfig): string { function modelOptionLabel(config: ModelConfig): string {
return (config.display_name || config.name || "").trim(); return (config.display_name || config.name || "").trim();
} }
@@ -187,7 +200,7 @@ export function OmniParamBar({
const seconds = Number(String(duration || "").replace(/\D/g, "")); const seconds = Number(String(duration || "").replace(/\D/g, ""));
const isPlannedSegmentedDuration = seconds > 30 && seconds <= 60 && canCreateSegmentedVideo(selected, model); const isPlannedSegmentedDuration = seconds > 30 && seconds <= 60 && canCreateSegmentedVideo(selected, model);
// 60 秒是总时长,生成时会拆段;不要因为单段目录最高 30 秒就把它偷偷改回 8 秒/智能时长。 // 60 秒是总时长,生成时会拆段;不要因为单段目录最高 30 秒就把它偷偷改回 8 秒/智能时长。
if (duration && duration !== "智能时长" && !nextDur.includes(duration) && !isPlannedSegmentedDuration) { if (duration && duration !== "智能时长" && !includesDuration(nextDur, duration) && !isPlannedSegmentedDuration) {
onDuration(nextDur.includes("8 秒") ? "8 秒" : (nextDur.find((x) => x !== "智能时长") || "智能时长")); onDuration(nextDur.includes("8 秒") ? "8 秒" : (nextDur.find((x) => x !== "智能时长") || "智能时长"));
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
+23
View File
@@ -2612,6 +2612,29 @@
cursor: not-allowed; cursor: not-allowed;
} }
/* 单选追问直接点选即提交;长方向文案纵向铺开,避免挤成难扫读的小胶囊。 */
.omni-chat-choice-actions {
width: 100%;
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.omni-chat-choice-actions button {
width: 100%;
min-height: 40px;
padding: 9px 12px;
text-align: left;
line-height: 1.55;
transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease;
}
.omni-chat-choice-actions button:hover:not(:disabled) {
border-color: var(--heat-40);
color: var(--heat);
background: var(--heat-12);
}
.omni-gate-answered { .omni-gate-answered {
margin: 0; margin: 0;
font-size: 12px; font-size: 12px;
+19 -3
View File
@@ -53,6 +53,12 @@ function attemptKind(attempt: AdminTaskDetail["attempts"][number]) {
return "首次调用"; return "首次调用";
} }
function taskTypeLabel(task: AdminTask) {
return task.task_category === "omni_create"
? `全能创作 · ${task.task_type}`
: task.task_type;
}
export function AdminTasksPage({ notify }: { notify: Notify }) { export function AdminTasksPage({ notify }: { notify: Notify }) {
const [tasks, setTasks] = useState<AdminTask[]>([]); const [tasks, setTasks] = useState<AdminTask[]>([]);
const [count, setCount] = useState(0); const [count, setCount] = useState(0);
@@ -60,6 +66,7 @@ export function AdminTasksPage({ notify }: { notify: Notify }) {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [tab, setTab] = useState(""); const [tab, setTab] = useState("");
const [anomalyOnly, setAnomalyOnly] = useState(false); const [anomalyOnly, setAnomalyOnly] = useState(false);
const [omniOnly, setOmniOnly] = useState(false);
const [detail, setDetail] = useState<AdminTaskDetail | null>(null); const [detail, setDetail] = useState<AdminTaskDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -67,7 +74,13 @@ export function AdminTasksPage({ notify }: { notify: Notify }) {
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const res = await adminApi.tasks({ status: tab || undefined, anomaly: anomalyOnly ? "1" : undefined, page, page_size: PAGE_SIZE }); const res = await adminApi.tasks({
status: tab || undefined,
category: omniOnly ? "omni_create" : undefined,
anomaly: anomalyOnly ? "1" : undefined,
page,
page_size: PAGE_SIZE,
});
if (res.results.length === 0 && res.count > 0 && page > 1) { setPage((p) => Math.max(1, p - 1)); return; } if (res.results.length === 0 && res.count > 0 && page > 1) { setPage((p) => Math.max(1, p - 1)); return; }
setTasks(res.results); setTasks(res.results);
setCount(res.count); setCount(res.count);
@@ -77,7 +90,7 @@ export function AdminTasksPage({ notify }: { notify: Notify }) {
setLoading(false); setLoading(false);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab, anomalyOnly, page]); }, [tab, anomalyOnly, omniOnly, page]);
useEffect(() => { void load(); }, [load]); useEffect(() => { void load(); }, [load]);
@@ -160,6 +173,9 @@ export function AdminTasksPage({ notify }: { notify: Notify }) {
<button type="button" className={`chip admin-anomaly-chip${anomalyOnly ? " active" : ""}`} onClick={() => { setAnomalyOnly((v) => !v); setPage(1); }}> <button type="button" className={`chip admin-anomaly-chip${anomalyOnly ? " active" : ""}`} onClick={() => { setAnomalyOnly((v) => !v); setPage(1); }}>
</button> </button>
<button type="button" className={`chip admin-anomaly-chip${omniOnly ? " active" : ""}`} onClick={() => { setOmniOnly((v) => !v); setPage(1); }}>
</button>
</div> </div>
{loading ? ( {loading ? (
@@ -175,7 +191,7 @@ export function AdminTasksPage({ notify }: { notify: Notify }) {
<tbody> <tbody>
{tasks.map((t) => ( {tasks.map((t) => (
<tr key={t.id}> <tr key={t.id}>
<td className="mono admin-code">{t.task_type}</td> <td className="mono admin-code">{taskTypeLabel(t)}</td>
<td>{t.team_name || <span className="muted"></span>}</td> <td>{t.team_name || <span className="muted"></span>}</td>
<td>{t.model_name || <span className="muted"></span>}</td> <td>{t.model_name || <span className="muted"></span>}</td>
<td>{statusPill(t.status)}</td> <td>{statusPill(t.status)}</td>
+4 -3
View File
@@ -43,7 +43,7 @@ const VIDEO_PRESETS: PresetItem[] = [
{ name: "剧情反转带货", category: "story", mode: "video", title: "剧情反转带货", desc: "用人物冲突与意外反转建立记忆点,商品承担剧情中的关键作用。", starter: "创作一条有前后反转的剧情带货视频,让商品自然成为解决问题的关键。", cover: "/assets/video-presets/plot-twist-commerce.jpg", previewVideo: "/assets/video-presets/plot-twist-commerce.mp4" }, { name: "剧情反转带货", category: "story", mode: "video", title: "剧情反转带货", desc: "用人物冲突与意外反转建立记忆点,商品承担剧情中的关键作用。", starter: "创作一条有前后反转的剧情带货视频,让商品自然成为解决问题的关键。", cover: "/assets/video-presets/plot-twist-commerce.jpg", previewVideo: "/assets/video-presets/plot-twist-commerce.mp4" },
{ name: "达人口播种草", category: "speaker", mode: "video", title: "达人口播种草", desc: "用真实体验与生活化表达建立信任,适合电商和本地生活内容。", starter: "创作一条真实自然的达人口播种草视频,重点讲清使用场景和核心卖点。", cover: "/assets/video-presets/creator-recommendation.jpg", previewVideo: "/assets/video-presets/creator-recommendation.mp4" }, { name: "达人口播种草", category: "speaker", mode: "video", title: "达人口播种草", desc: "用真实体验与生活化表达建立信任,适合电商和本地生活内容。", starter: "创作一条真实自然的达人口播种草视频,重点讲清使用场景和核心卖点。", cover: "/assets/video-presets/creator-recommendation.jpg", previewVideo: "/assets/video-presets/creator-recommendation.mp4" },
{ name: "鱼眼换装", category: "visual", mode: "video", title: "鱼眼换装", desc: "强调近距离透视与连续变装节奏,适合服饰和人物视觉内容。", starter: "创作一条鱼眼镜头风格的连续换装视频,人物和服装需要保持稳定。", cover: "/assets/video-presets/rhythm-outfit-change.jpg", previewVideo: "/assets/video-presets/rhythm-outfit-change.mp4" }, { name: "鱼眼换装", category: "visual", mode: "video", title: "鱼眼换装", desc: "强调近距离透视与连续变装节奏,适合服饰和人物视觉内容。", starter: "创作一条鱼眼镜头风格的连续换装视频,人物和服装需要保持稳定。", cover: "/assets/video-presets/rhythm-outfit-change.jpg", previewVideo: "/assets/video-presets/rhythm-outfit-change.mp4" },
{ name: "多色商品换款", category: "visual", mode: "video", title: "多色商品换款", desc: "统一商品比例与机位,用动作连续展示不同颜色和款式。", starter: "创作一条多色商品换款视频:统一构图展示不同颜色或款式,用明确动作触发切换,最后给出全款总览。", cover: "/assets/video-presets/multi-sku-switch.jpg", previewVideo: "/assets/video-presets/multi-sku-switch.mp4" }, { name: "点击换款", category: "visual", mode: "video", title: "点击换款", desc: "固定商品与机位,手指每次点击都在原位切换下一款。", starter: "创作一条点击换款视频:使用单一固定机位和同一背景,商品始终保持同一位置和比例;每次手指点击商品时,立即在原位切换到下一个颜色或款式,按确认顺序逐款展示,最后全款总览收束。不做口播、剧情、换场景或普通商品使用演示。", cover: "/assets/video-presets/multi-sku-switch.jpg", previewVideo: "/assets/video-presets/multi-sku-switch.mp4" },
{ name: "AI 宠物拟人", category: "story", mode: "video", title: "AI 宠物拟人", desc: "让宠物角色参与有趣小剧情,同时保留商品真实结构与用途。", starter: "创作一条 AI 宠物拟人短片:宠物有明确性格和动作,商品以真实结构和正常用法自然参与剧情。", cover: "/assets/video-presets/ai-pet-personification.jpg", previewVideo: "/assets/video-presets/ai-pet-personification.mp4" }, { name: "AI 宠物拟人", category: "story", mode: "video", title: "AI 宠物拟人", desc: "让宠物角色参与有趣小剧情,同时保留商品真实结构与用途。", starter: "创作一条 AI 宠物拟人短片:宠物有明确性格和动作,商品以真实结构和正常用法自然参与剧情。", cover: "/assets/video-presets/ai-pet-personification.jpg", previewVideo: "/assets/video-presets/ai-pet-personification.mp4" },
]; ];
@@ -398,6 +398,7 @@ export function OmniCreatePage({
disabled={starting || uploading} disabled={starting || uploading}
onClick={() => { onClick={() => {
const text = prompt.trim(); const text = prompt.trim();
const creationBrief = text || selectedCase?.starter || "";
if (!text && !selectedCase && pendingRefs.length === 0) { if (!text && !selectedCase && pendingRefs.length === 0) {
onNotify?.("info", "请先输入你想制作的内容,或选择一个创作预设"); onNotify?.("info", "请先输入你想制作的内容,或选择一个创作预设");
return; return;
@@ -407,7 +408,7 @@ export function OmniCreatePage({
// 会话 mode 与顶栏参数在这里定死,进对话页后不再改(契约 §0) // 会话 mode 与顶栏参数在这里定死,进对话页后不再改(契约 §0)
void api void api
.createCreation({ .createCreation({
title: (text || selectedCase?.title || "未命名创作").slice(0, 20), title: (creationBrief || selectedCase?.title || "未命名创作").slice(0, 20),
mode: outputMode, mode: outputMode,
preset: selectedCase?.name || "", preset: selectedCase?.name || "",
params: { params: {
@@ -420,7 +421,7 @@ export function OmniCreatePage({
}) })
.then((conversation) => { .then((conversation) => {
// 首条消息交给对话页发,避免这里再复制一份 SSE 消费逻辑 // 首条消息交给对话页发,避免这里再复制一份 SSE 消费逻辑
navigate("omniSession", { conversationId: conversation.id, firstMessage: text, firstRefs: pendingRefs, firstUploads: sessionUploads }); navigate("omniSession", { conversationId: conversation.id, firstMessage: creationBrief, firstRefs: pendingRefs, firstUploads: sessionUploads });
}) })
.catch((error) => { .catch((error) => {
const status = (error as { status?: number }).status; const status = (error as { status?: number }).status;
+286 -15
View File
@@ -23,7 +23,7 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { api } from "../api"; import { api } from "../api";
import { findCatalogModel, OmniParamBar } from "../components/omni-param-bar"; import { findCatalogModel, normalizeDurationValue, OmniParamBar } from "../components/omni-param-bar";
import { estimateCost, pointsPerImageFromCatalog } from "../components/free-create/constants"; import { estimateCost, pointsPerImageFromCatalog } from "../components/free-create/constants";
import { MediaLightbox } from "../components/overlays"; import { MediaLightbox } from "../components/overlays";
import type { import type {
@@ -153,8 +153,33 @@ function numberedReplyOptions(text: string): ReplyOption[] {
})); }));
} }
function replyGuidanceText(text: string): string {
const paragraphs = (text || "")
.split(/\n+/)
.map((part) => part.trim())
.filter(Boolean);
const source = paragraphs.at(-1) || text || "";
const sentences = source
.split(/(?<=[。!?!?])/)
.map((part) => part.trim())
.filter(Boolean);
return (sentences.slice(-2).join("") || source).slice(-240);
}
function contextualReplyOptions(text: string): ReplyOption[] { function contextualReplyOptions(text: string): ReplyOption[] {
if (/(?:两位|两个|多位|多个).{0,24}(?:人物|角色|模特|男士|女生).{0,80}(?:哪位|哪个|选择|用哪|出镜)/i.test(text)) { // 只判断回复末尾真正交给用户决定的内容。创作描述本身经常同时出现商品、人物、
// 场景和颜色;扫描整段会把正文里的普通名词误判成下一步操作。
const guidance = replyGuidanceText(text);
const asksForDetail = /(?:额外|另外|其他|重点).{0,12}(?:突出|强调).{0,12}(?:细节|重点|卖点)|(?:细节|重点|卖点).{0,12}(?:突出|强调)/i.test(guidance);
const asksForColorOrder = /配色.{0,12}(?:顺序|排序|调换|调整)|(?:调换|调整).{0,12}配色/i.test(guidance);
if (asksForDetail && asksForColorOrder) {
return [
{ label: "补充突出细节", text: "我想补充需要额外突出的细节" },
{ label: "调整配色顺序", text: "我想调整配色的展示顺序" },
{ label: "按当前描述继续", text: "没有其他调整,按当前描述继续" },
];
}
if (/(?:两位|两个|多位|多个).{0,24}(?:人物|角色|模特|男士|女生).{0,80}(?:哪位|哪个|选择|用哪|出镜)/i.test(guidance)) {
return [ return [
{ label: "1", text: "选择第 1 位人物出镜" }, { label: "1", text: "选择第 1 位人物出镜" },
{ label: "2", text: "选择第 2 位人物出镜" }, { label: "2", text: "选择第 2 位人物出镜" },
@@ -163,42 +188,46 @@ function contextualReplyOptions(text: string): ReplyOption[] {
} }
// 人物参考和商品参考是两件事。已锁定人物后,助手若提到实物图,不能又给出「上传人物图」—— // 人物参考和商品参考是两件事。已锁定人物后,助手若提到实物图,不能又给出「上传人物图」——
// 这会让用户误以为刚上传的参考没有生效。 // 这会让用户误以为刚上传的参考没有生效。
if (/实物图|商品图|产品图|商品外观|产品外观|产品素材|耳机(?:图|外观)?|IP设计/i.test(text)) { const productReference = /实物图|商品图|产品图|商品外观|产品外观|产品素材|耳机(?:图|外观)?|IP设计/i;
const asksForProductReference =
/(?:上传|提供|补充|补一张|发一张|给我|需要|最好|建议).{0,24}(?:实物图|商品图|产品图|商品外观|产品外观|产品素材|耳机(?:图|外观)?|IP设计)/i.test(guidance)
|| /(?:实物图|商品图|产品图|商品外观|产品外观|产品素材|耳机(?:图|外观)?|IP设计).{0,24}(?:上传|提供|补充|发来|参考|需要)/i.test(guidance);
if (productReference.test(guidance) && asksForProductReference) {
return [ return [
{ label: "上传商品实物图", text: "我上传商品实物图", action: "upload" }, { label: "上传商品实物图", text: "我上传商品实物图", action: "upload" },
{ label: "按当前描述继续", text: "先按当前商品描述继续,不再补图" }, { label: "按当前描述继续", text: "先按当前商品描述继续,不再补图" },
{ label: "换一个商品", text: "我想换一个商品来创作" }, { label: "换一个商品", text: "我想换一个商品来创作" },
]; ];
} }
if (/颜色|色号|色彩|SKU|款式|几种/i.test(text)) { if (/颜色|色号|色彩|配色|SKU|款式|几种|展示顺序/i.test(guidance)) {
return [ return [
{ label: "补充颜色和顺序", text: "我来补充每个颜色和展示顺序" }, { label: "补充颜色和顺序", text: "我来补充每个颜色和展示顺序" },
{ label: "上传各款实物图", text: "我补充各颜色/款式的实物图", action: "upload" }, { label: "上传各款实物图", text: "我补充各颜色/款式的实物图", action: "upload" },
{ label: "先按当前主款做", text: "先按当前主款做,其他颜色后面再补" }, { label: "先按当前主款做", text: "先按当前主款做,其他颜色后面再补" },
]; ];
} }
if (/商品|产品|主推|哪款/i.test(text)) { if (/(?:哪款|哪个|什么).{0,8}(?:商品|产品)|(?:商品|产品).{0,12}(?:选择|选|换|更换|主推|想推|要推)|(?:选择|选|换|更换|主推|想推|要推).{0,12}(?:商品|产品)/i.test(guidance)) {
return [ return [
{ label: "发商品列表", text: "把商品列表发给我选" }, { label: "发商品列表", text: "把商品列表发给我选" },
{ label: "我直接说商品名", text: "我直接告诉你商品名" }, { label: "我直接说商品名", text: "我直接告诉你商品名" },
{ label: "你来推荐", text: "你根据当前需求推荐一款" }, { label: "你来推荐", text: "你根据当前需求推荐一款" },
]; ];
} }
if (/人物|角色|模特|出镜/i.test(text)) { if (/(?:哪位|哪个|什么|选择|选|换|更换|调整|修改|改).{0,12}(?:人物|角色|模特|出镜)|(?:人物|角色|模特|出镜).{0,12}(?:哪位|哪个|选择|选|换|更换|调整|修改|改)/i.test(guidance)) {
return [ return [
{ label: "上传人物图", text: "我上传人物参考图", action: "upload" }, { label: "上传人物图", text: "我上传人物参考图", action: "upload" },
{ label: "由你设定角色", text: "你先帮我设定一个合适的角色" }, { label: "由你设定角色", text: "你先帮我设定一个合适的角色" },
{ label: "不需要人物", text: "这条先不需要人物出镜" }, { label: "不需要人物", text: "这条先不需要人物出镜" },
]; ];
} }
if (/场景|地点|背景|在哪/i.test(text)) { if (/(?:哪里|哪儿|什么|选择|选|换|更换|调整|修改|改).{0,12}(?:场景|地点|背景)|(?:场景|地点|背景).{0,12}(?:哪里|哪儿|选择|选|换|更换|调整|修改|改)/i.test(guidance)) {
return [ return [
{ label: "上传场景图", text: "我上传场景参考图", action: "upload" }, { label: "上传场景图", text: "我上传场景参考图", action: "upload" },
{ label: "你来推荐场景", text: "你按商品和预设推荐场景" }, { label: "你来推荐场景", text: "你按商品和预设推荐场景" },
{ label: "用干净日常场景", text: "先用干净自然的日常场景" }, { label: "用干净日常场景", text: "先用干净自然的日常场景" },
]; ];
} }
if (/卖点|功能|效果|优惠|价格/i.test(text)) { if (/(?:补充|选择|选|换|更换|调整|修改|改|突出|强调).{0,12}(?:卖点|功能|效果|优惠|价格)|(?:卖点|功能|效果|优惠|价格).{0,12}(?:补充|选择|选|换|更换|调整|修改|改|突出|强调)/i.test(guidance)) {
return [ return [
{ label: "补充真实卖点", text: "我来补充商品真实卖点" }, { label: "补充真实卖点", text: "我来补充商品真实卖点" },
{ label: "从素材里判断", text: "先根据我上传的素材判断可表达的卖点" }, { label: "从素材里判断", text: "先根据我上传的素材判断可表达的卖点" },
@@ -745,6 +774,59 @@ function strategyField(payload: Record<string, unknown>, ...keys: string[]): str
return ""; return "";
} }
const STRATEGY_TEXT_SECTION_ALIASES: Record<string, "target" | "trust" | "belief" | "direction"> = {
: "target",
: "target",
: "target",
: "target",
: "trust",
: "trust",
: "trust",
: "trust",
: "trust",
: "belief",
: "belief",
: "belief",
: "belief",
: "direction",
: "direction",
: "direction",
: "direction",
};
/** 兼容历史上漏调 write_strategy、已经落库成普通文字的策略消息。 */
function strategyPayloadFromText(text: string): Record<string, string> | null {
const sections: Record<"target" | "trust" | "belief" | "direction", string[]> = {
target: [], trust: [], belief: [], direction: [],
};
let current: keyof typeof sections | "" = "";
for (const rawLine of String(text || "").split("\n")) {
const line = rawLine.replace(/^\s*(?:[-*#>]+\s*)?/, "").replace(/\*\*/g, "").trim();
if (!line) continue;
const match = line.match(/^([^:\n]{2,36})\s*[:]\s*(.*)$/);
if (match) {
const heading = match[1].replace(/[(].*$/, "").replace(/\s+/g, "").trim();
const key = STRATEGY_TEXT_SECTION_ALIASES[heading];
if (key) {
current = key;
const content = match[2].trim();
if (content) sections[key].push(content);
continue;
}
if (["创作策略", "策略理解", "创作策略理解"].includes(heading)) {
current = "";
continue;
}
}
if (!current || /^(?:你看|请确认|如果你|是否需要|可以再)/.test(line)) continue;
sections[current].push(line);
}
const payload = Object.fromEntries(
Object.entries(sections).map(([key, lines]) => [key, lines.join("\n").trim()]),
) as Record<string, string>;
return Object.values(payload).every(Boolean) ? payload : null;
}
function StrategyCard({ payload }: { payload: Record<string, unknown> }) { function StrategyCard({ payload }: { payload: Record<string, unknown> }) {
const items: Array<[string, string]> = [ const items: Array<[string, string]> = [
["这条视频给谁看", strategyField(payload, "target", "audience", "who", "给谁看", "目标人群")], ["这条视频给谁看", strategyField(payload, "target", "audience", "who", "给谁看", "目标人群")],
@@ -1043,6 +1125,7 @@ function ElicitCard({
disabled, disabled,
onSubmit, onSubmit,
onChatAnswer, onChatAnswer,
onPersonSourceAction,
}: { }: {
message: CreationMessage; message: CreationMessage;
disabled: boolean; disabled: boolean;
@@ -1051,6 +1134,8 @@ function ElicitCard({
onSubmit: (answers: Record<string, string | string[]>, refs: CreationRef[]) => void; onSubmit: (answers: Record<string, string | string[]>, refs: CreationRef[]) => void;
/** 普通追问直接在气泡下回答,仍走 text 链路以保留用户消息与上下文。 */ /** 普通追问直接在气泡下回答,仍走 text 链路以保留用户消息与上下文。 */
onChatAnswer: (text: string) => void; onChatAnswer: (text: string) => void;
/** 人物来源三选一要分别打开系统文件、模特库和平台生成流程。 */
onPersonSourceAction: (source: "local_upload" | "model_library" | "platform_generate") => void;
}) { }) {
const fields = ((message.payload.fields as CreationField[] | undefined) || []).filter(Boolean); const fields = ((message.payload.fields as CreationField[] | undefined) || []).filter(Boolean);
const submitted = Boolean(message.payload.submitted); const submitted = Boolean(message.payload.submitted);
@@ -1105,6 +1190,51 @@ function ElicitCard({
); );
} }
if (interaction === "person_source_gate") {
const selected = String(saved.person_source || "");
const selectedLabel = fields[0]?.options?.find((option) => option.value === selected)?.label;
return (
<div className="omni-chat-row agent">
<span className="omni-chat-avatar">
<Sparkles />
</span>
<div className="omni-chat-bubble omni-gate-bubble">
<ChatMarkdown text={message.text || "先选定出镜人物。"} />
{!submitted ? (
<div className="omni-gate-actions" aria-label="人物来源选择">
<button
type="button"
className="primary"
disabled={disabled}
onClick={() => onPersonSourceAction("local_upload")}
>
</button>
<button
type="button"
disabled={disabled}
onClick={() => onPersonSourceAction("model_library")}
>
</button>
<button
type="button"
disabled={disabled}
onClick={() => onPersonSourceAction("platform_generate")}
>
</button>
</div>
) : (
<p className="omni-gate-answered">
{selectedLabel ? `已选择:${selectedLabel}` : "人物来源已确定"}
</p>
)}
</div>
</div>
);
}
if (interaction === "selling_point_gate") { if (interaction === "selling_point_gate") {
const sellingPoint = typeof answers.selling_point === "string" ? answers.selling_point : ""; const sellingPoint = typeof answers.selling_point === "string" ? answers.selling_point : "";
const savedMode = String(saved.selling_point_mode || ""); const savedMode = String(saved.selling_point_mode || "");
@@ -1314,6 +1444,12 @@ function ElicitCard({
if (interaction === "chat") { if (interaction === "chat") {
const question = message.text || fields[0]?.label || "这项你想怎么定?"; const question = message.text || fields[0]?.label || "这项你想怎么定?";
const choiceField = fields.find(
(field) => field.type === "single" && Array.isArray(field.options) && field.options.length > 0,
);
const savedChoice = choiceField ? String(saved[choiceField.key] || "") : "";
const savedChoiceLabel = choiceField?.options?.find((option) => option.value === savedChoice)?.label;
const savedChoiceText = savedChoiceLabel || savedChoice;
const submitChatAnswer = (event: FormEvent<HTMLFormElement>) => { const submitChatAnswer = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
const text = chatAnswer.trim(); const text = chatAnswer.trim();
@@ -1332,6 +1468,20 @@ function ElicitCard({
</div> </div>
{!submitted ? ( {!submitted ? (
<div className="omni-reply-guide" aria-label="回复操作"> <div className="omni-reply-guide" aria-label="回复操作">
{choiceField ? (
<div className="omni-gate-actions omni-chat-choice-actions" aria-label={choiceField.label}>
{(choiceField.options || []).map((option) => (
<button
type="button"
key={option.value}
disabled={disabled}
onClick={() => onSubmit({ [choiceField.key]: option.value }, [])}
>
{option.label}
</button>
))}
</div>
) : null}
<form className="omni-chat-question-input" onSubmit={submitChatAnswer}> <form className="omni-chat-question-input" onSubmit={submitChatAnswer}>
<input <input
className="input" className="input"
@@ -1346,6 +1496,8 @@ function ElicitCard({
</button> </button>
</form> </form>
</div> </div>
) : savedChoiceText ? (
<p className="omni-gate-answered">{savedChoiceText}</p>
) : null} ) : null}
</div> </div>
</div> </div>
@@ -1485,9 +1637,13 @@ function ConfirmCard({
const payloadParams = asStringMap(message.payload.params); const payloadParams = asStringMap(message.payload.params);
const snapshot = { ...sessionParams, ...payloadParams }; const snapshot = { ...sessionParams, ...payloadParams };
const [draft, setDraft] = useState(snapshot); const [draft, setDraft] = useState(snapshot);
const [initialDuration] = useState(snapshot.duration || "");
const cardIsVideo = message.payload.kind !== "image" && isVideo; const cardIsVideo = message.payload.kind !== "image" && isVideo;
const durationChanged = const durationChanged =
cardIsVideo && Boolean(draft.duration) && Boolean(snapshot.duration) && draft.duration !== snapshot.duration; cardIsVideo
&& Boolean(draft.duration)
&& Boolean(initialDuration)
&& normalizeDurationValue(draft.duration) !== normalizeDurationValue(initialDuration);
const durationSeconds = Number(String(draft.duration || "").replace(/\D/g, "")); const durationSeconds = Number(String(draft.duration || "").replace(/\D/g, ""));
const willGenerateInSegments = cardIsVideo && durationSeconds > 30 && durationSeconds <= 60; const willGenerateInSegments = cardIsVideo && durationSeconds > 30 && durationSeconds <= 60;
const setField = (key: string, value: string) => setDraft((prev) => ({ ...prev, [key]: value })); const setField = (key: string, value: string) => setDraft((prev) => ({ ...prev, [key]: value }));
@@ -2055,6 +2211,11 @@ export function OmniSessionPage({
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// 对话引导里的「上传人物图」要直达系统文件选择器,而不是绕回通用素材菜单。 // 对话引导里的「上传人物图」要直达系统文件选择器,而不是绕回通用素材菜单。
const quickUploadReplyRef = useRef<ReplyOption | null>(null); const quickUploadReplyRef = useRef<ReplyOption | null>(null);
// 人物来源闸门借用全局文件选择器 / @素材面板,选完后需回到对应卡片提交。
const personSourceRequestRef = useRef<{
messageId: string;
source: "local_upload" | "model_library";
} | null>(null);
const composerRef = useRef<HTMLTextAreaElement>(null); const composerRef = useRef<HTMLTextAreaElement>(null);
const [streaming, setStreaming] = useState(false); const [streaming, setStreaming] = useState(false);
const [liveText, setLiveText] = useState(""); const [liveText, setLiveText] = useState("");
@@ -2716,6 +2877,36 @@ export function OmniSessionPage({
}, [mentionMenuOpen]); }, [mentionMenuOpen]);
const insertMention = (ref: CreationRef) => { const insertMention = (ref: CreationRef) => {
const personRequest = personSourceRequestRef.current;
if (personRequest) {
if (ref.type !== "model" && ref.type !== "character") {
notify("info", "请选择一位模特或角色");
return;
}
personSourceRequestRef.current = null;
setMentionMenuOpen(false);
setMessages((prev) =>
prev.map((item) =>
item.id === personRequest.messageId
? {
...item,
payload: {
...item.payload,
submitted: true,
answers: { person_source: "model_library" },
},
}
: item
)
);
void send({
kind: "elicit_answer",
reply_to: personRequest.messageId,
answers: { person_source: "model_library" },
refs: [ref],
});
return;
}
// 整条 Ref 存起来一起发:只把名字拼进文本的话,后端取不到卖点和参考图 // 整条 Ref 存起来一起发:只把名字拼进文本的话,后端取不到卖点和参考图
if (pendingRefs.some((r) => r.id === ref.id)) { if (pendingRefs.some((r) => r.id === ref.id)) {
setMentionMenuOpen(false); setMentionMenuOpen(false);
@@ -2782,6 +2973,41 @@ export function OmniSessionPage({
void send({ kind: "elicit_answer", reply_to: message.id, answers, refs }); void send({ kind: "elicit_answer", reply_to: message.id, answers, refs });
}} }}
onChatAnswer={(text) => void send({ kind: "text", text })} onChatAnswer={(text) => void send({ kind: "text", text })}
onPersonSourceAction={(source) => {
if (source === "local_upload") {
personSourceRequestRef.current = { messageId: message.id, source };
quickUploadReplyRef.current = null;
setMentionMenuOpen(false);
setUploadMenuOpen(false);
fileInputRef.current?.click();
return;
}
if (source === "model_library") {
personSourceRequestRef.current = { messageId: message.id, source };
void openMentions("", "model");
return;
}
setMessages((prev) =>
prev.map((item) =>
item.id === message.id
? {
...item,
payload: {
...item.payload,
submitted: true,
answers: { person_source: source },
},
}
: item
)
);
void send({
kind: "elicit_answer",
reply_to: message.id,
answers: { person_source: source },
refs: [],
});
}}
/> />
); );
case "strategy": case "strategy":
@@ -2844,6 +3070,12 @@ export function OmniSessionPage({
const rawBody = stripMentionText(message.text, refs); const rawBody = stripMentionText(message.text, refs);
const body = message.role === "assistant" ? stripNumericReplyInstruction(rawBody) : rawBody; const body = message.role === "assistant" ? stripNumericReplyInstruction(rawBody) : rawBody;
const pending = message.id === pendingUserId; const pending = message.id === pendingUserId;
const legacyStrategy = message.role === "assistant" && message.kind === "text"
? strategyPayloadFromText(body)
: null;
if (legacyStrategy) {
return <StrategyCard key={message.clientKey || message.id} payload={legacyStrategy} />;
}
const showReplyGuide = const showReplyGuide =
message.role === "assistant" message.role === "assistant"
&& message.kind === "text" && message.kind === "text"
@@ -2875,6 +3107,7 @@ export function OmniSessionPage({
onSubmit={(text) => void send({ kind: "text", text })} onSubmit={(text) => void send({ kind: "text", text })}
onUpload={(option) => { onUpload={(option) => {
if (streaming || uploading) return; if (streaming || uploading) return;
personSourceRequestRef.current = null;
quickUploadReplyRef.current = option; quickUploadReplyRef.current = option;
fileInputRef.current?.click(); fileInputRef.current?.click();
}} }}
@@ -3029,6 +3262,7 @@ export function OmniSessionPage({
disabled={uploading || streaming} disabled={uploading || streaming}
onClick={() => { onClick={() => {
if (uploading || streaming) return; if (uploading || streaming) return;
personSourceRequestRef.current = null;
quickUploadReplyRef.current = null; quickUploadReplyRef.current = null;
setUploadMenuOpen((open) => !open); setUploadMenuOpen((open) => !open);
setMentionMenuOpen(false); setMentionMenuOpen(false);
@@ -3042,6 +3276,7 @@ export function OmniSessionPage({
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
personSourceRequestRef.current = null;
setUploadMenuOpen(false); setUploadMenuOpen(false);
void openMentions(); void openMentions();
}} }}
@@ -3054,6 +3289,7 @@ export function OmniSessionPage({
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
personSourceRequestRef.current = null;
quickUploadReplyRef.current = null; quickUploadReplyRef.current = null;
setUploadMenuOpen(false); setUploadMenuOpen(false);
fileInputRef.current?.click(); fileInputRef.current?.click();
@@ -3075,9 +3311,16 @@ export function OmniSessionPage({
const files = Array.from(event.target.files || []); const files = Array.from(event.target.files || []);
event.target.value = ""; event.target.value = "";
if (!files.length) return; if (!files.length) return;
const images = files.filter((file) => file.type.startsWith("image/")); const personRequest = personSourceRequestRef.current;
const selectedImages = files.filter((file) => file.type.startsWith("image/"));
const images = personRequest ? selectedImages.slice(0, 1) : selectedImages;
if (images.length !== files.length) { if (images.length !== files.length) {
notify("info", "全能创作仅支持上传图片"); notify(
"info",
personRequest && selectedImages.length > 1
? "人物参考每次选择一张图片"
: "全能创作仅支持上传图片",
);
} }
if (!images.length) return; if (!images.length) return;
setUploading(true); setUploading(true);
@@ -3090,28 +3333,55 @@ export function OmniSessionPage({
// 后端上传时同步送审并等待通过,期间按钮保持「上传中」 // 后端上传时同步送审并等待通过,期间按钮保持「上传中」
const data = await api.uploadFreeVideoRef(form); const data = await api.uploadFreeVideoRef(form);
const ref: CreationRef = { const ref: CreationRef = {
type: "asset", // 人物闸门上传的图是身份参考,不能作为普通 asset 传给出片模型。
type: personRequest ? "character" : "asset",
id: data.asset_id, id: data.asset_id,
name: data.name || file.name, name: data.name || file.name,
cover: data.thumb_url || data.url, cover: data.thumb_url || data.url,
}; };
setSessionUploads((prev) => (prev.some((item) => item.id === ref.id) ? prev : [...prev, ref])); setSessionUploads((prev) => (prev.some((item) => item.id === ref.id) ? prev : [...prev, ref]));
uploadedRefs.push(ref); uploadedRefs.push(ref);
if (!quickReply) { if (!quickReply && !personRequest) {
setPendingRefs((prev) => { setPendingRefs((prev) => {
if (prev.some((item) => item.id === ref.id) || prev.length >= MENTION_REF_LIMIT) return prev; if (prev.some((item) => item.id === ref.id) || prev.length >= MENTION_REF_LIMIT) return prev;
return [...prev, ref]; return [...prev, ref];
}); });
} }
} }
notify("success", images.length > 1 ? "素材已上传并通过审核" : "素材已上传并通过审核"); notify(
if (quickReply && uploadedRefs.length) { "success",
personRequest ? "人物参考已上传并锁定" : "素材已上传并通过审核",
);
if (personRequest && uploadedRefs.length) {
personSourceRequestRef.current = null;
setMessages((prev) =>
prev.map((item) =>
item.id === personRequest.messageId
? {
...item,
payload: {
...item.payload,
submitted: true,
answers: { person_source: "local_upload" },
},
}
: item
)
);
void send({
kind: "elicit_answer",
reply_to: personRequest.messageId,
answers: { person_source: "local_upload" },
refs: uploadedRefs,
});
} else if (quickReply && uploadedRefs.length) {
quickUploadReplyRef.current = null; quickUploadReplyRef.current = null;
void send({ kind: "text", text: uploadReplyText(quickReply), refs: uploadedRefs }); void send({ kind: "text", text: uploadReplyText(quickReply), refs: uploadedRefs });
} }
} catch (error) { } catch (error) {
notify("error", (error as Error).message); notify("error", (error as Error).message);
quickUploadReplyRef.current = null; quickUploadReplyRef.current = null;
personSourceRequestRef.current = null;
} finally { } finally {
setUploading(false); setUploading(false);
} }
@@ -3124,6 +3394,7 @@ export function OmniSessionPage({
className="omni-icon-tool" className="omni-icon-tool"
aria-label="引用素材" aria-label="引用素材"
onClick={() => { onClick={() => {
personSourceRequestRef.current = null;
if (mentionMenuOpen) setMentionMenuOpen(false); if (mentionMenuOpen) setMentionMenuOpen(false);
else void openMentions("", mentionTab); else void openMentions("", mentionTab);
}} }}
+2
View File
@@ -118,6 +118,8 @@ export type AdminReviewAsset = {
export type AdminTask = { export type AdminTask = {
id: string; id: string;
task_type: string; task_type: string;
/** 平台后台展示来源:全能创作任务沿用底层 task_type 调度,不与自由创作混在一类。 */
task_category?: "omni_create" | "standard";
status: string; status: string;
team: string; team: string;
team_name: string | null; team_name: string | null;