@@ -7,8 +7,8 @@
|
|||||||
铁律(踩过就回不来的三条):
|
铁律(踩过就回不来的三条):
|
||||||
1. **视频 5–10 分钟,绝不在 SSE 里等。** 生成工具立刻返回 task_id,落一条
|
1. **视频 5–10 分钟,绝不在 SSE 里等。** 生成工具立刻返回 task_id,落一条
|
||||||
`generating` 消息,发 `task` 事件,收流。前端轮询完成后原地换成 `result`。
|
`generating` 消息,发 `task` 事件,收流。前端轮询完成后原地换成 `result`。
|
||||||
2. **闸门必须等人确认。** `ask_user` / `write_strategy` / `write_plan` /
|
2. **闸门必须等人确认。** `ask_user` / `write_plan` / `write_prompt`
|
||||||
`write_prompt` 一旦落卡就中断循环;视频 5 步(澄清→策略→方案→Prompt→出片确认)
|
一旦落卡就中断循环;`write_strategy` 仅供模型内部梳理,不展示给用户;视频 4 步(澄清→架构→Prompt→出片确认)
|
||||||
不可同轮连跳。
|
不可同轮连跳。
|
||||||
3. **一条用户消息最多计费生成一次。** 对话式会放大调用量,一句「多做几版」
|
3. **一条用户消息最多计费生成一次。** 对话式会放大调用量,一句「多做几版」
|
||||||
能烧掉一堆积分。
|
能烧掉一堆积分。
|
||||||
@@ -52,8 +52,6 @@ from .services import (
|
|||||||
build_provider,
|
build_provider,
|
||||||
enforce_no_embedded_captions,
|
enforce_no_embedded_captions,
|
||||||
get_default_model,
|
get_default_model,
|
||||||
get_seed_text_model,
|
|
||||||
resolve_text_model,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -71,9 +69,20 @@ LONG_VIDEO_DURATION_SLACK_SECONDS = 2
|
|||||||
# 单条用户消息最多触发一次计费生成(契约 §4)
|
# 单条用户消息最多触发一次计费生成(契约 §4)
|
||||||
MAX_BILLED_GENERATIONS = 1
|
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:
|
def creation_agent_max_output_tokens() -> int:
|
||||||
"""豆包 Seed 系列最大输出 16k、默认 4k。不抬高会把长方案的 tool 参数截成坏 JSON。"""
|
"""长架构需要足够输出空间,避免 tool 参数被截成不完整 JSON。"""
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
return max(1024, int(getattr(settings, "CREATION_AGENT_MAX_OUTPUT_TOKENS", 16000) or 16000))
|
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:
|
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 django.conf import settings
|
||||||
|
|
||||||
from .services import OFFICIAL_DIRECT_PROVIDERS
|
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()
|
mode = (getattr(settings, "CREATION_AGENT_THINKING_MODE", "") or "").strip()
|
||||||
provider_name = str(getattr(getattr(model_config, "provider", None), "name", "") or "")
|
provider_name = str(getattr(getattr(model_config, "provider", None), "name", "") or "")
|
||||||
if mode in {"enabled", "disabled", "auto"} and provider_name in OFFICIAL_DIRECT_PROVIDERS:
|
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.save(update_fields=["memory", "updated_at"])
|
||||||
|
|
||||||
# 视频闸门阶段(落在 conversation.memory.stage;resume 靠它)
|
# 视频闸门阶段(落在 conversation.memory.stage;resume 靠它)
|
||||||
# clarify → strategy → plan → prompt → confirm → done
|
# clarify → strategy → plan → prompt → cast → confirm → done
|
||||||
VIDEO_GATE_STAGES = ("clarify", "strategy", "plan", "prompt", "confirm", "done")
|
# 角色定妆必须在 Prompt 之后补齐:脚本先围绕需求完成,最后才把已定稿的
|
||||||
|
# 角色参考锁进出片参数,避免角色选择反过来打断视频架构。
|
||||||
|
VIDEO_GATE_STAGES = ("clarify", "strategy", "plan", "prompt", "cast", "confirm", "done")
|
||||||
PAIN_POINT_PRESET = "痛点解决演示"
|
PAIN_POINT_PRESET = "痛点解决演示"
|
||||||
PAIN_POINT_DIRECTION_KEY = "pain_point_direction"
|
PAIN_POINT_DIRECTION_KEY = "pain_point_direction"
|
||||||
_STEP_CONFIRM_LABELS = {
|
_STEP_CONFIRM_LABELS = {
|
||||||
@@ -383,6 +404,7 @@ def append_plot_twist_story_depth_question(conversation: CreationConversation) -
|
|||||||
"options": [
|
"options": [
|
||||||
{"value": item["value"], "label": f"{item['label']}|{item['summary']}"}
|
{"value": item["value"], "label": f"{item['label']}|{item['summary']}"}
|
||||||
for item in PLOT_TWIST_STORY_DEPTH_OPTIONS
|
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:
|
def video_needs_person_source(conversation: CreationConversation, user_text: str = "") -> bool:
|
||||||
"""需要真人/角色的视频在写策略前必须先锁定人物来源。"""
|
"""Prompt 完成后检查角色图是否按架构人数补齐。"""
|
||||||
if conversation.mode != CreationConversation.Mode.VIDEO:
|
if conversation.mode != CreationConversation.Mode.VIDEO:
|
||||||
return False
|
return False
|
||||||
# 已钉角色图 / 正在生成 / 只出手:才算人物步骤完成。禁止仅凭「你来推荐」空跑跳过。
|
|
||||||
if person_identity_ready(conversation):
|
|
||||||
return False
|
|
||||||
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
|
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):
|
if is_click_swap_preset(conversation.preset):
|
||||||
return click_swap_mode(conversation) == "character"
|
return click_swap_mode(conversation) == "character"
|
||||||
if conversation.preset in _PERSON_SOURCE_PRESETS or is_pet_preset(conversation.preset):
|
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(
|
recent = list(
|
||||||
conversation.messages.order_by("-seq").values_list("text", flat=True)[:12]
|
conversation.messages.order_by("-seq").values_list("text", flat=True)[:12]
|
||||||
)
|
)
|
||||||
pending_prompt = str(memory.get("pending_video_prompt") or "")
|
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]:
|
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
|
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 = ""
|
product_name = ""
|
||||||
for ref in locked_product_references(conversation):
|
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()
|
product_name = str(memory.get("product_name") or memory.get("product_brand_and_name") or "").strip()
|
||||||
|
|
||||||
is_pet = is_pet_preset(conversation.preset)
|
is_pet = is_pet_preset(conversation.preset)
|
||||||
|
missing_cast = 1
|
||||||
if is_pet:
|
if is_pet:
|
||||||
prompt_text = (
|
prompt_text = (
|
||||||
f"商品已选定【{product_name}】。这条视频想由哪只宠物角色出镜?选定后,所有镜头和分段都会锁定同一只宠物形象。"
|
f"商品已选定【{product_name}】。这条视频想由哪只宠物角色出镜?选定后,所有镜头和分段都会锁定同一只宠物形象。"
|
||||||
@@ -1048,14 +1149,17 @@ def append_person_source_gate(conversation: CreationConversation) -> CreationMes
|
|||||||
field_label = "选择宠物来源"
|
field_label = "选择宠物来源"
|
||||||
library_label = "从角色库选择"
|
library_label = "从角色库选择"
|
||||||
else:
|
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:
|
if cast_needed > 1:
|
||||||
prompt_text = (
|
prompt_text = (
|
||||||
f"商品已选定【{product_name}】。这条视频需要 {cast_needed} 位出镜人物;"
|
f"商品已选定【{product_name}】。这条视频需要 {cast_needed} 位出镜人物,"
|
||||||
"请上传/选择/生成对应数量的角色定妆图,所有镜头和分段都会按这些图锁脸。"
|
f"当前补全第 {cast_index}/{cast_needed} 位;所有镜头和分段都会按角色图锁脸。"
|
||||||
if product_name else
|
if product_name else
|
||||||
f"这条视频需要 {cast_needed} 位出镜人物。请上传/选择/生成对应数量的角色定妆图,"
|
f"这条视频需要 {cast_needed} 位出镜人物,当前补全第 {cast_index}/{cast_needed} 位。"
|
||||||
"所有镜头和分段都会按这些图锁脸,避免长视频前后形象漂移。"
|
"所有镜头和分段都会按角色图锁脸,避免前后形象漂移。"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
prompt_text = (
|
prompt_text = (
|
||||||
@@ -1074,6 +1178,9 @@ def append_person_source_gate(conversation: CreationConversation) -> CreationMes
|
|||||||
payload={
|
payload={
|
||||||
"interaction": "person_source_gate",
|
"interaction": "person_source_gate",
|
||||||
"is_pet": is_pet,
|
"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": [{
|
"fields": [{
|
||||||
"key": "person_source",
|
"key": "person_source",
|
||||||
"label": field_label,
|
"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:
|
def click_swap_sequence(conversation: CreationConversation) -> str:
|
||||||
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
|
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
|
||||||
return str(memory.get("click_swap_sequence") or "").strip()
|
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]
|
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]
|
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)))
|
total = max(1, min(4, int(total or 1)))
|
||||||
if is_pet_preset(conversation.preset):
|
if is_pet_preset(conversation.preset):
|
||||||
total = 1
|
total = 1
|
||||||
@@ -1619,6 +1743,43 @@ def emit_prompt_gate(
|
|||||||
return messages
|
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(
|
def emit_final_confirm_gate(
|
||||||
conversation: CreationConversation,
|
conversation: CreationConversation,
|
||||||
*,
|
*,
|
||||||
@@ -1667,6 +1828,8 @@ def emit_final_confirm_gate(
|
|||||||
)
|
)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
credits = 0
|
credits = 0
|
||||||
|
duration = video_duration(conversation.params or {}, prompt=prompt, timeline=timeline)
|
||||||
|
segments = plan_video_segments(duration, timeline=timeline)
|
||||||
confirm = append_message(
|
confirm = append_message(
|
||||||
conversation,
|
conversation,
|
||||||
role="assistant",
|
role="assistant",
|
||||||
@@ -1677,6 +1840,16 @@ def emit_final_confirm_gate(
|
|||||||
"estimated_credits": credits,
|
"estimated_credits": credits,
|
||||||
"video_prompt": prompt,
|
"video_prompt": prompt,
|
||||||
"timeline": timeline,
|
"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,
|
"submitted": False,
|
||||||
"params": snapshot_session_params(conversation),
|
"params": snapshot_session_params(conversation),
|
||||||
"param_options": confirm_param_options(True),
|
"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]:
|
def apply_confirm_params(conversation, incoming: dict | None) -> tuple[dict, bool]:
|
||||||
"""确认卡上改的参数写回会话。返回 (最新 params, 视频时长是否变了)。"""
|
"""确认卡上改的参数写回会话。返回 (最新 params, 是否必须同步重写架构/Prompt)。"""
|
||||||
current = dict(conversation.params or {})
|
current = dict(conversation.params or {})
|
||||||
old_duration = str(current.get("duration") or "")
|
old_duration = str(current.get("duration") or "")
|
||||||
|
old_model = str(current.get("model") or "")
|
||||||
changed = False
|
changed = False
|
||||||
for key, raw in (incoming or {}).items():
|
for key, raw in (incoming or {}).items():
|
||||||
if key not in {"model", "ratio", "resolution", "duration", "count"}:
|
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(str(current.get("duration") or ""))
|
||||||
and bool(old_duration)
|
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:
|
if changed:
|
||||||
conversation.params = current
|
conversation.params = current
|
||||||
conversation.save(update_fields=["params", "updated_at"])
|
conversation.save(update_fields=["params", "updated_at"])
|
||||||
if is_plot_twist_conversation(conversation):
|
if is_plot_twist_conversation(conversation):
|
||||||
# 在确认卡改时长也要切换故事契约;随后视图会要求重写旧方案。
|
# 在确认卡改时长也要切换故事契约;随后视图会要求重写旧方案。
|
||||||
set_plot_twist_story_depth(conversation, str(current.get("duration") or ""))
|
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
|
# ---------------------------------------------------------------- 工具 schema
|
||||||
@@ -2904,10 +3088,10 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
|
|||||||
"function": {
|
"function": {
|
||||||
"name": "write_strategy",
|
"name": "write_strategy",
|
||||||
"description": (
|
"description": (
|
||||||
"写「创作策略理解」卡:说清这条片给谁看、他为什么会信、你想让他信什么、整体创作方向。"
|
"内部梳理创作策略:说清这条片给谁看、他为什么会信、你想让他信什么、整体创作方向。"
|
||||||
"四个字段都必须写具体非空文案,禁止空字符串。"
|
"四个字段都必须写具体非空文案,禁止空字符串。"
|
||||||
"策略从第一稿就使用健康、正向、明确成年的人物与情节表达,不要复述需要规避的原始措辞。"
|
"策略从第一稿就使用健康、正向、明确成年的人物与情节表达,不要复述需要规避的原始措辞。"
|
||||||
"调完会停下来等用户确认或提出修改,不要同轮接着 write_plan。"
|
"本工具不会展示给用户;调用成功后必须在同一轮立即调用 write_plan,交付唯一可见的视频架构卡。"
|
||||||
"仅当用户明确要做片/出方案时调用;打招呼或闲聊不要调。"
|
"仅当用户明确要做片/出方案时调用;打招呼或闲聊不要调。"
|
||||||
),
|
),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -2927,9 +3111,9 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
|
|||||||
"function": {
|
"function": {
|
||||||
"name": "write_plan",
|
"name": "write_plan",
|
||||||
"description": (
|
"description": (
|
||||||
"写「视频最终方案」卡(USP/卖点/时间轴)并请用户确认。"
|
"写唯一对用户展示的「视频架构」卡并请用户确认。"
|
||||||
"**仅当用户已确认策略、或明确要改方案时调用**;打招呼或闲聊不要调。"
|
"架构要让用户看懂并能逐段修改;打招呼或闲聊不要调。"
|
||||||
"调完只出方案卡并停下等人确认 —— 不要同轮出 Prompt 卡或积分确认卡。"
|
"调完只出架构卡并停下等人确认 —— 不要同轮出 Prompt 卡或积分确认卡。"
|
||||||
"usp / points / timeline 必须写满具体文案;同时把 video_prompt 写好存档,"
|
"usp / points / timeline 必须写满具体文案;同时把 video_prompt 写好存档,"
|
||||||
"用户确认方案后由平台展示 Prompt。"
|
"用户确认方案后由平台展示 Prompt。"
|
||||||
+ (
|
+ (
|
||||||
@@ -2942,11 +3126,14 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
|
|||||||
)
|
)
|
||||||
+
|
+
|
||||||
"第一稿必须已经可直接过平台审核:只写正向安全描述,不要输出风险词清单或否定式免责声明。"
|
"第一稿必须已经可直接过平台审核:只写正向安全描述,不要输出风险词清单或否定式免责声明。"
|
||||||
"先有已确认的 write_strategy,再调它。"
|
"先完成内部 write_strategy,再调它。修改架构时必须沿用上一版,只改用户指出的部分,未受影响的时间段原样保留。"
|
||||||
),
|
),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"goal": {"type": "string", "description": "视频目标,例如建立认知、证明卖点或推动下单"},
|
||||||
|
"duration": {"type": "string", "description": "本架构采用的目标时长"},
|
||||||
|
"concept": {"type": "string", "description": "一句话创意概念"},
|
||||||
"usp": {"type": "string", "description": "主打卖点,全片只讲这一个核心价值"},
|
"usp": {"type": "string", "description": "主打卖点,全片只讲这一个核心价值"},
|
||||||
"points": {
|
"points": {
|
||||||
"type": "array", "maxItems": 3, "items": {"type": "string"},
|
"type": "array", "maxItems": 3, "items": {"type": "string"},
|
||||||
@@ -2959,9 +3146,13 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
|
|||||||
"properties": {
|
"properties": {
|
||||||
"start": {"type": "number"}, "end": {"type": "number"},
|
"start": {"type": "number"}, "end": {"type": "number"},
|
||||||
"stage": {"type": "string", "description": "Hook / 过桥 / 正文 / CTA"},
|
"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": {
|
"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:
|
def get_creation_chat_model(requested: ModelConfig | None = None) -> ModelConfig | None:
|
||||||
"""全能创作编排固定优先 Seed 2.1 Pro;显式传入的可用模型仍尊重用户选择。"""
|
"""全能创作的语言编排只允许 GPT-6 Luna,绝不回退到豆包或后台默认模型。"""
|
||||||
if requested is not None:
|
if (
|
||||||
return resolve_text_model(requested)
|
requested is not None
|
||||||
return get_seed_text_model() or resolve_text_model(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:
|
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:
|
if getattr(model_config, "capability", "") == ModelConfig.Capability.VISION:
|
||||||
return True
|
return True
|
||||||
name = str(getattr(model_config, "name", "") or "").lower()
|
name = str(getattr(model_config, "name", "") or "").lower()
|
||||||
# 豆包 Seed 2.x / 1.6 文本档都支持图文;vl / vision 后缀同理。
|
# 全能创作指定的 GPT-6 Luna 与 gpt-image-2 共用 YunQi 网关,走 chat/completions
|
||||||
if name.startswith("doubao-seed-") or "vision" in name or name.endswith("-vl") or "-vl-" in name:
|
# 时也接收 OpenAI image_url 内容;不能因后台能力栏标作 text 又切回豆包。
|
||||||
|
if name == CREATION_CHAT_MODEL_NAME:
|
||||||
return True
|
return True
|
||||||
metadata = model_config.metadata if isinstance(getattr(model_config, "metadata", None), dict) else {}
|
metadata = model_config.metadata if isinstance(getattr(model_config, "metadata", None), dict) else {}
|
||||||
capabilities = metadata.get("capabilities") if isinstance(metadata.get("capabilities"), 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:
|
def _prefer_vision_text_model(current: ModelConfig | None, team, refs: list | None) -> ModelConfig | None:
|
||||||
"""有参考图时,尽量换成能看图的文本模型(豆包 Seed 等),否则聊天侧完全看不见男女。"""
|
"""参考图也继续交给 Luna;全能创作不得为了视觉能力暗中切换到豆包。"""
|
||||||
if current is not None and _creation_model_sees_images(current):
|
|
||||||
return 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
|
|
||||||
|
|
||||||
|
|
||||||
def _ref_image_urls(team, refs: list | None) -> list[str]:
|
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 推进;禁止再打开上一轮模特/商品库追问。",
|
" 先短确认,再按最初 brief/已钉素材从澄清或 write_strategy 推进;禁止再打开上一轮模特/商品库追问。",
|
||||||
"- 用户选择暂不提供某项素材时,把它当成明确授权:按已有信息和合理默认继续。除非任务客观上无法完成,否则不要再次追问同一素材。",
|
"- 用户选择暂不提供某项素材时,把它当成明确授权:按已有信息和合理默认继续。除非任务客观上无法完成,否则不要再次追问同一素材。",
|
||||||
"- 用户说「你来定」「你帮我选」「随便」「都行」时,就是授权你做专业判断;直接选合理方案继续,不要把选择题再抛回去。",
|
"- 用户说「你来定」「你帮我选」「随便」「都行」时,就是授权你做专业判断;直接选合理方案继续,不要把选择题再抛回去。",
|
||||||
"- 禁止问「要不要继续」「要不要生成」「是否开始创作」这类流程问题。缺信息用 ask_user;信息够了就写策略。"
|
"- 禁止问「要不要继续」「要不要生成」「是否开始创作」这类流程问题。缺信息用 ask_user;信息够了内部整理策略并直接写视频架构。"
|
||||||
" 视频每写完策略或方案,平台会出确认卡;方案确认后平台会在后台整理出片指令,再让用户核对生成参数。不要口头问流程。",
|
" 视频架构写完后平台会出确认卡;架构确认后平台会在后台整理出片 Prompt,再让用户核对生成参数。不要口头问流程。",
|
||||||
"- 用户打招呼或闲聊(hi / 你好 / 在吗 / 你在干什么 / 嗯 / 好的 / ok):"
|
"- 用户打招呼或闲聊(hi / 你好 / 在吗 / 你在干什么 / 嗯 / 好的 / ok):"
|
||||||
" **禁止**调用 write_strategy、write_plan、generate_image,不要「整理方案」或直接开写脚本。",
|
" **禁止**调用 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 只补短规则,不再扩写逐镜。"
|
"全文约 800–1400 字;禁止把 15 秒短片的制作级长文整篇套用。用户确认方案后,write_prompt 只补短规则,不再扩写逐镜。"
|
||||||
)
|
)
|
||||||
lines.append(
|
lines.append(
|
||||||
"- 视频 5 步闸门(不可同轮连跳):①缺信息 ask_user 停 → ②write_strategy 停等确认 → "
|
"- 视频 4 步闸门:①缺信息 ask_user 停 → ②write_strategy 仅内部梳理并同轮 write_plan,视频架构停等确认 → "
|
||||||
"③用户确认后 write_plan 停等确认 → ④方案确认后展示完整出片指令并停等确认 → ⑤再显示积分确认卡。"
|
"③架构确认后展示完整出片 Prompt 并停等确认 → ④角色补全后显示参数与积分确认卡。"
|
||||||
)
|
)
|
||||||
lines.append(
|
lines.append(
|
||||||
"- 只有用户明确要做片、出方案、改方案、换卖点/剧情时才调用 write_strategy / write_plan;"
|
"- 只有用户明确要做片、出方案、改方案、换卖点/剧情时才调用 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"))
|
strategy_confirmed = bool(memory.get("strategy_confirmed"))
|
||||||
stage_hint = {
|
stage_hint = {
|
||||||
"clarify": "当前阶段=澄清:缺关键信息就 ask_user;信息够了只调 write_strategy。",
|
"clarify": "当前阶段=澄清:缺关键信息就 ask_user;信息够了先调内部 write_strategy,再同轮调 write_plan。",
|
||||||
"strategy": (
|
"strategy": (
|
||||||
"当前阶段=策略已确认,请调用 write_plan 写方案;不要再写策略。"
|
"当前阶段=内部策略已完成,请调用 write_plan 写视频架构;不要再写策略。"
|
||||||
if strategy_confirmed else
|
if strategy_confirmed else
|
||||||
"当前阶段=等策略确认:不要再写方案或出片;用户确认后才会进入方案。若用户在改策略,只重调 write_strategy。"
|
"当前阶段=内部策略整理中:立即调用 write_plan 输出视频架构,不要等待策略确认。"
|
||||||
),
|
),
|
||||||
"plan": "当前阶段=等方案确认:不要出片。若用户在改方案,只重调 write_plan。",
|
"plan": "当前阶段=等视频架构确认:不要出片。若用户在改架构,只重调 write_plan,保留未受影响段落。",
|
||||||
"prompt": "当前阶段=兼容历史会话的出片指令确认:不要出片,按用户反馈重写内部出片指令。",
|
"prompt": "当前阶段=兼容历史会话的出片指令确认:不要出片,按用户反馈重写内部出片指令。",
|
||||||
"confirm": "当前阶段=等出片确认:不要再写策略/方案;用户会在确认卡上点开始生成。",
|
"confirm": "当前阶段=等出片确认:不要再写策略/方案;用户会在确认卡上点开始生成。",
|
||||||
"done": "当前阶段=已出片:等用户新的修改或新需求再行动。用户说重新来/重来/从头开始时,当作新一轮创作,从澄清或 write_strategy 重开,不要再弹旧模特/商品追问。",
|
"done": "当前阶段=已出片:等用户新的修改或新需求再行动。用户说重新来/重来/从头开始时,当作新一轮创作,从澄清或 write_strategy 重开,不要再弹旧模特/商品追问。",
|
||||||
@@ -4326,6 +4524,18 @@ def _coerce_timeline(raw) -> list[dict]:
|
|||||||
desc = str(item.get("desc") or item.get("description") or "").strip()
|
desc = str(item.get("desc") or item.get("description") or "").strip()
|
||||||
if desc:
|
if desc:
|
||||||
entry["desc"] = 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)
|
items.append(entry)
|
||||||
return items
|
return items
|
||||||
|
|
||||||
@@ -4436,6 +4646,9 @@ def _coerce_plan_card_args(args: dict) -> dict:
|
|||||||
if not isinstance(matrix, dict) or not matrix.get("rows"):
|
if not isinstance(matrix, dict) or not matrix.get("rows"):
|
||||||
matrix = _default_plan_matrix(usp, points) if (usp or points) else {}
|
matrix = _default_plan_matrix(usp, points) if (usp or points) else {}
|
||||||
return {
|
return {
|
||||||
|
"goal": _pick_str(args, "goal", "目标", "video_goal"),
|
||||||
|
"duration": _pick_str(args, "duration", "时长", "target_duration"),
|
||||||
|
"concept": _pick_str(args, "concept", "创意概念", "idea"),
|
||||||
"usp": usp,
|
"usp": usp,
|
||||||
"points": points,
|
"points": points,
|
||||||
"timeline": timeline,
|
"timeline": timeline,
|
||||||
@@ -4636,18 +4849,7 @@ def iter_creation_agent_events(
|
|||||||
yield {"type": "done"}
|
yield {"type": "done"}
|
||||||
return
|
return
|
||||||
|
|
||||||
# 0. 本地上传商品图优先确认品牌与具体品名
|
# 剧情带货的第一个必经步骤永远是选时长;之后才核对商品与剧情方向。
|
||||||
if product_info_needs_confirmation(conversation, text):
|
|
||||||
question = append_product_info_gate(conversation)
|
|
||||||
set_video_gate_stage(conversation, "clarify")
|
|
||||||
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
|
|
||||||
conversation.save(update_fields=["agent_status", "updated_at"])
|
|
||||||
yield {"type": "message", "message": _message_payload(question)}
|
|
||||||
yield {"type": "done"}
|
|
||||||
return
|
|
||||||
|
|
||||||
# 剧情反转预设必须先选故事深度。平台直接落选择卡,不能把这一步交给模型猜,
|
|
||||||
# 否则默认 15 秒会吞掉 30/60 秒该有的人物关系和冲突发展。
|
|
||||||
if is_plot_twist_conversation(conversation) and not active_plot_twist_story_depth(conversation):
|
if is_plot_twist_conversation(conversation) and not active_plot_twist_story_depth(conversation):
|
||||||
explicit_depth = plot_twist_story_depth(text)
|
explicit_depth = plot_twist_story_depth(text)
|
||||||
if explicit_depth is not None and explicit_depth["value"] != "smart":
|
if explicit_depth is not None and explicit_depth["value"] != "smart":
|
||||||
@@ -4660,6 +4862,16 @@ def iter_creation_agent_events(
|
|||||||
yield {"type": "done"}
|
yield {"type": "done"}
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# 0. 本地上传商品图优先确认品牌与具体品名
|
||||||
|
if product_info_needs_confirmation(conversation, text):
|
||||||
|
question = append_product_info_gate(conversation)
|
||||||
|
set_video_gate_stage(conversation, "clarify")
|
||||||
|
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
|
||||||
|
conversation.save(update_fields=["agent_status", "updated_at"])
|
||||||
|
yield {"type": "message", "message": _message_payload(question)}
|
||||||
|
yield {"type": "done"}
|
||||||
|
return
|
||||||
|
|
||||||
# 商品类创作先锁定结构化商品。普通上传始终只是素材,不能靠图片内容猜成商品。
|
# 商品类创作先锁定结构化商品。普通上传始终只是素材,不能靠图片内容猜成商品。
|
||||||
if creation_needs_product_source(conversation, text):
|
if creation_needs_product_source(conversation, text):
|
||||||
result, _stop = _dispatch_tool(
|
result, _stop = _dispatch_tool(
|
||||||
@@ -4674,12 +4886,31 @@ def iter_creation_agent_events(
|
|||||||
}]},
|
}]},
|
||||||
allow_pick=False,
|
allow_pick=False,
|
||||||
)
|
)
|
||||||
|
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
|
||||||
|
conversation.save(update_fields=["agent_status", "updated_at"])
|
||||||
for event in result.get("_events", []):
|
for event in result.get("_events", []):
|
||||||
yield event
|
yield event
|
||||||
yield {"type": "done"}
|
yield {"type": "done"}
|
||||||
return
|
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):
|
if click_swap_needs_sequence(conversation):
|
||||||
question = append_click_swap_sequence_gate(conversation)
|
question = append_click_swap_sequence_gate(conversation)
|
||||||
set_video_gate_stage(conversation, "clarify")
|
set_video_gate_stage(conversation, "clarify")
|
||||||
@@ -4689,28 +4920,6 @@ def iter_creation_agent_events(
|
|||||||
yield {"type": "done"}
|
yield {"type": "done"}
|
||||||
return
|
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)
|
requested_card = requested_asset_card_from_context(conversation, text)
|
||||||
if requested_card:
|
if requested_card:
|
||||||
@@ -4789,6 +4998,7 @@ def iter_creation_agent_events(
|
|||||||
model=model_config.name,
|
model=model_config.name,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
endpoint=model_config.endpoint or "chat/completions",
|
endpoint=model_config.endpoint or "chat/completions",
|
||||||
|
temperature=creation_model_temperature(model_config),
|
||||||
extra_body=extra_body,
|
extra_body=extra_body,
|
||||||
timeout=remaining,
|
timeout=remaining,
|
||||||
):
|
):
|
||||||
@@ -5480,16 +5690,6 @@ def _dispatch_tool(
|
|||||||
"payload": {"asked": True, "field": "sku_sequence"},
|
"payload": {"asked": True, "field": "sku_sequence"},
|
||||||
"_events": [{"type": "message", "message": _message_payload(gate)}],
|
"_events": [{"type": "message", "message": _message_payload(gate)}],
|
||||||
}, True
|
}, 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 {}
|
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):
|
if context.is_video and is_plot_twist_conversation(context.conversation) and not plot_twist_selected_direction(context.conversation):
|
||||||
return {
|
return {
|
||||||
@@ -5532,25 +5732,21 @@ def _dispatch_tool(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}, False
|
}, False
|
||||||
message = append_message(
|
# 策略只作为 GPT 的内部工作记忆,不再给用户多放一张模糊策略卡。
|
||||||
context.conversation, role="assistant",
|
# 同轮继续 write_plan,页面只展示一张可编辑的「视频架构」。
|
||||||
kind=CreationMessage.Kind.STRATEGY,
|
|
||||||
payload=strategy_payload,
|
|
||||||
)
|
|
||||||
confirm = append_step_confirm(context.conversation, "strategy")
|
|
||||||
set_video_gate_stage(context.conversation, "strategy")
|
set_video_gate_stage(context.conversation, "strategy")
|
||||||
memory = dict(context.conversation.memory or {})
|
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.memory = memory
|
||||||
context.conversation.save(update_fields=["memory", "updated_at"])
|
context.conversation.save(update_fields=["memory", "updated_at"])
|
||||||
# 策略闸门:必须停下等人确认,禁止同轮连写方案
|
|
||||||
return {
|
return {
|
||||||
"payload": {"written": True, "awaiting_step": "strategy"},
|
"payload": {
|
||||||
"_events": [
|
"written": True,
|
||||||
{"type": "message", "message": _message_payload(message)},
|
"internal_only": True,
|
||||||
{"type": "message", "message": _message_payload(confirm)},
|
"next": "请立即调用 write_plan,输出唯一可见的视频架构卡。",
|
||||||
],
|
},
|
||||||
}, True
|
}, False
|
||||||
|
|
||||||
if name == "write_plan":
|
if name == "write_plan":
|
||||||
if context.is_video and click_swap_needs_mode(context.conversation):
|
if context.is_video and click_swap_needs_mode(context.conversation):
|
||||||
@@ -5570,13 +5766,6 @@ def _dispatch_tool(
|
|||||||
"payload": {"asked": True, "field": "sku_sequence"},
|
"payload": {"asked": True, "field": "sku_sequence"},
|
||||||
"_events": [{"type": "message", "message": _message_payload(gate)}],
|
"_events": [{"type": "message", "message": _message_payload(gate)}],
|
||||||
}, True
|
}, 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))
|
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):
|
if is_click_swap_preset(context.conversation.preset):
|
||||||
video_prompt = (
|
video_prompt = (
|
||||||
@@ -5668,6 +5857,9 @@ def _dispatch_tool(
|
|||||||
lo = max(20, round(duration * 3.4))
|
lo = max(20, round(duration * 3.4))
|
||||||
hi = max(lo + 1, round(duration * 4))
|
hi = max(lo + 1, round(duration * 4))
|
||||||
plan_payload = {
|
plan_payload = {
|
||||||
|
"goal": card["goal"],
|
||||||
|
"duration": card["duration"] or str((context.conversation.params or {}).get("duration") or ""),
|
||||||
|
"concept": card["concept"],
|
||||||
"usp": card["usp"],
|
"usp": card["usp"],
|
||||||
"points": card["points"],
|
"points": card["points"],
|
||||||
"timeline": card["timeline"],
|
"timeline": card["timeline"],
|
||||||
@@ -5826,6 +6018,7 @@ def compress_memory(context: AgentContext) -> None:
|
|||||||
model=context.model_config.name,
|
model=context.model_config.name,
|
||||||
messages=[{"role": "user", "content": instruction}],
|
messages=[{"role": "user", "content": instruction}],
|
||||||
endpoint=context.model_config.endpoint or "chat/completions",
|
endpoint=context.model_config.endpoint or "chat/completions",
|
||||||
|
temperature=creation_model_temperature(context.model_config),
|
||||||
):
|
):
|
||||||
if chunk.get("type") == "delta":
|
if chunk.get("type") == "delta":
|
||||||
pieces.append(chunk.get("text", ""))
|
pieces.append(chunk.get("text", ""))
|
||||||
|
|||||||
@@ -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 = {
|
_COVER_SLOTS = {
|
||||||
"hero": "正面或四分之三角度的商品 / 上身主视觉,主体占画面 60%-80%,纯净干净背景,经典电商主图构图,无文案",
|
"hero": "主图:正面或四分之三角度的商品 / 上身主视觉,主体占画面 60%-80%,纯净干净背景,经典电商主图构图,无文案",
|
||||||
"scene": "场景主视觉:挂拍 / 衣架 / 生活场景 / 手部整理 / 使用情境,环境自然光,氛围感,无文案或仅 1 个极短标题",
|
"selling": "卖点图:商品主体居中或偏置,最多 1 个短标题加 1-2 个极短标签,文字区克制,不堆参数",
|
||||||
"selling": "轻卖点封面:商品主体居中或偏置,最多 1 个短标题加 1-2 个极短标签,文字区克制,不堆参数",
|
"detail": "细节图:放大材质 / 做工 / 关键结构(扣位 / 肩带 / 边缘走线 / 质地),近景视角,突出质感,无文案或 1 个短标签",
|
||||||
"detail": "质感特写:放大材质 / 做工 / 关键结构(扣位 / 肩带 / 边缘走线),近景视角,突出质感,无文案或 1 个短标签",
|
"scene": "场景图:挂拍 / 衣架 / 生活场景 / 手部整理 / 使用情境,环境自然光,氛围感,无文案或仅 1 个极短标题",
|
||||||
"multi": "多角度组合:商品换一个朝向 / 视角,几何分区干净背景,现代简约风,无文案",
|
"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 头图低信息密度上限:所有平台 / 所有模型都必须遵守,防止漂成详情页。
|
# §3.1 / §8 头图低信息密度上限:所有平台 / 所有模型都必须遵守,防止漂成详情页。
|
||||||
_COVER_LOW_DENSITY = (
|
_COVER_LOW_DENSITY = (
|
||||||
@@ -1999,12 +2047,17 @@ def build_platform_cover_prompt_refs(
|
|||||||
index: int = 0,
|
index: int = 0,
|
||||||
count: int = 4, # noqa: ARG001 — 透传保留,后续可据张数扩展 slot 选择
|
count: int = 4, # noqa: ARG001 — 透传保留,后续可据张数扩展 slot 选择
|
||||||
product_ref_count: int = 1,
|
product_ref_count: int = 1,
|
||||||
|
product_info: dict | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""平台套图 image_edit 提示词(refs 优化版,对照「平台套图线上提示词优化版.md」):
|
"""平台套图(电商主图套图)image_edit 提示词:
|
||||||
参考图1~N=同一件真实商品(锁外形/品牌/配色/Logo/比例),有模特时参考图N+1=出镜模特。
|
参考图1~N=同一件真实商品(锁外形/品牌/配色/Logo/比例),有模特时参考图N+1=出镜模特。
|
||||||
按 `platform_id` 注入平台块、按 `index` 选 slot 版式,统一服从「头图低信息密度」与「背景反差」,
|
`product_info`(用户在页面填写的核心卖点 / 商品作用 / 适用人群 / 规格 / 补充要求)作为本组图的卖点依据注入,
|
||||||
内衣 + 有模特时叠加强约束。只出头图 / 主图 / 封面候选,不再出详情。"""
|
并按 slot 分配到卖点图 / 细节图 / 场景图;未传时回落为把 `base_prompt` 当作商品信息 / 画面要求(旧记录兼容)。
|
||||||
|
`platform_id` 已改为可选:传了(旧记录 / 旧调用方)仍注入平台块,不传则用通用电商主图规范。
|
||||||
|
统一服从「头图低信息密度」与「背景反差」,内衣 + 有模特时叠加强约束。只出主图 / 卖点 / 细节 / 场景,不出详情。"""
|
||||||
name = (getattr(product, "title", "") or "商品").strip()
|
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))
|
n = max(1, int(product_ref_count or 1))
|
||||||
if n <= 1:
|
if n <= 1:
|
||||||
ref_word = "参考图1"
|
ref_word = "参考图1"
|
||||||
@@ -2018,15 +2071,37 @@ def build_platform_cover_prompt_refs(
|
|||||||
f"请严格锁定{ref_word}中商品的外形、颜色、材质、结构、Logo、品牌文字与比例,"
|
f"请严格锁定{ref_word}中商品的外形、颜色、材质、结构、Logo、品牌文字与比例,"
|
||||||
"严禁重新设计 / 改样 / 改品类;这些参考图只锁商品本体,不锁原图里的床品 / 桌面 / 墙面 / 绿植 / 道具与拍摄光线。"
|
"严禁重新设计 / 改样 / 改品类;这些参考图只锁商品本体,不锁原图里的床品 / 桌面 / 墙面 / 绿植 / 道具与拍摄光线。"
|
||||||
)
|
)
|
||||||
# 平台块(canonical key 命中则用 §4 平台块,否则回落平台名 / 通用)
|
# 平台块(可选,旧记录兼容):canonical key 命中则用 §4 平台块;否则用通用电商主图规范
|
||||||
block = _PLATFORM_COVER_BLOCKS.get(platform_id)
|
block = _PLATFORM_COVER_BLOCKS.get(platform_id or "")
|
||||||
pname = _PLATFORM_NAMES.get(platform_id, "")
|
pname = _PLATFORM_NAMES.get(platform_id or "", "")
|
||||||
if block:
|
if block:
|
||||||
lines.append(block)
|
lines.append(block)
|
||||||
elif pname:
|
elif pname:
|
||||||
lines.append(f"请生成一张适合「{pname}」平台的商品头图 / 主图 / 封面候选,统一视觉风格。")
|
lines.append(f"请生成一张适合「{pname}」平台的商品头图 / 主图 / 封面候选,统一视觉风格。")
|
||||||
else:
|
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)
|
lines.append(_COVER_LOW_DENSITY)
|
||||||
# 模特身份 + 内衣强约束
|
# 模特身份 + 内衣强约束
|
||||||
@@ -2037,15 +2112,33 @@ def build_platform_cover_prompt_refs(
|
|||||||
)
|
)
|
||||||
if _is_underwear_product(product):
|
if _is_underwear_product(product):
|
||||||
lines.append(_UNDERWEAR_ON_MODEL)
|
lines.append(_UNDERWEAR_ON_MODEL)
|
||||||
# 本张 slot 版式
|
# 本张 slot 版式(主图 → 卖点图 → 细节图 → 场景图 → …),并把商品信息分配到对应图位
|
||||||
slot_key = _COVER_SLOT_ORDER[index % len(_COVER_SLOT_ORDER)]
|
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)
|
lines.append(_COVER_BG_CONTRAST)
|
||||||
if base_prompt and base_prompt.strip():
|
if info.get("notes"):
|
||||||
lines.append(
|
lines.append(
|
||||||
"用户补充(只影响氛围 / 构图 / 场景 / 光线 / 表达偏好,不得覆盖商品一致性、平台与版式规则):"
|
"用户补充要求(只影响氛围 / 构图 / 场景 / 光线 / 表达偏好,不得覆盖商品一致性与版式规则):"
|
||||||
+ base_prompt.strip()
|
+ info["notes"]
|
||||||
)
|
)
|
||||||
lines.append(_COVER_NEGATIVE)
|
lines.append(_COVER_NEGATIVE)
|
||||||
return " ".join(lines)
|
return " ".join(lines)
|
||||||
@@ -3533,7 +3626,7 @@ def _reap_stale_standalone_image_tasks(*, team) -> None:
|
|||||||
continue
|
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 请求里只做「建任务 +
|
"""独立生图(图片创作 / 模特上身图 / 平台套图)改为**异步**:本函数在 Web 请求里只做「建任务 +
|
||||||
预留额度」这种秒级的活,真正 ~30s 的 ARK 出图交给 Celery worker(generate_standalone_image_task)。
|
预留额度」这种秒级的活,真正 ~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),
|
status__in=(AITask.Status.FAILED, AITask.Status.CANCELLED),
|
||||||
).first()
|
).first()
|
||||||
retry_of_task_id = str(retry_of_task.id) if retry_of_task else None
|
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_key = str(platform_id or "").strip() if mode == "cover" else ""
|
||||||
platform_name = _PLATFORM_NAMES.get(platform_key, "")
|
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
|
from apps.billing.pricing import quote_flat
|
||||||
|
|
||||||
# Step 2.1:只为“模特上身图 + 商品”记录一次确定性分类快照。这里不读取图片、不调用模型;
|
# Step 2.1:只为“模特上身图 + 商品”记录一次确定性分类快照。这里不读取图片、不调用模型;
|
||||||
@@ -3647,8 +3743,12 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
|
|||||||
for index in range(count):
|
for index in range(count):
|
||||||
quote = quote_flat(model_config, team=team)
|
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}
|
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:
|
if feature:
|
||||||
request_payload["feature"] = str(feature)
|
request_payload["feature"] = str(feature)
|
||||||
|
if cover_product_info:
|
||||||
|
request_payload["product_info"] = dict(cover_product_info)
|
||||||
if use_model_routing:
|
if use_model_routing:
|
||||||
request_payload["model_routing_v1"] = True
|
request_payload["model_routing_v1"] = True
|
||||||
if tryon_classification is not None:
|
if tryon_classification is not None:
|
||||||
@@ -3707,6 +3807,9 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
|||||||
user = task.created_by
|
user = task.created_by
|
||||||
payload = dict(task.request_payload or {})
|
payload = dict(task.request_payload or {})
|
||||||
prompt = str(payload.get("prompt") 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")
|
mode = str(payload.get("mode") or "image")
|
||||||
index = int(payload.get("index") or 0)
|
index = int(payload.get("index") or 0)
|
||||||
product_id = payload.get("product_id") or None
|
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:
|
elif mode == "cover" and product_urls:
|
||||||
# 平台套图:参考图1~N=商品真实图(多角度,_product_reference_urls 已优先真实上传图/排除 AI 图),
|
# 平台套图:参考图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_images = product_urls + ([model_url] if model_url else [])
|
||||||
edit_prompt = build_platform_cover_prompt_refs(
|
edit_prompt = build_platform_cover_prompt_refs(
|
||||||
product,
|
product,
|
||||||
@@ -3810,6 +3913,7 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
|||||||
platform_id=str(payload.get("platform_id") or ""),
|
platform_id=str(payload.get("platform_id") or ""),
|
||||||
index=index,
|
index=index,
|
||||||
product_ref_count=len(product_urls),
|
product_ref_count=len(product_urls),
|
||||||
|
product_info=payload.get("product_info") or None,
|
||||||
)
|
)
|
||||||
elif bool(payload.get("reference_product")) and product_url:
|
elif bool(payload.get("reference_product")) and product_url:
|
||||||
edit_images = [product_url]
|
edit_images = [product_url]
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ from .creation_agent import (
|
|||||||
TRUNCATION_GIVE_UP_NOTICE,
|
TRUNCATION_GIVE_UP_NOTICE,
|
||||||
creation_agent_max_output_tokens,
|
creation_agent_max_output_tokens,
|
||||||
creation_model_extra_body,
|
creation_model_extra_body,
|
||||||
|
creation_model_temperature,
|
||||||
creation_agent_timeout_notice,
|
creation_agent_timeout_notice,
|
||||||
long_video_script_covers_requested_duration,
|
long_video_script_covers_requested_duration,
|
||||||
plan_video_segments,
|
plan_video_segments,
|
||||||
@@ -136,6 +137,10 @@ class CreationAgentBaseTests(TestCase):
|
|||||||
provider=provider, name="fake-text", display_name="Fake Text",
|
provider=provider, name="fake-text", display_name="Fake Text",
|
||||||
capability=ModelConfig.Capability.TEXT, endpoint="chat/completions",
|
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(
|
self.conversation = CreationConversation.objects.create(
|
||||||
team=self.team, created_by=self.user, mode="image", title="出图",
|
team=self.team, created_by=self.user, mode="image", title="出图",
|
||||||
params={"ratio": "1:1", "model": "Seedream5.0"},
|
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 = [ref for ref in (self.conversation.pinned_refs or []) if isinstance(ref, dict)]
|
||||||
refs.append({"type": "product", "id": str(product.id), "name": title})
|
refs.append({"type": "product", "id": str(product.id), "name": title})
|
||||||
self.conversation.pinned_refs = refs
|
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
|
return product
|
||||||
|
|
||||||
|
|
||||||
@@ -900,6 +908,112 @@ class SendEndpointTests(TestCase):
|
|||||||
for ref in self.conversation.pinned_refs
|
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):
|
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_owner = User.objects.create_user(username="official-model-owner", password="p")
|
||||||
official_team = Team.objects.create(name="Official Models", owner=official_owner)
|
official_team = Team.objects.create(name="Official Models", owner=official_owner)
|
||||||
@@ -1520,7 +1634,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
|||||||
self.conversation.refresh_from_db()
|
self.conversation.refresh_from_db()
|
||||||
self.assertEqual(self.conversation.params["duration"], "15 秒")
|
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.preset = "剧情反转带货"
|
||||||
self.conversation.params = {**self.conversation.params, "duration": "智能时长"}
|
self.conversation.params = {**self.conversation.params, "duration": "智能时长"}
|
||||||
self.conversation.memory = {}
|
self.conversation.memory = {}
|
||||||
@@ -1543,12 +1657,65 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
|||||||
self.assertEqual(question["payload"].get("interaction"), "plot_twist_story_depth")
|
self.assertEqual(question["payload"].get("interaction"), "plot_twist_story_depth")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[option["value"] for option in question["payload"]["fields"][0]["options"]],
|
[option["value"] for option in question["payload"]["fields"][0]["options"]],
|
||||||
["15s", "30s", "60s", "180s", "smart"],
|
["15s", "30s", "60s"],
|
||||||
)
|
)
|
||||||
self.conversation.refresh_from_db()
|
self.conversation.refresh_from_db()
|
||||||
self.assertEqual(self.conversation.agent_status, "awaiting_user")
|
self.assertEqual(self.conversation.agent_status, "awaiting_user")
|
||||||
self.assertEqual(fake.calls, [])
|
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):
|
def test_plot_twist_depth_syncs_duration_and_is_written_into_final_prompt(self):
|
||||||
self.conversation.preset = "剧情反转带货"
|
self.conversation.preset = "剧情反转带货"
|
||||||
self.conversation.save(update_fields=["preset", "updated_at"])
|
self.conversation.save(update_fields=["preset", "updated_at"])
|
||||||
@@ -2139,7 +2306,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
|||||||
self.assertIn("还没写完", errors[0])
|
self.assertIn("还没写完", errors[0])
|
||||||
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._pin_product("三色通勤包")
|
||||||
self.conversation.preset = "点击换款"
|
self.conversation.preset = "点击换款"
|
||||||
self.conversation.memory = {}
|
self.conversation.memory = {}
|
||||||
@@ -2158,8 +2325,8 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
|||||||
event["message"] for event in events
|
event["message"] for event in events
|
||||||
if event.get("type") == "message" and event["message"]["kind"] == "elicit"
|
if event.get("type") == "message" and event["message"]["kind"] == "elicit"
|
||||||
)
|
)
|
||||||
self.assertEqual(gate["payload"].get("interaction"), "click_swap_sku_gate")
|
self.assertEqual(gate["payload"].get("interaction"), "click_swap_mode_gate")
|
||||||
self.assertEqual(gate["payload"]["fields"][0]["key"], "sku_sequence")
|
self.assertEqual(gate["payload"]["fields"][0]["key"], "click_swap_mode")
|
||||||
self.assertEqual(fake.calls, [])
|
self.assertEqual(fake.calls, [])
|
||||||
|
|
||||||
def test_click_swap_plan_overrides_conflicting_generic_script(self):
|
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((self.conversation.memory or {}).get("stage"), "prompt")
|
||||||
self.assertEqual(len(fake.calls), 1)
|
self.assertEqual(len(fake.calls), 1)
|
||||||
|
|
||||||
def test_strategy_and_plan_same_round_stops_after_strategy(self):
|
def test_strategy_and_plan_same_round_only_exposes_video_architecture(self):
|
||||||
"""模型若同轮连调 write_strategy+write_plan,只落策略闸门。"""
|
"""内部策略不落用户卡,同轮继续交付唯一可见的视频架构。"""
|
||||||
from apps.ai.creation_agent import _parse_arguments # noqa: F401
|
from apps.ai.creation_agent import _parse_arguments # noqa: F401
|
||||||
|
|
||||||
self._pin_person()
|
self._pin_person()
|
||||||
@@ -2278,15 +2445,28 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
|||||||
events = _events(stream_creation_agent(conversation=self.conversation, user=self.user,
|
events = _events(stream_creation_agent(conversation=self.conversation, user=self.user,
|
||||||
text="做条视频", model_config=self.model))
|
text="做条视频", model_config=self.model))
|
||||||
kinds = [e["message"]["kind"] for e in events if e.get("type") == "message"]
|
kinds = [e["message"]["kind"] for e in events if e.get("type") == "message"]
|
||||||
self.assertIn("strategy", kinds)
|
self.assertNotIn("strategy", kinds)
|
||||||
self.assertNotIn("plan", kinds)
|
self.assertIn("plan", kinds)
|
||||||
self.assertEqual(len(fake.calls), 1)
|
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._pin_product("控油粉饼")
|
||||||
self.conversation.preset = "达人口播种草"
|
self.conversation.preset = "达人口播种草"
|
||||||
self.conversation.save(update_fields=["preset", "updated_at"])
|
self.conversation.memory = {
|
||||||
fake = FakeProvider([_text_chunks("这一轮不应调用模型")])
|
"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):
|
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
|
||||||
events = _events(stream_creation_agent(
|
events = _events(stream_creation_agent(
|
||||||
@@ -2296,18 +2476,15 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
|||||||
model_config=self.model,
|
model_config=self.model,
|
||||||
))
|
))
|
||||||
|
|
||||||
card = next(
|
kinds = [event["message"]["kind"] for event in events if event.get("type") == "message"]
|
||||||
event["message"] for event in events
|
self.assertIn("plan", kinds)
|
||||||
if event.get("type") == "message" and event["message"]["kind"] == "elicit"
|
self.assertNotIn("person_source_gate", [
|
||||||
)
|
event["message"].get("payload", {}).get("interaction")
|
||||||
self.assertEqual(card["payload"]["interaction"], "person_source_gate")
|
for event in events if event.get("type") == "message"
|
||||||
self.assertEqual(
|
])
|
||||||
[option["value"] for option in card["payload"]["fields"][0]["options"]],
|
self.assertEqual(len(fake.calls), 2)
|
||||||
["local_upload", "model_library", "platform_generate"],
|
|
||||||
)
|
|
||||||
self.assertEqual(fake.calls, [])
|
|
||||||
|
|
||||||
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="蓝牙耳机")
|
product = Product.objects.create(team=self.team, created_by=self.user, title="蓝牙耳机")
|
||||||
characters = [
|
characters = [
|
||||||
Asset.objects.create(
|
Asset.objects.create(
|
||||||
@@ -2328,8 +2505,14 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
|||||||
for character in characters
|
for character in characters
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
self.conversation.save(update_fields=["preset", "pinned_refs", "updated_at"])
|
self.conversation.memory = {"selling_point_ready": True, "selling_point_mode": "auto"}
|
||||||
fake = FakeProvider([_text_chunks("这一轮不应调用模型")])
|
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):
|
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
|
||||||
events = _events(stream_creation_agent(
|
events = _events(stream_creation_agent(
|
||||||
@@ -2342,43 +2525,14 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
|||||||
model_config=self.model,
|
model_config=self.model,
|
||||||
))
|
))
|
||||||
|
|
||||||
card = next(
|
kinds = [event["message"]["kind"] for event in events if event.get("type") == "message"]
|
||||||
event["message"] for event in events
|
self.assertIn("strategy", kinds)
|
||||||
if event.get("type") == "message" and event["message"]["kind"] == "elicit"
|
topics = [
|
||||||
)
|
event["message"].get("payload", {}).get("topic")
|
||||||
self.assertEqual(card["payload"]["topic"], "cast_relation")
|
for event in events if event.get("type") == "message"
|
||||||
self.assertIn("3 位角色,我都会保留", card["text"])
|
]
|
||||||
self.assertIn("共同出镜", card["text"])
|
self.assertNotIn("cast_relation", topics)
|
||||||
self.assertIn("点一位角色作为主讲", card["text"])
|
self.assertEqual(len(fake.calls), 1)
|
||||||
self.assertNotIn("选哪一位", card["text"])
|
|
||||||
self.assertEqual(card["payload"]["fields"][0]["type"], "single")
|
|
||||||
self.assertEqual(
|
|
||||||
[option["label"] for option in card["payload"]["fields"][0]["options"]],
|
|
||||||
["全部共同出镜", "南卡主讲", "红发男生主讲", "戴墨镜女生主讲"],
|
|
||||||
)
|
|
||||||
self.assertEqual(fake.calls, [])
|
|
||||||
|
|
||||||
follow_up = FakeProvider([_text_chunks("这个安排能做,我按三位角色继续整理。")])
|
|
||||||
with patch("apps.ai.creation_agent.build_provider", return_value=follow_up):
|
|
||||||
_events(stream_creation_agent(
|
|
||||||
conversation=self.conversation,
|
|
||||||
user=self.user,
|
|
||||||
text="南卡",
|
|
||||||
model_config=self.model,
|
|
||||||
))
|
|
||||||
|
|
||||||
self.conversation.refresh_from_db()
|
|
||||||
self.assertEqual(
|
|
||||||
(self.conversation.memory or {}).get("cast_relation"),
|
|
||||||
"南卡作为主讲,其余已锁定角色辅助出镜",
|
|
||||||
)
|
|
||||||
self.assertEqual(len(follow_up.calls), 1)
|
|
||||||
system = follow_up.calls[0]["messages"][0]["content"]
|
|
||||||
self.assertIn("这些角色默认都必须保留", system)
|
|
||||||
self.assertIn("南卡作为主讲,其余已锁定角色辅助出镜", system)
|
|
||||||
self.assertIn("不得再次询问同一问题", system)
|
|
||||||
self.assertIn("已添加多位角色时保留全部角色", system)
|
|
||||||
self.assertNotIn("固定一位可信的真人", system)
|
|
||||||
|
|
||||||
def test_commerce_preset_asks_brand_and_name_for_an_uploaded_product_image(self):
|
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"])
|
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):
|
def test_duration_change_returns_regenerate_instead_of_submitting(self):
|
||||||
self.conversation.params = {
|
self.conversation.params = {
|
||||||
"model": "Seedance 2.0 Fast", "resolution": "480p",
|
"model": "Seedance 2.0 Fast", "resolution": "480p",
|
||||||
@@ -2851,7 +3037,7 @@ class ConfirmEndpointTests(TestCase):
|
|||||||
self.card.refresh_from_db()
|
self.card.refresh_from_db()
|
||||||
self.assertTrue(self.card.payload["submitted"])
|
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 = {
|
self.conversation.params = {
|
||||||
"model": "Seedance 2.0 Fast", "resolution": "480p",
|
"model": "Seedance 2.0 Fast", "resolution": "480p",
|
||||||
"ratio": "1:1", "duration": "8 秒",
|
"ratio": "1:1", "duration": "8 秒",
|
||||||
@@ -2874,11 +3060,11 @@ class ConfirmEndpointTests(TestCase):
|
|||||||
"resolution": "720p", "ratio": "1:1"}},
|
"resolution": "720p", "ratio": "1:1"}},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 201)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertFalse(response.json().get("regenerate"))
|
self.assertTrue(response.json().get("regenerate"))
|
||||||
self.assertEqual(submit.call_args.kwargs["params"]["model"], "doubao-seedance-2-5-260628")
|
submit.assert_not_called()
|
||||||
self.assertEqual(submit.call_args.kwargs["params"]["resolution"], "720p")
|
self.conversation.refresh_from_db()
|
||||||
self.assertEqual(submit.call_args.kwargs["params"]["duration"], 8)
|
self.assertEqual((self.conversation.memory or {}).get("stage"), "strategy")
|
||||||
|
|
||||||
def test_confirm_ignores_duration_spacing_when_model_changes(self):
|
def test_confirm_ignores_duration_spacing_when_model_changes(self):
|
||||||
self.conversation.params = {
|
self.conversation.params = {
|
||||||
@@ -2903,9 +3089,9 @@ class ConfirmEndpointTests(TestCase):
|
|||||||
"resolution": "720p", "ratio": "9:16"}},
|
"resolution": "720p", "ratio": "9:16"}},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 201)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertFalse(response.json().get("regenerate"))
|
self.assertTrue(response.json().get("regenerate"))
|
||||||
submit.assert_called_once()
|
submit.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
class MemoryCompressionTests(CreationAgentBaseTests):
|
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))
|
blob = " ".join(str(item.get("text") or "") for item in content if isinstance(item, dict))
|
||||||
self.assertIn("性别", blob)
|
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):
|
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)
|
context = AgentContext(conversation=self.conversation, user=self.user, model_config=self.model)
|
||||||
messages = build_messages(context)
|
messages = build_messages(context)
|
||||||
for message in messages:
|
for message in messages:
|
||||||
@@ -3338,19 +3538,31 @@ class ChatVisionTests(CreationAgentBaseTests):
|
|||||||
|
|
||||||
|
|
||||||
class CreationChatModelTests(CreationAgentBaseTests):
|
class CreationChatModelTests(CreationAgentBaseTests):
|
||||||
def test_prefers_seed_21_as_chat_model(self):
|
def test_prefers_gpt_6_luna_as_chat_model(self):
|
||||||
self.model.is_default = False
|
luna = self.creation_model
|
||||||
self.model.save(update_fields=["is_default"])
|
doubao = ModelConfig.objects.create(
|
||||||
seed = ModelConfig.objects.create(
|
provider=self.model.provider, name="doubao-seed-2-0-pro",
|
||||||
provider=self.model.provider, name="doubao-seed-2-1-pro-260628",
|
display_name="Doubao", capability=ModelConfig.Capability.TEXT,
|
||||||
display_name="Doubao-Seed-2.1-Pro",
|
endpoint="chat/completions", is_default=True,
|
||||||
capability=ModelConfig.Capability.TEXT, endpoint="chat/completions",
|
|
||||||
is_default=True,
|
|
||||||
)
|
)
|
||||||
picked = get_creation_chat_model(None)
|
picked = get_creation_chat_model(None)
|
||||||
# 测试迁移已经可能种过同名 Seed 2.1,重点是编排层选到该模型家族,
|
self.assertEqual(picked.pk, luna.pk)
|
||||||
# 不是强行命中本测试后建的重复记录。
|
self.assertEqual(get_creation_chat_model(doubao).pk, luna.pk)
|
||||||
self.assertEqual(picked.name, seed.name)
|
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):
|
class PureChitchatTests(SimpleTestCase):
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ class StandaloneSingleImageRoutingTests(TestCase):
|
|||||||
product.save(update_fields=["cover_asset"])
|
product.save(update_fields=["cover_asset"])
|
||||||
return product
|
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()
|
product = self.platform_product()
|
||||||
return enqueue_standalone_images(
|
return enqueue_standalone_images(
|
||||||
team=self.team,
|
team=self.team,
|
||||||
@@ -213,6 +213,7 @@ class StandaloneSingleImageRoutingTests(TestCase):
|
|||||||
product_id=str(product.id),
|
product_id=str(product.id),
|
||||||
ratio=ratio,
|
ratio=ratio,
|
||||||
platform_id=platform_id,
|
platform_id=platform_id,
|
||||||
|
product_info=product_info,
|
||||||
image_model=f"{primary.provider.name}:{primary.name}",
|
image_model=f"{primary.provider.name}:{primary.name}",
|
||||||
dispatch=False,
|
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.RESERVE), 1)
|
||||||
self.assertEqual(self.ledger_count(task, CreditLedger.Type.CHARGE), 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):
|
def test_platform_kit_selected_seedream_stays_primary(self):
|
||||||
primary = self.model(
|
primary = self.model(
|
||||||
self.provider("volcano", 10),
|
self.provider("volcano", 10),
|
||||||
|
|||||||
+158
-32
@@ -40,6 +40,7 @@ from .creation_agent import (
|
|||||||
_RESTART_CONTINUATION,
|
_RESTART_CONTINUATION,
|
||||||
apply_cast_relation_choice,
|
apply_cast_relation_choice,
|
||||||
apply_click_swap_mode,
|
apply_click_swap_mode,
|
||||||
|
append_multi_character_relation_gate,
|
||||||
append_person_source_gate,
|
append_person_source_gate,
|
||||||
apply_pain_point_direction,
|
apply_pain_point_direction,
|
||||||
apply_confirm_params,
|
apply_confirm_params,
|
||||||
@@ -47,22 +48,27 @@ from .creation_agent import (
|
|||||||
apply_session_params,
|
apply_session_params,
|
||||||
emit_prompt_gate,
|
emit_prompt_gate,
|
||||||
emit_final_confirm_gate,
|
emit_final_confirm_gate,
|
||||||
|
insufficient_cast_refs_message,
|
||||||
locked_product_references,
|
locked_product_references,
|
||||||
set_plot_twist_story_depth,
|
set_plot_twist_story_depth,
|
||||||
is_greeting,
|
is_greeting,
|
||||||
is_pain_point_conversation,
|
is_pain_point_conversation,
|
||||||
is_pain_point_direction_payload,
|
is_pain_point_direction_payload,
|
||||||
is_restart_intent,
|
is_restart_intent,
|
||||||
|
multi_character_relation_needs_clarification,
|
||||||
restore_gated_step_after_cancel,
|
restore_gated_step_after_cancel,
|
||||||
|
get_video_gate_stage,
|
||||||
set_video_gate_stage,
|
set_video_gate_stage,
|
||||||
|
sync_prompt_after_cast,
|
||||||
submit_confirmed_image,
|
submit_confirmed_image,
|
||||||
submit_confirmed_video,
|
submit_confirmed_video,
|
||||||
submit_generated_person_reference,
|
submit_generated_person_reference,
|
||||||
|
video_needs_person_source,
|
||||||
is_incomplete_product_brand_answer,
|
is_incomplete_product_brand_answer,
|
||||||
PRODUCT_BRAND_EMPTY_TEMPLATE,
|
PRODUCT_BRAND_EMPTY_TEMPLATE,
|
||||||
)
|
)
|
||||||
from .tasks import run_creation_agent_turn_task
|
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 .models import AITask, CreationConversation, CreationMessage, ImageConversation, ModelConfig
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
AITaskSerializer,
|
AITaskSerializer,
|
||||||
@@ -73,7 +79,7 @@ from .serializers import (
|
|||||||
ImageConversationTrashSerializer,
|
ImageConversationTrashSerializer,
|
||||||
ModelConfigSerializer,
|
ModelConfigSerializer,
|
||||||
)
|
)
|
||||||
from .services import enqueue_standalone_images
|
from .services import enqueue_standalone_images, normalize_cover_product_info
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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(
|
def _handle_step_confirm_answer(
|
||||||
conversation: CreationConversation,
|
conversation: CreationConversation,
|
||||||
*,
|
*,
|
||||||
@@ -497,20 +541,10 @@ def _handle_step_confirm_answer(
|
|||||||
return JsonResponse(body, status=200), False, ""
|
return JsonResponse(body, status=200), False, ""
|
||||||
|
|
||||||
if step == "prompt":
|
if step == "prompt":
|
||||||
confirm = emit_final_confirm_gate(conversation)
|
messages = _advance_after_video_prompt(conversation)
|
||||||
if confirm is None:
|
if not messages:
|
||||||
return None, True, _STEP_CONTINUE_INSTRUCTIONS["prompt"]
|
return None, True, _STEP_CONTINUE_INSTRUCTIONS["prompt"]
|
||||||
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
|
return _video_gate_response(conversation, messages), False, ""
|
||||||
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, ""
|
|
||||||
|
|
||||||
# 未知 step:当普通继续
|
# 未知 step:当普通继续
|
||||||
return None, True, "用户已确认上一步。继续推进创作,不要复述确认。"
|
return None, True, "用户已确认上一步。继续推进创作,不要复述确认。"
|
||||||
@@ -539,8 +573,12 @@ class GenerateImageView(APIView):
|
|||||||
model_entity_id = str(request.data.get("model_entity_id") or "").strip() or None
|
model_entity_id = str(request.data.get("model_entity_id") or "").strip() or None
|
||||||
ratio = str(request.data.get("ratio") 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
|
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
|
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
|
conversation_id = str(request.data.get("conversation_id") or "").strip() or None
|
||||||
# 重跑/补图:前端带原批次 batch_id → enqueue 沿用(UUID 校验),记录归回原批次不裂新卡
|
# 重跑/补图:前端带原批次 batch_id → enqueue 沿用(UUID 校验),记录归回原批次不裂新卡
|
||||||
batch_id = str(request.data.get("batch_id") or "").strip() or None
|
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()]
|
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()]
|
reference_image_ids = [str(r).strip() for r in raw_refs if str(r).strip()]
|
||||||
team = get_current_team(request.user)
|
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:
|
if product_id:
|
||||||
try:
|
try:
|
||||||
normalized_product_id = str(uuid.UUID(product_id))
|
normalized_product_id = str(uuid.UUID(product_id))
|
||||||
@@ -604,7 +661,7 @@ class GenerateImageView(APIView):
|
|||||||
title=(prompt[:24] or "默认创作"),
|
title=(prompt[:24] or "默认创作"),
|
||||||
)
|
)
|
||||||
try:
|
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: # 无可用模型 / 余额不足等,立即反馈
|
except ValueError as exc: # 无可用模型 / 余额不足等,立即反馈
|
||||||
internal_kind = "user_credit_insufficient" if str(exc).strip().lower() == "insufficient credit" else ""
|
internal_kind = "user_credit_insufficient" if str(exc).strip().lower() == "insufficient credit" else ""
|
||||||
public_error = classify_generation_error(
|
public_error = classify_generation_error(
|
||||||
@@ -901,6 +958,8 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
|
|||||||
rp_prompt=KeyTextTransform("prompt", "request_payload"),
|
rp_prompt=KeyTextTransform("prompt", "request_payload"),
|
||||||
rp_ratio=KeyTextTransform("ratio", "request_payload"),
|
rp_ratio=KeyTextTransform("ratio", "request_payload"),
|
||||||
rp_platform_id=KeyTextTransform("platform_id", "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_id=KeyTextTransform("model_id", "request_payload"),
|
||||||
rp_model_entity_id=KeyTextTransform("model_entity_id", "request_payload"),
|
rp_model_entity_id=KeyTextTransform("model_entity_id", "request_payload"),
|
||||||
# 只在重跑任务里落此键(值恒为 True);键不存在 → NULL → 假值,存在 → "true"/"1" → 真值
|
# 只在重跑任务里落此键(值恒为 True);键不存在 → NULL → 假值,存在 → "true"/"1" → 真值
|
||||||
@@ -928,6 +987,7 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
|
|||||||
"model_id": t.rp_model_id or "",
|
"model_id": t.rp_model_id or "",
|
||||||
"model_entity_id": t.rp_model_entity_id or "",
|
"model_entity_id": t.rp_model_entity_id or "",
|
||||||
"platform_id": t.rp_platform_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),
|
"rerun": bool(t.rp_batch_append),
|
||||||
"retry_of_task_id": str((t.request_payload or {}).get("retry_of_task_id") or ""),
|
"retry_of_task_id": str((t.request_payload or {}).get("retry_of_task_id") or ""),
|
||||||
"created_at": t.created_at,
|
"created_at": t.created_at,
|
||||||
@@ -2030,6 +2090,26 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
record_user_message = False
|
record_user_message = False
|
||||||
force_creative_turn = True
|
force_creative_turn = True
|
||||||
continuation_instruction = _plot_twist_depth_continuation(depth)
|
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":
|
elif payload.get("interaction") == "plot_twist_directions":
|
||||||
options = [item for item in (payload.get("directions") or []) if isinstance(item, dict)]
|
options = [item for item in (payload.get("directions") or []) if isinstance(item, dict)]
|
||||||
chosen = next(
|
chosen = next(
|
||||||
@@ -2379,6 +2459,13 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
memory_now["person_confirm_pending"] = False
|
memory_now["person_confirm_pending"] = False
|
||||||
conversation.memory = memory_now
|
conversation.memory = memory_now
|
||||||
conversation.save(update_fields=["memory", "updated_at"])
|
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
|
force_creative_turn = True
|
||||||
continuation_instruction = (
|
continuation_instruction = (
|
||||||
"用户已确认使用当前生成的角色出镜。"
|
"用户已确认使用当前生成的角色出镜。"
|
||||||
@@ -2455,7 +2542,7 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
incoming = request.data.get("params")
|
incoming = request.data.get("params")
|
||||||
if incoming is not None and not isinstance(incoming, dict):
|
if incoming is not None and not isinstance(incoming, dict):
|
||||||
return JsonResponse({"detail": "params 必须是对象"}, status=400)
|
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
|
conversation, incoming if isinstance(incoming, dict) else None
|
||||||
)
|
)
|
||||||
card.payload = {
|
card.payload = {
|
||||||
@@ -2464,14 +2551,34 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
"params": latest_params,
|
"params": latest_params,
|
||||||
}
|
}
|
||||||
card.save(update_fields=["payload", "updated_at"])
|
card.save(update_fields=["payload", "updated_at"])
|
||||||
# 改时长会让旧脚本对不上(5 秒方案不能直接出 10 秒)。确认卡作废,前端再发一轮让模型重写。
|
# 改时长或视频模型会影响能力与 Prompt。保留事实/素材,只重写受影响步骤。
|
||||||
if duration_changed:
|
if needs_rebuild:
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
"regenerate": True,
|
"regenerate": True,
|
||||||
"params": latest_params,
|
"params": latest_params,
|
||||||
"message": None,
|
"message": None,
|
||||||
}, status=200)
|
}, status=200)
|
||||||
is_image = (card.payload or {}).get("kind") == "image" or conversation.mode == CreationConversation.Mode.IMAGE
|
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
|
submitter = submit_confirmed_image if is_image else submit_confirmed_video
|
||||||
message, error = submitter(
|
message, error = submitter(
|
||||||
conversation=conversation, user=request.user, confirm_message=card
|
conversation=conversation, user=request.user, confirm_message=card
|
||||||
@@ -2607,6 +2714,26 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
record_user_message = False
|
record_user_message = False
|
||||||
force_creative_turn = True
|
force_creative_turn = True
|
||||||
continuation_instruction = _plot_twist_depth_continuation(depth)
|
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":
|
elif payload.get("interaction") == "plot_twist_directions":
|
||||||
choice = str(answers.get("story_direction") or "").strip()
|
choice = str(answers.get("story_direction") or "").strip()
|
||||||
if not choice:
|
if not choice:
|
||||||
@@ -2670,6 +2797,10 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
)
|
)
|
||||||
if not relation:
|
if not relation:
|
||||||
return JsonResponse({"detail": "这个角色选项已经失效,请重新选择"}, status=400)
|
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 = ""
|
text = ""
|
||||||
record_user_message = False
|
record_user_message = False
|
||||||
force_creative_turn = True
|
force_creative_turn = True
|
||||||
@@ -2717,6 +2848,11 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
conversation.memory = memory
|
conversation.memory = memory
|
||||||
conversation.status = CreationConversation.Status.RUNNING
|
conversation.status = CreationConversation.Status.RUNNING
|
||||||
conversation.save(update_fields=["memory", "status", "updated_at"])
|
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 = ""
|
text = ""
|
||||||
record_user_message = False
|
record_user_message = False
|
||||||
force_creative_turn = True
|
force_creative_turn = True
|
||||||
@@ -2965,23 +3101,13 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
):
|
):
|
||||||
return _agent_busy_response()
|
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 = {
|
turn_kwargs = {
|
||||||
"conversation_id": str(conversation.id),
|
"conversation_id": str(conversation.id),
|
||||||
"user_id": str(request.user.id),
|
"user_id": str(request.user.id),
|
||||||
"text": text,
|
"text": text,
|
||||||
"refs": refs,
|
"refs": refs,
|
||||||
"model_config_id": model_config_id,
|
# 全能创作不接受前端覆盖语言模型;worker 内只解析固定的 GPT-6 Luna。
|
||||||
|
"model_config_id": None,
|
||||||
"record_user_message": record_user_message,
|
"record_user_message": record_user_message,
|
||||||
"force_creative_turn": force_creative_turn,
|
"force_creative_turn": force_creative_turn,
|
||||||
"continuation_instruction": continuation_instruction,
|
"continuation_instruction": continuation_instruction,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
AITask,
|
AITask,
|
||||||
Asset,
|
Asset,
|
||||||
BillingSummary,
|
BillingSummary,
|
||||||
|
CoverProductInfo,
|
||||||
BillingTrend,
|
BillingTrend,
|
||||||
ExportPoll,
|
ExportPoll,
|
||||||
Ledger,
|
Ledger,
|
||||||
@@ -823,7 +824,7 @@ export function App() {
|
|||||||
if (res) setUser(res);
|
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 里跑——
|
// 异步生图:提交后立刻拿到任务列表,前端轮询直到出图。慢的 ARK 出图在 Celery worker 里跑——
|
||||||
// Web 层不被 ~30s 请求占住 → 健康探针不饿死 → 根治"几张图整站 502";且提交成功后浏览器关掉/断网,
|
// Web 层不被 ~30s 请求占住 → 健康探针不饿死 → 根治"几张图整站 502";且提交成功后浏览器关掉/断网,
|
||||||
// worker 仍会把图生成并落库(扣费/退费在 worker 内闭环),重开素材库即可见。
|
// worker 仍会把图生成并落库(扣费/退费在 worker 内闭环),重开素材库即可见。
|
||||||
|
|||||||
@@ -2676,7 +2676,49 @@
|
|||||||
}
|
}
|
||||||
.yz-image .generator-settings .control-heading,
|
.yz-image .generator-settings .control-heading,
|
||||||
.yz-image .generator-settings .field-group:last-child { grid-column: 1 / -1; }
|
.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 {
|
.yz-image .control-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2975,20 +3017,20 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 18px;
|
gap: 12px;
|
||||||
padding: 0 20px;
|
padding: 0 14px;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
border-bottom: 1px solid rgba(34, 42, 54, 0.08);
|
border-bottom: 1px solid rgba(34, 42, 54, 0.08);
|
||||||
}
|
}
|
||||||
.yz-image .result-topbar strong { font-size: 14px; }
|
.yz-image .result-topbar strong { flex: 0 0 auto; font-size: 14px; white-space: nowrap; }
|
||||||
.yz-image .result-tags { display: flex; align-items: center; gap: 8px; }
|
.yz-image .result-tags { display: flex; align-items: center; min-width: 0; }
|
||||||
.yz-image .result-tags span {
|
.yz-image .result-tags span {
|
||||||
padding: 6px 9px;
|
overflow: hidden;
|
||||||
border-radius: 999px;
|
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
background: rgba(34, 42, 54, 0.055);
|
font-size: 11px;
|
||||||
font-size: 10px;
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.yz-image .result-canvas {
|
.yz-image .result-canvas {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -3231,6 +3273,89 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.6;
|
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 {
|
.yz-image .image-composer-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3349,8 +3474,16 @@
|
|||||||
.yz-image .result-topbar-tools {
|
.yz-image .result-topbar-tools {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: auto;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
.yz-image .tb-search-wrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
.yz-image .result-topbar .search-btn {
|
.yz-image .result-topbar .search-btn {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
CoverProductInfo,
|
||||||
AdminIntegrity,
|
AdminIntegrity,
|
||||||
AdminLedger,
|
AdminLedger,
|
||||||
AdminModel,
|
AdminModel,
|
||||||
@@ -1007,7 +1008,7 @@ export const api = {
|
|||||||
// 异步生图:提交后秒级返回任务列表(慢出图在 worker 跑),再用 generateImageStatus 轮询取结果。
|
// 异步生图:提交后秒级返回任务列表(慢出图在 worker 跑),再用 generateImageStatus 轮询取结果。
|
||||||
// 带 conversation_id 则归属该对话;不带则后端自动开一条新对话并回传其 id。
|
// 带 conversation_id 则归属该对话;不带则后端自动开一条新对话并回传其 id。
|
||||||
// 带 batch_id(重跑/补图)则任务归回原批次;响应回传本批 batch_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) });
|
return request<{ conversation_id: string; batch_id?: string; tasks: { id: string; status: string }[] }>("/api/ai/generate-image/", { method: "POST", body: JSON.stringify(payload) });
|
||||||
},
|
},
|
||||||
// 图片创作对话 CRUD —— 左栏会话列表 / 新对话 / 切换 / 重命名 / 删除
|
// 图片创作对话 CRUD —— 左栏会话列表 / 新对话 / 切换 / 重命名 / 删除
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const SHELL_COMMANDS: Command[] = [
|
|||||||
{ id: "new-project", group: "常用动作", label: "新建视频项目", sub: "选择商品并进入脚本配置", page: "projectWizard", icon: "clapperboard" },
|
{ id: "new-project", group: "常用动作", label: "新建视频项目", sub: "选择商品并进入脚本配置", page: "projectWizard", icon: "clapperboard" },
|
||||||
{ id: "quick-create-action", group: "常用动作", label: "一键成片", sub: "输入商品名称并上传图片,自动生成视频", page: "quickCreate", icon: "wand" },
|
{ id: "quick-create-action", group: "常用动作", label: "一键成片", sub: "输入商品名称并上传图片,自动生成视频", page: "quickCreate", icon: "wand" },
|
||||||
{ id: "model-photo", group: "常用动作", label: "生成模特上身图", sub: "快速生成 3:4 商品展示素材", page: "modelPhoto", icon: "users" },
|
{ 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" }
|
{ id: "image-optimize", group: "常用动作", label: "自由创作", sub: "对话式生成、编辑", page: "imageOptimize", icon: "images" }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -909,6 +909,52 @@ html[data-theme="dark"] .omni-upload-menu button > svg {
|
|||||||
color: #8eb0ff;
|
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 {
|
html[data-theme="dark"] .omni-slot-tag {
|
||||||
color: #0f1115;
|
color: #0f1115;
|
||||||
background: #e8eaed;
|
background: #e8eaed;
|
||||||
@@ -1072,6 +1118,65 @@ html[data-theme="dark"] .omni-session-page .omni-process-card {
|
|||||||
color: var(--accent-black);
|
color: var(--accent-black);
|
||||||
box-shadow: none;
|
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 {
|
html[data-theme="dark"] .omni-session-page .omni-chat-row.agent .omni-chat-bubble {
|
||||||
border-color: var(--border-faint);
|
border-color: var(--border-faint);
|
||||||
border-bottom-left-radius: var(--r-sm);
|
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);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
box-shadow: 0 0 0 3px rgba(61, 107, 255, 0.14);
|
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 .asset-pair-grid > .control-section + .control-section,
|
||||||
html[data-theme="dark"] .yz-image .generator-settings,
|
html[data-theme="dark"] .yz-image .generator-settings,
|
||||||
html[data-theme="dark"] .yz-image .result-topbar,
|
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;
|
background: #fff;
|
||||||
}
|
}
|
||||||
html[data-theme="dark"] .yz-image .result-tags span {
|
html[data-theme="dark"] .yz-image .result-tags span {
|
||||||
color: rgba(232, 234, 237, 0.62);
|
color: var(--black-alpha-56);
|
||||||
background: rgba(255, 255, 255, 0.06);
|
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 {
|
html[data-theme="dark"] .yz-image .result-empty-icon {
|
||||||
border-color: rgba(255, 255, 255, 0.10);
|
border-color: rgba(255, 255, 255, 0.10);
|
||||||
@@ -2355,6 +2536,50 @@ html[data-theme="dark"] .fc-page .fc-composer {
|
|||||||
background: #1a1e26;
|
background: #1a1e26;
|
||||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45);
|
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 {
|
html[data-theme="dark"] .fc-page .fc-tag {
|
||||||
color: rgba(232, 234, 237, 0.82);
|
color: rgba(232, 234, 237, 0.82);
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
|||||||
@@ -418,10 +418,10 @@
|
|||||||
.omni-elicit-card {
|
.omni-elicit-card {
|
||||||
width: min(760px, calc(100% - 44px));
|
width: min(760px, calc(100% - 44px));
|
||||||
margin: 0 0 24px 44px;
|
margin: 0 0 24px 44px;
|
||||||
border: 1px solid rgba(34, 42, 54, .09);
|
border: 0;
|
||||||
border-radius: 16px;
|
border-radius: var(--r-md);
|
||||||
background: var(--surface);
|
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;
|
animation: omniMessageIn 220ms ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,7 +527,7 @@
|
|||||||
|
|
||||||
.omni-video-plan-card {
|
.omni-video-plan-card {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-top: 3px solid var(--black);
|
border-top: 3px solid var(--heat);
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-video-plan-head span {
|
.omni-video-plan-head span {
|
||||||
@@ -542,7 +542,7 @@
|
|||||||
|
|
||||||
.omni-plan-section {
|
.omni-plan-section {
|
||||||
padding: 15px 17px;
|
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 {
|
.omni-plan-section:last-child {
|
||||||
@@ -564,9 +564,9 @@
|
|||||||
|
|
||||||
.omni-plan-points span {
|
.omni-plan-points span {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-radius: 9px;
|
border-radius: var(--r-md);
|
||||||
color: #525965;
|
color: var(--black-alpha-72);
|
||||||
background: #f6f8fa;
|
background: var(--background-lighter);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
@@ -596,7 +596,7 @@
|
|||||||
|
|
||||||
.omni-plan-timeline {
|
.omni-plan-timeline {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,10 +604,11 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
min-height: 78px;
|
min-height: 78px;
|
||||||
padding: 11px 10px;
|
padding: 11px 10px;
|
||||||
border: 1px solid rgba(34, 42, 54, .08);
|
border: 0;
|
||||||
border-radius: 10px;
|
border-radius: var(--r-md);
|
||||||
color: #5b626d;
|
color: var(--black-alpha-72);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--border-faint);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
@@ -620,6 +621,53 @@
|
|||||||
line-height: 1.45;
|
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 {
|
.omni-plan-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1164,12 +1212,21 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.omni-at-list button small {
|
||||||
color: #8b919a;
|
display: none;
|
||||||
font-size: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-session-send {
|
.omni-session-send {
|
||||||
@@ -1446,6 +1503,8 @@
|
|||||||
.omni-strategy-grid,
|
.omni-strategy-grid,
|
||||||
.omni-plan-points,
|
.omni-plan-points,
|
||||||
.omni-plan-timeline,
|
.omni-plan-timeline,
|
||||||
|
.omni-plan-overview-grid,
|
||||||
|
.omni-product-brief-list,
|
||||||
.omni-direction-list {
|
.omni-direction-list {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -1492,6 +1551,61 @@
|
|||||||
padding: 14px 18px 16px;
|
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 {
|
.omni-selling-point-card {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
@@ -20,14 +20,13 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
SlidersHorizontal,
|
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Trash2,
|
Trash2,
|
||||||
Users,
|
Users,
|
||||||
WandSparkles,
|
WandSparkles,
|
||||||
X
|
X
|
||||||
} from "lucide-react";
|
} 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 { pointsPerImageFromCatalog } from "../components/free-create/constants";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { useFileDrop } from "../components/use-file-drop";
|
import { useFileDrop } from "../components/use-file-drop";
|
||||||
@@ -35,6 +34,7 @@ import { imageModelPickerOptions } from "../model-display";
|
|||||||
import { ModelLibrary } from "../components/model-library";
|
import { ModelLibrary } from "../components/model-library";
|
||||||
import { SkeletonRows, SystemLoading } from "../components/loading";
|
import { SkeletonRows, SystemLoading } from "../components/loading";
|
||||||
import { ConfirmModal, MediaLightbox } from "../components/overlays";
|
import { ConfirmModal, MediaLightbox } from "../components/overlays";
|
||||||
|
import { RichMentionEditor, type RichMentionEditorHandle } from "../components/rich-mention-editor";
|
||||||
import { Pager } from "../components/pager";
|
import { Pager } from "../components/pager";
|
||||||
import { useViewMode } from "../components/use-view-mode";
|
import { useViewMode } from "../components/use-view-mode";
|
||||||
import type { Page } from "./route-config";
|
import type { Page } from "./route-config";
|
||||||
@@ -454,11 +454,11 @@ const MODE_META: Record<
|
|||||||
},
|
},
|
||||||
cover: {
|
cover: {
|
||||||
title: "平台套图",
|
title: "平台套图",
|
||||||
desc: "根据平台规范生成主图、卖点图、细节图与场景图",
|
desc: "根据商品信息和商品图生成主图、卖点图、细节图与场景图",
|
||||||
// 优化版:商品上架主图默认 1:1(原 4:5 会 fallback 成方图致比例错乱);竖图按平台/类目再选
|
// 通用电商主图规范:默认 1:1(不再按平台切换比例)
|
||||||
ratio: "1:1",
|
ratio: "1:1",
|
||||||
// YYX#4:只出平台头图,去掉「详情排版」
|
// 仅作兜底文案;实际提交的 prompt 由「商品信息」拼出(composeCoverPrompt)
|
||||||
promptTemplate: (title) => `${title},平台商品头图,统一视觉,商品主体清晰`
|
promptTemplate: (title) => `${title},电商主图套图,统一视觉,商品主体清晰`
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -479,38 +479,73 @@ const IMAGE_SUGGESTIONS = [
|
|||||||
{ label: "都市夜景海报", prompt: "电影感都市夜景,街道湿润反射霓虹,4K 高清产品海报" }
|
{ label: "都市夜景海报", prompt: "电影感都市夜景,街道湿润反射霓虹,4K 高清产品海报" }
|
||||||
];
|
];
|
||||||
|
|
||||||
/* 平台套图 · 平台卡(YYX#row10:用真平台 logo,素材在 public/assets/svg;
|
/* 前端平台 id(dy/tb…) ↔ 后端规范化 platform_id。平台套图已不再选平台(走通用电商主图规范),
|
||||||
logo 留作图加载失败时的兜底字符,img 为真 logo) */
|
这里只保留给旧记录:恢复旧批次时还原平台 key,重跑旧批次时原样带回 platform_id(后端仍兼容)。 */
|
||||||
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 注入平台版式块)。 */
|
|
||||||
const PLATFORM_ID_MAP: Record<string, string> = {
|
const PLATFORM_ID_MAP: Record<string, string> = {
|
||||||
dy: "douyin", tb: "taobao", tm: "tmall", jd: "jd", pdd: "pdd",
|
dy: "douyin", tb: "taobao", tm: "tmall", jd: "jd", pdd: "pdd",
|
||||||
xhs: "xhs", ks: "kuaishou", sph: "wechat", amz: "amazon", al: "1688"
|
xhs: "xhs", ks: "kuaishou", sph: "wechat", amz: "amazon", al: "1688"
|
||||||
};
|
};
|
||||||
|
|
||||||
/* YYX#row10:平台 logo —— 优先真 logo 图(public/assets/svg),加载失败回退到品牌色块+字符。
|
/* 平台套图 · 商品信息(替代原「画面要求」):用户填写卖点 / 作用 / 人群 / 规格 / 补充,
|
||||||
className 默认 p-logo(平台卡/筛选弹窗),分组头传 cg-logo。 */
|
默认从商品资料(selling_points / description / target_audience / specs)带入。 */
|
||||||
function PlatformLogo({ p, className }: { p: { id: string; name: string; logo: string; img?: string }; className?: string }) {
|
type CoverInfoDraft = { sellingPoints: string; effect: string; audience: string; specs: string; notes: string };
|
||||||
const [err, setErr] = useState(false);
|
|
||||||
return (
|
function formatProductSpecs(specs?: Record<string, unknown>): string {
|
||||||
<span className={`${className || "p-logo"} p-logo-${p.id}${p.img && !err ? " has-img" : ""}`}>
|
if (!specs || typeof specs !== "object") return "";
|
||||||
{p.img && !err
|
return Object.entries(specs)
|
||||||
? <img className="p-logo-img" src={p.img} alt={p.name} loading="lazy" decoding="async" onError={() => setErr(true)} />
|
.filter(([, value]) => typeof value === "string" || typeof value === "number")
|
||||||
: p.logo}
|
.map(([key, value]) => `${key}:${String(value).trim()}`)
|
||||||
</span>
|
.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;
|
modelId?: string;
|
||||||
/** 该批次选中的模特展示名(导航头显示) */
|
/** 该批次选中的模特展示名(导航头显示) */
|
||||||
modelName?: string;
|
modelName?: string;
|
||||||
/** 该批次选中的平台 id 列表(平台套图:多选 → 各平台分组,P0③) */
|
/** 旧平台套图记录的平台 id 列表(已不再选平台;仅用于重跑旧批次时原样带回) */
|
||||||
platformIds?: string[];
|
platformIds?: string[];
|
||||||
|
/** 平台套图:本批提交的商品信息(重跑 / 补图沿用) */
|
||||||
|
productInfo?: CoverProductInfo;
|
||||||
/** 该批次提交的参考图(图片创作:用户上传作生成参考):批次头回显 + 重跑时凭 assetId 原样复用 */
|
/** 该批次提交的参考图(图片创作:用户上传作生成参考):批次头回显 + 重跑时凭 assetId 原样复用 */
|
||||||
refs?: { name: string; url: string; assetId?: string }[];
|
refs?: { name: string; url: string; assetId?: string }[];
|
||||||
|
/** 图片创作 @ 引用的结构化实体;重跑时仍按实体取事实和参考图。 */
|
||||||
|
mentions?: CreationRef[];
|
||||||
/** 后端批次 id:重跑/补图带它回去,新任务归回原批次(否则后端裂成新批次,刷新后多出一条记录) */
|
/** 后端批次 id:重跑/补图带它回去,新任务归回原批次(否则后端裂成新批次,刷新后多出一条记录) */
|
||||||
backendBatchId?: string;
|
backendBatchId?: string;
|
||||||
/** 该批次已提交、尚未终态的生图任务 id:切走再回来可据此对每一批各自续轮询(PMC#5/#10) */
|
/** 该批次已提交、尚未终态的生图任务 id:切走再回来可据此对每一批各自续轮询(PMC#5/#10) */
|
||||||
@@ -582,7 +621,7 @@ export function ImageWorkbenchPage({
|
|||||||
modelConfigs: ModelConfig[];
|
modelConfigs: ModelConfig[];
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
navigate?: (page: Page) => 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>;
|
onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>;
|
||||||
/** 图片创作仅接受路由显式带入的商品;不使用工作台全局当前商品。 */
|
/** 图片创作仅接受路由显式带入的商品;不使用工作台全局当前商品。 */
|
||||||
imageProductId?: string;
|
imageProductId?: string;
|
||||||
@@ -619,6 +658,15 @@ export function ImageWorkbenchPage({
|
|||||||
conversationScopeKeyRef.current = conversationScopeKey;
|
conversationScopeKeyRef.current = conversationScopeKey;
|
||||||
// 图片创作(image)默认留空,只靠 placeholder 引导;模特/平台仍预填模板省一步
|
// 图片创作(image)默认留空,只靠 placeholder 引导;模特/平台仍预填模板省一步
|
||||||
const [prompt, setPrompt] = useState(mode === "image" ? "" : meta.promptTemplate(products[0]?.title || "商品"));
|
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);
|
const [ratio, setRatio] = useState(meta.ratio);
|
||||||
// 手动输入比例:开启后用 W:H 两个输入框自定义,关闭则用预设 pill
|
// 手动输入比例:开启后用 W:H 两个输入框自定义,关闭则用预设 pill
|
||||||
const [ratioManual, setRatioManual] = useState(false);
|
const [ratioManual, setRatioManual] = useState(false);
|
||||||
@@ -686,17 +734,23 @@ export function ImageWorkbenchPage({
|
|||||||
// 参考图:支持多张(可多选 / 多次追加),逐张可移除。提交时上传成 Asset 作生成参考。
|
// 参考图:支持多张(可多选 / 多次追加),逐张可移除。提交时上传成 Asset 作生成参考。
|
||||||
const [refImages, setRefImages] = useState<{ name: string; url: string; file: File }[]>([]);
|
const [refImages, setRefImages] = useState<{ name: string; url: string; file: File }[]>([]);
|
||||||
const refInputRef = useRef<HTMLInputElement | null>(null);
|
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)
|
// 生成后把结果面板滚到最新批次用的哨兵(PMC#14/#22)
|
||||||
const resultsEndRef = useRef<HTMLDivElement | null>(null);
|
const resultsEndRef = useRef<HTMLDivElement | null>(null);
|
||||||
// 生成结果图片放大预览
|
// 生成结果图片放大预览
|
||||||
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
|
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
|
||||||
// 平台套图头部:按提示词搜索已生成的结果图(gridQuery / searchOpen 仅 cover 用;
|
// 平台套图头部:按提示词搜索已生成的结果图(gridQuery / searchOpen 仅 cover 用;
|
||||||
// row40 后模特模式改单卡选择,已无网格可搜/排序,时间排序/模特筛选下拉一并移除)
|
// row40 后模特模式改单卡选择,已无网格可搜/排序,时间排序/模特筛选下拉一并移除)
|
||||||
|
// 平台筛选弹窗已随「选择平台」一起移除。
|
||||||
const [gridQuery, setGridQuery] = useState("");
|
const [gridQuery, setGridQuery] = useState("");
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
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)));
|
const visibleProducts = products.filter((item) => !(mode === "model" && isLocalLife(item)));
|
||||||
// 商品主图:用后端内嵌的 preview_url(cover_preview_url / images[].preview_url),不再反查全局 assets
|
// 商品主图:用后端内嵌的 preview_url(cover_preview_url / images[].preview_url),不再反查全局 assets
|
||||||
const productCoverUrl = (p: Product): string => {
|
const productCoverUrl = (p: Product): string => {
|
||||||
@@ -718,6 +772,49 @@ export function ImageWorkbenchPage({
|
|||||||
|
|
||||||
const refDrop = useFileDrop(acceptReferences, { accept: (f) => f.type.startsWith("image/") });
|
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"));
|
const imageModels = modelConfigs.filter((model) => model.capability.includes("image"));
|
||||||
// 团队价格系数(差异化调价):预估所见即所扣;拉不到按标准价 1
|
// 团队价格系数(差异化调价):预估所见即所扣;拉不到按标准价 1
|
||||||
const [priceMultiplier, setPriceMultiplier] = useState(1);
|
const [priceMultiplier, setPriceMultiplier] = useState(1);
|
||||||
@@ -790,26 +887,17 @@ export function ImageWorkbenchPage({
|
|||||||
document.addEventListener("click", close);
|
document.addEventListener("click", close);
|
||||||
return () => document.removeEventListener("click", close);
|
return () => document.removeEventListener("click", close);
|
||||||
}, [openMore]);
|
}, [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 ratioVar = ratio.replace(":", " / ");
|
||||||
const candidateCount = Math.max(1, Number(count) || 4);
|
const candidateCount = Math.max(1, Number(count) || 4);
|
||||||
// PMC#5:平台套图(cover)对每个选中平台各起一批,实扣 = 平台数 × candidateCount × 单价。
|
// 平台套图已不再按平台拆批:每次提交一批 candidateCount 张,实扣 = candidateCount × 单价(三种模式同口径)。
|
||||||
// 预估必须乘平台数才与实扣口径一致(model/image 模式只生成 candidateCount 张,不乘)。
|
const estimateCount = candidateCount;
|
||||||
const estimateCount = mode === "cover" ? Math.max(1, pickedIds.length) * candidateCount : candidateCount;
|
|
||||||
// 行31:并发提交——只要 prompt 非空就能再次提交,不再因"有批次在跑"被禁用
|
// 行31:并发提交——只要 prompt 非空就能再次提交,不再因"有批次在跑"被禁用
|
||||||
// 模特上身图需结合「商品图 + 模特图」合成,故必须先选商品且选一个模特,否则只是凭文字脑补
|
// 模特上身图需结合「商品图 + 模特图」合成,故必须先选商品且选一个模特,否则只是凭文字脑补
|
||||||
const canGenerate =
|
// 平台套图:必须选商品(商品图作参考)+ 至少填写核心卖点或商品作用之一
|
||||||
prompt.trim().length > 0 &&
|
const canGenerate = mode === "cover"
|
||||||
(mode !== "model" || (!!product?.id && pickedIds.length > 0)) &&
|
? !!product?.id && coverInfoReady
|
||||||
// 平台套图:必须选商品 + 至少一个平台(P0③ 多选)
|
: prompt.trim().length > 0 && (mode !== "model" || (!!product?.id && pickedIds.length > 0));
|
||||||
(mode !== "cover" || (!!product?.id && pickedIds.length > 0));
|
|
||||||
|
|
||||||
/* R100:工作台记录不再走 localStorage 持久化(1 小时过期、换浏览器即空 = 「任务中心有记录、
|
/* R100:工作台记录不再走 localStorage 持久化(1 小时过期、换浏览器即空 = 「任务中心有记录、
|
||||||
工作台丢」的根因)。模特/平台模式的批次流改从后端持久任务恢复(见下方 workbenchTasks effect),
|
工作台丢」的根因)。模特/平台模式的批次流改从后端持久任务恢复(见下方 workbenchTasks effect),
|
||||||
@@ -919,6 +1007,7 @@ export function ImageWorkbenchPage({
|
|||||||
productId: first.product_id || undefined,
|
productId: first.product_id || undefined,
|
||||||
modelId: first.model_id || undefined,
|
modelId: first.model_id || undefined,
|
||||||
platformIds: platformKey ? [platformKey] : undefined,
|
platformIds: platformKey ? [platformKey] : undefined,
|
||||||
|
productInfo: first.product_info || undefined,
|
||||||
backendBatchId: first.batch_id || undefined,
|
backendBatchId: first.batch_id || undefined,
|
||||||
taskIds: live.map((t) => t.id),
|
taskIds: live.map((t) => t.id),
|
||||||
rerunTaskIds: live.filter((t) => t.rerun).map((t) => t.id),
|
rerunTaskIds: live.filter((t) => t.rerun).map((t) => t.id),
|
||||||
@@ -976,6 +1065,8 @@ export function ImageWorkbenchPage({
|
|||||||
setActiveConvId(conv.id);
|
setActiveConvId(conv.id);
|
||||||
setBatches([]);
|
setBatches([]);
|
||||||
setPrompt(mode === "image" ? "" : meta.promptTemplate(product?.title || "商品"));
|
setPrompt(mode === "image" ? "" : meta.promptTemplate(product?.title || "商品"));
|
||||||
|
setMentionRefs([]);
|
||||||
|
setMentionMenuOpen(false);
|
||||||
setPickedIds([]);
|
setPickedIds([]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 不再静默吞错:把失败摆到用户面前(最常见原因 = 后端未更新,对话接口 404)
|
// 不再静默吞错:把失败摆到用户面前(最常见原因 = 后端未更新,对话接口 404)
|
||||||
@@ -1053,6 +1144,8 @@ export function ImageWorkbenchPage({
|
|||||||
setActiveConvId("");
|
setActiveConvId("");
|
||||||
setConversations([]);
|
setConversations([]);
|
||||||
setBatches([]);
|
setBatches([]);
|
||||||
|
setMentionRefs([]);
|
||||||
|
setMentionMenuOpen(false);
|
||||||
setRenamingId("");
|
setRenamingId("");
|
||||||
setConvError("");
|
setConvError("");
|
||||||
void loadConversations();
|
void loadConversations();
|
||||||
@@ -1069,10 +1162,14 @@ export function ImageWorkbenchPage({
|
|||||||
modelId?: string;
|
modelId?: string;
|
||||||
modelName?: string;
|
modelName?: string;
|
||||||
platformIds?: string[];
|
platformIds?: string[];
|
||||||
/** 优化版:规范化平台 id(douyin/taobao…),透传给后端注入平台版式块 */
|
/** 旧记录兼容:规范化平台 id(douyin/taobao…),仅重跑旧平台批次时带回 */
|
||||||
platformId?: string;
|
platformId?: string;
|
||||||
|
/** 平台套图:商品信息(卖点 / 作用 / 人群 / 规格 / 补充),透传后端注入主图提示词 */
|
||||||
|
productInfo?: CoverProductInfo;
|
||||||
/** 图片创作:本批要参考的上传图(含 file 用于上传;已是 asset 的可只给 id) */
|
/** 图片创作:本批要参考的上传图(含 file 用于上传;已是 asset 的可只给 id) */
|
||||||
refs?: { name: string; url: string; file?: File; assetId?: string }[];
|
refs?: { name: string; url: string; file?: File; assetId?: string }[];
|
||||||
|
/** 图片创作 @ 引用的实体;后端根据 id 解析为素材事实和参考图。 */
|
||||||
|
mentions?: CreationRef[];
|
||||||
/** PMC#25:原地重跑——复位这张已有批次卡(清旧结果、重新生成),不新开任务卡 */
|
/** PMC#25:原地重跑——复位这张已有批次卡(清旧结果、重新生成),不新开任务卡 */
|
||||||
reuseBatchId?: string;
|
reuseBatchId?: string;
|
||||||
/** PMC#25:单图重跑——把新图追加进这张已有批次卡(不清旧好图、不新开卡) */
|
/** PMC#25:单图重跑——把新图追加进这张已有批次卡(不清旧好图、不新开卡) */
|
||||||
@@ -1105,8 +1202,10 @@ export function ImageWorkbenchPage({
|
|||||||
modelId: opts.modelId,
|
modelId: opts.modelId,
|
||||||
modelName: opts.modelName,
|
modelName: opts.modelName,
|
||||||
platformIds: opts.platformIds,
|
platformIds: opts.platformIds,
|
||||||
|
productInfo: opts.productInfo,
|
||||||
// 批次头回显「参考了哪些图」(存名+预览+已知 assetId,不存 file;上传成功后统一回写 assetId)
|
// 批次头回显「参考了哪些图」(存名+预览+已知 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]);
|
setBatches((prev) => [...prev, newBatch]);
|
||||||
}
|
}
|
||||||
@@ -1135,7 +1234,7 @@ export function ImageWorkbenchPage({
|
|||||||
// 带上当前对话 id(空则后端自动开一条并回传);conversation_id 用 ref 取最新值,避免闭包旧值
|
// 带上当前对话 id(空则后端自动开一条并回传);conversation_id 用 ref 取最新值,避免闭包旧值
|
||||||
// batch_id:重跑/补图带原批次 id → 后端沿用,记录归回原批次(刷新后不裂新聊天记录)
|
// batch_id:重跑/补图带原批次 id → 后端沿用,记录归回原批次(刷新后不裂新聊天记录)
|
||||||
// onSubmitted:提交成功拿到任务 id 记进本批 pendingIds → 切走再回来由后端记录接续轮询(R100)
|
// 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) => {
|
onSubmitted: (taskIds, submittedBatchId) => {
|
||||||
if (mode === "image" && submittedScopeKey !== conversationScopeKeyRef.current) return;
|
if (mode === "image" && submittedScopeKey !== conversationScopeKeyRef.current) return;
|
||||||
setBatches((prev) => prev.map((b) => {
|
setBatches((prev) => prev.map((b) => {
|
||||||
@@ -1210,11 +1309,13 @@ export function ImageWorkbenchPage({
|
|||||||
productTitle: mode === "image" ? imageProduct?.title : product?.title
|
productTitle: mode === "image" ? imageProduct?.title : product?.title
|
||||||
};
|
};
|
||||||
if (mode === "cover") {
|
if (mode === "cover") {
|
||||||
// P0③:每个选中平台各起一批 → 右侧自然形成多平台分组 section;
|
// 不再选平台:一次提交一批,结构化商品信息走 product_info(后端注入电商主图提示词),
|
||||||
// 优化版:不再把平台名拼进 prompt,改传规范化 platform_id 给后端注入平台版式块(平台调性/版式/负面约束)
|
// prompt 为可读摘要(批次头展示 / 搜索用)。商品图参考链路不变(后端按 product_id 取商品图)。
|
||||||
for (const pid of pickedIds) {
|
void startBatch({
|
||||||
void startBatch({ ...base, platformId: PLATFORM_ID_MAP[pid], platformIds: [pid] });
|
...base,
|
||||||
}
|
prompt: composeCoverPrompt(product?.title || "商品", coverInfo),
|
||||||
|
productInfo: coverInfoPayload(coverInfo)
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void startBatch({
|
void startBatch({
|
||||||
@@ -1222,11 +1323,12 @@ export function ImageWorkbenchPage({
|
|||||||
modelId: mode === "model" ? pickedIds[0] : undefined,
|
modelId: mode === "model" ? pickedIds[0] : undefined,
|
||||||
modelName: mode === "model" ? pickedModelName : undefined,
|
modelName: mode === "model" ? pickedModelName : undefined,
|
||||||
// 图片创作:把已选参考图带进这一批(startBatch 内上传并传给后端)
|
// 图片创作:把已选参考图带进这一批(startBatch 内上传并传给后端)
|
||||||
refs: mode === "image" && refImages.length ? refImages : undefined
|
refs: mode === "image" && refImages.length ? refImages : undefined,
|
||||||
|
mentions: mode === "image" && mentionRefs.length ? mentionRefs : undefined
|
||||||
});
|
});
|
||||||
// 图片创作:提交后清空输入栏(参考图 + 提示词文字),像对话一样「发完即清」,等下次输入(PMC#15)。
|
// 图片创作:提交后清空输入栏(参考图 + 提示词文字),像对话一样「发完即清」,等下次输入(PMC#15)。
|
||||||
// 批次头会保留这次用过的提示词/参考图,信息不丢。模特/平台模式的提示词是按商品预填的模板,不清。
|
// 批次头会保留这次用过的提示词/参考图,信息不丢。模特/平台模式的提示词是按商品预填的模板,不清。
|
||||||
if (mode === "image") { setRefImages([]); setPrompt(""); }
|
if (mode === "image") { setRefImages([]); setMentionRefs([]); setPrompt(""); }
|
||||||
// 生成后自动把面板滚到最新批次,不用用户自己往下拖找(PMC#14/#22)。等新批次渲染出来再滚。
|
// 生成后自动把面板滚到最新批次,不用用户自己往下拖找(PMC#14/#22)。等新批次渲染出来再滚。
|
||||||
window.setTimeout(() => resultsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }), 80);
|
window.setTimeout(() => resultsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }), 80);
|
||||||
}
|
}
|
||||||
@@ -1244,8 +1346,10 @@ export function ImageWorkbenchPage({
|
|||||||
modelName: src.modelName,
|
modelName: src.modelName,
|
||||||
platformIds: src.platformIds,
|
platformIds: src.platformIds,
|
||||||
platformId: src.platformIds?.[0] ? PLATFORM_ID_MAP[src.platformIds[0]] : undefined,
|
platformId: src.platformIds?.[0] ? PLATFORM_ID_MAP[src.platformIds[0]] : undefined,
|
||||||
|
productInfo: src.productInfo,
|
||||||
// 原批次的参考图 + 后端批次 id 一并带回:重跑仍参考原素材,且任务归回原批次不裂新记录
|
// 原批次的参考图 + 后端批次 id 一并带回:重跑仍参考原素材,且任务归回原批次不裂新记录
|
||||||
refs: src.refs,
|
refs: src.refs,
|
||||||
|
mentions: src.mentions,
|
||||||
batchId: src.backendBatchId,
|
batchId: src.backendBatchId,
|
||||||
reuseBatchId: src.id
|
reuseBatchId: src.id
|
||||||
});
|
});
|
||||||
@@ -1321,6 +1425,7 @@ export function ImageWorkbenchPage({
|
|||||||
modelName: src.modelName,
|
modelName: src.modelName,
|
||||||
platformIds: src.platformIds,
|
platformIds: src.platformIds,
|
||||||
platformId: src.platformIds?.[0] ? PLATFORM_ID_MAP[src.platformIds[0]] : undefined,
|
platformId: src.platformIds?.[0] ? PLATFORM_ID_MAP[src.platformIds[0]] : undefined,
|
||||||
|
productInfo: src.productInfo,
|
||||||
// 原批次的参考图 + 后端批次 id 一并带回:补的这张仍参考原素材,且归回原批次不裂新记录
|
// 原批次的参考图 + 后端批次 id 一并带回:补的这张仍参考原素材,且归回原批次不裂新记录
|
||||||
refs: src.refs,
|
refs: src.refs,
|
||||||
batchId: src.backendBatchId,
|
batchId: src.backendBatchId,
|
||||||
@@ -1381,12 +1486,8 @@ export function ImageWorkbenchPage({
|
|||||||
const q = gridQuery.trim().toLowerCase();
|
const q = gridQuery.trim().toLowerCase();
|
||||||
list = list.filter((b) => (b.prompt || "").toLowerCase().includes(q));
|
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;
|
return list;
|
||||||
}, [batches, productId, mode, gridQuery, platformFilter]);
|
}, [batches, productId, mode, gridQuery]);
|
||||||
const hasResults = productBatches.length > 0;
|
const hasResults = productBatches.length > 0;
|
||||||
|
|
||||||
/* ── 单个批次的结果网格 · §4.18 gen-card 三件套 ──
|
/* ── 单个批次的结果网格 · §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 featuredProducts = (() => {
|
||||||
const selected = visibleProducts.find((item) => item.id === productId);
|
const selected = visibleProducts.find((item) => item.id === productId);
|
||||||
const rest = visibleProducts.filter((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]);
|
const rest = personAssets.filter((item) => item.id !== pickedIds[0]);
|
||||||
return (selected ? [selected, ...rest] : rest).slice(0, 3);
|
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 ratioOptions = mode === "model" ? MODEL_RATIO_OPTIONS : RATIO_OPTIONS;
|
||||||
const countOptions = mode === "model" ? MODEL_COUNT_OPTIONS : COVER_COUNT_OPTIONS;
|
const countOptions = mode === "model" ? MODEL_COUNT_OPTIONS : COVER_COUNT_OPTIONS;
|
||||||
const openProductLibrary = () => {
|
const openProductLibrary = () => {
|
||||||
@@ -1758,12 +1822,40 @@ export function ImageWorkbenchPage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<div className="studio-prompt-wrap" ref={mentionMenuRef}>
|
||||||
|
<RichMentionEditor
|
||||||
|
ref={mentionEditorRef}
|
||||||
className="studio-prompt"
|
className="studio-prompt"
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={(event) => setPrompt(event.target.value)}
|
refs={mentionRefs}
|
||||||
placeholder="描述你想生成或修改的画面,@ 可引用商品、模特或已有素材……"
|
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>
|
||||||
<div className="image-composer-footer">
|
<div className="image-composer-footer">
|
||||||
<div className="image-composer-options">
|
<div className="image-composer-options">
|
||||||
@@ -1831,7 +1923,7 @@ export function ImageWorkbenchPage({
|
|||||||
|
|
||||||
<div className="generator-layout">
|
<div className="generator-layout">
|
||||||
<aside className="generator-panel">
|
<aside className="generator-panel">
|
||||||
<div className="asset-pair-grid">
|
<div className={`asset-pair-grid${mode === "cover" ? " single" : ""}`}>
|
||||||
<section className="control-section">
|
<section className="control-section">
|
||||||
<div className="control-heading"><span className="step-number">1</span><strong>选择商品</strong></div>
|
<div className="control-heading"><span className="step-number">1</span><strong>选择商品</strong></div>
|
||||||
<div className="product-choice-list">
|
<div className="product-choice-list">
|
||||||
@@ -1866,7 +1958,7 @@ export function ImageWorkbenchPage({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{mode === "model" ? (
|
{mode === "model" && (
|
||||||
<section className="control-section">
|
<section className="control-section">
|
||||||
<div className="control-heading"><span className="step-number">2</span><strong>选择模特</strong></div>
|
<div className="control-heading"><span className="step-number">2</span><strong>选择模特</strong></div>
|
||||||
<div className="model-choice-grid">
|
<div className="model-choice-grid">
|
||||||
@@ -1895,33 +1987,24 @@ export function ImageWorkbenchPage({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
|
|
||||||
<section className={`control-section generator-settings${mode === "cover" ? " platform-generator-settings" : ""}`}>
|
<section className={`control-section generator-settings${mode === "cover" ? " cover-generator-settings" : ""}`}>
|
||||||
<div className="control-heading"><span className="step-number">3</span><strong>生成设置</strong></div>
|
<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-group">
|
||||||
<div className="field-label">
|
<div className="field-label">
|
||||||
<span>生成数量</span>
|
<span>生成数量</span>
|
||||||
<small>{mode === "cover" ? "覆盖不同图位" : "单次最多 4 张"}</small>
|
<small>{mode === "cover" ? "依次覆盖主图 / 卖点 / 细节 / 场景" : "单次最多 4 张"}</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="choice-row">
|
<div className="choice-row">
|
||||||
{countOptions.map((value) => (
|
{countOptions.map((value) => (
|
||||||
@@ -1983,10 +2066,85 @@ export function ImageWorkbenchPage({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{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">
|
<div className="field-group">
|
||||||
<label className="field-label">
|
<label className="field-label">
|
||||||
<span>{mode === "cover" ? "画面要求" : "提示词"}</span>
|
<span>提示词</span>
|
||||||
<small>{mode === "cover" ? "平台规范已自动载入" : "可继续修改"}</small>
|
<small>可继续修改</small>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="image-prompt"
|
className="image-prompt"
|
||||||
@@ -1996,22 +2154,19 @@ export function ImageWorkbenchPage({
|
|||||||
/>
|
/>
|
||||||
<div className="model-line">
|
<div className="model-line">
|
||||||
<Pill label="模型" value={selectedImageModelLabel} options={imageModelPickerOptions} onSelect={setGenModel} />
|
<Pill label="模型" value={selectedImageModelLabel} options={imageModelPickerOptions} onSelect={setGenModel} />
|
||||||
<span>
|
<span>自动匹配商品参考图</span>
|
||||||
{mode === "cover"
|
|
||||||
? (selectedPlatform ? `${selectedPlatform.name}规范` : "请选择平台")
|
|
||||||
: "自动匹配商品参考图"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="generator-footer">
|
<div className="generator-footer">
|
||||||
<button type="button" className="generate-button" onClick={runGenerate} disabled={!canGenerate}>
|
<button type="button" className="generate-button" onClick={runGenerate} disabled={!canGenerate}>
|
||||||
<WandSparkles />
|
<WandSparkles />
|
||||||
<span>{mode === "cover" ? "生成平台套图" : "立即生成"}</span>
|
<span>{mode === "cover" ? "生成套图" : "立即生成"}</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="cost-line">
|
<div className="cost-line">
|
||||||
<span>{mode === "cover" ? "生成成功后按实际数量结算" : "仅在生成成功后扣费"}</span>
|
<span>成功后扣费</span>
|
||||||
<strong>预计 {estimateFee(estimateCount)}</strong>
|
<strong>预计 {estimateFee(estimateCount)}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2021,21 +2176,11 @@ export function ImageWorkbenchPage({
|
|||||||
<div className="result-topbar">
|
<div className="result-topbar">
|
||||||
<strong>{mode === "cover" ? "套图预览" : "生成结果"}</strong>
|
<strong>{mode === "cover" ? "套图预览" : "生成结果"}</strong>
|
||||||
<div className="result-topbar-tools">
|
<div className="result-topbar-tools">
|
||||||
|
{!searchOpen && (
|
||||||
<div className="result-tags">
|
<div className="result-tags">
|
||||||
{mode === "cover" ? (
|
<span>{mode === "cover" ? `${count} 张套图` : `${ratio} · ${count} 张`}</span>
|
||||||
<>
|
|
||||||
<span>{selectedPlatform?.name || "未选平台"}</span>
|
|
||||||
<span>{count} 个图位</span>
|
|
||||||
<span>统一视觉</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span>{ratio}</span>
|
|
||||||
<span>{count} 张</span>
|
|
||||||
<span>自动保存至成品库</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{mode === "cover" && (
|
{mode === "cover" && (
|
||||||
<>
|
<>
|
||||||
<div className="tb-search-wrap">
|
<div className="tb-search-wrap">
|
||||||
@@ -2053,43 +2198,17 @@ export function ImageWorkbenchPage({
|
|||||||
<Search size={14} />
|
<Search size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<div className={`result-canvas${hasResults ? " has-results" : ""}`}>
|
<div className={`result-canvas${hasResults ? " has-results" : ""}`}>
|
||||||
{mode === "cover" && (platformFilter.length > 0 || gridQuery.trim()) && (
|
{mode === "cover" && gridQuery.trim() && (
|
||||||
<div className="iw-filter-notice">
|
<div className="iw-filter-notice">
|
||||||
<span className="ifn-text">当前已开启筛选,部分内容可能隐藏</span>
|
<span className="ifn-text">当前已开启搜索,部分内容可能隐藏</span>
|
||||||
<button type="button" className="ifn-clear" onClick={() => { setPlatformFilter([]); setGridQuery(""); setSearchOpen(false); }}>
|
<button type="button" className="ifn-clear" onClick={() => { setGridQuery(""); setSearchOpen(false); }}>
|
||||||
<X size={12} />
|
<X size={12} />
|
||||||
清空筛选
|
清空搜索
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2099,23 +2218,24 @@ export function ImageWorkbenchPage({
|
|||||||
<strong>{mode === "cover" ? "还没有套图结果" : "等待生成"}</strong>
|
<strong>{mode === "cover" ? "还没有套图结果" : "等待生成"}</strong>
|
||||||
<p>
|
<p>
|
||||||
{mode === "cover"
|
{mode === "cover"
|
||||||
? "选择平台后,系统会按对应图位和尺寸生成一组可继续编辑的电商素材。"
|
? "填写商品卖点与作用后,系统会结合商品图生成主图、卖点图、细节图与场景图。"
|
||||||
: "选择模特并确认设置后开始生成,结果会按版本保留,可随时切换使用。"}
|
: "选择模特并确认设置后开始生成,结果会按版本保留,可随时切换使用。"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
|
<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 ref={resultsEndRef} aria-hidden="true" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{hasResults && (
|
||||||
<div className="result-footer">
|
<div className="result-footer">
|
||||||
{mode === "cover" ? (
|
{mode === "cover" ? (
|
||||||
<>
|
<>
|
||||||
<div className="result-stat"><span>主图</span><strong>白底 / 场景各 1 张</strong></div>
|
<div className="result-stat"><span>图位</span><strong>主图 / 卖点 / 细节 / 场景</strong></div>
|
||||||
<div className="result-stat"><span>卖点证明</span><strong>2 个核心卖点</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>成品库 / 平台套图</strong></div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -2126,6 +2246,7 @@ export function ImageWorkbenchPage({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2167,14 +2288,16 @@ export function ImageWorkbenchPage({
|
|||||||
coverUrl={productCoverUrl}
|
coverUrl={productCoverUrl}
|
||||||
onClose={() => setPlOpen(false)}
|
onClose={() => setPlOpen(false)}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
// 第一个选中作为当前商品;若选了多个,各商品另起一批(沿用当前提示词/比例/张数/模特/平台)
|
// 第一个选中作为当前商品;若选了多个,各商品另起一批(沿用当前提示词/比例/张数/模特;
|
||||||
|
// 平台套图则用各自商品资料带入的商品信息 + 当前补充要求)
|
||||||
if (plDraft.length) {
|
if (plDraft.length) {
|
||||||
setProductId(plDraft[0]);
|
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)) {
|
for (const pid of plDraft.slice(1)) {
|
||||||
const p = products.find((x) => x.id === pid);
|
const p = products.find((x) => x.id === pid);
|
||||||
if (mode === "cover") {
|
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 {
|
} 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 });
|
void startBatch({ prompt: prompt.trim(), ratio, count: candidateCount, productId: pid, productTitle: p?.title, modelId: mode === "model" ? pickedIds[0] : undefined, modelName: mode === "model" ? pickedModelName : undefined });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -700,17 +700,27 @@ function strategyDocumentBody(payload: Record<string, unknown>): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function planDocumentBody(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 usp = strategyField(payload, "usp", "主打卖点", "卖点");
|
||||||
const points = coercePlanPoints(payload.points);
|
const points = coercePlanPoints(payload.points);
|
||||||
const timeline = (payload.timeline as PlanTimelineItem[] | undefined) || [];
|
const timeline = (payload.timeline as PlanTimelineItem[] | undefined) || [];
|
||||||
const voice = coerceVoiceChars(payload.voice_chars);
|
const voice = coerceVoiceChars(payload.voice_chars);
|
||||||
const pointText = points.length ? points.map((point) => `- ${point}`).join("\n") : "—";
|
const pointText = points.length ? points.map((point) => `- ${point}`).join("\n") : "—";
|
||||||
const timelineText = timeline.length
|
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% 时长` : "—";
|
const voiceText = voice.length === 2 ? `${voice[0]}–${voice[1]} 字,预计覆盖约 90% 时长` : "—";
|
||||||
return [
|
return [
|
||||||
"# 视频最终方案",
|
"# 视频架构",
|
||||||
|
`## 创作概览\n- 视频目标:${goal || "—"}\n- 目标时长:${duration || "—"}\n- 创意概念:${concept || "—"}`,
|
||||||
`## 主打卖点 USP\n${usp || "—"}`,
|
`## 主打卖点 USP\n${usp || "—"}`,
|
||||||
`## 核心支撑\n${pointText}`,
|
`## 核心支撑\n${pointText}`,
|
||||||
`## 时间轴\n${timelineText}`,
|
`## 时间轴\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[] {
|
function coercePlanPoints(raw: unknown): string[] {
|
||||||
if (Array.isArray(raw)) {
|
if (Array.isArray(raw)) {
|
||||||
@@ -1039,6 +1058,9 @@ function coerceVoiceChars(raw: unknown): number[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PlanCard({ payload }: { payload: Record<string, unknown> }) {
|
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 usp = strategyField(payload, "usp", "主打卖点", "卖点");
|
||||||
const points = coercePlanPoints(payload.points);
|
const points = coercePlanPoints(payload.points);
|
||||||
const timeline = (payload.timeline as PlanTimelineItem[] | undefined) || [];
|
const timeline = (payload.timeline as PlanTimelineItem[] | undefined) || [];
|
||||||
@@ -1049,17 +1071,17 @@ function PlanCard({ payload }: { payload: Record<string, unknown> }) {
|
|||||||
return (
|
return (
|
||||||
<section className="omni-video-plan-card">
|
<section className="omni-video-plan-card">
|
||||||
<header className="omni-video-plan-head">
|
<header className="omni-video-plan-head">
|
||||||
<strong>视频最终方案</strong>
|
<strong>视频架构</strong>
|
||||||
<div className="omni-doc-head-actions">
|
<div className="omni-doc-head-actions">
|
||||||
<span>确认后进入出片参数核对</span>
|
<span>确认后撰写完整 Prompt</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="omni-doc-download"
|
className="omni-doc-download"
|
||||||
title="下载"
|
title="下载"
|
||||||
aria-label="下载视频最终方案"
|
aria-label="下载视频架构"
|
||||||
onClick={() => void downloadSessionResource({
|
onClick={() => void downloadSessionResource({
|
||||||
kind: "document",
|
kind: "document",
|
||||||
title: "视频最终方案.md",
|
title: "视频架构.md",
|
||||||
body: planDocumentBody(payload),
|
body: planDocumentBody(payload),
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
@@ -1067,6 +1089,14 @@ function PlanCard({ payload }: { payload: Record<string, unknown> }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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">
|
<section className="omni-plan-section">
|
||||||
<strong>卖点做减法</strong>
|
<strong>卖点做减法</strong>
|
||||||
<div className="omni-plan-points">
|
<div className="omni-plan-points">
|
||||||
@@ -1087,11 +1117,14 @@ function PlanCard({ payload }: { payload: Record<string, unknown> }) {
|
|||||||
<strong>Hook 只负责留人,正文负责说服</strong>
|
<strong>Hook 只负责留人,正文负责说服</strong>
|
||||||
<div className="omni-plan-timeline">
|
<div className="omni-plan-timeline">
|
||||||
{timeline.map((item, index) => (
|
{timeline.map((item, index) => (
|
||||||
<span key={`${item.stage}-${index}`}>
|
<span className="omni-plan-timeline-item" key={`${item.stage}-${index}`}>
|
||||||
<b>
|
<b>
|
||||||
{format(item.start)}–{format(item.end)} 秒 · {item.stage}
|
{format(item.start)}–{format(item.end)} 秒 · {item.stage}
|
||||||
</b>
|
</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>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1426,6 +1459,9 @@ function ElicitCard({
|
|||||||
{isPet
|
{isPet
|
||||||
? "不填写也可以,平台会结合当前商品和拟人主题自动设计萌宠角色。"
|
? "不填写也可以,平台会结合当前商品和拟人主题自动设计萌宠角色。"
|
||||||
: "不填写也可以。若脚本是多人物,平台会按人数一次生成多张定妆图,避免长视频前后形象漂移。"}
|
: "不填写也可以。若脚本是多人物,平台会按人数一次生成多张定妆图,避免长视频前后形象漂移。"}
|
||||||
|
{Number(message.payload?.estimated_credits || 0) > 0
|
||||||
|
? ` 预计 ${Number(message.payload.estimated_credits)} 积分。`
|
||||||
|
: ""}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1834,6 +1870,20 @@ function ElicitCard({
|
|||||||
<span>{submitted ? (saved._action === "cancel" ? "已取消" : "已回答") : "选一下就好"}</span>
|
<span>{submitted ? (saved._action === "cancel" ? "已取消" : "已回答") : "选一下就好"}</span>
|
||||||
</header>
|
</header>
|
||||||
<div className="omni-elicit-body">
|
<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) => {
|
{fields.map((field) => {
|
||||||
const value = answers[field.key];
|
const value = answers[field.key];
|
||||||
return (
|
return (
|
||||||
@@ -1976,14 +2026,18 @@ function ConfirmCard({
|
|||||||
const snapshot = { ...sessionParams, ...payloadParams };
|
const snapshot = { ...sessionParams, ...payloadParams };
|
||||||
const [draft, setDraft] = useState(snapshot);
|
const [draft, setDraft] = useState(snapshot);
|
||||||
const [initialDuration] = useState(snapshot.duration || "");
|
const [initialDuration] = useState(snapshot.duration || "");
|
||||||
|
const [initialModel] = useState(snapshot.model || "");
|
||||||
const cardIsVideo = message.payload.kind !== "image" && isVideo;
|
const cardIsVideo = message.payload.kind !== "image" && isVideo;
|
||||||
const durationChanged =
|
const durationChanged =
|
||||||
cardIsVideo
|
cardIsVideo
|
||||||
&& Boolean(draft.duration)
|
&& Boolean(draft.duration)
|
||||||
&& Boolean(initialDuration)
|
&& Boolean(initialDuration)
|
||||||
&& normalizeDurationValue(draft.duration) !== normalizeDurationValue(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 durationSeconds = Number(String(draft.duration || "").replace(/\D/g, ""));
|
||||||
const willGenerateInSegments = cardIsVideo && durationSeconds > 30 && durationSeconds <= 60;
|
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 setField = (key: string, value: string) => setDraft((prev) => ({ ...prev, [key]: value }));
|
||||||
const summary = paramLine(draft, cardIsVideo) || "当前参数";
|
const summary = paramLine(draft, cardIsVideo) || "当前参数";
|
||||||
// 确认卡积分:改模型/分辨率/时长/张数时按后台挂牌实时重算(与后端 quote_* 同口径;标准团队系数=1)
|
// 确认卡积分:改模型/分辨率/时长/张数时按后台挂牌实时重算(与后端 quote_* 同口径;标准团队系数=1)
|
||||||
@@ -2018,7 +2072,7 @@ function ConfirmCard({
|
|||||||
: generationPhase === "failed" ? "可重新确认方案后再生成"
|
: generationPhase === "failed" ? "可重新确认方案后再生成"
|
||||||
: generationPhase === "running" ? "正在出片,请稍候"
|
: generationPhase === "running" ? "正在出片,请稍候"
|
||||||
: generationPhase === "submitted" ? "正在提交生成"
|
: generationPhase === "submitted" ? "正在提交生成"
|
||||||
: "确认前可以改参数。改时长会按新时长重写脚本。";
|
: "确认前可以改参数。改时长或模型会同步更新视频架构与 Prompt。";
|
||||||
const foot =
|
const foot =
|
||||||
generationPhase === "done" ? "已完成出片"
|
generationPhase === "done" ? "已完成出片"
|
||||||
: generationPhase === "failed" ? "出片失败"
|
: generationPhase === "failed" ? "出片失败"
|
||||||
@@ -2029,8 +2083,8 @@ function ConfirmCard({
|
|||||||
: generationPhase === "failed" ? "生成失败"
|
: generationPhase === "failed" ? "生成失败"
|
||||||
: generationPhase === "running" || generationPhase === "submitted"
|
: generationPhase === "running" || generationPhase === "submitted"
|
||||||
? "生成中"
|
? "生成中"
|
||||||
: durationChanged
|
: needsRebuild
|
||||||
? "确认并重写脚本"
|
? "确认并同步架构"
|
||||||
: String(message.payload.label || "开始生成");
|
: String(message.payload.label || "开始生成");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -2056,18 +2110,21 @@ function ConfirmCard({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{submitted ? null : durationChanged ? (
|
{submitted ? null : needsRebuild ? (
|
||||||
<p className="omni-confirm-hint">改时长会重新生成脚本。确认后先重写方案,不会直接出片。</p>
|
<p className="omni-confirm-hint">参数变化会同步更新受影响的视频架构与 Prompt,不会直接出片。</p>
|
||||||
) : willGenerateInSegments ? (
|
) : willGenerateInSegments ? (
|
||||||
<p className="omni-confirm-hint">
|
<p className="omni-confirm-hint">
|
||||||
{durationSeconds} 秒成片一次生成,时长较长会多花一点时间,请耐心等待。
|
{draft.duration === snapshot.duration && generationPlan.note
|
||||||
|
? String(generationPlan.note)
|
||||||
|
: `${durationSeconds} 秒将分 ${Math.ceil(durationSeconds / 30)} 段生成后自动合并。`}
|
||||||
|
{credits > 0 ? ` 预计共 ${credits} 积分。` : ""}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="omni-confirm-foot">
|
<div className="omni-confirm-foot">
|
||||||
<span>{foot}</span>
|
<span>{foot}</span>
|
||||||
<button type="button" disabled={disabled || submitted} onClick={() => onConfirm(draft)}>
|
<button type="button" disabled={disabled || submitted} onClick={() => onConfirm(draft)}>
|
||||||
{buttonLabel}
|
{buttonLabel}
|
||||||
{!submitted && !durationChanged && credits > 0 ? <i>约 {credits} 积分</i> : null}
|
{!submitted && !needsRebuild && credits > 0 ? <i>约 {credits} 积分</i> : null}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -3303,10 +3360,10 @@ export function OmniSessionPage({
|
|||||||
prev ? { ...prev, params: { ...prev.params, ...nextParams } } : prev
|
prev ? { ...prev, params: { ...prev.params, ...nextParams } } : prev
|
||||||
);
|
);
|
||||||
if (result.regenerate) {
|
if (result.regenerate) {
|
||||||
notify("info", `时长已改为 ${nextParams.duration || ""},正在按新时长重写脚本`);
|
notify("info", "参数已更新,正在同步视频架构与 Prompt");
|
||||||
void send({
|
void send({
|
||||||
kind: "text",
|
kind: "text",
|
||||||
text: `时长改成了${nextParams.duration},请按新参数重新写方案,旧方案作废`,
|
text: `生成参数已更新为:${paramLine(nextParams, true)}。请只更新受影响的视频架构与 Prompt,保留已经确认的商品信息、角色图和未受影响的段落。`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3965,15 +4022,19 @@ export function OmniSessionPage({
|
|||||||
) : mentionResults.length === 0 ? (
|
) : mentionResults.length === 0 ? (
|
||||||
<strong>这类还没有可引用的内容</strong>
|
<strong>这类还没有可引用的内容</strong>
|
||||||
) : (
|
) : (
|
||||||
mentionResults.map((ref) => (
|
mentionResults.map((ref) => {
|
||||||
<button type="button" key={ref.id} onClick={() => insertMention(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 />}
|
{ref.cover ? <img src={ref.cover} alt="" /> : <Play />}
|
||||||
<span>
|
<span>
|
||||||
{ref.name.split(" · ")[0]}
|
<strong>{filename}</strong>
|
||||||
<small>{typeLabels[ref.type] || MENTION_TABS.find((tab) => tab.type === ref.type)?.label}</small>
|
<small>{typeLabel}</small>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -780,6 +780,16 @@ export type ImageConversationTrash = ImageConversation & {
|
|||||||
cover_preview_url: string;
|
cover_preview_url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 平台套图 · 用户填写的商品信息(随生成请求提交 product_info,后端据此构建电商主图提示词)。
|
||||||
|
// 各字段均可选;selling_points 为多行文本(每行一个卖点)。
|
||||||
|
export type CoverProductInfo = {
|
||||||
|
selling_points?: string;
|
||||||
|
effect?: string;
|
||||||
|
audience?: string;
|
||||||
|
specs?: string;
|
||||||
|
notes?: string;
|
||||||
|
};
|
||||||
|
|
||||||
// 工作台生成记录(R100):模特上身图/平台套图页从后端持久任务拉取(与任务中心同源),
|
// 工作台生成记录(R100):模特上身图/平台套图页从后端持久任务拉取(与任务中心同源),
|
||||||
// 按 batch_id 归批还原批次流;assets 只含存活成图(软删的图已被后端过滤)。
|
// 按 batch_id 归批还原批次流;assets 只含存活成图(软删的图已被后端过滤)。
|
||||||
export type WorkbenchTask = {
|
export type WorkbenchTask = {
|
||||||
@@ -792,7 +802,10 @@ export type WorkbenchTask = {
|
|||||||
product_id: string;
|
product_id: string;
|
||||||
model_id: string;
|
model_id: string;
|
||||||
model_entity_id: string;
|
model_entity_id: string;
|
||||||
|
/** 旧平台套图记录的平台 id;新记录不再选平台,为空串 */
|
||||||
platform_id: string;
|
platform_id: string;
|
||||||
|
/** 平台套图商品信息(新记录才有;重跑时沿用) */
|
||||||
|
product_info?: CoverProductInfo | null;
|
||||||
/** 重跑/补图任务(复用原批次 batch_id 追加):不计入批次「应出张数」 */
|
/** 重跑/补图任务(复用原批次 batch_id 追加):不计入批次「应出张数」 */
|
||||||
rerun?: boolean;
|
rerun?: boolean;
|
||||||
retry_of_task_id?: string;
|
retry_of_task_id?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user