From 15718a60bf3de577e2b8e45ca10c04bdee557e99 Mon Sep 17 00:00:00 2001 From: "Azmat@qq.com" Date: Fri, 4 Sep 2026 17:20:22 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=A7=E9=87=8F=E4=BC=98=E5=8C=96=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=E6=89=A3=E7=A7=AF=E5=88=86=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/backend/apps/adminpanel/serializers.py | 30 ++- core/backend/apps/adminpanel/views.py | 20 +- core/backend/apps/ai/creation_agent.py | 147 +++++++++++- core/backend/apps/ai/entity_local.py | 158 ++++++++++++ core/backend/apps/ai/free_video.py | 30 +-- core/backend/apps/ai/model_catalog.py | 14 ++ core/backend/apps/ai/script_agent.py | 12 +- core/backend/apps/ai/services.py | 123 ++++------ core/backend/apps/ai/test_creation_agent.py | 1 + core/backend/apps/ai/video_replace.py | 29 ++- .../backend/apps/ai/video_timeout_recovery.py | 16 +- core/backend/apps/ai/views.py | 25 ++ core/backend/apps/billing/points_rules.py | 207 ++++++++++++++++ core/backend/apps/billing/pricing.py | 164 +++++++++++-- core/backend/apps/billing/tests.py | 93 +++++++ .../apps/projects/services/pipeline.py | 9 + .../apps/projects/test_quick_create.py | 2 + core/backend/apps/projects/tests.py | 134 ++++------- core/backend/apps/projects/views.py | 27 +-- core/frontend/src/App.tsx | 42 +++- core/frontend/src/ai-tools-page.css | 64 +++-- core/frontend/src/api.ts | 7 +- core/frontend/src/components/app-shell.tsx | 12 +- .../src/components/free-create/constants.ts | 64 +++-- .../src/components/free-create/input-bar.tsx | 4 +- .../src/components/free-create/toolbar.tsx | 24 +- .../src/components/omni-param-bar.tsx | 94 +++++++- core/frontend/src/design-restraint.css | 3 +- core/frontend/src/free-create-page.css | 20 +- core/frontend/src/omni-session-page.css | 7 +- core/frontend/src/pipeline-page.css | 3 +- .../src/routes/admin/admin-models.tsx | 226 ++++++++++++++++-- .../frontend/src/routes/admin/admin-tasks.tsx | 19 +- core/frontend/src/routes/ai-tools.tsx | 9 +- core/frontend/src/routes/free-create.tsx | 5 - core/frontend/src/routes/omni-create.tsx | 34 +-- core/frontend/src/routes/omni-session.tsx | 33 ++- core/frontend/src/routes/pipeline.tsx | 68 ++++-- core/frontend/src/routes/quick-create.tsx | 22 +- core/frontend/src/routes/route-config.ts | 12 +- core/frontend/src/routes/video-replace.tsx | 6 +- 41 files changed, 1590 insertions(+), 429 deletions(-) create mode 100644 core/backend/apps/ai/entity_local.py create mode 100644 core/backend/apps/ai/model_catalog.py create mode 100644 core/backend/apps/billing/points_rules.py diff --git a/core/backend/apps/adminpanel/serializers.py b/core/backend/apps/adminpanel/serializers.py index 881b4eb..61292b1 100644 --- a/core/backend/apps/adminpanel/serializers.py +++ b/core/backend/apps/adminpanel/serializers.py @@ -4,6 +4,7 @@ from rest_framework import serializers from apps.accounts.models import Team, TeamMember, User from apps.ai.model_routing import model_metadata_errors, provider_metadata_errors +from apps.billing.points_rules import normalize_points_pricing_for_save, points_pricing_errors from apps.ai.models import AIModelAttempt, AITask, ModelConfig, ModelProvider, PromptTemplate, QualityWord from apps.assets.models import Asset from apps.billing.models import CreditLedger, QuotaPolicy @@ -247,12 +248,35 @@ class AdminModelConfigSerializer(serializers.ModelSerializer): def validate(self, attrs): attrs = super().validate(attrs) - if self.instance is None or "metadata" in attrs or "capability" in attrs: + if self.instance is None or "metadata" in attrs or "capability" in attrs or "unit_price" in attrs: capability = attrs.get("capability", getattr(self.instance, "capability", "")) metadata = attrs.get("metadata", getattr(self.instance, "metadata", {})) - errors = model_metadata_errors(capability, metadata) + if not isinstance(metadata, dict): + metadata = {} + metadata = normalize_points_pricing_for_save(capability, metadata) + # 图片/文本:表单 unit_price 与挂牌积分同步 + if "unit_price" in attrs and capability in {"image", "text", "vision"}: + from decimal import Decimal + try: + pts = int(Decimal(str(attrs.get("unit_price") or 0))) + except Exception: + pts = 0 + pricing = dict(metadata.get("points_pricing") or {}) + if capability == "image": + pricing.update({"mode": "per_image", "points_per_image": max(pts, 0)}) + else: + pricing.update({"mode": "per_call", "points_per_call": max(pts, 0)}) + metadata["points_pricing"] = pricing + attrs["metadata"] = metadata + # 视频有挂牌秒价时,把展示用 unit_price 写成最低档秒积分,列表更好读 + if capability == "video": + tiers = (metadata.get("points_pricing") or {}).get("tiers") or [] + secs = [t.get("points_per_second") for t in tiers if isinstance(t, dict) and t.get("points_per_second") is not None] + if secs: + attrs["unit_price"] = min(int(x) for x in secs) + errors = list(model_metadata_errors(capability, metadata)) + list(points_pricing_errors(capability, metadata)) if errors: - raise serializers.ValidationError({"metadata": list(errors)}) + raise serializers.ValidationError({"metadata": errors}) return attrs diff --git a/core/backend/apps/adminpanel/views.py b/core/backend/apps/adminpanel/views.py index 7888f3d..4e1b850 100644 --- a/core/backend/apps/adminpanel/views.py +++ b/core/backend/apps/adminpanel/views.py @@ -13,6 +13,7 @@ from apps.accounts.audit import log_admin_action from apps.accounts.models import Invitation, Team, User from apps.accounts.permissions import IsPlatformAdmin from apps.accounts.serializers import InvitationSerializer +from apps.ai.model_catalog import invalidate_model_catalog_cache from apps.ai.models import AITask, ModelConfig, ModelProvider, PromptTemplate, QualityWord from apps.assets.models import Asset from apps.assets.review import poll_asset_review, submit_asset_for_review @@ -45,6 +46,14 @@ logger = logging.getLogger(__name__) # 后台「刷新状态」只向供应商拉取已提交的异步视频任务;单次上限避免请求拖死。 _ADMIN_TASK_POLL_LIMIT = 20 _ADMIN_TASK_POLL_STATUSES = (AITask.Status.SUBMITTED, AITask.Status.POLLING) +# 任务监控「生成中」Tab:含已创建/已预留/已提交/轮询/后处理(与自由创作 IN_FLIGHT 对齐) +_ADMIN_TASK_INFLIGHT_STATUSES = ( + AITask.Status.CREATED, + AITask.Status.RESERVED, + AITask.Status.SUBMITTED, + AITask.Status.POLLING, + AITask.Status.POSTPROCESSING, +) def _team_qs(): @@ -463,7 +472,9 @@ def admin_tasks(request): .order_by("-created_at") ) st = request.query_params.get("status") - if st in dict(AITask.Status.choices): + if st in {"generating", "running", "in_flight"}: + qs = qs.filter(status__in=_ADMIN_TASK_INFLIGHT_STATUSES) + elif st in dict(AITask.Status.choices): qs = qs.filter(status=st) tt = request.query_params.get("task_type") if tt in dict(AITask.Type.choices): @@ -772,11 +783,12 @@ def admin_provider_detail(request, provider_id): return Response(AdminModelProviderSerializer(ModelProvider.objects.annotate(model_count_anno=Count("models", distinct=True)).get(id=obj.id)).data) + @api_view(["GET", "POST"]) @permission_classes([IsPlatformAdmin]) def admin_models(request): if request.method == "GET": - qs = ModelConfig.objects.select_related("provider").order_by("provider__name", "capability", "created_at") + qs = ModelConfig.objects.select_related("provider").order_by("-is_default", "status", "capability", "provider__name", "created_at") prov = request.query_params.get("provider") if prov: qs = qs.filter(provider_id=prov) @@ -787,6 +799,7 @@ def admin_models(request): serializer = AdminModelConfigSerializer(data=request.data) serializer.is_valid(raise_exception=True) obj = serializer.save() + invalidate_model_catalog_cache() log_admin_action(request, "model.create", target_type="model_config", target_id=obj.id, target_name=f"{obj.provider_id}:{obj.name}") return Response(AdminModelConfigSerializer(obj).data, status=status.HTTP_201_CREATED) @@ -800,10 +813,12 @@ def admin_model_detail(request, model_id): if request.method == "DELETE": log_admin_action(request, "model.delete", target_type="model_config", target_id=obj.id, target_name=obj.name) obj.delete() + invalidate_model_catalog_cache() return Response(status=status.HTTP_204_NO_CONTENT) serializer = AdminModelConfigSerializer(obj, data=request.data, partial=True) serializer.is_valid(raise_exception=True) serializer.save() + invalidate_model_catalog_cache() log_admin_action(request, "model.update", target_type="model_config", target_id=obj.id, target_name=obj.name) return Response(AdminModelConfigSerializer(ModelConfig.objects.select_related("provider").get(id=obj.id)).data) @@ -821,6 +836,7 @@ def admin_model_set_default(request, model_id): ModelConfig.objects.filter(capability=obj.capability).exclude(id=obj.id).update(is_default=False) obj.is_default = True obj.save(update_fields=["is_default", "updated_at"]) + invalidate_model_catalog_cache() log_admin_action(request, "model.set_default", target_type="model_config", target_id=obj.id, target_name=f"{obj.capability}:{obj.name}") return Response(AdminModelConfigSerializer(ModelConfig.objects.select_related("provider").get(id=obj.id)).data) diff --git a/core/backend/apps/ai/creation_agent.py b/core/backend/apps/ai/creation_agent.py index 14e5619..60799d3 100644 --- a/core/backend/apps/ai/creation_agent.py +++ b/core/backend/apps/ai/creation_agent.py @@ -66,6 +66,32 @@ IMAGE_MODEL_BY_LABEL = { # 「智能时长」= 交给我们定,取一个口播讲得完又不烧钱的中间值 SMART_DURATION = 15 +# 全能创作 video_prompt 写作规范:从专业创作 / 一键成片(口播脚本)抽硬规则, +# 适配「整段出片指令」而不是 ScriptDraft JSON。只要求口径接近,不改工具形态。 +_OMNI_VIDEO_PROMPT_RULES = """ +【出片脚本写法 · 对齐专业口播】 +表现形式默认**口播带货**,像真人在讲刚遇到的一件事,不要详情页朗读或主播念稿。 + +video_prompt 必须能被出片模型直接执行,按时间轴写秒级分镜,建议结构: +- 片头写清:总时长、画幅、整体光线/色调、口播语气(口语、有停顿与立场) +- 然后按「0-3s / 3-8s / …」逐段写,每段同时写清这五项(不能省): + 1. 景别(大特写/特写/近景/中景/全景;一条片里至少切两次景别) + 2. 机位(平视/俯拍/仰拍/过肩/桌面视角) + 3. 运镜(手持跟拍/推近/拉远/横摇/环绕/固定 —— 每段至少一个运镜词) + 4. 动作(谁、哪只手、对什么、做什么;要连贯可拍,禁止「展示质感」等抽象词) + 5. 信息变化(这几秒画面上多了/变了什么) +- 口播原文单独写清(可用「口播:…」),字数贴近会话时长:大约 5.0–5.7 字/秒, + 15 秒约 75–85 字;太短撑不满,太长会赶。 +- 钩子段画面不要「对着镜头说话」静态开场;前 15 个口播字禁止「大家好/今天分享/给你们推荐」。 +- 全片只围绕一个具体情境推进一个主卖点;卖点要有看得见的证据(质地/前后变化/用法结果)。 +- CTA 像跟朋友说话;禁止小黄车/立即购买/闭眼入等平台指令腔。 +- **不要写字幕/花字/标题贴片/弹幕/角标/水印/购物浮层**,也不要写「无字幕」 + (否定说法也容易把字画上屏)。口播只存在于声音;包装上原有印刷字除外。 +- 已 @ 的角色/商品/场景参考图会自动附上,不要在 prompt 里重描长相;以图锁定性别年龄服装外形。 +- 同一场戏保持地点、光线、服装连续;要换环境就明确写下一时间段切换。 +""" + + class AgentError(Exception): """Agent 循环里的业务错误,已经是可以直接给用户看的中文。""" @@ -353,8 +379,9 @@ def tool_schemas(context: AgentContext) -> list[dict]: "name": "write_plan", "description": ( "写「视频最终方案」卡并请用户确认。**这是出片前的最后一步**,调完会等用户点确认," - "确认后平台直接按 video_prompt 出片,你不会再有插话机会 —— 所以 video_prompt " - "必须是完整、可独立执行的成片指令。先调 write_strategy 再调它。" + "确认后平台直接按 video_prompt 出片,你不会再有插话机会。" + "video_prompt 按系统里的「出片脚本写法」写成口播秒级分镜(专业创作同口径),不要只写大纲。" + "先调 write_strategy 再调它。" ), "parameters": { "type": "object", @@ -383,9 +410,11 @@ def tool_schemas(context: AgentContext) -> list[dict]: "video_prompt": { "type": "string", "description": ( - "交给出片模型的完整指令:秒级分镜(每镜画面/动作/机位/光线)、口播原文、" - "风格锚点、一致性要求。已 @ 的素材会自动作为参考图附上," - "不要在这里重复描述它们的长相。**不要写字幕相关要求。**" + "交给出片模型的完整口播带货指令(对齐专业创作口径)。" + "必须含:总时长与画幅、整体光线色调、按 0-Ns 分段的秒级分镜" + "(每段写清景别/机位/运镜/具体动作/信息变化)、口播原文、一个主卖点与可见证据、口语 CTA。" + "禁止字幕/花字/贴片及「无字幕」字样;禁止详情页腔与「大家好」开场。" + "已 @ 素材会自动作参考图,勿重描长相。" ), }, }, @@ -464,9 +493,68 @@ def _coerce_fields(raw) -> list[dict]: return fields + + +def _normalize_model_label(value: str) -> str: + return "".join(ch for ch in str(value or "").lower() if ch.isalnum()) + + +def image_model_name(params: dict) -> str | None: + """出图模型 label → 供应商模型名;目录优先,认不出返回 None 让下游用默认。""" + from django.db.models import Q + + from .models import ModelConfig + + label = str(params.get("model") or "").strip() + if not label: + return None + mapped = IMAGE_MODEL_BY_LABEL.get(label) + if mapped: + return mapped + hit = ( + ModelConfig.objects.filter(capability=ModelConfig.Capability.IMAGE, status=ModelConfig.Status.ACTIVE) + .filter(Q(display_name=label) | Q(name=label)) + .order_by("created_at") + .first() + ) + return hit.name if hit else None + + def video_model_name(params: dict) -> str: - """会话参数里的模型 label → 火山模型名。认不出就回落 2.5(最长 30 秒那档)。""" - return VIDEO_MODEL_BY_LABEL.get(str(params.get("model") or ""), DEFAULT_VIDEO_MODEL) + """会话参数里的模型 label → 供应商模型名。 + + 先认历史写死映射,再按 ModelConfig.display_name / name 查目录 —— 后台新加模型不用改代码。 + """ + from django.db.models import Q + + from .models import ModelConfig + + label = str(params.get("model") or "").strip() + if not label: + return DEFAULT_VIDEO_MODEL + mapped = VIDEO_MODEL_BY_LABEL.get(label) + if not mapped: + norm = _normalize_model_label(label) + for k, v in VIDEO_MODEL_BY_LABEL.items(): + if _normalize_model_label(k) == norm: + mapped = v + break + if mapped: + return mapped + hit = ( + ModelConfig.objects.filter(capability=ModelConfig.Capability.VIDEO, status=ModelConfig.Status.ACTIVE) + .filter(Q(display_name=label) | Q(name=label)) + .order_by("created_at") + .first() + ) + if hit is None: + hit = ( + ModelConfig.objects.filter(capability=ModelConfig.Capability.VIDEO, status=ModelConfig.Status.ACTIVE) + .filter(Q(display_name__icontains=label) | Q(name__icontains=label)) + .order_by("created_at") + .first() + ) + return hit.name if hit else DEFAULT_VIDEO_MODEL def video_duration(params: dict) -> int: @@ -531,8 +619,9 @@ def _run_generate_image(context: AgentContext, args: dict) -> tuple[dict, list]: mode="image", count=count, ratio=params.get("ratio") or None, - image_model=IMAGE_MODEL_BY_LABEL.get(str(params.get("model") or ""), params.get("model") or None), + image_model=image_model_name(params) or params.get("model") or None, reference_image_ids=reference_image_ids or None, + feature="omni_create", ) context.generations_used += 1 return ( @@ -587,6 +676,27 @@ def estimate_video_credits(context: AgentContext) -> int: return 0 +def estimate_image_credits(context: AgentContext) -> int: + """出图确认卡预计积分:挂牌单价(含团队系数)逐张取整后再 × 张数,与 enqueue 逐任务预留同口径。""" + from apps.billing.pricing import quote_flat + from apps.ai.services import resolve_image_model, get_default_model + + params = context.conversation.params or {} + model_name = image_model_name(params) or str(params.get("model") or "").strip() or None + model_config = resolve_image_model(model_name) if model_name else None + if model_config is None: + model_config = get_default_model(ModelConfig.Capability.IMAGE) + if model_config is None: + return 0 + count = _image_count(params, None) + try: + per = quote_flat(model_config, units=1, team=context.team) + return int(per.points) * count + except Exception: # noqa: BLE001 + logger.warning("omni create: image estimate failed", exc_info=True) + return 0 + + def submit_confirmed_video(*, conversation: CreationConversation, user, confirm_message: CreationMessage): """用户点了确认 → 直接按方案卡里存好的 video_prompt 出片。 @@ -764,7 +874,18 @@ def build_system_prompt(context: AgentContext) -> str: "- 用户说改时长/模型/比例/分辨率:立刻 ask_user,type=single 给出选项;选完后旧方案作废,必须按新参数重新 write_plan。不要让用户去点底部菜单。", "- 光线、构图、镜头这些专业判断是你的活,不要反过来问用户。", ] - if not context.is_video: + if context.is_video: + lines.extend(["", _OMNI_VIDEO_PROMPT_RULES.strip()]) + # 按会话时长给出口播字数锚点(与专业创作 narration_limit 同口径) + try: + dur = video_duration(params) + except Exception: # noqa: BLE001 + dur = SMART_DURATION + lo = max(1, int(dur * 5.0)) + hi = max(lo, min(85, int(dur * 5.7))) + lines.append(f"- 当前按约 {dur} 秒出片,口播建议 {lo}–{hi} 字;write_plan 的 voice_chars 填这个区间。") + lines.append("- 写方案时必须调用 write_plan;video_prompt 按上面的秒级分镜规范写满,不要只给大纲。") + else: lines.extend([ "", "【出图】", @@ -1106,20 +1227,24 @@ def _dispatch_tool(context: AgentContext, name: str, args: dict) -> tuple[dict, if not prompt: return {"payload": {"error": "生成失败:模型没有给出画面描述"}}, False # 出图也走确认卡:用户先看当前模型/比例/张数,点了才提交。 + credits = estimate_image_credits(context) confirm = append_message( context.conversation, role="assistant", kind=CreationMessage.Kind.CONFIRM, payload={ "kind": "image", "label": "开始生成", - "estimated_credits": 0, + "estimated_credits": credits, "prompt": prompt, "submitted": False, "params": snapshot_session_params(context.conversation), "param_options": confirm_param_options(False), }, ) - events = [{"type": "message", "message": _message_payload(confirm)}] + events = [ + {"type": "message", "message": _message_payload(confirm)}, + {"type": "credits", "estimated": credits}, + ] return {"payload": {"awaiting_confirmation": True}, "_events": events}, True return {"payload": {"error": f"未知工具 {name}"}}, False diff --git a/core/backend/apps/ai/entity_local.py b/core/backend/apps/ai/entity_local.py new file mode 100644 index 0000000..26740b7 --- /dev/null +++ b/core/backend/apps/ai/entity_local.py @@ -0,0 +1,158 @@ +"""从已落库脚本本地拆出角色/场景 —— 不调模型、不扣积分。 + +脚本生成时已要求结构化 entities;这里负责: +1. 读 ScriptVersion.metadata / content 里的 entities +2. 只保留 character / scene(商品不抽) +3. 至少保证 1 角色 + 1 场景(缺则按分镜补默认) +4. 回填 project.metadata + 每镜 entity_refs + entities_extracted +""" +from __future__ import annotations + +import json +import re +from typing import Any + + +def _as_list(value: Any) -> list: + return value if isinstance(value, list) else [] + + +def _load_draft_entities(script) -> list[dict]: + meta = script.metadata if isinstance(script.metadata, dict) else {} + ents = _as_list(meta.get("entities")) + if ents: + return [e for e in ents if isinstance(e, dict)] + raw = (script.content or "").strip() + if not raw: + return [] + try: + data = json.loads(raw) + except (TypeError, ValueError, json.JSONDecodeError): + return [] + if not isinstance(data, dict): + return [] + return [e for e in _as_list(data.get("entities")) if isinstance(e, dict)] + + +def _normalize_entities(raw: list[dict]) -> list[dict]: + out: list[dict] = [] + seen: set[str] = set() + for i, ent in enumerate(raw): + etype = str(ent.get("type") or "").strip().lower() + if etype in {"person", "cast", "role"}: + etype = "character" + if etype in {"location", "bg", "background"}: + etype = "scene" + if etype not in {"character", "scene"}: + continue + eid = str(ent.get("id") or f"{'c' if etype == 'character' else 's'}{i + 1}").strip() + while eid in seen: + eid = f"{eid}_{i}" + seen.add(eid) + name = str(ent.get("name") or eid).strip() or eid + visual = str(ent.get("visual_prompt") or ent.get("prompt") or "").strip() + if not visual: + visual = ( + f"{name},全身出镜,自然光,9:16竖屏" + if etype == "character" + else f"{name},环境空镜,干净构图,16:9横屏" + ) + out.append( + { + "id": eid, + "type": etype, + "name": name, + "visual_prompt": visual, + "ref_index": len(out) + 1, + } + ) + return out + + +def _default_character(segments) -> dict: + # 优先用第一镜画面里像「人」的描述,否则通用主角 + hint = "" + for seg in segments: + text = f"{getattr(seg, 'visual_prompt', '') or ''} {getattr(seg, 'narration', '') or ''}" + if re.search(r"女|男|人|主播|模特|客户|宝妈|姐姐|哥哥", text): + hint = (getattr(seg, "visual_prompt", None) or text).strip() + break + visual = hint[:80] if hint else "电商短视频出镜主角,全身,自然妆容,干净背景,9:16竖屏" + return { + "id": "c1", + "type": "character", + "name": "主角", + "visual_prompt": visual, + "ref_index": 1, + } + + +def _default_scene(segments) -> dict: + hint = "" + for seg in segments: + text = (getattr(seg, "visual_prompt", None) or "").strip() + if text: + hint = text + break + visual = hint[:80] if hint else "干净室内场景,柔和光线,适合电商口播,16:9横屏" + return { + "id": "s1", + "type": "scene", + "name": "主场景", + "visual_prompt": visual, + "ref_index": 1, + } + + +def _ensure_min_entities(entities: list[dict], segments) -> list[dict]: + has_char = any(e["type"] == "character" for e in entities) + has_scene = any(e["type"] == "scene" for e in entities) + if not has_char: + entities.append(_default_character(segments)) + if not has_scene: + entities.append(_default_scene(segments)) + # 重排 ref_index + for i, e in enumerate(entities): + e["ref_index"] = i + 1 + return entities + + +def _backfill_segment_refs(segments, entities: list[dict]) -> None: + valid = {e["id"] for e in entities} + char_ids = [e["id"] for e in entities if e["type"] == "character"] + scene_ids = [e["id"] for e in entities if e["type"] == "scene"] + default_refs = (char_ids[:1] + scene_ids[:1]) or list(valid)[:2] + for seg in segments: + refs = [r for r in (seg.entity_refs or []) if r in valid] + speaker = (seg.speaker or "").strip() + if speaker and speaker in valid and speaker not in refs: + refs.append(speaker) + if not refs: + refs = list(default_refs) + if refs != (seg.entity_refs or []): + seg.entity_refs = refs + seg.save(update_fields=["entity_refs", "updated_at"]) + + +def materialize_script_entities(*, project, script) -> list[dict]: + """本地拆角色/场景并落库。返回规范化后的 entities 列表。""" + from django.db import transaction + + from apps.ai.script_agent import _map_entities_to_project_metadata + + segments = list(script.segments.order_by("sort_order")) + entities = _ensure_min_entities(_normalize_entities(_load_draft_entities(script)), segments) + with transaction.atomic(): + _map_entities_to_project_metadata(project, entities) + md = dict(project.metadata or {}) + md["entities_extracted"] = True + md["entities_extract_mode"] = "local" + project.metadata = md + project.save(update_fields=["metadata", "updated_at"]) + _backfill_segment_refs(segments, entities) + # 同步写回脚本 metadata.entities,方便下次读 + sm = dict(script.metadata or {}) + sm["entities"] = entities + script.metadata = sm + script.save(update_fields=["metadata", "updated_at"]) + return entities diff --git a/core/backend/apps/ai/free_video.py b/core/backend/apps/ai/free_video.py index 94b277d..425059e 100644 --- a/core/backend/apps/ai/free_video.py +++ b/core/backend/apps/ai/free_video.py @@ -25,7 +25,7 @@ from django.utils import timezone from apps.assets.models import Asset, AssetFile, FreeAsset, FreeAssetGroup from apps.assets.storage import TosStorage -from apps.billing.pricing import quote_video_actual, quote_video_estimate, video_reserve_amount +from apps.billing.pricing import settle_video_from_payload, quote_video_estimate, video_quote_payload, video_reserve_amount from apps.billing.services.ledger import charge_reserved_credit, release_credit, reserve_credit from .models import AITask, ModelConfig @@ -530,7 +530,7 @@ def submit_free_video(*, team, user, params: dict) -> AITask: references=built["snapshots"], team=team, ) - reserve_amount = video_reserve_amount(quote.points) + reserve_amount = video_reserve_amount(quote.points, rule=quote.meta.get("rule")) request_payload = { "feature": feature, @@ -546,9 +546,8 @@ def submit_free_video(*, team, user, params: dict) -> AITask: "generate_audio": generate_audio, "search_mode": search_mode, "estimated_tokens": tokens, - # 团队价格系数快照:按实结算用它,中途改价不影响在途任务 - "price_multiplier": quote.meta.get("price_multiplier", "1"), - "points_per_yuan_snapshot": quote.meta.get("rate", ""), + # 计价快照:挂牌秒价/团队系数下单时钉死,结算只认这些 + **video_quote_payload(quote), "references": built["snapshots"], "model_routing_v1": True, } @@ -646,7 +645,7 @@ def start_pending_free_video(task: AITask) -> AITask: references=built["snapshots"], team=task.team, ) - reserve_amount = video_reserve_amount(quote.points) + reserve_amount = video_reserve_amount(quote.points, rule=quote.meta.get("rule")) with transaction.atomic(): locked = ( @@ -671,6 +670,7 @@ def start_pending_free_video(task: AITask) -> AITask: next_payload["references"] = built["snapshots"] next_payload["estimated_tokens"] = tokens next_payload["review_pending"] = False + next_payload.update(video_quote_payload(quote)) locked.request_payload = next_payload locked.estimated_cost = quote.points locked.status = AITask.Status.RESERVED @@ -991,21 +991,15 @@ def finalize_free_video(*, task: AITask) -> AITask: total_tokens = int(usage.get("total_tokens") or 0) except (TypeError, ValueError): total_tokens = 0 - with_video_ref = any((r or {}).get("type") == "video" for r in payload.get("references") or []) - resolution = payload.get("resolution") or "720p" - if total_tokens > 0: - from decimal import Decimal - - settle = quote_video_actual( - actual_model, tokens=total_tokens, with_video_ref=with_video_ref, resolution=resolution, - multiplier=Decimal(str(payload.get("price_multiplier") or "1")), - ) + settle = settle_video_from_payload(actual_model, payload=payload, tokens=total_tokens) + if settle.meta.get("rule") == "missing_usage": + actual, base_cost = locked.estimated_cost, locked.base_cost + else: actual, base_cost = settle.points, settle.base_cost_yuan - payload["actual_tokens"] = total_tokens + if total_tokens > 0: + payload["actual_tokens"] = total_tokens if settle.meta.get("rate"): payload["points_per_yuan_snapshot"] = settle.meta["rate"] - else: - actual, base_cost = locked.estimated_cost, locked.base_cost seed_out = response.get("seed") if seed_out is not None: payload["seed_used"] = seed_out diff --git a/core/backend/apps/ai/model_catalog.py b/core/backend/apps/ai/model_catalog.py new file mode 100644 index 0000000..61723e3 --- /dev/null +++ b/core/backend/apps/ai/model_catalog.py @@ -0,0 +1,14 @@ +"""公开模型目录缓存(前端下拉 /api/ai/models/)。 + +后台增删改模型或设默认时失效,避免停用模型仍出现在创作页。 +""" +from __future__ import annotations + +from django.core.cache import cache + +MODEL_CATALOG_CACHE_KEY = "ai_models_catalog_v1" +MODEL_CATALOG_CACHE_TTL = 60 # 秒;失效以写路径为准,TTL 兜底 + + +def invalidate_model_catalog_cache() -> None: + cache.delete(MODEL_CATALOG_CACHE_KEY) diff --git a/core/backend/apps/ai/script_agent.py b/core/backend/apps/ai/script_agent.py index 66e7f37..a61af16 100644 --- a/core/backend/apps/ai/script_agent.py +++ b/core/backend/apps/ai/script_agent.py @@ -1612,7 +1612,17 @@ def persist_script_draft(*, project, user, task, draft: dict, source: str): dialogue=seg.get("dialogue") or [], product_points=[], ) - _map_entities_to_project_metadata(project, draft.get("entities", [])) + ents = draft.get("entities") or [] + _map_entities_to_project_metadata(project, ents) + # 出稿已带角色+场景时,直接视为已提取(资产页免再点付费提取) + if any((e or {}).get("type") == "character" for e in ents if isinstance(e, dict)) and any( + (e or {}).get("type") == "scene" for e in ents if isinstance(e, dict) + ): + md = dict(project.metadata or {}) + md["entities_extracted"] = True + md["entities_extract_mode"] = "from_script" + project.metadata = md + project.save(update_fields=["metadata", "updated_at"]) stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT) stage.status = ProjectStage.Status.NEEDS_REVIEW stage.save(update_fields=["status", "updated_at"]) diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 43a521a..fd2a4f1 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -1146,16 +1146,13 @@ def get_inflight_extraction(project): def submit_extract_entities(*, project, user) -> AITask: - """提交独立实体提取步(**异步**)。读已定稿(或最新)脚本 → 校验 + 建 RESERVED 任务 + 预留额度(秒级), - 慢活(豆包思考模型流式抽取,可达数十秒)交给 Celery worker(run_extract_entities_task)跑。 + """从已定稿脚本**本地**拆出角色/场景(不调模型、不扣积分)。 - 这样 Web 层(gunicorn/nginx)不被数十秒的模型请求占住 → 不再 502。 - **防重复扣费**:本项目已有在途提取任务时直接复用它,不新建/不二次预扣 - (用户刷新后手痒重点、或并发点击都安全 —— 提取是实体唯一权威来源,本就只需跑一次)。 - 校验失败(无脚本/无分镜/无模型/额度不足)抛 ValueError 由端点转 400;运行期失败由 worker 记进 - task.error_message,前端轮询 extract-status 读取。返回 AITask。""" + 脚本生成时已带结构化 entities;这里只做规范化 + 最少 1 角色/1 场景兜底 + 落库。 + 仍返回一条 SUCCEEDED 的 ENTITY_EXTRACTION 任务,方便前端轮询/审计口径不变。 + """ + from apps.ai.entity_local import materialize_script_entities from apps.projects.models import ScriptVersion - from apps.ai.tasks import extract_entities_task script = ( ScriptVersion.objects.filter(project=project, is_adopted=True).order_by("-created_at").first() @@ -1163,56 +1160,34 @@ def submit_extract_entities(*, project, user) -> AITask: ) if script is None: raise ValueError("请先生成并定稿脚本,再提取角色 / 场景") - segments = list(script.segments.order_by("sort_order")) - if not segments: + if not script.segments.exists(): raise ValueError("脚本没有分镜,无法提取") - inflight = get_inflight_extraction(project) - if inflight is not None: - return inflight # 已有提取在跑:复用,绝不二次预扣 / 重复出活 + entities = materialize_script_entities(project=project, script=script) model_config = _resolve_extract_model_config() if model_config is None: + # AITask.model_config 非空;本地提取不调模型,但仍需一条配置挂审计任务 raise ValueError("没有可用的文本模型") - - product = project.product - if product is not None: - sp = "、".join([p.title for p in product.selling_points.all()[:5]]) - prod_line = f"名称:{product.title}\n品类:{product.category or '未填'}\n卖点:{sp or '未填'}" - else: - prod_line = "(无商品信息)" - seg_lines = [ - f"镜{i} role={s.role or ''} narration={(s.narration or '').strip()} visual={(s.visual_prompt or '').strip()}" - for i, s in enumerate(segments) - ] - user_msg = ( - f"商品信息:\n{prod_line}\n\n分镜脚本(共 {len(segments)} 镜,index 从 0 开始):\n" + "\n".join(seg_lines) + task = AITask.objects.create( + team=project.team, + created_by=user, + project=project, + task_type=AITask.Type.ENTITY_EXTRACTION, + status=AITask.Status.SUCCEEDED, + model_config=model_config, + idempotency_key=f"entity_extraction:local:{project.id}:{uuid.uuid4()}", + request_payload={ + "mode": "local", + "script_id": str(script.id), + "entity_count": len(entities), + }, + response_payload={"entities": entities, "mode": "local"}, + estimated_cost=Decimal("0"), + actual_cost=Decimal("0"), + base_cost=Decimal("0"), + completed_at=timezone.now(), ) - # skill 正文(领域功力)+ 写死的输出契约兜底(保证「只输出 JSON」永远在,即便 skill 丢失) - system = _load_skill_system_prompt("ecommerce-entity-extract") + _EXTRACT_OUTPUT_CONTRACT - messages = [{"role": "system", "content": system}, {"role": "user", "content": user_msg}] - - try: - task = create_ai_task( - project=project, - user=user, - task_type=AITask.Type.ENTITY_EXTRACTION, - model_config=model_config, - request_payload={ - "model": model_config.name, - "endpoint": model_config.endpoint, - "messages": messages, - "script_id": str(script.id), - "model_routing_v1": True, - }, - ) - except Exception as exc: # 余额不足等预扣失败 - raise ValueError("额度不足,无法提取(请先充值)") from exc - - # 真实平台成本由每条 AIModelAttempt 按实际模型累加,避免 Fallback 后仍记主模型旧成本。 - task.base_cost = Decimal("0") - task.save(update_fields=["base_cost", "updated_at"]) - extract_entities_task.delay(str(task.id)) return task @@ -3181,7 +3156,7 @@ def submit_video_segment( # 视频段 token 计量计价(与自由创作同一成本表+同一毛利):按用户选定的比例/清晰度/目标时长预估, # 预留=积分×buffer,终态按火山真实 usage.total_tokens 结算(poll_video_segment true-up)。 # 这里终结了「视频 ¥1/段、成本 ¥15」的倒贴定价。 - from apps.billing.pricing import quote_video_estimate, video_reserve_amount + from apps.billing.pricing import quote_video_estimate, settle_video_from_payload, video_quote_payload, video_reserve_amount est_tokens, quote = quote_video_estimate( model_config, @@ -3204,7 +3179,7 @@ def submit_video_segment( task_type=AITask.Type.VIDEO_SEGMENT, model_config=model_config, quote=quote, - reserve_amount=video_reserve_amount(quote.points), + reserve_amount=video_reserve_amount(quote.points, rule=quote.meta.get("rule")), request_payload={ "model": model_config.name, "endpoint": model_config.endpoint, @@ -3213,8 +3188,7 @@ def submit_video_segment( "ratio": aspect_ratio, "resolution": resolution, "estimated_tokens": est_tokens, - # 团队价格系数快照:按实结算用它,中途改价不影响在途任务(jimeng 同款纪律) - "price_multiplier": quote.meta.get("price_multiplier", "1"), + **video_quote_payload(quote), "video_segment_id": str(video_segment.id), "reference_images": reference_images, "model_routing_v1": True, @@ -3375,34 +3349,26 @@ def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVers # 按火山真实 usage.total_tokens 结算(true-up,与自由创作同口径): # 多退(charge 差额自动 RELEASE)/超预留 clamp(ledger 禁超扣,差额平台承担并告警)。 # usage 缺失(异常响应)回落预估价,不阻断出片。 - from apps.billing.pricing import quote_video_actual - reservation = locked_task.credit_reservation - payload = locked_task.request_payload or {} + payload = dict(locked_task.request_payload or {}) try: usage_tokens = int((response.get("usage") or {}).get("total_tokens") or 0) except (TypeError, ValueError): usage_tokens = 0 - if usage_tokens > 0: - settle = quote_video_actual( - actual_model, - tokens=usage_tokens, - with_video_ref=False, - resolution=str(payload.get("resolution") or "720p"), - multiplier=Decimal(str(payload.get("price_multiplier") or "1")), - ) + settle = settle_video_from_payload(actual_model, payload=payload, tokens=usage_tokens) + if settle.meta.get("rule") == "missing_usage": + actual_points, base_cost = locked_task.estimated_cost, locked_task.base_cost + else: actual_points, base_cost = settle.points, settle.base_cost_yuan + if settle.meta.get("rate"): + payload["points_per_yuan_snapshot"] = settle.meta["rate"] + locked_task.request_payload = payload if actual_points > reservation.amount: logger.warning( "video segment task %s actual %s exceeds reserved %s, clamped", locked_task.id, actual_points, reservation.amount, ) actual_points = reservation.amount - else: - actual_points, base_cost = locked_task.estimated_cost, locked_task.base_cost - if usage_tokens > 0 and settle.meta.get("rate"): - payload["points_per_yuan_snapshot"] = settle.meta["rate"] - locked_task.request_payload = payload locked_task.status = AITask.Status.SUCCEEDED locked_task.response_payload = response locked_task.actual_cost = actual_points @@ -3527,7 +3493,7 @@ def _reap_stale_standalone_image_tasks(*, team) -> None: continue -def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1, product_id: str | None = None, reference_product: bool = False, model_id: str | None = None, model_entity_id: str | None = None, ratio: str | None = None, image_model: str | None = None, conversation=None, reference_image_ids: list[str] | None = None, platform_id: str | None = None, batch_id: str | None = None, retry_of_task_id: str | None = None, tryon_prompt_v2_override: bool = False, tryon_ab: dict | None = None, dispatch: bool = True) -> list[AITask]: +def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1, product_id: str | None = None, reference_product: bool = False, model_id: str | None = None, model_entity_id: str | None = None, ratio: str | None = None, image_model: str | None = None, conversation=None, reference_image_ids: list[str] | None = None, platform_id: str | None = None, batch_id: str | None = None, retry_of_task_id: str | None = None, tryon_prompt_v2_override: bool = False, tryon_ab: dict | None = None, feature: str | None = None, dispatch: bool = True) -> list[AITask]: """独立生图(图片创作 / 模特上身图 / 平台套图)改为**异步**:本函数在 Web 请求里只做「建任务 + 预留额度」这种秒级的活,真正 ~30s 的 ARK 出图交给 Celery worker(generate_standalone_image_task)。 @@ -3641,6 +3607,8 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c for index in range(count): quote = quote_flat(model_config, team=team) request_payload = {"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode, "index": index, "product_id": str(product_id) if product_id else None, "reference_product": bool(reference_product), "model_id": str(model_id) if model_id else None, "model_entity_id": str(model_entity_id) if model_entity_id else None, "batch_id": batch_id, "ratio": str(ratio) if ratio else None, "reference_image_ids": ref_ids, "platform_id": platform_key or None, "platform_name": platform_name or None} + if feature: + request_payload["feature"] = str(feature) if use_model_routing: request_payload["model_routing_v1"] = True if tryon_classification is not None: @@ -3899,13 +3867,22 @@ def run_standalone_image_task(*, task_id: str) -> None: asset_meta["batch_id"] = str(payload["batch_id"]) if payload.get("model_entity_id"): asset_meta["model_entity_id"] = str(payload["model_entity_id"]) + # 全能创作出图只留在会话里,不进图片创作最近列表 / 资产库。 + is_omni = str(payload.get("feature") or "") == "omni_create" + if is_omni: + asset_meta["feature"] = "omni_create" asset = Asset.objects.create( id=asset_id, team=team, created_by=user, name=f"AI 生成 · {asset_label} · {index + 1}", asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=asset_category, origin_task=task, metadata=asset_meta, # 模特候选与项目角色均是功能性资料:候选保存后进入模特库,不进入 /library; # 商品/创作等图片趴成图保持原有自动入库行为。 - in_library=asset_category not in (Asset.Category.PERSON, Asset.Category.MODEL_PORTRAIT), + # 全能创作同视频:不自动入库。 + in_library=( + False + if is_omni + else asset_category not in (Asset.Category.PERSON, Asset.Category.MODEL_PORTRAIT) + ), ) AssetFile.objects.create(asset=asset, object_key=stored.object_key, bucket=stored.bucket, content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True) except Exception as exc: # noqa: BLE001 — 失败要退费并把错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费) diff --git a/core/backend/apps/ai/test_creation_agent.py b/core/backend/apps/ai/test_creation_agent.py index 58db6f6..31edd78 100644 --- a/core/backend/apps/ai/test_creation_agent.py +++ b/core/backend/apps/ai/test_creation_agent.py @@ -273,6 +273,7 @@ class GenerateImageTests(CreationAgentBaseTests): self.assertEqual(kwargs["reference_image_ids"], [str(self.product_asset.id)]) self.assertEqual(kwargs["ratio"], "1:1") # 会话级参数直接用,不再问用户 self.assertEqual(kwargs["count"], 1) + self.assertEqual(kwargs["feature"], "omni_create") def test_session_image_count_overrides_model_count(self): self.conversation.params = {"ratio": "1:1", "count": "2 张"} diff --git a/core/backend/apps/ai/video_replace.py b/core/backend/apps/ai/video_replace.py index 670a5b7..f016bf2 100644 --- a/core/backend/apps/ai/video_replace.py +++ b/core/backend/apps/ai/video_replace.py @@ -23,7 +23,7 @@ from django.utils import timezone from apps.assets.models import Asset, Model from apps.billing.models import CreditAccount -from apps.billing.pricing import quote_video_estimate, video_reserve_amount +from apps.billing.pricing import quote_video_estimate, settle_video_from_payload, video_quote_payload, video_reserve_amount from apps.products.models import Product from .free_video import ( @@ -1053,7 +1053,7 @@ def _legacy_start_pending_replace_shots(task): references=built["snapshots"], team=task.team, ) - reserve_amount = video_reserve_amount(quote.points) + reserve_amount = video_reserve_amount(quote.points, rule=quote.meta.get("rule")) with transaction.atomic(): locked = ( @@ -1083,6 +1083,7 @@ def _legacy_start_pending_replace_shots(task): next_payload["estimated_tokens"] = tokens next_payload["review_pending"] = False next_payload["duration"] = billed_duration + next_payload.update(video_quote_payload(quote)) locked.request_payload = next_payload locked.estimated_cost = quote.points locked.status = AITask.Status.RESERVED @@ -1262,7 +1263,6 @@ def complete_replace_shots(task): """全部镜头出完:下载 → ffmpeg 拼接 → 转存 TOS → 按 tokens 合计结算。""" from decimal import Decimal - from apps.billing.pricing import quote_video_actual from apps.billing.services.ledger import charge_reserved_credit, release_credit from .free_video import _notify_failure, _store_free_video_media @@ -1289,18 +1289,18 @@ def complete_replace_shots(task): total_tokens += int(item.get("tokens") or 0) except (TypeError, ValueError): pass - resolution = payload.get("resolution") or "720p" - if total_tokens > 0: - settle = quote_video_actual( - locked.model_config, tokens=total_tokens, with_video_ref=True, - resolution=resolution, multiplier=Decimal(str(payload.get("price_multiplier") or "1")), - ) + # 挂牌秒价按快照扣;无挂牌才按 tokens true-up + if "with_ref_video" not in payload: + payload["with_ref_video"] = True # 替换片默认带视频参考 + settle = settle_video_from_payload(locked.model_config, payload=payload, tokens=total_tokens) + if settle.meta.get("rule") == "missing_usage": + actual, base_cost = locked.estimated_cost, locked.base_cost + else: actual, base_cost = settle.points, settle.base_cost_yuan - payload["actual_tokens"] = total_tokens + if total_tokens > 0: + payload["actual_tokens"] = total_tokens if settle.meta.get("rate"): payload["points_per_yuan_snapshot"] = settle.meta["rate"] - else: - actual, base_cost = locked.estimated_cost, locked.base_cost with transaction.atomic(): locked = AITask.objects.select_for_update().get(id=locked.id) if locked.status != AITask.Status.POSTPROCESSING: @@ -1584,7 +1584,7 @@ def _create_reviewing_task(*, team, user, params: dict, digest_pending: bool = F references=references, team=team, ) - reserve_amount = video_reserve_amount(quote.points) + reserve_amount = video_reserve_amount(quote.points, rule=quote.meta.get("rule")) account = CreditAccount.objects.filter(team=team).first() available = (account.balance - account.reserved_balance) if account else Decimal("0") if available < reserve_amount: @@ -1610,8 +1610,7 @@ def _create_reviewing_task(*, team, user, params: dict, digest_pending: bool = F "generate_audio": True, "search_mode": "off", "estimated_tokens": tokens, - "price_multiplier": quote.meta.get("price_multiplier", "1"), - "points_per_yuan_snapshot": quote.meta.get("rate", ""), + **video_quote_payload(quote), "references": references, "model_routing_v1": True, "review_pending": True, diff --git a/core/backend/apps/ai/video_timeout_recovery.py b/core/backend/apps/ai/video_timeout_recovery.py index 23ab6ef..e23e723 100644 --- a/core/backend/apps/ai/video_timeout_recovery.py +++ b/core/backend/apps/ai/video_timeout_recovery.py @@ -14,7 +14,7 @@ from django.utils import timezone from apps.ai.models import AITask from apps.assets.models import Asset from apps.billing.models import CreditReservation -from apps.billing.pricing import quote_video_actual +from apps.billing.pricing import settle_video_from_payload from apps.projects.models import VideoSegment, VideoSegmentVersion @@ -108,16 +108,12 @@ def _platform_cost(response: dict, *, task: AITask, actual_model, with_video_ref total_tokens = int((response.get("usage") or {}).get("total_tokens") or 0) except (TypeError, ValueError): total_tokens = 0 - if total_tokens <= 0: + payload = dict(task.request_payload or {}) + if "with_ref_video" not in payload: + payload["with_ref_video"] = with_video_ref + settle = settle_video_from_payload(actual_model, payload=payload, tokens=total_tokens) + if settle.meta.get("rule") == "missing_usage": return 0, task.base_cost - payload = task.request_payload or {} - settle = quote_video_actual( - actual_model, - tokens=total_tokens, - with_video_ref=with_video_ref, - resolution=str(payload.get("resolution") or "720p"), - multiplier=Decimal(str(payload.get("price_multiplier") or "1")), - ) return total_tokens, settle.base_cost_yuan diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 70b076d..1e0894c 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -1248,6 +1248,7 @@ class FreeVideoUploadView(APIView): class ModelConfigViewSet(ReadOnlyModelViewSet): # 按创建序固定排序:最早创建的 active 模型排第一 = 前端选择器默认项,与 get_default_model 口径一致。 # DeepSeek 已停用,下拉里不再出现。 + # 列表短且高频(创作页下拉),整页结果缓存;后台改模型走 invalidate_model_catalog_cache。 queryset = ( ModelConfig.objects.select_related("provider") .filter(status=ModelConfig.Status.ACTIVE) @@ -1259,6 +1260,30 @@ class ModelConfigViewSet(ReadOnlyModelViewSet): search_fields = ["name", "display_name", "capability"] ordering_fields = ["created_at", "display_name"] + def list(self, request, *args, **kwargs): + from django.core.cache import cache + + from apps.ai.model_catalog import MODEL_CATALOG_CACHE_KEY, MODEL_CATALOG_CACHE_TTL + + # 缓存「无 search/ordering/capability、首页」目录。page_size=200 是创作页常规用法。 + qp = request.query_params + page = str(qp.get("page") or "1") + page_size = str(qp.get("page_size") or "") + is_plain = ( + not any(qp.get(k) for k in ("search", "ordering", "capability")) + and page in {"1", ""} + and page_size in {"", "200"} + ) + cache_key = MODEL_CATALOG_CACHE_KEY if page_size in {"", "200"} else f"{MODEL_CATALOG_CACHE_KEY}:{page_size}" + if is_plain: + cached = cache.get(cache_key) + if cached is not None: + return Response(cached) + response = super().list(request, *args, **kwargs) + if is_plain and response.status_code == 200: + cache.set(cache_key, response.data, MODEL_CATALOG_CACHE_TTL) + return response + class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): diff --git a/core/backend/apps/billing/points_rules.py b/core/backend/apps/billing/points_rules.py new file mode 100644 index 0000000..904d6ec --- /dev/null +++ b/core/backend/apps/billing/points_rules.py @@ -0,0 +1,207 @@ +"""老板挂牌积分规则(不谈供应商成本)。 + +存在 ModelConfig.metadata["points_pricing"]: + · image → mode=per_image, points_per_image + · text → mode=per_call, points_per_call + · video → mode=per_second, tiers=[{resolution, points_per_second, points_per_second_with_ref?}] + +能力(可选分辨率/时长)在 metadata["capabilities"]。 +未配置 points_pricing 时由 pricing.quote_* 回落旧口径(unit_price / token 价表)。 +""" +from __future__ import annotations + +from decimal import Decimal, ROUND_HALF_UP +from typing import Any + + +def _dict(value: Any) -> dict: + return value if isinstance(value, dict) else {} + + +def _int_points(value: Any) -> int | None: + if value is None or value is False: + return None + try: + n = int(Decimal(str(value)).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) + except Exception: # noqa: BLE001 + return None + return n if n >= 0 else None + + +def points_pricing_of(model_config) -> dict: + return _dict(_dict(getattr(model_config, "metadata", None)).get("points_pricing")) + + +def capabilities_of(model_config) -> dict: + return _dict(_dict(getattr(model_config, "metadata", None)).get("capabilities")) + + +def has_points_pricing(model_config) -> bool: + pricing = points_pricing_of(model_config) + mode = str(pricing.get("mode") or "").strip() + if mode == "per_image": + return _int_points(pricing.get("points_per_image")) is not None + if mode == "per_call": + return _int_points(pricing.get("points_per_call")) is not None + if mode == "per_second": + tiers = pricing.get("tiers") + return isinstance(tiers, list) and any(isinstance(t, dict) for t in tiers) + # 兼容:只填了数字字段 + return ( + _int_points(pricing.get("points_per_image")) is not None + or _int_points(pricing.get("points_per_call")) is not None + ) + + +def image_points_per_unit(model_config) -> int | None: + pricing = points_pricing_of(model_config) + n = _int_points(pricing.get("points_per_image")) + if n is not None: + return n + # 回落 unit_price(积分/张) + return _int_points(getattr(model_config, "unit_price", None)) + + +def text_points_per_call(model_config) -> int | None: + pricing = points_pricing_of(model_config) + n = _int_points(pricing.get("points_per_call")) + if n is not None: + return n + return _int_points(getattr(model_config, "unit_price", None)) + + +def video_tier(model_config, *, resolution: str, with_ref_video: bool = False) -> dict | None: + pricing = points_pricing_of(model_config) + tiers = pricing.get("tiers") + if not isinstance(tiers, list): + return None + resolution = str(resolution or "").strip().lower() + hit = None + for raw in tiers: + if not isinstance(raw, dict): + continue + if str(raw.get("resolution") or "").strip().lower() == resolution: + hit = raw + break + return hit + + +def video_points_per_second(model_config, *, resolution: str, with_ref_video: bool = False) -> int | None: + tier = video_tier(model_config, resolution=resolution, with_ref_video=with_ref_video) + if not tier: + return None + if with_ref_video: + n = _int_points(tier.get("points_per_second_with_ref")) + if n is not None: + return n + return _int_points(tier.get("points_per_second")) + + +def quote_points_per_second(*, points_per_second: int, duration: int) -> Decimal: + seconds = max(int(duration or 0), 0) + pts = max(int(points_per_second) * seconds, 1) if seconds > 0 else 0 + return Decimal(pts) + + +def quote_points_units(*, points_per_unit: int, units: int) -> Decimal: + count = max(int(units or 0), 0) + if count <= 0: + return Decimal("0") + return Decimal(max(int(points_per_unit) * count, 1)) + + +def normalize_points_pricing_for_save(capability: str, metadata: dict | None) -> dict: + """整理 metadata:补齐 points_pricing.mode,并让 unit_price 与挂牌积分对齐(兼容旧读法)。""" + root = dict(metadata or {}) + pricing = dict(_dict(root.get("points_pricing"))) + caps = dict(_dict(root.get("capabilities"))) + capability = str(capability or "") + + if capability == "image": + pts = _int_points(pricing.get("points_per_image")) + if pts is not None: + pricing["mode"] = "per_image" + pricing["points_per_image"] = pts + root["points_pricing"] = pricing + return root + + if capability in {"text", "vision"}: + pts = _int_points(pricing.get("points_per_call")) + if pts is not None: + pricing["mode"] = "per_call" + pricing["points_per_call"] = pts + root["points_pricing"] = pricing + return root + + if capability == "video": + tiers_in = pricing.get("tiers") if isinstance(pricing.get("tiers"), list) else [] + tiers: list[dict] = [] + resolutions: list[str] = [] + for raw in tiers_in: + if not isinstance(raw, dict): + continue + res = str(raw.get("resolution") or "").strip() + pps = _int_points(raw.get("points_per_second")) + if not res or pps is None: + continue + row = {"resolution": res, "points_per_second": pps} + with_ref = _int_points(raw.get("points_per_second_with_ref")) + if with_ref is not None: + row["points_per_second_with_ref"] = with_ref + tiers.append(row) + if res not in resolutions: + resolutions.append(res) + if tiers: + pricing["mode"] = "per_second" + pricing["tiers"] = tiers + # 能力分辨率与价表对齐,前端下拉只出有价的档 + if not caps.get("resolutions"): + caps["resolutions"] = resolutions + else: + # 保留已配能力,但确保价表分辨率都在里面 + existing = [str(x) for x in (caps.get("resolutions") or [])] + for res in resolutions: + if res not in existing: + existing.append(res) + caps["resolutions"] = existing + root["points_pricing"] = pricing + root["capabilities"] = caps + return root + + root["points_pricing"] = pricing + return root + + +def points_pricing_errors(capability: str, metadata: Any) -> tuple[str, ...]: + """可选校验:填了 points_pricing 就要完整。""" + root = _dict(metadata) + pricing = _dict(root.get("points_pricing")) + if not pricing: + return () + errors: list[str] = [] + mode = str(pricing.get("mode") or "").strip() + if capability == "image": + if _int_points(pricing.get("points_per_image")) is None and mode in {"", "per_image"}: + if "points_per_image" in pricing: + errors.append("points_pricing.points_per_image 必须是 ≥0 的整数积分") + elif capability in {"text", "vision"}: + if "points_per_call" in pricing and _int_points(pricing.get("points_per_call")) is None: + errors.append("points_pricing.points_per_call 必须是 ≥0 的整数积分") + elif capability == "video": + tiers = pricing.get("tiers") + if tiers is None: + return tuple(errors) + if not isinstance(tiers, list) or not tiers: + errors.append("视频 points_pricing.tiers 至少配置一档分辨率积分") + else: + for i, raw in enumerate(tiers): + if not isinstance(raw, dict): + errors.append(f"tiers[{i}] 必须是对象") + continue + if not str(raw.get("resolution") or "").strip(): + errors.append(f"tiers[{i}].resolution 不能为空") + if _int_points(raw.get("points_per_second")) is None: + errors.append(f"tiers[{i}].points_per_second 必须是 ≥0 的整数积分") + if "points_per_second_with_ref" in raw and _int_points(raw.get("points_per_second_with_ref")) is None: + errors.append(f"tiers[{i}].points_per_second_with_ref 必须是 ≥0 的整数积分") + return tuple(errors) diff --git a/core/backend/apps/billing/pricing.py b/core/backend/apps/billing/pricing.py index 1a07eca..7f66c1f 100644 --- a/core/backend/apps/billing/pricing.py +++ b/core/backend/apps/billing/pricing.py @@ -10,8 +10,9 @@ 平台成本配在 metadata.pricing.base_cost_yuan(未配=0,毛利报表按未知处理)。 · 配音:metadata.pricing = {"mode":"per_chars","chars_per_unit":500,"points_per_unit":10, "min_units":1,"base_cost_yuan_per_unit":...},按字符数阶梯。 - · 视频:metadata.pricing 是火山**成本价表**(元/百万tokens,见 apps/ai/video_pricing.py), - 用户价 = ¥成本 × video_margin_multiplier → 积分。按真实 usage.total_tokens 结算。 + · 视频:优先 metadata.points_pricing 挂牌(分辨率积分/秒 × 时长;可配含视频参考价); + 未挂牌才走 metadata.pricing 火山成本表 × 毛利 → 积分,并按 usage.total_tokens true-up。 + 挂牌任务预留=精确积分、结算认下单快照;token 任务预留仍 × video_reserve_buffer。 取整(前后端必须逐字一致,前端镜像在 frontend/src/components/free-create/constants.ts): ¥成本先 quantize 到 0.01(video_pricing.calculate_cost 现状)→ ×毛利 ×汇率 → ROUND_HALF_UP @@ -100,19 +101,38 @@ def _pricing_meta(model_config) -> dict: def quote_flat(model_config, *, units: int = 1, team=None) -> Quote: - """文本(次)/ 图像(张)。unit_price = 积分/单位;≤0 回落 10 积分。team 传入则乘团队价格系数。""" - unit_points = Decimal(str(getattr(model_config, "unit_price", 0) or 0)) + """文本(次)/ 图像(张)。优先 metadata.points_pricing 挂牌;否则 unit_price;≤0 回落默认积分。team 传入则乘团队价格系数。""" + from apps.billing.points_rules import image_points_per_unit, text_points_per_call + + capability = str(getattr(model_config, "capability", "") or "") + listed = None + if capability == "image": + listed = image_points_per_unit(model_config) + elif capability in {"text", "vision"}: + listed = text_points_per_call(model_config) + listed_from_hang = listed is not None and listed > 0 + if listed_from_hang: + unit_points = Decimal(listed) + else: + unit_points = Decimal(str(getattr(model_config, "unit_price", 0) or 0)) if unit_points <= 0: - is_image = str(getattr(model_config, "capability", "")) == "image" + is_image = capability == "image" unit_points = FLAT_FALLBACK_POINTS_IMAGE if is_image else FLAT_FALLBACK_POINTS unit_points = unit_points.quantize(Decimal("1"), rounding=ROUND_HALF_UP) base_per_unit = Decimal(str(_pricing_meta(model_config).get("base_cost_yuan") or 0)) multiplier = team_price_multiplier(team) points = apply_team_price(max(unit_points * units, Decimal("1")), multiplier) + # 挂牌:image=per_image / text=per_call;未挂牌仍记 flat(unit_price 或默认) + if listed_from_hang and capability == "image": + rule = "per_image" + elif listed_from_hang and capability in {"text", "vision"}: + rule = "per_call" + else: + rule = "flat" return Quote( points=points, base_cost_yuan=base_per_unit * units, - meta={"rule": "flat", "units": units, "unit_points": str(unit_points), "price_multiplier": str(multiplier), "rate": str(get_billing_config().points_per_yuan)}, + meta={"rule": rule, "units": units, "unit_points": str(unit_points), "price_multiplier": str(multiplier), "rate": str(get_billing_config().points_per_yuan)}, ) @@ -176,25 +196,141 @@ def quote_video_from_cost(cost_yuan: Decimal, *, tokens: int = 0, multiplier: De def quote_video_estimate(model_config, *, aspect_ratio: str, resolution: str, duration: int, references: list, team=None) -> tuple[int, Quote]: - """预估:apps/ai/video_pricing 的 ¥成本 × 毛利 → 积分 → ×团队当前系数。返回 (tokens, Quote)。""" - from apps.ai.video_pricing import estimate_video_cost + """预估视频积分。优先老板挂牌(分辨率×秒);未配置则回落 token×成本×毛利。返回 (tokens占位, Quote)。""" + from apps.ai.video_pricing import estimate_video_cost, has_video_reference + from apps.billing.points_rules import quote_points_per_second, video_points_per_second + + with_ref = has_video_reference(references) + pps = video_points_per_second(model_config, resolution=resolution, with_ref_video=with_ref) + multiplier = team_price_multiplier(team) + if pps is not None: + list_points = quote_points_per_second(points_per_second=pps, duration=duration) + points = apply_team_price(list_points, multiplier) + return 0, Quote( + points=points, + base_cost_yuan=Decimal("0"), + meta={ + "rule": "points_per_second", + "resolution": resolution, + "duration": duration, + "with_ref_video": with_ref, + "points_per_second": pps, + "price_multiplier": str(multiplier), + "rate": str(get_billing_config().points_per_yuan), + }, + ) tokens, cost_yuan = estimate_video_cost( model_config, aspect_ratio=aspect_ratio, resolution=resolution, duration=duration, references=references ) - return tokens, quote_video_from_cost(cost_yuan, tokens=tokens, multiplier=team_price_multiplier(team)) + return tokens, quote_video_from_cost(cost_yuan, tokens=tokens, multiplier=multiplier) -def quote_video_actual(model_config, *, tokens: int, with_video_ref: bool, resolution: str, multiplier: Decimal | None = None) -> Quote: - """按真实 usage.total_tokens 结算(与预估同一张成本表+同一毛利)。 - multiplier 必须传**下单时的快照系数**(request_payload.price_multiplier),不读团队当前值。""" +def quote_video_actual( + model_config, + *, + tokens: int, + with_video_ref: bool, + resolution: str, + multiplier: Decimal | None = None, + duration: int | None = None, + listed_points_per_second: int | None = None, +) -> Quote: + """结算视频积分。优先老板挂牌秒价(与预估同口径);未挂牌才按 tokens×成本表。 + multiplier / listed_points_per_second 必须用**下单快照**,中途改价不影响在途任务。""" from apps.ai.video_pricing import tokens_to_cost + from apps.billing.points_rules import quote_points_per_second, video_points_per_second + + multiplier = multiplier if multiplier is not None else Decimal("1") + pps = listed_points_per_second + if pps is None: + pps = video_points_per_second(model_config, resolution=resolution, with_ref_video=with_video_ref) + if pps is not None and duration is not None and int(duration) > 0: + list_points = quote_points_per_second(points_per_second=int(pps), duration=int(duration)) + points = apply_team_price(list_points, multiplier) + return Quote( + points=points, + base_cost_yuan=Decimal("0"), + meta={ + "rule": "points_per_second", + "resolution": resolution, + "duration": int(duration), + "with_ref_video": with_video_ref, + "points_per_second": int(pps), + "price_multiplier": str(multiplier), + "rate": str(get_billing_config().points_per_yuan), + }, + ) cost_yuan = tokens_to_cost(model_config, tokens, with_video_ref=with_video_ref, resolution=resolution) return quote_video_from_cost(cost_yuan, tokens=tokens, multiplier=multiplier) -def video_reserve_amount(points: Decimal) -> Decimal: - """视频预留额 = 预估积分 × buffer(真实 tokens 可能略超预估;ledger 禁超预留扣费)。""" +def video_reserve_amount(points: Decimal, *, rule: str | None = None) -> Decimal: + """视频预留额。挂牌秒价=精确积分(无 buffer);token 估价才 × buffer(真实 tokens 可能略超预估)。""" + points = Decimal(str(points)).quantize(Decimal("1"), rounding=ROUND_HALF_UP) + if rule == "points_per_second": + return max(points, Decimal("1")) if points > 0 else Decimal("0") buffer = get_billing_config().video_reserve_buffer return (points * buffer).quantize(Decimal("1"), rounding=ROUND_HALF_UP) + + +def video_quote_payload(quote: Quote) -> dict: + """下单时写入 request_payload 的计价快照,结算只认这些字段。""" + meta = quote.meta or {} + out = { + "pricing_rule": meta.get("rule") or "", + "price_multiplier": meta.get("price_multiplier", "1"), + "points_per_yuan_snapshot": meta.get("rate", ""), + } + if meta.get("rule") == "points_per_second": + out["listed_points_per_second"] = int(meta.get("points_per_second") or 0) + out["with_ref_video"] = bool(meta.get("with_ref_video")) + if meta.get("duration") is not None: + out["billing_duration"] = int(meta["duration"]) + return out + + +def settle_video_from_payload(model_config, *, payload: dict, tokens: int = 0) -> Quote: + """统一视频结算:挂牌任务按快照秒价×时长扣;token 任务按 usage true-up。 + 挂牌不依赖 tokens——用量缺失也能按后台填的积分扣到位。""" + payload = payload or {} + multiplier = Decimal(str(payload.get("price_multiplier") or "1")) + resolution = str(payload.get("resolution") or "720p") + duration = payload.get("billing_duration") + if duration in (None, ""): + duration = payload.get("duration") + try: + duration_i = int(duration or 0) or None + except (TypeError, ValueError): + duration_i = None + with_ref = payload.get("with_ref_video") + if with_ref is None: + with_ref = any((r or {}).get("type") == "video" for r in (payload.get("references") or [])) + listed = payload.get("listed_points_per_second") + try: + listed_i = int(listed) if listed not in (None, "") else None + except (TypeError, ValueError): + listed_i = None + rule = str(payload.get("pricing_rule") or "") + # 挂牌快照在:即使 usage.tokens=0 也按秒价结算(与预估同数) + if rule == "points_per_second" or listed_i is not None: + return quote_video_actual( + model_config, + tokens=int(tokens or 0), + with_video_ref=bool(with_ref), + resolution=resolution, + multiplier=multiplier, + duration=duration_i, + listed_points_per_second=listed_i, + ) + if int(tokens or 0) <= 0: + # 无 usage 且无挂牌:由调用方回落 estimated_cost + return Quote(points=Decimal("0"), base_cost_yuan=Decimal("0"), meta={"rule": "missing_usage"}) + # 无挂牌快照 → 纯 token 结算(即便模型后来配了挂牌也不改在途口径) + from apps.ai.video_pricing import tokens_to_cost + + cost_yuan = tokens_to_cost( + model_config, int(tokens), with_video_ref=bool(with_ref), resolution=resolution, + ) + return quote_video_from_cost(cost_yuan, tokens=int(tokens), multiplier=multiplier) diff --git a/core/backend/apps/billing/tests.py b/core/backend/apps/billing/tests.py index 532b660..1f0f029 100644 --- a/core/backend/apps/billing/tests.py +++ b/core/backend/apps/billing/tests.py @@ -487,3 +487,96 @@ class TeamPriceMultiplierTests(TestCase): client = APIClient() client.force_authenticate(self.user) self.assertEqual(client.get("/api/billing/config/").json()["team_price_multiplier"], "0.80") + + +class ListedPointsPricingTests(TestCase): + """老板挂牌秒价:预留精确、结算认快照,不跟 tokens/中途改价跑偏。""" + + def setUp(self): + from apps.billing.pricing import invalidate_billing_config_cache + + invalidate_billing_config_cache() + self.user = User.objects.create_user(username="listed-pts", password="p") + self.team = Team.objects.create(name="listed", owner=self.user, price_multiplier=Decimal("1.00")) + TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER) + CreditAccount.objects.create(team=self.team, balance=Decimal("10000"), reserved_balance=Decimal("0")) + provider, _ = ModelProvider.objects.get_or_create(name="volcengine", defaults={"display_name": "V"}) + self.model = ModelConfig.objects.create( + provider=provider, + name="doubao-seedance-listed", + display_name="Listed Mini", + capability=ModelConfig.Capability.VIDEO, + unit_price=Decimal("0"), + status=ModelConfig.Status.ACTIVE, + metadata={ + "capabilities": {"resolutions": ["480p", "720p"], "durations": [4, 5]}, + "points_pricing": { + "mode": "per_second", + "tiers": [ + {"resolution": "720p", "points_per_second": 10, "points_per_second_with_ref": 15}, + {"resolution": "480p", "points_per_second": 6}, + ], + }, + }, + ) + + def test_estimate_and_exact_reserve(self): + from apps.billing.pricing import quote_video_estimate, video_reserve_amount + + _tokens, quote = quote_video_estimate( + self.model, aspect_ratio="16:9", resolution="720p", duration=5, references=[], team=self.team, + ) + self.assertEqual(quote.meta["rule"], "points_per_second") + self.assertEqual(quote.points, Decimal("50")) # 10 * 5 + self.assertEqual(video_reserve_amount(quote.points, rule=quote.meta["rule"]), Decimal("50")) + # token 路径仍乘 buffer(默认 1.10) + self.assertEqual(video_reserve_amount(Decimal("50"), rule="video_tokens"), Decimal("55")) + + def test_settle_snapshot_ignores_model_price_change_and_tokens(self): + from apps.billing.pricing import quote_video_estimate, settle_video_from_payload, video_quote_payload + + _tokens, quote = quote_video_estimate( + self.model, + aspect_ratio="16:9", + resolution="720p", + duration=5, + references=[{"type": "video", "url": "http://x"}], + team=self.team, + ) + # 含视频参考 → 15 * 5 = 75 + self.assertEqual(quote.points, Decimal("75")) + payload = { + "resolution": "720p", + "duration": 5, + **video_quote_payload(quote), + "references": [{"type": "video"}], + } + # 中途把挂牌改成天价 + meta = dict(self.model.metadata) + meta["points_pricing"] = { + "mode": "per_second", + "tiers": [{"resolution": "720p", "points_per_second": 999, "points_per_second_with_ref": 999}], + } + self.model.metadata = meta + self.model.save(update_fields=["metadata"]) + settle = settle_video_from_payload(self.model, payload=payload, tokens=9_999_999) + self.assertEqual(settle.points, Decimal("75")) + self.assertEqual(settle.meta["rule"], "points_per_second") + settle0 = settle_video_from_payload(self.model, payload=payload, tokens=0) + self.assertEqual(settle0.points, Decimal("75")) + + def test_flat_image_uses_points_per_image(self): + from apps.billing.pricing import quote_flat + + provider, _ = ModelProvider.objects.get_or_create(name="volcengine", defaults={"display_name": "V"}) + image = ModelConfig.objects.create( + provider=provider, + name="seedream-listed", + capability=ModelConfig.Capability.IMAGE, + unit_price=Decimal("0"), + status=ModelConfig.Status.ACTIVE, + metadata={"points_pricing": {"mode": "per_image", "points_per_image": 33}}, + ) + quote = quote_flat(image, units=2, team=self.team) + self.assertEqual(quote.points, Decimal("66")) + self.assertEqual(quote.meta.get("rule"), "per_image") diff --git a/core/backend/apps/projects/services/pipeline.py b/core/backend/apps/projects/services/pipeline.py index 6ef27f2..46081a4 100644 --- a/core/backend/apps/projects/services/pipeline.py +++ b/core/backend/apps/projects/services/pipeline.py @@ -106,6 +106,15 @@ def adopt_script_version(project: Project, script: ScriptVersion) -> None: if not script.is_adopted: script.is_adopted = True script.save(update_fields=["is_adopted", "updated_at"]) + # 定稿即本地拆角色/场景(不调模型、不扣费),资产页无需再付费「提取」 + try: + from apps.ai.entity_local import materialize_script_entities + + materialize_script_entities(project=project, script=script) + except Exception: # noqa: BLE001 — 拆实体失败不挡定稿进资产;用户仍可点「提取」重试 + import logging + + logging.getLogger(__name__).exception("local entity materialize on adopt failed for project %s", project.id) sync_video_segments_to_script(project, script) stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT) stage.status = ProjectStage.Status.SUCCEEDED diff --git a/core/backend/apps/projects/test_quick_create.py b/core/backend/apps/projects/test_quick_create.py index 19aaab7..2780b6f 100644 --- a/core/backend/apps/projects/test_quick_create.py +++ b/core/backend/apps/projects/test_quick_create.py @@ -1362,3 +1362,5 @@ class QuickCreateCoordinatorTests(TestCase): + + diff --git a/core/backend/apps/projects/tests.py b/core/backend/apps/projects/tests.py index c5302b9..494121a 100644 --- a/core/backend/apps/projects/tests.py +++ b/core/backend/apps/projects/tests.py @@ -198,123 +198,71 @@ class ProjectApiTests(TestCase): ) self.assertEqual(response.status_code, 404) - @patch("apps.ai.services.VolcanoArkProvider") - def test_extract_entities_streams_and_persists(self, provider_cls): - """提取走流式通道(豆包 2.0 Pro 思考模型):思考事件丢弃、只收正文 JSON → - 落库 cast/scenes + 每镜 entity_refs + entities_extracted 标记,计费一次。""" - import json as _json - extracted = { - "entities": [ - {"id": "c1", "type": "character", "name": "女主", "visual_prompt": "26岁都市女性,利落短发"}, - {"id": "s1", "type": "scene", "name": "出租屋客厅", "visual_prompt": "ins风小客厅,暖白自然光"}, - ], - "segments": [ - {"index": 0, "entity_refs": ["c1", "s1"]}, - {"index": 1, "entity_refs": ["c1", "s1"]}, - ], - } - provider = provider_cls.return_value - provider.chat_completion_stream.return_value = iter([ - {"type": "reasoning", "text": "先通读分镜"}, # 思考事件:必须被丢弃,不进正文 - {"type": "delta", "text": "```json\n" + _json.dumps(extracted, ensure_ascii=False)}, - {"type": "delta", "text": "\n```"}, - {"type": "done"}, - ]) - project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P") + def test_extract_entities_local_from_script_metadata(self): + """本地提取:读脚本 metadata.entities,不调模型、不扣积分,落 cast/scenes + entities_extracted。""" + from apps.billing.models import CreditLedger + + project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P-local") script = ScriptVersion.objects.create( - project=project, title="脚本", content="x", is_adopted=True, metadata={"entities": []}, + project=project, + title="脚本", + content="{}", + is_adopted=True, + metadata={ + "entities": [ + {"id": "c1", "type": "character", "name": "女主", "visual_prompt": "都市女性"}, + {"id": "s1", "type": "scene", "name": "出租屋客厅", "visual_prompt": "小客厅"}, + ] + }, ) - ScriptSegment.objects.create(script_version=script, sort_order=0, narration="旧0", visual_prompt="画0") - ScriptSegment.objects.create(script_version=script, sort_order=1, narration="旧1", visual_prompt="画1") + ScriptSegment.objects.create(script_version=script, sort_order=0, narration="旧0", visual_prompt="画0", entity_refs=[]) + ScriptSegment.objects.create(script_version=script, sort_order=1, narration="旧1", visual_prompt="画1", entity_refs=[]) response = self.client.post(f"/api/projects/{project.id}/extract-entities/", {}, format="json") self.assertEqual(response.status_code, 200) - # 锁定豆包 2.0 Pro,且走的是流式通道(而非非流式 chat_completion) - self.assertEqual(provider.chat_completion_stream.call_args.kwargs["model"], "doubao-seed-2-0-pro-260215") - provider.chat_completion.assert_not_called() - # 实体落库:cast/scenes + entities_extracted 标记 + 每镜 entity_refs 回填 + self.assertEqual(response.data["status"], "succeeded") + self.assertEqual(response.data["mode"], "local") project.refresh_from_db() self.assertIn("女主", project.metadata.get("cast", [])) self.assertIn("出租屋客厅", project.metadata.get("scenes", [])) self.assertTrue(project.metadata.get("entities_extracted")) + self.assertEqual(project.metadata.get("entities_extract_mode"), "local") segs = list(script.segments.order_by("sort_order")) self.assertEqual(segs[0].entity_refs, ["c1", "s1"]) - self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 1) + self.assertEqual(CreditLedger.objects.filter(team=self.team).count(), 0) - @patch("apps.ai.services.VolcanoArkProvider") - def test_extract_entities_recovers_when_content_empty_uses_reasoning(self, provider_cls): - """极端兜底:思考模型整轮只发了 reasoning、正文 content 为空 → 回退用 reasoning 里的 JSON, - 不再像旧非流式那样拿到空串报「未返回有效 JSON」。""" - import json as _json - extracted = { - "entities": [{"id": "c1", "type": "character", "name": "男主", "visual_prompt": "28岁居家男性"}], - "segments": [{"index": 0, "entity_refs": ["c1"]}], - } - provider = provider_cls.return_value - provider.chat_completion_stream.return_value = iter([ - {"type": "reasoning", "text": _json.dumps(extracted, ensure_ascii=False)}, - {"type": "done"}, - ]) - - project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P2") + def test_extract_entities_local_fills_defaults_when_missing(self): + """脚本没带 entities 时,本地补最少 1 角色 + 1 场景。""" + project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P-default") script = ScriptVersion.objects.create( - project=project, title="脚本", content="x", is_adopted=True, metadata={"entities": []}, + project=project, title="脚本", content="{}", is_adopted=True, metadata={"entities": []}, + ) + ScriptSegment.objects.create( + script_version=script, sort_order=0, narration="女主介绍产品", visual_prompt="明亮客厅口播", ) - ScriptSegment.objects.create(script_version=script, sort_order=0, narration="旧0", visual_prompt="画0") - response = self.client.post(f"/api/projects/{project.id}/extract-entities/", {}, format="json") self.assertEqual(response.status_code, 200) project.refresh_from_db() - self.assertIn("男主", project.metadata.get("cast", [])) + self.assertTrue(project.metadata.get("cast")) + self.assertTrue(project.metadata.get("scenes")) + self.assertTrue(project.metadata.get("entities_extracted")) - @patch("apps.ai.services.VolcanoArkProvider") - def test_extract_entities_inflight_guard_reuses_task(self, provider_cls): - """已有在途提取任务时再次提交 → 复用同一任务,不新建、不二次预扣、不重跑模型 - (用户刷新后手痒重点 / 并发点击都不会重复扣费)。""" - from apps.ai.services import submit_extract_entities - - project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P3") + def test_extract_status_after_local_success(self): + project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P-status") script = ScriptVersion.objects.create( - project=project, title="脚本", content="x", is_adopted=True, metadata={"entities": []}, - ) - ScriptSegment.objects.create(script_version=script, sort_order=0, narration="旧0", visual_prompt="画0") - inflight = AITask.objects.create( - team=self.team, created_by=self.user, project=project, model_config=self.model, - task_type=AITask.Type.ENTITY_EXTRACTION, status=AITask.Status.SUBMITTED, - idempotency_key="entity_extraction:inflight:1", - ) - before = AITask.objects.filter(project=project, task_type=AITask.Type.ENTITY_EXTRACTION).count() - - task = submit_extract_entities(project=project, user=self.user) - self.assertEqual(str(task.id), str(inflight.id)) # 复用在途任务 - self.assertEqual( # 没新建任务 - AITask.objects.filter(project=project, task_type=AITask.Type.ENTITY_EXTRACTION).count(), before - ) - provider_cls.return_value.chat_completion_stream.assert_not_called() # 没重跑模型 - # 没二次预扣额度(复用直接 return,根本没走到 create_ai_task) - self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.RESERVE).count(), 0) - - def test_extract_status_reports_running_then_safe_failure(self): - """extract-status:在途→running=True;失败→安全错误对象,原始任务错误不透给前端。""" - project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P4") - t = AITask.objects.create( - team=self.team, created_by=self.user, project=project, model_config=self.model, - task_type=AITask.Type.ENTITY_EXTRACTION, status=AITask.Status.SUBMITTED, - idempotency_key="entity_extraction:status:1", + project=project, title="脚本", content="{}", is_adopted=True, + metadata={"entities": [{"id": "c1", "type": "character", "name": "男主", "visual_prompt": "x"}, + {"id": "s1", "type": "scene", "name": "厨房", "visual_prompt": "y"}]}, ) + ScriptSegment.objects.create(script_version=script, sort_order=0, narration="a", visual_prompt="b") + self.client.post(f"/api/projects/{project.id}/extract-entities/", {}, format="json") res = self.client.get(f"/api/projects/{project.id}/extract-status/") self.assertEqual(res.status_code, 200) - self.assertTrue(res.data["running"]) - - t.status = AITask.Status.FAILED - t.error_message = "提取结果解析失败(模型未返回有效 JSON),请重试" - t.save(update_fields=["status", "error_message"]) - res = self.client.get(f"/api/projects/{project.id}/extract-status/") self.assertFalse(res.data["running"]) - self.assertEqual(res.data["status"], "failed") - self.assertEqual(res.data["error"]["code"], "unknown") - self.assertNotIn("有效 JSON", res.data["error_message"]) + self.assertEqual(res.data["status"], "succeeded") + self.assertTrue(any(e.get("name") == "男主" for e in res.data["entities"])) + @patch("apps.ai.services._store_generated_media") @patch("apps.ai.services.get_image_provider") diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index f514ca9..aacedef 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -928,29 +928,28 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): @action(detail=True, methods=["post"], url_path="extract-entities") def extract_entities_action(self, request, pk=None): - """从已定稿脚本提取角色 / 场景实体(独立步,进资产阶段时调)——**异步**:豆包思考模型流式抽取较慢, - Web 层只建任务秒回(不再 502),慢活交 worker(run_extract_entities_task)。前端拿 task_id 轮询 - extract-status 取结果。已有在途提取则复用(防刷新后重点重复扣费)。落库覆盖 project.metadata + - 每镜 entity_refs;**商品不提**(参考图层用真实主图)。""" - require_worker() # 异步提取依赖 worker 兜底执行,没 worker 直接拒绝(否则任务永远 RESERVED) + """从已定稿脚本**本地**拆出角色 / 场景(不调模型、不扣积分)。 + 读脚本自带 entities,缺则按分镜补最少 1 角色 + 1 场景;商品不提。""" project = self.get_object() try: task = submit_extract_entities(project=project, user=request.user) - except ValueError as exc: # 无脚本 / 无分镜 / 无模型 / 额度不足,立即反馈 - message = str(exc) - internal_kind = ( - "user_credit_insufficient" if "额度不足" in message - else "model_unavailable" if "模型" in message - else "invalid_input" - ) + except ValueError as exc: public_error = classify_generation_error( - exc, operation="entity_extract", internal_kind=internal_kind + exc, operation="entity_extract", internal_kind="invalid_input" ) return Response( {"detail": public_error.fallback_message, "error": public_error.as_dict()}, status=status.HTTP_400_BAD_REQUEST, ) - return Response({"task_id": str(task.id), "status": task.status}) + project.refresh_from_db() + entities = (task.response_payload or {}).get("entities") or (project.metadata or {}).get("script_entities") or [] + return Response({ + "task_id": str(task.id), + "status": task.status, + "mode": "local", + "entities": entities, + }) + @action(detail=True, methods=["get"], url_path="extract-status") def extract_status_action(self, request, pk=None): diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 746899c..57d304c 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -28,7 +28,6 @@ import { AccountPage, AssetFactoryPage, AuthScreen, - Dashboard, FreeCreatePage, QuickCreatePage, OmniCreatePage, @@ -122,6 +121,12 @@ export function App() { const [projects, setProjects] = useState([]); const [projectTotal, setProjectTotal] = useState(0); const [modelConfigs, setModelConfigs] = useState([]); + + const refreshModelConfigs = useCallback(() => { + void api.modelConfigs() + .then((modelData) => setModelConfigs(modelData?.results || [])) + .catch(() => undefined); + }, []); const [billing, setBilling] = useState(null); const [unreadCount, setUnreadCount] = useState(0); // YYX#row22:未读生成任务 —— 导航「图片生成」总数 + 每个商品的未读分数(商品角标) @@ -344,23 +349,39 @@ export function App() { if (user.is_platform_admin && !team && route.admin === undefined) { navigateAdmin("", { replace: true }); } else if (!user.is_platform_admin && route.admin !== undefined) { - navigate("dashboard", { replace: true }); + navigate("omniCreate", { replace: true }); } // navigate/navigateAdmin 为组件内函数,故意不入依赖避免每次渲染重跑 // eslint-disable-next-line react-hooks/exhaustive-deps }, [booting, user, team, route.admin]); - // 子账号(非主账号)纠回:团队 / 消费 页仅主账号(owner=超管)可见,子账号直接拉回工作台(PMC#3)。 + // 子账号(非主账号)纠回:团队 / 消费 页仅主账号(owner=超管)可见,子账号直接拉回全能创作(PMC#3)。 // role 为空时(身份尚未带回角色)不拦,避免误纠超管。 useLayoutEffect(() => { if (booting || !user || !role) return; if (!isOwner && isOwnerOnlyPage(page)) { - navigate("dashboard", { replace: true }); + navigate("omniCreate", { replace: true }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [booting, user, role, isOwner, page]); + // 工作台暂时隐藏:普通入口落到 dashboard 时改走全能创作。 + // /admin/* 故意用 page=dashboard + route.admin 占位,绝不能再踢去 omni,否则会和上面的超管 gating 对踢死循环。 + useLayoutEffect(() => { + if (booting || !user || page !== "dashboard") return; + if (route.admin !== undefined) return; + navigate("omniCreate", { replace: true }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [booting, user, page, route.admin]); + // Load preferences + sessions when entering settings. + // 进创作相关页时重拉模型目录,避免后台刚改能力/积分,前端还捧着首屏缓存 + useEffect(() => { + if (!authed || !dataLoaded) return; + if (!["freeCreate", "omniCreate", "omniSession", "omniHistory", "quickCreate", "videoReplace", "pipeline"].includes(page)) return; + refreshModelConfigs(); + }, [authed, page, dataLoaded, refreshModelConfigs]); + useEffect(() => { if (!authed || (page !== "settings" && page !== "settingsNotify")) return; loadSettingsData(); @@ -856,7 +877,7 @@ export function App() { navigateAdmin("", { replace: true }); return; } - navigate("dashboard", { replace: true }); + navigate("omniCreate", { replace: true }); // 先把工作台必需数据拉好,这期间「登录页」保持「登录成功,正在进入工作台…」提示(authed 仍 false → AuthScreen 不卸载), // 数据就绪后才揭开外壳直接进有数据的工作台 —— 避免中间闪一个空白「加载中…」页(PMC#7)。 try { @@ -923,9 +944,10 @@ export function App() { const currentTeam: Team = team; function renderPage() { - switch (page) { + switch (page) { case "dashboard": - return ; + // 工作台隐藏期间不渲染该页,上面的 effect 会改到全能创作 + return null; case "products": return ( ); case "omniCreate": - return setNotice({ type, text })} />; + return setNotice({ type, text })} />; case "omniHistory": return setNotice({ type, text })} />; case "omniSession": @@ -1089,6 +1111,7 @@ export function App() { firstMessage={route.firstMessage} firstRefs={route.firstRefs} firstUploads={route.firstUploads} + modelConfigs={modelConfigs} navigate={navigate} onNotify={(type, text) => setNotice({ type, text })} /> @@ -1140,7 +1163,7 @@ export function App() { case "settingsNotify": return setNotice({ type: "success", text })} onLogout={logout} />; default: - return ; + return setNotice({ type, text })} />; } } @@ -1182,6 +1205,7 @@ export function App() { scriptModelName={publicModelDisplayName(pipelineTextModel, "AI")} textModels={modelConfigs.filter((m) => m.capability === "text" && m.status === "active")} videoModels={modelConfigs.filter((m) => m.capability === "video" && m.status === "active")} + imageModels={modelConfigs.filter((m) => m.capability === "image" && m.status === "active")} loading={loading} navigate={navigate} onBack={() => goBack("projects")} diff --git a/core/frontend/src/ai-tools-page.css b/core/frontend/src/ai-tools-page.css index bf9a2b5..00dfc27 100644 --- a/core/frontend/src/ai-tools-page.css +++ b/core/frontend/src/ai-tools-page.css @@ -1760,28 +1760,41 @@ .image-workbench .ic-param.open .ic-param-btn svg { transform: rotate(180deg); } .image-workbench .ic-param-menu { position: absolute; bottom: calc(100% + 6px); left: -2px; - min-width: 140px; + min-width: max(100%, 180px); + width: max-content; + max-width: min(280px, calc(100vw - 24px)); background: var(--surface); - border: 1px solid var(--border-faint); - border-radius: var(--r-md); - box-shadow: 0 6px 24px rgba(0, 0, 0, .08); - padding: 4px; + border: 1px solid rgba(34, 42, 54, 0.10); + border-radius: 10px; + box-shadow: 0 12px 30px rgba(20, 27, 38, 0.12); + padding: 6px; display: none; - z-index: 30; + z-index: 40; } -.image-workbench .ic-param.open .ic-param-menu { display: block; } +.image-workbench .ic-param.open .ic-param-menu { display: grid; gap: 3px; } .image-workbench .ic-param-menu .mi { width: 100%; + min-height: 36px; display: flex; align-items: center; gap: 8px; padding: 7px 10px; - border: 0; border-radius: var(--r-sm); + border: 0; border-radius: 8px; background: transparent; font-size: 13px; color: var(--accent-black); font-family: inherit; text-align: left; cursor: pointer; + white-space: nowrap; +} +.image-workbench .ic-param-menu .mi:hover { background: var(--black-alpha-4); } +.image-workbench .ic-param-menu .mi.selected { + background: var(--heat-8); + color: var(--heat); + font-weight: 600; +} +.image-workbench .ic-param-menu .mi .mi-check { + margin-left: auto; + flex: 0 0 auto; + visibility: hidden; + color: var(--heat); } -.image-workbench .ic-param-menu .mi:hover { background: var(--background-lighter); } -.image-workbench .ic-param-menu .mi.selected { color: var(--heat); font-weight: 600; } -.image-workbench .ic-param-menu .mi .mi-check { margin-left: auto; visibility: hidden; color: var(--heat); } .image-workbench .ic-param-menu .mi.selected .mi-check { visibility: visible; } /* ============================================================ @@ -3040,7 +3053,9 @@ display: flex; flex-direction: column; padding: 14px; + min-width: 0; min-height: 0; + overflow: hidden; } .yz-image .new-conversation { height: 46px; @@ -3070,24 +3085,38 @@ gap: 4px; } .yz-image .history-item { + min-width: 0; + width: 100%; + box-sizing: border-box; display: flex; align-items: center; gap: 8px; - padding: 10px; + padding: 10px 12px; border-radius: 10px; color: var(--text); cursor: pointer; + overflow: hidden; } +.yz-image .history-item:hover { background: rgba(34, 42, 54, 0.045); } .yz-image .history-item.active { background: rgba(0, 47, 167, 0.075); } .yz-image .history-item .nm { min-width: 0; - flex: 1; + flex: 1 1 auto; overflow: hidden; font-size: 13px; + line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; } -.yz-image .history-item .ic-conv-acts { display: flex; gap: 2px; } +.yz-image .history-item.active .nm { color: var(--klein); font-weight: 600; } +.yz-image .history-item .ic-conv-acts { + flex: 0 0 auto; + display: none; + align-items: center; + gap: 2px; +} +.yz-image .history-item:hover .ic-conv-acts, +.yz-image .history-item:focus-within .ic-conv-acts { display: inline-flex; } .yz-image .history-empty { margin: 6px 4px; color: var(--muted); @@ -3419,7 +3448,12 @@ color: var(--klein); background: rgba(0, 47, 167, 0.075); } -.yz-image .ic-param-menu .mi.selected { color: var(--klein); } +.yz-image .ic-param-menu .mi.selected { + background: rgba(0, 47, 167, 0.08); + color: var(--klein); +} +.yz-image .ic-param-menu .mi.selected .mi-check { color: var(--klein); } + .yz-image .image-reference-thumbs button { position: absolute; top: 0; diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index b5a2c3b..8e5e84f 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -669,7 +669,7 @@ export const api = { // 独立实体提取(**异步**):只提交任务、秒回 task_id(慢活在 worker 跑,不再 502);已有在途则复用(防重复扣费)。 // 进度/结果用 extractStatus 轮询。商品不提(参考图层用真实主图)。 extractEntities(projectId: string) { - return request<{ task_id: string; status: string }>(`/api/projects/${projectId}/extract-entities/`, { method: "POST" }); + return request<{ task_id: string; status: string; mode?: string; entities?: Array<{ id: string; type: "character" | "scene"; name: string; visual_prompt: string; ref_index: number }> }>(`/api/projects/${projectId}/extract-entities/`, { method: "POST" }); }, // 实体提取进度:running=有在途任务(刷新后据此重建 loading);否则读最近一次成败,成功回带 entities 供「提取+生成」继续出图。 extractStatus(projectId: string) { @@ -947,7 +947,8 @@ export const api = { return request(`/api/billing/trend/${range ? `?range=${range}` : ""}`); }, modelConfigs() { - return request>("/api/ai/models/"); + // 创作页下拉依赖完整 active 目录;加大 page_size 避免默认分页截断 + return request>("/api/ai/models/?page_size=200"); }, aiTasks() { // 生图工作室的任务中心:只看生图任务(模特上身图/平台套图/图片创作 = person_image / @@ -1335,7 +1336,7 @@ export const adminApi = { const q = qs.toString(); return request(`/api/admin/models/${q ? `?${q}` : ""}`); }, - createModel(payload: { provider: string; name: string; display_name: string; capability: string; endpoint?: string; unit_price?: string; status?: string }) { + createModel(payload: { provider: string; name: string; display_name: string; capability: string; endpoint?: string; unit_price?: string; status?: string; metadata?: Record }) { return request("/api/admin/models/", { method: "POST", body: JSON.stringify(payload) }); }, updateModel(id: string, payload: Record) { diff --git a/core/frontend/src/components/app-shell.tsx b/core/frontend/src/components/app-shell.tsx index 7ec2050..fafb5a4 100644 --- a/core/frontend/src/components/app-shell.tsx +++ b/core/frontend/src/components/app-shell.tsx @@ -11,7 +11,6 @@ const SIDEBAR_COLLAPSED_KEY = "airshelf:sidebar-collapsed"; // 全局命令面板(Ctrl K / 点搜索框)—— 忠实搬设计稿 SHELL_COMMANDS,href 改成真路由导航 type Command = { id: string; group: string; label: string; sub: string; page: Page; icon: string; key?: string }; const SHELL_COMMANDS: Command[] = [ - { id: "dashboard", group: "导航", label: "工作台", sub: "任务队列、今日消耗、项目进度", page: "dashboard", icon: "dashboard", key: "D" }, { id: "omni-create", group: "导航", label: "全能创作", sub: "预设工作流 + 对话式 Agent", page: "omniCreate", icon: "sparkles", key: "O" }, { id: "omni-history", group: "导航", label: "创作历史", sub: "查看与继续独立会话", page: "omniHistory", icon: "history", key: "H" }, { id: "products", group: "导航", label: "商品库", sub: "管理 SKU、商品图册、卖点信息", page: "products", icon: "package", key: "P" }, @@ -225,7 +224,6 @@ export function AccountMenu({ open, anchorRect, onClose, navigate, logout, user, type NavDef = { id: string; page: Page; label: string; icon: string; badge?: number }; const NAV: NavDef[] = [ - { id: "dashboard", page: "dashboard", label: "工作台", icon: "dashboard" }, { id: "products", page: "products", label: "商品库", icon: "package" }, { id: "models", page: "models", label: "模特库", icon: "model" }, { id: "projects", page: "projects", label: "视频创作", icon: "clapperboard" }, @@ -281,7 +279,6 @@ export function topModuleForPage(page: Page): TopModule | null { } const MODE_TABS: { id: TopModule; label: string; page: Page }[] = [ - { id: "workbench", label: "工作台", page: "dashboard" }, { id: "omni", label: "全能创作", page: "omniCreate" }, { id: "image", label: "图片创作", page: "assetFactory" }, { id: "video", label: "视频创作", page: "projects" }, @@ -365,10 +362,9 @@ export function ModeTabs({ active, navigate }: { active: TopModule | null; navig @@ -567,7 +563,7 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p {mobileNavOpen &&
setMobileNavOpen(false)} />}
- - ≈ {tokens.toLocaleString()} tokens · {points.toLocaleString()} 积分 - - + {points > 0 ? ( + + + ) : null}
) : null} + {/* 存模板 / 重生成 / 确认下一步:有镜头脚本后才显示;积分提示已挪到右侧「确定」旁 */} + {currentScript ? (
-
脚本生成预计消耗 {pts(10)} 积分 / 次,失败不扣除
+
重新生成预计消耗 {pts(100)} 积分 / 次,失败不扣除
- + -
+ ) : null} {/* ============= STAGE 2 · 基础资产(真实 base_asset_groups,按 kind 分组)============= */} @@ -3439,8 +3461,7 @@ export function PipelinePage(props: { || assetUrl(productCover); // 商品卡显示商品主图,三视图在右侧面板 const triViewGen = `${productName} 商品三视图,从左到右:正面 / 侧面 / 背面,统一光照,白色背景,16:9`; const runProductTri = () => { setTriPanelOpen(true); setTriPreviewId(null); void genBaseAsset("product", triViewGen, undefined, "tri:product"); }; - // 实体提取闸门:没走过正式提取步(entities_extracted)且没生成任何基础资产 → 盖蒙版 + 三按钮,不自动花钱。 - // 用 entities_extracted 标记而非 script_entities 存在性:脚本生成期也可能吐过不稳的 entities,那不算正式提取。 + // 实体整理闸门:定稿时一般已本地落 entities_extracted;未落且无资产时盖蒙版。整理免费、不调模型。 const entitiesExtracted = project.metadata?.entities_extracted === true; const hasAnyAsset = KIND_ORDER.some((k) => groupsByKind(k).length > 0); const gateVisible = extractState === "running" || (!entitiesExtracted && !hasAnyAsset); @@ -3476,16 +3497,16 @@ export function PipelinePage(props: {
-
{extractMsg || "正在提取角色 / 场景"}
+
{extractMsg || "正在整理角色 / 场景"}
) : ( <> -
先从剧本认出角色 / 场景
-
AI 认出角色 / 场景并出提示词,稍后你再逐个生成
- -
{pts(10)} 积分/次 · 失败不扣
+
从剧本整理角色 / 场景
+
直接读取脚本里的角色与场景,不调用模型、不扣积分
+ +
免费 · 本地整理
{extractErr &&
{extractErr}
} )} @@ -3501,7 +3522,7 @@ export function PipelinePage(props: {
{(entitiesExtracted || hasAnyAsset) ? ( ) : null}
资产完成度{assetDone} / {assetTotal}
@@ -3578,7 +3599,7 @@ export function PipelinePage(props: { - {pts(20)} 积分 / 次 + {pts(imageUnitPoints)} 积分 / 次
) : null} {!isLocalProduct ? ( @@ -3780,7 +3801,6 @@ export function PipelinePage(props: { })}