优化全能创作
Deploy dev / deploy (push) Successful in 38s

This commit is contained in:
Azmat@qq.com
2026-09-29 10:46:59 +08:00
parent 4421706290
commit 1130ecd4ba
9 changed files with 574 additions and 65 deletions
+151 -14
View File
@@ -65,6 +65,8 @@ CREATION_AGENT_MODEL_TIMEOUT_SECONDS = 180
CREATION_AGENT_TURN_TIMEOUT_SECONDS = 420
# 输出被 max_tokens 截断后最多让模型重写几次;再失败就明确告诉用户,不再闷着重试到超时。
MAX_TRUNCATION_RETRIES = 2
# 已明确要求推进创作时,模型若只回说明文字而没调工具,最多强制纠正两次。
MAX_PROGRESS_TOOL_RETRIES = 2
LONG_VIDEO_DURATION_SLACK_SECONDS = 2
# 单条用户消息最多触发一次计费生成(契约 §4)
MAX_BILLED_GENERATIONS = 1
@@ -145,6 +147,16 @@ TRUNCATION_GIVE_UP_NOTICE = (
"可以把时长改短一点,或直接点「按这个继续」让我按更紧凑的结构重写一次。"
)
PROGRESS_TOOL_RETRY_INSTRUCTION = (
"用户已经明确授权继续创作,本轮不能用说明文字、建议或‘继续完善方案’结束。"
"信息不足就调用 ask_user;信息足够就立即调用 write_strategy,并在同一轮继续调用 write_plan,"
"最终必须落下可见的视频架构卡。不要输出工具调用以外的解释文字。"
)
PROGRESS_TOOL_GIVE_UP_NOTICE = (
"这次没有成功整理出视频方案,已经停止。请把刚才的要求再发一次,我会直接生成方案卡。"
)
def long_video_script_covers_requested_duration(
raw_duration: int | None,
@@ -195,6 +207,17 @@ def set_public_agent_progress(
):
return
history = list(memory.get("agent_progress_history") or [])
# reasoning 可能在「理解需求 / 商品 / 角色」之间来回切换。再次回到同一阶段时
# 把旧位置移除后放到末尾,既保留当前顺序,也不让加载卡重复刷同一句话。
history = [
item
for item in history
if not (
isinstance(item, dict)
and str(item.get("phase") or "") == phase
and str(item.get("detail_key") or "") == detail_key
)
]
history.append({"phase": phase, "detail_key": detail_key})
memory["agent_progress_history"] = history[-12:]
memory["agent_progress_phase"] = phase
@@ -1446,6 +1469,70 @@ def build_cast_role_briefs(count: int, appearance_prompt: str = "") -> list[str]
return [_character_only_appearance_text(item)[:500] for item in briefs[:count]]
_FEMALE_CAST_WORDS = r"女性|女生|女人|女孩|女演员|女模特|女士|女主|妈妈|母亲|女儿|姐姐|妹妹"
_MALE_CAST_WORDS = r"男性|男生|男人|男孩|男演员|男模特|男士|男主|爸爸|父亲|儿子|哥哥|弟弟"
_CAST_COUNT_WORDS = {"一": 1, "1": 1, "两": 2, "二": 2, "2": 2, "三": 3, "3": 3, "四": 4, "4": 4}
def _cast_gender_from_text(text: str) -> str:
"""只读明确的角色性别描述;不从商品或风格词推断。"""
female = bool(re.search(_FEMALE_CAST_WORDS, text))
male = bool(re.search(_MALE_CAST_WORDS, text))
if female == male:
return ""
return "女性" if female else "男性"
def _shared_cast_gender_from_text(text: str, total: int) -> str:
"""仅当文本明确说整组角色同一性别时,才把性别约束复制给每张定妆图。"""
matches = re.finditer(
rf"(?<!\d)([一1两二2三3四4])\s*(?:位|名|个|人)?\s*(?:成年|年轻|中年|年长)?\s*"
rf"({_FEMALE_CAST_WORDS}|{_MALE_CAST_WORDS})",
text,
)
genders = {
_cast_gender_from_text(match.group(2))
for match in matches
if _CAST_COUNT_WORDS.get(match.group(1)) == total
}
genders.discard("")
if len(genders) == 1:
return genders.pop()
if genders:
return ""
if total == 2:
if re.search(r"母女|姐妹|闺蜜|双女主", text) and not re.search(r"父子|兄弟|双男主|一男一女|男女主", text):
return "女性"
if re.search(r"父子|兄弟|双男主", text) and not re.search(r"母女|姐妹|闺蜜|双女主|一男一女|男女主", text):
return "男性"
return ""
def _cast_gender_requirements(
conversation: CreationConversation,
role_briefs: list[str],
appearance_prompt: str,
) -> list[str]:
"""外观输入优先,其次读取已确认的视频 Prompt;只传性别,不带商品文案。"""
total = len(role_briefs)
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
sources = [
appearance_prompt,
str(memory.get("pending_video_prompt") or ""),
]
if getattr(conversation, "created_at", None):
sources.extend(
str(item or "")
for item in conversation.messages.filter(role=CreationMessage.Role.USER)
.order_by("-seq").values_list("text", flat=True)[:6]
)
shared = next(
(gender for source in sources if (gender := _shared_cast_gender_from_text(source, total))),
"",
)
return [_cast_gender_from_text(brief) or shared for brief in role_briefs]
def _build_single_person_reference_prompt(
*,
@@ -1454,6 +1541,7 @@ def _build_single_person_reference_prompt(
context_brief: str,
cast_index: int,
cast_total: int,
gender_requirement: str = "",
) -> tuple[str, str, str]:
"""返回 (prompt, generating_label, model_name)。"""
is_pet = is_pet_preset(conversation.preset)
@@ -1486,9 +1574,13 @@ def _build_single_person_reference_prompt(
model_name = "平台生成宠物角色"
else:
role_tag = f"第{cast_index}位/共{cast_total}位" if cast_total > 1 else "唯一"
gender_instruction = (
f"这位角色必须是成年{gender_requirement},外貌和性别不得改变。"
if gender_requirement else ""
)
prompt = (
f"为短视频生成一张可反复用于锁定身份的真人模特定妆参考图({role_tag}出镜角色)。"
"只出现一位成年人物,正面或轻微三分之四角度,中近景,表情自然,"
f"只出现一位成年人物。{gender_instruction}正面或轻微三分之四角度,中近景,表情自然,"
"五官、发型、肤色、身形和服装细节清晰,简洁中性背景,写实摄影,"
"不要文字、水印、拼图、多人、遮挡脸部或夸张滤镜。"
f"{no_product}"
@@ -1497,7 +1589,7 @@ def _build_single_person_reference_prompt(
prompt += f" 用户指定的人物外观:{appearance_only}。严格保留这些外观要求。"
if cast_total > 1:
prompt += (
f" 这是多人物视频中的角色{cast_index},必须与其他角色在性别或发型或年龄段或服装气质上"
f" 这是多人物视频中的角色{cast_index},必须与其他角色在发型、年龄段或服装气质上"
"有清晰可辨的差异,便于整片锁脸。"
)
label = f"正在生成人物参考({cast_index}/{cast_total})"
@@ -1542,6 +1634,7 @@ def submit_generated_person_reference(
if is_pet_preset(conversation.preset):
total = 1
role_briefs = build_cast_role_briefs(total, raw_appearance)
gender_requirements = _cast_gender_requirements(conversation, role_briefs, raw_appearance)
memory = dict(conversation.memory or {})
memory["person_source"] = "platform_generate"
@@ -1566,6 +1659,7 @@ def submit_generated_person_reference(
context_brief=context_brief,
cast_index=index,
cast_total=total,
gender_requirement=gender_requirements[index - 1],
)
tasks = enqueue_standalone_images(
team=conversation.team,
@@ -1876,6 +1970,8 @@ VIDEO_MODEL_BY_LABEL = {
"Seedance 2.0 Fast": "doubao-seedance-2-0-fast-260128",
"Seedance 2.0 Mini": "doubao-seedance-2-0-mini-260615",
}
FAST_VIDEO_MODEL = "doubao-seedance-2-0-fast-260128"
FAST_VIDEO_MODEL_LABEL = "Seedance 2.0 Fast"
DEFAULT_VIDEO_MODEL = "doubao-seedance-2-5-260628"
IMAGE_MODEL_BY_LABEL = {
"Seedream5.0": "volcano",
@@ -2588,6 +2684,7 @@ _CONTINUE_INTENT_RE = re.compile(
r"^\s*("
r"继续|继续做|接着|接着做|往下做|开始吧|开做吧|就这样|就按这个|按这个来|照这个做|直接做|直接来|"
r"继续完善方案|完善方案|继续创作|继续推进|往下推进|"
r"推荐|推荐一下|你来推荐|帮我推荐|按你推荐(?:的)?|你来定|你决定|按你来|你看着办|随便|都行|"
r"按当前描述继续|按这个继续|没有其他调整[,,]?按当前描述继续|"
r"使用这[个位只]角色继续创作|使用这[个位只]宠物角色继续创作"
r")[吧啊呀呢。.!!]*\s*$"
@@ -2873,6 +2970,7 @@ def apply_session_params(conversation, fields, answers: dict) -> bool:
current = dict(conversation.params or {})
field_by_key = {str(item.get("key") or ""): item for item in (fields or []) if isinstance(item, dict)}
changed = False
model_user_selected = False
for key, raw in (answers or {}).items():
field = field_by_key.get(str(key)) or {}
if field.get("type") == "asset":
@@ -2889,9 +2987,17 @@ def apply_session_params(conversation, fields, answers: dict) -> bool:
value = f"{MAX_VIDEO_DURATION} 秒"
current[stored] = value
changed = True
if stored == "model":
model_user_selected = True
if changed:
conversation.params = current
conversation.save(update_fields=["params", "updated_at"])
update_fields = ["params", "updated_at"]
if model_user_selected and conversation.mode == CreationConversation.Mode.VIDEO:
memory = dict(conversation.memory or {})
memory["video_model_user_selected"] = True
conversation.memory = memory
update_fields.append("memory")
conversation.save(update_fields=update_fields)
if is_plot_twist_conversation(conversation):
# 用户在参数卡里改了时长,剧情结构也必须立即跟着切换。
set_plot_twist_story_depth(conversation, str(current.get("duration") or ""))
@@ -2958,11 +3064,17 @@ def apply_confirm_params(conversation, incoming: dict | None) -> tuple[dict, boo
)
if changed:
conversation.params = current
conversation.save(update_fields=["params", "updated_at"])
if is_plot_twist_conversation(conversation):
update_fields = ["params", "updated_at"]
if model_changed:
memory = dict(conversation.memory or {})
memory["video_model_user_selected"] = True
conversation.memory = memory
update_fields.append("memory")
conversation.save(update_fields=update_fields)
if duration_changed and is_plot_twist_conversation(conversation):
# 在确认卡改时长也要切换故事契约;随后视图会要求重写旧方案。
set_plot_twist_story_depth(conversation, str(current.get("duration") or ""))
needs_rebuild = duration_changed or model_changed
needs_rebuild = duration_changed
if needs_rebuild:
# 角色和商品素材继续保留;只让 GPT 基于新参数重写受影响的架构与 Prompt。
set_video_gate_stage(conversation, "strategy", clear_pending_prompt=True)
@@ -3616,17 +3728,20 @@ def resolve_smart_video_duration(
params["duration"] = f"{duration} 秒"
selected_model = video_model_name(params)
switched = False
# Seedance 2.5 是当前唯一可稳定承载 16–30 秒单段、以及 31–180 秒分段的模型。
# 总时长不在单段能力表里时,后续会拆成多条 <=30 秒的 2.5 任务。
if duration > 15 and selected_model != DEFAULT_VIDEO_MODEL:
params["model"] = "Seedance 2.5"
memory = dict(conversation.memory or {})
model_user_selected = bool(memory.get("video_model_user_selected"))
# 默认按目标时长选模型:15 秒及以下优先 Fast,超过 15 秒使用 2.5。
# 用户在最终确认卡手动换过模型后尊重其选择;只有能力不支持时才自动回退。
preferred_model = FAST_VIDEO_MODEL if duration <= 15 else DEFAULT_VIDEO_MODEL
preferred_label = FAST_VIDEO_MODEL_LABEL if duration <= 15 else "Seedance 2.5"
if not model_user_selected and selected_model != preferred_model:
params["model"] = preferred_label
switched = True
elif not _model_supports_duration(selected_model, duration):
# 智能模式可自动选能完成完整脚本的模型;确认卡会清楚展示变更,用户仍能手动调整。
if _model_supports_duration(DEFAULT_VIDEO_MODEL, duration):
params["model"] = "Seedance 2.5"
if _model_supports_duration(preferred_model, duration):
params["model"] = preferred_label
switched = True
memory = dict(conversation.memory or {})
memory.pop("video_model_user_selected", None)
memory["smart_duration_resolved"] = duration
if switched:
memory["smart_duration_model_switched"] = True
@@ -4980,6 +5095,7 @@ def iter_creation_agent_events(
turn_deadline = turn_started + CREATION_AGENT_TURN_TIMEOUT_SECONDS
extra_body = creation_model_extra_body(model_config, tools)
truncation_retries = 0
progress_tool_retries = 0
for _round in range(MAX_TOOL_ROUNDS):
if is_agent_cancel_requested(conversation.id):
# 用户终止:干净收束,不落 ERROR,已落库消息保留
@@ -5199,6 +5315,27 @@ def iter_creation_agent_events(
turn_has_gate = True
break
# 用户已经明确说「推荐 / 继续 / 做方案」,模型却只回一段说明文字时,
# 不能把加载状态正常收起后只留“继续完善方案”。把这一轮文本作废并强制重试工具调用,
# 直到真正落下追问卡或视频架构卡。
if not calls and allow_plan and context.is_video and not fallback_fields:
progress_tool_retries += 1
if progress_tool_retries <= MAX_PROGRESS_TOOL_RETRIES:
if said:
messages.append({"role": "assistant", "content": said})
messages.append({"role": "user", "content": PROGRESS_TOOL_RETRY_INSTRUCTION})
continue
clear_public_agent_progress(conversation)
failure = append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ERROR,
text=PROGRESS_TOOL_GIVE_UP_NOTICE,
)
yield {"type": "message", "message": _message_payload(failure)}
yield {"type": "done"}
return
# ask_user 自己会落一条可追踪的聊天问题。模型同时吐出的过渡文案不再
# 另存一条,否则界面会连续出现两遍几乎相同的问题。
asks_user = bool(fallback_fields) or any(call.get("name") == "ask_user" for call in calls)
+11 -1
View File
@@ -233,7 +233,17 @@ class CreationConversationSerializer(serializers.ModelSerializer):
# 兼容服务端升级前已开始的轮次:至少显示当前一条进度。
if not history or history[-1] != progress:
history.append(progress)
progress["history"] = history[-12:]
# 兼容升级前已经存下的重复历史:同一句只保留最后出现的位置,
# 保证当前阶段仍位于列表末尾。
seen: set[tuple[str, str]] = set()
unique_reversed: list[dict[str, str]] = []
for item in reversed(history):
key = (str(item.get("label") or ""), str(item.get("detail") or ""))
if key in seen:
continue
seen.add(key)
unique_reversed.append(item)
progress["history"] = list(reversed(unique_reversed))[-12:]
return progress
def update(self, instance, validated_data):
+160 -9
View File
@@ -55,6 +55,7 @@ from .creation_agent import (
creation_agent_timeout_notice,
long_video_script_covers_requested_duration,
plan_video_segments,
resolve_smart_video_duration,
is_continue_intent,
is_greeting,
is_pure_chitchat,
@@ -741,6 +742,36 @@ class PersonReferenceCompletionTests(CreationAgentBaseTests):
self.assertEqual(self.conversation.memory.get("person_cast_total"), 2)
self.assertEqual(self.conversation.memory.get("person_cast_pending"), 2)
def test_video_prompt_two_adult_women_applies_to_both_character_images(self):
"""用户未额外填写外观时,视频 Prompt 的两位女性约束不能在生图阶段丢失。"""
self.conversation.memory = {
"pending_video_prompt": "这是一条剧情视频,两位成年女性分别出镜,展示商品使用前后。",
}
self.conversation.save(update_fields=["memory", "updated_at"])
tasks = [
AITask.objects.create(
team=self.team,
created_by=self.user,
task_type=AITask.Type.PERSON_IMAGE,
model_config=self.model,
idempotency_key=f"k-two-women-{index}",
)
for index in (1, 2)
]
with patch("apps.ai.services.enqueue_standalone_images", side_effect=[[tasks[0]], [tasks[1]]]) as enqueue:
messages = submit_generated_person_reference(
conversation=self.conversation,
user=self.user,
)
self.assertEqual(len(messages), 2)
self.assertEqual(enqueue.call_count, 2)
for call in enqueue.call_args_list:
prompt = call.kwargs["prompt"]
self.assertIn("必须是成年女性", prompt)
self.assertNotIn("性别或发型", prompt)
self.assertNotIn("商品使用前后", prompt)
class SendEndpointTests(TestCase):
def setUp(self):
@@ -1602,6 +1633,48 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.conversation.refresh_from_db()
self.assertEqual(self.conversation.params["duration"], "120 秒")
def test_short_video_defaults_to_fast_model(self):
self.conversation.params = {
**self.conversation.params,
"duration": "15 秒",
"model": "Seedance 2.5",
}
self.conversation.memory = {}
self.conversation.save(update_fields=["params", "memory", "updated_at"])
resolve_smart_video_duration(self.conversation)
self.conversation.refresh_from_db()
self.assertEqual(self.conversation.params["model"], "Seedance 2.0 Fast")
def test_longer_video_defaults_to_seedance_25(self):
self.conversation.params = {
**self.conversation.params,
"duration": "30 秒",
"model": "Seedance 2.0 Fast",
}
self.conversation.memory = {}
self.conversation.save(update_fields=["params", "memory", "updated_at"])
resolve_smart_video_duration(self.conversation)
self.conversation.refresh_from_db()
self.assertEqual(self.conversation.params["model"], "Seedance 2.5")
def test_short_video_keeps_manually_selected_model(self):
self.conversation.params = {
**self.conversation.params,
"duration": "15 秒",
"model": "Seedance 2.5",
}
self.conversation.memory = {"video_model_user_selected": True}
self.conversation.save(update_fields=["params", "memory", "updated_at"])
resolve_smart_video_duration(self.conversation)
self.conversation.refresh_from_db()
self.assertEqual(self.conversation.params["model"], "Seedance 2.5")
def test_planning_exposes_safe_progress_not_raw_reasoning(self):
self.conversation.agent_status = CreationConversation.AgentStatus.PLANNING
self.conversation.save(update_fields=["agent_status", "updated_at"])
@@ -1624,6 +1697,22 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.conversation.save(update_fields=["agent_status", "updated_at"])
self.assertIsNone(CreationConversationDetailSerializer(self.conversation).data["agent_progress"])
def test_planning_progress_deduplicates_revisited_steps(self):
self.conversation.agent_status = CreationConversation.AgentStatus.PLANNING
self.conversation.save(update_fields=["agent_status", "updated_at"])
set_public_agent_progress(self.conversation, "starting")
set_public_agent_progress(self.conversation, "reasoning", detail_key="brief")
set_public_agent_progress(self.conversation, "reasoning", detail_key="product")
set_public_agent_progress(self.conversation, "reasoning", detail_key="brief")
set_public_agent_progress(self.conversation, "write_plan")
data = CreationConversationDetailSerializer(self.conversation).data
details = [item["detail"] for item in data["agent_progress"]["history"]]
self.assertEqual(len(details), len(set(details)))
self.assertEqual(details.count("正在理解这次创作的重点和限制"), 1)
self.assertEqual(details[-1], "正在安排分段节奏,并锁定角色和商品的一致性")
def test_shot_timeline_does_not_override_session_duration(self):
changed = apply_explicit_video_duration_from_text(
self.conversation,
@@ -2484,6 +2573,42 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
])
self.assertEqual(len(fake.calls), 2)
def test_recommendation_reply_cannot_finish_without_a_plan_card(self):
self._pin_product("补水精华水")
self._pin_person()
self.conversation.memory = {
"selling_point_ready": True,
"selling_point_mode": "auto",
"product_brief_reviewed": True,
}
self.conversation.save(update_fields=["memory", "updated_at"])
fake = FakeProvider([
_text_chunks("可以回复继续完善方案,我再为你整理。"),
_tool_chunks("write_strategy", {
"target": "通勤护肤人群",
"trust": "真实上脸补水过程",
"belief": "肤感清爽且方便日常补水",
"direction": "自然生活流体验",
}),
_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,
))
messages = [event["message"] for event in events if event.get("type") == "message"]
self.assertTrue(any(message["kind"] == "plan" for message in messages))
self.assertFalse(any(
message["kind"] == "text" and "继续完善方案" in message.get("text", "")
for message in messages
))
self.assertEqual(len(fake.calls), 3)
def test_multiple_characters_do_not_interrupt_architecture_writing(self):
product = Product.objects.create(team=self.team, created_by=self.user, title="蓝牙耳机")
characters = [
@@ -2960,6 +3085,26 @@ class ConfirmEndpointTests(TestCase):
{"kind": "confirm", "reply_to": str(self.card.id)}, format="json",
)
def _pin_person_reference(self):
portrait = Asset.objects.create(
team=self.team,
created_by=self.user,
name="确认参数测试角色",
asset_type=Asset.Type.IMAGE,
category=Asset.Category.PERSON,
)
AssetFile.objects.create(
asset=portrait,
object_key="confirm-person.jpg",
bucket="test",
preview_url="https://cdn.example/confirm-person.jpg",
is_primary=True,
)
self.conversation.pinned_refs = [
{"type": "character", "id": str(portrait.id), "name": portrait.name}
]
self.conversation.save(update_fields=["pinned_refs", "updated_at"])
def test_confirm_twice_is_refused(self):
provider = ModelProvider.objects.create(name="fk2", display_name="F", base_url="https://x")
model = ModelConfig.objects.create(
@@ -3037,7 +3182,8 @@ class ConfirmEndpointTests(TestCase):
self.card.refresh_from_db()
self.assertTrue(self.card.payload["submitted"])
def test_confirm_model_change_rebuilds_architecture_before_submitting(self):
def test_confirm_model_change_without_duration_change_submits_directly(self):
self._pin_person_reference()
self.conversation.params = {
"model": "Seedance 2.0 Fast", "resolution": "480p",
"ratio": "1:1", "duration": "8 秒",
@@ -3060,13 +3206,14 @@ class ConfirmEndpointTests(TestCase):
"resolution": "720p", "ratio": "1:1"}},
format="json",
)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json().get("regenerate"))
submit.assert_not_called()
self.assertEqual(response.status_code, 201, response.content)
self.assertFalse(response.json().get("regenerate", False))
submit.assert_called_once()
self.conversation.refresh_from_db()
self.assertEqual((self.conversation.memory or {}).get("stage"), "strategy")
self.assertNotEqual((self.conversation.memory or {}).get("stage"), "strategy")
def test_confirm_ignores_duration_spacing_when_model_changes(self):
def test_confirm_ignores_duration_spacing_and_submits_model_change_directly(self):
self._pin_person_reference()
self.conversation.params = {
"model": "Seedance 2.0 Fast", "resolution": "480p",
"ratio": "9:16", "duration": "8秒",
@@ -3089,9 +3236,9 @@ class ConfirmEndpointTests(TestCase):
"resolution": "720p", "ratio": "9:16"}},
format="json",
)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json().get("regenerate"))
submit.assert_not_called()
self.assertEqual(response.status_code, 201, response.content)
self.assertFalse(response.json().get("regenerate", False))
submit.assert_called_once()
class MemoryCompressionTests(CreationAgentBaseTests):
@@ -3618,6 +3765,10 @@ class CreativeIntentTests(SimpleTestCase):
"完善方案",
"继续创作",
"使用这个角色继续创作",
"推荐",
"你来推荐",
"按你来",
"你看着办",
):
self.assertTrue(is_continue_intent(text), text)
+1 -1
View File
@@ -2551,7 +2551,7 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
"params": latest_params,
}
card.save(update_fields=["payload", "updated_at"])
# 改时长或视频模型会影响能力与 Prompt。保留事实/素材,只重写受影响步骤。
# 只有改时长会影响既有时间轴。模型、分辨率和比例可直接用于当前出片指令。
if needs_rebuild:
return JsonResponse({
"regenerate": True,
+7 -1
View File
@@ -613,7 +613,13 @@ export const api = {
* video_prompt 直接提交,同步返回「生成中」消息,之后靠轮询转成结果。
*/
confirmCreationPlan(id: string, replyTo: string, params?: Record<string, string>) {
return request<{ message?: CreationMessage; regenerate?: boolean; params?: Record<string, string> }>(
return request<{
message?: CreationMessage;
messages?: CreationMessage[];
regenerate?: boolean;
params?: Record<string, string>;
agent_status?: CreationConversationDetail["agent_status"];
}>(
`/api/ai/creations/${id}/send/`,
{
method: "POST",
+33
View File
@@ -1119,6 +1119,39 @@ html[data-theme="dark"] .omni-session-page .omni-process-card {
box-shadow: none;
}
/* 生成中不是空白占位图:用一层深色渲染画布和内框说明工作仍在进行。 */
html[data-theme="dark"] .omni-session-page .omni-process-card .omni-process-frame {
position: relative;
isolation: isolate;
overflow: hidden;
background: var(--background-lighter);
animation: none;
}
html[data-theme="dark"] .omni-session-page .omni-process-card .omni-process-ring {
border-color: var(--heat-20);
border-top-color: var(--heat);
}
html[data-theme="dark"] .omni-session-page .omni-process-card .omni-process-frame strong {
color: var(--accent-black);
}
html[data-theme="dark"] .omni-session-page .omni-process-card .omni-process-meta {
color: var(--black-alpha-48);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: .06em;
line-height: 1;
}
html[data-theme="dark"] .omni-session-page .omni-process-card .omni-result-info {
border-top: 1px solid var(--border-faint);
background: var(--surface);
}
html[data-theme="dark"] .omni-session-page .omni-process-card .omni-result-info strong {
color: var(--accent-black);
}
html[data-theme="dark"] .omni-session-page .omni-process-card .omni-result-info small {
color: var(--black-alpha-56);
}
/* 确认生成卡的参数条仍复用浅色页的控件基样式;在深色会话里单独收回到工作面的层级。
禁用态保留可读性,但不再出现白色输入框。 */
html[data-theme="dark"] .omni-session-page .omni-confirm-params .rs-select-btn,
+30
View File
@@ -412,6 +412,24 @@
max-width: 340px;
}
/* 多角色按生成顺序逐张揭示;已完成角色与当前生成卡横向排,空间不足自动换行。 */
.omni-person-reference-flow {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 12px;
width: min(760px, calc(100% - 44px));
margin: 0 0 24px 44px;
}
.omni-person-reference-flow > .omni-result-card,
.omni-person-reference-flow > .omni-process-card {
flex: 0 1 236px;
width: min(236px, 100%);
max-width: 236px;
margin: 0;
}
.omni-strategy-card,
.omni-video-plan-card,
.omni-prompt-file-card,
@@ -1500,6 +1518,11 @@
margin-left: 0;
}
.omni-person-reference-flow {
width: 100%;
margin-left: 0;
}
.omni-strategy-grid,
.omni-plan-points,
.omni-plan-timeline,
@@ -1868,6 +1891,13 @@
outline: none;
}
.omni-elicit-field input[type="text"]:disabled {
border-color: var(--black-alpha-12);
background: var(--black-alpha-5);
color: var(--black-alpha-32);
cursor: not-allowed;
}
.omni-elicit-assets {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
+5 -2
View File
@@ -173,8 +173,11 @@ export function OmniCreatePage({
setRatio("1:1");
setDuration("1 张");
} else {
const vid = findCatalogModel(modelConfigs, "", "video");
const label = vid ? (vid.display_name || vid.name) : "Seedance 2.5";
const vid = (
findCatalogModel(modelConfigs, "doubao-seedance-2-0-fast-260128", "video")
|| findCatalogModel(modelConfigs, "Seedance 2.0 Fast", "video")
);
const label = vid ? (vid.display_name || vid.name) : "Seedance 2.0 Fast";
const resList = modelResolutions(vid);
setModel(label);
setResolution(resList.includes("720p") ? "720p" : (resList[0] || "1080p"));
+176 -37
View File
@@ -1352,6 +1352,7 @@ function ElicitCard({
const [chatAnswer, setChatAnswer] = useState("");
const [personPromptOpen, setPersonPromptOpen] = useState(false);
const [personPrompt, setPersonPrompt] = useState("");
const productBriefNoteRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (submitted || interaction === "chat" || interaction === "step_confirm") return;
@@ -1415,8 +1416,9 @@ function ElicitCard({
<div className="omni-gate-actions" aria-label={isPet ? "宠物角色来源选择" : "人物来源选择"}>
<button
type="button"
className="primary"
className={personPromptOpen ? "" : "primary"}
disabled={disabled}
aria-pressed={!personPromptOpen}
onClick={() => onPersonSourceAction("local_upload")}
>
本地上传
@@ -1430,8 +1432,10 @@ function ElicitCard({
</button>
<button
type="button"
className={personPromptOpen ? "primary" : ""}
disabled={disabled}
aria-expanded={personPromptOpen}
aria-pressed={personPromptOpen}
onClick={() => setPersonPromptOpen((open) => !open)}
>
平台帮忙生成
@@ -1901,11 +1905,31 @@ function ElicitCard({
type="button"
key={option.value}
className={active ? "active" : ""}
onClick={() =>
field.type === "multi"
? toggleMulti(field.key, option.value)
: setAnswers((prev) => ({ ...prev, [field.key]: option.value }))
}
disabled={disabled || submitted}
onClick={() => {
if (field.type === "multi") {
toggleMulti(field.key, option.value);
return;
}
setAnswers((prev) => {
const next = { ...prev, [field.key]: option.value };
if (
interaction === "product_brief_review"
&& field.key === "review_action"
&& option.value !== "supplement"
) {
next.product_brief_note = "";
}
return next;
});
if (
interaction === "product_brief_review"
&& field.key === "review_action"
&& option.value === "supplement"
) {
requestAnimationFrame(() => productBriefNoteRef.current?.focus());
}
}}
>
{option.label}
</button>
@@ -1915,10 +1939,23 @@ function ElicitCard({
)}
{field.type === "text" && (
<input
ref={
interaction === "product_brief_review" && field.key === "product_brief_note"
? productBriefNoteRef
: undefined
}
type="text"
placeholder={field.placeholder || ""}
value={typeof value === "string" ? value : ""}
disabled={submitted}
disabled={
disabled
|| submitted
|| (
interaction === "product_brief_review"
&& field.key === "product_brief_note"
&& answers.review_action !== "supplement"
)
}
onChange={(event) =>
setAnswers((prev) => ({ ...prev, [field.key]: event.target.value }))
}
@@ -2026,15 +2063,13 @@ function ConfirmCard({
const snapshot = { ...sessionParams, ...payloadParams };
const [draft, setDraft] = useState(snapshot);
const [initialDuration] = useState(snapshot.duration || "");
const [initialModel] = useState(snapshot.model || "");
const cardIsVideo = message.payload.kind !== "image" && isVideo;
const durationChanged =
cardIsVideo
&& Boolean(draft.duration)
&& Boolean(initialDuration)
&& normalizeDurationValue(draft.duration) !== normalizeDurationValue(initialDuration);
const modelChanged = cardIsVideo && Boolean(initialModel) && Boolean(draft.model) && draft.model !== initialModel;
const needsRebuild = durationChanged || modelChanged;
const needsRebuild = durationChanged;
const durationSeconds = Number(String(draft.duration || "").replace(/\D/g, ""));
const willGenerateInSegments = cardIsVideo && durationSeconds > 30 && durationSeconds <= 60;
const generationPlan = (message.payload.generation_plan as Record<string, unknown> | undefined) || {};
@@ -2072,7 +2107,7 @@ function ConfirmCard({
: generationPhase === "failed" ? "可重新确认方案后再生成"
: generationPhase === "running" ? "正在出片,请稍候"
: generationPhase === "submitted" ? "正在提交生成"
: "确认前可以改参数。改时长或模型会同步更新视频架构与 Prompt。";
: "确认前可以改参数。只有改时长时会同步更新视频架构与 Prompt。";
const foot =
generationPhase === "done" ? "已完成出片"
: generationPhase === "failed" ? "出片失败"
@@ -2111,7 +2146,7 @@ function ConfirmCard({
</div>
)}
{submitted ? null : needsRebuild ? (
<p className="omni-confirm-hint">参数变化会同步更新受影响的视频架构与 Prompt,不会直接出片。</p>
<p className="omni-confirm-hint">时长变化会同步更新受影响的视频架构与 Prompt,不会直接出片。</p>
) : willGenerateInSegments ? (
<p className="omni-confirm-hint">
{draft.duration === snapshot.duration && generationPlan.note
@@ -2439,7 +2474,11 @@ function mergeMessagePreserveLocal(prevHit: CreationMessage | undefined, msg: Cr
* 合并服务端快照时保留尚未 ack 的乐观气泡,并稳住未变消息的对象引用。
* local 只对上「本轮新出现」的服务端用户消息,绝不按正文吞历史。
*/
function keepUnackedLocals(prev: CreationMessage[], incoming: CreationMessage[]): CreationMessage[] {
function keepUnackedLocals(
prev: CreationMessage[],
incoming: CreationMessage[],
pendingSinceSeq: number,
): CreationMessage[] {
const prevById = new Map(prev.map((m) => [m.id, m]));
const locals = prev.filter((m) => isLocalUserId(m.id));
if (!locals.length) {
@@ -2463,18 +2502,14 @@ function keepUnackedLocals(prev: CreationMessage[], incoming: CreationMessage[])
}
return merged;
}
const knownServerIds = new Set(
prev.filter((m) => !isLocalUserId(m.id)).map((m) => m.id)
);
const knownSeq = lastKnownServerSeq(prev);
const used = new Set<string>();
const merged = incoming.map((msg) => {
const prevHit = prevById.get(msg.id);
let withKey = mergeMessagePreserveLocal(prevHit, msg);
if (msg.role !== "user") return withKey;
const isNewServerUser =
!knownServerIds.has(msg.id) && serverSeq(msg) > knownSeq;
if (!isNewServerUser) return withKey;
// 确认角色等短路径会先返回 seq 更大的确认卡,用户消息稍后才在详情快照里出现。
// 应以发送前的 seq 判断是否属于本轮,不能用此刻列表的最大 seq。
if (serverSeq(msg) <= pendingSinceSeq) return withKey;
const local = locals.find((item) => !used.has(item.id) && sameUserBubble(item, msg));
if (!local) return withKey;
used.add(local.id);
@@ -2534,6 +2569,51 @@ function withoutLegacyGateArtifacts(
return hidden.size ? list.filter((message) => !hidden.has(message.id)) : list;
}
function isMultiPersonReferenceCard(message: CreationMessage): boolean {
return (
(message.kind === "generating" || message.kind === "result")
&& message.payload?.kind === "person_reference"
&& Number(message.payload?.cast_total || 1) > 1
);
}
/**
* 多角色任务可以在后台并行,但对话流按角色顺序逐个揭示:当前角色完成后,
* 才展示下一张生成卡。已经揭示的结果和当前进度归为一组,交给 flex 横向排列。
*/
function groupVisibleCreationMessages(list: CreationMessage[]): CreationMessage[][] {
const groups: CreationMessage[][] = [];
let index = 0;
while (index < list.length) {
const message = list[index];
if (!isMultiPersonReferenceCard(message)) {
groups.push([message]);
index += 1;
continue;
}
const castTotal = Number(message.payload?.cast_total || 1);
const batch: CreationMessage[] = [];
let cursor = index;
while (
cursor < list.length
&& isMultiPersonReferenceCard(list[cursor])
&& Number(list[cursor].payload?.cast_total || 1) === castTotal
) {
batch.push(list[cursor]);
cursor += 1;
}
const currentGeneratingIndex = batch.findIndex((item) => item.kind === "generating");
groups.push(
currentGeneratingIndex >= 0
? batch.slice(0, currentGeneratingIndex + 1)
: batch,
);
index = cursor;
}
return groups;
}
function ProcessCard({
payload,
}: {
@@ -2554,6 +2634,7 @@ function ProcessCard({
<div className="omni-process-frame" aria-hidden="true">
<span className="omni-process-ring" />
<strong>{isVideo ? "视频生成中" : "图片生成中"}</strong>
<span className="omni-process-meta">[ RENDERING ]</span>
</div>
</figure>
</div>
@@ -2765,7 +2846,7 @@ export function OmniSessionPage({
if (cancelled) return;
setConversation(detail);
// 保留尚未 ack 的乐观用户气泡,避免进页种子/发送中的气泡被首包冲掉
setMessages((prev) => keepUnackedLocals(prev, detail.messages || []));
setMessages((prev) => keepUnackedLocals(prev, detail.messages || [], pendingSinceSeqRef.current));
if (bootLocalIdRef.current && pendingUserIdRef.current === bootLocalIdRef.current) {
const boot = makeLocalUserMessage(
firstMessage?.trim() || "",
@@ -2894,10 +2975,26 @@ export function OmniSessionPage({
),
[conversation?.agent_started_at, conversation?.agent_status, messages],
);
const visibleMessageGroups = useMemo(
() => groupVisibleCreationMessages(visibleMessages),
[visibleMessages],
);
const currentPlanningHasError = Boolean(
conversation && hasCurrentPlanningError(conversation)
);
const visibleProgressHistory = conversation?.agent_progress?.history || [];
const visibleProgressHistory = useMemo(() => {
const history = conversation?.agent_progress?.history || [];
const seen = new Set<string>();
const uniqueReversed = [] as typeof history;
for (let index = history.length - 1; index >= 0; index -= 1) {
const item = history[index];
const key = `${item.label}\u0000${item.detail}`;
if (seen.has(key)) continue;
seen.add(key);
uniqueReversed.push(item);
}
return uniqueReversed.reverse();
}, [conversation?.agent_progress?.history]);
useEffect(() => {
const log = thinkingLogRef.current;
if (log) log.scrollTop = log.scrollHeight;
@@ -2967,7 +3064,7 @@ export function OmniSessionPage({
.then((detail) => {
if (cancelled) return;
setConversation(detail);
setMessages((prev) => keepUnackedLocals(prev, detail.messages || []));
setMessages((prev) => keepUnackedLocals(prev, detail.messages || [], pendingSinceSeqRef.current));
})
.catch(() => {
/* 轮询失败静默重试,不打扰用户 */
@@ -2994,7 +3091,7 @@ export function OmniSessionPage({
}
return detail;
});
setMessages((prev) => keepUnackedLocals(prev, detail.messages || []));
setMessages((prev) => keepUnackedLocals(prev, detail.messages || [], pendingSinceSeqRef.current));
if (localId && pendingUserIdRef.current === localId) {
// 必须是「发送之后」新出现的用户消息,不能靠正文撞上历史气泡
const acked = detail.messages.some(
@@ -3202,7 +3299,12 @@ export function OmniSessionPage({
}
return next;
});
if (localId && pendingUserIdRef.current === localId) {
const userAcknowledged = (result.messages || []).some((message) =>
message.role === "user"
&& serverSeq(message) > pendingSinceSeqRef.current
&& (message.text || "") === (payload.text || "")
);
if (localId && userAcknowledged && pendingUserIdRef.current === localId) {
pendingUserIdRef.current = null;
setPendingUserId(null);
}
@@ -3347,18 +3449,20 @@ export function OmniSessionPage({
const handleConfirm = async (message: CreationMessage, nextParams: Record<string, string>) => {
if (confirming) return;
setConfirming(true);
// 提交视频前还要同步完成素材检查和上游任务创建,可能需要数秒。
// 先把确认卡切成「正在提交」,不能让用户面对一张毫无变化的可点击卡片。
setMessages((prev) =>
prev.map((item) =>
item.id === message.id
? { ...item, payload: { ...item.payload, submitted: true, params: nextParams } }
: item
)
);
setConversation((prev) =>
prev ? { ...prev, params: { ...prev.params, ...nextParams } } : prev
);
try {
const result = await api.confirmCreationPlan(conversationId, message.id, nextParams);
setMessages((prev) =>
prev.map((m) =>
m.id === message.id
? { ...m, payload: { ...m.payload, submitted: true, params: nextParams } }
: m
)
);
setConversation((prev) =>
prev ? { ...prev, params: { ...prev.params, ...nextParams } } : prev
);
if (result.regenerate) {
notify("info", "参数已更新,正在同步视频架构与 Prompt");
void send({
@@ -3367,11 +3471,38 @@ export function OmniSessionPage({
});
return;
}
if (result.messages?.length) {
// 兼容旧确认卡:服务端可能把本次确认改道到补角色步骤,并重开确认卡。
// 拉服务端快照,避免本地乐观的 submitted 状态盖住新步骤。
const detail = await api.getCreation(conversationId);
mergeCreationDetail(detail);
return;
}
if (result.message) {
setMessages((prev) => [...prev, result.message as CreationMessage]);
}
} catch (error) {
notify("error", (error as Error).message);
let recovered = false;
try {
// 响应断开不等于提交失败:以后端快照为准,防止用户再次点击造成重复出片。
const detail = await api.getCreation(conversationId);
const remoteCard = detail.messages.find((item) => item.id === message.id);
recovered = Boolean(remoteCard?.payload?.submitted);
mergeCreationDetail(detail);
} catch {
setMessages((prev) =>
prev.map((item) =>
item.id === message.id
? { ...item, payload: { ...item.payload, submitted: false } }
: item
)
);
}
if (recovered) {
notify("info", "生成任务已提交,正在处理");
} else {
notify("error", (error as Error).message);
}
} finally {
setConfirming(false);
}
@@ -3733,7 +3864,15 @@ export function OmniSessionPage({
</button>
<main className="omni-session-feed" ref={feedRef} aria-live="polite">
{visibleMessages.map(renderMessage)}
{visibleMessageGroups.map((group) => {
const isPersonReferenceGroup = group.some(isMultiPersonReferenceCard);
if (!isPersonReferenceGroup) return renderMessage(group[0]);
return (
<div className="omni-person-reference-flow" key={`person-reference-${group[0].id}`}>
{group.map(renderMessage)}
</div>
);
})}
{/* 流式中的临时气泡。挂 is-live 关掉入场动画 —— 它每来一个字符都会
重渲染,带动画的话整条会一直在闪;真消息落地时它被替换掉,
那一下也不该再演一次入场。 */}