@@ -1,5 +1,7 @@
|
||||
from pathlib import Path
|
||||
import os
|
||||
import sys
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
@@ -12,6 +14,20 @@ def env(name: str, default: str | None = None) -> str | None:
|
||||
return os.getenv(name, default)
|
||||
|
||||
|
||||
def redis_url(name: str, database: int) -> str:
|
||||
"""本机运行时把 Docker 内部 Redis 主机名映射到已启动的本机 Redis。
|
||||
|
||||
容器/Linux 与其他显式配置保持原样;不读取或输出 URL 中的凭证。
|
||||
"""
|
||||
fallback = f"redis://127.0.0.1:6379/{database}"
|
||||
configured = env(name, fallback) or fallback
|
||||
if sys.platform == "darwin" and urlsplit(configured).hostname == "yingqing-redis":
|
||||
# Celery 自身也优先读取 CELERY_BROKER_URL 环境变量,只改 Django settings 不够。
|
||||
os.environ[name] = fallback
|
||||
return fallback
|
||||
return configured
|
||||
|
||||
|
||||
def env_bool(name: str, default: bool = False) -> bool:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
@@ -191,12 +207,12 @@ CORS_ALLOW_CREDENTIALS = True
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.redis.RedisCache",
|
||||
"LOCATION": env("REDIS_CACHE_URL", "redis://127.0.0.1:6379/0"),
|
||||
"LOCATION": redis_url("REDIS_CACHE_URL", 0),
|
||||
}
|
||||
}
|
||||
|
||||
CELERY_BROKER_URL = env("CELERY_BROKER_URL", "redis://127.0.0.1:6379/1")
|
||||
CELERY_RESULT_BACKEND = env("CELERY_RESULT_BACKEND", "redis://127.0.0.1:6379/2")
|
||||
CELERY_BROKER_URL = redis_url("CELERY_BROKER_URL", 1)
|
||||
CELERY_RESULT_BACKEND = redis_url("CELERY_RESULT_BACKEND", 2)
|
||||
# 本地端到端联调开关:true = 任务同进程内联执行,不碰共享 Redis 队列(线上 worker 拓扑不受影响)。
|
||||
# 生产/集群绝不开;test.py 恒为 True。自重排类任务(poll_free_video_task)在 eager 下会跳过重排防递归。
|
||||
CELERY_TASK_ALWAYS_EAGER = str(env("CELERY_TASK_ALWAYS_EAGER", "false")).lower() == "true"
|
||||
@@ -236,7 +252,7 @@ CREATION_AGENT_MAX_OUTPUT_TOKENS = env_int("CREATION_AGENT_MAX_OUTPUT_TOKENS", 1
|
||||
# 置空则不下发、跟随模型默认(= 开思考),仅在需要对比质量时才这么配。
|
||||
CREATION_AGENT_THINKING_MODE = (env("CREATION_AGENT_THINKING_MODE", "disabled") or "").strip()
|
||||
|
||||
REDIS_LOCK_URL = env("REDIS_LOCK_URL", "redis://127.0.0.1:6379/3")
|
||||
REDIS_LOCK_URL = redis_url("REDIS_LOCK_URL", 3)
|
||||
|
||||
def _tos_region() -> str:
|
||||
"""阿里云 OSS 的 S3 签名区域是 oss-cn-beijing 这种主机名前缀。火山 TOS 仍用 cn-shanghai。"""
|
||||
|
||||
@@ -83,7 +83,7 @@ def _meta_from_task(task, message: CreationMessage) -> dict:
|
||||
model = task.model_config.display_name or task.model_config.name
|
||||
return {
|
||||
"model": model or payload.get("model") or req.get("model") or "",
|
||||
"ratio": req.get("ratio") or payload.get("ratio") or "",
|
||||
"ratio": req.get("aspect_ratio") or req.get("ratio") or payload.get("ratio") or "",
|
||||
"prompt": payload.get("prompt") or req.get("prompt") or "",
|
||||
}
|
||||
|
||||
@@ -103,11 +103,15 @@ def _message_task(message: CreationMessage):
|
||||
@transaction.atomic
|
||||
def fail_generating_message(message: CreationMessage, error: str) -> CreationMessage:
|
||||
"""生成失败:GENERATING 原地改成 ERROR,不另开一条,避免中间态刷屏。"""
|
||||
# worker 与会话轮询可能同时回填同一条消息;先锁会话再锁消息,和 append_message 的锁顺序一致。
|
||||
conversation = CreationConversation.objects.select_for_update().get(pk=message.conversation_id)
|
||||
message = CreationMessage.objects.select_for_update().get(pk=message.pk)
|
||||
if message.kind != CreationMessage.Kind.GENERATING:
|
||||
return message
|
||||
is_person_reference = (message.payload or {}).get("kind") == "person_reference"
|
||||
message.kind = CreationMessage.Kind.ERROR
|
||||
message.text = (error or "生成失败")[:500]
|
||||
message.save(update_fields=["kind", "text", "updated_at"])
|
||||
conversation = message.conversation
|
||||
if is_person_reference:
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["person_source_pending"] = False
|
||||
@@ -443,6 +447,7 @@ def start_segmented_video_merge(*, conversation: CreationConversation, message:
|
||||
"source_message_id": str(message.id),
|
||||
"source_task_ids": task_ids,
|
||||
"prompt": payload.get("prompt") or "",
|
||||
"aspect_ratio": payload.get("ratio") or (source_tasks[0].request_payload or {}).get("aspect_ratio") or "",
|
||||
},
|
||||
)
|
||||
payload["merge_state"] = "queued"
|
||||
@@ -453,7 +458,11 @@ def start_segmented_video_merge(*, conversation: CreationConversation, message:
|
||||
conversation,
|
||||
role="assistant",
|
||||
kind=CreationMessage.Kind.GENERATING,
|
||||
payload={"task_id": str(merge_task.id), "kind": "video_merge", "prompt": payload.get("prompt") or ""},
|
||||
payload={
|
||||
"task_id": str(merge_task.id), "kind": "video_merge",
|
||||
"prompt": payload.get("prompt") or "",
|
||||
"ratio": payload.get("ratio") or (source_tasks[0].request_payload or {}).get("aspect_ratio") or "",
|
||||
},
|
||||
task=merge_task,
|
||||
)
|
||||
return merge_task, generating
|
||||
@@ -573,12 +582,13 @@ def finish_generating_message(message: CreationMessage, *, assets: list[dict], m
|
||||
重生成是**新开一条** GENERATING → RESULT,所以对话流仍然是往下叠加;
|
||||
这里改的只是同一次生成自己的中间态。
|
||||
"""
|
||||
# select_for_update 后重读状态:第二个回填者不能凭旧 GENERATING 对象再发一次确认。
|
||||
conversation = CreationConversation.objects.select_for_update().get(pk=message.conversation_id)
|
||||
message = CreationMessage.objects.select_for_update().get(pk=message.pk)
|
||||
if message.kind != CreationMessage.Kind.GENERATING:
|
||||
return message
|
||||
original_payload = dict(message.payload or {})
|
||||
is_person_reference = original_payload.get("kind") == "person_reference"
|
||||
message.kind = CreationMessage.Kind.RESULT
|
||||
message.payload = {**original_payload, **meta, "assets": assets}
|
||||
message.save(update_fields=["kind", "payload", "updated_at"])
|
||||
conversation = message.conversation
|
||||
if is_person_reference:
|
||||
from apps.assets.models import Asset, Model
|
||||
|
||||
@@ -591,6 +601,10 @@ def finish_generating_message(message: CreationMessage, *, assets: list[dict], m
|
||||
).first()
|
||||
if asset is None:
|
||||
return fail_generating_message(message, "人物参考已生成,但未找到可锁定的图片资产")
|
||||
message.kind = CreationMessage.Kind.RESULT
|
||||
message.payload = {**original_payload, **meta, "assets": assets}
|
||||
message.save(update_fields=["kind", "payload", "updated_at"])
|
||||
if is_person_reference:
|
||||
cast_index = int(original_payload.get("cast_index") or 1)
|
||||
cast_total = int(original_payload.get("cast_total") or 1)
|
||||
cast_model_name = str(original_payload.get("cast_model_name") or "").strip()
|
||||
|
||||
@@ -29,14 +29,17 @@ from django.db import transaction
|
||||
|
||||
from .creation import append_message, pin_refs
|
||||
from .creation_presets import (
|
||||
LEGACY_PLOT_TWIST_PRESET,
|
||||
PLOT_TWIST_PRESET,
|
||||
apply_image_preset_prompt,
|
||||
apply_plot_twist_story_contract,
|
||||
apply_plot_twist_direction_contract,
|
||||
format_plot_twist_direction_contract,
|
||||
apply_video_preset_prompt,
|
||||
canonical_preset_name,
|
||||
is_click_swap_preset,
|
||||
is_fish_eye_outfit_preset,
|
||||
is_plot_twist_preset,
|
||||
fish_eye_outfit_prompt_template,
|
||||
plot_twist_story_depth,
|
||||
plot_twist_story_contract,
|
||||
@@ -255,6 +258,7 @@ _STEP_CONFIRM_LABELS = {
|
||||
_PERSON_SOURCE_PRESETS = {
|
||||
"痛点解决演示",
|
||||
PLOT_TWIST_PRESET,
|
||||
LEGACY_PLOT_TWIST_PRESET,
|
||||
"短剧反转带货",
|
||||
"达人口播种草",
|
||||
"鱼眼换装",
|
||||
@@ -278,6 +282,7 @@ _CAST_RELATION_AUTO_RE = re.compile(r"(你来定|你安排|你决定|随便|都
|
||||
_PRODUCT_REQUIRED_PRESETS = {
|
||||
"痛点解决演示",
|
||||
PLOT_TWIST_PRESET,
|
||||
LEGACY_PLOT_TWIST_PRESET,
|
||||
"达人口播种草",
|
||||
"点击换款",
|
||||
"多色商品换款",
|
||||
@@ -290,7 +295,7 @@ _PRODUCT_REQUIRED_PRESETS = {
|
||||
|
||||
|
||||
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 is_plot_twist_preset(conversation.preset)
|
||||
|
||||
|
||||
|
||||
@@ -594,6 +599,45 @@ _SELLING_POINT_FOCUS_RE = re.compile(
|
||||
_SELLING_POINT_BRIEF_CREATIVE_RE = re.compile(
|
||||
r"(?:创作|做[一条]?|视频|帮我|生成|脚本|出片|参考|预设|方向)",
|
||||
)
|
||||
_BRIEF_CLAUSE_END = r"[^。!?!?;;,,\n]{2,160}"
|
||||
_BRIEF_AUDIENCE_RE = re.compile(
|
||||
rf"(?:适用人群|目标人群|目标用户|适合人群|面向人群|受众)\s*(?:是|为|包括|:|:)\s*({_BRIEF_CLAUSE_END})"
|
||||
)
|
||||
_BRIEF_SCENARIO_RE = re.compile(
|
||||
rf"(?:使用环境|使用场景|适用场景|拍摄场景|发生场景|场景)\s*(?:是|为|包括|设在|设定为|:|:)\s*({_BRIEF_CLAUSE_END})"
|
||||
)
|
||||
_BRIEF_ACTION_BENEFIT_RE = re.compile(
|
||||
r"(?:^|[。!?!?;;,,]\s*)(?P<action>(?:把|将)[^。!?!?;;,,\n]{2,100}?"
|
||||
r"(?:就|即可|便能|可以)[^。!?!?;;,,\n]{2,100})"
|
||||
)
|
||||
|
||||
|
||||
def extract_brief_context(text: str) -> dict[str, str]:
|
||||
"""只采集用户明确说出的受众、场景和使用动作,不推断价格、功效等商品事实。"""
|
||||
raw = (text or "").strip()
|
||||
found: dict[str, str] = {}
|
||||
for key, pattern in (("target_audience", _BRIEF_AUDIENCE_RE), ("usage_scenario", _BRIEF_SCENARIO_RE)):
|
||||
matches = list(pattern.finditer(raw))
|
||||
if matches:
|
||||
value = matches[-1].group(1).strip(" ::。;;,, ")
|
||||
if value and not re.match(r"^(?:和|与|及|或)?(?:核心卖点|使用场景|目标用户|价格)$", value):
|
||||
found[key] = value[:160]
|
||||
action = _BRIEF_ACTION_BENEFIT_RE.search(raw)
|
||||
if action and not re.search(r"(?:不要|不能|禁止|别)\s*$", raw[max(0, action.start() - 8):action.start()]):
|
||||
found["usage_benefit"] = action.group("action").strip()[:160]
|
||||
return found
|
||||
|
||||
|
||||
def collect_product_brief_context(conversation: CreationConversation, user_text: str = "") -> dict[str, str]:
|
||||
"""按消息顺序合并商品 brief,后续补充覆盖先前描述。"""
|
||||
found: dict[str, str] = {}
|
||||
for message_text in conversation.messages.filter(role=CreationMessage.Role.USER).order_by("seq").values_list("text", flat=True):
|
||||
found.update(extract_brief_context(str(message_text or "")))
|
||||
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
|
||||
found.update(extract_brief_context(str(memory.get("product_brief_note") or "")))
|
||||
if user_text:
|
||||
found.update(extract_brief_context(user_text))
|
||||
return found
|
||||
|
||||
|
||||
def extract_declared_selling_point(text: str) -> str:
|
||||
@@ -614,6 +658,11 @@ def extract_declared_selling_point(text: str) -> str:
|
||||
value = value.strip("。;;!!??,,、 ")
|
||||
if len(value) >= 2:
|
||||
return value[:200]
|
||||
# 用户可能没有说「卖点」二字,却明确描述了商品使用动作及结果。
|
||||
# 这里只复述该动作,不衍生「更快/更健康」等未经证实的功效。
|
||||
action = extract_brief_context(raw).get("usage_benefit", "")
|
||||
if action:
|
||||
return action
|
||||
# 整段就是短卖点列表(如「补水保湿,便于携带,买三送一」),且不像创作指令
|
||||
if len(raw) <= 80 and not _SELLING_POINT_BRIEF_CREATIVE_RE.search(raw):
|
||||
parts = [part.strip() for part in re.split(r"[,,、;;]", raw) if part.strip()]
|
||||
@@ -655,7 +704,7 @@ def lock_selling_point(
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["selling_point_ready"] = True
|
||||
memory["selling_point_mode"] = mode
|
||||
memory["selling_point"] = selling_point if mode == "manual" else ""
|
||||
memory["selling_point"] = selling_point if mode in {"manual", "inferred"} else ""
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
|
||||
@@ -674,17 +723,19 @@ def ensure_selling_point_ready_from_context(
|
||||
if memory.get("selling_point_ready"):
|
||||
return True
|
||||
|
||||
candidates: list[str] = []
|
||||
declared = extract_declared_selling_point(user_text)
|
||||
if declared:
|
||||
candidates.append(declared)
|
||||
candidates: list[tuple[str, str]] = []
|
||||
for text in conversation.messages.filter(role=CreationMessage.Role.USER).order_by("seq").values_list("text", flat=True):
|
||||
found = extract_declared_selling_point(text)
|
||||
if found:
|
||||
candidates.append(found)
|
||||
candidates.append((found, "inferred" if found == extract_brief_context(text).get("usage_benefit") else "manual"))
|
||||
for source in (str(memory.get("product_brief_note") or ""), user_text):
|
||||
found = extract_declared_selling_point(source)
|
||||
if found:
|
||||
candidates.append((found, "inferred" if found == extract_brief_context(source).get("usage_benefit") else "manual"))
|
||||
if candidates:
|
||||
# 以最近一次声明为准(主页首条或后续补充)
|
||||
lock_selling_point(conversation, candidates[-1], mode="manual")
|
||||
selling_point, mode = candidates[-1]
|
||||
lock_selling_point(conversation, selling_point, mode=mode)
|
||||
return True
|
||||
|
||||
summary = product_selling_points_summary(conversation)
|
||||
@@ -694,6 +745,17 @@ def ensure_selling_point_ready_from_context(
|
||||
return False
|
||||
|
||||
|
||||
def record_product_brief_review(conversation: CreationConversation, note: str = "") -> None:
|
||||
"""提交商品补充时同步卖点状态,避免选完剧情方向后再次追问已填写的内容。"""
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["product_brief_reviewed"] = True
|
||||
if note:
|
||||
memory["product_brief_note"] = note
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
ensure_selling_point_ready_from_context(conversation, note)
|
||||
|
||||
|
||||
def append_selling_point_gate(conversation: CreationConversation) -> CreationMessage:
|
||||
"""在视频方案生成前确认卖点来源。
|
||||
|
||||
@@ -1117,6 +1179,20 @@ def product_brief_items(conversation: CreationConversation) -> list[dict]:
|
||||
or ""
|
||||
).strip()
|
||||
selling = str(memory.get("selling_point") or "").strip()
|
||||
context = collect_product_brief_context(conversation)
|
||||
audience = context.get("target_audience", "")
|
||||
for ref in products:
|
||||
if audience or ref.get("type") != "product":
|
||||
continue
|
||||
from apps.products.models import Product
|
||||
audience = str(
|
||||
Product.objects.filter(id=ref.get("id"), team_id=conversation.team_id)
|
||||
.values_list("target_audience", flat=True).first() or ""
|
||||
).strip()
|
||||
scenario = context.get("usage_scenario", "")
|
||||
audience_and_scenario = ";".join(
|
||||
part for part in (f"目标用户:{audience}" if audience else "", f"使用场景:{scenario}" if scenario else "") if part
|
||||
)
|
||||
|
||||
def item(label: str, status: str, value: str = "") -> dict:
|
||||
return {"label": label, "status": status, "value": value}
|
||||
@@ -1131,7 +1207,7 @@ def product_brief_items(conversation: CreationConversation) -> list[dict]:
|
||||
item("名称与品牌", "ready" if name else "missing", name),
|
||||
item("品类与外观", "ready" if products else "missing", "将以已锁定商品图为准" if products else ""),
|
||||
item("核心卖点", "ready" if selling else "missing", selling),
|
||||
item("使用场景与目标用户", "missing", ""),
|
||||
item("使用场景与目标用户", "ready" if audience and scenario else "missing", audience_and_scenario),
|
||||
item("价格与优惠", "missing" if factual_price_requested else "not_needed", ""),
|
||||
item("特别强调或禁止内容", "ready" if special else "not_needed", special),
|
||||
]
|
||||
@@ -1144,6 +1220,20 @@ def product_brief_needs_review(conversation: CreationConversation) -> bool:
|
||||
return has_locked_product_reference(conversation) and not bool(memory.get("product_brief_reviewed"))
|
||||
|
||||
|
||||
def accept_complete_product_brief(conversation: CreationConversation) -> bool:
|
||||
"""用户和商品库已给足必要信息时,跳过重复的核对问答。"""
|
||||
if not product_brief_needs_review(conversation):
|
||||
return False
|
||||
items = product_brief_items(conversation)
|
||||
if any(item["status"] == "missing" for item in items):
|
||||
return False
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["product_brief_reviewed"] = True
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
return True
|
||||
|
||||
|
||||
def append_product_brief_review(conversation: CreationConversation) -> CreationMessage:
|
||||
"""商品信息只核对一次;已获取、待补充、暂不需要在一张卡里说清。"""
|
||||
return append_message(
|
||||
@@ -2024,7 +2114,7 @@ def emit_final_confirm_gate(
|
||||
},
|
||||
"submitted": False,
|
||||
"params": snapshot_session_params(conversation),
|
||||
"param_options": confirm_param_options(True),
|
||||
"param_options": confirm_param_options(True, fixed_duration=is_plot_twist_conversation(conversation)),
|
||||
},
|
||||
)
|
||||
set_video_gate_stage(conversation, "confirm", pending_video_prompt=prompt)
|
||||
@@ -2090,7 +2180,7 @@ video_prompt 是交给出片模型的完整制作文件,不是方案摘要、
|
||||
色彩与材质系统:写出主色、材质、皮肤/产品/环境的可见质感。
|
||||
打光规则:光源方向、软硬、色温、人物和产品分别如何受光。
|
||||
剪辑节奏:列出时间段与 Hook → 证据/体验 → 转化收束的推进逻辑。
|
||||
剧情反转带货例外:上述广告节奏让位于完整故事的起因→行动→受阻→升级→反转→结果;商品证据嵌入剧情因果,结局之后才自然收束,不要把 60 秒写成某一场戏的片段。
|
||||
短剧带货例外:上述广告节奏让位于完整故事的起因→行动→受阻→升级→反转→结果;商品证据嵌入剧情因果,结局之后才自然收束,不要把 60 秒写成某一场戏的片段。
|
||||
声音方向:人声身份、语气、语速、环境声/拟音、背景音乐的进入和收束;人声原文必须分配到对应分镜。
|
||||
场景:逐一写清可见地点、前中后景、环境道具与景深。
|
||||
主体与参考素材:逐一说明角色、商品、场景的可见身份和一致性要求。多人物时先分行写「角色1:身份、性别、成年年龄段、发型与服装」「角色2:…」,每位角色单独一行,便于逐位生成定妆图;角色外观不得与剧情身份矛盾。已 @ 的素材按 @图片1、@图片2 … 标注其用途;仅引用实际提供的素材。
|
||||
@@ -3028,6 +3118,8 @@ def apply_explicit_video_duration_from_text(
|
||||
"""
|
||||
if conversation.mode != CreationConversation.Mode.VIDEO:
|
||||
return False
|
||||
if is_plot_twist_conversation(conversation):
|
||||
return False
|
||||
match = _EXPLICIT_VIDEO_DURATION_RE.search(str(user_text or ""))
|
||||
if match is None:
|
||||
return False
|
||||
@@ -3057,6 +3149,8 @@ def apply_session_params(conversation, fields, answers: dict) -> bool:
|
||||
stored = _PARAM_TO_STORED.get(str(key), str(key))
|
||||
if stored not in {"model", "ratio", "resolution", "duration", "count"}:
|
||||
continue
|
||||
if stored == "duration" and is_plot_twist_conversation(conversation):
|
||||
continue
|
||||
value = "、".join(raw) if isinstance(raw, list) else str(raw or "").strip()
|
||||
if not value or current.get(stored) == value:
|
||||
continue
|
||||
@@ -3094,12 +3188,12 @@ def snapshot_session_params(conversation) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def confirm_param_options(is_video: bool) -> dict:
|
||||
def confirm_param_options(is_video: bool, *, fixed_duration: bool = False) -> dict:
|
||||
return {
|
||||
"model": VIDEO_MODELS if is_video else IMAGE_MODELS,
|
||||
"resolution": RESOLUTIONS if is_video else [],
|
||||
"ratio": RATIOS,
|
||||
"duration": VIDEO_DURATIONS if is_video else [],
|
||||
"duration": VIDEO_DURATIONS if is_video and not fixed_duration else [],
|
||||
"count": IMAGE_COUNTS if not is_video else [],
|
||||
}
|
||||
|
||||
@@ -3119,7 +3213,11 @@ def apply_confirm_params(conversation, incoming: dict | None) -> tuple[dict, boo
|
||||
old_duration = str(current.get("duration") or "")
|
||||
old_model = str(current.get("model") or "")
|
||||
changed = False
|
||||
for key, raw in (incoming or {}).items():
|
||||
confirmed = dict(incoming or {})
|
||||
if conversation.mode == CreationConversation.Mode.VIDEO and is_plot_twist_conversation(conversation):
|
||||
# 确认卡不开放时长;旧客户端和旧会话也必须回到固定的 60 秒。
|
||||
confirmed["duration"] = "60 秒"
|
||||
for key, raw in confirmed.items():
|
||||
if key not in {"model", "ratio", "resolution", "duration", "count"}:
|
||||
continue
|
||||
value = str(raw or "").strip()
|
||||
@@ -3248,7 +3346,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
|
||||
"function": {
|
||||
"name": "present_story_directions",
|
||||
"description": (
|
||||
"剧情反转带货预设默认按 60 秒完整短剧展开,先调用此工具展示 3 个可点击的剧情方向。"
|
||||
"短剧带货预设默认按 60 秒完整短剧展开,先调用此工具展示 3 个可点击的剧情方向。"
|
||||
"每条都必须有不同的冲突、商品承担的实际作用、反转和情绪;不能只说‘我准备了三个方向’。"
|
||||
"用户点击其一后才可 write_strategy;本工具调用后必须停下来等待选择。"
|
||||
),
|
||||
@@ -3319,7 +3417,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
|
||||
"声音/灯光/场景/参考素材锁定、可执行镜头和一致性收束,不要只写大纲。"
|
||||
)
|
||||
+ (
|
||||
f"剧情反转带货须先写【完整故事线】的{plot_beats},"
|
||||
f"短剧带货须先写【完整故事线】的{plot_beats},"
|
||||
f"再按至少{plot_stage_count}段连续时间轴写完整的一集;结尾解决开头的问题,不能只交中间一场戏。"
|
||||
if is_plot_twist_conversation(context.conversation) else ""
|
||||
)
|
||||
@@ -4055,7 +4153,10 @@ def submit_confirmed_video(*, conversation: CreationConversation, user, confirm_
|
||||
# 先落生成中消息,剩余片段会在槽位释放后由分批调度器继续提交。
|
||||
|
||||
if len(segments) == 1:
|
||||
message_payload = {"task_id": str(tasks[0].id), "kind": "video", "prompt": prompt}
|
||||
message_payload = {
|
||||
"task_id": str(tasks[0].id), "kind": "video", "prompt": prompt,
|
||||
"ratio": submit["aspect_ratio"],
|
||||
}
|
||||
else:
|
||||
submitted_by_index = {
|
||||
int(segment["index"]): str(task.id)
|
||||
@@ -4066,6 +4167,7 @@ def submit_confirmed_video(*, conversation: CreationConversation, user, confirm_
|
||||
"task_ids": [str(task.id) for task in tasks],
|
||||
"kind": "video_segments",
|
||||
"prompt": prompt,
|
||||
"ratio": submit["aspect_ratio"],
|
||||
"total_duration": total_duration,
|
||||
"segments": [
|
||||
{**segment, **({"task_id": submitted_by_index[int(segment["index"])]} if int(segment["index"]) in submitted_by_index else {})}
|
||||
@@ -4352,6 +4454,11 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
|
||||
f"- 商家已确认核心卖点:【{selling_point}】。策略、方案、脚本和出片指令必须围绕它展开;"
|
||||
"只补充可从素材或正常使用中证明的支撑,不得替换或夸大。"
|
||||
)
|
||||
elif selling_mode == "inferred" and selling_point:
|
||||
lines.append(
|
||||
f"- 从用户原话归纳出的商品使用动作:【{selling_point}】。可作为创意切入点,"
|
||||
"但不是已核实的功效、规格或价格;不得放大成未经证实的商品承诺。"
|
||||
)
|
||||
elif selling_mode == "auto":
|
||||
lines.append(
|
||||
"- 商家已授权系统推荐卖点。你必须从商品资料、可见素材和正常使用动作中选择一个最易证明的核心卖点;"
|
||||
@@ -4360,6 +4467,11 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
|
||||
product_note = str(memory.get("product_brief_note") or "").strip()
|
||||
if product_note:
|
||||
lines.append(f"- 用户在商品信息核对卡补充的真实事实:【{product_note}】。后续架构与 Prompt 必须沿用。")
|
||||
brief_context = collect_product_brief_context(conversation)
|
||||
if brief_context.get("target_audience"):
|
||||
lines.append(f"- 用户指定的目标人群:【{brief_context['target_audience']}】。视频架构和 Prompt 必须体现。")
|
||||
if brief_context.get("usage_scenario"):
|
||||
lines.append(f"- 用户指定的使用/拍摄场景:【{brief_context['usage_scenario']}】。视频架构和 Prompt 必须体现。")
|
||||
lines.append(
|
||||
"- 品牌、商品名、价格、优惠、规格、功效属于事实:缺失时只能询问或标待补充,"
|
||||
"禁止用三个创意选项让用户从虚构事实里选择。创意方向可以给 3 个基于已知商品的建议并允许自定义。"
|
||||
@@ -4411,7 +4523,7 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
|
||||
if conversation.preset:
|
||||
# 只给名字模型只能靠猜;把这个预设的拍法约束一起给它
|
||||
guidance = preset_guidance(conversation.preset)
|
||||
lines.append(f"\n【创作预设】{conversation.preset}")
|
||||
lines.append(f"\n【创作预设】{canonical_preset_name(conversation.preset)}")
|
||||
if context.is_video:
|
||||
lines.append(
|
||||
"预设必须贯穿本次对话:据此判断该补问哪些必要事实和素材、给什么创意选择、"
|
||||
@@ -4423,7 +4535,7 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
|
||||
if is_plot_twist_conversation(conversation):
|
||||
depth = active_plot_twist_story_depth(conversation)
|
||||
if not depth:
|
||||
lines.append("剧情反转带货默认 60 秒完整短剧;不要追问开场时长,先按 60 秒核对商品,再展示剧情方向。")
|
||||
lines.append("短剧带货默认 60 秒完整短剧;不要追问开场时长,先按 60 秒核对商品,再展示剧情方向。")
|
||||
else:
|
||||
lines.append(plot_twist_story_contract(depth))
|
||||
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
|
||||
@@ -5112,6 +5224,7 @@ def iter_creation_agent_events(
|
||||
yield {"type": "done"}
|
||||
return
|
||||
|
||||
accept_complete_product_brief(conversation)
|
||||
if product_brief_needs_review(conversation):
|
||||
question = append_product_brief_review(conversation)
|
||||
set_video_gate_stage(conversation, "clarify")
|
||||
@@ -5917,7 +6030,7 @@ def _dispatch_tool(
|
||||
|
||||
if name == "present_story_directions":
|
||||
if not is_plot_twist_conversation(context.conversation):
|
||||
return {"payload": {"error": "当前会话不是剧情反转带货预设"}}, False
|
||||
return {"payload": {"error": "当前会话不是短剧带货预设"}}, False
|
||||
directions = _coerce_plot_twist_directions(args.get("directions"))
|
||||
if not directions:
|
||||
return {
|
||||
@@ -5955,7 +6068,7 @@ def _dispatch_tool(
|
||||
return {
|
||||
"payload": {
|
||||
"error": (
|
||||
"剧情反转带货必须先选定剧情方向:调用 present_story_directions 展示 3 张方向卡,"
|
||||
"短剧带货必须先选定剧情方向:调用 present_story_directions 展示 3 张方向卡,"
|
||||
"等用户选择后再写策略。不得自行编造未选中的故事桥段。"
|
||||
)
|
||||
}
|
||||
@@ -6061,7 +6174,7 @@ def _dispatch_tool(
|
||||
else:
|
||||
return {
|
||||
"payload": {
|
||||
"error": "剧情反转带货必须先选定剧情方向,再写方案。请先 present_story_directions 让用户选择。"
|
||||
"error": "短剧带货必须先选定剧情方向,再写方案。请先 present_story_directions 让用户选择。"
|
||||
}
|
||||
}, False
|
||||
if not card["usp"] or not card["points"]:
|
||||
|
||||
@@ -11,6 +11,20 @@ from __future__ import annotations
|
||||
import re
|
||||
|
||||
CLICK_SWAP_PRESETS = frozenset({"点击换款", "点触换款", "多色商品换款"})
|
||||
PLOT_TWIST_PRESET = "短剧带货"
|
||||
LEGACY_PLOT_TWIST_PRESET = "剧情反转带货"
|
||||
|
||||
|
||||
def is_plot_twist_preset(name: str | None) -> bool:
|
||||
"""新旧预设名称执行同一套短剧流程,兼容已保存的历史会话。"""
|
||||
return str(name or "").strip() in {PLOT_TWIST_PRESET, LEGACY_PLOT_TWIST_PRESET}
|
||||
|
||||
|
||||
def canonical_preset_name(name: str | None) -> str:
|
||||
raw = str(name or "").strip()
|
||||
return PLOT_TWIST_PRESET if raw == LEGACY_PLOT_TWIST_PRESET else raw
|
||||
|
||||
|
||||
_CLICK_SWAP_FINGER_GUIDANCE = (
|
||||
"形态:只出手指出镜。固定机位的商品点击换款短片。必须先确认要展示的颜色/款式/SKU 及顺序,"
|
||||
"画面只围绕同一件商品的逐款切换:机位、背景与灯光稳定,商品构图稳定。"
|
||||
@@ -39,8 +53,8 @@ VIDEO_PRESETS: dict[str, str] = {
|
||||
"没有明确的防漏/承重/容量事实时,不用装水、尖锐物或超载来证明性能。"
|
||||
"用户没给关键功能或使用边界时,只问一次缺失事实;其余镜头用可观察的正常动作补齐。"
|
||||
),
|
||||
"剧情反转带货": (
|
||||
"剧情反转带货不是给商品套一个故事壳。商品必须作为解决困境、解除误会、证明事实、完成翻盘或回收伏笔的关键物,"
|
||||
PLOT_TWIST_PRESET: (
|
||||
"短剧带货不是给商品套一个故事壳。商品必须作为解决困境、解除误会、证明事实、完成翻盘或回收伏笔的关键物,"
|
||||
"并在最终 Prompt、镜头、动作、对白和反转结果中反复可见地承担这个作用。"
|
||||
"不得在故事结束后突然停下来介绍商品,也不能只在最后两秒放商品图。"
|
||||
"开场默认按 60 秒完整短剧结构写人物关系、持续冲突、商品介入时机、反转和情绪落点,不追问 15/30 秒故事深度;"
|
||||
@@ -100,9 +114,6 @@ VIDEO_PRESETS: dict[str, str] = {
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
PLOT_TWIST_PRESET = "剧情反转带货"
|
||||
|
||||
# 保留旧会话 15/30 秒的稳定标识以便回放;新会话不再展示故事深度选择卡。
|
||||
PLOT_TWIST_STORY_DEPTH_OPTIONS = (
|
||||
{
|
||||
@@ -164,7 +175,7 @@ def plot_twist_story_contract(value: str) -> str:
|
||||
depth = _PLOT_TWIST_DEPTH_BY_VALUE["60s"]
|
||||
if depth["value"] == "15s":
|
||||
return (
|
||||
"【剧情反转带货·15秒快节奏反转·强制执行】虽短也要有开端、冲突、反转和明确结局,不能只截取一场戏;"
|
||||
"【短剧带货·15秒快节奏反转·强制执行】虽短也要有开端、冲突、反转和明确结局,不能只截取一场戏;"
|
||||
"只保留一条主冲突,主角 1 人、最多 1 名辅助人物。"
|
||||
"0-3 秒强冲突或意外,3-6 秒问题升级,6-11 秒商品以正常使用动作介入并证明一个核心卖点,"
|
||||
"11-15 秒完成结果反转与自然行动引导。商品必须在中段前出现;对白短、直接、有记忆点;"
|
||||
@@ -172,7 +183,7 @@ def plot_twist_story_contract(value: str) -> str:
|
||||
)
|
||||
if depth["value"] == "30s":
|
||||
return (
|
||||
"【剧情反转带货·30秒轻剧情带货·强制执行】写完人物目标从开端、冲突、反转到结局的因果线,"
|
||||
"【短剧带货·30秒轻剧情带货·强制执行】写完人物目标从开端、冲突、反转到结局的因果线,"
|
||||
"不可只写故事中段;必须交代人物处境,可设置两人互动。"
|
||||
"0-4 秒抛出结果预告或冲突钩子,4-10 秒建立处境,10-17 秒让矛盾升级或一次错误尝试,"
|
||||
"17-24 秒商品在转折点以完整正常使用过程介入,24-28 秒回收反转与人物反应,28-30 秒自然收束。"
|
||||
@@ -180,7 +191,7 @@ def plot_twist_story_contract(value: str) -> str:
|
||||
)
|
||||
if depth["value"] == "60s":
|
||||
return (
|
||||
"【剧情反转带货·60秒完整短剧带货·强制执行】这是一集有开端和结局的独立短剧,不是一部长剧中截取的一段。"
|
||||
"【短剧带货·60秒完整短剧带货·强制执行】这是一集有开端和结局的独立短剧,不是一部长剧中截取的一段。"
|
||||
"先写【完整故事线】,依次写清开端、冲突、升级、反转、结局;每一段都有前因后果,人物目标和关系最终有结果。"
|
||||
"0-5 秒可以用结果闪前作钩子,但随后必须回到故事起因;5-15 秒交代关系、目标与处境;"
|
||||
"15-28 秒发生具体阻碍并升级;28-38 秒主角尝试、失败或作出选择;"
|
||||
@@ -191,7 +202,7 @@ def plot_twist_story_contract(value: str) -> str:
|
||||
)
|
||||
if depth["value"] == "180s":
|
||||
return (
|
||||
"【剧情反转带货·180秒三幕完整故事·强制执行】全片按三个连续 60 秒章节推进,不能把三条短片简单拼接。"
|
||||
"【短剧带货·180秒三幕完整故事·强制执行】全片按三个连续 60 秒章节推进,不能把三条短片简单拼接。"
|
||||
"第一章 0-60 秒:0-8 秒以结果预告或关系冲突抓人,8-28 秒建立人物目标与现实阻力,"
|
||||
"28-48 秒埋入商品/SKU 与一个可见卖点证据,48-60 秒用第一次选择或失败把行动推入下一章。"
|
||||
"第二章 60-120 秒:承接上一章未完成动作,扩大矛盾并安排一次错误尝试;商品通过正常使用过程提供新的证据,"
|
||||
@@ -207,7 +218,7 @@ def plot_twist_story_contract(value: str) -> str:
|
||||
def apply_plot_twist_story_contract(name: str, story_depth: str, prompt: str) -> str:
|
||||
"""在最终出片指令里确定性追加所选故事深度,避免模型漏写关键结构。"""
|
||||
base = str(prompt or "").strip()
|
||||
if (name or "").strip() != PLOT_TWIST_PRESET or not base:
|
||||
if not is_plot_twist_preset(name) or not base:
|
||||
return base
|
||||
contract = plot_twist_story_contract(story_depth)
|
||||
if not contract or contract in base:
|
||||
@@ -277,7 +288,7 @@ def apply_plot_twist_direction_contract(prompt: str, *, title: str = "", conflic
|
||||
VIDEO_PRESET_WORKFLOWS: dict[str, str] = {
|
||||
"痛点解决演示": "先确认商品真实解决的具体问题与正常用法;生成前核对痛点、过程和结果都有可见证据。",
|
||||
"真实使用演示": "优先核对商品真实用途、关键步骤和可证实卖点;不为氛围而增加不合理测试。",
|
||||
"剧情反转带货": "默认按 60 秒完整短剧带货创作,不在开头追问或展示 15/30 秒故事深度选择;核对商品信息后给三个完整剧情方向。商品必须成为解决问题、解除误会、证明事实、回收伏笔或完成翻盘的关键。",
|
||||
PLOT_TWIST_PRESET: "默认按 60 秒完整短剧带货创作,不在开头追问或展示 15/30 秒故事深度选择;核对商品信息后给三个完整剧情方向。商品必须成为解决问题、解除误会、证明事实、回收伏笔或完成翻盘的关键。",
|
||||
"商品拟人广告": "先确认商品外观和性格表达方式;默认无脸拟人,商品保持真实完整,台词用画外声。",
|
||||
"达人口播种草": "优先确认人物、多人出镜关系、真实体验和主卖点;生成前核对口播字数能在时长内说完,每个卖点都有画面证明。",
|
||||
"商品图一键成片": "优先从商品参考图锁定外观;自动补场景和动作,但不替换或改变用户商品图里的结构、颜色和包装。",
|
||||
@@ -304,8 +315,8 @@ VIDEO_PRESET_DELIVERY_CONTRACTS: dict[str, str] = {
|
||||
"【预设执行层·真实使用演示】像一镜到底可复核的日常实拍:先交代真实场景,再按正确用途完成步骤,"
|
||||
"用近景展示材质、动作或结果。商品全程保持正常完整;不以破损、漏液、渗水、超载或超出用途的测试制造戏剧性。"
|
||||
),
|
||||
"剧情反转带货": (
|
||||
"【预设执行层·剧情反转带货】按同一条完整故事线连续推进开端、冲突、升级、反转和结局;"
|
||||
PLOT_TWIST_PRESET: (
|
||||
"【预设执行层·短剧带货】按同一条完整故事线连续推进开端、冲突、升级、反转和结局;"
|
||||
"人物关系、目标与情绪在每个关键镜头都有因果变化,结尾回收开头问题而非停在故事中段。"
|
||||
"商品不是摆拍道具:必须充当解决问题的工具、解除误会的证据、关系变化的礼物或回收伏笔的关键物;"
|
||||
"商品出现、使用和结果必须直接改变剧情走向,禁止剧情结束后再硬插卖点。"
|
||||
@@ -411,12 +422,12 @@ ALL_PRESETS: dict[str, str] = {**VIDEO_PRESETS, **IMAGE_PRESETS}
|
||||
def preset_guidance(name: str) -> str:
|
||||
"""预设名 → 拍法约束。认不出的名字返回 "" —— 前端加了新卡但这里还没写时,
|
||||
退回「只有名字」的行为,不要报错。"""
|
||||
return ALL_PRESETS.get((name or "").strip(), "")
|
||||
return ALL_PRESETS.get(canonical_preset_name(name), "")
|
||||
|
||||
|
||||
def preset_workflow_guidance(name: str) -> str:
|
||||
"""预设名 → Agent 的素材、追问和检查重点。"""
|
||||
return VIDEO_PRESET_WORKFLOWS.get((name or "").strip(), "")
|
||||
return VIDEO_PRESET_WORKFLOWS.get(canonical_preset_name(name), "")
|
||||
|
||||
|
||||
_CLICK_SWAP_FINGER_CONTRACT = (
|
||||
@@ -439,7 +450,7 @@ _CLICK_SWAP_CHARACTER_CONTRACT = (
|
||||
|
||||
def video_preset_delivery_contract(name: str, *, click_swap_mode: str = "") -> str:
|
||||
"""预设名 → 最终视频生成阶段必须执行的结构与镜头约束。"""
|
||||
preset = (name or "").strip()
|
||||
preset = canonical_preset_name(name)
|
||||
if preset in CLICK_SWAP_PRESETS:
|
||||
if (click_swap_mode or "").strip() == "character":
|
||||
return _CLICK_SWAP_CHARACTER_CONTRACT
|
||||
@@ -546,13 +557,13 @@ def scrub_click_swap_same_position_wording(text: str) -> str:
|
||||
def apply_video_preset_prompt(name: str, prompt: str, *, click_swap_mode: str = "") -> str:
|
||||
"""把视频预设确定性并入最终出片指令,避免只依赖对话模型主动复述。"""
|
||||
base = str(prompt or "").strip()
|
||||
preset = (name or "").strip()
|
||||
preset = canonical_preset_name(name)
|
||||
mode = (click_swap_mode or "").strip()
|
||||
contract = video_preset_delivery_contract(preset, click_swap_mode=mode)
|
||||
if not base or not contract:
|
||||
return base
|
||||
marker = f"【视频预设】{preset}"
|
||||
if marker in base:
|
||||
if marker in base or (is_plot_twist_preset(name) and f"【视频预设】{LEGACY_PLOT_TWIST_PRESET}" in base):
|
||||
return base
|
||||
if is_click_swap_preset(preset):
|
||||
if mode == "character":
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from .creation_presets import PLOT_TWIST_PRESET
|
||||
from .creation_presets import is_plot_twist_preset
|
||||
from .models import (
|
||||
AITask,
|
||||
CreationConversation,
|
||||
@@ -248,18 +248,20 @@ class CreationConversationSerializer(serializers.ModelSerializer):
|
||||
return progress
|
||||
|
||||
def create(self, validated_data):
|
||||
# 剧情反转带货的新会话直接以完整短剧的 60 秒开场,不再弹故事深度选择。
|
||||
# 旧客户端仍传「智能时长」时也要在服务端落成确定参数。
|
||||
if validated_data.get("mode") == CreationConversation.Mode.VIDEO and validated_data.get("preset") == PLOT_TWIST_PRESET:
|
||||
# 短剧带货现阶段固定为完整的 60 秒短剧,旧客户端传任何时长也不能覆盖。
|
||||
if validated_data.get("mode") == CreationConversation.Mode.VIDEO and is_plot_twist_preset(validated_data.get("preset")):
|
||||
params = dict(validated_data.get("params") or {})
|
||||
if not str(params.get("duration") or "").strip() or str(params.get("duration")).strip() == "智能时长":
|
||||
params["duration"] = "60 秒"
|
||||
params["duration"] = "60 秒"
|
||||
validated_data["params"] = params
|
||||
return super().create(validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
# mode 定死:允许传但忽略,避免前端误改后顶栏参数与已生成内容对不上
|
||||
validated_data.pop("mode", None)
|
||||
if instance.mode == CreationConversation.Mode.VIDEO and is_plot_twist_preset(validated_data.get("preset", instance.preset)):
|
||||
params = dict(validated_data.get("params", instance.params) or {})
|
||||
params["duration"] = "60 秒"
|
||||
validated_data["params"] = params
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
工具调用拼装、追问中断、计费闸门、参考图带入、错误不打崩流。
|
||||
"""
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import requests
|
||||
@@ -30,6 +31,9 @@ from .creation_agent import (
|
||||
apply_pain_point_direction,
|
||||
ensure_selling_point_ready_from_context,
|
||||
extract_declared_selling_point,
|
||||
extract_brief_context,
|
||||
product_brief_items,
|
||||
accept_complete_product_brief,
|
||||
apply_restart_intent,
|
||||
apply_person_identity_guard,
|
||||
apply_product_reference_guard,
|
||||
@@ -60,6 +64,7 @@ from .creation_agent import (
|
||||
resolve_smart_video_duration,
|
||||
is_continue_intent,
|
||||
is_greeting,
|
||||
is_plot_twist_conversation,
|
||||
is_pure_chitchat,
|
||||
is_restart_intent,
|
||||
session_has_creative_context,
|
||||
@@ -623,6 +628,17 @@ class PersonReferenceCompletionTests(CreationAgentBaseTests):
|
||||
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"},
|
||||
)
|
||||
# worker 与页面轮询都拿着旧的 GENERATING 对象回填时,只能出现一次确认。
|
||||
finish_generating_message(
|
||||
generating,
|
||||
assets=[{
|
||||
@@ -646,6 +662,9 @@ class PersonReferenceCompletionTests(CreationAgentBaseTests):
|
||||
self.assertTrue(
|
||||
self.conversation.messages.filter(text__contains="是否使用这个角色").exists()
|
||||
)
|
||||
self.assertEqual(
|
||||
self.conversation.messages.filter(text__contains="是否使用这个角色").count(), 1
|
||||
)
|
||||
|
||||
def test_platform_person_submission_uses_model_mode(self):
|
||||
task = AITask.objects.create(
|
||||
@@ -1831,6 +1850,67 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
self.assertEqual(conversation.params["duration"], "60 秒")
|
||||
self.assertEqual(conversation.params["ratio"], "9:16")
|
||||
|
||||
def test_short_drama_new_name_and_legacy_name_share_the_same_workflow(self):
|
||||
from apps.ai.creation_presets import (
|
||||
PLOT_TWIST_PRESET,
|
||||
apply_plot_twist_story_contract,
|
||||
apply_video_preset_prompt,
|
||||
preset_guidance,
|
||||
preset_workflow_guidance,
|
||||
video_preset_delivery_contract,
|
||||
)
|
||||
from apps.ai.serializers import CreationConversationSerializer
|
||||
|
||||
self.assertEqual(PLOT_TWIST_PRESET, "短剧带货")
|
||||
for name in ("短剧带货", "剧情反转带货"):
|
||||
self.conversation.preset = name
|
||||
self.assertTrue(is_plot_twist_conversation(self.conversation))
|
||||
self.assertEqual(preset_guidance(name), preset_guidance("短剧带货"))
|
||||
self.assertEqual(preset_workflow_guidance(name), preset_workflow_guidance("短剧带货"))
|
||||
self.assertEqual(video_preset_delivery_contract(name), video_preset_delivery_contract("短剧带货"))
|
||||
self.assertIn("【短剧带货·60秒", apply_plot_twist_story_contract(name, "60s", "完整短剧提示词"))
|
||||
self.assertIn("【视频预设】短剧带货", apply_video_preset_prompt(name, "完整短剧提示词"))
|
||||
|
||||
serializer = CreationConversationSerializer(data={
|
||||
"title": "短剧带货",
|
||||
"mode": "video",
|
||||
"preset": "短剧带货",
|
||||
"params": {"ratio": "9:16"},
|
||||
})
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
created = serializer.save(team=self.team, created_by=self.user)
|
||||
self.assertEqual(created.params["duration"], "60 秒")
|
||||
|
||||
# 即使旧客户端在创建/修改会话时仍传其他时长,短剧也保持 60 秒。
|
||||
serializer = CreationConversationSerializer(data={
|
||||
"title": "短剧带货", "mode": "video", "preset": "短剧带货",
|
||||
"params": {"duration": "15 秒", "ratio": "9:16"},
|
||||
})
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
fixed = serializer.save(team=self.team, created_by=self.user)
|
||||
self.assertEqual(fixed.params["duration"], "60 秒")
|
||||
serializer = CreationConversationSerializer(fixed, data={"params": {"duration": "30 秒"}}, partial=True)
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
self.assertEqual(serializer.save().params["duration"], "60 秒")
|
||||
|
||||
def test_short_drama_confirm_duration_is_fixed_and_legacy_session_rebuilds(self):
|
||||
from .creation_agent import apply_confirm_params, confirm_param_options
|
||||
|
||||
self.conversation.preset = "短剧带货"
|
||||
self.conversation.params = {**self.conversation.params, "duration": "60 秒"}
|
||||
self.conversation.save(update_fields=["preset", "params", "updated_at"])
|
||||
params, rebuild = apply_confirm_params(self.conversation, {"duration": "15 秒", "model": "Seedance 2.5"})
|
||||
self.assertEqual(params["duration"], "60 秒")
|
||||
self.assertFalse(rebuild)
|
||||
self.assertEqual(confirm_param_options(True, fixed_duration=True)["duration"], [])
|
||||
self.assertEqual([segment["duration"] for segment in plan_video_segments(60)], [30, 30])
|
||||
|
||||
self.conversation.params = {**self.conversation.params, "duration": "30 秒"}
|
||||
self.conversation.save(update_fields=["params", "updated_at"])
|
||||
params, rebuild = apply_confirm_params(self.conversation, {"duration": "30 秒"})
|
||||
self.assertEqual(params["duration"], "60 秒")
|
||||
self.assertTrue(rebuild)
|
||||
|
||||
def test_plot_twist_legacy_smart_choice_uses_60_second_story_contract(self):
|
||||
from apps.ai.creation_presets import plot_twist_story_contract
|
||||
|
||||
@@ -1933,6 +2013,117 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
)
|
||||
self.assertEqual(fake.calls, [])
|
||||
|
||||
def test_natural_product_brief_uses_stated_audience_scene_and_action(self):
|
||||
self._pin_product("空气炸锅")
|
||||
self.conversation.preset = "短剧带货"
|
||||
self.conversation.memory = {}
|
||||
self.conversation.save(update_fields=["preset", "memory", "updated_at"])
|
||||
brief = (
|
||||
"使用此商品制作一条短剧带货视频,商品的适用人群是懒得下厨做饭的人以及来不及做饭的人,"
|
||||
"将食材放进空气炸锅一炸就可以吃了。使用环境是家庭环境或公司午休环境。"
|
||||
)
|
||||
append_message(self.conversation, role="user", text=brief)
|
||||
self.assertTrue(ensure_selling_point_ready_from_context(self.conversation, brief))
|
||||
items = {item["label"]: item for item in product_brief_items(self.conversation)}
|
||||
self.assertEqual(items["核心卖点"]["status"], "ready")
|
||||
self.assertIn("将食材放进空气炸锅", items["核心卖点"]["value"])
|
||||
self.assertEqual(items["使用场景与目标用户"]["status"], "ready")
|
||||
self.assertIn("懒得下厨做饭的人", items["使用场景与目标用户"]["value"])
|
||||
self.assertIn("公司午休环境", items["使用场景与目标用户"]["value"])
|
||||
self.assertEqual(items["价格与优惠"]["status"], "not_needed")
|
||||
self.assertTrue(accept_complete_product_brief(self.conversation))
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertTrue(self.conversation.memory["product_brief_reviewed"])
|
||||
system = build_system_prompt(AgentContext(conversation=self.conversation, user=self.user, model_config=self.model))
|
||||
self.assertIn("懒得下厨做饭的人", system)
|
||||
self.assertIn("公司午休环境", system)
|
||||
|
||||
def test_product_brief_does_not_invent_missing_claims(self):
|
||||
self._pin_product("空气炸锅")
|
||||
self.conversation.preset = "短剧带货"
|
||||
self.conversation.memory = {}
|
||||
self.conversation.save(update_fields=["preset", "memory", "updated_at"])
|
||||
append_message(self.conversation, role="user", text="面向人群是上班族。价格是多少?做个短剧。")
|
||||
self.assertFalse(ensure_selling_point_ready_from_context(self.conversation))
|
||||
items = {item["label"]: item for item in product_brief_items(self.conversation)}
|
||||
self.assertEqual(items["核心卖点"]["status"], "missing")
|
||||
self.assertEqual(items["使用场景与目标用户"]["status"], "missing")
|
||||
self.assertIn("上班族", items["使用场景与目标用户"]["value"])
|
||||
self.assertEqual(items["价格与优惠"]["status"], "missing")
|
||||
self.assertFalse(accept_complete_product_brief(self.conversation))
|
||||
self.assertEqual(extract_brief_context("重点讲清使用场景和目标用户"), {})
|
||||
|
||||
def test_complete_natural_brief_skips_redundant_review_in_stream(self):
|
||||
self._pin_product("空气炸锅")
|
||||
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,
|
||||
))
|
||||
interactions = [
|
||||
event["message"]["payload"].get("interaction")
|
||||
for event in events
|
||||
if event.get("type") == "message" and event["message"]["kind"] == "elicit"
|
||||
]
|
||||
self.assertNotIn("product_brief_review", interactions)
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertTrue(self.conversation.memory.get("product_brief_reviewed"))
|
||||
|
||||
@override_settings(CREATION_AGENT_INLINE=False, CREATION_AGENT_TASK_QUEUE="airshelf.local")
|
||||
def test_review_card_note_is_locked_as_selling_point_before_story_choice(self):
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role="owner")
|
||||
self._pin_product("空气炸锅")
|
||||
self.conversation.preset = "短剧带货"
|
||||
self.conversation.memory = {}
|
||||
self.conversation.save(update_fields=["preset", "memory", "updated_at"])
|
||||
review = append_message(
|
||||
self.conversation, role="assistant", kind=CreationMessage.Kind.ELICIT,
|
||||
payload={"interaction": "product_brief_review", "submitted": False, "answers": {}},
|
||||
)
|
||||
note = (
|
||||
"商品的适用人群是懒得下厨做饭的人以及来不及做饭的人,"
|
||||
"将食材放进空气炸锅一炸就可以吃了。使用环境是家庭环境或公司午休环境。"
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_authenticate(self.user)
|
||||
with patch("apps.ai.views.begin_agent_planning", return_value=True), \
|
||||
patch("apps.ai.views.team_agent_busy", return_value=False), \
|
||||
patch("apps.ai.views.run_creation_agent_turn_task") as task:
|
||||
response = client.post(
|
||||
f"/api/ai/creations/{self.conversation.id}/send/",
|
||||
{"kind": "elicit_answer", "reply_to": str(review.id), "answers": {
|
||||
"review_action": "supplement", "product_brief_note": note,
|
||||
}},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 202)
|
||||
self.assertTrue(task.apply_async.called)
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertTrue(self.conversation.memory.get("selling_point_ready"))
|
||||
self.assertIn("将食材放进空气炸锅", self.conversation.memory.get("selling_point", ""))
|
||||
from .views import _plot_twist_direction_continuation
|
||||
_plot_twist_direction_continuation(
|
||||
self.conversation,
|
||||
{"directions": [{"id": "office", "title": "午休误会", "conflict": "同事误会", "product_role": "空气炸锅做饭", "reversal": "误会解除"}]},
|
||||
"office",
|
||||
)
|
||||
result, _ = _dispatch_tool(
|
||||
AgentContext(conversation=self.conversation, user=self.user, model_config=self.model),
|
||||
"write_strategy",
|
||||
{"target": "上班族", "trust": "用户描述的使用动作", "belief": "操作方便", "direction": "办公室轻喜剧"},
|
||||
)
|
||||
self.assertNotEqual(result.get("payload", {}).get("field"), "selling_point")
|
||||
self.assertFalse(self.conversation.messages.filter(
|
||||
kind=CreationMessage.Kind.ELICIT, payload__interaction="selling_point_gate"
|
||||
).exists())
|
||||
|
||||
def test_plot_twist_depth_syncs_duration_and_is_written_into_final_prompt(self):
|
||||
self.conversation.preset = "剧情反转带货"
|
||||
self.conversation.save(update_fields=["preset", "updated_at"])
|
||||
@@ -2889,7 +3080,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
portrait_asset=portrait,
|
||||
)
|
||||
self.conversation.preset = "达人口播种草"
|
||||
self.conversation.params = {**self.conversation.params, "duration": "60 秒"}
|
||||
self.conversation.params = {**self.conversation.params, "duration": "60 秒", "ratio": "16:9"}
|
||||
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(
|
||||
@@ -2922,6 +3113,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
|
||||
self.assertEqual(error, "")
|
||||
self.assertEqual(message.payload["kind"], "video_segments")
|
||||
self.assertEqual(message.payload["ratio"], "16:9")
|
||||
self.assertEqual(submit.call_count, 2)
|
||||
first = submit.call_args_list[0].kwargs["params"]
|
||||
second = submit.call_args_list[1].kwargs["params"]
|
||||
@@ -2929,6 +3121,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
self.assertEqual(first["seed"], second["seed"])
|
||||
self.assertEqual(first["references"][0]["asset_id"], str(portrait.id))
|
||||
for params in (first, second):
|
||||
self.assertEqual(params["aspect_ratio"], "16:9")
|
||||
self.assertTrue(params["prompt"].startswith("【画面洁净 · 最高优先级】"))
|
||||
self.assertIn("【开场】这是全片的第一段", params["prompt"])
|
||||
self.assertNotIn("限时8折", params["prompt"])
|
||||
@@ -3141,6 +3334,16 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
|
||||
|
||||
class VideoParamParsingTests(TestCase):
|
||||
def test_video_result_ratio_reads_aspect_ratio_from_task(self):
|
||||
from .creation import _meta_from_task
|
||||
|
||||
task = SimpleNamespace(
|
||||
model_config_id=None,
|
||||
request_payload={"aspect_ratio": "16:9", "prompt": "横屏视频"},
|
||||
)
|
||||
message = SimpleNamespace(payload={"ratio": "9:16"})
|
||||
self.assertEqual(_meta_from_task(task, message)["ratio"], "16:9")
|
||||
|
||||
def test_duration_label_and_smart_fallback(self):
|
||||
self.assertEqual(video_duration({"duration": "15 秒"}), 15)
|
||||
self.assertEqual(video_duration({"duration": "智能时长"}), SMART_DURATION)
|
||||
|
||||
@@ -57,6 +57,7 @@ from .creation_agent import (
|
||||
is_restart_intent,
|
||||
multi_character_relation_needs_clarification,
|
||||
restore_gated_step_after_cancel,
|
||||
record_product_brief_review,
|
||||
get_video_gate_stage,
|
||||
set_video_gate_stage,
|
||||
sync_prompt_after_cast,
|
||||
@@ -2087,12 +2088,7 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
force_creative_turn = True
|
||||
continuation_instruction = _plot_twist_depth_continuation(depth)
|
||||
elif payload.get("interaction") == "product_brief_review":
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["product_brief_reviewed"] = True
|
||||
if text:
|
||||
memory["product_brief_note"] = text
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
record_product_brief_review(conversation, text)
|
||||
payload["answers"] = {
|
||||
"review_action": "supplement" if text else "continue",
|
||||
"product_brief_note": text,
|
||||
@@ -2715,12 +2711,7 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
note = str(answers.get("product_brief_note") or "").strip()
|
||||
if action == "supplement" and not note:
|
||||
return JsonResponse({"detail": "请填写要补充的真实商品信息"}, status=400)
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["product_brief_reviewed"] = True
|
||||
if note:
|
||||
memory["product_brief_note"] = note
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
record_product_brief_review(conversation, note)
|
||||
text = ""
|
||||
record_user_message = False
|
||||
force_creative_turn = True
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from django.test import SimpleTestCase, override_settings
|
||||
@@ -7,6 +8,24 @@ from apps.common import celery_health
|
||||
from apps.common.celery_health import require_worker_task, worker_consumes_queue
|
||||
|
||||
|
||||
class RedisLocalUrlTests(SimpleTestCase):
|
||||
def test_macos_maps_docker_only_host_to_local_redis_for_celery_and_django(self):
|
||||
from airshelf.settings import base
|
||||
|
||||
with patch.dict(os.environ, {"CELERY_BROKER_URL": "redis://:example@yingqing-redis:6379/1"}):
|
||||
with patch.object(base.sys, "platform", "darwin"):
|
||||
self.assertEqual(base.redis_url("CELERY_BROKER_URL", 1), "redis://127.0.0.1:6379/1")
|
||||
self.assertEqual(os.environ["CELERY_BROKER_URL"], "redis://127.0.0.1:6379/1")
|
||||
|
||||
def test_container_keeps_configured_redis_host(self):
|
||||
from airshelf.settings import base
|
||||
|
||||
configured = "redis://:example@yingqing-redis:6379/1"
|
||||
with patch.dict(os.environ, {"CELERY_BROKER_URL": configured}):
|
||||
with patch.object(base.sys, "platform", "linux"):
|
||||
self.assertEqual(base.redis_url("CELERY_BROKER_URL", 1), configured)
|
||||
|
||||
|
||||
class RequireWorkerTaskTests(SimpleTestCase):
|
||||
def setUp(self):
|
||||
celery_health._registered_task_cache.clear()
|
||||
|
||||
@@ -89,7 +89,7 @@ function canCreateSegmentedVideo(config: ModelConfig | undefined, model: string)
|
||||
// 31–60 秒总时长会拆成 ≤30 秒片段(长视频 >60 秒入口已临时关闭)。
|
||||
// 只有 Seedance 2.5(或目录里声明了 30 秒能力的模型)可选这些总时长。
|
||||
return modelDurations(config).some((seconds) => seconds >= 30)
|
||||
|| /seedance\s*2\.5/i.test(model || "");
|
||||
|| normalizeModelKey(model).includes("seedance25");
|
||||
}
|
||||
|
||||
function durationLabelsForModel(config: ModelConfig | undefined, model: string, isVideo: boolean): string[] {
|
||||
@@ -111,6 +111,7 @@ export function OmniParamBar({
|
||||
resolution,
|
||||
ratio,
|
||||
duration,
|
||||
fixedDuration = false,
|
||||
catalogModels,
|
||||
onModel,
|
||||
onResolution,
|
||||
@@ -123,6 +124,7 @@ export function OmniParamBar({
|
||||
resolution: string;
|
||||
ratio: string;
|
||||
duration: string;
|
||||
fixedDuration?: boolean;
|
||||
catalogModels?: ModelConfig[];
|
||||
onModel: (value: string) => void;
|
||||
onResolution: (value: string) => void;
|
||||
@@ -141,10 +143,11 @@ export function OmniParamBar({
|
||||
[catalogModels, model, capability],
|
||||
);
|
||||
|
||||
const models = withCurrent(
|
||||
catalogModelLabels(catalogModels, capability, isVideo ? OMNI_VIDEO_MODELS : OMNI_IMAGE_MODELS),
|
||||
model,
|
||||
);
|
||||
const availableModels = catalogModelLabels(catalogModels, capability, isVideo ? OMNI_VIDEO_MODELS : OMNI_IMAGE_MODELS);
|
||||
const compatibleModels = fixedDuration && isVideo
|
||||
? availableModels.filter((label) => canCreateSegmentedVideo(findCatalogModel(catalogModels, label, "video"), label))
|
||||
: [];
|
||||
const models = withCurrent(fixedDuration && isVideo ? compatibleModels : availableModels, model);
|
||||
const allowedRes = isVideo ? modelResolutions(selected) : [];
|
||||
const resolutions = withCurrent(allowedRes.length ? allowedRes : OMNI_RESOLUTIONS, resolution);
|
||||
const durations = withCurrent(durationLabelsForModel(selected, model, isVideo), duration);
|
||||
@@ -200,6 +203,7 @@ export function OmniParamBar({
|
||||
if (nextRes.length && resolution && !nextRes.includes(resolution)) {
|
||||
onResolution(nextRes.includes("720p") ? "720p" : nextRes[0]);
|
||||
}
|
||||
if (fixedDuration) return;
|
||||
const nextDur = durationLabelsForModel(selected, model, true);
|
||||
const seconds = Number(String(duration || "").replace(/\D/g, ""));
|
||||
// 已选 >60 秒的旧会话:收回长视频入口后压回 60 秒。
|
||||
@@ -215,6 +219,12 @@ export function OmniParamBar({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selected?.id, isVideo, model]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fixedDuration || !isVideo || !compatibleModels.length) return;
|
||||
if (!canCreateSegmentedVideo(selected, model)) onModel(compatibleModels[0]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fixedDuration, isVideo, model, selected?.id, compatibleModels.join("|")]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<label className="omni-parameter omni-parameter-model">
|
||||
@@ -250,7 +260,7 @@ export function OmniParamBar({
|
||||
options={toOptions(withCurrent(OMNI_RATIOS, ratio))}
|
||||
/>
|
||||
</label>
|
||||
<div className={`omni-duration-control${isVideo ? "" : " is-image-count"}`} ref={wrapRef}>
|
||||
{fixedDuration ? null : <div className={`omni-duration-control${isVideo ? "" : " is-image-count"}`} ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="omni-duration-trigger"
|
||||
@@ -321,7 +331,7 @@ export function OmniParamBar({
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</div>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1249,6 +1249,11 @@ body.sidebar-collapsed .user::after { display: none; }
|
||||
overflow: hidden;
|
||||
animation: none;
|
||||
}
|
||||
.content:has(.omni-session-page) {
|
||||
/* 对话页内部已按顶栏以下的视口高度排版;通用内容 padding 会让自动滚底遮住首条消息。 */
|
||||
padding: 0;
|
||||
animation: none;
|
||||
}
|
||||
@keyframes yz-page-enter {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/** 已保存的旧短剧会话保留原始预设值,界面统一显示新名称。 */
|
||||
export function displayOmniPresetLabel(preset?: string | null): string {
|
||||
if (preset === "剧情反转带货") return "短剧带货";
|
||||
return preset || "自由创作";
|
||||
}
|
||||
@@ -431,11 +431,40 @@
|
||||
}
|
||||
|
||||
/* 横向角色设定图需要留出三视图和两处脸部细节,预览时不得裁掉边缘。 */
|
||||
.omni-person-reference-flow > .omni-result-card.is-person-reference,
|
||||
.omni-person-reference-flow > .omni-result-card.is-person-reference {
|
||||
flex: 0 0 min(372px, 100%);
|
||||
width: min(372px, 100%);
|
||||
max-width: 372px;
|
||||
}
|
||||
|
||||
/* 角色定妆图生成时占满整条流程区域;完成后恢复为可并排的大图卡。 */
|
||||
.omni-result-card.omni-process-card.is-person-reference {
|
||||
width: min(760px, calc(100% - 44px));
|
||||
max-width: 760px;
|
||||
height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.omni-person-reference-flow > .omni-process-card.is-person-reference {
|
||||
flex-basis: 360px;
|
||||
width: min(360px, 100%);
|
||||
max-width: 360px;
|
||||
flex: 1 0 100%;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.omni-process-card.is-person-reference .omni-result-media {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.omni-process-card.is-person-reference .omni-result-tile {
|
||||
height: 100%;
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
|
||||
.omni-process-card.is-person-reference .omni-process-frame {
|
||||
min-height: 0;
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
|
||||
.omni-result-card.is-person-reference .omni-result-tile img {
|
||||
@@ -1535,6 +1564,11 @@
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.omni-result-card.omni-process-card.is-person-reference {
|
||||
width: 100%;
|
||||
height: clamp(280px, 58vw, 400px);
|
||||
}
|
||||
|
||||
.omni-strategy-grid,
|
||||
.omni-plan-points,
|
||||
.omni-plan-timeline,
|
||||
@@ -2572,27 +2606,47 @@
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* 发送中:不要文案;左侧柔和呼吸高亮,气泡本身不变形 */
|
||||
.omni-chat-row.is-user .omni-chat-stack.is-pending {
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.omni-chat-row.is-user .omni-chat-stack.is-pending::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
background: #5b6572;
|
||||
animation: omniSendPulse 1.35s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 发送状态只留在原消息气泡内,不额外占一行。 */
|
||||
.omni-chat-row.is-user .omni-chat-stack.is-pending .omni-chat-bubble {
|
||||
background: color-mix(in srgb, var(--surface) 84%, transparent);
|
||||
border-color: var(--black-alpha-16);
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background: var(--black-alpha-4);
|
||||
border-color: var(--black-alpha-12);
|
||||
animation: omniPendingBubble 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.omni-send-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.omni-send-dots i {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: .35;
|
||||
animation: omniSendDotPulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.omni-send-dots i:nth-child(2) { animation-delay: .18s; }
|
||||
.omni-send-dots i:nth-child(3) { animation-delay: .36s; }
|
||||
|
||||
@keyframes omniSendDotPulse {
|
||||
0%, 70%, 100% { opacity: .35; transform: translateY(0); }
|
||||
35% { opacity: 1; transform: translateY(-3px); }
|
||||
}
|
||||
|
||||
@keyframes omniPendingBubble {
|
||||
0%, 100% { border-color: var(--black-alpha-12); }
|
||||
50% { border-color: var(--black-alpha-24); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.omni-chat-row.is-user .omni-chat-stack.is-pending .omni-chat-bubble,
|
||||
.omni-send-dots i { animation: none; }
|
||||
}
|
||||
|
||||
.omni-send-spinner {
|
||||
@@ -2601,45 +2655,75 @@
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.omni-chat-bubble.is-thinking {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 10px;
|
||||
color: var(--black-alpha-64);
|
||||
}
|
||||
|
||||
.omni-chat-bubble.is-reasoning {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
color: var(--black-alpha-64);
|
||||
}
|
||||
|
||||
/* 规划过程不是一行 loading:给过程本身足够横向空间,历史在内部滚动。 */
|
||||
.omni-chat-row.agent.is-live .omni-chat-bubble.is-reasoning {
|
||||
.omni-chat-row.agent.is-live .omni-chat-bubble.omni-wait-card {
|
||||
position: relative;
|
||||
width: min(620px, calc(100% - 44px));
|
||||
max-width: min(620px, calc(100% - 44px));
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
padding: 18px 20px 16px;
|
||||
border-radius: var(--r-md);
|
||||
color: var(--black-alpha-64);
|
||||
}
|
||||
|
||||
.omni-wait-card::before,
|
||||
.omni-wait-card::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.omni-wait-card::before {
|
||||
width: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--border-muted) 0%,
|
||||
var(--border-muted) 35%,
|
||||
var(--heat-40) 50%,
|
||||
var(--border-muted) 65%,
|
||||
var(--border-muted) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: omniWaitPending 2.6s linear infinite;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .omni-wait-card::before {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
var(--background-base) 0%,
|
||||
var(--background-base) 35%,
|
||||
var(--heat-40) 50%,
|
||||
var(--background-base) 65%,
|
||||
var(--background-base) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.omni-wait-card::after {
|
||||
width: 100%;
|
||||
background: var(--klein);
|
||||
transform: scaleX(.06);
|
||||
transform-origin: left center;
|
||||
animation: omniWaitProgress 36s linear forwards;
|
||||
}
|
||||
|
||||
.omni-think-head {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 24px;
|
||||
color: var(--accent-black);
|
||||
font-size: 13px;
|
||||
color: var(--black-alpha-64);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.omni-think-log {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 248px;
|
||||
overflow-y: auto;
|
||||
padding: 10px 8px 2px 0;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border-faint);
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.omni-think-log-row {
|
||||
@@ -2673,27 +2757,17 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.omni-typing {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
@keyframes omniWaitProgress {
|
||||
0% { transform: scaleX(.06); }
|
||||
10% { transform: scaleX(.38); }
|
||||
45% { transform: scaleX(.66); }
|
||||
75% { transform: scaleX(.8); }
|
||||
100% { transform: scaleX(.94); }
|
||||
}
|
||||
|
||||
.omni-typing i {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--klein);
|
||||
opacity: .35;
|
||||
animation: omniTyping 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.omni-typing i:nth-child(2) { animation-delay: .15s; }
|
||||
.omni-typing i:nth-child(3) { animation-delay: .3s; }
|
||||
|
||||
@keyframes omniTyping {
|
||||
0%, 80%, 100% { opacity: .28; transform: translateY(0); }
|
||||
40% { opacity: 1; transform: translateY(-2px); }
|
||||
@keyframes omniWaitPending {
|
||||
from { background-position: 100% 0; }
|
||||
to { background-position: -100% 0; }
|
||||
}
|
||||
|
||||
@keyframes omniCurrentStepPulse {
|
||||
@@ -2701,15 +2775,13 @@
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes omniSendPulse {
|
||||
0%, 100% {
|
||||
background: #5b6572;
|
||||
opacity: .45;
|
||||
}
|
||||
50% {
|
||||
background: #8b95a3;
|
||||
opacity: 1;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.omni-wait-card::before,
|
||||
.omni-wait-card::after,
|
||||
.omni-think-log-row.is-current::before { animation: none; }
|
||||
.omni-wait-card::before { background: var(--border-muted); }
|
||||
html[data-theme="dark"] .omni-wait-card::before { background: var(--background-base); }
|
||||
.omni-wait-card::after { transform: scaleX(.6); }
|
||||
}
|
||||
|
||||
.omni-process-skeleton {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, ApiError } from "../api";
|
||||
import { displayOmniPresetLabel } from "../omni-preset-label";
|
||||
import { findCatalogModel } from "../components/omni-param-bar";
|
||||
import { modelResolutions } from "../components/free-create/constants";
|
||||
import { ConfirmModal, MediaLightbox } from "../components/overlays";
|
||||
@@ -46,7 +47,7 @@ type PresetItem = {
|
||||
|
||||
const VIDEO_PRESETS: PresetItem[] = [
|
||||
{ name: "痛点解决演示", category: "commerce", mode: "video", title: "痛点解决演示", desc: "从真实痛点到正常使用,再用看得见的过程证明商品价值。", starter: "基于商品的真实用途创作一条痛点解决演示:先让人看见困扰,再拍清使用步骤和真实结果,不夸张、不出现不合理的测试。", cover: "/assets/video-presets/pain-point-demo.jpg", previewVideo: "/assets/video-presets/pain-point-demo.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: "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: "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" },
|
||||
@@ -667,7 +668,7 @@ export function OmniCreatePage({
|
||||
model,
|
||||
ratio,
|
||||
...(outputMode === "video"
|
||||
? { resolution, duration: selectedCase?.name === "剧情反转带货" ? "60 秒" : duration }
|
||||
? { resolution, duration: selectedCase?.name === "短剧带货" ? "60 秒" : duration }
|
||||
: { count: duration }),
|
||||
},
|
||||
})
|
||||
@@ -931,7 +932,7 @@ export function OmniHistoryPage({
|
||||
<h2>{item.title}</h2>
|
||||
<p>
|
||||
{[
|
||||
item.preset || "自由创作",
|
||||
displayOmniPresetLabel(item.preset),
|
||||
item.mode === "video" ? "视频" : "图片",
|
||||
item.params?.model,
|
||||
item.params?.ratio,
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import { displayOmniPresetLabel } from "../omni-preset-label";
|
||||
import { findCatalogModel, normalizeDurationValue, OmniParamBar } from "../components/omni-param-bar";
|
||||
import { estimateCost, pointsPerImageFromCatalog } from "../components/free-create/constants";
|
||||
import { MediaLightbox } from "../components/overlays";
|
||||
@@ -1620,7 +1621,7 @@ function ElicitCard({
|
||||
{selected ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-ghost"
|
||||
className="btn btn-ghost"
|
||||
disabled={disabled}
|
||||
onClick={() => setAnswers((prev) => ({ ...prev, story_direction: "" }))}
|
||||
>
|
||||
@@ -1629,7 +1630,7 @@ function ElicitCard({
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-primary"
|
||||
className="btn btn-primary"
|
||||
disabled={disabled || !selected}
|
||||
onClick={() => onSubmit({ story_direction: selected }, [])}
|
||||
>
|
||||
@@ -2048,6 +2049,7 @@ function ConfirmCard({
|
||||
message,
|
||||
sessionParams,
|
||||
isVideo,
|
||||
isShortDrama,
|
||||
catalogModels,
|
||||
disabled,
|
||||
onConfirm,
|
||||
@@ -2056,6 +2058,7 @@ function ConfirmCard({
|
||||
message: CreationMessage;
|
||||
sessionParams: Record<string, string>;
|
||||
isVideo: boolean;
|
||||
isShortDrama: boolean;
|
||||
catalogModels?: ModelConfig[];
|
||||
disabled: boolean;
|
||||
onConfirm: (params: Record<string, string>) => void;
|
||||
@@ -2066,7 +2069,7 @@ function ConfirmCard({
|
||||
const payloadCredits = Number(message.payload.estimated_credits || 0);
|
||||
const payloadParams = asStringMap(message.payload.params);
|
||||
const snapshot = { ...sessionParams, ...payloadParams };
|
||||
const [draft, setDraft] = useState(snapshot);
|
||||
const [draft, setDraft] = useState(() => isShortDrama && isVideo && !submitted ? { ...snapshot, duration: "60 秒" } : snapshot);
|
||||
const [initialDuration] = useState(snapshot.duration || "");
|
||||
const cardIsVideo = message.payload.kind !== "image" && isVideo;
|
||||
const durationChanged =
|
||||
@@ -2112,6 +2115,7 @@ function ConfirmCard({
|
||||
: generationPhase === "failed" ? "可重新确认方案后再生成"
|
||||
: generationPhase === "running" ? "正在出片,请稍候"
|
||||
: generationPhase === "submitted" ? "正在提交生成"
|
||||
: isShortDrama ? "短剧带货固定 60 秒;确认前可以调整模型、分辨率和画面比例。"
|
||||
: "确认前可以改参数。只有改时长时会同步更新视频架构与 Prompt。";
|
||||
const foot =
|
||||
generationPhase === "done" ? "已完成出片"
|
||||
@@ -2137,6 +2141,7 @@ function ConfirmCard({
|
||||
<div className="omni-confirm-params">
|
||||
<OmniParamBar
|
||||
isVideo={cardIsVideo}
|
||||
fixedDuration={cardIsVideo && isShortDrama}
|
||||
disabled={disabled}
|
||||
catalogModels={catalogModels}
|
||||
model={draft.model || ""}
|
||||
@@ -2173,8 +2178,10 @@ function ConfirmCard({
|
||||
|
||||
function ResultCard({
|
||||
payload,
|
||||
fallbackRatio,
|
||||
}: {
|
||||
payload: Record<string, unknown>;
|
||||
fallbackRatio?: string;
|
||||
}) {
|
||||
const assets = (payload.assets as Array<Record<string, string>> | undefined) || [];
|
||||
const first = assets[0] || {};
|
||||
@@ -2187,7 +2194,7 @@ function ResultCard({
|
||||
const showSegmentGrid = partialFailure && assets.length > 0;
|
||||
const showSingleMedia = !showSegmentGrid && assets.length > 0;
|
||||
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
|
||||
const ratio = String(payload.ratio || "").trim();
|
||||
const ratio = String(payload.ratio || fallbackRatio || "").trim();
|
||||
const ratioClass = ratio === "16:9" ? "is-ratio-16-9" : ratio === "1:1" ? "is-ratio-1-1" : "is-ratio-9-16";
|
||||
const personReferenceClass = payload.kind === "person_reference" ? " is-person-reference" : "";
|
||||
const tileCount = showSegmentGrid ? assets.length : showSingleMedia ? 1 : 0;
|
||||
@@ -2622,15 +2629,18 @@ function groupVisibleCreationMessages(list: CreationMessage[]): CreationMessage[
|
||||
|
||||
function ProcessCard({
|
||||
payload,
|
||||
fallbackRatio,
|
||||
}: {
|
||||
payload: Record<string, unknown>;
|
||||
fallbackRatio?: string;
|
||||
}) {
|
||||
const isSegmentedVideo = payload.kind === "video_segments";
|
||||
const isMerge = payload.kind === "video_merge";
|
||||
const isVideo = payload.kind === "video" || isSegmentedVideo || isMerge;
|
||||
const segmentCount = Array.isArray(payload.segments) ? payload.segments.length : 0;
|
||||
const completedSegments = Number(payload.completed_segment_count || 0);
|
||||
const ratio = String(payload.ratio || "").trim();
|
||||
const generationSpec = payload.generation_spec as Record<string, unknown> | undefined;
|
||||
const ratio = String(payload.ratio || generationSpec?.aspect_ratio || fallbackRatio || "").trim();
|
||||
const ratioClass = ratio === "16:9" ? "is-ratio-16-9" : ratio === "1:1" ? "is-ratio-1-1" : "is-ratio-9-16";
|
||||
const personReferenceClass = payload.kind === "person_reference" ? " is-person-reference" : "";
|
||||
|
||||
@@ -2900,14 +2910,23 @@ export function OmniSessionPage({
|
||||
const scrollFrameRef = useRef(0);
|
||||
const initialScrolledForRef = useRef<string | null>(null);
|
||||
|
||||
const bottomScrollTarget = useCallback(() => {
|
||||
const maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
|
||||
const latest = feedRef.current?.lastElementChild as HTMLElement | null;
|
||||
if (!latest || latest.getBoundingClientRect().height > window.innerHeight * 0.6) return maxScroll;
|
||||
const topbarHeight = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--topbar-height")) || 0;
|
||||
const latestTop = latest.getBoundingClientRect().top + window.scrollY;
|
||||
// 短对话或低视口时,滚到底部不能把最后一条消息推进 sticky 顶栏背后。
|
||||
return Math.min(maxScroll, Math.max(0, latestTop - topbarHeight - 16));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
const gap = document.documentElement.scrollHeight - window.scrollY - window.innerHeight;
|
||||
stickToBottomRef.current = gap < 120;
|
||||
stickToBottomRef.current = bottomScrollTarget() - window.scrollY < 120;
|
||||
};
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
}, [bottomScrollTarget]);
|
||||
|
||||
const scrollToBottom = useCallback((smooth: boolean) => {
|
||||
if (!stickToBottomRef.current) return;
|
||||
@@ -2915,11 +2934,11 @@ export function OmniSessionPage({
|
||||
scrollFrameRef.current = requestAnimationFrame(() => {
|
||||
scrollFrameRef.current = 0;
|
||||
window.scrollTo({
|
||||
top: document.documentElement.scrollHeight,
|
||||
top: bottomScrollTarget(),
|
||||
behavior: smooth ? "smooth" : "instant",
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
}, [bottomScrollTarget]);
|
||||
|
||||
// 重进已有会话时,等详情与消息一起落到 DOM 后直接显示最新内容。
|
||||
// 首次定位不能用 smooth:长历史的滚动动画会在中途触发 onScroll,误判成用户上滑。
|
||||
@@ -2928,12 +2947,12 @@ export function OmniSessionPage({
|
||||
initialScrolledForRef.current = conversationId;
|
||||
stickToBottomRef.current = true;
|
||||
if (scrollFrameRef.current) cancelAnimationFrame(scrollFrameRef.current);
|
||||
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" });
|
||||
window.scrollTo({ top: bottomScrollTarget(), behavior: "instant" });
|
||||
const frame = requestAnimationFrame(() => {
|
||||
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" });
|
||||
window.scrollTo({ top: bottomScrollTarget(), behavior: "instant" });
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [conversation, conversationId]);
|
||||
}, [bottomScrollTarget, conversation, conversationId]);
|
||||
|
||||
// 新消息落地:平滑滚一次
|
||||
useEffect(() => {
|
||||
@@ -3729,6 +3748,7 @@ export function OmniSessionPage({
|
||||
message={message}
|
||||
sessionParams={params}
|
||||
isVideo={isVideo}
|
||||
isShortDrama={Boolean(conversation?.preset && ["短剧带货", "剧情反转带货"].includes(conversation.preset))}
|
||||
catalogModels={modelConfigs}
|
||||
generationPhase={generationPhase}
|
||||
disabled={
|
||||
@@ -3745,6 +3765,7 @@ export function OmniSessionPage({
|
||||
<ProcessCard
|
||||
key={message.clientKey || message.id}
|
||||
payload={message.payload}
|
||||
fallbackRatio={params.ratio}
|
||||
/>
|
||||
);
|
||||
case "result":
|
||||
@@ -3752,6 +3773,7 @@ export function OmniSessionPage({
|
||||
<ResultCard
|
||||
key={message.clientKey || message.id}
|
||||
payload={message.payload}
|
||||
fallbackRatio={params.ratio}
|
||||
/>
|
||||
);
|
||||
default: {
|
||||
@@ -3786,6 +3808,11 @@ export function OmniSessionPage({
|
||||
{(body || message.role !== "user") ? (
|
||||
<div className="omni-chat-bubble" aria-busy={pending || undefined}>
|
||||
{body ? (message.role === "assistant" ? <ChatMarkdown text={body} /> : <p className="omni-chat-text">{body}</p>) : null}
|
||||
{pending ? (
|
||||
<span className="omni-send-dots" role="status" aria-label="发送中">
|
||||
<i aria-hidden="true" /><i aria-hidden="true" /><i aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{showReplyGuide ? (
|
||||
@@ -3848,7 +3875,7 @@ export function OmniSessionPage({
|
||||
<h1>{conversation?.title || "未命名创作"}</h1>
|
||||
<div className="omni-session-meta">
|
||||
{[
|
||||
conversation?.preset || "自由创作",
|
||||
displayOmniPresetLabel(conversation?.preset),
|
||||
params.model,
|
||||
params.resolution && params.ratio
|
||||
? `${params.resolution} · ${params.ratio}`
|
||||
@@ -3903,7 +3930,7 @@ export function OmniSessionPage({
|
||||
&& Boolean(conversation?.agent_progress)
|
||||
&& !currentPlanningHasError ? (
|
||||
/生成图片|生成视频/.test(activeTool) && !liveText ? (
|
||||
<ProcessCard payload={{ kind: /生成视频/.test(activeTool) ? "video" : "image" }} />
|
||||
<ProcessCard payload={{ kind: /生成视频/.test(activeTool) ? "video" : "image" }} fallbackRatio={params.ratio} />
|
||||
) : liveText ? (
|
||||
<div className="omni-chat-row agent is-live">
|
||||
<span className="omni-chat-avatar">
|
||||
@@ -3914,22 +3941,20 @@ export function OmniSessionPage({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="omni-chat-row agent is-live" aria-label="正在回复">
|
||||
<div className="omni-chat-row agent is-live" aria-label="正在生成创作内容">
|
||||
<span className="omni-chat-avatar">
|
||||
<Sparkles />
|
||||
</span>
|
||||
<div className={`omni-chat-bubble${liveReasoning ? " is-reasoning" : " is-thinking"}`}>
|
||||
<div className="omni-chat-bubble omni-wait-card" role="status" aria-live="polite">
|
||||
<div className="omni-think-head">
|
||||
<span className="omni-typing" aria-hidden="true"><i /><i /><i /></span>
|
||||
<span>{activeTool || (liveReasoning ? "正在思考" : isVideo ? "正在整理方案" : "正在整理画面")}</span>
|
||||
</div>
|
||||
{liveReasoning ? (
|
||||
<div className="omni-think-log" ref={thinkingLogRef} aria-label="创作过程">
|
||||
{(visibleProgressHistory.length
|
||||
? visibleProgressHistory
|
||||
<div className="omni-think-log" ref={thinkingLogRef} aria-label="创作过程">
|
||||
{(visibleProgressHistory.length
|
||||
? visibleProgressHistory.slice(-2)
|
||||
: [{
|
||||
label: activeTool || "正在思考",
|
||||
detail: liveReasoning,
|
||||
detail: liveReasoning || "正在准备创作内容,请稍候…",
|
||||
}]
|
||||
).map((progress, index, history) => (
|
||||
<div
|
||||
@@ -3939,8 +3964,7 @@ export function OmniSessionPage({
|
||||
<p>{progress.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user