diff --git a/core/ARCHITECTURE.md b/core/ARCHITECTURE.md index 6000488..d9169cf 100644 --- a/core/ARCHITECTURE.md +++ b/core/ARCHITECTURE.md @@ -338,8 +338,8 @@ AIProvider 当前模型决策来自 `account.md`,代码只读取环境变量: -- 文本主模型:DeepSeek-V3-2 -- 文本备用模型:Doubao Seed 2.0 Pro / Lite +- 文本主模型:Doubao Seed 2.1 Pro +- 文本备用:不再使用 DeepSeek - 图片模型:Seedream 5.0 Lite / 5.0 / 4.5 - 视频模型:Seedance 2.0 / 2.0 Fast / 1.5 Pro diff --git a/core/backend/apps/ai/catalog.py b/core/backend/apps/ai/catalog.py index 67d370a..32dce07 100644 --- a/core/backend/apps/ai/catalog.py +++ b/core/backend/apps/ai/catalog.py @@ -29,12 +29,23 @@ YUNQI_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", "name": "doubao-seed-2-0-pro-260215", "capability": "text", "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", @@ -184,7 +195,10 @@ def _catalog_capabilities(item: dict) -> dict: capability = item["capability"] metadata = item["metadata"] 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": modes = set(metadata.get("modes") or []) supports_reference = bool(metadata.get("supports_reference")) or bool( diff --git a/core/backend/apps/ai/creation_agent.py b/core/backend/apps/ai/creation_agent.py index 9e4a1ed..14e5619 100644 --- a/core/backend/apps/ai/creation_agent.py +++ b/core/backend/apps/ai/creation_agent.py @@ -29,7 +29,7 @@ from .creation import append_message, pin_refs from .creation_presets import preset_guidance from .mentions import TYPE_LABELS, infer_field_types, resolve_refs, search_mentions 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__) @@ -209,6 +209,52 @@ def apply_session_params(conversation, fields, answers: dict) -> bool: 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 def tool_schemas(context: AgentContext) -> list[dict]: @@ -330,24 +376,6 @@ def tool_schemas(context: AgentContext) -> list[dict]: "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": { "type": "array", "items": {"type": "integer"}, "description": "口播字数区间 [下限, 上限]", @@ -587,9 +615,134 @@ def submit_confirmed_video(*, conversation: CreationConversation, user, confirm_ 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: conversation = context.conversation params = conversation.params or {} @@ -635,7 +788,9 @@ def build_system_prompt(context: AgentContext) -> str: lines.append("\n【本次会话已锁定的素材事实】") lines.append(resolved.facts_text) lines.append( - "以上素材的参考图会自动附给生成模型锁人锁物,你在 prompt 里不需要重复描述它们的外观。" + "以上素材的参考图会附给你看,出片时也会自动锁人锁物。" + "人物性别、年龄段、发型、服装、商品颜色外形必须以图为准;" + "图上看不清或没附图时,必须问用户,禁止凭文件名猜测男女。" ) memory = conversation.memory or {} if memory.get("summary"): @@ -682,6 +837,11 @@ def build_messages(context: AgentContext) -> list[dict]: messages.append({"role": "assistant", "content": f"(我生成了一版,prompt:{prompt[:200]})"}) elif message.kind == CreationMessage.Kind.ERROR: 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 @@ -727,7 +887,7 @@ def stream_creation_agent( ) -> Iterator[str]: """一条用户消息 → SSE 流。生成器,由 StreamingHttpResponse 逐帧下发。""" 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: yield _sse({"type": "error", "detail": "没有可用的文本模型,请先在模型库配置"}) return @@ -738,6 +898,8 @@ def stream_creation_agent( pin_refs(conversation, refs) user_message = append_message(conversation, role="user", text=text, refs=refs) 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) if resolved.missing: @@ -898,7 +1060,6 @@ def _dispatch_tool(context: AgentContext, name: str, args: dict) -> tuple[dict, "usp": str(args.get("usp") or ""), "points": [str(p) for p in (args.get("points") or [])][:3], "timeline": args.get("timeline") or [], - "matrix": args.get("matrix") or {}, "voice_chars": args.get("voice_chars") 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( context.conversation, role="assistant", kind=CreationMessage.Kind.CONFIRM, - payload={"label": "开始生成", "estimated_credits": credits, - "video_prompt": video_prompt, "submitted": False}, + payload={ + "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": "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: # 一条用户消息只计费一次。模型想连出好几版时在这里挡住。 return {"payload": {"error": "本轮已经生成过一次了,请让用户看过再决定要不要改"}}, True - payload, tasks = _run_generate_image(context, args) - events = [] - for task in tasks: - message = append_message( - context.conversation, role="assistant", - kind=CreationMessage.Kind.GENERATING, - payload={"task_id": str(task.id), "kind": "image", "prompt": args.get("prompt", "")}, - task=task, - ) - events.append({"type": "message", "message": _message_payload(message)}) - events.append({"type": "task", "task_id": str(task.id), "kind": "image"}) - _remember_artifact(context.conversation, args.get("prompt", ""), "image") - return {"payload": payload, "_events": events}, True + prompt = str(args.get("prompt") or "").strip() + if not prompt: + return {"payload": {"error": "生成失败:模型没有给出画面描述"}}, False + # 出图也走确认卡:用户先看当前模型/比例/张数,点了才提交。 + confirm = append_message( + context.conversation, role="assistant", + kind=CreationMessage.Kind.CONFIRM, + payload={ + "kind": "image", + "label": "开始生成", + "estimated_credits": 0, + "prompt": prompt, + "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 diff --git a/core/backend/apps/ai/free_video.py b/core/backend/apps/ai/free_video.py index 12272b3..94b277d 100644 --- a/core/backend/apps/ai/free_video.py +++ b/core/backend/apps/ai/free_video.py @@ -839,6 +839,8 @@ def _store_free_video_media(*, task: AITask, media: str = "", video_bytes: bytes if feature == "video_replace": mode_label = "角色复刻" if payload.get("replace_mode") == "character" else "商品复刻" asset_name = f"{subject}{mode_label}" if subject else mode_label + elif feature == "omni_create": + asset_name = prompt[:255] or "全能创作视频" else: asset_name = prompt[:255] or "自由创作视频" 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, category=Asset.Category.FREE_CREATE, origin_task=task, + # 全能创作成品只挂在会话结果卡上,不进资产库/专业创作用的视频列表。 + in_library=feature != "omni_create", metadata={"feature": feature}, ) AssetFile.objects.create( diff --git a/core/backend/apps/ai/mentions.py b/core/backend/apps/ai/mentions.py index d8a7730..1c31e07 100644 --- a/core/backend/apps/ai/mentions.py +++ b/core/backend/apps/ai/mentions.py @@ -231,6 +231,19 @@ def refs_from_elicit_answers(team, fields, answers: dict) -> list[dict]: 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: """商品事实块。全能创作没有 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: """商品参考图:**真实上传图优先,排除 AI 生成图** —— 拿生成图当真相再喂回模型会误差累积。 一张真实图都没有才回落封面(可能是 AI 图,但好过纯文生图)。 @@ -314,6 +377,9 @@ def resolve_refs(team, refs: list[dict]) -> ResolvedRefs: entry = _product_reference(product) if entry: resolved.references.append(entry) + triview = _product_triview_asset(product) + if triview is not None: + _append_ref(resolved, triview, "product", f"{product.title}三视图") continue if type_ == "model": model = Model.objects.filter( @@ -322,15 +388,23 @@ def resolve_refs(team, refs: list[dict]) -> ResolvedRefs: if model is None: resolved.missing.append(ref) continue - if (model.description or "").strip(): - resolved.facts.append(f"模特「{model.name}」:{model.description.strip()}") - # 锁脸优先用三视图(正/侧/背都在一张 16:9 里,信息量最大),没有才回落形象图 - entry = _asset_reference(model.triview_asset, "model", model.name) or _asset_reference( - model.portrait_asset, "model", model.name - ) - if entry: - resolved.references.append(entry) + person_lines = [] + desc = (model.description or "").strip() + meta_lines = _person_meta_facts(model.metadata) + if desc: + person_lines.append(f"模特「{model.name}」:{desc}") 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) continue asset = Asset.objects.filter( @@ -339,12 +413,23 @@ def resolve_refs(team, refs: list[dict]) -> ResolvedRefs: if asset is None: resolved.missing.append(ref) continue - if (asset.description or "").strip(): - resolved.facts.append(f"{TYPE_LABELS[type_]}「{asset.name}」:{asset.description.strip()}") - entry = _asset_reference(asset, type_, asset.name) - if entry: - resolved.references.append(entry) - else: + desc = (asset.description or "").strip() + meta_lines = _person_meta_facts(asset.metadata) + label = TYPE_LABELS[type_] + person_lines = [f"{label}「{asset.name}」:{desc}" if desc else f"{label}「{asset.name}」"] + person_lines.extend(meta_lines) + 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) # 角色 → 场景 → 商品。同优先级内保持用户 @ 的先后。 diff --git a/core/backend/apps/ai/migrations/0035_seed_21_pro_default_text.py b/core/backend/apps/ai/migrations/0035_seed_21_pro_default_text.py new file mode 100644 index 0000000..e8470eb --- /dev/null +++ b/core/backend/apps/ai/migrations/0035_seed_21_pro_default_text.py @@ -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), + ] diff --git a/core/backend/apps/ai/migrations/0036_disable_deepseek_text.py b/core/backend/apps/ai/migrations/0036_disable_deepseek_text.py new file mode 100644 index 0000000..41cd803 --- /dev/null +++ b/core/backend/apps/ai/migrations/0036_disable_deepseek_text.py @@ -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), + ] diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 64c6abe..43a521a 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -54,11 +54,61 @@ logger = logging.getLogger(__name__) 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: qs = ( ModelConfig.objects.select_related("provider") .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」(原行为,零回归) return qs.filter(is_default=True).order_by("created_at").first() or qs.order_by("created_at").first() diff --git a/core/backend/apps/ai/test_creation_agent.py b/core/backend/apps/ai/test_creation_agent.py index dcd21f1..58db6f6 100644 --- a/core/backend/apps/ai/test_creation_agent.py +++ b/core/backend/apps/ai/test_creation_agent.py @@ -15,6 +15,7 @@ from apps.products.models import Product, ProductImage from .creation import append_message from .creation_agent import ( + AgentContext, COMPRESS_MIN_BATCH, DEFAULT_VIDEO_MODEL, KEEP_RECENT_MESSAGES, @@ -22,7 +23,10 @@ from .creation_agent import ( _coerce_fields, _image_count, _merge_tool_call_deltas, + build_messages, + get_creation_chat_model, stream_creation_agent, + submit_confirmed_image, submit_confirmed_video, video_duration, video_model_name, @@ -241,51 +245,67 @@ class GenerateImageTests(CreationAgentBaseTests): ProductImage.objects.create(product=self.product, asset=asset, is_primary=True) 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: enqueue.return_value = [self._fake_task("k-ref")] - events, _ = self._run( - [_tool_chunks("generate_image", {"prompt": "干净棚拍,柔光,居中构图"})], - refs=[{"type": "product", "id": str(self.product.id), "name": "净颜精华"}], + message, error = submit_confirmed_image( + conversation=self.conversation, user=self.user, confirm_message=card, ) 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["ratio"], "1: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): self.conversation.params = {"ratio": "1:1", "count": "2 张"} 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: enqueue.return_value = [self._fake_task("k-count-1"), self._fake_task("k-count-2")] - events, _ = self._run( - [_tool_chunks("generate_image", {"prompt": "白底棚拍", "count": 1})], + submit_confirmed_image( + conversation=self.conversation, user=self.user, confirm_message=card, ) 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): - with patch("apps.ai.services.enqueue_standalone_images") as enqueue: - enqueue.return_value = [self._fake_task("k-twice")] - self._run([ - _tool_chunks("generate_image", {"prompt": "第一版"}), - _tool_chunks("generate_image", {"prompt": "第二版"}), - ]) - # 一条用户消息只计费一次:一句「多做几版」不能烧掉一堆积分 - self.assertEqual(enqueue.call_count, 1) + events, fake = self._run([ + _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(len(confirms), 1) 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: 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.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, 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]): - 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]) - generating = next(e for e in events if e.get("type") == "message" - and e["message"]["kind"] == "generating") - self.assertEqual(generating["message"]["task"], str(task.id)) + self.assertEqual(error, "") + payload = { + "id": str(message.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): @@ -464,7 +495,6 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): "usp": "核心效果:一整天不泛油光", "points": ["质地轻薄"], "timeline": [{"start": 0, "end": 2.7, "stage": "Hook"}], - "matrix": {"shots": 4, "rows": [{"point": "USP", "hits": [1, 3]}]}, "voice_chars": [51, 60], "video_prompt": "0-3秒 近景手持商品…", } @@ -616,6 +646,57 @@ class ConfirmEndpointTests(TestCase): # 出片没提交成功,闸门要放回去让用户改完再确认 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): """长会话记忆压缩(契约 §5)。""" @@ -731,3 +812,60 @@ class PresetGuidanceTests(CreationAgentBaseTests): self.assertEqual(len(VIDEO_PRESETS), 8) self.assertEqual(len(IMAGE_PRESETS), 6) 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) diff --git a/core/backend/apps/ai/test_creation_mentions.py b/core/backend/apps/ai/test_creation_mentions.py index 0424d6a..52181e5 100644 --- a/core/backend/apps/ai/test_creation_mentions.py +++ b/core/backend/apps/ai/test_creation_mentions.py @@ -112,7 +112,7 @@ class ResolveRefsTests(TestCase): self.assertEqual(entry["review_status"], "active") 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") triview = _image_asset(self.team, self.user, "三视图", Asset.Category.TRI_VIEW, url="https://cdn/t.jpg") model = Model.objects.create( @@ -121,8 +121,7 @@ class ResolveRefsTests(TestCase): ) resolved = resolve_refs(self.team, [{"type": "model", "id": str(model.id)}]) - # 三视图信息量最大,锁脸优先用它 - self.assertEqual(resolved.references[0]["url"], "https://cdn/t.jpg") + self.assertEqual([r["url"] for r in resolved.references], ["https://cdn/p.jpg", "https://cdn/t.jpg"]) 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") @@ -153,6 +152,51 @@ class ResolveRefsTests(TestCase): # 编号错位会让 @图N 指错,必须去重 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): bare = Product.objects.create(team=self.team, created_by=self.user, title="裸商品") self.assertIn("裸商品", product_facts_text(bare)) diff --git a/core/backend/apps/ai/test_entity_extraction_routing.py b/core/backend/apps/ai/test_entity_extraction_routing.py index 30338b9..d91c56c 100644 --- a/core/backend/apps/ai/test_entity_extraction_routing.py +++ b/core/backend/apps/ai/test_entity_extraction_routing.py @@ -242,7 +242,7 @@ class EntityExtractionRoutingTests(TestCase): older = self.model(self.provider("volcengine-old", 20), "doubao-seed-2-0-pro-260215") default = self.model( self.provider("volcengine-default", 10), - "deepseek-v4-pro", + "doubao-seed-2-1-pro-260628", is_default=True, ) task = self.submit() @@ -254,5 +254,5 @@ class EntityExtractionRoutingTests(TestCase): self.assertEqual(task.status, AITask.Status.SUCCEEDED) self.assertEqual(attempt.model_config_id, default.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") diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 687abbd..70b076d 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -21,7 +21,7 @@ from apps.products.models import Product from .generation_errors import classify_generation_error, public_error_for_task 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 .models import AITask, CreationConversation, CreationMessage, ImageConversation, ModelConfig from .serializers import ( @@ -693,12 +693,14 @@ def _free_video_list_queryset(team, *, include_replace=False): ) if include_replace: 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): return ( 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") .prefetch_related("generated_assets", "generated_assets__files") ) @@ -1244,9 +1246,15 @@ class FreeVideoUploadView(APIView): class ModelConfigViewSet(ReadOnlyModelViewSet): - # 按创建序固定排序:最早创建的 active 模型排第一 = 前端选择器默认项,与 get_default_model 口径一致 - # (否则 DB 默认序不稳定,可能默认选到 Gemini 等;用户要默认 = 豆包 2.0 Pro,它最早创建) - queryset = ModelConfig.objects.select_related("provider").filter(status=ModelConfig.Status.ACTIVE).order_by("created_at") + # 按创建序固定排序:最早创建的 active 模型排第一 = 前端选择器默认项,与 get_default_model 口径一致。 + # DeepSeek 已停用,下拉里不再出现。 + 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 search_fields = ["name", "display_name", "capability"] ordering_fields = ["created_at", "display_name"] @@ -1360,9 +1368,28 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): if (card.payload or {}).get("submitted"): # 确认闸门是一次性的:连点两下会出两条片、扣两次积分 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"]) - 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 ) if error: diff --git a/core/backend/apps/assets/views.py b/core/backend/apps/assets/views.py index a598d6f..69fc4b3 100644 --- a/core/backend/apps/assets/views.py +++ b/core/backend/apps/assets/views.py @@ -57,13 +57,13 @@ def _tab_q(tab: str) -> Q: if tab == "image_creations": # 图片自由创作 return Q(category="free_create", asset_type="image") 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": # 自由创作(兼容旧入口:图片+视频) - return Q(category="free_create") + return Q(category="free_create") & ~Q(metadata__feature="omni_create") if tab == "uploads": return Q(category="upload") 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": # 其他(资产库成品化):我的上传 + 未归类非视频(兜底) return Q(category="upload") | (~Q(category__in=_KNOWN_CATS) & ~Q(asset_type="video")) if tab == "unclassified": # 未归类且非视频(也不含最终成片) diff --git a/core/backend/apps/projects/services/quick_create.py b/core/backend/apps/projects/services/quick_create.py index 581afd0..6b7a191 100644 --- a/core/backend/apps/projects/services/quick_create.py +++ b/core/backend/apps/projects/services/quick_create.py @@ -104,10 +104,7 @@ def _script_generation_inflight(project: Project) -> bool: def get_quick_script_model() -> ModelConfig | None: - """极速成片固定复用专业创作的豆包 Seed 2.1 Pro 脚本模型。 - - 不回退到默认文本模型,避免默认配置切到 DeepSeek 后两条创作链路的成片质量不一致。 - """ + """极速成片固定复用专业创作的豆包 Seed 2.1 Pro 脚本模型。""" return ( ModelConfig.objects.select_related("provider") .filter( diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index 3a8ed54..f514ca9 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -31,6 +31,7 @@ from apps.ai.services import ( generate_base_asset, generate_person_triview, get_default_model, + resolve_text_model, get_inflight_extraction, poll_video_segment, regenerate_script_segment, @@ -526,7 +527,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): if total_duration not in {15, 30, 45, 60}: 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 text_model = get_quick_script_model() @@ -890,16 +891,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): except (TypeError, ValueError): target_index = None - model_config = None - requested = request.data.get("model_config_id") - if requested: - model_config = ( - ModelConfig.objects.select_related("provider") - .filter(id=requested, capability=ModelConfig.Capability.TEXT, status=ModelConfig.Status.ACTIVE) - .first() - ) - if model_config is None: - model_config = get_default_model(ModelConfig.Capability.TEXT) + model_config = resolve_text_model(requested_id=request.data.get("model_config_id")) if model_config is None: # 纯 Django 响应:绕开 DRF 渲染(此 action 只挂了 SSE renderer) return JsonResponse({"detail": "没有可用的文本模型,请先在模型库配置"}, status=400) diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index ec9ad71..746899c 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -567,7 +567,7 @@ export function App() { } setRoute({ 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 = { airshelf: 1, @@ -1088,6 +1088,7 @@ export function App() { conversationId={route.conversationId} firstMessage={route.firstMessage} firstRefs={route.firstRefs} + firstUploads={route.firstUploads} navigate={navigate} onNotify={(type, text) => setNotice({ type, text })} /> diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 102538f..b5a2c3b 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -593,10 +593,13 @@ export const api = { * video_prompt 直接提交,同步返回「生成中」消息,之后靠轮询转成结果。 */ confirmCreationPlan(id: string, replyTo: string, params?: Record) { - return request<{ message: CreationMessage }>(`/api/ai/creations/${id}/send/`, { - method: "POST", - body: JSON.stringify({ kind: "confirm", reply_to: replyTo, params }) - }); + return request<{ message?: CreationMessage; regenerate?: boolean; params?: Record }>( + `/api/ai/creations/${id}/send/`, + { + method: "POST", + body: JSON.stringify({ kind: "confirm", reply_to: replyTo, params }) + } + ); }, /** * 发一条消息 → SSE 流。事件:tool / reasoning / delta / message / task / credits / done / error。 diff --git a/core/frontend/src/components/omni-param-bar.tsx b/core/frontend/src/components/omni-param-bar.tsx new file mode 100644 index 0000000..f8e68cc --- /dev/null +++ b/core/frontend/src/components/omni-param-bar.tsx @@ -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(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 ( + <> + + + +
+ + +
+ + ); +} diff --git a/core/frontend/src/omni-create-page.css b/core/frontend/src/omni-create-page.css index b03533f..3897e4a 100644 --- a/core/frontend/src/omni-create-page.css +++ b/core/frontend/src/omni-create-page.css @@ -5,6 +5,7 @@ --black: #101012; --text: #17181a; --muted: #6f747c; + font-size: 14px; } @keyframes omniMessageIn { @@ -33,24 +34,31 @@ .omni-home-history { position: absolute; - top: 24px; + top: 20px; right: 0; - height: 36px; + height: 40px; display: inline-flex; align-items: center; - gap: 7px; - padding: 0 12px; - border: 1px solid rgba(34, 42, 54, .10); - border-radius: 10px; - color: #414750; + gap: 8px; + padding: 0 14px; + border: 1.5px solid rgba(16, 16, 18, .22); + border-radius: 12px; + color: var(--black); background: #fff; - font-size: 11px; + font-size: 13px; + font-weight: 650; + box-shadow: 0 4px 12px rgba(16, 16, 18, .08); cursor: pointer; } +.omni-home-history:hover { + border-color: var(--black); + background: #f7f7f8; +} + .omni-home-history svg { - width: 15px; - height: 15px; + width: 16px; + height: 16px; } .omni-home-kicker { @@ -59,7 +67,7 @@ gap: 7px; margin-bottom: 12px; color: var(--klein); - font-size: 10px; + font-size: 12px; font-weight: 750; letter-spacing: .13em; } @@ -81,7 +89,7 @@ .omni-home-hero > p { margin: 10px 0 17px; color: #747b86; - font-size: 13px; + font-size: 14px; } .omni-output-switch { @@ -100,7 +108,7 @@ border-radius: 999px; color: #6d737c; background: transparent; - font-size: 11px; + font-size: 12px; font-weight: 650; cursor: pointer; } @@ -152,7 +160,7 @@ align-items: center; gap: 7px; color: var(--klein); - font-size: 11px; + font-size: 12px; } .omni-selected-case svg, @@ -199,7 +207,7 @@ border-radius: 8px; color: #344159; background: rgba(0, 47, 167, .045); - font-size: 11px; + font-size: 12px; font-weight: 600; } @@ -255,7 +263,7 @@ outline: 0; color: var(--text); background: transparent; - font-size: 14px; + font-size: 15px; line-height: 1.7; } @@ -290,7 +298,7 @@ border-radius: 9px; color: #353b45; background: #f8f9fa; - font-size: 14px; + font-size: 15px; font-weight: 700; cursor: pointer; transition: color 150ms ease, border-color 150ms ease, background-color 150ms ease; @@ -336,7 +344,7 @@ display: block; padding: 5px 8px 7px; color: #8b919a; - font-size: 9px; + font-size: 11px; } .omni-upload-menu button { @@ -373,14 +381,14 @@ } .omni-upload-menu button span { - font-size: 11px; + font-size: 12px; font-weight: 650; } .omni-upload-menu button small { margin-top: 3px; color: #9298a1; - font-size: 9px; + font-size: 11px; font-weight: 400; } @@ -405,7 +413,7 @@ display: block; padding: 5px 8px 7px; color: #8b919a; - font-size: 9px; + font-size: 11px; font-weight: 650; } @@ -419,7 +427,7 @@ display: block; padding: 5px 8px 7px; color: #8b919a; - font-size: 9px; + font-size: 11px; font-weight: 650; } @@ -466,14 +474,14 @@ } .omni-mention-menu button span { - font-size: 11px; + font-size: 12px; font-weight: 650; } .omni-mention-menu button small { margin-top: 2px; color: #8b919a; - font-size: 9px; + font-size: 11px; font-weight: 400; } @@ -521,7 +529,7 @@ border-color: rgba(34, 42, 54, .11); border-radius: 10px; background: #f8f9fa; - font-size: 11px; + font-size: 12px; font-weight: 650; } @@ -547,7 +555,7 @@ .omni-parameter .custom-select-option { min-height: 34px; - font-size: 11px; + font-size: 12px; } .omni-duration-control { @@ -572,11 +580,16 @@ border-radius: 10px; color: #373d46; background: #f8f9fa; - font-size: 11px; + font-size: 12px; font-weight: 650; cursor: pointer; } +.omni-duration-trigger:disabled { + opacity: .55; + cursor: not-allowed; +} + .omni-duration-trigger:hover, .omni-duration-trigger[aria-expanded="true"] { border-color: rgba(0, 47, 167, .36); @@ -616,7 +629,7 @@ display: block; margin-bottom: 10px; color: #7d838d; - font-size: 11px; + font-size: 12px; } .omni-duration-modes { @@ -635,7 +648,7 @@ border-radius: 10px; color: #626974; background: #f7f8fa; - font-size: 11px; + font-size: 12px; cursor: pointer; } @@ -674,7 +687,7 @@ border-radius: 8px; color: #575e69; background: #fff; - font-size: 11px; + font-size: 12px; text-align: left; cursor: pointer; } @@ -708,7 +721,7 @@ border-radius: 10px; color: #fff; background: var(--klein); - font-size: 11px; + font-size: 12px; font-weight: 650; cursor: pointer; box-shadow: 0 9px 18px rgba(0, 47, 167, .17); @@ -753,7 +766,7 @@ border-radius: 8px; color: #707681; background: transparent; - font-size: 12px; + font-size: 13px; font-weight: 600; cursor: pointer; } @@ -861,7 +874,7 @@ color: #fff; background: #111318; box-shadow: 0 8px 18px rgba(0, 0, 0, .18); - font-size: 10px; + font-size: 12px; font-weight: 700; cursor: pointer; opacity: 0; @@ -1001,7 +1014,7 @@ border-radius: 999px; color: var(--klein); background: #edf5ff; - font-size: 10px; + font-size: 12px; font-weight: 700; } @@ -1013,7 +1026,7 @@ .omni-preset-detail > p { margin: 0; color: #737a84; - font-size: 13px; + font-size: 14px; line-height: 1.85; } @@ -1026,13 +1039,13 @@ .omni-preset-default span { color: #8a9099; - font-size: 10px; + font-size: 12px; } .omni-preset-default p { margin: 7px 0 0; color: #3e4653; - font-size: 12px; + font-size: 13px; line-height: 1.7; } @@ -1050,7 +1063,7 @@ border-radius: 10px; color: #616873; background: #fff; - font-size: 12px; + font-size: 13px; cursor: pointer; } @@ -1073,14 +1086,14 @@ .omni-case-copy strong { margin-bottom: 6px; - font-size: 13px; + font-size: 14px; } .omni-case-copy small { display: -webkit-box; overflow: hidden; color: #858b94; - font-size: 10px; + font-size: 12px; line-height: 1.5; -webkit-box-orient: vertical; -webkit-line-clamp: 2; @@ -1107,7 +1120,7 @@ .omni-history-heading > span { color: var(--klein); - font-size: 9px; + font-size: 11px; font-weight: 750; letter-spacing: .13em; } @@ -1144,7 +1157,7 @@ .omni-history-header p { margin-top: 7px; color: #858b94; - font-size: 11px; + font-size: 12px; } .omni-history-tools { @@ -1174,7 +1187,7 @@ border-radius: 12px; color: #fff; background: var(--klein); - font-size: 12px; + font-size: 13px; font-weight: 650; cursor: pointer; } @@ -1191,7 +1204,7 @@ border-radius: 9px; color: #707681; background: transparent; - font-size: 12px; + font-size: 13px; font-weight: 650; cursor: pointer; } @@ -1259,7 +1272,7 @@ .omni-history-item p, .omni-history-item small { color: #858b94; - font-size: 12.5px; + font-size: 14px; line-height: 1.45; } @@ -1359,7 +1372,7 @@ .omni-delete-dialog p { margin: 8px 0 20px; color: #7d838d; - font-size: 11px; + font-size: 12px; } .omni-delete-dialog > div { @@ -1374,7 +1387,7 @@ border-radius: 9px; color: #5e6570; background: #fff; - font-size: 10px; + font-size: 12px; font-weight: 650; cursor: pointer; } @@ -1390,7 +1403,7 @@ align-items: center; gap: 5px; color: #a86a00; - font-size: 11.5px; + font-size: 13px; font-weight: 650; } @@ -1422,7 +1435,7 @@ border-color: rgba(34, 42, 54, .11); border-radius: 10px; background: #f8f9fa; - font-size: 11px; + font-size: 12px; font-weight: 650; } @@ -1454,7 +1467,7 @@ .omni-history-empty p { margin: 0; - font-size: 13px; + font-size: 14px; } .omni-history-empty button { @@ -1466,7 +1479,7 @@ border-radius: 10px; color: #fff; background: var(--klein); - font-size: 12px; + font-size: 13px; font-weight: 650; } diff --git a/core/frontend/src/omni-session-page.css b/core/frontend/src/omni-session-page.css index 89726c3..7d45da8 100644 --- a/core/frontend/src/omni-session-page.css +++ b/core/frontend/src/omni-session-page.css @@ -7,6 +7,7 @@ .omni-session-page { min-height: calc(100vh - var(--topbar-height)); background: transparent; + font-size: 14px; } /* 进会话后顶栏换成对话信息,滚下去也还在 */ @@ -73,7 +74,7 @@ border-radius: 10px; color: #414750; background: #fff; - font-size: 11px; + font-size: 12px; cursor: pointer; } @@ -92,7 +93,7 @@ .omni-session-meta span { color: #7d838c; - font-size: 9px; + font-size: 11px; } .omni-session-meta span:not(:last-child)::after { @@ -149,7 +150,7 @@ border-radius: 14px; color: #323945; background: #f7f8fa; - font-size: 12px; + font-size: 13px; line-height: 1.7; } @@ -218,7 +219,7 @@ .omni-strategy-head strong, .omni-video-plan-head strong { - font-size: 13px; + font-size: 14px; } .omni-strategy-head span, @@ -227,7 +228,7 @@ border-radius: 999px; color: var(--klein); background: #edf5ff; - font-size: 9px; + font-size: 11px; font-weight: 700; } @@ -252,13 +253,13 @@ .omni-strategy-grid span { color: #858b94; - font-size: 9px; + font-size: 11px; } .omni-strategy-grid strong { margin-top: 6px; color: #272d37; - font-size: 11px; + font-size: 12px; line-height: 1.55; } @@ -272,7 +273,7 @@ border-radius: 10px; color: #39404b; background: #f5f9ff; - font-size: 10px; + font-size: 12px; } .omni-strategy-direction b { @@ -301,7 +302,7 @@ .omni-plan-section > strong { display: block; margin-bottom: 10px; - font-size: 11px; + font-size: 12px; } .omni-plan-points { @@ -315,7 +316,7 @@ border-radius: 9px; color: #525965; background: #f6f8fa; - font-size: 10px; + font-size: 12px; line-height: 1.5; } @@ -323,7 +324,7 @@ display: block; margin-bottom: 4px; color: #252b35; - font-size: 9px; + font-size: 11px; } .omni-plan-points i, @@ -345,7 +346,7 @@ border-radius: 10px; color: #5b626d; background: #fff; - font-size: 9px; + font-size: 11px; line-height: 1.5; } @@ -353,7 +354,7 @@ display: block; margin-bottom: 5px; color: var(--klein); - font-size: 10px; + font-size: 12px; } .omni-plan-matrix { @@ -369,7 +370,7 @@ border-radius: 7px; color: #6d747e; background: #f6f8fa; - font-size: 9px; + font-size: 11px; font-weight: 600; text-align: center; } @@ -395,7 +396,7 @@ padding: 13px 17px; color: #444b56; background: #f6f8fb; - font-size: 10px; + font-size: 12px; line-height: 1.5; } @@ -433,13 +434,13 @@ } .omni-prompt-file-copy strong { - font-size: 12px; + font-size: 13px; } .omni-prompt-file-copy small { margin-top: 4px; color: #858b94; - font-size: 9px; + font-size: 11px; } .omni-prompt-file-card button { @@ -449,7 +450,7 @@ border-radius: 9px; color: #333944; background: #fff; - font-size: 10px; + font-size: 12px; font-weight: 650; cursor: pointer; } @@ -472,7 +473,7 @@ border-radius: 9px; color: var(--klein); background: #fff; - font-size: 11px; + font-size: 12px; font-weight: 650; cursor: pointer; } @@ -505,7 +506,7 @@ outline: 0; color: var(--text); background: transparent; - font-size: 12px; + font-size: 13px; line-height: 1.6; } @@ -557,7 +558,7 @@ border-radius: 10px; color: #373d46; background: #f8f9fa; - font-size: 10.5px; + font-size: 12px; font-weight: 650; cursor: pointer; } @@ -619,12 +620,12 @@ outline: 0; color: #272d36; background: transparent; - font-size: 12px; + font-size: 13px; } .omni-session-preset-search span { color: #7d838c; - font-size: 11px; + font-size: 12px; } .omni-session-preset-body { @@ -659,7 +660,7 @@ .omni-session-preset-categories button { min-height: 36px; padding: 0 10px; - font-size: 11px; + font-size: 12px; } .omni-session-preset-categories button.active { @@ -691,13 +692,13 @@ } .omni-session-preset-list strong { - font-size: 12px; + font-size: 13px; } .omni-session-preset-list small { overflow: hidden; color: #949aa3; - font-size: 9px; + font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } @@ -729,7 +730,7 @@ overflow: hidden; margin: 0; color: #737a85; - font-size: 10px; + font-size: 12px; line-height: 1.55; -webkit-box-orient: vertical; -webkit-line-clamp: 3; @@ -738,7 +739,7 @@ .omni-session-preset-preview small { margin-top: 8px; color: #8d939c; - font-size: 9px; + font-size: 11px; } .omni-session-preset-preview button { @@ -747,7 +748,7 @@ border-radius: 9px; color: #fff; background: var(--klein); - font-size: 11px; + font-size: 12px; font-weight: 650; cursor: pointer; } @@ -755,7 +756,7 @@ .omni-session-parameter .custom-select-trigger { height: 34px; padding: 0 9px; - font-size: 10.5px; + font-size: 12px; } .omni-session-parameter .custom-select-menu { @@ -816,7 +817,7 @@ border-radius: 8px; background: transparent; color: #5b616a; - font-size: 12px; + font-size: 13px; text-align: left; cursor: pointer; } @@ -852,7 +853,7 @@ justify-content: center; height: 100%; color: #8b919a; - font-size: 12px; + font-size: 13px; font-weight: 500; text-align: center; } @@ -864,7 +865,7 @@ gap: 8px; height: 100%; color: #8b919a; - font-size: 12px; + font-size: 13px; } .omni-at-loading .omni-send-spinner { @@ -882,7 +883,7 @@ border-radius: 8px; color: #303641; background: transparent; - font-size: 12px; + font-size: 13px; text-align: left; cursor: pointer; } @@ -908,7 +909,7 @@ .omni-at-list button small { color: #8b919a; - font-size: 10px; + font-size: 12px; } .omni-session-send { @@ -945,7 +946,7 @@ .omni-process-frame strong { color: #5c6570; - font-size: 13px; + font-size: 14px; font-weight: 500; } @@ -1073,13 +1074,13 @@ } .omni-result-info strong { - font-size: 12px; + font-size: 13px; } .omni-result-info small { margin-top: 3px; color: #858b94; - font-size: 9px; + font-size: 11px; } .omni-result-info button { @@ -1091,7 +1092,7 @@ border-radius: 9px; color: #fff; background: var(--black); - font-size: 10px; + font-size: 12px; cursor: pointer; } @@ -1166,7 +1167,7 @@ display: block; margin-bottom: 9px; color: var(--black); - font-size: 12px; + font-size: 13px; font-weight: 600; } @@ -1182,7 +1183,7 @@ border-radius: 8px; background: #fff; color: #575d66; - font-size: 12px; + font-size: 13px; cursor: pointer; transition: border-color .15s ease, color .15s ease, background .15s ease; } @@ -1205,7 +1206,7 @@ border-radius: 8px; background: #fff; color: var(--black); - font-size: 12px; + font-size: 13px; font-family: inherit; } @@ -1248,7 +1249,7 @@ .omni-elicit-assets span { overflow: hidden; color: #575d66; - font-size: 10px; + font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } @@ -1266,7 +1267,7 @@ border-radius: 8px; background: var(--klein); color: #fff; - font-size: 12px; + font-size: 13px; cursor: pointer; } @@ -1287,19 +1288,61 @@ .omni-confirm-card { width: min(760px, 100%); display: flex; - align-items: center; - justify-content: space-between; - gap: 14px; + flex-direction: column; + align-items: stretch; + gap: 12px; margin: 0 0 24px 44px; - padding: 13px 16px; + padding: 14px 16px; border: 1px solid rgba(34, 42, 54, .10); border-radius: 14px; 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; - 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 { @@ -1311,7 +1354,7 @@ border-radius: 8px; background: var(--klein); color: #fff; - font-size: 12px; + font-size: 13px; cursor: pointer; } @@ -1322,7 +1365,7 @@ .omni-confirm-card button i { color: rgba(255, 255, 255, .72); - font-size: 11px; + font-size: 12px; font-style: normal; } @@ -1352,7 +1395,7 @@ max-width: 220px; padding: 3px 8px 3px 4px; border-radius: 999px; - font-size: 10px; + font-size: 12px; line-height: 1.2; } @@ -1361,7 +1404,7 @@ padding: 2px 6px; border-radius: 999px; font-style: normal; - font-size: 9px; + font-size: 11px; font-weight: 700; } @@ -1424,7 +1467,7 @@ align-items: center; gap: 6px; color: rgba(255, 255, 255, .72); - font-size: 10px; + font-size: 12px; } .omni-send-spinner, @@ -1564,7 +1607,7 @@ .omni-prompt-drawer header strong { overflow: hidden; - font-size: 14px; + font-size: 15px; text-overflow: ellipsis; white-space: nowrap; } @@ -1572,7 +1615,7 @@ .omni-prompt-drawer header small { margin-top: 2px; color: #8b919a; - font-size: 11px; + font-size: 12px; } .omni-prompt-drawer header button { @@ -1616,14 +1659,14 @@ .omni-prompt-block h3 { margin: 0 0 8px; color: var(--klein); - font-size: 12px; + font-size: 13px; font-weight: 650; } .omni-prompt-block p { margin: 0; color: #2b3240; - font-size: 13px; + font-size: 14px; line-height: 1.7; white-space: pre-wrap; word-break: break-word; diff --git a/core/frontend/src/routes/omni-create.tsx b/core/frontend/src/routes/omni-create.tsx index 99440c2..49751eb 100644 --- a/core/frontend/src/routes/omni-create.tsx +++ b/core/frontend/src/routes/omni-create.tsx @@ -2,14 +2,12 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { ArrowLeft, Box, - ChevronDown, ChevronRight, FolderOpen, History, Image as ImageIcon, Play, Plus, - SlidersHorizontal, Sparkles, Trash2, Upload, @@ -20,7 +18,7 @@ import { X, } from "lucide-react"; import { api } from "../api"; -import { CustomSelect } from "../components/custom-select"; +import { OmniParamBar } from "../components/omni-param-bar"; import { ConfirmModal } from "../components/overlays"; import type { CreationConversation, CreationRef } from "../types"; import type { NavigateFn } from "./route-config"; @@ -37,8 +35,6 @@ type PresetItem = { cover: string; }; -type Attachment = { name: string; type: string; url?: string; source: "local" | "library" }; - const VIDEO_PRESETS: PresetItem[] = [ { 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" }, @@ -75,12 +71,6 @@ const IMAGE_FILTERS = [ { 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_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 }, ]; +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) { const then = new Date(iso).getTime(); if (Number.isNaN(then)) return ""; @@ -103,10 +101,6 @@ function formatRelativeTime(iso: string) { return days < 30 ? `${days} 天前` : new Date(then).toLocaleDateString("zh-CN"); } -function toOptions(values: string[]) { - return values.map((value) => ({ value, label: value })); -} - export function OmniCreatePage({ navigate, onNotify, @@ -117,13 +111,12 @@ export function OmniCreatePage({ const [outputMode, setOutputMode] = useState("video"); const [selectedCase, setSelectedCase] = useState(null); const [prompt, setPrompt] = useState(""); - const [attachments, setAttachments] = useState([]); + const [sessionUploads, setSessionUploads] = useState([]); + const [uploading, setUploading] = useState(false); const [model, setModel] = useState("Seedance 2.5"); const [resolution, setResolution] = useState("1080p"); const [ratio, setRatio] = useState("9:16"); const [duration, setDuration] = useState("智能时长"); - const [customDurationOn, setCustomDurationOn] = useState(false); - const [durationMenuOpen, setDurationMenuOpen] = useState(false); const [uploadMenuOpen, setUploadMenuOpen] = useState(false); const [mentionMenuOpen, setMentionMenuOpen] = useState(false); const [mentionTab, setMentionTab] = useState("asset"); @@ -143,16 +136,13 @@ export function OmniCreatePage({ setResolution("模型默认"); setRatio("1:1"); setDuration("1 张"); - setCustomDurationOn(true); } else { setModel("Seedance 2.5"); setResolution("1080p"); setRatio("9:16"); setDuration("智能时长"); - setCustomDurationOn(false); } setActiveCategory("all"); - setDurationMenuOpen(false); setSelectedCase((prev) => (prev && prev.mode !== outputMode ? null : prev)); }, [outputMode]); @@ -162,8 +152,7 @@ export function OmniCreatePage({ if (toolsRef.current?.contains(target)) return; setUploadMenuOpen(false); setMentionMenuOpen(false); - setDurationMenuOpen(false); - }; + }; document.addEventListener("mousedown", onDown); return () => document.removeEventListener("mousedown", onDown); }, []); @@ -186,12 +175,11 @@ export function OmniCreatePage({ setMentionTab(type); setMentionMenuOpen(true); setUploadMenuOpen(false); - setDurationMenuOpen(false); setMentionLoading(true); setMentionResults([]); try { 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); } catch (error) { onNotify?.("error", (error as Error).message); @@ -215,20 +203,39 @@ export function OmniCreatePage({ setMentionMenuOpen(false); }; - const handleFileChange = (event: React.ChangeEvent) => { + const handleFileChange = async (event: React.ChangeEvent) => { 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 = ""; + 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 ( @@ -273,24 +280,11 @@ export function OmniCreatePage({ - ))}{attachments.map((file, index) => ( - - {file.type.startsWith("video") ? ))}