@@ -65,6 +65,8 @@ CREATION_AGENT_MODEL_TIMEOUT_SECONDS = 180
|
|||||||
CREATION_AGENT_TURN_TIMEOUT_SECONDS = 420
|
CREATION_AGENT_TURN_TIMEOUT_SECONDS = 420
|
||||||
# 输出被 max_tokens 截断后最多让模型重写几次;再失败就明确告诉用户,不再闷着重试到超时。
|
# 输出被 max_tokens 截断后最多让模型重写几次;再失败就明确告诉用户,不再闷着重试到超时。
|
||||||
MAX_TRUNCATION_RETRIES = 2
|
MAX_TRUNCATION_RETRIES = 2
|
||||||
|
# 已明确要求推进创作时,模型若只回说明文字而没调工具,最多强制纠正两次。
|
||||||
|
MAX_PROGRESS_TOOL_RETRIES = 2
|
||||||
LONG_VIDEO_DURATION_SLACK_SECONDS = 2
|
LONG_VIDEO_DURATION_SLACK_SECONDS = 2
|
||||||
# 单条用户消息最多触发一次计费生成(契约 §4)
|
# 单条用户消息最多触发一次计费生成(契约 §4)
|
||||||
MAX_BILLED_GENERATIONS = 1
|
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(
|
def long_video_script_covers_requested_duration(
|
||||||
raw_duration: int | None,
|
raw_duration: int | None,
|
||||||
@@ -195,6 +207,17 @@ def set_public_agent_progress(
|
|||||||
):
|
):
|
||||||
return
|
return
|
||||||
history = list(memory.get("agent_progress_history") or [])
|
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})
|
history.append({"phase": phase, "detail_key": detail_key})
|
||||||
memory["agent_progress_history"] = history[-12:]
|
memory["agent_progress_history"] = history[-12:]
|
||||||
memory["agent_progress_phase"] = phase
|
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]]
|
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(
|
def _build_single_person_reference_prompt(
|
||||||
*,
|
*,
|
||||||
@@ -1454,6 +1541,7 @@ def _build_single_person_reference_prompt(
|
|||||||
context_brief: str,
|
context_brief: str,
|
||||||
cast_index: int,
|
cast_index: int,
|
||||||
cast_total: int,
|
cast_total: int,
|
||||||
|
gender_requirement: str = "",
|
||||||
) -> tuple[str, str, str]:
|
) -> tuple[str, str, str]:
|
||||||
"""返回 (prompt, generating_label, model_name)。"""
|
"""返回 (prompt, generating_label, model_name)。"""
|
||||||
is_pet = is_pet_preset(conversation.preset)
|
is_pet = is_pet_preset(conversation.preset)
|
||||||
@@ -1486,9 +1574,13 @@ def _build_single_person_reference_prompt(
|
|||||||
model_name = "平台生成宠物角色"
|
model_name = "平台生成宠物角色"
|
||||||
else:
|
else:
|
||||||
role_tag = f"第{cast_index}位/共{cast_total}位" if cast_total > 1 else "唯一"
|
role_tag = f"第{cast_index}位/共{cast_total}位" if cast_total > 1 else "唯一"
|
||||||
|
gender_instruction = (
|
||||||
|
f"这位角色必须是成年{gender_requirement},外貌和性别不得改变。"
|
||||||
|
if gender_requirement else ""
|
||||||
|
)
|
||||||
prompt = (
|
prompt = (
|
||||||
f"为短视频生成一张可反复用于锁定身份的真人模特定妆参考图({role_tag}出镜角色)。"
|
f"为短视频生成一张可反复用于锁定身份的真人模特定妆参考图({role_tag}出镜角色)。"
|
||||||
"只出现一位成年人物,正面或轻微三分之四角度,中近景,表情自然,"
|
f"只出现一位成年人物。{gender_instruction}正面或轻微三分之四角度,中近景,表情自然,"
|
||||||
"五官、发型、肤色、身形和服装细节清晰,简洁中性背景,写实摄影,"
|
"五官、发型、肤色、身形和服装细节清晰,简洁中性背景,写实摄影,"
|
||||||
"不要文字、水印、拼图、多人、遮挡脸部或夸张滤镜。"
|
"不要文字、水印、拼图、多人、遮挡脸部或夸张滤镜。"
|
||||||
f"{no_product}"
|
f"{no_product}"
|
||||||
@@ -1497,7 +1589,7 @@ def _build_single_person_reference_prompt(
|
|||||||
prompt += f" 用户指定的人物外观:{appearance_only}。严格保留这些外观要求。"
|
prompt += f" 用户指定的人物外观:{appearance_only}。严格保留这些外观要求。"
|
||||||
if cast_total > 1:
|
if cast_total > 1:
|
||||||
prompt += (
|
prompt += (
|
||||||
f" 这是多人物视频中的角色{cast_index},必须与其他角色在性别或发型或年龄段或服装气质上"
|
f" 这是多人物视频中的角色{cast_index},必须与其他角色在发型、年龄段或服装气质上"
|
||||||
"有清晰可辨的差异,便于整片锁脸。"
|
"有清晰可辨的差异,便于整片锁脸。"
|
||||||
)
|
)
|
||||||
label = f"正在生成人物参考({cast_index}/{cast_total})"
|
label = f"正在生成人物参考({cast_index}/{cast_total})"
|
||||||
@@ -1542,6 +1634,7 @@ def submit_generated_person_reference(
|
|||||||
if is_pet_preset(conversation.preset):
|
if is_pet_preset(conversation.preset):
|
||||||
total = 1
|
total = 1
|
||||||
role_briefs = build_cast_role_briefs(total, raw_appearance)
|
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 = dict(conversation.memory or {})
|
||||||
memory["person_source"] = "platform_generate"
|
memory["person_source"] = "platform_generate"
|
||||||
@@ -1566,6 +1659,7 @@ def submit_generated_person_reference(
|
|||||||
context_brief=context_brief,
|
context_brief=context_brief,
|
||||||
cast_index=index,
|
cast_index=index,
|
||||||
cast_total=total,
|
cast_total=total,
|
||||||
|
gender_requirement=gender_requirements[index - 1],
|
||||||
)
|
)
|
||||||
tasks = enqueue_standalone_images(
|
tasks = enqueue_standalone_images(
|
||||||
team=conversation.team,
|
team=conversation.team,
|
||||||
@@ -1876,6 +1970,8 @@ VIDEO_MODEL_BY_LABEL = {
|
|||||||
"Seedance 2.0 Fast": "doubao-seedance-2-0-fast-260128",
|
"Seedance 2.0 Fast": "doubao-seedance-2-0-fast-260128",
|
||||||
"Seedance 2.0 Mini": "doubao-seedance-2-0-mini-260615",
|
"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"
|
DEFAULT_VIDEO_MODEL = "doubao-seedance-2-5-260628"
|
||||||
IMAGE_MODEL_BY_LABEL = {
|
IMAGE_MODEL_BY_LABEL = {
|
||||||
"Seedream5.0": "volcano",
|
"Seedream5.0": "volcano",
|
||||||
@@ -2588,6 +2684,7 @@ _CONTINUE_INTENT_RE = re.compile(
|
|||||||
r"^\s*("
|
r"^\s*("
|
||||||
r"继续|继续做|接着|接着做|往下做|开始吧|开做吧|就这样|就按这个|按这个来|照这个做|直接做|直接来|"
|
r"继续|继续做|接着|接着做|往下做|开始吧|开做吧|就这样|就按这个|按这个来|照这个做|直接做|直接来|"
|
||||||
r"继续完善方案|完善方案|继续创作|继续推进|往下推进|"
|
r"继续完善方案|完善方案|继续创作|继续推进|往下推进|"
|
||||||
|
r"推荐|推荐一下|你来推荐|帮我推荐|按你推荐(?:的)?|你来定|你决定|按你来|你看着办|随便|都行|"
|
||||||
r"按当前描述继续|按这个继续|没有其他调整[,,]?按当前描述继续|"
|
r"按当前描述继续|按这个继续|没有其他调整[,,]?按当前描述继续|"
|
||||||
r"使用这[个位只]角色继续创作|使用这[个位只]宠物角色继续创作"
|
r"使用这[个位只]角色继续创作|使用这[个位只]宠物角色继续创作"
|
||||||
r")[吧啊呀呢。.!!]*\s*$"
|
r")[吧啊呀呢。.!!]*\s*$"
|
||||||
@@ -2873,6 +2970,7 @@ def apply_session_params(conversation, fields, answers: dict) -> bool:
|
|||||||
current = dict(conversation.params or {})
|
current = dict(conversation.params or {})
|
||||||
field_by_key = {str(item.get("key") or ""): item for item in (fields or []) if isinstance(item, dict)}
|
field_by_key = {str(item.get("key") or ""): item for item in (fields or []) if isinstance(item, dict)}
|
||||||
changed = False
|
changed = False
|
||||||
|
model_user_selected = False
|
||||||
for key, raw in (answers or {}).items():
|
for key, raw in (answers or {}).items():
|
||||||
field = field_by_key.get(str(key)) or {}
|
field = field_by_key.get(str(key)) or {}
|
||||||
if field.get("type") == "asset":
|
if field.get("type") == "asset":
|
||||||
@@ -2889,9 +2987,17 @@ def apply_session_params(conversation, fields, answers: dict) -> bool:
|
|||||||
value = f"{MAX_VIDEO_DURATION} 秒"
|
value = f"{MAX_VIDEO_DURATION} 秒"
|
||||||
current[stored] = value
|
current[stored] = value
|
||||||
changed = True
|
changed = True
|
||||||
|
if stored == "model":
|
||||||
|
model_user_selected = True
|
||||||
if changed:
|
if changed:
|
||||||
conversation.params = current
|
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):
|
if is_plot_twist_conversation(conversation):
|
||||||
# 用户在参数卡里改了时长,剧情结构也必须立即跟着切换。
|
# 用户在参数卡里改了时长,剧情结构也必须立即跟着切换。
|
||||||
set_plot_twist_story_depth(conversation, str(current.get("duration") or ""))
|
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:
|
if changed:
|
||||||
conversation.params = current
|
conversation.params = current
|
||||||
conversation.save(update_fields=["params", "updated_at"])
|
update_fields = ["params", "updated_at"]
|
||||||
if is_plot_twist_conversation(conversation):
|
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 ""))
|
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:
|
if needs_rebuild:
|
||||||
# 角色和商品素材继续保留;只让 GPT 基于新参数重写受影响的架构与 Prompt。
|
# 角色和商品素材继续保留;只让 GPT 基于新参数重写受影响的架构与 Prompt。
|
||||||
set_video_gate_stage(conversation, "strategy", clear_pending_prompt=True)
|
set_video_gate_stage(conversation, "strategy", clear_pending_prompt=True)
|
||||||
@@ -3616,17 +3728,20 @@ def resolve_smart_video_duration(
|
|||||||
params["duration"] = f"{duration} 秒"
|
params["duration"] = f"{duration} 秒"
|
||||||
selected_model = video_model_name(params)
|
selected_model = video_model_name(params)
|
||||||
switched = False
|
switched = False
|
||||||
# Seedance 2.5 是当前唯一可稳定承载 16–30 秒单段、以及 31–180 秒分段的模型。
|
memory = dict(conversation.memory or {})
|
||||||
# 总时长不在单段能力表里时,后续会拆成多条 <=30 秒的 2.5 任务。
|
model_user_selected = bool(memory.get("video_model_user_selected"))
|
||||||
if duration > 15 and selected_model != DEFAULT_VIDEO_MODEL:
|
# 默认按目标时长选模型:15 秒及以下优先 Fast,超过 15 秒使用 2.5。
|
||||||
params["model"] = "Seedance 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
|
switched = True
|
||||||
elif not _model_supports_duration(selected_model, duration):
|
elif not _model_supports_duration(selected_model, duration):
|
||||||
# 智能模式可自动选能完成完整脚本的模型;确认卡会清楚展示变更,用户仍能手动调整。
|
if _model_supports_duration(preferred_model, duration):
|
||||||
if _model_supports_duration(DEFAULT_VIDEO_MODEL, duration):
|
params["model"] = preferred_label
|
||||||
params["model"] = "Seedance 2.5"
|
|
||||||
switched = True
|
switched = True
|
||||||
memory = dict(conversation.memory or {})
|
memory.pop("video_model_user_selected", None)
|
||||||
memory["smart_duration_resolved"] = duration
|
memory["smart_duration_resolved"] = duration
|
||||||
if switched:
|
if switched:
|
||||||
memory["smart_duration_model_switched"] = True
|
memory["smart_duration_model_switched"] = True
|
||||||
@@ -4980,6 +5095,7 @@ def iter_creation_agent_events(
|
|||||||
turn_deadline = turn_started + CREATION_AGENT_TURN_TIMEOUT_SECONDS
|
turn_deadline = turn_started + CREATION_AGENT_TURN_TIMEOUT_SECONDS
|
||||||
extra_body = creation_model_extra_body(model_config, tools)
|
extra_body = creation_model_extra_body(model_config, tools)
|
||||||
truncation_retries = 0
|
truncation_retries = 0
|
||||||
|
progress_tool_retries = 0
|
||||||
for _round in range(MAX_TOOL_ROUNDS):
|
for _round in range(MAX_TOOL_ROUNDS):
|
||||||
if is_agent_cancel_requested(conversation.id):
|
if is_agent_cancel_requested(conversation.id):
|
||||||
# 用户终止:干净收束,不落 ERROR,已落库消息保留
|
# 用户终止:干净收束,不落 ERROR,已落库消息保留
|
||||||
@@ -5199,6 +5315,27 @@ def iter_creation_agent_events(
|
|||||||
turn_has_gate = True
|
turn_has_gate = True
|
||||||
break
|
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 自己会落一条可追踪的聊天问题。模型同时吐出的过渡文案不再
|
# ask_user 自己会落一条可追踪的聊天问题。模型同时吐出的过渡文案不再
|
||||||
# 另存一条,否则界面会连续出现两遍几乎相同的问题。
|
# 另存一条,否则界面会连续出现两遍几乎相同的问题。
|
||||||
asks_user = bool(fallback_fields) or any(call.get("name") == "ask_user" for call in calls)
|
asks_user = bool(fallback_fields) or any(call.get("name") == "ask_user" for call in calls)
|
||||||
|
|||||||
@@ -233,7 +233,17 @@ class CreationConversationSerializer(serializers.ModelSerializer):
|
|||||||
# 兼容服务端升级前已开始的轮次:至少显示当前一条进度。
|
# 兼容服务端升级前已开始的轮次:至少显示当前一条进度。
|
||||||
if not history or history[-1] != progress:
|
if not history or history[-1] != progress:
|
||||||
history.append(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
|
return progress
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ from .creation_agent import (
|
|||||||
creation_agent_timeout_notice,
|
creation_agent_timeout_notice,
|
||||||
long_video_script_covers_requested_duration,
|
long_video_script_covers_requested_duration,
|
||||||
plan_video_segments,
|
plan_video_segments,
|
||||||
|
resolve_smart_video_duration,
|
||||||
is_continue_intent,
|
is_continue_intent,
|
||||||
is_greeting,
|
is_greeting,
|
||||||
is_pure_chitchat,
|
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_total"), 2)
|
||||||
self.assertEqual(self.conversation.memory.get("person_cast_pending"), 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):
|
class SendEndpointTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -1602,6 +1633,48 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
|||||||
self.conversation.refresh_from_db()
|
self.conversation.refresh_from_db()
|
||||||
self.assertEqual(self.conversation.params["duration"], "120 秒")
|
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):
|
def test_planning_exposes_safe_progress_not_raw_reasoning(self):
|
||||||
self.conversation.agent_status = CreationConversation.AgentStatus.PLANNING
|
self.conversation.agent_status = CreationConversation.AgentStatus.PLANNING
|
||||||
self.conversation.save(update_fields=["agent_status", "updated_at"])
|
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.conversation.save(update_fields=["agent_status", "updated_at"])
|
||||||
self.assertIsNone(CreationConversationDetailSerializer(self.conversation).data["agent_progress"])
|
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):
|
def test_shot_timeline_does_not_override_session_duration(self):
|
||||||
changed = apply_explicit_video_duration_from_text(
|
changed = apply_explicit_video_duration_from_text(
|
||||||
self.conversation,
|
self.conversation,
|
||||||
@@ -2484,6 +2573,42 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
|||||||
])
|
])
|
||||||
self.assertEqual(len(fake.calls), 2)
|
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):
|
def test_multiple_characters_do_not_interrupt_architecture_writing(self):
|
||||||
product = Product.objects.create(team=self.team, created_by=self.user, title="蓝牙耳机")
|
product = Product.objects.create(team=self.team, created_by=self.user, title="蓝牙耳机")
|
||||||
characters = [
|
characters = [
|
||||||
@@ -2960,6 +3085,26 @@ class ConfirmEndpointTests(TestCase):
|
|||||||
{"kind": "confirm", "reply_to": str(self.card.id)}, format="json",
|
{"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):
|
def test_confirm_twice_is_refused(self):
|
||||||
provider = ModelProvider.objects.create(name="fk2", display_name="F", base_url="https://x")
|
provider = ModelProvider.objects.create(name="fk2", display_name="F", base_url="https://x")
|
||||||
model = ModelConfig.objects.create(
|
model = ModelConfig.objects.create(
|
||||||
@@ -3037,7 +3182,8 @@ class ConfirmEndpointTests(TestCase):
|
|||||||
self.card.refresh_from_db()
|
self.card.refresh_from_db()
|
||||||
self.assertTrue(self.card.payload["submitted"])
|
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 = {
|
self.conversation.params = {
|
||||||
"model": "Seedance 2.0 Fast", "resolution": "480p",
|
"model": "Seedance 2.0 Fast", "resolution": "480p",
|
||||||
"ratio": "1:1", "duration": "8 秒",
|
"ratio": "1:1", "duration": "8 秒",
|
||||||
@@ -3060,13 +3206,14 @@ class ConfirmEndpointTests(TestCase):
|
|||||||
"resolution": "720p", "ratio": "1:1"}},
|
"resolution": "720p", "ratio": "1:1"}},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 201, response.content)
|
||||||
self.assertTrue(response.json().get("regenerate"))
|
self.assertFalse(response.json().get("regenerate", False))
|
||||||
submit.assert_not_called()
|
submit.assert_called_once()
|
||||||
self.conversation.refresh_from_db()
|
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 = {
|
self.conversation.params = {
|
||||||
"model": "Seedance 2.0 Fast", "resolution": "480p",
|
"model": "Seedance 2.0 Fast", "resolution": "480p",
|
||||||
"ratio": "9:16", "duration": "8秒",
|
"ratio": "9:16", "duration": "8秒",
|
||||||
@@ -3089,9 +3236,9 @@ class ConfirmEndpointTests(TestCase):
|
|||||||
"resolution": "720p", "ratio": "9:16"}},
|
"resolution": "720p", "ratio": "9:16"}},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 201, response.content)
|
||||||
self.assertTrue(response.json().get("regenerate"))
|
self.assertFalse(response.json().get("regenerate", False))
|
||||||
submit.assert_not_called()
|
submit.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
class MemoryCompressionTests(CreationAgentBaseTests):
|
class MemoryCompressionTests(CreationAgentBaseTests):
|
||||||
@@ -3618,6 +3765,10 @@ class CreativeIntentTests(SimpleTestCase):
|
|||||||
"完善方案",
|
"完善方案",
|
||||||
"继续创作",
|
"继续创作",
|
||||||
"使用这个角色继续创作",
|
"使用这个角色继续创作",
|
||||||
|
"推荐",
|
||||||
|
"你来推荐",
|
||||||
|
"按你来",
|
||||||
|
"你看着办",
|
||||||
):
|
):
|
||||||
self.assertTrue(is_continue_intent(text), text)
|
self.assertTrue(is_continue_intent(text), text)
|
||||||
|
|
||||||
|
|||||||
@@ -2551,7 +2551,7 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
"params": latest_params,
|
"params": latest_params,
|
||||||
}
|
}
|
||||||
card.save(update_fields=["payload", "updated_at"])
|
card.save(update_fields=["payload", "updated_at"])
|
||||||
# 改时长或视频模型会影响能力与 Prompt。保留事实/素材,只重写受影响步骤。
|
# 只有改时长会影响既有时间轴。模型、分辨率和比例可直接用于当前出片指令。
|
||||||
if needs_rebuild:
|
if needs_rebuild:
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
"regenerate": True,
|
"regenerate": True,
|
||||||
|
|||||||
@@ -613,7 +613,13 @@ export const api = {
|
|||||||
* video_prompt 直接提交,同步返回「生成中」消息,之后靠轮询转成结果。
|
* video_prompt 直接提交,同步返回「生成中」消息,之后靠轮询转成结果。
|
||||||
*/
|
*/
|
||||||
confirmCreationPlan(id: string, replyTo: string, params?: Record<string, string>) {
|
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/`,
|
`/api/ai/creations/${id}/send/`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -1119,6 +1119,39 @@ html[data-theme="dark"] .omni-session-page .omni-process-card {
|
|||||||
box-shadow: none;
|
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,
|
html[data-theme="dark"] .omni-session-page .omni-confirm-params .rs-select-btn,
|
||||||
|
|||||||
@@ -412,6 +412,24 @@
|
|||||||
max-width: 340px;
|
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-strategy-card,
|
||||||
.omni-video-plan-card,
|
.omni-video-plan-card,
|
||||||
.omni-prompt-file-card,
|
.omni-prompt-file-card,
|
||||||
@@ -1500,6 +1518,11 @@
|
|||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.omni-person-reference-flow {
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.omni-strategy-grid,
|
.omni-strategy-grid,
|
||||||
.omni-plan-points,
|
.omni-plan-points,
|
||||||
.omni-plan-timeline,
|
.omni-plan-timeline,
|
||||||
@@ -1868,6 +1891,13 @@
|
|||||||
outline: none;
|
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 {
|
.omni-elicit-assets {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
|
||||||
|
|||||||
@@ -173,8 +173,11 @@ export function OmniCreatePage({
|
|||||||
setRatio("1:1");
|
setRatio("1:1");
|
||||||
setDuration("1 张");
|
setDuration("1 张");
|
||||||
} else {
|
} else {
|
||||||
const vid = findCatalogModel(modelConfigs, "", "video");
|
const vid = (
|
||||||
const label = vid ? (vid.display_name || vid.name) : "Seedance 2.5";
|
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);
|
const resList = modelResolutions(vid);
|
||||||
setModel(label);
|
setModel(label);
|
||||||
setResolution(resList.includes("720p") ? "720p" : (resList[0] || "1080p"));
|
setResolution(resList.includes("720p") ? "720p" : (resList[0] || "1080p"));
|
||||||
|
|||||||
@@ -1352,6 +1352,7 @@ function ElicitCard({
|
|||||||
const [chatAnswer, setChatAnswer] = useState("");
|
const [chatAnswer, setChatAnswer] = useState("");
|
||||||
const [personPromptOpen, setPersonPromptOpen] = useState(false);
|
const [personPromptOpen, setPersonPromptOpen] = useState(false);
|
||||||
const [personPrompt, setPersonPrompt] = useState("");
|
const [personPrompt, setPersonPrompt] = useState("");
|
||||||
|
const productBriefNoteRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (submitted || interaction === "chat" || interaction === "step_confirm") return;
|
if (submitted || interaction === "chat" || interaction === "step_confirm") return;
|
||||||
@@ -1415,8 +1416,9 @@ function ElicitCard({
|
|||||||
<div className="omni-gate-actions" aria-label={isPet ? "宠物角色来源选择" : "人物来源选择"}>
|
<div className="omni-gate-actions" aria-label={isPet ? "宠物角色来源选择" : "人物来源选择"}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="primary"
|
className={personPromptOpen ? "" : "primary"}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
aria-pressed={!personPromptOpen}
|
||||||
onClick={() => onPersonSourceAction("local_upload")}
|
onClick={() => onPersonSourceAction("local_upload")}
|
||||||
>
|
>
|
||||||
本地上传
|
本地上传
|
||||||
@@ -1430,8 +1432,10 @@ function ElicitCard({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
className={personPromptOpen ? "primary" : ""}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-expanded={personPromptOpen}
|
aria-expanded={personPromptOpen}
|
||||||
|
aria-pressed={personPromptOpen}
|
||||||
onClick={() => setPersonPromptOpen((open) => !open)}
|
onClick={() => setPersonPromptOpen((open) => !open)}
|
||||||
>
|
>
|
||||||
平台帮忙生成
|
平台帮忙生成
|
||||||
@@ -1901,11 +1905,31 @@ function ElicitCard({
|
|||||||
type="button"
|
type="button"
|
||||||
key={option.value}
|
key={option.value}
|
||||||
className={active ? "active" : ""}
|
className={active ? "active" : ""}
|
||||||
onClick={() =>
|
disabled={disabled || submitted}
|
||||||
field.type === "multi"
|
onClick={() => {
|
||||||
? toggleMulti(field.key, option.value)
|
if (field.type === "multi") {
|
||||||
: setAnswers((prev) => ({ ...prev, [field.key]: option.value }))
|
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}
|
{option.label}
|
||||||
</button>
|
</button>
|
||||||
@@ -1915,10 +1939,23 @@ function ElicitCard({
|
|||||||
)}
|
)}
|
||||||
{field.type === "text" && (
|
{field.type === "text" && (
|
||||||
<input
|
<input
|
||||||
|
ref={
|
||||||
|
interaction === "product_brief_review" && field.key === "product_brief_note"
|
||||||
|
? productBriefNoteRef
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={field.placeholder || ""}
|
placeholder={field.placeholder || ""}
|
||||||
value={typeof value === "string" ? value : ""}
|
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) =>
|
onChange={(event) =>
|
||||||
setAnswers((prev) => ({ ...prev, [field.key]: event.target.value }))
|
setAnswers((prev) => ({ ...prev, [field.key]: event.target.value }))
|
||||||
}
|
}
|
||||||
@@ -2026,15 +2063,13 @@ function ConfirmCard({
|
|||||||
const snapshot = { ...sessionParams, ...payloadParams };
|
const snapshot = { ...sessionParams, ...payloadParams };
|
||||||
const [draft, setDraft] = useState(snapshot);
|
const [draft, setDraft] = useState(snapshot);
|
||||||
const [initialDuration] = useState(snapshot.duration || "");
|
const [initialDuration] = useState(snapshot.duration || "");
|
||||||
const [initialModel] = useState(snapshot.model || "");
|
|
||||||
const cardIsVideo = message.payload.kind !== "image" && isVideo;
|
const cardIsVideo = message.payload.kind !== "image" && isVideo;
|
||||||
const durationChanged =
|
const durationChanged =
|
||||||
cardIsVideo
|
cardIsVideo
|
||||||
&& Boolean(draft.duration)
|
&& Boolean(draft.duration)
|
||||||
&& Boolean(initialDuration)
|
&& Boolean(initialDuration)
|
||||||
&& normalizeDurationValue(draft.duration) !== normalizeDurationValue(initialDuration);
|
&& normalizeDurationValue(draft.duration) !== normalizeDurationValue(initialDuration);
|
||||||
const modelChanged = cardIsVideo && Boolean(initialModel) && Boolean(draft.model) && draft.model !== initialModel;
|
const needsRebuild = durationChanged;
|
||||||
const needsRebuild = durationChanged || modelChanged;
|
|
||||||
const durationSeconds = Number(String(draft.duration || "").replace(/\D/g, ""));
|
const durationSeconds = Number(String(draft.duration || "").replace(/\D/g, ""));
|
||||||
const willGenerateInSegments = cardIsVideo && durationSeconds > 30 && durationSeconds <= 60;
|
const willGenerateInSegments = cardIsVideo && durationSeconds > 30 && durationSeconds <= 60;
|
||||||
const generationPlan = (message.payload.generation_plan as Record<string, unknown> | undefined) || {};
|
const generationPlan = (message.payload.generation_plan as Record<string, unknown> | undefined) || {};
|
||||||
@@ -2072,7 +2107,7 @@ function ConfirmCard({
|
|||||||
: generationPhase === "failed" ? "可重新确认方案后再生成"
|
: generationPhase === "failed" ? "可重新确认方案后再生成"
|
||||||
: generationPhase === "running" ? "正在出片,请稍候"
|
: generationPhase === "running" ? "正在出片,请稍候"
|
||||||
: generationPhase === "submitted" ? "正在提交生成"
|
: generationPhase === "submitted" ? "正在提交生成"
|
||||||
: "确认前可以改参数。改时长或模型会同步更新视频架构与 Prompt。";
|
: "确认前可以改参数。只有改时长时会同步更新视频架构与 Prompt。";
|
||||||
const foot =
|
const foot =
|
||||||
generationPhase === "done" ? "已完成出片"
|
generationPhase === "done" ? "已完成出片"
|
||||||
: generationPhase === "failed" ? "出片失败"
|
: generationPhase === "failed" ? "出片失败"
|
||||||
@@ -2111,7 +2146,7 @@ function ConfirmCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{submitted ? null : needsRebuild ? (
|
{submitted ? null : needsRebuild ? (
|
||||||
<p className="omni-confirm-hint">参数变化会同步更新受影响的视频架构与 Prompt,不会直接出片。</p>
|
<p className="omni-confirm-hint">时长变化会同步更新受影响的视频架构与 Prompt,不会直接出片。</p>
|
||||||
) : willGenerateInSegments ? (
|
) : willGenerateInSegments ? (
|
||||||
<p className="omni-confirm-hint">
|
<p className="omni-confirm-hint">
|
||||||
{draft.duration === snapshot.duration && generationPlan.note
|
{draft.duration === snapshot.duration && generationPlan.note
|
||||||
@@ -2439,7 +2474,11 @@ function mergeMessagePreserveLocal(prevHit: CreationMessage | undefined, msg: Cr
|
|||||||
* 合并服务端快照时保留尚未 ack 的乐观气泡,并稳住未变消息的对象引用。
|
* 合并服务端快照时保留尚未 ack 的乐观气泡,并稳住未变消息的对象引用。
|
||||||
* local 只对上「本轮新出现」的服务端用户消息,绝不按正文吞历史。
|
* 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 prevById = new Map(prev.map((m) => [m.id, m]));
|
||||||
const locals = prev.filter((m) => isLocalUserId(m.id));
|
const locals = prev.filter((m) => isLocalUserId(m.id));
|
||||||
if (!locals.length) {
|
if (!locals.length) {
|
||||||
@@ -2463,18 +2502,14 @@ function keepUnackedLocals(prev: CreationMessage[], incoming: CreationMessage[])
|
|||||||
}
|
}
|
||||||
return merged;
|
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 used = new Set<string>();
|
||||||
const merged = incoming.map((msg) => {
|
const merged = incoming.map((msg) => {
|
||||||
const prevHit = prevById.get(msg.id);
|
const prevHit = prevById.get(msg.id);
|
||||||
let withKey = mergeMessagePreserveLocal(prevHit, msg);
|
let withKey = mergeMessagePreserveLocal(prevHit, msg);
|
||||||
if (msg.role !== "user") return withKey;
|
if (msg.role !== "user") return withKey;
|
||||||
const isNewServerUser =
|
// 确认角色等短路径会先返回 seq 更大的确认卡,用户消息稍后才在详情快照里出现。
|
||||||
!knownServerIds.has(msg.id) && serverSeq(msg) > knownSeq;
|
// 应以发送前的 seq 判断是否属于本轮,不能用此刻列表的最大 seq。
|
||||||
if (!isNewServerUser) return withKey;
|
if (serverSeq(msg) <= pendingSinceSeq) return withKey;
|
||||||
const local = locals.find((item) => !used.has(item.id) && sameUserBubble(item, msg));
|
const local = locals.find((item) => !used.has(item.id) && sameUserBubble(item, msg));
|
||||||
if (!local) return withKey;
|
if (!local) return withKey;
|
||||||
used.add(local.id);
|
used.add(local.id);
|
||||||
@@ -2534,6 +2569,51 @@ function withoutLegacyGateArtifacts(
|
|||||||
return hidden.size ? list.filter((message) => !hidden.has(message.id)) : list;
|
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({
|
function ProcessCard({
|
||||||
payload,
|
payload,
|
||||||
}: {
|
}: {
|
||||||
@@ -2554,6 +2634,7 @@ function ProcessCard({
|
|||||||
<div className="omni-process-frame" aria-hidden="true">
|
<div className="omni-process-frame" aria-hidden="true">
|
||||||
<span className="omni-process-ring" />
|
<span className="omni-process-ring" />
|
||||||
<strong>{isVideo ? "视频生成中" : "图片生成中"}</strong>
|
<strong>{isVideo ? "视频生成中" : "图片生成中"}</strong>
|
||||||
|
<span className="omni-process-meta">[ RENDERING ]</span>
|
||||||
</div>
|
</div>
|
||||||
</figure>
|
</figure>
|
||||||
</div>
|
</div>
|
||||||
@@ -2765,7 +2846,7 @@ export function OmniSessionPage({
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setConversation(detail);
|
setConversation(detail);
|
||||||
// 保留尚未 ack 的乐观用户气泡,避免进页种子/发送中的气泡被首包冲掉
|
// 保留尚未 ack 的乐观用户气泡,避免进页种子/发送中的气泡被首包冲掉
|
||||||
setMessages((prev) => keepUnackedLocals(prev, detail.messages || []));
|
setMessages((prev) => keepUnackedLocals(prev, detail.messages || [], pendingSinceSeqRef.current));
|
||||||
if (bootLocalIdRef.current && pendingUserIdRef.current === bootLocalIdRef.current) {
|
if (bootLocalIdRef.current && pendingUserIdRef.current === bootLocalIdRef.current) {
|
||||||
const boot = makeLocalUserMessage(
|
const boot = makeLocalUserMessage(
|
||||||
firstMessage?.trim() || "",
|
firstMessage?.trim() || "",
|
||||||
@@ -2894,10 +2975,26 @@ export function OmniSessionPage({
|
|||||||
),
|
),
|
||||||
[conversation?.agent_started_at, conversation?.agent_status, messages],
|
[conversation?.agent_started_at, conversation?.agent_status, messages],
|
||||||
);
|
);
|
||||||
|
const visibleMessageGroups = useMemo(
|
||||||
|
() => groupVisibleCreationMessages(visibleMessages),
|
||||||
|
[visibleMessages],
|
||||||
|
);
|
||||||
const currentPlanningHasError = Boolean(
|
const currentPlanningHasError = Boolean(
|
||||||
conversation && hasCurrentPlanningError(conversation)
|
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(() => {
|
useEffect(() => {
|
||||||
const log = thinkingLogRef.current;
|
const log = thinkingLogRef.current;
|
||||||
if (log) log.scrollTop = log.scrollHeight;
|
if (log) log.scrollTop = log.scrollHeight;
|
||||||
@@ -2967,7 +3064,7 @@ export function OmniSessionPage({
|
|||||||
.then((detail) => {
|
.then((detail) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setConversation(detail);
|
setConversation(detail);
|
||||||
setMessages((prev) => keepUnackedLocals(prev, detail.messages || []));
|
setMessages((prev) => keepUnackedLocals(prev, detail.messages || [], pendingSinceSeqRef.current));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
/* 轮询失败静默重试,不打扰用户 */
|
/* 轮询失败静默重试,不打扰用户 */
|
||||||
@@ -2994,7 +3091,7 @@ export function OmniSessionPage({
|
|||||||
}
|
}
|
||||||
return detail;
|
return detail;
|
||||||
});
|
});
|
||||||
setMessages((prev) => keepUnackedLocals(prev, detail.messages || []));
|
setMessages((prev) => keepUnackedLocals(prev, detail.messages || [], pendingSinceSeqRef.current));
|
||||||
if (localId && pendingUserIdRef.current === localId) {
|
if (localId && pendingUserIdRef.current === localId) {
|
||||||
// 必须是「发送之后」新出现的用户消息,不能靠正文撞上历史气泡
|
// 必须是「发送之后」新出现的用户消息,不能靠正文撞上历史气泡
|
||||||
const acked = detail.messages.some(
|
const acked = detail.messages.some(
|
||||||
@@ -3202,7 +3299,12 @@ export function OmniSessionPage({
|
|||||||
}
|
}
|
||||||
return next;
|
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;
|
pendingUserIdRef.current = null;
|
||||||
setPendingUserId(null);
|
setPendingUserId(null);
|
||||||
}
|
}
|
||||||
@@ -3347,18 +3449,20 @@ export function OmniSessionPage({
|
|||||||
const handleConfirm = async (message: CreationMessage, nextParams: Record<string, string>) => {
|
const handleConfirm = async (message: CreationMessage, nextParams: Record<string, string>) => {
|
||||||
if (confirming) return;
|
if (confirming) return;
|
||||||
setConfirming(true);
|
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 {
|
try {
|
||||||
const result = await api.confirmCreationPlan(conversationId, message.id, nextParams);
|
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) {
|
if (result.regenerate) {
|
||||||
notify("info", "参数已更新,正在同步视频架构与 Prompt");
|
notify("info", "参数已更新,正在同步视频架构与 Prompt");
|
||||||
void send({
|
void send({
|
||||||
@@ -3367,11 +3471,38 @@ export function OmniSessionPage({
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (result.messages?.length) {
|
||||||
|
// 兼容旧确认卡:服务端可能把本次确认改道到补角色步骤,并重开确认卡。
|
||||||
|
// 拉服务端快照,避免本地乐观的 submitted 状态盖住新步骤。
|
||||||
|
const detail = await api.getCreation(conversationId);
|
||||||
|
mergeCreationDetail(detail);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (result.message) {
|
if (result.message) {
|
||||||
setMessages((prev) => [...prev, result.message as CreationMessage]);
|
setMessages((prev) => [...prev, result.message as CreationMessage]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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 {
|
} finally {
|
||||||
setConfirming(false);
|
setConfirming(false);
|
||||||
}
|
}
|
||||||
@@ -3733,7 +3864,15 @@ export function OmniSessionPage({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<main className="omni-session-feed" ref={feedRef} aria-live="polite">
|
<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 关掉入场动画 —— 它每来一个字符都会
|
{/* 流式中的临时气泡。挂 is-live 关掉入场动画 —— 它每来一个字符都会
|
||||||
重渲染,带动画的话整条会一直在闪;真消息落地时它被替换掉,
|
重渲染,带动画的话整条会一直在闪;真消息落地时它被替换掉,
|
||||||
那一下也不该再演一次入场。 */}
|
那一下也不该再演一次入场。 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user