大量优化修改扣积分规则
This commit is contained in:
@@ -4,6 +4,7 @@ from rest_framework import serializers
|
|||||||
|
|
||||||
from apps.accounts.models import Team, TeamMember, User
|
from apps.accounts.models import Team, TeamMember, User
|
||||||
from apps.ai.model_routing import model_metadata_errors, provider_metadata_errors
|
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.ai.models import AIModelAttempt, AITask, ModelConfig, ModelProvider, PromptTemplate, QualityWord
|
||||||
from apps.assets.models import Asset
|
from apps.assets.models import Asset
|
||||||
from apps.billing.models import CreditLedger, QuotaPolicy
|
from apps.billing.models import CreditLedger, QuotaPolicy
|
||||||
@@ -247,12 +248,35 @@ class AdminModelConfigSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
attrs = super().validate(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", ""))
|
capability = attrs.get("capability", getattr(self.instance, "capability", ""))
|
||||||
metadata = attrs.get("metadata", getattr(self.instance, "metadata", {}))
|
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:
|
if errors:
|
||||||
raise serializers.ValidationError({"metadata": list(errors)})
|
raise serializers.ValidationError({"metadata": errors})
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from apps.accounts.audit import log_admin_action
|
|||||||
from apps.accounts.models import Invitation, Team, User
|
from apps.accounts.models import Invitation, Team, User
|
||||||
from apps.accounts.permissions import IsPlatformAdmin
|
from apps.accounts.permissions import IsPlatformAdmin
|
||||||
from apps.accounts.serializers import InvitationSerializer
|
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.ai.models import AITask, ModelConfig, ModelProvider, PromptTemplate, QualityWord
|
||||||
from apps.assets.models import Asset
|
from apps.assets.models import Asset
|
||||||
from apps.assets.review import poll_asset_review, submit_asset_for_review
|
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_LIMIT = 20
|
||||||
_ADMIN_TASK_POLL_STATUSES = (AITask.Status.SUBMITTED, AITask.Status.POLLING)
|
_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():
|
def _team_qs():
|
||||||
@@ -463,7 +472,9 @@ def admin_tasks(request):
|
|||||||
.order_by("-created_at")
|
.order_by("-created_at")
|
||||||
)
|
)
|
||||||
st = request.query_params.get("status")
|
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)
|
qs = qs.filter(status=st)
|
||||||
tt = request.query_params.get("task_type")
|
tt = request.query_params.get("task_type")
|
||||||
if tt in dict(AITask.Type.choices):
|
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)
|
return Response(AdminModelProviderSerializer(ModelProvider.objects.annotate(model_count_anno=Count("models", distinct=True)).get(id=obj.id)).data)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@api_view(["GET", "POST"])
|
@api_view(["GET", "POST"])
|
||||||
@permission_classes([IsPlatformAdmin])
|
@permission_classes([IsPlatformAdmin])
|
||||||
def admin_models(request):
|
def admin_models(request):
|
||||||
if request.method == "GET":
|
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")
|
prov = request.query_params.get("provider")
|
||||||
if prov:
|
if prov:
|
||||||
qs = qs.filter(provider_id=prov)
|
qs = qs.filter(provider_id=prov)
|
||||||
@@ -787,6 +799,7 @@ def admin_models(request):
|
|||||||
serializer = AdminModelConfigSerializer(data=request.data)
|
serializer = AdminModelConfigSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
obj = serializer.save()
|
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}")
|
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)
|
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":
|
if request.method == "DELETE":
|
||||||
log_admin_action(request, "model.delete", target_type="model_config", target_id=obj.id, target_name=obj.name)
|
log_admin_action(request, "model.delete", target_type="model_config", target_id=obj.id, target_name=obj.name)
|
||||||
obj.delete()
|
obj.delete()
|
||||||
|
invalidate_model_catalog_cache()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
serializer = AdminModelConfigSerializer(obj, data=request.data, partial=True)
|
serializer = AdminModelConfigSerializer(obj, data=request.data, partial=True)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
serializer.save()
|
serializer.save()
|
||||||
|
invalidate_model_catalog_cache()
|
||||||
log_admin_action(request, "model.update", target_type="model_config", target_id=obj.id, target_name=obj.name)
|
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)
|
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)
|
ModelConfig.objects.filter(capability=obj.capability).exclude(id=obj.id).update(is_default=False)
|
||||||
obj.is_default = True
|
obj.is_default = True
|
||||||
obj.save(update_fields=["is_default", "updated_at"])
|
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}")
|
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)
|
return Response(AdminModelConfigSerializer(ModelConfig.objects.select_related("provider").get(id=obj.id)).data)
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,32 @@ IMAGE_MODEL_BY_LABEL = {
|
|||||||
# 「智能时长」= 交给我们定,取一个口播讲得完又不烧钱的中间值
|
# 「智能时长」= 交给我们定,取一个口播讲得完又不烧钱的中间值
|
||||||
SMART_DURATION = 15
|
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):
|
class AgentError(Exception):
|
||||||
"""Agent 循环里的业务错误,已经是可以直接给用户看的中文。"""
|
"""Agent 循环里的业务错误,已经是可以直接给用户看的中文。"""
|
||||||
@@ -353,8 +379,9 @@ def tool_schemas(context: AgentContext) -> list[dict]:
|
|||||||
"name": "write_plan",
|
"name": "write_plan",
|
||||||
"description": (
|
"description": (
|
||||||
"写「视频最终方案」卡并请用户确认。**这是出片前的最后一步**,调完会等用户点确认,"
|
"写「视频最终方案」卡并请用户确认。**这是出片前的最后一步**,调完会等用户点确认,"
|
||||||
"确认后平台直接按 video_prompt 出片,你不会再有插话机会 —— 所以 video_prompt "
|
"确认后平台直接按 video_prompt 出片,你不会再有插话机会。"
|
||||||
"必须是完整、可独立执行的成片指令。先调 write_strategy 再调它。"
|
"video_prompt 按系统里的「出片脚本写法」写成口播秒级分镜(专业创作同口径),不要只写大纲。"
|
||||||
|
"先调 write_strategy 再调它。"
|
||||||
),
|
),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -383,9 +410,11 @@ def tool_schemas(context: AgentContext) -> list[dict]:
|
|||||||
"video_prompt": {
|
"video_prompt": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": (
|
"description": (
|
||||||
"交给出片模型的完整指令:秒级分镜(每镜画面/动作/机位/光线)、口播原文、"
|
"交给出片模型的完整口播带货指令(对齐专业创作口径)。"
|
||||||
"风格锚点、一致性要求。已 @ 的素材会自动作为参考图附上,"
|
"必须含:总时长与画幅、整体光线色调、按 0-Ns 分段的秒级分镜"
|
||||||
"不要在这里重复描述它们的长相。**不要写字幕相关要求。**"
|
"(每段写清景别/机位/运镜/具体动作/信息变化)、口播原文、一个主卖点与可见证据、口语 CTA。"
|
||||||
|
"禁止字幕/花字/贴片及「无字幕」字样;禁止详情页腔与「大家好」开场。"
|
||||||
|
"已 @ 素材会自动作参考图,勿重描长相。"
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -464,9 +493,68 @@ def _coerce_fields(raw) -> list[dict]:
|
|||||||
return fields
|
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:
|
def video_model_name(params: dict) -> str:
|
||||||
"""会话参数里的模型 label → 火山模型名。认不出就回落 2.5(最长 30 秒那档)。"""
|
"""会话参数里的模型 label → 供应商模型名。
|
||||||
return VIDEO_MODEL_BY_LABEL.get(str(params.get("model") or ""), DEFAULT_VIDEO_MODEL)
|
|
||||||
|
先认历史写死映射,再按 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:
|
def video_duration(params: dict) -> int:
|
||||||
@@ -531,8 +619,9 @@ def _run_generate_image(context: AgentContext, args: dict) -> tuple[dict, list]:
|
|||||||
mode="image",
|
mode="image",
|
||||||
count=count,
|
count=count,
|
||||||
ratio=params.get("ratio") or None,
|
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,
|
reference_image_ids=reference_image_ids or None,
|
||||||
|
feature="omni_create",
|
||||||
)
|
)
|
||||||
context.generations_used += 1
|
context.generations_used += 1
|
||||||
return (
|
return (
|
||||||
@@ -587,6 +676,27 @@ def estimate_video_credits(context: AgentContext) -> int:
|
|||||||
return 0
|
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):
|
def submit_confirmed_video(*, conversation: CreationConversation, user, confirm_message: CreationMessage):
|
||||||
"""用户点了确认 → 直接按方案卡里存好的 video_prompt 出片。
|
"""用户点了确认 → 直接按方案卡里存好的 video_prompt 出片。
|
||||||
|
|
||||||
@@ -764,7 +874,18 @@ def build_system_prompt(context: AgentContext) -> str:
|
|||||||
"- 用户说改时长/模型/比例/分辨率:立刻 ask_user,type=single 给出选项;选完后旧方案作废,必须按新参数重新 write_plan。不要让用户去点底部菜单。",
|
"- 用户说改时长/模型/比例/分辨率:立刻 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([
|
lines.extend([
|
||||||
"",
|
"",
|
||||||
"【出图】",
|
"【出图】",
|
||||||
@@ -1106,20 +1227,24 @@ def _dispatch_tool(context: AgentContext, name: str, args: dict) -> tuple[dict,
|
|||||||
if not prompt:
|
if not prompt:
|
||||||
return {"payload": {"error": "生成失败:模型没有给出画面描述"}}, False
|
return {"payload": {"error": "生成失败:模型没有给出画面描述"}}, False
|
||||||
# 出图也走确认卡:用户先看当前模型/比例/张数,点了才提交。
|
# 出图也走确认卡:用户先看当前模型/比例/张数,点了才提交。
|
||||||
|
credits = estimate_image_credits(context)
|
||||||
confirm = append_message(
|
confirm = append_message(
|
||||||
context.conversation, role="assistant",
|
context.conversation, role="assistant",
|
||||||
kind=CreationMessage.Kind.CONFIRM,
|
kind=CreationMessage.Kind.CONFIRM,
|
||||||
payload={
|
payload={
|
||||||
"kind": "image",
|
"kind": "image",
|
||||||
"label": "开始生成",
|
"label": "开始生成",
|
||||||
"estimated_credits": 0,
|
"estimated_credits": credits,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"submitted": False,
|
"submitted": False,
|
||||||
"params": snapshot_session_params(context.conversation),
|
"params": snapshot_session_params(context.conversation),
|
||||||
"param_options": confirm_param_options(False),
|
"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": {"awaiting_confirmation": True}, "_events": events}, True
|
||||||
|
|
||||||
return {"payload": {"error": f"未知工具 {name}"}}, False
|
return {"payload": {"error": f"未知工具 {name}"}}, False
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -25,7 +25,7 @@ from django.utils import timezone
|
|||||||
|
|
||||||
from apps.assets.models import Asset, AssetFile, FreeAsset, FreeAssetGroup
|
from apps.assets.models import Asset, AssetFile, FreeAsset, FreeAssetGroup
|
||||||
from apps.assets.storage import TosStorage
|
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 apps.billing.services.ledger import charge_reserved_credit, release_credit, reserve_credit
|
||||||
|
|
||||||
from .models import AITask, ModelConfig
|
from .models import AITask, ModelConfig
|
||||||
@@ -530,7 +530,7 @@ def submit_free_video(*, team, user, params: dict) -> AITask:
|
|||||||
references=built["snapshots"],
|
references=built["snapshots"],
|
||||||
team=team,
|
team=team,
|
||||||
)
|
)
|
||||||
reserve_amount = video_reserve_amount(quote.points)
|
reserve_amount = video_reserve_amount(quote.points, rule=quote.meta.get("rule"))
|
||||||
|
|
||||||
request_payload = {
|
request_payload = {
|
||||||
"feature": feature,
|
"feature": feature,
|
||||||
@@ -546,9 +546,8 @@ def submit_free_video(*, team, user, params: dict) -> AITask:
|
|||||||
"generate_audio": generate_audio,
|
"generate_audio": generate_audio,
|
||||||
"search_mode": search_mode,
|
"search_mode": search_mode,
|
||||||
"estimated_tokens": tokens,
|
"estimated_tokens": tokens,
|
||||||
# 团队价格系数快照:按实结算用它,中途改价不影响在途任务
|
# 计价快照:挂牌秒价/团队系数下单时钉死,结算只认这些
|
||||||
"price_multiplier": quote.meta.get("price_multiplier", "1"),
|
**video_quote_payload(quote),
|
||||||
"points_per_yuan_snapshot": quote.meta.get("rate", ""),
|
|
||||||
"references": built["snapshots"],
|
"references": built["snapshots"],
|
||||||
"model_routing_v1": True,
|
"model_routing_v1": True,
|
||||||
}
|
}
|
||||||
@@ -646,7 +645,7 @@ def start_pending_free_video(task: AITask) -> AITask:
|
|||||||
references=built["snapshots"],
|
references=built["snapshots"],
|
||||||
team=task.team,
|
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():
|
with transaction.atomic():
|
||||||
locked = (
|
locked = (
|
||||||
@@ -671,6 +670,7 @@ def start_pending_free_video(task: AITask) -> AITask:
|
|||||||
next_payload["references"] = built["snapshots"]
|
next_payload["references"] = built["snapshots"]
|
||||||
next_payload["estimated_tokens"] = tokens
|
next_payload["estimated_tokens"] = tokens
|
||||||
next_payload["review_pending"] = False
|
next_payload["review_pending"] = False
|
||||||
|
next_payload.update(video_quote_payload(quote))
|
||||||
locked.request_payload = next_payload
|
locked.request_payload = next_payload
|
||||||
locked.estimated_cost = quote.points
|
locked.estimated_cost = quote.points
|
||||||
locked.status = AITask.Status.RESERVED
|
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)
|
total_tokens = int(usage.get("total_tokens") or 0)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
total_tokens = 0
|
total_tokens = 0
|
||||||
with_video_ref = any((r or {}).get("type") == "video" for r in payload.get("references") or [])
|
settle = settle_video_from_payload(actual_model, payload=payload, tokens=total_tokens)
|
||||||
resolution = payload.get("resolution") or "720p"
|
if settle.meta.get("rule") == "missing_usage":
|
||||||
if total_tokens > 0:
|
actual, base_cost = locked.estimated_cost, locked.base_cost
|
||||||
from decimal import Decimal
|
else:
|
||||||
|
|
||||||
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")),
|
|
||||||
)
|
|
||||||
actual, base_cost = settle.points, settle.base_cost_yuan
|
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"):
|
if settle.meta.get("rate"):
|
||||||
payload["points_per_yuan_snapshot"] = settle.meta["rate"]
|
payload["points_per_yuan_snapshot"] = settle.meta["rate"]
|
||||||
else:
|
|
||||||
actual, base_cost = locked.estimated_cost, locked.base_cost
|
|
||||||
seed_out = response.get("seed")
|
seed_out = response.get("seed")
|
||||||
if seed_out is not None:
|
if seed_out is not None:
|
||||||
payload["seed_used"] = seed_out
|
payload["seed_used"] = seed_out
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1612,7 +1612,17 @@ def persist_script_draft(*, project, user, task, draft: dict, source: str):
|
|||||||
dialogue=seg.get("dialogue") or [],
|
dialogue=seg.get("dialogue") or [],
|
||||||
product_points=[],
|
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, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT)
|
||||||
stage.status = ProjectStage.Status.NEEDS_REVIEW
|
stage.status = ProjectStage.Status.NEEDS_REVIEW
|
||||||
stage.save(update_fields=["status", "updated_at"])
|
stage.save(update_fields=["status", "updated_at"])
|
||||||
|
|||||||
@@ -1146,16 +1146,13 @@ def get_inflight_extraction(project):
|
|||||||
|
|
||||||
|
|
||||||
def submit_extract_entities(*, project, user) -> AITask:
|
def submit_extract_entities(*, project, user) -> AITask:
|
||||||
"""提交独立实体提取步(**异步**)。读已定稿(或最新)脚本 → 校验 + 建 RESERVED 任务 + 预留额度(秒级),
|
"""从已定稿脚本**本地**拆出角色/场景(不调模型、不扣积分)。
|
||||||
慢活(豆包思考模型流式抽取,可达数十秒)交给 Celery worker(run_extract_entities_task)跑。
|
|
||||||
|
|
||||||
这样 Web 层(gunicorn/nginx)不被数十秒的模型请求占住 → 不再 502。
|
脚本生成时已带结构化 entities;这里只做规范化 + 最少 1 角色/1 场景兜底 + 落库。
|
||||||
**防重复扣费**:本项目已有在途提取任务时直接复用它,不新建/不二次预扣
|
仍返回一条 SUCCEEDED 的 ENTITY_EXTRACTION 任务,方便前端轮询/审计口径不变。
|
||||||
(用户刷新后手痒重点、或并发点击都安全 —— 提取是实体唯一权威来源,本就只需跑一次)。
|
"""
|
||||||
校验失败(无脚本/无分镜/无模型/额度不足)抛 ValueError 由端点转 400;运行期失败由 worker 记进
|
from apps.ai.entity_local import materialize_script_entities
|
||||||
task.error_message,前端轮询 extract-status 读取。返回 AITask。"""
|
|
||||||
from apps.projects.models import ScriptVersion
|
from apps.projects.models import ScriptVersion
|
||||||
from apps.ai.tasks import extract_entities_task
|
|
||||||
|
|
||||||
script = (
|
script = (
|
||||||
ScriptVersion.objects.filter(project=project, is_adopted=True).order_by("-created_at").first()
|
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:
|
if script is None:
|
||||||
raise ValueError("请先生成并定稿脚本,再提取角色 / 场景")
|
raise ValueError("请先生成并定稿脚本,再提取角色 / 场景")
|
||||||
segments = list(script.segments.order_by("sort_order"))
|
if not script.segments.exists():
|
||||||
if not segments:
|
|
||||||
raise ValueError("脚本没有分镜,无法提取")
|
raise ValueError("脚本没有分镜,无法提取")
|
||||||
|
|
||||||
inflight = get_inflight_extraction(project)
|
entities = materialize_script_entities(project=project, script=script)
|
||||||
if inflight is not None:
|
|
||||||
return inflight # 已有提取在跑:复用,绝不二次预扣 / 重复出活
|
|
||||||
|
|
||||||
model_config = _resolve_extract_model_config()
|
model_config = _resolve_extract_model_config()
|
||||||
if model_config is None:
|
if model_config is None:
|
||||||
|
# AITask.model_config 非空;本地提取不调模型,但仍需一条配置挂审计任务
|
||||||
raise ValueError("没有可用的文本模型")
|
raise ValueError("没有可用的文本模型")
|
||||||
|
task = AITask.objects.create(
|
||||||
product = project.product
|
team=project.team,
|
||||||
if product is not None:
|
created_by=user,
|
||||||
sp = "、".join([p.title for p in product.selling_points.all()[:5]])
|
project=project,
|
||||||
prod_line = f"名称:{product.title}\n品类:{product.category or '未填'}\n卖点:{sp or '未填'}"
|
task_type=AITask.Type.ENTITY_EXTRACTION,
|
||||||
else:
|
status=AITask.Status.SUCCEEDED,
|
||||||
prod_line = "(无商品信息)"
|
model_config=model_config,
|
||||||
seg_lines = [
|
idempotency_key=f"entity_extraction:local:{project.id}:{uuid.uuid4()}",
|
||||||
f"镜{i} role={s.role or ''} narration={(s.narration or '').strip()} visual={(s.visual_prompt or '').strip()}"
|
request_payload={
|
||||||
for i, s in enumerate(segments)
|
"mode": "local",
|
||||||
]
|
"script_id": str(script.id),
|
||||||
user_msg = (
|
"entity_count": len(entities),
|
||||||
f"商品信息:\n{prod_line}\n\n分镜脚本(共 {len(segments)} 镜,index 从 0 开始):\n" + "\n".join(seg_lines)
|
},
|
||||||
|
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
|
return task
|
||||||
|
|
||||||
|
|
||||||
@@ -3181,7 +3156,7 @@ def submit_video_segment(
|
|||||||
# 视频段 token 计量计价(与自由创作同一成本表+同一毛利):按用户选定的比例/清晰度/目标时长预估,
|
# 视频段 token 计量计价(与自由创作同一成本表+同一毛利):按用户选定的比例/清晰度/目标时长预估,
|
||||||
# 预留=积分×buffer,终态按火山真实 usage.total_tokens 结算(poll_video_segment true-up)。
|
# 预留=积分×buffer,终态按火山真实 usage.total_tokens 结算(poll_video_segment true-up)。
|
||||||
# 这里终结了「视频 ¥1/段、成本 ¥15」的倒贴定价。
|
# 这里终结了「视频 ¥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(
|
est_tokens, quote = quote_video_estimate(
|
||||||
model_config,
|
model_config,
|
||||||
@@ -3204,7 +3179,7 @@ def submit_video_segment(
|
|||||||
task_type=AITask.Type.VIDEO_SEGMENT,
|
task_type=AITask.Type.VIDEO_SEGMENT,
|
||||||
model_config=model_config,
|
model_config=model_config,
|
||||||
quote=quote,
|
quote=quote,
|
||||||
reserve_amount=video_reserve_amount(quote.points),
|
reserve_amount=video_reserve_amount(quote.points, rule=quote.meta.get("rule")),
|
||||||
request_payload={
|
request_payload={
|
||||||
"model": model_config.name,
|
"model": model_config.name,
|
||||||
"endpoint": model_config.endpoint,
|
"endpoint": model_config.endpoint,
|
||||||
@@ -3213,8 +3188,7 @@ def submit_video_segment(
|
|||||||
"ratio": aspect_ratio,
|
"ratio": aspect_ratio,
|
||||||
"resolution": resolution,
|
"resolution": resolution,
|
||||||
"estimated_tokens": est_tokens,
|
"estimated_tokens": est_tokens,
|
||||||
# 团队价格系数快照:按实结算用它,中途改价不影响在途任务(jimeng 同款纪律)
|
**video_quote_payload(quote),
|
||||||
"price_multiplier": quote.meta.get("price_multiplier", "1"),
|
|
||||||
"video_segment_id": str(video_segment.id),
|
"video_segment_id": str(video_segment.id),
|
||||||
"reference_images": reference_images,
|
"reference_images": reference_images,
|
||||||
"model_routing_v1": True,
|
"model_routing_v1": True,
|
||||||
@@ -3375,34 +3349,26 @@ def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVers
|
|||||||
# 按火山真实 usage.total_tokens 结算(true-up,与自由创作同口径):
|
# 按火山真实 usage.total_tokens 结算(true-up,与自由创作同口径):
|
||||||
# 多退(charge 差额自动 RELEASE)/超预留 clamp(ledger 禁超扣,差额平台承担并告警)。
|
# 多退(charge 差额自动 RELEASE)/超预留 clamp(ledger 禁超扣,差额平台承担并告警)。
|
||||||
# usage 缺失(异常响应)回落预估价,不阻断出片。
|
# usage 缺失(异常响应)回落预估价,不阻断出片。
|
||||||
from apps.billing.pricing import quote_video_actual
|
|
||||||
|
|
||||||
reservation = locked_task.credit_reservation
|
reservation = locked_task.credit_reservation
|
||||||
payload = locked_task.request_payload or {}
|
payload = dict(locked_task.request_payload or {})
|
||||||
try:
|
try:
|
||||||
usage_tokens = int((response.get("usage") or {}).get("total_tokens") or 0)
|
usage_tokens = int((response.get("usage") or {}).get("total_tokens") or 0)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
usage_tokens = 0
|
usage_tokens = 0
|
||||||
if usage_tokens > 0:
|
settle = settle_video_from_payload(actual_model, payload=payload, tokens=usage_tokens)
|
||||||
settle = quote_video_actual(
|
if settle.meta.get("rule") == "missing_usage":
|
||||||
actual_model,
|
actual_points, base_cost = locked_task.estimated_cost, locked_task.base_cost
|
||||||
tokens=usage_tokens,
|
else:
|
||||||
with_video_ref=False,
|
|
||||||
resolution=str(payload.get("resolution") or "720p"),
|
|
||||||
multiplier=Decimal(str(payload.get("price_multiplier") or "1")),
|
|
||||||
)
|
|
||||||
actual_points, base_cost = settle.points, settle.base_cost_yuan
|
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:
|
if actual_points > reservation.amount:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"video segment task %s actual %s exceeds reserved %s, clamped",
|
"video segment task %s actual %s exceeds reserved %s, clamped",
|
||||||
locked_task.id, actual_points, reservation.amount,
|
locked_task.id, actual_points, reservation.amount,
|
||||||
)
|
)
|
||||||
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.status = AITask.Status.SUCCEEDED
|
||||||
locked_task.response_payload = response
|
locked_task.response_payload = response
|
||||||
locked_task.actual_cost = actual_points
|
locked_task.actual_cost = actual_points
|
||||||
@@ -3527,7 +3493,7 @@ def _reap_stale_standalone_image_tasks(*, team) -> None:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1, product_id: str | None = None, reference_product: bool = False, model_id: str | None = None, model_entity_id: str | None = None, ratio: str | None = None, image_model: str | None = None, conversation=None, reference_image_ids: list[str] | None = None, platform_id: str | None = None, batch_id: str | None = None, retry_of_task_id: str | None = None, tryon_prompt_v2_override: bool = False, tryon_ab: dict | None = None, 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 请求里只做「建任务 +
|
"""独立生图(图片创作 / 模特上身图 / 平台套图)改为**异步**:本函数在 Web 请求里只做「建任务 +
|
||||||
预留额度」这种秒级的活,真正 ~30s 的 ARK 出图交给 Celery worker(generate_standalone_image_task)。
|
预留额度」这种秒级的活,真正 ~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):
|
for index in range(count):
|
||||||
quote = quote_flat(model_config, team=team)
|
quote = quote_flat(model_config, team=team)
|
||||||
request_payload = {"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode, "index": index, "product_id": str(product_id) if product_id else None, "reference_product": bool(reference_product), "model_id": str(model_id) if model_id else None, "model_entity_id": str(model_entity_id) if model_entity_id else None, "batch_id": batch_id, "ratio": str(ratio) if ratio else None, "reference_image_ids": ref_ids, "platform_id": platform_key or None, "platform_name": platform_name or None}
|
request_payload = {"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode, "index": index, "product_id": str(product_id) if product_id else None, "reference_product": bool(reference_product), "model_id": str(model_id) if model_id else None, "model_entity_id": str(model_entity_id) if model_entity_id else None, "batch_id": batch_id, "ratio": str(ratio) if ratio else None, "reference_image_ids": ref_ids, "platform_id": platform_key or None, "platform_name": platform_name or None}
|
||||||
|
if feature:
|
||||||
|
request_payload["feature"] = str(feature)
|
||||||
if use_model_routing:
|
if use_model_routing:
|
||||||
request_payload["model_routing_v1"] = True
|
request_payload["model_routing_v1"] = True
|
||||||
if tryon_classification is not None:
|
if tryon_classification is not None:
|
||||||
@@ -3899,13 +3867,22 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
|||||||
asset_meta["batch_id"] = str(payload["batch_id"])
|
asset_meta["batch_id"] = str(payload["batch_id"])
|
||||||
if payload.get("model_entity_id"):
|
if payload.get("model_entity_id"):
|
||||||
asset_meta["model_entity_id"] = str(payload["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(
|
asset = Asset.objects.create(
|
||||||
id=asset_id, team=team, created_by=user, name=f"AI 生成 · {asset_label} · {index + 1}",
|
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,
|
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=asset_category, origin_task=task,
|
||||||
metadata=asset_meta,
|
metadata=asset_meta,
|
||||||
# 模特候选与项目角色均是功能性资料:候选保存后进入模特库,不进入 /library;
|
# 模特候选与项目角色均是功能性资料:候选保存后进入模特库,不进入 /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)
|
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 重试二次扣费)
|
except Exception as exc: # noqa: BLE001 — 失败要退费并把错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费)
|
||||||
|
|||||||
@@ -273,6 +273,7 @@ class GenerateImageTests(CreationAgentBaseTests):
|
|||||||
self.assertEqual(kwargs["reference_image_ids"], [str(self.product_asset.id)])
|
self.assertEqual(kwargs["reference_image_ids"], [str(self.product_asset.id)])
|
||||||
self.assertEqual(kwargs["ratio"], "1:1") # 会话级参数直接用,不再问用户
|
self.assertEqual(kwargs["ratio"], "1:1") # 会话级参数直接用,不再问用户
|
||||||
self.assertEqual(kwargs["count"], 1)
|
self.assertEqual(kwargs["count"], 1)
|
||||||
|
self.assertEqual(kwargs["feature"], "omni_create")
|
||||||
|
|
||||||
def test_session_image_count_overrides_model_count(self):
|
def test_session_image_count_overrides_model_count(self):
|
||||||
self.conversation.params = {"ratio": "1:1", "count": "2 张"}
|
self.conversation.params = {"ratio": "1:1", "count": "2 张"}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from django.utils import timezone
|
|||||||
|
|
||||||
from apps.assets.models import Asset, Model
|
from apps.assets.models import Asset, Model
|
||||||
from apps.billing.models import CreditAccount
|
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 apps.products.models import Product
|
||||||
|
|
||||||
from .free_video import (
|
from .free_video import (
|
||||||
@@ -1053,7 +1053,7 @@ def _legacy_start_pending_replace_shots(task):
|
|||||||
references=built["snapshots"],
|
references=built["snapshots"],
|
||||||
team=task.team,
|
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():
|
with transaction.atomic():
|
||||||
locked = (
|
locked = (
|
||||||
@@ -1083,6 +1083,7 @@ def _legacy_start_pending_replace_shots(task):
|
|||||||
next_payload["estimated_tokens"] = tokens
|
next_payload["estimated_tokens"] = tokens
|
||||||
next_payload["review_pending"] = False
|
next_payload["review_pending"] = False
|
||||||
next_payload["duration"] = billed_duration
|
next_payload["duration"] = billed_duration
|
||||||
|
next_payload.update(video_quote_payload(quote))
|
||||||
locked.request_payload = next_payload
|
locked.request_payload = next_payload
|
||||||
locked.estimated_cost = quote.points
|
locked.estimated_cost = quote.points
|
||||||
locked.status = AITask.Status.RESERVED
|
locked.status = AITask.Status.RESERVED
|
||||||
@@ -1262,7 +1263,6 @@ def complete_replace_shots(task):
|
|||||||
"""全部镜头出完:下载 → ffmpeg 拼接 → 转存 TOS → 按 tokens 合计结算。"""
|
"""全部镜头出完:下载 → ffmpeg 拼接 → 转存 TOS → 按 tokens 合计结算。"""
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from apps.billing.pricing import quote_video_actual
|
|
||||||
from apps.billing.services.ledger import charge_reserved_credit, release_credit
|
from apps.billing.services.ledger import charge_reserved_credit, release_credit
|
||||||
|
|
||||||
from .free_video import _notify_failure, _store_free_video_media
|
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)
|
total_tokens += int(item.get("tokens") or 0)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
resolution = payload.get("resolution") or "720p"
|
# 挂牌秒价按快照扣;无挂牌才按 tokens true-up
|
||||||
if total_tokens > 0:
|
if "with_ref_video" not in payload:
|
||||||
settle = quote_video_actual(
|
payload["with_ref_video"] = True # 替换片默认带视频参考
|
||||||
locked.model_config, tokens=total_tokens, with_video_ref=True,
|
settle = settle_video_from_payload(locked.model_config, payload=payload, tokens=total_tokens)
|
||||||
resolution=resolution, multiplier=Decimal(str(payload.get("price_multiplier") or "1")),
|
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
|
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"):
|
if settle.meta.get("rate"):
|
||||||
payload["points_per_yuan_snapshot"] = settle.meta["rate"]
|
payload["points_per_yuan_snapshot"] = settle.meta["rate"]
|
||||||
else:
|
|
||||||
actual, base_cost = locked.estimated_cost, locked.base_cost
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
locked = AITask.objects.select_for_update().get(id=locked.id)
|
locked = AITask.objects.select_for_update().get(id=locked.id)
|
||||||
if locked.status != AITask.Status.POSTPROCESSING:
|
if locked.status != AITask.Status.POSTPROCESSING:
|
||||||
@@ -1584,7 +1584,7 @@ def _create_reviewing_task(*, team, user, params: dict, digest_pending: bool = F
|
|||||||
references=references,
|
references=references,
|
||||||
team=team,
|
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()
|
account = CreditAccount.objects.filter(team=team).first()
|
||||||
available = (account.balance - account.reserved_balance) if account else Decimal("0")
|
available = (account.balance - account.reserved_balance) if account else Decimal("0")
|
||||||
if available < reserve_amount:
|
if available < reserve_amount:
|
||||||
@@ -1610,8 +1610,7 @@ def _create_reviewing_task(*, team, user, params: dict, digest_pending: bool = F
|
|||||||
"generate_audio": True,
|
"generate_audio": True,
|
||||||
"search_mode": "off",
|
"search_mode": "off",
|
||||||
"estimated_tokens": tokens,
|
"estimated_tokens": tokens,
|
||||||
"price_multiplier": quote.meta.get("price_multiplier", "1"),
|
**video_quote_payload(quote),
|
||||||
"points_per_yuan_snapshot": quote.meta.get("rate", ""),
|
|
||||||
"references": references,
|
"references": references,
|
||||||
"model_routing_v1": True,
|
"model_routing_v1": True,
|
||||||
"review_pending": True,
|
"review_pending": True,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from django.utils import timezone
|
|||||||
from apps.ai.models import AITask
|
from apps.ai.models import AITask
|
||||||
from apps.assets.models import Asset
|
from apps.assets.models import Asset
|
||||||
from apps.billing.models import CreditReservation
|
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
|
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)
|
total_tokens = int((response.get("usage") or {}).get("total_tokens") or 0)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
total_tokens = 0
|
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
|
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
|
return total_tokens, settle.base_cost_yuan
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1248,6 +1248,7 @@ class FreeVideoUploadView(APIView):
|
|||||||
class ModelConfigViewSet(ReadOnlyModelViewSet):
|
class ModelConfigViewSet(ReadOnlyModelViewSet):
|
||||||
# 按创建序固定排序:最早创建的 active 模型排第一 = 前端选择器默认项,与 get_default_model 口径一致。
|
# 按创建序固定排序:最早创建的 active 模型排第一 = 前端选择器默认项,与 get_default_model 口径一致。
|
||||||
# DeepSeek 已停用,下拉里不再出现。
|
# DeepSeek 已停用,下拉里不再出现。
|
||||||
|
# 列表短且高频(创作页下拉),整页结果缓存;后台改模型走 invalidate_model_catalog_cache。
|
||||||
queryset = (
|
queryset = (
|
||||||
ModelConfig.objects.select_related("provider")
|
ModelConfig.objects.select_related("provider")
|
||||||
.filter(status=ModelConfig.Status.ACTIVE)
|
.filter(status=ModelConfig.Status.ACTIVE)
|
||||||
@@ -1259,6 +1260,30 @@ class ModelConfigViewSet(ReadOnlyModelViewSet):
|
|||||||
search_fields = ["name", "display_name", "capability"]
|
search_fields = ["name", "display_name", "capability"]
|
||||||
ordering_fields = ["created_at", "display_name"]
|
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):
|
class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -10,8 +10,9 @@
|
|||||||
平台成本配在 metadata.pricing.base_cost_yuan(未配=0,毛利报表按未知处理)。
|
平台成本配在 metadata.pricing.base_cost_yuan(未配=0,毛利报表按未知处理)。
|
||||||
· 配音:metadata.pricing = {"mode":"per_chars","chars_per_unit":500,"points_per_unit":10,
|
· 配音:metadata.pricing = {"mode":"per_chars","chars_per_unit":500,"points_per_unit":10,
|
||||||
"min_units":1,"base_cost_yuan_per_unit":...},按字符数阶梯。
|
"min_units":1,"base_cost_yuan_per_unit":...},按字符数阶梯。
|
||||||
· 视频:metadata.pricing 是火山**成本价表**(元/百万tokens,见 apps/ai/video_pricing.py),
|
· 视频:优先 metadata.points_pricing 挂牌(分辨率积分/秒 × 时长;可配含视频参考价);
|
||||||
用户价 = ¥成本 × video_margin_multiplier → 积分。按真实 usage.total_tokens 结算。
|
未挂牌才走 metadata.pricing 火山成本表 × 毛利 → 积分,并按 usage.total_tokens true-up。
|
||||||
|
挂牌任务预留=精确积分、结算认下单快照;token 任务预留仍 × video_reserve_buffer。
|
||||||
|
|
||||||
取整(前后端必须逐字一致,前端镜像在 frontend/src/components/free-create/constants.ts):
|
取整(前后端必须逐字一致,前端镜像在 frontend/src/components/free-create/constants.ts):
|
||||||
¥成本先 quantize 到 0.01(video_pricing.calculate_cost 现状)→ ×毛利 ×汇率 → ROUND_HALF_UP
|
¥成本先 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:
|
def quote_flat(model_config, *, units: int = 1, team=None) -> Quote:
|
||||||
"""文本(次)/ 图像(张)。unit_price = 积分/单位;≤0 回落 10 积分。team 传入则乘团队价格系数。"""
|
"""文本(次)/ 图像(张)。优先 metadata.points_pricing 挂牌;否则 unit_price;≤0 回落默认积分。team 传入则乘团队价格系数。"""
|
||||||
unit_points = Decimal(str(getattr(model_config, "unit_price", 0) or 0))
|
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:
|
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 = FLAT_FALLBACK_POINTS_IMAGE if is_image else FLAT_FALLBACK_POINTS
|
||||||
unit_points = unit_points.quantize(Decimal("1"), rounding=ROUND_HALF_UP)
|
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))
|
base_per_unit = Decimal(str(_pricing_meta(model_config).get("base_cost_yuan") or 0))
|
||||||
multiplier = team_price_multiplier(team)
|
multiplier = team_price_multiplier(team)
|
||||||
points = apply_team_price(max(unit_points * units, Decimal("1")), multiplier)
|
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(
|
return Quote(
|
||||||
points=points,
|
points=points,
|
||||||
base_cost_yuan=base_per_unit * units,
|
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]:
|
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)。"""
|
"""预估视频积分。优先老板挂牌(分辨率×秒);未配置则回落 token×成本×毛利。返回 (tokens占位, Quote)。"""
|
||||||
from apps.ai.video_pricing import estimate_video_cost
|
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(
|
tokens, cost_yuan = estimate_video_cost(
|
||||||
model_config, aspect_ratio=aspect_ratio, resolution=resolution, duration=duration, references=references
|
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:
|
def quote_video_actual(
|
||||||
"""按真实 usage.total_tokens 结算(与预估同一张成本表+同一毛利)。
|
model_config,
|
||||||
multiplier 必须传**下单时的快照系数**(request_payload.price_multiplier),不读团队当前值。"""
|
*,
|
||||||
|
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.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)
|
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)
|
return quote_video_from_cost(cost_yuan, tokens=tokens, multiplier=multiplier)
|
||||||
|
|
||||||
|
|
||||||
def video_reserve_amount(points: Decimal) -> Decimal:
|
def video_reserve_amount(points: Decimal, *, rule: str | None = None) -> Decimal:
|
||||||
"""视频预留额 = 预估积分 × buffer(真实 tokens 可能略超预估;ledger 禁超预留扣费)。"""
|
"""视频预留额。挂牌秒价=精确积分(无 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
|
buffer = get_billing_config().video_reserve_buffer
|
||||||
return (points * buffer).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
|
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)
|
||||||
|
|||||||
@@ -487,3 +487,96 @@ class TeamPriceMultiplierTests(TestCase):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_authenticate(self.user)
|
client.force_authenticate(self.user)
|
||||||
self.assertEqual(client.get("/api/billing/config/").json()["team_price_multiplier"], "0.80")
|
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")
|
||||||
|
|||||||
@@ -106,6 +106,15 @@ def adopt_script_version(project: Project, script: ScriptVersion) -> None:
|
|||||||
if not script.is_adopted:
|
if not script.is_adopted:
|
||||||
script.is_adopted = True
|
script.is_adopted = True
|
||||||
script.save(update_fields=["is_adopted", "updated_at"])
|
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)
|
sync_video_segments_to_script(project, script)
|
||||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT)
|
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT)
|
||||||
stage.status = ProjectStage.Status.SUCCEEDED
|
stage.status = ProjectStage.Status.SUCCEEDED
|
||||||
|
|||||||
@@ -1362,3 +1362,5 @@ class QuickCreateCoordinatorTests(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -198,123 +198,71 @@ class ProjectApiTests(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 404)
|
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(
|
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=0, narration="旧0", visual_prompt="画0", entity_refs=[])
|
||||||
ScriptSegment.objects.create(script_version=script, sort_order=1, narration="旧1", visual_prompt="画1")
|
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")
|
response = self.client.post(f"/api/projects/{project.id}/extract-entities/", {}, format="json")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
# 锁定豆包 2.0 Pro,且走的是流式通道(而非非流式 chat_completion)
|
self.assertEqual(response.data["status"], "succeeded")
|
||||||
self.assertEqual(provider.chat_completion_stream.call_args.kwargs["model"], "doubao-seed-2-0-pro-260215")
|
self.assertEqual(response.data["mode"], "local")
|
||||||
provider.chat_completion.assert_not_called()
|
|
||||||
# 实体落库:cast/scenes + entities_extracted 标记 + 每镜 entity_refs 回填
|
|
||||||
project.refresh_from_db()
|
project.refresh_from_db()
|
||||||
self.assertIn("女主", project.metadata.get("cast", []))
|
self.assertIn("女主", project.metadata.get("cast", []))
|
||||||
self.assertIn("出租屋客厅", project.metadata.get("scenes", []))
|
self.assertIn("出租屋客厅", project.metadata.get("scenes", []))
|
||||||
self.assertTrue(project.metadata.get("entities_extracted"))
|
self.assertTrue(project.metadata.get("entities_extracted"))
|
||||||
|
self.assertEqual(project.metadata.get("entities_extract_mode"), "local")
|
||||||
segs = list(script.segments.order_by("sort_order"))
|
segs = list(script.segments.order_by("sort_order"))
|
||||||
self.assertEqual(segs[0].entity_refs, ["c1", "s1"])
|
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_local_fills_defaults_when_missing(self):
|
||||||
def test_extract_entities_recovers_when_content_empty_uses_reasoning(self, provider_cls):
|
"""脚本没带 entities 时,本地补最少 1 角色 + 1 场景。"""
|
||||||
"""极端兜底:思考模型整轮只发了 reasoning、正文 content 为空 → 回退用 reasoning 里的 JSON,
|
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P-default")
|
||||||
不再像旧非流式那样拿到空串报「未返回有效 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")
|
|
||||||
script = ScriptVersion.objects.create(
|
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")
|
response = self.client.post(f"/api/projects/{project.id}/extract-entities/", {}, format="json")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
project.refresh_from_db()
|
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_status_after_local_success(self):
|
||||||
def test_extract_entities_inflight_guard_reuses_task(self, provider_cls):
|
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P-status")
|
||||||
"""已有在途提取任务时再次提交 → 复用同一任务,不新建、不二次预扣、不重跑模型
|
|
||||||
(用户刷新后手痒重点 / 并发点击都不会重复扣费)。"""
|
|
||||||
from apps.ai.services import submit_extract_entities
|
|
||||||
|
|
||||||
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P3")
|
|
||||||
script = ScriptVersion.objects.create(
|
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": "x"},
|
||||||
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="旧0", visual_prompt="画0")
|
{"id": "s1", "type": "scene", "name": "厨房", "visual_prompt": "y"}]},
|
||||||
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",
|
|
||||||
)
|
)
|
||||||
|
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/")
|
res = self.client.get(f"/api/projects/{project.id}/extract-status/")
|
||||||
self.assertEqual(res.status_code, 200)
|
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.assertFalse(res.data["running"])
|
||||||
self.assertEqual(res.data["status"], "failed")
|
self.assertEqual(res.data["status"], "succeeded")
|
||||||
self.assertEqual(res.data["error"]["code"], "unknown")
|
self.assertTrue(any(e.get("name") == "男主" for e in res.data["entities"]))
|
||||||
self.assertNotIn("有效 JSON", res.data["error_message"])
|
|
||||||
|
|
||||||
@patch("apps.ai.services._store_generated_media")
|
@patch("apps.ai.services._store_generated_media")
|
||||||
@patch("apps.ai.services.get_image_provider")
|
@patch("apps.ai.services.get_image_provider")
|
||||||
|
|||||||
@@ -928,29 +928,28 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
|
|
||||||
@action(detail=True, methods=["post"], url_path="extract-entities")
|
@action(detail=True, methods=["post"], url_path="extract-entities")
|
||||||
def extract_entities_action(self, request, pk=None):
|
def extract_entities_action(self, request, pk=None):
|
||||||
"""从已定稿脚本提取角色 / 场景实体(独立步,进资产阶段时调)——**异步**:豆包思考模型流式抽取较慢,
|
"""从已定稿脚本**本地**拆出角色 / 场景(不调模型、不扣积分)。
|
||||||
Web 层只建任务秒回(不再 502),慢活交 worker(run_extract_entities_task)。前端拿 task_id 轮询
|
读脚本自带 entities,缺则按分镜补最少 1 角色 + 1 场景;商品不提。"""
|
||||||
extract-status 取结果。已有在途提取则复用(防刷新后重点重复扣费)。落库覆盖 project.metadata +
|
|
||||||
每镜 entity_refs;**商品不提**(参考图层用真实主图)。"""
|
|
||||||
require_worker() # 异步提取依赖 worker 兜底执行,没 worker 直接拒绝(否则任务永远 RESERVED)
|
|
||||||
project = self.get_object()
|
project = self.get_object()
|
||||||
try:
|
try:
|
||||||
task = submit_extract_entities(project=project, user=request.user)
|
task = submit_extract_entities(project=project, user=request.user)
|
||||||
except ValueError as exc: # 无脚本 / 无分镜 / 无模型 / 额度不足,立即反馈
|
except ValueError as exc:
|
||||||
message = str(exc)
|
|
||||||
internal_kind = (
|
|
||||||
"user_credit_insufficient" if "额度不足" in message
|
|
||||||
else "model_unavailable" if "模型" in message
|
|
||||||
else "invalid_input"
|
|
||||||
)
|
|
||||||
public_error = classify_generation_error(
|
public_error = classify_generation_error(
|
||||||
exc, operation="entity_extract", internal_kind=internal_kind
|
exc, operation="entity_extract", internal_kind="invalid_input"
|
||||||
)
|
)
|
||||||
return Response(
|
return Response(
|
||||||
{"detail": public_error.fallback_message, "error": public_error.as_dict()},
|
{"detail": public_error.fallback_message, "error": public_error.as_dict()},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
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")
|
@action(detail=True, methods=["get"], url_path="extract-status")
|
||||||
def extract_status_action(self, request, pk=None):
|
def extract_status_action(self, request, pk=None):
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
AccountPage,
|
AccountPage,
|
||||||
AssetFactoryPage,
|
AssetFactoryPage,
|
||||||
AuthScreen,
|
AuthScreen,
|
||||||
Dashboard,
|
|
||||||
FreeCreatePage,
|
FreeCreatePage,
|
||||||
QuickCreatePage,
|
QuickCreatePage,
|
||||||
OmniCreatePage,
|
OmniCreatePage,
|
||||||
@@ -122,6 +121,12 @@ export function App() {
|
|||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [projectTotal, setProjectTotal] = useState(0);
|
const [projectTotal, setProjectTotal] = useState(0);
|
||||||
const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>([]);
|
const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>([]);
|
||||||
|
|
||||||
|
const refreshModelConfigs = useCallback(() => {
|
||||||
|
void api.modelConfigs()
|
||||||
|
.then((modelData) => setModelConfigs(modelData?.results || []))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, []);
|
||||||
const [billing, setBilling] = useState<BillingSummary | null>(null);
|
const [billing, setBilling] = useState<BillingSummary | null>(null);
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
// YYX#row22:未读生成任务 —— 导航「图片生成」总数 + 每个商品的未读分数(商品角标)
|
// YYX#row22:未读生成任务 —— 导航「图片生成」总数 + 每个商品的未读分数(商品角标)
|
||||||
@@ -344,23 +349,39 @@ export function App() {
|
|||||||
if (user.is_platform_admin && !team && route.admin === undefined) {
|
if (user.is_platform_admin && !team && route.admin === undefined) {
|
||||||
navigateAdmin("", { replace: true });
|
navigateAdmin("", { replace: true });
|
||||||
} else if (!user.is_platform_admin && route.admin !== undefined) {
|
} else if (!user.is_platform_admin && route.admin !== undefined) {
|
||||||
navigate("dashboard", { replace: true });
|
navigate("omniCreate", { replace: true });
|
||||||
}
|
}
|
||||||
// navigate/navigateAdmin 为组件内函数,故意不入依赖避免每次渲染重跑
|
// navigate/navigateAdmin 为组件内函数,故意不入依赖避免每次渲染重跑
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [booting, user, team, route.admin]);
|
}, [booting, user, team, route.admin]);
|
||||||
|
|
||||||
// 子账号(非主账号)纠回:团队 / 消费 页仅主账号(owner=超管)可见,子账号直接拉回工作台(PMC#3)。
|
// 子账号(非主账号)纠回:团队 / 消费 页仅主账号(owner=超管)可见,子账号直接拉回全能创作(PMC#3)。
|
||||||
// role 为空时(身份尚未带回角色)不拦,避免误纠超管。
|
// role 为空时(身份尚未带回角色)不拦,避免误纠超管。
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (booting || !user || !role) return;
|
if (booting || !user || !role) return;
|
||||||
if (!isOwner && isOwnerOnlyPage(page)) {
|
if (!isOwner && isOwnerOnlyPage(page)) {
|
||||||
navigate("dashboard", { replace: true });
|
navigate("omniCreate", { replace: true });
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [booting, user, role, isOwner, page]);
|
}, [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.
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!authed || (page !== "settings" && page !== "settingsNotify")) return;
|
if (!authed || (page !== "settings" && page !== "settingsNotify")) return;
|
||||||
loadSettingsData();
|
loadSettingsData();
|
||||||
@@ -856,7 +877,7 @@ export function App() {
|
|||||||
navigateAdmin("", { replace: true });
|
navigateAdmin("", { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigate("dashboard", { replace: true });
|
navigate("omniCreate", { replace: true });
|
||||||
// 先把工作台必需数据拉好,这期间「登录页」保持「登录成功,正在进入工作台…」提示(authed 仍 false → AuthScreen 不卸载),
|
// 先把工作台必需数据拉好,这期间「登录页」保持「登录成功,正在进入工作台…」提示(authed 仍 false → AuthScreen 不卸载),
|
||||||
// 数据就绪后才揭开外壳直接进有数据的工作台 —— 避免中间闪一个空白「加载中…」页(PMC#7)。
|
// 数据就绪后才揭开外壳直接进有数据的工作台 —— 避免中间闪一个空白「加载中…」页(PMC#7)。
|
||||||
try {
|
try {
|
||||||
@@ -923,9 +944,10 @@ export function App() {
|
|||||||
const currentTeam: Team = team;
|
const currentTeam: Team = team;
|
||||||
|
|
||||||
function renderPage() {
|
function renderPage() {
|
||||||
switch (page) {
|
switch (page) {
|
||||||
case "dashboard":
|
case "dashboard":
|
||||||
return <Dashboard products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} billing={billing} userName={currentUser.username} loading={!dataLoaded} navigate={navigate} onDelete={deleteProjectAction} />;
|
// 工作台隐藏期间不渲染该页,上面的 effect 会改到全能创作
|
||||||
|
return null;
|
||||||
case "products":
|
case "products":
|
||||||
return (
|
return (
|
||||||
<ProductsPage
|
<ProductsPage
|
||||||
@@ -1079,7 +1101,7 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "omniCreate":
|
case "omniCreate":
|
||||||
return <OmniCreatePage navigate={navigate} onNotify={(type, text) => setNotice({ type, text })} />;
|
return <OmniCreatePage modelConfigs={modelConfigs} navigate={navigate} onNotify={(type, text) => setNotice({ type, text })} />;
|
||||||
case "omniHistory":
|
case "omniHistory":
|
||||||
return <OmniHistoryPage navigate={navigate} onNotify={(type, text) => setNotice({ type, text })} />;
|
return <OmniHistoryPage navigate={navigate} onNotify={(type, text) => setNotice({ type, text })} />;
|
||||||
case "omniSession":
|
case "omniSession":
|
||||||
@@ -1089,6 +1111,7 @@ export function App() {
|
|||||||
firstMessage={route.firstMessage}
|
firstMessage={route.firstMessage}
|
||||||
firstRefs={route.firstRefs}
|
firstRefs={route.firstRefs}
|
||||||
firstUploads={route.firstUploads}
|
firstUploads={route.firstUploads}
|
||||||
|
modelConfigs={modelConfigs}
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
onNotify={(type, text) => setNotice({ type, text })}
|
onNotify={(type, text) => setNotice({ type, text })}
|
||||||
/>
|
/>
|
||||||
@@ -1140,7 +1163,7 @@ export function App() {
|
|||||||
case "settingsNotify":
|
case "settingsNotify":
|
||||||
return <SettingsPage user={currentUser} team={currentTeam} initialSection="notify" preferences={preferences} sessions={sessions} onSavePreferences={savePreferences} onRevokeSession={revokeSession} onRevokeOthers={revokeOtherSessions} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} onResetAvatar={resetOwnAvatar} onNotify={(text) => setNotice({ type: "success", text })} onLogout={logout} />;
|
return <SettingsPage user={currentUser} team={currentTeam} initialSection="notify" preferences={preferences} sessions={sessions} onSavePreferences={savePreferences} onRevokeSession={revokeSession} onRevokeOthers={revokeOtherSessions} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} onResetAvatar={resetOwnAvatar} onNotify={(text) => setNotice({ type: "success", text })} onLogout={logout} />;
|
||||||
default:
|
default:
|
||||||
return <Dashboard products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} billing={billing} userName={currentUser.username} loading={!dataLoaded} navigate={navigate} onDelete={deleteProjectAction} />;
|
return <OmniCreatePage modelConfigs={modelConfigs} navigate={navigate} onNotify={(type, text) => setNotice({ type, text })} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1182,6 +1205,7 @@ export function App() {
|
|||||||
scriptModelName={publicModelDisplayName(pipelineTextModel, "AI")}
|
scriptModelName={publicModelDisplayName(pipelineTextModel, "AI")}
|
||||||
textModels={modelConfigs.filter((m) => m.capability === "text" && m.status === "active")}
|
textModels={modelConfigs.filter((m) => m.capability === "text" && m.status === "active")}
|
||||||
videoModels={modelConfigs.filter((m) => m.capability === "video" && 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}
|
loading={loading}
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
onBack={() => goBack("projects")}
|
onBack={() => goBack("projects")}
|
||||||
|
|||||||
@@ -1760,28 +1760,41 @@
|
|||||||
.image-workbench .ic-param.open .ic-param-btn svg { transform: rotate(180deg); }
|
.image-workbench .ic-param.open .ic-param-btn svg { transform: rotate(180deg); }
|
||||||
.image-workbench .ic-param-menu {
|
.image-workbench .ic-param-menu {
|
||||||
position: absolute; bottom: calc(100% + 6px); left: -2px;
|
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);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border-faint);
|
border: 1px solid rgba(34, 42, 54, 0.10);
|
||||||
border-radius: var(--r-md);
|
border-radius: 10px;
|
||||||
box-shadow: 0 6px 24px rgba(0, 0, 0, .08);
|
box-shadow: 0 12px 30px rgba(20, 27, 38, 0.12);
|
||||||
padding: 4px;
|
padding: 6px;
|
||||||
display: none;
|
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 {
|
.image-workbench .ic-param-menu .mi {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-height: 36px;
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
border: 0; border-radius: var(--r-sm);
|
border: 0; border-radius: 8px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
font-size: 13px; color: var(--accent-black);
|
font-size: 13px; color: var(--accent-black);
|
||||||
font-family: inherit; text-align: left; cursor: pointer;
|
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; }
|
.image-workbench .ic-param-menu .mi.selected .mi-check { visibility: visible; }
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
@@ -3040,7 +3053,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.yz-image .new-conversation {
|
.yz-image .new-conversation {
|
||||||
height: 46px;
|
height: 46px;
|
||||||
@@ -3070,24 +3085,38 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
.yz-image .history-item {
|
.yz-image .history-item {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px;
|
padding: 10px 12px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
cursor: pointer;
|
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.active { background: rgba(0, 47, 167, 0.075); }
|
||||||
.yz-image .history-item .nm {
|
.yz-image .history-item .nm {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
flex: 1 1 auto;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
line-height: 1.35;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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 {
|
.yz-image .history-empty {
|
||||||
margin: 6px 4px;
|
margin: 6px 4px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -3419,7 +3448,12 @@
|
|||||||
color: var(--klein);
|
color: var(--klein);
|
||||||
background: rgba(0, 47, 167, 0.075);
|
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 {
|
.yz-image .image-reference-thumbs button {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
@@ -669,7 +669,7 @@ export const api = {
|
|||||||
// 独立实体提取(**异步**):只提交任务、秒回 task_id(慢活在 worker 跑,不再 502);已有在途则复用(防重复扣费)。
|
// 独立实体提取(**异步**):只提交任务、秒回 task_id(慢活在 worker 跑,不再 502);已有在途则复用(防重复扣费)。
|
||||||
// 进度/结果用 extractStatus 轮询。商品不提(参考图层用真实主图)。
|
// 进度/结果用 extractStatus 轮询。商品不提(参考图层用真实主图)。
|
||||||
extractEntities(projectId: string) {
|
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 供「提取+生成」继续出图。
|
// 实体提取进度:running=有在途任务(刷新后据此重建 loading);否则读最近一次成败,成功回带 entities 供「提取+生成」继续出图。
|
||||||
extractStatus(projectId: string) {
|
extractStatus(projectId: string) {
|
||||||
@@ -947,7 +947,8 @@ export const api = {
|
|||||||
return request<BillingTrend>(`/api/billing/trend/${range ? `?range=${range}` : ""}`);
|
return request<BillingTrend>(`/api/billing/trend/${range ? `?range=${range}` : ""}`);
|
||||||
},
|
},
|
||||||
modelConfigs() {
|
modelConfigs() {
|
||||||
return request<Paginated<ModelConfig>>("/api/ai/models/");
|
// 创作页下拉依赖完整 active 目录;加大 page_size 避免默认分页截断
|
||||||
|
return request<Paginated<ModelConfig>>("/api/ai/models/?page_size=200");
|
||||||
},
|
},
|
||||||
aiTasks() {
|
aiTasks() {
|
||||||
// 生图工作室的任务中心:只看生图任务(模特上身图/平台套图/图片创作 = person_image /
|
// 生图工作室的任务中心:只看生图任务(模特上身图/平台套图/图片创作 = person_image /
|
||||||
@@ -1335,7 +1336,7 @@ export const adminApi = {
|
|||||||
const q = qs.toString();
|
const q = qs.toString();
|
||||||
return request<AdminModel[]>(`/api/admin/models/${q ? `?${q}` : ""}`);
|
return request<AdminModel[]>(`/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<string, unknown> }) {
|
||||||
return request<AdminModel>("/api/admin/models/", { method: "POST", body: JSON.stringify(payload) });
|
return request<AdminModel>("/api/admin/models/", { method: "POST", body: JSON.stringify(payload) });
|
||||||
},
|
},
|
||||||
updateModel(id: string, payload: Record<string, unknown>) {
|
updateModel(id: string, payload: Record<string, unknown>) {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ const SIDEBAR_COLLAPSED_KEY = "airshelf:sidebar-collapsed";
|
|||||||
// 全局命令面板(Ctrl K / 点搜索框)—— 忠实搬设计稿 SHELL_COMMANDS,href 改成真路由导航
|
// 全局命令面板(Ctrl K / 点搜索框)—— 忠实搬设计稿 SHELL_COMMANDS,href 改成真路由导航
|
||||||
type Command = { id: string; group: string; label: string; sub: string; page: Page; icon: string; key?: string };
|
type Command = { id: string; group: string; label: string; sub: string; page: Page; icon: string; key?: string };
|
||||||
const SHELL_COMMANDS: Command[] = [
|
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-create", group: "导航", label: "全能创作", sub: "预设工作流 + 对话式 Agent", page: "omniCreate", icon: "sparkles", key: "O" },
|
||||||
{ id: "omni-history", group: "导航", label: "创作历史", sub: "查看与继续独立会话", page: "omniHistory", icon: "history", key: "H" },
|
{ id: "omni-history", group: "导航", label: "创作历史", sub: "查看与继续独立会话", page: "omniHistory", icon: "history", key: "H" },
|
||||||
{ id: "products", group: "导航", label: "商品库", sub: "管理 SKU、商品图册、卖点信息", page: "products", icon: "package", key: "P" },
|
{ 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 };
|
type NavDef = { id: string; page: Page; label: string; icon: string; badge?: number };
|
||||||
|
|
||||||
const NAV: NavDef[] = [
|
const NAV: NavDef[] = [
|
||||||
{ id: "dashboard", page: "dashboard", label: "工作台", icon: "dashboard" },
|
|
||||||
{ id: "products", page: "products", label: "商品库", icon: "package" },
|
{ id: "products", page: "products", label: "商品库", icon: "package" },
|
||||||
{ id: "models", page: "models", label: "模特库", icon: "model" },
|
{ id: "models", page: "models", label: "模特库", icon: "model" },
|
||||||
{ id: "projects", page: "projects", label: "视频创作", icon: "clapperboard" },
|
{ 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 }[] = [
|
const MODE_TABS: { id: TopModule; label: string; page: Page }[] = [
|
||||||
{ id: "workbench", label: "工作台", page: "dashboard" },
|
|
||||||
{ id: "omni", label: "全能创作", page: "omniCreate" },
|
{ id: "omni", label: "全能创作", page: "omniCreate" },
|
||||||
{ id: "image", label: "图片创作", page: "assetFactory" },
|
{ id: "image", label: "图片创作", page: "assetFactory" },
|
||||||
{ id: "video", label: "视频创作", page: "projects" },
|
{ id: "video", label: "视频创作", page: "projects" },
|
||||||
@@ -365,10 +362,9 @@ export function ModeTabs({ active, navigate }: { active: TopModule | null; navig
|
|||||||
<svg className="mode-clip-defs" width="0" height="0" aria-hidden="true" focusable="false">
|
<svg className="mode-clip-defs" width="0" height="0" aria-hidden="true" focusable="false">
|
||||||
<defs>
|
<defs>
|
||||||
<clipPath id="modeButtonMask" clipPathUnits="userSpaceOnUse">
|
<clipPath id="modeButtonMask" clipPathUnits="userSpaceOnUse">
|
||||||
<rect rx="22" ry="22" x="0" y="0" width="104" height="44" />
|
<rect rx="22" ry="22" x="0" y="0" width="110" height="44" />
|
||||||
<rect rx="22" ry="22" x="120" y="0" width="110" height="44" />
|
<rect rx="22" ry="22" x="126" y="0" width="110" height="44" />
|
||||||
<rect rx="22" ry="22" x="246" y="0" width="110" height="44" />
|
<rect rx="22" ry="22" x="252" y="0" width="110" height="44" />
|
||||||
<rect rx="22" ry="22" x="372" y="0" width="110" height="44" />
|
|
||||||
</clipPath>
|
</clipPath>
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -567,7 +563,7 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
|
|||||||
{mobileNavOpen && <div className="mobile-nav-backdrop" onClick={() => setMobileNavOpen(false)} />}
|
{mobileNavOpen && <div className="mobile-nav-backdrop" onClick={() => setMobileNavOpen(false)} />}
|
||||||
<aside className={`sidebar${mobileNavOpen ? " mobile-open" : ""}`}>
|
<aside className={`sidebar${mobileNavOpen ? " mobile-open" : ""}`}>
|
||||||
<div className="sidebar-head">
|
<div className="sidebar-head">
|
||||||
<a className="brand" href="/dashboard" aria-label="影擎工作台" onClick={(event) => { event.preventDefault(); navigate("dashboard"); }}>
|
<a className="brand" href="/omni-create" aria-label="影擎全能创作" onClick={(event) => { event.preventDefault(); navigate("omniCreate"); }}>
|
||||||
<span className="brand-clip"><img className="brand-logo" src="/assets/yz/logo-horizontal.png" alt="影擎" /></span>
|
<span className="brand-clip"><img className="brand-logo" src="/assets/yz/logo-horizontal.png" alt="影擎" /></span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,6 +47,37 @@ export function modelDurations(config: ModelConfig | undefined): number[] {
|
|||||||
return list?.length ? list : [...FC_DURATIONS];
|
return list?.length ? list : [...FC_DURATIONS];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 老板挂牌: metadata.points_pricing 每秒积分(视频)。无挂牌返回 null。 */
|
||||||
|
export function pointsPerSecondFromCatalog(
|
||||||
|
config: ModelConfig | undefined,
|
||||||
|
resolution: string,
|
||||||
|
hasVideoRef = false,
|
||||||
|
): number | null {
|
||||||
|
const pricing = (config?.metadata?.points_pricing || {}) as Record<string, unknown>;
|
||||||
|
const tiers = Array.isArray(pricing.tiers) ? pricing.tiers : [];
|
||||||
|
const res = (resolution || "").toLowerCase();
|
||||||
|
const hit = tiers.find((row) => row && typeof row === "object" && String((row as { resolution?: string }).resolution || "").toLowerCase() === res) as
|
||||||
|
| { points_per_second?: number; points_per_second_with_ref?: number }
|
||||||
|
| undefined;
|
||||||
|
if (!hit) return null;
|
||||||
|
if (hasVideoRef && hit.points_per_second_with_ref != null) {
|
||||||
|
const n = Number(hit.points_per_second_with_ref);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
const n = Number(hit.points_per_second);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pointsPerImageFromCatalog(config: ModelConfig | undefined): number | null {
|
||||||
|
const pricing = (config?.metadata?.points_pricing || {}) as Record<string, unknown>;
|
||||||
|
if (pricing.points_per_image != null) {
|
||||||
|
const n = Number(pricing.points_per_image);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
const unit = Number(config?.unit_price);
|
||||||
|
return Number.isFinite(unit) && unit > 0 ? unit : null;
|
||||||
|
}
|
||||||
|
|
||||||
export const MODE_LABELS: Record<FreeMode, string> = { universal: "全能参考", keyframe: "首尾帧" };
|
export const MODE_LABELS: Record<FreeMode, string> = { universal: "全能参考", keyframe: "首尾帧" };
|
||||||
|
|
||||||
// 上传素材限制(与后端 FreeVideoUploadView / jimeng inputBar 对齐)
|
// 上传素材限制(与后端 FreeVideoUploadView / jimeng inputBar 对齐)
|
||||||
@@ -117,28 +148,27 @@ export function tokenPrice(config: ModelConfig | undefined, resolution: string,
|
|||||||
export type BillingRates = { margin: number; rate: number; multiplier: number };
|
export type BillingRates = { margin: number; rate: number; multiplier: number };
|
||||||
export const DEFAULT_BILLING_RATES: BillingRates = { margin: 1.5, rate: 10, multiplier: 1 };
|
export const DEFAULT_BILLING_RATES: BillingRates = { margin: 1.5, rate: 10, multiplier: 1 };
|
||||||
|
|
||||||
|
export function hasVideoPointsPricing(config: ModelConfig | undefined): boolean {
|
||||||
|
const pricing = (config?.metadata?.points_pricing || {}) as Record<string, unknown>;
|
||||||
|
const tiers = Array.isArray(pricing.tiers) ? pricing.tiers : [];
|
||||||
|
return tiers.some((row) => row && typeof row === "object" && Number((row as { points_per_second?: number }).points_per_second) >= 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 视频预估积分。只认后台 metadata.points_pricing 挂牌;没填挂牌返回 0(不拿旧 token×毛利糊弄用户)。 */
|
||||||
export function estimateCost(
|
export function estimateCost(
|
||||||
config: ModelConfig | undefined,
|
config: ModelConfig | undefined,
|
||||||
params: { ratio: string; resolution: string; duration: number; refs: { type: string; duration?: number }[] },
|
params: { ratio: string; resolution: string; duration: number; refs: { type: string; duration?: number }[] },
|
||||||
billing: BillingRates = DEFAULT_BILLING_RATES
|
billing: BillingRates = DEFAULT_BILLING_RATES
|
||||||
): { tokens: number; points: number } {
|
): { tokens: number; points: number; listed: boolean } {
|
||||||
const inputVideoSeconds = params.refs
|
|
||||||
.filter((r) => r.type === "video")
|
|
||||||
.reduce((sum, r) => sum + (r.duration || 0), 0);
|
|
||||||
const tokens = estimateTokens(params.ratio, params.resolution, params.duration, inputVideoSeconds);
|
|
||||||
const hasVideoRef = params.refs.some((r) => r.type === "video");
|
const hasVideoRef = params.refs.some((r) => r.type === "video");
|
||||||
const price = tokenPrice(config, params.resolution, hasVideoRef);
|
const pps = pointsPerSecondFromCatalog(config, params.resolution, hasVideoRef);
|
||||||
const costYuan = Math.round(((tokens * price) / 1e6) * 100) / 100;
|
if (pps != null && params.duration > 0) {
|
||||||
if (costYuan <= 0) return { tokens, points: 0 };
|
const listPoints = Math.max(1, Math.round(pps * params.duration));
|
||||||
// 浮点乘积在 .5 边界可能落成 27.499999…,先吸附 6 位小数再 round,
|
const multiplier = billing.multiplier || 1;
|
||||||
// 与后端 Decimal ROUND_HALF_UP 逐字对齐。只吸浮点噪声(1e-9 级),
|
const points = multiplier === 1 ? listPoints : Math.max(1, Math.round(Number((listPoints * multiplier).toFixed(6))));
|
||||||
// 不能用 toFixed(2):非默认 margin 下 x.495 会被先四舍五入成 x.50 多显 1 积分(review 确认)
|
return { tokens: 0, points, listed: true };
|
||||||
const raw = Number((costYuan * billing.margin * billing.rate).toFixed(6));
|
}
|
||||||
const listPoints = Math.max(1, Math.round(raw));
|
return { tokens: 0, points: 0, listed: false };
|
||||||
// 团队价格系数(差异化调价):两步取整与后端 apply_team_price 逐字对齐(先挂牌取整,再乘系数取整)
|
|
||||||
const multiplier = billing.multiplier || 1;
|
|
||||||
const points = multiplier === 1 ? listPoints : Math.max(1, Math.round(Number((listPoints * multiplier).toFixed(6))));
|
|
||||||
return { tokens, points };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function modelLabel(name: string): string {
|
export function modelLabel(name: string): string {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function KeyframeSlot({ role, item, onPickLibrary, onRemove }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, billingRates, submitting, promptRef, onFiles, onRemoveRef, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onOpenLibrary, onOpenPlatformLibrary, onClear, onSend }: {
|
export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, billingRates, submitting, promptRef, onFiles, onRemoveRef, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onOpenLibrary, onOpenPlatformLibrary, onSend }: {
|
||||||
mode: FreeMode;
|
mode: FreeMode;
|
||||||
model: string;
|
model: string;
|
||||||
ratio: string;
|
ratio: string;
|
||||||
@@ -82,7 +82,6 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
|
|||||||
onSeedChange: (seed: number) => void;
|
onSeedChange: (seed: number) => void;
|
||||||
onOpenLibrary: (role?: "first_frame" | "last_frame") => void;
|
onOpenLibrary: (role?: "first_frame" | "last_frame") => void;
|
||||||
onOpenPlatformLibrary: (role?: "first_frame" | "last_frame") => void;
|
onOpenPlatformLibrary: (role?: "first_frame" | "last_frame") => void;
|
||||||
onClear: () => void;
|
|
||||||
onSend: () => void;
|
onSend: () => void;
|
||||||
}) {
|
}) {
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -175,7 +174,6 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
|
|||||||
onResolutionChange={onResolutionChange}
|
onResolutionChange={onResolutionChange}
|
||||||
onDurationChange={onDurationChange}
|
onDurationChange={onDurationChange}
|
||||||
onSeedChange={onSeedChange}
|
onSeedChange={onSeedChange}
|
||||||
onClear={onClear}
|
|
||||||
onSend={onSend}
|
onSend={onSend}
|
||||||
/>
|
/>
|
||||||
{dragOver && <div className="fc-drop-hint">松开上传到「{MODE_LABELS[mode]}」</div>}
|
{dragOver && <div className="fc-drop-hint">松开上传到「{MODE_LABELS[mode]}」</div>}
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
// 自由创作·输入条工具栏:模型/模式/比例/分辨率/时长/种子 下拉 + 预估消耗 + 清空 + 生成。
|
// 自由创作·输入条工具栏:模型/模式/比例/分辨率/时长/种子 下拉 + 预估积分 + 生成。
|
||||||
// 模型下拉列后端返回的全部视频模型(FC_MODELS 只提供好看的标签)。
|
// 模型下拉列后端返回的全部视频模型(FC_MODELS 只提供好看的标签)。
|
||||||
// 约束联动(与后端校验一致):分辨率与时长档位取自所选模型的 metadata.capabilities,
|
// 约束联动(与后端校验一致):分辨率与时长档位取自所选模型的 metadata.capabilities,
|
||||||
// 换档时把超出新模型能力的选择夹回去。
|
// 换档时把超出新模型能力的选择夹回去。
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ArrowRight, ChevronDown } from "lucide-react";
|
import { ArrowRight, ChevronDown, Coins } from "lucide-react";
|
||||||
import type { ModelConfig } from "../../types";
|
import type { ModelConfig } from "../../types";
|
||||||
import {
|
import {
|
||||||
DEFAULT_BILLING_RATES,
|
DEFAULT_BILLING_RATES,
|
||||||
FC_MODELS,
|
FC_MODELS,
|
||||||
FC_RATIOS,
|
FC_RATIOS,
|
||||||
FC_RESOLUTIONS,
|
|
||||||
MODE_LABELS,
|
MODE_LABELS,
|
||||||
estimateCost,
|
estimateCost,
|
||||||
modelDurations,
|
modelDurations,
|
||||||
@@ -77,7 +76,7 @@ function FcDropdown({ label, display, items, onSelect, disabled }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, billingRates, hasPrompt, submitting, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onClear, onSend }: {
|
export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, billingRates, hasPrompt, submitting, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onSend }: {
|
||||||
mode: FreeMode;
|
mode: FreeMode;
|
||||||
model: string;
|
model: string;
|
||||||
ratio: string;
|
ratio: string;
|
||||||
@@ -95,14 +94,13 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
|
|||||||
onResolutionChange: (resolution: string) => void;
|
onResolutionChange: (resolution: string) => void;
|
||||||
onDurationChange: (duration: number) => void;
|
onDurationChange: (duration: number) => void;
|
||||||
onSeedChange: (seed: number) => void;
|
onSeedChange: (seed: number) => void;
|
||||||
onClear: () => void;
|
|
||||||
onSend: () => void;
|
onSend: () => void;
|
||||||
}) {
|
}) {
|
||||||
const config = videoConfigs.find((c) => c.name === model);
|
const config = videoConfigs.find((c) => c.name === model);
|
||||||
// 可选的分辨率/时长按所选模型的能力来,不再写死 —— Seedance 2.5 能出 30 秒,2.0 只有 15。
|
// 可选的分辨率/时长按所选模型的能力来,不再写死 —— Seedance 2.5 能出 30 秒,2.0 只有 15。
|
||||||
const allowedResolutions = modelResolutions(config);
|
const allowedResolutions = modelResolutions(config);
|
||||||
const allowedDurations = modelDurations(config);
|
const allowedDurations = modelDurations(config);
|
||||||
const { tokens, points } = estimateCost(config, { ratio, resolution, duration, refs }, billingRates || DEFAULT_BILLING_RATES);
|
const { points } = estimateCost(config, { ratio, resolution, duration, refs }, billingRates || DEFAULT_BILLING_RATES);
|
||||||
const [seedOpen, setSeedOpen] = useState(false);
|
const [seedOpen, setSeedOpen] = useState(false);
|
||||||
const seedRef = useRef<HTMLDivElement>(null);
|
const seedRef = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -152,11 +150,9 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
|
|||||||
<FcDropdown
|
<FcDropdown
|
||||||
label="分辨率"
|
label="分辨率"
|
||||||
display={resolution.toUpperCase()}
|
display={resolution.toUpperCase()}
|
||||||
items={FC_RESOLUTIONS.map((r) => ({
|
items={allowedResolutions.map((r) => ({
|
||||||
value: r,
|
value: r,
|
||||||
label: r.toUpperCase(),
|
label: r.toUpperCase(),
|
||||||
disabled: !allowedResolutions.includes(r),
|
|
||||||
hint: "当前模型不支持这一档"
|
|
||||||
}))}
|
}))}
|
||||||
onSelect={onResolutionChange}
|
onSelect={onResolutionChange}
|
||||||
/>
|
/>
|
||||||
@@ -191,10 +187,12 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="fc-toolbar-r">
|
<div className="fc-toolbar-r">
|
||||||
<span className="fc-estimate" title="预估消耗(实际按真实用量结算,多退少不补超)">
|
{points > 0 ? (
|
||||||
≈ {tokens.toLocaleString()} tokens · {points.toLocaleString()} 积分
|
<span className="fc-estimate" title="预估积分(实际按真实用量结算)">
|
||||||
</span>
|
<Coins aria-hidden="true" />
|
||||||
<button type="button" className="fc-clear" onClick={onClear}>清空</button>
|
≈ {points.toLocaleString()} 积分
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<button type="button" className="fc-gen" disabled={!canSend} onClick={onSend} title="Ctrl/Cmd + Enter">
|
<button type="button" className="fc-gen" disabled={!canSend} onClick={onSend} title="Ctrl/Cmd + Enter">
|
||||||
{submitting ? "提交中…" : uploading ? "素材上传中…" : "生成"}
|
{submitting ? "提交中…" : uploading ? "素材上传中…" : "生成"}
|
||||||
<ArrowRight />
|
<ArrowRight />
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ChevronDown, SlidersHorizontal, Sparkles } from "lucide-react";
|
import { ChevronDown, SlidersHorizontal, Sparkles } from "lucide-react";
|
||||||
import { CustomSelect } from "./custom-select";
|
import { CustomSelect } from "./custom-select";
|
||||||
|
import { modelDurations, modelResolutions } from "./free-create/constants";
|
||||||
|
import type { ModelConfig } from "../types";
|
||||||
|
|
||||||
|
/** 无目录时的回落清单(展示名,与会话 params.model 历史值兼容)。 */
|
||||||
export const OMNI_VIDEO_MODELS = ["Seedance 2.5", "Seedance 2.0", "Seedance 2.0 Fast", "Seedance 2.0 Mini"];
|
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_IMAGE_MODELS = ["Seedream5.0", "YQ image2"];
|
||||||
export const OMNI_RESOLUTIONS = ["1080p", "720p", "480p"];
|
export const OMNI_RESOLUTIONS = ["1080p", "720p", "480p"];
|
||||||
export const OMNI_RATIOS = ["9:16", "16:9", "1:1", "4:3", "3:4"];
|
export const OMNI_RATIOS = ["9:16", "16:9", "1:1", "4:3", "3:4"];
|
||||||
export const OMNI_VIDEO_DURATIONS = [
|
export const OMNI_VIDEO_DURATIONS = [
|
||||||
"4 秒", "5 秒", "6 秒", "7 秒", "8 秒", "9 秒", "10 秒",
|
"智能时长", "4 秒", "5 秒", "6 秒", "7 秒", "8 秒", "9 秒", "10 秒",
|
||||||
"11 秒", "12 秒", "13 秒", "14 秒", "15 秒", "30 秒",
|
"11 秒", "12 秒", "13 秒", "14 秒", "15 秒", "30 秒",
|
||||||
];
|
];
|
||||||
export const OMNI_IMAGE_COUNTS = ["1 张", "2 张", "4 张", "8 张"];
|
export const OMNI_IMAGE_COUNTS = ["1 张", "2 张", "4 张", "8 张"];
|
||||||
@@ -20,6 +23,59 @@ function withCurrent(values: string[], current: string) {
|
|||||||
return current && !values.includes(current) ? [current, ...values] : values;
|
return current && !values.includes(current) ? [current, ...values] : values;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function modelOptionLabel(config: ModelConfig): string {
|
||||||
|
return (config.display_name || config.name || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 忽略空格/横杠/大小写,兼容旧会话「Seedance 2.0 Mini」对上后台「Seedance-2.0-Mini」。 */
|
||||||
|
function normalizeModelKey(value: string): string {
|
||||||
|
return String(value || "").toLowerCase().replace(/[\s_\-·.]+/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 会话里存的是展示名;用 display_name / name 都能对上目录项。 */
|
||||||
|
export function findCatalogModel(
|
||||||
|
configs: ModelConfig[] | undefined,
|
||||||
|
label: string,
|
||||||
|
capability: "video" | "image",
|
||||||
|
): ModelConfig | undefined {
|
||||||
|
const list = (configs || []).filter((c) => c.capability === capability && c.status !== "disabled");
|
||||||
|
const key = (label || "").trim();
|
||||||
|
if (!key) return list[0];
|
||||||
|
const norm = normalizeModelKey(key);
|
||||||
|
return (
|
||||||
|
list.find((c) => modelOptionLabel(c) === key)
|
||||||
|
|| list.find((c) => c.name === key)
|
||||||
|
|| list.find((c) => normalizeModelKey(modelOptionLabel(c)) === norm)
|
||||||
|
|| list.find((c) => normalizeModelKey(c.name) === norm)
|
||||||
|
|| list.find((c) => {
|
||||||
|
const dn = normalizeModelKey(c.display_name || "");
|
||||||
|
const nm = normalizeModelKey(c.name || "");
|
||||||
|
return (dn && (dn.includes(norm) || norm.includes(dn))) || (nm && (nm.includes(norm) || norm.includes(nm)));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function catalogModelLabels(
|
||||||
|
configs: ModelConfig[] | undefined,
|
||||||
|
capability: "video" | "image",
|
||||||
|
fallback: string[],
|
||||||
|
): string[] {
|
||||||
|
const labels = (configs || [])
|
||||||
|
.filter((c) => c.capability === capability)
|
||||||
|
.map(modelOptionLabel)
|
||||||
|
.filter(Boolean);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const unique = labels.filter((x) => (seen.has(x) ? false : (seen.add(x), true)));
|
||||||
|
return unique.length ? unique : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationLabelsForModel(config: ModelConfig | undefined, isVideo: boolean): string[] {
|
||||||
|
if (!isVideo) return [...OMNI_IMAGE_COUNTS];
|
||||||
|
const seconds = modelDurations(config);
|
||||||
|
if (!seconds.length) return [...OMNI_VIDEO_DURATIONS];
|
||||||
|
return ["智能时长", ...seconds.map((n) => `${n} 秒`)];
|
||||||
|
}
|
||||||
|
|
||||||
export function OmniParamBar({
|
export function OmniParamBar({
|
||||||
isVideo,
|
isVideo,
|
||||||
disabled,
|
disabled,
|
||||||
@@ -27,6 +83,7 @@ export function OmniParamBar({
|
|||||||
resolution,
|
resolution,
|
||||||
ratio,
|
ratio,
|
||||||
duration,
|
duration,
|
||||||
|
catalogModels,
|
||||||
onModel,
|
onModel,
|
||||||
onResolution,
|
onResolution,
|
||||||
onRatio,
|
onRatio,
|
||||||
@@ -38,6 +95,7 @@ export function OmniParamBar({
|
|||||||
resolution: string;
|
resolution: string;
|
||||||
ratio: string;
|
ratio: string;
|
||||||
duration: string;
|
duration: string;
|
||||||
|
catalogModels?: ModelConfig[];
|
||||||
onModel: (value: string) => void;
|
onModel: (value: string) => void;
|
||||||
onResolution: (value: string) => void;
|
onResolution: (value: string) => void;
|
||||||
onRatio: (value: string) => void;
|
onRatio: (value: string) => void;
|
||||||
@@ -47,6 +105,20 @@ export function OmniParamBar({
|
|||||||
const [customOn, setCustomOn] = useState(isVideo ? duration !== "智能时长" : true);
|
const [customOn, setCustomOn] = useState(isVideo ? duration !== "智能时长" : true);
|
||||||
const wrapRef = useRef<HTMLDivElement>(null);
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const capability = isVideo ? "video" as const : "image" as const;
|
||||||
|
const selected = useMemo(
|
||||||
|
() => findCatalogModel(catalogModels, model, capability),
|
||||||
|
[catalogModels, model, capability],
|
||||||
|
);
|
||||||
|
|
||||||
|
const models = withCurrent(
|
||||||
|
catalogModelLabels(catalogModels, capability, isVideo ? OMNI_VIDEO_MODELS : OMNI_IMAGE_MODELS),
|
||||||
|
model,
|
||||||
|
);
|
||||||
|
const allowedRes = isVideo ? modelResolutions(selected) : [];
|
||||||
|
const resolutions = withCurrent(allowedRes.length ? allowedRes : OMNI_RESOLUTIONS, resolution);
|
||||||
|
const durations = withCurrent(durationLabelsForModel(selected, isVideo), duration);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCustomOn(isVideo ? duration !== "智能时长" : true);
|
setCustomOn(isVideo ? duration !== "智能时长" : true);
|
||||||
}, [isVideo, duration]);
|
}, [isVideo, duration]);
|
||||||
@@ -60,8 +132,18 @@ export function OmniParamBar({
|
|||||||
return () => document.removeEventListener("mousedown", onDown);
|
return () => document.removeEventListener("mousedown", onDown);
|
||||||
}, [menuOpen]);
|
}, [menuOpen]);
|
||||||
|
|
||||||
const models = withCurrent(isVideo ? OMNI_VIDEO_MODELS : OMNI_IMAGE_MODELS, model);
|
useEffect(() => {
|
||||||
const durations = isVideo ? OMNI_VIDEO_DURATIONS : OMNI_IMAGE_COUNTS;
|
if (!selected || !isVideo) return;
|
||||||
|
const nextRes = modelResolutions(selected);
|
||||||
|
if (nextRes.length && resolution && !nextRes.includes(resolution)) {
|
||||||
|
onResolution(nextRes.includes("720p") ? "720p" : nextRes[0]);
|
||||||
|
}
|
||||||
|
const nextDur = durationLabelsForModel(selected, true);
|
||||||
|
if (duration && duration !== "智能时长" && !nextDur.includes(duration)) {
|
||||||
|
onDuration(nextDur.includes("8 秒") ? "8 秒" : (nextDur.find((x) => x !== "智能时长") || "智能时长"));
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selected?.id, isVideo]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -84,7 +166,7 @@ export function OmniParamBar({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
value={resolution}
|
value={resolution}
|
||||||
onChange={onResolution}
|
onChange={onResolution}
|
||||||
options={toOptions(withCurrent(OMNI_RESOLUTIONS, resolution))}
|
options={toOptions(resolutions)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="omni-parameter">
|
<label className="omni-parameter">
|
||||||
@@ -136,7 +218,7 @@ export function OmniParamBar({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="omni-duration-values" hidden={isVideo && !customOn}>
|
<div className="omni-duration-values" hidden={isVideo && !customOn}>
|
||||||
{durations.map((value) => (
|
{(isVideo ? durations.filter((value) => value !== "智能时长") : durations).map((value) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={value}
|
key={value}
|
||||||
|
|||||||
@@ -1650,7 +1650,8 @@ select.duration-select:focus,
|
|||||||
max-width: none;
|
max-width: none;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
z-index: calc(var(--z-overlay) + 40);
|
/* 必须高于 .modal-bg(999) / .admin-drawer(1800),否则弹窗里点开下拉看不见、点不着 */
|
||||||
|
z-index: 1900;
|
||||||
}
|
}
|
||||||
.rs-select-group {
|
.rs-select-group {
|
||||||
padding: 8px 10px 4px;
|
padding: 8px 10px 4px;
|
||||||
|
|||||||
@@ -646,23 +646,21 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.fc-page .fc-estimate {
|
.fc-page .fc-estimate {
|
||||||
font-size: 11px;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
color: var(--fc-muted);
|
color: var(--fc-muted);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
margin-right: 2px;
|
||||||
}
|
}
|
||||||
.fc-page .fc-clear {
|
.fc-page .fc-estimate svg {
|
||||||
height: 32px;
|
width: 14px;
|
||||||
padding: 0 10px;
|
height: 14px;
|
||||||
border: 0;
|
flex-shrink: 0;
|
||||||
border-radius: 8px;
|
|
||||||
color: var(--fc-muted);
|
color: var(--fc-muted);
|
||||||
background: transparent;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
.fc-page .fc-clear:hover { color: var(--accent-black); background: rgba(34, 42, 54, 0.06); }
|
|
||||||
.fc-page .fc-gen {
|
.fc-page .fc-gen {
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
height: 42px;
|
height: 42px;
|
||||||
|
|||||||
@@ -1345,7 +1345,8 @@
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-confirm-card button {
|
/* 只染脚上的确认按钮。参数条里的 CustomSelect / 时长触发器不能吃到克莱因蓝。 */
|
||||||
|
.omni-confirm-foot > button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -1358,12 +1359,12 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-confirm-card button:disabled {
|
.omni-confirm-foot > button:disabled {
|
||||||
background: rgba(34, 42, 54, .18);
|
background: rgba(34, 42, 54, .18);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-confirm-card button i {
|
.omni-confirm-foot > button i {
|
||||||
color: rgba(255, 255, 255, .72);
|
color: rgba(255, 255, 255, .72);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
|
|||||||
@@ -1018,7 +1018,7 @@
|
|||||||
.as-action-bar {
|
.as-action-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: flex-end;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
border-top: 1px solid rgba(34, 42, 54, 0.09);
|
border-top: 1px solid rgba(34, 42, 54, 0.09);
|
||||||
@@ -1490,6 +1490,7 @@
|
|||||||
.setup-card .tpl-expect { font-size: 12px; line-height: 1.6; color: var(--black-alpha-56); background: var(--heat-8); border-radius: var(--r-sm); padding: 8px 10px; margin-bottom: 8px; }
|
.setup-card .tpl-expect { font-size: 12px; line-height: 1.6; color: var(--black-alpha-56); background: var(--heat-8); border-radius: var(--r-sm); padding: 8px 10px; margin-bottom: 8px; }
|
||||||
.setup-card .tpl-expect .mono { font-family: var(--font-mono); font-size: 10px; color: var(--heat); letter-spacing: .04em; margin-right: 6px; }
|
.setup-card .tpl-expect .mono { font-family: var(--font-mono); font-size: 10px; color: var(--heat); letter-spacing: .04em; margin-right: 6px; }
|
||||||
.setup-card .setup-foot { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-top: 4px; }
|
.setup-card .setup-foot { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-top: 4px; }
|
||||||
|
.setup-card .setup-cost-hint { color: var(--pl-muted); font-size: 10px; white-space: nowrap; margin-right: 2px; }
|
||||||
|
|
||||||
/* ── 行34 · 添加分镜:本地草稿可编辑卡片 ── */
|
/* ── 行34 · 添加分镜:本地草稿可编辑卡片 ── */
|
||||||
.draft-shot-card { border-style: dashed; }
|
.draft-shot-card { border-style: dashed; }
|
||||||
|
|||||||
@@ -7,6 +7,18 @@ import { CustomSelect } from "../../components/custom-select";
|
|||||||
import type { AdminModel, AdminProvider } from "../../types";
|
import type { AdminModel, AdminProvider } from "../../types";
|
||||||
import { pts } from "../stage-config";
|
import { pts } from "../stage-config";
|
||||||
|
|
||||||
|
|
||||||
|
function sortAdminModels(list: AdminModel[]): AdminModel[] {
|
||||||
|
// 默认最前,启用次之,停用靠后;同档按能力、供应商
|
||||||
|
return [...list].sort((a, b) => {
|
||||||
|
if (Boolean(a.is_default) !== Boolean(b.is_default)) return a.is_default ? -1 : 1;
|
||||||
|
if ((a.status === "active") !== (b.status === "active")) return a.status === "active" ? -1 : 1;
|
||||||
|
const cap = String(a.capability || "").localeCompare(String(b.capability || ""));
|
||||||
|
if (cap !== 0) return cap;
|
||||||
|
return String(a.provider_name || a.provider || "").localeCompare(String(b.provider_name || b.provider || ""));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
@@ -20,7 +32,100 @@ function statusPill(active: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_PROVIDER = { id: "", name: "", display_name: "", base_url: "", api_key: "", status: "active" };
|
const EMPTY_PROVIDER = { id: "", name: "", display_name: "", base_url: "", api_key: "", status: "active" };
|
||||||
const EMPTY_MODEL = { id: "", provider: "", name: "", display_name: "", capability: "text", endpoint: "", unit_price: "", status: "active" };
|
const VIDEO_RES_OPTIONS = ["480p", "720p", "1080p", "4k"];
|
||||||
|
|
||||||
|
type VideoTier = { resolution: string; points_per_second: string; points_per_second_with_ref: string };
|
||||||
|
|
||||||
|
type ModelForm = {
|
||||||
|
id: string;
|
||||||
|
provider: string;
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
|
capability: string;
|
||||||
|
endpoint: string;
|
||||||
|
unit_price: string;
|
||||||
|
status: string;
|
||||||
|
durationsText: string;
|
||||||
|
videoTiers: VideoTier[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_MODEL: ModelForm = {
|
||||||
|
id: "", provider: "", name: "", display_name: "", capability: "text", endpoint: "",
|
||||||
|
unit_price: "", status: "active", durationsText: "4,5,6,8,10,12,15",
|
||||||
|
videoTiers: [{ resolution: "480p", points_per_second: "20", points_per_second_with_ref: "" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
function readModelForm(m: AdminModel): ModelForm {
|
||||||
|
const meta = (m.metadata || {}) as Record<string, unknown>;
|
||||||
|
const caps = (meta.capabilities || {}) as Record<string, unknown>;
|
||||||
|
const pricing = (meta.points_pricing || {}) as Record<string, unknown>;
|
||||||
|
const durations = Array.isArray(caps.durations) ? caps.durations.map(String) : [];
|
||||||
|
const tiersRaw = Array.isArray(pricing.tiers) ? pricing.tiers : [];
|
||||||
|
const videoTiers: VideoTier[] = tiersRaw
|
||||||
|
.filter((row): row is Record<string, unknown> => !!row && typeof row === "object")
|
||||||
|
.map((row) => ({
|
||||||
|
resolution: String(row.resolution || ""),
|
||||||
|
points_per_second: String(row.points_per_second ?? ""),
|
||||||
|
points_per_second_with_ref: String(row.points_per_second_with_ref ?? ""),
|
||||||
|
}));
|
||||||
|
let unit = m.unit_price || "";
|
||||||
|
if (m.capability === "image" && pricing.points_per_image != null) unit = String(pricing.points_per_image);
|
||||||
|
if ((m.capability === "text" || m.capability === "vision") && pricing.points_per_call != null) {
|
||||||
|
unit = String(pricing.points_per_call);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: m.id,
|
||||||
|
provider: m.provider,
|
||||||
|
name: m.name,
|
||||||
|
display_name: m.display_name,
|
||||||
|
capability: m.capability,
|
||||||
|
endpoint: m.endpoint,
|
||||||
|
unit_price: unit,
|
||||||
|
status: m.status,
|
||||||
|
durationsText: durations.length ? durations.join(",") : "4,5,6,8,10,12,15",
|
||||||
|
videoTiers: videoTiers.length
|
||||||
|
? videoTiers
|
||||||
|
: [{ resolution: "480p", points_per_second: "", points_per_second_with_ref: "" }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMetadata(form: ModelForm): Record<string, unknown> {
|
||||||
|
if (form.capability === "image") {
|
||||||
|
const pts = Number(form.unit_price || 0);
|
||||||
|
return { points_pricing: { mode: "per_image", points_per_image: Number.isFinite(pts) ? pts : 0 } };
|
||||||
|
}
|
||||||
|
if (form.capability === "text" || form.capability === "vision") {
|
||||||
|
const pts = Number(form.unit_price || 0);
|
||||||
|
return { points_pricing: { mode: "per_call", points_per_call: Number.isFinite(pts) ? pts : 0 } };
|
||||||
|
}
|
||||||
|
if (form.capability === "video") {
|
||||||
|
const durations = form.durationsText
|
||||||
|
.split(/[,,\s]+/)
|
||||||
|
.map((x) => Number(x))
|
||||||
|
.filter((n) => Number.isFinite(n) && n > 0);
|
||||||
|
const tiers = form.videoTiers
|
||||||
|
.filter((row) => row.resolution && row.points_per_second !== "")
|
||||||
|
.map((row) => {
|
||||||
|
const item: Record<string, unknown> = {
|
||||||
|
resolution: row.resolution,
|
||||||
|
points_per_second: Number(row.points_per_second),
|
||||||
|
};
|
||||||
|
if (row.points_per_second_with_ref !== "") {
|
||||||
|
item.points_per_second_with_ref = Number(row.points_per_second_with_ref);
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
capabilities: {
|
||||||
|
resolutions: tiers.map((t) => String(t.resolution)),
|
||||||
|
durations,
|
||||||
|
operations: ["video_generate"],
|
||||||
|
},
|
||||||
|
points_pricing: { mode: "per_second", tiers },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
export function AdminModelsPage({ notify }: { notify: Notify }) {
|
export function AdminModelsPage({ notify }: { notify: Notify }) {
|
||||||
const [providers, setProviders] = useState<AdminProvider[]>([]);
|
const [providers, setProviders] = useState<AdminProvider[]>([]);
|
||||||
@@ -28,7 +133,7 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
|
|||||||
const [modelPage, setModelPage] = useState(1);
|
const [modelPage, setModelPage] = useState(1);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [provModal, setProvModal] = useState<typeof EMPTY_PROVIDER | null>(null);
|
const [provModal, setProvModal] = useState<typeof EMPTY_PROVIDER | null>(null);
|
||||||
const [modelModal, setModelModal] = useState<typeof EMPTY_MODEL | null>(null);
|
const [modelModal, setModelModal] = useState<ModelForm | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -36,7 +141,7 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
|
|||||||
try {
|
try {
|
||||||
const [ps, ms] = await Promise.all([adminApi.providers(), adminApi.models()]);
|
const [ps, ms] = await Promise.all([adminApi.providers(), adminApi.models()]);
|
||||||
setProviders(ps);
|
setProviders(ps);
|
||||||
setModels(ms);
|
setModels(sortAdminModels(ms));
|
||||||
} catch {
|
} catch {
|
||||||
notify("error", "加载模型供应商失败");
|
notify("error", "加载模型供应商失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -101,12 +206,32 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
|
|||||||
async function saveModel() {
|
async function saveModel() {
|
||||||
if (!modelModal || saving) return;
|
if (!modelModal || saving) return;
|
||||||
if (!modelModal.provider || !modelModal.name.trim()) { notify("error", "请选择供应商并填写模型名"); return; }
|
if (!modelModal.provider || !modelModal.name.trim()) { notify("error", "请选择供应商并填写模型名"); return; }
|
||||||
|
if (modelModal.capability === "video") {
|
||||||
|
const ok = modelModal.videoTiers.some((row) => row.resolution && row.points_per_second !== "");
|
||||||
|
if (!ok) { notify("error", "视频模型请至少添加一档分辨率和每秒积分"); return; }
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
|
const metadata = buildMetadata(modelModal);
|
||||||
if (modelModal.id) {
|
if (modelModal.id) {
|
||||||
await adminApi.updateModel(modelModal.id, { display_name: modelModal.display_name, endpoint: modelModal.endpoint, unit_price: modelModal.unit_price || "0", status: modelModal.status });
|
await adminApi.updateModel(modelModal.id, {
|
||||||
|
display_name: modelModal.display_name,
|
||||||
|
endpoint: modelModal.endpoint,
|
||||||
|
unit_price: modelModal.unit_price || "0",
|
||||||
|
status: modelModal.status,
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await adminApi.createModel({ provider: modelModal.provider, name: modelModal.name, display_name: modelModal.display_name || modelModal.name, capability: modelModal.capability, endpoint: modelModal.endpoint, unit_price: modelModal.unit_price || "0", status: modelModal.status });
|
await adminApi.createModel({
|
||||||
|
provider: modelModal.provider,
|
||||||
|
name: modelModal.name,
|
||||||
|
display_name: modelModal.display_name || modelModal.name,
|
||||||
|
capability: modelModal.capability,
|
||||||
|
endpoint: modelModal.endpoint,
|
||||||
|
unit_price: modelModal.unit_price || "0",
|
||||||
|
status: modelModal.status,
|
||||||
|
metadata,
|
||||||
|
} as Parameters<typeof adminApi.createModel>[0] & { metadata: Record<string, unknown> });
|
||||||
}
|
}
|
||||||
notify("success", "模型已保存");
|
notify("success", "模型已保存");
|
||||||
setModelModal(null);
|
setModelModal(null);
|
||||||
@@ -170,7 +295,7 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
|
|||||||
<td>{m.is_default ? <span className="pill info"><span className="dot" />默认</span> : <span className="muted">—</span>}</td>
|
<td>{m.is_default ? <span className="pill info"><span className="dot" />默认</span> : <span className="muted">—</span>}</td>
|
||||||
<td className="col-actions">
|
<td className="col-actions">
|
||||||
{!m.is_default && <button className="btn btn-sm btn-ghost" type="button" onClick={() => setDefault(m)}>设默认</button>}
|
{!m.is_default && <button className="btn btn-sm btn-ghost" type="button" onClick={() => setDefault(m)}>设默认</button>}
|
||||||
<button className="btn btn-sm btn-ghost" type="button" onClick={() => setModelModal({ id: m.id, provider: m.provider, name: m.name, display_name: m.display_name, capability: m.capability, endpoint: m.endpoint, unit_price: m.unit_price, status: m.status })}>编辑</button>
|
<button className="btn btn-sm btn-ghost" type="button" onClick={() => setModelModal(readModelForm(m))}>编辑</button>
|
||||||
<button className={`btn btn-sm btn-ghost${m.status === "active" ? " danger" : ""}`} type="button" onClick={() => toggleModel(m)}>{m.status === "active" ? "停用" : "启用"}</button>
|
<button className={`btn btn-sm btn-ghost${m.status === "active" ? " danger" : ""}`} type="button" onClick={() => toggleModel(m)}>{m.status === "active" ? "停用" : "启用"}</button>
|
||||||
<button className="btn btn-sm btn-ghost danger" type="button" onClick={() => delModel(m)}>删除</button>
|
<button className="btn btn-sm btn-ghost danger" type="button" onClick={() => delModel(m)}>删除</button>
|
||||||
</td>
|
</td>
|
||||||
@@ -184,7 +309,7 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{provModal && (
|
{provModal && (
|
||||||
<div className="modal-bg show" onClick={(e) => { if (e.target === e.currentTarget) setProvModal(null); }}>
|
<div className="modal-bg show">
|
||||||
<div className="modal" role="dialog" aria-modal="true" aria-label="供应商">
|
<div className="modal" role="dialog" aria-modal="true" aria-label="供应商">
|
||||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||||
<div className="modal-h">
|
<div className="modal-h">
|
||||||
@@ -219,7 +344,7 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{modelModal && (
|
{modelModal && (
|
||||||
<div className="modal-bg show" onClick={(e) => { if (e.target === e.currentTarget) setModelModal(null); }}>
|
<div className="modal-bg show">
|
||||||
<div className="modal" role="dialog" aria-modal="true" aria-label="模型">
|
<div className="modal" role="dialog" aria-modal="true" aria-label="模型">
|
||||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||||
<div className="modal-h">
|
<div className="modal-h">
|
||||||
@@ -259,16 +384,89 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
|
|||||||
<label className="field-label">显示名</label>
|
<label className="field-label">显示名</label>
|
||||||
<input className="input" type="text" value={modelModal.display_name} onChange={(e) => setModelModal((m) => m && ({ ...m, display_name: e.target.value }))} />
|
<input className="input" type="text" value={modelModal.display_name} onChange={(e) => setModelModal((m) => m && ({ ...m, display_name: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
<div className="field-row">
|
<div className="field">
|
||||||
|
<label className="field-label">endpoint</label>
|
||||||
|
<input className="input" type="text" value={modelModal.endpoint} onChange={(e) => setModelModal((m) => m && ({ ...m, endpoint: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
{modelModal.capability === "image" ? (
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">积分规则 · 每张</label>
|
||||||
|
<input className="input" type="number" min={0} step={1} placeholder="例如 10" value={modelModal.unit_price} onChange={(e) => setModelModal((m) => m && ({ ...m, unit_price: e.target.value }))} />
|
||||||
|
<div className="field-hint">老板填写:用该模型出 1 张图扣多少积分</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{modelModal.capability === "text" || modelModal.capability === "vision" ? (
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">积分规则 · 每次</label>
|
||||||
|
<input className="input" type="number" min={0} step={1} placeholder="例如 4" value={modelModal.unit_price} onChange={(e) => setModelModal((m) => m && ({ ...m, unit_price: e.target.value }))} />
|
||||||
|
<div className="field-hint">老板填写:调用 1 次扣多少积分</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{modelModal.capability === "video" ? (
|
||||||
|
<>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">可用时长(秒,逗号分隔)</label>
|
||||||
|
<input className="input" type="text" value={modelModal.durationsText} onChange={(e) => setModelModal((m) => m && ({ ...m, durationsText: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">分辨率积分(每秒)</label>
|
||||||
|
<div className="field-hint">每个分辨率一档;含视频参考可另填更高积分,不填则同普通价</div>
|
||||||
|
<div style={{ display: "grid", gap: 8, marginTop: 8 }}>
|
||||||
|
{modelModal.videoTiers.map((tier, index) => (
|
||||||
|
<div key={index} className="field-row" style={{ alignItems: "end" }}>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">分辨率</label>
|
||||||
|
<CustomSelect
|
||||||
|
fill
|
||||||
|
value={tier.resolution}
|
||||||
|
onChange={(next) => setModelModal((m) => {
|
||||||
|
if (!m) return m;
|
||||||
|
const videoTiers = m.videoTiers.map((row, i) => i === index ? { ...row, resolution: next } : row);
|
||||||
|
return { ...m, videoTiers };
|
||||||
|
})}
|
||||||
|
options={VIDEO_RES_OPTIONS.map((r) => ({ value: r, label: r }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">积分/秒</label>
|
||||||
|
<input className="input" type="number" min={0} step={1} value={tier.points_per_second} onChange={(e) => setModelModal((m) => {
|
||||||
|
if (!m) return m;
|
||||||
|
const videoTiers = m.videoTiers.map((row, i) => i === index ? { ...row, points_per_second: e.target.value } : row);
|
||||||
|
return { ...m, videoTiers };
|
||||||
|
})} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">含视频参考·积分/秒</label>
|
||||||
|
<input className="input" type="number" min={0} step={1} placeholder="可选" value={tier.points_per_second_with_ref} onChange={(e) => setModelModal((m) => {
|
||||||
|
if (!m) return m;
|
||||||
|
const videoTiers = m.videoTiers.map((row, i) => i === index ? { ...row, points_per_second_with_ref: e.target.value } : row);
|
||||||
|
return { ...m, videoTiers };
|
||||||
|
})} />
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-sm btn-ghost" type="button" onClick={() => setModelModal((m) => m && ({ ...m, videoTiers: m.videoTiers.filter((_, i) => i !== index) }))}>删除</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
type="button"
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
onClick={() => setModelModal((m) => m && ({
|
||||||
|
...m,
|
||||||
|
videoTiers: [...m.videoTiers, { resolution: "720p", points_per_second: "", points_per_second_with_ref: "" }],
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
+ 添加分辨率
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{!["image", "text", "vision", "video"].includes(modelModal.capability) ? (
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="field-label">单价(积分/次)</label>
|
<label className="field-label">单价(积分/次)</label>
|
||||||
<input className="input" type="text" placeholder="0" value={modelModal.unit_price} onChange={(e) => setModelModal((m) => m && ({ ...m, unit_price: e.target.value }))} />
|
<input className="input" type="text" placeholder="0" value={modelModal.unit_price} onChange={(e) => setModelModal((m) => m && ({ ...m, unit_price: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
) : null}
|
||||||
<label className="field-label">endpoint</label>
|
|
||||||
<input className="input" type="text" value={modelModal.endpoint} onChange={(e) => setModelModal((m) => m && ({ ...m, endpoint: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-f">
|
<div className="modal-f">
|
||||||
<button className="btn" type="button" onClick={() => setModelModal(null)}>取消</button>
|
<button className="btn" type="button" onClick={() => setModelModal(null)}>取消</button>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const PAGE_SIZE = 10;
|
|||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ key: "", label: "全部" },
|
{ key: "", label: "全部" },
|
||||||
|
{ key: "generating", label: "生成中" },
|
||||||
{ key: "failed", label: "失败" },
|
{ key: "failed", label: "失败" },
|
||||||
{ key: "succeeded", label: "成功" }
|
{ key: "succeeded", label: "成功" }
|
||||||
];
|
];
|
||||||
@@ -25,11 +26,25 @@ function fmtDate(iso: string) {
|
|||||||
return `${d.getFullYear()}/${p(d.getMonth() + 1)}/${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
return `${d.getFullYear()}/${p(d.getMonth() + 1)}/${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
|
created: "已创建",
|
||||||
|
reserved: "已预留",
|
||||||
|
submitted: "已提交",
|
||||||
|
polling: "生成中",
|
||||||
|
postprocessing: "后处理",
|
||||||
|
succeeded: "成功",
|
||||||
|
failed: "失败",
|
||||||
|
cancelled: "已取消",
|
||||||
|
compensating: "补偿中",
|
||||||
|
};
|
||||||
|
|
||||||
function statusPill(status: string) {
|
function statusPill(status: string) {
|
||||||
if (status === "succeeded") return <span className="pill ok"><span className="dot" />成功</span>;
|
if (status === "succeeded") return <span className="pill ok"><span className="dot" />成功</span>;
|
||||||
if (status === "failed") return <span className="pill err"><span className="dot" />失败</span>;
|
if (status === "failed") return <span className="pill err"><span className="dot" />失败</span>;
|
||||||
if (["cancelled", "compensating"].includes(status)) return <span className="pill neutral"><span className="dot" />{status}</span>;
|
if (["cancelled", "compensating"].includes(status)) {
|
||||||
return <span className="pill info"><span className="dot" />进行中</span>;
|
return <span className="pill neutral"><span className="dot" />{STATUS_LABEL[status] || status}</span>;
|
||||||
|
}
|
||||||
|
return <span className="pill info"><span className="dot" />{STATUS_LABEL[status] || "生成中"}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function attemptKind(attempt: AdminTaskDetail["attempts"][number]) {
|
function attemptKind(attempt: AdminTaskDetail["attempts"][number]) {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
X
|
X
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { AITask, Asset, ImageConversation, ImageConversationTask, ModelConfig, ModelEntity, Product, WorkbenchTask } from "../types";
|
import type { AITask, Asset, ImageConversation, ImageConversationTask, ModelConfig, ModelEntity, Product, WorkbenchTask } from "../types";
|
||||||
|
import { pointsPerImageFromCatalog } from "../components/free-create/constants";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { useFileDrop } from "../components/use-file-drop";
|
import { useFileDrop } from "../components/use-file-drop";
|
||||||
import { imageModelPickerOptions } from "../model-display";
|
import { imageModelPickerOptions } from "../model-display";
|
||||||
@@ -727,13 +728,13 @@ export function ImageWorkbenchPage({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void api.billingConfig().then((cfg) => setPriceMultiplier(Number(cfg.team_price_multiplier) || 1)).catch(() => undefined);
|
void api.billingConfig().then((cfg) => setPriceMultiplier(Number(cfg.team_price_multiplier) || 1)).catch(() => undefined);
|
||||||
}, []);
|
}, []);
|
||||||
// 每张图实扣单价:取「当前选的生图模型」的 unit_price(火山/gpt-image),没匹配上用第一个图像模型,
|
// 每张图实扣单价:优先后台挂牌 points_pricing.points_per_image,否则 unit_price,再兜底 20。
|
||||||
// 再兜底 20 积分(后端 quote_flat=unit_price 积分/张,默认模型 gpt-image-2=20 积分)。前端预估据此算,和后端实扣一致(PMC#20)。
|
// 与后端 quote_flat / image_points_per_unit 同口径(PMC#20)。
|
||||||
const perImagePrice = (() => {
|
const perImagePrice = (() => {
|
||||||
const want = genModel === "gpt-image" ? "gpt-image" : genModel === "volcano" ? "seedream" : genModel;
|
const want = genModel === "gpt-image" ? "gpt-image" : genModel === "volcano" ? "seedream" : genModel;
|
||||||
const m = imageModels.find((x) => x.name.toLowerCase().includes(want)) || imageModels[0];
|
const m = imageModels.find((x) => x.name.toLowerCase().includes(want)) || imageModels[0];
|
||||||
const p = Number(m?.unit_price);
|
const listed = pointsPerImageFromCatalog(m);
|
||||||
return Number.isFinite(p) && p > 0 ? p : 20;
|
return listed != null && listed > 0 ? listed : 20;
|
||||||
})();
|
})();
|
||||||
// 与后端逐张对齐:每张 = 挂牌单价取整 × 系数 → HALF_UP 最低 1,总价 = 张数 × 单张
|
// 与后端逐张对齐:每张 = 挂牌单价取整 × 系数 → HALF_UP 最低 1,总价 = 张数 × 单张
|
||||||
// (不能先乘张数再取整:0.85 系数 × 3 张会比后端逐张各取整少 1-2 积分,review 确认)
|
// (不能先乘张数再取整:0.85 系数 × 3 张会比后端逐张各取整少 1-2 积分,review 确认)
|
||||||
|
|||||||
@@ -567,10 +567,6 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
|
|||||||
window.setTimeout(() => promptRef.current?.insertMention({ label, thumb: ref.thumb_url || (ref.type === "image" ? ref.url : "") }), 0);
|
window.setTimeout(() => promptRef.current?.insertMention({ label, thumb: ref.thumb_url || (ref.type === "image" ? ref.url : "") }), 0);
|
||||||
}, [mode, refs, libraryTargetRole, notify]);
|
}, [mode, refs, libraryTargetRole, notify]);
|
||||||
|
|
||||||
const clearInput = useCallback(() => {
|
|
||||||
promptRef.current?.clear();
|
|
||||||
setRefs([]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const filterCounts = useMemo(() => ({
|
const filterCounts = useMemo(() => ({
|
||||||
all: tasks.length,
|
all: tasks.length,
|
||||||
@@ -701,7 +697,6 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
|
|||||||
onSeedChange={setSeed}
|
onSeedChange={setSeed}
|
||||||
onOpenLibrary={openLibrary}
|
onOpenLibrary={openLibrary}
|
||||||
onOpenPlatformLibrary={openPlatformLibrary}
|
onOpenPlatformLibrary={openPlatformLibrary}
|
||||||
onClear={clearInput}
|
|
||||||
onSend={() => void handleSend()}
|
onSend={() => void handleSend()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { OmniParamBar } from "../components/omni-param-bar";
|
import { findCatalogModel } from "../components/omni-param-bar";
|
||||||
|
import { modelResolutions } from "../components/free-create/constants";
|
||||||
import { ConfirmModal } from "../components/overlays";
|
import { ConfirmModal } from "../components/overlays";
|
||||||
import type { CreationConversation, CreationRef } from "../types";
|
import type { CreationConversation, CreationRef, ModelConfig } from "../types";
|
||||||
import type { NavigateFn } from "./route-config";
|
import type { NavigateFn } from "./route-config";
|
||||||
|
|
||||||
type OutputMode = "video" | "image";
|
type OutputMode = "video" | "image";
|
||||||
@@ -102,9 +103,11 @@ function formatRelativeTime(iso: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function OmniCreatePage({
|
export function OmniCreatePage({
|
||||||
|
modelConfigs,
|
||||||
navigate,
|
navigate,
|
||||||
onNotify,
|
onNotify,
|
||||||
}: {
|
}: {
|
||||||
|
modelConfigs?: ModelConfig[];
|
||||||
navigate: NavigateFn;
|
navigate: NavigateFn;
|
||||||
onNotify?: (type: "success" | "error" | "info", text: string) => void;
|
onNotify?: (type: "success" | "error" | "info", text: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -132,19 +135,23 @@ export function OmniCreatePage({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (outputMode === "image") {
|
if (outputMode === "image") {
|
||||||
setModel("Seedream5.0");
|
const img = findCatalogModel(modelConfigs, "", "image");
|
||||||
|
setModel(img ? (img.display_name || img.name) : "Seedream5.0");
|
||||||
setResolution("模型默认");
|
setResolution("模型默认");
|
||||||
setRatio("1:1");
|
setRatio("1:1");
|
||||||
setDuration("1 张");
|
setDuration("1 张");
|
||||||
} else {
|
} else {
|
||||||
setModel("Seedance 2.5");
|
const vid = findCatalogModel(modelConfigs, "", "video");
|
||||||
setResolution("1080p");
|
const label = vid ? (vid.display_name || vid.name) : "Seedance 2.5";
|
||||||
|
const resList = modelResolutions(vid);
|
||||||
|
setModel(label);
|
||||||
|
setResolution(resList.includes("720p") ? "720p" : (resList[0] || "1080p"));
|
||||||
setRatio("9:16");
|
setRatio("9:16");
|
||||||
setDuration("智能时长");
|
setDuration("智能时长");
|
||||||
}
|
}
|
||||||
setActiveCategory("all");
|
setActiveCategory("all");
|
||||||
setSelectedCase((prev) => (prev && prev.mode !== outputMode ? null : prev));
|
setSelectedCase((prev) => (prev && prev.mode !== outputMode ? null : prev));
|
||||||
}, [outputMode]);
|
}, [outputMode, modelConfigs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onDown = (event: MouseEvent) => {
|
const onDown = (event: MouseEvent) => {
|
||||||
@@ -385,17 +392,6 @@ export function OmniCreatePage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<OmniParamBar
|
|
||||||
isVideo={outputMode === "video"}
|
|
||||||
model={model}
|
|
||||||
resolution={resolution}
|
|
||||||
ratio={ratio}
|
|
||||||
duration={duration}
|
|
||||||
onModel={setModel}
|
|
||||||
onResolution={setResolution}
|
|
||||||
onRatio={setRatio}
|
|
||||||
onDuration={setDuration}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -595,6 +591,10 @@ export function OmniHistoryPage({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" className="omni-history-new" onClick={() => navigate("omniCreate")}>
|
||||||
|
<Plus />
|
||||||
|
新建会话
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="omni-history-list">
|
<div className="omni-history-list">
|
||||||
|
|||||||
@@ -17,13 +17,15 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { OmniParamBar } from "../components/omni-param-bar";
|
import { findCatalogModel, OmniParamBar } from "../components/omni-param-bar";
|
||||||
|
import { estimateCost, pointsPerImageFromCatalog } from "../components/free-create/constants";
|
||||||
import { MediaLightbox } from "../components/overlays";
|
import { MediaLightbox } from "../components/overlays";
|
||||||
import type {
|
import type {
|
||||||
CreationConversationDetail,
|
CreationConversationDetail,
|
||||||
CreationField,
|
CreationField,
|
||||||
CreationMessage,
|
CreationMessage,
|
||||||
CreationRef,
|
CreationRef,
|
||||||
|
ModelConfig,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import type { NavigateFn } from "./route-config";
|
import type { NavigateFn } from "./route-config";
|
||||||
|
|
||||||
@@ -421,17 +423,19 @@ function ConfirmCard({
|
|||||||
message,
|
message,
|
||||||
sessionParams,
|
sessionParams,
|
||||||
isVideo,
|
isVideo,
|
||||||
|
catalogModels,
|
||||||
disabled,
|
disabled,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: {
|
}: {
|
||||||
message: CreationMessage;
|
message: CreationMessage;
|
||||||
sessionParams: Record<string, string>;
|
sessionParams: Record<string, string>;
|
||||||
isVideo: boolean;
|
isVideo: boolean;
|
||||||
|
catalogModels?: ModelConfig[];
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
onConfirm: (params: Record<string, string>) => void;
|
onConfirm: (params: Record<string, string>) => void;
|
||||||
}) {
|
}) {
|
||||||
const submitted = Boolean(message.payload.submitted);
|
const submitted = Boolean(message.payload.submitted);
|
||||||
const credits = Number(message.payload.estimated_credits || 0);
|
const payloadCredits = Number(message.payload.estimated_credits || 0);
|
||||||
const payloadParams = asStringMap(message.payload.params);
|
const payloadParams = asStringMap(message.payload.params);
|
||||||
const snapshot = { ...sessionParams, ...payloadParams };
|
const snapshot = { ...sessionParams, ...payloadParams };
|
||||||
const [draft, setDraft] = useState(snapshot);
|
const [draft, setDraft] = useState(snapshot);
|
||||||
@@ -440,6 +444,27 @@ function ConfirmCard({
|
|||||||
cardIsVideo && Boolean(draft.duration) && Boolean(snapshot.duration) && draft.duration !== snapshot.duration;
|
cardIsVideo && Boolean(draft.duration) && Boolean(snapshot.duration) && draft.duration !== snapshot.duration;
|
||||||
const setField = (key: string, value: string) => setDraft((prev) => ({ ...prev, [key]: value }));
|
const setField = (key: string, value: string) => setDraft((prev) => ({ ...prev, [key]: value }));
|
||||||
const summary = paramLine(draft, cardIsVideo) || "当前参数";
|
const summary = paramLine(draft, cardIsVideo) || "当前参数";
|
||||||
|
// 确认卡积分:改模型/分辨率/时长/张数时按后台挂牌实时重算(与后端 quote_* 同口径;标准团队系数=1)
|
||||||
|
const credits = (() => {
|
||||||
|
if (cardIsVideo) {
|
||||||
|
const model = findCatalogModel(catalogModels, draft.model || "", "video");
|
||||||
|
const resolution = (draft.resolution || "720p").toLowerCase();
|
||||||
|
const raw = String(draft.duration || "");
|
||||||
|
const digits = raw.replace(/\D/g, "");
|
||||||
|
// 与后端 video_duration 一致:「智能时长」/解析不出 → 15 秒
|
||||||
|
const duration = digits ? Math.max(4, Math.min(Number(digits), 30)) : 15;
|
||||||
|
const est = estimateCost(model, { ratio: draft.ratio || "9:16", resolution, duration, refs: [] });
|
||||||
|
if (est.listed && est.points > 0) return est.points;
|
||||||
|
// 挂牌缺失时仍展示后端下发的预估(若有)
|
||||||
|
return payloadCredits;
|
||||||
|
}
|
||||||
|
const model = findCatalogModel(catalogModels, draft.model || "", "image");
|
||||||
|
const unit = pointsPerImageFromCatalog(model);
|
||||||
|
if (unit == null || unit <= 0) return payloadCredits;
|
||||||
|
const countLabel = String(draft.count || draft.duration || "1");
|
||||||
|
const count = Math.max(1, Math.min(8, parseInt(countLabel, 10) || 1));
|
||||||
|
return unit * count;
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="omni-confirm-card">
|
<section className="omni-confirm-card">
|
||||||
@@ -452,6 +477,7 @@ function ConfirmCard({
|
|||||||
<OmniParamBar
|
<OmniParamBar
|
||||||
isVideo={cardIsVideo}
|
isVideo={cardIsVideo}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
catalogModels={catalogModels}
|
||||||
model={draft.model || ""}
|
model={draft.model || ""}
|
||||||
resolution={draft.resolution || ""}
|
resolution={draft.resolution || ""}
|
||||||
ratio={draft.ratio || ""}
|
ratio={draft.ratio || ""}
|
||||||
@@ -636,6 +662,7 @@ export function OmniSessionPage({
|
|||||||
firstMessage,
|
firstMessage,
|
||||||
firstRefs,
|
firstRefs,
|
||||||
firstUploads,
|
firstUploads,
|
||||||
|
modelConfigs,
|
||||||
navigate,
|
navigate,
|
||||||
onNotify,
|
onNotify,
|
||||||
}: {
|
}: {
|
||||||
@@ -644,6 +671,7 @@ export function OmniSessionPage({
|
|||||||
firstMessage?: string;
|
firstMessage?: string;
|
||||||
firstRefs?: CreationRef[];
|
firstRefs?: CreationRef[];
|
||||||
firstUploads?: CreationRef[];
|
firstUploads?: CreationRef[];
|
||||||
|
modelConfigs?: ModelConfig[];
|
||||||
navigate: NavigateFn;
|
navigate: NavigateFn;
|
||||||
onNotify?: (type: "success" | "error" | "info", text: string) => void;
|
onNotify?: (type: "success" | "error" | "info", text: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -1041,6 +1069,7 @@ export function OmniSessionPage({
|
|||||||
message={message}
|
message={message}
|
||||||
sessionParams={params}
|
sessionParams={params}
|
||||||
isVideo={isVideo}
|
isVideo={isVideo}
|
||||||
|
catalogModels={modelConfigs}
|
||||||
disabled={streaming || confirming}
|
disabled={streaming || confirming}
|
||||||
onConfirm={(nextParams) => void handleConfirm(message, nextParams)}
|
onConfirm={(nextParams) => void handleConfirm(message, nextParams)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Fragment, memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
|
import { Fragment, memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import type { ChangeEvent, CSSProperties, PointerEvent as ReactPointerEvent } from "react";
|
import type { ChangeEvent, CSSProperties, PointerEvent as ReactPointerEvent } from "react";
|
||||||
import { ArrowLeft, ArrowRight, Check, ChevronRight, Image, Info, LayoutList, Play, RefreshCw, Route, Sparkles, Upload, UsersRound, X } from "lucide-react";
|
import { ArrowLeft, ArrowRight, Check, ChevronRight, Image, LayoutList, Play, RefreshCw, Route, Sparkles, Upload, UsersRound, X } from "lucide-react";
|
||||||
import { api, ApiError } from "../api";
|
import { api, ApiError } from "../api";
|
||||||
import type { Asset, BillingSummary, ExportPoll, ModelConfig, Product, Project, Team, TimelineSavePayload, User } from "../types";
|
import type { Asset, BillingSummary, ExportPoll, ModelConfig, Product, Project, Team, TimelineSavePayload, User } from "../types";
|
||||||
import { isHiddenFromScriptPicker, publicModelDisplayName } from "../model-display";
|
import { isHiddenFromScriptPicker, publicModelDisplayName } from "../model-display";
|
||||||
@@ -10,7 +10,7 @@ import type { Notice, Page } from "./route-config";
|
|||||||
import { stageOrder, statusPill } from "./stage-config";
|
import { stageOrder, statusPill } from "./stage-config";
|
||||||
import { ConfirmModal, MediaLightbox, TeamModal, useBodyScrollLock } from "../components/overlays";
|
import { ConfirmModal, MediaLightbox, TeamModal, useBodyScrollLock } from "../components/overlays";
|
||||||
import { CustomSelect } from "../components/custom-select";
|
import { CustomSelect } from "../components/custom-select";
|
||||||
import { DEFAULT_BILLING_RATES, estimateCost, FC_MODELS, modelLabel } from "../components/free-create/constants";
|
import { DEFAULT_BILLING_RATES, estimateCost, FC_MODELS, modelLabel, pointsPerImageFromCatalog } from "../components/free-create/constants";
|
||||||
import { ModelLibrary } from "../components/model-library";
|
import { ModelLibrary } from "../components/model-library";
|
||||||
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
|
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
|
||||||
import {
|
import {
|
||||||
@@ -537,6 +537,7 @@ export function PipelinePage(props: {
|
|||||||
scriptModelName: string;
|
scriptModelName: string;
|
||||||
textModels?: ModelConfig[];
|
textModels?: ModelConfig[];
|
||||||
videoModels?: ModelConfig[];
|
videoModels?: ModelConfig[];
|
||||||
|
imageModels?: ModelConfig[];
|
||||||
onAdoptScript: (scriptId: string) => void | Promise<unknown>;
|
onAdoptScript: (scriptId: string) => void | Promise<unknown>;
|
||||||
onUpdateShot: (payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) => Promise<unknown>;
|
onUpdateShot: (payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) => Promise<unknown>;
|
||||||
onAddShot: (afterSegmentId: string, content?: { narration?: string; visual_prompt?: string }) => Promise<unknown>;
|
onAddShot: (afterSegmentId: string, content?: { narration?: string; visual_prompt?: string }) => Promise<unknown>;
|
||||||
@@ -575,7 +576,7 @@ export function PipelinePage(props: {
|
|||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
project, loading, navigate, products, assets, onNotify,
|
project, loading, navigate, products, assets, onNotify,
|
||||||
textModels, videoModels, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
|
textModels, videoModels, imageModels, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
|
||||||
onGenerateBaseAsset, onAdoptBaseAsset, onSetAdoptState, onDeleteBaseAsset, onAttachBaseAsset, onGenerateModel, onUploadModel, onGenerateTriview, onRenameModel,
|
onGenerateBaseAsset, onAdoptBaseAsset, onSetAdoptState, onDeleteBaseAsset, onAttachBaseAsset, onGenerateModel, onUploadModel, onGenerateTriview, onRenameModel,
|
||||||
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject, onRefreshBilling,
|
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject, onRefreshBilling,
|
||||||
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
|
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
|
||||||
@@ -971,14 +972,24 @@ export function PipelinePage(props: {
|
|||||||
if (extractState === "running") return;
|
if (extractState === "running") return;
|
||||||
setExtractErr("");
|
setExtractErr("");
|
||||||
setExtractState("running");
|
setExtractState("running");
|
||||||
setExtractMsg("正在从剧本认出角色 / 场景…");
|
setExtractMsg("正在整理角色 / 场景…");
|
||||||
try {
|
try {
|
||||||
await api.extractEntities(project.id); // 异步提交(已有在途则后端复用,不重复扣费),慢活在 worker 跑
|
// 后端本地拆实体:秒回 succeeded(不调模型、不扣费);兼容旧异步任务仍可轮询
|
||||||
pollExtractUntilDone(mode); // 轮询直到 worker 跑完
|
const res = await api.extractEntities(project.id) as { status?: string; entities?: ExtractEntity[] };
|
||||||
|
if (res?.status === "succeeded") {
|
||||||
|
await onRefreshProject();
|
||||||
|
if ((mode === "gen" || mode === "full") && (res.entities?.length ?? 0) > 0) {
|
||||||
|
await runGenForEntities(res.entities as ExtractEntity[], mode);
|
||||||
|
}
|
||||||
|
setExtractState("idle");
|
||||||
|
setExtractMsg("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pollExtractUntilDone(mode);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setExtractState("idle");
|
setExtractState("idle");
|
||||||
setExtractMsg("");
|
setExtractMsg("");
|
||||||
setExtractErr(err instanceof Error ? err.message : "提取失败,请重试");
|
setExtractErr(err instanceof Error ? err.message : "整理失败,请重试");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ── 流程步骤3 · 兜底弹窗拦截:生成视频前,查被引用的角色/场景是否都有「按名字采用」的参考图 ──
|
// ── 流程步骤3 · 兜底弹窗拦截:生成视频前,查被引用的角色/场景是否都有「按名字采用」的参考图 ──
|
||||||
@@ -1126,6 +1137,13 @@ export function PipelinePage(props: {
|
|||||||
const videoAnyStarted = segments.some((s) => ["running", "succeeded", "queued", "completed", "done"].includes(s.status));
|
const videoAnyStarted = segments.some((s) => ["running", "succeeded", "queued", "completed", "done"].includes(s.status));
|
||||||
const [chargeConfirm, setChargeConfirm] = useState<"video" | null>(null);
|
const [chargeConfirm, setChargeConfirm] = useState<"video" | null>(null);
|
||||||
const videoConfigs = videoModels ?? [];
|
const videoConfigs = videoModels ?? [];
|
||||||
|
// 基础资产生成用默认图像模型挂牌(与后端 get_default_model(IMAGE)+quote_flat 同口径)
|
||||||
|
const imageUnitPoints = (() => {
|
||||||
|
const configs = imageModels ?? [];
|
||||||
|
const preferred = configs.find((m) => m.status === "active") || configs[0];
|
||||||
|
const listed = pointsPerImageFromCatalog(preferred);
|
||||||
|
return listed != null && listed > 0 ? listed : 20;
|
||||||
|
})();
|
||||||
const defaultVideoModel = videoConfigs.find((m) => m.status === "active" && m.name === DEFAULT_VIDEO_MODEL_NAME)
|
const defaultVideoModel = videoConfigs.find((m) => m.status === "active" && m.name === DEFAULT_VIDEO_MODEL_NAME)
|
||||||
|| videoConfigs.find((m) => m.status === "active")
|
|| videoConfigs.find((m) => m.status === "active")
|
||||||
|| videoConfigs[0];
|
|| videoConfigs[0];
|
||||||
@@ -1209,10 +1227,10 @@ export function PipelinePage(props: {
|
|||||||
size="sm"
|
size="sm"
|
||||||
value={outputResolution}
|
value={outputResolution}
|
||||||
onChange={(next) => changeOutputSpec({ resolution: next })}
|
onChange={(next) => changeOutputSpec({ resolution: next })}
|
||||||
options={OUTPUT_RESOLUTIONS.map((option) => ({
|
options={(supportedResolutions.length
|
||||||
...option,
|
? OUTPUT_RESOLUTIONS.filter((option) => supportedResolutions.includes(option.value))
|
||||||
disabled: Boolean(supportedResolutions.length && !supportedResolutions.includes(option.value)),
|
: OUTPUT_RESOLUTIONS
|
||||||
}))}
|
)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="as-spec-field">
|
<label className="as-spec-field">
|
||||||
@@ -3289,6 +3307,7 @@ export function PipelinePage(props: {
|
|||||||
setSetupPersona(recommended.persona);
|
setSetupPersona(recommended.persona);
|
||||||
setSetupDuration(recommended.duration);
|
setSetupDuration(recommended.duration);
|
||||||
}}>重新推荐</button>
|
}}>重新推荐</button>
|
||||||
|
<span className="mono setup-cost-hint" title="按文本模型挂牌计价,失败不扣">预计 {pts(100)} 积分/次 · 失败不扣</span>
|
||||||
<button type="button" className="btn btn-primary btn-sm" disabled={loading || scriptFileBusy || videoDigestBusy || ((setupSource === "manual" || setupSource === "video") && !chatText.trim())} onClick={() => void runScriptWithSetup()}>确定</button>
|
<button type="button" className="btn btn-primary btn-sm" disabled={loading || scriptFileBusy || videoDigestBusy || ((setupSource === "manual" || setupSource === "video") && !chatText.trim())} onClick={() => void runScriptWithSetup()}>确定</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3407,17 +3426,20 @@ export function PipelinePage(props: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 存模板 / 重生成 / 确认下一步:有镜头脚本后才显示;积分提示已挪到右侧「确定」旁 */}
|
||||||
|
{currentScript ? (
|
||||||
<div className="stage-foot">
|
<div className="stage-foot">
|
||||||
<div className="info pl-tip">脚本生成预计消耗 {pts(10)} 积分 / 次,失败不扣除</div>
|
<div className="info pl-tip">重新生成预计消耗 {pts(100)} 积分 / 次,失败不扣除</div>
|
||||||
<div className="hstack">
|
<div className="hstack">
|
||||||
<button className="pl-ghost" type="button" disabled={loading || !currentScript} onClick={openSaveTemplate}>存为模板</button>
|
<button className="pl-ghost" type="button" disabled={loading} onClick={openSaveTemplate}>存为模板</button>
|
||||||
<button className="pl-ghost" type="button" disabled={loading} onClick={() => void runScriptGeneration("整体重新生成 · 突出商品卖点,节奏紧凑", "重新生成全部", undefined, "auto")}>重新生成脚本</button>
|
<button className="pl-ghost" type="button" disabled={loading} onClick={() => void runScriptGeneration("整体重新生成 · 突出商品卖点,节奏紧凑", "重新生成全部", undefined, "auto")}>重新生成脚本</button>
|
||||||
<button className="pl-next" type="button" disabled={loading || !currentScript} onClick={confirmScript}>
|
<button className="pl-next" type="button" disabled={loading} onClick={confirmScript}>
|
||||||
<span>{scriptAdopted ? "进入下一步" : "确认脚本,进入下一步"}</span>
|
<span>{scriptAdopted ? "进入下一步" : "确认脚本,进入下一步"}</span>
|
||||||
<ArrowRight />
|
<ArrowRight />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ============= STAGE 2 · 基础资产(真实 base_asset_groups,按 kind 分组)============= */}
|
{/* ============= STAGE 2 · 基础资产(真实 base_asset_groups,按 kind 分组)============= */}
|
||||||
@@ -3439,8 +3461,7 @@ export function PipelinePage(props: {
|
|||||||
|| assetUrl(productCover); // 商品卡显示商品主图,三视图在右侧面板
|
|| assetUrl(productCover); // 商品卡显示商品主图,三视图在右侧面板
|
||||||
const triViewGen = `${productName} 商品三视图,从左到右:正面 / 侧面 / 背面,统一光照,白色背景,16:9`;
|
const triViewGen = `${productName} 商品三视图,从左到右:正面 / 侧面 / 背面,统一光照,白色背景,16:9`;
|
||||||
const runProductTri = () => { setTriPanelOpen(true); setTriPreviewId(null); void genBaseAsset("product", triViewGen, undefined, "tri:product"); };
|
const runProductTri = () => { setTriPanelOpen(true); setTriPreviewId(null); void genBaseAsset("product", triViewGen, undefined, "tri:product"); };
|
||||||
// 实体提取闸门:没走过正式提取步(entities_extracted)且没生成任何基础资产 → 盖蒙版 + 三按钮,不自动花钱。
|
// 实体整理闸门:定稿时一般已本地落 entities_extracted;未落且无资产时盖蒙版。整理免费、不调模型。
|
||||||
// 用 entities_extracted 标记而非 script_entities 存在性:脚本生成期也可能吐过不稳的 entities,那不算正式提取。
|
|
||||||
const entitiesExtracted = project.metadata?.entities_extracted === true;
|
const entitiesExtracted = project.metadata?.entities_extracted === true;
|
||||||
const hasAnyAsset = KIND_ORDER.some((k) => groupsByKind(k).length > 0);
|
const hasAnyAsset = KIND_ORDER.some((k) => groupsByKind(k).length > 0);
|
||||||
const gateVisible = extractState === "running" || (!entitiesExtracted && !hasAnyAsset);
|
const gateVisible = extractState === "running" || (!entitiesExtracted && !hasAnyAsset);
|
||||||
@@ -3476,16 +3497,16 @@ export function PipelinePage(props: {
|
|||||||
<div className="sgv-card">
|
<div className="sgv-card">
|
||||||
<span className="sgv-ico"><span className="spinner"></span></span>
|
<span className="sgv-ico"><span className="spinner"></span></span>
|
||||||
<div className="sgv-body">
|
<div className="sgv-body">
|
||||||
<div className="sgv-title">{extractMsg || "正在提取角色 / 场景"}<span className="sgv-dots"><i></i><i></i><i></i></span></div>
|
<div className="sgv-title">{extractMsg || "正在整理角色 / 场景"}<span className="sgv-dots"><i></i><i></i><i></i></span></div>
|
||||||
<div className="sgv-bar"></div>
|
<div className="sgv-bar"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="eg-title">先从剧本认出角色 / 场景</div>
|
<div className="eg-title">从剧本整理角色 / 场景</div>
|
||||||
<div className="mono eg-sub">AI 认出角色 / 场景并出提示词,稍后你再逐个生成</div>
|
<div className="mono eg-sub">直接读取脚本里的角色与场景,不调用模型、不扣积分</div>
|
||||||
<button type="button" className="as-ai-btn as-ai-btn-lg eg-extract-btn" onClick={() => void runExtract("only")}>提取角色 / 场景</button>
|
<button type="button" className="as-ai-btn as-ai-btn-lg eg-extract-btn" onClick={() => void runExtract("only")}>整理角色 / 场景</button>
|
||||||
<div className="mono eg-cost-hint">{pts(10)} 积分/次 · 失败不扣</div>
|
<div className="mono eg-cost-hint">免费 · 本地整理</div>
|
||||||
{extractErr && <div className="eg-err">{extractErr}</div>}
|
{extractErr && <div className="eg-err">{extractErr}</div>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -3501,7 +3522,7 @@ export function PipelinePage(props: {
|
|||||||
<div className="as-head-side">
|
<div className="as-head-side">
|
||||||
{(entitiesExtracted || hasAnyAsset) ? (
|
{(entitiesExtracted || hasAnyAsset) ? (
|
||||||
<button type="button" className="as-ghost-btn" disabled={extractState === "running"} data-stop onClick={() => void runExtract("only")}>
|
<button type="button" className="as-ghost-btn" disabled={extractState === "running"} data-stop onClick={() => void runExtract("only")}>
|
||||||
{extractState === "running" ? "提取中…" : "重新提取角色 / 场景"}
|
{extractState === "running" ? "提取中…" : "重新整理角色 / 场景"}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="as-completion"><span>资产完成度</span><strong>{assetDone} / {assetTotal}</strong></div>
|
<div className="as-completion"><span>资产完成度</span><strong>{assetDone} / {assetTotal}</strong></div>
|
||||||
@@ -3578,7 +3599,7 @@ export function PipelinePage(props: {
|
|||||||
<button className="as-ghost-btn as-ghost-btn-sm prod-preview-adopt" type="button" disabled={previewTriAsset === adoptedTriAsset || loading} title={previewTriAsset === adoptedTriAsset ? "此版本已采用" : "将此版本设为商品采用版本"} onClick={() => { if (productGroup && previewTriAsset !== adoptedTriAsset) void onAdoptBaseAsset(productGroup.id, previewTriAsset); }}>
|
<button className="as-ghost-btn as-ghost-btn-sm prod-preview-adopt" type="button" disabled={previewTriAsset === adoptedTriAsset || loading} title={previewTriAsset === adoptedTriAsset ? "此版本已采用" : "将此版本设为商品采用版本"} onClick={() => { if (productGroup && previewTriAsset !== adoptedTriAsset) void onAdoptBaseAsset(productGroup.id, previewTriAsset); }}>
|
||||||
{previewTriAsset === adoptedTriAsset ? "已采用" : "采用此版本"}
|
{previewTriAsset === adoptedTriAsset ? "已采用" : "采用此版本"}
|
||||||
</button>
|
</button>
|
||||||
<span className="as-cost">{pts(20)} 积分 / 次</span>
|
<span className="as-cost">{pts(imageUnitPoints)} 积分 / 次</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{!isLocalProduct ? (
|
{!isLocalProduct ? (
|
||||||
@@ -3780,7 +3801,6 @@ export function PipelinePage(props: {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<footer className="as-action-bar">
|
<footer className="as-action-bar">
|
||||||
<span><Info />确认后将用以上资产直接生成视频,后续仍可替换。提取 {pts(10)} / 人物 {pts(20)} / 场景 {pts(20)} · 失败不扣</span>
|
|
||||||
<div>
|
<div>
|
||||||
{videoAnyStarted ? (
|
{videoAnyStarted ? (
|
||||||
<span className="pill pill-l2 neutral" title="后续生成视频将使用此设置">
|
<span className="pill pill-l2 neutral" title="后续生成视频将使用此设置">
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
estimateCost,
|
estimateCost,
|
||||||
FC_MODELS,
|
FC_MODELS,
|
||||||
modelLabel,
|
modelLabel,
|
||||||
|
pointsPerImageFromCatalog,
|
||||||
type BillingRates,
|
type BillingRates,
|
||||||
} from "../components/free-create/constants";
|
} from "../components/free-create/constants";
|
||||||
import type { NavigateFn } from "./route-config";
|
import type { NavigateFn } from "./route-config";
|
||||||
@@ -571,9 +572,14 @@ export function QuickCreatePage({
|
|||||||
{ ratio: aspectRatio, resolution, duration: totalDuration, refs: [] },
|
{ ratio: aspectRatio, resolution, duration: totalDuration, refs: [] },
|
||||||
billingRates,
|
billingRates,
|
||||||
);
|
);
|
||||||
// 商品理解和基础资产在脚本生成前无法精确报价;按每场约40积分给出透明预估,最终按成功任务结算。
|
// 基础资产按图像挂牌预估;视频按挂牌秒价。最终按成功任务实扣。
|
||||||
// 故事板下线后每场少一次 image-2 出图,预估相应下调。
|
const imageUnit = (() => {
|
||||||
const estimatedPoints = videoEstimate.points > 0 ? videoEstimate.points + sceneCount * 40 : sceneCount * 200;
|
const img = modelConfigs.find((m) => m.capability === "image" && m.status === "active")
|
||||||
|
|| modelConfigs.find((m) => m.capability === "image");
|
||||||
|
const listed = pointsPerImageFromCatalog(img);
|
||||||
|
return listed != null && listed > 0 ? listed : 20;
|
||||||
|
})();
|
||||||
|
const estimatedPoints = videoEstimate.listed ? videoEstimate.points + sceneCount * imageUnit : 0;
|
||||||
const shellClass = [
|
const shellClass = [
|
||||||
"quick-create-shell",
|
"quick-create-shell",
|
||||||
restoring ? "is-restoring" : "",
|
restoring ? "is-restoring" : "",
|
||||||
@@ -688,10 +694,10 @@ export function QuickCreatePage({
|
|||||||
value={resolution}
|
value={resolution}
|
||||||
onChange={setResolution}
|
onChange={setResolution}
|
||||||
disabled={isGenerating}
|
disabled={isGenerating}
|
||||||
options={QUICK_RESOLUTIONS.map((option) => ({
|
options={(supportedResolutions.length
|
||||||
...option,
|
? QUICK_RESOLUTIONS.filter((option) => supportedResolutions.includes(option.value))
|
||||||
disabled: Boolean(supportedResolutions.length && !supportedResolutions.includes(option.value)),
|
: QUICK_RESOLUTIONS
|
||||||
}))}
|
)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="quick-parameter-field">
|
<label className="quick-parameter-field">
|
||||||
@@ -727,7 +733,7 @@ export function QuickCreatePage({
|
|||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button type="button" className="quick-generate-button" onClick={() => void startGeneration()} disabled={!canStart}>
|
<button type="button" className="quick-generate-button" onClick={() => void startGeneration()} disabled={!canStart}>
|
||||||
<WandSparkles /><span>{`立即生成视频 · 消耗 ${estimatedPoints} 积分`}</span>
|
<WandSparkles /><span>{estimatedPoints > 0 ? `立即生成视频 · 消耗 ${estimatedPoints} 积分` : "立即生成视频"}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Film,
|
Film,
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
Home,
|
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
Library,
|
Library,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
@@ -90,7 +89,6 @@ export function isOwnerOnlyPage(page: Page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const mainNav: NavItem[] = [
|
export const mainNav: NavItem[] = [
|
||||||
{ page: "dashboard", label: "工作台", icon: Home },
|
|
||||||
{ page: "products", label: "商品库", icon: Package },
|
{ page: "products", label: "商品库", icon: Package },
|
||||||
{ page: "models", label: "模特库", icon: UserRound },
|
{ page: "models", label: "模特库", icon: UserRound },
|
||||||
{ page: "projects", label: "视频创作", icon: FolderKanban },
|
{ page: "projects", label: "视频创作", icon: FolderKanban },
|
||||||
@@ -169,7 +167,8 @@ export function resolveRoute(): ResolvedRoute {
|
|||||||
return { page: "dashboard", authMode: "login", admin: path.slice("/admin/".length), hash };
|
return { page: "dashboard", authMode: "login", admin: path.slice("/admin/".length), hash };
|
||||||
}
|
}
|
||||||
if (path === "/" && isPage(hash)) return { page: hash, authMode: "login", hash };
|
if (path === "/" && isPage(hash)) return { page: hash, authMode: "login", hash };
|
||||||
if (path === "/" || path === "/dashboard") return { page: "dashboard", authMode: "login", hash };
|
// 工作台暂时隐藏:根路径与 /dashboard 都进全能创作
|
||||||
|
if (path === "/" || path === "/dashboard") return { page: "omniCreate", authMode: "login", hash };
|
||||||
if (path === "/products") return { page: "products", authMode: "login", hash };
|
if (path === "/products") return { page: "products", authMode: "login", hash };
|
||||||
if (path === "/products/new") return { page: "productCreateUpload", authMode: "login", hash };
|
if (path === "/products/new") return { page: "productCreateUpload", authMode: "login", hash };
|
||||||
if (path.startsWith("/products/")) {
|
if (path.startsWith("/products/")) {
|
||||||
@@ -210,13 +209,14 @@ export function resolveRoute(): ResolvedRoute {
|
|||||||
if (path === "/settings/notify") return { page: "settingsNotify", authMode: "login", hash };
|
if (path === "/settings/notify") return { page: "settingsNotify", authMode: "login", hash };
|
||||||
if (path === "/settings") return { page: "settings", authMode: "login", hash };
|
if (path === "/settings") return { page: "settings", authMode: "login", hash };
|
||||||
if (path === "/trash") return { page: "trash", authMode: "login", hash };
|
if (path === "/trash") return { page: "trash", authMode: "login", hash };
|
||||||
return { page: "dashboard", authMode: "login", hash };
|
return { page: "omniCreate", authMode: "login", hash };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pathForPage(page: Page, options: NavigateOptions = {}) {
|
export function pathForPage(page: Page, options: NavigateOptions = {}) {
|
||||||
switch (page) {
|
switch (page) {
|
||||||
case "dashboard":
|
case "dashboard":
|
||||||
return "/dashboard";
|
// 工作台隐藏期间,旧入口也落到全能创作
|
||||||
|
return "/omni-create";
|
||||||
case "products":
|
case "products":
|
||||||
return "/products";
|
return "/products";
|
||||||
case "productCreateUpload":
|
case "productCreateUpload":
|
||||||
@@ -272,6 +272,6 @@ export function pathForPage(page: Page, options: NavigateOptions = {}) {
|
|||||||
case "trash":
|
case "trash":
|
||||||
return "/trash";
|
return "/trash";
|
||||||
default:
|
default:
|
||||||
return "/dashboard";
|
return "/omni-create";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -441,7 +441,7 @@ export function VideoReplacePage({
|
|||||||
).map(() => ({ type: "image" }))),
|
).map(() => ({ type: "image" }))),
|
||||||
],
|
],
|
||||||
}, billingRates);
|
}, billingRates);
|
||||||
const points = estimated.points || 220;
|
const points = estimated.points;
|
||||||
// 上传参考视频不算「正在复刻」:右侧面板要保持待命,只在上传区自己显示进度。
|
// 上传参考视频不算「正在复刻」:右侧面板要保持待命,只在上传区自己显示进度。
|
||||||
// 生成按钮不会因此被误点 —— videoReady 要等 asset_id 回来才为真。
|
// 生成按钮不会因此被误点 —— videoReady 要等 asset_id 回来才为真。
|
||||||
const generating = Boolean(job && isInFlight(job.status)) || submitting;
|
const generating = Boolean(job && isInFlight(job.status)) || submitting;
|
||||||
@@ -464,8 +464,8 @@ export function VideoReplacePage({
|
|||||||
const generateLabel = generating
|
const generateLabel = generating
|
||||||
? (digesting ? "正在提炼参考视频…" : reviewing ? "正在审核素材…" : (shotTotal > 1 && shotIndex > 0 ? `正在复刻 ${shotIndex}/${shotTotal} 镜…` : "正在复刻…"))
|
? (digesting ? "正在提炼参考视频…" : reviewing ? "正在审核素材…" : (shotTotal > 1 && shotIndex > 0 ? `正在复刻 ${shotIndex}/${shotTotal} 镜…` : "正在复刻…"))
|
||||||
: hasResult
|
: hasResult
|
||||||
? `再次${copy.modeLabel} · 消耗 ${points} 积分`
|
? (points > 0 ? `再次${copy.modeLabel} · 消耗 ${points} 积分` : `再次${copy.modeLabel}`)
|
||||||
: `开始${copy.modeLabel} · 消耗 ${points} 积分`;
|
: (points > 0 ? `开始${copy.modeLabel} · 消耗 ${points} 积分` : `开始${copy.modeLabel}`);
|
||||||
|
|
||||||
const loadHistory = async () => {
|
const loadHistory = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user