优化已发现问题

This commit is contained in:
Azmat@qq.com
2026-09-18 14:51:57 +08:00
parent abccf4393a
commit b5d970a4b5
8 changed files with 1080 additions and 236 deletions
+18 -5
View File
@@ -453,14 +453,15 @@ def finish_generating_message(message: CreationMessage, *, assets: list[dict], m
is_deleted=False, is_deleted=False,
purged_at__isnull=True, purged_at__isnull=True,
).first() ).first()
is_pet = "宠物" in str(conversation.preset or "")
if model is None: if model is None:
model = Model.objects.create( model = Model.objects.create(
team=conversation.team, team=conversation.team,
created_by=conversation.created_by, created_by=conversation.created_by,
name="平台生成出镜人物", name="平台生成宠物角色" if is_pet else "平台生成出镜人物",
source=Model.Source.AI, source=Model.Source.AI,
portrait_asset=asset, portrait_asset=asset,
description="由全能创作生成并锁定的视频出镜人物。", description="由全能创作生成并锁定的宠物角色。" if is_pet else "由全能创作生成并锁定的视频出镜人物。",
metadata={"feature": "omni_create", "conversation_id": str(conversation.id)}, metadata={"feature": "omni_create", "conversation_id": str(conversation.id)},
) )
pin_refs(conversation, [{ pin_refs(conversation, [{
@@ -484,10 +485,22 @@ def finish_generating_message(message: CreationMessage, *, assets: list[dict], m
append_message( append_message(
conversation, conversation,
role="assistant", role="assistant",
text="人物参考已生成并锁定。后续所有镜头和长视频分段都会使用这位人物。", text=(
"宠物角色已生成。你看这只宠物合适吗?是否使用这个宠物角色继续创作?"
if is_pet else
"人物参考已生成。你看这位角色合适吗?是否使用这个角色继续创作?"
),
payload={ payload={
"reply_hint": "继续创作…", "reply_hint": (
"reply_options": [{"label": "继续创作", "text": "继续创作"}], "回复「使用这个角色」继续,或说明想要的宠物品种与外观…"
if is_pet else
"回复「使用这个角色」继续,或说明想调整的地方…"
),
"reply_options": [
{"label": "使用这个角色", "text": "使用这个角色继续创作"},
{"label": "重新生成一个", "text": "重新生成一个宠物角色" if is_pet else "重新生成一个角色"},
{"label": "上传其他宠物" if is_pet else "上传其他人物", "text": "我上传宠物参考图" if is_pet else "我上传人物参考图"},
],
}, },
) )
return message return message
+244 -43
View File
@@ -72,9 +72,16 @@ _STEP_CONFIRM_LABELS = {
_PERSON_SOURCE_PRESETS = { _PERSON_SOURCE_PRESETS = {
"痛点解决演示", "痛点解决演示",
PLOT_TWIST_PRESET, PLOT_TWIST_PRESET,
"短剧反转带货",
"达人口播种草", "达人口播种草",
"鱼眼换装", "鱼眼换装",
"AI 宠物拟人",
} }
def is_pet_preset(preset: str | None) -> bool:
name = str(preset or "").strip()
return "宠物" in name
_PERSON_VISUAL_RE = re.compile( _PERSON_VISUAL_RE = re.compile(
r"(人物|角色|模特|主角|达人|主播|出镜|口播|女生|女性|男生|男性|" r"(人物|角色|模特|主角|达人|主播|出镜|口播|女生|女性|男生|男性|"
r"女主|男主|年轻人|手模|手部|真人|换装|穿搭|剧情|短剧)" r"女主|男主|年轻人|手模|手部|真人|换装|穿搭|剧情|短剧)"
@@ -537,7 +544,7 @@ def video_needs_person_source(conversation: CreationConversation, user_text: str
memory = conversation.memory if isinstance(conversation.memory, dict) else {} memory = conversation.memory if isinstance(conversation.memory, dict) else {}
if memory.get("person_source_ready") or memory.get("person_source_pending"): if memory.get("person_source_ready") or memory.get("person_source_pending"):
return False return False
if conversation.preset in _PERSON_SOURCE_PRESETS: if conversation.preset in _PERSON_SOURCE_PRESETS or is_pet_preset(conversation.preset):
return True return True
recent = list( recent = list(
conversation.messages.order_by("-seq").values_list("text", flat=True)[:12] conversation.messages.order_by("-seq").values_list("text", flat=True)[:12]
@@ -546,10 +553,96 @@ def video_needs_person_source(conversation: CreationConversation, user_text: str
return bool(_PERSON_VISUAL_RE.search("\n".join([user_text, pending_prompt, *recent]))) return bool(_PERSON_VISUAL_RE.search("\n".join([user_text, pending_prompt, *recent])))
def locked_product_references(conversation: CreationConversation) -> list[dict]:
"""返回会话里所有已锁定的商品/产品素材实体(包括商品库商品和本地上传的商品图片)。"""
products: list[dict] = []
seen: set[tuple[str, str]] = set()
person_ids = {
str(ref.get("id"))
for ref in locked_person_references(conversation)
if isinstance(ref, dict) and ref.get("id")
}
for ref in (conversation.pinned_refs or []):
if not isinstance(ref, dict):
continue
type_ = str(ref.get("type") or "").strip()
ref_id = str(ref.get("id") or "").strip()
if not ref_id or ref_id in person_ids:
continue
# 显式商品,或上传的素材图片(排除人物角色/模特)
if type_ == "product" or (type_ == "asset" and ref.get("category") != "character"):
mark = (type_, ref_id)
if mark not in seen:
products.append(ref)
seen.add(mark)
return products
def has_locked_product_reference(conversation: CreationConversation) -> bool: def has_locked_product_reference(conversation: CreationConversation) -> bool:
return any( return len(locked_product_references(conversation)) > 0
isinstance(ref, dict) and ref.get("type") == "product" and ref.get("id")
for ref in (conversation.pinned_refs or [])
def product_info_needs_confirmation(conversation: CreationConversation, user_text: str = "") -> bool:
"""针对本地上传的商品素材,在对话开始或方案前必须确认品牌与具体品名。"""
if conversation.preset not in _PRODUCT_REQUIRED_PRESETS:
return False
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
if memory.get("product_info_resolved") or memory.get("product_brand") or memory.get("product_name"):
return False
# 如果已锁定的首个商品是正式商品库商品(已自带品牌和品名),无需重复询问
first_prod = next(
(ref for ref in (conversation.pinned_refs or []) if isinstance(ref, dict) and ref.get("type") == "product"),
None,
)
if first_prod is not None:
return False
# 检查是否包含本地上传的商品素材
uploaded_prods = [ref for ref in locked_product_references(conversation) if ref.get("type") == "asset"]
if not uploaded_prods:
return False
# 检查用户输入的文字中是否已经明确提供了品牌/品名
user_messages = (
conversation.messages.filter(role="user").order_by("seq")
if getattr(conversation, "created_at", None)
else []
)
all_text = " ".join(str(m.text or "") for m in user_messages) + " " + (user_text or "")
has_explicit_brand = bool(re.search(r"(?:品牌|牌子)[:是为]\s*([^\n,。!!]{2,30})", all_text))
has_explicit_name = bool(re.search(r"(?:品名|商品名|产品名)[::是为]\s*([^\n,。!!]{2,30})", all_text))
if has_explicit_brand or has_explicit_name:
return False
return True
def append_product_info_gate(conversation: CreationConversation) -> CreationMessage:
"""针对已上传商品图但尚未说明品牌/品名的情况,主动询问商品品牌与品名。"""
question = (
"已收到你上传的商品图。请问这款商品的**品牌**和**具体品名**是什么?"
"有想要重点突出的核心卖点也可以一起告诉我,以便在后续脚本中精准植入。"
)
return append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text=question,
payload={
"interaction": "chat",
"topic": "product_info",
"fields": [{
"key": "product_brand_and_name",
"label": "请提供商品品牌与具体品名",
"type": "text",
"required": True,
"placeholder": "例如:品牌「浅蓝小熊」,品名「婴儿柔湿巾」",
}],
"submitted": False,
"answers": {},
"reply_hint": "输入品牌和品名,例如「品牌XX,品名YY」…",
"reply_options": [
{"label": "按图片内容创作", "text": "直接根据图片内容设计品牌与品名,不用再问"},
{"label": "我来补充品牌品名", "text": "品牌是:,商品名是:"},
],
},
) )
@@ -568,17 +661,35 @@ def creation_needs_product_source(conversation: CreationConversation, user_text:
def append_person_source_gate(conversation: CreationConversation) -> CreationMessage: def append_person_source_gate(conversation: CreationConversation) -> CreationMessage:
"""可视化的人物来源闸门;三个选项分别进文件、模特库和生图流程。""" """可视化的人物/角色来源闸门;三个选项分别进文件、模特库和生图流程。"""
product_name = "" product_name = ""
for ref in (conversation.pinned_refs or []): for ref in locked_product_references(conversation):
if isinstance(ref, dict) and ref.get("type") == "product": name = str(ref.get("name") or "").split(" · ")[0].strip()
product_name = str(ref.get("name") or "").split(" · ")[0].strip() if name and not name.lower().endswith((".png", ".jpg", ".jpeg", ".webp")):
product_name = name
break break
prompt_text = ( if not product_name:
f"商品已选定【{product_name}】。这条视频想由哪位角色/达人出镜?选定后,所有镜头和分段都会锁定同一位人物。" memory = conversation.memory if isinstance(conversation.memory, dict) else {}
if product_name else product_name = str(memory.get("product_name") or memory.get("product_brand_and_name") or "").strip()
"先确定这条视频的出镜人物。选定后,所有镜头和分段都会锁定同一位人物。"
) is_pet = is_pet_preset(conversation.preset)
if is_pet:
prompt_text = (
f"商品已选定【{product_name}】。这条视频想由哪只宠物角色出镜?选定后,所有镜头和分段都会锁定同一只宠物形象。"
if product_name else
"先确定这条视频出镜的宠物角色。选定后,所有镜头和分段都会锁定同一只宠物形象。"
)
field_label = "选择宠物来源"
library_label = "从角色库选择"
else:
prompt_text = (
f"商品已选定【{product_name}】。这条视频想由哪位角色/达人出镜?选定后,所有镜头和分段都会锁定同一位人物。"
if product_name else
"先确定这条视频的出镜人物。选定后,所有镜头和分段都会锁定同一位人物。"
)
field_label = "选择人物来源"
library_label = "从模特库选择"
return append_message( return append_message(
conversation, conversation,
role="assistant", role="assistant",
@@ -586,14 +697,15 @@ def append_person_source_gate(conversation: CreationConversation) -> CreationMes
text=prompt_text, text=prompt_text,
payload={ payload={
"interaction": "person_source_gate", "interaction": "person_source_gate",
"is_pet": is_pet,
"fields": [{ "fields": [{
"key": "person_source", "key": "person_source",
"label": "选择人物来源", "label": field_label,
"type": "single", "type": "single",
"required": True, "required": True,
"options": [ "options": [
{"value": "local_upload", "label": "本地上传"}, {"value": "local_upload", "label": "本地上传"},
{"value": "model_library", "label": "从模特库选择"}, {"value": "model_library", "label": library_label},
{"value": "platform_generate", "label": "平台帮忙生成"}, {"value": "platform_generate", "label": "平台帮忙生成"},
], ],
}], }],
@@ -639,25 +751,67 @@ def append_click_swap_sequence_gate(conversation: CreationConversation) -> Creat
) )
def submit_generated_person_reference(*, conversation: CreationConversation, user) -> CreationMessage: def submit_generated_person_reference(
"""先生成独立人物定妆参考,完成后再由 creation.py 自动建模特并锁定。""" *,
conversation: CreationConversation,
user,
appearance_prompt: str = "",
) -> CreationMessage:
"""先生成独立人物/宠物角色定妆参考,完成后再由 creation.py 自动建模特并锁定。"""
from .services import enqueue_standalone_images from .services import enqueue_standalone_images
recent_user = list( recent_user = (
conversation.messages.filter(role=CreationMessage.Role.USER) list(
.order_by("-seq").values_list("text", flat=True)[:6] conversation.messages.filter(role=CreationMessage.Role.USER)
.order_by("-seq").values_list("text", flat=True)[:6]
)
if getattr(conversation, "created_at", None)
else []
) )
brief = "\n".join(reversed([item.strip() for item in recent_user if item and item.strip()]))[:700] brief = "\n".join(reversed([item.strip() for item in recent_user if item and item.strip()]))[:700]
prompt = ( appearance_prompt = (appearance_prompt or "").strip()[:500]
"为短视频生成一张可反复用于锁定身份的真人模特定妆参考图。"
"只出现一位成年人物,正面或轻微三分之四角度,中近景,表情自然," is_pet = is_pet_preset(conversation.preset)
"五官、发型、肤色、身形和服装细节清晰,简洁中性背景,写实摄影," if is_pet:
"不要文字、水印、拼图、多人、遮挡脸部或夸张滤镜。" # 智能推断宠物类别:用户指定 > 商品及需求上下文 > 默认萌宠
) context_text = f"{appearance_prompt} {brief} {conversation.preset}"
if conversation.preset: for ref in locked_product_references(conversation):
prompt += f" 适配视频预设:{conversation.preset}" context_text += " " + str(ref.get("name") or "")
if brief: if any(w in context_text for w in ("", "", "咬胶", "磨牙", "骨头", "", "狗粮", "犬粮", "幼犬", "成犬", "中大型犬", "小型犬")):
prompt += f" 参考用户需求:{brief}" pet_type_hint = "一只呆萌可爱、神采奕奕的小狗(如金毛幼犬、柯基或柴犬等萌犬)"
elif any(w in context_text for w in ("", "", "猫砂", "猫条", "猫粮", "冻干", "猫草", "逗猫", "幼猫")):
pet_type_hint = "一只大眼睛、圆脸可爱的萌宠猫咪(如英短、美短或布偶等萌猫)"
else:
pet_type_hint = "一只可爱灵动的萌宠(如呆萌小狗或可爱猫咪)"
prompt = (
f"为AI宠物拟人短视频生成一张可反复用于锁定角色身份的萌宠主角定妆参考图。"
f"主角必须是{pet_type_hint},展现拟人化的生动表情与灵动神态,"
f"正面或微侧三分之四角度,中近景特写,眼神清澈,毛发蓬松有光泽且根根分明,"
f"可搭配精致简约的拟人小配饰(如可爱小领结、小方巾或背带,契合萌宠调性),"
f"写实摄影风格,电影级柔和摄影棚光影,简洁纯净背景,"
f"画面中只出现这一只可爱的宠物动物主角,绝对不要出现真人人类!不要文字、水印、拼图或变形。"
)
if appearance_prompt:
prompt += f" 用户指定的宠物外观与品种:{appearance_prompt}。严格保留这些宠物外观要求。"
if brief:
prompt += f" 参考用户需求:{brief}"
label = "正在生成宠物角色参考"
else:
prompt = (
"为短视频生成一张可反复用于锁定身份的真人模特定妆参考图。"
"只出现一位成年人物,正面或轻微三分之四角度,中近景,表情自然,"
"五官、发型、肤色、身形和服装细节清晰,简洁中性背景,写实摄影,"
"不要文字、水印、拼图、多人、遮挡脸部或夸张滤镜。"
)
if appearance_prompt:
prompt += f" 用户指定的人物外观:{appearance_prompt}。严格保留这些外观要求。"
if conversation.preset:
prompt += f" 适配视频预设:{conversation.preset}"
if brief:
prompt += f" 参考用户需求:{brief}"
label = "正在生成人物参考"
tasks = enqueue_standalone_images( tasks = enqueue_standalone_images(
team=conversation.team, team=conversation.team,
user=user, user=user,
@@ -671,6 +825,7 @@ def submit_generated_person_reference(*, conversation: CreationConversation, use
memory = dict(conversation.memory or {}) memory = dict(conversation.memory or {})
memory["person_source"] = "platform_generate" memory["person_source"] = "platform_generate"
memory["person_source_pending"] = True memory["person_source_pending"] = True
memory["person_prompt"] = appearance_prompt
conversation.memory = memory conversation.memory = memory
conversation.status = CreationConversation.Status.RUNNING conversation.status = CreationConversation.Status.RUNNING
conversation.agent_status = CreationConversation.AgentStatus.IDLE conversation.agent_status = CreationConversation.AgentStatus.IDLE
@@ -683,7 +838,7 @@ def submit_generated_person_reference(*, conversation: CreationConversation, use
"task_id": str(task.id), "task_id": str(task.id),
"kind": "person_reference", "kind": "person_reference",
"prompt": prompt, "prompt": prompt,
"label": "正在生成人物参考", "label": label,
}, },
task=task, task=task,
) )
@@ -1258,6 +1413,16 @@ def default_reply_options(
{"label": "我直接说商品名", "text": "我直接告诉你商品名"}, {"label": "我直接说商品名", "text": "我直接告诉你商品名"},
{"label": "你来推荐", "text": "你根据当前需求推荐一款"}, {"label": "你来推荐", "text": "你根据当前需求推荐一款"},
] ]
if re.search(
r"是否使用这[个位]角色|角色.{0,8}(?:已生成|合适吗)|你看这[个位]角色|人物参考已生成",
guidance,
re.IGNORECASE,
):
return [
{"label": "使用这个角色", "text": "使用这个角色继续创作"},
{"label": "重新生成一个", "text": "重新生成一个角色"},
{"label": "上传其他人物", "text": "我上传人物参考图"},
]
if re.search( if re.search(
r"(?:哪位|哪个|什么|选择|选|换|更换|调整|修改|改).{0,12}(?:人物|角色|模特|出镜)|" r"(?:哪位|哪个|什么|选择|选|换|更换|调整|修改|改).{0,12}(?:人物|角色|模特|出镜)|"
r"(?:人物|角色|模特|出镜).{0,12}(?:哪位|哪个|选择|选|换|更换|调整|修改|改)", r"(?:人物|角色|模特|出镜).{0,12}(?:哪位|哪个|选择|选|换|更换|调整|修改|改)",
@@ -1962,7 +2127,31 @@ def _coerce_fields(raw) -> list[dict]:
continue continue
key = str(item.get("key") or "").strip() key = str(item.get("key") or "").strip()
label = str(item.get("label") or "").strip() label = str(item.get("label") or "").strip()
raw_options = item.get("options") or []
if isinstance(raw_options, dict):
raw_options = [{"value": str(k), "label": str(v)} for k, v in raw_options.items()]
options = []
for idx, o in enumerate(raw_options, start=1):
if isinstance(o, str):
s = o.strip()
if s:
options.append({"value": f"option_{idx}", "label": s})
elif isinstance(o, dict):
val = o.get("value")
lbl = o.get("label") or o.get("text") or o.get("title") or o.get("name")
if val is None or str(val).strip() == "":
val = lbl if lbl is not None else f"option_{idx}"
if lbl is None or str(lbl).strip() == "":
lbl = val
val_str = str(val).strip()
lbl_str = str(lbl).strip()
if lbl_str:
options.append({"value": val_str, "label": lbl_str})
type_ = str(item.get("type") or "").strip() type_ = str(item.get("type") or "").strip()
if not type_:
type_ = "single" if options else "text"
elif type_ in ("choice", "select", "radio"):
type_ = "single"
if not key or not label or type_ not in FIELD_TYPES: if not key or not label or type_ not in FIELD_TYPES:
continue continue
field = { field = {
@@ -1971,15 +2160,13 @@ def _coerce_fields(raw) -> list[dict]:
"type": type_, "type": type_,
"required": bool(item.get("required", True)), "required": bool(item.get("required", True)),
} }
options = [
{"value": str(o.get("value")), "label": str(o.get("label"))}
for o in (item.get("options") or [])
if isinstance(o, dict) and o.get("value") and o.get("label")
]
if type_ in ("single", "multi"): if type_ in ("single", "multi"):
if not options: if not options:
continue # 单选/多选没选项 = 废卡,丢掉 continue # 单选/多选没选项 = 废卡,丢掉
field["options"] = options field["options"] = options
# 明确提供了具体 options 的选择题(如痛点方向三选一),保留 options,绝不降级转为 asset
fields.append(field)
continue
if type_ == "asset": if type_ == "asset":
asset_types = [t for t in (item.get("asset_types") or []) if t in TYPE_LABELS] asset_types = [t for t in (item.get("asset_types") or []) if t in TYPE_LABELS]
field["asset_types"] = asset_types or list(TYPE_LABELS) field["asset_types"] = asset_types or list(TYPE_LABELS)
@@ -3324,6 +3511,16 @@ def iter_creation_agent_events(
yield {"type": "done"} yield {"type": "done"}
return return
# 0. 本地上传商品图优先确认品牌与具体品名
if product_info_needs_confirmation(conversation, text):
question = append_product_info_gate(conversation)
set_video_gate_stage(conversation, "clarify")
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
yield {"type": "message", "message": _message_payload(question)}
yield {"type": "done"}
return
# 剧情反转预设必须先选故事深度。平台直接落选择卡,不能把这一步交给模型猜, # 剧情反转预设必须先选故事深度。平台直接落选择卡,不能把这一步交给模型猜,
# 否则默认 15 秒会吞掉 30/60 秒该有的人物关系和冲突发展。 # 否则默认 15 秒会吞掉 30/60 秒该有的人物关系和冲突发展。
if is_plot_twist_conversation(conversation) and not active_plot_twist_story_depth(conversation): if is_plot_twist_conversation(conversation) and not active_plot_twist_story_depth(conversation):
@@ -3826,7 +4023,8 @@ def _guided_elicit_text(field: dict) -> str:
return label return label
if str(field.get("key") or "") in SESSION_PARAM_KEYS: if str(field.get("key") or "") in SESSION_PARAM_KEYS:
return label return label
if field.get("type") == "single" and field.get("options"): options = field.get("options")
if field.get("type") in ("single", "multi") and isinstance(options, list) and len(options) > 0:
return f"{label} 直接点击一个选项,也可以输入自己的想法。" return f"{label} 直接点击一个选项,也可以输入自己的想法。"
return f"{label} 直接用一句话告诉我就行,不用整理成完整需求。" return f"{label} 直接用一句话告诉我就行,不用整理成完整需求。"
@@ -3852,9 +4050,9 @@ def _elicit_payload_for_fields(
if primary_type == "product": if primary_type == "product":
gate_label = ( gate_label = (
"这条视频想推哪款商品?可以直接告诉我商品名,或者需要我把商品库列表发给你选吗?" "这条视频想推哪款商品?可以直接告诉我商品的品牌与品名,也可以上传一张商品实物图,或需要我把商品库列表发给你选吗?"
if is_video else if is_video else
"这次想做哪款商品?可以直接告诉我商品名,或者需要我把商品库列表发给你选吗?" "这次想做哪款商品?可以直接告诉我商品的品牌与品名,也可以上传一张商品实物图,或需要我把商品库列表发给你选吗?"
) )
else: else:
gate_label = _GATE_LABELS.get(primary_type, _GATE_LABELS["asset"]) gate_label = _GATE_LABELS.get(primary_type, _GATE_LABELS["asset"])
@@ -3865,15 +4063,18 @@ def _elicit_payload_for_fields(
"model": "模特", "model": "模特",
"scene": "场景", "scene": "场景",
}.get(primary_type, "素材") }.get(primary_type, "素材")
gate_options = [
{"value": "send", "label": f"{asset_label}列表"},
{"value": "auto", "label": "你来推荐"},
]
if primary_type == "product":
gate_options.insert(0, {"value": "upload", "label": "上传商品图"})
gate_field = { gate_field = {
"key": "_asset_gate", "key": "_asset_gate",
"label": gate_label, "label": gate_label,
"type": "text", "type": "text",
"required": False, "required": False,
"options": [ "options": gate_options,
{"value": "send", "label": f"{asset_label}列表"},
{"value": "auto", "label": "你来推荐"},
],
} }
return { return {
# 这是 Agent 的一句自然追问,不是让用户点选流程的卡片。 # 这是 Agent 的一句自然追问,不是让用户点选流程的卡片。
+18 -4
View File
@@ -13,6 +13,8 @@ from __future__ import annotations
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from django.db.models import Q
from apps.assets.models import Asset, Model from apps.assets.models import Asset, Model
from apps.products.models import Product from apps.products.models import Product
@@ -72,11 +74,15 @@ def _search_products(team, q: str, limit: int) -> list[dict]:
def _search_models(team, q: str, limit: int) -> list[dict]: def _search_models(team, q: str, limit: int) -> list[dict]:
queryset = Model.objects.filter(team=team, is_deleted=False, purged_at__isnull=True) queryset = Model.objects.filter(
Q(team=team) | Q(is_official=True),
is_deleted=False,
purged_at__isnull=True,
)
if q: if q:
queryset = queryset.filter(name__icontains=q) queryset = queryset.filter(name__icontains=q)
out = [] out = []
for model in queryset.select_related("portrait_asset").order_by("-created_at")[:limit]: for model in queryset.select_related("portrait_asset").order_by("-is_official", "-created_at")[:limit]:
out.append(_ref("model", model.id, model.name, _asset_preview_url(model.portrait_asset))) out.append(_ref("model", model.id, model.name, _asset_preview_url(model.portrait_asset)))
return out return out
@@ -173,7 +179,12 @@ def lookup_mention(team, value: str, types: list[str] | None = None) -> dict | N
return _ref("product", product.id, product.title, _product_cover_url(product)) return _ref("product", product.id, product.title, _product_cover_url(product))
if "model" in wanted: if "model" in wanted:
model = ( model = (
Model.objects.filter(team=team, id=uid, is_deleted=False, purged_at__isnull=True) Model.objects.filter(
Q(team=team) | Q(is_official=True),
id=uid,
is_deleted=False,
purged_at__isnull=True,
)
.select_related("portrait_asset") .select_related("portrait_asset")
.first() .first()
) )
@@ -384,7 +395,10 @@ def resolve_refs(team, refs: list[dict]) -> ResolvedRefs:
continue continue
if type_ == "model": if type_ == "model":
model = Model.objects.filter( model = Model.objects.filter(
team=team, id=ref_id, is_deleted=False, purged_at__isnull=True Q(team=team) | Q(is_official=True),
id=ref_id,
is_deleted=False,
purged_at__isnull=True,
).select_related("triview_asset", "portrait_asset").first() ).select_related("triview_asset", "portrait_asset").first()
if model is None: if model is None:
resolved.missing.append(ref) resolved.missing.append(ref)
+66 -2
View File
@@ -507,7 +507,7 @@ class PersonReferenceCompletionTests(CreationAgentBaseTests):
self.assertTrue(AssetModel.objects.filter(id=model_ref["id"], portrait_asset=asset).exists()) self.assertTrue(AssetModel.objects.filter(id=model_ref["id"], portrait_asset=asset).exists())
self.assertTrue(self.conversation.memory["person_source_ready"]) self.assertTrue(self.conversation.memory["person_source_ready"])
self.assertTrue( self.assertTrue(
self.conversation.messages.filter(text__contains="人物参考已生成并锁定").exists() self.conversation.messages.filter(text__contains="是否使用这个角色").exists()
) )
def test_platform_person_submission_uses_model_mode(self): def test_platform_person_submission_uses_model_mode(self):
@@ -519,13 +519,23 @@ class PersonReferenceCompletionTests(CreationAgentBaseTests):
idempotency_key="k-person-submit", idempotency_key="k-person-submit",
) )
with patch("apps.ai.services.enqueue_standalone_images", return_value=[task]) as enqueue: with patch("apps.ai.services.enqueue_standalone_images", return_value=[task]) as enqueue:
message = submit_generated_person_reference(conversation=self.conversation, user=self.user) message = submit_generated_person_reference(
conversation=self.conversation,
user=self.user,
appearance_prompt="25 岁左右女性,利落短发,干练通勤风",
)
self.assertEqual(message.kind, CreationMessage.Kind.GENERATING) self.assertEqual(message.kind, CreationMessage.Kind.GENERATING)
self.assertEqual(message.payload["kind"], "person_reference") self.assertEqual(message.payload["kind"], "person_reference")
self.assertEqual(enqueue.call_args.kwargs["mode"], "model") self.assertEqual(enqueue.call_args.kwargs["mode"], "model")
self.assertEqual(enqueue.call_args.kwargs["count"], 1) self.assertEqual(enqueue.call_args.kwargs["count"], 1)
self.assertEqual(enqueue.call_args.kwargs["feature"], "omni_create") self.assertEqual(enqueue.call_args.kwargs["feature"], "omni_create")
self.assertIn("利落短发", enqueue.call_args.kwargs["prompt"])
self.conversation.refresh_from_db()
self.assertEqual(
self.conversation.memory["person_prompt"],
"25 岁左右女性,利落短发,干练通勤风",
)
class SendEndpointTests(TestCase): class SendEndpointTests(TestCase):
@@ -600,6 +610,60 @@ class SendEndpointTests(TestCase):
for ref in self.conversation.pinned_refs for ref in self.conversation.pinned_refs
)) ))
def test_official_model_can_be_selected_from_person_source_gate(self):
official_owner = User.objects.create_user(username="official-model-owner", password="p")
official_team = Team.objects.create(name="Official Models", owner=official_owner)
TeamMember.objects.create(team=official_team, user=official_owner, role="owner")
portrait = Asset.objects.create(
team=official_team,
created_by=official_owner,
name="官方出镜人物",
asset_type=Asset.Type.IMAGE,
source=Asset.Source.AI_GENERATED,
category=Asset.Category.MODEL_PORTRAIT,
)
model = AssetModel.objects.create(
team=official_team,
created_by=official_owner,
name="官方出镜人物",
portrait_asset=portrait,
is_official=True,
)
self.conversation.mode = CreationConversation.Mode.VIDEO
self.conversation.save(update_fields=["mode", "updated_at"])
card = append_message(
self.conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text="先确定出镜人物",
payload={
"interaction": "person_source_gate",
"fields": [{"key": "person_source", "type": "single"}],
"submitted": False,
"answers": {},
},
)
response = self.client.post(
f"/api/ai/creations/{self.conversation.id}/send/",
{
"kind": "elicit_answer",
"reply_to": str(card.id),
"answers": {"person_source": "model_library"},
"refs": [{"type": "model", "id": str(model.id), "name": model.name}],
},
format="json",
)
self.assertEqual(response.status_code, 202)
card.refresh_from_db()
self.conversation.refresh_from_db()
self.assertTrue(card.payload["submitted"])
self.assertTrue(any(
ref.get("type") == "model" and str(ref.get("id")) == str(model.id)
for ref in self.conversation.pinned_refs
))
@override_settings(CREATION_AGENT_INLINE=False, CREATION_AGENT_TASK_QUEUE="airshelf.local") @override_settings(CREATION_AGENT_INLINE=False, CREATION_AGENT_TASK_QUEUE="airshelf.local")
def test_cast_relation_label_click_saves_lead_and_continues_without_text_input(self): def test_cast_relation_label_click_saves_lead_and_continues_without_text_input(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="蓝牙耳机")
+114 -1
View File
@@ -1993,6 +1993,14 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
pending.save(update_fields=["payload", "updated_at"]) pending.save(update_fields=["payload", "updated_at"])
if is_product_gate: if is_product_gate:
_mark_product_source_resolved(conversation) _mark_product_source_resolved(conversation)
is_character_gate = any(t in ("character", "model") for t in asset_types)
if is_character_gate:
memory = dict(conversation.memory or {})
memory["person_source"] = "auto"
memory["person_source_ready"] = True
memory["person_source_pending"] = False
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
force_creative_turn = True force_creative_turn = True
if hits: if hits:
@@ -2089,6 +2097,24 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
continuation_instruction = apply_pain_point_direction( continuation_instruction = apply_pain_point_direction(
conversation, payload, choice conversation, payload, choice
) )
elif payload.get("topic") == "product_info":
clean_ans = text.strip()
memory = dict(conversation.memory or {})
memory["product_info_resolved"] = True
if clean_ans and not any(kw in clean_ans for kw in ("按图片", "设计品牌", "不用再问")):
memory["product_brand_and_name"] = clean_ans
for r in (conversation.pinned_refs or []):
if isinstance(r, dict) and r.get("type") in ("asset", "product"):
r["name"] = clean_ans
break
conversation.pinned_refs = conversation.pinned_refs
continuation_instruction = f"用户已提供商品品牌与品名:【{clean_ans}】。直接围绕该商品推进方案,不要重复追问。"
else:
continuation_instruction = "用户要求直接根据图片内容设计品牌与品名推进创作。直接基于图片外观特征推进方案,不要重复追问品牌。"
conversation.memory = memory
conversation.save(update_fields=["memory", "pinned_refs", "updated_at"])
text = ""
record_user_message = False
else: else:
continuation_instruction = ( continuation_instruction = (
"用户刚用输入框回答了你上一句问题。直接基于这条真实回答继续原任务;" "用户刚用输入框回答了你上一句问题。直接基于这条真实回答继续原任务;"
@@ -2097,6 +2123,30 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
if params_changed: if params_changed:
continuation_instruction += " 会话参数已更新,旧方案作废,按新参数重新产出。" continuation_instruction += " 会话参数已更新,旧方案作废,按新参数重新产出。"
# 用户对刚生成的角色给出确认/重刷反馈
clean_text = text.strip()
if re.search(r"^(使用这[个位只]角色|就用这[个位只]角色|就用[她他它]|满意|确认使用|合适|可以)[继续创作。!! ]*$", clean_text) or clean_text in ("使用这个角色继续创作", "使用这个宠物角色继续创作"):
force_creative_turn = True
continuation_instruction = (
"用户已确认使用当前生成的角色出镜。直接基于该角色继续推进创作方案"
"(若尚未选商品则确定商品,已选好商品则开始写创作策略和方案),不要再次追问角色来源。"
)
elif re.search(r"^(重新生成|再生成|重刷|换一个|换位|不满意).{0,6}(角色|人物|模特|宠物)?[。!! ]*$", clean_text) and (conversation.memory or {}).get("person_source") == "platform_generate":
person_prompt = str((conversation.memory or {}).get("person_prompt") or "").strip()
try:
generating = submit_generated_person_reference(
conversation=conversation,
user=request.user,
appearance_prompt=person_prompt,
)
return JsonResponse({
"conversation_id": str(conversation.id),
"agent_status": conversation.agent_status,
"messages": [CreationMessageSerializer(generating).data],
}, status=202)
except Exception:
pass
if kind == "confirm": if kind == "confirm":
reply_to = str(request.data.get("reply_to") or "").strip() reply_to = str(request.data.get("reply_to") or "").strip()
card = conversation.messages.filter( card = conversation.messages.filter(
@@ -2215,7 +2265,7 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
] ]
valid = any( valid = any(
AssetModel.objects.filter( AssetModel.objects.filter(
team=conversation.team, Q(team=conversation.team) | Q(is_official=True),
id=ref.get("id"), id=ref.get("id"),
is_deleted=False, is_deleted=False,
purged_at__isnull=True, purged_at__isnull=True,
@@ -2322,10 +2372,12 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
elif payload.get("interaction") == "person_source_gate": elif payload.get("interaction") == "person_source_gate":
source = str(answers.get("person_source") or "").strip() source = str(answers.get("person_source") or "").strip()
if source == "platform_generate": if source == "platform_generate":
person_prompt = str(answers.get("person_prompt") or "").strip()[:500]
try: try:
generating = submit_generated_person_reference( generating = submit_generated_person_reference(
conversation=conversation, conversation=conversation,
user=request.user, user=request.user,
appearance_prompt=person_prompt,
) )
except ValueError as exc: except ValueError as exc:
# 生图没提交成功,把闸门放回去供用户换方式或重试。 # 生图没提交成功,把闸门放回去供用户换方式或重试。
@@ -2357,10 +2409,40 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
text = "" text = ""
record_user_message = False record_user_message = False
force_creative_turn = True force_creative_turn = True
is_pet = "宠物" in str(conversation.preset or "")
continuation_instruction = ( continuation_instruction = (
"用户已选定出镜宠物角色,该宠物形象已作为整条视频的固定身份参考。"
"直接继续创作;所有镜头和分段保持同一宠物,不要再追问角色来源。"
if is_pet else
"用户已选定出镜人物,该人物已作为整条视频的固定身份参考。" "用户已选定出镜人物,该人物已作为整条视频的固定身份参考。"
"直接继续创作;所有镜头和分段保持同一人,不要再追问人物来源。" "直接继续创作;所有镜头和分段保持同一人,不要再追问人物来源。"
) )
elif payload.get("topic") == "product_info":
ans = str(answers.get("product_brand_and_name") or "").strip()
memory = dict(conversation.memory or {})
memory["product_info_resolved"] = True
clean_ans = ans.replace("品牌是:,商品名是:", "").strip()
if clean_ans and not any(kw in clean_ans for kw in ("按图片", "设计品牌", "不用再问")):
memory["product_brand_and_name"] = clean_ans
bm = re.search(r"品牌[:是为]?\s*([^\n,。!!]+)", clean_ans)
nm = re.search(r"(?:品名|商品名|产品名)[::是为]?\s*([^\n,。!!]+)", clean_ans)
if bm:
memory["product_brand"] = bm.group(1).strip()
if nm:
memory["product_name"] = nm.group(1).strip()
for r in (conversation.pinned_refs or []):
if isinstance(r, dict) and r.get("type") in ("asset", "product"):
r["name"] = clean_ans
break
conversation.pinned_refs = conversation.pinned_refs
continuation_instruction = f"用户已提供商品品牌与品名:【{clean_ans}】。直接围绕该商品推进方案,不要重复追问。"
else:
continuation_instruction = "用户要求直接根据图片内容设计品牌与品名推进创作。直接基于图片外观特征推进方案,不要重复追问品牌。"
conversation.memory = memory
conversation.save(update_fields=["memory", "pinned_refs", "updated_at"])
text = ""
record_user_message = False
force_creative_turn = True
elif payload.get("phase") == "gate": elif payload.get("phase") == "gate":
choice = str(answers.get("_asset_gate") or "").strip() choice = str(answers.get("_asset_gate") or "").strip()
pending = payload.get("pending_fields") or [] pending = payload.get("pending_fields") or []
@@ -2411,6 +2493,14 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
force_creative_turn = True force_creative_turn = True
if is_product_gate: if is_product_gate:
_mark_product_source_resolved(conversation) _mark_product_source_resolved(conversation)
is_character_gate = any(t in ("character", "model") for t in gate_types)
if is_character_gate:
memory = dict(conversation.memory or {})
memory["person_source"] = "auto"
memory["person_source_ready"] = True
memory["person_source_pending"] = False
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
else: else:
# 卡片本身已经记录了用户的选择。不要再伪造一条黑色用户气泡; # 卡片本身已经记录了用户的选择。不要再伪造一条黑色用户气泡;
# 继续原任务,并明确告诉模型不要再次追问同一项素材。 # 继续原任务,并明确告诉模型不要再次追问同一项素材。
@@ -2423,6 +2513,29 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
"用户刚选择暂不添加这项素材。接受这个选择,按会话里已有的需求和合理默认继续原任务。" "用户刚选择暂不添加这项素材。接受这个选择,按会话里已有的需求和合理默认继续原任务。"
"先用一句自然的话承接,随后直接推进创作;不要再次追问同一素材,不要只说收到或有需要再说。" "先用一句自然的话承接,随后直接推进创作;不要再次追问同一素材,不要只说收到或有需要再说。"
) )
elif payload.get("phase") == "pick" and (
answers.get("_action") == "cancel"
or any(str(v).lower() in ("cancel", "取消", "skip", "跳过") for v in answers.values())
):
pick_fields = payload.get("fields") or []
primary = pick_fields[0] if pick_fields else {}
types = primary.get("asset_types") or []
key = str(primary.get("key") or "").strip()
if "product" in types or key == "product":
_mark_product_source_resolved(conversation)
if any(t in types for t in ("character", "model")) or key in ("character", "model", "person"):
memory = dict(conversation.memory or {})
memory["person_source_ready"] = True
memory["person_source_pending"] = False
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
text = ""
record_user_message = False
force_creative_turn = True
continuation_instruction = (
"用户取消了从列表选择素材(素材库暂无合适素材或用户放弃选择)。"
"接受这个选择,根据会话里已有的需求和合理默认继续推进原任务,不要重复追问同一项素材。"
)
else: else:
params_changed = apply_session_params(conversation, payload.get("fields") or [], answers) params_changed = apply_session_params(conversation, payload.get("fields") or [], answers)
# 点选商品/角色必须钉成 Ref:模型常把选项做成单选文字,前端只回 answers。 # 点选商品/角色必须钉成 Ref:模型常把选项做成单选文字,前端只回 answers。
@@ -1375,6 +1375,8 @@ class QuickCreateCoordinatorTests(TestCase):
+222 -5
View File
@@ -360,13 +360,34 @@
.omni-result-card, .omni-result-card,
.omni-process-card { .omni-process-card {
width: min(760px, calc(100% - 44px)); box-sizing: border-box;
width: min(320px, calc(100% - 44px));
max-width: 320px;
margin: 0 0 24px 44px; margin: 0 0 24px 44px;
border: 1px solid rgba(34, 42, 54, .09); border: 1px solid rgba(34, 42, 54, .09);
border-radius: 15px; border-radius: 15px;
background: #fff; background: #fff;
box-shadow: 0 10px 26px rgba(20, 27, 38, .06); box-shadow: 0 10px 26px rgba(20, 27, 38, .06);
animation: omniMessageIn 220ms ease both; animation: omniMessageIn 220ms ease both;
overflow: hidden;
}
.omni-result-card.has-multiple,
.omni-process-card.has-multiple {
width: min(520px, calc(100% - 44px));
max-width: 520px;
}
.omni-result-card.has-single.is-ratio-16-9,
.omni-process-card.has-single.is-ratio-16-9 {
width: min(480px, calc(100% - 44px));
max-width: 480px;
}
.omni-result-card.has-single.is-ratio-1-1,
.omni-process-card.has-single.is-ratio-1-1 {
width: min(340px, calc(100% - 44px));
max-width: 340px;
} }
.omni-strategy-card, .omni-strategy-card,
@@ -1167,7 +1188,10 @@
} }
.omni-process-frame { .omni-process-frame {
height: 310px; width: 100%;
height: 100%;
aspect-ratio: 9 / 16;
min-height: 200px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -1178,6 +1202,14 @@
animation: omniShimmer 1.2s ease infinite; animation: omniShimmer 1.2s ease infinite;
} }
.is-ratio-16-9 .omni-process-frame {
aspect-ratio: 16 / 9;
}
.is-ratio-1-1 .omni-process-frame {
aspect-ratio: 1 / 1;
}
.omni-process-frame strong { .omni-process-frame strong {
color: #5c6570; color: #5c6570;
font-size: 14px; font-size: 14px;
@@ -1211,6 +1243,8 @@
.omni-result-media.is-grid { .omni-result-media.is-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
padding: 10px;
} }
.omni-result-tile { .omni-result-tile {
@@ -1220,11 +1254,22 @@
border-radius: var(--r-md, 12px); border-radius: var(--r-md, 12px);
background: #edf0f4; background: #edf0f4;
box-shadow: 0 8px 20px rgba(20, 27, 38, .08); box-shadow: 0 8px 20px rgba(20, 27, 38, .08);
aspect-ratio: 9 / 16;
width: 100%;
}
.is-ratio-16-9 .omni-result-tile {
aspect-ratio: 16 / 9;
}
.is-ratio-1-1 .omni-result-tile {
aspect-ratio: 1 / 1;
} }
.omni-result-preview { .omni-result-preview {
display: block; display: block;
width: 100%; width: 100%;
height: 100%;
padding: 0; padding: 0;
border: 0; border: 0;
background: transparent; background: transparent;
@@ -1233,18 +1278,19 @@
.omni-result-tile img { .omni-result-tile img {
width: 100%; width: 100%;
height: 310px; height: 100%;
aspect-ratio: inherit;
display: block; display: block;
object-fit: cover; object-fit: cover;
background: #edf0f4; background: #edf0f4;
} }
.omni-result-media.is-grid .omni-result-tile img { .omni-result-media.is-grid .omni-result-tile img {
height: 220px; height: 100%;
} }
.omni-result-media.is-grid .omni-process-frame { .omni-result-media.is-grid .omni-process-frame {
height: 220px; height: 100%;
} }
.omni-result-play { .omni-result-play {
@@ -1731,14 +1777,36 @@
background: var(--klein); background: var(--klein);
color: #fff; color: #fff;
font-size: 13px; font-size: 13px;
font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all 160ms ease;
}
.omni-elicit-actions button.cancel-btn {
background: var(--surface, #fff);
color: var(--text-secondary, #606a78);
border: 1px solid var(--border-faint, #e2e8f0);
}
.omni-elicit-actions button.cancel-btn:hover:not(:disabled) {
background: var(--background-hover, #f8fafc);
color: var(--accent-black, #1a202c);
border-color: var(--border-subtle, #cbd5e1);
} }
.omni-elicit-actions button:disabled { .omni-elicit-actions button:disabled {
background: rgba(34, 42, 54, .18); background: rgba(34, 42, 54, .18);
color: rgba(255, 255, 255, .7);
cursor: not-allowed; cursor: not-allowed;
} }
.omni-elicit-actions button.cancel-btn:disabled {
background: var(--surface, #fff);
color: var(--text-tertiary, #94a3b8);
border-color: var(--border-faint, #e2e8f0);
opacity: 0.6;
}
/* 已提交的追问卡变成只读回顾,不能再改 —— 重复提交后端会 409 */ /* 已提交的追问卡变成只读回顾,不能再改 —— 重复提交后端会 409 */
.omni-elicit-card.is-submitted .omni-elicit-options button, .omni-elicit-card.is-submitted .omni-elicit-options button,
.omni-elicit-card.is-submitted .omni-elicit-assets button { .omni-elicit-card.is-submitted .omni-elicit-assets button {
@@ -2703,6 +2771,47 @@
cursor: not-allowed; cursor: not-allowed;
} }
.omni-person-generate-panel {
display: grid;
width: 100%;
gap: 8px;
margin-top: 4px;
padding: 12px;
border-radius: var(--r-md);
background: var(--background-lighter);
box-shadow: inset 0 0 0 1px var(--border-faint);
}
.omni-person-generate-panel label {
color: var(--accent-black);
font-size: 12px;
font-weight: 600;
}
.omni-person-generate-panel .textarea {
width: 100%;
min-height: 88px;
resize: vertical;
}
.omni-person-generate-panel > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.omni-person-generate-panel > div > span {
flex: 1 1 auto;
color: var(--black-alpha-48);
font-size: 11px;
line-height: 1.6;
}
.omni-person-generate-panel > div > button {
flex: 0 0 auto;
}
/* 单选追问直接点选即提交;长方向文案纵向铺开,避免挤成难扫读的小胶囊。 */ /* 单选追问直接点选即提交;长方向文案纵向铺开,避免挤成难扫读的小胶囊。 */
.omni-chat-choice-actions { .omni-chat-choice-actions {
width: 100%; width: 100%;
@@ -3079,6 +3188,105 @@
font-size: 11px; font-size: 11px;
} }
.omni-model-picker-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.omni-model-picker-card {
display: flex;
min-width: 0;
flex-direction: column;
gap: 8px;
padding: 8px;
border: 0;
border-radius: var(--r-md);
background: var(--surface);
color: var(--accent-black);
box-shadow: inset 0 0 0 1px var(--border-faint);
text-align: left;
cursor: pointer;
transition: background var(--t-base), box-shadow var(--t-base);
}
.omni-model-picker-card:hover,
.omni-model-picker-card.is-selected {
background: var(--background-lighter);
box-shadow: inset 0 0 0 1px var(--heat-40);
}
.omni-model-picker-card.is-selected {
color: var(--heat);
}
.omni-model-picker-media {
display: grid;
width: 100%;
aspect-ratio: 3 / 4;
place-items: center;
overflow: hidden;
border-radius: 6px;
background: var(--background-base);
color: var(--black-alpha-48);
}
.omni-model-picker-media img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.omni-model-picker-media svg {
width: 24px;
height: 24px;
}
.omni-model-picker-copy {
display: block;
min-width: 0;
padding: 0 2px 2px;
}
.omni-model-picker-copy strong,
.omni-model-picker-copy small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.omni-model-picker-copy strong {
font-size: 13px;
font-weight: 600;
}
.omni-model-picker-copy small {
margin-top: 2px;
color: var(--black-alpha-48);
font-family: var(--font-mono);
font-size: 10px;
}
.omni-model-picker-footer {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
}
.omni-model-picker-selection {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
color: var(--black-alpha-48);
font-family: var(--font-mono);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (min-width: 1101px) { @media (min-width: 1101px) {
.omni-session-page.has-assets-panel { .omni-session-page.has-assets-panel {
box-sizing: border-box; box-sizing: border-box;
@@ -3130,4 +3338,13 @@
.omni-assets-grid { .omni-assets-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.omni-person-generate-panel > div {
align-items: stretch;
flex-direction: column;
}
.omni-person-generate-panel > div > button {
width: 100%;
}
} }
+396 -176
View File
@@ -38,6 +38,7 @@ import type {
CreationMessage, CreationMessage,
CreationRef, CreationRef,
ModelConfig, ModelConfig,
ModelEntity,
} from "../types"; } from "../types";
import type { NavigateFn } from "./route-config"; import type { NavigateFn } from "./route-config";
@@ -219,6 +220,14 @@ function contextualReplyOptions(text: string): ReplyOption[] {
{ label: "你来推荐", text: "你根据当前需求推荐一款" }, { label: "你来推荐", text: "你根据当前需求推荐一款" },
]; ];
} }
// 角色/人物图已生成时,询问用户是否使用该角色,不再提供「由你设定角色/上传人物图/不需要人物」
if (/是否使用这[个位]角色|角色.{0,8}(?:已生成|合适吗)|你看这[个位]角色|人物参考已生成/i.test(guidance)) {
return [
{ label: "使用这个角色", text: "使用这个角色继续创作" },
{ label: "重新生成一个", text: "重新生成一个角色" },
{ label: "上传其他人物", text: "我上传人物参考图", action: "upload" },
];
}
if (/(?:哪位|哪个|什么|选择|选|换|更换|调整|修改|改).{0,12}(?:人物|角色|模特|出镜)|(?:人物|角色|模特|出镜).{0,12}(?:哪位|哪个|选择|选|换|更换|调整|修改|改)/i.test(guidance)) { if (/(?:哪位|哪个|什么|选择|选|换|更换|调整|修改|改).{0,12}(?:人物|角色|模特|出镜)|(?:人物|角色|模特|出镜).{0,12}(?:哪位|哪个|选择|选|换|更换|调整|修改|改)/i.test(guidance)) {
return [ return [
{ label: "上传人物图", text: "我上传人物参考图", action: "upload" }, { label: "上传人物图", text: "我上传人物参考图", action: "upload" },
@@ -1143,6 +1152,7 @@ function ElicitCard({
onSubmit, onSubmit,
onChatAnswer, onChatAnswer,
onPersonSourceAction, onPersonSourceAction,
onUpload,
}: { }: {
message: CreationMessage; message: CreationMessage;
disabled: boolean; disabled: boolean;
@@ -1152,7 +1162,11 @@ function ElicitCard({
/** 普通追问直接在气泡下回答,仍走 text 链路以保留用户消息与上下文。 */ /** 普通追问直接在气泡下回答,仍走 text 链路以保留用户消息与上下文。 */
onChatAnswer: (text: string) => void; onChatAnswer: (text: string) => void;
/** 人物来源三选一要分别打开系统文件、模特库和平台生成流程。 */ /** 人物来源三选一要分别打开系统文件、模特库和平台生成流程。 */
onPersonSourceAction: (source: "local_upload" | "model_library" | "platform_generate") => void; onPersonSourceAction: (
source: "local_upload" | "model_library" | "platform_generate",
personPrompt?: string,
) => void;
onUpload?: () => void;
}) { }) {
const fields = ((message.payload.fields as CreationField[] | undefined) || []).filter(Boolean); const fields = ((message.payload.fields as CreationField[] | undefined) || []).filter(Boolean);
const submitted = Boolean(message.payload.submitted); const submitted = Boolean(message.payload.submitted);
@@ -1164,6 +1178,8 @@ function ElicitCard({
const [assetPicked, setAssetPicked] = useState<Record<string, CreationRef>>({}); const [assetPicked, setAssetPicked] = useState<Record<string, CreationRef>>({});
const [customDirection, setCustomDirection] = useState(""); const [customDirection, setCustomDirection] = useState("");
const [chatAnswer, setChatAnswer] = useState(""); const [chatAnswer, setChatAnswer] = useState("");
const [personPromptOpen, setPersonPromptOpen] = useState(false);
const [personPrompt, setPersonPrompt] = useState("");
useEffect(() => { useEffect(() => {
if (submitted || interaction === "chat" || interaction === "step_confirm") return; if (submitted || interaction === "chat" || interaction === "step_confirm") return;
@@ -1210,15 +1226,21 @@ function ElicitCard({
if (interaction === "person_source_gate") { if (interaction === "person_source_gate") {
const selected = String(saved.person_source || ""); const selected = String(saved.person_source || "");
const selectedLabel = fields[0]?.options?.find((option) => option.value === selected)?.label; const selectedLabel = fields[0]?.options?.find((option) => option.value === selected)?.label;
const isPet = Boolean(
message.payload?.is_pet ||
message.text?.includes("宠物")
);
const libraryOption = fields[0]?.options?.find((option) => option.value === "model_library");
const libraryLabel = libraryOption?.label || (isPet ? "从角色库选择" : "从模特库选择");
return ( return (
<div className="omni-chat-row agent"> <div className="omni-chat-row agent">
<span className="omni-chat-avatar"> <span className="omni-chat-avatar">
<Sparkles /> <Sparkles />
</span> </span>
<div className="omni-chat-bubble omni-gate-bubble"> <div className="omni-chat-bubble omni-gate-bubble">
<ChatMarkdown text={message.text || "先选定出镜人物。"} /> <ChatMarkdown text={message.text || (isPet ? "先选定出镜宠物角色。" : "先选定出镜人物。")} />
{!submitted ? ( {!submitted ? (
<div className="omni-gate-actions" aria-label="人物来源选择"> <div className="omni-gate-actions" aria-label={isPet ? "宠物角色来源选择" : "人物来源选择"}>
<button <button
type="button" type="button"
className="primary" className="primary"
@@ -1232,19 +1254,55 @@ function ElicitCard({
disabled={disabled} disabled={disabled}
onClick={() => onPersonSourceAction("model_library")} onClick={() => onPersonSourceAction("model_library")}
> >
{libraryLabel}
</button> </button>
<button <button
type="button" type="button"
disabled={disabled} disabled={disabled}
onClick={() => onPersonSourceAction("platform_generate")} aria-expanded={personPromptOpen}
onClick={() => setPersonPromptOpen((open) => !open)}
> >
</button> </button>
{personPromptOpen ? (
<div className="omni-person-generate-panel">
<label htmlFor={`person-prompt-${message.id}`}>
{isPet ? "描述想要的宠物外观或品种(可选)" : "描述想要的角色外观(可选)"}
</label>
<textarea
id={`person-prompt-${message.id}`}
className="textarea"
value={personPrompt}
disabled={disabled}
maxLength={500}
placeholder={
isPet
? "例如:呆萌可爱的金毛幼犬,毛发蓬松干净,系着小红领巾,眼神灵动"
: "例如:25岁左右女性,短发,干练通勤风,表情自然"
}
onChange={(event) => setPersonPrompt(event.target.value)}
/>
<div>
<span>
{isPet
? "不填写也可以,平台会结合当前商品和拟人主题自动设计萌宠角色。"
: "不填写也可以,平台会结合当前商品和创作主题自动设计角色。"}
</span>
<button
type="button"
className="primary"
disabled={disabled}
onClick={() => onPersonSourceAction("platform_generate", personPrompt.trim())}
>
{isPet ? "开始生成宠物角色" : "开始生成角色"}
</button>
</div>
</div>
) : null}
</div> </div>
) : ( ) : (
<p className="omni-gate-answered"> <p className="omni-gate-answered">
{selectedLabel ? `已选择:${selectedLabel}` : "人物来源已确定"} {selectedLabel ? `已选择:${selectedLabel}` : isPet ? "宠物角色已确定" : "人物来源已确定"}
</p> </p>
)} )}
</div> </div>
@@ -1441,6 +1499,10 @@ function ElicitCard({
className={index === 0 ? "primary" : ""} className={index === 0 ? "primary" : ""}
disabled={disabled} disabled={disabled}
onClick={() => { onClick={() => {
if (option.value === "upload" || /上传/.test(option.label)) {
onUpload?.();
return;
}
if (!gateField?.key) return; if (!gateField?.key) return;
onSubmit({ [gateField.key]: option.value }, []); onSubmit({ [gateField.key]: option.value }, []);
}} }}
@@ -1462,11 +1524,37 @@ function ElicitCard({
if (interaction === "chat") { if (interaction === "chat") {
const question = message.text || fields[0]?.label || "这项你想怎么定?"; const question = message.text || fields[0]?.label || "这项你想怎么定?";
const compactChoiceOnly = String(message.payload.topic || "") === "cast_relation"; const compactChoiceOnly = String(message.payload.topic || "") === "cast_relation";
const choiceField = fields.find( // 兼容多种 options 来源:单选、多选、或字段内直接携带 options
(field) => field.type === "single" && Array.isArray(field.options) && field.options.length > 0, const rawChoiceField = fields.find(
(field) => Array.isArray(field.options) && field.options.length > 0,
);
// 如果是痛点方向追问、或文案明确提及「直接点击一个选项」,但在 fields 里未能取到 options,提供兜底选项
const isPainPointQuestion =
fields[0]?.key === "pain_point_direction"
|| /痛点方向|最想拍的痛点|解决的痛点/.test(question)
|| /直接点击一个选项/.test(question);
const fallbackOptions = isPainPointQuestion
? [
{ value: "dry_makeup", label: "换季皮肤干绷紧绷,上妆卡粉斑驳浮粉" },
{ value: "dull_rough", label: "熬夜后脸部粗糙,肤色暗沉蜡黄无光泽" },
{ value: "poor_absorption", label: "护肤浮在表面吸收慢,黏腻厚重不透气" },
]
: [];
const choiceField = rawChoiceField || (
fallbackOptions.length > 0
? {
key: fields[0]?.key || "pain_point_direction",
label: question,
type: "single" as const,
options: fallbackOptions,
}
: null
); );
const savedChoice = choiceField ? String(saved[choiceField.key] || "") : ""; const savedChoice = choiceField ? String(saved[choiceField.key] || "") : "";
const savedChoiceLabel = choiceField?.options?.find((option) => option.value === savedChoice)?.label; const savedChoiceLabel = (choiceField?.options as any[])?.find((option: any) => {
const val = typeof option === "string" ? option : option?.value;
return val === savedChoice;
})?.label || (typeof choiceField?.options?.[0] === "object" ? "" : savedChoice);
const savedChoiceText = savedChoiceLabel || savedChoice; const savedChoiceText = savedChoiceLabel || savedChoice;
const submitChatAnswer = (event: FormEvent<HTMLFormElement>) => { const submitChatAnswer = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
@@ -1491,16 +1579,21 @@ function ElicitCard({
className={`omni-gate-actions${compactChoiceOnly ? "" : " omni-chat-choice-actions"}`} className={`omni-gate-actions${compactChoiceOnly ? "" : " omni-chat-choice-actions"}`}
aria-label={choiceField.label} aria-label={choiceField.label}
> >
{(choiceField.options || []).map((option) => ( {(choiceField.options || []).map((option: any, index: number) => {
<button const label = typeof option === "string" ? option : String(option?.label || option?.text || option?.title || option?.value || `选项 ${index + 1}`).trim();
type="button" const value = typeof option === "string" ? option : String(option?.value || option?.label || option?.text || option?.title || `option_${index + 1}`).trim();
key={option.value} if (!label) return null;
disabled={disabled} return (
onClick={() => onSubmit({ [choiceField.key]: option.value }, [])} <button
> type="button"
{option.label} key={value || index}
</button> disabled={disabled}
))} onClick={() => onSubmit({ [choiceField.key || "choice"]: value }, [])}
>
{label}
</button>
);
})}
</div> </div>
) : null} ) : null}
{!compactChoiceOnly ? ( {!compactChoiceOnly ? (
@@ -1532,7 +1625,7 @@ function ElicitCard({
<section className={`omni-elicit-card${submitted ? " is-submitted" : ""}`}> <section className={`omni-elicit-card${submitted ? " is-submitted" : ""}`}>
<header className="omni-elicit-head"> <header className="omni-elicit-head">
<strong></strong> <strong></strong>
<span>{submitted ? "已回答" : "选一下就好"}</span> <span>{submitted ? (saved._action === "cancel" ? "已取消" : "已回答") : "选一下就好"}</span>
</header> </header>
<div className="omni-elicit-body"> <div className="omni-elicit-body">
{fields.map((field) => { {fields.map((field) => {
@@ -1592,7 +1685,9 @@ function ElicitCard({
<span>{option.name}</span> <span>{option.name}</span>
</button> </button>
))} ))}
{(assetOptions[field.key] || []).length === 0 && <span></span>} {(assetOptions[field.key] || []).length === 0 && (
<span>{submitted && saved._action === "cancel" ? "已取消选择" : "暂无可选素材"}</span>
)}
</div> </div>
)} )}
</div> </div>
@@ -1601,6 +1696,17 @@ function ElicitCard({
</div> </div>
{!submitted && ( {!submitted && (
<div className="omni-elicit-actions"> <div className="omni-elicit-actions">
<button
type="button"
className="cancel-btn"
disabled={disabled}
onClick={() => {
const primaryKey = fields[0]?.key || "choice";
onSubmit({ _action: "cancel", [primaryKey]: "cancel" }, []);
}}
>
</button>
<button <button
type="button" type="button"
disabled={disabled || !complete} disabled={disabled || !complete}
@@ -1746,9 +1852,12 @@ function ResultCard({
const meta = [payload.model, payload.resolution, payload.ratio].filter(Boolean).join(" · "); const meta = [payload.model, payload.resolution, payload.ratio].filter(Boolean).join(" · ");
const isGeneratedVideo = first.type === "video"; const isGeneratedVideo = first.type === "video";
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null); const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
const ratio = String(payload.ratio || "").trim();
const ratioClass = ratio === "16:9" ? "is-ratio-16-9" : ratio === "1:1" ? "is-ratio-1-1" : "is-ratio-9-16";
const multiClass = assets.length > 1 ? "has-multiple" : "has-single";
return ( return (
<section className="omni-result-card"> <section className={`omni-result-card ${multiClass} ${ratioClass}`}>
<div className={`omni-result-media${assets.length > 1 ? " is-grid" : ""}`}> <div className={`omni-result-media${assets.length > 1 ? " is-grid" : ""}`}>
{assets.map((asset, index) => { {assets.map((asset, index) => {
const cover = asset.cover || asset.url || ""; const cover = asset.cover || asset.url || "";
@@ -2114,9 +2223,14 @@ function ProcessCard({
const assets = (payload.assets as Array<Record<string, string>> | undefined) || []; const assets = (payload.assets as Array<Record<string, string>> | undefined) || [];
const completedSegments = Number(payload.completed_segment_count || 0); const completedSegments = Number(payload.completed_segment_count || 0);
const waitingSegments = isSegmentedVideo ? Math.max(1, segmentCount - completedSegments) : 1; const waitingSegments = isSegmentedVideo ? Math.max(1, segmentCount - completedSegments) : 1;
const totalTiles = assets.length + waitingSegments;
const multiClass = totalTiles > 1 || isSegmentedVideo ? "has-multiple" : "has-single";
const ratio = String(payload.ratio || "").trim();
const ratioClass = ratio === "16:9" ? "is-ratio-16-9" : ratio === "1:1" ? "is-ratio-1-1" : "is-ratio-9-16";
return ( return (
<section className="omni-result-card omni-process-card"> <section className={`omni-result-card omni-process-card ${multiClass} ${ratioClass}`}>
<div className={`omni-result-media${isSegmentedVideo ? " is-grid" : ""}`}> <div className={`omni-result-media${multiClass === "has-multiple" ? " is-grid" : ""}`}>
{assets.map((asset, index) => { {assets.map((asset, index) => {
const cover = asset.cover || asset.url || ""; const cover = asset.cover || asset.url || "";
const url = asset.url || cover; const url = asset.url || cover;
@@ -2236,6 +2350,7 @@ export function OmniSessionPage({
const [sessionUploads, setSessionUploads] = useState<CreationRef[]>(firstUploads || []); const [sessionUploads, setSessionUploads] = useState<CreationRef[]>(firstUploads || []);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const personFileInputRef = useRef<HTMLInputElement>(null);
// 对话引导里的「上传人物图」要直达系统文件选择器,而不是绕回通用素材菜单。 // 对话引导里的「上传人物图」要直达系统文件选择器,而不是绕回通用素材菜单。
const quickUploadReplyRef = useRef<ReplyOption | null>(null); const quickUploadReplyRef = useRef<ReplyOption | null>(null);
// 人物来源闸门借用全局文件选择器 / @素材面板,选完后需回到对应卡片提交。 // 人物来源闸门借用全局文件选择器 / @素材面板,选完后需回到对应卡片提交。
@@ -2262,8 +2377,12 @@ export function OmniSessionPage({
const [stopping, setStopping] = useState(false); const [stopping, setStopping] = useState(false);
const [promptView, setPromptView] = useState<{ title: string; body: string } | null>(null); const [promptView, setPromptView] = useState<{ title: string; body: string } | null>(null);
const [assetsOpen, setAssetsOpen] = useState(false); const [assetsOpen, setAssetsOpen] = useState(false);
const [assetsPanelMode, setAssetsPanelMode] = useState<"session" | "model_library">("session");
const [assetView, setAssetView] = useState<"grid" | "list">("grid"); const [assetView, setAssetView] = useState<"grid" | "list">("grid");
const [assetQuery, setAssetQuery] = useState(""); const [assetQuery, setAssetQuery] = useState("");
const [modelLibraryItems, setModelLibraryItems] = useState<ModelEntity[]>([]);
const [modelLibraryLoading, setModelLibraryLoading] = useState(false);
const [selectedModelRef, setSelectedModelRef] = useState<CreationRef | null>(null);
const [assetPreview, setAssetPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null); const [assetPreview, setAssetPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
const [pendingUserId, setPendingUserId] = useState<string | null>(() => bootLocalIdRef.current); const [pendingUserId, setPendingUserId] = useState<string | null>(() => bootLocalIdRef.current);
// App 传进来的 onNotify 是内联箭头,**每次 App 重渲染都是新身份**。 // App 传进来的 onNotify 是内联箭头,**每次 App 重渲染都是新身份**。
@@ -2285,6 +2404,26 @@ export function OmniSessionPage({
} }
}, [notify]); }, [notify]);
const openPersonModelLibrary = useCallback(async (messageId: string) => {
personSourceRequestRef.current = { messageId, source: "model_library" };
setMentionMenuOpen(false);
setUploadMenuOpen(false);
setSessionAssetModalOpen(false);
setAssetsPanelMode("model_library");
setAssetQuery("");
setSelectedModelRef(null);
setAssetsOpen(true);
setModelLibraryLoading(true);
try {
const response = await api.listModels({ pageSize: 200 });
setModelLibraryItems(response.results || []);
} catch (error) {
notify("error", (error as Error).message || "模特库加载失败");
} finally {
setModelLibraryLoading(false);
}
}, [notify]);
const editUserMessage = useCallback((message: CreationMessage) => { const editUserMessage = useCallback((message: CreationMessage) => {
const refs = message.refs || []; const refs = message.refs || [];
setPrompt(message.text); setPrompt(message.text);
@@ -2465,6 +2604,13 @@ export function OmniSessionPage({
})) }))
.filter((group) => group.items.length > 0); .filter((group) => group.items.length > 0);
}, [assetQuery, sessionAssetGroups]); }, [assetQuery, sessionAssetGroups]);
const visibleModelLibraryItems = useMemo(() => {
const query = assetQuery.trim().toLocaleLowerCase();
if (!query) return modelLibraryItems;
return modelLibraryItems.filter((model) =>
`${model.name} ${model.description || ""}`.toLocaleLowerCase().includes(query)
);
}, [assetQuery, modelLibraryItems]);
messagesRef.current = messages; messagesRef.current = messages;
// 出片在 worker 里跑。刷新后 GENERATING 还在库里,进来立刻拉一次再轮询, // 出片在 worker 里跑。刷新后 GENERATING 还在库里,进来立刻拉一次再轮询,
@@ -2639,7 +2785,7 @@ export function OmniSessionPage({
const send = useCallback( const send = useCallback(
async (payload: Parameters<typeof api.creationSend>[1]) => { async (payload: Parameters<typeof api.creationSend>[1]) => {
// awaiting_user 下允许回答闸门;其它状态仍禁止并发发送 // awaiting_user 下允许回答闸门;其它状态仍禁止并发发送
if (streamingRef.current && conversation?.agent_status !== "awaiting_user") return; if (streamingRef.current && conversation?.agent_status !== "awaiting_user") return false;
// 先占位用户气泡,再亮「正在整理方案」—— 顺序不能反 // 先占位用户气泡,再亮「正在整理方案」—— 顺序不能反
const isTextTurn = payload.kind !== "elicit_answer"; const isTextTurn = payload.kind !== "elicit_answer";
let localId: string | null = null; let localId: string | null = null;
@@ -2711,7 +2857,7 @@ export function OmniSessionPage({
/* 已 idle / 竞态忽略 */ /* 已 idle / 竞态忽略 */
}); });
} }
return; return false;
} }
setConversation((prev) => setConversation((prev) =>
prev prev
@@ -2739,6 +2885,7 @@ export function OmniSessionPage({
.catch(() => { .catch(() => {
/* agent poll 会接着拉 */ /* agent poll 会接着拉 */
}); });
return true;
} catch (error) { } catch (error) {
if (localId) { if (localId) {
setMessages((prev) => prev.filter((m) => m.id !== localId)); setMessages((prev) => prev.filter((m) => m.id !== localId));
@@ -2754,11 +2901,37 @@ export function OmniSessionPage({
setActiveTool(""); setActiveTool("");
const status = (error as { status?: number }).status; const status = (error as { status?: number }).status;
notify(status === 409 ? "info" : "error", (error as Error).message); notify(status === 409 ? "info" : "error", (error as Error).message);
return false;
} }
}, },
[conversationId, conversation?.agent_status, mergeCreationDetail, notify] [conversationId, conversation?.agent_status, mergeCreationDetail, notify]
); );
const submitPersonSourceChoice = useCallback(async (
messageId: string,
source: "local_upload" | "model_library" | "platform_generate",
refs: CreationRef[] = [],
personPrompt = "",
) => {
const answers: Record<string, string> = { person_source: source };
if (source === "platform_generate") answers.person_prompt = personPrompt.trim();
const succeeded = await send({
kind: "elicit_answer",
reply_to: messageId,
answers,
refs,
});
if (!succeeded) return false;
setMessages((prev) =>
prev.map((item) =>
item.id === messageId
? { ...item, payload: { ...item.payload, submitted: true, answers } }
: item
)
);
return true;
}, [send]);
const handleStop = useCallback(async () => { const handleStop = useCallback(async () => {
if (stopping || !isPlanning) return; if (stopping || !isPlanning) return;
setStopping(true); setStopping(true);
@@ -2917,31 +3090,13 @@ export function OmniSessionPage({
const insertMention = (ref: CreationRef) => { const insertMention = (ref: CreationRef) => {
const personRequest = personSourceRequestRef.current; const personRequest = personSourceRequestRef.current;
if (personRequest) { if (personRequest) {
if (ref.type !== "model" && ref.type !== "character") { if (personRequest.source !== "model_library" || ref.type !== "model") {
notify("info", "请选择一位模特或角色"); notify("info", "请选择模特库中的人物");
return; return;
} }
personSourceRequestRef.current = null;
setMentionMenuOpen(false); setMentionMenuOpen(false);
setMessages((prev) => void submitPersonSourceChoice(personRequest.messageId, "model_library", [ref]).then((succeeded) => {
prev.map((item) => if (succeeded) personSourceRequestRef.current = null;
item.id === personRequest.messageId
? {
...item,
payload: {
...item.payload,
submitted: true,
answers: { person_source: "model_library" },
},
}
: item
)
);
void send({
kind: "elicit_answer",
reply_to: personRequest.messageId,
answers: { person_source: "model_library" },
refs: [ref],
}); });
return; return;
} }
@@ -3008,43 +3163,22 @@ export function OmniSessionPage({
void send({ kind: "elicit_answer", reply_to: message.id, answers, refs }); void send({ kind: "elicit_answer", reply_to: message.id, answers, refs });
}} }}
onChatAnswer={(text) => void send({ kind: "text", text })} onChatAnswer={(text) => void send({ kind: "text", text })}
onPersonSourceAction={(source) => { onPersonSourceAction={(source, personPrompt = "") => {
if (source === "local_upload") { if (source === "local_upload") {
personSourceRequestRef.current = { messageId: message.id, source }; personSourceRequestRef.current = { messageId: message.id, source };
quickUploadReplyRef.current = null; quickUploadReplyRef.current = null;
setMentionMenuOpen(false); setMentionMenuOpen(false);
setUploadMenuOpen(false); setUploadMenuOpen(false);
fileInputRef.current?.click(); personFileInputRef.current?.click();
return; return;
} }
if (source === "model_library") { if (source === "model_library") {
personSourceRequestRef.current = { messageId: message.id, source }; void openPersonModelLibrary(message.id);
setMentionMenuOpen(false);
setSessionAssetModalType("character");
setSessionAssetModalOpen(true);
return; return;
} }
setMessages((prev) => void submitPersonSourceChoice(message.id, source, [], personPrompt);
prev.map((item) =>
item.id === message.id
? {
...item,
payload: {
...item.payload,
submitted: true,
answers: { person_source: source },
},
}
: item
)
);
void send({
kind: "elicit_answer",
reply_to: message.id,
answers: { person_source: source },
refs: [],
});
}} }}
onUpload={() => fileInputRef.current?.click()}
/> />
); );
case "strategy": case "strategy":
@@ -3222,7 +3356,11 @@ export function OmniSessionPage({
aria-expanded={assetsOpen} aria-expanded={assetsOpen}
aria-controls="omni-session-assets-drawer" aria-controls="omni-session-assets-drawer"
title="查看对话资源" title="查看对话资源"
onClick={() => setAssetsOpen((open) => !open)} onClick={() => {
setAssetsPanelMode("session");
setAssetQuery("");
setAssetsOpen((open) => !open || assetsPanelMode !== "session");
}}
> >
<List /> <List />
<span></span> <span></span>
@@ -3350,16 +3488,10 @@ export function OmniSessionPage({
const files = Array.from(event.target.files || []); const files = Array.from(event.target.files || []);
event.target.value = ""; event.target.value = "";
if (!files.length) return; if (!files.length) return;
const personRequest = personSourceRequestRef.current;
const selectedImages = files.filter((file) => file.type.startsWith("image/")); const selectedImages = files.filter((file) => file.type.startsWith("image/"));
const images = personRequest ? selectedImages.slice(0, 1) : selectedImages; const images = selectedImages;
if (images.length !== files.length) { if (images.length !== files.length) {
notify( notify("info", "全能创作仅支持上传图片");
"info",
personRequest && selectedImages.length > 1
? "人物参考每次选择一张图片"
: "全能创作仅支持上传图片",
);
} }
if (!images.length) return; if (!images.length) return;
setUploading(true); setUploading(true);
@@ -3372,15 +3504,14 @@ export function OmniSessionPage({
// 后端上传时同步送审并等待通过,期间按钮保持「上传中」 // 后端上传时同步送审并等待通过,期间按钮保持「上传中」
const data = await api.uploadFreeVideoRef(form); const data = await api.uploadFreeVideoRef(form);
const ref: CreationRef = { const ref: CreationRef = {
// 人物闸门上传的图是身份参考,不能作为普通 asset 传给出片模型。 type: "asset",
type: personRequest ? "character" : "asset",
id: data.asset_id, id: data.asset_id,
name: data.name || file.name, name: data.name || file.name,
cover: data.thumb_url || data.url, cover: data.thumb_url || data.url,
}; };
setSessionUploads((prev) => (prev.some((item) => item.id === ref.id) ? prev : [...prev, ref])); setSessionUploads((prev) => (prev.some((item) => item.id === ref.id) ? prev : [...prev, ref]));
uploadedRefs.push(ref); uploadedRefs.push(ref);
if (!quickReply && !personRequest) { if (!quickReply) {
setPendingRefs((prev) => { setPendingRefs((prev) => {
if (prev.some((item) => item.id === ref.id) || prev.length >= MENTION_REF_LIMIT) return prev; if (prev.some((item) => item.id === ref.id) || prev.length >= MENTION_REF_LIMIT) return prev;
return [...prev, ref]; return [...prev, ref];
@@ -3388,40 +3519,62 @@ export function OmniSessionPage({
insertPromptMention(ref); insertPromptMention(ref);
} }
} }
notify( notify("success", "素材已上传并通过审核");
"success", if (quickReply && uploadedRefs.length) {
personRequest ? "人物参考已上传并锁定" : "素材已上传并通过审核",
);
if (personRequest && uploadedRefs.length) {
personSourceRequestRef.current = null;
setMessages((prev) =>
prev.map((item) =>
item.id === personRequest.messageId
? {
...item,
payload: {
...item.payload,
submitted: true,
answers: { person_source: "local_upload" },
},
}
: item
)
);
void send({
kind: "elicit_answer",
reply_to: personRequest.messageId,
answers: { person_source: "local_upload" },
refs: uploadedRefs,
});
} else if (quickReply && uploadedRefs.length) {
quickUploadReplyRef.current = null; quickUploadReplyRef.current = null;
void send({ kind: "text", text: uploadReplyText(quickReply), refs: uploadedRefs }); void send({ kind: "text", text: uploadReplyText(quickReply), refs: uploadedRefs });
} }
} catch (error) { } catch (error) {
notify("error", (error as Error).message); notify("error", (error as Error).message);
quickUploadReplyRef.current = null; quickUploadReplyRef.current = null;
personSourceRequestRef.current = null; } finally {
setUploading(false);
}
}}
/>
<input
ref={personFileInputRef}
type="file"
accept="image/*"
hidden
onChange={async (event) => {
const file = event.target.files?.[0];
event.target.value = "";
const personRequest = personSourceRequestRef.current;
if (!file || !personRequest || personRequest.source !== "local_upload") {
if (!file) personSourceRequestRef.current = null;
return;
}
const isPet = Boolean(conversation?.preset && /宠物/.test(conversation.preset));
if (!file.type.startsWith("image/")) {
notify("info", isPet ? "请选择一张宠物图片" : "请选择一张人物图片");
return;
}
setUploading(true);
try {
const form = new FormData();
form.append("file", file);
const data = await api.uploadFreeVideoRef(form);
const ref: CreationRef = {
type: "character",
id: data.asset_id,
name: data.name || file.name,
cover: data.thumb_url || data.url,
};
setSessionUploads((prev) =>
prev.some((item) => item.id === ref.id) ? prev : [...prev, ref]
);
const succeeded = await submitPersonSourceChoice(
personRequest.messageId,
"local_upload",
[ref],
);
if (succeeded) {
personSourceRequestRef.current = null;
notify("success", isPet ? "宠物参考已上传并锁定" : "人物参考已上传并锁定");
}
} catch (error) {
notify("error", (error as Error).message || (isPet ? "宠物图片上传失败" : "人物图片上传失败"));
} finally { } finally {
setUploading(false); setUploading(false);
} }
@@ -3597,50 +3750,113 @@ export function OmniSessionPage({
id="omni-session-assets-drawer" id="omni-session-assets-drawer"
className="drawer omni-assets-drawer show" className="drawer omni-assets-drawer show"
role="dialog" role="dialog"
aria-label="对话资源" aria-label={assetsPanelMode === "model_library" ? "选择模特" : "对话资源"}
> >
<header className="drawer-h omni-assets-drawer-head"> <header className="drawer-h omni-assets-drawer-head">
<div> <div>
<strong></strong> <strong>{assetsPanelMode === "model_library" ? "模特库" : "资源"}</strong>
<small>{sessionAssets.length ? `本会话 · ${sessionAssets.length}` : "仅展示本会话素材"}</small> <small>
{assetsPanelMode === "model_library"
? modelLibraryLoading
? "正在加载模特"
: `选择一位出镜人物 · ${modelLibraryItems.length}`
: sessionAssets.length
? `本会话 · ${sessionAssets.length}`
: "仅展示本会话素材"}
</small>
</div> </div>
<div className="omni-assets-panel-tools"> <div className="omni-assets-panel-tools">
<div className="view-toggle" aria-label="资源显示方式"> {assetsPanelMode === "session" ? (
<button <div className="view-toggle" aria-label="资源显示方式">
type="button" <button
className={assetView === "grid" ? "active" : ""} type="button"
aria-label="网格显示" className={assetView === "grid" ? "active" : ""}
title="网格显示" aria-label="网格显示"
onClick={() => setAssetView("grid")} title="网格显示"
> onClick={() => setAssetView("grid")}
<Grid2X2 /> >
</button> <Grid2X2 />
<button </button>
type="button" <button
className={assetView === "list" ? "active" : ""} type="button"
aria-label="列表显示" className={assetView === "list" ? "active" : ""}
title="列表显示" aria-label="列表显示"
onClick={() => setAssetView("list")} title="列表显示"
> onClick={() => setAssetView("list")}
<List /> >
</button> <List />
</div> </button>
</div>
) : null}
<label className="omni-assets-search"> <label className="omni-assets-search">
<Search /> <Search />
<input <input
value={assetQuery} value={assetQuery}
onChange={(event) => setAssetQuery(event.target.value)} onChange={(event) => setAssetQuery(event.target.value)}
placeholder="搜索资源…" placeholder={assetsPanelMode === "model_library" ? "搜索模特…" : "搜索资源…"}
aria-label="搜索对话资源" aria-label={assetsPanelMode === "model_library" ? "搜索模特" : "搜索对话资源"}
/> />
</label> </label>
<button type="button" className="x" onClick={() => setAssetsOpen(false)} aria-label="关闭资源"> <button
type="button"
className="x"
onClick={() => {
setAssetsOpen(false);
if (assetsPanelMode === "model_library") {
personSourceRequestRef.current = null;
setSelectedModelRef(null);
}
}}
aria-label={assetsPanelMode === "model_library" ? "关闭模特库" : "关闭资源"}
>
<X /> <X />
</button> </button>
</div> </div>
</header> </header>
<div className="drawer-b omni-assets-drawer-body"> <div className="drawer-b omni-assets-drawer-body">
{sessionAssets.length === 0 ? ( {assetsPanelMode === "model_library" ? (
modelLibraryLoading ? (
<div className="omni-assets-empty" role="status">
<span className="omni-send-spinner" />
<p></p>
</div>
) : visibleModelLibraryItems.length === 0 ? (
<div className="omni-assets-empty" role="status">
<Search />
<p>{modelLibraryItems.length ? "没有找到相关模特" : "模特库还没有人物"}</p>
</div>
) : (
<div className="omni-model-picker-grid" role="listbox" aria-label="模特列表">
{visibleModelLibraryItems.map((model) => {
const ref: CreationRef = {
type: "model",
id: model.id,
name: model.name,
cover: model.portrait,
};
const selected = selectedModelRef?.id === model.id;
return (
<button
type="button"
role="option"
aria-selected={selected}
className={`omni-model-picker-card${selected ? " is-selected" : ""}`}
key={model.id}
onClick={() => setSelectedModelRef(ref)}
>
<span className="omni-model-picker-media">
{model.portrait ? <img src={model.portrait} alt="" /> : <UserRound />}
</span>
<span className="omni-model-picker-copy">
<strong>{model.name}</strong>
<small>{model.is_official ? "官方模特" : "我的模特"}</small>
</span>
</button>
);
})}
</div>
)
) : sessionAssets.length === 0 ? (
<div className="omni-assets-empty" role="status"> <div className="omni-assets-empty" role="status">
<FolderOpen /> <FolderOpen />
<p></p> <p></p>
@@ -3721,6 +3937,44 @@ export function OmniSessionPage({
)) ))
)} )}
</div> </div>
{assetsPanelMode === "model_library" ? (
<footer className="drawer-f omni-model-picker-footer">
<span className="omni-model-picker-selection">
{selectedModelRef ? `已选择:${selectedModelRef.name}` : "请选择一位模特"}
</span>
<button
type="button"
className="btn"
onClick={() => {
setAssetsOpen(false);
personSourceRequestRef.current = null;
setSelectedModelRef(null);
}}
>
</button>
<button
type="button"
className="btn btn-primary"
disabled={!selectedModelRef || streaming}
onClick={async () => {
const request = personSourceRequestRef.current;
if (!request || !selectedModelRef) return;
const succeeded = await submitPersonSourceChoice(
request.messageId,
"model_library",
[selectedModelRef],
);
if (!succeeded) return;
personSourceRequestRef.current = null;
setAssetsOpen(false);
setSelectedModelRef(null);
}}
>
使
</button>
</footer>
) : null}
</aside> </aside>
) : null} ) : null}
<MediaLightbox <MediaLightbox
@@ -3733,42 +3987,8 @@ export function OmniSessionPage({
<AssetSelectModal <AssetSelectModal
open={sessionAssetModalOpen} open={sessionAssetModalOpen}
type={sessionAssetModalType} type={sessionAssetModalType}
onClose={() => { onClose={() => setSessionAssetModalOpen(false)}
setSessionAssetModalOpen(false);
if (personSourceRequestRef.current?.source === "model_library") {
personSourceRequestRef.current = null;
}
}}
onSelect={(newRef) => { onSelect={(newRef) => {
const personRequest = personSourceRequestRef.current;
if (personRequest) {
if (newRef.type !== "model" && newRef.type !== "character") {
notify("info", "请选择一位角色");
return;
}
personSourceRequestRef.current = null;
setMessages((prev) =>
prev.map((item) =>
item.id === personRequest.messageId
? {
...item,
payload: {
...item.payload,
submitted: true,
answers: { person_source: "model_library" },
},
}
: item,
),
);
void send({
kind: "elicit_answer",
reply_to: personRequest.messageId,
answers: { person_source: "model_library" },
refs: [newRef],
});
return;
}
setPendingRefs((prev) => { setPendingRefs((prev) => {
if (prev.some((item) => item.type === newRef.type && item.id === newRef.id) || prev.length >= MENTION_REF_LIMIT) { if (prev.some((item) => item.type === newRef.type && item.id === newRef.id) || prev.length >= MENTION_REF_LIMIT) {
return prev; return prev;