From 1130ecd4ba21bd0c29cf760530569a7e6b9e4785 Mon Sep 17 00:00:00 2001 From: "Azmat@qq.com" Date: Tue, 29 Sep 2026 10:46:59 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=85=A8=E8=83=BD=E5=88=9B?= =?UTF-8?q?=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/backend/apps/ai/creation_agent.py | 165 +++++++++++++-- core/backend/apps/ai/serializers.py | 12 +- core/backend/apps/ai/test_creation_agent.py | 169 +++++++++++++++- core/backend/apps/ai/views.py | 2 +- core/frontend/src/api.ts | 8 +- core/frontend/src/dark-mode-pages.css | 33 +++ core/frontend/src/omni-session-page.css | 30 +++ core/frontend/src/routes/omni-create.tsx | 7 +- core/frontend/src/routes/omni-session.tsx | 213 ++++++++++++++++---- 9 files changed, 574 insertions(+), 65 deletions(-) diff --git a/core/backend/apps/ai/creation_agent.py b/core/backend/apps/ai/creation_agent.py index ff03cf4..1265559 100644 --- a/core/backend/apps/ai/creation_agent.py +++ b/core/backend/apps/ai/creation_agent.py @@ -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"(? 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) diff --git a/core/backend/apps/ai/serializers.py b/core/backend/apps/ai/serializers.py index 040f779..e23b0b3 100644 --- a/core/backend/apps/ai/serializers.py +++ b/core/backend/apps/ai/serializers.py @@ -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): diff --git a/core/backend/apps/ai/test_creation_agent.py b/core/backend/apps/ai/test_creation_agent.py index 0d0f51a..29f8979 100644 --- a/core/backend/apps/ai/test_creation_agent.py +++ b/core/backend/apps/ai/test_creation_agent.py @@ -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) diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 697c70e..ecfb3c2 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -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, diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 0c43159..0c370c7 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -613,7 +613,13 @@ export const api = { * video_prompt 直接提交,同步返回「生成中」消息,之后靠轮询转成结果。 */ confirmCreationPlan(id: string, replyTo: string, params?: Record) { - return request<{ message?: CreationMessage; regenerate?: boolean; params?: Record }>( + return request<{ + message?: CreationMessage; + messages?: CreationMessage[]; + regenerate?: boolean; + params?: Record; + agent_status?: CreationConversationDetail["agent_status"]; + }>( `/api/ai/creations/${id}/send/`, { method: "POST", diff --git a/core/frontend/src/dark-mode-pages.css b/core/frontend/src/dark-mode-pages.css index 22d6c5e..03e8bef 100644 --- a/core/frontend/src/dark-mode-pages.css +++ b/core/frontend/src/dark-mode-pages.css @@ -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, diff --git a/core/frontend/src/omni-session-page.css b/core/frontend/src/omni-session-page.css index 351bef2..3aa5809 100644 --- a/core/frontend/src/omni-session-page.css +++ b/core/frontend/src/omni-session-page.css @@ -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)); diff --git a/core/frontend/src/routes/omni-create.tsx b/core/frontend/src/routes/omni-create.tsx index 3adfc60..1e7ae94 100644 --- a/core/frontend/src/routes/omni-create.tsx +++ b/core/frontend/src/routes/omni-create.tsx @@ -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")); diff --git a/core/frontend/src/routes/omni-session.tsx b/core/frontend/src/routes/omni-session.tsx index 7217b99..3b3fe91 100644 --- a/core/frontend/src/routes/omni-session.tsx +++ b/core/frontend/src/routes/omni-session.tsx @@ -1352,6 +1352,7 @@ function ElicitCard({ const [chatAnswer, setChatAnswer] = useState(""); const [personPromptOpen, setPersonPromptOpen] = useState(false); const [personPrompt, setPersonPrompt] = useState(""); + const productBriefNoteRef = useRef(null); useEffect(() => { if (submitted || interaction === "chat" || interaction === "step_confirm") return; @@ -1415,8 +1416,9 @@ function ElicitCard({
@@ -1915,10 +1939,23 @@ function ElicitCard({ )} {field.type === "text" && ( 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 | 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({
)} {submitted ? null : needsRebuild ? ( -

参数变化会同步更新受影响的视频架构与 Prompt,不会直接出片。

+

时长变化会同步更新受影响的视频架构与 Prompt,不会直接出片。

) : willGenerateInSegments ? (

{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(); 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({

@@ -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(); + 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) => { 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({
- {visibleMessages.map(renderMessage)} + {visibleMessageGroups.map((group) => { + const isPersonReferenceGroup = group.some(isMultiPersonReferenceCard); + if (!isPersonReferenceGroup) return renderMessage(group[0]); + return ( +
+ {group.map(renderMessage)} +
+ ); + })} {/* 流式中的临时气泡。挂 is-live 关掉入场动画 —— 它每来一个字符都会 重渲染,带动画的话整条会一直在闪;真消息落地时它被替换掉, 那一下也不该再演一次入场。 */}