修改全能创作发现问题
This commit is contained in:
@@ -338,8 +338,8 @@ AIProvider
|
|||||||
|
|
||||||
当前模型决策来自 `account.md`,代码只读取环境变量:
|
当前模型决策来自 `account.md`,代码只读取环境变量:
|
||||||
|
|
||||||
- 文本主模型:DeepSeek-V3-2
|
- 文本主模型:Doubao Seed 2.1 Pro
|
||||||
- 文本备用模型:Doubao Seed 2.0 Pro / Lite
|
- 文本备用:不再使用 DeepSeek
|
||||||
- 图片模型:Seedream 5.0 Lite / 5.0 / 4.5
|
- 图片模型:Seedream 5.0 Lite / 5.0 / 4.5
|
||||||
- 视频模型:Seedance 2.0 / 2.0 Fast / 1.5 Pro
|
- 视频模型:Seedance 2.0 / 2.0 Fast / 1.5 Pro
|
||||||
|
|
||||||
|
|||||||
@@ -29,12 +29,23 @@ YUNQI_MODELS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
VOLCANO_MODELS = [
|
VOLCANO_MODELS = [
|
||||||
|
{
|
||||||
|
"display_name": "Doubao-Seed-2.1-Pro",
|
||||||
|
"name": "doubao-seed-2-1-pro-260628",
|
||||||
|
"capability": "text",
|
||||||
|
"endpoint": "chat/completions",
|
||||||
|
"metadata": {
|
||||||
|
"think": True,
|
||||||
|
"vision": True,
|
||||||
|
"source": "volcengine ark · Seed 2.1 Pro",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"display_name": "Doubao-Seed-2.0-Pro",
|
"display_name": "Doubao-Seed-2.0-Pro",
|
||||||
"name": "doubao-seed-2-0-pro-260215",
|
"name": "doubao-seed-2-0-pro-260215",
|
||||||
"capability": "text",
|
"capability": "text",
|
||||||
"endpoint": "chat/completions",
|
"endpoint": "chat/completions",
|
||||||
"metadata": {"think": True, "source": "video-flow/data/vendor/volcengine.ts"},
|
"metadata": {"think": True, "vision": True, "source": "video-flow/data/vendor/volcengine.ts"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"display_name": "Doubao-Seed-2.0-Lite",
|
"display_name": "Doubao-Seed-2.0-Lite",
|
||||||
@@ -184,7 +195,10 @@ def _catalog_capabilities(item: dict) -> dict:
|
|||||||
capability = item["capability"]
|
capability = item["capability"]
|
||||||
metadata = item["metadata"]
|
metadata = item["metadata"]
|
||||||
if capability == "text":
|
if capability == "text":
|
||||||
return {"operations": ["chat"], "features": ["streaming", "structured_output"]}
|
features = ["streaming", "structured_output"]
|
||||||
|
if metadata.get("vision"):
|
||||||
|
features.extend(["vision", "image_input", "multimodal"])
|
||||||
|
return {"operations": ["chat"], "features": features}
|
||||||
if capability == "image":
|
if capability == "image":
|
||||||
modes = set(metadata.get("modes") or [])
|
modes = set(metadata.get("modes") or [])
|
||||||
supports_reference = bool(metadata.get("supports_reference")) or bool(
|
supports_reference = bool(metadata.get("supports_reference")) or bool(
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from .creation import append_message, pin_refs
|
|||||||
from .creation_presets import preset_guidance
|
from .creation_presets import preset_guidance
|
||||||
from .mentions import TYPE_LABELS, infer_field_types, resolve_refs, search_mentions
|
from .mentions import TYPE_LABELS, infer_field_types, resolve_refs, search_mentions
|
||||||
from .models import CreationConversation, CreationMessage, ModelConfig
|
from .models import CreationConversation, CreationMessage, ModelConfig
|
||||||
from .services import build_provider, get_default_model
|
from .services import build_provider, get_default_model, resolve_text_model
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -209,6 +209,52 @@ def apply_session_params(conversation, fields, answers: dict) -> bool:
|
|||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_session_params(conversation) -> dict:
|
||||||
|
params = conversation.params or {}
|
||||||
|
return {
|
||||||
|
"model": str(params.get("model") or ""),
|
||||||
|
"resolution": str(params.get("resolution") or ""),
|
||||||
|
"ratio": str(params.get("ratio") or ""),
|
||||||
|
"duration": str(params.get("duration") or ""),
|
||||||
|
"count": str(params.get("count") or params.get("duration") or ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def confirm_param_options(is_video: bool) -> dict:
|
||||||
|
return {
|
||||||
|
"model": VIDEO_MODELS if is_video else IMAGE_MODELS,
|
||||||
|
"resolution": RESOLUTIONS if is_video else [],
|
||||||
|
"ratio": RATIOS,
|
||||||
|
"duration": VIDEO_DURATIONS if is_video else [],
|
||||||
|
"count": IMAGE_COUNTS if not is_video else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_confirm_params(conversation, incoming: dict | None) -> tuple[dict, bool]:
|
||||||
|
"""确认卡上改的参数写回会话。返回 (最新 params, 视频时长是否变了)。"""
|
||||||
|
current = dict(conversation.params or {})
|
||||||
|
old_duration = str(current.get("duration") or "")
|
||||||
|
changed = False
|
||||||
|
for key, raw in (incoming or {}).items():
|
||||||
|
if key not in {"model", "ratio", "resolution", "duration", "count"}:
|
||||||
|
continue
|
||||||
|
value = str(raw or "").strip()
|
||||||
|
if not value or current.get(key) == value:
|
||||||
|
continue
|
||||||
|
current[key] = value
|
||||||
|
changed = True
|
||||||
|
duration_changed = (
|
||||||
|
conversation.mode == CreationConversation.Mode.VIDEO
|
||||||
|
and str(current.get("duration") or "") != old_duration
|
||||||
|
and bool(str(current.get("duration") or ""))
|
||||||
|
and bool(old_duration)
|
||||||
|
)
|
||||||
|
if changed:
|
||||||
|
conversation.params = current
|
||||||
|
conversation.save(update_fields=["params", "updated_at"])
|
||||||
|
return snapshot_session_params(conversation), duration_changed
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- 工具 schema
|
# ---------------------------------------------------------------- 工具 schema
|
||||||
|
|
||||||
def tool_schemas(context: AgentContext) -> list[dict]:
|
def tool_schemas(context: AgentContext) -> list[dict]:
|
||||||
@@ -330,24 +376,6 @@ def tool_schemas(context: AgentContext) -> list[dict]:
|
|||||||
"required": ["start", "end", "stage"],
|
"required": ["start", "end", "stage"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"matrix": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "卖点覆盖矩阵:哪个卖点落在第几个镜头",
|
|
||||||
"properties": {
|
|
||||||
"shots": {"type": "integer"},
|
|
||||||
"rows": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"point": {"type": "string"},
|
|
||||||
"hits": {"type": "array", "items": {"type": "integer"}},
|
|
||||||
},
|
|
||||||
"required": ["point", "hits"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"voice_chars": {
|
"voice_chars": {
|
||||||
"type": "array", "items": {"type": "integer"},
|
"type": "array", "items": {"type": "integer"},
|
||||||
"description": "口播字数区间 [下限, 上限]",
|
"description": "口播字数区间 [下限, 上限]",
|
||||||
@@ -587,9 +615,134 @@ def submit_confirmed_video(*, conversation: CreationConversation, user, confirm_
|
|||||||
return message, ""
|
return message, ""
|
||||||
|
|
||||||
|
|
||||||
|
def submit_confirmed_image(*, conversation: CreationConversation, user, confirm_message: CreationMessage):
|
||||||
|
"""用户点了确认 → 按确认卡里存的画面描述出图。同样不跑一轮模型。"""
|
||||||
|
payload = confirm_message.payload or {}
|
||||||
|
prompt = str(payload.get("prompt") or payload.get("image_prompt") or "").strip()
|
||||||
|
if not prompt:
|
||||||
|
return None, "这条方案没有存下出图指令,请让我重新写一次。"
|
||||||
|
|
||||||
|
context = AgentContext(conversation=conversation, user=user, model_config=None)
|
||||||
|
try:
|
||||||
|
_result, tasks = _run_generate_image(context, {"prompt": prompt})
|
||||||
|
except AgentError as exc:
|
||||||
|
return None, str(exc)
|
||||||
|
except ValueError as exc:
|
||||||
|
return None, str(exc)
|
||||||
|
|
||||||
|
message = None
|
||||||
|
for task in tasks:
|
||||||
|
message = append_message(
|
||||||
|
conversation, role="assistant",
|
||||||
|
kind=CreationMessage.Kind.GENERATING,
|
||||||
|
payload={"task_id": str(task.id), "kind": "image", "prompt": prompt},
|
||||||
|
task=task,
|
||||||
|
)
|
||||||
|
if message is None:
|
||||||
|
return None, "出图没有提交成功,请再试一次。"
|
||||||
|
_remember_artifact(conversation, prompt, "image")
|
||||||
|
return message, ""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- 提示词
|
# ---------------------------------------------------------------- 提示词
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_creation_chat_model(requested: ModelConfig | None = None) -> ModelConfig | None:
|
||||||
|
"""全能创作对话模型:走平台文本解析,DeepSeek 一律换成 Seed 2.1 Pro。"""
|
||||||
|
return resolve_text_model(requested)
|
||||||
|
|
||||||
|
|
||||||
|
def _creation_model_sees_images(model_config: ModelConfig | None) -> bool:
|
||||||
|
"""对话模型能不能收图。参考图只在能看图时才塞进 chat messages,避免纯文本模型整轮失败。"""
|
||||||
|
if model_config is None:
|
||||||
|
return False
|
||||||
|
if getattr(model_config, "capability", "") == ModelConfig.Capability.VISION:
|
||||||
|
return True
|
||||||
|
name = str(getattr(model_config, "name", "") or "").lower()
|
||||||
|
# 豆包 Seed 2.x / 1.6 文本档都支持图文;vl / vision 后缀同理。
|
||||||
|
if name.startswith("doubao-seed-") or "vision" in name or name.endswith("-vl") or "-vl-" in name:
|
||||||
|
return True
|
||||||
|
metadata = model_config.metadata if isinstance(getattr(model_config, "metadata", None), dict) else {}
|
||||||
|
capabilities = metadata.get("capabilities") if isinstance(metadata.get("capabilities"), dict) else {}
|
||||||
|
features = {str(item) for item in capabilities.get("features") or []}
|
||||||
|
return bool({"vision", "image_input", "multimodal"} & features)
|
||||||
|
|
||||||
|
|
||||||
|
def _prefer_vision_text_model(current: ModelConfig | None, team, refs: list | None) -> ModelConfig | None:
|
||||||
|
"""有参考图时,尽量换成能看图的文本模型(豆包 Seed 等),否则聊天侧完全看不见男女。"""
|
||||||
|
if current is not None and _creation_model_sees_images(current):
|
||||||
|
return current
|
||||||
|
if not _ref_image_urls(team, refs):
|
||||||
|
return current
|
||||||
|
qs = (
|
||||||
|
ModelConfig.objects.select_related("provider")
|
||||||
|
.filter(
|
||||||
|
capability=ModelConfig.Capability.TEXT,
|
||||||
|
status=ModelConfig.Status.ACTIVE,
|
||||||
|
provider__status="active",
|
||||||
|
)
|
||||||
|
.order_by("created_at")
|
||||||
|
)
|
||||||
|
for candidate in qs:
|
||||||
|
if _creation_model_sees_images(candidate):
|
||||||
|
return candidate
|
||||||
|
return get_default_model(ModelConfig.Capability.VISION) or current
|
||||||
|
|
||||||
|
|
||||||
|
def _ref_image_urls(team, refs: list | None) -> list[str]:
|
||||||
|
resolved = resolve_refs(team, refs or [])
|
||||||
|
urls: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for item in resolved.references:
|
||||||
|
url = str(item.get("url") or "").strip()
|
||||||
|
if not url or url in seen:
|
||||||
|
continue
|
||||||
|
seen.add(url)
|
||||||
|
urls.append(url)
|
||||||
|
return urls[:6]
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_ref_images(messages: list[dict], image_urls: list[str]) -> list[dict]:
|
||||||
|
"""把锁定素材图挂到最近一条 user 消息上(OpenAI image_url 格式)。"""
|
||||||
|
if not image_urls or not messages:
|
||||||
|
return messages
|
||||||
|
note = (
|
||||||
|
f"【参考图·请亲眼看】下面 {len(image_urls)} 张是用户锁定的素材。"
|
||||||
|
"人物的性别、年龄段、发型、服装必须以图为准;看不清再问用户,禁止凭文件名猜测性别。"
|
||||||
|
)
|
||||||
|
out = [dict(message) for message in messages]
|
||||||
|
index = next((i for i in range(len(out) - 1, -1, -1) if out[i].get("role") == "user"), None)
|
||||||
|
if index is None:
|
||||||
|
content = [{"type": "text", "text": note}]
|
||||||
|
content.extend({"type": "image_url", "image_url": {"url": url}} for url in image_urls)
|
||||||
|
out.append({"role": "user", "content": content})
|
||||||
|
return out
|
||||||
|
last = dict(out[index])
|
||||||
|
raw = last.get("content")
|
||||||
|
if isinstance(raw, list):
|
||||||
|
content = list(raw)
|
||||||
|
text_bits = [str(item.get("text") or "") for item in content if isinstance(item, dict) and item.get("type") == "text"]
|
||||||
|
if not any(note[:8] in bit for bit in text_bits):
|
||||||
|
content.append({"type": "text", "text": note})
|
||||||
|
existing = {
|
||||||
|
(item.get("image_url") or {}).get("url")
|
||||||
|
for item in content
|
||||||
|
if isinstance(item, dict) and item.get("type") == "image_url"
|
||||||
|
}
|
||||||
|
content.extend(
|
||||||
|
{"type": "image_url", "image_url": {"url": url}}
|
||||||
|
for url in image_urls
|
||||||
|
if url not in existing
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
content = [{"type": "text", "text": f"{raw or ''}\n\n{note}".strip()}]
|
||||||
|
content.extend({"type": "image_url", "image_url": {"url": url}} for url in image_urls)
|
||||||
|
last["content"] = content
|
||||||
|
out[index] = last
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def build_system_prompt(context: AgentContext) -> str:
|
def build_system_prompt(context: AgentContext) -> str:
|
||||||
conversation = context.conversation
|
conversation = context.conversation
|
||||||
params = conversation.params or {}
|
params = conversation.params or {}
|
||||||
@@ -635,7 +788,9 @@ def build_system_prompt(context: AgentContext) -> str:
|
|||||||
lines.append("\n【本次会话已锁定的素材事实】")
|
lines.append("\n【本次会话已锁定的素材事实】")
|
||||||
lines.append(resolved.facts_text)
|
lines.append(resolved.facts_text)
|
||||||
lines.append(
|
lines.append(
|
||||||
"以上素材的参考图会自动附给生成模型锁人锁物,你在 prompt 里不需要重复描述它们的外观。"
|
"以上素材的参考图会附给你看,出片时也会自动锁人锁物。"
|
||||||
|
"人物性别、年龄段、发型、服装、商品颜色外形必须以图为准;"
|
||||||
|
"图上看不清或没附图时,必须问用户,禁止凭文件名猜测男女。"
|
||||||
)
|
)
|
||||||
memory = conversation.memory or {}
|
memory = conversation.memory or {}
|
||||||
if memory.get("summary"):
|
if memory.get("summary"):
|
||||||
@@ -682,6 +837,11 @@ def build_messages(context: AgentContext) -> list[dict]:
|
|||||||
messages.append({"role": "assistant", "content": f"(我生成了一版,prompt:{prompt[:200]})"})
|
messages.append({"role": "assistant", "content": f"(我生成了一版,prompt:{prompt[:200]})"})
|
||||||
elif message.kind == CreationMessage.Kind.ERROR:
|
elif message.kind == CreationMessage.Kind.ERROR:
|
||||||
messages.append({"role": "assistant", "content": f"(上一次生成失败:{message.text})"})
|
messages.append({"role": "assistant", "content": f"(上一次生成失败:{message.text})"})
|
||||||
|
if _creation_model_sees_images(context.model_config):
|
||||||
|
messages = _attach_ref_images(
|
||||||
|
messages,
|
||||||
|
_ref_image_urls(context.team, context.conversation.pinned_refs or []),
|
||||||
|
)
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
|
|
||||||
@@ -727,7 +887,7 @@ def stream_creation_agent(
|
|||||||
) -> Iterator[str]:
|
) -> Iterator[str]:
|
||||||
"""一条用户消息 → SSE 流。生成器,由 StreamingHttpResponse 逐帧下发。"""
|
"""一条用户消息 → SSE 流。生成器,由 StreamingHttpResponse 逐帧下发。"""
|
||||||
refs = refs or []
|
refs = refs or []
|
||||||
model_config = model_config or get_default_model(ModelConfig.Capability.TEXT)
|
model_config = get_creation_chat_model(model_config)
|
||||||
if model_config is None:
|
if model_config is None:
|
||||||
yield _sse({"type": "error", "detail": "没有可用的文本模型,请先在模型库配置"})
|
yield _sse({"type": "error", "detail": "没有可用的文本模型,请先在模型库配置"})
|
||||||
return
|
return
|
||||||
@@ -738,6 +898,8 @@ def stream_creation_agent(
|
|||||||
pin_refs(conversation, refs)
|
pin_refs(conversation, refs)
|
||||||
user_message = append_message(conversation, role="user", text=text, refs=refs)
|
user_message = append_message(conversation, role="user", text=text, refs=refs)
|
||||||
yield _sse({"type": "message", "message": _message_payload(user_message)})
|
yield _sse({"type": "message", "message": _message_payload(user_message)})
|
||||||
|
model_config = _prefer_vision_text_model(model_config, conversation.team, conversation.pinned_refs or [])
|
||||||
|
context.model_config = model_config
|
||||||
|
|
||||||
resolved = resolve_refs(context.team, refs)
|
resolved = resolve_refs(context.team, refs)
|
||||||
if resolved.missing:
|
if resolved.missing:
|
||||||
@@ -898,7 +1060,6 @@ def _dispatch_tool(context: AgentContext, name: str, args: dict) -> tuple[dict,
|
|||||||
"usp": str(args.get("usp") or ""),
|
"usp": str(args.get("usp") or ""),
|
||||||
"points": [str(p) for p in (args.get("points") or [])][:3],
|
"points": [str(p) for p in (args.get("points") or [])][:3],
|
||||||
"timeline": args.get("timeline") or [],
|
"timeline": args.get("timeline") or [],
|
||||||
"matrix": args.get("matrix") or {},
|
|
||||||
"voice_chars": args.get("voice_chars") or [],
|
"voice_chars": args.get("voice_chars") or [],
|
||||||
"ref_count": len(context.conversation.pinned_refs or []),
|
"ref_count": len(context.conversation.pinned_refs or []),
|
||||||
}
|
}
|
||||||
@@ -922,8 +1083,15 @@ def _dispatch_tool(context: AgentContext, name: str, args: dict) -> tuple[dict,
|
|||||||
confirm = append_message(
|
confirm = append_message(
|
||||||
context.conversation, role="assistant",
|
context.conversation, role="assistant",
|
||||||
kind=CreationMessage.Kind.CONFIRM,
|
kind=CreationMessage.Kind.CONFIRM,
|
||||||
payload={"label": "开始生成", "estimated_credits": credits,
|
payload={
|
||||||
"video_prompt": video_prompt, "submitted": False},
|
"kind": "video",
|
||||||
|
"label": "开始生成",
|
||||||
|
"estimated_credits": credits,
|
||||||
|
"video_prompt": video_prompt,
|
||||||
|
"submitted": False,
|
||||||
|
"params": snapshot_session_params(context.conversation),
|
||||||
|
"param_options": confirm_param_options(True),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
events.append({"type": "message", "message": _message_payload(confirm)})
|
events.append({"type": "message", "message": _message_payload(confirm)})
|
||||||
events.append({"type": "credits", "estimated": credits})
|
events.append({"type": "credits", "estimated": credits})
|
||||||
@@ -934,19 +1102,25 @@ def _dispatch_tool(context: AgentContext, name: str, args: dict) -> tuple[dict,
|
|||||||
if context.generations_used >= MAX_BILLED_GENERATIONS:
|
if context.generations_used >= MAX_BILLED_GENERATIONS:
|
||||||
# 一条用户消息只计费一次。模型想连出好几版时在这里挡住。
|
# 一条用户消息只计费一次。模型想连出好几版时在这里挡住。
|
||||||
return {"payload": {"error": "本轮已经生成过一次了,请让用户看过再决定要不要改"}}, True
|
return {"payload": {"error": "本轮已经生成过一次了,请让用户看过再决定要不要改"}}, True
|
||||||
payload, tasks = _run_generate_image(context, args)
|
prompt = str(args.get("prompt") or "").strip()
|
||||||
events = []
|
if not prompt:
|
||||||
for task in tasks:
|
return {"payload": {"error": "生成失败:模型没有给出画面描述"}}, False
|
||||||
message = append_message(
|
# 出图也走确认卡:用户先看当前模型/比例/张数,点了才提交。
|
||||||
context.conversation, role="assistant",
|
confirm = append_message(
|
||||||
kind=CreationMessage.Kind.GENERATING,
|
context.conversation, role="assistant",
|
||||||
payload={"task_id": str(task.id), "kind": "image", "prompt": args.get("prompt", "")},
|
kind=CreationMessage.Kind.CONFIRM,
|
||||||
task=task,
|
payload={
|
||||||
)
|
"kind": "image",
|
||||||
events.append({"type": "message", "message": _message_payload(message)})
|
"label": "开始生成",
|
||||||
events.append({"type": "task", "task_id": str(task.id), "kind": "image"})
|
"estimated_credits": 0,
|
||||||
_remember_artifact(context.conversation, args.get("prompt", ""), "image")
|
"prompt": prompt,
|
||||||
return {"payload": payload, "_events": events}, True
|
"submitted": False,
|
||||||
|
"params": snapshot_session_params(context.conversation),
|
||||||
|
"param_options": confirm_param_options(False),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
events = [{"type": "message", "message": _message_payload(confirm)}]
|
||||||
|
return {"payload": {"awaiting_confirmation": True}, "_events": events}, True
|
||||||
|
|
||||||
return {"payload": {"error": f"未知工具 {name}"}}, False
|
return {"payload": {"error": f"未知工具 {name}"}}, False
|
||||||
|
|
||||||
|
|||||||
@@ -839,6 +839,8 @@ def _store_free_video_media(*, task: AITask, media: str = "", video_bytes: bytes
|
|||||||
if feature == "video_replace":
|
if feature == "video_replace":
|
||||||
mode_label = "角色复刻" if payload.get("replace_mode") == "character" else "商品复刻"
|
mode_label = "角色复刻" if payload.get("replace_mode") == "character" else "商品复刻"
|
||||||
asset_name = f"{subject}{mode_label}" if subject else mode_label
|
asset_name = f"{subject}{mode_label}" if subject else mode_label
|
||||||
|
elif feature == "omni_create":
|
||||||
|
asset_name = prompt[:255] or "全能创作视频"
|
||||||
else:
|
else:
|
||||||
asset_name = prompt[:255] or "自由创作视频"
|
asset_name = prompt[:255] or "自由创作视频"
|
||||||
asset = Asset.objects.create(
|
asset = Asset.objects.create(
|
||||||
@@ -852,6 +854,8 @@ def _store_free_video_media(*, task: AITask, media: str = "", video_bytes: bytes
|
|||||||
source=Asset.Source.AI_GENERATED,
|
source=Asset.Source.AI_GENERATED,
|
||||||
category=Asset.Category.FREE_CREATE,
|
category=Asset.Category.FREE_CREATE,
|
||||||
origin_task=task,
|
origin_task=task,
|
||||||
|
# 全能创作成品只挂在会话结果卡上,不进资产库/专业创作用的视频列表。
|
||||||
|
in_library=feature != "omni_create",
|
||||||
metadata={"feature": feature},
|
metadata={"feature": feature},
|
||||||
)
|
)
|
||||||
AssetFile.objects.create(
|
AssetFile.objects.create(
|
||||||
|
|||||||
@@ -231,6 +231,19 @@ def refs_from_elicit_answers(team, fields, answers: dict) -> list[dict]:
|
|||||||
return refs
|
return refs
|
||||||
|
|
||||||
|
|
||||||
|
def _person_meta_facts(metadata) -> list[str]:
|
||||||
|
"""角色/模特库里填过的性别、年龄。聊天模型不一定看得到图,这些字必须进事实块。"""
|
||||||
|
meta = metadata if isinstance(metadata, dict) else {}
|
||||||
|
lines = []
|
||||||
|
gender = str(meta.get("gender") or meta.get("sex") or "").strip()
|
||||||
|
age = str(meta.get("age") or meta.get("age_range") or "").strip()
|
||||||
|
if gender:
|
||||||
|
lines.append(f"性别:{gender}")
|
||||||
|
if age:
|
||||||
|
lines.append(f"年龄:{age}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def product_facts_text(product) -> str:
|
def product_facts_text(product) -> str:
|
||||||
"""商品事实块。全能创作没有 project,所以不能复用 script_agent._product_context()。
|
"""商品事实块。全能创作没有 project,所以不能复用 script_agent._product_context()。
|
||||||
这里只给**客观事实**(标题/品牌/品类/规格/卖点),不带人设和口吻 —— 那些由策略卡决定。"""
|
这里只给**客观事实**(标题/品牌/品类/规格/卖点),不带人设和口吻 —— 那些由策略卡决定。"""
|
||||||
@@ -270,6 +283,56 @@ def _asset_reference(asset, type_: str, label: str) -> dict | None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _product_triview_asset(product):
|
||||||
|
"""商品三视图是独立 Asset(metadata.view=three_view),不在商品图列表里。没有就返回 None。"""
|
||||||
|
return (
|
||||||
|
Asset.objects.filter(
|
||||||
|
team_id=product.team_id,
|
||||||
|
is_deleted=False,
|
||||||
|
purged_at__isnull=True,
|
||||||
|
metadata__product_id=str(product.id),
|
||||||
|
metadata__view="three_view",
|
||||||
|
)
|
||||||
|
.order_by("-created_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _character_triview_asset(team, portrait_id):
|
||||||
|
"""角色立绘配对的三视图:metadata.triview_of = 立绘 id。没有就返回 None。"""
|
||||||
|
if not portrait_id:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
Asset.objects.filter(
|
||||||
|
team=team,
|
||||||
|
is_deleted=False,
|
||||||
|
purged_at__isnull=True,
|
||||||
|
category=Asset.Category.TRI_VIEW,
|
||||||
|
metadata__triview_of=str(portrait_id),
|
||||||
|
)
|
||||||
|
.order_by("-created_at")
|
||||||
|
.first()
|
||||||
|
) or (
|
||||||
|
Asset.objects.filter(
|
||||||
|
team=team,
|
||||||
|
is_deleted=False,
|
||||||
|
purged_at__isnull=True,
|
||||||
|
metadata__triview_of=str(portrait_id),
|
||||||
|
)
|
||||||
|
.order_by("-created_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_ref(resolved: ResolvedRefs, asset, type_: str, label: str) -> bool:
|
||||||
|
entry = _asset_reference(asset, type_, label)
|
||||||
|
if not entry:
|
||||||
|
return False
|
||||||
|
resolved.references.append(entry)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _product_reference(product) -> dict | None:
|
def _product_reference(product) -> dict | None:
|
||||||
"""商品参考图:**真实上传图优先,排除 AI 生成图** —— 拿生成图当真相再喂回模型会误差累积。
|
"""商品参考图:**真实上传图优先,排除 AI 生成图** —— 拿生成图当真相再喂回模型会误差累积。
|
||||||
一张真实图都没有才回落封面(可能是 AI 图,但好过纯文生图)。
|
一张真实图都没有才回落封面(可能是 AI 图,但好过纯文生图)。
|
||||||
@@ -314,6 +377,9 @@ def resolve_refs(team, refs: list[dict]) -> ResolvedRefs:
|
|||||||
entry = _product_reference(product)
|
entry = _product_reference(product)
|
||||||
if entry:
|
if entry:
|
||||||
resolved.references.append(entry)
|
resolved.references.append(entry)
|
||||||
|
triview = _product_triview_asset(product)
|
||||||
|
if triview is not None:
|
||||||
|
_append_ref(resolved, triview, "product", f"{product.title}三视图")
|
||||||
continue
|
continue
|
||||||
if type_ == "model":
|
if type_ == "model":
|
||||||
model = Model.objects.filter(
|
model = Model.objects.filter(
|
||||||
@@ -322,15 +388,23 @@ def resolve_refs(team, refs: list[dict]) -> ResolvedRefs:
|
|||||||
if model is None:
|
if model is None:
|
||||||
resolved.missing.append(ref)
|
resolved.missing.append(ref)
|
||||||
continue
|
continue
|
||||||
if (model.description or "").strip():
|
person_lines = []
|
||||||
resolved.facts.append(f"模特「{model.name}」:{model.description.strip()}")
|
desc = (model.description or "").strip()
|
||||||
# 锁脸优先用三视图(正/侧/背都在一张 16:9 里,信息量最大),没有才回落形象图
|
meta_lines = _person_meta_facts(model.metadata)
|
||||||
entry = _asset_reference(model.triview_asset, "model", model.name) or _asset_reference(
|
if desc:
|
||||||
model.portrait_asset, "model", model.name
|
person_lines.append(f"模特「{model.name}」:{desc}")
|
||||||
)
|
|
||||||
if entry:
|
|
||||||
resolved.references.append(entry)
|
|
||||||
else:
|
else:
|
||||||
|
person_lines.append(f"模特「{model.name}」")
|
||||||
|
person_lines.extend(meta_lines)
|
||||||
|
if desc or meta_lines:
|
||||||
|
resolved.facts.append("\n".join(person_lines))
|
||||||
|
# 形象图和三视图都带上(有就带,没有不加、不报错)。三视图锁脸更稳。
|
||||||
|
added = False
|
||||||
|
if _append_ref(resolved, model.portrait_asset, "model", model.name):
|
||||||
|
added = True
|
||||||
|
if _append_ref(resolved, model.triview_asset, "model", f"{model.name}三视图"):
|
||||||
|
added = True
|
||||||
|
if not added:
|
||||||
resolved.missing.append(ref)
|
resolved.missing.append(ref)
|
||||||
continue
|
continue
|
||||||
asset = Asset.objects.filter(
|
asset = Asset.objects.filter(
|
||||||
@@ -339,12 +413,23 @@ def resolve_refs(team, refs: list[dict]) -> ResolvedRefs:
|
|||||||
if asset is None:
|
if asset is None:
|
||||||
resolved.missing.append(ref)
|
resolved.missing.append(ref)
|
||||||
continue
|
continue
|
||||||
if (asset.description or "").strip():
|
desc = (asset.description or "").strip()
|
||||||
resolved.facts.append(f"{TYPE_LABELS[type_]}「{asset.name}」:{asset.description.strip()}")
|
meta_lines = _person_meta_facts(asset.metadata)
|
||||||
entry = _asset_reference(asset, type_, asset.name)
|
label = TYPE_LABELS[type_]
|
||||||
if entry:
|
person_lines = [f"{label}「{asset.name}」:{desc}" if desc else f"{label}「{asset.name}」"]
|
||||||
resolved.references.append(entry)
|
person_lines.extend(meta_lines)
|
||||||
else:
|
if desc or meta_lines:
|
||||||
|
resolved.facts.append("\n".join(person_lines))
|
||||||
|
added = _append_ref(resolved, asset, type_, asset.name)
|
||||||
|
# 角色/模特立绘若有配对三视图,一并带给出片;没有就跳过,不要当成引用失败。
|
||||||
|
if type_ in {"character", "model", "asset"}:
|
||||||
|
portrait_id = asset.id
|
||||||
|
# 用户 @ 的就是三视图本身时,不再反查。
|
||||||
|
if asset.category != Asset.Category.TRI_VIEW:
|
||||||
|
paired = _character_triview_asset(team, portrait_id)
|
||||||
|
if paired is not None:
|
||||||
|
_append_ref(resolved, paired, type_, f"{asset.name}三视图")
|
||||||
|
if not added:
|
||||||
resolved.missing.append(ref)
|
resolved.missing.append(ref)
|
||||||
|
|
||||||
# 角色 → 场景 → 商品。同优先级内保持用户 @ 的先后。
|
# 角色 → 场景 → 商品。同优先级内保持用户 @ 的先后。
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""把默认文本模型换成火山 Doubao-Seed-2.1-Pro。
|
||||||
|
|
||||||
|
全能创作 / 实体提取 / 脚本思考都走 get_default_model(TEXT)。
|
||||||
|
此前后台默认是 DeepSeek(DP V4 Pro),看不了参考图,聊天里分不出男女。
|
||||||
|
Seed 2.1 Pro 支持图文 + function calling,模型 ID: doubao-seed-2-1-pro-260628。
|
||||||
|
|
||||||
|
幂等:可重复 apply。不改其它能力(生图/视频)的 is_default。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
NAME = "doubao-seed-2-1-pro-260628"
|
||||||
|
DISPLAY = "Doubao-Seed-2.1-Pro"
|
||||||
|
METADATA = {
|
||||||
|
"think": True,
|
||||||
|
"vision": True,
|
||||||
|
"source": "volcengine ark · Seed 2.1 Pro",
|
||||||
|
"routing": {"fallback_on_failure": False, "fallback_candidate": True},
|
||||||
|
"capabilities": {
|
||||||
|
"operations": ["chat"],
|
||||||
|
"features": ["streaming", "structured_output", "vision", "image_input", "multimodal"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def seed(apps, schema_editor):
|
||||||
|
ModelProvider = apps.get_model("ai", "ModelProvider")
|
||||||
|
ModelConfig = apps.get_model("ai", "ModelConfig")
|
||||||
|
|
||||||
|
provider, _ = ModelProvider.objects.get_or_create(
|
||||||
|
name="volcengine",
|
||||||
|
defaults={
|
||||||
|
"display_name": "火山引擎(豆包)",
|
||||||
|
"status": "active",
|
||||||
|
"base_url": "https://ark.cn-beijing.volces.com/api/v3",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
config, created = ModelConfig.objects.get_or_create(
|
||||||
|
provider=provider,
|
||||||
|
name=NAME,
|
||||||
|
capability="text",
|
||||||
|
defaults={
|
||||||
|
"display_name": DISPLAY,
|
||||||
|
"endpoint": "chat/completions",
|
||||||
|
"status": "active",
|
||||||
|
"is_default": True,
|
||||||
|
"metadata": METADATA,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
metadata = dict(config.metadata or {})
|
||||||
|
for key, value in METADATA.items():
|
||||||
|
if key == "capabilities":
|
||||||
|
caps = dict(metadata.get("capabilities") or {})
|
||||||
|
caps.setdefault("operations", value["operations"])
|
||||||
|
features = list(caps.get("features") or [])
|
||||||
|
for feat in value["features"]:
|
||||||
|
if feat not in features:
|
||||||
|
features.append(feat)
|
||||||
|
caps["features"] = features
|
||||||
|
metadata["capabilities"] = caps
|
||||||
|
else:
|
||||||
|
metadata.setdefault(key, value)
|
||||||
|
config.metadata = metadata
|
||||||
|
config.display_name = config.display_name or DISPLAY
|
||||||
|
config.endpoint = config.endpoint or "chat/completions"
|
||||||
|
config.status = "active"
|
||||||
|
config.is_default = True
|
||||||
|
config.save()
|
||||||
|
|
||||||
|
# 同一能力只留一个默认:清掉 DeepSeek 等其它文本模型的 is_default。
|
||||||
|
ModelConfig.objects.filter(capability="text", is_default=True).exclude(pk=config.pk).update(is_default=False)
|
||||||
|
|
||||||
|
|
||||||
|
def unseed(apps, schema_editor):
|
||||||
|
ModelConfig = apps.get_model("ai", "ModelConfig")
|
||||||
|
ModelConfig.objects.filter(name=NAME, capability="text").update(is_default=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("ai", "0034_creationconversation_creationmessage_and_more"),
|
||||||
|
]
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(seed, unseed),
|
||||||
|
]
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""彻底停用 DeepSeek 文本模型,默认只留 Seed 2.1 Pro。
|
||||||
|
|
||||||
|
幂等:可重复 apply。不删行,方便后台还能看见历史配置。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
|
||||||
|
def apply(apps, schema_editor):
|
||||||
|
ModelConfig = apps.get_model("ai", "ModelConfig")
|
||||||
|
retired = ModelConfig.objects.filter(capability="text").filter(
|
||||||
|
Q(name__icontains="deepseek") | Q(display_name__icontains="deepseek") | Q(display_name__icontains="DP V4")
|
||||||
|
)
|
||||||
|
retired.update(status="disabled", is_default=False)
|
||||||
|
seed = (
|
||||||
|
ModelConfig.objects.filter(name="doubao-seed-2-1-pro-260628", capability="text")
|
||||||
|
.order_by("created_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if seed is None:
|
||||||
|
return
|
||||||
|
seed.status = "active"
|
||||||
|
seed.is_default = True
|
||||||
|
seed.save(update_fields=["status", "is_default", "updated_at"])
|
||||||
|
ModelConfig.objects.filter(capability="text", is_default=True).exclude(pk=seed.pk).update(is_default=False)
|
||||||
|
|
||||||
|
|
||||||
|
def noop(apps, schema_editor):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("ai", "0035_seed_21_pro_default_text"),
|
||||||
|
]
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(apply, noop),
|
||||||
|
]
|
||||||
@@ -54,11 +54,61 @@ logger = logging.getLogger(__name__)
|
|||||||
OFFICIAL_DIRECT_PROVIDERS = {"volcengine", "volcano", "ark", "volcano_ark", "doubao"}
|
OFFICIAL_DIRECT_PROVIDERS = {"volcengine", "volcano", "ark", "volcano_ark", "doubao"}
|
||||||
|
|
||||||
|
|
||||||
|
SEED_21_PRO_NAME = "doubao-seed-2-1-pro-260628"
|
||||||
|
|
||||||
|
|
||||||
|
def is_retired_text_model(model) -> bool:
|
||||||
|
"""DeepSeek / DP V4 Pro 已停用,解析层一律跳过。"""
|
||||||
|
blob = f"{getattr(model, 'name', '')} {getattr(model, 'display_name', '')}".lower()
|
||||||
|
return "deepseek" in blob or "dp v4" in blob or "dp-v4" in blob
|
||||||
|
|
||||||
|
|
||||||
|
def get_seed_text_model() -> ModelConfig | None:
|
||||||
|
qs = (
|
||||||
|
ModelConfig.objects.select_related("provider")
|
||||||
|
.filter(
|
||||||
|
capability=ModelConfig.Capability.TEXT,
|
||||||
|
status=ModelConfig.Status.ACTIVE,
|
||||||
|
provider__status="active",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
qs.filter(name=SEED_21_PRO_NAME).first()
|
||||||
|
or qs.filter(name__startswith="doubao-seed-2-1-pro").first()
|
||||||
|
or qs.filter(name__startswith="doubao-seed-2-0-pro").first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_text_model(requested: ModelConfig | None = None, requested_id=None) -> ModelConfig | None:
|
||||||
|
"""文本模型入口:用户选了 DeepSeek 也改走 Seed 2.1 Pro。"""
|
||||||
|
model = requested
|
||||||
|
if model is None and requested_id:
|
||||||
|
model = (
|
||||||
|
ModelConfig.objects.select_related("provider")
|
||||||
|
.filter(id=requested_id, capability=ModelConfig.Capability.TEXT, status=ModelConfig.Status.ACTIVE)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if model is not None and not is_retired_text_model(model):
|
||||||
|
return model
|
||||||
|
return get_default_model(ModelConfig.Capability.TEXT)
|
||||||
|
|
||||||
|
|
||||||
def get_default_model(capability: str) -> ModelConfig:
|
def get_default_model(capability: str) -> ModelConfig:
|
||||||
qs = (
|
qs = (
|
||||||
ModelConfig.objects.select_related("provider")
|
ModelConfig.objects.select_related("provider")
|
||||||
.filter(capability=capability, status=ModelConfig.Status.ACTIVE, provider__status="active")
|
.filter(capability=capability, status=ModelConfig.Status.ACTIVE, provider__status="active")
|
||||||
)
|
)
|
||||||
|
if capability == ModelConfig.Capability.TEXT:
|
||||||
|
for model in qs.filter(is_default=True).order_by("created_at"):
|
||||||
|
if not is_retired_text_model(model):
|
||||||
|
return model
|
||||||
|
seed = get_seed_text_model()
|
||||||
|
if seed is not None:
|
||||||
|
return seed
|
||||||
|
for model in qs.order_by("created_at"):
|
||||||
|
if not is_retired_text_model(model):
|
||||||
|
return model
|
||||||
|
return None
|
||||||
# 优先平台超管钦定的默认模型;未钦定则回落「最早创建的 active」(原行为,零回归)
|
# 优先平台超管钦定的默认模型;未钦定则回落「最早创建的 active」(原行为,零回归)
|
||||||
return qs.filter(is_default=True).order_by("created_at").first() or qs.order_by("created_at").first()
|
return qs.filter(is_default=True).order_by("created_at").first() or qs.order_by("created_at").first()
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from apps.products.models import Product, ProductImage
|
|||||||
|
|
||||||
from .creation import append_message
|
from .creation import append_message
|
||||||
from .creation_agent import (
|
from .creation_agent import (
|
||||||
|
AgentContext,
|
||||||
COMPRESS_MIN_BATCH,
|
COMPRESS_MIN_BATCH,
|
||||||
DEFAULT_VIDEO_MODEL,
|
DEFAULT_VIDEO_MODEL,
|
||||||
KEEP_RECENT_MESSAGES,
|
KEEP_RECENT_MESSAGES,
|
||||||
@@ -22,7 +23,10 @@ from .creation_agent import (
|
|||||||
_coerce_fields,
|
_coerce_fields,
|
||||||
_image_count,
|
_image_count,
|
||||||
_merge_tool_call_deltas,
|
_merge_tool_call_deltas,
|
||||||
|
build_messages,
|
||||||
|
get_creation_chat_model,
|
||||||
stream_creation_agent,
|
stream_creation_agent,
|
||||||
|
submit_confirmed_image,
|
||||||
submit_confirmed_video,
|
submit_confirmed_video,
|
||||||
video_duration,
|
video_duration,
|
||||||
video_model_name,
|
video_model_name,
|
||||||
@@ -241,51 +245,67 @@ class GenerateImageTests(CreationAgentBaseTests):
|
|||||||
ProductImage.objects.create(product=self.product, asset=asset, is_primary=True)
|
ProductImage.objects.create(product=self.product, asset=asset, is_primary=True)
|
||||||
self.product_asset = asset
|
self.product_asset = asset
|
||||||
|
|
||||||
def test_generate_image_submits_with_pinned_reference_and_emits_task(self):
|
def _confirm_from_events(self, events):
|
||||||
|
confirm = [e for e in events if e.get("type") == "message" and e["message"]["kind"] == "confirm"]
|
||||||
|
self.assertEqual(len(confirm), 1)
|
||||||
|
return CreationMessage.objects.get(id=confirm[0]["message"]["id"])
|
||||||
|
|
||||||
|
def test_generate_image_emits_confirm_then_submits_with_pinned_reference(self):
|
||||||
|
events, _ = self._run(
|
||||||
|
[_tool_chunks("generate_image", {"prompt": "干净棚拍,柔光,居中构图"})],
|
||||||
|
refs=[{"type": "product", "id": str(self.product.id), "name": "净颜精华"}],
|
||||||
|
)
|
||||||
|
card = self._confirm_from_events(events)
|
||||||
|
self.assertEqual(card.payload["kind"], "image")
|
||||||
|
self.assertEqual(card.payload["prompt"], "干净棚拍,柔光,居中构图")
|
||||||
|
self.assertFalse(any(e.get("type") == "task" for e in events))
|
||||||
|
|
||||||
with patch("apps.ai.services.enqueue_standalone_images") as enqueue:
|
with patch("apps.ai.services.enqueue_standalone_images") as enqueue:
|
||||||
enqueue.return_value = [self._fake_task("k-ref")]
|
enqueue.return_value = [self._fake_task("k-ref")]
|
||||||
events, _ = self._run(
|
message, error = submit_confirmed_image(
|
||||||
[_tool_chunks("generate_image", {"prompt": "干净棚拍,柔光,居中构图"})],
|
conversation=self.conversation, user=self.user, confirm_message=card,
|
||||||
refs=[{"type": "product", "id": str(self.product.id), "name": "净颜精华"}],
|
|
||||||
)
|
)
|
||||||
kwargs = enqueue.call_args.kwargs
|
kwargs = enqueue.call_args.kwargs
|
||||||
|
|
||||||
|
self.assertEqual(error, "")
|
||||||
|
self.assertEqual(message.kind, CreationMessage.Kind.GENERATING)
|
||||||
# @ 引用的商品图必须作为参考图带上,否则出的图跟商品长得不一样
|
# @ 引用的商品图必须作为参考图带上,否则出的图跟商品长得不一样
|
||||||
self.assertEqual(kwargs["reference_image_ids"], [str(self.product_asset.id)])
|
self.assertEqual(kwargs["reference_image_ids"], [str(self.product_asset.id)])
|
||||||
self.assertEqual(kwargs["ratio"], "1:1") # 会话级参数直接用,不再问用户
|
self.assertEqual(kwargs["ratio"], "1:1") # 会话级参数直接用,不再问用户
|
||||||
self.assertEqual(kwargs["count"], 1)
|
self.assertEqual(kwargs["count"], 1)
|
||||||
|
|
||||||
task_events = [e for e in events if e.get("type") == "task"]
|
|
||||||
self.assertEqual(len(task_events), 1)
|
|
||||||
generating = [e for e in events if e.get("type") == "message" and e["message"]["kind"] == "generating"]
|
|
||||||
self.assertEqual(len(generating), 1)
|
|
||||||
|
|
||||||
def test_session_image_count_overrides_model_count(self):
|
def test_session_image_count_overrides_model_count(self):
|
||||||
self.conversation.params = {"ratio": "1:1", "count": "2 张"}
|
self.conversation.params = {"ratio": "1:1", "count": "2 张"}
|
||||||
self.conversation.save(update_fields=["params"])
|
self.conversation.save(update_fields=["params"])
|
||||||
|
events, _ = self._run(
|
||||||
|
[_tool_chunks("generate_image", {"prompt": "白底棚拍", "count": 1})],
|
||||||
|
)
|
||||||
|
card = self._confirm_from_events(events)
|
||||||
with patch("apps.ai.services.enqueue_standalone_images") as enqueue:
|
with patch("apps.ai.services.enqueue_standalone_images") as enqueue:
|
||||||
enqueue.return_value = [self._fake_task("k-count-1"), self._fake_task("k-count-2")]
|
enqueue.return_value = [self._fake_task("k-count-1"), self._fake_task("k-count-2")]
|
||||||
events, _ = self._run(
|
submit_confirmed_image(
|
||||||
[_tool_chunks("generate_image", {"prompt": "白底棚拍", "count": 1})],
|
conversation=self.conversation, user=self.user, confirm_message=card,
|
||||||
)
|
)
|
||||||
self.assertEqual(enqueue.call_args.kwargs["count"], 2)
|
self.assertEqual(enqueue.call_args.kwargs["count"], 2)
|
||||||
generating = [e for e in events if e.get("type") == "message" and e["message"]["kind"] == "generating"]
|
|
||||||
self.assertEqual(len(generating), 2)
|
|
||||||
|
|
||||||
def test_second_generation_in_one_turn_is_blocked(self):
|
def test_second_generation_in_one_turn_is_blocked(self):
|
||||||
with patch("apps.ai.services.enqueue_standalone_images") as enqueue:
|
events, fake = self._run([
|
||||||
enqueue.return_value = [self._fake_task("k-twice")]
|
_tool_chunks("generate_image", {"prompt": "第一版"}),
|
||||||
self._run([
|
_tool_chunks("generate_image", {"prompt": "第二版"}),
|
||||||
_tool_chunks("generate_image", {"prompt": "第一版"}),
|
])
|
||||||
_tool_chunks("generate_image", {"prompt": "第二版"}),
|
# 确认闸门打断循环:模型不能连出两张未确认的图
|
||||||
])
|
self.assertEqual(len(fake.calls), 1)
|
||||||
# 一条用户消息只计费一次:一句「多做几版」不能烧掉一堆积分
|
confirms = [e for e in events if e.get("type") == "message" and e["message"]["kind"] == "confirm"]
|
||||||
self.assertEqual(enqueue.call_count, 1)
|
self.assertEqual(len(confirms), 1)
|
||||||
|
|
||||||
def test_prompt_is_remembered_for_the_next_revision(self):
|
def test_prompt_is_remembered_for_the_next_revision(self):
|
||||||
|
events, _ = self._run([_tool_chunks("generate_image", {"prompt": "白底棚拍"})])
|
||||||
|
card = self._confirm_from_events(events)
|
||||||
with patch("apps.ai.services.enqueue_standalone_images") as enqueue:
|
with patch("apps.ai.services.enqueue_standalone_images") as enqueue:
|
||||||
enqueue.return_value = [self._fake_task("k-memory")]
|
enqueue.return_value = [self._fake_task("k-memory")]
|
||||||
self._run([_tool_chunks("generate_image", {"prompt": "白底棚拍"})])
|
submit_confirmed_image(
|
||||||
|
conversation=self.conversation, user=self.user, confirm_message=card,
|
||||||
|
)
|
||||||
self.conversation.refresh_from_db()
|
self.conversation.refresh_from_db()
|
||||||
# 产物索引:下一轮「背景换夜景」要靠它知道在改哪一版
|
# 产物索引:下一轮「背景换夜景」要靠它知道在改哪一版
|
||||||
self.assertEqual(self.conversation.memory["artifacts"][-1]["prompt"], "白底棚拍")
|
self.assertEqual(self.conversation.memory["artifacts"][-1]["prompt"], "白底棚拍")
|
||||||
@@ -342,13 +362,24 @@ class SseFramingTests(CreationAgentBaseTests):
|
|||||||
team=self.team, created_by=self.user, task_type=AITask.Type.PRODUCT_IMAGE,
|
team=self.team, created_by=self.user, task_type=AITask.Type.PRODUCT_IMAGE,
|
||||||
model_config=self.model, idempotency_key="k-sse",
|
model_config=self.model, idempotency_key="k-sse",
|
||||||
)
|
)
|
||||||
|
events, _ = self._run([_tool_chunks("generate_image", {"prompt": "白底棚拍"})])
|
||||||
|
card = next(e["message"] for e in events if e.get("type") == "message"
|
||||||
|
and e["message"]["kind"] == "confirm")
|
||||||
|
confirm = CreationMessage.objects.get(id=card["id"])
|
||||||
with patch("apps.ai.services.enqueue_standalone_images", return_value=[task]):
|
with patch("apps.ai.services.enqueue_standalone_images", return_value=[task]):
|
||||||
events, _ = self._run([_tool_chunks("generate_image", {"prompt": "白底棚拍"})])
|
message, error = submit_confirmed_image(
|
||||||
|
conversation=self.conversation, user=self.user, confirm_message=confirm,
|
||||||
|
)
|
||||||
|
|
||||||
self.assertNotIn("error", [e.get("type") for e in events])
|
self.assertEqual(error, "")
|
||||||
generating = next(e for e in events if e.get("type") == "message"
|
payload = {
|
||||||
and e["message"]["kind"] == "generating")
|
"id": str(message.id),
|
||||||
self.assertEqual(generating["message"]["task"], str(task.id))
|
"kind": message.kind,
|
||||||
|
"task": str(message.task_id),
|
||||||
|
"created_at": message.created_at,
|
||||||
|
}
|
||||||
|
encoded = json.dumps(payload, cls=__import__("django.core.serializers.json", fromlist=["DjangoJSONEncoder"]).DjangoJSONEncoder)
|
||||||
|
self.assertIn(str(task.id), encoded)
|
||||||
|
|
||||||
|
|
||||||
class SendEndpointTests(TestCase):
|
class SendEndpointTests(TestCase):
|
||||||
@@ -464,7 +495,6 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
|||||||
"usp": "核心效果:一整天不泛油光",
|
"usp": "核心效果:一整天不泛油光",
|
||||||
"points": ["质地轻薄"],
|
"points": ["质地轻薄"],
|
||||||
"timeline": [{"start": 0, "end": 2.7, "stage": "Hook"}],
|
"timeline": [{"start": 0, "end": 2.7, "stage": "Hook"}],
|
||||||
"matrix": {"shots": 4, "rows": [{"point": "USP", "hits": [1, 3]}]},
|
|
||||||
"voice_chars": [51, 60],
|
"voice_chars": [51, 60],
|
||||||
"video_prompt": "0-3秒 近景手持商品…",
|
"video_prompt": "0-3秒 近景手持商品…",
|
||||||
}
|
}
|
||||||
@@ -616,6 +646,57 @@ class ConfirmEndpointTests(TestCase):
|
|||||||
# 出片没提交成功,闸门要放回去让用户改完再确认
|
# 出片没提交成功,闸门要放回去让用户改完再确认
|
||||||
self.assertFalse(self.card.payload["submitted"])
|
self.assertFalse(self.card.payload["submitted"])
|
||||||
|
|
||||||
|
def test_duration_change_returns_regenerate_instead_of_submitting(self):
|
||||||
|
self.conversation.params = {
|
||||||
|
"model": "Seedance 2.0 Fast", "resolution": "480p",
|
||||||
|
"ratio": "1:1", "duration": "8 秒",
|
||||||
|
}
|
||||||
|
self.conversation.save(update_fields=["params"])
|
||||||
|
with patch("apps.ai.free_video.submit_free_video") as submit:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/ai/creations/{self.conversation.id}/send/",
|
||||||
|
{"kind": "confirm", "reply_to": str(self.card.id),
|
||||||
|
"params": {"duration": "15 秒", "model": "Seedance 2.0 Fast",
|
||||||
|
"resolution": "480p", "ratio": "1:1"}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(response.json()["regenerate"])
|
||||||
|
submit.assert_not_called()
|
||||||
|
self.conversation.refresh_from_db()
|
||||||
|
self.assertEqual(self.conversation.params["duration"], "15 秒")
|
||||||
|
self.card.refresh_from_db()
|
||||||
|
self.assertTrue(self.card.payload["submitted"])
|
||||||
|
|
||||||
|
def test_confirm_applies_model_without_rewriting_script(self):
|
||||||
|
self.conversation.params = {
|
||||||
|
"model": "Seedance 2.0 Fast", "resolution": "480p",
|
||||||
|
"ratio": "1:1", "duration": "8 秒",
|
||||||
|
}
|
||||||
|
self.conversation.save(update_fields=["params"])
|
||||||
|
provider = ModelProvider.objects.create(name="fk-param", display_name="F", base_url="https://x")
|
||||||
|
model = ModelConfig.objects.create(
|
||||||
|
provider=provider, name="fk-video-param", display_name="V",
|
||||||
|
capability=ModelConfig.Capability.VIDEO,
|
||||||
|
)
|
||||||
|
task = AITask.objects.create(
|
||||||
|
team=self.team, created_by=self.user, task_type=AITask.Type.FREE_VIDEO,
|
||||||
|
model_config=model, idempotency_key="k-param",
|
||||||
|
)
|
||||||
|
with patch("apps.ai.free_video.submit_free_video", return_value=task) as submit:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/ai/creations/{self.conversation.id}/send/",
|
||||||
|
{"kind": "confirm", "reply_to": str(self.card.id),
|
||||||
|
"params": {"duration": "8 秒", "model": "Seedance 2.5",
|
||||||
|
"resolution": "720p", "ratio": "1:1"}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
self.assertFalse(response.json().get("regenerate"))
|
||||||
|
self.assertEqual(submit.call_args.kwargs["params"]["model"], "doubao-seedance-2-5-260628")
|
||||||
|
self.assertEqual(submit.call_args.kwargs["params"]["resolution"], "720p")
|
||||||
|
self.assertEqual(submit.call_args.kwargs["params"]["duration"], 8)
|
||||||
|
|
||||||
|
|
||||||
class MemoryCompressionTests(CreationAgentBaseTests):
|
class MemoryCompressionTests(CreationAgentBaseTests):
|
||||||
"""长会话记忆压缩(契约 §5)。"""
|
"""长会话记忆压缩(契约 §5)。"""
|
||||||
@@ -731,3 +812,60 @@ class PresetGuidanceTests(CreationAgentBaseTests):
|
|||||||
self.assertEqual(len(VIDEO_PRESETS), 8)
|
self.assertEqual(len(VIDEO_PRESETS), 8)
|
||||||
self.assertEqual(len(IMAGE_PRESETS), 6)
|
self.assertEqual(len(IMAGE_PRESETS), 6)
|
||||||
self.assertTrue(all(text.strip() for text in {**VIDEO_PRESETS, **IMAGE_PRESETS}.values()))
|
self.assertTrue(all(text.strip() for text in {**VIDEO_PRESETS, **IMAGE_PRESETS}.values()))
|
||||||
|
|
||||||
|
|
||||||
|
class ChatVisionTests(CreationAgentBaseTests):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.person = Asset.objects.create(
|
||||||
|
team=self.team, created_by=self.user, name="参考人物",
|
||||||
|
asset_type=Asset.Type.IMAGE, source=Asset.Source.UPLOAD,
|
||||||
|
category=Asset.Category.PERSON,
|
||||||
|
)
|
||||||
|
AssetFile.objects.create(
|
||||||
|
asset=self.person, object_key="k/person", bucket="b", is_primary=True,
|
||||||
|
preview_url="https://cdn/person.jpg",
|
||||||
|
)
|
||||||
|
self.conversation.pinned_refs = [
|
||||||
|
{"type": "character", "id": str(self.person.id), "name": "参考人物"},
|
||||||
|
]
|
||||||
|
self.conversation.save(update_fields=["pinned_refs"])
|
||||||
|
append_message(self.conversation, role="user", text="按这个人出图")
|
||||||
|
|
||||||
|
def test_seed_chat_model_receives_reference_images(self):
|
||||||
|
self.model.name = "doubao-seed-2-0-pro-260215"
|
||||||
|
self.model.save(update_fields=["name"])
|
||||||
|
context = AgentContext(conversation=self.conversation, user=self.user, model_config=self.model)
|
||||||
|
messages = build_messages(context)
|
||||||
|
user_messages = [m for m in messages if m.get("role") == "user"]
|
||||||
|
self.assertTrue(user_messages)
|
||||||
|
content = user_messages[-1]["content"]
|
||||||
|
self.assertIsInstance(content, list)
|
||||||
|
urls = [
|
||||||
|
(item.get("image_url") or {}).get("url")
|
||||||
|
for item in content
|
||||||
|
if isinstance(item, dict) and item.get("type") == "image_url"
|
||||||
|
]
|
||||||
|
self.assertIn("https://cdn/person.jpg", urls)
|
||||||
|
blob = " ".join(str(item.get("text") or "") for item in content if isinstance(item, dict))
|
||||||
|
self.assertIn("性别", blob)
|
||||||
|
|
||||||
|
def test_plain_text_model_does_not_receive_images(self):
|
||||||
|
context = AgentContext(conversation=self.conversation, user=self.user, model_config=self.model)
|
||||||
|
messages = build_messages(context)
|
||||||
|
for message in messages:
|
||||||
|
self.assertIsInstance(message.get("content"), str)
|
||||||
|
|
||||||
|
|
||||||
|
class CreationChatModelTests(CreationAgentBaseTests):
|
||||||
|
def test_prefers_seed_21_as_chat_model(self):
|
||||||
|
self.model.is_default = False
|
||||||
|
self.model.save(update_fields=["is_default"])
|
||||||
|
seed = ModelConfig.objects.create(
|
||||||
|
provider=self.model.provider, name="doubao-seed-2-1-pro-260628",
|
||||||
|
display_name="Doubao-Seed-2.1-Pro",
|
||||||
|
capability=ModelConfig.Capability.TEXT, endpoint="chat/completions",
|
||||||
|
is_default=True,
|
||||||
|
)
|
||||||
|
picked = get_creation_chat_model(None)
|
||||||
|
self.assertEqual(picked.id, seed.id)
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ class ResolveRefsTests(TestCase):
|
|||||||
self.assertEqual(entry["review_status"], "active")
|
self.assertEqual(entry["review_status"], "active")
|
||||||
self.assertEqual(entry["review_remote_id"], "R-1")
|
self.assertEqual(entry["review_remote_id"], "R-1")
|
||||||
|
|
||||||
def test_model_ref_prefers_triview_over_portrait(self):
|
def test_model_ref_includes_portrait_and_triview(self):
|
||||||
portrait = _image_asset(self.team, self.user, "形象图", Asset.Category.MODEL_PORTRAIT, url="https://cdn/p.jpg")
|
portrait = _image_asset(self.team, self.user, "形象图", Asset.Category.MODEL_PORTRAIT, url="https://cdn/p.jpg")
|
||||||
triview = _image_asset(self.team, self.user, "三视图", Asset.Category.TRI_VIEW, url="https://cdn/t.jpg")
|
triview = _image_asset(self.team, self.user, "三视图", Asset.Category.TRI_VIEW, url="https://cdn/t.jpg")
|
||||||
model = Model.objects.create(
|
model = Model.objects.create(
|
||||||
@@ -121,8 +121,7 @@ class ResolveRefsTests(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
resolved = resolve_refs(self.team, [{"type": "model", "id": str(model.id)}])
|
resolved = resolve_refs(self.team, [{"type": "model", "id": str(model.id)}])
|
||||||
# 三视图信息量最大,锁脸优先用它
|
self.assertEqual([r["url"] for r in resolved.references], ["https://cdn/p.jpg", "https://cdn/t.jpg"])
|
||||||
self.assertEqual(resolved.references[0]["url"], "https://cdn/t.jpg")
|
|
||||||
|
|
||||||
def test_model_falls_back_to_portrait_when_no_triview(self):
|
def test_model_falls_back_to_portrait_when_no_triview(self):
|
||||||
portrait = _image_asset(self.team, self.user, "形象图2", Asset.Category.MODEL_PORTRAIT, url="https://cdn/p2.jpg")
|
portrait = _image_asset(self.team, self.user, "形象图2", Asset.Category.MODEL_PORTRAIT, url="https://cdn/p2.jpg")
|
||||||
@@ -153,6 +152,51 @@ class ResolveRefsTests(TestCase):
|
|||||||
# 编号错位会让 @图N 指错,必须去重
|
# 编号错位会让 @图N 指错,必须去重
|
||||||
self.assertEqual(len(resolved.references), 1)
|
self.assertEqual(len(resolved.references), 1)
|
||||||
|
|
||||||
|
def test_character_gender_in_metadata_becomes_fact(self):
|
||||||
|
self.person.metadata = {"gender": "女", "age": "25-30"}
|
||||||
|
self.person.save(update_fields=["metadata"])
|
||||||
|
resolved = resolve_refs(self.team, [{"type": "character", "id": str(self.person.id)}])
|
||||||
|
self.assertIn("性别:女", resolved.facts_text)
|
||||||
|
self.assertIn("年龄:25-30", resolved.facts_text)
|
||||||
|
|
||||||
|
def test_model_gender_in_metadata_becomes_fact(self):
|
||||||
|
portrait = _image_asset(self.team, self.user, "形象图3", Asset.Category.MODEL_PORTRAIT, url="https://cdn/p3.jpg")
|
||||||
|
model = Model.objects.create(
|
||||||
|
team=self.team, created_by=self.user, name="阿岚",
|
||||||
|
portrait_asset=portrait, metadata={"gender": "男"},
|
||||||
|
)
|
||||||
|
resolved = resolve_refs(self.team, [{"type": "model", "id": str(model.id)}])
|
||||||
|
self.assertIn("性别:男", resolved.facts_text)
|
||||||
|
|
||||||
|
def test_character_triview_is_attached_when_paired(self):
|
||||||
|
self.person.metadata = {}
|
||||||
|
self.person.save(update_fields=["metadata"])
|
||||||
|
_image_asset(
|
||||||
|
self.team, self.user, "白领三视图", Asset.Category.TRI_VIEW,
|
||||||
|
url="https://cdn/person-tri.jpg",
|
||||||
|
metadata={"triview_of": str(self.person.id)},
|
||||||
|
)
|
||||||
|
resolved = resolve_refs(self.team, [{"type": "character", "id": str(self.person.id)}])
|
||||||
|
urls = [r["url"] for r in resolved.references]
|
||||||
|
self.assertIn("https://cdn/person.jpg", urls)
|
||||||
|
self.assertIn("https://cdn/person-tri.jpg", urls)
|
||||||
|
|
||||||
|
def test_missing_triview_does_not_mark_ref_missing(self):
|
||||||
|
resolved = resolve_refs(self.team, [{"type": "character", "id": str(self.person.id)}])
|
||||||
|
self.assertEqual(resolved.missing, [])
|
||||||
|
self.assertEqual([r["url"] for r in resolved.references], ["https://cdn/person.jpg"])
|
||||||
|
|
||||||
|
def test_product_triview_is_attached_when_present(self):
|
||||||
|
_image_asset(
|
||||||
|
self.team, self.user, "商品三视图", Asset.Category.PRODUCT_IMAGE,
|
||||||
|
url="https://cdn/prod-tri.jpg",
|
||||||
|
metadata={"product_id": str(self.product.id), "view": "three_view"},
|
||||||
|
)
|
||||||
|
resolved = resolve_refs(self.team, [{"type": "product", "id": str(self.product.id)}])
|
||||||
|
urls = [r["url"] for r in resolved.references]
|
||||||
|
self.assertIn("https://cdn/prod.jpg", urls)
|
||||||
|
self.assertIn("https://cdn/prod-tri.jpg", urls)
|
||||||
|
|
||||||
def test_product_facts_text_without_selling_points_still_has_title(self):
|
def test_product_facts_text_without_selling_points_still_has_title(self):
|
||||||
bare = Product.objects.create(team=self.team, created_by=self.user, title="裸商品")
|
bare = Product.objects.create(team=self.team, created_by=self.user, title="裸商品")
|
||||||
self.assertIn("裸商品", product_facts_text(bare))
|
self.assertIn("裸商品", product_facts_text(bare))
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ class EntityExtractionRoutingTests(TestCase):
|
|||||||
older = self.model(self.provider("volcengine-old", 20), "doubao-seed-2-0-pro-260215")
|
older = self.model(self.provider("volcengine-old", 20), "doubao-seed-2-0-pro-260215")
|
||||||
default = self.model(
|
default = self.model(
|
||||||
self.provider("volcengine-default", 10),
|
self.provider("volcengine-default", 10),
|
||||||
"deepseek-v4-pro",
|
"doubao-seed-2-1-pro-260628",
|
||||||
is_default=True,
|
is_default=True,
|
||||||
)
|
)
|
||||||
task = self.submit()
|
task = self.submit()
|
||||||
@@ -254,5 +254,5 @@ class EntityExtractionRoutingTests(TestCase):
|
|||||||
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
|
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
|
||||||
self.assertEqual(attempt.model_config_id, default.id)
|
self.assertEqual(attempt.model_config_id, default.id)
|
||||||
self.assertNotEqual(attempt.model_config_id, older.id)
|
self.assertNotEqual(attempt.model_config_id, older.id)
|
||||||
self.assertEqual(task.request_payload["model"], "deepseek-v4-pro")
|
self.assertEqual(task.request_payload["model"], "doubao-seed-2-1-pro-260628")
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from apps.products.models import Product
|
|||||||
|
|
||||||
from .generation_errors import classify_generation_error, public_error_for_task
|
from .generation_errors import classify_generation_error, public_error_for_task
|
||||||
from .creation import append_message, sync_generating_messages
|
from .creation import append_message, sync_generating_messages
|
||||||
from .creation_agent import apply_session_params, stream_creation_agent, submit_confirmed_video
|
from .creation_agent import apply_confirm_params, apply_session_params, stream_creation_agent, submit_confirmed_image, submit_confirmed_video
|
||||||
from .mentions import TYPE_LABELS, VALID_TYPES, refs_from_elicit_answers, search_mentions
|
from .mentions import TYPE_LABELS, VALID_TYPES, refs_from_elicit_answers, search_mentions
|
||||||
from .models import AITask, CreationConversation, CreationMessage, ImageConversation, ModelConfig
|
from .models import AITask, CreationConversation, CreationMessage, ImageConversation, ModelConfig
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
@@ -693,12 +693,14 @@ def _free_video_list_queryset(team, *, include_replace=False):
|
|||||||
)
|
)
|
||||||
if include_replace:
|
if include_replace:
|
||||||
return qs.filter(video_replace_q())
|
return qs.filter(video_replace_q())
|
||||||
return qs.exclude(video_replace_q())
|
# 全能创作也走 FREE_VIDEO 任务类型,但不能出现在自由生成任务流里。
|
||||||
|
return qs.exclude(video_replace_q()).exclude(request_payload__feature="omni_create")
|
||||||
|
|
||||||
|
|
||||||
def _free_video_trash_queryset(team):
|
def _free_video_trash_queryset(team):
|
||||||
return (
|
return (
|
||||||
AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, is_deleted=True, purged_at__isnull=True)
|
AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, is_deleted=True, purged_at__isnull=True)
|
||||||
|
.exclude(request_payload__feature="omni_create")
|
||||||
.select_related("model_config")
|
.select_related("model_config")
|
||||||
.prefetch_related("generated_assets", "generated_assets__files")
|
.prefetch_related("generated_assets", "generated_assets__files")
|
||||||
)
|
)
|
||||||
@@ -1244,9 +1246,15 @@ class FreeVideoUploadView(APIView):
|
|||||||
|
|
||||||
|
|
||||||
class ModelConfigViewSet(ReadOnlyModelViewSet):
|
class ModelConfigViewSet(ReadOnlyModelViewSet):
|
||||||
# 按创建序固定排序:最早创建的 active 模型排第一 = 前端选择器默认项,与 get_default_model 口径一致
|
# 按创建序固定排序:最早创建的 active 模型排第一 = 前端选择器默认项,与 get_default_model 口径一致。
|
||||||
# (否则 DB 默认序不稳定,可能默认选到 Gemini 等;用户要默认 = 豆包 2.0 Pro,它最早创建)
|
# DeepSeek 已停用,下拉里不再出现。
|
||||||
queryset = ModelConfig.objects.select_related("provider").filter(status=ModelConfig.Status.ACTIVE).order_by("created_at")
|
queryset = (
|
||||||
|
ModelConfig.objects.select_related("provider")
|
||||||
|
.filter(status=ModelConfig.Status.ACTIVE)
|
||||||
|
.exclude(name__icontains="deepseek")
|
||||||
|
.exclude(display_name__icontains="deepseek")
|
||||||
|
.order_by("created_at")
|
||||||
|
)
|
||||||
serializer_class = ModelConfigSerializer
|
serializer_class = ModelConfigSerializer
|
||||||
search_fields = ["name", "display_name", "capability"]
|
search_fields = ["name", "display_name", "capability"]
|
||||||
ordering_fields = ["created_at", "display_name"]
|
ordering_fields = ["created_at", "display_name"]
|
||||||
@@ -1360,9 +1368,28 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
if (card.payload or {}).get("submitted"):
|
if (card.payload or {}).get("submitted"):
|
||||||
# 确认闸门是一次性的:连点两下会出两条片、扣两次积分
|
# 确认闸门是一次性的:连点两下会出两条片、扣两次积分
|
||||||
return JsonResponse({"detail": "这条方案已经确认过了"}, status=409)
|
return JsonResponse({"detail": "这条方案已经确认过了"}, status=409)
|
||||||
card.payload = {**(card.payload or {}), "submitted": True}
|
incoming = request.data.get("params")
|
||||||
|
if incoming is not None and not isinstance(incoming, dict):
|
||||||
|
return JsonResponse({"detail": "params 必须是对象"}, status=400)
|
||||||
|
latest_params, duration_changed = apply_confirm_params(
|
||||||
|
conversation, incoming if isinstance(incoming, dict) else None
|
||||||
|
)
|
||||||
|
card.payload = {
|
||||||
|
**(card.payload or {}),
|
||||||
|
"submitted": True,
|
||||||
|
"params": latest_params,
|
||||||
|
}
|
||||||
card.save(update_fields=["payload", "updated_at"])
|
card.save(update_fields=["payload", "updated_at"])
|
||||||
message, error = submit_confirmed_video(
|
# 改时长会让旧脚本对不上(5 秒方案不能直接出 10 秒)。确认卡作废,前端再发一轮让模型重写。
|
||||||
|
if duration_changed:
|
||||||
|
return JsonResponse({
|
||||||
|
"regenerate": True,
|
||||||
|
"params": latest_params,
|
||||||
|
"message": None,
|
||||||
|
}, status=200)
|
||||||
|
is_image = (card.payload or {}).get("kind") == "image" or conversation.mode == CreationConversation.Mode.IMAGE
|
||||||
|
submitter = submit_confirmed_image if is_image else submit_confirmed_video
|
||||||
|
message, error = submitter(
|
||||||
conversation=conversation, user=request.user, confirm_message=card
|
conversation=conversation, user=request.user, confirm_message=card
|
||||||
)
|
)
|
||||||
if error:
|
if error:
|
||||||
|
|||||||
@@ -57,13 +57,13 @@ def _tab_q(tab: str) -> Q:
|
|||||||
if tab == "image_creations": # 图片自由创作
|
if tab == "image_creations": # 图片自由创作
|
||||||
return Q(category="free_create", asset_type="image")
|
return Q(category="free_create", asset_type="image")
|
||||||
if tab == "video_creations": # 视频自由创作
|
if tab == "video_creations": # 视频自由创作
|
||||||
return Q(category="free_create", asset_type="video")
|
return Q(category="free_create", asset_type="video") & ~Q(metadata__feature="omni_create")
|
||||||
if tab == "creations": # 自由创作(兼容旧入口:图片+视频)
|
if tab == "creations": # 自由创作(兼容旧入口:图片+视频)
|
||||||
return Q(category="free_create")
|
return Q(category="free_create") & ~Q(metadata__feature="omni_create")
|
||||||
if tab == "uploads":
|
if tab == "uploads":
|
||||||
return Q(category="upload")
|
return Q(category="upload")
|
||||||
if tab == "materials": # 素材(期3):视频素材 = 所有视频,排除「最终成片」(final_video 隐藏不列)
|
if tab == "materials": # 素材(期3):视频素材 = 所有视频,排除「最终成片」(final_video 隐藏不列)
|
||||||
return ~Q(category="final_video") & Q(asset_type="video")
|
return ~Q(category="final_video") & Q(asset_type="video") & ~Q(metadata__feature="omni_create")
|
||||||
if tab == "others": # 其他(资产库成品化):我的上传 + 未归类非视频(兜底)
|
if tab == "others": # 其他(资产库成品化):我的上传 + 未归类非视频(兜底)
|
||||||
return Q(category="upload") | (~Q(category__in=_KNOWN_CATS) & ~Q(asset_type="video"))
|
return Q(category="upload") | (~Q(category__in=_KNOWN_CATS) & ~Q(asset_type="video"))
|
||||||
if tab == "unclassified": # 未归类且非视频(也不含最终成片)
|
if tab == "unclassified": # 未归类且非视频(也不含最终成片)
|
||||||
|
|||||||
@@ -104,10 +104,7 @@ def _script_generation_inflight(project: Project) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def get_quick_script_model() -> ModelConfig | None:
|
def get_quick_script_model() -> ModelConfig | None:
|
||||||
"""极速成片固定复用专业创作的豆包 Seed 2.1 Pro 脚本模型。
|
"""极速成片固定复用专业创作的豆包 Seed 2.1 Pro 脚本模型。"""
|
||||||
|
|
||||||
不回退到默认文本模型,避免默认配置切到 DeepSeek 后两条创作链路的成片质量不一致。
|
|
||||||
"""
|
|
||||||
return (
|
return (
|
||||||
ModelConfig.objects.select_related("provider")
|
ModelConfig.objects.select_related("provider")
|
||||||
.filter(
|
.filter(
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from apps.ai.services import (
|
|||||||
generate_base_asset,
|
generate_base_asset,
|
||||||
generate_person_triview,
|
generate_person_triview,
|
||||||
get_default_model,
|
get_default_model,
|
||||||
|
resolve_text_model,
|
||||||
get_inflight_extraction,
|
get_inflight_extraction,
|
||||||
poll_video_segment,
|
poll_video_segment,
|
||||||
regenerate_script_segment,
|
regenerate_script_segment,
|
||||||
@@ -526,7 +527,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
if total_duration not in {15, 30, 45, 60}:
|
if total_duration not in {15, 30, 45, 60}:
|
||||||
return Response({"detail": "视频时长仅支持15、30、45或60秒"}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"detail": "视频时长仅支持15、30、45或60秒"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# 极速成片和专业创作使用同一款豆包脚本模型;绝不因默认模型变化而退回 DeepSeek。
|
# 极速成片和专业创作使用同一款豆包 Seed 2.1 Pro 脚本模型。
|
||||||
from .services.quick_create import get_quick_script_model
|
from .services.quick_create import get_quick_script_model
|
||||||
|
|
||||||
text_model = get_quick_script_model()
|
text_model = get_quick_script_model()
|
||||||
@@ -890,16 +891,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
target_index = None
|
target_index = None
|
||||||
|
|
||||||
model_config = None
|
model_config = resolve_text_model(requested_id=request.data.get("model_config_id"))
|
||||||
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 None:
|
|
||||||
model_config = get_default_model(ModelConfig.Capability.TEXT)
|
|
||||||
if model_config is None:
|
if model_config is None:
|
||||||
# 纯 Django 响应:绕开 DRF 渲染(此 action 只挂了 SSE renderer)
|
# 纯 Django 响应:绕开 DRF 渲染(此 action 只挂了 SSE renderer)
|
||||||
return JsonResponse({"detail": "没有可用的文本模型,请先在模型库配置"}, status=400)
|
return JsonResponse({"detail": "没有可用的文本模型,请先在模型库配置"}, status=400)
|
||||||
|
|||||||
@@ -567,7 +567,7 @@ export function App() {
|
|||||||
}
|
}
|
||||||
setRoute({
|
setRoute({
|
||||||
page: next, authMode, productId, projectId, conversationId, hash,
|
page: next, authMode, productId, projectId, conversationId, hash,
|
||||||
tab: options.tab, firstMessage: options.firstMessage, firstRefs: options.firstRefs,
|
tab: options.tab, firstMessage: options.firstMessage, firstRefs: options.firstRefs, firstUploads: options.firstUploads,
|
||||||
});
|
});
|
||||||
const arriving: NavHistoryState = {
|
const arriving: NavHistoryState = {
|
||||||
airshelf: 1,
|
airshelf: 1,
|
||||||
@@ -1088,6 +1088,7 @@ export function App() {
|
|||||||
conversationId={route.conversationId}
|
conversationId={route.conversationId}
|
||||||
firstMessage={route.firstMessage}
|
firstMessage={route.firstMessage}
|
||||||
firstRefs={route.firstRefs}
|
firstRefs={route.firstRefs}
|
||||||
|
firstUploads={route.firstUploads}
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
onNotify={(type, text) => setNotice({ type, text })}
|
onNotify={(type, text) => setNotice({ type, text })}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -593,10 +593,13 @@ export const api = {
|
|||||||
* video_prompt 直接提交,同步返回「生成中」消息,之后靠轮询转成结果。
|
* video_prompt 直接提交,同步返回「生成中」消息,之后靠轮询转成结果。
|
||||||
*/
|
*/
|
||||||
confirmCreationPlan(id: string, replyTo: string, params?: Record<string, string>) {
|
confirmCreationPlan(id: string, replyTo: string, params?: Record<string, string>) {
|
||||||
return request<{ message: CreationMessage }>(`/api/ai/creations/${id}/send/`, {
|
return request<{ message?: CreationMessage; regenerate?: boolean; params?: Record<string, string> }>(
|
||||||
method: "POST",
|
`/api/ai/creations/${id}/send/`,
|
||||||
body: JSON.stringify({ kind: "confirm", reply_to: replyTo, params })
|
{
|
||||||
});
|
method: "POST",
|
||||||
|
body: JSON.stringify({ kind: "confirm", reply_to: replyTo, params })
|
||||||
|
}
|
||||||
|
);
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* 发一条消息 → SSE 流。事件:tool / reasoning / delta / message / task / credits / done / error。
|
* 发一条消息 → SSE 流。事件:tool / reasoning / delta / message / task / credits / done / error。
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { ChevronDown, SlidersHorizontal, Sparkles } from "lucide-react";
|
||||||
|
import { CustomSelect } from "./custom-select";
|
||||||
|
|
||||||
|
export const OMNI_VIDEO_MODELS = ["Seedance 2.5", "Seedance 2.0", "Seedance 2.0 Fast", "Seedance 2.0 Mini"];
|
||||||
|
export const OMNI_IMAGE_MODELS = ["Seedream5.0", "YQ image2"];
|
||||||
|
export const OMNI_RESOLUTIONS = ["1080p", "720p", "480p"];
|
||||||
|
export const OMNI_RATIOS = ["9:16", "16:9", "1:1", "4:3", "3:4"];
|
||||||
|
export const OMNI_VIDEO_DURATIONS = [
|
||||||
|
"4 秒", "5 秒", "6 秒", "7 秒", "8 秒", "9 秒", "10 秒",
|
||||||
|
"11 秒", "12 秒", "13 秒", "14 秒", "15 秒", "30 秒",
|
||||||
|
];
|
||||||
|
export const OMNI_IMAGE_COUNTS = ["1 张", "2 张", "4 张", "8 张"];
|
||||||
|
|
||||||
|
function toOptions(values: string[]) {
|
||||||
|
return values.map((value) => ({ value, label: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function withCurrent(values: string[], current: string) {
|
||||||
|
return current && !values.includes(current) ? [current, ...values] : values;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OmniParamBar({
|
||||||
|
isVideo,
|
||||||
|
disabled,
|
||||||
|
model,
|
||||||
|
resolution,
|
||||||
|
ratio,
|
||||||
|
duration,
|
||||||
|
onModel,
|
||||||
|
onResolution,
|
||||||
|
onRatio,
|
||||||
|
onDuration,
|
||||||
|
}: {
|
||||||
|
isVideo: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
model: string;
|
||||||
|
resolution: string;
|
||||||
|
ratio: string;
|
||||||
|
duration: string;
|
||||||
|
onModel: (value: string) => void;
|
||||||
|
onResolution: (value: string) => void;
|
||||||
|
onRatio: (value: string) => void;
|
||||||
|
onDuration: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [customOn, setCustomOn] = useState(isVideo ? duration !== "智能时长" : true);
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCustomOn(isVideo ? duration !== "智能时长" : true);
|
||||||
|
}, [isVideo, duration]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menuOpen) return;
|
||||||
|
const onDown = (event: MouseEvent) => {
|
||||||
|
if (!wrapRef.current?.contains(event.target as Node)) setMenuOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onDown);
|
||||||
|
return () => document.removeEventListener("mousedown", onDown);
|
||||||
|
}, [menuOpen]);
|
||||||
|
|
||||||
|
const models = withCurrent(isVideo ? OMNI_VIDEO_MODELS : OMNI_IMAGE_MODELS, model);
|
||||||
|
const durations = isVideo ? OMNI_VIDEO_DURATIONS : OMNI_IMAGE_COUNTS;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<label className="omni-parameter omni-parameter-model">
|
||||||
|
<CustomSelect
|
||||||
|
fill
|
||||||
|
size="sm"
|
||||||
|
aria-label={isVideo ? "视频模型" : "图片模型"}
|
||||||
|
disabled={disabled}
|
||||||
|
value={model}
|
||||||
|
onChange={onModel}
|
||||||
|
options={toOptions(models)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={`omni-parameter${isVideo ? "" : " is-hidden"}`}>
|
||||||
|
<CustomSelect
|
||||||
|
fill
|
||||||
|
size="sm"
|
||||||
|
aria-label="分辨率"
|
||||||
|
disabled={disabled}
|
||||||
|
value={resolution}
|
||||||
|
onChange={onResolution}
|
||||||
|
options={toOptions(withCurrent(OMNI_RESOLUTIONS, resolution))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="omni-parameter">
|
||||||
|
<CustomSelect
|
||||||
|
fill
|
||||||
|
size="sm"
|
||||||
|
aria-label="画面比例"
|
||||||
|
disabled={disabled}
|
||||||
|
value={ratio}
|
||||||
|
onChange={onRatio}
|
||||||
|
options={toOptions(withCurrent(OMNI_RATIOS, ratio))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className={`omni-duration-control${isVideo ? "" : " is-image-count"}`} ref={wrapRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="omni-duration-trigger"
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setMenuOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
<span>{duration}</span>
|
||||||
|
<ChevronDown />
|
||||||
|
</button>
|
||||||
|
<div className="omni-duration-menu" hidden={!menuOpen}>
|
||||||
|
<span className="omni-duration-title">{isVideo ? "时长" : "生成张数"}</span>
|
||||||
|
{isVideo ? (
|
||||||
|
<div className="omni-duration-modes">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={!customOn ? "active" : ""}
|
||||||
|
onClick={() => {
|
||||||
|
setCustomOn(false);
|
||||||
|
onDuration("智能时长");
|
||||||
|
setMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sparkles />
|
||||||
|
<span>智能时长</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={customOn ? "active" : ""}
|
||||||
|
onClick={() => setCustomOn(true)}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal />
|
||||||
|
<span>自定义时长</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="omni-duration-values" hidden={isVideo && !customOn}>
|
||||||
|
{durations.map((value) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={value}
|
||||||
|
className={duration === value ? "active" : ""}
|
||||||
|
onClick={() => {
|
||||||
|
onDuration(value);
|
||||||
|
setCustomOn(true);
|
||||||
|
setMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
--black: #101012;
|
--black: #101012;
|
||||||
--text: #17181a;
|
--text: #17181a;
|
||||||
--muted: #6f747c;
|
--muted: #6f747c;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes omniMessageIn {
|
@keyframes omniMessageIn {
|
||||||
@@ -33,24 +34,31 @@
|
|||||||
|
|
||||||
.omni-home-history {
|
.omni-home-history {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 24px;
|
top: 20px;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 36px;
|
height: 40px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 8px;
|
||||||
padding: 0 12px;
|
padding: 0 14px;
|
||||||
border: 1px solid rgba(34, 42, 54, .10);
|
border: 1.5px solid rgba(16, 16, 18, .22);
|
||||||
border-radius: 10px;
|
border-radius: 12px;
|
||||||
color: #414750;
|
color: var(--black);
|
||||||
background: #fff;
|
background: #fff;
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
box-shadow: 0 4px 12px rgba(16, 16, 18, .08);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.omni-home-history:hover {
|
||||||
|
border-color: var(--black);
|
||||||
|
background: #f7f7f8;
|
||||||
|
}
|
||||||
|
|
||||||
.omni-home-history svg {
|
.omni-home-history svg {
|
||||||
width: 15px;
|
width: 16px;
|
||||||
height: 15px;
|
height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-home-kicker {
|
.omni-home-kicker {
|
||||||
@@ -59,7 +67,7 @@
|
|||||||
gap: 7px;
|
gap: 7px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
color: var(--klein);
|
color: var(--klein);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
letter-spacing: .13em;
|
letter-spacing: .13em;
|
||||||
}
|
}
|
||||||
@@ -81,7 +89,7 @@
|
|||||||
.omni-home-hero > p {
|
.omni-home-hero > p {
|
||||||
margin: 10px 0 17px;
|
margin: 10px 0 17px;
|
||||||
color: #747b86;
|
color: #747b86;
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-output-switch {
|
.omni-output-switch {
|
||||||
@@ -100,7 +108,7 @@
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: #6d737c;
|
color: #6d737c;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -152,7 +160,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
color: var(--klein);
|
color: var(--klein);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-selected-case svg,
|
.omni-selected-case svg,
|
||||||
@@ -199,7 +207,7 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: #344159;
|
color: #344159;
|
||||||
background: rgba(0, 47, 167, .045);
|
background: rgba(0, 47, 167, .045);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +263,7 @@
|
|||||||
outline: 0;
|
outline: 0;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +298,7 @@
|
|||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
color: #353b45;
|
color: #353b45;
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 150ms ease, border-color 150ms ease, background-color 150ms ease;
|
transition: color 150ms ease, border-color 150ms ease, background-color 150ms ease;
|
||||||
@@ -336,7 +344,7 @@
|
|||||||
display: block;
|
display: block;
|
||||||
padding: 5px 8px 7px;
|
padding: 5px 8px 7px;
|
||||||
color: #8b919a;
|
color: #8b919a;
|
||||||
font-size: 9px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-upload-menu button {
|
.omni-upload-menu button {
|
||||||
@@ -373,14 +381,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.omni-upload-menu button span {
|
.omni-upload-menu button span {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-upload-menu button small {
|
.omni-upload-menu button small {
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
color: #9298a1;
|
color: #9298a1;
|
||||||
font-size: 9px;
|
font-size: 11px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,7 +413,7 @@
|
|||||||
display: block;
|
display: block;
|
||||||
padding: 5px 8px 7px;
|
padding: 5px 8px 7px;
|
||||||
color: #8b919a;
|
color: #8b919a;
|
||||||
font-size: 9px;
|
font-size: 11px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,7 +427,7 @@
|
|||||||
display: block;
|
display: block;
|
||||||
padding: 5px 8px 7px;
|
padding: 5px 8px 7px;
|
||||||
color: #8b919a;
|
color: #8b919a;
|
||||||
font-size: 9px;
|
font-size: 11px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,14 +474,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.omni-mention-menu button span {
|
.omni-mention-menu button span {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-mention-menu button small {
|
.omni-mention-menu button small {
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
color: #8b919a;
|
color: #8b919a;
|
||||||
font-size: 9px;
|
font-size: 11px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,7 +529,7 @@
|
|||||||
border-color: rgba(34, 42, 54, .11);
|
border-color: rgba(34, 42, 54, .11);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,7 +555,7 @@
|
|||||||
|
|
||||||
.omni-parameter .custom-select-option {
|
.omni-parameter .custom-select-option {
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-duration-control {
|
.omni-duration-control {
|
||||||
@@ -572,11 +580,16 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: #373d46;
|
color: #373d46;
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.omni-duration-trigger:disabled {
|
||||||
|
opacity: .55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.omni-duration-trigger:hover,
|
.omni-duration-trigger:hover,
|
||||||
.omni-duration-trigger[aria-expanded="true"] {
|
.omni-duration-trigger[aria-expanded="true"] {
|
||||||
border-color: rgba(0, 47, 167, .36);
|
border-color: rgba(0, 47, 167, .36);
|
||||||
@@ -616,7 +629,7 @@
|
|||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
color: #7d838d;
|
color: #7d838d;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-duration-modes {
|
.omni-duration-modes {
|
||||||
@@ -635,7 +648,7 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: #626974;
|
color: #626974;
|
||||||
background: #f7f8fa;
|
background: #f7f8fa;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -674,7 +687,7 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: #575e69;
|
color: #575e69;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -708,7 +721,7 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: var(--klein);
|
background: var(--klein);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 9px 18px rgba(0, 47, 167, .17);
|
box-shadow: 0 9px 18px rgba(0, 47, 167, .17);
|
||||||
@@ -753,7 +766,7 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: #707681;
|
color: #707681;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -861,7 +874,7 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
background: #111318;
|
background: #111318;
|
||||||
box-shadow: 0 8px 18px rgba(0, 0, 0, .18);
|
box-shadow: 0 8px 18px rgba(0, 0, 0, .18);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -1001,7 +1014,7 @@
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: var(--klein);
|
color: var(--klein);
|
||||||
background: #edf5ff;
|
background: #edf5ff;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1013,7 +1026,7 @@
|
|||||||
.omni-preset-detail > p {
|
.omni-preset-detail > p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #737a84;
|
color: #737a84;
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
line-height: 1.85;
|
line-height: 1.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1026,13 +1039,13 @@
|
|||||||
|
|
||||||
.omni-preset-default span {
|
.omni-preset-default span {
|
||||||
color: #8a9099;
|
color: #8a9099;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-preset-default p {
|
.omni-preset-default p {
|
||||||
margin: 7px 0 0;
|
margin: 7px 0 0;
|
||||||
color: #3e4653;
|
color: #3e4653;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1050,7 +1063,7 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: #616873;
|
color: #616873;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1073,14 +1086,14 @@
|
|||||||
|
|
||||||
.omni-case-copy strong {
|
.omni-case-copy strong {
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-case-copy small {
|
.omni-case-copy small {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #858b94;
|
color: #858b94;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
@@ -1107,7 +1120,7 @@
|
|||||||
|
|
||||||
.omni-history-heading > span {
|
.omni-history-heading > span {
|
||||||
color: var(--klein);
|
color: var(--klein);
|
||||||
font-size: 9px;
|
font-size: 11px;
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
letter-spacing: .13em;
|
letter-spacing: .13em;
|
||||||
}
|
}
|
||||||
@@ -1144,7 +1157,7 @@
|
|||||||
.omni-history-header p {
|
.omni-history-header p {
|
||||||
margin-top: 7px;
|
margin-top: 7px;
|
||||||
color: #858b94;
|
color: #858b94;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-history-tools {
|
.omni-history-tools {
|
||||||
@@ -1174,7 +1187,7 @@
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: var(--klein);
|
background: var(--klein);
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -1191,7 +1204,7 @@
|
|||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
color: #707681;
|
color: #707681;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -1259,7 +1272,7 @@
|
|||||||
.omni-history-item p,
|
.omni-history-item p,
|
||||||
.omni-history-item small {
|
.omni-history-item small {
|
||||||
color: #858b94;
|
color: #858b94;
|
||||||
font-size: 12.5px;
|
font-size: 14px;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1359,7 +1372,7 @@
|
|||||||
.omni-delete-dialog p {
|
.omni-delete-dialog p {
|
||||||
margin: 8px 0 20px;
|
margin: 8px 0 20px;
|
||||||
color: #7d838d;
|
color: #7d838d;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-delete-dialog > div {
|
.omni-delete-dialog > div {
|
||||||
@@ -1374,7 +1387,7 @@
|
|||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
color: #5e6570;
|
color: #5e6570;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -1390,7 +1403,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
color: #a86a00;
|
color: #a86a00;
|
||||||
font-size: 11.5px;
|
font-size: 13px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1422,7 +1435,7 @@
|
|||||||
border-color: rgba(34, 42, 54, .11);
|
border-color: rgba(34, 42, 54, .11);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1454,7 +1467,7 @@
|
|||||||
|
|
||||||
.omni-history-empty p {
|
.omni-history-empty p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-history-empty button {
|
.omni-history-empty button {
|
||||||
@@ -1466,7 +1479,7 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: var(--klein);
|
background: var(--klein);
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
.omni-session-page {
|
.omni-session-page {
|
||||||
min-height: calc(100vh - var(--topbar-height));
|
min-height: calc(100vh - var(--topbar-height));
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 进会话后顶栏换成对话信息,滚下去也还在 */
|
/* 进会话后顶栏换成对话信息,滚下去也还在 */
|
||||||
@@ -73,7 +74,7 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: #414750;
|
color: #414750;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +93,7 @@
|
|||||||
|
|
||||||
.omni-session-meta span {
|
.omni-session-meta span {
|
||||||
color: #7d838c;
|
color: #7d838c;
|
||||||
font-size: 9px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-session-meta span:not(:last-child)::after {
|
.omni-session-meta span:not(:last-child)::after {
|
||||||
@@ -149,7 +150,7 @@
|
|||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
color: #323945;
|
color: #323945;
|
||||||
background: #f7f8fa;
|
background: #f7f8fa;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +219,7 @@
|
|||||||
|
|
||||||
.omni-strategy-head strong,
|
.omni-strategy-head strong,
|
||||||
.omni-video-plan-head strong {
|
.omni-video-plan-head strong {
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-strategy-head span,
|
.omni-strategy-head span,
|
||||||
@@ -227,7 +228,7 @@
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: var(--klein);
|
color: var(--klein);
|
||||||
background: #edf5ff;
|
background: #edf5ff;
|
||||||
font-size: 9px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,13 +253,13 @@
|
|||||||
|
|
||||||
.omni-strategy-grid span {
|
.omni-strategy-grid span {
|
||||||
color: #858b94;
|
color: #858b94;
|
||||||
font-size: 9px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-strategy-grid strong {
|
.omni-strategy-grid strong {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
color: #272d37;
|
color: #272d37;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +273,7 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: #39404b;
|
color: #39404b;
|
||||||
background: #f5f9ff;
|
background: #f5f9ff;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-strategy-direction b {
|
.omni-strategy-direction b {
|
||||||
@@ -301,7 +302,7 @@
|
|||||||
.omni-plan-section > strong {
|
.omni-plan-section > strong {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-plan-points {
|
.omni-plan-points {
|
||||||
@@ -315,7 +316,7 @@
|
|||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
color: #525965;
|
color: #525965;
|
||||||
background: #f6f8fa;
|
background: #f6f8fa;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,7 +324,7 @@
|
|||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
color: #252b35;
|
color: #252b35;
|
||||||
font-size: 9px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-plan-points i,
|
.omni-plan-points i,
|
||||||
@@ -345,7 +346,7 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: #5b626d;
|
color: #5b626d;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
font-size: 9px;
|
font-size: 11px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,7 +354,7 @@
|
|||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
color: var(--klein);
|
color: var(--klein);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-plan-matrix {
|
.omni-plan-matrix {
|
||||||
@@ -369,7 +370,7 @@
|
|||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
color: #6d747e;
|
color: #6d747e;
|
||||||
background: #f6f8fa;
|
background: #f6f8fa;
|
||||||
font-size: 9px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -395,7 +396,7 @@
|
|||||||
padding: 13px 17px;
|
padding: 13px 17px;
|
||||||
color: #444b56;
|
color: #444b56;
|
||||||
background: #f6f8fb;
|
background: #f6f8fb;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,13 +434,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.omni-prompt-file-copy strong {
|
.omni-prompt-file-copy strong {
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-prompt-file-copy small {
|
.omni-prompt-file-copy small {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
color: #858b94;
|
color: #858b94;
|
||||||
font-size: 9px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-prompt-file-card button {
|
.omni-prompt-file-card button {
|
||||||
@@ -449,7 +450,7 @@
|
|||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
color: #333944;
|
color: #333944;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -472,7 +473,7 @@
|
|||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
color: var(--klein);
|
color: var(--klein);
|
||||||
background: #fff;
|
background: #fff;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -505,7 +506,7 @@
|
|||||||
outline: 0;
|
outline: 0;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,7 +558,7 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: #373d46;
|
color: #373d46;
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
font-size: 10.5px;
|
font-size: 12px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -619,12 +620,12 @@
|
|||||||
outline: 0;
|
outline: 0;
|
||||||
color: #272d36;
|
color: #272d36;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-session-preset-search span {
|
.omni-session-preset-search span {
|
||||||
color: #7d838c;
|
color: #7d838c;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-session-preset-body {
|
.omni-session-preset-body {
|
||||||
@@ -659,7 +660,7 @@
|
|||||||
.omni-session-preset-categories button {
|
.omni-session-preset-categories button {
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-session-preset-categories button.active {
|
.omni-session-preset-categories button.active {
|
||||||
@@ -691,13 +692,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.omni-session-preset-list strong {
|
.omni-session-preset-list strong {
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-session-preset-list small {
|
.omni-session-preset-list small {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #949aa3;
|
color: #949aa3;
|
||||||
font-size: 9px;
|
font-size: 11px;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -729,7 +730,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #737a85;
|
color: #737a85;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
-webkit-line-clamp: 3;
|
-webkit-line-clamp: 3;
|
||||||
@@ -738,7 +739,7 @@
|
|||||||
.omni-session-preset-preview small {
|
.omni-session-preset-preview small {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
color: #8d939c;
|
color: #8d939c;
|
||||||
font-size: 9px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-session-preset-preview button {
|
.omni-session-preset-preview button {
|
||||||
@@ -747,7 +748,7 @@
|
|||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: var(--klein);
|
background: var(--klein);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -755,7 +756,7 @@
|
|||||||
.omni-session-parameter .custom-select-trigger {
|
.omni-session-parameter .custom-select-trigger {
|
||||||
height: 34px;
|
height: 34px;
|
||||||
padding: 0 9px;
|
padding: 0 9px;
|
||||||
font-size: 10.5px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-session-parameter .custom-select-menu {
|
.omni-session-parameter .custom-select-menu {
|
||||||
@@ -816,7 +817,7 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #5b616a;
|
color: #5b616a;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -852,7 +853,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
color: #8b919a;
|
color: #8b919a;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -864,7 +865,7 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
color: #8b919a;
|
color: #8b919a;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-at-loading .omni-send-spinner {
|
.omni-at-loading .omni-send-spinner {
|
||||||
@@ -882,7 +883,7 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: #303641;
|
color: #303641;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -908,7 +909,7 @@
|
|||||||
|
|
||||||
.omni-at-list button small {
|
.omni-at-list button small {
|
||||||
color: #8b919a;
|
color: #8b919a;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-session-send {
|
.omni-session-send {
|
||||||
@@ -945,7 +946,7 @@
|
|||||||
|
|
||||||
.omni-process-frame strong {
|
.omni-process-frame strong {
|
||||||
color: #5c6570;
|
color: #5c6570;
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1073,13 +1074,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.omni-result-info strong {
|
.omni-result-info strong {
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-result-info small {
|
.omni-result-info small {
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
color: #858b94;
|
color: #858b94;
|
||||||
font-size: 9px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-result-info button {
|
.omni-result-info button {
|
||||||
@@ -1091,7 +1092,7 @@
|
|||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: var(--black);
|
background: var(--black);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1166,7 +1167,7 @@
|
|||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 9px;
|
margin-bottom: 9px;
|
||||||
color: var(--black);
|
color: var(--black);
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1182,7 +1183,7 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
color: #575d66;
|
color: #575d66;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color .15s ease, color .15s ease, background .15s ease;
|
transition: border-color .15s ease, color .15s ease, background .15s ease;
|
||||||
}
|
}
|
||||||
@@ -1205,7 +1206,7 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
color: var(--black);
|
color: var(--black);
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1248,7 +1249,7 @@
|
|||||||
.omni-elicit-assets span {
|
.omni-elicit-assets span {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #575d66;
|
color: #575d66;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -1266,7 +1267,7 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--klein);
|
background: var(--klein);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1287,19 +1288,61 @@
|
|||||||
.omni-confirm-card {
|
.omni-confirm-card {
|
||||||
width: min(760px, 100%);
|
width: min(760px, 100%);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
align-items: stretch;
|
||||||
gap: 14px;
|
gap: 12px;
|
||||||
margin: 0 0 24px 44px;
|
margin: 0 0 24px 44px;
|
||||||
padding: 13px 16px;
|
padding: 14px 16px;
|
||||||
border: 1px solid rgba(34, 42, 54, .10);
|
border: 1px solid rgba(34, 42, 54, .10);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-confirm-card > span {
|
.omni-confirm-copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.omni-confirm-copy strong {
|
||||||
|
color: #22262e;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.omni-confirm-copy span,
|
||||||
|
.omni-confirm-foot > span {
|
||||||
color: #6f747c;
|
color: #6f747c;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.omni-confirm-params {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.omni-confirm-params .omni-duration-control {
|
||||||
|
position: relative;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.omni-confirm-hint {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #9a3412;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.omni-confirm-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-confirm-card button {
|
.omni-confirm-card button {
|
||||||
@@ -1311,7 +1354,7 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--klein);
|
background: var(--klein);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1322,7 +1365,7 @@
|
|||||||
|
|
||||||
.omni-confirm-card button i {
|
.omni-confirm-card button i {
|
||||||
color: rgba(255, 255, 255, .72);
|
color: rgba(255, 255, 255, .72);
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1352,7 +1395,7 @@
|
|||||||
max-width: 220px;
|
max-width: 220px;
|
||||||
padding: 3px 8px 3px 4px;
|
padding: 3px 8px 3px 4px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1361,7 +1404,7 @@
|
|||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-size: 9px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1424,7 +1467,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
color: rgba(255, 255, 255, .72);
|
color: rgba(255, 255, 255, .72);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-send-spinner,
|
.omni-send-spinner,
|
||||||
@@ -1564,7 +1607,7 @@
|
|||||||
|
|
||||||
.omni-prompt-drawer header strong {
|
.omni-prompt-drawer header strong {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -1572,7 +1615,7 @@
|
|||||||
.omni-prompt-drawer header small {
|
.omni-prompt-drawer header small {
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
color: #8b919a;
|
color: #8b919a;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-prompt-drawer header button {
|
.omni-prompt-drawer header button {
|
||||||
@@ -1616,14 +1659,14 @@
|
|||||||
.omni-prompt-block h3 {
|
.omni-prompt-block h3 {
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
color: var(--klein);
|
color: var(--klein);
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-prompt-block p {
|
.omni-prompt-block p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #2b3240;
|
color: #2b3240;
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
|||||||
@@ -2,14 +2,12 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Box,
|
Box,
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
History,
|
History,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
SlidersHorizontal,
|
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
@@ -20,7 +18,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { CustomSelect } from "../components/custom-select";
|
import { OmniParamBar } from "../components/omni-param-bar";
|
||||||
import { ConfirmModal } from "../components/overlays";
|
import { ConfirmModal } from "../components/overlays";
|
||||||
import type { CreationConversation, CreationRef } from "../types";
|
import type { CreationConversation, CreationRef } from "../types";
|
||||||
import type { NavigateFn } from "./route-config";
|
import type { NavigateFn } from "./route-config";
|
||||||
@@ -37,8 +35,6 @@ type PresetItem = {
|
|||||||
cover: string;
|
cover: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Attachment = { name: string; type: string; url?: string; source: "local" | "library" };
|
|
||||||
|
|
||||||
const VIDEO_PRESETS: PresetItem[] = [
|
const VIDEO_PRESETS: PresetItem[] = [
|
||||||
{ name: "剧情反转带货", category: "story", mode: "video", title: "剧情反转带货", desc: "用人物冲突与意外反转建立记忆点,商品承担剧情中的关键作用。", starter: "创作一条有前后反转的剧情带货视频,让商品自然成为解决问题的关键。", cover: "/assets/prototype/photo-1529139574466-a303027c1d8b.jpg" },
|
{ name: "剧情反转带货", category: "story", mode: "video", title: "剧情反转带货", desc: "用人物冲突与意外反转建立记忆点,商品承担剧情中的关键作用。", starter: "创作一条有前后反转的剧情带货视频,让商品自然成为解决问题的关键。", cover: "/assets/prototype/photo-1529139574466-a303027c1d8b.jpg" },
|
||||||
{ name: "商品拟人广告", category: "commerce", mode: "video", title: "商品拟人广告", desc: "让商品成为故事主角,通过个性、动作和情绪完成趣味表达。", starter: "把商品塑造成有性格的拟人角色,创作一条轻快、有趣的短广告。", cover: "/assets/prototype/photo-1608248543803-ba4f8c70ae0b.jpg" },
|
{ name: "商品拟人广告", category: "commerce", mode: "video", title: "商品拟人广告", desc: "让商品成为故事主角,通过个性、动作和情绪完成趣味表达。", starter: "把商品塑造成有性格的拟人角色,创作一条轻快、有趣的短广告。", cover: "/assets/prototype/photo-1608248543803-ba4f8c70ae0b.jpg" },
|
||||||
@@ -75,12 +71,6 @@ const IMAGE_FILTERS = [
|
|||||||
{ key: "style", label: "风格" },
|
{ key: "style", label: "风格" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const VIDEO_MODELS = ["Seedance 2.5", "Seedance 2.0", "Seedance 2.0 Fast", "Seedance 2.0 Mini"];
|
|
||||||
const IMAGE_MODELS = ["Seedream5.0", "YQ image2"];
|
|
||||||
const VIDEO_DURATIONS = ["4 秒", "5 秒", "6 秒", "7 秒", "8 秒", "9 秒", "10 秒", "11 秒", "12 秒", "13 秒", "14 秒", "15 秒", "30 秒"];
|
|
||||||
const IMAGE_COUNTS = ["1 张", "2 张", "4 张", "8 张"];
|
|
||||||
const RATIOS = ["9:16", "16:9", "1:1", "4:3", "3:4"];
|
|
||||||
|
|
||||||
const MENTION_REF_LIMIT = 5;
|
const MENTION_REF_LIMIT = 5;
|
||||||
|
|
||||||
const MENTION_TABS: Array<{ type: CreationRef["type"]; label: string; Icon: typeof ImageIcon }> = [
|
const MENTION_TABS: Array<{ type: CreationRef["type"]; label: string; Icon: typeof ImageIcon }> = [
|
||||||
@@ -91,6 +81,14 @@ const MENTION_TABS: Array<{ type: CreationRef["type"]; label: string; Icon: type
|
|||||||
{ type: "scene", label: "场景", Icon: FolderOpen },
|
{ type: "scene", label: "场景", Icon: FolderOpen },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function mergeSessionUploads(results: CreationRef[], uploads: CreationRef[], query: string, type: CreationRef["type"]) {
|
||||||
|
if (type !== "asset") return results;
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
const extras = uploads.filter((item) => !q || item.name.toLowerCase().includes(q));
|
||||||
|
const seen = new Set(extras.map((item) => item.id));
|
||||||
|
return [...extras, ...results.filter((item) => !seen.has(item.id))];
|
||||||
|
}
|
||||||
|
|
||||||
function formatRelativeTime(iso: string) {
|
function formatRelativeTime(iso: string) {
|
||||||
const then = new Date(iso).getTime();
|
const then = new Date(iso).getTime();
|
||||||
if (Number.isNaN(then)) return "";
|
if (Number.isNaN(then)) return "";
|
||||||
@@ -103,10 +101,6 @@ function formatRelativeTime(iso: string) {
|
|||||||
return days < 30 ? `${days} 天前` : new Date(then).toLocaleDateString("zh-CN");
|
return days < 30 ? `${days} 天前` : new Date(then).toLocaleDateString("zh-CN");
|
||||||
}
|
}
|
||||||
|
|
||||||
function toOptions(values: string[]) {
|
|
||||||
return values.map((value) => ({ value, label: value }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OmniCreatePage({
|
export function OmniCreatePage({
|
||||||
navigate,
|
navigate,
|
||||||
onNotify,
|
onNotify,
|
||||||
@@ -117,13 +111,12 @@ export function OmniCreatePage({
|
|||||||
const [outputMode, setOutputMode] = useState<OutputMode>("video");
|
const [outputMode, setOutputMode] = useState<OutputMode>("video");
|
||||||
const [selectedCase, setSelectedCase] = useState<PresetItem | null>(null);
|
const [selectedCase, setSelectedCase] = useState<PresetItem | null>(null);
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
const [sessionUploads, setSessionUploads] = useState<CreationRef[]>([]);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
const [model, setModel] = useState("Seedance 2.5");
|
const [model, setModel] = useState("Seedance 2.5");
|
||||||
const [resolution, setResolution] = useState("1080p");
|
const [resolution, setResolution] = useState("1080p");
|
||||||
const [ratio, setRatio] = useState("9:16");
|
const [ratio, setRatio] = useState("9:16");
|
||||||
const [duration, setDuration] = useState("智能时长");
|
const [duration, setDuration] = useState("智能时长");
|
||||||
const [customDurationOn, setCustomDurationOn] = useState(false);
|
|
||||||
const [durationMenuOpen, setDurationMenuOpen] = useState(false);
|
|
||||||
const [uploadMenuOpen, setUploadMenuOpen] = useState(false);
|
const [uploadMenuOpen, setUploadMenuOpen] = useState(false);
|
||||||
const [mentionMenuOpen, setMentionMenuOpen] = useState(false);
|
const [mentionMenuOpen, setMentionMenuOpen] = useState(false);
|
||||||
const [mentionTab, setMentionTab] = useState<CreationRef["type"]>("asset");
|
const [mentionTab, setMentionTab] = useState<CreationRef["type"]>("asset");
|
||||||
@@ -143,16 +136,13 @@ export function OmniCreatePage({
|
|||||||
setResolution("模型默认");
|
setResolution("模型默认");
|
||||||
setRatio("1:1");
|
setRatio("1:1");
|
||||||
setDuration("1 张");
|
setDuration("1 张");
|
||||||
setCustomDurationOn(true);
|
|
||||||
} else {
|
} else {
|
||||||
setModel("Seedance 2.5");
|
setModel("Seedance 2.5");
|
||||||
setResolution("1080p");
|
setResolution("1080p");
|
||||||
setRatio("9:16");
|
setRatio("9:16");
|
||||||
setDuration("智能时长");
|
setDuration("智能时长");
|
||||||
setCustomDurationOn(false);
|
|
||||||
}
|
}
|
||||||
setActiveCategory("all");
|
setActiveCategory("all");
|
||||||
setDurationMenuOpen(false);
|
|
||||||
setSelectedCase((prev) => (prev && prev.mode !== outputMode ? null : prev));
|
setSelectedCase((prev) => (prev && prev.mode !== outputMode ? null : prev));
|
||||||
}, [outputMode]);
|
}, [outputMode]);
|
||||||
|
|
||||||
@@ -162,8 +152,7 @@ export function OmniCreatePage({
|
|||||||
if (toolsRef.current?.contains(target)) return;
|
if (toolsRef.current?.contains(target)) return;
|
||||||
setUploadMenuOpen(false);
|
setUploadMenuOpen(false);
|
||||||
setMentionMenuOpen(false);
|
setMentionMenuOpen(false);
|
||||||
setDurationMenuOpen(false);
|
};
|
||||||
};
|
|
||||||
document.addEventListener("mousedown", onDown);
|
document.addEventListener("mousedown", onDown);
|
||||||
return () => document.removeEventListener("mousedown", onDown);
|
return () => document.removeEventListener("mousedown", onDown);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -186,12 +175,11 @@ export function OmniCreatePage({
|
|||||||
setMentionTab(type);
|
setMentionTab(type);
|
||||||
setMentionMenuOpen(true);
|
setMentionMenuOpen(true);
|
||||||
setUploadMenuOpen(false);
|
setUploadMenuOpen(false);
|
||||||
setDurationMenuOpen(false);
|
|
||||||
setMentionLoading(true);
|
setMentionLoading(true);
|
||||||
setMentionResults([]);
|
setMentionResults([]);
|
||||||
try {
|
try {
|
||||||
const res = await api.searchMentions({ q: query, types: [type], limit: 12 });
|
const res = await api.searchMentions({ q: query, types: [type], limit: 12 });
|
||||||
setMentionResults(res.results);
|
setMentionResults(mergeSessionUploads(res.results, sessionUploads, query, type));
|
||||||
setTypeLabels(res.type_labels);
|
setTypeLabels(res.type_labels);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify?.("error", (error as Error).message);
|
onNotify?.("error", (error as Error).message);
|
||||||
@@ -215,20 +203,39 @@ export function OmniCreatePage({
|
|||||||
setMentionMenuOpen(false);
|
setMentionMenuOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(event.target.files || []);
|
const files = Array.from(event.target.files || []);
|
||||||
if (!files.length) return;
|
|
||||||
setAttachments((prev) => [
|
|
||||||
...prev,
|
|
||||||
...files.map((file) => ({
|
|
||||||
name: file.name,
|
|
||||||
type: file.type,
|
|
||||||
url: URL.createObjectURL(file),
|
|
||||||
source: "local" as const,
|
|
||||||
})),
|
|
||||||
]);
|
|
||||||
setUploadMenuOpen(false);
|
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
|
setUploadMenuOpen(false);
|
||||||
|
if (!files.length) return;
|
||||||
|
const images = files.filter((file) => file.type.startsWith("image/"));
|
||||||
|
if (images.length !== files.length) {
|
||||||
|
onNotify?.("info", "全能创作仅支持上传图片");
|
||||||
|
}
|
||||||
|
if (!images.length) return;
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
for (const file of images) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
const data = await api.uploadFreeVideoRef(form);
|
||||||
|
const ref: CreationRef = {
|
||||||
|
type: "asset",
|
||||||
|
id: data.asset_id,
|
||||||
|
name: data.name || file.name,
|
||||||
|
cover: data.thumb_url || data.url,
|
||||||
|
};
|
||||||
|
setSessionUploads((prev) => (prev.some((item) => item.id === ref.id) ? prev : [...prev, ref]));
|
||||||
|
setPendingRefs((prev) => {
|
||||||
|
if (prev.some((item) => item.id === ref.id) || prev.length >= MENTION_REF_LIMIT) return prev;
|
||||||
|
return [...prev, ref];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
onNotify?.("error", (error as Error).message);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -273,24 +280,11 @@ export function OmniCreatePage({
|
|||||||
<X />
|
<X />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}{attachments.map((file, index) => (
|
|
||||||
<span className="omni-attachment-chip" key={`${file.name}-${index}`}>
|
|
||||||
{file.type.startsWith("video") ? <Video /> : <ImageIcon />}
|
|
||||||
<span>{file.name}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="omni-attachment-remove"
|
|
||||||
aria-label={`删除 ${file.name}`}
|
|
||||||
onClick={() => setAttachments((prev) => prev.filter((_, i) => i !== index))}
|
|
||||||
>
|
|
||||||
<X />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}</div>
|
))}</div>
|
||||||
<textarea
|
<textarea
|
||||||
id="omniStartPrompt"
|
id="omniStartPrompt"
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="描述你想制作的内容,@ 可引用商品、模特、场景或已有素材……"
|
placeholder="描述你想制作的内容,@ 可引用商品、模特、场景、素材或刚上传的图片……"
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const value = event.target.value;
|
const value = event.target.value;
|
||||||
@@ -309,7 +303,6 @@ export function OmniCreatePage({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setUploadMenuOpen((open) => !open);
|
setUploadMenuOpen((open) => !open);
|
||||||
setMentionMenuOpen(false);
|
setMentionMenuOpen(false);
|
||||||
setDurationMenuOpen(false);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus />
|
<Plus />
|
||||||
@@ -328,14 +321,14 @@ export function OmniCreatePage({
|
|||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => fileInputRef.current?.click()}>
|
<button type="button" onClick={() => fileInputRef.current?.click()}>
|
||||||
<Upload />
|
<Upload />
|
||||||
<span>本地上传<small>添加电脑中的图片或视频</small></span>
|
<span>本地上传<small>添加电脑中的图片</small></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*,video/*"
|
accept="image/*"
|
||||||
multiple
|
multiple
|
||||||
hidden
|
hidden
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
@@ -349,7 +342,6 @@ export function OmniCreatePage({
|
|||||||
if (mentionMenuOpen) setMentionMenuOpen(false);
|
if (mentionMenuOpen) setMentionMenuOpen(false);
|
||||||
else void openMentions("", mentionTab);
|
else void openMentions("", mentionTab);
|
||||||
setUploadMenuOpen(false);
|
setUploadMenuOpen(false);
|
||||||
setDurationMenuOpen(false);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@
|
@
|
||||||
@@ -393,100 +385,25 @@ export function OmniCreatePage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="omni-parameter omni-parameter-model">
|
<OmniParamBar
|
||||||
<CustomSelect
|
isVideo={outputMode === "video"}
|
||||||
fill
|
model={model}
|
||||||
size="sm"
|
resolution={resolution}
|
||||||
aria-label={outputMode === "video" ? "视频模型" : "图片模型"}
|
ratio={ratio}
|
||||||
value={model}
|
duration={duration}
|
||||||
onChange={setModel}
|
onModel={setModel}
|
||||||
options={toOptions(outputMode === "video" ? VIDEO_MODELS : IMAGE_MODELS)}
|
onResolution={setResolution}
|
||||||
/>
|
onRatio={setRatio}
|
||||||
</label>
|
onDuration={setDuration}
|
||||||
<label className={`omni-parameter${outputMode === "image" ? " is-hidden" : ""}`}>
|
/>
|
||||||
<CustomSelect
|
|
||||||
fill
|
|
||||||
size="sm"
|
|
||||||
aria-label="分辨率"
|
|
||||||
value={resolution}
|
|
||||||
onChange={setResolution}
|
|
||||||
options={toOptions(["1080p", "720p", "480p"])}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="omni-parameter">
|
|
||||||
<CustomSelect
|
|
||||||
fill
|
|
||||||
size="sm"
|
|
||||||
aria-label="画面比例"
|
|
||||||
value={ratio}
|
|
||||||
onChange={setRatio}
|
|
||||||
options={toOptions(RATIOS)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className={`omni-duration-control${outputMode === "image" ? " is-image-count" : ""}`}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="omni-duration-trigger"
|
|
||||||
aria-expanded={durationMenuOpen}
|
|
||||||
onClick={() => {
|
|
||||||
setDurationMenuOpen((open) => !open);
|
|
||||||
setUploadMenuOpen(false);
|
|
||||||
setMentionMenuOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>{duration}</span>
|
|
||||||
<ChevronDown />
|
|
||||||
</button>
|
|
||||||
<div className="omni-duration-menu" hidden={!durationMenuOpen}>
|
|
||||||
<span className="omni-duration-title">{outputMode === "image" ? "生成张数" : "时长"}</span>
|
|
||||||
<div className="omni-duration-modes">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={!customDurationOn ? "active" : ""}
|
|
||||||
onClick={() => {
|
|
||||||
setCustomDurationOn(false);
|
|
||||||
setDuration("智能时长");
|
|
||||||
setDurationMenuOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Sparkles />
|
|
||||||
<span>智能时长</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={customDurationOn ? "active" : ""}
|
|
||||||
onClick={() => setCustomDurationOn(true)}
|
|
||||||
>
|
|
||||||
<SlidersHorizontal />
|
|
||||||
<span>自定义时长</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="omni-duration-values" hidden={outputMode === "video" && !customDurationOn}>
|
|
||||||
{(outputMode === "image" ? IMAGE_COUNTS : VIDEO_DURATIONS).map((value) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={value}
|
|
||||||
className={duration === value ? "active" : ""}
|
|
||||||
onClick={() => {
|
|
||||||
setDuration(value);
|
|
||||||
setCustomDurationOn(true);
|
|
||||||
setDurationMenuOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="omni-start-generate"
|
className="omni-start-generate"
|
||||||
disabled={starting}
|
disabled={starting || uploading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const text = prompt.trim();
|
const text = prompt.trim();
|
||||||
if (!text && !selectedCase && attachments.length === 0) {
|
if (!text && !selectedCase && pendingRefs.length === 0) {
|
||||||
onNotify?.("info", "请先输入你想制作的内容,或选择一个创作预设");
|
onNotify?.("info", "请先输入你想制作的内容,或选择一个创作预设");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -508,7 +425,7 @@ export function OmniCreatePage({
|
|||||||
})
|
})
|
||||||
.then((conversation) => {
|
.then((conversation) => {
|
||||||
// 首条消息交给对话页发,避免这里再复制一份 SSE 消费逻辑
|
// 首条消息交给对话页发,避免这里再复制一份 SSE 消费逻辑
|
||||||
navigate("omniSession", { conversationId: conversation.id, firstMessage: text, firstRefs: pendingRefs });
|
navigate("omniSession", { conversationId: conversation.id, firstMessage: text, firstRefs: pendingRefs, firstUploads: sessionUploads });
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
onNotify?.("error", (error as Error).message);
|
onNotify?.("error", (error as Error).message);
|
||||||
@@ -516,7 +433,7 @@ export function OmniCreatePage({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{starting ? "正在创建…" : "开始创作"}
|
{uploading ? "图片上传中…" : starting ? "正在创建…" : "开始创作"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -678,10 +595,6 @@ export function OmniHistoryPage({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="omni-history-new" onClick={() => navigate("omniCreate")}>
|
|
||||||
<Plus />
|
|
||||||
新建会话
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="omni-history-list">
|
<div className="omni-history-list">
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
|
import { OmniParamBar } from "../components/omni-param-bar";
|
||||||
import { MediaLightbox } from "../components/overlays";
|
import { MediaLightbox } from "../components/overlays";
|
||||||
import type {
|
import type {
|
||||||
CreationConversationDetail,
|
CreationConversationDetail,
|
||||||
@@ -149,14 +150,10 @@ 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 };
|
||||||
type PlanMatrixRow = { point: string; hits: number[] };
|
|
||||||
|
|
||||||
function PlanCard({ payload }: { payload: Record<string, unknown> }) {
|
function PlanCard({ payload }: { payload: Record<string, unknown> }) {
|
||||||
const points = (payload.points as string[] | undefined) || [];
|
const points = (payload.points as string[] | undefined) || [];
|
||||||
const timeline = (payload.timeline as PlanTimelineItem[] | undefined) || [];
|
const timeline = (payload.timeline as PlanTimelineItem[] | undefined) || [];
|
||||||
const matrix = (payload.matrix as { shots?: number; rows?: PlanMatrixRow[] } | undefined) || {};
|
|
||||||
const shots = matrix.shots || 4;
|
|
||||||
const rows = matrix.rows || [];
|
|
||||||
const voice = (payload.voice_chars as number[] | undefined) || [];
|
const voice = (payload.voice_chars as number[] | undefined) || [];
|
||||||
const format = (value: number) => (Number.isInteger(value) ? String(value) : value.toFixed(1));
|
const format = (value: number) => (Number.isInteger(value) ? String(value) : value.toFixed(1));
|
||||||
|
|
||||||
@@ -196,33 +193,6 @@ function PlanCard({ payload }: { payload: Record<string, unknown> }) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
{rows.length > 0 ? (
|
|
||||||
<section className="omni-plan-section">
|
|
||||||
<strong>卖点覆盖矩阵</strong>
|
|
||||||
<table className="omni-plan-matrix">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>卖点</th>
|
|
||||||
{Array.from({ length: shots }, (_, i) => (
|
|
||||||
<th key={i}>镜头 {i + 1}</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.map((row) => (
|
|
||||||
<tr key={row.point}>
|
|
||||||
<td>{row.point}</td>
|
|
||||||
{Array.from({ length: shots }, (_, i) => (
|
|
||||||
<td key={i} className={row.hits?.includes(i + 1) ? "hit" : undefined}>
|
|
||||||
{row.hits?.includes(i + 1) ? "✓" : ""}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
<div className="omni-plan-summary">
|
<div className="omni-plan-summary">
|
||||||
{voice.length === 2 ? (
|
{voice.length === 2 ? (
|
||||||
<span>
|
<span>
|
||||||
@@ -426,24 +396,83 @@ function ElicitCard({
|
|||||||
* 确认闸门:方案卡下面那条「开始生成 · 约 N 积分」。
|
* 确认闸门:方案卡下面那条「开始生成 · 约 N 积分」。
|
||||||
* 点一次就锁住 —— 连点两下后端会 409,但前端也不该让它发生第二次。
|
* 点一次就锁住 —— 连点两下后端会 409,但前端也不该让它发生第二次。
|
||||||
*/
|
*/
|
||||||
|
function asStringMap(value: unknown): Record<string, string> {
|
||||||
|
if (!value || typeof value !== "object") return {};
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
if (item != null && item !== "") out[key] = String(item);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paramLine(params: Record<string, string>, isVideo: boolean) {
|
||||||
|
|
||||||
|
return [
|
||||||
|
params.model,
|
||||||
|
isVideo ? params.resolution : "",
|
||||||
|
params.ratio,
|
||||||
|
isVideo ? params.duration : params.count,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
function ConfirmCard({
|
function ConfirmCard({
|
||||||
message,
|
message,
|
||||||
|
sessionParams,
|
||||||
|
isVideo,
|
||||||
disabled,
|
disabled,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: {
|
}: {
|
||||||
message: CreationMessage;
|
message: CreationMessage;
|
||||||
|
sessionParams: Record<string, string>;
|
||||||
|
isVideo: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
onConfirm: () => void;
|
onConfirm: (params: Record<string, string>) => void;
|
||||||
}) {
|
}) {
|
||||||
const submitted = Boolean(message.payload.submitted);
|
const submitted = Boolean(message.payload.submitted);
|
||||||
const credits = Number(message.payload.estimated_credits || 0);
|
const credits = Number(message.payload.estimated_credits || 0);
|
||||||
|
const payloadParams = asStringMap(message.payload.params);
|
||||||
|
const snapshot = { ...sessionParams, ...payloadParams };
|
||||||
|
const [draft, setDraft] = useState(snapshot);
|
||||||
|
const cardIsVideo = message.payload.kind !== "image" && isVideo;
|
||||||
|
const durationChanged =
|
||||||
|
cardIsVideo && Boolean(draft.duration) && Boolean(snapshot.duration) && draft.duration !== snapshot.duration;
|
||||||
|
const setField = (key: string, value: string) => setDraft((prev) => ({ ...prev, [key]: value }));
|
||||||
|
const summary = paramLine(draft, cardIsVideo) || "当前参数";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="omni-confirm-card">
|
<section className="omni-confirm-card">
|
||||||
<span>{submitted ? "已确认,正在出片" : "方案确认后即可出片,中途不再打断"}</span>
|
<div className="omni-confirm-copy">
|
||||||
<button type="button" disabled={disabled || submitted} onClick={onConfirm}>
|
<strong>{submitted ? "已确认" : `即将用 ${summary} 生成`}</strong>
|
||||||
{String(message.payload.label || "开始生成")}
|
<span>{submitted ? "正在提交生成" : "确认前可以改参数。改时长会按新时长重写脚本。"}</span>
|
||||||
{credits > 0 ? <i>约 {credits} 积分</i> : null}
|
</div>
|
||||||
</button>
|
{submitted ? null : (
|
||||||
|
<div className="omni-confirm-params">
|
||||||
|
<OmniParamBar
|
||||||
|
isVideo={cardIsVideo}
|
||||||
|
disabled={disabled}
|
||||||
|
model={draft.model || ""}
|
||||||
|
resolution={draft.resolution || ""}
|
||||||
|
ratio={draft.ratio || ""}
|
||||||
|
duration={cardIsVideo ? (draft.duration || "") : (draft.count || "")}
|
||||||
|
onModel={(value) => setField("model", value)}
|
||||||
|
onResolution={(value) => setField("resolution", value)}
|
||||||
|
onRatio={(value) => setField("ratio", value)}
|
||||||
|
onDuration={(value) => setField(cardIsVideo ? "duration" : "count", value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{durationChanged ? (
|
||||||
|
<p className="omni-confirm-hint">改时长会重新生成脚本。确认后先重写方案,不会直接出片。</p>
|
||||||
|
) : null}
|
||||||
|
<div className="omni-confirm-foot">
|
||||||
|
<span>{submitted ? "已确认,正在出片" : "点确认后才会提交生成"}</span>
|
||||||
|
<button type="button" disabled={disabled || submitted} onClick={() => onConfirm(draft)}>
|
||||||
|
{durationChanged ? "确认并重写脚本" : String(message.payload.label || "开始生成")}
|
||||||
|
{!durationChanged && credits > 0 ? <i>约 {credits} 积分</i> : null}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -592,12 +621,21 @@ const MENTION_TABS: Array<{ type: CreationRef["type"]; label: string; Icon: type
|
|||||||
{ type: "scene", label: "场景", Icon: FolderOpen },
|
{ type: "scene", label: "场景", Icon: FolderOpen },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function mergeSessionUploads(results: CreationRef[], uploads: CreationRef[], query: string, type: CreationRef["type"]) {
|
||||||
|
if (type !== "asset") return results;
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
const extras = uploads.filter((item) => !q || item.name.toLowerCase().includes(q));
|
||||||
|
const seen = new Set(extras.map((item) => item.id));
|
||||||
|
return [...extras, ...results.filter((item) => !seen.has(item.id))];
|
||||||
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────── 页面
|
// ────────────────────────────────────────────────────────── 页面
|
||||||
|
|
||||||
export function OmniSessionPage({
|
export function OmniSessionPage({
|
||||||
conversationId,
|
conversationId,
|
||||||
firstMessage,
|
firstMessage,
|
||||||
firstRefs,
|
firstRefs,
|
||||||
|
firstUploads,
|
||||||
navigate,
|
navigate,
|
||||||
onNotify,
|
onNotify,
|
||||||
}: {
|
}: {
|
||||||
@@ -605,6 +643,7 @@ export function OmniSessionPage({
|
|||||||
/** 首页「开始创作」带过来的第一句话。只在刚进页面时发一次,刷新后不重发(它已经在库里)。 */
|
/** 首页「开始创作」带过来的第一句话。只在刚进页面时发一次,刷新后不重发(它已经在库里)。 */
|
||||||
firstMessage?: string;
|
firstMessage?: string;
|
||||||
firstRefs?: CreationRef[];
|
firstRefs?: CreationRef[];
|
||||||
|
firstUploads?: CreationRef[];
|
||||||
navigate: NavigateFn;
|
navigate: NavigateFn;
|
||||||
onNotify?: (type: "success" | "error" | "info", text: string) => void;
|
onNotify?: (type: "success" | "error" | "info", text: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -612,6 +651,9 @@ export function OmniSessionPage({
|
|||||||
const [messages, setMessages] = useState<CreationMessage[]>([]);
|
const [messages, setMessages] = useState<CreationMessage[]>([]);
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [pendingRefs, setPendingRefs] = useState<CreationRef[]>([]);
|
const [pendingRefs, setPendingRefs] = useState<CreationRef[]>([]);
|
||||||
|
const [sessionUploads, setSessionUploads] = useState<CreationRef[]>(firstUploads || []);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [streaming, setStreaming] = useState(false);
|
const [streaming, setStreaming] = useState(false);
|
||||||
const [liveText, setLiveText] = useState("");
|
const [liveText, setLiveText] = useState("");
|
||||||
const [activeTool, setActiveTool] = useState("");
|
const [activeTool, setActiveTool] = useState("");
|
||||||
@@ -708,17 +750,29 @@ export function OmniSessionPage({
|
|||||||
// firstSentRef 挡住 StrictMode 的双次挂载,否则会重复发一条。
|
// firstSentRef 挡住 StrictMode 的双次挂载,否则会重复发一条。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!conversation || firstSentRef.current) return;
|
if (!conversation || firstSentRef.current) return;
|
||||||
if (!firstMessage?.trim() || conversation.messages.length > 0) {
|
const text = firstMessage?.trim() || "";
|
||||||
|
const refs = firstRefs || [];
|
||||||
|
if ((!text && refs.length === 0) || conversation.messages.length > 0) {
|
||||||
firstSentRef.current = true;
|
firstSentRef.current = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
firstSentRef.current = true;
|
firstSentRef.current = true;
|
||||||
void send({ kind: "text", text: firstMessage.trim(), refs: firstRefs || [] });
|
void send({ kind: "text", text, refs });
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [conversation, firstMessage]);
|
}, [conversation, firstMessage]);
|
||||||
|
|
||||||
useEffect(() => () => abortRef.current?.abort(), []);
|
useEffect(() => () => abortRef.current?.abort(), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fromHistory = messages.flatMap((message) => message.refs || []).filter((ref) => ref.type === "asset");
|
||||||
|
if (!fromHistory.length) return;
|
||||||
|
setSessionUploads((prev) => {
|
||||||
|
const seen = new Set(prev.map((item) => item.id));
|
||||||
|
const extra = fromHistory.filter((item) => !seen.has(item.id));
|
||||||
|
return extra.length ? [...prev, ...extra] : prev;
|
||||||
|
});
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
const hasGenerating = useMemo(
|
const hasGenerating = useMemo(
|
||||||
() => messages.some((message) => message.kind === "generating"),
|
() => messages.some((message) => message.kind === "generating"),
|
||||||
[messages]
|
[messages]
|
||||||
@@ -863,18 +917,32 @@ export function OmniSessionPage({
|
|||||||
[conversationId, applyEvent, notify]
|
[conversationId, applyEvent, notify]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleConfirm = async (message: CreationMessage) => {
|
const handleConfirm = async (message: CreationMessage, nextParams: Record<string, string>) => {
|
||||||
if (confirming) return;
|
if (confirming) return;
|
||||||
setConfirming(true);
|
setConfirming(true);
|
||||||
try {
|
try {
|
||||||
const { message: generating } = await api.confirmCreationPlan(conversationId, message.id);
|
const result = await api.confirmCreationPlan(conversationId, message.id, nextParams);
|
||||||
// 闸门置灰 + 追加「生成中」卡;结果由轮询回填
|
setMessages((prev) =>
|
||||||
setMessages((prev) => [
|
prev.map((m) =>
|
||||||
...prev.map((m) =>
|
m.id === message.id
|
||||||
m.id === message.id ? { ...m, payload: { ...m.payload, submitted: true } } : m
|
? { ...m, payload: { ...m.payload, submitted: true, params: nextParams } }
|
||||||
),
|
: m
|
||||||
generating,
|
)
|
||||||
]);
|
);
|
||||||
|
setConversation((prev) =>
|
||||||
|
prev ? { ...prev, params: { ...prev.params, ...nextParams } } : prev
|
||||||
|
);
|
||||||
|
if (result.regenerate) {
|
||||||
|
notify("info", `时长已改为 ${nextParams.duration || ""},正在按新时长重写脚本`);
|
||||||
|
void send({
|
||||||
|
kind: "text",
|
||||||
|
text: `时长改成了${nextParams.duration},请按新参数重新写方案,旧方案作废`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.message) {
|
||||||
|
setMessages((prev) => [...prev, result.message as CreationMessage]);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notify("error", (error as Error).message);
|
notify("error", (error as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -885,7 +953,7 @@ export function OmniSessionPage({
|
|||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
// 顺序要紧:先判 streaming 再清输入框。反过来的话,流式期间敲一次回车
|
// 顺序要紧:先判 streaming 再清输入框。反过来的话,流式期间敲一次回车
|
||||||
// 会把已经打好的内容清空,消息却没发出去。
|
// 会把已经打好的内容清空,消息却没发出去。
|
||||||
if (streaming) return;
|
if (streaming || uploading) return;
|
||||||
const text = prompt.trim();
|
const text = prompt.trim();
|
||||||
if (!text && pendingRefs.length === 0) return;
|
if (!text && pendingRefs.length === 0) return;
|
||||||
setPrompt("");
|
setPrompt("");
|
||||||
@@ -902,7 +970,7 @@ export function OmniSessionPage({
|
|||||||
setMentionResults([]);
|
setMentionResults([]);
|
||||||
try {
|
try {
|
||||||
const res = await api.searchMentions({ q: query, types: [type], limit: 12 });
|
const res = await api.searchMentions({ q: query, types: [type], limit: 12 });
|
||||||
setMentionResults(res.results);
|
setMentionResults(mergeSessionUploads(res.results, sessionUploads, query, type));
|
||||||
setTypeLabels(res.type_labels);
|
setTypeLabels(res.type_labels);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notify("error", (error as Error).message);
|
notify("error", (error as Error).message);
|
||||||
@@ -971,8 +1039,10 @@ export function OmniSessionPage({
|
|||||||
<ConfirmCard
|
<ConfirmCard
|
||||||
key={message.id}
|
key={message.id}
|
||||||
message={message}
|
message={message}
|
||||||
|
sessionParams={params}
|
||||||
|
isVideo={isVideo}
|
||||||
disabled={streaming || confirming}
|
disabled={streaming || confirming}
|
||||||
onConfirm={() => void handleConfirm(message)}
|
onConfirm={(nextParams) => void handleConfirm(message, nextParams)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "generating":
|
case "generating":
|
||||||
@@ -1086,9 +1156,9 @@ export function OmniSessionPage({
|
|||||||
<textarea
|
<textarea
|
||||||
id="omniSessionPrompt"
|
id="omniSessionPrompt"
|
||||||
rows={2}
|
rows={2}
|
||||||
placeholder="回复创作助手,也可以继续补充图片、视频或要求……"
|
placeholder="回复创作助手,也可以继续补充图片或要求……"
|
||||||
value={prompt}
|
value={prompt}
|
||||||
disabled={streaming}
|
disabled={streaming || uploading}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const value = event.target.value;
|
const value = event.target.value;
|
||||||
setPrompt(value);
|
setPrompt(value);
|
||||||
@@ -1134,15 +1204,55 @@ export function OmniSessionPage({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setUploadMenuOpen(false);
|
setUploadMenuOpen(false);
|
||||||
notify("info", "本地上传稍后接入");
|
fileInputRef.current?.click();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Upload />
|
<Upload />
|
||||||
<span>
|
<span>
|
||||||
本地上传<small>添加电脑中的图片或视频</small>
|
本地上传<small>添加电脑中的图片</small>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
onChange={async (event) => {
|
||||||
|
const files = Array.from(event.target.files || []);
|
||||||
|
event.target.value = "";
|
||||||
|
if (!files.length) return;
|
||||||
|
const images = files.filter((file) => file.type.startsWith("image/"));
|
||||||
|
if (images.length !== files.length) {
|
||||||
|
notify("info", "全能创作仅支持上传图片");
|
||||||
|
}
|
||||||
|
if (!images.length) return;
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
for (const file of images) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
const data = await api.uploadFreeVideoRef(form);
|
||||||
|
const ref: CreationRef = {
|
||||||
|
type: "asset",
|
||||||
|
id: data.asset_id,
|
||||||
|
name: data.name || file.name,
|
||||||
|
cover: data.thumb_url || data.url,
|
||||||
|
};
|
||||||
|
setSessionUploads((prev) => (prev.some((item) => item.id === ref.id) ? prev : [...prev, ref]));
|
||||||
|
setPendingRefs((prev) => {
|
||||||
|
if (prev.some((item) => item.id === ref.id) || prev.length >= MENTION_REF_LIMIT) return prev;
|
||||||
|
return [...prev, ref];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
notify("error", (error as Error).message);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="omni-session-mention-wrap" ref={mentionWrapRef}>
|
<div className="omni-session-mention-wrap" ref={mentionWrapRef}>
|
||||||
<button
|
<button
|
||||||
@@ -1199,7 +1309,7 @@ export function OmniSessionPage({
|
|||||||
type="button"
|
type="button"
|
||||||
className="omni-session-send"
|
className="omni-session-send"
|
||||||
aria-label="发送"
|
aria-label="发送"
|
||||||
disabled={streaming}
|
disabled={streaming || uploading}
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
>
|
>
|
||||||
<ArrowUp />
|
<ArrowUp />
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export type ResolvedRoute = {
|
|||||||
// 刷新后它已经在库里了,再发一遍会重复。
|
// 刷新后它已经在库里了,再发一遍会重复。
|
||||||
firstMessage?: string;
|
firstMessage?: string;
|
||||||
firstRefs?: import("../types").CreationRef[];
|
firstRefs?: import("../types").CreationRef[];
|
||||||
|
firstUploads?: import("../types").CreationRef[];
|
||||||
hash?: string;
|
hash?: string;
|
||||||
// 视频项目页初始 tab(all/wip/done/fail):由 navigate 透传,刷新/前进后退不持久(回默认 all)。
|
// 视频项目页初始 tab(all/wip/done/fail):由 navigate 透传,刷新/前进后退不持久(回默认 all)。
|
||||||
tab?: string;
|
tab?: string;
|
||||||
@@ -67,6 +68,7 @@ export type NavigateOptions = {
|
|||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
firstMessage?: string;
|
firstMessage?: string;
|
||||||
firstRefs?: import("../types").CreationRef[];
|
firstRefs?: import("../types").CreationRef[];
|
||||||
|
firstUploads?: import("../types").CreationRef[];
|
||||||
replace?: boolean;
|
replace?: boolean;
|
||||||
hash?: string;
|
hash?: string;
|
||||||
// 视频项目页初始 tab(all/wip/done/fail):仅经 route state 透传给 ProjectsPage,不进 URL path。
|
// 视频项目页初始 tab(all/wip/done/fail):仅经 route state 透传给 ProjectsPage,不进 URL path。
|
||||||
|
|||||||
Reference in New Issue
Block a user