大量优化全能创作
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import uuid
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db import transaction
|
||||
@@ -123,9 +124,16 @@ def sync_generating_message(message: CreationMessage) -> bool:
|
||||
|
||||
if message.kind != CreationMessage.Kind.GENERATING:
|
||||
return False
|
||||
payload = message.payload or {}
|
||||
if payload.get("kind") == "video_segments":
|
||||
return _sync_segmented_video_message(message)
|
||||
task = _message_task(message)
|
||||
if task is None:
|
||||
return False
|
||||
if payload.get("kind") == "video_merge" and task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING):
|
||||
# Celery 不在线的本地环境同样可完成用户已确认的合并;此前从不在普通出片完成时调用。
|
||||
run_segmented_video_merge(str(task.id))
|
||||
task.refresh_from_db()
|
||||
if task.status == AITask.Status.SUCCEEDED:
|
||||
assets = _assets_from_task(task)
|
||||
if not assets:
|
||||
@@ -138,6 +146,191 @@ def sync_generating_message(message: CreationMessage) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _sync_segmented_video_message(message: CreationMessage) -> bool:
|
||||
"""同步全能创作的多段出片。
|
||||
|
||||
每段仍是普通 FREE_VIDEO 任务,故可沿用既有计费、轮询和资产落库;这里仅把它们聚合成
|
||||
一张结果卡。所有分段完成后停在结果卡,绝不在此处触发 ffmpeg。
|
||||
"""
|
||||
from .free_video import finalize_free_video
|
||||
from .models import AITask
|
||||
|
||||
payload = message.payload or {}
|
||||
ids = [str(value) for value in payload.get("task_ids") or [] if value]
|
||||
if not ids:
|
||||
return False
|
||||
by_id = {
|
||||
str(task.id): task
|
||||
for task in AITask.objects.filter(id__in=ids).select_related("model_config")
|
||||
}
|
||||
tasks = [by_id.get(task_id) for task_id in ids]
|
||||
if any(task is None for task in tasks):
|
||||
fail_generating_message(message, "分段视频任务不完整,请重新生成。")
|
||||
return True
|
||||
|
||||
# 本地没有 worker 时,用户的会话轮询本身即可推进每个片段;已在 worker 中的任务则幂等返回。
|
||||
refreshed = []
|
||||
for task in tasks:
|
||||
assert task is not None
|
||||
if task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING):
|
||||
try:
|
||||
task = finalize_free_video(task=task)
|
||||
except Exception: # noqa: BLE001 - 单段网络抖动不能使整组直接失败
|
||||
task.refresh_from_db()
|
||||
else:
|
||||
task.refresh_from_db()
|
||||
refreshed.append(task)
|
||||
|
||||
failed = next(
|
||||
(task for task in refreshed if task.status in (AITask.Status.FAILED, AITask.Status.CANCELLED)),
|
||||
None,
|
||||
)
|
||||
if failed is not None:
|
||||
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 '请重试'}")
|
||||
return True
|
||||
if not all(task.status == AITask.Status.SUCCEEDED for task in refreshed):
|
||||
return False
|
||||
|
||||
assets: list[dict] = []
|
||||
for index, task in enumerate(refreshed, start=1):
|
||||
task_assets = _assets_from_task(task)
|
||||
if not task_assets:
|
||||
return False
|
||||
for asset in task_assets:
|
||||
assets.append({**asset, "label": f"第 {index} 段"})
|
||||
first = refreshed[0]
|
||||
finish_generating_message(
|
||||
message,
|
||||
assets=assets,
|
||||
meta={
|
||||
**_meta_from_task(first, message),
|
||||
"kind": "video_segments",
|
||||
"segment_count": len(refreshed),
|
||||
"total_duration": payload.get("total_duration") or "",
|
||||
"needs_merge": True,
|
||||
"segments": payload.get("segments") or [],
|
||||
},
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def start_segmented_video_merge(*, conversation: CreationConversation, message: CreationMessage, user):
|
||||
"""用户明确点击后才建立合并任务;这之前绝不下载片段或调用 ffmpeg。"""
|
||||
from .models import AITask
|
||||
|
||||
payload = dict(message.payload or {})
|
||||
if message.kind != CreationMessage.Kind.RESULT or not payload.get("needs_merge"):
|
||||
raise ValueError("这条结果不需要合并")
|
||||
state = str(payload.get("merge_state") or "")
|
||||
if state in {"queued", "processing", "completed"}:
|
||||
raise ValueError("这组片段已经在合并中或已合并")
|
||||
task_ids = [str(value) for value in payload.get("task_ids") or [] if value]
|
||||
source_tasks = list(AITask.objects.filter(id__in=task_ids).select_related("model_config"))
|
||||
if len(source_tasks) != len(task_ids):
|
||||
raise ValueError("分段视频不完整,无法合并")
|
||||
model_config = source_tasks[0].model_config
|
||||
merge_task = AITask.objects.create(
|
||||
team=conversation.team,
|
||||
created_by=user,
|
||||
project=None,
|
||||
task_type=AITask.Type.EXPORT,
|
||||
status=AITask.Status.SUBMITTED,
|
||||
model_config=model_config,
|
||||
idempotency_key=f"omni-video-merge:{conversation.id}:{message.id}:{uuid.uuid4()}",
|
||||
request_payload={
|
||||
"feature": "omni_video_merge",
|
||||
"source_message_id": str(message.id),
|
||||
"source_task_ids": task_ids,
|
||||
"prompt": payload.get("prompt") or "",
|
||||
},
|
||||
)
|
||||
payload["merge_state"] = "queued"
|
||||
payload["merge_task_id"] = str(merge_task.id)
|
||||
message.payload = payload
|
||||
message.save(update_fields=["payload", "updated_at"])
|
||||
generating = append_message(
|
||||
conversation,
|
||||
role="assistant",
|
||||
kind=CreationMessage.Kind.GENERATING,
|
||||
payload={"task_id": str(merge_task.id), "kind": "video_merge", "prompt": payload.get("prompt") or ""},
|
||||
task=merge_task,
|
||||
)
|
||||
return merge_task, generating
|
||||
|
||||
|
||||
def run_segmented_video_merge(task_id: str) -> None:
|
||||
"""后台执行用户已确认的合并。任务创建不等于执行;仅此函数调用 ffmpeg。"""
|
||||
from .free_video import _store_free_video_media
|
||||
from .models import AITask
|
||||
from .services import asset_stable_url
|
||||
from .video_replace import _concat_shot_media
|
||||
from apps.assets.models import Asset
|
||||
|
||||
task = AITask.objects.select_related("team", "created_by").filter(id=task_id).first()
|
||||
if task is None or task.task_type != AITask.Type.EXPORT:
|
||||
return
|
||||
source_message_id = str((task.request_payload or {}).get("source_message_id") or "")
|
||||
source_message = CreationMessage.objects.filter(id=source_message_id).first()
|
||||
if source_message is None:
|
||||
return
|
||||
try:
|
||||
with transaction.atomic():
|
||||
locked = AITask.objects.select_for_update().get(id=task.id)
|
||||
if locked.status not in (AITask.Status.SUBMITTED, AITask.Status.POLLING):
|
||||
return
|
||||
locked.status = AITask.Status.POSTPROCESSING
|
||||
locked.save(update_fields=["status", "updated_at"])
|
||||
urls: list[str] = []
|
||||
for source_id in (locked.request_payload or {}).get("source_task_ids") or []:
|
||||
source_asset = (
|
||||
Asset.objects.filter(origin_task_id=source_id, is_deleted=False, purged_at__isnull=True)
|
||||
.prefetch_related("files")
|
||||
.first()
|
||||
)
|
||||
url, _cover = asset_stable_url(source_asset)
|
||||
if not url:
|
||||
raise ValueError("有片段尚未准备好,暂时不能合并")
|
||||
urls.append(url)
|
||||
merged_bytes = _concat_shot_media(urls)
|
||||
_store_free_video_media(task=locked, video_bytes=merged_bytes)
|
||||
with transaction.atomic():
|
||||
locked = AITask.objects.select_for_update().get(id=task.id)
|
||||
if locked.status != AITask.Status.POSTPROCESSING:
|
||||
return
|
||||
locked.status = AITask.Status.SUCCEEDED
|
||||
locked.completed_at = timezone.now()
|
||||
locked.save(update_fields=["status", "completed_at", "updated_at"])
|
||||
source = CreationMessage.objects.select_for_update().get(id=source_message.id)
|
||||
source_payload = dict(source.payload or {})
|
||||
source_payload["merge_state"] = "completed"
|
||||
source.payload = source_payload
|
||||
source.save(update_fields=["payload", "updated_at"])
|
||||
except Exception as exc: # noqa: BLE001 - 合并失败不影响已完成的分段视频
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).exception("omni video merge failed for %s", task_id)
|
||||
with transaction.atomic():
|
||||
locked = AITask.objects.select_for_update().get(id=task.id)
|
||||
if locked.status == AITask.Status.POSTPROCESSING:
|
||||
locked.status = AITask.Status.FAILED
|
||||
locked.error_code = "MergeError"
|
||||
locked.error_message = str(exc)[:500]
|
||||
locked.completed_at = timezone.now()
|
||||
locked.save(update_fields=["status", "error_code", "error_message", "completed_at", "updated_at"])
|
||||
source = CreationMessage.objects.select_for_update().filter(id=source_message.id).first()
|
||||
if source is not None:
|
||||
source_payload = dict(source.payload or {})
|
||||
source_payload["merge_state"] = "failed"
|
||||
source.payload = source_payload
|
||||
source.save(update_fields=["payload", "updated_at"])
|
||||
finally:
|
||||
# 复用既有「生成中 → 结果/错误」转换,生成的合成片会成为独立结果卡。
|
||||
fresh = AITask.objects.filter(id=task_id).first()
|
||||
if fresh is not None:
|
||||
sync_generating_for_task(fresh)
|
||||
|
||||
|
||||
def sync_generating_messages(conversation: CreationConversation) -> int:
|
||||
"""把一条会话里所有已结束的 GENERATING 回填。返回改了几条。"""
|
||||
pending = list(
|
||||
|
||||
@@ -232,6 +232,34 @@ def get_pending_video_prompt(conversation: CreationConversation) -> str:
|
||||
return str(memory.get("pending_video_prompt") or "").strip()
|
||||
|
||||
|
||||
def append_selling_point_gate(conversation: CreationConversation) -> CreationMessage:
|
||||
"""在视频方案生成前确认卖点来源。
|
||||
|
||||
商家可以直接给真实卖点;若交给系统,则后续只从商品资料和参考素材中选择可证实的表达,
|
||||
不把“系统推荐”误做成无依据的夸大文案。
|
||||
"""
|
||||
return append_message(
|
||||
conversation,
|
||||
role="assistant",
|
||||
kind=CreationMessage.Kind.ELICIT,
|
||||
text="这条视频准备先讲哪个卖点?你可以直接写真实卖点,也可以让我从商品和素材里推荐一个。",
|
||||
payload={
|
||||
"interaction": "selling_point_gate",
|
||||
"fields": [
|
||||
{
|
||||
"key": "selling_point",
|
||||
"label": "商品卖点",
|
||||
"type": "text",
|
||||
"required": False,
|
||||
"placeholder": "例如:油污一喷一擦就干净,适合厨房重油污…",
|
||||
}
|
||||
],
|
||||
"submitted": False,
|
||||
"answers": {},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _step_confirm_payload(step: str) -> dict:
|
||||
return {
|
||||
"interaction": "step_confirm",
|
||||
@@ -438,8 +466,16 @@ IMAGE_MODEL_BY_LABEL = {
|
||||
"YQ image2": "gpt-image",
|
||||
"影擎-Image2": "gpt-image",
|
||||
}
|
||||
# 「智能时长」= 交给我们定,取一个口播讲得完又不烧钱的中间值
|
||||
# 「智能时长」没有可读到的脚本时才用的保守兜底。实际方案生成后必须从时间轴推导,
|
||||
# 不能把每条片都悄悄压成 15 秒。
|
||||
SMART_DURATION = 15
|
||||
_SMART_DURATION_RE = re.compile(
|
||||
r"(?<!\d)(\d{1,2}(?:\.\d+)?)\s*(?:-|—|–|~|至|到)\s*(\d{1,2}(?:\.\d+)?)\s*秒?"
|
||||
)
|
||||
_TOTAL_DURATION_RE = re.compile(
|
||||
r"(?:总时长|成片时长|时长)\s*[::]?\s*(\d{1,2}(?:\.\d+)?)\s*秒",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# 全能创作 video_prompt 写作规范。目标是把 Prompt 写成导演、摄影、声音与出片模型都能
|
||||
# 直接执行的制作文件,而不是只有几行「0-3 秒做什么」的提纲。
|
||||
@@ -876,6 +912,9 @@ def apply_restart_intent(conversation: CreationConversation) -> int:
|
||||
memory["stage"] = "clarify"
|
||||
memory.pop("pending_video_prompt", None)
|
||||
memory.pop("strategy_confirmed", None)
|
||||
memory.pop("selling_point_ready", None)
|
||||
memory.pop("selling_point_mode", None)
|
||||
memory.pop("selling_point", None)
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
|
||||
@@ -945,6 +984,17 @@ def requested_asset_card_from_context(
|
||||
)
|
||||
if not wants_card:
|
||||
return None
|
||||
# 用户明确要某类素材列表时,不能依赖上一条是否碰巧留下追问卡。
|
||||
# 否则模型会把检索结果念成名称段落,而不会返回可点击的卡片。
|
||||
direct_types = (
|
||||
("product", ("商品", "产品")),
|
||||
("character", ("角色", "人物")),
|
||||
("model", ("模特",)),
|
||||
("scene", ("场景",)),
|
||||
)
|
||||
for type_, keywords in direct_types:
|
||||
if any(keyword in text for keyword in keywords):
|
||||
return type_
|
||||
recent = conversation.messages.filter(
|
||||
kind=CreationMessage.Kind.ELICIT
|
||||
).order_by("-seq")[:8]
|
||||
@@ -1457,15 +1507,175 @@ def video_model_name(params: dict) -> str:
|
||||
return hit.name if hit else DEFAULT_VIDEO_MODEL
|
||||
|
||||
|
||||
def video_duration(params: dict) -> int:
|
||||
"""「15 秒」→ 15;「智能时长」/ 解析不出 → SMART_DURATION。"""
|
||||
def _is_smart_duration(params: dict) -> bool:
|
||||
raw = str((params or {}).get("duration") or "").strip().lower()
|
||||
return not raw or "智能" in raw or raw in {"smart", "auto"}
|
||||
|
||||
|
||||
def infer_script_duration(*, timeline: list[dict] | None = None, prompt: str = "") -> int | None:
|
||||
"""从已写好的方案推算实际片长:优先方案时间轴,其次 Prompt 的秒级分段。"""
|
||||
ends: list[float] = []
|
||||
for item in timeline or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
end = float(item.get("end"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if end > 0:
|
||||
ends.append(end)
|
||||
for match in _SMART_DURATION_RE.finditer(prompt or ""):
|
||||
try:
|
||||
ends.append(float(match.group(2)))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
for match in _TOTAL_DURATION_RE.finditer(prompt or ""):
|
||||
try:
|
||||
ends.append(float(match.group(1)))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not ends:
|
||||
return None
|
||||
# 方案的最后一个时间点就是成片总长;向上取整避免 19.2 秒被截成 19 秒。
|
||||
return max(4, min(60, int(max(ends) + 0.999)))
|
||||
|
||||
|
||||
def _raw_script_duration(*, timeline: list[dict] | None = None, prompt: str = "") -> int | None:
|
||||
"""返回方案真实的最大时间点,不在这里截断,供 60 秒硬上限校验使用。"""
|
||||
ends: list[float] = []
|
||||
for item in timeline or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
end = float(item.get("end"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if end > 0:
|
||||
ends.append(end)
|
||||
for match in _SMART_DURATION_RE.finditer(prompt or ""):
|
||||
try:
|
||||
ends.append(float(match.group(2)))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
for match in _TOTAL_DURATION_RE.finditer(prompt or ""):
|
||||
try:
|
||||
ends.append(float(match.group(1)))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return int(max(ends) + 0.999) if ends else None
|
||||
|
||||
|
||||
def plan_video_segments(duration: int, timeline: list[dict] | None = None) -> list[dict]:
|
||||
"""按方案节奏把 31–60 秒视频拆成可由 Seedance 2.5 单独完成的片段。
|
||||
|
||||
每段不超过 30 秒;优先落在时间轴的自然转场处,找不到合适转场再均分。
|
||||
当前模型上限下 31–60 秒稳定拆为两段,避免无意义地把同一支片拆得过碎。
|
||||
"""
|
||||
total = max(4, min(int(duration or 0), 60))
|
||||
if total <= 30:
|
||||
return [{"index": 1, "start": 0, "end": total, "duration": total}]
|
||||
|
||||
candidates: list[int] = []
|
||||
for item in timeline or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
end = int(round(float(item.get("end"))))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if 4 <= end <= total - 4:
|
||||
candidates.append(end)
|
||||
# 两段都必须 <= 30 秒,因此 60 秒时唯一合法分点就是 30 秒。
|
||||
lower, upper = max(4, total - 30), min(30, total - 4)
|
||||
target = total / 2
|
||||
legal = [point for point in candidates if lower <= point <= upper]
|
||||
split = min(legal, key=lambda point: abs(point - target)) if legal else int(round(target))
|
||||
split = max(lower, min(upper, split))
|
||||
return [
|
||||
{"index": 1, "start": 0, "end": split, "duration": split},
|
||||
{"index": 2, "start": split, "end": total, "duration": total - split},
|
||||
]
|
||||
|
||||
|
||||
def segment_video_prompt(prompt: str, segment: dict, total_duration: int) -> str:
|
||||
"""让单段模型只拍本段,不把整条长脚本压回每一个片段里。"""
|
||||
index = int(segment.get("index") or 1)
|
||||
start = int(segment.get("start") or 0)
|
||||
end = int(segment.get("end") or 0)
|
||||
return (
|
||||
f"{prompt.strip()}\n\n"
|
||||
f"【分段出片约束】这是整支 {total_duration} 秒视频的第 {index} 段,只生成 {start}–{end} 秒的内容。"
|
||||
"仅呈现这一时间段对应的情节与镜头,承接上一段的角色、服装、商品、场景和光线,"
|
||||
"为下一段留出自然动作衔接;不要重演完整故事,不要添加字幕、文字、角标或水印。"
|
||||
)
|
||||
|
||||
|
||||
def video_duration(params: dict, *, prompt: str = "", timeline: list[dict] | None = None) -> int:
|
||||
"""显式时长优先;智能时长从方案/Prompt 推导,完全缺失才回退 15 秒。"""
|
||||
raw = str(params.get("duration") or "")
|
||||
digits = "".join(ch for ch in raw if ch.isdigit())
|
||||
if not digits:
|
||||
inferred = infer_script_duration(timeline=timeline, prompt=prompt)
|
||||
if inferred is not None:
|
||||
return inferred
|
||||
return SMART_DURATION
|
||||
return max(4, min(int(digits), 60))
|
||||
|
||||
|
||||
def _model_supports_duration(model_name: str, duration: int) -> bool:
|
||||
"""仅用于智能时长的自动路由;配置缺失时不武断拦截,交由提交校验给出明确错误。"""
|
||||
model = ModelConfig.objects.filter(
|
||||
name=model_name,
|
||||
capability=ModelConfig.Capability.VIDEO,
|
||||
status=ModelConfig.Status.ACTIVE,
|
||||
).first()
|
||||
if model is None:
|
||||
return True
|
||||
meta = model.metadata if isinstance(model.metadata, dict) else {}
|
||||
listed = (meta.get("capabilities") or {}).get("durations") or meta.get("durations") or []
|
||||
values = [int(value) for value in listed if str(value).isdigit()]
|
||||
return not values or min(values) <= duration <= max(values)
|
||||
|
||||
|
||||
def resolve_smart_video_duration(
|
||||
conversation: CreationConversation,
|
||||
*,
|
||||
prompt: str = "",
|
||||
timeline: list[dict] | None = None,
|
||||
) -> int:
|
||||
"""把智能时长固化为方案真实时长,并在需要时切换到能承载它的模型。
|
||||
|
||||
这一步发生在方案写完、用户看到最终确认卡之前,所以页面显示、计费和实际 API 参数
|
||||
始终是同一个秒数。
|
||||
"""
|
||||
params = dict(conversation.params or {})
|
||||
smart_duration = _is_smart_duration(params)
|
||||
duration = video_duration(params, prompt=prompt, timeline=timeline)
|
||||
if smart_duration:
|
||||
params["duration"] = f"{duration} 秒"
|
||||
selected_model = video_model_name(params)
|
||||
switched = False
|
||||
# Seedance 2.5 是当前唯一可稳定承载 16–30 秒单段、以及 31–60 秒分段的模型。
|
||||
# 即使总时长 45 秒不在单段能力表里,后续也会拆成两条 <=30 秒的 2.5 任务。
|
||||
if duration > 15 and selected_model != DEFAULT_VIDEO_MODEL:
|
||||
params["model"] = "Seedance 2.5"
|
||||
switched = True
|
||||
elif not _model_supports_duration(selected_model, duration):
|
||||
# 智能模式可自动选能完成完整脚本的模型;确认卡会清楚展示变更,用户仍能手动调整。
|
||||
if _model_supports_duration(DEFAULT_VIDEO_MODEL, duration):
|
||||
params["model"] = "Seedance 2.5"
|
||||
switched = True
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["smart_duration_resolved"] = duration
|
||||
if switched:
|
||||
memory["smart_duration_model_switched"] = True
|
||||
if smart_duration or switched:
|
||||
conversation.params = params
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["params", "memory", "updated_at"])
|
||||
return duration
|
||||
|
||||
|
||||
def _image_count(params: dict, raw) -> int:
|
||||
"""出图张数:首页选过「N 张」就用它,否则用模型传的 count,默认 1,上限 8。"""
|
||||
label = str((params or {}).get("count") or (params or {}).get("duration") or "")
|
||||
@@ -1534,7 +1744,6 @@ def _run_generate_image(context: AgentContext, args: dict) -> tuple[dict, list]:
|
||||
def _video_submit_params(context: AgentContext, prompt: str) -> tuple[dict, list]:
|
||||
"""拼 submit_free_video 的入参。references 直接用 resolve_refs 的产物 ——
|
||||
它已经排好 角色 → 场景 → 商品 的顺序,那正是出片模型 @图N 的语义依据。"""
|
||||
params = context.conversation.params or {}
|
||||
resolved = resolve_refs(context.team, context.conversation.pinned_refs or [])
|
||||
prompt = apply_video_preset_prompt(context.conversation.preset, prompt)
|
||||
prompt = apply_product_voice_visual_guard(context.conversation, prompt)
|
||||
@@ -1544,6 +1753,9 @@ def _video_submit_params(context: AgentContext, prompt: str) -> tuple[dict, list
|
||||
active_plot_twist_story_depth(context.conversation),
|
||||
prompt,
|
||||
)
|
||||
duration = resolve_smart_video_duration(context.conversation, prompt=prompt)
|
||||
# resolve_smart_video_duration 可能为了完整脚本切到支持长时长的模型,必须重新取参数。
|
||||
params = context.conversation.params or {}
|
||||
submit = {
|
||||
"prompt": prompt,
|
||||
"feature": "omni_create",
|
||||
@@ -1551,7 +1763,7 @@ def _video_submit_params(context: AgentContext, prompt: str) -> tuple[dict, list
|
||||
"model": video_model_name(params),
|
||||
"aspect_ratio": params.get("ratio") or "9:16",
|
||||
"resolution": params.get("resolution") or "720p",
|
||||
"duration": video_duration(params),
|
||||
"duration": duration,
|
||||
"generate_audio": True,
|
||||
"references": resolved.references,
|
||||
}
|
||||
@@ -1612,7 +1824,10 @@ def submit_confirmed_video(*, conversation: CreationConversation, user, confirm_
|
||||
**这里不再跑一轮模型**:方案已经确认过了,再让模型决定一次既费钱又可能它不调工具。
|
||||
返回 (生成中消息, 错误文案),两者必有其一。
|
||||
"""
|
||||
from .free_video import submit_free_video
|
||||
from django.conf import settings
|
||||
|
||||
from .free_video import IN_FLIGHT_STATUSES, submit_free_video
|
||||
from .models import AITask
|
||||
|
||||
payload = confirm_message.payload or {}
|
||||
prompt = str(payload.get("video_prompt") or "").strip()
|
||||
@@ -1621,14 +1836,54 @@ def submit_confirmed_video(*, conversation: CreationConversation, user, confirm_
|
||||
|
||||
context = AgentContext(conversation=conversation, user=user, model_config=None)
|
||||
submit, _references = _video_submit_params(context, prompt)
|
||||
total_duration = int(submit["duration"])
|
||||
segments = plan_video_segments(total_duration)
|
||||
# 先整体检查并发余量,再提交任何一段;否则第一段已扣费、第二段才因额度满失败会留下孤儿片段。
|
||||
in_flight = AITask.objects.filter(
|
||||
team=conversation.team,
|
||||
task_type=AITask.Type.FREE_VIDEO,
|
||||
status__in=IN_FLIGHT_STATUSES,
|
||||
).count()
|
||||
max_concurrent = int(getattr(settings, "FREE_VIDEO_MAX_CONCURRENT", 3))
|
||||
if in_flight + len(segments) > max_concurrent:
|
||||
return None, f"当前视频任务余量不足,需要同时生成 {len(segments)} 段;请等待现有任务完成后再试。"
|
||||
tasks = []
|
||||
try:
|
||||
task = submit_free_video(team=conversation.team, user=user, params=submit)
|
||||
for segment in segments:
|
||||
segment_submit = {
|
||||
**submit,
|
||||
"duration": int(segment["duration"]),
|
||||
"prompt": segment_video_prompt(prompt, segment, total_duration) if len(segments) > 1 else submit["prompt"],
|
||||
"extra_payload": {
|
||||
"omni_segment": {
|
||||
"index": segment["index"],
|
||||
"start": segment["start"],
|
||||
"end": segment["end"],
|
||||
"total_duration": total_duration,
|
||||
}
|
||||
},
|
||||
}
|
||||
tasks.append(submit_free_video(team=conversation.team, user=user, params=segment_submit))
|
||||
except ValueError as exc: # 校验类错误(时长/比例/额度),给用户看原文
|
||||
return None, str(exc)
|
||||
|
||||
if len(tasks) == 1:
|
||||
message_payload = {"task_id": str(tasks[0].id), "kind": "video", "prompt": prompt}
|
||||
else:
|
||||
message_payload = {
|
||||
"task_id": str(tasks[0].id),
|
||||
"task_ids": [str(task.id) for task in tasks],
|
||||
"kind": "video_segments",
|
||||
"prompt": prompt,
|
||||
"total_duration": total_duration,
|
||||
"segments": [
|
||||
{**segment, "task_id": str(task.id)}
|
||||
for segment, task in zip(segments, tasks, strict=True)
|
||||
],
|
||||
}
|
||||
message = append_message(
|
||||
conversation, role="assistant", kind=CreationMessage.Kind.GENERATING,
|
||||
payload={"task_id": str(task.id), "kind": "video", "prompt": prompt}, task=task,
|
||||
payload=message_payload, task=tasks[0],
|
||||
)
|
||||
_remember_artifact(conversation, prompt, "video")
|
||||
set_video_gate_stage(conversation, "done", clear_pending_prompt=True)
|
||||
@@ -1849,6 +2104,18 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
|
||||
)
|
||||
stage = get_video_gate_stage(conversation)
|
||||
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
|
||||
selling_point = str(memory.get("selling_point") or "").strip()
|
||||
selling_mode = str(memory.get("selling_point_mode") or "").strip()
|
||||
if selling_mode == "manual" and selling_point:
|
||||
lines.append(
|
||||
f"- 商家已确认核心卖点:【{selling_point}】。策略、方案、脚本和出片指令必须围绕它展开;"
|
||||
"只补充可从素材或正常使用中证明的支撑,不得替换或夸大。"
|
||||
)
|
||||
elif selling_mode == "auto":
|
||||
lines.append(
|
||||
"- 商家已授权系统推荐卖点。你必须从商品资料、可见素材和正常使用动作中选择一个最易证明的核心卖点;"
|
||||
"不要虚构功效、价格或规格。"
|
||||
)
|
||||
strategy_confirmed = bool(memory.get("strategy_confirmed"))
|
||||
stage_hint = {
|
||||
"clarify": "当前阶段=澄清:缺关键信息就 ask_user;信息够了只调 write_strategy。",
|
||||
@@ -2315,6 +2582,29 @@ def iter_creation_agent_events(
|
||||
yield {"type": "done"}
|
||||
return
|
||||
|
||||
# 明确要商品/角色/场景列表时,直接生成真实选择卡,绝不先让模型念出素材名称。
|
||||
requested_card = requested_asset_card_from_context(conversation, text)
|
||||
if requested_card:
|
||||
result, _stop = _dispatch_tool(
|
||||
context,
|
||||
"ask_user",
|
||||
{"fields": [{
|
||||
"key": requested_card,
|
||||
"label": _ASSET_PICK_LABEL.get(
|
||||
requested_card,
|
||||
_ASSET_CARD_LABELS.get(requested_card, "请选择素材"),
|
||||
),
|
||||
"type": "asset",
|
||||
"required": True,
|
||||
"asset_types": [requested_card],
|
||||
}]},
|
||||
allow_pick=True,
|
||||
)
|
||||
for event in result.get("_events", []):
|
||||
yield event
|
||||
yield {"type": "done"}
|
||||
return
|
||||
|
||||
model_config = _prefer_vision_text_model(model_config, conversation.team, conversation.pinned_refs or [])
|
||||
context.model_config = model_config
|
||||
|
||||
@@ -2796,6 +3086,14 @@ def _dispatch_tool(
|
||||
return {"payload": _run_search_library(context, args)}, False
|
||||
|
||||
if name == "write_strategy":
|
||||
memory = context.conversation.memory if isinstance(context.conversation.memory, dict) else {}
|
||||
if context.is_video and not memory.get("selling_point_ready"):
|
||||
gate = append_selling_point_gate(context.conversation)
|
||||
set_video_gate_stage(context.conversation, "clarify")
|
||||
return {
|
||||
"payload": {"asked": True, "field": "selling_point"},
|
||||
"_events": [{"type": "message", "message": _message_payload(gate)}],
|
||||
}, True
|
||||
strategy_payload = _coerce_strategy_args(args if isinstance(args, dict) else {})
|
||||
# 空卡会落成「只有标签没有正文」——拒绝,让模型把四字段写满再调
|
||||
if not all(strategy_payload.values()):
|
||||
@@ -2849,11 +3147,25 @@ def _dispatch_tool(
|
||||
)
|
||||
}
|
||||
}, False
|
||||
duration = 15
|
||||
try:
|
||||
duration = int(float(str((context.conversation.params or {}).get("duration") or "15").replace("秒", "").strip() or "15"))
|
||||
except (TypeError, ValueError):
|
||||
duration = 15
|
||||
selling_memory = context.conversation.memory if isinstance(context.conversation.memory, dict) else {}
|
||||
chosen_selling_point = str(selling_memory.get("selling_point") or "").strip()
|
||||
if chosen_selling_point and str(selling_memory.get("selling_point_mode") or "") == "manual":
|
||||
# 用户亲自给出的真实卖点是本轮的唯一 USP,模型只能围绕它补充画面证据,不能擅自替换。
|
||||
card["usp"] = chosen_selling_point
|
||||
video_prompt = (
|
||||
f"{video_prompt}\n\n【商家确认的核心卖点】{chosen_selling_point}\n"
|
||||
"整条视频必须围绕这个卖点展开,并用真实使用动作或素材可见细节证明;不得替换、夸大或新增未经确认的功效。"
|
||||
)
|
||||
raw_duration = _raw_script_duration(timeline=card["timeline"], prompt=video_prompt)
|
||||
if raw_duration is not None and raw_duration > 60:
|
||||
return {
|
||||
"payload": {"error": "脚本时长不能超过 60 秒。请把方案收束到 60 秒以内,再重新给出完整时间轴和出片指令。"}
|
||||
}, False
|
||||
duration = resolve_smart_video_duration(
|
||||
context.conversation,
|
||||
prompt=video_prompt,
|
||||
timeline=card["timeline"],
|
||||
)
|
||||
lo = max(20, round(duration * 3.4))
|
||||
hi = max(lo + 1, round(duration * 4))
|
||||
plan_payload = {
|
||||
|
||||
@@ -867,7 +867,7 @@ def _store_free_video_media(*, task: AITask, media: str = "", video_bytes: bytes
|
||||
if feature == "video_replace":
|
||||
mode_label = "角色复刻" if payload.get("replace_mode") == "character" else "商品复刻"
|
||||
asset_name = f"{subject}{mode_label}" if subject else mode_label
|
||||
elif feature == "omni_create":
|
||||
elif feature in {"omni_create", "omni_video_merge"}:
|
||||
asset_name = prompt[:255] or "全能创作视频"
|
||||
else:
|
||||
asset_name = prompt[:255] or "自由创作视频"
|
||||
@@ -883,7 +883,7 @@ def _store_free_video_media(*, task: AITask, media: str = "", video_bytes: bytes
|
||||
category=Asset.Category.FREE_CREATE,
|
||||
origin_task=task,
|
||||
# 全能创作成品只挂在会话结果卡上,不进资产库/专业创作用的视频列表。
|
||||
in_library=feature != "omni_create",
|
||||
in_library=feature not in {"omni_create", "omni_video_merge"},
|
||||
metadata={"feature": feature},
|
||||
)
|
||||
AssetFile.objects.create(
|
||||
|
||||
@@ -121,6 +121,15 @@ def poll_free_video_task(self, task_id: str, attempt: int = 0) -> str:
|
||||
return task_id
|
||||
|
||||
|
||||
@app.task(bind=True, max_retries=0)
|
||||
def merge_omni_video_segments_task(self, task_id: str) -> str:
|
||||
"""全能创作的分段视频仅在用户点「合并成片」后进入此任务。"""
|
||||
from apps.ai.creation import run_segmented_video_merge
|
||||
|
||||
run_segmented_video_merge(task_id)
|
||||
return task_id
|
||||
|
||||
|
||||
@app.task(bind=True, max_retries=0, queue=QUICK_QUEUE)
|
||||
def drain_asset_reviews_task(self) -> str:
|
||||
"""全平台人脸审核兜底:未送审自动送火山、审核中自动拉绿/红盾。
|
||||
|
||||
@@ -34,6 +34,8 @@ from .creation_agent import (
|
||||
get_creation_chat_model,
|
||||
get_video_gate_stage,
|
||||
has_creative_intent,
|
||||
infer_script_duration,
|
||||
plan_video_segments,
|
||||
is_continue_intent,
|
||||
is_greeting,
|
||||
is_pure_chitchat,
|
||||
@@ -943,6 +945,8 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
self.assertNotIn("generate_image", names)
|
||||
|
||||
def test_strategy_card_stops_with_step_confirm(self):
|
||||
self.conversation.memory = {"selling_point_ready": True, "selling_point_mode": "auto"}
|
||||
self.conversation.save(update_fields=["memory", "updated_at"])
|
||||
fake = FakeProvider([
|
||||
_tool_chunks("write_strategy", {"target": "油皮通勤人群", "trust": "真实使用反馈",
|
||||
"belief": "值得一试", "direction": "达人 UGC 口播"}),
|
||||
@@ -964,6 +968,21 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
# 策略闸门必须停下等人确认,不能同轮连写方案
|
||||
self.assertEqual(len(fake.calls), 1)
|
||||
|
||||
def test_selling_point_is_confirmed_before_strategy(self):
|
||||
fake = FakeProvider([
|
||||
_tool_chunks("write_strategy", {"target": "油皮通勤人群", "trust": "真实使用反馈",
|
||||
"belief": "值得一试", "direction": "达人 UGC 口播"}),
|
||||
])
|
||||
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"] == "elicit"
|
||||
]
|
||||
self.assertEqual(cards[-1]["payload"].get("interaction"), "selling_point_gate")
|
||||
self.assertFalse(any(event.get("message", {}).get("kind") == "strategy" for event in events))
|
||||
|
||||
def test_plan_emits_plan_and_step_confirm_only(self):
|
||||
fake = FakeProvider([
|
||||
_tool_chunks("write_plan", self._plan_args()),
|
||||
@@ -991,6 +1010,23 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
# 方案闸门停下,不能同轮出 Prompt/积分卡
|
||||
self.assertEqual(len(fake.calls), 1)
|
||||
|
||||
def test_manual_selling_point_is_locked_as_plan_usp(self):
|
||||
self.conversation.memory = {
|
||||
"selling_point_ready": True,
|
||||
"selling_point_mode": "manual",
|
||||
"selling_point": "一喷一擦去除厨房重油污",
|
||||
}
|
||||
self.conversation.save(update_fields=["memory", "updated_at"])
|
||||
fake = FakeProvider([_tool_chunks("write_plan", self._plan_args())])
|
||||
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.assertEqual(plan["payload"]["usp"], "一喷一擦去除厨房重油污")
|
||||
|
||||
def test_write_prompt_emits_document_and_requires_prompt_confirmation(self):
|
||||
fake = FakeProvider([
|
||||
_tool_chunks("write_prompt", {"video_prompt": "0-3秒 近景手持商品…"}),
|
||||
@@ -1015,6 +1051,9 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
"""模型若同轮连调 write_strategy+write_plan,只落策略闸门。"""
|
||||
from apps.ai.creation_agent import _parse_arguments # noqa: F401
|
||||
|
||||
self.conversation.memory = {"selling_point_ready": True, "selling_point_mode": "auto"}
|
||||
self.conversation.save(update_fields=["memory", "updated_at"])
|
||||
|
||||
# FakeProvider 一轮里多个 tool_call:模拟两个独立 rounds 不行,需单轮多 call。
|
||||
# 用手动合并:第一帧带两个 tool call deltas。
|
||||
def _multi_tool_chunks(*pairs):
|
||||
@@ -1174,6 +1213,27 @@ class VideoParamParsingTests(TestCase):
|
||||
# 当前视频工作流支持完整短剧,超出预设上限时仍需夹住。
|
||||
self.assertEqual(video_duration({"duration": "99 秒"}), 60)
|
||||
|
||||
def test_smart_duration_uses_final_plan_timestamp(self):
|
||||
timeline = [
|
||||
{"start": 0, "end": 2, "stage": "Hook"},
|
||||
{"start": 2, "end": 14, "stage": "正文"},
|
||||
{"start": 14, "end": 18, "stage": "证据"},
|
||||
{"start": 18, "end": 20, "stage": "CTA"},
|
||||
]
|
||||
self.assertEqual(infer_script_duration(timeline=timeline), 20)
|
||||
self.assertEqual(video_duration({"duration": "智能时长"}, timeline=timeline), 20)
|
||||
|
||||
def test_long_video_is_split_into_seedance_sized_segments(self):
|
||||
self.assertEqual(
|
||||
plan_video_segments(45, [{"end": 14}, {"end": 25}, {"end": 45}]),
|
||||
[
|
||||
{"index": 1, "start": 0, "end": 25, "duration": 25},
|
||||
{"index": 2, "start": 25, "end": 45, "duration": 20},
|
||||
],
|
||||
)
|
||||
self.assertEqual(plan_video_segments(60)[0]["duration"], 30)
|
||||
self.assertEqual(plan_video_segments(60)[1]["duration"], 30)
|
||||
|
||||
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": "没见过的模型"}), DEFAULT_VIDEO_MODEL)
|
||||
|
||||
@@ -30,6 +30,7 @@ from .creation import (
|
||||
cleanup_stale_agent_planning,
|
||||
finish_agent_planning,
|
||||
request_agent_cancel,
|
||||
start_segmented_video_merge,
|
||||
sync_generating_messages,
|
||||
team_agent_busy,
|
||||
)
|
||||
@@ -139,6 +140,11 @@ _WANTS_CARD_RE = re.compile(
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_WANTS_PRODUCT_PICKER_RE = re.compile(
|
||||
r"(?:商品|产品).{0,8}(?:列表|库)|(?:列表|库).{0,8}(?:商品|产品)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# 步骤确认卡下,用户往往不会去点「按这个继续」,而是自然地回一句
|
||||
# 「这个还行」「就这样」「没问题」。这些是确认,不应被误判成修改意见。
|
||||
_STEP_TEXT_CONFIRM_RE = re.compile(
|
||||
@@ -169,6 +175,50 @@ def _pending_chat_question(conversation: CreationConversation) -> CreationMessag
|
||||
return None
|
||||
|
||||
|
||||
def _open_product_picker(
|
||||
conversation: CreationConversation,
|
||||
user_text: str,
|
||||
pending: CreationMessage | None = None,
|
||||
):
|
||||
"""用户要求商品列表时,直接返回可点击的商品卡。"""
|
||||
if pending is not None and not bool((pending.payload or {}).get("submitted")):
|
||||
payload = dict(pending.payload or {})
|
||||
payload.update({"submitted": True, "superseded_by": "product_picker"})
|
||||
pending.payload = payload
|
||||
pending.save(update_fields=["payload", "updated_at"])
|
||||
user_message = append_message(conversation, role="user", text=user_text)
|
||||
field = {
|
||||
"key": "product",
|
||||
"label": _ASSET_CARD_LABELS.get("product", "选择要推广的商品"),
|
||||
"type": "asset",
|
||||
"required": True,
|
||||
"asset_types": ["product"],
|
||||
}
|
||||
picker = append_message(
|
||||
conversation,
|
||||
role="assistant",
|
||||
kind=CreationMessage.Kind.ELICIT,
|
||||
text=field["label"],
|
||||
payload={
|
||||
"interaction": "asset_picker",
|
||||
"phase": "pick",
|
||||
"fields": [field],
|
||||
"submitted": False,
|
||||
"answers": {},
|
||||
},
|
||||
)
|
||||
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
|
||||
conversation.save(update_fields=["agent_status", "updated_at"])
|
||||
return JsonResponse({
|
||||
"conversation_id": str(conversation.id),
|
||||
"agent_status": conversation.agent_status,
|
||||
"messages": [
|
||||
CreationMessageSerializer(user_message).data,
|
||||
CreationMessageSerializer(picker).data,
|
||||
],
|
||||
}, status=200)
|
||||
|
||||
|
||||
def _plot_twist_depth_continuation(depth: dict) -> str:
|
||||
"""让故事深度卡的回答直接进入对应的创作分支。"""
|
||||
if depth.get("value") == "smart":
|
||||
@@ -1694,6 +1744,28 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
raise ValidationError({"detail": "after_seq 必须是整数"}) from exc
|
||||
return Response(CreationMessageSerializer(queryset, many=True).data)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="merge-video-segments")
|
||||
def merge_video_segments(self, request, pk=None):
|
||||
"""用户确认后才合并多段成片;未点击前不下载视频、更不调用 ffmpeg。"""
|
||||
conversation = self.get_object()
|
||||
message_id = str(request.data.get("message_id") or "").strip()
|
||||
message = conversation.messages.filter(id=message_id).first() if message_id else None
|
||||
if message is None:
|
||||
return JsonResponse({"detail": "找不到要合并的视频片段"}, status=404)
|
||||
try:
|
||||
merge_task, generating = start_segmented_video_merge(
|
||||
conversation=conversation, message=message, user=request.user
|
||||
)
|
||||
except ValueError as exc:
|
||||
return JsonResponse({"detail": str(exc)}, status=400)
|
||||
try:
|
||||
from .tasks import merge_omni_video_segments_task
|
||||
|
||||
merge_omni_video_segments_task.apply_async(args=[str(merge_task.id)])
|
||||
except Exception: # noqa: BLE001 - 没有 worker 时前端轮询仍可看到任务,避免重复执行 ffmpeg
|
||||
logger.warning("omni video merge enqueue failed for %s", merge_task.id, exc_info=True)
|
||||
return JsonResponse({"message": CreationMessageSerializer(generating).data}, status=202)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="cancel")
|
||||
def cancel(self, request, pk=None):
|
||||
"""终止进行中的整理方案(agent planning)。
|
||||
@@ -1765,6 +1837,10 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
# 打招呼不应被误当成上一条素材追问的答案;让 agent 立刻简短回应即可。
|
||||
elif kind == "text" and text and not is_greeting(text):
|
||||
pending = _pending_chat_question(conversation)
|
||||
# 无论旧会话遗留了什么追问,只要用户要商品列表,就直接出可点击卡片。
|
||||
# 不能再交给模型 search_library 后用文字念出商品名。
|
||||
if _WANTS_PRODUCT_PICKER_RE.search(text):
|
||||
return _open_product_picker(conversation, text, pending)
|
||||
if pending is not None:
|
||||
payload = dict(pending.payload or {})
|
||||
if payload.get("interaction") == "step_confirm":
|
||||
@@ -2049,6 +2125,13 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
record_user_message = False
|
||||
# force_creative / continuation 已设;跳过后面普通 elicit 逻辑
|
||||
else:
|
||||
if payload.get("interaction") == "selling_point_gate":
|
||||
mode = str(answers.get("selling_point_mode") or "").strip().lower()
|
||||
selling_point = str(answers.get("selling_point") or "").strip()
|
||||
if mode not in {"manual", "auto"}:
|
||||
return JsonResponse({"detail": "请选择自己填写卖点或系统推荐"}, status=400)
|
||||
if mode == "manual" and not selling_point:
|
||||
return JsonResponse({"detail": "请先填写一个真实卖点,或选择系统推荐"}, status=400)
|
||||
payload["answers"] = answers
|
||||
payload["submitted"] = True
|
||||
card.payload = payload
|
||||
@@ -2078,6 +2161,25 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
continuation_instruction = _plot_twist_direction_continuation(
|
||||
conversation, payload, choice
|
||||
)
|
||||
elif payload.get("interaction") == "selling_point_gate":
|
||||
mode = str(answers.get("selling_point_mode") or "").strip().lower()
|
||||
selling_point = str(answers.get("selling_point") or "").strip()
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["selling_point_ready"] = True
|
||||
memory["selling_point_mode"] = mode
|
||||
memory["selling_point"] = selling_point if mode == "manual" else ""
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
text = ""
|
||||
record_user_message = False
|
||||
force_creative_turn = True
|
||||
continuation_instruction = (
|
||||
f"商家已确认核心卖点:【{selling_point}】。现在只调用 write_strategy 写创作策略,"
|
||||
"再等待商家确认;策略、后续方案和视频都必须围绕这个真实卖点,不要替换或夸大。"
|
||||
if mode == "manual"
|
||||
else "商家选择系统推荐卖点。现在只调用 write_strategy 写创作策略;"
|
||||
"从商品资料和现有素材中挑一个最容易被画面证明的真实核心卖点,不要虚构功效、价格或规格。"
|
||||
)
|
||||
elif payload.get("phase") == "gate":
|
||||
choice = str(answers.get("_asset_gate") or "").strip()
|
||||
if choice == "send":
|
||||
|
||||
@@ -601,6 +601,13 @@ export const api = {
|
||||
}
|
||||
);
|
||||
},
|
||||
/** 分段视频全部完成后,用户明确确认才触发服务端拼接。 */
|
||||
mergeCreationVideoSegments(id: string, messageId: string) {
|
||||
return request<{ message: CreationMessage }>(`/api/ai/creations/${id}/merge-video-segments/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ message_id: messageId }),
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 发一条消息 → 异步整理方案。成功 **202** `{conversation_id, agent_status}`,
|
||||
* 闸门短路径可能 **200** 带 `messages`;冲突 **409**(团队已有对话在整理方案)。
|
||||
|
||||
@@ -281,34 +281,81 @@
|
||||
|
||||
.omni-reply-custom-input {
|
||||
display: flex;
|
||||
flex: 1 1 220px;
|
||||
flex: 1 1 260px;
|
||||
gap: 6px;
|
||||
width: min(300px, 100%);
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.omni-reply-custom-input .input {
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.omni-reply-custom-input button {
|
||||
flex: 0 0 30px;
|
||||
width: 30px;
|
||||
flex: 0 0 36px;
|
||||
width: 36px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 小云雀式跟随追问:答案留在问题附近,尺寸不抢占对话空间。 */
|
||||
.omni-chat-question-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: min(300px, 100%);
|
||||
}
|
||||
|
||||
.omni-chat-question-input .input {
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
border-color: var(--border-faint);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.omni-chat-question-input button {
|
||||
flex: 0 0 36px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
color: var(--text-secondary);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.omni-chat-question-input button:hover:not(:disabled) {
|
||||
border-color: var(--heat-40);
|
||||
color: var(--heat);
|
||||
background: var(--heat-12);
|
||||
}
|
||||
|
||||
.omni-chat-question-input button:disabled {
|
||||
color: var(--black-alpha-48);
|
||||
background: var(--black-alpha-4);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
|
||||
.omni-chat-row.is-user .omni-chat-bubble {
|
||||
align-items: flex-end;
|
||||
border-color: var(--black);
|
||||
border-color: var(--black-alpha-12);
|
||||
border-bottom-right-radius: 4px;
|
||||
color: #fff;
|
||||
background: var(--black);
|
||||
color: var(--accent-black);
|
||||
background: color-mix(in srgb, var(--surface) 72%, transparent);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(115%);
|
||||
backdrop-filter: blur(14px) saturate(115%);
|
||||
}
|
||||
|
||||
.omni-result-card,
|
||||
@@ -1359,6 +1406,64 @@
|
||||
padding: 14px 18px 16px;
|
||||
}
|
||||
|
||||
/* 卖点确认在策略和脚本之前出现:输入真实卖点,或把筛选权交给系统。 */
|
||||
.omni-selling-point-card {
|
||||
box-sizing: border-box;
|
||||
width: min(760px, calc(100% - 44px));
|
||||
margin: 0 0 24px 44px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--r-md);
|
||||
background: var(--surface);
|
||||
box-shadow: inset 0 0 0 1px var(--border-faint);
|
||||
}
|
||||
|
||||
.omni-selling-point-card > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 58px;
|
||||
padding: 12px 16px;
|
||||
box-shadow: inset 0 -1px 0 var(--border-faint);
|
||||
}
|
||||
|
||||
.omni-selling-point-card header > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.omni-selling-point-card header strong {
|
||||
color: var(--accent-black);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.omni-selling-point-card header span,
|
||||
.omni-selling-point-selected {
|
||||
color: var(--black-alpha-56);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.omni-selling-point-card form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px 16px 16px;
|
||||
}
|
||||
|
||||
.omni-selling-point-card .input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.omni-selling-point-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.omni-selling-point-selected {
|
||||
padding: 14px 16px 16px;
|
||||
}
|
||||
|
||||
/* 剧情反转预设的方向选择:三个完整方向直接平铺,避免只留一句「我准备了三个方向」。 */
|
||||
.omni-direction-card {
|
||||
box-sizing: border-box;
|
||||
@@ -1497,6 +1602,20 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.omni-direction-confirm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.omni-direction-confirm > span {
|
||||
margin-right: auto;
|
||||
color: var(--black-alpha-56);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.omni-elicit-field > label {
|
||||
display: block;
|
||||
margin-bottom: 9px;
|
||||
@@ -2104,8 +2223,8 @@
|
||||
}
|
||||
|
||||
.omni-chat-row.is-user .omni-chat-stack.is-pending .omni-chat-bubble {
|
||||
background: #3a424e;
|
||||
border-color: #3a424e;
|
||||
background: color-mix(in srgb, var(--surface) 84%, transparent);
|
||||
border-color: var(--black-alpha-16);
|
||||
}
|
||||
|
||||
.omni-send-spinner {
|
||||
|
||||
@@ -41,7 +41,7 @@ type PromptBlock =
|
||||
| { type: "shot"; heading: string; fields: Array<{ label: string; value: string }>; text: string }
|
||||
| { type: "text"; heading: string; text: string };
|
||||
|
||||
type ReplyOption = { label: string; text: string };
|
||||
type ReplyOption = { label: string; text: string; action?: "upload" };
|
||||
|
||||
function renderInlineMarkdown(text: string): ReactNode[] {
|
||||
return text.split(/(\*\*[^*]+\*\*|`[^`]+`|\*[^*]+\*)/g).filter(Boolean).map((part, index) => {
|
||||
@@ -143,6 +143,10 @@ function stripNumericReplyInstruction(text: string): string {
|
||||
function numberedReplyOptions(text: string): ReplyOption[] {
|
||||
const matches = [...(text || "").matchAll(/(?:^|\n)\s*([1-3])[.、.]\s*(?:\*\*)?([^\n*]{1,80})/g)];
|
||||
if (matches.length < 2) return [];
|
||||
// 分镜、步骤、卖点清单也经常使用 1./2./3.;只有助手明确要求用户在这些项中选择时,
|
||||
// 才把编号转成操作按钮,避免把分镜 1–6 错当成三个可选方案。
|
||||
const hasChoiceInstruction = /(?:请|你)?(?:选择|选)(?:其中|一个|一项|下面|以下|第[一二三123]|\s*1\s*[/、,]\s*2)|(?:回复|输入)(?:数字|编号)\s*1\s*[/、,]\s*2|(?:下面|以下)(?:有|为)?(?:三|3)个?(?:方向|方案|选项)/.test(text || "");
|
||||
if (!hasChoiceInstruction) return [];
|
||||
return matches.slice(0, 3).map((match) => ({
|
||||
label: match[1],
|
||||
text: `选择第${match[1]}个方向`,
|
||||
@@ -150,10 +154,26 @@ function numberedReplyOptions(text: string): ReplyOption[] {
|
||||
}
|
||||
|
||||
function contextualReplyOptions(text: string): ReplyOption[] {
|
||||
if (/(?:两位|两个|多位|多个).{0,24}(?:人物|角色|模特|男士|女生).{0,80}(?:哪位|哪个|选择|用哪|出镜)/i.test(text)) {
|
||||
return [
|
||||
{ label: "1", text: "选择第 1 位人物出镜" },
|
||||
{ label: "2", text: "选择第 2 位人物出镜" },
|
||||
{ label: "你来决定", text: "请根据当前脚本帮我选择更合适的人物" },
|
||||
];
|
||||
}
|
||||
// 人物参考和商品参考是两件事。已锁定人物后,助手若提到实物图,不能又给出「上传人物图」——
|
||||
// 这会让用户误以为刚上传的参考没有生效。
|
||||
if (/实物图|商品图|产品图|商品外观|产品外观|产品素材|耳机(?:图|外观)?|IP设计/i.test(text)) {
|
||||
return [
|
||||
{ label: "上传商品实物图", text: "我上传商品实物图", action: "upload" },
|
||||
{ label: "按当前描述继续", text: "先按当前商品描述继续,不再补图" },
|
||||
{ label: "换一个商品", text: "我想换一个商品来创作" },
|
||||
];
|
||||
}
|
||||
if (/颜色|色号|色彩|SKU|款式|几种/i.test(text)) {
|
||||
return [
|
||||
{ label: "补充颜色和顺序", text: "我来补充每个颜色和展示顺序" },
|
||||
{ label: "上传各款实物图", text: "我补充各颜色/款式的实物图" },
|
||||
{ label: "上传各款实物图", text: "我补充各颜色/款式的实物图", action: "upload" },
|
||||
{ label: "先按当前主款做", text: "先按当前主款做,其他颜色后面再补" },
|
||||
];
|
||||
}
|
||||
@@ -166,14 +186,14 @@ function contextualReplyOptions(text: string): ReplyOption[] {
|
||||
}
|
||||
if (/人物|角色|模特|出镜/i.test(text)) {
|
||||
return [
|
||||
{ label: "上传人物图", text: "我上传人物参考图" },
|
||||
{ label: "上传人物图", text: "我上传人物参考图", action: "upload" },
|
||||
{ label: "由你设定角色", text: "你先帮我设定一个合适的角色" },
|
||||
{ label: "不需要人物", text: "这条先不需要人物出镜" },
|
||||
];
|
||||
}
|
||||
if (/场景|地点|背景|在哪/i.test(text)) {
|
||||
return [
|
||||
{ label: "上传场景图", text: "我上传场景参考图" },
|
||||
{ label: "上传场景图", text: "我上传场景参考图", action: "upload" },
|
||||
{ label: "你来推荐场景", text: "你按商品和预设推荐场景" },
|
||||
{ label: "用干净日常场景", text: "先用干净自然的日常场景" },
|
||||
];
|
||||
@@ -188,30 +208,58 @@ function contextualReplyOptions(text: string): ReplyOption[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
function uploadReplyText(option: ReplyOption): string {
|
||||
if (/人物|角色|模特/.test(option.label)) {
|
||||
return "我上传人物参考图,请使用刚上传的这张图作为本次视频唯一的出镜人物参考,不需要再让我选择其他人物。";
|
||||
}
|
||||
if (/商品|实物|款/.test(option.label)) {
|
||||
return "我上传商品实物图,请使用刚上传的图片作为本次视频的商品外观参考。";
|
||||
}
|
||||
if (/场景/.test(option.label)) {
|
||||
return "我上传场景参考图,请使用刚上传的图片作为本次视频的主要场景参考。";
|
||||
}
|
||||
return option.text;
|
||||
}
|
||||
|
||||
function replyOptionsForMessage(text: string, payload: Record<string, unknown>): ReplyOption[] {
|
||||
const numbered = numberedReplyOptions(text);
|
||||
if (numbered.length) return numbered;
|
||||
// 文本正在明确索要的内容永远优先于旧的 reply_options。
|
||||
// Agent 的历史 payload 可能残留「上传人物图」,但当前气泡已改成要商品实物图;
|
||||
// 此时沿用旧按钮会让用户上传错素材。
|
||||
const contextual = contextualReplyOptions(text);
|
||||
if (contextual.length) return contextual;
|
||||
const stored = payload.reply_options;
|
||||
if (Array.isArray(stored)) {
|
||||
const options = stored
|
||||
.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object")
|
||||
.map((item) => ({ label: String(item.label || "").trim(), text: String(item.text || "").trim() }))
|
||||
.map((item) => {
|
||||
const label = String(item.label || "").trim();
|
||||
return {
|
||||
label,
|
||||
text: String(item.text || "").trim(),
|
||||
// 任何引导里的“上传图片/实物图/素材图”都走本地文件选择器,避免同一套对话里有的能传、有的只是文字回复。
|
||||
action: /上传.*(?:图|图片|素材)|(?:图|图片|素材).*上传/.test(label) ? "upload" as const : undefined,
|
||||
};
|
||||
})
|
||||
.filter((item) => item.label && item.text)
|
||||
.slice(0, 3);
|
||||
const legacyGeneric = ["继续完善方案", "换个场景", "改卖点", "按这个方向出图", "改人物状态"];
|
||||
if (options.length && !options.every((item) => legacyGeneric.includes(item.label))) return options;
|
||||
}
|
||||
return contextualReplyOptions(text);
|
||||
return [];
|
||||
}
|
||||
|
||||
function ReplyActions({
|
||||
options,
|
||||
disabled,
|
||||
onSubmit,
|
||||
onUpload,
|
||||
}: {
|
||||
options: ReplyOption[];
|
||||
disabled?: boolean;
|
||||
onSubmit: (text: string) => void;
|
||||
onUpload: (option: ReplyOption) => void;
|
||||
}) {
|
||||
const [customIdea, setCustomIdea] = useState("");
|
||||
const submitCustomIdea = (event: FormEvent<HTMLFormElement>) => {
|
||||
@@ -230,7 +278,10 @@ function ReplyActions({
|
||||
className={index === 0 && options.length < 3 ? "primary" : ""}
|
||||
key={option.text}
|
||||
disabled={disabled}
|
||||
onClick={() => onSubmit(option.text)}
|
||||
onClick={() => {
|
||||
if (option.action === "upload") onUpload(option);
|
||||
else onSubmit(option.text);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
@@ -522,8 +573,50 @@ type SessionAsset = {
|
||||
src?: string;
|
||||
promptTitle?: string;
|
||||
promptBody?: string;
|
||||
documentDescription?: string;
|
||||
};
|
||||
|
||||
function formatDocumentTime(value: unknown): string {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? (Number.isInteger(number) ? String(number) : number.toFixed(1)) : "—";
|
||||
}
|
||||
|
||||
function strategyDocumentBody(payload: Record<string, unknown>): string {
|
||||
const sections: Array<[string, string]> = [
|
||||
["这条视频给谁看", strategyField(payload, "target", "audience", "who", "给谁看", "目标人群")],
|
||||
["用户为什么相信", strategyField(payload, "trust", "credibility", "为什么相信", "信任")],
|
||||
["希望用户相信什么", strategyField(payload, "belief", "希望相信", "想让他信什么", "认知")],
|
||||
["创作方向", strategyField(payload, "direction", "创作方向", "方向", "style")],
|
||||
];
|
||||
return ["# 创作策略摘要", ...sections.map(([label, value]) => `## ${label}\n${value || "—"}`)].join("\n\n");
|
||||
}
|
||||
|
||||
function planDocumentBody(payload: Record<string, unknown>): string {
|
||||
const usp = strategyField(payload, "usp", "主打卖点", "卖点");
|
||||
const points = coercePlanPoints(payload.points);
|
||||
const timeline = (payload.timeline as PlanTimelineItem[] | undefined) || [];
|
||||
const voice = coerceVoiceChars(payload.voice_chars);
|
||||
const pointText = points.length ? points.map((point) => `- ${point}`).join("\n") : "—";
|
||||
const timelineText = timeline.length
|
||||
? timeline.map((item) => `- ${formatDocumentTime(item.start)}–${formatDocumentTime(item.end)} 秒 · ${item.stage}${item.desc ? `:${item.desc}` : ""}`).join("\n")
|
||||
: "—";
|
||||
const voiceText = voice.length === 2 ? `${voice[0]}–${voice[1]} 字,预计覆盖约 90% 时长` : "—";
|
||||
return [
|
||||
"# 视频最终方案",
|
||||
`## 主打卖点 USP\n${usp || "—"}`,
|
||||
`## 核心支撑\n${pointText}`,
|
||||
`## 时间轴\n${timelineText}`,
|
||||
`## 口播目标\n${voiceText}`,
|
||||
`## 出片准备\n已整合生成参数与 ${String(payload.ref_count ?? 0)} 组参考素材。`,
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
function documentDescription(title: string): string {
|
||||
if (title.includes("创作策略")) return "创作策略 · 点击查看";
|
||||
if (title.includes("视频最终方案")) return "视频方案 · 点击查看";
|
||||
return "出片指令 · 点击查看";
|
||||
}
|
||||
|
||||
/** 从本会话已加载 messages(+pinned_refs / 会话上传)聚合聊天资产,不另打 BE。 */
|
||||
function collectSessionAssets(
|
||||
messages: CreationMessage[],
|
||||
@@ -579,8 +672,43 @@ function collectSessionAssets(
|
||||
|
||||
}
|
||||
|
||||
// 视频确认卡保存的是最终用于出片的 Prompt。它不是聊天正文,而是本会话
|
||||
// 的一份可复查资源;右侧资源栏始终只保留最新的一版,避免多次改稿堆出一串副本。
|
||||
// 策略、方案、Prompt 都是会话的正式产物。资源栏只保留各自最新的一版,
|
||||
// 避免多次修改后堆出一串相同文档。
|
||||
const latestDocument = (kind: CreationMessage["kind"]) => {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (message.kind !== kind) continue;
|
||||
return { message, payload: message.payload || {}, index };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const strategy = latestDocument("strategy");
|
||||
if (strategy) {
|
||||
const title = "创作策略摘要.md";
|
||||
push({
|
||||
key: `strategy:${strategy.message.id || strategy.index}`,
|
||||
kind: "document",
|
||||
title,
|
||||
promptTitle: title,
|
||||
promptBody: strategyDocumentBody(strategy.payload),
|
||||
documentDescription: documentDescription(title),
|
||||
});
|
||||
}
|
||||
|
||||
const plan = latestDocument("plan");
|
||||
if (plan) {
|
||||
const title = "视频最终方案.md";
|
||||
push({
|
||||
key: `plan:${plan.message.id || plan.index}`,
|
||||
kind: "document",
|
||||
title,
|
||||
promptTitle: title,
|
||||
promptBody: planDocumentBody(plan.payload),
|
||||
documentDescription: documentDescription(title),
|
||||
});
|
||||
}
|
||||
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
const payload = message.payload || {};
|
||||
@@ -594,6 +722,7 @@ function collectSessionAssets(
|
||||
title: String(payload.title || "视频生成Prompt.md"),
|
||||
promptTitle: String(payload.title || "视频生成Prompt.md"),
|
||||
promptBody: body,
|
||||
documentDescription: documentDescription(String(payload.title || "视频生成Prompt.md")),
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -913,12 +1042,15 @@ function ElicitCard({
|
||||
message,
|
||||
disabled,
|
||||
onSubmit,
|
||||
onChatAnswer,
|
||||
}: {
|
||||
message: CreationMessage;
|
||||
disabled: boolean;
|
||||
/** answers 给模型读(人话),refs 给后端取卖点和参考图。**选素材必须两样都回**,
|
||||
只回名字的话同名素材会配错货。 */
|
||||
onSubmit: (answers: Record<string, string | string[]>, refs: CreationRef[]) => void;
|
||||
/** 普通追问直接在气泡下回答,仍走 text 链路以保留用户消息与上下文。 */
|
||||
onChatAnswer: (text: string) => void;
|
||||
}) {
|
||||
const fields = ((message.payload.fields as CreationField[] | undefined) || []).filter(Boolean);
|
||||
const submitted = Boolean(message.payload.submitted);
|
||||
@@ -929,6 +1061,7 @@ function ElicitCard({
|
||||
const [assetOptions, setAssetOptions] = useState<Record<string, CreationRef[]>>({});
|
||||
const [assetPicked, setAssetPicked] = useState<Record<string, CreationRef>>({});
|
||||
const [customDirection, setCustomDirection] = useState("");
|
||||
const [chatAnswer, setChatAnswer] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (submitted || interaction === "chat" || interaction === "step_confirm") return;
|
||||
@@ -972,6 +1105,58 @@ function ElicitCard({
|
||||
);
|
||||
}
|
||||
|
||||
if (interaction === "selling_point_gate") {
|
||||
const sellingPoint = typeof answers.selling_point === "string" ? answers.selling_point : "";
|
||||
const savedMode = String(saved.selling_point_mode || "");
|
||||
const submitManual = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const value = sellingPoint.trim();
|
||||
if (!value || disabled) return;
|
||||
onSubmit({ selling_point_mode: "manual", selling_point: value }, []);
|
||||
};
|
||||
return (
|
||||
<div className="omni-elicit-slot">
|
||||
<section className={`omni-selling-point-card${submitted ? " is-submitted" : ""}`}>
|
||||
<header>
|
||||
<div>
|
||||
<strong>先确定核心卖点</strong>
|
||||
<span>它会贯穿后面的策略、脚本和视频画面</span>
|
||||
</div>
|
||||
</header>
|
||||
{submitted ? (
|
||||
<p className="omni-selling-point-selected">
|
||||
{savedMode === "auto" ? "已交给系统从商品和素材中推荐卖点" : `已使用:${String(saved.selling_point || "")}`}
|
||||
</p>
|
||||
) : (
|
||||
<form onSubmit={submitManual}>
|
||||
<input
|
||||
className="input"
|
||||
value={sellingPoint}
|
||||
disabled={disabled}
|
||||
placeholder="输入你最想让用户记住的真实卖点…"
|
||||
aria-label="输入商品卖点"
|
||||
onChange={(event) => setAnswers((prev) => ({ ...prev, selling_point: event.target.value }))}
|
||||
/>
|
||||
<div className="omni-selling-point-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-ghost"
|
||||
disabled={disabled}
|
||||
onClick={() => onSubmit({ selling_point_mode: "auto", selling_point: "" }, [])}
|
||||
>
|
||||
系统推荐卖点
|
||||
</button>
|
||||
<button type="submit" className="btn btn-sm btn-primary" disabled={disabled || !sellingPoint.trim()}>
|
||||
使用这个卖点
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (interaction === "plot_twist_directions") {
|
||||
const directions = Array.isArray(message.payload.directions)
|
||||
? message.payload.directions
|
||||
@@ -986,12 +1171,16 @@ function ElicitCard({
|
||||
}))
|
||||
.filter((item) => item.id)
|
||||
: [];
|
||||
const selected = String(saved.story_direction || "");
|
||||
// 选卡片只是本地暂存;明确点「确认选择」后才真正提交并开始整理下一步。
|
||||
const selected = String(answers.story_direction || saved.story_direction || "");
|
||||
const selectedDirection = directions.find(
|
||||
(direction) => selected === direction.id || selected === direction.title,
|
||||
);
|
||||
const submitCustomDirection = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const idea = customDirection.trim();
|
||||
if (!idea || disabled) return;
|
||||
onSubmit({ story_direction: idea }, []);
|
||||
setAnswers((prev) => ({ ...prev, story_direction: idea }));
|
||||
setCustomDirection("");
|
||||
};
|
||||
return (
|
||||
@@ -1000,7 +1189,7 @@ function ElicitCard({
|
||||
<header className="omni-direction-head">
|
||||
<div>
|
||||
<strong>三个剧情反转方向</strong>
|
||||
<span>直接选一个,我会继续展开成完整方案</span>
|
||||
<span>选择方向后确认,再继续展开完整方案</span>
|
||||
</div>
|
||||
</header>
|
||||
<div className="omni-direction-list">
|
||||
@@ -1012,7 +1201,7 @@ function ElicitCard({
|
||||
key={direction.id}
|
||||
className={isSelected ? "is-selected" : ""}
|
||||
disabled={disabled || submitted}
|
||||
onClick={() => onSubmit({ story_direction: direction.id }, [])}
|
||||
onClick={() => setAnswers((prev) => ({ ...prev, story_direction: direction.id }))}
|
||||
>
|
||||
<span className="omni-direction-index">方向 {index + 1}</span>
|
||||
<strong>{direction.title}</strong>
|
||||
@@ -1025,17 +1214,40 @@ function ElicitCard({
|
||||
})}
|
||||
</div>
|
||||
{!submitted ? (
|
||||
<form className="omni-direction-custom" onSubmit={submitCustomDirection}>
|
||||
<input
|
||||
className="input"
|
||||
value={customDirection}
|
||||
disabled={disabled}
|
||||
onChange={(event) => setCustomDirection(event.target.value)}
|
||||
placeholder="或者写下你自己的剧情想法…"
|
||||
aria-label="输入自己的剧情想法"
|
||||
/>
|
||||
<button type="submit" disabled={disabled || !customDirection.trim()}>使用这个想法</button>
|
||||
</form>
|
||||
<>
|
||||
<form className="omni-direction-custom" onSubmit={submitCustomDirection}>
|
||||
<input
|
||||
className="input"
|
||||
value={customDirection}
|
||||
disabled={disabled}
|
||||
onChange={(event) => setCustomDirection(event.target.value)}
|
||||
placeholder="或者写下你自己的剧情想法…"
|
||||
aria-label="输入自己的剧情想法"
|
||||
/>
|
||||
<button type="submit" disabled={disabled || !customDirection.trim()}>选用这个想法</button>
|
||||
</form>
|
||||
<div className="omni-direction-confirm">
|
||||
<span>{selectedDirection ? `已选:${selectedDirection.title}` : selected ? "已选自定义剧情方向" : "请先选择一个方向"}</span>
|
||||
{selected ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-ghost"
|
||||
disabled={disabled}
|
||||
onClick={() => setAnswers((prev) => ({ ...prev, story_direction: "" }))}
|
||||
>
|
||||
重新选择
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-primary"
|
||||
disabled={disabled || !selected}
|
||||
onClick={() => onSubmit({ story_direction: selected }, [])}
|
||||
>
|
||||
确认选择
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
@@ -1102,6 +1314,13 @@ function ElicitCard({
|
||||
|
||||
if (interaction === "chat") {
|
||||
const question = message.text || fields[0]?.label || "这项你想怎么定?";
|
||||
const submitChatAnswer = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const text = chatAnswer.trim();
|
||||
if (!text || disabled) return;
|
||||
onChatAnswer(text);
|
||||
setChatAnswer("");
|
||||
};
|
||||
return (
|
||||
<div className="omni-chat-row agent">
|
||||
<span className="omni-chat-avatar">
|
||||
@@ -1113,11 +1332,19 @@ function ElicitCard({
|
||||
</div>
|
||||
{!submitted ? (
|
||||
<div className="omni-reply-guide" aria-label="回复操作">
|
||||
<div className="omni-reply-guide-options">
|
||||
<button type="button" className="primary" onClick={() => document.getElementById("omniSessionPrompt")?.focus()}>
|
||||
现在填写
|
||||
<form className="omni-chat-question-input" onSubmit={submitChatAnswer}>
|
||||
<input
|
||||
className="input"
|
||||
value={chatAnswer}
|
||||
disabled={disabled}
|
||||
onChange={(event) => setChatAnswer(event.target.value)}
|
||||
placeholder="输入你的想法…"
|
||||
aria-label="回答这个问题"
|
||||
/>
|
||||
<button type="submit" aria-label="发送回答" disabled={disabled || !chatAnswer.trim()}>
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1324,8 +1551,12 @@ function ConfirmCard({
|
||||
|
||||
function ResultCard({
|
||||
payload,
|
||||
onMerge,
|
||||
merging,
|
||||
}: {
|
||||
payload: Record<string, unknown>;
|
||||
onMerge?: () => void;
|
||||
merging?: boolean;
|
||||
}) {
|
||||
const assets = (payload.assets as Array<Record<string, string>> | undefined) || [];
|
||||
const first = assets[0] || {};
|
||||
@@ -1340,7 +1571,7 @@ function ResultCard({
|
||||
const cover = asset.cover || asset.url || "";
|
||||
const url = asset.url || cover;
|
||||
const video = asset.type === "video";
|
||||
const label = assets.length > 1 ? `生成结果 ${index + 1}` : "生成结果";
|
||||
const label = String(asset.label || (assets.length > 1 ? `第 ${index + 1} 段` : "生成结果"));
|
||||
return (
|
||||
<figure className="omni-result-tile" key={asset.id || `${url}-${index}`}>
|
||||
<button
|
||||
@@ -1375,9 +1606,22 @@ function ResultCard({
|
||||
</div>
|
||||
<div className="omni-result-info">
|
||||
<div>
|
||||
<strong>{isGeneratedVideo ? "视频已生成" : assets.length > 1 ? `已生成 ${assets.length} 张图` : "图片已生成"}</strong>
|
||||
<small>{meta}</small>
|
||||
<strong>{
|
||||
isGeneratedVideo
|
||||
? payload.needs_merge ? `已生成 ${assets.length} 段视频` : "视频已生成"
|
||||
: assets.length > 1 ? `已生成 ${assets.length} 张图` : "图片已生成"
|
||||
}</strong>
|
||||
<small>{payload.needs_merge ? "请先预览片段,确认后再合并成片" : meta}</small>
|
||||
</div>
|
||||
{payload.needs_merge ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={merging || payload.merge_state === "queued" || payload.merge_state === "processing" || payload.merge_state === "completed"}
|
||||
onClick={onMerge}
|
||||
>
|
||||
{payload.merge_state === "completed" ? "已合并" : merging || payload.merge_state === "queued" || payload.merge_state === "processing" ? "正在合并" : "合并成片"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<MediaLightbox
|
||||
open={Boolean(preview?.src)}
|
||||
@@ -1674,7 +1918,10 @@ function withoutLegacyGateArtifacts(list: CreationMessage[]): CreationMessage[]
|
||||
}
|
||||
|
||||
function ProcessCard({ payload }: { payload: Record<string, unknown> }) {
|
||||
const isVideo = payload.kind === "video";
|
||||
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;
|
||||
return (
|
||||
<section className="omni-result-card omni-process-card">
|
||||
<div className="omni-result-media">
|
||||
@@ -1687,8 +1934,8 @@ function ProcessCard({ payload }: { payload: Record<string, unknown> }) {
|
||||
</div>
|
||||
<div className="omni-result-info">
|
||||
<div>
|
||||
<strong>{isVideo ? "正在生成视频" : "正在出图"}</strong>
|
||||
<small>{isVideo ? "方案编译中,请稍候" : "画面生成中,请稍候"}</small>
|
||||
<strong>{isMerge ? "正在合并成片" : isSegmentedVideo ? `正在生成 ${segmentCount || 2} 段视频` : isVideo ? "正在生成视频" : "正在出图"}</strong>
|
||||
<small>{isMerge ? "正在拼接已确认的片段" : isSegmentedVideo ? "片段完成后会先展示给你确认" : isVideo ? "方案编译中,请稍候" : "画面生成中,请稍候"}</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1755,6 +2002,8 @@ export function OmniSessionPage({
|
||||
const [sessionUploads, setSessionUploads] = useState<CreationRef[]>(firstUploads || []);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
// 对话引导里的「上传人物图」要直达系统文件选择器,而不是绕回通用素材菜单。
|
||||
const quickUploadReplyRef = useRef<ReplyOption | null>(null);
|
||||
const composerRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [liveText, setLiveText] = useState("");
|
||||
@@ -1768,6 +2017,7 @@ export function OmniSessionPage({
|
||||
const mentionWrapRef = useRef<HTMLDivElement>(null);
|
||||
const [uploadMenuOpen, setUploadMenuOpen] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [mergingMessageIds, setMergingMessageIds] = useState<string[]>([]);
|
||||
const [stopping, setStopping] = useState(false);
|
||||
const [promptView, setPromptView] = useState<{ title: string; body: string } | null>(null);
|
||||
const [assetsOpen, setAssetsOpen] = useState(false);
|
||||
@@ -2354,6 +2604,24 @@ export function OmniSessionPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleMergeSegments = async (message: CreationMessage) => {
|
||||
if (mergingMessageIds.includes(message.id)) return;
|
||||
setMergingMessageIds((prev) => [...prev, message.id]);
|
||||
try {
|
||||
const result = await api.mergeCreationVideoSegments(conversationId, message.id);
|
||||
setMessages((prev) => [
|
||||
...prev.map((item) => item.id === message.id
|
||||
? { ...item, payload: { ...item.payload, merge_state: "queued" } }
|
||||
: item),
|
||||
result.message,
|
||||
]);
|
||||
notify("info", "正在合并成片");
|
||||
} catch (error) {
|
||||
notify("error", (error as Error).message || "合并失败,请重试");
|
||||
setMergingMessageIds((prev) => prev.filter((id) => id !== message.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = () => {
|
||||
// 顺序要紧:先判 streaming 再清输入框。反过来的话,流式期间敲一次回车
|
||||
// 会把已经打好的内容清空,消息却没发出去。
|
||||
@@ -2462,6 +2730,7 @@ export function OmniSessionPage({
|
||||
);
|
||||
void send({ kind: "elicit_answer", reply_to: message.id, answers, refs });
|
||||
}}
|
||||
onChatAnswer={(text) => void send({ kind: "text", text })}
|
||||
/>
|
||||
);
|
||||
case "strategy":
|
||||
@@ -2509,6 +2778,8 @@ export function OmniSessionPage({
|
||||
<ResultCard
|
||||
key={message.clientKey || message.id}
|
||||
payload={message.payload}
|
||||
merging={mergingMessageIds.includes(message.id)}
|
||||
onMerge={message.payload?.needs_merge ? () => void handleMergeSegments(message) : undefined}
|
||||
/>
|
||||
);
|
||||
default: {
|
||||
@@ -2545,6 +2816,11 @@ export function OmniSessionPage({
|
||||
options={replyOptions}
|
||||
disabled={streaming}
|
||||
onSubmit={(text) => void send({ kind: "text", text })}
|
||||
onUpload={(option) => {
|
||||
if (streaming || uploading) return;
|
||||
quickUploadReplyRef.current = option;
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -2696,6 +2972,7 @@ export function OmniSessionPage({
|
||||
disabled={uploading || streaming}
|
||||
onClick={() => {
|
||||
if (uploading || streaming) return;
|
||||
quickUploadReplyRef.current = null;
|
||||
setUploadMenuOpen((open) => !open);
|
||||
setMentionMenuOpen(false);
|
||||
}}
|
||||
@@ -2720,6 +2997,7 @@ export function OmniSessionPage({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
quickUploadReplyRef.current = null;
|
||||
setUploadMenuOpen(false);
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
@@ -2747,6 +3025,8 @@ export function OmniSessionPage({
|
||||
if (!images.length) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const quickReply = quickUploadReplyRef.current;
|
||||
const uploadedRefs: CreationRef[] = [];
|
||||
for (const file of images) {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
@@ -2759,14 +3039,22 @@ export function OmniSessionPage({
|
||||
cover: data.thumb_url || data.url,
|
||||
};
|
||||
setSessionUploads((prev) => (prev.some((item) => item.id === ref.id) ? prev : [...prev, ref]));
|
||||
setPendingRefs((prev) => {
|
||||
if (prev.some((item) => item.id === ref.id) || prev.length >= MENTION_REF_LIMIT) return prev;
|
||||
return [...prev, ref];
|
||||
});
|
||||
uploadedRefs.push(ref);
|
||||
if (!quickReply) {
|
||||
setPendingRefs((prev) => {
|
||||
if (prev.some((item) => item.id === ref.id) || prev.length >= MENTION_REF_LIMIT) return prev;
|
||||
return [...prev, ref];
|
||||
});
|
||||
}
|
||||
}
|
||||
notify("success", images.length > 1 ? "素材已上传并通过审核" : "素材已上传并通过审核");
|
||||
if (quickReply && uploadedRefs.length) {
|
||||
quickUploadReplyRef.current = null;
|
||||
void send({ kind: "text", text: uploadReplyText(quickReply), refs: uploadedRefs });
|
||||
}
|
||||
} catch (error) {
|
||||
notify("error", (error as Error).message);
|
||||
quickUploadReplyRef.current = null;
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
@@ -2857,7 +3145,7 @@ export function OmniSessionPage({
|
||||
</span>
|
||||
<div>
|
||||
<strong>{promptView.title}</strong>
|
||||
<small>出片指令 · 可直接给生成模型用</small>
|
||||
<small>{documentDescription(promptView.title)}</small>
|
||||
</div>
|
||||
<button type="button" onClick={() => setPromptView(null)} aria-label="关闭">
|
||||
<X />
|
||||
@@ -3020,7 +3308,7 @@ export function OmniSessionPage({
|
||||
</span>
|
||||
<span className="omni-assets-meta">
|
||||
<strong>{asset.title}</strong>
|
||||
<small>文档 · 点击查看</small>
|
||||
<small>{asset.documentDescription || "文档 · 点击查看"}</small>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user