Compare commits

...
10 Commits
Author SHA1 Message Date
Azmat@qq.com b1ef04ee30 恢复图片创作三张风格预设封面
Build and Deploy / build-and-deploy (push) Canceled after 0s
03ceb82 误删 film-vintage / warm-lifestyle / dreamy-soft,导致全能创作图片风格卡裂图,现从本地构建产物补回。
2026-09-22 14:18:15 +08:00
Azmat@qq.com 03ceb82329 优化后台 2026-09-22 11:46:22 +08:00
Azmat@qq.com 2745760d33 全能创作闸门与多人物定妆收口
主页已填卖点跳过卖点确认;按图片内容创作不再重复问品牌品名;方案卡支撑改为 P0/P1/P2;多人物视频一次生成多张定妆图并在出片前校验数量;超管登录直进后台。
2026-09-22 10:02:47 +08:00
Azmat@qq.com 2889c92c34 同步图片/视频创作页卡片封面与设计稿
图片创作三张与视频创作五张入口封面切到 yz 设计稿资源;自由创作改名为图片创作,自由生成封面升至 film-v5。
2026-09-21 18:49:21 +08:00
Azmat@qq.com 9f15b71f19 全能创作与模特库收口:品牌品名输入、确认角色循环、换款落点与鱼眼模板
补充品牌品名改为预填输入框且空骨架不跳步;痛点确认角色后「继续完善方案」可继续推进;点击换款要求每次落点不同;鱼眼换装改用小云雀连环换装 prompt 模板;模特详情标签自动换行。
2026-09-21 16:47:50 +08:00
Azmat@qq.com 0bd1db6bf9 模特库标签分页与全能创作收口:藏长视频、选择器分页
角色库导入打标签并支持筛选;模特库与全能创作角色/商品选择改为每页 20 条分页。临时限制成片 ≤60 秒,过滤对话里的超长时长选项,并收拢本地全能创作与后台用户相关修复。
2026-09-21 16:24:37 +08:00
Azmat@qq.com 690eb3d843 优化后台用户管理和发积分 2026-09-20 16:30:45 +08:00
Azmat@qq.com fb3b2904ae 全能创作:定妆/台词/换款与剧情方向硬约束,资源可下载
修角色定妆修改不重出图、角色图混入商品、宠物旁白腔、换款同点复读、剧情反转方向漂移,并补会话资源下载与外观/空消息闸门。
2026-09-20 14:52:51 +08:00
Azmat@qq.com f2e8a5f3c1 全能创作:换款双形态与素材闸门防跳过
本地上传优先于商品库推荐;点击换款支持只出手/角色日常;角色未锁定禁止进核心卖点,「你来推荐」空库不再假完成。
2026-09-20 09:28:31 +08:00
Azmat@qq.com b5d970a4b5 优化已发现问题 2026-09-18 14:51:57 +08:00
81 changed files with 7585 additions and 1527 deletions
+9
View File
@@ -227,6 +227,15 @@ if CREATION_AGENT_TASK_QUEUE and CREATION_AGENT_TASK_QUEUE != "celery":
"queue": CREATION_AGENT_TASK_QUEUE,
}
# 全能创作 Agent 的单次模型输出上限。豆包 Seed 系列最大输出 16k,但**默认只有 4k**;
# 不显式抬高会把长方案的 tool_call arguments 截断成坏 JSON → 工具报「缺正文」→ 模型反复重写 → 整轮超时。
CREATION_AGENT_MAX_OUTPUT_TOKENS = env_int("CREATION_AGENT_MAX_OUTPUT_TOKENS", 16000)
# 思考模式(仅火山官方直连下发)。Seed 2.1 Pro 只认 enabled / disabled,收到 auto 直接 400。
# 默认 disabled:实测同一条 120 秒方案,开思考 156s(正文 131s 后才开始吐)、关思考 56s(首字 0.9s),
# 产出长度相当。编排 agent 的结构由 skill 提示词给定,不需要模型再自己推演。
# 置空则不下发、跟随模型默认(= 开思考),仅在需要对比质量时才这么配。
CREATION_AGENT_THINKING_MODE = (env("CREATION_AGENT_THINKING_MODE", "disabled") or "").strip()
REDIS_LOCK_URL = env("REDIS_LOCK_URL", "redis://127.0.0.1:6379/3")
TOS = {
+7
View File
@@ -12,6 +12,13 @@ PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True
# 本机 .env 常开 CREATION_AGENT_INLINE=true,走的是 transaction.on_commit + 后台线程;
# 而 TestCase 的事务永远不提交,on_commit 回调不会触发 → agent turn 一轮都不跑,
# 一整批编排测试会凭空变红(fake.calls 为空、卡片没落库)。测试必须走 eager Celery。
# 队列同时钉成 airshelf.local,跳过 require_worker_task 的 broker 探测。
CREATION_AGENT_INLINE = False
CREATION_AGENT_TASK_QUEUE = "airshelf.local"
# 测试必须完全离线:base.py 的 Redis cache 会让 billing 等普通业务逻辑在
# 单测中意外连到实际环境。测试用进程内缓存即可,既不依赖网络,也不会碰线上数据。
CACHES = {
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0009_team_price_multiplier"),
]
operations = [
migrations.AddField(
model_name="team",
name="is_personal",
field=models.BooleanField(default=False),
),
]
+3
View File
@@ -40,6 +40,9 @@ class Team(TimeStampedModel):
# 视频类按实结算用**下单时的快照系数**(request_payload.price_multiplier),中途改价不影响在途任务。
# 仅平台超管可改(admin 团队定价端点,0.10~10.00 边界)。
price_multiplier = models.DecimalField(max_digits=5, decimal_places=2, default=1)
# 个人团队:后台直建用户时自动配的一人队,只当这个用户的积分钱包(积分账户是 OneToOne 挂 Team 的,
# 所以「给用户发积分」在数据层仍然是给团队发)。团队体系一行没改,只是这类队不进后台团队列表。
is_personal = models.BooleanField(default=False)
def __str__(self) -> str:
return self.name
+39 -2
View File
@@ -98,20 +98,57 @@ class AdminTeamMemberSerializer(serializers.ModelSerializer):
read_only_fields = fields
def wallet_membership(user):
"""用户的钱包归属:第一个 active 成员关系(与 common.api.get_current_team 同口径,消费从这个团队扣)。
列表页依赖 prefetch 好的 team_memberships,这里只在内存里挑,不要改成 .filter() 否则每行多一次查询。"""
actives = [m for m in user.team_memberships.all() if m.status == TeamMember.Status.ACTIVE]
return min(actives, key=lambda m: m.created_at) if actives else None
class AdminUserSerializer(serializers.ModelSerializer):
teams = serializers.SerializerMethodField()
# 后台按「用户」视角管积分,但账户实际挂在团队上。这里把钱包团队拍平给前端:
# wallet_shared=True 表示这是个多人共享池(老用户),发积分弹窗要明确提示钱会进整个团队。
balance = serializers.SerializerMethodField()
wallet_team = serializers.SerializerMethodField()
wallet_team_name = serializers.SerializerMethodField()
wallet_shared = serializers.SerializerMethodField()
class Meta:
model = User
fields = ["id", "username", "status", "is_platform_admin", "date_joined", "teams"]
fields = [
"id", "username", "first_name", "status", "is_platform_admin", "date_joined", "teams",
"balance", "wallet_team", "wallet_team_name", "wallet_shared",
]
read_only_fields = fields
def get_teams(self, obj):
return [
{"team_id": str(m.team_id), "team_name": m.team.name, "role": m.role}
for m in obj.team_memberships.select_related("team").all()
for m in obj.team_memberships.all()
if not m.team.is_personal # 个人团队是钱包实现细节,不当「所属团队」展示
]
def get_balance(self, obj):
m = wallet_membership(obj)
acct = getattr(m.team, "credit_account", None) if m else None
return str(acct.balance) if acct is not None else "0"
def get_wallet_team(self, obj):
m = wallet_membership(obj)
return str(m.team_id) if m else None
def get_wallet_team_name(self, obj):
m = wallet_membership(obj)
return m.team.name if m else None
def get_wallet_shared(self, obj):
m = wallet_membership(obj)
if m is None:
return False
count = getattr(m, "team_member_count", None)
return bool((count if count is not None else m.team.members.count()) > 1)
class AdminTaskSerializer(serializers.ModelSerializer):
team_name = serializers.CharField(source="team.name", read_only=True, default=None)
+114
View File
@@ -5,6 +5,7 @@ from rest_framework.test import APIClient
from django.test import TestCase
from apps.accounts.models import AdminAuditLog, Invitation, Team, TeamMember, User
from apps.billing.models import CreditAccount, CreditLedger
class AdminInvitationApiTests(TestCase):
@@ -850,3 +851,116 @@ class AdminGovernanceTests(TestCase):
self.assertGreaterEqual(checks["teams_without_account"], 1) # GovTeam 无 credit_account
self.assertIn("totals", r.data)
self.assertGreaterEqual(r.data["totals"]["projects"], 2)
class AdminUserProvisioningTests(TestCase):
"""后台直建用户 + 按用户发积分。积分账户仍挂 Team(计费链路不变),
新用户配一个 is_personal 的一人团队当专属钱包;老的多人团队用户发积分则进共享池。"""
def setUp(self):
self.admin = User.objects.create_user(username="padmin", password="x", is_platform_admin=True)
self.normal = User.objects.create_user(username="normal", password="x")
self.team = Team.objects.create(name="Legacy Team", owner=self.normal)
TeamMember.objects.create(team=self.team, user=self.normal, role=TeamMember.Role.OWNER)
self.mate = User.objects.create_user(username="mate", password="x")
TeamMember.objects.create(team=self.team, user=self.mate, role=TeamMember.Role.MEMBER)
CreditAccount.objects.create(team=self.team, balance=Decimal("100"))
self.ac = APIClient()
self.ac.force_authenticate(self.admin)
self.nc = APIClient()
self.nc.force_authenticate(self.normal)
def _create(self, **overrides):
payload = {
"username": "creat1",
"display_name": "测试用户",
"password": "strong-pass-1",
"initial_credits": "500",
}
payload.update(overrides)
return self.ac.post("/api/admin/users/", payload, format="json")
def test_create_user_provisions_personal_wallet(self):
r = self._create()
self.assertEqual(r.status_code, 201)
self.assertEqual(r.data["balance"], "500.0000")
self.assertFalse(r.data["wallet_shared"])
self.assertEqual(r.data["teams"], []) # 个人团队是钱包实现细节,不当「所属团队」露出
user = User.objects.get(username="creat1")
self.assertEqual(user.first_name, "测试用户")
team = Team.objects.get(owner=user)
self.assertTrue(team.is_personal)
self.assertEqual(team.credit_account.balance, Decimal("500"))
ledger = CreditLedger.objects.get(team=team, ledger_type=CreditLedger.Type.RECHARGE)
self.assertEqual(ledger.metadata["target_user_id"], str(user.id))
self.assertTrue(AdminAuditLog.objects.filter(action="user.create").exists())
def test_created_user_can_login(self):
self._create()
r = APIClient().post("/api/auth/login/", {"username": "creat1", "password": "strong-pass-1"}, format="json")
self.assertEqual(r.status_code, 200)
def test_create_user_validation_and_permission(self):
self.assertEqual(self.nc.post("/api/admin/users/", {}, format="json").status_code, 403)
self.assertEqual(self._create(username="").status_code, 400)
self.assertEqual(self._create(display_name="").status_code, 400)
self.assertEqual(self._create(username="abc").status_code, 400) # 不足 6 位
self.assertEqual(self._create(username="abc-12").status_code, 400) # 含非法字符
self.assertEqual(self._create(username="abcdefg").status_code, 400) # 超过 6 位
self.assertEqual(self._create(password="short").status_code, 400)
self.assertEqual(self._create(initial_credits="-5").status_code, 400)
# 准备一个 6 位账号占用,验证重名(大小写不敏感)
User.objects.create_user(username="taken1", password="x")
self.assertEqual(self._create(username="taken1").status_code, 400)
self.assertEqual(self._create(username="TAKEN1").status_code, 400)
self.assertEqual(User.objects.filter(username="creat1").count(), 0)
def test_create_user_without_initial_credits(self):
r = self._create(initial_credits="")
self.assertEqual(r.status_code, 201)
self.assertEqual(r.data["balance"], "0.0000")
team = Team.objects.get(owner__username="creat1")
self.assertEqual(team.name, "测试用户")
self.assertFalse(CreditLedger.objects.filter(team=team).exists()) # 0 积分不落空流水
def test_adjust_user_credit(self):
user_id = self._create().data["id"]
r = self.ac.post(f"/api/admin/users/{user_id}/credits/", {"amount": "250", "reason": "活动赠送"}, format="json")
self.assertEqual(r.status_code, 201)
self.assertEqual(r.data["balance"], "750.0000")
ledger = CreditLedger.objects.filter(ledger_type=CreditLedger.Type.ADJUSTMENT).latest("created_at")
self.assertEqual(ledger.reason, "活动赠送")
self.assertEqual(ledger.metadata["target_user_id"], user_id)
self.assertTrue(AdminAuditLog.objects.filter(action="user.credit_adjust").exists())
# 扣减合法,但不能扣成负数
self.assertEqual(self.ac.post(f"/api/admin/users/{user_id}/credits/", {"amount": "-50"}, format="json").data["balance"], "700.0000")
self.assertEqual(self.ac.post(f"/api/admin/users/{user_id}/credits/", {"amount": "-9999"}, format="json").status_code, 400)
self.assertEqual(self.ac.post(f"/api/admin/users/{user_id}/credits/", {"amount": "0"}, format="json").status_code, 400)
self.assertEqual(self.nc.post(f"/api/admin/users/{user_id}/credits/", {"amount": "1"}, format="json").status_code, 403)
def test_legacy_multi_member_user_uses_shared_pool(self):
r = self.ac.get("/api/admin/users/?search=mate")
row = next(u for u in r.data["results"] if u["username"] == "mate")
self.assertEqual(row["balance"], "100.0000")
self.assertTrue(row["wallet_shared"])
self.assertEqual(row["wallet_team_name"], "Legacy Team")
self.ac.post(f"/api/admin/users/{row['id']}/credits/", {"amount": "40"}, format="json")
self.team.credit_account.refresh_from_db()
self.assertEqual(self.team.credit_account.balance, Decimal("140")) # 进的是团队共享池
def test_platform_admin_without_team_has_no_wallet(self):
r = self.ac.get("/api/admin/users/?search=padmin")
row = next(u for u in r.data["results"] if u["username"] == "padmin")
self.assertIsNone(row["wallet_team"])
self.assertEqual(row["balance"], "0")
def test_personal_teams_hidden_from_team_list(self):
self._create()
names = {t["name"] for t in self.ac.get("/api/admin/teams/").data["results"]}
self.assertIn("Legacy Team", names)
self.assertNotIn("created-user", names)
all_names = {t["name"] for t in self.ac.get("/api/admin/teams/?include_personal=1").data["results"]}
self.assertIn("created-user", all_names)
+2
View File
@@ -31,6 +31,7 @@ from .views import (
admin_team_pricing,
admin_team_toggle,
admin_teams,
admin_user_credits,
admin_user_reset_password,
admin_user_toggle,
admin_users,
@@ -47,6 +48,7 @@ urlpatterns = [
path("users/", admin_users, name="admin-users"),
path("users/<uuid:user_id>/toggle/", admin_user_toggle, name="admin-user-toggle"),
path("users/<uuid:user_id>/reset-password/", admin_user_reset_password, name="admin-user-reset-password"),
path("users/<uuid:user_id>/credits/", admin_user_credits, name="admin-user-credits"),
path("quality-words/", admin_quality_words, name="admin-quality-words"),
path("quality-words/<uuid:word_id>/", admin_quality_word_detail, name="admin-quality-word-detail"),
path("prompt-templates/", admin_prompt_templates, name="admin-prompt-templates"),
+173 -9
View File
@@ -3,21 +3,23 @@
import logging
from decimal import Decimal, ROUND_HALF_UP
from django.db.models import Case, CharField, Count, F, Q, Value, When
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import transaction
from django.db.models import Case, CharField, Count, F, Prefetch, Q, Value, When
from rest_framework import status
from rest_framework.authtoken.models import Token
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response
from apps.accounts.audit import log_admin_action
from apps.accounts.models import Invitation, Team, User
from apps.accounts.models import Invitation, Team, TeamMember, User
from apps.accounts.permissions import IsPlatformAdmin
from apps.accounts.serializers import InvitationSerializer
from apps.ai.model_catalog import invalidate_model_catalog_cache
from apps.ai.models import AITask, ModelConfig, ModelProvider, PromptTemplate, QualityWord
from apps.assets.models import Asset
from apps.assets.review import poll_asset_review, submit_asset_for_review
from apps.billing.models import BillingConfig, CreditLedger, QuotaPolicy
from apps.billing.models import BillingConfig, CreditAccount, CreditLedger, QuotaPolicy
from apps.billing.pricing import get_billing_config, invalidate_billing_config_cache
from apps.billing.services.ledger import adjust_credit
from apps.common.pagination import DefaultPagination
@@ -63,6 +65,48 @@ def _team_qs():
)
def _admin_user_qs():
"""用户列表:把成员关系连同团队钱包一次性 prefetch 出来,序列化器才能免查询地拍出余额。
team_member_count 用来判断钱包是不是多人共享池(决定发积分弹窗的提示文案)。"""
memberships = (
TeamMember.objects.select_related("team", "team__credit_account")
.annotate(team_member_count=Count("team__members", distinct=True))
.order_by("created_at")
)
return User.objects.prefetch_related(Prefetch("team_memberships", queryset=memberships))
def _wallet_team(user):
"""用户的钱包团队:第一个 active 成员关系(与 get_current_team 同口径)。无团队返回 None。"""
membership = (
user.team_memberships.filter(status=TeamMember.Status.ACTIVE)
.select_related("team")
.order_by("created_at")
.first()
)
return membership.team if membership else None
def _parse_points(raw, *, field: str, allow_negative: bool):
"""解析后台传来的积分数量。积分全站按整数流通,这里统一 HALF_UP 取整(与计价引擎同口径)。
返回 (Decimal, None) 或 (None, Response)。"""
from decimal import InvalidOperation
try:
value = Decimal(str(raw))
except (InvalidOperation, TypeError, ValueError):
return None, Response({field: ["数值格式不正确"]}, status=status.HTTP_400_BAD_REQUEST)
# Decimal("NaN")/("Infinity") 构造不抛,进库才炸 500 → 显式拦
if not value.is_finite():
return None, Response({field: ["数值格式不正确"]}, status=status.HTTP_400_BAD_REQUEST)
value = value.quantize(Decimal("1"), rounding=ROUND_HALF_UP)
if not allow_negative and value < 0:
return None, Response({field: ["积分不能为负"]}, status=status.HTTP_400_BAD_REQUEST)
if abs(value) > Decimal("10000000"):
return None, Response({field: ["单次不能超过 1,000 万积分"]}, status=status.HTTP_400_BAD_REQUEST)
return value, None
@api_view(["GET", "POST"])
@permission_classes([IsPlatformAdmin])
def admin_invitations(request):
@@ -127,8 +171,11 @@ def admin_revoke_invitation(request, invite_id):
@api_view(["GET"])
@permission_classes([IsPlatformAdmin])
def admin_teams(request):
"""列所有团队(跨团队,可按 status/search 过滤,分页)。"""
"""列所有团队(跨团队,可按 status/search 过滤,分页)。
默认隐藏个人团队(后台直建用户的钱包容器,只在用户页以「余额」形态出现),?include_personal=1 可看全。"""
qs = _team_qs().order_by("-created_at")
if str(request.query_params.get("include_personal") or "") not in {"1", "true"}:
qs = qs.filter(is_personal=False)
st = request.query_params.get("status")
if st in dict(Team.Status.choices):
qs = qs.filter(status=st)
@@ -209,22 +256,139 @@ def admin_team_pricing(request, team_id):
return Response(AdminTeamSerializer(_team_qs().get(id=team.id)).data)
@api_view(["GET"])
@api_view(["GET", "POST"])
@permission_classes([IsPlatformAdmin])
def admin_users(request):
"""列所有用户(跨团队,可按 status/search 过滤,分页)。"""
qs = User.objects.prefetch_related("team_memberships__team").order_by("-date_joined")
"""GET 列所有用户(可按 status/search 过滤,分页,每行带钱包余额);
POST 平台超管直接开户(不走邀请码),顺带配一个个人团队当钱包。"""
if request.method == "POST":
return _admin_create_user(request)
qs = _admin_user_qs().order_by("-date_joined")
st = request.query_params.get("status")
if st in dict(User.Status.choices):
qs = qs.filter(status=st)
search = (request.query_params.get("search") or "").strip()
if search:
qs = qs.filter(username__icontains=search)
qs = qs.filter(Q(username__icontains=search) | Q(first_name__icontains=search))
paginator = DefaultPagination()
page = paginator.paginate_queryset(qs, request)
return paginator.get_paginated_response(AdminUserSerializer(page, many=True).data)
def _admin_create_user(request):
"""后台直建用户:{username, display_name?, password, initial_credits?}。
username = 登录账号(仅英文+数字,固定 6 位);display_name = 展示用用户名(可中文)。
积分账户是 OneToOne 挂 Team 的,所以这里给新用户配一个 is_personal 的一人团队当专属钱包 ——
计费链路(预留/实扣/流水/额度)一行不用改,后台却能按「用户」视角管人和钱。
后续要恢复多人协作,把人拉进同一个团队即可,不需要迁数据。"""
import re
username = str(request.data.get("username") or "").strip()
display_name = str(
request.data.get("display_name")
or request.data.get("name")
or ""
).strip()
password = str(request.data.get("password") or "").strip()
if not display_name:
return Response({"display_name": ["请填写用户名"]}, status=status.HTTP_400_BAD_REQUEST)
if len(display_name) > 64:
return Response({"display_name": ["用户名不能超过 64 字"]}, status=status.HTTP_400_BAD_REQUEST)
if not username:
return Response({"username": ["请填写登录账号"]}, status=status.HTTP_400_BAD_REQUEST)
if not re.fullmatch(r"[A-Za-z0-9]{6}", username):
return Response(
{"username": ["登录账号须为 6 位英文或数字,不能含其他字符"]},
status=status.HTTP_400_BAD_REQUEST,
)
# 统一小写入库,避免 Abc123 / abc123 被当成两个账号
username = username.lower()
if User.objects.filter(username__iexact=username).exists():
return Response({"username": ["该登录账号已存在,不能重复"]}, status=status.HTTP_400_BAD_REQUEST)
if len(password) < 8:
return Response({"password": ["密码至少 8 位"]}, status=status.HTTP_400_BAD_REQUEST)
initial, err = _parse_points(request.data.get("initial_credits") or 0, field="initial_credits", allow_negative=False)
if err is not None:
return err
with transaction.atomic():
user = User.objects.create_user(
username=username,
password=password,
first_name=display_name,
)
# 个人团队名用展示名,侧栏第一行显示用户名、第二行显示登录账号
team = Team.objects.create(name=display_name, owner=user, is_personal=True)
TeamMember.objects.create(team=team, user=user, role=TeamMember.Role.OWNER)
CreditAccount.objects.create(team=team, balance=initial)
if initial > 0:
# 开户额度必须进流水,否则这笔钱在账单里查无凭证、无法对账(与注册赠送同口径)
CreditLedger.objects.create(
team=team,
user=request.user,
ledger_type=CreditLedger.Type.RECHARGE,
amount=initial,
balance_after=initial,
reason="后台开户初始积分",
metadata={"kind": "admin_create_user", "target_user_id": str(user.id)},
)
log_admin_action(
request,
"user.create",
target_type="user",
target_id=user.id,
target_name=user.username,
after={
"initial_credits": str(initial),
"wallet_team": str(team.id),
"display_name": display_name,
},
)
return Response(AdminUserSerializer(_admin_user_qs().get(id=user.id)).data, status=status.HTTP_201_CREATED)
@api_view(["POST"])
@permission_classes([IsPlatformAdmin])
def admin_user_credits(request, user_id):
"""给用户发/扣积分:{amount, reason?}。amount 可正可负,落 ADJUSTMENT 流水。
钱实际进的是这个用户的钱包团队 —— 个人团队即专属钱包;老用户若在多人团队里,
这笔会进团队共享池(前端弹窗已明确提示,后端流水的 metadata 记下目标用户备查)。"""
user = User.objects.filter(id=user_id).first()
if user is None:
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
team = _wallet_team(user)
if team is None:
return Response({"detail": "该用户没有可用的积分钱包(无生效团队)"}, status=status.HTTP_400_BAD_REQUEST)
amount, err = _parse_points(request.data.get("amount"), field="amount", allow_negative=True)
if err is not None:
return err
if amount == 0:
return Response({"amount": ["调额金额不能为 0"]}, status=status.HTTP_400_BAD_REQUEST)
reason = str(request.data.get("reason") or "").strip()
try:
ledger = adjust_credit(
team=team,
amount=amount,
reason=reason or f"平台给 {user.username} 调额",
operator=request.user,
metadata={"target_user_id": str(user.id), "target_username": user.username},
)
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
log_admin_action(
request,
"user.credit_adjust",
target_type="user",
target_id=user.id,
target_name=user.username,
after={"amount": str(amount), "balance_after": str(ledger.balance_after), "team": str(team.id), "reason": reason},
)
return Response(AdminUserSerializer(_admin_user_qs().get(id=user.id)).data, status=status.HTTP_201_CREATED)
@api_view(["POST"])
@permission_classes([IsPlatformAdmin])
def admin_user_toggle(request, user_id):
@@ -248,7 +412,7 @@ def admin_user_toggle(request, user_id):
before={"status": before},
after={"status": user.status},
)
return Response(AdminUserSerializer(user).data)
return Response(AdminUserSerializer(_admin_user_qs().get(id=user.id)).data)
@api_view(["POST"])
+317 -96
View File
@@ -181,105 +181,242 @@ def _sync_segmented_video_message(message: CreationMessage) -> bool:
一张结果卡。任一片段先完成就先写回 GENERATING 卡供用户预览;所有分段完成后才转成
可合并的结果卡,绝不在此处触发 ffmpeg。
"""
from .free_video import finalize_free_video
from django.conf import settings
from .free_video import IN_FLIGHT_STATUSES, finalize_free_video, submit_free_video
from .models import AITask
payload = message.payload or {}
ids = [str(value) for value in payload.get("task_ids") or [] if value]
if not ids:
# 团队级锁:同一团队若有两支长视频同时回填,不能都按同一份剩余并发额度补交。
lock_key = f"omni:segment-schedule:{message.conversation.team_id}"
if not cache.add(lock_key, "1", timeout=90):
return False
by_id = {
str(task.id): task
for task in AITask.objects.filter(id__in=ids).select_related("model_config")
}
tasks = [by_id.get(task_id) for task_id in ids]
if any(task is None for task in tasks):
fail_generating_message(message, "分段视频任务不完整,请重新生成。")
return True
try:
message.refresh_from_db()
payload = dict(message.payload or {})
segments = [dict(item) for item in payload.get("segments") or [] if isinstance(item, dict)]
ids = [str(value) for value in payload.get("task_ids") or [] if value]
if not ids or not segments:
return False
by_id = {
str(task.id): task
for task in AITask.objects.filter(id__in=ids).select_related("model_config")
}
tasks = [by_id.get(task_id) for task_id in ids]
if any(task is None for task in tasks):
fail_generating_message(message, "分段视频任务不完整,请重新生成。")
return True
# 本地没有 worker 时,用户的会话轮询本身即可推进每个片段;已在 worker 中的任务则幂等返回。
refreshed = []
for task in tasks:
assert task is not None
if task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING):
try:
task = finalize_free_video(task=task)
except Exception: # noqa: BLE001 - 单段网络抖动不能使整组直接失败
# 本地没有 worker 时,会话轮询也能推进已提交片段;已在 worker 中则幂等返回。
refreshed = []
for task in tasks:
assert task is not None
if task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING):
try:
task = finalize_free_video(task=task)
except Exception: # noqa: BLE001 - 单段网络抖动不能使整组直接失败
task.refresh_from_db()
else:
task.refresh_from_db()
else:
task.refresh_from_db()
refreshed.append(task)
refreshed.append(task)
failed = next(
(task for task in refreshed if task.status in (AITask.Status.FAILED, AITask.Status.CANCELLED)),
None,
)
if failed is not None:
from .generation_errors import public_error_for_task
segment_by_task = {
str(item.get("task_id")): int(item.get("index") or index)
for index, item in enumerate(segments, start=1)
if item.get("task_id")
}
failed = next(
(task for task in refreshed if task.status in (AITask.Status.FAILED, AITask.Status.CANCELLED)),
None,
)
number = next((index + 1 for index, task in enumerate(refreshed) if task.id == failed.id), 1)
public_error = public_error_for_task(failed, operation="video_generate")
detail = public_error.fallback_message if public_error else (failed.error_message or "请重试")
fail_generating_message(message, f"第 {number} 段生成失败:{detail}")
assets: list[dict] = []
completed_segments = 0
for task in refreshed:
if task.status != AITask.Status.SUCCEEDED:
continue
task_assets = _assets_from_task(task)
if not task_assets:
continue
segment_index = segment_by_task.get(str(task.id), completed_segments + 1)
completed_segments += 1
for asset in task_assets:
assets.append({**asset, "label": f"第 {segment_index} 段", "segment_index": segment_index})
assets.sort(key=lambda item: int(item.get("segment_index") or 0))
if failed is not None:
from .generation_errors import public_error_for_task
number = segment_by_task.get(str(failed.id), 1)
public_error = public_error_for_task(failed, operation="video_generate")
detail = public_error.fallback_message if public_error else (failed.error_message or "请重试")
error_text = f"第 {number} 段生成失败:{detail}"
# 取消仍在飞的其它分段,避免失败后继续扣费/刷进度。
for task in refreshed:
if task.id == failed.id:
continue
if task.status in IN_FLIGHT_STATUSES:
from apps.billing.services.ledger import release_credit
task.status = AITask.Status.CANCELLED
task.error_message = "同组其它分段已失败,已取消"
task.completed_at = timezone.now()
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
if task.credit_reservation is not None:
try:
release_credit(
reservation=task.credit_reservation,
reason="同组分段失败,取消未完成片段",
)
except Exception: # noqa: BLE001
pass
if assets:
first = next((task for task in refreshed if task.status == AITask.Status.SUCCEEDED), failed)
finish_generating_message(
message,
assets=assets,
meta={
**_meta_from_task(first, message),
"kind": "video_segments",
"segment_count": len(segments),
"total_duration": payload.get("total_duration") or "",
"needs_merge": False,
"partial_failure": True,
"failed_segment_index": number,
"error": error_text,
"segments": segments,
"task_ids": ids,
},
)
conversation = message.conversation
conversation.status = CreationConversation.Status.FAILED
conversation.save(update_fields=["status", "updated_at"])
append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ERROR,
text=f"{error_text}。已保留成功片段供预览,未合并成片;可重新确认方案再生成。",
)
else:
fail_generating_message(message, error_text)
return True
# 释放出来的并发槽位自动补交下一批。首批与后续批次统一从 generation_spec 派生,
# 因而复用同一份人物/商品参考、完整脚本和 seed。
pending = [item for item in segments if not item.get("task_id")]
if pending:
in_flight = AITask.objects.filter(
team=message.conversation.team,
task_type=AITask.Type.FREE_VIDEO,
status__in=IN_FLIGHT_STATUSES,
).count()
slots = max(0, int(getattr(settings, "FREE_VIDEO_MAX_CONCURRENT", 3)) - in_flight)
spec = payload.get("generation_spec") if isinstance(payload.get("generation_spec"), dict) else {}
if slots and not spec:
fail_generating_message(message, "长视频续段参数不完整,请重新生成。")
return True
if slots:
from .creation_agent import build_segment_video_submit
total_duration = int(payload.get("total_duration") or 0)
submitted_now = 0
try:
for segment in pending[:slots]:
task = submit_free_video(
team=message.conversation.team,
user=message.conversation.created_by,
params=build_segment_video_submit(spec, segment, total_duration),
)
task_id = str(task.id)
segment["task_id"] = task_id
ids.append(task_id)
request_payload = dict(task.request_payload or {})
marker = dict(request_payload.get("omni_segment") or {})
marker.update({
"index": int(segment.get("index") or 0),
"start": int(segment.get("start") or 0),
"end": int(segment.get("end") or 0),
"total_duration": total_duration,
"message_id": str(message.id),
})
request_payload["omni_segment"] = marker
task.request_payload = request_payload
task.save(update_fields=["request_payload", "updated_at"])
submitted_now += 1
except ValueError as exc:
if not submitted_now:
fail_generating_message(message, f"后续分段提交失败:{exc}")
return True
# 逐段提交期间并发槽位被别的请求占用时,保留本轮已提交任务;余下片段下轮再补。
payload["task_ids"] = ids
payload["segments"] = segments
progress = {
**payload,
"assets": assets,
"completed_segment_count": completed_segments,
"submitted_segment_count": len(ids),
}
all_submitted = all(item.get("task_id") for item in segments)
all_succeeded = all(task.status == AITask.Status.SUCCEEDED for task in refreshed)
# 刚补交的新任务不在 refreshed 内,因此必须同时校验提交数,不能提前完成结果卡。
if not all_submitted or len(refreshed) != len(segments) or not all_succeeded:
if progress != payload:
message.payload = progress
message.save(update_fields=["payload", "updated_at"])
return True
return False
# 状态已成功但资产尚未落库时继续等待,避免最终成片缺段。
if completed_segments != len(segments):
if progress != payload:
message.payload = progress
message.save(update_fields=["payload", "updated_at"])
return True
return False
first = refreshed[0]
finish_generating_message(
message,
assets=assets,
meta={
**_meta_from_task(first, message),
"kind": "video_segments",
"segment_count": len(segments),
"total_duration": payload.get("total_duration") or "",
"needs_merge": True,
"auto_merge": True,
"segments": segments,
"task_ids": ids,
},
)
message.refresh_from_db()
# 全部分段成功后由平台自动合并,不再等用户点「合并成片」。
try:
merge_task, _generating = start_segmented_video_merge(
conversation=message.conversation,
message=message,
user=message.conversation.created_by,
)
try:
from .tasks import merge_omni_video_segments_task
merge_omni_video_segments_task.apply_async(args=[str(merge_task.id)])
except Exception: # noqa: BLE001
import logging
logging.getLogger(__name__).warning(
"omni auto video merge enqueue failed for %s", merge_task.id, exc_info=True
)
except ValueError:
# 已在合并中或状态不允许时忽略,避免重复入队。
pass
return True
assets: list[dict] = []
completed_segments = 0
for index, task in enumerate(refreshed, start=1):
if task.status != AITask.Status.SUCCEEDED:
continue
task_assets = _assets_from_task(task)
# 上游状态先成功、资产稍后才落库时,保留 GENERATING,下一轮再展示这段。
if not task_assets:
continue
completed_segments += 1
for asset in task_assets:
assets.append({**asset, "label": f"第 {index} 段", "segment_index": index})
# 先完成的片段必须立刻回到前端,不能等另一段慢任务一起完成才出现。
# 保持同一条 GENERATING 消息,避免把一个 60 秒视频拆成多条对话消息。
if not all(task.status == AITask.Status.SUCCEEDED for task in refreshed):
progress = {
**payload,
"assets": assets,
"completed_segment_count": completed_segments,
}
if progress != payload:
message.payload = progress
message.save(update_fields=["payload", "updated_at"])
return True
return False
# 任务都成功但有片段的资产还在落库,继续保持生成中,避免最终结果缺片。
if completed_segments != len(refreshed):
progress = {
**payload,
"assets": assets,
"completed_segment_count": completed_segments,
}
if progress != payload:
message.payload = progress
message.save(update_fields=["payload", "updated_at"])
return True
return False
first = refreshed[0]
finish_generating_message(
message,
assets=assets,
meta={
**_meta_from_task(first, message),
"kind": "video_segments",
"segment_count": len(refreshed),
"total_duration": payload.get("total_duration") or "",
"needs_merge": True,
"segments": payload.get("segments") or [],
},
)
return True
finally:
cache.delete(lock_key)
def start_segmented_video_merge(*, conversation: CreationConversation, message: CreationMessage, user):
"""用户明确点击后才建立合并任务;这之前绝不下载片段或调用 ffmpeg。"""
"""建立合并任务(全部分段成功后由平台自动调用;也可由旧接口手动触发)。合并执行仍只在 run_segmented_video_merge。"""
from .models import AITask
payload = dict(message.payload or {})
@@ -406,9 +543,16 @@ def sync_generating_messages(conversation: CreationConversation) -> int:
def sync_generating_for_task(task) -> int:
"""worker / poll 终态后:只扫挂在这个任务上的 GENERATING。失败不能向外抛。"""
try:
from django.db.models import Q
marker = (task.request_payload or {}).get("omni_segment") or {}
aggregate_message_id = str(marker.get("message_id") or "") if isinstance(marker, dict) else ""
lookup = Q(task=task)
if aggregate_message_id:
lookup |= Q(id=aggregate_message_id)
pending = list(
CreationMessage.objects.filter(
task=task, kind=CreationMessage.Kind.GENERATING,
lookup, kind=CreationMessage.Kind.GENERATING,
).select_related("conversation", "task", "task__model_config")
)
return sum(1 for message in pending if sync_generating_message(message))
@@ -447,6 +591,17 @@ def finish_generating_message(message: CreationMessage, *, assets: list[dict], m
).first()
if asset is None:
return fail_generating_message(message, "人物参考已生成,但未找到可锁定的图片资产")
cast_index = int(original_payload.get("cast_index") or 1)
cast_total = int(original_payload.get("cast_total") or 1)
cast_model_name = str(original_payload.get("cast_model_name") or "").strip()
is_pet = "宠物" in str(conversation.preset or "")
if not cast_model_name:
if is_pet:
cast_model_name = "平台生成宠物角色"
elif cast_total > 1:
cast_model_name = f"平台生成出镜人物{cast_index}"
else:
cast_model_name = "平台生成出镜人物"
model = Model.objects.filter(
team=conversation.team,
portrait_asset=asset,
@@ -457,12 +612,24 @@ def finish_generating_message(message: CreationMessage, *, assets: list[dict], m
model = Model.objects.create(
team=conversation.team,
created_by=conversation.created_by,
name="平台生成出镜人物",
name=cast_model_name,
source=Model.Source.AI,
portrait_asset=asset,
description="由全能创作生成并锁定的视频出镜人物。",
metadata={"feature": "omni_create", "conversation_id": str(conversation.id)},
description=(
"由全能创作生成并锁定的宠物角色。"
if is_pet else
"由全能创作生成并锁定的视频出镜人物。"
),
metadata={
"feature": "omni_create",
"conversation_id": str(conversation.id),
"cast_index": cast_index,
"cast_total": cast_total,
},
)
elif model.name != cast_model_name and cast_total > 1:
model.name = cast_model_name
model.save(update_fields=["name", "updated_at"])
pin_refs(conversation, [{
"type": "model",
"id": str(model.id),
@@ -471,9 +638,37 @@ def finish_generating_message(message: CreationMessage, *, assets: list[dict], m
}])
memory = dict(conversation.memory or {})
memory["person_source"] = "platform_generate"
model_ids = [
str(item)
for item in (memory.get("person_cast_model_ids") or [])
if str(item).strip()
]
model_id = str(model.id)
if model_id not in model_ids:
model_ids.append(model_id)
memory["person_cast_model_ids"] = model_ids
memory["person_model_id"] = model_id
pending = int(memory.get("person_cast_pending") or cast_total or 1)
pending = max(0, pending - 1)
memory["person_cast_pending"] = pending
memory["person_cast_total"] = int(memory.get("person_cast_total") or cast_total or 1)
if pending > 0:
# 还有其它角色定妆图在生成:继续等待,不打断用户确认
memory["person_source_pending"] = True
memory.pop("person_source_ready", None)
memory.pop("person_confirm_pending", None)
conversation.memory = memory
conversation.status = CreationConversation.Status.RUNNING
conversation.agent_status = CreationConversation.AgentStatus.IDLE
conversation.last_active_at = timezone.now()
conversation.save(update_fields=[
"memory", "status", "agent_status", "last_active_at", "updated_at",
])
return message
memory["person_source_pending"] = False
memory["person_source_ready"] = True
memory["person_model_id"] = str(model.id)
memory["person_confirm_pending"] = True
conversation.memory = memory
conversation.status = CreationConversation.Status.RUNNING
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
@@ -481,13 +676,39 @@ def finish_generating_message(message: CreationMessage, *, assets: list[dict], m
conversation.save(update_fields=[
"memory", "status", "agent_status", "last_active_at", "updated_at",
])
total = int(memory.get("person_cast_total") or 1)
if is_pet:
confirm_text = "宠物角色已生成。你看这只宠物合适吗?是否使用这个宠物角色继续创作?"
reply_hint = "回复「使用这个角色」继续,或说明想要的宠物品种与外观…"
regen = "重新生成一个宠物角色"
upload = "我上传宠物参考图"
upload_label = "上传其他宠物"
elif total > 1:
confirm_text = (
f"已生成 {total} 位出镜角色定妆图。长视频会按这些图分别锁脸,"
"避免前后形象漂移。你看这组角色合适吗?是否使用这些角色继续创作?"
)
reply_hint = "回复「使用这个角色」继续,或说明想调整哪一位…"
regen = "重新生成一个角色"
upload = "我上传人物参考图"
upload_label = "上传其他人物"
else:
confirm_text = "人物参考已生成。你看这位角色合适吗?是否使用这个角色继续创作?"
reply_hint = "回复「使用这个角色」继续,或说明想调整的地方…"
regen = "重新生成一个角色"
upload = "我上传人物参考图"
upload_label = "上传其他人物"
append_message(
conversation,
role="assistant",
text="人物参考已生成并锁定。后续所有镜头和长视频分段都会使用这位人物。",
text=confirm_text,
payload={
"reply_hint": "继续创作…",
"reply_options": [{"label": "继续创作", "text": "继续创作"}],
"reply_hint": reply_hint,
"reply_options": [
{"label": "使用这个角色", "text": "使用这个角色继续创作"},
{"label": "重新生成一个", "text": regen},
{"label": upload_label, "text": upload},
],
},
)
return message
File diff suppressed because it is too large Load Diff
+272 -35
View File
@@ -8,13 +8,22 @@ key 必须是同一个中文名(会话建的时候原样存进 CreationConversat
"""
from __future__ import annotations
import re
CLICK_SWAP_PRESETS = frozenset({"点击换款", "点触换款", "多色商品换款"})
_CLICK_SWAP_GUIDANCE = (
"固定机位的商品点击换款短片。必须先确认要展示的颜色/款式/SKU 及顺序,"
"画面只围绕同一件商品的逐款切换:商品始终居中、大小与角度不变,背景、灯光、机位不变。"
"每次画面中的手指点击/轻触商品后,下一款在原位立即完成干净的 match cut 切换。"
"禁止改成口播、剧情、使用教程、多场景展示、换人或商品飞舞变形;最后才用同一构图做全款式收束。"
_CLICK_SWAP_FINGER_GUIDANCE = (
"形态:只出手指出镜。固定机位的商品点击换款短片。必须先确认要展示的颜色/款式/SKU 及顺序,"
"画面只围绕同一件商品的逐款切换:机位、背景与灯光稳定,商品构图稳定。"
"用手指点击/轻触触发换款,接触后以干净 match cut 切到下一款。"
"每一次换款的手指点击落点必须与上一次不同(点商品的不同区域/角点),禁止写或暗示「每次点击同一位置 / 点同一点 / 手指始终点同一处」;分镜与 video_prompt 要体现「本次点击位置不同于上次」,不要复读同一落点。"
"禁止完整人物出镜、禁止生成角色定妆图、禁止改成口播/剧情/使用教程;最后才用同一构图做全款式收束。"
)
_CLICK_SWAP_CHARACTER_GUIDANCE = (
"形态:角色在日常使用场景中变色换款。锁定同一位角色与同一生活场景,角色自然使用或展示商品;"
"商品颜色/款式必须按用户确认顺序切换,人物五官、发型、身形、基础服装与场景保持一致。"
"允许自然动作与生活感,但不要改成口播带货腔、复杂剧情反转或频繁换景;结尾给出全款式收束。"
)
_CLICK_SWAP_GUIDANCE = _CLICK_SWAP_FINGER_GUIDANCE # 未选形态前的默认提示
VIDEO_PRESETS: dict[str, str] = {
"痛点解决演示": (
@@ -54,9 +63,12 @@ VIDEO_PRESETS: dict[str, str] = {
"节奏清晰,转场干净。商品外观严格以参考图为准。"
),
"鱼眼换装": (
"鱼眼/广角近距离透视,连续换装节奏。**人物面部和身形必须全程一致**,"
"只有服装在变。每次换装用一个明确动作触发。"
),
"超广角鱼眼/近距离透视的连环换装短片。"
"全片单一机位、同一构图与背景锁定;画面中心人物清晰、四边缘畸变拉伸,高饱和时尚质感,逐拍卡点剪辑。"
"人物从第一镜起就已经穿着当前套装;换装发生在遮挡/甩头/甩镜等动态瞬间,瞬间切到已穿好的下一套。"
"禁止拍穿衣服、套袖、拉拉链等穿戴过程。video_prompt 必须按「鱼眼连环换装」制作模板撰写"
"(时长比例、标题、风格、单机位节拍、转场规则、主体、服装状态机、无对白卡点音效)。"
),
"点击换款": _CLICK_SWAP_GUIDANCE,
"多色商品换款": _CLICK_SWAP_GUIDANCE,
# 旧会话仍会保存「点触换款」,保留同一拍法约束以支持历史继续创作。
@@ -76,6 +88,8 @@ VIDEO_PRESETS: dict[str, str] = {
"AI 宠物拟人": (
"先确定宠物的外观、性格和可执行动作,再设计一段轻巧剧情。宠物与商品互动要符合商品真实结构和正常操作方式;"
"趣味来自角色反应和剧情,不把商品改造成不真实的玩具或凭空增加能力。"
"有台词时必须是宠物第一人称开口(像这只宠物在说话),口语、性格化、短句;"
"禁止旁白解说、达人口播腔、画外音旁白或第三人称讲述。"
),
"人物与音色替换": (
"先理解原视频的镜头、人物、动作、台词、画外音、字幕、场景和节奏。只替换用户指定的人物、声音或两者;"
@@ -118,10 +132,15 @@ _PLOT_TWIST_DEPTH_BY_VALUE = {item["value"]: item for item in PLOT_TWIST_STORY_D
def plot_twist_story_depth(value: str) -> dict | None:
"""兼容卡片 value、展示文案和自然语言里带的秒数。"""
"""兼容卡片 value、展示文案和自然语言里带的秒数。
180 秒长视频入口已临时关闭:历史「180s」选择落到 60 秒档。
"""
raw = str(value or "").strip()
if raw in _PLOT_TWIST_DEPTH_BY_VALUE:
return _PLOT_TWIST_DEPTH_BY_VALUE[raw]
if "180" in raw or "90" in raw or "120" in raw:
return _PLOT_TWIST_DEPTH_BY_VALUE["60s"]
if "60" in raw:
return _PLOT_TWIST_DEPTH_BY_VALUE["60s"]
if "30" in raw:
@@ -160,6 +179,18 @@ def plot_twist_story_contract(value: str) -> str:
"56-60 秒回扣商品价值并自然转化。商品可前置为伏笔但必须在后半段真正改变结局;"
"禁止用重复对白、无意义空镜或硬插卖点填满时长。"
)
if depth["value"] == "180s":
return (
"【剧情反转带货·180秒三幕完整故事·强制执行】全片按三个连续 60 秒章节推进,不能把三条短片简单拼接。"
"第一章 0-60 秒:0-8 秒以结果预告或关系冲突抓人,8-28 秒建立人物目标与现实阻力,"
"28-48 秒埋入商品/SKU 与一个可见卖点证据,48-60 秒用第一次选择或失败把行动推入下一章。"
"第二章 60-120 秒:承接上一章未完成动作,扩大矛盾并安排一次错误尝试;商品通过正常使用过程提供新的证据,"
"在 105-120 秒完成中段转折,但不得提前总结或重复开场。"
"第三章 120-180 秒:让前两章伏笔、人物选择和商品证据共同触发主要反转,165 秒前完成结果验证,"
"165-176 秒释放人物情绪并回扣核心卖点,176-180 秒只做一次自然行动引导。"
"每章都要推进新的因果关系;同一角色、服装、商品/SKU、场景空间和光线跨六个 30 秒分段连续,"
"禁止重复对白、重复卖点、空镜凑时长或在章节边界换人换商品。"
)
return (
"【剧情反转带货·智能推荐】先根据商品卖点、已有素材和剧情空间推荐 15、30 或 60 秒之一并说明理由;"
"随后必须让用户选择实际时长,未确认前不得写剧情方向、策略、方案或出片指令。"
@@ -176,23 +207,81 @@ def apply_plot_twist_story_contract(name: str, story_depth: str, prompt: str) ->
return base
return f"{base}\n\n{contract}"
def format_plot_twist_direction_contract(
*,
title: str = "",
conflict: str = "",
product_role: str = "",
reversal: str = "",
tone: str = "",
detail: str = "",
) -> str:
"""把用户选定的剧情方向写成出片/方案硬约束。"""
title = str(title or "").strip()
conflict = str(conflict or "").strip()
product_role = str(product_role or "").strip()
reversal = str(reversal or "").strip()
tone = str(tone or "").strip()
detail = str(detail or "").strip()
if not title and not detail and not any((conflict, product_role, reversal)):
return ""
lines = ["【剧情反转方向·强制执行·最高优先级】"]
if title:
lines.append(f"已选方向标题:{title}")
if conflict:
lines.append(f"开场冲突必须是:{conflict}")
if product_role:
lines.append(f"商品在剧情中的作用必须是:{product_role}")
if reversal:
lines.append(f"最终反转必须是:{reversal}")
if tone:
lines.append(f"情绪调性:{tone}")
if detail and not any((conflict, product_role, reversal)):
lines.append(f"方向细节:{detail}")
lines.append(
"策略、方案时间轴和 video_prompt 必须严格沿用上述冲突→商品作用→反转;"
"禁止改成另一套常见带货故事(例如擅自换成「出门忘了喂宠/忘记带东西」等默认桥段),"
"除非用户所选方向本身就是该桥段。"
)
return "\n".join(lines)
def apply_plot_twist_direction_contract(prompt: str, *, title: str = "", conflict: str = "", product_role: str = "", reversal: str = "", tone: str = "", detail: str = "") -> str:
"""确定性把已选剧情方向写入方案/出片 prompt,避免模型另起炉灶。"""
base = str(prompt or "").strip()
contract = format_plot_twist_direction_contract(
title=title,
conflict=conflict,
product_role=product_role,
reversal=reversal,
tone=tone,
detail=detail,
)
if not base or not contract:
return base
if "【剧情反转方向·强制执行·最高优先级】" in base:
return base
return f"{base}\n\n{contract}"
# 预设除了决定最终 Prompt,也决定 Agent 在素材整理、追问与生成前检查时的工作重点。
# 这段进入系统提示词,避免把预设退化成一句风格修饰词。
VIDEO_PRESET_WORKFLOWS: dict[str, str] = {
"痛点解决演示": "先确认商品真实解决的具体问题与正常用法;生成前核对痛点、过程和结果都有可见证据。",
"真实使用演示": "优先核对商品真实用途、关键步骤和可证实卖点;不为氛围而增加不合理测试。",
"剧情反转带货": "先让用户选故事深度(15秒快节奏反转、30秒轻剧情带货、60秒完整短剧带货或智能推荐),再给三个与时长匹配的剧情方向;商品必须成为解决问题、解除误会、证明事实、回收伏笔或完成翻盘的关键。",
"剧情反转带货": "先让用户选故事深度(15秒快节奏反转、30秒轻剧情带货、60秒完整短剧带货或智能推荐;暂不开放更长时长),再给三个与时长匹配的剧情方向;商品必须成为解决问题、解除误会、证明事实、回收伏笔或完成翻盘的关键。",
"商品拟人广告": "先确认商品外观和性格表达方式;默认无脸拟人,商品保持真实完整,台词用画外声。",
"达人口播种草": "优先确认人物、多人出镜关系、真实体验和主卖点;生成前核对口播字数能在时长内说完,每个卖点都有画面证明。",
"商品图一键成片": "优先从商品参考图锁定外观;自动补场景和动作,但不替换或改变用户商品图里的结构、颜色和包装。",
"鱼眼换装": "优先确认人物参考、服装套数和展示顺序;生成前核对脸、身形、场景稳定,只有服装随动作切换。",
"点击换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位下手指逐次点击、商品原位换款,禁止转成剧情或口播。",
"多色商品换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位下手指逐次点击、商品原位换款,禁止转成剧情或口播。",
"点触换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位下手指逐次点击、商品原位换款,禁止转成剧情或口播。",
"鱼眼换装": "优先确认人物参考、服装套数N与展示顺序、本条机位(低机位仰拍/高机位俯拍/荷兰角/贴地滑行/手持环绕等择一全片不变)、场景与转场方式;时长按 N×1.5–2.5 秒估算(约 4–15 秒);write_prompt 严格用「鱼眼连环换装」模板:节拍换装 + 服装状态机,无对白,禁止穿衣过程。",
"点击换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位 + 手指点击触发换款;每次换款点击位置必须不同(换商品不同区域),禁止「每次点击同一位置 / 点同一点」。禁止转成剧情或口播。",
"多色商品换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位 + 手指点击触发换款;每次换款点击位置必须不同(换商品不同区域),禁止「每次点击同一位置 / 点同一点」。禁止转成剧情或口播。",
"点触换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位 + 手指点击触发换款;每次换款点击位置必须不同(换商品不同区域),禁止「每次点击同一位置 / 点同一点」。禁止转成剧情或口播。",
"探店漫游": "优先确认门店动线与主推项目;用连续移动串联入口、环境、细节和服务,不做碎片化硬切。",
"品牌质感大片": "优先确认品牌气质、材质和商品主卖点;由 Agent 决定光线和镜头,不把专业选择反复抛给用户。",
"前后对比实测": "优先确认同一对象的前、中、后素材或可比较条件;生成前核对对比不夸大且没有缺失关键阶段。",
"AI 宠物拟人": "优先确认宠物角色参考、性格和商品互动方式;生成前核对宠物、商品结构和使用动作前后一致。",
"AI 宠物拟人": "优先确认宠物角色参考、性格和商品互动方式;台词必须是宠物自己说的第一人称,禁止旁白/口播/画外音感;生成前核对宠物、商品结构和使用动作前后一致。",
"人物与音色替换": "先确认原视频、替换对象和替换范围;未经要求不得改变原视频的构图、动作、商品、场景或节奏。",
}
@@ -228,27 +317,39 @@ VIDEO_PRESET_DELIVERY_CONTRACTS: dict[str, str] = {
"最后以干净的产品收束。包装文字、颜色、比例和结构全程稳定,不能用 AI 生成的相似商品替代参考图。"
),
"鱼眼换装": (
"【预设执行层·鱼眼换装】使用近距离鱼眼/广角透视和稳定的同一机位;人物脸、身形、发型、场景、光线连续一致。"
"每一套服装由一个清晰的身体动作触发切换,按用户提供顺序完整展示;镜头的变化来自动作和节奏,不额外编复杂营销剧情。"
),
"【预设执行层·鱼眼换装·小云雀模板】生成时尚换装短视频。"
"比例默认 9:16;时长按服装套数 N 估算(每套约 1.5–2.5 秒,通常 4–15 秒)。"
"风格:超广角鱼眼,中心清晰、四边缘明显畸变拉伸,高饱和,时尚质感,逐拍卡点剪辑。"
"镜头:全片单一机位(本条自选一种并整条不变:低机位仰拍 / 高机位俯拍 / 荷兰角倾斜 / 贴地滑行 / 手持环绕);"
"0–1 秒该机位对准主体、第 1 套造型定格 pose;之后逐拍换装,机位始终不变,仅服装与动作逐拍更新;最后一拍同机位定格收尾。"
"转场:每次换装发生在遮挡/甩镜动态瞬间(抬手遮挡镜头 / 甩头转身划过画面 / 镜头快速甩动出虚影);"
"转场瞬间服装切换,机位保持原位,下一拍回到同一构图。"
"主体:只采用参考图五官、发型、妆容、姿态,不采用参考图服装与背景。"
"服装状态机:S0 造型1 → 转场动作 → S1 造型2 → … → SN 定格收尾;"
"全过程人物身份/体型/发型一致,机位/构图/背景锁定,仅服装逐拍变化;人物始终已穿好当前套,禁止穿戴过程镜头。"
"声音:无对白,预留卡点 BGM,每次转场配快门/whoosh 音效。"
),
"点击换款": (
"【预设执行层·点击换款·强制】这不是口播片、剧情片或普通商品展示片。"
"使用单一固定机位和同一背景,商品全程处于完全相同的中心位置、尺寸、角度与透视。"
"每个切换节点都必须清楚拍到一根手指轻触/点击商品,接触瞬间通过原位 match cut 换成下一款;"
"使用单一固定机位和同一背景,商品构图、尺寸与角度保持稳定。"
"换款由手指轻触/点击触发,接触后以干净 match cut 切到下一款;"
"每一次换款的手指点击落点必须与上一次不同(点商品的不同区域/角点),禁止写或暗示「每次点击同一位置 / 点同一点 / 手指始终点同一处」;分镜与 video_prompt 要体现「本次点击位置不同于上次」,不要复读同一落点。"
"不改场景、不改机位、不运镜、不说话、不出现完整人物、不演剧情、不演示使用步骤。"
"各款必须按用户确认的顺序逐一出现,不自行发明颜色或款式;结尾停在干净的全款式总览。"
),
"多色商品换款": (
"【预设执行层·点击换款·强制】这不是口播片、剧情片或普通商品展示片。"
"使用单一固定机位和同一背景,商品全程处于完全相同的中心位置、尺寸、角度与透视。"
"每个切换节点都必须清楚拍到一根手指轻触/点击商品,接触瞬间通过原位 match cut 换成下一款;"
"使用单一固定机位和同一背景,商品构图、尺寸与角度保持稳定。"
"换款由手指轻触/点击触发,接触后以干净 match cut 切到下一款;"
"每一次换款的手指点击落点必须与上一次不同(点商品的不同区域/角点),禁止写或暗示「每次点击同一位置 / 点同一点 / 手指始终点同一处」;分镜与 video_prompt 要体现「本次点击位置不同于上次」,不要复读同一落点。"
"不改场景、不改机位、不运镜、不说话、不出现完整人物、不演剧情、不演示使用步骤。"
"各款必须按用户确认的顺序逐一出现,不自行发明颜色或款式;结尾停在干净的全款式总览。"
),
"点触换款": (
"【预设执行层·点击换款·强制】这不是口播片、剧情片或普通商品展示片。"
"使用单一固定机位和同一背景,商品全程处于完全相同的中心位置、尺寸、角度与透视。"
"每个切换节点都必须清楚拍到一根手指轻触/点击商品,接触瞬间通过原位 match cut 换成下一款;"
"使用单一固定机位和同一背景,商品构图、尺寸与角度保持稳定。"
"换款由手指轻触/点击触发,接触后以干净 match cut 切到下一款;"
"每一次换款的手指点击落点必须与上一次不同(点商品的不同区域/角点),禁止写或暗示「每次点击同一位置 / 点同一点 / 手指始终点同一处」;分镜与 video_prompt 要体现「本次点击位置不同于上次」,不要复读同一落点。"
"不改场景、不改机位、不运镜、不说话、不出现完整人物、不演剧情、不演示使用步骤。"
"各款必须按用户确认的顺序逐一出现,不自行发明颜色或款式;结尾停在干净的全款式总览。"
),
@@ -267,6 +368,8 @@ VIDEO_PRESET_DELIVERY_CONTRACTS: dict[str, str] = {
"AI 宠物拟人": (
"【预设执行层·AI 宠物拟人】宠物外观、体型、毛色和性格前后一致;趣味来自拟人动作与真实反应。"
"宠物和商品的互动必须符合商品真实结构与正常用法,商品不变成玩具或获得不存在的能力;剧情里至少有一个可见卖点证据。"
"人声必须是宠物角色第一人称台词(我/本汪/本喵等贴合物种的口吻),像宠物对着镜头或商品说话;"
"禁止写成旁白解说、达人口播、画外音讲述或「今天给大家介绍…」类带货腔。"
),
"人物与音色替换": (
"【预设执行层·人物与音色替换】严格保留原视频的构图、镜头顺序、动作、节奏、商品、场景和剪辑;"
@@ -308,39 +411,173 @@ def preset_workflow_guidance(name: str) -> str:
return VIDEO_PRESET_WORKFLOWS.get((name or "").strip(), "")
def video_preset_delivery_contract(name: str) -> str:
"""预设名 → 最终视频生成阶段必须执行的结构与镜头约束。"""
return VIDEO_PRESET_DELIVERY_CONTRACTS.get((name or "").strip(), "")
_CLICK_SWAP_FINGER_CONTRACT = (
"【预设执行层·点击换款·只出手】这不是口播片、剧情片或普通商品展示片。"
"使用单一固定机位和同一背景,商品构图、尺寸与角度保持稳定。"
"换款由手指轻触/点击触发,接触后以干净 match cut 切到下一款;"
"每一次换款的手指点击落点必须与上一次不同(点商品的不同区域/角点),"
"禁止写或暗示「每次点击同一位置 / 点同一点 / 手指始终点同一处」;"
"分镜与 video_prompt 要体现「本次点击位置不同于上次」。"
"不改场景、不改机位、不运镜、不说话、不出现完整人物、不演剧情、不演示使用步骤、不生成角色定妆图。"
"各款必须按用户确认的顺序逐一出现,不自行发明颜色或款式;结尾停在干净的全款式总览。"
)
_CLICK_SWAP_CHARACTER_CONTRACT = (
"【预设执行层·点击换款·角色日常换款】锁定同一位角色与同一日常使用场景。"
"角色在自然生活动作中展示/使用商品,商品颜色或款式按用户确认顺序切换;"
"人物五官、发型、年龄感、身形与基础服装全程一致,场景与光线保持连贯。"
"允许生活感动作,禁止口播带货腔、复杂剧情反转、频繁换景或随机换人;结尾给出全款式收束。"
)
def video_preset_delivery_contract(name: str, *, click_swap_mode: str = "") -> str:
"""预设名 → 最终视频生成阶段必须执行的结构与镜头约束。"""
preset = (name or "").strip()
if preset in CLICK_SWAP_PRESETS:
if (click_swap_mode or "").strip() == "character":
return _CLICK_SWAP_CHARACTER_CONTRACT
return _CLICK_SWAP_FINGER_CONTRACT
return VIDEO_PRESET_DELIVERY_CONTRACTS.get(preset, "")
FISH_EYE_OUTFIT_PRESET = "鱼眼换装"
_FISH_EYE_OUTFIT_PROMPT_TEMPLATE = """
【鱼眼连环换装 · video_prompt 强制模板(来自小云雀写法)】
当预设为「鱼眼换装」时,write_plan / write_prompt 的 video_prompt 必须按下面骨架填写,
用会话里真实的人物、服装套数、机位、场景与顺序替换【】占位;不要改回通用「制作级分镜四行」长文,也不要编口播。
时长:【4-15秒,按N推算,每套服装约1.5-2.5秒】秒
比例:9:16
任务:生成时尚换装短视频。
标题:《鱼眼连环换装·【机位名】版》
风格与视觉参考:
超广角鱼眼镜头风格,画面中心人物清晰、四边缘明显畸变拉伸,
高饱和色彩,时尚质感,逐拍卡点剪辑。
镜头语言:
全片单一机位:【本条视频机位描述,如:低机位仰拍 / 高机位俯拍 /
荷兰角倾斜 / 贴地滑行 / 手持环绕——每条视频自选一种,整条不变】
0-1秒:该机位对准【主体】,第1套造型定格pose
之后逐拍换装,机位始终不变,仅服装与动作逐拍更新:
节拍1:造型1,随节拍做动作
节拍2:造型2,随节拍做动作
节拍3:造型3,随节拍做动作
……按N套延伸
最后一拍:同机位稳定构图,定格收尾pose
转场规则:每次换装发生在遮挡/甩镜的动态瞬间——
【转场方式:抬手遮挡镜头 / 甩头转身划过画面 / 镜头快速甩动出虚影,
转场瞬间服装切换,机位保持原位,下一拍回到同一构图】
主体:
主体1:【人物描述】,对应@图片1
(只采用五官、发型、妆容、姿态,不采用服装与背景)
服装状态机:
S0 造型1:【服装1描述】
→ 动作:【转场方式】
→ S1 造型2:【服装2描述】
→ 动作:【转场方式】
→ ……按N套延伸
→ SN 造型N:【服装N描述】定格收尾
全过程不变量:人物身份、体型、发型全程一致;
机位、构图、背景全程锁定不变,仅服装逐拍变化;
人物始终已穿好当前套,禁止穿衣/套袖/拉拉链等穿戴过程镜头。
声音:无对白,预留卡点BGM位,每次转场配快门/whoosh音效
全片保持主体1身份一致、鱼眼畸变风格统一、
机位与构图全片同一,背景【场景描述】不变。
"""
def is_fish_eye_outfit_preset(name: str) -> bool:
return (name or "").strip() == FISH_EYE_OUTFIT_PRESET
def fish_eye_outfit_prompt_template() -> str:
return _FISH_EYE_OUTFIT_PROMPT_TEMPLATE.strip()
def is_click_swap_preset(name: str) -> bool:
return (name or "").strip() in CLICK_SWAP_PRESETS
def apply_video_preset_prompt(name: str, prompt: str) -> str:
_SAME_CLICK_POS_RE = re.compile(
r"(?:每次(?:都)?)?(?:点击|点触|轻触|手指(?:点击|点)?)(?:在|到)?(?:完全)?(?:相?同的?)?(?:一个位置|一(?:个)?位置|一点|同一处|同一点|同一个位置)"
r"|每次点击同一位置"
r"|点同一点"
r"|手指点在完全相同的位置"
r"|手指始终点同一"
r"|点击位置保持不变"
r"|固定点击位置"
r"|不写点击同一位置"
r"|不必强调每次点同一位置"
)
def scrub_click_swap_same_position_wording(text: str) -> str:
"""去掉提示词里「每次点击同一位置」类表述,并在缺失时补上「每次点击位置不同」。"""
raw = str(text or "")
if not raw:
return raw
cleaned = _SAME_CLICK_POS_RE.sub("", raw)
cleaned = re.sub(r"[,。;]{2,}", ",", cleaned)
cleaned = re.sub(r"\s{2,}", " ", cleaned).strip()
if "点击位置必须不同" not in cleaned and "落点必须与上一次不同" not in cleaned and "落点必须不同于上一次" not in cleaned:
cleaned = (
f"{cleaned}\n\n【点击落点硬性要求】每一次换款的手指点击落点必须与上一次不同"
"(点商品的不同区域/角点),禁止「每次点击同一位置 / 点同一点」。"
).strip()
return cleaned
def apply_video_preset_prompt(name: str, prompt: str, *, click_swap_mode: str = "") -> str:
"""把视频预设确定性并入最终出片指令,避免只依赖对话模型主动复述。"""
base = str(prompt or "").strip()
preset = (name or "").strip()
contract = video_preset_delivery_contract(preset)
mode = (click_swap_mode or "").strip()
contract = video_preset_delivery_contract(preset, click_swap_mode=mode)
if not base or not contract:
return base
marker = f"【视频预设】{preset}"
if marker in base:
return base
if is_click_swap_preset(preset):
# 换款是镜头结构,不是可有可无的风格词。把强制执行层放在前面,
# 原始 Agent Prompt 只作为商品/SKU 事实来源;其中若有口播、剧情、运镜或换场景等拍法,不得执行。
if mode == "character":
return (
f"{marker}\n{contract}\n\n"
"【原始商品与 SKU 信息】仅提取下文中的商品外观、颜色、款式、顺序与角色/场景事实;"
"下文任何与「同一角色、同一日常场景、按序换款」冲突的拍法一律忽略。\n"
f"{base}\n\n"
"【最终执行检查】全片锁定同一位角色与同一生活场景;"
"商品颜色/款式严格按确认顺序切换,禁止随机换人、口播带货腔或复杂剧情反转。"
)
base = scrub_click_swap_same_position_wording(base)
return (
f"{marker}\n{contract}\n\n"
"【原始商品与 SKU 信息】仅提取下文中的商品外观、颜色、款式和顺序事实;"
"下文任何与「固定机位、手指点击、商品原位换款」冲突的拍法一律忽略。\n"
"下文任何与「固定机位、手指点击触发换款、构图稳定」冲突的拍法一律忽略。\n"
f"{base}\n\n"
"【最终执行检查】每一次换款都必须由画面内手指的一次清晰点击触发,"
"切换前后商品中心点、尺寸、角度、背景、光线和机位不变。"
"【最终执行检查】换款由手指点击触发,机位、背景、光线与商品构图保持稳定;"
"每一次换款点击落点必须不同于上一次,禁止「每次点击同一位置 / 点同一点」;"
"不出现完整人物、不生成角色图。"
)
if is_fish_eye_outfit_preset(preset):
return (
f"{marker}\n{contract}\n\n"
"【原始人物与服装信息】仅提取下文中的人物身份、服装套数/顺序、机位、场景与转场事实;"
"下文任何与「全片单机位、鱼眼畸变、节拍换装、服装状态机、无对白」冲突的拍法一律忽略。\n"
f"{base}\n\n"
"【最终执行检查】全片单一机位与构图锁定;鱼眼中心清晰、边缘畸变;"
"换装发生在遮挡/甩镜瞬间且人物始终已穿好当前套;无对白,转场配快门/whoosh;"
"禁止穿衣过程与口播带货腔。"
)
return f"{base}\n\n{marker}\n{contract}"
def apply_image_preset_prompt(name: str, prompt: str) -> str:
"""将已选图片预设的风格字段确定性并入实际出图指令,不依赖 Agent 自行复述。"""
base = str(prompt or "").strip()
+43 -9
View File
@@ -44,6 +44,23 @@ FREE_VIDEO_MODELS = {
"doubao-seedance-2-0-mini-260615",
}
HIGH_RES_MODEL = "doubao-seedance-2-0-260128" # 1080p/4k 仅标准档(火山限制)
# 火山对 seed 的硬上限是 int32 正数上限;超一点整条请求直接 InvalidParameter 被拒。
VIDEO_SEED_MAX = 2147483647
def normalize_video_seed(value) -> int:
"""把任何来源的 seed 收进火山认的区间。-1 = 不指定(随机)。
越界不报错而是折回区间内:长视频各段共用同一个确定性 seed 保一致性,
折算规则固定,同一个会话每次算出来仍是同一个值。
"""
try:
seed = int(value)
except (TypeError, ValueError):
return -1
if seed < 0:
return -1
return seed & VIDEO_SEED_MAX
# 视频复刻固定 Seedance 2.5(单次最长 30 秒)。原片只用来提炼分镜,不传给火山。
REPLACE_MODEL = "doubao-seedance-2-5-260628"
# 出片时长的兜底上限。真实上限按 ModelConfig.metadata.durations 取(见 model_duration_range),
@@ -129,6 +146,29 @@ def _refresh_processing_free_asset(free_asset: FreeAsset) -> bool:
return False
def _reference_asset(team, asset_id) -> Asset | None:
"""出片参考图允许两种来源:团队自有资产,以及官方模特库的人像/三视图。
官方模特挂在平台团队名下,按 team 过滤会直接查不到 —— 这是模特库里明明存在、
生成时却报「不存在或已被删除」的根因。放行范围与视频复刻(video_replace)保持一致。
"""
from django.db.models import Q
from apps.assets.models import Model as AssetModel
asset = Asset.objects.filter(id=asset_id, is_deleted=False).first()
if asset is None:
return None
if asset.team_id == team.id:
return asset
is_official_model_asset = (
AssetModel.objects.filter(is_official=True, is_deleted=False, purged_at__isnull=True)
.filter(Q(portrait_asset=asset) | Q(triview_asset=asset))
.exists()
)
return asset if is_official_model_asset else None
def _guard_asset_reference(asset: Asset, label: str, ref_type: str = "") -> None:
"""三库引用前的审核闸(模块4 · 4.2)。
@@ -317,7 +357,7 @@ def build_content_items(
# 全能创作 resolve_refs / 前端常带 type=character|product 且 source 缺省为 upload,
# 只要有 asset_id 就按 Asset 解析(含审核态 → asset://),避免直链分支把语义 type skipped。
if ref.get("asset_id") and source in {"asset", "upload", ""}:
asset = Asset.objects.filter(id=ref["asset_id"], team=team, is_deleted=False).first()
asset = _reference_asset(team, ref["asset_id"])
if asset is None:
raise ValueError(f"素材「{label or '未命名'}」不存在或已被删除")
_guard_asset_reference(asset, label, ref_type)
@@ -480,10 +520,7 @@ def submit_free_video(*, team, user, params: dict) -> AITask:
duration = int(params.get("duration") or 5)
except (TypeError, ValueError):
raise ValueError("时长参数无效")
try:
seed = int(params.get("seed") if params.get("seed") is not None else -1)
except (TypeError, ValueError):
seed = -1
seed = normalize_video_seed(params.get("seed"))
if not prompt:
raise ValueError("提示词不能为空")
@@ -637,10 +674,7 @@ def start_pending_free_video(task: AITask) -> AITask:
duration = int(payload.get("duration") or 5)
except (TypeError, ValueError):
duration = 5
try:
seed = int(payload.get("seed") if payload.get("seed") is not None else -1)
except (TypeError, ValueError):
seed = -1
seed = normalize_video_seed(payload.get("seed"))
references = list(payload.get("references") or [])
if feature == "video_replace" and payload.get("replace_mode") == "product":
# 商品复刻:原片只用来提炼,绝不能进 Seedance,否则真人素材直接被火山拒。
+61 -23
View File
@@ -13,6 +13,8 @@ from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from django.db.models import Q
from apps.assets.models import Asset, Model
from apps.products.models import Product
@@ -72,11 +74,15 @@ def _search_products(team, q: str, limit: int) -> list[dict]:
def _search_models(team, q: str, limit: int) -> list[dict]:
queryset = Model.objects.filter(team=team, is_deleted=False, purged_at__isnull=True)
queryset = Model.objects.filter(
Q(team=team) | Q(is_official=True),
is_deleted=False,
purged_at__isnull=True,
)
if q:
queryset = queryset.filter(name__icontains=q)
out = []
for model in queryset.select_related("portrait_asset").order_by("-created_at")[:limit]:
for model in queryset.select_related("portrait_asset").order_by("-is_official", "-created_at")[:limit]:
out.append(_ref("model", model.id, model.name, _asset_preview_url(model.portrait_asset)))
return out
@@ -173,7 +179,12 @@ def lookup_mention(team, value: str, types: list[str] | None = None) -> dict | N
return _ref("product", product.id, product.title, _product_cover_url(product))
if "model" in wanted:
model = (
Model.objects.filter(team=team, id=uid, is_deleted=False, purged_at__isnull=True)
Model.objects.filter(
Q(team=team) | Q(is_official=True),
id=uid,
is_deleted=False,
purged_at__isnull=True,
)
.select_related("portrait_asset")
.first()
)
@@ -326,6 +337,25 @@ def _character_triview_asset(team, portrait_id):
)
def _newest_asset(*assets):
"""多张可用图里取最新一张。已删/空值直接跳过。"""
living = [
asset for asset in assets
if asset is not None and not getattr(asset, "is_deleted", False)
]
if not living:
return None
return max(living, key=lambda asset: (asset.created_at, str(asset.id)))
def _single_entity_asset(cover, *triviews):
"""一个角色/商品只喂一张参考图:有三视图用最新三视图,没有才用封面/立绘。
封面和三视图衣服经常不一致;两张一起送,出片模型会当成多出来的角色或 SKU。
"""
return _newest_asset(*triviews) or cover
def _append_ref(resolved: ResolvedRefs, asset, type_: str, label: str) -> bool:
entry = _asset_reference(asset, type_, label)
if not entry:
@@ -375,16 +405,24 @@ def resolve_refs(team, refs: list[dict]) -> ResolvedRefs:
resolved.missing.append(ref)
continue
resolved.facts.append(product_facts_text(product))
chosen = _single_entity_asset(
product.cover_asset,
_product_triview_asset(product),
)
if chosen is not None and _append_ref(resolved, chosen, "product", product.title):
continue
entry = _product_reference(product)
if entry:
resolved.references.append(entry)
triview = _product_triview_asset(product)
if triview is not None:
_append_ref(resolved, triview, "product", f"{product.title}三视图")
elif chosen is None:
resolved.missing.append(ref)
continue
if type_ == "model":
model = Model.objects.filter(
team=team, id=ref_id, is_deleted=False, purged_at__isnull=True
Q(team=team) | Q(is_official=True),
id=ref_id,
is_deleted=False,
purged_at__isnull=True,
).select_related("triview_asset", "portrait_asset").first()
if model is None:
resolved.missing.append(ref)
@@ -399,13 +437,14 @@ def resolve_refs(team, refs: list[dict]) -> ResolvedRefs:
person_lines.extend(meta_lines)
if desc or meta_lines:
resolved.facts.append("\n".join(person_lines))
# 形象图和三视图都带上(有就带,没有不加、不报错)。三视图锁脸更稳。
added = False
if _append_ref(resolved, model.portrait_asset, "model", model.name):
added = True
if _append_ref(resolved, model.triview_asset, "model", f"{model.name}三视图"):
added = True
if not added:
# 一人一张图:有三视图只用最新三视图。形象图和三视图衣服经常不一致,
# 两张一起送会被当成多出来的角色。
chosen = _single_entity_asset(
model.portrait_asset,
model.triview_asset,
_character_triview_asset(team, getattr(model.portrait_asset, "id", None)),
)
if not _append_ref(resolved, chosen, "model", model.name):
resolved.missing.append(ref)
continue
asset = Asset.objects.filter(
@@ -421,15 +460,14 @@ def resolve_refs(team, refs: list[dict]) -> ResolvedRefs:
person_lines.extend(meta_lines)
if desc or meta_lines:
resolved.facts.append("\n".join(person_lines))
added = _append_ref(resolved, asset, type_, asset.name)
# 角色/模特立绘若有配对三视图,一并带给出片;没有就跳过,不要当成引用失败。
if type_ in {"character", "model", "asset"}:
portrait_id = asset.id
# 用户 @ 的就是三视图本身时,不再反查。
if asset.category != Asset.Category.TRI_VIEW:
paired = _character_triview_asset(team, portrait_id)
if paired is not None:
_append_ref(resolved, paired, type_, f"{asset.name}三视图")
if type_ in {"character", "model", "asset"} and asset.category != Asset.Category.TRI_VIEW:
chosen = _single_entity_asset(
asset,
_character_triview_asset(team, asset.id),
)
else:
chosen = asset
added = _append_ref(resolved, chosen, type_, asset.name)
if not added:
resolved.missing.append(ref)
+20 -2
View File
@@ -107,7 +107,13 @@ class VolcanoArkProvider:
stream=True,
timeout=timeout,
) as response:
response.raise_for_status()
if response.status_code >= 400:
# 光有状态码定位不了问题:ARK 的参数校验原因只在响应体里(例如某个
# 字段不被该模型支持)。带上正文再抛,否则每次都只能靠猜。
detail = (response.text or "").strip()[:600]
raise requests.HTTPError(
f"{response.status_code} from {endpoint}: {detail}", response=response
)
# SSE 响应常不带 charset,requests 会按 latin-1 解码 → 中文乱码。强制 UTF-8。
response.encoding = "utf-8"
for raw in response.iter_lines(decode_unicode=True):
@@ -123,6 +129,11 @@ class VolcanoArkProvider:
choices = chunk.get("choices") or []
if not choices:
continue
# finish_reason=length 说明输出撞到 max_tokens 被截断。调用方必须能区分
# 「模型没写」和「写了但被砍掉」,否则坏 JSON 只会被当成模型偷懒反复重试。
finish = choices[0].get("finish_reason")
if finish:
yield {"type": "finish", "reason": str(finish)}
delta = choices[0].get("delta") or {}
# 推理模型(豆包 seed-pro / 部分中转 o系/gemini)思考阶段只发 reasoning_content,
# 不发 content。必须单独转发,否则整个思考期(可达几十秒~分钟)前端零输出 = 假死。
@@ -226,7 +237,14 @@ class VolcanoArkProvider:
"generate_audio": generate_audio,
}
if seed is not None and seed != -1:
body["seed"] = seed
# Seedance 2.5 r2v 硬上限是 int32 正数(2147483647)。文档写 2^32-1,但 r2v 实际更严;
# 超一点整条 InvalidParameter。这里兜住所有上游构造路径。
try:
clamped = int(seed) & 2147483647
except (TypeError, ValueError):
clamped = None
if clamped:
body["seed"] = clamped
if search_mode == "smart":
body["tools"] = [{"type": "web_search"}]
response = requests.post(
+88 -1
View File
@@ -10,6 +10,65 @@ from .models import (
)
# 仅把可解释的阶段名返回给创作页。模型原始 reasoning 可能冗长、跑题或包含内部工作草稿,
# 不能直接作为用户可见内容;前端据此展示「正在做什么」,避免异步规划看起来像卡住。
_PUBLIC_AGENT_PROGRESS = {
"starting": {
"label": "正在读取创作要求",
"detail": "已收到素材、角色与时长设定",
},
"reasoning": {
"label": "正在分析素材与创作方向",
"detail": "正在结合商品、角色和目标时长梳理方案",
},
"search_library": {
"label": "正在查找可用素材",
"detail": "正在核对当前创作需要的素材信息",
},
"write_strategy": {
"label": "正在确定创作策略",
"detail": "正在整理受众、核心卖点和整体表达方向",
},
"write_plan": {
"label": "正在编排视频方案",
"detail": "正在安排分段节奏,并锁定角色和商品的一致性",
},
"write_prompt": {
"label": "正在整理出片指令",
"detail": "正在把方案转成可直接生成的视频脚本",
},
"generate_image": {
"label": "正在准备生成画面",
"detail": "正在整理画面参考与生成条件",
},
"ask_user": {
"label": "正在核对关键设定",
"detail": "正在确认会影响成片效果的信息",
},
"responding": {
"label": "正在整理回复内容",
"detail": "马上把下一步呈现给你",
},
}
_PUBLIC_REASONING_DETAILS = {
"brief": "正在理解这次创作的重点和限制",
"product": "正在提炼商品卖点与可呈现的真实证据",
"cast": "正在安排角色出镜方式与人物关系",
"structure": "正在检查时长、分段节奏和前后衔接",
"consistency": "正在锁定人物与商品在各段的一致性",
"shots": "正在细化镜头、动作和出片表达",
}
def _public_agent_progress(phase: str, detail_key: str = "") -> dict[str, str]:
"""把持久化的阶段键转换成用户可见的受控摘要。"""
progress = dict(_PUBLIC_AGENT_PROGRESS.get(phase, _PUBLIC_AGENT_PROGRESS["starting"]))
if phase == "reasoning":
progress["detail"] = _PUBLIC_REASONING_DETAILS.get(detail_key, progress["detail"])
return progress
class ModelProviderSerializer(serializers.ModelSerializer):
class Meta:
model = ModelProvider
@@ -114,17 +173,19 @@ class CreationConversationSerializer(serializers.ModelSerializer):
message_count = serializers.SerializerMethodField()
cover_url = serializers.SerializerMethodField()
agent_progress = serializers.SerializerMethodField()
class Meta:
model = CreationConversation
fields = [
"id", "title", "mode", "preset", "params", "status",
"agent_status", "agent_started_at",
"agent_progress",
"message_count", "cover_url",
"last_active_at", "created_at", "updated_at",
]
read_only_fields = [
"id", "status", "agent_status", "agent_started_at",
"id", "status", "agent_status", "agent_started_at", "agent_progress",
"message_count", "cover_url",
"last_active_at", "created_at", "updated_at",
]
@@ -149,6 +210,32 @@ class CreationConversationSerializer(serializers.ModelSerializer):
first = assets[0] or {}
return first.get("cover") or first.get("url") or ""
def get_agent_progress(self, obj):
"""规划期间只返回受控阶段文案,绝不把模型原始 thinking 暴露给页面。"""
if obj.agent_status != CreationConversation.AgentStatus.PLANNING:
return None
memory = obj.memory if isinstance(obj.memory, dict) else {}
phase = str(memory.get("agent_progress_phase") or "")
if not phase:
return None
detail_key = str(memory.get("agent_progress_detail_key") or "")
progress = _public_agent_progress(phase, detail_key)
history: list[dict[str, str]] = []
for item in memory.get("agent_progress_history") or []:
if not isinstance(item, dict):
continue
history.append(
_public_agent_progress(
str(item.get("phase") or ""),
str(item.get("detail_key") or ""),
)
)
# 兼容服务端升级前已开始的轮次:至少显示当前一条进度。
if not history or history[-1] != progress:
history.append(progress)
progress["history"] = history[-12:]
return progress
def update(self, instance, validated_data):
# mode 定死:允许传但忽略,避免前端误改后顶栏参数与已生成内容对不上
validated_data.pop("mode", None)
+4
View File
@@ -3389,6 +3389,10 @@ def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVers
# 按火山真实 usage.total_tokens 结算(true-up,与自由创作同口径):
# 多退(charge 差额自动 RELEASE)/超预留 clamp(ledger 禁超扣,差额平台承担并告警)。
# usage 缺失(异常响应)回落预估价,不阻断出片。
# 提交那头(submit_video_segment)是函数内 import;这里漏了同一个名字,
# 视频段一出片走到结算就 NameError 炸掉,钱没结、版本没建、段永远卡「生成中」。
from apps.billing.pricing import settle_video_from_payload
reservation = locked_task.credit_reservation
payload = dict(locked_task.request_payload or {})
try:
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,7 @@
"""全能创作 · 会话与消息底座(契约 §1/§3)。"""
from django.test import TestCase
from unittest.mock import patch
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
@@ -208,6 +210,79 @@ class GenerationBackfillTests(TestCase):
self.assertEqual(message.payload["assets"][0]["label"], "第 1 段")
self.assertEqual(message.payload["assets"][0]["url"], "https://cdn.example/segment-1.mp4")
@override_settings(FREE_VIDEO_MAX_CONCURRENT=3)
def test_long_video_submits_next_wave_after_first_wave_finishes(self):
first_wave = []
segments = []
for index in range(1, 7):
segment = {"index": index, "start": (index - 1) * 30, "end": index * 30, "duration": 30}
if index <= 3:
task = AITask.objects.create(
team=self.team,
created_by=self.user,
task_type=AITask.Type.FREE_VIDEO,
model_config=self.model,
status=AITask.Status.SUCCEEDED,
idempotency_key=f"k-wave-first-{index}",
)
self._asset(task, url=f"https://cdn.example/segment-{index}.mp4")
first_wave.append(task)
segment["task_id"] = str(task.id)
segments.append(segment)
message = append_message(
self.conversation,
role="assistant",
kind=CreationMessage.Kind.GENERATING,
task=first_wave[0],
payload={
"kind": "video_segments",
"task_id": str(first_wave[0].id),
"task_ids": [str(task.id) for task in first_wave],
"total_duration": 180,
"segments": segments,
"generation_spec": {
"prompt": "完整 180 秒连续脚本",
"feature": "omni_create",
"mode": "universal",
"model": "doubao-seedance-2-5-260628",
"aspect_ratio": "9:16",
"resolution": "720p",
"duration": 180,
"generate_audio": True,
"references": [],
"seed": 42,
},
},
)
created = []
def fake_submit(*, team, user, params):
task = AITask.objects.create(
team=team,
created_by=user,
task_type=AITask.Type.FREE_VIDEO,
model_config=self.model,
status=AITask.Status.SUBMITTED,
idempotency_key=f"k-wave-second-{len(created) + 1}",
request_payload=params,
)
created.append(task)
return task
with patch("apps.ai.free_video.submit_free_video", side_effect=fake_submit) as submit:
self.assertEqual(sync_generating_messages(self.conversation), 1)
message.refresh_from_db()
self.assertEqual(submit.call_count, 3)
self.assertEqual(message.kind, CreationMessage.Kind.GENERATING)
self.assertEqual(message.payload["completed_segment_count"], 3)
self.assertEqual(message.payload["submitted_segment_count"], 6)
self.assertEqual(len(message.payload["task_ids"]), 6)
self.assertTrue(all(item.get("task_id") for item in message.payload["segments"]))
self.assertTrue(all(task.request_payload["seed"] == 42 for task in created))
self.assertTrue(all(task.request_payload["omni_segment"]["message_id"] == str(message.id) for task in created))
self.assertIn("第 4/6 段", created[0].request_payload["prompt"])
def test_retrieve_backfills_before_returning_messages(self):
task = self._task(AITask.Status.SUCCEEDED, key="k-api")
self._asset(task, url="https://cdn.example/api.png")
+26 -10
View File
@@ -112,7 +112,8 @@ class ResolveRefsTests(TestCase):
self.assertEqual(entry["review_status"], "active")
self.assertEqual(entry["review_remote_id"], "R-1")
def test_model_ref_includes_portrait_and_triview(self):
def test_model_ref_prefers_triview_over_portrait(self):
"""一人一张图。形象图和三视图衣服经常不一致,两张一起送会被当成多出来的角色。"""
portrait = _image_asset(self.team, self.user, "形象图", Asset.Category.MODEL_PORTRAIT, url="https://cdn/p.jpg")
triview = _image_asset(self.team, self.user, "三视图", Asset.Category.TRI_VIEW, url="https://cdn/t.jpg")
model = Model.objects.create(
@@ -121,7 +122,8 @@ class ResolveRefsTests(TestCase):
)
resolved = resolve_refs(self.team, [{"type": "model", "id": str(model.id)}])
self.assertEqual([r["url"] for r in resolved.references], ["https://cdn/p.jpg", "https://cdn/t.jpg"])
self.assertEqual([r["url"] for r in resolved.references], ["https://cdn/t.jpg"])
self.assertEqual(resolved.references[0]["label"], "小夏")
def test_model_falls_back_to_portrait_when_no_triview(self):
portrait = _image_asset(self.team, self.user, "形象图2", Asset.Category.MODEL_PORTRAIT, url="https://cdn/p2.jpg")
@@ -168,7 +170,7 @@ class ResolveRefsTests(TestCase):
resolved = resolve_refs(self.team, [{"type": "model", "id": str(model.id)}])
self.assertIn("性别:男", resolved.facts_text)
def test_character_triview_is_attached_when_paired(self):
def test_character_uses_triview_only_when_paired(self):
self.person.metadata = {}
self.person.save(update_fields=["metadata"])
_image_asset(
@@ -177,25 +179,39 @@ class ResolveRefsTests(TestCase):
metadata={"triview_of": str(self.person.id)},
)
resolved = resolve_refs(self.team, [{"type": "character", "id": str(self.person.id)}])
urls = [r["url"] for r in resolved.references]
self.assertIn("https://cdn/person.jpg", urls)
self.assertIn("https://cdn/person-tri.jpg", urls)
self.assertEqual([r["url"] for r in resolved.references], ["https://cdn/person-tri.jpg"])
self.assertEqual(len(resolved.references), 1)
def test_missing_triview_does_not_mark_ref_missing(self):
resolved = resolve_refs(self.team, [{"type": "character", "id": str(self.person.id)}])
self.assertEqual(resolved.missing, [])
self.assertEqual([r["url"] for r in resolved.references], ["https://cdn/person.jpg"])
def test_product_triview_is_attached_when_present(self):
def test_product_uses_triview_only_when_present(self):
_image_asset(
self.team, self.user, "商品三视图", Asset.Category.PRODUCT_IMAGE,
url="https://cdn/prod-tri.jpg",
metadata={"product_id": str(self.product.id), "view": "three_view"},
)
resolved = resolve_refs(self.team, [{"type": "product", "id": str(self.product.id)}])
urls = [r["url"] for r in resolved.references]
self.assertIn("https://cdn/prod.jpg", urls)
self.assertIn("https://cdn/prod-tri.jpg", urls)
self.assertEqual([r["url"] for r in resolved.references], ["https://cdn/prod-tri.jpg"])
self.assertEqual(resolved.references[0]["label"], "净颜精华")
def test_latest_triview_wins_when_several_exist(self):
portrait = _image_asset(self.team, self.user, "形象图4", Asset.Category.MODEL_PORTRAIT, url="https://cdn/p4.jpg")
old = _image_asset(self.team, self.user, "旧三视图", Asset.Category.TRI_VIEW, url="https://cdn/old-t.jpg")
new = _image_asset(self.team, self.user, "新三视图", Asset.Category.TRI_VIEW, url="https://cdn/new-t.jpg")
old.created_at = old.created_at.replace(year=2024)
old.save(update_fields=["created_at"])
model = Model.objects.create(
team=self.team, created_by=self.user, name="周野",
portrait_asset=portrait, triview_asset=old,
)
new.metadata = {"triview_of": str(portrait.id)}
new.save(update_fields=["metadata"])
resolved = resolve_refs(self.team, [{"type": "model", "id": str(model.id)}])
self.assertEqual([r["url"] for r in resolved.references], ["https://cdn/new-t.jpg"])
def test_product_facts_text_without_selling_points_still_has_title(self):
bare = Product.objects.create(team=self.team, created_by=self.user, title="裸商品")
@@ -1,35 +1,33 @@
"""实体提取已经改成**本地**拆脚本:不调模型、不扣积分、不换模型重试。
这份文件原来锁的是「调模型 + 失败换模型 + 按 token 结算」那条链路(AIModelAttempt、
CreditLedger、model_routing_v1)。那条链路已经随 submit_extract_entities 改成本地提取一起下线,
`run_extract_entities_task` 现在没有任何地方会入队。所以这里改为锁住真正在跑的契约:
提取必须零成本、不留模型调用痕迹,脚本缺 entities 时本地补齐。
落库内容本身(cast/scenes/entity_refs 回填)由 apps/projects/tests.py 的本地提取用例覆盖。
"""
from decimal import Decimal
from unittest.mock import Mock, patch
from unittest.mock import patch
import requests
from django.test import TestCase
from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import AITask, ModelConfig, ModelProvider
from apps.ai.services import run_extract_entities_task, submit_extract_entities
from apps.ai.services import submit_extract_entities
from apps.billing.models import CreditAccount, CreditLedger
from apps.products.models import Product
from apps.projects.models import Project, ScriptSegment, ScriptVersion
def _metadata(*, outbound=True, base_cost="0.50"):
return {
"routing": {"fallback_on_failure": outbound, "fallback_candidate": True},
"capabilities": {
"operations": ["chat"],
"features": ["streaming", "structured_output"],
},
"pricing": {"base_cost_yuan": base_cost},
}
class EntityExtractionRoutingTests(TestCase):
class LocalEntityExtractionTests(TestCase):
def setUp(self):
ModelConfig.objects.filter(capability=ModelConfig.Capability.TEXT).update(
status=ModelConfig.Status.DISABLED
)
self.user = User.objects.create_user(username="entity-routing", password="x")
self.team = Team.objects.create(name="Entity Routing", owner=self.user)
self.user = User.objects.create_user(username="entity-local", password="x")
self.team = Team.objects.create(name="Entity Local", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
CreditAccount.objects.create(team=self.team, balance=Decimal("1000"))
product = Product.objects.create(team=self.team, created_by=self.user, title="测试商品")
@@ -37,222 +35,77 @@ class EntityExtractionRoutingTests(TestCase):
team=self.team,
created_by=self.user,
product=product,
name="实体提取路由项目",
name="实体提取项目",
metadata={"cast": ["旧角色"], "entities_extracted": False},
)
provider = ModelProvider.objects.create(
name="entity-local-provider",
display_name="entity-local-provider",
status=ModelProvider.Status.ACTIVE,
)
ModelConfig.objects.create(
provider=provider,
name="entity-local-model",
display_name="entity-local-model",
capability=ModelConfig.Capability.TEXT,
endpoint="chat/completions",
unit_price=Decimal("10"),
status=ModelConfig.Status.ACTIVE,
)
self.script = ScriptVersion.objects.create(
project=self.project,
title="脚本",
content="结构化脚本",
is_adopted=True,
metadata={"entities": [
{"id": "c1", "type": "character", "name": "女主", "visual_prompt": "都市女主"},
{"id": "s1", "type": "scene", "name": "客厅", "visual_prompt": "现代客厅"},
]},
)
self.segment = ScriptSegment.objects.create(
ScriptSegment.objects.create(
script_version=self.script,
sort_order=0,
narration="女主在客厅展示商品",
visual_prompt="女主站在客厅",
entity_refs=["old"],
)
self.provider_mocks = {}
patch("apps.ai.services.get_text_provider", side_effect=self._provider_for).start()
patch("apps.ai.tasks.extract_entities_task.delay").start()
self.addCleanup(patch.stopall)
def provider(self, name, priority):
return ModelProvider.objects.create(
name=name,
display_name=name,
status=ModelProvider.Status.ACTIVE,
metadata={"routing": {"fallback_priority": priority}},
)
def test_extraction_is_free_and_never_calls_a_model(self):
with patch("apps.ai.services.get_text_provider") as get_provider:
task = submit_extract_entities(project=self.project, user=self.user)
def model(self, provider, name, *, outbound=True, base_cost="0.50", is_default=False):
return ModelConfig.objects.create(
provider=provider,
name=name,
display_name=name,
capability=ModelConfig.Capability.TEXT,
endpoint="chat/completions",
unit_price=Decimal("10"),
status=ModelConfig.Status.ACTIVE,
is_default=is_default,
metadata=_metadata(outbound=outbound, base_cost=base_cost),
)
get_provider.assert_not_called()
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
self.assertEqual(task.request_payload.get("mode"), "local")
self.assertEqual(task.estimated_cost, Decimal("0"))
self.assertEqual(task.actual_cost, Decimal("0"))
self.assertEqual(task.base_cost, Decimal("0"))
# 不走模型就不该留下换模型痕迹,也不该有任何积分流水。
self.assertFalse(task.model_attempts.exists())
self.assertFalse(CreditLedger.objects.filter(task=task).exists())
@staticmethod
def valid_events():
return [
{
"type": "delta",
"text": (
'{"entities":['
'{"id":"c1","type":"character","name":"女主","visual_prompt":"都市女主"},'
'{"id":"s1","type":"scene","name":"客厅","visual_prompt":"现代客厅"}'
'],"segments":[{"index":0,"entity_refs":["c1","s1"]}]}'
),
},
{"type": "done"},
]
def test_entities_are_persisted_and_old_metadata_is_replaced(self):
submit_extract_entities(project=self.project, user=self.user)
@staticmethod
def invalid_events():
return [{"type": "delta", "text": "这是一段没有 JSON 的解释"}, {"type": "done"}]
@classmethod
def _new_provider_mock(cls):
provider = Mock()
provider.chat_completion_stream.return_value = cls.valid_events()
return provider
def _provider_for(self, model):
return self.provider_mocks.setdefault(model.id, self._new_provider_mock())
def submit(self):
return submit_extract_entities(project=self.project, user=self.user)
@staticmethod
def ledger_count(task, ledger_type):
return CreditLedger.objects.filter(task=task, ledger_type=ledger_type).count()
def test_first_success_records_streaming_structured_attempt_and_persists_entities(self):
primary = self.model(self.provider("entity-primary", 20), "entity-primary", is_default=True)
task = self.submit()
run_extract_entities_task(task_id=str(task.id))
task.refresh_from_db()
self.project.refresh_from_db()
self.segment.refresh_from_db()
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
self.assertTrue(task.request_payload["model_routing_v1"])
attempt = task.model_attempts.get()
self.assertEqual(attempt.model_config_id, primary.id)
self.assertEqual(attempt.operation, "chat")
self.assertTrue(attempt.request_summary["streaming"])
self.assertTrue(attempt.request_summary["structured_output"])
self.assertEqual(attempt.request_summary["business_operation"], "entity_extract")
self.assertEqual(attempt.public_model_name, "AirShelf Script")
self.assertTrue(self.project.metadata["entities_extracted"])
self.assertEqual(self.project.metadata["cast"], ["女主"])
self.assertEqual(self.project.metadata["scenes"], ["客厅"])
self.assertEqual(self.segment.entity_refs, ["c1", "s1"])
call = self.provider_mocks[primary.id].chat_completion_stream.call_args
self.assertGreater(call.kwargs["timeout"], 0)
self.assertEqual(call.kwargs["temperature"], 0.3)
self.assertEqual(self.ledger_count(task, CreditLedger.Type.RESERVE), 1)
self.assertEqual(self.ledger_count(task, CreditLedger.Type.CHARGE), 1)
metadata = self.project.metadata or {}
self.assertTrue(metadata.get("entities_extracted"))
self.assertIn("女主", metadata.get("cast") or [])
self.assertNotIn("旧角色", metadata.get("cast") or [])
def test_invalid_structure_retries_then_falls_back_and_records_failed_call_costs(self):
primary = self.model(
self.provider("entity-fallback-primary", 100),
"entity-primary",
base_cost="0.25",
def test_script_without_segments_is_rejected_before_any_task(self):
empty_script = ScriptVersion.objects.create(
project=self.project, title="空脚本", content="{}", is_adopted=True,
)
candidate = self.model(
self.provider("entity-fallback-candidate", 10),
"entity-candidate",
outbound=False,
base_cost="0.75",
self.script.is_adopted = False
self.script.save(update_fields=["is_adopted"])
with self.assertRaises(ValueError):
submit_extract_entities(project=self.project, user=self.user)
self.assertFalse(
AITask.objects.filter(
project=self.project, task_type=AITask.Type.ENTITY_EXTRACTION
).exists()
)
primary_mock = self._new_provider_mock()
primary_mock.chat_completion_stream.side_effect = [
self.invalid_events(),
self.invalid_events(),
self.invalid_events(),
]
self.provider_mocks[primary.id] = primary_mock
task = self.submit()
run_extract_entities_task(task_id=str(task.id))
task.refresh_from_db()
attempts = list(task.model_attempts.all())
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
self.assertEqual(
[a.model_config_id for a in attempts],
[primary.id, primary.id, primary.id, candidate.id],
)
self.assertEqual(
[a.status for a in attempts],
["failed", "failed", "failed", "succeeded"],
)
self.assertTrue(attempts[-1].is_fallback)
self.assertTrue(attempts[0].response_summary["validation_failed"])
self.assertEqual(task.base_cost, Decimal("1.5000"))
self.assertEqual(self.ledger_count(task, CreditLedger.Type.RESERVE), 1)
self.assertEqual(self.ledger_count(task, CreditLedger.Type.CHARGE), 1)
self.assertEqual(self.ledger_count(task, CreditLedger.Type.RELEASE), 0)
def test_all_candidates_fail_releases_and_preserves_old_project_metadata(self):
primary = self.model(self.provider("entity-all-primary", 100), "entity-primary")
fallback_1 = self.model(
self.provider("entity-all-fallback-1", 10), "entity-fallback-1", outbound=False
)
fallback_2 = self.model(
self.provider("entity-all-fallback-2", 20), "entity-fallback-2", outbound=False
)
for model in (primary, fallback_1, fallback_2):
provider = self._new_provider_mock()
provider.chat_completion_stream.side_effect = requests.ConnectionError("offline")
self.provider_mocks[model.id] = provider
task = self.submit()
run_extract_entities_task(task_id=str(task.id))
task.refresh_from_db()
self.project.refresh_from_db()
self.segment.refresh_from_db()
self.assertEqual(task.status, AITask.Status.FAILED)
self.assertEqual(task.model_attempts.count(), 4)
self.assertEqual(self.project.metadata["cast"], ["旧角色"])
self.assertFalse(self.project.metadata["entities_extracted"])
self.assertEqual(self.segment.entity_refs, ["old"])
self.assertEqual(self.ledger_count(task, CreditLedger.Type.RESERVE), 1)
self.assertEqual(self.ledger_count(task, CreditLedger.Type.CHARGE), 0)
self.assertEqual(self.ledger_count(task, CreditLedger.Type.RELEASE), 1)
self.assertEqual(CreditAccount.objects.get(team=self.team).reserved_balance, Decimal("0"))
def test_direct_doubao_primary_retries_but_does_not_switch_when_outbound_disabled(self):
primary = self.model(
self.provider("doubao", 10), "doubao-seed-2-0-pro-260215", outbound=False
)
candidate = self.model(
self.provider("entity-unused-candidate", 20), "entity-unused", outbound=False
)
provider = self._new_provider_mock()
provider.chat_completion_stream.side_effect = requests.ConnectionError("offline")
self.provider_mocks[primary.id] = provider
task = self.submit()
run_extract_entities_task(task_id=str(task.id))
task.refresh_from_db()
attempts = list(task.model_attempts.all())
self.assertEqual(task.status, AITask.Status.FAILED)
self.assertEqual(
[attempt.model_config_id for attempt in attempts],
[primary.id, primary.id, primary.id],
)
self.assertFalse(any(attempt.model_config_id == candidate.id for attempt in attempts))
self.assertTrue(all(attempt.public_model_name == primary.display_name for attempt in attempts))
self.assertEqual(self.ledger_count(task, CreditLedger.Type.RESERVE), 1)
self.assertEqual(self.ledger_count(task, CreditLedger.Type.RELEASE), 1)
def test_uses_database_default_text_model_not_older_doubao_pin(self):
older = self.model(self.provider("volcengine-old", 20), "doubao-seed-2-0-pro-260215")
default = self.model(
self.provider("volcengine-default", 10),
"doubao-seed-2-1-pro-260628",
is_default=True,
)
task = self.submit()
run_extract_entities_task(task_id=str(task.id))
task.refresh_from_db()
attempt = task.model_attempts.get()
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
self.assertEqual(attempt.model_config_id, default.id)
self.assertNotEqual(attempt.model_config_id, older.id)
self.assertEqual(task.request_payload["model"], "doubao-seed-2-1-pro-260628")
self.assertFalse(empty_script.segments.exists())
+15
View File
@@ -261,6 +261,21 @@ class SubmitFreeVideoTests(TestCase):
self.assertEqual(task.request_payload["estimated_tokens"], tokens)
self.assertEqual(task.request_payload["feature"], "free_video")
def test_oversized_seed_is_clamped_before_volcano(self):
"""全能创作长视频用会话 id 前 8 位十六进制当 seed,最大约 42.9 亿。
Seedance 2.5 r2v 只接受 <= 2147483647,用户这次翻车的值就是 3857139456。"""
from apps.ai.free_video import VIDEO_SEED_MAX, normalize_video_seed
raw = 3857139456
task = submit_free_video(
team=self.team, user=self.user, params=self._params(seed=raw)
)
sent = self.provider.create_video_task.call_args.kwargs["seed"]
self.assertEqual(task.request_payload["seed"], normalize_video_seed(raw))
self.assertEqual(sent, normalize_video_seed(raw))
self.assertLessEqual(sent, VIDEO_SEED_MAX)
self.assertNotEqual(sent, raw)
def test_insufficient_balance_leaves_nothing(self):
CreditAccount.objects.filter(team=self.team).update(balance="0.0100")
with self.assertRaisesMessage(ValueError, "余额不足"):
@@ -11,7 +11,7 @@ from django.test import TestCase
from apps.accounts.models import Team, User
from apps.ai.free_video import build_content_items
from apps.assets.models import Asset, AssetFile
from apps.assets.models import Asset, AssetFile, Model as AssetModel
from apps.assets.review import reference_review_state
@@ -88,6 +88,24 @@ class ReferenceReviewStateTests(TestCase):
self.assertEqual(reference_review_state(_asset(self.team, source=Asset.Source.UPLOAD)), "allowed")
class VideoSeedRangeTests(TestCase):
"""火山 seed 上限是 int32 正数上限。长视频各段共用的确定性 seed 取会话 id 前 8 位
十六进制,最大约 42.9 亿 —— 不收口整条请求会被 InvalidParameter 拒掉。"""
def test_seed_is_clamped_into_volcano_range(self):
from apps.ai.free_video import VIDEO_SEED_MAX, normalize_video_seed
self.assertEqual(VIDEO_SEED_MAX, 2147483647)
self.assertLessEqual(normalize_video_seed(3857139456), VIDEO_SEED_MAX)
self.assertLessEqual(normalize_video_seed(0xFFFFFFFF), VIDEO_SEED_MAX)
self.assertEqual(normalize_video_seed(12345), 12345)
# 同一个值每次折算结果一致,跨段一致性不会因此漂移
self.assertEqual(normalize_video_seed(3857139456), normalize_video_seed(3857139456))
self.assertEqual(normalize_video_seed(None), -1)
self.assertEqual(normalize_video_seed(-1), -1)
self.assertEqual(normalize_video_seed("abc"), -1)
class AssetReferenceBuildTests(TestCase):
"""source=asset 分支:URL 解析 + 闸门拦截 + @label 映射。"""
@@ -175,6 +193,44 @@ class AssetReferenceBuildTests(TestCase):
self._build(asset)
self.assertIn("不存在", str(ctx.exception))
def test_official_model_asset_is_usable_across_teams(self):
"""官方模特挂在平台团队名下。按 team 过滤会让模特库里明明在的模特,
出片时报「不存在或已被删除」—— 放行范围与视频复刻保持一致。"""
platform = Team.objects.create(
name="PLATFORM",
owner=User.objects.create_user(username="platform-owner", password="p"),
)
portrait = _asset(platform, source=Asset.Source.AI_GENERATED)
AssetModel.objects.create(
team=platform,
name="阳光运动 · 周野",
source=AssetModel.Source.AI,
portrait_asset=portrait,
is_official=True,
)
built = self._build(portrait, label="阳光运动 · 周野")
self.assertEqual(built["image_n"], 1)
self.assertEqual(built["content_items"][0]["image_url"]["url"], "http://tos/1.png")
def test_non_official_model_asset_stays_team_scoped(self):
"""只放行官方模特。别的团队自建模特的图仍然不可跨团队引用。"""
stranger = Team.objects.create(
name="STRANGER",
owner=User.objects.create_user(username="stranger-owner", password="p"),
)
portrait = _asset(stranger, source=Asset.Source.AI_GENERATED)
AssetModel.objects.create(
team=stranger,
name="别人家的模特",
source=AssetModel.Source.AI,
portrait_asset=portrait,
)
with self.assertRaises(ValueError) as ctx:
self._build(portrait)
self.assertIn("不存在", str(ctx.exception))
def test_keyframe_rejects_non_image(self):
asset = _asset(self.team, source=Asset.Source.AI_GENERATED)
asset.asset_type = Asset.Type.VIDEO
@@ -19,6 +19,20 @@ from apps.billing.models import CreditAccount
from apps.products.models import Product
from apps.projects.models import Project, ScriptVersion
# 这组只验「来源」徽标记得对不对,但稿子仍要真的过脚本自检(口播字数、画面 ≥110 字、秒级分镜),
# 否则生成会先以「结果处理失败」收场,根本走不到落库那一步。
VALID_NARRATION = (
"下午三点工位犯困,键盘都敲不利索。我以前总是硬扛,越扛脑子越乱。"
"后来会先倒杯热茶,第一口是回甘不是苦,桌上的文件终于能看进去,"
"整个人才慢慢醒过来,状态也稳下来。"
)
VALID_VISUAL = (
"0-3s:近景;平视;固定机位;女主在画面右侧对镜头抬眼,工位键盘还亮着,窗外是下午的光\n"
"3-8s:中近景;侧后方过肩;手持跟拍到桌面;右手把茶包放进盛了热水的玻璃杯,水面起蒸汽\n"
"8-12s:特写;俯拍杯口;缓慢推近;茶汤从浅金慢慢变深,标签贴在杯沿\n"
"12-15s:中近景;平视;拉回;女主双手捧杯喝一口,眉头松开肩膀塌下来"
)
class ScriptEntrySourceTests(TransactionTestCase):
reset_sequences = True
@@ -61,7 +75,7 @@ class ScriptEntrySourceTests(TransactionTestCase):
def _provider(self, _model):
raw = json.dumps(
{
"hook": "开场钩子",
"hook": "下午三点工位犯困,键盘都敲不利索",
"tone": "自然",
"aspect_ratio": "9:16",
"total_duration": 15,
@@ -72,7 +86,7 @@ class ScriptEntrySourceTests(TransactionTestCase):
"segments": [
{
"index": 0, "duration": 15, "role": "钩子",
"narration": "全新脚本口播", "visual": "女主展示商品",
"narration": VALID_NARRATION, "visual": VALID_VISUAL,
"speaker": "女主", "product_exposure": "展示",
"entity_refs": ["c1"], "dialogue": [],
}
@@ -14,6 +14,21 @@ from apps.products.models import Product
from apps.projects.models import Project, ScriptSegment, ScriptVersion
# 15 秒一镜的合法口播与画面:口播要到 narration_floor(15),画面要 ≥110 字且带足秒级分镜、
# 景别与运镜标记。路由测试只关心换模型和结算,但稿子仍要真的过得了脚本自检。
VALID_NARRATION = (
"下午三点工位犯困,键盘都敲不利索。我以前总是硬扛,越扛脑子越乱。"
"后来会先倒杯热茶,第一口是回甘不是苦,桌上的文件终于能看进去,"
"整个人才慢慢醒过来,状态也稳下来。"
)
VALID_VISUAL = (
"0-3s:近景;平视;固定机位;女主在画面右侧对镜头抬眼,工位键盘还亮着,窗外是下午的光\n"
"3-8s:中近景;侧后方过肩;手持跟拍到桌面;右手把茶包放进盛了热水的玻璃杯,水面起蒸汽\n"
"8-12s:特写;俯拍杯口;缓慢推近;茶汤从浅金慢慢变深,标签贴在杯沿\n"
"12-15s:中近景;平视;拉回;女主双手捧杯喝一口,眉头松开肩膀塌下来"
)
def _metadata(*, outbound=True, base_cost="0.50"):
return {
"routing": {"fallback_on_failure": outbound, "fallback_candidate": True},
@@ -69,8 +84,10 @@ class ScriptStreamRoutingTests(TransactionTestCase):
@staticmethod
def valid_draft():
# 口播字数、画面字数和秒级分镜都必须真的过 assert_shot_density / assert_script_has_a_hook;
# 写成「全新脚本口播」这种占位文本会被当成模型偷懒直接判失败,整条流就退化成 error。
return {
"hook": "开场钩子",
"hook": "下午三点工位犯困,键盘都敲不利索",
"tone": "自然",
"aspect_ratio": "9:16",
"total_duration": 15,
@@ -89,8 +106,8 @@ class ScriptStreamRoutingTests(TransactionTestCase):
"index": 0,
"duration": 15,
"role": "钩子",
"narration": "全新脚本口播",
"visual": "女主展示商品",
"narration": VALID_NARRATION,
"visual": VALID_VISUAL,
"speaker": "女主",
"product_exposure": "展示",
"entity_refs": ["c1"],
+45
View File
@@ -1907,6 +1907,51 @@ class ChatStreamReasoningTests(SimpleTestCase):
self.assertEqual([e["text"] for e in events if e["type"] == "reasoning"], ["先想想", "用户要4镜"])
self.assertEqual("".join(e["text"] for e in events if e["type"] == "delta"), "正在生成脚本…")
def test_length_finish_reason_is_forwarded(self):
"""撞 max_tokens 的那一刀必须能被上层看见,否则截断的 tool 参数会被当成模型偷懒反复重试。"""
lines = [
"data: " + json.dumps({"choices": [{"delta": {"content": "前半段"}}]}, ensure_ascii=False),
"data: " + json.dumps({"choices": [{"delta": {}, "finish_reason": "length"}]}, ensure_ascii=False),
"data: [DONE]",
]
prov = VolcanoArkProvider(api_key="k", base_url="http://x")
with patch("apps.ai.providers.volcano.requests.post", return_value=_FakeStreamResp(lines)):
events = list(prov.chat_completion_stream(model="m", messages=[{"role": "user", "content": "hi"}]))
self.assertEqual([e["type"] for e in events], ["delta", "finish", "done"])
self.assertEqual(events[1]["reason"], "length")
class VolcanoVideoSeedTests(SimpleTestCase):
"""Seedance 2.5 r2v 的 seed 上限是 int32,超了会 InvalidParameter。"""
def test_create_video_task_clamps_seed_to_int32(self):
captured = {}
class _Resp:
ok = True
status_code = 200
text = "{}"
def json(self):
return {"id": "ark-1"}
def _post(*_args, **kwargs):
captured["body"] = kwargs["json"]
return _Resp()
prov = VolcanoArkProvider(api_key="k", base_url="http://x")
with patch("apps.ai.providers.volcano.requests.post", side_effect=_post):
prov.create_video_task(
model="doubao-seedance-2-5-260628",
endpoint="contents/generations/tasks",
prompt="test",
seed=3857139456,
)
self.assertEqual(captured["body"]["seed"], 3857139456 & 2147483647)
self.assertLessEqual(captured["body"]["seed"], 2147483647)
class WorkbenchAndUnreadTests(TestCase):
"""R100(工作台记录后端持久化)+ R96(未读生成任务角标)+ R109(删除联动)端到端:
+22 -13
View File
@@ -33,6 +33,7 @@ from .free_video import (
IN_FLIGHT_STATUSES,
RATIOS,
RESOLUTIONS,
normalize_video_seed,
_reap_stale_free_video_tasks,
model_duration_range,
serialize_free_video_task,
@@ -654,6 +655,21 @@ def video_replace_q() -> Q:
return Q(request_payload__feature=FEATURE) | Q(request_payload__prompt__startswith=LEGACY_PROMPT_PREFIX)
def not_video_replace_q() -> Q:
"""「不是视频复刻」。不能写成 exclude(video_replace_q())。
JSON 键不存在时取值是 SQL NULL,NOT(NULL = 'x') 仍是 NULL,整行会被 exclude 一起筛掉 ——
于是 request_payload 里没有 feature 的历史任务在列表和回收站里凭空消失。必须把「键缺失」
显式算作不匹配。
"""
return (
Q(request_payload__feature__isnull=True) | ~Q(request_payload__feature=FEATURE)
) & (
Q(request_payload__prompt__isnull=True)
| ~Q(request_payload__prompt__startswith=LEGACY_PROMPT_PREFIX)
)
def _fresh_digest_source(payload: dict) -> dict | None:
"""历史卡「原视频」用的原片快照。快照 URL 会过期,统一走 rehydrate_ref_urls 重新取长期直链。"""
from .free_video import rehydrate_ref_urls
@@ -1028,10 +1044,7 @@ def _legacy_start_pending_replace_shots(task):
generate_audio = bool(payload.get("generate_audio", True))
search_mode = str(payload.get("search_mode") or "off")
feature = str(payload.get("feature") or FEATURE)
try:
seed = int(payload.get("seed") if payload.get("seed") is not None else -1)
except (TypeError, ValueError):
seed = -1
seed = normalize_video_seed(payload.get("seed"))
billed_duration = sum(int(item.get("seconds") or 0) for item in plan) or int(payload.get("duration") or 5)
references = _seedance_references(payload)
try:
@@ -1238,10 +1251,7 @@ def _dispatch_next_replace_shot(task, shot: dict):
locked.request_payload = next_payload
locked.save(update_fields=["request_payload", "updated_at"])
try:
seed = int(payload.get("seed") if payload.get("seed") is not None else -1)
except (TypeError, ValueError):
seed = -1
seed = normalize_video_seed(payload.get("seed"))
dispatched = _dispatch_free_video_provider(
task=locked,
built=built,
@@ -1403,7 +1413,9 @@ def _concat_shot_media(urls: list[str]) -> bytes:
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "veryfast", "-threads", "2",
"-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", str(output),
]
proc = subprocess.run(cmd, capture_output=True, timeout=180)
# 180 秒成片最多 6 段,1080p 重编码明显可能超过旧的 180 秒固定超时。
# 按片段数放宽,但仍保留上限式超时,避免异常 ffmpeg 永久占住 worker。
proc = subprocess.run(cmd, capture_output=True, timeout=max(180, len(paths) * 120))
if proc.returncode != 0 or not output.exists() or not output.stat().st_size:
err = (proc.stderr or b"").decode("utf-8", errors="replace")[-500:]
raise RuntimeError(f"镜头拼接失败:{err or 'ffmpeg 未产出文件'}")
@@ -1605,10 +1617,7 @@ def _create_reviewing_task(*, team, user, params: dict, digest_pending: bool = F
if available < reserve_amount:
raise ValueError("团队余额不足,请充值后重试")
try:
seed = int(params.get("seed") if params.get("seed") is not None else -1)
except (TypeError, ValueError):
seed = -1
seed = normalize_video_seed(params.get("seed"))
request_payload = {
"feature": FEATURE,
"mode": "universal",
+538 -46
View File
@@ -39,12 +39,15 @@ from .creation_agent import (
_ASSET_CARD_LABELS,
_RESTART_CONTINUATION,
apply_cast_relation_choice,
apply_click_swap_mode,
append_person_source_gate,
apply_pain_point_direction,
apply_confirm_params,
apply_restart_intent,
apply_session_params,
emit_prompt_gate,
emit_final_confirm_gate,
locked_product_references,
set_plot_twist_story_depth,
is_greeting,
is_pain_point_conversation,
@@ -55,6 +58,8 @@ from .creation_agent import (
submit_confirmed_image,
submit_confirmed_video,
submit_generated_person_reference,
is_incomplete_product_brand_answer,
PRODUCT_BRAND_EMPTY_TEMPLATE,
)
from .tasks import run_creation_agent_turn_task
from .mentions import TYPE_LABELS, VALID_TYPES, refs_from_elicit_answers, search_mentions
@@ -255,23 +260,43 @@ def _plot_twist_direction_continuation(
)
if isinstance(direction, dict):
title = str(direction.get("title") or choice).strip()
conflict = str(direction.get("conflict") or "").strip()
product_role = str(direction.get("product_role") or "").strip()
reversal = str(direction.get("reversal") or "").strip()
tone = str(direction.get("tone") or "").strip()
detail = ";".join(
str(direction.get(key) or "").strip()
for key in ("conflict", "product_role", "reversal", "tone")
if str(direction.get(key) or "").strip()
part for part in (conflict, product_role, reversal, tone) if part
)
payload_dir = {
"id": str(direction.get("id") or "").strip(),
"title": title,
"conflict": conflict,
"product_role": product_role,
"reversal": reversal,
"tone": tone,
}
else:
title = choice.strip() or "用户自定义方向"
conflict = product_role = reversal = tone = ""
detail = title
payload_dir = {
"id": "",
"title": title,
"conflict": "",
"product_role": "",
"reversal": "",
"tone": "",
}
memory = dict(conversation.memory or {})
memory["plot_twist_story_direction"] = title
memory["plot_twist_story_direction_detail"] = detail
memory["plot_twist_story_direction_payload"] = payload_dir
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
return (
f"用户已选择剧情方向【{title}】。方向细节:{detail}。"
"现在只调用 write_strategy 写创作策略卡,必须沿用该冲突、商品作用与反转;"
"不要再展示方向卡、不要复述选择、不要直接写方案或出片。"
"现在只调用 write_strategy 写创作策略卡,策略的创作方向与后续方案/分镜必须严格沿用该冲突、商品作用与反转,"
"不得改写成另一套常见带货故事;不要再展示方向卡、不要复述选择、不要直接写方案或出片。"
)
@@ -289,6 +314,7 @@ def _store_click_swap_sequence(conversation: CreationConversation, value: str) -
)
def _mark_product_source_resolved(conversation: CreationConversation) -> None:
"""用户选择跳过、自动推荐或直接描述商品后,不重复弹同一个商品闸门。"""
memory = dict(conversation.memory or {})
@@ -297,13 +323,92 @@ def _mark_product_source_resolved(conversation: CreationConversation) -> None:
conversation.save(update_fields=["memory", "updated_at"])
def _is_product_like_ref(ref: dict) -> bool:
"""商品库商品,或本地上传的商品图(排除人物角色/模特)。"""
if not isinstance(ref, dict):
return False
type_ = str(ref.get("type") or "").strip()
ref_id = str(ref.get("id") or "").strip()
if not ref_id:
return False
if type_ == "product":
return True
if type_ != "asset":
return False
category = str(ref.get("category") or "").strip().lower()
return category not in {"character", "person", "model"}
def _dedupe_refs(refs: list[dict]) -> list[dict]:
out: list[dict] = []
seen: set[tuple[str, str]] = set()
for ref in refs or []:
if not _is_product_like_ref(ref):
continue
mark = (str(ref.get("type")), str(ref.get("id")))
if mark in seen:
continue
seen.add(mark)
out.append(ref)
return out
def _session_product_refs(conversation: CreationConversation, request_refs=None) -> list[dict]:
"""优先收集本会话已上传/已锁定的商品图,避免「你来推荐」盲取商品库最新一条。"""
buckets: list[list] = [list(request_refs or []), list(locked_product_references(conversation))]
recent = (
conversation.messages.filter(role=CreationMessage.Role.USER)
.order_by("-seq")[:12]
)
for message in recent:
buckets.append(list(message.refs or []))
merged: list[dict] = []
for bucket in buckets:
merged.extend(bucket)
return _dedupe_refs(merged)
def _ref_display_name(ref: dict) -> str:
name = str(ref.get("name") or "").split(" · ")[0].strip()
if name and not name.lower().endswith((".png", ".jpg", ".jpeg", ".webp", ".gif")):
return name
return "已上传商品图"
def _auto_pick_product_continuation(conversation: CreationConversation, request_refs=None):
"""「你来推荐」:有本地上传/已锁定商品图时用它们;否则才回落到商品库检索。"""
preferred = _session_product_refs(conversation, request_refs)
if preferred:
pin_refs(conversation, preferred)
names = "、".join(_ref_display_name(item) for item in preferred[:6])
return preferred, (
f"用户已提供商品参考图({names})。必须基于这些上传图推进创作,"
"禁止改用商品库里的其他商品(例如库里最新一条)。"
"若品牌或具体品名仍不明确,先确认品牌与品名,再继续方案。"
)
hits = search_mentions(conversation.team, q="", types=["product"], limit=1)
if hits:
pin_refs(conversation, hits)
return hits, (
f"用户希望你帮忙挑选素材,会话里尚无本地上传商品图,已从商品库选定【{hits[0]['name']}】。"
"直接基于该素材推进创作方案,不要复述选项,不要重复追问。"
)
return [], (
"用户希望你帮忙定素材,目前会话无本地上传商品图,商品库也暂无可用商品。"
"请结合所选预设与合理电商默认设想一款匹配的商品继续推进创作方案,不要重复追问。"
)
_STEP_CONTINUE_INSTRUCTIONS = {
"strategy": (
"用户已确认创作策略。现在只调用 write_plan 写方案卡(含完整 video_prompt 存档);"
"用户已确认创作策略。现在只调用 write_plan 写方案卡并存档 video_prompt;"
"超过 60 秒时只写紧凑章节稿(章节结构 + 每个 30 秒段关键镜头 + 交接状态),不要写制作级长文。"
"不要再写策略,不要调用 write_prompt,不要出片。"
),
"plan": (
"用户已确认视频方案。补齐完整 video_prompt 后直接调用 write_prompt 整理后台出片指令并给出积分确认卡;"
"用户已确认视频方案。调用 write_prompt 整理后台出片指令并给出积分确认卡;"
"超过 60 秒时长优先沿用方案里已存的 video_prompt,只补简短全片规则,不要重写成短片密度的制作长文。"
"不要重写策略/方案,也不要直接出片。"
),
"prompt": (
@@ -318,12 +423,12 @@ _STEP_REVISE_INSTRUCTIONS = {
"写完即停,不要同轮 write_plan / write_prompt。"
),
"plan": (
"用户要求修改视频方案。根据反馈只重新调用 write_plan 写一版修订方案"
"(含完整 video_prompt 存档);写完即停,不要同轮 write_prompt 或出片。"
"用户要求修改视频方案。根据反馈只重新调用 write_plan 写一版修订方案并更新 video_prompt;"
"超过 60 秒时仍写紧凑章节稿,不要写制作级长文。写完即停,不要同轮 write_prompt 或出片。"
),
"prompt": (
"用户要求修改出片细节。根据反馈只重新调用 write_prompt 整理一版修订指令;"
"完成后给出积分确认卡,不要直接出片。"
"长视频不要把逐镜再扩写成短片密度。完成后给出积分确认卡,不要直接出片。"
),
}
@@ -1046,9 +1151,15 @@ def _free_video_task_queryset(team):
)
def _not_omni_create_q() -> Q:
"""「不是全能创作」。同样不能写成 exclude(request_payload__feature="omni_create")——
JSON 键缺失时比较结果是 NULL,exclude 会把没写 feature 的历史任务一起筛没。"""
return Q(request_payload__feature__isnull=True) | ~Q(request_payload__feature="omni_create")
def _free_video_list_queryset(team, *, include_replace=False):
"""正常任务流隐藏已从资产库删除的成品,但保留生成中/失败及无落库资产的历史任务。"""
from .video_replace import video_replace_q
from .video_replace import not_video_replace_q, video_replace_q
video_assets = Asset.objects.filter(origin_task_id=OuterRef("pk"), asset_type=Asset.Type.VIDEO)
active_video_assets = video_assets.filter(is_deleted=False, purged_at__isnull=True)
@@ -1067,13 +1178,13 @@ def _free_video_list_queryset(team, *, include_replace=False):
if include_replace:
return qs.filter(video_replace_q())
# 全能创作也走 FREE_VIDEO 任务类型,但不能出现在自由生成任务流里。
return qs.exclude(video_replace_q()).exclude(request_payload__feature="omni_create")
return qs.filter(not_video_replace_q()).filter(_not_omni_create_q())
def _free_video_trash_queryset(team):
return (
AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, is_deleted=True, purged_at__isnull=True)
.exclude(request_payload__feature="omni_create")
.filter(_not_omni_create_q())
.select_related("model_config")
.prefetch_related("generated_assets", "generated_assets__files")
)
@@ -1774,7 +1885,7 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
@action(detail=True, methods=["post"], url_path="merge-video-segments")
def merge_video_segments(self, request, pk=None):
"""用户确认后才合并多段成片;未点击前不下载视频、更不调用 ffmpeg。"""
"""合并多段成片(兼容旧前端手动触发;正常流程在分段全成功后由平台自动合并)。"""
conversation = self.get_object()
message_id = str(request.data.get("message_id") or "").strip()
message = conversation.messages.filter(id=message_id).first() if message_id else None
@@ -1921,6 +2032,24 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
continuation_instruction = _plot_twist_direction_continuation(
conversation, payload, choice
)
elif payload.get("interaction") == "click_swap_mode_gate":
raw = text.strip()
mode = ""
if re.search(r"只出手|手指|不用角色|不要角色|无角色", raw):
mode = "finger"
elif re.search(r"角色|日常|拟人|出镜人物|达人", raw):
mode = "character"
instruction = apply_click_swap_mode(conversation, mode) if mode else ""
if instruction:
payload["answers"] = {"click_swap_mode": mode}
payload["submitted"] = True
payload["answered_via"] = "chat"
pending.payload = payload
pending.save(update_fields=["payload", "updated_at"])
text = ""
record_user_message = False
force_creative_turn = True
continuation_instruction = instruction
elif payload.get("interaction") == "click_swap_sku_gate":
sequence = text.strip()
if sequence:
@@ -1977,32 +2106,83 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
],
}, status=200)
# 2. 用户说「你帮我选/你定/随便」
elif re.search(r"(你来定|你定|你帮我定|你帮我选|帮我挑|没想好|随便|都行|你挑)", text):
hits = search_mentions(conversation.team, q="", types=asset_types, limit=1)
chosen_name = hits[0]["name"] if hits else "推荐商品"
if hits:
mark = (hits[0].get("type"), str(hits[0].get("id")))
existing = {(item.get("type"), str(item.get("id"))) for item in refs if isinstance(item, dict)}
if mark not in existing:
refs.append(hits[0])
payload["answers"] = {"_asset_gate": "auto", str(primary_field.get("key") or "product"): chosen_name}
payload["submitted"] = True
payload["answered_via"] = "chat"
pending.payload = payload
pending.save(update_fields=["payload", "updated_at"])
# 2. 用户说「你帮我选/你定/随便/你来推荐」
elif re.search(r"(你来定|你定|你帮我定|你帮我选|帮我挑|没想好|随便|都行|你挑|你来推荐|推荐一款|推荐一个)", text):
if is_product_gate:
preferred, continuation_instruction = _auto_pick_product_continuation(
conversation, refs
)
chosen_name = (
_ref_display_name(preferred[0]) if preferred else "推荐商品"
)
for item in preferred:
mark = (item.get("type"), str(item.get("id")))
existing = {
(r.get("type"), str(r.get("id")))
for r in refs if isinstance(r, dict)
}
if mark not in existing:
refs.append(item)
payload["answers"] = {
"_asset_gate": "auto",
str(primary_field.get("key") or "product"): chosen_name,
}
payload["submitted"] = True
payload["answered_via"] = "chat"
pending.payload = payload
pending.save(update_fields=["payload", "updated_at"])
_mark_product_source_resolved(conversation)
force_creative_turn = True
if hits:
continuation_instruction = (
f"用户让你帮忙挑选,系统已为你选定【{hits[0]['name']}】。直接基于该素材推进创作方案,不要复述选项,不要重复追问。"
)
force_creative_turn = True
else:
continuation_instruction = (
"用户让你帮忙定素材,目前素材库暂无已上传素材。请结合所选预设与合理电商默认设想一款匹配的商品继续推进创作方案,不要重复追问。"
)
hits = search_mentions(conversation.team, q="", types=asset_types, limit=1)
chosen_name = hits[0]["name"] if hits else "推荐素材"
if hits:
mark = (hits[0].get("type"), str(hits[0].get("id")))
existing = {(item.get("type"), str(item.get("id"))) for item in refs if isinstance(item, dict)}
if mark not in existing:
refs.append(hits[0])
pin_refs(conversation, [hits[0]])
payload["answers"] = {"_asset_gate": "auto", str(primary_field.get("key") or "product"): chosen_name}
payload["submitted"] = True
payload["answered_via"] = "chat"
pending.payload = payload
pending.save(update_fields=["payload", "updated_at"])
is_character_gate = any(t in ("character", "model") for t in asset_types)
if is_character_gate and hits:
memory = dict(conversation.memory or {})
memory["person_source"] = "auto"
memory["person_source_ready"] = True
memory["person_source_pending"] = False
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
if hits:
force_creative_turn = True
continuation_instruction = (
f"用户让你帮忙挑选,系统已为你选定【{hits[0]['name']}】。"
"直接基于该素材推进创作方案,不要复述选项,不要再次 ask_user 追问同一类素材。"
)
elif is_character_gate:
memory = dict(conversation.memory or {})
memory.pop("person_source_ready", None)
memory.pop("person_source_pending", None)
memory["person_source"] = ""
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
gate = append_person_source_gate(conversation)
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
return JsonResponse({
"conversation_id": str(conversation.id),
"agent_status": conversation.agent_status,
"messages": [CreationMessageSerializer(gate).data],
}, status=200)
else:
force_creative_turn = True
continuation_instruction = (
"用户让你帮忙定素材,目前素材库暂无现成项。"
"按用户刚说的外观要求继续创作;禁止再次弹出同类「发列表 / 你来推荐」追问。"
)
# 3. 用户说「先不用/不选了/跳过」
elif re.search(r"(先不用|不用|不选|先不选|跳过|暂不|没有商品|不需要)", text):
@@ -2059,6 +2239,20 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
fields = [item for item in (payload.get("fields") or []) if isinstance(item, dict)]
if fields:
field = fields[0]
if payload.get("topic") == "product_info":
clean_probe = text.strip()
design_from_image = any(
kw in clean_probe for kw in ("按图片", "设计品牌", "不用再问")
)
if (not design_from_image) and is_incomplete_product_brand_answer(clean_probe):
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
return JsonResponse({
"conversation_id": str(conversation.id),
"agent_status": conversation.agent_status,
"detail": "请填写品牌和品名后再发送,例如「品牌浅蓝小熊,品名婴儿柔湿巾」。",
"messages": [CreationMessageSerializer(pending).data],
}, status=200)
answers = {
str(field.get("key") or "answer"):
_chat_answer_for_field(field, text, conversation.team)
@@ -2089,6 +2283,45 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
continuation_instruction = apply_pain_point_direction(
conversation, payload, choice
)
elif payload.get("topic") == "product_info":
clean_ans = text.strip()
memory = dict(conversation.memory or {})
design_from_image = any(kw in clean_ans for kw in ("按图片", "设计品牌", "不用再问"))
if design_from_image:
memory["product_info_resolved"] = True
memory["product_info_from_image"] = True
continuation_instruction = (
"用户要求直接根据图片内容设计品牌与品名推进创作。"
"直接基于图片外观特征推进方案,不要重复追问品牌品名,也不要再弹出品牌确认卡。"
)
elif is_incomplete_product_brand_answer(clean_ans):
memory.pop("product_info_resolved", None)
continuation_instruction = (
"用户尚未填写完整品牌与品名。请继续请他补充,不要进入下一步。"
)
else:
memory["product_info_resolved"] = True
memory["product_brand_and_name"] = clean_ans
bm = re.search(r"品牌[::是为]?\s*([^\n,,。!!]+)", clean_ans)
nm = re.search(r"(?:品名|商品名|产品名)[::是为]?\s*([^\n,,。!!]+)", clean_ans)
if bm:
memory["product_brand"] = bm.group(1).strip()
if nm:
memory["product_name"] = nm.group(1).strip()
for r in (conversation.pinned_refs or []):
if isinstance(r, dict) and r.get("type") in ("asset", "product"):
r["name"] = clean_ans
break
conversation.pinned_refs = conversation.pinned_refs
continuation_instruction = (
f"用户已提供商品品牌与品名:【{clean_ans}】。"
"直接围绕该商品推进方案,不要重复追问。"
)
conversation.memory = memory
conversation.save(update_fields=["memory", "pinned_refs", "updated_at"])
# 保留用户原文气泡,便于回看刚填的品牌/品名;不要清空 text,
# 否则会落到「消息不能为空」且对话里看不到这次回答。
force_creative_turn = True
else:
continuation_instruction = (
"用户刚用输入框回答了你上一句问题。直接基于这条真实回答继续原任务;"
@@ -2097,6 +2330,98 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
if params_changed:
continuation_instruction += " 会话参数已更新,旧方案作废,按新参数重新产出。"
# 用户对刚生成的角色给出确认/重刷/外观修改反馈
clean_text = text.strip()
memory_now = dict(conversation.memory or {})
person_confirm_pending = bool(memory_now.get("person_confirm_pending"))
is_platform_person = memory_now.get("person_source") == "platform_generate"
confirmed_person = (
bool(re.search(
r"^(使用这[个位只]角色|就用这[个位只]角色|就用[她他它]|满意|确认使用|合适|可以)[继续创作。!! ]*$",
clean_text,
))
or clean_text in (
"使用这个角色继续创作",
"使用这个宠物角色继续创作",
)
or (person_confirm_pending and clean_text in ("继续", "继续创作"))
)
regenerate_button = bool(re.search(
r"^(重新生成|再生成|重刷|换一个|换位|不满意).{0,6}(角色|人物|模特|宠物)?[。!! ]*$",
clean_text,
))
upload_person = bool(re.search(
r"(上传|发一张|发个).{0,8}(人物|角色|模特|宠物)|我上传",
clean_text,
))
if confirmed_person:
if person_confirm_pending:
memory_now["person_confirm_pending"] = False
conversation.memory = memory_now
conversation.save(update_fields=["memory", "updated_at"])
force_creative_turn = True
continuation_instruction = (
"用户已确认使用当前生成的角色出镜。"
"本轮必须实质推进创作:若还缺商品则 ask_user 选商品;"
"痛点解决演示若还缺痛点方向则 ask_user(pain_point_direction);"
"信息够了就立刻 write_strategy。"
"禁止只回一句「可以回复继续完善方案」之类的空引导,也不要再次追问角色来源。"
)
elif is_platform_person and (regenerate_button or (person_confirm_pending and clean_text and not upload_person)):
prev_prompt = str(memory_now.get("person_prompt") or "").strip()
if regenerate_button:
appearance_prompt = prev_prompt
elif prev_prompt:
appearance_prompt = f"{prev_prompt};按用户最新要求调整:{clean_text}"
else:
appearance_prompt = clean_text
# 卸掉上一批平台定妆锁定,避免新旧角色叠在 pinned_refs 里
old_model_ids = {
str(item).strip()
for item in (memory_now.get("person_cast_model_ids") or [])
if str(item).strip()
}
legacy_id = str(memory_now.get("person_model_id") or "").strip()
if legacy_id:
old_model_ids.add(legacy_id)
if old_model_ids:
conversation.pinned_refs = [
ref for ref in (conversation.pinned_refs or [])
if not (
isinstance(ref, dict)
and ref.get("type") in {"model", "character"}
and str(ref.get("id") or "") in old_model_ids
)
]
memory_now["person_confirm_pending"] = False
memory_now.pop("person_source_ready", None)
memory_now.pop("person_model_id", None)
memory_now.pop("person_cast_model_ids", None)
memory_now.pop("person_cast_pending", None)
memory_now.pop("person_cast_total", None)
conversation.memory = memory_now
conversation.save(update_fields=["memory", "pinned_refs", "updated_at"])
try:
user_msg = append_message(conversation, role="user", text=clean_text)
generating_messages = submit_generated_person_reference(
conversation=conversation,
user=request.user,
appearance_prompt=appearance_prompt,
)
return JsonResponse({
"conversation_id": str(conversation.id),
"agent_status": conversation.agent_status,
"messages": [
CreationMessageSerializer(user_msg).data,
*[
CreationMessageSerializer(item).data
for item in generating_messages
],
],
}, status=202)
except Exception:
pass
if kind == "confirm":
reply_to = str(request.data.get("reply_to") or "").strip()
card = conversation.messages.filter(
@@ -2215,7 +2540,7 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
]
valid = any(
AssetModel.objects.filter(
team=conversation.team,
Q(team=conversation.team) | Q(is_official=True),
id=ref.get("id"),
is_deleted=False,
purged_at__isnull=True,
@@ -2224,6 +2549,10 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
)
if not valid:
return JsonResponse({"detail": "请先从模特库选择一位人物"}, status=400)
if payload.get("interaction") == "click_swap_mode_gate":
mode = str(answers.get("click_swap_mode") or "").strip()
if mode not in {"finger", "character"}:
return JsonResponse({"detail": "请选择只出手指出镜,或角色日常换款"}, status=400)
if payload.get("interaction") == "click_swap_sku_gate":
sequence = str(answers.get("sku_sequence") or "").strip()
if not sequence:
@@ -2299,6 +2628,15 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
else "商家选择系统推荐卖点。现在只调用 write_strategy 写创作策略;"
"从商品资料和现有素材中挑一个最容易被画面证明的真实核心卖点,不要虚构功效、价格或规格。"
)
elif payload.get("interaction") == "click_swap_mode_gate":
mode = str(answers.get("click_swap_mode") or "").strip()
instruction = apply_click_swap_mode(conversation, mode)
if not instruction:
return JsonResponse({"detail": "请选择只出手指出镜,或角色日常换款"}, status=400)
text = ""
record_user_message = False
force_creative_turn = True
continuation_instruction = instruction
elif payload.get("interaction") == "click_swap_sku_gate":
sequence = str(answers.get("sku_sequence") or "").strip()
text = ""
@@ -2322,10 +2660,12 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
elif payload.get("interaction") == "person_source_gate":
source = str(answers.get("person_source") or "").strip()
if source == "platform_generate":
person_prompt = str(answers.get("person_prompt") or "").strip()[:500]
try:
generating = submit_generated_person_reference(
generating_messages = submit_generated_person_reference(
conversation=conversation,
user=request.user,
appearance_prompt=person_prompt,
)
except ValueError as exc:
# 生图没提交成功,把闸门放回去供用户换方式或重试。
@@ -2337,7 +2677,10 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
return JsonResponse({
"conversation_id": str(conversation.id),
"agent_status": conversation.agent_status,
"messages": [CreationMessageSerializer(generating).data],
"messages": [
CreationMessageSerializer(item).data
for item in generating_messages
],
}, status=202)
# 上传/模特库的人物立即进入实体锁定,不等 Celery turn 开始才保存。
@@ -2357,10 +2700,62 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
text = ""
record_user_message = False
force_creative_turn = True
is_pet = "宠物" in str(conversation.preset or "")
continuation_instruction = (
"用户已选定出镜宠物角色,该宠物形象已作为整条视频的固定身份参考。"
"直接继续创作;所有镜头和分段保持同一宠物,不要再追问角色来源。"
if is_pet else
"用户已选定出镜人物,该人物已作为整条视频的固定身份参考。"
"直接继续创作;所有镜头和分段保持同一人,不要再追问人物来源。"
)
elif payload.get("topic") == "product_info":
ans = str(answers.get("product_brand_and_name") or "").strip()
memory = dict(conversation.memory or {})
design_from_image = any(kw in ans for kw in ("按图片", "设计品牌", "不用再问"))
clean_ans = ans.replace(PRODUCT_BRAND_EMPTY_TEMPLATE, "").strip()
if (not design_from_image) and is_incomplete_product_brand_answer(ans):
payload["submitted"] = False
payload["answers"] = {}
pending.payload = payload
pending.save(update_fields=["payload", "updated_at"])
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
return JsonResponse({
"conversation_id": str(conversation.id),
"agent_status": conversation.agent_status,
"detail": "请填写品牌和品名后再发送,例如「品牌浅蓝小熊,品名婴儿柔湿巾」。",
"messages": [CreationMessageSerializer(pending).data],
}, status=200)
if design_from_image:
memory["product_info_resolved"] = True
memory["product_info_from_image"] = True
continuation_instruction = (
"用户要求直接根据图片内容设计品牌与品名推进创作。"
"直接基于图片外观特征推进方案,不要重复追问品牌品名,也不要再弹出品牌确认卡。"
)
else:
memory["product_info_resolved"] = True
memory["product_brand_and_name"] = clean_ans or ans
bm = re.search(r"品牌[::是为]?\s*([^\n,,。!!]+)", clean_ans or ans)
nm = re.search(r"(?:品名|商品名|产品名)[::是为]?\s*([^\n,,。!!]+)", clean_ans or ans)
if bm:
memory["product_brand"] = bm.group(1).strip()
if nm:
memory["product_name"] = nm.group(1).strip()
for r in (conversation.pinned_refs or []):
if isinstance(r, dict) and r.get("type") in ("asset", "product"):
r["name"] = clean_ans or ans
break
conversation.pinned_refs = conversation.pinned_refs
continuation_instruction = (
f"用户已提供商品品牌与品名:【{clean_ans or ans}】。"
"直接围绕该商品推进方案,不要重复追问。"
)
conversation.memory = memory
conversation.save(update_fields=["memory", "pinned_refs", "updated_at"])
text = ""
record_user_message = False
force_creative_turn = True
elif payload.get("phase") == "gate":
choice = str(answers.get("_asset_gate") or "").strip()
pending = payload.get("pending_fields") or []
@@ -2395,16 +2790,88 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
"messages": [CreationMessageSerializer(pick).data],
}, status=200)
elif choice == "auto":
hits = search_mentions(conversation.team, q="", types=gate_types, limit=1)
if hits:
if is_product_gate:
preferred, continuation_instruction = _auto_pick_product_continuation(
conversation, refs
)
refs = list(refs)
refs.append(hits[0])
existing = {
(item.get("type"), str(item.get("id")))
for item in refs if isinstance(item, dict)
}
for item in preferred:
mark = (item.get("type"), str(item.get("id")))
if mark not in existing:
refs.append(item)
existing.add(mark)
_mark_product_source_resolved(conversation)
else:
hits = search_mentions(conversation.team, q="", types=gate_types, limit=1)
is_character_gate = any(t in ("character", "model") for t in gate_types)
if hits:
refs = list(refs)
refs.append(hits[0])
pin_refs(conversation, [hits[0]])
continuation_instruction = (
f"用户希望你帮忙挑选素材,已为你选定【{hits[0]['name']}】。"
"直接基于该素材推进创作方案,不要复述选项,不要再次 ask_user 追问同一类素材。"
)
else:
if is_character_gate:
# 库里没有角色却点了「你来推荐」:不能假完成,否则会跳到核心卖点。
# 改走人物来源闸门(上传 / 模特库 / 平台生成)。
memory = dict(conversation.memory or {})
memory.pop("person_source_ready", None)
memory.pop("person_source_pending", None)
memory["person_source"] = ""
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
gate = append_person_source_gate(conversation)
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
return JsonResponse({
"conversation_id": str(conversation.id),
"agent_status": conversation.agent_status,
"messages": [CreationMessageSerializer(gate).data],
}, status=200)
continuation_instruction = (
"用户希望你帮忙定素材,目前素材库暂无现成项。"
"按用户刚说的外观要求继续创作;不要再弹同一类「发列表 / 你来推荐」。"
)
if is_character_gate and hits:
memory = dict(conversation.memory or {})
memory["person_source"] = "auto"
memory["person_source_ready"] = True
memory["person_source_pending"] = False
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
text = ""
record_user_message = False
force_creative_turn = True
elif choice == "upload":
# 闸门「上传商品图」:用本轮 refs / 会话已上传图,禁止再去商品库抽奖。
preferred = _session_product_refs(conversation, refs)
if preferred:
pin_refs(conversation, preferred)
refs = list(refs)
existing = {
(item.get("type"), str(item.get("id")))
for item in refs if isinstance(item, dict)
}
for item in preferred:
mark = (item.get("type"), str(item.get("id")))
if mark not in existing:
refs.append(item)
existing.add(mark)
names = "、".join(_ref_display_name(item) for item in preferred[:6])
continuation_instruction = (
f"用户希望你帮忙挑选素材,已为你选定【{hits[0]['name']}】。直接基于该素材推进创作方案,不要复述选项,不要重复追问。"
f"用户刚上传了商品参考图({names})。必须基于这些上传图推进创作,"
"禁止改用商品库里的其他商品。若品牌或具体品名不明确,先确认品牌与品名。"
)
else:
continuation_instruction = (
"用户希望你帮忙定素材,目前素材库暂无已上传素材。请结合所选预设与合理电商默认设想一款匹配的商品继续推进创作方案,不要重复追问。"
"用户选择上传商品图,但本轮尚未收到可用图片。"
"请提醒用户再传一张清晰的商品实物图,不要改从商品库挑选。"
)
text = ""
record_user_message = False
@@ -2423,6 +2890,29 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
"用户刚选择暂不添加这项素材。接受这个选择,按会话里已有的需求和合理默认继续原任务。"
"先用一句自然的话承接,随后直接推进创作;不要再次追问同一素材,不要只说收到或有需要再说。"
)
elif payload.get("phase") == "pick" and (
answers.get("_action") == "cancel"
or any(str(v).lower() in ("cancel", "取消", "skip", "跳过") for v in answers.values())
):
pick_fields = payload.get("fields") or []
primary = pick_fields[0] if pick_fields else {}
types = primary.get("asset_types") or []
key = str(primary.get("key") or "").strip()
if "product" in types or key == "product":
_mark_product_source_resolved(conversation)
if any(t in types for t in ("character", "model")) or key in ("character", "model", "person"):
memory = dict(conversation.memory or {})
memory["person_source_ready"] = True
memory["person_source_pending"] = False
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
text = ""
record_user_message = False
force_creative_turn = True
continuation_instruction = (
"用户取消了从列表选择素材(素材库暂无合适素材或用户放弃选择)。"
"接受这个选择,根据会话里已有的需求和合理默认继续推进原任务,不要重复追问同一项素材。"
)
else:
params_changed = apply_session_params(conversation, payload.get("fields") or [], answers)
# 点选商品/角色必须钉成 Ref:模型常把选项做成单选文字,前端只回 answers。
@@ -2443,7 +2933,9 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
)
if params_changed:
continuation_instruction += " 会话参数已经更新,旧方案作废,按新参数重新产出方案。"
elif not text and not refs:
elif not text and not refs and not (force_creative_turn or continuation_instruction):
# 追问卡(品牌品名/点选闸门等)会把正文清空、改走 continuation;
# 这时没有 text/refs 也是合法推进,不能误报「消息不能为空」。
return JsonResponse({"detail": "消息不能为空"}, status=400)
# 同一账号同时只允许一个整理方案 turn(含本会话已在 planning 的二次发送)
+113
View File
@@ -0,0 +1,113 @@
"""角色库文件名 → 模特标签。
文件名形如「警察-男性-日本-年轻男性.png」「动画角色-齿轮蝠.png」。
文件夹名(医生/警察/动画角色…)作为职业/分类标签;性别归一成「男人」「女人」。
"""
from __future__ import annotations
import re
from pathlib import Path
GENDER_TOKENS = {
"男性": "男人",
"男童": "男人",
"年轻男性": "男人",
"中年男性": "男人",
"老年男性": "男人",
"青少年男性": "男人",
"女性": "女人",
"女童": "女人",
"少女": "女人",
"年轻女性": "女人",
"中年女性": "女人",
"老年女性": "女人",
"青少年女性": "女人",
"成年女性": "女人",
}
AGE_TOKENS = ("年轻", "中年", "老年", "青少年", "童", "成年")
# 文件名里常带的噪声后缀,不当标签
NOISE = {"面孔", "中国面孔", "欧美面孔", "亚洲面孔", "非洲面孔", "美国面孔", "印度面孔"}
def _split_tokens(stem: str) -> list[str]:
parts = re.split(r"[-_·//]+", stem)
return [p.strip() for p in parts if p and p.strip()]
def parse_character_tags(*, folder: str, filename: str) -> list[str]:
"""从文件夹 + 文件名解析去重后的标签列表(顺序:分类 → 性别 → 其余)。"""
stem = Path(filename).stem
tokens = _split_tokens(stem)
folder_tag = folder.strip()
tags: list[str] = []
seen: set[str] = set()
def add(tag: str) -> None:
tag = tag.strip()
if not tag or tag in seen or tag in NOISE:
return
seen.add(tag)
tags.append(tag)
if folder_tag:
add(folder_tag)
# 性别优先归一
for tok in tokens:
if tok in GENDER_TOKENS:
add(GENDER_TOKENS[tok])
break
for key, gender in GENDER_TOKENS.items():
if key in tok:
add(gender)
break
for tok in tokens:
if tok == folder_tag:
continue
if tok in GENDER_TOKENS:
continue
# 年龄词单独保留简写
age_hit = next((a for a in AGE_TOKENS if a in tok and tok not in GENDER_TOKENS), None)
if age_hit and tok in GENDER_TOKENS.values():
continue
if tok in NOISE:
continue
# 去掉已归一进性别的复合词本体
if any(g in tok for g in ("男性", "女性", "男童", "女童", "少女")):
for a in AGE_TOKENS:
if a in tok:
add(a)
break
continue
add(tok)
return tags
def display_name_from_file(*, folder: str, filename: str) -> str:
stem = Path(filename).stem
parts = _split_tokens(stem)
if not parts:
return folder or "角色"
# 动画角色:「动画角色-齿轮蝠」→ 齿轮蝠
if folder == "动画角色" and len(parts) >= 2:
return parts[-1][:64]
body = parts[1:] if parts[0] in {folder, "民族服饰"} and len(parts) > 1 else parts
# 去掉纯性别词,保留「中年女性」这类年龄+性别复合,以及地区
cleaned: list[str] = []
for tok in body:
if tok in {"男性", "女性"}:
continue
cleaned.append(tok.replace("面孔", ""))
label = "·".join(t for t in cleaned if t) or stem
if folder and folder not in {"动画角色"}:
return f"{folder}·{label}"[:64]
return label[:64]
def import_key(relative_path: str) -> str:
return f"character_pack:{relative_path.replace(chr(92), '/')}"
@@ -0,0 +1,157 @@
"""把本地「角色库」文件夹批量导入为官方模特(带标签)。
用法:
python manage.py import_character_pack "/Users/Admin/Downloads/角色库"
python manage.py import_character_pack "/path" --dry-run
python manage.py import_character_pack "/path" --team-owner admin
幂等:同一相对路径(metadata.import_key)不会重复导入。
"""
from __future__ import annotations
import mimetypes
import uuid
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from apps.accounts.models import Team, TeamMember, User
from apps.assets.character_tags import display_name_from_file, import_key, parse_character_tags
from apps.assets.models import Asset, AssetFile, Model
from apps.assets.storage import TosStorage
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
class Command(BaseCommand):
help = "Import a local character-pack folder into official Model library with tags"
def add_arguments(self, parser):
parser.add_argument("folder", type=str, help="角色库根目录")
parser.add_argument("--dry-run", action="store_true", help="只解析标签,不上传")
parser.add_argument("--team-owner", default="admin", help="托管官方模特的团队 owner 用户名")
parser.add_argument("--limit", type=int, default=0, help="最多导入 N 张(0=全部)")
def handle(self, *args, **options):
root = Path(options["folder"]).expanduser().resolve()
if not root.is_dir():
raise CommandError(f"目录不存在: {root}")
owner = User.objects.filter(username=options["team_owner"]).first()
if owner is None:
raise CommandError(f"找不到用户 {options['team_owner']}")
team = (
Team.objects.filter(owner=owner, is_personal=True).order_by("created_at").first()
or Team.objects.filter(owner=owner).order_by("created_at").first()
)
if team is None:
team = Team.objects.create(name=f"{owner.username}-official", owner=owner, is_personal=True)
TeamMember.objects.create(team=team, user=owner, role=TeamMember.Role.OWNER)
self.stdout.write(f"已创建托管团队 {team.name}")
files = sorted(
p for p in root.rglob("*")
if p.is_file() and p.suffix.lower() in IMAGE_SUFFIXES and p.name != ".DS_Store"
)
if options["limit"]:
files = files[: options["limit"]]
if not files:
raise CommandError("目录里没有图片")
storage = None if options["dry_run"] else TosStorage()
created = skipped = failed = 0
for path in files:
rel = str(path.relative_to(root)).replace("\\", "/")
folder = path.parent.name if path.parent != root else ""
tags = parse_character_tags(folder=folder, filename=path.name)
name = display_name_from_file(folder=folder, filename=path.name)
key = import_key(rel)
existing = Model.objects.filter(
is_deleted=False,
metadata__import_key=key,
).first()
if existing:
skipped += 1
self.stdout.write(f"skip {rel} tags={tags}")
continue
self.stdout.write(f"{'dry' if options['dry_run'] else 'add '} {rel} → {name} tags={tags}")
if options["dry_run"]:
created += 1
continue
try:
self._create_one(
storage=storage,
team=team,
owner=owner,
path=path,
name=name,
tags=tags,
key=key,
folder=folder,
rel=rel,
)
created += 1
except Exception as exc: # noqa: BLE001
failed += 1
self.stderr.write(self.style.ERROR(f"fail {rel}: {exc}"))
self.stdout.write(self.style.SUCCESS(
f"done created={created} skipped={skipped} failed={failed} team={team.id}"
))
def _create_one(self, *, storage, team, owner, path: Path, name: str, tags: list[str], key: str, folder: str, rel: str):
asset_id = uuid.uuid4()
suffix = path.suffix.lower() or ".png"
object_key = f"teams/{team.id}/models/official/{asset_id}{suffix}"
content_type = mimetypes.guess_type(path.name)[0] or "image/png"
with path.open("rb") as fh:
stored = storage.upload_fileobj(
fileobj=fh,
object_key=object_key,
content_type=content_type,
)
with transaction.atomic():
portrait = Asset.objects.create(
id=asset_id,
team=team,
created_by=owner,
name=f"{name}·形象图",
asset_type=Asset.Type.IMAGE,
source=Asset.Source.UPLOAD,
category=Asset.Category.MODEL_PORTRAIT,
metadata={
"kind": "model",
"view": "frontal",
"source": "character_pack",
"tags": tags,
},
)
AssetFile.objects.create(
asset=portrait,
object_key=stored.object_key,
bucket=stored.bucket,
content_type=stored.content_type,
size_bytes=stored.size_bytes,
is_primary=True,
)
Model.objects.create(
team=team,
created_by=owner,
name=name,
is_official=True,
source=Model.Source.UPLOAD,
portrait_asset=portrait,
description=f"角色库 · {folder}" if folder else "角色库",
metadata={
"source": "character_pack",
"tags": tags,
"import_key": key,
"folder": folder,
"relative_path": rel,
},
)
@@ -0,0 +1,29 @@
# Generated by Django 5.1.15 on 2026-09-21 08:04
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0010_asset_model_purged_at'),
]
operations = [
migrations.CreateModel(
name='ModelLibraryTag',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=64, unique=True)),
('label', models.CharField(blank=True, max_length=64)),
('visible', models.BooleanField(default=True)),
('sort', models.IntegerField(default=0)),
],
options={
'ordering': ['sort', 'name'],
},
),
]
@@ -0,0 +1,144 @@
"""模特库标签目录:从 Model.metadata.tags 聚合 + 与 ModelLibraryTag 同步。"""
from __future__ import annotations
from collections import Counter
from django.db.models import Q
from .models import Model, ModelLibraryTag
# 前台默认优先露出的粗粒度标签(即使 count 偏低也显示)
PREFERRED = (
"男人", "女人", "动画角色", "学生", "医生", "警察", "法官", "厨师",
"民族", "民族服饰", "中国",
)
# 首次入库:出现次数低于此值且不在 PREFERRED → 默认隐藏
DEFAULT_VISIBLE_MIN_COUNT = 3
def aggregate_tag_counts(*, official_only: bool = False, mine_team_id=None) -> dict[str, int]:
qs = Model.objects.filter(is_deleted=False, purged_at__isnull=True)
if official_only:
qs = qs.filter(is_official=True)
elif mine_team_id is not None:
qs = qs.filter(is_official=False, team_id=mine_team_id)
counts: Counter[str] = Counter()
for md in qs.values_list("metadata", flat=True):
tags = (md or {}).get("tags") if isinstance(md, dict) else None
if not isinstance(tags, list):
continue
for tag in tags:
if isinstance(tag, str) and tag.strip():
counts[tag.strip()] += 1
return dict(counts)
def sync_tag_catalog(*, create_missing: bool = True) -> list[ModelLibraryTag]:
"""把库里出现过的标签写入目录;已有行不改 visible/sort/label。"""
counts = aggregate_tag_counts()
existing = {t.name: t for t in ModelLibraryTag.objects.all()}
created: list[ModelLibraryTag] = []
if create_missing:
for name, count in counts.items():
if name in existing:
continue
visible = name in PREFERRED or count >= DEFAULT_VISIBLE_MIN_COUNT
sort = PREFERRED.index(name) if name in PREFERRED else 100 + (0 if count >= 10 else 50)
obj = ModelLibraryTag.objects.create(name=name, visible=visible, sort=sort)
existing[name] = obj
created.append(obj)
return created
def public_tag_rows(*, tab: str | None = None, team_id=None) -> list[dict]:
"""前台筛选条:只返回 visible=True 的标签 + 当前范围计数。"""
sync_tag_catalog(create_missing=True)
if tab == "official":
counts = aggregate_tag_counts(official_only=True)
elif tab == "mine":
counts = aggregate_tag_counts(mine_team_id=team_id)
else:
counts = aggregate_tag_counts()
rows = []
for tag in ModelLibraryTag.objects.filter(visible=True).order_by("sort", "name"):
c = counts.get(tag.name, 0)
if c <= 0:
continue
rows.append({"name": tag.name, "label": tag.display_name, "count": c})
return rows
def rename_tag_everywhere(old: str, new: str) -> int:
old, new = old.strip(), new.strip()
if not old or not new or old == new:
return 0
updated = 0
qs = Model.objects.filter(metadata__tags__contains=[old])
for m in qs.iterator():
md = dict(m.metadata or {})
tags = md.get("tags")
if not isinstance(tags, list):
continue
nxt: list[str] = []
seen: set[str] = set()
for t in tags:
if not isinstance(t, str):
continue
v = new if t.strip() == old else t.strip()
if not v or v in seen:
continue
seen.add(v)
nxt.append(v)
if nxt != tags:
md["tags"] = nxt
m.metadata = md
m.save(update_fields=["metadata", "updated_at"])
updated += 1
# catalog row
src = ModelLibraryTag.objects.filter(name=old).first()
dst = ModelLibraryTag.objects.filter(name=new).first()
if src and dst:
src.delete()
elif src:
src.name = new
src.save(update_fields=["name", "updated_at"])
return updated
def replace_tag_on_models(old: str, new: str | None) -> int:
"""把 old 换成 new;new=None 表示从模特上移除该标签。"""
if new is None:
new_s = None
else:
new_s = new.strip()
if not new_s:
return 0
old = old.strip()
updated = 0
qs = Model.objects.filter(metadata__tags__contains=[old])
for m in qs.iterator():
md = dict(m.metadata or {})
tags = md.get("tags")
if not isinstance(tags, list):
continue
nxt: list[str] = []
seen: set[str] = set()
for t in tags:
if not isinstance(t, str):
continue
v = t.strip()
if v == old:
if new_s is None:
continue
v = new_s
if not v or v in seen:
continue
seen.add(v)
nxt.append(v)
if nxt != tags:
md["tags"] = nxt
m.metadata = md
m.save(update_fields=["metadata", "updated_at"])
updated += 1
return updated
+23
View File
@@ -118,6 +118,29 @@ class Model(TeamOwnedModel):
return self.name
class ModelLibraryTag(TimeStampedModel):
"""模特库筛选标签目录 · 控制前台芯片显隐 / 排序 / 展示名。
实际标签仍写在 Model.metadata.tags;本表只管「筛选条怎么展示」。
未入表的标签在 sync 时按出现次数决定默认显隐。
"""
name = models.CharField(max_length=64, unique=True)
label = models.CharField(max_length=64, blank=True) # 空则用 name
visible = models.BooleanField(default=True)
sort = models.IntegerField(default=0)
class Meta:
ordering = ["sort", "name"]
def __str__(self) -> str:
return self.label or self.name
@property
def display_name(self) -> str:
return (self.label or self.name).strip() or self.name
class AssetReviewGroup(TimeStampedModel):
"""一团队一火山人像素材组(真人资产审核统一上传到这里,单组可放 500 万)。"""
+40 -9
View File
@@ -888,22 +888,52 @@ class ReviewScopeTests(TestCase):
("person", "model_portrait", "tri_view", "storyboard", "scene", "product_image"),
)
def test_poll_team_reviews_only_scans_review_categories(self):
def test_poll_only_covers_assets_that_really_reached_volcano(self):
"""轮询的判据是「拿到了火山远端 Id」,不是类别。
force 送审的上传视频、临时图也会是 processing,worker 必须盯到绿/红;
反过来,没有远端 Id 的 processing 是没真正送出去的,轮它只会白打请求。
送审范围仍然只挑 REVIEW_CATEGORIES(见下一条)。
"""
from apps.assets import review
_, team = _mk_team("uc", "TeamC")
keep = {}
polled = {}
for cat in ("person", "model_portrait", "tri_view", "storyboard", "scene", "product_image"):
a = Asset.objects.create(team=team, name=cat, asset_type="image", source="ai_generated",
category=cat, review_status="processing")
keep[cat] = str(a.id)
# 图片趴:不应进送审队列
for cat in ("model_tryon", "platform_kit", "free_create"):
Asset.objects.create(team=team, name=cat, asset_type="image", source="ai_generated",
category=cat, review_status="processing")
category=cat, review_status="processing",
review_remote_id=f"remote-{cat}")
polled[cat] = str(a.id)
# 图片趴不在送审范围里,但已经 force 送出去的同样要盯到终态。
forced = Asset.objects.create(
team=team, name="force", asset_type="image", source="ai_generated",
category="free_create", review_status="processing", review_remote_id="remote-force",
)
# 没有远端 Id = 没真送出去,不该被轮询。
Asset.objects.create(team=team, name="没送出去", asset_type="image", source="ai_generated",
category="person", review_status="processing")
with patch("apps.assets.review.assets_client.is_enabled", return_value=False):
out = review.poll_team_reviews(team)
self.assertEqual(set(out.keys()), set(keep.values()))
self.assertEqual(set(out.keys()), set(polled.values()) | {str(forced.id)})
def test_submission_still_only_covers_review_categories(self):
from apps.assets import review
_, team = _mk_team("uc-sub", "TeamCSub")
for cat in ("person", "product_image"):
Asset.objects.create(team=team, name=cat, asset_type="image", source="ai_generated",
category=cat, review_status="")
for cat in ("model_tryon", "platform_kit", "free_create"):
Asset.objects.create(team=team, name=cat, asset_type="image", source="ai_generated",
category=cat, review_status="")
with patch("apps.assets.review.submit_asset_for_review", return_value=True) as sub:
review.submit_unsubmitted_reviews(team=team)
submitted = {asset.category for (asset,), _ in (call for call in sub.call_args_list)}
self.assertEqual(submitted, {"person", "product_image"})
def test_poll_team_reviews_auto_submits_unsubmitted(self):
from apps.assets import review
@@ -931,6 +961,7 @@ class ReviewScopeTests(TestCase):
proc = Asset.objects.create(
team=team, name="审中", asset_type="image", source="ai_generated",
category=Asset.Category.PERSON, review_status="processing",
review_remote_id="remote-proc", # 只有真的送到火山、拿到 Id 的才会被轮询
)
with patch("apps.assets.review.submit_asset_for_review", return_value=True) as sub, patch(
"apps.assets.review.poll_asset_review", return_value="active"
+47 -4
View File
@@ -43,6 +43,15 @@ _KNOWN_CATS = [
]
def _not_omni_create_q() -> Q:
"""「不是全能创作产出」。不能只写 ~Q(metadata__feature="omni_create")。
metadata 里没有 feature 键时取值是 SQL NULL,NOT(NULL = 'x') 还是 NULL,整行会被筛掉 ——
历史资产(没写过 feature)会从「视频自由创作」「自由创作」「素材」这些分桶里整批消失。
"""
return Q(metadata__feature__isnull=True) | ~Q(metadata__feature="omni_create")
def _tab_q(tab: str) -> Q:
if tab == "people":
return Q(category="person")
@@ -57,13 +66,13 @@ def _tab_q(tab: str) -> Q:
if tab == "image_creations": # 图片自由创作
return Q(category="free_create", asset_type="image")
if tab == "video_creations": # 视频自由创作
return Q(category="free_create", asset_type="video") & ~Q(metadata__feature="omni_create")
return Q(category="free_create", asset_type="video") & _not_omni_create_q()
if tab == "creations": # 自由创作(兼容旧入口:图片+视频)
return Q(category="free_create") & ~Q(metadata__feature="omni_create")
return Q(category="free_create") & _not_omni_create_q()
if tab == "uploads":
return Q(category="upload")
if tab == "materials": # 素材(期3):视频素材 = 所有视频,排除「最终成片」(final_video 隐藏不列)
return ~Q(category="final_video") & Q(asset_type="video") & ~Q(metadata__feature="omni_create")
return ~Q(category="final_video") & Q(asset_type="video") & _not_omni_create_q()
if tab == "others": # 其他(资产库成品化):我的上传 + 未归类非视频(兜底)
return Q(category="upload") | (~Q(category__in=_KNOWN_CATS) & ~Q(asset_type="video"))
if tab == "unclassified": # 未归类且非视频(也不含最终成片)
@@ -482,6 +491,14 @@ class ModelLibraryViewSet(ModelViewSet):
if self.request.query_params.get("q"):
q = self.request.query_params["q"]
qs = qs.filter(Q(name__icontains=q) | Q(description__icontains=q))
# 标签筛选: ?tag=男人 或 ?tag=男人&tag=警察(多标签 AND)
raw_tags = self.request.query_params.getlist("tag") or []
if not raw_tags:
joined = (self.request.query_params.get("tags") or "").strip()
if joined:
raw_tags = [t.strip() for t in joined.split(",") if t.strip()]
for tag in raw_tags:
qs = qs.filter(metadata__tags__contains=[tag])
# 官方模板靠前,再按新→旧
return qs.order_by("-is_official", "-created_at")
@@ -605,6 +622,18 @@ class ModelLibraryViewSet(ModelViewSet):
model.save(update_fields=["purged_at", "updated_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=False, methods=["get"], url_path="tags")
def tags(self, request):
"""当前可见模特里出现过的标签(供前端筛选条)· 只返回后台标记为显示的。"""
from .model_library_tags import public_tag_rows
tab = (request.query_params.get("tab") or "").strip() or None
if tab not in (None, "official", "mine"):
tab = None
team = self.get_team()
items = public_tag_rows(tab=tab, team_id=getattr(team, "id", None))
return Response({"results": items})
@action(detail=False, methods=["post"], url_path="upload", parser_classes=[MultiPartParser, FormParser])
def upload(self, request):
"""真人上传:一张人像图 → 建 model_portrait 资产 + Model(source=upload)。三视图/声线后续补。"""
@@ -640,13 +669,27 @@ class ModelLibraryViewSet(ModelViewSet):
size_bytes=stored.size_bytes,
is_primary=True,
)
raw_tags = request.data.get("tags")
tags: list[str] = []
if isinstance(raw_tags, str) and raw_tags.strip():
import json
try:
parsed = json.loads(raw_tags)
if isinstance(parsed, list):
tags = [str(t).strip() for t in parsed if str(t).strip()]
else:
tags = [t.strip() for t in raw_tags.split(",") if t.strip()]
except Exception:
tags = [t.strip() for t in raw_tags.split(",") if t.strip()]
elif isinstance(raw_tags, list):
tags = [str(t).strip() for t in raw_tags if str(t).strip()]
model = Model.objects.create(
team=team,
created_by=request.user,
name=name,
source=Model.Source.UPLOAD,
portrait_asset=portrait,
metadata={"source": "upload"},
metadata={"source": "upload", "tags": tags},
)
return Response(ModelLibrarySerializer(model).data, status=status.HTTP_201_CREATED)
+2 -2
View File
@@ -113,7 +113,7 @@ def _enforce_quota_policy(*, team, project, amount: Decimal) -> None:
@transaction.atomic
def adjust_credit(*, team, amount: Decimal, reason: str = "", operator=None) -> CreditLedger:
def adjust_credit(*, team, amount: Decimal, reason: str = "", operator=None, metadata: dict | None = None) -> CreditLedger:
"""平台超管手动调额(争议 / 补偿)。amount 可正可负;落 ADJUSTMENT 流水并更新余额。
调额后余额不得为负(否则拒绝)。"""
account, _ = CreditAccount.objects.select_for_update().get_or_create(team=team)
@@ -129,7 +129,7 @@ def adjust_credit(*, team, amount: Decimal, reason: str = "", operator=None) ->
amount=amount,
balance_after=new_balance,
reason=reason or "平台手动调额",
metadata={"kind": "admin_adjust"},
metadata={"kind": "admin_adjust", **(metadata or {})},
)
+29 -22
View File
@@ -37,6 +37,22 @@ class QuickCreateApiTests(TestCase):
self.client = APIClient()
self.client.force_authenticate(self.user)
def _fake_video_model(self, name="doubao-seedance-2-0-fast-260128"):
"""提交时会先按 metadata["pricing"] 预估费用,缺价目一律 400 —— 假模型必须带价。"""
return SimpleNamespace(
id=uuid.uuid4(),
name=name,
display_name="Seedance 2.0 Fast",
metadata={
"capabilities": {
"resolutions": ["480p", "720p"],
"aspect_ratios": ["9:16"],
"durations": [15, 30],
},
"pricing": {"default": {"no_ref_video": 46, "with_ref_video": 28}},
},
)
def _uploaded_asset(self, **kwargs):
upload = kwargs["upload"]
return Asset.objects.create(
@@ -55,16 +71,9 @@ class QuickCreateApiTests(TestCase):
@patch("apps.projects.views._store_uploaded_asset")
def test_submit_creates_product_project_and_persistent_job(self, store_asset, require_worker_task, enqueue, get_model, get_quick_model):
store_asset.side_effect = self._uploaded_asset
video_model_id = uuid.uuid4()
get_model.side_effect = [
object(),
SimpleNamespace(
id=video_model_id,
name="doubao-seedance-2-0-fast-260128",
display_name="Seedance 2.0 Fast",
metadata={"capabilities": {"resolutions": ["480p", "720p"], "aspect_ratios": ["9:16"], "durations": [15]}},
),
]
video_model = self._fake_video_model()
video_model_id = video_model.id
get_model.side_effect = [object(), video_model]
response = self.client.post(
"/api/projects/quick-create/",
{
@@ -120,12 +129,7 @@ class QuickCreateApiTests(TestCase):
"""刷新页面时前端拉状态有空档,用户容易以为「没任务」再提交一次 ——
那会重复建商品、重复扣费。后端必须拦住,并把在跑的那条带回去。"""
store_asset.side_effect = self._uploaded_asset
video_model = SimpleNamespace(
id=uuid.uuid4(),
name="doubao-seedance-2-0-fast-260128",
display_name="Seedance 2.0 Fast",
metadata={"capabilities": {"resolutions": ["480p", "720p"], "aspect_ratios": ["9:16"], "durations": [15]}},
)
video_model = self._fake_video_model()
get_model.side_effect = [object(), video_model, object(), video_model]
def _post():
@@ -167,10 +171,7 @@ class QuickCreateApiTests(TestCase):
@patch("apps.projects.views._store_uploaded_asset")
def test_submit_requires_product_category(self, store_asset, require_worker_task, enqueue, get_model, get_quick_model):
store_asset.side_effect = self._uploaded_asset
get_model.side_effect = [
object(),
SimpleNamespace(id=uuid.uuid4(), name="seedance", display_name="Seedance", metadata={}),
]
get_model.side_effect = [object(), self._fake_video_model("seedance")]
response = self.client.post(
"/api/projects/quick-create/",
{
@@ -200,7 +201,7 @@ class QuickCreateApiTests(TestCase):
)
source = Product.objects.create(team=self.team, created_by=self.user, title="旧商品", cover_asset=source_asset)
ProductImage.objects.create(product=source, asset=source_asset, sort_order=0, is_primary=True)
get_model.side_effect = [object(), SimpleNamespace(id=uuid.uuid4(), name="seedance", display_name="Seedance", metadata={})]
get_model.side_effect = [object(), self._fake_video_model("seedance")]
response = self.client.post(
"/api/projects/quick-create/",
@@ -230,7 +231,7 @@ class QuickCreateApiTests(TestCase):
other = User.objects.create_user(username="quick-image-other", password="pass")
other_team = Team.objects.create(name="Image Other Team", owner=other)
foreign = Product.objects.create(team=other_team, created_by=other, title="别人的图")
get_model.side_effect = [object(), SimpleNamespace(metadata={})]
get_model.side_effect = [object(), self._fake_video_model("seedance")]
response = self.client.post(
"/api/projects/quick-create/",
{
@@ -1371,6 +1372,12 @@ class QuickCreateCoordinatorTests(TestCase):
@@ -36,8 +36,8 @@
- **语气**:穿搭分享、身材/场景导向,强调上身效果。
- **卖点侧重**:版型显瘦/显高、面料舒适、好搭配、场合适配、尺码建议。
- **常用露出**:上身使用中、面料细节特写、多场景/多角度展示。
- **违规雷区**:材质成分虚标;"显瘦 20 斤"等夸张数字;绝对化用语。
- **常用露出**:已穿着上身效果、面料细节特写、走动垂坠、多场景/多角度展示;换款用瞬间切换。
- **违规雷区**:材质成分虚标;"显瘦 20 斤"等夸张数字;绝对化用语;**不要写穿衣服、套袖、扣扣、拉拉链等穿戴过程镜头**。
- **示例**:「这个版型是真的藏肉,梨形身材穿上腿一下显直,通勤约会都能穿。」
## 家居日用
Binary file not shown.

Before

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 420 KiB

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 2.0 MiB

+12 -9
View File
@@ -358,13 +358,17 @@ export function App() {
}, [page, route.projectId, route.productId]);
// 平台后台 gating(身份就绪后):
// - 平台超管且无团队:任何非 admin 路由都送进 /admin(否则普通外壳因 !team 永久卡 loading)
// - 平台超管不进工作台:任何非 /admin 路由都送回后台
// - 非超管访问 /admin/*:纠回工作台
useLayoutEffect(() => {
if (booting || !user?.is_platform_admin || route.admin !== undefined) return;
navigateAdmin("", { replace: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [booting, user, route.admin]);
useEffect(() => {
if (booting || !user) return;
if (user.is_platform_admin && !team && route.admin === undefined) {
navigateAdmin("", { replace: true });
} else if (!user.is_platform_admin && route.admin !== undefined) {
if (!user.is_platform_admin && route.admin !== undefined) {
navigate("omniCreate", { replace: true });
}
// navigate/navigateAdmin 为组件内函数,故意不入依赖避免每次渲染重跑
@@ -386,6 +390,8 @@ export function App() {
useLayoutEffect(() => {
if (booting || !user || page !== "dashboard") return;
if (route.admin !== undefined) return;
// 超管由上面的后台 gating 接走,这里不能再改写成全能创作。
if (user.is_platform_admin) return;
navigate("omniCreate", { replace: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [booting, user, page, route.admin]);
@@ -888,8 +894,8 @@ export function App() {
setTeam(payload.team);
setRole(payload.role || "");
setBooting(false);
// 平台超管且无团队:直落后台,不拉团队级数据(否则 products/projects 等接口因无团队报错)
if (payload.user.is_platform_admin && !payload.team) {
// 平台超管只进后台,不拉工作台数据。
if (payload.user.is_platform_admin) {
setAuthed(true);
navigateAdmin("", { replace: true });
return;
@@ -934,7 +940,6 @@ export function App() {
<ConfirmModal
open={sessionInvalidated}
title="当前账号已在其他设备登录"
subtitle="// SESSION REPLACED"
icon={<MonitorOff size={16} />}
detail="为保护账号安全,当前设备的登录已失效。点击确认后返回登录页面。"
confirmText="确认并返回登录"
@@ -970,9 +975,7 @@ export function App() {
<AdminApp
section={route.admin}
user={user}
team={team}
navigateAdmin={navigateAdmin}
navigate={navigate}
logout={logout}
/>
{sessionInvalidatedModal}
-18
View File
@@ -90,24 +90,6 @@
border-top: 1px solid var(--st-line);
background: rgba(28, 34, 43, 0.02);
}
.admin-back {
display: inline-flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 10px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--st-muted);
font: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.admin-back:hover { color: var(--st-text); background: var(--st-hover); }
.admin-back svg { width: 16px; height: 16px; }
.admin-user {
display: flex;
align-items: center;
+25 -4
View File
@@ -323,9 +323,16 @@ export const api = {
revokeInvitation(id: string) {
return request<Invitation>(`/api/auth/team/invitations/${id}/revoke/`, { method: "POST" });
},
products(pageSize?: number) {
const query = pageSize ? `?page_size=${pageSize}` : "";
return request<Paginated<Product>>(`/api/products/${query}`);
products(pageSizeOrParams?: number | { page?: number; pageSize?: number; q?: string }) {
const params = typeof pageSizeOrParams === "number"
? { pageSize: pageSizeOrParams }
: (pageSizeOrParams || {});
const qs = new URLSearchParams();
if (params.page) qs.set("page", String(params.page));
if (params.pageSize) qs.set("page_size", String(params.pageSize));
if (params.q?.trim()) qs.set("search", params.q.trim());
const query = qs.toString();
return request<Paginated<Product>>(`/api/products/${query ? `?${query}` : ""}`);
},
product(id: string) {
return request<Product>(`/api/products/${id}/`);
@@ -875,14 +882,22 @@ export const api = {
return request<Asset>(`/api/assets/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
},
// 模特库:列出(本团队 ∪ 官方模板);tab=official 只看官方、mine 只看自建
listModels(params: { tab?: "official" | "mine"; q?: string; portraitAsset?: string; pageSize?: number } = {}) {
listModels(params: { tab?: "official" | "mine"; q?: string; tags?: string[]; portraitAsset?: string; page?: number; pageSize?: number } = {}) {
const qs = new URLSearchParams();
if (params.tab) qs.set("tab", params.tab);
if (params.q) qs.set("q", params.q);
if (params.portraitAsset) qs.set("portrait_asset", params.portraitAsset);
for (const tag of params.tags || []) qs.append("tag", tag);
if (params.page) qs.set("page", String(params.page));
qs.set("page_size", String(params.pageSize ?? 200));
return request<Paginated<ModelEntity>>(`/api/models/?${qs.toString()}`);
},
listModelTags(params: { tab?: "official" | "mine" } = {}) {
const qs = new URLSearchParams();
if (params.tab) qs.set("tab", params.tab);
const q = qs.toString();
return request<{ results: { name: string; label?: string; count: number }[] }>(`/api/models/tags/${q ? `?${q}` : ""}`);
},
// 真人上传:一张人像图 → 建 model_portrait 资产 + Model 实体
uploadModel(formData: FormData) {
return request<ModelEntity>("/api/models/upload/", { method: "POST", body: formData });
@@ -1216,6 +1231,12 @@ export const adminApi = {
const q = qs.toString();
return request<Paginated<AdminUser>>(`/api/admin/users/${q ? `?${q}` : ""}`);
},
createUser(payload: { username: string; display_name: string; password: string; initial_credits?: string }) {
return request<AdminUser>("/api/admin/users/", { method: "POST", body: JSON.stringify(payload) });
},
adjustUserCredit(id: string, payload: { amount: string; reason?: string }) {
return request<AdminUser>(`/api/admin/users/${id}/credits/`, { method: "POST", body: JSON.stringify(payload) });
},
toggleUser(id: string) {
return request<AdminUser>(`/api/admin/users/${id}/toggle/`, { method: "POST" });
},
@@ -372,3 +372,12 @@
display: none;
}
}
.omni-asset-picker-pager {
margin-top: 14px;
padding-bottom: 2px;
}
.omni-asset-picker-pager .list-pager {
justify-content: center;
flex-wrap: wrap;
}
@@ -11,6 +11,7 @@ import {
X,
} from "lucide-react";
import { api } from "../api";
import { Pager } from "./pager";
import type { CreationRef } from "../types";
import "./asset-select-modal.css";
@@ -24,11 +25,7 @@ interface AssetSelectModalProps {
onNotify?: (type: "success" | "error" | "info", text: string) => void;
}
const assetLibraryCache = new Map<string, CreationRef[]>();
function assetLibraryKey(type: AssetModalType, query: string) {
return `${type}:${query.trim().toLocaleLowerCase("zh-CN")}`;
}
const PAGE_SIZE = 20;
export function AssetSelectModal({
open,
@@ -40,7 +37,8 @@ export function AssetSelectModal({
const [searchQuery, setSearchQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const [items, setItems] = useState<CreationRef[]>([]);
const [loadedKey, setLoadedKey] = useState("");
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [selectedRef, setSelectedRef] = useState<CreationRef | null>(null);
const [sortOrder, setSortOrder] = useState<"recent" | "name">("recent");
@@ -57,34 +55,31 @@ export function AssetSelectModal({
const titleText = type === "product" ? "商品" : "角色";
const title = type === "product" ? "商品库" : "角色库";
const subtitle = type === "product" ? "[ PRODUCT · SELECT ]" : "[ CHARACTER · SELECT ]";
const requestKey = assetLibraryKey(type, debouncedQuery);
const cachedItems = assetLibraryCache.get(requestKey);
const currentItems = loadedKey === requestKey ? items : (cachedItems || []);
const hasCurrentData = loadedKey === requestKey || Boolean(cachedItems);
const visibleItems = useMemo(() => {
if (sortOrder === "recent") return currentItems;
return [...currentItems].sort((left, right) => left.name.localeCompare(right.name, "zh-CN"));
}, [currentItems, sortOrder]);
if (sortOrder === "recent") return items;
return [...items].sort((left, right) => left.name.localeCompare(right.name, "zh-CN"));
}, [items, sortOrder]);
useEffect(() => {
if (!open) {
setIsAddingNew(false);
setSearchQuery("");
setDebouncedQuery("");
setSelectedRef(null);
setSortOrder("recent");
setNewFile(null);
setNewPreview("");
setNewName("");
setPage(1);
setItems([]);
setTotal(0);
return;
}
}, [open]);
useEffect(() => {
if (!open) {
setDebouncedQuery("");
return;
}
if (!open) return;
const query = searchQuery.trim();
if (!query) {
setDebouncedQuery("");
@@ -94,45 +89,68 @@ export function AssetSelectModal({
return () => window.clearTimeout(timer);
}, [open, searchQuery]);
// 换库 / 搜索时回到第 1 页
useEffect(() => {
if (!open) return;
setPage(1);
setSelectedRef(null);
}, [type, debouncedQuery]);
const cached = assetLibraryCache.get(requestKey);
if (cached) {
setItems(cached);
setLoadedKey(requestKey);
setLoading(false);
return;
}
useEffect(() => {
if (!open || isAddingNew) return;
let cancelled = false;
setLoading(true);
setSelectedRef(null);
const types: CreationRef["type"][] = type === "product" ? ["product"] : ["model", "character"];
void api
.searchMentions({ q: debouncedQuery, types, limit: 20 })
.then((response) => {
if (cancelled) return;
const results = response.results || [];
assetLibraryCache.set(requestKey, results);
setItems(results);
setLoadedKey(requestKey);
})
.catch((error) => {
if (cancelled) return;
assetLibraryCache.set(requestKey, []);
setItems([]);
setLoadedKey(requestKey);
notifyRef.current?.("error", (error as Error).message || "加载失败");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
const load = async () => {
try {
if (type === "character") {
const response = await api.listModels({
q: debouncedQuery || undefined,
page,
pageSize: PAGE_SIZE,
});
if (cancelled) return;
const mapped: CreationRef[] = (response.results || [])
.filter((m) => m.portrait)
.map((m) => ({
type: "model",
id: m.id,
name: m.name,
cover: m.portrait,
}));
setItems(mapped);
setTotal(response.count ?? mapped.length);
} else {
const response = await api.products({
q: debouncedQuery || undefined,
page,
pageSize: PAGE_SIZE,
});
if (cancelled) return;
const mapped: CreationRef[] = (response.results || []).map((p) => ({
type: "product",
id: p.id,
name: p.title,
cover: p.cover_preview_url || "",
}));
setItems(mapped);
setTotal(response.count ?? mapped.length);
}
} catch (error) {
if (cancelled) return;
setItems([]);
setTotal(0);
notifyRef.current?.("error", (error as Error).message || "加载失败");
} finally {
if (!cancelled) setLoading(false);
}
};
void load();
return () => {
cancelled = true;
};
}, [debouncedQuery, open, requestKey, type]);
}, [debouncedQuery, open, page, type, isAddingNew]);
useEffect(() => {
if (!open) return;
@@ -199,9 +217,6 @@ export function AssetSelectModal({
}
notifyRef.current?.("success", `已新增${titleText}:${finalRef.name}`);
for (const key of assetLibraryCache.keys()) {
if (key.startsWith(`${type}:`)) assetLibraryCache.delete(key);
}
onSelect(finalRef);
onClose();
} catch (error) {
@@ -234,7 +249,6 @@ export function AssetSelectModal({
</span>
<div className="ti" id={titleId}>
{isAddingNew ? `新增${titleText}` : title}
<span>{isAddingNew ? "[ UPLOAD · CREATE ]" : subtitle}</span>
</div>
<div className="omni-asset-picker-head-actions">
{isAddingNew ? (
@@ -328,8 +342,8 @@ export function AssetSelectModal({
<option value="name">按名称</option>
</select>
</div>
<div className="modal-b omni-asset-picker-body" aria-busy={loading || !hasCurrentData}>
{!hasCurrentData ? (
<div className="modal-b omni-asset-picker-body" aria-busy={loading}>
{loading && items.length === 0 ? (
<div className="omni-asset-picker-grid is-loading" role="status" aria-label={`正在加载${titleText}库`}>
{Array.from({ length: 10 }, (_, index) => (
<div className="omni-asset-picker-skeleton" key={index} aria-hidden="true">
@@ -353,38 +367,42 @@ export function AssetSelectModal({
) : null}
</div>
) : (
<div className="omni-asset-picker-grid">
{visibleItems.map((item) => {
const isSelected = selectedRef?.type === item.type && selectedRef.id === item.id;
const displayName = item.name.split(" · ")[0];
return (
<button
type="button"
key={`${item.type}:${item.id}`}
className={`omni-asset-picker-card${isSelected ? " is-selected" : ""}`}
aria-pressed={isSelected}
onClick={() => setSelectedRef(item)}
onDoubleClick={() => {
onSelect(item);
onClose();
}}
>
<span className="omni-asset-picker-thumb">
{item.cover ? (
<img src={item.cover} alt="" />
) : (
<span className="omni-asset-picker-fallback">
{type === "product" ? <Box /> : <UserRound />}
</span>
)}
{isSelected ? <span className="omni-asset-picker-check"><Check /></span> : null}
</span>
<span className="omni-asset-picker-card-name" title={displayName}>{displayName}</span>
<span className="mono">// {item.type === "product" ? "商品" : item.type === "model" ? "角色库" : "角色素材"}</span>
</button>
);
})}
</div>
<>
<div className={`omni-asset-picker-grid${loading ? " is-loading" : ""}`}>
{visibleItems.map((item) => {
const isSelected = selectedRef?.type === item.type && selectedRef.id === item.id;
const displayName = item.name.split(" · ")[0];
return (
<button
type="button"
key={`${item.type}:${item.id}`}
className={`omni-asset-picker-card${isSelected ? " is-selected" : ""}`}
aria-pressed={isSelected}
onClick={() => setSelectedRef(item)}
onDoubleClick={() => {
onSelect(item);
onClose();
}}
>
<span className="omni-asset-picker-thumb">
{item.cover ? (
<img src={item.cover} alt="" />
) : (
<span className="omni-asset-picker-fallback">
{type === "product" ? <Box /> : <UserRound />}
</span>
)}
{isSelected ? <span className="omni-asset-picker-check"><Check /></span> : null}
</span>
<span className="omni-asset-picker-card-name" title={displayName}>{displayName}</span>
</button>
);
})}
</div>
<div className="omni-asset-picker-pager">
<Pager page={page} total={total} pageSize={PAGE_SIZE} onChange={setPage} alwaysShow />
</div>
</>
)}
</div>
<footer className="modal-f">
+1 -2
View File
@@ -28,8 +28,7 @@ export function SystemLoading({
<span className="system-loading-corner corner-br" aria-hidden="true">+</span>
<div className="system-loading-meta mono">
<span>[ AIRSHELF / SYSTEM ]</span>
<span>{state === "error" ? "[ CONNECTION ERROR ]" : "[ SYNCING ]"}</span>
<span>{state === "error" ? "连接异常" : "同步中"}</span>
</div>
<div className="system-loading-main">
+38 -6
View File
@@ -18,7 +18,7 @@ const modelToAsset = (m: ModelEntity): Asset => ({
source: m.source === "upload" ? "upload" : "ai_generated",
category: "model_portrait",
description: "",
metadata: { kind: "model", model_entity_id: m.id, is_official: m.is_official },
metadata: { kind: "model", model_entity_id: m.id, is_official: m.is_official, tags: Array.isArray(m.metadata?.tags) ? m.metadata.tags : [] },
files: m.portrait ? [{ id: m.portrait_asset as string, object_key: "", bucket: "", content_type: "image/png", size_bytes: 0, preview_url: m.portrait, is_primary: true }] : [],
created_at: m.created_at,
updated_at: m.updated_at
@@ -61,20 +61,34 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen
// 模特选择器只拉 Model;不再把普通 person 资产并入可选列表。
const [fetched, setFetched] = useState<Asset[]>([]);
const [tagFilter, setTagFilter] = useState<string[]>([]);
const [tagOptions, setTagOptions] = useState<{ name: string; label?: string; count: number }[]>([]);
const reload = useCallback(async () => {
const models = await api.listModels({ pageSize: 200 }).catch(() => null);
const models = await api.listModels({ pageSize: 200, tags: tagFilter }).catch(() => null);
const mapped = (models?.results ?? []).filter((m) => m.portrait_asset).map(modelToAsset);
setFetched(mapped);
}, []);
}, [tagFilter]);
useEffect(() => { if (open) void reload(); }, [open, reload]);
useEffect(() => {
if (!open) return;
let alive = true;
api.listModelTags()
.then((r) => { if (alive) setTagOptions(r.results || []); })
.catch(() => { if (alive) setTagOptions([]); });
return () => { alive = false; };
}, [open]);
useEffect(() => { if (open) setTagFilter([]); }, [open]);
const list = useMemo(() => fetched.filter((a) => previewOf(a)), [fetched]);
function toggleTag(name: string) {
setTagFilter((prev) => prev.includes(name) ? prev.filter((t) => t !== name) : [...prev, name]);
}
// 模特库分页(客户端):一页 10 个(5 列 × 2 行)。
const PAGE_SIZE = 10;
// 模特库分页(客户端):一页 20 个。
const PAGE_SIZE = 20;
const [page, setPage] = useState(1);
const pageCount = Math.max(1, Math.ceil(list.length / PAGE_SIZE));
useEffect(() => { setPage(1); }, [open]);
useEffect(() => { setPage(1); }, [open, tagFilter]);
useEffect(() => { setPage((p) => Math.min(p, pageCount)); }, [pageCount]);
const pageList = list.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
@@ -222,6 +236,24 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen
添加模特
</button>
</div>
{tagOptions.length > 0 && (
<div className="actorlib-tag-bar" role="toolbar" aria-label="按标签筛选" style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 12 }}>
<button
type="button"
className={`btn btn-sm${tagFilter.length === 0 ? " btn-primary" : ""}`}
onClick={() => setTagFilter([])}
>全部</button>
{tagOptions.slice(0, 24).map((tag) => (
<button
key={tag.name}
type="button"
className={`btn btn-sm${tagFilter.includes(tag.name) ? " btn-primary" : ""}`}
onClick={() => toggleTag(tag.name)}
title={`${tag.count} 个`}
>{tag.label || tag.name}<span className="mono" style={{ marginLeft: 4, opacity: 0.65 }}>{tag.count}</span></button>
))}
</div>
)}
{list.length ? (
<>
<div className="actorlib-grid">
@@ -10,6 +10,8 @@ export const OMNI_VIDEO_MODELS = ["Seedance 2.5", "Seedance 2.0", "Seedance 2.0
export const OMNI_IMAGE_MODELS = ["Seedream5.0", "YQ image2"];
export const OMNI_RESOLUTIONS = ["1080p", "720p", "480p"];
export const OMNI_RATIOS = ["9:16", "16:9", "1:1", "4:3", "3:4"];
/** 临时只开放 ≤60 秒;90/120/180 长视频入口先隐藏。 */
export const OMNI_MAX_VIDEO_DURATION = 60;
export const OMNI_VIDEO_DURATIONS = [
"智能时长", "4 秒", "5 秒", "6 秒", "7 秒", "8 秒", "9 秒", "10 秒",
"11 秒", "12 秒", "13 秒", "14 秒", "15 秒", "30 秒", "45 秒", "60 秒",
@@ -84,20 +86,22 @@ function catalogModelLabels(
}
function canCreateSegmentedVideo(config: ModelConfig | undefined, model: string): boolean {
// 31–60 秒不是单次 API 时长,而是平台会自动拆成 <=30 秒的两段。
// 只有 Seedance 2.5(或目录里声明了 30 秒能力的模型)可选这个总时长。
// 31–60 秒总时长会拆成 ≤30 秒片段(长视频 >60 秒入口已临时关闭)。
// 只有 Seedance 2.5(或目录里声明了 30 秒能力的模型)可选这些总时长。
return modelDurations(config).some((seconds) => seconds >= 30)
|| /seedance\s*2\.5/i.test(model || "");
}
function durationLabelsForModel(config: ModelConfig | undefined, model: string, isVideo: boolean): string[] {
if (!isVideo) return [...OMNI_IMAGE_COUNTS];
const seconds = modelDurations(config);
const seconds = modelDurations(config).filter((n) => n <= OMNI_MAX_VIDEO_DURATION);
if (!seconds.length) return [...OMNI_VIDEO_DURATIONS];
const segmented = canCreateSegmentedVideo(config, model) ? [45, 60] : [];
const segmented = canCreateSegmentedVideo(config, model)
? [45, 60].filter((n) => n <= OMNI_MAX_VIDEO_DURATION)
: [];
return ["智能时长", ...seconds, ...segmented]
.filter((seconds, index, values) => values.indexOf(seconds) === index)
.map((seconds) => typeof seconds === "number" ? `${seconds} 秒` : seconds);
.filter((value, index, values) => values.indexOf(value) === index)
.map((value) => typeof value === "number" ? `${value} 秒` : value);
}
export function OmniParamBar({
@@ -198,8 +202,13 @@ export function OmniParamBar({
}
const nextDur = durationLabelsForModel(selected, model, true);
const seconds = Number(String(duration || "").replace(/\D/g, ""));
const isPlannedSegmentedDuration = seconds > 30 && seconds <= 60 && canCreateSegmentedVideo(selected, model);
// 60 秒是总时长,生成时会拆段;不要因为单段目录最高 30 秒就把它偷偷改回 8 秒/智能时长。
// 已选 >60 秒的旧会话:收回长视频入口后压回 60 秒。
if (seconds > OMNI_MAX_VIDEO_DURATION) {
onDuration(`${OMNI_MAX_VIDEO_DURATION} 秒`);
return;
}
const isPlannedSegmentedDuration = seconds > 30 && seconds <= OMNI_MAX_VIDEO_DURATION && canCreateSegmentedVideo(selected, model);
// 31–60 秒是总时长,生成时会拆段;不要因为单段目录最高 30 秒就偷偷改回 8 秒/智能时长。
if (duration && duration !== "智能时长" && !includesDuration(nextDur, duration) && !isPlannedSegmentedDuration) {
onDuration(nextDur.includes("8 秒") ? "8 秒" : (nextDur.find((x) => x !== "智能时长") || "智能时长"));
}
+9 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { CheckCircle2, Inbox, Music, Shield, Trash2, X } from "lucide-react";
import { CheckCircle2, Download, Inbox, Music, Shield, Trash2, X } from "lucide-react";
// 抽屉 / 弹窗 / 全屏播放器必须挂到 document.body。
// 写在页面树里会被顶栏(sticky + z-index)盖住:搜索、余额、铃铛会浮在抽屉上。
@@ -55,12 +55,13 @@ export function useOverlayTransition(open: boolean, onClose?: () => void, durati
// 通用媒体预览灯箱:点击图片放大 / 点击视频弹窗播放 / 点击音频弹窗试听(复用 .np-lightbox 样式)
// 背景点击 / Esc / 关闭键都可关闭;点媒体本身不关闭。
export function MediaLightbox({ open, src, kind, name, close }: {
export function MediaLightbox({ open, src, kind, name, close, onDownload }: {
open: boolean;
src: string;
kind?: "image" | "video" | "audio";
name?: string;
close: () => void;
onDownload?: () => void;
}) {
useBodyScrollLock(open && !!src);
useEffect(() => {
@@ -73,7 +74,12 @@ export function MediaLightbox({ open, src, kind, name, close }: {
// 挂到 body:浮层才能盖住顶栏(搜索 / 余额 / 铃铛)和侧栏
return createPortal(
<div className="np-lightbox show" onClick={close}>
<button className="lb-x" type="button" aria-label="关闭" onClick={close}><X /></button>
<div className="lb-actions" onClick={(event) => event.stopPropagation()}>
{onDownload ? (
<button className="lb-dl" type="button" aria-label="下载" title="下载" onClick={onDownload}><Download size={16} /></button>
) : null}
<button className="lb-x" type="button" aria-label="关闭" onClick={close}><X /></button>
</div>
{kind === "video" ? (
<video
src={src}
+18 -3
View File
@@ -1238,6 +1238,12 @@ body.sidebar-collapsed .user::after { display: none; }
.field-label .req { color: var(--accent-crimson); margin-left: 2px; }
.field-hint { font-size: 12px; color: var(--black-alpha-48); }
.field-hint.is-error { color: var(--accent-crimson); }
/* 标签内联注解(取值范围 / 单位 / 可留空):跟 .req 一样附在 .field-label 里,mono 灰弱化 */
.field-label .lbl-note { color: var(--black-alpha-48); font-weight: 400; margin-left: 2px; font-family: var(--font-mono); }
/* 数字输入:去掉浏览器上下箭头 + 等宽数字,避免输入时字宽跳动 */
.num-input::-webkit-outer-spin-button,
.num-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.num-input { -moz-appearance: textfield; font-variant-numeric: tabular-nums; }
.input, .textarea, .select {
height: 36px;
padding: 0 14px;
@@ -2025,9 +2031,16 @@ select.duration-select:focus,
border-radius: var(--r-md);
box-shadow: 0 20px 60px rgba(0, 0, 0, .5);
}
.np-lightbox .lb-x {
.np-lightbox .lb-actions {
position: fixed;
top: 24px; right: 24px;
z-index: 1;
display: flex;
align-items: center;
gap: 10px;
}
.np-lightbox .lb-x,
.np-lightbox .lb-dl {
width: 44px; height: 44px;
border-radius: 50%;
background: rgba(255, 255, 255, .12);
@@ -2038,8 +2051,10 @@ select.duration-select:focus,
place-items: center;
transition: background var(--t-base);
}
.np-lightbox .lb-x:hover { background: rgba(255, 255, 255, .24); }
.np-lightbox .lb-x svg { width: 18px; height: 18px; }
.np-lightbox .lb-x:hover,
.np-lightbox .lb-dl:hover { background: rgba(255, 255, 255, .24); }
.np-lightbox .lb-x svg,
.np-lightbox .lb-dl svg { width: 18px; height: 18px; }
.np-lightbox .lb-name {
position: fixed;
bottom: 24px; left: 50%;
+75 -2
View File
@@ -113,9 +113,15 @@
.models-page .ml-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
}
@media (max-width: 1280px) {
.models-page .ml-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); }
}
@media (max-width: 1024px) {
.models-page .ml-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
.models-page .ml-card {
position: relative;
min-width: 0;
@@ -281,7 +287,10 @@
.models-page .ml-sel-bar { left: 50%; }
}
@media (max-width: 860px) {
.models-page .ml-grid { grid-template-columns: repeat(auto-fill, minmax(148px, 1fr)); }
.models-page .ml-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 560px) {
.models-page .ml-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* 模特详情弹窗:参考视频项目角色详情结构 */
@@ -318,9 +327,17 @@
.model-detail-portrait[role="button"] { cursor: zoom-in; }
.model-detail-tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 12px;
min-width: 0;
max-width: 100%;
}
.model-detail-tags .pill {
flex: 0 1 auto;
max-width: 100%;
overflow-wrap: anywhere;
}
.model-detail-triview {
width: 100%;
@@ -351,3 +368,59 @@
outline: 1.5px solid var(--heat);
outline-offset: 2px;
}
.models-page .ml-tag-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 0 18px;
}
.models-page .ml-tag-chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
padding: 0 12px;
border: 1px solid rgba(34, 42, 54, .10);
border-radius: 999px;
background: #fff;
color: #414750;
font-size: 12px;
cursor: pointer;
transition: background .15s ease, color .15s ease, border-color .15s ease;
}
.models-page .ml-tag-chip i {
font-style: normal;
color: #9aa0a8;
font-family: var(--font-mono);
font-size: 11px;
}
.models-page .ml-tag-chip:hover {
border-color: rgba(0, 47, 167, .28);
color: var(--klein);
}
.models-page .ml-tag-chip.is-on {
border-color: transparent;
background: var(--ml-black, #1b2028);
color: #fff;
}
.models-page .ml-tag-chip.is-on i { color: rgba(255,255,255,.72); }
.models-page .ml-tag-chip.ml-tag-scope {
font-weight: 600;
}
.models-page .ml-tag-sep {
width: 1px;
align-self: stretch;
min-height: 22px;
background: rgba(0, 0, 0, 0.12);
margin: 0 4px;
}
.models-page .ml-pager {
margin-top: 18px;
}
.models-page .ml-pager .list-pager {
justify-content: center;
}
+17 -1
View File
@@ -879,11 +879,27 @@
height: 100%;
display: block;
object-fit: cover;
}
.omni-case-visual img {
transition: transform 260ms ease;
}
/* 预览视频叠在封面上。播不起来时保持透明,封面继续可见。
不要给 video 加 scale:Safari 里变换过的 video 会直接画成空白。 */
.omni-case-visual video {
position: absolute;
inset: 0;
opacity: 0;
transform: translateZ(0);
}
.omni-case-visual video.is-playing {
opacity: 1;
}
.omni-case-card:hover .omni-case-visual img,
.omni-case-card:hover .omni-case-visual video {
.omni-case-card.active .omni-case-visual img {
transform: scale(1.035);
}
+461 -25
View File
@@ -303,12 +303,21 @@
justify-content: center;
}
/* 小云雀式跟随追问:答案留在问题附近,尺寸不抢占对话空间。 */
/* 小云雀式跟随追问:与上方选项同宽;与选项拉开一点距离。 */
.omni-reply-guide:has(.omni-chat-choice-actions) {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 14px;
}
.omni-chat-question-input {
display: flex;
align-items: center;
gap: 6px;
width: min(300px, 100%);
width: 100%;
max-width: none;
box-sizing: border-box;
}
.omni-chat-question-input .input {
@@ -360,13 +369,34 @@
.omni-result-card,
.omni-process-card {
width: min(760px, calc(100% - 44px));
box-sizing: border-box;
width: min(320px, calc(100% - 44px));
max-width: 320px;
margin: 0 0 24px 44px;
border: 1px solid rgba(34, 42, 54, .09);
border-radius: 15px;
background: #fff;
box-shadow: 0 10px 26px rgba(20, 27, 38, .06);
animation: omniMessageIn 220ms ease both;
overflow: hidden;
}
.omni-result-card.has-multiple,
.omni-process-card.has-multiple {
width: min(520px, calc(100% - 44px));
max-width: 520px;
}
.omni-result-card.has-single.is-ratio-16-9,
.omni-process-card.has-single.is-ratio-16-9 {
width: min(480px, calc(100% - 44px));
max-width: 480px;
}
.omni-result-card.has-single.is-ratio-1-1,
.omni-process-card.has-single.is-ratio-1-1 {
width: min(340px, calc(100% - 44px));
max-width: 340px;
}
.omni-strategy-card,
@@ -1167,7 +1197,10 @@
}
.omni-process-frame {
height: 310px;
width: 100%;
height: 100%;
aspect-ratio: 9 / 16;
min-height: 200px;
display: flex;
flex-direction: column;
align-items: center;
@@ -1178,6 +1211,14 @@
animation: omniShimmer 1.2s ease infinite;
}
.is-ratio-16-9 .omni-process-frame {
aspect-ratio: 16 / 9;
}
.is-ratio-1-1 .omni-process-frame {
aspect-ratio: 1 / 1;
}
.omni-process-frame strong {
color: #5c6570;
font-size: 14px;
@@ -1211,6 +1252,12 @@
.omni-result-media.is-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
padding: 10px;
}
.omni-result-card.has-many .omni-result-media.is-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.omni-result-tile {
@@ -1220,11 +1267,22 @@
border-radius: var(--r-md, 12px);
background: #edf0f4;
box-shadow: 0 8px 20px rgba(20, 27, 38, .08);
aspect-ratio: 9 / 16;
width: 100%;
}
.is-ratio-16-9 .omni-result-tile {
aspect-ratio: 16 / 9;
}
.is-ratio-1-1 .omni-result-tile {
aspect-ratio: 1 / 1;
}
.omni-result-preview {
display: block;
width: 100%;
height: 100%;
padding: 0;
border: 0;
background: transparent;
@@ -1233,18 +1291,19 @@
.omni-result-tile img {
width: 100%;
height: 310px;
height: 100%;
aspect-ratio: inherit;
display: block;
object-fit: cover;
background: #edf0f4;
}
.omni-result-media.is-grid .omni-result-tile img {
height: 220px;
height: 100%;
}
.omni-result-media.is-grid .omni-process-frame {
height: 220px;
height: 100%;
}
.omni-result-play {
@@ -1387,6 +1446,10 @@
flex-direction: column;
}
.omni-result-card.has-many .omni-result-media.is-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.omni-direction-custom button {
width: 100%;
}
@@ -1731,14 +1794,36 @@
background: var(--klein);
color: #fff;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 160ms ease;
}
.omni-elicit-actions button.cancel-btn {
background: var(--surface, #fff);
color: var(--text-secondary, #606a78);
border: 1px solid var(--border-faint, #e2e8f0);
}
.omni-elicit-actions button.cancel-btn:hover:not(:disabled) {
background: var(--background-hover, #f8fafc);
color: var(--accent-black, #1a202c);
border-color: var(--border-subtle, #cbd5e1);
}
.omni-elicit-actions button:disabled {
background: rgba(34, 42, 54, .18);
color: rgba(255, 255, 255, .7);
cursor: not-allowed;
}
.omni-elicit-actions button.cancel-btn:disabled {
background: var(--surface, #fff);
color: var(--text-tertiary, #94a3b8);
border-color: var(--border-faint, #e2e8f0);
opacity: 0.6;
}
/* 已提交的追问卡变成只读回顾,不能再改 —— 重复提交后端会 409 */
.omni-elicit-card.is-submitted .omni-elicit-options button,
.omni-elicit-card.is-submitted .omni-elicit-assets button {
@@ -2160,7 +2245,8 @@
position: absolute;
bottom: calc(100% + 10px);
left: 0;
z-index: 40;
/* 高于顶栏(--z-topbar:50),避免往上展开时被 sticky 顶栏盖住(同层仍可能被裁,配合 is-preview-below) */
z-index: calc(var(--z-topbar) + 20);
width: 244px;
display: grid;
gap: 10px;
@@ -2182,6 +2268,18 @@
transform: translateY(0);
}
/* 靠近顶栏时改为向下展开 */
.omni-mention-chip.has-thumb.is-preview-below .omni-mention-hover-preview {
top: calc(100% + 10px);
bottom: auto;
transform: translateY(-4px);
}
.omni-mention-chip.has-thumb.is-preview-below:hover .omni-mention-hover-preview,
.omni-mention-chip.has-thumb.is-preview-below:focus-within .omni-mention-hover-preview {
transform: translateY(0);
}
.omni-mention-chips.is-user .omni-mention-hover-preview {
right: 0;
left: auto;
@@ -2340,7 +2438,7 @@
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
color: #5c6570;
color: var(--black-alpha-64);
}
.omni-chat-bubble.is-reasoning {
@@ -2348,7 +2446,13 @@
flex-direction: column;
align-items: stretch;
gap: 8px;
color: #5c6570;
color: var(--black-alpha-64);
}
/* 规划过程不是一行 loading:给过程本身足够横向空间,历史在内部滚动。 */
.omni-chat-row.agent.is-live .omni-chat-bubble.is-reasoning {
width: min(620px, calc(100% - 44px));
max-width: min(620px, calc(100% - 44px));
}
.omni-think-head {
@@ -2356,20 +2460,48 @@
align-items: center;
gap: 8px;
font-size: 13px;
color: #5c6570;
color: var(--black-alpha-64);
}
.omni-think-text {
.omni-think-log {
display: grid;
gap: 8px;
max-height: 248px;
overflow-y: auto;
padding: 10px 8px 2px 0;
border-top: 1px solid var(--border-faint);
scrollbar-gutter: stable;
}
.omni-think-log-row {
padding-left: 10px;
border-left: 1px solid var(--border-muted);
}
.omni-think-log-row.is-current {
position: relative;
border-left-color: transparent;
}
.omni-think-log-row.is-current::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: -1px;
width: 2px;
border-radius: var(--r-sm);
background: var(--klein);
animation: omniCurrentStepPulse 1.2s ease-in-out infinite;
}
.omni-think-log-row p {
margin: 0;
min-width: 0;
color: var(--black-alpha-64);
font-size: 12px;
line-height: 1.65;
color: #8a93a0;
white-space: pre-wrap;
word-break: break-word;
max-height: 168px;
overflow-y: auto;
border-left: 1px solid rgba(15, 23, 42, 0.08);
padding-left: 10px;
}
.omni-typing {
@@ -2395,6 +2527,11 @@
40% { opacity: 1; transform: translateY(-2px); }
}
@keyframes omniCurrentStepPulse {
0%, 100% { opacity: .32; }
50% { opacity: 1; }
}
@keyframes omniSendPulse {
0%, 100% {
background: #5b6572;
@@ -2459,9 +2596,10 @@
box-shadow: -8px 12px 40px rgba(20, 27, 38, .16);
}
.omni-prompt-drawer header {
.omni-prompt-drawer header,
.omni-prompt-drawer-head {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) 32px;
grid-template-columns: 36px minmax(0, 1fr) 32px 32px;
align-items: center;
gap: 10px;
padding: 14px 14px 12px;
@@ -2484,29 +2622,35 @@
height: 16px;
}
.omni-prompt-drawer header div {
.omni-prompt-drawer header div,
.omni-prompt-drawer-head div {
min-width: 0;
}
.omni-prompt-drawer header strong,
.omni-prompt-drawer header small {
.omni-prompt-drawer header small,
.omni-prompt-drawer-head strong,
.omni-prompt-drawer-head small {
display: block;
}
.omni-prompt-drawer header strong {
.omni-prompt-drawer header strong,
.omni-prompt-drawer-head strong {
overflow: hidden;
font-size: 15px;
text-overflow: ellipsis;
white-space: nowrap;
}
.omni-prompt-drawer header small {
.omni-prompt-drawer header small,
.omni-prompt-drawer-head small {
margin-top: 2px;
color: #8b919a;
font-size: 12px;
}
.omni-prompt-drawer header button {
.omni-prompt-drawer header button,
.omni-prompt-drawer-head button {
width: 32px;
height: 32px;
display: grid;
@@ -2703,6 +2847,47 @@
cursor: not-allowed;
}
.omni-person-generate-panel {
display: grid;
width: 100%;
gap: 8px;
margin-top: 4px;
padding: 12px;
border-radius: var(--r-md);
background: var(--background-lighter);
box-shadow: inset 0 0 0 1px var(--border-faint);
}
.omni-person-generate-panel label {
color: var(--accent-black);
font-size: 12px;
font-weight: 600;
}
.omni-person-generate-panel .textarea {
width: 100%;
min-height: 88px;
resize: vertical;
}
.omni-person-generate-panel > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.omni-person-generate-panel > div > span {
flex: 1 1 auto;
color: var(--black-alpha-48);
font-size: 11px;
line-height: 1.6;
}
.omni-person-generate-panel > div > button {
flex: 0 0 auto;
}
/* 单选追问直接点选即提交;长方向文案纵向铺开,避免挤成难扫读的小胶囊。 */
.omni-chat-choice-actions {
width: 100%;
@@ -3079,6 +3264,248 @@
font-size: 11px;
}
/* 对话资源 / 文档卡下载 */
.omni-doc-head-actions {
display: inline-flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.omni-doc-download {
flex: 0 0 auto;
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 1px solid var(--border-faint);
border-radius: 8px;
background: var(--surface);
color: var(--accent-black);
cursor: pointer;
transition: background var(--t-base), border-color var(--t-base), color var(--t-base);
}
.omni-doc-download:hover {
border-color: var(--border-loud);
background: var(--background-lighter);
color: var(--klein);
}
.omni-doc-download svg {
width: 14px;
height: 14px;
}
.omni-prompt-file-actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
}
.omni-prompt-file-actions button {
display: inline-flex;
align-items: center;
gap: 6px;
}
.omni-prompt-file-actions button.is-secondary {
background: transparent;
border: 1px solid var(--border-faint);
color: var(--accent-black);
}
.omni-prompt-file-actions button svg {
width: 14px;
height: 14px;
}
.omni-assets-card {
position: relative;
}
.omni-assets-card-main {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 8px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
text-align: left;
width: 100%;
cursor: pointer;
}
.omni-assets-card.is-doc .omni-assets-card-main,
.omni-assets-grid.is-list .omni-assets-card-main {
flex-direction: row;
align-items: center;
gap: 10px;
}
.omni-assets-card-main:disabled {
cursor: default;
opacity: .72;
}
.omni-assets-dl {
position: absolute;
right: 8px;
bottom: 8px;
z-index: 1;
width: 28px;
height: 28px;
display: grid;
place-items: center;
border: 0;
border-radius: 8px;
color: #fff;
background: rgba(16, 16, 18, .72);
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity .16s ease;
}
.omni-assets-card.is-doc .omni-assets-dl,
.omni-assets-grid.is-list .omni-assets-dl {
position: static;
flex: 0 0 auto;
margin-left: auto;
color: var(--accent-black);
background: var(--background-lighter);
opacity: 1;
pointer-events: auto;
}
.omni-assets-card:hover .omni-assets-dl,
.omni-assets-dl:focus-visible {
opacity: 1;
pointer-events: auto;
}
.omni-assets-dl:disabled {
opacity: .4;
cursor: default;
pointer-events: none;
}
.omni-assets-dl svg {
width: 14px;
height: 14px;
}
@media (hover: none) {
.omni-assets-dl {
opacity: 1;
pointer-events: auto;
}
}
.omni-model-picker-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.omni-model-picker-card {
display: flex;
min-width: 0;
flex-direction: column;
gap: 8px;
padding: 8px;
border: 0;
border-radius: var(--r-md);
background: var(--surface);
color: var(--accent-black);
box-shadow: inset 0 0 0 1px var(--border-faint);
text-align: left;
cursor: pointer;
transition: background var(--t-base), box-shadow var(--t-base);
}
.omni-model-picker-card:hover,
.omni-model-picker-card.is-selected {
background: var(--background-lighter);
box-shadow: inset 0 0 0 1px var(--heat-40);
}
.omni-model-picker-card.is-selected {
color: var(--heat);
}
.omni-model-picker-media {
display: grid;
width: 100%;
aspect-ratio: 3 / 4;
place-items: center;
overflow: hidden;
border-radius: 6px;
background: var(--background-base);
color: var(--black-alpha-48);
}
.omni-model-picker-media img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.omni-model-picker-media svg {
width: 24px;
height: 24px;
}
.omni-model-picker-copy {
display: block;
min-width: 0;
padding: 0 2px 2px;
}
.omni-model-picker-copy strong,
.omni-model-picker-copy small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.omni-model-picker-copy strong {
font-size: 13px;
font-weight: 600;
}
.omni-model-picker-copy small {
margin-top: 2px;
color: var(--black-alpha-48);
font-family: var(--font-mono);
font-size: 10px;
}
.omni-model-picker-footer {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
}
.omni-model-picker-selection {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
color: var(--black-alpha-48);
font-family: var(--font-mono);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (min-width: 1101px) {
.omni-session-page.has-assets-panel {
box-sizing: border-box;
@@ -3130,4 +3557,13 @@
.omni-assets-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.omni-person-generate-panel > div {
align-items: stretch;
flex-direction: column;
}
.omni-person-generate-panel > div > button {
width: 100%;
}
}
+3 -12
View File
@@ -1,10 +1,9 @@
import { useEffect, useState } from "react";
import { ArrowLeft, ArrowRight, LogOut } from "lucide-react";
import { ArrowRight, LogOut } from "lucide-react";
import { IconKitSvg } from "../../components/IconKitSvg";
import { CornerMarks, Decorations, ToastLike } from "../../components/app-shell";
import { ConfirmModal } from "../../components/overlays";
import type { Team, User } from "../../types";
import type { NavigateFn } from "../route-config";
import type { User } from "../../types";
import { AdminLedgersPage, AdminQuotaPage } from "./admin-billing";
import { AdminGovernancePage } from "./admin-governance";
import { AdminInvitesPage } from "./admin-invites";
@@ -70,13 +69,11 @@ const GROUP_HINT: Record<string, string> = {
type AdminAppProps = {
section: string;
user: User;
team: Team | null;
navigateAdmin: (section: string, options?: { replace?: boolean }) => void;
navigate: NavigateFn;
logout: () => void;
};
export function AdminApp({ section, user, team, navigateAdmin, navigate, logout }: AdminAppProps) {
export function AdminApp({ section, user, navigateAdmin, logout }: AdminAppProps) {
const active = ADMIN_SECTIONS.find((s) => s.slug === section) || ADMIN_SECTIONS[0];
const [toast, setToast] = useState<{ type: "success" | "error" | "info"; text: string } | null>(null);
const [logoutOpen, setLogoutOpen] = useState(false);
@@ -143,12 +140,6 @@ export function AdminApp({ section, user, team, navigateAdmin, navigate, logout
})}
</div>
<div className="admin-foot">
{team && (
<button type="button" className="admin-back" onClick={() => navigate("dashboard")}>
<ArrowLeft size={16} strokeWidth={1.8} />
返回工作台
</button>
)}
<div className="admin-user">
<div className="av">{(user.username || "A").slice(0, 1).toUpperCase()}</div>
<div className="admin-user-meta">
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { Building, KeyRound, Percent, X } from "lucide-react";
import { Building, Coins, KeyRound, Percent, UserPlus, X } from "lucide-react";
import { adminApi } from "../../api";
import { Pager } from "../../components/pager";
import { IconKitSvg } from "../../components/IconKitSvg";
@@ -247,6 +247,18 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
const [pwdTarget, setPwdTarget] = useState<AdminUser | null>(null);
const [pwd, setPwd] = useState("");
const [saving, setSaving] = useState(false);
// 直接开户:不发邀请码,后端建号时自动配一个个人团队当钱包(团队体系保留,后台只按用户视角管)
const [createOpen, setCreateOpen] = useState(false);
const [newName, setNewName] = useState("");
const [newLogin, setNewLogin] = useState("");
const [newPwd, setNewPwd] = useState("");
const [newCredits, setNewCredits] = useState("");
const [creating, setCreating] = useState(false);
// 发积分:金额可正可负,落一条调额流水
const [creditTarget, setCreditTarget] = useState<AdminUser | null>(null);
const [creditAmt, setCreditAmt] = useState("");
const [creditReason, setCreditReason] = useState("");
const [creditSaving, setCreditSaving] = useState(false);
const load = useCallback(async () => {
setLoading(true);
@@ -293,12 +305,79 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
}
}
function closeCreate() {
setCreateOpen(false);
setNewName("");
setNewLogin("");
setNewPwd("");
setNewCredits("");
}
async function doCreate() {
if (creating) return;
const name = newName.trim();
const login = newLogin.trim();
if (!name) { notify("error", "请填写用户名"); return; }
if (!/^[A-Za-z0-9]{6}$/.test(login)) {
notify("error", "登录账号须为 6 位英文或数字,不能含其他字符");
return;
}
if (newPwd.trim().length < 8) { notify("error", "密码至少 8 位"); return; }
const credits = newCredits.trim();
if (credits && (!Number.isFinite(Number(credits)) || Number(credits) < 0)) { notify("error", "初始积分需为非负数"); return; }
setCreating(true);
try {
await adminApi.createUser({
username: login,
display_name: name,
password: newPwd.trim(),
initial_credits: credits || "0",
});
notify("success", `已创建用户 ${name}(${login})`);
closeCreate();
setPage(1);
await load();
} catch (e) {
notify("error", e instanceof Error ? e.message : "创建失败");
} finally {
setCreating(false);
}
}
function closeCredit() {
setCreditTarget(null);
setCreditAmt("");
setCreditReason("");
}
async function doCredit() {
if (!creditTarget || creditSaving) return;
const amt = Number(creditAmt);
if (!Number.isFinite(amt) || amt === 0) { notify("error", "请填写非 0 的积分数量"); return; }
setCreditSaving(true);
try {
await adminApi.adjustUserCredit(creditTarget.id, { amount: creditAmt.trim(), reason: creditReason.trim() });
notify("success", `已为 ${creditTarget.username} ${amt > 0 ? "发放" : "扣减"} ${Math.abs(amt)} 积分`);
closeCredit();
await load();
} catch (e) {
notify("error", e instanceof Error ? e.message : "调额失败");
} finally {
setCreditSaving(false);
}
}
return (
<>
<div className="page-head">
<div>
<h1>用户</h1>
<div className="sub"><span className="mono">{count} 个用户</span> · 跨团队 · 启停 / 强制改密</div>
<div className="sub"><span className="mono">{count} 个用户</span> · 直接开户 · 发积分 / 启停 / 强制改密</div>
</div>
<div className="actions">
<button className="btn btn-primary" type="button" onClick={() => setCreateOpen(true)}>
<UserPlus size={15} />新建用户
</button>
</div>
</div>
@@ -308,7 +387,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
<button key={t.key || "all"} type="button" className={`tab-sub${statusFilter === t.key ? " active" : ""}`} onClick={() => { setStatusFilter(t.key); setPage(1); }}>{t.label}</button>
))}
</div>
<input className="input admin-search" type="text" placeholder="搜索用户名…" value={search} onChange={(e) => { setSearch(e.target.value); setPage(1); }} />
<input className="input admin-search" type="text" placeholder="搜索用户名 / 登录账号…" value={search} onChange={(e) => { setSearch(e.target.value); setPage(1); }} />
</div>
{loading ? (
@@ -319,15 +398,19 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
<div className="admin-table-wrap">
<table className="t admin-table">
<thead>
<tr><th>用户名</th><th>所属团队</th><th>状态</th><th>注册时间</th><th className="col-actions">操作</th></tr>
<tr><th>用户名</th><th>登录账号</th><th>积分余额</th><th>所属团队</th><th>状态</th><th>注册时间</th><th className="col-actions">操作</th></tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>
{u.username}
{u.first_name || u.username}
{u.is_platform_admin && <span className="pill info admin-inline-pill"><span className="dot" />超管</span>}
</td>
<td className="mono">{u.username}</td>
<td className="num mono">
{u.wallet_team ? `${pts(u.balance)} 积分` : <span className="muted">—</span>}
</td>
<td>
{u.teams.length === 0
? <span className="muted">—</span>
@@ -340,6 +423,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
<span className="muted">—</span>
) : (
<>
{u.wallet_team && <button className="btn btn-sm btn-ghost" type="button" onClick={() => setCreditTarget(u)}>发积分</button>}
<button className="btn btn-sm btn-ghost" type="button" onClick={() => setPwdTarget(u)}>改密</button>
<button className={`btn btn-sm btn-ghost${u.status === "active" ? " danger" : ""}`} type="button" onClick={() => toggle(u)}>
{u.status === "active" ? "停用" : "启用"}
@@ -355,6 +439,86 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
</div>
)}
{createOpen && (
<div className="modal-bg show">
<div className="modal" role="dialog" aria-modal="true" aria-label="新建用户">
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><UserPlus size={16} /></div>
<div className="ti">新建用户</div>
<button className="x modal-x" type="button" aria-label="关闭" onClick={closeCreate}><X size={14} /></button>
</div>
<div className="modal-b">
<p className="admin-modal-desc">直接开户,不用发邀请码。用户名可中文展示;登录账号仅英文+数字且必须 6 位,建好后用登录账号和密码登录。</p>
<div className="field">
<label className="field-label" htmlFor="new-display-name">用户名 <span className="req">*</span></label>
<input id="new-display-name" className="input" type="text" placeholder="展示名称,如:适柔" value={newName} onChange={(e) => setNewName(e.target.value)} />
</div>
<div className="field">
<label className="field-label" htmlFor="new-login">登录账号 <span className="req">*</span> <span className="lbl-note">(6 位英文或数字)</span></label>
<input
id="new-login"
className="input"
type="text"
inputMode="text"
autoComplete="off"
spellCheck={false}
maxLength={6}
placeholder="例如:sr0001"
value={newLogin}
onChange={(e) => setNewLogin(e.target.value.replace(/[^A-Za-z0-9]/g, "").slice(0, 6).toLowerCase())}
/>
</div>
<div className="field">
<label className="field-label" htmlFor="new-password">初始密码 <span className="req">*</span> <span className="lbl-note">(至少 8 位)</span></label>
<input id="new-password" className="input" type="text" placeholder="告知用户后建议其自行修改" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} />
</div>
<div className="field">
<label className="field-label" htmlFor="new-credits">初始积分 <span className="lbl-note">(可留空,默认 0)</span></label>
<input id="new-credits" className="input num-input" type="number" inputMode="numeric" min="0" placeholder="例: 1000" value={newCredits} onChange={(e) => setNewCredits(e.target.value)} />
</div>
</div>
<div className="modal-f">
<button className="btn" type="button" onClick={closeCreate}>取消</button>
<button className="btn btn-primary" type="button" disabled={creating} onClick={() => void doCreate()}>{creating ? "创建中…" : "创建用户"}</button>
</div>
</div>
</div>
)}
{creditTarget && (
<div className="modal-bg show" onClick={(e) => { if (e.target === e.currentTarget) closeCredit(); }}>
<div className="modal" role="dialog" aria-modal="true" aria-label="发积分">
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><Coins size={16} /></div>
<div className="ti">发积分<span>{creditTarget.username}</span></div>
<button className="x modal-x" type="button" aria-label="关闭" onClick={closeCredit}><X size={14} /></button>
</div>
<div className="modal-b">
<p className="admin-modal-desc">
当前余额 <b style={{ color: "var(--accent-black)" }}>{pts(creditTarget.balance)} 积分</b>。填正数为发放,负数为扣减;扣减后余额不得为负。会落一条调额流水。
</p>
<div className="field">
<label className="field-label" htmlFor="credit-amount">积分数量 <span className="req">*</span> <span className="lbl-note">(正数发放 / 负数扣减)</span></label>
<input id="credit-amount" className="input num-input" type="number" inputMode="numeric" placeholder="例: 1000 或 -200" value={creditAmt} onChange={(e) => setCreditAmt(e.target.value)} />
{creditTarget.wallet_shared && (
<div className="field-hint">⚠ 该用户属于多人团队「{creditTarget.wallet_team_name}」,这笔积分会进团队共享池,同队成员都能使用。</div>
)}
</div>
<div className="field">
<label className="field-label" htmlFor="credit-reason">备注 <span className="lbl-note">(选填,写进流水便于对账)</span></label>
<input id="credit-reason" className="input" type="text" placeholder="例: 活动赠送 / 争议补偿" value={creditReason} onChange={(e) => setCreditReason(e.target.value)} />
</div>
</div>
<div className="modal-f">
<button className="btn" type="button" onClick={closeCredit}>取消</button>
<button className="btn btn-primary" type="button" disabled={creditSaving} onClick={() => void doCredit()}>{creditSaving ? "提交中…" : "确认调额"}</button>
</div>
</div>
</div>
)}
{pwdTarget && (
<div className="modal-bg show" onClick={(e) => { if (e.target === e.currentTarget) setPwdTarget(null); }}>
<div className="modal" role="dialog" aria-modal="true" aria-label="重置密码">
+5 -9
View File
@@ -143,19 +143,19 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
page: "modelPhoto" as Page,
title: "模特上身",
desc: "上传商品和模特参考图,生成自然统一的服装、饰品上身效果。",
image: "/assets/prototype/photo-1524504388940-b1c1722653e1.jpg",
image: "/assets/yz/image-model-wear-film-v1.jpg",
},
{
page: "platformCover" as Page,
title: "平台套图",
desc: "基于商品主图一次生成主图、卖点图、细节图与场景图。",
image: "/assets/prototype/unbranded-running-shoe-remix.png",
image: "/assets/yz/image-platform-set-film-v1.jpg",
},
{
page: "imageOptimize" as Page,
title: "自由创作",
title: "图片创作",
desc: "使用提示词、参考图和画布比例自由生成或修改视觉素材。",
image: "/assets/prototype/photo-1557682250-33bd709cbe85.jpg",
image: "/assets/yz/image-studio-film-v2.jpg",
}
];
@@ -435,29 +435,25 @@ const MODE_META: Record<
WorkMode,
{
title: string;
tag: string;
desc: string;
ratio: string;
promptTemplate: (productTitle: string) => string;
}
> = {
image: {
title: "自由创作",
tag: "[ IMAGE · STUDIO ]",
title: "图片创作",
desc: "使用提示词与参考图,自由生成或修改电商视觉素材",
ratio: "1:1",
promptTemplate: (title) => `${title},电商高转化视觉,干净背景,商品主体清晰`
},
model: {
title: "模特上身",
tag: "[ MODEL · TRY-ON ]",
desc: "选择授权模特与生成规格,为商品创建自然真实的上身展示图",
ratio: "1:1",
promptTemplate: (title) => `${title},模特上身展示,自然光,真实质感,电商主图`
},
cover: {
title: "平台套图",
tag: "[ PLATFORM · KIT ]",
desc: "根据平台规范生成主图、卖点图、细节图与场景图",
// 优化版:商品上架主图默认 1:1(原 4:5 会 fallback 成方图致比例错乱);竖图按平台/类目再选
ratio: "1:1",
-1
View File
@@ -266,7 +266,6 @@ export function AuthScreen({
<div className="login-brand-copy"><span>YINGQING AIGC STUDIO</span></div>
</div>
<div className="login-hero">
<p className="login-eyebrow">// AIGC COMMERCE CONTENT ENGINE</p>
<div className="login-hero-copy">
<h1>
<span className="login-hero-line">让每一次商品表达,</span>
+98 -19
View File
@@ -7,6 +7,7 @@ import { useFileDrop } from "../components/use-file-drop";
import { generationErrorText } from "../generation-error";
import type { ModelEntity } from "../types";
import { SystemLoading } from "../components/loading";
import { Pager } from "../components/pager";
import { ConfirmModal, MediaLightbox, useBodyScrollLock, useOverlayTransition } from "../components/overlays";
import "../models-page.css";
@@ -19,6 +20,8 @@ const TABS: { k: Tab; label: string; title: string; note: string }[] = [
{ k: "mine", label: "我的模特", title: "我的模特", note: "维护可复用的品牌模特与人物参考资产" }
];
const PAGE_SIZE = 20;
type Preview = { src: string; kind: "image"; name: string };
const formatPoints = (value: string) => {
@@ -83,6 +86,11 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify, onBillingCh
useEffect(() => () => {
if (pendingPortraitUrlRef.current) URL.revokeObjectURL(pendingPortraitUrlRef.current);
}, []);
// Hooks must stay above the early return — opening the modal used to add useFileDrop mid-tree and crash.
const portraitDrop = useFileDrop(
(files) => selectPortrait(files[0]),
{ disabled: saving || !model || Boolean(model?.is_official), accept: (f) => f.type.startsWith("image/") }
);
if (!mounted || !model) return null;
const currentModel = model;
const portraitUrl = pendingPortraitUrl || model.portrait;
@@ -121,13 +129,9 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify, onBillingCh
setSaving(false);
}
}
const portraitDrop = useFileDrop(
(files) => selectPortrait(files[0]),
{ disabled: saving, accept: (f) => f.type.startsWith("image/") }
);
function selectPortrait(file?: File) {
if (!file || currentModel.is_official || saving) return;
if (!file || !model || model.is_official || saving) return;
clearPendingPortrait();
const url = URL.createObjectURL(file);
pendingPortraitUrlRef.current = url;
@@ -223,6 +227,9 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify, onBillingCh
{model.is_official && <span className="pill info">官方模板</span>}
<span className="pill neutral">{model.source === "upload" ? "真人上传" : "AI 生成"}</span>
{pendingPortrait && <span className="pill info">待保存</span>}
{(Array.isArray(model.metadata?.tags) ? model.metadata.tags : []).filter((t): t is string => typeof t === "string" && Boolean(t.trim())).map((tag) => (
<span className="pill neutral" key={tag}>{tag}</span>
))}
</div>
</div>
<div className="model-detail-right">
@@ -279,12 +286,23 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify, onBillingCh
// 模特库:顶级实体(团队级可复用)= 形象图 + 三视图 + 声线(尾期)。官方预制打「官方模板」标签。
// 图片创作(模特上身图)与视频项目(角色)都引用它。期1:看归好的成套模特 + 真人上传 + 删自建。
function modelTags(m: ModelEntity): string[] {
const raw = m.metadata?.tags;
if (!Array.isArray(raw)) return [];
return raw.filter((t): t is string => typeof t === "string" && Boolean(t.trim()));
}
export function ModelsPage({ onNotify, onBillingChanged }: {
onNotify?: (type: "success" | "error", text: string) => void;
onBillingChanged?: () => void;
}) {
const [tab, setTab] = useState<Tab>("all");
const [tagFilter, setTagFilter] = useState<string[]>([]);
const [tagOptions, setTagOptions] = useState<{ name: string; label?: string; count: number }[]>([]);
const [items, setItems] = useState<ModelEntity[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [editMode, setEditMode] = useState(false);
@@ -304,15 +322,38 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
useEffect(() => {
let alive = true;
setLoading(true);
const tabParam = tab === "all" ? undefined : tab;
api
.listModels({ tab: tab === "all" ? undefined : tab })
.then((r) => { if (alive) setItems(r.results); })
.catch(() => { if (alive) setItems([]); })
.listModels({ tab: tabParam, tags: tagFilter, page, pageSize: PAGE_SIZE })
.then((r) => {
if (!alive) return;
setItems(r.results);
setTotal(r.count ?? r.results.length);
})
.catch(() => {
if (!alive) return;
setItems([]);
setTotal(0);
})
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, [tab, tagFilter, page]);
useEffect(() => {
let alive = true;
api
.listModelTags({ tab: tab === "all" ? undefined : tab })
.then((r) => { if (alive) setTagOptions(r.results || []); })
.catch(() => { if (alive) setTagOptions([]); });
return () => { alive = false; };
}, [tab]);
useEffect(() => { setSelected(new Set()); }, [tab]);
useEffect(() => { setSelected(new Set()); setTagFilter([]); setPage(1); }, [tab]);
useEffect(() => { setPage(1); }, [tagFilter]);
function toggleTag(name: string) {
setTagFilter((prev) => prev.includes(name) ? prev.filter((t) => t !== name) : [...prev, name]);
}
const modelDrop = useFileDrop(
(files) => { void acceptModelFile(files[0]); },
@@ -333,7 +374,11 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
form.append("file", file);
form.append("name", file.name.replace(/\.[^.]+$/, ""));
const created = await api.uploadModel(form);
setItems((list) => (tab === "official" ? list : [created, ...list]));
if (tab !== "official") {
setPage(1);
setTotal((n) => n + 1);
setItems((list) => [created, ...list].slice(0, PAGE_SIZE));
}
} finally {
setUploading(false);
}
@@ -351,8 +396,11 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
const removed = ids.filter((id) => !failed.has(id));
if (removed.length) {
setItems((list) => list.filter((m) => !removed.includes(m.id)));
setTotal((n) => Math.max(0, n - removed.length));
setSelected((prev) => new Set([...prev].filter((id) => !removed.includes(id))));
onNotify?.("success", removed.length > 1 ? `已移至垃圾桶 · ${removed.length} 个` : "已移至垃圾桶");
// 当前页删空且还有上一页 → 回退一页重新拉
if (items.length <= removed.length && page > 1) setPage((p) => p - 1);
}
if (failed.size) onNotify?.("error", "部分模特删除失败");
setConfirmIds(null);
@@ -379,15 +427,38 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
</div>
</header>
<div className="ml-toolbar">
<div className="ml-seg" role="tablist" aria-label="模特来源">
{TABS.map((t) => (
<button key={t.k} type="button" role="tab" aria-selected={tab === t.k} className={`ml-seg-btn${tab === t.k ? " active" : ""}`} onClick={() => setTab(t.k)}>
{t.label}
</button>
))}
</div>
<span className="ml-note">官方模特均为平台导入的授权素材</span>
<div className="ml-tag-bar" role="toolbar" aria-label="按来源与标签筛选">
{TABS.map((t) => (
<button
key={t.k}
type="button"
className={`ml-tag-chip ml-tag-scope${tab === t.k ? " is-on" : ""}`}
onClick={() => setTab(t.k)}
aria-pressed={tab === t.k}
>
{t.label}
</button>
))}
<span className="ml-tag-sep" aria-hidden="true" />
<button
type="button"
className={`ml-tag-chip${tagFilter.length === 0 ? " is-on" : ""}`}
onClick={() => setTagFilter([])}
>
不限标签
</button>
{tagOptions.map((tag) => (
<button
key={tag.name}
type="button"
className={`ml-tag-chip${tagFilter.includes(tag.name) ? " is-on" : ""}`}
onClick={() => toggleTag(tag.name)}
title={`${tag.count} 个`}
>
{tag.label || tag.name}
<i>{tag.count}</i>
</button>
))}
</div>
<div className="ml-section">
@@ -404,6 +475,7 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
<span>点右上「添加模特」上传一张形象图,或在图片/视频流程里生成</span>
</div>
) : (
<>
<div className={`ml-grid${modelDrop.dragging ? " is-dragover" : ""}`} {...modelDrop.dropProps}>
{items.map((m) => {
const selectable = !m.is_official;
@@ -458,12 +530,19 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
</>
)}
<span className="ml-tag">{m.source === "upload" ? "真人上传" : "AI 生成"}</span>
{modelTags(m).slice(0, 4).map((tag) => (
<span className="ml-tag" key={tag}>{tag}</span>
))}
</div>
</div>
</article>
);
})}
</div>
<div className="ml-pager">
<Pager page={page} total={total} pageSize={PAGE_SIZE} onChange={setPage} alwaysShow />
</div>
</>
)}
</div>
+38 -24
View File
@@ -71,6 +71,37 @@ const IMAGE_PRESETS: PresetItem[] = [
const MENTION_REF_LIMIT = 20;
const ROLE_REF_LIMIT = 3;
function playPresetPreview(card: HTMLElement) {
const video = card.querySelector<HTMLVideoElement>("video");
if (!video || video.dataset.broken === "1") return;
const reveal = () => {
if (video.dataset.broken === "1" || video.videoWidth === 0) return;
video.classList.add("is-playing");
};
const fail = () => {
video.dataset.broken = "1";
video.classList.remove("is-playing");
video.pause();
};
video.addEventListener("error", fail, { once: true });
video.addEventListener("playing", reveal, { once: true });
void video.play().catch(fail);
}
function stopPresetPreview(card: HTMLElement) {
const video = card.querySelector<HTMLVideoElement>("video");
if (!video) return;
video.pause();
if (video.dataset.broken !== "1" && Number.isFinite(video.duration)) {
try {
video.currentTime = 0;
} catch {
/* Safari 在还没解码时不允许回绕 */
}
}
video.classList.remove("is-playing");
}
const MENTION_TABS: Array<{ type: CreationRef["type"]; label: string; Icon: typeof ImageIcon }> = [
{ type: "asset", label: "素材", Icon: ImageIcon },
{ type: "character", label: "角色", Icon: UserRound },
@@ -671,39 +702,22 @@ export function OmniCreatePage({
className={`omni-case-card${selectedCase?.name === card.name ? " active" : ""}`}
data-mode={card.mode}
key={card.name}
onMouseEnter={(event) => {
const video = event.currentTarget.querySelector<HTMLVideoElement>("video");
void video?.play().catch(() => undefined);
}}
onMouseLeave={(event) => {
const video = event.currentTarget.querySelector<HTMLVideoElement>("video");
if (!video) return;
video.pause();
video.currentTime = 0;
}}
onFocus={(event) => {
const video = event.currentTarget.querySelector<HTMLVideoElement>("video");
void video?.play().catch(() => undefined);
}}
onBlur={(event) => {
const video = event.currentTarget.querySelector<HTMLVideoElement>("video");
if (!video) return;
video.pause();
video.currentTime = 0;
}}
onMouseEnter={(event) => playPresetPreview(event.currentTarget)}
onMouseLeave={(event) => stopPresetPreview(event.currentTarget)}
onFocus={(event) => playPresetPreview(event.currentTarget)}
onBlur={(event) => stopPresetPreview(event.currentTarget)}
onClick={() => {
if (card.mode === "image") applyPreset(card);
else setPreviewCase(card);
}}
>
<span className="omni-case-visual">
<img src={card.cover} alt="" />
{card.previewVideo ? (
<video muted loop playsInline preload="metadata" poster={card.cover} aria-label={`${card.title} 样片预览`}>
<video muted loop playsInline preload="none" poster={card.cover} aria-hidden="true">
<source src={card.previewVideo} type="video/mp4" />
</video>
) : (
<img src={card.cover} alt="" />
)}
) : null}
</span>
<span className="omni-case-copy">
<strong>{card.title}</strong>
File diff suppressed because it is too large Load Diff
-1
View File
@@ -4783,7 +4783,6 @@ export function PipelinePage(props: {
<ConfirmModal
open={chargeConfirm !== null}
title="确认生成视频"
subtitle="// 失败不扣 · 成功后结算"
icon={<Sparkles size={16} />}
detail={(
<>
+5 -5
View File
@@ -444,13 +444,13 @@ const PROJ_TABS: Array<{ filter: "all" | "draft" | "wip" | "done" | "fail"; labe
type ProjTab = (typeof PROJ_TABS)[number]["filter"];
const VIDEO_MAKE: Array<{ title: string; desc: string; page: Page; image: string; primary?: boolean }> = [
{ title: "一键成片", desc: "输入商品名称并上传图片,自动完成脚本、场景与视频生成。", page: "quickCreate", image: "/assets/prototype/video-oneclick-film-v3.png", primary: true },
{ title: "专业创作", desc: "逐步控制脚本结构、商品与人物资产和最终视频。", page: "projectWizard", image: "/assets/prototype/photo-1485846234645-a62644f84728.jpg", primary: true },
{ title: "一键成片", desc: "输入商品名称并上传图片,自动完成脚本、场景与视频生成。", page: "quickCreate", image: "/assets/yz/video-oneclick-film-v3.png", primary: true },
{ title: "专业创作", desc: "逐步控制脚本结构、商品与人物资产和最终视频。", page: "projectWizard", image: "/assets/yz/video-pro-film-v1.jpg", primary: true },
];
const VIDEO_TOOLS: Array<{ title: string; desc: string; page: Page; image: string; style?: React.CSSProperties }> = [
{ title: "自由生成", desc: "使用提示词、参考图片与素材库,自由控制画面和生成参数。", page: "freeCreate", image: "/assets/prototype/video-free-film-v3.png" },
{ title: "提炼提示词", desc: "从参考视频中提炼可编辑的视频生成提示词。", page: "videoRemix", image: "/assets/prototype/video-prompt-extract-film-v3.png" },
{ title: "视频复刻", desc: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容。", page: "videoReplace", image: "/assets/prototype/video-remix-film-v3.png", style: { objectPosition: "center 58%" } },
{ title: "自由生成", desc: "使用提示词、参考图片与素材库,自由控制画面和生成参数。", page: "freeCreate", image: "/assets/yz/video-free-film-v5.png" },
{ title: "提炼提示词", desc: "从参考视频中提炼可编辑的视频生成提示词。", page: "videoRemix", image: "/assets/yz/video-prompt-extract-film-v3.png" },
{ title: "视频复刻", desc: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容。", page: "videoReplace", image: "/assets/yz/video-remix-film-v3.png", style: { objectPosition: "center 58%" } },
];
function isQuickCreateProject(project: Project) {
+1 -7
View File
@@ -389,19 +389,13 @@
绝不复用 styles.css 旧暖米调色板的同名类(.role-choice/.cred-card 等)。
═══════════════════════════════════════════════════════════════════ */
/* 共用:字段必填星标 / 标签副注 */
.invite-modal .field-label .req { color: var(--accent-crimson); margin-left: 2px; }
.invite-modal .field-label .lbl-note { color: var(--black-alpha-48); font-weight: 400; margin-left: 2px; font-family: var(--font-mono); }
/* .req / .lbl-note / .num-input 已提到 design-restraint.css 作共享类,这里不再重写 */
/* 共用:输入框 + 行内随机生成按钮 */
.invite-modal .input-with-gen { display: flex; gap: 8px; }
.invite-modal .input-with-gen .input { flex: 1; min-width: 0; }
.invite-modal .input-with-gen .btn-sm { height: 38px; padding: 0 12px; flex: 0 0 auto; }
.invite-modal .pw-input { letter-spacing: .04em; }
/* 数字输入去掉 spinner(对齐设计稿) */
.invite-modal .num-input::-webkit-outer-spin-button,
.invite-modal .num-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.invite-modal .num-input { -moz-appearance: textfield; font-variant-numeric: tabular-nums; }
/* ─── 角色双卡(创建账户 / 编辑成员共用)─── */
.invite-modal .role-choices { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
+14
View File
@@ -79,10 +79,17 @@ export type AdminTeamDetail = AdminTeam & { members: AdminTeamMember[] };
export type AdminUser = {
id: string;
username: string;
/** 展示用用户名(可中文);登录账号是 username */
first_name?: string;
status: string;
is_platform_admin: boolean;
date_joined: string;
teams: { team_id: string; team_name: string; role: string }[];
// 钱包:积分账户挂在团队上,后台按用户视角拍平。wallet_shared=true 表示这是多人共享池
balance: string;
wallet_team: string | null;
wallet_team_name: string | null;
wallet_shared: boolean;
};
export type AdminQualityWord = {
id: string;
@@ -939,6 +946,13 @@ export type CreationConversation = {
status: "running" | "completed" | "failed";
agent_status: CreationAgentStatus;
agent_started_at?: string | null;
/** 后台整理时的用户可见进度;服务端只返回受控阶段文案,不含模型原始 thinking。 */
agent_progress?: {
label: string;
detail: string;
/** 当前轮可滚动查看的受控创作过程,不包含模型原始 thinking。 */
history?: Array<{ label: string; detail: string }>;
} | null;
message_count: number;
cover_url: string;
last_active_at: string;
-160
View File
@@ -1,160 +0,0 @@
# AirShelf · 性能审计报告
> **范围:** 全量扫描 —— 前端(数据加载/水合、列表渲染、pipeline 重渲染、全站轮询)+ 后端(ORM N+1、端点耗时/payload、数据库索引)
> **背景:** 用户反馈刷新/进页面拉数据慢、不够丝滑;张业昌已优化过一波,仍有体感卡顿
> **日期:** 2026-06-19
> **方法:** 6 路并行审计,每块两端源码核查到 file:line
> **严重度:** P0 = 明显卡顿/秒级阻塞 · P1 = 可感知延迟 · P2 = 优化空间
---
## 〇、先说结论:"刷新慢/不丝滑"的真正元凶
按"改动成本 vs 体感收益"排序。**前两条是一行/小改就能立竿见影的,强烈建议先做。**
| # | 根因 | 类型 | 现状 | 一句话修复 | 收益 |
|---|---|---|---|---|---|
| 🔴 1 | **线上 DB 连接不复用** | 后端配置 | `production.py` 缺 `CONN_MAX_AGE`,每请求重连远程 MySQL ~1.6s;并发 N 接口=N×1.6s。development 有这条优化,**线上没搬** | production.py 加 `CONN_MAX_AGE=300`+`CONN_HEALTH_CHECKS` | **刷新整页快几十秒→秒级** |
| 🔴 2 | **登录假动画延迟 1.5s** | 前端 | 凭证验过后还硬演 verified+entering 两段共 ~1.5s | **已修**(见本报告末) | 登录即时进 |
| 🔴 3 | **视频生成期 5s 全量回拉项目详情** | 前后端 | 有视频在跑时每 5s 调 `api.project()`,跑最重的 12-prefetch 序列化器、回 ~26KB,整页每 5 秒抖一下 | 轮询改用轻量 `poll-video-segment` 单段状态 | 视频阶段不再整页抖、payload 砍 99% |
| 🔴 4 | **消息中心首屏同步 N+1 爆炸** | 后端 | 进消息面板时 `list()` 里同步循环 aggregate+get_or_create,单次可达 **500~1000+ 条 SQL** | 批量聚合 + bulk_create + 改 Celery 异步 | 打开消息面板秒回 |
| 🔴 5 | **Asset 大表零索引** | 后端索引 | 全站最大增长表,资产库列表/计数/facets 全表扫 + filesort | 加 `(team,category,-created_at)` 等复合索引 | 资产多时数量级提速 |
| 🔴 6 | **pipeline 3100 行巨页不拆不 memo** | 前端 | 任一 state 变(打字/轮询/hover)reconcile 整棵树 | 5 个 stage 拆 memo 子组件 + 派生数据 useMemo | 输入/拖动恢复跟手 |
| 🟠 7 | **切走再回必重拉、无缓存** | 前端 | 换页即卸载,每次进页面从零拉、白骨架闪烁 | 加内存缓存 + stale-while-revalidate | 二次进页 0 等待 |
| 🟠 8 | **大页一次拉 200 条 + img 漏 lazy** | 前端 | 商品详情/图片工具 `pageSize:200`,任务中心缩图无 `loading=lazy` | 改服务端分页 + 补 lazy | 首屏字节/解码降一个量级 |
---
## 一、前端 · 数据加载与水合(7/10)
> 水合链路已认真优化过:骨架先出、`loadData` 并行 Promise.all、不阻塞首屏、liteRefresh、懒加载——核心卡顿点基本消除。剩下的是缓存与"小操作全量重拉"。
| 严重度 | 位置 | 问题 | 体感影响 | 修复建议 |
|---|---|---|---|---|
| P1 | 各路由 + `App.tsx` renderPage switch | **切走再回每次都重拉**:换页即卸载旧组件,`reloadFlag` 归零,资产库/消息/团队/消费/图片工具/商品详情/工作台进去都从零拉,无任何缓存 | 反复横跳每次白骨架闪烁,不丝滑 | 模块级内存缓存或 SWR:先渲染上次结果,再后台 revalidate |
| P1 | `App.tsx:387` `action()` | **几乎所有变更都触发全量 `loadData()`**(并发重拉 products+projects+billing+models+badge 五个);只有 pipeline 改/加/删分镜传了 liteRefresh | 改商品/传图/改资料后顶栏侧栏整体抖一下,且并发拉无关数据 | 给不动列表的操作普遍补 `liteRefresh`,或让调用方只刷相关切片 |
| P1 | `App.tsx:388` `action()` | **每次 action 无条件 `refreshProjectDetail()`**,即便不在 pipeline、操作与项目无关(改头像/充值/建商品都白拉 26KB 项目详情) | 非管线页做操作时白发一个大请求 | 仅 `page==="pipeline"` 或操作确属项目相关时才刷 |
| P2 | `ai-tools.tsx:94`/`products.tsx:551`/`:568` | `pageSize:200` 一次拉大页再前端筛/分页 | 资产多的团队首屏偏慢 | 改服务端分页 + 触底加载 |
| P2 | 站内点进管线 `App.tsx:759-786` | 详情未到时整页 loading,未与导航预取重叠 | 从项目列表点进管线有一次明显等待 | `openPipeline` 时即并行预取 `api.project(id)` |
✅ 已核实无问题:loadData 并行且不阻塞首屏、骨架态完整、搜索去抖到位、防连点到位、**无人为延迟卡流程**(除已修的登录)。
---
## 二、前端 · 列表与图片渲染(6.5/10)
> 分页/懒加载/去抖基础都对,但几处"大页一次拉 + 一次性渲染"和漏 lazy,会在素材多的真实账号上突然变卡。
| 严重度 | 位置 | 问题 | 体感影响 | 修复建议 |
|---|---|---|---|---|
| **P0** | 商品详情 `products.tsx:551` | 进详情一次拉该商品**最多 200 条**素材,前端再 filter+slice 只显示 12,200 条全进 state | 素材多的商品点进去明显"转一下" | 服务端按页拉(page+pageSize=12),"加载更多"走下一页 |
| **P0** | 图片工具任务中心 `ai-tools.tsx:314,349` | 结果缩图 `<img>` **漏 `loading="lazy"`**(同文件别处都加了)+ `:94` 一次拉 200 张,首屏并发几十张原图 | 进"图片生成"页一批图齐刷、带宽抖动 | 两处补 `loading="lazy"`,源数据改分页 |
| **P0** | 商品库/项目页 `App.tsx:141-142`+`api.ts:175,204` | `api.products()/projects()` 只取后端**第 1 页 20 条**,却被当"全量"做客户端搜索/筛选/分页 → 第 21 个起根本不在内存 | **搜不到/筛不到第 20 名以后的数据**,用户误判"卡了没加载" | products/projects 改服务端分页(像 library/messages 那样) |
| P1 | 图片工具模特网格 `ai-tools.tsx:568` | 一次拉 200 个模特进 state,点"全部模特"渲染全部缩图 | 模特库大时工作台首开+展开卡 | 默认拉前若干,"全部"走分页 |
| P1 | 商品详情视频 tab `products.tsx:643-646` | `videoProjects` 不分页不截断,一次渲染全部视频卡(assets tab 有 limit,videos 没有) | 项目多的商品切到视频 tab 一次铺满 | videos tab 也加 limit/分页 |
| P1 | 向导商品选择器 `projects.tsx:278-280` | 页码用 `Array.from({length:totalPages})` 全量展开,没用 pageWindow 折叠 | 商品多时页码一长条 | 复用 `pageWindow()` 折叠 |
| P2 | 商品库 `products.tsx:124-134` | 搜索纯客户端但每键触发整列表重算;`ProductCard` 未 memo,map 内联闭包 | 商品多时逐字搜索略顿 | ProductCard 包 memo,filtered 用 useMemo |
| P2 | 列表行未 memo / key 用 index | `projects.tsx:588`/`account.tsx:331,347` 内联闭包无 memo;`products.tsx:435,819` key=index | 当前每页 10 条无碍,数据增长后退化 | 列表项抽组件 memo,key 用稳定 id |
✅ 正向:六个页面 loading 都用骨架不白屏;messages/library/account 已是真服务端分页+去抖,messages 还做了滚动无限加载+防竞态。
---
## 三、前端 · pipeline 重渲染与轮询(5/10)
> 轮询清理/动画做得不错,但**单个 3100 行巨页 + 5s 全量回拉**让整页每 5 秒抖,后台 tab 也照拉不停。这是 pipeline"不丝滑"的核心。
| 严重度 | 位置 | 问题 | 体感影响 | 修复建议 |
|---|---|---|---|---|
| **P0** | `App.tsx:320-334`+`api.ts:207` | 视频 5s 轮询每轮 `api.project()` 拉**全量 ~26KB**再 `setProjectDetail`,无脏检查,即便没变也 set → 整页重渲染 | 有视频在跑时整页每 5 秒"刷一下",滚动/输入发顿 | 后端加轻量 `video-status` 端点;或 set 前对比版本号/JSON 跳过 |
| **P0** | `pipeline.tsx:445`(整组件)+`503-585` | 单个 3100 行函数持 60+ useState,5 阶段全在一棵树;`byId/scripts/shots/groups` 每次渲染重算(memo 远不够),组件无 memo 无拆分 | 任一 state 变(打字/轮询/hover)reconcile 整棵巨树,输入延迟 | 5 阶段拆 memo 子组件只渲当前 stage;派生数据包 useMemo |
| P1 | 全站轮询无 `visibilitychange` | 5s/8s/4s 轮询在**隐藏 tab/切后台时照跑**(全局 0 命中 document.hidden) | 切走后仍持续打接口,耗电耗流量,服务端空转 | poll tick 起始加 `if(document.hidden)return` |
| P1 | `pipeline.tsx:2076-2095` | stage2 `pendingAssets` 4s 轮询**空闲也永不停**,`list.length==0` 也照 setTimeout | 停在资产页啥也不干也每 4s 打接口 | 连续 N 轮空就停,有新提交再启 |
| P2 | `pipeline.tsx:84-89` `getAssetBlobUrl` | `createObjectURL` 进 module Map 缓存后**全程不 revoke**,跨项目累积 | 长会话内存缓慢上涨 | 切/卸载项目时 revoke + 清 Map |
✅ 已核实无问题:ProgressTimeline 的 1s interval 作用域小且 done 即停;两处 rAF 正确 gate+cleanup;5s/8s/120ms 轮询都在卸载时清理且按阶段 gate;CSS infinite 动画都是条件出现的 spinner/shimmer;无人为卡流程的 setTimeout。
---
## 四、后端 · ORM N+1 与序列化器(核心路径已治理,集中 2 个热点)
> 详情/列表核心路径已被认真优化(prefetch、defer 大 JSON 列、annotate 计数、轻量列表序列化器)。真正的放大点集中在消息中心首屏 + 写操作回吐全量。
| 严重度 | 位置 | 问题(估算多发 SQL) | 体感影响 | 修复建议 |
|---|---|---|---|---|
| **P0** | `apps/ops/views.py:69-235` `ensure_team_notifications`(在 `list()` 同步触发) | 首屏循环内:5 项目各 `credit_ledgers.aggregate()` + 拉最多 500 条 charge 流水逐条 `get_or_create`。**单次首屏 500~1000+ 条往返**,远程库几秒~几十秒 | 第一次开消息面板/换团队整页长转圈 | 项目花费 `values('project').annotate(Sum)` 批量;charge 通知批量查 dedupe_key + bulk_create;首屏走 Celery 异步 |
| P1 | `apps/ops/views.py:285-292` `type_counts` | **每次**列表请求额外 6 条 `.count()`(all/unread/task/team/billing/system) | 收件箱翻页每次多 6 个往返 | 一次 `values('notification_type').annotate(Count)`+单 unread count = 2 条 |
| P1 | `apps/projects/views.py` 写 action 一律回 `ProjectSerializer(project).data`(`:491/544/681/781` 等) | 每个采用版本/上传/存草稿按钮都回吐**整棵项目树**(几十~上百行),个别 action 未走 prefetch 还会 N+1 | 每次写操作后界面可感卡顿 + 回包巨大 | 只回受影响子对象(已有 VideoSegmentVersionSerializer 等);必须回全量则走带 prefetch 的重取 |
| P2 | `apps/projects/serializers.py:90-100` `BaseAssetGroupSerializer` | 线索核实:列表/详情**不 N+1**(queryset 已 prefetch adopted_asset/candidate/files)。仅 `adopt_base_asset` 单独序列化未预取的 group 时每组 +2~3 条 | 采用基础资产后单次轻微多查 | 单对象序列化前补 select_related/prefetch |
| P2 | `apps/assets/views.py:98-118` summary/facets | 资产库徽标 6 条 count、facets 多条 distinct(固定不放大) | 进资产库一组并发 count | 可合成一条 `aggregate(Count(Case(When)))` |
✅ 已核实无问题:项目列表用 ProjectListSerializer+annotate 无 N+1;详情/商品/AITask/资产列表均已 select_related/prefetch_related + defer 大 JSON;billing/team_members 已批量化。
---
## 五、后端 · 端点耗时与 payload(7/10)
| 严重度 | 位置 | 问题 | 体感影响 | 修复建议 |
|---|---|---|---|---|
| **P0** | `settings/production.py`(缺 `CONN_MAX_AGE`)vs `development.py:10-13` | **线上 MySQL 连接未复用**,每请求新建连接 ~1.6s 握手。开发环境设了 300s 复用并注明"登录并发 12 接口=卡几十秒",**这条优化只在 development 生效** | 线上每接口多 1.6s,一进页面并发 N 请求=N×握手 | production.py 加 `CONN_MAX_AGE=300`+`CONN_HEALTH_CHECKS=True`(提到 base.py 更稳) |
| **P0** | `App.tsx:324,331`+`pipeline.tsx:1876`→`projects/views.py:118` | 任一视频段 running 时**每 5s 调 `api.project`**,触发 12-prefetch 最重序列化器,把全部版本/分镜/资产/timeline 一次塞响应 | 视频阶段持续拉巨 payload + 整页重渲 | 轮询改用已有轻量 `poll-video-segment`(views.py:546)只回单段状态;或详情加 ETag→304 |
| P1 | `projects/views.py:278`+`assets/review.py:111`,前端 `pipeline.tsx:816`(8s) | `poll-reviews` 在**请求线程里同步循环调火山外部 HTTP**(每个审核中资产一次),前端每 8s 打一次 | N 个审核资产时单次请求阻塞=N×火山往返 | 火山审核轮询挪 Celery 后台,端点只读 DB review_status |
| P1 | `apps/ai/views.py:68` `AITaskViewSet` | 未设显式 `ordering`,分页可能重/漏行 | 任务多时前端反复拉 | 加 `ordering=["-created_at"]` |
| P2 | `apps/ops/views.py:276` list | 每次额外 6 条 count(同上 N+1 P1) | 无高频轮询,影响有限 | 合并 count |
| P2 | `apps/billing/views.py:182` trend | "按阶段分布"用 Python 循环累加本月全部流水,非 DB 聚合 | 流水多时账户页加载慢 | 改 `values('task__task_type').annotate(Sum)` |
✅ 已核实无问题:全局分页健全(PAGE_SIZE=20,无全量返回);assets/AITask/billing 已 defer 3MB+ JSON 大列(注释"40 条要 30s+"已修);poll-storyboard 后台线程不阻塞;通知生成已 Celery+60s 缓存。
---
## 六、后端 · 数据库索引(7/10)
> 基表 `team` FK 自带单列索引,纯 `filter(team=)` 不慢。真正缺的全是"team + 第二过滤列/时间排序"的**复合索引**。远程 MySQL 上数据量上来后,缺索引的过滤/排序会明显变慢。
| 严重度 | 表/字段 | 问题 | 体感影响 | 加什么索引 |
|---|---|---|---|---|
| **P0** | **Asset** `(team,category,-created_at)` 等 | `assets/models.py:6` 整表**零自定义索引**,资产库每 tab `filter(team,category).order_by(-created_at)` + 6 个 count + facets distinct。Asset 是全站最大增长表 | 资产库刷新/切 tab/徽标计数,大表全表扫+filesort 秒级卡 | `Index(["team","category","-created_at"])` + `(team,asset_type)` + 视需要 `(team,source)` |
| **P0** | **Asset** `(team,-created_at)` | 默认无筛选排序也要 team 内取全部再 filesort | 默认视图随资产数线性变慢 | `Index(["team","-created_at"])`(或被上条复合覆盖) |
| P1 | **CreditLedger** `(team,-created_at)` | 流水分页 `filter(team).order_by(-created_at)`,现有索引不覆盖。Ledger 高频写入 | 账单流水翻页随增长变慢 | `Index(["team","-created_at"])` |
| P1 | **CreditLedger** `created_at` 范围 | 消费趋势 `filter(team,ledger_type=CHARGE,created_at__date__gte)`+TruncDate 分组无法走索引 | 账户消费分析月度数据多时慢 | `(team,ledger_type)` 扩成 `(team,ledger_type,created_at)` |
| P1 | **AITask** `(team,-created_at)` | 无显式 ordering + 无时间序索引 | 任务列表分页不稳+filesort | `Index(["team","-created_at"])` + ViewSet `ordering` |
| P1 | **Project** `(team,-updated_at)` | 列表 `filter(team).order_by(-updated_at)`,索引不含 updated_at | 项目列表/侧栏随项目数变慢 | `Index(["team","-updated_at"])` |
| P1 | **Product** `(team,-created_at)` | 列表无显式 ordering + 时间序索引 | 商品列表分页随商品数变慢 | `Index(["team","-created_at"])` + 默认 ordering |
| P2 | **Asset** `metadata__product_id` | 按商品过滤走 JSON 提取 + 三路 Q 并集 distinct,无法走普通索引 | 商品详情"该商品资产"大表下慢 | MySQL 函数/生成列索引,或冗余真实 `product_id` 列 + `(team,product_id)` |
> ⚠️ 远程 MySQL 大表 `ALTER TABLE ADD INDEX` 会锁表,建议低峰执行或用 online DDL。Notification 表(`ops/models.py:52`)索引已做得很好,可作其它表范本。
---
## 七、已修复
- **登录假动画延迟(🔴2)** —— [auth-screen.tsx](core/frontend/src/routes/auth-screen.tsx) 已删除 `api.login()` 成功后的 verified/entering 两段共 ~1.5s 人为 `wait`,以及 checking 阶段的最小垫时。现在凭证验过即进,"进入"态由真实的 `onAuthed`(身份+数据水合)驱动,不再演动画。
---
## 八、建议执行顺序(按 ROI)
### 第一波 · 一行/小改,立竿见影(强烈建议先做)
1. **🔴1 production.py 加 `CONN_MAX_AGE`** —— 消掉线上每请求 1.6s 握手,"刷新慢"最大单点
2. **🔴3 视频 5s 轮询改轻量端点** —— 别再每 5s 拉全量项目详情
3. **🔴8 两处 `<img>` 补 `loading="lazy"`** + 商品详情/图片工具改服务端分页
4. **隐藏 tab 暂停轮询**(poll 加 document.hidden 守卫)
### 第二波 · 后端查询治理
5. **🔴4 消息中心首屏 N+1** —— 批量聚合 + bulk_create + Celery 异步
6. **🔴5 Asset/Ledger/Project/AITask 加复合索引**(低峰执行 DDL)
7. **poll-reviews 火山同步 HTTP 挪 Celery**
8. **写 action 不再回吐全量 ProjectSerializer**
### 第三波 · 前端体验
9. **🔴6 pipeline 巨页拆 memo 子组件**(工作量大,但根治 pipeline 不丝滑)
10. **🟠7 切走再回加内存缓存 + SWR**
11. **action() 普遍补 liteRefresh + 条件化 refreshProjectDetail**
---
## 九、客观说明
- **张业昌确实优化过一波且方向对**:骨架先出、并行水合、defer 大 JSON 列、列表轻量序列化器、prefetch、防竞态、去抖——这些都到位,不是没做。
- **剩下的卡顿高度集中**:① 一条线上配置漏搬(CONN_MAX_AGE)② 视频轮询拉全量 ③ 消息中心首屏 N+1 ④ Asset 缺索引 ⑤ pipeline 巨页。**修掉前 4 个(都不算大改),"刷新慢"基本能解决**;pipeline 巨页拆分是更大的工程,可单独排期。
- 本报告每条都带 file:line,可直接当修复 checklist 交给张业昌认领。