大量优化 模型改成gpt6
Deploy dev / deploy (push) Successful in 41s

This commit is contained in:
Azmat@qq.com
2026-09-28 18:15:08 +08:00
parent a84431c9c5
commit 4421706290
14 changed files with 1878 additions and 540 deletions
+323 -130
View File
@@ -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", ""))
+126 -22
View File
@@ -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]
+295 -83
View File
@@ -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):
@@ -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),
+158 -32
View File
@@ -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,
+2 -1
View File
@@ -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 内闭环),重开素材库即可见。
+142 -9
View File
@@ -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;
+2 -1
View File
@@ -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 —— 左栏会话列表 / 新对话 / 切换 / 重命名 / 删除
+1 -1
View File
@@ -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" }
];
+227 -2
View File
@@ -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);
+129 -15
View File
@@ -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;
+343 -220
View File
@@ -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<string, string> = {
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 (
<span className={`${className || "p-logo"} p-logo-${p.id}${p.img && !err ? " has-img" : ""}`}>
{p.img && !err
? <img className="p-logo-img" src={p.img} alt={p.name} loading="lazy" decoding="async" onError={() => setErr(true)} />
: p.logo}
</span>
);
/* 平台套图 · 商品信息(替代原「画面要求」):用户填写卖点 / 作用 / 人群 / 规格 / 补充,
默认从商品资料(selling_points / description / target_audience / specs)带入。 */
type CoverInfoDraft = { sellingPoints: string; effect: string; audience: string; specs: string; notes: string };
function formatProductSpecs(specs?: Record<string, unknown>): 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<CoverInfoDraft>(() => 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<HTMLInputElement | null>(null);
// 图片创作 @ 引用保留实体 id;后端再回库取卖点和真实参考图,不依赖名字匹配。
const [mentionRefs, setMentionRefs] = useState<CreationRef[]>([]);
const [mentionMenuOpen, setMentionMenuOpen] = useState(false);
const [mentionLoading, setMentionLoading] = useState(false);
const [mentionResults, setMentionResults] = useState<CreationRef[]>([]);
const [mentionTypeLabels, setMentionTypeLabels] = useState<Record<string, string>>({});
const mentionEditorRef = useRef<RichMentionEditorHandle>(null);
const mentionMenuRef = useRef<HTMLDivElement>(null);
// 生成后把结果面板滚到最新批次用的哨兵(PMC#14/#22)
const resultsEndRef = useRef<HTMLDivElement | null>(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<string[]>([]);
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<string, CreationRef[]>();
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 (
<Fragment key={g.pid}>
<div className="iw-cover-group-h">
{plat ? (
<>
<PlatformLogo p={plat} className="cg-logo" />
<span className="cg-name">{plat.name}</span>
</>
) : (
<span className="cg-name">未分类平台</span>
)}
<span className="cg-ct">{g.batches.length} 批</span>
</div>
{renderBatchCards(g.batches)}
</Fragment>
);
})}
</>
);
}
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({
</div>
)}
</div>
<textarea
className="studio-prompt"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder="描述你想生成或修改的画面,@ 可引用商品、模特或已有素材……"
/>
<div className="studio-prompt-wrap" ref={mentionMenuRef}>
<RichMentionEditor
ref={mentionEditorRef}
className="studio-prompt"
value={prompt}
refs={mentionRefs}
placeholder="描述你想生成或修改的画面,@ 可引用商品、模特或已有素材……"
ariaLabel="图片创作提示词"
onChange={handleStudioPromptChange}
onAtTrigger={() => { void openImageMentions(); }}
/>
{mentionMenuOpen ? (
<div className="studio-mention-menu" role="dialog" aria-label="引用素材">
<div className="studio-mention-head">引用素材</div>
<div className="studio-mention-list">
{mentionLoading ? (
<span className="studio-mention-state">正在加载素材…</span>
) : mentionGroups.length === 0 ? (
<span className="studio-mention-state">暂无可引用的商品、模特或素材</span>
) : mentionGroups.map(([type, refs]) => (
<div className="studio-mention-group" key={type}>
<span>{mentionTypeLabels[type] || type}</span>
{refs.map((ref) => (
<button type="button" key={`${ref.type}:${ref.id}`} onClick={() => selectImageMention(ref)}>
{ref.cover ? <img src={ref.cover} alt="" /> : <i>{(mentionTypeLabels[ref.type] || ref.type).slice(0, 1)}</i>}
<b>{mentionName(ref)}</b>
</button>
))}
</div>
))}
</div>
</div>
) : null}
</div>
</div>
<div className="image-composer-footer">
<div className="image-composer-options">
@@ -1831,7 +1923,7 @@ export function ImageWorkbenchPage({
<div className="generator-layout">
<aside className="generator-panel">
<div className="asset-pair-grid">
<div className={`asset-pair-grid${mode === "cover" ? " single" : ""}`}>
<section className="control-section">
<div className="control-heading"><span className="step-number">1</span><strong>选择商品</strong></div>
<div className="product-choice-list">
@@ -1866,7 +1958,7 @@ export function ImageWorkbenchPage({
</div>
</section>
{mode === "model" ? (
{mode === "model" && (
<section className="control-section">
<div className="control-heading"><span className="step-number">2</span><strong>选择模特</strong></div>
<div className="model-choice-grid">
@@ -1895,33 +1987,24 @@ export function ImageWorkbenchPage({
</button>
</div>
</section>
) : (
<section className="control-section">
<div className="control-heading"><span className="step-number">2</span><strong>选择平台</strong></div>
<div className="yz-platform-grid">
{PLATFORM_OPTIONS.map((item) => (
<button
type="button"
key={item.id}
className={`platform-choice ${pickedIds.includes(item.id) ? "active" : ""}`}
onClick={() => togglePick(item.id)}
>
<span className="choice-check" />
<PlatformLogo p={item} />
<span>{item.name}</span>
</button>
))}
</div>
</section>
)}
</div>
<section className={`control-section generator-settings${mode === "cover" ? " platform-generator-settings" : ""}`}>
<div className="control-heading"><span className="step-number">3</span><strong>生成设置</strong></div>
<section className={`control-section generator-settings${mode === "cover" ? " cover-generator-settings" : ""}`}>
<div className="control-heading">
<span className="step-number">{mode === "cover" ? 2 : 3}</span>
<strong>生成设置</strong>
{mode === "cover" && product && (
<button type="button" className="cover-info-reset" title="用商品库中的卖点、描述、人群与规格重新填充" onClick={() => setCoverInfo(coverInfoFromProduct(product))}>
<RefreshCw size={12} />
重新带入
</button>
)}
</div>
<div className="field-group">
<div className="field-label">
<span>生成数量</span>
<small>{mode === "cover" ? "覆盖不同图位" : "单次最多 4 张"}</small>
<small>{mode === "cover" ? "依次覆盖主图 / 卖点 / 细节 / 场景" : "单次最多 4 张"}</small>
</div>
<div className="choice-row">
{countOptions.map((value) => (
@@ -1983,35 +2066,107 @@ export function ImageWorkbenchPage({
)}
</div>
)}
<div className="field-group">
<label className="field-label">
<span>{mode === "cover" ? "画面要求" : "提示词"}</span>
<small>{mode === "cover" ? "平台规范已自动载入" : "可继续修改"}</small>
</label>
<textarea
className="image-prompt"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder={`例如:${meta.promptTemplate(product?.title || "商品")}`}
/>
<div className="model-line">
<Pill label="模型" value={selectedImageModelLabel} options={imageModelPickerOptions} onSelect={setGenModel} />
<span>
{mode === "cover"
? (selectedPlatform ? `${selectedPlatform.name}规范` : "请选择平台")
: "自动匹配商品参考图"}
</span>
{mode === "cover" ? (
<>
{/* 商品信息(替代原「画面要求」):结构化提交 product_info,后端据此生成电商主图 */}
<div className="field-group half">
<label className="field-label" htmlFor="cover-info-selling">
<span>核心卖点</span>
</label>
<textarea
id="cover-info-selling"
className="image-prompt cover-info-textarea"
value={coverInfo.sellingPoints}
onChange={(event) => updateCoverInfo("sellingPoints", event.target.value)}
placeholder={"例如:\n72 小时长效保湿\n无酒精,敏感肌可用"}
/>
</div>
<div className="field-group half">
<label className="field-label" htmlFor="cover-info-effect">
<span>商品功效</span>
</label>
<textarea
id="cover-info-effect"
className="image-prompt cover-info-textarea"
value={coverInfo.effect}
onChange={(event) => updateCoverInfo("effect", event.target.value)}
placeholder="例如:深层补水、舒缓干燥紧绷,让上妆更服帖"
/>
</div>
<div className="field-group half">
<label className="field-label" htmlFor="cover-info-audience">
<span>适用人群</span>
</label>
<input
id="cover-info-audience"
className="cover-info-input"
value={coverInfo.audience}
onChange={(event) => updateCoverInfo("audience", event.target.value)}
placeholder="例如:干皮 / 敏感肌、熬夜人群"
/>
</div>
<div className="field-group half">
<label className="field-label" htmlFor="cover-info-specs">
<span>规格参数</span>
</label>
<input
id="cover-info-specs"
className="cover-info-input"
value={coverInfo.specs}
onChange={(event) => updateCoverInfo("specs", event.target.value)}
placeholder="例如:200ml · 玻璃瓶装"
/>
</div>
<div className="field-group">
<label className="field-label" htmlFor="cover-info-notes">
<span>补充要求</span>
</label>
<input
id="cover-info-notes"
className="cover-info-input"
value={coverInfo.notes}
onChange={(event) => updateCoverInfo("notes", event.target.value)}
placeholder="例如:清透蓝白色调,浴室台面场景"
/>
<div className="model-line">
<Pill label="模型" value={selectedImageModelLabel} options={imageModelPickerOptions} onSelect={setGenModel} />
<span className={coverInfoReady ? "" : "cover-info-hint"}>
{!product
? "请先选择商品"
: coverInfoReady
? `引用商品图 ${product.images?.length || 0} 张 · 1:1 电商主图`
: "补充卖点后即可生成"}
</span>
</div>
</div>
</>
) : (
<div className="field-group">
<label className="field-label">
<span>提示词</span>
<small>可继续修改</small>
</label>
<textarea
className="image-prompt"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder={`例如:${meta.promptTemplate(product?.title || "商品")}`}
/>
<div className="model-line">
<Pill label="模型" value={selectedImageModelLabel} options={imageModelPickerOptions} onSelect={setGenModel} />
<span>自动匹配商品参考图</span>
</div>
</div>
</div>
)}
</section>
<div className="generator-footer">
<button type="button" className="generate-button" onClick={runGenerate} disabled={!canGenerate}>
<WandSparkles />
<span>{mode === "cover" ? "生成平台套图" : "立即生成"}</span>
<span>{mode === "cover" ? "生成套图" : "立即生成"}</span>
</button>
<div className="cost-line">
<span>{mode === "cover" ? "生成成功后按实际数量结算" : "仅在生成成功后扣费"}</span>
<span>成功后扣费</span>
<strong>预计 {estimateFee(estimateCount)}</strong>
</div>
</div>
@@ -2021,21 +2176,11 @@ export function ImageWorkbenchPage({
<div className="result-topbar">
<strong>{mode === "cover" ? "套图预览" : "生成结果"}</strong>
<div className="result-topbar-tools">
<div className="result-tags">
{mode === "cover" ? (
<>
<span>{selectedPlatform?.name || "未选平台"}</span>
<span>{count} 个图位</span>
<span>统一视觉</span>
</>
) : (
<>
<span>{ratio}</span>
<span>{count} 张</span>
<span>自动保存至成品库</span>
</>
)}
</div>
{!searchOpen && (
<div className="result-tags">
<span>{mode === "cover" ? `${count} 张套图` : `${ratio} · ${count} 张`}</span>
</div>
)}
{mode === "cover" && (
<>
<div className="tb-search-wrap">
@@ -2053,43 +2198,17 @@ export function ImageWorkbenchPage({
<Search size={14} />
</button>
</div>
<div className={`tb-menu-wrap chip-wrap iw-filter-wrap${filterOpen ? " open" : ""}`} data-filter="platform">
<button className={`search-btn${platformFilter.length ? " active" : ""}`} type="button" title="按平台筛选" onClick={() => setFilterOpen((v) => !v)}>
<SlidersHorizontal size={14} />
{platformFilter.length > 0 && <span className="iw-filter-count">{platformFilter.length}</span>}
</button>
<div className="chip-menu align-right iw-filter-menu">
<div className="iw-filter-h">
<span className="mono">按平台筛选</span>
<button type="button" className="iw-filter-clear" disabled={!platformFilter.length} onClick={() => setPlatformFilter([])}>清空</button>
</div>
<div className="iw-filter-grid">
{PLATFORM_OPTIONS.map((p) => (
<button
type="button"
key={p.id}
className={`iw-filter-opt${platformFilter.includes(p.id) ? " on" : ""}`}
onClick={() => setPlatformFilter((prev) => (prev.includes(p.id) ? prev.filter((x) => x !== p.id) : [...prev, p.id]))}
>
<PlatformLogo p={p} />
<span className="nm">{p.name}</span>
<Check className="ck" size={12} />
</button>
))}
</div>
</div>
</div>
</>
)}
</div>
</div>
<div className={`result-canvas${hasResults ? " has-results" : ""}`}>
{mode === "cover" && (platformFilter.length > 0 || gridQuery.trim()) && (
{mode === "cover" && gridQuery.trim() && (
<div className="iw-filter-notice">
<span className="ifn-text">当前已开启筛选,部分内容可能隐藏</span>
<button type="button" className="ifn-clear" onClick={() => { setPlatformFilter([]); setGridQuery(""); setSearchOpen(false); }}>
<span className="ifn-text">当前已开启搜索,部分内容可能隐藏</span>
<button type="button" className="ifn-clear" onClick={() => { setGridQuery(""); setSearchOpen(false); }}>
<X size={12} />
清空筛选
清空搜索
</button>
</div>
)}
@@ -2099,33 +2218,35 @@ export function ImageWorkbenchPage({
<strong>{mode === "cover" ? "还没有套图结果" : "等待生成"}</strong>
<p>
{mode === "cover"
? "选择平台后,系统会按对应图位和尺寸生成一组可继续编辑的电商素材。"
? "填写商品卖点与作用后,系统会结合商品图生成主图、卖点图、细节图与场景图。"
: "选择模特并确认设置后开始生成,结果会按版本保留,可随时切换使用。"}
</p>
</div>
) : (
<>
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
{mode === "cover" ? renderCoverGrouped(productBatches) : renderBatchCards(productBatches)}
{renderBatchCards(productBatches)}
<div ref={resultsEndRef} aria-hidden="true" />
</>
)}
</div>
<div className="result-footer">
{mode === "cover" ? (
<>
<div className="result-stat"><span>主图</span><strong>白底 / 场景各 1 张</strong></div>
<div className="result-stat"><span>卖点证明</span><strong>2 个核心卖点</strong></div>
<div className="result-stat"><span>输出位置</span><strong>成品库 / 平台套图</strong></div>
</>
) : (
<>
<div className="result-stat"><span>人物一致性</span><strong>{pickedModelName || "锁定所选模特"}</strong></div>
<div className="result-stat"><span>商品参考</span><strong>{product ? `${product.images?.length || 0} 张已关联` : "待选择商品"}</strong></div>
<div className="result-stat"><span>输出位置</span><strong>成品库 / 模特上身图</strong></div>
</>
)}
</div>
{hasResults && (
<div className="result-footer">
{mode === "cover" ? (
<>
<div className="result-stat"><span>图位</span><strong>主图 / 卖点 / 细节 / 场景</strong></div>
<div className="result-stat"><span>卖点依据</span><strong>{coverSellingCount ? `${coverSellingCount} 个核心卖点` : coverInfo.effect.trim() ? "商品功效" : "待填写"}</strong></div>
<div className="result-stat"><span>输出位置</span><strong>成品库 / 平台套图</strong></div>
</>
) : (
<>
<div className="result-stat"><span>人物一致性</span><strong>{pickedModelName || "锁定所选模特"}</strong></div>
<div className="result-stat"><span>商品参考</span><strong>{product ? `${product.images?.length || 0} 张已关联` : "待选择商品"}</strong></div>
<div className="result-stat"><span>输出位置</span><strong>成品库 / 模特上身图</strong></div>
</>
)}
</div>
)}
</section>
</div>
@@ -2167,14 +2288,16 @@ export function ImageWorkbenchPage({
coverUrl={productCoverUrl}
onClose={() => setPlOpen(false)}
onConfirm={() => {
// 第一个选中作为当前商品;若选了多个,各商品另起一批(沿用当前提示词/比例/张数/模特/平台)
// 第一个选中作为当前商品;若选了多个,各商品另起一批(沿用当前提示词/比例/张数/模特;
// 平台套图则用各自商品资料带入的商品信息 + 当前补充要求)
if (plDraft.length) {
setProductId(plDraft[0]);
if (plDraft.length > 1 && (mode === "model" ? pickedIds.length > 0 : mode === "cover" ? pickedIds.length > 0 : true) && prompt.trim()) {
if (plDraft.length > 1 && (mode === "model" ? pickedIds.length > 0 : true) && (mode === "cover" || prompt.trim())) {
for (const pid of plDraft.slice(1)) {
const p = products.find((x) => x.id === pid);
if (mode === "cover") {
for (const platId of pickedIds) void startBatch({ prompt: prompt.trim(), ratio, count: candidateCount, productId: pid, productTitle: p?.title, platformId: PLATFORM_ID_MAP[platId], platformIds: [platId] });
const info = { ...coverInfoFromProduct(p), notes: coverInfo.notes };
void startBatch({ prompt: composeCoverPrompt(p?.title || "商品", info), ratio, count: candidateCount, productId: pid, productTitle: p?.title, productInfo: coverInfoPayload(info) });
} else {
void startBatch({ prompt: prompt.trim(), ratio, count: candidateCount, productId: pid, productTitle: p?.title, modelId: mode === "model" ? pickedIds[0] : undefined, modelName: mode === "model" ? pickedModelName : undefined });
}
+84 -23
View File
@@ -700,17 +700,27 @@ function strategyDocumentBody(payload: Record<string, unknown>): string {
}
function planDocumentBody(payload: Record<string, unknown>): string {
const goal = String(payload.goal || "").trim();
const duration = String(payload.duration || "").trim();
const concept = String(payload.concept || "").trim();
const usp = strategyField(payload, "usp", "主打卖点", "卖点");
const points = coercePlanPoints(payload.points);
const timeline = (payload.timeline as PlanTimelineItem[] | undefined) || [];
const voice = coerceVoiceChars(payload.voice_chars);
const pointText = points.length ? points.map((point) => `- ${point}`).join("\n") : "—";
const timelineText = timeline.length
? timeline.map((item) => `- ${formatDocumentTime(item.start)}–${formatDocumentTime(item.end)} 秒 · ${item.stage}${item.desc ? `:${item.desc}` : ""}`).join("\n")
? timeline.map((item) => [
`### ${formatDocumentTime(item.start)}–${formatDocumentTime(item.end)} 秒 · ${item.stage}`,
`- 画面 / 剧情:${item.visual || item.desc || "—"}`,
`- 动作 / 对白:${item.action_dialogue || "—"}`,
`- 商品 / 卖点:${item.product || "—"}`,
`- 段落作用:${item.purpose || "—"}`,
].join("\n")).join("\n\n")
: "—";
const voiceText = voice.length === 2 ? `${voice[0]}–${voice[1]} 字,预计覆盖约 90% 时长` : "—";
return [
"# 视频最终方案",
"# 视频架构",
`## 创作概览\n- 视频目标:${goal || "—"}\n- 目标时长:${duration || "—"}\n- 创意概念:${concept || "—"}`,
`## 主打卖点 USP\n${usp || "—"}`,
`## 核心支撑\n${pointText}`,
`## 时间轴\n${timelineText}`,
@@ -999,7 +1009,16 @@ function StrategyCard({ payload }: { payload: Record<string, unknown> }) {
);
}
type PlanTimelineItem = { start: number; end: number; stage: string; desc?: string };
type PlanTimelineItem = {
start: number;
end: number;
stage: string;
desc?: string;
visual?: string;
action_dialogue?: string;
product?: string;
purpose?: string;
};
function coercePlanPoints(raw: unknown): string[] {
if (Array.isArray(raw)) {
@@ -1039,6 +1058,9 @@ function coerceVoiceChars(raw: unknown): number[] {
}
function PlanCard({ payload }: { payload: Record<string, unknown> }) {
const goal = String(payload.goal || "").trim();
const duration = String(payload.duration || "").trim();
const concept = String(payload.concept || "").trim();
const usp = strategyField(payload, "usp", "主打卖点", "卖点");
const points = coercePlanPoints(payload.points);
const timeline = (payload.timeline as PlanTimelineItem[] | undefined) || [];
@@ -1049,17 +1071,17 @@ function PlanCard({ payload }: { payload: Record<string, unknown> }) {
return (
<section className="omni-video-plan-card">
<header className="omni-video-plan-head">
<strong>视频最终方案</strong>
<strong>视频架构</strong>
<div className="omni-doc-head-actions">
<span>确认后进入出片参数核对</span>
<span>确认后撰写完整 Prompt</span>
<button
type="button"
className="omni-doc-download"
title="下载"
aria-label="下载视频最终方案"
aria-label="下载视频架构"
onClick={() => void downloadSessionResource({
kind: "document",
title: "视频最终方案.md",
title: "视频架构.md",
body: planDocumentBody(payload),
})}
>
@@ -1067,6 +1089,14 @@ function PlanCard({ payload }: { payload: Record<string, unknown> }) {
</button>
</div>
</header>
<section className="omni-plan-section omni-plan-overview">
<strong>创作概览</strong>
<div className="omni-plan-overview-grid">
<span><b>视频目标</b><em>{goal || "—"}</em></span>
<span><b>目标时长</b><em>{duration || "—"}</em></span>
<span><b>创意概念</b><em>{concept || "—"}</em></span>
</div>
</section>
<section className="omni-plan-section">
<strong>卖点做减法</strong>
<div className="omni-plan-points">
@@ -1087,11 +1117,14 @@ function PlanCard({ payload }: { payload: Record<string, unknown> }) {
<strong>Hook 只负责留人,正文负责说服</strong>
<div className="omni-plan-timeline">
{timeline.map((item, index) => (
<span key={`${item.stage}-${index}`}>
<span className="omni-plan-timeline-item" key={`${item.stage}-${index}`}>
<b>
{format(item.start)}–{format(item.end)} 秒 · {item.stage}
</b>
{item.desc || ""}
<em><i>画面 / 剧情</i>{item.visual || item.desc || "—"}</em>
<em><i>动作 / 对白</i>{item.action_dialogue || "—"}</em>
<em><i>商品 / 卖点</i>{item.product || "—"}</em>
<em><i>段落作用</i>{item.purpose || "—"}</em>
</span>
))}
</div>
@@ -1426,6 +1459,9 @@ function ElicitCard({
{isPet
? "不填写也可以,平台会结合当前商品和拟人主题自动设计萌宠角色。"
: "不填写也可以。若脚本是多人物,平台会按人数一次生成多张定妆图,避免长视频前后形象漂移。"}
{Number(message.payload?.estimated_credits || 0) > 0
? ` 预计 ${Number(message.payload.estimated_credits)} 积分。`
: ""}
</span>
<button
type="button"
@@ -1834,6 +1870,20 @@ function ElicitCard({
<span>{submitted ? (saved._action === "cancel" ? "已取消" : "已回答") : "选一下就好"}</span>
</header>
<div className="omni-elicit-body">
{interaction === "product_brief_review" ? (
<div className="omni-product-brief-list">
{((message.payload.items as Array<Record<string, unknown>> | undefined) || []).map((item, index) => {
const status = String(item.status || "missing");
const statusLabel = status === "ready" ? "已获取" : status === "not_needed" ? "暂不需要" : "待补充";
return (
<div className={`omni-product-brief-item is-${status}`} key={`${String(item.label || "商品信息")}-${index}`}>
<span><b>{String(item.label || "商品信息")}</b>{item.value ? <em>{String(item.value)}</em> : null}</span>
<i>{statusLabel}</i>
</div>
);
})}
</div>
) : null}
{fields.map((field) => {
const value = answers[field.key];
return (
@@ -1976,14 +2026,18 @@ function ConfirmCard({
const snapshot = { ...sessionParams, ...payloadParams };
const [draft, setDraft] = useState(snapshot);
const [initialDuration] = useState(snapshot.duration || "");
const [initialModel] = useState(snapshot.model || "");
const cardIsVideo = message.payload.kind !== "image" && isVideo;
const durationChanged =
cardIsVideo
&& Boolean(draft.duration)
&& Boolean(initialDuration)
&& normalizeDurationValue(draft.duration) !== normalizeDurationValue(initialDuration);
const modelChanged = cardIsVideo && Boolean(initialModel) && Boolean(draft.model) && draft.model !== initialModel;
const needsRebuild = durationChanged || modelChanged;
const durationSeconds = Number(String(draft.duration || "").replace(/\D/g, ""));
const willGenerateInSegments = cardIsVideo && durationSeconds > 30 && durationSeconds <= 60;
const generationPlan = (message.payload.generation_plan as Record<string, unknown> | undefined) || {};
const setField = (key: string, value: string) => setDraft((prev) => ({ ...prev, [key]: value }));
const summary = paramLine(draft, cardIsVideo) || "当前参数";
// 确认卡积分:改模型/分辨率/时长/张数时按后台挂牌实时重算(与后端 quote_* 同口径;标准团队系数=1)
@@ -2018,7 +2072,7 @@ function ConfirmCard({
: generationPhase === "failed" ? "可重新确认方案后再生成"
: generationPhase === "running" ? "正在出片,请稍候"
: generationPhase === "submitted" ? "正在提交生成"
: "确认前可以改参数。改时长会按新时长重写脚本。";
: "确认前可以改参数。改时长或模型会同步更新视频架构与 Prompt。";
const foot =
generationPhase === "done" ? "已完成出片"
: generationPhase === "failed" ? "出片失败"
@@ -2029,8 +2083,8 @@ function ConfirmCard({
: generationPhase === "failed" ? "生成失败"
: generationPhase === "running" || generationPhase === "submitted"
? "生成中"
: durationChanged
? "确认并重写脚本"
: needsRebuild
? "确认并同步架构"
: String(message.payload.label || "开始生成");
return (
@@ -2056,18 +2110,21 @@ function ConfirmCard({
/>
</div>
)}
{submitted ? null : durationChanged ? (
<p className="omni-confirm-hint">改时长会重新生成脚本。确认后先重写方案,不会直接出片。</p>
{submitted ? null : needsRebuild ? (
<p className="omni-confirm-hint">参数变化会同步更新受影响的视频架构与 Prompt,不会直接出片。</p>
) : willGenerateInSegments ? (
<p className="omni-confirm-hint">
{durationSeconds} 秒成片一次生成,时长较长会多花一点时间,请耐心等待。
{draft.duration === snapshot.duration && generationPlan.note
? String(generationPlan.note)
: `${durationSeconds} 秒将分 ${Math.ceil(durationSeconds / 30)} 段生成后自动合并。`}
{credits > 0 ? ` 预计共 ${credits} 积分。` : ""}
</p>
) : null}
<div className="omni-confirm-foot">
<span>{foot}</span>
<button type="button" disabled={disabled || submitted} onClick={() => onConfirm(draft)}>
{buttonLabel}
{!submitted && !durationChanged && credits > 0 ? <i>约 {credits} 积分</i> : null}
{!submitted && !needsRebuild && credits > 0 ? <i>约 {credits} 积分</i> : null}
</button>
</div>
</section>
@@ -3303,10 +3360,10 @@ export function OmniSessionPage({
prev ? { ...prev, params: { ...prev.params, ...nextParams } } : prev
);
if (result.regenerate) {
notify("info", `时长已改为 ${nextParams.duration || ""},正在按新时长重写脚本`);
notify("info", "参数已更新,正在同步视频架构与 Prompt");
void send({
kind: "text",
text: `时长改成了${nextParams.duration},请按新参数重新写方案,旧方案作废`,
text: `生成参数已更新为:${paramLine(nextParams, true)}。请只更新受影响的视频架构与 Prompt,保留已经确认的商品信息、角色图和未受影响的段落。`,
});
return;
}
@@ -3965,15 +4022,19 @@ export function OmniSessionPage({
) : mentionResults.length === 0 ? (
<strong>这类还没有可引用的内容</strong>
) : (
mentionResults.map((ref) => (
<button type="button" key={ref.id} onClick={() => insertMention(ref)}>
mentionResults.map((ref) => {
const filename = ref.name.split(" · ")[0];
const typeLabel = typeLabels[ref.type] || MENTION_TABS.find((tab) => tab.type === ref.type)?.label;
return (
<button type="button" key={ref.id} title={filename} onClick={() => insertMention(ref)}>
{ref.cover ? <img src={ref.cover} alt="" /> : <Play />}
<span>
{ref.name.split(" · ")[0]}
<small>{typeLabels[ref.type] || MENTION_TABS.find((tab) => tab.type === ref.type)?.label}</small>
<strong>{filename}</strong>
<small>{typeLabel}</small>
</span>
</button>
))
);
})
)}
</div>
</div>
+13
View File
@@ -780,6 +780,16 @@ export type ImageConversationTrash = ImageConversation & {
cover_preview_url: string;
};
// 平台套图 · 用户填写的商品信息(随生成请求提交 product_info,后端据此构建电商主图提示词)。
// 各字段均可选;selling_points 为多行文本(每行一个卖点)。
export type CoverProductInfo = {
selling_points?: string;
effect?: string;
audience?: string;
specs?: string;
notes?: string;
};
// 工作台生成记录(R100):模特上身图/平台套图页从后端持久任务拉取(与任务中心同源),
// 按 batch_id 归批还原批次流;assets 只含存活成图(软删的图已被后端过滤)。
export type WorkbenchTask = {
@@ -792,7 +802,10 @@ export type WorkbenchTask = {
product_id: string;
model_id: string;
model_entity_id: string;
/** 旧平台套图记录的平台 id;新记录不再选平台,为空串 */
platform_id: string;
/** 平台套图商品信息(新记录才有;重跑时沿用) */
product_info?: CoverProductInfo | null;
/** 重跑/补图任务(复用原批次 batch_id 追加):不计入批次「应出张数」 */
rerun?: boolean;
retry_of_task_id?: string;