添加艾特角色功能
This commit is contained in:
@@ -79,6 +79,24 @@ _PERSON_VISUAL_RE = re.compile(
|
||||
r"(人物|角色|模特|主角|达人|主播|出镜|口播|女生|女性|男生|男性|"
|
||||
r"女主|男主|年轻人|手模|手部|真人|换装|穿搭|剧情|短剧)"
|
||||
)
|
||||
_CAST_RELATION_RE = re.compile(
|
||||
r"(共同出镜|一起出镜|一同出镜|同框|都(?:要|会|需)?出镜|全部(?:角色)?出镜|"
|
||||
r"(?:一起|一同)(?:拍|演)|全员出镜|(?:三个|三位|两位|两个|大家)(?:都要|一起)|"
|
||||
r"轮流出镜|分别出镜|主讲|主角|主演|辅助出镜|配角|只(?:用|留|要)|单人出镜)"
|
||||
)
|
||||
_CAST_RELATION_AUTO_RE = re.compile(r"(你来定|你安排|你决定|随便|都行)")
|
||||
_PRODUCT_REQUIRED_PRESETS = {
|
||||
"痛点解决演示",
|
||||
PLOT_TWIST_PRESET,
|
||||
"达人口播种草",
|
||||
"点击换款",
|
||||
"多色商品换款",
|
||||
"点触换款",
|
||||
"商品拟人广告",
|
||||
"商品图一键成片",
|
||||
"前后对比实测",
|
||||
"AI 宠物拟人",
|
||||
}
|
||||
|
||||
|
||||
def is_plot_twist_conversation(conversation: CreationConversation) -> bool:
|
||||
@@ -351,6 +369,167 @@ def has_locked_person_reference(conversation: CreationConversation) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def locked_person_references(conversation: CreationConversation) -> list[dict]:
|
||||
"""返回去重后的已锁定人物,顺序与用户添加顺序一致。"""
|
||||
people: list[dict] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for ref in conversation.pinned_refs or []:
|
||||
if not isinstance(ref, dict) or ref.get("type") not in {"model", "character"} or not ref.get("id"):
|
||||
continue
|
||||
key = (str(ref.get("type")), str(ref.get("id")))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
people.append(ref)
|
||||
return people
|
||||
|
||||
|
||||
def _cast_relation_signature(conversation: CreationConversation) -> str:
|
||||
return "|".join(
|
||||
f"{ref.get('type')}:{ref.get('id')}"
|
||||
for ref in locked_person_references(conversation)
|
||||
)
|
||||
|
||||
|
||||
def cast_relation_options(conversation: CreationConversation) -> list[dict[str, str]]:
|
||||
"""把当前已锁定人物直接变成一键选择项,不再让用户重复输入名称。"""
|
||||
options = [{"value": "all_together", "label": "全部共同出镜"}]
|
||||
for ref in locked_person_references(conversation):
|
||||
name = str(ref.get("name") or "未命名角色").split(" · ")[0].strip() or "未命名角色"
|
||||
options.append({
|
||||
"value": f"lead:{ref.get('type')}:{ref.get('id')}",
|
||||
"label": f"{name}主讲",
|
||||
})
|
||||
return options
|
||||
|
||||
|
||||
def apply_cast_relation_choice(conversation: CreationConversation, choice: str) -> str:
|
||||
"""保存多角色标签选择,并返回可直接喂给 Agent 的完整人物关系。"""
|
||||
raw = str(choice or "").strip()
|
||||
people = locked_person_references(conversation)
|
||||
signature = _cast_relation_signature(conversation)
|
||||
relation = ""
|
||||
if raw == "all_together":
|
||||
relation = "全部已锁定角色共同出镜"
|
||||
elif raw.startswith("lead:"):
|
||||
selected = next(
|
||||
(
|
||||
ref for ref in people
|
||||
if raw == f"lead:{ref.get('type')}:{ref.get('id')}"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if selected is not None:
|
||||
name = str(selected.get("name") or "未命名角色").split(" · ")[0].strip() or "未命名角色"
|
||||
relation = f"{name}作为主讲,其余已锁定角色辅助出镜"
|
||||
if not relation:
|
||||
return ""
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["cast_relation"] = relation
|
||||
memory["cast_relation_ref_signature"] = signature
|
||||
memory.pop("cast_relation_pending_signature", None)
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
return relation
|
||||
|
||||
|
||||
def _normalized_cast_relation(conversation: CreationConversation, user_text: str) -> str:
|
||||
"""把简短回答补成不会丢角色的出镜安排。"""
|
||||
answer = str(user_text or "").strip()
|
||||
if _CAST_RELATION_AUTO_RE.search(answer):
|
||||
return "由系统安排一位主讲,其余已锁定角色辅助出镜;全部角色都保留"
|
||||
if re.search(
|
||||
r"(共同出镜|一起出镜|一同出镜|同框|都(?:要|会|需)?出镜|全部(?:角色)?出镜|"
|
||||
r"(?:一起|一同)(?:拍|演)|全员出镜|(?:三个|三位|两位|两个|大家)(?:都要|一起))",
|
||||
answer,
|
||||
):
|
||||
return "全部已锁定角色共同出镜"
|
||||
if not _CAST_RELATION_RE.search(answer):
|
||||
# 闸门明确提示「有主讲就直接说角色名」;用户只回名字时,补全语义。
|
||||
names = [str(ref.get("name") or "").strip() for ref in locked_person_references(conversation)]
|
||||
aliases = {
|
||||
alias
|
||||
for name in names
|
||||
for alias in (name, name.split(" · ")[0].strip())
|
||||
if alias
|
||||
}
|
||||
if any(answer == alias or answer in alias or alias in answer for alias in aliases):
|
||||
return f"{answer}作为主讲,其余已锁定角色辅助出镜"
|
||||
return answer
|
||||
|
||||
|
||||
def multi_character_relation_needs_clarification(
|
||||
conversation: CreationConversation,
|
||||
user_text: str = "",
|
||||
) -> bool:
|
||||
"""多人物已添加但关系不明时,先确认共同出镜还是主讲+辅助。
|
||||
|
||||
用人物 Ref 签名记录答案:后续新增/替换人物会重新确认,原班角色继续改稿时
|
||||
不会反复追问。若当前消息本身已经写明关系,则直接采纳并放行。
|
||||
"""
|
||||
people = locked_person_references(conversation)
|
||||
if len(people) < 2:
|
||||
return False
|
||||
signature = _cast_relation_signature(conversation)
|
||||
memory = dict(conversation.memory or {})
|
||||
if memory.get("cast_relation_ref_signature") == signature and memory.get("cast_relation"):
|
||||
return False
|
||||
|
||||
text = str(user_text or "").strip()
|
||||
if memory.get("cast_relation_pending_signature") == signature:
|
||||
if not text:
|
||||
return True
|
||||
memory["cast_relation"] = _normalized_cast_relation(conversation, text)
|
||||
memory["cast_relation_ref_signature"] = signature
|
||||
memory.pop("cast_relation_pending_signature", None)
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
return False
|
||||
|
||||
if _CAST_RELATION_RE.search(text) or _CAST_RELATION_AUTO_RE.search(text):
|
||||
memory["cast_relation"] = _normalized_cast_relation(conversation, text)
|
||||
memory["cast_relation_ref_signature"] = signature
|
||||
memory.pop("cast_relation_pending_signature", None)
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def append_multi_character_relation_gate(conversation: CreationConversation) -> CreationMessage:
|
||||
"""保留全部人物,只询问人物之间的出镜关系。"""
|
||||
people = locked_person_references(conversation)
|
||||
signature = _cast_relation_signature(conversation)
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["cast_relation_pending_signature"] = signature
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
count = len(people)
|
||||
question = (
|
||||
f"这次已经添加了 {count} 位角色,我都会保留。"
|
||||
"直接选择共同出镜,或点一位角色作为主讲,其余角色会辅助出镜。"
|
||||
)
|
||||
return append_message(
|
||||
conversation,
|
||||
role="assistant",
|
||||
kind=CreationMessage.Kind.ELICIT,
|
||||
text=question,
|
||||
payload={
|
||||
"interaction": "chat",
|
||||
"topic": "cast_relation",
|
||||
"fields": [{
|
||||
"key": "cast_relation",
|
||||
"label": question,
|
||||
"type": "single",
|
||||
"required": True,
|
||||
"options": cast_relation_options(conversation),
|
||||
}],
|
||||
"submitted": False,
|
||||
"answers": {},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def video_needs_person_source(conversation: CreationConversation, user_text: str = "") -> bool:
|
||||
"""需要真人/角色的视频在写策略前必须先锁定人物来源。"""
|
||||
if conversation.mode != CreationConversation.Mode.VIDEO or has_locked_person_reference(conversation):
|
||||
@@ -367,13 +546,44 @@ def video_needs_person_source(conversation: CreationConversation, user_text: str
|
||||
return bool(_PERSON_VISUAL_RE.search("\n".join([user_text, pending_prompt, *recent])))
|
||||
|
||||
|
||||
def has_locked_product_reference(conversation: CreationConversation) -> bool:
|
||||
return any(
|
||||
isinstance(ref, dict) and ref.get("type") == "product" and ref.get("id")
|
||||
for ref in (conversation.pinned_refs or [])
|
||||
)
|
||||
|
||||
|
||||
def creation_needs_product_source(conversation: CreationConversation, user_text: str = "") -> bool:
|
||||
"""明确的商品类预设缺少结构化商品时,先索取而不是把普通素材猜成商品。
|
||||
|
||||
自由创作仍由 Agent 的 ask_user 规则和无输出兜底负责自然追问,避免抢在模型
|
||||
已有澄清问题之前重复弹卡;预设创作则由平台确定性保证商品前置。
|
||||
"""
|
||||
if has_locked_product_reference(conversation):
|
||||
return False
|
||||
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
|
||||
if memory.get("product_source_resolved"):
|
||||
return False
|
||||
return conversation.preset in _PRODUCT_REQUIRED_PRESETS
|
||||
|
||||
|
||||
def append_person_source_gate(conversation: CreationConversation) -> CreationMessage:
|
||||
"""可视化的人物来源闸门;三个选项分别进文件、模特库和生图流程。"""
|
||||
product_name = ""
|
||||
for ref in (conversation.pinned_refs or []):
|
||||
if isinstance(ref, dict) and ref.get("type") == "product":
|
||||
product_name = str(ref.get("name") or "").split(" · ")[0].strip()
|
||||
break
|
||||
prompt_text = (
|
||||
f"商品已选定【{product_name}】。这条视频想由哪位角色/达人出镜?选定后,所有镜头和分段都会锁定同一位人物。"
|
||||
if product_name else
|
||||
"先确定这条视频的出镜人物。选定后,所有镜头和分段都会锁定同一位人物。"
|
||||
)
|
||||
return append_message(
|
||||
conversation,
|
||||
role="assistant",
|
||||
kind=CreationMessage.Kind.ELICIT,
|
||||
text="先确定这条视频的出镜人物。选定后,所有镜头和分段都会锁定同一位人物。",
|
||||
text=prompt_text,
|
||||
payload={
|
||||
"interaction": "person_source_gate",
|
||||
"fields": [{
|
||||
@@ -737,7 +947,7 @@ video_prompt 是交给出片模型的完整制作文件,不是方案摘要、
|
||||
禁止无依据出现破损、漏液、渗水、失效、异常变形、脏污,或把商品拿去做超出用途的压力测试。
|
||||
不确定防水、防漏、承重、耐热、容量、材质等关键能力时,不编造测试和结果;要么问用户,要么改用可观察的正常使用动作。
|
||||
- 画面中不出现新增字幕、花字、标题贴片、弹幕、角标、水印、购物浮层或说明性文字;口播只存在于声音,包装本身原有印刷字除外。
|
||||
- 结尾单列「全片一致性与约束」:用正向、可执行的句子重申角色、商品、场景、光线、服装/材质的连续性。
|
||||
- 结尾单列「全片一致性与禁用项」:用正向、可执行的句子重申角色、商品、场景、光线、服装/材质的连续性。
|
||||
- 用户说「商品说话 / 商品自述 / 商品拟人」时,默认无脸拟人:商品声音是画外角色声,商品本体不做口型、不新增卡通五官;性格只通过整体倾斜、转向、弹跳、进退、镜头和音效表达。只有用户明确要求可见卡通五官时才例外。
|
||||
- 审核安全必须在第一次生成时完成,不能依赖提交前清洗。没有锁定人物素材时,人物只写「成年女性 / 成年男性 / 成年人」,不要自创精确年龄区间,不使用带幼态联想的称呼、音色或人设。服装写日常得体,默认平视、自然俯拍或尊重主体的正面构图,不强调身体局部。
|
||||
- 最终 video_prompt 只使用正向安全描述。不要把平台风险类别、禁用词或用户原始高风险措辞逐项写进 Prompt,否定句、免责声明和「禁止出现某词」也不能照抄;先在内部把冲突改写成成年人之间积极、友善的日常互动,再输出改写后的可拍内容。
|
||||
@@ -1242,6 +1452,7 @@ def apply_restart_intent(conversation: CreationConversation) -> int:
|
||||
memory.pop("selling_point_ready", None)
|
||||
memory.pop("selling_point_mode", None)
|
||||
memory.pop("selling_point", None)
|
||||
memory.pop("product_source_resolved", None)
|
||||
memory.pop("pain_point_direction_ready", None)
|
||||
memory.pop("pain_point_direction", None)
|
||||
memory.pop("click_swap_ready", None)
|
||||
@@ -1950,8 +2161,8 @@ def segment_video_prompt(prompt: str, segment: dict, total_duration: int) -> str
|
||||
return (
|
||||
f"{prompt.strip()}\n\n"
|
||||
f"【分段出片约束】这是整支 {total_duration} 秒视频的第 {index} 段,只生成 {start}–{end} 秒的内容。"
|
||||
"仅呈现这一时间段对应的情节与镜头。必须继续使用参考图锁定的同一位人物,"
|
||||
"不得在本段重新设计、随机替换或改变其五官、发型、年龄、身形和服装。"
|
||||
"仅呈现这一时间段对应的情节与镜头。必须继续使用参考图锁定的原有人物阵容与身份关系,"
|
||||
"不得在本段重新设计、随机替换、遗漏人物或改变各自的五官、发型、年龄、身形和服装。"
|
||||
"承接上一段的动作、商品、场景和光线,"
|
||||
"为下一段留出自然动作衔接;不要重演完整故事,不要添加字幕、文字、角标或水印。"
|
||||
)
|
||||
@@ -1970,12 +2181,22 @@ def apply_person_identity_guard(prompt: str, references: list[dict]) -> str:
|
||||
if not indexes:
|
||||
return prompt
|
||||
labels = "、".join(f"参考图{index}" for index in indexes)
|
||||
return (
|
||||
f"{prompt.strip()}\n\n【人物一致性硬约束】{labels}定义本片的固定出镜人物。"
|
||||
if len(indexes) == 1:
|
||||
identity_rule = (
|
||||
"整片及所有分段、远景、近景、转场都必须保持同一人;五官比例、脸型、发型、"
|
||||
"肤色、年龄、身形、手部特征和基础服装不得漂移。不得换人、随机造人、合并成新面孔,"
|
||||
"不得因镜头或光线变化而改变身份。"
|
||||
)
|
||||
else:
|
||||
identity_rule = (
|
||||
"这些参考图分别对应不同角色。整片及所有分段、远景、近景、转场都必须保持每位角色各自的"
|
||||
"五官比例、脸型、发型、肤色、年龄、身形、手部特征和基础服装;人物不得互换、遗漏、"
|
||||
"随机替换或合并成新面孔,也不得因镜头或光线变化而改变任何角色身份。"
|
||||
)
|
||||
return (
|
||||
f"{prompt.strip()}\n\n【人物一致性硬约束】{labels}定义本片的固定出镜人物。"
|
||||
f"{identity_rule}"
|
||||
)
|
||||
|
||||
|
||||
def video_duration(params: dict, *, prompt: str = "", timeline: list[dict] | None = None) -> int:
|
||||
@@ -2452,6 +2673,8 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
|
||||
"- ask_user 一次只问一件事。type=asset 会以类Agent自然追问轻量询问是否发送列表或由你推荐;其他类型显示普通聊天问题,让用户直接输入。",
|
||||
"- 商品是谁、给谁看、什么调性 —— 这些缺了会直接影响成片,值得问。",
|
||||
"- 让用户选商品/角色/模特/场景时,ask_user 必须用 type=asset 并填 asset_types。",
|
||||
"- 用户已经添加多位角色并分别描述造型时,这些角色默认都属于本次创作,不得擅自删掉或要求用户从中三选一。"
|
||||
" 如果人物关系没说清,只确认‘共同出镜’还是‘一位主讲、其余辅助’;只有用户明确说只用一位时才缩减角色。",
|
||||
"- 用户说「改商品」「换角色」却没点名是哪个:立刻 ask_user 启动素材确认,不要强行出大卡片。",
|
||||
"- 用户说改时长/模型/比例/分辨率但没给新值:立刻 ask_user,type=single 提供可识别的候选值;聊天里只显示问题,用户直接输入。回答后旧方案作废,必须按新参数重新 write_plan。",
|
||||
"- 光线、构图、镜头这些专业判断是你的活,不要反过来问用户。",
|
||||
@@ -2601,6 +2824,20 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
|
||||
"图上看不清或没附图时,必须问用户,禁止凭文件名猜测男女。"
|
||||
)
|
||||
memory = conversation.memory or {}
|
||||
people = locked_person_references(conversation)
|
||||
if len(people) > 1:
|
||||
names = "、".join(str(ref.get("name") or "未命名角色") for ref in people)
|
||||
relation = str(memory.get("cast_relation") or "").strip()
|
||||
keep_rule = (
|
||||
"按用户明确要求缩减出镜角色,但未出镜的人物素材仍保留在会话中。"
|
||||
if re.search(r"(只用|只留|只要|单人出镜)", relation)
|
||||
else "这些角色默认都必须保留。"
|
||||
)
|
||||
lines.append(f"\n【多角色锁定】本次已添加 {len(people)} 位角色:{names}。{keep_rule}")
|
||||
if relation:
|
||||
lines.append(f"已确认出镜安排:{relation}。后续策略、脚本和分镜必须遵守,不得再次询问同一问题。")
|
||||
else:
|
||||
lines.append("出镜关系尚未确认:只询问共同出镜还是主讲+辅助,不得问‘三位选哪一位’。")
|
||||
if memory.get("summary"):
|
||||
lines.append(f"\n【前情提要】{memory['summary']}")
|
||||
artifacts = memory.get("artifacts") or []
|
||||
@@ -3101,6 +3338,25 @@ def iter_creation_agent_events(
|
||||
yield {"type": "done"}
|
||||
return
|
||||
|
||||
# 商品类创作先锁定结构化商品。普通上传始终只是素材,不能靠图片内容猜成商品。
|
||||
if creation_needs_product_source(conversation, text):
|
||||
result, _stop = _dispatch_tool(
|
||||
context,
|
||||
"ask_user",
|
||||
{"fields": [{
|
||||
"key": "product",
|
||||
"label": "这条内容要使用哪个商品?",
|
||||
"type": "asset",
|
||||
"required": True,
|
||||
"asset_types": ["product"],
|
||||
}]},
|
||||
allow_pick=False,
|
||||
)
|
||||
for event in result.get("_events", []):
|
||||
yield event
|
||||
yield {"type": "done"}
|
||||
return
|
||||
|
||||
# 点击换款的款式清单和顺序是脚本事实,不能让模型自行猜色号或把预设改成普通展示片。
|
||||
if click_swap_needs_sequence(conversation):
|
||||
question = append_click_swap_sequence_gate(conversation)
|
||||
@@ -3122,6 +3378,17 @@ def iter_creation_agent_events(
|
||||
yield {"type": "done"}
|
||||
return
|
||||
|
||||
# 已经添加多位人物时,人物都属于本次 brief。只确认他们如何出镜,不能让
|
||||
# 模型把「多角色 + 各自造型」误读成候选人列表并强迫用户三选一。
|
||||
if context.is_video and multi_character_relation_needs_clarification(conversation, text):
|
||||
question = append_multi_character_relation_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
|
||||
|
||||
# 明确要商品/角色/场景列表时,直接生成真实选择卡,绝不先让模型念出素材名称。
|
||||
requested_card = requested_asset_card_from_context(conversation, text)
|
||||
if requested_card:
|
||||
|
||||
@@ -45,7 +45,9 @@ VIDEO_PRESETS: dict[str, str] = {
|
||||
),
|
||||
"达人口播种草": (
|
||||
"真人出镜口播,生活化语气,像朋友分享而不是念广告稿。开头一句话建立停留理由,"
|
||||
"中段讲清使用场景和一个核心卖点,结尾给明确行动理由。禁止万能主播腔和空卖点。"
|
||||
"中段讲清使用场景和一个核心卖点,结尾给明确行动理由。"
|
||||
"只有一位人物时全片锁定同一人;用户已添加多位角色时保留全部,并固定共同出镜或主讲+辅助关系。"
|
||||
"禁止万能主播腔和空卖点。"
|
||||
),
|
||||
"商品图一键成片": (
|
||||
"从商品参考图出发建立镜头语言:主图定调 → 补场景 → 补使用动作 → 收尾。"
|
||||
@@ -181,7 +183,7 @@ VIDEO_PRESET_WORKFLOWS: dict[str, str] = {
|
||||
"真实使用演示": "优先核对商品真实用途、关键步骤和可证实卖点;不为氛围而增加不合理测试。",
|
||||
"剧情反转带货": "先让用户选故事深度(15秒快节奏反转、30秒轻剧情带货、60秒完整短剧带货或智能推荐),再给三个与时长匹配的剧情方向;商品必须成为解决问题、解除误会、证明事实、回收伏笔或完成翻盘的关键。",
|
||||
"商品拟人广告": "先确认商品外观和性格表达方式;默认无脸拟人,商品保持真实完整,台词用画外声。",
|
||||
"达人口播种草": "优先确认达人/人物、真实体验和主卖点;生成前核对口播字数能在时长内说完,每个卖点都有画面证明。",
|
||||
"达人口播种草": "优先确认人物、多人出镜关系、真实体验和主卖点;生成前核对口播字数能在时长内说完,每个卖点都有画面证明。",
|
||||
"商品图一键成片": "优先从商品参考图锁定外观;自动补场景和动作,但不替换或改变用户商品图里的结构、颜色和包装。",
|
||||
"鱼眼换装": "优先确认人物参考、服装套数和展示顺序;生成前核对脸、身形、场景稳定,只有服装随动作切换。",
|
||||
"点击换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位下手指逐次点击、商品原位换款,禁止转成剧情或口播。",
|
||||
@@ -216,7 +218,8 @@ VIDEO_PRESET_DELIVERY_CONTRACTS: dict[str, str] = {
|
||||
"性格由整体运动、镜头、环境反应和画外角色声表达。每次拟人动作都要符合物理状态,趣味服务于一个真实卖点。"
|
||||
),
|
||||
"达人口播种草": (
|
||||
"【预设执行层·达人口播种草】固定一位可信的真人,在真实生活场景中自然口播。"
|
||||
"【预设执行层·达人口播种草】固定已经确认的人物与出镜关系:只有一位人物时全片锁定同一位可信真人;"
|
||||
"已添加多位角色时保留全部角色,主讲身份与辅助角色不得在镜头间互换。真实生活场景中自然口播。"
|
||||
"结构为:前 2–3 秒具体痛点或体验钩子 → 真实使用/质地/细节证明 → 一句个人感受 → 克制收束。"
|
||||
"口播像朋友分享,所有卖点必须被动作或特写佐证;禁止万能主播腔、堆砌卖点、画面字幕和夸张承诺。"
|
||||
),
|
||||
|
||||
@@ -129,7 +129,7 @@ def _refresh_processing_free_asset(free_asset: FreeAsset) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _guard_asset_reference(asset: Asset, label: str) -> None:
|
||||
def _guard_asset_reference(asset: Asset, label: str, ref_type: str = "") -> None:
|
||||
"""三库引用前的审核闸(模块4 · 4.2)。
|
||||
|
||||
平台生成的资产免审直接过;用户上传的必须真过一遍审核才能当生成参考。
|
||||
@@ -140,7 +140,13 @@ def _guard_asset_reference(asset: Asset, label: str) -> None:
|
||||
"""
|
||||
from apps.assets.review import poll_asset_review, reference_review_state, wait_upload_review
|
||||
|
||||
state = reference_review_state(asset)
|
||||
# Seedance 对人物类参考图不区分「用户上传」还是「平台生成」:只要像真人,普通 URL
|
||||
# 都可能触发 PrivacyInformation。人物语义或审核类资产必须先登记,再以 asset:// 引用。
|
||||
require_registered = (
|
||||
ref_type in {"model", "character"}
|
||||
or asset.category in Asset.REVIEW_CATEGORIES
|
||||
)
|
||||
state = reference_review_state(asset, require_registered=require_registered)
|
||||
if state == "processing":
|
||||
poll_asset_review(asset)
|
||||
state = reference_review_state(asset)
|
||||
@@ -314,7 +320,7 @@ def build_content_items(
|
||||
asset = Asset.objects.filter(id=ref["asset_id"], team=team, is_deleted=False).first()
|
||||
if asset is None:
|
||||
raise ValueError(f"素材「{label or '未命名'}」不存在或已被删除")
|
||||
_guard_asset_reference(asset, label)
|
||||
_guard_asset_reference(asset, label, ref_type)
|
||||
from .services import _asset_preview_url, _seedance_ref_url
|
||||
|
||||
raw_url = _asset_preview_url(asset)
|
||||
|
||||
@@ -3925,6 +3925,12 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
||||
),
|
||||
)
|
||||
AssetFile.objects.create(asset=asset, object_key=stored.object_key, bucket=stored.bucket, content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True)
|
||||
# 全能创作「平台帮忙生成」的人物稍后会作为 Seedance 参考图。平台生成不等于
|
||||
# 可用普通 URL 绕过真人素材校验:提前登记火山素材库,出片时才能使用 asset://。
|
||||
if asset_category == Asset.Category.MODEL_PORTRAIT:
|
||||
from apps.assets.review import submit_asset_for_review
|
||||
|
||||
transaction.on_commit(lambda a=asset: submit_asset_for_review(a))
|
||||
except Exception as exc: # noqa: BLE001 — 失败要退费并把错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费)
|
||||
task.status = AITask.Status.FAILED
|
||||
task.error_message = str(exc)
|
||||
|
||||
@@ -28,6 +28,7 @@ from .creation_agent import (
|
||||
apply_restart_intent,
|
||||
apply_person_identity_guard,
|
||||
active_plot_twist_story_depth,
|
||||
append_multi_character_relation_gate,
|
||||
build_system_prompt,
|
||||
build_messages,
|
||||
default_reply_hint,
|
||||
@@ -599,6 +600,62 @@ class SendEndpointTests(TestCase):
|
||||
for ref in self.conversation.pinned_refs
|
||||
))
|
||||
|
||||
@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):
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="蓝牙耳机")
|
||||
characters = [
|
||||
Asset.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
name=name,
|
||||
asset_type=Asset.Type.IMAGE,
|
||||
source=Asset.Source.UPLOAD,
|
||||
category=Asset.Category.PERSON,
|
||||
)
|
||||
for name in ("南卡", "红发男生", "戴墨镜女生")
|
||||
]
|
||||
self.conversation.mode = CreationConversation.Mode.VIDEO
|
||||
self.conversation.preset = "达人口播种草"
|
||||
self.conversation.pinned_refs = [
|
||||
{"type": "product", "id": str(product.id), "name": product.title},
|
||||
*[
|
||||
{"type": "character", "id": str(character.id), "name": character.name}
|
||||
for character in characters
|
||||
],
|
||||
]
|
||||
self.conversation.save(update_fields=["mode", "preset", "pinned_refs", "updated_at"])
|
||||
card = append_multi_character_relation_gate(self.conversation)
|
||||
lead_value = card.payload["fields"][0]["options"][1]["value"]
|
||||
user_message_count = self.conversation.messages.filter(role="user").count()
|
||||
|
||||
with patch("apps.ai.views.begin_agent_planning", return_value=True), \
|
||||
patch("apps.ai.views.run_creation_agent_turn_task") as task:
|
||||
response = self.client.post(
|
||||
f"/api/ai/creations/{self.conversation.id}/send/",
|
||||
{
|
||||
"kind": "elicit_answer",
|
||||
"reply_to": str(card.id),
|
||||
"answers": {"cast_relation": lead_value},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertIn(response.status_code, (200, 202))
|
||||
card.refresh_from_db()
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertTrue(card.payload["submitted"])
|
||||
self.assertEqual(card.payload["answers"]["cast_relation"], lead_value)
|
||||
self.assertEqual(
|
||||
self.conversation.memory["cast_relation"],
|
||||
"南卡作为主讲,其余已锁定角色辅助出镜",
|
||||
)
|
||||
self.assertNotIn("cast_relation_pending_signature", self.conversation.memory)
|
||||
self.assertEqual(self.conversation.messages.filter(role="user").count(), user_message_count)
|
||||
self.assertTrue(task.apply_async.called)
|
||||
turn_kwargs = task.apply_async.call_args.kwargs["kwargs"]
|
||||
self.assertFalse(turn_kwargs["record_user_message"])
|
||||
self.assertIn("南卡作为主讲", turn_kwargs["continuation_instruction"])
|
||||
|
||||
def test_click_swap_sequence_answer_is_saved_before_agent_continues(self):
|
||||
self.conversation.mode = CreationConversation.Mode.VIDEO
|
||||
self.conversation.preset = "点击换款"
|
||||
@@ -901,6 +958,46 @@ class SendEndpointTests(TestCase):
|
||||
message.text for message in self.conversation.messages.filter(role="assistant")
|
||||
))
|
||||
|
||||
@override_settings(CREATION_AGENT_INLINE=False, CREATION_AGENT_TASK_QUEUE="airshelf.local")
|
||||
def test_skipping_required_product_gate_does_not_ask_for_product_again(self):
|
||||
self.conversation.mode = CreationConversation.Mode.VIDEO
|
||||
self.conversation.preset = "达人口播种草"
|
||||
self.conversation.save(update_fields=["mode", "preset", "updated_at"])
|
||||
card = append_message(
|
||||
self.conversation,
|
||||
role="assistant",
|
||||
kind=CreationMessage.Kind.ELICIT,
|
||||
payload={
|
||||
"interaction": "chat",
|
||||
"phase": "gate",
|
||||
"fields": [{"key": "_asset_gate", "type": "single"}],
|
||||
"pending_fields": [{
|
||||
"key": "product",
|
||||
"type": "asset",
|
||||
"asset_types": ["product"],
|
||||
}],
|
||||
"submitted": False,
|
||||
"answers": {},
|
||||
},
|
||||
)
|
||||
|
||||
with patch("apps.ai.views.begin_agent_planning", return_value=True), \
|
||||
patch("apps.ai.views.run_creation_agent_turn_task") as task:
|
||||
response = self.client.post(
|
||||
f"/api/ai/creations/{self.conversation.id}/send/",
|
||||
{
|
||||
"kind": "elicit_answer",
|
||||
"reply_to": str(card.id),
|
||||
"answers": {"_asset_gate": "skip"},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 202)
|
||||
self.assertTrue(task.apply_async.called)
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertTrue((self.conversation.memory or {}).get("product_source_resolved"))
|
||||
|
||||
def test_gate_emits_conversational_question_not_direct_pick_card(self):
|
||||
"""用户发消息缺商品时,先出轻量对话闸门询问是否发列表,不直接出大卡片。"""
|
||||
fake = FakeProvider([_tool_chunks("ask_user", {"fields": [
|
||||
@@ -1022,6 +1119,16 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
args.update(overrides)
|
||||
return args
|
||||
|
||||
def _pin_product(self, title="测试商品"):
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title=title)
|
||||
self.conversation.pinned_refs = [{
|
||||
"type": "product",
|
||||
"id": str(product.id),
|
||||
"name": product.title,
|
||||
}]
|
||||
self.conversation.save(update_fields=["pinned_refs", "updated_at"])
|
||||
return product
|
||||
|
||||
def test_plot_twist_preset_requires_story_depth_before_creative_output(self):
|
||||
self.conversation.preset = "剧情反转带货"
|
||||
self.conversation.params = {**self.conversation.params, "duration": "智能时长"}
|
||||
@@ -1079,6 +1186,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
self.assertIn("17-24 秒商品在转折点", prompt)
|
||||
|
||||
def test_plot_twist_always_displays_three_direction_cards(self):
|
||||
self._pin_product("剧情测试商品")
|
||||
self.conversation.preset = "剧情反转带货"
|
||||
self.conversation.params = {**self.conversation.params, "duration": "30 秒"}
|
||||
self.conversation.memory = {"person_source_ready": True}
|
||||
@@ -1189,6 +1297,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
self.assertEqual(len(fake.calls), 1)
|
||||
|
||||
def test_plain_strategy_prose_is_recovered_as_strategy_card(self):
|
||||
self._pin_product("小熊婴儿湿巾")
|
||||
self.conversation.preset = "痛点解决演示"
|
||||
self.conversation.memory = {
|
||||
"selling_point_ready": True,
|
||||
@@ -1285,6 +1394,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
self.assertEqual(len(fake.calls), 1)
|
||||
|
||||
def test_click_swap_requires_sequence_before_calling_model(self):
|
||||
self._pin_product("三色通勤包")
|
||||
self.conversation.preset = "点击换款"
|
||||
self.conversation.memory = {}
|
||||
self.conversation.save(update_fields=["preset", "memory", "updated_at"])
|
||||
@@ -1307,6 +1417,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
self.assertEqual(fake.calls, [])
|
||||
|
||||
def test_click_swap_plan_overrides_conflicting_generic_script(self):
|
||||
self._pin_product("三色通勤包")
|
||||
self.conversation.preset = "点击换款"
|
||||
self.conversation.memory = {
|
||||
"click_swap_ready": True,
|
||||
@@ -1425,6 +1536,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
self.assertEqual(len(fake.calls), 1)
|
||||
|
||||
def test_person_video_requires_a_source_before_the_provider_runs(self):
|
||||
self._pin_product("控油粉饼")
|
||||
self.conversation.preset = "达人口播种草"
|
||||
self.conversation.save(update_fields=["preset", "updated_at"])
|
||||
fake = FakeProvider([_text_chunks("这一轮不应调用模型")])
|
||||
@@ -1448,6 +1560,109 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
)
|
||||
self.assertEqual(fake.calls, [])
|
||||
|
||||
def test_multiple_characters_are_kept_and_relation_is_clarified_before_model_runs(self):
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="蓝牙耳机")
|
||||
characters = [
|
||||
Asset.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
name=name,
|
||||
asset_type=Asset.Type.IMAGE,
|
||||
source=Asset.Source.UPLOAD,
|
||||
category=Asset.Category.PERSON,
|
||||
)
|
||||
for name in ("南卡", "红发男生", "戴墨镜女生")
|
||||
]
|
||||
self.conversation.preset = "达人口播种草"
|
||||
self.conversation.pinned_refs = [
|
||||
{"type": "product", "id": str(product.id), "name": product.title},
|
||||
*[
|
||||
{"type": "character", "id": str(character.id), "name": character.name}
|
||||
for character in characters
|
||||
],
|
||||
]
|
||||
self.conversation.save(update_fields=["preset", "pinned_refs", "updated_at"])
|
||||
fake = FakeProvider([_text_chunks("这一轮不应调用模型")])
|
||||
|
||||
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,
|
||||
))
|
||||
|
||||
card = next(
|
||||
event["message"] for event in events
|
||||
if event.get("type") == "message" and event["message"]["kind"] == "elicit"
|
||||
)
|
||||
self.assertEqual(card["payload"]["topic"], "cast_relation")
|
||||
self.assertIn("3 位角色,我都会保留", card["text"])
|
||||
self.assertIn("共同出镜", card["text"])
|
||||
self.assertIn("点一位角色作为主讲", card["text"])
|
||||
self.assertNotIn("选哪一位", card["text"])
|
||||
self.assertEqual(card["payload"]["fields"][0]["type"], "single")
|
||||
self.assertEqual(
|
||||
[option["label"] for option in card["payload"]["fields"][0]["options"]],
|
||||
["全部共同出镜", "南卡主讲", "红发男生主讲", "戴墨镜女生主讲"],
|
||||
)
|
||||
self.assertEqual(fake.calls, [])
|
||||
|
||||
follow_up = FakeProvider([_text_chunks("这个安排能做,我按三位角色继续整理。")])
|
||||
with patch("apps.ai.creation_agent.build_provider", return_value=follow_up):
|
||||
_events(stream_creation_agent(
|
||||
conversation=self.conversation,
|
||||
user=self.user,
|
||||
text="南卡",
|
||||
model_config=self.model,
|
||||
))
|
||||
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertEqual(
|
||||
(self.conversation.memory or {}).get("cast_relation"),
|
||||
"南卡作为主讲,其余已锁定角色辅助出镜",
|
||||
)
|
||||
self.assertEqual(len(follow_up.calls), 1)
|
||||
system = follow_up.calls[0]["messages"][0]["content"]
|
||||
self.assertIn("这些角色默认都必须保留", system)
|
||||
self.assertIn("南卡作为主讲,其余已锁定角色辅助出镜", system)
|
||||
self.assertIn("不得再次询问同一问题", system)
|
||||
self.assertIn("已添加多位角色时保留全部角色", system)
|
||||
self.assertNotIn("固定一位可信的真人", system)
|
||||
|
||||
def test_commerce_preset_requests_product_and_does_not_treat_raw_asset_as_product(self):
|
||||
material = Asset.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
name="未分类参考图",
|
||||
asset_type=Asset.Type.IMAGE,
|
||||
source=Asset.Source.UPLOAD,
|
||||
)
|
||||
self.conversation.preset = "达人口播种草"
|
||||
self.conversation.save(update_fields=["preset", "updated_at"])
|
||||
fake = FakeProvider([_text_chunks("这一轮不应调用模型")])
|
||||
|
||||
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
|
||||
events = _events(stream_creation_agent(
|
||||
conversation=self.conversation,
|
||||
user=self.user,
|
||||
text="做一条自然的种草视频",
|
||||
refs=[{"type": "asset", "id": str(material.id), "name": material.name}],
|
||||
model_config=self.model,
|
||||
))
|
||||
|
||||
card = next(
|
||||
event["message"] for event in events
|
||||
if event.get("type") == "message" and event["message"]["kind"] == "elicit"
|
||||
)
|
||||
self.assertEqual(card["payload"].get("interaction"), "chat")
|
||||
self.assertEqual(card["payload"].get("phase"), "gate")
|
||||
self.assertEqual(card["payload"]["pending_fields"][0]["asset_types"], ["product"])
|
||||
self.assertEqual(fake.calls, [])
|
||||
|
||||
def test_sixty_second_segments_reuse_full_prompt_and_same_person_reference(self):
|
||||
portrait = Asset.objects.create(
|
||||
team=self.team,
|
||||
@@ -1519,7 +1734,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
self.assertEqual(params["prompt"].count("字幕"), 1)
|
||||
self.assertIn("【人物一致性硬约束】", params["prompt"])
|
||||
self.assertIn("【预设执行层·达人口播种草】", params["prompt"])
|
||||
self.assertIn("必须继续使用参考图锁定的同一位人物", params["prompt"])
|
||||
self.assertIn("必须继续使用参考图锁定的原有人物阵容与身份关系", params["prompt"])
|
||||
|
||||
def test_plan_without_video_prompt_is_rejected_without_emitting_cards(self):
|
||||
fake = FakeProvider([
|
||||
@@ -1690,6 +1905,9 @@ class VideoParamParsingTests(TestCase):
|
||||
])
|
||||
self.assertIn("参考图1、参考图2", prompt)
|
||||
self.assertNotIn("参考图3定义本片的固定出镜人物", prompt)
|
||||
self.assertIn("分别对应不同角色", prompt)
|
||||
self.assertIn("人物不得互换、遗漏", prompt)
|
||||
self.assertNotIn("必须保持同一人", prompt)
|
||||
|
||||
def test_model_label_maps_to_volcano_name(self):
|
||||
self.assertEqual(video_model_name({"model": "Seedance 2.0 Fast"}), "doubao-seedance-2-0-fast-260128")
|
||||
@@ -1916,8 +2134,10 @@ class PresetGuidanceTests(CreationAgentBaseTests):
|
||||
self.assertIn("人物面部和身形必须全程一致", system)
|
||||
|
||||
def test_product_personification_defaults_to_faceless_expression(self):
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="拟人测试商品")
|
||||
conversation = CreationConversation.objects.create(
|
||||
team=self.team, created_by=self.user, mode="video", preset="商品拟人广告", params={},
|
||||
pinned_refs=[{"type": "product", "id": str(product.id), "name": product.title}],
|
||||
)
|
||||
fake = FakeProvider([_text_chunks("开始设计")])
|
||||
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
|
||||
@@ -2222,6 +2442,7 @@ class RestartSendEndpointTests(TestCase):
|
||||
"stage": "done",
|
||||
"strategy_confirmed": True,
|
||||
"pending_video_prompt": "旧出片指令",
|
||||
"product_source_resolved": True,
|
||||
},
|
||||
)
|
||||
self.client = APIClient()
|
||||
@@ -2279,6 +2500,7 @@ class RestartSendEndpointTests(TestCase):
|
||||
self.assertEqual(get_video_gate_stage(self.conversation), "clarify")
|
||||
self.assertFalse((self.conversation.memory or {}).get("strategy_confirmed"))
|
||||
self.assertNotIn("pending_video_prompt", self.conversation.memory or {})
|
||||
self.assertNotIn("product_source_resolved", self.conversation.memory or {})
|
||||
self.assertTrue(turn_kwargs["force_creative_turn"])
|
||||
self.assertIn("重新来", turn_kwargs["continuation_instruction"])
|
||||
self.assertIn("模特库", turn_kwargs["continuation_instruction"])
|
||||
|
||||
@@ -50,6 +50,17 @@ class ReferenceReviewStateTests(TestCase):
|
||||
asset = _asset(self.team, source=Asset.Source.AI_GENERATED)
|
||||
self.assertEqual(reference_review_state(asset), "allowed")
|
||||
|
||||
def test_platform_generated_person_must_be_registered_for_seedance(self):
|
||||
asset = _asset(
|
||||
self.team,
|
||||
source=Asset.Source.AI_GENERATED,
|
||||
category=Asset.Category.MODEL_PORTRAIT,
|
||||
)
|
||||
self.assertEqual(
|
||||
reference_review_state(asset, require_registered=True),
|
||||
"unsubmitted",
|
||||
)
|
||||
|
||||
def test_upload_needs_active(self):
|
||||
self.assertEqual(reference_review_state(_asset(self.team, source=Asset.Source.UPLOAD)), "unsubmitted")
|
||||
self.assertEqual(
|
||||
@@ -114,6 +125,28 @@ class AssetReferenceBuildTests(TestCase):
|
||||
built = self._build(asset)
|
||||
self.assertEqual(built["content_items"][0]["image_url"]["url"], "asset://asset-abc")
|
||||
|
||||
def test_platform_generated_person_is_registered_before_video(self):
|
||||
asset = _asset(
|
||||
self.team,
|
||||
source=Asset.Source.AI_GENERATED,
|
||||
category=Asset.Category.MODEL_PORTRAIT,
|
||||
)
|
||||
|
||||
def activate(target, **_kwargs):
|
||||
target.review_status = "active"
|
||||
target.review_remote_id = "asset-platform-person"
|
||||
target.save(update_fields=["review_status", "review_remote_id"])
|
||||
return "active"
|
||||
|
||||
with patch("apps.assets.review.wait_upload_review", side_effect=activate) as wait:
|
||||
built = self._build(asset)
|
||||
|
||||
wait.assert_called_once()
|
||||
self.assertEqual(
|
||||
built["content_items"][0]["image_url"]["url"],
|
||||
"asset://asset-platform-person",
|
||||
)
|
||||
|
||||
def test_unreviewed_upload_waits_and_allows_when_active(self):
|
||||
asset = _asset(self.team, source=Asset.Source.UPLOAD)
|
||||
with patch("apps.assets.review.wait_upload_review", return_value="active") as wait:
|
||||
|
||||
@@ -242,6 +242,28 @@ class StandaloneSingleImageRoutingTests(TestCase):
|
||||
self.assertEqual(self.ledger_count(task, CreditLedger.Type.RESERVE), 1)
|
||||
self.assertEqual(self.ledger_count(task, CreditLedger.Type.CHARGE), 1)
|
||||
|
||||
def test_generated_model_portrait_is_submitted_for_video_review(self):
|
||||
primary = self.model(self.provider("portrait-primary", 100), "portrait-model")
|
||||
task = enqueue_standalone_images(
|
||||
team=self.team,
|
||||
user=self.user,
|
||||
prompt="生成一位成年电商模特定妆参考",
|
||||
mode="model",
|
||||
count=1,
|
||||
ratio="portrait",
|
||||
image_model=f"{primary.provider.name}:{primary.name}",
|
||||
feature="omni_create",
|
||||
dispatch=False,
|
||||
)[0]
|
||||
|
||||
with patch("apps.assets.review.submit_asset_for_review") as submit_review:
|
||||
with self.captureOnCommitCallbacks(execute=True):
|
||||
run_standalone_image_task(task_id=str(task.id))
|
||||
|
||||
asset = Asset.objects.get(origin_task=task)
|
||||
self.assertEqual(asset.category, Asset.Category.MODEL_PORTRAIT)
|
||||
submit_review.assert_called_once_with(asset)
|
||||
|
||||
def test_primary_retry_then_dynamic_candidate_success_has_one_charge(self):
|
||||
primary = self.model(self.provider("single-fallback-primary", 100), "primary", base_cost="0.25")
|
||||
candidate = self.model(self.provider("volcano", 10), "candidate", outbound=False, base_cost="0.75")
|
||||
|
||||
@@ -38,6 +38,7 @@ from .creation import (
|
||||
from .creation_agent import (
|
||||
_ASSET_CARD_LABELS,
|
||||
_RESTART_CONTINUATION,
|
||||
apply_cast_relation_choice,
|
||||
apply_pain_point_direction,
|
||||
apply_confirm_params,
|
||||
apply_restart_intent,
|
||||
@@ -288,6 +289,14 @@ def _store_click_swap_sequence(conversation: CreationConversation, value: str) -
|
||||
)
|
||||
|
||||
|
||||
def _mark_product_source_resolved(conversation: CreationConversation) -> None:
|
||||
"""用户选择跳过、自动推荐或直接描述商品后,不重复弹同一个商品闸门。"""
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["product_source_resolved"] = True
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
|
||||
|
||||
_STEP_CONTINUE_INSTRUCTIONS = {
|
||||
"strategy": (
|
||||
"用户已确认创作策略。现在只调用 write_plan 写方案卡(含完整 video_prompt 存档);"
|
||||
@@ -1928,6 +1937,7 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
pending_fields = [item for item in (payload.get("pending_fields") or []) if isinstance(item, dict)]
|
||||
primary_field = pending_fields[0] if pending_fields else {}
|
||||
asset_types = [t for t in (primary_field.get("asset_types") or []) if t in TYPE_LABELS] or ["product"]
|
||||
is_product_gate = "product" in asset_types
|
||||
|
||||
# 1. 用户明确在打字说「发列表/发给我/发卡片/打开列表」
|
||||
if _WANTS_CARD_RE.search(text):
|
||||
@@ -1981,6 +1991,8 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
payload["answered_via"] = "chat"
|
||||
pending.payload = payload
|
||||
pending.save(update_fields=["payload", "updated_at"])
|
||||
if is_product_gate:
|
||||
_mark_product_source_resolved(conversation)
|
||||
|
||||
force_creative_turn = True
|
||||
if hits:
|
||||
@@ -1999,6 +2011,8 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
payload["answered_via"] = "chat"
|
||||
pending.payload = payload
|
||||
pending.save(update_fields=["payload", "updated_at"])
|
||||
if is_product_gate:
|
||||
_mark_product_source_resolved(conversation)
|
||||
|
||||
force_creative_turn = True
|
||||
continuation_instruction = (
|
||||
@@ -2037,6 +2051,8 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
payload["answered_via"] = "chat"
|
||||
pending.payload = payload
|
||||
pending.save(update_fields=["payload", "updated_at"])
|
||||
if is_product_gate:
|
||||
_mark_product_source_resolved(conversation)
|
||||
force_creative_turn = True
|
||||
|
||||
else:
|
||||
@@ -2212,6 +2228,17 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
sequence = str(answers.get("sku_sequence") or "").strip()
|
||||
if not sequence:
|
||||
return JsonResponse({"detail": "请填写要展示的款式和切换顺序"}, status=400)
|
||||
if payload.get("topic") == "cast_relation":
|
||||
choice = str(answers.get("cast_relation") or "").strip()
|
||||
valid_choices = {
|
||||
str(option.get("value") or "")
|
||||
for field in (payload.get("fields") or [])
|
||||
if isinstance(field, dict) and field.get("key") == "cast_relation"
|
||||
for option in (field.get("options") or [])
|
||||
if isinstance(option, dict)
|
||||
}
|
||||
if not choice or choice not in valid_choices:
|
||||
return JsonResponse({"detail": "请选择共同出镜或一位主讲角色"}, status=400)
|
||||
payload["answers"] = answers
|
||||
payload["submitted"] = True
|
||||
card.payload = payload
|
||||
@@ -2278,6 +2305,20 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
record_user_message = False
|
||||
force_creative_turn = True
|
||||
continuation_instruction = _store_click_swap_sequence(conversation, sequence)
|
||||
elif payload.get("topic") == "cast_relation":
|
||||
relation = apply_cast_relation_choice(
|
||||
conversation,
|
||||
str(answers.get("cast_relation") or ""),
|
||||
)
|
||||
if not relation:
|
||||
return JsonResponse({"detail": "这个角色选项已经失效,请重新选择"}, status=400)
|
||||
text = ""
|
||||
record_user_message = False
|
||||
force_creative_turn = True
|
||||
continuation_instruction = (
|
||||
f"商家已确认出镜安排:【{relation}】。保留全部已锁定角色,直接继续原任务;"
|
||||
"后续策略、脚本、分镜和视频分段都严格保持该人物关系,不要再次追问。"
|
||||
)
|
||||
elif payload.get("interaction") == "person_source_gate":
|
||||
source = str(answers.get("person_source") or "").strip()
|
||||
if source == "platform_generate":
|
||||
@@ -2322,10 +2363,12 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
)
|
||||
elif payload.get("phase") == "gate":
|
||||
choice = str(answers.get("_asset_gate") or "").strip()
|
||||
if choice == "send":
|
||||
pending = payload.get("pending_fields") or []
|
||||
primary_field = pending[0] if pending else {}
|
||||
types = [t for t in (primary_field.get("asset_types") or []) if t in TYPE_LABELS] or ["product"]
|
||||
gate_types = [item for item in (primary_field.get("asset_types") or []) if item in TYPE_LABELS] or ["product"]
|
||||
is_product_gate = "product" in gate_types
|
||||
if choice == "send":
|
||||
types = gate_types
|
||||
pick_field = dict(primary_field)
|
||||
pick_field["label"] = _ASSET_CARD_LABELS.get(
|
||||
types[0], _ASSET_CARD_LABELS.get("asset", "请选择素材")
|
||||
@@ -2352,10 +2395,7 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"messages": [CreationMessageSerializer(pick).data],
|
||||
}, status=200)
|
||||
elif choice == "auto":
|
||||
pending = payload.get("pending_fields") or []
|
||||
primary_field = pending[0] if pending else {}
|
||||
types = [item for item in (primary_field.get("asset_types") or []) if item in TYPE_LABELS] or ["product"]
|
||||
hits = search_mentions(conversation.team, q="", types=types, limit=1)
|
||||
hits = search_mentions(conversation.team, q="", types=gate_types, limit=1)
|
||||
if hits:
|
||||
refs = list(refs)
|
||||
refs.append(hits[0])
|
||||
@@ -2369,12 +2409,16 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
text = ""
|
||||
record_user_message = False
|
||||
force_creative_turn = True
|
||||
if is_product_gate:
|
||||
_mark_product_source_resolved(conversation)
|
||||
else:
|
||||
# 卡片本身已经记录了用户的选择。不要再伪造一条黑色用户气泡;
|
||||
# 继续原任务,并明确告诉模型不要再次追问同一项素材。
|
||||
text = ""
|
||||
record_user_message = False
|
||||
force_creative_turn = True
|
||||
if is_product_gate:
|
||||
_mark_product_source_resolved(conversation)
|
||||
continuation_instruction = (
|
||||
"用户刚选择暂不添加这项素材。接受这个选择,按会话里已有的需求和合理默认继续原任务。"
|
||||
"先用一句自然的话承接,随后直接推进创作;不要再次追问同一素材,不要只说收到或有需要再说。"
|
||||
|
||||
@@ -147,23 +147,28 @@ def _record_submit_error(asset: Asset, reason: str) -> None:
|
||||
SELF_TRUSTED_SOURCES = (Asset.Source.AI_GENERATED, Asset.Source.SYSTEM)
|
||||
|
||||
|
||||
def reference_review_state(asset: Asset) -> str:
|
||||
def reference_review_state(asset: Asset, *, require_registered: bool = False) -> str:
|
||||
"""引用一个平台资产(自由创作 @引用三库)前的审核判定。
|
||||
|
||||
返回 allowed / processing / failed / unsubmitted 四态之一,由调用方决定放行还是给提示。
|
||||
未送审(unsubmitted)不代表拒绝到底 —— 调用方应顺手送一次审,让用户等一会儿再来。
|
||||
|
||||
require_registered=True 用于 Seedance 的人物类参考图:即使图片由平台生成,也必须先取得
|
||||
火山素材 ID 并通过 asset:// 引用。普通直链会被生成接口按真人隐私素材直接拒绝。
|
||||
"""
|
||||
if not assets_client.is_enabled():
|
||||
# 审核整套机制没配置时不能拿它拦人,否则一关审核全平台引用都用不了
|
||||
return "allowed"
|
||||
if asset.source in SELF_TRUSTED_SOURCES:
|
||||
return "allowed"
|
||||
if asset.review_status == "active":
|
||||
if asset.review_status == "active" and (asset.review_remote_id or not require_registered):
|
||||
return "allowed"
|
||||
if asset.review_status == "processing":
|
||||
return "processing"
|
||||
if asset.review_status == "failed":
|
||||
return "failed"
|
||||
if require_registered:
|
||||
return "unsubmitted"
|
||||
if asset.source in SELF_TRUSTED_SOURCES:
|
||||
return "allowed"
|
||||
return "unsubmitted"
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const SIDEBAR_COLLAPSED_KEY = "airshelf:sidebar-collapsed";
|
||||
// 全局命令面板(Ctrl K / 点搜索框)—— 忠实搬设计稿 SHELL_COMMANDS,href 改成真路由导航
|
||||
type Command = { id: string; group: string; label: string; sub: string; page: Page; icon: string; key?: string };
|
||||
const SHELL_COMMANDS: Command[] = [
|
||||
{ id: "omni-create", group: "导航", label: "全能创作", sub: "预设工作流 + 对话式 Agent", page: "omniCreate", icon: "sparkles", key: "O" },
|
||||
{ id: "omni-create", group: "导航", label: "全能创作", sub: "预设工作流 + 对话式创作", page: "omniCreate", icon: "sparkles", key: "O" },
|
||||
{ id: "omni-history", group: "导航", label: "创作历史", sub: "查看与继续独立会话", page: "omniHistory", icon: "history", key: "H" },
|
||||
{ id: "products", group: "导航", label: "商品库", sub: "管理 SKU、商品图册、卖点信息", page: "products", icon: "package", key: "P" },
|
||||
{ id: "projects", group: "导航", label: "视频创作", sub: "从商品或参考视频出发,选择生产方式", page: "projects", icon: "clapperboard", key: "V" },
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
.omni-asset-picker-layer {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.omni-asset-picker.modal {
|
||||
width: min(880px, calc(100vw - 48px));
|
||||
max-width: 880px;
|
||||
height: min(680px, calc(100vh - 48px));
|
||||
}
|
||||
|
||||
.omni-asset-picker .modal-h {
|
||||
min-height: 76px;
|
||||
}
|
||||
|
||||
.omni-asset-picker-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.omni-asset-picker-head-actions .icon-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.omni-asset-picker-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 136px;
|
||||
gap: 12px;
|
||||
padding: 16px 24px 0;
|
||||
}
|
||||
|
||||
.omni-asset-picker-search {
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.omni-asset-picker-search > svg {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 12px;
|
||||
z-index: 1;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--black-alpha-48);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.omni-asset-picker-search .input {
|
||||
padding-left: 38px;
|
||||
}
|
||||
|
||||
.omni-asset-picker-sort {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.omni-asset-picker-body.modal-b {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.omni-asset-picker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.omni-asset-picker-grid.is-loading {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.omni-asset-picker-skeleton {
|
||||
min-width: 0;
|
||||
padding: 8px;
|
||||
border-radius: var(--r-md);
|
||||
background: var(--background-lighter);
|
||||
box-shadow: inset 0 0 0 1px var(--border-faint);
|
||||
}
|
||||
|
||||
.omni-asset-picker-skeleton-thumb {
|
||||
width: 100%;
|
||||
display: block;
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--r-md);
|
||||
background: var(--black-alpha-4);
|
||||
}
|
||||
|
||||
.omni-asset-picker.is-character .omni-asset-picker-skeleton-thumb {
|
||||
aspect-ratio: 3 / 4;
|
||||
}
|
||||
|
||||
.omni-asset-picker-skeleton-name,
|
||||
.omni-asset-picker-skeleton-meta {
|
||||
height: 10px;
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--black-alpha-7);
|
||||
}
|
||||
|
||||
.omni-asset-picker-skeleton-name {
|
||||
width: 72%;
|
||||
}
|
||||
|
||||
.omni-asset-picker-skeleton-meta {
|
||||
width: 44%;
|
||||
height: 7px;
|
||||
margin-top: 7px;
|
||||
background: var(--black-alpha-4);
|
||||
}
|
||||
|
||||
.omni-asset-picker-card {
|
||||
min-width: 0;
|
||||
padding: 8px;
|
||||
border: 0;
|
||||
border-radius: var(--r-md);
|
||||
color: var(--accent-black);
|
||||
background: var(--background-lighter);
|
||||
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-asset-picker-card:hover {
|
||||
background: var(--surface);
|
||||
box-shadow: inset 0 0 0 1px var(--black-alpha-24);
|
||||
}
|
||||
|
||||
.omni-asset-picker-card.is-selected {
|
||||
background: var(--heat-12);
|
||||
box-shadow: inset 0 0 0 1px var(--heat);
|
||||
}
|
||||
|
||||
.omni-asset-picker-card:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--heat-40);
|
||||
}
|
||||
|
||||
.omni-asset-picker-thumb {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border-radius: var(--r-md);
|
||||
background: var(--black-alpha-4);
|
||||
}
|
||||
|
||||
.omni-asset-picker.is-character .omni-asset-picker-thumb {
|
||||
aspect-ratio: 3 / 4;
|
||||
}
|
||||
|
||||
.omni-asset-picker-thumb > img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.omni-asset-picker-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--black-alpha-32);
|
||||
}
|
||||
|
||||
.omni-asset-picker-fallback svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.omni-asset-picker-check {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--r-pill);
|
||||
color: var(--surface);
|
||||
background: var(--heat);
|
||||
}
|
||||
|
||||
.omni-asset-picker-check svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.omni-asset-picker-card-name {
|
||||
display: block;
|
||||
margin-top: 9px;
|
||||
overflow: hidden;
|
||||
color: var(--accent-black);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.omni-asset-picker-card > .mono {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: var(--black-alpha-48);
|
||||
font-size: 10.5px;
|
||||
}
|
||||
|
||||
.omni-asset-picker-state {
|
||||
min-height: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--black-alpha-48);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.omni-asset-picker-state > svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: var(--heat);
|
||||
}
|
||||
|
||||
.omni-asset-picker-state strong {
|
||||
margin-top: 4px;
|
||||
color: var(--accent-black);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.omni-asset-picker-state > span:not(.omni-asset-picker-state-icon) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.omni-asset-picker-state .btn {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.omni-asset-picker-state-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--r-md);
|
||||
color: var(--heat);
|
||||
background: var(--heat-12);
|
||||
}
|
||||
|
||||
.omni-asset-picker-state-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.omni-asset-picker .modal-f {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.omni-asset-picker-selection {
|
||||
margin-right: auto;
|
||||
overflow: hidden;
|
||||
color: var(--black-alpha-48);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: .04em;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.omni-asset-upload-pane.modal-b {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 1.2fr) minmax(240px, .8fr);
|
||||
align-items: start;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.omni-asset-dropzone {
|
||||
width: 100%;
|
||||
min-height: 340px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
padding: 20px;
|
||||
border: 1px dashed var(--black-alpha-24);
|
||||
border-radius: var(--r-md);
|
||||
color: var(--black-alpha-56);
|
||||
background: var(--background-lighter);
|
||||
cursor: pointer;
|
||||
transition: background var(--t-base), border-color var(--t-base), color var(--t-base);
|
||||
}
|
||||
|
||||
.omni-asset-dropzone:hover {
|
||||
border-color: var(--heat-40);
|
||||
color: var(--heat);
|
||||
background: var(--heat-12);
|
||||
}
|
||||
|
||||
.omni-asset-dropzone:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--heat-40);
|
||||
}
|
||||
|
||||
.omni-asset-dropzone.has-preview {
|
||||
background: var(--black-alpha-4);
|
||||
}
|
||||
|
||||
.omni-asset-dropzone-prompt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.omni-asset-dropzone-prompt > svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.omni-asset-dropzone-prompt strong {
|
||||
color: var(--accent-black);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.omni-asset-dropzone-prompt small {
|
||||
color: var(--black-alpha-48);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.omni-asset-upload-thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 380px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.omni-asset-upload-field {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.omni-asset-picker-layer {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.omni-asset-picker.modal {
|
||||
width: calc(100vw - 24px);
|
||||
height: calc(100vh - 24px);
|
||||
}
|
||||
|
||||
.omni-asset-picker-toolbar,
|
||||
.omni-asset-upload-pane.modal-b {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.omni-asset-picker-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.omni-asset-dropzone {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.omni-asset-picker-selection {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Box,
|
||||
Check,
|
||||
LoaderCircle,
|
||||
Plus,
|
||||
Search,
|
||||
Upload,
|
||||
UserRound,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import type { CreationRef } from "../types";
|
||||
import "./asset-select-modal.css";
|
||||
|
||||
export type AssetModalType = "product" | "character";
|
||||
|
||||
interface AssetSelectModalProps {
|
||||
open: boolean;
|
||||
type: AssetModalType;
|
||||
onClose: () => void;
|
||||
onSelect: (ref: CreationRef) => void;
|
||||
onNotify?: (type: "success" | "error" | "info", text: string) => void;
|
||||
}
|
||||
|
||||
const assetLibraryCache = new Map<string, CreationRef[]>();
|
||||
|
||||
function assetLibraryKey(type: AssetModalType, query: string) {
|
||||
return `${type}:${query.trim().toLocaleLowerCase("zh-CN")}`;
|
||||
}
|
||||
|
||||
export function AssetSelectModal({
|
||||
open,
|
||||
type,
|
||||
onClose,
|
||||
onSelect,
|
||||
onNotify,
|
||||
}: AssetSelectModalProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||
const [items, setItems] = useState<CreationRef[]>([]);
|
||||
const [loadedKey, setLoadedKey] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedRef, setSelectedRef] = useState<CreationRef | null>(null);
|
||||
const [sortOrder, setSortOrder] = useState<"recent" | "name">("recent");
|
||||
const [isAddingNew, setIsAddingNew] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newFile, setNewFile] = useState<File | null>(null);
|
||||
const [newPreview, setNewPreview] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const notifyRef = useRef(onNotify);
|
||||
const nameInputId = useId();
|
||||
const titleId = useId();
|
||||
notifyRef.current = onNotify;
|
||||
|
||||
const titleText = type === "product" ? "商品" : "角色";
|
||||
const title = type === "product" ? "商品库" : "角色库";
|
||||
const subtitle = type === "product" ? "[ PRODUCT · SELECT ]" : "[ CHARACTER · SELECT ]";
|
||||
const requestKey = assetLibraryKey(type, debouncedQuery);
|
||||
const cachedItems = assetLibraryCache.get(requestKey);
|
||||
const currentItems = loadedKey === requestKey ? items : (cachedItems || []);
|
||||
const hasCurrentData = loadedKey === requestKey || Boolean(cachedItems);
|
||||
const visibleItems = useMemo(() => {
|
||||
if (sortOrder === "recent") return currentItems;
|
||||
return [...currentItems].sort((left, right) => left.name.localeCompare(right.name, "zh-CN"));
|
||||
}, [currentItems, sortOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setIsAddingNew(false);
|
||||
setSearchQuery("");
|
||||
setSelectedRef(null);
|
||||
setSortOrder("recent");
|
||||
setNewFile(null);
|
||||
setNewPreview("");
|
||||
setNewName("");
|
||||
return;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setDebouncedQuery("");
|
||||
return;
|
||||
}
|
||||
const query = searchQuery.trim();
|
||||
if (!query) {
|
||||
setDebouncedQuery("");
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => setDebouncedQuery(query), 180);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [open, searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const cached = assetLibraryCache.get(requestKey);
|
||||
if (cached) {
|
||||
setItems(cached);
|
||||
setLoadedKey(requestKey);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setSelectedRef(null);
|
||||
const types: CreationRef["type"][] = type === "product" ? ["product"] : ["model", "character"];
|
||||
void api
|
||||
.searchMentions({ q: debouncedQuery, types, limit: 20 })
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
const results = response.results || [];
|
||||
assetLibraryCache.set(requestKey, results);
|
||||
setItems(results);
|
||||
setLoadedKey(requestKey);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
assetLibraryCache.set(requestKey, []);
|
||||
setItems([]);
|
||||
setLoadedKey(requestKey);
|
||||
notifyRef.current?.("error", (error as Error).message || "加载失败");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [debouncedQuery, open, requestKey, type]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape" || uploading) return;
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [onClose, open, uploading]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (newPreview) URL.revokeObjectURL(newPreview);
|
||||
};
|
||||
}, [newPreview]);
|
||||
|
||||
const handleFilePick = (file: File) => {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
notifyRef.current?.("info", "请选择图片文件");
|
||||
return;
|
||||
}
|
||||
setNewFile(file);
|
||||
setNewName((current) => current.trim() || file.name.replace(/\.[^/.]+$/, ""));
|
||||
setNewPreview(URL.createObjectURL(file));
|
||||
};
|
||||
|
||||
const handleUploadSubmit = async () => {
|
||||
if (!newFile || uploading) {
|
||||
if (!newFile) notifyRef.current?.("info", `请先选择${titleText}图片`);
|
||||
return;
|
||||
}
|
||||
const name = newName.trim() || newFile.name.replace(/\.[^/.]+$/, "");
|
||||
setUploading(true);
|
||||
try {
|
||||
let finalRef: CreationRef;
|
||||
if (type === "character") {
|
||||
const form = new FormData();
|
||||
form.append("file", newFile);
|
||||
form.append("name", name);
|
||||
const model = await api.uploadModel(form);
|
||||
finalRef = {
|
||||
type: "model",
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
cover: model.portrait,
|
||||
};
|
||||
} else {
|
||||
const form = new FormData();
|
||||
form.append("file", newFile);
|
||||
const uploaded = await api.uploadFreeVideoRef(form);
|
||||
const product = await api.createProduct({
|
||||
title: name,
|
||||
cover_asset: uploaded.asset_id,
|
||||
images: [{ asset: uploaded.asset_id, sort_order: 0, is_primary: true }],
|
||||
});
|
||||
finalRef = {
|
||||
type: "product",
|
||||
id: product.id,
|
||||
name: product.title,
|
||||
cover: product.cover_preview_url || uploaded.thumb_url || uploaded.url,
|
||||
};
|
||||
}
|
||||
|
||||
notifyRef.current?.("success", `已新增${titleText}:${finalRef.name}`);
|
||||
for (const key of assetLibraryCache.keys()) {
|
||||
if (key.startsWith(`${type}:`)) assetLibraryCache.delete(key);
|
||||
}
|
||||
onSelect(finalRef);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
notifyRef.current?.("error", (error as Error).message || `新增${titleText}失败`);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-bg modal-priority show omni-asset-picker-layer"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget && !uploading) onClose();
|
||||
}}
|
||||
>
|
||||
<section
|
||||
className={`modal omni-asset-picker${type === "character" ? " is-character" : " is-product"}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<span className="corner-tr" />
|
||||
<span className="corner-bl" />
|
||||
<header className="modal-h">
|
||||
<span className="ic-m" aria-hidden="true">
|
||||
{type === "product" ? <Box /> : <UserRound />}
|
||||
</span>
|
||||
<div className="ti" id={titleId}>
|
||||
{isAddingNew ? `新增${titleText}` : title}
|
||||
<span>{isAddingNew ? "[ UPLOAD · CREATE ]" : subtitle}</span>
|
||||
</div>
|
||||
<div className="omni-asset-picker-head-actions">
|
||||
{isAddingNew ? (
|
||||
<button type="button" className="btn btn-sm" disabled={uploading} onClick={() => setIsAddingNew(false)}>
|
||||
<ArrowLeft /> 返回选择
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="btn btn-sm" onClick={() => setIsAddingNew(true)}>
|
||||
<Plus /> 新增{titleText}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="icon-btn" disabled={uploading} onClick={onClose} aria-label="关闭">
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{isAddingNew ? (
|
||||
<>
|
||||
<div className="modal-b omni-asset-upload-pane">
|
||||
<button
|
||||
type="button"
|
||||
className={`omni-asset-dropzone${newPreview ? " has-preview" : ""}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
{newPreview ? (
|
||||
<img src={newPreview} alt={`${titleText}图片预览`} className="omni-asset-upload-thumb" />
|
||||
) : (
|
||||
<span className="omni-asset-dropzone-prompt">
|
||||
<Upload />
|
||||
<strong>上传一张清晰的{titleText}图片</strong>
|
||||
<small>支持 JPG、PNG、WebP · 建议主体完整、无遮挡</small>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (file) handleFilePick(file);
|
||||
}}
|
||||
/>
|
||||
<div className="field omni-asset-upload-field">
|
||||
<label className="field-label" htmlFor={nameInputId}>{titleText}名称</label>
|
||||
<input
|
||||
id={nameInputId}
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder={`输入${titleText}名称`}
|
||||
value={newName}
|
||||
disabled={uploading}
|
||||
onChange={(event) => setNewName(event.target.value)}
|
||||
/>
|
||||
<span className="field-hint">
|
||||
{type === "product" ? "创建后会进入商品库,可继续补充卖点与规格。" : "创建后会进入角色库,后续镜头可持续锁定同一人物。"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="modal-f">
|
||||
<button type="button" className="btn" disabled={uploading} onClick={() => setIsAddingNew(false)}>取消</button>
|
||||
<button type="button" className="btn btn-primary" disabled={!newFile || uploading} onClick={handleUploadSubmit}>
|
||||
{uploading ? <><LoaderCircle className="spin" /> 正在创建</> : `创建并使用${titleText}`}
|
||||
</button>
|
||||
</footer>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="omni-asset-picker-toolbar">
|
||||
<label className="omni-asset-picker-search">
|
||||
<Search aria-hidden="true" />
|
||||
<input
|
||||
className="input"
|
||||
type="search"
|
||||
placeholder={`搜索已有${titleText}`}
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<select
|
||||
className="select omni-asset-picker-sort"
|
||||
aria-label="排序方式"
|
||||
value={sortOrder}
|
||||
onChange={(event) => setSortOrder(event.target.value as "recent" | "name")}
|
||||
>
|
||||
<option value="recent">最近添加</option>
|
||||
<option value="name">按名称</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="modal-b omni-asset-picker-body" aria-busy={loading || !hasCurrentData}>
|
||||
{!hasCurrentData ? (
|
||||
<div className="omni-asset-picker-grid is-loading" role="status" aria-label={`正在加载${titleText}库`}>
|
||||
{Array.from({ length: 10 }, (_, index) => (
|
||||
<div className="omni-asset-picker-skeleton" key={index} aria-hidden="true">
|
||||
<span className="omni-asset-picker-skeleton-thumb" />
|
||||
<span className="omni-asset-picker-skeleton-name" />
|
||||
<span className="omni-asset-picker-skeleton-meta" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : visibleItems.length === 0 ? (
|
||||
<div className="omni-asset-picker-state">
|
||||
<span className="omni-asset-picker-state-icon" aria-hidden="true">
|
||||
{type === "product" ? <Box /> : <UserRound />}
|
||||
</span>
|
||||
<strong>{searchQuery ? `没有找到“${searchQuery}”` : `暂时没有${titleText}`}</strong>
|
||||
<span>{searchQuery ? "换个关键词试试" : `可以先新增一个${titleText}`}</span>
|
||||
{!searchQuery ? (
|
||||
<button type="button" className="btn btn-primary" onClick={() => setIsAddingNew(true)}>
|
||||
<Plus /> 新增{titleText}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="omni-asset-picker-grid">
|
||||
{visibleItems.map((item) => {
|
||||
const isSelected = selectedRef?.type === item.type && selectedRef.id === item.id;
|
||||
const displayName = item.name.split(" · ")[0];
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={`${item.type}:${item.id}`}
|
||||
className={`omni-asset-picker-card${isSelected ? " is-selected" : ""}`}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => setSelectedRef(item)}
|
||||
onDoubleClick={() => {
|
||||
onSelect(item);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<span className="omni-asset-picker-thumb">
|
||||
{item.cover ? (
|
||||
<img src={item.cover} alt="" />
|
||||
) : (
|
||||
<span className="omni-asset-picker-fallback">
|
||||
{type === "product" ? <Box /> : <UserRound />}
|
||||
</span>
|
||||
)}
|
||||
{isSelected ? <span className="omni-asset-picker-check"><Check /></span> : null}
|
||||
</span>
|
||||
<span className="omni-asset-picker-card-name" title={displayName}>{displayName}</span>
|
||||
<span className="mono">// {item.type === "product" ? "商品" : item.type === "model" ? "角色库" : "角色素材"}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<footer className="modal-f">
|
||||
<span className="omni-asset-picker-selection">
|
||||
{selectedRef ? `已选择:${selectedRef.name.split(" · ")[0]}` : `选择一个${titleText}后添加`}
|
||||
</span>
|
||||
<button type="button" className="btn" onClick={onClose}>取消</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!selectedRef}
|
||||
onClick={() => {
|
||||
if (!selectedRef) return;
|
||||
onSelect(selectedRef);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
添加{titleText}
|
||||
</button>
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
.rich-mention-editor {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: var(--accent-black);
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.rich-mention-editor:empty::before {
|
||||
color: var(--black-alpha-48);
|
||||
content: attr(data-placeholder);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rich-mention-editor[aria-disabled="true"] {
|
||||
color: var(--black-alpha-48);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.rich-mention-token {
|
||||
position: relative;
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
max-width: 32px;
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
max-height: 32px;
|
||||
aspect-ratio: 1;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
overflow: visible;
|
||||
margin: 0 3px;
|
||||
border-radius: var(--r-md);
|
||||
color: var(--black-alpha-56);
|
||||
background: var(--background-lighter);
|
||||
box-shadow: inset 0 0 0 1px var(--border-faint);
|
||||
vertical-align: middle;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.rich-mention-editor .rich-mention-token > img,
|
||||
.rich-mention-fallback {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
max-height: 100%;
|
||||
display: block;
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
|
||||
.rich-mention-editor .rich-mention-token > img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.rich-mention-fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rich-mention-preview {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 80;
|
||||
width: 264px;
|
||||
min-width: 264px;
|
||||
max-width: 264px;
|
||||
display: none;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: var(--r-md);
|
||||
color: var(--accent-black);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--shadow-floating);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
text-align: left;
|
||||
transform: translateY(-4px);
|
||||
transition: opacity var(--t-base), transform var(--t-base);
|
||||
}
|
||||
|
||||
.rich-mention-token:hover .rich-mention-preview,
|
||||
.rich-mention-token:focus-within .rich-mention-preview {
|
||||
display: grid;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.rich-mention-preview-head {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rich-mention-preview-head strong {
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rich-mention-preview-head small {
|
||||
flex: 0 0 auto;
|
||||
color: var(--black-alpha-48);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
}
|
||||
|
||||
.rich-mention-preview-media {
|
||||
width: 240px;
|
||||
min-width: 240px;
|
||||
max-width: 240px;
|
||||
height: 240px;
|
||||
min-height: 240px;
|
||||
max-height: 240px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: var(--r-md);
|
||||
color: var(--black-alpha-48);
|
||||
background: var(--background-lighter);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.rich-mention-preview-media img {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
max-height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import {
|
||||
forwardRef,
|
||||
type ClipboardEvent,
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
} from "react";
|
||||
import type { CreationRef } from "../types";
|
||||
import "./rich-mention-editor.css";
|
||||
|
||||
const TYPE_LABELS: Record<CreationRef["type"], string> = {
|
||||
asset: "素材",
|
||||
character: "角色",
|
||||
model: "角色",
|
||||
product: "商品",
|
||||
scene: "场景",
|
||||
};
|
||||
|
||||
function shortName(name: string) {
|
||||
return name.split(" · ")[0].trim();
|
||||
}
|
||||
|
||||
export function getMentionFreeText(value: string, refs: CreationRef[]) {
|
||||
const mentionNames = Array.from(
|
||||
new Set(refs.map((ref) => shortName(ref.name)).filter(Boolean)),
|
||||
).sort((a, b) => b.length - a.length);
|
||||
|
||||
let text = value;
|
||||
mentionNames.forEach((name) => {
|
||||
text = text.split(`@${name}`).join(" ");
|
||||
});
|
||||
return text
|
||||
.replace(/[\u200B-\u200D\uFEFF]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function serializeNode(node: Node): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) return node.textContent || "";
|
||||
if (!(node instanceof HTMLElement)) {
|
||||
return Array.from(node.childNodes).map(serializeNode).join("");
|
||||
}
|
||||
if (node.dataset.mentionName) return `@${node.dataset.mentionName}`;
|
||||
if (node.tagName === "BR") return "\n";
|
||||
const text = Array.from(node.childNodes).map(serializeNode).join("");
|
||||
if ((node.tagName === "DIV" || node.tagName === "P") && node.nextSibling && !text.endsWith("\n")) {
|
||||
return `${text}\n`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function serializeEditor(root: HTMLElement) {
|
||||
return Array.from(root.childNodes)
|
||||
.map(serializeNode)
|
||||
.join("")
|
||||
.replace(/\u00a0/g, " ");
|
||||
}
|
||||
|
||||
function createMentionToken(ref: CreationRef) {
|
||||
const name = shortName(ref.name) || TYPE_LABELS[ref.type];
|
||||
const token = document.createElement("span");
|
||||
token.className = "rich-mention-token";
|
||||
token.contentEditable = "false";
|
||||
token.dataset.mentionName = name;
|
||||
token.dataset.refKey = `${ref.type}:${ref.id}`;
|
||||
token.setAttribute("aria-label", `${TYPE_LABELS[ref.type]}:${name}`);
|
||||
token.title = `${TYPE_LABELS[ref.type]}:${name}`;
|
||||
|
||||
if (ref.cover) {
|
||||
const image = document.createElement("img");
|
||||
image.src = ref.cover;
|
||||
image.alt = "";
|
||||
token.append(image);
|
||||
} else {
|
||||
const fallback = document.createElement("span");
|
||||
fallback.className = "rich-mention-fallback";
|
||||
fallback.textContent = TYPE_LABELS[ref.type].slice(0, 1);
|
||||
token.append(fallback);
|
||||
}
|
||||
|
||||
const preview = document.createElement("span");
|
||||
preview.className = "rich-mention-preview";
|
||||
preview.setAttribute("role", "tooltip");
|
||||
|
||||
const head = document.createElement("span");
|
||||
head.className = "rich-mention-preview-head";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = name;
|
||||
const type = document.createElement("small");
|
||||
type.textContent = TYPE_LABELS[ref.type];
|
||||
head.append(title, type);
|
||||
|
||||
const media = document.createElement("span");
|
||||
media.className = "rich-mention-preview-media";
|
||||
if (ref.cover) {
|
||||
const image = document.createElement("img");
|
||||
image.src = ref.cover;
|
||||
image.alt = name;
|
||||
media.append(image);
|
||||
} else {
|
||||
media.textContent = TYPE_LABELS[ref.type];
|
||||
}
|
||||
preview.append(head, media);
|
||||
token.append(preview);
|
||||
return token;
|
||||
}
|
||||
|
||||
function renderValue(root: HTMLElement, value: string, refs: CreationRef[]) {
|
||||
root.replaceChildren();
|
||||
if (!value) return;
|
||||
|
||||
const byName = new Map<string, CreationRef>();
|
||||
refs.forEach((ref) => {
|
||||
const name = shortName(ref.name);
|
||||
if (name && !byName.has(name)) byName.set(name, ref);
|
||||
});
|
||||
const names = Array.from(byName.keys()).sort((a, b) => b.length - a.length);
|
||||
if (!names.length) {
|
||||
root.append(document.createTextNode(value));
|
||||
return;
|
||||
}
|
||||
|
||||
const matcher = new RegExp(`@(${names.map(escapeRegExp).join("|")})`, "g");
|
||||
let cursor = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = matcher.exec(value))) {
|
||||
if (match.index > cursor) root.append(document.createTextNode(value.slice(cursor, match.index)));
|
||||
const ref = byName.get(match[1]);
|
||||
root.append(ref ? createMentionToken(ref) : document.createTextNode(match[0]));
|
||||
cursor = match.index + match[0].length;
|
||||
}
|
||||
if (cursor < value.length) root.append(document.createTextNode(value.slice(cursor)));
|
||||
}
|
||||
|
||||
function placeCaretAtEnd(root: HTMLElement) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(root);
|
||||
range.collapse(false);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
|
||||
export type RichMentionEditorHandle = {
|
||||
focus: () => void;
|
||||
focusAtEnd: () => void;
|
||||
insertMention: (ref: CreationRef, replaceTrigger?: boolean) => void;
|
||||
};
|
||||
|
||||
type RichMentionEditorProps = {
|
||||
id?: string;
|
||||
value: string;
|
||||
refs: CreationRef[];
|
||||
placeholder: string;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
submitOnEnter?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
onAtTrigger?: () => void;
|
||||
onSubmit?: () => void;
|
||||
};
|
||||
|
||||
export const RichMentionEditor = forwardRef<RichMentionEditorHandle, RichMentionEditorProps>(
|
||||
function RichMentionEditor(
|
||||
{
|
||||
id,
|
||||
value,
|
||||
refs,
|
||||
placeholder,
|
||||
ariaLabel = "创作描述",
|
||||
className = "",
|
||||
disabled = false,
|
||||
submitOnEnter = false,
|
||||
onChange,
|
||||
onAtTrigger,
|
||||
onSubmit,
|
||||
},
|
||||
forwardedRef,
|
||||
) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const signatureRef = useRef("");
|
||||
const changeRef = useRef(onChange);
|
||||
const atTriggerRef = useRef(onAtTrigger);
|
||||
const submitRef = useRef(onSubmit);
|
||||
changeRef.current = onChange;
|
||||
atTriggerRef.current = onAtTrigger;
|
||||
submitRef.current = onSubmit;
|
||||
|
||||
const emitChange = () => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return;
|
||||
const next = serializeEditor(root);
|
||||
const isVisuallyEmpty = !next
|
||||
.replace(/[\u200B-\u200D\uFEFF]/g, "")
|
||||
.trim();
|
||||
if (isVisuallyEmpty) root.replaceChildren();
|
||||
changeRef.current(isVisuallyEmpty ? "" : next);
|
||||
};
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
focus: () => rootRef.current?.focus(),
|
||||
focusAtEnd: () => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return;
|
||||
root.focus();
|
||||
placeCaretAtEnd(root);
|
||||
},
|
||||
insertMention: (ref, replaceTrigger = false) => {
|
||||
const root = rootRef.current;
|
||||
if (!root || disabled) return;
|
||||
root.focus();
|
||||
const selection = window.getSelection();
|
||||
const range = selection?.rangeCount ? selection.getRangeAt(0) : document.createRange();
|
||||
if (!root.contains(range.commonAncestorContainer)) {
|
||||
range.selectNodeContents(root);
|
||||
range.collapse(false);
|
||||
}
|
||||
|
||||
if (replaceTrigger && range.collapsed && range.startContainer.nodeType === Node.TEXT_NODE) {
|
||||
const text = range.startContainer.textContent || "";
|
||||
const trigger = text.slice(0, range.startOffset).match(/@\S*$/)?.[0];
|
||||
if (trigger) range.setStart(range.startContainer, range.startOffset - trigger.length);
|
||||
}
|
||||
|
||||
const before = document.createRange();
|
||||
before.selectNodeContents(root);
|
||||
before.setEnd(range.startContainer, range.startOffset);
|
||||
const prefix = serializeNode(before.cloneContents());
|
||||
const fragment = document.createDocumentFragment();
|
||||
if (prefix && !/\s$/.test(prefix)) fragment.append(document.createTextNode(" "));
|
||||
fragment.append(createMentionToken(ref));
|
||||
const tail = document.createTextNode(" ");
|
||||
fragment.append(tail);
|
||||
range.deleteContents();
|
||||
range.insertNode(fragment);
|
||||
range.setStartAfter(tail);
|
||||
range.collapse(true);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
emitChange();
|
||||
},
|
||||
}), [disabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return;
|
||||
const signature = refs.map((ref) => `${ref.type}:${ref.id}:${ref.name}:${ref.cover || ""}`).join("|");
|
||||
const current = serializeEditor(root);
|
||||
if (current === value && signature === signatureRef.current) return;
|
||||
const focused = document.activeElement === root;
|
||||
renderValue(root, value, refs);
|
||||
signatureRef.current = signature;
|
||||
if (focused) placeCaretAtEnd(root);
|
||||
}, [refs, value]);
|
||||
|
||||
const handleInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
emitChange();
|
||||
const native = event.nativeEvent as InputEvent;
|
||||
if (native.data === "@") atTriggerRef.current?.();
|
||||
};
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.rangeCount) return;
|
||||
const range = selection.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
const node = document.createTextNode(text);
|
||||
range.insertNode(node);
|
||||
range.setStartAfter(node);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (submitOnEnter && event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
submitRef.current?.();
|
||||
}
|
||||
};
|
||||
|
||||
const positionPreview = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const root = rootRef.current;
|
||||
const target = event.target as Element;
|
||||
const token = target.closest<HTMLElement>(".rich-mention-token");
|
||||
if (!root || !token || !root.contains(token)) return;
|
||||
const preview = token.querySelector<HTMLElement>(".rich-mention-preview");
|
||||
if (!preview) return;
|
||||
const tokenRect = token.getBoundingClientRect();
|
||||
const previewHeight = preview.offsetHeight || 302;
|
||||
const previewWidth = preview.offsetWidth || 244;
|
||||
const left = Math.min(Math.max(12, tokenRect.left), window.innerWidth - previewWidth - 12);
|
||||
const hasRoomBelow = window.innerHeight - tokenRect.bottom >= previewHeight + 20;
|
||||
const desiredTop = hasRoomBelow
|
||||
? tokenRect.bottom + 8
|
||||
: Math.max(12, tokenRect.top - previewHeight - 8);
|
||||
const top = Math.min(
|
||||
Math.max(12, desiredTop),
|
||||
Math.max(12, window.innerHeight - previewHeight - 12),
|
||||
);
|
||||
preview.style.left = `${left}px`;
|
||||
preview.style.top = `${top}px`;
|
||||
preview.dataset.side = hasRoomBelow ? "below" : "above";
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
ref={rootRef}
|
||||
className={`rich-mention-editor${className ? ` ${className}` : ""}`}
|
||||
contentEditable={!disabled}
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
aria-label={ariaLabel}
|
||||
aria-multiline="true"
|
||||
aria-disabled={disabled}
|
||||
data-placeholder={placeholder}
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPointerOver={positionPreview}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -779,6 +779,12 @@
|
||||
box-shadow: 0 9px 18px rgba(0, 47, 167, .17);
|
||||
}
|
||||
|
||||
.omni-start-generate:disabled {
|
||||
opacity: .42;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.omni-case-library {
|
||||
padding: 0 4px;
|
||||
transition: opacity 180ms ease, transform 180ms ease;
|
||||
@@ -1685,3 +1691,288 @@
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
/* 全能创作 · 结构化引用槽位 */
|
||||
.omni-composer-main-wrap {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
align-items: stretch;
|
||||
padding: 4px 0 12px;
|
||||
}
|
||||
|
||||
.omni-composer-slots {
|
||||
width: max-content;
|
||||
max-width: 300px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
height: 164px;
|
||||
min-height: 164px;
|
||||
max-height: 164px;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
overflow: hidden;
|
||||
padding: 2px 16px 2px 2px;
|
||||
border-right: 1px solid var(--border-faint);
|
||||
}
|
||||
|
||||
.omni-slots-grid {
|
||||
width: max-content;
|
||||
max-width: 280px;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
display: grid;
|
||||
grid-auto-rows: auto;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
.omni-slots-grid.is-columns-1 {
|
||||
grid-template-columns: 88px;
|
||||
}
|
||||
|
||||
.omni-slots-grid.is-columns-2 {
|
||||
grid-template-columns: repeat(2, 88px);
|
||||
}
|
||||
|
||||
.omni-slots-grid.is-columns-3 {
|
||||
grid-template-columns: repeat(3, 88px);
|
||||
}
|
||||
|
||||
.omni-slots-grid.is-columns-4 {
|
||||
grid-template-columns: repeat(4, 64px);
|
||||
}
|
||||
|
||||
.omni-slots-grid:focus-within {
|
||||
scrollbar-color: var(--black-alpha-12) transparent;
|
||||
}
|
||||
|
||||
.omni-slot-box {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 11 / 12;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--r-md);
|
||||
color: var(--black-alpha-56);
|
||||
background: var(--surface);
|
||||
box-shadow: inset 0 0 0 1px var(--border-faint);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.omni-slot-box.is-empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px dashed var(--border-faint);
|
||||
border-style: dashed;
|
||||
background: var(--background-lighter);
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
transition: background var(--t-base), border-color var(--t-base), color var(--t-base);
|
||||
}
|
||||
|
||||
.omni-slot-box.is-empty:hover {
|
||||
border-color: var(--heat-40);
|
||||
color: var(--heat);
|
||||
background: var(--heat-12);
|
||||
}
|
||||
|
||||
.omni-slot-box:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--heat-40);
|
||||
}
|
||||
|
||||
.omni-slot-tag {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
z-index: 2;
|
||||
min-height: 17px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: calc(100% - 10px);
|
||||
overflow: hidden;
|
||||
padding: 0 6px;
|
||||
border-radius: var(--r-pill);
|
||||
color: var(--surface);
|
||||
background: var(--accent-black);
|
||||
font-size: 9.5px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.omni-slot-plus {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.omni-slot-plus svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.omni-slot-box.is-filled {
|
||||
background: var(--black-alpha-4);
|
||||
}
|
||||
|
||||
.omni-slot-img,
|
||||
.omni-slot-img-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.omni-slot-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--r-md);
|
||||
overflow: hidden;
|
||||
background: var(--black-alpha-4);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.omni-slot-preview:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--heat-40);
|
||||
}
|
||||
|
||||
.omni-slot-img {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.omni-slot-img-placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--black-alpha-32);
|
||||
background: var(--black-alpha-4);
|
||||
}
|
||||
|
||||
.omni-slot-img-placeholder svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.omni-slot-remove,
|
||||
.omni-slot-at-btn {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: color var(--t-base), background var(--t-base), opacity var(--t-base);
|
||||
}
|
||||
|
||||
.omni-slot-remove {
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
border: 0;
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--black-alpha-48);
|
||||
background: var(--surface);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.omni-slot-box:hover .omni-slot-remove,
|
||||
.omni-slot-remove:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.omni-slot-remove:hover {
|
||||
color: var(--accent-crimson);
|
||||
background: var(--crimson-bg);
|
||||
}
|
||||
|
||||
.omni-slot-at-btn {
|
||||
right: 5px;
|
||||
bottom: 5px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 0;
|
||||
border-radius: var(--r-md);
|
||||
color: var(--accent-black);
|
||||
background: var(--surface);
|
||||
box-shadow: inset 0 0 0 1px var(--border-faint);
|
||||
}
|
||||
|
||||
.omni-slot-at-btn:hover {
|
||||
color: var(--heat);
|
||||
background: var(--heat-12);
|
||||
box-shadow: inset 0 0 0 1px var(--heat-40);
|
||||
}
|
||||
|
||||
.omni-slot-at-char {
|
||||
font-family: var(--font-inter);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.omni-slots-counter {
|
||||
flex: 0 0 auto;
|
||||
margin: auto 0 0;
|
||||
color: var(--black-alpha-48);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.omni-composer-editor {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.omni-composer-editor #omniStartPrompt {
|
||||
min-height: 164px;
|
||||
max-height: 190px;
|
||||
padding: 8px 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.omni-composer-main-wrap {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.omni-composer-slots {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
min-height: 88px;
|
||||
max-height: none;
|
||||
padding: 0 2px 12px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border-faint);
|
||||
}
|
||||
|
||||
.omni-slots-grid,
|
||||
.omni-slots-grid.is-columns-1,
|
||||
.omni-slots-grid.is-columns-2,
|
||||
.omni-slots-grid.is-columns-3,
|
||||
.omni-slots-grid.is-columns-4 {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
grid-template-columns: repeat(auto-fit, minmax(64px, 88px));
|
||||
grid-auto-rows: auto;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.omni-slot-remove {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2019,7 +2019,7 @@
|
||||
gap: 6px;
|
||||
max-width: 220px;
|
||||
padding: 3px 8px 3px 4px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@@ -2027,7 +2027,7 @@
|
||||
.omni-mention-chip.has-thumb {
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--r-md);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
overflow: visible;
|
||||
@@ -2038,10 +2038,11 @@
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(34, 42, 54, .1);
|
||||
border-radius: 10px;
|
||||
border: 0;
|
||||
border-radius: var(--r-md);
|
||||
overflow: hidden;
|
||||
background: #f3f4f7;
|
||||
background: var(--background-lighter);
|
||||
box-shadow: inset 0 0 0 1px var(--border-faint);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
@@ -2055,15 +2056,15 @@
|
||||
.omni-mention-chip i {
|
||||
flex: 0 0 auto;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--r-pill);
|
||||
font-style: normal;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.omni-mention-chip b {
|
||||
overflow: hidden;
|
||||
font-weight: 650;
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -2076,7 +2077,7 @@
|
||||
margin-left: 2px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
border-radius: var(--r-pill);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -2087,9 +2088,8 @@
|
||||
right: -6px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #fff;
|
||||
background: rgba(34, 42, 54, .72);
|
||||
box-shadow: 0 2px 6px rgba(20, 27, 38, .18);
|
||||
color: var(--surface);
|
||||
background: var(--black-alpha-72);
|
||||
}
|
||||
|
||||
.omni-mention-chip .omni-mention-remove svg {
|
||||
@@ -2098,13 +2098,13 @@
|
||||
}
|
||||
|
||||
.omni-mention-chips.is-user .omni-mention-chip:not(.has-thumb) {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, .14);
|
||||
color: var(--surface);
|
||||
background: var(--black-alpha-12);
|
||||
}
|
||||
|
||||
.omni-mention-chips.is-user .omni-mention-chip i {
|
||||
color: var(--klein);
|
||||
background: #edf5ff;
|
||||
color: var(--heat);
|
||||
background: var(--heat-12);
|
||||
}
|
||||
|
||||
.omni-chat-stack .omni-mention-chips.is-user {
|
||||
@@ -2117,22 +2117,113 @@
|
||||
}
|
||||
|
||||
.omni-mention-chips.is-composer {
|
||||
margin: 0 2px 8px;
|
||||
margin: 0 2px 6px;
|
||||
}
|
||||
|
||||
.omni-mention-chips.is-composer .omni-mention-chip:not(.has-thumb) {
|
||||
color: #2b3240;
|
||||
background: #edf5ff;
|
||||
border: 1px solid rgba(0, 47, 167, .12);
|
||||
color: var(--accent-black);
|
||||
background: var(--heat-12);
|
||||
box-shadow: inset 0 0 0 1px var(--heat-20);
|
||||
}
|
||||
|
||||
.omni-mention-chips.is-composer .omni-mention-chip i {
|
||||
color: #fff;
|
||||
background: var(--klein);
|
||||
color: var(--surface);
|
||||
background: var(--heat);
|
||||
}
|
||||
|
||||
.omni-mention-chips.is-composer .omni-mention-chip:not(.has-thumb) .omni-mention-remove {
|
||||
color: #66707d;
|
||||
color: var(--black-alpha-56);
|
||||
}
|
||||
|
||||
.omni-mention-chips.is-composer .omni-mention-thumb {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--r-md);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.omni-mention-chips.is-composer .omni-mention-chip.has-thumb .omni-mention-remove {
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
opacity: 0;
|
||||
transition: opacity var(--t-base);
|
||||
}
|
||||
|
||||
.omni-mention-chips.is-composer .omni-mention-chip.has-thumb:hover .omni-mention-remove,
|
||||
.omni-mention-chips.is-composer .omni-mention-chip.has-thumb:focus-within .omni-mention-remove {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.omni-mention-hover-preview {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 10px);
|
||||
left: 0;
|
||||
z-index: 40;
|
||||
width: 244px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: var(--r-md);
|
||||
color: var(--accent-black);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--shadow-floating);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
text-align: left;
|
||||
transform: translateY(4px);
|
||||
transition: opacity var(--t-base), transform var(--t-base);
|
||||
}
|
||||
|
||||
.omni-mention-chip.has-thumb:hover .omni-mention-hover-preview,
|
||||
.omni-mention-chip.has-thumb:focus-within .omni-mention-hover-preview {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.omni-mention-chips.is-user .omni-mention-hover-preview {
|
||||
right: 0;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.omni-mention-hover-head {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.omni-mention-hover-head strong {
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.omni-mention-hover-head small {
|
||||
flex: 0 0 auto;
|
||||
color: var(--black-alpha-48);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
}
|
||||
|
||||
.omni-mention-hover-media {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border-radius: var(--r-md);
|
||||
background: var(--background-lighter);
|
||||
}
|
||||
|
||||
.omni-mention-hover-media img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.omni-chat-stack {
|
||||
|
||||
@@ -21,7 +21,13 @@ import {
|
||||
import { api, ApiError } from "../api";
|
||||
import { findCatalogModel } from "../components/omni-param-bar";
|
||||
import { modelResolutions } from "../components/free-create/constants";
|
||||
import { ConfirmModal } from "../components/overlays";
|
||||
import { ConfirmModal, MediaLightbox } from "../components/overlays";
|
||||
import { AssetSelectModal, type AssetModalType } from "../components/asset-select-modal";
|
||||
import {
|
||||
getMentionFreeText,
|
||||
RichMentionEditor,
|
||||
type RichMentionEditorHandle,
|
||||
} from "../components/rich-mention-editor";
|
||||
import type { CreationConversation, CreationRef, ModelConfig } from "../types";
|
||||
import type { NavigateFn } from "./route-config";
|
||||
|
||||
@@ -62,7 +68,8 @@ const IMAGE_PRESETS: PresetItem[] = [
|
||||
{ name: "新中式", category: "style", mode: "image", title: "新中式", cover: "/assets/image-presets/neo-chinese.png" },
|
||||
];
|
||||
|
||||
const MENTION_REF_LIMIT = 5;
|
||||
const MENTION_REF_LIMIT = 20;
|
||||
const ROLE_REF_LIMIT = 3;
|
||||
|
||||
const MENTION_TABS: Array<{ type: CreationRef["type"]; label: string; Icon: typeof ImageIcon }> = [
|
||||
{ type: "asset", label: "素材", Icon: ImageIcon },
|
||||
@@ -119,7 +126,11 @@ export function OmniCreatePage({
|
||||
const [typeLabels, setTypeLabels] = useState<Record<string, string>>({});
|
||||
const [pendingRefs, setPendingRefs] = useState<CreationRef[]>([]);
|
||||
const [previewCase, setPreviewCase] = useState<PresetItem | null>(null);
|
||||
const [assetPreview, setAssetPreview] = useState<{ src: string; name: string } | null>(null);
|
||||
const [assetModalOpen, setAssetModalOpen] = useState(false);
|
||||
const [assetModalType, setAssetModalType] = useState<AssetModalType>("product");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const promptRef = useRef<RichMentionEditorHandle>(null);
|
||||
const toolsRef = useRef<HTMLDivElement>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
|
||||
@@ -156,6 +167,28 @@ export function OmniCreatePage({
|
||||
const presets = useMemo(() => {
|
||||
return outputMode === "video" ? VIDEO_PRESETS : IMAGE_PRESETS;
|
||||
}, [outputMode]);
|
||||
const productRefs = useMemo(
|
||||
() => pendingRefs.filter((ref) => ref.type === "product"),
|
||||
[pendingRefs],
|
||||
);
|
||||
const roleRefs = useMemo(
|
||||
() => pendingRefs.filter((ref) => ref.type === "character" || ref.type === "model"),
|
||||
[pendingRefs],
|
||||
);
|
||||
const materialRefs = useMemo(
|
||||
() => pendingRefs.filter((ref) => ref.type === "asset" || ref.type === "scene"),
|
||||
[pendingRefs],
|
||||
);
|
||||
const hasPromptDescription = useMemo(
|
||||
() => Boolean(getMentionFreeText(prompt, pendingRefs)),
|
||||
[pendingRefs, prompt],
|
||||
);
|
||||
const visibleSlotCount =
|
||||
(productRefs.length || 1)
|
||||
+ roleRefs.length
|
||||
+ (roleRefs.length < ROLE_REF_LIMIT ? 1 : 0)
|
||||
+ materialRefs.length;
|
||||
const slotColumnCount = Math.min(Math.max(visibleSlotCount, 1), 4);
|
||||
|
||||
const applyPreset = (preset: PresetItem) => {
|
||||
setSelectedCase(preset);
|
||||
@@ -181,21 +214,53 @@ export function OmniCreatePage({
|
||||
}
|
||||
};
|
||||
|
||||
const insertMention = (ref: CreationRef) => {
|
||||
if (pendingRefs.some((r) => r.id === ref.id)) {
|
||||
setMentionMenuOpen(false);
|
||||
return;
|
||||
const insertVisibleMention = (ref: CreationRef, replaceTrigger: boolean) => {
|
||||
const name = ref.name.split(" · ")[0].trim();
|
||||
const tag = `@${name} `;
|
||||
if (promptRef.current) {
|
||||
promptRef.current.insertMention(ref, replaceTrigger);
|
||||
} else {
|
||||
setPrompt((prev) => (prev ? `${prev} ${tag}` : tag));
|
||||
}
|
||||
if (pendingRefs.length >= MENTION_REF_LIMIT) {
|
||||
};
|
||||
|
||||
const insertMention = (ref: CreationRef) => {
|
||||
const exists = pendingRefs.some((item) => item.type === ref.type && item.id === ref.id);
|
||||
if (!exists && pendingRefs.length >= MENTION_REF_LIMIT) {
|
||||
onNotify?.("info", `最多引用 ${MENTION_REF_LIMIT} 个`);
|
||||
setMentionMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
setPendingRefs((prev) => [...prev, ref]);
|
||||
setPrompt((prev) => prev.replace(/@\S*$/, "").replace(/\s+$/, " "));
|
||||
if (!exists) setPendingRefs((prev) => [...prev, ref]);
|
||||
insertVisibleMention(ref, true);
|
||||
setMentionMenuOpen(false);
|
||||
};
|
||||
|
||||
const insertCardMention = (ref: CreationRef) => insertVisibleMention(ref, false);
|
||||
|
||||
const removePendingRef = (ref: CreationRef) => {
|
||||
setPendingRefs((prev) => prev.filter((item) => !(item.type === ref.type && item.id === ref.id)));
|
||||
const name = ref.name.split(" · ")[0].trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
setPrompt((prev) => prev.replace(new RegExp(`(^|\\s)@${name}(?=\\s|$)`, "g"), " ").replace(/\s{2,}/g, " ").trimStart());
|
||||
};
|
||||
|
||||
const addStructuredRef = (ref: CreationRef) => {
|
||||
setPendingRefs((prev) => {
|
||||
if (prev.some((item) => item.type === ref.type && item.id === ref.id)) return prev;
|
||||
if (prev.length >= MENTION_REF_LIMIT) {
|
||||
onNotify?.("info", `最多添加 ${MENTION_REF_LIMIT} 个引用`);
|
||||
return prev;
|
||||
}
|
||||
const isRole = ref.type === "character" || ref.type === "model";
|
||||
const currentRoleCount = prev.filter((item) => item.type === "character" || item.type === "model").length;
|
||||
if (isRole && currentRoleCount >= ROLE_REF_LIMIT) {
|
||||
onNotify?.("info", `最多添加 ${ROLE_REF_LIMIT} 个角色`);
|
||||
return prev;
|
||||
}
|
||||
return [...prev, ref];
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
event.target.value = "";
|
||||
@@ -243,7 +308,7 @@ export function OmniCreatePage({
|
||||
创作历史
|
||||
</button>
|
||||
<span className="omni-home-kicker">
|
||||
<Sparkles /> YINGQING CREATIVE AGENT
|
||||
<Sparkles /> YINGQING CREATIVE STUDIO
|
||||
</span>
|
||||
<h1>和全能创作聊聊你的想法</h1>
|
||||
<p>选择一种创作方法,或直接描述你想生成的画面。</p>
|
||||
@@ -254,77 +319,228 @@ export function OmniCreatePage({
|
||||
</header>
|
||||
|
||||
<section className="omni-start-composer" aria-label="全能创作输入区">
|
||||
<div className="omni-start-context" hidden={!selectedCase && pendingRefs.length === 0}>
|
||||
<div className="omni-selected-case" hidden={!selectedCase}>
|
||||
{selectedCase && (
|
||||
<div className="omni-start-context">
|
||||
<div className="omni-selected-case">
|
||||
<span>
|
||||
<WandSparkles />
|
||||
<strong>{selectedCase?.title}</strong>
|
||||
<strong>{selectedCase.title}</strong>
|
||||
</span>
|
||||
<button type="button" aria-label="取消预设" onClick={() => setSelectedCase(null)}>
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
<div className="omni-start-attachments" aria-live="polite">{pendingRefs.map((ref) => (
|
||||
<span className="omni-attachment-chip" key={ref.id}>
|
||||
{ref.cover ? <img src={ref.cover} alt="" /> : <ImageIcon />}
|
||||
<span>{ref.name.split(" · ")[0]}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="omni-composer-main-wrap">
|
||||
<div className="omni-composer-slots" aria-label="创作槽位">
|
||||
<div className={`omni-slots-grid is-columns-${slotColumnCount}`}>
|
||||
{/* 1. 商品槽位 */}
|
||||
{productRefs.map((ref) => (
|
||||
<div className="omni-slot-box is-filled" key={`${ref.type}:${ref.id}`} title={ref.name}>
|
||||
<span className="omni-slot-tag">商品</span>
|
||||
{ref.cover ? (
|
||||
<button
|
||||
type="button"
|
||||
className="omni-attachment-remove"
|
||||
aria-label={`删除 ${ref.name}`}
|
||||
onClick={() => setPendingRefs((prev) => prev.filter((item) => item.id !== ref.id))}
|
||||
className="omni-slot-preview"
|
||||
aria-label={`放大预览商品 ${ref.name}`}
|
||||
title="点击放大预览"
|
||||
onClick={() => setAssetPreview({ src: ref.cover || "", name: ref.name })}
|
||||
>
|
||||
<X />
|
||||
<img src={ref.cover} alt={ref.name} className="omni-slot-img" />
|
||||
</button>
|
||||
</span>
|
||||
))}</div>
|
||||
) : (
|
||||
<div className="omni-slot-img-placeholder">
|
||||
<Box size={24} />
|
||||
</div>
|
||||
<textarea
|
||||
id="omniStartPrompt"
|
||||
rows={3}
|
||||
placeholder="描述你想制作的内容,@ 可引用商品、模特、场景、素材或刚上传的图片……"
|
||||
value={prompt}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
setPrompt(value);
|
||||
const caret = event.target.selectionStart ?? 0;
|
||||
if (caret > 0 && value.charAt(caret - 1) === "@") void openMentions();
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="omni-slot-remove"
|
||||
aria-label={`移除商品 ${ref.name}`}
|
||||
title="移除"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removePendingRef(ref);
|
||||
}}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="omni-slot-at-btn"
|
||||
aria-label={`艾特 ${ref.name}`}
|
||||
title={`在输入框中艾特 ${ref.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
insertCardMention(ref);
|
||||
}}
|
||||
>
|
||||
<span className="omni-slot-at-char">@</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{productRefs.length === 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="omni-slot-box is-empty"
|
||||
onClick={() => {
|
||||
setAssetModalType("product");
|
||||
setAssetModalOpen(true);
|
||||
}}
|
||||
title="点击添加商品"
|
||||
>
|
||||
<span className="omni-slot-tag">商品</span>
|
||||
<span className="omni-slot-plus">
|
||||
<Plus size={20} />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 2. 角色槽位 */}
|
||||
{roleRefs.map((ref, idx) => (
|
||||
<div className="omni-slot-box is-filled" key={`${ref.type}:${ref.id}`} title={ref.name}>
|
||||
<span className="omni-slot-tag">
|
||||
{`角色${idx + 1}`}
|
||||
</span>
|
||||
{ref.cover ? (
|
||||
<button
|
||||
type="button"
|
||||
className="omni-slot-preview"
|
||||
aria-label={`放大预览角色 ${ref.name}`}
|
||||
title="点击放大预览"
|
||||
onClick={() => setAssetPreview({ src: ref.cover || "", name: ref.name })}
|
||||
>
|
||||
<img src={ref.cover} alt={ref.name} className="omni-slot-img" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="omni-slot-img-placeholder">
|
||||
<UserRound size={24} />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="omni-slot-remove"
|
||||
aria-label={`移除角色 ${ref.name}`}
|
||||
title="移除"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removePendingRef(ref);
|
||||
}}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="omni-slot-at-btn"
|
||||
aria-label={`艾特 ${ref.name}`}
|
||||
title={`在输入框中艾特 ${ref.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
insertCardMention(ref);
|
||||
}}
|
||||
>
|
||||
<span className="omni-slot-at-char">@</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{roleRefs.length < ROLE_REF_LIMIT && (
|
||||
<button
|
||||
type="button"
|
||||
className="omni-slot-box is-empty"
|
||||
onClick={() => {
|
||||
setAssetModalType("character");
|
||||
setAssetModalOpen(true);
|
||||
}}
|
||||
title={roleRefs.length ? "继续添加角色" : "点击添加角色"}
|
||||
>
|
||||
<span className="omni-slot-tag">{roleRefs.length ? `角色${roleRefs.length + 1}` : "角色"}</span>
|
||||
<span className="omni-slot-plus">
|
||||
<Plus size={20} />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 3. 素材槽位 (直接上传算素材) */}
|
||||
{materialRefs.map((ref) => (
|
||||
<div className="omni-slot-box is-filled" key={`${ref.type}:${ref.id}`} title={ref.name}>
|
||||
<span className="omni-slot-tag">{ref.type === "scene" ? "场景" : "素材"}</span>
|
||||
{ref.cover ? (
|
||||
<button
|
||||
type="button"
|
||||
className="omni-slot-preview"
|
||||
aria-label={`放大预览素材 ${ref.name}`}
|
||||
title="点击放大预览"
|
||||
onClick={() => setAssetPreview({ src: ref.cover || "", name: ref.name })}
|
||||
>
|
||||
<img src={ref.cover} alt={ref.name} className="omni-slot-img" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="omni-slot-img-placeholder">
|
||||
<ImageIcon size={24} />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="omni-slot-remove"
|
||||
aria-label={`移除素材 ${ref.name}`}
|
||||
title="移除"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removePendingRef(ref);
|
||||
}}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="omni-slot-at-btn"
|
||||
aria-label={`艾特 ${ref.name}`}
|
||||
title={`在输入框中艾特 ${ref.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
insertCardMention(ref);
|
||||
}}
|
||||
>
|
||||
<span className="omni-slot-at-char">@</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span className="omni-slots-counter">{pendingRefs.length}/20</span>
|
||||
</div>
|
||||
|
||||
<div className="omni-composer-editor">
|
||||
<RichMentionEditor
|
||||
id="omniStartPrompt"
|
||||
ref={promptRef}
|
||||
placeholder="添加商品,写下核心卖点或创意方向,系统将为你补全生成方案"
|
||||
value={prompt}
|
||||
refs={pendingRefs}
|
||||
onChange={setPrompt}
|
||||
onAtTrigger={() => void openMentions()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="omni-start-toolbar">
|
||||
<div className="omni-start-tools" ref={toolsRef}>
|
||||
<div className="omni-upload-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className={`omni-icon-tool${uploading ? " is-uploading" : ""}`}
|
||||
aria-label={uploading ? "正在上传参考素材" : "添加参考素材"}
|
||||
aria-label={uploading ? "正在上传素材" : "上传素材"}
|
||||
title={uploading ? "正在上传素材…" : "上传素材(直接添加为素材参考)"}
|
||||
aria-busy={uploading}
|
||||
disabled={uploading}
|
||||
onClick={() => {
|
||||
setUploadMenuOpen((open) => !open);
|
||||
setMentionMenuOpen(false);
|
||||
}}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{uploading ? <LoaderCircle /> : <Plus />}
|
||||
</button>
|
||||
<div className="omni-upload-menu" hidden={!uploadMenuOpen}>
|
||||
<strong>添加参考素材</strong>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUploadMenuOpen(false);
|
||||
void openMentions();
|
||||
}}
|
||||
>
|
||||
<FolderOpen />
|
||||
<span>从资产库选择<small>使用平台已有商品、人物或场景</small></span>
|
||||
</button>
|
||||
<button type="button" onClick={() => fileInputRef.current?.click()}>
|
||||
<Upload />
|
||||
<span>本地上传<small>添加电脑中的图片</small></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{uploading ? (
|
||||
<span className="omni-upload-status" role="status" aria-live="polite">
|
||||
<LoaderCircle aria-hidden="true" />
|
||||
@@ -395,12 +611,14 @@ export function OmniCreatePage({
|
||||
<button
|
||||
type="button"
|
||||
className="omni-start-generate"
|
||||
disabled={starting || uploading}
|
||||
aria-label={hasPromptDescription ? "开始创作" : "请先输入创作描述"}
|
||||
title={hasPromptDescription ? "开始创作" : "请先输入创作描述,@引用不能代替描述"}
|
||||
disabled={starting || uploading || !hasPromptDescription}
|
||||
onClick={() => {
|
||||
const text = prompt.trim();
|
||||
const creationBrief = text || selectedCase?.starter || "";
|
||||
if (!text && !selectedCase && pendingRefs.length === 0) {
|
||||
onNotify?.("info", "请先输入你想制作的内容,或选择一个创作预设");
|
||||
if (!getMentionFreeText(text, pendingRefs)) {
|
||||
onNotify?.("info", "请先输入具体的创作描述,@引用不能单独提交");
|
||||
return;
|
||||
}
|
||||
if (starting) return;
|
||||
@@ -545,6 +763,21 @@ export function OmniCreatePage({
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AssetSelectModal
|
||||
open={assetModalOpen}
|
||||
type={assetModalType}
|
||||
onClose={() => setAssetModalOpen(false)}
|
||||
onSelect={addStructuredRef}
|
||||
onNotify={onNotify}
|
||||
/>
|
||||
<MediaLightbox
|
||||
open={Boolean(assetPreview?.src)}
|
||||
src={assetPreview?.src || ""}
|
||||
kind="image"
|
||||
name={assetPreview?.name}
|
||||
close={() => setAssetPreview(null)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,12 @@ import { api } from "../api";
|
||||
import { findCatalogModel, normalizeDurationValue, OmniParamBar } from "../components/omni-param-bar";
|
||||
import { estimateCost, pointsPerImageFromCatalog } from "../components/free-create/constants";
|
||||
import { MediaLightbox } from "../components/overlays";
|
||||
import { AssetSelectModal, type AssetModalType } from "../components/asset-select-modal";
|
||||
import {
|
||||
getMentionFreeText,
|
||||
RichMentionEditor,
|
||||
type RichMentionEditorHandle,
|
||||
} from "../components/rich-mention-editor";
|
||||
import type {
|
||||
CreationConversationDetail,
|
||||
CreationField,
|
||||
@@ -542,6 +548,7 @@ function MentionChips({
|
||||
return (
|
||||
<span className={`omni-mention-chip${cover ? " has-thumb" : ""}`} key={`${ref.type}-${ref.id}`}>
|
||||
{cover ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="omni-mention-thumb"
|
||||
@@ -550,6 +557,16 @@ function MentionChips({
|
||||
>
|
||||
<img src={cover} alt={label} />
|
||||
</button>
|
||||
<span className="omni-mention-hover-preview" role="tooltip">
|
||||
<span className="omni-mention-hover-head">
|
||||
<strong>{label}</strong>
|
||||
<small>{REF_CHIP_LABEL[ref.type] || ref.type}</small>
|
||||
</span>
|
||||
<span className="omni-mention-hover-media">
|
||||
<img src={cover} alt={label} />
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i>{REF_CHIP_LABEL[ref.type] || ref.type}</i>
|
||||
@@ -1444,6 +1461,7 @@ function ElicitCard({
|
||||
|
||||
if (interaction === "chat") {
|
||||
const question = message.text || fields[0]?.label || "这项你想怎么定?";
|
||||
const compactChoiceOnly = String(message.payload.topic || "") === "cast_relation";
|
||||
const choiceField = fields.find(
|
||||
(field) => field.type === "single" && Array.isArray(field.options) && field.options.length > 0,
|
||||
);
|
||||
@@ -1469,7 +1487,10 @@ function ElicitCard({
|
||||
{!submitted ? (
|
||||
<div className="omni-reply-guide" aria-label="回复操作">
|
||||
{choiceField ? (
|
||||
<div className="omni-gate-actions omni-chat-choice-actions" aria-label={choiceField.label}>
|
||||
<div
|
||||
className={`omni-gate-actions${compactChoiceOnly ? "" : " omni-chat-choice-actions"}`}
|
||||
aria-label={choiceField.label}
|
||||
>
|
||||
{(choiceField.options || []).map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1482,6 +1503,7 @@ function ElicitCard({
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{!compactChoiceOnly ? (
|
||||
<form className="omni-chat-question-input" onSubmit={submitChatAnswer}>
|
||||
<input
|
||||
className="input"
|
||||
@@ -1495,6 +1517,7 @@ function ElicitCard({
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
) : savedChoiceText ? (
|
||||
<p className="omni-gate-answered">已选择:{savedChoiceText}</p>
|
||||
@@ -2206,6 +2229,10 @@ export function OmniSessionPage({
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [composerHint, setComposerHint] = useState<string | null>(null);
|
||||
const [pendingRefs, setPendingRefs] = useState<CreationRef[]>([]);
|
||||
const hasComposerDescription = useMemo(
|
||||
() => Boolean(getMentionFreeText(prompt, pendingRefs)),
|
||||
[pendingRefs, prompt],
|
||||
);
|
||||
const [sessionUploads, setSessionUploads] = useState<CreationRef[]>(firstUploads || []);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -2216,7 +2243,7 @@ export function OmniSessionPage({
|
||||
messageId: string;
|
||||
source: "local_upload" | "model_library";
|
||||
} | null>(null);
|
||||
const composerRef = useRef<HTMLTextAreaElement>(null);
|
||||
const composerRef = useRef<RichMentionEditorHandle>(null);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [liveText, setLiveText] = useState("");
|
||||
const [liveReasoning, setLiveReasoning] = useState("");
|
||||
@@ -2228,6 +2255,8 @@ export function OmniSessionPage({
|
||||
const [typeLabels, setTypeLabels] = useState<Record<string, string>>({});
|
||||
const mentionWrapRef = useRef<HTMLDivElement>(null);
|
||||
const [uploadMenuOpen, setUploadMenuOpen] = useState(false);
|
||||
const [sessionAssetModalOpen, setSessionAssetModalOpen] = useState(false);
|
||||
const [sessionAssetModalType, setSessionAssetModalType] = useState<AssetModalType>("product");
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [mergingMessageIds, setMergingMessageIds] = useState<string[]>([]);
|
||||
const [stopping, setStopping] = useState(false);
|
||||
@@ -2258,15 +2287,11 @@ export function OmniSessionPage({
|
||||
|
||||
const editUserMessage = useCallback((message: CreationMessage) => {
|
||||
const refs = message.refs || [];
|
||||
setPrompt(stripMentionText(message.text, refs));
|
||||
setPrompt(message.text);
|
||||
setPendingRefs(refs);
|
||||
setComposerHint(null);
|
||||
requestAnimationFrame(() => {
|
||||
composerRef.current?.focus();
|
||||
composerRef.current?.setSelectionRange(
|
||||
composerRef.current.value.length,
|
||||
composerRef.current.value.length,
|
||||
);
|
||||
composerRef.current?.focusAtEnd();
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -2840,7 +2865,10 @@ export function OmniSessionPage({
|
||||
if (uploading) return;
|
||||
if (streaming && conversation?.agent_status !== "awaiting_user") return;
|
||||
const text = prompt.trim();
|
||||
if (!text && pendingRefs.length === 0) return;
|
||||
if (!getMentionFreeText(text, pendingRefs)) {
|
||||
notify("info", "请先输入具体的创作描述,@引用不能单独发送");
|
||||
return;
|
||||
}
|
||||
setPrompt("");
|
||||
setComposerHint(null);
|
||||
const refs = pendingRefs;
|
||||
@@ -2876,6 +2904,16 @@ export function OmniSessionPage({
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [mentionMenuOpen]);
|
||||
|
||||
const insertPromptMention = (ref: CreationRef, replaceTrigger = false) => {
|
||||
const name = ref.name.split(" · ")[0].trim();
|
||||
const tag = `@${name} `;
|
||||
if (!composerRef.current) {
|
||||
setPrompt((prev) => (prev ? `${prev} ${tag}` : tag));
|
||||
return;
|
||||
}
|
||||
composerRef.current.insertMention(ref, replaceTrigger);
|
||||
};
|
||||
|
||||
const insertMention = (ref: CreationRef) => {
|
||||
const personRequest = personSourceRequestRef.current;
|
||||
if (personRequest) {
|
||||
@@ -2908,17 +2946,14 @@ export function OmniSessionPage({
|
||||
return;
|
||||
}
|
||||
// 整条 Ref 存起来一起发:只把名字拼进文本的话,后端取不到卖点和参考图
|
||||
if (pendingRefs.some((r) => r.id === ref.id)) {
|
||||
setMentionMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
if (pendingRefs.length >= MENTION_REF_LIMIT) {
|
||||
const exists = pendingRefs.some((item) => item.type === ref.type && item.id === ref.id);
|
||||
if (!exists && pendingRefs.length >= MENTION_REF_LIMIT) {
|
||||
notify("info", `最多引用 ${MENTION_REF_LIMIT} 个`);
|
||||
setMentionMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
setPendingRefs((prev) => [...prev, ref]);
|
||||
setPrompt((prev) => prev.replace(/@\S*$/, "").replace(/\s+$/, " "));
|
||||
if (!exists) setPendingRefs((prev) => [...prev, ref]);
|
||||
insertPromptMention(ref, true);
|
||||
setMentionMenuOpen(false);
|
||||
};
|
||||
|
||||
@@ -2984,7 +3019,9 @@ export function OmniSessionPage({
|
||||
}
|
||||
if (source === "model_library") {
|
||||
personSourceRequestRef.current = { messageId: message.id, source };
|
||||
void openMentions("", "model");
|
||||
setMentionMenuOpen(false);
|
||||
setSessionAssetModalType("character");
|
||||
setSessionAssetModalOpen(true);
|
||||
return;
|
||||
}
|
||||
setMessages((prev) =>
|
||||
@@ -3226,30 +3263,17 @@ export function OmniSessionPage({
|
||||
</main>
|
||||
|
||||
<footer className={`omni-session-composer${composerHint ? " is-revise-hint" : ""}`}>
|
||||
<MentionChips
|
||||
refs={pendingRefs}
|
||||
tone="composer"
|
||||
onRemove={(id) => setPendingRefs((prev) => prev.filter((ref) => ref.id !== id))}
|
||||
/>
|
||||
<textarea
|
||||
<RichMentionEditor
|
||||
id="omniSessionPrompt"
|
||||
ref={composerRef}
|
||||
rows={2}
|
||||
placeholder={composerHint || "回复创作助手,也可以继续补充图片或要求……"}
|
||||
placeholder={composerHint || "继续补充图片或创作要求……"}
|
||||
value={prompt}
|
||||
refs={pendingRefs}
|
||||
disabled={uploading || (streaming && conversation?.agent_status !== "awaiting_user")}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
setPrompt(value);
|
||||
const caret = event.target.selectionStart ?? 0;
|
||||
if (caret > 0 && value.charAt(caret - 1) === "@") void openMentions();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
submitOnEnter
|
||||
onChange={setPrompt}
|
||||
onAtTrigger={() => void openMentions()}
|
||||
onSubmit={handleSend}
|
||||
/>
|
||||
<div className="omni-session-composer-tools">
|
||||
<div className="omni-session-composer-options">
|
||||
@@ -3278,12 +3302,27 @@ export function OmniSessionPage({
|
||||
onClick={() => {
|
||||
personSourceRequestRef.current = null;
|
||||
setUploadMenuOpen(false);
|
||||
void openMentions();
|
||||
setSessionAssetModalType("product");
|
||||
setSessionAssetModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<FolderOpen />
|
||||
<Box />
|
||||
<span>
|
||||
从资产库选择<small>引用已有商品、人物或场景</small>
|
||||
添加商品<small>从商品库选择或新增商品</small>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
personSourceRequestRef.current = null;
|
||||
setUploadMenuOpen(false);
|
||||
setSessionAssetModalType("character");
|
||||
setSessionAssetModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<UserRound />
|
||||
<span>
|
||||
添加角色<small>从角色库选择或新增人物</small>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -3297,7 +3336,7 @@ export function OmniSessionPage({
|
||||
>
|
||||
<Upload />
|
||||
<span>
|
||||
本地上传<small>添加电脑中的图片</small>
|
||||
上传素材<small>添加电脑中的图片作为素材</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -3346,6 +3385,7 @@ export function OmniSessionPage({
|
||||
if (prev.some((item) => item.id === ref.id) || prev.length >= MENTION_REF_LIMIT) return prev;
|
||||
return [...prev, ref];
|
||||
});
|
||||
insertPromptMention(ref);
|
||||
}
|
||||
}
|
||||
notify(
|
||||
@@ -3443,14 +3483,15 @@ export function OmniSessionPage({
|
||||
<button
|
||||
type="button"
|
||||
className={`omni-session-send${isPlanning ? " is-stop" : ""}`}
|
||||
aria-label={isPlanning ? "终止" : "发送"}
|
||||
title={isPlanning ? "终止整理方案" : "发送"}
|
||||
aria-label={isPlanning ? "终止" : hasComposerDescription ? "发送" : "请先输入创作描述"}
|
||||
title={isPlanning ? "终止整理方案" : hasComposerDescription ? "发送" : "请先输入创作描述,@引用不能单独发送"}
|
||||
disabled={
|
||||
isPlanning
|
||||
? stopping
|
||||
: (
|
||||
uploading
|
||||
|| (streaming && conversation?.agent_status !== "awaiting_user")
|
||||
|| !hasComposerDescription
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
@@ -3689,6 +3730,55 @@ export function OmniSessionPage({
|
||||
name={assetPreview?.name}
|
||||
close={() => setAssetPreview(null)}
|
||||
/>
|
||||
<AssetSelectModal
|
||||
open={sessionAssetModalOpen}
|
||||
type={sessionAssetModalType}
|
||||
onClose={() => {
|
||||
setSessionAssetModalOpen(false);
|
||||
if (personSourceRequestRef.current?.source === "model_library") {
|
||||
personSourceRequestRef.current = null;
|
||||
}
|
||||
}}
|
||||
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) => {
|
||||
if (prev.some((item) => item.type === newRef.type && item.id === newRef.id) || prev.length >= MENTION_REF_LIMIT) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, newRef];
|
||||
});
|
||||
insertPromptMention(newRef);
|
||||
}}
|
||||
onNotify={notify}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user