diff --git a/core/backend/apps/ai/creation_agent.py b/core/backend/apps/ai/creation_agent.py index d14cc65..a49aca2 100644 --- a/core/backend/apps/ai/creation_agent.py +++ b/core/backend/apps/ai/creation_agent.py @@ -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,11 +2181,21 @@ def apply_person_identity_guard(prompt: str, references: list[dict]) -> str: if not indexes: return prompt labels = "、".join(f"参考图{index}" for index in indexes) + if len(indexes) == 1: + identity_rule = ( + "整片及所有分段、远景、近景、转场都必须保持同一人;五官比例、脸型、发型、" + "肤色、年龄、身形、手部特征和基础服装不得漂移。不得换人、随机造人、合并成新面孔," + "不得因镜头或光线变化而改变身份。" + ) + else: + identity_rule = ( + "这些参考图分别对应不同角色。整片及所有分段、远景、近景、转场都必须保持每位角色各自的" + "五官比例、脸型、发型、肤色、年龄、身形、手部特征和基础服装;人物不得互换、遗漏、" + "随机替换或合并成新面孔,也不得因镜头或光线变化而改变任何角色身份。" + ) return ( f"{prompt.strip()}\n\n【人物一致性硬约束】{labels}定义本片的固定出镜人物。" - "整片及所有分段、远景、近景、转场都必须保持同一人;五官比例、脸型、发型、" - "肤色、年龄、身形、手部特征和基础服装不得漂移。不得换人、随机造人、合并成新面孔," - "不得因镜头或光线变化而改变身份。" + f"{identity_rule}" ) @@ -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: diff --git a/core/backend/apps/ai/creation_presets.py b/core/backend/apps/ai/creation_presets.py index ecf469e..bd3855e 100644 --- a/core/backend/apps/ai/creation_presets.py +++ b/core/backend/apps/ai/creation_presets.py @@ -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 秒具体痛点或体验钩子 → 真实使用/质地/细节证明 → 一句个人感受 → 克制收束。" "口播像朋友分享,所有卖点必须被动作或特写佐证;禁止万能主播腔、堆砌卖点、画面字幕和夸张承诺。" ), diff --git a/core/backend/apps/ai/free_video.py b/core/backend/apps/ai/free_video.py index 35a9b9c..ee98f1c 100644 --- a/core/backend/apps/ai/free_video.py +++ b/core/backend/apps/ai/free_video.py @@ -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) diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 483a98b..507ec07 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -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) diff --git a/core/backend/apps/ai/test_creation_agent.py b/core/backend/apps/ai/test_creation_agent.py index 1159606..d8e43a0 100644 --- a/core/backend/apps/ai/test_creation_agent.py +++ b/core/backend/apps/ai/test_creation_agent.py @@ -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"]) diff --git a/core/backend/apps/ai/test_free_video_asset_ref.py b/core/backend/apps/ai/test_free_video_asset_ref.py index 4a03b24..1932658 100644 --- a/core/backend/apps/ai/test_free_video_asset_ref.py +++ b/core/backend/apps/ai/test_free_video_asset_ref.py @@ -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: diff --git a/core/backend/apps/ai/test_standalone_image_routing.py b/core/backend/apps/ai/test_standalone_image_routing.py index e208008..02938f8 100644 --- a/core/backend/apps/ai/test_standalone_image_routing.py +++ b/core/backend/apps/ai/test_standalone_image_routing.py @@ -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") diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 0bfcfb6..ae40aea 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -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() + pending = payload.get("pending_fields") or [] + primary_field = pending[0] if pending else {} + 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": - 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"] + 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 = ( "用户刚选择暂不添加这项素材。接受这个选择,按会话里已有的需求和合理默认继续原任务。" "先用一句自然的话承接,随后直接推进创作;不要再次追问同一素材,不要只说收到或有需要再说。" diff --git a/core/backend/apps/assets/review.py b/core/backend/apps/assets/review.py index 4e503c6..974efbe 100644 --- a/core/backend/apps/assets/review.py +++ b/core/backend/apps/assets/review.py @@ -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" diff --git a/core/frontend/src/components/app-shell.tsx b/core/frontend/src/components/app-shell.tsx index b29a9df..e93ccf4 100644 --- a/core/frontend/src/components/app-shell.tsx +++ b/core/frontend/src/components/app-shell.tsx @@ -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" }, diff --git a/core/frontend/src/components/asset-select-modal.css b/core/frontend/src/components/asset-select-modal.css new file mode 100644 index 0000000..39e5eae --- /dev/null +++ b/core/frontend/src/components/asset-select-modal.css @@ -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; + } +} diff --git a/core/frontend/src/components/asset-select-modal.tsx b/core/frontend/src/components/asset-select-modal.tsx new file mode 100644 index 0000000..edb64bf --- /dev/null +++ b/core/frontend/src/components/asset-select-modal.tsx @@ -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(); + +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([]); + const [loadedKey, setLoadedKey] = useState(""); + const [loading, setLoading] = useState(false); + const [selectedRef, setSelectedRef] = useState(null); + const [sortOrder, setSortOrder] = useState<"recent" | "name">("recent"); + const [isAddingNew, setIsAddingNew] = useState(false); + const [newName, setNewName] = useState(""); + const [newFile, setNewFile] = useState(null); + const [newPreview, setNewPreview] = useState(""); + const [uploading, setUploading] = useState(false); + const fileInputRef = useRef(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 ( +
{ + if (event.target === event.currentTarget && !uploading) onClose(); + }} + > +
+ + +
+ +
+ {isAddingNew ? `新增${titleText}` : title} + {isAddingNew ? "[ UPLOAD · CREATE ]" : subtitle} +
+
+ {isAddingNew ? ( + + ) : ( + + )} + +
+
+ + {isAddingNew ? ( + <> +
+ + { + const file = event.target.files?.[0]; + event.target.value = ""; + if (file) handleFilePick(file); + }} + /> +
+ + setNewName(event.target.value)} + /> + + {type === "product" ? "创建后会进入商品库,可继续补充卖点与规格。" : "创建后会进入角色库,后续镜头可持续锁定同一人物。"} + +
+
+
+ + +
+ + ) : ( + <> +
+ + +
+
+ {!hasCurrentData ? ( +
+ {Array.from({ length: 10 }, (_, index) => ( + + ))} +
+ ) : visibleItems.length === 0 ? ( +
+ + {searchQuery ? `没有找到“${searchQuery}”` : `暂时没有${titleText}`} + {searchQuery ? "换个关键词试试" : `可以先新增一个${titleText}`} + {!searchQuery ? ( + + ) : null} +
+ ) : ( +
+ {visibleItems.map((item) => { + const isSelected = selectedRef?.type === item.type && selectedRef.id === item.id; + const displayName = item.name.split(" · ")[0]; + return ( + + ); + })} +
+ )} +
+
+ + {selectedRef ? `已选择:${selectedRef.name.split(" · ")[0]}` : `选择一个${titleText}后添加`} + + + +
+ + )} +
+
+ ); +} diff --git a/core/frontend/src/components/rich-mention-editor.css b/core/frontend/src/components/rich-mention-editor.css new file mode 100644 index 0000000..f323fda --- /dev/null +++ b/core/frontend/src/components/rich-mention-editor.css @@ -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; +} diff --git a/core/frontend/src/components/rich-mention-editor.tsx b/core/frontend/src/components/rich-mention-editor.tsx new file mode 100644 index 0000000..ecd8c2b --- /dev/null +++ b/core/frontend/src/components/rich-mention-editor.tsx @@ -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 = { + 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(); + 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( + function RichMentionEditor( + { + id, + value, + refs, + placeholder, + ariaLabel = "创作描述", + className = "", + disabled = false, + submitOnEnter = false, + onChange, + onAtTrigger, + onSubmit, + }, + forwardedRef, + ) { + const rootRef = useRef(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) => { + emitChange(); + const native = event.nativeEvent as InputEvent; + if (native.data === "@") atTriggerRef.current?.(); + }; + + const handlePaste = (event: ClipboardEvent) => { + 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) => { + if (submitOnEnter && event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + submitRef.current?.(); + } + }; + + const positionPreview = (event: ReactPointerEvent) => { + const root = rootRef.current; + const target = event.target as Element; + const token = target.closest(".rich-mention-token"); + if (!root || !token || !root.contains(token)) return; + const preview = token.querySelector(".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 ( +
+ ); + }, +); diff --git a/core/frontend/src/omni-create-page.css b/core/frontend/src/omni-create-page.css index ae4ce6e..3a04bda 100644 --- a/core/frontend/src/omni-create-page.css +++ b/core/frontend/src/omni-create-page.css @@ -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; + } +} diff --git a/core/frontend/src/omni-session-page.css b/core/frontend/src/omni-session-page.css index 1fed823..cb7dacc 100644 --- a/core/frontend/src/omni-session-page.css +++ b/core/frontend/src/omni-session-page.css @@ -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 { diff --git a/core/frontend/src/routes/omni-create.tsx b/core/frontend/src/routes/omni-create.tsx index c6cb34a..2ab2980 100644 --- a/core/frontend/src/routes/omni-create.tsx +++ b/core/frontend/src/routes/omni-create.tsx @@ -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>({}); const [pendingRefs, setPendingRefs] = useState([]); const [previewCase, setPreviewCase] = useState(null); + const [assetPreview, setAssetPreview] = useState<{ src: string; name: string } | null>(null); + const [assetModalOpen, setAssetModalOpen] = useState(false); + const [assetModalType, setAssetModalType] = useState("product"); const fileInputRef = useRef(null); + const promptRef = useRef(null); const toolsRef = useRef(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) => { const files = Array.from(event.target.files || []); event.target.value = ""; @@ -243,7 +308,7 @@ export function OmniCreatePage({ 创作历史 - YINGQING CREATIVE AGENT + YINGQING CREATIVE STUDIO

和全能创作聊聊你的想法

选择一种创作方法,或直接描述你想生成的画面。

@@ -254,77 +319,228 @@ export function OmniCreatePage({
- +
+ )} + +
+
+
+ {/* 1. 商品槽位 */} + {productRefs.map((ref) => ( +
+ 商品 + {ref.cover ? ( + + ) : ( +
+ +
+ )} + + +
+ ))} + + {productRefs.length === 0 && ( + + )} + + {/* 2. 角色槽位 */} + {roleRefs.map((ref, idx) => ( +
+ + {`角色${idx + 1}`} + + {ref.cover ? ( + + ) : ( +
+ +
+ )} + + +
+ ))} + + {roleRefs.length < ROLE_REF_LIMIT && ( + + )} + + {/* 3. 素材槽位 (直接上传算素材) */} + {materialRefs.map((ref) => ( +
+ {ref.type === "scene" ? "场景" : "素材"} + {ref.cover ? ( + + ) : ( +
+ +
+ )} + + +
+ ))} +
+ {pendingRefs.length}/20 +
+ +
+ void openMentions()} + /> +
-