模特库标签分页与全能创作收口:藏长视频、选择器分页

角色库导入打标签并支持筛选;模特库与全能创作角色/商品选择改为每页 20 条分页。临时限制成片 ≤60 秒,过滤对话里的超长时长选项,并收拢本地全能创作与后台用户相关修复。
This commit is contained in:
Azmat@qq.com
2026-09-21 16:24:37 +08:00
parent 690eb3d843
commit 0bd1db6bf9
48 changed files with 3386 additions and 896 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 = {
+1 -1
View File
@@ -117,7 +117,7 @@ class AdminUserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = [
"id", "username", "status", "is_platform_admin", "date_joined", "teams",
"id", "username", "first_name", "status", "is_platform_admin", "date_joined", "teams",
"balance", "wallet_team", "wallet_team_name", "wallet_shared",
]
read_only_fields = fields
+20 -6
View File
@@ -871,7 +871,12 @@ class AdminUserProvisioningTests(TestCase):
self.nc.force_authenticate(self.normal)
def _create(self, **overrides):
payload = {"username": "created-user", "password": "strong-pass-1", "initial_credits": "500"}
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")
@@ -882,7 +887,8 @@ class AdminUserProvisioningTests(TestCase):
self.assertFalse(r.data["wallet_shared"])
self.assertEqual(r.data["teams"], []) # 个人团队是钱包实现细节,不当「所属团队」露出
user = User.objects.get(username="created-user")
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"))
@@ -892,22 +898,30 @@ class AdminUserProvisioningTests(TestCase):
def test_created_user_can_login(self):
self._create()
r = APIClient().post("/api/auth/login/", {"username": "created-user", "password": "strong-pass-1"}, format="json")
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)
self.assertEqual(self._create(username="normal").status_code, 400) # 重名
self.assertEqual(User.objects.filter(username="created-user").count(), 0)
# 准备一个 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="created-user")
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):
+36 -15
View File
@@ -269,31 +269,43 @@ def admin_users(request):
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, password, initial_credits?}。
"""后台直建用户:{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 len(username) > 150:
return Response({"username": ["用户名不能超过 150 字"]}, status=status.HTTP_400_BAD_REQUEST)
try:
for validator in User._meta.get_field("username").validators:
validator(username)
except DjangoValidationError as exc:
return Response({"username": list(exc.messages)}, status=status.HTTP_400_BAD_REQUEST)
if User.objects.filter(username=username).exists():
return Response({"username": ["用户名已存在"]}, status=status.HTTP_400_BAD_REQUEST)
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)
@@ -301,8 +313,13 @@ def _admin_create_user(request):
return err
with transaction.atomic():
user = User.objects.create_user(username=username, password=password)
team = Team.objects.create(name=username, owner=user, is_personal=True)
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:
@@ -323,7 +340,11 @@ def _admin_create_user(request):
target_type="user",
target_id=user.id,
target_name=user.username,
after={"initial_credits": str(initial), "wallet_team": str(team.id)},
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)
+233 -89
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))
File diff suppressed because it is too large Load Diff
+19 -2
View File
@@ -128,10 +128,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:
@@ -170,6 +175,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 秒之一并说明理由;"
"随后必须让用户选择实际时长,未确认前不得写剧情方向、策略、方案或出片指令。"
@@ -249,7 +266,7 @@ def apply_plot_twist_direction_contract(prompt: str, *, title: str = "", conflic
VIDEO_PRESET_WORKFLOWS: dict[str, str] = {
"痛点解决演示": "先确认商品真实解决的具体问题与正常用法;生成前核对痛点、过程和结果都有可见证据。",
"真实使用演示": "优先核对商品真实用途、关键步骤和可证实卖点;不为氛围而增加不合理测试。",
"剧情反转带货": "先让用户选故事深度(15秒快节奏反转、30秒轻剧情带货、60秒完整短剧带货或智能推荐),再给三个与时长匹配的剧情方向;商品必须成为解决问题、解除误会、证明事实、回收伏笔或完成翻盘的关键。",
"剧情反转带货": "先让用户选故事深度(15秒快节奏反转、30秒轻剧情带货、60秒完整短剧带货或智能推荐;暂不开放更长时长),再给三个与时长匹配的剧情方向;商品必须成为解决问题、解除误会、证明事实、回收伏笔或完成翻盘的关键。",
"商品拟人广告": "先确认商品外观和性格表达方式;默认无脸拟人,商品保持真实完整,台词用画外声。",
"达人口播种草": "优先确认人物、多人出镜关系、真实体验和主卖点;生成前核对口播字数能在时长内说完,每个卖点都有画面证明。",
"商品图一键成片": "优先从商品参考图锁定外观;自动补场景和动作,但不替换或改变用户商品图里的结构、颜色和包装。",
+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,否则真人素材直接被火山拒。
+43 -19
View File
@@ -337,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:
@@ -386,12 +405,17 @@ 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(
@@ -413,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(
@@ -435,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:
+519 -40
View File
@@ -6,6 +6,8 @@
import json
from unittest.mock import patch
import requests
from django.test import SimpleTestCase, TestCase, override_settings
from rest_framework.test import APIClient
@@ -24,14 +26,18 @@ from .creation_agent import (
_coerce_fields,
_image_count,
_merge_tool_call_deltas,
apply_explicit_video_duration_from_text,
apply_pain_point_direction,
apply_restart_intent,
apply_person_identity_guard,
apply_product_reference_guard,
active_plot_twist_story_depth,
plot_twist_selected_direction,
append_multi_character_relation_gate,
append_step_confirm,
build_system_prompt,
build_messages,
clear_public_agent_progress,
default_reply_hint,
default_reply_options,
ensure_turn_guides,
@@ -39,12 +45,19 @@ from .creation_agent import (
get_video_gate_stage,
has_creative_intent,
infer_script_duration,
MAX_TRUNCATION_RETRIES,
TRUNCATION_GIVE_UP_NOTICE,
creation_agent_max_output_tokens,
creation_model_extra_body,
creation_agent_timeout_notice,
long_video_script_covers_requested_duration,
plan_video_segments,
is_continue_intent,
is_greeting,
is_pure_chitchat,
is_restart_intent,
session_has_creative_context,
set_public_agent_progress,
stream_creation_agent,
set_plot_twist_story_depth,
strip_numeric_reply_instruction,
@@ -53,12 +66,14 @@ from .creation_agent import (
submit_generated_person_reference,
text_already_guides,
tool_schemas,
_video_submit_params,
video_duration,
video_model_name,
wanted_asset_pick,
wanted_param_keys,
)
from .models import AITask, CreationConversation, CreationMessage, ModelConfig, ModelProvider
from .serializers import CreationConversationDetailSerializer
def _text_chunks(text):
@@ -78,6 +93,15 @@ def _tool_chunks(name, arguments, *, said=""):
yield {"type": "done"}
def _truncated_tool_chunks(name, arguments):
"""模拟输出撞到 max_tokens:arguments 断在 JSON 中间,并带 finish_reason=length。"""
yield {"type": "tool_call", "tool_calls": [{"index": 0, "function": {"name": name, "arguments": ""}}]}
blob = json.dumps(arguments, ensure_ascii=False)
yield {"type": "tool_call", "tool_calls": [{"index": 0, "function": {"arguments": blob[: len(blob) // 2]}}]}
yield {"type": "finish", "reason": "length"}
yield {"type": "done"}
class FakeProvider:
"""按脚本逐轮回放。每调用一次 chat_completion_stream 消费一个剧本。"""
@@ -124,6 +148,33 @@ class CreationAgentBaseTests(TestCase):
))
return events, fake
def _pin_person(self, name="出镜达人"):
"""把人物这一步真正走完 —— 钉住一个角色素材。
memory["person_source_ready"] 已经不算完成了(person_identity_ready 防跳过)
只有钉住人正在生成或明确只出手才算想跳过人物闸门的用例必须钉真人物
"""
asset = Asset.objects.create(
team=self.team, created_by=self.user, name=name,
asset_type=Asset.Type.IMAGE, category=Asset.Category.PERSON,
)
refs = [ref for ref in (self.conversation.pinned_refs or []) if isinstance(ref, dict)]
refs.append({"type": "character", "id": str(asset.id), "name": name})
self.conversation.pinned_refs = refs
self.conversation.save(update_fields=["pinned_refs", "updated_at"])
return asset
def _pin_product(self, title="舒缓面霜"):
"""钉住商品 —— 写方案前会先问「推哪款商品」,想直接拿方案的用例必须先把商品定下来。"""
from apps.products.models import Product
product = Product.objects.create(team=self.team, created_by=self.user, title=title)
refs = [ref for ref in (self.conversation.pinned_refs or []) if isinstance(ref, dict)]
refs.append({"type": "product", "id": str(product.id), "name": title})
self.conversation.pinned_refs = refs
self.conversation.save(update_fields=["pinned_refs", "updated_at"])
return product
class ToolCallAssemblyTests(TestCase):
def test_streamed_arguments_are_concatenated_not_overwritten(self):
@@ -173,13 +224,21 @@ class FieldCoercionTests(TestCase):
raw.append({"key": "bad", "label": "x", "type": "dropdown"})
self.assertEqual(len(_coerce_fields(raw)), 1) # 聊天式追问一次只问 1 项
def test_product_text_or_single_is_coerced_to_asset_card(self):
def test_product_text_is_coerced_to_asset_card(self):
fields = _coerce_fields([
{"key": "product", "label": "选择商品", "type": "text"},
])
self.assertEqual(fields[0]["type"], "asset")
self.assertEqual(fields[0]["asset_types"], ["product"])
def test_choice_with_real_options_is_not_degraded_to_asset_card(self):
"""模型真给了具体选项的选择题保留 options —— 否则痛点方向三选一会被换成素材卡。"""
fields = _coerce_fields([
{"key": "product", "label": "选择商品", "type": "single",
"options": [{"value": "a", "label": "A"}]},
])
self.assertEqual(fields[0]["type"], "asset")
self.assertEqual(fields[0]["asset_types"], ["product"])
self.assertEqual(fields[0]["type"], "single")
self.assertEqual(fields[0]["options"], [{"value": "a", "label": "A"}])
def test_duration_single_stays_single(self):
fields = _coerce_fields([
@@ -285,7 +344,12 @@ class AskUserTests(CreationAgentBaseTests):
self.assertEqual(payload["fields"][0]["key"], "_asset_gate")
self.assertEqual(
payload["fields"][0]["options"],
[{"value": "send", "label": "发商品列表"}, {"value": "auto", "label": "你来推荐"}],
[
# 商品闸门多给一条「上传商品图」——手上只有实物图的商家不用先去建商品。
{"value": "upload", "label": "上传商品图"},
{"value": "send", "label": "发商品列表"},
{"value": "auto", "label": "你来推荐"},
],
)
self.assertEqual(payload["pending_fields"][0]["type"], "asset")
self.assertEqual(payload["pending_fields"][0]["asset_types"], ["product"])
@@ -384,7 +448,9 @@ class GenerateImageTests(CreationAgentBaseTests):
)
card = self._confirm_from_events(events)
self.assertEqual(card.payload["kind"], "image")
self.assertEqual(card.payload["prompt"], "干净棚拍,柔光,居中构图")
# 带了商品参考图就会追加外观锁定约束,避免出图换色换款。
self.assertTrue(card.payload["prompt"].startswith("干净棚拍,柔光,居中构图"))
self.assertIn("【商品外观锁定·最高优先级】", card.payload["prompt"])
self.assertFalse(any(e.get("type") == "task" for e in events))
with patch("apps.ai.services.enqueue_standalone_images") as enqueue:
@@ -1370,14 +1436,51 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
return args
def _pin_product(self, title="测试商品"):
product = Product.objects.create(team=self.team, created_by=self.user, title=title)
self.conversation.pinned_refs = [{
"type": "product",
"id": str(product.id),
"name": product.title,
}]
self.conversation.save(update_fields=["pinned_refs", "updated_at"])
return product
# 追加而不是整表替换:已经钉过人物的用例不能因为钉商品把人物挤掉,
# 那样人物来源闸门会重新拦住整轮,方案卡一张都出不来。
return super()._pin_product(title)
def test_natural_brief_updates_explicit_120_second_duration(self):
changed = apply_explicit_video_duration_from_text(
self.conversation,
"用这三个角色和这个商品,做一条120秒的真实自然带货视频。",
)
self.assertTrue(changed)
self.conversation.refresh_from_db()
self.assertEqual(self.conversation.params["duration"], "120 秒")
def test_planning_exposes_safe_progress_not_raw_reasoning(self):
self.conversation.agent_status = CreationConversation.AgentStatus.PLANNING
self.conversation.save(update_fields=["agent_status", "updated_at"])
set_public_agent_progress(self.conversation, "starting")
set_public_agent_progress(self.conversation, "reasoning", detail_key="structure")
set_public_agent_progress(self.conversation, "write_plan")
data = CreationConversationDetailSerializer(self.conversation).data
self.assertEqual(data["agent_progress"]["label"], "正在编排视频方案")
self.assertEqual(data["agent_progress"]["detail"], "正在安排分段节奏,并锁定角色和商品的一致性")
self.assertEqual(
data["agent_progress"]["history"][1]["detail"],
"正在检查时长、分段节奏和前后衔接",
)
self.assertNotIn("reasoning", data["agent_progress"])
clear_public_agent_progress(self.conversation)
self.assertIsNone(CreationConversationDetailSerializer(self.conversation).data["agent_progress"])
self.conversation.agent_status = CreationConversation.AgentStatus.IDLE
self.conversation.save(update_fields=["agent_status", "updated_at"])
self.assertIsNone(CreationConversationDetailSerializer(self.conversation).data["agent_progress"])
def test_shot_timeline_does_not_override_session_duration(self):
changed = apply_explicit_video_duration_from_text(
self.conversation,
"0-30秒先让角色出场,30-60秒展示商品。",
)
self.assertFalse(changed)
self.conversation.refresh_from_db()
self.assertEqual(self.conversation.params["duration"], "15 秒")
def test_plot_twist_preset_requires_story_depth_before_creative_output(self):
self.conversation.preset = "剧情反转带货"
@@ -1402,7 +1505,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertEqual(question["payload"].get("interaction"), "plot_twist_story_depth")
self.assertEqual(
[option["value"] for option in question["payload"]["fields"][0]["options"]],
["15s", "30s", "60s", "smart"],
["15s", "30s", "60s", "180s", "smart"],
)
self.conversation.refresh_from_db()
self.assertEqual(self.conversation.agent_status, "awaiting_user")
@@ -1439,8 +1542,8 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self._pin_product("剧情测试商品")
self.conversation.preset = "剧情反转带货"
self.conversation.params = {**self.conversation.params, "duration": "30 秒"}
self.conversation.memory = {"person_source_ready": True}
self.conversation.save(update_fields=["preset", "params", "memory", "updated_at"])
self.conversation.save(update_fields=["preset", "params", "updated_at"])
self._pin_person()
# 即使模型只输出一句空话,平台也必须补上三个可点击方向,而不是让用户继续追问。
fake = FakeProvider([_text_chunks("我准备了三个剧情反转方向。")])
@@ -1464,14 +1567,16 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
def test_plot_twist_direction_is_locked_into_plan_prompt(self):
"""已选剧情方向必须写进方案 video_prompt,不能漂成另一套默认带货故事。"""
from apps.ai.creation_agent import get_pending_video_prompt
from apps.ai.creation_presets import apply_plot_twist_direction_contract
from apps.ai.views import _plot_twist_direction_continuation
self.conversation.mode = CreationConversation.Mode.VIDEO
self.conversation.preset = "剧情反转带货"
self._pin_person()
self._pin_product("自动喂食器")
self.conversation.memory = {
"plot_twist_story_depth": "30s",
"person_source_ready": True,
"selling_point_ready": True,
"selling_point_mode": "manual",
"selling_point": "定时定量",
@@ -1518,11 +1623,13 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
text="按选定方向写方案",
model_config=self.model,
))
plan = next(
event["message"] for event in events
if event.get("type") == "message" and event["message"]["kind"] == "plan"
)
prompt = str((plan.get("payload") or {}).get("video_prompt") or "")
self.assertTrue(any(
event.get("type") == "message" and event["message"]["kind"] == "plan"
for event in events
))
# 出片指令不再挂在方案卡上,改为存成待确认的 pending prompt。
self.conversation.refresh_from_db()
prompt = get_pending_video_prompt(self.conversation)
self.assertIn("【剧情反转方向·强制执行·最高优先级】", prompt)
self.assertIn("邻居误以为", prompt)
self.assertIn("误会当场解除", prompt)
@@ -1533,9 +1640,9 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
product = Product.objects.create(team=self.team, created_by=self.user, title="舒缓面霜")
self.conversation.mode = CreationConversation.Mode.VIDEO
self.conversation.preset = "痛点解决演示"
self.conversation.memory = {"person_source_ready": True}
self.conversation.pinned_refs = [{"type": "product", "id": str(product.id), "name": product.title}]
self.conversation.save(update_fields=["mode", "preset", "memory", "pinned_refs", "updated_at"])
self.conversation.save(update_fields=["mode", "preset", "pinned_refs", "updated_at"])
self._pin_person()
fake = FakeProvider([_text_chunks(
"可以从下面三个方向选一个:\n"
"- 换季干燥紧绷,正常涂抹后保持舒适不拔干\n"
@@ -1586,10 +1693,10 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertNotIn("generate_image", names)
def test_strategy_card_stops_with_step_confirm(self):
self._pin_person()
self.conversation.memory = {
"selling_point_ready": True,
"selling_point_mode": "auto",
"person_source_ready": True,
}
self.conversation.save(update_fields=["memory", "updated_at"])
fake = FakeProvider([
@@ -1615,6 +1722,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
def test_plain_strategy_prose_is_recovered_as_strategy_card(self):
self._pin_product("小熊婴儿湿巾")
self._pin_person()
self.conversation.preset = "痛点解决演示"
self.conversation.memory = {
"selling_point_ready": True,
@@ -1622,7 +1730,6 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
"selling_point": "温和不刺激",
"pain_point_direction_ready": True,
"pain_point_direction": "温和不刺激",
"person_source_ready": True,
}
self.conversation.save(update_fields=["preset", "memory", "updated_at"])
fake = FakeProvider([_text_chunks(
@@ -1667,8 +1774,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertEqual(confirm["payload"].get("step"), "strategy")
def test_selling_point_is_confirmed_before_strategy(self):
self.conversation.memory = {"person_source_ready": True}
self.conversation.save(update_fields=["memory", "updated_at"])
self._pin_person()
fake = FakeProvider([
_tool_chunks("write_strategy", {"target": "油皮通勤人群", "trust": "真实使用反馈",
"belief": "值得一试", "direction": "达人 UGC 口播"}),
@@ -1710,6 +1816,214 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
# 方案闸门停下,不能同轮出 Prompt/积分卡
self.assertEqual(len(fake.calls), 1)
def test_120_second_plan_accepts_two_second_timeline_slack(self):
self.conversation.params = {**self.conversation.params, "duration": "120 秒"}
self.conversation.memory = {"stage": "strategy", "strategy_confirmed": True, "person_source_ready": True}
self.conversation.save(update_fields=["params", "memory", "updated_at"])
fake = FakeProvider([
_tool_chunks("write_plan", self._plan_args(
timeline=[
{"start": 0, "end": 60, "stage": "第一章"},
{"start": 60, "end": 118, "stage": "第二章"},
],
video_prompt="总时长:120秒。第一章 0-60 秒建立信任,第二章 60-118 秒完成使用证据和收束。",
)),
_text_chunks("不该继续"),
])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
events = _events(stream_creation_agent(
conversation=self.conversation,
user=self.user,
text="按这个继续",
model_config=self.model,
))
kinds = [event["message"]["kind"] for event in events if event.get("type") == "message"]
self.assertIn("plan", kinds)
self.assertEqual(len(fake.calls), 1)
def test_120_second_plan_tool_asks_for_compact_chapter_draft(self):
self.conversation.params = {**self.conversation.params, "duration": "120 秒"}
self.conversation.save(update_fields=["params", "updated_at"])
context = AgentContext(conversation=self.conversation, user=self.user, model_config=self.model)
tools = {item["function"]["name"]: item["function"] for item in tool_schemas(context, allow_plan=True)}
self.assertIn("8001400", tools["write_plan"]["description"])
self.assertIn("不要套 15 秒短片的制作级长文模板", tools["write_plan"]["description"])
self.assertIn("只补一段简短全片规则", tools["write_prompt"]["description"])
system = build_system_prompt(context)
self.assertIn("紧凑章节稿", system)
self.assertNotIn("18003200", system)
def test_120_second_plan_rejects_incomplete_timeline(self):
self.conversation.params = {**self.conversation.params, "duration": "120 秒"}
self.conversation.memory = {"stage": "strategy", "strategy_confirmed": True, "person_source_ready": True}
self.conversation.save(update_fields=["params", "memory", "updated_at"])
fake = FakeProvider([
_tool_chunks("write_plan", self._plan_args(
timeline=[{"start": 0, "end": 90, "stage": "第一章"}],
video_prompt="只写到 90 秒的半成品脚本",
)),
_text_chunks("我补一版完整时间轴"),
])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
events = _events(stream_creation_agent(
conversation=self.conversation,
user=self.user,
text="按这个继续",
model_config=self.model,
))
kinds = [event["message"]["kind"] for event in events if event.get("type") == "message"]
self.assertNotIn("plan", kinds)
def test_model_call_lifts_max_output_tokens(self):
"""豆包默认只给 4k 输出,长方案会被截断成坏 JSON。请求必须显式抬高上限。"""
self.conversation.memory = {"stage": "strategy", "strategy_confirmed": True, "person_source_ready": True}
self.conversation.save(update_fields=["memory", "updated_at"])
fake = FakeProvider([_tool_chunks("write_plan", self._plan_args())])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
_events(stream_creation_agent(
conversation=self.conversation,
user=self.user,
text="按这个继续",
model_config=self.model,
))
extra_body = fake.calls[0]["extra_body"]
self.assertEqual(extra_body["max_tokens"], creation_agent_max_output_tokens())
self.assertGreaterEqual(extra_body["max_tokens"], 16000)
self.assertTrue(extra_body["tools"])
def _volcano_text_model(self):
volcano, _ = ModelProvider.objects.get_or_create(
name="volcengine", defaults={"display_name": "火山方舟", "base_url": "https://ark"}
)
model, _ = ModelConfig.objects.get_or_create(
provider=volcano,
name="doubao-seed-2-1-pro-260628",
capability=ModelConfig.Capability.TEXT,
defaults={"display_name": "Seed 2.1 Pro", "endpoint": "chat/completions"},
)
return model
def test_thinking_is_disabled_by_default_and_never_hits_gateways(self):
"""深度思考实测要多花 100 秒才开始吐正文,编排 agent 默认关掉;
thinking 是火山私有字段中转站一律不下发"""
official = self._volcano_text_model()
self.assertEqual(creation_model_extra_body(official, [])["thinking"], {"type": "disabled"})
self.assertNotIn("thinking", creation_model_extra_body(self.model, []))
with override_settings(CREATION_AGENT_THINKING_MODE=""):
self.assertNotIn("thinking", creation_model_extra_body(official, []))
def test_model_rejecting_thinking_param_retries_without_it(self):
"""模型不认这个档位时自动去掉重试,不能整条会话吐「生成过程出错了」。"""
self.conversation.memory = {"stage": "strategy", "strategy_confirmed": True, "person_source_ready": True}
self.conversation.save(update_fields=["memory", "updated_at"])
class PickyProvider(FakeProvider):
def chat_completion_stream(self, **kwargs):
if "thinking" in (kwargs.get("extra_body") or {}):
raise requests.HTTPError(
"400 from chat/completions: "
'{"error":{"code":"InvalidParameter",'
'"message":"Unsupported thinking type for the current model: auto"}}'
)
return super().chat_completion_stream(**kwargs)
fake = PickyProvider([_tool_chunks("write_plan", self._plan_args())])
with override_settings(CREATION_AGENT_THINKING_MODE="auto"), \
patch("apps.ai.creation_agent.build_provider", return_value=fake):
events = _events(stream_creation_agent(
conversation=self.conversation,
user=self.user,
text="按这个继续",
model_config=self._volcano_text_model(),
))
kinds = [event["message"]["kind"] for event in events if event.get("type") == "message"]
self.assertIn("plan", kinds)
self.assertNotIn("error", kinds)
self.assertNotIn("thinking", fake.calls[-1]["extra_body"])
def test_truncated_plan_arguments_ask_for_a_compact_rewrite(self):
"""被 max_tokens 砍断的 tool 参数不是「模型没写」。要指名截断并让它写紧凑版,而不是原样重试到超时。"""
self.conversation.params = {**self.conversation.params, "duration": "120 秒"}
self.conversation.memory = {"stage": "strategy", "strategy_confirmed": True, "person_source_ready": True}
self.conversation.save(update_fields=["params", "memory", "updated_at"])
fake = FakeProvider([
_truncated_tool_chunks("write_plan", self._plan_args(
timeline=[{"start": 0, "end": 60, "stage": "第一章"}, {"start": 60, "end": 120, "stage": "第二章"}],
video_prompt="写到一半就被砍掉的残稿" * 200,
)),
_tool_chunks("write_plan", self._plan_args(
timeline=[{"start": 0, "end": 60, "stage": "第一章"}, {"start": 60, "end": 120, "stage": "第二章"}],
video_prompt="第一章 0-60 秒 关键镜头…第二章 60-120 秒 关键镜头…",
)),
])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
events = _events(stream_creation_agent(
conversation=self.conversation,
user=self.user,
text="按这个继续",
model_config=self.model,
))
# FakeProvider 记录的是同一个 messages 列表引用,只能看最终态
history = json.dumps(fake.calls[-1]["messages"], ensure_ascii=False)
self.assertIn("截断", history)
self.assertIn("紧凑", history)
# 坏 JSON 不进历史,否则模型会照着残稿续写
self.assertNotIn("写到一半就被砍掉的残稿", history)
self.assertEqual(len(fake.calls), 2)
kinds = [event["message"]["kind"] for event in events if event.get("type") == "message"]
self.assertIn("plan", kinds)
self.assertNotIn("error", kinds)
def test_repeated_truncation_stops_with_a_plain_notice(self):
self.conversation.memory = {"stage": "strategy", "strategy_confirmed": True, "person_source_ready": True}
self.conversation.save(update_fields=["memory", "updated_at"])
fake = FakeProvider([
_truncated_tool_chunks("write_plan", self._plan_args(video_prompt="太长了…" * 300))
for _ in range(4)
])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
events = _events(stream_creation_agent(
conversation=self.conversation,
user=self.user,
text="按这个继续",
model_config=self.model,
))
errors = [
event["message"]["text"]
for event in events
if event.get("type") == "message" and event["message"].get("kind") == "error"
]
self.assertEqual(errors, [TRUNCATION_GIVE_UP_NOTICE])
# 只重写 MAX_TRUNCATION_RETRIES 次就收手,不再闷着重试到整轮超时
self.assertEqual(len(fake.calls), MAX_TRUNCATION_RETRIES + 1)
def test_long_video_timeout_keeps_confirm_and_explains_wait(self):
self.conversation.params = {**self.conversation.params, "duration": "120 秒"}
self.conversation.save(update_fields=["params", "updated_at"])
append_step_confirm(self.conversation, "strategy")
class TimeoutProvider:
def chat_completion_stream(self, **kwargs):
raise TimeoutError("creation agent model deadline exceeded")
with patch("apps.ai.creation_agent.build_provider", return_value=TimeoutProvider()):
events = _events(stream_creation_agent(
conversation=self.conversation,
user=self.user,
text="按这个继续",
model_config=self.model,
))
errors = [
event["message"]["text"]
for event in events
if event.get("type") == "message" and event["message"].get("kind") == "error"
]
self.assertTrue(errors)
self.assertIn("还没写完", errors[0])
self.assertIn("按这个继续", errors[0])
def test_click_swap_requires_sequence_before_calling_model(self):
self._pin_product("三色通勤包")
self.conversation.preset = "点击换款"
@@ -1739,7 +2053,8 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.conversation.memory = {
"click_swap_ready": True,
"click_swap_sequence": "黑色 → 白色 → 樱花粉",
"person_source_ready": True,
# 形态是硬闸门:不选「只出手 / 角色换款」,write_plan 会先弹形态卡而不是出方案。
"click_swap_mode": "finger",
}
self.conversation.save(update_fields=["preset", "memory", "updated_at"])
fake = FakeProvider([_tool_chunks("write_plan", self._plan_args(
@@ -1814,10 +2129,10 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
"""模型若同轮连调 write_strategy+write_plan,只落策略闸门。"""
from apps.ai.creation_agent import _parse_arguments # noqa: F401
self._pin_person()
self.conversation.memory = {
"selling_point_ready": True,
"selling_point_mode": "auto",
"person_source_ready": True,
}
self.conversation.save(update_fields=["memory", "updated_at"])
@@ -1950,7 +2265,8 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertIn("已添加多位角色时保留全部角色", system)
self.assertNotIn("固定一位可信的真人", system)
def test_commerce_preset_requests_product_and_does_not_treat_raw_asset_as_product(self):
def test_commerce_preset_asks_brand_and_name_for_an_uploaded_product_image(self):
"""上传图现在可以直接当商品图用,但品牌和具体品名必须先问清楚,不许平台自己猜。"""
material = Asset.objects.create(
team=self.team,
created_by=self.user,
@@ -1976,8 +2292,9 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
if event.get("type") == "message" and event["message"]["kind"] == "elicit"
)
self.assertEqual(card["payload"].get("interaction"), "chat")
self.assertEqual(card["payload"].get("phase"), "gate")
self.assertEqual(card["payload"]["pending_fields"][0]["asset_types"], ["product"])
self.assertEqual(card["payload"].get("topic"), "product_info")
self.assertEqual(card["payload"]["fields"][0]["key"], "product_brand_and_name")
self.assertIn("品牌", card["text"])
self.assertEqual(fake.calls, [])
def test_sixty_second_segments_reuse_full_prompt_and_same_person_reference(self):
@@ -2051,7 +2368,85 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertEqual(params["prompt"].count("字幕"), 1)
self.assertIn("【人物一致性硬约束】", params["prompt"])
self.assertIn("【预设执行层·达人口播种草】", params["prompt"])
self.assertIn("必须继续使用参考图锁定的原有人物阵容与身份关系", params["prompt"])
self.assertIn("所有分段必须复用完全相同的参考图编号、人物阵容", params["prompt"])
self.assertLessEqual(params["seed"], 2147483647)
def test_conversation_id_seed_fits_seedance_int32(self):
"""用户这次翻车:会话 id e5e74700… 算出 seed=3857139456,火山 r2v 直接拒。"""
import uuid
from apps.ai.free_video import VIDEO_SEED_MAX, normalize_video_seed
portrait = Asset.objects.create(
team=self.team,
created_by=self.user,
name="周野",
asset_type=Asset.Type.IMAGE,
source=Asset.Source.UPLOAD,
category=Asset.Category.MODEL_PORTRAIT,
)
AssetFile.objects.create(
asset=portrait, object_key="p.jpg", bucket="test",
preview_url="https://cdn.example/p.jpg", is_primary=True,
)
model = AssetModel.objects.create(
team=self.team, created_by=self.user, name="阳光运动 · 周野",
portrait_asset=portrait,
)
self.conversation.pinned_refs = [{"type": "model", "id": str(model.id), "name": model.name}]
self.conversation.save(update_fields=["pinned_refs", "updated_at"])
CreationConversation.objects.filter(pk=self.conversation.pk).update(
id=uuid.UUID("e5e74700-ef19-49fb-bcb2-c156da27c05c")
)
self.conversation = CreationConversation.objects.get(pk="e5e74700-ef19-49fb-bcb2-c156da27c05c")
context = AgentContext(conversation=self.conversation, user=self.user, model_config=self.model)
submit, _refs = _video_submit_params(context, "闺蜜日常带货")
raw = int("e5e74700", 16)
self.assertEqual(raw, 3857139456)
self.assertEqual(submit["seed"], normalize_video_seed(raw))
self.assertLessEqual(submit["seed"], VIDEO_SEED_MAX)
@override_settings(FREE_VIDEO_MAX_CONCURRENT=3)
def test_180_second_video_starts_in_batches_and_keeps_all_six_segments(self):
self.conversation.params = {**self.conversation.params, "duration": "180 秒"}
self.conversation.save(update_fields=["params", "updated_at"])
card = append_message(
self.conversation,
role="assistant",
kind=CreationMessage.Kind.CONFIRM,
payload={"video_prompt": "三章连续叙事,角色用同一件商品解决问题", "submitted": False},
)
tasks = [
AITask.objects.create(
team=self.team,
created_by=self.user,
task_type=AITask.Type.FREE_VIDEO,
model_config=self.model,
idempotency_key=f"k-180-batch-{index}",
status=AITask.Status.SUCCEEDED,
)
for index in range(1, 4)
]
with patch("apps.ai.free_video.submit_free_video", side_effect=tasks) as submit:
message, error = submit_confirmed_video(
conversation=self.conversation,
user=self.user,
confirm_message=card,
)
self.assertEqual(error, "")
self.assertEqual(submit.call_count, 3)
self.assertEqual(len(message.payload["segments"]), 6)
self.assertEqual(len(message.payload["task_ids"]), 3)
self.assertEqual([item["duration"] for item in message.payload["segments"]], [30] * 6)
self.assertTrue(all(item.get("task_id") for item in message.payload["segments"][:3]))
self.assertTrue(all(not item.get("task_id") for item in message.payload["segments"][3:]))
self.assertEqual(message.payload["generation_spec"]["duration"], 180)
prompts = [call.kwargs["params"]["prompt"] for call in submit.call_args_list]
self.assertIn("第 1/6 段", prompts[0])
self.assertIn("第 3/6 段", prompts[2])
self.assertIn("商品只按已知真实用途", message.payload["generation_spec"]["prompt"])
def test_plan_without_video_prompt_is_rejected_without_emitting_cards(self):
fake = FakeProvider([
@@ -2111,7 +2506,8 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertEqual(error, "")
self.assertIsNotNone(message)
self.assertIn("【视频预设】多色商品换款", prompt)
self.assertIn("【预设执行层·点击换款·强制】", prompt)
# 出片约束按换款方式分成「只出手」和「角色日常换款」两版;没选角色就是只出手。
self.assertIn("【预设执行层·点击换款·只出手】", prompt)
self.assertIn("手指轻触/点击", prompt)
self.assertIn("不演剧情", prompt)
self.assertIn("干净 match cut", prompt)
@@ -2182,8 +2578,9 @@ class VideoParamParsingTests(TestCase):
self.assertEqual(video_duration({"duration": "15 秒"}), 15)
self.assertEqual(video_duration({"duration": "智能时长"}), SMART_DURATION)
self.assertEqual(video_duration({}), SMART_DURATION)
# 当前视频工作流支持完整短剧,超出预设上限时仍需夹住。
self.assertEqual(video_duration({"duration": "99 秒"}), 60)
# 当前视频工作流支持最长 180 秒,超出预设上限时仍需夹住。
self.assertEqual(video_duration({"duration": "99 秒"}), 99)
self.assertEqual(video_duration({"duration": "999 秒"}), 180)
def test_smart_duration_uses_final_plan_timestamp(self):
timeline = [
@@ -2195,6 +2592,15 @@ class VideoParamParsingTests(TestCase):
self.assertEqual(infer_script_duration(timeline=timeline), 20)
self.assertEqual(video_duration({"duration": "智能时长"}, timeline=timeline), 20)
def test_smart_duration_can_resolve_to_180_seconds(self):
timeline = [
{"start": 0, "end": 60, "stage": "第一章"},
{"start": 60, "end": 120, "stage": "第二章"},
{"start": 120, "end": 180, "stage": "第三章"},
]
self.assertEqual(infer_script_duration(timeline=timeline), 180)
self.assertEqual(video_duration({"duration": "智能时长"}, timeline=timeline), 180)
def test_age_range_is_not_mistaken_for_video_duration(self):
prompt = (
"人声是18-22岁软甜少女音,语气自然。\n"
@@ -2214,6 +2620,26 @@ class VideoParamParsingTests(TestCase):
)
self.assertEqual(plan_video_segments(60)[0]["duration"], 30)
self.assertEqual(plan_video_segments(60)[1]["duration"], 30)
self.assertEqual(
[(item["start"], item["end"], item["duration"]) for item in plan_video_segments(180)],
[(0, 30, 30), (30, 60, 30), (60, 90, 30), (90, 120, 30), (120, 150, 30), (150, 180, 30)],
)
def test_timeout_notice_only_points_at_a_button_that_exists(self):
self.assertIn("约 3 分钟", creation_agent_timeout_notice(180))
self.assertIn("约 7 分钟", creation_agent_timeout_notice(420))
self.assertIn("按这个继续", creation_agent_timeout_notice(180, has_open_gate=True))
# 没有待确认的步骤卡时页面上并没有这个按钮,不能让用户去点找不到的东西
self.assertNotIn("按这个继续", creation_agent_timeout_notice(180))
self.assertIn("再发一次", creation_agent_timeout_notice(180))
def test_long_video_script_allows_two_second_slack(self):
self.assertTrue(long_video_script_covers_requested_duration(118, 120))
self.assertTrue(long_video_script_covers_requested_duration(120, 120))
self.assertTrue(long_video_script_covers_requested_duration(180, 180))
self.assertFalse(long_video_script_covers_requested_duration(None, 120))
self.assertFalse(long_video_script_covers_requested_duration(90, 120))
self.assertFalse(long_video_script_covers_requested_duration(150, 120))
def test_person_identity_guard_uses_real_reference_indexes(self):
prompt = apply_person_identity_guard("base", [
@@ -2227,6 +2653,18 @@ class VideoParamParsingTests(TestCase):
self.assertIn("人物不得互换、遗漏", prompt)
self.assertNotIn("必须保持同一人", prompt)
def test_product_reference_guard_keeps_skus_independent(self):
prompt = apply_product_reference_guard("base", [
{"type": "model", "label": "女主"},
{"type": "product", "label": "黑色耳机"},
{"type": "product", "label": "黑色耳机三视图"},
{"type": "product", "label": "白色耳机"},
])
self.assertIn("参考图2=黑色耳机", prompt)
self.assertIn("参考图4=白色耳机", prompt)
self.assertIn("不得把多张商品图融合成新商品", prompt)
self.assertIn("不同名称代表相互独立的商品或 SKU", prompt)
def test_model_label_maps_to_volcano_name(self):
self.assertEqual(video_model_name({"model": "Seedance 2.0 Fast"}), "doubao-seedance-2-0-fast-260128")
self.assertEqual(video_model_name({"model": "没见过的模型"}), DEFAULT_VIDEO_MODEL)
@@ -2438,9 +2876,14 @@ class PresetGuidanceTests(CreationAgentBaseTests):
"""预设不只是个名字,要把拍法约束一起给模型(契约 §6)。"""
def test_preset_guidance_reaches_the_system_prompt(self):
person = Asset.objects.create(
team=self.team, created_by=self.user, name="换装模特",
asset_type=Asset.Type.IMAGE, category=Asset.Category.PERSON,
)
conversation = CreationConversation.objects.create(
team=self.team, created_by=self.user, mode="video", preset="鱼眼换装", params={},
memory={"person_source_ready": True},
# 鱼眼换装必定要人物;没钉住人会先弹人物来源闸门,这一轮压根不会调模型。
pinned_refs=[{"type": "character", "id": str(person.id), "name": person.name}],
)
fake = FakeProvider([_text_chunks("")])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
@@ -2522,7 +2965,8 @@ class PresetGuidanceTests(CreationAgentBaseTests):
)
self.assertTrue(prompt.startswith("【视频预设】点击换款"))
self.assertIn("【预设执行层·点击换款·强制】", prompt)
# 未选换款方式时出片走「只出手」那一版约束。
self.assertIn("【预设执行层·点击换款·只出手】", prompt)
self.assertIn("原始商品与 SKU 信息", prompt)
self.assertIn("冲突的拍法一律忽略", prompt)
self.assertIn("换款由手指点击触发", prompt)
@@ -2555,7 +2999,9 @@ class PresetGuidanceTests(CreationAgentBaseTests):
"旁白:今天给大家介绍这款猫粮,超好吃。",
)
self.assertIn("宠物拟人台词", prompt)
self.assertNotIn("今天给大家介绍", prompt)
# 只看被改写的正文:约束段本身会引用「今天给大家介绍/测评」当反例,不算漏改。
body = prompt.split("【宠物拟人台词")[0]
self.assertNotIn("今天给大家介绍", body)
self.assertIn("我今天发现", prompt)
self.assertEqual(apply_pet_dialogue_guard(conversation, prompt), prompt)
@@ -2608,6 +3054,39 @@ class PresetGuidanceTests(CreationAgentBaseTests):
self.assertIn("已穿着", prompt)
self.assertEqual(apply_clothing_video_guard(conversation, prompt), prompt)
def test_clothing_guard_is_not_triggered_by_character_wardrobe(self):
"""卖的是精华液,人物穿的针织衫/阔腿裤不该把整条片判成服装视频。
误判之后每轮还会再叠一段穿衣禁令最终提示词里出现四五份"""
from .creation_agent import CLOTHING_GUARD_MARKER, apply_clothing_video_guard
conversation = CreationConversation.objects.create(
team=self.team, created_by=self.user, mode="video", preset="达人口播种草", params={},
pinned_refs=[{"type": "product", "id": "1", "name": "焕亮修护精华液", "category": "护肤"}],
)
prompt = (
"林晚穿燕麦色坑条针织衫配米白阔腿裤,周野穿浅灰字母卫衣,"
"三人围在梳妆台前试用精华液。"
)
once = apply_clothing_video_guard(conversation, prompt)
self.assertNotIn(CLOTHING_GUARD_MARKER, once)
self.assertEqual(once, prompt)
def test_clothing_guard_stays_single_across_repeated_passes(self):
"""改写规则会动到约束段自身,按原文去重会失效 —— 反复调用必须仍然只有一段。"""
from .creation_agent import CLOTHING_GUARD_MARKER, apply_clothing_video_guard
conversation = CreationConversation.objects.create(
team=self.team, created_by=self.user, mode="video", preset="达人口播种草", params={},
pinned_refs=[{"type": "product", "id": "1", "name": "法式收腰连衣裙", "category": "女装"}],
)
prompt = apply_clothing_video_guard(conversation, "0-3秒:女主把这件衣服穿上。")
for _ in range(3):
prompt = apply_clothing_video_guard(conversation, prompt)
self.assertEqual(prompt.count(CLOTHING_GUARD_MARKER), 1)
def test_clothing_video_guard_skips_non_apparel(self):
from .creation_agent import apply_clothing_video_guard, CLOTHING_VIDEO_NO_DRESSING_GUARD
@@ -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
CreditLedgermodel_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 NULLNOT(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",
+17 -9
View File
@@ -400,11 +400,13 @@ def _auto_pick_product_continuation(conversation: CreationConversation, request_
_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": (
@@ -419,12 +421,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 整理一版修订指令;"
"完成后给出积分确认卡,不要直接出片。"
"长视频不要把逐镜再扩写成短片密度。完成后给出积分确认卡,不要直接出片。"
),
}
@@ -1147,9 +1149,15 @@ def _free_video_task_queryset(team):
)
def _not_omni_create_q() -> Q:
"""「不是全能创作」。同样不能写成 exclude(request_payload__feature="omni_create")——
JSON 键缺失时比较结果是 NULLexclude 会把没写 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)
@@ -1168,13 +1176,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")
)
@@ -1875,7 +1883,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
+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 送审的上传视频临时图也会是 processingworker 必须盯到绿/
反过来没有远端 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 NULLNOT(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)
+27 -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/",
{
@@ -1375,6 +1376,10 @@ class QuickCreateCoordinatorTests(TestCase):
-1
View File
@@ -934,7 +934,6 @@ export function App() {
<ConfirmModal
open={sessionInvalidated}
title="当前账号已在其他设备登录"
subtitle="// SESSION REPLACED"
icon={<MonitorOff size={16} />}
detail="为保护账号安全,当前设备的登录已失效。点击确认后返回登录页面。"
confirmText="确认并返回登录"
+20 -5
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,7 +1231,7 @@ export const adminApi = {
const q = qs.toString();
return request<Paginated<AdminUser>>(`/api/admin/users/${q ? `?${q}` : ""}`);
},
createUser(payload: { username: string; password: string; initial_credits?: string }) {
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 }) {
@@ -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 {
// 3160 秒不是单次 API 时长,而是平台会自动拆成 <=30 秒的两段
// 只有 Seedance 2.5(或目录里声明了 30 秒能力的模型)可选这总时长。
// 3160 秒总时长会拆成 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 !== "智能时长") || "智能时长"));
}
+56
View File
@@ -351,3 +351,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;
}
+82 -13
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 {
@@ -1247,6 +1256,10 @@
padding: 10px;
}
.omni-result-card.has-many .omni-result-media.is-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.omni-result-tile {
position: relative;
margin: 0;
@@ -1433,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%;
}
@@ -2228,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;
@@ -2250,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;
@@ -2408,7 +2438,7 @@
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
color: #5c6570;
color: var(--black-alpha-64);
}
.omni-chat-bubble.is-reasoning {
@@ -2416,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 {
@@ -2424,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 {
@@ -2463,6 +2527,11 @@
40% { opacity: 1; transform: translateY(-2px); }
}
@keyframes omniCurrentStepPulse {
0%, 100% { opacity: .32; }
50% { opacity: 1; }
}
@keyframes omniSendPulse {
0%, 100% {
background: #5b6572;
@@ -250,6 +250,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
// 直接开户:不发邀请码,后端建号时自动配一个个人团队当钱包(团队体系保留,后台只按用户视角管)
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);
@@ -307,6 +308,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
function closeCreate() {
setCreateOpen(false);
setNewName("");
setNewLogin("");
setNewPwd("");
setNewCredits("");
}
@@ -314,14 +316,24 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
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: name, password: newPwd.trim(), initial_credits: credits || "0" });
notify("success", `已创建用户 ${name}`);
await adminApi.createUser({
username: login,
display_name: name,
password: newPwd.trim(),
initial_credits: credits || "0",
});
notify("success", `已创建用户 ${name}${login}`);
closeCreate();
setPage(1);
await load();
@@ -375,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 ? (
@@ -386,15 +398,16 @@ 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></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>
@@ -436,10 +449,25 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
<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">,,</p>
<p className="admin-modal-desc">,;+ 6 ,</p>
<div className="field">
<label className="field-label" htmlFor="new-username"> <span className="req">*</span></label>
<input id="new-username" className="input" type="text" placeholder="登录用的用户名" value={newName} onChange={(e) => setNewName(e.target.value)} />
<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>
-4
View File
@@ -435,7 +435,6 @@ const MODE_META: Record<
WorkMode,
{
title: string;
tag: string;
desc: string;
ratio: string;
promptTemplate: (productTitle: string) => string;
@@ -443,21 +442,18 @@ const MODE_META: Record<
> = {
image: {
title: "自由创作",
tag: "[ IMAGE · STUDIO ]",
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>
+291 -138
View File
@@ -147,6 +147,20 @@ function stripNumericReplyInstruction(text: string): string {
.trim();
}
/** 临时隐藏 >60 秒长视频入口:选项文案里带秒数且超过 60 的不展示。 */
const OMNI_VISIBLE_MAX_DURATION = 60;
function durationSecondsFromLabel(label: string): number | null {
const match = String(label || "").match(/(\d+(?:\.\d+)?)\s*秒/);
if (!match) return null;
const n = Number(match[1]);
return Number.isFinite(n) ? n : null;
}
function isHiddenLongVideoOption(label: string): boolean {
const seconds = durationSecondsFromLabel(label);
return seconds != null && seconds > OMNI_VISIBLE_MAX_DURATION;
}
function numberedReplyOptions(text: string): ReplyOption[] {
const matches = [...(text || "").matchAll(/(?:^|\n)\s*([1-3])[.、.]\s*(?:\*\*)?([^\n*]{1,80})/g)];
if (matches.length < 2) return [];
@@ -314,12 +328,13 @@ function ReplyActions({
setCustomIdea("");
};
const visibleOptions = options.filter((option) => !isHiddenLongVideoOption(option.label) && !isHiddenLongVideoOption(option.text));
return (
<div className="omni-reply-guide-options">
{options.map((option, index) => (
{visibleOptions.map((option, index) => (
<button
type="button"
className={index === 0 && options.length < 3 ? "primary" : ""}
className={index === 0 && visibleOptions.length < 3 ? "primary" : ""}
key={option.text}
disabled={disabled}
onClick={() => {
@@ -537,6 +552,18 @@ function messageTime(createdAt: string) {
});
}
function mentionHoverPlacement(chip: HTMLElement): "above" | "below" {
const rect = chip.getBoundingClientRect();
const previewNeed = 300;
const topbarRaw = getComputedStyle(document.documentElement).getPropertyValue("--topbar-height").trim();
const topbarH = Number.parseFloat(topbarRaw) || 116;
// 往上展开会伸进顶栏区域时改往下开,避免被 sticky 顶栏压住
const wouldHitTopbar = rect.top - previewNeed < topbarH;
if (!wouldHitTopbar) return "above";
const spaceBelow = window.innerHeight - rect.bottom;
return spaceBelow >= Math.min(previewNeed, 160) ? "below" : "above";
}
function MentionChips({
refs,
tone = "user",
@@ -547,6 +574,8 @@ function MentionChips({
onRemove?: (id: string) => void;
}) {
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
const [hoverKey, setHoverKey] = useState<string>("");
const [hoverDir, setHoverDir] = useState<"above" | "below">("above");
if (!refs.length) return null;
return (
<>
@@ -554,8 +583,19 @@ function MentionChips({
{refs.map((ref) => {
const cover = (ref.cover || "").trim();
const label = shortRefName(ref.name) || REF_CHIP_LABEL[ref.type] || ref.type;
const chipKey = `${ref.type}-${ref.id}`;
const openBelow = hoverKey === chipKey && hoverDir === "below";
return (
<span className={`omni-mention-chip${cover ? " has-thumb" : ""}`} key={`${ref.type}-${ref.id}`}>
<span
className={`omni-mention-chip${cover ? " has-thumb" : ""}${openBelow ? " is-preview-below" : ""}`}
key={chipKey}
onMouseEnter={(event) => {
if (!cover) return;
setHoverKey(chipKey);
setHoverDir(mentionHoverPlacement(event.currentTarget));
}}
data-chip-key={chipKey}
>
{cover ? (
<>
<button
@@ -563,6 +603,12 @@ function MentionChips({
className="omni-mention-thumb"
aria-label={`预览 ${label}`}
onClick={() => setPreview({ src: cover, name: label })}
onFocus={(event) => {
const chip = event.currentTarget.closest(".omni-mention-chip") as HTMLElement | null;
if (!chip) return;
setHoverKey(chipKey);
setHoverDir(mentionHoverPlacement(chip));
}}
>
<img src={cover} alt={label} />
</button>
@@ -1646,7 +1692,7 @@ function ElicitCard({
{ value: "poor_absorption", label: "护肤浮在表面吸收慢,黏腻厚重不透气" },
]
: [];
const choiceField = rawChoiceField || (
const choiceFieldRaw = rawChoiceField || (
fallbackOptions.length > 0
? {
key: fields[0]?.key || "pain_point_direction",
@@ -1656,6 +1702,16 @@ function ElicitCard({
}
: null
);
const choiceField = choiceFieldRaw
? {
...choiceFieldRaw,
options: (choiceFieldRaw.options || []).filter((option: any) => {
const label = typeof option === "string" ? option : String(option?.label || option?.text || option?.value || "");
const value = typeof option === "string" ? option : String(option?.value || option?.label || "");
return !isHiddenLongVideoOption(label) && !isHiddenLongVideoOption(value);
}),
}
: null;
const savedChoice = choiceField ? String(saved[choiceField.key] || "") : "";
const savedChoiceLabel = (choiceField?.options as any[])?.find((option: any) => {
const val = typeof option === "string" ? option : option?.value;
@@ -1688,7 +1744,7 @@ function ElicitCard({
{(choiceField.options || []).map((option: any, index: number) => {
const label = typeof option === "string" ? option : String(option?.label || option?.text || option?.title || option?.value || `选项 ${index + 1}`).trim();
const value = typeof option === "string" ? option : String(option?.value || option?.label || option?.text || option?.title || `option_${index + 1}`).trim();
if (!label) return null;
if (!label || isHiddenLongVideoOption(label) || isHiddenLongVideoOption(value)) return null;
return (
<button
type="button"
@@ -1866,6 +1922,7 @@ function ConfirmCard({
catalogModels,
disabled,
onConfirm,
generationPhase = "idle",
}: {
message: CreationMessage;
sessionParams: Record<string, string>;
@@ -1873,8 +1930,10 @@ function ConfirmCard({
catalogModels?: ModelConfig[];
disabled: boolean;
onConfirm: (params: Record<string, string>) => void;
/** idle=未点确认;submitted=已点还没出片消息;running=生成中;done=下方已有成片;failed=出片失败 */
generationPhase?: "idle" | "submitted" | "running" | "done" | "failed";
}) {
const submitted = Boolean(message.payload.submitted);
const submitted = Boolean(message.payload.submitted) || generationPhase !== "idle";
const payloadCredits = Number(message.payload.estimated_credits || 0);
const payloadParams = asStringMap(message.payload.params);
const snapshot = { ...sessionParams, ...payloadParams };
@@ -1912,11 +1971,36 @@ function ConfirmCard({
return unit * count;
})();
const title =
generationPhase === "done" ? "出片已完成"
: generationPhase === "failed" ? "出片未完成"
: generationPhase === "running" || generationPhase === "submitted" ? "已确认"
: `即将用 ${summary} 生成`;
const subtitle =
generationPhase === "done" ? "成片见下方"
: generationPhase === "failed" ? "可重新确认方案后再生成"
: generationPhase === "running" ? "正在出片,请稍候"
: generationPhase === "submitted" ? "正在提交生成"
: "确认前可以改参数。改时长会按新时长重写脚本。";
const foot =
generationPhase === "done" ? "已完成出片"
: generationPhase === "failed" ? "出片失败"
: generationPhase === "running" || generationPhase === "submitted" ? "已确认,正在出片"
: "点确认后才会提交生成";
const buttonLabel =
generationPhase === "done" ? "已生成"
: generationPhase === "failed" ? "生成失败"
: generationPhase === "running" || generationPhase === "submitted"
? "生成中"
: durationChanged
? "确认并重写脚本"
: String(message.payload.label || "开始生成");
return (
<section className="omni-confirm-card">
<section className={`omni-confirm-card${generationPhase === "done" ? " is-done" : ""}`}>
<div className="omni-confirm-copy">
<strong>{submitted ? "已确认" : `即将用 ${summary} 生成`}</strong>
<span>{submitted ? "正在提交生成" : "确认前可以改参数。改时长会按新时长重写脚本。"}</span>
<strong>{title}</strong>
<span>{subtitle}</span>
</div>
{submitted ? null : (
<div className="omni-confirm-params">
@@ -1935,16 +2019,18 @@ function ConfirmCard({
/>
</div>
)}
{durationChanged ? (
{submitted ? null : durationChanged ? (
<p className="omni-confirm-hint"></p>
) : willGenerateInSegments ? (
<p className="omni-confirm-hint">{durationSeconds} 2 30 </p>
<p className="omni-confirm-hint">
{durationSeconds}
</p>
) : null}
<div className="omni-confirm-foot">
<span>{submitted ? "已确认,正在出片" : "点确认后才会提交生成"}</span>
<span>{foot}</span>
<button type="button" disabled={disabled || submitted} onClick={() => onConfirm(draft)}>
{durationChanged ? "确认并重写脚本" : String(message.payload.label || "开始生成")}
{!durationChanged && credits > 0 ? <i> {credits} </i> : null}
{buttonLabel}
{!submitted && !durationChanged && credits > 0 ? <i> {credits} </i> : null}
</button>
</div>
</section>
@@ -1953,30 +2039,35 @@ function ConfirmCard({
function ResultCard({
payload,
onMerge,
merging,
}: {
payload: Record<string, unknown>;
onMerge?: () => void;
merging?: boolean;
}) {
const assets = (payload.assets as Array<Record<string, string>> | undefined) || [];
const first = assets[0] || {};
const meta = [payload.model, payload.resolution, payload.ratio].filter(Boolean).join(" · ");
const isGeneratedVideo = first.type === "video";
const partialFailure = Boolean(payload.partial_failure);
// 全成功自动合并:分段结果卡隐藏,只展示后面的合并进度/成片。
// 局部失败才摊开已成功分段;普通单段/图片结果照常展示。
if (Boolean(payload.needs_merge) && !partialFailure) return null;
const showSegmentGrid = partialFailure && assets.length > 0;
const showSingleMedia = !showSegmentGrid && assets.length > 0;
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
const ratio = String(payload.ratio || "").trim();
const ratioClass = ratio === "16:9" ? "is-ratio-16-9" : ratio === "1:1" ? "is-ratio-1-1" : "is-ratio-9-16";
const multiClass = assets.length > 1 ? "has-multiple" : "has-single";
const tileCount = showSegmentGrid ? assets.length : showSingleMedia ? 1 : 0;
const multiClass = tileCount > 1 ? "has-multiple" : "has-single";
const manyClass = tileCount > 4 ? " has-many" : "";
const displayAssets = showSegmentGrid ? assets : showSingleMedia ? assets.slice(0, 1) : [];
return (
<section className={`omni-result-card ${multiClass} ${ratioClass}`}>
<div className={`omni-result-media${assets.length > 1 ? " is-grid" : ""}`}>
{assets.map((asset, index) => {
<section className={`omni-result-card ${multiClass}${manyClass} ${ratioClass}`}>
<div className={`omni-result-media${displayAssets.length > 1 ? " is-grid" : ""}`}>
{displayAssets.map((asset, index) => {
const cover = asset.cover || asset.url || "";
const url = asset.url || cover;
const video = asset.type === "video";
const label = String(asset.label || (assets.length > 1 ? `${index + 1}` : "生成结果"));
const label = String(asset.label || (displayAssets.length > 1 ? `${index + 1}` : "生成结果"));
return (
<figure className="omni-result-tile" key={asset.id || `${url}-${index}`}>
<button
@@ -2012,21 +2103,14 @@ function ResultCard({
<div className="omni-result-info">
<div>
<strong>{
isGeneratedVideo
? payload.needs_merge ? `生成 ${assets.length} 段视频` : "视频已生成"
: assets.length > 1 ? `已生成 ${assets.length} 张图` : "图片已生成"
partialFailure
? `保留 ${assets.length}成功视频`
: isGeneratedVideo
? "视频已生成"
: assets.length > 1 ? `已生成 ${assets.length} 张图` : "图片已生成"
}</strong>
<small>{payload.needs_merge ? "请先预览片段,确认后再合并成片" : meta}</small>
{partialFailure && payload.error ? <small>{String(payload.error)}</small> : null}
</div>
{payload.needs_merge ? (
<button
type="button"
disabled={merging || payload.merge_state === "queued" || payload.merge_state === "processing" || payload.merge_state === "completed"}
onClick={onMerge}
>
{payload.merge_state === "completed" ? "已合并" : merging || payload.merge_state === "queued" || payload.merge_state === "processing" ? "正在合并" : "合并成片"}
</button>
) : null}
</div>
<MediaLightbox
open={Boolean(preview?.src)}
@@ -2039,6 +2123,7 @@ function ResultCard({
);
}
function isLocalUserId(id: string) {
return id.startsWith("local-user-");
}
@@ -2169,6 +2254,21 @@ function turnLooksSettled(detail: CreationConversationDetail) {
return msgs.slice(userIdx + 1).some((m) => m.role !== "user");
}
/**
*
* user error
* planning
*/
function hasCurrentPlanningError(detail: CreationConversationDetail) {
const startedAt = Date.parse(detail.agent_started_at || "");
if (!Number.isFinite(startedAt)) return false;
return (detail.messages || []).some((message) => (
message.kind === "error"
&& Number.isFinite(Date.parse(message.created_at || ""))
&& Date.parse(message.created_at) >= startedAt
));
}
/** payload 是否实质相同(忽略 key 顺序之外的引用身份) */
function samePayload(a: Record<string, unknown>, b: Record<string, unknown>) {
return JSON.stringify(a || {}) === JSON.stringify(b || {});
@@ -2291,7 +2391,10 @@ function keepUnackedLocals(prev: CreationMessage[], incoming: CreationMessage[])
return collapseDupUser(leftover.length ? [...merged, ...leftover] : merged);
}
function withoutLegacyGateArtifacts(list: CreationMessage[]): CreationMessage[] {
function withoutLegacyGateArtifacts(
list: CreationMessage[],
activePlanningStartedAt?: string | null,
): CreationMessage[] {
/**
* user ,
* ,,
@@ -2319,79 +2422,62 @@ function withoutLegacyGateArtifacts(list: CreationMessage[]): CreationMessage[]
hidden.add(deadEnd.id);
}
}
// 同一会话反复重试时,旧的「整理超时」只会制造噪音。保留最近一次,
// 不删除数据库历史,用户再次操作后也不会看到两三条同义错误堆在一起。
const timeoutErrors = list.filter((message) => (
message.kind === "error"
&& /(?:方案整理.*(?:超时|超过)|整理\s*\d+\s*秒方案耗时过长)/.test(message.text || "")
));
const planningStartedMs = Date.parse(activePlanningStartedAt || "");
const visibleTimeoutErrors = Number.isFinite(planningStartedMs)
// 已开始新的整理:上一次的超时只属于历史,当前界面只应展示本轮进度。
? timeoutErrors.filter((message) => Date.parse(message.created_at || "") >= planningStartedMs)
: timeoutErrors;
for (const message of timeoutErrors) {
if (!visibleTimeoutErrors.includes(message)) hidden.add(message.id);
}
for (const message of visibleTimeoutErrors.slice(0, -1)) hidden.add(message.id);
return hidden.size ? list.filter((message) => !hidden.has(message.id)) : list;
}
function ProcessCard({
payload,
onPreview,
}: {
payload: Record<string, unknown>;
onPreview?: (src: string, kind: "image" | "video", name: string) => void;
}) {
const isSegmentedVideo = payload.kind === "video_segments";
const isMerge = payload.kind === "video_merge";
const isVideo = payload.kind === "video" || isSegmentedVideo || isMerge;
const segmentCount = Array.isArray(payload.segments) ? payload.segments.length : 0;
const assets = (payload.assets as Array<Record<string, string>> | undefined) || [];
const completedSegments = Number(payload.completed_segment_count || 0);
const waitingSegments = isSegmentedVideo ? Math.max(1, segmentCount - completedSegments) : 1;
const totalTiles = assets.length + waitingSegments;
const multiClass = totalTiles > 1 || isSegmentedVideo ? "has-multiple" : "has-single";
const ratio = String(payload.ratio || "").trim();
const ratioClass = ratio === "16:9" ? "is-ratio-16-9" : ratio === "1:1" ? "is-ratio-1-1" : "is-ratio-9-16";
return (
<section className={`omni-result-card omni-process-card ${multiClass} ${ratioClass}`}>
<div className={`omni-result-media${multiClass === "has-multiple" ? " is-grid" : ""}`}>
{assets.map((asset, index) => {
const cover = asset.cover || asset.url || "";
const url = asset.url || cover;
const video = asset.type === "video";
const label = String(asset.label || `${index + 1}`);
return (
<figure className="omni-result-tile" key={asset.id || `${url}-${index}`}>
<button
type="button"
className="omni-result-preview"
onClick={() => url && onPreview?.(url, video ? "video" : "image", label)}
>
<img src={cover} alt={label} />
{video ? (
<span className="omni-result-play" aria-hidden="true">
<Play />
</span>
) : null}
</button>
</figure>
);
})}
{Array.from({ length: waitingSegments }).map((_, index) => (
<figure className="omni-result-tile" key={`generating-${index}`}>
<div className="omni-process-frame" aria-hidden="true">
<span className="omni-process-ring" />
<strong>{isVideo ? "视频生成中" : "图片生成中"}</strong>
</div>
</figure>
))}
<section className={`omni-result-card omni-process-card has-single ${ratioClass}`}>
<div className="omni-result-media">
<figure className="omni-result-tile">
<div className="omni-process-frame" aria-hidden="true">
<span className="omni-process-ring" />
<strong>{isVideo ? "视频生成中" : "图片生成中"}</strong>
</div>
</figure>
</div>
<div className="omni-result-info">
<div>
<strong>{
isMerge
? "正在合并成片"
: isSegmentedVideo && completedSegments
? `${completedSegments} 段已生成,正在生成第 ${completedSegments + 1}`
: isSegmentedVideo
? `正在生成 ${segmentCount || 2} 段视频`
: isVideo ? "正在生成视频" : "正在出图"
? "成片收尾中"
: isSegmentedVideo
? "正在生成长视频"
: isVideo ? "正在生成视频" : "正在出图"
}</strong>
<small>{
isMerge
? "正在拼接已确认的片段"
: isSegmentedVideo && completedSegments
? "已生成的片段可以先点击预览"
: isSegmentedVideo ? "片段完成后会先展示给你确认" : isVideo ? "方案编译中,请稍候" : "画面生成中,请稍候"
? "马上就好,请稍候"
: isSegmentedVideo
? "时长较长,生成会多花一点时间,请耐心等待"
: isVideo ? "方案编译中,请稍候" : "画面生成中,请稍候"
}</small>
</div>
</div>
@@ -2460,6 +2546,17 @@ export function OmniSessionPage({
() => Boolean(getMentionFreeText(prompt, pendingRefs)),
[pendingRefs, prompt],
);
/**
* @提及会在编辑器里渲染成带缩略图的 token @
* @
*/
const looseComposerRefs = useMemo(
() => pendingRefs.filter((ref) => {
const name = shortRefName(ref.name);
return !name || !prompt.includes(`@${name}`);
}),
[pendingRefs, prompt],
);
const [sessionUploads, setSessionUploads] = useState<CreationRef[]>(firstUploads || []);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -2474,6 +2571,7 @@ export function OmniSessionPage({
/** 商品闸门点「上传商品图」后,上传完成要回填 elicit_answer,而不是只塞进输入框。 */
const productGateMessageIdRef = useRef<string | null>(null);
const composerRef = useRef<RichMentionEditorHandle>(null);
const thinkingLogRef = useRef<HTMLDivElement>(null);
const [streaming, setStreaming] = useState(false);
const [liveText, setLiveText] = useState("");
const [liveReasoning, setLiveReasoning] = useState("");
@@ -2488,7 +2586,6 @@ export function OmniSessionPage({
const [sessionAssetModalOpen, setSessionAssetModalOpen] = useState(false);
const [sessionAssetModalType, setSessionAssetModalType] = useState<AssetModalType>("product");
const [confirming, setConfirming] = useState(false);
const [mergingMessageIds, setMergingMessageIds] = useState<string[]>([]);
const [stopping, setStopping] = useState(false);
const [promptView, setPromptView] = useState<{ title: string; body: string } | null>(null);
const [assetsOpen, setAssetsOpen] = useState(false);
@@ -2600,6 +2697,10 @@ export function OmniSessionPage({
if (waitingForFreshTurn) return;
streamingRef.current = planning;
setStreaming(planning);
if (planning) {
setActiveTool(detail.agent_progress?.label || "");
setLiveReasoning(detail.agent_progress?.detail || "");
}
})
.catch((error) => notify("error", (error as Error).message));
return () => {
@@ -2692,7 +2793,21 @@ export function OmniSessionPage({
() => messages.some((message) => message.kind === "generating"),
[messages]
);
const visibleMessages = useMemo(() => withoutLegacyGateArtifacts(messages), [messages]);
const visibleMessages = useMemo(
() => withoutLegacyGateArtifacts(
messages,
conversation?.agent_status === "planning" ? conversation.agent_started_at : null,
),
[conversation?.agent_started_at, conversation?.agent_status, messages],
);
const currentPlanningHasError = Boolean(
conversation && hasCurrentPlanningError(conversation)
);
const visibleProgressHistory = conversation?.agent_progress?.history || [];
useEffect(() => {
const log = thinkingLogRef.current;
if (log) log.scrollTop = log.scrollHeight;
}, [liveReasoning, visibleProgressHistory]);
const sessionAssets = useMemo(
() => collectSessionAssets(messages, conversation?.pinned_refs || [], sessionUploads),
[messages, conversation?.pinned_refs, sessionUploads],
@@ -2825,15 +2940,7 @@ export function OmniSessionPage({
return;
}
// 本轮已落 error:不要继续挂「正在整理方案」
const msgs = detail.messages || [];
let lastUserIdx = -1;
for (let i = msgs.length - 1; i >= 0; i -= 1) {
if (msgs[i].role === "user") {
lastUserIdx = i;
break;
}
}
if (lastUserIdx !== -1 && msgs.slice(lastUserIdx + 1).some((m) => m.kind === "error")) {
if (hasCurrentPlanningError(detail)) {
stalePlanningNotifiedRef.current = false;
clearThinking();
setConversation((prev) => (prev ? { ...prev, agent_status: "idle" } : prev));
@@ -2858,6 +2965,10 @@ export function OmniSessionPage({
holdPlanningUntilRef.current = Math.max(holdPlanningUntilRef.current, Date.now() + 2500);
streamingRef.current = true;
setStreaming(true);
// 后台 Agent 不直接连着浏览器 SSE;通过轮询拿到受控进度,
// 展示「正在做什么」而非模型原始 thinking,避免长方案看起来像卡住。
setActiveTool(detail.agent_progress?.label || "");
setLiveReasoning(detail.agent_progress?.detail || "");
return;
}
@@ -2876,14 +2987,33 @@ export function OmniSessionPage({
clearThinking();
}, [conversationId, notify]);
// awaiting_user 时不要被短暂 streaming 闪回拖进 planning 轮询,否则闸门按钮会跟着灰/闪
// Agent 整理方案中才算「规划中」。后台出片/出图(hasGenerating)不占用 Agent
// 不能因为残留 streaming 把发送钮收成终止、或把输入灰掉。
const isPlanning =
conversation?.agent_status === "planning"
|| (Boolean(streaming) && conversation?.agent_status !== "awaiting_user");
|| (
Boolean(streaming)
&& conversation?.agent_status !== "awaiting_user"
&& !hasGenerating
);
// 若状态已是等待用户而 streaming 仍残留,立刻收起,避免 send 被 streamingRef 卡住、按钮被灰掉
// 输入区锁定:仅上传中 / Agent 规划中;出片生成中允许继续发消息
const composerBlocked =
uploading
|| conversation?.agent_status === "planning"
|| (
Boolean(streaming)
&& conversation?.agent_status !== "awaiting_user"
&& !hasGenerating
);
// 若状态已是等待用户,或后台出片中,streaming 残留立刻收起,解开输入
useEffect(() => {
if (conversation?.agent_status !== "awaiting_user") return;
const status = conversation?.agent_status;
const shouldClear =
status === "awaiting_user"
|| (hasGenerating && status !== "planning");
if (!shouldClear) return;
if (!streaming && !streamingRef.current) return;
holdPlanningUntilRef.current = 0;
streamingRef.current = false;
@@ -2891,7 +3021,7 @@ export function OmniSessionPage({
setLiveText("");
setLiveReasoning("");
setActiveTool("");
}, [conversation?.agent_status, streaming]);
}, [conversation?.agent_status, streaming, hasGenerating]);
// 整理方案在 Celery:靠 poll 续进度。刷新/重进只要 agent_status=planning 就会接着转。
useEffect(() => {
@@ -2918,8 +3048,13 @@ export function OmniSessionPage({
const send = useCallback(
async (payload: Parameters<typeof api.creationSend>[1]) => {
// awaiting_user 下允许回答闸门;其它状态仍禁止并发发送
if (streamingRef.current && conversation?.agent_status !== "awaiting_user") return false;
// awaiting_user 下允许回答闸门;后台出片中也允许继续发消息;仅 Agent 规划中禁止并发
if (conversation?.agent_status === "planning") return false;
if (
streamingRef.current
&& conversation?.agent_status !== "awaiting_user"
&& !messagesRef.current.some((message) => message.kind === "generating")
) return false;
// 先占位用户气泡,再亮「正在整理方案」—— 顺序不能反
const isTextTurn = payload.kind !== "elicit_answer";
let localId: string | null = null;
@@ -3148,29 +3283,16 @@ export function OmniSessionPage({
}
};
const handleMergeSegments = async (message: CreationMessage) => {
if (mergingMessageIds.includes(message.id)) return;
setMergingMessageIds((prev) => [...prev, message.id]);
try {
const result = await api.mergeCreationVideoSegments(conversationId, message.id);
setMessages((prev) => [
...prev.map((item) => item.id === message.id
? { ...item, payload: { ...item.payload, merge_state: "queued" } }
: item),
result.message,
]);
notify("info", "正在合并成片");
} catch (error) {
notify("error", (error as Error).message || "合并失败,请重试");
setMergingMessageIds((prev) => prev.filter((id) => id !== message.id));
}
};
const handleSend = () => {
// 顺序要紧:先判 streaming 再清输入框。反过来的话,流式期间敲一次回车
// 顺序要紧:先判锁定再清输入框。反过来的话,锁定期间敲一次回车
// 会把已经打好的内容清空,消息却没发出去。
if (uploading) return;
if (streaming && conversation?.agent_status !== "awaiting_user") return;
if (conversation?.agent_status === "planning") return;
if (
streaming
&& conversation?.agent_status !== "awaiting_user"
&& !hasGenerating
) return;
const text = prompt.trim();
if (!getMentionFreeText(text, pendingRefs)) {
notify("info", "请先输入具体的创作描述,@引用不能单独发送");
@@ -3344,6 +3466,17 @@ export function OmniSessionPage({
!item.payload?.submitted
)
: false;
const confirmIndex = messages.findIndex((item) => item.id === message.id);
const later = confirmIndex >= 0 ? messages.slice(confirmIndex + 1) : [];
const generationPhase = !message.payload?.submitted
? "idle"
: later.some((item) => item.kind === "generating")
? "running"
: later.some((item) => item.kind === "result")
? "done"
: later.some((item) => item.kind === "error")
? "failed"
: "submitted";
return (
<ConfirmCard
key={message.clientKey || message.id}
@@ -3351,6 +3484,7 @@ export function OmniSessionPage({
sessionParams={params}
isVideo={isVideo}
catalogModels={modelConfigs}
generationPhase={generationPhase}
disabled={
confirming
|| pendingStep
@@ -3365,7 +3499,6 @@ export function OmniSessionPage({
<ProcessCard
key={message.clientKey || message.id}
payload={message.payload}
onPreview={(src, kind, name) => setAssetPreview({ src, kind, name })}
/>
);
case "result":
@@ -3373,8 +3506,6 @@ export function OmniSessionPage({
<ResultCard
key={message.clientKey || message.id}
payload={message.payload}
merging={mergingMessageIds.includes(message.id)}
onMerge={message.payload?.needs_merge ? () => void handleMergeSegments(message) : undefined}
/>
);
default: {
@@ -3512,7 +3643,11 @@ export function OmniSessionPage({
{/* is-live
,;,
*/}
{(conversation?.agent_status !== "awaiting_user") && (streaming || conversation?.agent_status === "planning") && !hasGenerating ? (
{(conversation?.agent_status !== "awaiting_user")
&& (streaming || conversation?.agent_status === "planning")
&& !hasGenerating
&& Boolean(conversation?.agent_progress)
&& !currentPlanningHasError ? (
/生成图片|生成视频/.test(activeTool) && !liveText ? (
<ProcessCard payload={{ kind: /生成视频/.test(activeTool) ? "video" : "image" }} />
) : liveText ? (
@@ -3532,9 +3667,26 @@ export function OmniSessionPage({
<div className={`omni-chat-bubble${liveReasoning ? " is-reasoning" : " is-thinking"}`}>
<div className="omni-think-head">
<span className="omni-typing" aria-hidden="true"><i /><i /><i /></span>
<span>{conversation?.agent_status === "planning" || streaming ? (isVideo ? "正在整理方案" : "正在整理画面") : activeTool ? `${activeTool}` : liveReasoning ? "思考" : isVideo ? "正在整理方案" : "正在整理画面"}</span>
<span>{activeTool || (liveReasoning ? "正在思考" : isVideo ? "正在整理方案" : "正在整理画面")}</span>
</div>
{liveReasoning ? <p className="omni-think-text">{liveReasoning}</p> : null}
{liveReasoning ? (
<div className="omni-think-log" ref={thinkingLogRef} aria-label="创作过程">
{(visibleProgressHistory.length
? visibleProgressHistory
: [{
label: activeTool || "正在思考",
detail: liveReasoning,
}]
).map((progress, index, history) => (
<div
className={`omni-think-log-row${index === history.length - 1 ? " is-current" : ""}`}
key={`${progress.label}-${progress.detail}-${index}`}
>
<p>{progress.detail}</p>
</div>
))}
</div>
) : null}
</div>
</div>
)
@@ -3542,13 +3694,18 @@ export function OmniSessionPage({
</main>
<footer className={`omni-session-composer${composerHint ? " is-revise-hint" : ""}`}>
<MentionChips
refs={looseComposerRefs}
tone="composer"
onRemove={(id) => setPendingRefs((prev) => prev.filter((ref) => ref.id !== id))}
/>
<RichMentionEditor
id="omniSessionPrompt"
ref={composerRef}
placeholder={composerHint || "继续补充图片或创作要求……"}
value={prompt}
refs={pendingRefs}
disabled={uploading || (streaming && conversation?.agent_status !== "awaiting_user")}
disabled={composerBlocked}
submitOnEnter
onChange={setPrompt}
onAtTrigger={() => void openMentions()}
@@ -3562,9 +3719,9 @@ export function OmniSessionPage({
className="omni-icon-tool"
aria-label={uploading ? "上传审核中" : "添加素材"}
title={uploading ? "上传并审核中…" : "添加素材"}
disabled={uploading || streaming}
disabled={composerBlocked}
onClick={() => {
if (uploading || streaming) return;
if (composerBlocked) return;
personSourceRequestRef.current = null;
quickUploadReplyRef.current = null;
setUploadMenuOpen((open) => !open);
@@ -3793,11 +3950,7 @@ export function OmniSessionPage({
disabled={
isPlanning
? stopping
: (
uploading
|| (streaming && conversation?.agent_status !== "awaiting_user")
|| !hasComposerDescription
)
: (composerBlocked || !hasComposerDescription)
}
onClick={() => {
if (isPlanning) void handleStop();
-1
View File
@@ -4783,7 +4783,6 @@ export function PipelinePage(props: {
<ConfirmModal
open={chargeConfirm !== null}
title="确认生成视频"
subtitle="// 失败不扣 · 成功后结算"
icon={<Sparkles size={16} />}
detail={(
<>
+9
View File
@@ -79,6 +79,8 @@ 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;
@@ -944,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;