diff --git a/core/backend/apps/ai/creation_agent.py b/core/backend/apps/ai/creation_agent.py index a938ccf..ff03cf4 100644 --- a/core/backend/apps/ai/creation_agent.py +++ b/core/backend/apps/ai/creation_agent.py @@ -7,8 +7,8 @@ 铁律(踩过就回不来的三条): 1. **视频 5–10 分钟,绝不在 SSE 里等。** 生成工具立刻返回 task_id,落一条 `generating` 消息,发 `task` 事件,收流。前端轮询完成后原地换成 `result`。 -2. **闸门必须等人确认。** `ask_user` / `write_strategy` / `write_plan` / - `write_prompt` 一旦落卡就中断循环;视频 5 步(澄清→策略→方案→Prompt→出片确认) +2. **闸门必须等人确认。** `ask_user` / `write_plan` / `write_prompt` + 一旦落卡就中断循环;`write_strategy` 仅供模型内部梳理,不展示给用户;视频 4 步(澄清→架构→Prompt→出片确认) 不可同轮连跳。 3. **一条用户消息最多计费生成一次。** 对话式会放大调用量,一句「多做几版」 能烧掉一堆积分。 @@ -52,8 +52,6 @@ from .services import ( build_provider, enforce_no_embedded_captions, get_default_model, - get_seed_text_model, - resolve_text_model, ) logger = logging.getLogger(__name__) @@ -71,9 +69,20 @@ LONG_VIDEO_DURATION_SLACK_SECONDS = 2 # 单条用户消息最多触发一次计费生成(契约 §4) MAX_BILLED_GENERATIONS = 1 +# 全能创作的对话编排模型由后台模型库配置;当前指定为 YunQi 的 GPT-6 Luna。 +# 生图仍由各生成工具按 gpt-image-2 的图片模型路由,不受这里影响。 +CREATION_CHAT_MODEL_NAME = "gpt-6-luna" + + +def creation_model_temperature(model_config: ModelConfig | None) -> float: + """返回全能创作模型允许的 temperature。""" + name = str(getattr(model_config, "name", "") or "").lower() + # GPT-6 Luna 网关拒绝非默认值;显式传 1 保持与 OpenAI 默认行为一致。 + return 1.0 if name == CREATION_CHAT_MODEL_NAME else 0.8 + def creation_agent_max_output_tokens() -> int: - """豆包 Seed 系列最大输出 16k、默认 4k。不抬高会把长方案的 tool 参数截成坏 JSON。""" + """长架构需要足够输出空间,避免 tool 参数被截成不完整 JSON。""" from django.conf import settings return max(1024, int(getattr(settings, "CREATION_AGENT_MAX_OUTPUT_TOKENS", 16000) or 16000)) @@ -82,13 +91,23 @@ def creation_agent_max_output_tokens() -> int: def creation_model_extra_body(model_config: ModelConfig, tools: list[dict]) -> dict: """统一拼模型请求体的可选参数。 - max_tokens 所有网关都认;thinking 只对火山官方直连下发,避免中转站因未知参数报 400。 + Luna 使用 OpenAI 新版的 max_completion_tokens;其余网关维持 max_tokens。 + thinking 只对火山官方直连下发,避免中转站因未知参数报 400。 """ from django.conf import settings from .services import OFFICIAL_DIRECT_PROVIDERS - body: dict = {"tools": tools, "max_tokens": creation_agent_max_output_tokens()} + token_key = ( + "max_completion_tokens" + if str(getattr(model_config, "name", "") or "").lower() == CREATION_CHAT_MODEL_NAME + else "max_tokens" + ) + body: dict = {"tools": tools, token_key: creation_agent_max_output_tokens()} + if str(getattr(model_config, "name", "") or "").lower() == CREATION_CHAT_MODEL_NAME: + # Luna 在 chat/completions 使用 function tools 时,不支持 reasoning_effort 默认档。 + # 显式关掉后仍可走标准 OpenAI tools 协议。 + body["reasoning_effort"] = "none" mode = (getattr(settings, "CREATION_AGENT_THINKING_MODE", "") or "").strip() provider_name = str(getattr(getattr(model_config, "provider", None), "name", "") or "") if mode in {"enabled", "disabled", "auto"} and provider_name in OFFICIAL_DIRECT_PROVIDERS: @@ -199,8 +218,10 @@ def clear_public_agent_progress(conversation: CreationConversation) -> None: conversation.save(update_fields=["memory", "updated_at"]) # 视频闸门阶段(落在 conversation.memory.stage;resume 靠它) -# clarify → strategy → plan → prompt → confirm → done -VIDEO_GATE_STAGES = ("clarify", "strategy", "plan", "prompt", "confirm", "done") +# clarify → strategy → plan → prompt → cast → confirm → done +# 角色定妆必须在 Prompt 之后补齐:脚本先围绕需求完成,最后才把已定稿的 +# 角色参考锁进出片参数,避免角色选择反过来打断视频架构。 +VIDEO_GATE_STAGES = ("clarify", "strategy", "plan", "prompt", "cast", "confirm", "done") PAIN_POINT_PRESET = "痛点解决演示" PAIN_POINT_DIRECTION_KEY = "pain_point_direction" _STEP_CONFIRM_LABELS = { @@ -383,6 +404,7 @@ def append_plot_twist_story_depth_question(conversation: CreationConversation) - "options": [ {"value": item["value"], "label": f"{item['label']}|{item['summary']}"} for item in PLOT_TWIST_STORY_DEPTH_OPTIONS + if item["value"] in {"15s", "30s", "60s"} ], } ], @@ -838,23 +860,23 @@ def append_multi_character_relation_gate(conversation: CreationConversation) -> def video_needs_person_source(conversation: CreationConversation, user_text: str = "") -> bool: - """需要真人/角色的视频在写策略前必须先锁定人物来源。""" + """Prompt 完成后检查角色图是否按架构人数补齐。""" if conversation.mode != CreationConversation.Mode.VIDEO: return False - # 已钉角色图 / 正在生成 / 只出手:才算人物步骤完成。禁止仅凭「你来推荐」空跑跳过。 - if person_identity_ready(conversation): - return False memory = conversation.memory if isinstance(conversation.memory, dict) else {} + if memory.get("person_source_pending") or str(memory.get("person_source") or "") == "finger_only": + return False # 点击换款:只有「角色日常换款」才要人物;「只出手」跳过,且不被开场文案里的「口播/剧情」否定词误触发。 if is_click_swap_preset(conversation.preset): return click_swap_mode(conversation) == "character" if conversation.preset in _PERSON_SOURCE_PRESETS or is_pet_preset(conversation.preset): - return True + return len(locked_person_references(conversation)) < infer_needed_cast_count(conversation, user_text) recent = list( conversation.messages.order_by("-seq").values_list("text", flat=True)[:12] ) pending_prompt = str(memory.get("pending_video_prompt") or "") - return bool(_PERSON_VISUAL_RE.search("\n".join([user_text, pending_prompt, *recent]))) + needs_person = bool(_PERSON_VISUAL_RE.search("\n".join([user_text, pending_prompt, *recent]))) + return needs_person and len(locked_person_references(conversation)) < infer_needed_cast_count(conversation, user_text) def locked_product_references(conversation: CreationConversation) -> list[dict]: @@ -1026,7 +1048,85 @@ def creation_needs_product_source(conversation: CreationConversation, user_text: return conversation.preset in _PRODUCT_REQUIRED_PRESETS -def append_person_source_gate(conversation: CreationConversation) -> CreationMessage: +def product_brief_items(conversation: CreationConversation) -> list[dict]: + """把已知商品事实整理成可核对清单;缺少事实只标待补充,不替用户编造。""" + memory = conversation.memory if isinstance(conversation.memory, dict) else {} + products = locked_product_references(conversation) + product = products[0] if products else {} + history = " ".join( + str(item or "") + for item in conversation.messages.filter(role="user").values_list("text", flat=True) + ) + name = str( + memory.get("product_brand_and_name") + or memory.get("product_name") + or product.get("name") + or "" + ).strip() + selling = str(memory.get("selling_point") or "").strip() + + def item(label: str, status: str, value: str = "") -> dict: + return {"label": label, "status": status, "value": value} + + factual_price_requested = bool(re.search(r"价格|售价|优惠|折扣|券|满减|到手", history)) + special = "" + special_match = re.search(r"(?:重点|强调|禁止|不要)[::]?([^。!!\n]{2,80})", history) + if special_match: + special = special_match.group(0).strip() + return [ + item("商品图", "ready" if products else "missing", "已锁定参考图" if products else ""), + item("名称与品牌", "ready" if name else "missing", name), + item("品类与外观", "ready" if products else "missing", "将以已锁定商品图为准" if products else ""), + item("核心卖点", "ready" if selling else "missing", selling), + item("使用场景与目标用户", "missing", ""), + item("价格与优惠", "missing" if factual_price_requested else "not_needed", ""), + item("特别强调或禁止内容", "ready" if special else "not_needed", special), + ] + + +def product_brief_needs_review(conversation: CreationConversation) -> bool: + if conversation.preset not in _PRODUCT_REQUIRED_PRESETS: + return False + memory = conversation.memory if isinstance(conversation.memory, dict) else {} + return has_locked_product_reference(conversation) and not bool(memory.get("product_brief_reviewed")) + + +def append_product_brief_review(conversation: CreationConversation) -> CreationMessage: + """商品信息只核对一次;已获取、待补充、暂不需要在一张卡里说清。""" + return append_message( + conversation, + role="assistant", + kind=CreationMessage.Kind.ELICIT, + text="先核对商品信息。已知内容会直接复用;品牌、价格、优惠或功效等事实缺失时只接受你补充,不会自动编造。", + payload={ + "interaction": "product_brief_review", + "items": product_brief_items(conversation), + "fields": [ + { + "key": "review_action", + "label": "商品信息是否可以继续?", + "type": "single", + "required": True, + "options": [ + {"value": "continue", "label": "按现有信息继续"}, + {"value": "supplement", "label": "我来补充"}, + ], + }, + { + "key": "product_brief_note", + "label": "补充真实商品信息(选填)", + "type": "text", + "required": False, + "placeholder": "例如:主打卖点、使用场景、目标用户、真实价格或优惠", + }, + ], + "submitted": False, + "answers": {}, + }, + ) + + +def append_person_source_gate(conversation: CreationConversation, extra_text: str = "") -> CreationMessage: """可视化的人物/角色来源闸门;三个选项分别进文件、模特库和生图流程。""" product_name = "" for ref in locked_product_references(conversation): @@ -1039,6 +1139,7 @@ def append_person_source_gate(conversation: CreationConversation) -> CreationMes product_name = str(memory.get("product_name") or memory.get("product_brand_and_name") or "").strip() is_pet = is_pet_preset(conversation.preset) + missing_cast = 1 if is_pet: prompt_text = ( f"商品已选定【{product_name}】。这条视频想由哪只宠物角色出镜?选定后,所有镜头和分段都会锁定同一只宠物形象。" @@ -1048,14 +1149,17 @@ def append_person_source_gate(conversation: CreationConversation) -> CreationMes field_label = "选择宠物来源" library_label = "从角色库选择" else: - cast_needed = infer_needed_cast_count(conversation) + cast_needed = infer_needed_cast_count(conversation, extra_text) + cast_have = len(locked_person_references(conversation)) + missing_cast = max(1, cast_needed - cast_have) + cast_index = min(cast_needed, cast_have + 1) if cast_needed > 1: prompt_text = ( - f"商品已选定【{product_name}】。这条视频需要 {cast_needed} 位出镜人物;" - "请上传/选择/生成对应数量的角色定妆图,所有镜头和分段都会按这些图锁脸。" + f"商品已选定【{product_name}】。这条视频需要 {cast_needed} 位出镜人物," + f"当前补全第 {cast_index}/{cast_needed} 位;所有镜头和分段都会按角色图锁脸。" if product_name else - f"这条视频需要 {cast_needed} 位出镜人物。请上传/选择/生成对应数量的角色定妆图," - "所有镜头和分段都会按这些图锁脸,避免长视频前后形象漂移。" + f"这条视频需要 {cast_needed} 位出镜人物,当前补全第 {cast_index}/{cast_needed} 位。" + "所有镜头和分段都会按角色图锁脸,避免前后形象漂移。" ) else: prompt_text = ( @@ -1074,6 +1178,9 @@ def append_person_source_gate(conversation: CreationConversation) -> CreationMes payload={ "interaction": "person_source_gate", "is_pet": is_pet, + "cast_needed": 1 if is_pet else infer_needed_cast_count(conversation, extra_text), + "cast_have": len(locked_person_references(conversation)), + "estimated_credits": estimate_role_image_credits(conversation, missing_cast), "fields": [{ "key": "person_source", "label": field_label, @@ -1091,6 +1198,21 @@ def append_person_source_gate(conversation: CreationConversation) -> CreationMes ) +def estimate_role_image_credits(conversation: CreationConversation, count: int) -> int: + """角色图开始生成前展示预计积分;失败时返回 0,不阻塞流程。""" + from apps.billing.pricing import quote_flat + + model_config = get_default_model(ModelConfig.Capability.IMAGE) + if model_config is None: + return 0 + try: + per = quote_flat(model_config, units=1, team=conversation.team) + return int(per.points) * max(1, int(count or 1)) + except Exception: # noqa: BLE001 + logger.warning("omni create: role image estimate failed", exc_info=True) + return 0 + + def click_swap_sequence(conversation: CreationConversation) -> str: memory = conversation.memory if isinstance(conversation.memory, dict) else {} return str(memory.get("click_swap_sequence") or "").strip() @@ -1413,7 +1535,9 @@ def submit_generated_person_reference( context_brief = "\n".join(reversed([item.strip() for item in recent_user if item and item.strip()]))[:700] raw_appearance = (appearance_prompt or "").strip()[:500] - total = cast_count if cast_count is not None else infer_needed_cast_count(conversation, raw_appearance) + total = cast_count if cast_count is not None else ( + infer_needed_cast_count(conversation, raw_appearance) - len(locked_person_references(conversation)) + ) total = max(1, min(4, int(total or 1))) if is_pet_preset(conversation.preset): total = 1 @@ -1619,6 +1743,43 @@ def emit_prompt_gate( return messages +def sync_prompt_after_cast(conversation: CreationConversation) -> CreationMessage | None: + """角色图确定后把真实参考图编号补进 Prompt;没有变化时不重复落文件卡。""" + prompt = get_pending_video_prompt(conversation) + if not prompt: + return None + resolved = resolve_refs(conversation.team, conversation.pinned_refs or []) + references = list(resolved.references) + people = [item for item in references if item.get("type") in {"model", "character"}] + if not people: + # 兼容本地上传角色尚未被 resolver 补齐 URL 的瞬间;锁定身份事实本身仍要写进 Prompt。 + people = locked_person_references(conversation) + references = [*people, *references] + if not people: + return None + signature = "|".join(str(item.get("id") or item.get("asset_id") or item.get("url") or "") for item in people) + memory = dict(conversation.memory or {}) + if signature and memory.get("prompt_cast_signature") == signature: + return None + synced = apply_person_identity_guard(prompt, references) + synced = apply_product_reference_guard(synced, references) + memory["pending_video_prompt"] = synced + memory["prompt_cast_signature"] = signature + conversation.memory = memory + conversation.save(update_fields=["memory", "updated_at"]) + return append_message( + conversation, + role="assistant", + kind=CreationMessage.Kind.PROMPT_FILE, + payload={ + "title": "视频生成Prompt-角色已同步.md", + "body": synced, + "ref_count": len(conversation.pinned_refs or []), + "synced_after_cast": True, + }, + ) + + def emit_final_confirm_gate( conversation: CreationConversation, *, @@ -1667,6 +1828,8 @@ def emit_final_confirm_gate( ) except Exception: # noqa: BLE001 credits = 0 + duration = video_duration(conversation.params or {}, prompt=prompt, timeline=timeline) + segments = plan_video_segments(duration, timeline=timeline) confirm = append_message( conversation, role="assistant", @@ -1677,6 +1840,16 @@ def emit_final_confirm_gate( "estimated_credits": credits, "video_prompt": prompt, "timeline": timeline, + "generation_plan": { + "duration": duration, + "segment_count": len(segments), + "segments": segments, + "note": ( + f"目标时长 {duration} 秒,将分 {len(segments)} 段生成后自动合并。" + if len(segments) > 1 + else f"目标时长 {duration} 秒,单段生成。" + ), + }, "submitted": False, "params": snapshot_session_params(conversation), "param_options": confirm_param_options(True), @@ -2756,9 +2929,10 @@ def _normalize_confirm_duration(value) -> tuple[str, int | str]: def apply_confirm_params(conversation, incoming: dict | None) -> tuple[dict, bool]: - """确认卡上改的参数写回会话。返回 (最新 params, 视频时长是否变了)。""" + """确认卡上改的参数写回会话。返回 (最新 params, 是否必须同步重写架构/Prompt)。""" current = dict(conversation.params or {}) old_duration = str(current.get("duration") or "") + old_model = str(current.get("model") or "") changed = False for key, raw in (incoming or {}).items(): if key not in {"model", "ratio", "resolution", "duration", "count"}: @@ -2776,13 +2950,23 @@ def apply_confirm_params(conversation, incoming: dict | None) -> tuple[dict, boo and bool(str(current.get("duration") or "")) and bool(old_duration) ) + model_changed = ( + conversation.mode == CreationConversation.Mode.VIDEO + and bool(old_model) + and bool(str(current.get("model") or "")) + and str(current.get("model") or "") != old_model + ) if changed: conversation.params = current conversation.save(update_fields=["params", "updated_at"]) if is_plot_twist_conversation(conversation): # 在确认卡改时长也要切换故事契约;随后视图会要求重写旧方案。 set_plot_twist_story_depth(conversation, str(current.get("duration") or "")) - return snapshot_session_params(conversation), duration_changed + needs_rebuild = duration_changed or model_changed + if needs_rebuild: + # 角色和商品素材继续保留;只让 GPT 基于新参数重写受影响的架构与 Prompt。 + set_video_gate_stage(conversation, "strategy", clear_pending_prompt=True) + return snapshot_session_params(conversation), needs_rebuild # ---------------------------------------------------------------- 工具 schema @@ -2904,10 +3088,10 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict "function": { "name": "write_strategy", "description": ( - "写「创作策略理解」卡:说清这条片给谁看、他为什么会信、你想让他信什么、整体创作方向。" + "内部梳理创作策略:说清这条片给谁看、他为什么会信、你想让他信什么、整体创作方向。" "四个字段都必须写具体非空文案,禁止空字符串。" "策略从第一稿就使用健康、正向、明确成年的人物与情节表达,不要复述需要规避的原始措辞。" - "调完会停下来等用户确认或提出修改,不要同轮接着 write_plan。" + "本工具不会展示给用户;调用成功后必须在同一轮立即调用 write_plan,交付唯一可见的视频架构卡。" "仅当用户明确要做片/出方案时调用;打招呼或闲聊不要调。" ), "parameters": { @@ -2927,9 +3111,9 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict "function": { "name": "write_plan", "description": ( - "写「视频最终方案」卡(USP/卖点/时间轴)并请用户确认。" - "**仅当用户已确认策略、或明确要改方案时调用**;打招呼或闲聊不要调。" - "调完只出方案卡并停下等人确认 —— 不要同轮出 Prompt 卡或积分确认卡。" + "写唯一对用户展示的「视频架构」卡并请用户确认。" + "架构要让用户看懂并能逐段修改;打招呼或闲聊不要调。" + "调完只出架构卡并停下等人确认 —— 不要同轮出 Prompt 卡或积分确认卡。" "usp / points / timeline 必须写满具体文案;同时把 video_prompt 写好存档," "用户确认方案后由平台展示 Prompt。" + ( @@ -2942,11 +3126,14 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict ) + "第一稿必须已经可直接过平台审核:只写正向安全描述,不要输出风险词清单或否定式免责声明。" - "先有已确认的 write_strategy,再调它。" + "先完成内部 write_strategy,再调它。修改架构时必须沿用上一版,只改用户指出的部分,未受影响的时间段原样保留。" ), "parameters": { "type": "object", "properties": { + "goal": {"type": "string", "description": "视频目标,例如建立认知、证明卖点或推动下单"}, + "duration": {"type": "string", "description": "本架构采用的目标时长"}, + "concept": {"type": "string", "description": "一句话创意概念"}, "usp": {"type": "string", "description": "主打卖点,全片只讲这一个核心价值"}, "points": { "type": "array", "maxItems": 3, "items": {"type": "string"}, @@ -2959,9 +3146,13 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict "properties": { "start": {"type": "number"}, "end": {"type": "number"}, "stage": {"type": "string", "description": "Hook / 过桥 / 正文 / CTA"}, - "desc": {"type": "string"}, + "visual": {"type": "string", "description": "这一段的画面、剧情或冲突"}, + "action_dialogue": {"type": "string", "description": "角色动作与对白/口播"}, + "product": {"type": "string", "description": "商品何时出现、如何承载已确认卖点"}, + "purpose": {"type": "string", "description": "这一段承担 Hook、冲突、证明、反转或 CTA 中的什么作用"}, + "desc": {"type": "string", "description": "兼容旧稿的简述;已有四个详细字段时可省略"}, }, - "required": ["start", "end", "stage"], + "required": ["start", "end", "stage", "visual", "action_dialogue", "product", "purpose"], }, }, "voice_chars": { @@ -2983,7 +3174,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict ), }, }, - "required": ["usp", "points", "video_prompt"], + "required": ["goal", "duration", "concept", "usp", "points", "timeline", "video_prompt"], }, }, }) @@ -3733,10 +3924,25 @@ def submit_confirmed_image(*, conversation: CreationConversation, user, confirm_ def get_creation_chat_model(requested: ModelConfig | None = None) -> ModelConfig | None: - """全能创作编排固定优先 Seed 2.1 Pro;显式传入的可用模型仍尊重用户选择。""" - if requested is not None: - return resolve_text_model(requested) - return get_seed_text_model() or resolve_text_model(None) + """全能创作的语言编排只允许 GPT-6 Luna,绝不回退到豆包或后台默认模型。""" + if ( + requested is not None + and str(getattr(requested, "name", "") or "").lower() == CREATION_CHAT_MODEL_NAME + and getattr(requested, "status", "") == ModelConfig.Status.ACTIVE + and getattr(getattr(requested, "provider", None), "status", "") == "active" + ): + return requested + return ( + ModelConfig.objects.select_related("provider") + .filter( + name=CREATION_CHAT_MODEL_NAME, + capability=ModelConfig.Capability.TEXT, + status=ModelConfig.Status.ACTIVE, + provider__status="active", + ) + .order_by("created_at") + .first() + ) def _creation_model_sees_images(model_config: ModelConfig | None) -> bool: @@ -3746,8 +3952,9 @@ def _creation_model_sees_images(model_config: ModelConfig | None) -> bool: if getattr(model_config, "capability", "") == ModelConfig.Capability.VISION: return True name = str(getattr(model_config, "name", "") or "").lower() - # 豆包 Seed 2.x / 1.6 文本档都支持图文;vl / vision 后缀同理。 - if name.startswith("doubao-seed-") or "vision" in name or name.endswith("-vl") or "-vl-" in name: + # 全能创作指定的 GPT-6 Luna 与 gpt-image-2 共用 YunQi 网关,走 chat/completions + # 时也接收 OpenAI image_url 内容;不能因后台能力栏标作 text 又切回豆包。 + if name == CREATION_CHAT_MODEL_NAME: return True metadata = model_config.metadata if isinstance(getattr(model_config, "metadata", None), dict) else {} capabilities = metadata.get("capabilities") if isinstance(metadata.get("capabilities"), dict) else {} @@ -3756,24 +3963,8 @@ def _creation_model_sees_images(model_config: ModelConfig | None) -> bool: def _prefer_vision_text_model(current: ModelConfig | None, team, refs: list | None) -> ModelConfig | None: - """有参考图时,尽量换成能看图的文本模型(豆包 Seed 等),否则聊天侧完全看不见男女。""" - if current is not None and _creation_model_sees_images(current): - return current - if not _ref_image_urls(team, refs): - return current - qs = ( - ModelConfig.objects.select_related("provider") - .filter( - capability=ModelConfig.Capability.TEXT, - status=ModelConfig.Status.ACTIVE, - provider__status="active", - ) - .order_by("created_at") - ) - for candidate in qs: - if _creation_model_sees_images(candidate): - return candidate - return get_default_model(ModelConfig.Capability.VISION) or current + """参考图也继续交给 Luna;全能创作不得为了视觉能力暗中切换到豆包。""" + return current def _ref_image_urls(team, refs: list | None) -> list[str]: @@ -3853,8 +4044,8 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c " 先短确认,再按最初 brief/已钉素材从澄清或 write_strategy 推进;禁止再打开上一轮模特/商品库追问。", "- 用户选择暂不提供某项素材时,把它当成明确授权:按已有信息和合理默认继续。除非任务客观上无法完成,否则不要再次追问同一素材。", "- 用户说「你来定」「你帮我选」「随便」「都行」时,就是授权你做专业判断;直接选合理方案继续,不要把选择题再抛回去。", - "- 禁止问「要不要继续」「要不要生成」「是否开始创作」这类流程问题。缺信息用 ask_user;信息够了就写策略。" - " 视频每写完策略或方案,平台会出确认卡;方案确认后平台会在后台整理出片指令,再让用户核对生成参数。不要口头问流程。", + "- 禁止问「要不要继续」「要不要生成」「是否开始创作」这类流程问题。缺信息用 ask_user;信息够了内部整理策略并直接写视频架构。" + " 视频架构写完后平台会出确认卡;架构确认后平台会在后台整理出片 Prompt,再让用户核对生成参数。不要口头问流程。", "- 用户打招呼或闲聊(hi / 你好 / 在吗 / 你在干什么 / 嗯 / 好的 / ok):" " **禁止**调用 write_strategy、write_plan、generate_image,不要「整理方案」或直接开写脚本。", "- 会话里**还没有**商品/方向时:自然地告诉用户可以直接丢一句想法,别硬推销,也别用客服式结束语。", @@ -3913,8 +4104,8 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c "全文约 800–1400 字;禁止把 15 秒短片的制作级长文整篇套用。用户确认方案后,write_prompt 只补短规则,不再扩写逐镜。" ) lines.append( - "- 视频 5 步闸门(不可同轮连跳):①缺信息 ask_user 停 → ②write_strategy 停等确认 → " - "③用户确认后 write_plan 停等确认 → ④方案确认后展示完整出片指令并停等确认 → ⑤再显示积分确认卡。" + "- 视频 4 步闸门:①缺信息 ask_user 停 → ②write_strategy 仅内部梳理并同轮 write_plan,视频架构停等确认 → " + "③架构确认后展示完整出片 Prompt 并停等确认 → ④角色补全后显示参数与积分确认卡。" ) lines.append( "- 只有用户明确要做片、出方案、改方案、换卖点/剧情时才调用 write_strategy / write_plan;" @@ -3939,15 +4130,22 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c "- 商家已授权系统推荐卖点。你必须从商品资料、可见素材和正常使用动作中选择一个最易证明的核心卖点;" "不要虚构功效、价格或规格。" ) + product_note = str(memory.get("product_brief_note") or "").strip() + if product_note: + lines.append(f"- 用户在商品信息核对卡补充的真实事实:【{product_note}】。后续架构与 Prompt 必须沿用。") + lines.append( + "- 品牌、商品名、价格、优惠、规格、功效属于事实:缺失时只能询问或标待补充," + "禁止用三个创意选项让用户从虚构事实里选择。创意方向可以给 3 个基于已知商品的建议并允许自定义。" + ) strategy_confirmed = bool(memory.get("strategy_confirmed")) stage_hint = { - "clarify": "当前阶段=澄清:缺关键信息就 ask_user;信息够了只调 write_strategy。", + "clarify": "当前阶段=澄清:缺关键信息就 ask_user;信息够了先调内部 write_strategy,再同轮调 write_plan。", "strategy": ( - "当前阶段=策略已确认,请调用 write_plan 写方案;不要再写策略。" + "当前阶段=内部策略已完成,请调用 write_plan 写视频架构;不要再写策略。" if strategy_confirmed else - "当前阶段=等策略确认:不要再写方案或出片;用户确认后才会进入方案。若用户在改策略,只重调 write_strategy。" + "当前阶段=内部策略整理中:立即调用 write_plan 输出视频架构,不要等待策略确认。" ), - "plan": "当前阶段=等方案确认:不要出片。若用户在改方案,只重调 write_plan。", + "plan": "当前阶段=等视频架构确认:不要出片。若用户在改架构,只重调 write_plan,保留未受影响段落。", "prompt": "当前阶段=兼容历史会话的出片指令确认:不要出片,按用户反馈重写内部出片指令。", "confirm": "当前阶段=等出片确认:不要再写策略/方案;用户会在确认卡上点开始生成。", "done": "当前阶段=已出片:等用户新的修改或新需求再行动。用户说重新来/重来/从头开始时,当作新一轮创作,从澄清或 write_strategy 重开,不要再弹旧模特/商品追问。", @@ -4326,6 +4524,18 @@ def _coerce_timeline(raw) -> list[dict]: desc = str(item.get("desc") or item.get("description") or "").strip() if desc: entry["desc"] = desc + for key, aliases in { + "visual": ("visual", "画面", "plot"), + "action_dialogue": ("action_dialogue", "action", "dialogue", "动作对白"), + "product": ("product", "product_exposure", "商品"), + "purpose": ("purpose", "goal", "作用"), + }.items(): + value = next((str(item.get(alias) or "").strip() for alias in aliases if item.get(alias)), "") + if value: + entry[key] = value + # 历史模型只有 desc 时也能渲染,不丢旧会话。 + if desc and not entry.get("visual"): + entry["visual"] = desc items.append(entry) return items @@ -4436,6 +4646,9 @@ def _coerce_plan_card_args(args: dict) -> dict: if not isinstance(matrix, dict) or not matrix.get("rows"): matrix = _default_plan_matrix(usp, points) if (usp or points) else {} return { + "goal": _pick_str(args, "goal", "目标", "video_goal"), + "duration": _pick_str(args, "duration", "时长", "target_duration"), + "concept": _pick_str(args, "concept", "创意概念", "idea"), "usp": usp, "points": points, "timeline": timeline, @@ -4636,18 +4849,7 @@ def iter_creation_agent_events( yield {"type": "done"} return - # 0. 本地上传商品图优先确认品牌与具体品名 - if product_info_needs_confirmation(conversation, text): - question = append_product_info_gate(conversation) - set_video_gate_stage(conversation, "clarify") - conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER - conversation.save(update_fields=["agent_status", "updated_at"]) - yield {"type": "message", "message": _message_payload(question)} - yield {"type": "done"} - return - - # 剧情反转预设必须先选故事深度。平台直接落选择卡,不能把这一步交给模型猜, - # 否则默认 15 秒会吞掉 30/60 秒该有的人物关系和冲突发展。 + # 剧情带货的第一个必经步骤永远是选时长;之后才核对商品与剧情方向。 if is_plot_twist_conversation(conversation) and not active_plot_twist_story_depth(conversation): explicit_depth = plot_twist_story_depth(text) if explicit_depth is not None and explicit_depth["value"] != "smart": @@ -4660,6 +4862,16 @@ def iter_creation_agent_events( yield {"type": "done"} return + # 0. 本地上传商品图优先确认品牌与具体品名 + if product_info_needs_confirmation(conversation, text): + question = append_product_info_gate(conversation) + set_video_gate_stage(conversation, "clarify") + conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER + conversation.save(update_fields=["agent_status", "updated_at"]) + yield {"type": "message", "message": _message_payload(question)} + yield {"type": "done"} + return + # 商品类创作先锁定结构化商品。普通上传始终只是素材,不能靠图片内容猜成商品。 if creation_needs_product_source(conversation, text): result, _stop = _dispatch_tool( @@ -4674,12 +4886,31 @@ def iter_creation_agent_events( }]}, allow_pick=False, ) + conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER + conversation.save(update_fields=["agent_status", "updated_at"]) for event in result.get("_events", []): yield event yield {"type": "done"} return + if product_brief_needs_review(conversation): + question = append_product_brief_review(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 + # 点击换款的款式清单和顺序是脚本事实,不能让模型自行猜色号或把预设改成普通展示片。 + if click_swap_needs_mode(conversation): + question = append_click_swap_mode_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 if click_swap_needs_sequence(conversation): question = append_click_swap_sequence_gate(conversation) set_video_gate_stage(conversation, "clarify") @@ -4689,28 +4920,6 @@ def iter_creation_agent_events( yield {"type": "done"} return - # 需要真人/角色的视频必须先选定人物来源。这是平台闸门, - # 不交给模型自由发挥,否则它会在脚本里随机造人,到 60s 分段时必然漂移。 - if video_needs_person_source(conversation, text): - question = append_person_source_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 - - # 已经添加多位人物时,人物都属于本次 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: @@ -4789,6 +4998,7 @@ def iter_creation_agent_events( model=model_config.name, messages=messages, endpoint=model_config.endpoint or "chat/completions", + temperature=creation_model_temperature(model_config), extra_body=extra_body, timeout=remaining, ): @@ -5480,16 +5690,6 @@ def _dispatch_tool( "payload": {"asked": True, "field": "sku_sequence"}, "_events": [{"type": "message", "message": _message_payload(gate)}], }, True - if context.is_video and video_needs_person_source( - context.conversation, - json.dumps(args if isinstance(args, dict) else {}, ensure_ascii=False), - ): - gate = append_person_source_gate(context.conversation) - set_video_gate_stage(context.conversation, "clarify") - return { - "payload": {"asked": True, "field": "person_source"}, - "_events": [{"type": "message", "message": _message_payload(gate)}], - }, True memory = context.conversation.memory if isinstance(context.conversation.memory, dict) else {} if context.is_video and is_plot_twist_conversation(context.conversation) and not plot_twist_selected_direction(context.conversation): return { @@ -5532,25 +5732,21 @@ def _dispatch_tool( ) } }, False - message = append_message( - context.conversation, role="assistant", - kind=CreationMessage.Kind.STRATEGY, - payload=strategy_payload, - ) - confirm = append_step_confirm(context.conversation, "strategy") + # 策略只作为 GPT 的内部工作记忆,不再给用户多放一张模糊策略卡。 + # 同轮继续 write_plan,页面只展示一张可编辑的「视频架构」。 set_video_gate_stage(context.conversation, "strategy") memory = dict(context.conversation.memory or {}) - memory.pop("strategy_confirmed", None) + memory["strategy_confirmed"] = True + memory["internal_strategy"] = strategy_payload context.conversation.memory = memory context.conversation.save(update_fields=["memory", "updated_at"]) - # 策略闸门:必须停下等人确认,禁止同轮连写方案 return { - "payload": {"written": True, "awaiting_step": "strategy"}, - "_events": [ - {"type": "message", "message": _message_payload(message)}, - {"type": "message", "message": _message_payload(confirm)}, - ], - }, True + "payload": { + "written": True, + "internal_only": True, + "next": "请立即调用 write_plan,输出唯一可见的视频架构卡。", + }, + }, False if name == "write_plan": if context.is_video and click_swap_needs_mode(context.conversation): @@ -5570,13 +5766,6 @@ def _dispatch_tool( "payload": {"asked": True, "field": "sku_sequence"}, "_events": [{"type": "message", "message": _message_payload(gate)}], }, True - if context.is_video and video_needs_person_source(context.conversation, video_prompt): - gate = append_person_source_gate(context.conversation) - set_video_gate_stage(context.conversation, "clarify") - return { - "payload": {"asked": True, "field": "person_source"}, - "_events": [{"type": "message", "message": _message_payload(gate)}], - }, True video_prompt = apply_video_preset_prompt(context.conversation.preset, video_prompt, click_swap_mode=click_swap_mode(context.conversation)) if is_click_swap_preset(context.conversation.preset): video_prompt = ( @@ -5668,6 +5857,9 @@ def _dispatch_tool( lo = max(20, round(duration * 3.4)) hi = max(lo + 1, round(duration * 4)) plan_payload = { + "goal": card["goal"], + "duration": card["duration"] or str((context.conversation.params or {}).get("duration") or ""), + "concept": card["concept"], "usp": card["usp"], "points": card["points"], "timeline": card["timeline"], @@ -5826,6 +6018,7 @@ def compress_memory(context: AgentContext) -> None: model=context.model_config.name, messages=[{"role": "user", "content": instruction}], endpoint=context.model_config.endpoint or "chat/completions", + temperature=creation_model_temperature(context.model_config), ): if chunk.get("type") == "delta": pieces.append(chunk.get("text", "")) diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index e9a8c66..6aa365b 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -1937,16 +1937,64 @@ _PLATFORM_COVER_BLOCKS = { ), } -# §3 / §3.1 slot 版式骨架(低信息密度版):默认 4 张按 hero→scene→selling→detail 取,8/12 张再补 multi/promo。 +# §3 / §3.1 slot 版式骨架(低信息密度版):默认 4 张按 主图 hero → 卖点图 selling → 细节图 detail → 场景图 scene +# 取(与页面文案「主图、卖点图、细节图与场景图」一致),8/12 张再补 multi/promo 后循环。 _COVER_SLOTS = { - "hero": "正面或四分之三角度的商品 / 上身主视觉,主体占画面 60%-80%,纯净干净背景,经典电商主图构图,无文案", - "scene": "场景主视觉:挂拍 / 衣架 / 生活场景 / 手部整理 / 使用情境,环境自然光,氛围感,无文案或仅 1 个极短标题", - "selling": "轻卖点封面:商品主体居中或偏置,最多 1 个短标题加 1-2 个极短标签,文字区克制,不堆参数", - "detail": "质感特写:放大材质 / 做工 / 关键结构(扣位 / 肩带 / 边缘走线),近景视角,突出质感,无文案或 1 个短标签", + "hero": "主图:正面或四分之三角度的商品 / 上身主视觉,主体占画面 60%-80%,纯净干净背景,经典电商主图构图,无文案", + "selling": "卖点图:商品主体居中或偏置,最多 1 个短标题加 1-2 个极短标签,文字区克制,不堆参数", + "detail": "细节图:放大材质 / 做工 / 关键结构(扣位 / 肩带 / 边缘走线 / 质地),近景视角,突出质感,无文案或 1 个短标签", + "scene": "场景图:挂拍 / 衣架 / 生活场景 / 手部整理 / 使用情境,环境自然光,氛围感,无文案或仅 1 个极短标题", "multi": "多角度组合:商品换一个朝向 / 视角,几何分区干净背景,现代简约风,无文案", - "promo": "促销封面:主体大、利益点单一,底部或角落留 1 条活动短语,高对比配色,不铺满文字、不编造价格", + "promo": "活动封面:主体大、利益点单一(只能取自用户提供的卖点),底部或角落留 1 条短语,高对比配色,不铺满文字、不编造价格", } -_COVER_SLOT_ORDER = ["hero", "scene", "selling", "detail", "multi", "promo"] +_COVER_SLOT_ORDER = ["hero", "selling", "detail", "scene", "multi", "promo"] + +# 通用电商主图规范:平台套图不再要求选平台(platform_id 为空)时使用;旧记录 / 旧调用方带 platform_id +# 仍走上面的平台块,保持向后兼容。比例由输出 size 控制(页面默认 1:1),这里只描述版式要求。 +_ECOM_MAIN_IMAGE_SPEC = ( + "请生成一张通用电商商品主图套图中的一张,适配主流电商平台商品主图规范:默认正方形构图," + "商品主体清晰完整、不被裁切,移动端缩略图下一眼可识别;光线专业、边缘干净、质感真实;" + "整组图保持统一视觉调性(同一色系 / 光线 / 质感),只出主图 / 卖点图 / 细节图 / 场景图,不出详情长图。" +) + +# 商品信息字段(前端「商品信息」输入 → request_payload.product_info),每项截断防止撑爆提示词。 +_COVER_INFO_FIELDS = ("selling_points", "effect", "audience", "specs", "notes") +_COVER_INFO_MAX_LEN = 600 + + +def normalize_cover_product_info(raw) -> dict[str, str]: + """把前端传来的商品信息规范成 {selling_points, effect, audience, specs, notes} 的非空字符串字典。 + 兼容 JSON 字符串 / list 值;未知键丢弃;全空返回 {}(= 旧行为)。""" + if isinstance(raw, str): + try: + raw = json.loads(raw) + except (TypeError, ValueError): + return {} + if not isinstance(raw, dict): + return {} + out: dict[str, str] = {} + for key in _COVER_INFO_FIELDS: + val = raw.get(key) + if isinstance(val, (list, tuple)): + val = "\n".join(str(v).strip() for v in val if str(v or "").strip()) + text = str(val or "").strip() + if text: + out[key] = text[:_COVER_INFO_MAX_LEN] + return out + + +def _split_selling_points(text: str) -> list[str]: + """卖点文本 → 卖点列表:优先按换行 / 分号拆,只有一行时再按顿号 / 逗号拆;最多 6 条。""" + text = (text or "").strip() + if not text: + return [] + # 去掉行首列表符号(「- 」「• 」「1.」「2、」),不误删卖点本身的数字(如「3重玻尿酸」) + parts = [re.sub(r"^\s*(?:[-•·*]+|\d+\s*[.、))])\s*", "", x).strip() for x in re.split(r"[\n\r;;]+", text)] + parts = [x for x in parts if x] + if len(parts) <= 1: + parts = [x.strip() for x in re.split(r"[、,,]+", parts[0] if parts else text) if x.strip()] + return parts[:6] + # §3.1 / §8 头图低信息密度上限:所有平台 / 所有模型都必须遵守,防止漂成详情页。 _COVER_LOW_DENSITY = ( @@ -1999,12 +2047,17 @@ def build_platform_cover_prompt_refs( index: int = 0, count: int = 4, # noqa: ARG001 — 透传保留,后续可据张数扩展 slot 选择 product_ref_count: int = 1, + product_info: dict | None = None, ) -> str: - """平台套图 image_edit 提示词(refs 优化版,对照「平台套图线上提示词优化版.md」): + """平台套图(电商主图套图)image_edit 提示词: 参考图1~N=同一件真实商品(锁外形/品牌/配色/Logo/比例),有模特时参考图N+1=出镜模特。 - 按 `platform_id` 注入平台块、按 `index` 选 slot 版式,统一服从「头图低信息密度」与「背景反差」, - 内衣 + 有模特时叠加强约束。只出头图 / 主图 / 封面候选,不再出详情。""" + `product_info`(用户在页面填写的核心卖点 / 商品作用 / 适用人群 / 规格 / 补充要求)作为本组图的卖点依据注入, + 并按 slot 分配到卖点图 / 细节图 / 场景图;未传时回落为把 `base_prompt` 当作商品信息 / 画面要求(旧记录兼容)。 + `platform_id` 已改为可选:传了(旧记录 / 旧调用方)仍注入平台块,不传则用通用电商主图规范。 + 统一服从「头图低信息密度」与「背景反差」,内衣 + 有模特时叠加强约束。只出主图 / 卖点 / 细节 / 场景,不出详情。""" name = (getattr(product, "title", "") or "商品").strip() + info = normalize_cover_product_info(product_info) if product_info else {} + selling_points = _split_selling_points(info.get("selling_points", "")) n = max(1, int(product_ref_count or 1)) if n <= 1: ref_word = "参考图1" @@ -2018,15 +2071,37 @@ def build_platform_cover_prompt_refs( f"请严格锁定{ref_word}中商品的外形、颜色、材质、结构、Logo、品牌文字与比例," "严禁重新设计 / 改样 / 改品类;这些参考图只锁商品本体,不锁原图里的床品 / 桌面 / 墙面 / 绿植 / 道具与拍摄光线。" ) - # 平台块(canonical key 命中则用 §4 平台块,否则回落平台名 / 通用) - block = _PLATFORM_COVER_BLOCKS.get(platform_id) - pname = _PLATFORM_NAMES.get(platform_id, "") + # 平台块(可选,旧记录兼容):canonical key 命中则用 §4 平台块;否则用通用电商主图规范 + block = _PLATFORM_COVER_BLOCKS.get(platform_id or "") + pname = _PLATFORM_NAMES.get(platform_id or "", "") if block: lines.append(block) elif pname: lines.append(f"请生成一张适合「{pname}」平台的商品头图 / 主图 / 封面候选,统一视觉风格。") else: - lines.append("请生成一张电商平台商品头图 / 主图 / 封面候选,统一视觉风格。") + lines.append(_ECOM_MAIN_IMAGE_SPEC) + # 商品信息:用户填写的卖点 / 作用是本组图唯一的卖点依据 + if info: + facts = [f"商品名称:{name}"] + if selling_points: + facts.append("核心卖点:" + ";".join(selling_points)) + if info.get("effect"): + facts.append("商品作用 / 功效:" + info["effect"]) + if info.get("audience"): + facts.append("适用人群:" + info["audience"]) + if info.get("specs"): + facts.append("规格参数:" + info["specs"]) + lines.append( + "商品信息(用户提供,是本组图唯一的卖点与文案依据):" + ";".join(facts) + "。" + "画面要准确传达这些卖点与商品作用,画面文案只能从中提炼(短标题 ≤8 字)," + "不得编造信息之外的功效 / 成分 / 数据 / 认证 / 价格。" + ) + elif base_prompt and base_prompt.strip(): + # 旧记录 / 旧调用方:没有结构化商品信息,prompt 即用户的商品信息与画面要求 + lines.append( + "用户提供的商品信息与画面要求(卖点 / 作用只能取自这里,不得编造;" + "不得覆盖商品一致性、版式与低信息密度规则):" + base_prompt.strip() + ) # 头图低信息密度上限 lines.append(_COVER_LOW_DENSITY) # 模特身份 + 内衣强约束 @@ -2037,15 +2112,33 @@ def build_platform_cover_prompt_refs( ) if _is_underwear_product(product): lines.append(_UNDERWEAR_ON_MODEL) - # 本张 slot 版式 + # 本张 slot 版式(主图 → 卖点图 → 细节图 → 场景图 → …),并把商品信息分配到对应图位 slot_key = _COVER_SLOT_ORDER[index % len(_COVER_SLOT_ORDER)] - lines.append("本张版式:" + _COVER_SLOTS[slot_key] + "。") + slot_line = "本张版式:" + _COVER_SLOTS[slot_key] + "。" + if info: + cycle = index // len(_COVER_SLOT_ORDER) + if slot_key in {"selling", "promo"} and selling_points: + point = selling_points[(cycle * 2 + (1 if slot_key == "promo" else 0)) % len(selling_points)] + slot_line += f"本张重点表现卖点「{point}」:用画面直观演示该卖点,短标题从该卖点提炼。" + elif slot_key == "selling" and info.get("effect"): + slot_line += "本张重点表现商品作用 / 功效,用画面直观演示使用效果,短标题从商品作用中提炼。" + elif slot_key == "detail": + slot_line += "细节要能佐证商品卖点 / 作用(如对应的材质、质地、成分形态、结构或工艺)。" + elif slot_key == "scene": + who = info.get("audience") + slot_line += ( + f"场景贴合适用人群「{who}」的真实使用情境,体现商品作用。" if who + else "场景选择能体现商品作用的真实使用情境。" + ) + elif slot_key == "hero" and selling_points: + slot_line += "主图不加卖点文案,只把商品本体拍得清楚、高级、可信。" + lines.append(slot_line) # 背景反差 lines.append(_COVER_BG_CONTRAST) - if base_prompt and base_prompt.strip(): + if info.get("notes"): lines.append( - "用户补充(只影响氛围 / 构图 / 场景 / 光线 / 表达偏好,不得覆盖商品一致性、平台与版式规则):" - + base_prompt.strip() + "用户补充要求(只影响氛围 / 构图 / 场景 / 光线 / 表达偏好,不得覆盖商品一致性与版式规则):" + + info["notes"] ) lines.append(_COVER_NEGATIVE) return " ".join(lines) @@ -3533,7 +3626,7 @@ def _reap_stale_standalone_image_tasks(*, team) -> None: continue -def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1, product_id: str | None = None, reference_product: bool = False, model_id: str | None = None, model_entity_id: str | None = None, ratio: str | None = None, image_model: str | None = None, conversation=None, reference_image_ids: list[str] | None = None, platform_id: str | None = None, batch_id: str | None = None, retry_of_task_id: str | None = None, tryon_prompt_v2_override: bool = False, tryon_ab: dict | None = None, feature: str | None = None, dispatch: bool = True) -> list[AITask]: +def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1, product_id: str | None = None, reference_product: bool = False, model_id: str | None = None, model_entity_id: str | None = None, ratio: str | None = None, image_model: str | None = None, conversation=None, reference_image_ids: list[str] | None = None, reference_context: str | None = None, platform_id: str | None = None, product_info: dict | None = None, batch_id: str | None = None, retry_of_task_id: str | None = None, tryon_prompt_v2_override: bool = False, tryon_ab: dict | None = None, feature: str | None = None, dispatch: bool = True) -> list[AITask]: """独立生图(图片创作 / 模特上身图 / 平台套图)改为**异步**:本函数在 Web 请求里只做「建任务 + 预留额度」这种秒级的活,真正 ~30s 的 ARK 出图交给 Celery worker(generate_standalone_image_task)。 @@ -3591,9 +3684,12 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c status__in=(AITask.Status.FAILED, AITask.Status.CANCELLED), ).first() retry_of_task_id = str(retry_of_task.id) if retry_of_task else None - # 平台套图:规范化平台 id(前端 dy/tb… → canonical),用于注入平台版式块(优化版);非 cover 模式忽略。 + # 平台套图:平台 id 已改为可选(页面不再选平台 → 走通用电商主图规范);旧调用方仍可传 canonical id + # 注入平台版式块。非 cover 模式忽略。 platform_key = str(platform_id or "").strip() if mode == "cover" else "" platform_name = _PLATFORM_NAMES.get(platform_key, "") + # 平台套图:用户填写的商品信息(卖点 / 作用 / 人群 / 规格 / 补充),worker 据此构建主图提示词;非 cover 忽略。 + cover_product_info = normalize_cover_product_info(product_info) if mode == "cover" else {} from apps.billing.pricing import quote_flat # Step 2.1:只为“模特上身图 + 商品”记录一次确定性分类快照。这里不读取图片、不调用模型; @@ -3647,8 +3743,12 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c for index in range(count): quote = quote_flat(model_config, team=team) request_payload = {"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode, "index": index, "product_id": str(product_id) if product_id else None, "reference_product": bool(reference_product), "model_id": str(model_id) if model_id else None, "model_entity_id": str(model_entity_id) if model_entity_id else None, "batch_id": batch_id, "ratio": str(ratio) if ratio else None, "reference_image_ids": ref_ids, "platform_id": platform_key or None, "platform_name": platform_name or None} + if reference_context: + request_payload["reference_context"] = str(reference_context) if feature: request_payload["feature"] = str(feature) + if cover_product_info: + request_payload["product_info"] = dict(cover_product_info) if use_model_routing: request_payload["model_routing_v1"] = True if tryon_classification is not None: @@ -3707,6 +3807,9 @@ def run_standalone_image_task(*, task_id: str) -> None: user = task.created_by payload = dict(task.request_payload or {}) prompt = str(payload.get("prompt") or "") + reference_context = str(payload.get("reference_context") or "").strip() + if reference_context: + prompt = f"{prompt}\n\n【已引用素材事实】\n{reference_context}" mode = str(payload.get("mode") or "image") index = int(payload.get("index") or 0) product_id = payload.get("product_id") or None @@ -3801,7 +3904,7 @@ def run_standalone_image_task(*, task_id: str) -> None: } elif mode == "cover" and product_urls: # 平台套图:参考图1~N=商品真实图(多角度,_product_reference_urls 已优先真实上传图/排除 AI 图), - # 有模特则参考图N+1=模特(锁人脸/身形)。platform_id 注入平台版式块(优化版)。 + # 有模特则参考图N+1=模特(锁人脸/身形)。product_info 注入商品卖点 / 作用;platform_id 可选(旧记录兼容)。 edit_images = product_urls + ([model_url] if model_url else []) edit_prompt = build_platform_cover_prompt_refs( product, @@ -3810,6 +3913,7 @@ def run_standalone_image_task(*, task_id: str) -> None: platform_id=str(payload.get("platform_id") or ""), index=index, product_ref_count=len(product_urls), + product_info=payload.get("product_info") or None, ) elif bool(payload.get("reference_product")) and product_url: edit_images = [product_url] diff --git a/core/backend/apps/ai/test_creation_agent.py b/core/backend/apps/ai/test_creation_agent.py index 196c526..0d0f51a 100644 --- a/core/backend/apps/ai/test_creation_agent.py +++ b/core/backend/apps/ai/test_creation_agent.py @@ -51,6 +51,7 @@ from .creation_agent import ( TRUNCATION_GIVE_UP_NOTICE, creation_agent_max_output_tokens, creation_model_extra_body, + creation_model_temperature, creation_agent_timeout_notice, long_video_script_covers_requested_duration, plan_video_segments, @@ -136,6 +137,10 @@ class CreationAgentBaseTests(TestCase): provider=provider, name="fake-text", display_name="Fake Text", capability=ModelConfig.Capability.TEXT, endpoint="chat/completions", ) + self.creation_model = ModelConfig.objects.create( + provider=provider, name="gpt-6-luna", display_name="GPT-6 Luna", + capability=ModelConfig.Capability.TEXT, endpoint="chat/completions", + ) self.conversation = CreationConversation.objects.create( team=self.team, created_by=self.user, mode="image", title="出图", params={"ratio": "1:1", "model": "Seedream5.0"}, @@ -174,7 +179,10 @@ class CreationAgentBaseTests(TestCase): refs = [ref for ref in (self.conversation.pinned_refs or []) if isinstance(ref, dict)] refs.append({"type": "product", "id": str(product.id), "name": title}) self.conversation.pinned_refs = refs - self.conversation.save(update_fields=["pinned_refs", "updated_at"]) + memory = dict(self.conversation.memory or {}) + memory["product_brief_reviewed"] = True + self.conversation.memory = memory + self.conversation.save(update_fields=["pinned_refs", "memory", "updated_at"]) return product @@ -900,6 +908,112 @@ class SendEndpointTests(TestCase): for ref in self.conversation.pinned_refs )) + def test_prompt_confirmation_then_role_then_parameter_confirmation(self): + """全能创作固定顺序:Prompt 确认后才补角色,角色完成后直接进参数卡。""" + self.conversation.mode = CreationConversation.Mode.VIDEO + self.conversation.preset = "达人口播种草" + self.conversation.memory = { + "stage": "prompt", + "pending_video_prompt": "总时长:15秒。达人展示控油粉饼并自然讲解。", + } + self.conversation.save(update_fields=["mode", "preset", "memory", "updated_at"]) + prompt_confirm = append_step_confirm(self.conversation, "prompt") + + response = self.client.post( + f"/api/ai/creations/{self.conversation.id}/send/", + { + "kind": "elicit_answer", + "reply_to": str(prompt_confirm.id), + "answers": {"step_action": "confirm"}, + }, + format="json", + ) + + self.assertEqual(response.status_code, 200, response.content) + role_gate = response.json()["messages"][0] + self.assertEqual(role_gate["payload"]["interaction"], "person_source_gate") + self.conversation.refresh_from_db() + self.assertEqual(self.conversation.memory["stage"], "cast") + + portrait = Asset.objects.create( + team=self.team, + created_by=self.user, + name="固定达人", + asset_type=Asset.Type.IMAGE, + source=Asset.Source.UPLOAD, + category=Asset.Category.PERSON, + ) + response = self.client.post( + f"/api/ai/creations/{self.conversation.id}/send/", + { + "kind": "elicit_answer", + "reply_to": role_gate["id"], + "answers": {"person_source": "local_upload"}, + "refs": [{"type": "character", "id": str(portrait.id), "name": portrait.name}], + }, + format="json", + ) + + self.assertEqual(response.status_code, 200, response.content) + confirm = next(item for item in response.json()["messages"] if item["kind"] == "confirm") + self.assertTrue(any(item["kind"] == "prompt_file" for item in response.json()["messages"])) + self.assertEqual(confirm["kind"], "confirm") + self.assertIn("param_options", confirm["payload"]) + self.conversation.refresh_from_db() + self.assertEqual(self.conversation.memory["stage"], "confirm") + + def test_prompt_confirmation_collects_multi_role_arrangement_before_params(self): + self.conversation.mode = CreationConversation.Mode.VIDEO + self.conversation.preset = "达人口播种草" + people = [ + 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.pinned_refs = [ + {"type": "character", "id": str(person.id), "name": person.name} + for person in people + ] + self.conversation.memory = { + "stage": "prompt", + "pending_video_prompt": "总时长:15秒。两位达人依次展示商品体验。", + } + self.conversation.save(update_fields=["mode", "preset", "pinned_refs", "memory", "updated_at"]) + prompt_confirm = append_step_confirm(self.conversation, "prompt") + + response = self.client.post( + f"/api/ai/creations/{self.conversation.id}/send/", + { + "kind": "elicit_answer", + "reply_to": str(prompt_confirm.id), + "answers": {"step_action": "confirm"}, + }, + format="json", + ) + + self.assertEqual(response.status_code, 200, response.content) + relation_gate = response.json()["messages"][0] + self.assertEqual(relation_gate["payload"]["topic"], "cast_relation") + lead = relation_gate["payload"]["fields"][0]["options"][1]["value"] + response = self.client.post( + f"/api/ai/creations/{self.conversation.id}/send/", + { + "kind": "elicit_answer", + "reply_to": relation_gate["id"], + "answers": {"cast_relation": lead}, + }, + format="json", + ) + self.assertEqual(response.status_code, 200, response.content) + self.assertTrue(any(item["kind"] == "prompt_file" for item in response.json()["messages"])) + self.assertTrue(any(item["kind"] == "confirm" for item in response.json()["messages"])) + def test_official_model_can_be_selected_from_person_source_gate(self): official_owner = User.objects.create_user(username="official-model-owner", password="p") official_team = Team.objects.create(name="Official Models", owner=official_owner) @@ -1520,7 +1634,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): self.conversation.refresh_from_db() self.assertEqual(self.conversation.params["duration"], "15 秒") - def test_plot_twist_preset_requires_story_depth_before_creative_output(self): + def test_plot_twist_requires_story_depth_before_product(self): self.conversation.preset = "剧情反转带货" self.conversation.params = {**self.conversation.params, "duration": "智能时长"} self.conversation.memory = {} @@ -1543,12 +1657,65 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): self.assertEqual(question["payload"].get("interaction"), "plot_twist_story_depth") self.assertEqual( [option["value"] for option in question["payload"]["fields"][0]["options"]], - ["15s", "30s", "60s", "180s", "smart"], + ["15s", "30s", "60s"], ) self.conversation.refresh_from_db() self.assertEqual(self.conversation.agent_status, "awaiting_user") self.assertEqual(fake.calls, []) + def test_plot_twist_requires_story_depth_after_product_is_ready(self): + self._pin_product("洗面奶") + self.conversation.preset = "剧情反转带货" + self.conversation.params = {**self.conversation.params, "duration": "智能时长"} + self.conversation.memory = {} + self.conversation.save(update_fields=["preset", "params", "memory", "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, + )) + + question = next( + event["message"] for event in events + if event.get("type") == "message" and event["message"]["kind"] == "elicit" + ) + self.assertEqual(question["payload"].get("interaction"), "plot_twist_story_depth") + self.assertEqual( + [option["value"] for option in question["payload"]["fields"][0]["options"]], + ["15s", "30s", "60s"], + ) + self.assertEqual(fake.calls, []) + + def test_product_information_is_reviewed_as_status_checklist(self): + self._pin_product("舒缓面霜") + self.conversation.preset = "达人口播种草" + self.conversation.memory = {} + self.conversation.save(update_fields=["preset", "memory", "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"].get("interaction"), "product_brief_review") + self.assertEqual( + {item["status"] for item in card["payload"]["items"]}, + {"ready", "missing", "not_needed"}, + ) + self.assertEqual(fake.calls, []) + def test_plot_twist_depth_syncs_duration_and_is_written_into_final_prompt(self): self.conversation.preset = "剧情反转带货" self.conversation.save(update_fields=["preset", "updated_at"]) @@ -2139,7 +2306,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): self.assertIn("还没写完", errors[0]) self.assertIn("按这个继续", errors[0]) - def test_click_swap_requires_sequence_before_calling_model(self): + def test_click_swap_requires_mode_before_sequence_and_model(self): self._pin_product("三色通勤包") self.conversation.preset = "点击换款" self.conversation.memory = {} @@ -2158,8 +2325,8 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): event["message"] for event in events if event.get("type") == "message" and event["message"]["kind"] == "elicit" ) - self.assertEqual(gate["payload"].get("interaction"), "click_swap_sku_gate") - self.assertEqual(gate["payload"]["fields"][0]["key"], "sku_sequence") + self.assertEqual(gate["payload"].get("interaction"), "click_swap_mode_gate") + self.assertEqual(gate["payload"]["fields"][0]["key"], "click_swap_mode") self.assertEqual(fake.calls, []) def test_click_swap_plan_overrides_conflicting_generic_script(self): @@ -2240,8 +2407,8 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): self.assertEqual((self.conversation.memory or {}).get("stage"), "prompt") self.assertEqual(len(fake.calls), 1) - def test_strategy_and_plan_same_round_stops_after_strategy(self): - """模型若同轮连调 write_strategy+write_plan,只落策略闸门。""" + def test_strategy_and_plan_same_round_only_exposes_video_architecture(self): + """内部策略不落用户卡,同轮继续交付唯一可见的视频架构。""" from apps.ai.creation_agent import _parse_arguments # noqa: F401 self._pin_person() @@ -2278,15 +2445,28 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): events = _events(stream_creation_agent(conversation=self.conversation, user=self.user, text="做条视频", model_config=self.model)) kinds = [e["message"]["kind"] for e in events if e.get("type") == "message"] - self.assertIn("strategy", kinds) - self.assertNotIn("plan", kinds) + self.assertNotIn("strategy", kinds) + self.assertIn("plan", kinds) self.assertEqual(len(fake.calls), 1) - def test_person_video_requires_a_source_before_the_provider_runs(self): + def test_person_video_writes_architecture_before_asking_for_a_source(self): self._pin_product("控油粉饼") self.conversation.preset = "达人口播种草" - self.conversation.save(update_fields=["preset", "updated_at"]) - fake = FakeProvider([_text_chunks("这一轮不应调用模型")]) + self.conversation.memory = { + "selling_point_ready": True, + "selling_point_mode": "auto", + "product_brief_reviewed": True, + } + self.conversation.save(update_fields=["preset", "memory", "updated_at"]) + fake = FakeProvider([ + _tool_chunks("write_strategy", { + "target": "油皮通勤人群", + "trust": "真实上妆体验", + "belief": "控油不厚重", + "direction": "达人自然口播", + }), + _tool_chunks("write_plan", self._plan_args()), + ]) with patch("apps.ai.creation_agent.build_provider", return_value=fake): events = _events(stream_creation_agent( @@ -2296,18 +2476,15 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): 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"]["interaction"], "person_source_gate") - self.assertEqual( - [option["value"] for option in card["payload"]["fields"][0]["options"]], - ["local_upload", "model_library", "platform_generate"], - ) - self.assertEqual(fake.calls, []) + kinds = [event["message"]["kind"] for event in events if event.get("type") == "message"] + self.assertIn("plan", kinds) + self.assertNotIn("person_source_gate", [ + event["message"].get("payload", {}).get("interaction") + for event in events if event.get("type") == "message" + ]) + self.assertEqual(len(fake.calls), 2) - def test_multiple_characters_are_kept_and_relation_is_clarified_before_model_runs(self): + def test_multiple_characters_do_not_interrupt_architecture_writing(self): product = Product.objects.create(team=self.team, created_by=self.user, title="蓝牙耳机") characters = [ Asset.objects.create( @@ -2328,8 +2505,14 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): for character in characters ], ] - self.conversation.save(update_fields=["preset", "pinned_refs", "updated_at"]) - fake = FakeProvider([_text_chunks("这一轮不应调用模型")]) + self.conversation.memory = {"selling_point_ready": True, "selling_point_mode": "auto"} + self.conversation.save(update_fields=["preset", "pinned_refs", "memory", "updated_at"]) + fake = FakeProvider([_tool_chunks("write_strategy", { + "target": "耳机通勤用户", + "trust": "多角色实测", + "belief": "佩戴舒适稳定", + "direction": "剧情化口播", + })]) with patch("apps.ai.creation_agent.build_provider", return_value=fake): events = _events(stream_creation_agent( @@ -2342,43 +2525,14 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): 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) + kinds = [event["message"]["kind"] for event in events if event.get("type") == "message"] + self.assertIn("strategy", kinds) + topics = [ + event["message"].get("payload", {}).get("topic") + for event in events if event.get("type") == "message" + ] + self.assertNotIn("cast_relation", topics) + self.assertEqual(len(fake.calls), 1) def test_commerce_preset_asks_brand_and_name_for_an_uploaded_product_image(self): """上传图现在可以直接当商品图用,但品牌和具体品名必须先问清楚,不许平台自己猜。""" @@ -2829,6 +2983,38 @@ class ConfirmEndpointTests(TestCase): # 出片没提交成功,闸门要放回去让用户改完再确认 self.assertFalse(self.card.payload["submitted"]) + def test_stale_confirm_card_routes_missing_cast_to_guided_role_step(self): + """更新前留下的确认卡也不能再把角色不足直接落成 ERROR。""" + portrait = Asset.objects.create( + team=self.team, + created_by=self.user, + name="角色1", + asset_type=Asset.Type.IMAGE, + category=Asset.Category.PERSON, + ) + self.conversation.preset = "剧情反转带货" + self.conversation.pinned_refs = [ + {"type": "character", "id": str(portrait.id), "name": portrait.name} + ] + self.conversation.save(update_fields=["preset", "pinned_refs", "updated_at"]) + self.card.payload = { + "video_prompt": "三位成年角色共同出镜,角色1、角色2、角色3依次推进剧情。", + "submitted": False, + } + self.card.save(update_fields=["payload", "updated_at"]) + + with patch("apps.ai.free_video.submit_free_video") as submit: + response = self._post() + + self.assertEqual(response.status_code, 200, response.content) + payload = response.json() + self.assertEqual(payload["messages"][0]["payload"]["interaction"], "person_source_gate") + self.assertIn("第 2/3 位", payload["messages"][0]["text"]) + submit.assert_not_called() + self.assertFalse( + self.conversation.messages.filter(kind=CreationMessage.Kind.ERROR).exists() + ) + def test_duration_change_returns_regenerate_instead_of_submitting(self): self.conversation.params = { "model": "Seedance 2.0 Fast", "resolution": "480p", @@ -2851,7 +3037,7 @@ class ConfirmEndpointTests(TestCase): self.card.refresh_from_db() self.assertTrue(self.card.payload["submitted"]) - def test_confirm_applies_model_without_rewriting_script(self): + def test_confirm_model_change_rebuilds_architecture_before_submitting(self): self.conversation.params = { "model": "Seedance 2.0 Fast", "resolution": "480p", "ratio": "1:1", "duration": "8 秒", @@ -2874,11 +3060,11 @@ class ConfirmEndpointTests(TestCase): "resolution": "720p", "ratio": "1:1"}}, format="json", ) - self.assertEqual(response.status_code, 201) - self.assertFalse(response.json().get("regenerate")) - self.assertEqual(submit.call_args.kwargs["params"]["model"], "doubao-seedance-2-5-260628") - self.assertEqual(submit.call_args.kwargs["params"]["resolution"], "720p") - self.assertEqual(submit.call_args.kwargs["params"]["duration"], 8) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json().get("regenerate")) + submit.assert_not_called() + self.conversation.refresh_from_db() + self.assertEqual((self.conversation.memory or {}).get("stage"), "strategy") def test_confirm_ignores_duration_spacing_when_model_changes(self): self.conversation.params = { @@ -2903,9 +3089,9 @@ class ConfirmEndpointTests(TestCase): "resolution": "720p", "ratio": "9:16"}}, format="json", ) - self.assertEqual(response.status_code, 201) - self.assertFalse(response.json().get("regenerate")) - submit.assert_called_once() + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json().get("regenerate")) + submit.assert_not_called() class MemoryCompressionTests(CreationAgentBaseTests): @@ -3330,7 +3516,21 @@ class ChatVisionTests(CreationAgentBaseTests): blob = " ".join(str(item.get("text") or "") for item in content if isinstance(item, dict)) self.assertIn("性别", blob) + def test_gpt_6_luna_receives_reference_images(self): + self.model.name = "gpt-6-luna" + self.model.save(update_fields=["name"]) + context = AgentContext(conversation=self.conversation, user=self.user, model_config=self.model) + messages = build_messages(context) + content = [m for m in messages if m.get("role") == "user"][-1]["content"] + self.assertIsInstance(content, list) + self.assertIn( + "https://cdn/person.jpg", + [(item.get("image_url") or {}).get("url") for item in content if isinstance(item, dict)], + ) + def test_plain_text_model_does_not_receive_images(self): + self.model.name = "plain-text" + self.model.save(update_fields=["name"]) context = AgentContext(conversation=self.conversation, user=self.user, model_config=self.model) messages = build_messages(context) for message in messages: @@ -3338,19 +3538,31 @@ class ChatVisionTests(CreationAgentBaseTests): class CreationChatModelTests(CreationAgentBaseTests): - def test_prefers_seed_21_as_chat_model(self): - self.model.is_default = False - self.model.save(update_fields=["is_default"]) - seed = ModelConfig.objects.create( - provider=self.model.provider, name="doubao-seed-2-1-pro-260628", - display_name="Doubao-Seed-2.1-Pro", - capability=ModelConfig.Capability.TEXT, endpoint="chat/completions", - is_default=True, + def test_prefers_gpt_6_luna_as_chat_model(self): + luna = self.creation_model + doubao = ModelConfig.objects.create( + provider=self.model.provider, name="doubao-seed-2-0-pro", + display_name="Doubao", capability=ModelConfig.Capability.TEXT, + endpoint="chat/completions", is_default=True, ) picked = get_creation_chat_model(None) - # 测试迁移已经可能种过同名 Seed 2.1,重点是编排层选到该模型家族, - # 不是强行命中本测试后建的重复记录。 - self.assertEqual(picked.name, seed.name) + self.assertEqual(picked.pk, luna.pk) + self.assertEqual(get_creation_chat_model(doubao).pk, luna.pk) + self.assertEqual(creation_model_temperature(picked), 1.0) + self.assertEqual(creation_model_temperature(doubao), 0.8) + self.assertIn("max_completion_tokens", creation_model_extra_body(picked, [])) + self.assertNotIn("max_tokens", creation_model_extra_body(picked, [])) + self.assertEqual(creation_model_extra_body(picked, [])["reasoning_effort"], "none") + + def test_does_not_fall_back_to_doubao_when_luna_is_unavailable(self): + self.creation_model.status = ModelConfig.Status.DISABLED + self.creation_model.save(update_fields=["status"]) + doubao = ModelConfig.objects.create( + provider=self.model.provider, name="doubao-seed-2-0-pro-fallback", + display_name="Doubao fallback", capability=ModelConfig.Capability.TEXT, + endpoint="chat/completions", is_default=True, + ) + self.assertIsNone(get_creation_chat_model(doubao)) class PureChitchatTests(SimpleTestCase): diff --git a/core/backend/apps/ai/test_standalone_image_routing.py b/core/backend/apps/ai/test_standalone_image_routing.py index 02938f8..fcc07a2 100644 --- a/core/backend/apps/ai/test_standalone_image_routing.py +++ b/core/backend/apps/ai/test_standalone_image_routing.py @@ -202,7 +202,7 @@ class StandaloneSingleImageRoutingTests(TestCase): product.save(update_fields=["cover_asset"]) return product - def submit_platform(self, primary, *, count=1, ratio="4:5", platform_id="taobao"): + def submit_platform(self, primary, *, count=1, ratio="4:5", platform_id="taobao", product_info=None): product = self.platform_product() return enqueue_standalone_images( team=self.team, @@ -213,6 +213,7 @@ class StandaloneSingleImageRoutingTests(TestCase): product_id=str(product.id), ratio=ratio, platform_id=platform_id, + product_info=product_info, image_model=f"{primary.provider.name}:{primary.name}", dispatch=False, ) @@ -681,6 +682,37 @@ 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_platform_kit_without_platform_uses_product_info_and_generic_spec(self): + primary = self.model( + self.provider("platform-info-primary", 20), + "platform-info-edit", + reference_modes=["none", "single", "multiple"], + max_reference_images=9, + ) + task = self.submit_platform( + primary, + ratio="1:1", + platform_id=None, + product_info={"selling_points": "透气速干\n亲肤不起球", "effect": "夏季户外穿着清爽", "unknown": "丢弃"}, + )[0] + + self.assertIsNone(task.request_payload["platform_id"]) + self.assertEqual( + task.request_payload["product_info"], + {"selling_points": "透气速干\n亲肤不起球", "effect": "夏季户外穿着清爽"}, + ) + run_standalone_image_task(task_id=str(task.id)) + + task.refresh_from_db() + self.assertEqual(task.status, AITask.Status.SUCCEEDED) + call = self.provider_mocks[primary.id].image_edit.call_args + self.assertEqual(call.kwargs["images"], ["http://example.test/platform-product.png"]) + prompt = call.kwargs["prompt"] + self.assertIn("通用电商商品主图", prompt) + self.assertIn("透气速干", prompt) + self.assertIn("夏季户外穿着清爽", prompt) + self.assertNotIn("平台:淘宝", prompt) + def test_platform_kit_selected_seedream_stays_primary(self): primary = self.model( self.provider("volcano", 10), diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index f96e601..697c70e 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -40,6 +40,7 @@ from .creation_agent import ( _RESTART_CONTINUATION, apply_cast_relation_choice, apply_click_swap_mode, + append_multi_character_relation_gate, append_person_source_gate, apply_pain_point_direction, apply_confirm_params, @@ -47,22 +48,27 @@ from .creation_agent import ( apply_session_params, emit_prompt_gate, emit_final_confirm_gate, + insufficient_cast_refs_message, locked_product_references, set_plot_twist_story_depth, is_greeting, is_pain_point_conversation, is_pain_point_direction_payload, is_restart_intent, + multi_character_relation_needs_clarification, restore_gated_step_after_cancel, + get_video_gate_stage, set_video_gate_stage, + sync_prompt_after_cast, submit_confirmed_image, submit_confirmed_video, submit_generated_person_reference, + video_needs_person_source, is_incomplete_product_brand_answer, PRODUCT_BRAND_EMPTY_TEMPLATE, ) from .tasks import run_creation_agent_turn_task -from .mentions import TYPE_LABELS, VALID_TYPES, refs_from_elicit_answers, search_mentions +from .mentions import TYPE_LABELS, VALID_TYPES, refs_from_elicit_answers, resolve_refs, search_mentions from .models import AITask, CreationConversation, CreationMessage, ImageConversation, ModelConfig from .serializers import ( AITaskSerializer, @@ -73,7 +79,7 @@ from .serializers import ( ImageConversationTrashSerializer, ModelConfigSerializer, ) -from .services import enqueue_standalone_images +from .services import enqueue_standalone_images, normalize_cover_product_info logger = logging.getLogger(__name__) @@ -433,6 +439,44 @@ _STEP_REVISE_INSTRUCTIONS = { } +def _advance_after_video_prompt(conversation: CreationConversation) -> list[CreationMessage]: + """Prompt 确认后按固定顺序补角色,再展示可调参数的最终生成确认。 + + 视频架构与 Prompt 已经定稿,角色图只在这里锁定,保证预设的必经选择和 + 架构撰写不会被人物来源卡片抢断。多角色时继续在同一阶段确认出镜关系。 + """ + from .creation_agent import video_needs_person_source + + if video_needs_person_source(conversation): + set_video_gate_stage(conversation, "cast") + return [append_person_source_gate(conversation)] + if multi_character_relation_needs_clarification(conversation): + set_video_gate_stage(conversation, "cast") + return [append_multi_character_relation_gate(conversation)] + synced = sync_prompt_after_cast(conversation) + confirm = emit_final_confirm_gate(conversation) + return [item for item in (synced, confirm) if item is not None] + + +def _video_gate_response( + conversation: CreationConversation, + messages: list[CreationMessage], +) -> JsonResponse: + """返回角色或最终确认卡;只有最终确认卡携带预计积分。""" + conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER + conversation.save(update_fields=["agent_status", "updated_at"]) + body = { + "conversation_id": str(conversation.id), + "agent_status": conversation.agent_status, + "messages": [CreationMessageSerializer(message).data for message in messages], + } + if len(messages) == 1 and messages[0].kind == CreationMessage.Kind.CONFIRM: + credits = int((messages[0].payload or {}).get("estimated_credits") or 0) + if credits: + body["estimated_credits"] = credits + return JsonResponse(body, status=200) + + def _handle_step_confirm_answer( conversation: CreationConversation, *, @@ -497,20 +541,10 @@ def _handle_step_confirm_answer( return JsonResponse(body, status=200), False, "" if step == "prompt": - confirm = emit_final_confirm_gate(conversation) - if confirm is None: + messages = _advance_after_video_prompt(conversation) + if not messages: return None, True, _STEP_CONTINUE_INSTRUCTIONS["prompt"] - conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER - conversation.save(update_fields=["agent_status", "updated_at"]) - credits = int((confirm.payload or {}).get("estimated_credits") or 0) - body = { - "conversation_id": str(conversation.id), - "agent_status": conversation.agent_status, - "messages": [CreationMessageSerializer(confirm).data], - } - if credits: - body["estimated_credits"] = credits - return JsonResponse(body, status=200), False, "" + return _video_gate_response(conversation, messages), False, "" # 未知 step:当普通继续 return None, True, "用户已确认上一步。继续推进创作,不要复述确认。" @@ -539,8 +573,12 @@ class GenerateImageView(APIView): model_entity_id = str(request.data.get("model_entity_id") or "").strip() or None ratio = str(request.data.get("ratio") or "").strip() or None image_model = str(request.data.get("image_model") or "").strip() or None - # 平台套图:前端传规范化平台 id(taobao/douyin/…),用于后端注入平台版式块(优化版) + # 平台套图:平台 id 已改为可选(页面不再选平台,默认通用电商主图规范);旧调用方传 canonical id + # (taobao/douyin/…)仍注入平台版式块,向后兼容。 platform_id = str(request.data.get("platform_id") or "").strip() or None + # 平台套图:用户填写的商品信息(selling_points/effect/audience/specs/notes),只对 cover 生效, + # 由 enqueue_standalone_images 规范化后写入 request_payload.product_info;不传 = 旧行为。 + product_info = request.data.get("product_info") if mode == "cover" else None conversation_id = str(request.data.get("conversation_id") or "").strip() or None # 重跑/补图:前端带原批次 batch_id → enqueue 沿用(UUID 校验),记录归回原批次不裂新卡 batch_id = str(request.data.get("batch_id") or "").strip() or None @@ -551,6 +589,25 @@ class GenerateImageView(APIView): raw_refs = [s for s in raw_refs.split(",") if s.strip()] reference_image_ids = [str(r).strip() for r in raw_refs if str(r).strip()] team = get_current_team(request.user) + # 图片创作输入框的 @ 引用和全能创作使用同一份解析契约: + # 不信任前端传来的图片地址,只拿 type/id 回库取事实与真正的参考 Asset。 + raw_mentions = request.data.get("mention_refs") or [] + if not isinstance(raw_mentions, list): + raw_mentions = [] + mention_refs = [ + {"type": str(item.get("type") or ""), "id": str(item.get("id") or "")} + for item in raw_mentions + if isinstance(item, dict) + and str(item.get("type") or "") in VALID_TYPES + and str(item.get("id") or "").strip() + ] + resolved_mentions = resolve_refs(team, mention_refs) + # @引用的素材与手动上传参考图都进同一条 image_edit 链路;保持解析器的 + # 角色 → 场景 → 商品顺序,再追加用户临时上传图并去重。 + reference_image_ids = list(dict.fromkeys( + [str(item["asset_id"]) for item in resolved_mentions.references if item.get("asset_id")] + + reference_image_ids + )) if product_id: try: normalized_product_id = str(uuid.UUID(product_id)) @@ -604,7 +661,7 @@ class GenerateImageView(APIView): title=(prompt[:24] or "默认创作"), ) try: - tasks = enqueue_standalone_images(team=team, user=request.user, prompt=prompt, mode=mode, count=count, product_id=product_id, reference_product=reference_product, model_id=model_id, model_entity_id=model_entity_id, ratio=ratio, image_model=image_model, conversation=conversation, reference_image_ids=reference_image_ids, platform_id=platform_id, batch_id=batch_id, retry_of_task_id=retry_of_task_id) + tasks = enqueue_standalone_images(team=team, user=request.user, prompt=prompt, mode=mode, count=count, product_id=product_id, reference_product=reference_product, model_id=model_id, model_entity_id=model_entity_id, ratio=ratio, image_model=image_model, conversation=conversation, reference_image_ids=reference_image_ids, reference_context=resolved_mentions.facts_text, platform_id=platform_id, product_info=product_info, batch_id=batch_id, retry_of_task_id=retry_of_task_id) except ValueError as exc: # 无可用模型 / 余额不足等,立即反馈 internal_kind = "user_credit_insufficient" if str(exc).strip().lower() == "insufficient credit" else "" public_error = classify_generation_error( @@ -901,6 +958,8 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet): rp_prompt=KeyTextTransform("prompt", "request_payload"), rp_ratio=KeyTextTransform("ratio", "request_payload"), rp_platform_id=KeyTextTransform("platform_id", "request_payload"), + # 平台套图商品信息(JSON 对象的文本形态),前端恢复批次后重跑沿用;旧记录无此键 → NULL + rp_product_info=KeyTextTransform("product_info", "request_payload"), rp_model_id=KeyTextTransform("model_id", "request_payload"), rp_model_entity_id=KeyTextTransform("model_entity_id", "request_payload"), # 只在重跑任务里落此键(值恒为 True);键不存在 → NULL → 假值,存在 → "true"/"1" → 真值 @@ -928,6 +987,7 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet): "model_id": t.rp_model_id or "", "model_entity_id": t.rp_model_entity_id or "", "platform_id": t.rp_platform_id or "", + "product_info": normalize_cover_product_info(t.rp_product_info) or None, "rerun": bool(t.rp_batch_append), "retry_of_task_id": str((t.request_payload or {}).get("retry_of_task_id") or ""), "created_at": t.created_at, @@ -2030,6 +2090,26 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): record_user_message = False force_creative_turn = True continuation_instruction = _plot_twist_depth_continuation(depth) + elif payload.get("interaction") == "product_brief_review": + memory = dict(conversation.memory or {}) + memory["product_brief_reviewed"] = True + if text: + memory["product_brief_note"] = text + conversation.memory = memory + conversation.save(update_fields=["memory", "updated_at"]) + payload["answers"] = { + "review_action": "supplement" if text else "continue", + "product_brief_note": text, + } + payload["submitted"] = True + payload["answered_via"] = "chat" + pending.payload = payload + pending.save(update_fields=["payload", "updated_at"]) + force_creative_turn = True + continuation_instruction = ( + "用户已核对商品信息。把补充内容当作真实商品事实;未确认的品牌、价格、优惠或功效不得编造。" + "继续完成视频架构。" + ) elif payload.get("interaction") == "plot_twist_directions": options = [item for item in (payload.get("directions") or []) if isinstance(item, dict)] chosen = next( @@ -2379,6 +2459,13 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): memory_now["person_confirm_pending"] = False conversation.memory = memory_now conversation.save(update_fields=["memory", "updated_at"]) + # 角色图属于 Prompt 之后的固定闸门。用户确认平台定妆后,直接进入 + # 多角色安排或最终参数确认,不再重新跑一轮架构 / Prompt Agent。 + if get_video_gate_stage(conversation) == "cast": + append_message(conversation, role="user", text=clean_text) + messages = _advance_after_video_prompt(conversation) + if messages: + return _video_gate_response(conversation, messages) force_creative_turn = True continuation_instruction = ( "用户已确认使用当前生成的角色出镜。" @@ -2455,7 +2542,7 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): incoming = request.data.get("params") if incoming is not None and not isinstance(incoming, dict): return JsonResponse({"detail": "params 必须是对象"}, status=400) - latest_params, duration_changed = apply_confirm_params( + latest_params, needs_rebuild = apply_confirm_params( conversation, incoming if isinstance(incoming, dict) else None ) card.payload = { @@ -2464,14 +2551,34 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): "params": latest_params, } card.save(update_fields=["payload", "updated_at"]) - # 改时长会让旧脚本对不上(5 秒方案不能直接出 10 秒)。确认卡作废,前端再发一轮让模型重写。 - if duration_changed: + # 改时长或视频模型会影响能力与 Prompt。保留事实/素材,只重写受影响步骤。 + if needs_rebuild: return JsonResponse({ "regenerate": True, "params": latest_params, "message": None, }, status=200) is_image = (card.payload or {}).get("kind") == "image" or conversation.mode == CreationConversation.Mode.IMAGE + if not is_image: + prompt = str((card.payload or {}).get("video_prompt") or "").strip() + cast_gap = insufficient_cast_refs_message(conversation, prompt) + if cast_gap: + memory = conversation.memory if isinstance(conversation.memory, dict) else {} + if memory.get("person_source_pending"): + # 历史确认卡可能仍可见;角色正在生成时不落 ERROR,也不重复创建角色闸门。 + card.payload = {**(card.payload or {}), "submitted": False} + card.save(update_fields=["payload", "updated_at"]) + return JsonResponse({ + "detail": "角色图正在生成,完成后会自动继续到参数确认。", + "message": None, + }, status=409) + # 兼容更新前已经生成的确认卡:把缺角色错误转换成明确的角色补全步骤。 + if video_needs_person_source(conversation, prompt): + set_video_gate_stage(conversation, "cast") + return _video_gate_response( + conversation, + [append_person_source_gate(conversation, prompt)], + ) submitter = submit_confirmed_image if is_image else submit_confirmed_video message, error = submitter( conversation=conversation, user=request.user, confirm_message=card @@ -2607,6 +2714,26 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): record_user_message = False force_creative_turn = True continuation_instruction = _plot_twist_depth_continuation(depth) + elif payload.get("interaction") == "product_brief_review": + action = str(answers.get("review_action") or "").strip() + if action not in {"continue", "supplement"}: + return JsonResponse({"detail": "请确认按现有信息继续,或补充真实商品信息"}, status=400) + note = str(answers.get("product_brief_note") or "").strip() + if action == "supplement" and not note: + return JsonResponse({"detail": "请填写要补充的真实商品信息"}, status=400) + memory = dict(conversation.memory or {}) + memory["product_brief_reviewed"] = True + if note: + memory["product_brief_note"] = note + conversation.memory = memory + conversation.save(update_fields=["memory", "updated_at"]) + text = "" + record_user_message = False + force_creative_turn = True + continuation_instruction = ( + "用户已核对商品信息。把卡片中的补充内容当作真实商品事实;" + "任何未确认的品牌、价格、优惠或功效都不得编造。继续完成视频架构。" + ) elif payload.get("interaction") == "plot_twist_directions": choice = str(answers.get("story_direction") or "").strip() if not choice: @@ -2670,6 +2797,10 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): ) if not relation: return JsonResponse({"detail": "这个角色选项已经失效,请重新选择"}, status=400) + if get_video_gate_stage(conversation) == "cast": + messages = _advance_after_video_prompt(conversation) + if messages: + return _video_gate_response(conversation, messages) text = "" record_user_message = False force_creative_turn = True @@ -2717,6 +2848,11 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): conversation.memory = memory conversation.status = CreationConversation.Status.RUNNING conversation.save(update_fields=["memory", "status", "updated_at"]) + # Prompt 已确认后才开始选角色;选择完成后直接开放可调视频参数。 + if get_video_gate_stage(conversation) == "cast": + messages = _advance_after_video_prompt(conversation) + if messages: + return _video_gate_response(conversation, messages) text = "" record_user_message = False force_creative_turn = True @@ -2965,23 +3101,13 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): ): return _agent_busy_response() - model_config_id = None - requested = request.data.get("model_config_id") - if requested: - model_config = ( - ModelConfig.objects.select_related("provider") - .filter(id=requested, capability=ModelConfig.Capability.TEXT, status=ModelConfig.Status.ACTIVE) - .first() - ) - if model_config is not None: - model_config_id = str(model_config.id) - turn_kwargs = { "conversation_id": str(conversation.id), "user_id": str(request.user.id), "text": text, "refs": refs, - "model_config_id": model_config_id, + # 全能创作不接受前端覆盖语言模型;worker 内只解析固定的 GPT-6 Luna。 + "model_config_id": None, "record_user_message": record_user_message, "force_creative_turn": force_creative_turn, "continuation_instruction": continuation_instruction, diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 64d0e4a..b4a2f65 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -6,6 +6,7 @@ import type { AITask, Asset, BillingSummary, + CoverProductInfo, BillingTrend, ExportPoll, Ledger, @@ -823,7 +824,7 @@ export function App() { if (res) setUser(res); } - function generateImages(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; ratio?: string; image_model?: string; platform_id?: string; conversation_id?: string; reference_image_ids?: string[]; batch_id?: string; retry_of_task_id?: string; onSubmitted?: (taskIds: string[], batchId?: string) => void }) { + function generateImages(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; ratio?: string; image_model?: string; platform_id?: string; product_info?: CoverProductInfo; conversation_id?: string; reference_image_ids?: string[]; mention_refs?: import("./types").CreationRef[]; batch_id?: string; retry_of_task_id?: string; onSubmitted?: (taskIds: string[], batchId?: string) => void }) { // 异步生图:提交后立刻拿到任务列表,前端轮询直到出图。慢的 ARK 出图在 Celery worker 里跑—— // Web 层不被 ~30s 请求占住 → 健康探针不饿死 → 根治"几张图整站 502";且提交成功后浏览器关掉/断网, // worker 仍会把图生成并落库(扣费/退费在 worker 内闭环),重开素材库即可见。 diff --git a/core/frontend/src/ai-tools-page.css b/core/frontend/src/ai-tools-page.css index d0a20b3..26037e5 100644 --- a/core/frontend/src/ai-tools-page.css +++ b/core/frontend/src/ai-tools-page.css @@ -2676,7 +2676,49 @@ } .yz-image .generator-settings .control-heading, .yz-image .generator-settings .field-group:last-child { grid-column: 1 / -1; } -.yz-image .platform-generator-settings .field-group { grid-column: 1 / -1; } +/* 平台套图(cover):无「选择平台」—— 选择商品独占一行(商品卡横排),生成设置内为商品信息表单 */ +.yz-image .asset-pair-grid.single { grid-template-columns: minmax(0, 1fr); } +.yz-image .asset-pair-grid.single .product-choice-list { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.yz-image .asset-pair-grid.single .product-library-choice { min-height: 72px; } +.yz-image .cover-generator-settings .field-group { grid-column: 1 / -1; } +.yz-image .cover-generator-settings .field-group.half { grid-column: auto; } +.yz-image .cover-generator-settings .control-heading { margin-bottom: 4px; } +.yz-image .cover-info-reset { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 5px; + height: 22px; + padding: 0; + border: 0; + color: var(--muted); + background: transparent; + font: inherit; + font-size: 11px; + cursor: pointer; +} +.yz-image .cover-info-reset:hover { color: var(--klein); } +.yz-image .image-prompt.cover-info-textarea { min-height: 88px; height: 88px; font-size: 13px; line-height: 1.6; } +.yz-image .cover-info-input { + width: 100%; + height: 40px; + padding: 0 13px; + border: 1px solid rgba(34, 42, 54, 0.11); + border-radius: 10px; + outline: 0; + color: var(--text); + background: rgba(34, 42, 54, 0.03); + font: inherit; + font-size: 13px; +} +.yz-image .cover-info-input:focus { + border-color: rgba(0, 47, 167, 0.48); + background: var(--surface); + box-shadow: 0 0 0 3px rgba(0, 47, 167, 0.07); +} +.yz-image .cover-info-input::placeholder, +.yz-image .cover-info-textarea::placeholder { color: rgba(74, 80, 89, 0.55); } +.yz-image .model-line .cover-info-hint { color: var(--heat, #c2410c); } .yz-image .control-heading { display: flex; align-items: center; @@ -2975,20 +3017,20 @@ display: flex; align-items: center; justify-content: space-between; - gap: 18px; - padding: 0 20px; + gap: 12px; + padding: 0 14px; position: relative; z-index: 5; border-bottom: 1px solid rgba(34, 42, 54, 0.08); } -.yz-image .result-topbar strong { font-size: 14px; } -.yz-image .result-tags { display: flex; align-items: center; gap: 8px; } +.yz-image .result-topbar strong { flex: 0 0 auto; font-size: 14px; white-space: nowrap; } +.yz-image .result-tags { display: flex; align-items: center; min-width: 0; } .yz-image .result-tags span { - padding: 6px 9px; - border-radius: 999px; + overflow: hidden; color: var(--muted); - background: rgba(34, 42, 54, 0.055); - font-size: 10px; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; } .yz-image .result-canvas { position: relative; @@ -3231,6 +3273,89 @@ font-size: 14px; line-height: 1.6; } +.yz-image .studio-prompt-wrap { + position: relative; + min-width: 0; + min-height: 96px; +} +.yz-image .studio-prompt.rich-mention-editor { + min-height: 96px; + max-height: 160px; + overflow-y: auto; +} +.yz-image .studio-mention-menu { + position: absolute; + z-index: 40; + bottom: calc(100% + 8px); + left: 0; + width: min(320px, 100%); + max-height: 320px; + overflow-y: auto; + padding: 8px; + border: 1px solid var(--border-faint); + border-radius: var(--r-md); + background: var(--surface-raised); + box-shadow: var(--shadow-floating); +} +.yz-image .studio-mention-head, +.yz-image .studio-mention-group > span { + display: block; + padding: 4px 8px; + color: var(--black-alpha-48); + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: .04em; +} +.yz-image .studio-mention-list { display: grid; gap: 6px; } +.yz-image .studio-mention-group + .studio-mention-group { + padding-top: 6px; + border-top: 1px solid var(--border-faint); +} +.yz-image .studio-mention-group button { + width: 100%; + min-width: 0; + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 8px; + padding: 6px 8px; + border: 0; + border-radius: var(--r-md); + color: var(--accent-black); + background: transparent; + font: inherit; + text-align: left; + cursor: pointer; +} +.yz-image .studio-mention-group button:hover { background: var(--black-alpha-4); } +.yz-image .studio-mention-group img, +.yz-image .studio-mention-group i { + width: 28px; + height: 28px; + display: grid; + place-items: center; + overflow: hidden; + border-radius: var(--r-md); + color: var(--heat); + background: var(--heat-12); + font-family: var(--font-mono); + font-size: 11px; + font-style: normal; +} +.yz-image .studio-mention-group img { object-fit: cover; } +.yz-image .studio-mention-group b { + overflow: hidden; + font-size: 13px; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} +.yz-image .studio-mention-state { + display: block; + padding: 12px 8px; + color: var(--black-alpha-48); + font-size: 12px; +} .yz-image .image-composer-footer { display: flex; align-items: center; @@ -3349,8 +3474,16 @@ .yz-image .result-topbar-tools { display: flex; align-items: center; + min-width: 0; + margin-left: auto; gap: 8px; } +.yz-image .tb-search-wrap { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; +} .yz-image .result-topbar .search-btn { width: 32px; height: 32px; diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 1cb0ce5..0c43159 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -1,4 +1,5 @@ import type { + CoverProductInfo, AdminIntegrity, AdminLedger, AdminModel, @@ -1007,7 +1008,7 @@ export const api = { // 异步生图:提交后秒级返回任务列表(慢出图在 worker 跑),再用 generateImageStatus 轮询取结果。 // 带 conversation_id 则归属该对话;不带则后端自动开一条新对话并回传其 id。 // 带 batch_id(重跑/补图)则任务归回原批次;响应回传本批 batch_id 供前端存进批次卡。 - submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; model_entity_id?: string; ratio?: string; image_model?: string; platform_id?: string; conversation_id?: string; reference_image_ids?: string[]; batch_id?: string; retry_of_task_id?: string }) { + submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; model_entity_id?: string; ratio?: string; image_model?: string; platform_id?: string; product_info?: CoverProductInfo; conversation_id?: string; reference_image_ids?: string[]; mention_refs?: CreationRef[]; batch_id?: string; retry_of_task_id?: string }) { return request<{ conversation_id: string; batch_id?: string; tasks: { id: string; status: string }[] }>("/api/ai/generate-image/", { method: "POST", body: JSON.stringify(payload) }); }, // 图片创作对话 CRUD —— 左栏会话列表 / 新对话 / 切换 / 重命名 / 删除 diff --git a/core/frontend/src/components/app-shell.tsx b/core/frontend/src/components/app-shell.tsx index 0bc7ca0..282849d 100644 --- a/core/frontend/src/components/app-shell.tsx +++ b/core/frontend/src/components/app-shell.tsx @@ -29,7 +29,7 @@ const SHELL_COMMANDS: Command[] = [ { id: "new-project", group: "常用动作", label: "新建视频项目", sub: "选择商品并进入脚本配置", page: "projectWizard", icon: "clapperboard" }, { id: "quick-create-action", group: "常用动作", label: "一键成片", sub: "输入商品名称并上传图片,自动生成视频", page: "quickCreate", icon: "wand" }, { id: "model-photo", group: "常用动作", label: "生成模特上身图", sub: "快速生成 3:4 商品展示素材", page: "modelPhoto", icon: "users" }, - { id: "platform-cover", group: "常用动作", label: "生成平台套图", sub: "适配电商平台封面与详情图", page: "platformCover", icon: "images" }, + { id: "platform-cover", group: "常用动作", label: "生成平台套图", sub: "按商品信息生成主图、卖点图、细节图与场景图", page: "platformCover", icon: "images" }, { id: "image-optimize", group: "常用动作", label: "自由创作", sub: "对话式生成、编辑", page: "imageOptimize", icon: "images" } ]; diff --git a/core/frontend/src/dark-mode-pages.css b/core/frontend/src/dark-mode-pages.css index c9ba55e..22d6c5e 100644 --- a/core/frontend/src/dark-mode-pages.css +++ b/core/frontend/src/dark-mode-pages.css @@ -909,6 +909,52 @@ html[data-theme="dark"] .omni-upload-menu button > svg { color: #8eb0ff; } +/* 全能创作会话的 @ 引用面板是独立组件,不能沿用旧 .omni-mention-menu 的浅色 hover。 */ +html[data-theme="dark"] .omni-session-mention-menu { + border-color: var(--border-faint); + background: var(--surface-raised); + box-shadow: var(--shadow-floating); +} +html[data-theme="dark"] .omni-session-mention-menu .omni-at-cats { + border-color: var(--border-faint); +} +html[data-theme="dark"] .omni-session-mention-menu .omni-at-cats button { + color: var(--black-alpha-56); +} +html[data-theme="dark"] .omni-session-mention-menu .omni-at-cats button svg { + color: var(--black-alpha-48); +} +html[data-theme="dark"] .omni-session-mention-menu .omni-at-cats button:hover, +html[data-theme="dark"] .omni-session-mention-menu .omni-at-cats button.is-on { + color: var(--heat); + background: var(--heat-12); +} +html[data-theme="dark"] .omni-session-mention-menu .omni-at-cats button.is-on svg { + color: var(--heat); +} +html[data-theme="dark"] .omni-session-mention-menu .omni-at-list > strong, +html[data-theme="dark"] .omni-session-mention-menu .omni-at-loading { + color: var(--black-alpha-48); +} +html[data-theme="dark"] .omni-session-mention-menu .omni-at-loading .omni-send-spinner { + border-color: var(--heat-20); + border-top-color: var(--heat); +} +html[data-theme="dark"] .omni-session-mention-menu .omni-at-list button { + color: var(--accent-black); +} +html[data-theme="dark"] .omni-session-mention-menu .omni-at-list button:hover, +html[data-theme="dark"] .omni-session-mention-menu .omni-at-list button:focus-visible { + color: var(--heat); + background: var(--heat-12); +} +html[data-theme="dark"] .omni-session-mention-menu .omni-at-list button small { + color: var(--black-alpha-48); +} +html[data-theme="dark"] .omni-session-mention-menu .omni-at-list button img { + background: var(--background-lighter); +} + html[data-theme="dark"] .omni-slot-tag { color: #0f1115; background: #e8eaed; @@ -1072,6 +1118,65 @@ html[data-theme="dark"] .omni-session-page .omni-process-card { color: var(--accent-black); box-shadow: none; } + +/* 确认生成卡的参数条仍复用浅色页的控件基样式;在深色会话里单独收回到工作面的层级。 + 禁用态保留可读性,但不再出现白色输入框。 */ +html[data-theme="dark"] .omni-session-page .omni-confirm-params .rs-select-btn, +html[data-theme="dark"] .omni-session-page .omni-confirm-params .omni-duration-trigger { + border-color: var(--border-muted); + color: var(--accent-black); + background: var(--background-lighter); +} +html[data-theme="dark"] .omni-session-page .omni-confirm-params .rs-select-btn:hover:not(:disabled), +html[data-theme="dark"] .omni-session-page .omni-confirm-params .rs-select.open .rs-select-btn, +html[data-theme="dark"] .omni-session-page .omni-confirm-params .omni-duration-trigger:hover:not(:disabled), +html[data-theme="dark"] .omni-session-page .omni-confirm-params .omni-duration-trigger[aria-expanded="true"] { + border-color: var(--heat-40); + color: var(--accent-black); + background: var(--surface-raised); + box-shadow: 0 0 0 3px var(--heat-8); +} +html[data-theme="dark"] .omni-session-page .omni-confirm-params .rs-select-btn:disabled, +html[data-theme="dark"] .omni-session-page .omni-confirm-params .omni-duration-trigger:disabled { + border-color: var(--border-faint); + color: var(--black-alpha-48); + background: var(--black-alpha-4); + opacity: 1; +} +html[data-theme="dark"] .omni-session-page .omni-confirm-params .rs-select-btn svg, +html[data-theme="dark"] .omni-session-page .omni-confirm-params .omni-duration-trigger svg { + color: var(--black-alpha-56); +} +html[data-theme="dark"] .omni-session-page .omni-confirm-params .rs-select.open .rs-select-btn svg, +html[data-theme="dark"] .omni-session-page .omni-confirm-params .omni-duration-trigger[aria-expanded="true"] svg { + color: var(--heat); +} + +/* 时长面板通过 portal 挂到 body,不能依赖会话页父级选择器。 */ +html[data-theme="dark"] .omni-duration-menu { + border-color: var(--border-faint); + background: var(--surface-raised); + box-shadow: var(--shadow-floating); +} +html[data-theme="dark"] .omni-duration-title { + color: var(--black-alpha-56); +} +html[data-theme="dark"] .omni-duration-modes button, +html[data-theme="dark"] .omni-duration-values button { + color: var(--black-alpha-72); + background: var(--background-lighter); +} +html[data-theme="dark"] .omni-duration-modes button, +html[data-theme="dark"] .omni-duration-values { + border-color: var(--border-faint); +} +html[data-theme="dark"] .omni-duration-modes button.active, +html[data-theme="dark"] .omni-duration-values button:hover, +html[data-theme="dark"] .omni-duration-values button.active { + border-color: var(--heat-40); + color: var(--heat); + background: var(--heat-12); +} html[data-theme="dark"] .omni-session-page .omni-chat-row.agent .omni-chat-bubble { border-color: var(--border-faint); border-bottom-left-radius: var(--r-sm); @@ -1377,6 +1482,32 @@ html[data-theme="dark"] .yz-image .image-prompt:focus { background: rgba(255, 255, 255, 0.06); box-shadow: 0 0 0 3px rgba(61, 107, 255, 0.14); } +/* 平台套图 · 商品信息表单(核心卖点 / 作用 / 人群 / 规格 / 补充) */ +html[data-theme="dark"] .yz-image .cover-info-input { + border-color: rgba(255, 255, 255, 0.12); + color: #e8eaed; + background: rgba(255, 255, 255, 0.04); +} +html[data-theme="dark"] .yz-image .cover-info-input:focus { + border-color: rgba(61, 107, 255, 0.5); + background: rgba(255, 255, 255, 0.06); + box-shadow: 0 0 0 3px rgba(61, 107, 255, 0.14); +} +html[data-theme="dark"] .yz-image .cover-info-input::placeholder, +html[data-theme="dark"] .yz-image .cover-info-textarea::placeholder { + color: rgba(232, 234, 237, 0.42); +} +html[data-theme="dark"] .yz-image .cover-info-reset { + border: 0; + color: var(--black-alpha-56); + background: transparent; +} +html[data-theme="dark"] .yz-image .cover-info-reset:hover { + color: var(--heat); +} +html[data-theme="dark"] .yz-image .model-line .cover-info-hint { + color: #fbbf77; +} html[data-theme="dark"] .yz-image .asset-pair-grid > .control-section + .control-section, html[data-theme="dark"] .yz-image .generator-settings, html[data-theme="dark"] .yz-image .result-topbar, @@ -1400,8 +1531,58 @@ html[data-theme="dark"] .yz-image .platform-choice.active .choice-check::after { background: #fff; } html[data-theme="dark"] .yz-image .result-tags span { - color: rgba(232, 234, 237, 0.62); - background: rgba(255, 255, 255, 0.06); + color: var(--black-alpha-56); + background: transparent; +} +/* 套图预览工具:不能继承浅色态的白色搜索方块;模型选择和展开菜单保持同一暗面层级。 */ +html[data-theme="dark"] .yz-image .result-topbar .search-btn, +html[data-theme="dark"] .yz-image .model-line .ic-param-btn, +html[data-theme="dark"] .yz-image .image-composer-options .ic-param-btn { + border: 1px solid var(--border-faint); + color: var(--black-alpha-64); + background: var(--background-lighter); +} +html[data-theme="dark"] .yz-image .result-topbar .search-btn:hover, +html[data-theme="dark"] .yz-image .result-topbar .search-btn.active, +html[data-theme="dark"] .yz-image .model-line .ic-param-btn:hover, +html[data-theme="dark"] .yz-image .image-composer-options .ic-param-btn:hover { + border-color: var(--heat-20); + color: var(--heat); + background: var(--heat-12); +} +html[data-theme="dark"] .yz-image .model-line .ic-param-btn .lbl-mono, +html[data-theme="dark"] .yz-image .model-line .ic-param-btn svg, +html[data-theme="dark"] .yz-image .image-composer-options .ic-param-btn .lbl-mono, +html[data-theme="dark"] .yz-image .image-composer-options .ic-param-btn svg { + color: var(--black-alpha-48); +} +html[data-theme="dark"] .yz-image .model-line .ic-param-btn span:not(.lbl-mono), +html[data-theme="dark"] .yz-image .image-composer-options .ic-param-btn span:not(.lbl-mono) { + color: var(--accent-black); +} +html[data-theme="dark"] .yz-image .model-line .ic-param.open .ic-param-btn, +html[data-theme="dark"] .yz-image .image-composer-options .ic-param.open .ic-param-btn { + border-color: var(--heat-20); + color: var(--heat); + background: var(--heat-12); +} +html[data-theme="dark"] .yz-image .model-line .ic-param.open .ic-param-btn .lbl-mono, +html[data-theme="dark"] .yz-image .model-line .ic-param.open .ic-param-btn span:not(.lbl-mono), +html[data-theme="dark"] .yz-image .model-line .ic-param.open .ic-param-btn svg, +html[data-theme="dark"] .yz-image .image-composer-options .ic-param.open .ic-param-btn .lbl-mono, +html[data-theme="dark"] .yz-image .image-composer-options .ic-param.open .ic-param-btn span:not(.lbl-mono), +html[data-theme="dark"] .yz-image .image-composer-options .ic-param.open .ic-param-btn svg { + color: var(--heat); +} +html[data-theme="dark"] .yz-image .model-line .ic-param-menu { + border-color: var(--border-faint); + background: var(--surface-raised); + box-shadow: var(--shadow-floating); +} +html[data-theme="dark"] .yz-image .image-composer-options .ic-param-menu { + border-color: var(--border-faint); + background: var(--surface-raised); + box-shadow: var(--shadow-floating); } html[data-theme="dark"] .yz-image .result-empty-icon { border-color: rgba(255, 255, 255, 0.10); @@ -2355,6 +2536,50 @@ html[data-theme="dark"] .fc-page .fc-composer { background: #1a1e26; box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45); } +/* 底部参数条不能沿用浅色态的深灰字:标签和值需保持明确层级,展开菜单同一暗面。 */ +html[data-theme="dark"] .fc-page .fc-chip { + border: 1px solid var(--border-faint); + color: var(--black-alpha-56); + background: var(--background-lighter); +} +html[data-theme="dark"] .fc-page .fc-chip strong { + color: var(--accent-black); +} +html[data-theme="dark"] .fc-page .fc-chip svg { + color: var(--black-alpha-48); +} +html[data-theme="dark"] .fc-page .fc-chip:hover, +html[data-theme="dark"] .fc-page .fc-dd.open .fc-chip { + border-color: var(--heat-20); + color: var(--heat); + background: var(--heat-12); +} +html[data-theme="dark"] .fc-page .fc-dd.open .fc-chip strong, +html[data-theme="dark"] .fc-page .fc-dd.open .fc-chip svg { + color: var(--heat); +} +html[data-theme="dark"] .fc-page .fc-dd-menu { + border-color: var(--border-faint); + background: var(--surface-raised); + box-shadow: var(--shadow-floating); +} +html[data-theme="dark"] .fc-page .fc-dd-item:hover { + background: var(--heat-12); +} +html[data-theme="dark"] .fc-page .fc-dd-item.selected { + background: var(--heat-16); +} +html[data-theme="dark"] .fc-page .fc-dd-item .ti { + color: var(--accent-black); +} +html[data-theme="dark"] .fc-page .fc-dd-item .de, +html[data-theme="dark"] .fc-page .fc-seed-hint { + color: var(--black-alpha-48); +} +html[data-theme="dark"] .fc-page .fc-dd-item.disabled .ti, +html[data-theme="dark"] .fc-page .fc-dd-item.disabled .de { + color: var(--black-alpha-32); +} html[data-theme="dark"] .fc-page .fc-tag { color: rgba(232, 234, 237, 0.82); background: rgba(255, 255, 255, 0.08); diff --git a/core/frontend/src/omni-session-page.css b/core/frontend/src/omni-session-page.css index 25a0d7c..351bef2 100644 --- a/core/frontend/src/omni-session-page.css +++ b/core/frontend/src/omni-session-page.css @@ -418,10 +418,10 @@ .omni-elicit-card { width: min(760px, calc(100% - 44px)); margin: 0 0 24px 44px; - border: 1px solid rgba(34, 42, 54, .09); - border-radius: 16px; + border: 0; + border-radius: var(--r-md); background: var(--surface); - box-shadow: 0 12px 30px rgba(20, 27, 38, .065); + box-shadow: inset 0 0 0 1px var(--border-faint); animation: omniMessageIn 220ms ease both; } @@ -527,7 +527,7 @@ .omni-video-plan-card { overflow: hidden; - border-top: 3px solid var(--black); + border-top: 3px solid var(--heat); } .omni-video-plan-head span { @@ -542,7 +542,7 @@ .omni-plan-section { padding: 15px 17px; - border-bottom: 1px solid rgba(34, 42, 54, .07); + box-shadow: inset 0 -1px 0 var(--border-faint); } .omni-plan-section:last-child { @@ -564,9 +564,9 @@ .omni-plan-points span { padding: 10px; - border-radius: 9px; - color: #525965; - background: #f6f8fa; + border-radius: var(--r-md); + color: var(--black-alpha-72); + background: var(--background-lighter); font-size: 13px; line-height: 1.65; } @@ -596,7 +596,7 @@ .omni-plan-timeline { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; } @@ -604,10 +604,11 @@ position: relative; min-height: 78px; padding: 11px 10px; - border: 1px solid rgba(34, 42, 54, .08); - border-radius: 10px; - color: #5b626d; + border: 0; + border-radius: var(--r-md); + color: var(--black-alpha-72); background: var(--surface); + box-shadow: inset 0 0 0 1px var(--border-faint); font-size: 13px; line-height: 1.65; } @@ -620,6 +621,53 @@ line-height: 1.45; } +.omni-plan-overview-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.omni-plan-overview-grid span, +.omni-plan-timeline-item em { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + font-style: normal; +} + +.omni-plan-overview-grid span { + padding: 10px; + border-radius: var(--r-md); + background: var(--background-lighter); +} + +.omni-plan-overview-grid b, +.omni-plan-timeline-item i { + color: var(--black-alpha-56); + font-size: 11px; + font-style: normal; + font-weight: 500; +} + +.omni-plan-overview-grid em { + color: var(--accent-black); + font-size: 13px; + font-style: normal; + line-height: 1.55; +} + +.omni-plan-timeline-item { + display: flex; + flex-direction: column; + gap: 9px; +} + +.omni-plan-timeline-item em { + padding-top: 8px; + box-shadow: inset 0 1px 0 var(--border-faint); +} + .omni-plan-summary { display: flex; align-items: center; @@ -1164,12 +1212,21 @@ min-width: 0; display: flex; flex-direction: column; - gap: 2px; + overflow: hidden; +} + +.omni-at-list button strong { + overflow: hidden; + color: inherit; + font-size: inherit; + font-weight: 500; + line-height: 28px; + text-overflow: ellipsis; + white-space: nowrap; } .omni-at-list button small { - color: #8b919a; - font-size: 12px; + display: none; } .omni-session-send { @@ -1446,6 +1503,8 @@ .omni-strategy-grid, .omni-plan-points, .omni-plan-timeline, + .omni-plan-overview-grid, + .omni-product-brief-list, .omni-direction-list { grid-template-columns: 1fr; } @@ -1492,6 +1551,61 @@ padding: 14px 18px 16px; } +.omni-product-brief-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.omni-product-brief-item { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + padding: 10px; + border-radius: var(--r-md); + background: var(--background-lighter); + box-shadow: inset 0 0 0 1px var(--border-faint); +} + +.omni-product-brief-item > span { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.omni-product-brief-item b { + color: var(--accent-black); + font-size: 12px; + font-weight: 600; +} + +.omni-product-brief-item em { + overflow: hidden; + color: var(--black-alpha-56); + font-size: 11px; + font-style: normal; + text-overflow: ellipsis; + white-space: nowrap; +} + +.omni-product-brief-item > i { + flex: 0 0 auto; + padding: 3px 6px; + border-radius: 999px; + color: var(--black-alpha-56); + background: var(--surface); + font-size: 10px; + font-style: normal; + font-weight: 500; +} + +.omni-product-brief-item.is-ready > i { + color: var(--heat); + background: var(--heat-12); +} + /* 卖点确认在策略和脚本之前出现:输入真实卖点,或把筛选权交给系统。 */ .omni-selling-point-card { box-sizing: border-box; diff --git a/core/frontend/src/routes/ai-tools.tsx b/core/frontend/src/routes/ai-tools.tsx index 2c527bc..570c086 100644 --- a/core/frontend/src/routes/ai-tools.tsx +++ b/core/frontend/src/routes/ai-tools.tsx @@ -20,14 +20,13 @@ import { Plus, RefreshCw, Search, - SlidersHorizontal, Sparkles, Trash2, Users, WandSparkles, X } from "lucide-react"; -import type { AITask, Asset, ImageConversation, ImageConversationTask, ModelConfig, ModelEntity, Product, WorkbenchTask } from "../types"; +import type { AITask, Asset, CoverProductInfo, CreationRef, ImageConversation, ImageConversationTask, ModelConfig, ModelEntity, Product, WorkbenchTask } from "../types"; import { pointsPerImageFromCatalog } from "../components/free-create/constants"; import { api } from "../api"; import { useFileDrop } from "../components/use-file-drop"; @@ -35,6 +34,7 @@ import { imageModelPickerOptions } from "../model-display"; import { ModelLibrary } from "../components/model-library"; import { SkeletonRows, SystemLoading } from "../components/loading"; import { ConfirmModal, MediaLightbox } from "../components/overlays"; +import { RichMentionEditor, type RichMentionEditorHandle } from "../components/rich-mention-editor"; import { Pager } from "../components/pager"; import { useViewMode } from "../components/use-view-mode"; import type { Page } from "./route-config"; @@ -454,11 +454,11 @@ const MODE_META: Record< }, cover: { title: "平台套图", - desc: "根据平台规范生成主图、卖点图、细节图与场景图", - // 优化版:商品上架主图默认 1:1(原 4:5 会 fallback 成方图致比例错乱);竖图按平台/类目再选 + desc: "根据商品信息和商品图生成主图、卖点图、细节图与场景图", + // 通用电商主图规范:默认 1:1(不再按平台切换比例) ratio: "1:1", - // YYX#4:只出平台头图,去掉「详情排版」 - promptTemplate: (title) => `${title},平台商品头图,统一视觉,商品主体清晰` + // 仅作兜底文案;实际提交的 prompt 由「商品信息」拼出(composeCoverPrompt) + promptTemplate: (title) => `${title},电商主图套图,统一视觉,商品主体清晰` } }; @@ -479,38 +479,73 @@ const IMAGE_SUGGESTIONS = [ { label: "都市夜景海报", prompt: "电影感都市夜景,街道湿润反射霓虹,4K 高清产品海报" } ]; -/* 平台套图 · 平台卡(YYX#row10:用真平台 logo,素材在 public/assets/svg; - logo 留作图加载失败时的兜底字符,img 为真 logo) */ -const PLATFORM_OPTIONS = [ - { id: "dy", name: "抖音电商", logo: "抖", img: "/assets/svg/icon-platform-douyin.svg" }, - { id: "tb", name: "淘宝", logo: "淘", img: "/assets/svg/icon-platform-taobao.svg" }, - { id: "tm", name: "天猫", logo: "猫", img: "/assets/svg/icon-platform-tmall.svg" }, - { id: "jd", name: "京东", logo: "京", img: "/assets/svg/icon-platform-jd.svg" }, - { id: "pdd", name: "拼多多", logo: "拼", img: "/assets/svg/icon-platform-pdd.svg" }, - { id: "xhs", name: "小红书", logo: "红", img: "/assets/svg/icon-platform-xiaohongshu.svg" }, - { id: "ks", name: "快手", logo: "快", img: "/assets/svg/icon-platform-kuaishou.svg" }, - { id: "sph", name: "视频号", logo: "视", img: "/assets/svg/icon-platform-wechat-video.svg" }, - { id: "amz", name: "亚马逊", logo: "a", img: "/assets/svg/icon-platform-amazon.svg" }, - { id: "al", name: "1688", logo: "阿", img: "/assets/svg/icon-platform-1688.svg" } -]; - -/* 前端平台 id(dy/tb…) → 后端规范化 platform_id(优化版:后端按此 key 注入平台版式块)。 */ +/* 前端平台 id(dy/tb…) ↔ 后端规范化 platform_id。平台套图已不再选平台(走通用电商主图规范), + 这里只保留给旧记录:恢复旧批次时还原平台 key,重跑旧批次时原样带回 platform_id(后端仍兼容)。 */ const PLATFORM_ID_MAP: Record = { dy: "douyin", tb: "taobao", tm: "tmall", jd: "jd", pdd: "pdd", xhs: "xhs", ks: "kuaishou", sph: "wechat", amz: "amazon", al: "1688" }; -/* YYX#row10:平台 logo —— 优先真 logo 图(public/assets/svg),加载失败回退到品牌色块+字符。 - className 默认 p-logo(平台卡/筛选弹窗),分组头传 cg-logo。 */ -function PlatformLogo({ p, className }: { p: { id: string; name: string; logo: string; img?: string }; className?: string }) { - const [err, setErr] = useState(false); - return ( - - {p.img && !err - ? {p.name} setErr(true)} /> - : p.logo} - - ); +/* 平台套图 · 商品信息(替代原「画面要求」):用户填写卖点 / 作用 / 人群 / 规格 / 补充, + 默认从商品资料(selling_points / description / target_audience / specs)带入。 */ +type CoverInfoDraft = { sellingPoints: string; effect: string; audience: string; specs: string; notes: string }; + +function formatProductSpecs(specs?: Record): string { + if (!specs || typeof specs !== "object") return ""; + return Object.entries(specs) + .filter(([, value]) => typeof value === "string" || typeof value === "number") + .map(([key, value]) => `${key}:${String(value).trim()}`) + .filter((line) => !line.endsWith(":")) + .slice(0, 6) + .join(";"); +} + +function coverInfoFromProduct(p?: Product): CoverInfoDraft { + const points = [...(p?.selling_points || [])] + .sort((a, b) => a.sort_order - b.sort_order) + .map((sp) => { + const title = (sp.title || "").trim(); + const detail = (sp.detail || "").trim(); + if (!title) return detail.slice(0, 40); + return detail && detail.length <= 40 ? `${title}:${detail}` : title; + }) + .filter(Boolean); + return { + sellingPoints: points.join("\n"), + effect: (p?.description || "").trim().slice(0, 300), + audience: (p?.target_audience || "").trim(), + specs: formatProductSpecs(p?.specs), + notes: "" + }; +} + +/* 与后端 _split_selling_points 同口径:按换行 / 分号拆,只有一行时再按顿号 / 逗号拆,最多 6 条 */ +function splitSellingPoints(text: string): string[] { + const lines = text.split(/[\n\r;;]+/).map((x) => x.replace(/^\s*(?:[-•·*]+|\d+\s*[.、))])\s*/, "").trim()).filter(Boolean); + const parts = lines.length <= 1 ? (lines[0] || text).split(/[、,,]+/).map((x) => x.trim()).filter(Boolean) : lines; + return parts.slice(0, 6); +} + +function coverInfoPayload(d: CoverInfoDraft): CoverProductInfo { + const out: CoverProductInfo = {}; + if (d.sellingPoints.trim()) out.selling_points = d.sellingPoints.trim(); + if (d.effect.trim()) out.effect = d.effect.trim(); + if (d.audience.trim()) out.audience = d.audience.trim(); + if (d.specs.trim()) out.specs = d.specs.trim(); + if (d.notes.trim()) out.notes = d.notes.trim(); + return out; +} + +/* 可读摘要:作为批次 prompt(批次头展示 / 搜索 / 旧客户端兼容),结构化信息另走 product_info */ +function composeCoverPrompt(title: string, d: CoverInfoDraft): string { + const parts: string[] = []; + const points = splitSellingPoints(d.sellingPoints); + if (points.length) parts.push(`核心卖点:${points.join("、")}`); + if (d.effect.trim()) parts.push(`商品作用:${d.effect.trim()}`); + if (d.audience.trim()) parts.push(`适用人群:${d.audience.trim()}`); + if (d.specs.trim()) parts.push(`规格:${d.specs.trim()}`); + if (d.notes.trim()) parts.push(`补充:${d.notes.trim()}`); + return `${title}电商主图 · ${parts.join(";")}`; } /* 一次生成/重跑 = 一个批次:各自独立展示自己的图、状态;支持多批并行跑。 @@ -530,10 +565,14 @@ type GenBatch = { modelId?: string; /** 该批次选中的模特展示名(导航头显示) */ modelName?: string; - /** 该批次选中的平台 id 列表(平台套图:多选 → 各平台分组,P0③) */ + /** 旧平台套图记录的平台 id 列表(已不再选平台;仅用于重跑旧批次时原样带回) */ platformIds?: string[]; + /** 平台套图:本批提交的商品信息(重跑 / 补图沿用) */ + productInfo?: CoverProductInfo; /** 该批次提交的参考图(图片创作:用户上传作生成参考):批次头回显 + 重跑时凭 assetId 原样复用 */ refs?: { name: string; url: string; assetId?: string }[]; + /** 图片创作 @ 引用的结构化实体;重跑时仍按实体取事实和参考图。 */ + mentions?: CreationRef[]; /** 后端批次 id:重跑/补图带它回去,新任务归回原批次(否则后端裂成新批次,刷新后多出一条记录) */ backendBatchId?: string; /** 该批次已提交、尚未终态的生图任务 id:切走再回来可据此对每一批各自续轮询(PMC#5/#10) */ @@ -582,7 +621,7 @@ export function ImageWorkbenchPage({ modelConfigs: ModelConfig[]; onBack: () => void; navigate?: (page: Page) => void; - onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string; image_model?: string; platform_id?: string; conversation_id?: string; reference_image_ids?: string[]; batch_id?: string; retry_of_task_id?: string; onSubmitted?: (taskIds: string[], batchId?: string) => void }) => Promise<{ assets: Asset[]; conversation_id?: string; batch_id?: string } | null>; + onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string; image_model?: string; platform_id?: string; product_info?: CoverProductInfo; conversation_id?: string; reference_image_ids?: string[]; mention_refs?: CreationRef[]; batch_id?: string; retry_of_task_id?: string; onSubmitted?: (taskIds: string[], batchId?: string) => void }) => Promise<{ assets: Asset[]; conversation_id?: string; batch_id?: string } | null>; onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>; /** 图片创作仅接受路由显式带入的商品;不使用工作台全局当前商品。 */ imageProductId?: string; @@ -619,6 +658,15 @@ export function ImageWorkbenchPage({ conversationScopeKeyRef.current = conversationScopeKey; // 图片创作(image)默认留空,只靠 placeholder 引导;模特/平台仍预填模板省一步 const [prompt, setPrompt] = useState(mode === "image" ? "" : meta.promptTemplate(products[0]?.title || "商品")); + // 平台套图:商品信息草稿(切换商品时从商品资料重新带入;同一商品的资料刷新不覆盖用户编辑) + const [coverInfo, setCoverInfo] = useState(() => coverInfoFromProduct(product)); + useEffect(() => { + if (mode === "cover") setCoverInfo(coverInfoFromProduct(product)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode, product?.id]); + const updateCoverInfo = (key: keyof CoverInfoDraft, value: string) => setCoverInfo((prev) => ({ ...prev, [key]: value })); + const coverInfoReady = !!(coverInfo.sellingPoints.trim() || coverInfo.effect.trim()); + const coverSellingCount = splitSellingPoints(coverInfo.sellingPoints).length; const [ratio, setRatio] = useState(meta.ratio); // 手动输入比例:开启后用 W:H 两个输入框自定义,关闭则用预设 pill const [ratioManual, setRatioManual] = useState(false); @@ -686,17 +734,23 @@ export function ImageWorkbenchPage({ // 参考图:支持多张(可多选 / 多次追加),逐张可移除。提交时上传成 Asset 作生成参考。 const [refImages, setRefImages] = useState<{ name: string; url: string; file: File }[]>([]); const refInputRef = useRef(null); + // 图片创作 @ 引用保留实体 id;后端再回库取卖点和真实参考图,不依赖名字匹配。 + const [mentionRefs, setMentionRefs] = useState([]); + const [mentionMenuOpen, setMentionMenuOpen] = useState(false); + const [mentionLoading, setMentionLoading] = useState(false); + const [mentionResults, setMentionResults] = useState([]); + const [mentionTypeLabels, setMentionTypeLabels] = useState>({}); + const mentionEditorRef = useRef(null); + const mentionMenuRef = useRef(null); // 生成后把结果面板滚到最新批次用的哨兵(PMC#14/#22) const resultsEndRef = useRef(null); // 生成结果图片放大预览 const [preview, setPreview] = useState<{ src: string; name: string } | null>(null); // 平台套图头部:按提示词搜索已生成的结果图(gridQuery / searchOpen 仅 cover 用; // row40 后模特模式改单卡选择,已无网格可搜/排序,时间排序/模特筛选下拉一并移除) + // 平台筛选弹窗已随「选择平台」一起移除。 const [gridQuery, setGridQuery] = useState(""); const [searchOpen, setSearchOpen] = useState(false); - // YYX#12:平台套图结果筛选弹窗 —— 按平台筛选已生成的图(空集 = 不筛) - const [filterOpen, setFilterOpen] = useState(false); - const [platformFilter, setPlatformFilter] = useState([]); const visibleProducts = products.filter((item) => !(mode === "model" && isLocalLife(item))); // 商品主图:用后端内嵌的 preview_url(cover_preview_url / images[].preview_url),不再反查全局 assets const productCoverUrl = (p: Product): string => { @@ -718,6 +772,49 @@ export function ImageWorkbenchPage({ const refDrop = useFileDrop(acceptReferences, { accept: (f) => f.type.startsWith("image/") }); + const mentionName = (ref: CreationRef) => ref.name.split(" · ")[0].trim(); + const openImageMentions = useCallback(async () => { + setMentionMenuOpen(true); + setMentionLoading(true); + try { + const result = await api.searchMentions({ limit: 6 }); + setMentionResults(result.results); + setMentionTypeLabels(result.type_labels); + } catch (error) { + onNotify?.("error", error instanceof Error ? error.message : "引用素材加载失败"); + setMentionResults([]); + } finally { + setMentionLoading(false); + } + }, [onNotify]); + + const selectImageMention = (ref: CreationRef) => { + setMentionRefs((prev) => prev.some((item) => item.type === ref.type && item.id === ref.id) ? prev : [...prev, ref]); + mentionEditorRef.current?.insertMention(ref, true); + setMentionMenuOpen(false); + }; + + const handleStudioPromptChange = (value: string) => { + setPrompt(value); + // 引用标签被用户删除时,同步解除结构化引用,避免看不见的素材仍参与生成。 + setMentionRefs((prev) => prev.filter((ref) => value.includes(`@${mentionName(ref)}`))); + }; + + const mentionGroups = useMemo(() => { + const groups = new Map(); + mentionResults.forEach((ref) => groups.set(ref.type, [...(groups.get(ref.type) || []), ref])); + return Array.from(groups.entries()); + }, [mentionResults]); + + useEffect(() => { + if (!mentionMenuOpen) return; + const close = (event: MouseEvent) => { + if (!mentionMenuRef.current?.contains(event.target as Node)) setMentionMenuOpen(false); + }; + document.addEventListener("mousedown", close); + return () => document.removeEventListener("mousedown", close); + }, [mentionMenuOpen]); + const imageModels = modelConfigs.filter((model) => model.capability.includes("image")); // 团队价格系数(差异化调价):预估所见即所扣;拉不到按标准价 1 const [priceMultiplier, setPriceMultiplier] = useState(1); @@ -790,26 +887,17 @@ export function ImageWorkbenchPage({ document.addEventListener("click", close); return () => document.removeEventListener("click", close); }, [openMore]); - // YYX#12:点空白收起平台筛选弹窗 - useEffect(() => { - if (!filterOpen) return; - const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".iw-filter-wrap")) setFilterOpen(false); }; - document.addEventListener("click", close); - return () => document.removeEventListener("click", close); - }, [filterOpen]); const ratioVar = ratio.replace(":", " / "); const candidateCount = Math.max(1, Number(count) || 4); - // PMC#5:平台套图(cover)对每个选中平台各起一批,实扣 = 平台数 × candidateCount × 单价。 - // 预估必须乘平台数才与实扣口径一致(model/image 模式只生成 candidateCount 张,不乘)。 - const estimateCount = mode === "cover" ? Math.max(1, pickedIds.length) * candidateCount : candidateCount; + // 平台套图已不再按平台拆批:每次提交一批 candidateCount 张,实扣 = candidateCount × 单价(三种模式同口径)。 + const estimateCount = candidateCount; // 行31:并发提交——只要 prompt 非空就能再次提交,不再因"有批次在跑"被禁用 // 模特上身图需结合「商品图 + 模特图」合成,故必须先选商品且选一个模特,否则只是凭文字脑补 - const canGenerate = - prompt.trim().length > 0 && - (mode !== "model" || (!!product?.id && pickedIds.length > 0)) && - // 平台套图:必须选商品 + 至少一个平台(P0③ 多选) - (mode !== "cover" || (!!product?.id && pickedIds.length > 0)); + // 平台套图:必须选商品(商品图作参考)+ 至少填写核心卖点或商品作用之一 + const canGenerate = mode === "cover" + ? !!product?.id && coverInfoReady + : prompt.trim().length > 0 && (mode !== "model" || (!!product?.id && pickedIds.length > 0)); /* R100:工作台记录不再走 localStorage 持久化(1 小时过期、换浏览器即空 = 「任务中心有记录、 工作台丢」的根因)。模特/平台模式的批次流改从后端持久任务恢复(见下方 workbenchTasks effect), @@ -919,6 +1007,7 @@ export function ImageWorkbenchPage({ productId: first.product_id || undefined, modelId: first.model_id || undefined, platformIds: platformKey ? [platformKey] : undefined, + productInfo: first.product_info || undefined, backendBatchId: first.batch_id || undefined, taskIds: live.map((t) => t.id), rerunTaskIds: live.filter((t) => t.rerun).map((t) => t.id), @@ -976,6 +1065,8 @@ export function ImageWorkbenchPage({ setActiveConvId(conv.id); setBatches([]); setPrompt(mode === "image" ? "" : meta.promptTemplate(product?.title || "商品")); + setMentionRefs([]); + setMentionMenuOpen(false); setPickedIds([]); } catch (err) { // 不再静默吞错:把失败摆到用户面前(最常见原因 = 后端未更新,对话接口 404) @@ -1053,6 +1144,8 @@ export function ImageWorkbenchPage({ setActiveConvId(""); setConversations([]); setBatches([]); + setMentionRefs([]); + setMentionMenuOpen(false); setRenamingId(""); setConvError(""); void loadConversations(); @@ -1069,10 +1162,14 @@ export function ImageWorkbenchPage({ modelId?: string; modelName?: string; platformIds?: string[]; - /** 优化版:规范化平台 id(douyin/taobao…),透传给后端注入平台版式块 */ + /** 旧记录兼容:规范化平台 id(douyin/taobao…),仅重跑旧平台批次时带回 */ platformId?: string; + /** 平台套图:商品信息(卖点 / 作用 / 人群 / 规格 / 补充),透传后端注入主图提示词 */ + productInfo?: CoverProductInfo; /** 图片创作:本批要参考的上传图(含 file 用于上传;已是 asset 的可只给 id) */ refs?: { name: string; url: string; file?: File; assetId?: string }[]; + /** 图片创作 @ 引用的实体;后端根据 id 解析为素材事实和参考图。 */ + mentions?: CreationRef[]; /** PMC#25:原地重跑——复位这张已有批次卡(清旧结果、重新生成),不新开任务卡 */ reuseBatchId?: string; /** PMC#25:单图重跑——把新图追加进这张已有批次卡(不清旧好图、不新开卡) */ @@ -1105,8 +1202,10 @@ export function ImageWorkbenchPage({ modelId: opts.modelId, modelName: opts.modelName, platformIds: opts.platformIds, + productInfo: opts.productInfo, // 批次头回显「参考了哪些图」(存名+预览+已知 assetId,不存 file;上传成功后统一回写 assetId) - refs: opts.refs?.map((r) => ({ name: r.name, url: r.url, assetId: r.assetId })) + refs: opts.refs?.map((r) => ({ name: r.name, url: r.url, assetId: r.assetId })), + mentions: opts.mentions }; setBatches((prev) => [...prev, newBatch]); } @@ -1135,7 +1234,7 @@ export function ImageWorkbenchPage({ // 带上当前对话 id(空则后端自动开一条并回传);conversation_id 用 ref 取最新值,避免闭包旧值 // batch_id:重跑/补图带原批次 id → 后端沿用,记录归回原批次(刷新后不裂新聊天记录) // onSubmitted:提交成功拿到任务 id 记进本批 pendingIds → 切走再回来由后端记录接续轮询(R100) - const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio, image_model: genModel, platform_id: opts.platformId, conversation_id: activeConvRef.current || undefined, reference_image_ids: referenceImageIds, batch_id: opts.batchId, retry_of_task_id: opts.retryOfTaskId, + const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio, image_model: genModel, platform_id: opts.platformId, product_info: opts.productInfo, conversation_id: activeConvRef.current || undefined, reference_image_ids: referenceImageIds, mention_refs: opts.mentions, batch_id: opts.batchId, retry_of_task_id: opts.retryOfTaskId, onSubmitted: (taskIds, submittedBatchId) => { if (mode === "image" && submittedScopeKey !== conversationScopeKeyRef.current) return; setBatches((prev) => prev.map((b) => { @@ -1210,11 +1309,13 @@ export function ImageWorkbenchPage({ productTitle: mode === "image" ? imageProduct?.title : product?.title }; if (mode === "cover") { - // P0③:每个选中平台各起一批 → 右侧自然形成多平台分组 section; - // 优化版:不再把平台名拼进 prompt,改传规范化 platform_id 给后端注入平台版式块(平台调性/版式/负面约束) - for (const pid of pickedIds) { - void startBatch({ ...base, platformId: PLATFORM_ID_MAP[pid], platformIds: [pid] }); - } + // 不再选平台:一次提交一批,结构化商品信息走 product_info(后端注入电商主图提示词), + // prompt 为可读摘要(批次头展示 / 搜索用)。商品图参考链路不变(后端按 product_id 取商品图)。 + void startBatch({ + ...base, + prompt: composeCoverPrompt(product?.title || "商品", coverInfo), + productInfo: coverInfoPayload(coverInfo) + }); return; } void startBatch({ @@ -1222,11 +1323,12 @@ export function ImageWorkbenchPage({ modelId: mode === "model" ? pickedIds[0] : undefined, modelName: mode === "model" ? pickedModelName : undefined, // 图片创作:把已选参考图带进这一批(startBatch 内上传并传给后端) - refs: mode === "image" && refImages.length ? refImages : undefined + refs: mode === "image" && refImages.length ? refImages : undefined, + mentions: mode === "image" && mentionRefs.length ? mentionRefs : undefined }); // 图片创作:提交后清空输入栏(参考图 + 提示词文字),像对话一样「发完即清」,等下次输入(PMC#15)。 // 批次头会保留这次用过的提示词/参考图,信息不丢。模特/平台模式的提示词是按商品预填的模板,不清。 - if (mode === "image") { setRefImages([]); setPrompt(""); } + if (mode === "image") { setRefImages([]); setMentionRefs([]); setPrompt(""); } // 生成后自动把面板滚到最新批次,不用用户自己往下拖找(PMC#14/#22)。等新批次渲染出来再滚。 window.setTimeout(() => resultsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }), 80); } @@ -1244,8 +1346,10 @@ export function ImageWorkbenchPage({ modelName: src.modelName, platformIds: src.platformIds, platformId: src.platformIds?.[0] ? PLATFORM_ID_MAP[src.platformIds[0]] : undefined, + productInfo: src.productInfo, // 原批次的参考图 + 后端批次 id 一并带回:重跑仍参考原素材,且任务归回原批次不裂新记录 refs: src.refs, + mentions: src.mentions, batchId: src.backendBatchId, reuseBatchId: src.id }); @@ -1321,6 +1425,7 @@ export function ImageWorkbenchPage({ modelName: src.modelName, platformIds: src.platformIds, platformId: src.platformIds?.[0] ? PLATFORM_ID_MAP[src.platformIds[0]] : undefined, + productInfo: src.productInfo, // 原批次的参考图 + 后端批次 id 一并带回:补的这张仍参考原素材,且归回原批次不裂新记录 refs: src.refs, batchId: src.backendBatchId, @@ -1381,12 +1486,8 @@ export function ImageWorkbenchPage({ const q = gridQuery.trim().toLowerCase(); list = list.filter((b) => (b.prompt || "").toLowerCase().includes(q)); } - // YYX#12:筛选弹窗按平台筛选已生成的图(选了哪些平台就只看哪些) - if (mode === "cover" && platformFilter.length) { - list = list.filter((b) => (b.platformIds || []).some((p) => platformFilter.includes(p))); - } return list; - }, [batches, productId, mode, gridQuery, platformFilter]); + }, [batches, productId, mode, gridQuery]); const hasResults = productBatches.length > 0; /* ── 单个批次的结果网格 · §4.18 gen-card 三件套 ── @@ -1533,42 +1634,6 @@ export function ImageWorkbenchPage({ ); } - /* ── 平台套图 · 多平台分组结果(P0③)── - 每个平台一个 section 头(平台 logo + 名 + 批次数),其下挂该平台的批次卡。 */ - function renderCoverGrouped(list: GenBatch[]) { - // 按平台 id 分组;无平台标签的批次归到「未分类」组(兜底,正常不出现) - const groups: Array<{ pid: string; batches: GenBatch[] }> = []; - for (const batch of list) { - const pid = batch.platformIds?.[0] || "_"; - let g = groups.find((x) => x.pid === pid); - if (!g) { g = { pid, batches: [] }; groups.push(g); } - g.batches.push(batch); - } - return ( - <> - {groups.map((g) => { - const plat = PLATFORM_OPTIONS.find((p) => p.id === g.pid); - return ( - -
- {plat ? ( - <> - - {plat.name} - - ) : ( - 未分类平台 - )} - {g.batches.length} 批 -
- {renderBatchCards(g.batches)} -
- ); - })} - - ); - } - const featuredProducts = (() => { const selected = visibleProducts.find((item) => item.id === productId); const rest = visibleProducts.filter((item) => item.id !== productId); @@ -1579,7 +1644,6 @@ export function ImageWorkbenchPage({ const rest = personAssets.filter((item) => item.id !== pickedIds[0]); return (selected ? [selected, ...rest] : rest).slice(0, 3); })(); - const selectedPlatform = PLATFORM_OPTIONS.find((item) => item.id === pickedIds[0]); const ratioOptions = mode === "model" ? MODEL_RATIO_OPTIONS : RATIO_OPTIONS; const countOptions = mode === "model" ? MODEL_COUNT_OPTIONS : COVER_COUNT_OPTIONS; const openProductLibrary = () => { @@ -1758,12 +1822,40 @@ export function ImageWorkbenchPage({ )} -